fix(nuxt): extract route rules/page meta in 2+ script blocks (#28625)

This commit is contained in:
Daniel Roe 2024-08-21 12:38:18 +01:00
parent 81774f3a5a
commit eed8730688
No known key found for this signature in database
GPG Key ID: 3714AB03996F442B
3 changed files with 144 additions and 103 deletions

View File

@ -18,30 +18,33 @@ export async function extractRouteRules (code: string): Promise<NitroRouteConfig
}
if (!ROUTE_RULE_RE.test(code)) { return null }
const script = extractScriptContent(code)
code = script?.code || code
let rule: NitroRouteConfig | null = null
const contents = extractScriptContent(code)
for (const script of contents) {
if (rule) { break }
const js = await transform(code, { loader: script?.loader || 'ts' })
walk(parse(js.code, {
sourceType: 'module',
ecmaVersion: 'latest',
}) as Node, {
enter (_node) {
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
const node = _node as CallExpression & { start: number, end: number }
const name = 'name' in node.callee && node.callee.name
if (name === 'defineRouteRules') {
const rulesString = js.code.slice(node.start, node.end)
try {
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
} catch {
throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.')
code = script?.code || code
const js = await transform(code, { loader: script?.loader || 'ts' })
walk(parse(js.code, {
sourceType: 'module',
ecmaVersion: 'latest',
}) as Node, {
enter (_node) {
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
const node = _node as CallExpression & { start: number, end: number }
const name = 'name' in node.callee && node.callee.name
if (name === 'defineRouteRules') {
const rulesString = js.code.slice(node.start, node.end)
try {
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
} catch {
throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.')
}
}
}
},
})
},
})
}
ruleCache[code] = rule
return rule

View File

@ -168,21 +168,23 @@ export async function augmentPages (routes: NuxtPage[], vfs: Record<string, stri
return augmentedPages
}
const SFC_SCRIPT_RE = /<script(?<attrs>[^>]*)>(?<content>[\s\S]*?)<\/script[^>]*>/i
const SFC_SCRIPT_RE = /<script(?<attrs>[^>]*)>(?<content>[\s\S]*?)<\/script[^>]*>/gi
export function extractScriptContent (html: string) {
const groups = html.match(SFC_SCRIPT_RE)?.groups || {}
if (groups.content) {
return {
loader: groups.attrs.includes('tsx') ? 'tsx' : 'ts',
code: groups.content.trim(),
} as const
const contents: Array<{ loader: 'tsx' | 'ts', code: string }> = []
for (const match of html.matchAll(SFC_SCRIPT_RE)) {
if (match?.groups?.content) {
contents.push({
loader: match.groups.attrs.includes('tsx') ? 'tsx' : 'ts',
code: match.groups.content.trim(),
})
}
}
return null
return contents
}
const PAGE_META_RE = /definePageMeta\([\s\S]*?\)/
const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
const pageContentsCache: Record<string, string> = {}
@ -197,100 +199,101 @@ export async function getRouteMeta (contents: string, absolutePath: string): Pro
if (absolutePath in metaCache) { return metaCache[absolutePath] }
const loader = getLoader(absolutePath)
const script = !loader ? null : loader === 'vue' ? extractScriptContent(contents) : { code: contents, loader }
if (!script) {
const scriptBlocks = !loader ? null : loader === 'vue' ? extractScriptContent(contents) : [{ code: contents, loader }]
if (!scriptBlocks) {
metaCache[absolutePath] = {}
return {}
}
if (!PAGE_META_RE.test(script.code)) {
metaCache[absolutePath] = {}
return {}
}
const js = await transform(script.code, { loader: script.loader })
const ast = parse(js.code, {
sourceType: 'module',
ecmaVersion: 'latest',
ranges: true,
}) as unknown as Program
const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>>
const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const
const dynamicProperties = new Set<keyof NuxtPage>()
let foundMeta = false
for (const script of scriptBlocks) {
if (!PAGE_META_RE.test(script.code)) {
continue
}
walk(ast, {
enter (node) {
if (foundMeta) { return }
const js = await transform(script.code, { loader: script.loader })
const ast = parse(js.code, {
sourceType: 'module',
ecmaVersion: 'latest',
ranges: true,
}) as unknown as Program
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
const dynamicProperties = new Set<keyof NuxtPage>()
foundMeta = true
const pageMetaArgument = ((node as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
let foundMeta = false
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 }
walk(ast, {
enter (node) {
if (foundMeta) { return }
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 (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
if (property.value.type === 'ArrayExpression') {
const values: string[] = []
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}\`).`)
foundMeta = true
const pageMetaArgument = ((node as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
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
}
values.push(element.value)
}
extractedMeta[key] = values
continue
if (property.value.type === 'ArrayExpression') {
const values: string[] = []
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
}
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
for (const property of pageMetaArgument.properties) {
if (property.type !== 'Property') {
continue
}
const isIdentifierOrLiteral = property.key.type === 'Literal' || property.key.type === 'Identifier'
if (!isIdentifierOrLiteral) {
continue
}
const name = property.key.type === 'Identifier' ? property.key.name : String(property.value)
if (name) {
dynamicProperties.add('meta')
break
}
}
extractedMeta[key] = property.value.value
}
for (const property of pageMetaArgument.properties) {
if (property.type !== 'Property') {
continue
if (dynamicProperties.size) {
extractedMeta.meta ??= {}
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
}
const isIdentifierOrLiteral = property.key.type === 'Literal' || property.key.type === 'Identifier'
if (!isIdentifierOrLiteral) {
continue
}
const name = property.key.type === 'Identifier' ? property.key.name : String(property.value)
if (name) {
dynamicProperties.add('meta')
break
}
}
if (dynamicProperties.size) {
extractedMeta.meta ??= {}
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
}
},
})
},
})
}
metaCache[absolutePath] = extractedMeta
return extractedMeta

View File

@ -61,6 +61,41 @@ describe('page metadata', () => {
`)
})
it('should extract serialisable metadata from files with multiple blocks', async () => {
const meta = await getRouteMeta(`
<script lang="ts">
export default {
name: 'thing'
}
</script>
<script setup>
definePageMeta({
name: 'some-custom-name',
path: '/some-custom-path',
validate: () => true,
middleware: [
function () {},
],
otherValue: {
foo: 'bar',
},
})
</script>
`, filePath)
expect(meta).toMatchInlineSnapshot(`
{
"meta": {
"__nuxt_dynamic_meta_key": Set {
"meta",
},
},
"name": "some-custom-name",
"path": "/some-custom-path",
}
`)
})
it('should extract serialisable metadata in options api', async () => {
const meta = await getRouteMeta(`
<script>