From d61d239b4329f1ad054b275453fbc87ee572afcb Mon Sep 17 00:00:00 2001 From: derHodrig <53934248+derHodrig@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:37:34 +0100 Subject: [PATCH 01/51] docs: update auto-imports to advertise the scan feature (#30292) --- docs/2.guide/1.concepts/1.auto-imports.md | 22 ++++++++++++++++++++++ packages/schema/src/config/adhoc.ts | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/docs/2.guide/1.concepts/1.auto-imports.md b/docs/2.guide/1.concepts/1.auto-imports.md index c650abc685..664c5de7d2 100644 --- a/docs/2.guide/1.concepts/1.auto-imports.md +++ b/docs/2.guide/1.concepts/1.auto-imports.md @@ -143,6 +143,28 @@ export default defineNuxtConfig({ This will disable auto-imports completely but it's still possible to use [explicit imports](#explicit-imports) from `#imports`. +### Partially Disabling Auto-imports + +If you want framework-specific functions like `ref` to remain auto-imported but wish to disable auto-imports for your own code (e.g., custom composables), you can set the `imports.scan` option to `false` in your `nuxt.config.ts` file: + +```ts +export default defineNuxtConfig({ + imports: { + scan: false + } +}) +``` + +With this configuration: +- Framework functions like `ref`, `computed`, or `watch` will still work without needing manual imports. +- Custom code, such as composables, will need to be manually imported in your files. + +::warning +**Caution:** This setup has certain limitations: +- If you structure your project with layers, you will need to explicitly import the composables from each layer, rather than relying on auto-imports. +- This breaks the layer system’s override feature. If you use `imports.scan: false`, ensure you understand this side-effect and adjust your architecture accordingly. +:: + ## Auto-imported Components Nuxt also automatically imports components from your `~/components` directory, although this is configured separately from auto-importing composables and utility functions. diff --git a/packages/schema/src/config/adhoc.ts b/packages/schema/src/config/adhoc.ts index 21660fa731..1d9f6a9d40 100644 --- a/packages/schema/src/config/adhoc.ts +++ b/packages/schema/src/config/adhoc.ts @@ -28,6 +28,11 @@ export default defineUntypedSchema({ */ imports: { global: false, + /** + * Whether to scan your `composables/` and `utils/` directories for composables to auto-import. + * Auto-imports registered by Nuxt or other modules, such as imports from `vue` or `nuxt`, will still be enabled. + */ + scan: true, /** * An array of custom directories that will be auto-imported. From 59f5a76d5126daf0f3e5934a9e5326281f36323b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20=C4=8Cern=C3=BD?= <112722215+cernymatej@users.noreply.github.com> Date: Wed, 18 Dec 2024 10:42:43 +0100 Subject: [PATCH 02/51] feat(nuxt): support local functions in `definePageMeta` (#30241) --- docs/2.guide/2.directory-structure/1.pages.md | 16 +- .../nuxt/src/core/plugins/composable-keys.ts | 132 +--- packages/nuxt/src/core/utils/parse.ts | 532 +++++++++++++- packages/nuxt/src/pages/plugins/page-meta.ts | 128 +++- packages/nuxt/test/fixture/scope-tracker.ts | 31 + packages/nuxt/test/page-metadata.test.ts | 159 ++++- packages/nuxt/test/parse.test.ts | 670 ++++++++++++++++++ 7 files changed, 1514 insertions(+), 154 deletions(-) create mode 100644 packages/nuxt/test/fixture/scope-tracker.ts create mode 100644 packages/nuxt/test/parse.test.ts diff --git a/docs/2.guide/2.directory-structure/1.pages.md b/docs/2.guide/2.directory-structure/1.pages.md index 57d88ddd5c..1efaacc2aa 100644 --- a/docs/2.guide/2.directory-structure/1.pages.md +++ b/docs/2.guide/2.directory-structure/1.pages.md @@ -266,17 +266,27 @@ console.log(route.meta.title) // My home page If you are using nested routes, the page metadata from all these routes will be merged into a single object. For more on route meta, see the [vue-router docs](https://router.vuejs.org/guide/advanced/meta.html#route-meta-fields). -Much like `defineEmits` or `defineProps` (see [Vue docs](https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component. Therefore, the page meta object cannot reference the component (or values defined on the component). However, it can reference imported bindings. +Much like `defineEmits` or `defineProps` (see [Vue docs](https://vuejs.org/api/sfc-script-setup.html#defineprops-defineemits)), `definePageMeta` is a **compiler macro**. It will be compiled away so you cannot reference it within your component. Instead, the metadata passed to it will be hoisted out of the component. +Therefore, the page meta object cannot reference the component. However, it can reference imported bindings, as well as locally defined **pure functions**. + +::warning +Make sure not to reference any reactive data or functions that cause side effects. This can lead to unexpected behavior. +:: ```vue ``` diff --git a/packages/nuxt/src/core/plugins/composable-keys.ts b/packages/nuxt/src/core/plugins/composable-keys.ts index c9ddfc07a5..ea603c4e1c 100644 --- a/packages/nuxt/src/core/plugins/composable-keys.ts +++ b/packages/nuxt/src/core/plugins/composable-keys.ts @@ -3,11 +3,10 @@ import { createUnplugin } from 'unplugin' import { isAbsolute, relative } from 'pathe' import MagicString from 'magic-string' import { hash } from 'ohash' -import type { Pattern } from 'estree' import { parseQuery, parseURL } from 'ufo' import escapeRE from 'escape-string-regexp' import { findStaticImports, parseStaticImport } from 'mlly' -import { parseAndWalk, walk } from '../../core/utils/parse' +import { ScopeTracker, parseAndWalk, walk } from '../utils/parse' import { matchWithStringOrRegex } from '../utils/plugins' @@ -53,33 +52,18 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn const { pathname: relativePathname } = parseURL(relativeID) // 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() + const scopeTracker = new ScopeTracker({ + keepExitedScopes: true, + }) 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) - } - }, - leave (_node) { - if (_node.type === 'BlockStatement') { - scopeTracker.leaveScope() - varCollector.refresh(scopeTracker.curScopeKey) - } - }, + scopeTracker, }) - scopeTracker = new ScopeTracker() + scopeTracker.freeze() + walk(ast, { + scopeTracker, enter (node) { - if (node.type === 'BlockStatement') { - scopeTracker.enterScope() - } if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return } const name = node.callee.name if (!name || !keyedFunctions.has(name) || node.arguments.length >= maxLength) { return } @@ -89,7 +73,9 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn const meta = composableMeta[name] - if (varCollector.hasVar(scopeTracker.curScopeKey, name)) { + const declaration = scopeTracker.getDeclaration(name) + + if (declaration && declaration.type !== 'Import') { let skip = true if (meta.source) { skip = !matchWithStringOrRegex(relativePathname, meta.source) @@ -125,11 +111,6 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn (node.arguments.length && !endsWithComma ? ', ' : '') + '\'$' + hash(`${relativeID}-${++count}`) + '\'', ) }, - leave (_node) { - if (_node.type === 'BlockStatement') { - scopeTracker.leaveScope() - } - }, }) if (s.hasChanged()) { return { @@ -143,97 +124,6 @@ export const ComposableKeysPlugin = (options: ComposableKeysOptions) => createUn } }) -/* -* track scopes with unique keys. for example -* ```js -* // root scope, marked as '' -* function a () { // '0' -* function b () {} // '0-0' -* function c () {} // '0-1' -* } -* function d () {} // '1' -* // '' -* ``` -* */ -class ScopeTracker { - // the top of the stack is not a part of current key, it is used for next level - scopeIndexStack: number[] - curScopeKey: string - - constructor () { - this.scopeIndexStack = [0] - this.curScopeKey = '' - } - - getKey () { - return this.scopeIndexStack.slice(0, -1).join('-') - } - - enterScope () { - this.scopeIndexStack.push(0) - this.curScopeKey = this.getKey() - } - - leaveScope () { - this.scopeIndexStack.pop() - this.curScopeKey = this.getKey() - this.scopeIndexStack[this.scopeIndexStack.length - 1]!++ - } -} - -class ScopedVarsCollector { - curScopeKey: string - all: Map> - - constructor () { - this.all = new Map() - this.curScopeKey = '' - } - - refresh (scopeKey: string) { - this.curScopeKey = scopeKey - } - - addVar (name: string) { - let vars = this.all.get(this.curScopeKey) - if (!vars) { - vars = new Set() - this.all.set(this.curScopeKey, vars) - } - vars.add(name) - } - - hasVar (scopeKey: string, name: string) { - const indices = scopeKey.split('-').map(Number) - for (let i = indices.length; i >= 0; i--) { - if (this.all.get(indices.slice(0, i).join('-'))?.has(name)) { - return true - } - } - return false - } - - 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) - } - } - } -} - const NUXT_IMPORT_RE = /nuxt|#app|#imports/ export function detectImportNames (code: string, composableMeta: Record) { diff --git a/packages/nuxt/src/core/utils/parse.ts b/packages/nuxt/src/core/utils/parse.ts index 34f7af6d79..c60aefa82a 100644 --- a/packages/nuxt/src/core/utils/parse.ts +++ b/packages/nuxt/src/core/utils/parse.ts @@ -1,6 +1,17 @@ import { walk as _walk } from 'estree-walker' import type { Node, SyncHandler } from 'estree-walker' -import type { Program as ESTreeProgram } from 'estree' +import type { + ArrowFunctionExpression, + CatchClause, + Program as ESTreeProgram, + FunctionDeclaration, + FunctionExpression, + Identifier, + ImportDefaultSpecifier, + ImportNamespaceSpecifier, + ImportSpecifier, + VariableDeclaration, +} from 'estree' import { parse } from 'acorn' import type { Program } from 'acorn' @@ -9,20 +20,30 @@ export type { Node } type WithLocations = T & { start: number, end: number } type WalkerCallback = (this: ThisParameterType, node: WithLocations, parent: WithLocations | 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 }) { +interface WalkOptions { + enter: WalkerCallback + leave: WalkerCallback + scopeTracker: ScopeTracker +} + +export function walk (ast: Program | Node, callback: Partial) { return _walk(ast as unknown as ESTreeProgram | Node, { enter (node, parent, key, index) { + // @ts-expect-error - accessing a protected property + callback.scopeTracker?.processNodeEnter(node as WithLocations) callback.enter?.call(this, node as WithLocations, parent as WithLocations | null, { key, index, ast }) }, leave (node, parent, key, index) { + // @ts-expect-error - accessing a protected property + callback.scopeTracker?.processNodeLeave(node as WithLocations) callback.leave?.call(this, node as WithLocations, parent as WithLocations | 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) { +export function parseAndWalk (code: string, sourceFilename: string, object: Partial): Program +export function parseAndWalk (code: string, _sourceFilename: string, callback: Partial | WalkerCallback) { const ast = parse (code, { sourceType: 'module', ecmaVersion: 'latest', locations: true }) walk(ast, typeof callback === 'function' ? { enter: callback } : callback) return ast @@ -31,3 +52,506 @@ export function parseAndWalk (code: string, _sourceFilename: string, callback: { export function withLocations (node: T): WithLocations { return node as WithLocations } + +abstract class BaseNode { + abstract type: string + node: WithLocations + + constructor (node: WithLocations) { + this.node = node + } + + /** + * The starting position of the entire relevant node in the code. + * For instance, for a function parameter, this would be the start of the function declaration. + */ + abstract get start (): number + + /** + * The ending position of the entire relevant node in the code. + * For instance, for a function parameter, this would be the end of the function declaration. + */ + abstract get end (): number +} + +class IdentifierNode extends BaseNode { + override type = 'Identifier' as const + + get start () { + return this.node.start + } + + get end () { + return this.node.end + } +} + +class FunctionParamNode extends BaseNode { + type = 'FunctionParam' as const + fnNode: WithLocations + + constructor (node: WithLocations, fnNode: WithLocations) { + super(node) + this.fnNode = fnNode + } + + get start () { + return this.fnNode.start + } + + get end () { + return this.fnNode.end + } +} + +class FunctionNode extends BaseNode { + type = 'Function' as const + + get start () { + return this.node.start + } + + get end () { + return this.node.end + } +} + +class VariableNode extends BaseNode { + type = 'Variable' as const + variableNode: WithLocations + + constructor (node: WithLocations, variableNode: WithLocations) { + super(node) + this.variableNode = variableNode + } + + get start () { + return this.variableNode.start + } + + get end () { + return this.variableNode.end + } +} + +class ImportNode extends BaseNode { + type = 'Import' as const + importNode: WithLocations + + constructor (node: WithLocations, importNode: WithLocations) { + super(node) + this.importNode = importNode + } + + get start () { + return this.importNode.start + } + + get end () { + return this.importNode.end + } +} + +class CatchParamNode extends BaseNode { + type = 'CatchParam' as const + catchNode: WithLocations + + constructor (node: WithLocations, catchNode: WithLocations) { + super(node) + this.catchNode = catchNode + } + + get start () { + return this.catchNode.start + } + + get end () { + return this.catchNode.end + } +} + +export type ScopeTrackerNode = + | FunctionParamNode + | FunctionNode + | VariableNode + | IdentifierNode + | ImportNode + | CatchParamNode + +interface ScopeTrackerOptions { + /** + * If true, the scope tracker will keep exited scopes in memory. + * @default false + */ + keepExitedScopes?: boolean +} + +/** + * A class to track variable scopes and declarations of identifiers within a JavaScript AST. + * It maintains a stack of scopes, where each scope is a map of identifier names to their corresponding + * declaration nodes - allowing to get to the declaration easily. + * + * The class has integration with the `walk` function to automatically track scopes and declarations + * and that's why only the informative methods are exposed. + * + * ### Scope tracking + * Scopes are created when entering a block statement, however, they are also created + * for function parameters, loop variable declarations, etc. (e.g. `i` in `for (let i = 0; i < 10; i++) { ... }`). + * This means that the behaviour is not 100% equivalent to JavaScript's scoping rules, because internally, + * one JavaScript scope can be spread across multiple scopes in this class. + * + * @example + * ```ts + * const scopeTracker = new ScopeTracker() + * walk(code, { + * scopeTracker, + * enter(node) { + * // ... + * }, + * }) + * ``` + * + * @see parseAndWalk + * @see walk + */ +export class ScopeTracker { + protected scopeIndexStack: number[] = [] + protected scopeIndexKey = '' + protected scopes: Map>> = new Map() + + protected options: Partial + protected isFrozen = false + + constructor (options: ScopeTrackerOptions = {}) { + this.options = options + } + + protected updateScopeIndexKey () { + this.scopeIndexKey = this.scopeIndexStack.slice(0, -1).join('-') + } + + protected pushScope () { + this.scopeIndexStack.push(0) + this.updateScopeIndexKey() + } + + protected popScope () { + this.scopeIndexStack.pop() + if (this.scopeIndexStack[this.scopeIndexStack.length - 1] !== undefined) { + this.scopeIndexStack[this.scopeIndexStack.length - 1]!++ + } + + if (!this.options.keepExitedScopes) { + this.scopes.delete(this.scopeIndexKey) + } + + this.updateScopeIndexKey() + } + + protected declareIdentifier (name: string, data: ScopeTrackerNode) { + if (this.isFrozen) { return } + + let scope = this.scopes.get(this.scopeIndexKey) + if (!scope) { + scope = new Map() + this.scopes.set(this.scopeIndexKey, scope) + } + scope.set(name, data) + } + + protected declareFunctionParameter (param: WithLocations, fn: WithLocations) { + if (this.isFrozen) { return } + + const identifiers = getPatternIdentifiers(param) + for (const identifier of identifiers) { + this.declareIdentifier(identifier.name, new FunctionParamNode(identifier, fn)) + } + } + + protected declarePattern (pattern: WithLocations, parent: WithLocations) { + if (this.isFrozen) { return } + + const identifiers = getPatternIdentifiers(pattern) + for (const identifier of identifiers) { + this.declareIdentifier( + identifier.name, + parent.type === 'VariableDeclaration' + ? new VariableNode(identifier, parent) + : parent.type === 'CatchClause' + ? new CatchParamNode(identifier, parent) + : new FunctionParamNode(identifier, parent), + ) + } + } + + protected processNodeEnter (node: WithLocations) { + switch (node.type) { + case 'Program': + case 'BlockStatement': + case 'StaticBlock': + this.pushScope() + break + + case 'FunctionDeclaration': + // declare function name for named functions, skip for `export default` + if (node.id?.name) { + this.declareIdentifier(node.id.name, new FunctionNode(node)) + } + this.pushScope() + for (const param of node.params) { + this.declareFunctionParameter(withLocations(param), node) + } + break + + case 'FunctionExpression': + // make the name of the function available only within the function + // e.g. const foo = function bar() { // bar is only available within the function body + this.pushScope() + // can be undefined, for example in class method definitions + if (node.id?.name) { + this.declareIdentifier(node.id.name, new FunctionNode(node)) + } + + this.pushScope() + for (const param of node.params) { + this.declareFunctionParameter(withLocations(param), node) + } + break + case 'ArrowFunctionExpression': + this.pushScope() + for (const param of node.params) { + this.declareFunctionParameter(withLocations(param), node) + } + break + + case 'VariableDeclaration': + for (const decl of node.declarations) { + this.declarePattern(withLocations(decl.id), node) + } + break + + case 'ClassDeclaration': + // declare class name for named classes, skip for `export default` + if (node.id?.name) { + this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id))) + } + break + + case 'ClassExpression': + // make the name of the class available only within the class + // e.g. const MyClass = class InternalClassName { // InternalClassName is only available within the class body + this.pushScope() + if (node.id?.name) { + this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id))) + } + break + + case 'ImportDeclaration': + for (const specifier of node.specifiers) { + this.declareIdentifier(specifier.local.name, new ImportNode(withLocations(specifier), node)) + } + break + + case 'CatchClause': + this.pushScope() + if (node.param) { + this.declarePattern(withLocations(node.param), node) + } + break + + case 'ForStatement': + case 'ForOfStatement': + case 'ForInStatement': + // make the variables defined in for loops available only within the loop + // e.g. for (let i = 0; i < 10; i++) { // i is only available within the loop block scope + this.pushScope() + + if (node.type === 'ForStatement' && node.init?.type === 'VariableDeclaration') { + for (const decl of node.init.declarations) { + this.declarePattern(withLocations(decl.id), withLocations(node.init)) + } + } else if ((node.type === 'ForOfStatement' || node.type === 'ForInStatement') && node.left.type === 'VariableDeclaration') { + for (const decl of node.left.declarations) { + this.declarePattern(withLocations(decl.id), withLocations(node.left)) + } + } + break + } + } + + protected processNodeLeave (node: WithLocations) { + switch (node.type) { + case 'Program': + case 'BlockStatement': + case 'CatchClause': + case 'FunctionDeclaration': + case 'ArrowFunctionExpression': + case 'StaticBlock': + case 'ClassExpression': + case 'ForStatement': + case 'ForOfStatement': + case 'ForInStatement': + this.popScope() + break + case 'FunctionExpression': + this.popScope() + this.popScope() + break + } + } + + isDeclared (name: string) { + if (!this.scopeIndexKey) { + return this.scopes.get('')?.has(name) || false + } + + const indices = this.scopeIndexKey.split('-').map(Number) + for (let i = indices.length; i >= 0; i--) { + if (this.scopes.get(indices.slice(0, i).join('-'))?.has(name)) { + return true + } + } + return false + } + + getDeclaration (name: string): ScopeTrackerNode | null { + if (!this.scopeIndexKey) { + return this.scopes.get('')?.get(name) ?? null + } + + const indices = this.scopeIndexKey.split('-').map(Number) + for (let i = indices.length; i >= 0; i--) { + const node = this.scopes.get(indices.slice(0, i).join('-'))?.get(name) + if (node) { + return node + } + } + return null + } + + /** + * Freezes the scope tracker, preventing further declarations. + * It also resets the scope index stack to its initial state, so that the scope tracker can be reused. + * + * This is useful for second passes through the AST. + */ + freeze () { + this.isFrozen = true + this.scopeIndexStack = [] + this.updateScopeIndexKey() + } +} + +function getPatternIdentifiers (pattern: WithLocations) { + const identifiers: WithLocations[] = [] + + function collectIdentifiers (pattern: WithLocations) { + switch (pattern.type) { + case 'Identifier': + identifiers.push(pattern) + break + case 'AssignmentPattern': + collectIdentifiers(withLocations(pattern.left)) + break + case 'RestElement': + collectIdentifiers(withLocations(pattern.argument)) + break + case 'ArrayPattern': + for (const element of pattern.elements) { + if (element) { + collectIdentifiers(withLocations(element.type === 'RestElement' ? element.argument : element)) + } + } + break + case 'ObjectPattern': + for (const property of pattern.properties) { + collectIdentifiers(withLocations(property.type === 'RestElement' ? property.argument : property.value)) + } + break + } + } + + collectIdentifiers(pattern) + + return identifiers +} + +function isNotReferencePosition (node: WithLocations, parent: WithLocations | null) { + if (!parent || node.type !== 'Identifier') { return false } + + switch (parent.type) { + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + // function name or parameters + if (parent.type !== 'ArrowFunctionExpression' && parent.id === node) { return true } + if (parent.params.length) { + for (const param of parent.params) { + const identifiers = getPatternIdentifiers(withLocations(param)) + if (identifiers.includes(node)) { return true } + } + } + return false + + case 'ClassDeclaration': + case 'ClassExpression': + // class name + return parent.id === node + + case 'MethodDefinition': + // class method name + return parent.key === node + + case 'PropertyDefinition': + // class property name + return parent.key === node + + case 'VariableDeclarator': + // variable name + return getPatternIdentifiers(withLocations(parent.id)).includes(node) + + case 'CatchClause': + // catch clause param + if (!parent.param) { return false } + return getPatternIdentifiers(withLocations(parent.param)).includes(node) + + case 'Property': + // property key if not used as a shorthand + return parent.key === node && parent.value !== node + + case 'MemberExpression': + // member expression properties + return parent.property === node + } + + return false +} + +export function getUndeclaredIdentifiersInFunction (node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression) { + const scopeTracker = new ScopeTracker({ + keepExitedScopes: true, + }) + const undeclaredIdentifiers = new Set() + + function isIdentifierUndeclared (node: WithLocations, parent: WithLocations | null) { + return !isNotReferencePosition(node, parent) && !scopeTracker.isDeclared(node.name) + } + + // first pass to collect all declarations and hoist them + walk(node, { + scopeTracker, + }) + + scopeTracker.freeze() + + walk(node, { + scopeTracker, + enter (node, parent) { + if (node.type === 'Identifier' && isIdentifierUndeclared(node, parent)) { + undeclaredIdentifiers.add(node.name) + } + }, + }) + + return Array.from(undeclaredIdentifiers) +} diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts index 56e88c8f3a..ff179fb68d 100644 --- a/packages/nuxt/src/pages/plugins/page-meta.ts +++ b/packages/nuxt/src/pages/plugins/page-meta.ts @@ -8,7 +8,13 @@ import MagicString from 'magic-string' import { isAbsolute } from 'pathe' import { logger } from '@nuxt/kit' -import { parseAndWalk, withLocations } from '../../core/utils/parse' +import { + ScopeTracker, + type ScopeTrackerNode, + getUndeclaredIdentifiersInFunction, + parseAndWalk, + withLocations, +} from '../../core/utils/parse' interface PageMetaPluginOptions { dev?: boolean @@ -119,38 +125,110 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp } } - parseAndWalk(code, id, (node) => { - if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return } - if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return } + function isStaticIdentifier (name: string | false): name is string { + return !!(name && importMap.has(name)) + } - const meta = withLocations(node.arguments[0]) + function addImport (name: string | false) { + if (!isStaticIdentifier(name)) { return } + const importValue = importMap.get(name)!.code.trim() + if (!addedImports.has(importValue)) { + addedImports.add(importValue) + } + } - if (!meta) { return } + const declarationNodes: ScopeTrackerNode[] = [] + const addedDeclarations = new Set() - let contents = `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : '') + function addDeclaration (node: ScopeTrackerNode) { + const codeSectionKey = `${node.start}-${node.end}` + if (addedDeclarations.has(codeSectionKey)) { return } + addedDeclarations.add(codeSectionKey) + declarationNodes.push(node) + } - 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) - } + function addImportOrDeclaration (name: string) { + if (isStaticIdentifier(name)) { + addImport(name) + } else { + const declaration = scopeTracker.getDeclaration(name) + if (declaration) { + processDeclaration(declaration) } } + } - walk(meta, { - enter (node) { - if (node.type === 'CallExpression' && 'name' in node.callee) { - addImport(node.callee.name) - } - if (node.type === 'Identifier') { - addImport(node.name) - } - }, - }) + const scopeTracker = new ScopeTracker() - s.overwrite(0, code.length, contents) + function processDeclaration (node: ScopeTrackerNode | null) { + if (node?.type === 'Variable') { + addDeclaration(node) + + for (const decl of node.variableNode.declarations) { + if (!decl.init) { continue } + walk(decl.init, { + enter: (node) => { + if (node.type === 'AwaitExpression') { + logger.error(`[nuxt] Await expressions are not supported in definePageMeta. File: '${id}'`) + throw new Error('await in definePageMeta') + } + if (node.type !== 'Identifier') { return } + + addImportOrDeclaration(node.name) + }, + }) + } + } else if (node?.type === 'Function') { + // arrow functions are going to be assigned to a variable + if (node.node.type === 'ArrowFunctionExpression') { return } + const name = node.node.id?.name + if (!name) { return } + addDeclaration(node) + + const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(node.node) + for (const name of undeclaredIdentifiers) { + addImportOrDeclaration(name) + } + } + } + + parseAndWalk(code, id, { + scopeTracker, + enter: (node) => { + if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier') { return } + if (!('name' in node.callee) || node.callee.name !== 'definePageMeta') { return } + + const meta = withLocations(node.arguments[0]) + + if (!meta) { return } + + walk(meta, { + enter (node) { + if (node.type !== 'Identifier') { return } + + if (isStaticIdentifier(node.name)) { + addImport(node.name) + } else { + processDeclaration(scopeTracker.getDeclaration(node.name)) + } + }, + }) + + const importStatements = Array.from(addedImports).join('\n') + + const declarations = declarationNodes + .sort((a, b) => a.start - b.start) + .map(node => code.slice(node.start, node.end)) + .join('\n') + + const extracted = [ + importStatements, + declarations, + `const __nuxt_page_meta = ${code!.slice(meta.start, meta.end) || 'null'}\nexport default __nuxt_page_meta` + (options.dev ? CODE_HMR : ''), + ].join('\n') + + s.overwrite(0, code.length, extracted.trim()) + }, }) if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) { diff --git a/packages/nuxt/test/fixture/scope-tracker.ts b/packages/nuxt/test/fixture/scope-tracker.ts new file mode 100644 index 0000000000..de812588c8 --- /dev/null +++ b/packages/nuxt/test/fixture/scope-tracker.ts @@ -0,0 +1,31 @@ +import { ScopeTracker } from '../../src/core/utils/parse' + +export class TestScopeTracker extends ScopeTracker { + getScopes () { + return this.scopes + } + + getScopeIndexKey () { + return this.scopeIndexKey + } + + getScopeIndexStack () { + return this.scopeIndexStack + } + + isDeclaredInScope (identifier: string, scope: string) { + const oldKey = this.scopeIndexKey + this.scopeIndexKey = scope + const result = this.isDeclared(identifier) + this.scopeIndexKey = oldKey + return result + } + + getDeclarationFromScope (identifier: string, scope: string) { + const oldKey = this.scopeIndexKey + this.scopeIndexKey = scope + const result = this.getDeclaration(identifier) + this.scopeIndexKey = oldKey + return result + } +} diff --git a/packages/nuxt/test/page-metadata.test.ts b/packages/nuxt/test/page-metadata.test.ts index 62b8bfe580..0513b97148 100644 --- a/packages/nuxt/test/page-metadata.test.ts +++ b/packages/nuxt/test/page-metadata.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { compileScript, parse } from '@vue/compiler-sfc' import * as Parser from 'acorn' - +import { transform as esbuildTransform } from 'esbuild' import { PageMetaPlugin } from '../src/pages/plugins/page-meta' import { getRouteMeta, normalizeRoutes } from '../src/pages/utils' import type { NuxtPage } from '../schema' @@ -310,4 +310,161 @@ definePageMeta({ export default __nuxt_page_meta" `) }) + + it('should extract local functions', () => { + const sfc = ` + + ` + 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(` + "function isNumber(value) { + return value && !isNaN(Number(value)) + } + function validateIdParam (route) { + return isNumber(route.params.id) + } + const __nuxt_page_meta = { + validate: validateIdParam, + test: () => 'hello', + } + export default __nuxt_page_meta" + `) + }) + + it('should extract user imports', () => { + const sfc = ` + + ` + 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(` + "import { validateIdParam } from './utils' + + const __nuxt_page_meta = { + validate: validateIdParam, + dynamic: ref(true), + } + export default __nuxt_page_meta" + `) + }) + + it('should work with esbuild.keepNames = true', async () => { + const sfc = ` + + ` + const compiled = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + const res = await esbuildTransform(compiled.content, { + loader: 'ts', + keepNames: true, + }) + expect(transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.code, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(` + "import { foo } from "./utils"; + var __defProp = Object.defineProperty; + var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); + const checkNum = /* @__PURE__ */ __name((value) => { + return !isNaN(Number(foo(value))); + }, "checkNum"); + function isNumber(value) { + return value && checkNum(value); + } + const __nuxt_page_meta = { + validate: /* @__PURE__ */ __name(({ params }) => { + return isNumber(params.id); + }, "validate") + } + export default __nuxt_page_meta" + `) + }) + + it('should throw for await expressions', async () => { + const sfc = ` + + ` + const compiled = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + const res = await esbuildTransform(compiled.content, { + loader: 'ts', + }) + + let wasErrorThrown = false + + try { + transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.code, 'component.vue?macro=true') + } catch (e) { + if (e instanceof Error) { + expect(e.message).toMatch(/await in definePageMeta/) + wasErrorThrown = true + } + } + + expect(wasErrorThrown).toBe(true) + }) }) diff --git a/packages/nuxt/test/parse.test.ts b/packages/nuxt/test/parse.test.ts new file mode 100644 index 0000000000..a051a57c1d --- /dev/null +++ b/packages/nuxt/test/parse.test.ts @@ -0,0 +1,670 @@ +import { describe, expect, it } from 'vitest' +import { getUndeclaredIdentifiersInFunction, parseAndWalk } from '../src/core/utils/parse' +import { TestScopeTracker } from './fixture/scope-tracker' + +const filename = 'test.ts' + +describe('scope tracker', () => { + it('should throw away exited scopes', () => { + const code = ` + const a = 1 + { + const b = 2 + } + ` + + const scopeTracker = new TestScopeTracker() + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.getScopes().size).toBe(0) + }) + + it ('should keep exited scopes', () => { + const code = ` + const a = 1 + { + const b = 2 + } + ` + + const scopeTracker = new TestScopeTracker({ keepExitedScopes: true }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.getScopes().size).toBe(2) + }) + + it('should generate scope key correctly and not allocate unnecessary scopes', () => { + const code = ` + // starting in global scope ("") + const a = 1 + // pushing scope for function parameters ("0") + // pushing scope for function body ("0-0") + function foo (param) { + const b = 2 + // pushing scope for for loop variable declaration ("0-0-0") + // pushing scope for for loop body ("0-0-0-0") + for (let i = 0; i < 10; i++) { + const c = 3 + + // pushing scope for block statement ("0-0-0-0-0") + try { + const d = 4 + } + // in for loop body scope ("0-0-0-0") + // pushing scope for catch clause param ("0-0-0-0-1") + // pushing scope for block statement ("0-0-0-0-1-0") + catch (e) { + const f = 4 + } + + // in for loop body scope ("0-0-0-0") + + const cc = 3 + } + + // in function body scope ("0-0") + + // pushing scope for for of loop variable declaration ("0-0-1") + // pushing scope for for of loop body ("0-0-1-0") + for (const i of [1, 2, 3]) { + const dd = 3 + } + + // in function body scope ("0-0") + + // pushing scope for for in loop variable declaration ("0-0-2") + // pushing scope for for in loop body ("0-0-2-0") + for (const i in [1, 2, 3]) { + const ddd = 3 + } + + // in function body scope ("0-0") + + // pushing scope for while loop body ("0-0-3") + while (true) { + const e = 3 + } + } + + // in global scope ("") + + // pushing scope for function expression name ("1") + // pushing scope for function parameters ("1-0") + // pushing scope for function body ("1-0-0") + const baz = function bar (param) { + const g = 5 + + // pushing scope for block statement ("1-0-0-0") + if (true) { + const h = 6 + } + } + + // in global scope ("") + + // pushing scope for function expression name ("2") + { + const i = 7 + // pushing scope for block statement ("2-0") + { + const j = 8 + } + } + + // in global scope ("") + + // pushing scope for arrow function parameters ("3") + // pushing scope for arrow function body ("3-0") + const arrow = (param) => { + const k = 9 + } + + // in global scope ("") + + // pushing scope for class expression name ("4") + const classExpression = class InternalClassName { + classAttribute = 10 + // pushing scope for constructor function expression name ("4-0") + // pushing scope for constructor parameters ("4-0-0") + // pushing scope for constructor body ("4-0-0-0") + constructor(constructorParam) { + const l = 10 + } + + // in class body scope ("4") + + // pushing scope for static block ("4-1") + static { + const m = 11 + } + } + + // in global scope ("") + + class NoScopePushedForThis { + // pushing scope for constructor function expression name ("5") + // pushing scope for constructor parameters ("5-0") + // pushing scope for constructor body ("5-0-0") + constructor() { + const n = 12 + } + } + + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + // is in global scope initially + expect(scopeTracker.getScopeIndexKey()).toBe('') + + parseAndWalk(code, filename, { + scopeTracker, + }) + + // is in global scope after parsing + expect(scopeTracker.getScopeIndexKey()).toBe('') + + // check that the scopes are correct + const scopes = scopeTracker.getScopes() + + const expectedScopesInOrder = [ + '', + '0', + '0-0', + '0-0-0', + '0-0-0-0', + '0-0-0-0-0', + '0-0-0-0-1', + '0-0-0-0-1-0', + '0-0-1', + '0-0-1-0', + '0-0-2', + '0-0-2-0', + '0-0-3', + '1', + '1-0', + '1-0-0', + '1-0-0-0', + '2', + '2-0', + '3', + '3-0', + '4', + // '4-0', -> DO NOT UNCOMMENT - class constructor method definition doesn't provide a function expression id (scope doesn't have any identifiers) + '4-0-0', + '4-0-0-0', + '4-1', + // '5', -> DO NOT UNCOMMENT - class constructor - same as above + // '5-0', -> DO NOT UNCOMMENT - class constructor parameters (none in this case, so the scope isn't stored) + '5-0-0', + ] + + expect(scopes.size).toBe(expectedScopesInOrder.length) + + const scopeKeys = Array.from(scopes.keys()) + + expect(scopeKeys).toEqual(expectedScopesInOrder) + }) + + it ('should track variable declarations', () => { + const code = ` + const a = 1 + let x, y = 2 + + { + let b = 2 + } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + const scopes = scopeTracker.getScopes() + + const globalScope = scopes.get('') + expect(globalScope?.get('a')?.type).toEqual('Variable') + expect(globalScope?.get('b')).toBeUndefined() + expect(globalScope?.get('x')?.type).toEqual('Variable') + expect(globalScope?.get('y')?.type).toEqual('Variable') + + const blockScope = scopes.get('0') + expect(blockScope?.get('b')?.type).toEqual('Variable') + expect(blockScope?.get('a')).toBeUndefined() + expect(blockScope?.get('x')).toBeUndefined() + expect(blockScope?.get('y')).toBeUndefined() + + expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('a', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('y', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('y', '0')).toBe(true) + + expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('b', '0')).toBe(true) + }) + + it ('should separate variables in different scopes', () => { + const code = ` + const a = 1 + + { + let a = 2 + } + + function foo (a) { + // scope "1-0" + let b = a + } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + const globalA = scopeTracker.getDeclarationFromScope('a', '') + expect(globalA?.type).toEqual('Variable') + expect(globalA?.type === 'Variable' && globalA.variableNode.type).toEqual('VariableDeclaration') + + const blockA = scopeTracker.getDeclarationFromScope('a', '0') + expect(blockA?.type).toEqual('Variable') + expect(blockA?.type === 'Variable' && blockA.variableNode.type).toEqual('VariableDeclaration') + + // check that the two `a` variables are different + expect(globalA?.type === 'Variable' && globalA.variableNode).not.toBe(blockA?.type === 'Variable' && blockA.variableNode) + + // check that the `a` in the function scope is a function param and not a variable + const fooA = scopeTracker.getDeclarationFromScope('a', '1-0') + expect(fooA?.type).toEqual('FunctionParam') + }) + + it ('should handle patterns', () => { + const code = ` + const { a, b: c } = { a: 1, b: 2 } + const [d, [e]] = [3, [4]] + const { f: { g } } = { f: { g: 5 } } + + function foo ({ h, i: j } = {}, [k, [l, m], ...rest]) { + } + + try {} catch ({ message }) {} + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + const scopes = scopeTracker.getScopes() + expect(scopes.size).toBe(3) + + const globalScope = scopes.get('') + expect(globalScope?.size).toBe(6) + + expect(globalScope?.get('a')?.type).toEqual('Variable') + expect(globalScope?.get('b')?.type).toBeUndefined() + expect(globalScope?.get('c')?.type).toEqual('Variable') + expect(globalScope?.get('d')?.type).toEqual('Variable') + expect(globalScope?.get('e')?.type).toEqual('Variable') + expect(globalScope?.get('f')?.type).toBeUndefined() + expect(globalScope?.get('g')?.type).toEqual('Variable') + expect(globalScope?.get('foo')?.type).toEqual('Function') + + const fooScope = scopes.get('0') + expect(fooScope?.size).toBe(6) + + expect(fooScope?.get('h')?.type).toEqual('FunctionParam') + expect(fooScope?.get('i')?.type).toBeUndefined() + expect(fooScope?.get('j')?.type).toEqual('FunctionParam') + expect(fooScope?.get('k')?.type).toEqual('FunctionParam') + expect(fooScope?.get('l')?.type).toEqual('FunctionParam') + expect(fooScope?.get('m')?.type).toEqual('FunctionParam') + expect(fooScope?.get('rest')?.type).toEqual('FunctionParam') + + const catchScope = scopes.get('2') + expect(catchScope?.size).toBe(1) + expect(catchScope?.get('message')?.type).toEqual('CatchParam') + + expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('c', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('d', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('e', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('f', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('g', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('h', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('i', '0')).toBe(false) + expect(scopeTracker.isDeclaredInScope('j', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('k', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('l', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('m', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('rest', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('message', '2')).toBe(true) + }) + + it ('should handle loops', () => { + const code = ` + for (let i = 0, getI = () => i; i < 3; i++) { + console.log(getI()); + } + + let j = 0; + for (; j < 3; j++) { } + + const obj = { a: 1, b: 2, c: 3 } + for (const property in obj) { } + + const arr = ['a', 'b', 'c'] + for (const element of arr) { } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + const scopes = scopeTracker.getScopes() + expect(scopes.size).toBe(4) + + const globalScope = scopes.get('') + expect(globalScope?.size).toBe(3) + expect(globalScope?.get('j')?.type).toEqual('Variable') + expect(globalScope?.get('obj')?.type).toEqual('Variable') + expect(globalScope?.get('arr')?.type).toEqual('Variable') + + const forScope1 = scopes.get('0') + expect(forScope1?.size).toBe(2) + expect(forScope1?.get('i')?.type).toEqual('Variable') + expect(forScope1?.get('getI')?.type).toEqual('Variable') + + const forScope2 = scopes.get('1') + expect(forScope2).toBeUndefined() + + const forScope3 = scopes.get('2') + expect(forScope3?.size).toBe(1) + expect(forScope3?.get('property')?.type).toEqual('Variable') + + const forScope4 = scopes.get('3') + expect(forScope4?.size).toBe(1) + expect(forScope4?.get('element')?.type).toEqual('Variable') + + expect(scopeTracker.isDeclaredInScope('i', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('getI', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('i', '0-0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('getI', '0-0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('j', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('j', '1-0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('property', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('element', '')).toBe(false) + }) + + it ('should handle imports', () => { + const code = ` + import { a, b as c } from 'module-a' + import d from 'module-b' + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('b', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('c', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('d', '')).toBe(true) + + expect(scopeTracker.getScopes().get('')?.size).toBe(3) + }) + + it ('should handle classes', () => { + const code = ` + // "" + + class Foo { + someProperty = 1 + + // "0" - function expression name + // "0-0" - constructor parameters + // "0-0-0" - constructor body + constructor(param) { + let a = 1 + this.b = 1 + } + + // "1" - method name + // "1-0" - method parameters + // "1-0-0" - method body + someMethod(param) { + let c = 1 + } + + // "2" - method name + // "2-0" - method parameters + // "2-0-0" - method body + get d() { + let e = 1 + return 1 + } + } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + const scopes = scopeTracker.getScopes() + + // only the scopes containing identifiers are stored + const expectedScopes = [ + '', + '0-0', + '0-0-0', + '1-0', + '1-0-0', + '2-0-0', + ] + + expect(scopes.size).toBe(expectedScopes.length) + + const scopeKeys = Array.from(scopes.keys()) + expect(scopeKeys).toEqual(expectedScopes) + + expect(scopeTracker.isDeclaredInScope('Foo', '')).toBe(true) + + // properties should be accessible through the class + expect(scopeTracker.isDeclaredInScope('someProperty', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('someProperty', '0')).toBe(false) + + expect(scopeTracker.isDeclaredInScope('a', '0-0-0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('b', '0-0-0')).toBe(false) + + // method definitions don't have names in function expressions, so it is not stored + // they should be accessed through the class + expect(scopeTracker.isDeclaredInScope('someMethod', '1')).toBe(false) + expect(scopeTracker.isDeclaredInScope('someMethod', '1-0-0')).toBe(false) + expect(scopeTracker.isDeclaredInScope('someMethod', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('c', '1-0-0')).toBe(true) + + expect(scopeTracker.isDeclaredInScope('d', '2')).toBe(false) + expect(scopeTracker.isDeclaredInScope('d', '2-0-0')).toBe(false) + expect(scopeTracker.isDeclaredInScope('d', '')).toBe(false) + expect(scopeTracker.isDeclaredInScope('e', '2-0-0')).toBe(true) + }) + + it ('should freeze scopes', () => { + let code = ` + const a = 1 + { + const b = 2 + } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.getScopes().size).toBe(2) + + code = code + '\n' + ` + { + const c = 3 + } + ` + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.getScopes().size).toBe(3) + + scopeTracker.freeze() + + code = code + '\n' + ` + { + const d = 4 + } + ` + + parseAndWalk(code, filename, { + scopeTracker, + }) + + expect(scopeTracker.getScopes().size).toBe(3) + + expect(scopeTracker.isDeclaredInScope('a', '')).toBe(true) + expect(scopeTracker.isDeclaredInScope('b', '0')).toBe(true) + expect(scopeTracker.isDeclaredInScope('c', '1')).toBe(true) + expect(scopeTracker.isDeclaredInScope('d', '2')).toBe(false) + }) +}) + +describe('parsing', () => { + it ('should correctly get identifiers not declared in a function', () => { + const functionParams = `(param, { param1, temp: param2 } = {}, [param3, [param4]], ...rest)` + const functionBody = `{ + const c = 1, d = 2 + console.log(undeclaredIdentifier1, foo) + const obj = { + key1: param, + key2: undeclaredIdentifier1, + undeclaredIdentifier2: undeclaredIdentifier2, + undeclaredIdentifier3, + undeclaredIdentifier4, + } + nonExistentFunction() + + console.log(a, b, c, d, param, param1, param2, param3, param4, param['test']['key'], rest) + console.log(param3[0].access['someKey'], obj, obj.key1, obj.key2, obj.undeclaredIdentifier2, obj.undeclaredIdentifier3) + + try {} catch (error) { console.log(error) } + + class Foo { constructor() { console.log(Foo) } } + const cls = class Bar { constructor() { console.log(Bar, cls) } } + const cls2 = class Baz { + someProperty = someValue + someMethod() { } + } + console.log(Baz) + + function f() { + console.log(hoisted, nonHoisted) + } + let hoisted = 1 + f() + }` + + const code = ` + import { a } from 'module-a' + const b = 1 + + // "0" + function foo ${functionParams} ${functionBody} + + // "1" + const f = ${functionParams} => ${functionBody} + + // "2-0" + const bar = function ${functionParams} ${functionBody} + + // "3-0" + const baz = function foo ${functionParams} ${functionBody} + + // "4" + function emptyParams() { + console.log(param) + } + ` + + const scopeTracker = new TestScopeTracker({ + keepExitedScopes: true, + }) + + let processedFunctions = 0 + + parseAndWalk(code, filename, { + scopeTracker, + enter: (node) => { + const currentScope = scopeTracker.getScopeIndexKey() + if ((node.type !== 'FunctionDeclaration' && node.type !== 'FunctionExpression' && node.type !== 'ArrowFunctionExpression') || !['0', '1', '2-0', '3-0', '4'].includes(currentScope)) { return } + + const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(node) + expect(undeclaredIdentifiers).toEqual(currentScope === '4' + ? [ + 'console', + 'param', + ] + : [ + 'console', + 'undeclaredIdentifier1', + ...(node.type === 'ArrowFunctionExpression' || (node.type === 'FunctionExpression' && !node.id) ? ['foo'] : []), + 'undeclaredIdentifier2', + 'undeclaredIdentifier3', + 'undeclaredIdentifier4', + 'nonExistentFunction', + 'a', // import is outside the scope of the function + 'b', // variable is outside the scope of the function + 'someValue', + 'Baz', + 'nonHoisted', + ]) + + processedFunctions++ + }, + }) + + expect(processedFunctions).toBe(5) + }) +}) From 1d795a5db42ade269b318ed71f37b4c0365102fd Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 18 Dec 2024 09:58:20 +0000 Subject: [PATCH 03/51] ci: analyse github actions with codeql (#30293) --- .github/codeql/codeql-config.yml | 10 ---------- .github/workflows/ci.yml | 8 +++++--- 2 files changed, 5 insertions(+), 13 deletions(-) delete mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml deleted file mode 100644 index 1ab482ad65..0000000000 --- a/.github/codeql/codeql-config.yml +++ /dev/null @@ -1,10 +0,0 @@ -paths: - - 'packages/*/dist/**' - - 'packages/nuxt/bin/**' - - 'packages/schema/schema/**' -paths-ignore: - - 'test/**' - - '**/*.test.js' - - '**/*.test.ts' - - '**/*.test.tsx' - - '**/__tests__/**' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe6772524..0caa7e46bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: codeql: runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + matrix: + language: ['javascript-typescript', 'actions'] permissions: actions: read contents: read @@ -90,13 +93,12 @@ jobs: - '**/*.spec.ts' - '**/*.test.ts' - '**/__snapshots__/**' - languages: javascript-typescript - queries: +security-and-quality + languages: ${{ matrix.language }} - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9 with: - category: "/language:javascript-typescript" + category: "/language:${{ matrix.language }}" typecheck: runs-on: ${{ matrix.os }} From b4c0f17776edf9d2c45c14459278b44cd2a5d980 Mon Sep 17 00:00:00 2001 From: Connor Roberts <32241825+murshex@users.noreply.github.com> Date: Wed, 18 Dec 2024 17:26:32 +0700 Subject: [PATCH 04/51] fix(nuxt): respect `replace` in middleware with `navigateTo` (#30283) --- packages/nuxt/src/app/composables/router.ts | 3 +++ test/bundle.test.ts | 4 ++-- test/nuxt/composables.test.ts | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts index 0814873ff0..3f95ffe8ec 100644 --- a/packages/nuxt/src/app/composables/router.ts +++ b/packages/nuxt/src/app/composables/router.ts @@ -152,6 +152,9 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na // Early redirect on client-side if (import.meta.client && !isExternal && inMiddleware) { + if (options?.replace) { + return typeof to === 'string' ? { path: to, replace: true } : { ...to, replace: true } + } return to } diff --git a/test/bundle.test.ts b/test/bundle.test.ts index d15e9ec9f2..fa71467805 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -21,8 +21,8 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const [clientStats, clientStatsInlined] = await Promise.all((['.output', '.output-inline']) .map(outputDir => analyzeSizes(['**/*.js'], join(rootDir, outputDir, 'public')))) - expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"115k"`) - expect.soft(roundToKilobytes(clientStatsInlined!.totalBytes)).toMatchInlineSnapshot(`"115k"`) + expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"116k"`) + expect.soft(roundToKilobytes(clientStatsInlined!.totalBytes)).toMatchInlineSnapshot(`"116k"`) const files = new Set([...clientStats!.files, ...clientStatsInlined!.files].map(f => f.replace(/\..*\.js/, '.js'))) diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index bfb0eed5d1..bbb06f0f82 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -623,6 +623,18 @@ describe('routing utilities: `navigateTo`', () => { expect(() => navigateTo(url, { external: true })).toThrowError(`Cannot navigate to a URL with '${protocol}:' protocol.`) } }) + it('navigateTo should replace current navigation state if called within middleware', () => { + const nuxtApp = useNuxtApp() + nuxtApp._processingMiddleware = true + expect(navigateTo('/')).toMatchInlineSnapshot(`"/"`) + expect(navigateTo('/', { replace: true })).toMatchInlineSnapshot(` + { + "path": "/", + "replace": true, + } + `) + nuxtApp._processingMiddleware = false + }) }) describe('routing utilities: `resolveRouteObject`', () => { From 8de9c5be50b7edb754c31a6b5be87ba6682e897f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 18 Dec 2024 09:59:47 +0000 Subject: [PATCH 05/51] ci: exclude file that codeql cannot analyse --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0caa7e46bb..af68c1b75a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,8 @@ jobs: - '**/*.spec.ts' - '**/*.test.ts' - '**/__snapshots__/**' + # codeql bug: #L20C9:9: A parse error occurred: `Unexpected token`. + - 'packages/vite/src/runtime/vite-node.mjs' languages: ${{ matrix.language }} - name: Perform CodeQL Analysis From c83507e315de426f02c84c2861e341dfbd4327d5 Mon Sep 17 00:00:00 2001 From: Bobbie Goede Date: Wed, 18 Dec 2024 11:29:56 +0100 Subject: [PATCH 06/51] docs: update `nuxi` command pages (#30199) --- docs/3.api/4.commands/add.md | 28 ++++++++++++---- docs/3.api/4.commands/analyze.md | 26 ++++++++++++--- docs/3.api/4.commands/build-module.md | 30 +++++++++++++---- docs/3.api/4.commands/build.md | 31 ++++++++++++----- docs/3.api/4.commands/cleanup.md | 25 +++++++++++--- docs/3.api/4.commands/dev.md | 48 +++++++++++++++++++-------- docs/3.api/4.commands/devtools.md | 25 +++++++++++--- docs/3.api/4.commands/generate.md | 27 ++++++++++++--- docs/3.api/4.commands/info.md | 22 +++++++++--- docs/3.api/4.commands/init.md | 38 +++++++++++++-------- docs/3.api/4.commands/module.md | 48 ++++++++++++++++++++++----- docs/3.api/4.commands/prepare.md | 25 +++++++++++--- docs/3.api/4.commands/preview.md | 26 ++++++++++++--- docs/3.api/4.commands/typecheck.md | 23 ++++++++++--- docs/3.api/4.commands/upgrade.md | 26 ++++++++++++--- 15 files changed, 349 insertions(+), 99 deletions(-) diff --git a/docs/3.api/4.commands/add.md b/docs/3.api/4.commands/add.md index 6b88b119fd..4ed7d1eb09 100644 --- a/docs/3.api/4.commands/add.md +++ b/docs/3.api/4.commands/add.md @@ -8,16 +8,30 @@ links: size: xs --- + ```bash [Terminal] -npx nuxi add [--cwd] [--force]