forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
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, "&")
483 .replace(/</g, "<")
484 .replace(/>/g, ">")
485 .replace(/"/g, """);
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 };