A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

feat: Add subscription component #25

merged opened by heaths.dev targeting main from heaths.dev/sequoia: issue16

Resolves #16

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:tg3tb5wukiml4xmxml6qm637/sh.tangled.repo.pull/3mfe5ptlpci22
+422 -10
Diff #0
+21 -10
packages/cli/src/commands/add.ts
··· 14 14 15 15 const DEFAULT_COMPONENTS_PATH = "src/components"; 16 16 17 - const AVAILABLE_COMPONENTS = ["sequoia-comments"]; 17 + const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [ 18 + { 19 + name: "sequoia-comments", 20 + notes: 21 + `The component will automatically read the document URI from:\n` + 22 + `<link rel="site.standard.document" href="at://...">`, 23 + }, 24 + { 25 + name: "sequoia-subscribe", 26 + }, 27 + ]; 18 28 19 29 export const addCommand = command({ 20 30 name: "add", ··· 30 40 intro("Add Sequoia Component"); 31 41 32 42 // Validate component name 33 - if (!AVAILABLE_COMPONENTS.includes(componentName)) { 43 + const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName); 44 + if (!component) { 34 45 log.error(`Component '${componentName}' not found`); 35 46 log.info("Available components:"); 36 47 for (const comp of AVAILABLE_COMPONENTS) { 37 - log.info(` - ${comp}`); 48 + log.info(` - ${comp.name}`); 38 49 } 39 50 process.exit(1); 40 51 } ··· 143 154 } 144 155 145 156 // Show usage instructions 146 - note( 157 + let notes = 147 158 `Add to your HTML:\n\n` + 148 - `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 149 - `<${componentName}></${componentName}>\n\n` + 150 - `The component will automatically read the document URI from:\n` + 151 - `<link rel="site.standard.document" href="at://...">`, 152 - "Usage", 153 - ); 159 + `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 160 + `<${componentName}></${componentName}>\n`; 161 + if (component.notes) { 162 + notes += `\n${component.notes}`; 163 + } 164 + note(notes, "Usage"); 154 165 155 166 outro(`${componentName} added successfully!`); 156 167 },
+401
packages/cli/src/components/sequoia-subscribe.js
··· 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 + * - label: Button label text (default: "Subscribe on Bluesky") 16 + * 17 + * CSS Custom Properties: 18 + * - --sequoia-fg-color: Text color (default: #1f2937) 19 + * - --sequoia-bg-color: Background color (default: #ffffff) 20 + * - --sequoia-border-color: Border color (default: #e5e7eb) 21 + * - --sequoia-accent-color: Accent/button color (default: #2563eb) 22 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 23 + * - --sequoia-border-radius: Border radius (default: 8px) 24 + * 25 + * Events: 26 + * - sequoia-subscribed: Fired when the subscription is created successfully. 27 + * detail: { publicationUri: string, recordUri: string } 28 + * - sequoia-subscribe-error: Fired when the subscription fails. 29 + * detail: { message: string } 30 + */ 31 + 32 + // ============================================================================ 33 + // Styles 34 + // ============================================================================ 35 + 36 + const styles = ` 37 + :host { 38 + display: inline-block; 39 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 40 + color: var(--sequoia-fg-color, #1f2937); 41 + line-height: 1.5; 42 + } 43 + 44 + * { 45 + box-sizing: border-box; 46 + } 47 + 48 + .sequoia-subscribe-button { 49 + display: inline-flex; 50 + align-items: center; 51 + gap: 0.375rem; 52 + padding: 0.5rem 1rem; 53 + background: var(--sequoia-accent-color, #2563eb); 54 + color: #ffffff; 55 + border: none; 56 + border-radius: var(--sequoia-border-radius, 8px); 57 + font-size: 0.875rem; 58 + font-weight: 500; 59 + cursor: pointer; 60 + text-decoration: none; 61 + transition: background-color 0.15s ease; 62 + font-family: inherit; 63 + } 64 + 65 + .sequoia-subscribe-button:hover:not(:disabled) { 66 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 67 + } 68 + 69 + .sequoia-subscribe-button:disabled { 70 + opacity: 0.6; 71 + cursor: not-allowed; 72 + } 73 + 74 + .sequoia-subscribe-button svg { 75 + width: 1rem; 76 + height: 1rem; 77 + flex-shrink: 0; 78 + } 79 + 80 + .sequoia-subscribe-button--success { 81 + background: #16a34a; 82 + } 83 + 84 + .sequoia-subscribe-button--success:hover:not(:disabled) { 85 + background: color-mix(in srgb, #16a34a 85%, black); 86 + } 87 + 88 + .sequoia-loading-spinner { 89 + display: inline-block; 90 + width: 1rem; 91 + height: 1rem; 92 + border: 2px solid rgba(255, 255, 255, 0.4); 93 + border-top-color: #ffffff; 94 + border-radius: 50%; 95 + animation: sequoia-spin 0.8s linear infinite; 96 + flex-shrink: 0; 97 + } 98 + 99 + @keyframes sequoia-spin { 100 + to { transform: rotate(360deg); } 101 + } 102 + 103 + .sequoia-error-message { 104 + display: inline-block; 105 + font-size: 0.8125rem; 106 + color: #dc2626; 107 + margin-top: 0.375rem; 108 + } 109 + `; 110 + 111 + // ============================================================================ 112 + // Icons 113 + // ============================================================================ 114 + 115 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 116 + <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"/> 117 + </svg>`; 118 + 119 + const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 120 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> 121 + </svg>`; 122 + 123 + // ============================================================================ 124 + // AT Protocol Functions 125 + // ============================================================================ 126 + 127 + /** 128 + * Resolve a DID to its PDS URL. 129 + * Supports did:plc and did:web methods. 130 + * @param {string} did - Decentralized Identifier 131 + * @returns {Promise<string>} PDS URL 132 + */ 133 + async function resolvePDS(did) { 134 + let pdsUrl; 135 + 136 + if (did.startsWith("did:plc:")) { 137 + const didDocUrl = `https://plc.directory/${did}`; 138 + const didDocResponse = await fetch(didDocUrl); 139 + if (!didDocResponse.ok) { 140 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 141 + } 142 + const didDoc = await didDocResponse.json(); 143 + 144 + const pdsService = didDoc.service?.find( 145 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 146 + ); 147 + pdsUrl = pdsService?.serviceEndpoint; 148 + } else if (did.startsWith("did:web:")) { 149 + const domain = did.replace("did:web:", ""); 150 + const didDocUrl = `https://${domain}/.well-known/did.json`; 151 + const didDocResponse = await fetch(didDocUrl); 152 + if (!didDocResponse.ok) { 153 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 154 + } 155 + const didDoc = await didDocResponse.json(); 156 + 157 + const pdsService = didDoc.service?.find( 158 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 159 + ); 160 + pdsUrl = pdsService?.serviceEndpoint; 161 + } else { 162 + throw new Error(`Unsupported DID method: ${did}`); 163 + } 164 + 165 + if (!pdsUrl) { 166 + throw new Error("Could not find PDS URL for user"); 167 + } 168 + 169 + return pdsUrl; 170 + } 171 + 172 + /** 173 + * Create a site.standard.graph.subscription record in the subscriber's PDS. 174 + * @param {string} did - DID of the subscriber 175 + * @param {string} accessToken - AT Protocol access token 176 + * @param {string} publicationUri - AT URI of the publication to subscribe to 177 + * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID 178 + */ 179 + async function createRecord(did, accessToken, publicationUri) { 180 + const pdsUrl = await resolvePDS(did); 181 + 182 + const collection = "site.standard.graph.subscription"; 183 + const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; 184 + const response = await fetch(url, { 185 + method: "POST", 186 + headers: { 187 + "Content-Type": "application/json", 188 + Authorization: `Bearer ${accessToken}`, 189 + }, 190 + body: JSON.stringify({ 191 + repo: did, 192 + collection, 193 + record: { 194 + $type: "site.standard.graph.subscription", 195 + publication: publicationUri, 196 + }, 197 + }), 198 + }); 199 + 200 + if (!response.ok) { 201 + const body = await response.json().catch(() => ({})); 202 + const message = body?.message ?? body?.error ?? `HTTP ${response.status}`; 203 + throw new Error(`Failed to create record: ${message}`); 204 + } 205 + 206 + const data = await response.json(); 207 + return { uri: data.uri, cid: data.cid }; 208 + } 209 + 210 + /** 211 + * Fetch the publication AT URI from the host site's well-known endpoint. 212 + * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 213 + * @returns {Promise<string>} Publication AT URI 214 + */ 215 + async function fetchPublicationUri(origin) { 216 + const base = origin ?? window.location.origin; 217 + const url = `${base}/.well-known/site.standard.publication`; 218 + const response = await fetch(url); 219 + if (!response.ok) { 220 + throw new Error( 221 + `Could not fetch publication URI: ${response.status}`, 222 + ); 223 + } 224 + 225 + // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 226 + const contentType = response.headers.get("content-type") ?? ""; 227 + if (contentType.includes("application/json")) { 228 + const data = await response.json(); 229 + const uri = data?.uri ?? data?.atUri ?? data?.publication; 230 + if (!uri) { 231 + throw new Error("Publication response did not contain a URI"); 232 + } 233 + return uri; 234 + } 235 + 236 + const text = (await response.text()).trim(); 237 + if (!text.startsWith("at://")) { 238 + throw new Error(`Unexpected publication URI format: ${text}`); 239 + } 240 + return text; 241 + } 242 + 243 + // ============================================================================ 244 + // Web Component 245 + // ============================================================================ 246 + 247 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 248 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 249 + 250 + class SequoiaSubscribe extends BaseElement { 251 + constructor() { 252 + super(); 253 + const shadow = this.attachShadow({ mode: "open" }); 254 + 255 + const styleTag = document.createElement("style"); 256 + styleTag.innerText = styles; 257 + shadow.appendChild(styleTag); 258 + 259 + const wrapper = document.createElement("div"); 260 + shadow.appendChild(wrapper); 261 + wrapper.part = "container"; 262 + 263 + this.wrapper = wrapper; 264 + this.state = { type: "idle" }; 265 + this.render(); 266 + } 267 + 268 + static get observedAttributes() { 269 + return ["publication-uri", "label"]; 270 + } 271 + 272 + attributeChangedCallback() { 273 + // Reset to idle if attributes change after an error or success 274 + if ( 275 + this.state.type === "error" || 276 + this.state.type === "subscribed" 277 + ) { 278 + this.state = { type: "idle" }; 279 + } 280 + this.render(); 281 + } 282 + 283 + get publicationUri() { 284 + return this.getAttribute("publication-uri") ?? null; 285 + } 286 + 287 + get label() { 288 + return this.getAttribute("label") ?? "Subscribe on Bluesky"; 289 + } 290 + 291 + async handleClick() { 292 + if (this.state.type === "loading" || this.state.type === "subscribed") { 293 + return; 294 + } 295 + 296 + this.state = { type: "loading" }; 297 + this.render(); 298 + 299 + try { 300 + // Resolve the publication AT URI 301 + const publicationUri = 302 + this.publicationUri ?? (await fetchPublicationUri()); 303 + 304 + // TODO: resolve authenticated DID and access token before calling createRecord 305 + const { uri: recordUri } = await createRecord( 306 + /* did */ undefined, 307 + /* accessToken */ undefined, 308 + publicationUri, 309 + ); 310 + 311 + this.state = { type: "subscribed", recordUri, publicationUri }; 312 + this.render(); 313 + 314 + this.dispatchEvent( 315 + new CustomEvent("sequoia-subscribed", { 316 + bubbles: true, 317 + composed: true, 318 + detail: { publicationUri, recordUri }, 319 + }), 320 + ); 321 + } catch (error) { 322 + const message = 323 + error instanceof Error ? error.message : "Failed to subscribe"; 324 + this.state = { type: "error", message }; 325 + this.render(); 326 + 327 + this.dispatchEvent( 328 + new CustomEvent("sequoia-subscribe-error", { 329 + bubbles: true, 330 + composed: true, 331 + detail: { message }, 332 + }), 333 + ); 334 + } 335 + } 336 + 337 + render() { 338 + const { type } = this.state; 339 + const isLoading = type === "loading"; 340 + const isSubscribed = type === "subscribed"; 341 + 342 + const icon = isLoading 343 + ? `<span class="sequoia-loading-spinner"></span>` 344 + : isSubscribed 345 + ? CHECK_ICON 346 + : BLUESKY_ICON; 347 + 348 + const label = isSubscribed ? "Subscribed" : this.label; 349 + const buttonClass = [ 350 + "sequoia-subscribe-button", 351 + isSubscribed ? "sequoia-subscribe-button--success" : "", 352 + ] 353 + .filter(Boolean) 354 + .join(" "); 355 + 356 + const errorHtml = 357 + type === "error" 358 + ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 359 + : ""; 360 + 361 + this.wrapper.innerHTML = ` 362 + <button 363 + class="${buttonClass}" 364 + type="button" 365 + part="button" 366 + ${isLoading || isSubscribed ? "disabled" : ""} 367 + aria-label="${isSubscribed ? "Subscribed" : this.label}" 368 + > 369 + ${icon} 370 + ${label} 371 + </button> 372 + ${errorHtml} 373 + `; 374 + 375 + if (type !== "subscribed") { 376 + const btn = this.wrapper.querySelector("button"); 377 + btn?.addEventListener("click", () => this.handleClick()); 378 + } 379 + } 380 + } 381 + 382 + /** 383 + * Escape HTML special characters (no DOM dependency for SSR). 384 + * @param {string} text 385 + * @returns {string} 386 + */ 387 + function escapeHtml(text) { 388 + return text 389 + .replace(/&/g, "&amp;") 390 + .replace(/</g, "&lt;") 391 + .replace(/>/g, "&gt;") 392 + .replace(/"/g, "&quot;"); 393 + } 394 + 395 + // Register the custom element 396 + if (typeof customElements !== "undefined") { 397 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 398 + } 399 + 400 + // Export for module usage 401 + export { SequoiaSubscribe };

History

4 rounds 14 comments
sign up or login to add to the discussion
5 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
Add subscribe section to documentation and update navigation links
expand 2 comments

Excellent; thank you!! I'll make some follow up changes with the fetch-patch for the worker. Really appreciate your help here!

oop nvm, see its there now; we're good!

pull request successfully merged
4 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
expand 1 comment

I wasn't able to test e2e. I can get to the /subscribe page and authenticate, but with the static and API sites running on different ports I don't get a proper callback.

2 commits
expand
feat: Add subscription component
Add subscription support
expand 11 comments

@stevedylan.dev this is closed, but I'm having trouble verifying it e2e. I swapped out the CLIENT_URL variable for my local instance, but seems I need both the static site and wrangler site (on separate ports) running simultaneously and I'm not sure the flow is working right between the two as it might for Cloudflare. Is this something you can test easily, or maybe have some pointers? Does Cloudflare somehow proxy the calls to seem like a single site?

All that said, the flow seems to almost work right. I had to disable CORS for localhost on my machine but that should just be because it's localhost. I explicitly set the callback-uri in my SSG to test this all out. The rendered pages are somewhat themed. Not sure how vocs is setting the class on the HTML root element, but I have a script doing it here.

The auto-hide functionality also works if the publication-uri isn't set or isn't discoverable from /.well-known.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

@heaths.dev Awesome!! Yeah I can definitely test this later. I think if you run bun dev:api inside the docs folder it should spin up the actual API and render the site as the same time, so that should make it easier to test!

We also probably need to update the CORS on the index.ts file too, something like seen here: https://hono.dev/docs/middleware/builtin/cors

Whew, ok had to do some deep testing and deployment of code that still got me pretty stuck. Here's where we're at:

  • We need to add these cors settings to the index.ts file
import { cors } from "hono/cors";
// Other imports and initial hono app
app.use(
	"/subscribe/*",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
app.use(
	"/subscribe",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
  • For some reason the OAuth client really doesn't play well with cloudflare workers. Here's an example error:
GET https://sequoia.pub/oauth/login?handle=stevedylan.dev - Ok @ 2/24/2026, 8:11:56 PM
  (error) Identity resolution failed: DidError did-unknown-error (did:plc:ia2zdnhjaokf5lazhxrmj6eu): Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code).
  (error) Login error: Error: Failed to resolve identity: stevedylan.dev

I know with the CLI we use the @atproto/oauth-client-node so maybe that's the answer. Will see if I can take another crack at it later.

Figured it out!! What a pain in the ass lol. Ok here's what we need to do:

  • Create a new file docs/src/lib/path-redirect.ts with these contents and then import it to docs/src/index.ts like so import "./lib/patch-redirect";. This will solve the weird cloudflare worker issues with the atproto oauth client library and how it handles redirects.

  • Update all instances of scope: "atproto transition:generic" to scope: "atproto site.standard.graph.subscription"

  • The cors updates seen in the previous comment

I made these changes locally and deployed it so you can try it out from localhost and it should work! We just need to get the actual code committed and merged.

Also on the final screen where it shows the user that we created the records, could be nice to link to a pds.ls URL like so

https://pds.ls/at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.graph.subscription/3mfnrenwnps2h

Hold off on changing the scopes; started having weird issues with it so reverted to what you already have

Ok tested some more with scopes and I'm not sure what I was doing wrong before, perhaps an old session, but I confirmed it's working as expected. Just need to update the following lines:

docs/src/routes/auth.ts:30,47 docs/src/lib/oauth-client.ts:22

scope: "atproto repo:site.standard.graph.subscription"

Hey, sorry for not responding earlier and thanks so much for testing this with Cloudflare! I was building the docs site okay. The problem I was running into was that the static site was served from a different port than the API, and crossing between the two was having additional CORS issues than just the ones I expected tested against localhost that I temporarily worked around.

Since you already have the changes, do you just want to push them to this PR since you can actually test them? Given our previous thread, I don't mind that at all - it's collaboration! :) That was different than the other PR.

That said, not sure if you're online right now so I'll take a look through your changes and incorporate what I can. I'll even try to test but, like I said, without testing in an actual test environment on Cloudflare (or something like it), not sure how realistic testing would be.

heaths.dev submitted #0
1 commit
expand
feat: Add subscription component
expand 0 comments