From 407fde6765f9852b759bebb062f19a6ffb8947a4 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 29 Jan 2024 16:44:54 +0000 Subject: [PATCH] feat(nuxt): experimentally extract route metadata at build time (#25210) Co-authored-by: Bobbie Goede --- .../1.experimental-features.md | 6 + packages/nuxt/src/pages/module.ts | 14 +- packages/nuxt/src/pages/utils.ts | 202 ++++++-- .../pages-override-meta-disabled.test.ts.snap | 441 ++++++++++++++++++ .../pages-override-meta-enabled.test.ts.snap | 303 ++++++++++++ packages/nuxt/test/pages.test.ts | 129 ++++- packages/schema/src/config/experimental.ts | 9 + 7 files changed, 1049 insertions(+), 55 deletions(-) create mode 100644 packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap create mode 100644 packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md index 1410fb0b84..cc542c7e2e 100644 --- a/docs/2.guide/3.going-further/1.experimental-features.md +++ b/docs/2.guide/3.going-further/1.experimental-features.md @@ -375,6 +375,12 @@ globalThis.Buffer = globalThis.Buffer || Buffer ``` :: +## scanPageMeta + +This option allows exposing some route metadata defined in `definePageMeta` at build-time to modules (specifically `alias`, `name`, `path`, `redirect`). + +This only works with static or strings/arrays rather than variables or conditional assignment. See [original issue](https://github.com/nuxt/nuxt/issues/24770) for more information and context. + ## cookieStore Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index bf4375113b..f962fcde45 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -216,11 +216,17 @@ export default defineNuxtModule({ ] }) + function isPage (file: string, pages = nuxt.apps.default.pages): boolean { + if (!pages) { return false } + return pages.some(page => page.file === file) || pages.some(page => page.children && isPage(file, page.children)) + } nuxt.hook('builder:watch', async (event, relativePath) => { - if (event === 'change') { return } - const path = resolve(nuxt.options.srcDir, relativePath) - if (updateTemplatePaths.some(dir => path.startsWith(dir))) { + const shouldAlwaysRegenerate = nuxt.options.experimental.scanPageMeta && isPage(path) + + if (event === 'change' && !shouldAlwaysRegenerate) { return } + + if (shouldAlwaysRegenerate || updateTemplatePaths.some(dir => path.startsWith(dir))) { await updateTemplates({ filter: template => template.filename === 'routes.mjs' }) @@ -398,7 +404,7 @@ export default defineNuxtModule({ filename: 'routes.mjs', getContents ({ app }) { if (!app.pages) return 'export default []' - const { routes, imports } = normalizeRoutes(app.pages) + const { routes, imports } = normalizeRoutes(app.pages, new Set(), nuxt.options.experimental.scanPageMeta) return [...imports, `export default ${routes}`].join('\n') } }) diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 559723be71..53d4d80238 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import fs from 'node:fs' import { extname, normalize, relative, resolve } from 'pathe' import { encodePath, joinURL, withLeadingSlash } from 'ufo' @@ -55,12 +56,20 @@ export async function resolvePagesRoutes (): Promise { // sort scanned files using en-US locale to make the result consistent across different system locales scannedFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'en-US')) - const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), nuxt.options.experimental.typedPages, nuxt.vfs) + const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), { + shouldExtractBuildMeta: nuxt.options.experimental.scanPageMeta || nuxt.options.experimental.typedPages, + vfs: nuxt.vfs + }) return uniqueBy(allRoutes, 'path') } -export async function generateRoutesFromFiles (files: ScannedFile[], shouldExtractBuildMeta = false, vfs?: Record): Promise { +type GenerateRoutesFromFilesOptions = { + shouldExtractBuildMeta?: boolean + vfs?: Record +} + +export async function generateRoutesFromFiles (files: ScannedFile[], options: GenerateRoutesFromFilesOptions = {}): Promise { const routes: NuxtPage[] = [] for (const file of files) { @@ -101,12 +110,9 @@ export async function generateRoutesFromFiles (files: ScannedFile[], shouldExtra } } - if (shouldExtractBuildMeta && vfs) { - const fileContent = file.absolutePath in vfs ? vfs[file.absolutePath] : fs.readFileSync(file.absolutePath, 'utf-8') - const overrideRouteName = await getRouteName(fileContent) - if (overrideRouteName) { - route.name = overrideRouteName - } + if (options.shouldExtractBuildMeta && options.vfs) { + const fileContent = file.absolutePath in options.vfs ? options.vfs[file.absolutePath] : fs.readFileSync(file.absolutePath, 'utf-8') + Object.assign(route, await getRouteMeta(fileContent, file.absolutePath)) } parent.push(route) @@ -127,26 +133,96 @@ export function extractScriptContent (html: string) { } const PAGE_META_RE = /(definePageMeta\([\s\S]*?\))/ +const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const -async function getRouteName (file: string) { - const script = extractScriptContent(file) - if (!script) { return null } +const metaCache: Record>> = {} +async function getRouteMeta (contents: string, absolutePath?: string): Promise>> { + if (contents in metaCache) { return metaCache[contents] } - if (!PAGE_META_RE.test(script)) { return null } + const script = extractScriptContent(contents) + if (!script) { + metaCache[contents] = {} + return {} + } + + if (!PAGE_META_RE.test(script)) { + metaCache[contents] = {} + return {} + } const js = await transform(script, { loader: 'ts' }) const ast = parse(js.code, { sourceType: 'module', - ecmaVersion: 'latest' + ecmaVersion: 'latest', + ranges: true }) 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 } + if (!pageMetaAST) { + metaCache[contents] = {} + return {} + } 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 } + const extractedMeta = {} as Partial> + const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const + const dynamicProperties = new Set() - return nameProperty.value.value + for (const key of extractionKeys) { + const property = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === key) as Property + if (!property) { continue } + + if (property.value.type === 'ObjectExpression') { + const valueString = js.code.slice(property.value.range![0], property.value.range![1]) + try { + extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {})) + } catch { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not JSON-serializable (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + } + + if (property.value.type === 'ArrayExpression') { + const values = [] + for (const element of property.value.elements) { + if (!element) { + continue + } + if (element.type !== 'Literal' || typeof element.value !== 'string') { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not an array of string literals (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + values.push(element.value) + } + extractedMeta[key] = values + continue + } + + if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + extractedMeta[key] = property.value.value + } + + const extraneousMetaKeys = pageMetaArgument.properties + .filter(property => property.type === 'Property' && property.key.type === 'Identifier' && !(extractionKeys as unknown as string[]).includes(property.key.name)) + // @ts-expect-error inferred types have been filtered out + .map(property => property.key.name) + + if (extraneousMetaKeys.length) { + dynamicProperties.add('meta') + } + + if (dynamicProperties.size) { + extractedMeta.meta ??= {} + extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties + } + + metaCache[contents] = extractedMeta + return extractedMeta } function getRoutePath (tokens: SegmentToken[]): string { @@ -301,26 +377,42 @@ function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage, names = new Set = new Set()): { imports: Set, routes: string } { +function serializeRouteValue (value: any, skipSerialisation = false) { + if (skipSerialisation || value === undefined) return undefined + return JSON.stringify(value) +} + +type NormalizedRoute = Partial, string>> & { component?: string } +type NormalizedRouteKeys = (keyof NormalizedRoute)[] +export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = new Set(), overrideMeta = false): { imports: Set, routes: string } { return { imports: metaImports, routes: genArrayFromRaw(routes.map((page) => { - const route: Record, string> & { component?: string } = Object.create(null) - for (const [key, value] of Object.entries(page)) { - if (key !== 'file' && (Array.isArray(value) ? value.length : value)) { - route[key as Exclude] = JSON.stringify(value) + const markedDynamic = page.meta?.[DYNAMIC_META_KEY] ?? new Set() + const metaFiltered: Record = {} + let skipMeta = true + for (const key in page.meta || {}) { + if (key !== DYNAMIC_META_KEY && page.meta![key] !== undefined) { + skipMeta = false + metaFiltered[key] = page.meta![key] } } + const skipAlias = toArray(page.alias).every(val => !val) + + const route: NormalizedRoute = { + path: serializeRouteValue(page.path), + name: serializeRouteValue(page.name), + meta: serializeRouteValue(metaFiltered, skipMeta), + alias: serializeRouteValue(toArray(page.alias), skipAlias), + redirect: serializeRouteValue(page.redirect), + } if (page.children?.length) { - route.children = normalizeRoutes(page.children, metaImports).routes + route.children = normalizeRoutes(page.children, metaImports, overrideMeta).routes } // Without a file, we can't use `definePageMeta` to extract route-level meta from the file if (!page.file) { - for (const key of ['name', 'path', 'meta', 'alias', 'redirect'] as const) { - if (page[key]) { route[key] = JSON.stringify(page[key]) } - } return route } @@ -328,20 +420,56 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set = const metaImportName = genSafeVariableName(filename(file) + hash(file)) + 'Meta' metaImports.add(genImport(`${file}?macro=true`, [{ name: 'default', as: metaImportName }])) - let aliasCode = `${metaImportName}?.alias || []` - const alias = toArray(page.alias).filter(Boolean) - if (alias.length) { - aliasCode = `${JSON.stringify(alias)}.concat(${aliasCode})` + const metaRoute: NormalizedRoute = { + name: `${metaImportName}?.name ?? ${route.name}`, + path: `${metaImportName}?.path ?? ${route.path}`, + meta: `${metaImportName} || {}`, + alias: `${metaImportName}?.alias || []`, + redirect: `${metaImportName}?.redirect`, + component: genDynamicImport(file, { interopDefault: true }) } - route.name = `${metaImportName}?.name ?? ${page.name ? JSON.stringify(page.name) : 'undefined'}` - route.path = `${metaImportName}?.path ?? ${JSON.stringify(page.path)}` - route.meta = page.meta && Object.values(page.meta).filter(value => value !== undefined).length ? `{...(${metaImportName} || {}), ...${JSON.stringify(page.meta)}}` : `${metaImportName} || {}` - route.alias = aliasCode - route.redirect = page.redirect ? JSON.stringify(page.redirect) : `${metaImportName}?.redirect || undefined` - route.component = genDynamicImport(file, { interopDefault: true }) + if (route.children != null) { + metaRoute.children = route.children + } - return route + if (overrideMeta) { + metaRoute.name = `${metaImportName}?.name` + metaRoute.path = `${metaImportName}?.path ?? ''` + + // skip and retain fallback if marked dynamic + // set to extracted value or fallback if none extracted + for (const key of ['name', 'path'] satisfies NormalizedRouteKeys) { + if (markedDynamic.has(key)) continue + metaRoute[key] = route[key] ?? metaRoute[key] + } + + // set to extracted value or delete if none extracted + for (const key of ['meta', 'alias', 'redirect'] satisfies NormalizedRouteKeys) { + if (markedDynamic.has(key)) continue + + if (route[key] == null) { + delete metaRoute[key] + continue + } + + metaRoute[key] = route[key] + } + } else { + if (route.meta != null) { + metaRoute.meta = `{ ...(${metaImportName}) || {}), ...${route.meta} }` + } + + if (route.alias != null) { + metaRoute.alias = `${route.alias}.concat(${metaImportName}?.alias || [])` + } + + if (route.redirect != null) { + metaRoute.redirect = route.redirect + } + } + + return metaRoute })) } } diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap new file mode 100644 index 0000000000..0960895fa4 --- /dev/null +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap @@ -0,0 +1,441 @@ +{ + "route without file": [ + { + "alias": "["sweet-home"]", + "meta": "{"hello":"world"}", + "name": ""home"", + "path": ""/"", + "redirect": undefined, + }, + ], + "should allow pages with `:` in their path": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/test:name.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "test:name"", + "path": "mockMeta?.path ?? "/test\\:name"", + "redirect": "mockMeta?.redirect", + }, + ], + "should correctly merge nested routes": [ + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/param/index/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "param-index"", + "path": "mockMeta?.path ?? """, + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "param-index-sibling"", + "path": "mockMeta?.path ?? "sibling"", + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? undefined", + "path": "mockMeta?.path ?? """, + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/param/sibling.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "param-sibling"", + "path": "mockMeta?.path ?? "sibling"", + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/param.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? undefined", + "path": "mockMeta?.path ?? "/param"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "wrapper-expose-other"", + "path": "mockMeta?.path ?? """, + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "wrapper-expose-other-sibling"", + "path": "mockMeta?.path ?? "sibling"", + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? undefined", + "path": "mockMeta?.path ?? "/wrapper-expose/other"", + "redirect": "mockMeta?.redirect", + }, + ], + "should extract serializable values and override fallback when normalized with `overrideMeta: true`": [ + { + "alias": "["sweet-home"].concat(mockMeta?.alias || [])", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "home"", + "path": "mockMeta?.path ?? "/"", + "redirect": ""/"", + }, + ], + "should generate correct catch-all route": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[...slug].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "slug"", + "path": "mockMeta?.path ?? "/:slug(.*)*"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct dynamic routes": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[slug].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "slug"", + "path": "mockMeta?.path ?? "/:slug()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "foo"", + "path": "mockMeta?.path ?? """, + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/[[foo]]").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? undefined", + "path": "mockMeta?.path ?? "/:foo?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "optional-opt"", + "path": "mockMeta?.path ?? "/optional/:opt?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "optional-prefix-opt"", + "path": "mockMeta?.path ?? "/optional/prefix-:opt?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "optional-opt-postfix"", + "path": "mockMeta?.path ?? "/optional/:opt?-postfix"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "optional-prefix-opt-postfix"", + "path": "mockMeta?.path ?? "/optional/prefix-:opt?-postfix"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "bar"", + "path": "mockMeta?.path ?? "/:bar()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "nonopt-slug"", + "path": "mockMeta?.path ?? "/nonopt/:slug()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "opt-slug"", + "path": "mockMeta?.path ?? "/opt/:slug?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "sub-route-slug"", + "path": "mockMeta?.path ?? "/:sub?/route-:slug()"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct id for catchall (order 1)": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "stories"", + "path": "mockMeta?.path ?? "/:stories(.*)*"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "stories-id"", + "path": "mockMeta?.path ?? "/stories/:id()"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct id for catchall (order 2)": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "stories-id"", + "path": "mockMeta?.path ?? "/stories/:id()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "stories"", + "path": "mockMeta?.path ?? "/:stories(.*)*"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct route for kebab-case file": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/kebab-case.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "kebab-case"", + "path": "mockMeta?.path ?? "/kebab-case"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct route for snake_case file": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/snake_case.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "snake_case"", + "path": "mockMeta?.path ?? "/snake_case"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct routes for index pages": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/parent/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent"", + "path": "mockMeta?.path ?? "/parent"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent-child"", + "path": "mockMeta?.path ?? "/parent/child"", + "redirect": "mockMeta?.redirect", + }, + ], + "should generate correct routes for parent/child": [ + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/parent/child.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent-child"", + "path": "mockMeta?.path ?? "child"", + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/parent.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent"", + "path": "mockMeta?.path ?? "/parent"", + "redirect": "mockMeta?.redirect", + }, + ], + "should handle trailing slashes with index routes": [ + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index/index/all.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index-index-all"", + "path": "mockMeta?.path ?? "all"", + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/index/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + ], + "should not generate colliding route names when hyphens are in file name": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/parent/[child].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent-child"", + "path": "mockMeta?.path ?? "/parent/:child()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/parent-[child].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "parent-child"", + "path": "mockMeta?.path ?? "/parent-:child()"", + "redirect": "mockMeta?.redirect", + }, + ], + "should not merge required param as a child of optional param": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[[foo]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "foo"", + "path": "mockMeta?.path ?? "/:foo?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[foo].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "foo"", + "path": "mockMeta?.path ?? "/:foo()"", + "redirect": "mockMeta?.redirect", + }, + ], + "should only allow "_" & "." as special character for dynamic route": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "a1_1a"", + "path": "mockMeta?.path ?? "/:a1_1a()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "b2.2b"", + "path": "mockMeta?.path ?? "/:b2.2b()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "b2_2b"", + "path": "mockMeta?.path ?? "/:b2()_:2b()"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "c33c"", + "path": "mockMeta?.path ?? "/:c33c?"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "d44d"", + "path": "mockMeta?.path ?? "/:d44d?"", + "redirect": "mockMeta?.redirect", + }, + ], + "should properly override route name if definePageMeta name override is defined.": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "home"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + ], + "should use fallbacks when normalized with `overrideMeta: true`": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + ], +} \ No newline at end of file diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap new file mode 100644 index 0000000000..8a89561e83 --- /dev/null +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap @@ -0,0 +1,303 @@ +{ + "route without file": [ + { + "alias": "["sweet-home"]", + "meta": "{"hello":"world"}", + "name": ""home"", + "path": ""/"", + "redirect": undefined, + }, + ], + "should allow pages with `:` in their path": [ + { + "component": "() => import("pages/test:name.vue").then(m => m.default || m)", + "name": ""test:name"", + "path": ""/test\\:name"", + }, + ], + "should correctly merge nested routes": [ + { + "children": [ + { + "children": [ + { + "component": "() => import("pages/param/index/index.vue").then(m => m.default || m)", + "name": ""param-index"", + "path": """", + }, + { + "component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)", + "name": ""param-index-sibling"", + "path": ""sibling"", + }, + ], + "component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)", + "name": "mockMeta?.name", + "path": """", + }, + { + "component": "() => import("pages/param/sibling.vue").then(m => m.default || m)", + "name": ""param-sibling"", + "path": ""sibling"", + }, + ], + "component": "() => import("pages/param.vue").then(m => m.default || m)", + "name": "mockMeta?.name", + "path": ""/param"", + }, + { + "children": [ + { + "component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)", + "name": ""wrapper-expose-other"", + "path": """", + }, + { + "component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)", + "name": ""wrapper-expose-other-sibling"", + "path": ""sibling"", + }, + ], + "component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)", + "name": "mockMeta?.name", + "path": ""/wrapper-expose/other"", + }, + ], + "should extract serializable values and override fallback when normalized with `overrideMeta: true`": [ + { + "alias": "["sweet-home"]", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": ""home"", + "path": ""/"", + "redirect": ""/"", + }, + ], + "should generate correct catch-all route": [ + { + "component": "() => import("pages/[...slug].vue").then(m => m.default || m)", + "name": ""slug"", + "path": ""/:slug(.*)*"", + }, + { + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "name": ""index"", + "path": ""/"", + }, + ], + "should generate correct dynamic routes": [ + { + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "name": ""index"", + "path": ""/"", + }, + { + "component": "() => import("pages/[slug].vue").then(m => m.default || m)", + "name": ""slug"", + "path": ""/:slug()"", + }, + { + "children": [ + { + "component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)", + "name": ""foo"", + "path": """", + }, + ], + "component": "() => import("pages/[[foo]]").then(m => m.default || m)", + "name": "mockMeta?.name", + "path": ""/:foo?"", + }, + { + "component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)", + "name": ""optional-opt"", + "path": ""/optional/:opt?"", + }, + { + "component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)", + "name": ""optional-prefix-opt"", + "path": ""/optional/prefix-:opt?"", + }, + { + "component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)", + "name": ""optional-opt-postfix"", + "path": ""/optional/:opt?-postfix"", + }, + { + "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)", + "name": ""optional-prefix-opt-postfix"", + "path": ""/optional/prefix-:opt?-postfix"", + }, + { + "component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)", + "name": ""bar"", + "path": ""/:bar()"", + }, + { + "component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)", + "name": ""nonopt-slug"", + "path": ""/nonopt/:slug()"", + }, + { + "component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)", + "name": ""opt-slug"", + "path": ""/opt/:slug?"", + }, + { + "component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)", + "name": ""sub-route-slug"", + "path": ""/:sub?/route-:slug()"", + }, + ], + "should generate correct id for catchall (order 1)": [ + { + "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "name": ""stories"", + "path": ""/:stories(.*)*"", + }, + { + "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "name": ""stories-id"", + "path": ""/stories/:id()"", + }, + ], + "should generate correct id for catchall (order 2)": [ + { + "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "name": ""stories-id"", + "path": ""/stories/:id()"", + }, + { + "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "name": ""stories"", + "path": ""/:stories(.*)*"", + }, + ], + "should generate correct route for kebab-case file": [ + { + "component": "() => import("pages/kebab-case.vue").then(m => m.default || m)", + "name": ""kebab-case"", + "path": ""/kebab-case"", + }, + ], + "should generate correct route for snake_case file": [ + { + "component": "() => import("pages/snake_case.vue").then(m => m.default || m)", + "name": ""snake_case"", + "path": ""/snake_case"", + }, + ], + "should generate correct routes for index pages": [ + { + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "name": ""index"", + "path": ""/"", + }, + { + "component": "() => import("pages/parent/index.vue").then(m => m.default || m)", + "name": ""parent"", + "path": ""/parent"", + }, + { + "component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)", + "name": ""parent-child"", + "path": ""/parent/child"", + }, + ], + "should generate correct routes for parent/child": [ + { + "children": [ + { + "component": "() => import("pages/parent/child.vue").then(m => m.default || m)", + "name": ""parent-child"", + "path": ""child"", + }, + ], + "component": "() => import("pages/parent.vue").then(m => m.default || m)", + "name": ""parent"", + "path": ""/parent"", + }, + ], + "should handle trailing slashes with index routes": [ + { + "children": [ + { + "component": "() => import("pages/index/index/all.vue").then(m => m.default || m)", + "name": ""index-index-all"", + "path": ""all"", + }, + ], + "component": "() => import("pages/index/index.vue").then(m => m.default || m)", + "name": ""index"", + "path": ""/"", + }, + ], + "should not generate colliding route names when hyphens are in file name": [ + { + "component": "() => import("pages/parent/[child].vue").then(m => m.default || m)", + "name": ""parent-child"", + "path": ""/parent/:child()"", + }, + { + "component": "() => import("pages/parent-[child].vue").then(m => m.default || m)", + "name": ""parent-child"", + "path": ""/parent-:child()"", + }, + ], + "should not merge required param as a child of optional param": [ + { + "component": "() => import("pages/[[foo]].vue").then(m => m.default || m)", + "name": ""foo"", + "path": ""/:foo?"", + }, + { + "component": "() => import("pages/[foo].vue").then(m => m.default || m)", + "name": ""foo"", + "path": ""/:foo()"", + }, + ], + "should only allow "_" & "." as special character for dynamic route": [ + { + "component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)", + "name": ""a1_1a"", + "path": ""/:a1_1a()"", + }, + { + "component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)", + "name": ""b2.2b"", + "path": ""/:b2.2b()"", + }, + { + "component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)", + "name": ""b2_2b"", + "path": ""/:b2()_:2b()"", + }, + { + "component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)", + "name": ""c33c"", + "path": ""/:c33c?"", + }, + { + "component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)", + "name": ""d44d"", + "path": ""/:d44d?"", + }, + ], + "should properly override route name if definePageMeta name override is defined.": [ + { + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "name": ""home"", + "path": ""/"", + }, + ], + "should use fallbacks when normalized with `overrideMeta: true`": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/index.vue").then(m => m.default || m)", + "meta": "mockMeta || {}", + "name": "mockMeta?.name", + "path": ""/"", + "redirect": "mockMeta?.redirect", + }, + ], +} \ No newline at end of file diff --git a/packages/nuxt/test/pages.test.ts b/packages/nuxt/test/pages.test.ts index 074d6c7366..8f09df63c8 100644 --- a/packages/nuxt/test/pages.test.ts +++ b/packages/nuxt/test/pages.test.ts @@ -1,15 +1,28 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { NuxtPage } from 'nuxt/schema' -import { generateRoutesFromFiles, pathToNitroGlob } from '../src/pages/utils' +import { generateRoutesFromFiles, normalizeRoutes, pathToNitroGlob } from '../src/pages/utils' import { generateRouteKey } from '../src/pages/runtime/utils' describe('pages:generateRoutesFromFiles', () => { const pagesDir = 'pages' const layerDir = 'layer/pages' + const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const + + vi.mock('knitwork', async (original) => { + return { + ...(await original()), + 'genArrayFromRaw': (val: any) => val, + 'genSafeVariableName': (..._args: string[]) => { + return 'mock' + }, + } + }) + const tests: Array<{ description: string - files: Array<{ path: string; template?: string; }> + files?: Array<{ path: string; template?: string; }> output?: NuxtPage[] + normalized?: Record[] error?: string }> = [ { @@ -458,30 +471,118 @@ describe('pages:generateRoutesFromFiles', () => { name: 'index', path: '/' } + ], + }, + { + description: 'should use fallbacks when normalized with `overrideMeta: true`', + files: [ + { + path: `${pagesDir}/index.vue`, + template: ` + + ` + } + ], + output: [ + { + name: 'index', + path: '/', + file: `${pagesDir}/index.vue`, + meta: { [DYNAMIC_META_KEY]: new Set(['name', 'alias', 'redirect', 'meta']) }, + children: [] + } ] - } + }, + { + description: 'should extract serializable values and override fallback when normalized with `overrideMeta: true`', + files: [ + { + path: `${pagesDir}/index.vue`, + template: ` + + ` + } + ], + output: [ + { + name: 'home', + path: '/', + file: `${pagesDir}/index.vue`, + alias: ['sweet-home'], + redirect: '/', + children: [], + meta: { [DYNAMIC_META_KEY]: new Set(['meta']) }, + } + ] + }, + { + description: 'route without file', + output: [ + { + name: 'home', + path: '/', + alias: ['sweet-home'], + meta: { hello: 'world' }, + } + ] + }, ] + const normalizedResults: Record = {} + const normalizedOverrideMetaResults: Record = {} + for (const test of tests) { it(test.description, async () => { - const vfs = Object.fromEntries( - test.files.map(file => [file.path, 'template' in file ? file.template : '']) - ) as Record let result - try { - result = await generateRoutesFromFiles(test.files.map(file => ({ - absolutePath: file.path, - relativePath: file.path.replace(/^(pages|layer\/pages)\//, '') - })), true, vfs) - } catch (error: any) { - expect(error.message).toEqual(test.error) + if (test.files) { + const vfs = Object.fromEntries( + test.files.map(file => [file.path, 'template' in file ? file.template : '']) + ) as Record + + try { + result = await generateRoutesFromFiles(test.files.map(file => ({ + absolutePath: file.path, + relativePath: file.path.replace(/^(pages|layer\/pages)\//, '') + })), { shouldExtractBuildMeta: true, vfs }) + } catch (error: any) { + expect(error.message).toEqual(test.error) + } + } else { + result = test.output ?? [] } + if (result) { expect(result).toEqual(test.output) + normalizedResults[test.description] = normalizeRoutes(result, new Set()).routes + normalizedOverrideMetaResults[test.description] = normalizeRoutes(result, new Set(), true).routes } }) } + + it('should consistently normalize routes', async () => { + await expect(normalizedResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-disabled.test.ts.snap') + }) + + it('should consistently normalize routes when overriding meta', async () => { + await expect(normalizedOverrideMetaResults).toMatchFileSnapshot('./__snapshots__/pages-override-meta-enabled.test.ts.snap') + }) }) describe('pages:generateRouteKey', () => { diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 46b0c1397a..d1c5a6d793 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -261,6 +261,15 @@ export default defineUntypedSchema({ */ inlineRouteRules: false, + /** + * Allow exposing some route metadata defined in `definePageMeta` at build-time to modules (alias, name, path, redirect). + * + * This only works with static or strings/arrays rather than variables or conditional assignment. + * + * https://github.com/nuxt/nuxt/issues/24770 + */ + scanPageMeta: false, + /** * Automatically share payload _data_ between pages that are prerendered. This can result in a significant * performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same