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

fix(file-router): get back Flow AutoLayout #2931

Merged
merged 7 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 19 additions & 1 deletion packages/ts/file-router/src/runtime/createRoute.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
import type { AgnosticRoute, Module } from '../types.js';
import type { AgnosticRoute, Module, ViewConfig } from '../types.js';

/**
* Extends a router module's config with additional properties. The original
* module config is preferred over the extension.
*
* @param module - The module to extend.
* @param config - The extension config.
* @returns
*/
export function extendModule(module: Module, config?: ViewConfig): Module {
return {
...module,
config: {
...config,
...(module.config as ViewConfig),
},
};
}

/**
* Create a single framework-agnostic route object. Later, it can be transformed into a framework-specific route object,
Expand Down
25 changes: 10 additions & 15 deletions packages/ts/file-router/src/vite-plugin/applyLayouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,10 @@ function stripLeadingSlash(path: string) {
}

function enableFlowLayout(route: RouteMeta): RouteMeta {
const routeWithFlowLayout = {
return {
...route,
flowLayout: true,
};
return route.children
? {
...routeWithFlowLayout,
children: route.children.map(enableFlowLayout),
}
: routeWithFlowLayout;
}

/**
Expand Down Expand Up @@ -65,14 +59,15 @@ export default async function applyLayouts(
routeMeta: readonly RouteMeta[],
layoutsFile: URL,
): Promise<readonly RouteMeta[]> {
if (!existsSync(layoutsFile)) {
try {
const layoutContents = await readFile(layoutsFile, 'utf-8');
const availableLayouts: readonly LayoutMeta[] = JSON.parse(layoutContents);
const layoutPaths = availableLayouts.map((layout) => stripLeadingSlash(layout.path));

return routeMeta.map((route) =>
layoutExists(layoutPaths, stripLeadingSlash(route.path)) ? enableFlowLayout(route) : route,
);
} catch (e: unknown) {
return routeMeta;
}
const layoutContents = await readFile(layoutsFile, 'utf-8');
const availableLayouts: readonly LayoutMeta[] = JSON.parse(layoutContents);
const layoutPaths = availableLayouts.map((layout) => stripLeadingSlash(layout.path));

return routeMeta.map((route) =>
layoutExists(layoutPaths, stripLeadingSlash(route.path)) ? enableFlowLayout(route) : route,
);
}
245 changes: 124 additions & 121 deletions packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ import { relative, sep } 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 CallExpression,
type ImportDeclaration,
type StringLiteral,
type VariableStatement,
} from 'typescript';
import DependencyManager from '@vaadin/hilla-generator-utils/dependencies/DependencyManager.js';
import PathManager from '@vaadin/hilla-generator-utils/dependencies/PathManager.js';
import ts, { type CallExpression, type Identifier, type StringLiteral, type VariableStatement } from 'typescript';

import { transformTree } from '../shared/transformTree.js';
import type { RouteMeta } from './collectRoutesFromFS.js';
Expand All @@ -16,132 +13,138 @@ import { convertFSRouteSegmentToURLPatternFormat } from './utils.js';

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

/**
* Convert a file URL to a relative path from the generated directory.
*
* @param url - The file URL to convert.
* @param generatedDir - The directory where the generated view file will be stored.
*/
function relativize(url: URL, generatedDir: URL): string {
const result = relative(fileURLToPath(generatedDir), fileURLToPath(url)).replaceAll(sep, '/');

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

return result;
}
const extensions = ['.ts', '.tsx', '.js', '.jsx'];

/**
* Create an import declaration for a `views` module.
*
* @param mod - The name of the route module to import.
* @param file - The file path of the module.
*/
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);
}

