Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add file-based router #1985

Merged
merged 24 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6155a62
feat: add initial implementation of @vaadin/hilla-file-router package
Lodin Jan 24, 2024
d043aca
feat: add React implementation
Lodin Jan 25, 2024
6587593
chore(file-router): export react as a separate entrypoint
Lodin Jan 25, 2024
7f96e48
docs(file-router): improve comments
Lodin Jan 25, 2024
0a2547f
feat(file-router): add useViewConfig hook
Lodin Jan 25, 2024
de38f7e
refactor(file-router): generate meta info in JSON file
Lodin Jan 30, 2024
46bd2bf
refactor(file-router): get route title from component if there is no …
Lodin Jan 31, 2024
351a60a
refactor(file-router): improve file watching & fix title replacement
Lodin Feb 1, 2024
e93eb5d
test(file-router): update test implementation
Lodin Feb 1, 2024
8fa9be8
test(file-router): fix test execution
Lodin Feb 1, 2024
09d3088
refactor(file-router): fixes & implementations
Lodin Feb 3, 2024
aa554da
fix(file-router): resolve build issue
Lodin Feb 5, 2024
00ccd6b
Merge branch 'main' into feat/file-router
Lodin Feb 5, 2024
8810cdb
chore(file-router): update package-lock.json
Lodin Feb 5, 2024
45d41a3
chore: update codecov-action
Lodin Feb 5, 2024
deacb1e
Merge branch 'main' into feat/file-router
Lodin Feb 5, 2024
9799bcf
Merge branch 'main' into feat/file-router
Lodin Feb 6, 2024
9b41199
chore(file-router): update dependencies
Lodin Feb 6, 2024
921dbfb
refactor(file-router): address review comments
Lodin Feb 7, 2024
1a48962
refactor(file-router): rename priority -> order
Lodin Feb 7, 2024
dcbf690
Merge branch 'main' into feat/file-router
Lodin Feb 7, 2024
81a5745
refactor(file-router): address review comments
Lodin Feb 7, 2024
a7692ce
Merge branch 'main' into feat/file-router
Lodin Feb 7, 2024
9fa7b15
Merge branch 'main' into feat/file-router
platosha Feb 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ jobs:
echo "COVFILES=$COVFILES" >> $GITHUB_ENV
- name: Send Coverage to Codecov
if: ${{ env.COVFILES != '' }}
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ env.COVFILES }}
Expand Down
424 changes: 307 additions & 117 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions packages/ts/generator-utils/src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ts, {
type Node,
type VisitResult,
type SourceFile,
type Statement,
type TransformationContext,
Expand Down Expand Up @@ -43,9 +44,19 @@ export function template<T>(
return selector?.(sourceFile.statements) ?? sourceFile.statements;
}

export function transform<T extends Node>(transformer: (node: Node) => Node): TransformerFactory<T> {
export function transform<T extends Node>(
transformer: (node: Node) => VisitResult<Node | undefined>,
): TransformerFactory<T> {
return (context: TransformationContext) => (root: T) => {
const visitor = (node: Node): Node => ts.visitEachChild(transformer(node), visitor, context);
const visitor = (node: Node): VisitResult<Node | undefined> => {
const transformed = transformer(node);

if (transformed !== node) {
return transformed;
}

return ts.visitEachChild(transformed, visitor, context);
};
return ts.visitEachChild(root, visitor, context);
};
}
6 changes: 6 additions & 0 deletions packages/ts/hilla-file-router/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": ["../../../.eslintrc"],
"parserOptions": {
"project": "./tsconfig.json"
}
}
6 changes: 6 additions & 0 deletions packages/ts/hilla-file-router/.lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { commands, extensions } from '../../../.lintstagedrc.js';

export default {
[`src/**/*.{${extensions}}`]: commands,
[`test/**/*.{${extensions}}`]: commands,
};
74 changes: 74 additions & 0 deletions packages/ts/hilla-file-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{
"name": "@vaadin/hilla-file-router",
"version": "24.4.0-alpha2",
"description": "Hilla file-based router",
"main": "index.js",
"module": "index.js",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/vaadin/hilla.git",
"directory": "packages/ts/hilla-file-router"
},
"keywords": [
"Hilla",
"Vite",
"Plugin",
"File",
"Router",
"Routing"
],
"scripts": {
"clean:build": "git clean -fx . -e .vite -e node_modules",
"build": "concurrently npm:build:*",
"build:esbuild": "tsx ../../../scripts/build.ts",
"build:dts": "tsc --isolatedModules -p tsconfig.build.json",
"build:copy": "cd src && copyfiles **/*.d.ts ..",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"test": "mocha test/**/*.spec.ts --config ../../../.mocharc.cjs",
"test:coverage": "c8 -c ../../../.c8rc.json npm test",
"typecheck": "tsc --noEmit"
},
"exports": {
"./react.js": {
"default": "./react.js"
},
"./vite-plugin-file-router.js": {
"default": "./vite-plugin-file-router.js"
}
},
"author": "Vaadin Ltd",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/vaadin/hilla/issues"
},
"homepage": "https://vaadin.com",
"files": [
"*.{d.ts.map,d.ts,js.map,js}"
],
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"react-router": "^6.21.1"
},
"devDependencies": {
"@esm-bundle/chai": "^4.3.4-fix.0",
"@types/chai-like": "^1.1.3",
"@types/deep-equal-in-any-order": "^1.0.3",
"@types/mocha": "^10.0.6",
"@types/sinon": "^17.0.3",
"chai-like": "^1.1.1",
"deep-equal-in-any-order": "^2.0.6",
"mocha": "^10.2.0",
"rimraf": "^5.0.5",
"sinon": "^17.0.1",
"type-fest": "^4.9.0",
"typescript": "^5.3.3"
},
"dependencies": {
"@vaadin/hilla-generator-utils": "^24.4.0-alpha1",
"react": "^18.2.0"
}
}
68 changes: 68 additions & 0 deletions packages/ts/hilla-file-router/src/collectRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { opendir } from 'node:fs/promises';
import { basename, extname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

export type RouteMeta = Readonly<{
path: string;
file?: URL;
layout?: URL;
children: RouteMeta[];
}>;

export type CollectRoutesOptions = Readonly<{
extensions: readonly string[];
parent?: URL;
}>;

function cleanUp(blank: string) {
return blank
.replaceAll(/\{\.{3}(.+)\}/gu, '$1')
.replaceAll(/\{{2}(.+)\}{2}/gu, '$1')
.replaceAll(/\{(.+)\}/gu, '$1');
}

const collator = new Intl.Collator('en-US');

export default async function collectRoutes(
dir: URL,
{ extensions, parent = dir }: CollectRoutesOptions,
): Promise<RouteMeta> {
const path = relative(fileURLToPath(parent), fileURLToPath(dir));
const children: RouteMeta[] = [];
let layout: URL | undefined;

for await (const d of await opendir(dir)) {
if (d.isDirectory()) {
children.push(await collectRoutes(new URL(`${d.name}/`, dir), { extensions, parent: dir }));
} else if (d.isFile() && extensions.includes(extname(d.name))) {
const file = new URL(d.name, dir);
const name = basename(d.name, extname(d.name));

if (name.startsWith('$')) {
if (name === '$layout') {
layout = file;
} else if (name === '$index') {
children.push({
path: '',
file,
children: [],
});
} else {
throw new Error('Symbol "$" is reserved for special files; only "$layout" and "$index" are allowed');
}

Check warning on line 52 in packages/ts/hilla-file-router/src/collectRoutes.ts

View check run for this annotation

Codecov / codecov/patch

packages/ts/hilla-file-router/src/collectRoutes.ts#L51-L52

Added lines #L51 - L52 were not covered by tests
} else if (!name.startsWith('_')) {
children.push({
path: name,
file,
children: [],
});
}
}
}

return {
path,
layout,
children: children.sort(({ path: a }, { path: b }) => collator.compare(cleanUp(a), cleanUp(b))),
};
}
74 changes: 74 additions & 0 deletions packages/ts/hilla-file-router/src/generateJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { readFile } from 'node:fs/promises';
import { Script } from 'node:vm';
import ts, { type Node } from 'typescript';
import type { RouteMeta } from './collectRoutes.js';
import { prepareConfig, processPattern, type ViewConfig } from './utils.js';

function* traverse(
views: RouteMeta,
parents: readonly RouteMeta[] = [],
): Generator<readonly RouteMeta[], undefined, undefined> {
const chain = [...parents, views];

if (views.children.length === 0) {
yield chain;
}

for (const child of views.children) {
yield* traverse(child, chain);
}
}

type RouteModule = Readonly<{
default: unknown;
config?: ViewConfig;
}>;

function* walkAST(node: Node): Generator<Node> {
yield node;

for (const child of node.getChildren()) {
yield* walkAST(child);
}
}

export default async function generateJson(views: RouteMeta, configExportName: string): Promise<string> {
Lodin marked this conversation as resolved.
Show resolved Hide resolved
const res = await Promise.all(
Array.from(traverse(views), async (branch) => {
const configs = await Promise.all(
branch
.filter(({ file, layout }) => !!file || !!layout)
.map(({ file, layout }) => file ?? layout!)
.map(async (path) => {
const file = ts.createSourceFile('f.ts', await readFile(path, 'utf8'), ts.ScriptTarget.ESNext, true);
let config: ViewConfig | undefined;
let waitingForIdentifier = false;
let componentName: string | undefined;

for (const node of walkAST(file)) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === configExportName) {
if (node.initializer && ts.isObjectLiteralExpression(node.initializer)) {
const code = node.initializer.getText(file);
const script = new Script(`(${code})`);
config = script.runInThisContext() as ViewConfig;
}
} else if (node.getText(file).includes('export default')) {
waitingForIdentifier = true;
} else if (waitingForIdentifier && ts.isIdentifier(node)) {
componentName = node.text;
}
}

return prepareConfig(config, componentName);
}),
);

const key = branch.map(({ path }) => processPattern(path)).join('/');
const value = configs[configs.length - 1];

return [key, value] satisfies readonly [string, ViewConfig | undefined];
}),
);

