// Journal route configuration: defaults, stable route names, validation and // path filling. Pure TypeScript, so `node --test` and the layer module both // import it directly. import type { JournalRoutes } from './types.ts' /** Neutral default paths. An app moves or disables any of them in journal.routes. */ export const DEFAULT_JOURNAL_ROUTES: Readonly = Object.freeze({ index: '/blog', post: '/blog/:slug', category: '/blog/category/:category', tag: '/blog/tag/:tag', author: '/blog/author/:author', }) /** Route names stay stable whatever the paths are, so links never hard-code a path. */ export const JOURNAL_ROUTE_NAMES: Readonly> = Object.freeze({ index: 'journal-index', post: 'journal-post', category: 'journal-category', tag: 'journal-tag', author: 'journal-author', }) /** The route param each path must carry. */ const REQUIRED_PARAM: Readonly> = { index: null, post: 'slug', category: 'category', tag: 'tag', author: 'author', } const ROUTE_KEYS = Object.keys(DEFAULT_JOURNAL_ROUTES) as Array /** * Merges `input` over the defaults and validates every value: each is `false` * or a string starting with "/", and post, category, tag and author carry * their `:slug`, `:category`, `:tag` or `:author` param. Throws an Error that * names `journal.routes.` otherwise. */ export function normalizeJournalRoutes(input?: Partial> | null): JournalRoutes { const out = { ...DEFAULT_JOURNAL_ROUTES } as JournalRoutes for (const key of ROUTE_KEYS) { const raw = input && Object.prototype.hasOwnProperty.call(input, key) ? input[key] : undefined if (raw === undefined || raw === null) continue if (raw === false) { out[key] = false continue } if (typeof raw !== 'string' || !raw.startsWith('/')) { throw new Error(`journal.routes.${key} must be false or a path starting with "/", got ${JSON.stringify(raw)}`) } const param = REQUIRED_PARAM[key] if (param && !new RegExp(`:${param}(?![A-Za-z0-9_])`).test(raw)) { throw new Error(`journal.routes.${key} must contain the :${param} param, got "${raw}"`) } out[key] = raw } return out } /** * Fills the `:name` params of a route pattern, encoding each value per path * segment (a "/" inside a value stays a separator). A custom matcher such as * `:slug(.*)` and a trailing modifier are dropped with the param. Unknown * params become empty. */ export function fillJournalPath(pattern: string, params: Record): string { return pattern.replace(/:([A-Za-z_][A-Za-z0-9_]*)(\([^)]*\))?[?*+]?/g, (_m, name: string) => { const value = params[name] if (value === undefined || value === null) return '' return String(value) .split('/') .map((part) => encodeURIComponent(part)) .join('/') }) }