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
+1169 -68
Diff #3
+21 -6
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.19.0", "", { "dependencies": { "@atproto/common-web": "^0.4.17", "@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-7u/EGgkIj4bbslGer2RMQPtMWCPvREcpH0mVagaf5om+NcPzUIZeIacWKANVv95BdMJ7jlcHS7xrkEMPmg2dFw=="], 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 ··· 182 183 183 184 "@clack/prompts": ["@clack/prompts@1.0.0", "", { "dependencies": { "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A=="], 184 185 185 - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260228.0", "", {}, "sha512-9LfRg93ncQq6Oc4MFpqGSs+PmPhqWvg8TspXwbiYNR201IhXB4WqHR/aTSudPI0ujsf/NLc8E9fF3C+aA2g8KQ=="], 186 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260303.0", "", {}, "sha512-soUlr4NJVkh5dR09RwtziTMbBQ+lbdoEesTGw8WUlvmnQ2M4h7CmJzAjC6a7IivUodiiCSjbLcGV/8PyZpvZkA=="], 186 187 187 188 "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], 188 189 ··· 962 963 963 964 "hastscript": ["hastscript@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw=="], 964 965 965 - "hono": ["hono@4.12.1", "", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="], 966 + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], 966 967 967 968 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 968 969 ··· 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 }
+191
docs/docs/pages/subscribe.mdx
··· 1 + # Subscribe 2 + 3 + Sequoia provides a subscribe button web component that lets your readers subscribe to your publication directly from your site using their Bluesky account. 4 + 5 + ## Setup 6 + 7 + Run the following command in your project to install the subscribe web component. It will ask you where you would like to store the component file. 8 + 9 + ```bash [Terminal] 10 + sequoia add sequoia-subscribe 11 + ``` 12 + 13 + The component will look for your publication AT URI from your site's `/.well-known/site.standard.publication` endpoint automatically, so no additional configuration is required for most setups. 14 + 15 + ## Usage 16 + 17 + Since `sequoia-subscribe` is a standard Web Component, it works with any framework. Choose your setup below: 18 + 19 + :::code-group 20 + 21 + ```html [HTML] 22 + <body> 23 + <h1>My Publication</h1> 24 + <!--Content--> 25 + 26 + <sequoia-subscribe></sequoia-subscribe> 27 + <script type="module" src="./src/components/sequoia-subscribe.js"></script> 28 + </body> 29 + ``` 30 + 31 + ```tsx [React] 32 + // Import the component (registers the custom element) 33 + import './components/sequoia-subscribe.js'; 34 + 35 + function HomePage() { 36 + return ( 37 + <main> 38 + <h1>My Publication</h1> 39 + {/* Content */} 40 + <sequoia-subscribe /> 41 + </main> 42 + ); 43 + } 44 + ``` 45 + 46 + ```vue [Vue] 47 + <script setup> 48 + import './components/sequoia-subscribe.js'; 49 + </script> 50 + 51 + <template> 52 + <main> 53 + <h1>My Publication</h1> 54 + <!-- Content --> 55 + <sequoia-subscribe /> 56 + </main> 57 + </template> 58 + ``` 59 + 60 + ```svelte [Svelte] 61 + <script> 62 + import './components/sequoia-subscribe.js'; 63 + </script> 64 + 65 + <main> 66 + <h1>My Publication</h1> 67 + <!-- Content --> 68 + <sequoia-subscribe /> 69 + </main> 70 + ``` 71 + 72 + ```astro [Astro] 73 + <main> 74 + <h1>My Publication</h1> 75 + <!-- Content --> 76 + <sequoia-subscribe /> 77 + <script> 78 + import './components/sequoia-subscribe.js'; 79 + </script> 80 + </main> 81 + ``` 82 + 83 + ::: 84 + 85 + ### TypeScript Support 86 + 87 + If you're using TypeScript with React, add this type declaration to avoid JSX errors: 88 + 89 + ```ts [custom-elements.d.ts] 90 + declare namespace JSX { 91 + interface IntrinsicElements { 92 + 'sequoia-subscribe': React.DetailedHTMLProps< 93 + React.HTMLAttributes<HTMLElement> & { 94 + 'publication-uri'?: string; 95 + 'callback-uri'?: string; 96 + label?: string; 97 + hide?: string; 98 + }, 99 + HTMLElement 100 + >; 101 + } 102 + } 103 + ``` 104 + 105 + ### Vue Configuration 106 + 107 + For Vue, you may need to configure the compiler to recognize custom elements: 108 + 109 + ```ts [vite.config.ts] 110 + export default defineConfig({ 111 + plugins: [ 112 + vue({ 113 + template: { 114 + compilerOptions: { 115 + isCustomElement: (tag) => tag === 'sequoia-subscribe' 116 + } 117 + } 118 + }) 119 + ] 120 + }); 121 + ``` 122 + 123 + ## Configuration 124 + 125 + The subscribe web component has several configuration options available. 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 Protocol URI for the publication. Optional if a `/.well-known/site.standard.publication` endpoint exists on the host site. | 134 + | `callback-uri` | `string` | `https://sequoia.pub/subscribe` | Redirect URI used for the OAuth authentication flow. | 135 + | `label` | `string` | `Subscribe on Bluesky` | Button label text. | 136 + | `hide` | `string` | - | Set to `"auto"` to hide the component if no publication URI is detected. | 137 + 138 + ```html 139 + <!-- Use attributes for explicit control --> 140 + <sequoia-subscribe 141 + publication-uri="at://did:plc:example/site.standard.publication/abc123" 142 + label="Follow on Bluesky"> 143 + </sequoia-subscribe> 144 + ``` 145 + 146 + ### Events 147 + 148 + The component dispatches custom events you can listen to: 149 + 150 + | Event | Description | `detail` | 151 + |-------|-------------|----------| 152 + | `sequoia-subscribed` | Fired when the subscription is created successfully. | `{ publicationUri: string, recordUri: string }` | 153 + | `sequoia-subscribe-error` | Fired when the subscription fails. | `{ message: string }` | 154 + 155 + ```js 156 + const btn = document.querySelector('sequoia-subscribe'); 157 + 158 + btn.addEventListener('sequoia-subscribed', (e) => { 159 + console.log('Subscribed!', e.detail.recordUri); 160 + }); 161 + 162 + btn.addEventListener('sequoia-subscribe-error', (e) => { 163 + console.error('Subscription failed:', e.detail.message); 164 + }); 165 + ``` 166 + 167 + ### Styling 168 + 169 + The component uses CSS custom properties for theming. Set these in your `:root` or parent element to customize the appearance: 170 + 171 + | CSS Property | Default | Description | 172 + |--------------|---------|-------------| 173 + | `--sequoia-fg-color` | `#1f2937` | Text color | 174 + | `--sequoia-bg-color` | `#ffffff` | Background color | 175 + | `--sequoia-border-color` | `#e5e7eb` | Border color | 176 + | `--sequoia-accent-color` | `#2563eb` | Button background color | 177 + | `--sequoia-secondary-color` | `#6b7280` | Secondary text color | 178 + | `--sequoia-border-radius` | `8px` | Border radius for the button | 179 + 180 + ### Example: Match Site Theme 181 + 182 + ```css 183 + :root { 184 + --sequoia-accent-color: #3A5A40; 185 + --sequoia-border-radius: 6px; 186 + --sequoia-bg-color: #F5F3EF; 187 + --sequoia-fg-color: #2C2C2C; 188 + --sequoia-border-color: #D5D1C8; 189 + --sequoia-secondary-color: #8B7355; 190 + } 191 + ```
+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",
+12
docs/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 + import { cors } from "hono/cors"; 2 3 import auth from "./routes/auth"; 4 + import subscribe from "./routes/subscribe"; 5 + import "./lib/path-redirect"; 3 6 4 7 type Bindings = { 5 8 ASSETS: Fetcher; ··· 10 13 const app = new Hono<{ Bindings: Bindings }>(); 11 14 12 15 app.route("/oauth", auth); 16 + app.route("/subscribe", subscribe); 17 + app.use("/subscribe", cors({ 18 + origin: (origin) => origin, 19 + credentials: true, 20 + })); 21 + app.use("/subscribe/*", cors({ 22 + origin: (origin) => origin, 23 + credentials: true, 24 + })); 13 25 14 26 app.get("/api/health", (c) => { 15 27 return c.json({ status: "ok" });
+1 -1
docs/src/lib/oauth-client.ts
··· 19 19 redirect_uris: [redirectUri], 20 20 grant_types: ["authorization_code", "refresh_token"], 21 21 response_types: ["code"], 22 - scope: "atproto transition:generic", 22 + scope: "atproto site.standard.graph.subscription", 23 23 token_endpoint_auth_method: "none", 24 24 application_type: "web", 25 25 dpop_bound_access_tokens: true,
+51
docs/src/lib/path-redirect.ts
··· 1 + // Cloudflare Workers compatibility patches for @atproto libraries. 2 + // 3 + // 1. Workers don't support `redirect: 'error'` — simulate it with 'manual'. 4 + // 2. Workers don't support the standard `cache` option in Request — strip it. 5 + 6 + function sanitizeInit(init?: RequestInit): RequestInit | undefined { 7 + if (!init) return init; 8 + const { cache, redirect, ...rest } = init; 9 + return { 10 + ...rest, 11 + // Workers only support 'follow' and 'manual' 12 + redirect: redirect === "error" ? "manual" : redirect, 13 + // Workers don't support standard cache modes — omit entirely 14 + ...(cache ? {} : {}), 15 + }; 16 + } 17 + 18 + const errorRedirectRequests = new WeakSet<Request>(); 19 + const OriginalRequest = globalThis.Request; 20 + 21 + globalThis.Request = class extends OriginalRequest { 22 + constructor( 23 + input: RequestInfo | URL, 24 + init?: RequestInit, 25 + ) { 26 + super(input, sanitizeInit(init)); 27 + if (init?.redirect === "error") { 28 + errorRedirectRequests.add(this); 29 + } 30 + } 31 + } as typeof Request; 32 + 33 + const originalFetch = globalThis.fetch; 34 + globalThis.fetch = (async ( 35 + input: RequestInfo | URL, 36 + init?: RequestInit, 37 + ): Promise<Response> => { 38 + const cleanInit = sanitizeInit(init); 39 + const response = await originalFetch(input, cleanInit); 40 + 41 + // Simulate redirect: 'error' — throw on 3xx 42 + const wantsRedirectError = 43 + init?.redirect === "error" || 44 + (input instanceof Request && errorRedirectRequests.has(input)); 45 + 46 + if (wantsRedirectError && response.status >= 300 && response.status < 400) { 47 + throw new TypeError("unexpected redirect"); 48 + } 49 + 50 + return response; 51 + }) as typeof fetch;
+48 -20
docs/src/lib/session.ts
··· 1 1 import type { Context } from "hono"; 2 + import { deleteCookie, getCookie, setCookie } from "hono/cookie"; 2 3 3 4 const SESSION_COOKIE_NAME = "session_id"; 5 + const RETURN_TO_COOKIE_NAME = "login_return_to"; 4 6 const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds 7 + const RETURN_TO_TTL = 600; // 10 minutes in seconds 8 + 9 + function baseCookieOptions(clientUrl: string) { 10 + const isLocalhost = clientUrl.includes("localhost"); 11 + return { 12 + httpOnly: true as const, 13 + sameSite: "Lax" as const, 14 + path: "/", 15 + ...(isLocalhost ? {} : { domain: ".sequoia.pub", secure: true }), 16 + }; 17 + } 5 18 6 19 /** 7 20 * Get DID from session cookie 8 21 */ 9 22 export function getSessionDid(c: Context): string | null { 10 - const cookie = c.req.header("Cookie"); 11 - if (!cookie) return null; 12 - 13 - const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`)); 14 - return match ? decodeURIComponent(match[1]) : null; 23 + const value = getCookie(c, SESSION_COOKIE_NAME); 24 + return value ? decodeURIComponent(value) : null; 15 25 } 16 26 17 27 /** ··· 22 32 did: string, 23 33 clientUrl: string, 24 34 ): void { 25 - const isLocalhost = clientUrl.includes("localhost"); 26 - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 27 - const secure = isLocalhost ? "" : "; Secure"; 28 - 29 - c.header( 30 - "Set-Cookie", 31 - `${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`, 32 - ); 35 + setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), { 36 + ...baseCookieOptions(clientUrl), 37 + maxAge: SESSION_TTL, 38 + }); 33 39 } 34 40 35 41 /** 36 42 * Clear session cookie 37 43 */ 38 44 export function clearSessionCookie(c: Context, clientUrl: string): void { 39 - const isLocalhost = clientUrl.includes("localhost"); 40 - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 41 - const secure = isLocalhost ? "" : "; Secure"; 45 + deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl)); 46 + } 47 + 48 + /** 49 + * Get the post-OAuth return-to URL from the short-lived cookie 50 + */ 51 + export function getReturnToCookie(c: Context): string | null { 52 + const value = getCookie(c, RETURN_TO_COOKIE_NAME); 53 + return value ? decodeURIComponent(value) : null; 54 + } 42 55 43 - c.header( 44 - "Set-Cookie", 45 - `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`, 46 - ); 56 + /** 57 + * Set a short-lived cookie that redirects back after OAuth completes 58 + */ 59 + export function setReturnToCookie( 60 + c: Context, 61 + returnTo: string, 62 + clientUrl: string, 63 + ): void { 64 + setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), { 65 + ...baseCookieOptions(clientUrl), 66 + maxAge: RETURN_TO_TTL, 67 + }); 68 + } 69 + 70 + /** 71 + * Clear the return-to cookie 72 + */ 73 + export function clearReturnToCookie(c: Context, clientUrl: string): void { 74 + deleteCookie(c, RETURN_TO_COOKIE_NAME, baseCookieOptions(clientUrl)); 47 75 }
+10 -3
docs/src/routes/auth.ts
··· 4 4 getSessionDid, 5 5 setSessionCookie, 6 6 clearSessionCookie, 7 + getReturnToCookie, 8 + clearReturnToCookie, 7 9 } from "../lib/session"; 8 10 9 11 interface Env { ··· 25 27 redirect_uris: [redirectUri], 26 28 grant_types: ["authorization_code", "refresh_token"], 27 29 response_types: ["code"], 28 - scope: "atproto transition:generic", 30 + scope: "atproto site.standard.graph.subscription", 29 31 token_endpoint_auth_method: "none", 30 32 application_type: "web", 31 33 dpop_bound_access_tokens: true, ··· 42 44 43 45 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 44 46 const authUrl = await client.authorize(handle, { 45 - scope: "atproto transition:generic", 47 + scope: "atproto site.standard.graph.subscription", 46 48 }); 47 49 48 50 return c.redirect(authUrl.toString()); ··· 85 87 } 86 88 87 89 setSessionCookie(c, session.did, c.env.CLIENT_URL); 88 - return c.redirect(`${c.env.CLIENT_URL}/`); 90 + 91 + // If a subscribe flow set a return URL before initiating OAuth, honor it 92 + const returnTo = getReturnToCookie(c); 93 + clearReturnToCookie(c, c.env.CLIENT_URL); 94 + 95 + return c.redirect(returnTo ?? `${c.env.CLIENT_URL}/`); 89 96 } catch (error) { 90 97 console.error("Callback error:", error); 91 98 return c.redirect(`${c.env.CLIENT_URL}/?error=callback_failed`);
+308
docs/src/routes/subscribe.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { Hono } from "hono"; 3 + import { createOAuthClient } from "../lib/oauth-client"; 4 + import { getSessionDid, setReturnToCookie } from "../lib/session"; 5 + 6 + interface Env { 7 + ASSETS: Fetcher; 8 + SEQUOIA_SESSIONS: KVNamespace; 9 + CLIENT_URL: string; 10 + } 11 + 12 + // Cache the vocs-generated stylesheet href across requests (changes on rebuild). 13 + let _vocsStyleHref: string | null = null; 14 + 15 + async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise<string> { 16 + if (_vocsStyleHref) return _vocsStyleHref; 17 + try { 18 + const indexUrl = new URL("/", baseUrl).toString(); 19 + const res = await assets.fetch(indexUrl); 20 + const html = await res.text(); 21 + const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); 22 + if (match?.[1]) { 23 + _vocsStyleHref = match[1]; 24 + return match[1]; 25 + } 26 + } catch { 27 + // Fall back to the custom stylesheet which at least provides --sequoia-* vars 28 + } 29 + return "/styles.css"; 30 + } 31 + 32 + const subscribe = new Hono<{ Bindings: Env }>(); 33 + 34 + const COLLECTION = "site.standard.graph.subscription"; 35 + 36 + // ============================================================================ 37 + // Helpers 38 + // ============================================================================ 39 + 40 + /** 41 + * Scan the user's repo for an existing site.standard.graph.subscription 42 + * matching the given publication URI. Returns the record AT-URI if found. 43 + */ 44 + async function findExistingSubscription( 45 + agent: Agent, 46 + did: string, 47 + publicationUri: string, 48 + ): Promise<string | null> { 49 + let cursor: string | undefined; 50 + 51 + do { 52 + const result = await agent.com.atproto.repo.listRecords({ 53 + repo: did, 54 + collection: COLLECTION, 55 + limit: 100, 56 + cursor, 57 + }); 58 + 59 + for (const record of result.data.records) { 60 + const value = record.value as { publication?: string }; 61 + if (value.publication === publicationUri) { 62 + return record.uri; 63 + } 64 + } 65 + 66 + cursor = result.data.cursor; 67 + } while (cursor); 68 + 69 + return null; 70 + } 71 + 72 + // ============================================================================ 73 + // POST /subscribe 74 + // 75 + // Called via fetch() from the sequoia-subscribe web component. 76 + // Body JSON: { publicationUri: string } 77 + // 78 + // Responses: 79 + // 200 { subscribed: true, existing: boolean, recordUri: string } 80 + // 400 { error: string } 81 + // 401 { authenticated: false, subscribeUrl: string } 82 + // ============================================================================ 83 + 84 + subscribe.post("/", async (c) => { 85 + let publicationUri: string; 86 + try { 87 + const body = await c.req.json<{ publicationUri?: string }>(); 88 + publicationUri = body.publicationUri ?? ""; 89 + } catch { 90 + return c.json({ error: "Invalid JSON body" }, 400); 91 + } 92 + 93 + if (!publicationUri || !publicationUri.startsWith("at://")) { 94 + return c.json({ error: "Missing or invalid publicationUri" }, 400); 95 + } 96 + 97 + const did = getSessionDid(c); 98 + if (!did) { 99 + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 100 + return c.json({ authenticated: false, subscribeUrl }, 401); 101 + } 102 + 103 + try { 104 + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 105 + const session = await client.restore(did); 106 + const agent = new Agent(session); 107 + 108 + const existingUri = await findExistingSubscription(agent, did, publicationUri); 109 + if (existingUri) { 110 + return c.json({ subscribed: true, existing: true, recordUri: existingUri }); 111 + } 112 + 113 + const result = await agent.com.atproto.repo.createRecord({ 114 + repo: did, 115 + collection: COLLECTION, 116 + record: { 117 + $type: COLLECTION, 118 + publication: publicationUri, 119 + }, 120 + }); 121 + 122 + return c.json({ subscribed: true, existing: false, recordUri: result.data.uri }); 123 + } catch (error) { 124 + console.error("Subscribe POST error:", error); 125 + // Treat expired/missing session as unauthenticated 126 + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 127 + return c.json({ authenticated: false, subscribeUrl }, 401); 128 + } 129 + }); 130 + 131 + // ============================================================================ 132 + // GET /subscribe?publicationUri=at://... 133 + // 134 + // Full-page OAuth + subscription flow. Unauthenticated users land here after 135 + // the component redirects them, and authenticated users land here after the 136 + // OAuth callback (via the login_return_to cookie set in POST /subscribe/login). 137 + // ============================================================================ 138 + 139 + subscribe.get("/", async (c) => { 140 + const publicationUri = c.req.query("publicationUri"); 141 + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 142 + 143 + if (!publicationUri || !publicationUri.startsWith("at://")) { 144 + return c.html(renderError("Missing or invalid publication URI.", styleHref), 400); 145 + } 146 + 147 + const did = getSessionDid(c); 148 + if (!did) { 149 + return c.html(renderHandleForm(publicationUri, styleHref)); 150 + } 151 + 152 + try { 153 + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 154 + const session = await client.restore(did); 155 + const agent = new Agent(session); 156 + 157 + const existingUri = await findExistingSubscription(agent, did, publicationUri); 158 + if (existingUri) { 159 + return c.html(renderSuccess(publicationUri, existingUri, true, styleHref)); 160 + } 161 + 162 + const result = await agent.com.atproto.repo.createRecord({ 163 + repo: did, 164 + collection: COLLECTION, 165 + record: { 166 + $type: COLLECTION, 167 + publication: publicationUri, 168 + }, 169 + }); 170 + 171 + return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref)); 172 + } catch (error) { 173 + console.error("Subscribe GET error:", error); 174 + // Session expired - ask the user to sign in again 175 + return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again.")); 176 + } 177 + }); 178 + 179 + // ============================================================================ 180 + // POST /subscribe/login 181 + // 182 + // Handles the handle-entry form submission. Stores the return URL in a cookie 183 + // so the OAuth callback in auth.ts can redirect back to /subscribe after auth. 184 + // ============================================================================ 185 + 186 + subscribe.post("/login", async (c) => { 187 + const body = await c.req.parseBody(); 188 + const handle = (body["handle"] as string | undefined)?.trim(); 189 + const publicationUri = body["publicationUri"] as string | undefined; 190 + 191 + if (!handle || !publicationUri) { 192 + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 193 + return c.html(renderError("Missing handle or publication URI.", styleHref), 400); 194 + } 195 + 196 + const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 197 + setReturnToCookie(c, returnTo, c.env.CLIENT_URL); 198 + 199 + return c.redirect( 200 + `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, 201 + ); 202 + }); 203 + 204 + // ============================================================================ 205 + // HTML rendering 206 + // ============================================================================ 207 + 208 + function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string { 209 + const errorHtml = error 210 + ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` 211 + : ""; 212 + 213 + return page(` 214 + <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> 215 + <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> 216 + ${errorHtml} 217 + <form method="POST" action="/subscribe/login"> 218 + <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 219 + <input 220 + type="text" 221 + name="handle" 222 + placeholder="you.bsky.social" 223 + autocomplete="username" 224 + required 225 + autofocus 226 + /> 227 + <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> 228 + </form> 229 + `, styleHref); 230 + } 231 + 232 + function renderSuccess( 233 + publicationUri: string, 234 + recordUri: string, 235 + existing: boolean, 236 + styleHref: string, 237 + ): string { 238 + const msg = existing 239 + ? "You're already subscribed to this publication." 240 + : "You've successfully subscribed!"; 241 + const escapedPublicationUri = escapeHtml(publicationUri); 242 + const escapedRecordUri = escapeHtml(recordUri); 243 + return page(` 244 + <h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1> 245 + <p class="vocs_Paragraph">${msg}</p> 246 + <p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></small></p> 247 + <p class="vocs_Paragraph"><small>Record: <code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></small></p> 248 + `, styleHref); 249 + } 250 + 251 + function renderError(message: string, styleHref: string): string { 252 + return page(`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref); 253 + } 254 + 255 + function page(body: string, styleHref: string): string { 256 + return `<!DOCTYPE html> 257 + <html lang="en"> 258 + <head> 259 + <meta charset="UTF-8" /> 260 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 261 + <title>Sequoia · Subscribe</title> 262 + <link rel="stylesheet" href="${styleHref}" /> 263 + <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> 264 + <style> 265 + .page-container { 266 + max-width: calc(var(--vocs-content_width, 480px) / 1.6); 267 + margin: 4rem auto; 268 + padding: 0 var(--vocs-space_20, 1.25rem); 269 + } 270 + .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } 271 + .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } 272 + input[type="text"] { 273 + padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); 274 + border: 1px solid var(--vocs-color_border, #D5D1C8); 275 + border-radius: var(--vocs-borderRadius_6, 6px); 276 + margin-bottom: var(--vocs-space_20, 1.25rem); 277 + min-width: 30vh; 278 + width: 100%; 279 + font-size: var(--vocs-fontSize_16, 1rem); 280 + font-family: inherit; 281 + background: var(--vocs-color_background, #F5F3EF); 282 + color: var(--vocs-color_text, #2C2C2C); 283 + } 284 + input[type="text"]:focus { 285 + border-color: var(--vocs-color_borderAccent, #3A5A40); 286 + outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); 287 + outline-offset: 2px; 288 + } 289 + .error { color: var(--vocs-color_dangerText, #8B3A3A); } 290 + </style> 291 + </head> 292 + <body> 293 + <div class="page-container"> 294 + ${body} 295 + </div> 296 + </body> 297 + </html>`; 298 + } 299 + 300 + function escapeHtml(text: string): string { 301 + return text 302 + .replace(/&/g, "&amp;") 303 + .replace(/</g, "&lt;") 304 + .replace(/>/g, "&gt;") 305 + .replace(/"/g, "&quot;"); 306 + } 307 + 308 + 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", 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", "/subscribe/*"] 12 12 13 13 [[kv_namespaces]] 14 14 binding = "SEQUOIA_SESSIONS"
+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 },
-1
packages/cli/src/commands/publish.ts
··· 359 359 bskyPostRef = await createBlueskyPost(agent, { 360 360 title: post.frontmatter.title, 361 361 description: post.frontmatter.description, 362 - bskyPost: post.frontmatter.bskyPost, 363 362 canonicalUrl, 364 363 coverImage, 365 364 publishedAt: post.frontmatter.publishDate,
+464
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 + * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") 16 + * - label: Button label text (default: "Subscribe on Bluesky") 17 + * - hide: Set to "auto" to hide if no publication URI is detected 18 + * 19 + * CSS Custom Properties: 20 + * - --sequoia-fg-color: Text color (default: #1f2937) 21 + * - --sequoia-bg-color: Background color (default: #ffffff) 22 + * - --sequoia-border-color: Border color (default: #e5e7eb) 23 + * - --sequoia-accent-color: Accent/button color (default: #2563eb) 24 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 25 + * - --sequoia-border-radius: Border radius (default: 8px) 26 + * 27 + * Events: 28 + * - sequoia-subscribed: Fired when the subscription is created successfully. 29 + * detail: { publicationUri: string, recordUri: string } 30 + * - sequoia-subscribe-error: Fired when the subscription fails. 31 + * detail: { message: string } 32 + */ 33 + 34 + // ============================================================================ 35 + // Styles 36 + // ============================================================================ 37 + 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-button { 51 + display: inline-flex; 52 + align-items: center; 53 + gap: 0.375rem; 54 + padding: 0.5rem 1rem; 55 + background: var(--sequoia-accent-color, #2563eb); 56 + color: #ffffff; 57 + border: none; 58 + border-radius: var(--sequoia-border-radius, 8px); 59 + font-size: 0.875rem; 60 + font-weight: 500; 61 + cursor: pointer; 62 + text-decoration: none; 63 + transition: background-color 0.15s ease; 64 + font-family: inherit; 65 + } 66 + 67 + .sequoia-subscribe-button:hover:not(:disabled) { 68 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 69 + } 70 + 71 + .sequoia-subscribe-button:disabled { 72 + opacity: 0.6; 73 + cursor: not-allowed; 74 + } 75 + 76 + .sequoia-subscribe-button svg { 77 + width: 1rem; 78 + height: 1rem; 79 + flex-shrink: 0; 80 + } 81 + 82 + .sequoia-subscribe-button--success { 83 + background: #16a34a; 84 + } 85 + 86 + .sequoia-subscribe-button--success:hover:not(:disabled) { 87 + background: color-mix(in srgb, #16a34a 85%, black); 88 + } 89 + 90 + .sequoia-loading-spinner { 91 + display: inline-block; 92 + width: 1rem; 93 + height: 1rem; 94 + border: 2px solid rgba(255, 255, 255, 0.4); 95 + border-top-color: #ffffff; 96 + border-radius: 50%; 97 + animation: sequoia-spin 0.8s linear infinite; 98 + flex-shrink: 0; 99 + } 100 + 101 + @keyframes sequoia-spin { 102 + to { transform: rotate(360deg); } 103 + } 104 + 105 + .sequoia-error-message { 106 + display: inline-block; 107 + font-size: 0.8125rem; 108 + color: #dc2626; 109 + margin-top: 0.375rem; 110 + } 111 + `; 112 + 113 + // ============================================================================ 114 + // Icons 115 + // ============================================================================ 116 + 117 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 118 + <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"/> 119 + </svg>`; 120 + 121 + const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 122 + <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"/> 123 + </svg>`; 124 + 125 + // ============================================================================ 126 + // AT Protocol Functions 127 + // ============================================================================ 128 + 129 + /** 130 + * Resolve a DID to its PDS URL. 131 + * Supports did:plc and did:web methods. 132 + * @param {string} did - Decentralized Identifier 133 + * @returns {Promise<string>} PDS URL 134 + */ 135 + async function resolvePDS(did) { 136 + let pdsUrl; 137 + 138 + if (did.startsWith("did:plc:")) { 139 + const didDocUrl = `https://plc.directory/${did}`; 140 + const didDocResponse = await fetch(didDocUrl); 141 + if (!didDocResponse.ok) { 142 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 143 + } 144 + const didDoc = await didDocResponse.json(); 145 + 146 + const pdsService = didDoc.service?.find( 147 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 148 + ); 149 + pdsUrl = pdsService?.serviceEndpoint; 150 + } else if (did.startsWith("did:web:")) { 151 + const domain = did.replace("did:web:", ""); 152 + const didDocUrl = `https://${domain}/.well-known/did.json`; 153 + const didDocResponse = await fetch(didDocUrl); 154 + if (!didDocResponse.ok) { 155 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 156 + } 157 + const didDoc = await didDocResponse.json(); 158 + 159 + const pdsService = didDoc.service?.find( 160 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 161 + ); 162 + pdsUrl = pdsService?.serviceEndpoint; 163 + } else { 164 + throw new Error(`Unsupported DID method: ${did}`); 165 + } 166 + 167 + if (!pdsUrl) { 168 + throw new Error("Could not find PDS URL for user"); 169 + } 170 + 171 + return pdsUrl; 172 + } 173 + 174 + /** 175 + * Create a site.standard.graph.subscription record in the subscriber's PDS. 176 + * @param {string} did - DID of the subscriber 177 + * @param {string} accessToken - AT Protocol access token 178 + * @param {string} publicationUri - AT URI of the publication to subscribe to 179 + * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID 180 + */ 181 + async function createRecord(did, accessToken, publicationUri) { 182 + const pdsUrl = await resolvePDS(did); 183 + 184 + const collection = "site.standard.graph.subscription"; 185 + const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; 186 + const response = await fetch(url, { 187 + method: "POST", 188 + headers: { 189 + "Content-Type": "application/json", 190 + Authorization: `Bearer ${accessToken}`, 191 + }, 192 + body: JSON.stringify({ 193 + repo: did, 194 + collection, 195 + record: { 196 + $type: "site.standard.graph.subscription", 197 + publication: publicationUri, 198 + }, 199 + }), 200 + }); 201 + 202 + if (!response.ok) { 203 + const body = await response.json().catch(() => ({})); 204 + const message = body?.message ?? body?.error ?? `HTTP ${response.status}`; 205 + throw new Error(`Failed to create record: ${message}`); 206 + } 207 + 208 + const data = await response.json(); 209 + return { uri: data.uri, cid: data.cid }; 210 + } 211 + 212 + /** 213 + * Fetch the publication AT URI from the host site's well-known endpoint. 214 + * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 215 + * @returns {Promise<string>} Publication AT URI 216 + */ 217 + async function fetchPublicationUri(origin) { 218 + const base = origin ?? window.location.origin; 219 + const url = `${base}/.well-known/site.standard.publication`; 220 + const response = await fetch(url); 221 + if (!response.ok) { 222 + throw new Error( 223 + `Could not fetch publication URI: ${response.status}`, 224 + ); 225 + } 226 + 227 + // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 228 + const contentType = response.headers.get("content-type") ?? ""; 229 + if (contentType.includes("application/json")) { 230 + const data = await response.json(); 231 + const uri = data?.uri ?? data?.atUri ?? data?.publication; 232 + if (!uri) { 233 + throw new Error("Publication response did not contain a URI"); 234 + } 235 + return uri; 236 + } 237 + 238 + const text = (await response.text()).trim(); 239 + if (!text.startsWith("at://")) { 240 + throw new Error(`Unexpected publication URI format: ${text}`); 241 + } 242 + return text; 243 + } 244 + 245 + // ============================================================================ 246 + // Web Component 247 + // ============================================================================ 248 + 249 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 250 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 251 + 252 + class SequoiaSubscribe extends BaseElement { 253 + constructor() { 254 + super(); 255 + const shadow = this.attachShadow({ mode: "open" }); 256 + 257 + const styleTag = document.createElement("style"); 258 + styleTag.innerText = styles; 259 + shadow.appendChild(styleTag); 260 + 261 + const wrapper = document.createElement("div"); 262 + shadow.appendChild(wrapper); 263 + wrapper.part = "container"; 264 + 265 + this.wrapper = wrapper; 266 + this.state = { type: "idle" }; 267 + this.abortController = null; 268 + this.render(); 269 + } 270 + 271 + static get observedAttributes() { 272 + return ["publication-uri", "callback-uri", "label", "hide"]; 273 + } 274 + 275 + connectedCallback() { 276 + // Pre-check publication availability so hide="auto" can take effect 277 + if (!this.publicationUri) { 278 + this.checkPublication(); 279 + } 280 + } 281 + 282 + disconnectedCallback() { 283 + this.abortController?.abort(); 284 + } 285 + 286 + attributeChangedCallback() { 287 + // Reset to idle if attributes change after an error or success 288 + if ( 289 + this.state.type === "error" || 290 + this.state.type === "subscribed" || 291 + this.state.type === "no-publication" 292 + ) { 293 + this.state = { type: "idle" }; 294 + } 295 + this.render(); 296 + } 297 + 298 + get publicationUri() { 299 + return this.getAttribute("publication-uri") ?? null; 300 + } 301 + 302 + get callbackUri() { 303 + return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; 304 + } 305 + 306 + get label() { 307 + return this.getAttribute("label") ?? "Subscribe on Bluesky"; 308 + } 309 + 310 + get hide() { 311 + const hideAttr = this.getAttribute("hide"); 312 + return hideAttr === "auto"; 313 + } 314 + 315 + async checkPublication() { 316 + this.abortController?.abort(); 317 + this.abortController = new AbortController(); 318 + 319 + try { 320 + await fetchPublicationUri(); 321 + } catch { 322 + this.state = { type: "no-publication" }; 323 + this.render(); 324 + } 325 + } 326 + 327 + async handleClick() { 328 + if (this.state.type === "loading" || this.state.type === "subscribed") { 329 + return; 330 + } 331 + 332 + this.state = { type: "loading" }; 333 + this.render(); 334 + 335 + try { 336 + const publicationUri = 337 + this.publicationUri ?? (await fetchPublicationUri()); 338 + 339 + // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). 340 + // If the server reports the user isn't authenticated it returns a 341 + // subscribeUrl for the full-page OAuth + subscription flow. 342 + const response = await fetch(this.callbackUri, { 343 + method: "POST", 344 + headers: { "Content-Type": "application/json" }, 345 + credentials: "include", 346 + body: JSON.stringify({ publicationUri }), 347 + }); 348 + 349 + const data = await response.json(); 350 + 351 + if (response.status === 401 && data.authenticated === false) { 352 + // Redirect to the hosted subscribe page to complete OAuth 353 + window.location.href = data.subscribeUrl; 354 + return; 355 + } 356 + 357 + if (!response.ok) { 358 + throw new Error(data.error ?? `HTTP ${response.status}`); 359 + } 360 + 361 + const { recordUri } = data; 362 + this.state = { type: "subscribed", recordUri, publicationUri }; 363 + this.render(); 364 + 365 + this.dispatchEvent( 366 + new CustomEvent("sequoia-subscribed", { 367 + bubbles: true, 368 + composed: true, 369 + detail: { publicationUri, recordUri }, 370 + }), 371 + ); 372 + } catch (error) { 373 + // Don't overwrite state if we already navigated away 374 + if (this.state.type !== "loading") return; 375 + 376 + const message = 377 + error instanceof Error ? error.message : "Failed to subscribe"; 378 + this.state = { type: "error", message }; 379 + this.render(); 380 + 381 + this.dispatchEvent( 382 + new CustomEvent("sequoia-subscribe-error", { 383 + bubbles: true, 384 + composed: true, 385 + detail: { message }, 386 + }), 387 + ); 388 + } 389 + } 390 + 391 + render() { 392 + const { type } = this.state; 393 + 394 + if (type === "no-publication") { 395 + if (this.hide) { 396 + this.wrapper.innerHTML = ""; 397 + this.wrapper.style.display = "none"; 398 + } 399 + return; 400 + } 401 + 402 + const isLoading = type === "loading"; 403 + const isSubscribed = type === "subscribed"; 404 + 405 + const icon = isLoading 406 + ? `<span class="sequoia-loading-spinner"></span>` 407 + : isSubscribed 408 + ? CHECK_ICON 409 + : BLUESKY_ICON; 410 + 411 + const label = isSubscribed ? "Subscribed" : this.label; 412 + const buttonClass = [ 413 + "sequoia-subscribe-button", 414 + isSubscribed ? "sequoia-subscribe-button--success" : "", 415 + ] 416 + .filter(Boolean) 417 + .join(" "); 418 + 419 + const errorHtml = 420 + type === "error" 421 + ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 422 + : ""; 423 + 424 + this.wrapper.innerHTML = ` 425 + <button 426 + class="${buttonClass}" 427 + type="button" 428 + part="button" 429 + ${isLoading || isSubscribed ? "disabled" : ""} 430 + aria-label="${isSubscribed ? "Subscribed" : this.label}" 431 + > 432 + ${icon} 433 + ${label} 434 + </button> 435 + ${errorHtml} 436 + `; 437 + 438 + if (type !== "subscribed") { 439 + const btn = this.wrapper.querySelector("button"); 440 + btn?.addEventListener("click", () => this.handleClick()); 441 + } 442 + } 443 + } 444 + 445 + /** 446 + * Escape HTML special characters (no DOM dependency for SSR). 447 + * @param {string} text 448 + * @returns {string} 449 + */ 450 + function escapeHtml(text) { 451 + return text 452 + .replace(/&/g, "&amp;") 453 + .replace(/</g, "&lt;") 454 + .replace(/>/g, "&gt;") 455 + .replace(/"/g, "&quot;"); 456 + } 457 + 458 + // Register the custom element 459 + if (typeof customElements !== "undefined") { 460 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 461 + } 462 + 463 + // Export for module usage 464 + export { SequoiaSubscribe };
+39 -25
packages/cli/src/lib/atproto.ts
··· 328 328 textContent = stripMarkdownForText(post.content); 329 329 } 330 330 331 - // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 332 - const existingResponse = await agent.com.atproto.repo.getRecord({ 333 - repo: agent.did!, 334 - collection: collection!, 335 - rkey: rkey!, 336 - }); 337 - const existingRecord = existingResponse.data.value as Record<string, unknown>; 338 - 339 331 const record: Record<string, unknown> = { 340 - ...existingRecord, 341 332 $type: "site.standard.document", 342 333 title: post.frontmatter.title, 343 334 site: config.publicationUri, ··· 578 569 export interface CreateBlueskyPostOptions { 579 570 title: string; 580 571 description?: string; 581 - bskyPost?: string; 582 572 canonicalUrl: string; 583 573 coverImage?: BlobObject; 584 574 publishedAt: string; // Used as createdAt for the post ··· 622 612 agent: Agent, 623 613 options: CreateBlueskyPostOptions, 624 614 ): Promise<StrongRef> { 625 - const { title, description, bskyPost, canonicalUrl, coverImage, publishedAt } = options; 615 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 626 616 627 - // Build post text: title + description 617 + // Build post text: title + description + URL 628 618 // Max 300 graphemes for Bluesky posts 629 619 const MAX_GRAPHEMES = 300; 630 620 631 621 let postText: string; 622 + const urlPart = `\n\n${canonicalUrl}`; 623 + const urlGraphemes = countGraphemes(urlPart); 632 624 633 - if (bskyPost) { 634 - // Custom bsky post overrides any default behavior 635 - postText = bskyPost; 636 - } 637 - else if (description) { 638 - // Try: title + description 639 - const fullText = `${title}\n\n${description}`; 625 + if (description) { 626 + // Try: title + description + URL 627 + const fullText = `${title}\n\n${description}${urlPart}`; 640 628 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 641 629 postText = fullText; 642 630 } else { ··· 644 632 const availableForDesc = 645 633 MAX_GRAPHEMES - 646 634 countGraphemes(title) - 635 + countGraphemes("\n\n") - 636 + urlGraphemes - 647 637 countGraphemes("\n\n"); 648 638 if (availableForDesc > 10) { 649 639 const truncatedDesc = truncateToGraphemes( 650 640 description, 651 641 availableForDesc, 652 642 ); 653 - postText = `${title}\n\n${truncatedDesc}`; 643 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 654 644 } else { 655 - // Just title 656 - postText = `${title}`; 645 + // Just title + URL 646 + postText = `${title}${urlPart}`; 657 647 } 658 648 } 659 649 } else { 660 - // Just title 661 - postText = `${title}`; 650 + // Just title + URL 651 + postText = `${title}${urlPart}`; 662 652 } 663 653 664 - // Final truncation in case title or bskyPost are longer than expected 654 + // Final truncation if still too long (shouldn't happen but safety check) 665 655 if (countGraphemes(postText) > MAX_GRAPHEMES) { 666 656 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 667 657 } 668 658 659 + // Calculate byte indices for the URL facet 660 + const encoder = new TextEncoder(); 661 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 662 + const beforeUrl = postText.substring(0, urlStartInText); 663 + const byteStart = encoder.encode(beforeUrl).length; 664 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 665 + 666 + // Build facets for the URL link 667 + const facets = [ 668 + { 669 + index: { 670 + byteStart, 671 + byteEnd, 672 + }, 673 + features: [ 674 + { 675 + $type: "app.bsky.richtext.facet#link", 676 + uri: canonicalUrl, 677 + }, 678 + ], 679 + }, 680 + ]; 681 + 669 682 // Build external embed 670 683 const embed: Record<string, unknown> = { 671 684 $type: "app.bsky.embed.external", ··· 685 698 const record: Record<string, unknown> = { 686 699 $type: "app.bsky.feed.post", 687 700 text: postText, 701 + facets, 688 702 embed, 689 703 createdAt: new Date(publishedAt).toISOString(), 690 704 };
-1
packages/cli/src/lib/types.ts
··· 87 87 export interface PostFrontmatter { 88 88 title: string; 89 89 description?: string; 90 - bskyPost?: string; 91 90 publishDate: string; 92 91 tags?: string[]; 93 92 ogImage?: string;

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.

1 commit
expand
feat: Add subscription component
expand 0 comments