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

feat: Add sequoia-subscribe web component for publication subscriptions #31

Implement complete subscribe flow allowing users to subscribe to AT Protocol publications via Bluesky authentication:

  • sequoia-subscribe web component: Customizable button for initiating subscribe flow with configurable styling and callback support
  • /subscribe route: Landing page with handle input form, OAuth initiation, and subscription context storage in KV with TTL
  • OAuth callback enhancement: Added subscribe_ctx cookie handling to create subscription records after successful authentication
  • Documentation: Added subscribe.mdx guide with usage examples and API docs
  • CLI integration: Added sequoia-subscribe to available components list

Files added:

  • packages/cli/src/components/sequoia-subscribe.js (web component)
  • docs/docs/public/sequoia-subscribe.js (public copy)
  • docs/src/routes/subscribe.ts (landing + OAuth flow)
  • docs/docs/pages/subscribe.mdx (documentation)

Files modified:

  • docs/package.json: Added @atproto/api dependency
  • docs/src/index.ts: Mounted /subscribe route
  • docs/src/routes/auth.ts: Added subscribe_ctx handling in callback
  • docs/vocs.config.ts: Added sidebar entry
  • docs/wrangler.toml: Updated run_worker_first config
  • packages/cli/src/commands/add.ts: Included sequoia-subscribe component

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:i2fgba5nignuw4nccml33wjp/sh.tangled.repo.pull/3mfgbr55xu522
+1296 -15
Diff #0
+19 -4
bun.lock
··· 14 14 "version": "0.0.0", 15 15 "dependencies": { 16 16 "@atproto-labs/handle-resolver": "latest", 17 + "@atproto/api": "latest", 17 18 "@atproto/jwk-jose": "latest", 18 19 "@atproto/oauth-client": "latest", 19 20 "hono": "latest", ··· 78 79 79 80 "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 80 81 81 - "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 82 + "@atproto/api": ["@atproto/api@0.18.21", "", { "dependencies": { "@atproto/common-web": "^0.4.16", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w=="], 82 83 83 - "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 84 + "@atproto/common-web": ["@atproto/common-web@0.4.17", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "@atproto/lex-json": "^0.0.12", "@atproto/syntax": "^0.4.3", "zod": "^3.23.8" } }, "sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ=="], 84 85 85 86 "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 86 87 ··· 90 91 91 92 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 92 93 93 - "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 94 + "@atproto/lex-data": ["@atproto/lex-data@0.0.12", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw=="], 94 95 95 - "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 96 + "@atproto/lex-json": ["@atproto/lex-json@0.0.12", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "tslib": "^2.8.1" } }, "sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA=="], 96 97 97 98 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 98 99 ··· 1540 1541 1541 1542 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 1542 1543 1544 + "@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 1545 + 1543 1546 "@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 1544 1547 1545 1548 "@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], ··· 1616 1619 1617 1620 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1618 1621 1622 + "sequoia-cli/@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 1623 + 1619 1624 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1620 1625 1621 1626 "vocs/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], 1627 + 1628 + "@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 1629 + 1630 + "@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 1622 1631 1623 1632 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1624 1633 ··· 1643 1652 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1644 1653 1645 1654 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1655 + 1656 + "sequoia-cli/@atproto/api/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 1657 + 1658 + "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 1659 + 1660 + "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 1646 1661 } 1647 1662 }
+183
docs/docs/pages/subscribe.mdx
··· 1 + # Subscribe Button 2 + 3 + Sequoia provides a `sequoia-subscribe` web component that lets readers subscribe to your publication directly from your website. When a reader clicks the button, they are redirected to [sequoia.pub/subscribe](https://sequoia.pub/subscribe) where they can authenticate with Bluesky to create a `site.standard.graph.subscription` record in their AT Protocol repository. 4 + 5 + ## Setup 6 + 7 + Run the following command in your project to install the subscribe web component: 8 + 9 + ```bash [Terminal] 10 + sequoia add sequoia-subscribe 11 + ``` 12 + 13 + The component automatically discovers your publication URI from: 14 + 15 + 1. The `publication-uri` attribute on the element 16 + 2. A `<link rel="site.standard.publication" href="at://...">` tag in your page head 17 + 3. `/.well-known/site.standard.publication` at your site root 18 + 19 + ::::tip 20 + The `<link rel="site.standard.publication">` tag is added automatically by `sequoia inject`. For more information, check out the [verification guide](/verifying). 21 + :::: 22 + 23 + ## Usage 24 + 25 + Since `sequoia-subscribe` is a standard Web Component, it works with any framework. Choose your setup below: 26 + 27 + :::code-group 28 + 29 + ```html [HTML] 30 + <body> 31 + <h1>My Publication</h1> 32 + 33 + <sequoia-subscribe></sequoia-subscribe> 34 + <script type="module" src="./src/components/sequoia-subscribe.js"></script> 35 + </body> 36 + ``` 37 + 38 + ```tsx [React] 39 + // Import the component (registers the custom element) 40 + import './components/sequoia-subscribe.js'; 41 + 42 + function BlogHeader() { 43 + return ( 44 + <header> 45 + <h1>My Publication</h1> 46 + <sequoia-subscribe /> 47 + </header> 48 + ); 49 + } 50 + ``` 51 + 52 + ```vue [Vue] 53 + <script setup> 54 + import './components/sequoia-subscribe.js'; 55 + </script> 56 + 57 + <template> 58 + <header> 59 + <h1>My Publication</h1> 60 + <sequoia-subscribe /> 61 + </header> 62 + </template> 63 + ``` 64 + 65 + ```svelte [Svelte] 66 + <script> 67 + import './components/sequoia-subscribe.js'; 68 + </script> 69 + 70 + <header> 71 + <h1>My Publication</h1> 72 + <sequoia-subscribe /> 73 + </header> 74 + ``` 75 + 76 + ```astro [Astro] 77 + <header> 78 + <h1>My Publication</h1> 79 + <sequoia-subscribe /> 80 + <script> 81 + import './components/sequoia-subscribe.js'; 82 + </script> 83 + </header> 84 + ``` 85 + 86 + ::: 87 + 88 + ### TypeScript Support 89 + 90 + If you're using TypeScript with React, add this type declaration to avoid JSX errors: 91 + 92 + ```ts [custom-elements.d.ts] 93 + declare namespace JSX { 94 + interface IntrinsicElements { 95 + 'sequoia-subscribe': React.DetailedHTMLProps< 96 + React.HTMLAttributes<HTMLElement> & { 97 + 'publication-uri'?: string; 98 + 'callback-url'?: string; 99 + hide?: string; 100 + }, 101 + HTMLElement 102 + >; 103 + } 104 + } 105 + ``` 106 + 107 + ### Vue Configuration 108 + 109 + For Vue, you may need to configure the compiler to recognize custom elements: 110 + 111 + ```ts [vite.config.ts] 112 + export default defineConfig({ 113 + plugins: [ 114 + vue({ 115 + template: { 116 + compilerOptions: { 117 + isCustomElement: (tag) => tag === 'sequoia-subscribe' 118 + } 119 + } 120 + }) 121 + ] 122 + }); 123 + ``` 124 + 125 + ## Configuration 126 + 127 + ### Attributes 128 + 129 + The `<sequoia-subscribe>` component accepts the following attributes: 130 + 131 + | Attribute | Type | Default | Description | 132 + |-----------|------|---------|-------------| 133 + | `publication-uri` | `string` | โ€” | AT URI of the publication. Optional if a `<link rel="site.standard.publication">` tag or `.well-known` file is present. | 134 + | `callback-url` | `string` | `https://sequoia.pub/subscribe` | Override the subscribe callback URL. | 135 + | `hide` | `string` | โ€” | Set to `"auto"` to hide the button if no publication URI is found. | 136 + 137 + ```html 138 + <!-- Explicit publication URI --> 139 + <sequoia-subscribe 140 + publication-uri="at://did:plc:example/site.standard.publication/self"> 141 + </sequoia-subscribe> 142 + 143 + <!-- Hide if no publication found --> 144 + <sequoia-subscribe hide="auto"></sequoia-subscribe> 145 + ``` 146 + 147 + ### Styling 148 + 149 + The component uses CSS custom properties for theming: 150 + 151 + | CSS Property | Default | Description | 152 + |--------------|---------|-------------| 153 + | `--sequoia-accent-color` | `#2563eb` | Button background color | 154 + | `--sequoia-border-radius` | `8px` | Button border radius | 155 + | `--sequoia-fg-color` | `#1f2937` | Text color | 156 + 157 + You can also style the button directly using the `::part` pseudo-element: 158 + 159 + ```css 160 + sequoia-subscribe::part(button) { 161 + background: #3A5A40; 162 + border-radius: 999px; 163 + font-size: 0.875rem; 164 + } 165 + ``` 166 + 167 + ### Example: Themed Button 168 + 169 + ```css 170 + :root { 171 + --sequoia-accent-color: #3A5A40; 172 + --sequoia-border-radius: 999px; 173 + } 174 + ``` 175 + 176 + ## How It Works 177 + 178 + 1. A reader clicks the **Subscribe with Bluesky** button on your site. 179 + 2. The component redirects to `sequoia.pub/subscribe?pub={publication-uri}&return={current-url}`. 180 + 3. The reader enters their Bluesky handle on sequoia.pub. 181 + 4. sequoia.pub initiates an AT Protocol OAuth flow with the reader's PDS. 182 + 5. After authorization, sequoia.pub creates a `site.standard.graph.subscription` record in the reader's repository pointing to your publication. 183 + 6. The reader is redirected back to your page with a brief success confirmation.
+330
docs/docs/public/sequoia-subscribe.js
··· 1 + /** 2 + * Sequoia Subscribe - A Bluesky-powered subscribe button 3 + * 4 + * A self-contained Web Component that lets readers subscribe to AT Protocol 5 + * publications directly from your website via OAuth. 6 + * 7 + * Usage: 8 + * <sequoia-subscribe></sequoia-subscribe> 9 + * 10 + * The component looks for a publication URI in three places (in priority order): 11 + * 1. The `publication-uri` attribute on the element 12 + * 2. A <link rel="site.standard.publication" href="at://..."> tag in the document head 13 + * 3. Fetch /.well-known/site.standard.publication from current origin 14 + * 15 + * Attributes: 16 + * - publication-uri: AT URI of the publication (optional if discovered automatically) 17 + * - callback-url: Override callback URL (default: https://sequoia.pub/subscribe) 18 + * - hide: Set to "auto" to hide if no publication URI found 19 + * 20 + * CSS Custom Properties: 21 + * - --sequoia-fg-color: Text color (default: #1f2937) 22 + * - --sequoia-bg-color: Background color (default: #ffffff) 23 + * - --sequoia-border-color: Border color (default: #e5e7eb) 24 + * - --sequoia-accent-color: Accent/link color (default: #2563eb) 25 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 26 + * - --sequoia-border-radius: Border radius (default: 8px) 27 + */ 28 + 29 + // ============================================================================ 30 + // Styles 31 + // ============================================================================ 32 + 33 + const styles = ` 34 + :host { 35 + display: inline-block; 36 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 37 + color: var(--sequoia-fg-color, #1f2937); 38 + line-height: 1.5; 39 + } 40 + 41 + * { 42 + box-sizing: border-box; 43 + } 44 + 45 + .sequoia-subscribe-container { 46 + display: inline-block; 47 + } 48 + 49 + .sequoia-subscribe-button { 50 + display: inline-flex; 51 + align-items: center; 52 + gap: 0.5rem; 53 + padding: 0.5rem 1.125rem; 54 + background: var(--sequoia-accent-color, #2563eb); 55 + color: #ffffff; 56 + border: none; 57 + border-radius: var(--sequoia-border-radius, 8px); 58 + font-size: 0.9375rem; 59 + font-weight: 500; 60 + cursor: pointer; 61 + text-decoration: none; 62 + transition: background-color 0.15s ease, opacity 0.15s ease; 63 + font-family: inherit; 64 + } 65 + 66 + .sequoia-subscribe-button:hover:not(:disabled) { 67 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 68 + } 69 + 70 + .sequoia-subscribe-button:disabled { 71 + opacity: 0.7; 72 + cursor: not-allowed; 73 + } 74 + 75 + .sequoia-subscribe-button svg { 76 + width: 1.125rem; 77 + height: 1.125rem; 78 + flex-shrink: 0; 79 + } 80 + 81 + .sequoia-success { 82 + display: inline-flex; 83 + align-items: center; 84 + gap: 0.5rem; 85 + padding: 0.5rem 1rem; 86 + background: #f0fdf4; 87 + border: 1px solid #86efac; 88 + color: #15803d; 89 + border-radius: var(--sequoia-border-radius, 8px); 90 + font-size: 0.875rem; 91 + font-weight: 500; 92 + } 93 + 94 + .sequoia-error-inline { 95 + display: inline-flex; 96 + align-items: center; 97 + gap: 0.5rem; 98 + padding: 0.5rem 1rem; 99 + background: #fef2f2; 100 + border: 1px solid #fecaca; 101 + color: #dc2626; 102 + border-radius: var(--sequoia-border-radius, 8px); 103 + font-size: 0.875rem; 104 + } 105 + `; 106 + 107 + // ============================================================================ 108 + // Icons 109 + // ============================================================================ 110 + 111 + const BELL_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"> 112 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> 113 + <path d="M13.73 21a2 2 0 0 1-3.46 0"/> 114 + </svg>`; 115 + 116 + // ============================================================================ 117 + // Web Component 118 + // ============================================================================ 119 + 120 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 121 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 122 + 123 + class SequoiaSubscribe extends BaseElement { 124 + constructor() { 125 + super(); 126 + const shadow = this.attachShadow({ mode: "open" }); 127 + 128 + const styleTag = document.createElement("style"); 129 + shadow.appendChild(styleTag); 130 + styleTag.innerText = styles; 131 + 132 + const container = document.createElement("div"); 133 + shadow.appendChild(container); 134 + container.className = "sequoia-subscribe-container"; 135 + container.part = "container"; 136 + 137 + this.subscribeContainer = container; 138 + this.state = { type: "loading" }; 139 + } 140 + 141 + static get observedAttributes() { 142 + return ["publication-uri", "callback-url", "hide"]; 143 + } 144 + 145 + connectedCallback() { 146 + // Check for return state from OAuth callback first 147 + const returnState = this._checkReturnState(); 148 + if (returnState) { 149 + this.state = returnState; 150 + this.render(); 151 + // Auto-clear feedback after 5 seconds, then rediscover 152 + setTimeout(() => { 153 + const url = new URL(window.location.href); 154 + url.searchParams.delete("subscribed"); 155 + url.searchParams.delete("subscribe_error"); 156 + history.replaceState(null, "", url.toString()); 157 + this.discover(); 158 + }, 5000); 159 + return; 160 + } 161 + this.discover(); 162 + } 163 + 164 + attributeChangedCallback() { 165 + if (this.isConnected) { 166 + this.discover(); 167 + } 168 + } 169 + 170 + _checkReturnState() { 171 + if (typeof window === "undefined") return null; 172 + const params = new URLSearchParams(window.location.search); 173 + if (params.get("subscribed") === "true") { 174 + return { type: "subscribed" }; 175 + } 176 + const errMsg = params.get("subscribe_error"); 177 + if (errMsg) { 178 + return { type: "error", message: decodeURIComponent(errMsg) }; 179 + } 180 + return null; 181 + } 182 + 183 + get publicationUri() { 184 + return this.getAttribute("publication-uri"); 185 + } 186 + 187 + get callbackUrl() { 188 + return ( 189 + this.getAttribute("callback-url") || "https://sequoia.pub/subscribe" 190 + ); 191 + } 192 + 193 + get hideAuto() { 194 + return this.getAttribute("hide") === "auto"; 195 + } 196 + 197 + async discover() { 198 + this.state = { type: "loading" }; 199 + this.render(); 200 + 201 + // 1. Check attribute 202 + const attrUri = this.publicationUri; 203 + if (attrUri) { 204 + this.state = { type: "idle", pubUri: attrUri }; 205 + this.render(); 206 + return; 207 + } 208 + 209 + // 2. Check <link rel="site.standard.publication"> tag 210 + const linkTag = document.querySelector( 211 + 'link[rel="site.standard.publication"]', 212 + ); 213 + const linkHref = linkTag?.getAttribute("href"); 214 + if (linkHref?.startsWith("at://")) { 215 + this.state = { type: "idle", pubUri: linkHref }; 216 + this.render(); 217 + return; 218 + } 219 + 220 + // 3. Fetch /.well-known/site.standard.publication 221 + try { 222 + const resp = await fetch("/.well-known/site.standard.publication"); 223 + if (resp.ok) { 224 + const text = await resp.text(); 225 + const uri = text.trim(); 226 + if (uri.startsWith("at://")) { 227 + this.state = { type: "idle", pubUri: uri }; 228 + this.render(); 229 + return; 230 + } 231 + } 232 + } catch { 233 + // Network error or not found - fall through to no-publication 234 + } 235 + 236 + this.state = { type: "no-publication" }; 237 + this.render(); 238 + } 239 + 240 + _onSubscribeClick() { 241 + if (this.state.type !== "idle") return; 242 + const pubUri = this.state.pubUri; 243 + const returnUrl = window.location.href; 244 + 245 + const url = new URL(this.callbackUrl); 246 + url.searchParams.set("pub", pubUri); 247 + url.searchParams.set("return", returnUrl); 248 + 249 + this.state = { type: "redirecting" }; 250 + this.render(); 251 + window.location.href = url.toString(); 252 + } 253 + 254 + _escapeHtml(text) { 255 + const div = document.createElement("div"); 256 + div.textContent = text; 257 + return div.innerHTML; 258 + } 259 + 260 + render() { 261 + switch (this.state.type) { 262 + case "loading": 263 + this.subscribeContainer.innerHTML = ` 264 + <button class="sequoia-subscribe-button" disabled part="button"> 265 + ${BELL_ICON} 266 + Subscribe with Bluesky 267 + </button> 268 + `; 269 + break; 270 + 271 + case "no-publication": 272 + if (this.hideAuto) { 273 + this.subscribeContainer.innerHTML = ""; 274 + this.style.display = "none"; 275 + } else { 276 + this.subscribeContainer.innerHTML = ` 277 + <button class="sequoia-subscribe-button" disabled part="button"> 278 + ${BELL_ICON} 279 + Subscribe with Bluesky 280 + </button> 281 + `; 282 + } 283 + break; 284 + 285 + case "idle": { 286 + const btn = document.createElement("button"); 287 + btn.className = "sequoia-subscribe-button"; 288 + btn.setAttribute("part", "button"); 289 + btn.innerHTML = `${BELL_ICON} Subscribe with Bluesky`; 290 + btn.addEventListener("click", () => this._onSubscribeClick()); 291 + this.subscribeContainer.innerHTML = ""; 292 + this.subscribeContainer.appendChild(btn); 293 + break; 294 + } 295 + 296 + case "redirecting": 297 + this.subscribeContainer.innerHTML = ` 298 + <button class="sequoia-subscribe-button" disabled part="button"> 299 + ${BELL_ICON} 300 + Redirecting... 301 + </button> 302 + `; 303 + break; 304 + 305 + case "subscribed": 306 + this.subscribeContainer.innerHTML = ` 307 + <span class="sequoia-success" part="success"> 308 + &#10003; Subscribed! 309 + </span> 310 + `; 311 + break; 312 + 313 + case "error": 314 + this.subscribeContainer.innerHTML = ` 315 + <span class="sequoia-error-inline" part="error"> 316 + Failed to subscribe: ${this._escapeHtml(this.state.message || "Unknown error")} 317 + </span> 318 + `; 319 + break; 320 + } 321 + } 322 + } 323 + 324 + // Register the custom element 325 + if (typeof customElements !== "undefined") { 326 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 327 + } 328 + 329 + // Export for module usage 330 + export { SequoiaSubscribe };
+1
docs/package.json
··· 12 12 "preview": "vocs preview" 13 13 }, 14 14 "dependencies": { 15 + "@atproto/api": "latest", 15 16 "@atproto/oauth-client": "latest", 16 17 "@atproto/jwk-jose": "latest", 17 18 "@atproto-labs/handle-resolver": "latest",
+2
docs/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import auth from "./routes/auth"; 3 + import subscribe from "./routes/subscribe"; 3 4 4 5 type Bindings = { 5 6 ASSETS: Fetcher; ··· 10 11 const app = new Hono<{ Bindings: Bindings }>(); 11 12 12 13 app.route("/oauth", auth); 14 + app.route("/subscribe", subscribe); 13 15 14 16 app.get("/api/health", (c) => { 15 17 return c.json({ status: "ok" });
+39
docs/src/routes/auth.ts
··· 1 + import { Agent } from "@atproto/api"; 1 2 import { Hono } from "hono"; 3 + import { getCookie, deleteCookie } from "hono/cookie"; 2 4 import { createOAuthClient } from "../lib/oauth-client"; 3 5 import { 4 6 getSessionDid, ··· 67 69 68 70 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 69 71 const { session } = await client.callback(params); 72 + 73 + // Check for subscribe context cookie 74 + const subscribeCtxKey = getCookie(c, "subscribe_ctx"); 75 + if (subscribeCtxKey) { 76 + deleteCookie(c, "subscribe_ctx", { path: "/" }); 77 + const ctxJson = await c.env.SEQUOIA_SESSIONS.get(subscribeCtxKey); 78 + if (ctxJson) { 79 + await c.env.SEQUOIA_SESSIONS.delete(subscribeCtxKey); 80 + const ctx = JSON.parse(ctxJson) as { pub: string; returnUrl: string }; 81 + try { 82 + const agent = new Agent(session); 83 + await agent.com.atproto.repo.createRecord({ 84 + repo: session.did, 85 + collection: "site.standard.graph.subscription", 86 + record: { 87 + $type: "site.standard.graph.subscription", 88 + publication: ctx.pub, 89 + }, 90 + }); 91 + const returnUrl = new URL(ctx.returnUrl); 92 + returnUrl.searchParams.set("subscribed", "true"); 93 + return c.redirect(returnUrl.toString(), 302); 94 + } catch (err) { 95 + // Duplicate subscription = treat as success 96 + const msg = err instanceof Error ? err.message : String(err); 97 + if (msg.includes("already exists") || msg.includes("Conflict")) { 98 + const returnUrl = new URL(ctx.returnUrl); 99 + returnUrl.searchParams.set("subscribed", "true"); 100 + return c.redirect(returnUrl.toString(), 302); 101 + } 102 + // Real error: redirect back with error param 103 + const returnUrl = new URL(ctx.returnUrl); 104 + returnUrl.searchParams.set("subscribe_error", encodeURIComponent(msg)); 105 + return c.redirect(returnUrl.toString(), 302); 106 + } 107 + } 108 + } 70 109 71 110 // Resolve handle from DID 72 111 let handle: string | undefined;
+314
docs/src/routes/subscribe.ts
··· 1 + import { Hono } from "hono"; 2 + import { setCookie } from "hono/cookie"; 3 + import { createOAuthClient } from "../lib/oauth-client"; 4 + 5 + interface Env { 6 + SEQUOIA_SESSIONS: KVNamespace; 7 + CLIENT_URL: string; 8 + } 9 + 10 + const subscribe = new Hono<{ Bindings: Env }>(); 11 + 12 + const STATE_TTL_SECONDS = 600; 13 + 14 + // ============================================================================ 15 + // GET /subscribe - Landing page with handle input form 16 + // ============================================================================ 17 + 18 + subscribe.get("/", (c) => { 19 + const pub = c.req.query("pub") ?? ""; 20 + const returnUrl = c.req.query("return") ?? ""; 21 + 22 + if (!pub) { 23 + return c.html(renderPage("Missing publication URI", renderError("No publication URI provided."))); 24 + } 25 + 26 + return c.html(renderPage("Subscribe with Bluesky", renderForm(pub, returnUrl))); 27 + }); 28 + 29 + // ============================================================================ 30 + // POST /subscribe - Store context, initiate OAuth 31 + // ============================================================================ 32 + 33 + subscribe.post("/", async (c) => { 34 + let formData: FormData; 35 + try { 36 + formData = await c.req.formData(); 37 + } catch { 38 + return c.html( 39 + renderPage("Subscribe with Bluesky", renderError("Invalid form submission.")), 40 + 400, 41 + ); 42 + } 43 + 44 + const handle = (formData.get("handle") as string | null)?.trim() ?? ""; 45 + const pub = (formData.get("pub") as string | null) ?? ""; 46 + const returnUrl = (formData.get("return") as string | null) ?? ""; 47 + 48 + if (!handle) { 49 + return c.html( 50 + renderPage( 51 + "Subscribe with Bluesky", 52 + renderForm(pub, returnUrl, "Please enter your Bluesky handle."), 53 + ), 54 + 400, 55 + ); 56 + } 57 + 58 + if (!pub.startsWith("at://")) { 59 + return c.html( 60 + renderPage("Subscribe with Bluesky", renderError("Invalid publication URI.")), 61 + 400, 62 + ); 63 + } 64 + 65 + // Validate returnUrl is a well-formed HTTPS URL to prevent open redirect 66 + if (returnUrl) { 67 + try { 68 + const parsed = new URL(returnUrl); 69 + if (parsed.protocol !== "https:") { 70 + return c.html( 71 + renderPage("Subscribe with Bluesky", renderError("Invalid return URL.")), 72 + 400, 73 + ); 74 + } 75 + } catch { 76 + return c.html( 77 + renderPage("Subscribe with Bluesky", renderError("Invalid return URL.")), 78 + 400, 79 + ); 80 + } 81 + } 82 + 83 + try { 84 + // Store subscribe context in KV 85 + const ctxKey = `subscribe_ctx:${crypto.randomUUID()}`; 86 + await c.env.SEQUOIA_SESSIONS.put( 87 + ctxKey, 88 + JSON.stringify({ pub, returnUrl: returnUrl || c.env.CLIENT_URL }), 89 + { expirationTtl: STATE_TTL_SECONDS }, 90 + ); 91 + 92 + // Set subscribe_ctx cookie so the callback can retrieve it 93 + const isLocalhost = c.env.CLIENT_URL.includes("localhost"); 94 + setCookie(c, "subscribe_ctx", ctxKey, { 95 + httpOnly: true, 96 + secure: !isLocalhost, 97 + sameSite: "Lax", 98 + path: "/", 99 + maxAge: STATE_TTL_SECONDS, 100 + }); 101 + 102 + // Initiate OAuth via the shared client 103 + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 104 + const authUrl = await client.authorize(handle, { 105 + scope: "atproto transition:generic", 106 + }); 107 + 108 + return c.redirect(authUrl.toString(), 302); 109 + } catch (err) { 110 + const message = 111 + err instanceof Error ? err.message : "An unexpected error occurred."; 112 + return c.html( 113 + renderPage( 114 + "Subscribe with Bluesky", 115 + renderForm(pub, returnUrl, message), 116 + ), 117 + 400, 118 + ); 119 + } 120 + }); 121 + 122 + // ============================================================================ 123 + // HTML helpers 124 + // ============================================================================ 125 + 126 + function renderPage(title: string, content: string): string { 127 + return `<!DOCTYPE html> 128 + <html lang="en"> 129 + <head> 130 + <meta charset="UTF-8" /> 131 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 132 + <title>${escapeHtml(title)} โ€” Sequoia</title> 133 + <style> 134 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 135 + body { 136 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 137 + background: #F5F3EF; 138 + color: #2C2C2C; 139 + min-height: 100vh; 140 + display: flex; 141 + align-items: center; 142 + justify-content: center; 143 + padding: 1.5rem; 144 + } 145 + .card { 146 + background: #ffffff; 147 + border-radius: 12px; 148 + padding: 2.5rem; 149 + max-width: 480px; 150 + width: 100%; 151 + box-shadow: 0 1px 3px rgba(44, 44, 44, 0.1), 0 4px 16px rgba(44, 44, 44, 0.06); 152 + } 153 + .logo { 154 + display: flex; 155 + align-items: center; 156 + gap: 0.625rem; 157 + margin-bottom: 2rem; 158 + text-decoration: none; 159 + color: inherit; 160 + } 161 + .logo-text { 162 + font-size: 1.25rem; 163 + font-weight: 600; 164 + color: #3A5A40; 165 + } 166 + h1 { 167 + font-size: 1.375rem; 168 + font-weight: 600; 169 + margin-bottom: 0.5rem; 170 + color: #2C2C2C; 171 + } 172 + .subtitle { 173 + color: #6B6B6B; 174 + font-size: 0.9375rem; 175 + margin-bottom: 1.75rem; 176 + line-height: 1.5; 177 + } 178 + label { 179 + display: block; 180 + font-size: 0.875rem; 181 + font-weight: 500; 182 + margin-bottom: 0.375rem; 183 + color: #4A4A4A; 184 + } 185 + input[type="text"] { 186 + width: 100%; 187 + padding: 0.625rem 0.875rem; 188 + border: 1px solid #D5D1C8; 189 + border-radius: 8px; 190 + font-size: 1rem; 191 + font-family: inherit; 192 + color: #2C2C2C; 193 + background: #ffffff; 194 + transition: border-color 0.15s ease, box-shadow 0.15s ease; 195 + margin-bottom: 1.25rem; 196 + } 197 + input[type="text"]:focus { 198 + outline: none; 199 + border-color: #3A5A40; 200 + box-shadow: 0 0 0 3px rgba(58, 90, 64, 0.12); 201 + } 202 + .btn { 203 + width: 100%; 204 + display: flex; 205 + align-items: center; 206 + justify-content: center; 207 + gap: 0.5rem; 208 + padding: 0.75rem 1.5rem; 209 + background: #3A5A40; 210 + color: #ffffff; 211 + border: none; 212 + border-radius: 8px; 213 + font-size: 1rem; 214 + font-weight: 500; 215 + cursor: pointer; 216 + font-family: inherit; 217 + transition: background-color 0.15s ease; 218 + } 219 + .btn:hover { background: #2E4832; } 220 + .btn svg { width: 1.125rem; height: 1.125rem; } 221 + .error-msg { 222 + background: #fef2f2; 223 + border: 1px solid #fecaca; 224 + color: #dc2626; 225 + border-radius: 8px; 226 + padding: 0.75rem 1rem; 227 + font-size: 0.875rem; 228 + margin-bottom: 1.25rem; 229 + } 230 + .error-box { 231 + text-align: center; 232 + color: #6B6B6B; 233 + } 234 + .footer { 235 + margin-top: 2rem; 236 + padding-top: 1.25rem; 237 + border-top: 1px solid #E5E2DB; 238 + text-align: center; 239 + font-size: 0.8125rem; 240 + color: #8B8B8B; 241 + } 242 + .footer a { color: #3A5A40; text-decoration: none; } 243 + .footer a:hover { text-decoration: underline; } 244 + </style> 245 + </head> 246 + <body> 247 + <div class="card"> 248 + <a href="https://sequoia.pub" class="logo"> 249 + <svg width="28" height="28" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> 250 + <rect width="100" height="100" rx="20" fill="#3A5A40"/> 251 + <path d="M50 15 L72 40 L64 40 L64 85 L36 85 L36 40 L28 40 Z" fill="#ffffff"/> 252 + </svg> 253 + <span class="logo-text">Sequoia</span> 254 + </a> 255 + ${content} 256 + <div class="footer"> 257 + Powered by <a href="https://sequoia.pub">Sequoia</a> &amp; the <a href="https://atproto.com">AT Protocol</a> 258 + </div> 259 + </div> 260 + </body> 261 + </html>`; 262 + } 263 + 264 + const BSKY_ICON = `<svg viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 265 + <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"/> 266 + </svg>`; 267 + 268 + function renderForm(pub: string, returnUrl: string, errorMsg?: string): string { 269 + const errorHtml = errorMsg 270 + ? `<div class="error-msg">${escapeHtml(errorMsg)}</div>` 271 + : ""; 272 + 273 + return ` 274 + <h1>Subscribe with Bluesky</h1> 275 + <p class="subtitle">Enter your Bluesky handle to subscribe to this publication via the AT Protocol.</p> 276 + ${errorHtml} 277 + <form method="POST" action="/subscribe"> 278 + <input type="hidden" name="pub" value="${escapeHtml(pub)}" /> 279 + <input type="hidden" name="return" value="${escapeHtml(returnUrl)}" /> 280 + <label for="handle">Your Bluesky handle</label> 281 + <input 282 + type="text" 283 + id="handle" 284 + name="handle" 285 + placeholder="you.bsky.social" 286 + autocomplete="username" 287 + autocapitalize="none" 288 + spellcheck="false" 289 + required 290 + /> 291 + <button type="submit" class="btn"> 292 + ${BSKY_ICON} 293 + Subscribe 294 + </button> 295 + </form>`; 296 + } 297 + 298 + function renderError(message: string): string { 299 + return ` 300 + <div class="error-box"> 301 + <p>${escapeHtml(message)}</p> 302 + </div>`; 303 + } 304 + 305 + function escapeHtml(text: string): string { 306 + return text 307 + .replace(/&/g, "&amp;") 308 + .replace(/</g, "&lt;") 309 + .replace(/>/g, "&gt;") 310 + .replace(/"/g, "&quot;") 311 + .replace(/'/g, "&#x27;"); 312 + } 313 + 314 + export default subscribe;
+1
docs/vocs.config.ts
··· 34 34 { text: "Setup", link: "/setup" }, 35 35 { text: "Publishing", link: "/publishing" }, 36 36 { text: "Comments", link: "/comments" }, 37 + { text: "Subscribe Button", link: "/subscribe" }, 37 38 { text: "Verifying", link: "/verifying" }, 38 39 { text: "Workflows", link: "/workflows" }, 39 40 ],
+1 -1
docs/wrangler.toml
··· 8 8 binding = "ASSETS" 9 9 not_found_handling = "single-page-application" 10 10 html_handling = "auto-trailing-slash" 11 - run_worker_first = ["/api/*", "/oauth/*"] 11 + run_worker_first = ["/api/*", "/oauth/*", "/subscribe/*"] 12 12 13 13 [[kv_namespaces]] 14 14 binding = "SEQUOIA_SESSIONS"
+29 -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 = ["sequoia-comments", "sequoia-subscribe"]; 18 + 19 + function buildUsageNote(componentName: string, componentsDir: string): string { 20 + const scriptTag = `<script type="module" src="${componentsDir}/${componentName}.js"></script>`; 21 + const elementTag = `<${componentName}></${componentName}>`; 22 + 23 + if (componentName === "sequoia-subscribe") { 24 + return ( 25 + `Add to your HTML:\n\n` + 26 + `${scriptTag}\n` + 27 + `${elementTag}\n\n` + 28 + `The component discovers the publication URI from (in order):\n` + 29 + ` 1. publication-uri attribute on the element\n` + 30 + ` 2. <link rel="site.standard.publication" href="at://..."> in your page head\n` + 31 + ` 3. /.well-known/site.standard.publication at your site root` 32 + ); 33 + } 34 + 35 + return ( 36 + `Add to your HTML:\n\n` + 37 + `${scriptTag}\n` + 38 + `${elementTag}\n\n` + 39 + `The component will automatically read the document URI from:\n` + 40 + `<link rel="site.standard.document" href="at://">` 41 + ); 42 + } 18 43 19 44 export const addCommand = command({ 20 45 name: "add", ··· 142 167 process.exit(1); 143 168 } 144 169 145 - // Show usage instructions 146 - note( 147 - `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 - ); 170 + // Show component-specific usage instructions 171 + const usageNote = buildUsageNote(componentName, componentsDir); 172 + note(usageNote, "Usage"); 154 173 155 174 outro(`${componentName} added successfully!`); 156 175 },
+377
packages/cli/src/components/sequoia-subscribe.js
··· 1 + /** 2 + * Sequoia Subscribe - A Bluesky-powered subscribe button 3 + * 4 + * A self-contained Web Component that lets readers subscribe to AT Protocol 5 + * publications directly from your website via OAuth. 6 + * 7 + * Usage: 8 + * <sequoia-subscribe></sequoia-subscribe> 9 + * 10 + * The component looks for a publication URI in three places (in priority order): 11 + * 1. The `publication-uri` attribute on the element 12 + * 2. A <link rel="site.standard.publication" href="at://..."> tag in the document head 13 + * 3. Fetch /.well-known/site.standard.publication from current origin 14 + * 15 + * Attributes: 16 + * - publication-uri: AT URI of the publication (optional if discovered automatically) 17 + * - callback-url: Override callback URL (default: https://sequoia.pub/subscribe) 18 + * - hide: Set to "auto" to hide if no publication URI found 19 + * - label: Button label text (default: "Subscribe with Bluesky") 20 + * 21 + * CSS Custom Properties: 22 + * - --sequoia-fg-color: Text color (default: #1f2937) 23 + * - --sequoia-bg-color: Background color (default: #ffffff) 24 + * - --sequoia-border-color: Border color (default: #e5e7eb) 25 + * - --sequoia-accent-color: Accent/button color (default: #2563eb) 26 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 27 + * - --sequoia-border-radius: Border radius (default: 8px) 28 + * 29 + * Events: 30 + * - sequoia-subscribed: Fired when subscription is confirmed. detail: { publicationUri } 31 + * - sequoia-subscribe-error: Fired when an error occurs. detail: { message } 32 + */ 33 + 34 + // ============================================================================ 35 + // Styles 36 + // ============================================================================ 37 + 38 + const 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-container { 51 + display: inline-block; 52 + } 53 + 54 + .sequoia-subscribe-button { 55 + display: inline-flex; 56 + align-items: center; 57 + gap: 0.5rem; 58 + padding: 0.5rem 1.125rem; 59 + background: var(--sequoia-accent-color, #2563eb); 60 + color: #ffffff; 61 + border: none; 62 + border-radius: var(--sequoia-border-radius, 8px); 63 + font-size: 0.9375rem; 64 + font-weight: 500; 65 + cursor: pointer; 66 + text-decoration: none; 67 + transition: background-color 0.15s ease, opacity 0.15s ease; 68 + font-family: inherit; 69 + } 70 + 71 + .sequoia-subscribe-button:hover:not(:disabled) { 72 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 73 + } 74 + 75 + .sequoia-subscribe-button:disabled { 76 + opacity: 0.7; 77 + cursor: not-allowed; 78 + } 79 + 80 + .sequoia-subscribe-button svg { 81 + width: 1.125rem; 82 + height: 1.125rem; 83 + flex-shrink: 0; 84 + } 85 + 86 + .sequoia-subscribe-button--success { 87 + background: #16a34a; 88 + } 89 + 90 + .sequoia-subscribe-button--success:hover:not(:disabled) { 91 + background: color-mix(in srgb, #16a34a 85%, black); 92 + } 93 + 94 + .sequoia-error-message { 95 + display: inline-block; 96 + font-size: 0.8125rem; 97 + color: #dc2626; 98 + margin-top: 0.375rem; 99 + } 100 + 101 + .sequoia-loading-spinner { 102 + display: inline-block; 103 + width: 1rem; 104 + height: 1rem; 105 + border: 2px solid rgba(255, 255, 255, 0.4); 106 + border-top-color: #ffffff; 107 + border-radius: 50%; 108 + animation: sequoia-spin 0.8s linear infinite; 109 + flex-shrink: 0; 110 + } 111 + 112 + @keyframes sequoia-spin { 113 + to { transform: rotate(360deg); } 114 + } 115 + `; 116 + 117 + // ============================================================================ 118 + // Icons 119 + // ============================================================================ 120 + 121 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 122 + <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"/> 123 + </svg>`; 124 + 125 + const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 126 + <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"/> 127 + </svg>`; 128 + 129 + // ============================================================================ 130 + // Helpers 131 + // ============================================================================ 132 + 133 + function escapeHtml(text) { 134 + return text 135 + .replace(/&/g, "&amp;") 136 + .replace(/</g, "&lt;") 137 + .replace(/>/g, "&gt;") 138 + .replace(/"/g, "&quot;"); 139 + } 140 + 141 + // ============================================================================ 142 + // Web Component 143 + // ============================================================================ 144 + 145 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 146 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 147 + 148 + class SequoiaSubscribe extends BaseElement { 149 + constructor() { 150 + super(); 151 + const shadow = this.attachShadow({ mode: "open" }); 152 + 153 + const styleTag = document.createElement("style"); 154 + shadow.appendChild(styleTag); 155 + styleTag.innerText = styles; 156 + 157 + const container = document.createElement("div"); 158 + shadow.appendChild(container); 159 + container.className = "sequoia-subscribe-container"; 160 + container.part = "container"; 161 + 162 + this.subscribeContainer = container; 163 + this.state = { type: "loading" }; 164 + } 165 + 166 + static get observedAttributes() { 167 + return ["publication-uri", "callback-url", "hide", "label"]; 168 + } 169 + 170 + connectedCallback() { 171 + // Check for return state from OAuth callback first 172 + const returnState = this._checkReturnState(); 173 + if (returnState) { 174 + this.state = returnState; 175 + this.render(); 176 + // Auto-clear feedback after 5 seconds, then rediscover 177 + setTimeout(() => { 178 + const url = new URL(window.location.href); 179 + url.searchParams.delete("subscribed"); 180 + url.searchParams.delete("subscribe_error"); 181 + history.replaceState(null, "", url.toString()); 182 + this.discover(); 183 + }, 5000); 184 + return; 185 + } 186 + this.discover(); 187 + } 188 + 189 + attributeChangedCallback() { 190 + if (this.isConnected) { 191 + this.discover(); 192 + } 193 + } 194 + 195 + _checkReturnState() { 196 + if (typeof window === "undefined") return null; 197 + const params = new URLSearchParams(window.location.search); 198 + if (params.get("subscribed") === "true") { 199 + const publicationUri = params.get("pub") ?? undefined; 200 + this.dispatchEvent( 201 + new CustomEvent("sequoia-subscribed", { 202 + detail: { publicationUri }, 203 + bubbles: true, 204 + composed: true, 205 + }), 206 + ); 207 + return { type: "subscribed" }; 208 + } 209 + const errMsg = params.get("subscribe_error"); 210 + if (errMsg) { 211 + const message = decodeURIComponent(errMsg); 212 + this.dispatchEvent( 213 + new CustomEvent("sequoia-subscribe-error", { 214 + detail: { message }, 215 + bubbles: true, 216 + composed: true, 217 + }), 218 + ); 219 + return { type: "error", message }; 220 + } 221 + return null; 222 + } 223 + 224 + get publicationUri() { 225 + return this.getAttribute("publication-uri"); 226 + } 227 + 228 + get callbackUrl() { 229 + return ( 230 + this.getAttribute("callback-url") || "https://sequoia.pub/subscribe" 231 + ); 232 + } 233 + 234 + get hideAuto() { 235 + return this.getAttribute("hide") === "auto"; 236 + } 237 + 238 + get label() { 239 + return this.getAttribute("label") ?? "Subscribe with Bluesky"; 240 + } 241 + 242 + async discover() { 243 + this.state = { type: "loading" }; 244 + this.render(); 245 + 246 + // 1. Check attribute 247 + const attrUri = this.publicationUri; 248 + if (attrUri) { 249 + this.state = { type: "idle", pubUri: attrUri }; 250 + this.render(); 251 + return; 252 + } 253 + 254 + // 2. Check <link rel="site.standard.publication"> tag 255 + const linkTag = document.querySelector( 256 + 'link[rel="site.standard.publication"]', 257 + ); 258 + const linkHref = linkTag?.getAttribute("href"); 259 + if (linkHref?.startsWith("at://")) { 260 + this.state = { type: "idle", pubUri: linkHref }; 261 + this.render(); 262 + return; 263 + } 264 + 265 + // 3. Fetch /.well-known/site.standard.publication 266 + try { 267 + const resp = await fetch("/.well-known/site.standard.publication"); 268 + if (resp.ok) { 269 + const text = await resp.text(); 270 + const uri = text.trim(); 271 + if (uri.startsWith("at://")) { 272 + this.state = { type: "idle", pubUri: uri }; 273 + this.render(); 274 + return; 275 + } 276 + } 277 + } catch { 278 + // Network error or not found - fall through to no-publication 279 + } 280 + 281 + this.state = { type: "no-publication" }; 282 + this.render(); 283 + } 284 + 285 + _onSubscribeClick() { 286 + if (this.state.type !== "idle") return; 287 + const pubUri = this.state.pubUri; 288 + const returnUrl = window.location.href; 289 + 290 + const url = new URL(this.callbackUrl); 291 + url.searchParams.set("pub", pubUri); 292 + url.searchParams.set("return", returnUrl); 293 + 294 + this.state = { type: "redirecting" }; 295 + this.render(); 296 + window.location.href = url.toString(); 297 + } 298 + 299 + render() { 300 + switch (this.state.type) { 301 + case "loading": 302 + this.subscribeContainer.innerHTML = ` 303 + <button class="sequoia-subscribe-button" disabled part="button"> 304 + <span class="sequoia-loading-spinner"></span> 305 + ${escapeHtml(this.label)} 306 + </button> 307 + `; 308 + break; 309 + 310 + case "no-publication": 311 + if (this.hideAuto) { 312 + this.subscribeContainer.innerHTML = ""; 313 + this.style.display = "none"; 314 + } else { 315 + this.subscribeContainer.innerHTML = ` 316 + <button class="sequoia-subscribe-button" disabled part="button"> 317 + ${BLUESKY_ICON} 318 + ${escapeHtml(this.label)} 319 + </button> 320 + `; 321 + } 322 + break; 323 + 324 + case "idle": { 325 + const btn = document.createElement("button"); 326 + btn.className = "sequoia-subscribe-button"; 327 + btn.setAttribute("part", "button"); 328 + btn.innerHTML = `${BLUESKY_ICON} ${escapeHtml(this.label)}`; 329 + btn.addEventListener("click", () => this._onSubscribeClick()); 330 + this.subscribeContainer.innerHTML = ""; 331 + this.subscribeContainer.appendChild(btn); 332 + break; 333 + } 334 + 335 + case "redirecting": 336 + this.subscribeContainer.innerHTML = ` 337 + <button class="sequoia-subscribe-button" disabled part="button"> 338 + <span class="sequoia-loading-spinner"></span> 339 + Redirecting... 340 + </button> 341 + `; 342 + break; 343 + 344 + case "subscribed": 345 + this.subscribeContainer.innerHTML = ` 346 + <button class="sequoia-subscribe-button sequoia-subscribe-button--success" disabled part="button"> 347 + ${CHECK_ICON} 348 + Subscribed 349 + </button> 350 + `; 351 + break; 352 + 353 + case "error": 354 + this.subscribeContainer.innerHTML = ` 355 + <div> 356 + <button class="sequoia-subscribe-button" part="button" type="button"> 357 + ${BLUESKY_ICON} 358 + ${escapeHtml(this.label)} 359 + </button> 360 + <span class="sequoia-error-message" part="error">Failed to subscribe: ${escapeHtml(this.state.message || "Unknown error")}</span> 361 + </div> 362 + `; 363 + this.subscribeContainer 364 + .querySelector("button") 365 + .addEventListener("click", () => this._onSubscribeClick()); 366 + break; 367 + } 368 + } 369 + } 370 + 371 + // Register the custom element 372 + if (typeof customElements !== "undefined") { 373 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 374 + } 375 + 376 + // Export for module usage 377 + export { SequoiaSubscribe };

History

1 round 1 comment
sign up or login to add to the discussion
2 commits
expand
feat: Add sequoia-subscribe web component for publication subscriptions
fix: implementing 'label', 'sequoia-subscribed', 'sequoia-subscribe-error' and others from https://tangled.org/heaths.dev/sequoia/tree/issue16
expand 1 comment

Since you already picked up publication-url from my work, stick with a single term. Instead of callback-url, it should be callback-uri - same as the existing comments component.

closed without merging