Skip to content

Instantly share code, notes, and snippets.

@HugeLetters
Last active October 10, 2023 18:59
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save HugeLetters/7a2813897dfe08fa948a13cac8a359c7 to your computer and use it in GitHub Desktop.
Save HugeLetters/7a2813897dfe08fa948a13cac8a359c7 to your computer and use it in GitHub Desktop.
SvelteKIt type-safe router
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]));
},
[[]]
);
}
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);
}
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`
])
);
});
});
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