refactor(nuxt): simplify and improve core plugins that parse ast (#30088)

This commit is contained in:
Daniel Roe 2024-11-28 16:34:02 +00:00 committed by GitHub
parent a60d7fd51c
commit 3cb8b9fcb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 637 additions and 379 deletions

View File

@ -1,7 +1,8 @@
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import type { Component } from 'nuxt/schema'
import type { Program } from 'acorn'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { SX_RE, isVue } from '../../core/utils'
interface NameDevPluginOptions {
@ -37,12 +38,15 @@ export const ComponentNamePlugin = (options: NameDevPluginOptions) => createUnpl
// Without setup function, vue compiler does not generate __name
if (!s.hasChanged()) {
const ast = this.parse(code) as Program
const exportDefault = ast.body.find(node => node.type === 'ExportDefaultDeclaration')
if (exportDefault) {
const { start, end } = exportDefault.declaration
parseAndWalk(code, id, function (node) {
if (node.type !== 'ExportDefaultDeclaration') {
return
}
const { start, end } = withLocations(node.declaration)
s.overwrite(start, end, `Object.assign(${code.slice(start, end)}, { __name: ${JSON.stringify(component.pascalName)} })`)
}
this.skip()
})
}
if (s.hasChanged()) {

View File

@ -1,11 +1,14 @@
import { pathToFileURL } from 'node:url'
import { parseURL } from 'ufo'
import MagicString from 'magic-string'
import { walk } from 'estree-walker'
import type { AssignmentProperty, CallExpression, Identifier, Literal, MemberExpression, Node, ObjectExpression, Pattern, Program, Property, ReturnStatement, VariableDeclaration } from 'estree'
import type { AssignmentProperty, CallExpression, ObjectExpression, Pattern, Property, ReturnStatement, VariableDeclaration } from 'estree'
import type { Program } from 'acorn'
import { createUnplugin } from 'unplugin'
import type { Component } from '@nuxt/schema'
import { resolve } from 'pathe'
import { parseAndWalk, walk, withLocations } from '../../core/utils/parse'
import type { Node } from '../../core/utils/parse'
import { distDir } from '../../dirs'
interface TreeShakeTemplatePluginOptions {
@ -13,12 +16,9 @@ interface TreeShakeTemplatePluginOptions {
getComponents (): Component[]
}
type AcornNode<N extends Node> = N & { start: number, end: number }
const SSR_RENDER_RE = /ssrRenderComponent/
const PLACEHOLDER_EXACT_RE = /^(?:fallback|placeholder)$/
const CLIENT_ONLY_NAME_RE = /^(?:_unref\()?(?:_component_)?(?:Lazy|lazy_)?(?:client_only|ClientOnly\)?)$/
const PARSER_OPTIONS = { sourceType: 'module', ecmaVersion: 'latest' }
export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions) => createUnplugin(() => {
const regexpMap = new WeakMap<Component[], [RegExp, RegExp, string[]]>()
@ -29,7 +29,7 @@ export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions)
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
return pathname.endsWith('.vue')
},
transform (code) {
transform (code, id) {
const components = options.getComponents()
if (!regexpMap.has(components)) {
@ -47,62 +47,55 @@ export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions)
const [COMPONENTS_RE, COMPONENTS_IDENTIFIERS_RE] = regexpMap.get(components)!
if (!COMPONENTS_RE.test(code)) { return }
const codeAst = this.parse(code, PARSER_OPTIONS) as AcornNode<Program>
const componentsToRemoveSet = new Set<string>()
// remove client only components or components called in ClientOnly default slot
walk(codeAst, {
enter: (_node) => {
const node = _node as AcornNode<Node>
if (isSsrRender(node)) {
const [componentCall, _, children] = node.arguments
if (!componentCall) { return }
const ast = parseAndWalk(code, id, (node) => {
if (!isSsrRender(node)) {
return
}
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
const componentName = getComponentName(node)
const isClientComponent = COMPONENTS_IDENTIFIERS_RE.test(componentName)
const isClientOnlyComponent = CLIENT_ONLY_NAME_RE.test(componentName)
const [componentCall, _, children] = node.arguments
if (!componentCall) { return }
if (isClientComponent && children?.type === 'ObjectExpression') {
const slotsToRemove = isClientOnlyComponent ? children.properties.filter(prop => prop.type === 'Property' && prop.key.type === 'Identifier' && !PLACEHOLDER_EXACT_RE.test(prop.key.name)) as AcornNode<Property>[] : children.properties as AcornNode<Property>[]
if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') {
const componentName = getComponentName(node)
if (!componentName || !COMPONENTS_IDENTIFIERS_RE.test(componentName) || children?.type !== 'ObjectExpression') { return }
for (const slot of slotsToRemove) {
s.remove(slot.start, slot.end + 1)
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
const currentCodeAst = this.parse(s.toString(), PARSER_OPTIONS) as Node
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[]
walk(this.parse(removedCode, PARSER_OPTIONS) as Node, {
enter: (_node) => {
const node = _node as AcornNode<CallExpression>
if (isSsrRender(node)) {
const name = getComponentName(node)
for (const _slot of slotsToRemove) {
const slot = withLocations(_slot)
s.remove(slot.start, slot.end + 1)
const removedCode = `({${code.slice(slot.start, slot.end + 1)}})`
const currentState = s.toString()
// detect if the component is called else where
const nameToRemove = isComponentNotCalledInSetup(currentCodeAst, name)
if (nameToRemove) {
componentsToRemoveSet.add(nameToRemove)
}
}
},
})
}
parseAndWalk(removedCode, id, (node) => {
if (!isSsrRender(node)) { return }
const name = getComponentName(node)
if (!name) { return }
// detect if the component is called else where
const nameToRemove = isComponentNotCalledInSetup(currentState, id, name)
if (nameToRemove) {
componentsToRemoveSet.add(nameToRemove)
}
}
})
}
},
}
})
const componentsToRemove = [...componentsToRemoveSet]
const removedNodes = new WeakSet<AcornNode<Node>>()
const removedNodes = new WeakSet<Node>()
for (const componentName of componentsToRemove) {
// remove import declaration if it exists
removeImportDeclaration(codeAst, componentName, s)
removeImportDeclaration(ast, componentName, s)
// remove variable declaration
removeVariableDeclarator(codeAst, componentName, s, removedNodes)
removeVariableDeclarator(ast, componentName, s, removedNodes)
// remove from setup return statement
removeFromSetupReturn(codeAst, componentName, s)
removeFromSetupReturn(ast, componentName, s)
}
if (s.hasChanged()) {
@ -129,7 +122,7 @@ function removeFromSetupReturn (codeAst: Program, name: string, magicString: Mag
} else if (node.type === 'Property' && 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') {
if (node.value.body?.type === 'BlockStatement') {
const returnStatement = node.value.body.body.find(statement => statement.type === 'ReturnStatement') as ReturnStatement
if (returnStatement && returnStatement.argument?.type === 'ObjectExpression') {
// remove from return statement
@ -157,7 +150,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) {
magicString.remove((property as AcornNode<Property>).start, (property as AcornNode<Property>).end + 1)
const _property = withLocations(property)
magicString.remove(_property.start, _property.end + 1)
return true
}
}
@ -167,26 +161,26 @@ function removePropertyFromObject (node: ObjectExpression, name: string, magicSt
/**
* is the node a call expression ssrRenderComponent()
*/
function isSsrRender (node: Node): node is AcornNode<CallExpression> {
function isSsrRender (node: Node): node is CallExpression {
return node.type === 'CallExpression' && node.callee.type === 'Identifier' && SSR_RENDER_RE.test(node.callee.name)
}
function removeImportDeclaration (ast: Program, importName: string, magicString: MagicString): boolean {
for (const node of ast.body) {
if (node.type === 'ImportDeclaration') {
const specifier = node.specifiers.find(s => s.local.name === importName)
if (specifier) {
if (node.specifiers.length > 1) {
const specifierIndex = node.specifiers.findIndex(s => s.local.name === importName)
if (specifierIndex > -1) {
magicString.remove((node.specifiers[specifierIndex] as AcornNode<Node>).start, (node.specifiers[specifierIndex] as AcornNode<Node>).end + 1)
node.specifiers.splice(specifierIndex, 1)
}
} else {
magicString.remove((node as AcornNode<Node>).start, (node as AcornNode<Node>).end)
}
return true
if (node.type !== 'ImportDeclaration' || !node.specifiers) {
continue
}
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)
node.specifiers!.splice(specifierIndex, 1)
} else {
const specifier = withLocations(node)
magicString.remove(specifier.start, specifier.end)
}
return true
}
}
return false
@ -197,62 +191,61 @@ function removeImportDeclaration (ast: Program, importName: string, magicString:
* ImportDeclarations and VariableDeclarations are ignored
* return the name of the component if is not called
*/
function isComponentNotCalledInSetup (codeAst: Node, name: string): string | void {
if (name) {
let found = false
walk(codeAst, {
enter (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'))) {
// walk through the setup function node or the ssrRender function
walk(node, {
enter (node) {
if (found || node.type === 'VariableDeclaration') {
this.skip()
} else if (node.type === 'Identifier' && node.name === name) {
found = true
} else if (node.type === 'MemberExpression') {
// dev only with $setup or _ctx
found = (node.property.type === 'Literal' && node.property.value === name) || (node.property.type === 'Identifier' && node.property.name === name)
}
},
})
}
},
})
if (!found) { return name }
}
function isComponentNotCalledInSetup (code: string, id: string, name: string): string | void {
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'))) {
// walk through the setup function node or the ssrRender function
walk(node, {
enter (node) {
if (found || node.type === 'VariableDeclaration') {
this.skip()
} else if (node.type === 'Identifier' && node.name === name) {
found = true
} else if (node.type === 'MemberExpression') {
// dev only with $setup or _ctx
found = (node.property.type === 'Literal' && node.property.value === name) || (node.property.type === 'Identifier' && node.property.name === name)
}
},
})
}
})
if (!found) { return name }
}
/**
* retrieve the component identifier being used on ssrRender callExpression
* @param ssrRenderNode - ssrRender callExpression
*/
function getComponentName (ssrRenderNode: AcornNode<CallExpression>): string {
const componentCall = ssrRenderNode.arguments[0] as Identifier | MemberExpression | CallExpression
function getComponentName (ssrRenderNode: CallExpression): string | undefined {
const componentCall = ssrRenderNode.arguments[0]
if (!componentCall) { return }
if (componentCall.type === 'Identifier') {
return componentCall.name
} else if (componentCall.type === 'MemberExpression') {
return (componentCall.property as Literal).value as string
if (componentCall.property.type === 'Literal') {
return componentCall.property.value as string
}
} else if (componentCall.type === 'CallExpression') {
return getComponentName(componentCall)
}
return (componentCall.arguments[0] as Identifier).name
}
/**
* remove a variable declaration within the code
*/
function removeVariableDeclarator (codeAst: Node, name: string, magicString: MagicString, removedNodes: WeakSet<Node>): AcornNode<Node> | void {
function removeVariableDeclarator (codeAst: Program, name: string, magicString: MagicString, removedNodes: WeakSet<Node>): Node | void {
// remove variables
walk(codeAst, {
enter (node) {
if (node.type === 'VariableDeclaration') {
for (const declarator of node.declarations) {
const toRemove = findMatchingPatternToRemove(declarator.id as AcornNode<Pattern>, node as AcornNode<VariableDeclaration>, name, removedNodes)
if (toRemove) {
magicString.remove(toRemove.start, toRemove.end + 1)
removedNodes.add(toRemove)
return toRemove
}
if (node.type !== 'VariableDeclaration') { return }
for (const declarator of node.declarations) {
const toRemove = withLocations(findMatchingPatternToRemove(declarator.id, node, name, removedNodes))
if (toRemove) {
magicString.remove(toRemove.start, toRemove.end + 1)
removedNodes.add(toRemove)
}
}
},
@ -262,13 +255,13 @@ function removeVariableDeclarator (codeAst: Node, name: string, magicString: Mag
/**
* find the Pattern to remove which the identifier is equal to the name parameter.
*/
function findMatchingPatternToRemove (node: AcornNode<Pattern>, toRemoveIfMatched: AcornNode<Node>, name: string, removedNodeSet: WeakSet<Node>): AcornNode<Node> | undefined {
function findMatchingPatternToRemove (node: Pattern, toRemoveIfMatched: Node, name: string, removedNodeSet: WeakSet<Node>): Node | undefined {
if (node.type === 'Identifier') {
if (node.name === name) {
return toRemoveIfMatched
}
} else if (node.type === 'ArrayPattern') {
const elements = node.elements.filter((e): e is AcornNode<Pattern> => e !== null && !removedNodeSet.has(e))
const elements = node.elements.filter((e): e is Pattern => e !== null && !removedNodeSet.has(e))
for (const element of elements) {
const matched = findMatchingPatternToRemove(element, elements.length > 1 ? element : toRemoveIfMatched, name, removedNodeSet)
@ -278,12 +271,12 @@ function findMatchingPatternToRemove (node: AcornNode<Pattern>, toRemoveIfMatche
const properties = node.properties.filter((e): e is AssignmentProperty => e.type === 'Property' && !removedNodeSet.has(e))
for (const [index, property] of properties.entries()) {
let nodeToRemove = property as AcornNode<Node>
let nodeToRemove: Node = property
if (properties.length < 2) {
nodeToRemove = toRemoveIfMatched
}
const matched = findMatchingPatternToRemove(property.value as AcornNode<Pattern>, nodeToRemove as AcornNode<Node>, name, removedNodeSet)
const matched = findMatchingPatternToRemove(property.value, nodeToRemove, name, removedNodeSet)
if (matched) {
if (matched === property) {
properties.splice(index, 1)
@ -292,7 +285,7 @@ function findMatchingPatternToRemove (node: AcornNode<Pattern>, toRemoveIfMatche
}
}
} else if (node.type === 'AssignmentPattern') {
const matched = findMatchingPatternToRemove(node.left as AcornNode<Pattern>, toRemoveIfMatched, name, removedNodeSet)
const matched = findMatchingPatternToRemove(node.left, toRemoveIfMatched, name, removedNodeSet)
if (matched) { return matched }
}
}

View File

@ -42,7 +42,7 @@ import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata'
import { AsyncContextInjectionPlugin } from './plugins/async-context'
import { ComposableKeysPlugin } from './plugins/composable-keys'
import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports'
import { prehydrateTransformPlugin } from './plugins/prehydrate'
import { PrehydrateTransformPlugin } from './plugins/prehydrate'
import { VirtualFSPlugin } from './plugins/virtual'
export function createNuxt (options: NuxtOptions): Nuxt {
@ -283,7 +283,7 @@ async function initNuxt (nuxt: Nuxt) {
addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { server: false })
// Add transform for `onPrehydrate` lifecycle hook
addBuildPlugin(prehydrateTransformPlugin(nuxt))
addBuildPlugin(PrehydrateTransformPlugin({ sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client }))
if (nuxt.options.experimental.localLayerAliases) {
// Add layer aliasing support for ~, ~~, @ and @@ aliases

View File

@ -1,14 +1,14 @@
import { pathToFileURL } from 'node:url'
import { createUnplugin } from 'unplugin'
import { isAbsolute, relative } from 'pathe'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import type { CallExpression, Pattern } from 'estree'
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 { matchWithStringOrRegex } from '../utils/plugins'
interface ComposableKeysOptions {
@ -52,23 +52,18 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
const relativeID = isAbsolute(id) ? relative(options.rootDir, id) : id
const { pathname: relativePathname } = parseURL(relativeID)
const ast = this.parse(script, {
sourceType: 'module',
ecmaVersion: 'latest',
}) as Node
// To handle variables hoisting we need a pre-pass to collect variable and function declarations with scope info.
let scopeTracker = new ScopeTracker()
const varCollector = new ScopedVarsCollector()
walk(ast, {
enter (_node) {
if (_node.type === 'BlockStatement') {
const ast = parseAndWalk(script, id, {
enter (node) {
if (node.type === 'BlockStatement') {
scopeTracker.enterScope()
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 === 'FunctionDeclaration' && node.id) {
varCollector.addVar(node.id.name)
} else if (node.type === 'VariableDeclarator') {
varCollector.collect(node.id)
}
},
leave (_node) {
@ -81,13 +76,12 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn
scopeTracker = new ScopeTracker()
walk(ast, {
enter (_node) {
if (_node.type === 'BlockStatement') {
enter (node) {
if (node.type === 'BlockStatement') {
scopeTracker.enterScope()
}
if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return }
const node: CallExpression = _node as CallExpression
const name = 'name' in node.callee && node.callee.name
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
const name = node.callee.name
if (!name || !keyedFunctions.has(name) || node.arguments.length >= maxLength) { return }
imports = imports || detectImportNames(script, composableMeta)
@ -219,24 +213,23 @@ class ScopedVarsCollector {
return false
}
collect (n: Pattern) {
const t = n.type
if (t === 'Identifier') {
this.addVar(n.name)
} else if (t === 'RestElement') {
this.collect(n.argument)
} else if (t === 'AssignmentPattern') {
this.collect(n.left)
} else if (t === 'ArrayPattern') {
n.elements.forEach(e => e && this.collect(e))
} else if (t === 'ObjectPattern') {
n.properties.forEach((p) => {
if (p.type === 'RestElement') {
this.collect(p)
} else {
this.collect(p.value)
collect (pattern: Pattern) {
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') {
for (const element of pattern.elements) {
if (element) {
this.collect(element.type === 'RestElement' ? element.argument : element)
}
})
}
} else if (pattern.type === 'ObjectPattern') {
for (const property of pattern.properties) {
this.collect(property.type === 'RestElement' ? property.argument : property.value)
}
}
}
}

View File

@ -1,8 +1,5 @@
import type { CallExpression, Literal, Property, SpreadElement } from 'estree'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import type { Literal, Property, SpreadElement } from 'estree'
import { transform } from 'esbuild'
import { parse } from 'acorn'
import { defu } from 'defu'
import { findExports } from 'mlly'
import type { Nuxt } from '@nuxt/schema'
@ -11,6 +8,8 @@ import MagicString from 'magic-string'
import { normalize } from 'pathe'
import { logger } from '@nuxt/kit'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import type { ObjectPlugin, PluginMeta } from '#app'
const internalOrderMap = {
@ -47,36 +46,31 @@ export async function extractMetadata (code: string, loader = 'ts' as 'ts' | 'ts
return metaCache[code]
}
const js = await transform(code, { loader })
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 !== 'defineNuxtPlugin' && name !== 'definePayloadPlugin') { return }
parseAndWalk(js.code, `file.${loader}`, (node) => {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (name === 'definePayloadPlugin') {
meta.order = internalOrderMap['user-revivers']
const name = 'name' in node.callee && node.callee.name
if (name !== 'defineNuxtPlugin' && name !== 'definePayloadPlugin') { return }
if (name === 'definePayloadPlugin') {
meta.order = internalOrderMap['user-revivers']
}
const metaArg = node.arguments[1]
if (metaArg) {
if (metaArg.type !== 'ObjectExpression') {
throw new Error('Invalid plugin metadata')
}
meta = extractMetaFromObject(metaArg.properties)
}
const metaArg = node.arguments[1]
if (metaArg) {
if (metaArg.type !== 'ObjectExpression') {
throw new Error('Invalid plugin metadata')
}
meta = extractMetaFromObject(metaArg.properties)
}
const plugin = node.arguments[0]
if (plugin?.type === 'ObjectExpression') {
meta = defu(extractMetaFromObject(plugin.properties), meta)
}
const plugin = node.arguments[0]
if (plugin?.type === 'ObjectExpression') {
meta = defu(extractMetaFromObject(plugin.properties), meta)
}
meta.order = meta.order || orderMap[meta.enforce || 'default'] || orderMap.default
delete meta.enforce
},
meta.order = meta.order || orderMap[meta.enforce || 'default'] || orderMap.default
delete meta.enforce
})
metaCache[code] = meta
return meta as Omit<PluginMeta, 'enforce'>
@ -149,41 +143,33 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => {
const wrapperNames = new Set(['defineNuxtPlugin', 'definePayloadPlugin'])
try {
walk(this.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
}) as Node, {
enter (_node) {
if (_node.type === 'ImportSpecifier' && _node.imported.type === 'Identifier' && (_node.imported.name === 'defineNuxtPlugin' || _node.imported.name === 'definePayloadPlugin')) {
wrapperNames.add(_node.local.name)
}
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 || !wrapperNames.has(name)) { return }
wrapped = true
parseAndWalk(code, id, (node) => {
if (node.type === 'ImportSpecifier' && node.imported.type === 'Identifier' && (node.imported.name === 'defineNuxtPlugin' || node.imported.name === 'definePayloadPlugin')) {
wrapperNames.add(node.local.name)
}
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
// Remove metadata that already has been extracted
if (!('order' in plugin) && !('name' in plugin)) { return }
for (const [argIndex, _arg] of node.arguments.entries()) {
if (_arg.type !== 'ObjectExpression') { continue }
const name = 'name' in node.callee && node.callee.name
if (!name || !wrapperNames.has(name)) { return }
wrapped = true
const arg = _arg as typeof _arg & { start: number, end: number }
for (const [propertyIndex, _property] of arg.properties.entries()) {
if (_property.type === 'SpreadElement' || !('name' in _property.key)) { continue }
// Remove metadata that already has been extracted
if (!('order' in plugin) && !('name' in plugin)) { return }
for (const [argIndex, arg] of node.arguments.entries()) {
if (arg.type !== 'ObjectExpression') { continue }
const property = _property as typeof _property & { start: number, end: number }
const propertyKey = _property.key.name
if (propertyKey === 'order' || propertyKey === 'enforce' || propertyKey === 'name') {
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)
for (const [propertyIndex, property] of arg.properties.entries()) {
if (property.type === 'SpreadElement' || !('name' in property.key)) { continue }
s.remove(property.start, nextIndex)
}
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)
s.remove(withLocations(property).start, nextIndex)
}
}
},
}
})
} catch (e) {
logger.error(e)

View File

@ -1,16 +1,12 @@
import { transform } from 'esbuild'
import { parse } from 'acorn'
import { walk } from 'estree-walker'
import type { Node } from 'estree-walker'
import type { Nuxt } from '@nuxt/schema'
import { createUnplugin } from 'unplugin'
import type { SimpleCallExpression } from 'estree'
import MagicString from 'magic-string'
import { hash } from 'ohash'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import { isJS, isVue } from '../utils'
export function prehydrateTransformPlugin (nuxt: Nuxt) {
export function PrehydrateTransformPlugin (options: { sourcemap?: boolean } = {}) {
return createUnplugin(() => ({
name: 'nuxt:prehydrate-transform',
transformInclude (id) {
@ -22,33 +18,27 @@ export function prehydrateTransformPlugin (nuxt: Nuxt) {
const s = new MagicString(code)
const promises: Array<Promise<any>> = []
walk(parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
ranges: true,
}) as Node, {
enter (_node) {
if (_node.type !== 'CallExpression' || _node.callee.type !== 'Identifier') { return }
const node = _node as SimpleCallExpression & { start: number, end: number }
const name = 'name' in node.callee && node.callee.name
if (name === 'onPrehydrate') {
if (!node.arguments[0]) { return }
if (node.arguments[0].type !== 'ArrowFunctionExpression' && node.arguments[0].type !== 'FunctionExpression') { return }
parseAndWalk(code, id, (node) => {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') {
return
}
if (node.callee.name === 'onPrehydrate') {
const callback = withLocations(node.arguments[0])
if (!callback) { return }
if (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression') { return }
const needsAttr = node.arguments[0].params.length > 0
const { start, end } = node.arguments[0] as Node & { start: number, end: number }
const needsAttr = callback.params.length > 0
const p = transform(`forEach(${code.slice(start, end)})`, { loader: 'ts', minify: true })
promises.push(p.then(({ code: result }) => {
const cleaned = result.slice('forEach'.length).replace(/;\s+$/, '')
const args = [JSON.stringify(cleaned)]
if (needsAttr) {
args.push(JSON.stringify(hash(result)))
}
s.overwrite(start, end, args.join(', '))
}))
}
},
const p = transform(`forEach(${code.slice(callback.start, callback.end)})`, { loader: 'ts', minify: true })
promises.push(p.then(({ code: result }) => {
const cleaned = result.slice('forEach'.length).replace(/;\s+$/, '')
const args = [JSON.stringify(cleaned)]
if (needsAttr) {
args.push(JSON.stringify(hash(result)))
}
s.overwrite(callback.start, callback.end, args.join(', '))
}))
}
})
await Promise.all(promises).catch((e) => {
@ -58,7 +48,7 @@ export function prehydrateTransformPlugin (nuxt: Nuxt) {
if (s.hasChanged()) {
return {
code: s.toString(),
map: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client
map: options.sourcemap
? s.generateMap({ hires: true })
: undefined,
}

View File

@ -0,0 +1,33 @@
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'
export type { Node }
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
export function walk (ast: Program | Node, callback: { enter?: WalkerCallback, leave?: WalkerCallback }) {
return _walk(ast as unknown as ESTreeProgram | Node, {
enter (node, parent, key, index) {
callback.enter?.call(this, node as WithLocations<Node>, parent as WithLocations<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 })
},
}) 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 })
walk(ast, typeof callback === 'function' ? { enter: callback } : callback)
return ast
}
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)
const extractedRule = await extractRouteRules(code, path)
if (extractedRule) {
if (!glob) {
const relativePath = relative(nuxt.options.srcDir, path)

View File

@ -3,13 +3,13 @@ import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import type { StaticImport } from 'mlly'
import { findExports, findStaticImports, parseStaticImport } from 'mlly'
import type { CallExpression, Expression, Identifier } from 'estree'
import type { Node } from 'estree-walker'
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'
interface PageMetaPluginOptions {
dev?: boolean
sourcemap?: boolean
@ -36,7 +36,7 @@ if (import.meta.webpackHot) {
})
}`
export const PageMetaPlugin = (options: PageMetaPluginOptions) => createUnplugin(() => {
export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnplugin(() => {
return {
name: 'nuxt:pages-macros-transform',
enforce: 'post',
@ -112,45 +112,38 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions) => createUnplugin
}
}
walk(this.parse(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 !== 'definePageMeta') { return }
parseAndWalk(code, id, (node) => {
if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return }
if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return }
const meta = node.arguments[0] as Expression & { start: number, end: number }
const meta = withLocations(node.arguments[0])
let contents = `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : '')
if (!meta) { return }
function addImport (name: string | false) {
if (name && importMap.has(name)) {
const importValue = importMap.get(name)!.code
if (!addedImports.has(importValue)) {
contents = importMap.get(name)!.code + '\n' + contents
addedImports.add(importValue)
}
let contents = `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : '')
function addImport (name: string | false) {
if (name && importMap.has(name)) {
const importValue = importMap.get(name)!.code
if (!addedImports.has(importValue)) {
contents = importMap.get(name)!.code + '\n' + contents
addedImports.add(importValue)
}
}
}
walk(meta, {
enter (_node) {
if (_node.type === 'CallExpression') {
const node = _node as CallExpression & { start: number, end: number }
addImport('name' in node.callee && node.callee.name)
}
if (_node.type === 'Identifier') {
const node = _node as Identifier & { start: number, end: number }
addImport(node.name)
}
},
})
walk(meta, {
enter (node) {
if (node.type === 'CallExpression' && 'name' in node.callee) {
addImport(node.callee.name)
}
if (node.type === 'Identifier') {
addImport(node.name)
}
},
})
s.overwrite(0, code.length, contents)
},
s.overwrite(0, code.length, contents)
})
if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) {

View File

@ -1,48 +1,44 @@
import { runInNewContext } from 'node:vm'
import type { Node } from 'estree-walker'
import type { CallExpression } from 'estree'
import { walk } from 'estree-walker'
import { transform } from 'esbuild'
import { parse } from 'acorn'
import type { NuxtPage } from '@nuxt/schema'
import type { NitroRouteConfig } from 'nitro/types'
import { normalize } from 'pathe'
import { getLoader } from '../core/utils'
import { parseAndWalk } from '../core/utils/parse'
import { extractScriptContent, pathToNitroGlob } from './utils'
const ROUTE_RULE_RE = /\bdefineRouteRules\(/
const ruleCache: Record<string, NitroRouteConfig | null> = {}
export async function extractRouteRules (code: string): Promise<NitroRouteConfig | null> {
export async function extractRouteRules (code: string, path: string): Promise<NitroRouteConfig | null> {
if (code in ruleCache) {
return ruleCache[code] || null
}
if (!ROUTE_RULE_RE.test(code)) { return null }
let rule: NitroRouteConfig | null = null
const contents = extractScriptContent(code)
const loader = getLoader(path)
if (!loader) { return null }
const contents = loader === 'vue' ? extractScriptContent(code) : [{ code, loader }]
for (const script of contents) {
if (rule) { break }
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.')
}
parseAndWalk(js.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)
try {
rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {}))
} catch {
throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.')
}
},
}
})
}

View File

@ -8,11 +8,10 @@ import escapeRE from 'escape-string-regexp'
import { filename } from 'pathe/utils'
import { hash } from 'ohash'
import { transform } from 'esbuild'
import { parse } from 'acorn'
import { walk } from 'estree-walker'
import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree'
import type { Property } from 'estree'
import type { NuxtPage } from 'nuxt/schema'
import { parseAndWalk } from '../core/utils/parse'
import { getLoader, uniqueBy } from '../core/utils'
import { toArray } from '../utils'
@ -184,9 +183,9 @@ export async function augmentPages (routes: NuxtPage[], vfs: Record<string, stri
}
const SFC_SCRIPT_RE = /<script(?<attrs>[^>]*)>(?<content>[\s\S]*?)<\/script[^>]*>/gi
export function extractScriptContent (html: string) {
export function extractScriptContent (sfc: string) {
const contents: Array<{ loader: 'tsx' | 'ts', code: string }> = []
for (const match of html.matchAll(SFC_SCRIPT_RE)) {
for (const match of sfc.matchAll(SFC_SCRIPT_RE)) {
if (match?.groups?.content) {
contents.push({
loader: match.groups.attrs?.includes('tsx') ? 'tsx' : 'ts',
@ -222,7 +221,7 @@ export async function getRouteMeta (contents: string, absolutePath: string, extr
return {}
}
const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>>
const extractedMeta: Partial<Record<keyof NuxtPage, any>> = {}
const extractionKeys = new Set<keyof NuxtPage>([...defaultExtractionKeys, ...extraExtractionKeys as Array<keyof NuxtPage>])
@ -232,85 +231,79 @@ export async function getRouteMeta (contents: string, absolutePath: string, extr
}
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 dynamicProperties = new Set<keyof NuxtPage>()
let foundMeta = false
walk(ast, {
enter (node) {
if (foundMeta) { return }
parseAndWalk(js.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 }
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return }
foundMeta = true
const pageMetaArgument = ((node as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression
foundMeta = true
const pageMetaArgument = node.expression.arguments[0]
if (pageMetaArgument?.type !== 'ObjectExpression') { return }
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 }
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)
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: 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' && typeof property.value.value !== 'boolean')) {
console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`)
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
}
extractedMeta[key] = property.value.value
}
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 (!extractionKeys.has(name as keyof NuxtPage)) {
dynamicProperties.add('meta')
break
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 (dynamicProperties.size) {
extractedMeta.meta ??= {}
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
if (property.value.type !== 'Literal' || (typeof property.value.value !== 'string' && typeof property.value.value !== 'boolean')) {
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
}
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 (!extractionKeys.has(name as keyof NuxtPage)) {
dynamicProperties.add('meta')
break
}
}
if (dynamicProperties.size) {
extractedMeta.meta ??= {}
extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties
}
})
}

View File

@ -0,0 +1,50 @@
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'
describe('component names', () => {
const components = [{
filePath: 'test.ts',
pascalName: 'TestMe',
}] as [Component]
const transformPlugin = ComponentNamePlugin({ sourcemap: false, getComponents: () => components }).raw({}, {} as any) as { transform: (code: string, id: string) => { code: string } | null }
it('should add correct default component names', () => {
const sfc = `
<script setup>
onMounted(() => {
window.a = 32
})
</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) ?? {}
expect(code?.trim()).toMatchInlineSnapshot(`
"export default Object.assign({
setup(__props, { expose: __expose }) {
__expose();
onMounted(() => {
window.a = 32
})
const __returned__ = { }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}
}, { __name: "TestMe" })"
`)
})
})

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import * as Parser from 'acorn'
import { detectImportNames } from '../src/core/plugins/composable-keys'
import { ComposableKeysPlugin, detectImportNames } from '../src/core/plugins/composable-keys'
describe('detectImportNames', () => {
const keyedComposables = {
@ -25,3 +26,57 @@ describe('detectImportNames', () => {
`)
})
})
describe('composable keys plugin', () => {
const composables = [{
name: 'useAsyncData',
source: '#app',
argumentLength: 2,
}]
const transformPlugin = ComposableKeysPlugin({ sourcemap: false, rootDir: '/', composables }).raw({}, {} as any) as { transform: (code: string, id: string) => { code: string } | null }
it('should add keyed hash when there is none already provided', () => {
const code = `
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(`
"import { useAsyncData } from '#app'
useAsyncData(() => {}, '$yXewDLZblH')"
`)
})
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`)
})
it('should not add hash composables is imported from somewhere else', () => {
const code = `
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`)
})
})

View File

@ -1,4 +1,8 @@
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'
import type { NuxtPage } from '../schema'
@ -20,6 +24,43 @@ describe('page metadata', () => {
}
})
it('should parse JSX files', async () => {
const fileContents = `
export default {
setup () {
definePageMeta({ name: 'bar' })
return () => <div></div>
}
}
`
const meta = await 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 () => {
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 = await getRouteMeta(fileContents, `/app/pages/index.vue`)
expect(meta).toStrictEqual({
name: 'bar',
})
})
it('should use and invalidate cache', async () => {
const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>`
const meta = await getRouteMeta(fileContents, filePath)
@ -240,3 +281,33 @@ describe('normalizeRoutes', () => {
`)
})
})
describe('rewrite page meta', () => {
const transformPlugin = PageMetaPlugin().raw({}, {} as any) as { transform: (code: string, id: string) => { code: string } | null }
it('should extract metadata from vue components', () => {
const sfc = `
<script setup lang="ts">
definePageMeta({
name: 'hi',
other: 'value'
})
</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(`
"const __nuxt_page_meta = {
name: 'hi',
other: 'value'
}
export default __nuxt_page_meta"
`)
})
})

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { parse } from 'acorn'
import * as Parser from 'acorn'
import { RemovePluginMetadataPlugin, extractMetadata } from '../src/core/plugins/plugin-metadata'
import { checkForCircularDependencies } from '../src/core/app'
@ -40,7 +40,14 @@ describe('plugin-metadata', () => {
'export const plugin = {}',
]
for (const plugin of invalidPlugins) {
expect(transformPlugin.transform.call({ parse }, plugin, 'my-plugin.mjs').code).toBe('export default () => {}')
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 () => {}')
}
})
@ -52,7 +59,14 @@ describe('plugin-metadata', () => {
setup: () => {},
}, { order: 10, name: test })
`
expect(transformPlugin.transform.call({ parse }, plugin, 'my-plugin.mjs').code).toMatchInlineSnapshot(`
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(`
"
export default defineNuxtPlugin({
setup: () => {},

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { PrehydrateTransformPlugin } from '../src/core/plugins/prehydrate'
describe('prehydrate', () => {
const transformPlugin = PrehydrateTransformPlugin().raw({}, {} as any) as { transform: (code: string, id: string) => Promise<{ code: string } | null> }
it('should extract and minify code in onPrehydrate', async () => {
const snippet = `
onPrehydrate(() => {
console.log('hello world')
})
`
const snippet2 = `
export default {
async setup () {
onPrehydrate(() => {
console.log('hello world')
})
}
}
`
for (const item of [snippet, snippet2]) {
const { code } = await transformPlugin.transform(item, 'test.ts') ?? {}
expect(code).toContain(`onPrehydrate("(()=>{console.log(\\"hello world\\")})")`)
}
})
it('should add hash if required', async () => {
const snippet = `
onPrehydrate((attr) => {
console.log('hello world')
})
`
const { code } = await transformPlugin.transform(snippet, 'test.ts') ?? {}
expect(code?.trim()).toMatchInlineSnapshot(`"onPrehydrate("(o=>{console.log(\\"hello world\\")})", "rifMBArY0d")"`)
})
})

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { extractRouteRules } from '../src/pages/route-rules'
describe('route-rules', () => {
it('should extract route rules from pages', async () => {
for (const [path, code] of Object.entries(examples)) {
const result = await extractRouteRules(code, path)
expect(result).toStrictEqual({
'prerender': true,
})
}
})
})
const examples = {
// vue component with two script blocks
'app.vue': `
<template>
<div></div>
</template>
<script>
export default {}
</script>
<script setup lang="ts">
defineRouteRules({
prerender: true
})
</script>
`,
// vue component with a normal script block, and defineRouteRules ambiently
'component.vue': `
<script>
defineRouteRules({
prerender: true
})
export default {
setup() {}
}
</script>
`,
// TODO: JS component with defineRouteRules within a setup function
// 'component.ts': `
// export default {
// setup() {
// defineRouteRules({
// prerender: true
// })
// }
// }
// `,
}

View File

@ -3,7 +3,7 @@ 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 { Parser } from 'acorn'
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'
@ -81,16 +81,7 @@ async function SFCCompile (name: string, source: string, options: Options, ssr =
define: {},
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const result = await (plugin.transform! as Function).call({
parse: (code: string, opts: any = {}) => Parser.parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
...opts,
}),
}, source, name, {
ssr,
})
const result = await (plugin.transform! as Function)(source, name, { ssr })
return typeof result === 'string' ? result : result?.code
}