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(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, "&")
480 .replace(/</g, "<")
481 .replace(/>/g, ">")
482 .replace(/"/g, """);
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 };