···24242525It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action:
26262727-<iframe width="560" height="315" src="https://www.youtube.com/embed/sxursUHq5kw?si=aZSCmkMdYPiYns8u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
2727+<iframe
2828+ class="w-full"
2929+ style={{aspectRatio: "16/9"}}
3030+ src="https://www.youtube.com/embed/sxursUHq5kw"
3131+ title="YouTube video player"
3232+ frameborder="0"
3333+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
3434+ referrerpolicy="strict-origin-when-cross-origin"
3535+ allowfullscreen
3636+></iframe>
28372938ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons.
3039···4352bun i -g sequoia-cli
4453```
4554:::
5555+5656+<script type="module" src="/sequoia-comments.js"></script>
5757+<sequoia-comments
5858+document-uri="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v"
5959+depth="2"
6060+></sequoia-comments>
+52-1
docs/docs/pages/cli-reference.mdx
···11# CLI Reference
2233+## `login`
44+55+```bash [Terminal]
66+sequoia login
77+> Login with OAuth (browser-based authentication)
88+99+OPTIONS:
1010+ --logout <str> - Remove OAuth session for a specific DID [optional]
1111+1212+FLAGS:
1313+ --list - List all stored OAuth sessions [optional]
1414+ --help, -h - show help [optional]
1515+```
1616+1717+OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically.
1818+319## `auth`
420521```bash [Terminal]
622sequoia auth
77-> Authenticate with your ATProto PDS
2323+> Authenticate with your ATProto PDS using an app password
824925OPTIONS:
1026 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···1430 --help, -h - show help [optional]
1531```
16323333+Use this as an alternative to `login` when OAuth isn't available or for CI environments.
3434+3535+## `add`
3636+3737+```bash [Terminal]
3838+sequoia add <component>
3939+> Add a UI component to your project
4040+4141+ARGUMENTS:
4242+ component - The name of the component to add
4343+4444+FLAGS:
4545+ --help, -h - show help [optional]
4646+```
4747+4848+Available components:
4949+- `sequoia-comments` - Display Bluesky replies as comments on your blog posts
5050+5151+The component will be installed to the directory specified in `ui.components` (default: `src/components`). See the [Comments guide](/comments) for usage details.
5252+1753## `init`
18541955```bash [Terminal]
···6197 --dry-run, -n - Preview what would be synced without making changes [optional]
6298 --help, -h - show help [optional]
6399```
100100+101101+## `update`
102102+103103+```bash [Terminal]
104104+sequoia update
105105+> Update local config or ATProto publication record
106106+107107+FLAGS:
108108+ --help, -h - show help [optional]
109109+```
110110+111111+Interactive command to modify your existing configuration. Choose between:
112112+113113+- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
114114+- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+179
docs/docs/pages/comments.mdx
···11+# Comments
22+33+Sequoia has a small UI trick up its sleeve that lets you easily display comments on your blog posts through Bluesky posts. This is the general flow:
44+55+1. Setup your blog with `sequoia init`, and when prompted at the end to enable BlueSky posts, select `yes`.
66+2. When you run `sequoia publish` the CLI will publish a BlueSky post and link it to your `site.standard.document` record for your post.
77+3. As people reply to the BlueSky post, the replies can be rendered as comments below your post using the Sequoia UI web component.
88+99+## Setup
1010+1111+Run the following command in your project to install the comments web component. It will ask you where you would like to store the component file.
1212+1313+```bash [Terminal]
1414+sequoia add sequoia-comments
1515+```
1616+1717+The web component will look for the `<link rel="site.standard.document" href="atUri"/>` in your HTML head, then using the `atUri` fetch the post and the replies.
1818+1919+::::tip
2020+For more information on the `<link>` tags, check out the [verification guide](/verifying)
2121+::::
2222+2323+## Usage
2424+2525+Since `sequoia-comments` is a standard Web Component, it works with any framework. Choose your setup below:
2626+2727+:::code-group
2828+2929+```html [HTML]
3030+<body>
3131+ <h1>Blog Post Title</h1>
3232+ <!--Content-->
3333+ <h2>Comments</h2>
3434+3535+ <sequoia-comments></sequoia-comments>
3636+ <script type="module" src="./src/components/sequoia-comments.js"></script>
3737+</body>
3838+```
3939+4040+```tsx [React]
4141+// Import the component (registers the custom element)
4242+import './components/sequoia-comments.js';
4343+4444+function BlogPost() {
4545+ return (
4646+ <article>
4747+ <h1>Blog Post Title</h1>
4848+ {/* Content */}
4949+ <h2>Comments</h2>
5050+ <sequoia-comments />
5151+ </article>
5252+ );
5353+}
5454+```
5555+5656+```vue [Vue]
5757+<script setup>
5858+import './components/sequoia-comments.js';
5959+</script>
6060+6161+<template>
6262+ <article>
6363+ <h1>Blog Post Title</h1>
6464+ <!-- Content -->
6565+ <h2>Comments</h2>
6666+ <sequoia-comments />
6767+ </article>
6868+</template>
6969+```
7070+7171+```svelte [Svelte]
7272+<script>
7373+ import './components/sequoia-comments.js';
7474+</script>
7575+7676+<article>
7777+ <h1>Blog Post Title</h1>
7878+ <!-- Content -->
7979+ <h2>Comments</h2>
8080+ <sequoia-comments />
8181+</article>
8282+```
8383+8484+```astro [Astro]
8585+<article>
8686+ <h1>Blog Post Title</h1>
8787+ <!-- Content -->
8888+ <h2>Comments</h2>
8989+ <sequoia-comments />
9090+ <script>
9191+ import './components/sequoia-comments.js';
9292+ </script>
9393+</article>
9494+```
9595+9696+:::
9797+9898+### TypeScript Support
9999+100100+If you're using TypeScript with React, add this type declaration to avoid JSX errors:
101101+102102+```ts [custom-elements.d.ts]
103103+declare namespace JSX {
104104+ interface IntrinsicElements {
105105+ 'sequoia-comments': React.DetailedHTMLProps<
106106+ React.HTMLAttributes<HTMLElement> & {
107107+ 'document-uri'?: string;
108108+ depth?: string | number;
109109+ },
110110+ HTMLElement
111111+ >;
112112+ }
113113+}
114114+```
115115+116116+### Vue Configuration
117117+118118+For Vue, you may need to configure the compiler to recognize custom elements:
119119+120120+```ts [vite.config.ts]
121121+export default defineConfig({
122122+ plugins: [
123123+ vue({
124124+ template: {
125125+ compilerOptions: {
126126+ isCustomElement: (tag) => tag === 'sequoia-comments'
127127+ }
128128+ }
129129+ })
130130+ ]
131131+});
132132+```
133133+134134+## Configuration
135135+136136+The comments web component has several configuration options available.
137137+138138+### Attributes
139139+140140+The `<sequoia-comments>` component accepts the following attributes:
141141+142142+| Attribute | Type | Default | Description |
143143+|-----------|------|---------|-------------|
144144+| `document-uri` | `string` | - | AT Protocol URI for the document. Optional if a `<link rel="site.standard.document">` tag exists in the page head. |
145145+| `depth` | `number` | `6` | Maximum depth of nested replies to fetch. |
146146+147147+```html
148148+<!-- Use attributes for explicit control -->
149149+<sequoia-comments
150150+ document-uri="at://did:plc:example/site.standard.document/abc123"
151151+ depth="10">
152152+</sequoia-comments>
153153+```
154154+155155+### Styling
156156+157157+The component uses CSS custom properties for theming. Set these in your `:root` or parent element to customize the appearance:
158158+159159+| CSS Property | Default | Description |
160160+|--------------|---------|-------------|
161161+| `--sequoia-fg-color` | `#1f2937` | Text color |
162162+| `--sequoia-bg-color` | `#ffffff` | Background color |
163163+| `--sequoia-border-color` | `#e5e7eb` | Border color |
164164+| `--sequoia-accent-color` | `#2563eb` | Accent/link color |
165165+| `--sequoia-secondary-color` | `#6b7280` | Secondary text color (handles, timestamps) |
166166+| `--sequoia-border-radius` | `8px` | Border radius for cards and buttons |
167167+168168+### Example: Dark Theme
169169+170170+```css
171171+:root {
172172+ --sequoia-accent-color: #3A5A40;
173173+ --sequoia-border-radius: 12px;
174174+ --sequoia-bg-color: #1a1a1a;
175175+ --sequoia-fg-color: #F5F3EF;
176176+ --sequoia-border-color: #333;
177177+ --sequoia-secondary-color: #8B7355;
178178+}
179179+```
+46-2
docs/docs/pages/config.mdx
···1414| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
1515| `identity` | `string` | No | - | Which stored identity to use |
1616| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
1717+| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
1718| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
1919+| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
2020+| `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) |
2121+| `bluesky` | `object` | No | - | Bluesky posting configuration |
2222+| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) |
2323+| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
2424+| `ui` | `object` | No | - | UI components configuration |
2525+| `ui.components` | `string` | No | `"src/components"` | Directory where UI components are installed |
18261927### Example
2028···3139 "frontmatter": {
3240 "publishDate": "date"
3341 },
3434- "ignore": ["_index.md"]
4242+ "ignore": ["_index.md"],
4343+ "bluesky": {
4444+ "enabled": true,
4545+ "maxAgeDays": 30
4646+ },
4747+ "ui": {
4848+ "components": "src/components"
4949+ }
3550}
3651```
3752···4459| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
4560| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
4661| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
6262+| `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish |
47634864### Example
4965···5470publishDate: 2024-01-15
5571ogImage: cover.jpg
5672tags: [welcome, intro]
7373+draft: false
5774---
5875```
5976···6582{
6683 "frontmatter": {
6784 "publishDate": "date",
6868- "coverImage": "thumbnail"
8585+ "coverImage": "thumbnail",
8686+ "draft": "private"
6987 }
7088}
7189```
9090+9191+### Slug Configuration
9292+9393+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
9494+9595+```json
9696+{
9797+ "frontmatter": {
9898+ "slugField": "url"
9999+ }
100100+}
101101+```
102102+103103+If the frontmatter field is not found, it falls back to the filepath.
104104+105105+### Jekyll-Style Date Prefixes
106106+107107+Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs:
108108+109109+```json
110110+{
111111+ "stripDatePrefix": true
112112+}
113113+```
114114+115115+This transforms `2024-01-15-my-post.md` into the slug `my-post`.
7211673117### Ignoring Files
74118
+45-1
docs/docs/pages/publishing.mdx
···1010sequoia publish --dry-run
1111```
12121313-This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it!
1313+This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it!
14141515```bash [Terminal]
1616sequoia publish
···2727```
28282929Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config.
3030+3131+## Bluesky Posting
3232+3333+Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config:
3434+3535+```json
3636+{
3737+ "bluesky": {
3838+ "enabled": true,
3939+ "maxAgeDays": 30
4040+ }
4141+}
4242+```
4343+4444+When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters.
4545+4646+The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky.
4747+4848+## Draft Posts
4949+5050+Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it.
5151+5252+```yaml
5353+---
5454+title: Work in Progress
5555+draft: true
5656+---
5757+```
5858+5959+If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`:
6060+6161+```json
6262+{
6363+ "frontmatter": {
6464+ "draft": "private"
6565+ }
6666+}
6767+```
6868+6969+## Comments
7070+7171+When Bluesky posting is enabled, Sequoia links each published document to its corresponding Bluesky post. This enables comments on your blog posts through Bluesky replies.
7272+7373+To display comments on your site, use the `sequoia-comments` web component. See the [Comments guide](/comments) for setup instructions.
30743175## Troubleshooting
3276
+9-7
docs/docs/pages/quickstart.mdx
···3131sequoia
3232```
33333434-### Authorize
3535-3636-In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password.
3434+### Login
37353838-:::tip
3939-You can create an app password [here](https://bsky.app/settings/app-passwords)
4040-:::
3636+In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account.
41374238```bash [Terminal]
4343-sequoia auth
3939+sequoia login
4440```
4141+4242+This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI.
4343+4444+:::tip
4545+Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth.
4646+:::
45474648### Initialize
4749
docs/docs/public/icon-dark.png
This is a binary file and will not be displayed.
docs/docs/public/og.png
This is a binary file and will not be displayed.
+856
docs/docs/public/sequoia-comments.js
···11+/**
22+ * Sequoia Comments - A Bluesky-powered comments component
33+ *
44+ * A self-contained Web Component that displays comments from Bluesky posts
55+ * linked to documents via the AT Protocol.
66+ *
77+ * Usage:
88+ * <sequoia-comments></sequoia-comments>
99+ *
1010+ * The component looks for a document URI in two places:
1111+ * 1. The `document-uri` attribute on the element
1212+ * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head
1313+ *
1414+ * Attributes:
1515+ * - document-uri: AT Protocol URI for the document (optional if link tag exists)
1616+ * - depth: Maximum depth of nested replies to fetch (default: 6)
1717+ *
1818+ * CSS Custom Properties:
1919+ * - --sequoia-fg-color: Text color (default: #1f2937)
2020+ * - --sequoia-bg-color: Background color (default: #ffffff)
2121+ * - --sequoia-border-color: Border color (default: #e5e7eb)
2222+ * - --sequoia-accent-color: Accent/link color (default: #2563eb)
2323+ * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
2424+ * - --sequoia-border-radius: Border radius (default: 8px)
2525+ */
2626+2727+// ============================================================================
2828+// Styles
2929+// ============================================================================
3030+3131+const styles = `
3232+:host {
3333+ display: block;
3434+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
3535+ color: var(--sequoia-fg-color, #1f2937);
3636+ line-height: 1.5;
3737+}
3838+3939+* {
4040+ box-sizing: border-box;
4141+}
4242+4343+.sequoia-comments-container {
4444+ max-width: 100%;
4545+}
4646+4747+.sequoia-loading,
4848+.sequoia-error,
4949+.sequoia-empty,
5050+.sequoia-warning {
5151+ padding: 1rem;
5252+ border-radius: var(--sequoia-border-radius, 8px);
5353+ text-align: center;
5454+}
5555+5656+.sequoia-loading {
5757+ background: var(--sequoia-bg-color, #ffffff);
5858+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
5959+ color: var(--sequoia-secondary-color, #6b7280);
6060+}
6161+6262+.sequoia-loading-spinner {
6363+ display: inline-block;
6464+ width: 1.25rem;
6565+ height: 1.25rem;
6666+ border: 2px solid var(--sequoia-border-color, #e5e7eb);
6767+ border-top-color: var(--sequoia-accent-color, #2563eb);
6868+ border-radius: 50%;
6969+ animation: sequoia-spin 0.8s linear infinite;
7070+ margin-right: 0.5rem;
7171+ vertical-align: middle;
7272+}
7373+7474+@keyframes sequoia-spin {
7575+ to { transform: rotate(360deg); }
7676+}
7777+7878+.sequoia-error {
7979+ background: #fef2f2;
8080+ border: 1px solid #fecaca;
8181+ color: #dc2626;
8282+}
8383+8484+.sequoia-warning {
8585+ background: #fffbeb;
8686+ border: 1px solid #fde68a;
8787+ color: #d97706;
8888+}
8989+9090+.sequoia-empty {
9191+ background: var(--sequoia-bg-color, #ffffff);
9292+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
9393+ color: var(--sequoia-secondary-color, #6b7280);
9494+}
9595+9696+.sequoia-comments-header {
9797+ display: flex;
9898+ justify-content: space-between;
9999+ align-items: center;
100100+ margin-bottom: 1rem;
101101+ padding-bottom: 0.75rem;
102102+}
103103+104104+.sequoia-comments-title {
105105+ font-size: 1.125rem;
106106+ font-weight: 600;
107107+ margin: 0;
108108+}
109109+110110+.sequoia-reply-button {
111111+ display: inline-flex;
112112+ align-items: center;
113113+ gap: 0.375rem;
114114+ padding: 0.5rem 1rem;
115115+ background: var(--sequoia-accent-color, #2563eb);
116116+ color: #ffffff;
117117+ border: none;
118118+ border-radius: var(--sequoia-border-radius, 8px);
119119+ font-size: 0.875rem;
120120+ font-weight: 500;
121121+ cursor: pointer;
122122+ text-decoration: none;
123123+ transition: background-color 0.15s ease;
124124+}
125125+126126+.sequoia-reply-button:hover {
127127+ background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
128128+}
129129+130130+.sequoia-reply-button svg {
131131+ width: 1rem;
132132+ height: 1rem;
133133+}
134134+135135+.sequoia-comments-list {
136136+ display: flex;
137137+ flex-direction: column;
138138+}
139139+140140+.sequoia-thread {
141141+ border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
142142+ padding-bottom: 1rem;
143143+}
144144+145145+.sequoia-thread + .sequoia-thread {
146146+ margin-top: 0.5rem;
147147+}
148148+149149+.sequoia-thread:last-child {
150150+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
151151+}
152152+153153+.sequoia-comment {
154154+ display: flex;
155155+ gap: 0.75rem;
156156+ padding-top: 1rem;
157157+}
158158+159159+.sequoia-comment-avatar-column {
160160+ display: flex;
161161+ flex-direction: column;
162162+ align-items: center;
163163+ flex-shrink: 0;
164164+ width: 2.5rem;
165165+ position: relative;
166166+}
167167+168168+.sequoia-comment-avatar {
169169+ width: 2.5rem;
170170+ height: 2.5rem;
171171+ border-radius: 50%;
172172+ background: var(--sequoia-border-color, #e5e7eb);
173173+ object-fit: cover;
174174+ flex-shrink: 0;
175175+ position: relative;
176176+ z-index: 1;
177177+}
178178+179179+.sequoia-comment-avatar-placeholder {
180180+ width: 2.5rem;
181181+ height: 2.5rem;
182182+ border-radius: 50%;
183183+ background: var(--sequoia-border-color, #e5e7eb);
184184+ display: flex;
185185+ align-items: center;
186186+ justify-content: center;
187187+ flex-shrink: 0;
188188+ color: var(--sequoia-secondary-color, #6b7280);
189189+ font-weight: 600;
190190+ font-size: 1rem;
191191+ position: relative;
192192+ z-index: 1;
193193+}
194194+195195+.sequoia-thread-line {
196196+ position: absolute;
197197+ top: 2.5rem;
198198+ bottom: calc(-1rem - 0.5rem);
199199+ left: 50%;
200200+ transform: translateX(-50%);
201201+ width: 2px;
202202+ background: var(--sequoia-border-color, #e5e7eb);
203203+}
204204+205205+.sequoia-comment-content {
206206+ flex: 1;
207207+ min-width: 0;
208208+}
209209+210210+.sequoia-comment-header {
211211+ display: flex;
212212+ align-items: baseline;
213213+ gap: 0.5rem;
214214+ margin-bottom: 0.25rem;
215215+ flex-wrap: wrap;
216216+}
217217+218218+.sequoia-comment-author {
219219+ font-weight: 600;
220220+ color: var(--sequoia-fg-color, #1f2937);
221221+ text-decoration: none;
222222+ overflow: hidden;
223223+ text-overflow: ellipsis;
224224+ white-space: nowrap;
225225+}
226226+227227+.sequoia-comment-author:hover {
228228+ color: var(--sequoia-accent-color, #2563eb);
229229+}
230230+231231+.sequoia-comment-handle {
232232+ font-size: 0.875rem;
233233+ color: var(--sequoia-secondary-color, #6b7280);
234234+ overflow: hidden;
235235+ text-overflow: ellipsis;
236236+ white-space: nowrap;
237237+}
238238+239239+.sequoia-comment-time {
240240+ font-size: 0.875rem;
241241+ color: var(--sequoia-secondary-color, #6b7280);
242242+ flex-shrink: 0;
243243+}
244244+245245+.sequoia-comment-time::before {
246246+ content: "ยท";
247247+ margin-right: 0.5rem;
248248+}
249249+250250+.sequoia-comment-text {
251251+ margin: 0;
252252+ white-space: pre-wrap;
253253+ word-wrap: break-word;
254254+}
255255+256256+.sequoia-comment-text a {
257257+ color: var(--sequoia-accent-color, #2563eb);
258258+ text-decoration: none;
259259+}
260260+261261+.sequoia-comment-text a:hover {
262262+ text-decoration: underline;
263263+}
264264+265265+.sequoia-bsky-logo {
266266+ width: 1rem;
267267+ height: 1rem;
268268+}
269269+`;
270270+271271+// ============================================================================
272272+// Utility Functions
273273+// ============================================================================
274274+275275+/**
276276+ * Format a relative time string (e.g., "2 hours ago")
277277+ * @param {string} dateString - ISO date string
278278+ * @returns {string} Formatted relative time
279279+ */
280280+function formatRelativeTime(dateString) {
281281+ const date = new Date(dateString);
282282+ const now = new Date();
283283+ const diffMs = now.getTime() - date.getTime();
284284+ const diffSeconds = Math.floor(diffMs / 1000);
285285+ const diffMinutes = Math.floor(diffSeconds / 60);
286286+ const diffHours = Math.floor(diffMinutes / 60);
287287+ const diffDays = Math.floor(diffHours / 24);
288288+ const diffWeeks = Math.floor(diffDays / 7);
289289+ const diffMonths = Math.floor(diffDays / 30);
290290+ const diffYears = Math.floor(diffDays / 365);
291291+292292+ if (diffSeconds < 60) {
293293+ return "just now";
294294+ }
295295+ if (diffMinutes < 60) {
296296+ return `${diffMinutes}m ago`;
297297+ }
298298+ if (diffHours < 24) {
299299+ return `${diffHours}h ago`;
300300+ }
301301+ if (diffDays < 7) {
302302+ return `${diffDays}d ago`;
303303+ }
304304+ if (diffWeeks < 4) {
305305+ return `${diffWeeks}w ago`;
306306+ }
307307+ if (diffMonths < 12) {
308308+ return `${diffMonths}mo ago`;
309309+ }
310310+ return `${diffYears}y ago`;
311311+}
312312+313313+/**
314314+ * Escape HTML special characters
315315+ * @param {string} text - Text to escape
316316+ * @returns {string} Escaped HTML
317317+ */
318318+function escapeHtml(text) {
319319+ const div = document.createElement("div");
320320+ div.textContent = text;
321321+ return div.innerHTML;
322322+}
323323+324324+/**
325325+ * Convert post text with facets to HTML
326326+ * @param {string} text - Post text
327327+ * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
328328+ * @returns {string} HTML string with links
329329+ */
330330+function renderTextWithFacets(text, facets) {
331331+ if (!facets || facets.length === 0) {
332332+ return escapeHtml(text);
333333+ }
334334+335335+ // Convert text to bytes for proper indexing
336336+ const encoder = new TextEncoder();
337337+ const decoder = new TextDecoder();
338338+ const textBytes = encoder.encode(text);
339339+340340+ // Sort facets by start index
341341+ const sortedFacets = [...facets].sort(
342342+ (a, b) => a.index.byteStart - b.index.byteStart,
343343+ );
344344+345345+ let result = "";
346346+ let lastEnd = 0;
347347+348348+ for (const facet of sortedFacets) {
349349+ const { byteStart, byteEnd } = facet.index;
350350+351351+ // Add text before this facet
352352+ if (byteStart > lastEnd) {
353353+ const beforeBytes = textBytes.slice(lastEnd, byteStart);
354354+ result += escapeHtml(decoder.decode(beforeBytes));
355355+ }
356356+357357+ // Get the facet text
358358+ const facetBytes = textBytes.slice(byteStart, byteEnd);
359359+ const facetText = decoder.decode(facetBytes);
360360+361361+ // Find the first renderable feature
362362+ const feature = facet.features[0];
363363+ if (feature) {
364364+ if (feature.$type === "app.bsky.richtext.facet#link") {
365365+ result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
366366+ } else if (feature.$type === "app.bsky.richtext.facet#mention") {
367367+ result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
368368+ } else if (feature.$type === "app.bsky.richtext.facet#tag") {
369369+ result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
370370+ } else {
371371+ result += escapeHtml(facetText);
372372+ }
373373+ } else {
374374+ result += escapeHtml(facetText);
375375+ }
376376+377377+ lastEnd = byteEnd;
378378+ }
379379+380380+ // Add remaining text
381381+ if (lastEnd < textBytes.length) {
382382+ const remainingBytes = textBytes.slice(lastEnd);
383383+ result += escapeHtml(decoder.decode(remainingBytes));
384384+ }
385385+386386+ return result;
387387+}
388388+389389+/**
390390+ * Get initials from a name for avatar placeholder
391391+ * @param {string} name - Display name
392392+ * @returns {string} Initials (1-2 characters)
393393+ */
394394+function getInitials(name) {
395395+ const parts = name.trim().split(/\s+/);
396396+ if (parts.length >= 2) {
397397+ return (parts[0][0] + parts[1][0]).toUpperCase();
398398+ }
399399+ return name.substring(0, 2).toUpperCase();
400400+}
401401+402402+// ============================================================================
403403+// AT Protocol Client Functions
404404+// ============================================================================
405405+406406+/**
407407+ * Parse an AT URI into its components
408408+ * Format: at://did/collection/rkey
409409+ * @param {string} atUri - AT Protocol URI
410410+ * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
411411+ */
412412+function parseAtUri(atUri) {
413413+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
414414+ if (!match) return null;
415415+ return {
416416+ did: match[1],
417417+ collection: match[2],
418418+ rkey: match[3],
419419+ };
420420+}
421421+422422+/**
423423+ * Resolve a DID to its PDS URL
424424+ * Supports did:plc and did:web methods
425425+ * @param {string} did - Decentralized Identifier
426426+ * @returns {Promise<string>} PDS URL
427427+ */
428428+async function resolvePDS(did) {
429429+ let pdsUrl;
430430+431431+ if (did.startsWith("did:plc:")) {
432432+ // Fetch DID document from plc.directory
433433+ const didDocUrl = `https://plc.directory/${did}`;
434434+ const didDocResponse = await fetch(didDocUrl);
435435+ if (!didDocResponse.ok) {
436436+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
437437+ }
438438+ const didDoc = await didDocResponse.json();
439439+440440+ // Find the PDS service endpoint
441441+ const pdsService = didDoc.service?.find(
442442+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
443443+ );
444444+ pdsUrl = pdsService?.serviceEndpoint;
445445+ } else if (did.startsWith("did:web:")) {
446446+ // For did:web, fetch the DID document from the domain
447447+ const domain = did.replace("did:web:", "");
448448+ const didDocUrl = `https://${domain}/.well-known/did.json`;
449449+ const didDocResponse = await fetch(didDocUrl);
450450+ if (!didDocResponse.ok) {
451451+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
452452+ }
453453+ const didDoc = await didDocResponse.json();
454454+455455+ const pdsService = didDoc.service?.find(
456456+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
457457+ );
458458+ pdsUrl = pdsService?.serviceEndpoint;
459459+ } else {
460460+ throw new Error(`Unsupported DID method: ${did}`);
461461+ }
462462+463463+ if (!pdsUrl) {
464464+ throw new Error("Could not find PDS URL for user");
465465+ }
466466+467467+ return pdsUrl;
468468+}
469469+470470+/**
471471+ * Fetch a record from a PDS using the public API
472472+ * @param {string} did - DID of the repository owner
473473+ * @param {string} collection - Collection name
474474+ * @param {string} rkey - Record key
475475+ * @returns {Promise<any>} Record value
476476+ */
477477+async function getRecord(did, collection, rkey) {
478478+ const pdsUrl = await resolvePDS(did);
479479+480480+ const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
481481+ url.searchParams.set("repo", did);
482482+ url.searchParams.set("collection", collection);
483483+ url.searchParams.set("rkey", rkey);
484484+485485+ const response = await fetch(url.toString());
486486+ if (!response.ok) {
487487+ throw new Error(`Failed to fetch record: ${response.status}`);
488488+ }
489489+490490+ const data = await response.json();
491491+ return data.value;
492492+}
493493+494494+/**
495495+ * Fetch a document record from its AT URI
496496+ * @param {string} atUri - AT Protocol URI for the document
497497+ * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record
498498+ */
499499+async function getDocument(atUri) {
500500+ const parsed = parseAtUri(atUri);
501501+ if (!parsed) {
502502+ throw new Error(`Invalid AT URI: ${atUri}`);
503503+ }
504504+505505+ return getRecord(parsed.did, parsed.collection, parsed.rkey);
506506+}
507507+508508+/**
509509+ * Fetch a post thread from the public Bluesky API
510510+ * @param {string} postUri - AT Protocol URI for the post
511511+ * @param {number} [depth=6] - Maximum depth of replies to fetch
512512+ * @returns {Promise<ThreadViewPost>} Thread view post
513513+ */
514514+async function getPostThread(postUri, depth = 6) {
515515+ const url = new URL(
516516+ "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
517517+ );
518518+ url.searchParams.set("uri", postUri);
519519+ url.searchParams.set("depth", depth.toString());
520520+521521+ const response = await fetch(url.toString());
522522+ if (!response.ok) {
523523+ throw new Error(`Failed to fetch post thread: ${response.status}`);
524524+ }
525525+526526+ const data = await response.json();
527527+528528+ if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
529529+ throw new Error("Post not found or blocked");
530530+ }
531531+532532+ return data.thread;
533533+}
534534+535535+/**
536536+ * Build a Bluesky app URL for a post
537537+ * @param {string} postUri - AT Protocol URI for the post
538538+ * @returns {string} Bluesky app URL
539539+ */
540540+function buildBskyAppUrl(postUri) {
541541+ const parsed = parseAtUri(postUri);
542542+ if (!parsed) {
543543+ throw new Error(`Invalid post URI: ${postUri}`);
544544+ }
545545+546546+ return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
547547+}
548548+549549+/**
550550+ * Type guard for ThreadViewPost
551551+ * @param {any} post - Post to check
552552+ * @returns {boolean} True if post is a ThreadViewPost
553553+ */
554554+function isThreadViewPost(post) {
555555+ return post?.$type === "app.bsky.feed.defs#threadViewPost";
556556+}
557557+558558+// ============================================================================
559559+// Bluesky Icon
560560+// ============================================================================
561561+562562+const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
563563+ <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"/>
564564+</svg>`;
565565+566566+// ============================================================================
567567+// Web Component
568568+// ============================================================================
569569+570570+// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
571571+const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
572572+573573+class SequoiaComments extends BaseElement {
574574+ constructor() {
575575+ super();
576576+ this.shadow = this.attachShadow({ mode: "open" });
577577+ this.state = { type: "loading" };
578578+ this.abortController = null;
579579+ }
580580+581581+ static get observedAttributes() {
582582+ return ["document-uri", "depth"];
583583+ }
584584+585585+ connectedCallback() {
586586+ this.render();
587587+ this.loadComments();
588588+ }
589589+590590+ disconnectedCallback() {
591591+ this.abortController?.abort();
592592+ }
593593+594594+ attributeChangedCallback() {
595595+ if (this.isConnected) {
596596+ this.loadComments();
597597+ }
598598+ }
599599+600600+ get documentUri() {
601601+ // First check attribute
602602+ const attrUri = this.getAttribute("document-uri");
603603+ if (attrUri) {
604604+ return attrUri;
605605+ }
606606+607607+ // Then scan for link tag in document head
608608+ const linkTag = document.querySelector(
609609+ 'link[rel="site.standard.document"]',
610610+ );
611611+ return linkTag?.href ?? null;
612612+ }
613613+614614+ get depth() {
615615+ const depthAttr = this.getAttribute("depth");
616616+ return depthAttr ? parseInt(depthAttr, 10) : 6;
617617+ }
618618+619619+ async loadComments() {
620620+ // Cancel any in-flight request
621621+ this.abortController?.abort();
622622+ this.abortController = new AbortController();
623623+624624+ this.state = { type: "loading" };
625625+ this.render();
626626+627627+ const docUri = this.documentUri;
628628+ if (!docUri) {
629629+ this.state = { type: "no-document" };
630630+ this.render();
631631+ return;
632632+ }
633633+634634+ try {
635635+ // Fetch the document record
636636+ const document = await getDocument(docUri);
637637+638638+ // Check if document has a Bluesky post reference
639639+ if (!document.bskyPostRef) {
640640+ this.state = { type: "no-comments-enabled" };
641641+ this.render();
642642+ return;
643643+ }
644644+645645+ const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
646646+647647+ // Fetch the post thread
648648+ const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
649649+650650+ // Check if there are any replies
651651+ const replies = thread.replies?.filter(isThreadViewPost) ?? [];
652652+ if (replies.length === 0) {
653653+ this.state = { type: "empty", postUrl };
654654+ this.render();
655655+ return;
656656+ }
657657+658658+ this.state = { type: "loaded", thread, postUrl };
659659+ this.render();
660660+ } catch (error) {
661661+ const message =
662662+ error instanceof Error ? error.message : "Failed to load comments";
663663+ this.state = { type: "error", message };
664664+ this.render();
665665+ }
666666+ }
667667+668668+ render() {
669669+ const styleTag = `<style>${styles}</style>`;
670670+671671+ switch (this.state.type) {
672672+ case "loading":
673673+ this.shadow.innerHTML = `
674674+ ${styleTag}
675675+ <div class="sequoia-comments-container">
676676+ <div class="sequoia-loading">
677677+ <span class="sequoia-loading-spinner"></span>
678678+ Loading comments...
679679+ </div>
680680+ </div>
681681+ `;
682682+ break;
683683+684684+ case "no-document":
685685+ this.shadow.innerHTML = `
686686+ ${styleTag}
687687+ <div class="sequoia-comments-container">
688688+ <div class="sequoia-warning">
689689+ No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
690690+ </div>
691691+ </div>
692692+ `;
693693+ break;
694694+695695+ case "no-comments-enabled":
696696+ this.shadow.innerHTML = `
697697+ ${styleTag}
698698+ <div class="sequoia-comments-container">
699699+ <div class="sequoia-empty">
700700+ Comments are not enabled for this post.
701701+ </div>
702702+ </div>
703703+ `;
704704+ break;
705705+706706+ case "empty":
707707+ this.shadow.innerHTML = `
708708+ ${styleTag}
709709+ <div class="sequoia-comments-container">
710710+ <div class="sequoia-comments-header">
711711+ <h3 class="sequoia-comments-title">Comments</h3>
712712+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
713713+ ${BLUESKY_ICON}
714714+ Reply on Bluesky
715715+ </a>
716716+ </div>
717717+ <div class="sequoia-empty">
718718+ No comments yet. Be the first to reply on Bluesky!
719719+ </div>
720720+ </div>
721721+ `;
722722+ break;
723723+724724+ case "error":
725725+ this.shadow.innerHTML = `
726726+ ${styleTag}
727727+ <div class="sequoia-comments-container">
728728+ <div class="sequoia-error">
729729+ Failed to load comments: ${escapeHtml(this.state.message)}
730730+ </div>
731731+ </div>
732732+ `;
733733+ break;
734734+735735+ case "loaded": {
736736+ const replies =
737737+ this.state.thread.replies?.filter(isThreadViewPost) ?? [];
738738+ const threadsHtml = replies
739739+ .map((reply) => this.renderThread(reply))
740740+ .join("");
741741+ const commentCount = this.countComments(replies);
742742+743743+ this.shadow.innerHTML = `
744744+ ${styleTag}
745745+ <div class="sequoia-comments-container">
746746+ <div class="sequoia-comments-header">
747747+ <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
748748+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
749749+ ${BLUESKY_ICON}
750750+ Reply on Bluesky
751751+ </a>
752752+ </div>
753753+ <div class="sequoia-comments-list">
754754+ ${threadsHtml}
755755+ </div>
756756+ </div>
757757+ `;
758758+ break;
759759+ }
760760+ }
761761+ }
762762+763763+ /**
764764+ * Flatten a thread into a linear list of comments
765765+ * @param {ThreadViewPost} thread - Thread to flatten
766766+ * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
767767+ */
768768+ flattenThread(thread) {
769769+ const result = [];
770770+ const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
771771+772772+ result.push({
773773+ post: thread.post,
774774+ hasMoreReplies: nestedReplies.length > 0,
775775+ });
776776+777777+ // Recursively flatten nested replies
778778+ for (const reply of nestedReplies) {
779779+ result.push(...this.flattenThread(reply));
780780+ }
781781+782782+ return result;
783783+ }
784784+785785+ /**
786786+ * Render a complete thread (top-level comment + all nested replies)
787787+ */
788788+ renderThread(thread) {
789789+ const flatComments = this.flattenThread(thread);
790790+ const commentsHtml = flatComments
791791+ .map((item, index) =>
792792+ this.renderComment(item.post, item.hasMoreReplies, index),
793793+ )
794794+ .join("");
795795+796796+ return `<div class="sequoia-thread">${commentsHtml}</div>`;
797797+ }
798798+799799+ /**
800800+ * Render a single comment
801801+ * @param {any} post - Post data
802802+ * @param {boolean} showThreadLine - Whether to show the connecting thread line
803803+ * @param {number} _index - Index in the flattened thread (0 = top-level)
804804+ */
805805+ renderComment(post, showThreadLine = false, _index = 0) {
806806+ const author = post.author;
807807+ const displayName = author.displayName || author.handle;
808808+ const avatarHtml = author.avatar
809809+ ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
810810+ : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
811811+812812+ const profileUrl = `https://bsky.app/profile/${author.did}`;
813813+ const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
814814+ const timeAgo = formatRelativeTime(post.record.createdAt);
815815+ const threadLineHtml = showThreadLine
816816+ ? '<div class="sequoia-thread-line"></div>'
817817+ : "";
818818+819819+ return `
820820+ <div class="sequoia-comment">
821821+ <div class="sequoia-comment-avatar-column">
822822+ ${avatarHtml}
823823+ ${threadLineHtml}
824824+ </div>
825825+ <div class="sequoia-comment-content">
826826+ <div class="sequoia-comment-header">
827827+ <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
828828+ ${escapeHtml(displayName)}
829829+ </a>
830830+ <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
831831+ <span class="sequoia-comment-time">${timeAgo}</span>
832832+ </div>
833833+ <p class="sequoia-comment-text">${textHtml}</p>
834834+ </div>
835835+ </div>
836836+ `;
837837+ }
838838+839839+ countComments(replies) {
840840+ let count = 0;
841841+ for (const reply of replies) {
842842+ count += 1;
843843+ const nested = reply.replies?.filter(isThreadViewPost) ?? [];
844844+ count += this.countComments(nested);
845845+ }
846846+ return count;
847847+ }
848848+}
849849+850850+// Register the custom element
851851+if (typeof customElements !== "undefined") {
852852+ customElements.define("sequoia-comments", SequoiaComments);
853853+}
854854+855855+// Export for module usage
856856+export { SequoiaComments };
···11-import * as fs from "fs/promises";
22-import * as path from "path";
33-import * as os from "os";
44-import type { Credentials } from "./types";
11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
44+import {
55+ getOAuthHandle,
66+ getOAuthSession,
77+ listOAuthSessions,
88+ listOAuthSessionsWithHandles,
99+} from "./oauth-store";
1010+import type {
1111+ AppPasswordCredentials,
1212+ Credentials,
1313+ LegacyCredentials,
1414+ OAuthCredentials,
1515+} from "./types";
516617const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
718const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
81999-// Stored credentials keyed by identifier
1010-type CredentialsStore = Record<string, Credentials>;
2020+// Stored credentials keyed by identifier (can be legacy or typed)
2121+type CredentialsStore = Record<
2222+ string,
2323+ AppPasswordCredentials | LegacyCredentials
2424+>;
11251226async function fileExists(filePath: string): Promise<boolean> {
1313- try {
1414- await fs.access(filePath);
1515- return true;
1616- } catch {
1717- return false;
1818- }
2727+ try {
2828+ await fs.access(filePath);
2929+ return true;
3030+ } catch {
3131+ return false;
3232+ }
1933}
20342135/**
2222- * Load all stored credentials
3636+ * Normalize credentials to have explicit type
2337 */
3838+function normalizeCredentials(
3939+ creds: AppPasswordCredentials | LegacyCredentials,
4040+): AppPasswordCredentials {
4141+ // If it already has type, return as-is
4242+ if ("type" in creds && creds.type === "app-password") {
4343+ return creds;
4444+ }
4545+ // Migrate legacy format
4646+ return {
4747+ type: "app-password",
4848+ pdsUrl: creds.pdsUrl,
4949+ identifier: creds.identifier,
5050+ password: creds.password,
5151+ };
5252+}
5353+2454async function loadCredentialsStore(): Promise<CredentialsStore> {
2525- if (!(await fileExists(CREDENTIALS_FILE))) {
2626- return {};
2727- }
5555+ if (!(await fileExists(CREDENTIALS_FILE))) {
5656+ return {};
5757+ }
28582929- try {
3030- const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
3131- const parsed = JSON.parse(content);
5959+ try {
6060+ const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
6161+ const parsed = JSON.parse(content);
32623333- // Handle legacy single-credential format (migrate on read)
3434- if (parsed.identifier && parsed.password) {
3535- const legacy = parsed as Credentials;
3636- return { [legacy.identifier]: legacy };
3737- }
6363+ // Handle legacy single-credential format (migrate on read)
6464+ if (parsed.identifier && parsed.password) {
6565+ const legacy = parsed as LegacyCredentials;
6666+ return { [legacy.identifier]: legacy };
6767+ }
38683939- return parsed as CredentialsStore;
4040- } catch {
4141- return {};
4242- }
6969+ return parsed as CredentialsStore;
7070+ } catch {
7171+ return {};
7272+ }
4373}
44744575/**
4676 * Save the entire credentials store
4777 */
4878async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
4949- await fs.mkdir(CONFIG_DIR, { recursive: true });
5050- await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
5151- await fs.chmod(CREDENTIALS_FILE, 0o600);
7979+ await fs.mkdir(CONFIG_DIR, { recursive: true });
8080+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
8181+ await fs.chmod(CREDENTIALS_FILE, 0o600);
8282+}
8383+8484+/**
8585+ * Try to load OAuth credentials for a given profile (DID or handle)
8686+ */
8787+async function tryLoadOAuthCredentials(
8888+ profile: string,
8989+): Promise<OAuthCredentials | null> {
9090+ // If it looks like a DID, try to get the session directly
9191+ if (profile.startsWith("did:")) {
9292+ const session = await getOAuthSession(profile);
9393+ if (session) {
9494+ const handle = await getOAuthHandle(profile);
9595+ return {
9696+ type: "oauth",
9797+ did: profile,
9898+ handle: handle || profile,
9999+ };
100100+ }
101101+ }
102102+103103+ // Try to find OAuth session by handle
104104+ const sessions = await listOAuthSessionsWithHandles();
105105+ const match = sessions.find((s) => s.handle === profile);
106106+ if (match) {
107107+ return {
108108+ type: "oauth",
109109+ did: match.did,
110110+ handle: match.handle || match.did,
111111+ };
112112+ }
113113+114114+ return null;
52115}
5311654117/**
···56119 *
57120 * Priority:
58121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD)
5959- * 2. SEQUOIA_PROFILE env var - selects from stored credentials
122122+ * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID)
60123 * 3. projectIdentity parameter (from sequoia.json)
6161- * 4. If only one identity stored, use it
124124+ * 4. If only one identity stored (app-password or OAuth), use it
62125 * 5. Return null (caller should prompt user)
63126 */
64127export async function loadCredentials(
6565- projectIdentity?: string
128128+ projectIdentity?: string,
66129): Promise<Credentials | null> {
6767- // 1. Check environment variables first (full override)
6868- const envIdentifier = process.env.ATP_IDENTIFIER;
6969- const envPassword = process.env.ATP_APP_PASSWORD;
7070- const envPdsUrl = process.env.PDS_URL;
130130+ // 1. Check environment variables first (full override)
131131+ const envIdentifier = process.env.ATP_IDENTIFIER;
132132+ const envPassword = process.env.ATP_APP_PASSWORD;
133133+ const envPdsUrl = process.env.PDS_URL;
711347272- if (envIdentifier && envPassword) {
7373- return {
7474- identifier: envIdentifier,
7575- password: envPassword,
7676- pdsUrl: envPdsUrl || "https://bsky.social",
7777- };
7878- }
135135+ if (envIdentifier && envPassword) {
136136+ return {
137137+ type: "app-password",
138138+ identifier: envIdentifier,
139139+ password: envPassword,
140140+ pdsUrl: envPdsUrl || "https://bsky.social",
141141+ };
142142+ }
791438080- const store = await loadCredentialsStore();
8181- const identifiers = Object.keys(store);
144144+ const store = await loadCredentialsStore();
145145+ const appPasswordIds = Object.keys(store);
146146+ const oauthDids = await listOAuthSessions();
821478383- if (identifiers.length === 0) {
8484- return null;
8585- }
8686-8787- // 2. SEQUOIA_PROFILE env var
8888- const profileEnv = process.env.SEQUOIA_PROFILE;
8989- if (profileEnv && store[profileEnv]) {
9090- return store[profileEnv];
9191- }
148148+ // 2. SEQUOIA_PROFILE env var
149149+ const profileEnv = process.env.SEQUOIA_PROFILE;
150150+ if (profileEnv) {
151151+ // Try app-password credentials first
152152+ if (store[profileEnv]) {
153153+ return normalizeCredentials(store[profileEnv]);
154154+ }
155155+ // Try OAuth session (profile could be a DID)
156156+ const oauth = await tryLoadOAuthCredentials(profileEnv);
157157+ if (oauth) {
158158+ return oauth;
159159+ }
160160+ }
921619393- // 3. Project-specific identity (from sequoia.json)
9494- if (projectIdentity && store[projectIdentity]) {
9595- return store[projectIdentity];
9696- }
162162+ // 3. Project-specific identity (from sequoia.json)
163163+ if (projectIdentity) {
164164+ if (store[projectIdentity]) {
165165+ return normalizeCredentials(store[projectIdentity]);
166166+ }
167167+ const oauth = await tryLoadOAuthCredentials(projectIdentity);
168168+ if (oauth) {
169169+ return oauth;
170170+ }
171171+ }
971729898- // 4. If only one identity, use it
9999- if (identifiers.length === 1 && identifiers[0]) {
100100- return store[identifiers[0]] ?? null;
101101- }
173173+ // 4. If only one identity total, use it
174174+ const totalIdentities = appPasswordIds.length + oauthDids.length;
175175+ if (totalIdentities === 1) {
176176+ if (appPasswordIds.length === 1 && appPasswordIds[0]) {
177177+ return normalizeCredentials(store[appPasswordIds[0]]!);
178178+ }
179179+ if (oauthDids.length === 1 && oauthDids[0]) {
180180+ const session = await getOAuthSession(oauthDids[0]);
181181+ if (session) {
182182+ const handle = await getOAuthHandle(oauthDids[0]);
183183+ return {
184184+ type: "oauth",
185185+ did: oauthDids[0],
186186+ handle: handle || oauthDids[0],
187187+ };
188188+ }
189189+ }
190190+ }
102191103103- // Multiple identities exist but none selected
104104- return null;
192192+ // Multiple identities exist but none selected, or no identities
193193+ return null;
105194}
106195107196/**
108108- * Get a specific identity by identifier
197197+ * Get a specific identity by identifier (app-password only)
109198 */
110199export async function getCredentials(
111111- identifier: string
112112-): Promise<Credentials | null> {
113113- const store = await loadCredentialsStore();
114114- return store[identifier] || null;
200200+ identifier: string,
201201+): Promise<AppPasswordCredentials | null> {
202202+ const store = await loadCredentialsStore();
203203+ const creds = store[identifier];
204204+ if (!creds) return null;
205205+ return normalizeCredentials(creds);
115206}
116207117208/**
118118- * List all stored identities
209209+ * List all stored app-password identities
119210 */
120211export async function listCredentials(): Promise<string[]> {
121121- const store = await loadCredentialsStore();
122122- return Object.keys(store);
212212+ const store = await loadCredentialsStore();
213213+ return Object.keys(store);
214214+}
215215+216216+/**
217217+ * List all credentials (both app-password and OAuth)
218218+ */
219219+export async function listAllCredentials(): Promise<
220220+ Array<{ id: string; type: "app-password" | "oauth" }>
221221+> {
222222+ const store = await loadCredentialsStore();
223223+ const oauthDids = await listOAuthSessions();
224224+225225+ const result: Array<{ id: string; type: "app-password" | "oauth" }> = [];
226226+227227+ for (const id of Object.keys(store)) {
228228+ result.push({ id, type: "app-password" });
229229+ }
230230+231231+ for (const did of oauthDids) {
232232+ result.push({ id: did, type: "oauth" });
233233+ }
234234+235235+ return result;
123236}
124237125238/**
126126- * Save credentials for an identity (adds or updates)
239239+ * Save app-password credentials for an identity (adds or updates)
127240 */
128128-export async function saveCredentials(credentials: Credentials): Promise<void> {
129129- const store = await loadCredentialsStore();
130130- store[credentials.identifier] = credentials;
131131- await saveCredentialsStore(store);
241241+export async function saveCredentials(
242242+ credentials: AppPasswordCredentials,
243243+): Promise<void> {
244244+ const store = await loadCredentialsStore();
245245+ store[credentials.identifier] = credentials;
246246+ await saveCredentialsStore(store);
132247}
133248134249/**
135250 * Delete credentials for a specific identity
136251 */
137252export async function deleteCredentials(identifier?: string): Promise<boolean> {
138138- const store = await loadCredentialsStore();
139139- const identifiers = Object.keys(store);
253253+ const store = await loadCredentialsStore();
254254+ const identifiers = Object.keys(store);
140255141141- if (identifiers.length === 0) {
142142- return false;
143143- }
256256+ if (identifiers.length === 0) {
257257+ return false;
258258+ }
144259145145- // If identifier specified, delete just that one
146146- if (identifier) {
147147- if (!store[identifier]) {
148148- return false;
149149- }
150150- delete store[identifier];
151151- await saveCredentialsStore(store);
152152- return true;
153153- }
260260+ // If identifier specified, delete just that one
261261+ if (identifier) {
262262+ if (!store[identifier]) {
263263+ return false;
264264+ }
265265+ delete store[identifier];
266266+ await saveCredentialsStore(store);
267267+ return true;
268268+ }
154269155155- // If only one identity, delete it (backwards compat behavior)
156156- if (identifiers.length === 1 && identifiers[0]) {
157157- delete store[identifiers[0]];
158158- await saveCredentialsStore(store);
159159- return true;
160160- }
270270+ // If only one identity, delete it (backwards compat behavior)
271271+ if (identifiers.length === 1 && identifiers[0]) {
272272+ delete store[identifiers[0]];
273273+ await saveCredentialsStore(store);
274274+ return true;
275275+ }
161276162162- // Multiple identities but none specified
163163- return false;
277277+ // Multiple identities but none specified
278278+ return false;
164279}
165280166281export function getCredentialsPath(): string {
167167- return CREDENTIALS_FILE;
282282+ return CREDENTIALS_FILE;
168283}
+337-170
packages/cli/src/lib/markdown.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33import { glob } from "glob";
44import { minimatch } from "minimatch";
55-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
55+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
6677-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
88- frontmatter: PostFrontmatter;
99- body: string;
77+export function parseFrontmatter(
88+ content: string,
99+ mapping?: FrontmatterMapping,
1010+): {
1111+ frontmatter: PostFrontmatter;
1212+ body: string;
1313+ rawFrontmatter: Record<string, unknown>;
1014} {
1111- // Support multiple frontmatter delimiters:
1212- // --- (YAML) - Jekyll, Astro, most SSGs
1313- // +++ (TOML) - Hugo
1414- // *** - Alternative format
1515- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
1616- const match = content.match(frontmatterRegex);
1515+ // Support multiple frontmatter delimiters:
1616+ // --- (YAML) - Jekyll, Astro, most SSGs
1717+ // +++ (TOML) - Hugo
1818+ // *** - Alternative format
1919+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
2020+ const match = content.match(frontmatterRegex);
17211818- if (!match) {
1919- throw new Error("Could not parse frontmatter");
2020- }
2222+ if (!match) {
2323+ throw new Error("Could not parse frontmatter");
2424+ }
21252222- const delimiter = match[1];
2323- const frontmatterStr = match[2] ?? "";
2424- const body = match[3] ?? "";
2626+ const delimiter = match[1];
2727+ const frontmatterStr = match[2] ?? "";
2828+ const body = match[3] ?? "";
25292626- // Determine format based on delimiter:
2727- // +++ uses TOML (key = value)
2828- // --- and *** use YAML (key: value)
2929- const isToml = delimiter === "+++";
3030- const separator = isToml ? "=" : ":";
3030+ // Determine format based on delimiter:
3131+ // +++ uses TOML (key = value)
3232+ // --- and *** use YAML (key: value)
3333+ const isToml = delimiter === "+++";
3434+ const separator = isToml ? "=" : ":";
31353232- // Parse frontmatter manually
3333- const raw: Record<string, unknown> = {};
3434- const lines = frontmatterStr.split("\n");
3636+ // Parse frontmatter manually
3737+ const raw: Record<string, unknown> = {};
3838+ const lines = frontmatterStr.split("\n");
35393636- for (const line of lines) {
3737- const sepIndex = line.indexOf(separator);
3838- if (sepIndex === -1) continue;
4040+ let i = 0;
4141+ while (i < lines.length) {
4242+ const line = lines[i];
4343+ if (line === undefined) {
4444+ i++;
4545+ continue;
4646+ }
4747+ const sepIndex = line.indexOf(separator);
4848+ if (sepIndex === -1) {
4949+ i++;
5050+ continue;
5151+ }
39524040- const key = line.slice(0, sepIndex).trim();
4141- let value = line.slice(sepIndex + 1).trim();
5353+ const key = line.slice(0, sepIndex).trim();
5454+ let value = line.slice(sepIndex + 1).trim();
42554343- // Handle quoted strings
4444- if (
4545- (value.startsWith('"') && value.endsWith('"')) ||
4646- (value.startsWith("'") && value.endsWith("'"))
4747- ) {
4848- value = value.slice(1, -1);
4949- }
5656+ // Handle quoted strings
5757+ if (
5858+ (value.startsWith('"') && value.endsWith('"')) ||
5959+ (value.startsWith("'") && value.endsWith("'"))
6060+ ) {
6161+ value = value.slice(1, -1);
6262+ }
50635151- // Handle arrays (simple case for tags)
5252- if (value.startsWith("[") && value.endsWith("]")) {
5353- const arrayContent = value.slice(1, -1);
5454- raw[key] = arrayContent
5555- .split(",")
5656- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
5757- } else if (value === "true") {
5858- raw[key] = true;
5959- } else if (value === "false") {
6060- raw[key] = false;
6161- } else {
6262- raw[key] = value;
6363- }
6464- }
6464+ // Handle inline arrays (simple case for tags)
6565+ if (value.startsWith("[") && value.endsWith("]")) {
6666+ const arrayContent = value.slice(1, -1);
6767+ raw[key] = arrayContent
6868+ .split(",")
6969+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
7070+ } else if (value === "" && !isToml) {
7171+ // Check for YAML-style multiline array (key with no value followed by - items)
7272+ const arrayItems: string[] = [];
7373+ let j = i + 1;
7474+ while (j < lines.length) {
7575+ const nextLine = lines[j];
7676+ if (nextLine === undefined) {
7777+ j++;
7878+ continue;
7979+ }
8080+ // Check if line is a list item (starts with whitespace and -)
8181+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
8282+ if (listMatch && listMatch[1] !== undefined) {
8383+ let itemValue = listMatch[1].trim();
8484+ // Remove quotes if present
8585+ if (
8686+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
8787+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
8888+ ) {
8989+ itemValue = itemValue.slice(1, -1);
9090+ }
9191+ arrayItems.push(itemValue);
9292+ j++;
9393+ } else if (nextLine.trim() === "") {
9494+ // Skip empty lines within the array
9595+ j++;
9696+ } else {
9797+ // Hit a new key or non-list content
9898+ break;
9999+ }
100100+ }
101101+ if (arrayItems.length > 0) {
102102+ raw[key] = arrayItems;
103103+ i = j;
104104+ continue;
105105+ } else {
106106+ raw[key] = value;
107107+ }
108108+ } else if (value === "true") {
109109+ raw[key] = true;
110110+ } else if (value === "false") {
111111+ raw[key] = false;
112112+ } else {
113113+ raw[key] = value;
114114+ }
115115+ i++;
116116+ }
651176666- // Apply field mappings to normalize to standard PostFrontmatter fields
6767- const frontmatter: Record<string, unknown> = {};
118118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119119+ const frontmatter: Record<string, unknown> = {};
681206969- // Title mapping
7070- const titleField = mapping?.title || "title";
7171- frontmatter.title = raw[titleField] || raw.title;
121121+ // Title mapping
122122+ const titleField = mapping?.title || "title";
123123+ frontmatter.title = raw[titleField] || raw.title;
721247373- // Description mapping
7474- const descField = mapping?.description || "description";
7575- frontmatter.description = raw[descField] || raw.description;
125125+ // Description mapping
126126+ const descField = mapping?.description || "description";
127127+ frontmatter.description = raw[descField] || raw.description;
761287777- // Publish date mapping - check custom field first, then fallbacks
7878- const dateField = mapping?.publishDate;
7979- if (dateField && raw[dateField]) {
8080- frontmatter.publishDate = raw[dateField];
8181- } else if (raw.publishDate) {
8282- frontmatter.publishDate = raw.publishDate;
8383- } else {
8484- // Fallback to common date field names
8585- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
8686- for (const field of dateFields) {
8787- if (raw[field]) {
8888- frontmatter.publishDate = raw[field];
8989- break;
9090- }
9191- }
9292- }
129129+ // Publish date mapping - check custom field first, then fallbacks
130130+ const dateField = mapping?.publishDate;
131131+ if (dateField && raw[dateField]) {
132132+ frontmatter.publishDate = raw[dateField];
133133+ } else if (raw.publishDate) {
134134+ frontmatter.publishDate = raw.publishDate;
135135+ } else {
136136+ // Fallback to common date field names
137137+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
138138+ for (const field of dateFields) {
139139+ if (raw[field]) {
140140+ frontmatter.publishDate = raw[field];
141141+ break;
142142+ }
143143+ }
144144+ }
145145+146146+ // Cover image mapping
147147+ const coverField = mapping?.coverImage || "ogImage";
148148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
931499494- // Cover image mapping
9595- const coverField = mapping?.coverImage || "ogImage";
9696- frontmatter.ogImage = raw[coverField] || raw.ogImage;
150150+ // Tags mapping
151151+ const tagsField = mapping?.tags || "tags";
152152+ frontmatter.tags = raw[tagsField] || raw.tags;
971539898- // Tags mapping
9999- const tagsField = mapping?.tags || "tags";
100100- frontmatter.tags = raw[tagsField] || raw.tags;
154154+ // Draft mapping
155155+ const draftField = mapping?.draft || "draft";
156156+ const draftValue = raw[draftField] ?? raw.draft;
157157+ if (draftValue !== undefined) {
158158+ frontmatter.draft = draftValue === true || draftValue === "true";
159159+ }
101160102102- // Always preserve atUri (internal field)
103103- frontmatter.atUri = raw.atUri;
161161+ // Always preserve atUri (internal field)
162162+ frontmatter.atUri = raw.atUri;
104163105105- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
164164+ return {
165165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166166+ body,
167167+ rawFrontmatter: raw,
168168+ };
106169}
107170108171export function getSlugFromFilename(filename: string): string {
109109- return filename
110110- .replace(/\.mdx?$/, "")
111111- .toLowerCase()
112112- .replace(/\s+/g, "-");
172172+ return filename
173173+ .replace(/\.mdx?$/, "")
174174+ .toLowerCase()
175175+ .replace(/\s+/g, "-");
176176+}
177177+178178+export interface SlugOptions {
179179+ slugField?: string;
180180+ removeIndexFromSlug?: boolean;
181181+ stripDatePrefix?: boolean;
182182+}
183183+184184+export function getSlugFromOptions(
185185+ relativePath: string,
186186+ rawFrontmatter: Record<string, unknown>,
187187+ options: SlugOptions = {},
188188+): string {
189189+ const {
190190+ slugField,
191191+ removeIndexFromSlug = false,
192192+ stripDatePrefix = false,
193193+ } = options;
194194+195195+ let slug: string;
196196+197197+ // If slugField is set, try to get the value from frontmatter
198198+ if (slugField) {
199199+ const frontmatterValue = rawFrontmatter[slugField];
200200+ if (frontmatterValue && typeof frontmatterValue === "string") {
201201+ // Remove leading slash if present
202202+ slug = frontmatterValue
203203+ .replace(/^\//, "")
204204+ .toLowerCase()
205205+ .replace(/\s+/g, "-");
206206+ } else {
207207+ // Fallback to filepath if frontmatter field not found
208208+ slug = relativePath
209209+ .replace(/\.mdx?$/, "")
210210+ .toLowerCase()
211211+ .replace(/\s+/g, "-");
212212+ }
213213+ } else {
214214+ // Default: use filepath
215215+ slug = relativePath
216216+ .replace(/\.mdx?$/, "")
217217+ .toLowerCase()
218218+ .replace(/\s+/g, "-");
219219+ }
220220+221221+ // Remove /index or /_index suffix if configured
222222+ if (removeIndexFromSlug) {
223223+ slug = slug.replace(/\/_?index$/, "");
224224+ }
225225+226226+ // Strip Jekyll-style date prefix (YYYY-MM-DD-) from filename
227227+ if (stripDatePrefix) {
228228+ slug = slug.replace(/(^|\/)(\d{4}-\d{2}-\d{2})-/g, "$1");
229229+ }
230230+231231+ return slug;
113232}
114233115234export async function getContentHash(content: string): Promise<string> {
116116- const encoder = new TextEncoder();
117117- const data = encoder.encode(content);
118118- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
119119- const hashArray = Array.from(new Uint8Array(hashBuffer));
120120- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
235235+ const encoder = new TextEncoder();
236236+ const data = encoder.encode(content);
237237+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
238238+ const hashArray = Array.from(new Uint8Array(hashBuffer));
239239+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
121240}
122241123242function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
124124- for (const pattern of ignorePatterns) {
125125- if (minimatch(relativePath, pattern)) {
126126- return true;
127127- }
128128- }
129129- return false;
243243+ for (const pattern of ignorePatterns) {
244244+ if (minimatch(relativePath, pattern)) {
245245+ return true;
246246+ }
247247+ }
248248+ return false;
249249+}
250250+251251+export interface ScanOptions {
252252+ frontmatterMapping?: FrontmatterMapping;
253253+ ignorePatterns?: string[];
254254+ slugField?: string;
255255+ removeIndexFromSlug?: boolean;
256256+ stripDatePrefix?: boolean;
130257}
131258132259export async function scanContentDirectory(
133133- contentDir: string,
134134- frontmatterMapping?: FrontmatterMapping,
135135- ignorePatterns: string[] = []
260260+ contentDir: string,
261261+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
262262+ ignorePatterns: string[] = [],
136263): Promise<BlogPost[]> {
137137- const patterns = ["**/*.md", "**/*.mdx"];
138138- const posts: BlogPost[] = [];
264264+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
265265+ let options: ScanOptions;
266266+ if (
267267+ frontmatterMappingOrOptions &&
268268+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
269269+ "ignorePatterns" in frontmatterMappingOrOptions ||
270270+ "slugField" in frontmatterMappingOrOptions)
271271+ ) {
272272+ options = frontmatterMappingOrOptions as ScanOptions;
273273+ } else {
274274+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
275275+ options = {
276276+ frontmatterMapping: frontmatterMappingOrOptions as
277277+ | FrontmatterMapping
278278+ | undefined,
279279+ ignorePatterns,
280280+ };
281281+ }
139282140140- for (const pattern of patterns) {
141141- const files = await glob(pattern, {
142142- cwd: contentDir,
143143- absolute: false,
144144- });
283283+ const {
284284+ frontmatterMapping,
285285+ ignorePatterns: ignore = [],
286286+ slugField,
287287+ removeIndexFromSlug,
288288+ stripDatePrefix,
289289+ } = options;
290290+291291+ const patterns = ["**/*.md", "**/*.mdx"];
292292+ const posts: BlogPost[] = [];
293293+294294+ for (const pattern of patterns) {
295295+ const files = await glob(pattern, {
296296+ cwd: contentDir,
297297+ absolute: false,
298298+ });
145299146146- for (const relativePath of files) {
147147- // Skip files matching ignore patterns
148148- if (shouldIgnore(relativePath, ignorePatterns)) {
149149- continue;
150150- }
300300+ for (const relativePath of files) {
301301+ // Skip files matching ignore patterns
302302+ if (shouldIgnore(relativePath, ignore)) {
303303+ continue;
304304+ }
151305152152- const filePath = path.join(contentDir, relativePath);
153153- const rawContent = await fs.readFile(filePath, "utf-8");
306306+ const filePath = path.join(contentDir, relativePath);
307307+ const rawContent = await fs.readFile(filePath, "utf-8");
154308155155- try {
156156- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
157157- const filename = path.basename(relativePath);
158158- const slug = getSlugFromFilename(filename);
309309+ try {
310310+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
311311+ rawContent,
312312+ frontmatterMapping,
313313+ );
314314+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
315315+ slugField,
316316+ removeIndexFromSlug,
317317+ stripDatePrefix,
318318+ });
159319160160- posts.push({
161161- filePath,
162162- slug,
163163- frontmatter,
164164- content: body,
165165- rawContent,
166166- });
167167- } catch (error) {
168168- console.error(`Error parsing ${relativePath}:`, error);
169169- }
170170- }
171171- }
320320+ posts.push({
321321+ filePath,
322322+ slug,
323323+ frontmatter,
324324+ content: body,
325325+ rawContent,
326326+ rawFrontmatter,
327327+ });
328328+ } catch (error) {
329329+ console.error(`Error parsing ${relativePath}:`, error);
330330+ }
331331+ }
332332+ }
172333173173- // Sort by publish date (newest first)
174174- posts.sort((a, b) => {
175175- const dateA = new Date(a.frontmatter.publishDate);
176176- const dateB = new Date(b.frontmatter.publishDate);
177177- return dateB.getTime() - dateA.getTime();
178178- });
334334+ // Sort by publish date (newest first)
335335+ posts.sort((a, b) => {
336336+ const dateA = new Date(a.frontmatter.publishDate);
337337+ const dateB = new Date(b.frontmatter.publishDate);
338338+ return dateB.getTime() - dateA.getTime();
339339+ });
179340180180- return posts;
341341+ return posts;
181342}
182343183183-export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
184184- // Detect which delimiter is used (---, +++, or ***)
185185- const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
186186- const delimiter = delimiterMatch?.[1] ?? "---";
187187- const isToml = delimiter === "+++";
344344+export function updateFrontmatterWithAtUri(
345345+ rawContent: string,
346346+ atUri: string,
347347+): string {
348348+ // Detect which delimiter is used (---, +++, or ***)
349349+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
350350+ const delimiter = delimiterMatch?.[1] ?? "---";
351351+ const isToml = delimiter === "+++";
188352189189- // Format the atUri entry based on frontmatter type
190190- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
353353+ // Format the atUri entry based on frontmatter type
354354+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
191355192192- // Check if atUri already exists in frontmatter (handle both formats)
193193- if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
194194- // Replace existing atUri (match both YAML and TOML formats)
195195- return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
196196- }
356356+ // Check if atUri already exists in frontmatter (handle both formats)
357357+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
358358+ // Replace existing atUri (match both YAML and TOML formats)
359359+ return rawContent.replace(
360360+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
361361+ `${atUriEntry}\n`,
362362+ );
363363+ }
197364198198- // Insert atUri before the closing delimiter
199199- const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
200200- if (frontmatterEndIndex === -1) {
201201- throw new Error("Could not find frontmatter end");
202202- }
365365+ // Insert atUri before the closing delimiter
366366+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
367367+ if (frontmatterEndIndex === -1) {
368368+ throw new Error("Could not find frontmatter end");
369369+ }
203370204204- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
205205- const afterEnd = rawContent.slice(frontmatterEndIndex);
371371+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
372372+ const afterEnd = rawContent.slice(frontmatterEndIndex);
206373207207- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
374374+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
208375}
209376210377export function stripMarkdownForText(markdown: string): string {
211211- return markdown
212212- .replace(/#{1,6}\s/g, "") // Remove headers
213213- .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
214214- .replace(/\*([^*]+)\*/g, "$1") // Remove italic
215215- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
216216- .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
217217- .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
218218- .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
219219- .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
220220- .trim();
378378+ return markdown
379379+ .replace(/#{1,6}\s/g, "") // Remove headers
380380+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
381381+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
382382+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
383383+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
384384+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
385385+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
386386+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
387387+ .trim();
221388}
+94
packages/cli/src/lib/oauth-client.ts
···11+import {
22+ NodeOAuthClient,
33+ type NodeOAuthClientOptions,
44+} from "@atproto/oauth-client-node";
55+import { sessionStore, stateStore } from "./oauth-store";
66+77+const CALLBACK_PORT = 4000;
88+const CALLBACK_HOST = "127.0.0.1";
99+const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/oauth/callback`;
1010+1111+// OAuth scope for Sequoia CLI - includes atproto base scope plus our collections
1212+const OAUTH_SCOPE =
1313+ "atproto repo:site.standard.document repo:site.standard.publication repo:app.bsky.feed.post blob:*/*";
1414+1515+let oauthClient: NodeOAuthClient | null = null;
1616+1717+// Simple lock implementation for CLI (single process, no contention)
1818+// This prevents the "No lock mechanism provided" warning
1919+const locks = new Map<string, Promise<void>>();
2020+2121+async function requestLock<T>(
2222+ key: string,
2323+ fn: () => T | PromiseLike<T>,
2424+): Promise<T> {
2525+ // Wait for any existing lock on this key
2626+ while (locks.has(key)) {
2727+ await locks.get(key);
2828+ }
2929+3030+ // Create our lock
3131+ let resolve: () => void;
3232+ const lockPromise = new Promise<void>((r) => {
3333+ resolve = r;
3434+ });
3535+ locks.set(key, lockPromise);
3636+3737+ try {
3838+ return await fn();
3939+ } finally {
4040+ locks.delete(key);
4141+ resolve!();
4242+ }
4343+}
4444+4545+/**
4646+ * Get or create the OAuth client singleton
4747+ */
4848+export async function getOAuthClient(): Promise<NodeOAuthClient> {
4949+ if (oauthClient) {
5050+ return oauthClient;
5151+ }
5252+5353+ // Build client_id with required parameters
5454+ const clientIdParams = new URLSearchParams();
5555+ clientIdParams.append("redirect_uri", CALLBACK_URL);
5656+ clientIdParams.append("scope", OAUTH_SCOPE);
5757+5858+ const clientOptions: NodeOAuthClientOptions = {
5959+ clientMetadata: {
6060+ client_id: `http://localhost?${clientIdParams.toString()}`,
6161+ client_name: "Sequoia CLI",
6262+ client_uri: "https://github.com/stevedylandev/sequoia",
6363+ redirect_uris: [CALLBACK_URL],
6464+ grant_types: ["authorization_code", "refresh_token"],
6565+ response_types: ["code"],
6666+ token_endpoint_auth_method: "none",
6767+ application_type: "web",
6868+ scope: OAUTH_SCOPE,
6969+ dpop_bound_access_tokens: false,
7070+ },
7171+ stateStore,
7272+ sessionStore,
7373+ // Configure identity resolution
7474+ plcDirectoryUrl: "https://plc.directory",
7575+ // Provide lock mechanism to prevent warning
7676+ requestLock,
7777+ };
7878+7979+ oauthClient = new NodeOAuthClient(clientOptions);
8080+8181+ return oauthClient;
8282+}
8383+8484+export function getOAuthScope(): string {
8585+ return OAUTH_SCOPE;
8686+}
8787+8888+export function getCallbackUrl(): string {
8989+ return CALLBACK_URL;
9090+}
9191+9292+export function getCallbackPort(): number {
9393+ return CALLBACK_PORT;
9494+}
+161
packages/cli/src/lib/oauth-store.ts
···11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
44+import type {
55+ NodeSavedSession,
66+ NodeSavedSessionStore,
77+ NodeSavedState,
88+ NodeSavedStateStore,
99+} from "@atproto/oauth-client-node";
1010+1111+const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
1212+const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json");
1313+1414+interface OAuthStore {
1515+ states: Record<string, NodeSavedState>;
1616+ sessions: Record<string, NodeSavedSession>;
1717+ handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
1818+}
1919+2020+async function fileExists(filePath: string): Promise<boolean> {
2121+ try {
2222+ await fs.access(filePath);
2323+ return true;
2424+ } catch {
2525+ return false;
2626+ }
2727+}
2828+2929+async function loadOAuthStore(): Promise<OAuthStore> {
3030+ if (!(await fileExists(OAUTH_FILE))) {
3131+ return { states: {}, sessions: {} };
3232+ }
3333+3434+ try {
3535+ const content = await fs.readFile(OAUTH_FILE, "utf-8");
3636+ return JSON.parse(content) as OAuthStore;
3737+ } catch {
3838+ return { states: {}, sessions: {} };
3939+ }
4040+}
4141+4242+async function saveOAuthStore(store: OAuthStore): Promise<void> {
4343+ await fs.mkdir(CONFIG_DIR, { recursive: true });
4444+ await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2));
4545+ await fs.chmod(OAUTH_FILE, 0o600);
4646+}
4747+4848+/**
4949+ * State store for PKCE flow (temporary, used during auth)
5050+ */
5151+export const stateStore: NodeSavedStateStore = {
5252+ async set(key: string, state: NodeSavedState): Promise<void> {
5353+ const store = await loadOAuthStore();
5454+ store.states[key] = state;
5555+ await saveOAuthStore(store);
5656+ },
5757+5858+ async get(key: string): Promise<NodeSavedState | undefined> {
5959+ const store = await loadOAuthStore();
6060+ return store.states[key];
6161+ },
6262+6363+ async del(key: string): Promise<void> {
6464+ const store = await loadOAuthStore();
6565+ delete store.states[key];
6666+ await saveOAuthStore(store);
6767+ },
6868+};
6969+7070+/**
7171+ * Session store for OAuth tokens (persistent)
7272+ */
7373+export const sessionStore: NodeSavedSessionStore = {
7474+ async set(sub: string, session: NodeSavedSession): Promise<void> {
7575+ const store = await loadOAuthStore();
7676+ store.sessions[sub] = session;
7777+ await saveOAuthStore(store);
7878+ },
7979+8080+ async get(sub: string): Promise<NodeSavedSession | undefined> {
8181+ const store = await loadOAuthStore();
8282+ return store.sessions[sub];
8383+ },
8484+8585+ async del(sub: string): Promise<void> {
8686+ const store = await loadOAuthStore();
8787+ delete store.sessions[sub];
8888+ await saveOAuthStore(store);
8989+ },
9090+};
9191+9292+/**
9393+ * List all stored OAuth session DIDs
9494+ */
9595+export async function listOAuthSessions(): Promise<string[]> {
9696+ const store = await loadOAuthStore();
9797+ return Object.keys(store.sessions);
9898+}
9999+100100+/**
101101+ * Get an OAuth session by DID
102102+ */
103103+export async function getOAuthSession(
104104+ did: string,
105105+): Promise<NodeSavedSession | undefined> {
106106+ const store = await loadOAuthStore();
107107+ return store.sessions[did];
108108+}
109109+110110+/**
111111+ * Delete an OAuth session by DID
112112+ */
113113+export async function deleteOAuthSession(did: string): Promise<boolean> {
114114+ const store = await loadOAuthStore();
115115+ if (!store.sessions[did]) {
116116+ return false;
117117+ }
118118+ delete store.sessions[did];
119119+ await saveOAuthStore(store);
120120+ return true;
121121+}
122122+123123+export function getOAuthStorePath(): string {
124124+ return OAUTH_FILE;
125125+}
126126+127127+/**
128128+ * Store handle for an OAuth session (DID -> handle mapping)
129129+ */
130130+export async function setOAuthHandle(
131131+ did: string,
132132+ handle: string,
133133+): Promise<void> {
134134+ const store = await loadOAuthStore();
135135+ if (!store.handles) {
136136+ store.handles = {};
137137+ }
138138+ store.handles[did] = handle;
139139+ await saveOAuthStore(store);
140140+}
141141+142142+/**
143143+ * Get handle for an OAuth session by DID
144144+ */
145145+export async function getOAuthHandle(did: string): Promise<string | undefined> {
146146+ const store = await loadOAuthStore();
147147+ return store.handles?.[did];
148148+}
149149+150150+/**
151151+ * List all stored OAuth sessions with their handles
152152+ */
153153+export async function listOAuthSessionsWithHandles(): Promise<
154154+ Array<{ did: string; handle?: string }>
155155+> {
156156+ const store = await loadOAuthStore();
157157+ return Object.keys(store.sessions).map((did) => ({
158158+ did,
159159+ handle: store.handles?.[did],
160160+ }));
161161+}
+6-6
packages/cli/src/lib/prompts.ts
···11-import { isCancel, cancel } from "@clack/prompts";
11+import { cancel, isCancel } from "@clack/prompts";
2233export function exitOnCancel<T>(value: T | symbol): T {
44- if (isCancel(value)) {
55- cancel("Cancelled");
66- process.exit(0);
77- }
88- return value as T;
44+ if (isCancel(value)) {
55+ cancel("Cancelled");
66+ process.exit(0);
77+ }
88+ return value as T;
99}
+62-1
packages/cli/src/lib/types.ts
···44 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
55 coverImage?: string; // Field name for cover image (default: "ogImage")
66 tags?: string; // Field name for tags (default: "tags")
77+ draft?: string; // Field name for draft status (default: "draft")
88+ slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
99+}
1010+1111+// Strong reference for Bluesky post (com.atproto.repo.strongRef)
1212+export interface StrongRef {
1313+ uri: string; // at:// URI format
1414+ cid: string; // Content ID
1515+}
1616+1717+// Bluesky posting configuration
1818+export interface BlueskyConfig {
1919+ enabled: boolean;
2020+ maxAgeDays?: number; // Only post if published within N days (default: 7)
2121+}
2222+2323+// UI components configuration
2424+export interface UIConfig {
2525+ components: string; // Directory to install UI components (default: src/components)
726}
827928export interface PublisherConfig {
···1837 identity?: string; // Which stored identity to use (matches identifier)
1938 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
2039 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
4040+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
4141+ stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false)
4242+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
4343+ bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
4444+ ui?: UIConfig; // Optional UI components configuration
2145}
22462323-export interface Credentials {
4747+// Legacy credentials format (for backward compatibility during migration)
4848+export interface LegacyCredentials {
4949+ pdsUrl: string;
5050+ identifier: string;
5151+ password: string;
5252+}
5353+5454+// App password credentials (explicit type)
5555+export interface AppPasswordCredentials {
5656+ type: "app-password";
2457 pdsUrl: string;
2558 identifier: string;
2659 password: string;
2760}
28616262+// OAuth credentials (references stored OAuth session)
6363+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
6464+export interface OAuthCredentials {
6565+ type: "oauth";
6666+ did: string;
6767+ handle: string;
6868+}
6969+7070+// Union type for all credential types
7171+export type Credentials = AppPasswordCredentials | OAuthCredentials;
7272+7373+// Helper to check credential type
7474+export function isOAuthCredentials(
7575+ creds: Credentials,
7676+): creds is OAuthCredentials {
7777+ return creds.type === "oauth";
7878+}
7979+8080+export function isAppPasswordCredentials(
8181+ creds: Credentials,
8282+): creds is AppPasswordCredentials {
8383+ return creds.type === "app-password";
8484+}
8585+2986export interface PostFrontmatter {
3087 title: string;
3188 description?: string;
···3390 tags?: string[];
3491 ogImage?: string;
3592 atUri?: string;
9393+ draft?: boolean;
3694}
37953896export interface BlogPost {
···4199 frontmatter: PostFrontmatter;
42100 content: string;
43101 rawContent: string;
102102+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
44103}
4510446105export interface BlobRef {
···62121 contentHash: string;
63122 atUri?: string;
64123 lastPublished?: string;
124124+ slug?: string; // The generated slug for this post (used by inject command)
125125+ bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
65126}
6612767128export interface PublicationRecord {
+43
packages/cli/test.html
···11+<!DOCTYPE html>
22+<html lang="en">
33+<head>
44+ <meta charset="UTF-8">
55+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
66+ <title>Sequoia Comments Test</title>
77+ <!-- Link to a published document - replace with your own AT URI -->
88+ <link rel="site.standard.document" href="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v">
99+ <style>
1010+ body {
1111+ font-family: system-ui, -apple-system, sans-serif;
1212+ max-width: 800px;
1313+ margin: 2rem auto;
1414+ padding: 0 1rem;
1515+ line-height: 1.6;
1616+ background-color: #1A1A1A;
1717+ color: #F5F3EF;
1818+ }
1919+ h1 {
2020+ margin-bottom: 2rem;
2121+ }
2222+ /* Custom styling example */
2323+ :root {
2424+ --sequoia-accent-color: #3A5A40;
2525+ --sequoia-border-radius: 12px;
2626+ --sequoia-bg-color: #1a1a1a;
2727+ --sequoia-fg-color: #F5F3EF;
2828+ --sequoia-border-color: #333;
2929+ --sequoia-secondary-color: #8B7355;
3030+ }
3131+ </style>
3232+</head>
3333+<body>
3434+ <h1>Blog Post Title</h1>
3535+ <p>This is a test page for the sequoia-comments web component.</p>
3636+ <p>The component will look for a <code><link rel="site.standard.document"></code> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.</p>
3737+3838+ <h2>Comments</h2>
3939+ <sequoia-comments></sequoia-comments>
4040+4141+ <script type="module" src="./src/components/sequoia-comments.js"></script>
4242+</body>
4343+</html>