A CLI for publishing standard.site documents to ATProto

feat: added slug templating

authored by stevedylan.dev and committed by tangled.org 27600cda c612cf14

+91 -12
+27
docs/docs/pages/config.mdx
··· 18 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 19 | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 20 | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 + | `pathTemplate` | `string` | No | - | URL path template with tokens (overrides `pathPrefix` + slug) | 21 22 | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 23 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) | 23 24 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 34 35 "publicDir": "public", 35 36 "outputDir": "dist", 36 37 "pathPrefix": "/posts", 38 + "pathTemplate": "/blog/{year}/{month}/{slug}", 37 39 "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdlavhxjhm2v", 38 40 "pdsUrl": "https://andromeda.social", 39 41 "frontmatter": { ··· 113 115 ``` 114 116 115 117 This transforms `2024-01-15-my-post.md` into the slug `my-post`. 118 + 119 + ### Path Template 120 + 121 + By default, the URL path for each post is `pathPrefix + "/" + slug` (e.g., `/posts/my-post`). For more control over URL structure, use `pathTemplate` with token placeholders: 122 + 123 + ```json 124 + { 125 + "pathTemplate": "/blog/{year}/{month}/{slug}" 126 + } 127 + ``` 128 + 129 + This would produce paths like `/blog/2024/01/my-post`. 130 + 131 + **Available tokens:** 132 + 133 + | Token | Description | Example | 134 + |-------|-------------|---------| 135 + | `{slug}` | The generated slug (from filepath or `slugField`) | `my-post` | 136 + | `{year}` | Four-digit publish year | `2024` | 137 + | `{month}` | Zero-padded publish month | `01` | 138 + | `{day}` | Zero-padded publish day | `15` | 139 + | `{title}` | Slugified post title | `my-first-post` | 140 + | `{field}` | Any frontmatter field value (string fields only) | - | 141 + 142 + When `pathTemplate` is set, it overrides `pathPrefix`. If `pathTemplate` is not set, the default `pathPrefix`/slug behavior is used. 116 143 117 144 ### Ignoring Files 118 145
+4 -4
packages/cli/src/commands/publish.ts
··· 22 22 scanContentDirectory, 23 23 getContentHash, 24 24 updateFrontmatterWithAtUri, 25 + resolvePostPath, 25 26 } from "../lib/markdown"; 26 27 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 28 import { exitOnCancel } from "../lib/prompts"; ··· 240 241 241 242 let postUrl = ""; 242 243 if (verbose) { 243 - const pathPrefix = config.pathPrefix || "/posts"; 244 - postUrl = `\n ${config.siteUrl}${pathPrefix}/${post.slug}`; 244 + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); 245 + postUrl = `\n ${config.siteUrl}${postPath}`; 245 246 } 246 247 log.message( 247 248 ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, ··· 349 350 } else { 350 351 // Create Bluesky post 351 352 try { 352 - const pathPrefix = config.pathPrefix || "/posts"; 353 - const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 353 + const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; 354 354 355 355 bskyPostRef = await createBlueskyPost(agent, { 356 356 title: post.frontmatter.title,
+3 -3
packages/cli/src/commands/sync.ts
··· 14 14 scanContentDirectory, 15 15 getContentHash, 16 16 updateFrontmatterWithAtUri, 17 + resolvePostPath, 17 18 } from "../lib/markdown"; 18 19 import { exitOnCancel } from "../lib/prompts"; 19 20 ··· 147 148 s.stop(`Found ${localPosts.length} local posts`); 148 149 149 150 // Build a map of path -> local post for matching 150 - // Document path is like /posts/my-post-slug (or custom pathPrefix) 151 - const pathPrefix = config.pathPrefix || "/posts"; 151 + // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) 152 152 const postsByPath = new Map<string, (typeof localPosts)[0]>(); 153 153 for (const post of localPosts) { 154 - const postPath = `${pathPrefix}/${post.slug}`; 154 + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); 155 155 postsByPath.set(postPath, post); 156 156 } 157 157
+1
packages/cli/src/commands/update.ts
··· 160 160 ignore: configUpdated.ignore, 161 161 removeIndexFromSlug: configUpdated.removeIndexFromSlug, 162 162 stripDatePrefix: configUpdated.stripDatePrefix, 163 + pathTemplate: configUpdated.pathTemplate, 163 164 textContentField: configUpdated.textContentField, 164 165 bluesky: configUpdated.bluesky, 165 166 });
+3 -5
packages/cli/src/lib/atproto.ts
··· 2 2 import * as mimeTypes from "mime-types"; 3 3 import * as fs from "node:fs/promises"; 4 4 import * as path from "node:path"; 5 - import { stripMarkdownForText } from "./markdown"; 5 + import { stripMarkdownForText, resolvePostPath } from "./markdown"; 6 6 import { getOAuthClient } from "./oauth-client"; 7 7 import type { 8 8 BlobObject, ··· 245 245 config: PublisherConfig, 246 246 coverImage?: BlobObject, 247 247 ): Promise<string> { 248 - const pathPrefix = config.pathPrefix || "/posts"; 249 - const postPath = `${pathPrefix}/${post.slug}`; 248 + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); 250 249 const publishDate = new Date(post.frontmatter.publishDate); 251 250 252 251 // Determine textContent: use configured field from frontmatter, or fallback to markdown body ··· 307 306 308 307 const [, , collection, rkey] = uriMatch; 309 308 310 - const pathPrefix = config.pathPrefix || "/posts"; 311 - const postPath = `${pathPrefix}/${post.slug}`; 309 + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); 312 310 const publishDate = new Date(post.frontmatter.publishDate); 313 311 314 312 // Determine textContent: use configured field from frontmatter, or fallback to markdown body
+5
packages/cli/src/lib/config.ts
··· 83 83 ignore?: string[]; 84 84 removeIndexFromSlug?: boolean; 85 85 stripDatePrefix?: boolean; 86 + pathTemplate?: string; 86 87 textContentField?: string; 87 88 bluesky?: BlueskyConfig; 88 89 }): string { ··· 127 128 128 129 if (options.stripDatePrefix) { 129 130 config.stripDatePrefix = options.stripDatePrefix; 131 + } 132 + 133 + if (options.pathTemplate) { 134 + config.pathTemplate = options.pathTemplate; 130 135 } 131 136 132 137 if (options.textContentField) {
+47
packages/cli/src/lib/markdown.ts
··· 231 231 return slug; 232 232 } 233 233 234 + export function resolvePathTemplate( 235 + template: string, 236 + post: BlogPost, 237 + ): string { 238 + const publishDate = new Date(post.frontmatter.publishDate); 239 + const year = String(publishDate.getFullYear()); 240 + const month = String(publishDate.getMonth() + 1).padStart(2, "0"); 241 + const day = String(publishDate.getDate()).padStart(2, "0"); 242 + 243 + const slugifiedTitle = (post.frontmatter.title || "") 244 + .toLowerCase() 245 + .replace(/\s+/g, "-") 246 + .replace(/[^\w-]/g, ""); 247 + 248 + // Replace known tokens 249 + let result = template 250 + .replace(/\{slug\}/g, post.slug) 251 + .replace(/\{year\}/g, year) 252 + .replace(/\{month\}/g, month) 253 + .replace(/\{day\}/g, day) 254 + .replace(/\{title\}/g, slugifiedTitle); 255 + 256 + // Replace any remaining {field} tokens with raw frontmatter values 257 + result = result.replace(/\{(\w+)\}/g, (_match, field: string) => { 258 + const value = post.rawFrontmatter[field]; 259 + if (value != null && typeof value === "string") { 260 + return value; 261 + } 262 + return ""; 263 + }); 264 + 265 + // Ensure leading slash 266 + if (!result.startsWith("/")) { 267 + result = `/${result}`; 268 + } 269 + 270 + return result; 271 + } 272 + 273 + export function resolvePostPath(post: BlogPost, pathPrefix?: string, pathTemplate?: string): string { 274 + if (pathTemplate) { 275 + return resolvePathTemplate(pathTemplate, post); 276 + } 277 + const prefix = pathPrefix || "/posts"; 278 + return `${prefix}/${post.slug}`; 279 + } 280 + 234 281 export async function getContentHash(content: string): Promise<string> { 235 282 const encoder = new TextEncoder(); 236 283 const data = encoder.encode(content);
+1
packages/cli/src/lib/types.ts
··· 39 39 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 40 40 removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 41 41 stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 42 + pathTemplate?: string; // URL path template with tokens like {year}/{month}/{day}/{slug} (overrides pathPrefix + slug) 42 43 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 43 44 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 44 45 ui?: UIConfig; // Optional UI components configuration