feat: added slug templating 27600cda
Steve · 2026-02-12 19:41 8 file(s) · +91 −12
docs/docs/pages/config.mdx +27 −0
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
packages/cli/src/commands/publish.ts +4 −4
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,
packages/cli/src/commands/sync.ts +3 −3
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
packages/cli/src/commands/update.ts +1 −0
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
		});
packages/cli/src/lib/atproto.ts +3 −5
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
packages/cli/src/lib/config.ts +5 −0
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) {
packages/cli/src/lib/markdown.ts +47 −0
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);
packages/cli/src/lib/types.ts +1 −0
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