/**
* Create an abstract route creation function call. The nested function calls create a route tree.
*
* @param path - The path of the route.
* @param mod - The name of the route module imported as a namespace.
* @param children - The list of child route call expressions.
*/
function createRouteData(path: string, mod: string | undefined, children?: readonly CallExpression[]): CallExpression {
return template(
`const route = createRoute("${path}", ${mod ? `, ${mod}` : ''}${children ? `, CHILDREN` : ''})`,
([statement]) => (statement as VariableStatement).declarationList.declarations[0].initializer as CallExpression,
[
transformer((node) =>
ts.isIdentifier(node) && node.text === 'CHILDREN'
? ts.factory.createArrayLiteralExpression(children, true)
: node,
),
],
);
}
class RouteFromMetaProcessor {
readonly #manager: DependencyManager;
readonly #views: readonly RouteMeta[];

/**
* Loads all the files from the received metadata and creates a framework-agnostic route tree.
*
* @param views - The abstract route tree.
* @param generatedDir - The directory where the generated view file will be stored.
*/
export default function createRoutesFromMeta(views: readonly RouteMeta[], { code: codeFile }: RuntimeFileUrls): string {
const codeDir = new URL('./', codeFile);
const imports: ImportDeclaration[] = [];
const errors: string[] = [];
let id = 0;

const routes = transformTree<readonly RouteMeta[], readonly CallExpression[]>(views, (metas, next) => {
errors.push(
...metas
.map((route) => route.path)
.filter((item, index, arr) => arr.indexOf(item) !== index)
.map((dup) => `console.error("Two views share the same path: ${dup}");`),
);
constructor(views: readonly RouteMeta[], { code: codeFile }: RuntimeFileUrls) {
this.#views = views;

return metas.map(({ file, layout, path, children, flowLayout }) => {
let _children: readonly CallExpression[] | undefined;
const codeDir = new URL('./', codeFile);
this.#manager = new DependencyManager(new PathManager({ extension: '.js', relativeTo: codeDir }));
}

if (children) {
_children = next(...children);
}
/**
* Loads all the files from the received metadata and creates a framework-agnostic route tree.
*
* @param views - The abstract route tree.
* @param generatedDir - The directory where the generated view file will be stored.
*/
process(): string {
const {
paths,
imports: { named, namespace },
} = this.#manager;
const errors: string[] = [];

const routes = transformTree<readonly RouteMeta[], readonly CallExpression[]>(this.#views, (metas, next) => {
errors.push(
...metas
.map((route) => route.path)
.filter((item, index, arr) => arr.indexOf(item) !== index)
.map((dup) => `console.error("Two views share the same path: ${dup}");`),
);

return metas.map(({ file, layout, path, children, flowLayout }) => {
let _children: readonly CallExpression[] | undefined;

if (children) {
_children = next(...children);
}

let mod: Identifier | undefined;
if (file) {
const extension = extensions.find((ext) => file.pathname.endsWith(ext));
mod = namespace.add(paths.createRelativePath(file, extension), `Page`);
} else if (layout) {
const extension = extensions.find((ext) => layout.pathname.endsWith(ext));
mod = namespace.add(paths.createRelativePath(layout, extension), `Layout`);
}

const extension = flowLayout ? { flowLayout } : undefined;

return this.#createRouteData(convertFSRouteSegmentToURLPatternFormat(path), mod, extension, _children);
});
});

const currentId = id;
id += 1;
const agnosticRouteId =
named.getIdentifier('@vaadin/hilla-file-router/types.js', 'AgnosticRoute') ??
named.add('@vaadin/hilla-file-router/types.js', 'AgnosticRoute', true);

let mod: string | undefined;
if (file) {
mod = `Page${currentId}`;
imports.push(createImport(mod, relativize(file, codeDir)));
} else if (layout) {
mod = `Layout${currentId}`;
imports.push(createImport(mod, relativize(layout, codeDir)));
}
let routeDeclaration = template(
`${errors.join('\n')}

return createRouteData(convertFSRouteSegmentToURLPatternFormat(path), mod, _children);
});
});
const routes: readonly AGNOSTIC_ROUTE[] = ROUTE;

// Prepend the import for `createRoute` if it was used
if (imports.length > 0) {
const createRouteImport = template(
'import { createRoute } from "@vaadin/hilla-file-router/runtime.js";',
([statement]) => statement as ts.ImportDeclaration,
export default routes;
`,
[
transformer((node) =>
ts.isIdentifier(node) && node.text === 'ROUTE' ? ts.factory.createArrayLiteralExpression(routes, true) : node,
),
transformer((node) => (ts.isIdentifier(node) && node.text === 'AGNOSTIC_ROUTE' ? agnosticRouteId : node)),
],
);
imports.unshift(createRouteImport);
}

imports.unshift(
template(
'import type { AgnosticRoute } from "@vaadin/hilla-file-router/types.js";',
([statement]) => statement as ts.ImportDeclaration,
),
);
routeDeclaration = [...this.#manager.imports.toCode(), ...routeDeclaration];

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

${errors.join('\n')}
const file = createSourceFile(routeDeclaration, 'file-routes.ts');
return printer.printFile(file);
}

const routes: readonly AgnosticRoute[] = ROUTE;
/**
* Create an abstract route creation function call. The nested function calls
* create a route tree.
*
* @param path - The path of the route.
* @param mod - The name of the route module imported as a namespace.
* @param children - The list of child route call expressions.
*/
#createRouteData(
path: string,
mod: Identifier | undefined,
extension?: Readonly<Record<string, unknown>>,
children?: readonly CallExpression[],
): CallExpression {
const { named } = this.#manager.imports;

let extendModuleId: Identifier | undefined;
let modNode = '';

if (mod) {
if (extension) {
extendModuleId =
named.getIdentifier('@vaadin/hilla-file-router/runtime.js', 'extendModule') ??
named.add('@vaadin/hilla-file-router/runtime.js', 'extendModule');
modNode = `, EXTEND_MODULE(MOD, ${JSON.stringify(extension)})`;
} else {
modNode = `, MOD`;
}
}

const createRouteId =
named.getIdentifier('@vaadin/hilla-file-router/runtime.js', 'createRoute') ??
named.add('@vaadin/hilla-file-router/runtime.js', 'createRoute');

return template(
`const route = CREATE_ROUTE("${path}", ${modNode}${children ? `, CHILDREN` : ''})`,
([statement]) => (statement as VariableStatement).declarationList.declarations[0].initializer as CallExpression,
[
transformer((node) =>
ts.isIdentifier(node) && node.text === 'CHILDREN'
? ts.factory.createArrayLiteralExpression(children, true)
: node,
),
transformer((node) => (ts.isIdentifier(node) && node.text === 'MOD' ? mod : node)),
transformer((node) => (ts.isIdentifier(node) && node.text === 'EXTEND_MODULE' ? extendModuleId : node)),
transformer((node) => (ts.isIdentifier(node) && node.text === 'CREATE_ROUTE' ? createRouteId : node)),
],
);
}
}

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

const file = createSourceFile(routeDeclaration, 'file-routes.ts');
return printer.printFile(file);
export default function createRoutesFromMeta(views: readonly RouteMeta[], urls: RuntimeFileUrls): string {
return new RouteFromMetaProcessor(views, urls).process();
}
49 changes: 49 additions & 0 deletions packages/ts/file-router/test/runtime/createRoute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, use } from '@esm-bundle/chai';
import chaiLike from 'chai-like';
import { createRoute, extendModule } from '../../src/runtime/createRoute.js';

use(chaiLike);

describe('@vaadin/hilla-file-router', () => {
describe('createRoute', () => {
it('should create a route with a module', () => {
const route = createRoute('/path', { default: 'module' });

expect(route).to.be.like({
path: '/path',
module: { default: 'module' },
});
});

it('should create a route with children', () => {
const route = createRoute('/path', [createRoute('/child1'), createRoute('/child2')]);

expect(route).to.be.like({
path: '/path',
children: [{ path: '/child1' }, { path: '/child2' }],
});
});
});

describe('extendModule', () => {
it('should extend a module', () => {
const module = { default: 'module' };
const extendedModule = extendModule(module, { flowLayout: true });

expect(extendedModule).to.be.like({
default: 'module',
config: { flowLayout: true },
});
});

it('should prefer the original module config over the extension', () => {
const module = { default: 'module', config: { flowLayout: false } };
const extendedModule = extendModule(module, { flowLayout: true });

expect(extendedModule).to.be.like({
default: 'module',
config: { flowLayout: false },
});
});
});
});
Loading