From 27600cdac1c329af87431f9fc6d7896b06f06700 Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 12 Feb 2026 19:41:18 -0500 Subject: [PATCH] feat: added slug templating --- docs/docs/pages/config.mdx | 27 ++++++++++++++++ packages/cli/src/commands/publish.ts | 8 ++--- packages/cli/src/commands/sync.ts | 6 ++-- packages/cli/src/commands/update.ts | 1 + packages/cli/src/lib/atproto.ts | 8 ++--- packages/cli/src/lib/config.ts | 5 +++ packages/cli/src/lib/markdown.ts | 47 ++++++++++++++++++++++++++++ packages/cli/src/lib/types.ts | 1 + 8 files changed, 91 insertions(+), 12 deletions(-) diff --git a/docs/docs/pages/config.mdx b/docs/docs/pages/config.mdx index 0d1a47c..75444e4 100644 --- a/docs/docs/pages/config.mdx +++ b/docs/docs/pages/config.mdx @@ -18,6 +18,7 @@ | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | +| `pathTemplate` | `string` | No | - | URL path template with tokens (overrides `pathPrefix` + slug) | | `bluesky` | `object` | No | - | Bluesky posting configuration | | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) | | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | @@ -34,6 +35,7 @@ "publicDir": "public", "outputDir": "dist", "pathPrefix": "/posts", + "pathTemplate": "/blog/{year}/{month}/{slug}", "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdlavhxjhm2v", "pdsUrl": "https://andromeda.social", "frontmatter": { @@ -114,6 +116,31 @@ Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for order This transforms `2024-01-15-my-post.md` into the slug `my-post`. +### Path Template + +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: + +```json +{ + "pathTemplate": "/blog/{year}/{month}/{slug}" +} +``` + +This would produce paths like `/blog/2024/01/my-post`. + +**Available tokens:** + +| Token | Description | Example | +|-------|-------------|---------| +| `{slug}` | The generated slug (from filepath or `slugField`) | `my-post` | +| `{year}` | Four-digit publish year | `2024` | +| `{month}` | Zero-padded publish month | `01` | +| `{day}` | Zero-padded publish day | `15` | +| `{title}` | Slugified post title | `my-first-post` | +| `{field}` | Any frontmatter field value (string fields only) | - | + +When `pathTemplate` is set, it overrides `pathPrefix`. If `pathTemplate` is not set, the default `pathPrefix`/slug behavior is used. + ### Ignoring Files Some frameworks use special files like `_index.md` (Zola) for section pages that aren't actual blog posts. Use the `ignore` field to skip these files during publishing: diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index ef7db38..2a77a5b 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -22,6 +22,7 @@ import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri, + resolvePostPath, } from "../lib/markdown"; import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; import { exitOnCancel } from "../lib/prompts"; @@ -240,8 +241,8 @@ export const publishCommand = command({ let postUrl = ""; if (verbose) { - const pathPrefix = config.pathPrefix || "/posts"; - postUrl = `\n ${config.siteUrl}${pathPrefix}/${post.slug}`; + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); + postUrl = `\n ${config.siteUrl}${postPath}`; } log.message( ` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}${postUrl}`, @@ -349,8 +350,7 @@ export const publishCommand = command({ } else { // Create Bluesky post try { - const pathPrefix = config.pathPrefix || "/posts"; - const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; + const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; bskyPostRef = await createBlueskyPost(agent, { title: post.frontmatter.title, diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index ca4dff0..52cc137 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -14,6 +14,7 @@ import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri, + resolvePostPath, } from "../lib/markdown"; import { exitOnCancel } from "../lib/prompts"; @@ -147,11 +148,10 @@ export const syncCommand = command({ s.stop(`Found ${localPosts.length} local posts`); // Build a map of path -> local post for matching - // Document path is like /posts/my-post-slug (or custom pathPrefix) - const pathPrefix = config.pathPrefix || "/posts"; + // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) const postsByPath = new Map(); for (const post of localPosts) { - const postPath = `${pathPrefix}/${post.slug}`; + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); postsByPath.set(postPath, post); } diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index d25bb9d..5526801 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -160,6 +160,7 @@ async function updateConfigFlow( ignore: configUpdated.ignore, removeIndexFromSlug: configUpdated.removeIndexFromSlug, stripDatePrefix: configUpdated.stripDatePrefix, + pathTemplate: configUpdated.pathTemplate, textContentField: configUpdated.textContentField, bluesky: configUpdated.bluesky, }); diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index ddd3d31..b865fb4 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -2,7 +2,7 @@ import { Agent, AtpAgent } from "@atproto/api"; import * as mimeTypes from "mime-types"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { stripMarkdownForText } from "./markdown"; +import { stripMarkdownForText, resolvePostPath } from "./markdown"; import { getOAuthClient } from "./oauth-client"; import type { BlobObject, @@ -245,8 +245,7 @@ export async function createDocument( config: PublisherConfig, coverImage?: BlobObject, ): Promise { - const pathPrefix = config.pathPrefix || "/posts"; - const postPath = `${pathPrefix}/${post.slug}`; + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); const publishDate = new Date(post.frontmatter.publishDate); // Determine textContent: use configured field from frontmatter, or fallback to markdown body @@ -307,8 +306,7 @@ export async function updateDocument( const [, , collection, rkey] = uriMatch; - const pathPrefix = config.pathPrefix || "/posts"; - const postPath = `${pathPrefix}/${post.slug}`; + const postPath = resolvePostPath(post, config.pathPrefix, config.pathTemplate); const publishDate = new Date(post.frontmatter.publishDate); // Determine textContent: use configured field from frontmatter, or fallback to markdown body diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index a64785e..44c6a6c 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -83,6 +83,7 @@ export function generateConfigTemplate(options: { ignore?: string[]; removeIndexFromSlug?: boolean; stripDatePrefix?: boolean; + pathTemplate?: string; textContentField?: string; bluesky?: BlueskyConfig; }): string { @@ -129,6 +130,10 @@ export function generateConfigTemplate(options: { config.stripDatePrefix = options.stripDatePrefix; } + if (options.pathTemplate) { + config.pathTemplate = options.pathTemplate; + } + if (options.textContentField) { config.textContentField = options.textContentField; } diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index dc257b6..9bec8bc 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -231,6 +231,53 @@ export function getSlugFromOptions( return slug; } +export function resolvePathTemplate( + template: string, + post: BlogPost, +): string { + const publishDate = new Date(post.frontmatter.publishDate); + const year = String(publishDate.getFullYear()); + const month = String(publishDate.getMonth() + 1).padStart(2, "0"); + const day = String(publishDate.getDate()).padStart(2, "0"); + + const slugifiedTitle = (post.frontmatter.title || "") + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w-]/g, ""); + + // Replace known tokens + let result = template + .replace(/\{slug\}/g, post.slug) + .replace(/\{year\}/g, year) + .replace(/\{month\}/g, month) + .replace(/\{day\}/g, day) + .replace(/\{title\}/g, slugifiedTitle); + + // Replace any remaining {field} tokens with raw frontmatter values + result = result.replace(/\{(\w+)\}/g, (_match, field: string) => { + const value = post.rawFrontmatter[field]; + if (value != null && typeof value === "string") { + return value; + } + return ""; + }); + + // Ensure leading slash + if (!result.startsWith("/")) { + result = `/${result}`; + } + + return result; +} + +export function resolvePostPath(post: BlogPost, pathPrefix?: string, pathTemplate?: string): string { + if (pathTemplate) { + return resolvePathTemplate(pathTemplate, post); + } + const prefix = pathPrefix || "/posts"; + return `${prefix}/${post.slug}`; +} + export async function getContentHash(content: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(content); diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 993b474..0381ea4 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -39,6 +39,7 @@ export interface PublisherConfig { ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) + pathTemplate?: string; // URL path template with tokens like {year}/{month}/{day}/{slug} (overrides pathPrefix + slug) textContentField?: string; // Frontmatter field to use for textContent instead of markdown body bluesky?: BlueskyConfig; // Optional Bluesky posting configuration ui?: UIConfig; // Optional UI components configuration -- 2.51.2