fix(nuxt): support custom route name meta with typedPages (#21659)

This commit is contained in:
ChronicStone 2023-07-04 07:24:50 +02:00 committed by GitHub
parent 435ac87961
commit fd2b36a64d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 37 deletions

View File

@ -1,3 +1,4 @@
import fs from 'node:fs'
import { extname, normalize, relative, resolve } from 'pathe' import { extname, normalize, relative, resolve } from 'pathe'
import { encodePath } from 'ufo' import { encodePath } from 'ufo'
import { resolveFiles, useNuxt } from '@nuxt/kit' import { resolveFiles, useNuxt } from '@nuxt/kit'
@ -5,6 +6,9 @@ import { genArrayFromRaw, genDynamicImport, genImport, genSafeVariableName } fro
import escapeRE from 'escape-string-regexp' import escapeRE from 'escape-string-regexp'
import { filename } from 'pathe/utils' import { filename } from 'pathe/utils'
import { hash } from 'ohash' 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 type { NuxtPage } from 'nuxt/schema'
import { uniqueBy } from '../core/utils' import { uniqueBy } from '../core/utils'
@ -41,14 +45,14 @@ export async function resolvePagesRoutes (): Promise<NuxtPage[]> {
const files = await resolveFiles(dir, `**/*{${nuxt.options.extensions.join(',')}}`) const files = await resolveFiles(dir, `**/*{${nuxt.options.extensions.join(',')}}`)
// Sort to make sure parent are listed first // Sort to make sure parent are listed first
files.sort() files.sort()
return generateRoutesFromFiles(files, dir) return generateRoutesFromFiles(files, dir, nuxt.options.experimental.typedPages, nuxt.vfs)
}) })
)).flat() )).flat()
return uniqueBy(allRoutes, 'path') return uniqueBy(allRoutes, 'path')
} }
export function generateRoutesFromFiles (files: string[], pagesDir: string): NuxtPage[] { export async function generateRoutesFromFiles (files: string[], pagesDir: string, shouldExtractBuildMeta = false, vfs?: Record<string, string>): Promise<NuxtPage[]> {
const routes: NuxtPage[] = [] const routes: NuxtPage[] = []
for (const file of files) { 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) parent.push(route)
} }
return prepareRoutes(routes) return prepareRoutes(routes)
} }
const SFC_SCRIPT_RE = /<script\s*[^>]*>([\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 { function getRoutePath (tokens: SegmentToken[]): string {
return tokens.reduce((path, token) => { return tokens.reduce((path, token) => {
return ( return (

View File

@ -1,16 +1,22 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { NuxtPage } from 'nuxt/schema'
import { generateRoutesFromFiles } from '../src/pages/utils' import { generateRoutesFromFiles } from '../src/pages/utils'
import { generateRouteKey } from '../src/pages/runtime/utils' import { generateRouteKey } from '../src/pages/runtime/utils'
describe('pages:generateRoutesFromFiles', () => { describe('pages:generateRoutesFromFiles', () => {
const pagesDir = 'pages' 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', description: 'should generate correct routes for index pages',
files: [ files: [
`${pagesDir}/index.vue`, { path: `${pagesDir}/index.vue` },
`${pagesDir}/parent/index.vue`, { path: `${pagesDir}/parent/index.vue` },
`${pagesDir}/parent/child/index.vue` { path: `${pagesDir}/parent/child/index.vue` }
], ],
output: [ output: [
{ {
@ -36,8 +42,8 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should generate correct routes for parent/child', description: 'should generate correct routes for parent/child',
files: [ files: [
`${pagesDir}/parent.vue`, { path: `${pagesDir}/parent.vue` },
`${pagesDir}/parent/child.vue` { path: `${pagesDir}/parent/child.vue` }
], ],
output: [ output: [
{ {
@ -58,8 +64,8 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should not generate colliding route names when hyphens are in file name', description: 'should not generate colliding route names when hyphens are in file name',
files: [ files: [
`${pagesDir}/parent/[child].vue`, { path: `${pagesDir}/parent/[child].vue` },
`${pagesDir}/parent-[child].vue` { path: `${pagesDir}/parent-[child].vue` }
], ],
output: [ output: [
{ {
@ -79,8 +85,8 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should generate correct id for catchall (order 1)', description: 'should generate correct id for catchall (order 1)',
files: [ files: [
`${pagesDir}/[...stories].vue`, { path: `${pagesDir}/[...stories].vue` },
`${pagesDir}/stories/[id].vue` { path: `${pagesDir}/stories/[id].vue` }
], ],
output: [ output: [
{ {
@ -100,8 +106,8 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should generate correct id for catchall (order 2)', description: 'should generate correct id for catchall (order 2)',
files: [ files: [
`${pagesDir}/stories/[id].vue`, { path: `${pagesDir}/stories/[id].vue` },
`${pagesDir}/[...stories].vue` { path: `${pagesDir}/[...stories].vue` }
], ],
output: [ output: [
{ {
@ -121,7 +127,7 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should generate correct route for snake_case file', description: 'should generate correct route for snake_case file',
files: [ files: [
`${pagesDir}/snake_case.vue` { path: `${pagesDir}/snake_case.vue` }
], ],
output: [ output: [
{ {
@ -134,7 +140,7 @@ describe('pages:generateRoutesFromFiles', () => {
}, },
{ {
description: 'should generate correct route for kebab-case file', description: 'should generate correct route for kebab-case file',
files: [`${pagesDir}/kebab-case.vue`], files: [{ path: `${pagesDir}/kebab-case.vue` }],
output: [ output: [
{ {
name: 'kebab-case', name: 'kebab-case',
@ -147,14 +153,14 @@ describe('pages:generateRoutesFromFiles', () => {
{ {
description: 'should generate correct dynamic routes', description: 'should generate correct dynamic routes',
files: [ files: [
`${pagesDir}/index.vue`, { path: `${pagesDir}/index.vue` },
`${pagesDir}/[slug].vue`, { path: `${pagesDir}/[slug].vue` },
`${pagesDir}/[[foo]]`, { path: `${pagesDir}/[[foo]]` },
`${pagesDir}/[[foo]]/index.vue`, { path: `${pagesDir}/[[foo]]/index.vue` },
`${pagesDir}/[bar]/index.vue`, { path: `${pagesDir}/[bar]/index.vue` },
`${pagesDir}/nonopt/[slug].vue`, { path: `${pagesDir}/nonopt/[slug].vue` },
`${pagesDir}/opt/[[slug]].vue`, { path: `${pagesDir}/opt/[[slug]].vue` },
`${pagesDir}/[[sub]]/route-[slug].vue` { path: `${pagesDir}/[[sub]]/route-[slug].vue` }
], ],
output: [ output: [
{ {
@ -210,7 +216,7 @@ describe('pages:generateRoutesFromFiles', () => {
}, },
{ {
description: 'should generate correct catch-all route', description: 'should generate correct catch-all route',
files: [`${pagesDir}/[...slug].vue`, `${pagesDir}/index.vue`], files: [{ path: `${pagesDir}/[...slug].vue` }, { path: `${pagesDir}/index.vue` }],
output: [ output: [
{ {
name: 'slug', name: 'slug',
@ -228,24 +234,24 @@ describe('pages:generateRoutesFromFiles', () => {
}, },
{ {
description: 'should throw unfinished param error for dynamic route', description: 'should throw unfinished param error for dynamic route',
files: [`${pagesDir}/[slug.vue`], files: [{ path: `${pagesDir}/[slug.vue` }],
error: 'Unfinished param "slug"' error: 'Unfinished param "slug"'
}, },
{ {
description: 'should throw empty param error for dynamic route', description: 'should throw empty param error for dynamic route',
files: [ files: [
`${pagesDir}/[].vue` { path: `${pagesDir}/[].vue` }
], ],
error: 'Empty param' error: 'Empty param'
}, },
{ {
description: 'should only allow "_" & "." as special character for dynamic route', description: 'should only allow "_" & "." as special character for dynamic route',
files: [ files: [
`${pagesDir}/[a1_1a].vue`, { path: `${pagesDir}/[a1_1a].vue` },
`${pagesDir}/[b2.2b].vue`, { path: `${pagesDir}/[b2.2b].vue` },
`${pagesDir}/[b2]_[2b].vue`, { path: `${pagesDir}/[b2]_[2b].vue` },
`${pagesDir}/[[c3@3c]].vue`, { path: `${pagesDir}/[[c3@3c]].vue` },
`${pagesDir}/[[d4-4d]].vue` { path: `${pagesDir}/[[d4-4d]].vue` }
], ],
output: [ 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: `
<script setup lang="ts">
definePageMeta({
name: 'home'
})
</script>
`
}
],
output: [
{
name: 'home',
path: '/',
file: `${pagesDir}/index.vue`,
children: []
}
]
},
{ {
description: 'should allow pages with `:` in their path', description: 'should allow pages with `:` in their path',
files: [ files: [
`${pagesDir}/test:name.vue` { path: `${pagesDir}/test:name.vue` }
], ],
output: [ output: [
{ {
@ -298,10 +327,15 @@ describe('pages:generateRoutesFromFiles', () => {
for (const test of tests) { for (const test of tests) {
it(test.description, async () => { it(test.description, async () => {
if (test.error) { const vfs = Object.fromEntries(
expect(() => generateRoutesFromFiles(test.files, pagesDir)).to.throws(test.error) test.files.map(file => [file.path, 'template' in file ? file.template : ''])
} else { ) as Record<string, string>
expect(await generateRoutesFromFiles(test.files, pagesDir)).to.deep.equal(test.output)
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)
} }
}) })
} }

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
definePageMeta({
validate: () => true,
middleware: [
function () {}
],
name: 'some-custom-name'
})
</script>
<template>
<div>
<!-- -->
</div>
</template>

View File

@ -130,6 +130,11 @@ describe('typed router integration', () => {
router.push({ name: 'param-id', params: { id: 4 } }) 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', () => { it('allows typing useRoute', () => {
const route = useRoute('param-id') const route = useRoute('param-id')
// @ts-expect-error this param does not exist // @ts-expect-error this param does not exist