···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+:::
+28-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+| `bluesky` | `object` | No | - | Bluesky posting configuration |
2121+| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
2222+| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
18231924### Example
2025···3136 "frontmatter": {
3237 "publishDate": "date"
3338 },
3434- "ignore": ["_index.md"]
3939+ "ignore": ["_index.md"],
4040+ "bluesky": {
4141+ "enabled": true,
4242+ "maxAgeDays": 30
4343+ }
3544}
3645```
3746···4453| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
4554| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
4655| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
5656+| `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish |
47574858### Example
4959···5464publishDate: 2024-01-15
5565ogImage: cover.jpg
5666tags: [welcome, intro]
6767+draft: false
5768---
5869```
5970···6576{
6677 "frontmatter": {
6778 "publishDate": "date",
6868- "coverImage": "thumbnail"
7979+ "coverImage": "thumbnail",
8080+ "draft": "private"
6981 }
7082}
7183```
8484+8585+### Slug Configuration
8686+8787+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
8888+8989+```json
9090+{
9191+ "frontmatter": {
9292+ "slugField": "url"
9393+ }
9494+}
9595+```
9696+9797+If the frontmatter field is not found, it falls back to the filepath.
72987399### Ignoring Files
74100
+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
+2-2
docs/docs/pages/quickstart.mdx
···33333434### Authorize
35353636-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.
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.
37373838:::tip
3939You can create an app password [here](https://bsky.app/settings/app-passwords)
···5959- **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).
6060- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
6161- **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.
6262+- **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.
6363- **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`.
6464 - **Publication name** - The name of your blog
6565 - **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
···11-import * as path from "path";
22-import * as os from "os";
11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
34import type { Credentials } from "./types";
4556const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
···89// Stored credentials keyed by identifier
910type CredentialsStore = Record<string, Credentials>;
10111212+async function fileExists(filePath: string): Promise<boolean> {
1313+ try {
1414+ await fs.access(filePath);
1515+ return true;
1616+ } catch {
1717+ return false;
1818+ }
1919+}
2020+1121/**
1222 * Load all stored credentials
1323 */
1424async function loadCredentialsStore(): Promise<CredentialsStore> {
1515- const file = Bun.file(CREDENTIALS_FILE);
1616- if (!(await file.exists())) {
1717- return {};
1818- }
2525+ if (!(await fileExists(CREDENTIALS_FILE))) {
2626+ return {};
2727+ }
19282020- try {
2121- const content = await file.text();
2222- const parsed = JSON.parse(content);
2929+ try {
3030+ const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
3131+ const parsed = JSON.parse(content);
23322424- // Handle legacy single-credential format (migrate on read)
2525- if (parsed.identifier && parsed.password) {
2626- const legacy = parsed as Credentials;
2727- return { [legacy.identifier]: legacy };
2828- }
3333+ // 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+ }
29383030- return parsed as CredentialsStore;
3131- } catch {
3232- return {};
3333- }
3939+ return parsed as CredentialsStore;
4040+ } catch {
4141+ return {};
4242+ }
3443}
35443645/**
3746 * Save the entire credentials store
3847 */
3948async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
4040- await Bun.$`mkdir -p ${CONFIG_DIR}`;
4141- await Bun.write(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
4242- await Bun.$`chmod 600 ${CREDENTIALS_FILE}`;
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);
4352}
44534554/**
···5362 * 5. Return null (caller should prompt user)
5463 */
5564export async function loadCredentials(
5656- projectIdentity?: string
6565+ projectIdentity?: string,
5766): Promise<Credentials | null> {
5858- // 1. Check environment variables first (full override)
5959- const envIdentifier = process.env.ATP_IDENTIFIER;
6060- const envPassword = process.env.ATP_APP_PASSWORD;
6161- const envPdsUrl = process.env.PDS_URL;
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;
62716363- if (envIdentifier && envPassword) {
6464- return {
6565- identifier: envIdentifier,
6666- password: envPassword,
6767- pdsUrl: envPdsUrl || "https://bsky.social",
6868- };
6969- }
7272+ if (envIdentifier && envPassword) {
7373+ return {
7474+ identifier: envIdentifier,
7575+ password: envPassword,
7676+ pdsUrl: envPdsUrl || "https://bsky.social",
7777+ };
7878+ }
70797171- const store = await loadCredentialsStore();
7272- const identifiers = Object.keys(store);
8080+ const store = await loadCredentialsStore();
8181+ const identifiers = Object.keys(store);
73827474- if (identifiers.length === 0) {
7575- return null;
7676- }
8383+ if (identifiers.length === 0) {
8484+ return null;
8585+ }
77867878- // 2. SEQUOIA_PROFILE env var
7979- const profileEnv = process.env.SEQUOIA_PROFILE;
8080- if (profileEnv && store[profileEnv]) {
8181- return store[profileEnv];
8282- }
8787+ // 2. SEQUOIA_PROFILE env var
8888+ const profileEnv = process.env.SEQUOIA_PROFILE;
8989+ if (profileEnv && store[profileEnv]) {
9090+ return store[profileEnv];
9191+ }
83928484- // 3. Project-specific identity (from sequoia.json)
8585- if (projectIdentity && store[projectIdentity]) {
8686- return store[projectIdentity];
8787- }
9393+ // 3. Project-specific identity (from sequoia.json)
9494+ if (projectIdentity && store[projectIdentity]) {
9595+ return store[projectIdentity];
9696+ }
88978989- // 4. If only one identity, use it
9090- if (identifiers.length === 1 && identifiers[0]) {
9191- return store[identifiers[0]] ?? null;
9292- }
9898+ // 4. If only one identity, use it
9999+ if (identifiers.length === 1 && identifiers[0]) {
100100+ return store[identifiers[0]] ?? null;
101101+ }
931029494- // Multiple identities exist but none selected
9595- return null;
103103+ // Multiple identities exist but none selected
104104+ return null;
96105}
9710698107/**
99108 * Get a specific identity by identifier
100109 */
101110export async function getCredentials(
102102- identifier: string
111111+ identifier: string,
103112): Promise<Credentials | null> {
104104- const store = await loadCredentialsStore();
105105- return store[identifier] || null;
113113+ const store = await loadCredentialsStore();
114114+ return store[identifier] || null;
106115}
107116108117/**
109118 * List all stored identities
110119 */
111120export async function listCredentials(): Promise<string[]> {
112112- const store = await loadCredentialsStore();
113113- return Object.keys(store);
121121+ const store = await loadCredentialsStore();
122122+ return Object.keys(store);
114123}
115124116125/**
117126 * Save credentials for an identity (adds or updates)
118127 */
119128export async function saveCredentials(credentials: Credentials): Promise<void> {
120120- const store = await loadCredentialsStore();
121121- store[credentials.identifier] = credentials;
122122- await saveCredentialsStore(store);
129129+ const store = await loadCredentialsStore();
130130+ store[credentials.identifier] = credentials;
131131+ await saveCredentialsStore(store);
123132}
124133125134/**
126135 * Delete credentials for a specific identity
127136 */
128137export async function deleteCredentials(identifier?: string): Promise<boolean> {
129129- const store = await loadCredentialsStore();
130130- const identifiers = Object.keys(store);
138138+ const store = await loadCredentialsStore();
139139+ const identifiers = Object.keys(store);
131140132132- if (identifiers.length === 0) {
133133- return false;
134134- }
141141+ if (identifiers.length === 0) {
142142+ return false;
143143+ }
135144136136- // If identifier specified, delete just that one
137137- if (identifier) {
138138- if (!store[identifier]) {
139139- return false;
140140- }
141141- delete store[identifier];
142142- await saveCredentialsStore(store);
143143- return true;
144144- }
145145+ // 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+ }
145154146146- // If only one identity, delete it (backwards compat behavior)
147147- if (identifiers.length === 1 && identifiers[0]) {
148148- delete store[identifiers[0]];
149149- await saveCredentialsStore(store);
150150- return true;
151151- }
155155+ // 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+ }
152161153153- // Multiple identities but none specified
154154- return false;
162162+ // Multiple identities but none specified
163163+ return false;
155164}
156165157166export function getCredentialsPath(): string {
158158- return CREDENTIALS_FILE;
167167+ return CREDENTIALS_FILE;
159168}
+326-172
packages/cli/src/lib/markdown.ts
···11-import * as path from "path";
22-import { Glob } from "bun";
33-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33+import { glob } from "glob";
44+import { minimatch } from "minimatch";
55+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
4655-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
66- frontmatter: PostFrontmatter;
77- body: string;
77+export function parseFrontmatter(
88+ content: string,
99+ mapping?: FrontmatterMapping,
1010+): {
1111+ frontmatter: PostFrontmatter;
1212+ body: string;
1313+ rawFrontmatter: Record<string, unknown>;
814} {
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);
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);
2121+2222+ if (!match) {
2323+ throw new Error("Could not parse frontmatter");
2424+ }
15251616- if (!match) {
1717- throw new Error("Could not parse frontmatter");
1818- }
2626+ const delimiter = match[1];
2727+ const frontmatterStr = match[2] ?? "";
2828+ const body = match[3] ?? "";
19292020- const delimiter = match[1];
2121- const frontmatterStr = match[2] ?? "";
2222- const body = match[3] ?? "";
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 ? "=" : ":";
23352424- // Determine format based on delimiter:
2525- // +++ uses TOML (key = value)
2626- // --- and *** use YAML (key: value)
2727- const isToml = delimiter === "+++";
2828- const separator = isToml ? "=" : ":";
3636+ // Parse frontmatter manually
3737+ const raw: Record<string, unknown> = {};
3838+ const lines = frontmatterStr.split("\n");
29393030- // Parse frontmatter manually
3131- const raw: Record<string, unknown> = {};
3232- const lines = frontmatterStr.split("\n");
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+ }
33523434- for (const line of lines) {
3535- const sepIndex = line.indexOf(separator);
3636- if (sepIndex === -1) continue;
5353+ const key = line.slice(0, sepIndex).trim();
5454+ let value = line.slice(sepIndex + 1).trim();
37553838- const key = line.slice(0, sepIndex).trim();
3939- let value = line.slice(sepIndex + 1).trim();
5656+ // Handle quoted strings
5757+ if (
5858+ (value.startsWith('"') && value.endsWith('"')) ||
5959+ (value.startsWith("'") && value.endsWith("'"))
6060+ ) {
6161+ value = value.slice(1, -1);
6262+ }
40634141- // Handle quoted strings
4242- if (
4343- (value.startsWith('"') && value.endsWith('"')) ||
4444- (value.startsWith("'") && value.endsWith("'"))
4545- ) {
4646- value = value.slice(1, -1);
4747- }
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+ }
481174949- // 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- }
118118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119119+ const frontmatter: Record<string, unknown> = {};
631206464- // Apply field mappings to normalize to standard PostFrontmatter fields
6565- const frontmatter: Record<string, unknown> = {};
121121+ // Title mapping
122122+ const titleField = mapping?.title || "title";
123123+ frontmatter.title = raw[titleField] || raw.title;
661246767- // Title mapping
6868- const titleField = mapping?.title || "title";
6969- frontmatter.title = raw[titleField] || raw.title;
125125+ // Description mapping
126126+ const descField = mapping?.description || "description";
127127+ frontmatter.description = raw[descField] || raw.description;
701287171- // Description mapping
7272- const descField = mapping?.description || "description";
7373- frontmatter.description = raw[descField] || raw.description;
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+ }
741457575- // 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- }
146146+ // Cover image mapping
147147+ const coverField = mapping?.coverImage || "ogImage";
148148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
911499292- // Cover image mapping
9393- const coverField = mapping?.coverImage || "ogImage";
9494- frontmatter.ogImage = raw[coverField] || raw.ogImage;
150150+ // Tags mapping
151151+ const tagsField = mapping?.tags || "tags";
152152+ frontmatter.tags = raw[tagsField] || raw.tags;
951539696- // Tags mapping
9797- const tagsField = mapping?.tags || "tags";
9898- 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+ }
99160100100- // Always preserve atUri (internal field)
101101- frontmatter.atUri = raw.atUri;
161161+ // Always preserve atUri (internal field)
162162+ frontmatter.atUri = raw.atUri;
102163103103- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
164164+ return {
165165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166166+ body,
167167+ rawFrontmatter: raw,
168168+ };
104169}
105170106171export function getSlugFromFilename(filename: string): string {
107107- return filename
108108- .replace(/\.mdx?$/, "")
109109- .toLowerCase()
110110- .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+}
182182+183183+export function getSlugFromOptions(
184184+ relativePath: string,
185185+ rawFrontmatter: Record<string, unknown>,
186186+ options: SlugOptions = {},
187187+): string {
188188+ const { slugField, removeIndexFromSlug = false } = options;
189189+190190+ let slug: string;
191191+192192+ // If slugField is set, try to get the value from frontmatter
193193+ if (slugField) {
194194+ const frontmatterValue = rawFrontmatter[slugField];
195195+ if (frontmatterValue && typeof frontmatterValue === "string") {
196196+ // Remove leading slash if present
197197+ slug = frontmatterValue
198198+ .replace(/^\//, "")
199199+ .toLowerCase()
200200+ .replace(/\s+/g, "-");
201201+ } else {
202202+ // Fallback to filepath if frontmatter field not found
203203+ slug = relativePath
204204+ .replace(/\.mdx?$/, "")
205205+ .toLowerCase()
206206+ .replace(/\s+/g, "-");
207207+ }
208208+ } else {
209209+ // Default: use filepath
210210+ slug = relativePath
211211+ .replace(/\.mdx?$/, "")
212212+ .toLowerCase()
213213+ .replace(/\s+/g, "-");
214214+ }
215215+216216+ // Remove /index or /_index suffix if configured
217217+ if (removeIndexFromSlug) {
218218+ slug = slug.replace(/\/_?index$/, "");
219219+ }
220220+221221+ return slug;
111222}
112223113224export 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("");
225225+ const encoder = new TextEncoder();
226226+ const data = encoder.encode(content);
227227+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
228228+ const hashArray = Array.from(new Uint8Array(hashBuffer));
229229+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
119230}
120231121232function 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;
233233+ for (const pattern of ignorePatterns) {
234234+ if (minimatch(relativePath, pattern)) {
235235+ return true;
236236+ }
237237+ }
238238+ return false;
239239+}
240240+241241+export interface ScanOptions {
242242+ frontmatterMapping?: FrontmatterMapping;
243243+ ignorePatterns?: string[];
244244+ slugField?: string;
245245+ removeIndexFromSlug?: boolean;
129246}
130247131248export async function scanContentDirectory(
132132- contentDir: string,
133133- frontmatterMapping?: FrontmatterMapping,
134134- ignorePatterns: string[] = []
249249+ contentDir: string,
250250+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
251251+ ignorePatterns: string[] = [],
135252): Promise<BlogPost[]> {
136136- const patterns = ["**/*.md", "**/*.mdx"];
137137- const posts: BlogPost[] = [];
253253+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
254254+ let options: ScanOptions;
255255+ if (
256256+ frontmatterMappingOrOptions &&
257257+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
258258+ "ignorePatterns" in frontmatterMappingOrOptions ||
259259+ "slugField" in frontmatterMappingOrOptions)
260260+ ) {
261261+ options = frontmatterMappingOrOptions as ScanOptions;
262262+ } else {
263263+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
264264+ options = {
265265+ frontmatterMapping: frontmatterMappingOrOptions as
266266+ | FrontmatterMapping
267267+ | undefined,
268268+ ignorePatterns,
269269+ };
270270+ }
138271139139- for (const pattern of patterns) {
140140- const glob = new Glob(pattern);
272272+ const {
273273+ frontmatterMapping,
274274+ ignorePatterns: ignore = [],
275275+ slugField,
276276+ removeIndexFromSlug,
277277+ } = options;
278278+279279+ const patterns = ["**/*.md", "**/*.mdx"];
280280+ const posts: BlogPost[] = [];
281281+282282+ for (const pattern of patterns) {
283283+ const files = await glob(pattern, {
284284+ cwd: contentDir,
285285+ absolute: false,
286286+ });
141287142142- 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- }
288288+ for (const relativePath of files) {
289289+ // Skip files matching ignore patterns
290290+ if (shouldIgnore(relativePath, ignore)) {
291291+ continue;
292292+ }
150293151151- const filePath = path.join(contentDir, relativePath);
152152- const file = Bun.file(filePath);
153153- const rawContent = await file.text();
294294+ const filePath = path.join(contentDir, relativePath);
295295+ const rawContent = await fs.readFile(filePath, "utf-8");
154296155155- try {
156156- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
157157- const filename = path.basename(relativePath);
158158- const slug = getSlugFromFilename(filename);
297297+ try {
298298+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
299299+ rawContent,
300300+ frontmatterMapping,
301301+ );
302302+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
303303+ slugField,
304304+ removeIndexFromSlug,
305305+ });
159306160160- 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- }
307307+ posts.push({
308308+ filePath,
309309+ slug,
310310+ frontmatter,
311311+ content: body,
312312+ rawContent,
313313+ rawFrontmatter,
314314+ });
315315+ } catch (error) {
316316+ console.error(`Error parsing ${relativePath}:`, error);
317317+ }
318318+ }
319319+ }
172320173173- // 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- });
321321+ // Sort by publish date (newest first)
322322+ posts.sort((a, b) => {
323323+ const dateA = new Date(a.frontmatter.publishDate);
324324+ const dateB = new Date(b.frontmatter.publishDate);
325325+ return dateB.getTime() - dateA.getTime();
326326+ });
179327180180- return posts;
328328+ return posts;
181329}
182330183183-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 === "+++";
331331+export function updateFrontmatterWithAtUri(
332332+ rawContent: string,
333333+ atUri: string,
334334+): string {
335335+ // Detect which delimiter is used (---, +++, or ***)
336336+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
337337+ const delimiter = delimiterMatch?.[1] ?? "---";
338338+ const isToml = delimiter === "+++";
188339189189- // Format the atUri entry based on frontmatter type
190190- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
340340+ // Format the atUri entry based on frontmatter type
341341+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
191342192192- // 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- }
343343+ // Check if atUri already exists in frontmatter (handle both formats)
344344+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
345345+ // Replace existing atUri (match both YAML and TOML formats)
346346+ return rawContent.replace(
347347+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
348348+ `${atUriEntry}\n`,
349349+ );
350350+ }
197351198198- // 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- }
352352+ // Insert atUri before the closing delimiter
353353+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
354354+ if (frontmatterEndIndex === -1) {
355355+ throw new Error("Could not find frontmatter end");
356356+ }
203357204204- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
205205- const afterEnd = rawContent.slice(frontmatterEndIndex);
358358+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
359359+ const afterEnd = rawContent.slice(frontmatterEndIndex);
206360207207- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
361361+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
208362}
209363210364export 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();
365365+ return markdown
366366+ .replace(/#{1,6}\s/g, "") // Remove headers
367367+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
368368+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
369369+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
370370+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
371371+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
372372+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
373373+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
374374+ .trim();
221375}
+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}
+21
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+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
3737+ bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
2138}
22392340export interface Credentials {
···3350 tags?: string[];
3451 ogImage?: string;
3552 atUri?: string;
5353+ draft?: boolean;
3654}
37553856export interface BlogPost {
···4159 frontmatter: PostFrontmatter;
4260 content: string;
4361 rawContent: string;
6262+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
4463}
45644665export interface BlobRef {
···6281 contentHash: string;
6382 atUri?: string;
6483 lastPublished?: string;
8484+ slug?: string; // The generated slug for this post (used by inject command)
8585+ bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
6586}
66876788export interface PublicationRecord {