// 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', search: false, }) /** 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', search: 'journal-search', }) /** The route param each path must carry. */ const REQUIRED_PARAM: Readonly> = { index: null, post: 'slug', category: 'category', tag: 'tag', author: 'author', search: null, } 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('/') }) } /** The listing views that page (the search page keeps ?page with its ?q). */ export const JOURNAL_PAGED_VIEWS: ReadonlyArray = Object.freeze(['index', 'category', 'tag', 'author']) /** The route name of a listing's path-form page route for one segment. */ export function journalPagedRouteName(key: keyof JournalRoutes, segment: string): string { return `${JOURNAL_ROUTE_NAMES[key]}-page-${segment}` } /** * The pagination option the journal's path-form routes follow (P33-71, L4 * WR-03): vue-seo-layer's resolved option, which its module's setup writes * into runtimeConfig.public.seo.pagination with its defaults (segments pl * strona, en page), so the routes match the links usePagination builds; * else the raw seo.pagination of the app's config. Read inside the * extendPages callback, which runs after every module's setup. No import * from vue-seo-layer: a git-installed layer's .ts is not type-stripped by * Node under node_modules. */ export function journalPaginationSource(nuxtOptions: unknown): unknown { if (nuxtOptions === null || typeof nuxtOptions !== 'object') return undefined const o = nuxtOptions as { runtimeConfig?: { public?: { seo?: { pagination?: unknown } } }; seo?: { pagination?: unknown } } const resolved = o.runtimeConfig?.public?.seo?.pagination if (resolved !== null && typeof resolved === 'object') return resolved return o.seo?.pagination } /** * The extra listing routes of the path pagination form (D-40): with the * vue-seo-layer build option seo.pagination.form 'path', each listing gets * `//:page` for every distinct locale segment * (seo.pagination.segments, else the param name), rendering the same page * as the listing. The query form, or no option, adds none. */ export function journalPagedRoutes(routes: JournalRoutes, pagination: unknown): Array<{ key: keyof JournalRoutes; name: string; path: string }> { const p = pagination !== null && typeof pagination === 'object' ? (pagination as Record) : {} if (p.form !== 'path') return [] const segment = /^[\p{L}\p{N}_-]+$/u const raw = p.segments !== null && typeof p.segments === 'object' ? Object.values(p.segments as Record) : [] const segments = [...new Set(raw.filter((v): v is string => typeof v === 'string' && segment.test(v)))] if (segments.length === 0) segments.push(typeof p.param === 'string' && segment.test(p.param) ? p.param : 'page') const out: Array<{ key: keyof JournalRoutes; name: string; path: string }> = [] for (const key of JOURNAL_PAGED_VIEWS) { const base = routes[key] if (base === false) continue for (const seg of segments) out.push({ key, name: journalPagedRouteName(key, seg), path: `${base.replace(/\/+$/, '')}/${seg}/:page(\\d+)` }) } return out }