diff --git a/packages/ts/file-router/src/runtime/createRoute.ts b/packages/ts/file-router/src/runtime/createRoute.ts index d2d5ebf096..3400e6afed 100644 --- a/packages/ts/file-router/src/runtime/createRoute.ts +++ b/packages/ts/file-router/src/runtime/createRoute.ts @@ -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, diff --git a/packages/ts/file-router/src/vite-plugin/applyLayouts.ts b/packages/ts/file-router/src/vite-plugin/applyLayouts.ts index e3ab742e84..fb301c8734 100644 --- a/packages/ts/file-router/src/vite-plugin/applyLayouts.ts +++ b/packages/ts/file-router/src/vite-plugin/applyLayouts.ts @@ -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; } /** @@ -65,14 +59,15 @@ export default async function applyLayouts( routeMeta: readonly RouteMeta[], layoutsFile: URL, ): Promise { - 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, - ); } diff --git a/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts b/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts index 7b98abe733..1c2a450cd1 100644 --- a/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts +++ b/packages/ts/file-router/src/vite-plugin/createRoutesFromMeta.ts @@ -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'; @@ -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(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(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>, + 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(); } diff --git a/packages/ts/file-router/test/runtime/createRoute.spec.ts b/packages/ts/file-router/test/runtime/createRoute.spec.ts new file mode 100644 index 0000000000..41f1185343 --- /dev/null +++ b/packages/ts/file-router/test/runtime/createRoute.spec.ts @@ -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 }, + }); + }); + }); +}); diff --git a/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts b/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts index f477f3ccab..06e5756be2 100644 --- a/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts +++ b/packages/ts/file-router/test/vite-plugin/createRoutesFromMeta.spec.ts @@ -24,49 +24,60 @@ describe('@vaadin/hilla-file-router', () => { }); it('should generate a framework-agnostic tree of routes', () => { + meta = [ + ...meta, + { + path: 'issue-2928-flow-auto-layout', + file: new URL('test/issue-2928-flow-auto-layout.tsx', dir), + flowLayout: true, + }, + ]; const generated = createRoutesFromMeta(meta, runtimeUrls); - expect(generated).to.equal(`import type { AgnosticRoute } from "@vaadin/hilla-file-router/types.js"; -import { createRoute } from "@vaadin/hilla-file-router/runtime.js"; -import * as Page0 from "../views/nameToReplace.js"; -import * as Page1 from "../views/profile/@index.js"; -import * as Page2 from "../views/profile/account/security/password.js"; -import * as Page3 from "../views/profile/account/security/two-factor-auth.js"; -import * as Layout5 from "../views/profile/account/@layout.js"; -import * as Page6 from "../views/profile/friends/list.js"; -import * as Page7 from "../views/profile/friends/{user}.js"; -import * as Layout8 from "../views/profile/friends/@layout.js"; -import * as Page10 from "../views/test/{{optional}}.js"; -import * as Page11 from "../views/test/{...wildcard}.js"; -import * as Page12 from "../views/test/issue-002378/{requiredParam}/edit.js"; -import * as Layout15 from "../views/test/issue-002571-empty-layout/@layout.js"; -import * as Page16 from "../views/test/issue-002879-config-below.js"; -const routes: readonly AgnosticRoute[] = [ - createRoute("nameToReplace", Page0), - createRoute("profile", [ - createRoute("", Page1), - createRoute("account", Layout5, [ - createRoute("security", [ - createRoute("password", Page2), - createRoute("two-factor-auth", Page3) + expect(generated).to + .equal(`import { createRoute as createRoute_1, extendModule as extendModule_1 } from "@vaadin/hilla-file-router/runtime.js"; +import type { AgnosticRoute as AgnosticRoute_1 } from "@vaadin/hilla-file-router/types.js"; +import * as Page_1 from "../test/issue-2928-flow-auto-layout.js"; +import * as Page_2 from "../views/nameToReplace.js"; +import * as Page_3 from "../views/profile/@index.js"; +import * as Layout_1 from "../views/profile/account/@layout.js"; +import * as Page_4 from "../views/profile/account/security/password.js"; +import * as Page_5 from "../views/profile/account/security/two-factor-auth.js"; +import * as Page_6 from "../views/profile/friends/{user}.js"; +import * as Layout_2 from "../views/profile/friends/@layout.js"; +import * as Page_7 from "../views/profile/friends/list.js"; +import * as Page_8 from "../views/test/{...wildcard}.js"; +import * as Page_9 from "../views/test/{{optional}}.js"; +import * as Page_10 from "../views/test/issue-002378/{requiredParam}/edit.js"; +import * as Layout_3 from "../views/test/issue-002571-empty-layout/@layout.js"; +import * as Page_11 from "../views/test/issue-002879-config-below.js"; +const routes: readonly AgnosticRoute_1[] = [ + createRoute_1("nameToReplace", Page_2), + createRoute_1("profile", [ + createRoute_1("", Page_3), + createRoute_1("account", Layout_1, [ + createRoute_1("security", [ + createRoute_1("password", Page_4), + createRoute_1("two-factor-auth", Page_5) ]) ]), - createRoute("friends", Layout8, [ - createRoute("list", Page6), - createRoute(":user", Page7) + createRoute_1("friends", Layout_2, [ + createRoute_1("list", Page_7), + createRoute_1(":user", Page_6) ]) ]), - createRoute("test", [ - createRoute(":optional?", Page10), - createRoute("*", Page11), - createRoute("issue-002378", [ - createRoute(":requiredParam", [ - createRoute("edit", Page12) + createRoute_1("test", [ + createRoute_1(":optional?", Page_9), + createRoute_1("*", Page_8), + createRoute_1("issue-002378", [ + createRoute_1(":requiredParam", [ + createRoute_1("edit", Page_10) ]) ]), - createRoute("issue-002571-empty-layout", Layout15, []), - createRoute("issue-002879-config-below", Page16) - ]) + createRoute_1("issue-002571-empty-layout", Layout_3, []), + createRoute_1("issue-002879-config-below", Page_11) + ]), + createRoute_1("issue-2928-flow-auto-layout", extendModule_1(Page_1, { "flowLayout": true })) ]; export default routes; `); @@ -75,8 +86,9 @@ export default routes; it('should generate an empty list when no routes are found', () => { const generated = createRoutesFromMeta([], runtimeUrls); - expect(generated).to.equal(`import type { AgnosticRoute } from "@vaadin/hilla-file-router/types.js"; -const routes: readonly AgnosticRoute[] = []; + expect(generated).to + .equal(`import type { AgnosticRoute as AgnosticRoute_1 } from "@vaadin/hilla-file-router/types.js"; +const routes: readonly AgnosticRoute_1[] = []; export default routes; `); }); diff --git a/packages/ts/generator-utils/src/dependencies/ExportManager.ts b/packages/ts/generator-utils/src/dependencies/ExportManager.ts index faa724023f..47b594d4a6 100644 --- a/packages/ts/generator-utils/src/dependencies/ExportManager.ts +++ b/packages/ts/generator-utils/src/dependencies/ExportManager.ts @@ -8,6 +8,10 @@ export class NamedExportManager implements CodeConvertable(); + get size(): number { + return this.#map.size; + } + constructor(collator: Intl.Collator) { this.#collator = collator; } @@ -62,6 +66,10 @@ export class NamedExportManager implements CodeConvertable { readonly #map = new Map(); + get size(): number { + return this.#map.size; + } + addCombined(path: string, name: string, uniqueId?: Identifier): Identifier { const id = uniqueId ?? createFullyUniqueIdentifier(name); this.#map.set(path, id); @@ -114,6 +122,10 @@ export class NamespaceExportManager extends StatementRecordManager { #id?: Identifier; + get isEmpty(): boolean { + return !this.#id; + } + set(id: Identifier | string): Identifier { this.#id = typeof id === 'string' ? ts.factory.createIdentifier(id) : id; return this.#id; @@ -129,6 +141,10 @@ export default class ExportManager implements CodeConvertable specifiers.get(name)!.isType); + yield [ path, ts.factory.createImportDeclaration( undefined, ts.factory.createImportClause( - false, + isTypeOnly, undefined, ts.factory.createNamedImports( names.map((name) => { const { id, isType } = specifiers.get(name)!; - return ts.factory.createImportSpecifier(isType, ts.factory.createIdentifier(name), id); + return ts.factory.createImportSpecifier( + isTypeOnly ? false : isType, + ts.factory.createIdentifier(name), + id, + ); }), ), ), @@ -103,6 +113,10 @@ export class NamedImportManager extends StatementRecordManager { readonly #map = new Map(); + get size(): number { + return this.#map.size; + } + add(path: string, name: string, uniqueId?: Identifier): Identifier { const id = uniqueId ?? createFullyUniqueIdentifier(name); this.#map.set(path, id); @@ -152,6 +166,10 @@ export class NamespaceImportManager extends StatementRecordManager { readonly #map = new Map(); + get size(): number { + return this.#map.size; + } + add(path: string, name: string, isType?: boolean, uniqueId?: Identifier): Identifier { const id = uniqueId ?? createFullyUniqueIdentifier(name); this.#map.set(path, createDependencyRecord(id, isType)); @@ -218,6 +236,10 @@ export default class ImportManager implements CodeConvertable; export default class PathManager { @@ -38,15 +39,19 @@ export default class PathManager { return path; } - createRelativePath(path: string, relativeTo = this.#options.relativeTo): string { + createRelativePath(path: URL | string, fileExtension?: string, relativeTo = this.#options.relativeTo): string { const { extension } = this.#options; - let result = path; + const _path = path instanceof URL ? fileURLToPath(path) : path; + let result = _path; - if (extension && !path.endsWith(extension)) { - result = `${result}${extension}`; + if (extension && !_path.endsWith(extension)) { + result = `${dirname(result)}/${basename(result, fileExtension)}${extension}`; } - result = posix.relative(relativeTo, result); + result = relative(relativeTo instanceof URL ? fileURLToPath(relativeTo) : relativeTo, result).replaceAll( + sep, + posix.sep, + ); return result.startsWith('.') ? result : `./${result}`; }