feat: added slug templating
27600cda
8 file(s) · +91 −12
| 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 | ||
| 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, |
|
| 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 | ||
| 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 | }); |
| 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 |
|
| 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) { |
|
| 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); |
| 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 |