···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
017| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
018| `bluesky` | `object` | No | - | Bluesky posting configuration |
19| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
20| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
···79 }
80}
81```
000000000000008283### Ignoring Files
84
···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
17+| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
18| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
19+| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
20| `bluesky` | `object` | No | - | Bluesky posting configuration |
21| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
22| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
···81 }
82}
83```
84+85+### Slug Configuration
86+87+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
88+89+```json
90+{
91+ "frontmatter": {
92+ "slugField": "url"
93+ }
94+}
95+```
96+97+If the frontmatter field is not found, it falls back to the filepath.
9899### Ignoring Files
100
···1-import * as fs from "fs/promises";
2-import * as path from "path";
3-import * as os from "os";
4import type { Credentials } from "./types";
56const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
···10type CredentialsStore = Record<string, Credentials>;
1112async function fileExists(filePath: string): Promise<boolean> {
13- try {
14- await fs.access(filePath);
15- return true;
16- } catch {
17- return false;
18- }
19}
2021/**
22 * Load all stored credentials
23 */
24async function loadCredentialsStore(): Promise<CredentialsStore> {
25- if (!(await fileExists(CREDENTIALS_FILE))) {
26- return {};
27- }
2829- try {
30- const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
31- const parsed = JSON.parse(content);
3233- // Handle legacy single-credential format (migrate on read)
34- if (parsed.identifier && parsed.password) {
35- const legacy = parsed as Credentials;
36- return { [legacy.identifier]: legacy };
37- }
3839- return parsed as CredentialsStore;
40- } catch {
41- return {};
42- }
43}
4445/**
46 * Save the entire credentials store
47 */
48async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
49- await fs.mkdir(CONFIG_DIR, { recursive: true });
50- await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
51- await fs.chmod(CREDENTIALS_FILE, 0o600);
52}
5354/**
···62 * 5. Return null (caller should prompt user)
63 */
64export async function loadCredentials(
65- projectIdentity?: string
66): Promise<Credentials | null> {
67- // 1. Check environment variables first (full override)
68- const envIdentifier = process.env.ATP_IDENTIFIER;
69- const envPassword = process.env.ATP_APP_PASSWORD;
70- const envPdsUrl = process.env.PDS_URL;
7172- if (envIdentifier && envPassword) {
73- return {
74- identifier: envIdentifier,
75- password: envPassword,
76- pdsUrl: envPdsUrl || "https://bsky.social",
77- };
78- }
7980- const store = await loadCredentialsStore();
81- const identifiers = Object.keys(store);
8283- if (identifiers.length === 0) {
84- return null;
85- }
8687- // 2. SEQUOIA_PROFILE env var
88- const profileEnv = process.env.SEQUOIA_PROFILE;
89- if (profileEnv && store[profileEnv]) {
90- return store[profileEnv];
91- }
9293- // 3. Project-specific identity (from sequoia.json)
94- if (projectIdentity && store[projectIdentity]) {
95- return store[projectIdentity];
96- }
9798- // 4. If only one identity, use it
99- if (identifiers.length === 1 && identifiers[0]) {
100- return store[identifiers[0]] ?? null;
101- }
102103- // Multiple identities exist but none selected
104- return null;
105}
106107/**
108 * Get a specific identity by identifier
109 */
110export async function getCredentials(
111- identifier: string
112): Promise<Credentials | null> {
113- const store = await loadCredentialsStore();
114- return store[identifier] || null;
115}
116117/**
118 * List all stored identities
119 */
120export async function listCredentials(): Promise<string[]> {
121- const store = await loadCredentialsStore();
122- return Object.keys(store);
123}
124125/**
126 * Save credentials for an identity (adds or updates)
127 */
128export async function saveCredentials(credentials: Credentials): Promise<void> {
129- const store = await loadCredentialsStore();
130- store[credentials.identifier] = credentials;
131- await saveCredentialsStore(store);
132}
133134/**
135 * Delete credentials for a specific identity
136 */
137export async function deleteCredentials(identifier?: string): Promise<boolean> {
138- const store = await loadCredentialsStore();
139- const identifiers = Object.keys(store);
140141- if (identifiers.length === 0) {
142- return false;
143- }
144145- // If identifier specified, delete just that one
146- if (identifier) {
147- if (!store[identifier]) {
148- return false;
149- }
150- delete store[identifier];
151- await saveCredentialsStore(store);
152- return true;
153- }
154155- // If only one identity, delete it (backwards compat behavior)
156- if (identifiers.length === 1 && identifiers[0]) {
157- delete store[identifiers[0]];
158- await saveCredentialsStore(store);
159- return true;
160- }
161162- // Multiple identities but none specified
163- return false;
164}
165166export function getCredentialsPath(): string {
167- return CREDENTIALS_FILE;
168}
···1+import * as fs from "node:fs/promises";
2+import * as os from "node:os";
3+import * as path from "node:path";
4import type { Credentials } from "./types";
56const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
···10type CredentialsStore = Record<string, Credentials>;
1112async function fileExists(filePath: string): Promise<boolean> {
13+ try {
14+ await fs.access(filePath);
15+ return true;
16+ } catch {
17+ return false;
18+ }
19}
2021/**
22 * Load all stored credentials
23 */
24async function loadCredentialsStore(): Promise<CredentialsStore> {
25+ if (!(await fileExists(CREDENTIALS_FILE))) {
26+ return {};
27+ }
2829+ try {
30+ const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
31+ const parsed = JSON.parse(content);
3233+ // Handle legacy single-credential format (migrate on read)
34+ if (parsed.identifier && parsed.password) {
35+ const legacy = parsed as Credentials;
36+ return { [legacy.identifier]: legacy };
37+ }
3839+ return parsed as CredentialsStore;
40+ } catch {
41+ return {};
42+ }
43}
4445/**
46 * Save the entire credentials store
47 */
48async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
49+ await fs.mkdir(CONFIG_DIR, { recursive: true });
50+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
51+ await fs.chmod(CREDENTIALS_FILE, 0o600);
52}
5354/**
···62 * 5. Return null (caller should prompt user)
63 */
64export async function loadCredentials(
65+ projectIdentity?: string,
66): Promise<Credentials | null> {
67+ // 1. Check environment variables first (full override)
68+ const envIdentifier = process.env.ATP_IDENTIFIER;
69+ const envPassword = process.env.ATP_APP_PASSWORD;
70+ const envPdsUrl = process.env.PDS_URL;
7172+ if (envIdentifier && envPassword) {
73+ return {
74+ identifier: envIdentifier,
75+ password: envPassword,
76+ pdsUrl: envPdsUrl || "https://bsky.social",
77+ };
78+ }
7980+ const store = await loadCredentialsStore();
81+ const identifiers = Object.keys(store);
8283+ if (identifiers.length === 0) {
84+ return null;
85+ }
8687+ // 2. SEQUOIA_PROFILE env var
88+ const profileEnv = process.env.SEQUOIA_PROFILE;
89+ if (profileEnv && store[profileEnv]) {
90+ return store[profileEnv];
91+ }
9293+ // 3. Project-specific identity (from sequoia.json)
94+ if (projectIdentity && store[projectIdentity]) {
95+ return store[projectIdentity];
96+ }
9798+ // 4. If only one identity, use it
99+ if (identifiers.length === 1 && identifiers[0]) {
100+ return store[identifiers[0]] ?? null;
101+ }
102103+ // Multiple identities exist but none selected
104+ return null;
105}
106107/**
108 * Get a specific identity by identifier
109 */
110export async function getCredentials(
111+ identifier: string,
112): Promise<Credentials | null> {
113+ const store = await loadCredentialsStore();
114+ return store[identifier] || null;
115}
116117/**
118 * List all stored identities
119 */
120export async function listCredentials(): Promise<string[]> {
121+ const store = await loadCredentialsStore();
122+ return Object.keys(store);
123}
124125/**
126 * Save credentials for an identity (adds or updates)
127 */
128export async function saveCredentials(credentials: Credentials): Promise<void> {
129+ const store = await loadCredentialsStore();
130+ store[credentials.identifier] = credentials;
131+ await saveCredentialsStore(store);
132}
133134/**
135 * Delete credentials for a specific identity
136 */
137export async function deleteCredentials(identifier?: string): Promise<boolean> {
138+ const store = await loadCredentialsStore();
139+ const identifiers = Object.keys(store);
140141+ if (identifiers.length === 0) {
142+ return false;
143+ }
144145+ // If identifier specified, delete just that one
146+ if (identifier) {
147+ if (!store[identifier]) {
148+ return false;
149+ }
150+ delete store[identifier];
151+ await saveCredentialsStore(store);
152+ return true;
153+ }
154155+ // If only one identity, delete it (backwards compat behavior)
156+ if (identifiers.length === 1 && identifiers[0]) {
157+ delete store[identifiers[0]];
158+ await saveCredentialsStore(store);
159+ return true;
160+ }
161162+ // Multiple identities but none specified
163+ return false;
164}
165166export function getCredentialsPath(): string {
167+ return CREDENTIALS_FILE;
168}
+323-176
packages/cli/src/lib/markdown.ts
···1-import * as fs from "fs/promises";
2-import * as path from "path";
3import { glob } from "glob";
4import { minimatch } from "minimatch";
5-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
67-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
8- frontmatter: PostFrontmatter;
9- body: string;
000010} {
11- // Support multiple frontmatter delimiters:
12- // --- (YAML) - Jekyll, Astro, most SSGs
13- // +++ (TOML) - Hugo
14- // *** - Alternative format
15- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
16- const match = content.match(frontmatterRegex);
1718- if (!match) {
19- throw new Error("Could not parse frontmatter");
20- }
2122- const delimiter = match[1];
23- const frontmatterStr = match[2] ?? "";
24- const body = match[3] ?? "";
2526- // Determine format based on delimiter:
27- // +++ uses TOML (key = value)
28- // --- and *** use YAML (key: value)
29- const isToml = delimiter === "+++";
30- const separator = isToml ? "=" : ":";
3132- // Parse frontmatter manually
33- const raw: Record<string, unknown> = {};
34- const lines = frontmatterStr.split("\n");
3536- for (const line of lines) {
37- const sepIndex = line.indexOf(separator);
38- if (sepIndex === -1) continue;
0000000003940- const key = line.slice(0, sepIndex).trim();
41- let value = line.slice(sepIndex + 1).trim();
4243- // Handle quoted strings
44- if (
45- (value.startsWith('"') && value.endsWith('"')) ||
46- (value.startsWith("'") && value.endsWith("'"))
47- ) {
48- value = value.slice(1, -1);
49- }
5051- // Handle arrays (simple case for tags)
52- if (value.startsWith("[") && value.endsWith("]")) {
53- const arrayContent = value.slice(1, -1);
54- raw[key] = arrayContent
55- .split(",")
56- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
57- } else if (value === "true") {
58- raw[key] = true;
59- } else if (value === "false") {
60- raw[key] = false;
61- } else {
62- raw[key] = value;
63- }
64- }
0000000000000000000000000000000000000006566- // Apply field mappings to normalize to standard PostFrontmatter fields
67- const frontmatter: Record<string, unknown> = {};
6869- // Title mapping
70- const titleField = mapping?.title || "title";
71- frontmatter.title = raw[titleField] || raw.title;
7273- // Description mapping
74- const descField = mapping?.description || "description";
75- frontmatter.description = raw[descField] || raw.description;
7677- // Publish date mapping - check custom field first, then fallbacks
78- const dateField = mapping?.publishDate;
79- if (dateField && raw[dateField]) {
80- frontmatter.publishDate = raw[dateField];
81- } else if (raw.publishDate) {
82- frontmatter.publishDate = raw.publishDate;
83- } else {
84- // Fallback to common date field names
85- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
86- for (const field of dateFields) {
87- if (raw[field]) {
88- frontmatter.publishDate = raw[field];
89- break;
90- }
91- }
92- }
9394- // Cover image mapping
95- const coverField = mapping?.coverImage || "ogImage";
96- frontmatter.ogImage = raw[coverField] || raw.ogImage;
9798- // Tags mapping
99- const tagsField = mapping?.tags || "tags";
100- frontmatter.tags = raw[tagsField] || raw.tags;
101102- // Draft mapping
103- const draftField = mapping?.draft || "draft";
104- const draftValue = raw[draftField] ?? raw.draft;
105- if (draftValue !== undefined) {
106- frontmatter.draft = draftValue === true || draftValue === "true";
107- }
108109- // Always preserve atUri (internal field)
110- frontmatter.atUri = raw.atUri;
111112- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
0000113}
114115export function getSlugFromFilename(filename: string): string {
116- return filename
117- .replace(/\.mdx?$/, "")
118- .toLowerCase()
119- .replace(/\s+/g, "-");
0000000000000000000000000000000000000000000000120}
121122export async function getContentHash(content: string): Promise<string> {
123- const encoder = new TextEncoder();
124- const data = encoder.encode(content);
125- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
126- const hashArray = Array.from(new Uint8Array(hashBuffer));
127- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
128}
129130function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
131- for (const pattern of ignorePatterns) {
132- if (minimatch(relativePath, pattern)) {
133- return true;
134- }
135- }
136- return false;
0000000137}
138139export async function scanContentDirectory(
140- contentDir: string,
141- frontmatterMapping?: FrontmatterMapping,
142- ignorePatterns: string[] = []
143): Promise<BlogPost[]> {
144- const patterns = ["**/*.md", "**/*.mdx"];
145- const posts: BlogPost[] = [];
0000000000000000146147- for (const pattern of patterns) {
148- const files = await glob(pattern, {
149- cwd: contentDir,
150- absolute: false,
151- });
0000000000152153- for (const relativePath of files) {
154- // Skip files matching ignore patterns
155- if (shouldIgnore(relativePath, ignorePatterns)) {
156- continue;
157- }
158159- const filePath = path.join(contentDir, relativePath);
160- const rawContent = await fs.readFile(filePath, "utf-8");
161162- try {
163- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
164- const filename = path.basename(relativePath);
165- const slug = getSlugFromFilename(filename);
00000166167- posts.push({
168- filePath,
169- slug,
170- frontmatter,
171- content: body,
172- rawContent,
173- });
174- } catch (error) {
175- console.error(`Error parsing ${relativePath}:`, error);
176- }
177- }
178- }
0179180- // Sort by publish date (newest first)
181- posts.sort((a, b) => {
182- const dateA = new Date(a.frontmatter.publishDate);
183- const dateB = new Date(b.frontmatter.publishDate);
184- return dateB.getTime() - dateA.getTime();
185- });
186187- return posts;
188}
189190-export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
191- // Detect which delimiter is used (---, +++, or ***)
192- const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
193- const delimiter = delimiterMatch?.[1] ?? "---";
194- const isToml = delimiter === "+++";
000195196- // Format the atUri entry based on frontmatter type
197- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
198199- // Check if atUri already exists in frontmatter (handle both formats)
200- if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
201- // Replace existing atUri (match both YAML and TOML formats)
202- return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
203- }
000204205- // Insert atUri before the closing delimiter
206- const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
207- if (frontmatterEndIndex === -1) {
208- throw new Error("Could not find frontmatter end");
209- }
210211- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
212- const afterEnd = rawContent.slice(frontmatterEndIndex);
213214- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
215}
216217export function stripMarkdownForText(markdown: string): string {
218- return markdown
219- .replace(/#{1,6}\s/g, "") // Remove headers
220- .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
221- .replace(/\*([^*]+)\*/g, "$1") // Remove italic
222- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
223- .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
224- .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
225- .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
226- .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
227- .trim();
228}
···1+import * as fs from "node:fs/promises";
2+import * as path from "node:path";
3import { glob } from "glob";
4import { minimatch } from "minimatch";
5+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
67+export function parseFrontmatter(
8+ content: string,
9+ mapping?: FrontmatterMapping,
10+): {
11+ frontmatter: PostFrontmatter;
12+ body: string;
13+ rawFrontmatter: Record<string, unknown>;
14} {
15+ // Support multiple frontmatter delimiters:
16+ // --- (YAML) - Jekyll, Astro, most SSGs
17+ // +++ (TOML) - Hugo
18+ // *** - Alternative format
19+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
20+ const match = content.match(frontmatterRegex);
2122+ if (!match) {
23+ throw new Error("Could not parse frontmatter");
24+ }
2526+ const delimiter = match[1];
27+ const frontmatterStr = match[2] ?? "";
28+ const body = match[3] ?? "";
2930+ // Determine format based on delimiter:
31+ // +++ uses TOML (key = value)
32+ // --- and *** use YAML (key: value)
33+ const isToml = delimiter === "+++";
34+ const separator = isToml ? "=" : ":";
3536+ // Parse frontmatter manually
37+ const raw: Record<string, unknown> = {};
38+ const lines = frontmatterStr.split("\n");
3940+ let i = 0;
41+ while (i < lines.length) {
42+ const line = lines[i];
43+ if (line === undefined) {
44+ i++;
45+ continue;
46+ }
47+ const sepIndex = line.indexOf(separator);
48+ if (sepIndex === -1) {
49+ i++;
50+ continue;
51+ }
5253+ const key = line.slice(0, sepIndex).trim();
54+ let value = line.slice(sepIndex + 1).trim();
5556+ // Handle quoted strings
57+ if (
58+ (value.startsWith('"') && value.endsWith('"')) ||
59+ (value.startsWith("'") && value.endsWith("'"))
60+ ) {
61+ value = value.slice(1, -1);
62+ }
6364+ // Handle inline arrays (simple case for tags)
65+ if (value.startsWith("[") && value.endsWith("]")) {
66+ const arrayContent = value.slice(1, -1);
67+ raw[key] = arrayContent
68+ .split(",")
69+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
70+ } else if (value === "" && !isToml) {
71+ // Check for YAML-style multiline array (key with no value followed by - items)
72+ const arrayItems: string[] = [];
73+ let j = i + 1;
74+ while (j < lines.length) {
75+ const nextLine = lines[j];
76+ if (nextLine === undefined) {
77+ j++;
78+ continue;
79+ }
80+ // Check if line is a list item (starts with whitespace and -)
81+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
82+ if (listMatch && listMatch[1] !== undefined) {
83+ let itemValue = listMatch[1].trim();
84+ // Remove quotes if present
85+ if (
86+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
87+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
88+ ) {
89+ itemValue = itemValue.slice(1, -1);
90+ }
91+ arrayItems.push(itemValue);
92+ j++;
93+ } else if (nextLine.trim() === "") {
94+ // Skip empty lines within the array
95+ j++;
96+ } else {
97+ // Hit a new key or non-list content
98+ break;
99+ }
100+ }
101+ if (arrayItems.length > 0) {
102+ raw[key] = arrayItems;
103+ i = j;
104+ continue;
105+ } else {
106+ raw[key] = value;
107+ }
108+ } else if (value === "true") {
109+ raw[key] = true;
110+ } else if (value === "false") {
111+ raw[key] = false;
112+ } else {
113+ raw[key] = value;
114+ }
115+ i++;
116+ }
117118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119+ const frontmatter: Record<string, unknown> = {};
120121+ // Title mapping
122+ const titleField = mapping?.title || "title";
123+ frontmatter.title = raw[titleField] || raw.title;
124125+ // Description mapping
126+ const descField = mapping?.description || "description";
127+ frontmatter.description = raw[descField] || raw.description;
128129+ // Publish date mapping - check custom field first, then fallbacks
130+ const dateField = mapping?.publishDate;
131+ if (dateField && raw[dateField]) {
132+ frontmatter.publishDate = raw[dateField];
133+ } else if (raw.publishDate) {
134+ frontmatter.publishDate = raw.publishDate;
135+ } else {
136+ // Fallback to common date field names
137+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
138+ for (const field of dateFields) {
139+ if (raw[field]) {
140+ frontmatter.publishDate = raw[field];
141+ break;
142+ }
143+ }
144+ }
145146+ // Cover image mapping
147+ const coverField = mapping?.coverImage || "ogImage";
148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
149150+ // Tags mapping
151+ const tagsField = mapping?.tags || "tags";
152+ frontmatter.tags = raw[tagsField] || raw.tags;
153154+ // Draft mapping
155+ const draftField = mapping?.draft || "draft";
156+ const draftValue = raw[draftField] ?? raw.draft;
157+ if (draftValue !== undefined) {
158+ frontmatter.draft = draftValue === true || draftValue === "true";
159+ }
160161+ // Always preserve atUri (internal field)
162+ frontmatter.atUri = raw.atUri;
163164+ return {
165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166+ body,
167+ rawFrontmatter: raw,
168+ };
169}
170171export function getSlugFromFilename(filename: string): string {
172+ return filename
173+ .replace(/\.mdx?$/, "")
174+ .toLowerCase()
175+ .replace(/\s+/g, "-");
176+}
177+178+export interface SlugOptions {
179+ slugField?: string;
180+ removeIndexFromSlug?: boolean;
181+}
182+183+export function getSlugFromOptions(
184+ relativePath: string,
185+ rawFrontmatter: Record<string, unknown>,
186+ options: SlugOptions = {},
187+): string {
188+ const { slugField, removeIndexFromSlug = false } = options;
189+190+ let slug: string;
191+192+ // If slugField is set, try to get the value from frontmatter
193+ if (slugField) {
194+ const frontmatterValue = rawFrontmatter[slugField];
195+ if (frontmatterValue && typeof frontmatterValue === "string") {
196+ // Remove leading slash if present
197+ slug = frontmatterValue
198+ .replace(/^\//, "")
199+ .toLowerCase()
200+ .replace(/\s+/g, "-");
201+ } else {
202+ // Fallback to filepath if frontmatter field not found
203+ slug = relativePath
204+ .replace(/\.mdx?$/, "")
205+ .toLowerCase()
206+ .replace(/\s+/g, "-");
207+ }
208+ } else {
209+ // Default: use filepath
210+ slug = relativePath
211+ .replace(/\.mdx?$/, "")
212+ .toLowerCase()
213+ .replace(/\s+/g, "-");
214+ }
215+216+ // Remove /index or /_index suffix if configured
217+ if (removeIndexFromSlug) {
218+ slug = slug.replace(/\/_?index$/, "");
219+ }
220+221+ return slug;
222}
223224export async function getContentHash(content: string): Promise<string> {
225+ const encoder = new TextEncoder();
226+ const data = encoder.encode(content);
227+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
228+ const hashArray = Array.from(new Uint8Array(hashBuffer));
229+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
230}
231232function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
233+ for (const pattern of ignorePatterns) {
234+ if (minimatch(relativePath, pattern)) {
235+ return true;
236+ }
237+ }
238+ return false;
239+}
240+241+export interface ScanOptions {
242+ frontmatterMapping?: FrontmatterMapping;
243+ ignorePatterns?: string[];
244+ slugField?: string;
245+ removeIndexFromSlug?: boolean;
246}
247248export async function scanContentDirectory(
249+ contentDir: string,
250+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
251+ ignorePatterns: string[] = [],
252): Promise<BlogPost[]> {
253+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
254+ let options: ScanOptions;
255+ if (
256+ frontmatterMappingOrOptions &&
257+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
258+ "ignorePatterns" in frontmatterMappingOrOptions ||
259+ "slugField" in frontmatterMappingOrOptions)
260+ ) {
261+ options = frontmatterMappingOrOptions as ScanOptions;
262+ } else {
263+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
264+ options = {
265+ frontmatterMapping: frontmatterMappingOrOptions as
266+ | FrontmatterMapping
267+ | undefined,
268+ ignorePatterns,
269+ };
270+ }
271272+ const {
273+ frontmatterMapping,
274+ ignorePatterns: ignore = [],
275+ slugField,
276+ removeIndexFromSlug,
277+ } = options;
278+279+ const patterns = ["**/*.md", "**/*.mdx"];
280+ const posts: BlogPost[] = [];
281+282+ for (const pattern of patterns) {
283+ const files = await glob(pattern, {
284+ cwd: contentDir,
285+ absolute: false,
286+ });
287288+ for (const relativePath of files) {
289+ // Skip files matching ignore patterns
290+ if (shouldIgnore(relativePath, ignore)) {
291+ continue;
292+ }
293294+ const filePath = path.join(contentDir, relativePath);
295+ const rawContent = await fs.readFile(filePath, "utf-8");
296297+ try {
298+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
299+ rawContent,
300+ frontmatterMapping,
301+ );
302+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
303+ slugField,
304+ removeIndexFromSlug,
305+ });
306307+ posts.push({
308+ filePath,
309+ slug,
310+ frontmatter,
311+ content: body,
312+ rawContent,
313+ rawFrontmatter,
314+ });
315+ } catch (error) {
316+ console.error(`Error parsing ${relativePath}:`, error);
317+ }
318+ }
319+ }
320321+ // Sort by publish date (newest first)
322+ posts.sort((a, b) => {
323+ const dateA = new Date(a.frontmatter.publishDate);
324+ const dateB = new Date(b.frontmatter.publishDate);
325+ return dateB.getTime() - dateA.getTime();
326+ });
327328+ return posts;
329}
330331+export function updateFrontmatterWithAtUri(
332+ rawContent: string,
333+ atUri: string,
334+): string {
335+ // Detect which delimiter is used (---, +++, or ***)
336+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
337+ const delimiter = delimiterMatch?.[1] ?? "---";
338+ const isToml = delimiter === "+++";
339340+ // Format the atUri entry based on frontmatter type
341+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
342343+ // Check if atUri already exists in frontmatter (handle both formats)
344+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
345+ // Replace existing atUri (match both YAML and TOML formats)
346+ return rawContent.replace(
347+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
348+ `${atUriEntry}\n`,
349+ );
350+ }
351352+ // Insert atUri before the closing delimiter
353+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
354+ if (frontmatterEndIndex === -1) {
355+ throw new Error("Could not find frontmatter end");
356+ }
357358+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
359+ const afterEnd = rawContent.slice(frontmatterEndIndex);
360361+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
362}
363364export function stripMarkdownForText(markdown: string): string {
365+ return markdown
366+ .replace(/#{1,6}\s/g, "") // Remove headers
367+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
368+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
369+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
370+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
371+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
372+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
373+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
374+ .trim();
375}
+6-6
packages/cli/src/lib/prompts.ts
···1-import { isCancel, cancel } from "@clack/prompts";
23export function exitOnCancel<T>(value: T | symbol): T {
4- if (isCancel(value)) {
5- cancel("Cancelled");
6- process.exit(0);
7- }
8- return value as T;
9}
···1+import { cancel, isCancel } from "@clack/prompts";
23export function exitOnCancel<T>(value: T | symbol): T {
4+ if (isCancel(value)) {
5+ cancel("Cancelled");
6+ process.exit(0);
7+ }
8+ return value as T;
9}
+5
packages/cli/src/lib/types.ts
···5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
7 draft?: string; // Field name for draft status (default: "draft")
08}
910// Strong reference for Bluesky post (com.atproto.repo.strongRef)
···31 identity?: string; // Which stored identity to use (matches identifier)
32 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
33 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
0034 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
35}
36···56 frontmatter: PostFrontmatter;
57 content: string;
58 rawContent: string;
059}
6061export interface BlobRef {
···77 contentHash: string;
78 atUri?: string;
79 lastPublished?: string;
080 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
81}
82
···5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
7 draft?: string; // Field name for draft status (default: "draft")
8+ slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
9}
1011// Strong reference for Bluesky post (com.atproto.repo.strongRef)
···32 identity?: string; // Which stored identity to use (matches identifier)
33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
35+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
36+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
37 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
38}
39···59 frontmatter: PostFrontmatter;
60 content: string;
61 rawContent: string;
62+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
63}
6465export interface BlobRef {
···81 contentHash: string;
82 atUri?: string;
83 lastPublished?: string;
84+ slug?: string; // The generated slug for this post (used by inject command)
85 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
86}
87