A CLI for publishing standard.site documents to ATProto
at main 491 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(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); 124 document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; 125 } catch { 126 // Cookie write may fail in some embedded contexts 127 } 128 try { 129 localStorage.setItem("sequoia_did", did); 130 } catch { 131 // localStorage may be unavailable 132 } 133} 134 135/** 136 * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. 137 * @returns {string | null} 138 */ 139function getStoredSubscriberDid() { 140 try { 141 const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); 142 if (match) { 143 const did = decodeURIComponent(match[1]); 144 if (did.startsWith("did:")) return did; 145 } 146 } catch { 147 // ignore 148 } 149 try { 150 const did = localStorage.getItem("sequoia_did"); 151 if (did?.startsWith("did:")) return did; 152 } catch { 153 // ignore 154 } 155 return null; 156} 157 158/** 159 * Remove the stored subscriber DID from both cookie and localStorage. 160 */ 161function clearSubscriberDid() { 162 try { 163 document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; 164 } catch { 165 // ignore 166 } 167 try { 168 localStorage.removeItem("sequoia_did"); 169 } catch { 170 // ignore 171 } 172} 173 174/** 175 * Check the current page URL for sequoia_did / sequoia_unsubscribed params 176 * set by the subscribe redirect flow. Consumes them by removing from the URL. 177 */ 178function consumeReturnParams() { 179 const url = new URL(window.location.href); 180 const did = url.searchParams.get("sequoia_did"); 181 const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); 182 183 let changed = false; 184 185 if (unsubscribed === "1") { 186 clearSubscriberDid(); 187 url.searchParams.delete("sequoia_unsubscribed"); 188 changed = true; 189 } 190 191 if (did && did.startsWith("did:")) { 192 storeSubscriberDid(did); 193 url.searchParams.delete("sequoia_did"); 194 changed = true; 195 } 196 197 if (changed) { 198 const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); 199 try { 200 window.history.replaceState(null, "", cleanUrl); 201 } catch { 202 // ignore 203 } 204 } 205} 206 207// ============================================================================ 208// AT Protocol Functions 209// ============================================================================ 210 211/** 212 * Fetch the publication AT URI from the host site's well-known endpoint. 213 * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 214 * @returns {Promise<string>} Publication AT URI 215 */ 216async function fetchPublicationUri(origin) { 217 const base = origin ?? window.location.origin; 218 const url = `${base}/.well-known/site.standard.publication`; 219 const response = await fetch(url); 220 if (!response.ok) { 221 throw new Error(`Could not fetch publication URI: ${response.status}`); 222 } 223 224 // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 225 const contentType = response.headers.get("content-type") ?? ""; 226 if (contentType.includes("application/json")) { 227 const data = await response.json(); 228 const uri = data?.uri ?? data?.atUri ?? data?.publication; 229 if (!uri) { 230 throw new Error("Publication response did not contain a URI"); 231 } 232 return uri; 233 } 234 235 const text = (await response.text()).trim(); 236 if (!text.startsWith("at://")) { 237 throw new Error(`Unexpected publication URI format: ${text}`); 238 } 239 return text; 240} 241 242// ============================================================================ 243// Web Component 244// ============================================================================ 245 246// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 247const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 248 249class SequoiaSubscribe extends BaseElement { 250 constructor() { 251 super(); 252 const shadow = this.attachShadow({ mode: "open" }); 253 254 const styleTag = document.createElement("style"); 255 styleTag.innerText = styles; 256 shadow.appendChild(styleTag); 257 258 const wrapper = document.createElement("div"); 259 shadow.appendChild(wrapper); 260 wrapper.part = "container"; 261 262 this.wrapper = wrapper; 263 this.subscribed = false; 264 this.state = { type: "idle" }; 265 this.abortController = null; 266 this.render(); 267 } 268 269 static get observedAttributes() { 270 return ["publication-uri", "callback-uri", "label", "hide"]; 271 } 272 273 connectedCallback() { 274 consumeReturnParams(); 275 this.checkPublication(); 276 } 277 278 disconnectedCallback() { 279 this.abortController?.abort(); 280 } 281 282 attributeChangedCallback() { 283 if (this.state.type === "error" || this.state.type === "no-publication") { 284 this.state = { type: "idle" }; 285 } 286 this.render(); 287 } 288 289 get publicationUri() { 290 return this.getAttribute("publication-uri") ?? null; 291 } 292 293 get callbackUri() { 294 return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; 295 } 296 297 get label() { 298 return this.getAttribute("label") ?? "Subscribe on Bluesky"; 299 } 300 301 get hide() { 302 const hideAttr = this.getAttribute("hide"); 303 return hideAttr === "auto"; 304 } 305 306 async checkPublication() { 307 this.abortController?.abort(); 308 this.abortController = new AbortController(); 309 310 try { 311 const uri = this.publicationUri ?? (await fetchPublicationUri()); 312 this.checkSubscription(uri); 313 } catch { 314 this.state = { type: "no-publication" }; 315 this.render(); 316 } 317 } 318 319 async checkSubscription(publicationUri) { 320 try { 321 const checkUrl = new URL(`${this.callbackUri}/check`); 322 checkUrl.searchParams.set("publicationUri", publicationUri); 323 324 // Pass the stored DID so the server can check without a session cookie 325 const storedDid = getStoredSubscriberDid(); 326 if (storedDid) { 327 checkUrl.searchParams.set("did", storedDid); 328 } 329 330 const res = await fetch(checkUrl.toString(), { 331 credentials: "include", 332 }); 333 if (!res.ok) return; 334 const data = await res.json(); 335 if (data.subscribed) { 336 this.subscribed = true; 337 this.render(); 338 } 339 } catch { 340 // Ignore errors — show default subscribe button 341 } 342 } 343 344 async handleClick() { 345 if (this.state.type === "loading") { 346 return; 347 } 348 349 // Unsubscribe: redirect to full-page unsubscribe flow 350 if (this.subscribed) { 351 const publicationUri = 352 this.publicationUri ?? (await fetchPublicationUri()); 353 window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`; 354 return; 355 } 356 357 this.state = { type: "loading" }; 358 this.render(); 359 360 try { 361 const publicationUri = 362 this.publicationUri ?? (await fetchPublicationUri()); 363 364 const response = await fetch(this.callbackUri, { 365 method: "POST", 366 headers: { "Content-Type": "application/json" }, 367 credentials: "include", 368 referrerPolicy: "no-referrer-when-downgrade", 369 body: JSON.stringify({ publicationUri }), 370 }); 371 372 const data = await response.json(); 373 374 if (response.status === 401 && data.authenticated === false) { 375 // Redirect to the hosted subscribe page to complete OAuth, 376 // passing the current page URL (without credentials) as returnTo. 377 const subscribeUrl = new URL(data.subscribeUrl); 378 const pageUrl = new URL(window.location.href); 379 pageUrl.username = ""; 380 pageUrl.password = ""; 381 subscribeUrl.searchParams.set("returnTo", pageUrl.toString()); 382 window.location.href = subscribeUrl.toString(); 383 return; 384 } 385 386 if (!response.ok) { 387 throw new Error(data.error ?? `HTTP ${response.status}`); 388 } 389 390 const { recordUri } = data; 391 392 // Store the DID from the record URI (at://did:aaa:bbb/...) 393 if (recordUri) { 394 const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); 395 if (didMatch) { 396 storeSubscriberDid(didMatch[1]); 397 } 398 } 399 400 this.subscribed = true; 401 this.state = { type: "idle" }; 402 this.render(); 403 404 this.dispatchEvent( 405 new CustomEvent("sequoia-subscribed", { 406 bubbles: true, 407 composed: true, 408 detail: { publicationUri, recordUri }, 409 }), 410 ); 411 } catch (error) { 412 if (this.state.type !== "loading") return; 413 414 const message = 415 error instanceof Error ? error.message : "Failed to subscribe"; 416 this.state = { type: "error", message }; 417 this.render(); 418 419 this.dispatchEvent( 420 new CustomEvent("sequoia-subscribe-error", { 421 bubbles: true, 422 composed: true, 423 detail: { message }, 424 }), 425 ); 426 } 427 } 428 429 render() { 430 const { type } = this.state; 431 432 if (type === "no-publication") { 433 if (this.hide) { 434 this.wrapper.innerHTML = ""; 435 this.wrapper.style.display = "none"; 436 } 437 return; 438 } 439 440 const isLoading = type === "loading"; 441 442 const icon = isLoading 443 ? `<span class="sequoia-loading-spinner"></span>` 444 : BLUESKY_ICON; 445 446 const label = this.subscribed ? "Unsubscribe on Bluesky" : this.label; 447 448 const errorHtml = 449 type === "error" 450 ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 451 : ""; 452 453 this.wrapper.innerHTML = ` 454 <button 455 class="sequoia-subscribe-button" 456 type="button" 457 part="button" 458 ${isLoading ? "disabled" : ""} 459 aria-label="${label}" 460 > 461 ${icon} 462 ${label} 463 </button> 464 ${errorHtml} 465 `; 466 467 const btn = this.wrapper.querySelector("button"); 468 btn?.addEventListener("click", () => this.handleClick()); 469 } 470} 471 472/** 473 * Escape HTML special characters (no DOM dependency for SSR). 474 * @param {string} text 475 * @returns {string} 476 */ 477function escapeHtml(text) { 478 return text 479 .replace(/&/g, "&amp;") 480 .replace(/</g, "&lt;") 481 .replace(/>/g, "&gt;") 482 .replace(/"/g, "&quot;"); 483} 484 485// Register the custom element 486if (typeof customElements !== "undefined") { 487 customElements.define("sequoia-subscribe", SequoiaSubscribe); 488} 489 490// Export for module usage 491export { SequoiaSubscribe };