From 597d4cb38882c4e1fe8116edb009aac17633b4a2 Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Fri, 20 Feb 2026 23:58:12 -0800 Subject: [PATCH] feat: Add subscription component Resolves #16 --- packages/cli/src/commands/add.ts | 31 +- .../cli/src/components/sequoia-subscribe.js | 401 ++++++++++++++++++ 2 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/components/sequoia-subscribe.js diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 59c3c25..4bd2572 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -14,7 +14,17 @@ const COMPONENTS_DIR = path.join(__dirname, "components"); const DEFAULT_COMPONENTS_PATH = "src/components"; -const AVAILABLE_COMPONENTS = ["sequoia-comments"]; +const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [ + { + name: "sequoia-comments", + notes: + `The component will automatically read the document URI from:\n` + + ``, + }, + { + name: "sequoia-subscribe", + }, +]; export const addCommand = command({ name: "add", @@ -30,11 +40,12 @@ export const addCommand = command({ intro("Add Sequoia Component"); // Validate component name - if (!AVAILABLE_COMPONENTS.includes(componentName)) { + const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName); + if (!component) { log.error(`Component '${componentName}' not found`); log.info("Available components:"); for (const comp of AVAILABLE_COMPONENTS) { - log.info(` - ${comp}`); + log.info(` - ${comp.name}`); } process.exit(1); } @@ -143,14 +154,14 @@ export const addCommand = command({ } // Show usage instructions - note( + let notes = `Add to your HTML:\n\n` + - `\n` + - `<${componentName}>\n\n` + - `The component will automatically read the document URI from:\n` + - ``, - "Usage", - ); + `\n` + + `<${componentName}>\n`; + if (component.notes) { + notes += `\n${component.notes}`; + } + note(notes, "Usage"); outro(`${componentName} added successfully!`); }, diff --git a/packages/cli/src/components/sequoia-subscribe.js b/packages/cli/src/components/sequoia-subscribe.js new file mode 100644 index 0000000..2c61c29 --- /dev/null +++ b/packages/cli/src/components/sequoia-subscribe.js @@ -0,0 +1,401 @@ +/** + * Sequoia Subscribe - A Bluesky-powered subscribe component + * + * A self-contained Web Component that lets users subscribe to a publication + * via the AT Protocol by creating a site.standard.graph.subscription record. + * + * Usage: + * + * + * The component resolves the publication AT URI from the host site's + * /.well-known/site.standard.publication endpoint. + * + * Attributes: + * - publication-uri: Override the publication AT URI (optional) + * - label: Button label text (default: "Subscribe on Bluesky") + * + * CSS Custom Properties: + * - --sequoia-fg-color: Text color (default: #1f2937) + * - --sequoia-bg-color: Background color (default: #ffffff) + * - --sequoia-border-color: Border color (default: #e5e7eb) + * - --sequoia-accent-color: Accent/button color (default: #2563eb) + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) + * - --sequoia-border-radius: Border radius (default: 8px) + * + * Events: + * - sequoia-subscribed: Fired when the subscription is created successfully. + * detail: { publicationUri: string, recordUri: string } + * - sequoia-subscribe-error: Fired when the subscription fails. + * detail: { message: string } + */ + +// ============================================================================ +// Styles +// ============================================================================ + +const styles = ` +:host { + display: inline-block; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--sequoia-fg-color, #1f2937); + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +.sequoia-subscribe-button { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: var(--sequoia-accent-color, #2563eb); + color: #ffffff; + border: none; + border-radius: var(--sequoia-border-radius, 8px); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; + font-family: inherit; +} + +.sequoia-subscribe-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); +} + +.sequoia-subscribe-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.sequoia-subscribe-button svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.sequoia-subscribe-button--success { + background: #16a34a; +} + +.sequoia-subscribe-button--success:hover:not(:disabled) { + background: color-mix(in srgb, #16a34a 85%, black); +} + +.sequoia-loading-spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid rgba(255, 255, 255, 0.4); + border-top-color: #ffffff; + border-radius: 50%; + animation: sequoia-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes sequoia-spin { + to { transform: rotate(360deg); } +} + +.sequoia-error-message { + display: inline-block; + font-size: 0.8125rem; + color: #dc2626; + margin-top: 0.375rem; +} +`; + +// ============================================================================ +// Icons +// ============================================================================ + +const BLUESKY_ICON = ``; + +const CHECK_ICON = ` + +`; + +// ============================================================================ +// AT Protocol Functions +// ============================================================================ + +/** + * Resolve a DID to its PDS URL. + * Supports did:plc and did:web methods. + * @param {string} did - Decentralized Identifier + * @returns {Promise} PDS URL + */ +async function resolvePDS(did) { + let pdsUrl; + + if (did.startsWith("did:plc:")) { + const didDocUrl = `https://plc.directory/${did}`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } else if (did.startsWith("did:web:")) { + const domain = did.replace("did:web:", ""); + const didDocUrl = `https://${domain}/.well-known/did.json`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + + if (!pdsUrl) { + throw new Error("Could not find PDS URL for user"); + } + + return pdsUrl; +} + +/** + * Create a site.standard.graph.subscription record in the subscriber's PDS. + * @param {string} did - DID of the subscriber + * @param {string} accessToken - AT Protocol access token + * @param {string} publicationUri - AT URI of the publication to subscribe to + * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID + */ +async function createRecord(did, accessToken, publicationUri) { + const pdsUrl = await resolvePDS(did); + + const collection = "site.standard.graph.subscription"; + const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + repo: did, + collection, + record: { + $type: "site.standard.graph.subscription", + publication: publicationUri, + }, + }), + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + const message = body?.message ?? body?.error ?? `HTTP ${response.status}`; + throw new Error(`Failed to create record: ${message}`); + } + + const data = await response.json(); + return { uri: data.uri, cid: data.cid }; +} + +/** + * Fetch the publication AT URI from the host site's well-known endpoint. + * @param {string} [origin] - Origin to fetch from (defaults to current page origin) + * @returns {Promise} Publication AT URI + */ +async function fetchPublicationUri(origin) { + const base = origin ?? window.location.origin; + const url = `${base}/.well-known/site.standard.publication`; + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Could not fetch publication URI: ${response.status}`, + ); + } + + // Accept either plain text (the AT URI itself) or JSON with a `uri` field. + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = await response.json(); + const uri = data?.uri ?? data?.atUri ?? data?.publication; + if (!uri) { + throw new Error("Publication response did not contain a URI"); + } + return uri; + } + + const text = (await response.text()).trim(); + if (!text.startsWith("at://")) { + throw new Error(`Unexpected publication URI format: ${text}`); + } + return text; +} + +// ============================================================================ +// Web Component +// ============================================================================ + +// SSR-safe base class - use HTMLElement in browser, empty class in Node.js +const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; + +class SequoiaSubscribe extends BaseElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + + const styleTag = document.createElement("style"); + styleTag.innerText = styles; + shadow.appendChild(styleTag); + + const wrapper = document.createElement("div"); + shadow.appendChild(wrapper); + wrapper.part = "container"; + + this.wrapper = wrapper; + this.state = { type: "idle" }; + this.render(); + } + + static get observedAttributes() { + return ["publication-uri", "label"]; + } + + attributeChangedCallback() { + // Reset to idle if attributes change after an error or success + if ( + this.state.type === "error" || + this.state.type === "subscribed" + ) { + this.state = { type: "idle" }; + } + this.render(); + } + + get publicationUri() { + return this.getAttribute("publication-uri") ?? null; + } + + get label() { + return this.getAttribute("label") ?? "Subscribe on Bluesky"; + } + + async handleClick() { + if (this.state.type === "loading" || this.state.type === "subscribed") { + return; + } + + this.state = { type: "loading" }; + this.render(); + + try { + // Resolve the publication AT URI + const publicationUri = + this.publicationUri ?? (await fetchPublicationUri()); + + // TODO: resolve authenticated DID and access token before calling createRecord + const { uri: recordUri } = await createRecord( + /* did */ undefined, + /* accessToken */ undefined, + publicationUri, + ); + + this.state = { type: "subscribed", recordUri, publicationUri }; + this.render(); + + this.dispatchEvent( + new CustomEvent("sequoia-subscribed", { + bubbles: true, + composed: true, + detail: { publicationUri, recordUri }, + }), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to subscribe"; + this.state = { type: "error", message }; + this.render(); + + this.dispatchEvent( + new CustomEvent("sequoia-subscribe-error", { + bubbles: true, + composed: true, + detail: { message }, + }), + ); + } + } + + render() { + const { type } = this.state; + const isLoading = type === "loading"; + const isSubscribed = type === "subscribed"; + + const icon = isLoading + ? `` + : isSubscribed + ? CHECK_ICON + : BLUESKY_ICON; + + const label = isSubscribed ? "Subscribed" : this.label; + const buttonClass = [ + "sequoia-subscribe-button", + isSubscribed ? "sequoia-subscribe-button--success" : "", + ] + .filter(Boolean) + .join(" "); + + const errorHtml = + type === "error" + ? `${escapeHtml(this.state.message)}` + : ""; + + this.wrapper.innerHTML = ` + + ${errorHtml} + `; + + if (type !== "subscribed") { + const btn = this.wrapper.querySelector("button"); + btn?.addEventListener("click", () => this.handleClick()); + } + } +} + +/** + * Escape HTML special characters (no DOM dependency for SSR). + * @param {string} text + * @returns {string} + */ +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// Register the custom element +if (typeof customElements !== "undefined") { + customElements.define("sequoia-subscribe", SequoiaSubscribe); +} + +// Export for module usage +export { SequoiaSubscribe }; -- 2.51.2