Last active
September 6, 2024 01:46
-
-
Save HugeLetters/7a2813897dfe08fa948a13cac8a359c7 to your computer and use it in GitHub Desktop.
SvelteKIt type-safe router
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { watch } from 'chokidar'; | |
import { writeFile } from 'fs/promises'; | |
import { glob } from 'glob'; | |
import { format } from 'prettier'; | |
const pageGlobMatcher = './src/routes/**/+page?(@)*.svelte'; | |
export default async function generateRoutes() { | |
const paths = await getPaths(); | |
const routes = paths.map(parsePath); | |
const type = stringifyRoutes(routes); | |
return writeRouteFile(type).catch(console.error); | |
} | |
export function generateRoutesWatcher() { | |
let isGenerating = false; | |
async function handler() { | |
if (isGenerating) return; | |
isGenerating = true; | |
await generateRoutes(); | |
isGenerating = false; | |
} | |
const pageWatcher = watch(pageGlobMatcher); | |
pageWatcher.on('add', handler); | |
pageWatcher.on('unlink', handler); | |
const dirWatcher = watch('./src/routes'); | |
dirWatcher.on('unlinkDir', handler); | |
return () => { | |
pageWatcher.close(); | |
dirWatcher.close(); | |
}; | |
} | |
function getPaths() { | |
return glob(pageGlobMatcher, { withFileTypes: true }).then((files) => | |
files | |
.filter((file) => file.isFile()) | |
.sort((a, b) => (a.path > b.path ? 1 : -1)) | |
.map((path) => | |
path | |
.relative() | |
.split(path.sep) | |
// slice removes first 2 elements("src" & "routes") and last one("+page.svelte") | |
.slice(2, -1) | |
) | |
); | |
} | |
type Chunk = { type: 'STATIC' | 'DYNAMIC' | 'OPTIONAL' | 'REST'; key: string }; | |
type Segment = Chunk[]; | |
type Route = Segment[]; | |
export function parsePath(path: string[]): Route { | |
return ( | |
path | |
.map(parseSegment) | |
// filter null segments - null segments are a result of group routes | |
.filter((x): x is Segment => !!x) | |
); | |
} | |
function parseSegment(segment: string): Segment | null { | |
if (segment.startsWith('(') && segment.endsWith(')')) return null; | |
return ( | |
segment | |
.split(/(\[+.+?\]+)/) | |
// filter empty strings which appear after split if matched splitter is at the start/end | |
.filter(Boolean) | |
.map(parseChunk) | |
); | |
} | |
function parseChunk(chunk: string): Chunk { | |
if (!chunk.startsWith('[') && !chunk.endsWith(']')) return { type: 'STATIC', key: chunk }; | |
// remove [], dots & matchers(=matcher) | |
const key = chunk.replaceAll(/[[\].]|(=.+)/g, ''); | |
if (chunk.startsWith('[[')) return { type: 'OPTIONAL', key }; | |
if (chunk.startsWith('[...')) return { type: 'REST', key }; | |
return { type: 'DYNAMIC', key }; | |
} | |
function stringifyRoutes(routes: Route[]): string { | |
return [...new Set(routes.flatMap(stringifyRoute))].join(' | '); | |
} | |
export function stringifyRoute(route: Route): string[] { | |
return forkify(route.map(stringifySegment)).map( | |
(fork) => | |
'`/' + | |
fork | |
// filter empty strings which are results of optional chunks | |
.filter(Boolean) | |
.join('/') + | |
'`' | |
); | |
} | |
function stringifySegment(segment: Segment): string[] { | |
return forkify(segment.map(stringifyChunk)).map((fork) => fork.filter(Boolean).join('')); | |
} | |
const PARAM = 'Param'; | |
const REST_PARAM = 'RestParam'; | |
export const templateParam = '${' + PARAM + '}'; | |
export const templateRest = '${' + REST_PARAM + '}'; | |
function stringifyChunk(chunk: Chunk): string | [string, null] { | |
switch (chunk.type) { | |
case 'STATIC': | |
return chunk.key; | |
case 'DYNAMIC': | |
return templateParam; | |
case 'OPTIONAL': | |
return [templateParam, null]; | |
case 'REST': | |
return [templateRest, null]; | |
default: { | |
const x: never = chunk.type; | |
return x; | |
} | |
} | |
} | |
async function writeRouteFile(routeType: string) { | |
const fileData = ` | |
// This file is auto-generated. Please do not modify it. | |
declare const Brand: unique symbol; | |
type TemplateToken = string | number; | |
type ${PARAM} = TemplateToken & { readonly [Brand]: unique symbol }; | |
type ${REST_PARAM} = (TemplateToken & { readonly [Brand]: unique symbol }) | ${PARAM}; | |
type Route = ${routeType}; | |
export { ${PARAM}, ${REST_PARAM}, Route, TemplateToken } | |
`; | |
writeFile('./src/lib/router.d.ts', await format(fileData, { parser: 'typescript' })) | |
.catch((e) => { | |
console.error('Error while trying to write router.d.ts file'); | |
console.error(e); | |
}) | |
.then(() => { | |
console.log('Sucessfully saved router.d.ts file'); | |
}); | |
} | |
/** | |
* Flattens the array producing forks from provided array-elements. | |
* | |
* Example: `[1, [2, 3], 4]` will produce `[[1, 2, 4], [1, 3, 4]]` | |
*/ | |
function forkify<T>(array: Array<T | T[]>) { | |
return array.reduce<T[][]>( | |
(forks, value) => { | |
if (!Array.isArray(value)) { | |
forks.forEach((fork) => fork.push(value)); | |
return forks; | |
} | |
return value.flatMap((variant) => forks.map((fork) => [...fork, variant])); | |
}, | |
[[]] | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Param, RestParam, Route, TemplateToken } from '$lib/router'; // this will be your generated 'router.d.ts' file | |
function parseParam<P extends TemplateToken, R extends TemplateToken[]>(p: P, ...r: R) { | |
return (r.length ? `${p}/${r.join('/')}` : p) as R extends [TemplateToken, ...TemplateToken[]] | |
? RestParam | |
: Param; | |
} | |
/** | |
* Function which ensures that provided route exists in SvelteKit's file-router. | |
* @param path for static paths you may provide just a string. | |
* | |
* For dynamic routes it has to be a function. It's first argument will be a transform function through which you will need to pass route params. | |
* | |
* Exmaple: `route(p => '/user/${p(id)}')` | |
* | |
* For rest params a list of value is accepted: | |
* | |
* Example: `route(p => '/compare/${p(id1, id2, id3)}')` | |
* @returns a string with a resolved route | |
*/ | |
export function route(path: Route | ((param: typeof parseParam) => Route)) { | |
return typeof path === 'string' ? path : path(parseParam); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { describe, expect, test } from 'vitest'; | |
import { | |
templateParam as Param, | |
templateRest as Rest, | |
parsePath, | |
stringifyRoute | |
} from './router-gen'; | |
function parseType(path: string[]) { | |
return stringifyRoute(parsePath(path)); | |
} | |
function constructUnion(types: string[]) { | |
return types.map((type) => '`' + type + '`'); | |
} | |
describe('Test that router codegen parses', () => { | |
test('static paths', () => { | |
expect(parseType(['x', 'y', 'z'])).toEqual(constructUnion(['/x/y/z'])); | |
expect(parseType([])).toEqual(constructUnion(['/'])); | |
expect(parseType(['a'])).toEqual(constructUnion(['/a'])); | |
}); | |
test('params', () => { | |
expect(parseType(['x', '[y]', 'z'])).toEqual(constructUnion([`/x/${Param}/z`])); | |
expect(parseType(['[x]'])).toEqual(constructUnion([`/${Param}`])); | |
expect(parseType(['[x]', 'y'])).toEqual(constructUnion([`/${Param}/y`])); | |
expect(parseType(['x', '[y]'])).toEqual(constructUnion([`/x/${Param}`])); | |
}); | |
test('optional params', () => { | |
expect(parseType(['x', '[[y]]', 'z'])).toEqual(constructUnion([`/x/${Param}/z`, `/x/z`])); | |
expect(parseType(['[[x]]'])).toEqual(constructUnion([`/${Param}`, `/`])); | |
expect(parseType(['[[x]]', 'y'])).toEqual(constructUnion([`/${Param}/y`, `/y`])); | |
expect(parseType(['x', '[[y]]'])).toEqual(constructUnion([`/x/${Param}`, `/x`])); | |
}); | |
test('rest params', () => { | |
expect(parseType(['x', '[...y]', 'z'])).toEqual(constructUnion([`/x/${Rest}/z`, `/x/z`])); | |
expect(parseType(['[...x]'])).toEqual(constructUnion([`/${Rest}`, `/`])); | |
expect(parseType(['[...x]', 'y'])).toEqual(constructUnion([`/${Rest}/y`, `/y`])); | |
expect(parseType(['x', '[...y]'])).toEqual(constructUnion([`/x/${Rest}`, `/x`])); | |
}); | |
test('groups', () => { | |
expect(parseType(['x', '(y)', 'z'])).toEqual(constructUnion([`/x/z`])); | |
expect(parseType(['(x)'])).toEqual(constructUnion([`/`])); | |
expect(parseType(['(x)', 'y'])).toEqual(constructUnion([`/y`])); | |
expect(parseType(['x', '(y)'])).toEqual(constructUnion([`/x`])); | |
}); | |
test('multiple params', () => { | |
expect(parseType(['x', 'a-[x]-[[x]]-y', 'z'])).toEqual( | |
constructUnion([`/x/a-${Param}-${Param}-y/z`, `/x/a-${Param}--y/z`]) | |
); | |
expect(parseType(['a-[x]-[[x]]-y'])).toEqual( | |
constructUnion([`/a-${Param}-${Param}-y`, `/a-${Param}--y`]) | |
); | |
expect(parseType(['a-[x]-[[x]]-y', 'y'])).toEqual( | |
constructUnion([`/a-${Param}-${Param}-y/y`, `/a-${Param}--y/y`]) | |
); | |
expect(parseType(['x', 'a-[x]-[[x]]-y'])).toEqual( | |
constructUnion([`/x/a-${Param}-${Param}-y`, `/x/a-${Param}--y`]) | |
); | |
}); | |
test('some complicated route', () => { | |
expect(parseType(['(x)', '[[x]]-a-[...x]', '[x]-y', '(x)', 'y', '[x]-[[x]]', 'x'])).toEqual( | |
constructUnion([ | |
`/${Param}-a-${Rest}/${Param}-y/y/${Param}-${Param}/x`, | |
`/-a-${Rest}/${Param}-y/y/${Param}-${Param}/x`, | |
`/${Param}-a-/${Param}-y/y/${Param}-${Param}/x`, | |
`/-a-/${Param}-y/y/${Param}-${Param}/x`, | |
`/${Param}-a-${Rest}/${Param}-y/y/${Param}-/x`, | |
`/-a-${Rest}/${Param}-y/y/${Param}-/x`, | |
`/${Param}-a-/${Param}-y/y/${Param}-/x`, | |
`/-a-/${Param}-y/y/${Param}-/x` | |
]) | |
); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { sveltekit } from '@sveltejs/kit/vite'; | |
import { defineConfig } from 'vite'; | |
import { generateRoutesWatcher } from './scripts/router-gen'; | |
let cleanup = () => void 0; | |
export default defineConfig({ | |
plugins: [ | |
sveltekit(), | |
{ | |
name: 'codegen', | |
buildStart() { | |
if (process.env.NODE_ENV !== 'development') return; | |
const routegenCleanup = generateRoutesWatcher(); | |
cleanup = () => { | |
routegenCleanup(); | |
}; | |
}, | |
buildEnd() { | |
cleanup(); | |
} | |
} | |
], | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment