import fs from 'node:fs'; import path from 'node:path'; export interface RouteParam { name: string; matcher?: string; optional: boolean; rest: boolean; chained: boolean; } export interface RouteData { id: string; parent: RouteData | null; segment: string; pattern: RegExp; params: RouteParam[]; layout: PageNode | null; leaf: PageNode | null; page: { layouts: Array; leaf: number; } | null; } export interface PageNode { depth: number; component?: string; parent_id?: string; parent?: PageNode; child_pages?: PageNode[]; } export interface RoutingConfig { routes_dir: string; extensions: string[]; cwd?: string; } const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/; function parse_route_id(id: string): { pattern: RegExp; params: RouteParam[] } { const params: RouteParam[] = []; const pattern = id === '/' ? /^\/$/ : new RegExp( `^${get_route_segments(id) .map((segment) => { // special case — /[...rest]/ could contain zero segments const rest_match = /^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(segment); if (rest_match) { params.push({ name: rest_match[1], matcher: rest_match[2], optional: false, rest: true, chained: true, }); return '(?:/([^]*))?'; } // special case — /[[optional]]/ could contain zero segments const optional_match = /^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(segment); if (optional_match) { params.push({ name: optional_match[1], matcher: optional_match[2], optional: true, rest: false, chained: true, }); return '(?:/([^/]+))?'; } if (!segment) { return; } const parts = segment.split(/\[(.+?)\](?!\])/); const result = parts .map((content, i) => { if (i % 2) { if (content.startsWith('x+')) { return escape(String.fromCharCode(parseInt(content.slice(2), 16))); } if (content.startsWith('u+')) { return escape( String.fromCharCode( ...content .slice(2) .split('-') .map((code) => parseInt(code, 16)), ), ); } const match = param_pattern.exec(content); if (!match) { throw new Error( `Invalid param: ${content}. Params and matcher names can only have underscores and alphanumeric characters.`, ); } const [, is_optional, is_rest, name, matcher] = match; params.push({ name, matcher, optional: !!is_optional, rest: !!is_rest, chained: is_rest ? i === 1 && parts[0] === '' : false, }); return is_rest ? '([^]*?)' : is_optional ? '([^/]*)?' : '([^/]+?)'; } return escape(content); }) .join(''); return '/' + result; }) .join('')}/?$`, ); return { pattern, params }; } /** * Returns false for (group) segments */ function affects_path(segment: string): boolean { return segment !== '' && !/^\([^)]+\)$/.test(segment); } /** * Splits a route id into its segments, removing segments that * don't affect the path (i.e. groups). The root route is represented by `/` * and will be returned as `['']`. */ function get_route_segments(route: string): string[] { return route.slice(1).split('/').filter(affects_path); } /** * Populate a route ID with params to resolve a pathname. * @example * resolveRoute('/blog/[slug]/[...rest]', { slug: 'hello', rest: 'world/foo' }) * // returns '/blog/hello/world/foo' */ export function resolve_route(id: string, params: Record): string { const segments = get_route_segments(id); const has_id_trailing_slash = id !== '/' && id.endsWith('/'); const basic_param_pattern = /\[(\[)?(\.\.\.)?(\w+?)(?:=(\w+))?\]\]?/g; return ( '/' + segments .map((segment) => segment.replace(basic_param_pattern, (_, optional, rest, name) => { const param_value = params[name]; if (!param_value) { if (optional) return ''; if (rest && param_value !== undefined) return ''; throw new Error(`Missing parameter '${name}' in route ${id}`); } if (param_value.startsWith('/') || param_value.endsWith('/')) throw new Error(`Parameter '${name}' in route ${id} cannot start or end with a slash`); return param_value; }), ) .filter(Boolean) .join('/') + (has_id_trailing_slash ? '/' : '') ); } function escape(str: string): string { return str .normalize() .replace(/[[\]]/g, '\\$&') .replace(/%/g, '%25') .replace(/\//g, '%2[Ff]') .replace(/\?/g, '%3[Ff]') .replace(/#/g, '%23') .replace(/[.*+?^${}()|\\]/g, '\\$&'); } /** * Analyzes a Vue file to determine its type and properties */ function analyze( project_relative: string, file: string, component_extensions: string[], ): { kind: 'component'; is_page: boolean; is_layout: boolean; uses_layout?: string; } { const component_extension = component_extensions.find((ext) => file.endsWith(ext)); if (component_extension) { const name = file.slice(0, -component_extension.length); const pattern = /^\+(?:(page(?:@(.*))?)|(layout(?:@(.*))?))$/; const match = pattern.exec(name); if (!match) { throw new Error(`Files prefixed with + are reserved (saw ${project_relative})`); } return { kind: 'component', is_page: !!match[1], is_layout: !!match[3], uses_layout: match[2] ?? match[4], }; } throw new Error(`Files and directories prefixed with + are reserved (saw ${project_relative})`); } /** * Creates routes and nodes from filesystem structure */ export function create_routes_and_nodes(config: RoutingConfig): { routes: RouteData[]; nodes: PageNode[]; } { const routes: RouteData[] = []; const nodes: PageNode[] = []; const cwd = config.cwd || process.cwd(); const routes_base = path.relative(cwd, config.routes_dir); const walk = (depth: number, id: string, segment: string, parent: RouteData | null) => { const unescaped = id.replace(/\[([ux])\+([^\]]+)\]/gi, (match, type, code) => { if (match !== match.toLowerCase()) { throw new Error(`Character escape sequence in ${id} must be lowercase`); } if (!/[0-9a-f]+/.test(code)) { throw new Error(`Invalid character escape sequence in ${id}`); } if (type === 'x') { if (code.length !== 2) { throw new Error(`Hexadecimal escape sequence in ${id} must be two characters`); } return String.fromCharCode(parseInt(code, 16)); } else { if (code.length < 4 || code.length > 6) { throw new Error(`Unicode escape sequence in ${id} must be between four and six characters`); } return String.fromCharCode(parseInt(code, 16)); } }); // Validation checks if (/\]\[/.test(unescaped)) { throw new Error(`Invalid route ${id} — parameters must be separated`); } if (count_occurrences('[', id) !== count_occurrences(']', id)) { throw new Error(`Invalid route ${id} — brackets are unbalanced`); } if (/#/.test(segment)) { throw new Error(`Route ${id} should be renamed to ${id.replace(/#/g, '[x+23]')}`); } if (/\[\.\.\.\w+\]\/\[\[/.test(id)) { throw new Error( `Invalid route ${id} — an [[optional]] route segment cannot follow a [...rest] route segment`, ); } if (/\[\[\.\.\./.test(id)) { throw new Error( `Invalid route ${id} — a rest route segment is always optional, remove the outer square brackets`, ); } const { pattern, params } = parse_route_id(id); const route: RouteData = { id, parent, segment, pattern, params, layout: null, leaf: null, page: null, }; routes.push(route); Object.defineProperty(route, 'parent', { enumerable: false }); const dir = path.join(cwd, routes_base, id); if (!fs.existsSync(dir)) return; const files = fs.readdirSync(dir).map((name) => ({ is_dir: fs.statSync(path.join(dir, name)).isDirectory(), name, })); const valid_extensions = [...config.extensions]; // process files first for (const file of files) { if (file.is_dir) continue; const ext = valid_extensions.find((ext) => file.name.endsWith(ext)); if (!ext) continue; if (!file.name.startsWith('+')) { const name = file.name.slice(0, -ext.length); // check if it is a valid route filename but missing the + prefix const typo = /^(?:(page(?:@(.*))?)|(layout(?:@(.*))?))$/.test(name); if (typo) { console.warn(`Missing route file prefix. Did you mean +${file.name}?`); } continue; } const project_relative = path.relative(cwd, path.join(dir, file.name)).replace(/\\/g, '/'); const item = analyze(project_relative, file.name, config.extensions); const duplicate_files_error = (type: string, existing_file: string): Error => { return new Error( `Multiple ${type} files found in ${routes_base}${route.id} : ${path.basename( existing_file, )} and ${file.name}`, ); }; if (item.is_layout) { if (!route.layout) { route.layout = { depth, child_pages: [] }; } else if (route.layout.component) { throw duplicate_files_error('layout component', route.layout.component); } route.layout.component = project_relative; if (item.uses_layout !== undefined) route.layout.parent_id = item.uses_layout; } else if (item.is_page) { if (!route.leaf) { route.leaf = { depth }; } else if (route.leaf.component) { throw duplicate_files_error('page component', route.leaf.component); } route.leaf.component = project_relative; if (item.uses_layout !== undefined) route.leaf.parent_id = item.uses_layout; } } // Then handle children for (const file of files) { if (file.is_dir) { walk(depth + 1, path.posix.join(id, file.name), file.name, route); } } }; walk(0, '/', '', null); prevent_conflicts(routes); // we do layouts first as they are more likely to be reused, // and smaller indexes take fewer bytes for (const route of routes) { if (route.layout) { nodes.push(route.layout); } } for (const route of routes) { if (route.leaf) nodes.push(route.leaf); } const indexes = new Map(nodes.map((node, i) => [node, i])); for (const route of routes) { if (!route.leaf) continue; route.page = { layouts: [], leaf: indexes.get(route.leaf)!, }; let current_route: RouteData | null = route; let current_node = route.leaf; let parent_id = route.leaf.parent_id; while (current_route) { if (parent_id === undefined || current_route.segment === parent_id) { if (current_route.layout) { route.page.layouts.unshift(indexes.get(current_route.layout)); } if (current_route.layout) { if (!current_route.layout.child_pages) { current_route.layout.child_pages = []; } current_route.layout.child_pages.push(route.leaf); current_node.parent = current_node = current_route.layout; parent_id = current_node.parent_id; } else { parent_id = undefined; } } current_route = current_route.parent; } if (parent_id !== undefined) { throw new Error(`${current_node.component} references missing segment "${parent_id}"`); } } return { nodes, routes: sort_routes(routes), }; } function prevent_conflicts(routes: RouteData[]): void { const lookup = new Map(); for (const route of routes) { if (!route.leaf) continue; const normalized = normalize_route_id(route.id); // find all permutations created by optional parameters const split = normalized.split(/<\?(.+?)>/g); let permutations = [split[0] as string]; // turn `x/[[optional]]/y` into `x/y` and `x/[required]/y` for (let i = 1; i < split.length; i += 2) { const matcher = split[i]; const next = split[i + 1]; permutations = permutations.reduce((a, b) => { a.push(b + next); if (!(matcher === '*' && b.endsWith('//'))) a.push(b + `<${matcher}>${next}`); return a; }, [] as string[]); } for (const permutation of permutations) { // remove leading/trailing/duplicated slashes caused by prior // manipulation of optional parameters and (groups) const key = permutation .replace(/\/{2,}/, '/') .replace(/^\//, '') .replace(/\/$/, ''); if (lookup.has(key)) { throw new Error(`The "${lookup.get(key)}" and "${route.id}" routes conflict with each other`); } lookup.set(key, route.id); } } } function normalize_route_id(id: string): string { return ( id // remove groups .replace(/(?<=^|\/)\(.+?\)(?=$|\/)/g, '') .replace(/\[[ux]\+([0-9a-f]+)\]/g, (_, x) => String.fromCharCode(parseInt(x, 16)).replace(/\//g, '%2f')) // replace `[param]` with `<*>`, `[param=x]` with ``, and `[[param]]` with `` .replace( /\[(?:(\[)|(\.\.\.))?.+?(=.+?)?\]\]?/g, (_, optional, rest, matcher) => `<${optional ? '?' : ''}${rest ?? ''}${matcher ?? '*'}>`, ) ); } function count_occurrences(needle: string, haystack: string): number { let count = 0; for (let i = 0; i < haystack.length; i += 1) { if (haystack[i] === needle) count += 1; } return count; } function sort_routes(routes: RouteData[]): RouteData[] { interface Part { type: 'static' | 'required' | 'optional' | 'rest'; content: string; matched: boolean; } const EMPTY: Part = { type: 'static', content: '', matched: false }; const segment_cache = new Map(); function get_parts(segment: string): Part[] { if (!segment_cache.has(segment)) { segment_cache.set(segment, split(segment)); } return segment_cache.get(segment)!; } function split(id: string): Part[] { const parts: Part[] = []; let i = 0; while (i <= id.length) { const start = id.indexOf('[', i); if (start === -1) { parts.push({ type: 'static', content: id.slice(i), matched: false }); break; } parts.push({ type: 'static', content: id.slice(i, start), matched: false }); const type = id[start + 1] === '[' ? 'optional' : id[start + 1] === '.' ? 'rest' : 'required'; const delimiter = type === 'optional' ? ']]' : ']'; const end = id.indexOf(delimiter, start); if (end === -1) { throw new Error(`Invalid route ID ${id}`); } const content = id.slice(start, (i = end + delimiter.length)); parts.push({ type, content, matched: content.includes('='), }); } return parts; } function split_route_id(id: string): string[] { return get_route_segments(id.replace(/\[\[[^\]]+\]\](?!(?:\/\([^/]+\))*$)/g, '')).filter(Boolean); } function sort_static(a: string, b: string): number { if (a === b) return 0; for (let i = 0; true; i += 1) { const char_a = a[i]; const char_b = b[i]; if (char_a !== char_b) { if (char_a === undefined) return +1; if (char_b === undefined) return -1; return char_a < char_b ? -1 : +1; } } } return routes.sort((route_a, route_b) => { const segments_a = split_route_id(route_a.id).map(get_parts); const segments_b = split_route_id(route_b.id).map(get_parts); for (let i = 0; i < Math.max(segments_a.length, segments_b.length); i += 1) { const segment_a = segments_a[i] ?? [EMPTY]; const segment_b = segments_b[i] ?? [EMPTY]; for (let j = 0; j < Math.max(segment_a.length, segment_b.length); j += 1) { const a = segment_a[j]; const b = segment_b[j]; const dynamic = j % 2 === 1; if (dynamic) { if (!a) return -1; if (!b) return +1; const next_a = segment_a[j + 1]?.content || segments_a[i + 1]?.[0]?.content; const next_b = segment_b[j + 1]?.content || segments_b[i + 1]?.[0]?.content; if (a.type === 'rest' && b.type === 'rest') { if (next_a && next_b) continue; if (next_a) return -1; if (next_b) return +1; } if (a.type === 'rest') { return next_a && !next_b ? -1 : +1; } if (b.type === 'rest') { return next_b && !next_a ? +1 : -1; } if (a.matched !== b.matched) { return a.matched ? -1 : +1; } if (a.type !== b.type) { if (a.type === 'required') return -1; if (b.type === 'required') return +1; } } else if (a.content !== b.content) { if (a === EMPTY) return -1; if (b === EMPTY) return +1; return sort_static(a.content, b.content); } } } return route_a.id < route_b.id ? +1 : -1; }); }