···11+## [0.3.3.] - 2026-02-04
22+33+### โ๏ธ Miscellaneous Tasks
44+55+- Cleaned up remaining auth implementations
66+- Format
77+88+## [0.3.2] - 2026-02-05
99+1010+### ๐ Bug Fixes
1111+1212+- Fixed issue with auth selection in init command
1313+1414+### โ๏ธ Miscellaneous Tasks
1515+1616+- Release 0.3.2
1717+## [0.3.1] - 2026-02-04
1818+1919+### ๐ Bug Fixes
2020+2121+- Asset subdirectories
2222+2323+### โ๏ธ Miscellaneous Tasks
2424+2525+- Updated authentication ux
2626+- Release 0.3.1
2727+- Bumped version
2828+## [0.3.0] - 2026-02-04
2929+3030+### ๐ Features
3131+3232+- Initial oauth implementation
3333+- Add stripDatePrefix option for Jekyll-style filenames
3434+- Add `update` command
3535+3636+### โ๏ธ Miscellaneous Tasks
3737+3838+- Update changelog
3939+- Added workflows
4040+- Updated workflows
4141+- Updated workflows
4242+- Cleaned up types
4343+- Updated icon styles
4444+- Updated og image
4545+- Updated docs
4646+- Docs updates
4747+- Bumped version
4848+## [0.2.1] - 2026-02-02
4949+5050+### โ๏ธ Miscellaneous Tasks
5151+5252+- Added CHANGELOG
5353+- Merge main into chore/fronmatter-config-updates
5454+- Added linting and formatting
5555+- Linting updates
5656+- Refactored to use fallback approach if frontmatter.slugField is provided or not
5757+- Version bump
5858+## [0.2.0] - 2026-02-01
5959+6060+### ๐ Features
6161+6262+- Added bskyPostRef
6363+- Added draft field to frontmatter config
6464+6565+### โ๏ธ Miscellaneous Tasks
6666+6767+- Resolved action items from issue #3
6868+- Adjusted tags to accept yaml multiline arrays for tags
6969+- Updated inject to handle new slug options
7070+- Updated comments
7171+- Update blog post
7272+- Fix blog build error
7373+- Adjust blog post
7474+- Updated docs
7575+- Version bump
7676+## [0.1.1] - 2026-01-31
7777+7878+### ๐ Bug Fixes
7979+8080+- Fix tangled url to repo
8181+8282+### โ๏ธ Miscellaneous Tasks
8383+8484+- Merge branch 'main' into feat/blog-post
8585+- Updated blog post
8686+- Updated date
8787+- Added publishing
8888+- Spelling and grammar
8989+- Updated package scripts
9090+- Refactored codebase to use node and fs instead of bun
9191+- Version bump
9292+## [0.1.0] - 2026-01-30
9393+9494+### ๐ Features
9595+9696+- Init
9797+- Added blog post
9898+9999+### โ๏ธ Miscellaneous Tasks
100100+101101+- Updated package.json
102102+- Cleaned up commands and libs
103103+- Updated init commands
104104+- Updated greeting
105105+- Updated readme
106106+- Link updates
107107+- Version bump
108108+- Added hugo support through frontmatter parsing
109109+- Version bump
110110+- Updated docs
111111+- Adapted inject.ts pattern
112112+- Updated docs
113113+- Version bump"
114114+- Updated package scripts
115115+- Updated scripts
116116+- Added ignore field to config
117117+- Udpate docs
118118+- Version bump
119119+- Added tags to flow
120120+- Added ability to exit during init flow
121121+- Version bump
122122+- Updated docs
123123+- Updated links
124124+- Updated docs
125125+- Initial refactor
126126+- Checkpoint
127127+- Refactored mapping
128128+- Docs updates
129129+- Docs updates
130130+- Version bump
+85
CLAUDE.md
···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+## Project Overview
66+77+Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS.
88+99+Website: <https://sequoia.pub>
1010+1111+## Monorepo Structure
1212+1313+- **`packages/cli/`** โ Main CLI package (the core product)
1414+- **`docs/`** โ Documentation website (Vocs-based, deployed to Cloudflare Pages)
1515+1616+Bun workspaces manage the monorepo.
1717+1818+## Commands
1919+2020+```bash
2121+# Build CLI
2222+bun run build:cli
2323+2424+# Run CLI in dev (build + link)
2525+cd packages/cli && bun run dev
2626+2727+# Run tests
2828+bun run test:cli
2929+3030+# Run a single test file
3131+cd packages/cli && bun test src/lib/markdown.test.ts
3232+3333+# Lint (auto-fix)
3434+cd packages/cli && bun run lint
3535+3636+# Format (auto-fix)
3737+cd packages/cli && bun run format
3838+3939+# Docs dev server
4040+bun run dev:docs
4141+```
4242+4343+## Architecture
4444+4545+**Entry point:** `packages/cli/src/index.ts` โ Uses `cmd-ts` for type-safe subcommand routing.
4646+4747+**Commands** (`src/commands/`):
4848+4949+- `publish` โ Core workflow: scans markdown files, publishes to ATProto
5050+- `sync` โ Fetches published records state from ATProto
5151+- `update` โ Updates existing records
5252+- `auth` โ Multi-identity management (app-password + OAuth)
5353+- `init` โ Interactive config setup
5454+- `inject` โ Injects verification links into static HTML output
5555+- `login` โ Legacy auth (deprecated)
5656+5757+**Libraries** (`src/lib/`):
5858+5959+- `atproto.ts` โ ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client)
6060+- `config.ts` โ Loads `sequoia.json` config and `.sequoia-state.json` state files
6161+- `credentials.ts` โ Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions)
6262+- `markdown.ts` โ Frontmatter parsing (YAML/TOML), content hashing, atUri injection
6363+6464+**Extensions** (`src/extensions/`):
6565+6666+- `litenote.ts` โ Creates `space.litenote.note` records with embedded images
6767+6868+## Key Patterns
6969+7070+- **Config resolution:** `sequoia.json` is found by searching up the directory tree
7171+- **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters
7272+- **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle)
7373+- **Build:** `bun build src/index.ts --target node --outdir dist`
7474+7575+## Tooling
7676+7777+- **Runtime/bundler:** Bun
7878+- **Linter/formatter:** Biome (tabs, double quotes)
7979+- **Test runner:** Bun's native test runner
8080+- **CLI framework:** `cmd-ts`
8181+- **Interactive UI:** `@clack/prompts`
8282+8383+## Git Conventions
8484+8585+Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
···11+name: 'Sequoia Publish'
22+description: 'Publish your markdown content to ATProtocol using Sequoia CLI'
33+branding:
44+ icon: 'upload-cloud'
55+ color: 'green'
66+77+inputs:
88+ identifier:
99+ description: 'ATProto handle or DID (e.g. yourname.bsky.social)'
1010+ required: true
1111+ app-password:
1212+ description: 'ATProto app password'
1313+ required: true
1414+ pds-url:
1515+ description: 'PDS URL (defaults to https://bsky.social)'
1616+ required: false
1717+ default: 'https://bsky.social'
1818+ force:
1919+ description: 'Force publish all posts, ignoring change detection'
2020+ required: false
2121+ default: 'false'
2222+ commit-back:
2323+ description: 'Commit updated frontmatter and state file back to the repo'
2424+ required: false
2525+ default: 'true'
2626+ working-directory:
2727+ description: 'Directory containing sequoia.json (defaults to repo root)'
2828+ required: false
2929+ default: '.'
3030+3131+runs:
3232+ using: 'composite'
3333+ steps:
3434+ - name: Setup Bun
3535+ uses: oven-sh/setup-bun@v2
3636+3737+ - name: Build and install Sequoia CLI
3838+ shell: bash
3939+ run: |
4040+ cd ${{ github.action_path }}
4141+ bun install
4242+ bun run build:cli
4343+ bun link --cwd packages/cli
4444+4545+ - name: Sync state from ATProtocol
4646+ shell: bash
4747+ working-directory: ${{ inputs.working-directory }}
4848+ env:
4949+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5050+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
5151+ PDS_URL: ${{ inputs.pds-url }}
5252+ run: sequoia sync
5353+5454+ - name: Publish
5555+ shell: bash
5656+ working-directory: ${{ inputs.working-directory }}
5757+ env:
5858+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5959+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
6060+ PDS_URL: ${{ inputs.pds-url }}
6161+ run: |
6262+ FLAGS=""
6363+ if [ "${{ inputs.force }}" = "true" ]; then
6464+ FLAGS="--force"
6565+ fi
6666+ sequoia publish $FLAGS
6767+6868+ - name: Commit back changes
6969+ if: inputs.commit-back == 'true'
7070+ shell: bash
7171+ working-directory: ${{ inputs.working-directory }}
7272+ run: |
7373+ git config user.name "$(git log -1 --format='%an')"
7474+ git config user.email "$(git log -1 --format='%ae')"
7575+ git add -A *.md **/*.md || true
7676+ if git diff --cached --quiet; then
7777+ echo "No changes to commit"
7878+ else
7979+ git commit -m "chore: update sequoia state [skip ci]"
8080+ git push
8181+ fi
···11+---
22+layout: minimal
33+---
44+55+# Blog
66+77+::blog-posts
+54
docs/docs/pages/blog/introducing-sequoia.mdx
···11+---
22+layout: minimal
33+title: "Introducing Sequoia: Publishing for the Open Web"
44+date: 2026-01-30
55+atUri: "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v"
66+---
77+88+# Introducing Sequoia: Publishing for the Open Web
99+1010+
1111+1212+Today I'm excited to release a new tool for the [AT Protocol](https://atproto.com): Sequoia. This is a CLI tool that can take your existing self-hosted blog and publish it to the ATmosphere using [Standard.site](https://standard.site) lexicons.
1313+1414+If you haven't explored ATProto you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/), but in short, it's a new way to publish content to the web that puts ownership and control back in the hands of users. Blogs in some ways have already been doing this, but they've been missing a key piece: distribution. One of the unique features of ATProto is [lexicons](), which are schemas that apps build to create folders of content on a user's personal data server. The domain verified nature lets them be indexed and aggregated with ease. Outside of apps, lexicons can be extended by community members to build a common standard. That's exactly how [Standard.site](https://standard.site) was brought about, pushing a new way for standardizing publications and documents on ATProto.
1515+1616+The founders and platforms behind the standard, [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), all serve to make creating and sharing blogs easy. If you are not a technical person and don't have a blog already, I would highly recommend checking all of them out! However, for those of us who already have blogs, there was a need for a tool that could make it easy to publish existing and new content with this new standard. Thus Sequoia was born.
1717+1818+Sequoia is a relatively simple CLI that can do the following:
1919+- Authenticate with your ATProto handle
2020+- Configure your blog through an interactive setup process
2121+- Create publication and document records on your PDS
2222+- Add necessary verification pieces to your site
2323+- Sync with existing records on your PDS
2424+2525+It'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:
2626+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>
3737+3838+ATProto 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.
3939+4040+Install Sequoia today and check out the [quickstart guide](/quickstart) to publish your content into the ATmosphere ๐ณ
4141+4242+:::code-group
4343+```bash [npm]
4444+npm i -g sequoia-cli
4545+```
4646+4747+```bash [pnpm]
4848+pnpm i -g sequoia-cli
4949+```
5050+5151+```bash [bun]
5252+bun i -g sequoia-cli
5353+```
5454+:::
+34-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]
···1329 --list - List all stored identities [optional]
1430 --help, -h - show help [optional]
1531```
3232+3333+Use this as an alternative to `login` when OAuth isn't available or for CI environments.
16341735## `init`
1836···6179 --dry-run, -n - Preview what would be synced without making changes [optional]
6280 --help, -h - show help [optional]
6381```
8282+8383+## `update`
8484+8585+```bash [Terminal]
8686+sequoia update
8787+> Update local config or ATProto publication record
8888+8989+FLAGS:
9090+ --help, -h - show help [optional]
9191+```
9292+9393+Interactive command to modify your existing configuration. Choose between:
9494+9595+- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
9696+- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+41-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 |
2323+| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
18241925### Example
2026···3137 "frontmatter": {
3238 "publishDate": "date"
3339 },
3434- "ignore": ["_index.md"]
4040+ "ignore": ["_index.md"],
4141+ "bluesky": {
4242+ "enabled": true,
4343+ "maxAgeDays": 30
4444+ }
3545}
3646```
3747···4454| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
4555| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
4656| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
5757+| `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish |
47584859### Example
4960···5465publishDate: 2024-01-15
5566ogImage: cover.jpg
5667tags: [welcome, intro]
6868+draft: false
5769---
5870```
5971···6577{
6678 "frontmatter": {
6779 "publishDate": "date",
6868- "coverImage": "thumbnail"
8080+ "coverImage": "thumbnail",
8181+ "draft": "private"
8282+ }
8383+}
8484+```
8585+8686+### Slug Configuration
8787+8888+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
8989+9090+```json
9191+{
9292+ "frontmatter": {
9393+ "slugField": "url"
6994 }
7095}
7196```
9797+9898+If the frontmatter field is not found, it falls back to the filepath.
9999+100100+### Jekyll-Style Date Prefixes
101101+102102+Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs:
103103+104104+```json
105105+{
106106+ "stripDatePrefix": true
107107+}
108108+```
109109+110110+This transforms `2024-01-15-my-post.md` into the slug `my-post`.
7211173112### Ignoring Files
74113
+40-2
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
···2323If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command.
24242525```bash [Terminal]
2626-seuqoia sync
2626+sequoia sync
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+```
30683169## Troubleshooting
3270
+10-8
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 authoize 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···5961- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
6062- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
6163- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
6262-- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
6464+- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
6365- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
6466 - **Publication name** - The name of your blog
6567 - **Publication description** - A description for your blog
+2-2
docs/docs/pages/setup.mdx
···28282929## Authorize
30303131-In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password.
3131+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.
32323333:::tip
3434You can create an app password [here](https://bsky.app/settings/app-passwords)
···5656- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
5757- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
5858- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
5959-- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
5959+- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
6060- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
6161 - **Publication name** - The name of your blog
6262 - **Publication description** - A description for your blog
+2-2
docs/docs/pages/verifying.mdx
···33In order for your posts to show up on indexers you need to make sure your publication and your documents are verified.
4455:::tip
66-You an learn more about Standard.site verification [here](https://standard.site/)
66+You can learn more about Standard.site verification [here](https://standard.site/)
77:::
8899## Publication Verification
···22222323### pds.ls
24242525-Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as perscribed on their website.
2525+Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as prescribed on their website.
26262727### Standard.site Validator
2828
+2-2
docs/docs/pages/what-is-sequoia.mdx
···33Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down.
4455- [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/).
66-- [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. The unique property to lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more!
77-- [Standard.site](https://standard.site) - Standard.site is a set of lexicons specailly designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard.
66+- [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. A unique property of lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more!
77+- [Standard.site](https://standard.site) - Standard.site is a set of lexicons specially designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard.
8899The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too.
1010
···1111 "build:docs": "cd docs && bun run build",
1212 "build:cli": "cd packages/cli && bun run build",
1313 "deploy:docs": "cd docs && bun run deploy",
1414- "deploy:cli": "cd packages/cli && bun run deploy"
1414+ "deploy:cli": "cd packages/cli && bun run deploy",
1515+ "test:cli": "cd packages/cli && bun test"
1516 },
1617 "devDependencies": {
1718 "@types/bun": "latest",
···11-import * as path from "path";
22-import { Glob } from "bun";
33-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
11+import { webcrypto as crypto } from "node:crypto";
22+import * as fs from "node:fs/promises";
33+import * as path from "node:path";
44+import { glob } from "glob";
55+import { minimatch } from "minimatch";
66+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
4755-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
66- frontmatter: PostFrontmatter;
77- body: string;
88+export function parseFrontmatter(
99+ content: string,
1010+ mapping?: FrontmatterMapping,
1111+): {
1212+ frontmatter: PostFrontmatter;
1313+ body: string;
1414+ rawFrontmatter: Record<string, unknown>;
815} {
99- // Support multiple frontmatter delimiters:
1010- // --- (YAML) - Jekyll, Astro, most SSGs
1111- // +++ (TOML) - Hugo
1212- // *** - Alternative format
1313- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
1414- const match = content.match(frontmatterRegex);
1616+ // Support multiple frontmatter delimiters:
1717+ // --- (YAML) - Jekyll, Astro, most SSGs
1818+ // +++ (TOML) - Hugo
1919+ // *** - Alternative format
2020+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
2121+ const match = content.match(frontmatterRegex);
15221616- if (!match) {
1717- throw new Error("Could not parse frontmatter");
1818- }
2323+ if (!match) {
2424+ const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []
2525+ const title = titleMatch ?? ""
2626+ const [publishDate] = new Date().toISOString().split("T")
19272020- const delimiter = match[1];
2121- const frontmatterStr = match[2] ?? "";
2222- const body = match[3] ?? "";
2828+ return {
2929+ frontmatter: {
3030+ title,
3131+ publishDate: publishDate ?? ""
3232+ },
3333+ body: content,
3434+ rawFrontmatter: {
3535+ title:
3636+ publishDate
3737+ }
3838+ }
3939+ }
23402424- // Determine format based on delimiter:
2525- // +++ uses TOML (key = value)
2626- // --- and *** use YAML (key: value)
2727- const isToml = delimiter === "+++";
2828- const separator = isToml ? "=" : ":";
4141+ const delimiter = match[1];
4242+ const frontmatterStr = match[2] ?? "";
4343+ const body = match[3] ?? "";
29443030- // Parse frontmatter manually
3131- const raw: Record<string, unknown> = {};
3232- const lines = frontmatterStr.split("\n");
4545+ // Determine format based on delimiter:
4646+ // +++ uses TOML (key = value)
4747+ // --- and *** use YAML (key: value)
4848+ const isToml = delimiter === "+++";
4949+ const separator = isToml ? "=" : ":";
33503434- for (const line of lines) {
3535- const sepIndex = line.indexOf(separator);
3636- if (sepIndex === -1) continue;
5151+ // Parse frontmatter manually
5252+ const raw: Record<string, unknown> = {};
5353+ const lines = frontmatterStr.split("\n");
37543838- const key = line.slice(0, sepIndex).trim();
3939- let value = line.slice(sepIndex + 1).trim();
5555+ let i = 0;
5656+ while (i < lines.length) {
5757+ const line = lines[i];
5858+ if (line === undefined) {
5959+ i++;
6060+ continue;
6161+ }
6262+ const sepIndex = line.indexOf(separator);
6363+ if (sepIndex === -1) {
6464+ i++;
6565+ continue;
6666+ }
40674141- // Handle quoted strings
4242- if (
4343- (value.startsWith('"') && value.endsWith('"')) ||
4444- (value.startsWith("'") && value.endsWith("'"))
4545- ) {
4646- value = value.slice(1, -1);
4747- }
6868+ const key = line.slice(0, sepIndex).trim();
6969+ let value = line.slice(sepIndex + 1).trim();
7070+7171+ // Handle quoted strings
7272+ if (
7373+ (value.startsWith('"') && value.endsWith('"')) ||
7474+ (value.startsWith("'") && value.endsWith("'"))
7575+ ) {
7676+ value = value.slice(1, -1);
7777+ }
7878+7979+ // Handle inline arrays (simple case for tags)
8080+ if (value.startsWith("[") && value.endsWith("]")) {
8181+ const arrayContent = value.slice(1, -1);
8282+ raw[key] = arrayContent
8383+ .split(",")
8484+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
8585+ } else if (value === "" && !isToml) {
8686+ // Check for YAML-style multiline array (key with no value followed by - items)
8787+ const arrayItems: string[] = [];
8888+ let j = i + 1;
8989+ while (j < lines.length) {
9090+ const nextLine = lines[j];
9191+ if (nextLine === undefined) {
9292+ j++;
9393+ continue;
9494+ }
9595+ // Check if line is a list item (starts with whitespace and -)
9696+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
9797+ if (listMatch && listMatch[1] !== undefined) {
9898+ let itemValue = listMatch[1].trim();
9999+ // Remove quotes if present
100100+ if (
101101+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
102102+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
103103+ ) {
104104+ itemValue = itemValue.slice(1, -1);
105105+ }
106106+ arrayItems.push(itemValue);
107107+ j++;
108108+ } else if (nextLine.trim() === "") {
109109+ // Skip empty lines within the array
110110+ j++;
111111+ } else {
112112+ // Hit a new key or non-list content
113113+ break;
114114+ }
115115+ }
116116+ if (arrayItems.length > 0) {
117117+ raw[key] = arrayItems;
118118+ i = j;
119119+ continue;
120120+ } else {
121121+ raw[key] = value;
122122+ }
123123+ } else if (value === "true") {
124124+ raw[key] = true;
125125+ } else if (value === "false") {
126126+ raw[key] = false;
127127+ } else {
128128+ raw[key] = value;
129129+ }
130130+ i++;
131131+ }
481324949- // Handle arrays (simple case for tags)
5050- if (value.startsWith("[") && value.endsWith("]")) {
5151- const arrayContent = value.slice(1, -1);
5252- raw[key] = arrayContent
5353- .split(",")
5454- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
5555- } else if (value === "true") {
5656- raw[key] = true;
5757- } else if (value === "false") {
5858- raw[key] = false;
5959- } else {
6060- raw[key] = value;
6161- }
6262- }
133133+ // Apply field mappings to normalize to standard PostFrontmatter fields
134134+ const frontmatter: Record<string, unknown> = {};
631356464- // Apply field mappings to normalize to standard PostFrontmatter fields
6565- const frontmatter: Record<string, unknown> = {};
136136+ // Title mapping
137137+ const titleField = mapping?.title || "title";
138138+ frontmatter.title = raw[titleField] || raw.title;
661396767- // Title mapping
6868- const titleField = mapping?.title || "title";
6969- frontmatter.title = raw[titleField] || raw.title;
140140+ // Description mapping
141141+ const descField = mapping?.description || "description";
142142+ frontmatter.description = raw[descField] || raw.description;
701437171- // Description mapping
7272- const descField = mapping?.description || "description";
7373- frontmatter.description = raw[descField] || raw.description;
144144+ // Publish date mapping - check custom field first, then fallbacks
145145+ const dateField = mapping?.publishDate;
146146+ if (dateField && raw[dateField]) {
147147+ frontmatter.publishDate = raw[dateField];
148148+ } else if (raw.publishDate) {
149149+ frontmatter.publishDate = raw.publishDate;
150150+ } else {
151151+ // Fallback to common date field names
152152+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
153153+ for (const field of dateFields) {
154154+ if (raw[field]) {
155155+ frontmatter.publishDate = raw[field];
156156+ break;
157157+ }
158158+ }
159159+ }
741607575- // Publish date mapping - check custom field first, then fallbacks
7676- const dateField = mapping?.publishDate;
7777- if (dateField && raw[dateField]) {
7878- frontmatter.publishDate = raw[dateField];
7979- } else if (raw.publishDate) {
8080- frontmatter.publishDate = raw.publishDate;
8181- } else {
8282- // Fallback to common date field names
8383- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
8484- for (const field of dateFields) {
8585- if (raw[field]) {
8686- frontmatter.publishDate = raw[field];
8787- break;
8888- }
8989- }
9090- }
161161+ // Cover image mapping
162162+ const coverField = mapping?.coverImage || "ogImage";
163163+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
911649292- // Cover image mapping
9393- const coverField = mapping?.coverImage || "ogImage";
9494- frontmatter.ogImage = raw[coverField] || raw.ogImage;
165165+ // Tags mapping
166166+ const tagsField = mapping?.tags || "tags";
167167+ frontmatter.tags = raw[tagsField] || raw.tags;
951689696- // Tags mapping
9797- const tagsField = mapping?.tags || "tags";
9898- frontmatter.tags = raw[tagsField] || raw.tags;
169169+ // Draft mapping
170170+ const draftField = mapping?.draft || "draft";
171171+ const draftValue = raw[draftField] ?? raw.draft;
172172+ if (draftValue !== undefined) {
173173+ frontmatter.draft = draftValue === true || draftValue === "true";
174174+ }
99175100100- // Always preserve atUri (internal field)
101101- frontmatter.atUri = raw.atUri;
176176+ // Always preserve atUri (internal field)
177177+ frontmatter.atUri = raw.atUri;
102178103103- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
179179+ return {
180180+ frontmatter: frontmatter as unknown as PostFrontmatter,
181181+ body,
182182+ rawFrontmatter: raw,
183183+ };
104184}
105185106186export function getSlugFromFilename(filename: string): string {
107107- return filename
108108- .replace(/\.mdx?$/, "")
109109- .toLowerCase()
110110- .replace(/\s+/g, "-");
187187+ return filename
188188+ .replace(/\.mdx?$/, "")
189189+ .toLowerCase()
190190+ .replace(/\s+/g, "-");
191191+}
192192+193193+export interface SlugOptions {
194194+ slugField?: string;
195195+ removeIndexFromSlug?: boolean;
196196+ stripDatePrefix?: boolean;
197197+}
198198+199199+export function getSlugFromOptions(
200200+ relativePath: string,
201201+ rawFrontmatter: Record<string, unknown>,
202202+ options: SlugOptions = {},
203203+): string {
204204+ const {
205205+ slugField,
206206+ removeIndexFromSlug = false,
207207+ stripDatePrefix = false,
208208+ } = options;
209209+210210+ let slug: string;
211211+212212+ // If slugField is set, try to get the value from frontmatter
213213+ if (slugField) {
214214+ const frontmatterValue = rawFrontmatter[slugField];
215215+ if (frontmatterValue && typeof frontmatterValue === "string") {
216216+ // Remove leading slash if present
217217+ slug = frontmatterValue
218218+ .replace(/^\//, "")
219219+ .toLowerCase()
220220+ .replace(/\s+/g, "-");
221221+ } else {
222222+ // Fallback to filepath if frontmatter field not found
223223+ slug = relativePath
224224+ .replace(/\.mdx?$/, "")
225225+ .toLowerCase()
226226+ .replace(/\s+/g, "-");
227227+ }
228228+ } else {
229229+ // Default: use filepath
230230+ slug = relativePath
231231+ .replace(/\.mdx?$/, "")
232232+ .toLowerCase()
233233+ .replace(/\s+/g, "-");
234234+ }
235235+236236+ // Remove /index or /_index suffix if configured
237237+ if (removeIndexFromSlug) {
238238+ slug = slug.replace(/\/_?index$/, "");
239239+ }
240240+241241+ // Strip Jekyll-style date prefix (YYYY-MM-DD-) from filename
242242+ if (stripDatePrefix) {
243243+ slug = slug.replace(/(^|\/)(\d{4}-\d{2}-\d{2})-/g, "$1");
244244+ }
245245+246246+ return slug;
111247}
112248113249export async function getContentHash(content: string): Promise<string> {
114114- const encoder = new TextEncoder();
115115- const data = encoder.encode(content);
116116- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
117117- const hashArray = Array.from(new Uint8Array(hashBuffer));
118118- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
250250+ const encoder = new TextEncoder();
251251+ const data = encoder.encode(content);
252252+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
253253+ const hashArray = Array.from(new Uint8Array(hashBuffer));
254254+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
119255}
120256121257function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
122122- for (const pattern of ignorePatterns) {
123123- const glob = new Glob(pattern);
124124- if (glob.match(relativePath)) {
125125- return true;
126126- }
127127- }
128128- return false;
258258+ for (const pattern of ignorePatterns) {
259259+ if (minimatch(relativePath, pattern)) {
260260+ return true;
261261+ }
262262+ }
263263+ return false;
264264+}
265265+266266+export interface ScanOptions {
267267+ frontmatterMapping?: FrontmatterMapping;
268268+ ignorePatterns?: string[];
269269+ slugField?: string;
270270+ removeIndexFromSlug?: boolean;
271271+ stripDatePrefix?: boolean;
129272}
130273131274export async function scanContentDirectory(
132132- contentDir: string,
133133- frontmatterMapping?: FrontmatterMapping,
134134- ignorePatterns: string[] = []
275275+ contentDir: string,
276276+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
277277+ ignorePatterns: string[] = [],
135278): Promise<BlogPost[]> {
136136- const patterns = ["**/*.md", "**/*.mdx"];
137137- const posts: BlogPost[] = [];
279279+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
280280+ let options: ScanOptions;
281281+ if (
282282+ frontmatterMappingOrOptions &&
283283+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
284284+ "ignorePatterns" in frontmatterMappingOrOptions ||
285285+ "slugField" in frontmatterMappingOrOptions)
286286+ ) {
287287+ options = frontmatterMappingOrOptions as ScanOptions;
288288+ } else {
289289+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
290290+ options = {
291291+ frontmatterMapping: frontmatterMappingOrOptions as
292292+ | FrontmatterMapping
293293+ | undefined,
294294+ ignorePatterns,
295295+ };
296296+ }
138297139139- for (const pattern of patterns) {
140140- const glob = new Glob(pattern);
298298+ const {
299299+ frontmatterMapping,
300300+ ignorePatterns: ignore = [],
301301+ slugField,
302302+ removeIndexFromSlug,
303303+ stripDatePrefix,
304304+ } = options;
141305142142- for await (const relativePath of glob.scan({
143143- cwd: contentDir,
144144- absolute: false,
145145- })) {
146146- // Skip files matching ignore patterns
147147- if (shouldIgnore(relativePath, ignorePatterns)) {
148148- continue;
149149- }
306306+ const patterns = ["**/*.md", "**/*.mdx"];
307307+ const posts: BlogPost[] = [];
150308151151- const filePath = path.join(contentDir, relativePath);
152152- const file = Bun.file(filePath);
153153- const rawContent = await file.text();
309309+ for (const pattern of patterns) {
310310+ const files = await glob(pattern, {
311311+ cwd: contentDir,
312312+ absolute: false,
313313+ });
154314155155- try {
156156- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
157157- const filename = path.basename(relativePath);
158158- const slug = getSlugFromFilename(filename);
315315+ for (const relativePath of files) {
316316+ // Skip files matching ignore patterns
317317+ if (shouldIgnore(relativePath, ignore)) {
318318+ continue;
319319+ }
159320160160- 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- }
321321+ const filePath = path.join(contentDir, relativePath);
322322+ const rawContent = await fs.readFile(filePath, "utf-8");
172323173173- // 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- });
324324+ try {
325325+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
326326+ rawContent,
327327+ frontmatterMapping,
328328+ );
329329+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
330330+ slugField,
331331+ removeIndexFromSlug,
332332+ stripDatePrefix,
333333+ });
179334180180- return posts;
335335+ posts.push({
336336+ filePath,
337337+ slug,
338338+ frontmatter,
339339+ content: body,
340340+ rawContent,
341341+ rawFrontmatter,
342342+ });
343343+ } catch (error) {
344344+ console.error(`Error parsing ${relativePath}:`, error);
345345+ }
346346+ }
347347+ }
348348+349349+ // Sort by publish date (newest first)
350350+ posts.sort((a, b) => {
351351+ const dateA = new Date(a.frontmatter.publishDate);
352352+ const dateB = new Date(b.frontmatter.publishDate);
353353+ return dateB.getTime() - dateA.getTime();
354354+ });
355355+356356+ return posts;
181357}
182358183183-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 === "+++";
359359+export function updateFrontmatterWithAtUri(
360360+ rawContent: string,
361361+ atUri: string,
362362+): string {
363363+ // Detect which delimiter is used (---, +++, or ***)
364364+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
365365+ const delimiter = delimiterMatch?.[1] ?? "---";
366366+ const isToml = delimiter === "+++";
367367+368368+ // Format the atUri entry based on frontmatter type
369369+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
188370189189- // Format the atUri entry based on frontmatter type
190190- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
371371+ // No frontmatter: create one with atUri
372372+ if (!delimiterMatch) {
373373+ return `---\n${atUriEntry}\n---\n\n${rawContent}`;
374374+ }
191375192192- // 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- }
376376+ // Check if atUri already exists in frontmatter (handle both formats)
377377+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
378378+ // Replace existing atUri (match both YAML and TOML formats)
379379+ return rawContent.replace(
380380+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
381381+ `${atUriEntry}\n`,
382382+ );
383383+ }
197384198198- // 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- }
385385+ // Insert atUri before the closing delimiter
386386+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
387387+ if (frontmatterEndIndex === -1) {
388388+ throw new Error("Could not find frontmatter end");
389389+ }
203390204204- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
205205- const afterEnd = rawContent.slice(frontmatterEndIndex);
391391+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
392392+ const afterEnd = rawContent.slice(frontmatterEndIndex);
206393207207- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
394394+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
208395}
209396210397export 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();
398398+ return markdown
399399+ .replace(/#{1,6}\s/g, "") // Remove headers
400400+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
401401+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
402402+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
403403+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
404404+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
405405+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
406406+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
407407+ .trim();
408408+}
409409+410410+export function getTextContent(
411411+ post: { content: string; rawFrontmatter?: Record<string, unknown> },
412412+ textContentField?: string,
413413+): string {
414414+ if (textContentField && post.rawFrontmatter?.[textContentField]) {
415415+ return String(post.rawFrontmatter[textContentField]);
416416+ }
417417+ return stripMarkdownForText(post.content);
221418}
+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}
+56-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)
721}
822923export interface PublisherConfig {
···1832 identity?: string; // Which stored identity to use (matches identifier)
1933 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
2034 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
3535+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
3636+ stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false)
3737+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
3838+ bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
2139}
22402323-export interface Credentials {
4141+// Legacy credentials format (for backward compatibility during migration)
4242+export interface LegacyCredentials {
4343+ pdsUrl: string;
4444+ identifier: string;
4545+ password: string;
4646+}
4747+4848+// App password credentials (explicit type)
4949+export interface AppPasswordCredentials {
5050+ type: "app-password";
2451 pdsUrl: string;
2552 identifier: string;
2653 password: string;
2754}
28555656+// OAuth credentials (references stored OAuth session)
5757+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
5858+export interface OAuthCredentials {
5959+ type: "oauth";
6060+ did: string;
6161+ handle: string;
6262+}
6363+6464+// Union type for all credential types
6565+export type Credentials = AppPasswordCredentials | OAuthCredentials;
6666+6767+// Helper to check credential type
6868+export function isOAuthCredentials(
6969+ creds: Credentials,
7070+): creds is OAuthCredentials {
7171+ return creds.type === "oauth";
7272+}
7373+7474+export function isAppPasswordCredentials(
7575+ creds: Credentials,
7676+): creds is AppPasswordCredentials {
7777+ return creds.type === "app-password";
7878+}
7979+2980export interface PostFrontmatter {
3081 title: string;
3182 description?: string;
···3384 tags?: string[];
3485 ogImage?: string;
3586 atUri?: string;
8787+ draft?: boolean;
3688}
37893890export interface BlogPost {
···4193 frontmatter: PostFrontmatter;
4294 content: string;
4395 rawContent: string;
9696+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
4497}
45984699export interface BlobRef {
···62115 contentHash: string;
63116 atUri?: string;
64117 lastPublished?: string;
118118+ slug?: string; // The generated slug for this post (used by inject command)
119119+ bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
65120}
6612167122export interface PublicationRecord {