Merge branch 'main' into patch-21

This commit is contained in:
Michael Brevard 2024-12-28 19:46:48 +02:00 committed by GitHub
commit c7e154e11a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 4295 additions and 1994 deletions

View File

@ -1,4 +1,4 @@
FROM node:lts@sha256:35a5dd72bcac4bce43266408b58a02be6ff0b6098ffa6f5435aeea980a8951d7
FROM node:lts@sha256:0e910f435308c36ea60b4cfd7b80208044d77a074d16b768a81901ce938a62dc
RUN apt-get update && \
apt-get install -fy libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 && \

1
.github/assets/bluesky.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#A1A1AA" d="M12 11.388c-.906-1.761-3.372-5.044-5.665-6.662c-2.197-1.55-3.034-1.283-3.583-1.033C2.116 3.978 2 4.955 2 5.528c0 .575.315 4.709.52 5.4c.68 2.28 3.094 3.05 5.32 2.803c-3.26.483-6.157 1.67-2.36 5.898c4.178 4.325 5.726-.927 6.52-3.59c.794 2.663 1.708 7.726 6.444 3.59c3.556-3.59.977-5.415-2.283-5.898c2.225.247 4.64-.523 5.319-2.803c.205-.69.52-4.825.52-5.399c0-.575-.116-1.55-.752-1.838c-.549-.248-1.386-.517-3.583 1.033c-2.293 1.621-4.76 4.904-5.665 6.664"/></svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@ -1,10 +0,0 @@
paths:
- 'packages/*/dist/**'
- 'packages/nuxt/bin/**'
- 'packages/schema/schema/**'
paths-ignore:
- 'test/**'
- '**/*.test.js'
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/__tests__/**'

View File

@ -60,7 +60,7 @@ jobs:
run: pnpm test:attw
- name: Cache dist
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
retention-days: 3
name: dist
@ -69,6 +69,9 @@ jobs:
codeql:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
language: ['javascript-typescript', 'actions']
permissions:
actions: read
contents: read
@ -78,7 +81,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
config: |
paths:
@ -90,13 +93,14 @@ jobs:
- '**/*.spec.ts'
- '**/*.test.ts'
- '**/__snapshots__/**'
languages: javascript-typescript
queries: +security-and-quality
# codeql bug: #L20C9:9: A parse error occurred: `Unexpected token`.
- 'packages/vite/src/runtime/vite-node.mjs'
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
category: "/language:javascript-typescript"
category: "/language:${{ matrix.language }}"
typecheck:
runs-on: ${{ matrix.os }}
@ -251,7 +255,7 @@ jobs:
TEST_PAYLOAD: ${{ matrix.payload }}
SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || matrix.payload == 'js' || runner.os == 'Windows' }}
- uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # v5.1.1
- uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
if: github.event_name != 'push' && matrix.env == 'built' && matrix.builder == 'vite' && matrix.context == 'default' && matrix.os == 'ubuntu-latest' && matrix.manifest == 'manifest-on'
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Lychee link checker
uses: lycheeverse/lychee-action@4aa18b6ccdac05029fab067313a6a04f941e6494 # for v1.8.0
uses: lycheeverse/lychee-action@f796c8b7d468feb9b8c0a46da3fac0af6874d374 # for v1.8.0
with:
# arguments with file types to check
args: >-

View File

@ -59,7 +59,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
if: github.repository == 'nuxt/nuxt' && success()
with:
name: SARIF file
@ -68,7 +68,7 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3.27.9
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
if: github.repository == 'nuxt/nuxt' && success()
with:
sarif_file: results.sarif

3
.gitignore vendored
View File

@ -44,6 +44,9 @@ coverage
# Intellij idea
*.iml
.idea
!.idea/nuxt.iml
!.idea/modules.xml
!.idea/inspectionProfiles/Project_Default.xml
# OSX
.DS_Store

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/nuxt.iml" filepath="$PROJECT_DIR$/.idea/nuxt.iml" />
</modules>
</component>
</project>

