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

chore: refactored to use fallback approach if frontmatter.slugField is provided or not

+40 -52
+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
+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`);
-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; ··· 120 118 121 119 if (options.ignore && options.ignore.length > 0) { 122 120 config.ignore = options.ignore; 123 - } 124 - 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 121 } 132 122 133 123 if (options.removeIndexFromSlug) {
+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 });
+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