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}>${componentName}>\n\n` +
- `The component will automatically read the document URI from:\n` +
- ``,
- "Usage",
- );
+ `\n` +
+ `<${componentName}>${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 };