18
.idea/nuxt.iml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/packages/nuxt/dist" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/packages/schema/schema" />
<excludeFolder url="file://$MODULE_DIR$/playground/.output" />
<excludeFolder url="file://$MODULE_DIR$/test/fixtures/minimal/.nuxt-inline" />
<excludeFolder url="file://$MODULE_DIR$/test/fixtures/minimal/.output" />
<excludeFolder url="file://$MODULE_DIR$/test/fixtures/minimal/.output-inline" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -109,7 +109,7 @@ Follow the docs to [Set Up Your Local Development Environment](https://nuxt.com/
## <a name="follow-us">🔗 Follow Us</a>
<p valign="center">
<a href="https://go.nuxt.com/discord"><img width="20px" src="./.github/assets/discord.svg" alt="Discord"></a>&nbsp;&nbsp;<a href="https://go.nuxt.com/x"><img width="20px" src="./.github/assets/twitter.svg" alt="Twitter"></a>&nbsp;&nbsp;<a href="https://go.nuxt.com/github"><img width="20px" src="./.github/assets/github.svg" alt="GitHub"></a>
<a href="https://go.nuxt.com/discord"><img width="20px" src="./.github/assets/discord.svg" alt="Discord"></a>&nbsp;&nbsp;<a href="https://go.nuxt.com/x"><img width="20px" src="./.github/assets/twitter.svg" alt="Twitter"></a>&nbsp;&nbsp;<a href="https://go.nuxt.com/github"><img width="20px" src="./.github/assets/github.svg" alt="GitHub"></a>&nbsp;&nbsp;<a href="https://go.nuxt.com/bluesky"><img width="20px" src="./.github/assets/bluesky.svg" alt="Bluesky"></a>
</p>
## <a name="license">⚖️ License</a>

View File

@ -85,6 +85,9 @@ export default defineNuxtConfig({
// }
// }
// },
// features: {
// inlineStyles: true
// },
// unhead: {
// renderSSRHeadOptions: {
// omitLineBreaks: false
@ -281,6 +284,28 @@ export default defineNuxtConfig({
})
```
#### More Granular Inline Styles
🚦 **Impact Level**: Moderate
Nuxt will now only inline styles for Vue components, not global CSS.
##### What Changed
Previously, Nuxt would inline all CSS, including global styles, and remove `<link>` elements to separate CSS files. Now, Nuxt will only do this for Vue components (which previously produced separate chunks of CSS). We think this is a better balance of reducing separate network requests (just as before, there will not be separate requests for individual `.css` files per-page or per-component on the initial load), as well as allowing caching of a single global CSS file and reducing the document download size of the initial request.
##### Migration Steps
This feature is fully configurable and you can revert to the previous behavior by setting `inlineStyles: true` to inline global CSS as well as per-component CSS.
```ts twoslash [nuxt.config.ts]
export default defineNuxtConfig({
features: {
inlineStyles: true
}
})
```
#### Scan Page Meta After Resolution
🚦 **Impact Level**: Minimal

View File

@ -159,7 +159,7 @@ prerenderRoutes(["/some/other/url"]);
This is called before prerendering for additional routes to be registered.
```ts [nitro.config.ts]
```ts [nuxt.config.ts]
export default defineNuxtConfig({
hooks: {
async "prerender:routes"(ctx) {
@ -178,7 +178,7 @@ export default defineNuxtConfig({
This is called for each route during prerendering. You can use this for fine grained handling of each route that gets prerendered.
```ts [nitro.config.ts]
```ts [nuxt.config.ts]
export default defineNuxtConfig({
nitro: {
hooks: {

View File

@ -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 systems 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.

View File

@ -8,9 +8,7 @@ description: "Nuxt supports ESLint out of the box"
The recommended approach for Nuxt is to enable ESLint support using the [`@nuxt/eslint`](https://eslint.nuxt.com/packages/module) module, that will setup project-aware ESLint configuration for you.
:::callout{icon="i-ph-lightbulb"}
The module is designed for the [new ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) with is the [default format since ESLint v9](https://eslint.org/blog/2024/04/eslint-v9.0.0-released/).
If you are using the legacy `.eslintrc` config, you will need to [configure manually with `@nuxt/eslint-config`](https://eslint.nuxt.com/packages/config#legacy-config-format). We highly recommend you to migrate over the flat config to be future-proof.
The module is designed for the [new ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) with is the [default format since ESLint v9](https://eslint.org/blog/2024/04/eslint-v9.0.0-released/). If you are using the legacy `.eslintrc` config, you will need to [configure manually with `@nuxt/eslint-config`](https://eslint.nuxt.com/packages/config#legacy-config-format). We highly recommend you to migrate over the flat config to be future-proof.
:::
## Quick Setup

View File

@ -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
<script setup lang="ts">
import { someData } from '~/utils/example'
function validateIdParam(route) {
return route.params.id && !isNaN(Number(route.params.id))
}
const title = ref('')
definePageMeta({
title, // This will create an error
someData
validate: validateIdParam,
someData,
title, // do not do this, the ref will be hoisted out of the component
})
</script>
```

View File

@ -16,7 +16,7 @@ You can also pass a function that receives the path of a Vue component and retur
```ts [nuxt.config.ts]
export default defineNuxtConfig({
features: {
inlineStyles: true // or a function to determine inlining
inlineStyles: false // or a function to determine inlining
}
})
```
@ -74,6 +74,9 @@ export default defineNuxtConfig({
}
}
},
features: {
inlineStyles: true
},
unhead: {
renderSSRHeadOptions: {
omitLineBreaks: false

View File

@ -0,0 +1,203 @@
---
title: 'Sessions and Authentication'
description: "Authentication is an extremely common requirement in web apps. This recipe will show you how to implement basic user registration and authentication in your Nuxt app."
---
## Introduction
In this recipe we'll be setting up authentication in a full-stack Nuxt app using [Nuxt Auth Utils](https://github.com/Atinux/nuxt-auth-utils) which provides convenient utilities for managing client-side and server-side session data.
The module uses secured & sealed cookies to store session data, so you don't need to setup a database to store session data.
## Install nuxt-auth-utils
Install the `nuxt-auth-utils` module using the `nuxi` CLI.
```bash [Terminal]
npx nuxi@latest module add auth-utils
```
::callout
This command will install `nuxt-auth-utils` as dependency and push it in the `modules` section of our `nuxt.config.ts`
::
## Cookie Encryption Key
As `nuxt-auth-utils` uses sealed cookies to store session data, session cookies are encrypted using a secret key from the `NUXT_SESSION_PASSWORD` environment variable.
::note
If not set, this environment variable will be added to your `.env` automatically when running in development mode.
::
```dotenv [.env]
NUXT_SESSION_PASSWORD=a-random-password-with-at-least-32-characters
```
::important
You'll need to add this environment variable to your production environment before deploying.
::
## Login API Route
For this recipe, we'll create a simple API route to sign-in a user based on static data.
Let's create a `/api/login` API route that will accept a POST request with the email and password in the request body.
```ts [server/api/login.post.ts]
import { z } from 'zod'
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export default defineEventHandler(async (event) => {
const { email, password } = await readValidatedBody(event, bodySchema.parse)
if (email === 'admin@admin.com' && password === 'iamtheadmin') {
// set the user session in the cookie
// this server util is auto-imported by the auth-utils module
await setUserSession(event, {
user: {
name: 'John Doe'
}
})
return {}
}
throw createError({
statusCode: 401,
message: 'Bad credentials'
})
})
```
::callout
Make sure to install the `zod` dependency in your project (`npm i zod`).
::
::tip{to="https://github.com/atinux/nuxt-auth-utils#server-utils"}
Read more about the `setUserSession` server helper exposed by `nuxt-auth-utils`.
::
## Login Page
The module exposes a Vue composable to know if a user is authenticated in our application:
```vue
<script setup>
const { loggedIn, session, user, clear, fetch } = useUserSession()
</script>
```
Let's create a login page with a form to submit the login data to our `/api/login` route.
```vue [pages/login.vue]
<script setup lang="ts">
const { loggedIn, user, fetch: refreshSession } = useUserSession()
const credentials = reactive({
email: '',
password: '',
})
async function login() {
$fetch('/api/login', {
method: 'POST',
body: credentials
})
.then(async () => {
// Refresh the session on client-side and redirect to the home page
await refreshSession()
await navigateTo('/')
})
.catch(() => alert('Bad credentials'))
}
</script>
<template>
<form @submit.prevent="login">
<input v-model="credentials.email" type="email" placeholder="Email" />
<input v-model="credentials.password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
</template>
```
## Protect API Routes
Protecting server routes is key to making sure your data is safe. Client-side middleware is helpful for the user, but without server-side protection your data can still be accessed. It is critical to protect any routes with sensitive data, we should return a 401 error if the user is not logged in on those.
The `auth-utils` module provides the `requireUserSession` utility function to help make sure that users are logged in and have an active session.
Let's create an example of a `/api/user/stats` route that only authenticated users can access.
```ts [server/api/user/stats.get.ts]
export default defineEventHandler(async (event) => {
// make sure the user is logged in
// This will throw a 401 error if the request doesn't come from a valid user session
const { user } = await requireUserSession(event)
// TODO: Fetch some stats based on the user
return {}
});
```
## Protect App Routes
Our data is safe with the server-side route in place, but without doing anything else, unauthenticated users would probably get some odd data when trying to access the `/users` page. We should create a [client-side middleware](https://nuxt.com/docs/guide/directory-structure/middleware) to protect the route on the client side and redirect users to the login page.
`nuxt-auth-utils` provides a convenient `useUserSession` composable which we'll use to check if the user is logged in, and redirect them if they are not.
We'll create a middleware in the `/middleware` directory. Unlike on the server, client-side middleware is not automatically applied to all endpoints, and we'll need to specify where we want it applied.
```typescript [middleware/authenticated.ts]
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useUserSession()
// redirect the user to the login screen if they're not authenticated
if (!loggedIn.value) {
return navigateTo('/login')
}
})
```
## Home Page
Now that we have our app middleware to protect our routes, we can use it on our home page that display our authenticated user informations. If the user is not authenticated, they will be redirected to the login page.
We'll use [`definePageMeta`](/docs/api/utils/define-page-meta) to apply the middleware to the route that we want to protect.
```vue [pages/index.vue]
<script setup lang="ts">
definePageMeta({
middleware: ['authenticated'],
})
const { user, clear: clearSession } = useUserSession()
async function logout() {
await clearSession()
await navigateTo('/login')
}
</script>
<template>
<div>
<h1>Welcome {{ user.name }}</h1>
<button @click="logout">Logout</button>
</div>
</template>
```
We also added a logout button to clear the session and redirect the user to the login page.
## Conclusion
We've successfully set up a very basic user authentication and session management in our Nuxt app. We've also protected sensitive routes on the server and client side to ensure that only authenticated users can access them.
As next steps, you can:
- Add authentication using the [20+ supported OAuth providers](https://github.com/atinux/nuxt-auth-utils?tab=readme-ov-file#supported-oauth-providers)
- Add a database to store users, see [Nitro SQL Database](https://nitro.build/guide/database) or [NuxtHub SQL Database](https://hub.nuxt.com/docs/features/database)
- Let user signup with email & password using [password hashing](https://github.com/atinux/nuxt-auth-utils?tab=readme-ov-file#password-hashing)
- Add support for [WebAuthn / Passkeys](https://github.com/atinux/nuxt-auth-utils?tab=readme-ov-file#webauthn-passkey)
Checkout the open source [atidone repository](https://github.com/atinux/atidone) for a full example of a Nuxt app with OAuth authentication, database and CRUD operations.

View File

@ -8,16 +8,30 @@ links:
size: xs
---
<!--add-cmd-->
```bash [Terminal]
npx nuxi add [--cwd] [--force] <TEMPLATE> <NAME>
npx nuxi add <TEMPLATE> <NAME> [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--force]
```
<!--/add-cmd-->
Option | Default | Description
-------------------------|-----------------|------------------
`TEMPLATE` | - | Specify a template of the file to be generated.
`NAME` | - | Specify a name of the file that will be created.
`--cwd` | `.` | The directory of the target application.
`--force` | `false` | Force override file if it already exists.
### Arguments
<!--add-args-->
Argument | Description
--- | ---
`TEMPLATE` | Specify which template to generate (options: <api\|plugin\|component\|composable\|middleware\|layout\|page>)
`NAME` | Specify name of the generated file
<!--/add-args-->
### Options
<!--add-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | `.` | Specify the working directory
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--force` | `false` | Force override file if it already exists
<!--/add-opts-->
**Modifiers:**

View File

@ -8,15 +8,33 @@ links:
size: xs
---
<!--analyze-cmd-->
```bash [Terminal]
npx nuxi analyze [--log-level] [rootDir]
npx nuxi analyze [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv] [--name=<name>] [--no-serve]
```
<!--/analyze-cmd-->
The `analyze` command builds Nuxt and analyzes the production bundle (experimental).
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The directory of the target application.
## Arguments
<!--analyze-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/analyze-args-->
## Options
<!--analyze-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--dotenv` | | Path to `.env` file to load, relative to the root directory
`--name=<name>` | `default` | Name of the analysis
`--no-serve` | | Skip serving the analysis results
<!--/analyze-opts-->
::note
This command sets `process.env.NODE_ENV` to `production`.

View File

@ -8,17 +8,35 @@ links:
size: xs
---
<!--build-module-cmd-->
```bash [Terminal]
npx nuxi build-module [--stub] [rootDir]
npx nuxi build-module [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--build] [--stub] [--sourcemap] [--prepare]
```
<!--/build-module-cmd-->
The `build-module` command runs `@nuxt/module-builder` to generate `dist` directory within your `rootDir` that contains the full build for your **nuxt-module**.
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the module to bundle.
`--stub` | `false` | Stub out your module for development using [jiti](https://github.com/unjs/jiti#jiti). (**note:** This is mainly for development purposes.)
## Arguments
::read-more{to="https://github.com/nuxt/module-builder" icon="i-simple-icons-github" color="gray" target="_blank"}
<!--build-module-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/build-module-args-->
## Options
<!--build-module-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--build` | `false` | Build module for distribution
`--stub` | `false` | Stub dist instead of actually building it for development
`--sourcemap` | `false` | Generate sourcemaps
`--prepare` | `false` | Prepare module for local development
<!--/build-module-opts-->
::read-more{to="https://github.com/nuxt/module-builder" icon="i-simple-icons-github" color="gray" target="\_blank"}
Read more about `@nuxt/module-builder`.
::

View File

@ -8,19 +8,34 @@ links:
size: xs
---
<!--build-cmd-->
```bash [Terminal]
npx nuxi build [--prerender] [--preset] [--dotenv] [--log-level] [rootDir]
npx nuxi build [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--prerender] [--preset] [--dotenv] [--envName]
```
<!--/build-cmd-->
The `build` command creates a `.output` directory with all your application, server and dependencies ready for production.
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the application to bundle.
`--prerender` | `false` | Pre-render every route of your application. (**note:** This is an experimental flag. The behavior might be changed.)
`--preset` | - | Set a [Nitro preset](https://nitro.unjs.io/deploy#changing-the-deployment-preset)
`--dotenv` | `.` | Point to another `.env` file to load, **relative** to the root directory.
`--log-level` | `info` | Specify build-time logging level, allowing `silent` \| `info` \| `verbose`.
## Arguments
<!--build-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/build-args-->
## Options
<!--build-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--prerender` | | Build Nuxt and prerender static routes
`--preset` | | Nitro server preset
`--dotenv` | | Path to `.env` file to load, relative to the root directory
`--envName` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server)
<!--/build-opts-->
::note
This command sets `process.env.NODE_ENV` to `production`.

View File

@ -1,6 +1,6 @@
---
title: 'nuxi cleanup'
description: "Remove common generated Nuxt files and caches."
description: 'Remove common generated Nuxt files and caches.'
links:
- label: Source
icon: i-simple-icons-github
@ -8,16 +8,31 @@ links:
size: xs
---
<!--cleanup-cmd-->
```bash [Terminal]
npx nuxi cleanup [rootDir]
npx nuxi cleanup [ROOTDIR] [--cwd=<directory>]
```
<!--/cleanup-cmd-->
The `cleanup` command removes common generated Nuxt files and caches, including:
- `.nuxt`
- `.output`
- `node_modules/.vite`
- `node_modules/.cache`
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the project.
## Arguments
<!--cleanup-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/cleanup-args-->
## Options
<!--cleanup-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
<!--/cleanup-opts-->

View File

@ -8,25 +8,45 @@ links:
size: xs
---
<!--dev-cmd-->
```bash [Terminal]
npx nuxi dev [rootDir] [--dotenv] [--log-level] [--clipboard] [--open, -o] [--no-clear] [--port, -p] [--host, -h] [--https] [--ssl-cert] [--ssl-key] [--tunnel]
npx nuxi dev [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--dotenv] [--envName] [--no-clear] [--no-fork] [-p, --port] [-h, --host] [--clipboard] [-o, --open] [--https] [--publicURL] [--qr] [--public] [--tunnel] [--sslCert] [--sslKey]
```
<!--/dev-cmd-->
The `dev` command starts a development server with hot module replacement at [http://localhost:3000](https://localhost:3000)
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the application to serve.
`--dotenv` | `.` | Point to another `.env` file to load, **relative** to the root directory.
`--open, -o` | `false` | Open URL in browser.
`--clipboard` | `false` | Copy URL to clipboard.
`--no-clear` | `false` | Does not clear the console after startup.
`--port, -p` | `3000` | Port to listen.
`--host, -h` | `localhost` | Hostname of the server.
`--https` | `false` | Listen with `https` protocol with a self-signed certificate by default.
`--ssl-cert` |`null` | Specify a certificate for https.
`--ssl-key` |`null` | Specify the key for the https certificate.
`--tunnel` | `false` | Tunnel your local server to the internet with [unjs/untun](https://github.com/unjs/untun)
## Arguments
<!--dev-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/dev-args-->
## Options
<!--dev-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--dotenv` | | Path to `.env` file to load, relative to the root directory
`--envName` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server)
`--no-clear` | | Disable clear console on restart
`--no-fork` | | Disable forked mode
`-p, --port` | | Port to listen on (default: `NUXT_PORT \|\| NITRO_PORT \|\| PORT \|\| nuxtOptions.devServer.port`)
`-h, --host` | | Host to listen on (default: `NUXT_HOST \|\| NITRO_HOST \|\| HOST \|\| nuxtOptions._layers?.[0]?.devServer?.host`)
`--clipboard` | `false` | Copy the URL to the clipboard
`-o, --open` | `false` | Open the URL in the browser
`--https` | | Enable HTTPS
`--publicURL` | | Displayed public URL (used for QR code)
`--qr` | | Display The QR code of public URL when available
`--public` | | Listen to all network interfaces
`--tunnel` | | Open a tunnel using https://github.com/unjs/untun
`--sslCert` | | (DEPRECATED) Use `--https.cert` instead.
`--sslKey` | | (DEPRECATED) Use `--https.key` instead.
<!--/dev-opts-->
The port and host can also be set via NUXT_PORT, PORT, NUXT_HOST or HOST environment variables.

View File

@ -8,16 +8,31 @@ links:
size: xs
---
<!--devtools-cmd-->
```bash [Terminal]
npx nuxi devtools enable|disable [rootDir]
npx nuxi devtools <COMMAND> [ROOTDIR] [--cwd=<directory>]
```
<!--/devtools-cmd-->
Running `nuxi devtools enable` will install the Nuxt DevTools globally, and also enable it within the particular project you are using. It is saved as a preference in your user-level `.nuxtrc`. If you want to remove devtools support for a particular project, you can run `nuxi devtools disable`.
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the app you want to enable devtools for.
## Arguments
::read-more{icon="i-simple-icons-nuxtdotjs" to="https://devtools.nuxt.com" target="_blank"}
<!--devtools-args-->
Argument | Description
--- | ---
`COMMAND` | Command to run (options: <enable\|disable>)
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/devtools-args-->
## Options
<!--devtools-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
<!--/devtools-opts-->
::read-more{icon="i-simple-icons-nuxtdotjs" to="https://devtools.nuxt.com" target="\_blank"}
Read more about the **Nuxt DevTools**.
::

View File

@ -8,16 +8,33 @@ links:
size: xs
---
<!--generate-cmd-->
```bash [Terminal]
npx nuxi generate [rootDir] [--dotenv]
npx nuxi generate [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--preset] [--dotenv] [--envName]
```
<!--/generate-cmd-->
The `generate` command pre-renders every route of your application and stores the result in plain HTML files that you can deploy on any static hosting services. The command triggers the `nuxi build` command with the `prerender` argument set to `true`
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the application to generate
`--dotenv` | `.` | Point to another `.env` file to load, **relative** to the root directory.
## Arguments
<!--generate-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/generate-args-->
## Options
<!--generate-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--preset` | | Nitro server preset
`--dotenv` | | Path to `.env` file to load, relative to the root directory
`--envName` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server)
<!--/generate-opts-->
::read-more{to="/docs/getting-started/deployment#static-hosting"}
Read more about pre-rendering and static hosting.

View File

@ -8,12 +8,26 @@ links:
size: xs
---
<!--info-cmd-->
```bash [Terminal]
npx nuxi info [rootDir]
npx nuxi info [ROOTDIR] [--cwd=<directory>]
```
<!--/info-cmd-->
The `info` command logs information about the current or specified Nuxt project.
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The directory of the target application.
## Arguments
<!--info-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/info-args-->
## Options
<!--info-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
<!--/info-opts-->

View File

@ -8,27 +8,37 @@ links:
size: xs
---
<!--init-cmd-->
```bash [Terminal]
npx nuxi init [--verbose|-v] [--template,-t] [dir]
npx nuxi init [DIR] [--cwd=<directory>] [-t, --template] [-f, --force] [--offline] [--preferOffline] [--no-install] [--gitInit] [--shell] [--packageManager]
```
<!--/init-cmd-->
The `init` command initializes a fresh Nuxt project using [unjs/giget](https://github.com/unjs/giget).
## Arguments
<!--init-args-->
Argument | Description
--- | ---
`DIR=""` | Project directory
<!--/init-args-->
## Options
Option | Default | Description
-------------------------|-----------------|------------------
`--cwd` | | Current working directory
`--log-level` | | Log level
`--template, -t` | `v3` | Specify template name or git repository to use as a template. Format is `gh:org/name` to use a custom github template.
`--force, -f` | `false` | Force clone to any existing directory.
`--offline` | `false` | Force offline mode (do not attempt to download template from GitHub and only use local cache).
`--prefer-offline` | `false` | Prefer offline mode (try local cache first to download templates).
`--no-install` | `false` | Skip installing dependencies.
`--git-init` | `false` | Initialize git repository.
`--shell` | `false` | Start shell after installation in project directory (experimental).
`--package-manager` | `npm` | Package manager choice (npm, pnpm, yarn, bun).
`--dir` | | Project directory.
<!--init-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | `.` | Specify the working directory
`-t, --template` | | Template name
`-f, --force` | | Override existing directory
`--offline` | | Force offline mode
`--preferOffline` | | Prefer offline mode
`--no-install` | | Skip installing dependencies
`--gitInit` | | Initialize git repository
`--shell` | | Start shell after installation in project directory
`--packageManager` | | Package manager choice (npm, pnpm, yarn, bun)
<!--/init-opts-->
## Environment variables

View File

@ -12,17 +12,31 @@ Nuxi provides a few utilities to work with [Nuxt modules](/modules) seamlessly.
## nuxi module add
<!--module-add-cmd-->
```bash [Terminal]
npx nuxi module add <NAME>
npx nuxi module add <MODULENAME> [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--skipInstall] [--skipConfig]
```
<!--/module-add-cmd-->
Option | Default | Description
-------------------------|-----------------|------------------
`NAME` | - | The name of the module to install.
<!--module-add-args-->
Argument | Description
--- | ---
`MODULENAME` | Module name
<!--/module-add-args-->
<!--module-add-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | `.` | Specify the working directory
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--skipInstall` | | Skip npm install
`--skipConfig` | | Skip nuxt.config.ts update
<!--/module-add-opts-->
The command lets you install [Nuxt modules](/modules) in your application with no manual work.
When running the command, it will:
- install the module as a dependency using your package manager
- add it to your [package.json](/docs/guide/directory-structure/package) file
- update your [`nuxt.config`](/docs/guide/directory-structure/nuxt-config) file
@ -30,19 +44,35 @@ When running the command, it will:
**Example:**
Installing the [`Pinia`](/modules/pinia) module
```bash [Terminal]
npx nuxi module add pinia
npx nuxi module add pinia
```
## nuxi module search
<!--module-search-cmd-->
```bash [Terminal]
npx nuxi module search <QUERY>
npx nuxi module search <QUERY> [--cwd=<directory>] [--nuxtVersion=<2|3>]
```
<!--/module-search-cmd-->
Option | Default | Description
-------------------------|-----------------|------------------
`QUERY` | - | The name of the module to search for.
### Arguments
<!--module-search-args-->
Argument | Description
--- | ---
`QUERY` | keywords to search for
<!--/module-search-args-->
### Options
<!--module-search-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | `.` | Specify the working directory
`--nuxtVersion=<2\|3>` | | Filter by Nuxt version and list compatible modules only (auto detected by default)
<!--/module-search-opts-->
The command searches for Nuxt modules matching your query that are compatible with your Nuxt version.

View File

@ -8,12 +8,29 @@ links:
size: xs
---
<!--prepare-cmd-->
```bash [Terminal]
npx nuxi prepare [--log-level] [rootDir]
npx nuxi prepare [ROOTDIR] [--dotenv] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--envName]
```
<!--/prepare-cmd-->
The `prepare` command creates a [`.nuxt`](/docs/guide/directory-structure/nuxt) directory in your application and generates types. This can be useful in a CI environment or as a `postinstall` command in your [`package.json`](/docs/guide/directory-structure/package).
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the application to prepare.
## Arguments
<!--prepare-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/prepare-args-->
## Options
<!--prepare-opts-->
Option | Default | Description
--- | --- | ---
`--dotenv` | | Path to `.env` file to load, relative to the root directory
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--envName` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server)
<!--/prepare-opts-->

View File

@ -8,16 +8,32 @@ links:
size: xs
---
<!--preview-cmd-->
```bash [Terminal]
npx nuxi preview|start [rootDir] [--dotenv]
npx nuxi preview [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [--envName] [--dotenv]
```
<!--/preview-cmd-->
The `preview` command starts a server to preview your Nuxt application after running the `build` command. The `start` command is an alias for `preview`. When running your application in production refer to the [Deployment section](/docs/getting-started/deployment).
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The root directory of the application to preview.
`--dotenv` | `.` | Point to another `.env` file to load, **relative** to the root directory.
## Arguments
<!--preview-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/preview-args-->
## Options
<!--preview-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`--envName` | | The environment to use when resolving configuration overrides (default is `production` when building, and `development` when running the dev server)
`--dotenv` | | Path to `.env` file to load, relative to the root directory
<!--/preview-opts-->
This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a `.env` file or as command-line argument.

View File

@ -8,15 +8,30 @@ links:
size: xs
---
<!--typecheck-cmd-->
```bash [Terminal]
npx nuxi typecheck [--log-level] [rootDir]
npx nuxi typecheck [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>]
```
<!--/typecheck-cmd-->
The `typecheck` command runs [`vue-tsc`](https://github.com/vuejs/language-tools/tree/master/packages/tsc) to check types throughout your app.
Option | Default | Description
-------------------------|-----------------|------------------
`rootDir` | `.` | The directory of the target application.
## Arguments
<!--typecheck-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/typecheck-args-->
## Options
<!--typecheck-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
<!--/typecheck-opts-->
::note
This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a [`.env`](/docs/guide/directory-structure/env) file or as a command-line argument.

View File

@ -8,13 +8,29 @@ links:
size: xs
---
<!--upgrade-cmd-->
```bash [Terminal]
npx nuxi upgrade [--force|-f]
npx nuxi upgrade [ROOTDIR] [--cwd=<directory>] [--logLevel=<silent|info|verbose>] [-f, --force] [-ch, --channel=<stable|nightly>]
```
<!--/upgrade-cmd-->
The `upgrade` command upgrades Nuxt to the latest version.
Option | Default | Description
-------------------------|-----------------|------------------
`--force, -f` | `false` | Removes `node_modules` and lock files before upgrade.
`--channel, -ch` | `"stable"` | Specify a channel to install from ("nightly" or "stable")
## Arguments
<!--upgrade-args-->
Argument | Description
--- | ---
`ROOTDIR="."` | Specifies the working directory (default: `.`)
<!--/upgrade-args-->
## Options
<!--upgrade-opts-->
Option | Default | Description
--- | --- | ---
`--cwd=<directory>` | | Specify the working directory, this takes precedence over ROOTDIR (default: `.`)
`--logLevel=<silent\|info\|verbose>` | | Specify build-time log level
`-f, --force` | | Force upgrade to recreate lockfile and node_modules
`-ch, --channel=<stable\|nightly>` | `stable` | Specify a channel to install from (default: stable)
<!--/upgrade-opts-->

View File

@ -50,25 +50,25 @@
"@vue/shared": "3.5.13",
"c12": "2.0.1",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"jiti": "2.4.1",
"jiti": "2.4.2",
"magic-string": "^0.30.17",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nuxt": "workspace:*",
"ohash": "1.1.4",
"postcss": "8.4.49",
"rollup": "4.28.1",
"rollup": "4.29.1",
"send": ">=1.1.0",
"typescript": "5.6.3",
"typescript": "5.7.2",
"ufo": "1.5.4",
"unbuild": "3.0.1",
"unhead": "1.11.14",
"unimport": "3.14.5",
"vite": "6.0.3",
"vite": "6.0.6",
"vue": "3.5.13"
},
"devDependencies": {
"@arethetypeswrong/cli": "0.17.1",
"@nuxt/eslint-config": "0.7.3",
"@arethetypeswrong/cli": "0.17.2",
"@nuxt/eslint-config": "0.7.4",
"@nuxt/kit": "workspace:*",
"@nuxt/rspack-builder": "workspace:*",
"@nuxt/test-utils": "3.15.1",
@ -83,23 +83,23 @@
"autoprefixer": "10.4.20",
"case-police": "0.7.2",
"changelogen": "0.5.7",
"consola": "3.2.3",
"consola": "3.3.3",
"cssnano": "7.0.6",
"destr": "2.0.3",
"devalue": "5.1.1",
"eslint": "9.17.0",
"eslint-plugin-no-only-tests": "3.3.0",
"eslint-plugin-perfectionist": "4.3.0",
"eslint-plugin-perfectionist": "4.4.0",
"eslint-typegen": "0.3.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"happy-dom": "15.11.7",
"happy-dom": "16.0.1",
"installed-check": "9.3.0",
"jiti": "2.4.1",
"knip": "5.40.0",
"jiti": "2.4.2",
"knip": "5.41.1",
"markdownlint-cli": "0.43.0",
"memfs": "~4.14.1",
"memfs": "4.15.1",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nuxi": "3.16.0",
"nuxi": "3.17.2",
"nuxt": "workspace:*",
"nuxt-content-twoslash": "0.1.2",
"ofetch": "1.4.1",
@ -110,15 +110,15 @@
"std-env": "3.8.0",
"tinyexec": "0.3.1",
"tinyglobby": "0.2.10",
"typescript": "5.6.3",
"typescript": "5.7.2",
"ufo": "1.5.4",
"vitest": "2.1.8",
"vitest-environment-nuxt": "1.0.1",
"vue": "3.5.13",
"vue-tsc": "2.1.10",
"webpack": "5.96.1"
"vue-tsc": "2.2.0",
"webpack": "5.97.1"
},
"packageManager": "pnpm@9.15.0",
"packageManager": "pnpm@9.15.1",
"engines": {
"node": "^18.20.4 || ^20.9.0 || ^22.0.0 || >=23.0.0"
},

View File

@ -29,33 +29,33 @@
"dependencies": {
"@nuxt/schema": "workspace:*",
"c12": "^2.0.1",
"consola": "^3.2.3",
"consola": "^3.3.3",
"defu": "^6.1.4",
"destr": "^2.0.3",
"errx": "^0.1.0",
"globby": "^14.0.2",
"ignore": "^6.0.2",
"jiti": "^2.4.1",
"ignore": "^7.0.0",
"jiti": "^2.4.2",
"klona": "^2.0.6",
"mlly": "^1.7.3",
"ohash": "^1.1.4",
"pathe": "^1.1.2",
"pkg-types": "^1.2.1",
"pkg-types": "^1.3.0",
"scule": "^1.3.0",
"semver": "^7.6.3",
"ufo": "^1.5.4",
"unctx": "^2.4.0",
"unctx": "^2.4.1",
"unimport": "^3.14.5",
"untyped": "^1.5.2"
},
"devDependencies": {
"@rspack/core": "1.1.6",
"@rspack/core": "1.1.8",
"@types/semver": "7.5.8",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"unbuild": "3.0.1",
"vite": "6.0.3",
"vite": "6.0.6",
"vitest": "2.1.8",
"webpack": "5.96.1"
"webpack": "5.97.1"
},
"engines": {
"node": ">=18.20.5"

View File

@ -230,6 +230,7 @@ export async function _generateTypes (nuxt: Nuxt) {
: nuxt.options.buildDir
tsConfig.compilerOptions = tsConfig.compilerOptions || {}
tsConfig.compilerOptions.paths = tsConfig.compilerOptions.paths || {}
tsConfig.include = tsConfig.include || []
for (const alias in aliases) {

View File

@ -65,10 +65,10 @@
},
"dependencies": {
"@nuxt/devalue": "^2.0.2",
"@nuxt/devtools": "^1.6.4",
"@nuxt/devtools": "^1.7.0",
"@nuxt/kit": "workspace:*",
"@nuxt/schema": "workspace:*",
"@nuxt/telemetry": "^2.6.0",
"@nuxt/telemetry": "^2.6.2",
"@nuxt/vite-builder": "workspace:*",
"@unhead/dom": "^1.11.14",
"@unhead/shared": "^1.11.14",
@ -77,36 +77,36 @@
"@vue/shared": "^3.5.13",
"acorn": "8.14.0",
"c12": "^2.0.1",
"chokidar": "^4.0.1",
"chokidar": "^4.0.3",
"compatx": "^0.1.8",
"consola": "^3.2.3",
"consola": "^3.3.3",
"cookie-es": "^1.2.2",
"defu": "^6.1.4",
"destr": "^2.0.3",
"devalue": "^5.1.1",
"errx": "^0.1.0",
"esbuild": "^0.24.0",
"esbuild": "^0.24.2",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"hookable": "^5.5.3",
"ignore": "^6.0.2",
"ignore": "^7.0.0",
"impound": "^0.2.0",
"jiti": "^2.4.1",
"jiti": "^2.4.2",
"klona": "^2.0.6",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"mlly": "^1.7.3",
"nanotar": "^0.1.1",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"nuxi": "^3.16.0",
"nuxi": "^3.17.2",
"nypm": "^0.4.1",
"ofetch": "^1.4.1",
"ohash": "^1.1.4",
"pathe": "^1.1.2",
"perfect-debounce": "^1.0.0",
"pkg-types": "^1.2.1",
"pkg-types": "^1.3.0",
"radix3": "^1.1.2",
"scule": "^1.3.0",
"semver": "^7.6.3",
@ -116,13 +116,13 @@
"ufo": "^1.5.4",
"ultrahtml": "^1.5.3",
"uncrypto": "^0.1.3",
"unctx": "^2.4.0",
"unctx": "^2.4.1",
"unenv": "^1.10.0",
"unhead": "^1.11.14",
"unimport": "^3.14.5",
"unplugin": "^2.1.0",
"unplugin-vue-router": "^0.10.9",
"unstorage": "^1.13.1",
"unstorage": "^1.14.4",
"untyped": "^1.5.2",
"vue": "^3.5.13",
"vue-bundle-renderer": "^2.1.1",
@ -136,7 +136,7 @@
"@vitejs/plugin-vue": "5.2.1",
"@vue/compiler-sfc": "3.5.13",
"unbuild": "3.0.1",
"vite": "6.0.3",
"vite": "6.0.6",
"vitest": "2.1.8"
},
"peerDependencies": {

View File

@ -1,13 +1,10 @@
import { createElementBlock, defineComponent, onMounted, ref } from 'vue'
import { createElementBlock, defineComponent, onMounted, ref, useId } from 'vue'
import { useState } from '../composables/state'
export default defineComponent({
name: 'NuxtClientFallback',
inheritAttrs: false,
props: {
uid: {
type: String,
},
fallbackTag: {
type: String,
default: () => 'div',
@ -30,8 +27,7 @@ export default defineComponent({
emits: ['ssr-error'],
setup (props, ctx) {
const mounted = ref(false)
// This is deliberate - `uid` should not be provided by user but by a transform plugin and will not be reactive.
const ssrFailed = useState(`${props.uid}`)
const ssrFailed = useState(useId())
if (ssrFailed.value) {
onMounted(() => { mounted.value = true })

View File

@ -1,18 +1,14 @@
import { defineComponent, getCurrentInstance, onErrorCaptured, ref } from 'vue'
import { defineComponent, getCurrentInstance, onErrorCaptured, ref, useId } from 'vue'
import { ssrRenderAttrs, ssrRenderSlot, ssrRenderVNode } from 'vue/server-renderer'
import { isPromise } from '@vue/shared'
import { useState } from '../composables/state'
import { useNuxtApp } from '../nuxt'
import { createBuffer } from './utils'
const NuxtClientFallbackServer = defineComponent({
name: 'NuxtClientFallback',
inheritAttrs: false,
props: {
uid: {
type: String,
},
fallbackTag: {
type: String,
default: () => 'div',
@ -37,11 +33,10 @@ const NuxtClientFallbackServer = defineComponent({
return true
},
},
async setup (props, ctx) {
async setup (_, ctx) {
const vm = getCurrentInstance()
const ssrFailed = ref(false)
const nuxtApp = useNuxtApp()
const error = useState<boolean | undefined>(`${props.uid}`)
const error = useState<boolean | undefined>(useId())
onErrorCaptured((err) => {
error.value = true
@ -68,7 +63,7 @@ const NuxtClientFallbackServer = defineComponent({
return { ssrFailed, ssrVNodes }
} catch (ssrError) {
// catch in dev
nuxtApp.runWithContext(() => useState(`${props.uid}`, () => true))
error.value = true
ctx.emit('ssr-error', ssrError)
return { ssrFailed: true, ssrVNodes: [] }
}

View File

@ -18,6 +18,8 @@ import { cancelIdleCallback, requestIdleCallback } from '../compat/idle-callback
// @ts-expect-error virtual file
import { nuxtLinkDefaults } from '#build/nuxt.config.mjs'
import { hashMode } from '#build/router.options'
const firstNonUndefined = <T> (...args: (T | undefined)[]) => args.find(arg => arg !== undefined)
const NuxtLinkDevKeySymbol: InjectionKey<boolean> = Symbol('nuxt-link-dev-key')
@ -110,6 +112,10 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
}
}
function isHashLinkWithoutHashMode (link: unknown): boolean {
return !hashMode && typeof link === 'string' && link.startsWith('#')
}
function resolveTrailingSlashBehavior (to: string, resolve: Router['resolve']): string
function resolveTrailingSlashBehavior (to: RouteLocationRaw, resolve: Router['resolve']): Exclude<RouteLocationRaw, string>
function resolveTrailingSlashBehavior (to: RouteLocationRaw | undefined, resolve: Router['resolve']): RouteLocationRaw | RouteLocation | undefined {
@ -176,7 +182,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
// Resolves `to` value if it's a route location object
const href = computed(() => {
if (!to.value || isAbsoluteUrl.value) { return to.value as string }
if (!to.value || isAbsoluteUrl.value || isHashLinkWithoutHashMode(to.value)) {
return to.value as string
}
if (isExternal.value) {
const path = typeof to.value === 'object' && 'path' in to.value ? resolveRouteObject(to.value) : to.value
@ -373,7 +381,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) {
}
return () => {
if (!isExternal.value && !hasTarget.value) {
if (!isExternal.value && !hasTarget.value && !isHashLinkWithoutHashMode(to.value)) {
const routerLinkProps: RouterLinkProps & VNodeProps & AllowedComponentProps & AnchorHTMLAttributes = {
ref: elRef,
to: to.value,

View File

@ -350,6 +350,12 @@ export function useAsyncData<
if (import.meta.client) {
// Setup hook callbacks once per instance
const instance = getCurrentInstance()
// @ts-expect-error - instance.sp is an internal vue property
if (instance && fetchOnServer && options.immediate && !instance.sp) {
// @ts-expect-error - internal vue property. This force vue to mark the component as async boundary client-side to avoid useId hydration issue since we treeshake onServerPrefetch
instance.sp = []
}
if (import.meta.dev && !nuxtApp.isHydrating && !nuxtApp._processingMiddleware /* internal flag */ && (!instance || instance?.isMounted)) {
// @ts-expect-error private property
console.warn(`[nuxt] [${options._functionName || 'useAsyncData'}] Component is already mounted, please use $fetch instead. See https://nuxt.com/docs/getting-started/data-fetching`)

View File

@ -1,18 +1,23 @@
import { useNuxtApp } from '../nuxt'
type CallOnceOptions = {
mode?: 'navigation' | 'render'
}
/**
* An SSR-friendly utility to call a method once
* @param key a unique key ensuring the function can be properly de-duplicated across requests
* @param fn a function to call
* @param options Setup the mode, e.g. to re-execute on navigation
* @see https://nuxt.com/docs/api/utils/call-once
* @since 3.9.0
*/
export function callOnce (key?: string, fn?: (() => any | Promise<any>)): Promise<void>
export function callOnce (fn?: (() => any | Promise<any>)): Promise<void>
export function callOnce (key?: string, fn?: (() => any | Promise<any>), options?: CallOnceOptions): Promise<void>
export function callOnce (fn?: (() => any | Promise<any>), options?: CallOnceOptions): Promise<void>
export async function callOnce (...args: any): Promise<void> {
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
if (typeof args[0] !== 'string') { args.unshift(autoKey) }
const [_key, fn] = args as [string, (() => any | Promise<any>)]
const [_key, fn, options] = args as [string, (() => any | Promise<any>), CallOnceOptions | undefined]
if (!_key || typeof _key !== 'string') {
throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key)
}
@ -20,10 +25,18 @@ export async function callOnce (...args: any): Promise<void> {
throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn)
}
const nuxtApp = useNuxtApp()
if (options?.mode === 'navigation') {
nuxtApp.hooks.hookOnce('page:start', () => {
nuxtApp.payload.once.delete(_key)
})
}
// If key already ran
if (nuxtApp.payload.once.has(_key)) {
return
}
nuxtApp._once = nuxtApp._once || {}
nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true
await nuxtApp._once[_key]

View File

@ -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
}

View File

@ -7,7 +7,6 @@ import { distDir } from '../dirs'
import { componentNamesTemplate, componentsIslandsTemplate, componentsMetadataTemplate, componentsPluginTemplate, componentsTypeTemplate } from './templates'
import { scanComponents } from './scan'
import { ClientFallbackAutoIdPlugin } from './plugins/client-fallback-auto-id'
import { LoaderPlugin } from './plugins/loader'
import { ComponentsChunkPlugin, IslandsTransformPlugin } from './plugins/islands-transform'
import { TransformPlugin } from './plugins/transform'
@ -218,11 +217,6 @@ export default defineNuxtModule<ComponentsOptions>({
addBuildPlugin(TreeShakeTemplatePlugin({ sourcemap: !!nuxt.options.sourcemap.server, getComponents }), { client: false })
if (nuxt.options.experimental.clientFallback) {
addBuildPlugin(ClientFallbackAutoIdPlugin({ sourcemap: !!nuxt.options.sourcemap.client, rootDir: nuxt.options.rootDir }), { server: false })
addBuildPlugin(ClientFallbackAutoIdPlugin({ sourcemap: !!nuxt.options.sourcemap.server, rootDir: nuxt.options.rootDir }), { client: false })
}
const sharedLoaderOptions = {
getComponents,
serverComponentRuntime,

View File

@ -1,55 +0,0 @@
import { createUnplugin } from 'unplugin'
import type { ComponentsOptions } from '@nuxt/schema'
import MagicString from 'magic-string'
import { isAbsolute, relative } from 'pathe'
import { hash } from 'ohash'
import { isVue } from '../../core/utils'
interface LoaderOptions {
sourcemap?: boolean
transform?: ComponentsOptions['transform']
rootDir: string
}
const CLIENT_FALLBACK_RE = /<(?:NuxtClientFallback|nuxt-client-fallback)(?: [^>]*)?>/
const CLIENT_FALLBACK_GLOBAL_RE = /<(NuxtClientFallback|nuxt-client-fallback)( [^>]*)?>/g
const UID_RE = / :?uid=/
export const ClientFallbackAutoIdPlugin = (options: LoaderOptions) => createUnplugin(() => {
const exclude = options.transform?.exclude || []
const include = options.transform?.include || []
return {
name: 'nuxt:client-fallback-auto-id',
enforce: 'pre',
transformInclude (id) {
if (exclude.some(pattern => pattern.test(id))) {
return false
}
if (include.some(pattern => pattern.test(id))) {
return true
}
return isVue(id)
},
transform (code, id) {
if (!CLIENT_FALLBACK_RE.test(code)) { return }
const s = new MagicString(code)
const relativeID = isAbsolute(id) ? relative(options.rootDir, id) : id
let count = 0
s.replace(CLIENT_FALLBACK_GLOBAL_RE, (full, name, attrs) => {
count++
if (UID_RE.test(attrs)) { return full }
return `<${name} :uid="'${hash(relativeID)}' + JSON.stringify($props) + '${count}'" ${attrs ?? ''}>`
})
if (s.hasChanged()) {
return {
code: s.toString(),
map: options.sourcemap
? s.generateMap({ hires: true })
: undefined,
}
}
},
}
})

View File

@ -18,6 +18,8 @@ export const createClientPage = (loader: AsyncComponentLoader) => {
setup (_, { attrs }) {
const nuxtApp = useNuxtApp()
if (import.meta.server || nuxtApp.isHydrating) {
// wrapped with div to avoid Transition issues
// @see https://github.com/nuxt/nuxt/pull/25037#issuecomment-1877423894
return () => h('div', [
h(ClientOnly, undefined, {
default: () => h(page, attrs),

View File

@ -106,7 +106,12 @@ function createWatcher () {
],
})
watcher.on('all', (event, path) => nuxt.callHook('builder:watch', event, normalize(path)))
watcher.on('all', (event, path) => {
if (event === 'all' || event === 'ready' || event === 'error' || event === 'raw') {
return
}
nuxt.callHook('builder:watch', event, normalize(path))
})
nuxt.hook('close', () => watcher?.close())
}
@ -134,6 +139,9 @@ function createGranularWatcher () {
const watchers: Record<string, FSWatcher> = {}
watcher.on('all', (event, path) => {
if (event === 'all' || event === 'ready' || event === 'error' || event === 'raw') {
return
}
path = normalize(path)
if (!pending) {
nuxt.callHook('builder:watch', event, path)
@ -144,7 +152,12 @@ function createGranularWatcher () {
}
if (event === 'addDir' && path !== dir && !ignoredDirs.has(path) && !pathsToWatch.includes(path) && !(path in watchers) && !isIgnored(path)) {
const pathWatcher = watchers[path] = chokidarWatch(path, { ...nuxt.options.watchers.chokidar, ignored: [isIgnored] })
pathWatcher.on('all', (event, p) => nuxt.callHook('builder:watch', event, normalize(p)))
pathWatcher.on('all', (event, p) => {
if (event === 'all' || event === 'ready' || event === 'error' || event === 'raw') {
return
}
nuxt.callHook('builder:watch', event, normalize(p))
})
nuxt.hook('close', () => pathWatcher?.close())
}
})

View File

@ -380,7 +380,7 @@ async function initNuxt (nuxt: Nuxt) {
// Transform initial composable call within `<script setup>` to preserve context
if (nuxt.options.experimental.asyncContext) {
addBuildPlugin(AsyncContextInjectionPlugin(nuxt))
addBuildPlugin(AsyncContextInjectionPlugin(nuxt), { client: false })
}
// TODO: [Experimental] Avoid emitting assets when flag is enabled

View File

@ -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<string, Set<string>>
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<string, { source?: string | RegExp }>) {

View File

@ -7,7 +7,7 @@ import type { Nuxt } from '@nuxt/schema'
import { pkgDir } from '../../dirs'
export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin {
const exclude: string[] = ['virtual:', '\0virtual:', '/__skip_vite']
const exclude: string[] = ['virtual:', '\0virtual:', '/__skip_vite', '@vitest/']
let conditions: string[]
return {
name: 'nuxt:resolve-bare-imports',

View File

@ -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> = 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 }) {
interface WalkOptions {
enter: WalkerCallback
leave: WalkerCallback
scopeTracker: ScopeTracker
}
export function walk (ast: Program | Node, callback: Partial<WalkOptions>) {
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<Node>)
callback.enter?.call(this, node as WithLocations<Node>, parent as WithLocations<Node> | null, { key, index, ast })
},
leave (node, parent, key, index) {
// @ts-expect-error - accessing a protected property
callback.scopeTracker?.processNodeLeave(node as WithLocations<Node>)
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) {
export function parseAndWalk (code: string, sourceFilename: string, object: Partial<WalkOptions>): Program
export function parseAndWalk (code: string, _sourceFilename: string, callback: Partial<WalkOptions> | 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<T> (node: T): WithLocations<T> {
return node as WithLocations<T>
}
abstract class BaseNode<T extends Node = Node> {
abstract type: string
node: WithLocations<T>
constructor (node: WithLocations<T>) {
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<Identifier> {
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<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>
constructor (node: WithLocations<Node>, fnNode: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>) {
super(node)
this.fnNode = fnNode
}
get start () {
return this.fnNode.start
}
get end () {
return this.fnNode.end
}
}
class FunctionNode extends BaseNode<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression> {
type = 'Function' as const
get start () {
return this.node.start
}
get end () {
return this.node.end
}
}
class VariableNode extends BaseNode<Identifier> {
type = 'Variable' as const
variableNode: WithLocations<VariableDeclaration>
constructor (node: WithLocations<Identifier>, variableNode: WithLocations<VariableDeclaration>) {
super(node)
this.variableNode = variableNode
}
get start () {
return this.variableNode.start
}
get end () {
return this.variableNode.end
}
}
class ImportNode extends BaseNode<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier> {
type = 'Import' as const
importNode: WithLocations<Node>
constructor (node: WithLocations<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier>, importNode: WithLocations<Node>) {
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<CatchClause>
constructor (node: WithLocations<Node>, catchNode: WithLocations<CatchClause>) {
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<string, Map<string, WithLocations<ScopeTrackerNode>>> = new Map()
protected options: Partial<ScopeTrackerOptions>
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<Node>, fn: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression>) {
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<Node>, parent: WithLocations<FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | VariableDeclaration | CatchClause>) {
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<Node>) {
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<Node>) {
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<Node>) {
const identifiers: WithLocations<Identifier>[] = []
function collectIdentifiers (pattern: WithLocations<Node>) {
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
}
export function isNotReferencePosition (node: WithLocations<Node>, parent: WithLocations<Node> | 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<string>()
function isIdentifierUndeclared (node: WithLocations<Identifier>, parent: WithLocations<Node> | 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)
}

View File

@ -1,6 +1,7 @@
declare module '#build/router.options' {
import type { RouterOptions } from '@nuxt/schema'
export const hashMode: boolean
const _default: RouterOptions
export default _default
}

View File

@ -42,6 +42,7 @@ export default defineNuxtModule({
delete tsConfig.compilerOptions.paths['#vue-router/*']
})
const builtInRouterOptions = await findPath(resolve(runtimeDir, 'router.options')) || resolve(runtimeDir, 'router.options')
async function resolveRouterOptions () {
const context = {
files: [] as Array<{ path: string, optional?: boolean }>,
@ -53,7 +54,7 @@ export default defineNuxtModule({
}
// Add default options at beginning
context.files.unshift({ path: await findPath(resolve(runtimeDir, 'router.options')) || resolve(runtimeDir, 'router.options'), optional: true })
context.files.unshift({ path: builtInRouterOptions, optional: true })
await nuxt.callHook('pages:routerOptions', context)
return context.files
@ -128,6 +129,16 @@ export default defineNuxtModule({
'export const START_LOCATION = Symbol(\'router:start-location\')',
].join('\n'),
})
// used by `<NuxtLink>`
addTemplate({
filename: 'router.options.mjs',
getContents: () => {
return [
'export const hashMode = false',
'export default {}',
].join('\n')
},
})
addTypeTemplate({
filename: 'types/middleware.d.ts',
getContents: () => [
@ -535,6 +546,7 @@ export default defineNuxtModule({
return [
...routerOptionsFiles.map((file, index) => genImport(file.path, `routerOptions${index}`)),
`const configRouterOptions = ${configRouterOptions}`,
`export const hashMode = ${[...routerOptionsFiles.filter(o => o.path !== builtInRouterOptions).map((_, index) => `routerOptions${index}.hashMode`).reverse(), nuxt.options.router.options.hashMode].join(' ?? ')}`,
'export default {',
'...configRouterOptions,',
...routerOptionsFiles.map((_, index) => `...routerOptions${index},`),
@ -632,6 +644,7 @@ if (import.meta.hot) {
export function handleHotUpdate(_router) {
if (import.meta.hot) {
import.meta.hot.data ||= {}
import.meta.hot.data.router = _router
}
}

View File

@ -3,12 +3,19 @@ import { createUnplugin } from 'unplugin'
import { parseQuery, parseURL } from 'ufo'
import type { StaticImport } from 'mlly'
import { findExports, findStaticImports, parseStaticImport } from 'mlly'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { isAbsolute } from 'pathe'
import { logger } from '@nuxt/kit'
import { parseAndWalk, withLocations } from '../../core/utils/parse'
import {
ScopeTracker,
type ScopeTrackerNode,
getUndeclaredIdentifiersInFunction,
isNotReferencePosition,
parseAndWalk,
walk,
withLocations,
} from '../../core/utils/parse'
interface PageMetaPluginOptions {
dev?: boolean
@ -119,38 +126,130 @@ 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<string>()
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)
}
/**
* Adds an import or a declaration to the extracted code.
* @param name The name of the import or declaration to add.
* @param node The node that is currently being processed. (To detect self-references)
*/
function addImportOrDeclaration (name: string, node?: ScopeTrackerNode) {
if (isStaticIdentifier(name)) {
addImport(name)
} else {
const declaration = scopeTracker.getDeclaration(name)
/*
Without checking for `declaration !== node`, we would end up in an infinite loop
when, for example, a variable is declared and then used in its own initializer.
(we shouldn't mask the underlying error by throwing a `Maximum call stack size exceeded` error)
```ts
const a = { b: a }
```
*/
if (declaration && declaration !== node) {
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 (scopeTrackerNode: ScopeTrackerNode | null) {
if (scopeTrackerNode?.type === 'Variable') {
addDeclaration(scopeTrackerNode)
for (const decl of scopeTrackerNode.variableNode.declarations) {
if (!decl.init) { continue }
walk(decl.init, {
enter: (node, parent) => {
if (node.type === 'AwaitExpression') {
logger.error(`[nuxt] Await expressions are not supported in definePageMeta. File: '${id}'`)
throw new Error('await in definePageMeta')
}
if (
isNotReferencePosition(node, parent)
|| node.type !== 'Identifier' // checking for `node.type` to narrow down the type
) { return }
addImportOrDeclaration(node.name, scopeTrackerNode)
},
})
}
} else if (scopeTrackerNode?.type === 'Function') {
// arrow functions are going to be assigned to a variable
if (scopeTrackerNode.node.type === 'ArrowFunctionExpression') { return }
const name = scopeTrackerNode.node.id?.name
if (!name) { return }
addDeclaration(scopeTrackerNode)
const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(scopeTrackerNode.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, parent) {
if (
isNotReferencePosition(node, parent)
|| node.type !== 'Identifier' // checking for `node.type` to narrow down the type
) { 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')) {

View File

@ -7,7 +7,7 @@ import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt'
import { prerenderRoutes } from '#app/composables/ssr'
// @ts-expect-error virtual file
import _routes from '#build/routes'
import routerOptions from '#build/router.options'
import routerOptions, { hashMode } from '#build/router.options'
// @ts-expect-error virtual file
import { crawlLinks } from '#build/nuxt.config.mjs'
@ -16,7 +16,7 @@ let routes: string[]
let _routeRulesMatcher: undefined | ReturnType<typeof toRouteMatcher> = undefined
export default defineNuxtPlugin(async () => {
if (!import.meta.server || !import.meta.prerender || routerOptions.hashMode) {
if (!import.meta.server || !import.meta.prerender || hashMode) {
return
}
if (routes && !routes.length) { return }

View File

@ -19,7 +19,7 @@ import { navigateTo } from '#app/composables/router'
import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs'
// @ts-expect-error virtual file
import _routes, { handleHotUpdate } from '#build/routes'
import routerOptions from '#build/router.options'
import routerOptions, { hashMode } from '#build/router.options'
// @ts-expect-error virtual file
import { globalMiddleware, namedMiddleware } from '#build/middleware'
@ -51,13 +51,13 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({
enforce: 'pre',
async setup (nuxtApp) {
let routerBase = useRuntimeConfig().app.baseURL
if (routerOptions.hashMode && !routerBase.includes('#')) {
if (hashMode && !routerBase.includes('#')) {
// allow the user to provide a `#` in the middle: `/base/#/app`
routerBase += '#'
}
const history = routerOptions.history?.(routerBase) ?? (import.meta.client
? (routerOptions.hashMode ? createWebHashHistory(routerBase) : createWebHistory(routerBase))
? (hashMode ? createWebHashHistory(routerBase) : createWebHistory(routerBase))
: createMemoryHistory(routerBase)
)

View File

@ -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
}
}

View File

@ -119,6 +119,11 @@ describe('nuxt-link:isExternal', () => {
expect(nuxtLink({ to: '/foo/bar', target: '_blank' }).type).toBe(EXTERNAL)
expect(nuxtLink({ to: '/foo/bar?baz=qux', target: '_blank' }).type).toBe(EXTERNAL)
})
it('returns `true` if link starts with hash', () => {
expect(nuxtLink({ href: '#hash' }).type).toBe(EXTERNAL)
expect(nuxtLink({ to: '#hash' }).type).toBe(EXTERNAL)
})
})
describe('nuxt-link:propsOrAttributes', () => {

View File

@ -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,238 @@ definePageMeta({
export default __nuxt_page_meta"
`)
})
it('should extract local functions', () => {
const sfc = `
<script setup lang="ts">
function isNumber(value) {
return value && !isNaN(Number(value))
}
function validateIdParam (route) {
return isNumber(route.params.id)
}
definePageMeta({
validate: validateIdParam,
test: () => 'hello',
})
</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(`
"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 = `
<script setup lang="ts">
import { validateIdParam } from './utils'
definePageMeta({
validate: validateIdParam,
dynamic: ref(true),
})
</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(`
"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 = `
<script setup lang="ts">
import { foo } from './utils'
const checkNum = (value) => {
return !isNaN(Number(foo(value)))
}
function isNumber (value) {
return value && checkNum(value)
}
definePageMeta({
validate: ({ params }) => {
return isNumber(params.id)
},
})
</script>
`
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 = `
<script setup lang="ts">
const asyncValue = await Promise.resolve('test')
definePageMeta({
key: asyncValue,
})
</script>
`
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)
})
it('should only add definitions for reference identifiers', () => {
const sfc = `
<script setup lang="ts">
const foo = 'foo'
const bar = { bar: 'bar' }.bar, baz = { baz: 'baz' }.baz, x = { foo }
const test = 'test'
const prop = 'prop'
const num = 1
const val = 'val'
const useVal = () => ({ val: 'val' })
function recursive () {
recursive()
}
definePageMeta({
middleware: [
() => {
console.log(bar, baz)
recursive()
const val = useVal().val
const obj = {
num,
prop: 'prop',
}
const c = class test {
prop = 'prop'
test () {}
}
},
],
})
</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 foo = 'foo'
const num = 1
const bar = { bar: 'bar' }.bar, baz = { baz: 'baz' }.baz, x = { foo }
const useVal = () => ({ val: 'val' })
function recursive () {
recursive()
}
const __nuxt_page_meta = {
middleware: [
() => {
console.log(bar, baz)
recursive()
const val = useVal().val
const obj = {
num,
prop: 'prop',
}
const c = class test {
prop = 'prop'
test () {}
}
},
],
}
export default __nuxt_page_meta"
`)
})
})

View File

@ -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)
})
})

View File

@ -32,7 +32,7 @@
"dependencies": {
"@nuxt/friendly-errors-webpack-plugin": "^2.6.0",
"@nuxt/kit": "workspace:*",
"@rspack/core": "^1.1.6",
"@rspack/core": "^1.1.8",
"autoprefixer": "^10.4.20",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
@ -44,11 +44,11 @@
"fork-ts-checker-webpack-plugin": "^9.0.2",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"jiti": "^2.4.1",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"lodash-es": "4.17.21",
"magic-string": "^0.30.17",
"memfs": "^4.14.1",
"memfs": "^4.15.1",
"ohash": "^1.1.4",
"pathe": "^1.1.2",
"pify": "^6.1.0",
@ -77,7 +77,7 @@
"@types/pify": "5.0.4",
"@types/webpack-bundle-analyzer": "4.7.0",
"@types/webpack-hot-middleware": "2.25.9",
"rollup": "4.28.1",
"rollup": "4.29.1",
"unbuild": "3.0.1",
"vue": "3.5.13"
},

View File

@ -42,32 +42,32 @@
"@vitejs/plugin-vue-jsx": "4.1.1",
"@vue/compiler-core": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
"@vue/language-core": "2.1.10",
"@vue/language-core": "2.2.0",
"esbuild-loader": "4.2.2",
"file-loader": "6.2.0",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"ignore": "6.0.2",
"ignore": "7.0.0",
"nitro": "npm:nitro-nightly@3.0.0-beta-28796231.359af68d",
"ofetch": "1.4.1",
"sass-loader": "16.0.4",
"unbuild": "3.0.1",
"unctx": "2.4.0",
"vite": "6.0.3",
"unctx": "2.4.1",
"vite": "6.0.6",
"vue": "3.5.13",
"vue-bundle-renderer": "2.1.1",
"vue-loader": "17.4.2",
"vue-router": "4.5.0",
"webpack": "5.96.1",
"webpack": "5.97.1",
"webpack-dev-middleware": "7.4.2"
},
"dependencies": {
"c12": "^2.0.1",
"compatx": "^0.1.8",
"consola": "^3.2.3",
"consola": "^3.3.3",
"defu": "^6.1.4",
"hookable": "^5.5.3",
"pathe": "^1.1.2",
"pkg-types": "^1.2.1",
"pkg-types": "^1.3.0",
"scule": "^1.3.0",
"std-env": "^3.8.0",
"ufo": "^1.5.4",

View File

@ -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.

View File

@ -146,8 +146,7 @@ export default defineUntypedSchema({
*/
keyedComposables: {
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
{ name: 'useId', argumentLength: 1 },
{ name: 'callOnce', argumentLength: 2 },
{ name: 'callOnce', argumentLength: 3 },
{ name: 'defineNuxtComponent', argumentLength: 2 },
{ name: 'useState', argumentLength: 2 },
{ name: 'useFetch', argumentLength: 3 },

View File

@ -59,8 +59,8 @@ export default defineUntypedSchema({
if (val === false || (await get('dev')) || (await get('ssr')) === false || (await get('builder')) === '@nuxt/webpack-builder') {
return false
}
// Enabled by default for vite prod with ssr
return val ?? true
// Enabled by default for vite prod with ssr (for vue components)
return val ?? ((await get('future') as Record<string, unknown>).compatibilityVersion === 4 ? (id: string) => id && id.includes('.vue') : true)
},
},

View File

@ -17,11 +17,11 @@
"prerender": "pnpm build && jiti ./lib/prerender"
},
"devDependencies": {
"@unocss/reset": "0.65.1",
"@unocss/reset": "0.65.3",
"beasties": "0.2.0",
"html-validate": "8.27.0",
"html-validate": "9.1.0",
"htmlnano": "2.1.1",
"jiti": "2.4.1",
"jiti": "2.4.2",
"knitwork": "1.2.0",
"pathe": "1.1.2",
"prettier": "3.4.2",
@ -29,8 +29,8 @@
"svgo": "3.3.2",
"tinyexec": "0.3.1",
"tinyglobby": "0.2.10",
"unocss": "0.65.1",
"vite": "6.0.3"
"unocss": "0.65.3",
"vite": "6.0.6"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"

View File

@ -26,8 +26,7 @@
},
"devDependencies": {
"@nuxt/schema": "workspace:*",
"@types/clear": "0.1.4",
"rollup": "4.28.1",
"rollup": "4.29.1",
"unbuild": "3.0.1",
"vue": "3.5.13"
},
@ -37,28 +36,27 @@
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"autoprefixer": "^10.4.20",
"clear": "^0.1.0",
"consola": "^3.2.3",
"consola": "^3.3.3",
"cssnano": "^7.0.6",
"defu": "^6.1.4",
"esbuild": "^0.24.0",
"esbuild": "^0.24.2",
"escape-string-regexp": "^5.0.0",
"externality": "^1.0.2",
"get-port-please": "^3.1.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"jiti": "^2.4.1",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"magic-string": "^0.30.17",
"mlly": "^1.7.3",
"pathe": "^1.1.2",
"pkg-types": "^1.2.1",
"pkg-types": "^1.3.0",
"postcss": "^8.4.49",
"rollup-plugin-visualizer": "^5.12.0",
"rollup-plugin-visualizer": "^5.13.1",
"std-env": "^3.8.0",
"ufo": "^1.5.4",
"unenv": "^1.10.0",
"unplugin": "^2.1.0",
"vite": "^6.0.3",
"vite": "^6.0.6",
"vite-node": "^2.1.8",
"vite-plugin-checker": "^0.8.0",
"vue-bundle-renderer": "^2.1.1"

View File

@ -103,6 +103,9 @@ export async function buildClient (ctx: ViteBuildContext) {
'ufo',
'unctx',
'unenv',
// these will never be imported on the client
'#app-manifest',
],
},
resolve: {

View File

@ -1,8 +1,8 @@
import type * as vite from 'vite'
import { createLogger } from 'vite'
import { logger } from '@nuxt/kit'
import { colorize } from 'consola/utils'
import { hasTTY, isCI } from 'std-env'
import clear from 'clear'
import type { NuxtOptions } from '@nuxt/schema'
import { useResolveFromPublicAssets } from '../plugins/public-dirs'
@ -27,6 +27,13 @@ const RUNTIME_RESOLVE_REF_RE = /^([^ ]+) referenced in/m
export function createViteLogger (config: vite.InlineConfig): vite.Logger {
const loggedErrors = new WeakSet<any>()
const canClearScreen = hasTTY && !isCI && config.clearScreen
const _logger = createLogger()
const clear = () => {
_logger.clearScreen(
// @ts-expect-error silent is a log level but not a valid option for clearScreens
'silent',
)
}
const clearScreen = canClearScreen ? clear : () => {}
const { resolveFromPublicAssets } = useResolveFromPublicAssets()

View File

@ -43,11 +43,11 @@
"fork-ts-checker-webpack-plugin": "^9.0.2",
"globby": "^14.0.2",
"h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e",
"jiti": "^2.4.1",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"lodash-es": "4.17.21",
"magic-string": "^0.30.17",
"memfs": "^4.14.1",
"memfs": "^4.15.1",
"mini-css-extract-plugin": "^2.9.2",
"ohash": "^1.1.4",
"pathe": "^1.1.2",
@ -66,7 +66,7 @@
"url-loader": "^4.1.1",
"vue-bundle-renderer": "^2.1.1",
"vue-loader": "^17.4.2",
"webpack": "^5.96.1",
"webpack": "^5.97.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-dev-middleware": "^7.4.2",
"webpack-hot-middleware": "^2.26.1",
@ -74,12 +74,12 @@
},
"devDependencies": {
"@nuxt/schema": "workspace:*",
"@rspack/core": "1.1.6",
"@rspack/core": "1.1.8",
"@types/lodash-es": "4.17.12",
"@types/pify": "5.0.4",
"@types/webpack-bundle-analyzer": "4.7.0",
"@types/webpack-hot-middleware": "2.25.9",
"rollup": "4.28.1",
"rollup": "4.29.1",
"unbuild": "3.0.1",
"vue": "3.5.13"
},

View File

@ -88,9 +88,18 @@ export function dynamicRequire ({ dir, ignore, inline }: Options): Plugin {
}
}
type WebpackChunk = {
id: string
ids: string[]
modules: Record<string, unknown>
__webpack_id__?: string
__webpack_ids__?: string[]
__webpack_modules__?: Record<string, unknown>
}
async function getWebpackChunkMeta (src: string) {
const chunk = await importModule<{ id: string, ids: string[], modules: Record<string, unknown> }>(src) || {}
const { id, ids, modules } = chunk
const chunk = await importModule<WebpackChunk>(src) || {}
const { __webpack_id__, __webpack_ids__, __webpack_modules__, id = __webpack_id__, ids = __webpack_ids__, modules = __webpack_modules__ } = chunk
if (!id && !ids) {
return null // Not a webpack chunk
}

File diff suppressed because it is too large Load Diff

View File

@ -31,12 +31,6 @@
"@nuxt/kit"
]
},
{
"groupName": "typescript",
"matchPackageNames": [
"typescript"
]
},
{
"groupName": "webpack",
"matchPackageNames": [

View File

@ -1223,6 +1223,15 @@ describe('composables', () => {
const { page } = await renderPage('/once')
expect(await page.getByText('once:').textContent()).toContain('once: 2')
})
it('`callOnce` should run code once with navigation mode during initial render', async () => {
const html = await $fetch<string>('/once-nav-initial')
expect(html).toContain('once.vue')
expect(html).toContain('once: 2')
const { page } = await renderPage('/once-nav-initial')
expect(await page.getByText('once:').textContent()).toContain('once: 2')
})
it('`useId` should generate unique ids', async () => {
// TODO: work around interesting Vue bug where async components are loaded in a different order on first import
await $fetch<string>('/use-id')
@ -2616,6 +2625,11 @@ describe.skipIf(isWindows)('useAsyncData', () => {
await page.close()
})
it('works with useId', async () => {
const html = await $fetch<string>('/useAsyncData/use-id')
expect(html).toContain('<div>v-0-0-0</div> v-0-0</div>')
await expectNoClientErrors('/useAsyncData/use-id')
})
})
describe.runIf(isDev())('component testing', () => {

View File

@ -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')))

View File

@ -467,11 +467,6 @@ describe('composables', () => {
expectTypeOf(useFetch('/test', { default: () => 500 }).data).toEqualTypeOf<Ref<unknown>>()
})
it('prevents passing string to `useId`', () => {
// @ts-expect-error providing a key is not allowed
useId('test')
})
it('enforces readonly cookies', () => {
// @ts-expect-error readonly cookie
useCookie('test', { readonly: true }).value = 'thing'

View File

@ -0,0 +1,14 @@
<script setup>
const counter = useState('once', () => 0)
await callOnce(() => counter.value++, { mode: 'navigation' })
await callOnce('same-key', () => counter.value++, { mode: 'navigation' })
await callOnce('same-key', () => counter.value++, { mode: 'navigation' })
</script>
<template>
<div>
<div>once.vue</div>
<div>once: {{ counter }}</div>
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
const Child = defineComponent({
setup () {
const id = useId()
return () => h('div', id)
},
})
const id = useId()
useAsyncData('test', () => Promise.resolve('A'))
</script>
<template>
<div>
<Child />
{{ id }}
</div>
</template>

View File

@ -29,6 +29,10 @@ if (process.env.TEST_ENV !== 'built' && !isWindows) {
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
describe('hmr', () => {
it('should load dev server', async () => {
await expectWithPolling(() => $fetch<string>('/').then(r => r.includes('Home page')).catch(() => null), true)
})
it('should work', async () => {
const { page, pageErrors, consoleLogs } = await renderPage('/')

View File

@ -0,0 +1,2 @@
export default {}
export const hashMode = false

View File

@ -1,6 +1,6 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'
import { destr } from 'destr'
@ -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`', () => {
@ -766,33 +778,55 @@ describe('useCookie', () => {
})
describe('callOnce', () => {
it('should only call composable once', async () => {
const fn = vi.fn()
const execute = () => callOnce(fn)
await execute()
await execute()
expect(fn).toHaveBeenCalledTimes(1)
})
describe.each([
['without options', undefined],
['with "render" option', { mode: 'render' as const }],
['with "navigation" option', { mode: 'navigation' as const }],
])('%s', (_name, options) => {
const nuxtApp = useNuxtApp()
afterEach(() => {
nuxtApp.payload.once.clear()
})
it('should only call composable once', async () => {
const fn = vi.fn()
const execute = () => options ? callOnce(fn, options) : callOnce(fn)
await execute()
await execute()
expect(fn).toHaveBeenCalledTimes(1)
})
it('should only call composable once when called in parallel', async () => {
const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
const execute = () => callOnce(fn)
await Promise.all([execute(), execute(), execute()])
expect(fn).toHaveBeenCalledTimes(1)
it('should only call composable once when called in parallel', async () => {
const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
const execute = () => options ? callOnce(fn, options) : callOnce(fn)
await Promise.all([execute(), execute(), execute()])
expect(fn).toHaveBeenCalledTimes(1)
const fnSync = vi.fn().mockImplementation(() => {})
const executeSync = () => callOnce(fnSync)
await Promise.all([executeSync(), executeSync(), executeSync()])
expect(fnSync).toHaveBeenCalledTimes(1)
})
const fnSync = vi.fn().mockImplementation(() => {})
const executeSync = () => options ? callOnce(fnSync, options) : callOnce(fnSync)
await Promise.all([executeSync(), executeSync(), executeSync()])
expect(fnSync).toHaveBeenCalledTimes(1)
})
it('should use key to dedupe', async () => {
const fn = vi.fn()
const execute = (key?: string) => callOnce(key, fn)
await execute('first')
await execute('first')
await execute('second')
expect(fn).toHaveBeenCalledTimes(2)
it('should use key to dedupe', async () => {
const fn = vi.fn()
const execute = (key?: string) => options ? callOnce(key, fn, options) : callOnce(key, fn)
await execute('first')
await execute('first')
await execute('second')
expect(fn).toHaveBeenCalledTimes(2)
})
it.runIf(options?.mode === 'navigation')('should rerun on navigation', async () => {
const fn = vi.fn()
const execute = () => options ? callOnce(fn, options) : callOnce(fn)
await execute()
await execute()
expect(fn).toHaveBeenCalledTimes(1)
await nuxtApp.callHook('page:start')
await execute()
expect(fn).toHaveBeenCalledTimes(2)
})
})
})

View File

@ -1,7 +1,8 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isWindows } from 'std-env'
import { $fetch, createPage, setup, url } from '@nuxt/test-utils/e2e'
import { $fetch, createPage, fetch, setup, url } from '@nuxt/test-utils/e2e'
import { expectWithPolling } from '../utils'
const isWebpack =
process.env.TEST_BUILDER === 'webpack' ||
@ -25,6 +26,9 @@ await setup({
})
describe('spaLoadingTemplateLocation flag is set to `within`', () => {
it.runIf(isDev)('should load dev server', async () => {
await expectWithPolling(() => fetch('/').then(r => r.status === 200).catch(() => null), true)
})
it('should render loader inside appTag', async () => {
const html = await $fetch<string>('/spa')
expect(html).toContain(`<div id="__nuxt"><div data-testid="loader">loading...</div></div>`)

View File

@ -1,14 +1,16 @@
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { isWindows } from 'std-env'
import { createPage, setup, url } from '@nuxt/test-utils/e2e'
import { createPage, fetch, setup, url } from '@nuxt/test-utils/e2e'
import type { Page } from 'playwright-core'
import { expectWithPolling } from '../utils'
const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
const isDev = process.env.TEST_ENV === 'dev'
await setup({
rootDir: fileURLToPath(new URL('../fixtures/spa-loader', import.meta.url)),
dev: process.env.TEST_ENV === 'dev',
dev: isDev,
server: true,
browser: true,
setupTimeout: (isWindows ? 360 : 120) * 1000,
@ -22,6 +24,9 @@ await setup({
})
describe('spaLoadingTemplateLocation flag is set to `body`', () => {
it.runIf(isDev)('should load dev server', async () => {
await expectWithPolling(() => fetch('/').then(r => r.status === 200).catch(() => null), true)
})
it('should render spa-loader', async () => {
const page = await createPage()
await page.goto(url('/spa'), { waitUntil: 'domcontentloaded' })

View File

@ -8,6 +8,7 @@ export default defineConfig({
resolve: {
alias: {
'#build/nuxt.config.mjs': resolve('./test/mocks/nuxt-config'),
'#build/router.options': resolve('./test/mocks/router-options'),
'#internal/nuxt/paths': resolve('./test/mocks/paths'),
'#build/app.config.mjs': resolve('./test/mocks/app-config'),
'#app': resolve('./packages/nuxt/dist/app'),