diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 0663204749..aefaa7b0b5 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import { extname, normalize, relative, resolve } from 'pathe' import { encodePath } from 'ufo' import { resolveFiles, useNuxt } from '@nuxt/kit' @@ -5,6 +6,9 @@ import { genArrayFromRaw, genDynamicImport, genImport, genSafeVariableName } fro import escapeRE from 'escape-string-regexp' import { filename } from 'pathe/utils' import { hash } from 'ohash' +import { transform } from 'esbuild' +import { parse } from 'acorn' +import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree' import type { NuxtPage } from 'nuxt/schema' import { uniqueBy } from '../core/utils' @@ -41,14 +45,14 @@ export async function resolvePagesRoutes (): Promise { const files = await resolveFiles(dir, `**/*{${nuxt.options.extensions.join(',')}}`) // Sort to make sure parent are listed first files.sort() - return generateRoutesFromFiles(files, dir) + return generateRoutesFromFiles(files, dir, nuxt.options.experimental.typedPages, nuxt.vfs) }) )).flat() return uniqueBy(allRoutes, 'path') } -export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] { +export async function generateRoutesFromFiles (files: string[], pagesDir: string, shouldExtractBuildMeta = false, vfs?: Record): Promise { const routes: NuxtPage[] = [] for (const file of files) { @@ -88,12 +92,54 @@ export function generateRoutesFromFiles (files: string[], pagesDir: string): Nux } } + if (shouldExtractBuildMeta && vfs) { + const fileContent = file in vfs ? vfs[file] : fs.readFileSync(resolve(pagesDir, file), 'utf-8') + const overrideRouteName = await getRouteName(fileContent) + if (overrideRouteName) { + route.name = overrideRouteName + } + } + parent.push(route) } return prepareRoutes(routes) } +const SFC_SCRIPT_RE = /]*>([\s\S]*?)<\/script\s*[^>]*>/i +function extractScriptContent (html: string) { + const match = html.match(SFC_SCRIPT_RE) + + if (match && match[1]) { + return match[1].trim() + } + + return null +} + +const PAGE_META_RE = /(definePageMeta\([\s\S]*?\))/ + +async function getRouteName (file: string) { + const script = extractScriptContent(file) + if (!script) { return null } + + if (!PAGE_META_RE.test(script)) { return null } + + const js = await transform(script, { loader: 'ts' }) + const ast = parse(js.code, { + sourceType: 'module', + ecmaVersion: 'latest' + }) as unknown as Program + const pageMetaAST = ast.body.find(node => node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && node.expression.callee.name === 'definePageMeta') + if (!pageMetaAST) { return null } + + const pageMetaArgument = ((pageMetaAST as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression + const nameProperty = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === 'name') as Property + if (!nameProperty || nameProperty.value.type !== 'Literal' || typeof nameProperty.value.value !== 'string') { return null } + + return nameProperty.value.value +} + function getRoutePath (tokens: SegmentToken[]): string { return tokens.reduce((path, token) => { return ( diff --git a/packages/nuxt/test/pages.test.ts b/packages/nuxt/test/pages.test.ts index b13c56a351..0a98fc4425 100644 --- a/packages/nuxt/test/pages.test.ts +++ b/packages/nuxt/test/pages.test.ts @@ -1,16 +1,22 @@ import { describe, expect, it } from 'vitest' +import type { NuxtPage } from 'nuxt/schema' import { generateRoutesFromFiles } from '../src/pages/utils' import { generateRouteKey } from '../src/pages/runtime/utils' describe('pages:generateRoutesFromFiles', () => { const pagesDir = 'pages' - const tests = [ + const tests: Array<{ + description: string + files: Array<{ path: string; template?: string; }> + output?: NuxtPage[] + error?: string + }> = [ { description: 'should generate correct routes for index pages', files: [ - `${pagesDir}/index.vue`, - `${pagesDir}/parent/index.vue`, - `${pagesDir}/parent/child/index.vue` + { path: `${pagesDir}/index.vue` }, + { path: `${pagesDir}/parent/index.vue` }, + { path: `${pagesDir}/parent/child/index.vue` } ], output: [ { @@ -36,8 +42,8 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should generate correct routes for parent/child', files: [ - `${pagesDir}/parent.vue`, - `${pagesDir}/parent/child.vue` + { path: `${pagesDir}/parent.vue` }, + { path: `${pagesDir}/parent/child.vue` } ], output: [ { @@ -58,8 +64,8 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should not generate colliding route names when hyphens are in file name', files: [ - `${pagesDir}/parent/[child].vue`, - `${pagesDir}/parent-[child].vue` + { path: `${pagesDir}/parent/[child].vue` }, + { path: `${pagesDir}/parent-[child].vue` } ], output: [ { @@ -79,8 +85,8 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should generate correct id for catchall (order 1)', files: [ - `${pagesDir}/[...stories].vue`, - `${pagesDir}/stories/[id].vue` + { path: `${pagesDir}/[...stories].vue` }, + { path: `${pagesDir}/stories/[id].vue` } ], output: [ { @@ -100,8 +106,8 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should generate correct id for catchall (order 2)', files: [ - `${pagesDir}/stories/[id].vue`, - `${pagesDir}/[...stories].vue` + { path: `${pagesDir}/stories/[id].vue` }, + { path: `${pagesDir}/[...stories].vue` } ], output: [ { @@ -121,7 +127,7 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should generate correct route for snake_case file', files: [ - `${pagesDir}/snake_case.vue` + { path: `${pagesDir}/snake_case.vue` } ], output: [ { @@ -134,7 +140,7 @@ describe('pages:generateRoutesFromFiles', () => { }, { description: 'should generate correct route for kebab-case file', - files: [`${pagesDir}/kebab-case.vue`], + files: [{ path: `${pagesDir}/kebab-case.vue` }], output: [ { name: 'kebab-case', @@ -147,14 +153,14 @@ describe('pages:generateRoutesFromFiles', () => { { description: 'should generate correct dynamic routes', files: [ - `${pagesDir}/index.vue`, - `${pagesDir}/[slug].vue`, - `${pagesDir}/[[foo]]`, - `${pagesDir}/[[foo]]/index.vue`, - `${pagesDir}/[bar]/index.vue`, - `${pagesDir}/nonopt/[slug].vue`, - `${pagesDir}/opt/[[slug]].vue`, - `${pagesDir}/[[sub]]/route-[slug].vue` + { path: `${pagesDir}/index.vue` }, + { path: `${pagesDir}/[slug].vue` }, + { path: `${pagesDir}/[[foo]]` }, + { path: `${pagesDir}/[[foo]]/index.vue` }, + { path: `${pagesDir}/[bar]/index.vue` }, + { path: `${pagesDir}/nonopt/[slug].vue` }, + { path: `${pagesDir}/opt/[[slug]].vue` }, + { path: `${pagesDir}/[[sub]]/route-[slug].vue` } ], output: [ { @@ -210,7 +216,7 @@ describe('pages:generateRoutesFromFiles', () => { }, { description: 'should generate correct catch-all route', - files: [`${pagesDir}/[...slug].vue`, `${pagesDir}/index.vue`], + files: [{ path: `${pagesDir}/[...slug].vue` }, { path: `${pagesDir}/index.vue` }], output: [ { name: 'slug', @@ -228,24 +234,24 @@ describe('pages:generateRoutesFromFiles', () => { }, { description: 'should throw unfinished param error for dynamic route', - files: [`${pagesDir}/[slug.vue`], + files: [{ path: `${pagesDir}/[slug.vue` }], error: 'Unfinished param "slug"' }, { description: 'should throw empty param error for dynamic route', files: [ - `${pagesDir}/[].vue` + { path: `${pagesDir}/[].vue` } ], error: 'Empty param' }, { description: 'should only allow "_" & "." as special character for dynamic route', files: [ - `${pagesDir}/[a1_1a].vue`, - `${pagesDir}/[b2.2b].vue`, - `${pagesDir}/[b2]_[2b].vue`, - `${pagesDir}/[[c3@3c]].vue`, - `${pagesDir}/[[d4-4d]].vue` + { path: `${pagesDir}/[a1_1a].vue` }, + { path: `${pagesDir}/[b2.2b].vue` }, + { path: `${pagesDir}/[b2]_[2b].vue` }, + { path: `${pagesDir}/[[c3@3c]].vue` }, + { path: `${pagesDir}/[[d4-4d]].vue` } ], output: [ { @@ -280,10 +286,33 @@ describe('pages:generateRoutesFromFiles', () => { } ] }, + { + description: 'should properly override route name if definePageMeta name override is defined.', + files: [ + { + path: `${pagesDir}/index.vue`, + template: ` + + ` + } + ], + output: [ + { + name: 'home', + path: '/', + file: `${pagesDir}/index.vue`, + children: [] + } + ] + }, { description: 'should allow pages with `:` in their path', files: [ - `${pagesDir}/test:name.vue` + { path: `${pagesDir}/test:name.vue` } ], output: [ { @@ -298,10 +327,15 @@ describe('pages:generateRoutesFromFiles', () => { for (const test of tests) { it(test.description, async () => { - if (test.error) { - expect(() => generateRoutesFromFiles(test.files, pagesDir)).to.throws(test.error) - } else { - expect(await generateRoutesFromFiles(test.files, pagesDir)).to.deep.equal(test.output) + const vfs = Object.fromEntries( + test.files.map(file => [file.path, 'template' in file ? file.template : '']) + ) as Record + + try { + const result = await generateRoutesFromFiles(test.files.map(file => file.path), pagesDir, true, vfs) + expect(result).to.deep.equal(test.output) + } catch (error: any) { + expect(error.message).toEqual(test.error) } }) } diff --git a/test/fixtures/basic-types/pages/custom-name.vue b/test/fixtures/basic-types/pages/custom-name.vue new file mode 100644 index 0000000000..5a233d30e2 --- /dev/null +++ b/test/fixtures/basic-types/pages/custom-name.vue @@ -0,0 +1,15 @@ + + + diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts index eec9fedfbe..e28585cedc 100644 --- a/test/fixtures/basic-types/types.ts +++ b/test/fixtures/basic-types/types.ts @@ -130,6 +130,11 @@ describe('typed router integration', () => { router.push({ name: 'param-id', params: { id: 4 } }) }) + it('correctly reads custom names typed in `definePageMeta`', () => { + const router = useRouter() + router.push({ name: 'some-custom-name' }) + }) + it('allows typing useRoute', () => { const route = useRoute('param-id') // @ts-expect-error this param does not exist