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
+40 -52
Interdiff #3 โ†’ #4
bun.lock

This file has not been changed.

packages/cli/biome.json

This file has not been changed.

packages/cli/package.json

This file has not been changed.

packages/cli/src/commands/auth.ts

This file has not been changed.

packages/cli/src/commands/init.ts

This file has not been changed.

packages/cli/src/commands/inject.ts

This file has not been changed.

+1 -2
packages/cli/src/commands/publish.ts
··· 108 108 const posts = await scanContentDirectory(contentDir, { 109 109 frontmatterMapping: config.frontmatter, 110 110 ignorePatterns: config.ignore, 111 - slugSource: config.slugSource, 112 - slugField: config.slugField, 111 + slugField: config.frontmatter?.slugField, 113 112 removeIndexFromSlug: config.removeIndexFromSlug, 114 113 }); 115 114 s.stop(`Found ${posts.length} posts`);
+1 -2
packages/cli/src/commands/sync.ts
··· 103 103 const localPosts = await scanContentDirectory(contentDir, { 104 104 frontmatterMapping: config.frontmatter, 105 105 ignorePatterns: config.ignore, 106 - slugSource: config.slugSource, 107 - slugField: config.slugField, 106 + slugField: config.frontmatter?.slugField, 108 107 removeIndexFromSlug: config.removeIndexFromSlug, 109 108 }); 110 109 s.stop(`Found ${localPosts.length} local posts`);
packages/cli/src/lib/atproto.ts

This file has not been changed.

-10
packages/cli/src/lib/config.ts
··· 81 81 pdsUrl?: string; 82 82 frontmatter?: FrontmatterMapping; 83 83 ignore?: string[]; 84 - slugSource?: "filename" | "path" | "frontmatter"; 85 - slugField?: string; 86 84 removeIndexFromSlug?: boolean; 87 85 textContentField?: string; 88 86 bluesky?: BlueskyConfig; ··· 122 120 config.ignore = options.ignore; 123 121 } 124 122 125 - if (options.slugSource && options.slugSource !== "filename") { 126 - config.slugSource = options.slugSource; 127 - } 128 - 129 - if (options.slugField && options.slugField !== "slug") { 130 - config.slugField = options.slugField; 131 - } 132 - 133 123 if (options.removeIndexFromSlug) { 134 124 config.removeIndexFromSlug = options.removeIndexFromSlug; 135 125 }
packages/cli/src/lib/credentials.ts

This file has not been changed.

+21 -36
packages/cli/src/lib/markdown.ts
··· 176 176 } 177 177 178 178 export interface SlugOptions { 179 - slugSource?: "filename" | "path" | "frontmatter"; 180 179 slugField?: string; 181 180 removeIndexFromSlug?: boolean; 182 181 } ··· 186 185 rawFrontmatter: Record<string, unknown>, 187 186 options: SlugOptions = {}, 188 187 ): string { 189 - const { 190 - slugSource = "filename", 191 - slugField = "slug", 192 - removeIndexFromSlug = false, 193 - } = options; 188 + const { slugField, removeIndexFromSlug = false } = options; 194 189 195 190 let slug: string; 196 191 197 - switch (slugSource) { 198 - case "path": 199 - // Use full relative path without extension 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 200 203 slug = relativePath 201 204 .replace(/\.mdx?$/, "") 202 205 .toLowerCase() 203 206 .replace(/\s+/g, "-"); 204 - break; 205 - 206 - case "frontmatter": { 207 - // Use frontmatter field (slug or url) 208 - const frontmatterValue = 209 - rawFrontmatter[slugField] || rawFrontmatter.slug || rawFrontmatter.url; 210 - if (frontmatterValue && typeof frontmatterValue === "string") { 211 - // Remove leading slash if present 212 - slug = frontmatterValue 213 - .replace(/^\//, "") 214 - .toLowerCase() 215 - .replace(/\s+/g, "-"); 216 - } else { 217 - // Fallback to filename if frontmatter field not found 218 - slug = getSlugFromFilename(path.basename(relativePath)); 219 - } 220 - break; 221 207 } 222 - 223 - default: 224 - slug = getSlugFromFilename(path.basename(relativePath)); 225 - break; 208 + } else { 209 + // Default: use filepath 210 + slug = relativePath 211 + .replace(/\.mdx?$/, "") 212 + .toLowerCase() 213 + .replace(/\s+/g, "-"); 226 214 } 227 215 228 216 // Remove /index or /_index suffix if configured ··· 253 241 export interface ScanOptions { 254 242 frontmatterMapping?: FrontmatterMapping; 255 243 ignorePatterns?: string[]; 256 - slugSource?: "filename" | "path" | "frontmatter"; 257 244 slugField?: string; 258 245 removeIndexFromSlug?: boolean; 259 246 } ··· 267 254 let options: ScanOptions; 268 255 if ( 269 256 frontmatterMappingOrOptions && 270 - ("slugSource" in frontmatterMappingOrOptions || 271 - "frontmatterMapping" in frontmatterMappingOrOptions || 272 - "ignorePatterns" in frontmatterMappingOrOptions) 257 + ("frontmatterMapping" in frontmatterMappingOrOptions || 258 + "ignorePatterns" in frontmatterMappingOrOptions || 259 + "slugField" in frontmatterMappingOrOptions) 273 260 ) { 274 261 options = frontmatterMappingOrOptions as ScanOptions; 275 262 } else { ··· 285 272 const { 286 273 frontmatterMapping, 287 274 ignorePatterns: ignore = [], 288 - slugSource, 289 275 slugField, 290 276 removeIndexFromSlug, 291 277 } = options; ··· 314 300 frontmatterMapping, 315 301 ); 316 302 const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 317 - slugSource, 318 303 slugField, 319 304 removeIndexFromSlug, 320 305 });
packages/cli/src/lib/prompts.ts

This file has not been changed.

+1 -2
packages/cli/src/lib/types.ts
··· 5 5 coverImage?: string; // Field name for cover image (default: "ogImage") 6 6 tags?: string; // Field name for tags (default: "tags") 7 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) 8 9 } 9 10 10 11 // Strong reference for Bluesky post (com.atproto.repo.strongRef) ··· 31 32 identity?: string; // Which stored identity to use (matches identifier) 32 33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 33 34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 34 - slugSource?: "filename" | "path" | "frontmatter"; // How to generate slugs (default: "filename") 35 - slugField?: string; // Frontmatter field to use when slugSource is "frontmatter" (default: "slug") 36 35 removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 37 36 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 38 37 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
packages/cli/tsconfig.json

This file has not been changed.

+16
docs/docs/pages/config.mdx
··· 14 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 15 | `identity` | `string` | No | - | Which stored identity to use | 16 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 17 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 18 20 | `bluesky` | `object` | No | - | Bluesky posting configuration | 19 21 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 20 22 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 79 81 } 80 82 } 81 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. 82 98 83 99 ### Ignoring Files 84 100

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