Skip to content

Instantly share code, notes, and snippets.

@ryoppippi
Forked from HugeLetters/router-gen.ts
Created September 25, 2023 13:11
Show Gist options
  • Save ryoppippi/3734444129ef1403d0504f9932df6a9c to your computer and use it in GitHub Desktop.
Save ryoppippi/3734444129ef1403d0504f9932df6a9c to your computer and use it in GitHub Desktop.
SvelteKIt type-safe router
import { writeFile } from 'fs/promises';
import { glob } from 'glob';
import { format } from 'prettier';
import { watch } from 'chokidar';
export default async function generateRoutes() {
const routes = await glob('./src/routes/**/+page.svelte', { withFileTypes: true }).then((files) =>
files
.filter((file) => file.isFile())
.sort((a, b) => (a.path > b.path ? 1 : -1))
.map<Route>((file) =>
file
.relative()
.split(file.sep)
// slice removes first 2 elements("src" & "routes") and last one("+page.svelte")
.slice(2, -1)
.map(stringToSegment)
.filter((x): x is Segment => !!x)
)
);
const routeType = routesToType(routes);
writeRouteFile(routeType);
}
export function generateRoutesWatcher() {
const watcher = watch('./src/routes/**/+page.svelte');
watcher.on('add', generateRoutes);
// note: doesn't trigger when a folder containing page is deleted - I don't know how to circumvent this
watcher.on('unlink', generateRoutes);
return () => {
watcher.close();
};
}
function routesToType(routes: Route[]) {
const types = routes
.flatMap((route) => {
const routeForks = forkify(route.map(segmentToType));
return routeForks.map((fork) => '/' + fork.filter(Boolean).join('/'));
})
.map((route) => `\`${route}\``);
return [...new Set(types)].join(' | ');
}
type Segment = { type: 'STATIC' | 'DYNAMIC' | 'OPTIONAL' | 'REST'; key: string };
type Route = Segment[];
function stringToSegment(segment: string): Segment | null {
if (segment.startsWith('(') && segment.endsWith(')')) return null;
if (!(segment.startsWith('[') || segment.endsWith(']'))) return { type: 'STATIC', key: segment };
// remove [], dots & matchers(=matcher)
const key = segment.replaceAll(/[[\].]|(=.+)/g, '');
if (segment.startsWith('[[')) return { type: 'OPTIONAL', key };
if (segment.startsWith('[...')) return { type: 'REST', key };
return { type: 'DYNAMIC', key };
}
function segmentToType(segment: Segment): string | [string, null] {
switch (segment.type) {
case 'STATIC':
return segment.key;
case 'DYNAMIC':
return '${Param}';
case 'OPTIONAL':
return ['${Param}', null];
case 'REST':
return ['${RestParam}', null];
default: {
const x: never = segment.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 RestParam = (TemplateToken & { readonly [Brand]: unique symbol }) | Param;
type Route = ${routeType};
export { Param, RestParam, 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<T extends TemplateToken | [TemplateToken, ...TemplateToken[]]>(x: T) {
return (Array.isArray(x) ? x.join('/') : x) as T extends 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 multiple rest params an array needs to be passed in:
*
* 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 { 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();
}
}
],
});
@HugeLetters
Copy link

HugeLetters commented Oct 10, 2023

I've noticed you made a fork - I've updated my gist since it didn't take into account some stuff like having multiple params in one "segment"(x/[p]-a-[r]-a-[m]) or page layout escapes +page@id.svelte

@ryoppippi
Copy link
Author

Oh good thanks!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment