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

Add pathTemplate config option for custom URL paths #23

merged opened by stevedylan.dev targeting main from feat/slug-templating

Summary#

  • Adds a pathTemplate config field that supports token-based URL path construction (e.g., /blog/{year}/{month}/{slug}), overriding the default pathPrefix/slug behavior
  • Centralizes path resolution into a single resolvePostPath() helper used across publish, sync, Bluesky posting, and document creation
  • Preserves pathTemplate through config generation and the sequoia update flow
  • Documents the feature with available tokens ({slug}, {year}, {month}, {day}, {title}, and arbitrary frontmatter fields)

Test plan#

  • Verify sequoia publish --dry-run --verbose shows correct URLs with pathTemplate set
  • Verify sequoia update preserves pathTemplate in saved config
  • Verify default behavior unchanged when pathTemplate is not set

Prerelease NPM Package#

This PR can be tested two ways

  • Install the prerelease NPM package with npm i -g sequoia-cli@0.5.0-alpha.0
  • Checkout this branch and build locally
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3mepbv5k4po22
+109 -14
Diff #1
+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
+8 -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( 245 + post, 246 + config.pathPrefix, 247 + config.pathTemplate, 248 + ); 249 + postUrl = `\n ${config.siteUrl}${postPath}`; 245 250 } 246 251 log.message( 247 252 ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, ··· 349 354 } else { 350 355 // Create Bluesky post 351 356 try { 352 - const pathPrefix = config.pathPrefix || "/posts"; 353 - const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 357 + const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; 354 358 355 359 bskyPostRef = await createBlueskyPost(agent, { 356 360 title: post.frontmatter.title,
+7 -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( 155 + post, 156 + config.pathPrefix, 157 + config.pathTemplate, 158 + ); 155 159 postsByPath.set(postPath, post); 156 160 } 157 161
+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 });
+1 -2
packages/cli/src/components/sequoia-comments.js
··· 588 588 this.commentsContainer = container; 589 589 this.state = { type: "loading" }; 590 590 this.abortController = null; 591 - 592 591 } 593 592 594 593 static get observedAttributes() { ··· 701 700 </div> 702 701 `; 703 702 if (this.hide) { 704 - this.commentsContainer.style.display = 'none'; 703 + this.commentsContainer.style.display = "none"; 705 704 } 706 705 break; 707 706
+11 -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( 249 + post, 250 + config.pathPrefix, 251 + config.pathTemplate, 252 + ); 250 253 const publishDate = new Date(post.frontmatter.publishDate); 251 254 252 255 // Determine textContent: use configured field from frontmatter, or fallback to markdown body ··· 307 310 308 311 const [, , collection, rkey] = uriMatch; 309 312 310 - const pathPrefix = config.pathPrefix || "/posts"; 311 - const postPath = `${pathPrefix}/${post.slug}`; 313 + const postPath = resolvePostPath( 314 + post, 315 + config.pathPrefix, 316 + config.pathTemplate, 317 + ); 312 318 const publishDate = new Date(post.frontmatter.publishDate); 313 319 314 320 // 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) {
+48
packages/cli/src/lib/markdown.ts
··· 231 231 return slug; 232 232 } 233 233 234 + export function resolvePathTemplate(template: string, post: BlogPost): string { 235 + const publishDate = new Date(post.frontmatter.publishDate); 236 + const year = String(publishDate.getFullYear()); 237 + const month = String(publishDate.getMonth() + 1).padStart(2, "0"); 238 + const day = String(publishDate.getDate()).padStart(2, "0"); 239 + 240 + const slugifiedTitle = (post.frontmatter.title || "") 241 + .toLowerCase() 242 + .replace(/\s+/g, "-") 243 + .replace(/[^\w-]/g, ""); 244 + 245 + // Replace known tokens 246 + let result = template 247 + .replace(/\{slug\}/g, post.slug) 248 + .replace(/\{year\}/g, year) 249 + .replace(/\{month\}/g, month) 250 + .replace(/\{day\}/g, day) 251 + .replace(/\{title\}/g, slugifiedTitle); 252 + 253 + // Replace any remaining {field} tokens with raw frontmatter values 254 + result = result.replace(/\{(\w+)\}/g, (_match, field: string) => { 255 + const value = post.rawFrontmatter[field]; 256 + if (value != null && typeof value === "string") { 257 + return value; 258 + } 259 + return ""; 260 + }); 261 + 262 + // Ensure leading slash 263 + if (!result.startsWith("/")) { 264 + result = `/${result}`; 265 + } 266 + 267 + return result; 268 + } 269 + 270 + export function resolvePostPath( 271 + post: BlogPost, 272 + pathPrefix?: string, 273 + pathTemplate?: string, 274 + ): string { 275 + if (pathTemplate) { 276 + return resolvePathTemplate(pathTemplate, post); 277 + } 278 + const prefix = pathPrefix || "/posts"; 279 + return `${prefix}/${post.slug}`; 280 + } 281 + 234 282 export async function getContentHash(content: string): Promise<string> { 235 283 const encoder = new TextEncoder(); 236 284 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

History

2 rounds 0 comments
sign up or login to add to the discussion
2 commits
expand
feat: added slug templating
chore: lint and format
1/1 success
expand
expand 0 comments
pull request successfully merged
1 commit
expand
feat: added slug templating
1/1 failed
expand
expand 0 comments