A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Slug and Frontmatter Updates #3

closed opened by stevedylan.dev targeting main from chore/frontmatter-config-updates

Overview#

This PR fixes issues raised in #3

UX Changes#

  • Subdirectory support: Posts in nested folders like a/b/post.md can now preserve their full path in URLs (set slugSource: "path")
  • Frontmatter slug support: Use a field from your frontmatter (like slug or url) to define the path (set slugSource: "frontmatter")
  • Hugo index.md support: Remove /index suffix from paths automatically (set removeIndexFromSlug: true)
  • Custom text content: Use a frontmatter field like summary for the AT Protocol textContent instead of the full markdown body (set textContentField: "summary")
  • Description now works: The description field from frontmatter is now actually published to AT Protocol records (was a bug , never added to records before)

Example config using frontmatter slugs:

  {
    "siteUrl": "https://example.com",
    "contentDir": "content/posts",
    "pathPrefix": "/blog",
    "publicationUri": "at://did:plc:.../site.standard.publication/...",
    "slugSource": "frontmatter",
    "slugField": "url"
  }

Example frontmatter:

---
title: "My Post Title"
date: 2024-01-15
url: "2024/my-custom-slug"
summary: "A brief description of this post"
---

This would create a document with path /blog/2024/my-custom-slug

File Updates#

  1. src/lib/types.ts
  • Added new config options to PublisherConfig:
  • slugSource: "filename" | "path" | "frontmatter" (default: "filename")
  • slugField: string for frontmatter field when using slugSource: "frontmatter"
  • removeIndexFromSlug: boolean to strip /index or /_index suffixes
  • textContentField: frontmatter field to use for textContent instead of markdown body
  • Added rawFrontmatter: Record<string, unknown> to BlogPost
  1. src/lib/markdown.ts
  • Updated parseFrontmatter() to return rawFrontmatter
  • Added getSlugFromOptions() function supporting three modes (filename, path, frontmatter)
  • Added ScanOptions interface for cleaner option passing
  • Updated scanContentDirectory() to accept new slug options and pass rawFrontmatter
  1. src/lib/atproto.ts
  • Added description field to DocumentRecord interface
  • Fixed createDocument() to add description to record when available
  • Fixed updateDocument() to add description to record when available
  • Added textContentField support to both functions
  1. src/commands/publish.ts
  • Updated to pass new config options to scanContentDirectory()
  1. src/commands/sync.ts
  • Updated to pass new config options to scanContentDirectory()
  • Fixed path matching to use configured pathPrefix
  1. src/lib/config.ts
  • Updated generateConfigTemplate() to include new options
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3mdpsypgmvl22
+236 -65
Diff #1
+27 -50
packages/cli/src/commands/inject.ts
··· 44 44 // Load state to get atUri mappings 45 45 const state = await loadState(configDir); 46 46 47 - // Generic filenames where the slug is the parent directory, not the filename 48 - // Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc. 49 - const genericFilenames = new Set([ 50 - "+page", 51 - "index", 52 - "_index", 53 - "page", 54 - "readme", 55 - ]); 56 - 57 - // Build a map of slug/path to atUri from state 58 - const pathToAtUri = new Map<string, string>(); 47 + // Build a map of slug to atUri from state 48 + // The slug is stored in state by the publish command, using the configured slug options 49 + const slugToAtUri = new Map<string, string>(); 59 50 for (const [filePath, postState] of Object.entries(state.posts)) { 60 - if (postState.atUri) { 61 - // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 62 - let basename = path.basename(filePath, path.extname(filePath)); 51 + if (postState.atUri && postState.slug) { 52 + // Use the slug stored in state (computed by publish with config options) 53 + slugToAtUri.set(postState.slug, postState.atUri); 63 54 64 - // If the filename is a generic convention name, use the parent directory as slug 65 - if (genericFilenames.has(basename.toLowerCase())) { 66 - // Split path and filter out route groups like (blog-article) 67 - const pathParts = filePath 68 - .split(/[/\\]/) 69 - .filter((p) => p && !(p.startsWith("(") && p.endsWith(")"))); 70 - // The slug should be the second-to-last part (last is the filename) 71 - if (pathParts.length >= 2) { 72 - const slug = pathParts[pathParts.length - 2]; 73 - if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") { 74 - basename = slug; 75 - } 76 - } 55 + // Also add the last segment for simpler matching 56 + // e.g., "40th-puzzle-box/what-a-gift" -> also map "what-a-gift" 57 + const lastSegment = postState.slug.split("/").pop(); 58 + if (lastSegment && lastSegment !== postState.slug) { 59 + slugToAtUri.set(lastSegment, postState.atUri); 77 60 } 78 - 79 - pathToAtUri.set(basename, postState.atUri); 80 - 81 - // Also add variations that might match HTML file paths 82 - // e.g., /blog/my-post, /posts/my-post, my-post/index 83 - const dirName = path.basename(path.dirname(filePath)); 84 - // Skip route groups and common directory names 85 - if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) { 86 - pathToAtUri.set(`${dirName}/${basename}`, postState.atUri); 87 - } 61 + } else if (postState.atUri) { 62 + // Fallback for older state files without slug field 63 + // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 64 + const basename = path.basename(filePath, path.extname(filePath)); 65 + slugToAtUri.set(basename.toLowerCase(), postState.atUri); 88 66 } 89 67 } 90 68 91 - if (pathToAtUri.size === 0) { 69 + if (slugToAtUri.size === 0) { 92 70 log.warn( 93 71 "No published posts found in state. Run 'sequoia publish' first.", 94 72 ); 95 73 return; 96 74 } 97 75 98 - log.info(`Found ${pathToAtUri.size} published posts in state`); 76 + log.info(`Found ${slugToAtUri.size} slug mappings from published posts`); 99 77 100 78 // Scan for HTML files 101 79 const htmlFiles = await glob("**/*.html", { ··· 125 103 let atUri: string | undefined; 126 104 127 105 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post) 128 - atUri = pathToAtUri.get(htmlBasename); 106 + atUri = slugToAtUri.get(htmlBasename); 129 107 130 - // Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post) 108 + // Strategy 2: For index.html, try the directory path 109 + // e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift 131 110 if (!atUri && htmlBasename === "index" && htmlDir !== ".") { 132 - const slug = path.basename(htmlDir); 133 - atUri = pathToAtUri.get(slug); 111 + // Try full directory path (for nested subdirectories) 112 + atUri = slugToAtUri.get(htmlDir); 134 113 135 - // Also try parent/slug pattern 114 + // Also try just the last directory segment 136 115 if (!atUri) { 137 - const parentDir = path.dirname(htmlDir); 138 - if (parentDir !== ".") { 139 - atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`); 140 - } 116 + const lastDir = path.basename(htmlDir); 117 + atUri = slugToAtUri.get(lastDir); 141 118 } 142 119 } 143 120 144 121 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post) 145 122 if (!atUri && htmlDir !== ".") { 146 - atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`); 123 + atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`); 147 124 } 148 125 149 126 if (!atUri) {
+8 -1
packages/cli/src/commands/publish.ts
··· 87 87 // Scan for posts 88 88 const s = spinner(); 89 89 s.start("Scanning for posts..."); 90 - const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore); 90 + const posts = await scanContentDirectory(contentDir, { 91 + frontmatterMapping: config.frontmatter, 92 + ignorePatterns: config.ignore, 93 + slugSource: config.slugSource, 94 + slugField: config.slugField, 95 + removeIndexFromSlug: config.removeIndexFromSlug, 96 + }); 91 97 s.stop(`Found ${posts.length} posts`); 92 98 93 99 // Determine which posts need publishing ··· 215 221 contentHash, 216 222 atUri, 217 223 lastPublished: new Date().toISOString(), 224 + slug: post.slug, 218 225 }; 219 226 } catch (error) { 220 227 const errorMessage = error instanceof Error ? error.message : String(error);
+10 -3
packages/cli/src/commands/sync.ts
··· 90 90 91 91 // Scan local posts 92 92 s.start("Scanning local content..."); 93 - const localPosts = await scanContentDirectory(contentDir, config.frontmatter); 93 + const localPosts = await scanContentDirectory(contentDir, { 94 + frontmatterMapping: config.frontmatter, 95 + ignorePatterns: config.ignore, 96 + slugSource: config.slugSource, 97 + slugField: config.slugField, 98 + removeIndexFromSlug: config.removeIndexFromSlug, 99 + }); 94 100 s.stop(`Found ${localPosts.length} local posts`); 95 101 96 102 // Build a map of path -> local post for matching 97 - // Document path is like /posts/my-post-slug 103 + // Document path is like /posts/my-post-slug (or custom pathPrefix) 104 + const pathPrefix = config.pathPrefix || "/posts"; 98 105 const postsByPath = new Map<string, typeof localPosts[0]>(); 99 106 for (const post of localPosts) { 100 - const postPath = `/posts/${post.slug}`; 107 + const postPath = `${pathPrefix}/${post.slug}`; 101 108 postsByPath.set(postPath, post); 102 109 } 103 110
+25 -2
packages/cli/src/lib/atproto.ts
··· 171 171 ): Promise<string> { 172 172 const pathPrefix = config.pathPrefix || "/posts"; 173 173 const postPath = `${pathPrefix}/${post.slug}`; 174 - const textContent = stripMarkdownForText(post.content); 175 174 const publishDate = new Date(post.frontmatter.publishDate); 176 175 176 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 177 + let textContent: string; 178 + if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { 179 + textContent = String(post.rawFrontmatter[config.textContentField]); 180 + } else { 181 + textContent = stripMarkdownForText(post.content); 182 + } 183 + 177 184 const record: Record<string, unknown> = { 178 185 $type: "site.standard.document", 179 186 title: post.frontmatter.title, ··· 183 190 publishedAt: publishDate.toISOString(), 184 191 canonicalUrl: `${config.siteUrl}${postPath}`, 185 192 }; 193 + 194 + if (post.frontmatter.description) { 195 + record.description = post.frontmatter.description; 196 + } 186 197 187 198 if (coverImage) { 188 199 record.coverImage = coverImage; ··· 219 230 220 231 const pathPrefix = config.pathPrefix || "/posts"; 221 232 const postPath = `${pathPrefix}/${post.slug}`; 222 - const textContent = stripMarkdownForText(post.content); 223 233 const publishDate = new Date(post.frontmatter.publishDate); 224 234 235 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 236 + let textContent: string; 237 + if (config.textContentField && post.rawFrontmatter?.[config.textContentField]) { 238 + textContent = String(post.rawFrontmatter[config.textContentField]); 239 + } else { 240 + textContent = stripMarkdownForText(post.content); 241 + } 242 + 225 243 const record: Record<string, unknown> = { 226 244 $type: "site.standard.document", 227 245 title: post.frontmatter.title, ··· 231 249 publishedAt: publishDate.toISOString(), 232 250 canonicalUrl: `${config.siteUrl}${postPath}`, 233 251 }; 252 + 253 + if (post.frontmatter.description) { 254 + record.description = post.frontmatter.description; 255 + } 234 256 235 257 if (coverImage) { 236 258 record.coverImage = coverImage; ··· 266 288 textContent: string; 267 289 publishedAt: string; 268 290 canonicalUrl?: string; 291 + description?: string; 269 292 coverImage?: BlobObject; 270 293 tags?: string[]; 271 294 location?: string;
+20
packages/cli/src/lib/config.ts
··· 76 76 pdsUrl?: string; 77 77 frontmatter?: FrontmatterMapping; 78 78 ignore?: string[]; 79 + slugSource?: "filename" | "path" | "frontmatter"; 80 + slugField?: string; 81 + removeIndexFromSlug?: boolean; 82 + textContentField?: string; 79 83 }): string { 80 84 const config: Record<string, unknown> = { 81 85 siteUrl: options.siteUrl, ··· 110 114 111 115 if (options.ignore && options.ignore.length > 0) { 112 116 config.ignore = options.ignore; 117 + } 118 + 119 + if (options.slugSource && options.slugSource !== "filename") { 120 + config.slugSource = options.slugSource; 121 + } 122 + 123 + if (options.slugField && options.slugField !== "slug") { 124 + config.slugField = options.slugField; 125 + } 126 + 127 + if (options.removeIndexFromSlug) { 128 + config.removeIndexFromSlug = options.removeIndexFromSlug; 129 + } 130 + 131 + if (options.textContentField) { 132 + config.textContentField = options.textContentField; 113 133 } 114 134 115 135 return JSON.stringify(config, null, 2);
+140 -9
packages/cli/src/lib/markdown.ts
··· 7 7 export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { 8 8 frontmatter: PostFrontmatter; 9 9 body: string; 10 + rawFrontmatter: Record<string, unknown>; 10 11 } { 11 12 // Support multiple frontmatter delimiters: 12 13 // --- (YAML) - Jekyll, Astro, most SSGs ··· 33 34 const raw: Record<string, unknown> = {}; 34 35 const lines = frontmatterStr.split("\n"); 35 36 36 - for (const line of lines) { 37 + let i = 0; 38 + while (i < lines.length) { 39 + const line = lines[i]; 40 + if (line === undefined) { 41 + i++; 42 + continue; 43 + } 37 44 const sepIndex = line.indexOf(separator); 38 - if (sepIndex === -1) continue; 45 + if (sepIndex === -1) { 46 + i++; 47 + continue; 48 + } 39 49 40 50 const key = line.slice(0, sepIndex).trim(); 41 51 let value = line.slice(sepIndex + 1).trim(); ··· 48 58 value = value.slice(1, -1); 49 59 } 50 60 51 - // Handle arrays (simple case for tags) 61 + // Handle inline arrays (simple case for tags) 52 62 if (value.startsWith("[") && value.endsWith("]")) { 53 63 const arrayContent = value.slice(1, -1); 54 64 raw[key] = arrayContent 55 65 .split(",") 56 66 .map((item) => item.trim().replace(/^["']|["']$/g, "")); 67 + } else if (value === "" && !isToml) { 68 + // Check for YAML-style multiline array (key with no value followed by - items) 69 + const arrayItems: string[] = []; 70 + let j = i + 1; 71 + while (j < lines.length) { 72 + const nextLine = lines[j]; 73 + if (nextLine === undefined) { 74 + j++; 75 + continue; 76 + } 77 + // Check if line is a list item (starts with whitespace and -) 78 + const listMatch = nextLine.match(/^\s+-\s*(.*)$/); 79 + if (listMatch && listMatch[1] !== undefined) { 80 + let itemValue = listMatch[1].trim(); 81 + // Remove quotes if present 82 + if ( 83 + (itemValue.startsWith('"') && itemValue.endsWith('"')) || 84 + (itemValue.startsWith("'") && itemValue.endsWith("'")) 85 + ) { 86 + itemValue = itemValue.slice(1, -1); 87 + } 88 + arrayItems.push(itemValue); 89 + j++; 90 + } else if (nextLine.trim() === "") { 91 + // Skip empty lines within the array 92 + j++; 93 + } else { 94 + // Hit a new key or non-list content 95 + break; 96 + } 97 + } 98 + if (arrayItems.length > 0) { 99 + raw[key] = arrayItems; 100 + i = j; 101 + continue; 102 + } else { 103 + raw[key] = value; 104 + } 57 105 } else if (value === "true") { 58 106 raw[key] = true; 59 107 } else if (value === "false") { ··· 61 109 } else { 62 110 raw[key] = value; 63 111 } 112 + i++; 64 113 } 65 114 66 115 // Apply field mappings to normalize to standard PostFrontmatter fields ··· 102 151 // Always preserve atUri (internal field) 103 152 frontmatter.atUri = raw.atUri; 104 153 105 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; 154 + return { frontmatter: frontmatter as unknown as PostFrontmatter, body, rawFrontmatter: raw }; 106 155 } 107 156 108 157 export function getSlugFromFilename(filename: string): string { ··· 112 161 .replace(/\s+/g, "-"); 113 162 } 114 163 164 + export interface SlugOptions { 165 + slugSource?: "filename" | "path" | "frontmatter"; 166 + slugField?: string; 167 + removeIndexFromSlug?: boolean; 168 + } 169 + 170 + export function getSlugFromOptions( 171 + relativePath: string, 172 + rawFrontmatter: Record<string, unknown>, 173 + options: SlugOptions = {} 174 + ): string { 175 + const { slugSource = "filename", slugField = "slug", removeIndexFromSlug = false } = options; 176 + 177 + let slug: string; 178 + 179 + switch (slugSource) { 180 + case "path": 181 + // Use full relative path without extension 182 + slug = relativePath 183 + .replace(/\.mdx?$/, "") 184 + .toLowerCase() 185 + .replace(/\s+/g, "-"); 186 + break; 187 + 188 + case "frontmatter": 189 + // Use frontmatter field (slug or url) 190 + const frontmatterValue = rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; 191 + if (frontmatterValue && typeof frontmatterValue === "string") { 192 + // Remove leading slash if present 193 + slug = frontmatterValue.replace(/^\//, "").toLowerCase().replace(/\s+/g, "-"); 194 + } else { 195 + // Fallback to filename if frontmatter field not found 196 + slug = getSlugFromFilename(path.basename(relativePath)); 197 + } 198 + break; 199 + 200 + case "filename": 201 + default: 202 + slug = getSlugFromFilename(path.basename(relativePath)); 203 + break; 204 + } 205 + 206 + // Remove /index or /_index suffix if configured 207 + if (removeIndexFromSlug) { 208 + slug = slug.replace(/\/_?index$/, ""); 209 + } 210 + 211 + return slug; 212 + } 213 + 115 214 export async function getContentHash(content: string): Promise<string> { 116 215 const encoder = new TextEncoder(); 117 216 const data = encoder.encode(content); ··· 129 228 return false; 130 229 } 131 230 231 + export interface ScanOptions { 232 + frontmatterMapping?: FrontmatterMapping; 233 + ignorePatterns?: string[]; 234 + slugSource?: "filename" | "path" | "frontmatter"; 235 + slugField?: string; 236 + removeIndexFromSlug?: boolean; 237 + } 238 + 132 239 export async function scanContentDirectory( 133 240 contentDir: string, 134 - frontmatterMapping?: FrontmatterMapping, 241 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 135 242 ignorePatterns: string[] = [] 136 243 ): Promise<BlogPost[]> { 244 + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 245 + let options: ScanOptions; 246 + if (frontmatterMappingOrOptions && ('slugSource' in frontmatterMappingOrOptions || 'frontmatterMapping' in frontmatterMappingOrOptions || 'ignorePatterns' in frontmatterMappingOrOptions)) { 247 + options = frontmatterMappingOrOptions as ScanOptions; 248 + } else { 249 + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 250 + options = { 251 + frontmatterMapping: frontmatterMappingOrOptions as FrontmatterMapping | undefined, 252 + ignorePatterns, 253 + }; 254 + } 255 + 256 + const { 257 + frontmatterMapping, 258 + ignorePatterns: ignore = [], 259 + slugSource, 260 + slugField, 261 + removeIndexFromSlug, 262 + } = options; 263 + 137 264 const patterns = ["**/*.md", "**/*.mdx"]; 138 265 const posts: BlogPost[] = []; 139 266 ··· 145 272 146 273 for (const relativePath of files) { 147 274 // Skip files matching ignore patterns 148 - if (shouldIgnore(relativePath, ignorePatterns)) { 275 + if (shouldIgnore(relativePath, ignore)) { 149 276 continue; 150 277 } 151 278 ··· 153 280 const rawContent = await fs.readFile(filePath, "utf-8"); 154 281 155 282 try { 156 - const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); 157 - const filename = path.basename(relativePath); 158 - const slug = getSlugFromFilename(filename); 283 + const { frontmatter, body, rawFrontmatter } = parseFrontmatter(rawContent, frontmatterMapping); 284 + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 285 + slugSource, 286 + slugField, 287 + removeIndexFromSlug, 288 + }); 159 289 160 290 posts.push({ 161 291 filePath, ··· 163 293 frontmatter, 164 294 content: body, 165 295 rawContent, 296 + rawFrontmatter, 166 297 }); 167 298 } catch (error) { 168 299 console.error(`Error parsing ${relativePath}:`, error);
+6
packages/cli/src/lib/types.ts
··· 18 18 identity?: string; // Which stored identity to use (matches identifier) 19 19 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 20 20 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 21 + slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename") 22 + slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug") 23 + removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 24 + textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 21 25 } 22 26 23 27 export interface Credentials { ··· 41 45 frontmatter: PostFrontmatter; 42 46 content: string; 43 47 rawContent: string; 48 + rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField 44 49 } 45 50 46 51 export interface BlobRef { ··· 62 67 contentHash: string; 63 68 atUri?: string; 64 69 lastPublished?: string; 70 + slug?: string; // The generated slug for this post (used by inject command) 65 71 } 66 72 67 73 export interface PublicationRecord {

History

5 rounds 1 comment
sign up or login to add to the discussion
7 commits
expand
chore: resolved action items from issue #3
chore: adjusted tags to accept yaml multiline arrays for tags
chore: updated inject to handle new slug options
chore: updated comments
chore: added linting and formatting
chore: linting updates
chore: refactored to use fallback approach if frontmatter.slugField is provided or not
expand 1 comment

For some reason tangled doesn't like rebasing, so going to close this out and open a fresh PR

closed without merging
6 commits
expand
chore: resolved action items from issue #3
chore: adjusted tags to accept yaml multiline arrays for tags
chore: updated inject to handle new slug options
chore: updated comments
chore: added linting and formatting
chore: linting updates
expand 0 comments
4 commits
expand
chore: resolved action items from issue #3
chore: adjusted tags to accept yaml multiline arrays for tags
chore: updated inject to handle new slug options
chore: updated comments
expand 0 comments
3 commits
expand
chore: resolved action items from issue #3
chore: adjusted tags to accept yaml multiline arrays for tags
chore: updated inject to handle new slug options
expand 0 comments
1 commit
expand
chore: resolved action items from issue #3
expand 0 comments