Merge branch 'main' into fix/21721-spa-loading

This commit is contained in:
Nikolay 2024-11-30 01:06:57 +07:00 committed by GitHub
commit 24a0253fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 939 additions and 698 deletions

View File

@ -46,59 +46,3 @@ For example, referencing an image file that will be processed if a build tool is
::note
Nuxt won't serve files in the [`assets/`](/docs/guide/directory-structure/assets) directory at a static URL like `/assets/my-file.png`. If you need a static URL, use the [`public/`](#public-directory) directory.
::
### Global Styles Imports
To globally insert statements in your Nuxt components styles, you can use the [`Vite`](/docs/api/nuxt-config#vite) option at your [`nuxt.config`](/docs/api/nuxt-config) file.
#### Example
In this example, there is a [sass partial](https://sass-lang.com/documentation/at-rules/use#partials) file containing color variables to be used by your Nuxt [pages](/docs/guide/directory-structure/pages) and [components](/docs/guide/directory-structure/components)
::code-group
```scss [assets/_colors.scss]
$primary: #49240F;
$secondary: #E4A79D;
```
```sass [assets/_colors.sass]
$primary: #49240F
$secondary: #E4A79D
```
::
In your `nuxt.config`
::code-group
```ts twoslash [SCSS]
export default defineNuxtConfig({
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "~/assets/_colors.scss" as *;'
}
}
}
}
})
```
```ts twoslash [SASS]
export default defineNuxtConfig({
vite: {
css: {
preprocessorOptions: {
sass: {
additionalData: '@use "~/assets/_colors.sass" as *\n'
}
}
}
}
})
```
::

View File

@ -1,5 +1,5 @@
---
title: 'Data fetching'
title: 'Data Fetching'
description: Nuxt provides composables to handle data fetching within your application.
navigation.icon: i-ph-plugs-connected
---

View File

@ -35,7 +35,7 @@ Then, run [`nuxi typecheck`](/docs/api/commands/typecheck) command to check your
npx nuxi typecheck
```
To enable type-checking at build time, you can also use the [`typescript.typeCheck`](/docs/api/nuxt-config#typecheck) option in your `nuxt.config` file:
To enable type-checking at build or development time, you can also use the [`typescript.typeCheck`](/docs/api/nuxt-config#typecheck) option in your `nuxt.config` file:
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({

View File

@ -363,6 +363,26 @@ Headers that are **not meant to be forwarded** will **not be included** in the r
`transfer-encoding`, `connection`, `keep-alive`, `upgrade`, `expect`, `host`, `accept`
::
### Awaiting Promises After Response
When handling server requests, you might need to perform asynchronous tasks that shouldn't block the response to the client (for example, caching and logging). You can use `event.waitUntil` to await a promise in the background without delaying the response.
The `event.waitUntil` method accepts a promise that will be awaited before the handler terminates, ensuring the task is completed even if the server would otherwise terminate the handler right after the response is sent. This integrates with runtime providers to leverage their native capabilities for handling asynchronous operations after the response is sent.
```ts [server/api/background-task.ts]
const timeConsumingBackgroundTask = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000))
};
export default eventHandler((event) => {
// schedule a background task without blocking the response
event.waitUntil(timeConsumingBackgroundTask())
// immediately send the response to the client
return 'done'
});
```
## Advanced Usage
### Nitro Config

View File

@ -395,6 +395,35 @@ In addition, any changes to files within `srcDir` will trigger a rebuild of the
A maximum of 10 cache tarballs are kept.
::
## extraPageMetaExtractionKeys
The `definePageMeta()` macro is a useful way to collect build-time meta about pages. Nuxt itself provides a set list of supported keys which is used to power some of the internal features such as redirects, page aliases and custom paths.
This option allows passing additional keys to extract from the page metadata when using `scanPageMeta`.
```vue
<script lang="ts" setup>
definePageMeta({
foo: 'bar'
})
</script>
```
```ts
export default defineNuxtConfig({
experimental: {
extraPageMetaExtractionKeys: ['foo'],
},
hooks: {
'pages:resolved' (ctx) {
// ✅ foo is available
},
},
})
```
This allows modules to access additional metadata from the page metadata in the build context. If you are using this within a module, it's recommended also to [augment the `NuxtPage` types with your keys](/docs/guide/directory-structure/pages#typing-custom-metadata).
## normalizeComponentNames
Ensure that auto-generated Vue component names match the full component name

View File

@ -30,7 +30,7 @@ If you're using a custom useFetch wrapper, do not await it in the composable, as
::
::note
`data`, `status` and `error` are Vue refs and they should be accessed with `.value` when used within the `<script setup>`, while `refresh`/`execute` and `clear` are plain functions..
`data`, `status`, and `error` are Vue refs, and they should be accessed with `.value` when used within the `<script setup>`, while `refresh`/`execute` and `clear` are plain functions.
::
Using the `query` option, you can add search parameters to your query. This option is extended from [unjs/ofetch](https://github.com/unjs/ofetch) and is using [unjs/ufo](https://github.com/unjs/ufo) to create the URL. Objects are automatically stringified.

View File

@ -39,7 +39,7 @@
"@nuxt/schema": "workspace:*",
"@nuxt/vite-builder": "workspace:*",
"@nuxt/webpack-builder": "workspace:*",
"@types/node": "22.10.0",
"@types/node": "22.10.1",
"@unhead/dom": "1.11.13",
"@unhead/schema": "1.11.13",
"@unhead/shared": "1.11.13",
@ -72,7 +72,7 @@
"@nuxt/test-utils": "3.14.4",
"@nuxt/webpack-builder": "workspace:*",
"@testing-library/vue": "8.1.0",
"@types/node": "22.10.0",
"@types/node": "22.10.1",
"@types/semver": "7.5.8",
"@unhead/schema": "1.11.13",
"@unhead/vue": "1.11.13",
@ -90,14 +90,14 @@
"eslint-plugin-perfectionist": "4.1.2",
"eslint-typegen": "0.3.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"happy-dom": "15.11.6",
"happy-dom": "15.11.7",
"jiti": "2.4.0",
"knip": "5.38.1",
"knip": "5.38.2",
"markdownlint-cli": "0.43.0",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nuxi": "3.15.0",
"nuxi": "3.16.0",
"nuxt": "workspace:*",
"nuxt-content-twoslash": "0.1.1",
"nuxt-content-twoslash": "0.1.2",
"ofetch": "1.4.1",
"pathe": "1.1.2",
"playwright-core": "1.49.0",

View File

@ -95,7 +95,7 @@
"mlly": "^1.7.3",
"nanotar": "^0.1.1",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nuxi": "^3.15.0",
"nuxi": "^3.16.0",
"nypm": "^0.4.0",
"ofetch": "^1.4.1",
"ohash": "^1.1.4",

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

@ -67,7 +67,7 @@ const globalProps = {
type: Boolean,
default: undefined,
},
style: String,
style: [String, Object, Array],
tabindex: String,
title: String,
translate: String,

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'
@ -71,13 +70,14 @@ export async function resolvePagesRoutes (): Promise<NuxtPage[]> {
return pages
}
const augmentCtx = { extraExtractionKeys: nuxt.options.experimental.extraPageMetaExtractionKeys }
if (shouldAugment === 'after-resolve') {
await nuxt.callHook('pages:extend', pages)
await augmentPages(pages, nuxt.vfs)
await augmentPages(pages, nuxt.vfs, augmentCtx)
} else {
const augmentedPages = await augmentPages(pages, nuxt.vfs)
const augmentedPages = await augmentPages(pages, nuxt.vfs, augmentCtx)
await nuxt.callHook('pages:extend', pages)
await augmentPages(pages, nuxt.vfs, { pagesToSkip: augmentedPages })
await augmentPages(pages, nuxt.vfs, { pagesToSkip: augmentedPages, ...augmentCtx })
augmentedPages?.clear()
}
@ -158,13 +158,15 @@ export function generateRoutesFromFiles (files: ScannedFile[], options: Generate
interface AugmentPagesContext {
pagesToSkip?: Set<string>
augmentedPages?: Set<string>
extraExtractionKeys?: string[]
}
export async function augmentPages (routes: NuxtPage[], vfs: Record<string, string>, ctx: AugmentPagesContext = {}) {
ctx.augmentedPages ??= new Set()
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)
const routeMeta = await getRouteMeta(fileContent, route.file, ctx.extraExtractionKeys)
if (route.meta) {
routeMeta.meta = { ...routeMeta.meta, ...route.meta }
}
@ -181,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',
@ -196,12 +198,12 @@ export function extractScriptContent (html: string) {
}
const PAGE_META_RE = /definePageMeta\([\s\S]*?\)/
const extractionKeys = ['name', 'path', 'props', 'alias', 'redirect'] as const
const defaultExtractionKeys = ['name', 'path', 'props', 'alias', 'redirect'] as const
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): Promise<Partial<Record<keyof NuxtPage, any>>> {
export async function getRouteMeta (contents: string, absolutePath: string, extraExtractionKeys: string[] = []): Promise<Partial<Record<keyof NuxtPage, any>>> {
// set/update pageContentsCache, invalidate metaCache on cache mismatch
if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) {
pageContentsCache[absolutePath] = contents
@ -219,7 +221,9 @@ export async function getRouteMeta (contents: string, absolutePath: string): Pro
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>])
for (const script of scriptBlocks) {
if (!PAGE_META_RE.test(script.code)) {
@ -227,85 +231,79 @@ export async function getRouteMeta (contents: string, absolutePath: string): Pro
}
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 as unknown as string[]).includes(name)) {
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)
@ -142,6 +183,24 @@ describe('page metadata', () => {
}
`)
})
it('should extract configured extra meta', async () => {
const meta = await getRouteMeta(`
<script setup>
definePageMeta({
foo: 'bar',
bar: true,
})
</script>
`, filePath, ['bar', 'foo'])
expect(meta).toMatchInlineSnapshot(`
{
"bar": true,
"foo": "bar",
}
`)
})
})
describe('normalizeRoutes', () => {
@ -222,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
}

View File

@ -308,6 +308,16 @@ export default defineUntypedSchema({
},
},
/**
* Configure additional keys to extract from the page metadata when using `scanPageMeta`.
*
* This allows modules to access additional metadata from the page metadata. It's recommended
* to augment the NuxtPage types with your keys.
*
* @type {string[]}
*/
extraPageMetaExtractionKeys: [],
/**
* Automatically share payload _data_ between pages that are prerendered. This can result in a significant
* performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same

File diff suppressed because it is too large Load Diff