A CLI for publishing standard.site documents to ATProto
at test 494 lines 14 kB view raw
1/** 2 * Sequoia Subscribe - A Bluesky-powered subscribe component 3 * 4 * A self-contained Web Component that lets users subscribe to a publication 5 * via the AT Protocol by creating a site.standard.graph.subscription record. 6 * 7 * Usage: 8 * <sequoia-subscribe></sequoia-subscribe> 9 * 10 * The component resolves the publication AT URI from the host site's 11 * /.well-known/site.standard.publication endpoint. 12 * 13 * Attributes: 14 * - publication-uri: Override the publication AT URI (optional) 15 * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") 16 * - label: Button label text (default: "Subscribe on Bluesky") 17 * - hide: Set to "auto" to hide if no publication URI is detected 18 * 19 * CSS Custom Properties: 20 * - --sequoia-fg-color: Text color (default: #1f2937) 21 * - --sequoia-bg-color: Background color (default: #ffffff) 22 * - --sequoia-border-color: Border color (default: #e5e7eb) 23 * - --sequoia-accent-color: Accent/button color (default: #2563eb) 24 * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 25 * - --sequoia-border-radius: Border radius (default: 8px) 26 * 27 * Events: 28 * - sequoia-subscribed: Fired when the subscription is created successfully. 29 * detail: { publicationUri: string, recordUri: string } 30 * - sequoia-subscribe-error: Fired when the subscription fails. 31 * detail: { message: string } 32 */ 33 34// ============================================================================ 35// Styles 36// ============================================================================ 37 38const styles = ` 39:host { 40 display: inline-block; 41 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 42 color: var(--sequoia-fg-color, #1f2937); 43 line-height: 1.5; 44} 45 46* { 47 box-sizing: border-box; 48} 49 50.sequoia-subscribe-button { 51 display: inline-flex; 52 align-items: center; 53 gap: 0.375rem; 54 padding: 0.5rem 1rem; 55 background: var(--sequoia-accent-color, #2563eb); 56 color: #ffffff; 57 border: none; 58 border-radius: var(--sequoia-border-radius, 8px); 59 font-size: 0.875rem; 60 font-weight: 500; 61 cursor: pointer; 62 text-decoration: none; 63 transition: background-color 0.15s ease; 64 font-family: inherit; 65} 66 67.sequoia-subscribe-button:hover:not(:disabled) { 68 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 69} 70 71.sequoia-subscribe-button:disabled { 72 opacity: 0.6; 73 cursor: not-allowed; 74} 75 76.sequoia-subscribe-button svg { 77 width: 1rem; 78 height: 1rem; 79 flex-shrink: 0; 80} 81 82.sequoia-loading-spinner { 83 display: inline-block; 84 width: 1rem; 85 height: 1rem; 86 border: 2px solid rgba(255, 255, 255, 0.4); 87 border-top-color: #ffffff; 88 border-radius: 50%; 89 animation: sequoia-spin 0.8s linear infinite; 90 flex-shrink: 0; 91} 92 93@keyframes sequoia-spin { 94 to { transform: rotate(360deg); } 95} 96 97.sequoia-error-message { 98 display: inline-block; 99 font-size: 0.8125rem; 100 color: #dc2626; 101 margin-top: 0.375rem; 102} 103`; 104 105// ============================================================================ 106// Icons 107// ============================================================================ 108 109const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 110 <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 111</svg>`; 112 113// ============================================================================ 114// DID Storage 115// ============================================================================ 116 117/** 118 * Store the subscriber DID. Tries a cookie first; falls back to localStorage. 119 * @param {string} did 120 */ 121function storeSubscriberDid(did) { 122 try { 123 const expires = new Date( 124 Date.now() + 365 * 24 * 60 * 60 * 1000, 125 ).toUTCString(); 126 document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; 127 } catch { 128 // Cookie write may fail in some embedded contexts 129 } 130 try { 131 localStorage.setItem("sequoia_did", did); 132 } catch { 133 // localStorage may be unavailable 134 } 135} 136 137/** 138 * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. 139 * @returns {string | null} 140 */ 141function getStoredSubscriberDid() { 142 try { 143 const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); 144 if (match) { 145 const did = decodeURIComponent(match[1]); 146 if (did.startsWith("did:")) return did; 147 } 148 } catch { 149 // ignore 150 } 151 try { 152 const did = localStorage.getItem("sequoia_did"); 153 if (did?.startsWith("did:")) return did; 154 } catch { 155 // ignore 156 } 157 return null; 158} 159 160/** 161 * Remove the stored subscriber DID from both cookie and localStorage. 162 */ 163function clearSubscriberDid() { 164 try { 165 document.cookie = 166 "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; 167 } catch { 168 // ignore 169 } 170 try { 171 localStorage.removeItem("sequoia_did"); 172 } catch { 173 // ignore 174 } 175} 176 177/** 178 * Check the current page URL for sequoia_did / sequoia_unsubscribed params 179 * set by the subscribe redirect flow. Consumes them by removing from the URL. 180 */ 181function consumeReturnParams() { 182 const url = new URL(window.location.href); 183 const did = url.searchParams.get("sequoia_did"); 184 const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); 185 186 let changed = false; 187 188 if (unsubscribed === "1") { 189 clearSubscriberDid(); 190 url.searchParams.delete("sequoia_unsubscribed"); 191 changed = true; 192 } 193 194 if (did && did.startsWith("did:")) { 195 storeSubscriberDid(did); 196 url.searchParams.delete("sequoia_did"); 197 changed = true; 198 } 199 200 if (changed) { 201 const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); 202 try { 203 window.history.replaceState(null, "", cleanUrl); 204 } catch { 205 // ignore 206 } 207 } 208} 209 210// ============================================================================ 211// AT Protocol Functions 212// ============================================================================ 213 214/** 215 * Fetch the publication AT URI from the host site's well-known endpoint. 216 * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 217 * @returns {Promise<string>} Publication AT URI 218 */ 219async function fetchPublicationUri(origin) { 220 const base = origin ?? window.location.origin; 221 const url = `${base}/.well-known/site.standard.publication`; 222 const response = await fetch(url); 223 if (!response.ok) { 224 throw new Error(`Could not fetch publication URI: ${response.status}`); 225 } 226 227 // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 228 const contentType = response.headers.get("content-type") ?? ""; 229 if (contentType.includes("application/json")) { 230 const data = await response.json(); 231 const uri = data?.uri ?? data?.atUri ?? data?.publication; 232 if (!uri) { 233 throw new Error("Publication response did not contain a URI"); 234 } 235 return uri; 236 } 237 238 const text = (await response.text()).trim(); 239 if (!text.startsWith("at://")) { 240 throw new Error(`Unexpected publication URI format: ${text}`); 241 } 242 return text; 243} 244 245// ============================================================================ 246// Web Component 247// ============================================================================ 248 249// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 250const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 251 252class SequoiaSubscribe extends BaseElement { 253 constructor() { 254 super(); 255 const shadow = this.attachShadow({ mode: "open" }); 256 257 const styleTag = document.createElement("style"); 258 styleTag.innerText = styles; 259 shadow.appendChild(styleTag); 260 261 const wrapper = document.createElement("div"); 262 shadow.appendChild(wrapper); 263 wrapper.part = "container"; 264 265 this.wrapper = wrapper; 266 this.subscribed = false; 267 this.state = { type: "idle" }; 268 this.abortController = null; 269 this.render(); 270 } 271 272 static get observedAttributes() { 273 return ["publication-uri", "callback-uri", "label", "hide"]; 274 } 275 276 connectedCallback() { 277 consumeReturnParams(); 278 this.checkPublication(); 279 } 280 281 disconnectedCallback() { 282 this.abortController?.abort(); 283 } 284 285 attributeChangedCallback() { 286 if (this.state.type === "error" || this.state.type === "no-publication") { 287 this.state = { type: "idle" }; 288 } 289 this.render(); 290 } 291 292 get publicationUri() { 293 return this.getAttribute("publication-uri") ?? null; 294 } 295 296 get callbackUri() { 297 return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; 298 } 299 300 get label() { 301 return this.getAttribute("label") ?? "Subscribe on Bluesky"; 302 } 303 304 get hide() { 305 const hideAttr = this.getAttribute("hide"); 306 return hideAttr === "auto"; 307 } 308 309 async checkPublication() { 310 this.abortController?.abort(); 311 this.abortController = new AbortController(); 312 313 try { 314 const uri = this.publicationUri ?? (await fetchPublicationUri()); 315 this.checkSubscription(uri); 316 } catch { 317 this.state = { type: "no-publication" }; 318 this.render(); 319 } 320 } 321 322 async checkSubscription(publicationUri) { 323 try { 324 const checkUrl = new URL(`${this.callbackUri}/check`); 325 checkUrl.searchParams.set("publicationUri", publicationUri); 326 327 // Pass the stored DID so the server can check without a session cookie 328 const storedDid = getStoredSubscriberDid(); 329 if (storedDid) { 330 checkUrl.searchParams.set("did", storedDid); 331 } 332 333 const res = await fetch(checkUrl.toString(), { 334 credentials: "include", 335 }); 336 if (!res.ok) return; 337 const data = await res.json(); 338 if (data.subscribed) { 339 this.subscribed = true; 340 this.render(); 341 } 342 } catch { 343 // Ignore errors — show default subscribe button 344 } 345 } 346 347 async handleClick() { 348 if (this.state.type === "loading") { 349 return; 350 } 351 352 // Unsubscribe: redirect to full-page unsubscribe flow 353 if (this.subscribed) { 354 const publicationUri = 355 this.publicationUri ?? (await fetchPublicationUri()); 356 window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; 357 return; 358 } 359 360 this.state = { type: "loading" }; 361 this.render(); 362 363 try { 364 const publicationUri = 365 this.publicationUri ?? (await fetchPublicationUri()); 366 367 const response = await fetch(this.callbackUri, { 368 method: "POST", 369 headers: { "Content-Type": "application/json" }, 370 credentials: "include", 371 referrerPolicy: "no-referrer-when-downgrade", 372 body: JSON.stringify({ publicationUri }), 373 }); 374 375 const data = await response.json(); 376 377 if (response.status === 401 && data.authenticated === false) { 378 // Redirect to the hosted subscribe page to complete OAuth, 379 // passing the current page URL (without credentials) as returnTo. 380 const subscribeUrl = new URL(data.subscribeUrl); 381 const pageUrl = new URL(window.location.href); 382 pageUrl.username = ""; 383 pageUrl.password = ""; 384 subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); 385 window.location.href = subscribeUrl.toString(); 386 return; 387 } 388 389 if (!response.ok) { 390 throw new Error(data.error ?? `HTTP ${response.status}`); 391 } 392 393 const { recordUri } = data; 394 395 // Store the DID from the record URI (at://did:aaa:bbb/...) 396 if (recordUri) { 397 const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); 398 if (didMatch) { 399 storeSubscriberDid(didMatch[1]); 400 } 401 } 402 403 this.subscribed = true; 404 this.state = { type: "idle" }; 405 this.render(); 406 407 this.dispatchEvent( 408 new CustomEvent("sequoia-subscribed", { 409 bubbles: true, 410 composed: true, 411 detail: { publicationUri, recordUri }, 412 }), 413 ); 414 } catch (error) { 415 if (this.state.type !== "loading") return; 416 417 const message = 418 error instanceof Error ? error.message : "Failed to subscribe"; 419 this.state = { type: "error", message }; 420 this.render(); 421 422 this.dispatchEvent( 423 new CustomEvent("sequoia-subscribe-error", { 424 bubbles: true, 425 composed: true, 426 detail: { message }, 427 }), 428 ); 429 } 430 } 431 432 render() { 433 const { type } = this.state; 434 435 if (type === "no-publication") { 436 if (this.hide) { 437 this.wrapper.innerHTML = ""; 438 this.wrapper.style.display = "none"; 439 } 440 return; 441 } 442 443 const isLoading = type === "loading"; 444 445 const icon = isLoading 446 ? `<span class="sequoia-loading-spinner"></span>` 447 : BLUESKY_ICON; 448 449 const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label; 450 451 const errorHtml = 452 type === "error" 453 ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 454 : ""; 455 456 this.wrapper.innerHTML = ` 457 <button 458 class="sequoia-subscribe-button" 459 type="button" 460 part="button" 461 ${isLoading ? "disabled" : ""} 462 aria-label="${label}" 463 > 464 ${icon} 465 ${label} 466 </button> 467 ${errorHtml} 468 `; 469 470 const btn = this.wrapper.querySelector("button"); 471 btn?.addEventListener("click", () => this.handleClick()); 472 } 473} 474 475/** 476 * Escape HTML special characters (no DOM dependency for SSR). 477 * @param {string} text 478 * @returns {string} 479 */ 480function escapeHtml(text) { 481 return text 482 .replace(/&/g, "&amp;") 483 .replace(/</g, "&lt;") 484 .replace(/>/g, "&gt;") 485 .replace(/"/g, "&quot;"); 486} 487 488// Register the custom element 489if (typeof customElements !== "undefined") { 490 customElements.define("sequoia-subscribe", SequoiaSubscribe); 491} 492 493// Export for module usage 494export { SequoiaSubscribe };