feat(nuxt): use oxc-parser instead of esbuild + acorn

This commit is contained in:
Daniel Roe 2024-11-27 10:38:02 +00:00
parent 1c418d0ea3
commit 499ce3d5f3
No known key found for this signature in database
GPG Key ID: 3714AB03996F442B
18 changed files with 243 additions and 200 deletions

View File

@ -70,7 +70,6 @@
"@unhead/ssr": "^1.11.13",
"@unhead/vue": "^1.11.13",
"@vue/shared": "^3.5.13",
"acorn": "8.14.0",
"c12": "^2.0.1",
"chokidar": "^4.0.1",
"compatx": "^0.1.8",
@ -99,6 +98,7 @@
"nypm": "^0.4.0",
"ofetch": "^1.4.1",
"ohash": "^1.1.4",
"oxc-parser": "^0.38.0",
"pathe": "^1.1.2",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.2.1",

View File

@ -1,13 +1,12 @@
import { pathToFileURL } from 'node:url'
import { parseURL } from 'ufo'
import MagicString from 'magic-string'
import type { AssignmentProperty, CallExpression, ObjectExpression, Pattern, Property, ReturnStatement, VariableDeclaration } from 'estree'
import type { Program } from 'acorn'
import type { BindingPattern, BindingProperty, BindingRestElement, CallExpression, Expression, ObjectExpression, ObjectProperty, Program, ReturnStatement, VariableDeclaration } from 'oxc-parser'
import { createUnplugin } from 'unplugin'
import type { Component } from '@nuxt/schema'
import { resolve } from 'pathe'
import { parseAndWalk, walk, withLocations } from '../../core/utils/parse'
import { parseAndWalk, walk } from '../../core/utils/parse'
import type { Node } from '../../core/utils/parse'
import { distDir } from '../../dirs'
@ -58,15 +57,14 @@ export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions)
const [componentCall, _, children] = node.arguments
if (!componentCall) { return }
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
if (componentCall.type === 'Identifier' || componentCall.type === 'ComputedMemberExpression' || componentCall.type === 'StaticMemberExpression' || componentCall.type === 'CallExpression') {
const componentName = getComponentName(node)
if (!componentName || !COMPONENTS_IDENTIFIERS_RE.test(componentName) || children?.type !== 'ObjectExpression') { return }
const isClientOnlyComponent = CLIENT_ONLY_NAME_RE.test(componentName)
const slotsToRemove = isClientOnlyComponent ? children.properties.filter(prop => prop.type === 'Property' && prop.key.type === 'Identifier' && !PLACEHOLDER_EXACT_RE.test(prop.key.name)) as Property[] : children.properties as Property[]
const slotsToRemove = isClientOnlyComponent ? children.properties.filter(prop => prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && !PLACEHOLDER_EXACT_RE.test(prop.key.name)) as ObjectProperty[] : children.properties as ObjectProperty[]
for (const _slot of slotsToRemove) {
const slot = withLocations(_slot)
for (const slot of slotsToRemove) {
s.remove(slot.start, slot.end + 1)
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
const currentState = s.toString()
@ -87,7 +85,7 @@ export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions)
})
const componentsToRemove = [...componentsToRemoveSet]
const removedNodes = new WeakSet<Node>()
const removedNodes = new WeakSet<PropertyPattern>()
for (const componentName of componentsToRemove) {
// remove import declaration if it exists
@ -119,18 +117,18 @@ function removeFromSetupReturn (codeAst: Program, name: string, magicString: Mag
enter (node) {
if (walkedInSetup) {
this.skip()
} else if (node.type === 'Property' && node.key.type === 'Identifier' && node.key.name === 'setup' && (node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression')) {
} else if (node.type === 'ObjectProperty' && node.key.type === 'Identifier' && node.key.name === 'setup' && (node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression')) {
// walk into the setup function
walkedInSetup = true
if (node.value.body?.type === 'BlockStatement') {
const returnStatement = node.value.body.body.find(statement => statement.type === 'ReturnStatement') as ReturnStatement
if (node.value.body?.type === 'FunctionBody') {
const returnStatement = node.value.body.statements.find(statement => statement.type === 'ReturnStatement') as ReturnStatement
if (returnStatement && returnStatement.argument?.type === 'ObjectExpression') {
// remove from return statement
removePropertyFromObject(returnStatement.argument, name, magicString)
}
// remove from __returned__
const variableList = node.value.body.body.filter((statement): statement is VariableDeclaration => statement.type === 'VariableDeclaration')
const variableList = node.value.body.statements.filter((statement): statement is VariableDeclaration => statement.type === 'VariableDeclaration')
const returnedVariableDeclaration = variableList.find(declaration => declaration.declarations[0]?.id.type === 'Identifier' && declaration.declarations[0]?.id.name === '__returned__' && declaration.declarations[0]?.init?.type === 'ObjectExpression')
if (returnedVariableDeclaration) {
const init = returnedVariableDeclaration.declarations[0]?.init as ObjectExpression | undefined
@ -149,9 +147,8 @@ function removeFromSetupReturn (codeAst: Program, name: string, magicString: Mag
*/
function removePropertyFromObject (node: ObjectExpression, name: string, magicString: MagicString) {
for (const property of node.properties) {
if (property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === name) {
const _property = withLocations(property)
magicString.remove(_property.start, _property.end + 1)
if (property.type === 'ObjectProperty' && property.key.type === 'Identifier' && property.key.name === name) {
magicString.remove(property.start, property.end + 1)
return true
}
}
@ -173,12 +170,10 @@ function removeImportDeclaration (ast: Program, importName: string, magicString:
const specifierIndex = node.specifiers.findIndex(s => s.local.name === importName)
if (specifierIndex > -1) {
if (node.specifiers!.length > 1) {
const specifier = withLocations(node.specifiers![specifierIndex])
magicString.remove(specifier.start, specifier.end + 1)
magicString.remove((node.specifiers![specifierIndex])!.start, (node.specifiers![specifierIndex])!.end + 1)
node.specifiers!.splice(specifierIndex, 1)
} else {
const specifier = withLocations(node)
magicString.remove(specifier.start, specifier.end)
magicString.remove(node.start, node.end)
}
return true
}
@ -195,7 +190,7 @@ function isComponentNotCalledInSetup (code: string, id: string, name: string): s
if (!name) { return }
let found = false
parseAndWalk(code, id, function (node) {
if ((node.type === 'Property' && node.key.type === 'Identifier' && node.value.type === 'FunctionExpression' && node.key.name === 'setup') || (node.type === 'FunctionDeclaration' && (node.id?.name === '_sfc_ssrRender' || node.id?.name === 'ssrRender'))) {
if ((node.type === 'ObjectProperty' && node.key.type === 'Identifier' && node.value.type === 'FunctionExpression' && node.key.name === 'setup') || (node.type === 'FunctionDeclaration' && (node.id?.name === '_sfc_ssrRender' || node.id?.name === 'ssrRender'))) {
// walk through the setup function node or the ssrRender function
walk(node, {
enter (node) {
@ -203,9 +198,11 @@ function isComponentNotCalledInSetup (code: string, id: string, name: string): s
this.skip()
} else if (node.type === 'Identifier' && node.name === name) {
found = true
} else if (node.type === 'MemberExpression') {
} else if (node.type === 'StaticMemberExpression') {
// dev only with $setup or _ctx
found = (node.property.type === 'Literal' && node.property.value === name) || (node.property.type === 'Identifier' && node.property.name === name)
found = (node.property.type === 'Identifier' && node.property.name === name)
} else if (node.type === 'ComputedMemberExpression') {
found = (node.expression.type === 'Literal' && node.expression.value === name)
}
},
})
@ -224,10 +221,10 @@ function getComponentName (ssrRenderNode: CallExpression): string | undefined {
if (componentCall.type === 'Identifier') {
return componentCall.name
} else if (componentCall.type === 'MemberExpression') {
if (componentCall.property.type === 'Literal') {
return componentCall.property.value as string
}
} else if (componentCall.type === 'StaticMemberExpression') {
return componentCall.property.name
} else if (componentCall.type === 'ComputedMemberExpression' && componentCall.expression.type === 'Literal') {
return componentCall.expression.value as string
} else if (componentCall.type === 'CallExpression') {
return getComponentName(componentCall)
}
@ -236,14 +233,14 @@ function getComponentName (ssrRenderNode: CallExpression): string | undefined {
/**
* remove a variable declaration within the code
*/
function removeVariableDeclarator (codeAst: Program, name: string, magicString: MagicString, removedNodes: WeakSet<Node>): Node | void {
function removeVariableDeclarator (codeAst: Program, name: string, magicString: MagicString, removedNodes: WeakSet<PropertyPattern>): Expression | void {
// remove variables
walk(codeAst, {
enter (node) {
if (node.type !== 'VariableDeclaration') { return }
for (const declarator of node.declarations) {
const toRemove = withLocations(findMatchingPatternToRemove(declarator.id, node, name, removedNodes))
if (toRemove) {
const toRemove = findMatchingPatternToRemove(declarator.id, node, name, removedNodes)
if (toRemove && 'start' in toRemove) {
magicString.remove(toRemove.start, toRemove.end + 1)
removedNodes.add(toRemove)
}
@ -255,23 +252,24 @@ function removeVariableDeclarator (codeAst: Program, name: string, magicString:
/**
* find the Pattern to remove which the identifier is equal to the name parameter.
*/
function findMatchingPatternToRemove (node: Pattern, toRemoveIfMatched: Node, name: string, removedNodeSet: WeakSet<Node>): Node | undefined {
type PropertyPattern = BindingPattern | BindingRestElement | BindingProperty | Node
function findMatchingPatternToRemove (node: PropertyPattern, toRemoveIfMatched: PropertyPattern, name: string, removedNodeSet: WeakSet<PropertyPattern>): PropertyPattern | undefined {
if (node.type === 'Identifier') {
if (node.name === name) {
return toRemoveIfMatched
}
} else if (node.type === 'ArrayPattern') {
const elements = node.elements.filter((e): e is Pattern => e !== null && !removedNodeSet.has(e))
const elements = node.elements.filter((e): e is BindingPattern | BindingRestElement => e !== null && !removedNodeSet.has(e))
for (const element of elements) {
const matched = findMatchingPatternToRemove(element, elements.length > 1 ? element : toRemoveIfMatched, name, removedNodeSet)
if (matched) { return matched }
}
} else if (node.type === 'ObjectPattern') {
const properties = node.properties.filter((e): e is AssignmentProperty => e.type === 'Property' && !removedNodeSet.has(e))
const properties = node.properties.filter((e): e is BindingProperty => e.type === 'BindingProperty' && !removedNodeSet.has(e))
for (const [index, property] of properties.entries()) {
let nodeToRemove: Node = property
let nodeToRemove: PropertyPattern = property
if (properties.length < 2) {
nodeToRemove = toRemoveIfMatched
}

View File

@ -248,15 +248,13 @@ function resolvePaths<Item extends Record<string, any>> (items: Item[], key: { [
}))
}
const IS_TSX = /\.[jt]sx$/
export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) {
const _plugins: Array<NuxtPlugin & Omit<PluginMeta, 'enforce'>> = []
for (const plugin of plugins) {
try {
const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src]! : await fsp.readFile(plugin.src!, 'utf-8')
_plugins.push({
...await extractMetadata(code, IS_TSX.test(plugin.src) ? 'tsx' : 'ts'),
...await extractMetadata(code, plugin.src),
...plugin,
})
} catch (e) {

View File

@ -3,13 +3,13 @@ import { createUnplugin } from 'unplugin'
import { isAbsolute, relative } from 'pathe'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import type { Pattern } from 'estree'
import { parseQuery, parseURL } from 'ufo'
import escapeRE from 'escape-string-regexp'
import { findStaticImports, parseStaticImport } from 'mlly'
import { parseAndWalk, walk } from '../../core/utils/parse'
import type { BindingPattern } from 'oxc-parser'
import { matchWithStringOrRegex } from '../utils/plugins'
import { parseAndWalk, walk } from '../utils/parse'
interface ComposableKeysOptions {
sourcemap: boolean
@ -62,8 +62,10 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
varCollector.refresh(scopeTracker.curScopeKey)
} else if (node.type === 'FunctionDeclaration' && node.id) {
varCollector.addVar(node.id.name)
} else if (node.type === 'VariableDeclarator') {
varCollector.collect(node.id)
} else if (node.type === 'VariableDeclaration') {
for (const declaration of node.declarations) {
varCollector.collect(declaration.id)
}
}
},
leave (_node) {
@ -213,11 +215,9 @@ class ScopedVarsCollector {
return false
}
collect (pattern: Pattern) {
collect (pattern: BindingPattern) {
if (pattern.type === 'Identifier') {
this.addVar(pattern.name)
} else if (pattern.type === 'RestElement') {
this.collect(pattern.argument)
} else if (pattern.type === 'AssignmentPattern') {
this.collect(pattern.left)
} else if (pattern.type === 'ArrayPattern') {

View File

@ -1,5 +1,4 @@
import type { Literal, Property, SpreadElement } from 'estree'
import { transform } from 'esbuild'
import type { ObjectProperty, SpreadElement, StringLiteral } from 'oxc-parser'
import { defu } from 'defu'
import { findExports } from 'mlly'
import type { Nuxt } from '@nuxt/schema'
@ -8,7 +7,7 @@ import MagicString from 'magic-string'
import { normalize } from 'pathe'
import { logger } from '@nuxt/kit'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { parseAndWalk } from '../../core/utils/parse'
import type { ObjectPlugin, PluginMeta } from '#app'
@ -40,13 +39,12 @@ export const orderMap: Record<NonNullable<ObjectPlugin['enforce']>, number> = {
}
const metaCache: Record<string, Omit<PluginMeta, 'enforce'>> = {}
export async function extractMetadata (code: string, loader = 'ts' as 'ts' | 'tsx') {
export function extractMetadata (code: string, id: string) {
let meta: PluginMeta = {}
if (metaCache[code]) {
return metaCache[code]
}
const js = await transform(code, { loader })
parseAndWalk(js.code, `file.${loader}`, (node) => {
parseAndWalk(code, id, (node) => {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
const name = 'name' in node.callee && node.callee.name
@ -87,7 +85,7 @@ function isMetadataKey (key: string): key is PluginMetaKey {
return key in keys
}
function extractMetaFromObject (properties: Array<Property | SpreadElement>) {
function extractMetaFromObject (properties: Array<ObjectProperty | SpreadElement>) {
const meta: PluginMeta = {}
for (const property of properties) {
if (property.type === 'SpreadElement' || !('name' in property.key)) {
@ -105,7 +103,7 @@ function extractMetaFromObject (properties: Array<Property | SpreadElement>) {
if (property.value.elements.some(e => !e || e.type !== 'Literal' || typeof e.value !== 'string')) {
throw new Error('dependsOn must take an array of string literals')
}
meta[propertyKey] = property.value.elements.map(e => (e as Literal)!.value as string)
meta[propertyKey] = property.value.elements.map(e => (e as StringLiteral)!.value as string)
}
}
return meta
@ -163,10 +161,11 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => {
const propertyKey = property.key.name
if (propertyKey === 'order' || propertyKey === 'enforce' || propertyKey === 'name') {
const nextNode = arg.properties[propertyIndex + 1] || node.arguments[argIndex + 1]
const nextIndex = withLocations(nextNode)?.start || (withLocations(arg).end - 1)
const _nextNode = arg.properties[propertyIndex + 1] || node.arguments[argIndex + 1]
const nextNode = _nextNode as typeof _nextNode & { start: number, end: number }
const nextIndex = nextNode?.start || (arg.end - 1)
s.remove(withLocations(property).start, nextIndex)
s.remove(property.start, nextIndex)
}
}
}

View File

@ -3,7 +3,7 @@ import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { parseAndWalk } from '../../core/utils/parse'
import { isJS, isVue } from '../utils'
export function PrehydrateTransformPlugin (options: { sourcemap?: boolean } = {}) {
@ -23,11 +23,11 @@ export function PrehydrateTransformPlugin (options: { sourcemap?: boolean } = {}
return
}
if (node.callee.name === 'onPrehydrate') {
const callback = withLocations(node.arguments[0])
const callback = node.arguments[0]
if (!callback) { return }
if (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression') { return }
const needsAttr = callback.params.length > 0
const needsAttr = callback.params.items.length > 0
const p = transform(`forEach(${code.slice(callback.start, callback.end)})`, { loader: 'ts', minify: true })
promises.push(p.then(({ code: result }) => {

View File

@ -1,33 +1,37 @@
import { parseSync } from 'oxc-parser'
import type { CatchClause, ClassBody, Declaration, Expression, MethodDefinition, ModuleDeclaration, ObjectProperty, Pattern, PrivateIdentifier, Program, PropertyDefinition, SpreadElement, Statement, Super, SwitchCase, TemplateElement } from 'oxc-parser'
import { walk as _walk } from 'estree-walker'
import type { Node, SyncHandler } from 'estree-walker'
import type { Program as ESTreeProgram } from 'estree'
import { parse } from 'acorn'
import type { Program } from 'acorn'
import type { SyncHandler } from 'estree-walker'
import type { Node as ESTreeNode, Program as ESTreeProgram, ModuleSpecifier } from 'estree'
export type { Node }
/** estree also has AssignmentProperty, Identifier and Literal as possible node types */
export type Node = Declaration | Expression | ClassBody | CatchClause | MethodDefinition | ModuleDeclaration | ModuleSpecifier | Pattern | PrivateIdentifier | Program | SpreadElement | Statement | Super | SwitchCase | TemplateElement | ObjectProperty | PropertyDefinition
type WithLocations<T> = T & { start: number, end: number }
type WalkerCallback = (this: ThisParameterType<SyncHandler>, node: WithLocations<Node>, parent: WithLocations<Node> | null, ctx: { key: string | number | symbol | null | undefined, index: number | null | undefined, ast: Program | Node }) => void
type InferThis<T extends (...args: any[]) => any> = T extends (this: infer U, ...args: infer A) => any ? U : unknown
type WalkerCallback = (this: InferThis<SyncHandler>, node: Node, parent: Node | null, ctx: { key: string | number | symbol | null | undefined, index: number | null | undefined, ast: Program | Node }) => void
export function walk (ast: Program | Node, callback: { enter?: WalkerCallback, leave?: WalkerCallback }) {
return _walk(ast as unknown as ESTreeProgram | Node, {
return _walk(ast as unknown as ESTreeProgram | ESTreeNode, {
enter (node, parent, key, index) {
callback.enter?.call(this, node as WithLocations<Node>, parent as WithLocations<Node> | null, { key, index, ast })
callback.enter?.call(this, node as Node, parent as Node | null, { key, index, ast })
},
leave (node, parent, key, index) {
callback.leave?.call(this, node as WithLocations<Node>, parent as WithLocations<Node> | null, { key, index, ast })
callback.leave?.call(this, node as Node, parent as Node | null, { key, index, ast })
},
}) as Program | Node | null
})
}
export function parseAndWalk (code: string, sourceFilename: string, callback: WalkerCallback): Program
export function parseAndWalk (code: string, sourceFilename: string, object: { enter?: WalkerCallback, leave?: WalkerCallback }): Program
export function parseAndWalk (code: string, _sourceFilename: string, callback: { enter?: WalkerCallback, leave?: WalkerCallback } | WalkerCallback) {
const ast = parse (code, { sourceType: 'module', ecmaVersion: 'latest', locations: true })
export function parseAndWalk (code: string, sourceFilename: string, callback: { enter?: WalkerCallback, leave?: WalkerCallback } | WalkerCallback) {
const ast = parseSync(code, { sourceType: 'module', sourceFilename }).program
walk(ast, typeof callback === 'function' ? { enter: callback } : callback)
return ast
}
type WithLocations<T> = T & { start: number, end: number }
export function withLocations<T> (node: T): WithLocations<T> {
return node as WithLocations<T>
}

View File

@ -379,7 +379,7 @@ export default defineNuxtModule({
const glob = pageToGlobMap[path]
const code = path in nuxt.vfs ? nuxt.vfs[path]! : await readFile(path!, 'utf-8')
try {
const extractedRule = await extractRouteRules(code, path)
const extractedRule = extractRouteRules(code, path)
if (extractedRule) {
if (!glob) {
const relativePath = relative(nuxt.options.srcDir, path)

View File

@ -3,12 +3,11 @@ import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import type { StaticImport } from 'mlly'
import { findExports, findStaticImports, parseStaticImport } from 'mlly'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { isAbsolute } from 'pathe'
import { logger } from '@nuxt/kit'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { parseAndWalk, walk } from '../../core/utils/parse'
interface PageMetaPluginOptions {
dev?: boolean
@ -116,7 +115,7 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return }
const meta = withLocations(node.arguments[0])
const meta = node.arguments[0]
if (!meta) { return }

View File

@ -1,5 +1,4 @@
import { runInNewContext } from 'node:vm'
import { transform } from 'esbuild'
import type { NuxtPage } from '@nuxt/schema'
import type { NitroRouteConfig } from 'nitro/types'
import { normalize } from 'pathe'
@ -11,7 +10,7 @@ import { extractScriptContent, pathToNitroGlob } from './utils'
const ROUTE_RULE_RE = /\bdefineRouteRules\(/
const ruleCache: Record<string, NitroRouteConfig | null> = {}
export async function extractRouteRules (code: string, path: string): Promise<NitroRouteConfig | null> {
export function extractRouteRules (code: string, path: string): NitroRouteConfig | null {
if (code in ruleCache) {
return ruleCache[code] || null
}
@ -27,12 +26,10 @@ export async function extractRouteRules (code: string, path: string): Promise<Ni
code = script?.code || code
const js = await transform(code, { loader: script?.loader || 'ts' })
parseAndWalk(js.code, 'file.' + (script?.loader || 'ts'), (node) => {
parseAndWalk(code, 'file.' + (script?.loader || 'ts'), (node) => {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (node.callee.name === 'defineRouteRules') {
const rulesString = js.code.slice(node.start, node.end)
const rulesString = code.slice(node.start, node.end)
try {
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
} catch {

View File

@ -7,8 +7,7 @@ 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 type { Property } from 'estree'
import type { ObjectProperty } from 'oxc-parser'
import type { NuxtPage } from 'nuxt/schema'
import { parseAndWalk } from '../core/utils/parse'
@ -166,7 +165,7 @@ export async function augmentPages (routes: NuxtPage[], vfs: Record<string, stri
for (const route of routes) {
if (route.file && !ctx.pagesToSkip?.has(route.file)) {
const fileContent = route.file in vfs ? vfs[route.file]! : fs.readFileSync(await resolvePath(route.file), 'utf-8')
const routeMeta = await getRouteMeta(fileContent, route.file, ctx.extraExtractionKeys)
const routeMeta = getRouteMeta(fileContent, route.file, ctx.extraExtractionKeys)
if (route.meta) {
routeMeta.meta = { ...routeMeta.meta, ...route.meta }
}
@ -203,7 +202,7 @@ const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const
const pageContentsCache: Record<string, string> = {}
const metaCache: Record<string, Partial<Record<keyof NuxtPage, any>>> = {}
export async function getRouteMeta (contents: string, absolutePath: string, extraExtractionKeys: string[] = []): Promise<Partial<Record<keyof NuxtPage, any>>> {
export function getRouteMeta (contents: string, absolutePath: string, extraExtractionKeys: string[] = []): Partial<Record<keyof NuxtPage, any>> {
// set/update pageContentsCache, invalidate metaCache on cache mismatch
if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) {
pageContentsCache[absolutePath] = contents
@ -230,13 +229,11 @@ export async function getRouteMeta (contents: string, absolutePath: string, extr
continue
}
const js = await transform(script.code, { loader: script.loader })
const dynamicProperties = new Set<keyof NuxtPage>()
let foundMeta = false
parseAndWalk(js.code, absolutePath.replace(/\.\w+$/, '.' + script.loader), (node) => {
parseAndWalk(script.code, absolutePath.replace(/\.\w+$/, '.' + script.loader), (node) => {
if (foundMeta) { return }
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
@ -246,11 +243,11 @@ export async function getRouteMeta (contents: string, absolutePath: string, extr
if (pageMetaArgument?.type !== 'ObjectExpression') { return }
for (const key of extractionKeys) {
const property = pageMetaArgument.properties.find((property): property is Property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === key)
const property = pageMetaArgument.properties.find((property): property is ObjectProperty => property.type === 'ObjectProperty' && property.key.type === 'Identifier' && property.key.name === key)
if (!property) { continue }
if (property.value.type === 'ObjectExpression') {
const valueString = js.code.slice(property.value.range![0], property.value.range![1])
const valueString = script.code.slice(property.value.start, property.value.end)
try {
extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {}))
} catch {
@ -286,7 +283,7 @@ export async function getRouteMeta (contents: string, absolutePath: string, extr
}
for (const property of pageMetaArgument.properties) {
if (property.type !== 'Property') {
if (property.type !== 'ObjectProperty') {
continue
}
const isIdentifierOrLiteral = property.key.type === 'Literal' || property.key.type === 'Identifier'

View File

@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Component } from '@nuxt/schema'
import { compileScript, parse } from '@vue/compiler-sfc'
import * as Parser from 'acorn'
import { ComponentNamePlugin } from '../src/components/plugins/component-names'
@ -22,14 +21,7 @@ onMounted(() => {
</script>
`
const res = compileScript(parse(sfc).descriptor, { id: 'test.vue' })
const { code } = transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, res.content, components[0].filePath) ?? {}
const { code } = transformPlugin.transform(res.content, components[0].filePath) ?? {}
expect(code?.trim()).toMatchInlineSnapshot(`
"export default Object.assign({
setup(__props, { expose: __expose }) {

View File

@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import * as Parser from 'acorn'
import { ComposableKeysPlugin, detectImportNames } from '../src/core/plugins/composable-keys'
@ -40,14 +39,7 @@ describe('composable keys plugin', () => {
import { useAsyncData } from '#app'
useAsyncData(() => {})
`
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`
expect(transformPlugin.transform(code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`
"import { useAsyncData } from '#app'
useAsyncData(() => {}, '$yXewDLZblH')"
`)
@ -55,14 +47,7 @@ useAsyncData(() => {})
it('should not add hash when one exists', () => {
const code = `useAsyncData(() => {}, 'foo')`
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`undefined`)
expect(transformPlugin.transform(code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`undefined`)
})
it('should not add hash composables is imported from somewhere else', () => {
@ -70,13 +55,6 @@ useAsyncData(() => {})
const useAsyncData = () => {}
useAsyncData(() => {})
`
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`undefined`)
expect(transformPlugin.transform(code, 'plugin.ts')?.code.trim()).toMatchInlineSnapshot(`undefined`)
})
})

View File

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compileScript, parse } from '@vue/compiler-sfc'
import * as Parser from 'acorn'
import { PageMetaPlugin } from '../src/pages/plugins/page-meta'
import { getRouteMeta, normalizeRoutes } from '../src/pages/utils'
@ -9,22 +8,22 @@ import type { NuxtPage } from '../schema'
const filePath = '/app/pages/index.vue'
describe('page metadata', () => {
it('should not extract metadata from empty files', async () => {
expect(await getRouteMeta('', filePath)).toEqual({})
expect(await getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({})
it('should not extract metadata from empty files', () => {
expect(getRouteMeta('', filePath)).toEqual({})
expect(getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({})
})
it('should extract metadata from JS/JSX files', async () => {
it('should extract metadata from JS/JSX files', () => {
const fileContents = `definePageMeta({ name: 'bar' })`
for (const ext of ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']) {
const meta = await getRouteMeta(fileContents, `/app/pages/index.${ext}`)
const meta = getRouteMeta(fileContents, `/app/pages/index.${ext}`)
expect(meta).toStrictEqual({
name: 'bar',
})
}
})
it('should parse JSX files', async () => {
it('should parse JSX files', () => {
const fileContents = `
export default {
setup () {
@ -33,14 +32,13 @@ export default {
}
}
`
const meta = await getRouteMeta(fileContents, `/app/pages/index.jsx`)
const meta = getRouteMeta(fileContents, `/app/pages/index.jsx`)
expect(meta).toStrictEqual({
name: 'bar',
})
})
// TODO: https://github.com/nuxt/nuxt/pull/30066
it.todo('should handle experimental decorators', async () => {
it('should handle experimental decorators', async () => {
const fileContents = `
<script setup lang="ts">
function something (_method: () => unknown) {
@ -69,8 +67,37 @@ definePageMeta({ name: 'bar' })
expect(meta === await getRouteMeta('<template><div>Hi</div></template>' + fileContents, filePath)).toBeFalsy()
})
it('should extract serialisable metadata', async () => {
const meta = await getRouteMeta(`
it('should handle experimental decorators', () => {
const fileContents = `
<script setup lang="ts">
function something (_method: () => unknown) {
return () => 'decorated'
}
class SomeClass {
@something
public someMethod () {
return 'initial'
}
}
definePageMeta({ name: 'bar' })
</script>
`
const meta = getRouteMeta(fileContents, `/app/pages/index.vue`)
expect(meta).toStrictEqual({
name: 'bar',
})
})
it('should use and invalidate cache', () => {
const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>`
const meta = getRouteMeta(fileContents, filePath)
expect(meta === getRouteMeta(fileContents, filePath)).toBeTruthy()
expect(meta === getRouteMeta(fileContents, '/app/pages/other.vue')).toBeFalsy()
expect(meta === getRouteMeta('<template><div>Hi</div></template>' + fileContents, filePath)).toBeFalsy()
})
it('should extract serialisable metadata', () => {
const meta = getRouteMeta(`
<script setup>
definePageMeta({
name: 'some-custom-name',
@ -99,8 +126,8 @@ definePageMeta({ name: 'bar' })
`)
})
it('should extract serialisable metadata from files with multiple blocks', async () => {
const meta = await getRouteMeta(`
it('should extract serialisable metadata from files with multiple blocks', () => {
const meta = getRouteMeta(`
<script lang="ts">
export default {
name: 'thing'
@ -134,8 +161,8 @@ definePageMeta({ name: 'bar' })
`)
})
it('should extract serialisable metadata in options api', async () => {
const meta = await getRouteMeta(`
it('should extract serialisable metadata in options api', () => {
const meta = getRouteMeta(`
<script>
export default {
setup() {
@ -162,8 +189,8 @@ definePageMeta({ name: 'bar' })
`)
})
it('should extract serialisable metadata all quoted', async () => {
const meta = await getRouteMeta(`
it('should extract serialisable metadata all quoted', () => {
const meta = getRouteMeta(`
<script setup>
definePageMeta({
"otherValue": {
@ -204,9 +231,9 @@ definePageMeta({ name: 'bar' })
})
describe('normalizeRoutes', () => {
it('should produce valid route objects when used with extracted meta', async () => {
it('should produce valid route objects when used with extracted meta', () => {
const page: NuxtPage = { path: '/', file: filePath }
Object.assign(page, await getRouteMeta(`
Object.assign(page, getRouteMeta(`
<script setup>
definePageMeta({
name: 'some-custom-name',
@ -295,14 +322,7 @@ definePageMeta({
</script>
`
const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' })
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(`
expect(transformPlugin.transform(res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(`
"const __nuxt_page_meta = {
name: 'hi',
other: 'value'

View File

@ -1,5 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import * as Parser from 'acorn'
import { RemovePluginMetadataPlugin, extractMetadata } from '../src/core/plugins/plugin-metadata'
import { checkForCircularDependencies } from '../src/core/app'
@ -21,7 +20,7 @@ describe('plugin-metadata', () => {
'export default defineNuxtPlugin({',
...obj.map(([key, value]) => `${key}: ${typeof value === 'function' ? value.toString().replace('"[JSX]"', '() => <span>JSX</span>') : JSON.stringify(value)},`),
'})',
].join('\n'), 'tsx')
].join('\n'), 'file.tsx')
expect(meta).toEqual({
'name': 'test',
@ -40,14 +39,7 @@ describe('plugin-metadata', () => {
'export const plugin = {}',
]
for (const plugin of invalidPlugins) {
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, plugin, 'my-plugin.mjs').code).toBe('export default () => {}')
expect(transformPlugin.transform(plugin, 'my-plugin.mjs').code).toBe('export default () => {}')
}
})
@ -59,14 +51,7 @@ describe('plugin-metadata', () => {
setup: () => {},
}, { order: 10, name: test })
`
expect(transformPlugin.transform.call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, plugin, 'my-plugin.mjs').code).toMatchInlineSnapshot(`
expect(transformPlugin.transform(plugin, 'my-plugin.mjs').code).toMatchInlineSnapshot(`
"
export default defineNuxtPlugin({
setup: () => {},

View File

@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'
import { extractRouteRules } from '../src/pages/route-rules'
describe('route-rules', () => {
it('should extract route rules from pages', async () => {
it('should extract route rules from pages', () => {
for (const [path, code] of Object.entries(examples)) {
const result = await extractRouteRules(code, path)
const result = extractRouteRules(code, path)
expect(result).toStrictEqual({
'prerender': true,
@ -33,7 +33,6 @@ defineRouteRules({
`,
// vue component with a normal script block, and defineRouteRules ambiently
'component.vue': `
<script>
defineRouteRules({
prerender: true
@ -43,14 +42,14 @@ export default {
}
</script>
`,
// TODO: JS component with defineRouteRules within a setup function
// 'component.ts': `
// export default {
// setup() {
// defineRouteRules({
// prerender: true
// })
// }
// }
// `,
// JS component with defineRouteRules within a setup function
'component.ts': `
export default {
setup() {
defineRouteRules({
prerender: true
})
}
}
`,
}

View File

@ -3,7 +3,6 @@ import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import * as VueCompilerSFC from 'vue/compiler-sfc'
import type { Plugin } from 'vite'
import * as Parser from 'acorn'
import type { Options } from '@vitejs/plugin-vue'
import _vuePlugin from '@vitejs/plugin-vue'
import { TreeShakeTemplatePlugin } from '../src/components/plugins/tree-shake'
@ -56,14 +55,7 @@ const treeshakeTemplatePlugin = TreeShakeTemplatePlugin({
const treeshake = async (source: string): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const result = await (treeshakeTemplatePlugin.transform! as Function).call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, source)
const result = await (treeshakeTemplatePlugin.transform! as Function)(source)
return typeof result === 'string' ? result : result?.code
}

View File

@ -305,9 +305,6 @@ importers:
'@vue/shared':
specifier: 3.5.13
version: 3.5.13
acorn:
specifier: 8.14.0
version: 8.14.0
c12:
specifier: 2.0.1
version: 2.0.1(magicast@0.3.5)
@ -392,6 +389,9 @@ importers:
ohash:
specifier: 1.1.4
version: 1.1.4
oxc-parser:
specifier: ^0.38.0
version: 0.38.0
pathe:
specifier: ^1.1.2
version: 1.1.2
@ -2191,6 +2191,49 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@oxc-parser/binding-darwin-arm64@0.38.0':
resolution: {integrity: sha512-yMDY/KrF2gEEpVIzaVWuO1savy303onvhg96a+1mUfLhgH9lvfZZyus4/LVokRHYCCy+6ey/1ytyilVnSeTDYA==}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.38.0':
resolution: {integrity: sha512-OD8PQT5nAdNCbmzMVfafIQi9/yxSvfEH5fpLXPKVTCtAEc/d5aX6JpipjW231/3TF8mjXO1y3xZM1xKGXJEk/A==}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-linux-arm64-gnu@0.38.0':
resolution: {integrity: sha512-REfFdTk+cdIC8etE8v0CoZtVObKIiymFsPkQTYca2V2ZwJ9/SgUr6NNNjLJdkGOipwjBk8d2cyOZzNDIMfzmqw==}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-arm64-musl@0.38.0':
resolution: {integrity: sha512-tSDRTahOoT4KDS0dicDEFgBH2nEDqNmqFG+N6PS2LrExYEDS01noRIxMFpx2Sz22a+6ZnvkfVmStp6VK+9fpBQ==}
cpu: [arm64]
os: [linux]
'@oxc-parser/binding-linux-x64-gnu@0.38.0':
resolution: {integrity: sha512-qc6NdCzcCAxCK5KVde2ULq+7cyiJwrMV/1yozSUqvu26yme56Es3rWHFuPToxrhw+2olvc93AX+PVYMy3DqrGw==}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-linux-x64-musl@0.38.0':
resolution: {integrity: sha512-x/9XyivlMjnQ7gofNK6u+L+BMdwKf/GuLJ0vrmrWolKGR9CBSzZRwZfTw+cG0T7U6Y6CN36tSqijA0eTo3U8zw==}
cpu: [x64]
os: [linux]
'@oxc-parser/binding-win32-arm64-msvc@0.38.0':
resolution: {integrity: sha512-SUlt+MUid0VUL5cLbbZzVGA9xNoqLl29xF1ueTeDt/BdQyrLELjTxoGtgisF6blAD6Rj2JqPtnfk+CT7GBwL2w==}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.38.0':
resolution: {integrity: sha512-l/nFJutJ+zZTGj7ayL22r+og8g6dOC/47fSlsyl+KQXsmAFDStaARioHP9g+von/0bS23LX/9Eo8YeK5kFc7KA==}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.38.0':
resolution: {integrity: sha512-WjRra3cmQt/VPRTwiVuYITm6RNr4SjwYeVZkT0oPn1M0Li2caILj1mlRELhHXad4nLAlnfliH5daHFKjQ9d3jQ==}
'@parcel/watcher-android-arm64@2.5.0':
resolution: {integrity: sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==}
engines: {node: '>= 10.0.0'}
@ -2624,8 +2667,8 @@ packages:
engines: {node: '>=8.10'}
hasBin: true
'@stripe/stripe-js@4.8.0':
resolution: {integrity: sha512-+4Cb0bVHlV4BJXxkJ3cCLSLuWxm3pXKtgcRacox146EuugjCzRRII5T5gUMgL4HpzrBLVwVxjKaZqntNWAXawQ==}
'@stripe/stripe-js@4.9.0':
resolution: {integrity: sha512-tMPZQZZXGWyNX7hbgenq+1xEj2oigJ54XddbtSX36VedoKsPBq7dxwRXu4Xd5FdpT3JDyyDtnmvYkaSnH1yHTQ==}
engines: {node: '>=12.16'}
'@stylistic/eslint-plugin@2.11.0':
@ -5915,6 +5958,9 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
oxc-parser@0.38.0:
resolution: {integrity: sha512-w/cUL64wDb72gaBoOnvodKgHhNF9pDjb2b8gPkDKJDTvIvlbcE9XGDT3cnXOP4N3XCMrRT4MC23bCHGb3gCFSQ==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@ -8758,7 +8804,7 @@ snapshots:
'@nuxt/devtools-kit': 1.6.1(vite@6.0.1(@types/node@22.10.1)(jiti@2.4.0)(sass@1.78.0)(terser@5.32.0)(tsx@4.19.1)(yaml@2.5.1))
'@nuxt/devtools-ui-kit': 1.5.1(@nuxt/devtools@1.6.1(rollup@4.27.4)(vite@6.0.1(@types/node@22.10.1)(jiti@2.4.0)(sass@1.78.0)(terser@5.32.0)(tsx@4.19.1)(yaml@2.5.1))(vue@3.5.13(typescript@5.6.3)))(@unocss/webpack@0.62.4(rollup@4.27.4)(webpack@5.96.1(esbuild@0.24.0)))(@vue/compiler-core@3.5.13)(change-case@5.4.4)(nuxt@packages+nuxt)(postcss@8.4.49)(rollup@4.27.4)(vite@6.0.1(@types/node@22.10.1)(jiti@2.4.0)(sass@1.78.0)(terser@5.32.0)(tsx@4.19.1)(yaml@2.5.1))(vue@3.5.13(typescript@5.6.3))(webpack@5.96.1(esbuild@0.24.0))
'@nuxt/kit': link:packages/kit
'@stripe/stripe-js': 4.8.0
'@stripe/stripe-js': 4.9.0
'@types/google.maps': 3.58.1
'@types/vimeo__player': 2.18.3
'@types/youtube': 0.1.0
@ -8923,6 +8969,32 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@oxc-parser/binding-darwin-arm64@0.38.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.38.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.38.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.38.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.38.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.38.0':
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.38.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.38.0':
optional: true
'@oxc-project/types@0.38.0': {}
'@parcel/watcher-android-arm64@2.5.0':
optional: true
@ -9343,7 +9415,7 @@ snapshots:
ignore: 5.3.2
p-map: 4.0.0
'@stripe/stripe-js@4.8.0': {}
'@stripe/stripe-js@4.9.0': {}
'@stylistic/eslint-plugin@2.11.0(eslint@9.15.0(jiti@2.4.0))(typescript@5.6.3)':
dependencies:
@ -13582,6 +13654,19 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
oxc-parser@0.38.0:
dependencies:
'@oxc-project/types': 0.38.0
optionalDependencies:
'@oxc-parser/binding-darwin-arm64': 0.38.0
'@oxc-parser/binding-darwin-x64': 0.38.0
'@oxc-parser/binding-linux-arm64-gnu': 0.38.0
'@oxc-parser/binding-linux-arm64-musl': 0.38.0
'@oxc-parser/binding-linux-x64-gnu': 0.38.0
'@oxc-parser/binding-linux-x64-musl': 0.38.0
'@oxc-parser/binding-win32-arm64-msvc': 0.38.0
'@oxc-parser/binding-win32-x64-msvc': 0.38.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0