return JSON.stringify(Object.fromEntries(res));
}
94 changes: 94 additions & 0 deletions packages/ts/hilla-file-router/src/generateRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { extname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { template, transform as transformer } from '@vaadin/hilla-generator-utils/ast.js';
import createSourceFile from '@vaadin/hilla-generator-utils/createSourceFile.js';
import ts, {
type ImportDeclaration,
type ObjectLiteralExpression,
type StringLiteral,
type VariableStatement,
} from 'typescript';
import type { RouteMeta } from './collectRoutes.js';
import { processPattern, transformRoute } from './utils.js';

const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

function relativize(url: URL, generatedDir: URL): string {
const result = relative(fileURLToPath(generatedDir), fileURLToPath(url));

if (!result.startsWith('.')) {
return `./${result}`;
}

Check warning on line 21 in packages/ts/hilla-file-router/src/generateRoutes.ts

View check run for this annotation

Codecov / codecov/patch

packages/ts/hilla-file-router/src/generateRoutes.ts#L20-L21

Added lines #L20 - L21 were not covered by tests

return result;
}

function createImport(mod: string, file: string): ImportDeclaration {
const path = `${file.substring(0, file.lastIndexOf('.'))}.js`;
return template(`import * as ${mod} from '${path}';\n`, ([statement]) => statement as ts.ImportDeclaration);
}

function createRouteData(
path: string,
mod: string | undefined,
children: readonly ObjectLiteralExpression[],
): ObjectLiteralExpression {
return template(
`const route = {
path: '${path}',
${mod ? `module: ${mod}` : ''}
${children.length > 0 ? `children: CHILDREN,` : ''}
}`,
([statement]) =>
(statement as VariableStatement).declarationList.declarations[0].initializer as ObjectLiteralExpression,
[
transformer((node) =>
ts.isIdentifier(node) && node.text === 'CHILDREN' ? ts.factory.createArrayLiteralExpression(children) : node,
),
],
);
}

export default function generateRoutes(views: RouteMeta, generatedDir: URL): string {
const imports: ImportDeclaration[] = [];
let id = 0;

const routes = transformRoute<RouteMeta, ObjectLiteralExpression>(
views,
(view) => view.children.values(),
({ file, layout, path }, children) => {
const currentId = id;
id += 1;

let mod: string | undefined;
if (file) {
mod = `Page${currentId}`;
imports.push(createImport(mod, relativize(file, generatedDir)));
} else if (layout) {
mod = `Layout${currentId}`;
imports.push(createImport(mod, relativize(layout, generatedDir)));
}

return createRouteData(processPattern(path), mod, children);
},
);

const routeDeclaration = template(
`import a from 'IMPORTS';

const routes = ROUTE;

export default routes;
`,
[
transformer((node) =>
ts.isImportDeclaration(node) && (node.moduleSpecifier as StringLiteral).text === 'IMPORTS' ? imports : node,
),
transformer((node) => (ts.isIdentifier(node) && node.text === 'ROUTE' ? routes : node)),
],
);

const file = createSourceFile(routeDeclaration, 'views.ts');

return printer.printFile(file);
Lodin marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading