diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 68d3ffb9d6..3bbbe17f89 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts +FROM node:lts@sha256:fffa89e023a3351904c04284029105d9e2ac7020886d683775a298569591e5bb 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 && \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a5708e8235..469ac12b19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,7 @@ // https://containers.dev/implementors/json_reference/ { "name": "nuxt-devcontainer", - "dockerFile": "Dockerfile", + "build": { "dockerfile": "Dockerfile" }, "features": {}, "customizations": { "vscode": { diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 513682b8ec..0000000000 --- a/.eslintrc +++ /dev/null @@ -1,168 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc", - "ignorePatterns": [ - "dist", - "public", - "node_modules", - "packages/schema/schema" - ], - "globals": { - "NodeJS": true, - "$fetch": true - }, - "plugins": ["jsdoc", "import", "unicorn", "no-only-tests"], - "extends": [ - "plugin:jsdoc/recommended", - "@nuxt/eslint-config", - "plugin:import/typescript" - ], - "rules": { - "sort-imports": [ - "error", - { - "ignoreDeclarationSort": true - } - ], - "no-only-tests/no-only-tests": "error", - "unicorn/prefer-node-protocol": "error", - "no-console": "warn", - "vue/one-component-per-file": "off", - "vue/require-default-prop": "off", - - // Vue stylistic rules from `@antfu/eslint-config` - "vue/array-bracket-spacing": ["error", "never"], - "vue/arrow-spacing": ["error", { "after": true, "before": true }], - "vue/block-spacing": ["error", "always"], - "vue/block-tag-newline": [ - "error", - { - "multiline": "always", - "singleline": "always" - } - ], - "vue/brace-style": ["error", "stroustrup", { "allowSingleLine": true }], - "vue/comma-dangle": ["error", "always-multiline"], - "vue/comma-spacing": ["error", { "after": true, "before": false }], - "vue/comma-style": ["error", "last"], - "vue/html-comment-content-spacing": [ - "error", - "always", - { - "exceptions": ["-"] - } - ], - "vue/key-spacing": ["error", { "afterColon": true, "beforeColon": false }], - "vue/keyword-spacing": ["error", { "after": true, "before": true }], - "vue/object-curly-newline": "off", - "vue/object-curly-spacing": ["error", "always"], - "vue/object-property-newline": [ - "error", - { "allowMultiplePropertiesPerLine": true } - ], - "vue/operator-linebreak": ["error", "before"], - "vue/padding-line-between-blocks": ["error", "always"], - "vue/quote-props": ["error", "consistent-as-needed"], - "vue/space-in-parens": ["error", "never"], - "vue/template-curly-spacing": "error", - - "jsdoc/require-jsdoc": "off", - "jsdoc/require-param": "off", - "jsdoc/require-returns": "off", - "jsdoc/require-param-type": "off", - "import/order": [ - "error", - { - "pathGroups": [ - { - "pattern": "#vue-router", - "group": "external" - } - ] - } - ], - "import/no-restricted-paths": [ - "error", - { - "zones": [ - { - "from": "packages/nuxt/src/!(core)/**/*", - "target": "packages/nuxt/src/core", - "message": "core should not directly import from modules." - }, - { - "from": "packages/nuxt/src/!(app)/**/*", - "target": "packages/nuxt/src/app", - "message": "app should not directly import from modules." - }, - { - "from": "packages/nuxt/src/app/**/index.ts", - "target": "packages/nuxt/src", - "message": "should not import from barrel/index files" - }, - { - "from": "packages/nitro", - "target": "packages/!(nitro)/**/*", - "message": "nitro should not directly import other packages." - } - ] - } - ], - "@typescript-eslint/consistent-type-imports": [ - "error", - { - "disallowTypeAnnotations": false - } - ], - "@typescript-eslint/ban-ts-comment": [ - "error", - { - "ts-expect-error": "allow-with-description", - "ts-ignore": true - } - ], - "@typescript-eslint/prefer-ts-expect-error": "error", - "@typescript-eslint/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "ignoreRestSiblings": true - } - ], - "jsdoc/check-tag-names": [ - "error", - { - "definedTags": ["__NO_SIDE_EFFECTS__"] - } - ] - }, - "overrides": [ - { - "files": ["packages/schema/**"], - "rules": { - "jsdoc/valid-types": "off", - "jsdoc/check-tag-names": [ - "error", - { - "definedTags": ["experimental"] - } - ] - } - }, - { - "files": ["packages/nuxt/src/app/**", "test/**", "**/runtime/**"], - "rules": { - "no-console": "off" - } - } - ], - "settings": { - "jsdoc": { - "ignoreInternal": true, - "tagNamePreference": { - "warning": "warning", - "note": "note" - } - } - } -} diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index ad059c3630..780ffed438 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,6 @@ name: "\U0001F41E Bug report" description: Create a report to help us improve Nuxt -labels: ["pending triage", "3.x"] +labels: ["pending triage"] body: - type: markdown attributes: @@ -36,7 +36,7 @@ body: validations: required: true - type: textarea - id: additonal + id: additional attributes: label: Additional context description: If applicable, add any other context about the problem here diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7b2d765c12..3527bf128b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,8 @@ blank_issues_enabled: true contact_links: - - name: πŸ“š Nuxt 3 Documentation - url: https://nuxt.com/docs/ - about: Check the documentation for usage of Nuxt 3 - - name: πŸ“š Nuxt 2 Documentation - url: https://v2.nuxt.com/ - about: Check the documentation for usage of Nuxt 2 + - name: πŸ“š Nuxt Documentation + url: https://nuxt.com/docs + about: Check the documentation for usage of Nuxt - name: πŸ’¬ Discussions url: https://github.com/nuxt/nuxt/discussions about: Use discussions if you have another issue, an idea for improvement or for asking questions. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index a5614e9304..b155f19563 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,6 @@ name: "πŸš€ Feature request" description: Suggest a feature that will improve Nuxt -labels: ["pending triage", "3.x"] +labels: ["pending triage"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/z-bug-report-2.yml b/.github/ISSUE_TEMPLATE/z-bug-report-2.yml deleted file mode 100644 index d4124367b2..0000000000 --- a/.github/ISSUE_TEMPLATE/z-bug-report-2.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: "\U0001F41E Bug report (Nuxt 2)" -description: Create a report to help us improve Nuxt -labels: ["pending triage", "2.x"] -body: - - type: markdown - attributes: - value: | - Please carefully read the contribution docs before creating a bug report - πŸ‘‰ https://nuxt.com/docs/community/reporting-bugs - - Please use a template below to create a minimal reproduction - πŸ‘‰ https://stackblitz.com/github/nuxt/starter/tree/v2 - πŸ‘‰ https://codesandbox.io/s/github/nuxt/starter/v2 - - type: textarea - id: bug-env - attributes: - label: Environment - description: You can use `npx envinfo --system --npmPackages '{nuxt,@nuxt/*}' --binaries --browsers` to fill this section - placeholder: Environment - validations: - required: true - - type: textarea - id: reproduction - attributes: - label: Reproduction - description: Please provide a link to a repo that can reproduce the problem you ran into. A [**minimal reproduction**](https://nuxt.com/docs/community/reporting-bugs#create-a-minimal-reproduction) is required unless you are absolutely sure that the issue is obvious and the provided information is enough to understand the problem. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "need reproduction" label. If no reproduction is provided we might close it. - placeholder: Reproduction - validations: - required: true - - type: textarea - id: bug-description - attributes: - label: Describe the bug - description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! - placeholder: Bug description - validations: - required: true - - type: textarea - id: additonal - attributes: - label: Additional context - description: If applicable, add any other context about the problem here - - type: textarea - id: logs - attributes: - label: Logs - description: | - Optional if provided reproduction. Please try not to insert an image but copy paste the log text. - render: shell-script diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f99be3b89d..2618070610 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,37 +1,19 @@ - - ### πŸ”— Linked issue - - -### ❓ Type of change - - - -- [ ] πŸ“– Documentation (updates to the documentation, readme or JSdoc annotations) -- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue) -- [ ] πŸ‘Œ Enhancement (improving an existing functionality like performance) -- [ ] ✨ New feature (a non-breaking change that adds functionality) -- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries) -- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change) + ### πŸ“š Description - - - + -### πŸ“ Checklist + - - +- Check that there isn't already a PR that solves the problem the same way. If you find a duplicate, please help us reviewing it. +- Read the contribution docs at https://nuxt.com/docs/community/contribution +- Ensure that PR title follows conventional commits (https://www.conventionalcommits.org) +- Update the corresponding documentation if needed. +- Include relevant tests that fail without this PR but pass with it. -- [ ] I have linked an issue or discussion. -- [ ] I have added tests (if possible). -- [ ] I have updated the documentation accordingly. +Thank you for contributing to Nuxt! +-----------------------------------------------------------------------> diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..1ab482ad65 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,10 @@ +paths: + - 'packages/*/dist/**' + - 'packages/nuxt/bin/**' + - 'packages/schema/schema/**' +paths-ignore: + - 'test/**' + - '**/*.test.js' + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/__tests__/**' diff --git a/.github/workflows/autofix-docs.yml b/.github/workflows/autofix-docs.yml index 8091bf631b..c4350a2356 100644 --- a/.github/workflows/autofix-docs.yml +++ b/.github/workflows/autofix-docs.yml @@ -17,9 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -27,7 +27,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build (stub) + run: pnpm dev:prepare + - name: Lint (docs) run: pnpm lint:docs:fix - - uses: autofix-ci/action@ea32e3a12414e6d3183163c3424a7d7a8631ad84 + - uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 09e2bfd25a..610d3ac48e 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -26,9 +26,6 @@ jobs: - name: Build (stub) run: pnpm dev:prepare - - name: Lint (code) - run: pnpm lint:fix - - name: Test (unit) run: pnpm test:unit -u @@ -52,4 +49,7 @@ jobs: if: ${{ !contains(github.head_ref, 'renovate') }} run: pnpm vitest run bundle -u - - uses: autofix-ci/action@ea32e3a12414e6d3183163c3424a7d7a8631ad84 + - name: Lint (code) + run: pnpm lint:fix + + - uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 23af9bb1a5..e7e84f981a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -15,8 +15,6 @@ env: # 7 GiB by default on GitHub, setting to 6 GiB # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources NODE_OPTIONS: --max-old-space-size=6144 - # install playwright binary manually (because pnpm only runs install script once) - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" # Remove default permissions of GITHUB_TOKEN for security # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs @@ -31,9 +29,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -48,7 +46,7 @@ jobs: run: pnpm build - name: Run benchmarks - uses: CodSpeedHQ/action@fce3a2f16d0b352af341dcacb25caadfd9159055 # v2.1.1 + uses: CodSpeedHQ/action@ab07afd34cbbb7a1306e8d14b7cc44e029eee37a # v3.0.0 with: run: pnpm vitest bench token: ${{ secrets.CODSPEED_TOKEN }} diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 0000000000..bda394c562 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,38 @@ +# From https://github.com/actions/cache/blob/main/tips-and-workarounds.md#force-deletion-of-caches-overriding-default-cache-eviction-policy + +name: cache +on: + pull_request: + types: + - closed + +permissions: {} + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + # `actions:write` permission is required to delete caches + # See also: https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#delete-a-github-actions-cache-for-a-repository-using-a-cache-id + actions: write + contents: read + steps: + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache keys" + cacheKeysForPR=$(gh actions-cache list -R "$REPO" -B "$BRANCH" -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete "$cacheKey" -R "$REPO" -B "$BRANCH" --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge diff --git a/.github/workflows/changelogensets.yml b/.github/workflows/changelog.yml similarity index 62% rename from .github/workflows/changelogensets.yml rename to .github/workflows/changelog.yml index 92ad691096..1b7e9b7ed9 100644 --- a/.github/workflows/changelogensets.yml +++ b/.github/workflows/changelog.yml @@ -1,30 +1,32 @@ -name: Release +name: changelog on: push: branches: - main - - 2.x + - 3.x -permissions: - pull-requests: write - contents: write +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: ${{ github.event_name != 'push' }} jobs: - update-changelog: - if: github.repository_owner == 'nuxt' && !contains(github.event.head_commit.message, 'v3.') + update: + if: github.repository_owner == 'nuxt' && !contains(github.event.head_commit.message, 'v3.') && !contains(github.event.head_commit.message, 'v4.') runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e098c0bc10..fe626fbd56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,14 @@ on: - "*.md" branches: - main + - 3.x pull_request: paths-ignore: - "docs/**" - "*.md" branches: - main + - 3.x - "!v[0-9]*" # https://github.com/vitejs/vite/blob/main/.github/workflows/ci.yml @@ -20,8 +22,6 @@ env: # 7 GiB by default on GitHub, setting to 6 GiB # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources NODE_OPTIONS: --max-old-space-size=6144 - # install playwright binary manually (because pnpm only runs install script once) - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" # Remove default permissions of GITHUB_TOKEN for security # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs @@ -37,9 +37,9 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -57,7 +57,7 @@ jobs: run: pnpm build - name: Cache dist - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1 with: retention-days: 3 name: dist @@ -70,36 +70,30 @@ jobs: actions: read contents: read security-events: write - needs: - - build steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version: 20 - cache: "pnpm" - - - name: Install dependencies - run: pnpm install + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Initialize CodeQL - uses: github/codeql-action/init@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 with: - languages: javascript + config: | + paths: + - 'packages/*/src/**' + - 'packages/nuxt/bin/**' + - 'packages/schema/schema/**' + paths-ignore: + - 'test/**' + - '**/*.spec.ts' + - '**/*.test.ts' + - '**/__snapshots__/**' + languages: javascript-typescript queries: +security-and-quality - - name: Restore dist cache - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 - with: - name: dist - path: packages - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 with: - category: "/language:javascript" + category: "/language:javascript-typescript" typecheck: runs-on: ${{ matrix.os }} @@ -113,9 +107,9 @@ jobs: module: ["bundler", "node"] steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -124,7 +118,7 @@ jobs: run: pnpm install - name: Restore dist cache - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist path: packages @@ -144,9 +138,9 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -168,9 +162,9 @@ jobs: needs: - build steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -200,8 +194,17 @@ jobs: builder: ["vite", "webpack"] context: ["async", "default"] manifest: ["manifest-on", "manifest-off"] + payload: ["json", "js"] node: [18] exclude: + - builder: "webpack" + payload: "js" + - manifest: "manifest-off" + payload: "js" + - context: "default" + payload: "js" + - os: windows-latest + payload: "js" - env: "dev" builder: "webpack" - manifest: "manifest-off" @@ -210,9 +213,9 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: ${{ matrix.node }} cache: "pnpm" @@ -220,34 +223,11 @@ jobs: - name: Install dependencies run: pnpm install - # Install playwright's binary under custom directory to cache - - name: (non-windows) Set Playwright path and Get playwright version - if: runner.os != 'Windows' - run: | - echo "PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright-bin" >> $GITHUB_ENV - PLAYWRIGHT_VERSION="$(pnpm ls --depth 0 --json -w playwright-core | jq --raw-output '.[0].devDependencies["playwright-core"].version')" - echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV - - - name: (windows) Set Playwright path and Get playwright version - if: runner.os == 'Windows' - run: | - echo "PLAYWRIGHT_BROWSERS_PATH=$HOME\.cache\playwright-bin" >> $env:GITHUB_ENV - $env:PLAYWRIGHT_VERSION="$(pnpm ls --depth 0 --json -w playwright-core | jq --raw-output '.[0].devDependencies["playwright-core"].version')" - echo "PLAYWRIGHT_VERSION=$env:PLAYWRIGHT_VERSION" >> $env:GITHUB_ENV - - - name: Cache Playwright's binary - uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 - with: - key: ${{ runner.os }}-playwright-bin-v1-${{ env.PLAYWRIGHT_VERSION }} - path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - restore-keys: | - ${{ runner.os }}-playwright-bin-v1- - - name: Install Playwright run: pnpm playwright-core install chromium - name: Restore dist cache - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist path: packages @@ -259,21 +239,23 @@ jobs: TEST_BUILDER: ${{ matrix.builder }} TEST_MANIFEST: ${{ matrix.manifest }} TEST_CONTEXT: ${{ matrix.context }} - SKIP_BUNDLE_SIZE: ${{ github.event_name != 'push' || matrix.env == 'dev' || matrix.builder == 'webpack' || matrix.context == 'default' || runner.os == 'Windows' }} + 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@e0b68c6749509c5f83f984dd99a76a1c1a231044 # v4.0.1 + - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 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 }} build-release: + concurrency: + group: release permissions: id-token: write if: | github.event_name == 'push' && github.repository == 'nuxt/nuxt' && !contains(github.event.head_commit.message, '[skip-release]') && - !startsWith(github.event.head_commit.message, 'chore') && !startsWith(github.event.head_commit.message, 'docs') needs: - lint @@ -283,11 +265,11 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -296,18 +278,20 @@ jobs: run: pnpm install - name: Restore dist cache - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist path: packages - name: Release Edge - run: ./scripts/release-edge.sh + run: ./scripts/release-edge.sh ${{ github.ref == 'refs/heads/main' && 'latest' || '3x' }} env: NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} NPM_CONFIG_PROVENANCE: true release-pr: + concurrency: + group: release permissions: id-token: write pull-requests: write @@ -322,11 +306,11 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: fetch-depth: 0 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -335,7 +319,7 @@ jobs: run: pnpm install - name: Restore dist cache - uses: actions/download-artifact@eaceaf801fd36c7dee90939fad912460b18a1ffe # v4.1.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist path: packages diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 9146d1ab4b..705f8d5c2d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,6 +17,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: 'Dependency Review' - uses: actions/dependency-review-action@fd07d42ce87ab09f10c61a2d1a5e59e6c655620a # v4.1.1 + uses: actions/dependency-review-action@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4 diff --git a/.github/workflows/check-links.yml b/.github/workflows/docs-check-links.yml similarity index 78% rename from .github/workflows/check-links.yml rename to .github/workflows/docs-check-links.yml index 9b8ddaff9c..684644c94b 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/docs-check-links.yml @@ -1,4 +1,4 @@ -name: Check links with Lychee +name: docs on: pull_request: @@ -7,6 +7,7 @@ on: - "*.md" branches: - main + - 3.x # Remove default permissions of GITHUB_TOKEN for security # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs @@ -18,17 +19,17 @@ jobs: steps: # Cache lychee results (e.g. to avoid hitting rate limits) - name: Restore lychee cache - uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0 + uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0 with: path: .lycheecache key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- # check links with Lychee - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - name: Lychee link checker - uses: lycheeverse/lychee-action@c053181aa0c3d17606addfe97a9075a32723548a # for v1.8.0 + uses: lycheeverse/lychee-action@f87f0a62993c2647717456af92593666acb3a500 # for v1.8.0 with: # arguments with file types to check args: >- diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c4ad6fb3de..206d15e8d2 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -1,11 +1,11 @@ -name: Deploy docs +name: docs on: push: paths: - "docs/**" branches: - - main + - 3.x # Remove default permissions of GITHUB_TOKEN for security # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e71037f7e0..1948fd8ab5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,4 +1,4 @@ -name: Docs +name: docs on: push: @@ -9,6 +9,7 @@ on: # autofix workflow will be triggered instead for PRs branches: - main + - 3.x - "!v[0-9]*" # Remove default permissions of GITHUB_TOKEN for security @@ -20,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" @@ -30,6 +31,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build (stub) + run: pnpm dev:prepare + - name: Lint (docs) run: pnpm lint:docs diff --git a/.github/workflows/label-issue.yml b/.github/workflows/label-issue.yml new file mode 100644 index 0000000000..ebc2e3921b --- /dev/null +++ b/.github/workflows/label-issue.yml @@ -0,0 +1,28 @@ +name: chore + +on: + issues: + types: + - opened + +permissions: + issues: write + +jobs: + add-issue-labels: + name: Add labels + runs-on: ubuntu-latest + if: github.repository == 'nuxt/nuxt' + steps: + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + // add 'pending triage' label if issue is created with no labels + if (context.payload.issue.labels.length === 0) { + github.rest.issues.addLabels({ + issue_number: context.payload.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['pending triage'] + }) + } diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 9d462f9f8c..e0469e757f 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -1,4 +1,4 @@ -name: Label PR +name: chore on: pull_request_target: @@ -6,6 +6,9 @@ on: - opened branches: - main + - 3.x + +permissions: {} jobs: add-pr-labels: diff --git a/.github/workflows/lint-sherif.yml b/.github/workflows/lint-sherif.yml new file mode 100644 index 0000000000..00d7bf5a68 --- /dev/null +++ b/.github/workflows/lint-sherif.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + paths: + - "**/package.json" + branches: + - main + - 3.x + pull_request: + paths: + - "**/package.json" + branches: + - main + - 3.x + - "!v[0-9]*" + +permissions: + contents: read + +jobs: + lint-monorepo: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - run: corepack enable + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + - name: Lint monorepo + run: pnpm sherif -r multiple-dependency-versions diff --git a/.github/workflows/introspect.yml b/.github/workflows/lint-workflows.yml similarity index 59% rename from .github/workflows/introspect.yml rename to .github/workflows/lint-workflows.yml index 5f25bfa3b6..a3d7781132 100644 --- a/.github/workflows/introspect.yml +++ b/.github/workflows/lint-workflows.yml @@ -6,11 +6,13 @@ on: - ".github/workflows/**" branches: - main + - 3.x pull_request: paths: - ".github/workflows/**" branches: - main + - 3.x - "!v[0-9]*" permissions: @@ -21,9 +23,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 # From https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions - name: Check workflow files - run: | - bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/590d3bd9dde0c91f7a66071d40eb84716526e5a6/scripts/download-actionlint.bash) 1.6.25 - ./actionlint -color -shellcheck="" + uses: docker://rhysd/actionlint:1.7.3@sha256:7617f05bd698cd2f1c3aedc05bc733ccec92cca0738f3e8722c32c5b42c70ae6 + with: + args: -color diff --git a/.github/workflows/notify-nuxt-bridge.yml b/.github/workflows/notify-nuxt-bridge.yml index fa97b12b95..b1f67c0509 100644 --- a/.github/workflows/notify-nuxt-bridge.yml +++ b/.github/workflows/notify-nuxt-bridge.yml @@ -4,6 +4,9 @@ on: types: [closed] paths: - "packages/nuxt/src/app/composables/**" + +permissions: {} + jobs: notify: if: github.event.pull_request.merged == true diff --git a/.github/workflows/nuxt2-edge.yml b/.github/workflows/nuxt2-edge.yml deleted file mode 100644 index 5e2df5cce6..0000000000 --- a/.github/workflows/nuxt2-edge.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: nuxt2-nightly - -on: - workflow_dispatch: - schedule: - - cron: '0 0 * * *' - -# https://github.com/vitejs/vite/blob/main/.github/workflows/ci.yml -env: - # 7 GiB by default on GitHub, setting to 6 GiB - # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources - NODE_OPTIONS: --max-old-space-size=6144 - -permissions: - contents: read - -jobs: - nightly: - if: github.repository_owner == 'nuxt' - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - ref: '2.x' - fetch-depth: 0 # All history - - name: fetch tags - run: git fetch --depth=1 origin "+refs/tags/*:refs/tags/*" - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 - with: - node-version: 18 - registry-url: 'https://registry.npmjs.org' - - name: install - run: yarn --check-files --frozen-lockfile --non-interactive - - name: lint - run: yarn test:lint - - name: audit - run: yarn run audit - - name: build - run: yarn test:fixtures -i - - name: lint app - run: yarn lint:app - - name: test types - run: yarn test:types - - name: test dev - run: yarn test:dev - - name: test unit - run: yarn test:unit - - name: test e2e - run: yarn test:e2e - - name: bump version - run: yarn lerna version --yes --no-changelog --no-git-tag-version --no-push --force-publish "*" --loglevel verbose - - name: build - run: PACKAGE_SUFFIX=edge yarn build - - name: publish - run: ./scripts/workspace-run npm publish -q - env: - NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} - NPM_CONFIG_PROVENANCE: true - diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 778e5c454b..403ab99d59 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -1,4 +1,4 @@ -name: release +name: release-pr on: issue_comment: @@ -14,6 +14,8 @@ permissions: jobs: release-pr: if: github.repository == 'nuxt/nuxt' && github.event.issue.pull_request && github.event.comment.body == '/trigger release' + concurrency: + group: release permissions: id-token: write pull-requests: write @@ -22,20 +24,37 @@ jobs: steps: - name: Ensure action is by maintainer - uses: octokit/request-action@89697eb6635e52c6e1e5559f15b5c91ba5100cb0 # v2.1.9 + uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 id: check_role with: route: GET /repos/nuxt/nuxt/collaborators/${{ github.event.comment.user.login }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Get PR Info + id: pr + env: + PR_NUMBER: ${{ github.event.issue.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + COMMENT_AT: ${{ github.event.comment.created_at }} + run: | + pr="$(gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/"${GH_REPO}"/pulls/"${PR_NUMBER}")" + head_sha="$(echo "$pr" | jq -r .head.sha)" + updated_at="$(echo "$pr" | jq -r .updated_at)" + + if [[ $(date -d "$updated_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then + exit 1 + fi + + echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: - ref: refs/pull/${{ github.event.issue.number }}/merge - fetch-depth: 0 + ref: ${{ steps.pr.outputs.head_sha }} + fetch-depth: 1 - run: corepack enable - - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 with: node-version: 20 cache: "pnpm" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..1eda613a60 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: release + +on: + push: + tags: + - "v*" + +# Remove default permissions of GITHUB_TOKEN for security +# https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs +permissions: {} + +jobs: + release: + if: github.repository == 'nuxt/nuxt' && (startsWith(github.event.head_commit.message, 'v3.') || startsWith(github.event.head_commit.message, 'v4.')) + concurrency: + group: release + permissions: + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + with: + fetch-depth: 0 + - run: corepack enable + - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version: 20 + registry-url: "https://registry.npmjs.org/" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build (stub) + run: pnpm dev:prepare + + - name: Release + run: ./scripts/release.sh + env: + NODE_AUTH_TOKEN: ${{secrets.RELEASE_NODE_AUTH_TOKEN}} + NPM_CONFIG_PROVENANCE: true diff --git a/.github/workflows/reproduire.yml b/.github/workflows/reproduire.yml index fd7895b49a..d995f6d302 100644 --- a/.github/workflows/reproduire.yml +++ b/.github/workflows/reproduire.yml @@ -1,4 +1,4 @@ -name: Reproduire +name: chore on: issues: types: [labeled] @@ -10,7 +10,7 @@ jobs: reproduire: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 - uses: Hebilicious/reproduire@4b686ae9cbb72dad60f001d278b6e3b2ce40a9ac # v0.0.9-mp with: label: needs reproduction diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 160cee1c97..b9cfabd45a 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -2,7 +2,7 @@ # by a third-party and are governed by separate terms of service, privacy # policy, and support documentation. -name: Scorecard supply-chain security +name: ossf on: # For Branch-Protection check. Only the default branch is supported. See # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection @@ -28,15 +28,16 @@ jobs: id-token: write contents: read actions: read + if: github.event_name == 'push' || github.repository == 'nuxt/nuxt' steps: - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif @@ -58,7 +59,8 @@ 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@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1 + if: github.repository == 'nuxt/nuxt' && success() with: name: SARIF file path: results.sarif @@ -66,6 +68,7 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@379614612a29c9e28f31f39a59013eb8012a51f0 # v3.24.3 + uses: github/codeql-action/upload-sarif@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 + if: github.repository == 'nuxt/nuxt' && success() with: sarif_file: results.sarif diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 4ee0a30d46..d563f5045f 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -1,4 +1,4 @@ -name: Semantic pull request +name: chore on: pull_request_target: @@ -7,12 +7,12 @@ on: - edited - synchronize -permissions: - contents: read +permissions: {} jobs: - main: + semantic-pr: permissions: + contents: read pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR if: github.repository == 'nuxt/nuxt' && !startsWith(github.head_ref, 'v') @@ -20,14 +20,16 @@ jobs: name: Semantic pull request steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@e9fabac35e210fea40ca5b14c0da95a099eff26f # v5.4.0 + uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 with: scopes: | kit nuxi nuxt + rspack schema test-utils + ui-templates vite webpack deps diff --git a/.github/workflows/stackblitz-link.yml b/.github/workflows/stackblitz-link.yml new file mode 100644 index 0000000000..7da7c03a24 --- /dev/null +++ b/.github/workflows/stackblitz-link.yml @@ -0,0 +1,17 @@ +name: chore +on: + issues: + types: + opened + +permissions: + issues: write + +jobs: + stackblitz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + - uses: huang-julien/reproduire-sur-stackblitz@9ceccbfbb0f2f9a9a8db2d1f0dd909cf5cfe67aa # v1.0.2 + with: + reproduction-heading: '### Reproduction' diff --git a/.github/workflows/reproduire-close.yml b/.github/workflows/stale.yml similarity index 89% rename from .github/workflows/reproduire-close.yml rename to .github/workflows/stale.yml index 6bf5ca88e6..23dccb624d 100644 --- a/.github/workflows/reproduire-close.yml +++ b/.github/workflows/stale.yml @@ -1,4 +1,4 @@ -name: Close incomplete issues +name: chore on: workflow_dispatch: schedule: @@ -10,6 +10,7 @@ permissions: jobs: stale: runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.repository == 'nuxt/nuxt' steps: - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: diff --git a/.gitignore b/.gitignore index 35388c4fd2..815f0415e7 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,5 @@ Temporary Items fixtures-temp .pnpm-store +eslint-typegen.d.ts +.eslintcache diff --git a/.npmrc b/.npmrc index e2ad808f8d..a1ecadcefc 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ -shamefully-hoist=true -strict-peer-dependencies=false +# TODO: consider resolving webpack loaders to absolute path +public-hoist-pattern[]=*-loader +public-hoist-pattern[]=webpack-* shell-emulator=true diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..fc78d099a7 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @danielroe diff --git a/README.md b/README.md index 1289880ae0..5c5e76cc4c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version Downloads - License + License Website Discord

@@ -13,16 +13,33 @@ Nuxt is a free and open-source framework with an intuitive and extendable way to create type-safe, performant and production-grade full-stack web applications and websites with Vue.js. It provides a number of features that make it easy to build fast, SEO-friendly, and scalable web applications, including: -- Server-side rendering, Static Site Generation or Hybrid Rendering -- Automatic routing with code-splitting -- State management -- SEO Optimization -- Auto imports -- Extensible with [180+ modules](https://nuxt.com/modules) +- Server-side rendering, Static Site Generation, Hybrid Rendering and Edge-Side Rendering +- Automatic routing with code-splitting and pre-fetching +- Data fetching and state management +- SEO Optimization and Meta tags definition +- Auto imports of components, composables and utils +- TypeScript with zero configuration +- Go fullstack with our server/ directory +- Extensible with [200+ modules](https://nuxt.com/modules) - Deployment to a variety of [hosting platforms](https://nuxt.com/deploy) - ...[and much more](https://nuxt.com) πŸš€ -## Getting Started +### Table of Contents + +- πŸš€ [Getting Started](#getting-started) +- πŸ’» [ Vue Development](#vue-development) +- πŸ“– [Documentation](#documentation) +- 🧩 [Modules](#modules) +- ❀️ [Contribute](#contribute) +- 🏠 [Local Development](#local-development) +- ⛰️ [Nuxt 2](#nuxt-2) +- πŸ›Ÿ [Professional Support](#professional-support) +- πŸ”— [Follow Us](#follow-us) +- βš–οΈ [License](#license) + +--- + +## πŸš€ Getting Started Use the following command to create a new starter project. This will create a starter project with all the necessary files and dependencies: @@ -30,9 +47,10 @@ Use the following command to create a new starter project. This will create a st npx nuxi@latest init ``` -Discover also [nuxt.new](https://nuxt.new): Open a Nuxt starter on CodeSandbox, StackBlitz or locally to get up and running in a few seconds. +> [!TIP] +> Discover also [nuxt.new](https://nuxt.new): Open a Nuxt starter on CodeSandbox, StackBlitz or locally to get up and running in a few seconds. -## Vue Development +## πŸ’» Vue Development Simple, intuitive and powerful, Nuxt lets you write Vue components in a way that makes sense. Every repetitive task is automated, so you can focus on writing your full-stack Vue application with confidence. @@ -54,7 +72,7 @@ useSeoMeta({ - ``` -## Documentation +## πŸ“– Documentation We highly recommend you take a look at the [Nuxt documentation](https://nuxt.com/docs) to level up. It’s a great resource for learning more about the framework. It covers everything from getting started to advanced topics. -## Modules +## 🧩 Modules Discover our [list of modules](https://nuxt.com/modules) to supercharge your Nuxt project, created by the Nuxt team and community. -## Contribute +## ❀️ Contribute We invite you to contribute and help improve Nuxt πŸ’š Here are a few ways you can get involved: - **Reporting Bugs:** If you come across any bugs or issues, please check out the [reporting bugs guide](https://nuxt.com/docs/community/reporting-bugs) to learn how to submit a bug report. -- **Suggestions:** Have ideas to enhance Nuxt? We'd love to hear them! Check out the [contribution guide](https://nuxt.com/docs/community/contribution#creating-an-issue) to share your suggestions. +- **Suggestions:** Have ideas to enhance Nuxt? We'd love to hear them! Check out the [contribution guide](https://nuxt.com/docs/community/contribution) to share your suggestions. - **Questions:** If you have questions or need assistance, the [getting help guide](https://nuxt.com/docs/community/getting-help) provides resources to help you out. -## Local Development +## 🏠 Local Development Follow the docs to [Set Up Your Local Development Environment](https://nuxt.com/docs/community/framework-contribution#setup) to contribute to the framework and documentation. -## Nuxt 2 +## πŸ›Ÿ Professional Support -You can find the code for Nuxt 2 on the [`2.x` branch](https://github.com/nuxt/nuxt/tree/2.x) and the documentation at [v2.nuxt.com](https://v2.nuxt.com). +- Technical audit & consulting: [Nuxt Experts](https://nuxt.com/enterprise/support) +- Custom development & more: [Nuxt Agencies Partners](https://nuxt.com/enterprise/agencies) -## Follow us +## πŸ”— Follow Us

- Discord  Twitter  GitHub + Discord  Twitter  GitHub

-## License - -[MIT](./LICENSE) +## βš–οΈ License +[MIT](https://github.com/nuxt/nuxt/tree/main/LICENSE) diff --git a/docs/1.getting-started/1.introduction.md b/docs/1.getting-started/1.introduction.md index 1337e7187b..90f7e84da0 100644 --- a/docs/1.getting-started/1.introduction.md +++ b/docs/1.getting-started/1.introduction.md @@ -1,7 +1,8 @@ --- -title: 'Introduction' +title: Introduction description: Nuxt's goal is to make web development intuitive and performant with a great Developer Experience in mind. -navigation.icon: i-ph-info-duotone +navigation: + icon: i-ph-info --- Nuxt is a free and [open-source framework](https://github.com/nuxt/nuxt) with an intuitive and extendable way to create type-safe, performant and production-grade full-stack web applications and websites with [Vue.js](https://vuejs.org). @@ -10,6 +11,10 @@ We made everything so you can start writing `.vue` files from the beginning whil Nuxt has no vendor lock-in, allowing you to deploy your application [**everywhere, even on the edge**](/blog/nuxt-on-the-edge). +::tip +If you want to play around with Nuxt in your browser, you can [try it out in one of our online sandboxes](/docs/getting-started/installation#play-online). +:: + ## Automation and Conventions Nuxt uses conventions and an opinionated directory structure to automate repetitive tasks and allow developers to focus on pushing features. The configuration file can still customize and override its default behaviors. @@ -72,7 +77,5 @@ Nuxt is composed of different [core packages](https://github.com/nuxt/nuxt/tree/ - Command line interface: [nuxi](https://github.com/nuxt/nuxt/tree/main/packages/nuxi) - Server engine: [nitro](https://github.com/unjs/nitro) - Development kit: [@nuxt/kit](https://github.com/nuxt/nuxt/tree/main/packages/kit) -- Nuxt 2 Bridge: [@nuxt/bridge](https://github.com/nuxt/bridge) We recommend reading each concept to have a full vision of Nuxt capabilities and the scope of each package. - diff --git a/docs/1.getting-started/10.deployment.md b/docs/1.getting-started/10.deployment.md index eaf740880e..4b341e6a7d 100644 --- a/docs/1.getting-started/10.deployment.md +++ b/docs/1.getting-started/10.deployment.md @@ -1,12 +1,12 @@ --- title: 'Deployment' description: Learn how to deploy your Nuxt application to any hosting provider. -navigation.icon: i-ph-cloud-duotone +navigation.icon: i-ph-cloud --- A Nuxt application can be deployed on a Node.js server, pre-rendered for static hosting, or deployed to serverless or edge (CDN) environments. -::callout +::tip If you are looking for a list of cloud providers that support Nuxt 3, see the [Hosting providers](/deploy) section. :: @@ -64,6 +64,10 @@ By default, the workload gets distributed to the workers with the round robin st :read-more{to="https://nitro.unjs.io/deploy/node" title="the Nitro documentation for node-server preset"} +::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=0x1H6K5yOfs" target="\_blank"} +Watch Daniel Roe's short video on the topic. +:: + ## Static Hosting There are two ways to deploy a Nuxt application to any static hosting services: @@ -71,62 +75,7 @@ There are two ways to deploy a Nuxt application to any static hosting services: - Static site generation (SSG) with `ssr: true` pre-renders routes of your application at build time. (This is the default behavior when running `nuxi generate`.) It will also generate `/200.html` and `/404.html` single-page app fallback pages, which can render dynamic routes or 404 errors on the client (though you may need to configure this on your static host). - Alternatively, you can prerender your site with `ssr: false` (static single-page app). This will produce HTML pages with an empty `
` where your Vue app would normally be rendered. You will lose many SEO benefits of prerendering your site, so it is suggested instead to use [``](/docs/api/components/client-only) to wrap the portions of your site that cannot be server rendered (if any). -### Crawl-based Pre-rendering - -Use the [`nuxi generate` command](/docs/api/commands/generate) to build and pre-render your application using the [Nitro](/docs/guide/concepts/server-engine) crawler. This command is similar to `nuxt build` with the `nitro.static` option set to `true`, or running `nuxt build --prerender`. - -```bash [Terminal] -npx nuxi generate -``` - -That's it! You can now deploy the `.output/public` directory to any static hosting service or preview it locally with `npx serve .output/public`. - -Working of the Nitro crawler: - -1. Load the HTML of your application's root route (`/`), any non-dynamic pages in your `~/pages` directory, and any other routes in the `nitro.prerender.routes` array. -2. Save the HTML and `payload.json` to the `~/.output/public/` directory to be served statically. -3. Find all anchor tags (``) in the HTML to navigate to other routes. -4. Repeat steps 1-3 for each anchor tag found until there are no more anchor tags to crawl. - -This is important to understand since pages that are not linked to a discoverable page can't be pre-rendered automatically. - -::read-more{to="/docs/api/commands/generate#nuxi-generate"} -Read more about the `nuxi generate` command. -:: - -### Selective Pre-rendering - -You can manually specify routes that [Nitro](/docs/guide/concepts/server-engine) will fetch and pre-render during the build or ignore routes that you don't want to pre-render like `/dynamic` in the `nuxt.config` file: - -```ts twoslash [nuxt.config.ts] -export default defineNuxtConfig({ - nitro: { - prerender: { - routes: ['/user/1', '/user/2'], - ignore: ['/dynamic'] - } - } -}) -``` - -You can combine this with the `crawlLinks` option to pre-render a set of routes that the crawler can't discover like your `/sitemap.xml` or `/robots.txt`: - -```ts twoslash [nuxt.config.ts] -export default defineNuxtConfig({ - nitro: { - prerender: { - crawlLinks: true, - routes: ['/sitemap.xml', '/robots.txt'] - } - } -}) -``` - -Setting `nitro.prerender` to `true` is similar to `nitro.prerender.crawlLinks` to `true`. - -::read-more{to="https://nitro.unjs.io/config#prerender"} -Read more about pre-rendering in the Nitro documentation. -:: +:read-more{title="Nuxt prerendering" to="/docs/getting-started/prerendering"} ### Client-side Only Rendering @@ -140,13 +89,13 @@ export default defineNuxtConfig({ ## Hosting Providers -Nuxt 3 can be deployed to several cloud providers with a minimal amount of configuration: +Nuxt can be deployed to several cloud providers with a minimal amount of configuration: :read-more{to="/deploy"} ## Presets -In addition to Node.js servers and static hosting services, a Nuxt 3 project can be deployed with several well-tested presets and minimal amount of configuration. +In addition to Node.js servers and static hosting services, a Nuxt project can be deployed with several well-tested presets and minimal amount of configuration. You can explicitly set the desired preset in the [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file: @@ -172,10 +121,13 @@ In most cases, Nuxt can work with third-party content that is not generated or c Accordingly, you should make sure that the following options are unchecked / disabled in Cloudflare. Otherwise, unnecessary re-rendering or hydration errors could impact your production application. -1. Speed > Optimization > Auto Minify: Uncheck JavaScript, CSS and HTML -2. Speed > Optimization > Disable "Rocket Loaderβ„’" -3. Speed > Optimization > Disable "Mirage" +1. Speed > Optimization > Content Optimization > Auto Minify: Uncheck JavaScript, CSS and HTML +2. Speed > Optimization > Content Optimization > Disable "Rocket Loaderβ„’" +3. Speed > Optimization > Image Optimization > Disable "Mirage" 4. Scrape Shield > Disable "Email Address Obfuscation" -5. Scrape Shield > Disable "Server-side Excludes" With these settings, you can be sure that Cloudflare won't inject scripts into your Nuxt application that may cause unwanted side effects. + +::tip +Their location on the Cloudflare dashboard sometimes changes so don't hesitate to look around. +:: diff --git a/docs/1.getting-started/11.testing.md b/docs/1.getting-started/11.testing.md index 29485837f2..dce433a768 100644 --- a/docs/1.getting-started/11.testing.md +++ b/docs/1.getting-started/11.testing.md @@ -1,30 +1,34 @@ --- title: Testing description: How to test your Nuxt application. -navigation.icon: i-ph-check-circle-duotone +navigation.icon: i-ph-check-circle --- -::callout +::tip If you are a module author, you can find more specific information in the [Module Author's guide](/docs/guide/going-further/modules#testing). :: Nuxt offers first-class support for end-to-end and unit testing of your Nuxt application via `@nuxt/test-utils`, a library of test utilities and configuration that currently powers the [tests we use on Nuxt itself](https://github.com/nuxt/nuxt/tree/main/test) and tests throughout the module ecosystem. +::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=yGzwk9xi9gU" target="_blank"} +Watch a video from Alexander Lichter about getting started with the `@nuxt/test-utils`. +:: + ## Installation In order to allow you to manage your other testing dependencies, `@nuxt/test-utils` ships with various optional peer dependencies. For example: - you can choose between `happy-dom` and `jsdom` for a runtime Nuxt environment -- you can choose between `vitest` and `jest` for end-to-end test runners -- `playwright-core` is only required if you wish to use the built-in browser testing utilities +- you can choose between `vitest`, `cucumber`, `jest` and `playwright` for end-to-end test runners +- `playwright-core` is only required if you wish to use the built-in browser testing utilities (and are not using `@playwright/test` as your test runner) -::code-group -```bash [yarn] -yarn add --dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core -``` +::package-managers ```bash [npm] npm i --save-dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core ``` +```bash [yarn] +yarn add --dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core +``` ```bash [pnpm] pnpm add -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core ``` @@ -53,12 +57,17 @@ We currently ship an environment for unit testing code that needs a [Nuxt](https ```ts twoslash import { defineVitestConfig } from '@nuxt/test-utils/config' - + export default defineVitestConfig({ // any custom Vitest config you require }) ``` +::tip +When importing `@nuxt/test-utils` in your vitest config, It is necessary to have `"type": "module"` specified in your `package.json` or rename your vitest config file appropriately. +> ie. `vitest.config.m{ts,js}`. +:: + ### Using a Nuxt Runtime Environment By default, `@nuxt/test-utils` will not change your default Vitest environment, so you can do fine-grained opt-in and run Nuxt tests together with other unit tests. @@ -68,7 +77,7 @@ You can opt in to a Nuxt environment by adding `.nuxt.` to the test file's name ```ts twoslash // @vitest-environment nuxt import { test } from 'vitest' - + test('my test', () => { // ... test with Nuxt environment! }) @@ -88,6 +97,7 @@ export default defineVitestConfig({ // environmentOptions: { // nuxt: { // rootDir: fileURLToPath(new URL('./playground', import.meta.url)), + // domEnvironment: 'happy-dom', // 'happy-dom' (default) or 'jsdom' // overrides: { // // other Nuxt config you want to pass // } @@ -108,12 +118,10 @@ test('my test', () => { }) ``` -::callout{icon="i-ph-warning-duotone" color="amber"} - +::warning When you run your tests within the Nuxt environment, they will be running in a [`happy-dom`](https://github.com/capricorn86/happy-dom) or [`jsdom`](https://github.com/jsdom/jsdom) environment. Before your tests run, a global Nuxt app will be initialized (including, for example, running any plugins or code you've defined in your `app.vue`). This means you should take particular care not to mutate the global state in your tests (or, if you need to, to reset it afterwards). - :: ### 🎭 Built-In Mocks @@ -153,24 +161,41 @@ export default defineVitestConfig({ #### `mountSuspended` -`mountSuspended` allows you to mount any Vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. For example: +`mountSuspended` allows you to mount any Vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. + +::alert{type=info} +Under the hood, `mountSuspended` wraps `mount` from `@vue/test-utils`, so you can check out [the Vue Test Utils documentation](https://test-utils.vuejs.org/guide/) for more on the options you can pass, and how to use this utility. +:: + +For example: ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component +import type { Component } from 'vue' +declare module '#components' { + export const SomeComponent: Component +} // ---cut--- // tests/components/SomeComponents.nuxt.spec.ts import { mountSuspended } from '@nuxt/test-utils/runtime' +import { SomeComponent } from '#components' it('can mount some component', async () => { const component = await mountSuspended(SomeComponent) expect(component.text()).toMatchInlineSnapshot( - 'This is an auto-imported component' + '"This is an auto-imported component"' ) }) +``` + +```ts twoslash +import { it, expect } from 'vitest' +// ---cut--- +// tests/components/SomeComponents.nuxt.spec.ts +import { mountSuspended } from '@nuxt/test-utils/runtime' +import App from '~/app.vue' + // tests/App.nuxt.spec.ts it('can also mount an app', async () => { const component = await mountSuspended(App, { route: '/test' }) @@ -188,6 +213,7 @@ it('can also mount an app', async () => { `renderSuspended` allows you to render any Vue component within the Nuxt environment using `@testing-library/vue`, allowing async setup and access to injections from your Nuxt plugins. This should be used together with utilities from Testing Library, e.g. `screen` and `fireEvent`. Install [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro) in your project to use these. + Additionally, Testing Library also relies on testing globals for cleanup. You should turn these on in your [Vitest config](https://vitest.dev/config/#globals). The passed in component will be rendered inside a `
`. @@ -195,13 +221,15 @@ The passed in component will be rendered inside a `
Examples: ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component +import type { Component } from 'vue' +declare module '#components' { + export const SomeComponent: Component +} // ---cut--- // tests/components/SomeComponents.nuxt.spec.ts import { renderSuspended } from '@nuxt/test-utils/runtime' +import { SomeComponent } from '#components' import { screen } from '@testing-library/vue' it('can render some component', async () => { @@ -211,13 +239,11 @@ it('can render some component', async () => { ``` ```ts twoslash -import type { Component } from 'vue' import { it, expect } from 'vitest' -declare const SomeComponent: Component -declare const App: Component // ---cut--- // tests/App.nuxt.spec.ts import { renderSuspended } from '@nuxt/test-utils/runtime' +import App from '~/app.vue' it('can also render an app', async () => { const html = await renderSuspended(App, { route: '/test' }) @@ -247,7 +273,9 @@ mockNuxtImport('useStorage', () => { // your tests here ``` -> **Note**: `mockNuxtImport` can only be used once per mocked import per test file. It is actually a macro that gets transformed to `vi.mock` and `vi.mock` is hoisted, as described [here](https://vitest.dev/api/vi.html#vi-mock). +::alert{type=info} +`mockNuxtImport` can only be used once per mocked import per test file. It is actually a macro that gets transformed to `vi.mock` and `vi.mock` is hoisted, as described [here](https://vitest.dev/api/vi.html#vi-mock). +:: If you need to mock a Nuxt import and provide different implementations between tests, you can do it by creating and exposing your mocks using [`vi.hoisted`](https://vitest.dev/api/vi.html#vi-hoisted), and then use those mocks in `mockNuxtImport`. You then have access to the mocked imports, and can change the implementation between tests. Be careful to [restore mocks](https://vitest.dev/api/mock.html#mockrestore) before or after each test to undo mock state changes between runs. @@ -270,7 +298,7 @@ mockNuxtImport('useStorage', () => { // Then, inside a test useStorageMock.mockImplementation(() => { return { value: 'something else' } -}) +}) ``` #### `mockComponent` @@ -338,8 +366,8 @@ For example, to mock `/test/` endpoint, you can do: ```ts twoslash import { registerEndpoint } from '@nuxt/test-utils/runtime' -registerEndpoint("/test/", () => ({ - test: "test-field" +registerEndpoint('/test/', () => ({ + test: 'test-field' })) ``` @@ -348,13 +376,13 @@ By default, your request will be made using the `GET` method. You may use anothe ```ts twoslash import { registerEndpoint } from '@nuxt/test-utils/runtime' -registerEndpoint("/test/", { - method: "POST", - handler: () => ({ test: "test-field" }) +registerEndpoint('/test/', { + method: 'POST', + handler: () => ({ test: 'test-field' }) }) ``` -> **Note**: If your requests in a component go to external API, you can use `baseURL` and then make it empty using Nuxt Environment Config (`$test`) so all your requests will go to Nitro server. +> **Note**: If your requests in a component go to an external API, you can use `baseURL` and then make it empty using [Nuxt Environment Override Config](/docs/getting-started/configuration#environment-overrides) (`$test`) so all your requests will go to Nitro server. #### Conflict with End-To-End Testing @@ -365,7 +393,7 @@ If you would like to use both the end-to-end and unit testing functionality of ` `app.nuxt.spec.ts` ```ts twoslash -import { mockNuxtImport } from "@nuxt/test-utils/runtime" +import { mockNuxtImport } from '@nuxt/test-utils/runtime' mockNuxtImport('useStorage', () => { return () => { @@ -387,9 +415,98 @@ await setup({ // ... ``` +### Using `@vue/test-utils` + +If you prefer to use `@vue/test-utils` on its own for unit testing in Nuxt, and you are only testing components which do not rely on Nuxt composables, auto-imports or context, you can follow these steps to set it up. + +1. Install the needed dependencies + + ::package-managers + ```bash [npm] + npm i --save-dev vitest @vue/test-utils happy-dom @vitejs/plugin-vue + ``` + ```bash [yarn] + yarn add --dev vitest @vue/test-utils happy-dom @vitejs/plugin-vue + ``` + ```bash [pnpm] + pnpm add -D vitest @vue/test-utils happy-dom @vitejs/plugin-vue + ``` + ```bash [bun] + bun add --dev vitest @vue/test-utils happy-dom @vitejs/plugin-vue + ``` + :: + +2. Create a `vitest.config.ts` with the following content: + + ```ts twoslash + import { defineConfig } from 'vitest/config' + import vue from '@vitejs/plugin-vue' + + export default defineConfig({ + plugins: [vue()], + test: { + environment: 'happy-dom', + }, + }); + ``` + +3. Add a new command for test in your `package.json` + + ```json + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + ... + "test": "vitest" + }, + ``` + +4. Create a simple `` component `components/HelloWorld.vue` with the following content: + + ```vue + + ``` + +5. Create a simple unit test for this newly created component `~/components/HelloWorld.spec.ts` + + ```ts twoslash + import { describe, it, expect } from 'vitest' + import { mount } from '@vue/test-utils' + + import HelloWorld from './HelloWorld.vue' + + describe('HelloWorld', () => { + it('component renders Hello world properly', () => { + const wrapper = mount(HelloWorld) + expect(wrapper.text()).toContain('Hello world') + }) + }) + ``` + +6. Run vitest command + + ::package-managers + ```bash [npm] + npm run test + ``` + ```bash [yarn] + yarn test + ``` + ```bash [pnpm] + pnpm run test + ``` + ```bash [bun] + bun run test + ``` + :: + +Congratulations, you're all set to start unit testing with `@vue/test-utils` in Nuxt! Happy testing! + ## End-To-End Testing -For end-to-end testing, we support [Vitest](https://github.com/vitest-dev/vitest) and [Jest](https://jestjs.io) as test runners. +For end-to-end testing, we support [Vitest](https://github.com/vitest-dev/vitest), [Jest](https://jestjs.io), [Cucumber](https://cucumber.io/) and [Playwright](https://playwright.dev/) as test runners. ### Setup @@ -436,17 +553,22 @@ Please use the options below for the `setup` method. #### Features +- `build`: Whether to run a separate build step. + - Type: `boolean` + - Default: `true` (`false` if `browser` or `server` is disabled, or if a `host` is provided) + - `server`: Whether to launch a server to respond to requests in the test suite. - Type: `boolean` - - Default: `true` + - Default: `true` (`false` if a `host` is provided) - `port`: If provided, set the launched test server port to the value. - Type: `number | undefined` - Default: `undefined` -- `build`: Whether to run a separate build step. - - Type: `boolean` - - Default: `true` (`false` if `browser` or `server` is disabled) +- `host`: If provided, a URL to use as the test target instead of building and running a new server. Useful for running "real" end-to-end tests against a deployed version of your application, or against an already running local server (which may provide a significant reduction in test execution timings). See the [target host end-to-end example below](#target-host-end-to-end-example). + - Type: `string` + - Default: `undefined` + - `browser`: Under the hood, Nuxt test utils uses [`playwright`](https://playwright.dev) to carry out browser testing. If this option is set, a browser will be launched and can be controlled in the subsequent test suite. - Type: `boolean` - Default: `false` @@ -455,9 +577,34 @@ Please use the options below for the `setup` method. - `type`: The type of browser to launch - either `chromium`, `firefox` or `webkit` - `launch`: `object` of options that will be passed to playwright when launching the browser. See [full API reference](https://playwright.dev/docs/api/class-browsertype#browser-type-launch). - `runner`: Specify the runner for the test suite. Currently, [Vitest](https://vitest.dev) is recommended. - - Type: `'vitest' | 'jest'` + - Type: `'vitest' | 'jest' | 'cucumber'` - Default: `'vitest'` +##### Target `host` end-to-end example + +A common use-case for end-to-end testing is running the tests against a deployed application running in the same environment typically used for Production. + +For local development or automated deploy pipelines, testing against a separate local server can be more efficient and is typically faster than allowing the test framework to rebuild between tests. + +To utilize a separate target host for end-to-end tests, simply provide the `host` property of the `setup` function with the desired URL. + +```ts +import { setup, createPage } from '@nuxt/test-utils/e2e' +import { describe, it, expect } from 'vitest' + +describe('login page', async () => { + await setup({ + host: 'http://localhost:8787', + }) + + it('displays the email and password fields', async () => { + const page = await createPage('/login') + expect(await page.getByTestId('email').isVisible()).toBe(true) + expect(await page.getByTestId('password').isVisible()).toBe(true) + }) +}) +``` + ### APIs #### `$fetch(url)` @@ -494,11 +641,11 @@ const pageUrl = url('/page') ### Testing in a Browser -We provide built-in support using Playwright within `@nuxt/test-utils`, but you can also use other test runners for end-to-end browser testing. +We provide built-in support using Playwright within `@nuxt/test-utils`, either programmatically or via the Playwright test runner. #### `createPage(url)` -You can create a configured Playwright browser instance, and (optionally) point it at a path from the running server. You can find out more about the API methods available from [in the Playwright documentation](https://playwright.dev/docs/api/class-page). +Within `vitest`, `jest` or `cucumber`, you can create a configured Playwright browser instance with `createPage`, and (optionally) point it at a path from the running server. You can find out more about the API methods available from [in the Playwright documentation](https://playwright.dev/docs/api/class-page). ```ts twoslash import { createPage } from '@nuxt/test-utils/e2e' @@ -506,3 +653,70 @@ import { createPage } from '@nuxt/test-utils/e2e' const page = await createPage('/page') // you can access all the Playwright APIs from the `page` variable ``` + +#### Testing with Playwright Test Runner + +We also provide first-class support for testing Nuxt within [the Playwright test runner](https://playwright.dev/docs/intro). + +::package-managers +```bash [npm] +npm i --save-dev @playwright/test @nuxt/test-utils +``` +```bash [yarn] +yarn add --dev @playwright/test @nuxt/test-utils +``` +```bash [pnpm] +pnpm add -D @playwright/test @nuxt/test-utils +``` +```bash [bun] +bun add --dev @playwright/test @nuxt/test-utils +``` +:: + +You can provide global Nuxt configuration, with the same configuration details as the `setup()` function mentioned earlier in this section. + +```ts [playwright.config.ts] +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' +import type { ConfigOptions } from '@nuxt/test-utils/playwright' + +export default defineConfig({ + use: { + nuxt: { + rootDir: fileURLToPath(new URL('.', import.meta.url)) + } + }, + // ... +}) +``` + +::read-more{title="See full example config" to="https://github.com/nuxt/test-utils/blob/main/examples/app-playwright/playwright.config.ts" target="_blank"} +:: + +Your test file should then use `expect` and `test` directly from `@nuxt/test-utils/playwright`: + +```ts [tests/example.test.ts] +import { expect, test } from '@nuxt/test-utils/playwright' + +test('test', async ({ page, goto }) => { + await goto('/', { waitUntil: 'hydration' }) + await expect(page.getByRole('heading')).toHaveText('Welcome to Playwright!') +}) +``` + +You can alternatively configure your Nuxt server directly within your test file: + +```ts [tests/example.test.ts] +import { expect, test } from '@nuxt/test-utils/playwright' + +test.use({ + nuxt: { + rootDir: fileURLToPath(new URL('..', import.meta.url)) + } +}) + +test('test', async ({ page, goto }) => { + await goto('/', { waitUntil: 'hydration' }) + await expect(page.getByRole('heading')).toHaveText('Welcome to Playwright!') +}) +``` diff --git a/docs/1.getting-started/12.upgrade.md b/docs/1.getting-started/12.upgrade.md index 8d6a28477c..68cb635dc9 100644 --- a/docs/1.getting-started/12.upgrade.md +++ b/docs/1.getting-started/12.upgrade.md @@ -1,32 +1,606 @@ --- title: Upgrade Guide description: 'Learn how to upgrade to the latest Nuxt version.' -navigation.icon: i-ph-arrow-circle-up-duotone +navigation.icon: i-ph-arrow-circle-up --- - -## Upgrading Nuxt 3 +## Upgrading Nuxt ### Latest release -To upgrade Nuxt 3 to the [latest release](https://github.com/nuxt/nuxt/releases), use the `nuxi upgrade` command. +To upgrade Nuxt to the [latest release](https://github.com/nuxt/nuxt/releases), use the `nuxi upgrade` command. -```bash [Terminal] +::package-managers + +```bash [npm] npx nuxi upgrade ``` +```bash [yarn] +yarn dlx nuxi upgrade +``` + +```bash [pnpm] +pnpm dlx nuxi upgrade +``` + +```bash [bun] +bun x nuxi upgrade +``` + +:: + ### Nightly Release Channel -To use the latest Nuxt 3 build and test features before their release, read about the [nightly release channel](/docs/guide/going-further/nightly-release-channel) guide. +To use the latest Nuxt build and test features before their release, read about the [nightly release channel](/docs/guide/going-further/nightly-release-channel) guide. -## Nuxt 2 vs Nuxt 3 +::alert{type="warning"} +The nightly release channel `latest` tag is currently tracking the Nuxt v4 branch, meaning that it is particularly likely to have breaking changes right now - be careful! + +You can opt in to the 3.x branch nightly releases with `"nuxt": "npm:nuxt-nightly@3x"`. +:: + +## Testing Nuxt 4 + +The release date of Nuxt 4 is **to be announced**. It is dependent on having enough time after Nitro's major release to be properly tested in the community. You can follow progress towards Nitro's release in [this PR](https://github.com/unjs/nitro/pull/2521). + +Until the release, it is possible to test many of Nuxt 4's breaking changes from Nuxt version 3.12+. + +::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=r4wFKlcJK6c" target="_blank"} +Watch a video from Alexander Lichter showing how to opt in to Nuxt 4's breaking changes already. +:: + +### Opting in to Nuxt 4 + +First, upgrade Nuxt to the [latest release](https://github.com/nuxt/nuxt/releases). + +Then you can set your `compatibilityVersion` to match Nuxt 4 behavior: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + future: { + compatibilityVersion: 4, + }, + // To re-enable _all_ Nuxt v3 behavior, set the following options: + // srcDir: '.', + // dir: { + // app: 'app' + // }, + // experimental: { + // sharedPrerenderData: false, + // compileTemplate: true, + // resetAsyncDataToUndefined: true, + // templateUtils: true, + // relativeWatchPaths: true, + // normalizeComponentNames: false + // defaults: { + // useAsyncData: { + // deep: true + // } + // } + // }, + // unhead: { + // renderSSRHeadOptions: { + // omitLineBreaks: false + // } + // } +}) +``` + +When you set your `compatibilityVersion` to `4`, defaults throughout your Nuxt configuration will change to opt in to Nuxt v4 behavior, but you can granularly re-enable Nuxt v3 behavior when testing, following the commented out lines above. Please file issues if so, so that we can address them in Nuxt or in the ecosystem. + +### Migrating to Nuxt 4 + +Breaking or significant changes will be noted here along with migration steps for backward/forward compatibility. + +::alert +This section is subject to change until the final release, so please check back here regularly if you are testing Nuxt 4 using `compatibilityVersion: 4`. +:: + +#### Migrating Using Codemods + +To facilitate the upgrade process, we have collaborated with the [Codemod](https://github.com/codemod-com/codemod) team to automate many migration steps with some open-source codemods. + +::note +If you encounter any issues, please report them to the Codemod team with `npx codemod feedback` πŸ™ +:: + +For a complete list of Nuxt 4 codemods, detailed information on each, their source, and various ways to run them, visit the [Codemod Registry](https://go.codemod.com/codemod-registry). + +You can run all the codemods mentioned in this guide using the following `codemod` recipe: + +```bash +npx codemod@latest nuxt/4/migration-recipe +``` + +This command will execute all codemods in sequence, with the option to deselect any that you do not wish to run. Each codemod is also listed below alongside its respective change and can be executed independently. + +#### New Directory Structure + +🚦 **Impact Level**: Significant + +Nuxt now defaults to a new directory structure, with backwards compatibility (so if Nuxt detects you are using the old structure, such as with a top-level `pages/` directory, this new structure will not apply). + +πŸ‘‰ [See full RFC](https://github.com/nuxt/nuxt/issues/26444) + +##### What Changed + +* the new Nuxt default `srcDir` is `app/` by default, and most things are resolved from there. +* `serverDir` now defaults to `/server` rather than `/server` +* `layers/`, `modules/` and `public/` are resolved relative to `` by default +* if using [Nuxt Content v2.13+](https://github.com/nuxt/content/pull/2649), `content/` is resolved relative to `` +* a new `dir.app` is added, which is the directory we look for `router.options.ts` and `spa-loading-template.html` - this defaults to `/` + +
+ +An example v4 folder structure. + +```sh +.output/ +.nuxt/ +app/ + assets/ + components/ + composables/ + layouts/ + middleware/ + pages/ + plugins/ + utils/ + app.config.ts + app.vue + router.options.ts +content/ +layers/ +modules/ +node_modules/ +public/ +server/ + api/ + middleware/ + plugins/ + routes/ + utils/ +nuxt.config.ts +``` + +
+ +πŸ‘‰ For more details, see the [PR implementing this change](https://github.com/nuxt/nuxt/pull/27029). + +##### Reasons for Change + +1. **Performance** - placing all your code in the root of your repo causes issues with `.git/` and `node_modules/` folders being scanned/included by FS watchers which can significantly delay startup on non-Mac OSes. +1. **IDE type-safety** - `server/` and the rest of your app are running in two entirely different contexts with different global imports available, and making sure `server/` isn't _inside_ the same folder as the rest of your app is a big first step to ensuring you get good auto-completes in your IDE. + +##### Migration Steps + +1. Create a new directory called `app/`. +1. Move your `assets/`, `components/`, `composables/`, `layouts/`, `middleware/`, `pages/`, `plugins/` and `utils/` folders under it, as well as `app.vue`, `error.vue`, `app.config.ts`. If you have an `app/router-options.ts` or `app/spa-loading-template.html`, these paths remain the same. +1. Make sure your `nuxt.config.ts`, `content/`, `layers/`, `modules/`, `public/` and `server/` folders remain outside the `app/` folder, in the root of your project. +1. Remember to update any third-party configuration files to work with the new directory structure, such as your `tailwindcss` or `eslint` configuration (if required - `@nuxtjs/tailwindcss` should automatically configure `tailwindcss` correctly). + +::tip +You can automate this migration by running `npx codemod@latest nuxt/4/file-structure` +:: + +However, migration is _not required_. If you wish to keep your current folder structure, Nuxt should auto-detect it. (If it does not, please raise an issue.) The one exception is that if you _already_ have a custom `srcDir`. In this case, you should be aware that your `modules/`, `public/` and `server/` folders will be resolved from your `rootDir` rather than from your custom `srcDir`. You can override this by configuring `dir.modules`, `dir.public` and `serverDir` if you need to. + +You can also force a v3 folder structure with the following configuration: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + // This reverts the new srcDir default from `app` back to your root directory + srcDir: '.', + // This specifies the directory prefix for `app/router.options.ts` and `app/spa-loading-template.html` + dir: { + app: 'app' + } +}) +``` + +#### Normalized Component Names + +🚦 **Impact Level**: Moderate + +Vue will now generate component names that match the Nuxt pattern for component naming. + +##### What Changed + +By default, if you haven't set it manually, Vue will assign a component name that matches +the filename of the component. + +```bash [Directory structure] +β”œβ”€ components/ +β”œβ”€β”€β”€ SomeFolder/ +β”œβ”€β”€β”€β”€β”€ MyComponent.vue +``` + +In this case, the component name would be `MyComponent`, as far as Vue is concerned. If you wanted to use `` with it, or identify it in the Vue DevTools, you would need to use this name. + +But in order to auto-import it, you would need to use `SomeFolderMyComponent`. + +With this change, these two values will match, and Vue will generate a component name that matches the Nuxt pattern for component naming. + +##### Migration Steps + +Ensure that you use the updated name in any tests which use `findComponent` from `@vue/test-utils` and in any `` which depends on the name of your component. + +Alternatively, for now, you can disable this behaviour with: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + normalizeComponentNames: false + } +}) +``` + +#### Shared Prerender Data + +🚦 **Impact Level**: Medium + +##### What Changed + +We enabled a previously experimental feature to share data from `useAsyncData` and `useFetch` calls, across different pages. See [original PR](https://github.com/nuxt/nuxt/pull/24894). + +##### Reasons for Change + +This feature automatically shares payload _data_ between pages that are prerendered. This can result in a significant performance improvement when prerendering sites that use `useAsyncData` or `useFetch` and fetch the same data in different pages. + +For example, if your site requires a `useFetch` call for every page (for example, to get navigation data for a menu, or site settings from a CMS), this data would only be fetched once when prerendering the first page that uses it, and then cached for use when prerendering other pages. + +##### Migration Steps + +Make sure that any unique key of your data is always resolvable to the same data. For example, if you are using `useAsyncData` to fetch data related to a particular page, you should provide a key that uniquely matches that data. (`useFetch` should do this automatically for you.) + +```ts [app/pages/test/[slug\\].vue] +// This would be unsafe in a dynamic page (e.g. `[slug].vue`) because the route slug makes a difference +// to the data fetched, but Nuxt can't know that because it's not reflected in the key. +const route = useRoute() +const { data } = await useAsyncData(async () => { + return await $fetch(`/api/my-page/${route.params.slug}`) +}) +// Instead, you should use a key that uniquely identifies the data fetched. +const { data } = await useAsyncData(route.params.slug, async () => { + return await $fetch(`/api/my-page/${route.params.slug}`) +}) +``` + +Alternatively, you can disable this feature with: + +```ts twoslash [nuxt.config.ts] +export default defineNuxtConfig({ + experimental: { + sharedPrerenderData: false + } +}) +``` + +#### Default `data` and `error` values in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +`data` and `error` objects returned from `useAsyncData` will now default to `undefined`. + +##### Reasons for Change + +Previously `data` was initialized to `null` but reset in `clearNuxtData` to `undefined`. `error` was initialized to `null`. This change is to bring greater consistency. + +##### Migration Steps + +If you were checking if `data.value` or `error.value` were `null`, you can update these checks to check for `undefined` instead. + +::tip +You can automate this step by running `npx codemod@latest nuxt/4/default-data-error-value` +:: + +If you encounter any issues you can revert back to the previous behavior with: + +```ts twoslash [nuxt.config.ts] +// @errors: 2353 +export default defineNuxtConfig({ + experimental: { + defaults: { + useAsyncData: { + value: 'null', + errorValue: 'null' + } + } + } +}) +``` + +Please report an issue if you are doing this, as we do not plan to keep this as configurable. + +#### Removal of deprecated `boolean` values for `dedupe` option when calling `refresh` in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +Previously it was possible to pass `dedupe: boolean` to `refresh`. These were aliases of `cancel` (`true`) and `defer` (`false`). + +```ts twoslash [app.vue] +// @errors: 2322 +const { refresh } = await useAsyncData(async () => ({ message: 'Hello, Nuxt 3!' })) + +async function refreshData () { + await refresh({ dedupe: true }) +} +``` + +##### Reasons for Change + +These aliases were removed, for greater clarity. + +The issue came up when adding `dedupe` as an option to `useAsyncData`, and we removed the boolean values as they ended up being _opposites_. + +`refresh({ dedupe: false })` meant 'do not _cancel_ existing requests in favour of this new one'. But passing `dedupe: true` within the options of `useAsyncData` means 'do not make any new requests if there is an existing pending request.' (See [PR](https://github.com/nuxt/nuxt/pull/24564#pullrequestreview-1764584361).) + +##### Migration Steps + +The migration should be straightforward: + +```diff + const { refresh } = await useAsyncData(async () => ({ message: 'Hello, Nuxt 3!' })) + + async function refreshData () { +- await refresh({ dedupe: true }) ++ await refresh({ dedupe: 'cancel' }) + +- await refresh({ dedupe: false }) ++ await refresh({ dedupe: 'defer' }) + } +``` + +::tip +You can automate this step by running `npx codemod@latest nuxt/4/deprecated-dedupe-value` +:: + +#### Respect defaults when clearing `data` in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +If you provide a custom `default` value for `useAsyncData`, this will now be used when calling `clear` or `clearNuxtData` and it will be reset to its default value rather than simply unset. + +##### Reasons for Change + +Often users set an appropriately empty value, such as an empty array, to avoid the need to check for `null`/`undefined` when iterating over it. This should be respected when resetting/clearing the data. + +##### Migration Steps + +If you encounter any issues you can revert back to the previous behavior, for now, with: + +```ts twoslash [nuxt.config.ts] +// @errors: 2353 +export default defineNuxtConfig({ + experimental: { + resetAsyncDataToUndefined: true, + } +}) +``` + +Please report an issue if you are doing so, as we do not plan to keep this as configurable. + +#### Shallow Data Reactivity in `useAsyncData` and `useFetch` + +🚦 **Impact Level**: Minimal + +The `data` object returned from `useAsyncData`, `useFetch`, `useLazyAsyncData` and `useLazyFetch` is now a `shallowRef` rather than a `ref`. + +##### What Changed + +When new data is fetched, anything depending on `data` will still be reactive because the entire object is replaced. But if your code changes a property _within_ that data structure, this will not trigger any reactivity in your app. + +##### Reasons for Change + +This brings a **significant** performance improvement for deeply nested objects and arrays because Vue does not need to watch every single property/array for modification. In most cases, `data` should also be immutable. + +##### Migration Steps + +In most cases, no migration steps are required, but if you rely on the reactivity of the data object then you have two options: + +1. You can granularly opt in to deep reactivity on a per-composable basis: + ```diff + - const { data } = useFetch('/api/test') + + const { data } = useFetch('/api/test', { deep: true }) + ``` +1. You can change the default behavior on a project-wide basis (not recommended): + ```ts twoslash [nuxt.config.ts] + export default defineNuxtConfig({ + experimental: { + defaults: { + useAsyncData: { + deep: true + } + } + } + }) + ``` + +::tip +If you need to, you can automate this step by running `npx codemod@latest nuxt/4/shallow-function-reactivity` +:: + +#### Absolute Watch Paths in `builder:watch` + +🚦 **Impact Level**: Minimal + +##### What Changed + +The Nuxt `builder:watch` hook now emits a path which is absolute rather than relative to your project `srcDir`. + +##### Reasons for Change + +This allows us to support watching paths which are outside your `srcDir`, and offers better support for layers and other more complex patterns. + +##### Migration Steps + +We have already proactively migrated the public Nuxt modules which we are aware use this hook. See [issue #25339](https://github.com/nuxt/nuxt/issues/25339). + +However, if you are a module author using the `builder:watch` hook and wishing to remain backwards/forwards compatible, you can use the following code to ensure that your code works the same in both Nuxt v3 and Nuxt v4: + +```diff ++ import { relative, resolve } from 'node:fs' + // ... + nuxt.hook('builder:watch', async (event, path) => { ++ path = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, path)) + // ... + }) +``` + +::tip +You can automate this step by running `npx codemod@latest nuxt/4/absolute-watch-path` +:: + +#### Removal of `window.__NUXT__` object + +##### What Changed + +We are removing the global `window.__NUXT__` object after the app finishes hydration. + +##### Reasons for Change + +This opens the way to multi-app patterns ([#21635](https://github.com/nuxt/nuxt/issues/21635)) and enables us to focus on a single way to access Nuxt app data - `useNuxtApp()`. + +##### Migration Steps + +The data is still available, but can be accessed with `useNuxtApp().payload`: + +```diff +- console.log(window.__NUXT__) ++ console.log(useNuxtApp().payload) +``` + +#### Directory index scanning + +🚦 **Impact Level**: Medium + +##### What Changed + +Child folders in your `middleware/` folder are also scanned for `index` files and these are now also registered as middleware in your project. + +##### Reasons for Change + +Nuxt scans a number of folders automatically, including `middleware/` and `plugins/`. + +Child folders in your `plugins/` folder are scanned for `index` files and we wanted to make this behavior consistent between scanned directories. + +##### Migration Steps + +Probably no migration is necessary but if you wish to revert to previous behavior you can add a hook to filter out these middleware: + +```ts +export default defineNuxtConfig({ + hooks: { + 'app:resolve'(app) { + app.middleware = app.middleware.filter(mw => !/\/index\.[^/]+$/.test(mw.path)) + } + } +}) +``` + +#### Template Compilation Changes + +🚦 **Impact Level**: Minimal + +##### What Changed + +Previously, Nuxt used `lodash/template` to compile templates located on the file system using the `.ejs` file format/syntax. + +In addition, we provided some template utilities (`serialize`, `importName`, `importSources`) which could be used for code-generation within these templates, which are now being removed. + +##### Reasons for Change + +In Nuxt v3 we moved to a 'virtual' syntax with a `getContents()` function which is much more flexible and performant. + +In addition, `lodash/template` has had a succession of security issues. These do not really apply to Nuxt projects because it is being used at build-time, not runtime, and by trusted code. However, they still appear in security audits. Moreover, `lodash` is a hefty dependency and is unused by most projects. + +Finally, providing code serialization functions directly within Nuxt is not ideal. Instead, we maintain projects like [unjs/knitwork](http://github.com/unjs/knitwork) which can be dependencies of your project, and where security issues can be reported/resolved directly without requiring an upgrade of Nuxt itself. + +##### Migration Steps + +We have raised PRs to update modules using EJS syntax, but if you need to do this yourself, you have three backwards/forwards-compatible alternatives: + +* Moving your string interpolation logic directly into `getContents()`. +* Using a custom function to handle the replacement, such as in https://github.com/nuxt-modules/color-mode/pull/240. +* Continuing to use `lodash`, as a dependency of _your_ project rather than Nuxt: + +```diff ++ import { readFileSync } from 'node:fs' ++ import { template } from 'lodash-es' + // ... + addTemplate({ + fileName: 'appinsights-vue.js' + options: { /* some options */ }, +- src: resolver.resolve('./runtime/plugin.ejs'), ++ getContents({ options }) { ++ const contents = readFileSync(resolver.resolve('./runtime/plugin.ejs'), 'utf-8') ++ return template(contents)({ options }) ++ }, + }) +``` + +Finally, if you are using the template utilities (`serialize`, `importName`, `importSources`), you can replace them as follows with utilities from `knitwork`: + +```ts +import { genDynamicImport, genImport, genSafeVariableName } from 'knitwork' + +const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"{(.+)}"(?=,?$)/gm, r => JSON.parse(r).replace(/^{(.*)}$/, '$1')) + +const importSources = (sources: string | string[], { lazy = false } = {}) => { + return toArray(sources).map((src) => { + if (lazy) { + return `const ${genSafeVariableName(src)} = ${genDynamicImport(src, { comment: `webpackChunkName: ${JSON.stringify(src)}` })}` + } + return genImport(src, genSafeVariableName(src)) + }).join('\n') +} + +const importName = genSafeVariableName +``` + +::tip +You can automate this step by running `npx codemod@latest nuxt/4/template-compilation-changes` +:: + +#### Removal of Experimental Features + +🚦 **Impact Level**: Minimal + +##### What Changed + +Four experimental features are no longer configurable in Nuxt 4: + +* `experimental.treeshakeClientOnly` will be `true` (default since v3.0) +* `experimental.configSchema` will be `true` (default since v3.3) +* `experimental.polyfillVueUseHead` will be `false` (default since v3.4) +* `experimental.respectNoSSRHeader` will be `false` (default since v3.4) +* `vite.devBundler` is no longer configurable - it will use `vite-node` by default + +##### Reasons for Change + +These options have been set to their current values for some time and we do not have a reason to believe that they need to remain configurable. + +##### Migration Steps + +* `polyfillVueUseHead` is implementable in user-land with [this plugin](https://github.com/nuxt/nuxt/blob/f209158352b09d1986aa320e29ff36353b91c358/packages/nuxt/src/head/runtime/plugins/vueuse-head-polyfill.ts#L10-L11) + +* `respectNoSSRHeader`is implementable in user-land with [server middleware](https://github.com/nuxt/nuxt/blob/c660b39447f0d5b8790c0826092638d321cd6821/packages/nuxt/src/core/runtime/nitro/no-ssr.ts#L8-L9) + +## Nuxt 2 vs Nuxt 3+ In the table below, there is a quick comparison between 3 versions of Nuxt: -Feature / Version | Nuxt 2 | Nuxt Bridge | Nuxt 3 +Feature / Version | Nuxt 2 | Nuxt Bridge | Nuxt 3+ -------------------------|-----------------|------------------|--------- Vue | 2 | 2 | 3 -Stability | 😊 Stable | 😌 Semi-stable | 😊 Stable +Stability | 😊 Stable | 😊 Stable | 😊 Stable Performance | 🏎 Fast | ✈️ Faster | πŸš€ Fastest Nitro Engine | ❌ | βœ… | βœ… ESM support | πŸŒ™ Partial | πŸ‘ Better | βœ… @@ -41,9 +615,9 @@ Vite | ⚠️ Partial | 🚧 Partial | βœ… Nuxi CLI | ❌ Old | βœ… nuxi | βœ… nuxi Static sites | βœ… | βœ… | βœ… -## Nuxt 2 to Nuxt 3 +## Nuxt 2 to Nuxt 3+ -The migration guide provides a step-by-step comparison of Nuxt 2 features to Nuxt 3 features and guidance to adapt your current application. +The migration guide provides a step-by-step comparison of Nuxt 2 features to Nuxt 3+ features and guidance to adapt your current application. ::read-more{to="/docs/migration/overview"} Check out the **guide to migrating from Nuxt 2 to Nuxt 3**. @@ -51,7 +625,7 @@ Check out the **guide to migrating from Nuxt 2 to Nuxt 3**. ## Nuxt 2 to Nuxt Bridge -If you prefer to progressively migrate your Nuxt 2 application to Nuxt 3, you can use Nuxt Bridge. Nuxt Bridge is a compatibility layer that allows you to use Nuxt 3 features in Nuxt 2 with an opt-in mechanism. +If you prefer to progressively migrate your Nuxt 2 application to Nuxt 3, you can use Nuxt Bridge. Nuxt Bridge is a compatibility layer that allows you to use Nuxt 3+ features in Nuxt 2 with an opt-in mechanism. ::read-more{to="/docs/bridge/overview"} **Migrate from Nuxt 2 to Nuxt Bridge** diff --git a/docs/1.getting-started/2.installation.md b/docs/1.getting-started/2.installation.md index 5d50d4f91b..128a135f57 100644 --- a/docs/1.getting-started/2.installation.md +++ b/docs/1.getting-started/2.installation.md @@ -1,19 +1,19 @@ --- title: 'Installation' description: 'Get started with Nuxt quickly with our online starters or start locally with your terminal.' -navigation.icon: i-ph-play-duotone +navigation.icon: i-ph-play --- ## Play Online -You can start playing with Nuxt 3 in your browser using our online sandboxes: +If you just want to play around with Nuxt in your browser without setting up a project, you can use one of our online sandboxes: ::card-group :card{title="Open on StackBlitz" icon="i-simple-icons-stackblitz" to="https://nuxt.new/s/v3" target="_blank"} :card{title="Open on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://nuxt.new/c/v3" target="_blank"} :: -Start with one of our starters and themes directly by opening [nuxt.new](https://nuxt.new). +Or follow the steps below to set up a new Nuxt project on your computer. ## New Project @@ -22,46 +22,43 @@ Start with one of our starters and themes directly by opening [nuxt.new](https:/ #### Prerequisites - **Node.js** - [`v18.0.0`](https://nodejs.org/en) or newer -- **Text editor** - We recommend [Visual Studio Code](https://code.visualstudio.com/) with the [Volar Extension](https://marketplace.visualstudio.com/items?itemName=Vue.volar) +- **Text editor** - We recommend [Visual Studio Code](https://code.visualstudio.com/) with the [official Vue extension](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (previously known as Volar) - **Terminal** - In order to run Nuxt commands -::callout +::note ::details :summary[Additional notes for an optimal setup:] - **Node.js**: Make sure to use an even numbered version (18, 20, etc) - **Nuxtr**: Install the community-developed [Nuxtr extension](https://marketplace.visualstudio.com/items?itemName=Nuxtr.nuxtr-vscode) - - **Volar**: Either enable [**Take Over Mode**](https://vuejs.org/guide/typescript/overview.html#volar-takeover-mode) (recommended) or add the [TypeScript Vue Plugin](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) - - If you have enabled **Take Over Mode** or installed the **TypeScript Vue Plugin (Volar)**, you can disable generating the shim for `*.vue` files in your [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file: - - ```ts twoslash [nuxt.config.ts] - export default defineNuxtConfig({ - typescript: { - shim: false - } - }) - ``` :: :: Open a terminal (if you're using [Visual Studio Code](https://code.visualstudio.com), you can open an [integrated terminal](https://code.visualstudio.com/docs/editor/integrated-terminal)) and use the following command to create a new starter project: -::code-group +::package-managers -```bash [npx] +```bash [npm] npx nuxi@latest init ``` +```bash [yarn] +yarn dlx nuxi@latest init +``` + ```bash [pnpm] pnpm dlx nuxi@latest init ``` ```bash [bun] -bunx nuxi@latest init +bun x nuxi@latest init ``` :: +::tip +Alternatively, you can find other starters or themes by opening [nuxt.new](https://nuxt.new) and following the instructions there. +:: + Open your project folder in Visual Studio Code: ```bash [Terminal] @@ -74,47 +71,20 @@ Or change directory into your new project from your terminal: cd ``` -Install the dependencies: - -::code-group - -```bash [yarn] -yarn install -``` - -```bash [npm] -npm install -``` - -```bash [pnpm] -pnpm install -``` - -```bash [bun] -bun install -``` - -:: - -::callout -If you are using Yarn 2+ (Berry), add `nodeLinker: node-modules` to your `.yarnrc.yml` file. -[You can follow this issue status here](https://github.com/nuxt/nuxt/issues/22861) -:: - ## Development Server Now you'll be able to start your Nuxt app in development mode: -::code-group - -```bash [yarn] -yarn dev --open -``` +::package-managers ```bash [npm] npm run dev -- -o ``` +```bash [yarn] +yarn dev --open +``` + ```bash [pnpm] pnpm dev -o ``` @@ -124,12 +94,12 @@ bun run dev -o ``` :: -::callout{icon="i-ph-check-circle-duotone"} +::tip{icon="i-ph-check-circle"} Well done! A browser window should automatically open for . :: ## Next Steps -Now that you've created your Nuxt 3 project, you are ready to start building your application. +Now that you've created your Nuxt project, you are ready to start building your application. :read-more{title="Nuxt Concepts" to="/docs/guide/concepts"} diff --git a/docs/1.getting-started/3.configuration.md b/docs/1.getting-started/3.configuration.md index 75768a9599..4cf9310b81 100644 --- a/docs/1.getting-started/3.configuration.md +++ b/docs/1.getting-started/3.configuration.md @@ -1,10 +1,9 @@ --- title: Configuration description: Nuxt is configured with sensible defaults to make you productive. -navigation.icon: i-ph-gear-duotone +navigation.icon: i-ph-gear --- - By default, Nuxt is configured to cover most use cases. The [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file can override or extend this default configuration. ## Nuxt Configuration @@ -25,11 +24,11 @@ This file will often be mentioned in the documentation, for example to add custo Every option is described in the **Configuration Reference**. :: -::callout +::note You don't have to use TypeScript to build an application with Nuxt. However, it is strongly recommended to use the `.ts` extension for the `nuxt.config` file. This way you can benefit from hints in your IDE to avoid typos and mistakes while editing your configuration. :: -### Environment overrides +### Environment Overrides You can configure fully typed, per-environment overrides in your nuxt.config @@ -46,7 +45,11 @@ export default defineNuxtConfig({ }) ``` -::callout +::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=DFZI2iVCrNc" target="_blank"} +Watch a video from Alexander Lichter about the env-aware `nuxt.config.ts`. +:: + +::note If you're authoring layers, you can also use the `$meta` key to provide metadata that you or the consumers of your layer might use. :: @@ -135,7 +138,7 @@ Non primitive JS types | ❌ No | βœ… Yes ## External Configuration Files -Nuxt uses [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file as the single source of trust for configurations and skips reading external configuration files. During the course of building your project, you may have a need to configure those. The following table highlights common configurations and, where applicable, how they can be configured with Nuxt. +Nuxt uses [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file as the single source of truth for configurations and skips reading external configuration files. During the course of building your project, you may have a need to configure those. The following table highlights common configurations and, where applicable, how they can be configured with Nuxt. Name | Config File | How To Configure ---------------------------------------------|---------------------------|------------------------- @@ -149,7 +152,7 @@ Here is a list of other common config files: Name | Config File | How To Configure ---------------------------------------------|-------------------------|-------------------------- [TypeScript](https://www.typescriptlang.org) | `tsconfig.json` | [More Info](/docs/guide/concepts/typescript#nuxttsconfigjson) -[ESLint](https://eslint.org) | `.eslintrc.js` | [More Info](https://eslint.org/docs/latest/use/configure/configuration-files) +[ESLint](https://eslint.org) | `eslint.config.js` | [More Info](https://eslint.org/docs/latest/use/configure/configuration-files) [Prettier](https://prettier.io) | `.prettierrc.json` | [More Info](https://prettier.io/docs/en/configuration.html) [Stylelint](https://stylelint.io) | `.stylelintrc.json` | [More Info](https://stylelint.io/user-guide/configure) [TailwindCSS](https://tailwindcss.com) | `tailwind.config.js` | [More Info](https://tailwindcss.nuxtjs.org/tailwind/config) diff --git a/docs/1.getting-started/3.views.md b/docs/1.getting-started/3.views.md index 445ff82c94..e169bdfa66 100644 --- a/docs/1.getting-started/3.views.md +++ b/docs/1.getting-started/3.views.md @@ -1,7 +1,7 @@ --- title: 'Views' description: 'Nuxt provides several component layers to implement the user interface of your application.' -navigation.icon: i-ph-layout-duotone +navigation.icon: i-ph-layout --- ## `app.vue` @@ -18,7 +18,7 @@ By default, Nuxt will treat this file as the **entrypoint** and render its conte ``` -::callout +::tip If you are familiar with Vue, you might wonder where `main.js` is (the file that normally creates a Vue app). Nuxt does this behind the scene. :: @@ -90,12 +90,22 @@ To use pages, create `pages/index.vue` file and add `` component to Layouts are wrappers around pages that contain a common User Interface for several pages, such as a header and footer display. Layouts are Vue files using `` components to display the **page** content. The `layouts/default.vue` file will be used by default. Custom layouts can be set as part of your page metadata. -::callout +::note If you only have a single layout in your application, we recommend using [`app.vue`](/docs/guide/directory-structure/app) with [``](/docs/api/components/nuxt-page) instead. :: ::code-group +```vue [app.vue] + +``` + ```vue [layouts/default.vue] ``` -::callout +::note Nuxt won't serve files in the [`assets/`](/docs/guide/directory-structure/assets) directory at a static URL like `/assets/my-file.png`. If you need a static URL, use the [`public/`](#public-directory) directory. :: diff --git a/docs/1.getting-started/4.styling.md b/docs/1.getting-started/4.styling.md index 5fd268055d..d780ad3449 100644 --- a/docs/1.getting-started/4.styling.md +++ b/docs/1.getting-started/4.styling.md @@ -1,7 +1,7 @@ --- title: 'Styling' description: 'Learn how to style your Nuxt application.' -navigation.icon: i-ph-palette-duotone +navigation.icon: i-ph-palette --- Nuxt is highly flexible when it comes to styling. Write your own styles, or reference local and external stylesheets. @@ -30,7 +30,7 @@ import('~/assets/css/first.css') ``` -::callout +::tip The stylesheets will be inlined in the HTML rendered by Nuxt. :: @@ -45,7 +45,7 @@ export default defineNuxtConfig({ }) ``` -::callout +::tip The stylesheets will be inlined in the HTML rendered by Nuxt, injected globally and present in all pages. :: @@ -77,10 +77,26 @@ h1 { You can also reference stylesheets that are distributed through npm. Let's use the popular `animate.css` library as an example. -```bash [Terminal] +::package-managers + +```bash [npm] npm install animate.css ``` +```bash [yarn] +yarn add animate.css +``` + +```bash [pnpm] +pnpm install animate.css +``` + +```bash [bun] +bun install animate.css +``` + +:: + Then you can reference it directly in your pages, layouts and components: ```vue [app.vue] @@ -113,7 +129,8 @@ export default defineNuxtConfig({ head: { link: [{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css' }] } -}}) + } +}) ``` ### Dynamically Adding Stylesheets @@ -153,15 +170,15 @@ To use a preprocessor like SCSS, Sass, Less or Stylus, install it first. ::code-group ```bash [Sass & SCSS] -npm install sass +npm install -D sass ``` ```bash [Less] -npm install less +npm install -D less ``` ```bash [Stylus] -npm install stylus +npm install -D stylus ``` :: @@ -183,7 +200,7 @@ export default defineNuxtConfig({ }) ``` -::callout +::tip In both cases, the compiled stylesheets will be inlined in the HTML rendered by Nuxt. :: @@ -243,7 +260,7 @@ Nuxt uses Vite by default. If you wish to use webpack instead, refer to each pre ## Single File Components (SFC) Styling -One of the best things about Vue and SFC is how great it is at naturally dealing with styling. You can directly write CSS or preprocessor code in the style block of your components file, therefore you will have fantastic developer experience without having to use something like CSS-in-JS. However if you wish to use CSS-in-JS, you can find 3rd party libraries and modules that support it, such as [pinceau](https://pinceau.dev). +One of the best things about Vue and SFC is how great it is at naturally dealing with styling. You can directly write CSS or preprocessor code in the style block of your components file, therefore you will have fantastic developer experience without having to use something like CSS-in-JS. However if you wish to use CSS-in-JS, you can find 3rd party libraries and modules that support it, such as [pinceau](https://github.com/Tahul/pinceau). You can refer to the [Vue docs](https://vuejs.org/api/sfc-css-features.html) for a comprehensive reference about styling components in SFC. @@ -286,7 +303,7 @@ const classObject = computed(() => ({ ``` ```vue [Array] - @@ -410,8 +427,8 @@ Nuxt comes with postcss built-in. You can configure it in your `nuxt.config` fil export default defineNuxtConfig({ postcss: { plugins: { - 'postcss-nested': {} - "postcss-custom-media": {} + 'postcss-nested': {}, + 'postcss-custom-media': {} } } }) @@ -421,7 +438,7 @@ For proper syntax highlighting in SFC, you can use the postcss lang attribute. ```vue ``` @@ -430,7 +447,7 @@ By default, Nuxt comes with the following plugins already pre-configured: - [postcss-import](https://github.com/postcss/postcss-import): Improves the `@import` rule - [postcss-url](https://github.com/postcss/postcss-url): Transforms `url()` statements - [autoprefixer](https://github.com/postcss/autoprefixer): Automatically adds vendor prefixes -- [cssnano](https://cssnano.co): Minification and purge +- [cssnano](https://cssnano.github.io/cssnano): Minification and purge ## Leveraging Layouts For Multiple Styles @@ -458,14 +475,14 @@ Use different styles for different layouts. Nuxt isn't opinionated when it comes to styling and provides you with a wide variety of options. You can use any styling tool that you want, such as popular libraries like [UnoCSS](https://unocss.dev) or [Tailwind CSS](https://tailwindcss.com). -The community and the Nuxt team have developed plenty of Nuxt modules to makes the integration easier. +The community and the Nuxt team have developed plenty of Nuxt modules to make the integration easier. You can discover them on the [modules section](/modules) of the website. Here are a few modules to help you get started: - [UnoCSS](/modules/unocss): Instant on-demand atomic CSS engine - [Tailwind CSS](/modules/tailwindcss): Utility-first CSS framework - [Fontaine](https://github.com/nuxt-modules/fontaine): Font metric fallback -- [Pinceau](https://pinceau.dev): Adaptable styling framework +- [Pinceau](https://github.com/Tahul/pinceau): Adaptable styling framework - [Nuxt UI](https://ui.nuxt.com): A UI Library for Modern Web Apps - [Panda CSS](https://panda-css.com/docs/installation/nuxt): CSS-in-JS engine that generates atomic CSS at build time @@ -489,7 +506,7 @@ Nuxt comes with the same `` element that Vue has, and also has suppo We would recommend using [Fontaine](https://github.com/nuxt-modules/fontaine) to reduce your [CLS](https://web.dev/cls). If you need something more advanced, consider creating a Nuxt module to extend the build process or the Nuxt runtime. -::callout +::tip Always remember to take advantage of the various tools and techniques available in the Web ecosystem at large to make styling your application easier and more efficient. Whether you're using native CSS, a preprocessor, postcss, a UI library or a module, Nuxt has got you covered. Happy styling! :: @@ -513,7 +530,7 @@ export default defineNuxtConfig({ hooks: { 'build:manifest': (manifest) => { // find the app entry, css list - const css = manifest['node_modules/nuxt/dist/app/entry.js']?.css + const css = Object.values(manifest).find(options => options.isEntry)?.css if (css) { // start from the end of the array and go to the beginning for (let i = css.length - 1; i >= 0; i--) { diff --git a/docs/1.getting-started/5.routing.md b/docs/1.getting-started/5.routing.md index 605a0171c2..e88d9e8c24 100644 --- a/docs/1.getting-started/5.routing.md +++ b/docs/1.getting-started/5.routing.md @@ -1,7 +1,7 @@ --- title: 'Routing' description: Nuxt file-system routing creates a route for every file in the pages/ directory. -navigation.icon: i-ph-signpost-duotone +navigation.icon: i-ph-signpost --- One core feature of Nuxt is the file system router. Every Vue file inside the [`pages/`](/docs/guide/directory-structure/pages) directory creates a corresponding URL (or route) that displays the contents of the file. By using dynamic imports for each page, Nuxt leverages code-splitting to ship the minimum amount of JavaScript for the requested route. @@ -15,7 +15,7 @@ This file system routing uses naming conventions to create dynamic and nested ro ::code-group ```bash [Directory Structure] -| pages/ +-| pages/ ---| about.vue ---| index.vue ---| posts/ @@ -86,7 +86,7 @@ console.log(route.params.id) Nuxt provides a customizable route middleware framework you can use throughout your application, ideal for extracting code that you want to run before navigating to a particular route. -::callout +::note Route middleware runs within the Vue part of your Nuxt app. Despite the similar name, they are completely different from server middleware, which are run in the Nitro server part of your app. :: @@ -140,7 +140,7 @@ If you have a more complex use case, then you can use anonymous route middleware definePageMeta({ validate: async (route) => { // Check if the id is made up of digits - return /^\d+$/.test(route.params.id) + return typeof route.params.id === 'string' && /^\d+$/.test(route.params.id) } }) diff --git a/docs/1.getting-started/5.seo-meta.md b/docs/1.getting-started/5.seo-meta.md index ebc0ac99d2..46a3f2298d 100644 --- a/docs/1.getting-started/5.seo-meta.md +++ b/docs/1.getting-started/5.seo-meta.md @@ -1,12 +1,12 @@ --- title: SEO and Meta description: Improve your Nuxt app's SEO with powerful head config, composables and components. -navigation.icon: i-ph-file-search-duotone +navigation.icon: i-ph-file-search --- ## Defaults -Out-of-the-box, Nuxt provides sane defaults, which you can override if needed. +Out-of-the-box, Nuxt provides sensible defaults, which you can override if needed. ```ts twoslash [nuxt.config.ts] export default defineNuxtConfig({ @@ -21,7 +21,7 @@ export default defineNuxtConfig({ Providing an [`app.head`](/docs/api/nuxt-config#head) property in your [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) allows you to customize the head for your entire app. -::callout +::important This method does not allow you to provide reactive data. We recommend to use `useHead()` in `app.vue`. :: @@ -128,8 +128,6 @@ See [@unhead/schema](https://github.com/unjs/unhead/blob/main/packages/schema/sr Reactivity is supported on all properties, by providing a computed value, a getter, or a reactive object. -It's recommended to use getters (`() => value`) over computed (`computed(() => value)`). - ::code-group ```vue twoslash [useHead] @@ -143,7 +141,7 @@ It's recommended to use getters (`() => value`) over computed (`computed(() => v }) ``` - + ```vue twoslash [useSeoMeta] - + ``` -::callout{icon="i-ph-flag-duotone" color="red"} +::caution **Security note:** Be careful not to expose runtime config keys to the client-side by either rendering them or passing them to `useState`. :: @@ -142,7 +146,7 @@ export default defineEventHandler(async (event) => { }) ``` -::callout +::note Giving the `event` as argument to `useRuntimeConfig` is optional, but it is recommended to pass it to get the runtime config overwritten by [environment variables](/docs/guide/going-further/runtime-config#environment-variables) at runtime for server routes. :: @@ -164,3 +168,7 @@ declare module 'nuxt/schema' { // It is always important to ensure you import/export something when augmenting a type export {} ``` + +::note +`nuxt/schema` is provided as a convenience for end-users to access the version of the schema used by Nuxt in their project. Module authors should instead augment `@nuxt/schema`. +:: diff --git a/docs/2.guide/3.going-further/11.nightly-release-channel.md b/docs/2.guide/3.going-further/11.nightly-release-channel.md index fe908d1b7f..f359b5bc73 100644 --- a/docs/2.guide/3.going-further/11.nightly-release-channel.md +++ b/docs/2.guide/3.going-further/11.nightly-release-channel.md @@ -11,10 +11,16 @@ You can use these 'nightly' releases to beta test new features and changes. The build and publishing method and quality of these 'nightly' releases are the same as stable ones. The only difference is that you should often check the GitHub repository for updates. There is a slight chance of regressions not being caught during the review process and by the automated tests. Therefore, we internally use this channel to double-check everything before each release. -::callout +::note Features that are only available on the nightly release channel are marked with an alert in the documentation. :: +::alert{type="warning"} +The `latest` nightly release channel is currently tracking the Nuxt v4 branch, meaning that it is particularly likely to have breaking changes right now - be careful! + +You can opt in to the 3.x branch nightly releases with `"nuxt": "npm:nuxt-nightly@3x"`. +:: + ## Opting In Update `nuxt` dependency inside `package.json`: @@ -47,7 +53,7 @@ Remove lockfile (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, or `bun.loc ## Using Nightly `nuxi` -::callout +::note All cli dependencies are bundled because of the building method for reducing `nuxi` package size. :br You can get dependency updates and CLI improvements using the nightly release channel. :: diff --git a/docs/2.guide/3.going-further/2.hooks.md b/docs/2.guide/3.going-further/2.hooks.md index 50df4ed236..7e6bb474fe 100644 --- a/docs/2.guide/3.going-further/2.hooks.md +++ b/docs/2.guide/3.going-further/2.hooks.md @@ -3,7 +3,7 @@ title: "Lifecycle Hooks" description: "Nuxt provides a powerful hooking system to expand almost every aspect using hooks." --- -::callout +::tip The hooking system is powered by [unjs/hookable](https://github.com/unjs/hookable). :: @@ -90,7 +90,7 @@ declare module '#app' { } } -declare module 'nitropack' { +declare module 'nitro/types' { interface NitroRuntimeHooks { 'your-nitro-hook': () => void; } diff --git a/docs/2.guide/3.going-further/3.modules.md b/docs/2.guide/3.going-further/3.modules.md index 5ade0f627c..b12d886821 100644 --- a/docs/2.guide/3.going-further/3.modules.md +++ b/docs/2.guide/3.going-further/3.modules.md @@ -13,10 +13,25 @@ With modules, you can encapsulate, properly test, and share custom solutions as We recommend you get started with Nuxt Modules using our [starter template](https://github.com/nuxt/starter/tree/module): -```bash [Terminal] +::package-managers + +```bash [npm] npx nuxi init -t module my-module ``` +```bash [yarn] +yarn dlx nuxi init -t module my-module +``` + +```bash [pnpm] +pnpm dlx nuxi init -t module my-module +``` + +```bash [bun] +bun x nuxi init -t module my-module +``` +:: + This will create a `my-module` project with all the boilerplate necessary to develop and publish your module. **Next steps:** @@ -30,6 +45,10 @@ This will create a `my-module` project with all the boilerplate necessary to dev Learn how to perform basic tasks with the module starter. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/navigating-the-official-starter-template?friend=nuxt" target="_blank"} +Watch Vue School video about Nuxt module starter template. +:: + #### How to Develop While your module source code lives inside the `src` directory, in most cases, to develop a module, you need a Nuxt application. That's what the `playground` directory is about. It's a Nuxt application you can tinker with that is already configured to run with your module. @@ -39,7 +58,7 @@ You can interact with the playground like with any Nuxt application. - Launch its development server with `npm run dev`, it should reload itself as you make changes to your module in the `src` directory - Build it with `npm run dev:build` -::callout{color="blue" icon="i-ph-info-duotone"} +::note All other `nuxi` commands can be used against the `playground` directory (e.g. `nuxi playground`). Feel free to declare additional `dev:*` scripts within your `package.json` referencing them for convenience. :: @@ -50,7 +69,7 @@ The module starter comes with a basic test suite: - A linter powered by [ESLint](https://eslint.org), run it with `npm run lint` - A test runner powered by [Vitest](https://vitest.dev), run it with `npm run test` or `npm run test:watch` -::callout +::tip Feel free to augment this default test strategy to better suit your needs. :: @@ -60,19 +79,19 @@ Nuxt Modules come with their own builder provided by [`@nuxt/module-builder`](ht You can build your module by running `npm run prepack`. -::callout +::tip While building your module can be useful in some cases, most of the time you won't need to build it on your own: the `playground` takes care of it while developing, and the release script also has you covered when publishing. :: #### How to Publish -::callout{color="amber" icon="i-ph-warning-duotone"} +::important Before publishing your module to npm, makes sure you have an [npmjs.com](https://www.npmjs.com) account and that you're authenticated to it locally with `npm login`. :: While you can publish your module by bumping its version and using the `npm publish` command, the module starter comes with a release script that helps you make sure you publish a working version of your module to npm and more. -To use the release script, first, commit all your changes (we recommend you follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to also take advantage of automatic version bump and changelog update), then run the release script with `npm run release`. +To use the release script, first, commit all your changes (we recommend you follow [Conventional Commits](https://www.conventionalcommits.org) to also take advantage of automatic version bump and changelog update), then run the release script with `npm run release`. When running the release script, the following will happen: @@ -85,7 +104,7 @@ When running the release script, the following will happen: - Publishing the module to npm (for that purpose, the module will be built again to ensure its updated version number is taken into account in the published artifact) - Pushing a git tag representing the newly published version to your git remote origin -::callout +::tip As with other scripts, feel free to fine-tune the default `release` script in your `package.json` to better suit your needs. :: @@ -104,7 +123,7 @@ In either case, their anatomy is similar. #### Module Definition -::callout +::note When using the starter, your module definition is available at `src/module.ts`. :: @@ -151,7 +170,7 @@ export default defineNuxtModule({ // Compatibility constraints compatibility: { // Semver version of supported nuxt versions - nuxt: '^3.0.0' + nuxt: '>=3.0.0' } }, // Default configuration options for your module, can also be a function returning those @@ -179,7 +198,7 @@ Ultimately `defineNuxtModule` returns a wrapper function with the lower level `( #### Runtime Directory -::callout +::note When using the starter, the runtime directory is available at `src/runtime`. :: @@ -203,15 +222,15 @@ Or any other kind of asset you want to inject in users' Nuxt applications: You'll then be able to inject all those assets inside the application from your [module definition](#module-definition). -::callout +::tip Learn more about asset injection in [the recipes section](#recipes). :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Published modules cannot leverage auto-imports for assets within their runtime directory. Instead, they have to import them explicitly from `#imports` or alike. - +:br :br Indeed, auto-imports are not enabled for files within `node_modules` (the location where a published module will eventually live) for performance reasons. - +:br :br If you are using the module starter, auto-imports will not be enabled in your playground either. :: @@ -255,6 +274,10 @@ export default defineNuxtModule({ When you need to handle more complex configuration alterations, you should consider using [defu](https://github.com/unjs/defu). +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/extending-and-altering-nuxt-configuration-and-options?friend=nuxt" target="_blank"} +Watch Vue School video about altering Nuxt configuration. +:: + #### Exposing Options to Runtime Because modules aren't part of the application runtime, their options aren't either. However, in many cases, you might need access to some of these module options within your runtime code. We recommend exposing the needed config using Nuxt's [`runtimeConfig`](/docs/api/nuxt-config#runtimeconfig). @@ -282,12 +305,16 @@ You can then access your module options in a plugin, component, the application const options = useRuntimeConfig().public.myModule ``` -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Be careful not to expose any sensitive module configuration on the public runtime config, such as private API keys, as they will end up in the public bundle. :: :read-more{to="/docs/guide/going-further/runtime-config"} +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/passing-and-exposing-module-options?friend=nuxt" target="_blank"} +Watch Vue School video about passing and exposing Nuxt module options. +:: + #### Injecting Plugins With `addPlugin` Plugins are a common way for a module to add runtime logic. You can use the `addPlugin` utility to register them from your module. @@ -344,8 +371,8 @@ export default defineNuxtModule({ setup(options, nuxt) { const resolver = createResolver(import.meta.url) - addComponentsDir({ - path: resolver.resolve('runtime/components') + addComponentsDir({ + path: resolver.resolve('runtime/components') }) } }) @@ -364,8 +391,8 @@ export default defineNuxtModule({ addImports({ name: 'useComposable', // name of the composable to be used - as: 'useComposable', - from: resolver.resolve('runtime/composables/useComposable') // path of composable + as: 'useComposable', + from: resolver.resolve('runtime/composables/useComposable') // path of composable }) } }) @@ -462,7 +489,7 @@ If your module depends on other modules, you can add them by using Nuxt Kit's `i ```ts import { defineNuxtModule, createResolver, installModule } from '@nuxt/kit' -export default defineNuxtModule({ +export default defineNuxtModule({ async setup (options, nuxt) { const { resolve } = createResolver(import.meta.url) @@ -511,9 +538,14 @@ export default defineNuxtModule({ :read-more{to="/docs/api/advanced/hooks"} -::callout -**Module cleanup** +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/nuxt-lifecycle-hooks?friend=nuxt" target="_blank"} +Watch Vue School video about using Nuxt lifecycle hooks in modules. +:: +::note +**Module cleanup** +:br +:br If your module opens, handles, or starts a watcher, you should close it when the Nuxt lifecycle is done. The `close` hook is available for this. ```ts @@ -527,7 +559,6 @@ export default defineNuxtModule({ } }) ``` - :: #### Adding Templates/Virtual Files @@ -567,7 +598,7 @@ export default defineNuxtModule({ interface MyModuleNitroRules { myModule?: { foo: 'bar' } } - declare module 'nitropack' { + declare module 'nitro/types' { interface NitroRouteRules extends MyModuleNitroRules {} interface NitroRouteConfig extends MyModuleNitroRules {} } @@ -592,7 +623,7 @@ If you need to update your templates/virtual files, you can leverage the `update ```ts nuxt.hook('builder:watch', async (event, path) => { - if (path.includes('my-module-feature.config')) { + if (path.includes('my-module-feature.config')) { // This will reload the template that you registered updateTemplates({ filter: t => t.filename === 'my-module-feature.mjs' }) } @@ -605,9 +636,9 @@ Testing helps ensuring your module works as expected given various setup. Find i #### Unit and Integration -::callout +::tip We're still discussing and exploring how to ease unit and integration testing on Nuxt Modules. - +:br :br [Check out this RFC to join the conversation](https://github.com/nuxt/nuxt/discussions/18399). :: @@ -661,7 +692,7 @@ describe('ssr', async () => { describe('csr', async () => { /* ... */ }) ``` -::callout +::tip An example of such a workflow is available on [the module starter](https://github.com/nuxt/starter/blob/module/test/basic.test.ts). :: @@ -683,7 +714,7 @@ As we've seen, Nuxt Modules can be asynchronous. For example, you may want to de However, be careful with asynchronous behaviors as Nuxt will wait for your module to setup before going to the next module and starting the development server, build process, etc. Prefer deferring time-consuming logic to Nuxt hooks. -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning If your module takes more than **1 second** to setup, Nuxt will emit a warning about it. :: @@ -733,6 +764,10 @@ The module starter comes with a default set of tools and configurations (e.g. ES [Nuxt Module ecosystem](/modules) represents more than 15 million monthly NPM downloads and provides extended functionalities and integrations with all sort of tools. You can be part of this ecosystem! +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/exploring-nuxt-modules-ecosystem-and-module-types?friend=nuxt" target="_blank"} +Watch Vue School video about Nuxt module types. +:: + ### Module Types **Official modules** are modules prefixed (scoped) with `@nuxt/` (e.g. [`@nuxt/content`](https://content.nuxtjs.org)). They are made and maintained actively by the Nuxt team. Like with the framework, contributions from the community are more than welcome to help make them better! diff --git a/docs/2.guide/3.going-further/4.kit.md b/docs/2.guide/3.going-further/4.kit.md index a1236b4e72..226762b899 100644 --- a/docs/2.guide/3.going-further/4.kit.md +++ b/docs/2.guide/3.going-further/4.kit.md @@ -15,6 +15,10 @@ Discover all Nuxt Kit utilities. You can install the latest Nuxt Kit by adding it to the `dependencies` section of your `package.json`. However, please consider always explicitly installing the `@nuxt/kit` package even if it is already installed by Nuxt. +::note +`@nuxt/kit` and `@nuxt/schema` are key dependencies for Nuxt. If you are installing it separately, make sure that the versions of `@nuxt/kit` and `@nuxt/schema` are equal to or greater than your `nuxt` version to avoid any unexpected behavior. +:: + ```json [package.json] { "dependencies": { @@ -31,7 +35,7 @@ import { useNuxt } from '@nuxt/kit' :read-more{to="/docs/api/kit"} -::callout +::note Nuxt Kit utilities are only available for modules and not meant to be imported in runtime (components, Vue composables, pages, plugins, or server routes). :: diff --git a/docs/2.guide/3.going-further/7.layers.md b/docs/2.guide/3.going-further/7.layers.md index 60cc54e3dc..3285135cfd 100644 --- a/docs/2.guide/3.going-further/7.layers.md +++ b/docs/2.guide/3.going-further/7.layers.md @@ -96,11 +96,15 @@ export default defineNuxtConfig({ }) ``` -::callout +::tip If you want to extend a private remote source, you need to add the environment variable `GIGET_AUTH=` to provide a token. :: -::callout{color="blue" icon="i-ph-info-duotone"} +::tip +If you want to extend a remote source from a self-hosted GitHub or GitLab instance, you need to supply its URL with the `GIGET_GITHUB_URL=` or `GIGET_GITLAB_URL=` environment variable - or directly configure it with [the `auth` option](https://github.com/unjs/c12#extending-config-layer-from-remote-sources) in your `nuxt.config`. +:: + +::note When using git remote sources, if a layer has npm dependencies and you wish to install them, you can do so by specifying `install: true` in your layer options. ```ts [nuxt.config.ts] @@ -144,13 +148,13 @@ To publish a layer directory as an npm package, you want to make sure that the ` } ``` -::callout +::important Make sure any dependency imported in the layer is **explicitly added** to the `dependencies`. The `nuxt` dependency, and anything only used for testing the layer before publishing, should remain in the `devDependencies` field. :: Now you can proceed to publish the module to npm, either publicly or privately. -::callout{color="amber" icon="i-ph-warning-duotone"} +::important When publishing the layer as a private npm package, you need to make sure you log in, to authenticate with npm to download the node module. :: diff --git a/docs/2.guide/3.going-further/9.debugging.md b/docs/2.guide/3.going-further/9.debugging.md index 0fcbeef886..59558ba67d 100644 --- a/docs/2.guide/3.going-further/9.debugging.md +++ b/docs/2.guide/3.going-further/9.debugging.md @@ -26,7 +26,7 @@ nuxi dev --inspect ``` This will start Nuxt in `dev` mode with debugger active. If everything is working correctly a Node.js icon will appear on your Chrome DevTools and you can attach to the debugger. -::callout{color="amber" icon="i-ph-warning-duotone"} +::important Note that the Node.js and Chrome processes need to be run on the same platform. This doesn't work inside of Docker. :: @@ -38,6 +38,10 @@ It is possible to debug your Nuxt app in your IDE while you are developing it. You may need to update the config below with a path to your web browser. For more information, visit the [VS Code documentation about debug configuration](https://go.microsoft.com/fwlink/?linkid=830387). +::important +If you use `pnpm`, you will need to have `nuxi` installed as a devDependency for the configuration below to work. +:: + ```json5 { // Use IntelliSense to learn about possible attributes. diff --git a/docs/2.guide/3.going-further/_dir.yml b/docs/2.guide/3.going-further/_dir.yml index 20cbae3d01..80b2c5b728 100644 --- a/docs/2.guide/3.going-further/_dir.yml +++ b/docs/2.guide/3.going-further/_dir.yml @@ -1,3 +1,3 @@ title: Going Further titleTemplate: '%s Β· Nuxt Advanced' -icon: i-ph-star-duotone +icon: i-ph-star diff --git a/docs/2.guide/3.going-further/8.custom-routing.md b/docs/2.guide/4.recipes/1.custom-routing.md similarity index 86% rename from docs/2.guide/3.going-further/8.custom-routing.md rename to docs/2.guide/4.recipes/1.custom-routing.md index 20d8da1e6f..f9b191768a 100644 --- a/docs/2.guide/3.going-further/8.custom-routing.md +++ b/docs/2.guide/4.recipes/1.custom-routing.md @@ -9,26 +9,26 @@ In Nuxt 3, your routing is defined by the structure of your files inside the [pa ### Router Config -Using [router options](/docs/guide/going-further/custom-routing#router-options), you can optionally override or extend your routes using a function that accepts the scanned routes and returns customized routes. +Using [router options](/docs/guide/recipes/custom-routing#router-options), you can optionally override or extend your routes using a function that accepts the scanned routes and returns customized routes. If it returns `null` or `undefined`, Nuxt will fall back to the default routes (useful to modify input array). ```ts [app/router.options.ts] import type { RouterConfig } from '@nuxt/schema' -export default { +export default { // https://router.vuejs.org/api/interfaces/routeroptions.html#routes routes: (_routes) => [ { name: 'home', path: '/', - component: () => import('~/pages/home.vue').then(r => r.default || r) + component: () => import('~/pages/home.vue') } ], -} +} satisfies RouterConfig ``` -::callout +::note Nuxt will not augment any new routes you return from the `routes` function with metadata defined in `definePageMeta` of the component you provide. If you want that to happen, you should use the `pages:extend` hook which is [called at build-time](/docs/api/advanced/hooks#nuxt-hooks-build-time). :: @@ -39,6 +39,8 @@ You can add, change or remove pages from the scanned routes with the `pages:exte For example, to prevent creating routes for any `.ts` files: ```ts [nuxt.config.ts] +import type { NuxtPage } from '@nuxt/schema' + export default defineNuxtConfig({ hooks: { 'pages:extend' (pages) { @@ -51,9 +53,9 @@ export default defineNuxtConfig({ // remove routes function removePagesMatching (pattern: RegExp, pages: NuxtPage[] = []) { - const pagesToRemove = [] + const pagesToRemove: NuxtPage[] = [] for (const page of pages) { - if (pattern.test(page.file)) { + if (page.file && pattern.test(page.file)) { pagesToRemove.push(page) } else { removePagesMatching(pattern, page.children) @@ -85,11 +87,11 @@ On top of customizing options for [`vue-router`](https://router.vuejs.org/api/in This is the recommended way to specify [router options](/docs/api/nuxt-config#router). -```js [app/router.options.ts] +```ts [app/router.options.ts] import type { RouterConfig } from '@nuxt/schema' -export default { -} +export default { +} satisfies RouterConfig ``` It is possible to add more router options files by adding files within the `pages:routerOptions` hook. Later items in the array override earlier ones. @@ -99,13 +101,15 @@ Adding a router options file in this hook will switch on page-based routing, unl :: ```ts [nuxt.config.ts] +import { createResolver } from '@nuxt/kit' + export default defineNuxtConfig({ hooks: { 'pages:routerOptions' ({ files }) { const resolver = createResolver(import.meta.url) // add a route files.push({ - path: resolver.resolve('./runtime/app/router-options') + path: resolver.resolve('./runtime/app/router-options'), optional: true }) } @@ -151,7 +155,7 @@ export default defineNuxtConfig({ ### Scroll Behavior for hash links You can optionally customize the scroll behavior for hash links. When you set the [config](/docs/api/nuxt-config#router) to be `smooth` and you load a page with a hash link (e.g. `https://example.com/blog/my-article#comments`), you will see that the browser smoothly scrolls to this anchor. - + ```ts [nuxt.config.ts] export default defineNuxtConfig({ router: { @@ -166,12 +170,12 @@ export default defineNuxtConfig({ You can optionally override history mode using a function that accepts the base URL and returns the history mode. If it returns `null` or `undefined`, Nuxt will fallback to the default history. -```js [app/router.options.ts] +```ts [app/router.options.ts] import type { RouterConfig } from '@nuxt/schema' import { createMemoryHistory } from 'vue-router' -export default { +export default { // https://router.vuejs.org/api/interfaces/routeroptions.html - history: base => process.client ? createMemoryHistory(base) : null /* default */ -} + history: base => import.meta.client ? createMemoryHistory(base) : null /* default */ +} satisfies RouterConfig ``` diff --git a/docs/2.guide/4.recipes/2.vite-plugin.md b/docs/2.guide/4.recipes/2.vite-plugin.md new file mode 100644 index 0000000000..c74efc7610 --- /dev/null +++ b/docs/2.guide/4.recipes/2.vite-plugin.md @@ -0,0 +1,65 @@ +--- +navigation.title: 'Vite Plugins' +title: Using Vite Plugins in Nuxt +description: Learn how to integrate Vite plugins into your Nuxt project. +--- + +While Nuxt modules offer extensive functionality, sometimes a specific Vite plugin might meet your needs more directly. + +First, we need to install the Vite plugin, for our example, we'll use `@rollup/plugin-yaml`: + +::package-managers + + ```bash [npm] + npm install @rollup/plugin-yaml + ``` + + ```bash [yarn] + yarn add @rollup/plugin-yaml + ``` + + ```bash [pnpm] + pnpm add @rollup/plugin-yaml + ``` + + ```bash [bun] + bun add @rollup/plugin-yaml + ``` + +:: + +Next, we need to import and add it to our [`nuxt.config.ts`](/docs/guide/directory-structure/nuxt-config) file: + +```ts [nuxt.config.ts] +import yaml from '@rollup/plugin-yaml' + +export default defineNuxtConfig({ + vite: { + plugins: [ + yaml() + ] + } +}) +``` + +Now we installed and configured our Vite plugin, we can start using YAML files directly in our project. + +For example, we can have a `config.yaml` that stores configuration data and import this data in our Nuxt components: + +::code-group + +```yaml [data/hello.yaml] +greeting: "Hello, Nuxt with Vite!" +``` + +```vue [components/Hello.vue] + + + +``` + +:: diff --git a/docs/2.guide/4.recipes/3.custom-usefetch.md b/docs/2.guide/4.recipes/3.custom-usefetch.md new file mode 100644 index 0000000000..a0ac6d7e29 --- /dev/null +++ b/docs/2.guide/4.recipes/3.custom-usefetch.md @@ -0,0 +1,125 @@ +--- +navigation.title: 'Custom useFetch' +title: Custom useFetch in Nuxt +description: How to create a custom fetcher for calling your external API in Nuxt 3. +--- + +When working with Nuxt, you might be making the frontend and fetching an external API, and you might want to set some default options for fetching from your API. + +The [`$fetch`](/docs/api/utils/dollarfetch) utility function (used by the [`useFetch`](/docs/api/composables/use-fetch) composable) is intentionally not globally configurable. This is important so that fetching behavior throughout your application remains consistent, and other integrations (like modules) can rely on the behavior of core utilities like `$fetch`. + +However, Nuxt provides a way to create a custom fetcher for your API (or multiple fetchers if you have multiple APIs to call). + +## Custom `$fetch` + +Let's create a custom `$fetch` instance with a [Nuxt plugin](/docs/guide/directory-structure/plugins). + +::note +`$fetch` is a configured instance of [ofetch](https://github.com/unjs/ofetch) which supports adding the base URL of your Nuxt server as well as direct function calls during SSR (avoiding HTTP roundtrips). +:: + +Let's pretend here that: +- The main API is https://api.nuxt.com +- We are storing the JWT token in a session with [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) +- If the API responds with a `401` status code, we redirect the user to the `/login` page + +```ts [plugins/api.ts] +export default defineNuxtPlugin((nuxtApp) => { + const { session } = useUserSession() + + const api = $fetch.create({ + baseURL: 'https://api.nuxt.com', + onRequest({ request, options, error }) { + if (session.value?.token) { + // note that this relies on ofetch >= 1.4.0 - you may need to refresh your lockfile + options.headers.set('Authorization', `Bearer ${session.value?.token}`) + } + }, + async onResponseError({ response }) { + if (response.status === 401) { + await nuxtApp.runWithContext(() => navigateTo('/login')) + } + } + }) + + // Expose to useNuxtApp().$api + return { + provide: { + api + } + } +}) +``` + +With this Nuxt plugin, `$api` is exposed from `useNuxtApp()` to make API calls directly from the Vue components: + +```vue [app.vue] + +``` + +::callout +Wrapping with [`useAsyncData`](/docs/api/composables/use-async-data) **avoid double data fetching when doing server-side rendering** (server & client on hydration). +:: + +## Custom `useFetch`/`useAsyncData` + +Now that `$api` has the logic we want, let's create a `useAPI` composable to replace the usage of `useAsyncData` + `$api`: + +```ts [composables/useAPI.ts] +import type { UseFetchOptions } from 'nuxt/app' + +export function useAPI( + url: string | (() => string), + options?: UseFetchOptions, +) { + return useFetch(url, { + ...options, + $fetch: useNuxtApp().$api + }) +} +``` + +Let's use the new composable and have a nice and clean component: + +```vue [app.vue] + +``` + +If you want to customize the type of any error returned, you can also do so: + +```ts +import type { FetchError } from 'ofetch' +import type { UseFetchOptions } from 'nuxt/app' + +interface CustomError { + message: string + statusCode: number +} + +export function useAPI( + url: string | (() => string), + options?: UseFetchOptions, +) { + return useFetch>(url, { + ...options, + $fetch: useNuxtApp().$api + }) +} +``` + +::note +This example demonstrates how to use a custom `useFetch`, but the same structure is identical for a custom `useAsyncData`. +:: + +::callout{icon="i-simple-icons-youtube" color="red" to="https://www.youtube.com/watch?v=jXH8Tr-exhI"} +Watch a video about custom `$fetch` and Repository Pattern in Nuxt. +:: + +::note +We are currently discussing to find a cleaner way to let you create a custom fetcher, see https://github.com/nuxt/nuxt/issues/14736. +:: diff --git a/docs/2.guide/4.recipes/_dir.yml b/docs/2.guide/4.recipes/_dir.yml new file mode 100644 index 0000000000..5030f4b88d --- /dev/null +++ b/docs/2.guide/4.recipes/_dir.yml @@ -0,0 +1,3 @@ +title: Recipes +titleTemplate: '%s Β· Recipes' +icon: i-ph-cooking-pot diff --git a/docs/2.guide/_dir.yml b/docs/2.guide/_dir.yml index 39506eabf0..9fb4817fc8 100644 --- a/docs/2.guide/_dir.yml +++ b/docs/2.guide/_dir.yml @@ -1,2 +1,2 @@ title: 'Guide' -icon: i-ph-book-open-duotone +icon: i-ph-book-open diff --git a/docs/3.api/1.components/1.client-only.md b/docs/3.api/1.components/1.client-only.md index 481ab8ab9c..86e56d2547 100644 --- a/docs/3.api/1.components/1.client-only.md +++ b/docs/3.api/1.components/1.client-only.md @@ -8,7 +8,11 @@ links: size: xs --- -The `` component renders its slot only in client-side. To import a component only on the client, register the component in a client-side only plugin. +The `` component is used for purposely rendering a component only on client side. + +::note +The content of the default slot will be tree-shaken out of the server build. (This does mean that any CSS used by components within it may not be inlined when rendering the initial HTML.) +:: ## Props @@ -19,6 +23,7 @@ The `` component renders its slot only in client-side. To import a c ``` -::callout +::note Please note the layout name is normalized to kebab-case, so if your layout file is named `errorLayout.vue`, it will become `error-layout` when passed as a `name` property to ``. :: @@ -55,6 +55,10 @@ Please note the layout name is normalized to kebab-case, so if your layout file Read more about dynamic layouts. :: +- `fallback`: If an invalid layout is passed to the `name` prop, no layout will be rendered. Specify a `fallback` layout to be rendered in this scenario. It **must** match the name of the corresponding layout file in the [`layouts/`](/docs/guide/directory-structure/layouts) directory. + - **type**: `string` + - **default**: `null` + ## Additional Props `NuxtLayout` also accepts any additional props that you may need to pass to the layout. These custom props are then made accessible as attributes. @@ -83,6 +87,8 @@ console.log(layoutCustomProps.title) // I am a custom layout `` renders incoming content via ``, which is then wrapped around Vue’s `` component to activate layout transition. For this to work as expected, it is recommended that `` is **not** the root element of the page component. +::code-group + ```vue [pages/index.vue] ``` +```vue [layouts/custom.vue] + +``` + +:: + :read-more{to="/docs/getting-started/transitions"} ## Layout's Ref To get the ref of a layout component, access it through `ref.value.layoutRef`. -````vue [app.vue] +::code-group + +```vue [app.vue] -```` +``` + +```vue [layouts/default.vue] + + + +``` + +:: :read-more{to="/docs/guide/directory-structure/layouts"} diff --git a/docs/3.api/1.components/4.nuxt-link.md b/docs/3.api/1.components/4.nuxt-link.md index 287d8d94a4..d21b798345 100644 --- a/docs/3.api/1.components/4.nuxt-link.md +++ b/docs/3.api/1.components/4.nuxt-link.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note `` is a drop-in replacement for both Vue Router's `` component and HTML's `
` tag. It intelligently determines whether the link is _internal_ or _external_ and renders it accordingly with available optimizations (prefetching, default attributes, etc.) :: @@ -25,6 +25,22 @@ In this example, we use `` component to link to another page of the ap ``` +### Passing Params to Dynamic Routes + +In this example, we pass the `id` param to link to the route `~/pages/posts/[id].vue`. + +```vue [pages/index.vue] + +``` + +::tip +Check out the Pages panel in Nuxt DevTools to see the route name and the params it might take. +:: + ### Handling 404s When using `` for `/public` directory files or when pointing to a different app on the same domain, you should use the `external` prop. @@ -95,7 +111,7 @@ When you need to overwrite this behavior you can use the `rel` and `noRel` props When not using `external`, `` supports all Vue Router's [`RouterLink` props](https://router.vuejs.org/api/interfaces/RouterLinkProps.html) -- `to`: Any URL or a [route location object](https://router.vuejs.org/api/interfaces/RouteLocation.html) from Vue Router +- `to`: Any URL or a [route location object](https://router.vuejs.org/api/#RouteLocation) from Vue Router - `custom`: Whether `` should wrap its content in an `` element. It allows taking full control of how a link is rendered and how navigation works when it is clicked. Works the same as [Vue Router's `custom` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-custom) - `exactActiveClass`: A class to apply on exact active links. Works the same as [Vue Router's `exact-active-class` prop](https://router.vuejs.org/api/interfaces/RouterLinkProps.html#Properties-exactActiveClass) on internal links. Defaults to Vue Router's default `"router-link-exact-active"`) - `replace`: Works the same as [Vue Router's `replace` prop](https://router.vuejs.org/api/interfaces/RouteLocationOptions.html#Properties-replace) on internal links @@ -108,6 +124,7 @@ When not using `external`, `` supports all Vue Router's [`RouterLink` - `noRel`: If set to `true`, no `rel` attribute will be added to the link - `external`: Forces the link to be rendered as an `a` tag instead of a Vue Router `RouterLink`. - `prefetch`: When enabled will prefetch middleware, layouts and payloads (when using [payloadExtraction](/docs/api/nuxt-config#crossoriginprefetch)) of links in the viewport. Used by the experimental [crossOriginPrefetch](/docs/api/nuxt-config#crossoriginprefetch) config. +- `prefetchOn`: Allows custom control of when to prefetch links. Possible options are `interaction` and `visibility` (default). You can also pass an object for full control, for example: `{ interaction: true, visibility: true }`. This prop is only used when `prefetch` is enabled (default) and `noPrefetch` is not set. - `noPrefetch`: Disables prefetching. - `prefetchedClass`: A class to apply to links that have been prefetched. @@ -116,7 +133,7 @@ When not using `external`, `` supports all Vue Router's [`RouterLink` - `target`: A `target` attribute value to apply on the link - `rel`: A `rel` attribute value to apply on the link. Defaults to `"noopener noreferrer"` for external links. -::callout +::tip Defaults can be overwritten, see [overwriting defaults](#overwriting-defaults) if you want to change them. :: @@ -124,9 +141,9 @@ Defaults can be overwritten, see [overwriting defaults](#overwriting-defaults) i ### In Nuxt Config -You can overwrite some `` defaults in your [`nuxt.config`](https://nuxt.com/docs/api/nuxt-config#defaults) +You can overwrite some `` defaults in your [`nuxt.config`](/docs/api/nuxt-config#defaults) -::callout{color="amber" icon="i-ph-warning-duotone"} +::important These options will likely be moved elsewhere in the future, such as into `app.config` or into the `app/` directory. :: @@ -169,8 +186,13 @@ interface NuxtLinkOptions { externalRelAttribute?: string; activeClass?: string; exactActiveClass?: string; - prefetchedClass?: string; trailingSlash?: 'append' | 'remove' + prefetch?: boolean + prefetchedClass?: string + prefetchOn?: Partial<{ + visibility: boolean + interaction: boolean + }> } function defineNuxtLink(options: NuxtLinkOptions): Component {} ``` @@ -179,7 +201,9 @@ function defineNuxtLink(options: NuxtLinkOptions): Component {} - `externalRelAttribute`: A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable - `activeClass`: A default class to apply on active links. Works the same as [Vue Router's `linkActiveClass` option](https://router.vuejs.org/api/interfaces/RouterOptions.html#Properties-linkActiveClass). Defaults to Vue Router's default (`"router-link-active"`) - `exactActiveClass`: A default class to apply on exact active links. Works the same as [Vue Router's `linkExactActiveClass` option](https://router.vuejs.org/api/interfaces/RouterOptions.html#Properties-linkExactActiveClass). Defaults to Vue Router's default (`"router-link-exact-active"`) -- `prefetchedClass`: A default class to apply to links that have been prefetched. - `trailingSlash`: An option to either add or remove trailing slashes in the `href`. If unset or not matching the valid values `append` or `remove`, it will be ignored. +- `prefetch`: Whether or not to prefetch links by default. +- `prefetchOn`: Granular control of which prefetch strategies to apply by default. +- `prefetchedClass`: A default class to apply to links that have been prefetched. :link-example{to="/docs/examples/routing/pages"} diff --git a/docs/3.api/1.components/5.nuxt-loading-indicator.md b/docs/3.api/1.components/5.nuxt-loading-indicator.md index 016070181c..ad2f6936cb 100644 --- a/docs/3.api/1.components/5.nuxt-loading-indicator.md +++ b/docs/3.api/1.components/5.nuxt-loading-indicator.md @@ -30,20 +30,21 @@ You can pass custom HTML or components through the loading indicator's default s ## Props - `color`: The color of the loading bar. It can be set to `false` to turn off explicit color styling. +- `errorColor`: The color of the loading bar when `error` is set to `true`. - `height`: Height of the loading bar, in pixels (default `3`). - `duration`: Duration of the loading bar, in milliseconds (default `2000`). - `throttle`: Throttle the appearing and hiding, in milliseconds (default `200`). - `estimatedProgress`: By default Nuxt will back off as it approaches 100%. You can provide a custom function to customize the progress estimation, which is a function that receives the duration of the loading bar (above) and the elapsed time. It should return a value between 0 and 100. -::callout +::note This component is optional. :br To achieve full customization, you can implement your own one based on [its source code](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/components/nuxt-loading-indicator.ts). :: -::callout +::note You can hook into the underlying indicator instance using [the `useLoadingIndicator` composable](/docs/api/composables/use-loading-indicator), which will allow you to trigger start/finish events yourself. :: -::callout +::tip The loading indicator's speed gradually decreases after reaching a specific point controlled by `estimatedProgress`. This adjustment provides a more accurate reflection of longer page loading times and prevents the indicator from prematurely showing 100% completion. :: diff --git a/docs/3.api/1.components/6.nuxt-error-boundary.md b/docs/3.api/1.components/6.nuxt-error-boundary.md index 2e9efe3c82..35094f4ac5 100644 --- a/docs/3.api/1.components/6.nuxt-error-boundary.md +++ b/docs/3.api/1.components/6.nuxt-error-boundary.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::tip The `` uses Vue's [`onErrorCaptured`](https://vuejs.org/api/composition-api-lifecycle.html#onerrorcaptured) hook under the hood. :: diff --git a/docs/3.api/1.components/7.nuxt-welcome.md b/docs/3.api/1.components/7.nuxt-welcome.md index 54dc41305a..f68051f170 100644 --- a/docs/3.api/1.components/7.nuxt-welcome.md +++ b/docs/3.api/1.components/7.nuxt-welcome.md @@ -20,6 +20,6 @@ It includes links to the Nuxt documentation, source code, and social media accou Preview the `` component. :: -::callout +::tip This component is part of [nuxt/assets](https://github.com/nuxt/assets). :: diff --git a/docs/3.api/1.components/8.nuxt-island.md b/docs/3.api/1.components/8.nuxt-island.md index 40c2ccfcba..cd715534f2 100644 --- a/docs/3.api/1.components/8.nuxt-island.md +++ b/docs/3.api/1.components/8.nuxt-island.md @@ -12,15 +12,11 @@ When rendering an island component, the content of the island component is stati Changing the island component props triggers a refetch of the island component to re-render it again. -::read-more{to="/docs/guide/going-further/experimental-features#componentislands" icon="i-ph-star-duotone"} -This component is experimental and in order to use it you must enable the `experimental.componentIslands` option in your `nuxt.config`. -:: - -::callout +::note Global styles of your application are sent with the response. :: -::callout +::tip Server only components use `` under the hood :: @@ -40,7 +36,7 @@ Server only components use `` under the hood - **type**: `boolean` - **default**: `false` -::callout{color="blue" icon="i-ph-info-duotone"} +::note Remote islands need `experimental.componentIslands` to be `'local+remote'` in your `nuxt.config`. It is strongly discouraged to enable `dangerouslyLoadClientComponents` as you can't trust a remote server's javascript. :: @@ -60,3 +56,11 @@ Some slots are reserved to `NuxtIsland` for special cases. - `refresh()` - **type**: `() => Promise` - **description**: force refetch the server component by refetching it. + +## Events + +- `error` + - **parameters**: + - **error**: + - **type**: `unknown` + - **description**: emitted when when `NuxtIsland` fails to fetch the new island. diff --git a/docs/3.api/1.components/9.nuxt-img.md b/docs/3.api/1.components/9.nuxt-img.md index 8585db81f3..4b96442463 100644 --- a/docs/3.api/1.components/9.nuxt-img.md +++ b/docs/3.api/1.components/9.nuxt-img.md @@ -4,7 +4,7 @@ description: "Nuxt provides a component to handle automatic image opti links: - label: Source icon: i-simple-icons-github - to: https://github.com/nuxt/image/blob/main/src/runtime/components/nuxt-img.ts + to: https://github.com/nuxt/image/blob/main/src/runtime/components/NuxtImg.vue size: xs --- diff --git a/docs/3.api/1.components/_dir.yml b/docs/3.api/1.components/_dir.yml index d78fe4060a..33401303cf 100644 --- a/docs/3.api/1.components/_dir.yml +++ b/docs/3.api/1.components/_dir.yml @@ -1,3 +1,3 @@ title: 'Components' titleTemplate: '%s Β· Nuxt Components' -icon: i-ph-cube-duotone +icon: i-ph-cube diff --git a/docs/3.api/2.composables/_dir.yml b/docs/3.api/2.composables/_dir.yml index 35d41bbd10..e33d9ed036 100644 --- a/docs/3.api/2.composables/_dir.yml +++ b/docs/3.api/2.composables/_dir.yml @@ -1,3 +1,3 @@ title: 'Composables' titleTemplate: '%s Β· Nuxt Composables' -icon: i-ph-arrows-left-right-duotone +icon: i-ph-arrows-left-right diff --git a/docs/3.api/2.composables/on-prehydrate.md b/docs/3.api/2.composables/on-prehydrate.md new file mode 100644 index 0000000000..1f034d6cec --- /dev/null +++ b/docs/3.api/2.composables/on-prehydrate.md @@ -0,0 +1,61 @@ +--- +title: "onPrehydrate" +description: "Use onPrehydrate to run a callback on the client immediately before +Nuxt hydrates the page." +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/ssr.ts + size: xs +--- + +::important +This composable is available in Nuxt v3.12+. +:: + +`onPrehydrate` is a composable lifecycle hook that allows you to run a callback on the client immediately before +Nuxt hydrates the page. + +::note +This is an advanced utility and should be used with care. For example, [`nuxt-time`](https://github.com/danielroe/nuxt-time/pull/251) and [`@nuxtjs/color-mode`](https://github.com/nuxt-modules/color-mode/blob/main/src/script.js) manipulate the DOM to avoid hydration mismatches. +:: + +## Usage + +`onPrehydrate` can be called directly in the setup function of a Vue component (for example, in ` + + +``` diff --git a/docs/3.api/2.composables/use-async-data.md b/docs/3.api/2.composables/use-async-data.md index 4ec2505dd6..7619dd1eb7 100644 --- a/docs/3.api/2.composables/use-async-data.md +++ b/docs/3.api/2.composables/use-async-data.md @@ -1,6 +1,6 @@ --- title: 'useAsyncData' -description: useAsyncData provides access to data that resolves asynchronously in a SSR-friendly composable. +description: useAsyncData provides access to data that resolves asynchronously in an SSR-friendly composable. links: - label: Source icon: i-simple-icons-github @@ -10,7 +10,7 @@ links: Within your pages, components, and plugins you can use useAsyncData to get access to data that resolves asynchronously. -::callout +::note [`useAsyncData`](/docs/api/composables/use-async-data) is a composable meant to be called directly in the [Nuxt context](/docs/guide/going-further/nuxt-app#the-nuxt-context). It returns reactive composables and handles adding responses to the Nuxt payload so they can be passed from server to client **without re-fetching the data on client side** when the page hydrates. :: @@ -18,15 +18,19 @@ Within your pages, components, and plugins you can use useAsyncData to get acces ```vue [pages/index.vue] ``` -::callout -`data`, `status` and `error` are Vue refs and they should be accessed with `.value` when used within the ` ``` -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning [`useAsyncData`](/docs/api/composables/use-async-data) is a reserved function name transformed by the compiler, so you should not name your own function [`useAsyncData`](/docs/api/composables/use-async-data) . :: @@ -68,12 +72,12 @@ const { data: posts } = await useAsyncData( - `getCachedData`: Provide a function which returns cached data. A _null_ or _undefined_ return value will trigger a fetch. By default, this is: `key => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]`, which only caches data when `payloadExtraction` is enabled. - `pick`: only pick specified keys in this array from the `handler` function result - `watch`: watch reactive sources to auto-refresh - - `deep`: return data in a deep ref object (it is `true` by default). It can be set to `false` to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. + - `deep`: return data in a deep ref object. It is `false` by default to return data in a shallow ref object for performance. - `dedupe`: avoid fetching same key more than once at a time (defaults to `cancel`). Possible options: - `cancel` - cancels existing requests when a new one is made - `defer` - does not make new requests at all if there is a pending request -::callout +::note Under the hood, `lazy: false` uses `` to block the loading of the route before the data has been fetched. Consider using `lazy: true` and implementing a loading state instead for a snappier user experience. :: @@ -81,7 +85,7 @@ Under the hood, `lazy: false` uses `` to block the loading of the rout You can use `useLazyAsyncData` to have the same behavior as `lazy: true` with `useAsyncData`. :: -::callout{icon="i-simple-icons-youtube" color="gray" to="https://www.youtube.com/watch?v=aQPR0xn-MMk" target="_blank"} +::tip{icon="i-simple-icons-youtube" color="gray" to="https://www.youtube.com/watch?v=aQPR0xn-MMk" target="_blank"} Learn how to use `transform` and `getCachedData` to avoid superfluous calls to an API and cache data for visitors on the client. :: @@ -91,10 +95,11 @@ Learn how to use `transform` and `getCachedData` to avoid superfluous calls to a - `refresh`/`execute`: a function that can be used to refresh the data returned by the `handler` function. - `error`: an error object if the data fetching failed. - `status`: a string indicating the status of the data request (`"idle"`, `"pending"`, `"success"`, `"error"`). +- `clear`: a function which will set `data` to `undefined`, set `error` to `null`, set `status` to `'idle'`, and mark any currently pending requests as cancelled. By default, Nuxt waits until a `refresh` is finished before it can be executed again. -::callout +::note If you have not fetched data on the server (for example, with `server: false`), then the data _will not_ be fetched until hydration completes. This means even if you await [`useAsyncData`](/docs/api/composables/use-async-data) on the client side, `data` will remain `null` within ` ``` -::callout -`data`, `status` and `error` are Vue refs and they should be accessed with `.value` when used within the ` + + +``` + +Now you can generate your site and serve it: + +```bash [Terminal] +npx nuxi generate +npx nuxi preview +``` + +Then you can see your preview page by adding the query param `preview` to the end of the page you want to see once: + +```js +?preview=true +``` + +::note +`usePreviewMode` should be tested locally with `nuxi generate` and then `nuxi preview` rather than `nuxi dev`. (The [preview command](/docs/api/commands/preview) is not related to preview mode.) +:: diff --git a/docs/3.api/2.composables/use-request-event.md b/docs/3.api/2.composables/use-request-event.md index bde3460a05..4f9eefbe90 100644 --- a/docs/3.api/2.composables/use-request-event.md +++ b/docs/3.api/2.composables/use-request-event.md @@ -18,6 +18,6 @@ const event = useRequestEvent() const url = event?.path ``` -::callout +::tip In the browser, `useRequestEvent` will return `undefined`. :: diff --git a/docs/3.api/2.composables/use-request-fetch.md b/docs/3.api/2.composables/use-request-fetch.md new file mode 100644 index 0000000000..dac922e393 --- /dev/null +++ b/docs/3.api/2.composables/use-request-fetch.md @@ -0,0 +1,52 @@ +--- +title: 'useRequestFetch' +description: 'Forward the request context and headers for server-side fetch requests with the useRequestFetch composable.' +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/ssr.ts + size: xs +--- + +You can use `useRequestFetch` to forward the request context and headers when making server-side fetch requests. + +When making a client-side fetch request, the browser automatically sends the necessary headers. +However, when making a request during server-side rendering, because the request is made on the server, we need to forward the headers manually. + +::note +Headers that are **not meant to be forwarded** will **not be included** in the request. These headers include, for example: +`transfer-encoding`, `connection`, `keep-alive`, `upgrade`, `expect`, `host`, `accept` +:: + +::tip +The [`useFetch`](/docs/api/composables/use-fetch) composable uses `useRequestFetch` under the hood to automatically forward the request context and headers. +:: + +::code-group + +```vue [pages/index.vue] + +``` + +```ts [server/api/cookies.ts] +export default defineEventHandler((event) => { + const cookies = parseCookies(event) + + return { cookies } +}) +``` + +:: + +::tip +In the browser during client-side navigation, `useRequestFetch` will behave just like regular [`$fetch`](/docs/api/utils/dollarfetch). +:: diff --git a/docs/3.api/2.composables/use-request-header.md b/docs/3.api/2.composables/use-request-header.md index d2e21ecb4e..43c99fb9a2 100644 --- a/docs/3.api/2.composables/use-request-header.md +++ b/docs/3.api/2.composables/use-request-header.md @@ -15,7 +15,7 @@ You can use the built-in [`useRequestHeader`](/docs/api/composables/use-request- const authorization = useRequestHeader('authorization') ``` -::callout +::tip In the browser, `useRequestHeader` will return `undefined`. :: diff --git a/docs/3.api/2.composables/use-request-headers.md b/docs/3.api/2.composables/use-request-headers.md index 76434bddb0..a54247bf89 100644 --- a/docs/3.api/2.composables/use-request-headers.md +++ b/docs/3.api/2.composables/use-request-headers.md @@ -18,7 +18,7 @@ const headers = useRequestHeaders() const headers = useRequestHeaders(['cookie']) ``` -::callout +::tip In the browser, `useRequestHeaders` will return an empty object. :: diff --git a/docs/3.api/2.composables/use-request-url.md b/docs/3.api/2.composables/use-request-url.md index 29a0486bd1..1ed09bb66b 100644 --- a/docs/3.api/2.composables/use-request-url.md +++ b/docs/3.api/2.composables/use-request-url.md @@ -10,6 +10,12 @@ links: `useRequestURL` is a helper function that returns an [URL object](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) working on both server-side and client-side. +::important +When utilizing [Hybrid Rendering](/docs/guide/concepts/rendering#hybrid-rendering) with cache strategies, all incoming request headers are dropped when handling the cached responses via the [Nitro caching layer](https://nitro.unjs.io/guide/cache) (meaning `useRequestURL` will return `localhost` for the `host`). + +You can define the [`cache.varies` option](https://nitro.unjs.io/guide/cache#options) to specify headers that will be considered when caching and serving the responses, such as `host` and `x-forwarded-host` for multi-tenant environments. +:: + ::code-group ```vue [pages/about.vue] @@ -30,6 +36,6 @@ const url = useRequestURL() :: -::callout{icon="i-simple-icons-mdnwebdocs" color="gray" to="https://developer.mozilla.org/en-US/docs/Web/API/URL#instance_properties" target="_blank"} +::tip{icon="i-simple-icons-mdnwebdocs" color="gray" to="https://developer.mozilla.org/en-US/docs/Web/API/URL#instance_properties" target="_blank"} Read about the URL instance properties on the MDN documentation. :: diff --git a/docs/3.api/2.composables/use-route-announcer.md b/docs/3.api/2.composables/use-route-announcer.md new file mode 100644 index 0000000000..cdaa4408d9 --- /dev/null +++ b/docs/3.api/2.composables/use-route-announcer.md @@ -0,0 +1,60 @@ +--- +title: 'useRouteAnnouncer' +description: This composable observes the page title changes and updates the announcer message accordingly. +navigation: + badge: New +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/route-announcer.ts + size: xs +--- + +::important +This composable is available in Nuxt v3.12+. +:: + +## Description + +A composable which observes the page title changes and updates the announcer message accordingly. Used by [``](/docs/api/components/nuxt-route-announcer) and controllable. +It hooks into Unhead's [`dom:rendered`](https://unhead.unjs.io/api/core/hooks#dom-hooks) to read the page's title and set it as the announcer message. + +## Parameters + +- `politeness`: Sets the urgency for screen reader announcements: `off` (disable the announcement), `polite` (waits for silence), or `assertive` (interrupts immediately). (default `polite`). + +## Properties + +### `message` + +- **type**: `Ref` +- **description**: The message to announce + +### `politeness` + +- **type**: `Ref` +- **description**: Screen reader announcement urgency level `off`, `polite`, or `assertive` + +## Methods + +### `set(message, politeness = "polite")` + +Sets the message to announce with its urgency level. + +### `polite(message)` + +Sets the message with `politeness = "polite"` + +### `assertive(message)` + +Sets the message with `politeness = "assertive"` + +## Example + +```vue [pages/index.vue] + +``` diff --git a/docs/3.api/2.composables/use-route.md b/docs/3.api/2.composables/use-route.md index 0e28000736..cbaabb176c 100644 --- a/docs/3.api/2.composables/use-route.md +++ b/docs/3.api/2.composables/use-route.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note Within the template of a Vue component, you can access the route using `$route`. :: @@ -38,14 +38,15 @@ Apart from dynamic parameters and query parameters, `useRoute()` also provides t - `fullPath`: encoded URL associated with the current route that contains path, query and hash - `hash`: decoded hash section of the URL that starts with a # +- `query`: access route query parameters - `matched`: array of normalized matched routes with current route location - `meta`: custom data attached to the record - `name`: unique name for the route record - `path`: encoded pathname section of the URL - `redirectedFrom`: route location that was attempted to access before ending up on the current route location -::callout +::note Browsers don't send [URL fragments](https://url.spec.whatwg.org/#concept-url-fragment) (for example `#foo`) when making requests. So using `route.fullPath` in your template can trigger hydration issues because this will include the fragment on client but not the server. :: -:read-more{icon="i-simple-icons-vuedotjs" to="https://router.vuejs.org/api/interfaces/RouteLocationNormalizedLoaded.html"} +:read-more{icon="i-simple-icons-vuedotjs" to="https://router.vuejs.org/api/#RouteLocationNormalizedLoaded"} diff --git a/docs/3.api/2.composables/use-router.md b/docs/3.api/2.composables/use-router.md index 7cd065fc56..4cc7952d39 100644 --- a/docs/3.api/2.composables/use-router.md +++ b/docs/3.api/2.composables/use-router.md @@ -46,7 +46,7 @@ router.hasRoute('home') router.resolve({ name: 'home' }) ``` -::callout +::note `router.addRoute()` adds route details into an array of routes and it is useful while building [Nuxt plugins](/docs/guide/directory-structure/plugins) while `router.push()` on the other hand, triggers a new navigation immediately and it is useful in pages, Vue components and composable. :: diff --git a/docs/3.api/2.composables/use-runtime-config.md b/docs/3.api/2.composables/use-runtime-config.md index c1a97fe95c..91835423ba 100644 --- a/docs/3.api/2.composables/use-runtime-config.md +++ b/docs/3.api/2.composables/use-runtime-config.md @@ -4,7 +4,7 @@ description: 'Access runtime config variables with the useRuntimeConfig composab links: - label: Source icon: i-simple-icons-github - to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/asyncData.ts + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/nuxt.ts size: xs --- @@ -44,7 +44,7 @@ export default defineNuxtConfig({ }) ``` -::callout +::note Variables that need to be accessible on the server are added directly inside `runtimeConfig`. Variables that need to be accessible on both the client and the server are defined in `runtimeConfig.public`. :: @@ -87,11 +87,11 @@ NUXT_PUBLIC_API_BASE = "https://api.localhost:5555" NUXT_API_SECRET = "123" ``` -::callout +::note Any environment variables set within `.env` file are accessed using `process.env` in the Nuxt app during **development** and **build/generate**. :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning In **production runtime**, you should use platform environment variables and `.env` is not used. :: @@ -101,7 +101,7 @@ In **production runtime**, you should use platform environment variables and `.e Nuxt uses `app` namespace in runtime-config with keys including `baseURL` and `cdnURL`. You can customize their values at runtime by setting environment variables. -::callout +::note This is a reserved namespace. You should not introduce additional keys inside `app`. :: diff --git a/docs/3.api/2.composables/use-seo-meta.md b/docs/3.api/2.composables/use-seo-meta.md index 8bec86dbeb..e28debae32 100644 --- a/docs/3.api/2.composables/use-seo-meta.md +++ b/docs/3.api/2.composables/use-seo-meta.md @@ -10,7 +10,7 @@ links: This helps you avoid common mistakes, such as using `name` instead of `property`, as well as typos - with over 100+ meta tags fully typed. -::callout +::important This is the recommended way to add meta tags to your site as it is XSS safe and has full TypeScript support. :: @@ -46,6 +46,6 @@ useSeoMeta({ ## Parameters -There are over 100 parameters. See the [full list of parameters in the source code](https://github.com/harlan-zw/zhead/blob/main/src/metaFlat.ts). +There are over 100 parameters. See the [full list of parameters in the source code](https://github.com/harlan-zw/zhead/blob/main/packages/zhead/src/metaFlat.ts#L1035). :read-more{to="/docs/getting-started/seo-meta"} diff --git a/docs/3.api/2.composables/use-state.md b/docs/3.api/2.composables/use-state.md index d4be8390b5..c8194dc9ed 100644 --- a/docs/3.api/2.composables/use-state.md +++ b/docs/3.api/2.composables/use-state.md @@ -17,14 +17,18 @@ const count = useState('counter', () => Math.round(Math.random() * 100)) :read-more{to="/docs/getting-started/state-management"} -::callout +::important Because the data inside `useState` will be serialized to JSON, it is important that it does not contain anything that cannot be serialized, such as classes, functions or symbols. :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning `useState` is a reserved function name transformed by the compiler, so you should not name your own function `useState`. :: +::tip{icon="i-ph-video" to="https://www.youtube.com/watch?v=mv0WcBABcIk" target="_blank"} +Watch a video from Alexander Lichter about why and when to use `useState()`. +:: + ## Using `shallowRef` If you don't need your state to be deeply reactive, you can combine `useState` with [`shallowRef`](https://vuejs.org/api/reactivity-advanced.html#shallowref). This can improve performance when your state contains large objects and arrays. diff --git a/docs/3.api/3.utils/$fetch.md b/docs/3.api/3.utils/$fetch.md index 9f30e87285..ab8a947aad 100644 --- a/docs/3.api/3.utils/$fetch.md +++ b/docs/3.api/3.utils/$fetch.md @@ -10,11 +10,11 @@ links: Nuxt uses [ofetch](https://github.com/unjs/ofetch) to expose globally the `$fetch` helper for making HTTP requests within your Vue app or API routes. -::callout{icon="i-ph-rocket-launch-duotone"} +::tip{icon="i-ph-rocket-launch" color="gray"} During server-side rendering, calling `$fetch` to fetch your internal [API routes](/docs/guide/directory-structure/server) will directly call the relevant function (emulating the request), **saving an additional API call**. :: -::callout{color="blue" icon="i-ph-info-duotone"} +::note{color="blue" icon="i-ph-info"} Using `$fetch` in components without wrapping it with [`useAsyncData`](/docs/api/composables/use-async-data) causes fetching the data twice: initially on the server, then again on the client-side during hydration, because `$fetch` does not transfer state from the server to the client. Thus, the fetch will be executed on both sides because the client has to get the data again. :: @@ -52,6 +52,10 @@ function contactForm() { ``` -::callout +::tip `$fetch` is the preferred way to make HTTP calls in Nuxt instead of [@nuxt/http](https://github.com/nuxt/http) and [@nuxtjs/axios](https://github.com/nuxt-community/axios-module) that are made for Nuxt 2. :: + +::note +If you use `$fetch` to call an (external) HTTPS URL with a self-signed certificate in development, you will need to set `NODE_TLS_REJECT_UNAUTHORIZED=0` in your environment. +:: diff --git a/docs/3.api/3.utils/_dir.yml b/docs/3.api/3.utils/_dir.yml index 50d20caf25..c3ef54ea5c 100644 --- a/docs/3.api/3.utils/_dir.yml +++ b/docs/3.api/3.utils/_dir.yml @@ -1,3 +1,3 @@ title: 'Utils' titleTemplate: '%s Β· Nuxt Utils' -navigation.icon: i-ph-function-duotone +navigation.icon: i-ph-function diff --git a/docs/3.api/3.utils/abort-navigation.md b/docs/3.api/3.utils/abort-navigation.md index 669bc00b4b..fb51025a7d 100644 --- a/docs/3.api/3.utils/abort-navigation.md +++ b/docs/3.api/3.utils/abort-navigation.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning `abortNavigation` is only usable inside a [route middleware handler](/docs/guide/directory-structure/middleware). :: @@ -37,7 +37,7 @@ export default defineNuxtRouteMiddleware((to, from) => { if (!user.value.isAuthorized) { return abortNavigation() } - + if (to.path !== '/edit-post') { return navigateTo('/edit-post') } diff --git a/docs/3.api/3.utils/add-route-middleware.md b/docs/3.api/3.utils/add-route-middleware.md index 5ba1cc51ae..5db3ab7009 100644 --- a/docs/3.api/3.utils/add-route-middleware.md +++ b/docs/3.api/3.utils/add-route-middleware.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note Route middleware are navigation guards stored in the [`middleware/`](/docs/guide/directory-structure/middleware) directory of your Nuxt application (unless [set otherwise](/docs/api/nuxt-config#middleware)). :: @@ -36,7 +36,7 @@ The second argument is a function of type `RouteMiddleware`. Same as above, it p ### `options` -- **Type:** `AddRouteMiddlewareOptions` +- **Type:** `AddRouteMiddlewareOptions` An optional `options` argument lets you set the value of `global` to `true` to indicate whether the router middleware is global or not (set to `false` by default). diff --git a/docs/3.api/3.utils/call-once.md b/docs/3.api/3.utils/call-once.md index d776977219..f3f2b49bc3 100644 --- a/docs/3.api/3.utils/call-once.md +++ b/docs/3.api/3.utils/call-once.md @@ -10,7 +10,7 @@ links: size: xs --- -::callout{icon="i-ph-info-duotone" color="blue"} +::important This utility is available since [Nuxt v3.9](/blog/v3-9). :: @@ -35,17 +35,17 @@ await callOnce(async () => { ``` -::callout{to="/docs/getting-started/state-management#usage-with-pinia"} +::tip{to="/docs/getting-started/state-management#usage-with-pinia"} `callOnce` is useful in combination with the [Pinia module](/modules/pinia) to call store actions. :: :read-more{to="/docs/getting-started/state-management"} -::callout{color="info" icon="i-ph-warning-duotone"} +::warning Note that `callOnce` doesn't return anything. You should use [`useAsyncData`](/docs/api/composables/use-async-data) or [`useFetch`](/docs/api/composables/use-fetch) if you want to do data fetching during SSR. :: -::callout +::note `callOnce` is a composable meant to be called directly in a setup function, plugin, or route middleware, because it needs to add data to the Nuxt payload to avoid re-calling the function on the client when the page hydrates. :: diff --git a/docs/3.api/3.utils/clear-nuxt-data.md b/docs/3.api/3.utils/clear-nuxt-data.md index b1b7f7cb9b..70424b9760 100644 --- a/docs/3.api/3.utils/clear-nuxt-data.md +++ b/docs/3.api/3.utils/clear-nuxt-data.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note This method is useful if you want to invalidate the data fetching for another page. :: diff --git a/docs/3.api/3.utils/clear-nuxt-state.md b/docs/3.api/3.utils/clear-nuxt-state.md index 70ed999bf2..a2431c526c 100644 --- a/docs/3.api/3.utils/clear-nuxt-state.md +++ b/docs/3.api/3.utils/clear-nuxt-state.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note This method is useful if you want to invalidate the state of `useState`. :: diff --git a/docs/3.api/3.utils/create-error.md b/docs/3.api/3.utils/create-error.md index ecdc7e7386..4d12057e49 100644 --- a/docs/3.api/3.utils/create-error.md +++ b/docs/3.api/3.utils/create-error.md @@ -12,7 +12,9 @@ You can use this function to create an error object with additional metadata. It ## Parameters -- `err`: `{ cause, data, message, name, stack, statusCode, statusMessage, fatal }` +- `err`: `string | { cause, data, message, name, stack, statusCode, statusMessage, fatal }` + +You can pass either a string or an object to the `createError` function. If you pass a string, it will be used as the error `message`, and the `statusCode` will default to `500`. If you pass an object, you can set multiple properties of the error, such as `statusCode`, `message`, and other error properties. ## In Vue App @@ -39,7 +41,7 @@ Use `createError` to trigger error handling in server API routes. ### Example -```js +```ts [server/api/error.ts] export default eventHandler(() => { throw createError({ statusCode: 404, @@ -48,4 +50,6 @@ export default eventHandler(() => { }) ``` +In API routes, using `createError` by passing an object with a short `statusMessage` is recommended because it can be accessed on the client side. Otherwise, a `message` passed to `createError` on an API route will not propagate to the client. Alternatively, you can use the `data` property to pass data back to the client. In any case, always consider avoiding to put dynamic user input to the message to avoid potential security issues. + :read-more{to="/docs/getting-started/error-handling"} diff --git a/docs/3.api/3.utils/define-nuxt-component.md b/docs/3.api/3.utils/define-nuxt-component.md index 26e1d18320..7ee1073121 100644 --- a/docs/3.api/3.utils/define-nuxt-component.md +++ b/docs/3.api/3.utils/define-nuxt-component.md @@ -8,11 +8,11 @@ links: size: xs --- -::callout +::note `defineNuxtComponent()` is a helper function for defining type safe Vue components using options API similar to [`defineComponent()`](https://vuejs.org/api/general.html#definecomponent). `defineNuxtComponent()` wrapper also adds support for `asyncData` and `head` component options. :: -::callout{color="blue" icon="i-ph-info-duotone"} +::note Using ` ``` -::callout{to="/docs/guide/going-further/experimental-features#cookiestore"} +::note{to="/docs/guide/going-further/experimental-features#cookiestore"} You can enable experimental `cookieStore` option to automatically refresh `useCookie` value when cookie changes in the browser. :: diff --git a/docs/3.api/3.utils/refresh-nuxt-data.md b/docs/3.api/3.utils/refresh-nuxt-data.md index 32e1601a1d..89770397b4 100644 --- a/docs/3.api/3.utils/refresh-nuxt-data.md +++ b/docs/3.api/3.utils/refresh-nuxt-data.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note `refreshNuxtData` re-fetches all data from the server and updates the page as well as invalidates the cache of [`useAsyncData`](/docs/api/composables/use-async-data) , `useLazyAsyncData`, [`useFetch`](/docs/api/composables/use-fetch) and `useLazyFetch`. :: diff --git a/docs/3.api/3.utils/reload-nuxt-app.md b/docs/3.api/3.utils/reload-nuxt-app.md index 0868e1319c..0244c78b9c 100644 --- a/docs/3.api/3.utils/reload-nuxt-app.md +++ b/docs/3.api/3.utils/reload-nuxt-app.md @@ -8,13 +8,13 @@ links: size: xs --- -::callout +::note `reloadNuxtApp` will perform a hard reload of your app, re-requesting a page and its dependencies from the server. :: By default, it will also save the current `state` of your app (that is, any state you could access with `useState`). -::read-more{to="/docs/guide/going-further/experimental-features#restorestate" icon="i-ph-star-duotone"} +::read-more{to="/docs/guide/going-further/experimental-features#restorestate" icon="i-ph-star"} You can enable experimental restoration of this state by enabling the `experimental.restoreState` option in your `nuxt.config` file. :: diff --git a/docs/3.api/3.utils/set-page-layout.md b/docs/3.api/3.utils/set-page-layout.md index 6d1d11e6a7..de99133bba 100644 --- a/docs/3.api/3.utils/set-page-layout.md +++ b/docs/3.api/3.utils/set-page-layout.md @@ -1,6 +1,6 @@ --- title: 'setPageLayout' -description: setPageLayout allows you to dynamically change the layout of a page. +description: setPageLayout allows you to dynamically change the layout of a page. links: - label: Source icon: i-simple-icons-github @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::important `setPageLayout` allows you to dynamically change the layout of a page. It relies on access to the Nuxt context and therefore can only be called within the [Nuxt context](/docs/guide/going-further/nuxt-app#the-nuxt-context). :: @@ -19,6 +19,6 @@ export default defineNuxtRouteMiddleware((to) => { }) ``` -::callout +::note If you choose to set the layout dynamically on the server side, you _must_ do so before the layout is rendered by Vue (that is, within a plugin or route middleware) to avoid a hydration mismatch. :: diff --git a/docs/3.api/3.utils/set-response-status.md b/docs/3.api/3.utils/set-response-status.md index d3bf9119e3..967d942e01 100644 --- a/docs/3.api/3.utils/set-response-status.md +++ b/docs/3.api/3.utils/set-response-status.md @@ -12,7 +12,7 @@ Nuxt provides composables and utilities for first-class server-side-rendering su `setResponseStatus` sets the statusCode (and optionally the statusMessage) of the response. -::callout +::important `setResponseStatus` can only be called in the [Nuxt context](/docs/guide/going-further/nuxt-app#the-nuxt-context). :: @@ -29,7 +29,7 @@ if (event) { } ``` -::callout +::note In the browser, `setResponseStatus` will have no effect. :: diff --git a/docs/3.api/3.utils/show-error.md b/docs/3.api/3.utils/show-error.md index 75ef35d471..86f29c1d31 100644 --- a/docs/3.api/3.utils/show-error.md +++ b/docs/3.api/3.utils/show-error.md @@ -24,7 +24,7 @@ showError({ The error is set in the state using [`useError()`](/docs/api/composables/use-error) to create a reactive and SSR-friendly shared error state across components. -::callout +::tip `showError` calls the `app:error` hook. :: diff --git a/docs/3.api/3.utils/update-app-config.md b/docs/3.api/3.utils/update-app-config.md index 15f32044be..1ccfc61594 100644 --- a/docs/3.api/3.utils/update-app-config.md +++ b/docs/3.api/3.utils/update-app-config.md @@ -8,7 +8,7 @@ links: size: xs --- -::callout +::note Updates the [`app.config`](/docs/guide/directory-structure/app-config) using deep assignment. Existing (nested) properties will be preserved. :: diff --git a/docs/3.api/4.commands/_dir.yml b/docs/3.api/4.commands/_dir.yml index b1123168e0..00af2f6eb1 100644 --- a/docs/3.api/4.commands/_dir.yml +++ b/docs/3.api/4.commands/_dir.yml @@ -1,3 +1,3 @@ title: 'Commands' -icon: i-ph-terminal-window-duotone +icon: i-ph-terminal-window titleTemplate: '%s Β· Nuxt Commands' diff --git a/docs/3.api/4.commands/analyze.md b/docs/3.api/4.commands/analyze.md index 2d5dbe2c75..152cbedd36 100644 --- a/docs/3.api/4.commands/analyze.md +++ b/docs/3.api/4.commands/analyze.md @@ -18,6 +18,6 @@ Option | Default | Description -------------------------|-----------------|------------------ `rootDir` | `.` | The directory of the target application. -::callout +::note This command sets `process.env.NODE_ENV` to `production`. :: diff --git a/docs/3.api/4.commands/build.md b/docs/3.api/4.commands/build.md index ba561ba5f7..79da8bfc3e 100644 --- a/docs/3.api/4.commands/build.md +++ b/docs/3.api/4.commands/build.md @@ -9,7 +9,7 @@ links: --- ```bash [Terminal] -npx nuxi build [--prerender] [--dotenv] [--log-level] [rootDir] +npx nuxi build [--prerender] [--preset] [--dotenv] [--log-level] [rootDir] ``` The `build` command creates a `.output` directory with all your application, server and dependencies ready for production. @@ -18,9 +18,14 @@ 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`. -::callout +::note This command sets `process.env.NODE_ENV` to `production`. :: + +::note +`--prerender` will always set the `preset` to `static` +:: diff --git a/docs/3.api/4.commands/dev.md b/docs/3.api/4.commands/dev.md index 7ce1f18680..53368a35a4 100644 --- a/docs/3.api/4.commands/dev.md +++ b/docs/3.api/4.commands/dev.md @@ -34,6 +34,6 @@ Additionally to the above options, `nuxi` can pass options through to `listhen`, This command sets `process.env.NODE_ENV` to `development`. -::callout +::note If you are using a self-signed certificate in development, you will need to set `NODE_TLS_REJECT_UNAUTHORIZED=0` in your environment. :: diff --git a/docs/3.api/4.commands/init.md b/docs/3.api/4.commands/init.md index 0514e24e9b..52c739cf94 100644 --- a/docs/3.api/4.commands/init.md +++ b/docs/3.api/4.commands/init.md @@ -18,11 +18,17 @@ The `init` command initializes a fresh Nuxt project using [unjs/giget](https://g 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` | `false` | Force clone to any existing directory. -`--offline` | `false` | Do not attempt to download from github and only use local cache. -`--prefer-offline` | `false` | Try local cache first to download templates. -`--shell` | `false` | Open shell in cloned directory (experimental). +`--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. ## Environment variables diff --git a/docs/3.api/4.commands/preview.md b/docs/3.api/4.commands/preview.md index b3360d5e0d..9c767bacc6 100644 --- a/docs/3.api/4.commands/preview.md +++ b/docs/3.api/4.commands/preview.md @@ -21,6 +21,6 @@ Option | Default | Description This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a `.env` file or as command-line argument. -::callout +::note For convenience, in preview mode, your [`.env`](/docs/guide/directory-structure/env) file will be loaded into `process.env`. (However, in production you will need to ensure your environment variables are set yourself.) :: diff --git a/docs/3.api/4.commands/typecheck.md b/docs/3.api/4.commands/typecheck.md index a89ed8d22c..b1fcf28f53 100644 --- a/docs/3.api/4.commands/typecheck.md +++ b/docs/3.api/4.commands/typecheck.md @@ -18,7 +18,7 @@ Option | Default | Description -------------------------|-----------------|------------------ `rootDir` | `.` | The directory of the target application. -::callout +::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. :: diff --git a/docs/3.api/4.commands/upgrade.md b/docs/3.api/4.commands/upgrade.md index 187e915635..23958ea72f 100644 --- a/docs/3.api/4.commands/upgrade.md +++ b/docs/3.api/4.commands/upgrade.md @@ -1,6 +1,6 @@ --- title: "nuxi upgrade" -description: The upgrade command upgrades Nuxt 3 to the latest version. +description: The upgrade command upgrades Nuxt to the latest version. links: - label: Source icon: i-simple-icons-github @@ -12,7 +12,7 @@ links: npx nuxi upgrade [--force|-f] ``` -The `upgrade` command upgrades Nuxt 3 to the latest version. +The `upgrade` command upgrades Nuxt to the latest version. Option | Default | Description -------------------------|-----------------|------------------ diff --git a/docs/3.api/5.kit/10.runtime-config.md b/docs/3.api/5.kit/10.runtime-config.md new file mode 100644 index 0000000000..49f5348703 --- /dev/null +++ b/docs/3.api/5.kit/10.runtime-config.md @@ -0,0 +1,27 @@ +--- +title: Runtime Config +description: Nuxt Kit provides a set of utilities to help you access and modify Nuxt runtime configuration. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/kit/src/runtime-config.ts + size: xs +--- + +## `useRuntimeConfig` + +At build-time, it is possible to access the resolved Nuxt [runtime config](/docs/guide/going-further/runtime-config). + +### Type + +```ts +function useRuntimeConfig (): Record +``` + +## `updateRuntimeConfig` + +It is also possible to update runtime configuration. This will be merged with the existing runtime configuration, and if Nitro has already been initialized it will trigger an HMR event to reload the Nitro runtime config. + +```ts +function updateRuntimeConfig (config: Record): void | Promise +``` diff --git a/docs/3.api/5.kit/10.templates.md b/docs/3.api/5.kit/10.templates.md index d748246942..db6fdff969 100644 --- a/docs/3.api/5.kit/10.templates.md +++ b/docs/3.api/5.kit/10.templates.md @@ -117,7 +117,7 @@ import { defineNuxtPlugin } from '#imports' import metaConfig from '#build/meta.config.mjs' export default defineNuxtPlugin((nuxtApp) => { - const createHead = process.server ? createServerHead : createClientHead + const createHead = import.meta.server ? createServerHead : createClientHead const head = createHead() head.push(metaConfig.globalMeta) diff --git a/docs/3.api/5.kit/11.nitro.md b/docs/3.api/5.kit/11.nitro.md index 0a78203e13..9ca5785754 100644 --- a/docs/3.api/5.kit/11.nitro.md +++ b/docs/3.api/5.kit/11.nitro.md @@ -8,7 +8,7 @@ links: size: xs --- -Nitro is an open source TypeScript framework to build ultra-fast web servers. Nuxt 3 (and, optionally, Nuxt Bridge) uses Nitro as its server engine. You can use `useNitro` to access the Nitro instance, `addServerHandler` to add a server handler, `addDevServerHandler` to add a server handler to be used only in development mode, `addServerPlugin` to add a plugin to extend Nitro's runtime behavior, and `addPrerenderRoutes` to add routes to be prerendered by Nitro. +Nitro is an open source TypeScript framework to build ultra-fast web servers. Nuxt uses Nitro as its server engine. You can use `useNitro` to access the Nitro instance, `addServerHandler` to add a server handler, `addDevServerHandler` to add a server handler to be used only in development mode, `addServerPlugin` to add a plugin to extend Nitro's runtime behavior, and `addPrerenderRoutes` to add routes to be prerendered by Nitro. ## `addServerHandler` @@ -182,11 +182,11 @@ export default defineNuxtModule({ Returns the Nitro instance. -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning You can call `useNitro()` only after `ready` hook. :: -::callout +::note Changes to the Nitro instance configuration are not applied. :: @@ -239,7 +239,7 @@ export default defineNuxtModule({ Add plugin to extend Nitro's runtime behavior. -::callout +::tip You can read more about Nitro plugins in the [Nitro documentation](https://nitro.unjs.io/guide/plugins). :: diff --git a/docs/3.api/5.kit/12.resolving.md b/docs/3.api/5.kit/12.resolving.md index d8df4589ba..8218ecfb0d 100644 --- a/docs/3.api/5.kit/12.resolving.md +++ b/docs/3.api/5.kit/12.resolving.md @@ -211,6 +211,10 @@ Type of path to resolve. If set to `'file'`, the function will try to resolve a Creates resolver relative to base path. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/resolving-paths-and-injecting-assets-to-the-app?friend=nuxt" target="_blank"} +Watch Vue School video about createResolver. +:: + ### Type ```ts diff --git a/docs/3.api/5.kit/14.builder.md b/docs/3.api/5.kit/14.builder.md index 31557be079..68c4f876d3 100644 --- a/docs/3.api/5.kit/14.builder.md +++ b/docs/3.api/5.kit/14.builder.md @@ -220,7 +220,7 @@ interface ExtendWebpackConfigOptions { } ``` -::callout +::tip See [webpack website](https://webpack.js.org/concepts/plugins) for more information about webpack plugins. You can also use [this collection](https://webpack.js.org/awesome-webpack/#webpack-plugins) to find a plugin that suits your needs. :: @@ -275,9 +275,9 @@ Options to pass to the callback function. This object can have the following pro If set to `true`, the callback function will be called when building the client bundle. - `prepend` (optional) - + **Type**: `boolean` - + If set to `true`, the callback function will be prepended to the array with `unshift()` instead of `push()`. ### Examples @@ -302,7 +302,7 @@ export default defineNuxtModule({ context: nuxt.options.srcDir, files: options.include, lintDirtyModulesOnly: !options.lintOnStart - } + } addWebpackPlugin(new EslintWebpackPlugin(webpackOptions), { server: false }) } }) @@ -328,7 +328,7 @@ interface ExtendViteConfigOptions { } ``` -::callout +::tip See [Vite website](https://vitejs.dev/guide/api-plugin.html) for more information about Vite plugins. You can also use [this repository](https://github.com/vitejs/awesome-vite#plugins) to find a plugin that suits your needs. :: @@ -383,9 +383,9 @@ Options to pass to the callback function. This object can have the following pro If set to `true`, the callback function will be called when building the client bundle. - `prepend` (optional) - + **Type**: `boolean` - + If set to `true`, the callback function will be prepended to the array with `unshift()` instead of `push()`. ### Examples @@ -485,7 +485,7 @@ Options to pass to the callback function. This object can have the following pro If set to `true`, the callback function will be called when building the client bundle. - `prepend` (optional) - + **Type**: `boolean` - + If set to `true`, the callback function will be prepended to the array with `unshift()` instead of `push()`. diff --git a/docs/3.api/5.kit/3.compatibility.md b/docs/3.api/5.kit/3.compatibility.md index 20eebebd35..a253f7e845 100644 --- a/docs/3.api/5.kit/3.compatibility.md +++ b/docs/3.api/5.kit/3.compatibility.md @@ -25,6 +25,12 @@ async function checkNuxtCompatibility( interface NuxtCompatibility { nuxt?: string; bridge?: boolean; + builder?: { + // Set `false` if your module is not compatible with a builder + // or a semver-compatible string version constraint + vite?: false | string; + webpack?: false | string; + }; } interface NuxtCompatibilityIssue { diff --git a/docs/3.api/5.kit/4.autoimports.md b/docs/3.api/5.kit/4.autoimports.md index 66e781afbf..4aa9aac211 100644 --- a/docs/3.api/5.kit/4.autoimports.md +++ b/docs/3.api/5.kit/4.autoimports.md @@ -12,12 +12,16 @@ links: Nuxt auto-imports helper functions, composables and Vue APIs to use across your application without explicitly importing them. Based on the directory structure, every Nuxt application can also use auto-imports for its own composables and plugins. With Nuxt Kit you can also add your own auto-imports. `addImports` and `addImportsDir` allow you to add imports to the Nuxt application. `addImportsSources` allows you to add listed imports from 3rd party packages to the Nuxt application. -::callout +::note These functions are designed for registering your own utils, composables and Vue APIs. For pages, components and plugins, please refer to the specific sections: [Pages](/docs/api/kit/pages), [Components](/docs/api/kit/components), [Plugins](/docs/api/kit/plugins). :: Nuxt auto-imports helper functions, composables and Vue APIs to use across your application without explicitly importing them. Based on the directory structure, every Nuxt application can also use auto-imports for its own composables and plugins. Composables or plugins can use these functions. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/expanding-nuxt-s-auto-imports?friend=nuxt" target="_blank"} +Watch Vue School video about Auto-imports Nuxt Kit utilities. +:: + ## `addImports` Add imports to the Nuxt application. It makes your imports available in the Nuxt application without the need to import them manually. @@ -110,9 +114,9 @@ An object or an array of objects with the following properties: Using this as the from when generating type declarations. - `name` (required) - + **Type**: `string` - + Import name to be detected. - `as` (optional) @@ -296,9 +300,9 @@ An object or an array of objects with the following properties: Using this as the from when generating type declarations. - `name` (required) - + **Type**: `string` - + Import name to be detected. - `as` (optional) diff --git a/docs/3.api/5.kit/5.components.md b/docs/3.api/5.kit/5.components.md index 1e5848b4c8..3d41667d31 100644 --- a/docs/3.api/5.kit/5.components.md +++ b/docs/3.api/5.kit/5.components.md @@ -10,6 +10,10 @@ links: Components are the building blocks of your Nuxt application. They are reusable Vue instances that can be used to create a user interface. In Nuxt, components from the components directory are automatically imported by default. However, if you need to import components from an alternative directory or wish to selectively import them as needed, `@nuxt/kit` provides the `addComponentsDir` and `addComponent` methods. These utils allow you to customize the component configuration to better suit your needs. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/injecting-components-and-component-directories?friend=nuxt" target="_blank"} +Watch Vue School video about injecting components. +:: + ## `addComponentsDir` Register a directory to be scanned for components and imported only when used. Keep in mind, that this does not register components globally, until you specify `global: true` option. @@ -37,6 +41,11 @@ interface ComponentsDir { transpile?: 'auto' | boolean } +// You can augment this interface (exported from `@nuxt/schema`) if needed +interface ComponentMeta { + [key: string]: unknown +} + interface Component { pascalName: string kebabName: string @@ -50,6 +59,7 @@ interface Component { island?: boolean mode?: 'client' | 'server' | 'all' priority?: number + meta?: ComponentMeta } ``` diff --git a/docs/3.api/5.kit/6.context.md b/docs/3.api/5.kit/6.context.md index 91232cbafe..df4199fb35 100644 --- a/docs/3.api/5.kit/6.context.md +++ b/docs/3.api/5.kit/6.context.md @@ -10,7 +10,7 @@ links: Nuxt modules allow you to enhance Nuxt's capabilities. They offer a structured way to keep your code organized and modular. If you're looking to break down your module into smaller components, Nuxt offers the `useNuxt` and `tryUseNuxt` functions. These functions enable you to conveniently access the Nuxt instance from the context without having to pass it as argument. -::callout +::note When you're working with the `setup` function in Nuxt modules, Nuxt is already provided as the second argument. This means you can directly utilize it without needing to call `useNuxt()`. You can look at [Nuxt Site Config](https://github.com/harlan-zw/nuxt-site-config) as an example of usage. :: diff --git a/docs/3.api/5.kit/7.pages.md b/docs/3.api/5.kit/7.pages.md index 25170806d7..8c1d91a685 100644 --- a/docs/3.api/5.kit/7.pages.md +++ b/docs/3.api/5.kit/7.pages.md @@ -10,7 +10,11 @@ links: ## `extendPages` -In Nuxt 3, routes are automatically generated based on the structure of the files in the `pages` directory. However, there may be scenarios where you'd want to customize these routes. For instance, you might need to add a route for a dynamic page not generated by Nuxt, remove an existing route, or modify the configuration of a route. For such customizations, Nuxt 3 offers the `extendPages` feature, which allows you to extend and alter the pages configuration. +In Nuxt 3, routes are automatically generated based on the structure of the files in the `pages` directory. However, there may be scenarios where you'd want to customize these routes. For instance, you might need to add a route for a dynamic page not generated by Nuxt, remove an existing route, or modify the configuration of a route. For such customizations, Nuxt offers the `extendPages` feature, which allows you to extend and alter the pages configuration. + +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/extend-and-alter-nuxt-pages?friend=nuxt" target="_blank"} +Watch Vue School video about extendPages. +:: ### Type @@ -63,10 +67,14 @@ export default defineNuxtModule({ Nuxt is powered by the [Nitro](https://nitro.unjs.io) server engine. With Nitro, you can incorporate high-level logic directly into your configuration, which is useful for actions like redirects, proxying, caching, and appending headers to routes. This configuration works by associating route patterns with specific route settings. -::callout +::tip You can read more about Nitro route rules in the [Nitro documentation](https://nitro.unjs.io/guide/routing#route-rules). :: +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/adding-route-rules-and-route-middlewares?friend=nuxt" target="_blank"} +Watch Vue School video about adding route rules and route middelwares. +:: + ### Type ```ts @@ -180,10 +188,14 @@ Registers route middlewares to be available for all routes or for specific route Route middlewares can be also defined in plugins via [`addRouteMiddleware`](/docs/api/utils/add-route-middleware) composable. -::callout +::tip Read more about route middlewares in the [Route middleware documentation](/docs/getting-started/routing#route-middleware). :: +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/adding-route-rules-and-route-middlewares?friend=nuxt" target="_blank"} +Watch Vue School video about adding route rules and route middelwares. +:: + ### Type ```ts @@ -197,6 +209,7 @@ type NuxtMiddleware = { interface AddRouteMiddlewareOptions { override?: boolean + prepend?: boolean } ``` @@ -234,7 +247,21 @@ A middleware object or an array of middleware objects with the following propert **Default**: `{}` -Options to pass to the middleware. If `override` is set to `true`, it will override the existing middleware with the same name. +- `override` (optional) + + **Type**: `boolean` + + **Default**: `false` + + If enabled, overrides the existing middleware with the same name. + +- `prepend` (optional) + + **Type**: `boolean` + + **Default**: `false` + + If enabled, prepends the middleware to the list of existing middleware. ### Examples @@ -260,7 +287,7 @@ export default defineNuxtModule({ name: 'auth', path: resolver.resolve('runtime/auth.ts'), global: true - }) + }, { prepend: true }) } }) ``` diff --git a/docs/3.api/5.kit/8.layout.md b/docs/3.api/5.kit/8.layout.md index da80a70c90..9bf3ef78d2 100644 --- a/docs/3.api/5.kit/8.layout.md +++ b/docs/3.api/5.kit/8.layout.md @@ -14,8 +14,8 @@ Layouts is used to be a wrapper around your pages. It can be used to wrap your p Register template as layout and add it to the layouts. -::callout -In Nuxt 2 `error` layout can also be registered using this utility. In Nuxt 3 `error` layout [replaced](/docs/getting-started/error-handling#rendering-an-error-page) with `error.vue` page in project root. +::note +In Nuxt 2 `error` layout can also be registered using this utility. In Nuxt 3+ `error` layout [replaced](/docs/getting-started/error-handling#rendering-an-error-page) with `error.vue` page in project root. :: ### Type @@ -74,7 +74,7 @@ A template object or a string with the path to the template. If a string is prov A function that will be called with the `options` object. It should return a string or a promise that resolves to a string. If `src` is provided, this function will be ignored. - `write` (optional) - + **Type**: `boolean` If set to `true`, the template will be written to the destination file. Otherwise, the template will be used only in virtual filesystem. diff --git a/docs/3.api/5.kit/9.plugins.md b/docs/3.api/5.kit/9.plugins.md index 6f8924edae..e2f09cfc76 100644 --- a/docs/3.api/5.kit/9.plugins.md +++ b/docs/3.api/5.kit/9.plugins.md @@ -14,6 +14,10 @@ Plugins are self-contained code that usually add app-level functionality to Vue. Registers a Nuxt plugin and to the plugins array. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/injecting-plugins?friend=nuxt" target="_blank"} +Watch Vue School video about addPlugin. +:: + ### Type ```ts @@ -60,7 +64,7 @@ A plugin object or a string with the path to the plugin. If a string is provided Order of the plugin. This allows more granular control over plugin order and should only be used by advanced users. Lower numbers run first, and user plugins default to `0`. It's recommended to set `order` to a number between `-20` for `pre`-plugins (plugins that run before Nuxt plugins) and `20` for `post`-plugins (plugins that run after Nuxt plugins). -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Don't use `order` unless you know what you're doing. For most plugins, the default `order` of `0` is sufficient. To append a plugin to the end of the plugins array, use the `append` option instead. :: @@ -110,6 +114,10 @@ export default defineNuxtPlugin((nuxtApp) => { Adds a template and registers as a nuxt plugin. This is useful for plugins that need to generate code at build time. +::tip{icon="i-ph-video" to="https://vueschool.io/lessons/injecting-plugin-templates?friend=nuxt" target="_blank"} +Watch Vue School video about addPluginTemplate. +:: + ### Type ```ts @@ -184,7 +192,7 @@ A plugin template object with the following properties: A function that will be called with the `options` object. It should return a string or a promise that resolves to a string. If `src` is provided, this function will be ignored. - `write` (optional) - + **Type**: `boolean` If set to `true`, the template will be written to the destination file. Otherwise, the template will be used only in virtual filesystem. @@ -197,10 +205,10 @@ A plugin template object with the following properties: Order of the plugin. This allows more granular control over plugin order and should only be used by advanced users. Lower numbers run first, and user plugins default to `0`. It's recommended to set `order` to a number between `-20` for `pre`-plugins (plugins that run before Nuxt plugins) and `20` for `post`-plugins (plugins that run after Nuxt plugins). -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Don't use `order` unless you know what you're doing. For most plugins, the default `order` of `0` is sufficient. To append a plugin to the end of the plugins array, use the `append` option instead. :: - + #### `options` **Type**: `AddPluginOptions` @@ -243,7 +251,7 @@ export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.use(VueFire, { firebaseApp }) <% if(options.ssr) { %> - if (process.server) { + if (import.meta.server) { nuxtApp.payload.vuefire = useSSRInitialState(undefined, firebaseApp) } else if (nuxtApp.payload?.vuefire) { useSSRInitialState(nuxtApp.payload.vuefire, firebaseApp) diff --git a/docs/3.api/5.kit/_dir.yml b/docs/3.api/5.kit/_dir.yml index dda66db56e..86a5d387a4 100644 --- a/docs/3.api/5.kit/_dir.yml +++ b/docs/3.api/5.kit/_dir.yml @@ -1,3 +1,3 @@ title: Nuxt Kit -navigation.icon: i-ph-toolbox-duotone +navigation.icon: i-ph-toolbox titleTemplate: '%s Β· Nuxt Kit' diff --git a/docs/3.api/6.advanced/1.hooks.md b/docs/3.api/6.advanced/1.hooks.md index 35520e27c3..894f104728 100644 --- a/docs/3.api/6.advanced/1.hooks.md +++ b/docs/3.api/6.advanced/1.hooks.md @@ -7,7 +7,7 @@ description: Nuxt provides a powerful hooking system to expand almost every aspe ## App Hooks (runtime) -Check the [app source code](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/nuxt.ts#L27) for all available hooks. +Check the [app source code](https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/nuxt.ts#L37) for all available hooks. Hook | Arguments | Environment | Description -----------------------|---------------------|-----------------|------------- @@ -22,16 +22,19 @@ Hook | Arguments | Environment | Description `app:beforeMount` | `vueApp` | Client | Called before mounting the app, called only on client side. `app:mounted` | `vueApp` | Client | Called when Vue app is initialized and mounted in browser. `app:suspense:resolve` | `appComponent` | Client | On [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event. +`app:manifest:update` | `{ id, timestamp }` | Client | Called when there is a newer version of your app detected. `link:prefetch` | `to` | Client | Called when a `` is observed to be prefetched. `page:start` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) pending event. `page:finish` | `pageComponent?` | Client | Called on [Suspense](https://vuejs.org/guide/built-ins/suspense.html#suspense) resolved event. `page:loading:start` | - | Client | Called when the `setup()` of the new page is running. `page:loading:end` | - | Client | Called after `page:finish` `page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event. +`dev:ssr-logs` | `logs` | Client | Called with an array of server-side logs that have been passed to the client (if `features.devLogs` is enabled). +`page:view-transition:start` | `transition` | Client | Called after `document.startViewTransition` is called when [experimental viewTransition support is enabled](/docs/getting-started/transitions#view-transitions-api-experimental). ## Nuxt Hooks (build time) -Check the [schema source code](https://github.com/nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts#L53) for all available hooks. +Check the [schema source code](https://github.com/nuxt/nuxt/blob/main/packages/schema/src/types/hooks.ts#L83) for all available hooks. Hook | Arguments | Description -------------------------|----------------------------|------------- @@ -90,11 +93,12 @@ See [Nitro](https://nitro.unjs.io/guide/plugins#available-hooks) for all availab Hook | Arguments | Description | Types -----------------------|-----------------------|--------------------------------------|------------------ +`dev:ssr-logs` | `{ path, logs }` | Server | Called at the end of a request cycle with an array of server-side logs. `render:response` | `response, { event }` | Called before sending the response. | [response](https://github.com/nuxt/nuxt/blob/71ef8bd3ff207fd51c2ca18d5a8c7140476780c7/packages/nuxt/src/core/runtime/nitro/renderer.ts#L24), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `render:html` | `html, { event }` | Called before constructing the HTML. | [html](https://github.com/nuxt/nuxt/blob/71ef8bd3ff207fd51c2ca18d5a8c7140476780c7/packages/nuxt/src/core/runtime/nitro/renderer.ts#L15), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `render:island` | `islandResponse, { event, islandContext }` | Called before constructing the island HTML. | [islandResponse](https://github.com/nuxt/nuxt/blob/e50cabfed1984c341af0d0c056a325a8aec26980/packages/nuxt/src/core/runtime/nitro/renderer.ts#L28), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38), [islandContext](https://github.com/nuxt/nuxt/blob/e50cabfed1984c341af0d0c056a325a8aec26980/packages/nuxt/src/core/runtime/nitro/renderer.ts#L38) `close` | - | Called when Nitro is closed. | - -`error` | `error, { event? }` | Called when an error occurs. | [error](https://github.com/unjs/nitro/blob/main/src/runtime/types.ts#L24), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) +`error` | `error, { event? }` | Called when an error occurs. | [error](https://github.com/unjs/nitro/blob/d20ffcbd16fc4003b774445e1a01e698c2bb078a/src/types/runtime/nitro.ts#L48), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `request` | `event` | Called when a request is received. | [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `beforeResponse` | `event, { body }` | Called before sending the response. | [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38), unknown `afterResponse` | `event, { body }` | Called after sending the response. | [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38), unknown diff --git a/docs/3.api/6.advanced/2.import-meta.md b/docs/3.api/6.advanced/2.import-meta.md index 97d291ee98..633e0461fd 100644 --- a/docs/3.api/6.advanced/2.import-meta.md +++ b/docs/3.api/6.advanced/2.import-meta.md @@ -10,7 +10,9 @@ This is done through `import.meta`, which is an object that provides your code w Throughout the Nuxt documentation you may see snippets that use this already to figure out whether the code is currently running on the client or server side. -:read-more{to="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta"} +::read-more{to="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta"} +Read more about `import.meta`. +:: ## Runtime (App) Properties diff --git a/docs/3.api/6.advanced/_dir.yml b/docs/3.api/6.advanced/_dir.yml index b8a90804b7..e0a580e33c 100644 --- a/docs/3.api/6.advanced/_dir.yml +++ b/docs/3.api/6.advanced/_dir.yml @@ -1 +1 @@ -icon: i-ph-brain-duotone +icon: i-ph-brain diff --git a/docs/3.api/6.nuxt-config.md b/docs/3.api/6.nuxt-config.md index 027f73fe32..cad9250920 100644 --- a/docs/3.api/6.nuxt-config.md +++ b/docs/3.api/6.nuxt-config.md @@ -2,10 +2,10 @@ title: Nuxt Configuration titleTemplate: '%s' description: Discover all the options you can use in your nuxt.config.ts file. -navigation.icon: i-ph-gear-duotone +navigation.icon: i-ph-gear --- -::callout{icon="i-simple-icons-github" color="gray" to="https://github.com/nuxt/nuxt/tree/main/packages/schema/src/config" target="_blank"} +::note{icon="i-simple-icons-github" color="gray" to="https://github.com/nuxt/nuxt/tree/main/packages/schema/src/config" target="_blank"} This file is auto-generated from Nuxt source code. :: diff --git a/docs/3.api/index.md b/docs/3.api/index.md index f0b12b7425..7e4970a2ae 100644 --- a/docs/3.api/index.md +++ b/docs/3.api/index.md @@ -7,25 +7,25 @@ surround: false --- ::card-group - ::card{icon="i-ph-cube-duotone" title="Components" to="/docs/api/components/client-only"} + ::card{icon="i-ph-cube" title="Components" to="/docs/api/components/client-only"} Explore Nuxt built-in components for pages, layouts, head, and more. :: - ::card{icon="i-ph-arrows-left-right-duotone" title="Composables" to="/docs/api/composables/use-app-config"} + ::card{icon="i-ph-arrows-left-right" title="Composables" to="/docs/api/composables/use-app-config"} Discover Nuxt composable functions for data-fetching, head management and more. :: - ::card{icon="i-ph-function-duotone" title="Utils" to="/docs/api/utils/dollarfetch"} + ::card{icon="i-ph-function" title="Utils" to="/docs/api/utils/dollarfetch"} Learn about Nuxt utility functions for navigation, error handling and more. :: - ::card{icon="i-ph-terminal-window-duotone" title="Commands" to="/docs/api/commands/add"} + ::card{icon="i-ph-terminal-window" title="Commands" to="/docs/api/commands/add"} List of Nuxt CLI commands to init, analyze, build, and preview your application. :: - ::card{icon="i-ph-toolbox-duotone" title="Nuxt Kit" to="/docs/api/kit/modules"} + ::card{icon="i-ph-toolbox" title="Nuxt Kit" to="/docs/api/kit/modules"} Understand Nuxt Kit utilities to create modules and control Nuxt. :: - ::card{icon="i-ph-brain-duotone" title="Advanced" to="/docs/api/advanced/hooks"} + ::card{icon="i-ph-brain" title="Advanced" to="/docs/api/advanced/hooks"} Go deep in Nuxt internals with Nuxt lifecycle hooks. :: - ::card{icon="i-ph-gear-duotone" title="Nuxt Configuration" to="/docs/api/nuxt-config"} + ::card{icon="i-ph-gear" title="Nuxt Configuration" to="/docs/api/nuxt-config"} Explore all Nuxt configuration options to customize your application. :: :: diff --git a/docs/5.community/2.getting-help.md b/docs/5.community/2.getting-help.md index 70ab6946fc..ded7e1292a 100644 --- a/docs/5.community/2.getting-help.md +++ b/docs/5.community/2.getting-help.md @@ -1,7 +1,8 @@ --- -title: 'Getting Help' -description: "We're a friendly community of developers and we'd love to help." -navigation.icon: i-ph-lifebuoy-duotone +title: Getting Help +description: We're a friendly community of developers and we'd love to help. +navigation: + icon: i-ph-lifebuoy --- At some point, you may find that there's an issue you need some help with. @@ -16,13 +17,10 @@ Please don't feel embarrassed about asking a question that you think is easy - w Everyone you'll encounter is helping out because they care, not because they are paid to do so. The kindest thing to do is make it easy for them to help you. Here are some ideas: -* _Explain what your objective is, not just the problem you're facing._ "I need to ensure my form inputs are accessible, so I'm trying to get the ids to match between server and client." - -* _Make sure you've first read the docs and used your favorite search engine_. Let people know by saying something like "I've Googled for 'nuxt script setup' but I couldn't find code examples anywhere." - -* _Explain what you've tried._ Tell people the kind of solutions you've experimented with, and why. Often this can make people's advice more relevant to your situation. - -* _Share your code._ People probably won't be able to help if they just see an error message or a screenshot - but that all changes if you share your code in a copy/pasteable format - preferably in the form of a minimal reproduction like a CodeSandbox. +- _Explain what your objective is, not just the problem you're facing._ "I need to ensure my form inputs are accessible, so I'm trying to get the ids to match between server and client." +- _Make sure you've first read the docs and used your favorite search engine_. Let people know by saying something like "I've Googled for 'nuxt script setup' but I couldn't find code examples anywhere." +- _Explain what you've tried._ Tell people the kind of solutions you've experimented with, and why. Often this can make people's advice more relevant to your situation. +- _Share your code._ People probably won't be able to help if they just see an error message or a screenshot - but that all changes if you share your code in a copy/pasteable format - preferably in the form of a minimal reproduction like a CodeSandbox. And finally, just ask the question! There's no need to [ask permission to ask a question](https://dontasktoask.com) or [wait for someone to reply to your 'hello'](https://www.nohello.com). If you do, you might not get a response because people are waiting for the whole question before engaging. @@ -30,4 +28,12 @@ And finally, just ask the question! There's no need to [ask permission to ask a Something isn't working the way that the docs say that it should. You're not sure if it's a bug. You've searched through the [open issues](https://github.com/nuxt/nuxt/issues) and [discussions](https://github.com/nuxt/nuxt/discussions) but you can't find anything. (if there is a closed issue, please create a new one) -We recommend taking a look at [how to report bugs](/docs/community/reporting-bugs). Nuxt 3 is still in active development, and every issue helps make it better. +We recommend taking a look at [how to report bugs](/docs/community/reporting-bugs). Nuxt is still in active development, and every issue helps make it better. + +## "I need professional help" + +If the community couldn't provide the help you need in the time-frame you have, NuxtLabs offers professional support with the [Nuxt Experts](https://nuxt.com/enterprise/support). + +The objective of the Nuxt Expert is to provide support to the Vue ecosystem, while also creating freelance opportunities for those contributing to open-source solutions, thus helping to maintain the sustainability of the ecosystem. + +The Nuxt experts are Vue, Nuxt and Vite chosen contributors providing professional support and consulting services. diff --git a/docs/5.community/3.reporting-bugs.md b/docs/5.community/3.reporting-bugs.md index 8d0de17e5d..30b60f3386 100644 --- a/docs/5.community/3.reporting-bugs.md +++ b/docs/5.community/3.reporting-bugs.md @@ -1,7 +1,7 @@ --- title: 'Reporting Bugs' description: 'One of the most valuable roles in open source is taking the time to report bugs helpfully.' -navigation.icon: i-ph-bug-duotone +navigation.icon: i-ph-bug --- Try as we might, we will never completely eliminate bugs. @@ -22,31 +22,25 @@ Search through the [open issues](https://github.com/nuxt/nuxt/issues) and [discu It's important to be able to reproduce the bug reliably - in a minimal way and apart from the rest of your project. This narrows down what could be causing the issue and makes it possible for someone not only to find the cause, but also to test a potential solution. -Start with the Nuxt 3 or Nuxt Bridge sandbox and add the **minimum** amount of code necessary to reproduce the bug you're experiencing. +Start with the Nuxt sandbox and add the **minimum** amount of code necessary to reproduce the bug you're experiencing. -::callout -If your issue concerns Vue 3 or Vite, please try to reproduce it first with the Vue 3 SSR starter. +::note +If your issue concerns Vue or Vite, please try to reproduce it first with the Vue SSR starter. :: -**Nuxt 3**: +**Nuxt**: ::card-group - :card{title="Nuxt 3 on StackBlitz" icon="i-simple-icons-stackblitz" to="https://nuxt.new/s/v3" target="_blank"} - :card{title="Nuxt 3 on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://nuxt.new/c/v3" target="_blank"} + :card{title="Nuxt on StackBlitz" icon="i-simple-icons-stackblitz" to="https://nuxt.new/s/v3" target="_blank"} + :card{title="Nuxt on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://nuxt.new/c/v3" target="_blank"} :: -**Nuxt Bridge**: +**Vue**: ::card-group - :card{title="Nuxt Bridge on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://codesandbox.io/s/github/nuxt/starter/v2-bridge-codesandbox" target="_blank"} -:: - -**Vue 3**: - -::card-group - :card{title="Vue 3 SSR on StackBlitz" icon="i-simple-icons-stackblitz" to="https://stackblitz.com/github/nuxt-contrib/vue3-ssr-starter/tree/main?terminal=dev" target="_blank"} - :card{title="Vue 3 SSR on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://codesandbox.io/s/github/nuxt-contrib/vue3-ssr-starter/main" target="_blank"} - :card{title="Vue 3 SSR Template on GitHub" icon="i-simple-icons-github" to="https://github.com/nuxt-contrib/vue3-ssr-starter/generate" target="_blank"} + :card{title="Vue SSR on StackBlitz" icon="i-simple-icons-stackblitz" to="https://stackblitz.com/github/nuxt-contrib/vue3-ssr-starter/tree/main?terminal=dev" target="_blank"} + :card{title="Vue SSR on CodeSandbox" icon="i-simple-icons-codesandbox" to="https://codesandbox.io/s/github/nuxt-contrib/vue3-ssr-starter/main" target="_blank"} + :card{title="Vue SSR Template on GitHub" icon="i-simple-icons-github" to="https://github.com/nuxt-contrib/vue3-ssr-starter/generate" target="_blank"} :: Once you've reproduced the issue, remove as much code from your reproduction as you can (while still recreating the bug). The time spent making the reproduction as minimal as possible will make a huge difference to whoever sets out to fix the issue. diff --git a/docs/5.community/4.contribution.md b/docs/5.community/4.contribution.md index ea6b72dc08..d8e58c24dc 100644 --- a/docs/5.community/4.contribution.md +++ b/docs/5.community/4.contribution.md @@ -1,7 +1,7 @@ --- title: 'Contribution' description: 'Nuxt is a community project - and so we love contributions of all kinds! ❀️' -navigation.icon: i-ph-git-pull-request-duotone +navigation.icon: i-ph-git-pull-request --- There is a range of different ways you might be able to contribute to the Nuxt ecosystem. @@ -146,8 +146,8 @@ We recommend using [VS Code](https://code.visualstudio.com) along with the [ESLi ```json [settings.json] { "editor.codeActionsOnSave": { - "source.fixAll": false, - "source.fixAll.eslint": true + "source.fixAll": "never", + "source.fixAll.eslint": "explicit" } } ``` @@ -184,21 +184,21 @@ Here are some tips that may help improve your documentation: Keep in mind your readers can have different backgrounds and experiences. Therefore, these words don't convey meaning and can be harmful. - ::callout{color="red" icon="i-ph-x-circle-duotone"} + ::caution{ icon="i-ph-x-circle"} Simply make sure the function returns a promise. :: - ::callout{color="green" icon="i-ph-check-circle-duotone"} + ::tip{icon="i-ph-check-circle"} Make sure the function returns a [promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). :: * Prefer [active voice](https://developers.google.com/tech-writing/one/active-voice). - ::callout{color="red" icon="i-ph-x-circle-duotone"} + ::caution{icon="i-ph-x-circle"} An error will be thrown by Nuxt. :: - ::callout{color="green" icon="i-ph-check-circle-duotone"} + ::tip{icon="i-ph-check-circle"} Nuxt will throw an error. :: diff --git a/docs/5.community/5.framework-contribution.md b/docs/5.community/5.framework-contribution.md index 09b7e5b9e2..7c9315fa3a 100644 --- a/docs/5.community/5.framework-contribution.md +++ b/docs/5.community/5.framework-contribution.md @@ -1,6 +1,6 @@ --- title: 'Framework' -navigation.icon: i-ph-github-logo-duotone +navigation.icon: i-ph-github-logo description: Some specific points about contributions to the framework repository. --- @@ -25,11 +25,11 @@ To contribute to Nuxt, you need to set up a local environment. ```bash [Terminal] corepack enable ``` -4. Run `pnpm install` to Install the dependencies with pnpm: +4. Run `pnpm install --frozen-lockfile` to Install the dependencies with pnpm: ```bash [Terminal] - pnpm install + pnpm install --frozen-lockfile ``` - ::callout + ::note If you are adding a dependency, please use `pnpm add`. :br The `pnpm-lock.yaml` file is the source of truth for all Nuxt dependencies. :: @@ -54,7 +54,7 @@ You can modify the example app in `playground/`, and run: pnpm dev ``` -::callout{color="amber" icon="i-ph-warning-duotone"} +::important Please make sure not to commit it to your branch, but it could be helpful to add some example code to your PR description. This can help reviewers and other Nuxt users understand the feature you've built in-depth. :: @@ -78,7 +78,7 @@ Before committing your changes, to verify that the code style is correct, run: pnpm lint ``` -::callout +::note You can use `pnpm lint --fix` to fix most of the style changes. :br If there are still errors left, you must correct them manually. :: @@ -87,7 +87,7 @@ If there are still errors left, you must correct them manually. If you are adding a new feature or refactoring or changing the behavior of Nuxt in any other manner, you'll likely want to document the changes. Please include any changes to the docs in the same PR. You don't have to write documentation up on the first commit (but please do so as soon as your pull request is mature enough). -::callout +::important Make sure to make changes according to the [Documentation Style Guide](/docs/community/contribution#documentation-style-guide). :: @@ -99,7 +99,7 @@ When submitting your PR, there is a simple template that you have to fill out. P If you spot an area where we can improve documentation or error messages, please do open a PR - even if it's just to fix a typo! -::callout +::important Make sure to make changes according to the [Documentation Style Guide](/docs/community/contribution#documentation-style-guide). :: @@ -113,11 +113,11 @@ Make the change directly in the GitHub interface and open a Pull Request. The documentation content is inside the `docs/` directory of the [nuxt/nuxt](https://github.com/nuxt/nuxt) repository and written in markdown. -::callout{icon="i-ph-info-duotone" color="blue"} +::note To preview the docs locally, follow the steps on [nuxt/nuxt.com](https://github.com/nuxt/nuxt.com) repository. :: -::callout +::note We recommend that you install the [MDC extension](https://marketplace.visualstudio.com/items?itemName=Nuxt.mdc) for VS Code. :: @@ -129,13 +129,13 @@ Documentation is linted using [MarkdownLint](https://github.com/DavidAnson/markd pnpm lint:docs ``` -::callout +::note You can also run `pnpm lint:docs:fix` to highlight and resolve any lint issues. :: ### Open a PR -Please make sure your PR title adheres to the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0) guidelines. +Please make sure your PR title adheres to the [conventional commits](https://www.conventionalcommits.org) guidelines. ```bash [Example of PR title] docs: update the section about the nuxt.config.ts file diff --git a/docs/5.community/6.roadmap.md b/docs/5.community/6.roadmap.md index b936dfaf1c..df987f8bfb 100644 --- a/docs/5.community/6.roadmap.md +++ b/docs/5.community/6.roadmap.md @@ -1,7 +1,7 @@ --- title: 'Roadmap' description: 'Nuxt is constantly evolving, with new features and modules being added all the time.' -navigation.icon: i-ph-map-trifold-duotone +navigation.icon: i-ph-map-trifold --- ::read-more{to="/blog"} @@ -24,29 +24,26 @@ Nuxt Image: Performance and Status In roadmap below are some features we are planning or working on at the moment. -::callout{icon="i-ph-lightbulb-duotone" color="yellow"} +::tip Check [Discussions](https://github.com/nuxt/nuxt/discussions) and [RFCs](https://github.com/nuxt/nuxt/discussions/categories/rfcs) for more upcoming features and ideas. :: Milestone | Expected date | Notes | Description -------------|---------------|------------------------------------------------------------------------|----------------------- -SEO & PWA | 2023 | [nuxt/nuxt#18395](https://github.com/nuxt/nuxt/discussions/18395) | Migrating from [nuxt-community/pwa-module](https://github.com/nuxt-community/pwa-module) for built-in SEO utils and service worker support -DevTools | 2023 | - | Integrated and modular devtools experience for Nuxt -Scripts | 2023 | [nuxt/nuxt#22016](https://github.com/nuxt/nuxt/discussions/22016) | Easy 3rd party script management. -Fonts | 2023 | [nuxt/nuxt#22014](https://github.com/nuxt/nuxt/discussions/22014) | Allow developers to easily configure fonts in their Nuxt apps. -Assets | 2023 | [nuxt/nuxt#22012](https://github.com/nuxt/nuxt/discussions/22012) | Allow developers and modules to handle loading third-party assets. -A11y | 2023 | [nuxt/nuxt#23255](https://github.com/nuxt/nuxt/issues/23255) | Accessibility hinting and utilities -Translations | - | [nuxt/translations#4](https://github.com/nuxt/translations/discussions/4) ([request access](https://github.com/nuxt/nuxt/discussions/16054)) | A collaborative project for a stable translation process for Nuxt 3 docs. Currently pending for ideas and documentation tooling support (content v2 with remote sources). +SEO & PWA | 2024 | [nuxt/nuxt#18395](https://github.com/nuxt/nuxt/discussions/18395) | Migrating from [nuxt-community/pwa-module](https://github.com/nuxt-community/pwa-module) for built-in SEO utils and service worker support +Assets | 2024 | [nuxt/nuxt#22012](https://github.com/nuxt/nuxt/discussions/22012) | Allow developers and modules to handle loading third-party assets. +Translations | - | [nuxt/translations#4](https://github.com/nuxt/translations/discussions/4) ([request access](https://github.com/nuxt/nuxt/discussions/16054)) | A collaborative project for a stable translation process for Nuxt docs. Currently pending for ideas and documentation tooling support (content v2 with remote sources). -## Core Modules +## Core Modules Roadmap In addition to the Nuxt framework, there are modules that are vital for the ecosystem. Their status will be updated below. -Module | Status | Nuxt Support | Repository | Description ----------------|---------------------|--------------|------------|------------------- -Auth | Planned | 3.x | `nuxt/auth` to be announced | Nuxt 3 support is planned after session support -Image | Active | 2.x and 3.x | [nuxt/image](https://github.com/nuxt/image) | Nuxt 3 support is in progress: [nuxt/image#548](https://github.com/nuxt/image/discussions/548) -I18n | Active | 2.x and 3.x | [nuxt-modules/i18n](https://github.com/nuxt-modules/i18n) | See [nuxt-modules/i18n#1287](https://github.com/nuxt-modules/i18n/discussions/1287) for Nuxt 3 support +Module | Status | Nuxt Support | Repository | Description +------------------------------------|---------------------|--------------|------------|------------------- +[Scripts](https://scripts.nuxt.com) | Public Beta | 3.x | [nuxt/scripts](https://github.com/nuxt/scripts) | Easy 3rd party script management. +A11y | Planned | 3.x | `nuxt/a11y` to be announced | Accessibility hinting and utilities [nuxt/nuxt#23255](https://github.com/nuxt/nuxt/issues/23255) +Auth | Planned | 3.x | `nuxt/auth` to be announced | Support is planned after session support. +Hints | Planned | 3.x | `nuxt/hints` to be announced | Guidance and suggestions for enhancing development practices. ## Release Cycle @@ -62,14 +59,14 @@ The current active version of [Nuxt](https://nuxt.com) is **v3** which is availa Nuxt 2 is in maintenance mode and is available on npm with the `2x` tag. It will reach End of Life (EOL) on June 30, 2024. -Each active version has its own nightly releases which are generated automatically. For more about enabling the Nuxt 3 nightly release channel, see [the nightly release channel docs](/docs/guide/going-further/nightly-release-channel). +Each active version has its own nightly releases which are generated automatically. For more about enabling the Nuxt nightly release channel, see [the nightly release channel docs](/docs/guide/going-further/nightly-release-channel). Release | | Initial release | End Of Life | Docs ----------------------------------------|---------------------------------------------------------------------------------------------------|-----------------|--------------|------- -**4.x** (scheduled) | | 2024 Q1 | |   -**3.x** (stable) | Nuxt latest 3.x version | 2022-11-16 | TBA | [nuxt.com](/docs) -**2.x** (maintenance) | Nuxt 2.x version | 2018-09-21 | 2024-06-30 | [v2.nuxt.com](https://v2.nuxt.com/docs) -**1.x** (unsupported) | Nuxt 1.x version | 2018-01-08 | 2019-09-21 |   +**4.x** (scheduled) | | 2024 Q3 | |   +**3.x** (stable) | Nuxt latest 3.x version | 2022-11-16 | TBA | [nuxt.com](/docs) +**2.x** (unsupported) | Nuxt 2.x version | 2018-09-21 | 2024-06-30 | [v2.nuxt.com](https://v2.nuxt.com/docs) +**1.x** (unsupported) | Nuxt 1.x version | 2018-01-08 | 2019-09-21 |   ### Support Status diff --git a/docs/5.community/7.changelog.md b/docs/5.community/7.changelog.md index 593d5daa85..942ec3e32b 100644 --- a/docs/5.community/7.changelog.md +++ b/docs/5.community/7.changelog.md @@ -1,7 +1,7 @@ --- title: 'Releases' description: Discover the latest releases of Nuxt & Nuxt official modules. -navigation.icon: i-ph-notification-duotone +navigation.icon: i-ph-notification --- ::card-group @@ -48,6 +48,16 @@ navigation.icon: i-ph-notification-duotone ::card --- icon: i-simple-icons-github + title: nuxt/fonts + to: https://github.com/nuxt/fonts/releases + target: _blank + ui.icon.base: text-black dark:text-white + --- + Nuxt Fonts releases. + :: + ::card + --- + icon: i-simple-icons-github title: nuxt/image to: https://github.com/nuxt/image/releases target: _blank @@ -58,6 +68,16 @@ navigation.icon: i-ph-notification-duotone ::card --- icon: i-simple-icons-github + title: nuxt/scripts + to: https://github.com/nuxt/scripts/tags + target: _blank + ui.icon.base: text-black dark:text-white + --- + Nuxt Scripts releases. + :: + ::card + --- + icon: i-simple-icons-github title: nuxt/ui to: https://github.com/nuxt/ui/releases target: _blank diff --git a/docs/5.community/_dir.yml b/docs/5.community/_dir.yml index 1330352c11..de92f13d6f 100644 --- a/docs/5.community/_dir.yml +++ b/docs/5.community/_dir.yml @@ -1,3 +1,3 @@ title: 'Community' titleTemplate: '%s Β· Nuxt Community' -icon: i-ph-chats-teardrop-duotone +icon: i-ph-chats-teardrop diff --git a/docs/6.bridge/1.overview.md b/docs/6.bridge/1.overview.md index e1aaab0074..e37a64a8af 100644 --- a/docs/6.bridge/1.overview.md +++ b/docs/6.bridge/1.overview.md @@ -3,11 +3,11 @@ title: Overview description: Reduce the differences with Nuxt 3 and reduce the burden of migration to Nuxt 3. --- -::callout +::note If you're starting a fresh Nuxt 3 project, please skip this section and go to [Nuxt 3 Installation](/docs/getting-started/introduction). :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Nuxt Bridge provides identical features to Nuxt 3 ([docs](/docs/guide/concepts/auto-imports)) but there are some limitations, notably that [`useAsyncData`](/docs/api/composables/use-async-data) and [`useFetch`](/docs/api/composables/use-fetch) composables are not available. Please read the rest of this page for details. :: @@ -28,19 +28,18 @@ Make sure your dev server (`nuxt dev`) isn't running, remove any package lock fi Then, reinstall your dependencies: -::code-group - -```bash [yarn] -yarn install -``` +::package-managers ```bash [npm] npm install ``` +```bash [yarn] +yarn install +``` :: -::callout +::note Once the installation is complete, make sure both development and production builds are working as expected before proceeding. :: @@ -48,16 +47,16 @@ Once the installation is complete, make sure both development and production bui Install `@nuxt/bridge` and `nuxi` as development dependencies: -::code-group - -```bash [Yarn] -yarn add --dev @nuxt/bridge nuxi -``` +::package-managers ```bash [npm] npm install -D @nuxt/bridge nuxi ``` +```bash [yarn] +yarn add --dev @nuxt/bridge nuxi +``` + :: ### Update `nuxt.config` diff --git a/docs/6.bridge/10.configuration.md b/docs/6.bridge/10.configuration.md index 1adf1fc48d..da0fd85432 100644 --- a/docs/6.bridge/10.configuration.md +++ b/docs/6.bridge/10.configuration.md @@ -22,6 +22,16 @@ export default defineNuxtConfig({ // Enable Nuxt 3 compatible useHead // meta: true, + // Enable definePageMeta macro + // macros: { + // pageMeta: true + // }, + + // Enable transpiling TypeScript with esbuild + // typescript: { + // esbuild: true + // }, + // -- Default features -- // Use legacy server instead of Nitro diff --git a/docs/6.bridge/2.typescript.md b/docs/6.bridge/2.typescript.md index 6529a54010..691c8d1f21 100644 --- a/docs/6.bridge/2.typescript.md +++ b/docs/6.bridge/2.typescript.md @@ -34,11 +34,11 @@ If you are using TypeScript, you can edit your `tsconfig.json` to benefit from a } ``` -::callout +::note As `.nuxt/tsconfig.json` is generated and not checked into version control, you'll need to generate that file before running your tests. Add `nuxi prepare` as a step before your tests, otherwise you'll see `TS5083: Cannot read file '~/.nuxt/tsconfig.json'` :: -::callout +::note Keep in mind that all options extended from `./.nuxt/tsconfig.json` will be overwritten by the options defined in your `tsconfig.json`. Overwriting options such as `"compilerOptions.paths"` with your own configuration will lead TypeScript to not factor in the module resolutions from `./.nuxt/tsconfig.json`. This can lead to module resolutions such as `#imports` not being recognized. diff --git a/docs/6.bridge/4.plugins-and-middleware.md b/docs/6.bridge/4.plugins-and-middleware.md index 2bbaaf2ffa..de59c68649 100644 --- a/docs/6.bridge/4.plugins-and-middleware.md +++ b/docs/6.bridge/4.plugins-and-middleware.md @@ -16,11 +16,11 @@ export default defineNuxtPlugin(nuxtApp => { }) ``` -::callout +::note If you want to use the new Nuxt composables (such as [`useNuxtApp`](/docs/api/composables/use-nuxt-app) or `useRuntimeConfig`) within your plugins, you will need to use the `defineNuxtPlugin` helper for those plugins. :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Although a compatibility interface is provided via `nuxtApp.vueApp` you should avoid registering plugins, directives, mixins or components this way without adding your own logic to ensure they are not installed more than once, or this may cause a memory leak. :: @@ -38,11 +38,28 @@ export default defineNuxtRouteMiddleware((to) => { }) ``` -::callout{color="amber" icon="i-ph-warning-duotone"} +::important Use of `defineNuxtRouteMiddleware` is not supported outside of the middleware directory. :: -::callout -You can also use [`definePageMeta`](https://nuxt.com/docs/api/utils/define-page-meta) in Nuxt Bridge. +## definePageMeta + +You can also use [`definePageMeta`](/docs/api/utils/define-page-meta) in Nuxt Bridge. + +You can be enabled with the `macros.pageMeta` option in your configuration file + +```ts [nuxt.config.ts] +import { defineNuxtConfig } from '@nuxt/bridge' + +export default defineNuxtConfig({ + bridge: { + macros: { + pageMeta: true + } + } +}) +``` + +::note But only for `middleware` and `layout`. :: diff --git a/docs/6.bridge/5.nuxt3-compatible-api.md b/docs/6.bridge/5.nuxt3-compatible-api.md index ce8784c28b..956a556566 100644 --- a/docs/6.bridge/5.nuxt3-compatible-api.md +++ b/docs/6.bridge/5.nuxt3-compatible-api.md @@ -65,7 +65,7 @@ You can access injected helpers using `useNuxtApp`. + const { $axios } = useNuxtApp() ``` -::callout +::note `useNuxtApp()` also provides a key called `nuxt2Context` which contains all the same properties you would normally access from Nuxt 2 context, but it's advised _not_ to use this directly, as it won't exist in Nuxt 3. Instead, see if there is another way to access what you need. (If not, please raise a feature request or discussion.) :: @@ -84,11 +84,11 @@ const wrapProperty = (property, makeComputed = true) => () => { These two composables can be replaced with `useLazyAsyncData` and `useLazyFetch`, which are documented [in the Nuxt 3 docs](/docs/getting-started/data-fetching). Just like the previous `@nuxtjs/composition-api` composables, these composables do not block route navigation on the client-side (hence the 'lazy' part of the name). -::callout +::important Note that the API is entirely different, despite similar sounding names. Importantly, you should not attempt to change the value of other variables outside the composable (as you may have been doing with the previous `useFetch`). :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning The `useLazyFetch` must have been configured for [Nitro](/docs/bridge/nitro). :: @@ -146,7 +146,7 @@ title.value = 'new title' ``` -::callout +::note Be careful not to use both `useNuxt2Meta()` and the Options API `head()` within the same component, as behavior may be unpredictable. :: diff --git a/docs/6.bridge/6.meta.md b/docs/6.bridge/6.meta.md index e15a8b5143..e29ca7995f 100644 --- a/docs/6.bridge/6.meta.md +++ b/docs/6.bridge/6.meta.md @@ -69,11 +69,11 @@ useHead({ ``` -::callout +::tip This [`useHead`](/docs/api/composables/use-head) composable uses `@unhead/vue` under the hood (rather than `vue-meta`) to manipulate your ``. :: -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning We recommend not using the native Nuxt 2 `head()` properties in addition to [`useHead`](/docs/api/composables/use-head) , as they may conflict. :: @@ -98,6 +98,10 @@ export default defineNuxtComponent({ ``` +::warning +Possible breaking change: `head` receives the nuxt app but cannot access the component instance. If the code in your `head` tries to access the data object through `this` or `this.$data`, you will need to migrate to the `useHead` composable. +:: + ## Title Template If you want to use a function (for full control), then this cannot be set in your nuxt.config, and it is recommended instead to set it within your `/layouts` directory. diff --git a/docs/6.bridge/7.runtime-config.md b/docs/6.bridge/7.runtime-config.md index 70c0f06579..c94b1890e3 100644 --- a/docs/6.bridge/7.runtime-config.md +++ b/docs/6.bridge/7.runtime-config.md @@ -3,7 +3,7 @@ title: Runtime Config description: 'Nuxt provides a runtime config API to expose configuration and secrets within your application.' --- -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning When using `runtimeConfig` option, [nitro](/docs/bridge/nitro) must have been configured. :: diff --git a/docs/6.bridge/8.nitro.md b/docs/6.bridge/8.nitro.md index e2bd72a322..664ab52427 100644 --- a/docs/6.bridge/8.nitro.md +++ b/docs/6.bridge/8.nitro.md @@ -27,16 +27,16 @@ You will also need to update your scripts within your `package.json` to reflect Install `nuxi` as a development dependency: -::code-group - -```bash [yarn] -yarn add --dev nuxi -``` +::package-managers ```bash [npm] npm install -D nuxi ``` +```bash [yarn] +yarn add --dev nuxi +``` + :: ### Nuxi @@ -56,7 +56,7 @@ Nuxt 3 introduced the new Nuxt CLI command [`nuxi`](/docs/api/commands/add). Upd } ``` -::callout +::tip If `nitro: false`, use the `nuxt2` command. :: diff --git a/docs/6.bridge/9.vite.md b/docs/6.bridge/9.vite.md index cb44570118..3900784529 100644 --- a/docs/6.bridge/9.vite.md +++ b/docs/6.bridge/9.vite.md @@ -3,7 +3,7 @@ title: Vite description: 'Activate Vite to your Nuxt 2 application with Nuxt Bridge.' --- -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning When using `vite`, [nitro](/docs/bridge/nitro) must have been configured. :: diff --git a/docs/6.bridge/_dir.yml b/docs/6.bridge/_dir.yml index f2a37c2daa..f7db65f48d 100644 --- a/docs/6.bridge/_dir.yml +++ b/docs/6.bridge/_dir.yml @@ -1,3 +1,3 @@ titleTemplate: 'Migrate to Nuxt Bridge: %s' title: 'Migrate to Nuxt Bridge' -icon: i-ph-bridge-duotone +icon: i-ph-bridge diff --git a/docs/7.migration/1.overview.md b/docs/7.migration/1.overview.md index cef7bc52e4..168191061b 100644 --- a/docs/7.migration/1.overview.md +++ b/docs/7.migration/1.overview.md @@ -5,7 +5,7 @@ description: Nuxt 3 is a complete rewrite of Nuxt 2, and also based on a new set There are significant changes when migrating a Nuxt 2 app to Nuxt 3, although you can expect migration to become more straightforward as we move toward a stable release. -::callout +::note This migration guide is under progress to align with the development of Nuxt 3. :: @@ -15,7 +15,7 @@ Some of these significant changes include: 1. Moving from webpack 4 and Babel to Vite or webpack 5 and esbuild. 1. Moving from a runtime Nuxt dependency to a minimal, standalone server compiled with nitropack. -::callout +::tip If you need to remain on Nuxt 2, but want to benefit from Nuxt 3 features in Nuxt 2, you can alternatively check out [how to get started with Bridge](/docs/bridge/overview). :: diff --git a/docs/7.migration/2.configuration.md b/docs/7.migration/2.configuration.md index 11d4e31687..13508d7339 100644 --- a/docs/7.migration/2.configuration.md +++ b/docs/7.migration/2.configuration.md @@ -7,7 +7,7 @@ description: 'Learn how to migrate from Nuxt 2 to Nuxt 3 new configuration.' The starting point for your Nuxt app remains your `nuxt.config` file. -::callout +::note Nuxt configuration will be loaded using [`unjs/jiti`](https://github.com/unjs/jiti) and [`unjs/c12`](https://github.com/unjs/c12). :: @@ -57,6 +57,45 @@ Nuxt configuration will be loaded using [`unjs/jiti`](https://github.com/unjs/ji :: +1. If you were using `router.routeNameSplitter` you can achieve same result by updating route name generation logic in the new `pages:extend` hook: + + ::code-group + + ```ts [Nuxt 2] + export default { + router: { + routeNameSplitter: '/' + } + } + ``` + + ```ts [Nuxt 3] + import { createResolver } from '@nuxt/kit' + + export default defineNuxtConfig({ + hooks: { + 'pages:extend' (routes) { + const routeNameSplitter = '/' + const root = createResolver(import.meta.url).resolve('./pages') + + function updateName(routes) { + if (!routes) return + + for (const route of routes) { + const relativePath = route.file.substring(root.length + 1) + route.name = relativePath.slice(0, -4).replace(/\/index$/, '').replace(/\//g, routeNameSplitter) + + updateName(route.children) + } + } + updateName(routes) + }, + }, + }) + ``` + + :: + #### ESM Syntax Nuxt 3 is an [ESM native framework](/docs/guide/concepts/esm). Although [`unjs/jiti`](https://github.com/unjs/jiti) provides semi compatibility when loading `nuxt.config` file, avoid any usage of `require` and `module.exports` in this file. @@ -91,7 +130,7 @@ Nuxt and Nuxt Modules are now build-time-only. }) ``` -::callout +::tip If you are a module author, you can check out [more information about module compatibility](/docs/migration/module-authors) and [our module author guide](/docs/guide/going-further/modules). :: @@ -107,7 +146,7 @@ It will be much easier to migrate your application if you use Nuxt's TypeScript You can read more about Nuxt's TypeScript support [in the docs](/docs/guide/concepts/typescript). -::callout +::note Nuxt can type-check your app using [`vue-tsc`](https://github.com/vuejs/language-tools/tree/master/packages/tsc) with `nuxi typecheck` command. :: diff --git a/docs/7.migration/20.module-authors.md b/docs/7.migration/20.module-authors.md index a45545487e..abe8fc67c1 100644 --- a/docs/7.migration/20.module-authors.md +++ b/docs/7.migration/20.module-authors.md @@ -9,7 +9,7 @@ Nuxt 3 has a basic backward compatibility layer for Nuxt 2 modules using `@nuxt/ We have prepared a [Dedicated Guide](/docs/guide/going-further/modules) for authoring Nuxt 3 ready modules using `@nuxt/kit`. Currently best migration path is to follow it and rewrite your modules. Rest of this guide includes preparation steps if you prefer to avoid a full rewrite yet making modules compatible with Nuxt 3. -::callout{icon="i-ph-puzzle-piece-duotone" to="/modules"} +::tip{icon="i-ph-puzzle-piece" to="/modules"} Explore Nuxt 3 compatible modules. :: @@ -77,7 +77,7 @@ Your module should work even if it's only added to [`buildModules`](/docs/api/nu (*) Unless it is for `nuxt dev` purpose only and guarded with `if (nuxt.options.dev) { }`. -::callout +::tip Continue reading about Nuxt 3 modules in the [Modules Author Guide](/docs/guide/going-further/modules). :: @@ -85,10 +85,10 @@ Continue reading about Nuxt 3 modules in the [Modules Author Guide](/docs/guide/ While it is not essential, most of the Nuxt ecosystem is shifting to use TypeScript, so it is highly recommended to consider migration. -::callout +::tip You can start migration by renaming `.js` files, to `.ts`. TypeScript is designed to be progressive! :: -::callout +::tip You can use TypeScript syntax for Nuxt 2 and 3 modules and plugins without any extra dependencies. :: diff --git a/docs/7.migration/3.auto-imports.md b/docs/7.migration/3.auto-imports.md index ddfb19208f..b0ca8ff3f4 100644 --- a/docs/7.migration/3.auto-imports.md +++ b/docs/7.migration/3.auto-imports.md @@ -3,7 +3,7 @@ title: Auto Imports description: Nuxt 3 adopts a minimal friction approach, meaning wherever possible components and composables are auto-imported. --- -::callout +::note In the rest of the migration documentation, you will notice that key Nuxt and Vue utilities do not have explicit imports. This is not a typo; Nuxt will automatically import them for you, and you should get full type hinting if you have followed [the instructions](/docs/migration/configuration#typescript) to use Nuxt's TypeScript support. :: @@ -13,6 +13,6 @@ In the rest of the migration documentation, you will notice that key Nuxt and Vu 1. If you have been using `@nuxt/components` in Nuxt 2, you can remove `components: true` in your `nuxt.config`. If you had a more complex setup, then note that the component options have changed somewhat. See the [components documentation](/docs/guide/directory-structure/components) for more information. -::callout +::tip You can look at `.nuxt/types/components.d.ts` and `.nuxt/types/imports.d.ts` to see how Nuxt has resolved your components and composable auto-imports. :: diff --git a/docs/7.migration/4.meta.md b/docs/7.migration/4.meta.md index dfda62461f..1813c850b7 100644 --- a/docs/7.migration/4.meta.md +++ b/docs/7.migration/4.meta.md @@ -10,7 +10,7 @@ Nuxt 3 provides several different ways to manage your meta tags: You can customize `title`, `titleTemplate`, `base`, `script`, `noscript`, `style`, `meta`, `link`, `htmlAttrs` and `bodyAttrs`. -::callout +::tip Nuxt currently uses [`vueuse/head`](https://github.com/vueuse/head) to manage your meta tags, but implementation details may change. :: @@ -102,7 +102,7 @@ export default { :: -::callout +::important 1. Make sure you use capital letters for these component names to distinguish them from native HTML elements (`` rather than `<title>`). 2. You can place these components anywhere in your template for your page. :: diff --git a/docs/7.migration/6.pages-and-layouts.md b/docs/7.migration/6.pages-and-layouts.md index 73d1c1cbb8..0baf3dd849 100644 --- a/docs/7.migration/6.pages-and-layouts.md +++ b/docs/7.migration/6.pages-and-layouts.md @@ -7,7 +7,7 @@ description: Learn how to migrate from Nuxt 2 to Nuxt 3 pages and layouts. Nuxt 3 provides a central entry point to your app via `~/app.vue`. -::callout +::note If you don't have an `app.vue` file in your source directory, Nuxt will use its own default version. :: @@ -191,7 +191,7 @@ Most of the syntax and functionality are the same for the global [NuxtLink](/doc When migrating from Nuxt 2 to Nuxt 3, you will have to update how you programmatically navigate your users. In Nuxt 2, you had access to the underlying Vue Router with `this.$router`. In Nuxt 3, you can use the `navigateTo()` utility method which allows you to pass a route and parameters to Vue Router. -::callout{color="amber" icon="i-ph-warning-duotone"} +::warning Ensure to always `await` on [`navigateTo`](/docs/api/utils/navigate-to) or chain its result by returning from functions. :: diff --git a/docs/7.migration/7.component-options.md b/docs/7.migration/7.component-options.md index 245e42fd43..ac710b0a63 100644 --- a/docs/7.migration/7.component-options.md +++ b/docs/7.migration/7.component-options.md @@ -56,7 +56,7 @@ const { data: post, refresh } = await useFetch(`https://api.nuxtjs.dev/posts/${p You can now use `post` inside of your Nuxt 3 template, or call `refresh` to update the data. -::callout +::note Despite the names, [`useFetch`](/docs/api/composables/use-fetch) is not a direct replacement of the `fetch()` hook. Rather, [`useAsyncData`](/docs/api/composables/use-async-data) replaces both hooks and is more customizable; it can do more than simply fetching data from an endpoint. [`useFetch`](/docs/api/composables/use-fetch) is a convenience wrapper around [`useAsyncData`](/docs/api/composables/use-async-data) for simply fetching data from an endpoint. :: @@ -103,7 +103,7 @@ This feature is not yet supported in Nuxt 3. ## `scrollToTop` -This feature is not yet supported in Nuxt 3. If you want to overwrite the default scroll behavior of `vue-router`, you can do so in `~/app/router.options.ts` (see [docs](/docs/guide/going-further/custom-routing#router-options)) for more info. +This feature is not yet supported in Nuxt 3. If you want to overwrite the default scroll behavior of `vue-router`, you can do so in `~/app/router.options.ts` (see [docs](/docs/guide/recipes/custom-routing#router-options)) for more info. Similar to `key`, specify it within the [`definePageMeta`](/docs/api/utils/define-page-meta) compiler macro. ```diff [pages/index.vue] diff --git a/docs/7.migration/_dir.yml b/docs/7.migration/_dir.yml index 54585df393..a880111684 100644 --- a/docs/7.migration/_dir.yml +++ b/docs/7.migration/_dir.yml @@ -1,3 +1,3 @@ titleTemplate: 'Migrate to Nuxt 3: %s' title: 'Migrate to Nuxt 3' -icon: i-ph-arrow-circle-up-duotone +icon: i-ph-arrow-circle-up diff --git a/docs/_dir.yml b/docs/_dir.yml index 6639eb3a35..18fdf7dc26 100644 --- a/docs/_dir.yml +++ b/docs/_dir.yml @@ -1,2 +1,2 @@ title: Docs -icon: i-ph-book-bookmark-duotone +icon: i-ph-book-bookmark diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000..a6ad15a72e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,213 @@ +// @ts-check +import { createConfigForNuxt } from '@nuxt/eslint-config/flat' +// @ts-expect-error missing types +import noOnlyTests from 'eslint-plugin-no-only-tests' +import typegen from 'eslint-typegen' +import perfectionist from 'eslint-plugin-perfectionist' + +export default createConfigForNuxt({ + features: { + stylistic: { + commaDangle: 'always-multiline', + }, + tooling: true, + }, +}) + .prepend( + { + // Ignores have to be a separate object to be treated as global ignores + // Don't add other attributes to this object + ignores: [ + 'packages/schema/schema/**', + 'packages/nuxt/src/app/components/welcome.vue', + 'packages/nuxt/src/app/components/error-*.vue', + 'packages/nuxt/src/core/runtime/nitro/error-*', + ], + }, + { + languageOptions: { + globals: { + $fetch: 'readonly', + NodeJS: 'readonly', + }, + }, + name: 'local/settings', + settings: { + jsdoc: { + ignoreInternal: true, + tagNamePreference: { + note: 'note', + warning: 'warning', + }, + }, + }, + }, + ) + + .override('nuxt/javascript', { + rules: { + 'curly': ['error', 'all'], // Including if blocks with a single statement + 'dot-notation': 'error', + 'no-console': ['warn', { allow: ['warn', 'error', 'debug'] }], + 'no-lonely-if': 'error', // No single if in an "else" block + 'no-useless-rename': 'error', + 'object-shorthand': 'error', + 'prefer-const': ['error', { destructuring: 'any', ignoreReadBeforeAssign: false }], + 'require-await': 'error', + 'sort-imports': ['error', { ignoreDeclarationSort: true }], + }, + }) + + .override('nuxt/typescript/rules', { + rules: { + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-expect-error': 'allow-with-description', + 'ts-ignore': true, + }, + ], + '@typescript-eslint/no-dynamic-delete': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/triple-slash-reference': 'off', + '@typescript-eslint/unified-signatures': 'off', + ...{ + // TODO: Discuss if we want to enable this + '@typescript-eslint/ban-types': 'off', + // TODO: Discuss if we want to enable this + '@typescript-eslint/no-explicit-any': 'off', + // TODO: Discuss if we want to enable this + '@typescript-eslint/no-invalid-void-type': 'off', + }, + }, + }) + + .override('nuxt/tooling/unicorn', { + rules: { + 'unicorn/no-new-array': 'off', + 'unicorn/prefer-dom-node-text-content': 'off', + }, + }) + + .override('nuxt/vue/rules', { + rules: { + + }, + }) + + // Stylistic rules + .override('nuxt/stylistic', { + rules: { + '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], + '@stylistic/indent-binary-ops': 'off', + '@stylistic/max-statements-per-line': 'off', + '@stylistic/operator-linebreak': 'off', + '@stylistic/quote-props': ['error', 'consistent'], + '@stylistic/space-before-function-paren': ['error', 'always'], + }, + }) + + // Append local rules + .append( + { + files: ['**/*.vue', '**/*.ts', '**/*.mts', '**/*.js', '**/*.cjs', '**/*.mjs'], + name: 'local/rules', + rules: { + 'import/no-restricted-paths': [ + 'error', + { + zones: [ + { + from: 'packages/nuxt/src/!(core)/**/*', + message: 'core should not directly import from modules.', + target: 'packages/nuxt/src/core', + }, + { + from: 'packages/nuxt/src/!(app)/**/*', + message: 'app should not directly import from modules.', + target: 'packages/nuxt/src/app', + }, + { + from: 'packages/nuxt/src/app/**/index.ts', + message: 'should not import from barrel/index files', + target: 'packages/nuxt/src', + }, + { + from: 'packages/nitro', + message: 'nitro should not directly import other packages.', + target: 'packages/!(nitro)/**/*', + }, + ], + }, + ], + 'jsdoc/check-tag-names': [ + 'error', + { + definedTags: [ + 'experimental', + '__NO_SIDE_EFFECTS__', + ], + }, + ], + }, + }, + { + files: ['packages/nuxt/src/app/**', 'test/**', '**/runtime/**', '**/*.test.ts'], + name: 'local/disables/client-console', + rules: { + 'no-console': 'off', + }, + }, + { + files: ['**/fixtures/**', '**/fixture/**'], + name: 'local/disables/fixtures', + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/triple-slash-reference': 'off', + 'vue/multi-word-component-names': 'off', + 'vue/valid-v-for': 'off', + }, + }, + { + files: ['test/**', '**/*.test.ts'], + name: 'local/disables/tests', + plugins: { + 'no-only-tests': noOnlyTests, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + 'no-only-tests/no-only-tests': 'error', + }, + }, + // Sort rule keys in eslint config + // @ts-expect-error incorrect types πŸ€” + { + files: ['**/eslint.config.mjs'], + name: 'local/sort-eslint-config', + plugins: { + perfectionist, + }, + rules: { + 'perfectionist/sort-objects': 'error', + }, + }, + { + files: ['packages/nuxt/src/app/components/welcome.vue'], + rules: { + 'vue/multi-word-component-names': 'off', + }, + }, + ) + + // Generate type definitions for the eslint config + .onResolved((configs) => { + return typegen(configs) + }) diff --git a/examples/README.md b/examples/README.md index ba22dfb66e..da61500f7c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,4 @@ -# Nuxt 3 Examples +# Nuxt Examples - πŸ‘‰ See examples in your browser at https://nuxt.com/docs/examples - πŸ‘‰ View on GitHub at https://github.com/nuxt/examples diff --git a/knip.json b/knip.json index 2ce29ce41d..dd2512b5cd 100644 --- a/knip.json +++ b/knip.json @@ -1,9 +1,11 @@ { - "$schema": "https://unpkg.com/knip@2/schema.json", + "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { ".": { "entry": [ - "scripts/*" + "scripts/*", + "test/*", + "test/fixtures/*" ] }, "packages/*": { diff --git a/lychee.toml b/lychee.toml index 22f73e611a..b00e5bee37 100644 --- a/lychee.toml +++ b/lychee.toml @@ -9,7 +9,12 @@ max_retries = 6 # Explicitly exclude some URLs exclude = [ "https://twitter.nuxt.dev/", - "https://github.com/nuxt/nuxt.com", "https://github.com/nuxt/translations/discussions/4", - '(https?:\/\/github\.com\/)(.*\/)(generate)' + "https://stackoverflow.com/help/minimal-reproducible-example", + # TODO: remove when their SSL certificate is valid again + "https://www.conventionalcommits.org", + # single-quotes are required for regexp + '(https?:\/\/github\.com\/)(.*\/)(generate)', + "https://localhost:3000", + "https://github.com/nuxt-contrib/vue3-ssr-starter/generate", ] diff --git a/nuxt.config.ts b/nuxt.config.ts index db9349c8fc..54f547026e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,4 +1,21 @@ // For pnpm typecheck:docs to generate correct types + +import { addPluginTemplate, addRouteMiddleware } from 'nuxt/kit' + export default defineNuxtConfig({ - pages: process.env.DOCS_TYPECHECK === 'true' + typescript: { shim: process.env.DOCS_TYPECHECK === 'true' }, + pages: process.env.DOCS_TYPECHECK === 'true', + modules: [ + function () { + if (!process.env.DOCS_TYPECHECK) { return } + addPluginTemplate({ + filename: 'plugins/my-plugin.mjs', + getContents: () => 'export default defineNuxtPlugin({ name: \'my-plugin\' })', + }) + addRouteMiddleware({ + name: 'auth', + path: '#build/auth.js', + }) + }, + ], }) diff --git a/package.json b/package.json index 34b1824408..e6abff5817 100644 --- a/package.json +++ b/package.json @@ -12,87 +12,107 @@ "build:stub": "pnpm dev:prepare", "cleanup": "rimraf 'packages/**/node_modules' 'playground/node_modules' 'node_modules'", "dev": "pnpm play", - "dev:prepare": "pnpm --filter './packages/**' prepack --stub", - "lint": "eslint --ext .vue,.ts,.js,.mjs .", - "lint:fix": "eslint --ext .vue,.ts,.js,.mjs . --fix", + "dev:prepare": "pnpm --filter './packages/**' prepack --stub && pnpm --filter './packages/ui-templates' build", + "lint": "eslint . --cache", + "lint:fix": "eslint . --cache --fix", "lint:docs": "markdownlint ./docs && case-police 'docs/**/*.md' *.md", "lint:docs:fix": "markdownlint ./docs --fix && case-police 'docs/**/*.md' *.md --fix", "lint:knip": "pnpx knip", "play": "nuxi dev playground", "play:build": "nuxi build playground", + "play:generate": "nuxi generate playground", "play:preview": "nuxi preview playground", "test": "pnpm test:fixtures && pnpm test:fixtures:dev && pnpm test:fixtures:webpack && pnpm test:unit && pnpm test:runtime && pnpm test:types && pnpm typecheck", "test:prepare": "jiti ./test/prepare.ts", "test:fixtures": "pnpm test:prepare && vitest run --dir test", "test:fixtures:dev": "TEST_ENV=dev pnpm test:fixtures", "test:fixtures:webpack": "TEST_BUILDER=webpack pnpm test:fixtures", - "test:runtime": "vitest -c vitest.nuxt.config.ts --coverage", + "test:runtime": "vitest -c vitest.nuxt.config.ts", "test:types": "pnpm --filter './test/fixtures/**' test:types", - "test:unit": "vitest run packages/ --coverage", + "test:unit": "vitest run packages/", "typecheck": "tsc --noEmit", - "typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs" + "typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs --languages html" }, "resolutions": { "@nuxt/kit": "workspace:*", "@nuxt/schema": "workspace:*", + "@nuxt/ui-templates": "workspace:*", "@nuxt/vite-builder": "workspace:*", "@nuxt/webpack-builder": "workspace:*", - "rollup": "^4.12.0", + "@types/node": "20.16.11", + "@vue/compiler-core": "3.5.11", + "@vue/compiler-dom": "3.5.11", + "@vue/shared": "3.5.11", + "c12": "2.0.1", + "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", + "jiti": "2.3.3", + "magic-string": "^0.30.11", + "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", "nuxt": "workspace:*", - "vite": "5.1.3", - "vue": "3.4.19", - "magic-string": "^0.30.7" + "ohash": "1.1.4", + "postcss": "8.4.47", + "rollup": "4.24.0", + "send": ">=0.19.0", + "typescript": "5.6.2", + "ufo": "1.5.4", + "unbuild": "3.0.0-rc.11", + "vite": "5.4.8", + "vue": "3.5.11" }, "devDependencies": { - "@codspeed/vitest-plugin": "3.1.0", - "@nuxt/eslint-config": "0.2.0", + "@eslint/js": "9.12.0", + "@nuxt/eslint-config": "0.5.7", "@nuxt/kit": "workspace:*", - "@nuxt/test-utils": "3.11.0", + "@nuxt/test-utils": "3.14.3", "@nuxt/webpack-builder": "workspace:*", - "@testing-library/vue": "8.0.2", - "@types/fs-extra": "11.0.4", - "@types/node": "20.11.19", - "@types/semver": "7.5.7", - "@vitest/coverage-v8": "1.3.0", - "@vue/test-utils": "2.4.4", - "case-police": "0.6.1", - "changelogen": "0.5.5", + "@testing-library/vue": "8.1.0", + "@types/eslint__js": "8.42.3", + "@types/node": "20.16.11", + "@types/semver": "7.5.8", + "@unhead/schema": "1.11.7", + "@unhead/vue": "1.11.7", + "@vitejs/plugin-vue": "5.1.4", + "@vitest/coverage-v8": "2.1.2", + "@vue/test-utils": "2.4.6", + "autoprefixer": "10.4.20", + "case-police": "0.7.0", + "changelogen": "0.5.7", "consola": "3.2.3", - "devalue": "4.3.2", - "eslint": "8.56.0", - "eslint-plugin-import": "2.29.1", - "eslint-plugin-jsdoc": "48.1.0", - "eslint-plugin-no-only-tests": "3.1.0", - "eslint-plugin-unicorn": "51.0.1", - "execa": "8.0.1", - "fs-extra": "11.2.0", - "globby": "14.0.1", - "h3": "1.10.1", - "happy-dom": "13.3.8", - "jiti": "1.21.0", - "markdownlint-cli": "0.39.0", - "nitropack": "2.8.1", - "nuxi": "3.10.1", + "cssnano": "7.0.6", + "destr": "2.0.3", + "devalue": "5.1.1", + "eslint": "9.12.0", + "eslint-plugin-no-only-tests": "3.3.0", + "eslint-plugin-perfectionist": "3.8.0", + "eslint-typegen": "0.3.2", + "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", + "happy-dom": "15.7.4", + "jiti": "2.3.3", + "markdownlint-cli": "0.42.0", + "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", + "nuxi": "3.14.0", "nuxt": "workspace:*", - "nuxt-content-twoslash": "0.0.8", - "ofetch": "1.3.3", + "nuxt-content-twoslash": "0.1.1", + "ofetch": "1.4.0", "pathe": "1.1.2", - "playwright-core": "1.41.2", - "rimraf": "5.0.5", - "semver": "7.6.0", + "playwright-core": "1.48.0", + "rimraf": "6.0.1", + "semver": "7.6.3", + "sherif": "1.0.0", "std-env": "3.7.0", - "typescript": "5.3.3", - "ufo": "1.4.0", - "vitest": "1.3.0", - "vitest-environment-nuxt": "1.0.0", - "vue": "3.4.19", - "vue-eslint-parser": "9.4.2", - "vue-router": "4.2.5", - "vue-tsc": "1.8.27" + "tinyexec": "0.3.0", + "tinyglobby": "0.2.9", + "typescript": "5.6.2", + "ufo": "1.5.4", + "vitest": "2.1.2", + "vitest-environment-nuxt": "1.0.1", + "vue": "3.5.11", + "vue-router": "4.4.5", + "vue-tsc": "2.1.6" }, - "packageManager": "pnpm@8.15.3", + "packageManager": "pnpm@9.12.1", "engines": { - "node": "^14.18.0 || >=16.10.0" + "node": "^16.10.0 || >=18.0.0" }, "version": "" } diff --git a/packages/kit/build.config.ts b/packages/kit/build.config.ts index a2f84767f8..87a8ccb072 100644 --- a/packages/kit/build.config.ts +++ b/packages/kit/build.config.ts @@ -3,14 +3,14 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ declaration: true, entries: [ - 'src/index' + 'src/index', ], externals: [ '@nuxt/schema', 'nitropack', + 'nitro', 'webpack', 'vite', - 'h3' + 'h3', ], - failOnWarn: false }) diff --git a/packages/kit/index.d.ts b/packages/kit/index.d.ts deleted file mode 100644 index 43b479fd69..0000000000 --- a/packages/kit/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable no-var */ -declare global { - var __NUXT_PREPATHS__: string[] | string | undefined - var __NUXT_PATHS__: string[] | string | undefined -} - -export {} diff --git a/packages/kit/package.json b/packages/kit/package.json index 1298c5fb51..f1143c8439 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -1,6 +1,6 @@ { "name": "@nuxt/kit", - "version": "3.10.2", + "version": "3.12.2", "repository": { "type": "git", "url": "git+https://github.com/nuxt/nuxt.git", @@ -27,34 +27,34 @@ }, "dependencies": { "@nuxt/schema": "workspace:*", - "c12": "^1.8.0", + "c12": "^2.0.1", "consola": "^3.2.3", "defu": "^6.1.4", - "globby": "^14.0.1", + "destr": "^2.0.3", + "errx": "^0.1.0", + "globby": "^14.0.2", "hash-sum": "^2.0.0", - "ignore": "^5.3.1", - "jiti": "^1.21.0", - "knitwork": "^1.0.0", - "mlly": "^1.5.0", + "ignore": "^6.0.2", + "jiti": "^2.3.3", + "klona": "^2.0.6", + "mlly": "^1.7.2", "pathe": "^1.1.2", - "pkg-types": "^1.0.3", + "pkg-types": "^1.2.1", "scule": "^1.3.0", - "semver": "^7.6.0", - "ufo": "^1.4.0", + "semver": "^7.6.3", + "ufo": "^1.5.4", "unctx": "^2.3.1", - "unimport": "^3.7.1", - "untyped": "^1.4.2" + "unimport": "^3.13.1", + "untyped": "^1.5.1" }, "devDependencies": { "@types/hash-sum": "1.0.2", - "@types/lodash-es": "4.17.12", - "@types/semver": "7.5.7", - "lodash-es": "4.17.21", - "nitropack": "2.8.1", - "unbuild": "latest", - "vite": "5.1.3", - "vitest": "1.3.0", - "webpack": "5.90.2" + "@types/semver": "7.5.8", + "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", + "unbuild": "3.0.0-rc.11", + "vite": "5.4.8", + "vitest": "2.1.2", + "webpack": "5.95.0" }, "engines": { "node": "^14.18.0 || >=16.10.0" diff --git a/packages/kit/src/build.ts b/packages/kit/src/build.ts index 92c066eb2d..fab8c45228 100644 --- a/packages/kit/src/build.ts +++ b/packages/kit/src/build.ts @@ -30,8 +30,10 @@ export interface ExtendConfigOptions { prepend?: boolean } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ExtendWebpackConfigOptions extends ExtendConfigOptions {} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ExtendViteConfigOptions extends ExtendConfigOptions {} /** @@ -42,7 +44,7 @@ export interface ExtendViteConfigOptions extends ExtendConfigOptions {} */ export function extendWebpackConfig ( fn: ((config: WebpackConfig) => void), - options: ExtendWebpackConfigOptions = {} + options: ExtendWebpackConfigOptions = {}, ) { const nuxt = useNuxt() @@ -74,7 +76,7 @@ export function extendWebpackConfig ( */ export function extendViteConfig ( fn: ((config: ViteConfig) => void), - options: ExtendViteConfigOptions = {} + options: ExtendViteConfigOptions = {}, ) { const nuxt = useNuxt() diff --git a/packages/kit/src/compatibility.ts b/packages/kit/src/compatibility.ts index d1d7e50fda..00ad74b67c 100644 --- a/packages/kit/src/compatibility.ts +++ b/packages/kit/src/compatibility.ts @@ -1,9 +1,15 @@ import satisfies from 'semver/functions/satisfies.js' // npm/node-semver#381 +import { readPackageJSON } from 'pkg-types' import type { Nuxt, NuxtCompatibility, NuxtCompatibilityIssues } from '@nuxt/schema' import { useNuxt } from './context' export function normalizeSemanticVersion (version: string) { - return version.replace(/-[0-9]+\.[0-9a-f]+/, '') // Remove edge prefix + return version.replace(/-\d+\.[0-9a-f]+/, '') // Remove edge prefix +} + +const builderMap = { + '@nuxt/vite-builder': 'vite', + '@nuxt/webpack-builder': 'webpack', } /** @@ -18,25 +24,30 @@ export async function checkNuxtCompatibility (constraints: NuxtCompatibility, nu if (!satisfies(normalizeSemanticVersion(nuxtVersion), constraints.nuxt, { includePrerelease: true })) { issues.push({ name: 'nuxt', - message: `Nuxt version \`${constraints.nuxt}\` is required but currently using \`${nuxtVersion}\`` + message: `Nuxt version \`${constraints.nuxt}\` is required but currently using \`${nuxtVersion}\``, }) } } - // Bridge compatibility check - if (isNuxt2(nuxt)) { - const bridgeRequirement = constraints.bridge - const hasBridge = !!(nuxt.options as any).bridge - if (bridgeRequirement === true && !hasBridge) { - issues.push({ - name: 'bridge', - message: 'Nuxt bridge is required' - }) - } else if (bridgeRequirement === false && hasBridge) { - issues.push({ - name: 'bridge', - message: 'Nuxt bridge is not supported' - }) + // Builder compatibility check + if (constraints.builder && typeof nuxt.options.builder === 'string') { + const currentBuilder = builderMap[nuxt.options.builder] || nuxt.options.builder + if (currentBuilder in constraints.builder) { + const constraint = constraints.builder[currentBuilder]! + if (constraint === false) { + issues.push({ + name: 'builder', + message: `Not compatible with \`${nuxt.options.builder}\`.`, + }) + } else { + const builderVersion = await readPackageJSON(nuxt.options.builder, { url: nuxt.options.modulesDir }).then(r => r.version).catch(() => undefined) + if (builderVersion && !satisfies(normalizeSemanticVersion(builderVersion), constraint, { includePrerelease: true })) { + issues.push({ + name: 'builder', + message: `Not compatible with \`${builderVersion}\` of \`${currentBuilder}\`. This module requires \`${constraint}\`.`, + }) + } + } } } @@ -70,28 +81,35 @@ export async function hasNuxtCompatibility (constraints: NuxtCompatibility, nuxt } /** - * Check if current nuxt instance is version 2 legacy + * Check if current Nuxt instance is of specified major version */ -export function isNuxt2 (nuxt: Nuxt = useNuxt()) { +export function isNuxtMajorVersion (majorVersion: 2 | 3 | 4, nuxt: Nuxt = useNuxt()) { const version = getNuxtVersion(nuxt) - return version[0] === '2' && version[1] === '.' + + return version[0] === majorVersion.toString() && version[1] === '.' } /** - * Check if current nuxt instance is version 3 + * @deprecated Use `isNuxtMajorVersion(2, nuxt)` instead. This may be removed in \@nuxt/kit v5 or a future major version. + */ +export function isNuxt2 (nuxt: Nuxt = useNuxt()) { + return isNuxtMajorVersion(2, nuxt) +} + +/** + * @deprecated Use `isNuxtMajorVersion(3, nuxt)` instead. This may be removed in \@nuxt/kit v5 or a future major version. */ export function isNuxt3 (nuxt: Nuxt = useNuxt()) { - const version = getNuxtVersion(nuxt) - return version[0] === '3' && version[1] === '.' + return isNuxtMajorVersion(3, nuxt) } /** * Get nuxt version */ export function getNuxtVersion (nuxt: Nuxt | any = useNuxt() /* TODO: LegacyNuxt */) { - const version = (nuxt?._version || nuxt?.version || nuxt?.constructor?.version || '').replace(/^v/g, '') - if (!version) { - throw new Error('Cannot determine nuxt version! Is current instance passed?') + const rawVersion = nuxt?._version || nuxt?.version || nuxt?.constructor?.version + if (typeof rawVersion !== 'string') { + throw new TypeError('Cannot determine nuxt version! Is current instance passed?') } - return version + return rawVersion.replace(/^v/g, '') } diff --git a/packages/kit/src/components.ts b/packages/kit/src/components.ts index 047da47f91..669d6ce410 100644 --- a/packages/kit/src/components.ts +++ b/packages/kit/src/components.ts @@ -6,8 +6,6 @@ import { logger } from './logger' /** * Register a directory to be scanned for components and imported only when used. - * - * Requires Nuxt 2.13+ */ export async function addComponentsDir (dir: ComponentsDir, opts: { prepend?: boolean } = {}) { const nuxt = useNuxt() @@ -23,8 +21,6 @@ export type AddComponentOptions = { name: string, filePath: string } & Partial<E /** * Register a component by its name and filePath. - * - * Requires Nuxt 2.13+ */ export async function addComponent (opts: AddComponentOptions) { const nuxt = useNuxt() @@ -48,13 +44,14 @@ export async function addComponent (opts: AddComponentOptions) { mode: 'all', shortPath: opts.filePath, priority: 0, - ...opts + meta: {}, + ...opts, } nuxt.hook('components:extend', (components: Component[]) => { const existingComponentIndex = components.findIndex(c => (c.pascalName === component.pascalName || c.kebabName === component.kebabName) && c.mode === component.mode) if (existingComponentIndex !== -1) { - const existingComponent = components[existingComponentIndex] + const existingComponent = components[existingComponentIndex]! const existingPriority = existingComponent.priority ?? 0 const newPriority = component.priority ?? 0 diff --git a/packages/kit/src/ignore.test.ts b/packages/kit/src/ignore.test.ts index a37b65c82c..adc100a594 100644 --- a/packages/kit/src/ignore.test.ts +++ b/packages/kit/src/ignore.test.ts @@ -1,11 +1,23 @@ -import { describe, expect, it } from 'vitest' -import { resolveGroupSyntax } from './ignore.js' +import { describe, expect, it, vi } from 'vitest' +import type { Nuxt, NuxtOptions } from '@nuxt/schema' +import { isIgnored, resolveGroupSyntax, resolveIgnorePatterns } from './ignore.js' +import * as context from './context.js' + +describe('isIgnored', () => { + it('should populate _ignore', () => { + const mockNuxt = { options: { ignore: ['my-dir'] } as NuxtOptions } as Nuxt + vi.spyOn(context, 'tryUseNuxt').mockReturnValue(mockNuxt) + + expect(isIgnored('my-dir/my-file.ts')).toBe(true) + expect(resolveIgnorePatterns()?.includes('my-dir')).toBe(true) + }) +}) describe('resolveGroupSyntax', () => { it('should resolve single group syntax', () => { expect(resolveGroupSyntax('**/*.{spec}.{js,ts}')).toStrictEqual([ '**/*.spec.js', - '**/*.spec.ts' + '**/*.spec.ts', ]) }) @@ -14,13 +26,13 @@ describe('resolveGroupSyntax', () => { '**/*.spec.js', '**/*.spec.ts', '**/*.test.js', - '**/*.test.ts' + '**/*.test.ts', ]) }) it('should do nothing with normal globs', () => { expect(resolveGroupSyntax('**/*.spec.js')).toStrictEqual([ - '**/*.spec.js' + '**/*.spec.js', ]) }) }) diff --git a/packages/kit/src/ignore.ts b/packages/kit/src/ignore.ts index bc93e8cd7a..dc18508a06 100644 --- a/packages/kit/src/ignore.ts +++ b/packages/kit/src/ignore.ts @@ -28,6 +28,8 @@ export function isIgnored (pathname: string): boolean { return !!(relativePath && nuxt._ignore.ignores(relativePath)) } +const NEGATION_RE = /^(!?)(.*)$/ + export function resolveIgnorePatterns (relativePath?: string): string[] { const nuxt = tryUseNuxt() @@ -36,22 +38,26 @@ export function resolveIgnorePatterns (relativePath?: string): string[] { return [] } - if (!nuxt._ignorePatterns) { - nuxt._ignorePatterns = nuxt.options.ignore.flatMap(s => resolveGroupSyntax(s)) + const ignorePatterns = nuxt.options.ignore.flatMap(s => resolveGroupSyntax(s)) - const nuxtignoreFile = join(nuxt.options.rootDir, '.nuxtignore') - if (existsSync(nuxtignoreFile)) { - const contents = readFileSync(nuxtignoreFile, 'utf-8') - nuxt._ignorePatterns.push(...contents.trim().split(/\r?\n/)) - } + const nuxtignoreFile = join(nuxt.options.rootDir, '.nuxtignore') + if (existsSync(nuxtignoreFile)) { + const contents = readFileSync(nuxtignoreFile, 'utf-8') + ignorePatterns.push(...contents.trim().split(/\r?\n/)) } if (relativePath) { // Map ignore patterns based on if they start with * or !* - return nuxt._ignorePatterns.map(p => p[0] === '*' || (p[0] === '!' && p[1] === '*') ? p : relative(relativePath, resolve(nuxt.options.rootDir, p))) + return ignorePatterns.map((p) => { + const [_, negation = '', pattern] = p.match(NEGATION_RE) || [] + if (pattern && pattern[0] === '*') { + return p + } + return negation + relative(relativePath, resolve(nuxt.options.rootDir, pattern || p)) + }) } - return nuxt._ignorePatterns + return ignorePatterns } /** @@ -67,7 +73,7 @@ export function resolveGroupSyntax (group: string): string[] { groups = groups.flatMap((group) => { const [head, ...tail] = group.split('{') if (tail.length) { - const [body, ...rest] = tail.join('{').split('}') + const [body = '', ...rest] = tail.join('{').split('}') return body.split(',').map(part => `${head}${part}${rest.join('')}`) } diff --git a/packages/kit/src/imports.ts b/packages/kit/src/imports.ts index 530f4f6f96..4aa9649833 100644 --- a/packages/kit/src/imports.ts +++ b/packages/kit/src/imports.ts @@ -1,20 +1,15 @@ import type { Import } from 'unimport' import type { ImportPresetWithDeprecation } from '@nuxt/schema' import { useNuxt } from './context' -import { assertNuxtCompatibility } from './compatibility' import { toArray } from './utils' export function addImports (imports: Import | Import[]) { - assertNuxtCompatibility({ bridge: true }) - useNuxt().hook('imports:extend', (_imports) => { _imports.push(...toArray(imports)) }) } export function addImportsDir (dirs: string | string[], opts: { prepend?: boolean } = {}) { - assertNuxtCompatibility({ bridge: true }) - useNuxt().hook('imports:dirs', (_dirs: string[]) => { for (const dir of toArray(dirs)) { _dirs[opts.prepend ? 'unshift' : 'push'](dir) @@ -22,8 +17,6 @@ export function addImportsDir (dirs: string | string[], opts: { prepend?: boolea }) } export function addImportsSources (presets: ImportPresetWithDeprecation | ImportPresetWithDeprecation[]) { - assertNuxtCompatibility({ bridge: true }) - useNuxt().hook('imports:sources', (_presets: ImportPresetWithDeprecation[]) => { for (const preset of toArray(presets)) { _presets.push(preset) diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index da2fe614ad..bde038e6fb 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -1,37 +1,36 @@ // Module -export * from './module/define' -export * from './module/install' -export * from './module/compatibility' +export { defineNuxtModule } from './module/define' +export { getDirectory, installModule, loadNuxtModuleInstance, normalizeModuleTranspilePath } from './module/install' +export { getNuxtModuleVersion, hasNuxtModule, hasNuxtModuleCompatibility } from './module/compatibility' // Loader -export * from './loader/config' -export * from './loader/schema' -export * from './loader/nuxt' +export { loadNuxtConfig } from './loader/config' +export type { LoadNuxtConfigOptions } from './loader/config' +export { extendNuxtSchema } from './loader/schema' +export { buildNuxt, loadNuxt } from './loader/nuxt' +export type { LoadNuxtOptions } from './loader/nuxt' // Utils -export * from './imports' -export * from './build' -export * from './compatibility' -export * from './components' -export * from './context' +export { addImports, addImportsDir, addImportsSources } from './imports' +export { updateRuntimeConfig, useRuntimeConfig } from './runtime-config' +export { addBuildPlugin, addVitePlugin, addWebpackPlugin, extendViteConfig, extendWebpackConfig } from './build' +export type { ExtendConfigOptions, ExtendViteConfigOptions, ExtendWebpackConfigOptions } from './build' +export { assertNuxtCompatibility, checkNuxtCompatibility, getNuxtVersion, hasNuxtCompatibility, isNuxtMajorVersion, normalizeSemanticVersion, isNuxt2, isNuxt3 } from './compatibility' +export { addComponent, addComponentsDir } from './components' +export type { AddComponentOptions } from './components' +export { nuxtCtx, tryUseNuxt, useNuxt } from './context' export { isIgnored, resolveIgnorePatterns } from './ignore' -export * from './layout' -export * from './pages' -export * from './plugin' -export * from './resolve' -export * from './nitro' -export * from './template' -export * from './logger' +export { addLayout } from './layout' +export { addRouteMiddleware, extendPages, extendRouteRules } from './pages' +export type { AddRouteMiddlewareOptions, ExtendRouteRulesOptions } from './pages' +export { addPlugin, addPluginTemplate, normalizePlugin } from './plugin' +export type { AddPluginOptions } from './plugin' +export { createResolver, findPath, resolveAlias, resolveFiles, resolveNuxtModule, resolvePath } from './resolve' +export type { ResolvePathOptions, Resolver } from './resolve' +export { addServerHandler, addDevServerHandler, addServerPlugin, addPrerenderRoutes, useNitro, addServerImports, addServerImportsDir, addServerScanDir } from './nitro' +export { addTemplate, addTypeTemplate, normalizeTemplate, updateTemplates, writeTypes } from './template' +export { logger, useLogger } from './logger' // Internal Utils -// TODO -export { - resolveModule, - requireModule, - importModule, - tryImportModule, - tryRequireModule -} from './internal/cjs' -export type { ResolveModuleOptions, RequireModuleOptions } from './internal/cjs' -export { tryResolveModule } from './internal/esm' -export * from './internal/template' +export { resolveModule, tryResolveModule, importModule, tryImportModule, requireModule, tryRequireModule } from './internal/esm' +export type { ImportModuleOptions, ResolveModuleOptions } from './internal/esm' diff --git a/packages/kit/src/internal/cjs.ts b/packages/kit/src/internal/cjs.ts deleted file mode 100644 index 0b9b2d1d2b..0000000000 --- a/packages/kit/src/internal/cjs.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { pathToFileURL } from 'node:url' -import { normalize } from 'pathe' -import { interopDefault } from 'mlly' -import jiti from 'jiti' - -// TODO: use create-require for jest environment -const _require = jiti(process.cwd(), { interopDefault: true, esmResolve: true }) - -/** @deprecated Do not use CJS utils */ -export interface ResolveModuleOptions { - paths?: string | string[] -} - -/** @deprecated Do not use CJS utils */ -export interface RequireModuleOptions extends ResolveModuleOptions { - // TODO: use create-require for jest environment - // native?: boolean - /** Clear the require cache (force fresh require) but only if not within `node_modules` */ - clearCache?: boolean - - /** Automatically de-default the result of requiring the module. */ - interopDefault?: boolean -} - -/** @deprecated Do not use CJS utils */ -function isNodeModules (id: string) { - // TODO: Follow symlinks - return /[/\\]node_modules[/\\]/.test(id) -} - -/** @deprecated Do not use CJS utils */ -function clearRequireCache (id: string) { - if (isNodeModules(id)) { - return - } - - const entry = getRequireCacheItem(id) - - if (!entry) { - delete _require.cache[id] - return - } - - if (entry.parent) { - entry.parent.children = entry.parent.children.filter(e => e.id !== id) - } - - for (const child of entry.children) { - clearRequireCache(child.id) - } - - delete _require.cache[id] -} - -/** @deprecated Do not use CJS utils */ -function getRequireCacheItem (id: string) { - try { - return _require.cache[id] - } catch (e) { - // ignore issues accessing require.cache - } -} - -export function getModulePaths (paths?: string[] | string) { - return ([] as Array<string | undefined>).concat( - global.__NUXT_PREPATHS__, - paths || [], - process.cwd(), - global.__NUXT_PATHS__ - ).filter(Boolean) as string[] -} - -/** @deprecated Do not use CJS utils */ -export function resolveModule (id: string, opts: ResolveModuleOptions = {}) { - return normalize(_require.resolve(id, { - paths: getModulePaths(opts.paths) - })) -} - -/** @deprecated Do not use CJS utils */ -export function requireModule (id: string, opts: RequireModuleOptions = {}) { - // Resolve id - const resolvedPath = resolveModule(id, opts) - - // Clear require cache if necessary - if (opts.clearCache && !isNodeModules(id)) { - clearRequireCache(resolvedPath) - } - - // Try to require - const requiredModule = _require(resolvedPath) - - return requiredModule -} - -/** @deprecated Do not use CJS utils */ -export function importModule (id: string, opts: RequireModuleOptions = {}) { - const resolvedPath = resolveModule(id, opts) - if (opts.interopDefault !== false) { - return import(pathToFileURL(resolvedPath).href).then(interopDefault) - } - return import(pathToFileURL(resolvedPath).href) -} - -/** @deprecated Do not use CJS utils */ -export function tryImportModule (id: string, opts: RequireModuleOptions = {}) { - try { - return importModule(id, opts).catch(() => undefined) - } catch { - // intentionally empty as this is a `try-` function - } -} - -/** @deprecated Do not use CJS utils */ -export function tryRequireModule (id: string, opts: RequireModuleOptions = {}) { - try { - return requireModule(id, opts) - } catch { - // intentionally empty as this is a `try-` function - } -} diff --git a/packages/kit/src/internal/esm.ts b/packages/kit/src/internal/esm.ts index 54e4524a73..dabfa78d15 100644 --- a/packages/kit/src/internal/esm.ts +++ b/packages/kit/src/internal/esm.ts @@ -1,5 +1,11 @@ -import { pathToFileURL } from 'node:url' -import { interopDefault, resolvePath } from 'mlly' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { interopDefault, resolvePath, resolvePathSync } from 'mlly' +import { createJiti } from 'jiti' +import { captureStackTrace } from 'errx' + +export interface ResolveModuleOptions { + paths?: string | string[] +} /** * Resolve a module from a given root path using an algorithm patterned on @@ -15,14 +21,54 @@ export async function tryResolveModule (id: string, url: string | string[] = imp } } -export async function importModule (id: string, url: string | string[] = import.meta.url) { - const resolvedPath = await resolvePath(id, { url }) - return import(pathToFileURL(resolvedPath).href).then(interopDefault) +export function resolveModule (id: string, options?: ResolveModuleOptions) { + return resolvePathSync(id, { url: options?.paths ?? [import.meta.url] }) } -export function tryImportModule (id: string, url = import.meta.url) { +export interface ImportModuleOptions extends ResolveModuleOptions { + /** Automatically de-default the result of requiring the module. */ + interopDefault?: boolean +} + +export async function importModule<T = unknown> (id: string, opts?: ImportModuleOptions) { + const resolvedPath = await resolveModule(id, opts) + return import(pathToFileURL(resolvedPath).href).then(r => opts?.interopDefault !== false ? interopDefault(r) : r) as Promise<T> +} + +export function tryImportModule<T = unknown> (id: string, opts?: ImportModuleOptions) { try { - return importModule(id, url).catch(() => undefined) + return importModule<T>(id, opts).catch(() => undefined) + } catch { + // intentionally empty as this is a `try-` function + } +} + +const warnings = new Set<string>() + +/** + * @deprecated Please use `importModule` instead. + */ +export function requireModule<T = unknown> (id: string, opts?: ImportModuleOptions) { + const { source, line, column } = captureStackTrace().find(entry => entry.source !== import.meta.url) ?? {} + const explanation = source ? ` (used at \`${fileURLToPath(source)}:${line}:${column}\`)` : '' + const warning = `[@nuxt/kit] \`requireModule\` is deprecated${explanation}. Please use \`importModule\` instead.` + if (!warnings.has(warning)) { + console.warn(warning) + warnings.add(warning) + } + const resolvedPath = resolveModule(id, opts) + const jiti = createJiti(import.meta.url, { + interopDefault: opts?.interopDefault !== false, + }) + return jiti(pathToFileURL(resolvedPath).href) as T +} + +/** + * @deprecated Please use `tryImportModule` instead. + */ +export function tryRequireModule<T = unknown> (id: string, opts?: ImportModuleOptions) { + try { + return requireModule<T>(id, opts) } catch { // intentionally empty as this is a `try-` function } diff --git a/packages/kit/src/internal/template.ts b/packages/kit/src/internal/template.ts deleted file mode 100644 index 389e457d1c..0000000000 --- a/packages/kit/src/internal/template.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { promises as fsp } from 'node:fs' -// TODO: swap out when https://github.com/lodash/lodash/pull/5649 is merged -import { template as lodashTemplate } from 'lodash-es' -import { genDynamicImport, genImport, genSafeVariableName } from 'knitwork' - -import type { NuxtTemplate } from '@nuxt/schema' -import { logger } from '../logger' -import { toArray } from '../utils' - -/** @deprecated */ -// TODO: Remove support for compiling ejs templates in v4 -export async function compileTemplate <T>(template: NuxtTemplate<T>, ctx: any) { - const data = { ...ctx, options: template.options } - if (template.src) { - try { - const srcContents = await fsp.readFile(template.src, 'utf-8') - return lodashTemplate(srcContents, {})(data) - } catch (err) { - logger.error('Error compiling template: ', template) - throw err - } - } - if (template.getContents) { - return template.getContents(data) - } - throw new Error('Invalid template: ' + JSON.stringify(template)) -} - -/** @deprecated */ -const serialize = (data: any) => JSON.stringify(data, null, 2).replace(/"{(.+)}"(?=,?$)/gm, r => JSON.parse(r).replace(/^{(.*)}$/, '$1')) - -/** @deprecated */ -const importSources = (sources: string | string[], { lazy = false } = {}) => { - return toArray(sources).map((src) => { - if (lazy) { - return `const ${genSafeVariableName(src)} = ${genDynamicImport(src, { comment: `webpackChunkName: ${JSON.stringify(src)}` })}` - } - return genImport(src, genSafeVariableName(src)) - }).join('\n') -} - -/** @deprecated */ -const importName = genSafeVariableName - -/** @deprecated */ -export const templateUtils = { serialize, importName, importSources } diff --git a/packages/kit/src/layout.ts b/packages/kit/src/layout.ts index 91e50310c4..65fd183163 100644 --- a/packages/kit/src/layout.ts +++ b/packages/kit/src/layout.ts @@ -1,42 +1,26 @@ import type { NuxtTemplate } from '@nuxt/schema' import { join, parse, relative } from 'pathe' import { kebabCase } from 'scule' -import { isNuxt2 } from './compatibility' import { useNuxt } from './context' import { logger } from './logger' import { addTemplate } from './template' -export function addLayout (this: any, template: NuxtTemplate | string, name?: string) { +export function addLayout (template: NuxtTemplate | string, name?: string) { const nuxt = useNuxt() const { filename, src } = addTemplate(template) const layoutName = kebabCase(name || parse(filename).name).replace(/["']/g, '') - if (isNuxt2(nuxt)) { - // Nuxt 2 adds layouts in options - const layout = (nuxt.options as any).layouts[layoutName] - if (layout) { - return logger.warn( - `Not overriding \`${layoutName}\` (provided by \`${layout}\`) with \`${src || filename}\`.` - ) - } - (nuxt.options as any).layouts[layoutName] = `./${filename}` - if (name === 'error') { - this.addErrorLayout(filename) - } - return - } - // Nuxt 3 adds layouts on app nuxt.hook('app:templates', (app) => { if (layoutName in app.layouts) { - const relativePath = relative(nuxt.options.srcDir, app.layouts[layoutName].file) + const relativePath = relative(nuxt.options.srcDir, app.layouts[layoutName]!.file) return logger.warn( - `Not overriding \`${layoutName}\` (provided by \`~/${relativePath}\`) with \`${src || filename}\`.` + `Not overriding \`${layoutName}\` (provided by \`~/${relativePath}\`) with \`${src || filename}\`.`, ) } app.layouts[layoutName] = { file: join('#build', filename), - name: layoutName + name: layoutName, } }) } diff --git a/packages/kit/src/loader/config.ts b/packages/kit/src/loader/config.ts index a4bce4f0c4..80139fb015 100644 --- a/packages/kit/src/loader/config.ts +++ b/packages/kit/src/loader/config.ts @@ -1,23 +1,44 @@ -import { resolve } from 'pathe' +import { existsSync } from 'node:fs' import type { JSValue } from 'untyped' import { applyDefaults } from 'untyped' -import type { LoadConfigOptions } from 'c12' +import type { ConfigLayer, ConfigLayerMeta, LoadConfigOptions } from 'c12' import { loadConfig } from 'c12' import type { NuxtConfig, NuxtOptions } from '@nuxt/schema' import { NuxtConfigSchema } from '@nuxt/schema' +import { globby } from 'globby' +import defu from 'defu' +import { join } from 'pathe' -export interface LoadNuxtConfigOptions extends LoadConfigOptions<NuxtConfig> {} +export interface LoadNuxtConfigOptions extends Omit<LoadConfigOptions<NuxtConfig>, 'overrides'> { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + overrides?: Exclude<LoadConfigOptions<NuxtConfig>['overrides'], Promise<any> | Function> +} + +const layerSchemaKeys = ['future', 'srcDir', 'rootDir', 'serverDir', 'dir'] +const layerSchema = Object.create(null) +for (const key of layerSchemaKeys) { + if (key in NuxtConfigSchema) { + layerSchema[key] = NuxtConfigSchema[key] + } +} export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise<NuxtOptions> { + // Automatically detect and import layers from `~~/layers/` directory + opts.overrides = defu(opts.overrides, { + _extends: await globby('layers/*', { + onlyDirectories: true, + cwd: opts.cwd || process.cwd(), + }), + }); (globalThis as any).defineNuxtConfig = (c: any) => c const result = await loadConfig<NuxtConfig>({ name: 'nuxt', configFile: 'nuxt.config', rcFile: '.nuxtrc', - extend: { extendKey: ['theme', 'extends'] }, + extend: { extendKey: ['theme', 'extends', '_extends'] }, dotenv: true, globalRc: true, - ...opts + ...opts, }) delete (globalThis as any).defineNuxtConfig const { configFile, layers = [], cwd } = result @@ -28,15 +49,30 @@ export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise<Nuxt nuxtConfig._nuxtConfigFile = configFile nuxtConfig._nuxtConfigFiles = [configFile] - // Resolve `rootDir` & `srcDir` of layers - for (const layer of layers) { - layer.config = layer.config || {} - layer.config.rootDir = layer.config.rootDir ?? layer.cwd - layer.config.srcDir = resolve(layer.config.rootDir!, layer.config.srcDir!) + const defaultBuildDir = join(nuxtConfig.rootDir!, '.nuxt') + if (!opts.overrides?._prepare && !nuxtConfig.dev && !nuxtConfig.buildDir && existsSync(defaultBuildDir)) { + nuxtConfig.buildDir = join(nuxtConfig.rootDir!, 'node_modules/.cache/nuxt/.nuxt') + } + + const _layers: ConfigLayer<NuxtConfig, ConfigLayerMeta>[] = [] + const processedLayers = new Set<string>() + for (const layer of layers) { + // Resolve `rootDir` & `srcDir` of layers + layer.config = layer.config || {} + layer.config.rootDir = layer.config.rootDir ?? layer.cwd! + + // Only process/resolve layers once + if (processedLayers.has(layer.config.rootDir)) { continue } + processedLayers.add(layer.config.rootDir) + + // Normalise layer directories + layer.config = await applyDefaults(layerSchema, layer.config as NuxtConfig & Record<string, JSValue>) as unknown as NuxtConfig + + // Filter layers + if (!layer.configFile || layer.configFile.endsWith('.nuxtrc')) { continue } + _layers.push(layer) } - // Filter layers - const _layers = layers.filter(layer => layer.configFile && !layer.configFile.endsWith('.nuxtrc')) ;(nuxtConfig as any)._layers = _layers // Ensure at least one layer remains (without nuxt.config) @@ -45,8 +81,8 @@ export async function loadNuxtConfig (opts: LoadNuxtConfigOptions): Promise<Nuxt cwd, config: { rootDir: cwd, - srcDir: cwd - } + srcDir: cwd, + }, }) } diff --git a/packages/kit/src/loader/nuxt.ts b/packages/kit/src/loader/nuxt.ts index aaec491fae..892286f5b6 100644 --- a/packages/kit/src/loader/nuxt.ts +++ b/packages/kit/src/loader/nuxt.ts @@ -1,6 +1,7 @@ import { pathToFileURL } from 'node:url' import { readPackageJSON, resolvePackageJSON } from 'pkg-types' -import type { Nuxt } from '@nuxt/schema' +import type { Nuxt, NuxtConfig } from '@nuxt/schema' +import { resolve } from 'pathe' import { importModule, tryImportModule } from '../internal/esm' import type { LoadNuxtConfigOptions } from './config' @@ -10,76 +11,34 @@ export interface LoadNuxtOptions extends LoadNuxtConfigOptions { /** Use lazy initialization of nuxt if set to false */ ready?: boolean - - /** @deprecated Use cwd option */ - rootDir?: LoadNuxtConfigOptions['cwd'] - - /** @deprecated use overrides option */ - config?: LoadNuxtConfigOptions['overrides'] } export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { // Backward compatibility - opts.cwd = opts.cwd || opts.rootDir - opts.overrides = opts.overrides || opts.config || {} + opts.cwd = resolve(opts.cwd || (opts as any).rootDir /* backwards compat */ || '.') + opts.overrides = opts.overrides || (opts as any).config as NuxtConfig /* backwards compat */ || {} // Apply dev as config override opts.overrides.dev = !!opts.dev - const nearestNuxtPkg = await Promise.all(['nuxt-nightly', 'nuxt3', 'nuxt', 'nuxt-edge'] + const nearestNuxtPkg = await Promise.all(['nuxt-nightly', 'nuxt'] .map(pkg => resolvePackageJSON(pkg, { url: opts.cwd }).catch(() => null))) .then(r => (r.filter(Boolean) as string[]).sort((a, b) => b.length - a.length)[0]) if (!nearestNuxtPkg) { throw new Error(`Cannot find any nuxt version from ${opts.cwd}`) } const pkg = await readPackageJSON(nearestNuxtPkg) - const majorVersion = parseInt((pkg.version || '').split('.')[0]) - const rootDir = pathToFileURL(opts.cwd || process.cwd()).href + const rootDir = pathToFileURL(opts.cwd!).href - // Nuxt 3 - if (majorVersion === 3) { - const { loadNuxt } = await importModule((pkg as any)._name || pkg.name, rootDir) - const nuxt = await loadNuxt(opts) - return nuxt - } - - // Nuxt 2 - const { loadNuxt } = await tryImportModule('nuxt-edge', rootDir) || await importModule('nuxt', rootDir) - const nuxt = await loadNuxt({ - rootDir: opts.cwd, - for: opts.dev ? 'dev' : 'build', - configOverrides: opts.overrides, - ready: opts.ready, - envConfig: opts.dotenv // TODO: Backward format conversion - }) - - // Mock new hookable methods - nuxt.removeHook ||= nuxt.clearHook.bind(nuxt) - nuxt.removeAllHooks ||= nuxt.clearHooks.bind(nuxt) - nuxt.hookOnce ||= (name: string, fn: (...args: any[]) => any, ...hookArgs: any[]) => { - const unsub = nuxt.hook(name, (...args: any[]) => { - unsub() - return fn(...args) - }, ...hookArgs) - return unsub - } - // https://github.com/nuxt/nuxt/tree/main/packages/kit/src/module/define.ts#L111-L113 - nuxt.hooks ||= nuxt - - return nuxt as Nuxt + const { loadNuxt } = await importModule<typeof import('nuxt')>((pkg as any)._name || pkg.name, { paths: rootDir }) + const nuxt = await loadNuxt(opts) + return nuxt } export async function buildNuxt (nuxt: Nuxt): Promise<any> { const rootDir = pathToFileURL(nuxt.options.rootDir).href - // Nuxt 3 - if (nuxt.options._majorVersion === 3) { - const { build } = await tryImportModule('nuxt-nightly', rootDir) || await tryImportModule('nuxt3', rootDir) || await importModule('nuxt', rootDir) - return build(nuxt) - } - - // Nuxt 2 - const { build } = await tryImportModule('nuxt-edge', rootDir) || await importModule('nuxt', rootDir) + const { build } = await tryImportModule<typeof import('nuxt')>('nuxt-nightly', { paths: rootDir }) || await importModule<typeof import('nuxt')>('nuxt', { paths: rootDir }) return build(nuxt) } diff --git a/packages/kit/src/logger.test.ts b/packages/kit/src/logger.test.ts index a3cc49541d..44aa6ed3cd 100644 --- a/packages/kit/src/logger.test.ts +++ b/packages/kit/src/logger.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { consola } from 'consola' import { logger, useLogger } from './logger' -vi.mock("consola", () => { - const logger = {} as any; +vi.mock('consola', () => { + const logger = {} as any - logger.create = vi.fn(() => ({...logger})); - logger.withTag = vi.fn(() => ({...logger})); - - return { consola: logger }; + logger.create = vi.fn(() => ({ ...logger })) + logger.withTag = vi.fn(() => ({ ...logger })) + + return { consola: logger } }) describe('logger', () => { @@ -20,29 +20,28 @@ describe('logger', () => { describe('useLogger', () => { it('should expose consola when not passing a tag', () => { - expect(useLogger()).toBe(consola); - }); + expect(useLogger()).toBe(consola) + }) it('should create a new instance when passing a tag', () => { - const logger = vi.mocked(consola); - - const instance = useLogger("tag"); + const logger = vi.mocked(consola) - expect(instance).toEqual(logger); - expect(instance).not.toBe(logger); - expect(logger.create).toBeCalledWith({}); - expect(logger.withTag).toBeCalledWith("tag"); - }); + const instance = useLogger('tag') + + expect(instance).toEqual(logger) + expect(instance).not.toBe(logger) + expect(logger.create).toBeCalledWith({}) + expect(logger.withTag).toBeCalledWith('tag') + }) it('should create a new instance when passing a tag and options', () => { - const logger = vi.mocked(consola); - - const instance = useLogger("tag", { level: 0 }); + const logger = vi.mocked(consola) - expect(instance).toEqual(logger); - expect(instance).not.toBe(logger); - expect(logger.create).toBeCalledWith({ level: 0 }); - expect(logger.withTag).toBeCalledWith("tag"); - }); + const instance = useLogger('tag', { level: 0 }) + expect(instance).toEqual(logger) + expect(instance).not.toBe(logger) + expect(logger.create).toBeCalledWith({ level: 0 }) + expect(logger.withTag).toBeCalledWith('tag') + }) }) diff --git a/packages/kit/src/logger.ts b/packages/kit/src/logger.ts index be95003480..8572b11555 100644 --- a/packages/kit/src/logger.ts +++ b/packages/kit/src/logger.ts @@ -1,5 +1,5 @@ import { consola } from 'consola' -import type { ConsolaOptions } from 'consola'; +import type { ConsolaOptions } from 'consola' export const logger = consola diff --git a/packages/kit/src/module/compatibility.test.ts b/packages/kit/src/module/compatibility.test.ts index ce20f54f02..d298f800b9 100644 --- a/packages/kit/src/module/compatibility.test.ts +++ b/packages/kit/src/module/compatibility.test.ts @@ -10,21 +10,21 @@ describe('nuxt module compatibility', () => { modules: [ defineNuxtModule({ meta: { - name: 'nuxt-module-foo' - } + name: 'nuxt-module-foo', + }, }), [ defineNuxtModule({ meta: { - name: 'module-instance-with-options' - } + name: 'module-instance-with-options', + }, }), { - foo: 'bar' - } - ] - ] - } + foo: 'bar', + }, + ], + ], + }, }) expect(hasNuxtModule('nuxt-module-foo', nuxt)).toStrictEqual(true) expect(hasNuxtModule('module-instance-with-options', nuxt)).toStrictEqual(true) @@ -35,8 +35,8 @@ describe('nuxt module compatibility', () => { const module = defineNuxtModule({ meta: { name: 'nuxt-module-foo', - version: '1.0.0' - } + version: '1.0.0', + }, }) expect(await getNuxtModuleVersion(module, nuxt)).toEqual('1.0.0') await nuxt.close() @@ -46,8 +46,8 @@ describe('nuxt module compatibility', () => { const module = defineNuxtModule({ meta: { name: 'nuxt-module-foo', - version: '1.0.0' - } + version: '1.0.0', + }, }) expect(await hasNuxtModuleCompatibility(module, '^1.0.0', nuxt)).toStrictEqual(true) expect(await hasNuxtModuleCompatibility(module, '^2.0.0', nuxt)).toStrictEqual(false) diff --git a/packages/kit/src/module/compatibility.ts b/packages/kit/src/module/compatibility.ts index 666f4b4085..5bccbf8654 100644 --- a/packages/kit/src/module/compatibility.ts +++ b/packages/kit/src/module/compatibility.ts @@ -20,7 +20,7 @@ function resolveNuxtModuleEntryName (m: NuxtOptions['modules'][number]): string * This will check both the installed modules and the modules to be installed. Note * that it cannot detect if a module is _going to be_ installed programmatically by another module. */ -export function hasNuxtModule (moduleName: string, nuxt: Nuxt = useNuxt()) : boolean { +export function hasNuxtModule (moduleName: string, nuxt: Nuxt = useNuxt()): boolean { // check installed modules return nuxt.options._installedModules.some(({ meta }) => meta.name === moduleName) || // check modules to be installed @@ -36,7 +36,7 @@ export async function hasNuxtModuleCompatibility (module: string | NuxtModule, s return false } return satisfies(normalizeSemanticVersion(version), semverVersion, { - includePrerelease: true + includePrerelease: true, }) } @@ -51,11 +51,10 @@ export async function getNuxtModuleVersion (module: string | NuxtModule, nuxt: N // need a name from here if (!moduleMeta.name) { return false } // maybe the version got attached within the installed module instance? - const version = nuxt.options._installedModules - // @ts-expect-error _installedModules is not typed - .filter(m => m.meta.name === moduleMeta.name).map(m => m.meta.version)?.[0] - if (version) { - return version + for (const m of nuxt.options._installedModules) { + if (m.meta.name === moduleMeta.name && m.meta.version) { + return m.meta.version + } } // it's possible that the module will be installed, it just hasn't been done yet, preemptively load the instance if (hasNuxtModule(moduleMeta.name)) { diff --git a/packages/kit/src/module/define.ts b/packages/kit/src/module/define.ts index 360d1a14db..33ac6d015a 100644 --- a/packages/kit/src/module/define.ts +++ b/packages/kit/src/module/define.ts @@ -1,42 +1,87 @@ -import { promises as fsp } from 'node:fs' import { performance } from 'node:perf_hooks' import { defu } from 'defu' import { applyDefaults } from 'untyped' -import { dirname } from 'pathe' -import type { ModuleDefinition, ModuleOptions, ModuleSetupReturn, Nuxt, NuxtModule, NuxtOptions, ResolvedNuxtTemplate } from '@nuxt/schema' +import type { ModuleDefinition, ModuleOptions, ModuleSetupInstallResult, ModuleSetupReturn, Nuxt, NuxtModule, NuxtOptions, ResolvedModuleOptions } from '@nuxt/schema' import { logger } from '../logger' -import { nuxtCtx, tryUseNuxt, useNuxt } from '../context' -import { checkNuxtCompatibility, isNuxt2 } from '../compatibility' -import { compileTemplate, templateUtils } from '../internal/template' +import { tryUseNuxt, useNuxt } from '../context' +import { checkNuxtCompatibility } from '../compatibility' /** * Define a Nuxt module, automatically merging defaults with user provided options, installing * any hooks that are provided, and calling an optional setup function for full control. */ -export function defineNuxtModule<OptionsT extends ModuleOptions> (definition: ModuleDefinition<OptionsT> | NuxtModule<OptionsT>): NuxtModule<OptionsT> { - if (typeof definition === 'function') { return defineNuxtModule({ setup: definition }) } +export function defineNuxtModule<TOptions extends ModuleOptions> ( + definition: ModuleDefinition<TOptions, Partial<TOptions>, false> | NuxtModule<TOptions, Partial<TOptions>, false> +): NuxtModule<TOptions, TOptions, false> - // Normalize definition and meta - const module: ModuleDefinition<OptionsT> & Required<Pick<ModuleDefinition<OptionsT>, 'meta'>> = defu(definition, { meta: {} }) - if (module.meta.configKey === undefined) { - module.meta.configKey = module.meta.name +export function defineNuxtModule<TOptions extends ModuleOptions> (): { + with: <TOptionsDefaults extends Partial<TOptions>> ( + definition: ModuleDefinition<TOptions, TOptionsDefaults, true> | NuxtModule<TOptions, TOptionsDefaults, true> + ) => NuxtModule<TOptions, TOptionsDefaults, true> +} + +export function defineNuxtModule<TOptions extends ModuleOptions> ( + definition?: ModuleDefinition<TOptions, Partial<TOptions>, false> | NuxtModule<TOptions, Partial<TOptions>, false>, +) { + if (definition) { + return _defineNuxtModule(definition) } + return { + with: <TOptionsDefaults extends Partial<TOptions>>( + definition: ModuleDefinition<TOptions, TOptionsDefaults, true> | NuxtModule<TOptions, TOptionsDefaults, true>, + ) => _defineNuxtModule(definition), + } +} + +function _defineNuxtModule< + TOptions extends ModuleOptions, + TOptionsDefaults extends Partial<TOptions>, + TWith extends boolean, +> ( + definition: ModuleDefinition<TOptions, TOptionsDefaults, TWith> | NuxtModule<TOptions, TOptionsDefaults, TWith>, +): NuxtModule<TOptions, TOptionsDefaults, TWith> { + if (typeof definition === 'function') { + return _defineNuxtModule<TOptions, TOptionsDefaults, TWith>({ setup: definition }) + } + + // Normalize definition and meta + const module: ModuleDefinition<TOptions, TOptionsDefaults, TWith> & Required<Pick<ModuleDefinition<TOptions, TOptionsDefaults, TWith>, 'meta'>> = defu(definition, { meta: {} }) + + module.meta.configKey ||= module.meta.name + // Resolves module options from inline options, [configKey] in nuxt.config, defaults and schema - async function getOptions (inlineOptions?: OptionsT, nuxt: Nuxt = useNuxt()) { - const configKey = module.meta.configKey || module.meta.name! - const _defaults = module.defaults instanceof Function ? module.defaults(nuxt) : module.defaults - let _options = defu(inlineOptions, nuxt.options[configKey as keyof NuxtOptions], _defaults) as OptionsT + async function getOptions ( + inlineOptions?: Partial<TOptions>, + nuxt: Nuxt = useNuxt(), + ): Promise< + TWith extends true + ? ResolvedModuleOptions<TOptions, TOptionsDefaults> + : TOptions + > { + const nuxtConfigOptionsKey = module.meta.configKey || module.meta.name + + const nuxtConfigOptions: Partial<TOptions> = nuxtConfigOptionsKey && nuxtConfigOptionsKey in nuxt.options ? nuxt.options[<keyof NuxtOptions> nuxtConfigOptionsKey] : {} + + const optionsDefaults: TOptionsDefaults = + module.defaults instanceof Function + ? module.defaults(nuxt) + : module.defaults ?? <TOptionsDefaults> {} + + let options = defu(inlineOptions, nuxtConfigOptions, optionsDefaults) + if (module.schema) { - _options = await applyDefaults(module.schema, _options) as OptionsT + options = await applyDefaults(module.schema, options) as any } - return Promise.resolve(_options) + + // @ts-expect-error ignore type mismatch when calling `defineNuxtModule` without `.with()` + return Promise.resolve(options) } // Module format is always a simple function - async function normalizedModule (this: any, inlineOptions: OptionsT, nuxt: Nuxt) { + async function normalizedModule (inlineOptions: Partial<TOptions>, nuxt = tryUseNuxt()!): Promise<ModuleSetupReturn> { if (!nuxt) { - nuxt = tryUseNuxt() || this.nuxt /* invoked by nuxt 2 */ + throw new TypeError('Cannot use module outside of Nuxt context') } // Avoid duplicate installs @@ -58,9 +103,6 @@ export function defineNuxtModule<OptionsT extends ModuleOptions> (definition: Mo } } - // Prepare - nuxt2Shims(nuxt) - // Resolve module and options const _options = await getOptions(inlineOptions, nuxt) @@ -70,11 +112,10 @@ export function defineNuxtModule<OptionsT extends ModuleOptions> (definition: Mo } // Call setup - const key = `nuxt:module:${uniqueKey || (Math.round(Math.random() * 10000))}` - const mark = performance.mark(key) + const start = performance.now() const res = await module.setup?.call(null as any, _options, nuxt) ?? {} - const perf = performance.measure(key, mark?.name) // TODO: remove when Node 14 reaches EOL - const setupTime = perf ? Math.round((perf.duration * 100)) / 100 : 0 // TODO: remove when Node 14 reaches EOL + const perf = performance.now() - start + const setupTime = Math.round((perf * 100)) / 100 // Measure setup time if (setupTime > 5000 && uniqueKey !== '@nuxt/telemetry') { @@ -87,10 +128,10 @@ export function defineNuxtModule<OptionsT extends ModuleOptions> (definition: Mo if (res === false) { return false } // Return module install result - return defu(res, <ModuleSetupReturn> { + return defu(res, <ModuleSetupInstallResult> { timings: { - setup: setupTime - } + setup: setupTime, + }, }) } @@ -98,55 +139,5 @@ export function defineNuxtModule<OptionsT extends ModuleOptions> (definition: Mo normalizedModule.getMeta = () => Promise.resolve(module.meta) normalizedModule.getOptions = getOptions - return normalizedModule as NuxtModule<OptionsT> -} - -// -- Nuxt 2 compatibility shims -- -const NUXT2_SHIMS_KEY = '__nuxt2_shims_key__' -function nuxt2Shims (nuxt: Nuxt) { - // Avoid duplicate install and only apply to Nuxt2 - if (!isNuxt2(nuxt) || nuxt[NUXT2_SHIMS_KEY as keyof Nuxt]) { return } - nuxt[NUXT2_SHIMS_KEY as keyof Nuxt] = true - - // Allow using nuxt.hooks - // @ts-expect-error Nuxt 2 extends hookable - nuxt.hooks = nuxt - - // Allow using useNuxt() - if (!nuxtCtx.tryUse()) { - nuxtCtx.set(nuxt) - nuxt.hook('close', () => nuxtCtx.unset()) - } - - // Support virtual templates with getContents() by writing them to .nuxt directory - let virtualTemplates: ResolvedNuxtTemplate[] - // @ts-expect-error Nuxt 2 hook - nuxt.hook('builder:prepared', (_builder, buildOptions) => { - virtualTemplates = buildOptions.templates.filter((t: any) => t.getContents) - for (const template of virtualTemplates) { - buildOptions.templates.splice(buildOptions.templates.indexOf(template), 1) - } - }) - // @ts-expect-error Nuxt 2 hook - nuxt.hook('build:templates', async (templates) => { - const context = { - nuxt, - utils: templateUtils, - app: { - dir: nuxt.options.srcDir, - extensions: nuxt.options.extensions, - plugins: nuxt.options.plugins, - templates: [ - ...templates.templatesFiles, - ...virtualTemplates - ], - templateVars: templates.templateVars - } - } - for await (const template of virtualTemplates) { - const contents = await compileTemplate({ ...template, src: '' }, context) - await fsp.mkdir(dirname(template.dst), { recursive: true }) - await fsp.writeFile(template.dst, contents) - } - }) + return <NuxtModule<TOptions, TOptionsDefaults, TWith>> normalizedModule } diff --git a/packages/kit/src/module/install.ts b/packages/kit/src/module/install.ts index c1c0f8be29..39a460cbed 100644 --- a/packages/kit/src/module/install.ts +++ b/packages/kit/src/module/install.ts @@ -1,18 +1,19 @@ import { existsSync, promises as fsp, lstatSync } from 'node:fs' -import type { ModuleMeta, Nuxt, NuxtModule } from '@nuxt/schema' +import type { ModuleMeta, Nuxt, NuxtConfig, NuxtModule } from '@nuxt/schema' import { dirname, isAbsolute, join, resolve } from 'pathe' import { defu } from 'defu' -import { isNuxt2 } from '../compatibility' +import { createJiti } from 'jiti' import { useNuxt } from '../context' -import { requireModule } from '../internal/cjs' -import { importModule } from '../internal/esm' -import { resolveAlias, resolvePath } from '../resolve' +import { resolveAlias } from '../resolve' import { logger } from '../logger' const NODE_MODULES_RE = /[/\\]node_modules[/\\]/ /** Installs a module on a Nuxt instance. */ -export async function installModule (moduleToInstall: string | NuxtModule, inlineOptions?: any, nuxt: Nuxt = useNuxt()) { +export async function installModule< + T extends string | NuxtModule, + Config extends Extract<NonNullable<NuxtConfig['modules']>[number], [T, any]>, +> (moduleToInstall: T, inlineOptions?: [Config] extends [never] ? any : Config[1], nuxt: Nuxt = useNuxt()) { const { nuxtModule, buildTimeModuleMeta } = await loadNuxtModuleInstance(moduleToInstall, nuxt) const localLayerModuleDirs = new Set<string>() @@ -24,12 +25,7 @@ export async function installModule (moduleToInstall: string | NuxtModule, inlin } // Call module - const res = ( - isNuxt2() - // @ts-expect-error Nuxt 2 `moduleContainer` is not typed - ? await nuxtModule.call(nuxt.moduleContainer, inlineOptions, nuxt) - : await nuxtModule(inlineOptions, nuxt) - ) ?? {} + const res = await nuxtModule(inlineOptions || {}, nuxt) ?? {} if (res === false /* setup aborted */) { return } @@ -38,15 +34,21 @@ export async function installModule (moduleToInstall: string | NuxtModule, inlin nuxt.options.build.transpile.push(normalizeModuleTranspilePath(moduleToInstall)) const directory = getDirectory(moduleToInstall) if (directory !== moduleToInstall && !localLayerModuleDirs.has(directory)) { - nuxt.options.modulesDir.push(directory) + nuxt.options.modulesDir.push(resolve(directory, 'node_modules')) } } nuxt.options._installedModules = nuxt.options._installedModules || [] + const entryPath = typeof moduleToInstall === 'string' ? resolveAlias(moduleToInstall) : undefined + + if (typeof moduleToInstall === 'string' && entryPath !== moduleToInstall) { + buildTimeModuleMeta.rawPath = moduleToInstall + } + nuxt.options._installedModules.push({ meta: defu(await nuxtModule.getMeta?.(), buildTimeModuleMeta), timings: res.timings, - entryPath: typeof moduleToInstall === 'string' ? resolveAlias(moduleToInstall) : undefined + entryPath, }) } @@ -57,7 +59,7 @@ export function getDirectory (p: string) { // we need to target directories instead of module file paths themselves // /home/user/project/node_modules/module/index.js -> /home/user/project/node_modules/module return isAbsolute(p) && lstatSync(p).isFile() ? dirname(p) : p - } catch (e) { + } catch { // maybe the path is absolute but does not exist, allow this to bubble up } return p @@ -69,30 +71,35 @@ export const normalizeModuleTranspilePath = (p: string) => { export async function loadNuxtModuleInstance (nuxtModule: string | NuxtModule, nuxt: Nuxt = useNuxt()) { let buildTimeModuleMeta: ModuleMeta = {} + + const jiti = createJiti(nuxt.options.rootDir, { alias: nuxt.options.alias }) + // Import if input is string if (typeof nuxtModule === 'string') { - const paths = [join(nuxtModule, 'nuxt'), join(nuxtModule, 'module'), nuxtModule] - let error: unknown - for (const path of paths) { - const src = await resolvePath(path) - // Prefer ESM resolution if possible - try { - nuxtModule = await importModule(src, nuxt.options.modulesDir).catch(() => null) ?? requireModule(src, { paths: nuxt.options.modulesDir }) + const paths = [join(nuxtModule, 'nuxt'), join(nuxtModule, 'module'), nuxtModule, join(nuxt.options.rootDir, nuxtModule)] - // nuxt-module-builder generates a module.json with metadata including the version - const moduleMetadataPath = join(dirname(src), 'module.json') - if (existsSync(moduleMetadataPath)) { - buildTimeModuleMeta = JSON.parse(await fsp.readFile(moduleMetadataPath, 'utf-8')) + for (const parentURL of nuxt.options.modulesDir) { + for (const path of paths) { + try { + const src = jiti.esmResolve(path, { parentURL: parentURL.replace(/\/node_modules\/?$/, '') }) + nuxtModule = await jiti.import(src, { default: true }) as NuxtModule + + // nuxt-module-builder generates a module.json with metadata including the version + const moduleMetadataPath = join(dirname(src), 'module.json') + if (existsSync(moduleMetadataPath)) { + buildTimeModuleMeta = JSON.parse(await fsp.readFile(moduleMetadataPath, 'utf-8')) + } + break + } catch (error: unknown) { + const code = (error as Error & { code?: string }).code + if (code === 'MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || code === 'ERR_MODULE_NOT_FOUND' || code === 'ERR_UNSUPPORTED_DIR_IMPORT') { + continue + } + logger.error(`Error while importing module \`${nuxtModule}\`: ${error}`) + throw error } - break - } catch (_err: unknown) { - error = _err - continue } - } - if (typeof nuxtModule !== 'function' && error) { - logger.error(`Error while requiring module \`${nuxtModule}\`: ${error}`) - throw error + if (typeof nuxtModule !== 'string') { break } } } diff --git a/packages/kit/src/nitro.ts b/packages/kit/src/nitro.ts index eddcb91f14..29b0b44981 100644 --- a/packages/kit/src/nitro.ts +++ b/packages/kit/src/nitro.ts @@ -1,4 +1,4 @@ -import type { Nitro, NitroDevEventHandler, NitroEventHandler } from 'nitropack' +import type { Nitro, NitroDevEventHandler, NitroEventHandler } from 'nitro/types' import type { Import } from 'unimport' import { normalize } from 'pathe' import { useNuxt } from './context' @@ -12,9 +12,9 @@ function normalizeHandlerMethod (handler: NitroEventHandler) { // retrieve method from handler file name const [, method = undefined] = handler.handler.match(/\.(get|head|patch|post|put|delete|connect|options|trace)(\.\w+)*$/) || [] return { - method, + method: method as 'get' | 'head' | 'patch' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | undefined, ...handler, - handler: normalize(handler.handler) + handler: normalize(handler.handler), } } diff --git a/packages/kit/src/pages.ts b/packages/kit/src/pages.ts index 9820fe64ff..35c73fae83 100644 --- a/packages/kit/src/pages.ts +++ b/packages/kit/src/pages.ts @@ -1,19 +1,12 @@ import type { NuxtHooks, NuxtMiddleware } from '@nuxt/schema' -import type { NitroRouteConfig } from 'nitropack' +import type { NitroRouteConfig } from 'nitro/types' import { defu } from 'defu' import { useNuxt } from './context' -import { isNuxt2 } from './compatibility' import { logger } from './logger' import { toArray } from './utils' export function extendPages (cb: NuxtHooks['pages:extend']) { - const nuxt = useNuxt() - if (isNuxt2(nuxt)) { - // @ts-expect-error TODO: Nuxt 2 hook - nuxt.hook('build:extendRoutes', cb) - } else { - nuxt.hook('pages:extend', cb) - } + useNuxt().hook('pages:extend', cb) } export interface ExtendRouteRulesOptions { @@ -42,6 +35,11 @@ export interface AddRouteMiddlewareOptions { * @default false */ override?: boolean + /** + * Prepend middleware to the list + * @default false + */ + prepend?: boolean } export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], options: AddRouteMiddlewareOptions = {}) { @@ -51,13 +49,17 @@ export function addRouteMiddleware (input: NuxtMiddleware | NuxtMiddleware[], op for (const middleware of middlewares) { const find = app.middleware.findIndex(item => item.name === middleware.name) if (find >= 0) { + const foundPath = app.middleware[find]!.path + if (foundPath === middleware.path) { continue } if (options.override === true) { - app.middleware[find] = middleware + app.middleware[find] = { ...middleware } } else { - logger.warn(`'${middleware.name}' middleware already exists at '${app.middleware[find].path}'. You can set \`override: true\` to replace it.`) + logger.warn(`'${middleware.name}' middleware already exists at '${foundPath}'. You can set \`override: true\` to replace it.`) } + } else if (options.prepend === true) { + app.middleware.unshift({ ...middleware }) } else { - app.middleware.push(middleware) + app.middleware.push({ ...middleware }) } } }) diff --git a/packages/kit/src/plugin.ts b/packages/kit/src/plugin.ts index 5421c3027f..aadcdbc718 100644 --- a/packages/kit/src/plugin.ts +++ b/packages/kit/src/plugin.ts @@ -3,7 +3,6 @@ import type { NuxtPlugin, NuxtPluginTemplate } from '@nuxt/schema' import { useNuxt } from './context' import { addTemplate } from './template' import { resolveAlias } from './resolve' -import { logger } from './logger' /** * Normalize a nuxt plugin object @@ -20,12 +19,6 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin { throw new Error('Invalid plugin. src option is required: ' + JSON.stringify(plugin)) } - // TODO: only scan top-level files #18418 - const nonTopLevelPlugin = plugin.src.match(/\/plugins\/[^/]+\/index\.[^/]+$/i) - if (nonTopLevelPlugin && nonTopLevelPlugin.length > 0 && !useNuxt().options.plugins.find(i => (typeof i === 'string' ? i : i.src).endsWith(nonTopLevelPlugin[0]))) { - logger.warn(`[deprecation] You are using a plugin that is within a subfolder of your plugins directory without adding it to your config explicitly. You can move it to the top-level plugins directory, or include the file '~${nonTopLevelPlugin[0]}' in your plugins config (https://nuxt.com/docs/api/nuxt-config#plugins-1) to remove this warning.`) - } - // Normalize full path to plugin plugin.src = normalize(resolveAlias(plugin.src)) @@ -50,8 +43,11 @@ export function normalizePlugin (plugin: NuxtPlugin | string): NuxtPlugin { * Note: By default plugin is prepended to the plugins array. You can use second argument to append (push) instead. * @example * ```js + * import { createResolver } from '@nuxt/kit' + * const resolver = createResolver(import.meta.url) + * * addPlugin({ - * src: path.resolve(__dirname, 'templates/foo.js'), + * src: resolver.resolve('templates/foo.js'), * filename: 'foo.server.js' // [optional] only include in server bundle * }) * ``` diff --git a/packages/kit/src/resolve.test.ts b/packages/kit/src/resolve.test.ts new file mode 100644 index 0000000000..bcdb95486e --- /dev/null +++ b/packages/kit/src/resolve.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { resolve } from 'pathe' +import { loadNuxt } from './loader/nuxt' +import { findPath, resolvePath } from './resolve' +import { defineNuxtModule } from './module/define' +import { addTemplate } from './template' + +const nuxt = await loadNuxt({ + overrides: { + modules: [ + defineNuxtModule(() => { + addTemplate({ + filename: 'my-template.mjs', + getContents: () => 'export const myUtil = () => \'hello\'', + }) + }), + ], + }, +}) + +describe('resolvePath', () => { + it('should resolve paths correctly', async () => { + expect(await resolvePath('.nuxt/app.config')).toBe(resolve(nuxt.options.buildDir, 'app.config')) + }) +}) + +describe('findPath', () => { + it('should find paths correctly', async () => { + expect(await findPath(resolve(nuxt.options.buildDir, 'my-template'), { virtual: true })).toBe(resolve(nuxt.options.buildDir, 'my-template.mjs')) + }) +}) diff --git a/packages/kit/src/resolve.ts b/packages/kit/src/resolve.ts index a768ba6509..31ae08d2ed 100644 --- a/packages/kit/src/resolve.ts +++ b/packages/kit/src/resolve.ts @@ -17,6 +17,19 @@ export interface ResolvePathOptions { /** The file extensions to try. Default is Nuxt configured extensions. */ extensions?: string[] + + /** + * Whether to resolve files that exist in the Nuxt VFS (for example, as a Nuxt template). + * @default false + */ + virtual?: boolean + + /** + * Whether to fallback to the original path if the resolved path does not exist instead of returning the normalized input path. + * + * @default false + */ + fallbackToOriginal?: boolean } /** @@ -30,8 +43,13 @@ export async function resolvePath (path: string, opts: ResolvePathOptions = {}): path = normalize(path) // Fast return if the path exists - if (isAbsolute(path) && existsSync(path) && !(await isDirectory(path))) { - return path + if (isAbsolute(path)) { + if (opts?.virtual && existsInVFS(path)) { + return path + } + if (existsSync(path) && !(await isDirectory(path))) { + return path + } } // Use current nuxt options @@ -49,6 +67,10 @@ export async function resolvePath (path: string, opts: ResolvePathOptions = {}): } // Check if resolvedPath is a file + if (opts?.virtual && existsInVFS(path, nuxt)) { + return path + } + let _isDir = false if (existsSync(path)) { _isDir = await isDirectory(path) @@ -61,11 +83,17 @@ export async function resolvePath (path: string, opts: ResolvePathOptions = {}): for (const ext of extensions) { // path.[ext] const pathWithExt = path + ext + if (opts?.virtual && existsInVFS(pathWithExt, nuxt)) { + return pathWithExt + } if (existsSync(pathWithExt)) { return pathWithExt } // path/index.[ext] const pathWithIndex = join(path, 'index' + ext) + if (opts?.virtual && existsInVFS(pathWithIndex, nuxt)) { + return pathWithIndex + } if (_isDir && existsSync(pathWithIndex)) { return pathWithIndex } @@ -78,15 +106,24 @@ export async function resolvePath (path: string, opts: ResolvePathOptions = {}): } // Return normalized input - return path + return opts.fallbackToOriginal ? _path : path } /** * Try to resolve first existing file in paths */ export async function findPath (paths: string | string[], opts?: ResolvePathOptions, pathType: 'file' | 'dir' = 'file'): Promise<string | null> { + const nuxt = opts?.virtual ? tryUseNuxt() : undefined + for (const path of toArray(paths)) { const rPath = await resolvePath(path, opts) + + // Check VFS + if (opts?.virtual && existsInVFS(rPath, nuxt)) { + return rPath + } + + // Check file system if (await existsSensitive(rPath)) { const _isDir = await isDirectory(rPath) if (!pathType || (pathType === 'file' && !_isDir) || (pathType === 'dir' && _isDir)) { @@ -127,17 +164,17 @@ export function createResolver (base: string | URL): Resolver { return { resolve: (...path) => resolve(base as string, ...path), - resolvePath: (path, opts) => resolvePath(path, { cwd: base as string, ...opts }) + resolvePath: (path, opts) => resolvePath(path, { cwd: base as string, ...opts }), } } -export async function resolveNuxtModule (base: string, paths: string[]) { - const resolved = [] +export async function resolveNuxtModule (base: string, paths: string[]): Promise<string[]> { + const resolved: string[] = [] const resolver = createResolver(base) for (const path of paths) { if (path.startsWith(base)) { - resolved.push(path.split('/index.ts')[0]) + resolved.push(path.split('/index.ts')[0]!) } else { const resolvedPath = await resolver.resolvePath(path) resolved.push(resolvedPath.slice(0, resolvedPath.lastIndexOf(path) + path.length)) @@ -160,7 +197,24 @@ async function isDirectory (path: string) { return (await fsp.lstat(path)).isDirectory() } -export async function resolveFiles (path: string, pattern: string | string[], opts: { followSymbolicLinks?: boolean } = {}) { - const files = await globby(pattern, { cwd: path, followSymbolicLinks: opts.followSymbolicLinks ?? true }) - return files.map(p => resolve(path, p)).filter(p => !isIgnored(p)).sort() +function existsInVFS (path: string, nuxt = tryUseNuxt()) { + if (!nuxt) { return false } + + if (path in nuxt.vfs) { + return true + } + + const templates = nuxt.apps.default?.templates ?? nuxt.options.build.templates + return templates.some(template => template.dst === path) +} + +export async function resolveFiles (path: string, pattern: string | string[], opts: { followSymbolicLinks?: boolean } = {}) { + const files: string[] = [] + for (const file of await globby(pattern, { cwd: path, followSymbolicLinks: opts.followSymbolicLinks ?? true })) { + const p = resolve(path, file) + if (!isIgnored(p)) { + files.push(p) + } + } + return files.sort() } diff --git a/packages/kit/src/runtime-config.ts b/packages/kit/src/runtime-config.ts new file mode 100644 index 0000000000..034c59fa14 --- /dev/null +++ b/packages/kit/src/runtime-config.ts @@ -0,0 +1,103 @@ +import process from 'node:process' +import destr from 'destr' +import { snakeCase } from 'scule' +import { klona } from 'klona' + +import defu from 'defu' +import { useNuxt } from './context' +import { useNitro } from './nitro' + +/** + * Access 'resolved' Nuxt runtime configuration, with values updated from environment. + * + * This mirrors the runtime behavior of Nitro. + */ +export function useRuntimeConfig () { + const nuxt = useNuxt() + return applyEnv(klona(nuxt.options.nitro.runtimeConfig!), { + prefix: 'NITRO_', + altPrefix: 'NUXT_', + envExpansion: nuxt.options.nitro.experimental?.envExpansion ?? !!process.env.NITRO_ENV_EXPANSION, + }) +} + +/** + * Update Nuxt runtime configuration. + */ +export function updateRuntimeConfig (runtimeConfig: Record<string, unknown>) { + const nuxt = useNuxt() + Object.assign(nuxt.options.nitro.runtimeConfig as Record<string, unknown>, defu(runtimeConfig, nuxt.options.nitro.runtimeConfig)) + + try { + return useNitro().updateConfig({ runtimeConfig }) + } catch { + // Nitro is not yet initialised - we can safely ignore this error + } +} + +/** + * https://github.com/unjs/nitro/blob/main/src/runtime/utils.env.ts. +* + * These utils will be replaced by util exposed from nitropack. See https://github.com/unjs/nitro/pull/2404 + * for more context and future plans.) + * + * @internal + */ + +type EnvOptions = { + prefix?: string + altPrefix?: string + envExpansion?: boolean +} + +function getEnv (key: string, opts: EnvOptions, env = process.env) { + const envKey = snakeCase(key).toUpperCase() + return destr( + env[opts.prefix + envKey] ?? env[opts.altPrefix + envKey], + ) +} + +function _isObject (input: unknown) { + return typeof input === 'object' && !Array.isArray(input) +} + +function applyEnv ( + obj: Record<string, any>, + opts: EnvOptions, + parentKey = '', +) { + for (const key in obj) { + const subKey = parentKey ? `${parentKey}_${key}` : key + const envValue = getEnv(subKey, opts) + if (_isObject(obj[key])) { + // Same as before + if (_isObject(envValue)) { + obj[key] = { ...(obj[key] as any), ...(envValue as any) } + applyEnv(obj[key], opts, subKey) + } else if (envValue === undefined) { + // If envValue is undefined + // Then proceed to nested properties + applyEnv(obj[key], opts, subKey) + } else { + // If envValue is a primitive other than undefined + // Then set objValue and ignore the nested properties + obj[key] = envValue ?? obj[key] + } + } else { + obj[key] = envValue ?? obj[key] + } + // Experimental env expansion + if (opts.envExpansion && typeof obj[key] === 'string') { + obj[key] = _expandFromEnv(obj[key]) + } + } + return obj +} + +const envExpandRx = /\{\{(.*?)\}\}/g + +function _expandFromEnv (value: string, env: Record<string, any> = process.env) { + return value.replace(envExpandRx, (match, key) => { + return env[key] || match + }) +} diff --git a/packages/kit/src/template.ts b/packages/kit/src/template.ts index 851cdc48ce..903308497f 100644 --- a/packages/kit/src/template.ts +++ b/packages/kit/src/template.ts @@ -5,18 +5,18 @@ import type { Nuxt, NuxtTemplate, NuxtTypeTemplate, ResolvedNuxtTemplate, TSRefe import { withTrailingSlash } from 'ufo' import { defu } from 'defu' import type { TSConfig } from 'pkg-types' +import { gte } from 'semver' import { readPackageJSON } from 'pkg-types' import { tryResolveModule } from './internal/esm' import { getDirectory } from './module/install' import { tryUseNuxt, useNuxt } from './context' -import { getModulePaths } from './internal/cjs' import { resolveNuxtModule } from './resolve' /** * Renders given template using lodash template during build into the project buildDir */ -export function addTemplate <T>(_template: NuxtTemplate<T> | string) { +export function addTemplate<T> (_template: NuxtTemplate<T> | string) { const nuxt = useNuxt() // Normalize template @@ -36,7 +36,7 @@ export function addTemplate <T>(_template: NuxtTemplate<T> | string) { * Renders given types using lodash template during build into the project buildDir * and register them as types. */ -export function addTypeTemplate <T>(_template: NuxtTypeTemplate<T>) { +export function addTypeTemplate<T> (_template: NuxtTypeTemplate<T>) { const nuxt = useNuxt() const template = addTemplate(_template) @@ -56,7 +56,7 @@ export function addTypeTemplate <T>(_template: NuxtTypeTemplate<T>) { /** * Normalize a nuxt template object */ -export function normalizeTemplate <T>(template: NuxtTemplate<T> | string): ResolvedNuxtTemplate<T> { +export function normalizeTemplate<T> (template: NuxtTemplate<T> | string): ResolvedNuxtTemplate<T> { if (!template) { throw new Error('Invalid template: ' + JSON.stringify(template)) } @@ -110,66 +110,118 @@ export function normalizeTemplate <T>(template: NuxtTemplate<T> | string): Resol export async function updateTemplates (options?: { filter?: (template: ResolvedNuxtTemplate<any>) => boolean }) { return await tryUseNuxt()?.hooks.callHook('builder:generateApp', options) } -export async function writeTypes (nuxt: Nuxt) { - const nodeModulePaths = getModulePaths(nuxt.options.modulesDir) +export async function _generateTypes (nuxt: Nuxt) { const rootDirWithSlash = withTrailingSlash(nuxt.options.rootDir) + const relativeRootDir = relativeWithDot(nuxt.options.buildDir, nuxt.options.rootDir) - const modulePaths = await resolveNuxtModule(rootDirWithSlash, - nuxt.options._installedModules - .filter(m => m.entryPath) - .map(m => getDirectory(m.entryPath)) - ) + const include = new Set<string>([ + './nuxt.d.ts', + join(relativeRootDir, '.config/nuxt.*'), + join(relativeRootDir, '**/*'), + ]) + if (nuxt.options.srcDir !== nuxt.options.rootDir) { + include.add(join(relative(nuxt.options.buildDir, nuxt.options.srcDir), '**/*')) + } + + if (nuxt.options.typescript.includeWorkspace && nuxt.options.workspaceDir !== nuxt.options.rootDir) { + include.add(join(relative(nuxt.options.buildDir, nuxt.options.workspaceDir), '**/*')) + } + + for (const layer of nuxt.options._layers) { + const srcOrCwd = layer.config.srcDir ?? layer.cwd + if (!srcOrCwd.startsWith(rootDirWithSlash) || srcOrCwd.includes('node_modules')) { + include.add(join(relative(nuxt.options.buildDir, srcOrCwd), '**/*')) + } + } + + const exclude = new Set<string>([ + // nitro generate output: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/core/nitro.ts#L186 + relativeWithDot(nuxt.options.buildDir, resolve(nuxt.options.rootDir, 'dist')), + ]) + + for (const dir of nuxt.options.modulesDir) { + exclude.add(relativeWithDot(nuxt.options.buildDir, dir)) + } + + const moduleEntryPaths: string[] = [] + for (const m of nuxt.options._installedModules) { + if (m.entryPath) { + moduleEntryPaths.push(getDirectory(m.entryPath)) + } + } + + const modulePaths = await resolveNuxtModule(rootDirWithSlash, moduleEntryPaths) + + for (const path of modulePaths) { + const relative = relativeWithDot(nuxt.options.buildDir, path) + include.add(join(relative, 'runtime')) + exclude.add(join(relative, 'runtime/server')) + include.add(join(relative, 'dist/runtime')) + exclude.add(join(relative, 'dist/runtime/server')) + } + + const isV4 = nuxt.options.future?.compatibilityVersion === 4 + const hasTypescriptVersionWithModulePreserve = await readPackageJSON('typescript', { url: nuxt.options.modulesDir }) + .then(r => r?.version && gte(r.version, '5.4.0')) + .catch(() => isV4) + + // https://www.totaltypescript.com/tsconfig-cheat-sheet const tsConfig: TSConfig = defu(nuxt.options.typescript?.tsConfig, { compilerOptions: { + /* Base options: */ + esModuleInterop: true, + skipLibCheck: true, + target: 'ESNext', + allowJs: true, + resolveJsonModule: true, + moduleDetection: 'force', + isolatedModules: true, + verbatimModuleSyntax: true, + /* Strictness */ + strict: nuxt.options.typescript?.strict ?? true, + noUncheckedIndexedAccess: isV4, forceConsistentCasingInFileNames: true, + noImplicitOverride: true, + /* If NOT transpiling with TypeScript: */ + module: hasTypescriptVersionWithModulePreserve ? 'preserve' : 'ESNext', + noEmit: true, + /* If your code runs in the DOM: */ + lib: [ + 'ESNext', + 'dom', + 'dom.iterable', + 'webworker', + ], + /* JSX support for Vue */ jsx: 'preserve', jsxImportSource: 'vue', - target: 'ESNext', - module: 'ESNext', - moduleResolution: nuxt.options.future?.typescriptBundlerResolution || (nuxt.options.experimental as any)?.typescriptBundlerResolution ? 'Bundler' : 'Node', - skipLibCheck: true, - isolatedModules: true, - useDefineForClassFields: true, - strict: nuxt.options.typescript?.strict ?? true, - noImplicitThis: true, - esModuleInterop: true, + /* remove auto-scanning for types */ types: [], - verbatimModuleSyntax: true, - allowJs: true, - noEmit: true, - resolveJsonModule: true, + /* add paths object for filling-in later */ + paths: {}, + /* Possibly consider removing the following in future */ + moduleResolution: nuxt.options.future?.typescriptBundlerResolution || (nuxt.options.experimental as any)?.typescriptBundlerResolution ? 'Bundler' : 'Node', /* implied by module: preserve */ + useDefineForClassFields: true, /* implied by target: es2022+ */ + noImplicitThis: true, /* enabled with `strict` */ allowSyntheticDefaultImports: true, - paths: {} }, - include: [ - './nuxt.d.ts', - join(relativeWithDot(nuxt.options.buildDir, nuxt.options.rootDir), '**/*'), - ...nuxt.options.srcDir !== nuxt.options.rootDir ? [join(relative(nuxt.options.buildDir, nuxt.options.srcDir), '**/*')] : [], - ...nuxt.options._layers.map(layer => layer.config.srcDir ?? layer.cwd) - .filter(srcOrCwd => !srcOrCwd.startsWith(rootDirWithSlash) || srcOrCwd.includes('node_modules')) - .map(srcOrCwd => join(relative(nuxt.options.buildDir, srcOrCwd), '**/*')), - ...nuxt.options.typescript.includeWorkspace && nuxt.options.workspaceDir !== nuxt.options.rootDir ? [join(relative(nuxt.options.buildDir, nuxt.options.workspaceDir), '**/*')] : [], - ...modulePaths.map(m => join(relativeWithDot(nuxt.options.buildDir, m), 'runtime')) - ], - exclude: [ - ...nuxt.options.modulesDir.map(m => relativeWithDot(nuxt.options.buildDir, m)), - ...modulePaths.map(m => join(relativeWithDot(nuxt.options.buildDir, m), 'runtime/server')), - // nitro generate output: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/core/nitro.ts#L186 - relativeWithDot(nuxt.options.buildDir, resolve(nuxt.options.rootDir, 'dist')) - ] + include: [...include], + exclude: [...exclude], } satisfies TSConfig) const aliases: Record<string, string> = { ...nuxt.options.alias, - '#build': nuxt.options.buildDir + '#build': nuxt.options.buildDir, } // Exclude bridge alias types to support Volar const excludedAlias = [/^@vue\/.*$/] - const basePath = tsConfig.compilerOptions!.baseUrl ? resolve(nuxt.options.buildDir, tsConfig.compilerOptions!.baseUrl) : nuxt.options.buildDir + const basePath = tsConfig.compilerOptions!.baseUrl + ? resolve(nuxt.options.buildDir, tsConfig.compilerOptions!.baseUrl) + : nuxt.options.buildDir tsConfig.compilerOptions = tsConfig.compilerOptions || {} tsConfig.include = tsConfig.include || [] @@ -178,10 +230,10 @@ export async function writeTypes (nuxt: Nuxt) { if (excludedAlias.some(re => re.test(alias))) { continue } - let absolutePath = resolve(basePath, aliases[alias]) + let absolutePath = resolve(basePath, aliases[alias]!) let stats = await fsp.stat(absolutePath).catch(() => null /* file does not exist */) if (!stats) { - const resolvedModule = await tryResolveModule(aliases[alias], nuxt.options.modulesDir) + const resolvedModule = await tryResolveModule(aliases[alias]!, nuxt.options.modulesDir) if (resolvedModule) { absolutePath = resolvedModule stats = await fsp.stat(resolvedModule).catch(() => null) @@ -199,9 +251,9 @@ export async function writeTypes (nuxt: Nuxt) { } else { const path = stats?.isFile() // remove extension - ? relativePath.replace(/(?<=\w)\.\w+$/g, '') + ? relativePath.replace(/\b\.\w+$/g, '') // non-existent file probably shouldn't be resolved - : aliases[alias] + : aliases[alias]! tsConfig.compilerOptions.paths[alias] = [path] @@ -211,12 +263,13 @@ export async function writeTypes (nuxt: Nuxt) { } } - const references: TSReference[] = await Promise.all([ - ...nuxt.options.modules, - ...nuxt.options._modules - ] - .filter(f => typeof f === 'string') - .map(async id => ({ types: (await readPackageJSON(id, { url: nodeModulePaths }).catch(() => null))?.name || id }))) + const references: TSReference[] = [] + await Promise.all([...nuxt.options.modules, ...nuxt.options._modules].map(async (id) => { + if (typeof id !== 'string') { return } + + const pkg = await readPackageJSON(id, { url: nuxt.options.modulesDir }).catch(() => null) + references.push(({ types: pkg?.name || id })) + })) const declarations: string[] = [] @@ -227,7 +280,7 @@ export async function writeTypes (nuxt: Nuxt) { tsConfig.compilerOptions!.paths[alias] = await Promise.all(paths.map(async (path: string) => { if (!isAbsolute(path)) { return path } const stats = await fsp.stat(path).catch(() => null /* file does not exist */) - return relativeWithDot(nuxt.options.buildDir, stats?.isFile() ? path.replace(/(?<=\w)\.\w+$/g, '') /* remove extension */ : path) + return relativeWithDot(nuxt.options.buildDir, stats?.isFile() ? path.replace(/\b\.\w+$/g, '') /* remove extension */ : path) })) } @@ -244,9 +297,18 @@ export async function writeTypes (nuxt: Nuxt) { ...declarations, '', 'export {}', - '' + '', ].join('\n') + return { + declaration, + tsConfig, + } +} + +export async function writeTypes (nuxt: Nuxt) { + const { tsConfig, declaration } = await _generateTypes(nuxt) + async function writeFile () { const GeneratedBy = '// Generated by nuxi' @@ -258,19 +320,18 @@ export async function writeTypes (nuxt: Nuxt) { await fsp.writeFile(declarationPath, GeneratedBy + '\n' + declaration) } - // This is needed for Nuxt 2 which clears the build directory again before building - // https://github.com/nuxt/nuxt/blob/2.x/packages/builder/src/builder.js#L144 - // @ts-expect-error TODO: Nuxt 2 hook - nuxt.hook('builder:prepared', writeFile) - await writeFile() } function renderAttrs (obj: Record<string, string>) { - return Object.entries(obj).map(e => renderAttr(e[0], e[1])).join(' ') + const attrs: string[] = [] + for (const key in obj) { + attrs.push(renderAttr(key, obj[key])) + } + return attrs.join(' ') } -function renderAttr (key: string, value: string) { +function renderAttr (key: string, value?: string) { return value ? `${key}="${value}"` : '' } diff --git a/packages/kit/src/utils.ts b/packages/kit/src/utils.ts index 4cc2040ad9..72b096120b 100644 --- a/packages/kit/src/utils.ts +++ b/packages/kit/src/utils.ts @@ -1,3 +1,4 @@ +/** @since 3.9.0 */ export function toArray<T> (value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } diff --git a/packages/kit/test/generate-types.spec.ts b/packages/kit/test/generate-types.spec.ts new file mode 100644 index 0000000000..b5bcc9a6bb --- /dev/null +++ b/packages/kit/test/generate-types.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { Nuxt, NuxtConfig } from '@nuxt/schema' +import { defu } from 'defu' + +import { _generateTypes } from '../src/template' + +type DeepPartial<T> = { + [P in keyof T]?: T[P] extends Record<string, any> ? DeepPartial<T[P]> : T[P] +} + +const mockNuxt = { + options: { + rootDir: '/my-app', + srcDir: '/my-app', + alias: { + '~': '/my-app', + 'some-custom-alias': '/my-app/some-alias', + }, + typescript: { includeWorkspace: false }, + buildDir: '/my-app/.nuxt', + modulesDir: ['/my-app/node_modules', '/node_modules'], + modules: [], + _layers: [{ config: { srcDir: '/my-app' } }], + _installedModules: [], + _modules: [], + }, + callHook: () => {}, +} satisfies DeepPartial<Nuxt> as unknown as Nuxt + +const mockNuxtWithOptions = (options: NuxtConfig) => defu({ options }, mockNuxt) as Nuxt + +describe('tsConfig generation', () => { + it('should add correct relative paths for aliases', async () => { + const { tsConfig } = await _generateTypes(mockNuxt) + expect(tsConfig.compilerOptions?.paths).toMatchInlineSnapshot(` + { + "#build": [ + ".", + ], + "some-custom-alias": [ + "../some-alias", + ], + "~": [ + "..", + ], + } + `) + }) + + it('should add exclude for module paths', async () => { + const { tsConfig } = await _generateTypes(mockNuxtWithOptions({ + modulesDir: ['/my-app/modules/test/node_modules', '/my-app/modules/node_modules', '/my-app/node_modules/@some/module/node_modules'], + })) + expect(tsConfig.exclude).toMatchInlineSnapshot(` + [ + "../dist", + "../modules/test/node_modules", + "../modules/node_modules", + "../node_modules/@some/module/node_modules", + "../node_modules", + "../../node_modules", + ] + `) + }) +}) diff --git a/packages/nuxt/.gitignore b/packages/nuxt/.gitignore new file mode 100644 index 0000000000..95c0486757 --- /dev/null +++ b/packages/nuxt/.gitignore @@ -0,0 +1,6 @@ +src/app/components/error-404.vue +src/app/components/error-500.vue +src/app/components/error-dev.vue +src/app/components/welcome.vue +src/core/runtime/nitro/error-500.ts +src/core/runtime/nitro/error-dev.ts diff --git a/packages/nuxt/build.config.ts b/packages/nuxt/build.config.ts index dfaf1d97a0..1f8a82cf20 100644 --- a/packages/nuxt/build.config.ts +++ b/packages/nuxt/build.config.ts @@ -13,23 +13,23 @@ export default defineBuildConfig({ 'core', 'head', 'components', - 'pages' - ].map(name => ({ input: `src/${name}/runtime/`, outDir: `dist/${name}/runtime`, format: 'esm', ext: 'js' } as BuildEntry)) + 'pages', + ].map(name => ({ input: `src/${name}/runtime/`, outDir: `dist/${name}/runtime`, format: 'esm', ext: 'js' } as BuildEntry)), ], hooks: { 'mkdist:entry:options' (_ctx, _entry, mkdistOptions) { mkdistOptions.addRelativeDeclarationExtensions = true - } + }, }, dependencies: [ 'nuxi', 'vue-router', - 'ofetch' + 'ofetch', ], externals: [ 'nuxt', 'nuxt/schema', '@vue/shared', - '@unhead/vue' - ] + '@unhead/vue', + ], }) diff --git a/packages/nuxt/config.cjs b/packages/nuxt/config.cjs index cfdf3f8aa9..f4540f1290 100644 --- a/packages/nuxt/config.cjs +++ b/packages/nuxt/config.cjs @@ -3,5 +3,5 @@ function defineNuxtConfig (config) { } module.exports = { - defineNuxtConfig + defineNuxtConfig, } diff --git a/packages/nuxt/config.d.ts b/packages/nuxt/config.d.ts index e78965c0a3..3160b60bd0 100644 --- a/packages/nuxt/config.d.ts +++ b/packages/nuxt/config.d.ts @@ -1,6 +1,8 @@ import type { NuxtConfig } from 'nuxt/schema' import type { ConfigLayerMeta, DefineConfig } from 'c12' + export { NuxtConfig } from 'nuxt/schema' +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface DefineNuxtConfig extends DefineConfig<NuxtConfig, ConfigLayerMeta> {} export declare const defineNuxtConfig: DefineNuxtConfig diff --git a/packages/nuxt/index.d.ts b/packages/nuxt/index.d.ts index 2256348248..5630f9aeb0 100644 --- a/packages/nuxt/index.d.ts +++ b/packages/nuxt/index.d.ts @@ -2,8 +2,6 @@ declare global { var __NUXT_VERSION__: string var __NUXT_ASYNC_CONTEXT__: boolean - var __NUXT_PREPATHS__: string[] | string | undefined - var __NUXT_PATHS__: string[] | string | undefined interface Navigator { connection?: { @@ -14,7 +12,8 @@ declare global { interface Window { cookieStore?: { - onchange: (event: any) => void + addEventListener: (type: 'change', listener: (event: any) => void) => void + removeEventListener: (type: 'change', listener: (event: any) => void) => void } } } diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 6fb14af07d..e1bbdf050e 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "nuxt", - "version": "3.10.2", + "version": "3.12.2", "repository": { "type": "git", "url": "git+https://github.com/nuxt/nuxt.git", @@ -60,69 +60,80 @@ }, "dependencies": { "@nuxt/devalue": "^2.0.2", - "@nuxt/devtools": "^1.0.8", + "@nuxt/devtools": "^1.5.2", "@nuxt/kit": "workspace:*", "@nuxt/schema": "workspace:*", - "@nuxt/telemetry": "^2.5.3", - "@nuxt/ui-templates": "^1.3.1", + "@nuxt/telemetry": "^2.6.0", "@nuxt/vite-builder": "workspace:*", - "@unhead/dom": "^1.8.10", - "@unhead/ssr": "^1.8.10", - "@unhead/vue": "^1.8.10", - "@vue/shared": "^3.4.19", - "acorn": "8.11.3", - "c12": "^1.8.0", - "chokidar": "^3.6.0", - "cookie-es": "^1.0.0", + "@unhead/dom": "^1.11.7", + "@unhead/shared": "^1.11.7", + "@unhead/ssr": "^1.11.7", + "@unhead/vue": "^1.11.7", + "@vue/shared": "^3.5.11", + "acorn": "8.12.1", + "c12": "^2.0.1", + "chokidar": "^4.0.1", + "compatx": "^0.1.8", + "consola": "^3.2.3", + "cookie-es": "^1.2.2", "defu": "^6.1.4", - "destr": "^2.0.2", - "devalue": "^4.3.2", - "esbuild": "^0.20.1", + "destr": "^2.0.3", + "devalue": "^5.1.1", + "errx": "^0.1.0", + "esbuild": "^0.24.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", - "fs-extra": "^11.2.0", - "globby": "^14.0.1", - "h3": "^1.10.1", + "globby": "^14.0.2", + "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", "hookable": "^5.5.3", - "jiti": "^1.21.0", + "ignore": "^6.0.2", + "impound": "^0.1.0", + "jiti": "^2.3.3", "klona": "^2.0.6", - "knitwork": "^1.0.0", - "magic-string": "^0.30.7", - "mlly": "^1.5.0", - "nitropack": "^2.8.1", - "nuxi": "^3.10.1", - "nypm": "^0.3.6", - "ofetch": "^1.3.3", - "ohash": "^1.1.3", + "knitwork": "^1.1.0", + "magic-string": "^0.30.11", + "mlly": "^1.7.2", + "nanotar": "^0.1.1", + "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", + "nuxi": "^3.14.0", + "nypm": "^0.3.12", + "ofetch": "^1.4.0", + "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", - "pkg-types": "^1.0.3", - "radix3": "^1.1.0", + "pkg-types": "^1.2.1", + "radix3": "^1.1.2", "scule": "^1.3.0", + "semver": "^7.6.3", "std-env": "^3.7.0", - "strip-literal": "^2.0.0", - "ufo": "^1.4.0", - "ultrahtml": "^1.5.2", + "strip-literal": "^2.1.0", + "tinyglobby": "0.2.9", + "ufo": "^1.5.4", + "ultrahtml": "^1.5.3", "uncrypto": "^0.1.3", "unctx": "^2.3.1", - "unenv": "^1.9.0", - "unimport": "^3.7.1", - "unplugin": "^1.7.1", - "unplugin-vue-router": "^0.7.0", - "untyped": "^1.4.2", - "vue": "^3.4.19", - "vue-bundle-renderer": "^2.0.0", + "unenv": "^1.10.0", + "unhead": "^1.11.7", + "unimport": "^3.13.1", + "unplugin": "^1.14.1", + "unplugin-vue-router": "^0.10.8", + "unstorage": "^1.12.0", + "untyped": "^1.5.1", + "vue": "^3.5.11", + "vue-bundle-renderer": "^2.1.1", "vue-devtools-stub": "^0.1.0", - "vue-router": "^4.2.5" + "vue-router": "^4.4.5" }, "devDependencies": { - "@parcel/watcher": "2.4.0", - "@types/estree": "1.0.5", - "@types/fs-extra": "11.0.4", - "@vitejs/plugin-vue": "5.0.4", - "unbuild": "latest", - "vite": "5.1.3", - "vitest": "1.3.0" + "@nuxt/scripts": "0.9.4", + "@nuxt/ui-templates": "1.3.4", + "@parcel/watcher": "2.4.1", + "@types/estree": "1.0.6", + "@vitejs/plugin-vue": "5.1.4", + "@vue/compiler-sfc": "3.5.11", + "unbuild": "3.0.0-rc.11", + "vite": "5.4.8", + "vitest": "2.1.2" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", diff --git a/packages/nuxt/src/app/compat/idle-callback.ts b/packages/nuxt/src/app/compat/idle-callback.ts index 01779dc1fe..12e01230e7 100644 --- a/packages/nuxt/src/app/compat/idle-callback.ts +++ b/packages/nuxt/src/app/compat/idle-callback.ts @@ -6,7 +6,7 @@ export const requestIdleCallback: Window['requestIdleCallback'] = import.meta.se const start = Date.now() const idleDeadline = { didTimeout: false, - timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), } return setTimeout(() => { cb(idleDeadline) }, 1) })) diff --git a/packages/nuxt/src/app/compat/interval.ts b/packages/nuxt/src/app/compat/interval.ts index 74adf6c39c..36017305c1 100644 --- a/packages/nuxt/src/app/compat/interval.ts +++ b/packages/nuxt/src/app/compat/interval.ts @@ -2,13 +2,15 @@ import { createError } from '../composables/error' const intervalError = '[nuxt] `setInterval` should not be used on the server. Consider wrapping it with an `onNuxtReady`, `onBeforeMount` or `onMounted` lifecycle hook, or ensure you only call it in the browser by checking `import.meta.client`.' -export const setInterval = import.meta.client ? window.setInterval : () => { - if (import.meta.dev) { - throw createError({ - statusCode: 500, - message: intervalError - }) - } +export const setInterval = import.meta.client + ? window.setInterval + : () => { + if (import.meta.dev) { + throw createError({ + statusCode: 500, + message: intervalError, + }) + } - console.error(intervalError) -} + console.error(intervalError) + } diff --git a/packages/nuxt/src/app/components/client-fallback.client.ts b/packages/nuxt/src/app/components/client-fallback.client.ts index d1f249ba3f..f92dfb7a31 100644 --- a/packages/nuxt/src/app/components/client-fallback.client.ts +++ b/packages/nuxt/src/app/components/client-fallback.client.ts @@ -6,26 +6,26 @@ export default defineComponent({ inheritAttrs: false, props: { uid: { - type: String + type: String, }, fallbackTag: { type: String, - default: () => 'div' + default: () => 'div', }, fallback: { type: String, - default: () => '' + default: () => '', }, placeholder: { - type: String + type: String, }, placeholderTag: { - type: String + type: String, }, keepFallback: { type: Boolean, - default: () => false - } + default: () => false, + }, }, emits: ['ssr-error'], setup (props, ctx) { @@ -49,5 +49,5 @@ export default defineComponent({ } return ctx.slots.default?.() } - } + }, }) diff --git a/packages/nuxt/src/app/components/client-fallback.server.ts b/packages/nuxt/src/app/components/client-fallback.server.ts index 3b07745b38..dd4e0cdb28 100644 --- a/packages/nuxt/src/app/components/client-fallback.server.ts +++ b/packages/nuxt/src/app/components/client-fallback.server.ts @@ -1,6 +1,6 @@ import { defineComponent, getCurrentInstance, onErrorCaptured, ref } from 'vue' import { ssrRenderAttrs, ssrRenderSlot, ssrRenderVNode } from 'vue/server-renderer' -// eslint-disable-next-line + import { isPromise } from '@vue/shared' import { useState } from '../composables/state' import { useNuxtApp } from '../nuxt' @@ -11,39 +11,40 @@ const NuxtClientFallbackServer = defineComponent({ inheritAttrs: false, props: { uid: { - type: String + type: String, }, fallbackTag: { type: String, - default: () => 'div' + default: () => 'div', }, fallback: { type: String, - default: () => '' + default: () => '', }, placeholder: { - type: String + type: String, }, placeholderTag: { - type: String + type: String, }, keepFallback: { type: Boolean, - default: () => false - } + default: () => false, + }, }, emits: { 'ssr-error' (_error: unknown) { return true - } + }, }, async setup (props, ctx) { const vm = getCurrentInstance() const ssrFailed = ref(false) const nuxtApp = useNuxtApp() + const error = useState<boolean | undefined>(`${props.uid}`) onErrorCaptured((err) => { - useState(`${props.uid}`, () => true) + error.value = true ssrFailed.value = true ctx.emit('ssr-error', err) return false @@ -53,8 +54,10 @@ const NuxtClientFallbackServer = defineComponent({ const defaultSlot = ctx.slots.default?.() const ssrVNodes = createBuffer() - for (let i = 0; i < (defaultSlot?.length || 0); i++) { - ssrRenderVNode(ssrVNodes.push, defaultSlot![i], vm!) + if (defaultSlot) { + for (let i = 0; i < defaultSlot.length; i++) { + ssrRenderVNode(ssrVNodes.push, defaultSlot[i]!, vm!) + } } const buffer = ssrVNodes.getBuffer() @@ -86,7 +89,7 @@ const NuxtClientFallbackServer = defineComponent({ push(ctx.ssrVNodes.getBuffer()) push('<!--]-->') } - } + }, }) export default NuxtClientFallbackServer diff --git a/packages/nuxt/src/app/components/client-only.ts b/packages/nuxt/src/app/components/client-only.ts index a526347122..ab56eb4ab2 100644 --- a/packages/nuxt/src/app/components/client-only.ts +++ b/packages/nuxt/src/app/components/client-only.ts @@ -1,14 +1,16 @@ import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue' import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue' +import { isPromise } from '@vue/shared' import { useNuxtApp } from '../nuxt' import { getFragmentHTML } from './utils' +import ServerPlaceholder from './server-placeholder' export const clientOnlySymbol: InjectionKey<boolean> = Symbol.for('nuxt:client-only') export default defineComponent({ name: 'ClientOnly', inheritAttrs: false, - // eslint-disable-next-line vue/require-prop-types + props: ['fallback', 'placeholder', 'placeholderTag', 'fallbackTag'], setup (_, { slots, attrs }) { const mounted = ref(false) @@ -28,13 +30,16 @@ export default defineComponent({ const fallbackTag = props.fallbackTag || props.placeholderTag || 'span' return createElementBlock(fallbackTag, attrs, fallbackStr) } - } + }, }) const cache = new WeakMap() -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ export function createClientOnly<T extends ComponentOptions> (component: T) { + if (import.meta.server) { + return ServerPlaceholder + } if (cache.has(component)) { return cache.get(component) } @@ -42,7 +47,7 @@ export function createClientOnly<T extends ComponentOptions> (component: T) { const clone = { ...component } if (clone.render) { - // override the component render (non script setup component) + // override the component render (non script setup component) or dev mode clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => { if ($setup.mounted$ ?? ctx.mounted$) { const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options) @@ -51,7 +56,7 @@ export function createClientOnly<T extends ComponentOptions> (component: T) { : h(res) } else { const fragment = getFragmentHTML(ctx._.vnode.el ?? null) ?? ['<div></div>'] - return import.meta.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.$attrs ?? ctx._.attrs) + return createStaticVNode(fragment.join(''), fragment.length) } } } else if (clone.template) { @@ -63,43 +68,61 @@ export function createClientOnly<T extends ComponentOptions> (component: T) { } clone.setup = (props, ctx) => { + const nuxtApp = useNuxtApp() + const mounted$ = ref(nuxtApp.isHydrating === false) const instance = getCurrentInstance()! - const attrs = { ...instance.attrs } + if (nuxtApp.isHydrating) { + const attrs = { ...instance.attrs } + // remove existing directives during hydration + const directives = extractDirectives(instance) + // prevent attrs inheritance since a staticVNode is rendered before hydration + for (const key in attrs) { + delete instance.attrs[key] + } - // remove existing directives during hydration - const directives = extractDirectives(instance) - // prevent attrs inheritance since a staticVNode is rendered before hydration - for (const key in attrs) { - delete instance.attrs[key] + onMounted(() => { + Object.assign(instance.attrs, attrs) + instance.vnode.dirs = directives + }) } - const mounted$ = ref(false) onMounted(() => { - Object.assign(instance.attrs, attrs) - instance.vnode.dirs = directives mounted$.value = true }) + const setupState = component.setup?.(props, ctx) || {} - return Promise.resolve(component.setup?.(props, ctx) || {}) - .then((setupState) => { + if (isPromise(setupState)) { + return Promise.resolve(setupState).then((setupState) => { if (typeof setupState !== 'function') { setupState = setupState || {} setupState.mounted$ = mounted$ return setupState } return (...args: any[]) => { - if (mounted$.value) { + if (mounted$.value || !nuxtApp.isHydrating) { const res = setupState(...args) return (res.children === null || typeof res.children === 'string') ? cloneVNode(res) : h(res) } else { const fragment = getFragmentHTML(instance?.vnode.el ?? null) ?? ['<div></div>'] - return import.meta.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.attrs) + return createStaticVNode(fragment.join(''), fragment.length) } } }) + } else { + if (typeof setupState === 'function') { + return (...args: any[]) => { + if (mounted$.value) { + return h(setupState(...args), ctx.attrs) + } + const fragment = getFragmentHTML(instance?.vnode.el ?? null) ?? ['<div></div>'] + return createStaticVNode(fragment.join(''), fragment.length) + } + } + return Object.assign(setupState, { mounted$ }) + } } cache.set(component, clone) diff --git a/packages/nuxt/src/app/components/dev-only.ts b/packages/nuxt/src/app/components/dev-only.ts index 6c8ec7c5e9..94b5ae84e3 100644 --- a/packages/nuxt/src/app/components/dev-only.ts +++ b/packages/nuxt/src/app/components/dev-only.ts @@ -2,10 +2,11 @@ import { defineComponent } from 'vue' export default defineComponent({ name: 'DevOnly', + inheritAttrs: false, setup (_, props) { if (import.meta.dev) { return () => props.slots.default?.() } return () => props.slots.fallback?.() - } + }, }) diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts index 6fc2fd479d..189c980e50 100644 --- a/packages/nuxt/src/app/components/island-renderer.ts +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -1,25 +1,31 @@ import type { defineAsyncComponent } from 'vue' import { createVNode, defineComponent, onErrorCaptured } from 'vue' +import { injectHead } from '@unhead/vue' import { createError } from '../composables/error' // @ts-expect-error virtual file import { islandComponents } from '#build/components.islands.mjs' export default defineComponent({ + name: 'IslandRenderer', props: { context: { type: Object as () => { name: string, props?: Record<string, any> }, - required: true - } + required: true, + }, }, setup (props) { + // reset head - we don't want to have any head tags from plugin or anywhere else. + const head = injectHead() + head.headEntries().splice(0, head.headEntries().length) + const component = islandComponents[props.context.name] as ReturnType<typeof defineAsyncComponent> if (!component) { throw createError({ statusCode: 404, - statusMessage: `Island component not found: ${props.context.name}` + statusMessage: `Island component not found: ${props.context.name}`, }) } @@ -28,5 +34,5 @@ export default defineComponent({ }) return () => createVNode(component || 'span', { ...props.context.props, 'data-island-uid': '' }) - } + }, }) diff --git a/packages/nuxt/src/app/components/layout.ts b/packages/nuxt/src/app/components/layout.ts deleted file mode 100644 index b88def1a94..0000000000 --- a/packages/nuxt/src/app/components/layout.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: remove in 4.x -export { default } from './nuxt-layout' diff --git a/packages/nuxt/src/app/components/nuxt-error-boundary.ts b/packages/nuxt/src/app/components/nuxt-error-boundary.ts index 77659e2c29..8fb88d830a 100644 --- a/packages/nuxt/src/app/components/nuxt-error-boundary.ts +++ b/packages/nuxt/src/app/components/nuxt-error-boundary.ts @@ -2,28 +2,32 @@ import { defineComponent, onErrorCaptured, ref } from 'vue' import { useNuxtApp } from '../nuxt' export default defineComponent({ + name: 'NuxtErrorBoundary', + inheritAttrs: false, emits: { error (_error: unknown) { return true - } + }, }, setup (_props, { slots, emit }) { const error = ref<Error | null>(null) const nuxtApp = useNuxtApp() - onErrorCaptured((err, target, info) => { - if (import.meta.client && (!nuxtApp.isHydrating || !nuxtApp.payload.serverRendered)) { - emit('error', err) - nuxtApp.hooks.callHook('vue:error', err, target, info) - error.value = err - return false - } - }) + if (import.meta.client) { + onErrorCaptured((err, target, info) => { + if (!nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) { + emit('error', err) + nuxtApp.hooks.callHook('vue:error', err, target, info) + error.value = err + return false + } + }) + } function clearError () { error.value = null } return () => error.value ? slots.error?.({ error, clearError }) : slots.default?.() - } + }, }) diff --git a/packages/nuxt/src/app/components/nuxt-error-page.vue b/packages/nuxt/src/app/components/nuxt-error-page.vue index d3fa6e2636..c41743972b 100644 --- a/packages/nuxt/src/app/components/nuxt-error-page.vue +++ b/packages/nuxt/src/app/components/nuxt-error-page.vue @@ -6,28 +6,30 @@ import { defineAsyncComponent } from 'vue' const props = defineProps({ - error: Object + error: Object, }) // Deliberately prevent reactive update when error is cleared const _error = props.error // TODO: extract to a separate utility -const stacktrace = (_error.stack || '') - .split('\n') - .splice(1) - .map((line) => { - const text = line - .replace('webpack:/', '') - .replace('.vue', '.js') // TODO: Support sourcemap - .trim() - return { - text, - internal: (line.includes('node_modules') && !line.includes('.cache')) || - line.includes('internal') || - line.includes('new Promise') - } - }).map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n') +const stacktrace = _error.stack + ? _error.stack + .split('\n') + .splice(1) + .map((line) => { + const text = line + .replace('webpack:/', '') + .replace('.vue', '.js') // TODO: Support sourcemap + .trim() + return { + text, + internal: (line.includes('node_modules') && !line.includes('.cache')) || + line.includes('internal') || + line.includes('new Promise'), + } + }).map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n') + : '' // Error page props const statusCode = Number(_error.statusCode || 500) @@ -38,10 +40,10 @@ const description = _error.message || _error.toString() const stack = import.meta.dev && !is404 ? _error.description || `<pre>${stacktrace}</pre>` : undefined // TODO: Investigate side-effect issue with imports -const _Error404 = defineAsyncComponent(() => import('@nuxt/ui-templates/templates/error-404.vue').then(r => r.default || r)) +const _Error404 = defineAsyncComponent(() => import('./error-404.vue')) const _Error = import.meta.dev - ? defineAsyncComponent(() => import('@nuxt/ui-templates/templates/error-dev.vue').then(r => r.default || r)) - : defineAsyncComponent(() => import('@nuxt/ui-templates/templates/error-500.vue').then(r => r.default || r)) + ? defineAsyncComponent(() => import('./error-dev.vue')) + : defineAsyncComponent(() => import('./error-500.vue')) const ErrorTemplate = is404 ? _Error404 : _Error </script> diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 24e3e16891..c6bb6d8657 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,22 +1,21 @@ -import type { Component } from 'vue' +import type { Component, PropType, VNode } from 'vue' import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, watch, withMemo } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' import { appendResponseHeader } from 'h3' -import { useHead } from '@unhead/vue' +import { injectHead } from '@unhead/vue' import { randomUUID } from 'uncrypto' import { joinURL, withQuery } from 'ufo' import type { FetchResponse } from 'ofetch' import { join } from 'pathe' -// eslint-disable-next-line import/no-restricted-paths -import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' +import type { NuxtIslandResponse } from '../types' import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { prerenderRoutes, useRequestEvent } from '../composables/ssr' import { getFragmentHTML } from './utils' // @ts-expect-error virtual file -import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' +import { appBaseURL, remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' const pKey = '_islandPromises' const SSR_UID_RE = /data-island-uid="([^"]*)"/ @@ -29,47 +28,56 @@ const getId = import.meta.client ? () => (id++).toString() : randomUUID const components = import.meta.client ? new Map<string, Component>() : undefined -async function loadComponents (source = '/', paths: NuxtIslandResponse['components']) { - const promises = [] +async function loadComponents (source = appBaseURL, paths: NuxtIslandResponse['components']) { + if (!paths) { return } - for (const component in paths) { + const promises: Array<Promise<void>> = [] + + for (const [component, item] of Object.entries(paths)) { if (!(components!.has(component))) { promises.push((async () => { - const chunkSource = join(source, paths[component].chunk) + const chunkSource = join(source, item.chunk) const c = await import(/* @vite-ignore */ chunkSource).then(m => m.default || m) components!.set(component, c) })()) } } + await Promise.all(promises) } export default defineComponent({ name: 'NuxtIsland', + inheritAttrs: false, props: { name: { type: String, - required: true + required: true, }, lazy: Boolean, props: { type: Object, - default: () => undefined + default: () => undefined, }, context: { type: Object, - default: () => ({}) + default: () => ({}), + }, + scopeId: { + type: String as PropType<string | undefined | null>, + default: () => undefined, }, source: { type: String, - default: () => undefined + default: () => undefined, }, dangerouslyLoadClientComponents: { type: Boolean, - default: false - } + default: false, + }, }, - async setup (props, { slots, expose }) { + emits: ['error'], + async setup (props, { slots, expose, emit }) { let canTeleport = import.meta.server const teleportKey = ref(0) const key = ref(0) @@ -88,37 +96,41 @@ export default defineComponent({ onMounted(() => { mounted.value = true; teleportKey.value++ }) function setPayload (key: string, result: NuxtIslandResponse) { + const toRevive: Partial<NuxtIslandResponse> = {} + if (result.props) { toRevive.props = result.props } + if (result.slots) { toRevive.slots = result.slots } + if (result.components) { toRevive.components = result.components } + if (result.head) { toRevive.head = result.head } nuxtApp.payload.data[key] = { __nuxt_island: { key, ...(import.meta.server && import.meta.prerender) ? {} : { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }, - result: { - props: result.props, - slots: result.slots, - components: result.components - } + result: toRevive, }, - ...result + ...result, } } - const payloads: Required<Pick<NuxtIslandResponse, 'slots' | 'components'>> = { - slots: {}, - components: {} - } + const payloads: Partial<Pick<NuxtIslandResponse, 'slots' | 'components'>> = {} - - if (nuxtApp.isHydrating) { - payloads.slots = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.slots ?? {} - payloads.components = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.components ?? {} + if (instance.vnode.el) { + const slots = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.slots + if (slots) { payloads.slots = slots } + if (selectiveClient) { + const components = toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.components + if (components) { payloads.components = components } + } } const ssrHTML = ref<string>('') - if (import.meta.client && nuxtApp.isHydrating) { - ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || '' + if (import.meta.client && instance.vnode?.el) { + ssrHTML.value = getFragmentHTML(instance.vnode.el, true)?.join('') || '' + const key = `${props.name}_${hashId.value}` + nuxtApp.payload.data[key] ||= {} + nuxtApp.payload.data[key].html = ssrHTML.value } const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId()) @@ -127,6 +139,10 @@ export default defineComponent({ const currentSlots = Object.keys(slots) let html = ssrHTML.value + if (props.scopeId) { + html = html.replace(/^<[^> ]*/, full => full + ' ' + props.scopeId) + } + if (import.meta.client && !canLoadClientComponent.value) { for (const [key, value] of Object.entries(payloads.components || {})) { html = html.replace(new RegExp(` data-island-uid="${uid.value}" data-island-component="${key}"[^>]*>`), (full) => { @@ -135,21 +151,23 @@ export default defineComponent({ } } - return html.replaceAll(SLOT_FALLBACK_RE, (full, slotName) => { - if (!currentSlots.includes(slotName)) { - return full + (payloads.slots[slotName]?.fallback || '') - } - return full - }) + if (payloads.slots) { + return html.replaceAll(SLOT_FALLBACK_RE, (full, slotName) => { + if (!currentSlots.includes(slotName)) { + return full + (payloads.slots?.[slotName]?.fallback || '') + } + return full + }) + } + return html }) - const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] }) - useHead(cHead) + const head = injectHead() async function _fetchComponent (force = false) { const key = `${props.name}_${hashId.value}` - if (nuxtApp.payload.data[key]?.html && !force) { return nuxtApp.payload.data[key] } + if (!force && nuxtApp.payload.data[key]?.html) { return nuxtApp.payload.data[key] } const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json` @@ -161,7 +179,7 @@ export default defineComponent({ // $fetch handles the app.baseURL in dev const r = await eventFetch(withQuery(((import.meta.dev && import.meta.client) || props.source) ? url : joinURL(config.app.baseURL ?? '', url), { ...props.context, - props: props.props ? JSON.stringify(props.props) : undefined + props: props.props ? JSON.stringify(props.props) : undefined, })) const result = import.meta.server || !import.meta.dev ? await r.json() : (r as FetchResponse<NuxtIslandResponse>)._data // TODO: support passing on more headers @@ -184,8 +202,7 @@ export default defineComponent({ } try { const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value] - cHead.value.link = res.head.link - cHead.value.style = res.head.style + ssrHTML.value = res.html.replaceAll(DATA_ISLAND_UID_RE, `data-island-uid="${uid.value}"`) key.value++ error.value = null @@ -207,11 +224,12 @@ export default defineComponent({ } } catch (e) { error.value = e + emit('error', e) } } expose({ - refresh: () => fetchComponent(true) + refresh: () => fetchComponent(true), }) if (import.meta.hot) { @@ -232,6 +250,14 @@ export default defineComponent({ await loadComponents(props.source, payloads.components) } + if (import.meta.server || nuxtApp.isHydrating) { + // re-push head into active head instance + const responseHead = (nuxtApp.payload.data[`${props.name}_${hashId.value}`] as NuxtIslandResponse)?.head + if (responseHead) { + head.push(responseHead) + } + } + return (_ctx: any, _cache: any) => { if (!html.value || error.value) { return [slots.fallback?.({ error: error.value }) ?? createVNode('div')] @@ -243,46 +269,54 @@ export default defineComponent({ // should away be triggered ONE tick after re-rendering the static node withMemo([teleportKey.value], () => { - const teleports = [] + const teleports: Array<VNode> = [] // this is used to force trigger Teleport when vue makes the diff between old and new node const isKeyOdd = teleportKey.value === 0 || !!(teleportKey.value && !(teleportKey.value % 2)) - if (uid.value && html.value && (import.meta.server || props.lazy ? canTeleport : mounted.value || nuxtApp.isHydrating)) { + if (uid.value && html.value && (import.meta.server || props.lazy ? canTeleport : (mounted.value || instance.vnode?.el))) { for (const slot in slots) { if (availableSlots.value.includes(slot)) { teleports.push(createVNode(Teleport, // use different selectors for even and odd teleportKey to force trigger the teleport { to: import.meta.client ? `${isKeyOdd ? 'div' : ''}[data-island-uid="${uid.value}"][data-island-slot="${slot}"]` : `uid=${uid.value};slot=${slot}` }, - { default: () => (payloads.slots[slot].props?.length ? payloads.slots[slot].props : [{}]).map((data: any) => slots[slot]?.(data)) }) + { default: () => (payloads.slots?.[slot]?.props?.length ? payloads.slots[slot].props : [{}]).map((data: any) => slots[slot]?.(data)) }), ) } } - if (import.meta.server) { - for (const [id, info] of Object.entries(payloads.components ?? {})) { - const { html } = info - teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { - default: () => [createStaticVNode(html, 1)] - })) - } - } - if (selectiveClient && import.meta.client && canLoadClientComponent.value) { - for (const [id, info] of Object.entries(payloads.components ?? {})) { - const { props } = info - const component = components!.get(id)! - // use different selectors for even and odd teleportKey to force trigger the teleport - const vnode = createVNode(Teleport, { to: `${isKeyOdd ? 'div' : ''}[data-island-uid='${uid.value}'][data-island-component="${id}"]` }, { - default: () => { - return [h(component, props)] + if (selectiveClient) { + if (import.meta.server) { + if (payloads.components) { + for (const [id, info] of Object.entries(payloads.components)) { + const { html, slots } = info + let replaced = html.replaceAll('data-island-uid', `data-island-uid="${uid.value}"`) + for (const slot in slots) { + replaced = replaced.replaceAll(`data-island-slot="${slot}">`, full => full + slots[slot]) + } + teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { + default: () => [createStaticVNode(replaced, 1)], + })) } - }) - teleports.push(vnode) + } + } else if (canLoadClientComponent.value && payloads.components) { + for (const [id, info] of Object.entries(payloads.components)) { + const { props, slots } = info + const component = components!.get(id)! + // use different selectors for even and odd teleportKey to force trigger the teleport + const vnode = createVNode(Teleport, { to: `${isKeyOdd ? 'div' : ''}[data-island-uid='${uid.value}'][data-island-component="${id}"]` }, { + default: () => { + return [h(component, props, Object.fromEntries(Object.entries(slots || {}).map(([k, v]) => ([k, () => createStaticVNode(`<div style="display: contents" data-island-uid data-island-slot="${k}">${v}</div>`, 1), + ]))))] + }, + }) + teleports.push(vnode) + } } } } return h(Fragment, teleports) - }, _cache, 1) + }, _cache, 1), ] } - } + }, }) diff --git a/packages/nuxt/src/app/components/nuxt-layout.ts b/packages/nuxt/src/app/components/nuxt-layout.ts index 7509f2f9ff..e7af6597fc 100644 --- a/packages/nuxt/src/app/components/nuxt-layout.ts +++ b/packages/nuxt/src/app/components/nuxt-layout.ts @@ -2,7 +2,6 @@ import type { DefineComponent, MaybeRef, VNode } from 'vue' import { Suspense, Transition, computed, defineComponent, h, inject, mergeProps, nextTick, onMounted, provide, ref, unref } from 'vue' import type { RouteLocationNormalizedLoaded } from 'vue-router' -// eslint-disable-next-line import/no-restricted-paths import type { PageMeta } from '../../pages/runtime/composables' import { useRoute, useRouter } from '../composables/router' @@ -23,7 +22,7 @@ const LayoutLoader = defineComponent({ inheritAttrs: false, props: { name: String, - layoutProps: Object + layoutProps: Object, }, async setup (props, context) { // This is a deliberate hack - this component must always be called with an explicit key to ensure @@ -32,7 +31,7 @@ const LayoutLoader = defineComponent({ const LayoutComponent = await layouts[props.name]().then((r: any) => r.default || r) return () => h(LayoutComponent, props.layoutProps, context.slots) - } + }, }) export default defineComponent({ @@ -41,12 +40,12 @@ export default defineComponent({ props: { name: { type: [String, Boolean, Object] as unknown as () => unknown extends PageMeta['layout'] ? MaybeRef<string | false> : PageMeta['layout'], - default: null + default: null, }, fallback: { type: [String, Object] as unknown as () => unknown extends PageMeta['layout'] ? MaybeRef<string> : PageMeta['layout'], - default: null - } + default: null, + }, }, setup (props, context) { const nuxtApp = useNuxtApp() @@ -94,12 +93,12 @@ export default defineComponent({ key: layout.value || undefined, name: layout.value, shouldProvide: !props.name, - hasTransition: !!transitionProps - }, context.slots) - }) + hasTransition: !!transitionProps, + }, context.slots), + }), }).default() } - } + }, }) as unknown as DefineComponent<{ name?: (unknown extends PageMeta['layout'] ? MaybeRef<string | false> : PageMeta['layout']) | undefined }> @@ -109,17 +108,17 @@ const LayoutProvider = defineComponent({ inheritAttrs: false, props: { name: { - type: [String, Boolean] as unknown as () => string | false + type: [String, Boolean] as unknown as () => string | false, }, layoutProps: { - type: Object + type: Object, }, hasTransition: { - type: Boolean + type: Boolean, }, shouldProvide: { - type: Boolean - } + type: Boolean, + }, }, setup (props, context) { // Prevent reactivity when the page will be rerendered in a different suspense fork @@ -127,7 +126,7 @@ const LayoutProvider = defineComponent({ const name = props.name if (props.shouldProvide) { provide(LayoutMetaSymbol, { - isCurrent: (route: RouteLocationNormalizedLoaded) => name === (route.meta.layout ?? 'default') + isCurrent: (route: RouteLocationNormalizedLoaded) => name === (route.meta.layout ?? 'default'), }) } @@ -159,7 +158,7 @@ const LayoutProvider = defineComponent({ vnode = h( LayoutLoader, { key: name, layoutProps: props.layoutProps, name }, - context.slots + context.slots, ) return vnode @@ -168,8 +167,8 @@ const LayoutProvider = defineComponent({ return h( LayoutLoader, { key: name, layoutProps: props.layoutProps, name }, - context.slots + context.slots, ) } - } + }, }) diff --git a/packages/nuxt/src/app/components/nuxt-link.ts b/packages/nuxt/src/app/components/nuxt-link.ts index b5ad9dbc68..77006518a2 100644 --- a/packages/nuxt/src/app/components/nuxt-link.ts +++ b/packages/nuxt/src/app/components/nuxt-link.ts @@ -4,14 +4,14 @@ import type { ComputedRef, DefineComponent, InjectionKey, PropType, - VNodeProps + VNodeProps, } from 'vue' import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provide, ref, resolveComponent } from 'vue' -import type { RouteLocation, RouteLocationRaw, Router, RouterLinkProps } from '#vue-router' -import { hasProtocol, joinURL, parseQuery, parseURL, withTrailingSlash, withoutTrailingSlash } from 'ufo' +import type { RouteLocation, RouteLocationRaw, Router, RouterLink, RouterLinkProps, useLink } from 'vue-router' +import { hasProtocol, joinURL, parseQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo' import { preloadRouteComponents } from '../composables/preload' import { onNuxtReady } from '../composables/ready' -import { navigateTo, useRouter } from '../composables/router' +import { navigateTo, resolveRouteObject, useRouter } from '../composables/router' import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { cancelIdleCallback, requestIdleCallback } from '../compat/idle-callback' @@ -23,30 +23,7 @@ const firstNonUndefined = <T> (...args: (T | undefined)[]) => args.find(arg => a const NuxtLinkDevKeySymbol: InjectionKey<boolean> = Symbol('nuxt-link-dev-key') /** - * Create a NuxtLink component with given options as defaults. - * @see https://nuxt.com/docs/api/components/nuxt-link - */ -export interface NuxtLinkOptions extends - Pick<RouterLinkProps, 'activeClass' | 'exactActiveClass'>, - Pick<NuxtLinkProps, 'prefetchedClass'> { - /** - * The name of the component. - * @default "NuxtLink" - */ - componentName?: string - /** - * A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable. - */ - externalRelAttribute?: string | null - /** - * An option to either add or remove trailing slashes in the `href`. - * If unset or not matching the valid values `append` or `remove`, it will be ignored. - */ - trailingSlash?: 'append' | 'remove' -} - -/** - * <NuxtLink> is a drop-in replacement for both Vue Router's <RouterLink> component and HTML's <a> tag. + * `<NuxtLink>` is a drop-in replacement for both Vue Router's `<RouterLink>` component and HTML's `<a>` tag. * @see https://nuxt.com/docs/api/components/nuxt-link */ export interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> { @@ -82,13 +59,48 @@ export interface NuxtLinkProps extends Omit<RouterLinkProps, 'to'> { * When enabled will prefetch middleware, layouts and payloads of links in the viewport. */ prefetch?: boolean + /** + * Allows controlling when to prefetch links. By default, prefetch is triggered only on visibility. + */ + prefetchOn?: 'visibility' | 'interaction' | Partial<{ + visibility: boolean + interaction: boolean + }> /** * Escape hatch to disable `prefetch` attribute. */ noPrefetch?: boolean } - /*@__NO_SIDE_EFFECTS__*/ +/** + * Create a NuxtLink component with given options as defaults. + * @see https://nuxt.com/docs/api/components/nuxt-link + */ +export interface NuxtLinkOptions extends + Partial<Pick<RouterLinkProps, 'activeClass' | 'exactActiveClass'>>, + Partial<Pick<NuxtLinkProps, 'prefetch' | 'prefetchedClass'>> { + /** + * The name of the component. + * @default "NuxtLink" + */ + componentName?: string + /** + * A default `rel` attribute value applied on external links. Defaults to `"noopener noreferrer"`. Set it to `""` to disable. + */ + externalRelAttribute?: string | null + /** + * An option to either add or remove trailing slashes in the `href`. + * If unset or not matching the valid values `append` or `remove`, it will be ignored. + */ + trailingSlash?: 'append' | 'remove' + + /** + * Allows controlling default setting for when to prefetch links. By default, prefetch is triggered only on visibility. + */ + prefetchOn?: Exclude<NuxtLinkProps['prefetchOn'], string> +} + +/* @__NO_SIDE_EFFECTS__ */ export function defineNuxtLink (options: NuxtLinkOptions) { const componentName = options.componentName || 'NuxtLink' @@ -98,18 +110,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) { } } - function resolveTrailingSlashBehavior ( - to: string, - resolve: Router['resolve'] - ): string - function resolveTrailingSlashBehavior ( - to: RouteLocationRaw, - resolve: Router['resolve'] - ): Omit<RouteLocationRaw, string> - function resolveTrailingSlashBehavior ( - to: RouteLocationRaw, - resolve: Router['resolve'] - ): RouteLocationRaw | RouteLocation { + 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 { if (!to || (options.trailingSlash !== 'append' && options.trailingSlash !== 'remove')) { return to } @@ -118,12 +121,95 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return applyTrailingSlashBehavior(to, options.trailingSlash) } - const path = 'path' in to ? to.path : resolve(to).path + const path = 'path' in to && to.path !== undefined ? to.path : resolve(to).path - return { + const resolvedPath = { ...to, name: undefined, // named routes would otherwise always override trailing slash behavior - path: applyTrailingSlashBehavior(path, options.trailingSlash) + path: applyTrailingSlashBehavior(path, options.trailingSlash), + } + + return resolvedPath + } + + function useNuxtLink (props: NuxtLinkProps) { + const router = useRouter() + const config = useRuntimeConfig() + + const hasTarget = computed(() => !!props.target && props.target !== '_self') + + // Lazily check whether to.value has a protocol + const isAbsoluteUrl = computed(() => { + const path = props.to || props.href || '' + return typeof path === 'string' && hasProtocol(path, { acceptRelative: true }) + }) + + const builtinRouterLink = resolveComponent('RouterLink') as string | typeof RouterLink + const useBuiltinLink = builtinRouterLink && typeof builtinRouterLink !== 'string' ? builtinRouterLink.useLink : undefined + + // Resolving link type + const isExternal = computed<boolean>(() => { + // External prop is explicitly set + if (props.external) { + return true + } + + const path = props.to || props.href || '' + + // When `to` is a route object then it's an internal link + if (typeof path === 'object') { + return false + } + + return path === '' || isAbsoluteUrl.value + }) + + // Resolving `to` value from `to` and `href` props + const to: ComputedRef<RouteLocationRaw> = computed(() => { + checkPropConflicts(props, 'to', 'href') + const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) + if (isExternal.value) { return path } + return resolveTrailingSlashBehavior(path, router.resolve) + }) + + const link = isExternal.value ? undefined : useBuiltinLink?.({ ...props, to }) + + // Resolves `to` value if it's a route location object + const href = computed(() => { + if (!to.value || isAbsoluteUrl.value) { return to.value as string } + + if (isExternal.value) { + const path = typeof to.value === 'object' && 'path' in to.value ? resolveRouteObject(to.value) : to.value + // separately resolve route objects with a 'name' property and without 'path' + const href = typeof path === 'object' ? router.resolve(path).href : path + return resolveTrailingSlashBehavior(href, router.resolve /* will not be called */) as string + } + + if (typeof to.value === 'object') { + return router.resolve(to.value)?.href ?? null + } + + return resolveTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), router.resolve /* will not be called */) + }) + + return { + to, + hasTarget, + isAbsoluteUrl, + isExternal, + // + href, + isActive: link?.isActive ?? computed(() => to.value === router.currentRoute.value.path), + isExactActive: link?.isExactActive ?? computed(() => to.value === router.currentRoute.value.path), + route: link?.route ?? computed(() => router.resolve(to.value)), + async navigate () { + await navigateTo(href.value, { replace: props.replace, external: isExternal.value || hasTarget.value }) + }, + } satisfies ReturnType<typeof useLink> & { + to: ComputedRef<RouteLocationRaw> + hasTarget: ComputedRef<boolean | null | undefined> + isAbsoluteUrl: ComputedRef<boolean> + isExternal: ComputedRef<boolean> } } @@ -134,133 +220,124 @@ export function defineNuxtLink (options: NuxtLinkOptions) { to: { type: [String, Object] as PropType<RouteLocationRaw>, default: undefined, - required: false + required: false, }, href: { type: [String, Object] as PropType<RouteLocationRaw>, default: undefined, - required: false + required: false, }, // Attributes target: { type: String as PropType<NuxtLinkProps['target']>, default: undefined, - required: false + required: false, }, rel: { type: String as PropType<NuxtLinkProps['rel']>, default: undefined, - required: false + required: false, }, noRel: { type: Boolean as PropType<NuxtLinkProps['noRel']>, default: undefined, - required: false + required: false, }, // Prefetching prefetch: { type: Boolean as PropType<NuxtLinkProps['prefetch']>, default: undefined, - required: false + required: false, + }, + prefetchOn: { + type: [String, Object] as PropType<NuxtLinkProps['prefetchOn']>, + default: undefined, + required: false, }, noPrefetch: { type: Boolean as PropType<NuxtLinkProps['noPrefetch']>, default: undefined, - required: false + required: false, }, // Styling activeClass: { type: String as PropType<NuxtLinkProps['activeClass']>, default: undefined, - required: false + required: false, }, exactActiveClass: { type: String as PropType<NuxtLinkProps['exactActiveClass']>, default: undefined, - required: false + required: false, }, prefetchedClass: { type: String as PropType<NuxtLinkProps['prefetchedClass']>, default: undefined, - required: false + required: false, }, // Vue Router's `<RouterLink>` additional props replace: { type: Boolean as PropType<NuxtLinkProps['replace']>, default: undefined, - required: false + required: false, }, ariaCurrentValue: { type: String as PropType<NuxtLinkProps['ariaCurrentValue']>, default: undefined, - required: false + required: false, }, // Edge cases handling external: { type: Boolean as PropType<NuxtLinkProps['external']>, default: undefined, - required: false + required: false, }, // Slot API custom: { type: Boolean as PropType<NuxtLinkProps['custom']>, default: undefined, - required: false - } + required: false, + }, }, + useLink: useNuxtLink, setup (props, { slots }) { const router = useRouter() - const config = useRuntimeConfig() - // Resolving `to` value from `to` and `href` props - const to: ComputedRef<string | RouteLocationRaw> = computed(() => { - checkPropConflicts(props, 'to', 'href') - - const path = props.to || props.href || '' // Defaults to empty string (won't render any `href` attribute) - - return resolveTrailingSlashBehavior(path, router.resolve) - }) - - // Lazily check whether to.value has a protocol - const isAbsoluteUrl = computed(() => typeof to.value === 'string' && hasProtocol(to.value, { acceptRelative: true })) - - const hasTarget = computed(() => props.target && props.target !== '_self') - - // Resolving link type - const isExternal = computed<boolean>(() => { - // External prop is explicitly set - if (props.external) { - return true - } - - // When `target` prop is set, link is external - if (hasTarget.value) { - return true - } - - // When `to` is a route object then it's an internal link - if (typeof to.value === 'object') { - return false - } - - return to.value === '' || isAbsoluteUrl.value - }) + const { to, href, navigate, isExternal, hasTarget, isAbsoluteUrl } = useNuxtLink(props) // Prefetching const prefetched = ref(false) const el = import.meta.server ? undefined : ref<HTMLElement | null>(null) const elRef = import.meta.server ? undefined : (ref: any) => { el!.value = props.custom ? ref?.$el?.nextElementSibling : ref?.$el } + function shouldPrefetch (mode: 'visibility' | 'interaction') { + return !prefetched.value && (typeof props.prefetchOn === 'string' ? props.prefetchOn === mode : (props.prefetchOn?.[mode] ?? options.prefetchOn?.[mode])) && (props.prefetch ?? options.prefetch) !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection() + } + + async function prefetch (nuxtApp = useNuxtApp()) { + if (prefetched.value) { return } + + prefetched.value = true + + const path = typeof to.value === 'string' + ? to.value + : isExternal.value ? resolveRouteObject(to.value) : router.resolve(to.value).fullPath + const normalizedPath = isExternal.value ? new URL(path, window.location.href).href : path + await Promise.all([ + nuxtApp.hooks.callHook('link:prefetch', normalizedPath).catch(() => {}), + !isExternal.value && !hasTarget.value && preloadRouteComponents(to.value as string, router).catch(() => {}), + ]) + } + if (import.meta.client) { checkPropConflicts(props, 'prefetch', 'noPrefetch') - const shouldPrefetch = props.prefetch !== false && props.noPrefetch !== true && props.target !== '_blank' && !isSlowConnection() - if (shouldPrefetch) { + if (shouldPrefetch('visibility')) { const nuxtApp = useNuxtApp() let idleId: number let unobserve: (() => void) | null = null @@ -272,13 +349,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { unobserve = observer!.observe(el.value as HTMLElement, async () => { unobserve?.() unobserve = null - - const path = typeof to.value === 'string' ? to.value : router.resolve(to.value).fullPath - await Promise.all([ - nuxtApp.hooks.callHook('link:prefetch', path).catch(() => {}), - !isExternal.value && preloadRouteComponents(to.value as string, router).catch(() => {}) - ]) - prefetched.value = true + await prefetch(nuxtApp) }) } }) @@ -302,7 +373,7 @@ export function defineNuxtLink (options: NuxtLinkOptions) { } return () => { - if (!isExternal.value) { + if (!isExternal.value && !hasTarget.value) { const routerLinkProps: RouterLinkProps & VNodeProps & AllowedComponentProps & AnchorHTMLAttributes = { ref: elRef, to: to.value, @@ -310,12 +381,16 @@ export function defineNuxtLink (options: NuxtLinkOptions) { exactActiveClass: props.exactActiveClass || options.exactActiveClass, replace: props.replace, ariaCurrentValue: props.ariaCurrentValue, - custom: props.custom + custom: props.custom, } // `custom` API cannot support fallthrough attributes as the slot // may render fragment or text root nodes (#14897, #19375) if (!props.custom) { + if (shouldPrefetch('interaction')) { + routerLinkProps.onPointerenter = prefetch.bind(null, undefined) + routerLinkProps.onFocus = prefetch.bind(null, undefined) + } if (prefetched.value) { routerLinkProps.class = props.prefetchedClass || options.prefetchedClass } @@ -326,18 +401,10 @@ export function defineNuxtLink (options: NuxtLinkOptions) { return h( resolveComponent('RouterLink'), routerLinkProps, - slots.default + slots.default, ) } - // Resolves `to` value if it's a route location object - // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) - const href = typeof to.value === 'object' - ? router.resolve(to.value)?.href ?? null - : (to.value && !props.external && !isAbsoluteUrl.value) - ? resolveTrailingSlashBehavior(joinURL(config.app.baseURL, to.value), router.resolve) as string - : to.value || null - // Resolves `target` value const target = props.target || null @@ -351,11 +418,9 @@ export function defineNuxtLink (options: NuxtLinkOptions) { * A fallback rel of `noopener noreferrer` is applied for external links or links that open in a new tab. * This solves a reverse tabnapping security flaw in browsers pre-2021 as well as improving privacy. */ - (isAbsoluteUrl.value || hasTarget.value) ? 'noopener noreferrer' : '' + (isAbsoluteUrl.value || hasTarget.value) ? 'noopener noreferrer' : '', ) || null - const navigate = () => navigateTo(href, { replace: props.replace }) - // https://router.vuejs.org/api/#custom if (props.custom) { if (!slots.default) { @@ -363,12 +428,13 @@ export function defineNuxtLink (options: NuxtLinkOptions) { } return slots.default({ - href, + href: href.value, navigate, + prefetch, get route () { - if (!href) { return undefined } + if (!href.value) { return undefined } - const url = parseURL(href) + const url = new URL(href.value, import.meta.client ? window.location.href : 'http://localhost') return { path: url.pathname, fullPath: url.pathname, @@ -379,20 +445,21 @@ export function defineNuxtLink (options: NuxtLinkOptions) { matched: [], redirectedFrom: undefined, meta: {}, - href + href: href.value, } satisfies RouteLocation & { href: string } }, rel, target, - isExternal: isExternal.value, + isExternal: isExternal.value || hasTarget.value, isActive: false, - isExactActive: false + isExactActive: false, }) } - return h('a', { ref: el, href, rel, target }, slots.default?.()) + // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) + return h('a', { ref: el, href: href.value || null, rel, target }, slots.default?.()) } - } + }, }) as unknown as DefineComponent<NuxtLinkProps> } @@ -448,7 +515,7 @@ function useObserver (): { observe: ObserveFn } | undefined { } const _observer = nuxtApp._observer = { - observe + observe, } return _observer diff --git a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts index 1020be8164..a0273a8428 100644 --- a/packages/nuxt/src/app/components/nuxt-loading-indicator.ts +++ b/packages/nuxt/src/app/components/nuxt-loading-indicator.ts @@ -6,34 +6,38 @@ export default defineComponent({ props: { throttle: { type: Number, - default: 200 + default: 200, }, duration: { type: Number, - default: 2000 + default: 2000, }, height: { type: Number, - default: 3 + default: 3, }, color: { type: [String, Boolean], - default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)' + default: 'repeating-linear-gradient(to right,#00dc82 0%,#34cdfe 50%,#0047e1 100%)', + }, + errorColor: { + type: String, + default: 'repeating-linear-gradient(to right,#f87171 0%,#ef4444 100%)', }, estimatedProgress: { type: Function as unknown as () => (duration: number, elapsed: number) => number, - required: false + required: false, }, }, setup (props, { slots, expose }) { - const { progress, isLoading, start, finish, clear } = useLoadingIndicator({ + const { progress, isLoading, error, start, finish, clear } = useLoadingIndicator({ duration: props.duration, throttle: props.throttle, estimatedProgress: props.estimatedProgress, }) expose({ - progress, isLoading, start, finish, clear + progress, isLoading, error, start, finish, clear, }) return () => h('div', { @@ -47,13 +51,13 @@ export default defineComponent({ width: 'auto', height: `${props.height}px`, opacity: isLoading.value ? 1 : 0, - background: props.color || undefined, + background: error.value ? props.errorColor : props.color || undefined, backgroundSize: `${(100 / progress.value) * 100}% auto`, transform: `scaleX(${progress.value}%)`, transformOrigin: 'left', transition: 'transform 0.1s, height 0.4s, opacity 0.4s', - zIndex: 999999 - } + zIndex: 999999, + }, }, slots) - } + }, }) diff --git a/packages/nuxt/src/app/components/nuxt-root.vue b/packages/nuxt/src/app/components/nuxt-root.vue index cfbb1aaea9..eefe5fee7f 100644 --- a/packages/nuxt/src/app/components/nuxt-root.vue +++ b/packages/nuxt/src/app/components/nuxt-root.vue @@ -1,7 +1,8 @@ <template> <Suspense @resolve="onResolve"> + <div v-if="abortRender" /> <ErrorComponent - v-if="error" + v-else-if="error" :error="error" /> <IslandRenderer @@ -24,8 +25,10 @@ import { useRoute, useRouter } from '../composables/router' import { PageRouteSymbol } from '../components/injections' import AppComponent from '#build/app-component.mjs' import ErrorComponent from '#build/error-component.mjs' +// @ts-expect-error virtual file +import { componentIslands } from '#build/nuxt.config.mjs' -const IslandRenderer = import.meta.server +const IslandRenderer = import.meta.server && componentIslands ? defineAsyncComponent(() => import('./island-renderer').then(r => r.default || r)) : () => null @@ -51,6 +54,8 @@ if (import.meta.dev && results && results.some(i => i && 'then' in i)) { // error handling const error = useError() +// render an empty <div> when plugins have thrown an error but we're not yet rendering the error page +const abortRender = import.meta.server && error.value && !nuxtApp.ssrContext.error onErrorCaptured((err, target, info) => { nuxtApp.hooks.callHook('vue:error', err, target, info).catch(hookError => console.error('[nuxt] Error in `vue:error` hook', hookError)) if (import.meta.server || (isNuxtError(err) && (err.fatal || err.unhandled))) { diff --git a/packages/nuxt/src/app/components/nuxt-route-announcer.ts b/packages/nuxt/src/app/components/nuxt-route-announcer.ts new file mode 100644 index 0000000000..035e9e9e50 --- /dev/null +++ b/packages/nuxt/src/app/components/nuxt-route-announcer.ts @@ -0,0 +1,48 @@ +import { defineComponent, h } from 'vue' +import type { Politeness } from '#app/composables/route-announcer' +import { useRouteAnnouncer } from '#app/composables/route-announcer' + +export default defineComponent({ + name: 'NuxtRouteAnnouncer', + props: { + atomic: { + type: Boolean, + default: false, + }, + politeness: { + type: String as () => Politeness, + default: 'polite', + }, + }, + setup (props, { slots, expose }) { + const { set, polite, assertive, message, politeness } = useRouteAnnouncer({ politeness: props.politeness }) + + expose({ + set, polite, assertive, message, politeness, + }) + + return () => h('span', { + class: 'nuxt-route-announcer', + style: { + position: 'absolute', + }, + }, h('span', { + 'role': 'alert', + 'aria-live': politeness.value, + 'aria-atomic': props.atomic, + 'style': { + 'border': '0', + 'clip': 'rect(0 0 0 0)', + 'clip-path': 'inset(50%)', + 'height': '1px', + 'width': '1px', + 'overflow': 'hidden', + 'position': 'absolute', + 'white-space': 'nowrap', + 'word-wrap': 'normal', + 'margin': '-1px', + 'padding': '0', + }, + }, slots.default ? slots.default({ message: message.value }) : message.value)) + }, +}) diff --git a/packages/nuxt/src/app/components/nuxt-stubs.ts b/packages/nuxt/src/app/components/nuxt-stubs.ts index c2adc56412..5a3d20c10d 100644 --- a/packages/nuxt/src/app/components/nuxt-stubs.ts +++ b/packages/nuxt/src/app/components/nuxt-stubs.ts @@ -4,14 +4,14 @@ function renderStubMessage (name: string) { throw createError({ fatal: true, statusCode: 500, - statusMessage: `${name} is provided by @nuxt/image. Check your console to install it or run 'npx nuxi@latest module add @nuxt/image'` + statusMessage: `${name} is provided by @nuxt/image. Check your console to install it or run 'npx nuxi@latest module add @nuxt/image'`, }) } export const NuxtImg = { - setup: () => renderStubMessage('<NuxtImg>') + setup: () => renderStubMessage('<NuxtImg>'), } export const NuxtPicture = { - setup: () => renderStubMessage('<NuxtPicture>') + setup: () => renderStubMessage('<NuxtPicture>'), } diff --git a/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts index 5d527c3bdb..b8ef06f9c4 100644 --- a/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-component.ts @@ -1,60 +1,58 @@ -import type { Component } from 'vue' -import { Teleport, defineComponent, h } from 'vue' +import type { Component, InjectionKey } from 'vue' +import { Teleport, defineComponent, h, inject, provide } from 'vue' import { useNuxtApp } from '../nuxt' // @ts-expect-error virtual file import { paths } from '#build/components-chunk' type ExtendedComponent = Component & { - __file: string, + __file: string __name: string } +export const NuxtTeleportIslandSymbol = Symbol('NuxtTeleportIslandComponent') as InjectionKey<false | string> + /** * component only used with componentsIsland * this teleport the component in SSR only if it needs to be hydrated on client */ +/* @__PURE__ */ export default defineComponent({ name: 'NuxtTeleportIslandComponent', + inheritAttrs: false, props: { to: { type: String, - required: true + required: true, }, nuxtClient: { type: Boolean, - default: false + default: false, }, - /** - * ONLY used in dev mode since we use build:manifest result in production - * do not pass any value in production - */ - rootDir: { - type: String, - default: null - } }, setup (props, { slots }) { const nuxtApp = useNuxtApp() - if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient) { return () => slots.default!() } + // if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side + if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() } + provide(NuxtTeleportIslandSymbol, props.to) const islandContext = nuxtApp.ssrContext!.islandContext! return () => { - const slot = slots.default!()[0] - const slotType = (slot.type as ExtendedComponent) + const slot = slots.default!()[0]! + const slotType = slot.type as ExtendedComponent const name = (slotType.__name || slotType.name) as string islandContext.components[props.to] = { - chunk: import.meta.dev ? '_nuxt/' + paths[name] : paths[name], - props: slot.props || {} + chunk: import.meta.dev ? nuxtApp.$config.app.buildAssetsDir + paths[name] : paths[name], + props: slot.props || {}, } return [h('div', { - style: 'display: contents;', + 'style': 'display: contents;', 'data-island-uid': '', - 'data-island-component': props.to + 'data-island-component': props.to, }, []), h(Teleport, { to: props.to }, slot)] } - } + }, }) diff --git a/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts index eb1713280e..7db8042735 100644 --- a/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts @@ -1,47 +1,64 @@ -import { Teleport, defineComponent, h } from 'vue' +import type { VNode } from 'vue' +import { Teleport, createVNode, defineComponent, h, inject } from 'vue' import { useNuxtApp } from '../nuxt' - +import { NuxtTeleportIslandSymbol } from './nuxt-teleport-island-component' + /** * component only used within islands for slot teleport */ +/* @__PURE__ */ export default defineComponent({ name: 'NuxtTeleportIslandSlot', + inheritAttrs: false, props: { name: { - type: String, - required: true + type: String, + required: true, }, /** * must be an array to handle v-for */ props: { - type: Object as () => Array<any> - } + type: Object as () => Array<any>, + }, }, setup (props, { slots }) { const nuxtApp = useNuxtApp() const islandContext = nuxtApp.ssrContext?.islandContext - - if(!islandContext) { - return () => slots.default?.() + if (!islandContext) { + return () => slots.default?.()[0] } + const componentName = inject(NuxtTeleportIslandSymbol, false) islandContext.slots[props.name] = { - props: (props.props || []) as unknown[] + props: (props.props || []) as unknown[], } return () => { - const vnodes = [h('div', { - style: 'display: contents;', - 'data-island-uid': '', - 'data-island-slot': props.name, - })] + const vnodes: VNode[] = [] + + if (nuxtApp.ssrContext?.islandContext && slots.default) { + vnodes.push(h('div', { + 'style': 'display: contents;', + 'data-island-uid': '', + 'data-island-slot': props.name, + }, { + // Teleport in slot to not be hydrated client-side with the staticVNode + default: () => [createVNode(Teleport, { to: `island-slot=${componentName};${props.name}` }, slots.default?.())], + })) + } else { + vnodes.push(h('div', { + 'style': 'display: contents;', + 'data-island-uid': '', + 'data-island-slot': props.name, + })) + } if (slots.fallback) { - vnodes.push(h(Teleport, { to: `island-fallback=${props.name}`}, slots.fallback())) + vnodes.push(h(Teleport, { to: `island-fallback=${props.name}` }, slots.fallback())) } return vnodes } - } + }, }) diff --git a/packages/nuxt/src/app/components/route-provider.ts b/packages/nuxt/src/app/components/route-provider.ts index 6f57fb82f6..16ac724bc7 100644 --- a/packages/nuxt/src/app/components/route-provider.ts +++ b/packages/nuxt/src/app/components/route-provider.ts @@ -1,21 +1,21 @@ import { defineComponent, h, nextTick, onMounted, provide, shallowReactive } from 'vue' import type { Ref, VNode } from 'vue' -import type { RouteLocation, RouteLocationNormalizedLoaded } from '#vue-router' +import type { RouteLocationNormalizedLoaded } from 'vue-router' import { PageRouteSymbol } from './injections' export const RouteProvider = defineComponent({ props: { vnode: { type: Object as () => VNode, - required: true + required: true, }, route: { type: Object as () => RouteLocationNormalizedLoaded, - required: true + required: true, }, vnodeRef: Object as () => Ref<any>, renderKey: String, - trackRootNodes: Boolean + trackRootNodes: Boolean, }, setup (props) { // Prevent reactivity when the page will be rerendered in a different suspense fork @@ -23,10 +23,11 @@ export const RouteProvider = defineComponent({ const previousRoute = props.route // Provide a reactive route within the page - const route = {} as RouteLocation + const route = {} as RouteLocationNormalizedLoaded for (const key in props.route) { Object.defineProperty(route, key, { - get: () => previousKey === props.renderKey ? props.route[key as keyof RouteLocationNormalizedLoaded] : previousRoute[key as keyof RouteLocationNormalizedLoaded] + get: () => previousKey === props.renderKey ? props.route[key as keyof RouteLocationNormalizedLoaded] : previousRoute[key as keyof RouteLocationNormalizedLoaded], + enumerable: true, }) } @@ -52,5 +53,5 @@ export const RouteProvider = defineComponent({ return h(props.vnode, { ref: props.vnodeRef }) } - } + }, }) diff --git a/packages/nuxt/src/app/components/server-placeholder.ts b/packages/nuxt/src/app/components/server-placeholder.ts index eaa38e3282..accbfb9857 100644 --- a/packages/nuxt/src/app/components/server-placeholder.ts +++ b/packages/nuxt/src/app/components/server-placeholder.ts @@ -4,5 +4,5 @@ export default defineComponent({ name: 'ServerPlaceholder', render () { return createElementBlock('div') - } + }, }) diff --git a/packages/nuxt/src/app/components/test-component-wrapper.ts b/packages/nuxt/src/app/components/test-component-wrapper.ts index fd185150cc..b2de69d8f5 100644 --- a/packages/nuxt/src/app/components/test-component-wrapper.ts +++ b/packages/nuxt/src/app/components/test-component-wrapper.ts @@ -1,4 +1,3 @@ -import { parseURL } from 'ufo' import { defineComponent, h } from 'vue' import { parseQuery } from 'vue-router' import { resolve } from 'pathe' @@ -8,20 +7,20 @@ import { devRootDir } from '#build/nuxt.config.mjs' export default (url: string) => defineComponent({ name: 'NuxtTestComponentWrapper', - + inheritAttrs: false, async setup (props, { attrs }) { - const query = parseQuery(parseURL(url).search) + const query = parseQuery(new URL(url, 'http://localhost').search) const urlProps = query.props ? destr<Record<string, any>>(query.props as string) : {} const path = resolve(query.path as string) if (!path.startsWith(devRootDir)) { throw new Error(`[nuxt] Cannot access path outside of project root directory: \`${path}\`.`) } - const comp = await import(/* @vite-ignore */ query.path as string).then(r => r.default) + const comp = await import(/* @vite-ignore */ path as string).then(r => r.default) return () => [ - h('div', 'Component Test Wrapper for ' + query.path), + h('div', 'Component Test Wrapper for ' + path), h('div', { id: 'nuxt-component-root' }, [ - h(comp, { ...attrs, ...props, ...urlProps }) - ]) + h(comp, { ...attrs, ...props, ...urlProps }), + ]), ] - } + }, }) diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index 11108cb583..0bde127ec5 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -2,7 +2,7 @@ import { h } from 'vue' import type { Component, RendererNode } from 'vue' // eslint-disable-next-line import { isString, isPromise, isArray, isObject } from '@vue/shared' -import type { RouteLocationNormalized } from '#vue-router' +import type { RouteLocationNormalized } from 'vue-router' // @ts-expect-error virtual file import { START_LOCATION } from '#build/pages' @@ -36,7 +36,7 @@ export function isChangingPage (to: RouteLocationNormalized, from: RouteLocation if (generateRouteKey(to) !== generateRouteKey(from)) { return true } const areComponentsSame = to.matched.every((comp, index) => - comp.components && comp.components.default === from.matched[index]?.components?.default + comp.components && comp.components.default === from.matched[index]?.components?.default, ) if (areComponentsSame) { return false @@ -44,7 +44,6 @@ export function isChangingPage (to: RouteLocationNormalized, from: RouteLocation return true } -// eslint-disable-next-line no-use-before-define export type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean } export type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer> @@ -71,28 +70,10 @@ export function createBuffer () { if (isPromise(item) || (isArray(item) && item.hasAsync)) { buffer.hasAsync = true } - } + }, } } -const TRANSLATE_RE = /&(nbsp|amp|quot|lt|gt);/g -const NUMSTR_RE = /&#(\d+);/gi -export function decodeHtmlEntities (html: string) { - const translateDict = { - nbsp: ' ', - amp: '&', - quot: '"', - lt: '<', - gt: '>' - } as const - return html.replace(TRANSLATE_RE, function (_, entity: keyof typeof translateDict) { - return translateDict[entity] - }).replace(NUMSTR_RE, function (_, numStr: string) { - const num = parseInt(numStr, 10) - return String.fromCharCode(num) - }) -} - /** * helper for NuxtIsland to generate a correct array for scoped data */ @@ -105,7 +86,7 @@ export function vforToArray (source: any): any[] { if (import.meta.dev && !Number.isInteger(source)) { console.warn(`The v-for range expect an integer value but got ${source}.`) } - const array = [] + const array: number[] = [] for (let i = 0; i < source; i++) { array[i] = i } @@ -113,13 +94,13 @@ export function vforToArray (source: any): any[] { } else if (isObject(source)) { if (source[Symbol.iterator as any]) { return Array.from(source as Iterable<any>, item => - item + item, ) } else { const keys = Object.keys(source) const array = new Array(keys.length) for (let i = 0, l = keys.length; i < l; i++) { - const key = keys[i] + const key = keys[i]! array[i] = source[key] } return array diff --git a/packages/nuxt/src/app/composables/asyncData.ts b/packages/nuxt/src/app/composables/asyncData.ts index 12dd1a6515..dc1c24eb77 100644 --- a/packages/nuxt/src/app/composables/asyncData.ts +++ b/packages/nuxt/src/app/composables/asyncData.ts @@ -1,5 +1,5 @@ -import { getCurrentInstance, onBeforeMount, onServerPrefetch, onUnmounted, ref, shallowRef, toRef, unref, watch } from 'vue' -import type { Ref, WatchSource } from 'vue' +import { computed, getCurrentInstance, getCurrentScope, onBeforeMount, onScopeDispose, onServerPrefetch, onUnmounted, ref, shallowRef, toRef, unref, watch } from 'vue' +import type { MultiWatchSources, Ref } from 'vue' import type { NuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt' import { toArray } from '../utils' @@ -12,35 +12,37 @@ import { asyncDataDefaults } from '#build/nuxt.config.mjs' export type AsyncDataRequestStatus = 'idle' | 'pending' | 'success' | 'error' -export type _Transform<Input = any, Output = any> = (input: Input) => Output +export type _Transform<Input = any, Output = any> = (input: Input) => Output | Promise<Output> export type PickFrom<T, K extends Array<string>> = T extends Array<any> ? T : T extends Record<string, any> - ? keyof T extends K[number] - ? T // Exact same keys as the target, skip Pick - : K[number] extends never - ? T - : Pick<T, K[number]> - : T + ? keyof T extends K[number] + ? T // Exact same keys as the target, skip Pick + : K[number] extends never + ? T + : Pick<T, K[number]> + : T export type KeysOf<T> = Array< T extends T // Include all keys of union types, not just common keys - ? keyof T extends string - ? keyof T - : never - : never + ? keyof T extends string + ? keyof T + : never + : never > export type KeyOfRes<Transform extends _Transform> = KeysOf<ReturnType<Transform>> -export type MultiWatchSources = (WatchSource<unknown> | object)[] +export type { MultiWatchSources } + +export type NoInfer<T> = [T][T extends any ? 0 : never] export interface AsyncDataOptions< ResT, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > { /** * Whether to fetch on the server side. @@ -58,16 +60,18 @@ export interface AsyncDataOptions< default?: () => DefaultT | Ref<DefaultT> /** * Provide a function which returns cached data. - * A `null` or `undefined` return value will trigger a fetch. + * An `undefined` return value will trigger a fetch. * Default is `key => nuxt.isHydrating ? nuxt.payload.data[key] : nuxt.static.data[key]` which only caches data when payloadExtraction is enabled. */ - getCachedData?: (key: string) => DataT + getCachedData?: (key: string, nuxtApp: NuxtApp) => NoInfer<DataT> | undefined /** - * A function that can be used to alter handler function result after resolving + * A function that can be used to alter handler function result after resolving. + * Do not use it along with the `pick` option. */ transform?: _Transform<ResT, DataT> /** - * Only pick specified keys in this array from the handler function result + * Only pick specified keys in this array from the handler function result. + * Do not use it along with the `transform` option. */ pick?: PickKeys /** @@ -80,7 +84,7 @@ export interface AsyncDataOptions< */ immediate?: boolean /** - * Return data in a deep ref object (it is true by default). It can be set to false to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. + * Return data in a deep ref object (it is false by default). It can be set to false to return data in a shallow ref object, which can improve performance if your data does not need to be deeply reactive. */ deep?: boolean /** @@ -92,31 +96,25 @@ export interface AsyncDataOptions< export interface AsyncDataExecuteOptions { _initial?: boolean - // TODO: remove boolean option in Nuxt 4 /** * Force a refresh, even if there is already a pending request. Previous requests will * not be cancelled, but their result will not affect the data/pending state - and any * previously awaited promises will not resolve until this new request resolves. - * - * Instead of using `boolean` values, use `cancel` for `true` and `defer` for `false`. - * Boolean values will be removed in a future release. */ - dedupe?: boolean | 'cancel' | 'defer' + dedupe?: 'cancel' | 'defer' } export interface _AsyncData<DataT, ErrorT> { data: Ref<DataT> refresh: (opts?: AsyncDataExecuteOptions) => Promise<void> execute: (opts?: AsyncDataExecuteOptions) => Promise<void> - error: Ref<ErrorT | null> + clear: () => void + error: Ref<ErrorT | undefined> status: Ref<AsyncDataRequestStatus> } export type AsyncData<Data, Error> = _AsyncData<Data, Error> & Promise<_AsyncData<Data, Error>> -// TODO: remove boolean option in Nuxt 4 -const isDefer = (dedupe?: boolean | 'cancel' | 'defer') => dedupe === 'defer' || dedupe === false - /** * Provides access to data that resolves asynchronously in an SSR-friendly composable. * See {@link https://nuxt.com/docs/api/composables/use-async-data} @@ -129,11 +127,11 @@ export function useAsyncData< NuxtErrorDataT = unknown, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( handler: (ctx?: NuxtApp) => Promise<ResT>, options?: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | undefined> /** * Provides access to data that resolves asynchronously in an SSR-friendly composable. * See {@link https://nuxt.com/docs/api/composables/use-async-data} @@ -149,7 +147,7 @@ export function useAsyncData< > ( handler: (ctx?: NuxtApp) => Promise<ResT>, options?: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | undefined> /** * Provides access to data that resolves asynchronously in an SSR-friendly composable. * See {@link https://nuxt.com/docs/api/composables/use-async-data} @@ -162,17 +160,17 @@ export function useAsyncData< NuxtErrorDataT = unknown, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( key: string, handler: (ctx?: NuxtApp) => Promise<ResT>, options?: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | undefined> /** * Provides access to data that resolves asynchronously in an SSR-friendly composable. * See {@link https://nuxt.com/docs/api/composables/use-async-data} * @param key A unique key to ensure that data fetching can be properly de-duplicated across requests. - * @param handler An asynchronous function that must return a truthy value (for example, it should not be `undefined` or `null`) or the request may be duplicated on the client side. + * @param handler An asynchronous function that must return a value (it should not be `undefined`) or the request may be duplicated on the client side. * @param options customize the behavior of useAsyncData */ export function useAsyncData< @@ -185,14 +183,14 @@ export function useAsyncData< key: string, handler: (ctx?: NuxtApp) => Promise<ResT>, options?: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | undefined> export function useAsyncData< ResT, NuxtErrorDataT = unknown, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, -> (...args: any[]): AsyncData<PickFrom<DataT, PickKeys>, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | null> { + DefaultT = undefined, +> (...args: any[]): AsyncData<PickFrom<DataT, PickKeys>, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>) | undefined> { const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined if (typeof args[0] !== 'string') { args.unshift(autoKey) } @@ -211,17 +209,20 @@ export function useAsyncData< const nuxtApp = useNuxtApp() // When prerendering, share payload data automatically between requests - const handler = import.meta.client || !import.meta.prerender || !nuxtApp.ssrContext?._sharedPrerenderCache ? _handler : async () => { - const value = await nuxtApp.ssrContext!._sharedPrerenderCache!.get(key) - if (value) { return value as ResT } + const handler = import.meta.client || !import.meta.prerender || !nuxtApp.ssrContext?._sharedPrerenderCache + ? _handler + : () => { + const value = nuxtApp.ssrContext!._sharedPrerenderCache!.get(key) + if (value) { return value as Promise<ResT> } - const promise = nuxtApp.runWithContext(_handler) - nuxtApp.ssrContext!._sharedPrerenderCache!.set(key, promise) - return promise - } + const promise = Promise.resolve().then(() => nuxtApp.runWithContext(_handler)) + + nuxtApp.ssrContext!._sharedPrerenderCache!.set(key, promise) + return promise + } // Used to get default values - const getDefault = () => null + const getDefault = () => undefined const getDefaultCachedData = () => nuxtApp.isHydrating ? nuxtApp.payload.data[key] : nuxtApp.static.data[key] // Apply defaults @@ -234,39 +235,42 @@ export function useAsyncData< options.deep = options.deep ?? asyncDataDefaults.deep options.dedupe = options.dedupe ?? 'cancel' - if (import.meta.dev && typeof options.dedupe === 'boolean') { - console.warn('[nuxt] `boolean` values are deprecated for the `dedupe` option of `useAsyncData` and will be removed in the future. Use \'cancel\' or \'defer\' instead.') - } - - const hasCachedData = () => ![null, undefined].includes(options.getCachedData!(key) as any) - // Create or use a shared asyncData entity + const initialCachedData = options.getCachedData!(key, nuxtApp) + const hasCachedData = typeof initialCachedData !== 'undefined' + if (!nuxtApp._asyncData[key] || !options.immediate) { - nuxtApp.payload._errors[key] ??= null + nuxtApp.payload._errors[key] ??= undefined const _ref = options.deep ? ref : shallowRef - nuxtApp._asyncData[key] = { - data: _ref(options.getCachedData!(key) ?? options.default!()), + data: _ref(hasCachedData ? initialCachedData : options.default!()), error: toRef(nuxtApp.payload._errors, key), - status: ref('idle') + status: ref('idle'), + _default: options.default!, } } // TODO: Else, somehow check for conflicting keys with different defaults or fetcher - const asyncData = { ...nuxtApp._asyncData[key] } as AsyncData<DataT | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>)> + const asyncData = { ...nuxtApp._asyncData[key] } as { _default?: unknown } & AsyncData<DataT | DefaultT, (NuxtErrorDataT extends Error | NuxtError ? NuxtErrorDataT : NuxtError<NuxtErrorDataT>)> + + // Don't expose default function to end user + delete asyncData._default asyncData.refresh = asyncData.execute = (opts = {}) => { if (nuxtApp._asyncDataPromises[key]) { - if (isDefer(opts.dedupe ?? options.dedupe)) { + if ((opts.dedupe ?? options.dedupe) === 'defer') { // Avoid fetching same key more than once at a time return nuxtApp._asyncDataPromises[key]! } (nuxtApp._asyncDataPromises[key] as any).cancelled = true } // Avoid fetching same key that is already fetched - if ((opts._initial || (nuxtApp.isHydrating && opts._initial !== false)) && hasCachedData()) { - return Promise.resolve(options.getCachedData!(key)) + if ((opts._initial || (nuxtApp.isHydrating && opts._initial !== false))) { + const cachedData = opts._initial ? initialCachedData : options.getCachedData!(key, nuxtApp) + if (typeof cachedData !== 'undefined') { + return Promise.resolve(cachedData) + } } asyncData.status.value = 'pending' // TODO: Cancel previous promise @@ -278,22 +282,27 @@ export function useAsyncData< reject(err) } }) - .then((_result) => { + .then(async (_result) => { // If this request is cancelled, resolve to the latest request. if ((promise as any).cancelled) { return nuxtApp._asyncDataPromises[key] } let result = _result as unknown as DataT if (options.transform) { - result = options.transform(_result) + result = await options.transform(_result) } if (options.pick) { result = pick(result as any, options.pick) as DataT } + if (import.meta.dev && import.meta.server && typeof result === 'undefined') { + // @ts-expect-error private property + console.warn(`[nuxt] \`${options._functionName || 'useAsyncData'}\` must return a value (it should not be \`undefined\`) or the request may be duplicated on the client side.`) + } + nuxtApp.payload.data[key] = result asyncData.data.value = result - asyncData.error.value = null + asyncData.error.value = undefined asyncData.status.value = 'success' }) .catch((error: any) => { @@ -313,6 +322,8 @@ export function useAsyncData< return nuxtApp._asyncDataPromises[key]! } + asyncData.clear = () => clearNuxtDataByKey(nuxtApp, key) + const initialFetch = () => asyncData.refresh({ _initial: true }) const fetchOnServer = options.server !== false && nuxtApp.payload.serverRendered @@ -331,23 +342,21 @@ export function useAsyncData< if (import.meta.client) { // Setup hook callbacks once per instance const instance = getCurrentInstance() - if (import.meta.dev && !nuxtApp.isHydrating && (!instance || instance?.isMounted)) { + 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`) } if (instance && !instance._nuxtOnBeforeMountCbs) { instance._nuxtOnBeforeMountCbs = [] const cbs = instance._nuxtOnBeforeMountCbs - if (instance) { - onBeforeMount(() => { - cbs.forEach((cb) => { cb() }) - cbs.splice(0, cbs.length) - }) - onUnmounted(() => cbs.splice(0, cbs.length)) - } + onBeforeMount(() => { + cbs.forEach((cb) => { cb() }) + cbs.splice(0, cbs.length) + }) + onUnmounted(() => cbs.splice(0, cbs.length)) } - if (fetchOnServer && nuxtApp.isHydrating && (asyncData.error.value || hasCachedData())) { + if (fetchOnServer && nuxtApp.isHydrating && (asyncData.error.value || typeof initialCachedData !== 'undefined')) { // 1. Hydration (server: true): no fetch asyncData.status.value = asyncData.error.value ? 'error' : 'success' } else if (instance && ((nuxtApp.payload.serverRendered && nuxtApp.isHydrating) || options.lazy) && options.immediate) { @@ -358,16 +367,20 @@ export function useAsyncData< // 4. Navigation (lazy: false) - or plugin usage: await fetch initialFetch() } + const hasScope = getCurrentScope() if (options.watch) { - watch(options.watch, () => asyncData.refresh()) + const unsub = watch(options.watch, () => asyncData.refresh()) + if (hasScope) { + onScopeDispose(unsub) + } } const off = nuxtApp.hook('app:data:refresh', async (keys) => { if (!keys || keys.includes(key)) { await asyncData.refresh() } }) - if (instance) { - onUnmounted(off) + if (hasScope) { + onScopeDispose(off) } } @@ -383,11 +396,11 @@ export function useLazyAsyncData< DataE = Error, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( handler: (ctx?: NuxtApp) => Promise<ResT>, options?: Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | undefined> export function useLazyAsyncData< ResT, DataE = Error, @@ -397,18 +410,18 @@ export function useLazyAsyncData< > ( handler: (ctx?: NuxtApp) => Promise<ResT>, options?: Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | undefined> export function useLazyAsyncData< ResT, DataE = Error, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( key: string, handler: (ctx?: NuxtApp) => Promise<ResT>, options?: Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | undefined> export function useLazyAsyncData< ResT, DataE = Error, @@ -419,15 +432,15 @@ export function useLazyAsyncData< key: string, handler: (ctx?: NuxtApp) => Promise<ResT>, options?: Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | undefined> export function useLazyAsyncData< ResT, DataE = Error, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, -> (...args: any[]): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | null> { + DefaultT = undefined, +> (...args: any[]): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, DataE | undefined> { const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined if (typeof args[0] !== 'string') { args.unshift(autoKey) } const [key, handler, options = {}] = args as [string, (ctx?: NuxtApp) => Promise<ResT>, AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>] @@ -442,16 +455,27 @@ export function useLazyAsyncData< } /** @since 3.1.0 */ -export function useNuxtData<DataT = any> (key: string): { data: Ref<DataT | null> } { +export function useNuxtData<DataT = any> (key: string): { data: Ref<DataT | undefined> } { const nuxtApp = useNuxtApp() // Initialize value when key is not already set if (!(key in nuxtApp.payload.data)) { - nuxtApp.payload.data[key] = null + nuxtApp.payload.data[key] = undefined } return { - data: toRef(nuxtApp.payload.data, key) + data: computed({ + get () { + return nuxtApp._asyncData[key]?.data.value ?? nuxtApp.payload.data[key] + }, + set (value) { + if (nuxtApp._asyncData[key]) { + nuxtApp._asyncData[key]!.data.value = value + } else { + nuxtApp.payload.data[key] = value + } + }, + }), } } @@ -478,20 +502,31 @@ export function clearNuxtData (keys?: string | string[] | ((key: string) => bool : toArray(keys) for (const key of _keys) { - if (key in nuxtApp.payload.data) { - nuxtApp.payload.data[key] = undefined - } - if (key in nuxtApp.payload._errors) { - nuxtApp.payload._errors[key] = null - } - if (nuxtApp._asyncData[key]) { - nuxtApp._asyncData[key]!.data.value = undefined - nuxtApp._asyncData[key]!.error.value = null - nuxtApp._asyncData[key]!.status.value = 'idle' - } - if (key in nuxtApp._asyncDataPromises) { - nuxtApp._asyncDataPromises[key] = undefined + clearNuxtDataByKey(nuxtApp, key) + } +} + +function clearNuxtDataByKey (nuxtApp: NuxtApp, key: string): void { + if (key in nuxtApp.payload.data) { + nuxtApp.payload.data[key] = undefined + } + + if (key in nuxtApp.payload._errors) { + nuxtApp.payload._errors[key] = undefined + } + + if (nuxtApp._asyncData[key]) { + nuxtApp._asyncData[key]!.data.value = nuxtApp._asyncData[key]!._default() + nuxtApp._asyncData[key]!.error.value = undefined + nuxtApp._asyncData[key]!.status.value = 'idle' + } + + if (key in nuxtApp._asyncDataPromises) { + if (nuxtApp._asyncDataPromises[key]) { + (nuxtApp._asyncDataPromises[key] as any).cancelled = true } + + nuxtApp._asyncDataPromises[key] = undefined } } diff --git a/packages/nuxt/src/app/composables/component.ts b/packages/nuxt/src/app/composables/component.ts index e23436decf..e8bd93e20c 100644 --- a/packages/nuxt/src/app/composables/component.ts +++ b/packages/nuxt/src/app/composables/component.ts @@ -16,7 +16,7 @@ async function runLegacyAsyncData (res: Record<string, any> | Promise<Record<str const { fetchKey, _fetchKeyBase } = vm.proxy!.$options const key = (typeof fetchKey === 'function' ? fetchKey(() => '') : fetchKey) || ([_fetchKeyBase, route.fullPath, route.matched.findIndex(r => Object.values(r.components || {}).includes(vm.type))].join(':')) - const { data, error } = await useAsyncData(`options:asyncdata:${key}`, () => nuxtApp.runWithContext(() => fn(nuxtApp))) + const { data, error } = await useAsyncData(`options:asyncdata:${key}`, () => import.meta.server ? nuxtApp.runWithContext(() => fn(nuxtApp)) : fn(nuxtApp)) if (error.value) { throw createError(error.value) } @@ -28,7 +28,7 @@ async function runLegacyAsyncData (res: Record<string, any> | Promise<Record<str } /** @since 3.0.0 */ -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ export const defineNuxtComponent: typeof defineComponent = function defineNuxtComponent (...args: any[]): any { const [options, key] = args @@ -38,7 +38,7 @@ export const defineNuxtComponent: typeof defineComponent = if (!setup && !options.asyncData && !options.head) { return { [NuxtComponentIndicator]: true, - ...options + ...options, } } @@ -66,6 +66,6 @@ export const defineNuxtComponent: typeof defineComponent = .finally(() => { promises.length = 0 }) - } + }, } as DefineComponent } diff --git a/packages/nuxt/src/app/composables/cookie.ts b/packages/nuxt/src/app/composables/cookie.ts index 09015f8544..47a9299673 100644 --- a/packages/nuxt/src/app/composables/cookie.ts +++ b/packages/nuxt/src/app/composables/cookie.ts @@ -23,13 +23,14 @@ export interface CookieOptions<T = any> extends _CookieOptions { readonly?: boolean } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface CookieRef<T> extends Ref<T> {} const CookieDefaults = { path: '/', watch: true, decode: val => destr(decodeURIComponent(val)), - encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)) + encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)), } satisfies CookieOptions<any> const store = import.meta.client && cookieStore ? window.cookieStore : undefined @@ -39,6 +40,7 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: export function useCookie<T = string | null | undefined> (name: string, _opts: CookieOptions<T> & { readonly: true }): Readonly<CookieRef<T>> export function useCookie<T = string | null | undefined> (name: string, _opts?: CookieOptions<T>): CookieRef<T> { const opts = { ...CookieDefaults, ..._opts } + opts.filter ??= key => key === name const cookies = readRawCookies(opts) || {} let delay: number | undefined @@ -55,7 +57,7 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: // use a custom ref to expire the cookie on client side otherwise use basic ref const cookie = import.meta.client && delay && !hasExpired - ? cookieRef<T | undefined>(cookieValue, delay) + ? cookieRef<T | undefined>(cookieValue, delay, opts.watch && opts.watch !== 'shallow') : ref<T | undefined>(cookieValue) if (import.meta.dev && hasExpired) { @@ -63,7 +65,15 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: } if (import.meta.client) { - const channel = store || typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`) + let channel: null | BroadcastChannel = null + try { + if (!store && typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(`nuxt:cookies:${name}`) + } + } catch { + // BroadcastChannel will fail in certain situations when cookies are disabled + // or running in an iframe: see https://github.com/nuxt/nuxt/issues/26338 + } const callback = () => { if (opts.readonly || isEqual(cookie.value, cookies[name])) { return } writeClientCookie(name, cookie.value, opts as CookieSerializeOptions) @@ -75,13 +85,16 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: const handleChange = (data: { value?: any, refresh?: boolean }) => { const value = data.refresh ? readRawCookies(opts)?.[name] : opts.decode(data.value) watchPaused = true - cookies[name] = cookie.value = value + cookie.value = value + cookies[name] = klona(value) nextTick(() => { watchPaused = false }) } let watchPaused = false - if (getCurrentScope()) { + const hasScope = !!getCurrentScope() + + if (hasScope) { onScopeDispose(() => { watchPaused = true callback() @@ -90,9 +103,22 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: } if (store) { - store.onchange = (event) => { - const cookie = event.changed.find((c: any) => c.name === name) - if (cookie) handleChange({ value: cookie.value }) + /* event is of type CookieChangeEvent */ + const changeHandler = (event: any) => { + const changedCookie = event.changed.find((c: any) => c.name === name) + const removedCookie = event.deleted.find((c: any) => c.name === name) + + if (changedCookie) { + handleChange({ value: changedCookie.value }) + } + + if (removedCookie) { + handleChange({ value: null }) + } + } + store.addEventListener('change', changeHandler) + if (hasScope) { + onScopeDispose(() => store.removeEventListener('change', changeHandler)) } } else if (channel) { channel.onmessage = ({ data }) => handleChange(data) @@ -111,6 +137,16 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: const nuxtApp = useNuxtApp() const writeFinalCookieValue = () => { if (opts.readonly || isEqual(cookie.value, cookies[name])) { return } + nuxtApp._cookies ||= {} + if (name in nuxtApp._cookies) { + // do not append a second `set-cookie` header + if (isEqual(cookie.value, nuxtApp._cookies[name])) { return } + // warn in dev mode + if (import.meta.dev) { + console.warn(`[nuxt] cookie \`${name}\` was previously set to \`${opts.encode(nuxtApp._cookies[name] as any)}\` and is being overridden to \`${opts.encode(cookie.value as any)}\`. This may cause unexpected issues.`) + } + } + nuxtApp._cookies[name] = cookie.value writeServerCookie(useRequestEvent(nuxtApp)!, name, cookie.value, opts as CookieOptions<any>) } const unhook = nuxtApp.hooks.hookOnce('app:rendered', writeFinalCookieValue) @@ -123,9 +159,9 @@ export function useCookie<T = string | null | undefined> (name: string, _opts?: return cookie as CookieRef<T> } /** @since 3.10.0 */ -export function refreshCookie(name: string) { - if (store || typeof BroadcastChannel === 'undefined') return - +export function refreshCookie (name: string) { + if (import.meta.server || store || typeof BroadcastChannel === 'undefined') { return } + new BroadcastChannel(`nuxt:cookies:${name}`)?.postMessage({ refresh: true }) } @@ -174,15 +210,23 @@ function writeServerCookie (event: H3Event, name: string, value: any, opts: Cook const MAX_TIMEOUT_DELAY = 2_147_483_647 // custom ref that will update the value to undefined if the cookie expires -function cookieRef<T> (value: T | undefined, delay: number) { +function cookieRef<T> (value: T | undefined, delay: number, shouldWatch: boolean) { let timeout: NodeJS.Timeout + let unsubscribe: (() => void) | undefined let elapsed = 0 + const internalRef = shouldWatch ? ref(value) : { value } if (getCurrentScope()) { - onScopeDispose(() => { clearTimeout(timeout) }) + onScopeDispose(() => { + unsubscribe?.() + clearTimeout(timeout) + }) } return customRef((track, trigger) => { + if (shouldWatch) { unsubscribe = watch(internalRef, trigger) } + function createExpirationTimeout () { + elapsed = 0 clearTimeout(timeout) const timeRemaining = delay - elapsed const timeoutLength = timeRemaining < MAX_TIMEOUT_DELAY ? timeRemaining : MAX_TIMEOUT_DELAY @@ -190,7 +234,7 @@ function cookieRef<T> (value: T | undefined, delay: number) { elapsed += timeoutLength if (elapsed < delay) { return createExpirationTimeout() } - value = undefined + internalRef.value = undefined trigger() }, timeoutLength) } @@ -198,14 +242,14 @@ function cookieRef<T> (value: T | undefined, delay: number) { return { get () { track() - return value + return internalRef.value }, set (newValue) { createExpirationTimeout() - value = newValue + internalRef.value = newValue trigger() - } + }, } }) } diff --git a/packages/nuxt/src/app/composables/error.ts b/packages/nuxt/src/app/composables/error.ts index 2f6a748a08..6bde98fbdc 100644 --- a/packages/nuxt/src/app/composables/error.ts +++ b/packages/nuxt/src/app/composables/error.ts @@ -1,19 +1,25 @@ import type { H3Error } from 'h3' import { createError as createH3Error } from 'h3' import { toRef } from 'vue' +import type { Ref } from 'vue' import { useNuxtApp } from '../nuxt' +import type { NuxtPayload } from '../nuxt' import { useRouter } from './router' export const NUXT_ERROR_SIGNATURE = '__nuxt_error' /** @since 3.0.0 */ -export const useError = () => toRef(useNuxtApp().payload, 'error') +export const useError = (): Ref<NuxtPayload['error']> => toRef(useNuxtApp().payload, 'error') +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface NuxtError<DataT = unknown> extends H3Error<DataT> {} /** @since 3.0.0 */ export const showError = <DataT = unknown>( - error: string | Error | Partial<NuxtError<DataT>> + error: string | Error | (Partial<NuxtError<DataT>> & { + status?: number + statusText?: string + }), ) => { const nuxtError = createError<DataT>(error) @@ -44,26 +50,27 @@ export const clearError = async (options: { redirect?: string } = {}) => { await useRouter().replace(options.redirect) } - error.value = null + error.value = undefined } /** @since 3.0.0 */ export const isNuxtError = <DataT = unknown>( - error?: string | object -): error is NuxtError<DataT> => ( - !!error && typeof error === 'object' && NUXT_ERROR_SIGNATURE in error -) + error: unknown, +): error is NuxtError<DataT> => !!error && typeof error === 'object' && NUXT_ERROR_SIGNATURE in error /** @since 3.0.0 */ export const createError = <DataT = unknown>( - error: string | Partial<NuxtError<DataT>> + error: string | Error | (Partial<NuxtError<DataT>> & { + status?: number + statusText?: string + }), ) => { const nuxtError: NuxtError<DataT> = createH3Error<DataT>(error) Object.defineProperty(nuxtError, NUXT_ERROR_SIGNATURE, { value: true, configurable: false, - writable: false + writable: false, }) return nuxtError diff --git a/packages/nuxt/src/app/composables/fetch.ts b/packages/nuxt/src/app/composables/fetch.ts index 10e4d27fae..5ce5a87d1f 100644 --- a/packages/nuxt/src/app/composables/fetch.ts +++ b/packages/nuxt/src/app/composables/fetch.ts @@ -1,5 +1,5 @@ import type { FetchError, FetchOptions } from 'ofetch' -import type { NitroFetchRequest, TypedInternalResponse, AvailableRouterMethod as _AvailableRouterMethod } from 'nitropack' +import type { NitroFetchRequest, TypedInternalResponse, AvailableRouterMethod as _AvailableRouterMethod } from 'nitro/types' import type { MaybeRef, Ref } from 'vue' import { computed, reactive, toValue } from 'vue' import { hash } from 'ohash' @@ -17,11 +17,12 @@ type AvailableRouterMethod<R extends NitroFetchRequest> = _AvailableRouterMethod export type FetchResult<ReqT extends NitroFetchRequest, M extends AvailableRouterMethod<ReqT>> = TypedInternalResponse<ReqT, unknown, Lowercase<M>> type ComputedOptions<T extends Record<string, any>> = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K in keyof T]: T[K] extends Function ? T[K] : ComputedOptions<T[K]> | Ref<T[K]> | T[K] } interface NitroFetchOptions<R extends NitroFetchRequest, M extends AvailableRouterMethod<R> = AvailableRouterMethod<R>> extends FetchOptions { - method?: M; + method?: M } type ComputedFetchOptions<R extends NitroFetchRequest, M extends AvailableRouterMethod<R>> = ComputedOptions<NitroFetchOptions<R, M>> @@ -30,9 +31,9 @@ export interface UseFetchOptions< ResT, DataT = ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, R extends NitroFetchRequest = string & {}, - M extends AvailableRouterMethod<R> = AvailableRouterMethod<R> + M extends AvailableRouterMethod<R> = AvailableRouterMethod<R>, > extends Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'watch'>, ComputedFetchOptions<R, M> { key?: string $fetch?: typeof globalThis.$fetch @@ -54,11 +55,11 @@ export function useFetch< _ResT = ResT extends void ? FetchResult<ReqT, Method> : ResT, DataT = _ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( request: Ref<ReqT> | ReqT | (() => ReqT), opts?: UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | undefined> /** * Fetch data from an API endpoint with an SSR-friendly composable. * See {@link https://nuxt.com/docs/api/composables/use-fetch} @@ -77,7 +78,7 @@ export function useFetch< > ( request: Ref<ReqT> | ReqT | (() => ReqT), opts?: UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | undefined> export function useFetch< ResT = void, ErrorT = FetchError, @@ -86,21 +87,15 @@ export function useFetch< _ResT = ResT extends void ? FetchResult<ReqT, Method> : ResT, DataT = _ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( request: Ref<ReqT> | ReqT | (() => ReqT), arg1?: string | UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>, - arg2?: string + arg2?: string, ) { const [opts = {}, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2] - const _request = computed(() => { - let r = request - if (typeof r === 'function') { - r = r() - } - return toValue(r) - }) + const _request = computed(() => toValue(request)) const _key = opts.key || hash([autoKey, typeof _request.value === 'string' ? _request.value : '', ...generateOptionSegments(opts)]) if (!_key || typeof _key !== 'string') { @@ -133,7 +128,7 @@ export function useFetch< const _fetchOptions = reactive({ ...fetchDefaults, ...fetchOptions, - cache: typeof opts.cache === 'boolean' ? undefined : opts.cache + cache: typeof opts.cache === 'boolean' ? undefined : opts.cache, }) const _asyncDataOptions: AsyncDataOptions<_ResT, DataT, PickKeys, DefaultT> = { @@ -146,7 +141,7 @@ export function useFetch< getCachedData, deep, dedupe, - watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])] + watch: watch === false ? [] : [_fetchOptions, _request, ...(watch || [])], } if (import.meta.dev && import.meta.client) { @@ -157,7 +152,7 @@ export function useFetch< let controller: AbortController const asyncData = useAsyncData<_ResT, ErrorT, DataT, PickKeys, DefaultT>(key, () => { - controller?.abort?.() + controller?.abort?.(new DOMException('Request aborted as another request to the same endpoint was initiated.', 'AbortError')) controller = typeof AbortController !== 'undefined' ? new AbortController() : {} as AbortController /** @@ -167,8 +162,10 @@ export function useFetch< * @see https://github.com/unjs/ofetch/blob/bb2d72baa5d3f332a2185c20fc04e35d2c3e258d/src/fetch.ts#L152 */ const timeoutLength = toValue(opts.timeout) + let timeoutId: NodeJS.Timeout if (timeoutLength) { - setTimeout(() => controller.abort(), timeoutLength) + timeoutId = setTimeout(() => controller.abort(new DOMException('Request aborted due to timeout.', 'AbortError')), timeoutLength) + controller.signal.onabort = () => clearTimeout(timeoutId) } let _$fetch = opts.$fetch || globalThis.$fetch @@ -181,7 +178,7 @@ export function useFetch< } } - return _$fetch(_request.value, { signal: controller.signal, ..._fetchOptions } as any) as Promise<_ResT> + return _$fetch(_request.value, { signal: controller.signal, ..._fetchOptions } as any).finally(() => { clearTimeout(timeoutId) }) as Promise<_ResT> }, _asyncDataOptions) return asyncData @@ -196,11 +193,11 @@ export function useLazyFetch< _ResT = ResT extends void ? FetchResult<ReqT, Method> : ResT, DataT = _ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( request: Ref<ReqT> | ReqT | (() => ReqT), opts?: Omit<UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | undefined> export function useLazyFetch< ResT = void, ErrorT = FetchError, @@ -213,7 +210,7 @@ export function useLazyFetch< > ( request: Ref<ReqT> | ReqT | (() => ReqT), opts?: Omit<UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>, 'lazy'> -): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | null> +): AsyncData<PickFrom<DataT, PickKeys> | DefaultT, ErrorT | undefined> export function useLazyFetch< ResT = void, ErrorT = FetchError, @@ -222,11 +219,11 @@ export function useLazyFetch< _ResT = ResT extends void ? FetchResult<ReqT, Method> : ResT, DataT = _ResT, PickKeys extends KeysOf<DataT> = KeysOf<DataT>, - DefaultT = null, + DefaultT = undefined, > ( request: Ref<ReqT> | ReqT | (() => ReqT), arg1?: string | Omit<UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>, 'lazy'>, - arg2?: string + arg2?: string, ) { const [opts = {}, autoKey] = typeof arg1 === 'string' ? [{}, arg1] : [arg1, arg2] @@ -237,13 +234,13 @@ export function useLazyFetch< return useFetch<ResT, ErrorT, ReqT, Method, _ResT, DataT, PickKeys, DefaultT>(request, { ...opts, - lazy: true + lazy: true, }, // @ts-expect-error we pass an extra argument with the resolved auto-key to prevent another from being injected autoKey) } -function generateOptionSegments <_ResT, DataT, DefaultT>(opts: UseFetchOptions<_ResT, DataT, any, DefaultT, any, any>) { +function generateOptionSegments<_ResT, DataT, DefaultT> (opts: UseFetchOptions<_ResT, DataT, any, DefaultT, any, any>) { const segments: Array<string | undefined | Record<string, string>> = [ toValue(opts.method as MaybeRef<string | undefined> | undefined)?.toUpperCase() || 'GET', toValue(opts.baseURL), diff --git a/packages/nuxt/src/app/composables/id.ts b/packages/nuxt/src/app/composables/id.ts index 728ebcf238..7b3fdd6076 100644 --- a/packages/nuxt/src/app/composables/id.ts +++ b/packages/nuxt/src/app/composables/id.ts @@ -1,56 +1,3 @@ -import { getCurrentInstance, inject } from 'vue' -import { useNuxtApp } from '../nuxt' -import { clientOnlySymbol } from '#app/components/client-only' +import { useId as _useId } from 'vue' -const ATTR_KEY = 'data-n-ids' - -/** - * Generate an SSR-friendly unique identifier that can be passed to accessibility attributes. - */ -export function useId (): string -export function useId (key?: string): string { - if (typeof key !== 'string') { - throw new TypeError('[nuxt] [useId] key must be a string.') - } - // TODO: implement in composable-keys - key = key.slice(1) - const nuxtApp = useNuxtApp() - const instance = getCurrentInstance() - - if (!instance) { - // TODO: support auto-incrementing ID for plugins if there is need? - throw new TypeError('[nuxt] `useId` must be called within a component setup function.') - } - - nuxtApp._id ||= 0 - instance._nuxtIdIndex ||= {} - instance._nuxtIdIndex[key] ||= 0 - - const instanceIndex = key + ':' + instance._nuxtIdIndex[key]++ - - if (import.meta.server) { - const ids = JSON.parse(instance.attrs[ATTR_KEY] as string | undefined || '{}') - ids[instanceIndex] = key + ':' + nuxtApp._id++ - instance.attrs[ATTR_KEY] = JSON.stringify(ids) - return ids[instanceIndex] - } - - if (nuxtApp.payload.serverRendered && nuxtApp.isHydrating && !inject(clientOnlySymbol, false)) { - // Access data attribute from sibling if root is a comment node and sibling is an element - const el = instance.vnode.el?.nodeType === 8 && instance.vnode.el?.nextElementSibling?.getAttribute - ? instance.vnode.el?.nextElementSibling - : instance.vnode.el - - const ids = JSON.parse(el?.getAttribute?.(ATTR_KEY) || '{}') - if (ids[instanceIndex]) { - return ids[instanceIndex] - } - - if (import.meta.dev && instance.vnode.type && typeof instance.vnode.type === 'object' && 'inheritAttrs' in instance.vnode.type && instance.vnode.type.inheritAttrs === false) { - console.warn('[nuxt] `useId` might not work correctly with components that have `inheritAttrs: false`.') - } - } - - // pure client-side ids, avoiding potential collision with server-side ids - return key + '_' + nuxtApp._id++ -} +export const useId = _useId diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 92a6960a57..c0abdec2e4 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -9,12 +9,12 @@ export { /** @deprecated Import `useSeoMeta` from `#imports` instead. This may be removed in a future minor version. */ useSeoMeta, /** @deprecated Import `useServerSeoMeta` from `#imports` instead. This may be removed in a future minor version. */ - useServerSeoMeta + useServerSeoMeta, } from '@unhead/vue' export { defineNuxtComponent } from './component' export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData' -export type { AsyncDataOptions, AsyncData } from './asyncData' +export type { AsyncDataOptions, AsyncData, AsyncDataRequestStatus } from './asyncData' export { useHydration } from './hydrate' export { callOnce } from './once' export { useState, clearNuxtState } from './state' @@ -24,7 +24,7 @@ export { useFetch, useLazyFetch } from './fetch' export type { FetchResult, UseFetchOptions } from './fetch' export { useCookie, refreshCookie } from './cookie' export type { CookieOptions, CookieRef } from './cookie' -export { prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus } from './ssr' +export { onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus } from './ssr' export { onNuxtReady } from './ready' export { abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter } from './router' export type { AddRouteMiddlewareOptions, RouteMiddleware } from './router' @@ -35,4 +35,6 @@ export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest' export type { ReloadNuxtAppOptions } from './chunk' export { reloadNuxtApp } from './chunk' export { useRequestURL } from './url' +export { usePreviewMode } from './preview' export { useId } from './id' +export { useRouteAnnouncer } from './route-announcer' diff --git a/packages/nuxt/src/app/composables/loading-indicator.ts b/packages/nuxt/src/app/composables/loading-indicator.ts index 2003f68841..b3c0a86455 100644 --- a/packages/nuxt/src/app/composables/loading-indicator.ts +++ b/packages/nuxt/src/app/composables/loading-indicator.ts @@ -7,6 +7,10 @@ export type LoadingIndicatorOpts = { duration: number /** @default 200 */ throttle: number + /** @default 500 */ + hideDelay: number + /** @default 400 */ + resetDelay: number /** * You can provide a custom function to customize the progress estimation, * which is a function that receives the duration of the loading bar (above) @@ -15,22 +19,14 @@ export type LoadingIndicatorOpts = { estimatedProgress?: (duration: number, elapsed: number) => number } -function _hide (isLoading: Ref<boolean>, progress: Ref<number>) { - if (import.meta.client) { - setTimeout(() => { - isLoading.value = false - setTimeout(() => { progress.value = 0 }, 400) - }, 500) - } -} - export type LoadingIndicator = { _cleanup: () => void progress: Ref<number> isLoading: Ref<boolean> + error: Ref<boolean> start: () => void set: (value: number) => void - finish: () => void + finish: (opts?: { force?: boolean, error?: boolean }) => void clear: () => void } @@ -40,17 +36,23 @@ function defaultEstimatedProgress (duration: number, elapsed: number): number { } function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) { - const { duration = 2000, throttle = 200 } = opts + const { duration = 2000, throttle = 200, hideDelay = 500, resetDelay = 400 } = opts const getProgress = opts.estimatedProgress || defaultEstimatedProgress const nuxtApp = useNuxtApp() const progress = ref(0) const isLoading = ref(false) + const error = ref(false) let done = false let rafId: number - let _throttle: any = null + let throttleTimeout: number | NodeJS.Timeout + let hideTimeout: number | NodeJS.Timeout + let resetTimeout: number | NodeJS.Timeout - const start = () => set(0) + const start = () => { + error.value = false + set(0) + } function set (at = 0) { if (nuxtApp.isHydrating) { @@ -60,7 +62,7 @@ function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) { clear() progress.value = at < 0 ? 0 : at if (throttle && import.meta.client) { - _throttle = setTimeout(() => { + throttleTimeout = setTimeout(() => { isLoading.value = true _startProgress() }, throttle) @@ -70,19 +72,43 @@ function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) { } } - function finish () { + function _hide () { + if (import.meta.client) { + hideTimeout = setTimeout(() => { + isLoading.value = false + resetTimeout = setTimeout(() => { progress.value = 0 }, resetDelay) + }, hideDelay) + } + } + + function finish (opts: { force?: boolean, error?: boolean } = {}) { progress.value = 100 done = true clear() - _hide(isLoading, progress) + _clearTimeouts() + if (opts.error) { + error.value = true + } + if (opts.force) { + progress.value = 0 + isLoading.value = false + } else { + _hide() + } + } + + function _clearTimeouts () { + if (import.meta.client) { + clearTimeout(hideTimeout) + clearTimeout(resetTimeout) + } } function clear () { - clearTimeout(_throttle) if (import.meta.client) { + clearTimeout(throttleTimeout) cancelAnimationFrame(rafId) } - _throttle = null } function _startProgress () { @@ -113,7 +139,7 @@ function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) { const unsubLoadingFinishHook = nuxtApp.hook('page:loading:end', () => { finish() }) - const unsubError = nuxtApp.hook('vue:error', finish) + const unsubError = nuxtApp.hook('vue:error', () => finish()) _cleanup = () => { unsubError() @@ -127,10 +153,11 @@ function createLoadingIndicator (opts: Partial<LoadingIndicatorOpts> = {}) { _cleanup, progress: computed(() => progress.value), isLoading: computed(() => isLoading.value), + error: computed(() => error.value), start, set, finish, - clear + clear, } } diff --git a/packages/nuxt/src/app/composables/manifest.ts b/packages/nuxt/src/app/composables/manifest.ts index cc7ff41480..c828faeeb3 100644 --- a/packages/nuxt/src/app/composables/manifest.ts +++ b/packages/nuxt/src/app/composables/manifest.ts @@ -1,11 +1,11 @@ import type { MatcherExport, RouteMatcher } from 'radix3' -import { createMatcherFromExport } from 'radix3' +import { createMatcherFromExport, createRouter as createRadixRouter, toRouteMatcher } from 'radix3' import { defu } from 'defu' -import { useAppConfig } from '../config' +import { useRuntimeConfig } from '../nuxt' // @ts-expect-error virtual file import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs' // @ts-expect-error virtual file -import { buildAssetsURL } from '#build/paths.mjs' +import { buildAssetsURL } from '#internal/nuxt/paths' export interface NuxtAppManifestMeta { id: string @@ -24,11 +24,13 @@ function fetchManifest () { if (!isAppManifestEnabled) { throw new Error('[nuxt] app manifest should be enabled with `experimental.appManifest`') } - // @ts-expect-error private property - const buildId = useAppConfig().nuxt?.buildId - manifest = $fetch<NuxtAppManifest>(buildAssetsURL(`builds/meta/${buildId}.json`)) + manifest = $fetch<NuxtAppManifest>(buildAssetsURL(`builds/meta/${useRuntimeConfig().app.buildId}.json`), { + responseType: 'json', + }) manifest.then((m) => { matcher = createMatcherFromExport(m.matcher) + }).catch((e) => { + console.error('[nuxt] Error fetching app manifest.', e) }) return manifest } @@ -43,6 +45,21 @@ export function getAppManifest (): Promise<NuxtAppManifest> { /** @since 3.7.4 */ export async function getRouteRules (url: string) { + if (import.meta.server) { + const _routeRulesMatcher = toRouteMatcher( + createRadixRouter({ routes: useRuntimeConfig().nitro!.routeRules }), + ) + return defu({} as Record<string, any>, ..._routeRulesMatcher.matchAll(url).reverse()) + } await getAppManifest() - return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse()) + if (!matcher) { + console.error('[nuxt] Error creating app manifest matcher.', matcher) + return {} + } + try { + return defu({} as Record<string, any>, ...matcher.matchAll(url).reverse()) + } catch (e) { + console.error('[nuxt] Error matching route rules.', e) + return {} + } } diff --git a/packages/nuxt/src/app/composables/payload.ts b/packages/nuxt/src/app/composables/payload.ts index a4eeb66582..e24d34feab 100644 --- a/packages/nuxt/src/app/composables/payload.ts +++ b/packages/nuxt/src/app/composables/payload.ts @@ -1,14 +1,15 @@ import { hasProtocol, joinURL, withoutTrailingSlash } from 'ufo' import { parse } from 'devalue' import { useHead } from '@unhead/vue' -import { getCurrentInstance } from 'vue' +import { getCurrentInstance, onServerPrefetch, reactive } from 'vue' import { useNuxtApp, useRuntimeConfig } from '../nuxt' +import type { NuxtPayload } from '../nuxt' import { useRoute } from './router' import { getAppManifest, getRouteRules } from './manifest' // @ts-expect-error virtual import -import { appManifest, payloadExtraction, renderJsonPayloads } from '#build/nuxt.config.mjs' +import { appId, appManifest, multiApp, payloadExtraction, renderJsonPayloads } from '#build/nuxt.config.mjs' interface LoadPayloadOptions { fresh?: boolean @@ -16,13 +17,13 @@ interface LoadPayloadOptions { } /** @since 3.0.0 */ -export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record<string, any> | Promise<Record<string, any>> | null { +export async function loadPayload (url: string, opts: LoadPayloadOptions = {}): Promise<Record<string, any> | null> { if (import.meta.server || !payloadExtraction) { return null } - const payloadURL = _getPayloadURL(url, opts) + const payloadURL = await _getPayloadURL(url, opts) const nuxtApp = useNuxtApp() const cache = nuxtApp._payloadCache = nuxtApp._payloadCache || {} if (payloadURL in cache) { - return cache[payloadURL] + return cache[payloadURL] || null } cache[payloadURL] = isPrerendered(url).then((prerendered) => { if (!prerendered) { @@ -39,25 +40,34 @@ export function loadPayload (url: string, opts: LoadPayloadOptions = {}): Record return cache[payloadURL] } /** @since 3.0.0 */ -export function preloadPayload (url: string, opts: LoadPayloadOptions = {}) { - const payloadURL = _getPayloadURL(url, opts) - useHead({ - link: [ - { rel: 'modulepreload', href: payloadURL } - ] +export function preloadPayload (url: string, opts: LoadPayloadOptions = {}): Promise<void> { + const nuxtApp = useNuxtApp() + const promise = _getPayloadURL(url, opts).then((payloadURL) => { + nuxtApp.runWithContext(() => useHead({ + link: [ + { rel: 'modulepreload', href: payloadURL }, + ], + })) }) + if (import.meta.server) { + onServerPrefetch(() => promise) + } + return promise } // --- Internal --- -const extension = renderJsonPayloads ? 'json' : 'js' -function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { +const filename = renderJsonPayloads ? '_payload.json' : '_payload.js' +async function _getPayloadURL (url: string, opts: LoadPayloadOptions = {}) { const u = new URL(url, 'http://localhost') if (u.host !== 'localhost' || hasProtocol(u.pathname, { acceptRelative: true })) { throw new Error('Payload URL must not include hostname: ' + url) } - const hash = opts.hash || (opts.fresh ? Date.now() : '') - return joinURL(useRuntimeConfig().app.baseURL, u.pathname, hash ? `_payload.${hash}.${extension}` : `_payload.${extension}`) + const config = useRuntimeConfig() + const hash = opts.hash || (opts.fresh ? Date.now() : config.app.buildId) + const cdnURL = config.app.cdnURL + const baseOrCdnURL = cdnURL && await isPrerendered(url) ? cdnURL : config.app.baseURL + return joinURL(baseOrCdnURL, u.pathname, filename + (hash ? `?${hash}` : '')) } async function _importPayload (payloadURL: string) { @@ -86,19 +96,20 @@ export async function isPrerendered (url = useRoute().path) { return !!rules.prerender && !rules.redirect } -let payloadCache: any = null +let payloadCache: NuxtPayload | null = null + /** @since 3.4.0 */ export async function getNuxtClientPayload () { if (import.meta.server) { - return + return null } if (payloadCache) { return payloadCache } - const el = document.getElementById('__NUXT_DATA__') + const el = multiApp ? document.querySelector(`[data-nuxt-data="${appId}"]`) as HTMLElement : document.getElementById('__NUXT_DATA__') if (!el) { - return {} + return {} as Partial<NuxtPayload> } const inlineData = await parsePayload(el.textContent || '') @@ -108,7 +119,11 @@ export async function getNuxtClientPayload () { payloadCache = { ...inlineData, ...externalData, - ...window.__NUXT__ + ...(multiApp ? window.__NUXT__?.[appId] : window.__NUXT__), + } + + if (payloadCache!.config?.public) { + payloadCache!.config.public = reactive(payloadCache!.config.public) } return payloadCache @@ -124,7 +139,7 @@ export async function parsePayload (payload: string) { */ export function definePayloadReducer ( name: string, - reduce: (data: any) => any + reduce: (data: any) => any, ) { if (import.meta.server) { useNuxtApp().ssrContext!._payloadReducers[name] = reduce @@ -139,7 +154,7 @@ export function definePayloadReducer ( */ export function definePayloadReviver ( name: string, - revive: (data: any) => any | undefined + revive: (data: any) => any | undefined, ) { if (import.meta.dev && getCurrentInstance()) { console.warn('[nuxt] [definePayloadReviver] This function must be called in a Nuxt plugin that is `unshift`ed to the beginning of the Nuxt plugins array.') diff --git a/packages/nuxt/src/app/composables/preload.ts b/packages/nuxt/src/app/composables/preload.ts index ac94bbede6..cac6f5c85a 100644 --- a/packages/nuxt/src/app/composables/preload.ts +++ b/packages/nuxt/src/app/composables/preload.ts @@ -1,5 +1,5 @@ import type { Component } from 'vue' -import type { RouteLocationRaw, Router } from '#vue-router' +import type { RouteLocationRaw, Router } from 'vue-router' import { useNuxtApp } from '../nuxt' import { toArray } from '../utils' import { useRouter } from './router' @@ -14,7 +14,12 @@ export const preloadComponents = async (components: string | string[]) => { const nuxtApp = useNuxtApp() components = toArray(components) - await Promise.all(components.map(name => _loadAsyncComponent(nuxtApp.vueApp._context.components[name]))) + await Promise.all(components.map((name) => { + const component = nuxtApp.vueApp._context.components[name] + if (component) { + return _loadAsyncComponent(component) + } + })) } /** @@ -23,6 +28,8 @@ export const preloadComponents = async (components: string | string[]) => { * @since 3.0.0 */ export const prefetchComponents = (components: string | string[]) => { + if (import.meta.server) { return } + // TODO return preloadComponents(components) } @@ -36,7 +43,7 @@ function _loadAsyncComponent (component: Component) { } /** @since 3.0.0 */ -export async function preloadRouteComponents (to: RouteLocationRaw, router: Router & { _routePreloaded?: Set<string>; _preloadPromises?: Array<Promise<any>> } = useRouter()): Promise<void> { +export async function preloadRouteComponents (to: RouteLocationRaw, router: Router & { _routePreloaded?: Set<string>, _preloadPromises?: Array<Promise<unknown>> } = useRouter()): Promise<void> { if (import.meta.server) { return } const { path, matched } = router.resolve(to) @@ -59,7 +66,7 @@ export async function preloadRouteComponents (to: RouteLocationRaw, router: Rout .filter(component => typeof component === 'function') for (const component of components) { - const promise = Promise.resolve((component as Function)()) + const promise = Promise.resolve((component as () => unknown)()) .catch(() => {}) .finally(() => promises.splice(promises.indexOf(promise))) promises.push(promise) diff --git a/packages/nuxt/src/app/composables/preview.ts b/packages/nuxt/src/app/composables/preview.ts new file mode 100644 index 0000000000..18ca6a1d11 --- /dev/null +++ b/packages/nuxt/src/app/composables/preview.ts @@ -0,0 +1,114 @@ +import { toRef, watch } from 'vue' + +import { useState } from './state' +import { refreshNuxtData } from './asyncData' +import { useRoute, useRouter } from './router' + +interface Preview { + enabled: boolean + state: Record<any, unknown> + _initialized?: boolean +} + +/** + * Options for configuring preview mode. + */ +interface PreviewModeOptions<S> { + /** + * A function that determines whether preview mode should be enabled based on the current state. + * @param {Record<any, unknown>} state - The state of the preview. + * @returns {boolean} A boolean indicating whether the preview mode is enabled. + */ + shouldEnable?: (state: Preview['state']) => boolean + /** + * A function that retrieves the current state. + * The `getState` function will append returned values to current state, so be careful not to accidentally overwrite important state. + * @param {Record<any, unknown>} state - The preview state. + * @returns {Record<any, unknown>} The preview state. + */ + getState?: (state: Preview['state']) => S + /** + * A function to be called when the preview mode is enabled. + */ + onEnable?: () => void + /** + * A function to be called when the preview mode is disabled. + */ + onDisable?: () => void +} + +type EnteredState = Record<any, unknown> | null | undefined | void + +let unregisterRefreshHook: (() => any) | undefined + +/** @since 3.11.0 */ +export function usePreviewMode<S extends EnteredState> (options: PreviewModeOptions<S> = {}) { + const preview = useState<Preview>('_preview-state', () => ({ + enabled: false, + state: {}, + })) + + if (preview.value._initialized) { + return { + enabled: toRef(preview.value, 'enabled'), + state: preview.value.state as S extends void ? Preview['state'] : (NonNullable<S> & Preview['state']), + } + } + + if (import.meta.client) { + preview.value._initialized = true + } + + if (!preview.value.enabled) { + const shouldEnable = options.shouldEnable ?? defaultShouldEnable + const result = shouldEnable(preview.value.state) + + if (typeof result === 'boolean') { preview.value.enabled = result } + } + + watch(() => preview.value.enabled, (value) => { + if (value) { + const getState = options.getState ?? getDefaultState + const newState = getState(preview.value.state) + + if (newState !== preview.value.state) { + Object.assign(preview.value.state, newState) + } + + if (import.meta.client && !unregisterRefreshHook) { + const onEnable = options.onEnable ?? refreshNuxtData + onEnable() + + unregisterRefreshHook = options.onDisable ?? useRouter().afterEach(() => refreshNuxtData()) + } + } else if (unregisterRefreshHook) { + unregisterRefreshHook() + + unregisterRefreshHook = undefined + } + }, { immediate: true, flush: 'sync' }) + + return { + enabled: toRef(preview.value, 'enabled'), + state: preview.value.state as S extends void ? Preview['state'] : (NonNullable<S> & Preview['state']), + } +} + +function defaultShouldEnable () { + const route = useRoute() + const previewQueryName = 'preview' + + return route.query[previewQueryName] === 'true' +} + +function getDefaultState (state: Preview['state']) { + if (state.token !== undefined) { + return state + } + + const route = useRoute() + + state.token = Array.isArray(route.query.token) ? route.query.token[0] : route.query.token + + return state +} diff --git a/packages/nuxt/src/app/composables/ready.ts b/packages/nuxt/src/app/composables/ready.ts index 2360bc002d..d4a1257a19 100644 --- a/packages/nuxt/src/app/composables/ready.ts +++ b/packages/nuxt/src/app/composables/ready.ts @@ -7,8 +7,8 @@ export const onNuxtReady = (callback: () => any) => { const nuxtApp = useNuxtApp() if (nuxtApp.isHydrating) { - nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { requestIdleCallback(callback) }) + nuxtApp.hooks.hookOnce('app:suspense:resolve', () => { requestIdleCallback(() => callback()) }) } else { - requestIdleCallback(callback) + requestIdleCallback(() => callback()) } } diff --git a/packages/nuxt/src/app/composables/route-announcer.ts b/packages/nuxt/src/app/composables/route-announcer.ts new file mode 100644 index 0000000000..9a6e27741f --- /dev/null +++ b/packages/nuxt/src/app/composables/route-announcer.ts @@ -0,0 +1,89 @@ +import type { Ref } from 'vue' +import { getCurrentScope, onScopeDispose, ref } from 'vue' +import { injectHead } from '@unhead/vue' +import { useNuxtApp } from '../nuxt' + +export type Politeness = 'assertive' | 'polite' | 'off' + +export type NuxtRouteAnnouncerOpts = { + /** @default 'polite' */ + politeness?: Politeness +} + +export type RouteAnnouncer = { + message: Ref<string> + politeness: Ref<Politeness> + set: (message: string, politeness: Politeness) => void + polite: (message: string) => void + assertive: (message: string) => void + _cleanup: () => void +} + +function createRouteAnnouncer (opts: NuxtRouteAnnouncerOpts = {}) { + const message = ref('') + const politeness = ref<Politeness>(opts.politeness || 'polite') + const activeHead = injectHead() + + function set (messageValue: string = '', politenessSetting: Politeness = 'polite') { + message.value = messageValue + politeness.value = politenessSetting + } + + function polite (message: string) { + return set(message, 'polite') + } + + function assertive (message: string) { + return set(message, 'assertive') + } + + function _updateMessageWithPageHeading () { + set(document?.title?.trim(), politeness.value) + } + + function _cleanup () { + activeHead?.hooks?.removeHook('dom:rendered', _updateMessageWithPageHeading) + } + + _updateMessageWithPageHeading() + + activeHead?.hooks?.hook('dom:rendered', () => { + _updateMessageWithPageHeading() + }) + + return { + _cleanup, + message, + politeness, + set, + polite, + assertive, + } +} + +/** + * composable to handle the route announcer + * @since 3.12.0 + */ +export function useRouteAnnouncer (opts: Partial<NuxtRouteAnnouncerOpts> = {}): Omit<RouteAnnouncer, '_cleanup'> { + const nuxtApp = useNuxtApp() + + // Initialise global route announcer if it doesn't exist already + const announcer = nuxtApp._routeAnnouncer = nuxtApp._routeAnnouncer || createRouteAnnouncer(opts) + if (opts.politeness !== announcer.politeness.value) { + announcer.politeness.value = opts.politeness || 'polite' + } + if (import.meta.client && getCurrentScope()) { + nuxtApp._routeAnnouncerDeps = nuxtApp._routeAnnouncerDeps || 0 + nuxtApp._routeAnnouncerDeps++ + onScopeDispose(() => { + nuxtApp._routeAnnouncerDeps!-- + if (nuxtApp._routeAnnouncerDeps === 0) { + announcer._cleanup() + delete nuxtApp._routeAnnouncer + } + }) + } + + return announcer +} diff --git a/packages/nuxt/src/app/composables/router.ts b/packages/nuxt/src/app/composables/router.ts index 83501f96f3..fa8be0805c 100644 --- a/packages/nuxt/src/app/composables/router.ts +++ b/packages/nuxt/src/app/composables/router.ts @@ -1,10 +1,9 @@ import { getCurrentInstance, hasInjectionContext, inject, onScopeDispose } from 'vue' import type { Ref } from 'vue' -import type { NavigationFailure, NavigationGuard, RouteLocationNormalized, RouteLocationPathRaw, RouteLocationRaw, Router, useRoute as _useRoute, useRouter as _useRouter } from '#vue-router' +import type { NavigationFailure, NavigationGuard, RouteLocationNormalized, RouteLocationRaw, Router, useRoute as _useRoute, useRouter as _useRouter } from 'vue-router' import { sanitizeStatusCode } from 'h3' -import { hasProtocol, isScriptProtocol, joinURL, parseURL, withQuery } from 'ufo' +import { hasProtocol, isScriptProtocol, joinURL, withQuery } from 'ufo' -// eslint-disable-next-line import/no-restricted-paths import type { PageMeta } from '../../pages/runtime/composables' import { useNuxtApp, useRuntimeConfig } from '../nuxt' @@ -48,7 +47,7 @@ export interface RouteMiddleware { } /** @since 3.0.0 */ -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ export function defineNuxtRouteMiddleware (middleware: RouteMiddleware) { return middleware } @@ -85,24 +84,23 @@ const isProcessingMiddleware = () => { return true } } catch { - // Within an async middleware - return true + return false } return false } // Conditional types, either one or other type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never } -type XOR<T, U> = (T | U) extends Object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U +type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U export type OpenWindowFeatures = { popup?: boolean noopener?: boolean noreferrer?: boolean -} & XOR<{width?: number}, {innerWidth?: number}> - & XOR<{height?: number}, {innerHeight?: number}> - & XOR<{left?: number}, {screenX?: number}> - & XOR<{top?: number}, {screenY?: number}> +} & XOR<{ width?: number }, { innerWidth?: number }> + & XOR<{ height?: number }, { innerHeight?: number }> + & XOR<{ left?: number }, { screenX?: number }> + & XOR<{ top?: number }, { screenY?: number }> export type OpenOptions = { target: '_blank' | '_parent' | '_self' | '_top' | (string & {}) @@ -122,30 +120,28 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na to = '/' } - const toPath = typeof to === 'string' ? to : (withQuery((to as RouteLocationPathRaw).path || '/', to.query || {}) + (to.hash || '')) + const toPath = typeof to === 'string' ? to : 'path' in to ? resolveRouteObject(to) : useRouter().resolve(to).href // Early open handler - if (options?.open) { - if (import.meta.client) { - const { target = '_blank', windowFeatures = {} } = options.open + if (import.meta.client && options?.open) { + const { target = '_blank', windowFeatures = {} } = options.open - const features = Object.entries(windowFeatures) - .filter(([_, value]) => value !== undefined) - .map(([feature, value]) => `${feature.toLowerCase()}=${value}`) - .join(', ') - - open(toPath, target, features) - } + const features = Object.entries(windowFeatures) + .filter(([_, value]) => value !== undefined) + .map(([feature, value]) => `${feature.toLowerCase()}=${value}`) + .join(', ') + open(toPath, target, features) return Promise.resolve() } - const isExternal = options?.external || hasProtocol(toPath, { acceptRelative: true }) + const isExternalHost = hasProtocol(toPath, { acceptRelative: true }) + const isExternal = options?.external || isExternalHost if (isExternal) { if (!options?.external) { throw new Error('Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.') } - const protocol = parseURL(toPath).protocol + const { protocol } = new URL(toPath, import.meta.client ? window.location.href : 'http://localhost') if (protocol && isScriptProtocol(protocol)) { throw new Error(`Cannot navigate to a URL with '${protocol}' protocol.`) } @@ -171,10 +167,12 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na // TODO: consider deprecating in favour of `app:rendered` and removing await nuxtApp.callHook('app:redirected') const encodedLoc = location.replace(/"/g, '%22') + const encodedHeader = encodeURL(location, isExternalHost) + nuxtApp.ssrContext!._renderResponse = { statusCode: sanitizeStatusCode(options?.redirectCode || 302, 302), body: `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`, - headers: { location } + headers: { location: encodedHeader }, } return response } @@ -214,8 +212,8 @@ export const navigateTo = (to: RouteLocationRaw | undefined | null, options?: Na return options?.replace ? router.replace(to) : router.push(to) } -/** - * This will abort navigation within a Nuxt route middleware handler. +/** + * This will abort navigation within a Nuxt route middleware handler. * @since 3.0.0 */ export const abortNavigation = (err?: string | Partial<NuxtError>) => { @@ -257,3 +255,24 @@ export const setPageLayout = (layout: unknown extends PageMeta['layout'] ? strin useRoute().meta.layout = layout as Exclude<PageMeta['layout'], Ref | false> } } + +/** + * @internal + */ +export function resolveRouteObject (to: Exclude<RouteLocationRaw, string>) { + return withQuery(to.path || '', to.query || {}) + (to.hash || '') +} + +/** + * @internal + */ +export function encodeURL (location: string, isExternalHost = false) { + const url = new URL(location, 'http://localhost') + if (!isExternalHost) { + return url.pathname + url.search + url.hash + } + if (location.startsWith('//')) { + return url.toString().replace(url.protocol, '') + } + return url.toString() +} diff --git a/packages/nuxt/src/app/composables/script-stubs.ts b/packages/nuxt/src/app/composables/script-stubs.ts new file mode 100644 index 0000000000..f1f54c9ff2 --- /dev/null +++ b/packages/nuxt/src/app/composables/script-stubs.ts @@ -0,0 +1,122 @@ +import type { UseScriptInput } from '@unhead/vue' +import { createError } from './error' + +function renderStubMessage (name: string) { + const message = `\`${name}\` is provided by @nuxt/scripts. Check your console to install it or run 'npx nuxi@latest module add @nuxt/scripts' to install it.` + if (import.meta.client) { + throw createError({ + fatal: true, + statusCode: 500, + statusMessage: message, + }) + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScript<T extends Record<string | symbol, any>> (input: UseScriptInput, options?: Record<string, unknown>) { + renderStubMessage('useScript') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptTriggerElement (...args: unknown[]) { + renderStubMessage('useScriptTriggerElement') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptTriggerConsent (...args: unknown[]) { + renderStubMessage('useScriptTriggerConsent') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptEventPage (...args: unknown[]) { + renderStubMessage('useScriptEventPage') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptGoogleAnalytics (...args: unknown[]) { + renderStubMessage('useScriptGoogleAnalytics') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptPlausibleAnalytics (...args: unknown[]) { + renderStubMessage('useScriptPlausibleAnalytics') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptCloudflareWebAnalytics (...args: unknown[]) { + renderStubMessage('useScriptCloudflareWebAnalytics') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptCrisp (...args: unknown[]) { + renderStubMessage('useScriptCrisp') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptFathomAnalytics (...args: unknown[]) { + renderStubMessage('useScriptFathomAnalytics') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptMatomoAnalytics (...args: unknown[]) { + renderStubMessage('useScriptMatomoAnalytics') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptGoogleTagManager (...args: unknown[]) { + renderStubMessage('useScriptGoogleTagManager') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptSegment (...args: unknown[]) { + renderStubMessage('useScriptSegment') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptClarity (...args: unknown[]) { + renderStubMessage('useScriptClarity') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptMetaPixel (...args: unknown[]) { + renderStubMessage('useScriptMetaPixel') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptXPixel (...args: unknown[]) { + renderStubMessage('useScriptXPixel') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptIntercom (...args: unknown[]) { + renderStubMessage('useScriptIntercom') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptHotjar (...args: unknown[]) { + renderStubMessage('useScriptHotjar') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptStripe (...args: unknown[]) { + renderStubMessage('useScriptStripe') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptLemonSqueezy (...args: unknown[]) { + renderStubMessage('useScriptLemonSqueezy') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptVimeoPlayer (...args: unknown[]) { + renderStubMessage('useScriptVimeoPlayer') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptYouTubeIframe (...args: unknown[]) { + renderStubMessage('useScriptYouTubeIframe') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptGoogleMaps (...args: unknown[]) { + renderStubMessage('useScriptGoogleMaps') +} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptNpm (...args: unknown[]) { + renderStubMessage('useScriptNpm') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptGoogleAdsense (...args: unknown[]) { + renderStubMessage('useScriptGoogleAdsense') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useScriptYouTubePlayer (...args: unknown[]) { + renderStubMessage('useScriptYouTubePlayer') +} diff --git a/packages/nuxt/src/app/composables/ssr.ts b/packages/nuxt/src/app/composables/ssr.ts index 8509e968bc..56f3383109 100644 --- a/packages/nuxt/src/app/composables/ssr.ts +++ b/packages/nuxt/src/app/composables/ssr.ts @@ -1,5 +1,8 @@ import type { H3Event } from 'h3' import { setResponseStatus as _setResponseStatus, appendHeader, getRequestHeader, getRequestHeaders } from 'h3' +import { getCurrentInstance } from 'vue' +import { useServerHead } from '@unhead/vue' + import type { NuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt' import { toArray } from '../utils' @@ -65,3 +68,47 @@ export function prerenderRoutes (path: string | string[]) { const paths = toArray(path) appendHeader(useRequestEvent()!, 'x-nitro-prerender', paths.map(p => encodeURIComponent(p)).join(', ')) } + +const PREHYDRATE_ATTR_KEY = 'data-prehydrate-id' + +/** + * `onPrehydrate` is a composable lifecycle hook that allows you to run a callback on the client immediately before + * Nuxt hydrates the page. This is an advanced feature. + * + * The callback will be stringified and inlined in the HTML so it should not have any external + * dependencies (such as auto-imports) or refer to variables defined outside the callback. + * + * The callback will run before Nuxt runtime initializes so it should not rely on the Nuxt or Vue context. + * @since 3.12.0 + */ +export function onPrehydrate (callback: (el: HTMLElement) => void): void +export function onPrehydrate (callback: string | ((el: HTMLElement) => void), key?: string): undefined | string { + if (import.meta.client) { return } + + if (typeof callback !== 'string') { + throw new TypeError('[nuxt] To transform a callback into a string, `onPrehydrate` must be processed by the Nuxt build pipeline. If it is called in a third-party library, make sure to add the library to `build.transpile`.') + } + + const vm = getCurrentInstance() + if (vm && key) { + vm.attrs[PREHYDRATE_ATTR_KEY] ||= '' + key = ':' + key + ':' + if (!(vm.attrs[PREHYDRATE_ATTR_KEY] as string).includes(key)) { + vm.attrs[PREHYDRATE_ATTR_KEY] += key + } + } + const code = vm && key + ? `document.querySelectorAll('[${PREHYDRATE_ATTR_KEY}*=${JSON.stringify(key)}]').forEach` + callback + : (callback + '()') + + useServerHead({ + script: [{ + key: vm && key ? key : code, + tagPosition: 'bodyClose', + tagPriority: 'critical', + innerHTML: code, + }], + }) + + return vm && key ? vm.attrs[PREHYDRATE_ATTR_KEY] as string : undefined +} diff --git a/packages/nuxt/src/app/composables/state.ts b/packages/nuxt/src/app/composables/state.ts index 595bb06626..beaa193afb 100644 --- a/packages/nuxt/src/app/composables/state.ts +++ b/packages/nuxt/src/app/composables/state.ts @@ -10,9 +10,9 @@ const useStateKeyPrefix = '$s' * @param key a unique key ensuring that data fetching can be properly de-duplicated across requests * @param init a function that provides initial value for the state when it's not initiated */ -export function useState <T> (key?: string, init?: (() => T | Ref<T>)): Ref<T> -export function useState <T> (init?: (() => T | Ref<T>)): Ref<T> -export function useState <T> (...args: any): Ref<T> { +export function useState<T> (key?: string, init?: (() => T | Ref<T>)): Ref<T> +export function useState<T> (init?: (() => T | Ref<T>)): Ref<T> +export function useState<T> (...args: any): Ref<T> { const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined if (typeof args[0] !== 'string') { args.unshift(autoKey) } const [_key, init] = args as [string, (() => T | Ref<T>)] @@ -40,7 +40,7 @@ export function useState <T> (...args: any): Ref<T> { /** @since 3.6.0 */ export function clearNuxtState ( - keys?: string | string[] | ((key: string) => boolean) + keys?: string | string[] | ((key: string) => boolean), ): void { const nuxtApp = useNuxtApp() const _allKeys = Object.keys(nuxtApp.payload.state) diff --git a/packages/nuxt/src/app/composables/url.ts b/packages/nuxt/src/app/composables/url.ts index d8b6cf554f..a3eeff06e6 100644 --- a/packages/nuxt/src/app/composables/url.ts +++ b/packages/nuxt/src/app/composables/url.ts @@ -2,9 +2,9 @@ import { getRequestURL } from 'h3' import { useRequestEvent } from './ssr' /** @since 3.5.0 */ -export function useRequestURL () { +export function useRequestURL (opts?: Parameters<typeof getRequestURL>[1]) { if (import.meta.server) { - return getRequestURL(useRequestEvent()!) + return getRequestURL(useRequestEvent()!, opts) } return new URL(window.location.href) } diff --git a/packages/nuxt/src/app/config.ts b/packages/nuxt/src/app/config.ts index 922ad0c3cd..476f828abf 100644 --- a/packages/nuxt/src/app/config.ts +++ b/packages/nuxt/src/app/config.ts @@ -5,11 +5,21 @@ import { useNuxtApp } from './nuxt' // @ts-expect-error virtual file import __appConfig from '#build/app.config.mjs' +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type type DeepPartial<T> = T extends Function ? T : T extends Record<string, any> ? { [P in keyof T]?: DeepPartial<T[P]> } : T // Workaround for vite HMR with virtual modules export const _getAppConfig = () => __appConfig as AppConfig +function isPojoOrArray (val: unknown): val is object { + return ( + Array.isArray(val) || + (!!val && + typeof val === 'object' && + val.constructor?.name === 'Object') + ) +} + function deepDelete (obj: any, newObj: any) { for (const key in obj) { const val = newObj[key] @@ -17,7 +27,7 @@ function deepDelete (obj: any, newObj: any) { delete (obj as any)[key] } - if (val !== null && typeof val === 'object') { + if (isPojoOrArray(val)) { deepDelete(obj[key], newObj[key]) } } @@ -26,8 +36,9 @@ function deepDelete (obj: any, newObj: any) { function deepAssign (obj: any, newObj: any) { for (const key in newObj) { const val = newObj[key] - if (val !== null && typeof val === 'object') { - obj[key] = obj[key] || {} + if (isPojoOrArray(val)) { + const defaultVal = Array.isArray(val) ? [] : {} + obj[key] = obj[key] || defaultVal deepAssign(obj[key], val) } else { obj[key] = val diff --git a/packages/nuxt/src/app/entry.ts b/packages/nuxt/src/app/entry.ts index 72d889fb2c..dac40f4b97 100644 --- a/packages/nuxt/src/app/entry.ts +++ b/packages/nuxt/src/app/entry.ts @@ -1,10 +1,8 @@ import { createApp, createSSRApp, nextTick } from 'vue' import type { App } from 'vue' -// These files must be imported first as they have side effects: -// 1. (we set __webpack_public_path via this import, if using webpack builder) -import '#build/paths.mjs' -// 2. we set globalThis.$fetch via this import +// This file must be imported first as we set globalThis.$fetch via this import +// @ts-expect-error virtual file import '#build/fetch.mjs' import { applyPlugins, createNuxtApp } from './nuxt' @@ -12,13 +10,14 @@ import type { CreateOptions } from './nuxt' import { createError } from './composables/error' +// @ts-expect-error virtual file import '#build/css' // @ts-expect-error virtual file import plugins from '#build/plugins' // @ts-expect-error virtual file import RootComponent from '#build/root-component.mjs' // @ts-expect-error virtual file -import { vueAppRootContainer } from '#build/nuxt.config.mjs' +import { appId, multiApp, vueAppRootContainer } from '#build/nuxt.config.mjs' let entry: (ssrContext?: CreateOptions['ssrContext']) => Promise<App<Element>> @@ -53,20 +52,25 @@ if (import.meta.client) { entry = async function initApp () { if (vueAppPromise) { return vueAppPromise } + const isSSR = Boolean( - window.__NUXT__?.serverRendered || - document.getElementById('__NUXT_DATA__')?.dataset.ssr === 'true' + (multiApp ? window.__NUXT__?.[appId] : window.__NUXT__)?.serverRendered ?? + (multiApp ? document.querySelector(`[data-nuxt-data="${appId}"]`) as HTMLElement : document.getElementById('__NUXT_DATA__'))?.dataset.ssr === 'true', ) const vueApp = isSSR ? createSSRApp(RootComponent) : createApp(RootComponent) const nuxt = createNuxtApp({ vueApp }) - async function handleVueError(error: any) { + async function handleVueError (error: any) { await nuxt.callHook('app:error', error) nuxt.payload.error = nuxt.payload.error || createError(error as any) } vueApp.config.errorHandler = handleVueError + // If the errorHandler is not overridden by the user, we unset it after the app is hydrated + nuxt.hook('app:suspense:resolve', () => { + if (vueApp.config.errorHandler === handleVueError) { vueApp.config.errorHandler = undefined } + }) try { await applyPlugins(nuxt, plugins) @@ -84,10 +88,6 @@ if (import.meta.client) { handleVueError(err) } - // If the errorHandler is not overridden by the user, we unset it - if (vueApp.config.errorHandler === handleVueError) - vueApp.config.errorHandler = undefined - return vueApp } diff --git a/packages/nuxt/src/app/index.ts b/packages/nuxt/src/app/index.ts index 1acc4ad82e..363c72d1bf 100644 --- a/packages/nuxt/src/app/index.ts +++ b/packages/nuxt/src/app/index.ts @@ -1,13 +1,14 @@ -/// <reference path="types/augments.d.ts" /> +export { applyPlugin, applyPlugins, callWithNuxt, createNuxtApp, defineAppConfig, defineNuxtPlugin, definePayloadPlugin, isNuxtPlugin, registerPluginHooks, tryUseNuxtApp, useNuxtApp, useRuntimeConfig } from './nuxt' +export type { CreateOptions, NuxtApp, NuxtPayload, NuxtPluginIndicator, NuxtSSRContext, ObjectPlugin, Plugin, PluginEnvContext, PluginMeta, ResolvedPluginMeta, RuntimeNuxtHooks } from './nuxt' -export * from './nuxt' -// eslint-disable-next-line import/no-restricted-paths -export * from './composables/index' -// eslint-disable-next-line import/no-restricted-paths -export * from './components/index' -export * from './config' -export * from './compat/idle-callback' -export * from './types' +export { defineNuxtComponent, useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData, useHydration, callOnce, useState, clearNuxtState, clearError, createError, isNuxtError, showError, useError, useFetch, useLazyFetch, useCookie, refreshCookie, onPrehydrate, prerenderRoutes, useRequestHeaders, useRequestEvent, useRequestFetch, setResponseStatus, onNuxtReady, abortNavigation, addRouteMiddleware, defineNuxtRouteMiddleware, onBeforeRouteLeave, onBeforeRouteUpdate, setPageLayout, navigateTo, useRoute, useRouter, preloadComponents, prefetchComponents, preloadRouteComponents, isPrerendered, loadPayload, preloadPayload, definePayloadReducer, definePayloadReviver, getAppManifest, getRouteRules, reloadNuxtApp, useRequestURL, usePreviewMode, useId, useRouteAnnouncer, useHead, useSeoMeta, useServerSeoMeta } from './composables/index' +export type { AddRouteMiddlewareOptions, AsyncData, AsyncDataOptions, AsyncDataRequestStatus, CookieOptions, CookieRef, FetchResult, NuxtAppManifest, NuxtAppManifestMeta, NuxtError, ReloadNuxtAppOptions, RouteMiddleware, UseFetchOptions } from './composables/index' + +export { defineNuxtLink } from './components/index' +export type { NuxtLinkOptions, NuxtLinkProps } from './components/index' +export { _getAppConfig, updateAppConfig, useAppConfig } from './config' +export { cancelIdleCallback, requestIdleCallback } from './compat/idle-callback' +export type { NuxtAppLiterals, NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext, PageMeta } from './types' export const isVue2 = false export const isVue3 = true diff --git a/packages/nuxt/src/app/middleware/manifest-route-rule.ts b/packages/nuxt/src/app/middleware/manifest-route-rule.ts index 30f51c993b..ef3d2e1d37 100644 --- a/packages/nuxt/src/app/middleware/manifest-route-rule.ts +++ b/packages/nuxt/src/app/middleware/manifest-route-rule.ts @@ -1,3 +1,4 @@ +import { hasProtocol } from 'ufo' import { defineNuxtRouteMiddleware } from '../composables/router' import { getRouteRules } from '../composables/manifest' @@ -5,6 +6,10 @@ export default defineNuxtRouteMiddleware(async (to) => { if (import.meta.server || import.meta.test) { return } const rules = await getRouteRules(to.path) if (rules.redirect) { + if (hasProtocol(rules.redirect, { acceptRelative: true })) { + window.location.href = rules.redirect + return false + } return rules.redirect } }) diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index d95ebdce6d..61d41001c7 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -1,29 +1,34 @@ -/* eslint-disable no-use-before-define */ -import { effectScope, getCurrentInstance, hasInjectionContext, reactive } from 'vue' +import { effectScope, getCurrentInstance, getCurrentScope, hasInjectionContext, reactive, shallowReactive } from 'vue' import type { App, EffectScope, Ref, VNode, onErrorCaptured } from 'vue' -import type { RouteLocationNormalizedLoaded } from '#vue-router' +import type { RouteLocationNormalizedLoaded } from 'vue-router' import type { HookCallback, Hookable } from 'hookable' import { createHooks } from 'hookable' import { getContext } from 'unctx' import type { SSRContext, createRenderer } from 'vue-bundle-renderer/runtime' import type { EventHandlerRequest, H3Event } from 'h3' import type { AppConfig, AppConfigInput, RuntimeConfig } from 'nuxt/schema' -import type { RenderResponse } from 'nitropack' +import type { RenderResponse } from 'nitro/types' +import type { LogObject } from 'consola' import type { MergeHead, VueHeadClient } from '@unhead/vue' -// eslint-disable-next-line import/no-restricted-paths -import type { NuxtIslandContext } from '../core/runtime/nitro/renderer' +import type { NuxtIslandContext } from '../app/types' import type { RouteMiddleware } from '../app/composables/router' import type { NuxtError } from '../app/composables/error' import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { NuxtAppManifestMeta } from '../app/composables/manifest' import type { LoadingIndicator } from '../app/composables/loading-indicator' +import type { RouteAnnouncer } from '../app/composables/route-announcer' + +// @ts-expect-error virtual file +import { appId, chunkErrorEvent, multiApp } from '#build/nuxt.config.mjs' import type { NuxtAppLiterals } from '#app' -const nuxtAppCtx = /*@__PURE__*/ getContext<NuxtApp>('nuxt-app', { - asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server -}) +function getNuxtAppCtx (id = appId || 'nuxt-app') { + return getContext<NuxtApp>(id, { + asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server, + }) +} type HookResult = Promise<void> | void @@ -40,11 +45,13 @@ export interface RuntimeNuxtHooks { 'app:chunkError': (options: { error: any }) => HookResult 'app:data:refresh': (keys?: string[]) => HookResult 'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult + 'dev:ssr-logs': (logs: LogObject[]) => void | Promise<void> 'link:prefetch': (link: string) => HookResult 'page:start': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult 'page:transition:start': () => HookResult 'page:transition:finish': (Component?: VNode) => HookResult + 'page:view-transition:start': (transition: ViewTransition) => HookResult 'page:loading:start': () => HookResult 'page:loading:end': () => HookResult 'vue:setup': () => void @@ -59,7 +66,7 @@ export interface NuxtSSRContext extends SSRContext { /** whether we are rendering an SSR error */ error?: boolean nuxt: _NuxtApp - payload: NuxtPayload + payload: Partial<NuxtPayload> head: VueHeadClient<MergeHead> /** This is used solely to render runtime config with SPA renderer. */ config?: Pick<RuntimeConfig, 'public' | 'app'> @@ -71,7 +78,7 @@ export interface NuxtSSRContext extends SSRContext { _payloadReducers: Record<string, (data: any) => any> /** @internal */ _sharedPrerenderCache?: { - get<T = unknown> (key: string): Promise<T> + get<T = unknown> (key: string): Promise<T> | undefined set<T> (key: string, value: Promise<T>): Promise<void> } } @@ -84,14 +91,13 @@ export interface NuxtPayload { state: Record<string, any> once: Set<string> config?: Pick<RuntimeConfig, 'public' | 'app'> - error?: NuxtError | null - _errors: Record<string, NuxtError | null> + error?: NuxtError | undefined + _errors: Record<string, NuxtError | undefined> [key: string]: unknown } interface _NuxtApp { vueApp: App<Element> - globalName: string versions: Record<string, string> hooks: Hookable<RuntimeNuxtHooks> @@ -103,16 +109,27 @@ interface _NuxtApp { [key: string]: unknown /** @internal */ - _id?: number + _cookies?: Record<string, unknown> + /** + * The id of the Nuxt application. + * @internal */ + _id: string + /** + * The next id that can be used for generating unique ids via `useId`. + * @internal + */ + _genId?: number /** @internal */ _scope: EffectScope /** @internal */ _asyncDataPromises: Record<string, Promise<any> | undefined> /** @internal */ _asyncData: Record<string, { - data: Ref<any> - error: Ref<Error | null> + data: Ref<unknown> + error: Ref<Error | undefined> status: Ref<AsyncDataRequestStatus> + /** @internal */ + _default: () => unknown } | undefined> /** @internal */ @@ -147,6 +164,11 @@ interface _NuxtApp { /** @internal */ _payloadRevivers: Record<string, (data: any) => any> + /** @internal */ + _routeAnnouncer?: RouteAnnouncer + /** @internal */ + _routeAnnouncerDeps?: number + // Nuxt injections $config: RuntimeConfig @@ -162,6 +184,7 @@ interface _NuxtApp { provide: (name: string, value: any) => void } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface NuxtApp extends _NuxtApp {} export const NuxtPluginIndicator = '__nuxt_plugin' @@ -221,30 +244,39 @@ export type ObjectPluginInput<Injections extends Record<string, unknown> = Recor export interface CreateOptions { vueApp: NuxtApp['vueApp'] ssrContext?: NuxtApp['ssrContext'] - globalName?: NuxtApp['globalName'] + /** + * The id of the Nuxt application, overrides the default id specified in the Nuxt config (default: `nuxt-app`). + */ + id?: NuxtApp['_id'] } +/** @since 3.0.0 */ export function createNuxtApp (options: CreateOptions) { let hydratingCount = 0 const nuxtApp: NuxtApp = { + _id: options.id || appId || 'nuxt-app', _scope: effectScope(), provide: undefined, - globalName: 'nuxt', versions: { get nuxt () { return __NUXT_VERSION__ }, - get vue () { return nuxtApp.vueApp.version } + get vue () { return nuxtApp.vueApp.version }, }, - payload: reactive({ - data: {}, - state: {}, + payload: shallowReactive({ + ...options.ssrContext?.payload || {}, + data: shallowReactive({}), + state: reactive({}), once: new Set<string>(), - _errors: {}, - ...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true }) + _errors: shallowReactive({}), }), static: { - data: {} + data: {}, + }, + runWithContext <T>(fn: () => T) { + if (nuxtApp._scope.active && !getCurrentScope()) { + return nuxtApp._scope.run(() => callWithNuxt(nuxtApp, fn)) + } + return callWithNuxt(nuxtApp, fn) }, - runWithContext: (fn: any) => nuxtApp._scope.run(() => callWithNuxt(nuxtApp, fn)), isHydrating: import.meta.client, deferHydration () { if (!nuxtApp.isHydrating) { return () => {} } @@ -265,11 +297,49 @@ export function createNuxtApp (options: CreateOptions) { } }, _asyncDataPromises: {}, - _asyncData: {}, + _asyncData: shallowReactive({}), _payloadRevivers: {}, - ...options + ...options, } as any as NuxtApp + if (import.meta.server) { + nuxtApp.payload.serverRendered = true + } + + if (import.meta.server && nuxtApp.ssrContext) { + nuxtApp.payload.path = nuxtApp.ssrContext.url + + // Expose nuxt to the renderContext + nuxtApp.ssrContext.nuxt = nuxtApp + nuxtApp.ssrContext.payload = nuxtApp.payload + + // Expose client runtime-config to the payload + nuxtApp.ssrContext.config = { + public: nuxtApp.ssrContext.runtimeConfig.public, + app: nuxtApp.ssrContext.runtimeConfig.app, + } + } + + if (import.meta.client) { + const __NUXT__ = multiApp ? window.__NUXT__?.[nuxtApp._id] : window.__NUXT__ + // TODO: remove/refactor in https://github.com/nuxt/nuxt/issues/25336 + if (__NUXT__) { + for (const key in __NUXT__) { + switch (key) { + case 'data': + case 'state': + case '_errors': + // Preserve reactivity for non-rich payload support + Object.assign(nuxtApp.payload[key], __NUXT__[key]) + break + + default: + nuxtApp.payload[key] = __NUXT__[key] + } + } + } + } + nuxtApp.hooks = createHooks<RuntimeNuxtHooks>() nuxtApp.hook = nuxtApp.hooks.hook @@ -296,35 +366,16 @@ export function createNuxtApp (options: CreateOptions) { defineGetter(nuxtApp.vueApp, '$nuxt', nuxtApp) defineGetter(nuxtApp.vueApp.config.globalProperties, '$nuxt', nuxtApp) - if (import.meta.server) { - if (nuxtApp.ssrContext) { - // Expose nuxt to the renderContext - nuxtApp.ssrContext.nuxt = nuxtApp - // Expose payload types - nuxtApp.ssrContext._payloadReducers = {} - // Expose current path - nuxtApp.payload.path = nuxtApp.ssrContext.url - } - // Expose to server renderer to create payload - nuxtApp.ssrContext = nuxtApp.ssrContext || {} as any - if (nuxtApp.ssrContext!.payload) { - Object.assign(nuxtApp.payload, nuxtApp.ssrContext!.payload) - } - nuxtApp.ssrContext!.payload = nuxtApp.payload - - // Expose client runtime-config to the payload - nuxtApp.ssrContext!.config = { - public: options.ssrContext!.runtimeConfig.public, - app: options.ssrContext!.runtimeConfig.app - } - } - - // Listen to chunk load errors if (import.meta.client) { - window.addEventListener('nuxt.preloadError', (event) => { - nuxtApp.callHook('app:chunkError', { error: (event as Event & { payload: Error }).payload }) - }) - + // Listen to chunk load errors + if (chunkErrorEvent) { + window.addEventListener(chunkErrorEvent, (event) => { + nuxtApp.callHook('app:chunkError', { error: (event as Event & { payload: Error }).payload }) + if (nuxtApp.isHydrating || event.payload.message.includes('Unable to preload CSS')) { + event.preventDefault() + } + }) + } window.useNuxtApp = window.useNuxtApp || useNuxtApp // Log errors captured when running plugins, in the `app:created` and `app:beforeMount` hooks @@ -334,16 +385,21 @@ export function createNuxtApp (options: CreateOptions) { } // Expose runtime config - const runtimeConfig = import.meta.server ? options.ssrContext!.runtimeConfig : reactive(nuxtApp.payload.config!) - nuxtApp.provide('config', runtimeConfig) + const runtimeConfig = import.meta.server ? options.ssrContext!.runtimeConfig : nuxtApp.payload.config! + nuxtApp.provide('config', import.meta.client && import.meta.dev ? wrappedConfig(runtimeConfig) : runtimeConfig) return nuxtApp } -export async function applyPlugin (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlugin<any>) { +/** @since 3.12.0 */ +export function registerPluginHooks (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlugin<any>) { if (plugin.hooks) { nuxtApp.hooks.addHooks(plugin.hooks) } +} + +/** @since 3.0.0 */ +export async function applyPlugin (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlugin<any>) { if (typeof plugin === 'function') { const { provide } = await nuxtApp.runWithContext(() => plugin(nuxtApp)) || {} if (provide && typeof provide === 'object') { @@ -354,6 +410,7 @@ export async function applyPlugin (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlug } } +/** @since 3.0.0 */ export async function applyPlugins (nuxtApp: NuxtApp, plugins: Array<Plugin & ObjectPlugin<any>>) { const resolvedPlugins: string[] = [] const unresolvedPlugins: [Set<string>, Plugin & ObjectPlugin<any>][] = [] @@ -389,6 +446,11 @@ export async function applyPlugins (nuxtApp: NuxtApp, plugins: Array<Plugin & Ob } } + for (const plugin of plugins) { + if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue } + registerPluginHooks(nuxtApp, plugin) + } + for (const plugin of plugins) { if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue } await executePlugin(plugin) @@ -404,7 +466,8 @@ export async function applyPlugins (nuxtApp: NuxtApp, plugins: Array<Plugin & Ob if (errors.length) { throw errors[0] } } -/*@__NO_SIDE_EFFECTS__*/ +/** @since 3.0.0 */ +/* @__NO_SIDE_EFFECTS__ */ export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plugin<T> | ObjectPlugin<T>): Plugin<T> & ObjectPlugin<T> { if (typeof plugin === 'function') { return plugin } @@ -413,20 +476,23 @@ export function defineNuxtPlugin<T extends Record<string, unknown>> (plugin: Plu return Object.assign(plugin.setup || (() => {}), plugin, { [NuxtPluginIndicator]: true, _name } as const) } -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ export const definePayloadPlugin = defineNuxtPlugin +/** @since 3.0.0 */ export function isNuxtPlugin (plugin: unknown) { return typeof plugin === 'function' && NuxtPluginIndicator in plugin } /** - * Ensures that the setup function passed in has access to the Nuxt instance via `useNuxt`. + * Ensures that the setup function passed in has access to the Nuxt instance via `useNuxtApp`. * @param nuxt A Nuxt instance * @param setup The function to call + * @since 3.0.0 */ export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp | _NuxtApp, setup: T, args?: Parameters<T>) { const fn: () => ReturnType<T> = () => args ? setup(...args as Parameters<T>) : setup() + const nuxtAppCtx = getNuxtAppCtx(nuxt._id) if (import.meta.server) { return nuxt.vueApp.runWithContext(() => nuxtAppCtx.callAsync(nuxt as NuxtApp, fn)) } else { @@ -436,31 +502,36 @@ export function callWithNuxt<T extends (...args: any[]) => any> (nuxt: NuxtApp | } } -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ /** * Returns the current Nuxt instance. * * Returns `null` if Nuxt instance is unavailable. + * @since 3.10.0 */ -export function tryUseNuxtApp (): NuxtApp | null { +export function tryUseNuxtApp (): NuxtApp | null +export function tryUseNuxtApp (id?: string): NuxtApp | null { let nuxtAppInstance if (hasInjectionContext()) { nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt } - nuxtAppInstance = nuxtAppInstance || nuxtAppCtx.tryUse() + nuxtAppInstance = nuxtAppInstance || getNuxtAppCtx(id).tryUse() return nuxtAppInstance || null } -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ /** * Returns the current Nuxt instance. * * Throws an error if Nuxt instance is unavailable. + * @since 3.0.0 */ -export function useNuxtApp (): NuxtApp { - const nuxtAppInstance = tryUseNuxtApp() +export function useNuxtApp (): NuxtApp +export function useNuxtApp (id?: string): NuxtApp { + // @ts-expect-error internal usage of id + const nuxtAppInstance = tryUseNuxtApp(id) if (!nuxtAppInstance) { if (import.meta.dev) { @@ -473,7 +544,8 @@ export function useNuxtApp (): NuxtApp { return nuxtAppInstance } -/*@__NO_SIDE_EFFECTS__*/ +/** @since 3.0.0 */ +/* @__NO_SIDE_EFFECTS__ */ export function useRuntimeConfig (_event?: H3Event<EventHandlerRequest>): RuntimeConfig { return useNuxtApp().$config } @@ -482,6 +554,28 @@ function defineGetter<K extends string | number | symbol, V> (obj: Record<K, V>, Object.defineProperty(obj, key, { get: () => val }) } +/** @since 3.0.0 */ export function defineAppConfig<C extends AppConfigInput> (config: C): C { return config } + +/** + * Configure error getter on runtime secret property access that doesn't exist on the client side + */ +const loggedKeys = new Set<string>() +function wrappedConfig (runtimeConfig: Record<string, unknown>) { + if (!import.meta.dev || import.meta.server) { return runtimeConfig } + const keys = Object.keys(runtimeConfig).map(key => `\`${key}\``) + const lastKey = keys.pop() + return new Proxy(runtimeConfig, { + get (target, p, receiver) { + if (typeof p === 'string' && p !== 'public' && !(p in target) && !p.startsWith('__v') /* vue check for reactivity, e.g. `__v_isRef` */) { + if (!loggedKeys.has(p)) { + loggedKeys.add(p) + console.warn(`[nuxt] Could not access \`${p}\`. The only available runtime config keys on the client side are ${keys.join(', ')} and ${lastKey}. See https://nuxt.com/docs/guide/going-further/runtime-config for more information.`) + } + } + return Reflect.get(target, p, receiver) + }, + }) +} diff --git a/packages/nuxt/src/app/plugins/check-if-layout-used.ts b/packages/nuxt/src/app/plugins/check-if-layout-used.ts index 9cf7192ea9..a6646ddba4 100644 --- a/packages/nuxt/src/app/plugins/check-if-layout-used.ts +++ b/packages/nuxt/src/app/plugins/check-if-layout-used.ts @@ -17,12 +17,16 @@ export default defineNuxtPlugin({ } } if (import.meta.server) { - nuxtApp.hook('app:rendered', ({ renderResult }) => { renderResult?.html && nextTick(checkIfLayoutUsed) }) + nuxtApp.hook('app:rendered', ({ renderResult }) => { + if (renderResult?.html) { + nextTick(checkIfLayoutUsed) + } + }) } else { onNuxtReady(checkIfLayoutUsed) } }, env: { - islands: false - } + islands: false, + }, }) diff --git a/packages/nuxt/src/app/plugins/check-outdated-build.client.ts b/packages/nuxt/src/app/plugins/check-outdated-build.client.ts index e0b80462ca..874ea667bd 100644 --- a/packages/nuxt/src/app/plugins/check-outdated-build.client.ts +++ b/packages/nuxt/src/app/plugins/check-outdated-build.client.ts @@ -3,7 +3,9 @@ import { getAppManifest } from '../composables/manifest' import type { NuxtAppManifestMeta } from '../composables/manifest' import { onNuxtReady } from '../composables/ready' // @ts-expect-error virtual file -import { buildAssetsURL } from '#build/paths.mjs' +import { buildAssetsURL } from '#internal/nuxt/paths' +// @ts-expect-error virtual file +import { outdatedBuildInterval } from '#build/nuxt.config.mjs' export default defineNuxtPlugin((nuxtApp) => { if (import.meta.test) { return } @@ -13,13 +15,17 @@ export default defineNuxtPlugin((nuxtApp) => { async function getLatestManifest () { const currentManifest = await getAppManifest() if (timeout) { clearTimeout(timeout) } - timeout = setTimeout(getLatestManifest, 1000 * 60 * 60) - const meta = await $fetch<NuxtAppManifestMeta>(buildAssetsURL('builds/latest.json') + `?${Date.now()}`) - if (meta.id !== currentManifest.id) { - // There is a newer build which we will let the user handle - nuxtApp.hooks.callHook('app:manifest:update', meta) + timeout = setTimeout(getLatestManifest, outdatedBuildInterval) + try { + const meta = await $fetch<NuxtAppManifestMeta>(buildAssetsURL('builds/latest.json') + `?${Date.now()}`) + if (meta.id !== currentManifest.id) { + // There is a newer build which we will let the user handle + nuxtApp.hooks.callHook('app:manifest:update', meta) + } + } catch { + // fail gracefully on network issue } } - onNuxtReady(() => { timeout = setTimeout(getLatestManifest, 1000 * 60 * 60) }) + onNuxtReady(() => { timeout = setTimeout(getLatestManifest, outdatedBuildInterval) }) }) diff --git a/packages/nuxt/src/app/plugins/chunk-reload.client.ts b/packages/nuxt/src/app/plugins/chunk-reload.client.ts index 7b802c57f0..06cd15841c 100644 --- a/packages/nuxt/src/app/plugins/chunk-reload.client.ts +++ b/packages/nuxt/src/app/plugins/chunk-reload.client.ts @@ -10,7 +10,7 @@ export default defineNuxtPlugin({ const router = useRouter() const config = useRuntimeConfig() - const chunkErrors = new Set() + const chunkErrors = new Set<Error>() router.beforeEach(() => { chunkErrors.clear() }) nuxtApp.hook('app:chunkError', ({ error }) => { chunkErrors.add(error) }) @@ -30,5 +30,5 @@ export default defineNuxtPlugin({ reloadAppAtPath(to) } }) - } + }, }) diff --git a/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts b/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts index c386328c09..66788be26b 100644 --- a/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts +++ b/packages/nuxt/src/app/plugins/cross-origin-prefetch.client.ts @@ -1,8 +1,9 @@ import { ref } from 'vue' -import { parseURL } from 'ufo' import { useHead } from '@unhead/vue' import { defineNuxtPlugin } from '../nuxt' +const SUPPORTED_PROTOCOLS = ['http:', 'https:'] + export default defineNuxtPlugin({ name: 'nuxt:cross-origin-prefetch', setup (nuxtApp) { @@ -16,23 +17,22 @@ export default defineNuxtPlugin({ { source: 'list', urls: [...externalURLs.value], - requires: ['anonymous-client-ip-when-cross-origin'] - } - ] - }) + requires: ['anonymous-client-ip-when-cross-origin'], + }, + ], + }), } } const head = useHead({ - script: [generateRules()] + script: [generateRules()], }) nuxtApp.hook('link:prefetch', (url) => { - const { protocol } = parseURL(url) - if (protocol && ['http:', 'https:'].includes(protocol)) { + if (SUPPORTED_PROTOCOLS.some(p => url.startsWith(p)) && SUPPORTED_PROTOCOLS.includes(new URL(url).protocol)) { externalURLs.value.add(url) head?.patch({ - script: [generateRules()] + script: [generateRules()], }) } }) - } + }, }) diff --git a/packages/nuxt/src/app/plugins/debug.ts b/packages/nuxt/src/app/plugins/debug.ts index 69a989a586..ccb841672c 100644 --- a/packages/nuxt/src/app/plugins/debug.ts +++ b/packages/nuxt/src/app/plugins/debug.ts @@ -6,5 +6,5 @@ export default defineNuxtPlugin({ enforce: 'pre', setup (nuxtApp) { createDebugger(nuxtApp.hooks, { tag: 'nuxt-app' }) - } + }, }) diff --git a/packages/nuxt/src/app/plugins/dev-server-logs.ts b/packages/nuxt/src/app/plugins/dev-server-logs.ts new file mode 100644 index 0000000000..468ba80d3c --- /dev/null +++ b/packages/nuxt/src/app/plugins/dev-server-logs.ts @@ -0,0 +1,71 @@ +import { createConsola } from 'consola' +import type { LogObject } from 'consola' +import { parse } from 'devalue' +import type { ParsedTrace } from 'errx' + +import { h } from 'vue' +import { defineNuxtPlugin } from '../nuxt' + +// @ts-expect-error virtual file +import { devLogs, devRootDir } from '#build/nuxt.config.mjs' + +const devRevivers: Record<string, (data: any) => any> = import.meta.server + ? {} + : { + VNode: data => h(data.type, data.props), + URL: data => new URL(data), + } + +export default defineNuxtPlugin(async (nuxtApp) => { + if (import.meta.test) { return } + + if (import.meta.server) { + nuxtApp.ssrContext!.event.context._payloadReducers = nuxtApp.ssrContext!._payloadReducers + return + } + + // Show things in console + if (devLogs !== 'silent') { + const logger = createConsola({ + formatOptions: { + colors: true, + date: true, + }, + }) + nuxtApp.hook('dev:ssr-logs', (logs) => { + for (const log of logs) { + logger.log(normalizeServerLog({ ...log })) + } + }) + } + + if (typeof window !== 'undefined') { + const nuxtLogsElement = document.querySelector(`[data-nuxt-logs="${nuxtApp._id}"]`) + const content = nuxtLogsElement?.textContent + const logs = content ? parse(content, { ...devRevivers, ...nuxtApp._payloadRevivers }) as LogObject[] : [] + await nuxtApp.hooks.callHook('dev:ssr-logs', logs) + } +}) + +function normalizeFilenames (stack?: ParsedTrace[]) { + if (!stack) { + return '' + } + let message = '' + for (const item of stack) { + const source = item.source.replace(`${devRootDir}/`, '') + if (item.function) { + message += ` at ${item.function} (${source})\n` + } else { + message += ` at ${source}\n` + } + } + return message +} + +function normalizeServerLog (log: LogObject) { + log.additional = normalizeFilenames(log.stack as ParsedTrace[]) + log.tag = 'ssr' + delete log.stack + return log +} diff --git a/packages/nuxt/src/app/plugins/navigation-repaint.client.ts b/packages/nuxt/src/app/plugins/navigation-repaint.client.ts new file mode 100644 index 0000000000..fa368bf593 --- /dev/null +++ b/packages/nuxt/src/app/plugins/navigation-repaint.client.ts @@ -0,0 +1,23 @@ +import { defineNuxtPlugin } from '../nuxt' +import { onNuxtReady } from '../composables/ready' +import { useRouter } from '../composables/router' + +export default defineNuxtPlugin(() => { + const router = useRouter() + onNuxtReady(() => { + router.beforeResolve(async () => { + /** + * This gives an opportunity for the browser to repaint, acknowledging user interaction. + * It can reduce INP when navigating on prerendered routes. + * + * @see https://github.com/nuxt/nuxt/issues/26271#issuecomment-2178582037 + * @see https://vercel.com/blog/demystifying-inp-new-tools-and-actionable-insights + */ + await new Promise((resolve) => { + // Ensure we always resolve, even if the animation frame never fires + setTimeout(resolve, 100) + requestAnimationFrame(() => { setTimeout(resolve, 0) }) + }) + }) + }) +}) diff --git a/packages/nuxt/src/app/plugins/payload.client.ts b/packages/nuxt/src/app/plugins/payload.client.ts index 452fa841cf..48486c1a3e 100644 --- a/packages/nuxt/src/app/plugins/payload.client.ts +++ b/packages/nuxt/src/app/plugins/payload.client.ts @@ -1,4 +1,3 @@ -import { parseURL } from 'ufo' import { defineNuxtPlugin } from '../nuxt' import { loadPayload } from '../composables/payload' import { onNuxtReady } from '../composables/ready' @@ -24,7 +23,8 @@ export default defineNuxtPlugin({ onNuxtReady(() => { // Load payload into cache nuxtApp.hooks.hook('link:prefetch', async (url) => { - if (!parseURL(url).protocol) { + const { hostname } = new URL(url, window.location.href) + if (hostname === window.location.hostname) { await loadPayload(url) } }) @@ -32,5 +32,5 @@ export default defineNuxtPlugin({ setTimeout(getAppManifest, 1000) } }) - } + }, }) diff --git a/packages/nuxt/src/app/plugins/preload.server.ts b/packages/nuxt/src/app/plugins/preload.server.ts index e5d291a704..44f17795dc 100644 --- a/packages/nuxt/src/app/plugins/preload.server.ts +++ b/packages/nuxt/src/app/plugins/preload.server.ts @@ -5,10 +5,10 @@ export default defineNuxtPlugin({ setup (nuxtApp) { nuxtApp.vueApp.mixin({ beforeCreate () { - const { _registeredComponents } = this.$nuxt.ssrContext + const { modules } = this.$nuxt.ssrContext const { __moduleIdentifier } = this.$options - _registeredComponents.add(__moduleIdentifier) - } + modules.add(__moduleIdentifier) + }, }) - } + }, }) diff --git a/packages/nuxt/src/app/plugins/restore-state.client.ts b/packages/nuxt/src/app/plugins/restore-state.client.ts index 1914cf48d4..bc53feef73 100644 --- a/packages/nuxt/src/app/plugins/restore-state.client.ts +++ b/packages/nuxt/src/app/plugins/restore-state.client.ts @@ -15,6 +15,6 @@ export default defineNuxtPlugin({ } catch { // don't throw an error if we have issues reading sessionStorage } - } - } + }, + }, }) diff --git a/packages/nuxt/src/app/plugins/revive-payload.client.ts b/packages/nuxt/src/app/plugins/revive-payload.client.ts index 89f98a5809..2d2c2e8bbd 100644 --- a/packages/nuxt/src/app/plugins/revive-payload.client.ts +++ b/packages/nuxt/src/app/plugins/revive-payload.client.ts @@ -7,23 +7,23 @@ import { defineNuxtPlugin, useNuxtApp } from '../nuxt' // @ts-expect-error Virtual file. import { componentIslands } from '#build/nuxt.config.mjs' -const revivers: Record<string, (data: any) => any> = { - NuxtError: data => createError(data), - EmptyShallowRef: data => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : destr(data)), - EmptyRef: data => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : destr(data)), - ShallowRef: data => shallowRef(data), - ShallowReactive: data => shallowReactive(data), - Ref: data => ref(data), - Reactive: data => reactive(data) -} +const revivers: [string, (data: any) => any][] = [ + ['NuxtError', data => createError(data)], + ['EmptyShallowRef', data => shallowRef(data === '_' ? undefined : data === '0n' ? BigInt(0) : destr(data))], + ['EmptyRef', data => ref(data === '_' ? undefined : data === '0n' ? BigInt(0) : destr(data))], + ['ShallowRef', data => shallowRef(data)], + ['ShallowReactive', data => shallowReactive(data)], + ['Ref', data => ref(data)], + ['Reactive', data => reactive(data)], +] if (componentIslands) { - revivers.Island = ({ key, params, result }: any) => { + revivers.push(['Island', ({ key, params, result }: any) => { const nuxtApp = useNuxtApp() if (!nuxtApp.isHydrating) { nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}.json`, { responseType: 'json', - ...params ? { params } : {} + ...params ? { params } : {}, }).then((r) => { nuxtApp.payload.data[key] = r return r @@ -31,25 +31,19 @@ if (componentIslands) { } return { html: '', - state: {}, - head: { - link: [], - style: [] - }, - ...result + ...result, } - } + }]) } export default defineNuxtPlugin({ name: 'nuxt:revive-payload:client', order: -30, async setup (nuxtApp) { - for (const reviver in revivers) { - definePayloadReviver(reviver, revivers[reviver as keyof typeof revivers]) + for (const [reviver, fn] of revivers) { + definePayloadReviver(reviver, fn) } Object.assign(nuxtApp.payload, await nuxtApp.runWithContext(getNuxtClientPayload)) - // For backwards compatibility - TODO: remove later - window.__NUXT__ = nuxtApp.payload - } + delete window.__NUXT__ + }, }) diff --git a/packages/nuxt/src/app/plugins/revive-payload.server.ts b/packages/nuxt/src/app/plugins/revive-payload.server.ts index 39180b9dc9..773b8c77fb 100644 --- a/packages/nuxt/src/app/plugins/revive-payload.server.ts +++ b/packages/nuxt/src/app/plugins/revive-payload.server.ts @@ -6,25 +6,25 @@ import { defineNuxtPlugin } from '../nuxt' // @ts-expect-error Virtual file. import { componentIslands } from '#build/nuxt.config.mjs' -const reducers: Record<string, (data: any) => any> = { - NuxtError: data => isNuxtError(data) && data.toJSON(), - EmptyShallowRef: data => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), - EmptyRef: data => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_')), - ShallowRef: data => isRef(data) && isShallow(data) && data.value, - ShallowReactive: data => isReactive(data) && isShallow(data) && toRaw(data), - Ref: data => isRef(data) && data.value, - Reactive: data => isReactive(data) && toRaw(data) -} +const reducers: [string, (data: any) => any][] = [ + ['NuxtError', data => isNuxtError(data) && data.toJSON()], + ['EmptyShallowRef', data => isRef(data) && isShallow(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_'))], + ['EmptyRef', data => isRef(data) && !data.value && (typeof data.value === 'bigint' ? '0n' : (JSON.stringify(data.value) || '_'))], + ['ShallowRef', data => isRef(data) && isShallow(data) && data.value], + ['ShallowReactive', data => isReactive(data) && isShallow(data) && toRaw(data)], + ['Ref', data => isRef(data) && data.value], + ['Reactive', data => isReactive(data) && toRaw(data)], +] if (componentIslands) { - reducers.Island = data => data && data?.__nuxt_island + reducers.push(['Island', data => data && data?.__nuxt_island]) } export default defineNuxtPlugin({ name: 'nuxt:revive-payload:server', setup () { - for (const reducer in reducers) { - definePayloadReducer(reducer, reducers[reducer as keyof typeof reducers]) + for (const [reducer, fn] of reducers) { + definePayloadReducer(reducer, fn) } - } + }, }) diff --git a/packages/nuxt/src/app/plugins/router.ts b/packages/nuxt/src/app/plugins/router.ts index 576053bf56..207232e6bc 100644 --- a/packages/nuxt/src/app/plugins/router.ts +++ b/packages/nuxt/src/app/plugins/router.ts @@ -1,13 +1,16 @@ import type { Ref } from 'vue' import { computed, defineComponent, h, isReadonly, reactive } from 'vue' -import { isEqual, joinURL, parseQuery, parseURL, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo' +import { isEqual, joinURL, parseQuery, stringifyParsedURL, stringifyQuery, withoutBase } from 'ufo' import { createError } from 'h3' import { defineNuxtPlugin, useRuntimeConfig } from '../nuxt' +import { getRouteRules } from '../composables/manifest' import { clearError, showError } from '../composables/error' import { navigateTo } from '../composables/router' // @ts-expect-error virtual file import { globalMiddleware } from '#build/middleware' +// @ts-expect-error virtual file +import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs' interface Route { /** Percentage encoded pathname section of the URL. */ @@ -38,11 +41,11 @@ function getRouteFromPath (fullPath: string | Partial<Route>) { fullPath = stringifyParsedURL({ pathname: fullPath.path || '', search: stringifyQuery(fullPath.query || {}), - hash: fullPath.hash || '' + hash: fullPath.hash || '', }) } - const url = parseURL(fullPath.toString()) + const url = new URL(fullPath.toString(), import.meta.client ? window.location.href : 'http://localhost') return { path: url.pathname, fullPath, @@ -54,7 +57,7 @@ function getRouteFromPath (fullPath: string | Partial<Route>) { matched: [], redirectedFrom: undefined, meta: {}, - href: fullPath + href: fullPath, } } @@ -74,7 +77,7 @@ interface RouterHooks { interface Router { currentRoute: Ref<Route> isReady: () => Promise<void> - options: {} + options: Record<string, unknown> install: () => Promise<void> // Navigation push: (url: string) => Promise<void> @@ -109,7 +112,7 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ 'navigate:before': [], 'resolve:before': [], 'navigate:after': [], - error: [] + 'error': [], } const registerHook = <T extends keyof RouterHooks> (hook: T, guard: RouterHooks[T]) => { @@ -188,7 +191,7 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ if (index !== -1) { routes.splice(index, 1) } - } + }, } nuxtApp.vueApp.component('RouterLink', defineComponent({ @@ -196,14 +199,14 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ props: { to: { type: String, - required: true + required: true, }, custom: Boolean, replace: Boolean, // Not implemented activeClass: String, exactActiveClass: String, - ariaCurrentValue: String + ariaCurrentValue: String, }, setup: (props, { slots }) => { const navigate = () => handleNavigation(props.to!, props.replace) @@ -213,7 +216,7 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ ? slots.default?.({ href: props.to, navigate, route }) : h('a', { href: props.to, onClick: (e: MouseEvent) => { e.preventDefault(); return navigate() } }, slots) } - } + }, })) if (import.meta.client) { @@ -223,12 +226,13 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ }) } + // @ts-expect-error vue-router types diverge from our Route type above nuxtApp._route = route // Handle middleware nuxtApp._middleware = nuxtApp._middleware || { global: [], - named: {} + named: {}, } const initialLayout = nuxtApp.payload.state._layout @@ -243,6 +247,23 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ if (import.meta.client || !nuxtApp.ssrContext?.islandContext) { const middlewareEntries = new Set<RouteGuard>([...globalMiddleware, ...nuxtApp._middleware.global]) + if (isAppManifestEnabled) { + const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path)) + + if (routeRules.appMiddleware) { + for (const key in routeRules.appMiddleware) { + const guard = nuxtApp._middleware.named[key] as RouteGuard | undefined + if (!guard) { return } + + if (routeRules.appMiddleware[key]) { + middlewareEntries.add(guard) + } else { + middlewareEntries.delete(guard) + } + } + } + } + for (const middleware of middlewareEntries) { const result = await nuxtApp.runWithContext(() => middleware(to, from)) if (import.meta.server) { @@ -251,8 +272,8 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ statusCode: 404, statusMessage: `Page Not Found: ${initialURL}`, data: { - path: initialURL - } + path: initialURL, + }, }) delete nuxtApp._processingMiddleware return nuxtApp.runWithContext(() => showError(error)) @@ -275,8 +296,8 @@ export default defineNuxtPlugin<{ route: Route, router: Router }>({ return { provide: { route, - router - } + router, + }, } - } + }, }) diff --git a/packages/nuxt/src/app/plugins/view-transitions.client.ts b/packages/nuxt/src/app/plugins/view-transitions.client.ts index bb3a696d2d..c24fb0e35e 100644 --- a/packages/nuxt/src/app/plugins/view-transitions.client.ts +++ b/packages/nuxt/src/app/plugins/view-transitions.client.ts @@ -14,7 +14,7 @@ export default defineNuxtPlugin((nuxtApp) => { const router = useRouter() - router.beforeResolve((to, from) => { + router.beforeResolve(async (to, from) => { const viewTransitionMode = to.meta.viewTransition ?? defaultViewTransition const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersNoTransition = prefersReducedMotion && viewTransitionMode !== 'always' @@ -41,6 +41,8 @@ export default defineNuxtPlugin((nuxtApp) => { finishTransition = undefined }) + await nuxtApp.callHook('page:view-transition:start', transition) + return ready }) @@ -54,13 +56,3 @@ export default defineNuxtPlugin((nuxtApp) => { finishTransition = undefined }) }) - -declare global { - interface Document { - startViewTransition?: (callback: () => Promise<void> | void) => { - finished: Promise<void> - updateCallbackDone: Promise<void> - ready: Promise<void> - } - } -} diff --git a/packages/nuxt/src/app/types.ts b/packages/nuxt/src/app/types.ts index 0ec9c30f44..795e92384e 100644 --- a/packages/nuxt/src/app/types.ts +++ b/packages/nuxt/src/app/types.ts @@ -1,6 +1,7 @@ -// eslint-disable-next-line import/no-restricted-paths export type { PageMeta } from '../pages/runtime/index' export interface NuxtAppLiterals { [key: string]: string } + +export type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from '../core/runtime/nitro/renderer' diff --git a/packages/nuxt/src/app/types/augments.d.ts b/packages/nuxt/src/app/types/augments.d.ts index 601fcea877..5e5dd8cf44 100644 --- a/packages/nuxt/src/app/types/augments.d.ts +++ b/packages/nuxt/src/app/types/augments.d.ts @@ -1,22 +1,32 @@ +import type { UseHeadInput } from '@unhead/vue' import type { NuxtApp, useNuxtApp } from '../nuxt' -interface NuxtStaticBuildFlags { - browser: boolean - client: boolean - dev: boolean - server: boolean - test: boolean -} - declare global { namespace NodeJS { - interface Process extends NuxtStaticBuildFlags {} + interface Process { + /** @deprecated Use `import.meta.browser` instead. This may be removed in Nuxt v5 or a future major version. */ + browser: boolean + /** @deprecated Use `import.meta.client` instead. This may be removed in Nuxt v5 or a future major version. */ + client: boolean + /** @deprecated Use `import.meta.dev` instead. This may be removed in Nuxt v5 or a future major version. */ + dev: boolean + /** @deprecated Use `import.meta.server` instead. This may be removed in Nuxt v5 or a future major version. */ + server: boolean + /** @deprecated Use `import.meta.test` instead. This may be removed in Nuxt v5 or a future major version. */ + test: boolean + } } - interface ImportMeta extends NuxtStaticBuildFlags {} + interface ImportMeta extends NuxtStaticBuildFlags { + browser: boolean + client: boolean + dev: boolean + server: boolean + test: boolean + } interface Window { - __NUXT__?: Record<string, any> + __NUXT__?: Record<string, any> | Record<string, Record<string, any>> useNuxtApp?: typeof useNuxtApp } } @@ -30,7 +40,14 @@ declare module 'vue' { $nuxt: NuxtApp } interface ComponentInternalInstance { - _nuxtOnBeforeMountCbs: Function[] + _nuxtOnBeforeMountCbs: Array<() => void | Promise<void>> _nuxtIdIndex?: Record<string, number> } + interface ComponentCustomOptions { + /** + * Available exclusively for `defineNuxtComponent`. + * It will not be executed when using `defineComponent`. + */ + head?(nuxtApp: NuxtApp): UseHeadInput + } } diff --git a/packages/nuxt/src/app/utils.ts b/packages/nuxt/src/app/utils.ts index 4cc2040ad9..72b096120b 100644 --- a/packages/nuxt/src/app/utils.ts +++ b/packages/nuxt/src/app/utils.ts @@ -1,3 +1,4 @@ +/** @since 3.9.0 */ export function toArray<T> (value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts deleted file mode 100644 index 0c98ceb7f8..0000000000 --- a/packages/nuxt/src/components/islandsTransform.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { pathToFileURL } from 'node:url' -import fs from 'node:fs' -import { join } from 'pathe' -import type { Component } from '@nuxt/schema' -import { parseURL } from 'ufo' -import { createUnplugin } from 'unplugin' -import MagicString from 'magic-string' -import { ELEMENT_NODE, parse, walk } from 'ultrahtml' -import { hash } from 'ohash' -import { resolvePath } from '@nuxt/kit' -import { isVue } from '../core/utils' - -interface ServerOnlyComponentTransformPluginOptions { - getComponents: () => Component[] - /** - * passed down to `NuxtTeleportIslandComponent` - * should be done only in dev mode as we use build:manifest result in production - */ - rootDir?: string - isDev?: boolean - /** - * allow using `nuxt-client` attribute on components - */ - selectiveClient?: boolean -} - -interface ComponentChunkOptions { - getComponents: () => Component[] - buildDir: string -} - -const SCRIPT_RE = /<script[^>]*>/g -const HAS_SLOT_OR_CLIENT_RE = /(<slot[^>]*>)|(nuxt-client)/ -const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/ -const NUXTCLIENT_ATTR_RE = /\s:?nuxt-client(="[^"]*")?/g -const IMPORT_CODE = '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + '\nimport NuxtTeleportIslandComponent from \'#app/components/nuxt-teleport-island-component\'' + '\nimport NuxtTeleportSsrSlot from \'#app/components/nuxt-teleport-island-slot\'' - -function wrapWithVForDiv (code: string, vfor: string): string { - return `<div v-for="${vfor}" style="display: contents;">${code}</div>` -} - -export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions, meta) => { - const isVite = meta.framework === 'vite' - const { isDev, rootDir } = options - return { - name: 'server-only-component-transform', - enforce: 'pre', - transformInclude (id) { - if (!isVue(id)) { return false } - const components = options.getComponents() - - const islands = components.filter(component => - component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')) - ) - const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - return islands.some(c => c.filePath === pathname) - }, - async transform (code, id) { - if (!HAS_SLOT_OR_CLIENT_RE.test(code)) { return } - const template = code.match(TEMPLATE_RE) - if (!template) { return } - const startingIndex = template.index || 0 - const s = new MagicString(code) - - if (!code.match(SCRIPT_RE)) { - s.prepend('<script setup>' + IMPORT_CODE + '</script>') - } else { - s.replace(SCRIPT_RE, (full) => { - return full + IMPORT_CODE - }) - } - - let hasNuxtClient = false - - const ast = parse(template[0]) - await walk(ast, (node) => { - if (node.type === ELEMENT_NODE) { - if (node.name === 'slot') { - const { attributes, children, loc } = node - - // pass slot fallback to NuxtTeleportSsrSlot fallback - if (children.length) { - const attrString = Object.entries(attributes).map(([name, value]) => name ? `${name}="${value}" ` : value).join(' ') - const slice = code.slice(startingIndex + loc[0].end, startingIndex + loc[1].start).replaceAll(/:?key="[^"]"/g, '') - s.overwrite(startingIndex + loc[0].start, startingIndex + loc[1].end, `<slot ${attrString} /><template #fallback>${attributes["v-for"] ? wrapWithVForDiv(slice, attributes['v-for']) : slice}</template>`) - } - - const slotName = attributes.name ?? 'default' - let vfor: [string, string] | undefined - if (attributes['v-for']) { - vfor = attributes['v-for'].split(' in ').map((v: string) => v.trim()) as [string, string] - } - delete attributes['v-for'] - - if (attributes.name) { delete attributes.name } - if (attributes['v-bind']) { - attributes._bind = attributes['v-bind'] - delete attributes['v-bind'] - } - const bindings = getPropsToString(attributes, vfor) - - // add the wrapper - s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportSsrSlot name="${slotName}" :props="${bindings}">`) - s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportSsrSlot>') - } else if (options.selectiveClient && ('nuxt-client' in node.attributes || ':nuxt-client' in node.attributes)) { - hasNuxtClient = true - const attributeValue = node.attributes[':nuxt-client'] || node.attributes['nuxt-client'] || 'true' - if (isVite) { - // handle granular interactivity - const htmlCode = code.slice(startingIndex + node.loc[0].start, startingIndex + node.loc[1].end) - const uid = hash(id + node.loc[0].start + node.loc[0].end) - - s.overwrite(startingIndex + node.loc[0].start, startingIndex + node.loc[1].end, `<NuxtTeleportIslandComponent to="${node.name}-${uid}" ${rootDir && isDev ? `root-dir="${rootDir}"` : ''} :nuxt-client="${attributeValue}">${htmlCode.replaceAll(NUXTCLIENT_ATTR_RE, '')}</NuxtTeleportIslandComponent>`) - } - } - } - }) - - if (!isVite && hasNuxtClient) { - // eslint-disable-next-line no-console - console.warn(`nuxt-client attribute and client components within islands is only supported with Vite. file: ${id}`) - } - - if (s.hasChanged()) { - return { - code: s.toString(), - map: s.generateMap({ source: id, includeContent: true }) - } - } - } - } -}) - -function isBinding (attr: string): boolean { - return attr.startsWith(':') -} - -function getPropsToString (bindings: Record<string, string>, vfor?: [string, string]): string { - if (Object.keys(bindings).length === 0) { return 'undefined' } - const content = Object.entries(bindings).filter(b => b[0] && b[0] !== '_bind').map(([name, value]) => isBinding(name) ? `${name.slice(1)}: ${value}` : `${name}: \`${value}\``).join(',') - const data = bindings._bind ? `mergeProps(${bindings._bind}, { ${content} })` : `{ ${content} }` - if (!vfor) { - return `[${data}]` - } else { - return `__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data}))` - } -} - -export const componentsChunkPlugin = createUnplugin((options: ComponentChunkOptions) => { - const { buildDir } = options - return { - name: 'componentsChunkPlugin', - vite: { - async config (config) { - const components = options.getComponents() - config.build = config.build || {} - config.build.rollupOptions = config.build.rollupOptions || {} - config.build.rollupOptions.output = config.build.rollupOptions.output || {} - config.build.rollupOptions.input = config.build.rollupOptions.input || {} - // don't use 'strict', this would create another "facade" chunk for the entry file, causing the ssr styles to not detect everything - config.build.rollupOptions.preserveEntrySignatures = 'allow-extension' - for (const component of components) { - if (component.mode === 'client' || component.mode === 'all') { - (config.build.rollupOptions.input as Record<string, string>)[component.pascalName] = await resolvePath(component.filePath) - } - } - }, - - async generateBundle (_opts, bundle) { - const components = options.getComponents().filter(c => c.mode === 'client' || c.mode === 'all') - const pathAssociation: Record<string, string> = {} - for (const [chunkPath, chunkInfo] of Object.entries(bundle)) { - if (chunkInfo.type !== 'chunk') { continue } - - for (const component of components) { - if (chunkInfo.facadeModuleId && chunkInfo.exports.length > 0) { - const { pathname } = parseURL(decodeURIComponent(pathToFileURL(chunkInfo.facadeModuleId).href)) - const isPath = await resolvePath(component.filePath) === pathname - if (isPath) { - // avoid importing the component chunk in all pages - chunkInfo.isEntry = false - pathAssociation[component.pascalName] = chunkPath - } - } - } - } - - fs.writeFileSync(join(buildDir, 'components-chunk.mjs'), `export const paths = ${JSON.stringify(pathAssociation, null, 2)}`) - } - } - } -}) diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index bcc6fb259b..5c2d3102db 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -1,39 +1,41 @@ -import fs, { statSync } from 'node:fs' -import { join, normalize, relative, resolve } from 'pathe' -import { addPluginTemplate, addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, logger, resolveAlias, updateTemplates } from '@nuxt/kit' +import { existsSync, statSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, normalize, relative, resolve } from 'pathe' +import { addBuildPlugin, addPluginTemplate, addTemplate, addTypeTemplate, addVitePlugin, defineNuxtModule, logger, resolveAlias, resolvePath, updateTemplates } from '@nuxt/kit' import type { Component, ComponentsDir, ComponentsOptions } from 'nuxt/schema' import { distDir } from '../dirs' -import { clientFallbackAutoIdPlugin } from './client-fallback-auto-id' -import { componentNamesTemplate, componentsIslandsTemplate, componentsPluginTemplate, componentsTypeTemplate } from './templates' +import { componentNamesTemplate, componentsIslandsTemplate, componentsMetadataTemplate, componentsPluginTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' -import { loaderPlugin } from './loader' -import { TreeShakeTemplatePlugin } from './tree-shake' -import { componentsChunkPlugin, islandsTransform } from './islandsTransform' -import { createTransformPlugin } from './transform' + +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' +import { TreeShakeTemplatePlugin } from './plugins/tree-shake' +import { ComponentNamePlugin } from './plugins/component-names' const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string' -const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch (_e) { return false } } +const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch { return false } } function compareDirByPathLength ({ path: pathA }: { path: string }, { path: pathB }: { path: string }) { return pathB.split(/[\\/]/).filter(Boolean).length - pathA.split(/[\\/]/).filter(Boolean).length } -const DEFAULT_COMPONENTS_DIRS_RE = /\/components(\/global|\/islands)?$/ +const DEFAULT_COMPONENTS_DIRS_RE = /\/components(?:\/(?:global|islands))?$/ export type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[] export default defineNuxtModule<ComponentsOptions>({ meta: { name: 'components', - configKey: 'components' + configKey: 'components', }, defaults: { - dirs: [] + dirs: [], }, setup (componentOptions, nuxt) { let componentDirs: ComponentsDir[] = [] const context = { - components: [] as Component[] + components: [] as Component[], } const getComponents: getComponentsT = (mode) => { @@ -42,6 +44,11 @@ export default defineNuxtModule<ComponentsOptions>({ : context.components } + if (nuxt.options.experimental.normalizeComponentNames) { + addBuildPlugin(ComponentNamePlugin({ sourcemap: !!nuxt.options.sourcemap.client, getComponents }), { server: false }) + addBuildPlugin(ComponentNamePlugin({ sourcemap: !!nuxt.options.sourcemap.server, getComponents }), { client: false }) + } + const normalizeDirs = (dir: any, cwd: string, options?: { priority?: number }): ComponentsDir[] => { if (Array.isArray(dir)) { return dir.map(dir => normalizeDirs(dir, cwd, options)).flat().sort(compareDirByPathLength) @@ -50,12 +57,12 @@ export default defineNuxtModule<ComponentsOptions>({ return [ { priority: options?.priority || 0, path: resolve(cwd, 'components/islands'), island: true }, { priority: options?.priority || 0, path: resolve(cwd, 'components/global'), global: true }, - { priority: options?.priority || 0, path: resolve(cwd, 'components') } + { priority: options?.priority || 0, path: resolve(cwd, 'components') }, ] } if (typeof dir === 'string') { return [ - { priority: options?.priority || 0, path: resolve(cwd, resolveAlias(dir)) } + { priority: options?.priority || 0, path: resolve(cwd, resolveAlias(dir)) }, ] } if (!dir) { @@ -65,7 +72,7 @@ export default defineNuxtModule<ComponentsOptions>({ return dirs.map(_dir => ({ priority: options?.priority || 0, ..._dir, - path: resolve(cwd, resolveAlias(_dir.path)) + path: resolve(cwd, resolveAlias(_dir.path)), })) } @@ -100,50 +107,44 @@ export default defineNuxtModule<ComponentsOptions>({ ignore: [ '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', // ignore mixins '**/*.d.{cts,mts,ts}', // .d.ts files - ...(dirOptions.ignore || []) + ...(dirOptions.ignore || []), ], - transpile: (transpile === 'auto' ? dirPath.includes('node_modules') : transpile) + transpile: (transpile === 'auto' ? dirPath.includes('node_modules') : transpile), } }).filter(d => d.enabled) componentDirs = [ ...componentDirs.filter(dir => !dir.path.includes('node_modules')), - ...componentDirs.filter(dir => dir.path.includes('node_modules')) + ...componentDirs.filter(dir => dir.path.includes('node_modules')), ] nuxt.options.build!.transpile!.push(...componentDirs.filter(dir => dir.transpile).map(dir => dir.path)) }) // components.d.ts - addTemplate(componentsTypeTemplate) + addTypeTemplate(componentsTypeTemplate) // components.plugin.mjs addPluginTemplate(componentsPluginTemplate) // component-names.mjs addTemplate(componentNamesTemplate) // components.islands.mjs - if (nuxt.options.experimental.componentIslands) { - addTemplate({ ...componentsIslandsTemplate, filename: 'components.islands.mjs' }) - } else { - addTemplate({ filename: 'components.islands.mjs', getContents: () => 'export const islandComponents = {}' }) + addTemplate({ ...componentsIslandsTemplate, filename: 'components.islands.mjs' }) + + if (componentOptions.generateMetadata) { + addTemplate(componentsMetadataTemplate) } - const unpluginServer = createTransformPlugin(nuxt, getComponents, 'server') - const unpluginClient = createTransformPlugin(nuxt, getComponents, 'client') - - addVitePlugin(() => unpluginServer.vite(), { server: true, client: false }) - addVitePlugin(() => unpluginClient.vite(), { server: false, client: true }) - - addWebpackPlugin(() => unpluginServer.webpack(), { server: true, client: false }) - addWebpackPlugin(() => unpluginClient.webpack(), { server: false, client: true }) + addBuildPlugin(TransformPlugin(nuxt, getComponents, 'server'), { server: true, client: false }) + addBuildPlugin(TransformPlugin(nuxt, getComponents, 'client'), { server: false, client: true }) // Do not prefetch global components chunks nuxt.hook('build:manifest', (manifest) => { const sourceFiles = getComponents().filter(c => c.global).map(c => relative(nuxt.options.srcDir, c.filePath)) - for (const key in manifest) { - if (manifest[key].isEntry) { - manifest[key].dynamicImports = - manifest[key].dynamicImports?.filter(i => !sourceFiles.includes(i)) + for (const chunk of Object.values(manifest)) { + if (chunk.isEntry) { + chunk.dynamicImports = + chunk.dynamicImports?.filter(i => !sourceFiles.includes(i)) } } }) @@ -161,29 +162,37 @@ export default defineNuxtModule<ComponentsOptions>({ } }) + const serverPlaceholderPath = resolve(distDir, 'app/components/server-placeholder') + // Scan components and add to plugin nuxt.hook('app:templates', async (app) => { const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!) await nuxt.callHook('components:extend', newComponents) // add server placeholder for .client components server side. issue: #7085 for (const component of newComponents) { + if (!(component as any /* untyped internal property */)._scanned && !(component.filePath in nuxt.vfs) && isAbsolute(component.filePath) && !existsSync(component.filePath)) { + // attempt to resolve component path + component.filePath = await resolvePath(component.filePath, { fallbackToOriginal: true }) + } if (component.mode === 'client' && !newComponents.some(c => c.pascalName === component.pascalName && c.mode === 'server')) { newComponents.push({ ...component, _raw: true, mode: 'server', - filePath: resolve(distDir, 'app/components/server-placeholder'), - chunkName: 'components/' + component.kebabName + filePath: serverPlaceholderPath, + chunkName: 'components/' + component.kebabName, }) } + if (component.mode === 'server' && !nuxt.options.ssr && !newComponents.some(other => other.pascalName === component.pascalName && other.mode === 'client')) { + logger.warn(`Using server components with \`ssr: false\` is not supported with auto-detected component islands. If you need to use server component \`${component.pascalName}\`, set \`experimental.componentIslands\` to \`true\`.`) + } } context.components = newComponents app.components = newComponents }) - nuxt.hook('prepare:types', ({ references, tsConfig }) => { + nuxt.hook('prepare:types', ({ tsConfig }) => { tsConfig.compilerOptions!.paths['#components'] = [resolve(nuxt.options.buildDir, 'components')] - references.push({ path: resolve(nuxt.options.buildDir, 'components.d.ts') }) }) // Watch for changes @@ -198,113 +207,81 @@ export default defineNuxtModule<ComponentsOptions>({ 'components.plugin.mjs', 'components.d.ts', 'components.server.mjs', - 'components.client.mjs' - ].includes(template.filename) + 'components.client.mjs', + ].includes(template.filename), }) } }) - nuxt.hook('vite:extendConfig', (config, { isClient, isServer }) => { - const mode = isClient ? 'client' : 'server' + addBuildPlugin(TreeShakeTemplatePlugin({ sourcemap: !!nuxt.options.sourcemap.server, getComponents }), { client: false }) - config.plugins = config.plugins || [] - if (nuxt.options.experimental.treeshakeClientOnly && isServer) { - config.plugins.push(TreeShakeTemplatePlugin.vite({ - sourcemap: !!nuxt.options.sourcemap[mode], - getComponents - })) - } - config.plugins.push(clientFallbackAutoIdPlugin.vite({ - sourcemap: !!nuxt.options.sourcemap[mode], - rootDir: nuxt.options.rootDir - })) - config.plugins.push(loaderPlugin.vite({ - sourcemap: !!nuxt.options.sourcemap[mode], - getComponents, - mode, - transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined, - experimentalComponentIslands: !!nuxt.options.experimental.componentIslands - })) + 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 }) + } - if (nuxt.options.experimental.componentIslands) { - const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient + const sharedLoaderOptions = { + getComponents, + transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined, + experimentalComponentIslands: !!nuxt.options.experimental.componentIslands, + } + + addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.client, mode: 'client' }), { server: false }) + addBuildPlugin(LoaderPlugin({ ...sharedLoaderOptions, sourcemap: !!nuxt.options.sourcemap.server, mode: 'server' }), { client: false }) + + if (nuxt.options.experimental.componentIslands) { + const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient + + addVitePlugin({ + name: 'nuxt-server-component-hmr', + handleHotUpdate (ctx) { + const components = getComponents() + const filePath = normalize(ctx.file) + const comp = components.find(c => c.filePath === filePath) + if (comp?.mode === 'server') { + ctx.server.ws.send({ + event: `nuxt-server-component:${comp.pascalName}`, + type: 'custom', + }) + } + }, + }, { server: false }) + + addBuildPlugin(IslandsTransformPlugin({ getComponents, selectiveClient }), { client: false }) + + // TODO: refactor this + nuxt.hook('vite:extendConfig', (config, { isClient }) => { + config.plugins = config.plugins || [] if (isClient && selectiveClient) { - fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}') - if(!nuxt.options.dev) { - config.plugins.push(componentsChunkPlugin.vite({ + writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}') + if (!nuxt.options.dev) { + config.plugins.push(ComponentsChunkPlugin.vite({ getComponents, - buildDir: nuxt.options.buildDir + buildDir: nuxt.options.buildDir, })) } else { - fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'),`export const paths = ${JSON.stringify( + writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), `export const paths = ${JSON.stringify( getComponents().filter(c => c.mode === 'client' || c.mode === 'all').reduce((acc, c) => { - if(c.filePath.endsWith('.vue') || c.filePath.endsWith('.js') || c.filePath.endsWith('.ts')) return Object.assign(acc, {[c.pascalName]: `/@fs/${c.filePath}`}) - const filePath = fs.existsSync( `${c.filePath}.vue`) ? `${c.filePath}.vue` : fs.existsSync( `${c.filePath}.js`) ? `${c.filePath}.js` : `${c.filePath}.ts` - return Object.assign(acc, {[c.pascalName]: `/@fs/${filePath}`}) - }, {} as Record<string, string>) + if (c.filePath.endsWith('.vue') || c.filePath.endsWith('.js') || c.filePath.endsWith('.ts')) { return Object.assign(acc, { [c.pascalName]: `/@fs/${c.filePath}` }) } + const filePath = existsSync(`${c.filePath}.vue`) ? `${c.filePath}.vue` : existsSync(`${c.filePath}.js`) ? `${c.filePath}.js` : `${c.filePath}.ts` + return Object.assign(acc, { [c.pascalName]: `/@fs/${filePath}` }) + }, {} as Record<string, string>), )}`) - } - } - - if (isServer) { - config.plugins.push(islandsTransform.vite({ - getComponents, - rootDir: nuxt.options.rootDir, - isDev: nuxt.options.dev, - selectiveClient - })) - } - } - if (!isServer && nuxt.options.experimental.componentIslands) { - config.plugins.push({ - name: 'nuxt-server-component-hmr', - handleHotUpdate (ctx) { - const components = getComponents() - const filePath = normalize(ctx.file) - const comp = components.find(c => c.filePath === filePath) - if (comp?.mode === 'server') { - ctx.server.ws.send({ - event: `nuxt-server-component:${comp.pascalName}`, - type: 'custom' - }) - } - } - }) - } - }) - nuxt.hook('webpack:config', (configs) => { - configs.forEach((config) => { - const mode = config.name === 'client' ? 'client' : 'server' - config.plugins = config.plugins || [] - if (nuxt.options.experimental.treeshakeClientOnly && mode === 'server') { - config.plugins.push(TreeShakeTemplatePlugin.webpack({ - sourcemap: !!nuxt.options.sourcemap[mode], - getComponents - })) - } - config.plugins.push(clientFallbackAutoIdPlugin.webpack({ - sourcemap: !!nuxt.options.sourcemap[mode], - rootDir: nuxt.options.rootDir - })) - config.plugins.push(loaderPlugin.webpack({ - sourcemap: !!nuxt.options.sourcemap[mode], - getComponents, - mode, - transform: typeof nuxt.options.components === 'object' && !Array.isArray(nuxt.options.components) ? nuxt.options.components.transform : undefined, - experimentalComponentIslands: !!nuxt.options.experimental.componentIslands - })) - - if (nuxt.options.experimental.componentIslands) { - if (mode === 'server') { - config.plugins.push(islandsTransform.webpack({ - getComponents - })) - } else { - fs.writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}') } } }) - }) - } + + nuxt.hook('webpack:config', (configs) => { + configs.forEach((config) => { + const mode = config.name === 'client' ? 'client' : 'server' + config.plugins = config.plugins || [] + + if (mode !== 'server') { + writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}') + } + }) + }) + } + }, }) diff --git a/packages/nuxt/src/components/client-fallback-auto-id.ts b/packages/nuxt/src/components/plugins/client-fallback-auto-id.ts similarity index 79% rename from packages/nuxt/src/components/client-fallback-auto-id.ts rename to packages/nuxt/src/components/plugins/client-fallback-auto-id.ts index 3f01c80c25..9f3e1b119c 100644 --- a/packages/nuxt/src/components/client-fallback-auto-id.ts +++ b/packages/nuxt/src/components/plugins/client-fallback-auto-id.ts @@ -3,15 +3,16 @@ 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' +import { isVue } from '../../core/utils' + interface LoaderOptions { sourcemap?: boolean - transform?: ComponentsOptions['transform'], + transform?: ComponentsOptions['transform'] rootDir: string } -const CLIENT_FALLBACK_RE = /<(NuxtClientFallback|nuxt-client-fallback)( [^>]*)?>/ +const CLIENT_FALLBACK_RE = /<(?:NuxtClientFallback|nuxt-client-fallback)(?: [^>]*)?>/ const CLIENT_FALLBACK_GLOBAL_RE = /<(NuxtClientFallback|nuxt-client-fallback)( [^>]*)?>/g -export const clientFallbackAutoIdPlugin = createUnplugin((options: LoaderOptions) => { +export const ClientFallbackAutoIdPlugin = (options: LoaderOptions) => createUnplugin(() => { const exclude = options.transform?.exclude || [] const include = options.transform?.include || [] @@ -36,7 +37,7 @@ export const clientFallbackAutoIdPlugin = createUnplugin((options: LoaderOptions s.replace(CLIENT_FALLBACK_GLOBAL_RE, (full, name, attrs) => { count++ - if (/ :?uid=/g.test(attrs)) { return full } + if (/ :?uid=/.test(attrs)) { return full } return `<${name} :uid="'${hash(relativeID)}' + JSON.stringify($props) + '${count}'" ${attrs ?? ''}>` }) @@ -45,9 +46,9 @@ export const clientFallbackAutoIdPlugin = createUnplugin((options: LoaderOptions code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/components/plugins/component-names.ts b/packages/nuxt/src/components/plugins/component-names.ts new file mode 100644 index 0000000000..4a24ebe96b --- /dev/null +++ b/packages/nuxt/src/components/plugins/component-names.ts @@ -0,0 +1,46 @@ +import { createUnplugin } from 'unplugin' +import MagicString from 'magic-string' +import type { Component } from 'nuxt/schema' +import { isVue } from '../../core/utils' + +interface NameDevPluginOptions { + sourcemap: boolean + getComponents: () => Component[] +} +/** + * Set the default name of components to their PascalCase name + */ +export const ComponentNamePlugin = (options: NameDevPluginOptions) => createUnplugin(() => { + return { + name: 'nuxt:component-name-plugin', + enforce: 'post', + transformInclude (id) { + return isVue(id) || !!id.match(/\.[tj]sx$/) + }, + transform (code, id) { + const filename = id.match(/([^/\\]+)\.\w+$/)?.[1] + if (!filename) { + return + } + + const component = options.getComponents().find(c => c.filePath === id) + + if (!component) { + return + } + + const NAME_RE = new RegExp(`__name:\\s*['"]${filename}['"]`) + const s = new MagicString(code) + s.replace(NAME_RE, `__name: ${JSON.stringify(component.pascalName)}`) + + if (s.hasChanged()) { + return { + code: s.toString(), + map: options.sourcemap + ? s.generateMap({ hires: true }) + : undefined, + } + } + }, + } +}) diff --git a/packages/nuxt/src/components/plugins/islands-transform.ts b/packages/nuxt/src/components/plugins/islands-transform.ts new file mode 100644 index 0000000000..70ad9fb895 --- /dev/null +++ b/packages/nuxt/src/components/plugins/islands-transform.ts @@ -0,0 +1,236 @@ +import { pathToFileURL } from 'node:url' +import fs from 'node:fs' +import { join } from 'pathe' +import type { Component } from '@nuxt/schema' +import { parseURL } from 'ufo' +import { createUnplugin } from 'unplugin' +import MagicString from 'magic-string' +import { ELEMENT_NODE, parse, walk } from 'ultrahtml' +import { hash } from 'ohash' +import { resolvePath } from '@nuxt/kit' +import defu from 'defu' +import { isVue } from '../../core/utils' + +interface ServerOnlyComponentTransformPluginOptions { + getComponents: () => Component[] + /** + * allow using `nuxt-client` attribute on components + */ + selectiveClient?: boolean | 'deep' +} + +interface ComponentChunkOptions { + getComponents: () => Component[] + buildDir: string +} + +const SCRIPT_RE = /<script[^>]*>/gi +const HAS_SLOT_OR_CLIENT_RE = /<slot[^>]*>|nuxt-client/ +const TEMPLATE_RE = /<template>([\s\S]*)<\/template>/ +const NUXTCLIENT_ATTR_RE = /\s:?nuxt-client(="[^"]*")?/g +const IMPORT_CODE = '\nimport { mergeProps as __mergeProps } from \'vue\'' + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + '\nimport NuxtTeleportIslandComponent from \'#app/components/nuxt-teleport-island-component\'' + '\nimport NuxtTeleportSsrSlot from \'#app/components/nuxt-teleport-island-slot\'' +const EXTRACTED_ATTRS_RE = /v-(?:if|else-if|else)(="[^"]*")?/g + +function wrapWithVForDiv (code: string, vfor: string): string { + return `<div v-for="${vfor}" style="display: contents;">${code}</div>` +} + +export const IslandsTransformPlugin = (options: ServerOnlyComponentTransformPluginOptions) => createUnplugin((_options, meta) => { + const isVite = meta.framework === 'vite' + return { + name: 'nuxt:server-only-component-transform', + enforce: 'pre', + transformInclude (id) { + if (!isVue(id)) { return false } + if (isVite && options.selectiveClient === 'deep') { return true } + const components = options.getComponents() + + const islands = components.filter(component => + component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')), + ) + const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + return islands.some(c => c.filePath === pathname) + }, + async transform (code, id) { + if (!HAS_SLOT_OR_CLIENT_RE.test(code)) { return } + const template = code.match(TEMPLATE_RE) + if (!template) { return } + const startingIndex = template.index || 0 + const s = new MagicString(code) + + if (!code.match(SCRIPT_RE)) { + s.prepend('<script setup>' + IMPORT_CODE + '</script>') + } else { + s.replace(SCRIPT_RE, (full) => { + return full + IMPORT_CODE + }) + } + + let hasNuxtClient = false + + const ast = parse(template[0]) + await walk(ast, (node) => { + if (node.type !== ELEMENT_NODE) { + return + } + if (node.name === 'slot') { + const { attributes, children, loc } = node + + const slotName = attributes.name ?? 'default' + + if (attributes.name) { delete attributes.name } + if (attributes['v-bind']) { + attributes._bind = extractAttributes(attributes, ['v-bind'])['v-bind']! + } + const teleportAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else']) + const bindings = getPropsToString(attributes) + // add the wrapper + s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportSsrSlot${attributeToString(teleportAttributes)} name="${slotName}" :props="${bindings}">`) + + if (children.length) { + // pass slot fallback to NuxtTeleportSsrSlot fallback + const attrString = attributeToString(attributes) + const slice = code.slice(startingIndex + loc[0].end, startingIndex + loc[1].start).replaceAll(/:?key="[^"]"/g, '') + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[1].end, `<slot${attrString.replaceAll(EXTRACTED_ATTRS_RE, '')}/><template #fallback>${attributes['v-for'] ? wrapWithVForDiv(slice, attributes['v-for']) : slice}</template>`) + } else { + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replaceAll(EXTRACTED_ATTRS_RE, '')) + } + + s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportSsrSlot>') + return + } + + if (!('nuxt-client' in node.attributes) && !(':nuxt-client' in node.attributes)) { + return + } + + hasNuxtClient = true + + if (!isVite || !options.selectiveClient) { + return + } + + const { loc, attributes } = node + const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true' + + const uid = hash(id + node.loc[0].start + node.loc[0].end) + const wrapperAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else']) + + let startTag = code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replace(NUXTCLIENT_ATTR_RE, '') + if (wrapperAttributes) { + startTag = startTag.replaceAll(EXTRACTED_ATTRS_RE, '') + } + + s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportIslandComponent${attributeToString(wrapperAttributes)} to="${node.name}-${uid}" :nuxt-client="${attributeValue}">`) + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, startTag) + s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportIslandComponent>') + }) + + if (hasNuxtClient) { + if (!options.selectiveClient) { + console.warn(`The \`nuxt-client\` attribute and client components within islands are only supported when \`experimental.componentIslands.selectiveClient\` is enabled. file: ${id}`) + } else if (!isVite) { + console.warn(`The \`nuxt-client\` attribute and client components within islands are only supported with Vite. file: ${id}`) + } + } + + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ source: id, includeContent: true }), + } + } + }, + } +}) + +/** + * extract attributes from a node + */ +function extractAttributes (attributes: Record<string, string>, names: string[]) { + const extracted: Record<string, string> = {} + for (const name of names) { + if (name in attributes) { + extracted[name] = attributes[name]! + delete attributes[name] + } + } + return extracted +} + +function attributeToString (attributes: Record<string, string>) { + return Object.entries(attributes).map(([name, value]) => value ? ` ${name}="${value}"` : ` ${name}`).join('') +} + +function isBinding (attr: string): boolean { + return attr.startsWith(':') +} + +function getPropsToString (bindings: Record<string, string>): string { + const vfor = bindings['v-for']?.split(' in ').map((v: string) => v.trim()) as [string, string] | undefined + if (Object.keys(bindings).length === 0) { return 'undefined' } + const content = Object.entries(bindings).filter(b => b[0] && (b[0] !== '_bind' && b[0] !== 'v-for')).map(([name, value]) => isBinding(name) ? `[\`${name.slice(1)}\`]: ${value}` : `[\`${name}\`]: \`${value}\``).join(',') + const data = bindings._bind ? `__mergeProps(${bindings._bind}, { ${content} })` : `{ ${content} }` + if (!vfor) { + return `[${data}]` + } else { + return `__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data}))` + } +} + +export const ComponentsChunkPlugin = createUnplugin((options: ComponentChunkOptions) => { + const { buildDir } = options + return { + name: 'nuxt:components-chunk', + vite: { + async config (config) { + const components = options.getComponents() + + config.build = defu(config.build, { + rollupOptions: { + input: {}, + output: {}, + }, + }) + + const rollupOptions = config.build.rollupOptions! + + if (typeof rollupOptions.input === 'string') { + rollupOptions.input = { entry: rollupOptions.input } + } else if (typeof rollupOptions.input === 'object' && Array.isArray(rollupOptions.input)) { + rollupOptions.input = rollupOptions.input.reduce<{ [key: string]: string }>((acc, input) => { acc[input] = input; return acc }, {}) + } + + // don't use 'strict', this would create another "facade" chunk for the entry file, causing the ssr styles to not detect everything + rollupOptions.preserveEntrySignatures = 'allow-extension' + for (const component of components) { + if (component.mode === 'client' || component.mode === 'all') { + rollupOptions.input![component.pascalName] = await resolvePath(component.filePath) + } + } + }, + + async generateBundle (_opts, bundle) { + const components = options.getComponents().filter(c => c.mode === 'client' || c.mode === 'all') + const pathAssociation: Record<string, string> = {} + for (const [chunkPath, chunkInfo] of Object.entries(bundle)) { + if (chunkInfo.type !== 'chunk') { continue } + + for (const component of components) { + if (chunkInfo.facadeModuleId && chunkInfo.exports.length > 0) { + const { pathname } = parseURL(decodeURIComponent(pathToFileURL(chunkInfo.facadeModuleId).href)) + const isPath = await resolvePath(component.filePath) === pathname + if (isPath) { + // avoid importing the component chunk in all pages + chunkInfo.isEntry = false + pathAssociation[component.pascalName] = chunkPath + } + } + } + } + + fs.writeFileSync(join(buildDir, 'components-chunk.mjs'), `export const paths = ${JSON.stringify(pathAssociation, null, 2)}`) + }, + }, + } +}) diff --git a/packages/nuxt/src/components/loader.ts b/packages/nuxt/src/components/plugins/loader.ts similarity index 81% rename from packages/nuxt/src/components/loader.ts rename to packages/nuxt/src/components/plugins/loader.ts index 1f372adfb3..e8d6f3cd4b 100644 --- a/packages/nuxt/src/components/loader.ts +++ b/packages/nuxt/src/components/plugins/loader.ts @@ -2,12 +2,12 @@ import { createUnplugin } from 'unplugin' import { genDynamicImport, genImport } from 'knitwork' import MagicString from 'magic-string' import { pascalCase } from 'scule' -import { resolve } from 'pathe' +import { relative, resolve } from 'pathe' import type { Component, ComponentsOptions } from 'nuxt/schema' import { logger, tryUseNuxt } from '@nuxt/kit' -import { distDir } from '../dirs' -import { isVue } from '../core/utils' +import { distDir } from '../../dirs' +import { isVue } from '../../core/utils' interface LoaderOptions { getComponents (): Component[] @@ -17,10 +17,11 @@ interface LoaderOptions { experimentalComponentIslands?: boolean } -export const loaderPlugin = createUnplugin((options: LoaderOptions) => { +export const LoaderPlugin = (options: LoaderOptions) => createUnplugin(() => { const exclude = options.transform?.exclude || [] const include = options.transform?.include || [] const serverComponentRuntime = resolve(distDir, 'components/runtime/server-component') + const nuxt = tryUseNuxt() return { name: 'nuxt:components-loader', @@ -34,7 +35,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { } return isVue(id, { type: ['template', 'script'] }) || !!id.match(/\.[tj]sx$/) }, - transform (code) { + transform (code, id) { const components = options.getComponents() let num = 0 @@ -43,13 +44,17 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { const s = new MagicString(code) // replace `_resolveComponent("...")` to direct import - s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy)?([^'"]*?)["'][\s,]*[^)]*\)/g, (full: string, lazy: string, name: string) => { + s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, name: string) => { const component = findComponent(components, name, options.mode) if (component) { - // @ts-expect-error TODO: refactor to nuxi - if (component._internal_install && tryUseNuxt()?.options.test === false) { - // @ts-expect-error TODO: refactor to nuxi - import('../core/features').then(({ installNuxtModule }) => installNuxtModule(component._internal_install)) + // TODO: refactor to nuxi + const internalInstall = ((component as any)._internal_install) as string + if (internalInstall && nuxt?.options.test === false) { + if (!nuxt.options.dev) { + const relativePath = relative(nuxt.options.rootDir, id) + throw new Error(`[nuxt] \`~/${relativePath}\` is using \`${component.pascalName}\` which requires \`${internalInstall}\``) + } + import('../../core/features').then(({ installNuxtModule }) => installNuxtModule(internalInstall)) } let identifier = map.get(component) || `__nuxt_component_${num++}` map.set(component, identifier) @@ -58,7 +63,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { !components.some(c => c.pascalName === component.pascalName && c.mode === 'client') if (isServerOnly) { imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }])) - imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(name)})`) + imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(component.pascalName)})`) if (!options.experimentalComponentIslands) { logger.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`) } @@ -99,10 +104,10 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/components/transform.ts b/packages/nuxt/src/components/plugins/transform.ts similarity index 69% rename from packages/nuxt/src/components/transform.ts rename to packages/nuxt/src/components/plugins/transform.ts index 39258224e3..85108f0122 100644 --- a/packages/nuxt/src/components/transform.ts +++ b/packages/nuxt/src/components/plugins/transform.ts @@ -6,28 +6,30 @@ import { createUnplugin } from 'unplugin' import { parseURL } from 'ufo' import { parseQuery } from 'vue-router' import { normalize, resolve } from 'pathe' -import { distDir } from '../dirs' -import type { getComponentsT } from './module' +import { genImport } from 'knitwork' +import { distDir } from '../../dirs' +import type { getComponentsT } from '../module' const COMPONENT_QUERY_RE = /[?&]nuxt_component=/ -export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT, mode: 'client' | 'server' | 'all') { +export function TransformPlugin (nuxt: Nuxt, getComponents: getComponentsT, mode: 'client' | 'server' | 'all') { const serverComponentRuntime = resolve(distDir, 'components/runtime/server-component') const componentUnimport = createUnimport({ imports: [ { name: 'componentNames', - from: '#build/component-names' - } + from: '#build/component-names', + }, ], - virtualImports: ['#components'] + virtualImports: ['#components'], + injectAtEnd: true, }) function getComponentsImports (): Import[] { const components = getComponents(mode) return components.flatMap((c): Import[] => { const withMode = (mode: string | undefined) => mode - ? `${c.filePath}${c.filePath.includes('?') ? '&' : '?'}nuxt_component=${mode}&nuxt_component_name=${c.pascalName}` + ? `${c.filePath}${c.filePath.includes('?') ? '&' : '?'}nuxt_component=${mode}&nuxt_component_name=${c.pascalName}&nuxt_component_export=${c.export || 'default'}` : c.filePath const mode = !c._raw && c.mode && ['client', 'server'].includes(c.mode) ? c.mode : undefined @@ -36,19 +38,20 @@ export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT { as: c.pascalName, from: withMode(mode), - name: c.export || 'default' + name: c.export || 'default', }, { as: 'Lazy' + c.pascalName, from: withMode([mode, 'async'].filter(Boolean).join(',')), - name: c.export || 'default' - } + name: c.export || 'default', + }, ] }) } return createUnplugin(() => ({ name: 'nuxt:components:imports', + enforce: 'post', transformInclude (id) { id = normalize(id) return id.startsWith('virtual:') || id.startsWith('\0virtual:') || id.startsWith(nuxt.options.buildDir) || !isIgnored(id) @@ -60,40 +63,42 @@ export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT const query = parseQuery(search) const mode = query.nuxt_component const bare = id.replace(/\?.*/, '') + const componentExport = query.nuxt_component_export as string || 'default' + const exportWording = componentExport === 'default' ? 'export default' : `export const ${componentExport} =` if (mode === 'async') { return { code: [ 'import { defineAsyncComponent } from "vue"', - `export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => r.default))` + `${exportWording} defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => r[${JSON.stringify(componentExport)}] || r.default || r))`, ].join('\n'), - map: null + map: null, } } else if (mode === 'client') { return { code: [ - `import __component from ${JSON.stringify(bare)}`, + genImport(bare, [{ name: componentExport, as: '__component' }]), 'import { createClientOnly } from "#app/components/client-only"', - 'export default createClientOnly(__component)' + `${exportWording} createClientOnly(__component)`, ].join('\n'), - map: null + map: null, } } else if (mode === 'client,async') { return { code: [ 'import { defineAsyncComponent } from "vue"', 'import { createClientOnly } from "#app/components/client-only"', - `export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => createClientOnly(r.default)))` + `${exportWording} defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => createClientOnly(r[${JSON.stringify(componentExport)}] || r.default || r)))`, ].join('\n'), - map: null + map: null, } } else if (mode === 'server' || mode === 'server,async') { const name = query.nuxt_component_name return { code: [ `import { createServerComponent } from ${JSON.stringify(serverComponentRuntime)}`, - `export default createServerComponent(${JSON.stringify(name)})` + `${exportWording} createServerComponent(${JSON.stringify(name)})`, ].join('\n'), - map: null + map: null, } } else { throw new Error(`Unknown component mode: ${mode}, this might be an internal bug of Nuxt.`) @@ -115,8 +120,8 @@ export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT code: result.code, map: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client ? result.s.generateMap({ hires: true }) - : undefined + : undefined, } - } + }, })) } diff --git a/packages/nuxt/src/components/tree-shake.ts b/packages/nuxt/src/components/plugins/tree-shake.ts similarity index 94% rename from packages/nuxt/src/components/tree-shake.ts rename to packages/nuxt/src/components/plugins/tree-shake.ts index 1bcdb4dfb2..ff68f2fa21 100644 --- a/packages/nuxt/src/components/tree-shake.ts +++ b/packages/nuxt/src/components/plugins/tree-shake.ts @@ -6,7 +6,7 @@ import type { AssignmentProperty, CallExpression, Identifier, Literal, MemberExp import { createUnplugin } from 'unplugin' import type { Component } from '@nuxt/schema' import { resolve } from 'pathe' -import { distDir } from '../dirs' +import { distDir } from '../../dirs' interface TreeShakeTemplatePluginOptions { sourcemap?: boolean @@ -16,11 +16,11 @@ interface TreeShakeTemplatePluginOptions { type AcornNode<N extends Node> = N & { start: number, end: number } const SSR_RENDER_RE = /ssrRenderComponent/ -const PLACEHOLDER_EXACT_RE = /^(fallback|placeholder)$/ +const PLACEHOLDER_EXACT_RE = /^(?:fallback|placeholder)$/ const CLIENT_ONLY_NAME_RE = /^(?:_unref\()?(?:_component_)?(?:Lazy|lazy_)?(?:client_only|ClientOnly\)?)$/ const PARSER_OPTIONS = { sourceType: 'module', ecmaVersion: 'latest' } -export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplatePluginOptions) => { +export const TreeShakeTemplatePlugin = (options: TreeShakeTemplatePluginOptions) => createUnplugin(() => { const regexpMap = new WeakMap<Component[], [RegExp, RegExp, string[]]>() return { name: 'nuxt:tree-shake-template', @@ -56,6 +56,8 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat const node = _node as AcornNode<Node> if (isSsrRender(node)) { const [componentCall, _, children] = node.arguments + if (!componentCall) { return } + if (componentCall.type === 'Identifier' || componentCall.type === 'MemberExpression' || componentCall.type === 'CallExpression') { const componentName = getComponentName(node) const isClientComponent = COMPONENTS_IDENTIFIERS_RE.test(componentName) @@ -81,13 +83,13 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat componentsToRemoveSet.add(nameToRemove) } } - } + }, }) } } } } - } + }, }) const componentsToRemove = [...componentsToRemoveSet] @@ -107,10 +109,10 @@ export const TreeShakeTemplatePlugin = createUnplugin((options: TreeShakeTemplat code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) @@ -137,12 +139,14 @@ function removeFromSetupReturn (codeAst: Program, name: string, magicString: Mag const variableList = node.value.body.body.filter((statement): statement is VariableDeclaration => statement.type === 'VariableDeclaration') const returnedVariableDeclaration = variableList.find(declaration => declaration.declarations[0]?.id.type === 'Identifier' && declaration.declarations[0]?.id.name === '__returned__' && declaration.declarations[0]?.init?.type === 'ObjectExpression') if (returnedVariableDeclaration) { - const init = returnedVariableDeclaration.declarations[0].init as ObjectExpression - removePropertyFromObject(init, name, magicString) + const init = returnedVariableDeclaration.declarations[0]?.init as ObjectExpression | undefined + if (init) { + removePropertyFromObject(init, name, magicString) + } } } } - } + }, }) } @@ -197,7 +201,7 @@ function isComponentNotCalledInSetup (codeAst: Node, name: string): string | voi let found = false walk(codeAst, { enter (node) { - if ((node.type === 'Property' && node.key.type === 'Identifier' && node.value.type === 'FunctionExpression' && node.key.name === 'setup') || (node.type === 'FunctionDeclaration' && node.id?.name === '_sfc_ssrRender')) { + if ((node.type === 'Property' && node.key.type === 'Identifier' && node.value.type === 'FunctionExpression' && node.key.name === 'setup') || (node.type === 'FunctionDeclaration' && (node.id?.name === '_sfc_ssrRender' || node.id?.name === 'ssrRender'))) { // walk through the setup function node or the ssrRender function walk(node, { enter (node) { @@ -209,10 +213,10 @@ function isComponentNotCalledInSetup (codeAst: Node, name: string): string | voi // dev only with $setup or _ctx found = (node.property.type === 'Literal' && node.property.value === name) || (node.property.type === 'Identifier' && node.property.name === name) } - } + }, }) } - } + }, }) if (!found) { return name } } @@ -220,7 +224,7 @@ function isComponentNotCalledInSetup (codeAst: Node, name: string): string | voi /** * retrieve the component identifier being used on ssrRender callExpression - * @param {CallExpression} ssrRenderNode - ssrRender callExpression + * @param ssrRenderNode - ssrRender callExpression */ function getComponentName (ssrRenderNode: AcornNode<CallExpression>): string { const componentCall = ssrRenderNode.arguments[0] as Identifier | MemberExpression | CallExpression @@ -250,7 +254,7 @@ function removeVariableDeclarator (codeAst: Node, name: string, magicString: Mag } } } - } + }, }) } diff --git a/packages/nuxt/src/components/runtime/client-component.ts b/packages/nuxt/src/components/runtime/client-component.ts new file mode 100644 index 0000000000..cb2087c12a --- /dev/null +++ b/packages/nuxt/src/components/runtime/client-component.ts @@ -0,0 +1,25 @@ +import { defineAsyncComponent, defineComponent, h } from 'vue' +import type { AsyncComponentLoader } from 'vue' +import ClientOnly from '#app/components/client-only' + +/* @__NO_SIDE_EFFECTS__ */ +export const createClientPage = (loader: AsyncComponentLoader) => { + const page = defineAsyncComponent(import.meta.dev + ? () => loader().then((m) => { + // mark component as client-only for `definePageMeta` + (m.default || m).__clientOnlyPage = true + return m.default || m + }) + : loader) + + return defineComponent({ + inheritAttrs: false, + setup (_, { attrs }) { + return () => h('div', [ + h(ClientOnly, undefined, { + default: () => h(page, attrs), + }), + ]) + }, + }) +} diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 3a2463b09d..c5ecee9b2e 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -1,17 +1,21 @@ -import { defineComponent, h, ref } from 'vue' +import { defineComponent, getCurrentInstance, h, ref } from 'vue' import NuxtIsland from '#app/components/nuxt-island' +import { useRoute } from '#app/composables/router' +import { isPrerendered } from '#app/composables/payload' -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ export const createServerComponent = (name: string) => { return defineComponent({ name, inheritAttrs: false, props: { lazy: Boolean }, - setup (props, { attrs, slots, expose }) { + emits: ['error'], + setup (props, { attrs, slots, expose, emit }) { + const vm = getCurrentInstance() const islandRef = ref<null | typeof NuxtIsland>(null) expose({ - refresh: () => islandRef.value?.refresh() + refresh: () => islandRef.value?.refresh(), }) return () => { @@ -19,9 +23,43 @@ export const createServerComponent = (name: string) => { name, lazy: props.lazy, props: attrs, - ref: islandRef + scopeId: vm?.vnode.scopeId, + ref: islandRef, + onError: (err) => { + emit('error', err) + }, }, slots) } - } + }, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export const createIslandPage = (name: string) => { + return defineComponent({ + name, + inheritAttrs: false, + props: { lazy: Boolean }, + async setup (props, { slots, expose }) { + const islandRef = ref<null | typeof NuxtIsland>(null) + + expose({ + refresh: () => islandRef.value?.refresh(), + }) + + const route = useRoute() + const path = import.meta.client && await isPrerendered(route.path) ? route.path : route.fullPath.replace(/#.*$/, '') + + return () => { + return h('div', [ + h(NuxtIsland, { + name: `page:${name}`, + lazy: props.lazy, + ref: islandRef, + context: { url: path }, + }, slots), + ]) + } + }, }) } diff --git a/packages/nuxt/src/components/scan.ts b/packages/nuxt/src/components/scan.ts index 7bc26a6c57..01d8331cb3 100644 --- a/packages/nuxt/src/components/scan.ts +++ b/packages/nuxt/src/components/scan.ts @@ -26,6 +26,9 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr const scannedPaths: string[] = [] for (const dir of dirs) { + if (dir.enabled === false) { + continue + } // A map from resolved path to component name (used for making duplicate warning message) const resolvedNames = new Map<string, string>() @@ -69,7 +72,7 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr */ const prefixParts = ([] as string[]).concat( dir.prefix ? splitByCase(dir.prefix) : [], - (dir.pathPrefix !== false) ? splitByCase(relative(dir.path, dirname(filePath))) : [] + (dir.pathPrefix !== false) ? splitByCase(relative(dir.path, dirname(filePath))) : [], ) /** @@ -80,8 +83,8 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr */ let fileName = basename(filePath, extname(filePath)) - const island = /\.(island)(\.global)?$/.test(fileName) || dir.island - const global = /\.(global)(\.island)?$/.test(fileName) || dir.global + const island = /\.island(?:\.global)?$/.test(fileName) || dir.island + const global = /\.global(?:\.island)?$/.test(fileName) || dir.global const mode = island ? 'server' : (fileName.match(/(?<=\.)(client|server)(\.global|\.island)*$/)?.[1] || 'all') as 'client' | 'server' | 'all' fileName = fileName.replace(/(\.(client|server))?(\.global|\.island)*$/, '') @@ -93,6 +96,10 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr const componentNameSegments = resolveComponentNameSegments(fileName.replace(/["']/g, ''), prefixParts) const pascalName = pascalCase(componentNameSegments) + if (LAZY_COMPONENT_NAME_REGEX.test(pascalName)) { + logger.warn(`The component \`${pascalName}\` (in \`${filePath}\`) is using the reserved "Lazy" prefix used for dynamic imports, which may cause it to break at runtime.`) + } + if (resolvedNames.has(pascalName + suffix) || resolvedNames.has(pascalName)) { warnAboutDuplicateComponent(pascalName, filePath, resolvedNames.get(pascalName) || resolvedNames.get(pascalName + suffix)!) continue @@ -118,7 +125,9 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr shortPath, export: 'default', // by default, give priority to scanned components - priority: dir.priority ?? 1 + priority: dir.priority ?? 1, + // @ts-expect-error untyped property + _scanned: true, } if (typeof dir.extendComponent === 'function') { @@ -160,6 +169,8 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr function warnAboutDuplicateComponent (componentName: string, filePath: string, duplicatePath: string) { logger.warn(`Two component files resolving to the same name \`${componentName}\`:\n` + `\n - ${filePath}` + - `\n - ${duplicatePath}` + `\n - ${duplicatePath}`, ) } + +const LAZY_COMPONENT_NAME_REGEX = /^Lazy(?=[A-Z])/ diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index 22e845de7e..9707894ff1 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -13,7 +13,7 @@ const createImportMagicComments = (options: ImportMagicCommentsOptions) => { return [ `webpackChunkName: "${chunkName}"`, prefetch === true || typeof prefetch === 'number' ? `webpackPrefetch: ${prefetch}` : false, - preload === true || typeof preload === 'number' ? `webpackPreload: ${preload}` : false + preload === true || typeof preload === 'number' ? `webpackPreload: ${preload}` : false, ].filter(Boolean).join(', ') } @@ -58,26 +58,35 @@ export default defineNuxtPlugin({ } }) ` - } + }, } export const componentNamesTemplate: NuxtTemplate = { filename: 'component-names.mjs', getContents ({ app }) { return `export const componentNames = ${JSON.stringify(app.components.filter(c => !c.island).map(c => c.pascalName))}` - } + }, } export const componentsIslandsTemplate: NuxtTemplate = { // components.islands.mjs' - getContents ({ app }) { + getContents ({ app, nuxt }) { + if (!nuxt.options.experimental.componentIslands) { + return 'export const islandComponents = {}' + } + const components = app.components + const pages = app.pages const islands = components.filter(component => component.island || // .server components without a corresponding .client component will need to be rendered as an island - (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')) + (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client')), ) + const pageExports = pages?.filter(p => (p.mode === 'server' && p.file && p.name)).map((p) => { + return `"page:${p.name}": defineAsyncComponent(${genDynamicImport(p.file!)}.then(c => c.default || c))` + }) || [] + return [ 'import { defineAsyncComponent } from \'vue\'', 'export const islandComponents = import.meta.client ? {} : {', @@ -86,30 +95,38 @@ export const componentsIslandsTemplate: NuxtTemplate = { const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` const comment = createImportMagicComments(c) return ` "${c.pascalName}": defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` - } - ).join(',\n'), - '}' + }, + ).concat(pageExports).join(',\n'), + '}', ].join('\n') - } + }, } -export const componentsTypeTemplate: NuxtTemplate = { - filename: 'components.d.ts', +export const componentsTypeTemplate = { + filename: 'components.d.ts' as const, getContents: ({ app, nuxt }) => { const buildDir = nuxt.options.buildDir - const componentTypes = app.components.filter(c => !c.island).map(c => [ - c.pascalName, - `typeof ${genDynamicImport(isAbsolute(c.filePath) - ? relative(buildDir, c.filePath).replace(/(?<=\w)\.(?!vue)\w+$/g, '') - : c.filePath.replace(/(?<=\w)\.(?!vue)\w+$/g, ''), { wrapper: false })}['${c.export}']` - ]) + const componentTypes = app.components.filter(c => !c.island).map((c) => { + const type = `typeof ${genDynamicImport(isAbsolute(c.filePath) + ? relative(buildDir, c.filePath).replace(/\b\.(?!vue)\w+$/g, '') + : c.filePath.replace(/\b\.(?!vue)\w+$/g, ''), { wrapper: false })}['${c.export}']` + return [ + c.pascalName, + c.island || c.mode === 'server' ? `IslandComponent<${type}>` : type, + ] + }) + + const islandType = 'type IslandComponent<T extends DefineComponent> = T & DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>>' + return ` +import type { DefineComponent, SlotsType } from 'vue' +${nuxt.options.experimental.componentIslands ? islandType : ''} +interface _GlobalComponents { + ${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')} + ${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')} +} - return `// Generated by components discovery declare module 'vue' { - export interface GlobalComponents { -${componentTypes.map(([pascalName, type]) => ` '${pascalName}': ${type}`).join('\n')} -${componentTypes.map(([pascalName, type]) => ` 'Lazy${pascalName}': ${type}`).join('\n')} - } + export interface GlobalComponents extends _GlobalComponents { } } ${componentTypes.map(([pascalName, type]) => `export const ${pascalName}: ${type}`).join('\n')} @@ -117,5 +134,11 @@ ${componentTypes.map(([pascalName, type]) => `export const Lazy${pascalName}: ${ export const componentNames: string[] ` - } + }, +} satisfies NuxtTemplate + +export const componentsMetadataTemplate: NuxtTemplate = { + filename: 'components.json', + write: true, + getContents: ({ app }) => JSON.stringify(app.components, null, 2), } diff --git a/packages/nuxt/src/core/app.ts b/packages/nuxt/src/core/app.ts index 467376d827..50ac4118e5 100644 --- a/packages/nuxt/src/core/app.ts +++ b/packages/nuxt/src/core/app.ts @@ -1,7 +1,7 @@ import { promises as fsp, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'pathe' import { defu } from 'defu' -import { compileTemplate, findPath, logger, normalizePlugin, normalizeTemplate, resolveAlias, resolveFiles, resolvePath, templateUtils, tryResolveModule } from '@nuxt/kit' +import { findPath, logger, normalizePlugin, normalizeTemplate, resolveAlias, resolveFiles, resolvePath } from '@nuxt/kit' import type { Nuxt, NuxtApp, NuxtPlugin, NuxtTemplate, ResolvedNuxtTemplate } from 'nuxt/schema' import * as defaultTemplates from './templates' @@ -16,10 +16,16 @@ export function createApp (nuxt: Nuxt, options: Partial<NuxtApp> = {}): NuxtApp extensions: nuxt.options.extensions, plugins: [], components: [], - templates: [] + templates: [], } as unknown as NuxtApp) as NuxtApp } +const postTemplates = [ + defaultTemplates.clientPluginTemplate.filename, + defaultTemplates.serverPluginTemplate.filename, + defaultTemplates.pluginsDeclaration.filename, +] + export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?: (template: ResolvedNuxtTemplate<any>) => boolean } = {}) { // Resolve app await resolveApp(nuxt, app) @@ -33,22 +39,37 @@ export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?: // Normalize templates app.templates = app.templates.map(tmpl => normalizeTemplate(tmpl)) + // compile plugins first as they are needed within the nuxt.vfs + // in order to annotate templated plugins + const filteredTemplates: Record<'pre' | 'post', Array<ResolvedNuxtTemplate<any>>> = { + pre: [], + post: [], + } + + for (const template of app.templates as Array<ResolvedNuxtTemplate<any>>) { + if (options.filter && !options.filter(template)) { continue } + const key = template.filename && postTemplates.includes(template.filename) ? 'post' : 'pre' + filteredTemplates[key].push(template) + } + // Compile templates into vfs - // TODO: remove utils in v4 - const templateContext = { utils: templateUtils, nuxt, app } - const filteredTemplates = (app.templates as Array<ResolvedNuxtTemplate<any>>) - .filter(template => !options.filter || options.filter(template)) + const templateContext = { nuxt, app } const writes: Array<() => void> = [] - await Promise.allSettled(filteredTemplates - .map(async (template) => { - const fullPath = template.dst || resolve(nuxt.options.buildDir, template.filename!) - const mark = performance.mark(fullPath) - const contents = await compileTemplate(template, templateContext).catch((e) => { - logger.error(`Could not compile template \`${template.filename}\`.`) - throw e - }) + const changedTemplates: Array<ResolvedNuxtTemplate<any>> = [] + async function processTemplate (template: ResolvedNuxtTemplate) { + const fullPath = template.dst || resolve(nuxt.options.buildDir, template.filename!) + const start = performance.now() + const oldContents = nuxt.vfs[fullPath] + const contents = await compileTemplate(template, templateContext).catch((e) => { + logger.error(`Could not compile template \`${template.filename}\`.`) + logger.error(e) + throw e + }) + + template.modified = oldContents !== contents + if (template.modified) { nuxt.vfs[fullPath] = contents const aliasPath = '#build/' + template.filename!.replace(/\.\w+$/, '') @@ -59,41 +80,67 @@ export async function generateApp (nuxt: Nuxt, app: NuxtApp, options: { filter?: nuxt.vfs[fullPath.replace(/\//g, '\\')] = contents } - const perf = performance.measure(fullPath, mark?.name) // TODO: remove when Node 14 reaches EOL - const setupTime = perf ? Math.round((perf.duration * 100)) / 100 : 0 // TODO: remove when Node 14 reaches EOL + changedTemplates.push(template) + } - if (nuxt.options.debug || setupTime > 500) { - logger.info(`Compiled \`${template.filename}\` in ${setupTime}ms`) - } + const perf = performance.now() - start + const setupTime = Math.round((perf * 100)) / 100 - if (template.write) { - writes.push(() => { - mkdirSync(dirname(fullPath), { recursive: true }) - writeFileSync(fullPath, contents, 'utf8') - }) - } - })) + if (nuxt.options.debug || setupTime > 500) { + logger.info(`Compiled \`${template.filename}\` in ${setupTime}ms`) + } + + if (template.modified && template.write) { + writes.push(() => { + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, contents, 'utf8') + }) + } + } + + await Promise.allSettled(filteredTemplates.pre.map(processTemplate)) + await Promise.allSettled(filteredTemplates.post.map(processTemplate)) // Write template files in single synchronous step to avoid (possible) additional // runtime overhead of cascading HMRs from vite/webpack for (const write of writes) { write() } - await nuxt.callHook('app:templatesGenerated', app, filteredTemplates, options) + if (changedTemplates.length) { + await nuxt.callHook('app:templatesGenerated', app, changedTemplates, options) + } } /** @internal */ +async function compileTemplate<T> (template: NuxtTemplate<T>, ctx: { nuxt: Nuxt, app: NuxtApp, utils?: unknown }) { + delete ctx.utils + + if (template.src) { + try { + return await fsp.readFile(template.src, 'utf-8') + } catch (err) { + logger.error(`[nuxt] Error reading template from \`${template.src}\``) + throw err + } + } + if (template.getContents) { + return template.getContents({ ...ctx, options: template.options! }) + } + + throw new Error('[nuxt] Invalid template. Templates must have either `src` or `getContents`: ' + JSON.stringify(template)) +} + export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { // Resolve main (app.vue) if (!app.mainComponent) { app.mainComponent = await findPath( nuxt.options._layers.flatMap(layer => [ join(layer.config.srcDir, 'App'), - join(layer.config.srcDir, 'app') - ]) + join(layer.config.srcDir, 'app'), + ]), ) } if (!app.mainComponent) { - app.mainComponent = (await tryResolveModule('@nuxt/ui-templates/templates/welcome.vue', nuxt.options.modulesDir)) ?? '@nuxt/ui-templates/templates/welcome.vue' + app.mainComponent = resolve(nuxt.options.appDir, 'components/welcome.vue') } // Resolve root component @@ -104,7 +151,7 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { // Resolve error component if (!app.errorComponent) { app.errorComponent = (await findPath( - nuxt.options._layers.map(layer => join(layer.config.srcDir, 'error')) + nuxt.options._layers.map(layer => join(layer.config.srcDir, 'error')), )) ?? resolve(nuxt.options.appDir, 'components/nuxt-error-page.vue') } @@ -130,7 +177,12 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { app.middleware = [] for (const config of reversedConfigs) { const middlewareDir = (config.rootDir === nuxt.options.rootDir ? nuxt.options : config).dir?.middleware || 'middleware' - const middlewareFiles = await resolveFiles(config.srcDir, `${middlewareDir}/*{${nuxt.options.extensions.join(',')}}`) + const middlewareFiles = await resolveFiles(config.srcDir, [ + `${middlewareDir}/*{${nuxt.options.extensions.join(',')}}`, + ...nuxt.options.future.compatibilityVersion === 4 + ? [`${middlewareDir}/*/index{${nuxt.options.extensions.join(',')}}`] + : [], + ]) for (const file of middlewareFiles) { const name = getNameFromPath(file) if (!name) { @@ -150,10 +202,10 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { ...(config.plugins || []), ...config.srcDir ? await resolveFiles(config.srcDir, [ - `${pluginDir}/*.{ts,js,mjs,cjs,mts,cts}`, - `${pluginDir}/*/index.*{ts,js,mjs,cjs,mts,cts}` // TODO: remove, only scan top-level plugins #18418 + `${pluginDir}/*{${nuxt.options.extensions.join(',')}}`, + `${pluginDir}/*/index{${nuxt.options.extensions.join(',')}}`, ]) - : [] + : [], ].map(plugin => normalizePlugin(plugin as NuxtPlugin))) } @@ -191,22 +243,29 @@ function resolvePaths<Item extends Record<string, any>> (items: Item[], key: { [ if (!item[key]) { return item } return { ...item, - [key]: await resolvePath(resolveAlias(item[key])) + [key]: await resolvePath(resolveAlias(item[key])), } })) } +const IS_TSX = /\.[jt]sx$/ + export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) { const _plugins: Array<NuxtPlugin & Omit<PluginMeta, 'enforce'>> = [] for (const plugin of plugins) { try { - const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src] : await fsp.readFile(plugin.src!, 'utf-8') + const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src]! : await fsp.readFile(plugin.src!, 'utf-8') _plugins.push({ - ...await extractMetadata(code), - ...plugin + ...await extractMetadata(code, IS_TSX.test(plugin.src) ? 'tsx' : 'ts'), + ...plugin, }) } catch (e) { - logger.warn(`Could not resolve \`${plugin.src}\`.`) + const relativePluginSrc = relative(nuxt.options.rootDir, plugin.src) + if ((e as Error).message === 'Invalid plugin metadata') { + logger.warn(`Failed to parse static properties from plugin \`${relativePluginSrc}\`, falling back to non-optimized runtime meta. Learn more: https://nuxt.com/docs/guide/directory-structure/plugins#object-syntax-plugins`) + } else { + logger.warn(`Failed to parse static properties from plugin \`${relativePluginSrc}\`.`, e) + } _plugins.push(plugin) } } @@ -233,7 +292,7 @@ export function checkForCircularDependencies (_plugins: Array<NuxtPlugin & Omit< return [] } visited.push(name) - return (deps[name] || []).flatMap(dep => checkDeps(dep, [...visited])) + return deps[name]?.length ? deps[name].flatMap(dep => checkDeps(dep, [...visited])) : [] } for (const name in deps) { checkDeps(name) diff --git a/packages/nuxt/src/core/builder.ts b/packages/nuxt/src/core/builder.ts index 80b96abd8e..1e8d7eb0e1 100644 --- a/packages/nuxt/src/core/builder.ts +++ b/packages/nuxt/src/core/builder.ts @@ -1,15 +1,14 @@ -import { pathToFileURL } from 'node:url' import type { EventType } from '@parcel/watcher' import type { FSWatcher } from 'chokidar' -import chokidar from 'chokidar' -import { isIgnored, logger, tryResolveModule, useNuxt } from '@nuxt/kit' -import { interopDefault } from 'mlly' +import { watch as chokidarWatch } from 'chokidar' +import { importModule, isIgnored, logger, tryResolveModule, useNuxt } from '@nuxt/kit' import { debounce } from 'perfect-debounce' import { normalize, relative, resolve } from 'pathe' import type { Nuxt, NuxtBuilder } from 'nuxt/schema' import { generateApp as _generateApp, createApp } from './app' import { checkForExternalConfigurationFiles } from './external-config-files' +import { cleanupCaches, getVueHash } from './cache' export async function build (nuxt: Nuxt) { const app = createApp(nuxt) @@ -24,7 +23,7 @@ export async function build (nuxt: Nuxt) { if (event === 'change') { return } const path = resolve(nuxt.options.srcDir, relativePath) const relativePaths = nuxt.options._layers.map(l => relative(l.config.srcDir || l.cwd, path)) - const restartPath = relativePaths.find(relativePath => /^(app\.|error\.|plugins\/|middleware\/|layouts\/)/i.test(relativePath)) + const restartPath = relativePaths.find(relativePath => /^(?:app\.|error\.|plugins\/|middleware\/|layouts\/)/i.test(relativePath)) if (restartPath) { if (restartPath.startsWith('app')) { app.mainComponent = undefined @@ -42,12 +41,30 @@ export async function build (nuxt: Nuxt) { }) } - await nuxt.callHook('build:before') - if (!nuxt.options._prepare) { - await Promise.all([checkForExternalConfigurationFiles(), bundle(nuxt)]) - await nuxt.callHook('build:done') + if (!nuxt.options._prepare && !nuxt.options.dev && nuxt.options.experimental.buildCache) { + const { restoreCache, collectCache } = await getVueHash(nuxt) + if (await restoreCache()) { + await nuxt.callHook('build:done') + return await nuxt.callHook('close', nuxt) + } + nuxt.hooks.hookOnce('nitro:build:before', () => collectCache()) + nuxt.hooks.hookOnce('close', () => cleanupCaches(nuxt)) } + await nuxt.callHook('build:before') + if (nuxt.options._prepare) { + nuxt.hook('prepare:types', () => nuxt.close()) + return + } + + if (nuxt.options.dev) { + checkForExternalConfigurationFiles() + } + + await bundle(nuxt) + + await nuxt.callHook('build:done') + if (!nuxt.options.dev) { await nuxt.callHook('close', nuxt) } @@ -56,7 +73,7 @@ export async function build (nuxt: Nuxt) { const watchEvents: Record<EventType, 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'> = { create: 'add', delete: 'unlink', - update: 'change' + update: 'change', } async function watch (nuxt: Nuxt) { @@ -75,17 +92,16 @@ async function watch (nuxt: Nuxt) { function createWatcher () { const nuxt = useNuxt() - const watcher = chokidar.watch(nuxt.options._layers.map(i => i.config.srcDir as string).filter(Boolean), { + const watcher = chokidarWatch(nuxt.options._layers.map(i => i.config.srcDir as string).filter(Boolean), { ...nuxt.options.watchers.chokidar, ignoreInitial: true, ignored: [ isIgnored, - 'node_modules' - ] + 'node_modules', + ], }) - // TODO: consider moving to emit absolute path in 3.8 or 4.0 - watcher.on('all', (event, path) => nuxt.callHook('builder:watch', event, normalize(relative(nuxt.options.srcDir, path)))) + watcher.on('all', (event, path) => nuxt.callHook('builder:watch', event, normalize(path))) nuxt.hook('close', () => watcher?.close()) } @@ -109,24 +125,22 @@ function createGranularWatcher () { } for (const dir of pathsToWatch) { pending++ - const watcher = chokidar.watch(dir, { ...nuxt.options.watchers.chokidar, ignoreInitial: false, depth: 0, ignored: [isIgnored, '**/node_modules'] }) + const watcher = chokidarWatch(dir, { ...nuxt.options.watchers.chokidar, ignoreInitial: false, depth: 0, ignored: [isIgnored, '**/node_modules'] }) const watchers: Record<string, FSWatcher> = {} watcher.on('all', (event, path) => { path = normalize(path) if (!pending) { - // TODO: consider moving to emit absolute path in 3.8 or 4.0 - nuxt.callHook('builder:watch', event, relative(nuxt.options.srcDir, path)) + nuxt.callHook('builder:watch', event, path) } if (event === 'unlinkDir' && path in watchers) { watchers[path]?.close() delete watchers[path] } if (event === 'addDir' && path !== dir && !ignoredDirs.has(path) && !pathsToWatch.includes(path) && !(path in watchers) && !isIgnored(path)) { - watchers[path] = chokidar.watch(path, { ...nuxt.options.watchers.chokidar, ignored: [isIgnored] }) - // TODO: consider moving to emit absolute path in 3.8 or 4.0 - watchers[path].on('all', (event, p) => nuxt.callHook('builder:watch', event, normalize(relative(nuxt.options.srcDir, p)))) - nuxt.hook('close', () => watchers[path]?.close()) + const pathWatcher = watchers[path] = chokidarWatch(path, { ...nuxt.options.watchers.chokidar, ignored: [isIgnored] }) + pathWatcher.on('all', (event, p) => nuxt.callHook('builder:watch', event, normalize(p))) + nuxt.hook('close', () => pathWatcher?.close()) } }) watcher.on('ready', () => { @@ -136,6 +150,7 @@ function createGranularWatcher () { console.timeEnd('[nuxt] builder:chokidar:watch') } }) + nuxt.hook('close', () => watcher?.close()) } } @@ -151,21 +166,20 @@ async function createParcelWatcher () { return false } - const { subscribe } = await import(pathToFileURL(watcherPath).href).then(interopDefault) as typeof import('@parcel/watcher') + const { subscribe } = await importModule<typeof import('@parcel/watcher')>(watcherPath) for (const layer of nuxt.options._layers) { if (!layer.config.srcDir) { continue } const watcher = subscribe(layer.config.srcDir, (err, events) => { if (err) { return } for (const event of events) { if (isIgnored(event.path)) { continue } - // TODO: consider moving to emit absolute path in 3.8 or 4.0 - nuxt.callHook('builder:watch', watchEvents[event.type], normalize(relative(nuxt.options.srcDir, event.path))) + nuxt.callHook('builder:watch', watchEvents[event.type], normalize(event.path)) } }, { ignore: [ ...nuxt.options.ignore, - 'node_modules' - ] + 'node_modules', + ], }) watcher.then((subscription) => { if (nuxt.options.debug) { @@ -202,5 +216,5 @@ async function loadBuilder (nuxt: Nuxt, builder: string): Promise<NuxtBuilder> { if (!builderPath) { throw new Error(`Loading \`${builder}\` builder failed. You can read more about the nuxt \`builder\` option at: \`https://nuxt.com/docs/api/nuxt-config#builder\``) } - return import(pathToFileURL(builderPath).href) + return importModule(builderPath) } diff --git a/packages/nuxt/src/core/cache.ts b/packages/nuxt/src/core/cache.ts new file mode 100644 index 0000000000..748d57bc79 --- /dev/null +++ b/packages/nuxt/src/core/cache.ts @@ -0,0 +1,275 @@ +import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { isIgnored } from '@nuxt/kit' +import type { Nuxt, NuxtConfig, NuxtConfigLayer } from '@nuxt/schema' +import { hash, murmurHash, objectHash } from 'ohash' +import { glob } from 'tinyglobby' +import _consola, { consola } from 'consola' +import { dirname, join, relative } from 'pathe' +import { createTar, parseTar } from 'nanotar' +import type { TarFileInput } from 'nanotar' + +export async function getVueHash (nuxt: Nuxt) { + const id = 'vue' + + const { hash } = await getHashes(nuxt, { + id, + cwd: layer => layer.config?.srcDir, + patterns: layer => [ + join(relative(layer.cwd, layer.config.srcDir), '**'), + `!${relative(layer.cwd, layer.config.serverDir || join(layer.cwd, 'server'))}/**`, + `!${relative(layer.cwd, resolve(layer.config.srcDir || layer.cwd, layer.config.dir?.public || 'public'))}/**`, + `!${relative(layer.cwd, resolve(layer.config.srcDir || layer.cwd, layer.config.dir?.static || 'public'))}/**`, + '!node_modules/**', + '!nuxt.config.*', + ], + configOverrides: { + buildId: undefined, + serverDir: undefined, + nitro: undefined, + devServer: undefined, + runtimeConfig: undefined, + logLevel: undefined, + devServerHandlers: undefined, + generate: undefined, + devtools: undefined, + }, + }) + + const cacheFile = join(nuxt.options.workspaceDir, 'node_modules/.cache/nuxt/builds', id, hash + '.tar') + + return { + hash, + async collectCache () { + const start = Date.now() + await writeCache(nuxt.options.buildDir, nuxt.options.buildDir, cacheFile) + const elapsed = Date.now() - start + consola.success(`Cached Vue client and server builds in \`${elapsed}ms\`.`) + }, + async restoreCache () { + const start = Date.now() + const res = await restoreCache(nuxt.options.buildDir, cacheFile) + const elapsed = Date.now() - start + if (res) { + consola.success(`Restored Vue client and server builds from cache in \`${elapsed}ms\`.`) + } + return res + }, + } +} + +export async function cleanupCaches (nuxt: Nuxt) { + const start = Date.now() + const caches = await glob(['*/*.tar'], { + cwd: join(nuxt.options.workspaceDir, 'node_modules/.cache/nuxt/builds'), + absolute: true, + }) + if (caches.length >= 10) { + const cachesWithMeta = await Promise.all(caches.map(async (cache) => { + return [cache, await stat(cache).then(r => r.mtime.getTime()).catch(() => 0)] as const + })) + cachesWithMeta.sort((a, b) => a[1] - b[1]) + for (const [cache] of cachesWithMeta.slice(0, cachesWithMeta.length - 10)) { + await unlink(cache) + } + const elapsed = Date.now() - start + consola.success(`Cleaned up old build caches in \`${elapsed}ms\`.`) + } +} + +// internal + +type HashSource = { name: string, data: any } +type Hashes = { hash: string, sources: HashSource[] } + +interface GetHashOptions { + id: string + cwd: (layer: NuxtConfigLayer) => string + patterns: (layer: NuxtConfigLayer) => string[] + configOverrides: Partial<Record<keyof NuxtConfig, unknown>> +} + +async function getHashes (nuxt: Nuxt, options: GetHashOptions): Promise<Hashes> { + if ((nuxt as any)[`_${options.id}BuildHash`]) { + return (nuxt as any)[`_${options.id}BuildHash`] + } + + const start = Date.now() + const hashSources: HashSource[] = [] + + // Layers + let layerCtr = 0 + for (const layer of nuxt.options._layers) { + if (layer.cwd.includes('node_modules')) { continue } + + const layerName = `layer#${layerCtr++}` + hashSources.push({ + name: `${layerName}:config`, + data: objectHash({ + ...layer.config, + ...options.configOverrides || {}, + }), + }) + + const normalizeFiles = (files: Awaited<ReturnType<typeof readFilesRecursive>>) => files.map(f => ({ + name: f.name, + size: (f.attrs as any)?.size, + data: murmurHash(f.data as any /* ArrayBuffer */), + })) + + const sourceFiles = await readFilesRecursive(options.cwd(layer), { + shouldIgnore: isIgnored, // TODO: Validate if works with absolute paths + cwd: nuxt.options.rootDir, + patterns: options.patterns(layer), + }) + + hashSources.push({ + name: `${layerName}:src`, + data: normalizeFiles(sourceFiles), + }) + + const rootFiles = await readFilesRecursive(layer.config?.rootDir || layer.cwd, { + shouldIgnore: isIgnored, // TODO: Validate if works with absolute paths + cwd: nuxt.options.rootDir, + patterns: [ + '.nuxtrc', + '.npmrc', + 'package.json', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'tsconfig.json', + 'bun.lockb', + ], + }) + + hashSources.push({ + name: `${layerName}:root`, + data: normalizeFiles(rootFiles), + }) + } + + const res = ((nuxt as any)[`_${options.id}BuildHash`] = { + hash: hash(hashSources), + sources: hashSources, + }) + + const elapsed = Date.now() - start + consola.debug(`Computed \`${options.id}\` build hash in \`${elapsed}ms\`.`) + + return res +} + +type FileWithMeta = TarFileInput & { + attrs: { + mtime: number + size: number + } +} + +interface ReadFilesRecursiveOptions { + shouldIgnore?: (name: string) => boolean + patterns: string[] + cwd: string +} + +async function readFilesRecursive (dir: string | string[], opts: ReadFilesRecursiveOptions): Promise<FileWithMeta[]> { + if (Array.isArray(dir)) { + return (await Promise.all(dir.map(d => readFilesRecursive(d, opts)))).flat() + } + + const files = await glob(opts.patterns, { cwd: dir }) + + const fileEntries = await Promise.all(files.map(async (fileName) => { + if (!opts.shouldIgnore?.(fileName)) { + const file = await readFileWithMeta(dir, fileName) + if (!file) { return } + return { + ...file, + name: relative(opts.cwd, join(dir, file.name)), + } + } + })) + + return fileEntries.filter(Boolean) as FileWithMeta[] +} + +async function readFileWithMeta (dir: string, fileName: string, count = 0): Promise<FileWithMeta | undefined> { + let fd: FileHandle | undefined = undefined + + try { + fd = await open(resolve(dir, fileName)) + const stats = await fd.stat() + + if (!stats?.isFile()) { return } + + const mtime = stats.mtime.getTime() + const data = await fd.readFile() + + // retry if file has changed during read + if ((await fd.stat()).mtime.getTime() !== mtime) { + if (count < 5) { + return readFileWithMeta(dir, fileName, count + 1) + } + console.warn(`Failed to read file \`${fileName}\` as it changed during read.`) + return + } + + return { + name: fileName, + data, + attrs: { + mtime, + size: stats.size, + }, + } + } catch (err) { + console.warn(`Failed to read file \`${fileName}\`:`, err) + } finally { + await fd?.close() + } +} + +async function restoreCache (cwd: string, cacheFile: string) { + if (!existsSync(cacheFile)) { + return false + } + + const files = parseTar(await readFile(cacheFile)) + for (const file of files) { + let fd: FileHandle | undefined = undefined + try { + const filePath = resolve(cwd, file.name) + await mkdir(dirname(filePath), { recursive: true }) + + fd = await open(filePath, 'w') + + const stats = await fd.stat().catch(() => null) + if (stats?.isFile() && stats.size) { + const lastModified = Number.parseInt(file.attrs?.mtime?.toString().padEnd(13, '0') || '0') + if (stats.mtime.getTime() >= lastModified) { + consola.debug(`Skipping \`${file.name}\` (up to date or newer than cache)`) + continue + } + } + await fd.writeFile(file.data!) + } catch (err) { + console.error(err) + } finally { + await fd?.close() + } + } + return true +} + +async function writeCache (cwd: string, sources: string | string[], cacheFile: string) { + const fileEntries = await readFilesRecursive(sources, { + patterns: ['**/*', '!analyze/**'], + cwd, + }) + const tarData = createTar(fileEntries) + await mkdir(dirname(cacheFile), { recursive: true }) + await writeFile(cacheFile, tarData) +} diff --git a/packages/nuxt/src/core/external-config-files.ts b/packages/nuxt/src/core/external-config-files.ts index 517c88ecba..6c6d5d0d21 100644 --- a/packages/nuxt/src/core/external-config-files.ts +++ b/packages/nuxt/src/core/external-config-files.ts @@ -30,7 +30,7 @@ async function checkViteConfig () { return await checkAndWarnAboutConfigFileExistence({ fileName: 'vite.config', extensions: ['.js', '.mjs', '.ts', '.cjs', '.mts', '.cts'], - createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.vite\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#vite\`.` + createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.vite\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#vite\`.`, }) } @@ -39,7 +39,7 @@ async function checkWebpackConfig () { return await checkAndWarnAboutConfigFileExistence({ fileName: 'webpack.config', extensions: ['.js', '.mjs', '.ts', '.cjs', '.mts', '.cts', 'coffee'], - createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.webpack\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#webpack-1\`.` + createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.webpack\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#webpack-1\`.`, }) } @@ -48,7 +48,7 @@ async function checkNitroConfig () { return await checkAndWarnAboutConfigFileExistence({ fileName: 'nitro.config', extensions: ['.ts', '.mts'], - createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.nitro\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#nitro\`.` + createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.nitro\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#nitro\`.`, }) } @@ -56,13 +56,13 @@ async function checkPostCSSConfig () { return await checkAndWarnAboutConfigFileExistence({ fileName: 'postcss.config', extensions: ['.js', '.cjs'], - createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.postcss\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#postcss\`.` + createWarningMessage: foundFile => `Using \`${foundFile}\` is not supported together with Nuxt. Use \`options.postcss\` instead. You can read more in \`https://nuxt.com/docs/api/nuxt-config#postcss\`.`, }) } interface CheckAndWarnAboutConfigFileExistenceOptions { - fileName: string, - extensions: string[], + fileName: string + extensions: string[] createWarningMessage: (foundFile: string) => string } diff --git a/packages/nuxt/src/core/features.ts b/packages/nuxt/src/core/features.ts index b23b67444f..2e1bba0106 100644 --- a/packages/nuxt/src/core/features.ts +++ b/packages/nuxt/src/core/features.ts @@ -5,7 +5,7 @@ import { isCI, provider } from 'std-env' const isStackblitz = provider === 'stackblitz' -export interface EnsurePackageInstalledOptions { +interface EnsurePackageInstalledOptions { rootDir: string searchPaths?: string[] prompt?: boolean @@ -26,7 +26,7 @@ async function promptToInstall (name: string, installCommand: () => Promise<void const confirm = await logger.prompt(`Do you want to install ${name} package?`, { type: 'confirm', name: 'confirm', - initial: true + initial: true, }) if (!confirm) { @@ -60,6 +60,6 @@ export function installNuxtModule (name: string, options?: EnsurePackageInstalle export function ensurePackageInstalled (name: string, options: EnsurePackageInstalledOptions) { return promptToInstall(name, () => addDependency(name, { cwd: options.rootDir, - dev: true + dev: true, }), options) } diff --git a/packages/nuxt/src/core/modules.ts b/packages/nuxt/src/core/modules.ts index 401aa9c41d..6a9577f37d 100644 --- a/packages/nuxt/src/core/modules.ts +++ b/packages/nuxt/src/core/modules.ts @@ -10,7 +10,7 @@ export const addModuleTranspiles = (opts: AddModuleTranspilesOptions = {}) => { const modules = [ ...opts.additionalModules || [], ...nuxt.options.modules, - ...nuxt.options._modules + ...nuxt.options._modules, ] .map(m => typeof m === 'string' ? m : Array.isArray(m) ? m[0] : m.src) .filter(m => typeof m === 'string') diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index c0ca416fab..8c2b746d5c 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -1,38 +1,36 @@ +import { pathToFileURL } from 'node:url' import { existsSync, promises as fsp, readFileSync } from 'node:fs' import { cpus } from 'node:os' -import { join, normalize, relative, resolve } from 'pathe' +import { join, relative, resolve } from 'pathe' import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3' -import { randomUUID } from 'uncrypto' import { joinURL, withTrailingSlash } from 'ufo' -import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, scanHandlers, writeTypes } from 'nitropack' -import type { Nitro, NitroConfig } from 'nitropack' -import { findPath, logger, resolveIgnorePatterns, resolveNuxtModule } from '@nuxt/kit' +import { build, copyPublicAssets, createDevServer, createNitro, prepare, prerender, writeTypes } from 'nitro' +import type { Nitro, NitroConfig, NitroOptions } from 'nitro/types' +import { findPath, logger, resolveAlias, resolveIgnorePatterns, resolveNuxtModule } from '@nuxt/kit' import escapeRE from 'escape-string-regexp' import { defu } from 'defu' -import fsExtra from 'fs-extra' import { dynamicEventHandler } from 'h3' -import type { Nuxt, NuxtOptions, RuntimeConfig } from 'nuxt/schema' -// @ts-expect-error TODO: add legacy type support for subpath imports -import { template as defaultSpaLoadingTemplate } from '@nuxt/ui-templates/templates/spa-loading-icon.mjs' +import { isWindows } from 'std-env' +import { ImpoundPlugin } from 'impound' +import type { Nuxt, NuxtOptions } from 'nuxt/schema' import { version as nuxtVersion } from '../../package.json' import { distDir } from '../dirs' import { toArray } from '../utils' -import { ImportProtectionPlugin, nuxtImportProtections } from './plugins/import-protection' +import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon' +import { nuxtImportProtections } from './plugins/import-protection' const logLevelMapReverse = { silent: 0, info: 3, - verbose: 3 + verbose: 3, } satisfies Record<NuxtOptions['logLevel'], NitroConfig['logLevel']> export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { // Resolve config - const _nitroConfig = ((nuxt.options as any).nitro || {}) as NitroConfig - const excludePaths = nuxt.options._layers .flatMap(l => [ l.cwd.match(/(?<=\/)node_modules\/(.+)$/)?.[1], - l.cwd.match(/\.pnpm\/.+\/node_modules\/(.+)$/)?.[1] + l.cwd.match(/\.pnpm\/.+\/node_modules\/(.+)$/)?.[1], ]) .filter((dir): dir is string => Boolean(dir)) .map(dir => escapeRE(dir)) @@ -45,10 +43,10 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { const modules = await resolveNuxtModule(rootDirWithSlash, nuxt.options._installedModules .filter(m => m.entryPath) - .map(m => m.entryPath) + .map(m => m.entryPath!), ) - const nitroConfig: NitroConfig = defu(_nitroConfig, { + const nitroConfig: NitroConfig = defu(nuxt.options.nitro, { debug: nuxt.options.debug, rootDir: nuxt.options.rootDir, workspaceDir: nuxt.options.workspaceDir, @@ -57,11 +55,11 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { buildDir: nuxt.options.buildDir, experimental: { asyncContext: nuxt.options.experimental.asyncContext, - typescriptBundlerResolution: nuxt.options.future.typescriptBundlerResolution || nuxt.options.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' || _nitroConfig.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' + typescriptBundlerResolution: nuxt.options.future.typescriptBundlerResolution || nuxt.options.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' || nuxt.options.nitro.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler', }, framework: { name: 'nuxt', - version: nuxtVersion + version: nuxtVersion, }, imports: { autoImport: nuxt.options.imports.autoImport as boolean, @@ -69,80 +67,65 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { { as: '__buildAssetsURL', name: 'buildAssetsURL', - from: resolve(distDir, 'core/runtime/nitro/paths') + from: resolve(distDir, 'core/runtime/nitro/paths'), }, { as: '__publicAssetsURL', name: 'publicAssetsURL', - from: resolve(distDir, 'core/runtime/nitro/paths') + from: resolve(distDir, 'core/runtime/nitro/paths'), }, { // TODO: Remove after https://github.com/unjs/nitro/issues/1049 as: 'defineAppConfig', name: 'defineAppConfig', from: resolve(distDir, 'core/runtime/nitro/config'), - priority: -1 - } + priority: -1, + }, ], - exclude: [...excludePattern, /[\\/]\.git[\\/]/] + exclude: [...excludePattern, /[\\/]\.git[\\/]/], }, esbuild: { - options: { exclude: excludePattern } + options: { exclude: excludePattern }, }, analyze: !nuxt.options.test && nuxt.options.build.analyze && (nuxt.options.build.analyze === true || nuxt.options.build.analyze.enabled) ? { template: 'treemap', projectRoot: nuxt.options.rootDir, - filename: join(nuxt.options.analyzeDir, '{name}.html') + filename: join(nuxt.options.analyzeDir, '{name}.html'), } : false, scanDirs: nuxt.options._layers.map(layer => (layer.config.serverDir || layer.config.srcDir) && resolve(layer.cwd, layer.config.serverDir || resolve(layer.config.srcDir, 'server'))).filter(Boolean), renderer: resolve(distDir, 'core/runtime/nitro/renderer'), - errorHandler: resolve(distDir, 'core/runtime/nitro/error'), nodeModulesDirs: nuxt.options.modulesDir, handlers: nuxt.options.serverHandlers, devHandlers: [], baseURL: nuxt.options.app.baseURL, virtual: { '#internal/nuxt.config.mjs': () => nuxt.vfs['#build/nuxt.config'], - '#spa-template': async () => `export const template = ${JSON.stringify(await spaLoadingTemplate(nuxt))}` + '#internal/nuxt/app-config': () => nuxt.vfs['#build/app.config']?.replace(/\/\*\* client \*\*\/[\s\S]*\/\*\* client-end \*\*\//, ''), + '#spa-template': async () => `export const template = ${JSON.stringify(await spaLoadingTemplate(nuxt))}`, }, routeRules: { - '/__nuxt_error': { cache: false } + '/__nuxt_error': { cache: false }, }, - runtimeConfig: { - ...nuxt.options.runtimeConfig, - app: { - ...nuxt.options.runtimeConfig.app, - baseURL: nuxt.options.runtimeConfig.app.baseURL.startsWith('./') - ? nuxt.options.runtimeConfig.app.baseURL.slice(1) - : nuxt.options.runtimeConfig.app.baseURL - }, - nitro: { - envPrefix: 'NUXT_', - // TODO: address upstream issue with defu types...? - ...nuxt.options.runtimeConfig.nitro satisfies RuntimeConfig['nitro'] as any - } - }, - appConfig: nuxt.options.appConfig, - appConfigFiles: nuxt.options._layers.map( - layer => resolve(layer.config.srcDir, 'app.config') - ), typescript: { strict: true, generateTsConfig: true, tsconfigPath: 'tsconfig.server.json', tsConfig: { + compilerOptions: { + lib: ['esnext', 'webworker', 'dom.iterable'], + }, include: [ join(nuxt.options.buildDir, 'types/nitro-nuxt.d.ts'), - ...modules.map(m => join(relativeWithDot(nuxt.options.buildDir, m), 'runtime/server')) + ...modules.map(m => join(relativeWithDot(nuxt.options.buildDir, m), 'runtime/server')), ], exclude: [ ...nuxt.options.modulesDir.map(m => relativeWithDot(nuxt.options.buildDir, m)), // nitro generate output: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/core/nitro.ts#L186 - relativeWithDot(nuxt.options.buildDir, resolve(nuxt.options.rootDir, 'dist')) - ] - } + relativeWithDot(nuxt.options.buildDir, resolve(nuxt.options.rootDir, 'dist')), + ], + }, }, publicAssets: [ nuxt.options.dev @@ -150,17 +133,17 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { : { dir: join(nuxt.options.buildDir, 'dist/client', nuxt.options.app.buildAssetsDir), maxAge: 31536000 /* 1 year */, - baseURL: nuxt.options.app.buildAssetsDir + baseURL: nuxt.options.app.buildAssetsDir, }, ...nuxt.options._layers - .map(layer => join(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.public || 'public')) + .map(layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.public || 'public')) .filter(dir => existsSync(dir)) - .map(dir => ({ dir })) + .map(dir => ({ dir })), ], prerender: { failOnError: true, concurrency: cpus().length * 4 || 4, - routes: ([] as string[]).concat(nuxt.options.generate.routes) + routes: ([] as string[]).concat(nuxt.options.generate.routes), }, sourceMap: nuxt.options.sourcemap.server, externals: { @@ -170,13 +153,15 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { : [ ...nuxt.options.experimental.externalVue ? [] : ['vue', '@vue/'], '@nuxt/', - nuxt.options.buildDir + nuxt.options.buildDir, ]), ...nuxt.options.build.transpile.filter((i): i is string => typeof i === 'string'), 'nuxt/dist', 'nuxt3/dist', 'nuxt-nightly/dist', - distDir + distDir, + // Ensure app config files have auto-imports injected even if they are pure .js files + ...nuxt.options._layers.map(layer => resolve(layer.config.srcDir, 'app.config')), ], traceInclude: [ // force include files used in generated code from the runtime-compiler @@ -186,10 +171,10 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { const serverRendererPath = resolve(path, 'vue/server-renderer/index.js') if (existsSync(serverRendererPath)) { targets.push(serverRendererPath) } return targets - }, []) + }, []), ] - : [] - ] + : [], + ], }, alias: { // Vue 3 mocks @@ -200,15 +185,15 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { '@babel/parser': 'unenv/runtime/mock/proxy', '@vue/compiler-core': 'unenv/runtime/mock/proxy', '@vue/compiler-dom': 'unenv/runtime/mock/proxy', - '@vue/compiler-ssr': 'unenv/runtime/mock/proxy' + '@vue/compiler-ssr': 'unenv/runtime/mock/proxy', }, '@vue/devtools-api': 'vue-devtools-stub', // Paths - '#paths': resolve(distDir, 'core/runtime/nitro/paths'), + '#internal/nuxt/paths': resolve(distDir, 'core/runtime/nitro/paths'), // Nuxt aliases - ...nuxt.options.alias + ...nuxt.options.alias, }, replace: { 'process.env.NUXT_NO_SSR': nuxt.options.ssr === false, @@ -216,28 +201,41 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { 'process.env.NUXT_NO_SCRIPTS': !!nuxt.options.features.noScripts && !nuxt.options.dev, 'process.env.NUXT_INLINE_STYLES': !!nuxt.options.features.inlineStyles, 'process.env.NUXT_JSON_PAYLOADS': !!nuxt.options.experimental.renderJsonPayloads, - 'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands, 'process.env.NUXT_ASYNC_CONTEXT': !!nuxt.options.experimental.asyncContext, 'process.env.NUXT_SHARED_DATA': !!nuxt.options.experimental.sharedPrerenderData, 'process.dev': nuxt.options.dev, - __VUE_PROD_DEVTOOLS__: false + '__VUE_PROD_DEVTOOLS__': false, }, rollupConfig: { output: {}, - plugins: [] + plugins: [], }, logLevel: logLevelMapReverse[nuxt.options.logLevel], } satisfies NitroConfig) + if (nuxt.options.experimental.serverAppConfig && nitroConfig.imports) { + nitroConfig.imports.imports ||= [] + nitroConfig.imports.imports.push({ + name: 'useAppConfig', + from: resolve(distDir, 'core/runtime/nitro/app-config'), + }) + } + + // add error handler + if (!nitroConfig.errorHandler && (nuxt.options.dev || !nuxt.options.experimental.noVueServer)) { + nitroConfig.errorHandler = resolve(distDir, 'core/runtime/nitro/error') + } + // Resolve user-provided paths nitroConfig.srcDir = resolve(nuxt.options.rootDir, nuxt.options.srcDir, nitroConfig.srcDir!) - nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir)] + nitroConfig.ignore = [...(nitroConfig.ignore || []), ...resolveIgnorePatterns(nitroConfig.srcDir), `!${join(nuxt.options.buildDir, 'dist/client', nuxt.options.app.buildAssetsDir, '**/*')}`] + + // Resolve aliases in user-provided input - so `~/server/test` will work + nitroConfig.plugins = nitroConfig.plugins?.map(plugin => plugin ? resolveAlias(plugin, nuxt.options.alias) : plugin) // Add app manifest handler and prerender configuration if (nuxt.options.experimental.appManifest) { - // @ts-expect-error untyped nuxt property - const buildId = nuxt.options.appConfig.nuxt!.buildId ||= - (nuxt.options.dev ? 'dev' : nuxt.options.test ? 'test' : randomUUID()) + const buildId = nuxt.options.runtimeConfig.app.buildId ||= nuxt.options.buildId const buildTimestamp = Date.now() const manifestPrefix = joinURL(nuxt.options.app.buildAssetsDir, 'builds') @@ -252,16 +250,35 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { { dir: join(tempDir, 'meta'), maxAge: 31536000 /* 1 year */, - baseURL: joinURL(manifestPrefix, 'meta') + baseURL: joinURL(manifestPrefix, 'meta'), }, // latest build { dir: tempDir, maxAge: 1, - baseURL: manifestPrefix - } + baseURL: manifestPrefix, + }, ) + nuxt.options.alias['#app-manifest'] = join(tempDir, `meta/${buildId}.json`) + + nuxt.hook('nitro:config', (config) => { + const rules = config.routeRules + for (const rule in rules) { + if (!(rules[rule] as any).appMiddleware) { continue } + const value = (rules[rule] as any).appMiddleware + if (typeof value === 'string') { + (rules[rule] as NitroOptions['routeRules']).appMiddleware = { [value]: true } + } else if (Array.isArray(value)) { + const normalizedRules: Record<string, boolean> = {} + for (const middleware of value) { + normalizedRules[middleware] = true + } + (rules[rule] as NitroOptions['routeRules']).appMiddleware = normalizedRules + } + } + }) + nuxt.hook('nitro:init', (nitro) => { nitro.hooks.hook('rollup:before', async (nitro) => { const routeRules = {} as Record<string, any> @@ -272,8 +289,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { const filteredRules = {} as Record<string, any> for (const routeKey in _routeRules[key]) { const value = (_routeRules as any)[key][routeKey] - if (['prerender', 'redirect'].includes(routeKey) && value) { - filteredRules[routeKey] = routeKey === 'redirect' ? typeof value === 'string' ? value : value.to : value + if (['prerender', 'redirect', 'appMiddleware'].includes(routeKey) && value) { + if (routeKey === 'redirect') { + filteredRules[routeKey] = typeof value === 'string' ? value : value.to + } else { + filteredRules[routeKey] = value + } hasRules = true } } @@ -285,15 +306,17 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { // Add pages prerendered but not covered by route rules const prerenderedRoutes = new Set<string>() const routeRulesMatcher = toRouteMatcher( - createRadixRouter({ routes: routeRules }) + createRadixRouter({ routes: routeRules }), ) - const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js' - for (const route of nitro._prerenderedRoutes || []) { - if (!route.error && route.route.endsWith(payloadSuffix)) { - const url = route.route.slice(0, -payloadSuffix.length) || '/' - const rules = defu({}, ...routeRulesMatcher.matchAll(url).reverse()) as Record<string, any> - if (!rules.prerender) { - prerenderedRoutes.add(url) + if (nitro._prerenderedRoutes?.length) { + const payloadSuffix = nuxt.options.experimental.renderJsonPayloads ? '/_payload.json' : '/_payload.js' + for (const route of nitro._prerenderedRoutes) { + if (!route.error && route.route.endsWith(payloadSuffix)) { + const url = route.route.slice(0, -payloadSuffix.length) || '/' + const rules = defu({}, ...routeRulesMatcher.matchAll(url).reverse()) as Record<string, any> + if (!rules.prerender) { + prerenderedRoutes.add(url) + } } } } @@ -302,13 +325,13 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { id: buildId, timestamp: buildTimestamp, matcher: exportMatcher(routeRulesMatcher), - prerendered: nuxt.options.dev ? [] : [...prerenderedRoutes] + prerendered: nuxt.options.dev ? [] : [...prerenderedRoutes], } await fsp.mkdir(join(tempDir, 'meta'), { recursive: true }) await fsp.writeFile(join(tempDir, 'latest.json'), JSON.stringify({ id: buildId, - timestamp: buildTimestamp + timestamp: buildTimestamp, })) await fsp.writeFile(join(tempDir, `meta/${buildId}.json`), JSON.stringify(manifest)) }) @@ -332,31 +355,22 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { } } - // Add backward-compatible middleware to respect `x-nuxt-no-ssr` header - if (nuxt.options.experimental.respectNoSSRHeader) { - nitroConfig.handlers = nitroConfig.handlers || [] - nitroConfig.handlers.push({ - handler: resolve(distDir, 'core/runtime/nitro/no-ssr'), - middleware: true - }) - } - // Register nuxt protection patterns nitroConfig.rollupConfig!.plugins = await nitroConfig.rollupConfig!.plugins || [] nitroConfig.rollupConfig!.plugins = toArray(nitroConfig.rollupConfig!.plugins) nitroConfig.rollupConfig!.plugins!.push( - ImportProtectionPlugin.rollup({ - rootDir: nuxt.options.rootDir, + ImpoundPlugin.rollup({ + cwd: nuxt.options.rootDir, patterns: nuxtImportProtections(nuxt, { isNitro: true }), - exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/] - }) + exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/], + }), ) // Extend nitro config with hook await nuxt.callHook('nitro:config', nitroConfig) // TODO: extract to shared utility? - const excludedAlias = [/^@vue\/.*$/, '#imports', '#vue-router', 'vue-demi', /^#app/] + const excludedAlias = [/^@vue\/.*$/, '#imports', 'vue-demi', /^#app/] const basePath = nitroConfig.typescript!.tsConfig!.compilerOptions?.baseUrl ? resolve(nuxt.options.buildDir, nitroConfig.typescript!.tsConfig!.compilerOptions?.baseUrl) : nuxt.options.buildDir const aliases = nitroConfig.alias! const tsConfig = nitroConfig.typescript!.tsConfig! @@ -375,26 +389,34 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { tsConfig.compilerOptions.paths[alias] = [absolutePath] tsConfig.compilerOptions.paths[`${alias}/*`] = [`${absolutePath}/*`] } else { - tsConfig.compilerOptions.paths[alias] = [absolutePath.replace(/(?<=\w)\.\w+$/g, '')] /* remove extension */ + tsConfig.compilerOptions.paths[alias] = [absolutePath.replace(/\b\.\w+$/g, '')] /* remove extension */ } } // Init nitro - const nitro = await createNitro(nitroConfig) + const nitro = await createNitro(nitroConfig, { + compatibilityDate: nuxt.options.compatibilityDate, + }) // Trigger Nitro reload when SPA loading template changes const spaLoadingTemplateFilePath = await spaLoadingTemplatePath(nuxt) - nuxt.hook('builder:watch', async (_event, path) => { - if (normalize(path) === spaLoadingTemplateFilePath) { + nuxt.hook('builder:watch', async (_event, relativePath) => { + const path = resolve(nuxt.options.srcDir, relativePath) + if (path === spaLoadingTemplateFilePath) { await nitro.hooks.callHook('rollup:reload') } }) - // Set prerender-only options - nitro.options._config.storage ||= {} - nitro.options._config.storage['internal:nuxt:prerender'] = { driver: 'memory' } - nitro.options._config.storage['internal:nuxt:prerender:island'] = { driver: 'lruCache', max: 1000 } - nitro.options._config.storage['internal:nuxt:prerender:payload'] = { driver: 'lruCache', max: 1000 } + const cacheDir = resolve(nuxt.options.buildDir, 'cache/nitro/prerender') + const cacheDriverPath = join(distDir, 'core/runtime/nitro/cache-driver.js') + await fsp.rm(cacheDir, { recursive: true, force: true }).catch(() => {}) + nitro.options._config.storage = defu(nitro.options._config.storage, { + 'internal:nuxt:prerender': { + // TODO: resolve upstream where file URLs are not being resolved/inlined correctly + driver: isWindows ? pathToFileURL(cacheDriverPath).href : cacheDriverPath, + base: cacheDir, + }, + }) // Expose nitro to modules and kit nuxt._nitro = nitro @@ -416,12 +438,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { if (Array.isArray(config.resolve!.alias)) { config.resolve!.alias.push({ find: 'vue', - replacement: 'vue/dist/vue.esm-bundler' + replacement: 'vue/dist/vue.esm-bundler', }) } else { config.resolve!.alias = { ...config.resolve!.alias, - vue: 'vue/dist/vue.esm-bundler' + vue: 'vue/dist/vue.esm-bundler', } } } @@ -432,7 +454,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { if (Array.isArray(clientConfig!.resolve!.alias)) { clientConfig!.resolve!.alias.push({ name: 'vue', - alias: 'vue/dist/vue.esm-bundler' + alias: 'vue/dist/vue.esm-bundler', }) } else { clientConfig!.resolve!.alias!.vue = 'vue/dist/vue.esm-bundler' @@ -447,26 +469,27 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { nitro.options.handlers.unshift({ route: '/__nuxt_error', lazy: true, - handler: resolve(distDir, 'core/runtime/nitro/renderer') + handler: resolve(distDir, 'core/runtime/nitro/renderer'), }) if (!nuxt.options.dev && nuxt.options.experimental.noVueServer) { nitro.hooks.hook('rollup:before', (nitro) => { - if (nitro.options.preset === 'nitro-prerender') { return } + if (nitro.options.preset === 'nitro-prerender') { + nitro.options.errorHandler = resolve(distDir, 'core/runtime/nitro/error') + return + } const nuxtErrorHandler = nitro.options.handlers.findIndex(h => h.route === '/__nuxt_error') if (nuxtErrorHandler >= 0) { nitro.options.handlers.splice(nuxtErrorHandler, 1) } nitro.options.renderer = undefined - nitro.options.errorHandler = '#internal/nitro/error' }) } // Add typed route responses nuxt.hook('prepare:types', async (opts) => { if (!nuxt.options.dev) { - await scanHandlers(nitro) await writeTypes(nitro) } // Exclude nitro output dir from typescript @@ -477,9 +500,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { if (nitro.options.static) { nitro.hooks.hook('prerender:routes', (routes) => { - for (const route of [nuxt.options.ssr ? '/' : '/index.html', '/200.html', '/404.html']) { + for (const route of ['/200.html', '/404.html']) { routes.add(route) } + if (!nuxt.options.ssr) { + routes.add('/index.html') + } }) } @@ -491,31 +517,40 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { }) } + async function symlinkDist () { + if (nitro.options.static) { + const distDir = resolve(nuxt.options.rootDir, 'dist') + if (!existsSync(distDir)) { + await fsp.symlink(nitro.options.output.publicDir, distDir, 'junction').catch(() => {}) + } + } + } + // nuxt build/dev nuxt.hook('build:done', async () => { await nuxt.callHook('nitro:build:before', nitro) + await prepare(nitro) if (nuxt.options.dev) { - await build(nitro) - } else { - await prepare(nitro) - await prerender(nitro) - - logger.restoreAll() - await build(nitro) - logger.wrapAll() - - if (nitro.options.static) { - const distDir = resolve(nuxt.options.rootDir, 'dist') - if (!existsSync(distDir)) { - await fsp.symlink(nitro.options.output.publicDir, distDir, 'junction').catch(() => {}) - } - } + return build(nitro) } + + await prerender(nitro) + + logger.restoreAll() + await build(nitro) + logger.wrapAll() + + await symlinkDist() }) // nuxt dev if (nuxt.options.dev) { - nuxt.hook('webpack:compile', ({ compiler }) => { compiler.outputFileSystem = { ...fsExtra, join } as any }) + nuxt.hook('webpack:compile', ({ name, compiler }) => { + if (name === 'server') { + const memfs = compiler.outputFileSystem as typeof import('node:fs') + nitro.options.virtual['#build/dist/server/server.mjs'] = () => memfs.readFileSync(join(nuxt.options.buildDir, 'dist/server/server.mjs'), 'utf-8') + } + }) nuxt.hook('webpack:compiled', () => { nuxt.server.reload() }) nuxt.hook('vite:compiled', () => { nuxt.server.reload() }) @@ -536,9 +571,9 @@ async function spaLoadingTemplatePath (nuxt: Nuxt) { return resolve(nuxt.options.srcDir, nuxt.options.spaLoadingTemplate) } - const possiblePaths = nuxt.options._layers.map(layer => join(layer.config.srcDir, 'app/spa-loading-template.html')) + const possiblePaths = nuxt.options._layers.map(layer => resolve(layer.config.srcDir, layer.config.dir?.app || 'app', 'spa-loading-template.html')) - return await findPath(possiblePaths) ?? resolve(nuxt.options.srcDir, 'app/spa-loading-template.html') + return await findPath(possiblePaths) ?? resolve(nuxt.options.srcDir, nuxt.options.dir?.app || 'app', 'spa-loading-template.html') } async function spaLoadingTemplate (nuxt: Nuxt) { @@ -555,7 +590,7 @@ async function spaLoadingTemplate (nuxt: Nuxt) { } if (nuxt.options.spaLoadingTemplate === true) { - return defaultSpaLoadingTemplate({}) + return defaultSpaLoadingTemplate() } if (nuxt.options.spaLoadingTemplate) { diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index adc2aac1fa..6af0933b8a 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -1,25 +1,39 @@ +import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' import { join, normalize, relative, resolve } from 'pathe' import { createDebugger, createHooks } from 'hookable' +import ignore from 'ignore' import type { LoadNuxtOptions } from '@nuxt/kit' -import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' -import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema' - +import { addBuildPlugin, addComponent, addPlugin, addPluginTemplate, addRouteMiddleware, addServerPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolveIgnorePatterns, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' +import { resolvePath as _resolvePath } from 'mlly' +import type { Nuxt, NuxtHooks, NuxtModule, NuxtOptions } from 'nuxt/schema' +import type { PackageJson } from 'pkg-types' +import { readPackageJSON } from 'pkg-types' +import { hash } from 'ohash' +import consola from 'consola' +import { colorize } from 'consola/utils' +import { updateConfig } from 'c12/update' +import { formatDate, resolveCompatibilityDatesFromEnv } from 'compatx' +import type { DateString } from 'compatx' import escapeRE from 'escape-string-regexp' -import fse from 'fs-extra' -import { withoutLeadingSlash } from 'ufo' -/* eslint-disable import/no-restricted-paths */ +import { withTrailingSlash, withoutLeadingSlash } from 'ufo' +import { ImpoundPlugin } from 'impound' +import type { ImpoundOptions } from 'impound' import defu from 'defu' +import { gt, satisfies } from 'semver' +import { hasTTY, isCI } from 'std-env' + import pagesModule from '../pages/module' import metaModule from '../head/module' import componentsModule from '../components/module' import importsModule from '../imports/module' -/* eslint-enable */ + import { distDir, pkgDir } from '../dirs' import { version } from '../../package.json' -import { ImportProtectionPlugin, nuxtImportProtections } from './plugins/import-protection' -import type { UnctxTransformPluginOptions } from './plugins/unctx' +import { scriptsStubsPreset } from '../imports/presets' +import { resolveTypePath } from './utils/types' +import { nuxtImportProtections } from './plugins/import-protection' import { UnctxTransformPlugin } from './plugins/unctx' -import type { TreeShakeComposablesPluginOptions } from './plugins/tree-shake' import { TreeShakeComposablesPlugin } from './plugins/tree-shake' import { DevOnlyPlugin } from './plugins/dev-only' import { LayerAliasingPlugin } from './plugins/layer-aliasing' @@ -29,6 +43,7 @@ import schemaModule from './schema' import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata' import { AsyncContextInjectionPlugin } from './plugins/async-context' import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports' +import { prehydrateTransformPlugin } from './plugins/prehydrate' export function createNuxt (options: NuxtOptions): Nuxt { const hooks = createHooks<NuxtHooks>() @@ -41,14 +56,35 @@ export function createNuxt (options: NuxtOptions): Nuxt { addHooks: hooks.addHooks, hook: hooks.hook, ready: () => initNuxt(nuxt), - close: () => Promise.resolve(hooks.callHook('close', nuxt)), + close: () => hooks.callHook('close', nuxt), vfs: {}, - apps: {} + apps: {}, } + hooks.hookOnce('close', () => { hooks.removeAllHooks() }) + return nuxt } +// TODO: update to nitro import +const fallbackCompatibilityDate = '2024-04-03' as DateString + +const nightlies = { + 'nitropack': 'nitropack-nightly', + 'nitro': 'nitro-nightly', + 'h3': 'h3-nightly', + 'nuxt': 'nuxt-nightly', + '@nuxt/schema': '@nuxt/schema-nightly', + '@nuxt/kit': '@nuxt/kit-nightly', +} + +const keyDependencies = [ + '@nuxt/kit', + '@nuxt/schema', +] + +let warnedAboutCompatDate = false + async function initNuxt (nuxt: Nuxt) { // Register user hooks for (const config of nuxt.options._layers.map(layer => layer.config).reverse()) { @@ -57,10 +93,120 @@ async function initNuxt (nuxt: Nuxt) { } } + // Prompt to set compatibility date + nuxt.options.compatibilityDate = resolveCompatibilityDatesFromEnv(nuxt.options.compatibilityDate) + + if (!nuxt.options.compatibilityDate.default) { + const todaysDate = formatDate(new Date()) + nuxt.options.compatibilityDate.default = fallbackCompatibilityDate + + const shouldShowPrompt = nuxt.options.dev && hasTTY && !isCI + if (!shouldShowPrompt) { + logger.info(`Using \`${fallbackCompatibilityDate}\` as fallback compatibility date.`) + } + + async function promptAndUpdate () { + const result = await consola.prompt(`Do you want to update your ${colorize('cyan', 'nuxt.config')} to set ${colorize('cyan', `compatibilityDate: '${todaysDate}'`)}?`, { + type: 'confirm', + default: true, + }) + if (result !== true) { + logger.info(`Using \`${fallbackCompatibilityDate}\` as fallback compatibility date.`) + return + } + + try { + const res = await updateConfig({ + configFile: 'nuxt.config', + cwd: nuxt.options.rootDir, + async onCreate ({ configFile }) { + const shallCreate = await consola.prompt(`Do you want to create ${colorize('cyan', relative(nuxt.options.rootDir, configFile))}?`, { + type: 'confirm', + default: true, + }) + if (shallCreate !== true) { + return false + } + return _getDefaultNuxtConfig() + }, + onUpdate (config) { + config.compatibilityDate = todaysDate + }, + }) + + if (res?.configFile) { + nuxt.options.compatibilityDate = resolveCompatibilityDatesFromEnv(todaysDate) + consola.success(`Compatibility date set to \`${todaysDate}\` in \`${relative(nuxt.options.rootDir, res.configFile)}\``) + return + } + } catch (err) { + const message = err instanceof Error ? err.message : err + + consola.error(`Failed to update config: ${message}`) + } + + logger.info(`Using \`${fallbackCompatibilityDate}\` as fallback compatibility date.`) + } + + nuxt.hooks.hookOnce('nitro:init', (nitro) => { + if (warnedAboutCompatDate) { return } + + nitro.hooks.hookOnce('compiled', () => { + warnedAboutCompatDate = true + // Print warning + logger.info(`Nuxt now supports pinning the behavior of provider and deployment presets with a compatibility date. We recommend you specify a \`compatibilityDate\` in your \`nuxt.config\` file, or set an environment variable, such as \`COMPATIBILITY_DATE=${todaysDate}\`.`) + if (shouldShowPrompt) { promptAndUpdate() } + }) + }) + } + + // Restart Nuxt when layer directories are added or removed + const layersDir = withTrailingSlash(resolve(nuxt.options.rootDir, 'layers')) + nuxt.hook('builder:watch', (event, relativePath) => { + const path = resolve(nuxt.options.srcDir, relativePath) + if (event === 'addDir' || event === 'unlinkDir') { + if (path.startsWith(layersDir)) { + return nuxt.callHook('restart', { hard: true }) + } + } + }) + // Set nuxt instance for useNuxt nuxtCtx.set(nuxt) nuxt.hook('close', () => nuxtCtx.unset()) + const coreTypePackages = nuxt.options.typescript.hoist || [] + const packageJSON = await readPackageJSON(nuxt.options.rootDir).catch(() => ({}) as PackageJson) + nuxt._dependencies = new Set([...Object.keys(packageJSON.dependencies || {}), ...Object.keys(packageJSON.devDependencies || {})]) + const paths = Object.fromEntries(await Promise.all(coreTypePackages.map(async (pkg) => { + const [_pkg = pkg, _subpath] = /^[^@]+\//.test(pkg) ? pkg.split('/') : [pkg] + const subpath = _subpath ? '/' + _subpath : '' + + // ignore packages that exist in `package.json` as these can be resolved by TypeScript + if (nuxt._dependencies?.has(_pkg) && !(_pkg in nightlies)) { return [] } + + // deduplicate types for nightly releases + if (_pkg in nightlies) { + const nightly = nightlies[_pkg as keyof typeof nightlies] + const path = await resolveTypePath(nightly + subpath, subpath, nuxt.options.modulesDir) + if (path) { + return [[pkg, [path]], [nightly + subpath, [path]]] + } + } + + const path = await resolveTypePath(_pkg + subpath, subpath, nuxt.options.modulesDir) + if (path) { + return [[pkg, [path]]] + } + + return [] + })).then(r => r.flat())) + + // Set nitro resolutions for types that might be obscured with shamefully-hoist=false + nuxt.options.nitro.typescript = defu(nuxt.options.nitro.typescript, { + tsConfig: { compilerOptions: { paths: { ...paths } } }, + }) + // Add nuxt types nuxt.hook('prepare:types', (opts) => { opts.references.push({ types: 'nuxt' }) @@ -69,88 +215,93 @@ async function initNuxt (nuxt: Nuxt) { if (nuxt.options.typescript.shim) { opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/vue-shim.d.ts') }) } + // Add shims for `#build/*` imports that do not already have matching types + opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/build.d.ts') }) // Add module augmentations directly to NuxtConfig opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/schema.d.ts') }) opts.references.push({ path: resolve(nuxt.options.buildDir, 'types/app.config.d.ts') }) + // Set Nuxt resolutions for types that might be obscured with shamefully-hoist=false + opts.tsConfig.compilerOptions = defu(opts.tsConfig.compilerOptions, { paths: { ...paths } }) + for (const layer of nuxt.options._layers) { const declaration = join(layer.cwd, 'index.d.ts') - if (fse.existsSync(declaration)) { + if (existsSync(declaration)) { opts.references.push({ path: declaration }) } } }) + // Prompt to install `@nuxt/scripts` if user has configured it + // @ts-expect-error scripts types are not present as the module is not installed + if (nuxt.options.scripts) { + if (!nuxt.options._modules.some(m => m === '@nuxt/scripts' || m === '@nuxt/scripts-nightly')) { + await import('../core/features').then(({ installNuxtModule }) => installNuxtModule('@nuxt/scripts')) + } + } + // Add plugin normalization plugin addBuildPlugin(RemovePluginMetadataPlugin(nuxt)) // Add import protection - const config = { - rootDir: nuxt.options.rootDir, + const config: ImpoundOptions = { + cwd: nuxt.options.rootDir, // Exclude top-level resolutions by plugins - exclude: [join(nuxt.options.rootDir, 'index.html')], - patterns: nuxtImportProtections(nuxt) + exclude: [join(nuxt.options.srcDir, 'index.html')], + patterns: nuxtImportProtections(nuxt), } - addVitePlugin(() => ImportProtectionPlugin.vite(config)) - addWebpackPlugin(() => ImportProtectionPlugin.webpack(config)) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: false }), { name: 'nuxt:import-protection' }), { client: false }) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: true }), { name: 'nuxt:import-protection' }), { server: false }) + addWebpackPlugin(() => ImpoundPlugin.webpack(config)) // add resolver for modules used in virtual files - addVitePlugin(() => resolveDeepImportsPlugin(nuxt)) + addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { client: false }) + addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { server: false }) + + // Add transform for `onPrehydrate` lifecycle hook + addBuildPlugin(prehydrateTransformPlugin(nuxt)) if (nuxt.options.experimental.localLayerAliases) { // Add layer aliasing support for ~, ~~, @ and @@ aliases - addVitePlugin(() => LayerAliasingPlugin.vite({ - sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, - dev: nuxt.options.dev, - root: nuxt.options.srcDir, - // skip top-level layer (user's project) as the aliases will already be correctly resolved - layers: nuxt.options._layers.slice(1) - })) - addWebpackPlugin(() => LayerAliasingPlugin.webpack({ + addBuildPlugin(LayerAliasingPlugin({ sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, dev: nuxt.options.dev, root: nuxt.options.srcDir, // skip top-level layer (user's project) as the aliases will already be correctly resolved layers: nuxt.options._layers.slice(1), - transform: true })) } nuxt.hook('modules:done', async () => { // Add unctx transform - const options = { + addBuildPlugin(UnctxTransformPlugin({ sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, transformerOptions: { ...nuxt.options.optimization.asyncTransforms, - helperModule: await tryResolveModule('unctx', nuxt.options.modulesDir) ?? 'unctx' - } - } satisfies UnctxTransformPluginOptions - addVitePlugin(() => UnctxTransformPlugin.vite(options)) - addWebpackPlugin(() => UnctxTransformPlugin.webpack(options)) + helperModule: await tryResolveModule('unctx', nuxt.options.modulesDir) ?? 'unctx', + }, + })) // Add composable tree-shaking optimisations - const serverTreeShakeOptions: TreeShakeComposablesPluginOptions = { - sourcemap: !!nuxt.options.sourcemap.server, - composables: nuxt.options.optimization.treeShake.composables.server + if (Object.keys(nuxt.options.optimization.treeShake.composables.server).length) { + addBuildPlugin(TreeShakeComposablesPlugin({ + sourcemap: !!nuxt.options.sourcemap.server, + composables: nuxt.options.optimization.treeShake.composables.server, + }), { client: false }) } - if (Object.keys(serverTreeShakeOptions.composables).length) { - addVitePlugin(() => TreeShakeComposablesPlugin.vite(serverTreeShakeOptions), { client: false }) - addWebpackPlugin(() => TreeShakeComposablesPlugin.webpack(serverTreeShakeOptions), { client: false }) - } - const clientTreeShakeOptions: TreeShakeComposablesPluginOptions = { - sourcemap: !!nuxt.options.sourcemap.client, - composables: nuxt.options.optimization.treeShake.composables.client - } - if (Object.keys(clientTreeShakeOptions.composables).length) { - addVitePlugin(() => TreeShakeComposablesPlugin.vite(clientTreeShakeOptions), { server: false }) - addWebpackPlugin(() => TreeShakeComposablesPlugin.webpack(clientTreeShakeOptions), { server: false }) + if (Object.keys(nuxt.options.optimization.treeShake.composables.client).length) { + addBuildPlugin(TreeShakeComposablesPlugin({ + sourcemap: !!nuxt.options.sourcemap.client, + composables: nuxt.options.optimization.treeShake.composables.client, + }), { server: false }) } }) if (!nuxt.options.dev) { // DevOnly component tree-shaking - build time only - addVitePlugin(() => DevOnlyPlugin.vite({ sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client })) - addWebpackPlugin(() => DevOnlyPlugin.webpack({ sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client })) + addBuildPlugin(DevOnlyPlugin({ + sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, + })) } if (nuxt.options.dev) { @@ -158,6 +309,19 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/check-if-layout-used')) } + if (nuxt.options.dev && nuxt.options.features.devLogs) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/dev-server-logs')) + addServerPlugin(resolve(distDir, 'core/runtime/nitro/dev-server-logs')) + nuxt.options.nitro = defu(nuxt.options.nitro, { + externals: { + inline: [/#internal\/dev-server-logs-options/], + }, + virtual: { + '#internal/dev-server-logs-options': () => `export const rootDir = ${JSON.stringify(nuxt.options.rootDir)};`, + }, + }) + } + // Transform initial composable call within `<script setup>` to preserve context if (nuxt.options.experimental.asyncContext) { addBuildPlugin(AsyncContextInjectionPlugin(nuxt)) @@ -166,10 +330,10 @@ async function initNuxt (nuxt: Nuxt) { // TODO: [Experimental] Avoid emitting assets when flag is enabled if (nuxt.options.features.noScripts && !nuxt.options.dev) { nuxt.hook('build:manifest', async (manifest) => { - for (const file in manifest) { - if (manifest[file].resourceType === 'script') { - await fse.rm(resolve(nuxt.options.buildDir, 'dist/client', withoutLeadingSlash(nuxt.options.app.buildAssetsDir), manifest[file].file), { force: true }) - manifest[file].file = '' + for (const chunk of Object.values(manifest)) { + if (chunk.resourceType === 'script') { + await rm(resolve(nuxt.options.buildDir, 'dist/client', withoutLeadingSlash(nuxt.options.app.buildAssetsDir), chunk.file), { force: true }) + chunk.file = '' } } }) @@ -180,12 +344,15 @@ async function initNuxt (nuxt: Nuxt) { // Transpile layers within node_modules nuxt.options.build.transpile.push( - ...nuxt.options._layers.filter(i => i.cwd.includes('node_modules')).map(i => i.cwd as string) + ...nuxt.options._layers.filter(i => i.cwd.includes('node_modules')).map(i => i.cwd as string), ) + // Ensure we can resolve dependencies within layers + nuxt.options.modulesDir.push(...nuxt.options._layers.map(l => resolve(l.cwd, 'node_modules'))) + // Init user modules await nuxt.callHook('modules:before') - const modulesToInstall = [] + const modulesToInstall = new Map<string | NuxtModule, Record<string, any>>() const watchedPaths = new Set<string>() const specifiedModules = new Set<string>() @@ -193,7 +360,7 @@ async function initNuxt (nuxt: Nuxt) { for (const _mod of nuxt.options.modules) { const mod = Array.isArray(_mod) ? _mod[0] : _mod if (typeof mod !== 'string') { continue } - const modPath = await resolvePath(resolveAlias(mod)) + const modPath = await resolvePath(resolveAlias(mod), { fallbackToOriginal: true }) specifiedModules.add(modPath) } @@ -202,72 +369,89 @@ async function initNuxt (nuxt: Nuxt) { const modulesDir = (config.rootDir === nuxt.options.rootDir ? nuxt.options : config).dir?.modules || 'modules' const layerModules = await resolveFiles(config.srcDir, [ `${modulesDir}/*{${nuxt.options.extensions.join(',')}}`, - `${modulesDir}/*/index{${nuxt.options.extensions.join(',')}}` + `${modulesDir}/*/index{${nuxt.options.extensions.join(',')}}`, ]) for (const mod of layerModules) { watchedPaths.add(mod) if (specifiedModules.has(mod)) { continue } specifiedModules.add(mod) - modulesToInstall.push(mod) + modulesToInstall.set(mod, {}) } } // Register user and then ad-hoc modules - modulesToInstall.push(...nuxt.options.modules, ...nuxt.options._modules) + for (const key of ['modules', '_modules'] as const) { + for (const item of nuxt.options[key as 'modules']) { + if (item) { + const [key, options = {}] = Array.isArray(item) ? item : [item] + if (!modulesToInstall.has(key)) { + modulesToInstall.set(key, options) + } + } + } + } // Add <NuxtWelcome> addComponent({ name: 'NuxtWelcome', priority: 10, // built-in that we do not expect the user to override - filePath: (await tryResolveModule('@nuxt/ui-templates/templates/welcome.vue', nuxt.options.modulesDir))! + filePath: resolve(nuxt.options.appDir, 'components/welcome'), }) addComponent({ name: 'NuxtLayout', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-layout') + filePath: resolve(nuxt.options.appDir, 'components/nuxt-layout'), }) // Add <NuxtErrorBoundary> addComponent({ name: 'NuxtErrorBoundary', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-error-boundary') + filePath: resolve(nuxt.options.appDir, 'components/nuxt-error-boundary'), }) // Add <ClientOnly> addComponent({ name: 'ClientOnly', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/client-only') + filePath: resolve(nuxt.options.appDir, 'components/client-only'), }) // Add <DevOnly> addComponent({ name: 'DevOnly', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/dev-only') + filePath: resolve(nuxt.options.appDir, 'components/dev-only'), }) // Add <ServerPlaceholder> addComponent({ name: 'ServerPlaceholder', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/server-placeholder') + filePath: resolve(nuxt.options.appDir, 'components/server-placeholder'), }) // Add <NuxtLink> addComponent({ name: 'NuxtLink', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-link') + filePath: resolve(nuxt.options.appDir, 'components/nuxt-link'), }) // Add <NuxtLoadingIndicator> addComponent({ name: 'NuxtLoadingIndicator', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator') + filePath: resolve(nuxt.options.appDir, 'components/nuxt-loading-indicator'), + }) + + // Add <NuxtRouteAnnouncer> + addComponent({ + name: 'NuxtRouteAnnouncer', + priority: 10, // built-in that we do not expect the user to override + filePath: resolve(nuxt.options.appDir, 'components/nuxt-route-announcer'), + mode: 'client', }) // Add <NuxtClientFallback> @@ -277,7 +461,7 @@ async function initNuxt (nuxt: Nuxt) { _raw: true, priority: 10, // built-in that we do not expect the user to override filePath: resolve(nuxt.options.appDir, 'components/client-fallback.client'), - mode: 'client' + mode: 'client', }) addComponent({ @@ -285,25 +469,10 @@ async function initNuxt (nuxt: Nuxt) { _raw: true, priority: 10, // built-in that we do not expect the user to override filePath: resolve(nuxt.options.appDir, 'components/client-fallback.server'), - mode: 'server' + mode: 'server', }) } - // Add <NuxtIsland> - if (nuxt.options.experimental.componentIslands) { - addComponent({ - name: 'NuxtIsland', - priority: 10, // built-in that we do not expect the user to override - filePath: resolve(nuxt.options.appDir, 'components/nuxt-island') - }) - - if (!nuxt.options.ssr) { - nuxt.options.ssr = true - nuxt.options.nitro.routeRules ||= {} - nuxt.options.nitro.routeRules['/**'] = defu(nuxt.options.nitro.routeRules['/**'], { ssr: false }) - } - } - // Add stubs for <NuxtImg> and <NuxtPicture> for (const name of ['NuxtImg', 'NuxtPicture']) { addComponent({ @@ -312,10 +481,68 @@ async function initNuxt (nuxt: Nuxt) { priority: -1, filePath: resolve(nuxt.options.appDir, 'components/nuxt-stubs'), // @ts-expect-error TODO: refactor to nuxi - _internal_install: '@nuxt/image' + _internal_install: '@nuxt/image', }) } + // Track components used to render for webpack + if (nuxt.options.builder === '@nuxt/webpack-builder') { + addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) + } + + const envMap = { + // defaults from `builder` based on package name + '@nuxt/vite-builder': 'vite/client', + '@nuxt/webpack-builder': 'webpack/module', + // simpler overrides from `typescript.builder` for better DX + 'vite': 'vite/client', + 'webpack': 'webpack/module', + // default 'merged' builder environment for module authors + 'shared': '@nuxt/schema/builder-env', + } + + nuxt.hook('prepare:types', ({ references }) => { + // Disable entirely if `typescript.builder` is false + if (nuxt.options.typescript.builder === false) { return } + + const overrideEnv = nuxt.options.typescript.builder && envMap[nuxt.options.typescript.builder] + // If there's no override, infer based on builder. If a custom builder is provided, we disable shared types + const defaultEnv = typeof nuxt.options.builder === 'string' ? envMap[nuxt.options.builder] : false + const types = overrideEnv || defaultEnv + + if (types) { references.push({ types }) } + }) + + // Add nuxt app debugger + if (nuxt.options.debug) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/debug')) + } + + for (const [key, options] of modulesToInstall) { + await installModule(key, options) + } + + // (Re)initialise ignore handler with resolved ignores from modules + nuxt._ignore = ignore(nuxt.options.ignoreOptions) + nuxt._ignore.add(resolveIgnorePatterns()) + + await nuxt.callHook('modules:done') + + // Add <NuxtIsland> + if (nuxt.options.experimental.componentIslands) { + addComponent({ + name: 'NuxtIsland', + priority: 10, // built-in that we do not expect the user to override + filePath: resolve(nuxt.options.appDir, 'components/nuxt-island'), + }) + + if (!nuxt.options.ssr && nuxt.options.experimental.componentIslands !== 'auto') { + nuxt.options.ssr = true + nuxt.options.nitro.routeRules ||= {} + nuxt.options.nitro.routeRules['/**'] = defu(nuxt.options.nitro.routeRules['/**'], { ssr: false }) + } + } + // Add prerender payload support if (!nuxt.options.dev && nuxt.options.experimental.payloadExtraction) { addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client')) @@ -342,63 +569,41 @@ async function initNuxt (nuxt: Nuxt) { // Add experimental support for custom types in JSON payload if (nuxt.options.experimental.renderJsonPayloads) { - nuxt.hooks.hook('modules:done', () => { - addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.client')) - addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.server')) - }) + addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.client')) + addPlugin(resolve(nuxt.options.appDir, 'plugins/revive-payload.server')) } - // Track components used to render for webpack - if (nuxt.options.builder === '@nuxt/webpack-builder') { - addPlugin(resolve(nuxt.options.appDir, 'plugins/preload.server')) - } - - const envMap = { - // defaults from `builder` based on package name - '@nuxt/vite-builder': 'vite/client', - '@nuxt/webpack-builder': 'webpack/module', - // simpler overrides from `typescript.builder` for better DX - vite: 'vite/client', - webpack: 'webpack/module', - // default 'merged' builder environment for module authors - shared: '@nuxt/schema/builder-env' - } - - nuxt.hook('prepare:types', ({ references }) => { - // Disable entirely if `typescript.builder` is false - if (nuxt.options.typescript.builder === false) { return } - - const overrideEnv = nuxt.options.typescript.builder && envMap[nuxt.options.typescript.builder] - // If there's no override, infer based on builder. If a custom builder is provided, we disable shared types - const defaultEnv = typeof nuxt.options.builder === 'string' ? envMap[nuxt.options.builder] : false - const types = overrideEnv || defaultEnv - - if (types) { references.push({ types }) } - }) - - // Add nuxt app debugger - if (nuxt.options.debug) { - addPlugin(resolve(nuxt.options.appDir, 'plugins/debug')) - } - - for (const m of modulesToInstall) { - if (Array.isArray(m)) { - await installModule(m[0], m[1]) - } else { - await installModule(m, {}) - } - } - - await nuxt.callHook('modules:done') - if (nuxt.options.experimental.appManifest) { addRouteMiddleware({ name: 'manifest-route-rule', path: resolve(nuxt.options.appDir, 'middleware/manifest-route-rule'), - global: true + global: true, }) - addPlugin(resolve(nuxt.options.appDir, 'plugins/check-outdated-build.client')) + if (nuxt.options.experimental.checkOutdatedBuildInterval !== false) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/check-outdated-build.client')) + } + } + + if (nuxt.options.experimental.navigationRepaint) { + addPlugin({ + src: resolve(nuxt.options.appDir, 'plugins/navigation-repaint.client'), + }) + } + + if (nuxt.options.vue.config && Object.values(nuxt.options.vue.config).some(v => v !== null && v !== undefined)) { + addPluginTemplate({ + filename: 'vue-app-config.mjs', + getContents: () => ` +import { defineNuxtPlugin } from '#app/nuxt' +export default defineNuxtPlugin({ + name: 'nuxt:vue-app-config', + enforce: 'pre', + setup (nuxtApp) { + ${Object.keys(nuxt.options.vue.config!).map(k => ` nuxtApp.vueApp.config[${JSON.stringify(k)}] = ${JSON.stringify(nuxt.options.vue.config![k as 'idPrefix'])}`).join('\n')} + } +})`, + }) } nuxt.hooks.hook('builder:watch', (event, relativePath) => { @@ -422,6 +627,12 @@ async function initNuxt (nuxt: Nuxt) { } } + // Restart Nuxt when new `app/` dir is added + if (event === 'addDir' && path === resolve(nuxt.options.srcDir, 'app')) { + logger.info(`\`${path}/\` ${event === 'addDir' ? 'created' : 'removed'}`) + return nuxt.callHook('restart', { hard: true }) + } + // Core Nuxt files: app.vue, error.vue and app.config.ts const isFileChange = ['add', 'unlink'].includes(event) if (isFileChange && RESTART_RE.test(path)) { @@ -452,6 +663,12 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/payload.client')) } + // Show compatibility version banner when Nuxt is running with a compatibility version + // that is different from the current major version + if (!(satisfies(nuxt._version, nuxt.options.future.compatibilityVersion + '.x'))) { + logger.info(`Running with compatibility version \`${nuxt.options.future.compatibilityVersion}\``) + } + await nuxt.callHook('ready', nuxt) } @@ -460,7 +677,12 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { // Temporary until finding better placement for each options.appDir = options.alias['#app'] = resolve(distDir, 'app') - options._majorVersion = 3 + options._majorVersion = 4 + + // De-duplicate key arrays + for (const key in options.app.head || {}) { + options.app.head[key as 'link'] = deduplicateArray(options.app.head[key as 'link']) + } // Nuxt DevTools only works for Vite if (options.builder === '@nuxt/vite-builder') { @@ -475,11 +697,17 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { } } + if (!options._modules.some(m => m === '@nuxt/scripts' || m === '@nuxt/scripts-nightly')) { + options.imports = defu(options.imports, { + presets: [scriptsStubsPreset], + }) + } + // Nuxt Webpack Builder is currently opt-in if (options.builder === '@nuxt/webpack-builder') { if (!await import('./features').then(r => r.ensurePackageInstalled('@nuxt/webpack-builder', { rootDir: options.rootDir, - searchPaths: options.modulesDir + searchPaths: options.modulesDir, }))) { logger.warn('Failed to install `@nuxt/webpack-builder`, please install it manually, or change the `builder` option to vite in `nuxt.config`') } @@ -491,15 +719,14 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { transform: { include: options._layers .filter(i => i.cwd && i.cwd.includes('node_modules')) - .map(i => new RegExp(`(^|\\/)${escapeRE(i.cwd!.split('node_modules/').pop()!)}(\\/|$)(?!node_modules\\/)`)) - } + .map(i => new RegExp(`(^|\\/)${escapeRE(i.cwd!.split('node_modules/').pop()!)}(\\/|$)(?!node_modules\\/)`)), + }, }]) options._modules.push(schemaModule) options.modulesDir.push(resolve(options.workspaceDir, 'node_modules')) options.modulesDir.push(resolve(pkgDir, 'node_modules')) options.build.transpile.push( - '@nuxt/ui-templates', // this exposes vue SFCs - 'std-env' // we need to statically replace process.env when used in runtime code + 'std-env', // we need to statically replace process.env when used in runtime code ) options.alias['vue-demi'] = resolve(options.appDir, 'compat/vue-demi') options.alias['@vue/composition-api'] = resolve(options.appDir, 'compat/capi') @@ -507,8 +734,29 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { options._modules.push('@nuxt/telemetry') } + // Ensure we share key config between Nuxt and Nitro + createPortalProperties(options.nitro.runtimeConfig, options, ['nitro.runtimeConfig', 'runtimeConfig']) + createPortalProperties(options.nitro.routeRules, options, ['nitro.routeRules', 'routeRules']) + + // prevent replacement of options.nitro + const nitroOptions = options.nitro + Object.defineProperties(options, { + nitro: { + configurable: false, + enumerable: true, + get: () => nitroOptions, + set (value) { + Object.assign(nitroOptions, value) + }, + }, + }) + const nuxt = createNuxt(options) + for (const dep of keyDependencies) { + checkDependencyVersion(dep, nuxt._version) + } + // We register hooks layer-by-layer so any overrides need to be registered separately if (opts.overrides?.hooks) { nuxt.hooks.addHooks(opts.overrides.hooks) @@ -525,4 +773,65 @@ export async function loadNuxt (opts: LoadNuxtOptions): Promise<Nuxt> { return nuxt } -const RESTART_RE = /^(app|error|app\.config)\.(js|ts|mjs|jsx|tsx|vue)$/i +async function checkDependencyVersion (name: string, nuxtVersion: string): Promise<void> { + const path = await resolvePath(name, { fallbackToOriginal: true }).catch(() => null) + + if (!path || path === name) { return } + const { version } = await readPackageJSON(path) + + if (version && gt(nuxtVersion, version)) { + console.warn(`[nuxt] Expected \`${name}\` to be at least \`${nuxtVersion}\` but got \`${version}\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`) + } +} + +const RESTART_RE = /^(?:app|error|app\.config)\.(?:js|ts|mjs|jsx|tsx|vue)$/i + +function deduplicateArray<T = unknown> (maybeArray: T): T { + if (!Array.isArray(maybeArray)) { return maybeArray } + + const fresh: any[] = [] + const hashes = new Set<string>() + for (const item of maybeArray) { + const _hash = hash(item) + if (!hashes.has(_hash)) { + hashes.add(_hash) + fresh.push(item) + } + } + return fresh as T +} + +function createPortalProperties (sourceValue: any, options: NuxtOptions, paths: string[]) { + let sharedValue = sourceValue + + for (const path of paths) { + const segments = path.split('.') + const key = segments.pop()! + let parent: Record<string, any> = options + + while (segments.length) { + const key = segments.shift()! + parent = parent[key] || (parent[key] = {}) + } + + delete parent[key] + + Object.defineProperties(parent, { + [key]: { + configurable: false, + enumerable: true, + get: () => sharedValue, + set (value) { + sharedValue = value + }, + }, + }) + } +} + +const _getDefaultNuxtConfig = () => /* js */ + `// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + devtools: { enabled: true } +}) +` diff --git a/packages/nuxt/src/core/plugins/async-context.ts b/packages/nuxt/src/core/plugins/async-context.ts index fd997070dd..28f71c9c73 100644 --- a/packages/nuxt/src/core/plugins/async-context.ts +++ b/packages/nuxt/src/core/plugins/async-context.ts @@ -21,9 +21,9 @@ export const AsyncContextInjectionPlugin = (nuxt: Nuxt) => createUnplugin(() => code: s.toString(), map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/core/plugins/dev-only.ts b/packages/nuxt/src/core/plugins/dev-only.ts index f1f56122d0..17992aa3d1 100644 --- a/packages/nuxt/src/core/plugins/dev-only.ts +++ b/packages/nuxt/src/core/plugins/dev-only.ts @@ -10,7 +10,7 @@ interface DevOnlyPluginOptions { const DEVONLY_COMP_SINGLE_RE = /<(?:dev-only|DevOnly|lazy-dev-only|LazyDevOnly)>[\s\S]*?<\/(?:dev-only|DevOnly|lazy-dev-only|LazyDevOnly)>/ const DEVONLY_COMP_RE = /<(?:dev-only|DevOnly|lazy-dev-only|LazyDevOnly)>[\s\S]*?<\/(?:dev-only|DevOnly|lazy-dev-only|LazyDevOnly)>/g -export const DevOnlyPlugin = createUnplugin((options: DevOnlyPluginOptions) => { +export const DevOnlyPlugin = (options: DevOnlyPluginOptions) => createUnplugin(() => { return { name: 'nuxt:server-devonly:transform', enforce: 'pre', @@ -21,11 +21,10 @@ export const DevOnlyPlugin = createUnplugin((options: DevOnlyPluginOptions) => { if (!DEVONLY_COMP_SINGLE_RE.test(code)) { return } const s = new MagicString(code) - for (const match of code.matchAll(DEVONLY_COMP_RE) || []) { + for (const match of code.matchAll(DEVONLY_COMP_RE)) { const ast: Node = parse(match[0]).children[0] const fallback: Node | undefined = ast.children?.find((n: Node) => n.name === 'template' && Object.values(n.attributes).includes('#fallback')) const replacement = fallback ? match[0].slice(fallback.loc[0].end, fallback.loc[fallback.loc.length - 1].start) : '' - s.overwrite(match.index!, match.index! + match[0].length, replacement) } @@ -34,9 +33,9 @@ export const DevOnlyPlugin = createUnplugin((options: DevOnlyPluginOptions) => { code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/core/plugins/import-protection.ts b/packages/nuxt/src/core/plugins/import-protection.ts index f9f1578d23..0e6e7fa999 100644 --- a/packages/nuxt/src/core/plugins/import-protection.ts +++ b/packages/nuxt/src/core/plugins/import-protection.ts @@ -1,14 +1,10 @@ -import { createRequire } from 'node:module' -import { createUnplugin } from 'unplugin' -import { logger } from '@nuxt/kit' -import { isAbsolute, join, relative, resolve } from 'pathe' +import { relative, resolve } from 'pathe' import escapeRE from 'escape-string-regexp' import type { NuxtOptions } from 'nuxt/schema' -const _require = createRequire(import.meta.url) - interface ImportProtectionOptions { rootDir: string + modulesDir: string[] patterns: [importPattern: string | RegExp, warning?: string][] exclude?: Array<RegExp | string> } @@ -18,12 +14,12 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { patterns.push([ /^(nuxt|nuxt3|nuxt-nightly)$/, - '`nuxt`, `nuxt3` or `nuxt-nightly` cannot be imported directly.' + (options.isNitro ? '' : ' Instead, import runtime Nuxt composables from `#app` or `#imports`.') + '`nuxt`, `nuxt3` or `nuxt-nightly` cannot be imported directly.' + (options.isNitro ? '' : ' Instead, import runtime Nuxt composables from `#app` or `#imports`.'), ]) patterns.push([ - /^((|~|~~|@|@@)\/)?nuxt\.config(\.|$)/, - 'Importing directly from a `nuxt.config` file is not allowed. Instead, use runtime config or a module.' + /^((~|~~|@|@@)?\/)?nuxt\.config(\.|$)/, + 'Importing directly from a `nuxt.config` file is not allowed. Instead, use runtime config or a module.', ]) patterns.push([/(^|node_modules\/)@vue\/composition-api/]) @@ -31,11 +27,11 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { for (const mod of nuxt.options.modules.filter(m => typeof m === 'string')) { patterns.push([ new RegExp(`^${escapeRE(mod as string)}$`), - 'Importing directly from module entry-points is not allowed.' + 'Importing directly from module entry-points is not allowed.', ]) } - for (const i of [/(^|node_modules\/)@nuxt\/(kit|test-utils)/, /(^|node_modules\/)nuxi/, /(^|node_modules\/)nuxt\/(config|kit|schema)/, 'nitropack']) { + for (const i of [/(^|node_modules\/)@nuxt\/(kit|test-utils)/, /(^|node_modules\/)nuxi/, /(^|node_modules\/)nitro(?:pack)?(?:-nightly)?(?:$|\/)(?!(?:dist\/)?runtime|types)/, /(^|node_modules\/)nuxt\/(config|kit|schema)/]) { patterns.push([i, 'This module cannot be imported' + (options.isNitro ? ' in server runtime.' : ' in the Vue part of your app.')]) } @@ -48,46 +44,9 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { if (!options.isNitro) { patterns.push([ new RegExp(escapeRE(relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, nuxt.options.serverDir || 'server'))) + '\\/(api|routes|middleware|plugins)\\/'), - 'Importing from server is not allowed in the Vue part of your app.' + 'Importing from server is not allowed in the Vue part of your app.', ]) } return patterns } - -export const ImportProtectionPlugin = createUnplugin(function (options: ImportProtectionOptions) { - const cache: Record<string, Map<string | RegExp, boolean>> = {} - const importersToExclude = options?.exclude || [] - return { - name: 'nuxt:import-protection', - enforce: 'pre', - resolveId (id, importer) { - if (!importer) { return } - if (id[0] === '.') { - id = join(importer, '..', id) - } - if (isAbsolute(id)) { - id = relative(options.rootDir, id) - } - if (importersToExclude.some(p => typeof p === 'string' ? importer === p : p.test(importer))) { return } - - const invalidImports = options.patterns.filter(([pattern]) => pattern instanceof RegExp ? pattern.test(id) : pattern === id) - let matched = false - for (const match of invalidImports) { - cache[id] = cache[id] || new Map() - const [pattern, warning] = match - // Skip if already warned - if (cache[id].has(pattern)) { continue } - - const relativeImporter = isAbsolute(importer) ? relative(options.rootDir, importer) : importer - logger.error(warning || 'Invalid import', `[importing \`${id}\` from \`${relativeImporter}\`]`) - cache[id].set(pattern, true) - matched = true - } - if (matched) { - return _require.resolve('unenv/runtime/mock/proxy') - } - return null - } - } -}) diff --git a/packages/nuxt/src/core/plugins/layer-aliasing.ts b/packages/nuxt/src/core/plugins/layer-aliasing.ts index f73f67297f..9f12c72988 100644 --- a/packages/nuxt/src/core/plugins/layer-aliasing.ts +++ b/packages/nuxt/src/core/plugins/layer-aliasing.ts @@ -1,13 +1,11 @@ -import { existsSync, readdirSync } from 'node:fs' import { createUnplugin } from 'unplugin' import type { NuxtConfigLayer } from 'nuxt/schema' import { resolveAlias } from '@nuxt/kit' -import { join, normalize, relative } from 'pathe' +import { normalize } from 'pathe' import MagicString from 'magic-string' interface LayerAliasingOptions { sourcemap?: boolean - transform?: boolean root: string dev: boolean layers: NuxtConfigLayer[] @@ -16,22 +14,17 @@ interface LayerAliasingOptions { const ALIAS_RE = /(?<=['"])[~@]{1,2}(?=\/)/g const ALIAS_RE_SINGLE = /(?<=['"])[~@]{1,2}(?=\/)/ -export const LayerAliasingPlugin = createUnplugin((options: LayerAliasingOptions) => { - const aliases: Record<string, { aliases: Record<string, string>, prefix: string, publicDir: false | string }> = {} +export const LayerAliasingPlugin = (options: LayerAliasingOptions) => createUnplugin((_options, meta) => { + const aliases: Record<string, Record<string, string>> = {} for (const layer of options.layers) { const srcDir = layer.config.srcDir || layer.cwd const rootDir = layer.config.rootDir || layer.cwd - const publicDir = join(srcDir, layer.config?.dir?.public || 'public') aliases[srcDir] = { - aliases: { - '~': layer.config?.alias?.['~'] || srcDir, - '@': layer.config?.alias?.['@'] || srcDir, - '~~': layer.config?.alias?.['~~'] || rootDir, - '@@': layer.config?.alias?.['@@'] || rootDir - }, - prefix: relative(options.root, publicDir), - publicDir: !options.dev && existsSync(publicDir) && publicDir + '~': layer.config?.alias?.['~'] || srcDir, + '@': layer.config?.alias?.['@'] || srcDir, + '~~': layer.config?.alias?.['~~'] || rootDir, + '@@': layer.config?.alias?.['@@'] || rootDir, } } const layers = Object.keys(aliases).sort((a, b) => b.length - a.length) @@ -48,42 +41,37 @@ export const LayerAliasingPlugin = createUnplugin((options: LayerAliasingOptions const layer = layers.find(l => importer.startsWith(l)) if (!layer) { return } - const publicDir = aliases[layer].publicDir - if (id.startsWith('/') && publicDir && readdirSync(publicDir).some(file => file === id.slice(1) || id.startsWith('/' + file + '/'))) { - const resolvedId = '/' + join(aliases[layer].prefix, id.slice(1)) - return await this.resolve(resolvedId, importer, { skipSelf: true }) - } - - const resolvedId = resolveAlias(id, aliases[layer].aliases) + const resolvedId = resolveAlias(id, aliases[layer]) if (resolvedId !== id) { return await this.resolve(resolvedId, importer, { skipSelf: true }) } - } - } + }, + }, }, // webpack-only transform transformInclude: (id) => { - if (!options.transform) { return false } + if (meta.framework === 'vite') { return false } + const _id = normalize(id) return layers.some(dir => _id.startsWith(dir)) }, transform (code, id) { - if (!options.transform) { return } + if (meta.framework === 'vite') { return } const _id = normalize(id) const layer = layers.find(l => _id.startsWith(l)) if (!layer || !ALIAS_RE_SINGLE.test(code)) { return } const s = new MagicString(code) - s.replace(ALIAS_RE, r => aliases[layer].aliases[r as '~'] || r) + s.replace(ALIAS_RE, r => aliases[layer]?.[r as '~'] || r) if (s.hasChanged()) { return { code: s.toString(), - map: options.sourcemap ? s.generateMap({ hires: true }) : undefined + map: options.sourcemap ? s.generateMap({ hires: true }) : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/core/plugins/plugin-metadata.ts b/packages/nuxt/src/core/plugins/plugin-metadata.ts index b77678caa3..db6fad7b54 100644 --- a/packages/nuxt/src/core/plugins/plugin-metadata.ts +++ b/packages/nuxt/src/core/plugins/plugin-metadata.ts @@ -11,7 +11,6 @@ import MagicString from 'magic-string' import { normalize } from 'pathe' import { logger } from '@nuxt/kit' -// eslint-disable-next-line import/no-restricted-paths import type { ObjectPlugin, PluginMeta } from '#app' const internalOrderMap = { @@ -32,25 +31,25 @@ const internalOrderMap = { // +20: post (user) <-- post mapped to this 'user-post': 20, // +30: post-all (nuxt) - 'nuxt-post-all': 30 + 'nuxt-post-all': 30, } export const orderMap: Record<NonNullable<ObjectPlugin['enforce']>, number> = { pre: internalOrderMap['user-pre'], default: internalOrderMap['user-default'], - post: internalOrderMap['user-post'] + post: internalOrderMap['user-post'], } const metaCache: Record<string, Omit<PluginMeta, 'enforce'>> = {} -export async function extractMetadata (code: string) { +export async function extractMetadata (code: string, loader = 'ts' as 'ts' | 'tsx') { let meta: PluginMeta = {} if (metaCache[code]) { return metaCache[code] } - const js = await transform(code, { loader: 'ts' }) + const js = await transform(code, { loader }) walk(parse(js.code, { sourceType: 'module', - ecmaVersion: 'latest' + ecmaVersion: 'latest', }) as Node, { enter (_node) { if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } @@ -71,13 +70,13 @@ export async function extractMetadata (code: string) { } const plugin = node.arguments[0] - if (plugin.type === 'ObjectExpression') { + if (plugin?.type === 'ObjectExpression') { meta = defu(extractMetaFromObject(plugin.properties), meta) } meta.order = meta.order || orderMap[meta.enforce || 'default'] || orderMap.default delete meta.enforce - } + }, }) metaCache[code] = meta return meta as Omit<PluginMeta, 'enforce'> @@ -88,7 +87,7 @@ const keys: Record<PluginMetaKey, string> = { name: 'name', order: 'order', enforce: 'enforce', - dependsOn: 'dependsOn' + dependsOn: 'dependsOn', } function isMetadataKey (key: string): key is PluginMetaKey { return key in keys @@ -123,7 +122,7 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => { name: 'nuxt:remove-plugin-metadata', transform (code, id) { id = normalize(id) - const plugin = nuxt.apps.default.plugins.find(p => p.src === id) + const plugin = nuxt.apps.default?.plugins.find(p => p.src === id) if (!plugin) { return } const s = new MagicString(code) @@ -134,7 +133,7 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => { s.overwrite(0, code.length, 'export default () => {}') return { code: s.toString(), - map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null + map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null, } } @@ -144,35 +143,18 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => { try { walk(this.parse(code, { sourceType: 'module', - ecmaVersion: 'latest' + ecmaVersion: 'latest', }) as Node, { enter (_node) { - if (_node.type === 'ImportSpecifier' && (_node.imported.name === 'defineNuxtPlugin' || _node.imported.name === 'definePayloadPlugin')) { + if (_node.type === 'ImportSpecifier' && _node.imported.type === 'Identifier' && (_node.imported.name === 'defineNuxtPlugin' || _node.imported.name === 'definePayloadPlugin')) { wrapperNames.add(_node.local.name) } - if (_node.type === 'ExportDefaultDeclaration' && (_node.declaration.type === 'FunctionDeclaration' || _node.declaration.type === 'ArrowFunctionExpression')) { - if ('params' in _node.declaration && _node.declaration.params.length > 1) { - logger.warn(`Plugin \`${plugin.src}\` is in legacy Nuxt 2 format (context, inject) which is likely to be broken and will be ignored.`) - s.overwrite(0, code.length, 'export default () => {}') - wrapped = true // silence a duplicate error - return - } - } if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } const node = _node as CallExpression & { start: number, end: number } const name = 'name' in node.callee && node.callee.name if (!name || !wrapperNames.has(name)) { return } wrapped = true - if (node.arguments[0].type !== 'ObjectExpression') { - // TODO: Warn if legacy plugin format is detected - if ('params' in node.arguments[0] && node.arguments[0].params.length > 1) { - logger.warn(`Plugin \`${plugin.src}\` is in legacy Nuxt 2 format (context, inject) which is likely to be broken and will be ignored.`) - s.overwrite(0, code.length, 'export default () => {}') - return - } - } - // Remove metadata that already has been extracted if (!('order' in plugin) && !('name' in plugin)) { return } for (const [argIndex, _arg] of node.arguments.entries()) { @@ -193,7 +175,7 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => { } } } - } + }, }) } catch (e) { logger.error(e) @@ -207,9 +189,9 @@ export const RemovePluginMetadataPlugin = (nuxt: Nuxt) => createUnplugin(() => { if (s.hasChanged()) { return { code: s.toString(), - map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null + map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) : null, } } - } + }, } }) diff --git a/packages/nuxt/src/core/plugins/prehydrate.ts b/packages/nuxt/src/core/plugins/prehydrate.ts new file mode 100644 index 0000000000..c91e3cb5f7 --- /dev/null +++ b/packages/nuxt/src/core/plugins/prehydrate.ts @@ -0,0 +1,68 @@ +import { transform } from 'esbuild' +import { parse } from 'acorn' +import { walk } from 'estree-walker' +import type { Node } from 'estree-walker' +import type { Nuxt } from '@nuxt/schema' +import { createUnplugin } from 'unplugin' +import type { SimpleCallExpression } from 'estree' +import MagicString from 'magic-string' + +import { hash } from 'ohash' +import { isJS, isVue } from '../utils' + +export function prehydrateTransformPlugin (nuxt: Nuxt) { + return createUnplugin(() => ({ + name: 'nuxt:prehydrate-transform', + transformInclude (id) { + return isJS(id) || isVue(id, { type: ['script'] }) + }, + async transform (code, id) { + if (!code.includes('onPrehydrate(')) { return } + + const s = new MagicString(code) + const promises: Array<Promise<any>> = [] + + walk(parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) as Node, { + enter (_node) { + if (_node.type !== 'CallExpression' || _node.callee.type !== 'Identifier') { return } + const node = _node as SimpleCallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name === 'onPrehydrate') { + if (!node.arguments[0]) { return } + if (node.arguments[0].type !== 'ArrowFunctionExpression' && node.arguments[0].type !== 'FunctionExpression') { return } + + const needsAttr = node.arguments[0].params.length > 0 + const { start, end } = node.arguments[0] as Node & { start: number, end: number } + + const p = transform(`forEach(${code.slice(start, end)})`, { loader: 'ts', minify: true }) + promises.push(p.then(({ code: result }) => { + const cleaned = result.slice('forEach'.length).replace(/;\s+$/, '') + const args = [JSON.stringify(cleaned)] + if (needsAttr) { + args.push(JSON.stringify(hash(result))) + } + s.overwrite(start, end, args.join(', ')) + })) + } + }, + }) + + await Promise.all(promises).catch((e) => { + console.error(`[nuxt] Could not transform onPrehydrate in \`${id}\`:`, e) + }) + + if (s.hasChanged()) { + return { + code: s.toString(), + map: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client + ? s.generateMap({ hires: true }) + : undefined, + } + } + }, + })) +} diff --git a/packages/nuxt/src/core/plugins/resolve-deep-imports.ts b/packages/nuxt/src/core/plugins/resolve-deep-imports.ts index 3bf20e1983..fee568d654 100644 --- a/packages/nuxt/src/core/plugins/resolve-deep-imports.ts +++ b/packages/nuxt/src/core/plugins/resolve-deep-imports.ts @@ -8,24 +8,29 @@ import { pkgDir } from '../../dirs' export function resolveDeepImportsPlugin (nuxt: Nuxt): Plugin { const exclude: string[] = ['virtual:', '\0virtual:', '/__skip_vite'] + let conditions: string[] return { name: 'nuxt:resolve-bare-imports', enforce: 'post', - async resolveId (id, importer, options) { + configResolved (config) { + conditions = config.mode === 'test' ? [...config.resolve.conditions, 'import', 'require'] : config.resolve.conditions + }, + async resolveId (id, importer) { if (!importer || isAbsolute(id) || (!isAbsolute(importer) && !importer.startsWith('virtual:')) || exclude.some(e => id.startsWith(e))) { return } - id = normalize(id) - id = resolveAlias(id, nuxt.options.alias) - const { dir } = parseNodeModulePath(importer) - return await this.resolve?.(id, dir || pkgDir, { skipSelf: true }) ?? await resolvePath(id, { - url: [dir || pkgDir, ...nuxt.options.modulesDir], - // TODO: respect nitro runtime conditions - conditions: options.ssr ? ['node', 'import', 'require'] : ['import', 'require'] + + const normalisedId = resolveAlias(normalize(id), nuxt.options.alias) + const normalisedImporter = importer.replace(/^\0?virtual:(?:nuxt:)?/, '') + const dir = parseNodeModulePath(normalisedImporter).dir || pkgDir + + return await this.resolve?.(normalisedId, dir, { skipSelf: true }) ?? await resolvePath(id, { + url: [dir, ...nuxt.options.modulesDir], + conditions, }).catch(() => { logger.debug('Could not resolve id', id, importer) return null }) - } + }, } } diff --git a/packages/nuxt/src/core/plugins/tree-shake.ts b/packages/nuxt/src/core/plugins/tree-shake.ts index 35030990ca..aed3fad9e2 100644 --- a/packages/nuxt/src/core/plugins/tree-shake.ts +++ b/packages/nuxt/src/core/plugins/tree-shake.ts @@ -5,12 +5,12 @@ import { isJS, isVue } from '../utils' type ImportPath = string -export interface TreeShakeComposablesPluginOptions { +interface TreeShakeComposablesPluginOptions { sourcemap?: boolean composables: Record<ImportPath, string[]> } -export const TreeShakeComposablesPlugin = createUnplugin((options: TreeShakeComposablesPluginOptions) => { +export const TreeShakeComposablesPlugin = (options: TreeShakeComposablesPluginOptions) => createUnplugin(() => { /** * @todo Use the options import-path to tree-shake composables in a safer way. */ @@ -31,7 +31,7 @@ export const TreeShakeComposablesPlugin = createUnplugin((options: TreeShakeComp const s = new MagicString(code) const strippedCode = stripLiteral(code) - for (const match of strippedCode.matchAll(COMPOSABLE_RE_GLOBAL) || []) { + for (const match of strippedCode.matchAll(COMPOSABLE_RE_GLOBAL)) { s.overwrite(match.index!, match.index! + match[0].length, `${match[1]} false && /*@__PURE__*/ ${match[2]}`) } @@ -40,9 +40,9 @@ export const TreeShakeComposablesPlugin = createUnplugin((options: TreeShakeComp code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/core/plugins/unctx.ts b/packages/nuxt/src/core/plugins/unctx.ts index 1e458eda49..efaf9fde5a 100644 --- a/packages/nuxt/src/core/plugins/unctx.ts +++ b/packages/nuxt/src/core/plugins/unctx.ts @@ -6,18 +6,18 @@ import { isJS, isVue } from '../utils' const TRANSFORM_MARKER = '/* _processed_nuxt_unctx_transform */\n' -export interface UnctxTransformPluginOptions { +interface UnctxTransformPluginOptions { sourcemap?: boolean transformerOptions: TransformerOptions } -export const UnctxTransformPlugin = createUnplugin((options: UnctxTransformPluginOptions) => { +export const UnctxTransformPlugin = (options: UnctxTransformPluginOptions) => createUnplugin(() => { const transformer = createTransformer(options.transformerOptions) return { name: 'unctx:transform', enforce: 'post', transformInclude (id) { - return isVue(id) || isJS(id) + return isVue(id, { type: ['template', 'script'] }) || isJS(id) }, transform (code) { // TODO: needed for webpack - update transform in unctx/unplugin? @@ -28,9 +28,9 @@ export const UnctxTransformPlugin = createUnplugin((options: UnctxTransformPlugi code: TRANSFORM_MARKER + result.code, map: options.sourcemap ? result.magicString.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/core/runtime/nitro/app-config.ts b/packages/nuxt/src/core/runtime/nitro/app-config.ts new file mode 100644 index 0000000000..2b84d23877 --- /dev/null +++ b/packages/nuxt/src/core/runtime/nitro/app-config.ts @@ -0,0 +1,38 @@ +import type { H3Event } from 'h3' +import { klona } from 'klona' + +// @ts-expect-error virtual file +import _inlineAppConfig from '#internal/nuxt/app-config' + +// App config +const _sharedAppConfig = _deepFreeze(klona(_inlineAppConfig)) +export function useAppConfig (event?: H3Event) { + // Backwards compatibility with ambient context + if (!event) { + return _sharedAppConfig + } + if (!event.context.nuxt) { + event.context.nuxt = {} + } + // Reuse cached app config from event context + if (event.context.nuxt.appConfig) { + return event.context.nuxt.appConfig + } + // Prepare app config for event context + const appConfig = klona(_inlineAppConfig) + event.context.nuxt.appConfig = appConfig + return appConfig +} + +// --- Utils --- + +function _deepFreeze (object: Record<string, any>) { + const propNames = Object.getOwnPropertyNames(object) + for (const name of propNames) { + const value = object[name] + if (value && typeof value === 'object') { + _deepFreeze(value) + } + } + return Object.freeze(object) +} diff --git a/packages/nuxt/src/core/runtime/nitro/cache-driver.js b/packages/nuxt/src/core/runtime/nitro/cache-driver.js new file mode 100644 index 0000000000..7a6076c735 --- /dev/null +++ b/packages/nuxt/src/core/runtime/nitro/cache-driver.js @@ -0,0 +1,34 @@ +// @ts-check + +import { defineDriver } from 'unstorage' +import fsDriver from 'unstorage/drivers/fs-lite' +import lruCache from 'unstorage/drivers/lru-cache' + +/** + * @param {string} item + */ +const normalizeFsKey = item => item.replaceAll(':', '_') + +/** + * @param {{ base: string }} opts + */ +export default defineDriver((opts) => { + const fs = fsDriver({ base: opts.base }) + const lru = lruCache({ max: 1000 }) + + return { + ...fs, // fall back to file system - only the bottom three methods are used in renderer + async setItem (key, value, opts) { + await Promise.all([ + fs.setItem?.(normalizeFsKey(key), value, opts), + lru.setItem?.(key, value, opts), + ]) + }, + async hasItem (key, opts) { + return await lru.hasItem(key, opts) || await fs.hasItem(normalizeFsKey(key), opts) + }, + async getItem (key, opts) { + return await lru.getItem(key, opts) || await fs.getItem(normalizeFsKey(key), opts) + }, + } +}) diff --git a/packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts b/packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts new file mode 100644 index 0000000000..19c2cf1e26 --- /dev/null +++ b/packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts @@ -0,0 +1,98 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { LogObject } from 'consola' +import { consola } from 'consola' +import { stringify } from 'devalue' +import type { H3Event } from 'h3' +import { withTrailingSlash } from 'ufo' +import { getContext } from 'unctx' +import { captureRawStackTrace, parseRawStackTrace } from 'errx' +import type { ParsedTrace } from 'errx' + +import { isVNode } from 'vue' +import type { NitroApp } from 'nitro/types' + +// @ts-expect-error virtual file +import { rootDir } from '#internal/dev-server-logs-options' +// @ts-expect-error virtual file +import { appId } from '#internal/nuxt.config.mjs' + +const devReducers: Record<string, (data: any) => any> = { + VNode: data => isVNode(data) ? { type: data.type, props: data.props } : undefined, + URL: data => data instanceof URL ? data.toString() : undefined, +} + +interface NuxtDevAsyncContext { + logs: LogObject[] + event: H3Event +} + +const asyncContext = getContext<NuxtDevAsyncContext>('nuxt-dev', { asyncContext: true, AsyncLocalStorage }) + +export default (nitroApp: NitroApp) => { + const handler = nitroApp.h3App.handler + nitroApp.h3App.handler = (event) => { + return asyncContext.callAsync({ logs: [], event }, () => handler(event)) + } + + onConsoleLog((_log) => { + const ctx = asyncContext.tryUse() + if (!ctx) { return } + + const rawStack = captureRawStackTrace() + if (!rawStack || rawStack.includes('runtime/vite-node.mjs')) { return } + + const trace: ParsedTrace[] = [] + let filename = '' + for (const entry of parseRawStackTrace(rawStack)) { + if (entry.source === import.meta.url) { continue } + if (EXCLUDE_TRACE_RE.test(entry.source)) { continue } + + filename ||= entry.source.replace(withTrailingSlash(rootDir), '') + trace.push({ + ...entry, + source: entry.source.startsWith('file://') ? entry.source.replace('file://', '') : entry.source, + }) + } + + const log = { + ..._log, + // Pass along filename to allow the client to display more info about where log comes from + filename, + // Clean up file names in stack trace + stack: trace, + } + + // retain log to be include in the next render + ctx.logs.push(log) + }) + + nitroApp.hooks.hook('afterResponse', () => { + const ctx = asyncContext.tryUse() + if (!ctx) { return } + return nitroApp.hooks.callHook('dev:ssr-logs', { logs: ctx.logs, path: ctx.event.path }) + }) + + // Pass any logs to the client + nitroApp.hooks.hook('render:html', (htmlContext) => { + const ctx = asyncContext.tryUse() + if (!ctx) { return } + try { + const reducers = Object.assign(Object.create(null), devReducers, ctx.event.context._payloadReducers) + htmlContext.bodyAppend.unshift(`<script type="application/json" data-nuxt-logs="${appId}">${stringify(ctx.logs, reducers)}</script>`) + } catch (e) { + const shortError = e instanceof Error && 'toString' in e ? ` Received \`${e.toString()}\`.` : '' + console.warn(`[nuxt] Failed to stringify dev server logs.${shortError} You can define your own reducer/reviver for rich types following the instructions in https://nuxt.com/docs/api/composables/use-nuxt-app#payload.`) + } + }) +} + +const EXCLUDE_TRACE_RE = /\/node_modules\/(?:.*\/)?(?:nuxt|nuxt-nightly|nuxt-edge|nuxt3|consola|@vue)\/|core\/runtime\/nitro/ + +function onConsoleLog (callback: (log: LogObject) => void) { + consola.addReporter({ + log (logObj) { + callback(logObj) + }, + }) + consola.wrapConsole() +} diff --git a/packages/nuxt/src/core/runtime/nitro/error.ts b/packages/nuxt/src/core/runtime/nitro/error.ts index c58bb75fcd..d2899e4608 100644 --- a/packages/nuxt/src/core/runtime/nitro/error.ts +++ b/packages/nuxt/src/core/runtime/nitro/error.ts @@ -1,10 +1,8 @@ import { joinURL, withQuery } from 'ufo' -import type { NitroErrorHandler } from 'nitropack' -import type { H3Error } from 'h3' -import { getRequestHeaders, send, setResponseHeader, setResponseStatus } from 'h3' -import { useRuntimeConfig } from '#internal/nitro' -import { useNitroApp } from '#internal/nitro/app' -import { isJsonRequest, normalizeError } from '#internal/nitro/utils' +import type { NitroErrorHandler } from 'nitro/types' +import type { H3Error, H3Event } from 'h3' +import { getRequestHeader, getRequestHeaders, send, setResponseHeader, setResponseStatus } from 'h3' +import { useNitroApp, useRuntimeConfig } from 'nitro/runtime' import type { NuxtPayload } from '#app' export default <NitroErrorHandler> async function errorhandler (error: H3Error, event) { @@ -21,7 +19,7 @@ export default <NitroErrorHandler> async function errorhandler (error: H3Error, ? `<pre>${stack.map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n')}</pre>` : '', // TODO: check and validate error.data for serialisation into query - data: error.data as any + data: error.data as any, } satisfies Partial<NuxtPayload['error']> & { url: string } // Console output @@ -31,9 +29,9 @@ export default <NitroErrorHandler> async function errorhandler (error: H3Error, '[request error]', error.unhandled && '[unhandled]', error.fatal && '[fatal]', - Number(errorObject.statusCode) !== 200 && `[${errorObject.statusCode}]` + Number(errorObject.statusCode) !== 200 && `[${errorObject.statusCode}]`, ].filter(Boolean).join(' ') - console.error(tags, errorObject.message + '\n' + stack.map(l => ' ' + l.text).join(' \n')) + console.error(tags, (error.message || error.toString() || 'internal server error') + '\n' + stack.map(l => ' ' + l.text).join(' \n')) } if (event.handled) { return } @@ -54,21 +52,19 @@ export default <NitroErrorHandler> async function errorhandler (error: H3Error, const isRenderingError = event.path.startsWith('/__nuxt_error') || !!reqHeaders['x-nuxt-error'] // HTML response (via SSR) - const res = isRenderingError ? null : await useNitroApp().localFetch( - withQuery(joinURL(useRuntimeConfig().app.baseURL, '/__nuxt_error'), errorObject), - { - headers: { ...reqHeaders, 'x-nuxt-error': 'true' }, - redirect: 'manual' - } - ).catch(() => null) + const res = isRenderingError + ? null + : await useNitroApp().localFetch( + withQuery(joinURL(useRuntimeConfig(event).app.baseURL, '/__nuxt_error'), errorObject), + { + headers: { ...reqHeaders, 'x-nuxt-error': 'true' }, + redirect: 'manual', + }, + ).catch(() => null) // Fallback to static rendered error page if (!res) { - const { template } = import.meta.dev - // @ts-expect-error TODO: add legacy type support for subpath imports - ? await import('@nuxt/ui-templates/templates/error-dev.mjs') - // @ts-expect-error TODO: add legacy type support for subpath imports - : await import('@nuxt/ui-templates/templates/error-500.mjs') + const { template } = import.meta.dev ? await import('./error-dev') : await import('./error-500') if (import.meta.dev) { // TODO: Support `message` in template (errorObject as any).description = errorObject.message @@ -88,3 +84,70 @@ export default <NitroErrorHandler> async function errorhandler (error: H3Error, return send(event, html) } + +/** + * Nitro internal functions extracted from https://github.com/unjs/nitro/blob/main/src/runtime/internal/utils.ts + */ + +function isJsonRequest (event: H3Event) { + // If the client specifically requests HTML, then avoid classifying as JSON. + if (hasReqHeader(event, 'accept', 'text/html')) { + return false + } + return ( + hasReqHeader(event, 'accept', 'application/json') || + hasReqHeader(event, 'user-agent', 'curl/') || + hasReqHeader(event, 'user-agent', 'httpie/') || + hasReqHeader(event, 'sec-fetch-mode', 'cors') || + event.path.startsWith('/api/') || + event.path.endsWith('.json') + ) +} + +function hasReqHeader (event: H3Event, name: string, includes: string) { + const value = getRequestHeader(event, name) + return ( + value && typeof value === 'string' && value.toLowerCase().includes(includes) + ) +} + +function normalizeError (error: any) { + // temp fix for https://github.com/unjs/nitro/issues/759 + // TODO: investigate vercel-edge not using unenv pollyfill + const cwd = typeof process.cwd === 'function' ? process.cwd() : '/' + + // Hide details of unhandled/fatal errors in production + const hideDetails = !import.meta.dev && error.unhandled + + const stack = hideDetails && !import.meta.prerender + ? [] + : ((error.stack as string) || '') + .split('\n') + .splice(1) + .filter(line => line.includes('at ')) + .map((line) => { + const text = line + .replace(cwd + '/', './') + .replace('webpack:/', '') + .replace('file://', '') + .trim() + return { + text, + internal: + (line.includes('node_modules') && !line.includes('.cache')) || + line.includes('internal') || + line.includes('new Promise'), + } + }) + + const statusCode = error.statusCode || 500 + const statusMessage = error.statusMessage ?? (statusCode === 404 ? 'Not Found' : '') + const message = hideDetails ? 'internal server error' : (error.message || error.toString()) + + return { + stack, + statusCode, + statusMessage, + message, + } +} diff --git a/packages/nuxt/src/core/runtime/nitro/paths.ts b/packages/nuxt/src/core/runtime/nitro/paths.ts index 0d2a027687..ca9dc57eaf 100644 --- a/packages/nuxt/src/core/runtime/nitro/paths.ts +++ b/packages/nuxt/src/core/runtime/nitro/paths.ts @@ -1,20 +1,23 @@ -import { joinURL } from 'ufo' -import { useRuntimeConfig } from '#internal/nitro' +import { joinRelativeURL } from 'ufo' +import { useRuntimeConfig } from 'nitro/runtime' export function baseURL (): string { + // TODO: support passing event to `useRuntimeConfig` return useRuntimeConfig().app.baseURL } export function buildAssetsDir (): string { + // TODO: support passing event to `useRuntimeConfig` return useRuntimeConfig().app.buildAssetsDir as string } export function buildAssetsURL (...path: string[]): string { - return joinURL(publicAssetsURL(), buildAssetsDir(), ...path) + return joinRelativeURL(publicAssetsURL(), buildAssetsDir(), ...path) } export function publicAssetsURL (...path: string[]): string { + // TODO: support passing event to `useRuntimeConfig` const app = useRuntimeConfig().app const publicBase = app.cdnURL as string || app.baseURL - return path.length ? joinURL(publicBase, ...path) : publicBase + return path.length ? joinRelativeURL(publicBase, ...path) : publicBase } diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 5fb732d0ab..4a7037ae44 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -4,9 +4,10 @@ import { getPrefetchLinks, getPreloadLinks, getRequestDependencies, - renderResourceHeaders + renderResourceHeaders, } from 'vue-bundle-renderer/runtime' -import type { RenderResponse } from 'nitropack' +import type { Manifest as ClientManifest } from 'vue-bundle-renderer' +import type { RenderResponse } from 'nitro/types' import type { Manifest } from 'vite' import type { H3Event } from 'h3' import { appendResponseHeader, createError, getQuery, getResponseStatus, getResponseStatusText, readBody, writeEarlyHints } from 'h3' @@ -15,23 +16,23 @@ import { stringify, uneval } from 'devalue' import destr from 'destr' import { getQuery as getURLQuery, joinURL, withoutTrailingSlash } from 'ufo' import { renderToString as _renderToString } from 'vue/server-renderer' -import { hash } from 'ohash' -import { renderSSRHead } from '@unhead/ssr' -import type { HeadEntryOptions } from '@unhead/schema' +import { propsToString, renderSSRHead } from '@unhead/ssr' +import type { Head, HeadEntryOptions } from '@unhead/schema' import type { Link, Script, Style } from '@unhead/vue' -import { createServerHead } from '@unhead/vue' +import { createServerHead, resolveUnrefHeadInput } from '@unhead/vue' -import { defineRenderHandler, getRouteRules, useRuntimeConfig, useStorage } from '#internal/nitro' -import { useNitroApp } from '#internal/nitro/app' +import { defineRenderHandler, getRouteRules, useNitroApp, useRuntimeConfig, useStorage } from 'nitro/runtime' // @ts-expect-error virtual file import unheadPlugins from '#internal/unhead-plugins.mjs' -// eslint-disable-next-line import/no-restricted-paths +// @ts-expect-error virtual file +import { renderSSRHeadOptions } from '#internal/unhead.config.mjs' + import type { NuxtPayload, NuxtSSRContext } from '#app' // @ts-expect-error virtual file -import { appHead, appRootId, appRootTag } from '#internal/nuxt.config.mjs' +import { appHead, appId, appRootAttrs, appRootTag, appTeleportAttrs, appTeleportTag, componentIslands, multiApp } from '#internal/nuxt.config.mjs' // @ts-expect-error virtual file -import { buildAssetsURL, publicAssetsURL } from '#paths' +import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths' // @ts-expect-error private property consumed by vite-generated url helpers globalThis.__buildAssetsURL = buildAssetsURL @@ -53,6 +54,18 @@ export interface NuxtRenderHTMLContext { bodyAppend: string[] } +export interface NuxtIslandSlotResponse { + props: Array<unknown> + fallback?: string +} + +export interface NuxtIslandClientResponse { + html: string + props: unknown + chunk: string + slots?: Record<string, string> +} + export interface NuxtIslandContext { id?: string name: string @@ -62,38 +75,22 @@ export interface NuxtIslandContext { components: Record<string, Omit<NuxtIslandClientResponse, 'html'>> } -export interface NuxtIslandSlotResponse { - props: Array<unknown> - fallback?: string -} -export interface NuxtIslandClientResponse { - html: string - props: unknown - chunk: string -} - export interface NuxtIslandResponse { id?: string html: string - state: Record<string, any> - head: { - link: (Record<string, string>)[] - style: ({ innerHTML: string, key: string })[] - } + head: Head props?: Record<string, Record<string, any>> components?: Record<string, NuxtIslandClientResponse> slots?: Record<string, NuxtIslandSlotResponse> } export interface NuxtRenderResponse { - body: string, - statusCode: number, - statusMessage?: string, + body: string + statusCode: number + statusMessage?: string headers: Record<string, string> } -interface ClientManifest {} - // @ts-expect-error file will be produced after app build const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/server/client.manifest.mjs') .then(r => r.default || r) @@ -101,7 +98,7 @@ const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/ser const getEntryIds: () => Promise<string[]> = () => getClientManifest().then(r => Object.values(r).filter(r => // @ts-expect-error internal key set by CSS inlining configuration - r._globalCSS + r._globalCSS, ).map(r => r.src!)) // @ts-expect-error file will be produced after app build @@ -123,7 +120,7 @@ const getSSRRenderer = lazyCachedFunction(async () => { const options = { manifest, renderToString, - buildAssetsURL + buildAssetsURL, } // Create renderer const renderer = createRenderer(createSSRApp, options) @@ -135,7 +132,7 @@ const getSSRRenderer = lazyCachedFunction(async () => { if (import.meta.dev && process.env.NUXT_VITE_NODE_OPTIONS) { renderer.rendererContext.updateManifest(await getClientManifest()) } - return `<${appRootTag}${appRootId ? ` id="${appRootId}"` : ''}>${html}</${appRootTag}>` + return APP_ROOT_OPEN_TAG + html + APP_ROOT_CLOSE_TAG } return renderer @@ -147,36 +144,31 @@ const getSPARenderer = lazyCachedFunction(async () => { // @ts-expect-error virtual file const spaTemplate = await import('#spa-template').then(r => r.template).catch(() => '') + .then(r => APP_ROOT_OPEN_TAG + r + APP_ROOT_CLOSE_TAG) const options = { manifest, - renderToString: () => `<${appRootTag}${appRootId ? ` id="${appRootId}"` : ''}>${spaTemplate}</${appRootTag}>`, - buildAssetsURL + renderToString: () => spaTemplate, + buildAssetsURL, } // Create SPA renderer and cache the result for all requests const renderer = createRenderer(() => () => {}, options) const result = await renderer.renderToString({}) const renderToString = (ssrContext: NuxtSSRContext) => { - const config = useRuntimeConfig() + const config = useRuntimeConfig(ssrContext.event) ssrContext.modules = ssrContext.modules || new Set<string>() - ssrContext!.payload = { - _errors: {}, - serverRendered: false, - data: {}, - state: {}, - once: new Set<string>() - } + ssrContext.payload.serverRendered = false ssrContext.config = { public: config.public, - app: config.app + app: config.app, } return Promise.resolve(result) } return { rendererContext: renderer.rendererContext, - renderToString + renderToString, } }) @@ -184,21 +176,25 @@ const payloadCache = import.meta.prerender ? useStorage('internal:nuxt:prerender const islandCache = import.meta.prerender ? useStorage('internal:nuxt:prerender:island') : null const islandPropCache = import.meta.prerender ? useStorage('internal:nuxt:prerender:island-props') : null const sharedPrerenderPromises = import.meta.prerender && process.env.NUXT_SHARED_DATA ? new Map<string, Promise<any>>() : null -const sharedPrerenderCache = import.meta.prerender && process.env.NUXT_SHARED_DATA ? { - get <T = unknown>(key: string): Promise<T> { - if (sharedPrerenderPromises!.has(key)) { - return sharedPrerenderPromises!.get(key)! +const sharedPrerenderKeys = new Set<string>() +const sharedPrerenderCache = import.meta.prerender && process.env.NUXT_SHARED_DATA + ? { + get<T = unknown> (key: string): Promise<T> | undefined { + if (sharedPrerenderKeys.has(key)) { + return sharedPrerenderPromises!.get(key) ?? useStorage('internal:nuxt:prerender:shared').getItem(key) as Promise<T> + } + }, + async set<T> (key: string, value: Promise<T>): Promise<void> { + sharedPrerenderKeys.add(key) + sharedPrerenderPromises!.set(key, value) + useStorage('internal:nuxt:prerender:shared').setItem(key, await value as any) + // free up memory after the promise is resolved + .finally(() => sharedPrerenderPromises!.delete(key)) + }, } - return useStorage('internal:nuxt:prerender:shared').getItem(key) as Promise<T> - }, - async set <T>(key: string, value: Promise<T>) { - sharedPrerenderPromises!.set(key, value) - return useStorage('internal:nuxt:prerender:shared').setItem(key, await value as any) - // free up memory after the promise is resolved - .finally(() => sharedPrerenderPromises!.delete(key)) - }, -} : null + : null +const ISLAND_SUFFIX_RE = /\.json(\?.*)?$/ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { // TODO: Strict validation for url let url = event.path || '' @@ -206,8 +202,9 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { // rehydrate props from cache so we can rerender island if cache does not have it any more url = await islandPropCache!.getItem(event.path) as string } - url = url.substring('/__nuxt_island'.length + 1) || '' - const [componentName, hashId] = url.split('?')[0].replace(/\.json$/, '').split('_') + const componentParts = url.substring('/__nuxt_island'.length + 1).replace(ISLAND_SUFFIX_RE, '').split('_') + const hashId = componentParts.length > 1 ? componentParts.pop() : undefined + const componentName = componentParts.join('_') // TODO: Validate context const context = event.method === 'GET' ? getQuery(event) : await readBody(event) @@ -225,8 +222,15 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> { return ctx } -const PAYLOAD_URL_RE = process.env.NUXT_JSON_PAYLOADS ? /\/_payload(\.[a-zA-Z0-9]+)?.json(\?.*)?$/ : /\/_payload(\.[a-zA-Z0-9]+)?.js(\?.*)?$/ -const ROOT_NODE_REGEX = new RegExp(`^<${appRootTag}${appRootId ? ` id="${appRootId}"` : ''}>([\\s\\S]*)</${appRootTag}>$`) +const HAS_APP_TELEPORTS = !!(appTeleportTag && appTeleportAttrs.id) +const APP_TELEPORT_OPEN_TAG = HAS_APP_TELEPORTS ? `<${appTeleportTag}${propsToString(appTeleportAttrs)}>` : '' +const APP_TELEPORT_CLOSE_TAG = HAS_APP_TELEPORTS ? `</${appTeleportTag}>` : '' + +const APP_ROOT_OPEN_TAG = `<${appRootTag}${propsToString(appRootAttrs)}>` +const APP_ROOT_CLOSE_TAG = `</${appRootTag}>` + +const PAYLOAD_URL_RE = process.env.NUXT_JSON_PAYLOADS ? /\/_payload.json(\?.*)?$/ : /\/_payload.js(\?.*)?$/ +const ROOT_NODE_REGEX = new RegExp(`^<${appRootTag}[^>]*>([\\s\\S]*)<\\/${appRootTag}>$`) const PRERENDER_NO_SSR_ROUTES = new Set(['/index.html', '/200.html', '/404.html']) @@ -239,18 +243,18 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse : null if (ssrError && ssrError.statusCode) { - ssrError.statusCode = parseInt(ssrError.statusCode as any) + ssrError.statusCode = Number.parseInt(ssrError.statusCode as any) } if (ssrError && !('__unenv__' in event.node.req) /* allow internal fetch */) { throw createError({ statusCode: 404, - statusMessage: 'Page Not Found: /__nuxt_error' + statusMessage: 'Page Not Found: /__nuxt_error', }) } // Check for island component rendering - const isRenderingIsland = (process.env.NUXT_COMPONENT_ISLANDS as unknown as boolean && event.path.startsWith('/__nuxt_island')) + const isRenderingIsland = (componentIslands as unknown as boolean && event.path.startsWith('/__nuxt_island')) const islandContext = isRenderingIsland ? await getIslandContext(event) : undefined if (import.meta.prerender && islandContext && event.path && await islandCache!.hasItem(event.path)) { @@ -276,8 +280,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse const routeOptions = getRouteRules(event) const head = createServerHead({ - plugins: unheadPlugins + plugins: unheadPlugins, }) + // needed for hash hydration plugin to work const headEntryOptions: HeadEntryOptions = { mode: 'server' } if (!isRenderingIsland) { @@ -288,7 +293,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse const ssrContext: NuxtSSRContext = { url, event, - runtimeConfig: useRuntimeConfig() as NuxtSSRContext['runtimeConfig'], + runtimeConfig: useRuntimeConfig(event) as NuxtSSRContext['runtimeConfig'], noSSR: !!(process.env.NUXT_NO_SSR) || event.context.nuxt?.noSSR || @@ -298,11 +303,9 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse error: !!ssrError, nuxt: undefined!, /* NuxtApp */ payload: (ssrError ? { error: ssrError } : {}) as NuxtPayload, - _payloadReducers: {}, + _payloadReducers: Object.create(null), modules: new Set(), - set _registeredComponents(value) { this.modules = value }, - get _registeredComponents() { return this.modules }, - islandContext + islandContext, } if (import.meta.prerender && process.env.NUXT_SHARED_DATA) { @@ -311,7 +314,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse // Whether we are prerendering route const _PAYLOAD_EXTRACTION = import.meta.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !ssrContext.noSSR && !isRenderingIsland - const payloadURL = _PAYLOAD_EXTRACTION ? joinURL(useRuntimeConfig().app.baseURL, url, process.env.NUXT_JSON_PAYLOADS ? '_payload.json' : '_payload.js') : undefined + const payloadURL = _PAYLOAD_EXTRACTION ? joinURL(ssrContext.runtimeConfig.app.cdnURL || ssrContext.runtimeConfig.app.baseURL, url, process.env.NUXT_JSON_PAYLOADS ? '_payload.json' : '_payload.js') + '?' + ssrContext.runtimeConfig.app.buildId : undefined if (import.meta.prerender) { ssrContext.payload.prerenderedAt = Date.now() } @@ -322,10 +325,11 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse // Render 103 Early Hints if (process.env.NUXT_EARLY_HINTS && !isRenderingPayload && !import.meta.prerender) { const { link } = renderResourceHeaders({}, renderer.rendererContext) - writeEarlyHints(event, link) + if (link) { + writeEarlyHints(event, link) + } } - if (process.env.NUXT_INLINE_STYLES && !isRenderingIsland) { for (const id of await getEntryIds()) { ssrContext.modules!.add(id) @@ -376,22 +380,23 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse // Setup head const { styles, scripts } = getRequestDependencies(ssrContext, renderer.rendererContext) // 1.Extracted payload preloading - if (_PAYLOAD_EXTRACTION && !isRenderingIsland) { + if (_PAYLOAD_EXTRACTION && !NO_SCRIPTS && !isRenderingIsland) { head.push({ link: [ process.env.NUXT_JSON_PAYLOADS ? { rel: 'preload', as: 'fetch', crossorigin: 'anonymous', href: payloadURL } - : { rel: 'modulepreload', href: payloadURL } - ] + : { rel: 'modulepreload', crossorigin: '', href: payloadURL }, + ], }, headEntryOptions) } // 2. Styles - head.push({ style: inlinedStyles }) + if (inlinedStyles.length) { + head.push({ style: inlinedStyles }) + } if (!isRenderingIsland || import.meta.dev) { - const link = [] - for (const style in styles) { - const resource = styles[style] + const link: Link[] = [] + for (const resource of Object.values(styles)) { // Do not add links to resources that are inlined (vite v5+) if (import.meta.dev && 'inline' in getURLQuery(resource.file)) { continue @@ -401,35 +406,37 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse // - in dev mode when not rendering an island // - in dev mode when rendering an island and the file has scoped styles and is not a page if (!import.meta.dev || !isRenderingIsland || (resource.file.includes('scoped') && !resource.file.includes('pages/'))) { - link.push({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file) }) + link.push({ rel: 'stylesheet', href: renderer.rendererContext.buildAssetsURL(resource.file), crossorigin: '' }) } } - head.push({ link }, headEntryOptions) + if (link.length) { + head.push({ link }, headEntryOptions) + } } if (!NO_SCRIPTS && !isRenderingIsland) { // 3. Resource Hints // TODO: add priorities based on Capo head.push({ - link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[] + link: getPreloadLinks(ssrContext, renderer.rendererContext) as Link[], }, headEntryOptions) head.push({ - link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[] + link: getPrefetchLinks(ssrContext, renderer.rendererContext) as Link[], }, headEntryOptions) // 4. Payloads head.push({ script: _PAYLOAD_EXTRACTION ? process.env.NUXT_JSON_PAYLOADS - ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) + ? renderPayloadJsonScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) : renderPayloadScript({ ssrContext, data: splitPayload(ssrContext).initial, src: payloadURL }) : process.env.NUXT_JSON_PAYLOADS - ? renderPayloadJsonScript({ id: '__NUXT_DATA__', ssrContext, data: ssrContext.payload }) - : renderPayloadScript({ ssrContext, data: ssrContext.payload }) + ? renderPayloadJsonScript({ ssrContext, data: ssrContext.payload }) + : renderPayloadScript({ ssrContext, data: ssrContext.payload }), }, { ...headEntryOptions, // this should come before another end of body scripts tagPosition: 'bodyClose', - tagPriority: 'high' + tagPriority: 'high', }) } @@ -440,48 +447,40 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse type: resource.module ? 'module' : null, src: renderer.rendererContext.buildAssetsURL(resource.file), defer: resource.module ? null : true, - crossorigin: '' - })) + // if we are rendering script tag payloads that import an async payload + // we need to ensure this resolves before executing the Nuxt entry + tagPosition: (_PAYLOAD_EXTRACTION && !process.env.NUXT_JSON_PAYLOADS) ? 'bodyClose' : 'head', + crossorigin: '', + })), }, headEntryOptions) } // remove certain tags for nuxt islands - const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = await renderSSRHead(head) - - // Create render context - const htmlContext: NuxtRenderHTMLContext = { - island: isRenderingIsland, - htmlAttrs: htmlAttrs ? [htmlAttrs] : [], - head: normalizeChunks([headTags, ssrContext.styles]), - bodyAttrs: bodyAttrs ? [bodyAttrs] : [], - bodyPrepend: normalizeChunks([bodyTagsOpen, ssrContext.teleports?.body]), - body: [process.env.NUXT_COMPONENT_ISLANDS ? replaceIslandTeleports(ssrContext, _rendered.html) : _rendered.html], - bodyAppend: [bodyTags] - } - - // Allow hooking into the rendered result - await nitroApp.hooks.callHook('render:html', htmlContext, { event }) + const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = await renderSSRHead(head, renderSSRHeadOptions) // Response for component islands if (isRenderingIsland && islandContext) { - const islandHead: NuxtIslandResponse['head'] = { - link: [], - style: [] - } - for (const tag of await head.resolveTags()) { - if (tag.tag === 'link') { - islandHead.link.push({ key: 'island-link-' + hash(tag.props), ...tag.props }) - } else if (tag.tag === 'style' && tag.innerHTML) { - islandHead.style.push({ key: 'island-style-' + hash(tag.innerHTML), innerHTML: tag.innerHTML }) + const islandHead: Head = {} + for (const entry of head.headEntries()) { + for (const [key, value] of Object.entries(resolveUnrefHeadInput(entry.input) as Head)) { + const currentValue = islandHead[key as keyof Head] + if (Array.isArray(currentValue)) { + currentValue.push(...value) + } + islandHead[key as keyof Head] = value } } + + // TODO: remove for v4 + islandHead.link = islandHead.link || [] + islandHead.style = islandHead.style || [] + const islandResponse: NuxtIslandResponse = { id: islandContext.id, head: islandHead, - html: getServerComponentHTML(htmlContext.body), - state: ssrContext.payload.state, + html: getServerComponentHTML(_rendered.html), components: getClientIslandResponse(ssrContext), - slots: getSlotIslandResponse(ssrContext) + slots: getSlotIslandResponse(ssrContext), } await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext }) @@ -492,8 +491,8 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse statusMessage: getResponseStatusText(event), headers: { 'content-type': 'application/json;charset=utf-8', - 'x-powered-by': 'Nuxt' - } + 'x-powered-by': 'Nuxt', + }, } satisfies RenderResponse if (import.meta.prerender) { await islandCache!.setItem(`/__nuxt_island/${islandContext!.name}_${islandContext!.id}.json`, response) @@ -502,6 +501,23 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse return response } + // Create render context + const htmlContext: NuxtRenderHTMLContext = { + island: isRenderingIsland, + htmlAttrs: htmlAttrs ? [htmlAttrs] : [], + head: normalizeChunks([headTags]), + bodyAttrs: bodyAttrs ? [bodyAttrs] : [], + bodyPrepend: normalizeChunks([bodyTagsOpen, ssrContext.teleports?.body]), + body: [ + componentIslands ? replaceIslandTeleports(ssrContext, _rendered.html) : _rendered.html, + APP_TELEPORT_OPEN_TAG + (HAS_APP_TELEPORTS ? joinTags([ssrContext.teleports?.[`#${appTeleportAttrs.id}`]]) : '') + APP_TELEPORT_CLOSE_TAG, + ], + bodyAppend: [bodyTags], + } + + // Allow hooking into the rendered result + await nitroApp.hooks.callHook('render:html', htmlContext, { event }) + // Construct HTML response const response = { body: renderHTMLDocument(htmlContext), @@ -509,8 +525,8 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse statusMessage: getResponseStatusText(event), headers: { 'content-type': 'text/html;charset=utf-8', - 'x-powered-by': 'Nuxt' - } + 'x-powered-by': 'Nuxt', + }, } satisfies RenderResponse return response @@ -530,27 +546,28 @@ function normalizeChunks (chunks: (string | undefined)[]) { return chunks.filter(Boolean).map(i => i!.trim()) } -function joinTags (tags: string[]) { +function joinTags (tags: Array<string | undefined>) { return tags.join('') } function joinAttrs (chunks: string[]) { - return chunks.join(' ') + if (chunks.length === 0) { return '' } + return ' ' + chunks.join(' ') } function renderHTMLDocument (html: NuxtRenderHTMLContext) { - return '<!DOCTYPE html>' - + `<html${joinAttrs(html.htmlAttrs)}>` - + `<head>${joinTags(html.head)}</head>` - + `<body${joinAttrs(html.bodyAttrs)}>${joinTags(html.bodyPrepend)}${joinTags(html.body)}${joinTags(html.bodyAppend)}</body>` - + '</html>' + return '<!DOCTYPE html>' + + `<html${joinAttrs(html.htmlAttrs)}>` + + `<head>${joinTags(html.head)}</head>` + + `<body${joinAttrs(html.bodyAttrs)}>${joinTags(html.bodyPrepend)}${joinTags(html.body)}${joinTags(html.bodyAppend)}</body>` + + '</html>' } async function renderInlineStyles (usedModules: Set<string> | string[]): Promise<Style[]> { const styleMap = await getSSRStyles() const inlinedStyles = new Set<string>() for (const mod of usedModules) { - if (mod in styleMap) { + if (mod in styleMap && styleMap[mod]) { for (const style of await styleMap[mod]()) { inlinedStyles.add(style) } @@ -568,45 +585,56 @@ function renderPayloadResponse (ssrContext: NuxtSSRContext) { statusMessage: getResponseStatusText(ssrContext.event), headers: { 'content-type': process.env.NUXT_JSON_PAYLOADS ? 'application/json;charset=utf-8' : 'text/javascript;charset=utf-8', - 'x-powered-by': 'Nuxt' - } + 'x-powered-by': 'Nuxt', + }, } satisfies RenderResponse } -function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] { +function renderPayloadJsonScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] { const contents = opts.data ? stringify(opts.data, opts.ssrContext._payloadReducers) : '' const payload: Script = { - type: 'application/json', - id: opts.id, - innerHTML: contents, - 'data-ssr': !(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR) + 'type': 'application/json', + 'innerHTML': contents, + 'data-nuxt-data': appId, + 'data-ssr': !(process.env.NUXT_NO_SSR || opts.ssrContext.noSSR), + } + if (!multiApp) { + payload.id = '__NUXT_DATA__' } if (opts.src) { payload['data-src'] = opts.src } + const config = uneval(opts.ssrContext.config) return [ payload, { - innerHTML: `window.__NUXT__={};window.__NUXT__.config=${uneval(opts.ssrContext.config)}` - } + innerHTML: multiApp + ? `window.__NUXT__=window.__NUXT__||{};window.__NUXT__[${JSON.stringify(appId)}]={config:${config}}` + : `window.__NUXT__={};window.__NUXT__.config=${config}`, + }, ] } function renderPayloadScript (opts: { ssrContext: NuxtSSRContext, data?: any, src?: string }): Script[] { opts.data.config = opts.ssrContext.config const _PAYLOAD_EXTRACTION = import.meta.prerender && process.env.NUXT_PAYLOAD_EXTRACTION && !opts.ssrContext.noSSR + const nuxtData = devalue(opts.data) if (_PAYLOAD_EXTRACTION) { + const singleAppPayload = `import p from "${opts.src}";window.__NUXT__={...p,...(${nuxtData})}` + const multiAppPayload = `import p from "${opts.src}";window.__NUXT__=window.__NUXT__||{};window.__NUXT__[${JSON.stringify(appId)}]={...p,...(${nuxtData})}` return [ { type: 'module', - innerHTML: `import p from "${opts.src}";window.__NUXT__={...p,...(${devalue(opts.data)})}` - } + innerHTML: multiApp ? multiAppPayload : singleAppPayload, + }, ] } + const singleAppPayload = `window.__NUXT__=${nuxtData}` + const multiAppPayload = `window.__NUXT__=window.__NUXT__||{};window.__NUXT__[${JSON.stringify(appId)}]=${nuxtData}` return [ { - innerHTML: `window.__NUXT__=${devalue(opts.data)}` - } + innerHTML: multiApp ? multiAppPayload : singleAppPayload, + }, ] } @@ -614,46 +642,65 @@ function splitPayload (ssrContext: NuxtSSRContext) { const { data, prerenderedAt, ...initial } = ssrContext.payload return { initial: { ...initial, prerenderedAt }, - payload: { data, prerenderedAt } + payload: { data, prerenderedAt }, } } /** * remove the root node from the html body */ -function getServerComponentHTML (body: string[]): string { - const match = body[0].match(ROOT_NODE_REGEX) - return match ? match[1] : body[0] +function getServerComponentHTML (body: string): string { + const match = body.match(ROOT_NODE_REGEX) + return match?.[1] || body } const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/ const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/ +const SSR_CLIENT_SLOT_MARKER = /^island-slot=[^;]*;(.*)$/ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] { - if (!ssrContext.islandContext) { return {} } + if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.slots).length) { return undefined } const response: NuxtIslandResponse['slots'] = {} - for (const slot in ssrContext.islandContext.slots) { - response[slot] = { - ...ssrContext.islandContext.slots[slot], - fallback: ssrContext.teleports?.[`island-fallback=${slot}`] + for (const [name, slot] of Object.entries(ssrContext.islandContext.slots)) { + response[name] = { + ...slot, + fallback: ssrContext.teleports?.[`island-fallback=${name}`], } } return response } function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] { - if (!ssrContext.islandContext) { return {} } + if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.components).length) { return undefined } const response: NuxtIslandResponse['components'] = {} - for (const clientUid in ssrContext.islandContext.components) { - const html = ssrContext.teleports?.[clientUid] || '' + + for (const [clientUid, component] of Object.entries(ssrContext.islandContext.components)) { + // remove teleport anchor to avoid hydration issues + const html = ssrContext.teleports?.[clientUid]?.replaceAll('<!--teleport start anchor-->', '') || '' response[clientUid] = { - ...ssrContext.islandContext.components[clientUid], + ...component, html, + slots: getComponentSlotTeleport(ssrContext.teleports ?? {}), } } return response } +function getComponentSlotTeleport (teleports: Record<string, string>) { + const entries = Object.entries(teleports) + const slots: Record<string, string> = {} + + for (const [key, value] of entries) { + const match = key.match(SSR_CLIENT_SLOT_MARKER) + if (match) { + const [, slot] = match + if (!slot) { continue } + slots[slot] = value + } + } + return slots +} + function replaceIslandTeleports (ssrContext: NuxtSSRContext, html: string) { const { teleports, islandContext } = ssrContext @@ -663,7 +710,7 @@ function replaceIslandTeleports (ssrContext: NuxtSSRContext, html: string) { if (matchClientComp) { const [, uid, clientId] = matchClientComp if (!uid || !clientId) { continue } - html = html.replace(new RegExp(` data-island-component="${clientId}"[^>]*>`), (full) => { + html = html.replace(new RegExp(` data-island-uid="${uid}" data-island-component="${clientId}"[^>]*>`), (full) => { return full + teleports[key] }) continue diff --git a/packages/nuxt/src/core/schema.ts b/packages/nuxt/src/core/schema.ts index c0c18333ac..a2bf41d7dd 100644 --- a/packages/nuxt/src/core/schema.ts +++ b/packages/nuxt/src/core/schema.ts @@ -1,41 +1,34 @@ import { existsSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' -import { pathToFileURL } from 'node:url' -import { dirname, resolve } from 'pathe' -import chokidar from 'chokidar' -import { interopDefault } from 'mlly' +import { fileURLToPath } from 'node:url' +import { resolve } from 'pathe' +import { watch } from 'chokidar' import { defu } from 'defu' import { debounce } from 'perfect-debounce' -import { createResolver, defineNuxtModule, logger, tryResolveModule } from '@nuxt/kit' +import { createResolver, defineNuxtModule, importModule, logger, tryResolveModule } from '@nuxt/kit' import { generateTypes, - resolveSchema as resolveUntypedSchema + resolveSchema as resolveUntypedSchema, } from 'untyped' import type { Schema, SchemaDefinition } from 'untyped' import untypedPlugin from 'untyped/babel-plugin' -import jiti from 'jiti' +import { createJiti } from 'jiti' export default defineNuxtModule({ meta: { - name: 'nuxt-config-schema' + name: 'nuxt-config-schema', }, async setup (_, nuxt) { - if (!nuxt.options.experimental.configSchema) { - return - } const resolver = createResolver(import.meta.url) // Initialize untyped/jiti loader - const _resolveSchema = jiti(dirname(import.meta.url), { - esmResolve: true, - interopDefault: true, + const _resolveSchema = createJiti(fileURLToPath(import.meta.url), { cache: false, - requireCache: false, transformOptions: { babel: { - plugins: [untypedPlugin] - } - } + plugins: [untypedPlugin], + }, + }, }) // Register module types @@ -65,10 +58,10 @@ export default defineNuxtModule({ if (nuxt.options.experimental.watcher === 'parcel') { const watcherPath = await tryResolveModule('@parcel/watcher', [nuxt.options.rootDir, ...nuxt.options.modulesDir]) if (watcherPath) { - const { subscribe } = await import(pathToFileURL(watcherPath).href).then(interopDefault) as typeof import('@parcel/watcher') + const { subscribe } = await importModule<typeof import('@parcel/watcher')>(watcherPath) for (const layer of nuxt.options._layers) { const subscription = await subscribe(layer.config.rootDir, onChange, { - ignore: ['!nuxt.schema.*'] + ignore: ['!nuxt.schema.*'], }) nuxt.hook('close', () => subscription.unsubscribe()) } @@ -78,11 +71,11 @@ export default defineNuxtModule({ } const filesToWatch = await Promise.all(nuxt.options._layers.map(layer => - resolver.resolve(layer.config.rootDir, 'nuxt.schema.*') + resolver.resolve(layer.config.rootDir, 'nuxt.schema.*'), )) - const watcher = chokidar.watch(filesToWatch, { + const watcher = watch(filesToWatch, { ...nuxt.options.watchers.chokidar, - ignoreInitial: true + ignoreInitial: true, }) watcher.on('all', onChange) nuxt.hook('close', () => watcher.close()) @@ -103,12 +96,12 @@ export default defineNuxtModule({ let loadedConfig: SchemaDefinition try { // TODO: fix type for second argument of `import` - loadedConfig = await _resolveSchema.import(filePath, {}) as SchemaDefinition + loadedConfig = await _resolveSchema.import(filePath, { default: true }) as SchemaDefinition } catch (err) { logger.warn( 'Unable to load schema from', filePath, - err + err, ) continue } @@ -121,7 +114,7 @@ export default defineNuxtModule({ // Resolve and merge schemas const schemas = await Promise.all( - schemaDefs.map(schemaDef => resolveUntypedSchema(schemaDef)) + schemaDefs.map(schemaDef => resolveUntypedSchema(schemaDef)), ) // Merge after normalization @@ -140,13 +133,13 @@ export default defineNuxtModule({ await writeFile( resolve(nuxt.options.buildDir, 'schema/nuxt.schema.json'), JSON.stringify(schema, null, 2), - 'utf8' + 'utf8', ) const _types = generateTypes(schema, { addExport: true, interfaceName: 'NuxtCustomSchema', partial: true, - allowExtraKeys: false + allowExtraKeys: false, }) const types = _types + @@ -168,10 +161,10 @@ declare module 'nuxt/schema' { ` const typesPath = resolve( nuxt.options.buildDir, - 'schema/nuxt.schema.d.ts' + 'schema/nuxt.schema.d.ts', ) await writeFile(typesPath, types, 'utf8') await nuxt.hooks.callHook('schema:written') } - } + }, }) diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index 55ee480e13..32e2c49b24 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { genArrayFromRaw, genDynamicImport, genExport, genImport, genObjectFromRawEntries, genSafeVariableName, genString } from 'knitwork' -import { isAbsolute, join, relative, resolve } from 'pathe' +import { join, relative, resolve } from 'pathe' import type { JSValue } from 'untyped' import { generateTypes, resolveSchema } from 'untyped' import escapeRE from 'escape-string-regexp' @@ -8,6 +8,8 @@ import { hash } from 'ohash' import { camelCase } from 'scule' import { filename } from 'pathe/utils' import type { NuxtTemplate } from 'nuxt/schema' +import type { Nitro } from 'nitro/types' + import { annotatePlugins, checkForCircularDependencies } from './app' export const vueShim: NuxtTemplate = { @@ -22,37 +24,37 @@ export const vueShim: NuxtTemplate = { ' import { DefineComponent } from \'vue\'', ' const component: DefineComponent<{}, {}, any>', ' export default component', - '}' + '}', ].join('\n') - } + }, } // TODO: Use an alias export const appComponentTemplate: NuxtTemplate = { filename: 'app-component.mjs', - getContents: ctx => genExport(ctx.app.mainComponent!, ['default']) + getContents: ctx => genExport(ctx.app.mainComponent!, ['default']), } // TODO: Use an alias export const rootComponentTemplate: NuxtTemplate = { filename: 'root-component.mjs', // TODO: fix upstream in vite - this ensures that vite generates a module graph for islands // but should not be necessary (and has a warmup performance cost). See https://github.com/nuxt/nuxt/pull/24584. - getContents: ctx => (ctx.nuxt.options.dev ? "import '#build/components.islands.mjs';\n" : '') + genExport(ctx.app.rootComponent!, ['default']) + getContents: ctx => (ctx.nuxt.options.dev ? 'import \'#build/components.islands.mjs\';\n' : '') + genExport(ctx.app.rootComponent!, ['default']), } // TODO: Use an alias export const errorComponentTemplate: NuxtTemplate = { filename: 'error-component.mjs', - getContents: ctx => genExport(ctx.app.errorComponent!, ['default']) + getContents: ctx => genExport(ctx.app.errorComponent!, ['default']), } // TODO: Use an alias export const testComponentWrapperTemplate: NuxtTemplate = { filename: 'test-component-wrapper.mjs', - getContents: (ctx) => genExport(resolve(ctx.nuxt.options.appDir, 'components/test-component-wrapper'), ['default']) + getContents: ctx => genExport(resolve(ctx.nuxt.options.appDir, 'components/test-component-wrapper'), ['default']), } export const cssTemplate: NuxtTemplate = { filename: 'css.mjs', - getContents: ctx => ctx.nuxt.options.css.map(i => genImport(i)).join('\n') + getContents: ctx => ctx.nuxt.options.css.map(i => genImport(i)).join('\n'), } export const clientPluginTemplate: NuxtTemplate = { @@ -70,9 +72,9 @@ export const clientPluginTemplate: NuxtTemplate = { } return [ ...imports, - `export default ${genArrayFromRaw(exports)}` + `export default ${genArrayFromRaw(exports)}`, ].join('\n') - } + }, } export const serverPluginTemplate: NuxtTemplate = { @@ -90,42 +92,75 @@ export const serverPluginTemplate: NuxtTemplate = { } return [ ...imports, - `export default ${genArrayFromRaw(exports)}` + `export default ${genArrayFromRaw(exports)}`, ].join('\n') - } + }, } +const TS_RE = /\.[cm]?tsx?$/ + export const pluginsDeclaration: NuxtTemplate = { filename: 'types/plugins.d.ts', - getContents: async (ctx) => { - const EXTENSION_RE = new RegExp(`(?<=\\w)(${ctx.nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g') + getContents: async ({ nuxt, app }) => { + const EXTENSION_RE = new RegExp(`(?<=\\w)(${nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g') + + const typesDir = join(nuxt.options.buildDir, 'types') const tsImports: string[] = [] - for (const p of ctx.app.plugins) { - const sources = [p.src, p.src.replace(EXTENSION_RE, '.d.ts')] - if (!isAbsolute(p.src)) { - tsImports.push(p.src.replace(EXTENSION_RE, '')) - } else if (ctx.app.templates.some(t => t.write && t.dst && sources.includes(t.dst)) || sources.some(s => existsSync(s))) { - tsImports.push(relative(join(ctx.nuxt.options.buildDir, 'types'), p.src).replace(EXTENSION_RE, '')) - } + const pluginNames: string[] = [] + + function exists (path: string) { + return app.templates.some(t => t.write && path === t.dst) || existsSync(path) } - const pluginsName = (await annotatePlugins(ctx.nuxt, ctx.app.plugins)).filter(p => p.name).map(p => `'${p.name}'`) + for (const plugin of await annotatePlugins(nuxt, app.plugins)) { + if (plugin.name) { + pluginNames.push(`'${plugin.name}'`) + } + + const pluginPath = resolve(typesDir, plugin.src) + const relativePath = relative(typesDir, pluginPath) + + const correspondingDeclaration = pluginPath.replace(/\.(?<letter>[cm])?jsx?$/, '.d.$<letter>ts') + // if `.d.ts` file exists alongside a `.js` plugin, or if `.d.mts` file exists alongside a `.mjs` plugin, we can use the entire path + if (correspondingDeclaration !== pluginPath && exists(correspondingDeclaration)) { + tsImports.push(relativePath) + continue + } + + const incorrectDeclaration = pluginPath.replace(/\.[cm]jsx?$/, '.d.ts') + // if `.d.ts` file exists, but plugin is `.mjs`, add `.js` extension to the import + // to hotfix issue until ecosystem updates to `@nuxt/module-builder@>=0.8.0` + if (incorrectDeclaration !== pluginPath && exists(incorrectDeclaration)) { + tsImports.push(relativePath.replace(/\.[cm](jsx?)$/, '.$1')) + continue + } + + // if there is no declaration we only want to remove the extension if it's a TypeScript file + if (exists(pluginPath)) { + if (TS_RE.test(pluginPath)) { + tsImports.push(relativePath.replace(EXTENSION_RE, '')) + continue + } + tsImports.push(relativePath) + } + + // No declaration found that TypeScript can use + } return `// Generated by Nuxt' import type { Plugin } from '#app' type Decorate<T extends Record<string, any>> = { [K in keyof T as K extends string ? \`$\${K}\` : never]: T[K] } -type IsAny<T> = 0 extends 1 & T ? true : false -type InjectionType<A extends Plugin> = IsAny<A> extends true ? unknown : A extends Plugin<infer T> ? Decorate<T> : unknown +type InjectionType<A extends Plugin> = A extends {default: Plugin<infer T>} ? Decorate<T> : unknown -type NuxtAppInjections = \n ${tsImports.map(p => `InjectionType<typeof ${genDynamicImport(p, { wrapper: false })}.default>`).join(' &\n ')} +type NuxtAppInjections = \n ${tsImports.map(p => `InjectionType<typeof ${genDynamicImport(p, { wrapper: false })}>`).join(' &\n ')} declare module '#app' { interface NuxtApp extends NuxtAppInjections { } interface NuxtAppLiterals { - pluginName: ${pluginsName.join(' | ')} + pluginName: ${pluginNames.join(' | ')} } } @@ -135,35 +170,83 @@ declare module 'vue' { export { } ` - } + }, } const adHocModules = ['router', 'pages', 'imports', 'meta', 'components', 'nuxt-config-schema'] export const schemaTemplate: NuxtTemplate = { filename: 'types/schema.d.ts', getContents: async ({ nuxt }) => { - const moduleInfo = nuxt.options._installedModules.map(m => ({ - ...m.meta, - importName: m.entryPath || m.meta?.name - })).filter(m => m.configKey && m.name && !adHocModules.includes(m.name)) - const relativeRoot = relative(resolve(nuxt.options.buildDir, 'types'), nuxt.options.rootDir) const getImportName = (name: string) => (name[0] === '.' ? './' + join(relativeRoot, name) : name).replace(/\.\w+$/, '') - const modules = moduleInfo.map(meta => [genString(meta.configKey), getImportName(meta.importName)]) + + const modules = nuxt.options._installedModules + .filter(m => m.meta && m.meta.configKey && m.meta.name && !adHocModules.includes(m.meta.name)) + .map(m => [genString(m.meta.configKey), getImportName(m.entryPath || m.meta.name), m] as const) + const privateRuntimeConfig = Object.create(null) for (const key in nuxt.options.runtimeConfig) { if (key !== 'public') { privateRuntimeConfig[key] = nuxt.options.runtimeConfig[key] } } + + const moduleOptionsInterface = (options: { addJSDocTags: boolean, unresolved: boolean }) => [ + ...modules.flatMap(([configKey, importName, mod]) => { + let link: string | undefined + + // If it's not a local module, provide a link based on its name + if (!mod.meta?.rawPath) { + link = `https://www.npmjs.com/package/${importName}` + } + + if (typeof mod.meta?.docs === 'string') { + link = mod.meta.docs + } else if (mod.meta?.repository) { + if (typeof mod.meta.repository === 'string') { + link = mod.meta.repository + } else if (typeof mod.meta.repository.url === 'string') { + link = mod.meta.repository.url + } + if (link) { + if (link.startsWith('git+')) { + link = link.replace(/^git\+/, '') + } + if (!link.startsWith('http')) { + link = 'https://github.com/' + link + } + } + } + + return [ + ` /**`, + ` * Configuration for \`${importName}\``, + ...options.addJSDocTags && link ? [` * @see ${link}`] : [], + ` */`, + ` [${configKey}]${options.unresolved ? '?' : ''}: typeof ${genDynamicImport(importName, { wrapper: false })}.default extends NuxtModule<infer O> ? ${options.unresolved ? 'Partial<O>' : 'O'} : Record<string, any>`, + ] + }), + modules.length > 0 && options.unresolved ? ` modules?: (undefined | null | false | NuxtModule<any> | string | [NuxtModule | string, Record<string, any>] | ${modules.map(([configKey, importName, mod]) => `[${genString(mod.meta?.rawPath || importName)}, Exclude<NuxtConfig[${configKey}], boolean>]`).join(' | ')})[],` : '', + ].filter(Boolean) + return [ - "import { NuxtModule, RuntimeConfig } from 'nuxt/schema'", - "declare module 'nuxt/schema' {", + 'import { NuxtModule, RuntimeConfig } from \'@nuxt/schema\'', + 'declare module \'@nuxt/schema\' {', + ' interface NuxtOptions {', + ...moduleOptionsInterface({ addJSDocTags: false, unresolved: false }), + ' }', ' interface NuxtConfig {', - ...modules.map(([configKey, importName]) => - ` [${configKey}]?: typeof ${genDynamicImport(importName, { wrapper: false })}.default extends NuxtModule<infer O> ? Partial<O> : Record<string, any>` - ), - modules.length > 0 ? ` modules?: (undefined | null | false | NuxtModule | string | [NuxtModule | string, Record<string, any>] | ${modules.map(([configKey, importName]) => `[${genString(importName)}, Exclude<NuxtConfig[${configKey}], boolean>]`).join(' | ')})[],` : '', + // TypeScript will duplicate the jsdoc tags if we augment it twice + // So here we only generate tags for `nuxt/schema` + ...moduleOptionsInterface({ addJSDocTags: false, unresolved: true }), + ' }', + '}', + 'declare module \'nuxt/schema\' {', + ' interface NuxtOptions {', + ...moduleOptionsInterface({ addJSDocTags: true, unresolved: false }), + ' }', + ' interface NuxtConfig {', + ...moduleOptionsInterface({ addJSDocTags: true, unresolved: true }), ' }', generateTypes(await resolveSchema(privateRuntimeConfig as Record<string, JSValue>), { @@ -171,7 +254,7 @@ export const schemaTemplate: NuxtTemplate = { addExport: false, addDefaults: false, allowExtraKeys: false, - indentation: 2 + indentation: 2, }), generateTypes(await resolveSchema(nuxt.options.runtimeConfig.public as Record<string, JSValue>), { @@ -179,16 +262,16 @@ export const schemaTemplate: NuxtTemplate = { addExport: false, addDefaults: false, allowExtraKeys: false, - indentation: 2 + indentation: 2, }), '}', `declare module 'vue' { interface ComponentCustomProperties { $config: RuntimeConfig } - }` + }`, ].join('\n') - } + }, } // Add layouts template @@ -196,12 +279,12 @@ export const layoutTemplate: NuxtTemplate = { filename: 'layouts.mjs', getContents ({ app }) { const layoutsObject = genObjectFromRawEntries(Object.values(app.layouts).map(({ name, file }) => { - return [name, genDynamicImport(file, { interopDefault: true })] + return [name, genDynamicImport(file)] })) return [ - `export default ${layoutsObject}` + `export default ${layoutsObject}`, ].join('\n') - } + }, } // Add middleware template @@ -214,22 +297,23 @@ export const middlewareTemplate: NuxtTemplate = { return [ ...globalMiddleware.map(mw => genImport(mw.path, genSafeVariableName(mw.name))), `export const globalMiddleware = ${genArrayFromRaw(globalMiddleware.map(mw => genSafeVariableName(mw.name)))}`, - `export const namedMiddleware = ${namedMiddlewareObject}` + `export const namedMiddleware = ${namedMiddlewareObject}`, ].join('\n') - } + }, } export const nitroSchemaTemplate: NuxtTemplate = { filename: 'types/nitro-nuxt.d.ts', - getContents: () => { + getContents () { return /* typescript */` /// <reference path="./schema.d.ts" /> import type { RuntimeConfig } from 'nuxt/schema' import type { H3Event } from 'h3' -import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/dist/core/runtime/nitro/renderer' +import type { LogObject } from 'consola' +import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/app' -declare module 'nitropack' { +declare module 'nitro/types' { interface NitroRuntimeConfigApp { buildAssetsDir: string cdnURL: string @@ -242,30 +326,63 @@ declare module 'nitropack' { interface NitroRouteRules { ssr?: boolean experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> + 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> + 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> + } +} +declare module 'nitropack/types' { + interface NitroRuntimeConfigApp { + buildAssetsDir: string + cdnURL: string + } + interface NitroRuntimeConfig extends RuntimeConfig {} + interface NitroRouteConfig { + ssr?: boolean + experimentalNoScripts?: boolean + } + interface NitroRouteRules { + ssr?: boolean + experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> + } + interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> } } ` - } + }, } export const clientConfigTemplate: NuxtTemplate = { filename: 'nitro.client.mjs', - getContents: () => ` -export const useRuntimeConfig = () => window?.__NUXT__?.config || {} -` + getContents: ({ nuxt }) => { + const appId = JSON.stringify(nuxt.options.appId) + return [ + 'export const useRuntimeConfig = () => ', + (!nuxt.options.future.multiApp + ? 'window?.__NUXT__?.config || window?.useNuxtApp?.().payload?.config' + : `window?.__NUXT__?.[${appId}]?.config || window?.useNuxtApp?.(${appId}).payload?.config`) + || {}, + ].join('\n') + }, } export const appConfigDeclarationTemplate: NuxtTemplate = { filename: 'types/app.config.d.ts', - getContents: ({ app, nuxt }) => { + getContents ({ app, nuxt }) { + const typesDir = join(nuxt.options.buildDir, 'types') + const configPaths = app.configs.map(path => relative(typesDir, path).replace(/\b\.\w+$/g, '')) + return ` import type { CustomAppConfig } from 'nuxt/schema' import type { Defu } from 'defu' -${app.configs.map((id: string, index: number) => `import ${`cfg${index}`} from ${JSON.stringify(id.replace(/(?<=\w)\.\w+$/g, ''))}`).join('\n')} +${configPaths.map((id: string, index: number) => `import ${`cfg${index}`} from ${JSON.stringify(id)}`).join('\n')} declare const inlineConfig = ${JSON.stringify(nuxt.options.appConfig, null, 2)} type ResolvedAppConfig = Defu<typeof inlineConfig, [${app.configs.map((_id: string, index: number) => `typeof cfg${index}`).join(', ')}]> @@ -292,7 +409,7 @@ declare module '@nuxt/schema' { interface AppConfig extends MergedAppConfig<ResolvedAppConfig, CustomAppConfig> { } } ` - } + }, } export const appConfigTemplate: NuxtTemplate = { @@ -300,31 +417,34 @@ export const appConfigTemplate: NuxtTemplate = { write: true, getContents ({ app, nuxt }) { return ` -import { updateAppConfig } from '#app/config' import { defuFn } from 'defu' const inlineConfig = ${JSON.stringify(nuxt.options.appConfig, null, 2)} +/** client **/ +import { updateAppConfig } from '#app/config' + // Vite - webpack is handled directly in #app/config -if (import.meta.hot) { +if (import.meta.dev && !import.meta.nitro && import.meta.hot) { import.meta.hot.accept((newModule) => { updateAppConfig(newModule.default) }) } +/** client-end **/ ${app.configs.map((id: string, index: number) => `import ${`cfg${index}`} from ${JSON.stringify(id)}`).join('\n')} export default /*@__PURE__*/ defuFn(${app.configs.map((_id: string, index: number) => `cfg${index}`).concat(['inlineConfig']).join(', ')}) ` - } + }, } export const publicPathTemplate: NuxtTemplate = { filename: 'paths.mjs', getContents ({ nuxt }) { return [ - 'import { joinURL } from \'ufo\'', - !nuxt.options.dev && 'import { useRuntimeConfig } from \'#internal/nitro\'', + 'import { joinRelativeURL } from \'ufo\'', + !nuxt.options.dev && 'import { useRuntimeConfig } from \'nitro/runtime\'', nuxt.options.dev ? `const appConfig = ${JSON.stringify(nuxt.options.app)}` @@ -333,20 +453,20 @@ export const publicPathTemplate: NuxtTemplate = { 'export const baseURL = () => appConfig.baseURL', 'export const buildAssetsDir = () => appConfig.buildAssetsDir', - 'export const buildAssetsURL = (...path) => joinURL(publicAssetsURL(), buildAssetsDir(), ...path)', + 'export const buildAssetsURL = (...path) => joinRelativeURL(publicAssetsURL(), buildAssetsDir(), ...path)', 'export const publicAssetsURL = (...path) => {', ' const publicBase = appConfig.cdnURL || appConfig.baseURL', - ' return path.length ? joinURL(publicBase, ...path) : publicBase', + ' return path.length ? joinRelativeURL(publicBase, ...path) : publicBase', '}', // On server these are registered directly in packages/nuxt/src/core/runtime/nitro/renderer.ts 'if (import.meta.client) {', ' globalThis.__buildAssetsURL = buildAssetsURL', ' globalThis.__publicAssetsURL = publicAssetsURL', - '}' + '}', ].filter(Boolean).join('\n') - } + }, } export const dollarFetchTemplate: NuxtTemplate = { @@ -354,14 +474,14 @@ export const dollarFetchTemplate: NuxtTemplate = { getContents () { return [ 'import { $fetch } from \'ofetch\'', - "import { baseURL } from '#build/paths.mjs'", + 'import { baseURL } from \'#internal/nuxt/paths\'', 'if (!globalThis.$fetch) {', ' globalThis.$fetch = $fetch.create({', ' baseURL: baseURL()', ' })', - '}' + '}', ].join('\n') - } + }, } // Allow direct access to specific exposed nuxt.config @@ -371,24 +491,59 @@ export const nuxtConfigTemplate: NuxtTemplate = { const fetchDefaults = { ...ctx.nuxt.options.experimental.defaults.useFetch, baseURL: undefined, - headers: undefined + headers: undefined, } + const shouldEnableComponentIslands = ctx.nuxt.options.experimental.componentIslands && ( + ctx.nuxt.options.dev || ctx.nuxt.options.experimental.componentIslands !== 'auto' || ctx.app.pages?.some(p => p.mode === 'server') || ctx.app.components?.some(c => c.mode === 'server' && !ctx.app.components.some(other => other.pascalName === c.pascalName && other.mode === 'client')) + ) return [ ...Object.entries(ctx.nuxt.options.app).map(([k, v]) => `export const ${camelCase('app-' + k)} = ${JSON.stringify(v)}`), `export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`, - `export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`, + `export const componentIslands = ${shouldEnableComponentIslands}`, `export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`, `export const cookieStore = ${!!ctx.nuxt.options.experimental.cookieStore}`, `export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`, `export const remoteComponentIslands = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.remoteIsland}`, - `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.selectiveClient}`, + `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && Boolean(ctx.nuxt.options.experimental.componentIslands.selectiveClient)}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, + `export const devLogs = ${JSON.stringify(ctx.nuxt.options.features.devLogs)}`, `export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`, `export const asyncDataDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.useAsyncData)}`, `export const fetchDefaults = ${JSON.stringify(fetchDefaults)}`, - `export const vueAppRootContainer = ${ctx.nuxt.options.app.rootId ? `'#${ctx.nuxt.options.app.rootId}'` : `'body > ${ctx.nuxt.options.app.rootTag}'`}`, - `export const viewTransition = ${ctx.nuxt.options.experimental.viewTransition}` + `export const vueAppRootContainer = ${ctx.nuxt.options.app.rootAttrs.id ? `'#${ctx.nuxt.options.app.rootAttrs.id}'` : `'body > ${ctx.nuxt.options.app.rootTag}'`}`, + `export const viewTransition = ${ctx.nuxt.options.experimental.viewTransition}`, + `export const appId = ${JSON.stringify(ctx.nuxt.options.appId)}`, + `export const outdatedBuildInterval = ${ctx.nuxt.options.experimental.checkOutdatedBuildInterval}`, + `export const multiApp = ${!!ctx.nuxt.options.future.multiApp}`, + `export const chunkErrorEvent = ${ctx.nuxt.options.experimental.emitRouteChunkError ? ctx.nuxt.options.builder === '@nuxt/vite-builder' ? '"vite:preloadError"' : '"nuxt:preloadError"' : 'false'}`, + `export const crawlLinks = ${!!((ctx.nuxt as any)._nitro as Nitro).options.prerender.crawlLinks}`, ].join('\n\n') - } + }, +} + +const TYPE_FILENAME_RE = /\.([cm])?[jt]s$/ +const DECLARATION_RE = /\.d\.[cm]?ts$/ +export const buildTypeTemplate: NuxtTemplate = { + filename: 'types/build.d.ts', + getContents ({ app }) { + let declarations = '' + + for (const file of app.templates) { + if (file.write || !file.filename || DECLARATION_RE.test(file.filename)) { + continue + } + + if (TYPE_FILENAME_RE.test(file.filename)) { + const typeFilenames = new Set([file.filename.replace(TYPE_FILENAME_RE, '.d.$1ts'), file.filename.replace(TYPE_FILENAME_RE, '.d.ts')]) + if (app.templates.some(f => f.filename && typeFilenames.has(f.filename))) { + continue + } + } + + declarations += 'declare module ' + JSON.stringify(join('#build', file.filename)) + ';\n' + } + + return declarations + }, } diff --git a/packages/nuxt/src/core/utils/index.ts b/packages/nuxt/src/core/utils/index.ts index 8321e2fd01..2653f9a993 100644 --- a/packages/nuxt/src/core/utils/index.ts +++ b/packages/nuxt/src/core/utils/index.ts @@ -1,5 +1,5 @@ -export * from './names' -export * from './plugins' +export { getNameFromPath, hasSuffix, resolveComponentNameSegments } from './names' +export { getLoader, isJS, isVue } from './plugins' export function uniqueBy<T, K extends keyof T> (arr: T[], key: K) { if (arr.length < 2) { diff --git a/packages/nuxt/src/core/utils/names.ts b/packages/nuxt/src/core/utils/names.ts index 64121414c5..df6732c96f 100644 --- a/packages/nuxt/src/core/utils/names.ts +++ b/packages/nuxt/src/core/utils/names.ts @@ -13,12 +13,12 @@ export function getNameFromPath (path: string, relativeTo?: string) { } export function hasSuffix (path: string, suffix: string) { - return basename(path).replace(extname(path), '').endsWith(suffix) + return basename(path, extname(path)).endsWith(suffix) } export function resolveComponentNameSegments (fileName: string, prefixParts: string[]) { /** - * Array of fileName parts splitted by case, / or - + * Array of fileName parts split by case, / or - * @example third-component -> ['third', 'component'] * @example AwesomeComponent -> ['Awesome', 'Component'] */ @@ -28,11 +28,12 @@ export function resolveComponentNameSegments (fileName: string, prefixParts: str let index = prefixParts.length - 1 const matchedSuffix: string[] = [] while (index >= 0) { - matchedSuffix.unshift(...splitByCase(prefixParts[index] || '').map(p => p.toLowerCase())) + const prefixPart = prefixParts[index]! + matchedSuffix.unshift(...splitByCase(prefixPart).map(p => p.toLowerCase())) const matchedSuffixContent = matchedSuffix.join('/') if ((fileNamePartsContent === matchedSuffixContent || fileNamePartsContent.startsWith(matchedSuffixContent + '/')) || // e.g Item/Item/Item.vue -> Item - (prefixParts[index].toLowerCase() === fileNamePartsContent && + (prefixPart.toLowerCase() === fileNamePartsContent && prefixParts[index + 1] && prefixParts[index] === prefixParts[index + 1])) { componentNameParts.length = index diff --git a/packages/nuxt/src/core/utils/plugins.ts b/packages/nuxt/src/core/utils/plugins.ts index 54f214b8a3..1e80ddb07d 100644 --- a/packages/nuxt/src/core/utils/plugins.ts +++ b/packages/nuxt/src/core/utils/plugins.ts @@ -1,4 +1,5 @@ import { pathToFileURL } from 'node:url' +import { extname } from 'pathe' import { parseQuery, parseURL } from 'ufo' export function isVue (id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) { @@ -34,10 +35,22 @@ export function isVue (id: string, opts: { type?: Array<'template' | 'script' | return true } -const JS_RE = /\.((c|m)?j|t)sx?$/ +const JS_RE = /\.(?:[cm]?j|t)sx?$/ export function isJS (id: string) { // JavaScript files const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) return JS_RE.test(pathname) } + +export function getLoader (id: string): 'vue' | 'ts' | 'tsx' | null { + const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + const ext = extname(pathname) + if (ext === '.vue') { + return 'vue' + } + if (!JS_RE.test(ext)) { + return null + } + return ext.endsWith('x') ? 'tsx' : 'ts' +} diff --git a/packages/nuxt/src/core/utils/types.ts b/packages/nuxt/src/core/utils/types.ts new file mode 100644 index 0000000000..23069fd4f9 --- /dev/null +++ b/packages/nuxt/src/core/utils/types.ts @@ -0,0 +1,17 @@ +import { resolvePackageJSON } from 'pkg-types' +import { resolvePath as _resolvePath } from 'mlly' +import { dirname } from 'pathe' +import { tryUseNuxt } from '@nuxt/kit' + +export async function resolveTypePath (path: string, subpath: string, searchPaths = tryUseNuxt()?.options.modulesDir) { + try { + const r = await _resolvePath(path, { url: searchPaths, conditions: ['types', 'import', 'require'] }) + if (subpath) { + return r.replace(/(?:\.d)?\.[mc]?[jt]s$/, '') + } + const rootPath = await resolvePackageJSON(r) + return dirname(rootPath) + } catch { + return null + } +} diff --git a/packages/nuxt/src/head/module.ts b/packages/nuxt/src/head/module.ts index 5964ecc527..520091428b 100644 --- a/packages/nuxt/src/head/module.ts +++ b/packages/nuxt/src/head/module.ts @@ -1,12 +1,14 @@ import { resolve } from 'pathe' import { addComponent, addImportsSources, addPlugin, addTemplate, defineNuxtModule, tryResolveModule } from '@nuxt/kit' +import type { NuxtOptions } from '@nuxt/schema' import { distDir } from '../dirs' const components = ['NoScript', 'Link', 'Base', 'Title', 'Meta', 'Style', 'Head', 'Html', 'Body'] -export default defineNuxtModule({ +export default defineNuxtModule<NuxtOptions['unhead']>({ meta: { - name: 'meta' + name: 'meta', + configKey: 'unhead', }, async setup (options, nuxt) { const runtimeDir = resolve(distDir, 'head/runtime') @@ -24,14 +26,16 @@ export default defineNuxtModule({ // built-in that we do not expect the user to override priority: 10, // kebab case version of these tags is not valid - kebabName: componentName + kebabName: componentName, }) } // allow @unhead/vue server composables to be tree-shaken from the client bundle - nuxt.options.optimization.treeShake.composables.client['@unhead/vue'] = [ - 'useServerHead', 'useServerSeoMeta', 'useServerHeadSafe' - ] + if (!nuxt.options.dev) { + nuxt.options.optimization.treeShake.composables.client['@unhead/vue'] = [ + 'useServerHead', 'useServerSeoMeta', 'useServerHeadSafe', + ] + } addImportsSources({ from: '@unhead/vue', @@ -43,17 +47,12 @@ export default defineNuxtModule({ 'useHeadSafe', 'useServerHead', 'useServerSeoMeta', - 'useServerHeadSafe' - ] + 'useServerHeadSafe', + ], }) // Opt-out feature allowing dependencies using @vueuse/head to work const unheadVue = await tryResolveModule('@unhead/vue', nuxt.options.modulesDir) || '@unhead/vue' - if (nuxt.options.experimental.polyfillVueUseHead) { - // backwards compatibility - nuxt.options.alias['@vueuse/head'] = unheadVue - addPlugin({ src: resolve(runtimeDir, 'plugins/vueuse-head-polyfill') }) - } addTemplate({ filename: 'unhead-plugins.mjs', @@ -63,15 +62,25 @@ export default defineNuxtModule({ } return `import { CapoPlugin } from ${JSON.stringify(unheadVue)}; export default import.meta.server ? [CapoPlugin({ track: true })] : [];` - } + }, + }) + + addTemplate({ + filename: 'unhead.config.mjs', + getContents () { + return [ + `export const renderSSRHeadOptions = ${JSON.stringify(options.renderSSRHeadOptions || {})}`, + ].join('\n') + }, }) // template is only exposed in nuxt context, expose in nitro context as well nuxt.hooks.hook('nitro:config', (config) => { config.virtual!['#internal/unhead-plugins.mjs'] = () => nuxt.vfs['#build/unhead-plugins'] + config.virtual!['#internal/unhead.config.mjs'] = () => nuxt.vfs['#build/unhead.config'] }) // Add library-specific plugin addPlugin({ src: resolve(runtimeDir, 'plugins/unhead') }) - } + }, }) diff --git a/packages/nuxt/src/head/runtime/components.ts b/packages/nuxt/src/head/runtime/components.ts index 3ed7a1ee06..5913471dde 100644 --- a/packages/nuxt/src/head/runtime/components.ts +++ b/packages/nuxt/src/head/runtime/components.ts @@ -1,4 +1,3 @@ -/* eslint-disable vue/multi-word-component-names */ import { defineComponent } from 'vue' import type { PropType, SetupContext } from 'vue' import { useHead } from '@unhead/vue' @@ -9,7 +8,7 @@ import type { LinkRelationship, Props, ReferrerPolicy, - Target + Target, } from './types' const removeUndefinedProps = (props: Props) => { @@ -17,7 +16,7 @@ const removeUndefinedProps = (props: Props) => { for (const key in props) { const value = props[key] if (value !== undefined) { - filteredProps[key] = value; + filteredProps[key] = value } } return filteredProps @@ -33,24 +32,24 @@ const globalProps = { autocapitalize: String, autofocus: { type: Boolean, - default: undefined + default: undefined, }, class: [String, Object, Array], contenteditable: { type: Boolean, - default: undefined + default: undefined, }, contextmenu: String, dir: String, draggable: { type: Boolean, - default: undefined + default: undefined, }, enterkeyhint: String, exportparts: String, hidden: { type: Boolean, - default: undefined + default: undefined, }, id: String, inputmode: String, @@ -66,12 +65,12 @@ const globalProps = { slot: String, spellcheck: { type: Boolean, - default: undefined + default: undefined, }, style: String, tabindex: String, title: String, - translate: String + translate: String, } // <noscript> @@ -82,26 +81,26 @@ export const NoScript = defineComponent({ ...globalProps, title: String, body: Boolean, - renderPriority: [String, Number] + renderPriority: [String, Number], }, setup: setupForUseMeta((props, { slots }) => { const noscript = { ...props } - const textContent = (slots.default?.() || []) - .filter(({ children }) => children) - .map(({ children }) => children) - .join('') + const slotVnodes = slots.default?.() + const textContent = slotVnodes + ? slotVnodes.filter(({ children }) => children).map(({ children }) => children).join('') + : '' if (textContent) { noscript.children = textContent } return { - noscript: [noscript] + noscript: [noscript], } - }) + }), }) // <link> export const Link = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Link', inheritAttrs: false, props: { @@ -118,7 +117,7 @@ export const Link = defineComponent({ media: String, prefetch: { type: Boolean, - default: undefined + default: undefined, }, referrerpolicy: String as PropType<ReferrerPolicy>, rel: String as PropType<LinkRelationship>, @@ -130,55 +129,55 @@ export const Link = defineComponent({ /** @deprecated **/ target: String as PropType<Target>, body: Boolean, - renderPriority: [String, Number] + renderPriority: [String, Number], }, setup: setupForUseMeta(link => ({ - link: [link] - })) + link: [link], + })), }) // <base> export const Base = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Base', inheritAttrs: false, props: { ...globalProps, href: String, - target: String as PropType<Target> + target: String as PropType<Target>, }, setup: setupForUseMeta(base => ({ - base - })) + base, + })), }) // <title> export const Title = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Title', inheritAttrs: false, setup: setupForUseMeta((_, { slots }) => { if (import.meta.dev) { const defaultSlot = slots.default?.() - if (defaultSlot && (defaultSlot.length > 1 || typeof defaultSlot[0].children !== 'string')) { + if (defaultSlot && (defaultSlot.length > 1 || (defaultSlot[0] && typeof defaultSlot[0].children !== 'string'))) { console.error('<Title> can take only one string in its default slot.') } return { - title: defaultSlot?.[0]?.children || null + title: defaultSlot?.[0]?.children || null, } } return { - title: slots.default?.()?.[0]?.children || null + title: slots.default?.()?.[0]?.children || null, } - }) + }), }) // <meta> export const Meta = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Meta', inheritAttrs: false, props: { @@ -188,7 +187,7 @@ export const Meta = defineComponent({ httpEquiv: String as PropType<HTTPEquiv>, name: String, body: Boolean, - renderPriority: [String, Number] + renderPriority: [String, Number], }, setup: setupForUseMeta((props) => { const meta = { ...props } @@ -198,14 +197,14 @@ export const Meta = defineComponent({ delete meta.httpEquiv } return { - meta: [meta] + meta: [meta], } - }) + }), }) // <style> export const Style = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Style', inheritAttrs: false, props: { @@ -217,10 +216,10 @@ export const Style = defineComponent({ /** @deprecated **/ scoped: { type: Boolean, - default: undefined + default: undefined, }, body: Boolean, - renderPriority: [String, Number] + renderPriority: [String, Number], }, setup: setupForUseMeta((props, { slots }) => { const style = { ...props } @@ -232,22 +231,22 @@ export const Style = defineComponent({ style.children = textContent } return { - style: [style] + style: [style], } - }) + }), }) // <head> export const Head = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Head', inheritAttrs: false, - setup: (_props, ctx) => () => ctx.slots.default?.() + setup: (_props, ctx) => () => ctx.slots.default?.(), }) // <html> export const Html = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Html', inheritAttrs: false, props: { @@ -255,19 +254,19 @@ export const Html = defineComponent({ manifest: String, version: String, xmlns: String, - renderPriority: [String, Number] + renderPriority: [String, Number], }, - setup: setupForUseMeta(htmlAttrs => ({ htmlAttrs }), true) + setup: setupForUseMeta(htmlAttrs => ({ htmlAttrs }), true), }) // <body> export const Body = defineComponent({ - // eslint-disable-next-line vue/no-reserved-component-names + name: 'Body', inheritAttrs: false, props: { ...globalProps, - renderPriority: [String, Number] + renderPriority: [String, Number], }, - setup: setupForUseMeta(bodyAttrs => ({ bodyAttrs }), true) + setup: setupForUseMeta(bodyAttrs => ({ bodyAttrs }), true), }) diff --git a/packages/nuxt/src/head/runtime/plugins/unhead.ts b/packages/nuxt/src/head/runtime/plugins/unhead.ts index 956e2dd1f6..2a9c7a88b7 100644 --- a/packages/nuxt/src/head/runtime/plugins/unhead.ts +++ b/packages/nuxt/src/head/runtime/plugins/unhead.ts @@ -12,12 +12,12 @@ export default defineNuxtPlugin({ const head = import.meta.server ? nuxtApp.ssrContext!.head : createClientHead({ - plugins: unheadPlugins + plugins: unheadPlugins, }) // allow useHead to be used outside a Vue context but within a Nuxt context setHeadInjectionHandler( // need a fresh instance of the nuxt app to avoid parallel requests interfering with each other - () => useNuxtApp().vueApp._context.provides.usehead + () => useNuxtApp().vueApp._context.provides.usehead, ) // nuxt.config appHead is set server-side within the renderer nuxtApp.vueApp.use(head) @@ -41,5 +41,5 @@ export default defineNuxtPlugin({ // unpause the DOM once the mount suspense is resolved nuxtApp.hooks.hook('app:suspense:resolve', syncHead) } - } + }, }) diff --git a/packages/nuxt/src/head/runtime/plugins/vueuse-head-polyfill.ts b/packages/nuxt/src/head/runtime/plugins/vueuse-head-polyfill.ts deleted file mode 100644 index 83141d77df..0000000000 --- a/packages/nuxt/src/head/runtime/plugins/vueuse-head-polyfill.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { polyfillAsVueUseHead } from '@unhead/vue/polyfill' -import { defineNuxtPlugin } from '#app/nuxt' - -export default defineNuxtPlugin({ - name: 'nuxt:vueuse-head-polyfill', - setup (nuxtApp) { - // avoid breaking ecosystem dependencies using low-level @vueuse/head APIs - polyfillAsVueUseHead(nuxtApp.vueApp._context.provides.usehead) - } -}) diff --git a/packages/nuxt/src/imports/module.ts b/packages/nuxt/src/imports/module.ts index 175d8627b8..af728fb6db 100644 --- a/packages/nuxt/src/imports/module.ts +++ b/packages/nuxt/src/imports/module.ts @@ -1,30 +1,36 @@ -import { addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, isIgnored, logger, resolveAlias, tryResolveModule, updateTemplates, useNuxt } from '@nuxt/kit' +import { existsSync } from 'node:fs' +import { addBuildPlugin, addTemplate, addTypeTemplate, defineNuxtModule, isIgnored, logger, resolveAlias, tryResolveModule, updateTemplates, useNuxt } from '@nuxt/kit' import { isAbsolute, join, normalize, relative, resolve } from 'pathe' import type { Import, Unimport } from 'unimport' -import { createUnimport, scanDirExports } from 'unimport' -import type { ImportPresetWithDeprecation, ImportsOptions } from 'nuxt/schema' +import { createUnimport, scanDirExports, toExports } from 'unimport' +import type { ImportPresetWithDeprecation, ImportsOptions, ResolvedNuxtTemplate } from 'nuxt/schema' +import escapeRE from 'escape-string-regexp' import { lookupNodeModuleSubpath, parseNodeModulePath } from 'mlly' +import { isDirectory } from '../utils' import { TransformPlugin } from './transform' import { defaultPresets } from './presets' export default defineNuxtModule<Partial<ImportsOptions>>({ meta: { name: 'imports', - configKey: 'imports' + configKey: 'imports', }, - defaults: { + defaults: nuxt => ({ autoImport: true, + scan: true, presets: defaultPresets, global: false, imports: [], dirs: [], transform: { - include: [], - exclude: undefined + include: [ + new RegExp('^' + escapeRE(nuxt.options.buildDir)), + ], + exclude: undefined, }, - virtualImports: ['#imports'] - }, + virtualImports: ['#imports'], + }), async setup (options, nuxt) { // TODO: fix sharing of defaults between invocations of modules const presets = JSON.parse(JSON.stringify(options.presets)) as ImportPresetWithDeprecation[] @@ -37,72 +43,94 @@ export default defineNuxtModule<Partial<ImportsOptions>>({ // Create a context to share state between module internals const ctx = createUnimport({ + injectAtEnd: true, ...options, addons: { vueTemplate: options.autoImport, - ...options.addons + ...options.addons, }, - presets + presets, }) await nuxt.callHook('imports:context', ctx) // composables/ dirs from all layers let composablesDirs: string[] = [] - for (const layer of nuxt.options._layers) { - composablesDirs.push(resolve(layer.config.srcDir, 'composables')) - composablesDirs.push(resolve(layer.config.srcDir, 'utils')) - for (const dir of (layer.config.imports?.dirs ?? [])) { - if (!dir) { + if (options.scan) { + for (const layer of nuxt.options._layers) { + // Layer disabled scanning for itself + if (layer.config?.imports?.scan === false) { continue } - composablesDirs.push(resolve(layer.config.srcDir, dir)) + composablesDirs.push(resolve(layer.config.srcDir, 'composables')) + composablesDirs.push(resolve(layer.config.srcDir, 'utils')) + for (const dir of (layer.config.imports?.dirs ?? [])) { + if (!dir) { + continue + } + composablesDirs.push(resolve(layer.config.srcDir, dir)) + } } + + await nuxt.callHook('imports:dirs', composablesDirs) + composablesDirs = composablesDirs.map(dir => normalize(dir)) + + // Restart nuxt when composable directories are added/removed + nuxt.hook('builder:watch', (event, relativePath) => { + if (!['addDir', 'unlinkDir'].includes(event)) { return } + + const path = resolve(nuxt.options.srcDir, relativePath) + if (composablesDirs.includes(path)) { + logger.info(`Directory \`${relativePath}/\` ${event === 'addDir' ? 'created' : 'removed'}`) + return nuxt.callHook('restart') + } + }) } - await nuxt.callHook('imports:dirs', composablesDirs) - composablesDirs = composablesDirs.map(dir => normalize(dir)) - - // Restart nuxt when composable directories are added/removed - nuxt.hook('builder:watch', (event, relativePath) => { - if (!['addDir', 'unlinkDir'].includes(event)) { return } - - const path = resolve(nuxt.options.srcDir, relativePath) - if (composablesDirs.includes(path)) { - logger.info(`Directory \`${relativePath}/\` ${event === 'addDir' ? 'created' : 'removed'}`) - return nuxt.callHook('restart') - } - }) - // Support for importing from '#imports' addTemplate({ filename: 'imports.mjs', - getContents: async () => await ctx.toExports() + '\nif (import.meta.dev) { console.warn("[nuxt] `#imports` should be transformed with real imports. There seems to be something wrong with the imports plugin.") }' + getContents: async () => toExports(await ctx.getImports()) + '\nif (import.meta.dev) { console.warn("[nuxt] `#imports` should be transformed with real imports. There seems to be something wrong with the imports plugin.") }', }) nuxt.options.alias['#imports'] = join(nuxt.options.buildDir, 'imports') // Transform to inject imports in production mode - addVitePlugin(() => TransformPlugin.vite({ ctx, options, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client })) - addWebpackPlugin(() => TransformPlugin.webpack({ ctx, options, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client })) + addBuildPlugin(TransformPlugin({ ctx, options, sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client })) const priorities = nuxt.options._layers.map((layer, i) => [layer.config.srcDir, -i] as const).sort(([a], [b]) => b.length - a.length) + function isImportsTemplate (template: ResolvedNuxtTemplate) { + return [ + '/types/imports.d.ts', + '/imports.d.ts', + '/imports.mjs', + ].some(i => template.filename.endsWith(i)) + } + const regenerateImports = async () => { await ctx.modifyDynamicImports(async (imports) => { // Clear old imports imports.length = 0 - // Scan `composables/` - const composableImports = await scanDirExports(composablesDirs, { - fileFilter: file => !isIgnored(file) - }) - for (const i of composableImports) { - i.priority = i.priority || priorities.find(([dir]) => i.from.startsWith(dir))?.[1] + + // Scan for `composables/` and `utils/` directories + if (options.scan) { + const scannedImports = await scanDirExports(composablesDirs, { + fileFilter: file => !isIgnored(file), + }) + for (const i of scannedImports) { + i.priority = i.priority || priorities.find(([dir]) => i.from.startsWith(dir))?.[1] + } + imports.push(...scannedImports) } - imports.push(...composableImports) + // Modules extending await nuxt.callHook('imports:extend', imports) return imports }) + + await updateTemplates({ + filter: isImportsTemplate, + }) } await regenerateImports() @@ -110,46 +138,37 @@ export default defineNuxtModule<Partial<ImportsOptions>>({ // Generate types addDeclarationTemplates(ctx, options) - // Add generated types to `nuxt.d.ts` - nuxt.hook('prepare:types', ({ references }) => { - references.push({ path: resolve(nuxt.options.buildDir, 'types/imports.d.ts') }) - references.push({ path: resolve(nuxt.options.buildDir, 'imports.d.ts') }) - }) - // Watch composables/ directory - const templates = [ - 'types/imports.d.ts', - 'imports.d.ts', - 'imports.mjs' - ] nuxt.hook('builder:watch', async (_, relativePath) => { const path = resolve(nuxt.options.srcDir, relativePath) - if (composablesDirs.some(dir => dir === path || path.startsWith(dir + '/'))) { - await updateTemplates({ - filter: template => templates.includes(template.filename) - }) + if (options.scan && composablesDirs.some(dir => dir === path || path.startsWith(dir + '/'))) { + await regenerateImports() } }) - nuxt.hook('app:templatesGenerated', async () => { - await regenerateImports() + // Watch for template generation + nuxt.hook('app:templatesGenerated', async (_app, templates) => { + // Only regenerate when non-imports templates are updated + if (templates.some(t => !isImportsTemplate(t))) { + await regenerateImports() + } }) - } + }, }) function addDeclarationTemplates (ctx: Unimport, options: Partial<ImportsOptions>) { const nuxt = useNuxt() - // Remove file extension for benefit of TypeScript - const stripExtension = (path: string) => path.replace(/\.[a-z]+$/, '') - const resolvedImportPathMap = new Map<string, string>() const r = ({ from }: Import) => resolvedImportPathMap.get(from) - async function cacheImportPaths(imports: Import[]) { + const SUPPORTED_EXTENSION_RE = new RegExp(`\\.(${nuxt.options.extensions.map(i => i.replace('.', '')).join('|')})$`) + + async function cacheImportPaths (imports: Import[]) { const importSource = Array.from(new Set(imports.map(i => i.from))) + // skip relative import paths for node_modules that are explicitly installed await Promise.all(importSource.map(async (from) => { - if (resolvedImportPathMap.has(from)) { + if (resolvedImportPathMap.has(from) || nuxt._dependencies?.has(from)) { return } let path = resolveAlias(from) @@ -158,26 +177,32 @@ function addDeclarationTemplates (ctx: Unimport, options: Partial<ImportsOptions if (!r) { return r } const { dir, name } = parseNodeModulePath(r) + if (name && nuxt._dependencies?.has(name)) { return from } + if (!dir || !name) { return r } const subpath = await lookupNodeModuleSubpath(r) return join(dir, name, subpath || '') }) ?? path } + + if (existsSync(path) && !(await isDirectory(path))) { + path = path.replace(SUPPORTED_EXTENSION_RE, '') + } + if (isAbsolute(path)) { path = relative(join(nuxt.options.buildDir, 'types'), path) } - path = stripExtension(path) resolvedImportPathMap.set(from, path) })) } - addTemplate({ + addTypeTemplate({ filename: 'imports.d.ts', - getContents: () => ctx.toExports(nuxt.options.buildDir, true) + getContents: async ({ nuxt }) => toExports(await ctx.getImports(), nuxt.options.buildDir, true), }) - addTemplate({ + addTypeTemplate({ filename: 'types/imports.d.ts', getContents: async () => { const imports = await ctx.getImports() @@ -187,6 +212,6 @@ function addDeclarationTemplates (ctx: Unimport, options: Partial<ImportsOptions ? await ctx.generateTypeDeclarations({ resolvePath: r }) : '// Implicit auto importing is disabled, you can use explicitly import from `#imports` instead.' ) - } + }, }) } diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 79096eb0a0..913448cc86 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -7,110 +7,145 @@ const commonPresets: InlinePreset[] = [ from: 'vue-demi', imports: [ 'isVue2', - 'isVue3' - ] - }) + 'isVue3', + ], + }), ] const granularAppPresets: InlinePreset[] = [ { from: '#app/components/nuxt-link', - imports: ['defineNuxtLink'] + imports: ['defineNuxtLink'], }, { imports: ['useNuxtApp', 'tryUseNuxtApp', 'defineNuxtPlugin', 'definePayloadPlugin', 'useRuntimeConfig', 'defineAppConfig'], - from: '#app/nuxt' + from: '#app/nuxt', }, { imports: ['requestIdleCallback', 'cancelIdleCallback'], - from: '#app/compat/idle-callback' + from: '#app/compat/idle-callback', }, { imports: ['setInterval'], - from: '#app/compat/interval' + from: '#app/compat/interval', }, { imports: ['useAppConfig', 'updateAppConfig'], - from: '#app/config' + from: '#app/config', }, { imports: ['defineNuxtComponent'], - from: '#app/composables/component' + from: '#app/composables/component', }, { imports: ['useAsyncData', 'useLazyAsyncData', 'useNuxtData', 'refreshNuxtData', 'clearNuxtData'], - from: '#app/composables/asyncData' + from: '#app/composables/asyncData', }, { imports: ['useHydration'], - from: '#app/composables/hydrate' + from: '#app/composables/hydrate', }, { imports: ['callOnce'], - from: '#app/composables/once' + from: '#app/composables/once', }, { imports: ['useState', 'clearNuxtState'], - from: '#app/composables/state' + from: '#app/composables/state', }, { imports: ['clearError', 'createError', 'isNuxtError', 'showError', 'useError'], - from: '#app/composables/error' + from: '#app/composables/error', }, { imports: ['useFetch', 'useLazyFetch'], - from: '#app/composables/fetch' + from: '#app/composables/fetch', }, { imports: ['useCookie', 'refreshCookie'], - from: '#app/composables/cookie' + from: '#app/composables/cookie', }, { - imports: ['prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'], - from: '#app/composables/ssr' + imports: ['onPrehydrate', 'prerenderRoutes', 'useRequestHeader', 'useRequestHeaders', 'useRequestEvent', 'useRequestFetch', 'setResponseStatus'], + from: '#app/composables/ssr', }, { imports: ['onNuxtReady'], - from: '#app/composables/ready' + from: '#app/composables/ready', }, { imports: ['preloadComponents', 'prefetchComponents', 'preloadRouteComponents'], - from: '#app/composables/preload' + from: '#app/composables/preload', }, { imports: ['abortNavigation', 'addRouteMiddleware', 'defineNuxtRouteMiddleware', 'setPageLayout', 'navigateTo', 'useRoute', 'useRouter'], - from: '#app/composables/router' + from: '#app/composables/router', }, { imports: ['isPrerendered', 'loadPayload', 'preloadPayload', 'definePayloadReducer', 'definePayloadReviver'], - from: '#app/composables/payload' + from: '#app/composables/payload', }, { imports: ['useLoadingIndicator'], - from: '#app/composables/loading-indicator' + from: '#app/composables/loading-indicator', }, { imports: ['getAppManifest', 'getRouteRules'], - from: '#app/composables/manifest' + from: '#app/composables/manifest', }, { imports: ['reloadNuxtApp'], - from: '#app/composables/chunk' + from: '#app/composables/chunk', }, { imports: ['useRequestURL'], - from: '#app/composables/url' + from: '#app/composables/url', }, { - imports: ['useId'], - from: '#app/composables/id' - } + imports: ['usePreviewMode'], + from: '#app/composables/preview', + }, + { + imports: ['useRouteAnnouncer'], + from: '#app/composables/route-announcer', + }, ] +export const scriptsStubsPreset = { + imports: [ + 'useScriptTriggerConsent', + 'useScriptEventPage', + 'useScriptTriggerElement', + 'useScript', + 'useScriptGoogleAnalytics', + 'useScriptPlausibleAnalytics', + 'useScriptCrisp', + 'useScriptClarity', + 'useScriptCloudflareWebAnalytics', + 'useScriptFathomAnalytics', + 'useScriptMatomoAnalytics', + 'useScriptGoogleTagManager', + 'useScriptGoogleAdsense', + 'useScriptSegment', + 'useScriptMetaPixel', + 'useScriptXPixel', + 'useScriptIntercom', + 'useScriptHotjar', + 'useScriptStripe', + 'useScriptLemonSqueezy', + 'useScriptVimeoPlayer', + 'useScriptYouTubePlayer', + 'useScriptGoogleMaps', + 'useScriptNpm', + ], + priority: -1, + from: '#app/composables/script-stubs', +} satisfies InlinePreset + // This is a separate preset as we'll swap these out for import from `vue-router` itself in `pages` module const routerPreset = defineUnimportPreset({ imports: ['onBeforeRouteLeave', 'onBeforeRouteUpdate'], - from: '#app/composables/router' + from: '#app/composables/router', }) // vue @@ -191,8 +226,12 @@ const vuePreset = defineUnimportPreset({ 'useCssModule', 'useCssVars', 'useSlots', - 'useTransitionState' - ] + 'useTransitionState', + 'useId', + 'useTemplateRef', + 'useShadowRoot', + 'useCssVars', + ], }) const vueTypesPreset = defineUnimportPreset({ @@ -209,8 +248,8 @@ const vueTypesPreset = defineUnimportPreset({ 'Ref', 'MaybeRef', 'MaybeRefOrGetter', - 'VNode' - ] + 'VNode', + ], }) export const defaultPresets: InlinePreset[] = [ @@ -218,5 +257,5 @@ export const defaultPresets: InlinePreset[] = [ ...granularAppPresets, routerPreset, vuePreset, - vueTypesPreset + vueTypesPreset, ] diff --git a/packages/nuxt/src/imports/transform.ts b/packages/nuxt/src/imports/transform.ts index 5a82bad059..a155f18cd0 100644 --- a/packages/nuxt/src/imports/transform.ts +++ b/packages/nuxt/src/imports/transform.ts @@ -1,13 +1,14 @@ import { createUnplugin } from 'unplugin' import type { Unimport } from 'unimport' import { normalize } from 'pathe' +import { tryUseNuxt } from '@nuxt/kit' import type { ImportsOptions } from 'nuxt/schema' import { isJS, isVue } from '../core/utils' const NODE_MODULES_RE = /[\\/]node_modules[\\/]/ const IMPORTS_RE = /(['"])#imports\1/ -export const TransformPlugin = createUnplugin(({ ctx, options, sourcemap }: { ctx: Unimport, options: Partial<ImportsOptions>, sourcemap?: boolean }) => { +export const TransformPlugin = ({ ctx, options, sourcemap }: { ctx: Unimport, options: Partial<ImportsOptions>, sourcemap?: boolean }) => createUnplugin(() => { return { name: 'nuxt:imports-transform', enforce: 'post', @@ -37,15 +38,19 @@ export const TransformPlugin = createUnplugin(({ ctx, options, sourcemap }: { ct return } - const { s } = await ctx.injectImports(code, id, { autoImport: options.autoImport && !isNodeModule }) + const { s, imports } = await ctx.injectImports(code, id, { autoImport: options.autoImport && !isNodeModule }) + if (imports.some(i => i.from === '#app/composables/script-stubs') && tryUseNuxt()?.options.test === false) { + import('../core/features').then(({ installNuxtModule }) => installNuxtModule('@nuxt/scripts')) + } + if (s.hasChanged()) { return { code: s.toString(), map: sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/index.ts b/packages/nuxt/src/index.ts index 4940b0db94..966e4d8c8c 100644 --- a/packages/nuxt/src/index.ts +++ b/packages/nuxt/src/index.ts @@ -1,2 +1,2 @@ -export * from './core/nuxt' -export * from './core/builder' +export { createNuxt, loadNuxt } from './core/nuxt' +export { build } from './core/builder' diff --git a/packages/nuxt/src/pages/build.d.ts b/packages/nuxt/src/pages/build.d.ts new file mode 100644 index 0000000000..4a17fca4e5 --- /dev/null +++ b/packages/nuxt/src/pages/build.d.ts @@ -0,0 +1,6 @@ +declare module '#build/router.options' { + import type { RouterOptions } from '@nuxt/schema' + + const _default: RouterOptions + export default _default +} diff --git a/packages/nuxt/src/pages/module.ts b/packages/nuxt/src/pages/module.ts index bd6ae3b69d..3cb562dc88 100644 --- a/packages/nuxt/src/pages/module.ts +++ b/packages/nuxt/src/pages/module.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync } from 'node:fs' import { mkdir, readFile } from 'node:fs/promises' -import { addBuildPlugin, addComponent, addPlugin, addTemplate, addTypeTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, findPath, logger, updateTemplates, useNitro } from '@nuxt/kit' +import { addBuildPlugin, addComponent, addPlugin, addTemplate, addTypeTemplate, defineNuxtModule, findPath, logger, resolvePath, updateTemplates, useNitro } from '@nuxt/kit' import { dirname, join, relative, resolve } from 'pathe' import { genImport, genObjectFromRawEntries, genString } from 'knitwork' import { joinURL } from 'ufo' @@ -8,36 +8,46 @@ import type { Nuxt, NuxtApp, NuxtPage } from 'nuxt/schema' import { createRoutesContext } from 'unplugin-vue-router' import { resolveOptions } from 'unplugin-vue-router/options' import type { EditableTreeNode, Options as TypedRouterOptions } from 'unplugin-vue-router' +import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3' -import type { NitroRouteConfig } from 'nitropack' +import type { NitroRouteConfig } from 'nitro/types' import { defu } from 'defu' import { distDir } from '../dirs' -import { normalizeRoutes, resolvePagesRoutes } from './utils' +import { resolveTypePath } from '../core/utils/types' +import { normalizeRoutes, resolvePagesRoutes, resolveRoutePaths } from './utils' import { extractRouteRules, getMappedPages } from './route-rules' -import type { PageMetaPluginOptions } from './plugins/page-meta' import { PageMetaPlugin } from './plugins/page-meta' import { RouteInjectionPlugin } from './plugins/route-injection' -const OPTIONAL_PARAM_RE = /^\/?:.*(\?|\(\.\*\)\*)$/ +const OPTIONAL_PARAM_RE = /^\/?:.*(?:\?|\(\.\*\)\*)$/ export default defineNuxtModule({ meta: { - name: 'pages' + name: 'pages', }, async setup (_options, nuxt) { const useExperimentalTypedPages = nuxt.options.experimental.typedPages const runtimeDir = resolve(distDir, 'pages/runtime') const pagesDirs = nuxt.options._layers.map( - layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages') + layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages'), ) + nuxt.options.alias['#vue-router'] = 'vue-router' + const routerPath = await resolveTypePath('vue-router', '', nuxt.options.modulesDir) || 'vue-router' + nuxt.hook('prepare:types', ({ tsConfig }) => { + tsConfig.compilerOptions ||= {} + tsConfig.compilerOptions.paths ||= {} + tsConfig.compilerOptions.paths['#vue-router'] = [routerPath] + delete tsConfig.compilerOptions.paths['#vue-router/*'] + }) + async function resolveRouterOptions () { const context = { - files: [] as Array<{ path: string, optional?: boolean }> + files: [] as Array<{ path: string, optional?: boolean }>, } for (const layer of nuxt.options._layers) { - const path = await findPath(resolve(layer.config.srcDir, 'app/router.options')) + const path = await findPath(resolve(layer.config.srcDir, layer.config.dir?.app || 'app', 'router.options')) if (path) { context.files.unshift({ path }) } } @@ -64,8 +74,12 @@ export default defineNuxtModule({ } const pages = await resolvePagesRoutes() - await nuxt.callHook('pages:extend', pages) - if (pages.length) { return true } + if (pages.length) { + if (nuxt.apps.default) { + nuxt.apps.default.pages = pages + } + return true + } return false } @@ -78,15 +92,18 @@ export default defineNuxtModule({ nuxt.hook('app:templates', async (app) => { app.pages = await resolvePagesRoutes() - await nuxt.callHook('pages:extend', app.pages) + + if (!nuxt.options.ssr && app.pages.some(p => p.mode === 'server')) { + logger.warn('Using server pages with `ssr: false` is not supported with auto-detected component islands. Set `experimental.componentIslands` to `true`.') + } }) // Restart Nuxt when pages dir is added or removed const restartPaths = nuxt.options._layers.flatMap((layer) => { const pagesDir = (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages' return [ - join(layer.config.srcDir || layer.cwd, 'app/router.options.ts'), - join(layer.config.srcDir || layer.cwd, pagesDir) + resolve(layer.config.srcDir || layer.cwd, layer.config.dir?.app || 'app', 'router.options.ts'), + resolve(layer.config.srcDir || layer.cwd, pagesDir), ] }) @@ -101,37 +118,45 @@ export default defineNuxtModule({ } }) - // adds support for #vue-router alias (used for types) with and without pages integration - addTemplate({ - filename: 'vue-router-stub.d.ts', - getContents: () => `export * from '${useExperimentalTypedPages ? 'vue-router/auto' : 'vue-router'}'` - }) - - nuxt.options.alias['#vue-router'] = join(nuxt.options.buildDir, 'vue-router-stub') - if (!nuxt.options.pages) { addPlugin(resolve(distDir, 'app/plugins/router')) addTemplate({ filename: 'pages.mjs', getContents: () => [ 'export { useRoute } from \'#app/composables/router\'', - 'export const START_LOCATION = Symbol(\'router:start-location\')' - ].join('\n') + 'export const START_LOCATION = Symbol(\'router:start-location\')', + ].join('\n'), + }) + addTypeTemplate({ + filename: 'types/middleware.d.ts', + getContents: () => [ + 'declare module \'nitropack/types\' {', + ' interface NitroRouteConfig {', + ' appMiddleware?: string | string[] | Record<string, boolean>', + ' }', + '}', + 'declare module \'nitro/types\' {', + ' interface NitroRouteConfig {', + ' appMiddleware?: string | string[] | Record<string, boolean>', + ' }', + '}', + 'export {}', + ].join('\n'), }) addComponent({ name: 'NuxtPage', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(distDir, 'pages/runtime/page-placeholder') + filePath: resolve(distDir, 'pages/runtime/page-placeholder'), + }) + // Prerender index if pages integration is not enabled + nuxt.hook('nitro:init', (nitro) => { + if (nuxt.options.dev || !nuxt.options.ssr || !nitro.options.static || !nitro.options.prerender.crawlLinks) { return } + + nitro.options.prerender.routes.push('/') }) return } - addTemplate({ - filename: 'vue-router-stub.mjs', - // TODO: use `vue-router/auto` when we have support for page metadata - getContents: () => 'export * from \'vue-router\';' - }) - if (useExperimentalTypedPages) { const declarationFile = './types/typed-router.d.ts' @@ -141,10 +166,9 @@ export default defineNuxtModule({ logs: nuxt.options.debug, async beforeWriteFiles (rootPage) { rootPage.children.forEach(child => child.delete()) - let pages = nuxt.apps.default?.pages - if (!pages) { - pages = await resolvePagesRoutes() - await nuxt.callHook('pages:extend', pages) + const pages = nuxt.apps.default?.pages || await resolvePagesRoutes() + if (nuxt.apps.default) { + nuxt.apps.default.pages = pages } function addPage (parent: EditableTreeNode, page: NuxtPage) { // @ts-expect-error TODO: either fix types upstream or figure out another @@ -169,12 +193,13 @@ export default defineNuxtModule({ for (const page of pages) { addPage(rootPage, page) } - } + }, } nuxt.hook('prepare:types', ({ references }) => { // This file will be generated by unplugin-vue-router references.push({ path: declarationFile }) + references.push({ types: 'unplugin-vue-router/client' }) }) const context = createRoutesContext(resolveOptions(options)) @@ -187,12 +212,12 @@ export default defineNuxtModule({ const dts = await readFile(dtsFile, 'utf-8') addTemplate({ filename: 'types/typed-router.d.ts', - getContents: () => dts + getContents: () => dts, }) } // Regenerate types/typed-router.d.ts when adding or removing pages - nuxt.hook('builder:generateApp', async (options) => { + nuxt.hook('app:templatesGenerated', async (_app, _templates, options) => { if (!options?.filter || options.filter({ filename: 'routes.mjs' } as any)) { await context.scanPages() } @@ -201,14 +226,14 @@ export default defineNuxtModule({ // Add $router types nuxt.hook('prepare:types', ({ references }) => { - references.push({ types: useExperimentalTypedPages ? 'vue-router/auto' : 'vue-router' }) + references.push({ types: useExperimentalTypedPages ? 'vue-router/auto-routes' : 'vue-router' }) }) // Add vue-router route guard imports nuxt.hook('imports:sources', (sources) => { const routerImports = sources.find(s => s.from === '#app/composables/router' && s.imports.includes('onBeforeRouteLeave')) if (routerImports) { - routerImports.from = '#vue-router' + routerImports.from = 'vue-router' } }) @@ -216,13 +241,13 @@ export default defineNuxtModule({ const updateTemplatePaths = nuxt.options._layers.flatMap((l) => { const dir = (l.config.rootDir === nuxt.options.rootDir ? nuxt.options : l.config).dir return [ - join(l.config.srcDir || l.cwd, dir?.pages || 'pages') + '/', - join(l.config.srcDir || l.cwd, dir?.layouts || 'layouts') + '/', - join(l.config.srcDir || l.cwd, dir?.middleware || 'middleware') + '/' + resolve(l.config.srcDir || l.cwd, dir?.pages || 'pages') + '/', + resolve(l.config.srcDir || l.cwd, dir?.layouts || 'layouts') + '/', + resolve(l.config.srcDir || l.cwd, dir?.middleware || 'middleware') + '/', ] }) - function isPage (file: string, pages = nuxt.apps.default.pages): boolean { + function isPage (file: string, pages = nuxt.apps.default?.pages): boolean { if (!pages) { return false } return pages.some(page => page.file === file) || pages.some(page => page.children && isPage(file, page.children)) } @@ -234,57 +259,97 @@ export default defineNuxtModule({ if (shouldAlwaysRegenerate || updateTemplatePaths.some(dir => path.startsWith(dir))) { await updateTemplates({ - filter: template => template.filename === 'routes.mjs' + filter: template => template.filename === 'routes.mjs', }) } }) nuxt.hook('app:resolve', (app) => { // Add default layout for pages - if (app.mainComponent!.includes('@nuxt/ui-templates')) { + if (app.mainComponent === resolve(nuxt.options.appDir, 'components/welcome.vue')) { app.mainComponent = resolve(runtimeDir, 'app.vue') } app.middleware.unshift({ name: 'validate', path: resolve(runtimeDir, 'validate'), - global: true + global: true, }) }) - nuxt.hook('nitro:init', (nitro) => { - if (nuxt.options.dev || !nitro.options.static || nuxt.options.router.options.hashMode) { return } - // Prerender all non-dynamic page routes when generating app - const prerenderRoutes = new Set<string>() - nuxt.hook('pages:extend', (pages) => { - prerenderRoutes.clear() - const processPages = (pages: NuxtPage[], currentPath = '/') => { - for (const page of pages) { - // Add root of optional dynamic paths and catchalls - if (OPTIONAL_PARAM_RE.test(page.path) && !page.children?.length) { prerenderRoutes.add(currentPath) } - // Skip dynamic paths - if (page.path.includes(':')) { continue } - const route = joinURL(currentPath, page.path) - prerenderRoutes.add(route) - if (page.children) { processPages(page.children, route) } + nuxt.hook('app:resolve', (app) => { + const nitro = useNitro() + if (nitro.options.prerender.crawlLinks || Object.values(nitro.options.routeRules).some(rule => rule.prerender)) { + app.plugins.push({ + src: resolve(runtimeDir, 'plugins/prerender.server'), + mode: 'server', + }) + } + }) + + // Record all pages for use in prerendering + const prerenderRoutes = new Set<string>() + + function processPages (pages: NuxtPage[], currentPath = '/') { + for (const page of pages) { + // Add root of optional dynamic paths and catchalls + if (OPTIONAL_PARAM_RE.test(page.path) && !page.children?.length) { + prerenderRoutes.add(currentPath) + } + + // Skip dynamic paths + if (page.path.includes(':')) { continue } + + const route = joinURL(currentPath, page.path) + prerenderRoutes.add(route) + + if (page.children) { + processPages(page.children, route) + } + } + } + + nuxt.hook('pages:extend', (pages) => { + if (nuxt.options.dev) { return } + + prerenderRoutes.clear() + processPages(pages) + }) + + nuxt.hook('nitro:build:before', (nitro) => { + if (nuxt.options.dev || nuxt.options.router.options.hashMode) { return } + + // Inject page patterns that explicitly match `prerender: true` route rule + if (!nitro.options.static && !nitro.options.prerender.crawlLinks) { + const routeRulesMatcher = toRouteMatcher(createRadixRouter({ routes: nitro.options.routeRules })) + for (const route of prerenderRoutes) { + const rules = defu({} as Record<string, any>, ...routeRulesMatcher.matchAll(route).reverse()) + if (rules.prerender) { + nitro.options.prerender.routes.push(route) } } - processPages(pages) - }) - nuxt.hook('nitro:build:before', (nitro) => { - for (const route of nitro.options.prerender.routes || []) { - // Skip default route value as we only generate it if it is already - // in the detected routes from `~/pages`. - if (route === '/') { continue } - prerenderRoutes.add(route) - } - nitro.options.prerender.routes = Array.from(prerenderRoutes) - }) + } + + if (!nitro.options.static || !nitro.options.prerender.crawlLinks) { return } + + // Only hint the first route when `ssr: true` and no routes are provided + // as the rest will be injected at runtime when this is prerendered + if (nuxt.options.ssr) { + const [firstPage] = [...prerenderRoutes].sort() + nitro.options.prerender.routes.push(firstPage || '/') + return + } + + // Prerender all non-dynamic page routes when generating `ssr: false` app + for (const route of nitro.options.prerender.routes || []) { + prerenderRoutes.add(route) + } + nitro.options.prerender.routes = Array.from(prerenderRoutes) }) nuxt.hook('imports:extend', (imports) => { imports.push( { name: 'definePageMeta', as: 'definePageMeta', from: resolve(runtimeDir, 'composables') }, - { name: 'useLink', as: 'useLink', from: '#vue-router' } + { name: 'useLink', as: 'useLink', from: 'vue-router' }, ) if (nuxt.options.experimental.inlineRouteRules) { imports.push({ name: 'defineRouteRules', as: 'defineRouteRules', from: resolve(runtimeDir, 'composables') }) @@ -307,7 +372,7 @@ export default defineNuxtModule({ const updatePage = async function updatePage (path: string) { const glob = pageToGlobMap[path] - const code = path in nuxt.vfs ? nuxt.vfs[path] : await readFile(path!, 'utf-8') + const code = path in nuxt.vfs ? nuxt.vfs[path]! : await readFile(path!, 'utf-8') try { const extractedRule = await extractRouteRules(code) if (extractedRule) { @@ -332,7 +397,7 @@ export default defineNuxtModule({ } nuxt.hook('builder:watch', async (event, relativePath) => { - const path = join(nuxt.options.srcDir, relativePath) + const path = resolve(nuxt.options.srcDir, relativePath) if (!(path in pageToGlobMap)) { return } if (event === 'unlink') { delete inlineRules[path] @@ -350,29 +415,32 @@ export default defineNuxtModule({ } if (nuxt.options.experimental.appManifest) { + const componentStubPath = await resolvePath(resolve(runtimeDir, 'component-stub')) // Add all redirect paths as valid routes to router; we will handle these in a client-side middleware // when the app manifest is enabled. - nuxt.hook('pages:extend', routes => { + nuxt.hook('pages:extend', (routes) => { const nitro = useNitro() - for (const path in nitro.options.routeRules) { - const rule = nitro.options.routeRules[path] + let resolvedRoutes: string[] + for (const [path, rule] of Object.entries(nitro.options.routeRules)) { if (!rule.redirect) { continue } + resolvedRoutes ||= routes.flatMap(route => resolveRoutePaths(route)) + // skip if there's already a route matching this path + if (resolvedRoutes.includes(path)) { continue } routes.push({ + _sync: true, path: path.replace(/\/[^/]*\*\*/, '/:pathMatch(.*)'), - file: resolve(runtimeDir, 'component-stub'), + file: componentStubPath, }) } }) } // Extract macros from pages - const pageMetaOptions: PageMetaPluginOptions = { - dev: nuxt.options.dev, - sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client - } nuxt.hook('modules:done', () => { - addVitePlugin(() => PageMetaPlugin.vite(pageMetaOptions)) - addWebpackPlugin(() => PageMetaPlugin.webpack(pageMetaOptions)) + addBuildPlugin(PageMetaPlugin({ + dev: nuxt.options.dev, + sourcemap: !!nuxt.options.sourcemap.server || !!nuxt.options.sourcemap.client, + })) }) // Add prefetching support for middleware & layouts @@ -389,18 +457,22 @@ export default defineNuxtModule({ const getSources = (pages: NuxtPage[]): string[] => pages .filter(p => Boolean(p.file)) .flatMap(p => - [relative(nuxt.options.srcDir, p.file as string), ...getSources(p.children || [])] + [relative(nuxt.options.srcDir, p.file as string), ...(p.children?.length ? getSources(p.children) : [])], ) // Do not prefetch page chunks nuxt.hook('build:manifest', (manifest) => { if (nuxt.options.dev) { return } - const sourceFiles = getSources(nuxt.apps.default.pages || []) + const sourceFiles = nuxt.apps.default?.pages?.length ? getSources(nuxt.apps.default.pages) : [] - for (const key in manifest) { - if (manifest[key].isEntry) { - manifest[key].dynamicImports = - manifest[key].dynamicImports?.filter(i => !sourceFiles.includes(i)) + for (const [key, chunk] of Object.entries(manifest)) { + if (chunk.src && Object.values(nuxt.apps).some(app => app.pages?.some(page => page.mode === 'server' && page.file === join(nuxt.options.srcDir, chunk.src!)))) { + delete manifest[key] + continue + } + if (chunk.isEntry) { + chunk.dynamicImports = + chunk.dynamicImports?.filter(i => !sourceFiles.includes(i)) } } }) @@ -409,23 +481,18 @@ export default defineNuxtModule({ addTemplate({ filename: 'routes.mjs', getContents ({ app }) { - if (!app.pages) return 'export default []' + if (!app.pages) { return 'export default []' } const { routes, imports } = normalizeRoutes(app.pages, new Set(), nuxt.options.experimental.scanPageMeta) return [...imports, `export default ${routes}`].join('\n') - } + }, }) // Add vue-router import for `<NuxtLayout>` integration addTemplate({ filename: 'pages.mjs', - getContents: () => 'export { START_LOCATION, useRoute } from \'vue-router\'' + getContents: () => 'export { START_LOCATION, useRoute } from \'vue-router\'', }) - // Optimize vue-router to ensure we share the same injection symbol - nuxt.options.vite.optimizeDeps = nuxt.options.vite.optimizeDeps || {} - nuxt.options.vite.optimizeDeps.include = nuxt.options.vite.optimizeDeps.include || [] - nuxt.options.vite.optimizeDeps.include.push('vue-router') - nuxt.options.vite.resolve = nuxt.options.vite.resolve || {} nuxt.options.vite.resolve.dedupe = nuxt.options.vite.resolve.dedupe || [] nuxt.options.vite.resolve.dedupe.push('vue-router') @@ -446,45 +513,54 @@ export default defineNuxtModule({ 'export default {', '...configRouterOptions,', ...routerOptionsFiles.map((_, index) => `...routerOptions${index},`), - '}' + '}', ].join('\n') - } + }, }) - addTemplate({ + addTypeTemplate({ filename: 'types/middleware.d.ts', - getContents: ({ nuxt, app }: { nuxt: Nuxt, app: NuxtApp }) => { + getContents: ({ nuxt, app }) => { const composablesFile = relative(join(nuxt.options.buildDir, 'types'), resolve(runtimeDir, 'composables')) const namedMiddleware = app.middleware.filter(mw => !mw.global) return [ 'import type { NavigationGuard } from \'vue-router\'', - `export type MiddlewareKey = ${namedMiddleware.map(mw => genString(mw.name)).join(' | ') || 'string'}`, + `export type MiddlewareKey = ${namedMiddleware.map(mw => genString(mw.name)).join(' | ') || 'never'}`, `declare module ${genString(composablesFile)} {`, ' interface PageMeta {', ' middleware?: MiddlewareKey | NavigationGuard | Array<MiddlewareKey | NavigationGuard>', ' }', - '}' + '}', + 'declare module \'nitropack/types\' {', + ' interface NitroRouteConfig {', + ' appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>', + ' }', + '}', + 'declare module \'nitro/types\' {', + ' interface NitroRouteConfig {', + ' appMiddleware?: MiddlewareKey | MiddlewareKey[] | Record<MiddlewareKey, boolean>', + ' }', + '}', ].join('\n') - } + }, }) - addTemplate({ + addTypeTemplate({ filename: 'types/layouts.d.ts', getContents: ({ nuxt, app }: { nuxt: Nuxt, app: NuxtApp }) => { const composablesFile = relative(join(nuxt.options.buildDir, 'types'), resolve(runtimeDir, 'composables')) return [ - 'import { ComputedRef, MaybeRef } from \'vue\'', + 'import type { ComputedRef, MaybeRef } from \'vue\'', `export type LayoutKey = ${Object.keys(app.layouts).map(name => genString(name)).join(' | ') || 'string'}`, `declare module ${genString(composablesFile)} {`, ' interface PageMeta {', ' layout?: MaybeRef<LayoutKey | false> | ComputedRef<LayoutKey | false>', ' }', - '}' + '}', ].join('\n') - } + }, }) - // add page meta types if enabled if (nuxt.options.experimental.viewTransition) { addTypeTemplate({ @@ -493,14 +569,14 @@ export default defineNuxtModule({ const runtimeDir = resolve(distDir, 'pages/runtime') const composablesFile = relative(join(nuxt.options.buildDir, 'types'), resolve(runtimeDir, 'composables')) return [ - 'import { ComputedRef, MaybeRef } from \'vue\'', + 'import type { ComputedRef, MaybeRef } from \'vue\'', `declare module ${genString(composablesFile)} {`, ' interface PageMeta {', - ` viewTransition?: boolean | 'always'`, + ' viewTransition?: boolean | \'always\'', ' }', '}', ].join('\n') - } + }, }) } @@ -508,14 +584,7 @@ export default defineNuxtModule({ addComponent({ name: 'NuxtPage', priority: 10, // built-in that we do not expect the user to override - filePath: resolve(distDir, 'pages/runtime/page') + filePath: resolve(distDir, 'pages/runtime/page'), }) - - // Add declarations for middleware keys - nuxt.hook('prepare:types', ({ references }) => { - references.push({ path: resolve(nuxt.options.buildDir, 'types/middleware.d.ts') }) - references.push({ path: resolve(nuxt.options.buildDir, 'types/layouts.d.ts') }) - references.push({ path: resolve(nuxt.options.buildDir, 'vue-router-stub.d.ts') }) - }) - } + }, }) diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts index 07c64ebdf0..f6645a5653 100644 --- a/packages/nuxt/src/pages/plugins/page-meta.ts +++ b/packages/nuxt/src/pages/plugins/page-meta.ts @@ -10,7 +10,7 @@ import MagicString from 'magic-string' import { isAbsolute } from 'pathe' import { logger } from '@nuxt/kit' -export interface PageMetaPluginOptions { +interface PageMetaPluginOptions { dev?: boolean sourcemap?: boolean } @@ -36,7 +36,7 @@ if (import.meta.webpackHot) { }) }` -export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => { +export const PageMetaPlugin = (options: PageMetaPluginOptions) => createUnplugin(() => { return { name: 'nuxt:pages-macros-transform', enforce: 'post', @@ -54,7 +54,7 @@ export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => code: s.toString(), map: options.sourcemap ? s.generateMap({ hires: true }) - : undefined + : undefined, } } } @@ -106,7 +106,7 @@ export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => for (const name of [ parsed.defaultImport, ...Object.values(parsed.namedImports || {}), - parsed.namespacedImport + parsed.namespacedImport, ].filter(Boolean) as string[]) { importMap.set(name, i) } @@ -114,7 +114,7 @@ export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => walk(this.parse(code, { sourceType: 'module', - ecmaVersion: 'latest' + ecmaVersion: 'latest', }) as Node, { enter (_node) { if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } @@ -146,11 +146,11 @@ export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => const node = _node as Identifier & { start: number, end: number } addImport(node.name) } - } + }, }) s.overwrite(0, code.length, contents) - } + }, }) if (!s.hasChanged() && !code.includes('__nuxt_page_meta')) { @@ -168,9 +168,9 @@ export const PageMetaPlugin = createUnplugin((options: PageMetaPluginOptions) => if (index !== -1) { modules.splice(index, 1) } - } - } - } + }, + }, + }, } }) diff --git a/packages/nuxt/src/pages/plugins/route-injection.ts b/packages/nuxt/src/pages/plugins/route-injection.ts index 0a89d74470..41d235b2da 100644 --- a/packages/nuxt/src/pages/plugins/route-injection.ts +++ b/packages/nuxt/src/pages/plugins/route-injection.ts @@ -1,10 +1,13 @@ import { createUnplugin } from 'unplugin' import MagicString from 'magic-string' import type { Nuxt } from '@nuxt/schema' +import { stripLiteral } from 'strip-literal' import { isVue } from '../../core/utils' -const INJECTION_RE = /\b_ctx\.\$route\b/g -const INJECTION_SINGLE_RE = /\b_ctx\.\$route\b/ +const INJECTION_RE_TEMPLATE = /\b_ctx\.\$route\b/g +const INJECTION_RE_SCRIPT = /\bthis\.\$route\b/g + +const INJECTION_SINGLE_RE = /\bthis\.\$route\b|\b_ctx\.\$route\b/ export const RouteInjectionPlugin = (nuxt: Nuxt) => createUnplugin(() => { return { @@ -14,14 +17,30 @@ export const RouteInjectionPlugin = (nuxt: Nuxt) => createUnplugin(() => { return isVue(id, { type: ['template', 'script'] }) }, transform (code) { - if (!INJECTION_SINGLE_RE.test(code) || code.includes('_ctx._.provides[__nuxt_route_symbol')) { return } + if (!INJECTION_SINGLE_RE.test(code) || code.includes('_ctx._.provides[__nuxt_route_symbol') || code.includes('this._.provides[__nuxt_route_symbol')) { return } let replaced = false const s = new MagicString(code) - s.replace(INJECTION_RE, () => { - replaced = true - return '(_ctx._.provides[__nuxt_route_symbol] || _ctx.$route)' - }) + const strippedCode = stripLiteral(code) + + // Local helper function for regex-based replacements using `strippedCode` + const replaceMatches = (regExp: RegExp, replacement: string) => { + for (const match of strippedCode.matchAll(regExp)) { + const start = match.index! + const end = start + match[0].length + s.overwrite(start, end, replacement) + if (!replaced) { + replaced = true + } + } + } + + // handles `$route` in template + replaceMatches(INJECTION_RE_TEMPLATE, '(_ctx._.provides[__nuxt_route_symbol] || _ctx.$route)') + + // handles `this.$route` in script + replaceMatches(INJECTION_RE_SCRIPT, '(this._.provides[__nuxt_route_symbol] || this.$route)') + if (replaced) { s.prepend('import { PageRouteSymbol as __nuxt_route_symbol } from \'#app/components/injections\';\n') } @@ -31,9 +50,9 @@ export const RouteInjectionPlugin = (nuxt: Nuxt) => createUnplugin(() => { code: s.toString(), map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server ? s.generateMap({ hires: true }) - : undefined + : undefined, } } - } + }, } }) diff --git a/packages/nuxt/src/pages/route-rules.ts b/packages/nuxt/src/pages/route-rules.ts index c931c87546..1b76a75b52 100644 --- a/packages/nuxt/src/pages/route-rules.ts +++ b/packages/nuxt/src/pages/route-rules.ts @@ -5,7 +5,7 @@ import { walk } from 'estree-walker' import { transform } from 'esbuild' import { parse } from 'acorn' import type { NuxtPage } from '@nuxt/schema' -import type { NitroRouteConfig } from 'nitropack' +import type { NitroRouteConfig } from 'nitro/types' import { normalize } from 'pathe' import { extractScriptContent, pathToNitroGlob } from './utils' @@ -14,33 +14,37 @@ const ruleCache: Record<string, NitroRouteConfig | null> = {} export async function extractRouteRules (code: string): Promise<NitroRouteConfig | null> { if (code in ruleCache) { - return ruleCache[code] + return ruleCache[code] || null } if (!ROUTE_RULE_RE.test(code)) { return null } - code = extractScriptContent(code) || code - let rule: NitroRouteConfig | null = null + const contents = extractScriptContent(code) + for (const script of contents) { + if (rule) { break } - const js = await transform(code, { loader: 'ts' }) - walk(parse(js.code, { - sourceType: 'module', - ecmaVersion: 'latest' - }) as Node, { - enter (_node) { - if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } - const node = _node as CallExpression & { start: number, end: number } - const name = 'name' in node.callee && node.callee.name - if (name === 'defineRouteRules') { - const rulesString = js.code.slice(node.start, node.end) - try { - rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {})) - } catch { - throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.') + code = script?.code || code + + const js = await transform(code, { loader: script?.loader || 'ts' }) + walk(parse(js.code, { + sourceType: 'module', + ecmaVersion: 'latest', + }) as Node, { + enter (_node) { + if (_node.type !== 'CallExpression' || (_node as CallExpression).callee.type !== 'Identifier') { return } + const node = _node as CallExpression & { start: number, end: number } + const name = 'name' in node.callee && node.callee.name + if (name === 'defineRouteRules') { + const rulesString = js.code.slice(node.start, node.end) + try { + rule = JSON.parse(runInNewContext(rulesString.replace('defineRouteRules', 'JSON.stringify'), {})) + } catch { + throw new Error('[nuxt] Error parsing route rules. They should be JSON-serializable.') + } } - } - } - }) + }, + }) + } ruleCache[code] = rule return rule diff --git a/packages/nuxt/src/pages/runtime/composables.ts b/packages/nuxt/src/pages/runtime/composables.ts index dd3603b827..eb60aada99 100644 --- a/packages/nuxt/src/pages/runtime/composables.ts +++ b/packages/nuxt/src/pages/runtime/composables.ts @@ -1,8 +1,9 @@ import type { KeepAliveProps, TransitionProps, UnwrapRef } from 'vue' import { getCurrentInstance } from 'vue' -import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from '#vue-router' +import type { RouteLocationNormalized, RouteLocationNormalizedLoaded, RouteRecordRedirectOption } from 'vue-router' import { useRoute } from 'vue-router' -import type { NitroRouteConfig } from 'nitropack' +import type { NitroRouteConfig } from 'nitro/types' +import { useNuxtApp } from '#app/nuxt' import type { NuxtError } from '#app' export interface PageMeta { @@ -41,6 +42,7 @@ export interface PageMeta { } declare module 'vue-router' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface RouteMeta extends UnwrapRef<PageMeta> {} } @@ -48,7 +50,7 @@ const warnRuntimeUsage = (method: string) => { console.warn( `${method}() is a compiler-hint helper that is only usable inside ` + 'the script block of a single file component which is also a page. Its arguments should be ' + - 'compiled away and passing it at runtime has no effect.' + 'compiled away and passing it at runtime has no effect.', ) } @@ -58,8 +60,9 @@ export const definePageMeta = (meta: PageMeta): void => { const component = getCurrentInstance()?.type try { const isRouteComponent = component && useRoute().matched.some(p => Object.values(p.components || {}).includes(component)) - if (isRouteComponent) { - // don't warn if it's being used in a route component + const isRenderingServerPage = import.meta.server && useNuxtApp().ssrContext?.islandContext + if (isRouteComponent || isRenderingServerPage || ((component as any)?.__clientOnlyPage)) { + // don't warn if it's being used in a route component (or server page) return } } catch { @@ -78,6 +81,6 @@ export const definePageMeta = (meta: PageMeta): void => { * For more control, such as if you are using a custom `path` or `alias` set in the page's `definePageMeta`, you * should set `routeRules` directly within your `nuxt.config`. */ -/*@__NO_SIDE_EFFECTS__*/ +/* @__NO_SIDE_EFFECTS__ */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export const defineRouteRules = (rules: NitroRouteConfig): void => {} diff --git a/packages/nuxt/src/pages/runtime/index.ts b/packages/nuxt/src/pages/runtime/index.ts index 8ef8d8b066..2a46da204f 100644 --- a/packages/nuxt/src/pages/runtime/index.ts +++ b/packages/nuxt/src/pages/runtime/index.ts @@ -1 +1,2 @@ -export * from './composables' +export { definePageMeta, defineRouteRules } from './composables' +export type { PageMeta } from './composables' diff --git a/packages/nuxt/src/pages/runtime/page-placeholder.ts b/packages/nuxt/src/pages/runtime/page-placeholder.ts index 0e574f5819..be614d7709 100644 --- a/packages/nuxt/src/pages/runtime/page-placeholder.ts +++ b/packages/nuxt/src/pages/runtime/page-placeholder.ts @@ -9,5 +9,5 @@ export default defineComponent({ console.warn(`Create a Vue component in the \`${devPagesDir}/\` directory to enable \`<NuxtPage>\``) } return () => props.slots.default?.() - } + }, }) diff --git a/packages/nuxt/src/pages/runtime/page.ts b/packages/nuxt/src/pages/runtime/page.ts index 5e3cf7758c..5ba304cd23 100644 --- a/packages/nuxt/src/pages/runtime/page.ts +++ b/packages/nuxt/src/pages/runtime/page.ts @@ -1,12 +1,11 @@ -import { Suspense, Transition, defineComponent, h, inject, nextTick, ref, watch } from 'vue' +import { Fragment, Suspense, Transition, defineComponent, h, inject, nextTick, ref, watch } from 'vue' import type { KeepAliveProps, TransitionProps, VNode } from 'vue' -import { RouterView } from '#vue-router' +import { RouterView } from 'vue-router' import { defu } from 'defu' -import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from '#vue-router' +import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from 'vue-router' -import { toArray } from './utils' +import { generateRouteKey, toArray, wrapInKeepAlive } from './utils' import type { RouterViewSlotProps } from './utils' -import { generateRouteKey, wrapInKeepAlive } from './utils' import { RouteProvider } from '#app/components/route-provider' import { useNuxtApp } from '#app/nuxt' import { useRouter } from '#app/composables/router' @@ -20,25 +19,25 @@ export default defineComponent({ inheritAttrs: false, props: { name: { - type: String + type: String, }, transition: { type: [Boolean, Object] as any as () => boolean | TransitionProps, - default: undefined + default: undefined, }, keepalive: { type: [Boolean, Object] as any as () => boolean | KeepAliveProps, - default: undefined + default: undefined, }, route: { - type: Object as () => RouteLocationNormalized + type: Object as () => RouteLocationNormalized, }, pageKey: { type: [Function, String] as unknown as () => string | ((route: RouteLocationNormalizedLoaded) => string), - default: null - } + default: null, + }, }, - setup (props, { attrs, expose }) { + setup (props, { attrs, slots, expose }) { const nuxtApp = useNuxtApp() const pageRef = ref() const forkRoute = inject(PageRouteSymbol, null) @@ -108,7 +107,7 @@ export default defineComponent({ props.transition, routeProps.route.meta.pageTransition, defaultPageTransition, - { onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } } + { onAfterLeave: () => { nuxtApp.callHook('page:transition:finish', routeProps.Component) } }, ].filter(Boolean)) const keepaliveConfig = props.keepalive ?? routeProps.route.meta.keepalive ?? (defaultKeepaliveConfig as KeepAliveProps) @@ -116,36 +115,36 @@ export default defineComponent({ wrapInKeepAlive(keepaliveConfig, h(Suspense, { suspensible: true, onPending: () => nuxtApp.callHook('page:start', routeProps.Component), - onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).then(() => nuxtApp.callHook('page:loading:end')).finally(done)) } + onResolve: () => { nextTick(() => nuxtApp.callHook('page:finish', routeProps.Component).then(() => nuxtApp.callHook('page:loading:end')).finally(done)) }, }, { default: () => { const providerVNode = h(RouteProvider, { key: key || undefined, - vnode: routeProps.Component, + vnode: slots.default ? h(Fragment, undefined, slots.default(routeProps)) : routeProps.Component, route: routeProps.route, renderKey: key || undefined, trackRootNodes: hasTransition, - vnodeRef: pageRef + vnodeRef: pageRef, }) if (import.meta.client && keepaliveConfig) { (providerVNode.type as any).name = (routeProps.Component.type as any).name || (routeProps.Component.type as any).__name || 'RouteProvider' } return providerVNode - } - }) + }, + }), )).default() return vnode - } + }, }) } - } + }, }) function _mergeTransitionProps (routeProps: TransitionProps[]): TransitionProps { const _props: TransitionProps[] = routeProps.map(prop => ({ ...prop, - onAfterLeave: prop.onAfterLeave ? toArray(prop.onAfterLeave) : undefined + onAfterLeave: prop.onAfterLeave ? toArray(prop.onAfterLeave) : undefined, })) return defu(..._props as [TransitionProps, TransitionProps]) } diff --git a/packages/nuxt/src/pages/runtime/plugins/check-if-page-unused.ts b/packages/nuxt/src/pages/runtime/plugins/check-if-page-unused.ts index a347ff11cb..204cc29dad 100644 --- a/packages/nuxt/src/pages/runtime/plugins/check-if-page-unused.ts +++ b/packages/nuxt/src/pages/runtime/plugins/check-if-page-unused.ts @@ -11,20 +11,24 @@ export default defineNuxtPlugin({ function checkIfPageUnused () { if (!error.value && !nuxtApp._isNuxtPageUsed) { console.warn( - '[nuxt] Your project has pages but the `<NuxtPage />` component has not been used.' - + ' You might be using the `<RouterView />` component instead, which will not work correctly in Nuxt.' - + ' You can set `pages: false` in `nuxt.config` if you do not wish to use the Nuxt `vue-router` integration.' + '[nuxt] Your project has pages but the `<NuxtPage />` component has not been used.' + + ' You might be using the `<RouterView />` component instead, which will not work correctly in Nuxt.' + + ' You can set `pages: false` in `nuxt.config` if you do not wish to use the Nuxt `vue-router` integration.', ) } } if (import.meta.server) { - nuxtApp.hook('app:rendered', ({ renderResult }) => { renderResult?.html && nextTick(checkIfPageUnused) }) + nuxtApp.hook('app:rendered', ({ renderResult }) => { + if (renderResult?.html) { + nextTick(checkIfPageUnused) + } + }) } else { onNuxtReady(checkIfPageUnused) } }, env: { - islands: false - } + islands: false, + }, }) diff --git a/packages/nuxt/src/pages/runtime/plugins/prefetch.client.ts b/packages/nuxt/src/pages/runtime/plugins/prefetch.client.ts index 678e77e647..a205d0da0a 100644 --- a/packages/nuxt/src/pages/runtime/plugins/prefetch.client.ts +++ b/packages/nuxt/src/pages/runtime/plugins/prefetch.client.ts @@ -40,5 +40,5 @@ export default defineNuxtPlugin({ layouts[layout]() } }) - } + }, }) diff --git a/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts b/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts new file mode 100644 index 0000000000..3b880b1223 --- /dev/null +++ b/packages/nuxt/src/pages/runtime/plugins/prerender.server.ts @@ -0,0 +1,61 @@ +import type { RouteRecordRaw } from 'vue-router' +import { joinURL } from 'ufo' +import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3' +import defu from 'defu' + +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' +// @ts-expect-error virtual file +import { crawlLinks } from '#build/nuxt.config.mjs' + +let routes: string[] + +let _routeRulesMatcher: undefined | ReturnType<typeof toRouteMatcher> = undefined + +export default defineNuxtPlugin(async () => { + if (!import.meta.server || !import.meta.prerender || routerOptions.hashMode) { + return + } + if (routes && !routes.length) { return } + + const routeRules = useRuntimeConfig().nitro!.routeRules + if (!crawlLinks && routeRules && Object.values(routeRules).some(r => r.prerender)) { + _routeRulesMatcher = toRouteMatcher(createRadixRouter({ routes: routeRules })) + } + + routes ||= Array.from(processRoutes(await routerOptions.routes?.(_routes) ?? _routes)) + const batch = routes.splice(0, 10) + prerenderRoutes(batch) +}) + +// Implementation + +const OPTIONAL_PARAM_RE = /^\/?:.*(?:\?|\(\.\*\)\*)$/ + +function shouldPrerender (path: string) { + return !_routeRulesMatcher || defu({} as Record<string, any>, ..._routeRulesMatcher.matchAll(path).reverse()).prerender +} + +function processRoutes (routes: RouteRecordRaw[], currentPath = '/', routesToPrerender = new Set<string>()) { + for (const route of routes) { + // Add root of optional dynamic paths and catchalls + if (OPTIONAL_PARAM_RE.test(route.path) && !route.children?.length && shouldPrerender(currentPath)) { + routesToPrerender.add(currentPath) + } + // Skip dynamic paths + if (route.path.includes(':')) { + continue + } + const fullPath = joinURL(currentPath, route.path) + if (shouldPrerender(fullPath)) { + routesToPrerender.add(fullPath) + } + if (route.children) { + processRoutes(route.children, fullPath, routesToPrerender) + } + } + return routesToPrerender +} diff --git a/packages/nuxt/src/pages/runtime/plugins/router.ts b/packages/nuxt/src/pages/runtime/plugins/router.ts index 0de9129194..7f2e5faca6 100644 --- a/packages/nuxt/src/pages/runtime/plugins/router.ts +++ b/packages/nuxt/src/pages/runtime/plugins/router.ts @@ -1,13 +1,7 @@ import { isReadonly, reactive, shallowReactive, shallowRef } from 'vue' import type { Ref } from 'vue' -import type { RouteLocation, Router, RouterScrollBehavior } from '#vue-router' -import { - START_LOCATION, - createMemoryHistory, - createRouter, - createWebHashHistory, - createWebHistory -} from '#vue-router' +import type { RouteLocation, RouteLocationNormalizedLoaded, Router, RouterScrollBehavior } from 'vue-router' +import { START_LOCATION, createMemoryHistory, createRouter, createWebHashHistory, createWebHistory } from 'vue-router' import { createError } from 'h3' import { isEqual, withoutBase } from 'ufo' @@ -15,13 +9,15 @@ import type { PageMeta } from '../composables' import { toArray } from '../utils' import type { Plugin, RouteMiddleware } from '#app' +import { getRouteRules } from '#app/composables/manifest' import { defineNuxtPlugin, useRuntimeConfig } from '#app/nuxt' import { clearError, showError, useError } from '#app/composables/error' import { navigateTo } from '#app/composables/router' // @ts-expect-error virtual file -import _routes from '#build/routes' +import { appManifest as isAppManifestEnabled } from '#build/nuxt.config.mjs' // @ts-expect-error virtual file +import _routes from '#build/routes' import routerOptions from '#build/router.options' // @ts-expect-error virtual file import { globalMiddleware, namedMiddleware } from '#build/middleware' @@ -30,7 +26,7 @@ import { globalMiddleware, namedMiddleware } from '#build/middleware' function createCurrentLocation ( base: string, location: Location, - renderedPath?: string + renderedPath?: string, ): string { const { pathname, search, hash } = location // allows hash bases like #, /#, #/, #!, #!/, /#!/, or even /folder#end @@ -64,12 +60,9 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ : createMemoryHistory(routerBase) ) - const routes = routerOptions.routes?.(_routes) ?? _routes + const routes = routerOptions.routes ? await routerOptions.routes(_routes) ?? _routes : _routes let startPosition: Parameters<RouterScrollBehavior>[2] | null - const initialURL = import.meta.server - ? nuxtApp.ssrContext!.url - : createCurrentLocation(routerBase, window.location, nuxtApp.payload.path) const router = createRouter({ ...routerOptions, @@ -91,7 +84,7 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ } }, history, - routes + routes, }) if (import.meta.client && 'scrollRestoration' in window.history) { @@ -105,11 +98,15 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ }) Object.defineProperty(nuxtApp.vueApp.config.globalProperties, 'previousRoute', { - get: () => previousRoute.value + get: () => previousRoute.value, }) + const initialURL = import.meta.server + ? nuxtApp.ssrContext!.url + : createCurrentLocation(routerBase, window.location, nuxtApp.payload.path) + // Allows suspending the route object until page navigation completes - const _route = shallowRef(router.resolve(initialURL) as RouteLocation) + const _route = shallowRef(router.currentRoute.value) const syncCurrentRoute = () => { _route.value = router.currentRoute.value } nuxtApp.hook('page:finish', syncCurrentRoute) router.afterEach((to, from) => { @@ -120,11 +117,12 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ } }) - // https://github.com/vuejs/router/blob/main/packages/router/src/router.ts#L1225-L1233 - const route = {} as RouteLocation + // https://github.com/vuejs/router/blob/8487c3e18882a0883e464a0f25fb28fa50eeda38/packages/router/src/router.ts#L1283-L1289 + const route = {} as RouteLocationNormalizedLoaded for (const key in _route.value) { Object.defineProperty(route, key, { - get: () => _route.value[key as keyof RouteLocation] + get: () => _route.value[key as keyof RouteLocation], + enumerable: true, }) } @@ -132,22 +130,54 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ nuxtApp._middleware = nuxtApp._middleware || { global: [], - named: {} + named: {}, } const error = useError() + if (import.meta.client || !nuxtApp.ssrContext?.islandContext) { + router.afterEach(async (to, _from, failure) => { + delete nuxtApp._processingMiddleware + + if (import.meta.client && !nuxtApp.isHydrating && error.value) { + // Clear any existing errors + await nuxtApp.runWithContext(clearError) + } + if (failure) { + await nuxtApp.callHook('page:loading:end') + } + if (import.meta.server && failure?.type === 4 /* ErrorTypes.NAVIGATION_ABORTED */) { + return + } + if (to.matched.length === 0) { + await nuxtApp.runWithContext(() => showError(createError({ + statusCode: 404, + fatal: false, + statusMessage: `Page not found: ${to.fullPath}`, + data: { + path: to.fullPath, + }, + }))) + } else if (import.meta.server && to.redirectedFrom && to.fullPath !== initialURL) { + await nuxtApp.runWithContext(() => navigateTo(to.fullPath || '/')) + } + }) + } try { if (import.meta.server) { await router.push(initialURL) } - await router.isReady() } catch (error: any) { // We'll catch 404s here await nuxtApp.runWithContext(() => showError(error)) } + const resolvedInitialRoute = import.meta.client && initialURL !== router.currentRoute.value.fullPath + ? router.resolve(initialURL) + : router.currentRoute.value + syncCurrentRoute() + if (import.meta.server && nuxtApp.ssrContext?.islandContext) { // We're in an island context, and don't need to handle middleware or redirections return { provide: { router } } @@ -173,6 +203,20 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ } } + if (isAppManifestEnabled) { + const routeRules = await nuxtApp.runWithContext(() => getRouteRules(to.path)) + + if (routeRules.appMiddleware) { + for (const key in routeRules.appMiddleware) { + if (routeRules.appMiddleware[key]) { + middlewareEntries.add(key) + } else { + middlewareEntries.delete(key) + } + } + } + } + for (const entry of middlewareEntries) { const middleware = typeof entry === 'string' ? nuxtApp._middleware.named[entry] || await namedMiddleware[entry]?.().then((r: any) => r.default || r) : entry @@ -188,7 +232,7 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ if (result === false || result instanceof Error) { const error = result || createError({ statusCode: 404, - statusMessage: `Page Not Found: ${initialURL}` + statusMessage: `Page Not Found: ${initialURL}`, }) await nuxtApp.runWithContext(() => showError(error)) return false @@ -208,39 +252,15 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ await nuxtApp.callHook('page:loading:end') }) - router.afterEach(async (to, _from, failure) => { - delete nuxtApp._processingMiddleware - - if (import.meta.client && !nuxtApp.isHydrating && error.value) { - // Clear any existing errors - await nuxtApp.runWithContext(clearError) - } - if (failure) { - await nuxtApp.callHook('page:loading:end') - } - if (import.meta.server && failure?.type === 4 /* ErrorTypes.NAVIGATION_ABORTED */) { - return - } - if (to.matched.length === 0) { - await nuxtApp.runWithContext(() => showError(createError({ - statusCode: 404, - fatal: false, - statusMessage: `Page not found: ${to.fullPath}`, - data: { - path: to.fullPath - } - }))) - } else if (import.meta.server && to.redirectedFrom && to.fullPath !== initialURL) { - await nuxtApp.runWithContext(() => navigateTo(to.fullPath || '/')) - } - }) - nuxtApp.hooks.hookOnce('app:created', async () => { try { + // #4920, #4982 + if ('name' in resolvedInitialRoute) { + resolvedInitialRoute.name = undefined + } await router.replace({ - ...router.resolve(initialURL), - name: undefined, // #4920, #4982 - force: true + ...resolvedInitialRoute, + force: true, }) // reset scroll behavior to initial value router.options.scrollBehavior = routerOptions.scrollBehavior @@ -251,7 +271,7 @@ const plugin: Plugin<{ router: Router }> = defineNuxtPlugin({ }) return { provide: { router } } - } + }, }) export default plugin diff --git a/packages/nuxt/src/pages/runtime/router.options.ts b/packages/nuxt/src/pages/runtime/router.options.ts index 71eefaa4a1..a415cc52f3 100644 --- a/packages/nuxt/src/pages/runtime/router.options.ts +++ b/packages/nuxt/src/pages/runtime/router.options.ts @@ -1,5 +1,4 @@ -import type { RouteLocationNormalized, RouterScrollBehavior } from '#vue-router' -import { nextTick } from 'vue' +import type { RouteLocationNormalized, RouterScrollBehavior } from 'vue-router' import type { RouterConfig } from 'nuxt/schema' import { useNuxtApp } from '#app/nuxt' import { isChangingPage } from '#app/components/utils' @@ -36,6 +35,8 @@ export default <RouterConfig> { if (to.hash) { return { el: to.hash, top: _getHashElementScrollMarginTop(to.hash), behavior } } + // The route isn't changing so keep current scroll position + return false } // Wait for `page:transition:finish` or `page:finish` depending on if transitions are enabled or not @@ -43,21 +44,21 @@ export default <RouterConfig> { const hookToWait = (hasTransition(from) && hasTransition(to)) ? 'page:transition:finish' : 'page:finish' return new Promise((resolve) => { nuxtApp.hooks.hookOnce(hookToWait, async () => { - await nextTick() + await new Promise(resolve => setTimeout(resolve, 0)) if (to.hash) { position = { el: to.hash, top: _getHashElementScrollMarginTop(to.hash), behavior } } resolve(position) }) }) - } + }, } function _getHashElementScrollMarginTop (selector: string): number { try { const elem = document.querySelector(selector) if (elem) { - return parseFloat(getComputedStyle(elem).scrollMarginTop) + return (Number.parseFloat(getComputedStyle(elem).scrollMarginTop) || 0) + (Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0) } } catch { // ignore any errors parsing scrollMarginTop diff --git a/packages/nuxt/src/pages/runtime/utils.ts b/packages/nuxt/src/pages/runtime/utils.ts index 3a16247191..7dc4851ead 100644 --- a/packages/nuxt/src/pages/runtime/utils.ts +++ b/packages/nuxt/src/pages/runtime/utils.ts @@ -1,5 +1,5 @@ import { KeepAlive, h } from 'vue' -import type { RouteLocationMatched, RouteLocationNormalizedLoaded, RouterView } from '#vue-router' +import type { RouteLocationMatched, RouteLocationNormalizedLoaded, RouterView } from 'vue-router' type InstanceOf<T> = T extends new (...args: any[]) => infer R ? R : never type RouterViewSlot = Exclude<InstanceOf<typeof RouterView>['$slots']['default'], undefined> @@ -22,6 +22,7 @@ export const wrapInKeepAlive = (props: any, children: any) => { return { default: () => import.meta.client && props ? h(KeepAlive, props === true ? {} : props, children) : children } } +/** @since 3.9.0 */ export function toArray<T> (value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } diff --git a/packages/nuxt/src/pages/runtime/validate.ts b/packages/nuxt/src/pages/runtime/validate.ts index b3aac24ed9..263eab3cdc 100644 --- a/packages/nuxt/src/pages/runtime/validate.ts +++ b/packages/nuxt/src/pages/runtime/validate.ts @@ -12,16 +12,13 @@ export default defineNuxtRouteMiddleware(async (to) => { if (result === true) { return } - if (import.meta.server) { - return result - } const error = createError({ - statusCode: 404, - statusMessage: `Page Not Found: ${to.fullPath}`, + statusCode: (result && result.statusCode) || 404, + statusMessage: (result && result.statusMessage) || `Page Not Found: ${to.fullPath}`, data: { - path: to.fullPath - } + path: to.fullPath, + }, }) const unsub = router.beforeResolve((final) => { unsub() @@ -32,7 +29,7 @@ export default defineNuxtRouteMiddleware(async (to) => { // We pretend to have navigated to the invalid route so // that the user can return to the previous page with // the back button. - window.history.pushState({}, '', to.fullPath) + window?.history.pushState({}, '', to.fullPath) }) // We stop the navigation immediately before it resolves // if there is no other route matching it. diff --git a/packages/nuxt/src/pages/utils.ts b/packages/nuxt/src/pages/utils.ts index 9a88d3d4ab..e1e14517e9 100644 --- a/packages/nuxt/src/pages/utils.ts +++ b/packages/nuxt/src/pages/utils.ts @@ -2,18 +2,20 @@ import { runInNewContext } from 'node:vm' import fs from 'node:fs' import { extname, normalize, relative, resolve } from 'pathe' import { encodePath, joinURL, withLeadingSlash } from 'ufo' -import { logger, resolveFiles, useNuxt } from '@nuxt/kit' +import { logger, resolveFiles, resolvePath, useNuxt } from '@nuxt/kit' import { genArrayFromRaw, genDynamicImport, genImport, genSafeVariableName } from 'knitwork' import escapeRE from 'escape-string-regexp' import { filename } from 'pathe/utils' import { hash } from 'ohash' import { transform } from 'esbuild' import { parse } from 'acorn' +import { walk } from 'estree-walker' import type { CallExpression, ExpressionStatement, ObjectExpression, Program, Property } from 'estree' import type { NuxtPage } from 'nuxt/schema' -import { uniqueBy } from '../core/utils' +import { getLoader, uniqueBy } from '../core/utils' import { toArray } from '../utils' +import { distDir } from '../dirs' enum SegmentParserState { initial, @@ -21,6 +23,7 @@ enum SegmentParserState { dynamic, optional, catchall, + group, } enum SegmentTokenType { @@ -28,6 +31,7 @@ enum SegmentTokenType { dynamic, optional, catchall, + group, } interface SegmentToken { @@ -44,7 +48,7 @@ export async function resolvePagesRoutes (): Promise<NuxtPage[]> { const nuxt = useNuxt() const pagesDirs = nuxt.options._layers.map( - layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages') + layer => resolve(layer.config.srcDir, (layer.config.rootDir === nuxt.options.rootDir ? nuxt.options : layer.config).dir?.pages || 'pages'), ) const scannedFiles: ScannedFile[] = [] @@ -56,20 +60,31 @@ export async function resolvePagesRoutes (): Promise<NuxtPage[]> { // sort scanned files using en-US locale to make the result consistent across different system locales scannedFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'en-US')) - const allRoutes = await generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), { - shouldExtractBuildMeta: nuxt.options.experimental.scanPageMeta || nuxt.options.experimental.typedPages, - vfs: nuxt.vfs + const allRoutes = generateRoutesFromFiles(uniqueBy(scannedFiles, 'relativePath'), { + shouldUseServerComponents: !!nuxt.options.experimental.componentIslands, }) - return uniqueBy(allRoutes, 'path') + const pages = uniqueBy(allRoutes, 'path') + + const shouldAugment = nuxt.options.experimental.scanPageMeta || nuxt.options.experimental.typedPages + + if (shouldAugment) { + const augmentedPages = await augmentPages(pages, nuxt.vfs) + await nuxt.callHook('pages:extend', pages) + await augmentPages(pages, nuxt.vfs, augmentedPages) + augmentedPages.clear() + } else { + await nuxt.callHook('pages:extend', pages) + } + + return pages } type GenerateRoutesFromFilesOptions = { - shouldExtractBuildMeta?: boolean - vfs?: Record<string, string> + shouldUseServerComponents?: boolean } -export async function generateRoutesFromFiles (files: ScannedFile[], options: GenerateRoutesFromFilesOptions = {}): Promise<NuxtPage[]> { +export function generateRoutesFromFiles (files: ScannedFile[], options: GenerateRoutesFromFilesOptions = {}): NuxtPage[] { const routes: NuxtPage[] = [] for (const file of files) { @@ -81,17 +96,34 @@ export async function generateRoutesFromFiles (files: ScannedFile[], options: Ge name: '', path: '', file: file.absolutePath, - children: [] + children: [], } // Array where routes should be added, useful when adding child routes let parent = routes + const lastSegment = segments[segments.length - 1]! + if (lastSegment.endsWith('.server')) { + segments[segments.length - 1] = lastSegment.replace('.server', '') + if (options.shouldUseServerComponents) { + route.mode = 'server' + } + } else if (lastSegment.endsWith('.client')) { + segments[segments.length - 1] = lastSegment.replace('.client', '') + route.mode = 'client' + } + for (let i = 0; i < segments.length; i++) { const segment = segments[i] - const tokens = parseSegment(segment) - const segmentName = tokens.map(({ value }) => value).join('') + const tokens = parseSegment(segment!) + + // Skip group segments + if (tokens.every(token => token.type === SegmentTokenType.group)) { + continue + } + + const segmentName = tokens.map(({ value, type }) => type === SegmentTokenType.group ? '' : value).join('') // ex: parent/[slug].vue -> parent-slug route.name += (route.name && '/') + segmentName @@ -110,122 +142,159 @@ export async function generateRoutesFromFiles (files: ScannedFile[], options: Ge } } - if (options.shouldExtractBuildMeta && options.vfs) { - const fileContent = file.absolutePath in options.vfs ? options.vfs[file.absolutePath] : fs.readFileSync(file.absolutePath, 'utf-8') - Object.assign(route, await getRouteMeta(fileContent, file.absolutePath)) - } - parent.push(route) } return prepareRoutes(routes) } -const SFC_SCRIPT_RE = /<script\s*[^>]*>([\s\S]*?)<\/script\s*[^>]*>/i -export function extractScriptContent (html: string) { - const match = html.match(SFC_SCRIPT_RE) +export async function augmentPages (routes: NuxtPage[], vfs: Record<string, string>, augmentedPages = new Set<string>()) { + for (const route of routes) { + if (route.file && !augmentedPages.has(route.file)) { + const fileContent = route.file in vfs ? vfs[route.file]! : fs.readFileSync(await resolvePath(route.file), 'utf-8') + const routeMeta = await getRouteMeta(fileContent, route.file) + if (route.meta) { + routeMeta.meta = { ...routeMeta.meta, ...route.meta } + } - if (match && match[1]) { - return match[1].trim() + Object.assign(route, routeMeta) + augmentedPages.add(route.file) + } + + if (route.children && route.children.length > 0) { + await augmentPages(route.children, vfs, augmentedPages) + } } - - return null + return augmentedPages } -const PAGE_META_RE = /(definePageMeta\([\s\S]*?\))/ +const SFC_SCRIPT_RE = /<script(?<attrs>[^>]*)>(?<content>[\s\S]*?)<\/script[^>]*>/gi +export function extractScriptContent (html: string) { + const contents: Array<{ loader: 'tsx' | 'ts', code: string }> = [] + for (const match of html.matchAll(SFC_SCRIPT_RE)) { + if (match?.groups?.content) { + contents.push({ + loader: match.groups.attrs?.includes('tsx') ? 'tsx' : 'ts', + code: match.groups.content.trim(), + }) + } + } + + return contents +} + +const PAGE_META_RE = /definePageMeta\([\s\S]*?\)/ +const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const const DYNAMIC_META_KEY = '__nuxt_dynamic_meta_key' as const const pageContentsCache: Record<string, string> = {} const metaCache: Record<string, Partial<Record<keyof NuxtPage, any>>> = {} -async function getRouteMeta (contents: string, absolutePath: string): Promise<Partial<Record<keyof NuxtPage, any>>> { +export async function getRouteMeta (contents: string, absolutePath: string): Promise<Partial<Record<keyof NuxtPage, any>>> { // set/update pageContentsCache, invalidate metaCache on cache mismatch if (!(absolutePath in pageContentsCache) || pageContentsCache[absolutePath] !== contents) { pageContentsCache[absolutePath] = contents delete metaCache[absolutePath] } - if (absolutePath in metaCache) { return metaCache[absolutePath] } + if (absolutePath in metaCache && metaCache[absolutePath]) { + return metaCache[absolutePath] + } - const script = extractScriptContent(contents) - if (!script) { + const loader = getLoader(absolutePath) + const scriptBlocks = !loader ? null : loader === 'vue' ? extractScriptContent(contents) : [{ code: contents, loader }] + if (!scriptBlocks) { metaCache[absolutePath] = {} return {} } - if (!PAGE_META_RE.test(script)) { - metaCache[absolutePath] = {} - return {} - } - - const js = await transform(script, { loader: 'ts' }) - const ast = parse(js.code, { - sourceType: 'module', - ecmaVersion: 'latest', - ranges: true - }) as unknown as Program - const pageMetaAST = ast.body.find(node => node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && node.expression.callee.name === 'definePageMeta') - if (!pageMetaAST) { - metaCache[absolutePath] = {} - return {} - } - - const pageMetaArgument = ((pageMetaAST as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression const extractedMeta = {} as Partial<Record<keyof NuxtPage, any>> - const extractionKeys = ['name', 'path', 'alias', 'redirect'] as const - const dynamicProperties = new Set<keyof NuxtPage>() - for (const key of extractionKeys) { - const property = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === key) as Property - if (!property) { continue } - - if (property.value.type === 'ObjectExpression') { - const valueString = js.code.slice(property.value.range![0], property.value.range![1]) - try { - extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {})) - } catch { - console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not JSON-serializable (reading \`${absolutePath}\`).`) - dynamicProperties.add(key) - continue - } - } - - if (property.value.type === 'ArrayExpression') { - const values = [] - for (const element of property.value.elements) { - if (!element) { - continue - } - if (element.type !== 'Literal' || typeof element.value !== 'string') { - console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not an array of string literals (reading \`${absolutePath}\`).`) - dynamicProperties.add(key) - continue - } - values.push(element.value) - } - extractedMeta[key] = values + for (const script of scriptBlocks) { + if (!PAGE_META_RE.test(script.code)) { continue } - if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') { - console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`) - dynamicProperties.add(key) - continue - } - extractedMeta[key] = property.value.value - } + const js = await transform(script.code, { loader: script.loader }) + const ast = parse(js.code, { + sourceType: 'module', + ecmaVersion: 'latest', + ranges: true, + }) as unknown as Program - const extraneousMetaKeys = pageMetaArgument.properties - .filter(property => property.type === 'Property' && property.key.type === 'Identifier' && !(extractionKeys as unknown as string[]).includes(property.key.name)) - // @ts-expect-error inferred types have been filtered out - .map(property => property.key.name) + const dynamicProperties = new Set<keyof NuxtPage>() - if (extraneousMetaKeys.length) { - dynamicProperties.add('meta') - } + let foundMeta = false - if (dynamicProperties.size) { - extractedMeta.meta ??= {} - extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties + walk(ast, { + enter (node) { + if (foundMeta) { return } + + if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression' || node.expression.callee.type !== 'Identifier' || node.expression.callee.name !== 'definePageMeta') { return } + + foundMeta = true + const pageMetaArgument = ((node as ExpressionStatement).expression as CallExpression).arguments[0] as ObjectExpression + + for (const key of extractionKeys) { + const property = pageMetaArgument.properties.find(property => property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === key) as Property + if (!property) { continue } + + if (property.value.type === 'ObjectExpression') { + const valueString = js.code.slice(property.value.range![0], property.value.range![1]) + try { + extractedMeta[key] = JSON.parse(runInNewContext(`JSON.stringify(${valueString})`, {})) + } catch { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not JSON-serializable (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + } + + if (property.value.type === 'ArrayExpression') { + const values: string[] = [] + for (const element of property.value.elements) { + if (!element) { + continue + } + if (element.type !== 'Literal' || typeof element.value !== 'string') { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not an array of string literals (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + values.push(element.value) + } + extractedMeta[key] = values + continue + } + + if (property.value.type !== 'Literal' || typeof property.value.value !== 'string') { + console.debug(`[nuxt] Skipping extraction of \`${key}\` metadata as it is not a string literal or array of string literals (reading \`${absolutePath}\`).`) + dynamicProperties.add(key) + continue + } + extractedMeta[key] = property.value.value + } + + for (const property of pageMetaArgument.properties) { + if (property.type !== 'Property') { + continue + } + const isIdentifierOrLiteral = property.key.type === 'Literal' || property.key.type === 'Identifier' + if (!isIdentifierOrLiteral) { + continue + } + const name = property.key.type === 'Identifier' ? property.key.name : String(property.value) + if (!(extractionKeys as unknown as string[]).includes(name)) { + dynamicProperties.add('meta') + break + } + } + + if (dynamicProperties.size) { + extractedMeta.meta ??= {} + extractedMeta.meta[DYNAMIC_META_KEY] = dynamicProperties + } + }, + }) } metaCache[absolutePath] = extractedMeta @@ -242,12 +311,14 @@ function getRoutePath (tokens: SegmentToken[]): string { ? `:${token.value}()` : token.type === SegmentTokenType.catchall ? `:${token.value}(.*)*` - : encodePath(token.value).replace(/:/g, '\\:')) + : token.type === SegmentTokenType.group + ? '' + : encodePath(token.value).replace(/:/g, '\\:')) ) }, '/') } -const PARAM_CHAR_RE = /[\w\d_.]/ +const PARAM_CHAR_RE = /[\w.]/ function parseSegment (segment: string) { let state: SegmentParserState = SegmentParserState.initial @@ -272,8 +343,10 @@ function parseSegment (segment: string) { ? SegmentTokenType.dynamic : state === SegmentParserState.optional ? SegmentTokenType.optional - : SegmentTokenType.catchall, - value: buffer + : state === SegmentParserState.catchall + ? SegmentTokenType.catchall + : SegmentTokenType.group, + value: buffer, }) buffer = '' @@ -287,6 +360,8 @@ function parseSegment (segment: string) { buffer = '' if (c === '[') { state = SegmentParserState.dynamic + } else if (c === '(') { + state = SegmentParserState.group } else { i-- state = SegmentParserState.static @@ -297,6 +372,9 @@ function parseSegment (segment: string) { if (c === '[') { consumeBuffer() state = SegmentParserState.dynamic + } else if (c === '(') { + consumeBuffer() + state = SegmentParserState.group } else { buffer += c } @@ -305,6 +383,7 @@ function parseSegment (segment: string) { case SegmentParserState.catchall: case SegmentParserState.dynamic: case SegmentParserState.optional: + case SegmentParserState.group: if (buffer === '...') { buffer = '' state = SegmentParserState.catchall @@ -319,10 +398,16 @@ function parseSegment (segment: string) { consumeBuffer() } state = SegmentParserState.initial - } else if (PARAM_CHAR_RE.test(c)) { + } else if (c === ')' && state === SegmentParserState.group) { + if (!buffer) { + throw new Error('Empty group') + } else { + consumeBuffer() + } + state = SegmentParserState.initial + } else if (c && PARAM_CHAR_RE.test(c)) { buffer += c } else { - // console.debug(`[pages]Ignored character "${c}" while building param "${buffer}" from "segment"`) } break @@ -385,7 +470,7 @@ function prepareRoutes (routes: NuxtPage[], parent?: NuxtPage, names = new Set<s } function serializeRouteValue (value: any, skipSerialisation = false) { - if (skipSerialisation || value === undefined) return undefined + if (skipSerialisation || value === undefined) { return undefined } return JSON.stringify(value) } @@ -430,36 +515,64 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = } const file = normalize(page.file) - const metaImportName = genSafeVariableName(filename(file) + hash(file)) + 'Meta' + const pageImportName = genSafeVariableName(filename(file) + hash(file)) + const metaImportName = pageImportName + 'Meta' metaImports.add(genImport(`${file}?macro=true`, [{ name: 'default', as: metaImportName }])) + if (page._sync) { + metaImports.add(genImport(file, [{ name: 'default', as: pageImportName }])) + } + + const pageImport = page._sync && page.mode !== 'client' ? pageImportName : genDynamicImport(file) + const metaRoute: NormalizedRoute = { name: `${metaImportName}?.name ?? ${route.name}`, path: `${metaImportName}?.path ?? ${route.path}`, meta: `${metaImportName} || {}`, alias: `${metaImportName}?.alias || []`, redirect: `${metaImportName}?.redirect`, - component: genDynamicImport(file, { interopDefault: true }) + component: page.mode === 'server' + ? `() => createIslandPage(${route.name})` + : page.mode === 'client' + ? `() => createClientPage(${pageImport})` + : pageImport, } - if (route.children != null) { + if (page.mode === 'server') { + metaImports.add(` +let _createIslandPage +async function createIslandPage (name) { + _createIslandPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/server-component'))}).then(r => r.createIslandPage) + return _createIslandPage(name) +};`) + } else if (page.mode === 'client') { + metaImports.add(` +let _createClientPage +async function createClientPage(loader) { + _createClientPage ||= await import(${JSON.stringify(resolve(distDir, 'components/runtime/client-component'))}).then(r => r.createClientPage) + return _createClientPage(loader); +}`) + } + + if (route.children) { metaRoute.children = route.children } - if (overrideMeta) { - metaRoute.name = `${metaImportName}?.name` - metaRoute.path = `${metaImportName}?.path ?? ''` + if (route.meta) { + metaRoute.meta = `{ ...(${metaImportName} || {}), ...${route.meta} }` + } + if (overrideMeta) { // skip and retain fallback if marked dynamic // set to extracted value or fallback if none extracted for (const key of ['name', 'path'] satisfies NormalizedRouteKeys) { - if (markedDynamic.has(key)) continue - metaRoute[key] = route[key] ?? metaRoute[key] + if (markedDynamic.has(key)) { continue } + metaRoute[key] = route[key] ?? `${metaImportName}?.${key}` } // set to extracted value or delete if none extracted for (const key of ['meta', 'alias', 'redirect'] satisfies NormalizedRouteKeys) { - if (markedDynamic.has(key)) continue + if (markedDynamic.has(key)) { continue } if (route[key] == null) { delete metaRoute[key] @@ -469,10 +582,6 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = metaRoute[key] = route[key] } } else { - if (route.meta != null) { - metaRoute.meta = `{ ...(${metaImportName} || {}), ...${route.meta} }` - } - if (route.alias != null) { metaRoute.alias = `${route.alias}.concat(${metaImportName}?.alias || [])` } @@ -483,7 +592,7 @@ export function normalizeRoutes (routes: NuxtPage[], metaImports: Set<string> = } return metaRoute - })) + })), } } @@ -496,5 +605,12 @@ export function pathToNitroGlob (path: string) { return null } - return path.replace(/\/(?:[^:/]+)?:\w+.*$/, '/**') + return path.replace(/\/[^:/]*:\w.*$/, '/**') +} + +export function resolveRoutePaths (page: NuxtPage, parent = '/'): string[] { + return [ + joinURL(parent, page.path), + ...page.children?.flatMap(child => resolveRoutePaths(child, joinURL(parent, page.path))) || [], + ] } diff --git a/packages/nuxt/src/utils.ts b/packages/nuxt/src/utils.ts index 4cc2040ad9..03fd928135 100644 --- a/packages/nuxt/src/utils.ts +++ b/packages/nuxt/src/utils.ts @@ -1,3 +1,10 @@ +import { promises as fsp } from 'node:fs' + +/** @since 3.9.0 */ export function toArray<T> (value: T | T[]): T[] { return Array.isArray(value) ? value : [value] } + +export async function isDirectory (path: string) { + return (await fsp.lstat(path)).isDirectory() +} diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap index 11885f3625..8bc2211aff 100644 --- a/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-disabled.test.ts.snap @@ -2,7 +2,7 @@ "pushed route, skips generation from file": [ { "alias": "["pushed-route-alias"].concat(mockMeta?.alias || [])", - "component": "() => import("pages/route-file.vue").then(m => m.default || m)", + "component": "() => import("pages/route-file.vue")", "meta": "{ ...(mockMeta || {}), ...{"someMetaData":true} }", "name": "mockMeta?.name ?? "pushed-route"", "path": "mockMeta?.path ?? "/"", @@ -17,10 +17,20 @@ "path": ""/"", }, ], + "route.meta generated from file": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/page-with-meta.vue")", + "meta": "{ ...(mockMeta || {}), ...{"test":1} }", + "name": "mockMeta?.name ?? "page-with-meta"", + "path": "mockMeta?.path ?? "/page-with-meta"", + "redirect": "mockMeta?.redirect", + }, + ], "should allow pages with `:` in their path": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/test:name.vue").then(m => m.default || m)", + "component": "() => import("pages/test:name.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "test:name"", "path": "mockMeta?.path ?? "/test\\:name"", @@ -36,7 +46,7 @@ "children": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/param/index/index.vue").then(m => m.default || m)", + "component": "() => import("pages/param/index/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "param-index"", "path": "mockMeta?.path ?? """, @@ -44,14 +54,14 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/param/index/sibling.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "param-index-sibling"", "path": "mockMeta?.path ?? "sibling"", "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/param/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? undefined", "path": "mockMeta?.path ?? """, @@ -59,14 +69,14 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/param/sibling.vue").then(m => m.default || m)", + "component": "() => import("pages/param/sibling.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "param-sibling"", "path": "mockMeta?.path ?? "sibling"", "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("pages/param.vue").then(m => m.default || m)", + "component": "() => import("pages/param.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? undefined", "path": "mockMeta?.path ?? "/param"", @@ -77,7 +87,7 @@ "children": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/wrapper-expose/other/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "wrapper-expose-other"", "path": "mockMeta?.path ?? """, @@ -85,14 +95,14 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)", + "component": "() => import("pages/wrapper-expose/other/sibling.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "wrapper-expose-other-sibling"", "path": "mockMeta?.path ?? "sibling"", "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)", + "component": "() => import("pages/wrapper-expose/other.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? undefined", "path": "mockMeta?.path ?? "/wrapper-expose/other"", @@ -102,7 +112,7 @@ "should extract serializable values and override fallback when normalized with `overrideMeta: true`": [ { "alias": "["sweet-home"].concat(mockMeta?.alias || [])", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "home"", "path": "mockMeta?.path ?? "/"", @@ -112,7 +122,7 @@ "should generate correct catch-all route": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[...slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[...slug].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "slug"", "path": "mockMeta?.path ?? "/:slug(.*)*"", @@ -120,7 +130,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index"", "path": "mockMeta?.path ?? "/"", @@ -130,7 +140,7 @@ "should generate correct dynamic routes": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index"", "path": "mockMeta?.path ?? "/"", @@ -138,7 +148,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[slug].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "slug"", "path": "mockMeta?.path ?? "/:slug()"", @@ -149,14 +159,14 @@ "children": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)", + "component": "() => import("pages/[[foo]]/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "foo"", "path": "mockMeta?.path ?? """, "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("pages/[[foo]]").then(m => m.default || m)", + "component": "() => import("pages/[[foo]]")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? undefined", "path": "mockMeta?.path ?? "/:foo?"", @@ -164,7 +174,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)", + "component": "() => import("pages/optional/[[opt]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "optional-opt"", "path": "mockMeta?.path ?? "/optional/:opt?"", @@ -172,7 +182,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)", + "component": "() => import("pages/optional/prefix-[[opt]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "optional-prefix-opt"", "path": "mockMeta?.path ?? "/optional/prefix-:opt?"", @@ -180,7 +190,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)", + "component": "() => import("pages/optional/[[opt]]-postfix.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "optional-opt-postfix"", "path": "mockMeta?.path ?? "/optional/:opt?-postfix"", @@ -188,7 +198,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)", + "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "optional-prefix-opt-postfix"", "path": "mockMeta?.path ?? "/optional/prefix-:opt?-postfix"", @@ -196,7 +206,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)", + "component": "() => import("pages/[bar]/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "bar"", "path": "mockMeta?.path ?? "/:bar()"", @@ -204,7 +214,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/nonopt/[slug].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "nonopt-slug"", "path": "mockMeta?.path ?? "/nonopt/:slug()"", @@ -212,7 +222,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)", + "component": "() => import("pages/opt/[[slug]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "opt-slug"", "path": "mockMeta?.path ?? "/opt/:slug?"", @@ -220,7 +230,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[[sub]]/route-[slug].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "sub-route-slug"", "path": "mockMeta?.path ?? "/:sub?/route-:slug()"", @@ -230,7 +240,7 @@ "should generate correct id for catchall (order 1)": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "component": "() => import("pages/[...stories].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "stories"", "path": "mockMeta?.path ?? "/:stories(.*)*"", @@ -238,7 +248,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "component": "() => import("pages/stories/[id].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "stories-id"", "path": "mockMeta?.path ?? "/stories/:id()"", @@ -248,7 +258,7 @@ "should generate correct id for catchall (order 2)": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "component": "() => import("pages/stories/[id].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "stories-id"", "path": "mockMeta?.path ?? "/stories/:id()"", @@ -256,7 +266,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "component": "() => import("pages/[...stories].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "stories"", "path": "mockMeta?.path ?? "/:stories(.*)*"", @@ -266,7 +276,7 @@ "should generate correct route for kebab-case file": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/kebab-case.vue").then(m => m.default || m)", + "component": "() => import("pages/kebab-case.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "kebab-case"", "path": "mockMeta?.path ?? "/kebab-case"", @@ -276,7 +286,7 @@ "should generate correct route for snake_case file": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/snake_case.vue").then(m => m.default || m)", + "component": "() => import("pages/snake_case.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "snake_case"", "path": "mockMeta?.path ?? "/snake_case"", @@ -286,7 +296,7 @@ "should generate correct routes for index pages": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index"", "path": "mockMeta?.path ?? "/"", @@ -294,7 +304,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/parent/index.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent"", "path": "mockMeta?.path ?? "/parent"", @@ -302,7 +312,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/child/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent-child"", "path": "mockMeta?.path ?? "/parent/child"", @@ -315,44 +325,82 @@ "children": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/parent/child.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/child.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent-child"", "path": "mockMeta?.path ?? "child"", "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("pages/parent.vue").then(m => m.default || m)", + "component": "() => import("pages/parent.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent"", "path": "mockMeta?.path ?? "/parent"", "redirect": "mockMeta?.redirect", }, ], + "should handle route groups": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/(foo)/index.vue")", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "index"", + "path": "mockMeta?.path ?? "/"", + "redirect": "mockMeta?.redirect", + }, + { + "alias": "mockMeta?.alias || []", + "children": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/(bar)/about/index.vue")", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? "about"", + "path": "mockMeta?.path ?? """, + "redirect": "mockMeta?.redirect", + }, + ], + "component": "() => import("pages/(foo)/about.vue")", + "meta": "mockMeta || {}", + "name": "mockMeta?.name ?? undefined", + "path": "mockMeta?.path ?? "/about"", + "redirect": "mockMeta?.redirect", + }, + ], "should handle trailing slashes with index routes": [ { "alias": "mockMeta?.alias || []", "children": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index/index/all.vue").then(m => m.default || m)", + "component": "() => import("pages/index/index/all.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index-index-all"", "path": "mockMeta?.path ?? "all"", "redirect": "mockMeta?.redirect", }, ], - "component": "() => import("pages/index/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index"", "path": "mockMeta?.path ?? "/"", "redirect": "mockMeta?.redirect", }, ], + "should merge route.meta with meta from file": [ + { + "alias": "mockMeta?.alias || []", + "component": "() => import("pages/page-with-meta.vue")", + "meta": "{ ...(mockMeta || {}), ...{"test":1} }", + "name": "mockMeta?.name ?? "page-with-meta"", + "path": "mockMeta?.path ?? "/page-with-meta"", + "redirect": "mockMeta?.redirect", + }, + ], "should not generate colliding route names when hyphens are in file name": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/parent/[child].vue").then(m => m.default || m)", + "component": "() => import("pages/parent/[child].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent-child"", "path": "mockMeta?.path ?? "/parent/:child()"", @@ -360,7 +408,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/parent-[child].vue").then(m => m.default || m)", + "component": "() => import("pages/parent-[child].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "parent-child"", "path": "mockMeta?.path ?? "/parent-:child()"", @@ -370,7 +418,7 @@ "should not merge required param as a child of optional param": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[[foo]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[foo]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "foo"", "path": "mockMeta?.path ?? "/:foo?"", @@ -378,7 +426,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[foo].vue").then(m => m.default || m)", + "component": "() => import("pages/[foo].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "foo"", "path": "mockMeta?.path ?? "/:foo()"", @@ -388,7 +436,7 @@ "should only allow "_" & "." as special character for dynamic route": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)", + "component": "() => import("pages/[a1_1a].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "a1_1a"", "path": "mockMeta?.path ?? "/:a1_1a()"", @@ -396,7 +444,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)", + "component": "() => import("pages/[b2.2b].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "b2.2b"", "path": "mockMeta?.path ?? "/:b2.2b()"", @@ -404,7 +452,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)", + "component": "() => import("pages/[b2]_[2b].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "b2_2b"", "path": "mockMeta?.path ?? "/:b2()_:2b()"", @@ -412,7 +460,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[c3@3c]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "c33c"", "path": "mockMeta?.path ?? "/:c33c?"", @@ -420,7 +468,7 @@ }, { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[d4-4d]].vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "d44d"", "path": "mockMeta?.path ?? "/:d44d?"", @@ -430,7 +478,7 @@ "should properly override route name if definePageMeta name override is defined.": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "home"", "path": "mockMeta?.path ?? "/"", @@ -440,7 +488,7 @@ "should use fallbacks when normalized with `overrideMeta: true`": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": "mockMeta?.name ?? "index"", "path": "mockMeta?.path ?? "/"", diff --git a/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap index 88989345d1..26a4cc97a1 100644 --- a/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap +++ b/packages/nuxt/test/__snapshots__/pages-override-meta-enabled.test.ts.snap @@ -2,7 +2,7 @@ "pushed route, skips generation from file": [ { "alias": "["pushed-route-alias"]", - "component": "() => import("pages/route-file.vue").then(m => m.default || m)", + "component": "() => import("pages/route-file.vue")", "meta": "{"someMetaData":true}", "name": ""pushed-route"", "path": ""/"", @@ -16,9 +16,17 @@ "path": ""/"", }, ], + "route.meta generated from file": [ + { + "component": "() => import("pages/page-with-meta.vue")", + "meta": "{"test":1}", + "name": ""page-with-meta"", + "path": ""/page-with-meta"", + }, + ], "should allow pages with `:` in their path": [ { - "component": "() => import("pages/test:name.vue").then(m => m.default || m)", + "component": "() => import("pages/test:name.vue")", "name": ""test:name"", "path": ""/test\\:name"", }, @@ -29,44 +37,44 @@ { "children": [ { - "component": "() => import("pages/param/index/index.vue").then(m => m.default || m)", + "component": "() => import("pages/param/index/index.vue")", "name": ""param-index"", "path": """", }, { - "component": "() => import("layer/pages/param/index/sibling.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/param/index/sibling.vue")", "name": ""param-index-sibling"", "path": ""sibling"", }, ], - "component": "() => import("layer/pages/param/index.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/param/index.vue")", "name": "mockMeta?.name", "path": """", }, { - "component": "() => import("pages/param/sibling.vue").then(m => m.default || m)", + "component": "() => import("pages/param/sibling.vue")", "name": ""param-sibling"", "path": ""sibling"", }, ], - "component": "() => import("pages/param.vue").then(m => m.default || m)", + "component": "() => import("pages/param.vue")", "name": "mockMeta?.name", "path": ""/param"", }, { "children": [ { - "component": "() => import("layer/pages/wrapper-expose/other/index.vue").then(m => m.default || m)", + "component": "() => import("layer/pages/wrapper-expose/other/index.vue")", "name": ""wrapper-expose-other"", "path": """", }, { - "component": "() => import("pages/wrapper-expose/other/sibling.vue").then(m => m.default || m)", + "component": "() => import("pages/wrapper-expose/other/sibling.vue")", "name": ""wrapper-expose-other-sibling"", "path": ""sibling"", }, ], - "component": "() => import("pages/wrapper-expose/other.vue").then(m => m.default || m)", + "component": "() => import("pages/wrapper-expose/other.vue")", "name": "mockMeta?.name", "path": ""/wrapper-expose/other"", }, @@ -74,7 +82,7 @@ "should extract serializable values and override fallback when normalized with `overrideMeta: true`": [ { "alias": "["sweet-home"]", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", "name": ""home"", "path": ""/"", @@ -83,131 +91,131 @@ ], "should generate correct catch-all route": [ { - "component": "() => import("pages/[...slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[...slug].vue")", "name": ""slug"", "path": ""/:slug(.*)*"", }, { - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "name": ""index"", "path": ""/"", }, ], "should generate correct dynamic routes": [ { - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "name": ""index"", "path": ""/"", }, { - "component": "() => import("pages/[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[slug].vue")", "name": ""slug"", "path": ""/:slug()"", }, { "children": [ { - "component": "() => import("pages/[[foo]]/index.vue").then(m => m.default || m)", + "component": "() => import("pages/[[foo]]/index.vue")", "name": ""foo"", "path": """", }, ], - "component": "() => import("pages/[[foo]]").then(m => m.default || m)", + "component": "() => import("pages/[[foo]]")", "name": "mockMeta?.name", "path": ""/:foo?"", }, { - "component": "() => import("pages/optional/[[opt]].vue").then(m => m.default || m)", + "component": "() => import("pages/optional/[[opt]].vue")", "name": ""optional-opt"", "path": ""/optional/:opt?"", }, { - "component": "() => import("pages/optional/prefix-[[opt]].vue").then(m => m.default || m)", + "component": "() => import("pages/optional/prefix-[[opt]].vue")", "name": ""optional-prefix-opt"", "path": ""/optional/prefix-:opt?"", }, { - "component": "() => import("pages/optional/[[opt]]-postfix.vue").then(m => m.default || m)", + "component": "() => import("pages/optional/[[opt]]-postfix.vue")", "name": ""optional-opt-postfix"", "path": ""/optional/:opt?-postfix"", }, { - "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue").then(m => m.default || m)", + "component": "() => import("pages/optional/prefix-[[opt]]-postfix.vue")", "name": ""optional-prefix-opt-postfix"", "path": ""/optional/prefix-:opt?-postfix"", }, { - "component": "() => import("pages/[bar]/index.vue").then(m => m.default || m)", + "component": "() => import("pages/[bar]/index.vue")", "name": ""bar"", "path": ""/:bar()"", }, { - "component": "() => import("pages/nonopt/[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/nonopt/[slug].vue")", "name": ""nonopt-slug"", "path": ""/nonopt/:slug()"", }, { - "component": "() => import("pages/opt/[[slug]].vue").then(m => m.default || m)", + "component": "() => import("pages/opt/[[slug]].vue")", "name": ""opt-slug"", "path": ""/opt/:slug?"", }, { - "component": "() => import("pages/[[sub]]/route-[slug].vue").then(m => m.default || m)", + "component": "() => import("pages/[[sub]]/route-[slug].vue")", "name": ""sub-route-slug"", "path": ""/:sub?/route-:slug()"", }, ], "should generate correct id for catchall (order 1)": [ { - "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "component": "() => import("pages/[...stories].vue")", "name": ""stories"", "path": ""/:stories(.*)*"", }, { - "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "component": "() => import("pages/stories/[id].vue")", "name": ""stories-id"", "path": ""/stories/:id()"", }, ], "should generate correct id for catchall (order 2)": [ { - "component": "() => import("pages/stories/[id].vue").then(m => m.default || m)", + "component": "() => import("pages/stories/[id].vue")", "name": ""stories-id"", "path": ""/stories/:id()"", }, { - "component": "() => import("pages/[...stories].vue").then(m => m.default || m)", + "component": "() => import("pages/[...stories].vue")", "name": ""stories"", "path": ""/:stories(.*)*"", }, ], "should generate correct route for kebab-case file": [ { - "component": "() => import("pages/kebab-case.vue").then(m => m.default || m)", + "component": "() => import("pages/kebab-case.vue")", "name": ""kebab-case"", "path": ""/kebab-case"", }, ], "should generate correct route for snake_case file": [ { - "component": "() => import("pages/snake_case.vue").then(m => m.default || m)", + "component": "() => import("pages/snake_case.vue")", "name": ""snake_case"", "path": ""/snake_case"", }, ], "should generate correct routes for index pages": [ { - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "name": ""index"", "path": ""/"", }, { - "component": "() => import("pages/parent/index.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/index.vue")", "name": ""parent"", "path": ""/parent"", }, { - "component": "() => import("pages/parent/child/index.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/child/index.vue")", "name": ""parent-child"", "path": ""/parent/child"", }, @@ -216,84 +224,111 @@ { "children": [ { - "component": "() => import("pages/parent/child.vue").then(m => m.default || m)", + "component": "() => import("pages/parent/child.vue")", "name": ""parent-child"", "path": ""child"", }, ], - "component": "() => import("pages/parent.vue").then(m => m.default || m)", + "component": "() => import("pages/parent.vue")", "name": ""parent"", "path": ""/parent"", }, ], + "should handle route groups": [ + { + "component": "() => import("pages/(foo)/index.vue")", + "name": ""index"", + "path": ""/"", + }, + { + "children": [ + { + "component": "() => import("pages/(bar)/about/index.vue")", + "name": ""about"", + "path": """", + }, + ], + "component": "() => import("pages/(foo)/about.vue")", + "name": "mockMeta?.name", + "path": ""/about"", + }, + ], "should handle trailing slashes with index routes": [ { "children": [ { - "component": "() => import("pages/index/index/all.vue").then(m => m.default || m)", + "component": "() => import("pages/index/index/all.vue")", "name": ""index-index-all"", "path": ""all"", }, ], - "component": "() => import("pages/index/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index/index.vue")", "name": ""index"", "path": ""/"", }, ], + "should merge route.meta with meta from file": [ + { + "component": "() => import("pages/page-with-meta.vue")", + "meta": "{ ...(mockMeta || {}), ...{"test":1} }", + "name": ""page-with-meta"", + "path": ""/page-with-meta"", + }, + ], "should not generate colliding route names when hyphens are in file name": [ { - "component": "() => import("pages/parent/[child].vue").then(m => m.default || m)", + "component": "() => import("pages/parent/[child].vue")", "name": ""parent-child"", "path": ""/parent/:child()"", }, { - "component": "() => import("pages/parent-[child].vue").then(m => m.default || m)", + "component": "() => import("pages/parent-[child].vue")", "name": ""parent-child"", "path": ""/parent-:child()"", }, ], "should not merge required param as a child of optional param": [ { - "component": "() => import("pages/[[foo]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[foo]].vue")", "name": ""foo"", "path": ""/:foo?"", }, { - "component": "() => import("pages/[foo].vue").then(m => m.default || m)", + "component": "() => import("pages/[foo].vue")", "name": ""foo"", "path": ""/:foo()"", }, ], "should only allow "_" & "." as special character for dynamic route": [ { - "component": "() => import("pages/[a1_1a].vue").then(m => m.default || m)", + "component": "() => import("pages/[a1_1a].vue")", "name": ""a1_1a"", "path": ""/:a1_1a()"", }, { - "component": "() => import("pages/[b2.2b].vue").then(m => m.default || m)", + "component": "() => import("pages/[b2.2b].vue")", "name": ""b2.2b"", "path": ""/:b2.2b()"", }, { - "component": "() => import("pages/[b2]_[2b].vue").then(m => m.default || m)", + "component": "() => import("pages/[b2]_[2b].vue")", "name": ""b2_2b"", "path": ""/:b2()_:2b()"", }, { - "component": "() => import("pages/[[c3@3c]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[c3@3c]].vue")", "name": ""c33c"", "path": ""/:c33c?"", }, { - "component": "() => import("pages/[[d4-4d]].vue").then(m => m.default || m)", + "component": "() => import("pages/[[d4-4d]].vue")", "name": ""d44d"", "path": ""/:d44d?"", }, ], "should properly override route name if definePageMeta name override is defined.": [ { - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "name": ""home"", "path": ""/"", }, @@ -301,9 +336,9 @@ "should use fallbacks when normalized with `overrideMeta: true`": [ { "alias": "mockMeta?.alias || []", - "component": "() => import("pages/index.vue").then(m => m.default || m)", + "component": "() => import("pages/index.vue")", "meta": "mockMeta || {}", - "name": "mockMeta?.name", + "name": "mockMeta?.name ?? "index"", "path": ""/"", "redirect": "mockMeta?.redirect", }, diff --git a/packages/nuxt/test/__snapshots__/treeshake-client.test.ts.snap b/packages/nuxt/test/__snapshots__/treeshake-client.test.ts.snap index d34f990205..dc32c7380b 100644 Binary files a/packages/nuxt/test/__snapshots__/treeshake-client.test.ts.snap and b/packages/nuxt/test/__snapshots__/treeshake-client.test.ts.snap differ diff --git a/packages/nuxt/test/app.test.ts b/packages/nuxt/test/app.test.ts index cb9075b6c5..1bdcb5f8aa 100644 --- a/packages/nuxt/test/app.test.ts +++ b/packages/nuxt/test/app.test.ts @@ -30,7 +30,7 @@ describe('resolveApp', () => { ".vue", ], "layouts": {}, - "mainComponent": "@nuxt/ui-templates/dist/templates/welcome.vue", + "mainComponent": "<repoRoot>/packages/nuxt/src/app/components/welcome.vue", "middleware": [ { "global": true, @@ -43,6 +43,10 @@ describe('resolveApp', () => { "mode": "client", "src": "<repoRoot>/packages/nuxt/src/app/plugins/payload.client.ts", }, + { + "mode": "client", + "src": "<repoRoot>/packages/nuxt/src/app/plugins/navigation-repaint.client.ts", + }, { "mode": "client", "src": "<repoRoot>/packages/nuxt/src/app/plugins/check-outdated-build.client.ts", @@ -55,6 +59,10 @@ describe('resolveApp', () => { "mode": "client", "src": "<repoRoot>/packages/nuxt/src/app/plugins/revive-payload.client.ts", }, + { + "mode": "client", + "src": "<repoRoot>/packages/nuxt/src/app/plugins/chunk-reload.client.ts", + }, { "filename": "components.plugin.mjs", "getContents": [Function], @@ -69,10 +77,6 @@ describe('resolveApp', () => { "mode": "all", "src": "<repoRoot>/packages/nuxt/src/app/plugins/router.ts", }, - { - "mode": "client", - "src": "<repoRoot>/packages/nuxt/src/app/plugins/chunk-reload.client.ts", - }, ], "rootComponent": "<repoRoot>/packages/nuxt/src/app/components/nuxt-root.vue", "templates": [], @@ -130,8 +134,8 @@ describe('resolveApp', () => { 'plugins/object-named.ts', { name: 'nuxt.config.ts', - contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })' - } + contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })', + }, ]) const fixturePlugins = app.plugins.filter(p => !('getContents' in p) && p.src.includes('<rootDir>')).map(p => p.src) // TODO: support overriding named plugins @@ -167,8 +171,8 @@ describe('resolveApp', () => { 'middleware/named.ts', { name: 'nuxt.config.ts', - contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })' - } + contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })', + }, ]) const fixtureMiddleware = app.middleware.filter(p => p.path.includes('<rootDir>')).map(p => p.path) // TODO: fix this @@ -196,8 +200,8 @@ describe('resolveApp', () => { 'layouts/default.vue', { name: 'nuxt.config.ts', - contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })' - } + contents: 'export default defineNuxtConfig({ extends: [\'./layer2\', \'./layer1\'] })', + }, ]) expect(app.layouts).toMatchInlineSnapshot(` { @@ -222,7 +226,7 @@ describe('resolveApp', () => { 'layouts/thing/thing/thing.vue', 'layouts/desktop-base/base.vue', 'layouts/some.vue', - 'layouts/SomeOther/layout.ts' + 'layouts/SomeOther/layout.ts', ]) expect(app.layouts).toMatchInlineSnapshot(` { @@ -293,8 +297,8 @@ async function getResolvedApp (files: Array<string | { name: string, contents: s mw.path = normaliseToRepo(mw.path)! } - for (const layout in app.layouts) { - app.layouts[layout].file = normaliseToRepo(app.layouts[layout].file)! + for (const layout of Object.values(app.layouts)) { + layout.file = normaliseToRepo(layout.file)! } await nuxt.close() diff --git a/packages/nuxt/test/auto-imports.test.ts b/packages/nuxt/test/auto-imports.test.ts index 4c659ffbd7..58a0203f40 100644 --- a/packages/nuxt/test/auto-imports.test.ts +++ b/packages/nuxt/test/auto-imports.test.ts @@ -1,27 +1,30 @@ import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { join } from 'pathe' -import { createCommonJS, findExports } from 'mlly' +import { findExports } from 'mlly' import * as VueFunctions from 'vue' import type { Import } from 'unimport' import { createUnimport } from 'unimport' import type { Plugin } from 'vite' +import { registry as scriptRegistry } from '@nuxt/scripts/registry' import { TransformPlugin } from '../src/imports/transform' -import { defaultPresets } from '../src/imports/presets' +import { defaultPresets, scriptsStubsPreset } from '../src/imports/presets' describe('imports:transform', () => { const imports: Import[] = [ { name: 'ref', as: 'ref', from: 'vue' }, { name: 'computed', as: 'computed', from: 'bar' }, - { name: 'foo', as: 'foo', from: 'excluded' } + { name: 'foo', as: 'foo', from: 'excluded' }, ] const ctx = createUnimport({ - imports + injectAtEnd: true, + imports, }) - const transformPlugin = TransformPlugin.raw({ ctx, options: { transform: { exclude: [/node_modules/] } } }, { framework: 'rollup' }) as Plugin + const transformPlugin = TransformPlugin({ ctx, options: { transform: { exclude: [/node_modules/] } } }).raw({}, { framework: 'rollup' }) as Plugin const transform = async (source: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const result = await (transformPlugin.transform! as Function).call({ error: null, warn: null } as any, source, '') return typeof result === 'string' ? result : result?.code } @@ -54,12 +57,12 @@ describe('imports:transform', () => { }) }) -const excludedNuxtHelpers = ['useHydration', 'useHead', 'useSeoMeta', 'useServerSeoMeta'] +const excludedNuxtHelpers = ['useHydration', 'useHead', 'useSeoMeta', 'useServerSeoMeta', 'useId'] describe('imports:nuxt', () => { try { - const { __dirname } = createCommonJS(import.meta.url) - const entrypointContents = readFileSync(join(__dirname, '../src/app/composables/index.ts'), 'utf8') + const entrypointPath = fileURLToPath(new URL('../src/app/composables/index.ts', import.meta.url)) + const entrypointContents = readFileSync(entrypointPath, 'utf8') const names = findExports(entrypointContents).flatMap(i => i.names || i.name) for (let name of names) { @@ -73,7 +76,6 @@ describe('imports:nuxt', () => { } } catch (e) { it('should import composables', () => { - // eslint-disable-next-line no-console console.error(e) expect(false).toBe(true) }) @@ -170,7 +172,6 @@ const excludedVueHelpers = [ 'hydrate', 'initDirectivesForSSR', 'render', - 'useCssVars', 'vModelCheckbox', 'vModelDynamic', 'vModelRadio', @@ -181,7 +182,14 @@ const excludedVueHelpers = [ 'DeprecationTypes', 'ErrorCodes', 'TrackOpTypes', - 'TriggerOpTypes' + 'TriggerOpTypes', + 'useHost', + 'hydrateOnVisible', + 'hydrateOnMediaQuery', + 'hydrateOnInteraction', + 'hydrateOnIdle', + 'onWatcherCleanup', + 'getCurrentWatcher', ] describe('imports:vue', () => { @@ -194,3 +202,24 @@ describe('imports:vue', () => { }) } }) + +describe('imports:nuxt/scripts', () => { + const scripts = scriptRegistry().map(s => s.import?.name).filter(Boolean) + const globalScripts = new Set([ + 'useScript', + 'useScriptEventPage', + 'useScriptTriggerElement', + 'useScriptTriggerConsent', + // registered separately + 'useScriptGoogleTagManager', + 'useScriptGoogleAnalytics', + ]) + it.each(scriptsStubsPreset.imports)(`should register %s from @nuxt/scripts`, (name) => { + if (globalScripts.has(name)) { return } + + expect(scripts).toContain(name) + }) + it.each(scripts)(`should register %s from @nuxt/scripts`, (name) => { + expect(scriptsStubsPreset.imports).toContain(name) + }) +}) diff --git a/packages/nuxt/test/components-transform.test.ts b/packages/nuxt/test/components-transform.test.ts new file mode 100644 index 0000000000..167229a4dc --- /dev/null +++ b/packages/nuxt/test/components-transform.test.ts @@ -0,0 +1,116 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { Component, Nuxt } from '@nuxt/schema' +import { kebabCase } from 'scule' +import { normalize } from 'pathe' + +import { TransformPlugin } from '../src/components/plugins/transform' + +describe('components:transform', () => { + it('should transform #components imports', async () => { + const transform = createTransformer([ + createComponent('Foo'), + createComponent('Bar', { export: 'Bar' }), + ]) + + const code = await transform('import { Foo, Bar } from \'#components\'', '/app.vue') + expect(code).toMatchInlineSnapshot(` + " + import Foo from '/Foo.vue'; + import { Bar } from '/Bar.vue'; + " + `) + }) + + it('should correctly resolve server-only components', async () => { + const transform = createTransformer([ + createComponent('Foo', { mode: 'server' }), + ]) + + const code = await transform('import { Foo, LazyFoo } from \'#components\'', '/app.vue') + expect(code).toMatchInlineSnapshot(` + " + import Foo from '/Foo.vue?nuxt_component=server&nuxt_component_name=Foo&nuxt_component_export=default'; + import LazyFoo from '/Foo.vue?nuxt_component=server,async&nuxt_component_name=Foo&nuxt_component_export=default'; + " + `) + + expect(await transform('', '/Foo.vue?nuxt_component=server&nuxt_component_name=Foo&nuxt_component_export=default')).toMatchInlineSnapshot(` + "import { createServerComponent } from "<repo>/nuxt/src/components/runtime/server-component" + export default createServerComponent("Foo")" + `) + expect(await transform('', '/Foo.vue?nuxt_component=server,async&nuxt_component_name=Foo&nuxt_component_export=default')).toMatchInlineSnapshot(` + "import { createServerComponent } from "<repo>/nuxt/src/components/runtime/server-component" + export default createServerComponent("Foo")" + `) + expect(await transform('', '/Foo.vue?nuxt_component=server&nuxt_component_name=Foo&nuxt_component_export=Foo')).toMatchInlineSnapshot(` + "import { createServerComponent } from "<repo>/nuxt/src/components/runtime/server-component" + export const Foo = createServerComponent("Foo")" + `) + }) + + it('should correctly resolve client-only components', async () => { + const transform = createTransformer([ + createComponent('Foo', { mode: 'client' }), + ]) + + const code = await transform('import { Foo, LazyFoo } from \'#components\'', '/app.vue') + expect(code).toMatchInlineSnapshot(` + " + import Foo from '/Foo.vue?nuxt_component=client&nuxt_component_name=Foo&nuxt_component_export=default'; + import LazyFoo from '/Foo.vue?nuxt_component=client,async&nuxt_component_name=Foo&nuxt_component_export=default'; + " + `) + + expect(await transform('', '/Foo.vue?nuxt_component=client&nuxt_component_name=Foo&nuxt_component_export=default')).toMatchInlineSnapshot(` + "import { default as __component } from "/Foo.vue"; + import { createClientOnly } from "#app/components/client-only" + export default createClientOnly(__component)" + `) + expect(await transform('', '/Foo.vue?nuxt_component=client,async&nuxt_component_name=Foo&nuxt_component_export=default')).toMatchInlineSnapshot(` + "import { defineAsyncComponent } from "vue" + import { createClientOnly } from "#app/components/client-only" + export default defineAsyncComponent(() => import("/Foo.vue").then(r => createClientOnly(r["default"] || r.default || r)))" + `) + expect(await transform('', '/Foo.vue?nuxt_component=client,async&nuxt_component_name=Foo&nuxt_component_export=Foo')).toMatchInlineSnapshot(` + "import { defineAsyncComponent } from "vue" + import { createClientOnly } from "#app/components/client-only" + export const Foo = defineAsyncComponent(() => import("/Foo.vue").then(r => createClientOnly(r["Foo"] || r.default || r)))" + `) + }) +}) + +const rootDir = fileURLToPath(new URL('../..', import.meta.url)) + +function createTransformer (components: Component[], mode: 'client' | 'server' | 'all' = 'all') { + const stubNuxt = { + options: { + buildDir: '/', + sourcemap: { + server: false, + client: false, + }, + }, + } as Nuxt + const plugin = TransformPlugin(stubNuxt, () => components, mode).vite() + + return async (code: string, id: string) => { + const result = await (plugin as any).transform!(code, id) + return (typeof result === 'string' ? result : result?.code)?.replaceAll(normalize(rootDir), '<repo>/') + } +} + +function createComponent (pascalName: string, options: Partial<Component> = {}) { + return { + filePath: `/${pascalName}.vue`, + pascalName, + export: 'default', + chunkName: `components/${pascalName.toLowerCase()}`, + kebabName: kebabCase(pascalName), + mode: 'all', + prefetch: false, + preload: false, + shortPath: `components/${pascalName}.vue`, + ...options, + } satisfies Component +} diff --git a/packages/nuxt/test/devonly.test.ts b/packages/nuxt/test/devonly.test.ts index 1bb497fdc4..31a876e84c 100644 --- a/packages/nuxt/test/devonly.test.ts +++ b/packages/nuxt/test/devonly.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { Plugin } from 'vite' import { DevOnlyPlugin } from '../src/core/plugins/dev-only' import { normalizeLineEndings } from './utils' -const pluginVite = DevOnlyPlugin.raw({}, { framework: 'vite' }) as Plugin + +const pluginVite = DevOnlyPlugin({}).raw({}, { framework: 'vite' }) as Plugin const viteTransform = async (source: string, id: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const result = await (pluginVite.transform! as Function)(source, id) return typeof result === 'string' ? result : result?.code } @@ -57,7 +59,7 @@ describe('test devonly transform ', () => { expect(result).not.toContain('LazyDevOnly') }) - it('should not remove class -> nuxt#24491', async () => { + it('should not remove class -> nuxt#24491', async () => { const source = `<template> <DevOnly> <div class="red">This is red.</div> @@ -68,7 +70,7 @@ describe('test devonly transform ', () => { </template> ` - const result = await viteTransform(source, 'some id') + const result = await viteTransform(source, 'some id') expect(result).toMatchInlineSnapshot(` "<template> diff --git a/packages/nuxt/test/fixture/components/client/ComponentWithProps.vue b/packages/nuxt/test/fixture/components/client/ComponentWithProps.vue index 51d71012d8..da711f929e 100644 --- a/packages/nuxt/test/fixture/components/client/ComponentWithProps.vue +++ b/packages/nuxt/test/fixture/components/client/ComponentWithProps.vue @@ -6,6 +6,6 @@ <script setup lang="ts"> defineProps<{ - count?: number + count?: number }>() </script> diff --git a/packages/nuxt/test/import-protection.test.ts b/packages/nuxt/test/import-protection.test.ts index 13b2548584..110f47dfb1 100644 --- a/packages/nuxt/test/import-protection.test.ts +++ b/packages/nuxt/test/import-protection.test.ts @@ -1,6 +1,7 @@ import { normalize } from 'pathe' import { describe, expect, it } from 'vitest' -import { ImportProtectionPlugin, nuxtImportProtections } from '../src/core/plugins/import-protection' +import { ImpoundPlugin } from 'impound' +import { nuxtImportProtections } from '../src/core/plugins/import-protection' import type { NuxtOptions } from '../schema' const testsToTriggerOn = [ @@ -22,7 +23,7 @@ const testsToTriggerOn = [ ['/root/node_modules/@nuxt/kit', 'components/Component.vue', true], ['some-nuxt-module', 'components/Component.vue', true], ['/root/src/server/api/test.ts', 'components/Component.vue', true], - ['src/server/api/test.ts', 'components/Component.vue', true] + ['src/server/api/test.ts', 'components/Component.vue', true], ] as const describe('import protection', () => { @@ -38,16 +39,16 @@ describe('import protection', () => { }) const transformWithImportProtection = (id: string, importer: string) => { - const plugin = ImportProtectionPlugin.rollup({ - rootDir: '/root', + const plugin = ImpoundPlugin.rollup({ + cwd: '/root', patterns: nuxtImportProtections({ options: { modules: ['some-nuxt-module'], srcDir: '/root/src/', - serverDir: '/root/src/server' - } satisfies Partial<NuxtOptions> as NuxtOptions - }) + serverDir: '/root/src/server', + } satisfies Partial<NuxtOptions> as NuxtOptions, + }), }) - return (plugin as any).resolveId(id, importer) + return (plugin as any).resolveId.call({ error: () => {} }, id, importer) } diff --git a/packages/nuxt/test/islandTransform.test.ts b/packages/nuxt/test/island-transform.test.ts similarity index 66% rename from packages/nuxt/test/islandTransform.test.ts rename to packages/nuxt/test/island-transform.test.ts index 354a09f0e6..3743987565 100644 --- a/packages/nuxt/test/islandTransform.test.ts +++ b/packages/nuxt/test/island-transform.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Plugin } from 'vite' import type { Component } from '@nuxt/schema' import type { UnpluginOptions } from 'unplugin' -import { islandsTransform } from '../src/components/islandsTransform' +import { IslandsTransformPlugin } from '../src/components/plugins/islands-transform' import { normalizeLineEndings } from './utils' const getComponents = () => [{ @@ -15,27 +15,27 @@ const getComponents = () => [{ export: 'default', shortPath: '', prefetch: false, - preload: false + preload: false, }] as Component[] -const pluginWebpack = islandsTransform.raw({ +const pluginWebpack = IslandsTransformPlugin({ getComponents, - selectiveClient: true -}, { framework: 'webpack', webpack: { compiler: {} as any } }) + selectiveClient: true, +}).raw({}, { framework: 'webpack', webpack: { compiler: {} as any } }) -const viteTransform = async (source: string, id: string, isDev = false, selectiveClient = false) => { - const vitePlugin = islandsTransform.raw({ +const viteTransform = async (source: string, id: string, selectiveClient = false) => { + const vitePlugin = IslandsTransformPlugin({ getComponents, - rootDir: '/root', - isDev, - selectiveClient - }, { framework: 'vite' }) as Plugin + selectiveClient, + }).raw({}, { framework: 'vite' }) as Plugin + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const result = await (vitePlugin.transform! as Function)(source, id) return typeof result === 'string' ? result : result?.code } const webpackTransform = async (source: string, id: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const result = await ((pluginWebpack as UnpluginOptions).transform! as Function)(source, id) return typeof result === 'string' ? result : result?.code } @@ -58,21 +58,22 @@ describe('islandTransform - server and island components', () => { const someData = 'some data' </script>` - , 'hello.server.vue') + , 'hello.server.vue') expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> <div> <NuxtTeleportSsrSlot name="default" :props="undefined"><slot /></NuxtTeleportSsrSlot> - <NuxtTeleportSsrSlot name="named" :props="[{ some-data: someData }]"><slot name="named" :some-data="someData" /></NuxtTeleportSsrSlot> - <NuxtTeleportSsrSlot name="other" :props="[{ some-data: someData }]"><slot + <NuxtTeleportSsrSlot name="named" :props="[{ [\`some-data\`]: someData }]"><slot name="named" :some-data="someData" /></NuxtTeleportSsrSlot> + <NuxtTeleportSsrSlot name="other" :props="[{ [\`some-data\`]: someData }]"><slot name="other" :some-data="someData" /></NuxtTeleportSsrSlot> </div> </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -82,6 +83,42 @@ describe('islandTransform - server and island components', () => { `) }) + it('generates bindings when props are needed to be merged', async () => { + const result = await viteTransform(`<script setup lang="ts"> +withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), { + things: () => [], + somethingElse: "yay", +}); +</script> + +<template> + <template v-for="thing in things"> + <slot name="thing" v-bind="thing" /> + </template> +</template> +`, 'hello.server.vue') + + expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` + "<script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' + import { vforToArray as __vforToArray } from '#app/components/utils' + import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' + import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' + withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), { + things: () => [], + somethingElse: "yay", + }); + </script> + + <template> + <template v-for="thing in things"> + <NuxtTeleportSsrSlot name="thing" :props="[__mergeProps(thing, { })]"><slot name="thing" v-bind="thing" /></NuxtTeleportSsrSlot> + </template> + </template> + " + `) + }) + it('expect slot fallback transform to match inline snapshot', async () => { const result = await viteTransform(`<template> <div> @@ -94,17 +131,18 @@ describe('islandTransform - server and island components', () => { const someData = 'some data' </script>` - , 'hello.server.vue') + , 'hello.server.vue') expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> <div> - <NuxtTeleportSsrSlot name="default" :props="[{ some-data: someData }]"><slot :some-data="someData" /><template #fallback> + <NuxtTeleportSsrSlot name="default" :props="[{ [\`some-data\`]: someData }]"><slot :some-data="someData"/><template #fallback> <div>fallback</div> </template></NuxtTeleportSsrSlot> </div> </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -145,7 +183,7 @@ describe('islandTransform - server and island components', () => { const message = "Hello World"; </script> ` - , 'hello.server.vue') + , 'hello.server.vue') expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> @@ -158,7 +196,7 @@ describe('islandTransform - server and island components', () => { <p>message: {{ message }}</p> <p>Below is the slot I want to be hydrated on the client</p> <div> - <NuxtTeleportSsrSlot name="default" :props="undefined"><slot /><template #fallback> + <NuxtTeleportSsrSlot name="default" :props="undefined"><slot/><template #fallback> This is the default content of the slot, I should not see this after the client loading has completed. </template></NuxtTeleportSsrSlot> @@ -170,6 +208,7 @@ describe('islandTransform - server and island components', () => { </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -183,48 +222,39 @@ describe('islandTransform - server and island components', () => { " `) }) + + it('expect v-if/v-else/v-else-if to be set in teleport component wrapper', async () => { + const result = await viteTransform(`<script setup lang="ts"> + const foo = true; + </script> + <template> + <slot v-if="foo" /> + <slot v-else-if="test" /> + <slot v-else /> + </template> + `, 'WithVif.vue', true) + + expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` + "<script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' + import { vforToArray as __vforToArray } from '#app/components/utils' + import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' + import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' + const foo = true; + </script> + <template> + <NuxtTeleportSsrSlot v-if="foo" name="default" :props="undefined"><slot /></NuxtTeleportSsrSlot> + <NuxtTeleportSsrSlot v-else-if="test" name="default" :props="undefined"><slot /></NuxtTeleportSsrSlot> + <NuxtTeleportSsrSlot v-else name="default" :props="undefined"><slot /></NuxtTeleportSsrSlot> + </template> + " + `) + }) }) describe('nuxt-client', () => { describe('vite', () => { - it('test transform with vite in dev', async () => { - const result = await viteTransform(`<template> - <div> - <!-- should not be wrapped by NuxtTeleportIslandComponent --> - <HelloWorld /> - <!-- should be wrapped by NuxtTeleportIslandComponent with a rootDir attr --> - <HelloWorld nuxt-client /> - </div> - </template> - - <script setup lang="ts"> - import HelloWorld from './HelloWorld.vue' - </script> - `, 'hello.server.vue', true, true) - - expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` - "<template> - <div> - <!-- should not be wrapped by NuxtTeleportIslandComponent --> - <HelloWorld /> - <!-- should be wrapped by NuxtTeleportIslandComponent with a rootDir attr --> - <NuxtTeleportIslandComponent to="HelloWorld-ZsRS8qEyqK" root-dir="/root" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> - </div> - </template> - - <script setup lang="ts"> - import { vforToArray as __vforToArray } from '#app/components/utils' - import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' - import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' - import HelloWorld from './HelloWorld.vue' - </script> - " - `) - // root-dir prop should never be used in production - expect(result).toContain('root-dir="/root"') - }) - - it('test transform with vite in prod', async () => { + it('test transform with vite', async () => { const result = await viteTransform(`<template> <div> <HelloWorld /> @@ -235,17 +265,18 @@ describe('islandTransform - server and island components', () => { <script setup lang="ts"> import HelloWorld from './HelloWorld.vue' </script> - `, 'hello.server.vue', false, true) + `, 'hello.server.vue', true) expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> <div> <HelloWorld /> - <NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> + <NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> </div> </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -253,9 +284,6 @@ describe('islandTransform - server and island components', () => { </script> " `) - - // root-dir prop should never be used in production - expect(result).not.toContain('root-dir="') }) it('test dynamic nuxt-client', async () => { @@ -271,17 +299,18 @@ describe('islandTransform - server and island components', () => { const nuxtClient = false </script> - `, 'hello.server.vue', false, true) + `, 'hello.server.vue', true) expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> <div> <HelloWorld /> - <NuxtTeleportIslandComponent to="HelloWorld-eo0XycWCUV" :nuxt-client="nuxtClient"><HelloWorld /></NuxtTeleportIslandComponent> + <NuxtTeleportIslandComponent to="HelloWorld-eo0XycWCUV" :nuxt-client="nuxtClient"><HelloWorld /></NuxtTeleportIslandComponent> </div> </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -306,7 +335,7 @@ describe('islandTransform - server and island components', () => { const nuxtClient = false </script> - `, 'hello.server.vue', false, false) + `, 'hello.server.vue', false) expect(normalizeLineEndings(result)).toMatchInlineSnapshot(` "<template> @@ -317,6 +346,7 @@ describe('islandTransform - server and island components', () => { </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -336,22 +366,49 @@ describe('islandTransform - server and island components', () => { </div> </template> - `, 'hello.server.vue', false, true) + `, 'hello.server.vue', true) expect(result).toMatchInlineSnapshot(` - "<script setup> - import { vforToArray as __vforToArray } from '#app/components/utils' - import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' - import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template> - <div> - <HelloWorld /> - <NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> - </div> - </template> + "<script setup> + import { mergeProps as __mergeProps } from 'vue' + import { vforToArray as __vforToArray } from '#app/components/utils' + import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' + import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template> + <div> + <HelloWorld /> + <NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> + </div> + </template> - " - `) - expect(result).toContain(`import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component'`) + " + `) + expect(result).toContain('import NuxtTeleportIslandComponent from \'#app/components/nuxt-teleport-island-component\'') + }) + + it('should move v-if to the wrapper component', async () => { + const result = await viteTransform(`<template> + <div> + <HelloWorld v-if="false" nuxt-client /> + <HelloWorld v-else-if="true" nuxt-client /> + <HelloWorld v-else nuxt-client /> + </div> + </template> + `, 'hello.server.vue', true) + + expect(result).toMatchInlineSnapshot(` + "<script setup> + import { mergeProps as __mergeProps } from 'vue' + import { vforToArray as __vforToArray } from '#app/components/utils' + import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' + import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template> + <div> + <NuxtTeleportIslandComponent v-if="false" to="HelloWorld-D9uaHyzL7X" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> + <NuxtTeleportIslandComponent v-else-if="true" to="HelloWorld-o4RZMtArnE" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> + <NuxtTeleportIslandComponent v-else to="HelloWorld-m1IbXHdd8O" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> + </div> + </template> + " + `) }) }) @@ -386,6 +443,7 @@ describe('islandTransform - server and island components', () => { </template> <script setup lang="ts"> + import { mergeProps as __mergeProps } from 'vue' import { vforToArray as __vforToArray } from '#app/components/utils' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot' @@ -396,7 +454,7 @@ describe('islandTransform - server and island components', () => { " `) - expect(spyOnWarn).toHaveBeenCalledWith('nuxt-client attribute and client components within islands is only supported with Vite. file: hello.server.vue') + expect(spyOnWarn).toHaveBeenCalledWith('The `nuxt-client` attribute and client components within islands are only supported with Vite. file: hello.server.vue') }) }) }) diff --git a/packages/nuxt/test/load-nuxt.bench.ts b/packages/nuxt/test/load-nuxt.bench.ts index bb552ab396..483a96497c 100644 --- a/packages/nuxt/test/load-nuxt.bench.ts +++ b/packages/nuxt/test/load-nuxt.bench.ts @@ -11,7 +11,7 @@ describe('loadNuxt', () => { bench('empty directory', async () => { const nuxt = await loadNuxt({ cwd: emptyDir, - ready: true + ready: true, }) await nuxt.close() }) @@ -19,7 +19,7 @@ describe('loadNuxt', () => { bench('basic test fixture', async () => { const nuxt = await loadNuxt({ cwd: basicTestFixtureDir, - ready: true + ready: true, }) await nuxt.close() }) diff --git a/packages/nuxt/test/load-nuxt.test.ts b/packages/nuxt/test/load-nuxt.test.ts index e1ce3d950a..dffcdf7130 100644 --- a/packages/nuxt/test/load-nuxt.test.ts +++ b/packages/nuxt/test/load-nuxt.test.ts @@ -1,11 +1,32 @@ import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { normalize } from 'pathe' import { withoutTrailingSlash } from 'ufo' +import { readPackageJSON } from 'pkg-types' +import { inc } from 'semver' import { loadNuxt } from '../src' +import { version } from '../package.json' const repoRoot = withoutTrailingSlash(normalize(fileURLToPath(new URL('../../../', import.meta.url)))) +vi.stubGlobal('console', { + ...console, + error: vi.fn(console.error), + warn: vi.fn(console.warn), +}) + +vi.mock('pkg-types', async (og) => { + const originalPkgTypes = (await og<typeof import('pkg-types')>()) + return { + ...originalPkgTypes, + readPackageJSON: vi.fn(originalPkgTypes.readPackageJSON), + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + describe('loadNuxt', () => { it('respects hook overrides', async () => { let hookRan = false @@ -14,13 +35,55 @@ describe('loadNuxt', () => { ready: true, overrides: { hooks: { - ready() { + ready () { hookRan = true - } - } - } + }, + }, + }, }) await nuxt.close() expect(hookRan).toBe(true) }) }) + +describe('dependency mismatch', () => { + it('expect mismatched dependency to log a warning', async () => { + vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({ + version: '3.0.0', + })) + + const nuxt = await loadNuxt({ + cwd: repoRoot, + }) + + // @nuxt/kit is explicitly installed in repo root but @nuxt/schema isn't, so we only + // get warnings about @nuxt/schema + expect(console.warn).toHaveBeenCalledWith(`[nuxt] Expected \`@nuxt/kit\` to be at least \`${version}\` but got \`3.0.0\`. This might lead to unexpected behavior. Check your package.json or refresh your lockfile.`) + + vi.mocked(readPackageJSON).mockRestore() + await nuxt.close() + }) + it.each([ + { + name: 'nuxt version is lower', + depVersion: inc(version, 'minor'), + }, + { + name: 'version matches', + depVersion: version, + }, + ])('expect no warning when $name.', async ({ depVersion }) => { + vi.mocked(readPackageJSON).mockReturnValue(Promise.resolve({ + depVersion, + })) + + const nuxt = await loadNuxt({ + cwd: repoRoot, + }) + + expect(console.warn).not.toHaveBeenCalled() + + await nuxt.close() + vi.mocked(readPackageJSON).mockRestore() + }) +}) diff --git a/packages/nuxt/test/naming.test.ts b/packages/nuxt/test/naming.test.ts index 9aeb243387..bfb876897b 100644 --- a/packages/nuxt/test/naming.test.ts +++ b/packages/nuxt/test/naming.test.ts @@ -7,7 +7,7 @@ describe('getNameFromPath', () => { 'base.vue': 'base', 'base/base.vue': 'base', 'base/base-layout.vue': 'base-layout', - 'base-1-layout': 'base-1-layout' + 'base-1-layout': 'base-1-layout', } it.each(Object.keys(cases))('correctly deduplicates segments - %s', (filename) => { expect(getNameFromPath(filename)).toEqual(cases[filename]) @@ -32,7 +32,7 @@ const tests: Array<[string, string[], string]> = [ ['Icon', ['Icones'], 'IconesIcon'], ['IconHolder', ['IconHolder'], 'IconHolder'], ['GameList', ['Desktop', 'ShareGame', 'Review', 'Detail'], 'DesktopShareGameReviewDetailGameList'], - ['base-1-layout', [], 'Base1Layout'] + ['base-1-layout', [], 'Base1Layout'], ] describe('components:resolveComponentNameSegments', () => { diff --git a/packages/nuxt/test/nuxt-link.test.ts b/packages/nuxt/test/nuxt-link.test.ts index a7dacb916c..f57c5d43b0 100644 --- a/packages/nuxt/test/nuxt-link.test.ts +++ b/packages/nuxt/test/nuxt-link.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RouteLocation, RouteLocationRaw } from 'vue-router' +import { withQuery } from 'ufo' import type { NuxtLinkOptions, NuxtLinkProps } from '../src/app/components/nuxt-link' import { defineNuxtLink } from '../src/app/components/nuxt-link' import { useRuntimeConfig } from '../src/app/nuxt' @@ -8,9 +9,9 @@ import { useRuntimeConfig } from '../src/app/nuxt' vi.mock('../src/app/nuxt', () => ({ useRuntimeConfig: vi.fn(() => ({ app: { - baseURL: '/' - } - })) + baseURL: '/', + }, + })), })) // Mocks `h()` @@ -19,26 +20,28 @@ vi.mock('vue', async () => { return { ...vue, resolveComponent: (name: string) => name, - h: (...args: any[]) => args + h: (...args: any[]) => args, } }) // Mocks Nuxt `useRouter()` vi.mock('../src/app/composables/router', () => ({ + resolveRouteObject (to: Exclude<RouteLocationRaw, string>) { + return withQuery(to.path || '', to.query || {}) + (to.hash || '') + }, useRouter: () => ({ - resolve: (route: string | RouteLocation & { to?: string }): Partial<RouteLocation> & { href?: string } => { + resolve: (route: string | RouteLocation): Partial<RouteLocation> & { href: string } => { if (typeof route === 'string') { - return { href: route, path: route } + return { path: route, href: route } } - return route.to - ? { href: route.to } - : { - path: route.path || `/${route.name?.toString()}` || undefined, - query: route.query || undefined, - hash: route.hash || undefined - } - } - }) + return { + path: route.path || `/${route.name?.toString()}`, + query: route.query || undefined, + hash: route.hash || undefined, + href: route.path || `/${route.name?.toString()}`, + } + }, + }), })) // Helpers for test visibility @@ -48,12 +51,12 @@ const INTERNAL = 'RouterLink' // Renders a `<NuxtLink />` const nuxtLink = ( props: NuxtLinkProps = {}, - nuxtLinkOptions: Partial<NuxtLinkOptions> = {} + nuxtLinkOptions: Partial<NuxtLinkOptions> = {}, ): { type: string, props: Record<string, unknown>, slots: unknown } => { const component = defineNuxtLink({ componentName: 'NuxtLink', ...nuxtLinkOptions }) const [type, _props, slots] = (component.setup as unknown as (props: NuxtLinkProps, context: { slots: Record<string, () => unknown> }) => - () => [string, Record<string, unknown>, unknown])(props, { slots: { default: () => null } })() + () => [string, Record<string, unknown>, unknown])(props, { slots: { default: () => null } })() return { type, props: _props, slots } } @@ -99,7 +102,11 @@ describe('nuxt-link:isExternal', () => { }) it('returns `false` when `to` is a route location object', () => { - expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw }).type).toBe(INTERNAL) + expect(nuxtLink({ to: { path: '/to' } }).type).toBe(INTERNAL) + }) + + it('returns `true` when `to` has a `target`', () => { + expect(nuxtLink({ to: { path: '/to' }, target: '_blank' }).type).toBe(EXTERNAL) }) it('honors `external` prop', () => { @@ -122,7 +129,16 @@ describe('nuxt-link:propsOrAttributes', () => { }) it('resolves route location object', () => { - expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw, external: true }).props.href).toBe('/to') + expect(nuxtLink({ to: { path: '/to' }, external: true }).props.href).toBe('/to') + }) + + it('resolves route location object with name', () => { + expect(nuxtLink({ to: { name: 'to' }, external: true }).props.href).toBe('/to') + }) + + it('applies trailing slash behaviour', () => { + expect(nuxtLink({ to: { path: '/to' }, external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/') + expect(nuxtLink({ to: '/to', external: true }, { trailingSlash: 'append' }).props.href).toBe('/to/') }) }) @@ -140,8 +156,8 @@ describe('nuxt-link:propsOrAttributes', () => { vi.mocked(useRuntimeConfig).withImplementation(() => { return { app: { - baseURL: '/base' - } + baseURL: '/base', + }, } as any }, () => { expect(nuxtLink({ to: '/', target: '_blank' }).props.href).toBe('/base') @@ -161,12 +177,14 @@ describe('nuxt-link:propsOrAttributes', () => { vi.mocked(useRuntimeConfig).withImplementation(() => { return { app: { - baseURL: '/base' - } + baseURL: '/base', + }, } as any }, () => { expect(nuxtLink({ to: 'http://nuxtjs.org/app/about', target: '_blank' }).props.href).toBe('http://nuxtjs.org/app/about') expect(nuxtLink({ to: '//nuxtjs.org/app/about', target: '_blank' }).props.href).toBe('//nuxtjs.org/app/about') + expect(nuxtLink({ to: { path: '/' }, external: true }).props.href).toBe('/') + expect(nuxtLink({ to: '/', external: true }).props.href).toBe('/') }) }) }) @@ -209,7 +227,8 @@ describe('nuxt-link:propsOrAttributes', () => { describe('to', () => { it('forwards `to` prop', () => { expect(nuxtLink({ to: '/to' }).props.to).toBe('/to') - expect(nuxtLink({ to: { to: '/to' } as RouteLocationRaw }).props.to).toEqual({ to: '/to' }) + expect(nuxtLink({ to: { path: '/to' } }).props.to).toEqual({ path: '/to' }) + expect(nuxtLink({ to: { name: 'to' } }).props.to).toEqual({ name: 'to' }) }) }) diff --git a/packages/nuxt/test/page-metadata.test.ts b/packages/nuxt/test/page-metadata.test.ts new file mode 100644 index 0000000000..9e57ab407f --- /dev/null +++ b/packages/nuxt/test/page-metadata.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { getRouteMeta, normalizeRoutes } from '../src/pages/utils' +import type { NuxtPage } from '../schema' + +const filePath = '/app/pages/index.vue' + +describe('page metadata', () => { + it('should not extract metadata from empty files', async () => { + expect(await getRouteMeta('', filePath)).toEqual({}) + expect(await getRouteMeta('<template><div>Hi</div></template>', filePath)).toEqual({}) + }) + + it('should extract metadata from JS/JSX files', async () => { + const fileContents = `definePageMeta({ name: 'bar' })` + for (const ext of ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs']) { + const meta = await getRouteMeta(fileContents, `/app/pages/index.${ext}`) + expect(meta).toStrictEqual({ + name: 'bar', + }) + } + }) + + it('should use and invalidate cache', async () => { + const fileContents = `<script setup>definePageMeta({ foo: 'bar' })</script>` + const meta = await getRouteMeta(fileContents, filePath) + expect(meta === await getRouteMeta(fileContents, filePath)).toBeTruthy() + expect(meta === await getRouteMeta(fileContents, '/app/pages/other.vue')).toBeFalsy() + expect(meta === await getRouteMeta('<template><div>Hi</div></template>' + fileContents, filePath)).toBeFalsy() + }) + + it('should extract serialisable metadata', async () => { + const meta = await getRouteMeta(` + <script setup> + definePageMeta({ + name: 'some-custom-name', + path: '/some-custom-path', + validate: () => true, + middleware: [ + function () {}, + ], + otherValue: { + foo: 'bar', + }, + }) + </script> + `, filePath) + + expect(meta).toMatchInlineSnapshot(` + { + "meta": { + "__nuxt_dynamic_meta_key": Set { + "meta", + }, + }, + "name": "some-custom-name", + "path": "/some-custom-path", + } + `) + }) + + it('should extract serialisable metadata from files with multiple blocks', async () => { + const meta = await getRouteMeta(` + <script lang="ts"> + export default { + name: 'thing' + } + </script> + <script setup> + definePageMeta({ + name: 'some-custom-name', + path: '/some-custom-path', + validate: () => true, + middleware: [ + function () {}, + ], + otherValue: { + foo: 'bar', + }, + }) + </script> + `, filePath) + + expect(meta).toMatchInlineSnapshot(` + { + "meta": { + "__nuxt_dynamic_meta_key": Set { + "meta", + }, + }, + "name": "some-custom-name", + "path": "/some-custom-path", + } + `) + }) + + it('should extract serialisable metadata in options api', async () => { + const meta = await getRouteMeta(` + <script> + export default { + setup() { + definePageMeta({ + name: 'some-custom-name', + path: '/some-custom-path', + middleware: (from, to) => console.warn('middleware'), + }) + }, + }; + </script> + `, filePath) + + expect(meta).toMatchInlineSnapshot(` + { + "meta": { + "__nuxt_dynamic_meta_key": Set { + "meta", + }, + }, + "name": "some-custom-name", + "path": "/some-custom-path", + } + `) + }) + + it('should extract serialisable metadata all quoted', async () => { + const meta = await getRouteMeta(` + <script setup> + definePageMeta({ + "otherValue": { + foo: 'bar', + }, + }) + </script> + `, filePath) + + expect(meta).toMatchInlineSnapshot(` + { + "meta": { + "__nuxt_dynamic_meta_key": Set { + "meta", + }, + }, + } + `) + }) +}) + +describe('normalizeRoutes', () => { + it('should produce valid route objects when used with extracted meta', async () => { + const page: NuxtPage = { path: '/', file: filePath } + Object.assign(page, await getRouteMeta(` + <script setup> + definePageMeta({ + name: 'some-custom-name', + path: ref('/some-custom-path'), /* dynamic */ + validate: () => true, + redirect: '/', + middleware: [ + function () {}, + ], + otherValue: { + foo: 'bar', + }, + }) + </script> + `, filePath)) + + page.meta ||= {} + page.meta.layout = 'test' + page.meta.foo = 'bar' + + const { routes, imports } = normalizeRoutes([page], new Set(), true) + expect({ routes, imports }).toMatchInlineSnapshot(` + { + "imports": Set { + "import { default as indexN6pT4Un8hYMeta } from "/app/pages/index.vue?macro=true";", + }, + "routes": "[ + { + name: "some-custom-name", + path: indexN6pT4Un8hYMeta?.path ?? "/", + meta: { ...(indexN6pT4Un8hYMeta || {}), ...{"layout":"test","foo":"bar"} }, + redirect: "/", + component: () => import("/app/pages/index.vue") + } + ]", + } + `) + }) + + it('should produce valid route objects when used without extracted meta', () => { + const page: NuxtPage = { path: '/', file: filePath } + page.meta ||= {} + page.meta.layout = 'test' + page.meta.foo = 'bar' + + const { routes, imports } = normalizeRoutes([page], new Set()) + expect({ routes, imports }).toMatchInlineSnapshot(` + { + "imports": Set { + "import { default as indexN6pT4Un8hYMeta } from "/app/pages/index.vue?macro=true";", + }, + "routes": "[ + { + name: indexN6pT4Un8hYMeta?.name ?? undefined, + path: indexN6pT4Un8hYMeta?.path ?? "/", + meta: { ...(indexN6pT4Un8hYMeta || {}), ...{"layout":"test","foo":"bar"} }, + alias: indexN6pT4Un8hYMeta?.alias || [], + redirect: indexN6pT4Un8hYMeta?.redirect, + component: () => import("/app/pages/index.vue") + } + ]", + } + `) + }) +}) diff --git a/packages/nuxt/test/pages.test.ts b/packages/nuxt/test/pages.test.ts index dd1c2239ee..f2b2820e81 100644 --- a/packages/nuxt/test/pages.test.ts +++ b/packages/nuxt/test/pages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { NuxtPage } from 'nuxt/schema' -import { generateRoutesFromFiles, normalizeRoutes, pathToNitroGlob } from '../src/pages/utils' +import { augmentPages, generateRoutesFromFiles, normalizeRoutes, pathToNitroGlob } from '../src/pages/utils' import { generateRouteKey } from '../src/pages/runtime/utils' describe('pages:generateRoutesFromFiles', () => { @@ -11,16 +11,16 @@ describe('pages:generateRoutesFromFiles', () => { vi.mock('knitwork', async (original) => { return { ...(await original<typeof import('knitwork')>()), - 'genArrayFromRaw': (val: any) => val, - 'genSafeVariableName': (..._args: string[]) => { + genArrayFromRaw: (val: any) => val, + genSafeVariableName: (..._args: string[]) => { return 'mock' }, } }) - + const tests: Array<{ description: string - files?: Array<{ path: string; template?: string; }> + files?: Array<{ path: string, template?: string, meta?: Record<string, any> }> output?: NuxtPage[] normalized?: Record<string, any>[] error?: string @@ -30,34 +30,34 @@ describe('pages:generateRoutesFromFiles', () => { files: [ { path: `${pagesDir}/index.vue` }, { path: `${pagesDir}/parent/index.vue` }, - { path: `${pagesDir}/parent/child/index.vue` } + { path: `${pagesDir}/parent/child/index.vue` }, ], output: [ { name: 'index', path: '/', file: `${pagesDir}/index.vue`, - children: [] + children: [], }, { name: 'parent', path: '/parent', file: `${pagesDir}/parent/index.vue`, - children: [] + children: [], }, { name: 'parent-child', path: '/parent/child', file: `${pagesDir}/parent/child/index.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct routes for parent/child', files: [ { path: `${pagesDir}/parent.vue` }, - { path: `${pagesDir}/parent/child.vue` } + { path: `${pagesDir}/parent/child.vue` }, ], output: [ { @@ -69,88 +69,88 @@ describe('pages:generateRoutesFromFiles', () => { name: 'parent-child', path: 'child', file: `${pagesDir}/parent/child.vue`, - children: [] - } - ] - } - ] + children: [], + }, + ], + }, + ], }, { description: 'should not generate colliding route names when hyphens are in file name', files: [ { path: `${pagesDir}/parent/[child].vue` }, - { path: `${pagesDir}/parent-[child].vue` } + { path: `${pagesDir}/parent-[child].vue` }, ], output: [ { name: 'parent-child', path: '/parent/:child()', file: `${pagesDir}/parent/[child].vue`, - children: [] + children: [], }, { name: 'parent-child', path: '/parent-:child()', file: `${pagesDir}/parent-[child].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct id for catchall (order 1)', files: [ { path: `${pagesDir}/[...stories].vue` }, - { path: `${pagesDir}/stories/[id].vue` } + { path: `${pagesDir}/stories/[id].vue` }, ], output: [ { name: 'stories', path: '/:stories(.*)*', file: `${pagesDir}/[...stories].vue`, - children: [] + children: [], }, { name: 'stories-id', path: '/stories/:id()', file: `${pagesDir}/stories/[id].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct id for catchall (order 2)', files: [ { path: `${pagesDir}/stories/[id].vue` }, - { path: `${pagesDir}/[...stories].vue` } + { path: `${pagesDir}/[...stories].vue` }, ], output: [ { name: 'stories-id', path: '/stories/:id()', file: `${pagesDir}/stories/[id].vue`, - children: [] + children: [], }, { name: 'stories', path: '/:stories(.*)*', file: `${pagesDir}/[...stories].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct route for snake_case file', files: [ - { path: `${pagesDir}/snake_case.vue` } + { path: `${pagesDir}/snake_case.vue` }, ], output: [ { name: 'snake_case', path: '/snake_case', file: `${pagesDir}/snake_case.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct route for kebab-case file', @@ -160,9 +160,9 @@ describe('pages:generateRoutesFromFiles', () => { name: 'kebab-case', path: '/kebab-case', file: `${pagesDir}/kebab-case.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct dynamic routes', @@ -178,20 +178,20 @@ describe('pages:generateRoutesFromFiles', () => { { path: `${pagesDir}/[bar]/index.vue` }, { path: `${pagesDir}/nonopt/[slug].vue` }, { path: `${pagesDir}/opt/[[slug]].vue` }, - { path: `${pagesDir}/[[sub]]/route-[slug].vue` } + { path: `${pagesDir}/[[sub]]/route-[slug].vue` }, ], output: [ { name: 'index', path: '/', file: `${pagesDir}/index.vue`, - children: [] + children: [], }, { children: [], name: 'slug', file: `${pagesDir}/[slug].vue`, - path: '/:slug()' + path: '/:slug()', }, { children: [ @@ -200,62 +200,62 @@ describe('pages:generateRoutesFromFiles', () => { name: 'foo', path: '', file: `${pagesDir}/[[foo]]/index.vue`, - children: [] - } + children: [], + }, ], file: `${pagesDir}/[[foo]]`, - path: '/:foo?' + path: '/:foo?', }, { children: [], path: '/optional/:opt?', name: 'optional-opt', - file: `${pagesDir}/optional/[[opt]].vue` + file: `${pagesDir}/optional/[[opt]].vue`, }, { children: [], path: '/optional/prefix-:opt?', name: 'optional-prefix-opt', - file: `${pagesDir}/optional/prefix-[[opt]].vue` + file: `${pagesDir}/optional/prefix-[[opt]].vue`, }, { children: [], path: '/optional/:opt?-postfix', name: 'optional-opt-postfix', - file: `${pagesDir}/optional/[[opt]]-postfix.vue` + file: `${pagesDir}/optional/[[opt]]-postfix.vue`, }, { children: [], path: '/optional/prefix-:opt?-postfix', name: 'optional-prefix-opt-postfix', - file: `${pagesDir}/optional/prefix-[[opt]]-postfix.vue` + file: `${pagesDir}/optional/prefix-[[opt]]-postfix.vue`, }, { children: [], name: 'bar', file: `${pagesDir}/[bar]/index.vue`, - path: '/:bar()' + path: '/:bar()', }, { name: 'nonopt-slug', path: '/nonopt/:slug()', file: `${pagesDir}/nonopt/[slug].vue`, - children: [] + children: [], }, { name: 'opt-slug', path: '/opt/:slug?', file: `${pagesDir}/opt/[[slug]].vue`, - children: [] + children: [], }, { name: 'sub-route-slug', path: '/:sub?/route-:slug()', file: `${pagesDir}/[[sub]]/route-[slug].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should generate correct catch-all route', @@ -265,27 +265,27 @@ describe('pages:generateRoutesFromFiles', () => { name: 'slug', path: '/:slug(.*)*', file: `${pagesDir}/[...slug].vue`, - children: [] + children: [], }, { name: 'index', path: '/', file: `${pagesDir}/index.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should throw unfinished param error for dynamic route', files: [{ path: `${pagesDir}/[slug.vue` }], - error: 'Unfinished param "slug"' + error: 'Unfinished param "slug"', }, { description: 'should throw empty param error for dynamic route', files: [ - { path: `${pagesDir}/[].vue` } + { path: `${pagesDir}/[].vue` }, ], - error: 'Empty param' + error: 'Empty param', }, { description: 'should only allow "_" & "." as special character for dynamic route', @@ -294,40 +294,40 @@ describe('pages:generateRoutesFromFiles', () => { { path: `${pagesDir}/[b2.2b].vue` }, { path: `${pagesDir}/[b2]_[2b].vue` }, { path: `${pagesDir}/[[c3@3c]].vue` }, - { path: `${pagesDir}/[[d4-4d]].vue` } + { path: `${pagesDir}/[[d4-4d]].vue` }, ], output: [ { name: 'a1_1a', path: '/:a1_1a()', file: `${pagesDir}/[a1_1a].vue`, - children: [] + children: [], }, { name: 'b2.2b', path: '/:b2.2b()', file: `${pagesDir}/[b2.2b].vue`, - children: [] + children: [], }, { name: 'b2_2b', path: '/:b2()_:2b()', file: `${pagesDir}/[b2]_[2b].vue`, - children: [] + children: [], }, { name: 'c33c', path: '/:c33c?', file: `${pagesDir}/[[c3@3c]].vue`, - children: [] + children: [], }, { name: 'd44d', path: '/:d44d?', file: `${pagesDir}/[[d4-4d]].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should properly override route name if definePageMeta name override is defined.', @@ -340,37 +340,37 @@ describe('pages:generateRoutesFromFiles', () => { name: 'home' }) </script> - ` - } + `, + }, ], output: [ { name: 'home', path: '/', file: `${pagesDir}/index.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should allow pages with `:` in their path', files: [ - { path: `${pagesDir}/test:name.vue` } + { path: `${pagesDir}/test:name.vue` }, ], output: [ { name: 'test:name', path: '/test\\:name', file: `${pagesDir}/test:name.vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should not merge required param as a child of optional param', files: [ { path: `${pagesDir}/[[foo]].vue` }, - { path: `${pagesDir}/[foo].vue` } + { path: `${pagesDir}/[foo].vue` }, ], output: [ { @@ -378,15 +378,15 @@ describe('pages:generateRoutesFromFiles', () => { path: '/:foo?', file: `${pagesDir}/[[foo]].vue`, children: [ - ] + ], }, { name: 'foo', path: '/:foo()', file: `${pagesDir}/[foo].vue`, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should correctly merge nested routes', @@ -398,7 +398,7 @@ describe('pages:generateRoutesFromFiles', () => { { path: `${pagesDir}/wrapper-expose/other.vue` }, { path: `${layerDir}/wrapper-expose/other/index.vue` }, { path: `${pagesDir}/wrapper-expose/other/sibling.vue` }, - { path: `${pagesDir}/param/sibling.vue` } + { path: `${pagesDir}/param/sibling.vue` }, ], output: [ { @@ -409,27 +409,27 @@ describe('pages:generateRoutesFromFiles', () => { children: [], file: `${pagesDir}/param/index/index.vue`, name: 'param-index', - path: '' + path: '', }, { children: [], file: `${layerDir}/param/index/sibling.vue`, name: 'param-index-sibling', - path: 'sibling' - } + path: 'sibling', + }, ], file: `${layerDir}/param/index.vue`, - path: '' + path: '', }, { children: [], file: `${pagesDir}/param/sibling.vue`, name: 'param-sibling', - path: 'sibling' - } + path: 'sibling', + }, ], file: `${pagesDir}/param.vue`, - path: '/param' + path: '/param', }, { children: [ @@ -437,25 +437,25 @@ describe('pages:generateRoutesFromFiles', () => { children: [], file: `${layerDir}/wrapper-expose/other/index.vue`, name: 'wrapper-expose-other', - path: '' + path: '', }, { children: [], file: `${pagesDir}/wrapper-expose/other/sibling.vue`, name: 'wrapper-expose-other-sibling', - path: 'sibling' - } + path: 'sibling', + }, ], file: `${pagesDir}/wrapper-expose/other.vue`, - path: '/wrapper-expose/other' - } - ] + path: '/wrapper-expose/other', + }, + ], }, { description: 'should handle trailing slashes with index routes', files: [ { path: `${pagesDir}/index/index.vue` }, - { path: `${pagesDir}/index/index/all.vue` } + { path: `${pagesDir}/index/index/all.vue` }, ], output: [ { @@ -464,13 +464,13 @@ describe('pages:generateRoutesFromFiles', () => { children: [], file: `${pagesDir}/index/index/all.vue`, name: 'index-index-all', - path: 'all' - } + path: 'all', + }, ], file: `${pagesDir}/index/index.vue`, name: 'index', - path: '/' - } + path: '/', + }, ], }, { @@ -489,8 +489,8 @@ describe('pages:generateRoutesFromFiles', () => { redirect: () => '/' }) </script> - ` - } + `, + }, ], output: [ { @@ -498,9 +498,9 @@ describe('pages:generateRoutesFromFiles', () => { path: '/', file: `${pagesDir}/index.vue`, meta: { [DYNAMIC_META_KEY]: new Set(['name', 'alias', 'redirect', 'meta']) }, - children: [] - } - ] + children: [], + }, + ], }, { description: 'should extract serializable values and override fallback when normalized with `overrideMeta: true`', @@ -516,8 +516,8 @@ describe('pages:generateRoutesFromFiles', () => { hello: 'world' }) </script> - ` - } + `, + }, ], output: [ { @@ -528,8 +528,8 @@ describe('pages:generateRoutesFromFiles', () => { redirect: '/', children: [], meta: { [DYNAMIC_META_KEY]: new Set(['meta']) }, - } - ] + }, + ], }, { description: 'route without file', @@ -539,8 +539,8 @@ describe('pages:generateRoutesFromFiles', () => { path: '/', alias: ['sweet-home'], meta: { hello: 'world' }, - } - ] + }, + ], }, { description: 'pushed route, skips generation from file', @@ -551,8 +551,86 @@ describe('pages:generateRoutesFromFiles', () => { alias: ['pushed-route-alias'], meta: { someMetaData: true }, file: `${pagesDir}/route-file.vue`, - } - ] + }, + ], + }, + { + description: 'route.meta generated from file', + files: [ + { + path: `${pagesDir}/page-with-meta.vue`, + meta: { + test: 1, + }, + }, + ], + output: [ + { + name: 'page-with-meta', + path: '/page-with-meta', + file: `${pagesDir}/page-with-meta.vue`, + children: [], + meta: { test: 1 }, + }, + ], + }, + { + description: 'should merge route.meta with meta from file', + files: [ + { + path: `${pagesDir}/page-with-meta.vue`, + meta: { + test: 1, + }, + template: ` + <script setup lang="ts"> + definePageMeta({ + hello: 'world' + }) + </script> + `, + }, + ], + output: [ + { + name: 'page-with-meta', + path: '/page-with-meta', + file: `${pagesDir}/page-with-meta.vue`, + children: [], + meta: { [DYNAMIC_META_KEY]: new Set(['meta']), test: 1 }, + }, + ], + }, + { + description: 'should handle route groups', + files: [ + { path: `${pagesDir}/(foo)/index.vue` }, + { path: `${pagesDir}/(foo)/about.vue` }, + { path: `${pagesDir}/(bar)/about/index.vue` }, + ], + output: [ + { + name: 'index', + path: '/', + file: `${pagesDir}/(foo)/index.vue`, + meta: undefined, + children: [], + }, + { + path: '/about', + file: `${pagesDir}/(foo)/about.vue`, + meta: undefined, + children: [ + + { + name: 'about', + path: '', + file: `${pagesDir}/(bar)/about/index.vue`, + children: [], + }, + ], + }, + ], }, ] @@ -561,18 +639,25 @@ describe('pages:generateRoutesFromFiles', () => { for (const test of tests) { it(test.description, async () => { - let result if (test.files) { const vfs = Object.fromEntries( - test.files.map(file => [file.path, 'template' in file ? file.template : '']) + test.files.map(file => [file.path, 'template' in file ? file.template : '']), ) as Record<string, string> try { - result = await generateRoutesFromFiles(test.files.map(file => ({ + result = generateRoutesFromFiles(test.files.map(file => ({ + shouldUseServerComponents: true, absolutePath: file.path, - relativePath: file.path.replace(/^(pages|layer\/pages)\//, '') - })), { shouldExtractBuildMeta: true, vfs }) + relativePath: file.path.replace(/^(pages|layer\/pages)\//, ''), + }))).map((route, index) => { + return { + ...route, + meta: test.files![index]!.meta, + } + }) + + await augmentPages(result, vfs) } catch (error: any) { expect(error.message).toEqual(test.error) } @@ -606,20 +691,20 @@ describe('pages:generateRouteKey', () => { params: { id: 'foo', optional: 'bar', - array: ['a', 'b'] + array: ['a', 'b'], }, matched: [ { components: { default: {} }, - meta: { key: 'other-meta-key' } + meta: { key: 'other-meta-key' }, }, { components: { default: defaultComponent.type }, meta: { key: 'matched-meta-key' }, - ...matchedRoute - } - ] - } + ...matchedRoute, + }, + ], + }, }) as any const tests = [ @@ -630,66 +715,66 @@ describe('pages:generateRouteKey', () => { description: 'should key dynamic routes without keys', route: getRouteProps({ path: '/test/:id', - meta: {} + meta: {}, }), - output: '/test/foo' + output: '/test/foo', }, { description: 'should key dynamic routes without keys', route: getRouteProps({ path: '/test/:id(\\d+)', - meta: {} + meta: {}, }), - output: '/test/foo' + output: '/test/foo', }, { description: 'should key dynamic routes with optional params', route: getRouteProps({ path: '/test/:optional?', - meta: {} + meta: {}, }), - output: '/test/bar' + output: '/test/bar', }, { description: 'should key dynamic routes with optional params', route: getRouteProps({ path: '/test/:optional(\\d+)?', - meta: {} + meta: {}, }), - output: '/test/bar' + output: '/test/bar', }, { description: 'should key dynamic routes with optional params', route: getRouteProps({ path: '/test/:undefined(\\d+)?', - meta: {} + meta: {}, }), - output: '/test/' + output: '/test/', }, { description: 'should key dynamic routes with array params', route: getRouteProps({ path: '/:array+', - meta: {} + meta: {}, }), - output: '/a,b' + output: '/a,b', }, { description: 'should key dynamic routes with array params', route: getRouteProps({ path: '/test/:array*', - meta: {} + meta: {}, }), - output: '/test/a,b' + output: '/test/a,b', }, { description: 'should key dynamic routes with array params', route: getRouteProps({ path: '/test/:other*', - meta: {} + meta: {}, }), - output: '/test/' - } + output: '/test/', + }, ] for (const test of tests) { @@ -707,7 +792,7 @@ const pathToNitroGlobTests = { '/some-:id?': '/**', '/other/some-:id?': '/other/**', '/other/some-:id()-more': '/other/**', - '/other/nested': '/other/nested' + '/other/nested': '/other/nested', } describe('pages:pathToNitroGlob', () => { diff --git a/packages/nuxt/test/plugin-metadata.test.ts b/packages/nuxt/test/plugin-metadata.test.ts index 2d12a2f403..f11f3713ed 100644 --- a/packages/nuxt/test/plugin-metadata.test.ts +++ b/packages/nuxt/test/plugin-metadata.test.ts @@ -10,8 +10,8 @@ describe('plugin-metadata', () => { name: 'test', enforce: 'post', hooks: { 'app:mounted': () => {} }, - setup: () => {}, - order: 1 + setup: () => { return { provide: { jsx: '[JSX]' } } }, + order: 1, }) for (const item of properties) { @@ -19,28 +19,25 @@ describe('plugin-metadata', () => { const meta = await extractMetadata([ 'export default defineNuxtPlugin({', - ...obj.map(([key, value]) => `${key}: ${typeof value === 'function' ? value.toString() : JSON.stringify(value)},`), - '})' - ].join('\n')) + ...obj.map(([key, value]) => `${key}: ${typeof value === 'function' ? value.toString().replace('"[JSX]"', '() => <span>JSX</span>') : JSON.stringify(value)},`), + '})', + ].join('\n'), 'tsx') - expect(meta).toMatchInlineSnapshot(` - { - "name": "test", - "order": 1, - } - `) + expect(meta).toEqual({ + 'name': 'test', + 'order': 1, + }) } }) const transformPlugin: any = RemovePluginMetadataPlugin({ options: { sourcemap: { client: true } }, - apps: { default: { plugins: [{ src: 'my-plugin.mjs', order: 10 }] } } + apps: { default: { plugins: [{ src: 'my-plugin.mjs', order: 10 }] } }, } as any).raw({}, {} as any) it('should overwrite invalid plugins', () => { const invalidPlugins = [ 'export const plugin = {}', - 'export default function (ctx, inject) {}' ] for (const plugin of invalidPlugins) { expect(transformPlugin.transform.call({ parse }, plugin, 'my-plugin.mjs').code).toBe('export default () => {}') @@ -71,17 +68,17 @@ describe('plugin sanity checking', () => { checkForCircularDependencies([ { name: 'A', - src: '' + src: '', }, { name: 'B', dependsOn: ['D'], - src: '' + src: '', }, { name: 'C', - src: '' - } + src: '', + }, ]) expect(console.error).toBeCalledWith('Plugin `B` depends on `D` but they are not registered.') vi.restoreAllMocks() @@ -93,18 +90,18 @@ describe('plugin sanity checking', () => { { name: 'A', dependsOn: ['B'], - src: '' + src: '', }, { name: 'B', dependsOn: ['C'], - src: '' + src: '', }, { name: 'C', dependsOn: ['A'], - src: '' - } + src: '', + }, ]) expect(console.error).toBeCalledWith('Circular dependency detected in plugins: A -> B -> C -> A') expect(console.error).toBeCalledWith('Circular dependency detected in plugins: B -> C -> A -> B') diff --git a/packages/nuxt/test/route-injection.test.ts b/packages/nuxt/test/route-injection.test.ts new file mode 100644 index 0000000000..c341273876 --- /dev/null +++ b/packages/nuxt/test/route-injection.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc' +import type { Plugin } from 'vite' +import type { Nuxt } from '@nuxt/schema' + +import { RouteInjectionPlugin } from '../src/pages/plugins/route-injection' + +describe('route-injection:transform', () => { + const injectionPlugin = RouteInjectionPlugin({ options: { sourcemap: { client: false, server: false } } } as Nuxt).raw({}, { framework: 'rollup' }) as Plugin + + const transform = async (source: string) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const result = await (injectionPlugin.transform! as Function).call({ error: null, warn: null } as any, source, 'test.vue') + const code: string = typeof result === 'string' ? result : result?.code + let depth = 0 + return code.split('\n').map((l) => { + l = l.trim() + if (l.match(/^[}\]]/)) { depth-- } + const res = ''.padStart(depth * 2, ' ') + l + if (l.match(/[{[]$/)) { depth++ } + return res + }).join('\n') + } + + it('should correctly inject route in template', async () => { + const sfc = `<template>{{ $route.path }}</template>` + const res = compileTemplate({ + filename: 'test.vue', + id: 'test.vue', + source: sfc, + }) + const transformResult = await transform(res.code) + expect(transformResult).toMatchInlineSnapshot(` + "import { PageRouteSymbol as __nuxt_route_symbol } from '#app/components/injections'; + import { toDisplayString as _toDisplayString, createTextVNode as _createTextVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" + + export function render(_ctx, _cache) { + return (_openBlock(), _createElementBlock("template", null, [ + _createTextVNode(_toDisplayString((_ctx._.provides[__nuxt_route_symbol] || _ctx.$route).path), 1 /* TEXT */) + ])) + }" + `) + }) + + it('should correctly inject route in options api', async () => { + const sfc = ` + <template>{{ thing }}</template> + <script> + export default { + computed: { + thing () { + return this.$route.path + } + } + } + </script> + ` + + const res = compileScript(parse(sfc).descriptor, { id: 'test.vue' }) + const transformResult = await transform(res.content) + expect(transformResult).toMatchInlineSnapshot(` + "import { PageRouteSymbol as __nuxt_route_symbol } from '#app/components/injections'; + + export default { + computed: { + thing () { + return (this._.provides[__nuxt_route_symbol] || this.$route).path + } + } + } + " + `) + }) +}) diff --git a/packages/nuxt/test/scan-components.test.ts b/packages/nuxt/test/scan-components.test.ts index 71edeea406..65cc86aff0 100644 --- a/packages/nuxt/test/scan-components.test.ts +++ b/packages/nuxt/test/scan-components.test.ts @@ -1,14 +1,15 @@ +import { fileURLToPath } from 'node:url' import { resolve } from 'pathe' import { expect, it, vi } from 'vitest' import type { ComponentsDir } from 'nuxt/schema' import { scanComponents } from '../src/components/scan' -const fixtureDir = resolve(__dirname, 'fixture') +const fixtureDir = fileURLToPath(new URL('fixture', import.meta.url)) const rFixture = (...p: string[]) => resolve(fixtureDir, ...p) vi.mock('@nuxt/kit', () => ({ - isIgnored: () => false + isIgnored: () => false, })) const dirs: ComponentsDir[] = [ @@ -16,64 +17,64 @@ const dirs: ComponentsDir[] = [ path: rFixture('components/islands'), enabled: true, extensions: [ - 'vue' + 'vue', ], pattern: '**/*.{vue,}', ignore: [ '**/*.stories.{js,ts,jsx,tsx}', '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', - '**/*.d.ts' + '**/*.d.ts', ], transpile: false, - island: true + island: true, }, { path: rFixture('components/global'), enabled: true, extensions: [ - 'vue' + 'vue', ], pattern: '**/*.{vue,}', ignore: [ '**/*.stories.{js,ts,jsx,tsx}', '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', - '**/*.d.ts' + '**/*.d.ts', ], transpile: false, - global: true + global: true, }, { path: rFixture('components'), enabled: true, extensions: [ - 'vue' + 'vue', ], pattern: '**/*.{vue,}', ignore: [ '**/*.stories.{js,ts,jsx,tsx}', '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', - '**/*.d.ts' + '**/*.d.ts', ], - transpile: false + transpile: false, }, { path: rFixture('components'), enabled: true, extensions: [ - 'vue' + 'vue', ], pattern: '**/*.{vue,}', ignore: [ '**/*.stories.{js,ts,jsx,tsx}', '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', - '**/*.d.ts' + '**/*.d.ts', ], - transpile: false + transpile: false, }, { path: rFixture('components'), extensions: [ - 'vue' + 'vue', ], prefix: 'nuxt', enabled: true, @@ -81,12 +82,12 @@ const dirs: ComponentsDir[] = [ ignore: [ '**/*.stories.{js,ts,jsx,tsx}', '**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}', - '**/*.d.ts' + '**/*.d.ts', ], - transpile: false - } + transpile: false, + }, ] - +const dirUnable = dirs.map((d) => { return { ...d, enabled: false } }) const expectedComponents = [ { chunkName: 'components/isle-server', @@ -99,7 +100,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/islands/Isle.vue' + shortPath: 'components/islands/Isle.vue', }, { chunkName: 'components/glob', @@ -112,7 +113,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/global/Glob.vue' + shortPath: 'components/global/Glob.vue', }, { mode: 'all', @@ -125,7 +126,7 @@ const expectedComponents = [ island: undefined, prefetch: false, preload: false, - priority: 1 + priority: 1, }, { mode: 'client', @@ -138,7 +139,7 @@ const expectedComponents = [ island: undefined, prefetch: false, preload: false, - priority: 1 + priority: 1, }, { mode: 'server', @@ -151,7 +152,7 @@ const expectedComponents = [ island: undefined, prefetch: false, preload: false, - priority: 1 + priority: 1, }, { chunkName: 'components/client-component-with-props', @@ -164,7 +165,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/client/ComponentWithProps.vue' + shortPath: 'components/client/ComponentWithProps.vue', }, { chunkName: 'components/client-with-client-only-setup', @@ -177,7 +178,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/client/WithClientOnlySetup.vue' + shortPath: 'components/client/WithClientOnlySetup.vue', }, { mode: 'server', @@ -190,7 +191,7 @@ const expectedComponents = [ island: undefined, prefetch: false, preload: false, - priority: 1 + priority: 1, }, { chunkName: 'components/same-name-same', @@ -203,7 +204,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/same-name/same/Same.vue' + shortPath: 'components/same-name/same/Same.vue', }, { chunkName: 'components/some-glob', @@ -216,7 +217,7 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/some-glob.global.vue' + shortPath: 'components/some-glob.global.vue', }, { chunkName: 'components/some-server', @@ -229,8 +230,8 @@ const expectedComponents = [ prefetch: false, preload: false, priority: 1, - shortPath: 'components/some.island.vue' - } + shortPath: 'components/some.island.vue', + }, ] const srcDir = rFixture('.') @@ -240,6 +241,13 @@ it('components:scanComponents', async () => { for (const c of scannedComponents) { // @ts-expect-error filePath is not optional but we don't want it to be in the snapshot delete c.filePath + // @ts-expect-error _scanned is added internally but we don't want it to be in the snapshot + delete c._scanned } expect(scannedComponents).deep.eq(expectedComponents) }) + +it('components:scanComponents:unable', async () => { + const scannedComponents = await scanComponents(dirUnable, srcDir) + expect(scannedComponents).deep.eq([]) +}) diff --git a/packages/nuxt/test/treeshake-client.test.ts b/packages/nuxt/test/treeshake-client.test.ts index ac36ba584f..b0afa0cb19 100644 --- a/packages/nuxt/test/treeshake-client.test.ts +++ b/packages/nuxt/test/treeshake-client.test.ts @@ -6,7 +6,7 @@ import type { Plugin } from 'vite' import { Parser } from 'acorn' import type { Options } from '@vitejs/plugin-vue' import _vuePlugin from '@vitejs/plugin-vue' -import { TreeShakeTemplatePlugin } from '../src/components/tree-shake' +import { TreeShakeTemplatePlugin } from '../src/components/plugins/tree-shake' import { fixtureDir, normalizeLineEndings } from './utils' // mock due to differences of results between windows and linux @@ -21,13 +21,13 @@ function vuePlugin (options: Options) { return { ..._vuePlugin(options), handleHotUpdate () {}, - configureDevServer () {} + configureDevServer () {}, } } const WithClientOnly = normalizeLineEndings(readFileSync(path.resolve(fixtureDir, './components/client/WithClientOnlySetup.vue')).toString()) -const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({ +const treeshakeTemplatePlugin = TreeShakeTemplatePlugin({ sourcemap: false, getComponents () { return [{ @@ -39,7 +39,7 @@ const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({ chunkName: '123', prefetch: false, preload: false, - mode: 'client' + mode: 'client', }, { pascalName: 'DotClientComponent', kebabName: 'dot-client-component', @@ -49,140 +49,182 @@ const treeshakeTemplatePlugin = TreeShakeTemplatePlugin.raw({ chunkName: '123', prefetch: false, preload: false, - mode: 'client' + mode: 'client', }] - } -}, { framework: 'rollup' }) as Plugin + }, +}).raw({}, { framework: 'rollup' }) as Plugin const treeshake = async (source: string): Promise<string> => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const result = await (treeshakeTemplatePlugin.transform! as Function).call({ parse: (code: string, opts: any = {}) => Parser.parse(code, { sourceType: 'module', ecmaVersion: 'latest', locations: true, - ...opts - }) + ...opts, + }), }, source) return typeof result === 'string' ? result : result?.code } async function SFCCompile (name: string, source: string, options: Options, ssr = false): Promise<string> { - const result = await (vuePlugin({ + const plugin = vuePlugin({ compiler: VueCompilerSFC, - ...options - }).transform! as Function).call({ + ...options, + }) + // @ts-expect-error Types are not correct as they are too generic + plugin.configResolved!({ + isProduction: options.isProduction, + command: 'build', + root: process.cwd(), + build: { sourcemap: false }, + define: {}, + }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + const result = await (plugin.transform! as Function).call({ parse: (code: string, opts: any = {}) => Parser.parse(code, { sourceType: 'module', ecmaVersion: 'latest', locations: true, - ...opts - }) + ...opts, + }), }, source, name, { - ssr + ssr, }) return typeof result === 'string' ? result : result?.code } -const stateToTest: { name: string, options: Partial<Options & { devServer: { config: { server: any } } }> }[] = [ +const stateToTest: { index: number, name: string, options: Partial<Options & { devServer: { config: { server: any } } }> }[] = [ { + index: 0, name: 'prod', options: { - isProduction: true - } + isProduction: true, + }, }, { + index: 1, name: 'dev', options: { isProduction: false, devServer: { config: { // trigger dev behavior - server: false - } - } - } - } + server: false, + }, + }, + }, + }, ] describe('treeshake client only in ssr', () => { vi.spyOn(process, 'cwd').mockImplementation(() => '') - for (const [index, state] of stateToTest.entries()) { - it(`should treeshake ClientOnly correctly in ${state.name}`, async () => { - // add index to avoid using vite vue plugin cache - const clientResult = await SFCCompile(`SomeComponent${index}.vue`, WithClientOnly, state.options) + it.each(stateToTest)(`should treeshake ClientOnly correctly in $name`, async (state) => { + // add index to avoid using vite vue plugin cache + const clientResult = await SFCCompile(`SomeComponent${state.index}.vue`, WithClientOnly, state.options) - const ssrResult = await SFCCompile(`SomeComponent${index}.vue`, WithClientOnly, state.options, true) + const ssrResult = await SFCCompile(`SomeComponent${state.index}.vue`, WithClientOnly, state.options, true) - const treeshaken = await treeshake(ssrResult) - const [_, scopeId] = clientResult.match(/_pushScopeId\("(.*)"\)/)! + const treeshaken = await treeshake(ssrResult) + const [_, scopeId] = clientResult.match(/['"]__scopeId['"],\s*['"](data-v-[^'"]+)['"]/)! - // ensure the id is correctly passed between server and client - expect(clientResult).toContain(`pushScopeId("${scopeId}")`) - expect(treeshaken).toContain(`<div ${scopeId}>`) + // ensure the id is correctly passed between server and client + expect(clientResult).toContain(`'__scopeId',"${scopeId}"`) + expect(treeshaken).toContain(`<div ${scopeId}>`) - expect(clientResult).toContain('should-be-treeshaken') - expect(treeshaken).not.toContain('should-be-treeshaken') + expect(clientResult).toContain('should-be-treeshaken') + expect(treeshaken).not.toContain('should-be-treeshaken') - expect(treeshaken).not.toContain("import HelloWorld from '../HelloWorld.vue'") - expect(clientResult).toContain("import HelloWorld from '../HelloWorld.vue'") + expect(treeshaken).not.toContain('import HelloWorld from \'../HelloWorld.vue\'') + expect(clientResult).toContain('import HelloWorld from \'../HelloWorld.vue\'') - expect(treeshaken).not.toContain("import { Treeshaken } from 'somepath'") - expect(clientResult).toContain("import { Treeshaken } from 'somepath'") + expect(treeshaken).not.toContain('import { Treeshaken } from \'somepath\'') + expect(clientResult).toContain('import { Treeshaken } from \'somepath\'') - // remove resolved import - expect(treeshaken).not.toContain('const _component_ResolvedImport =') - expect(clientResult).toContain('const _component_ResolvedImport =') + // remove resolved import + expect(treeshaken).not.toContain('const _component_ResolvedImport =') + expect(clientResult).toContain('const _component_ResolvedImport =') - // treeshake multi line variable declaration - expect(clientResult).toContain('const SomeIsland = defineAsyncComponent(async () => {') - expect(treeshaken).not.toContain('const SomeIsland = defineAsyncComponent(async () => {') - expect(treeshaken).not.toContain("return (await import('./../some.island.vue'))") - expect(treeshaken).toContain('const NotToBeTreeShaken = defineAsyncComponent(async () => {') + // treeshake multi line variable declaration + expect(clientResult).toContain('const SomeIsland = defineAsyncComponent(async () => {') + expect(treeshaken).not.toContain('const SomeIsland = defineAsyncComponent(async () => {') + expect(treeshaken).not.toContain('return (await import(\'./../some.island.vue\'))') + expect(treeshaken).toContain('const NotToBeTreeShaken = defineAsyncComponent(async () => {') - // treeshake object and array declaration - expect(treeshaken).not.toContain("const { ObjectPattern } = await import('nuxt.com')") - expect(treeshaken).not.toContain("const { ObjectPattern: ObjectPatternDeclaration } = await import('nuxt.com')") - expect(treeshaken).toContain('const { ButShouldNotBeTreeShaken } = defineAsyncComponent(async () => {') - expect(treeshaken).toContain('const [ { Dont, }, That] = defineAsyncComponent(async () => {') + // treeshake object and array declaration + expect(treeshaken).not.toContain('const { ObjectPattern } = await import(\'nuxt.com\')') + expect(treeshaken).not.toContain('const { ObjectPattern: ObjectPatternDeclaration } = await import(\'nuxt.com\')') + expect(treeshaken).toContain('const { ButShouldNotBeTreeShaken } = defineAsyncComponent(async () => {') + expect(treeshaken).toContain('const [ { Dont, }, That] = defineAsyncComponent(async () => {') - // treeshake object that has an assignement pattern - expect(treeshaken).toContain('const { woooooo, } = defineAsyncComponent(async () => {') - expect(treeshaken).not.toContain('const { Deep, assignment: { Pattern = ofComponent } } = defineAsyncComponent(async () => {') + // treeshake object that has an assignment pattern + expect(treeshaken).toContain('const { woooooo, } = defineAsyncComponent(async () => {') + expect(treeshaken).not.toContain('const { Deep, assignment: { Pattern = ofComponent } } = defineAsyncComponent(async () => {') - // expect no empty ObjectPattern on treeshaking - expect(treeshaken).not.toContain('const { } = defineAsyncComponent') - expect(treeshaken).not.toContain('import { } from') + // expect no empty ObjectPattern on treeshaking + expect(treeshaken).not.toContain('const { } = defineAsyncComponent') + expect(treeshaken).not.toContain('import { } from') - // expect components used in setup to not be removed - expect(treeshaken).toContain("import DontRemoveThisSinceItIsUsedInSetup from './ComponentWithProps.vue'") + // expect components used in setup to not be removed + expect(treeshaken).toContain('import DontRemoveThisSinceItIsUsedInSetup from \'./ComponentWithProps.vue\'') - // expect import of ClientImport to be treeshaken but not Glob since it is also used outside <ClientOnly> - expect(treeshaken).not.toContain('ClientImport') - expect(treeshaken).toContain('import { Glob } from \'#components\'') + // expect import of ClientImport to be treeshaken but not Glob since it is also used outside <ClientOnly> + expect(treeshaken).not.toContain('ClientImport') + expect(treeshaken).toContain('import { Glob } from \'#components\'') - // treeshake .client slot - expect(treeshaken).not.toContain('ByeBye') - // don't treeshake variables that has the same name as .client components - expect(treeshaken).toContain('NotDotClientComponent') - expect(treeshaken).not.toContain('(DotClientComponent') + // treeshake .client slot + expect(treeshaken).not.toContain('ByeBye') + // don't treeshake variables that has the same name as .client components + expect(treeshaken).toContain('NotDotClientComponent') + expect(treeshaken).not.toContain('(DotClientComponent') - expect(treeshaken).not.toContain('AutoImportedComponent') - expect(treeshaken).toContain('AutoImportedNotTreeShakenComponent') + expect(treeshaken).not.toContain('AutoImportedComponent') + expect(treeshaken).toContain('AutoImportedNotTreeShakenComponent') - expect(treeshaken).not.toContain('Both') - expect(treeshaken).not.toContain('AreTreeshaken') + expect(treeshaken).not.toContain('Both') + expect(treeshaken).not.toContain('AreTreeshaken') - if (state.options.isProduction === false) { - // treeshake at inlined template - expect(treeshaken).not.toContain('ssrRenderComponent($setup["HelloWorld"]') - expect(treeshaken).toContain('ssrRenderComponent($setup["Glob"]') - } else { - // treeshake unref - expect(treeshaken).not.toContain('ssrRenderComponent(_unref(HelloWorld') - expect(treeshaken).toContain('ssrRenderComponent(_unref(Glob') - } - expect(treeshaken.replace(/data-v-[\d\w]{8}/g, 'data-v-one-hash').replace(/scoped=[\d\w]{8}/g, 'scoped=one-hash')).toMatchSnapshot() - }) - } + if (state.options.isProduction === false) { + // treeshake at inlined template + expect(treeshaken).not.toContain('ssrRenderComponent($setup["HelloWorld"]') + expect(treeshaken).toContain('ssrRenderComponent($setup["Glob"]') + } else { + // treeshake unref + expect(treeshaken).not.toContain('ssrRenderComponent(_unref(HelloWorld') + expect(treeshaken).toContain('ssrRenderComponent(_unref(Glob') + } + expect(treeshaken.replace(/data-v-\w{8}/g, 'data-v-one-hash').replace(/scoped=\w{8}/g, 'scoped=one-hash')).toMatchSnapshot() + }) + + it('should not treeshake reused component #26137', async () => { + const treeshaken = await treeshake(`import { resolveComponent as _resolveComponent, withCtx as _withCtx, createVNode as _createVNode } from "vue" + import { ssrRenderComponent as _ssrRenderComponent, ssrRenderAttrs as _ssrRenderAttrs } from "vue/server-renderer" + + export function ssrRender(_ctx, _push, _parent, _attrs) { + const _component_AppIcon = _resolveComponent("AppIcon") + const _component_ClientOnly = _resolveComponent("ClientOnly") + + _push(\`<div\${_ssrRenderAttrs(_attrs)}>\`) + _push(_ssrRenderComponent(_component_AppIcon, { name: "caret-left" }, null, _parent)) + _push(_ssrRenderComponent(_component_ClientOnly, null, { + default: _withCtx((_, _push, _parent, _scopeId) => { + if (_push) { + _push(\`<span\${_scopeId}>TEST</span>\`) + _push(_ssrRenderComponent(_component_AppIcon, { name: "caret-up" }, null, _parent, _scopeId)) + } else { + return [ + _createVNode("span", null, "TEST"), + _createVNode(_component_AppIcon, { name: "caret-up" }) + ] + } + }), + _: 1 /* STABLE */ + }, _parent)) + _push(\`</div>\`) + }`) + + expect(treeshaken).toContain('resolveComponent("AppIcon")') + expect(treeshaken).not.toContain('caret-up') + }) }) diff --git a/packages/nuxt/test/unctx-transform.test.ts b/packages/nuxt/test/unctx-transform.test.ts new file mode 100644 index 0000000000..42dff085dc --- /dev/null +++ b/packages/nuxt/test/unctx-transform.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { UnctxTransformPlugin } from '../src/core/plugins/unctx' + +describe('unctx transform in nuxt', () => { + it('should transform nuxt plugins', async () => { + const code = ` + export default defineNuxtPlugin({ + async setup () { + await Promise.resolve() + } + }) + ` + expect(await transform(code)).toMatchInlineSnapshot(` + "/* _processed_nuxt_unctx_transform */ + import { executeAsync as __executeAsync } from "unctx"; + export default defineNuxtPlugin({ + async setup () {let __temp, __restore; + ;(([__temp,__restore]=__executeAsync(()=>Promise.resolve())),await __temp,__restore()); + } + },1)" + `) + }) + + it('should transform vue components using defineNuxtComponent', async () => { + const code = ` + definePageMeta({ + async middleware() { + await Promise.resolve() + } + }) + export default defineNuxtComponent({ + async setup () { + await Promise.resolve() + } + }) + ` + expect(await transform(code, 'app.ts')).toMatchInlineSnapshot(` + "/* _processed_nuxt_unctx_transform */ + import { executeAsync as __executeAsync } from "unctx"; + definePageMeta({ + async middleware() {let __temp, __restore; + ;(([__temp,__restore]=__executeAsync(()=>Promise.resolve())),await __temp,__restore()); + } + }) + export default defineNuxtComponent({ + async setup () {let __temp, __restore; + ;(([__temp,__restore]=__executeAsync(()=>Promise.resolve())),await __temp,__restore()); + } + })" + `) + }) +}) + +function transform (code: string, id = 'app.vue') { + const transformerOptions = { + helperModule: 'unctx', + asyncFunctions: ['defineNuxtPlugin', 'defineNuxtRouteMiddleware'], + objectDefinitions: { + defineNuxtComponent: ['asyncData', 'setup'], + defineNuxtPlugin: ['setup'], + definePageMeta: ['middleware', 'validate'], + }, + } + const plugin = UnctxTransformPlugin({ sourcemap: false, transformerOptions }).raw({}, {} as any) as any + return plugin.transformInclude(id) ? Promise.resolve(plugin.transform(code)).then((r: any) => r?.code.replace(/^ {6}/gm, '').trim()) : null +} diff --git a/packages/nuxt/test/utils.ts b/packages/nuxt/test/utils.ts index 9d295ac1ec..a8ea696f22 100644 --- a/packages/nuxt/test/utils.ts +++ b/packages/nuxt/test/utils.ts @@ -1,6 +1,6 @@ -import { resolve } from 'pathe' +import { fileURLToPath } from 'node:url' -export const fixtureDir = resolve(__dirname, 'fixture') +export const fixtureDir = fileURLToPath(new URL('fixture', import.meta.url)) export function normalizeLineEndings (str: string, normalized = '\n') { return str.replace(/\r?\n/g, normalized) diff --git a/packages/nuxt/types.d.mts b/packages/nuxt/types.d.mts index 210e33bd5c..0f67301c2e 100644 --- a/packages/nuxt/types.d.mts +++ b/packages/nuxt/types.d.mts @@ -1,10 +1,13 @@ -/// <reference types="nitropack" /> -export * from './dist/index.js' +/// <reference types="nitro/types" /> +/// <reference path="dist/app/types/augments.d.ts" /> import type { DefineNuxtConfig } from 'nuxt/config' import type { RuntimeConfig, SchemaDefinition } from 'nuxt/schema' import type { H3Event } from 'h3' -import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/core/runtime/nitro/renderer.js' +import type { LogObject } from 'consola' +import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/app/types.js' + +export * from './dist/index.js' declare global { const defineNuxtConfig: DefineNuxtConfig @@ -12,11 +15,12 @@ declare global { } // Note: Keep in sync with packages/nuxt/src/core/templates.ts -declare module 'nitropack' { +declare module 'nitro/types' { interface NitroRuntimeConfigApp { buildAssetsDir: string cdnURL: string } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface NitroRuntimeConfig extends RuntimeConfig {} interface NitroRouteConfig { ssr?: boolean @@ -25,8 +29,32 @@ declare module 'nitropack' { interface NitroRouteRules { ssr?: boolean experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> + 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> + 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> + } +} +declare module 'nitropack/types' { + interface NitroRuntimeConfigApp { + buildAssetsDir: string + cdnURL: string + } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface NitroRuntimeConfig extends RuntimeConfig {} + interface NitroRouteConfig { + ssr?: boolean + experimentalNoScripts?: boolean + } + interface NitroRouteRules { + ssr?: boolean + experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> + } + interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> } diff --git a/packages/nuxt/types.d.ts b/packages/nuxt/types.d.ts index 04663194d6..e95023194b 100644 --- a/packages/nuxt/types.d.ts +++ b/packages/nuxt/types.d.ts @@ -1,10 +1,13 @@ -/// <reference types="nitropack" /> -export * from './dist/index' +/// <reference types="nitro/types" /> +/// <reference path="dist/app/types/augments.d.ts" /> import type { DefineNuxtConfig } from 'nuxt/config' import type { RuntimeConfig, SchemaDefinition } from 'nuxt/schema' import type { H3Event } from 'h3' -import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/core/runtime/nitro/renderer' +import type { LogObject } from 'consola' +import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/app/types' + +export * from './dist/index' declare global { const defineNuxtConfig: DefineNuxtConfig @@ -12,11 +15,12 @@ declare global { } // Note: Keep in sync with packages/nuxt/src/core/templates.ts -declare module 'nitropack' { +declare module 'nitro/types' { interface NitroRuntimeConfigApp { buildAssetsDir: string cdnURL: string } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface NitroRuntimeConfig extends RuntimeConfig {} interface NitroRouteConfig { ssr?: boolean @@ -25,8 +29,32 @@ declare module 'nitropack' { interface NitroRouteRules { ssr?: boolean experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> + 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> + 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> + } +} +declare module 'nitropack/types' { + interface NitroRuntimeConfigApp { + buildAssetsDir: string + cdnURL: string + } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface NitroRuntimeConfig extends RuntimeConfig {} + interface NitroRouteConfig { + ssr?: boolean + experimentalNoScripts?: boolean + } + interface NitroRouteRules { + ssr?: boolean + experimentalNoScripts?: boolean + appMiddleware?: Record<string, boolean> + } + interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void> 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void> 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void> } diff --git a/packages/schema/build.config.ts b/packages/schema/build.config.ts index 04a306a6c4..22f17cbef8 100644 --- a/packages/schema/build.config.ts +++ b/packages/schema/build.config.ts @@ -13,16 +13,18 @@ export default defineBuildConfig({ workspaceDir: '/<workspaceDir>/', rootDir: '/<rootDir>/', vite: { - base: '/' - } - } + base: '/', + }, + }, }, 'src/index', - 'src/builder-env' + 'src/builder-env', ], externals: [ // Type imports '#app/components/nuxt-link', + 'cssnano', + 'autoprefixer', 'ofetch', 'vue-router', '@nuxt/telemetry', @@ -31,6 +33,7 @@ export default defineBuildConfig({ 'vue', 'unctx', 'hookable', + 'nitro', 'nitropack', 'webpack', 'webpack-bundle-analyzer', @@ -53,9 +56,11 @@ export default defineBuildConfig({ 'sass-loader', 'c12', 'unenv', + '@vue/language-core', // Implicit '@vue/compiler-core', + '@vue/compiler-sfc', '@vue/shared', - 'untyped' - ] + 'untyped', + ], }) diff --git a/packages/schema/package.json b/packages/schema/package.json index 161cc4ddc4..14a419b1b1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@nuxt/schema", - "version": "3.10.2", + "version": "3.12.2", "repository": { "type": "git", "url": "git+https://github.com/nuxt/nuxt.git", @@ -34,43 +34,47 @@ "prepack": "unbuild" }, "devDependencies": { - "@nuxt/telemetry": "2.5.3", + "@nuxt/telemetry": "2.6.0", + "@nuxt/ui-templates": "1.3.4", "@types/file-loader": "5.0.4", "@types/pug": "2.0.10", - "@types/sass-loader": "8.0.8", - "@unhead/schema": "1.8.10", - "@vitejs/plugin-vue": "5.0.4", - "@vitejs/plugin-vue-jsx": "3.1.0", - "@vue/compiler-core": "3.4.19", - "c12": "1.8.0", - "esbuild-loader": "4.0.3", - "h3": "1.10.1", - "ignore": "5.3.1", - "nitropack": "2.8.1", - "ofetch": "1.3.3", - "unbuild": "latest", + "@types/sass-loader": "8.0.9", + "@unhead/schema": "1.11.7", + "@vitejs/plugin-vue": "5.1.4", + "@vitejs/plugin-vue-jsx": "4.0.1", + "@vue/compiler-core": "3.5.11", + "@vue/compiler-sfc": "3.5.11", + "@vue/language-core": "2.1.6", + "c12": "2.0.1", + "esbuild-loader": "4.2.2", + "h3": "npm:h3-nightly@2.0.0-1718872656.6765a6e", + "ignore": "6.0.2", + "nitro": "npm:nitro-nightly@3.0.0-beta-28665895.e727afda", + "ofetch": "1.4.0", + "unbuild": "3.0.0-rc.11", "unctx": "2.3.1", - "unenv": "1.9.0", - "vite": "5.1.3", - "vue": "3.4.19", - "vue-bundle-renderer": "2.0.0", + "unenv": "1.10.0", + "vite": "5.4.8", + "vue": "3.5.11", + "vue-bundle-renderer": "2.1.1", "vue-loader": "17.4.2", - "vue-router": "4.2.5", - "webpack": "5.90.2", - "webpack-dev-middleware": "7.0.0" + "vue-router": "4.4.5", + "webpack": "5.95.0", + "webpack-dev-middleware": "7.4.2" }, "dependencies": { - "@nuxt/ui-templates": "^1.3.1", + "compatx": "^0.1.8", "consola": "^3.2.3", "defu": "^6.1.4", "hookable": "^5.5.3", "pathe": "^1.1.2", - "pkg-types": "^1.0.3", + "pkg-types": "^1.2.1", "scule": "^1.3.0", "std-env": "^3.7.0", - "ufo": "^1.4.0", - "unimport": "^3.7.1", - "untyped": "^1.4.2" + "ufo": "^1.5.4", + "uncrypto": "^0.1.3", + "unimport": "^3.13.1", + "untyped": "^1.5.1" }, "engines": { "node": "^14.18.0 || >=16.10.0" diff --git a/packages/schema/src/config/adhoc.ts b/packages/schema/src/config/adhoc.ts index 70e1a90543..21660fa731 100644 --- a/packages/schema/src/config/adhoc.ts +++ b/packages/schema/src/config/adhoc.ts @@ -6,7 +6,7 @@ export default defineUntypedSchema({ * * Any components in the directories configured here can be used throughout your * pages, layouts (and other components) without needing to explicitly import them. - * @see https://nuxt.com/docs/guide/directory-structure/components + * @see [`components/` directory documentation](https://nuxt.com/docs/guide/directory-structure/components) * @type {boolean | typeof import('../src/types/components').ComponentsOptions | typeof import('../src/types/components').ComponentsOptions['dirs']} */ components: { @@ -18,12 +18,12 @@ export default defineUntypedSchema({ return { dirs: [{ path: '~/components/global', global: true }, '~/components'] } } return val - } + }, }, /** * Configure how Nuxt auto-imports composables into your application. - * @see [Nuxt 3 documentation](https://nuxt.com/docs/guide/directory-structure/composables) + * @see [Nuxt documentation](https://nuxt.com/docs/guide/directory-structure/composables) * @type {typeof import('../src/types/imports').ImportsOptions} */ imports: { @@ -40,7 +40,7 @@ export default defineUntypedSchema({ * } * ``` */ - dirs: [] + dirs: [], }, /** @@ -64,5 +64,5 @@ export default defineUntypedSchema({ * @see [Nuxt DevTools](https://devtools.nuxt.com/) for more information. * @type { { enabled: boolean, [key: string]: any } } */ - devtools: {} + devtools: {}, }) diff --git a/packages/schema/src/config/app.ts b/packages/schema/src/config/app.ts index 3496e5b4d4..9f5d8aa0b5 100644 --- a/packages/schema/src/config/app.ts +++ b/packages/schema/src/config/app.ts @@ -8,9 +8,17 @@ export default defineUntypedSchema({ * Vue.js config */ vue: { + /** @type {typeof import('@vue/compiler-sfc').AssetURLTagConfig} */ + transformAssetUrls: { + video: ['src', 'poster'], + source: ['src'], + img: ['src'], + image: ['xlink:href', 'href'], + use: ['xlink:href', 'href'], + }, /** * Options for the Vue compiler that will be passed at build time. - * @see [documentation](https://vuejs.org/api/application.html#app-config-compileroptions) + * @see [Vue documentation](https://vuejs.org/api/application.html#app-config-compileroptions) * @type {typeof import('@vue/compiler-core').CompilerOptions} */ compilerOptions: {}, @@ -19,15 +27,21 @@ export default defineUntypedSchema({ * Include Vue compiler in runtime bundle. */ runtimeCompiler: { - $resolve: async (val, get) => val ?? await get('experimental.runtimeVueCompiler') ?? false + $resolve: async (val, get) => val ?? await get('experimental.runtimeVueCompiler') ?? false, }, /** - * Vue Experimental: Enable reactive destructure for `defineProps` - * @see [Vue RFC#502](https://github.com/vuejs/rfcs/discussions/502) + * Enable reactive destructure for `defineProps` * @type {boolean} */ - propsDestructure: false, + propsDestructure: true, + + /** + * It is possible to pass configure the Vue app globally. Only serializable options + * may be set in your `nuxt.config`. All other options should be set at runtime in a Nuxt plugin.. + * @see [Vue app config documentation](https://vuejs.org/api/application.html#app-config) + */ + config: undefined, }, /** @@ -37,24 +51,44 @@ export default defineUntypedSchema({ /** * The base path of your Nuxt application. * - * This can be set at runtime by setting the NUXT_APP_BASE_URL environment variable. + * For example: + * @example + * ```ts + * export default defineNuxtConfig({ + * app: { + * baseURL: '/prefix/' + * } + * }) + * ``` + * + * This can also be set at runtime by setting the NUXT_APP_BASE_URL environment variable. * @example * ```bash * NUXT_APP_BASE_URL=/prefix/ node .output/server/index.mjs * ``` */ baseURL: { - $resolve: val => val || process.env.NUXT_APP_BASE_URL || '/' + $resolve: val => val || process.env.NUXT_APP_BASE_URL || '/', }, /** The folder name for the built site assets, relative to `baseURL` (or `cdnURL` if set). This is set at build time and should not be customized at runtime. */ buildAssetsDir: { - $resolve: val => val || process.env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/' + $resolve: val => val || process.env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/', }, /** * An absolute URL to serve the public folder from (production-only). * + * For example: + * @example + * ```ts + * export default defineNuxtConfig({ + * app: { + * cdnURL: 'https://mycdn.org/' + * } + * }) + * ``` + * * This can be set to a different value at runtime by setting the `NUXT_APP_CDN_URL` environment variable. * @example * ```bash @@ -62,7 +96,7 @@ export default defineUntypedSchema({ * ``` */ cdnURL: { - $resolve: async (val, get) => (await get('dev')) ? '' : (process.env.NUXT_APP_CDN_URL ?? val) || '' + $resolve: async (val, get) => (await get('dev')) ? '' : (process.env.NUXT_APP_CDN_URL ?? val) || '', }, /** @@ -104,7 +138,7 @@ export default defineUntypedSchema({ link: [], style: [], script: [], - noscript: [] + noscript: [], } as Required<Pick<AppHeadMetaObject, 'meta' | 'link' | 'style' | 'script' | 'noscript'>>) // provides default charset and viewport if not set @@ -122,7 +156,7 @@ export default defineUntypedSchema({ resolved.noscript = resolved.noscript.filter(Boolean) return resolved - } + }, }, /** @@ -130,7 +164,7 @@ export default defineUntypedSchema({ * * This can be overridden with `definePageMeta` on an individual page. * Only JSON-serializable values are allowed. - * @see https://vuejs.org/api/built-in-components.html#transition + * @see [Vue Transition docs](https://vuejs.org/api/built-in-components.html#transition) * @type {typeof import('../src/types/config').NuxtAppConfig['layoutTransition']} */ layoutTransition: false, @@ -140,7 +174,7 @@ export default defineUntypedSchema({ * * This can be overridden with `definePageMeta` on an individual page. * Only JSON-serializable values are allowed. - * @see https://vuejs.org/api/built-in-components.html#transition + * @see [Vue Transition docs](https://vuejs.org/api/built-in-components.html#transition) * @type {typeof import('../src/types/config').NuxtAppConfig['pageTransition']} */ pageTransition: false, @@ -152,13 +186,13 @@ export default defineUntypedSchema({ * [enabled in your nuxt.config file](/docs/getting-started/transitions#view-transitions-api-experimental). * * This can be overridden with `definePageMeta` on an individual page. - * @see https://nuxt.com/docs/getting-started/transitions#view-transitions-api-experimental + * @see [Nuxt View Transition API docs](https://nuxt.com/docs/getting-started/transitions#view-transitions-api-experimental) * @type {typeof import('../src/types/config').NuxtAppConfig['viewTransition']} */ viewTransition: { $resolve: async (val, get) => val ?? await (get('experimental') as Promise<Record<string, any>>).then( - (e) => e?.viewTransition - ) ?? false + e => e?.viewTransition, + ) ?? false, }, /** @@ -166,7 +200,7 @@ export default defineUntypedSchema({ * * This can be overridden with `definePageMeta` on an individual page. * Only JSON-serializable values are allowed. - * @see https://vuejs.org/api/built-in-components.html#keepalive + * @see [Vue KeepAlive](https://vuejs.org/api/built-in-components.html#keepalive) * @type {typeof import('../src/types/config').NuxtAppConfig['keepalive']} */ keepalive: false, @@ -174,22 +208,66 @@ export default defineUntypedSchema({ /** * Customize Nuxt root element id. * @type {string | false} + * @deprecated Prefer `rootAttrs.id` instead */ rootId: { - $resolve: val => val === false ? false : val || '__nuxt' + $resolve: val => val === false ? false : (val || '__nuxt'), }, /** * Customize Nuxt root element tag. */ rootTag: { - $resolve: val => val || 'div' - } + $resolve: val => val || 'div', + }, + + /** + * Customize Nuxt root element id. + * @type {typeof import('@unhead/schema').HtmlAttributes} + */ + rootAttrs: { + $resolve: async (val: undefined | null | Record<string, unknown>, get) => { + const rootId = await get('app.rootId') + return defu(val, { + id: rootId === false ? undefined : (rootId || '__nuxt'), + }) + }, + }, + + /** + * Customize Nuxt root element tag. + */ + teleportTag: { + $resolve: val => val || 'div', + }, + + /** + * Customize Nuxt Teleport element id. + * @type {string | false} + * @deprecated Prefer `teleportAttrs.id` instead + */ + teleportId: { + $resolve: val => val === false ? false : (val || 'teleports'), + }, + + /** + * Customize Nuxt Teleport element attributes. + * @type {typeof import('@unhead/schema').HtmlAttributes} + */ + teleportAttrs: { + $resolve: async (val: undefined | null | Record<string, unknown>, get) => { + const teleportId = await get('app.teleportId') + return defu(val, { + id: teleportId === false ? undefined : (teleportId || 'teleports'), + }) + }, + }, }, /** * Boolean or a path to an HTML file with the contents of which will be inserted into any HTML page * rendered with `ssr: false`. + * * - If it is unset, it will use `~/app/spa-loading-template.html` file in one of your layers, if it exists. * - If it is false, no SPA loading indicator will be loaded. * - If true, Nuxt will look for `~/app/spa-loading-template.html` file in one of your layers, or a @@ -242,7 +320,7 @@ export default defineUntypedSchema({ * @type {string | boolean} */ spaLoadingTemplate: { - $resolve: async (val: string | boolean | undefined, get) => typeof val === 'string' ? resolve(await get('srcDir') as string, val) : val ?? null + $resolve: async (val: string | boolean | undefined, get) => typeof val === 'string' ? resolve(await get('srcDir') as string, val) : val ?? null, }, /** @@ -256,7 +334,7 @@ export default defineUntypedSchema({ * @note Plugins are also auto-registered from the `~/plugins` directory * and these plugins do not need to be listed in `nuxt.config` unless you * need to customize their order. All plugins are deduplicated by their src path. - * @see https://nuxt.com/docs/guide/directory-structure/plugins + * @see [`plugins/` directory documentation](https://nuxt.com/docs/guide/directory-structure/plugins) * @example * ```js * plugins: [ @@ -293,6 +371,37 @@ export default defineUntypedSchema({ * @type {string[]} */ css: { - $resolve: (val: string[] | undefined) => (val ?? []).map((c: any) => c.src || c) - } + $resolve: (val: string[] | undefined) => (val ?? []).map((c: any) => c.src || c), + }, + + /** + * An object that allows us to configure the `unhead` nuxt module. + */ + unhead: { + /** + * An object that will be passed to `renderSSRHead` to customize the output. + * + * @see [`unhead` options documentation](https://unhead.unjs.io/setup/ssr/installation#options) + * + * @example + * ```ts + * export default defineNuxtConfig({ + * unhead: { + * renderSSRHeadOptions: { + * omitLineBreaks: true + * } + * }) + * ``` + * @type {typeof import('@unhead/schema').RenderSSRHeadOptions} + */ + renderSSRHeadOptions: { + $resolve: async (val: Record<string, unknown> | undefined, get) => { + const isV4 = ((await get('future') as Record<string, unknown>).compatibilityVersion === 4) + + return defu(val, { + omitLineBreaks: isV4, + }) + }, + }, + }, }) diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index 9a51397faa..6527a2aaa3 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -16,10 +16,10 @@ export default defineUntypedSchema({ } const map: Record<string, string> = { vite: '@nuxt/vite-builder', - webpack: '@nuxt/webpack-builder' + webpack: '@nuxt/webpack-builder', } return map[val] || val || (await get('vite') === false ? map.webpack : map.vite) - } + }, }, /** @@ -33,9 +33,9 @@ export default defineUntypedSchema({ } return defu(val, { server: true, - client: await get('dev') + client: await get('dev'), }) - } + }, }, /** @@ -51,7 +51,7 @@ export default defineUntypedSchema({ consola.warn(`Invalid \`logLevel\` option: \`${val}\`. Must be one of: \`silent\`, \`info\`, \`verbose\`.`) } return val ?? (isTest ? 'silent' : 'info') - } + }, }, /** @@ -66,12 +66,12 @@ export default defineUntypedSchema({ * You can also use a function to conditionally transpile. The function will receive an object ({ isDev, isServer, isClient, isModern, isLegacy }). * @example * ```js - transpile: [({ isLegacy }) => isLegacy && 'ky'] + * transpile: [({ isLegacy }) => isLegacy && 'ky'] * ``` * @type {Array<string | RegExp | ((ctx: { isClient?: boolean; isServer?: boolean; isDev: boolean }) => string | RegExp | false)>} */ transpile: { - $resolve: (val: Array<string | RegExp | ((ctx: { isClient?: boolean; isServer?: boolean; isDev: boolean }) => string | RegExp | false)> | undefined) => (val || []).filter(Boolean) + $resolve: (val: Array<string | RegExp | ((ctx: { isClient?: boolean, isServer?: boolean, isDev: boolean }) => string | RegExp | false)> | undefined) => (val || []).filter(Boolean), }, /** @@ -114,10 +114,10 @@ export default defineUntypedSchema({ return defu(typeof val === 'boolean' ? { enabled: val } : val, { template: 'treemap', projectRoot: rootDir, - filename: join(analyzeDir, '{name}.html') + filename: join(analyzeDir, '{name}.html'), }) - } - } + }, + }, }, /** @@ -144,8 +144,8 @@ export default defineUntypedSchema({ { name: 'useAsyncData', argumentLength: 3 }, { name: 'useLazyAsyncData', argumentLength: 3 }, { name: 'useLazyFetch', argumentLength: 3 }, - ...val || [] - ].filter(Boolean) + ...val || [], + ].filter(Boolean), }, /** @@ -165,22 +165,22 @@ export default defineUntypedSchema({ await get('dev') ? {} : { - vue: ['onBeforeMount', 'onMounted', 'onBeforeUpdate', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated', 'onBeforeUnmount'], - '#app': ['definePayloadReviver', 'definePageMeta'] - } - ) + 'vue': ['onMounted', 'onUpdated', 'onUnmounted', 'onBeforeMount', 'onBeforeUpdate', 'onBeforeUnmount', 'onRenderTracked', 'onRenderTriggered', 'onActivated', 'onDeactivated'], + '#app': ['definePayloadReviver', 'definePageMeta'], + }, + ), }, client: { $resolve: async (val, get) => defu(val || {}, await get('dev') ? {} : { - vue: ['onServerPrefetch', 'onRenderTracked', 'onRenderTriggered'], - '#app': ['definePayloadReducer', 'definePageMeta'] - } - ) - } - } + 'vue': ['onRenderTracked', 'onRenderTriggered', 'onServerPrefetch'], + '#app': ['definePayloadReducer', 'definePageMeta', 'onPrehydrate'], + }, + ), + }, + }, }, /** @@ -193,8 +193,8 @@ export default defineUntypedSchema({ objectDefinitions: { defineNuxtComponent: ['asyncData', 'setup'], defineNuxtPlugin: ['setup'], - definePageMeta: ['middleware', 'validate'] - } - } - } + definePageMeta: ['middleware', 'validate'], + }, + }, + }, }) diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index 3a5e85f3b7..61fcd1f2a5 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -1,8 +1,11 @@ +import { existsSync } from 'node:fs' +import { readdir } from 'node:fs/promises' import { defineUntypedSchema } from 'untyped' -import { join, relative, resolve } from 'pathe' +import { basename, join, relative, resolve } from 'pathe' import { isDebug, isDevelopment, isTest } from 'std-env' import { defu } from 'defu' import { findWorkspaceDir } from 'pkg-types' +import { randomUUID } from 'uncrypto' import type { RuntimeConfig } from '../types/config' export default defineUntypedSchema({ @@ -11,13 +14,25 @@ export default defineUntypedSchema({ * * Value should be either a string or array of strings pointing to source directories or config path relative to current config. * - * You can use `github:`, `gh:` `gitlab:` or `bitbucket:`. - * @see https://github.com/unjs/c12#extending-config-layer-from-remote-sources - * @see https://github.com/unjs/giget + * You can use `github:`, `gh:` `gitlab:` or `bitbucket:` + * @see [`c12` docs on extending config layers](https://github.com/unjs/c12#extending-config-layer-from-remote-sources) + * @see [`giget` documentation](https://github.com/unjs/giget) * @type {string | [string, typeof import('c12').SourceOptions?] | (string | [string, typeof import('c12').SourceOptions?])[]} */ extends: null, + /** + * Specify a compatibility date for your app. + * + * This is used to control the behavior of presets in Nitro, Nuxt Image + * and other modules that may change behavior without a major version bump. + * + * We plan to improve the tooling around this feature in the future. + * + * @type {typeof import('compatx').CompatibilityDateSpec} + */ + compatibilityDate: undefined, + /** * Extend project from a local or remote source. * @@ -38,7 +53,7 @@ export default defineUntypedSchema({ * It is normally not needed to configure this option. */ rootDir: { - $resolve: val => typeof val === 'string' ? resolve(val) : process.cwd() + $resolve: val => typeof val === 'string' ? resolve(val) : process.cwd(), }, /** @@ -53,7 +68,7 @@ export default defineUntypedSchema({ $resolve: async (val: string | undefined, get): Promise<string> => { const rootDir = await get('rootDir') as string return val ? resolve(rootDir, val) : await findWorkspaceDir(rootDir).catch(() => rootDir) - } + }, }, /** @@ -79,7 +94,7 @@ export default defineUntypedSchema({ * ------| middleware/ * ------| pages/ * ------| plugins/ - * ------| static/ + * ------| public/ * ------| store/ * ------| server/ * ------| app.config.ts @@ -88,7 +103,48 @@ export default defineUntypedSchema({ * ``` */ srcDir: { - $resolve: async (val: string | undefined, get): Promise<string> => resolve(await get('rootDir') as string, val || '.') + $resolve: async (val: string | undefined, get): Promise<string> => { + if (val) { + return resolve(await get('rootDir') as string, val) + } + + const [rootDir, isV4] = await Promise.all([ + get('rootDir') as Promise<string>, + (get('future') as Promise<Record<string, unknown>>).then(r => r.compatibilityVersion === 4), + ]) + + if (!isV4) { + return rootDir + } + + const srcDir = resolve(rootDir, 'app') + if (!existsSync(srcDir)) { + return rootDir + } + + const srcDirFiles = new Set<string>() + const files = await readdir(srcDir).catch(() => []) + for (const file of files) { + if (file !== 'spa-loading-template.html' && !file.startsWith('router.options')) { + srcDirFiles.add(file) + } + } + if (srcDirFiles.size === 0) { + for (const file of ['app.vue', 'App.vue']) { + if (existsSync(resolve(rootDir, file))) { + return rootDir + } + } + const keys = ['assets', 'layouts', 'middleware', 'pages', 'plugins'] as const + const dirs = await Promise.all(keys.map(key => get(`dir.${key}`) as Promise<string>)) + for (const dir of dirs) { + if (existsSync(resolve(rootDir, dir))) { + return rootDir + } + } + } + return srcDir + }, }, /** @@ -99,7 +155,14 @@ export default defineUntypedSchema({ * */ serverDir: { - $resolve: async (val: string | undefined, get): Promise<string> => resolve(await get('rootDir') as string, val || resolve(await get('srcDir') as string, 'server')) + $resolve: async (val: string | undefined, get): Promise<string> => { + if (val) { + const rootDir = await get('rootDir') as string + return resolve(rootDir, val) + } + const isV4 = (await get('future') as Record<string, unknown>).compatibilityVersion === 4 + return join(isV4 ? await get('rootDir') as string : await get('srcDir') as string, 'server') + }, }, /** @@ -115,7 +178,31 @@ export default defineUntypedSchema({ * ``` */ buildDir: { - $resolve: async (val: string | undefined, get): Promise<string> => resolve(await get('rootDir') as string, val || '.nuxt') + $resolve: async (val: string | undefined, get) => { + const rootDir = await get('rootDir') as string + return resolve(rootDir, val ?? '.nuxt') + }, + }, + + /** + * For multi-app projects, the unique id of the Nuxt application. + * + * Defaults to `nuxt-app`. + */ + appId: { + $resolve: (val: string) => val ?? 'nuxt-app', + }, + + /** + * A unique identifier matching the build. This may contain the hash of the current state of the project. + */ + buildId: { + $resolve: async (val: string | undefined, get): Promise<string> => { + if (typeof val === 'string') { return val } + + const [isDev, isTest] = await Promise.all([get('dev') as Promise<boolean>, get('test') as Promise<boolean>]) + return isDev ? 'dev' : isTest ? 'test' : randomUUID() + }, }, /** @@ -138,9 +225,9 @@ export default defineUntypedSchema({ const rootDir = await get('rootDir') as string return [...new Set([ ...(val || []).map((dir: string) => resolve(rootDir, dir)), - resolve(rootDir, 'node_modules') + resolve(rootDir, 'node_modules'), ])] - } + }, }, /** @@ -151,7 +238,7 @@ export default defineUntypedSchema({ analyzeDir: { $resolve: async (val: string | undefined, get): Promise<string> => val ? resolve(await get('rootDir') as string, val) - : resolve(await get('buildDir') as string, 'analyze') + : resolve(await get('buildDir') as string, 'analyze'), }, /** @@ -174,7 +261,7 @@ export default defineUntypedSchema({ * */ debug: { - $resolve: val => val ?? isDebug + $resolve: val => val ?? isDebug, }, /** @@ -182,7 +269,7 @@ export default defineUntypedSchema({ * If set to `false` generated pages will have no content. */ ssr: { - $resolve: val => val ?? true + $resolve: val => val ?? true, }, /** @@ -193,7 +280,8 @@ export default defineUntypedSchema({ * * Nuxt tries to resolve each item in the modules array using node require path * (in `node_modules`) and then will be resolved from project `srcDir` if `~` alias is used. - * @note Modules are executed sequentially so the order is important. + * @note Modules are executed sequentially so the order is important. First, the modules defined in `nuxt.config.ts` are loaded. Then, modules found in the `modules/` + * directory are executed, and they load in alphabetical order. * @example * ```js * modules: [ @@ -207,10 +295,10 @@ export default defineUntypedSchema({ * function () {} * ] * ``` - * @type {(typeof import('../src/types/module').NuxtModule | string | [typeof import('../src/types/module').NuxtModule | string, Record<string, any>] | undefined | null | false)[]} + * @type {(typeof import('../src/types/module').NuxtModule<any> | string | [typeof import('../src/types/module').NuxtModule | string, Record<string, any>] | undefined | null | false)[]} */ modules: { - $resolve: (val: string[] | undefined): string[] => (val || []).filter(Boolean) + $resolve: (val: string[] | undefined): string[] => (val || []).filter(Boolean), }, /** @@ -219,6 +307,16 @@ export default defineUntypedSchema({ * It is better to stick with defaults unless needed. */ dir: { + app: { + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record<string, unknown>).compatibilityVersion === 4 + if (isV4) { + const [srcDir, rootDir] = await Promise.all([get('srcDir') as Promise<string>, get('rootDir') as Promise<string>]) + return resolve(await get('srcDir') as string, val || (srcDir === rootDir ? 'app' : '.')) + } + return val || 'app' + }, + }, /** * The assets directory (aliased as `~assets` in your build). */ @@ -237,7 +335,15 @@ export default defineUntypedSchema({ /** * The modules directory, each file in which will be auto-registered as a Nuxt module. */ - modules: 'modules', + modules: { + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record<string, unknown>).compatibilityVersion === 4 + if (isV4) { + return resolve(await get('rootDir') as string, val || 'modules') + } + return val || 'modules' + }, + }, /** * The directory which will be processed to auto-generate your application page routes. @@ -254,20 +360,26 @@ export default defineUntypedSchema({ * and copied across into your `dist` folder when your app is generated. */ public: { - $resolve: async (val, get) => val || await get('dir.static') || 'public' + $resolve: async (val: string | undefined, get) => { + const isV4 = (await get('future') as Record<string, unknown>).compatibilityVersion === 4 + if (isV4) { + return resolve(await get('rootDir') as string, val || await get('dir.static') as string || 'public') + } + return val || await get('dir.static') as string || 'public' + }, }, static: { $schema: { deprecated: 'use `dir.public` option instead' }, - $resolve: async (val, get) => val || await get('dir.public') || 'public' - } + $resolve: async (val, get) => val || await get('dir.public') || 'public', + }, }, /** * The extensions that should be resolved by the Nuxt resolver. */ extensions: { - $resolve: (val: string[] | undefined): string[] => ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue', ...val || []].filter(Boolean) + $resolve: (val: string[] | undefined): string[] => ['.js', '.jsx', '.mjs', '.ts', '.tsx', '.vue', ...val || []].filter(Boolean), }, /** @@ -318,11 +430,11 @@ export default defineUntypedSchema({ '@': srcDir, '~~': rootDir, '@@': rootDir, - [assetsDir]: join(srcDir, assetsDir), - [publicDir]: join(srcDir, publicDir), - ...val + [basename(assetsDir)]: resolve(srcDir, assetsDir), + [basename(publicDir)]: resolve(srcDir, publicDir), + ...val, } - } + }, }, /** @@ -339,11 +451,13 @@ export default defineUntypedSchema({ ignoreOptions: undefined, /** - * Any file in `pages/`, `layouts/`, `middleware/` or `store/` will be ignored during - * building if its filename starts with the prefix specified by `ignorePrefix`. + * Any file in `pages/`, `layouts/`, `middleware/`, and `public/` directories will be ignored during + * the build process if its filename starts with the prefix specified by `ignorePrefix`. This is intended to prevent + * certain files from being processed or served in the built application. + * By default, the `ignorePrefix` is set to '-', ignoring any files starting with '-'. */ ignorePrefix: { - $resolve: val => val ?? '-' + $resolve: val => val ?? '-', }, /** @@ -361,9 +475,9 @@ export default defineUntypedSchema({ relative(rootDir, analyzeDir), relative(rootDir, buildDir), ignorePrefix && `**/${ignorePrefix}*.*`, - ...val || [] + ...val || [], ].filter(Boolean) - } + }, }, /** @@ -377,7 +491,7 @@ export default defineUntypedSchema({ watch: { $resolve: (val: Array<unknown> | undefined) => { return (val || []).filter((b: unknown) => typeof b === 'string' || b instanceof RegExp) - } + }, }, /** @@ -391,15 +505,15 @@ export default defineUntypedSchema({ * @see [webpack@4 watch options](https://v4.webpack.js.org/configuration/watch/#watchoptions). */ webpack: { - aggregateTimeout: 1000 + aggregateTimeout: 1000, }, /** * Options to pass directly to `chokidar`. * @see [chokidar](https://github.com/paulmillr/chokidar#api) */ chokidar: { - ignoreInitial: true - } + ignoreInitial: true, + }, }, /** @@ -448,7 +562,7 @@ export default defineUntypedSchema({ * ```js * export default { * runtimeConfig: { - * apiKey: '' // Default to an empty string, automatically set at runtime using process.env.NUXT_API_KEY + * apiKey: '', // Default to an empty string, automatically set at runtime using process.env.NUXT_API_KEY * public: { * baseURL: '' // Exposed to the frontend as well. * } @@ -459,17 +573,18 @@ export default defineUntypedSchema({ */ runtimeConfig: { $resolve: async (val: RuntimeConfig, get): Promise<Record<string, unknown>> => { - const app = await get('app') as Record<string, string> + const [app, buildId] = await Promise.all([get('app') as Promise<Record<string, string>>, get('buildId') as Promise<string>]) provideFallbackValues(val) return defu(val, { public: {}, app: { + buildId, baseURL: app.baseURL, buildAssetsDir: app.buildAssetsDir, - cdnURL: app.cdnURL - } + cdnURL: app.cdnURL, + }, }) - } + }, }, /** @@ -480,10 +595,10 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/config').AppConfig} */ appConfig: { - nuxt: {} + nuxt: {}, }, - $schema: {} + $schema: {}, }) function provideFallbackValues (obj: Record<string, any>) { diff --git a/packages/schema/src/config/dev.ts b/packages/schema/src/config/dev.ts index 209513ba69..de2a03ccf7 100644 --- a/packages/schema/src/config/dev.ts +++ b/packages/schema/src/config/dev.ts @@ -1,12 +1,12 @@ import { defineUntypedSchema } from 'untyped' -import { loading as loadingTemplate } from '@nuxt/ui-templates' +import { template as loadingTemplate } from '../../../ui-templates/dist/templates/loading' export default defineUntypedSchema({ devServer: { /** * Whether to enable HTTPS. * @example - * ``` + * ```ts * export default defineNuxtConfig({ * devServer: { * https: { @@ -38,6 +38,6 @@ export default defineUntypedSchema({ * Template to show a loading screen * @type {(data: { loading?: string }) => string} */ - loadingTemplate - } + loadingTemplate, + }, }) diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 5c969b7066..711a20b776 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -6,6 +6,18 @@ export default defineUntypedSchema({ * (possibly major) version of the framework. */ future: { + /** + * Enable early access to future features or flags. + * + * It is currently not configurable but may be in future. + * @type {4} + */ + compatibilityVersion: 4, + /** + * This enables early access to the experimental multi-app support. + * @see [Nuxt Issue #21635](https://github.com/nuxt/nuxt/issues/21635) + */ + multiApp: false, /** * This enables 'Bundler' module resolution mode for TypeScript, which is the recommended setting * for frameworks like Nuxt and Vite. @@ -14,7 +26,7 @@ export default defineUntypedSchema({ * * You can set it to false to use the legacy 'Node' mode, which is the default for TypeScript. * - * See https://github.com/microsoft/TypeScript/pull/51669 + * @see [TypeScript PR implementing `bundler` module resolution](https://github.com/microsoft/TypeScript/pull/51669) */ typescriptBundlerResolution: { async $resolve (val, get) { @@ -26,7 +38,7 @@ export default defineUntypedSchema({ return setting.toLowerCase() === 'bundler' } return true - } + }, }, }, /** @@ -49,7 +61,22 @@ export default defineUntypedSchema({ } // Enabled by default for vite prod with ssr return val ?? true - } + }, + }, + + /** + * Stream server logs to the client as you are developing. These logs can + * be handled in the `dev:ssr-logs` hook. + * + * If set to `silent`, the logs will not be printed to the browser console. + * @type {boolean | 'silent'} + */ + devLogs: { + async $resolve (val, get) { + if (val !== undefined) { return val } + const [isDev, isTest] = await Promise.all([get('dev'), get('test')]) + return isDev && !isTest + }, }, /** @@ -60,7 +87,7 @@ export default defineUntypedSchema({ async $resolve (val, get) { // TODO: remove in v3.10 return val ?? await (get('experimental') as Promise<Record<string, any>>).then((e: Record<string, any>) => e?.noScripts) ?? false - } + }, }, }, experimental: { @@ -68,7 +95,7 @@ export default defineUntypedSchema({ * Set to true to generate an async entry point for the Vue bundle (for module federation support). */ asyncEntry: { - $resolve: val => val ?? false + $resolve: val => val ?? false, }, // TODO: Remove when nitro has support for mocking traced dependencies @@ -80,11 +107,11 @@ export default defineUntypedSchema({ externalVue: true, /** - * Tree shakes contents of client-only components from server bundle. - * @see [Nuxt PR #5750](https://github.com/nuxt/framework/pull/5750) + * Enable accessing `appConfig` from server routes. + * + * @deprecated This option is not recommended. */ - treeshakeClientOnly: true, - + serverAppConfig: false, /** * Emit `app:chunkError` hook when there is an error loading vite/webpack * chunks. @@ -106,7 +133,7 @@ export default defineUntypedSchema({ return 'automatic' } return val ?? 'automatic' - } + }, }, /** @@ -171,8 +198,11 @@ export default defineUntypedSchema({ writeEarlyHints: false, /** - * Experimental component islands support with <NuxtIsland> and .island.vue files. - * @type {true | 'local' | 'local+remote' | Partial<{ remoteIsland: boolean, selectiveClient: boolean }> | false} + * Experimental component islands support with `<NuxtIsland>` and `.island.vue` files. + * + * By default it is set to 'auto', which means it will be enabled only when there are islands, + * server components or server pages in your app. + * @type {true | 'auto' | 'local' | 'local+remote' | Partial<{ remoteIsland: boolean, selectiveClient: boolean | 'deep' }> | false} */ componentIslands: { $resolve: (val) => { @@ -182,27 +212,10 @@ export default defineUntypedSchema({ if (val === 'local') { return true } - return val ?? false - } + return val ?? 'auto' + }, }, - /** - * Config schema support - * @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592) - */ - configSchema: true, - - /** - * Whether or not to add a compatibility layer for modules, plugins or user code relying on the old - * `@vueuse/head` API. - * - * This can be disabled for most Nuxt sites to reduce the client-side bundle by ~0.5kb. - */ - polyfillVueUseHead: false, - - /** Allow disabling Nuxt SSR responses by setting the `x-nuxt-no-ssr` header. */ - respectNoSSRHeader: false, - /** Resolve `~`, `~~`, `@` and `@@` aliases located within layers with respect to their layer source and root directories. */ localLayerAliases: true, @@ -214,26 +227,41 @@ export default defineUntypedSchema({ */ appManifest: true, - // This is enabled when `experimental.payloadExtraction` is set to `true`. - // appManifest: { - // $resolve: (val, get) => val ?? get('experimental.payloadExtraction') - // }, + /** + * Set the time interval (in ms) to check for new builds. Disabled when `experimental.appManifest` is `false`. + * + * Set to `false` to disable. + * @type {number | false} + */ + checkOutdatedBuildInterval: 1000 * 60 * 60, /** * Set an alternative watcher that will be used as the watching service for Nuxt. * - * Nuxt uses 'chokidar-granular' by default, which will ignore top-level directories - * (like `node_modules` and `.git`) that are excluded from watching. + * Nuxt uses 'chokidar-granular' if your source directory is the same as your root + * directory . This will ignore top-level directories (like `node_modules` and `.git`) + * that are excluded from watching. * * You can set this instead to `parcel` to use `@parcel/watcher`, which may improve * performance in large projects or on Windows platforms. * * You can also set this to `chokidar` to watch all files in your source directory. * @see [chokidar](https://github.com/paulmillr/chokidar) - * @see [Parcel watcher](https://github.com/parcel-bundler/watcher) + * @see [@parcel/watcher](https://github.com/parcel-bundler/watcher) * @type {'chokidar' | 'parcel' | 'chokidar-granular'} */ - watcher: 'chokidar-granular', + watcher: { + $resolve: async (val, get) => { + if (val) { + return val + } + const [srcDir, rootDir] = await Promise.all([get('srcDir'), get('rootDir')]) as [string, string] + if (srcDir === rootDir) { + return 'chokidar-granular' + } + return 'chokidar' + }, + }, /** * Enable native async context to be accessible for nested composables @@ -243,11 +271,13 @@ export default defineUntypedSchema({ /** * Use new experimental head optimisations: + * * - Add the capo.js head plugin in order to render tags in of the head in a more performant way. * - Uses the hash hydration plugin to reduce initial hydration - * @see [Nuxt Discussion #22632](https://github.com/nuxt/nuxt/discussions/22632] + * + * @see [Nuxt Discussion #22632](https://github.com/nuxt/nuxt/discussions/22632) */ - headNext: false, + headNext: true, /** * Allow defining `routeRules` directly within your `~/pages` directory using `defineRouteRules`. @@ -266,9 +296,9 @@ export default defineUntypedSchema({ * * This only works with static or strings/arrays rather than variables or conditional assignment. * - * https://github.com/nuxt/nuxt/issues/24770 + * @see [Nuxt Issues #24770](https://github.com/nuxt/nuxt/issues/24770) */ - scanPageMeta: false, + scanPageMeta: true, /** * Automatically share payload _data_ between pages that are prerendered. This can result in a significant @@ -293,13 +323,17 @@ export default defineUntypedSchema({ * }) * ``` */ - sharedPrerenderData: false, + sharedPrerenderData: { + async $resolve (val, get) { + return val ?? ((await get('future') as Record<string, unknown>).compatibilityVersion === 4) + }, + }, /** * Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. * @see [CookieStore](https://developer.mozilla.org/en-US/docs/Web/API/CookieStore) */ - cookieStore: false, + cookieStore: true, /** * This allows specifying the default options for core Nuxt components and composables. @@ -310,21 +344,25 @@ export default defineUntypedSchema({ defaults: { /** @type {typeof import('#app/components/nuxt-link')['NuxtLinkOptions']} */ nuxtLink: { - componentName: 'NuxtLink' + componentName: 'NuxtLink', + prefetch: true, + prefetchOn: { + visibility: true, + }, }, /** * Options that apply to `useAsyncData` (and also therefore `useFetch`) */ useAsyncData: { - deep: true + deep: false, }, /** @type {Pick<typeof import('ofetch')['FetchOptions'], 'timeout' | 'retry' | 'retryDelay' | 'retryStatusCodes'>} */ - useFetch: {} + useFetch: {}, }, /** * Automatically polyfill Node.js imports in the client build using `unenv`. - * @see https://github.com/unjs/unenv + * @see [unenv](https://github.com/unjs/unenv) * * **Note:** To make globals like `Buffer` work in the browser, you need to manually inject them. * @@ -336,5 +374,30 @@ export default defineUntypedSchema({ * @type {boolean} */ clientNodeCompat: false, - } + + /** + * Wait for a single animation frame before navigation, which gives an opportunity + * for the browser to repaint, acknowledging user interaction. + * + * It can reduce INP when navigating on prerendered routes. + */ + navigationRepaint: true, + + /** + * Cache Nuxt/Nitro build artifacts based on a hash of the configuration and source files. + * + * This only works for source files within `srcDir` and `serverDir` for the Vue/Nitro parts of your app. + */ + buildCache: false, + + /** + * Ensure that auto-generated Vue component names match the full component name + * you would use to auto-import the component. + */ + normalizeComponentNames: { + $resolve: async (val, get) => { + return val ?? ((await get('future') as Record<string, unknown>).compatibilityVersion === 4) + }, + }, + }, }) diff --git a/packages/schema/src/config/generate.ts b/packages/schema/src/config/generate.ts index 8e5561ce38..eb0e334393 100644 --- a/packages/schema/src/config/generate.ts +++ b/packages/schema/src/config/generate.ts @@ -21,6 +21,6 @@ export default defineUntypedSchema({ * This option is no longer used. Instead, use `nitro.prerender.ignore`. * @deprecated */ - exclude: [] - } + exclude: [], + }, }) diff --git a/packages/schema/src/config/index.ts b/packages/schema/src/config/index.ts index ca56799097..42666bc8c3 100644 --- a/packages/schema/src/config/index.ts +++ b/packages/schema/src/config/index.ts @@ -27,5 +27,5 @@ export default { ...router, ...typescript, ...vite, - ...webpack + ...webpack, } diff --git a/packages/schema/src/config/internal.ts b/packages/schema/src/config/internal.ts index c515110fe4..77c79bd5bc 100644 --- a/packages/schema/src/config/internal.ts +++ b/packages/schema/src/config/internal.ts @@ -2,7 +2,7 @@ import { defineUntypedSchema } from 'untyped' export default defineUntypedSchema({ /** @private */ - _majorVersion: 3, + _majorVersion: 4, /** @private */ _legacyGenerate: false, /** @private */ @@ -23,8 +23,11 @@ export default defineUntypedSchema({ _nuxtConfigFiles: [], /** @private */ appDir: '', - /** @private */ + /** + * @private + * @type {Array<{ meta: ModuleMeta; timings?: Record<string, number | undefined>; entryPath?: string }>} + */ _installedModules: [], /** @private */ - _modules: [] + _modules: [], }) diff --git a/packages/schema/src/config/nitro.ts b/packages/schema/src/config/nitro.ts index c37275dda8..58339db045 100644 --- a/packages/schema/src/config/nitro.ts +++ b/packages/schema/src/config/nitro.ts @@ -1,25 +1,44 @@ import { defineUntypedSchema } from 'untyped' +import type { RuntimeConfig } from '../types/config' export default defineUntypedSchema({ /** * Configuration for Nitro. - * @see https://nitro.unjs.io/config/ - * @type {typeof import('nitropack')['NitroConfig']} + * @see [Nitro configuration docs](https://nitro.unjs.io/config/) + * @type {typeof import('nitro/types')['NitroConfig']} */ nitro: { + runtimeConfig: { + $resolve: async (val: Record<string, any> | undefined, get) => { + const runtimeConfig = await get('runtimeConfig') as RuntimeConfig + return { + ...runtimeConfig, + app: { + ...runtimeConfig.app, + baseURL: runtimeConfig.app.baseURL.startsWith('./') + ? runtimeConfig.app.baseURL.slice(1) + : runtimeConfig.app.baseURL, + }, + nitro: { + envPrefix: 'NUXT_', + ...runtimeConfig.nitro, + }, + } + }, + }, routeRules: { $resolve: async (val: Record<string, any> | undefined, get) => ({ ...await get('routeRules') as Record<string, any>, - ...val - }) - } + ...val, + }), + }, }, /** * Global route options applied to matching server routes. * @experimental This is an experimental feature and API may change in the future. - * @see https://nitro.unjs.io/config/#routerules - * @type {typeof import('nitropack')['NitroConfig']['routeRules']} + * @see [Nitro route rules documentation](https://nitro.unjs.io/config/#routerules) + * @type {typeof import('nitro/types')['NitroConfig']['routeRules']} */ routeRules: {}, @@ -27,12 +46,14 @@ export default defineUntypedSchema({ * Nitro server handlers. * * Each handler accepts the following options: + * * - handler: The path to the file defining the handler. - * - route: The route under which the handler is available. This follows the conventions of https://github.com/unjs/radix3. + * - route: The route under which the handler is available. This follows the conventions of [rou3](https://github.com/unjs/rou3.) * - method: The HTTP method of requests that should be handled. * - middleware: Specifies whether it is a middleware handler. * - lazy: Specifies whether to use lazy loading to import the handler. - * @see https://nuxt.com/docs/guide/directory-structure/server + * + * @see [`server/` directory documentation](https://nuxt.com/docs/guide/directory-structure/server) * @note Files from `server/api`, `server/middleware` and `server/routes` will be automatically registered by Nuxt. * @example * ```js @@ -40,14 +61,14 @@ export default defineUntypedSchema({ * { route: '/path/foo/**:name', handler: '~/server/foohandler.ts' } * ] * ``` - * @type {typeof import('nitropack')['NitroEventHandler'][]} + * @type {typeof import('nitro/types')['NitroEventHandler'][]} */ serverHandlers: [], /** * Nitro development-only server handlers. - * @see https://nitro.unjs.io/guide/routing - * @type {typeof import('nitropack')['NitroDevEventHandler'][]} + * @see [Nitro server routes documentation](https://nitro.unjs.io/guide/routing) + * @type {typeof import('nitro/types')['NitroDevEventHandler'][]} */ - devServerHandlers: [] + devServerHandlers: [], }) diff --git a/packages/schema/src/config/postcss.ts b/packages/schema/src/config/postcss.ts index a1be5bcec3..573fcefb73 100644 --- a/packages/schema/src/config/postcss.ts +++ b/packages/schema/src/config/postcss.ts @@ -1,21 +1,56 @@ import { defineUntypedSchema } from 'untyped' +const ensureItemIsLast = (item: string) => (arr: string[]) => { + const index = arr.indexOf(item) + if (index !== -1) { + arr.splice(index, 1) + arr.push(item) + } + return arr +} + +const orderPresets = { + cssnanoLast: ensureItemIsLast('cssnano'), + autoprefixerLast: ensureItemIsLast('autoprefixer'), + autoprefixerAndCssnanoLast (names: string[]) { + return orderPresets.cssnanoLast(orderPresets.autoprefixerLast(names)) + }, +} + export default defineUntypedSchema({ postcss: { + /** + * A strategy for ordering PostCSS plugins. + * + * @type {'cssnanoLast' | 'autoprefixerLast' | 'autoprefixerAndCssnanoLast' | string[] | ((names: string[]) => string[])} + */ + order: { + $resolve: (val: string | string[] | ((plugins: string[]) => string[])): string[] | ((plugins: string[]) => string[]) => { + if (typeof val === 'string') { + if (!(val in orderPresets)) { + throw new Error(`[nuxt] Unknown PostCSS order preset: ${val}`) + } + return orderPresets[val as keyof typeof orderPresets] + } + return val ?? orderPresets.autoprefixerAndCssnanoLast + }, + }, /** * Options for configuring PostCSS plugins. * - * https://postcss.org/ - * @type {Record<string, any> & { autoprefixer?: any; cssnano?: any }} + * @see [PostCSS docs](https://postcss.org/) + * @type {Record<string, unknown> & { autoprefixer?: typeof import('autoprefixer').Options; cssnano?: typeof import('cssnano').Options }} */ plugins: { /** - * https://github.com/postcss/autoprefixer + * Plugin to parse CSS and add vendor prefixes to CSS rules. + * + * @see [`autoprefixer`](https://github.com/postcss/autoprefixer) */ autoprefixer: {}, /** - * https://cssnano.co/docs/config-file/#configuration-options + * @see [`cssnano` configuration options](https://cssnano.github.io/cssnano/docs/config-file/#configuration-options) */ cssnano: { $resolve: async (val, get) => { @@ -26,8 +61,8 @@ export default defineUntypedSchema({ return false } return {} - } - } - } - } + }, + }, + }, + }, }) diff --git a/packages/schema/src/config/router.ts b/packages/schema/src/config/router.ts index c57255d63b..5365829177 100644 --- a/packages/schema/src/config/router.ts +++ b/packages/schema/src/config/router.ts @@ -7,7 +7,7 @@ export default defineUntypedSchema({ * Nuxt offers additional options to customize the router (see below). * @note Only JSON serializable options should be passed by Nuxt config. * For more control, you can use `app/router.options.ts` file. - * @see [documentation](https://router.vuejs.org/api/interfaces/routeroptions.html). + * @see [Vue Router documentation](https://router.vuejs.org/api/interfaces/routeroptions.html). * @type {typeof import('../src/types/router').RouterConfigSerializable} */ options: { @@ -25,7 +25,7 @@ export default defineUntypedSchema({ * @type {typeof import('../src/types/router').RouterConfigSerializable['scrollBehaviorType']} * @default 'auto' */ - scrollBehaviorType: 'auto' - } - } + scrollBehaviorType: 'auto', + }, + }, }) diff --git a/packages/schema/src/config/typescript.ts b/packages/schema/src/config/typescript.ts index e2ae05df6f..402cd007fd 100644 --- a/packages/schema/src/config/typescript.ts +++ b/packages/schema/src/config/typescript.ts @@ -23,7 +23,36 @@ export default defineUntypedSchema({ * @type {'vite' | 'webpack' | 'shared' | false | undefined} */ builder: { - $resolve: val => val ?? null + $resolve: val => val ?? null, + }, + + /** + * Modules to generate deep aliases for within `compilerOptions.paths`. This does not yet support subpaths. + * It may be necessary when using Nuxt within a pnpm monorepo with `shamefully-hoist=false`. + */ + hoist: { + $resolve: (val) => { + const defaults = [ + // Nitro auto-imported/augmented dependencies + 'nitro/types', + 'defu', + 'h3', + 'consola', + 'ofetch', + // Key nuxt dependencies + '@unhead/vue', + '@nuxt/devtools', + 'vue', + '@vue/runtime-core', + '@vue/compiler-sfc', + 'vue-router', + 'vue-router/auto-routes', + 'unplugin-vue-router/client', + '@nuxt/schema', + 'nuxt', + ] + return val === false ? [] : (Array.isArray(val) ? val.concat(defaults) : defaults) + }, }, /** @@ -36,23 +65,26 @@ export default defineUntypedSchema({ * * If set to true, this will type check in development. You can restrict this to build-time type checking by setting it to `build`. * Requires to install `typescript` and `vue-tsc` as dev dependencies. - * @see https://nuxt.com/docs/guide/concepts/typescript + * @see [Nuxt TypeScript docs](https://nuxt.com/docs/guide/concepts/typescript) * @type {boolean | 'build'} */ typeCheck: false, /** * You can extend generated `.nuxt/tsconfig.json` using this option. - * @type {typeof import('pkg-types')['TSConfig']} + * @type {0 extends 1 & VueCompilerOptions ? typeof import('pkg-types')['TSConfig'] : typeof import('pkg-types')['TSConfig'] & { vueCompilerOptions?: typeof import('@vue/language-core')['VueCompilerOptions']}} */ tsConfig: {}, /** * Generate a `*.vue` shim. * - * We recommend instead either enabling [**Take Over Mode**](https://vuejs.org/guide/typescript/overview.html#volar-takeover-mode) or adding - * TypeScript Vue Plugin (Volar)** πŸ‘‰ [[Download](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin)]. + * We recommend instead letting the [official Vue extension](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + * generate accurate types for your components. + * + * Note that you may wish to set this to `true` if you are using other libraries, such as ESLint, + * that are unable to understand the type of `.vue` files. */ - shim: true - } + shim: false, + }, }) diff --git a/packages/schema/src/config/vite.ts b/packages/schema/src/config/vite.ts index f53fcb027a..035626b581 100644 --- a/packages/schema/src/config/vite.ts +++ b/packages/schema/src/config/vite.ts @@ -8,84 +8,98 @@ export default defineUntypedSchema({ /** * Configuration that will be passed directly to Vite. * - * See https://vitejs.dev/config for more information. + * @see [Vite configuration docs](https://vitejs.dev/config) for more information. * Please note that not all vite options are supported in Nuxt. * @type {typeof import('../src/types/config').ViteConfig & { $client?: typeof import('../src/types/config').ViteConfig, $server?: typeof import('../src/types/config').ViteConfig }} */ vite: { root: { - $resolve: async (val, get) => val ?? (await get('srcDir')) + $resolve: async (val, get) => val ?? (await get('srcDir')), }, mode: { - $resolve: async (val, get) => val ?? (await get('dev') ? 'development' : 'production') + $resolve: async (val, get) => val ?? (await get('dev') ? 'development' : 'production'), }, define: { $resolve: async (val: Record<string, any> | undefined, get) => { const [isDev, isDebug] = await Promise.all([get('dev'), get('debug')]) as [boolean, boolean] return { - __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: isDebug, + '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': isDebug, 'process.dev': isDev, 'import.meta.dev': isDev, 'process.test': isTest, 'import.meta.test': isTest, - ...val + ...val, } - } + }, }, resolve: { - extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'] + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'], }, publicDir: { - $resolve: async (val, get) => { + $resolve: (val) => { if (val) { consola.warn('Directly configuring the `vite.publicDir` option is not supported. Instead, set `dir.public`. You can read more in `https://nuxt.com/docs/api/nuxt-config#public`.') } - return val ?? await Promise.all([get('srcDir') as Promise<string>, get('dir') as Promise<Record<string, string>>]).then(([srcDir, dir]) => resolve(srcDir, dir.public)) - } + return false + }, }, vue: { isProduction: { - $resolve: async (val, get) => val ?? !(await get('dev')) + $resolve: async (val, get) => val ?? !(await get('dev')), }, template: { compilerOptions: { - $resolve: async (val, get) => val ?? (await get('vue') as Record<string, any>).compilerOptions - } + $resolve: async (val, get) => val ?? (await get('vue') as Record<string, any>).compilerOptions, + }, + transformAssetUrls: { + $resolve: async (val, get) => val ?? (await get('vue') as Record<string, any>).transformAssetUrls, + }, }, script: { + hoistStatic: { + $resolve: async (val, get) => val ?? (await get('vue') as Record<string, any>).compilerOptions?.hoistStatic, + }, + }, + features: { propsDestructure: { - $resolve: async (val, get) => val ?? Boolean((await get('vue') as Record<string, any>).propsDestructure) - } - } + $resolve: async (val, get) => { + if (val !== undefined && val !== null) { + return val + } + const vueOptions = await get('vue') as Record<string, any> || {} + return Boolean(vueOptions.script?.propsDestructure ?? vueOptions.propsDestructure) + }, + }, + }, }, vueJsx: { $resolve: async (val: Record<string, any>, get) => { return { isCustomElement: (await get('vue') as Record<string, any>).compilerOptions?.isCustomElement, - ...val + ...val, } - } + }, }, optimizeDeps: { exclude: { $resolve: async (val: string[] | undefined, get) => [ ...val || [], - ...(await get('build.transpile') as Array<string | RegExp | ((ctx: { isClient?: boolean; isServer?: boolean; isDev: boolean }) => string | RegExp | false)>).filter((i) => typeof i === 'string'), - 'vue-demi' - ] - } + ...(await get('build.transpile') as Array<string | RegExp | ((ctx: { isClient?: boolean, isServer?: boolean, isDev: boolean }) => string | RegExp | false)>).filter(i => typeof i === 'string'), + 'vue-demi', + ], + }, }, esbuild: { jsxFactory: 'h', jsxFragment: 'Fragment', - tsconfigRaw: '{}' + tsconfigRaw: '{}', }, clearScreen: true, build: { assetsDir: { - $resolve: async (val, get) => val ?? withoutLeadingSlash((await get('app') as Record<string, string>).buildAssetsDir) + $resolve: async (val, get) => val ?? withoutLeadingSlash((await get('app') as Record<string, string>).buildAssetsDir), }, - emptyOutDir: false + emptyOutDir: false, }, server: { fs: { @@ -98,11 +112,14 @@ export default defineUntypedSchema({ rootDir, workspaceDir, ...(modulesDir), - ...val ?? [] + ...val ?? [], ])] - } - } - } - } - } + }, + }, + }, + }, + cacheDir: { + $resolve: async (val, get) => val ?? resolve(await get('rootDir') as string, 'node_modules/.cache/vite'), + }, + }, }) diff --git a/packages/schema/src/config/webpack.ts b/packages/schema/src/config/webpack.ts index 252cef5542..ee4d94843d 100644 --- a/packages/schema/src/config/webpack.ts +++ b/packages/schema/src/config/webpack.ts @@ -1,5 +1,6 @@ import { defu } from 'defu' import { defineUntypedSchema } from 'untyped' +import type { VueLoaderOptions } from 'vue-loader' export default defineUntypedSchema({ webpack: { @@ -19,7 +20,7 @@ export default defineUntypedSchema({ $resolve: async (val: boolean | { enabled?: boolean } | Record<string, unknown>, get) => { const value = typeof val === 'boolean' ? { enabled: val } : val return defu(value, await get('build.analyze') as { enabled?: boolean } | Record<string, unknown>) - } + }, }, /** @@ -82,7 +83,7 @@ export default defineUntypedSchema({ * Enables CSS source map support (defaults to `true` in development). */ cssSourceMap: { - $resolve: async (val, get) => val ?? await get('dev') + $resolve: async (val, get) => val ?? await get('dev'), }, /** @@ -95,7 +96,7 @@ export default defineUntypedSchema({ /** * Customize bundle filenames. * - * To understand a bit more about the use of manifests, take a look at [this webpack documentation](https://webpack.js.org/guides/code-splitting/). + * To understand a bit more about the use of manifests, take a look at [webpack documentation](https://webpack.js.org/guides/code-splitting/). * @note Be careful when using non-hashed based filenames in production * as most browsers will cache the asset and not detect the changes on first load. * @@ -130,7 +131,7 @@ export default defineUntypedSchema({ css: ({ isDev }: { isDev: boolean }) => isDev ? '[name].css' : 'css/[contenthash:7].css', img: ({ isDev }: { isDev: boolean }) => isDev ? '[path][name].[ext]' : 'img/[name].[contenthash:7].[ext]', font: ({ isDev }: { isDev: boolean }) => isDev ? '[path][name].[ext]' : 'fonts/[name].[contenthash:7].[ext]', - video: ({ isDev }: { isDev: boolean }) => isDev ? '[path][name].[ext]' : 'videos/[name].[contenthash:7].[ext]' + video: ({ isDev }: { isDev: boolean }) => isDev ? '[path][name].[ext]' : 'videos/[name].[contenthash:7].[ext]', }, /** @@ -141,7 +142,7 @@ export default defineUntypedSchema({ const loaders: Record<string, any> = val && typeof val === 'object' ? val : {} const styleLoaders = [ 'css', 'cssModules', 'less', - 'sass', 'scss', 'stylus', 'vueStyle' + 'sass', 'scss', 'stylus', 'vueStyle', ] for (const name of styleLoaders) { const loader = loaders[name] @@ -153,13 +154,17 @@ export default defineUntypedSchema({ }, /** - * See https://github.com/esbuild-kit/esbuild-loader + * @see [esbuild loader](https://github.com/esbuild-kit/esbuild-loader) * @type {Omit<typeof import('esbuild-loader')['LoaderOptions'], 'loader'>} */ - esbuild: {}, + esbuild: { + jsxFactory: 'h', + jsxFragment: 'Fragment', + tsconfigRaw: '{}', + }, /** - * See: https://github.com/webpack-contrib/file-loader#options + * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) * @type {Omit<typeof import('file-loader')['Options'], 'name'>} * @default * ```ts @@ -169,7 +174,7 @@ export default defineUntypedSchema({ file: { esModule: false }, /** - * See: https://github.com/webpack-contrib/file-loader#options + * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) * @type {Omit<typeof import('file-loader')['Options'], 'name'>} * @default * ```ts @@ -179,7 +184,7 @@ export default defineUntypedSchema({ fontUrl: { esModule: false, limit: 1000 }, /** - * See: https://github.com/webpack-contrib/file-loader#options + * @see [`file-loader` Options](https://github.com/webpack-contrib/file-loader#options) * @type {Omit<typeof import('file-loader')['Options'], 'name'>} * @default * ```ts @@ -189,7 +194,7 @@ export default defineUntypedSchema({ imgUrl: { esModule: false, limit: 1000 }, /** - * See: https://pugjs.org/api/reference.html#options + * @see [`pug` options](https://pugjs.org/api/reference.html#options) * @type {typeof import('pug')['Options']} */ pugPlain: {}, @@ -200,41 +205,42 @@ export default defineUntypedSchema({ */ vue: { transformAssetUrls: { - video: 'src', - source: 'src', - object: 'src', - embed: 'src' + $resolve: async (val, get) => (val ?? (await get('vue.transformAssetUrls'))) as VueLoaderOptions['transformAssetUrls'], }, - compilerOptions: { $resolve: async (val, get) => val ?? (await get('vue.compilerOptions')) }, - propsDestructure: { $resolve: async (val, get) => val ?? Boolean(await get('vue.propsDestructure')) }, - }, + compilerOptions: { + $resolve: async (val, get) => (val ?? (await get('vue.compilerOptions'))) as VueLoaderOptions['compilerOptions'], + }, + propsDestructure: { + $resolve: async (val, get) => Boolean(val ?? await get('vue.propsDestructure')), + }, + } satisfies { [K in keyof VueLoaderOptions]: { $resolve: (val: unknown, get: (id: string) => Promise<unknown>) => Promise<VueLoaderOptions[K]> } }, css: { importLoaders: 0, url: { - filter: (url: string, _resourcePath: string) => url[0] !== '/' + filter: (url: string, _resourcePath: string) => url[0] !== '/', }, - esModule: false + esModule: false, }, cssModules: { importLoaders: 0, url: { - filter: (url: string, _resourcePath: string) => url[0] !== '/' + filter: (url: string, _resourcePath: string) => url[0] !== '/', }, esModule: false, modules: { - localIdentName: '[local]_[hash:base64:5]' - } + localIdentName: '[local]_[hash:base64:5]', + }, }, /** - * See: https://github.com/webpack-contrib/less-loader#options + * @see [`less-loader` Options](https://github.com/webpack-contrib/less-loader#options) */ less: {}, /** - * See: https://github.com/webpack-contrib/sass-loader#options + * @see [`sass-loader` Options](https://github.com/webpack-contrib/sass-loader#options) * @type {typeof import('sass-loader')['Options']} * @default * ```ts @@ -247,22 +253,22 @@ export default defineUntypedSchema({ */ sass: { sassOptions: { - indentedSyntax: true - } + indentedSyntax: true, + }, }, /** - * See: https://github.com/webpack-contrib/sass-loader#options + * @see [`sass-loader` Options](https://github.com/webpack-contrib/sass-loader#options) * @type {typeof import('sass-loader')['Options']} */ scss: {}, /** - * See: https://github.com/webpack-contrib/stylus-loader#options + * @see [`stylus-loader` Options](https://github.com/webpack-contrib/stylus-loader#options) */ stylus: {}, - vueStyle: {} + vueStyle: {}, }, /** @@ -294,7 +300,7 @@ export default defineUntypedSchema({ * @type {false | typeof import('css-minimizer-webpack-plugin').BasePluginOptions & typeof import('css-minimizer-webpack-plugin').DefinedDefaultMinimizerAndOptions<any>} */ optimizeCSS: { - $resolve: async (val, get) => val ?? (await get('build.extractCSS') ? {} : false) + $resolve: async (val, get) => val ?? (await get('build.extractCSS') ? {} : false), }, /** @@ -310,24 +316,24 @@ export default defineUntypedSchema({ splitChunks: { chunks: 'all', automaticNameDelimiter: '/', - cacheGroups: {} - } + cacheGroups: {}, + }, }, /** * Customize PostCSS Loader. - * Same options as https://github.com/webpack-contrib/postcss-loader#options + * same options as [`postcss-loader` options](https://github.com/webpack-contrib/postcss-loader#options) * @type {{ execute?: boolean, postcssOptions: typeof import('postcss').ProcessOptions, sourceMap?: boolean, implementation?: any }} */ postcss: { postcssOptions: { config: { - $resolve: async (val, get) => val ?? (await get('postcss.config')) + $resolve: async (val, get) => val ?? (await get('postcss.config')), }, plugins: { - $resolve: async (val, get) => val ?? (await get('postcss.plugins')) - } - } + $resolve: async (val, get) => val ?? (await get('postcss.plugins')), + }, + }, }, /** @@ -335,7 +341,7 @@ export default defineUntypedSchema({ * @type {typeof import('webpack-dev-middleware').Options<typeof import('http').IncomingMessage, typeof import('http').ServerResponse>} */ devMiddleware: { - stats: 'none' + stats: 'none', }, /** @@ -359,6 +365,6 @@ export default defineUntypedSchema({ * Configure [webpack experiments](https://webpack.js.org/configuration/experiments/) * @type {false | typeof import('webpack').Configuration['experiments']} */ - experiments: {} - } + experiments: {}, + }, }) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index eebdb6e56b..0eb5c1588f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,13 +1,13 @@ // Types -export * from './types/compatibility' -export * from './types/components' -export * from './types/config' -export * from './types/hooks' -export * from './types/imports' -export * from './types/head' -export * from './types/module' -export * from './types/nuxt' -export * from './types/router' +export type { NuxtCompatibility, NuxtCompatibilityIssue, NuxtCompatibilityIssues } from './types/compatibility' +export type { Component, ComponentMeta, ComponentsDir, ComponentsOptions, ScanDir } from './types/components' +export type { AppConfig, AppConfigInput, CustomAppConfig, NuxtAppConfig, NuxtBuilder, NuxtConfig, NuxtConfigLayer, NuxtOptions, PublicRuntimeConfig, RuntimeConfig, RuntimeValue, SchemaDefinition, UpperSnakeCase, ViteConfig } from './types/config' +export type { GenerateAppOptions, HookResult, ImportPresetWithDeprecation, NuxtAnalyzeMeta, NuxtHookName, NuxtHooks, NuxtLayout, NuxtMiddleware, NuxtPage, TSReference, VueTSConfig, WatchEvent } from './types/hooks' +export type { ImportsOptions } from './types/imports' +export type { AppHeadMetaObject, MetaObject, MetaObjectRaw, HeadAugmentations } from './types/head' +export type { ModuleDefinition, ModuleMeta, ModuleOptions, ModuleSetupInstallResult, ModuleSetupReturn, NuxtModule, ResolvedModuleOptions } from './types/module' +export type { Nuxt, NuxtApp, NuxtPlugin, NuxtPluginTemplate, NuxtTemplate, NuxtTypeTemplate, ResolvedNuxtTemplate } from './types/nuxt' +export type { RouterConfig, RouterConfigSerializable, RouterOptions } from './types/router' // Schema export { default as NuxtConfigSchema } from './config/index' diff --git a/packages/schema/src/types/builder-env/vite.ts b/packages/schema/src/types/builder-env/vite.ts index 06c444be52..da020f697c 100644 --- a/packages/schema/src/types/builder-env/vite.ts +++ b/packages/schema/src/types/builder-env/vite.ts @@ -30,7 +30,7 @@ export interface KnownAsTypeMap { export interface ImportGlobOptions< Eager extends boolean, - AsType extends string + AsType extends string, > { /** * Import type for the import url. @@ -65,13 +65,13 @@ export interface ImportGlobFunction { < Eager extends boolean, As extends string, - T = As extends keyof KnownAsTypeMap ? KnownAsTypeMap[As] : unknown + T = As extends keyof KnownAsTypeMap ? KnownAsTypeMap[As] : unknown, >( glob: string | string[], options?: ImportGlobOptions<Eager, As> ): (Eager extends true - ? true - : false) extends true + ? true + : false) extends true ? Record<string, T> : Record<string, () => Promise<T>> /** @@ -102,7 +102,7 @@ export interface ImportGlobEagerFunction { */ < As extends string, - T = As extends keyof KnownAsTypeMap ? KnownAsTypeMap[As] : unknown + T = As extends keyof KnownAsTypeMap ? KnownAsTypeMap[As] : unknown, >( glob: string | string[], options?: Omit<ImportGlobOptions<boolean, As>, 'eager'> diff --git a/packages/schema/src/types/compatibility.ts b/packages/schema/src/types/compatibility.ts index 3344af856e..562b187cb0 100644 --- a/packages/schema/src/types/compatibility.ts +++ b/packages/schema/src/types/compatibility.ts @@ -1,17 +1,32 @@ export interface NuxtCompatibility { /** * Required nuxt version in semver format. - * @example `^2.14.0` or `>=3.0.0-27219851.6e49637`. + * @example `^3.2.0` or `>=3.13.0`. */ nuxt?: string /** - * Bridge constraint for Nuxt 2 support. + * Mark a builder as incompatible, or require a particular version. * - * - `true`: When using Nuxt 2, using bridge module is required. - * - `false`: When using Nuxt 2, using bridge module is not supported. + * @example + * ```ts + * export default defineNuxtModule({ + * meta: { + * name: 'my-module', + * compatibility: { + * builder: { + * // marking as incompatible + * webpack: false, + * // you can require a (semver-compatible) version + * vite: '^5' + * } + * } + * } + * // ... + * }) + * ``` */ - bridge?: boolean + builder?: Partial<Record<'vite' | 'webpack' | (string & {}), false | string>> } export interface NuxtCompatibilityIssue { diff --git a/packages/schema/src/types/components.ts b/packages/schema/src/types/components.ts index 640119a941..9bea3a8cdc 100644 --- a/packages/schema/src/types/components.ts +++ b/packages/schema/src/types/components.ts @@ -1,3 +1,7 @@ +export interface ComponentMeta { + [key: string]: unknown +} + export interface Component { pascalName: string kebabName: string @@ -9,6 +13,7 @@ export interface Component { preload: boolean global?: boolean | 'sync' island?: boolean + meta?: ComponentMeta mode?: 'client' | 'server' | 'all' /** * This number allows configuring the behavior of overriding Nuxt components. @@ -48,7 +53,7 @@ export interface ScanDir { */ pathPrefix?: boolean /** - * Ignore scanning this directory if set to `true` + * Ignore scanning this directory if set to `false` */ enabled?: boolean /** @@ -114,6 +119,11 @@ export interface ComponentsOptions { * @default false */ global?: boolean + /** + * Whether to write metadata to the build directory with information about the components that + * are auto-registered in your app. + */ + generateMetadata?: boolean loader?: boolean transform?: { diff --git a/packages/schema/src/types/config.ts b/packages/schema/src/types/config.ts index aa8197d299..3de59b8493 100644 --- a/packages/schema/src/types/config.ts +++ b/packages/schema/src/types/config.ts @@ -1,15 +1,17 @@ -import type { KeepAliveProps, TransitionProps } from 'vue' +import type { KeepAliveProps, TransitionProps, AppConfig as VueAppConfig } from 'vue' import type { ServerOptions as ViteServerOptions, UserConfig as ViteUserConfig } from 'vite' import type { Options as VuePluginOptions } from '@vitejs/plugin-vue' import type { Options as VueJsxPluginOptions } from '@vitejs/plugin-vue-jsx' import type { SchemaDefinition } from 'untyped' -import type { NitroRuntimeConfig, NitroRuntimeConfigApp } from 'nitropack' +import type { NitroRuntimeConfig, NitroRuntimeConfigApp } from 'nitro/types' import type { SnakeCase } from 'scule' import type { ConfigSchema } from '../../schema/config' import type { Nuxt } from './nuxt' import type { AppHeadMetaObject } from './head' + export type { SchemaDefinition } from 'untyped' +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type type DeepPartial<T> = T extends Function ? T : T extends Record<string, any> ? { [P in keyof T]?: DeepPartial<T[P]> } : T export type UpperSnakeCase<S extends string> = Uppercase<SnakeCase<S>> @@ -23,39 +25,41 @@ type Overrideable<T extends Record<string, any>, Path extends string = ''> = { : T[K] extends Record<string, unknown> ? RuntimeValue<Overrideable<T[K], `${Path}_${UpperSnakeCase<K>}`>, `You can override this value at runtime with NUXT${Path}_${UpperSnakeCase<K>}`> : RuntimeValue<T[K], `You can override this value at runtime with NUXT${Path}_${UpperSnakeCase<K>}`> - : K extends number - ? T[K] - : never + : K extends number + ? T[K] + : never } // Runtime Config type RuntimeConfigNamespace = Record<string, unknown> +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface PublicRuntimeConfig extends RuntimeConfigNamespace { } export interface RuntimeConfig extends RuntimeConfigNamespace { - app: NitroRuntimeConfigApp - /** Only available on the server. */ - nitro?: NitroRuntimeConfig['nitro'] - public: PublicRuntimeConfig + app: NitroRuntimeConfigApp + /** Only available on the server. */ + nitro?: NitroRuntimeConfig['nitro'] + public: PublicRuntimeConfig } // User configuration in `nuxt.config` file -export interface NuxtConfig extends DeepPartial<Omit<ConfigSchema, 'vite' | 'runtimeConfig'>> { - // Avoid DeepPartial for vite config interface (#4772) - vite?: ConfigSchema['vite'] - runtimeConfig?: Overrideable<RuntimeConfig> - webpack?: DeepPartial<ConfigSchema['webpack']> & { - $client?: DeepPartial<ConfigSchema['webpack']> - $server?: DeepPartial<ConfigSchema['webpack']> - } +export interface NuxtConfig extends DeepPartial<Omit<ConfigSchema, 'vue' | 'vite' | 'runtimeConfig' | 'webpack'>> { + vue?: Omit<DeepPartial<ConfigSchema['vue']>, 'config'> & { config?: Partial<Filter<VueAppConfig, string | boolean>> } + // Avoid DeepPartial for vite config interface (#4772) + vite?: ConfigSchema['vite'] + runtimeConfig?: Overrideable<RuntimeConfig> + webpack?: DeepPartial<ConfigSchema['webpack']> & { + $client?: DeepPartial<ConfigSchema['webpack']> + $server?: DeepPartial<ConfigSchema['webpack']> + } - /** - * Experimental custom config schema - * @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592) - */ - $schema?: SchemaDefinition + /** + * Experimental custom config schema + * @see [Nuxt Issue #15592](https://github.com/nuxt/nuxt/issues/15592) + */ + $schema?: SchemaDefinition } // TODO: Expose ConfigLayer<T> from c12 @@ -65,18 +69,20 @@ interface ConfigLayer<T> { configFile: string } export type NuxtConfigLayer = ConfigLayer<NuxtConfig & { - srcDir: ConfigSchema['srcDir'], + srcDir: ConfigSchema['srcDir'] rootDir: ConfigSchema['rootDir'] }> export interface NuxtBuilder { - bundle: (nuxt: Nuxt) => Promise<void> + bundle: (nuxt: Nuxt) => Promise<void> } // Normalized Nuxt options available as `nuxt.options.*` -export interface NuxtOptions extends Omit<ConfigSchema, 'builder' | 'webpack'> { +export interface NuxtOptions extends Omit<ConfigSchema, 'vue' | 'sourcemap' | 'builder' | 'postcss' | 'webpack'> { + vue: Omit<ConfigSchema['vue'], 'config'> & { config?: Partial<Filter<VueAppConfig, string | boolean>> } sourcemap: Required<Exclude<ConfigSchema['sourcemap'], boolean>> builder: '@nuxt/vite-builder' | '@nuxt/webpack-builder' | NuxtBuilder + postcss: Omit<ConfigSchema['postcss'], 'order'> & { order: Exclude<ConfigSchema['postcss']['order'], string> } webpack: ConfigSchema['webpack'] & { $client: ConfigSchema['webpack'] $server: ConfigSchema['webpack'] @@ -100,12 +106,6 @@ export interface ViteConfig extends Omit<ViteUserConfig, 'publicDir'> { */ vueJsx?: VueJsxPluginOptions - /** - * Bundler for dev time server-side rendering. - * @default 'vite-node' - */ - devBundler?: 'vite-node' | 'legacy' - /** * Warmup vite entrypoint caches on dev startup. */ @@ -140,12 +140,18 @@ export interface AppConfigInput extends CustomAppConfig { server?: never } +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +type Serializable<T> = T extends Function ? never : T extends Promise<infer U> ? Serializable<U> : T extends string & {} ? T : T extends Record<string, any> ? { [K in keyof T]: Serializable<T[K]> } : T + +type ValueOf<T> = T[keyof T] +type Filter<T extends Record<string, any>, V> = Pick<T, ValueOf<{ [K in keyof T]: NonNullable<T[K]> extends V ? K : never }>> + export interface NuxtAppConfig { - head: AppHeadMetaObject - layoutTransition: boolean | TransitionProps - pageTransition: boolean | TransitionProps + head: Serializable<AppHeadMetaObject> + layoutTransition: boolean | Serializable<TransitionProps> + pageTransition: boolean | Serializable<TransitionProps> viewTransition?: boolean | 'always' - keepalive: boolean | KeepAliveProps + keepalive: boolean | Serializable<KeepAliveProps> } export interface AppConfig { diff --git a/packages/schema/src/types/head.ts b/packages/schema/src/types/head.ts index 70f54cf04b..937cf82c2c 100644 --- a/packages/schema/src/types/head.ts +++ b/packages/schema/src/types/head.ts @@ -3,13 +3,21 @@ import type { Head, MergeHead } from '@unhead/schema' /** @deprecated Extend types from `@unhead/schema` directly. This may be removed in a future minor version. */ export interface HeadAugmentations extends MergeHead { // runtime type modifications + // eslint-disable-next-line @typescript-eslint/no-empty-object-type base?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type link?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type meta?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type style?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type script?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type noscript?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type htmlAttrs?: {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type bodyAttrs?: {} } diff --git a/packages/schema/src/types/hooks.ts b/packages/schema/src/types/hooks.ts index 5fe595d314..780b8b0614 100644 --- a/packages/schema/src/types/hooks.ts +++ b/packages/schema/src/types/hooks.ts @@ -6,9 +6,10 @@ import type { Manifest } from 'vue-bundle-renderer' import type { EventHandler } from 'h3' import type { Import, InlinePreset, Unimport } from 'unimport' import type { Compiler, Configuration, Stats } from 'webpack' -import type { Nitro, NitroConfig } from 'nitropack' +import type { Nitro, NitroConfig } from 'nitro/types' import type { Schema, SchemaDefinition } from 'untyped' import type { RouteLocationRaw } from 'vue-router' +import type { VueCompilerOptions } from '@vue/language-core' import type { NuxtCompatibility, NuxtCompatibilityIssues, ViteConfig } from '..' import type { Component, ComponentsOptions } from './components' import type { Nuxt, NuxtApp, ResolvedNuxtTemplate } from './nuxt' @@ -20,6 +21,10 @@ export type TSReference = { types: string } | { path: string } export type WatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' +// If the user does not have `@vue/language-core` installed, VueCompilerOptions will be typed as `any`, +// thus making the whole `VueTSConfig` type `any`. We only augment TSConfig if VueCompilerOptions is available. +export type VueTSConfig = 0 extends 1 & VueCompilerOptions ? TSConfig : TSConfig & { vueCompilerOptions?: VueCompilerOptions } + export type NuxtPage = { name?: string path: string @@ -28,6 +33,19 @@ export type NuxtPage = { alias?: string[] | string redirect?: RouteLocationRaw children?: NuxtPage[] + /** + * Set the render mode. + * + * `all` means the page will be rendered isomorphically - with JavaScript both on client and server. + * + * `server` means pages are automatically rendered with server components, so there will be no JavaScript to render the page in your client bundle. + * + * `client` means that page will render on the client-side only. + * @default 'all' + */ + mode?: 'client' | 'server' | 'all' + /** @internal */ + _sync?: boolean } export type NuxtMiddleware = { @@ -41,6 +59,7 @@ export type NuxtLayout = { file: string } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ImportPresetWithDeprecation extends InlinePreset { } @@ -273,7 +292,7 @@ export interface NuxtHooks { * @param options Objects containing `references`, `declarations`, `tsConfig` * @returns Promise */ - 'prepare:types': (options: { references: TSReference[], declarations: string[], tsConfig: TSConfig }) => HookResult + 'prepare:types': (options: { references: TSReference[], declarations: string[], tsConfig: VueTSConfig }) => HookResult /** * Called when the dev server is loading. * @param listenerServer The HTTP/HTTPS server object diff --git a/packages/schema/src/types/imports.ts b/packages/schema/src/types/imports.ts index 1dc9e1b393..96171ecf87 100644 --- a/packages/schema/src/types/imports.ts +++ b/packages/schema/src/types/imports.ts @@ -15,6 +15,13 @@ export interface ImportsOptions extends UnimportOptions { */ dirs?: string[] + /** + * Enabled scan for local directories for auto imports. + * When this is disabled, `dirs` options will be ignored. + * @default true + */ + scan?: boolean + /** * Assign auto imported utilities to `globalThis` instead of using built time transformation. * @default false diff --git a/packages/schema/src/types/module.ts b/packages/schema/src/types/module.ts index 9b92d6a93e..1879678923 100644 --- a/packages/schema/src/types/module.ts +++ b/packages/schema/src/types/module.ts @@ -1,3 +1,4 @@ +import type { Defu } from 'defu' import type { NuxtHooks } from './hooks' import type { Nuxt } from './nuxt' import type { NuxtCompatibility } from './compatibility' @@ -26,8 +27,7 @@ export interface ModuleMeta { /** The options received. */ export type ModuleOptions = Record<string, any> -/** Optional result for nuxt modules */ -export interface ModuleSetupReturn { +export type ModuleSetupInstallResult = { /** * Timing information for the initial setup */ @@ -39,19 +39,62 @@ export interface ModuleSetupReturn { } type Awaitable<T> = T | Promise<T> -type _ModuleSetupReturn = Awaitable<void | false | ModuleSetupReturn> -/** Input module passed to defineNuxtModule. */ -export interface ModuleDefinition<T extends ModuleOptions = ModuleOptions> { +type Prettify<T> = { + [K in keyof T]: T[K]; +} & {} + +export type ModuleSetupReturn = Awaitable<false | void | ModuleSetupInstallResult> + +export type ResolvedModuleOptions< + TOptions extends ModuleOptions, + TOptionsDefaults extends Partial<TOptions>, +> = + Prettify< + Defu< + Partial<TOptions>, + [Partial<TOptions>, TOptionsDefaults] + > + > + +/** Module definition passed to 'defineNuxtModule(...)' or 'defineNuxtModule().with(...)'. */ +export interface ModuleDefinition< + TOptions extends ModuleOptions, + TOptionsDefaults extends Partial<TOptions>, + TWith extends boolean, +> { meta?: ModuleMeta - defaults?: T | ((nuxt: Nuxt) => T) - schema?: T + defaults?: TOptionsDefaults | ((nuxt: Nuxt) => TOptionsDefaults) + schema?: TOptions hooks?: Partial<NuxtHooks> - setup?: (this: void, resolvedOptions: T, nuxt: Nuxt) => _ModuleSetupReturn + setup?: ( + this: void, + resolvedOptions: TWith extends true + ? ResolvedModuleOptions<TOptions, TOptionsDefaults> + : TOptions, + nuxt: Nuxt + ) => ModuleSetupReturn } -export interface NuxtModule<T extends ModuleOptions = ModuleOptions> { - (this: void, inlineOptions: T, nuxt: Nuxt): _ModuleSetupReturn - getOptions?: (inlineOptions?: T, nuxt?: Nuxt) => Promise<T> +export interface NuxtModule< + TOptions extends ModuleOptions = ModuleOptions, + TOptionsDefaults extends Partial<TOptions> = Partial<TOptions>, + TWith extends boolean = false, +> { + ( + this: void, + resolvedOptions: TWith extends true + ? ResolvedModuleOptions<TOptions, TOptionsDefaults> + : TOptions, + nuxt: Nuxt + ): ModuleSetupReturn + getOptions?: ( + inlineOptions?: Partial<TOptions>, + nuxt?: Nuxt + ) => Promise< + TWith extends true + ? ResolvedModuleOptions<TOptions, TOptionsDefaults> + : TOptions + > getMeta?: () => Promise<ModuleMeta> } diff --git a/packages/schema/src/types/nuxt.ts b/packages/schema/src/types/nuxt.ts index 0f9b6bec7a..41130835a6 100644 --- a/packages/schema/src/types/nuxt.ts +++ b/packages/schema/src/types/nuxt.ts @@ -36,6 +36,7 @@ export interface NuxtTemplate<Options = TemplateDefaultOptions> { /** The resolved path to the source file to be template */ src?: string /** Provided compile option instead of src */ + getContents?: (data: { nuxt: Nuxt, app: NuxtApp, options: Options }) => string | Promise<string> /** Write to filesystem */ write?: boolean @@ -44,6 +45,7 @@ export interface NuxtTemplate<Options = TemplateDefaultOptions> { export interface ResolvedNuxtTemplate<Options = TemplateDefaultOptions> extends NuxtTemplate<Options> { filename: string dst: string + modified?: boolean } export interface NuxtTypeTemplate<Options = TemplateDefaultOptions> extends Omit<NuxtTemplate<Options>, 'write' | 'filename'> { @@ -52,6 +54,7 @@ export interface NuxtTypeTemplate<Options = TemplateDefaultOptions> extends Omit } type _TemplatePlugin<Options> = Omit<NuxtPlugin, 'src'> & NuxtTemplate<Options> +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface NuxtPluginTemplate<Options = TemplateDefaultOptions> extends _TemplatePlugin<Options> { } export interface NuxtApp { @@ -73,7 +76,7 @@ export interface Nuxt { // Private fields. _version: string _ignore?: Ignore - _ignorePatterns?: string[] + _dependencies?: Set<string> /** The resolved Nuxt configuration. */ options: NuxtOptions diff --git a/packages/schema/src/types/router.ts b/packages/schema/src/types/router.ts index 76abc6acb8..c7cb0ce850 100644 --- a/packages/schema/src/types/router.ts +++ b/packages/schema/src/types/router.ts @@ -2,7 +2,7 @@ import type { RouterHistory, RouterOptions as _RouterOptions } from 'vue-router' export type RouterOptions = Partial<Omit<_RouterOptions, 'history' | 'routes'>> & { history?: (baseURL?: string) => RouterHistory - routes?: (_routes: _RouterOptions['routes']) => _RouterOptions['routes'] + routes?: (_routes: _RouterOptions['routes']) => _RouterOptions['routes'] | Promise<_RouterOptions['routes']> hashMode?: boolean scrollBehaviorType?: 'smooth' | 'auto' } diff --git a/packages/schema/test/folder-structure.spec.ts b/packages/schema/test/folder-structure.spec.ts new file mode 100644 index 0000000000..a45a857ee8 --- /dev/null +++ b/packages/schema/test/folder-structure.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyDefaults } from 'untyped' + +import { normalize } from 'pathe' +import { NuxtConfigSchema } from '../src' +import type { NuxtOptions } from '../src' + +vi.mock('node:fs', () => ({ + existsSync: (id: string) => id.endsWith('app'), +})) + +describe('nuxt folder structure', () => { + it('should resolve directories for v3 setup correctly', async () => { + const result = await applyDefaults(NuxtConfigSchema, {}) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "<cwd>/app", + "modules": "<cwd>/modules", + "public": "<cwd>/public", + }, + "rootDir": "<cwd>", + "serverDir": "<cwd>/server", + "srcDir": "<cwd>/app", + "workspaceDir": "<cwd>", + } + `) + }) + + it('should resolve directories with a custom `srcDir` and `rootDir`', async () => { + const result = await applyDefaults(NuxtConfigSchema, { srcDir: 'src/', rootDir: '/test' }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "/test/src", + "modules": "/test/modules", + "public": "/test/public", + }, + "rootDir": "/test", + "serverDir": "/test/server", + "srcDir": "/test/src", + "workspaceDir": "/test", + } + `) + }) + + it('should resolve directories when opting-in to v4 schema', async () => { + const result = await applyDefaults(NuxtConfigSchema, { future: { compatibilityVersion: 4 } }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "<cwd>/app", + "modules": "<cwd>/modules", + "public": "<cwd>/public", + }, + "rootDir": "<cwd>", + "serverDir": "<cwd>/server", + "srcDir": "<cwd>/app", + "workspaceDir": "<cwd>", + } + `) + }) + + it('should resolve directories when opting-in to v4 schema with a custom `srcDir` and `rootDir`', async () => { + const result = await applyDefaults(NuxtConfigSchema, { future: { compatibilityVersion: 4 }, srcDir: 'customApp/', rootDir: '/test' }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "/test/customApp", + "modules": "/test/modules", + "public": "/test/public", + }, + "rootDir": "/test", + "serverDir": "/test/server", + "srcDir": "/test/customApp", + "workspaceDir": "/test", + } + `) + }) + + it('should not override value from user for serverDir', async () => { + const result = await applyDefaults(NuxtConfigSchema, { future: { compatibilityVersion: 4 }, serverDir: '/myServer' }) + expect(getDirs(result as unknown as NuxtOptions)).toMatchInlineSnapshot(` + { + "dir": { + "app": "<cwd>/app", + "modules": "<cwd>/modules", + "public": "<cwd>/public", + }, + "rootDir": "<cwd>", + "serverDir": "/myServer", + "srcDir": "<cwd>/app", + "workspaceDir": "<cwd>", + } + `) + }) +}) + +function getDirs (options: NuxtOptions) { + const stripRoot = (dir: string) => { + return normalize(dir).replace(normalize(process.cwd()), '<cwd>') + } + return { + rootDir: stripRoot(options.rootDir), + serverDir: stripRoot(options.serverDir), + srcDir: stripRoot(options.srcDir), + dir: { + app: stripRoot(options.dir.app), + modules: stripRoot(options.dir.modules), + public: stripRoot(options.dir.public), + }, + workspaceDir: stripRoot(options.workspaceDir!), + } +} diff --git a/packages/ui-templates/README.md b/packages/ui-templates/README.md new file mode 100644 index 0000000000..0eb922e19c --- /dev/null +++ b/packages/ui-templates/README.md @@ -0,0 +1,11 @@ +# `@nuxt/ui-templates` + +<a href="https://www.npmjs.com/package/@nuxt/ui-templates-edge"><img src="https://flat.badgen.net/npm/v/@nuxt/ui-templates-edge"></a> + +Pre-compiled html templates for internal pages. + +πŸ€ [Online Playground](https://templates.ui.nuxtjs.org) + +# License + +<a rel="license" href="http://creativecommons.org/licenses/by-nd/4.0/"><img alt="Creative Commons License" style="border-width:0" src="https://i.creativecommons.org/l/by-nd/4.0/88x31.png" /></a><br /><span xmlns:dct="http://purl.org/dc/terms/" property="dct:title">Nuxt UI</span> by <span xmlns:cc="http://creativecommons.org/ns#" property="cc:attributionName">Nuxt Project</span> is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by-nd/4.0/">Creative Commons Attribution-NoDerivatives 4.0 International License</a>.<br />Based on a work at <a xmlns:dct="http://purl.org/dc/terms/" href="https://github.com/nuxt/ui" rel="dct:source">https://github.com/nuxt/ui</a>. diff --git a/packages/ui-templates/index.html b/packages/ui-templates/index.html new file mode 100644 index 0000000000..60710321da --- /dev/null +++ b/packages/ui-templates/index.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <script src="https://cdn.jsdelivr.net/npm/petite-vue" defer init></script> + <script type="module" src="/styles.ts"></script> + </head> + <body v-scope='{{ data }}'> + <div class="container mx-auto pt-10"> + <h1 class="text-4xl mb-8">Nuxt Templates</h1> + <ul> + <li v-for="name in templateNames.filter(name => !name.includes('.json'))"> + <a :href="`/templates/${name}`" class="block border p-4 mb-2 hover:border-black">{{ name }}</a> + </li> + </ul> + </div> + </body> +</html> diff --git a/packages/ui-templates/lib/dev.ts b/packages/ui-templates/lib/dev.ts new file mode 100644 index 0000000000..3469dd6257 --- /dev/null +++ b/packages/ui-templates/lib/dev.ts @@ -0,0 +1,47 @@ +import { runInNewContext } from 'node:vm' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promises as fsp } from 'node:fs' +import type { Plugin } from 'vite' +import genericMessages from '../templates/messages.json' +import { version } from '../../nuxt/package.json' + +const templatesRoot = fileURLToPath(new URL('..', import.meta.url)) + +const r = (...path: string[]) => resolve(join(templatesRoot, ...path)) + +export const DevRenderingPlugin = () => { + return <Plugin>{ + name: 'dev-rendering', + async transformIndexHtml (html: string, context) { + const page = context.originalUrl || '/' + + if (page.endsWith('.png')) { return } + + if (page === '/') { + const templateNames = await fsp.readdir(r('templates')) + const serializedData = JSON.stringify({ templateNames }) + return html.replace('{{ data }}', serializedData) + } + + const contents = await fsp.readFile(r(page, 'index.html'), 'utf-8') + + const messages = JSON.parse(await fsp.readFile(r(page, 'messages.json'), 'utf-8')) + + const chunks = contents.split(/\{{2,3}[^{}]+\}{2,3}/g) + let templateString = chunks.shift() + for (const expression of contents.matchAll(/\{{2,3}([^{}]+)\}{2,3}/g)) { + const value = runInNewContext(expression[1]!.trim(), { + version, + messages: { ...genericMessages, ...messages }, + }) + templateString += `${value}${chunks.shift()}` + } + if (chunks.length > 0) { + templateString += chunks.join('') + } + + return templateString + }, + } +} diff --git a/packages/ui-templates/lib/prerender.ts b/packages/ui-templates/lib/prerender.ts new file mode 100644 index 0000000000..053de48927 --- /dev/null +++ b/packages/ui-templates/lib/prerender.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from 'node:url' +import { promises as fsp } from 'node:fs' +import { glob } from 'tinyglobby' + +const templatesRoot = fileURLToPath(new URL('..', import.meta.url)) + +async function main () { + const templates = await glob(['dist/templates/*.js'], { cwd: templatesRoot }) + for (const file of templates) { + const { template } = await import(file) + const updated = template({ + // messages: {}, + name: '{{ name }}', // TODO + }) + await fsp.mkdir(file.replace('.js', '')) + await fsp.writeFile(file.replace('.js', '/index.html'), updated) + } +} + +main().catch(console.error) diff --git a/packages/ui-templates/lib/render.ts b/packages/ui-templates/lib/render.ts new file mode 100644 index 0000000000..13ebacf790 --- /dev/null +++ b/packages/ui-templates/lib/render.ts @@ -0,0 +1,209 @@ +import { fileURLToPath } from 'node:url' +import { readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { copyFile } from 'node:fs/promises' +import { basename, dirname, join } from 'pathe' +import type { Plugin } from 'vite' +// @ts-expect-error https://github.com/GoogleChromeLabs/critters/pull/151 +import Critters from 'critters' +import { genObjectFromRawEntries } from 'knitwork' +import htmlnano from 'htmlnano' +import { glob } from 'tinyglobby' +import { camelCase } from 'scule' + +import { version } from '../../nuxt/package.json' +import genericMessages from '../templates/messages.json' + +const r = (path: string) => fileURLToPath(new URL(join('..', path), import.meta.url)) +const replaceAll = (input: string, search: string | RegExp, replace: string) => input.split(search).join(replace) + +export const RenderPlugin = () => { + let outputDir: string + return <Plugin> { + name: 'render', + configResolved (config) { + outputDir = r(config.build.outDir) + }, + enforce: 'post', + async writeBundle () { + const critters = new Critters({ path: outputDir }) + const htmlFiles = await glob(['templates/**/*.html'], { + cwd: outputDir, + absolute: true, + }) + + const templateExports: Array<{ + exportName: string + templateName: string + types: string + }> = [] + + for (const fileName of htmlFiles) { + // Infer template name + const templateName = basename(dirname(fileName)) + + // eslint-disable-next-line no-console + console.log('Processing', templateName) + + // Read source template + let html = readFileSync(fileName, 'utf-8') + const isCompleteHTML = html.includes('<!DOCTYPE html>') + + if (html.includes('<html')) { + // Apply critters to inline styles + html = await critters.process(html) + } + html = html.replace(/<html[^>]*>/, '<html lang="en">') + // We no longer need references to external CSS + html = html.replace(/<link[^>]*>/g, '') + + // Inline SVGs + const svgSources: string[] = [] + + for (const [_, src] of html.matchAll(/src="([^"]+)"|url([^)]+)/g)) { + if (src?.match(/\.svg$/)) { + svgSources.push(src) + } + } + + for (const src of svgSources) { + const svg = readFileSync(join(outputDir, src), 'utf-8') + const base64Source = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + html = replaceAll(html, src, base64Source) + } + + // Inline our scripts + const scriptSources: [string, string][] = [] + + for (const [block, src] of html.matchAll(/<script[^>]*src="([^"]*)"[^>]*>[\s\S]*?<\/script>/g)) { + if (src?.match(/^\/.*\.js$/)) { + scriptSources.push([block, src]) + } + } + + for (const [scriptBlock, src] of scriptSources) { + let contents = readFileSync(join(outputDir, src), 'utf-8') + contents = replaceAll(contents, '/* empty css */', '').trim() + html = html.replace(scriptBlock, contents.length ? `<script>${contents}</script>` : '') + } + + // Minify HTML + html = await htmlnano.process(html, { collapseWhitespace: 'aggressive' }).then(r => r.html) + + if (!isCompleteHTML) { + html = html.replace('<html><head></head><body>', '') + html = html.replace('</body></html>', '') + } + + html = html.replace(/\{\{ version \}\}/g, version) + + // Load messages + const messages = JSON.parse(readFileSync(r(`templates/${templateName}/messages.json`), 'utf-8')) + + // Serialize into a js function + const chunks = html.split(/\{{2,3}[^{}]+\}{2,3}/g).map(chunk => JSON.stringify(chunk)) + const hasMessages = chunks.length > 1 + let templateString = chunks.shift() + for (const [_, expression] of html.matchAll(/\{{2,3}([^{}]+)\}{2,3}/g)) { + if (expression) { + templateString += ` + (${expression.trim()}) + ${chunks.shift()}` + } + } + if (chunks.length > 0) { + templateString += ' + ' + chunks.join(' + ') + } + const functionalCode = [ + hasMessages ? `export type DefaultMessages = Record<${Object.keys({ ...genericMessages, ...messages }).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >` : '', + hasMessages ? `const _messages = ${JSON.stringify({ ...genericMessages, ...messages })}` : '', + `export const template = (${hasMessages ? 'messages: Partial<DefaultMessages>' : ''}) => {`, + hasMessages ? ' messages = { ..._messages, ...messages }' : '', + ` return ${templateString}`, + '}', + ].join('\n') + + const templateContent = html + .match(/<body[^>]*>([\s\S]*)<\/body>/)?.[0] + .replace(/(?<=<\/|<)body/g, 'div') + .replace(/messages\./g, '') + .replace(/<script[^>]*>([\s\S]*?)<\/script>/g, '') + .replace(/<a href="(\/[^"]*)"([^>]*)>([\s\S]*)<\/a>/g, '<NuxtLink to="$1"$2>\n$3\n</NuxtLink>') + + .replace(/<([^>]+) ([a-z]+)="([^"]*)(\{\{\s*(\w+)\s*\}\})([^"]*)"([^>]*)>/g, '<$1 :$2="`$3${$5}$6`"$7>') + .replace(/>\{\{\s*(\w+)\s*\}\}<\/[\w-]*>/g, ' v-text="$1" />') + .replace(/>\{\{\{\s*(\w+)\s*\}\}\}<\/[\w-]*>/g, ' v-html="$1" />') + // We are not matching <link> <script> and <meta> tags as these aren't used yet in nuxt/ui + // and should be taken care of wherever this SFC is used + const title = html.match(/<title[^>]*>([\s\S]*)<\/title>/)?.[1]?.replace(/\{\{([\s\S]+?)\}\}/g, (r) => { + return `\${${r.slice(2, -2)}}`.replace(/messages\./g, 'props.') + }) + const styleContent = Array.from(html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)).map(block => block[1]).join('\n') + const globalStyles = styleContent.replace(/(\.[^{\d][^{]*\{[^}]*\})+.?/g, (r) => { + const lastChar = r[r.length - 1] + if (lastChar && !['}', '.', '@', '*', ':'].includes(lastChar)) { + return ';' + lastChar + } + return lastChar || '' + }).replace(/@media[^{]*\{\}/g, '') + + const inlineScripts: string[] = [] + for (const [_, i] of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) { + if (i && !i.includes('const t=document.createElement("link")')) { + inlineScripts.push(i) + } + } + + const props = genObjectFromRawEntries(Object.entries({ ...genericMessages, ...messages }).map(([key, value]) => [key, { + type: typeof value === 'string' ? 'String' : typeof value === 'number' ? 'Number' : typeof value === 'boolean' ? 'Boolean' : 'undefined', + default: JSON.stringify(value), + }])) + const vueCode = [ + '<script setup>', + title && 'import { useHead } from \'#imports\'', + `const props = defineProps(${props})`, + title && 'useHead(' + genObjectFromRawEntries([ + ['title', `\`${title}\``], + ['script', inlineScripts.map(s => ({ children: `\`${s}\`` }))], + ['style', [{ children: `\`${globalStyles}\`` }]], + ]) + ')', + '</script>', + '<template>', + templateContent, + '</template>', + '<style scoped>', + styleContent.replace(globalStyles, ''), + '</style>', + ].filter(Boolean).join('\n').trim() + + // Generate types + const types = [ + `export type DefaultMessages = Record<${Object.keys(messages).map(a => `"${a}"`).join(' | ') || 'string'}, string | boolean | number >`, + 'declare const template: (data: Partial<DefaultMessages>) => string', + 'export { template }', + ].join('\n') + + // Register exports + templateExports.push({ + exportName: camelCase(templateName), + templateName, + types, + }) + + // Write new template + writeFileSync(fileName.replace('/index.html', '.ts'), functionalCode) + writeFileSync(fileName.replace('/index.html', '.vue'), vueCode) + + // Remove original html file + unlinkSync(fileName) + rmdirSync(dirname(fileName)) + } + + // we manually copy files across rather than using symbolic links for better windows support + const nuxtRoot = r('../nuxt') + for (const file of ['error-404.vue', 'error-500.vue', 'error-dev.vue', 'welcome.vue']) { + await copyFile(r(`dist/templates/${file}`), join(nuxtRoot, 'src/app/components', file)) + } + for (const file of ['error-500.ts', 'error-dev.ts']) { + await copyFile(r(`dist/templates/${file}`), join(nuxtRoot, 'src/core/runtime/nitro', file)) + } + }, + } +} diff --git a/packages/ui-templates/package.json b/packages/ui-templates/package.json new file mode 100644 index 0000000000..1b26037c63 --- /dev/null +++ b/packages/ui-templates/package.json @@ -0,0 +1,35 @@ +{ + "name": "@nuxt/ui-templates", + "version": "1.3.3", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/nuxt/nuxt.git", + "directory": "packages/ui-templates" + }, + "license": "CC-BY-ND-4.0", + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "optimize-assets": "npx svgo public/assets/**/*.svg", + "postinstall": "pnpm build", + "prerender": "pnpm build && jiti ./lib/prerender", + "test": "pnpm lint && pnpm build" + }, + "devDependencies": { + "@unocss/reset": "0.63.4", + "critters": "0.0.24", + "html-validate": "8.24.1", + "htmlnano": "2.1.1", + "jiti": "2.3.3", + "knitwork": "1.1.0", + "pathe": "1.1.2", + "prettier": "3.3.3", + "scule": "1.3.0", + "tinyexec": "0.3.0", + "tinyglobby": "0.2.9", + "unocss": "0.63.4", + "vite": "5.4.8" + } +} diff --git a/packages/ui-templates/public/icons/MiniGem.svg b/packages/ui-templates/public/icons/MiniGem.svg new file mode 100644 index 0000000000..12fc2180af --- /dev/null +++ b/packages/ui-templates/public/icons/MiniGem.svg @@ -0,0 +1 @@ +<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m6.667 0 4.041 7H2.625l4.042-7zm0 14L2.625 7h8.083l-4.041 7z" opacity=".9" fill="#00DC82"/></svg> \ No newline at end of file diff --git a/packages/ui-templates/public/icons/book-open-solid 1.svg b/packages/ui-templates/public/icons/book-open-solid 1.svg new file mode 100644 index 0000000000..26ee7d73eb --- /dev/null +++ b/packages/ui-templates/public/icons/book-open-solid 1.svg @@ -0,0 +1 @@ +<svg width="150" height="150" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M163.796 9.64c-16.555.935-49.457 4.34-69.77 16.72-1.401.855-2.196 2.374-2.196 3.962v109.445c0 3.474 3.816 5.67 7.033 4.058 20.898-10.474 51.121-13.331 66.065-14.113 5.103-.268 9.069-4.34 9.069-9.222V18.874c.003-5.327-4.637-9.547-10.201-9.234zM79.971 26.36C59.66 13.98 26.758 10.58 10.204 9.64 4.64 9.327 0 13.547 0 18.874v101.619c0 4.885 3.966 8.957 9.069 9.222 14.95.782 45.188 3.642 66.086 14.122 3.208 1.609 7.012-.584 7.012-4.049V30.268c0-1.591-.792-3.05-2.197-3.908z" fill="#D1E2E2"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h150v150H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui-templates/public/icons/discord-brands 1.svg b/packages/ui-templates/public/icons/discord-brands 1.svg new file mode 100644 index 0000000000..232cf6fc8c --- /dev/null +++ b/packages/ui-templates/public/icons/discord-brands 1.svg @@ -0,0 +1 @@ +<svg width="80" height="80" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M63.69 51.775c0 3.325-2.47 6.05-5.596 6.05-3.072 0-5.596-2.726-5.596-6.05 0-3.325 2.469-6.05 5.596-6.05 3.127 0 5.595 2.725 5.595 6.05zm-25.62-6.05c-3.126 0-5.595 2.725-5.595 6.05 0 3.325 2.524 6.05 5.596 6.05 3.127 0 5.595-2.726 5.595-6.05.055-3.325-2.468-6.05-5.595-6.05zM96 11.227V109c-13.82-12.133-9.4-8.117-25.454-22.945l2.908 10.083H11.246C5.046 96.138 0 91.124 0 84.911V11.227C0 5.014 5.047 0 11.246 0h73.508C90.954 0 96 5.014 96 11.227zM80.366 62.893c0-17.549-7.9-31.774-7.9-31.774-7.9-5.886-15.415-5.722-15.415-5.722l-.768.872c9.326 2.834 13.66 6.922 13.66 6.922-13.031-7.096-28.338-7.097-40.978-1.581-2.03.926-3.237 1.58-3.237 1.58s4.553-4.305 14.427-7.139l-.548-.654s-7.516-.163-15.415 5.723c0 0-7.9 14.224-7.9 31.773 0 0 4.609 7.903 16.732 8.284 0 0 2.03-2.453 3.675-4.524-6.966-2.07-9.6-6.43-9.6-6.43.807.56 2.138 1.288 2.25 1.362 9.259 5.152 22.411 6.84 34.23 1.907 1.92-.708 4.06-1.743 6.309-3.215 0 0-2.743 4.469-9.93 6.486 1.647 2.07 3.621 4.414 3.621 4.414 12.124-.382 16.787-8.284 16.787-8.284z" fill="#D1E2E2"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h80v80H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui-templates/public/icons/documentation-color-light.svg b/packages/ui-templates/public/icons/documentation-color-light.svg new file mode 100644 index 0000000000..4d53e26b54 --- /dev/null +++ b/packages/ui-templates/public/icons/documentation-color-light.svg @@ -0,0 +1,71 @@ +<svg width="342" height="165" viewBox="0 0 342 165" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2687_3947)"> +<path d="M0.152832 131.851H154.28" stroke="#E4E4E7"/> +<path d="M215.399 107.359H349.153" stroke="#E4E4E7"/> +<path d="M0.152832 77.2178L116.191 77.2178" stroke="#E4E4E7"/> +<path d="M36.1528 106.921L152.191 106.921" stroke="#E4E4E7"/> +<path d="M202.153 42.9209L317.305 42.9209" stroke="#E4E4E7"/> +<path d="M218.153 76.9209L345.305 76.9209" stroke="#E4E4E7"/> +<path d="M285.947 8.45605V166.979" stroke="#E4E4E7"/> +<path d="M252.602 16.8311V107.36" stroke="#E4E4E7"/> +<path d="M171.153 16.9209V107.45" stroke="#E4E4E7"/> +<path d="M218.153 16.9209V43.4501" stroke="#E4E4E7"/> +<path d="M122.153 16.9211L327.45 16.9209" stroke="#E4E4E7"/> +<path d="M1.92432 43.3086H148.163" stroke="#E4E4E7"/> +<path d="M122.392 16.4209V55.3659" stroke="#E4E4E7"/> +<path d="M36.084 0.920898L36.084 176.921" stroke="#E4E4E7"/> +<path d="M75.4448 43.249V175.152" stroke="#E4E4E7"/> +<circle opacity="0.7" cx="75.4448" cy="77.2178" r="3.5" fill="#00DC82"/> +<circle opacity="0.7" cx="36.1528" cy="131.85" r="3.5" fill="#00DC82"/> +<circle opacity="0.7" cx="285.947" cy="42.9209" r="3.5" fill="#00DC82"/> +<circle opacity="0.7" cx="252.602" cy="107.359" r="3.5" fill="#00DC82"/> +<g filter="url(#filter0_d_2687_3947)"> +<path d="M122.846 50.7109L163.067 26.0929C166.656 23.9507 171.117 23.8611 174.77 25.8579L217.894 49.0819C221.524 51.0665 223.807 54.8133 223.892 58.9246L224.15 104.352C224.235 108.448 222.13 112.287 218.609 114.46L177.783 139.658C174.174 141.886 169.638 142.011 165.931 139.984L123.774 116.935C120.045 114.896 117.125 111.001 117.153 106.776L117.153 60.5974C117.18 56.5529 119.338 52.8048 122.846 50.7109Z" fill="white"/> +<path d="M222.151 104.393C222.22 107.764 220.487 110.944 217.571 112.75C217.567 112.753 217.563 112.755 217.559 112.758L176.733 137.956C173.748 139.798 169.96 139.907 166.89 138.229L124.733 115.18C121.469 113.395 119.131 110.069 119.153 106.79L119.153 106.776L119.153 60.6107C119.153 60.6086 119.153 60.6065 119.153 60.6044C119.178 57.2703 120.958 54.1669 123.871 52.4282L123.881 52.4225L123.89 52.4167L164.101 27.8047C164.101 27.8047 164.101 27.8047 164.101 27.8047C164.106 27.8022 164.11 27.7997 164.114 27.7972C167.078 26.0385 170.793 25.9632 173.81 27.6128L173.81 27.6128L173.821 27.6188L216.934 50.8367C216.936 50.8377 216.938 50.8387 216.94 50.8397C219.935 52.4801 221.817 55.5878 221.892 58.9515L222.15 104.363L222.15 104.378L222.151 104.393Z" stroke="url(#paint0_linear_2687_3947)" stroke-width="4"/> +</g> +<path d="M192.349 96.9158L190.63 90.5186L183.778 64.9088C183.55 64.0605 182.994 63.3375 182.233 62.8988C181.472 62.4601 180.568 62.3416 179.72 62.5693L173.323 64.2877L173.116 64.3498C172.807 63.945 172.409 63.6168 171.953 63.3906C171.497 63.1644 170.995 63.0463 170.486 63.0455H163.861C163.279 63.0471 162.707 63.2043 162.205 63.501C161.703 63.2043 161.132 63.0471 160.549 63.0455H153.924C153.045 63.0455 152.203 63.3945 151.582 64.0157C150.96 64.6369 150.611 65.4795 150.611 66.358V99.483C150.611 100.362 150.96 101.204 151.582 101.825C152.203 102.447 153.045 102.796 153.924 102.796H160.549C161.132 102.794 161.703 102.637 162.205 102.34C162.707 102.637 163.279 102.794 163.861 102.796H170.486C171.365 102.796 172.207 102.447 172.829 101.825C173.45 101.204 173.799 100.362 173.799 99.483V78.8627L177.836 93.9346L179.554 100.332C179.742 101.039 180.158 101.665 180.739 102.11C181.32 102.556 182.031 102.797 182.763 102.796C183.049 102.791 183.334 102.756 183.612 102.692L190.009 100.974C190.43 100.861 190.824 100.665 191.169 100.399C191.514 100.132 191.802 99.7997 192.018 99.4209C192.238 99.047 192.381 98.6325 192.438 98.2021C192.495 97.7717 192.465 97.3342 192.349 96.9158V96.9158ZM176.325 75.4881L182.722 73.7697L187.007 89.7732L180.61 91.4916L176.325 75.4881ZM180.569 65.7783L181.873 70.5607L175.476 72.2791L174.171 67.4967L180.569 65.7783ZM170.486 66.358V91.2018H163.861V66.358H170.486ZM160.549 66.358V71.3268H153.924V66.358H160.549ZM153.924 99.483V74.6393H160.549V99.483H153.924ZM170.486 99.483H163.861V94.5143H170.486V99.483ZM189.161 97.7646L182.763 99.483L181.459 94.6799L187.877 92.9615L189.161 97.7646V97.7646Z" fill="url(#paint1_linear_2687_3947)"/> +<rect x="2.15283" y="-3.0791" width="327" height="23" fill="url(#paint2_linear_2687_3947)"/> +<rect width="327" height="25" transform="matrix(1 0 0 -1 2.15283 166.921)" fill="url(#paint3_linear_2687_3947)"/> +<rect width="327" height="25" transform="matrix(0 1 1 0 0.152832 -17.0791)" fill="url(#paint4_linear_2687_3947)"/> +<rect x="342.153" y="-17.0791" width="327" height="25" transform="rotate(90 342.153 -17.0791)" fill="url(#paint5_linear_2687_3947)"/> +</g> +<defs> +<filter id="filter0_d_2687_3947" x="86.1528" y="-6.5791" width="169" height="179" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="15.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2687_3947"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2687_3947" result="shape"/> +</filter> +<linearGradient id="paint0_linear_2687_3947" x1="118.202" y1="60.3042" x2="223.159" y2="113.509" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00DC82"/> +<stop offset="1" stop-color="#003F25"/> +</linearGradient> +<linearGradient id="paint1_linear_2687_3947" x1="150.495" y1="71.0767" x2="191.769" y2="94.1139" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00DC82"/> +<stop offset="1" stop-color="#003F25"/> +</linearGradient> +<linearGradient id="paint2_linear_2687_3947" x1="165.653" y1="-3.0791" x2="166.153" y2="19.9209" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint3_linear_2687_3947" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint4_linear_2687_3947" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint5_linear_2687_3947" x1="505.653" y1="-17.0791" x2="506.244" y2="7.91876" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_2687_3947"> +<rect width="341" height="164" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/documentation-color.svg b/packages/ui-templates/public/icons/documentation-color.svg new file mode 100644 index 0000000000..9f35f39cae --- /dev/null +++ b/packages/ui-templates/public/icons/documentation-color.svg @@ -0,0 +1,71 @@ +<svg width="342" height="165" viewBox="0 0 342 165" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2595_7273)"> +<path d="M0.152832 131.851H154.28" stroke="#27272A"/> +<path d="M215.399 107.359H349.153" stroke="#27272A"/> +<path d="M0.152832 77.2178L116.191 77.2178" stroke="#27272A"/> +<path d="M36.1528 106.921L152.191 106.921" stroke="#27272A"/> +<path d="M202.153 42.9209L317.305 42.9209" stroke="#27272A"/> +<path d="M218.153 76.9209L345.305 76.9209" stroke="#27272A"/> +<path d="M285.947 8.45605V166.979" stroke="#27272A"/> +<path d="M252.602 16.8311V107.36" stroke="#27272A"/> +<path d="M171.153 16.9209V107.45" stroke="#27272A"/> +<path d="M218.153 16.9209V43.4501" stroke="#27272A"/> +<path d="M122.153 16.9211L327.45 16.9209" stroke="#27272A"/> +<path d="M1.92432 43.3086H148.163" stroke="#27272A"/> +<path d="M122.392 16.4209V55.3659" stroke="#27272A"/> +<path d="M36.084 0.920898L36.084 176.921" stroke="#27272A"/> +<path d="M75.4448 43.249V175.152" stroke="#27272A"/> +<circle opacity="0.14" cx="75.4448" cy="77.2178" r="3.5" fill="#00DC82"/> +<circle opacity="0.14" cx="36.1528" cy="131.85" r="3.5" fill="#00DC82"/> +<circle opacity="0.14" cx="285.947" cy="42.9209" r="3.5" fill="#00DC82"/> +<circle opacity="0.14" cx="252.602" cy="107.359" r="3.5" fill="#00DC82"/> +<g filter="url(#filter0_d_2595_7273)"> +<path d="M122.846 50.7109L163.067 26.0929C166.656 23.9507 171.117 23.8611 174.77 25.8579L217.894 49.0819C221.524 51.0665 223.807 54.8133 223.892 58.9246L224.15 104.352C224.235 108.448 222.13 112.287 218.609 114.46L177.783 139.658C174.174 141.886 169.638 142.011 165.931 139.984L123.774 116.935C120.045 114.896 117.125 111.001 117.153 106.776L117.153 60.5974C117.18 56.5529 119.338 52.8048 122.846 50.7109Z" fill="#18181B"/> +<path d="M123.871 52.4282L123.881 52.4225L123.89 52.4167L164.101 27.8047C167.083 26.0291 170.786 25.9592 173.81 27.6128L173.81 27.6128L173.821 27.6188L216.934 50.8367C216.936 50.8376 216.938 50.8386 216.939 50.8395C219.938 52.4814 221.817 55.5694 221.892 58.9515L222.15 104.363L222.15 104.378L222.151 104.393C222.221 107.772 220.485 110.952 217.559 112.758L176.733 137.956C173.732 139.808 169.963 139.909 166.89 138.229L124.733 115.18C121.465 113.393 119.131 110.089 119.153 106.79L119.153 106.776L119.153 60.6107C119.153 60.6086 119.153 60.6065 119.153 60.6044C119.178 57.2703 120.958 54.1669 123.871 52.4282Z" stroke="url(#paint0_linear_2595_7273)" stroke-width="4"/> +</g> +<path d="M192.349 96.9158L190.63 90.5186L183.778 64.9088C183.55 64.0605 182.994 63.3375 182.233 62.8988C181.472 62.4601 180.568 62.3416 179.72 62.5693L173.323 64.2877L173.116 64.3498C172.807 63.945 172.409 63.6168 171.953 63.3906C171.497 63.1644 170.995 63.0463 170.486 63.0455H163.861C163.279 63.0471 162.707 63.2043 162.205 63.501C161.703 63.2043 161.132 63.0471 160.549 63.0455H153.924C153.045 63.0455 152.203 63.3945 151.582 64.0157C150.96 64.6369 150.611 65.4795 150.611 66.358V99.483C150.611 100.362 150.96 101.204 151.582 101.825C152.203 102.447 153.045 102.796 153.924 102.796H160.549C161.132 102.794 161.703 102.637 162.205 102.34C162.707 102.637 163.279 102.794 163.861 102.796H170.486C171.365 102.796 172.207 102.447 172.829 101.825C173.45 101.204 173.799 100.362 173.799 99.483V78.8627L177.836 93.9346L179.554 100.332C179.742 101.039 180.158 101.665 180.739 102.11C181.32 102.556 182.031 102.797 182.763 102.796C183.049 102.791 183.334 102.756 183.612 102.692L190.009 100.974C190.43 100.861 190.824 100.665 191.169 100.399C191.514 100.132 191.802 99.7998 192.018 99.4209C192.238 99.047 192.381 98.6325 192.438 98.2021C192.495 97.7717 192.465 97.3342 192.349 96.9158ZM176.325 75.4881L182.722 73.7697L187.007 89.7732L180.61 91.4916L176.325 75.4881ZM180.569 65.7783L181.873 70.5607L175.476 72.2791L174.171 67.4967L180.569 65.7783ZM170.486 66.358V91.2018H163.861V66.358H170.486ZM160.549 66.358V71.3268H153.924V66.358H160.549ZM153.924 99.483V74.6393H160.549V99.483H153.924ZM170.486 99.483H163.861V94.5143H170.486V99.483ZM189.161 97.7646L182.763 99.483L181.459 94.6799L187.877 92.9615L189.161 97.7646Z" fill="url(#paint1_linear_2595_7273)"/> +<rect x="2.15283" y="-3.0791" width="327" height="23" fill="url(#paint2_linear_2595_7273)"/> +<rect width="327" height="25" transform="matrix(1 0 0 -1 2.15283 166.921)" fill="url(#paint3_linear_2595_7273)"/> +<rect width="327" height="25" transform="matrix(0 1 1 0 0.152832 -17.0791)" fill="url(#paint4_linear_2595_7273)"/> +<rect x="342.153" y="-17.0791" width="327" height="25" transform="rotate(90 342.153 -17.0791)" fill="url(#paint5_linear_2595_7273)"/> +</g> +<defs> +<filter id="filter0_d_2595_7273" x="86.1528" y="-6.5791" width="169" height="179" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="15.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2595_7273"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2595_7273" result="shape"/> +</filter> +<linearGradient id="paint0_linear_2595_7273" x1="118.202" y1="60.3042" x2="223.159" y2="113.509" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00DC82"/> +<stop offset="1" stop-color="#003F25"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7273" x1="150.495" y1="71.0767" x2="191.769" y2="94.1139" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00DC82"/> +<stop offset="1" stop-color="#003F25"/> +</linearGradient> +<linearGradient id="paint2_linear_2595_7273" x1="165.653" y1="-3.0791" x2="166.153" y2="19.9209" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint3_linear_2595_7273" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint4_linear_2595_7273" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint5_linear_2595_7273" x1="505.653" y1="-17.0791" x2="506.244" y2="7.91876" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_2595_7273"> +<rect width="341" height="164" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/documentation-light.svg b/packages/ui-templates/public/icons/documentation-light.svg new file mode 100644 index 0000000000..43ba152c71 --- /dev/null +++ b/packages/ui-templates/public/icons/documentation-light.svg @@ -0,0 +1,71 @@ +<svg width="342" height="165" viewBox="0 0 342 165" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2687_3977)"> +<path d="M0.152832 131.851H154.28" stroke="#E4E4E7"/> +<path d="M215.399 107.359H349.153" stroke="#E4E4E7"/> +<path d="M0.152832 77.2178L116.191 77.2178" stroke="#E4E4E7"/> +<path d="M36.1528 106.921L152.191 106.921" stroke="#E4E4E7"/> +<path d="M202.153 42.9209L317.305 42.9209" stroke="#E4E4E7"/> +<path d="M218.153 76.9209L345.305 76.9209" stroke="#E4E4E7"/> +<path d="M285.947 8.45605V166.979" stroke="#E4E4E7"/> +<path d="M252.602 16.8311V107.36" stroke="#E4E4E7"/> +<path d="M171.153 16.9209V107.45" stroke="#E4E4E7"/> +<path d="M218.153 16.9209V43.4501" stroke="#E4E4E7"/> +<path d="M122.153 16.9211L327.45 16.9209" stroke="#E4E4E7"/> +<path d="M1.92432 43.3086H148.163" stroke="#E4E4E7"/> +<path d="M122.392 16.4209V55.3659" stroke="#E4E4E7"/> +<path d="M36.084 0.920898L36.084 176.921" stroke="#E4E4E7"/> +<path d="M75.4448 43.249V175.152" stroke="#E4E4E7"/> +<circle opacity="0.7" cx="75.4448" cy="77.2178" r="3.5" fill="#A1A1AA"/> +<circle opacity="0.7" cx="36.1528" cy="131.85" r="3.5" fill="#A1A1AA"/> +<circle opacity="0.7" cx="285.947" cy="42.9209" r="3.5" fill="#A1A1AA"/> +<circle opacity="0.7" cx="252.602" cy="107.359" r="3.5" fill="#A1A1AA"/> +<g filter="url(#filter0_d_2687_3977)"> +<path d="M122.846 50.7109L163.067 26.0929C166.656 23.9507 171.117 23.8611 174.77 25.8579L217.894 49.0819C221.524 51.0665 223.807 54.8133 223.892 58.9246L224.15 104.352C224.235 108.448 222.13 112.287 218.609 114.46L177.783 139.658C174.174 141.886 169.638 142.011 165.931 139.984L123.774 116.935C120.045 114.896 117.125 111.001 117.153 106.776L117.153 60.5974C117.18 56.5529 119.338 52.8048 122.846 50.7109Z" fill="white"/> +<path d="M222.151 104.393C222.22 107.764 220.487 110.944 217.571 112.75C217.567 112.753 217.563 112.755 217.559 112.758L176.733 137.956C173.748 139.798 169.96 139.907 166.89 138.229L124.733 115.18C121.469 113.395 119.131 110.069 119.153 106.79L119.153 106.776L119.153 60.6107C119.153 60.6086 119.153 60.6065 119.153 60.6044C119.178 57.2703 120.958 54.1669 123.871 52.4282L123.881 52.4225L123.89 52.4167L164.101 27.8047C164.101 27.8047 164.101 27.8047 164.101 27.8047C164.106 27.8022 164.11 27.7997 164.114 27.7972C167.078 26.0385 170.793 25.9632 173.81 27.6128L173.81 27.6128L173.821 27.6188L216.934 50.8367C216.936 50.8377 216.938 50.8387 216.94 50.8397C219.935 52.4801 221.817 55.5878 221.892 58.9515L222.15 104.363L222.15 104.378L222.151 104.393Z" stroke="url(#paint0_linear_2687_3977)" stroke-width="4"/> +</g> +<path d="M192.349 96.9158L190.63 90.5186L183.778 64.9088C183.55 64.0605 182.994 63.3375 182.233 62.8988C181.472 62.4601 180.568 62.3416 179.72 62.5693L173.323 64.2877L173.116 64.3498C172.807 63.945 172.409 63.6168 171.953 63.3906C171.497 63.1644 170.995 63.0463 170.486 63.0455H163.861C163.279 63.0471 162.707 63.2043 162.205 63.501C161.703 63.2043 161.132 63.0471 160.549 63.0455H153.924C153.045 63.0455 152.203 63.3945 151.582 64.0157C150.96 64.6369 150.611 65.4795 150.611 66.358V99.483C150.611 100.362 150.96 101.204 151.582 101.825C152.203 102.447 153.045 102.796 153.924 102.796H160.549C161.132 102.794 161.703 102.637 162.205 102.34C162.707 102.637 163.279 102.794 163.861 102.796H170.486C171.365 102.796 172.207 102.447 172.829 101.825C173.45 101.204 173.799 100.362 173.799 99.483V78.8627L177.836 93.9346L179.554 100.332C179.742 101.039 180.158 101.665 180.739 102.11C181.32 102.556 182.031 102.797 182.763 102.796C183.049 102.791 183.334 102.756 183.612 102.692L190.009 100.974C190.43 100.861 190.824 100.665 191.169 100.399C191.514 100.132 191.802 99.7997 192.018 99.4209C192.238 99.047 192.381 98.6325 192.438 98.2021C192.495 97.7717 192.465 97.3342 192.349 96.9158V96.9158ZM176.325 75.4881L182.722 73.7697L187.007 89.7732L180.61 91.4916L176.325 75.4881ZM180.569 65.7783L181.873 70.5607L175.476 72.2791L174.171 67.4967L180.569 65.7783ZM170.486 66.358V91.2018H163.861V66.358H170.486ZM160.549 66.358V71.3268H153.924V66.358H160.549ZM153.924 99.483V74.6393H160.549V99.483H153.924ZM170.486 99.483H163.861V94.5143H170.486V99.483ZM189.161 97.7646L182.763 99.483L181.459 94.6799L187.877 92.9615L189.161 97.7646V97.7646Z" fill="url(#paint1_linear_2687_3977)"/> +<rect x="2.15283" y="-3.0791" width="327" height="23" fill="url(#paint2_linear_2687_3977)"/> +<rect width="327" height="25" transform="matrix(1 0 0 -1 2.15283 166.921)" fill="url(#paint3_linear_2687_3977)"/> +<rect width="327" height="25" transform="matrix(0 1 1 0 0.152832 -17.0791)" fill="url(#paint4_linear_2687_3977)"/> +<rect x="342.153" y="-17.0791" width="327" height="25" transform="rotate(90 342.153 -17.0791)" fill="url(#paint5_linear_2687_3977)"/> +</g> +<defs> +<filter id="filter0_d_2687_3977" x="86.1528" y="-6.5791" width="169" height="179" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="15.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.831373 0 0 0 0 0.831373 0 0 0 0 0.847059 0 0 0 0.07 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2687_3977"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2687_3977" result="shape"/> +</filter> +<linearGradient id="paint0_linear_2687_3977" x1="118.202" y1="60.3042" x2="223.159" y2="113.509" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#3F3F46"/> +</linearGradient> +<linearGradient id="paint1_linear_2687_3977" x1="150.495" y1="71.0767" x2="191.769" y2="94.1139" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#3F3F46"/> +</linearGradient> +<linearGradient id="paint2_linear_2687_3977" x1="165.653" y1="-3.0791" x2="166.153" y2="19.9209" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint3_linear_2687_3977" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint4_linear_2687_3977" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint5_linear_2687_3977" x1="505.653" y1="-17.0791" x2="506.244" y2="7.91876" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_2687_3977"> +<rect width="341" height="164" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/documentation.svg b/packages/ui-templates/public/icons/documentation.svg new file mode 100644 index 0000000000..e30b52137d --- /dev/null +++ b/packages/ui-templates/public/icons/documentation.svg @@ -0,0 +1,71 @@ +<svg width="342" height="165" viewBox="0 0 342 165" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2595_7193)"> +<path d="M0.152832 131.851H154.28" stroke="#27272A"/> +<path d="M215.399 107.359H349.153" stroke="#27272A"/> +<path d="M0.152832 77.2178L116.191 77.2178" stroke="#27272A"/> +<path d="M36.1528 106.921L152.191 106.921" stroke="#27272A"/> +<path d="M202.153 42.9209L317.305 42.9209" stroke="#27272A"/> +<path d="M218.153 76.9209L345.305 76.9209" stroke="#27272A"/> +<path d="M285.947 8.45605V166.979" stroke="#27272A"/> +<path d="M252.602 16.8311V107.36" stroke="#27272A"/> +<path d="M171.153 16.9209V107.45" stroke="#27272A"/> +<path d="M218.153 16.9209V43.4501" stroke="#27272A"/> +<path d="M122.153 16.9211L327.45 16.9209" stroke="#27272A"/> +<path d="M1.92432 43.3086H148.163" stroke="#27272A"/> +<path d="M122.392 16.4209V55.3659" stroke="#27272A"/> +<path d="M36.084 0.920898L36.084 176.921" stroke="#27272A"/> +<path d="M75.4448 43.249V175.152" stroke="#27272A"/> +<circle opacity="0.14" cx="75.4448" cy="77.2178" r="3.5" fill="white"/> +<circle opacity="0.14" cx="36.1528" cy="131.85" r="3.5" fill="white"/> +<circle opacity="0.14" cx="285.947" cy="42.9209" r="3.5" fill="white"/> +<circle opacity="0.14" cx="252.602" cy="107.359" r="3.5" fill="white"/> +<g filter="url(#filter0_d_2595_7193)"> +<path d="M122.846 50.7109L163.067 26.0929C166.656 23.9507 171.117 23.8611 174.77 25.8579L217.894 49.0819C221.524 51.0665 223.807 54.8133 223.892 58.9246L224.15 104.352C224.235 108.448 222.13 112.287 218.609 114.46L177.783 139.658C174.174 141.886 169.638 142.011 165.931 139.984L123.774 116.935C120.045 114.896 117.125 111.001 117.153 106.776L117.153 60.5974C117.18 56.5529 119.338 52.8048 122.846 50.7109Z" fill="#18181B"/> +<path d="M123.871 52.4282L123.881 52.4225L123.89 52.4167L164.101 27.8047C167.083 26.0291 170.786 25.9592 173.81 27.6128L173.81 27.6128L173.821 27.6188L216.934 50.8367C216.936 50.8376 216.938 50.8386 216.939 50.8395C219.938 52.4814 221.817 55.5694 221.892 58.9515L222.15 104.363L222.15 104.378L222.151 104.393C222.221 107.772 220.485 110.952 217.559 112.758L176.733 137.956C173.732 139.808 169.963 139.909 166.89 138.229L124.733 115.18C121.465 113.393 119.131 110.089 119.153 106.79L119.153 106.776L119.153 60.6107C119.153 60.6086 119.153 60.6065 119.153 60.6044C119.178 57.2703 120.958 54.1669 123.871 52.4282Z" stroke="url(#paint0_linear_2595_7193)" stroke-width="4"/> +</g> +<path d="M192.349 96.9158L190.63 90.5186L183.778 64.9088C183.55 64.0605 182.994 63.3375 182.233 62.8988C181.472 62.4601 180.568 62.3416 179.72 62.5693L173.323 64.2877L173.116 64.3498C172.807 63.945 172.409 63.6168 171.953 63.3906C171.497 63.1644 170.995 63.0463 170.486 63.0455H163.861C163.279 63.0471 162.707 63.2043 162.205 63.501C161.703 63.2043 161.132 63.0471 160.549 63.0455H153.924C153.045 63.0455 152.203 63.3945 151.582 64.0157C150.96 64.6369 150.611 65.4795 150.611 66.358V99.483C150.611 100.362 150.96 101.204 151.582 101.825C152.203 102.447 153.045 102.796 153.924 102.796H160.549C161.132 102.794 161.703 102.637 162.205 102.34C162.707 102.637 163.279 102.794 163.861 102.796H170.486C171.365 102.796 172.207 102.447 172.829 101.825C173.45 101.204 173.799 100.362 173.799 99.483V78.8627L177.836 93.9346L179.554 100.332C179.742 101.039 180.158 101.665 180.739 102.11C181.32 102.556 182.031 102.797 182.763 102.796C183.049 102.791 183.334 102.756 183.612 102.692L190.009 100.974C190.43 100.861 190.824 100.665 191.169 100.399C191.514 100.132 191.802 99.7998 192.018 99.4209C192.238 99.047 192.381 98.6325 192.438 98.2021C192.495 97.7717 192.465 97.3342 192.349 96.9158ZM176.325 75.4881L182.722 73.7697L187.007 89.7732L180.61 91.4916L176.325 75.4881ZM180.569 65.7783L181.873 70.5607L175.476 72.2791L174.171 67.4967L180.569 65.7783ZM170.486 66.358V91.2018H163.861V66.358H170.486ZM160.549 66.358V71.3268H153.924V66.358H160.549ZM153.924 99.483V74.6393H160.549V99.483H153.924ZM170.486 99.483H163.861V94.5143H170.486V99.483ZM189.161 97.7646L182.763 99.483L181.459 94.6799L187.877 92.9615L189.161 97.7646Z" fill="url(#paint1_linear_2595_7193)"/> +<rect x="2.15283" y="-3.0791" width="327" height="23" fill="url(#paint2_linear_2595_7193)"/> +<rect width="327" height="25" transform="matrix(1 0 0 -1 2.15283 166.921)" fill="url(#paint3_linear_2595_7193)"/> +<rect width="327" height="25" transform="matrix(0 1 1 0 0.152832 -17.0791)" fill="url(#paint4_linear_2595_7193)"/> +<rect x="342.153" y="-17.0791" width="327" height="25" transform="rotate(90 342.153 -17.0791)" fill="url(#paint5_linear_2595_7193)"/> +</g> +<defs> +<filter id="filter0_d_2595_7193" x="86.1528" y="-6.5791" width="169" height="179" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="15.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2595_7193"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_2595_7193" result="shape"/> +</filter> +<linearGradient id="paint0_linear_2595_7193" x1="118.202" y1="60.3042" x2="223.159" y2="113.509" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7193" x1="150.495" y1="71.0767" x2="191.769" y2="94.1139" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint2_linear_2595_7193" x1="165.653" y1="-3.0791" x2="166.153" y2="19.9209" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint3_linear_2595_7193" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint4_linear_2595_7193" x1="163.5" y1="-2.30278e-07" x2="164.091" y2="24.9979" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint5_linear_2595_7193" x1="505.653" y1="-17.0791" x2="506.244" y2="7.91876" gradientUnits="userSpaceOnUse"> +<stop stop-color="#18181B"/> +<stop offset="1" stop-color="#18181B" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_2595_7193"> +<rect width="341" height="164" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/examples-color-light.svg b/packages/ui-templates/public/icons/examples-color-light.svg new file mode 100644 index 0000000000..56bf71461f --- /dev/null +++ b/packages/ui-templates/public/icons/examples-color-light.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M49.1971 43.7595C49.1113 43.8209 49.0231 43.8796 48.9325 43.9357L29.0918 56.2117C27.6504 57.1035 25.8212 57.1564 24.3387 56.3439L3.85107 45.1148C2.27157 44.2491 1.14238 42.6366 1.15291 41.0494L1.15293 41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4169 1.73583 26.2139 1.69824 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8136C50.0797 14.6078 50.9898 16.1132 51.026 17.7438L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1834 41.4138 50.4491 42.8635 49.1971 43.7595Z" fill="white" stroke="url(#paint0_linear_2613_3941)" stroke-width="2"/> +<path d="M37.1528 17.9209H15.1528C14.6224 17.9209 14.1137 18.1316 13.7386 18.5067C13.3635 18.8818 13.1528 19.3905 13.1528 19.9209V37.9209C13.1528 38.4513 13.3635 38.96 13.7386 39.3351C14.1137 39.7102 14.6224 39.9209 15.1528 39.9209H37.1528C37.6833 39.9209 38.192 39.7102 38.567 39.3351C38.9421 38.96 39.1528 38.4513 39.1528 37.9209V19.9209C39.1528 19.3905 38.9421 18.8818 38.567 18.5067C38.192 18.1316 37.6833 17.9209 37.1528 17.9209V17.9209ZM15.1528 19.9209H37.1528V24.9209H15.1528V19.9209ZM15.1528 26.9209H22.1528V37.9209H15.1528V26.9209ZM37.1528 37.9209H24.1528V26.9209H37.1528V37.9209Z" fill="url(#paint1_linear_2613_3941)"/> +<defs> +<linearGradient id="paint0_linear_2613_3941" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8DEAFF"/> +<stop offset="1" stop-color="#008AA9"/> +</linearGradient> +<linearGradient id="paint1_linear_2613_3941" x1="13.0804" y1="22.6224" x2="37.028" y2="37.847" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8DEAFF"/> +<stop offset="1" stop-color="#008AA9"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/examples-color.svg b/packages/ui-templates/public/icons/examples-color.svg new file mode 100644 index 0000000000..205303bc3b --- /dev/null +++ b/packages/ui-templates/public/icons/examples-color.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4246 1.73116 26.2124 1.69742 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8137C50.0812 14.6086 50.9896 16.1043 51.026 17.7437L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1856 41.5203 50.346 43.0611 48.9325 43.9357L29.0918 56.2117C27.6424 57.1085 25.8227 57.1572 24.3387 56.3439L3.85107 45.1148C2.26984 44.2481 1.14232 42.646 1.15293 41.0494V41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869Z" fill="#18181B" stroke="url(#paint0_linear_2595_7426)" stroke-width="2"/> +<path d="M37.1528 17.9209H15.1528C14.6224 17.9209 14.1137 18.1316 13.7386 18.5067C13.3635 18.8818 13.1528 19.3905 13.1528 19.9209V37.9209C13.1528 38.4513 13.3635 38.96 13.7386 39.3351C14.1137 39.7102 14.6224 39.9209 15.1528 39.9209H37.1528C37.6833 39.9209 38.192 39.7102 38.567 39.3351C38.9421 38.96 39.1528 38.4513 39.1528 37.9209V19.9209C39.1528 19.3905 38.9421 18.8818 38.567 18.5067C38.192 18.1316 37.6833 17.9209 37.1528 17.9209ZM15.1528 19.9209H37.1528V24.9209H15.1528V19.9209ZM15.1528 26.9209H22.1528V37.9209H15.1528V26.9209ZM37.1528 37.9209H24.1528V26.9209H37.1528V37.9209Z" fill="url(#paint1_linear_2595_7426)"/> +<defs> +<linearGradient id="paint0_linear_2595_7426" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8DEAFF"/> +<stop offset="1" stop-color="#008AA9"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7426" x1="13.0804" y1="22.6224" x2="37.028" y2="37.847" gradientUnits="userSpaceOnUse"> +<stop stop-color="#8DEAFF"/> +<stop offset="1" stop-color="#008AA9"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/examples-light.svg b/packages/ui-templates/public/icons/examples-light.svg new file mode 100644 index 0000000000..8fd136728f --- /dev/null +++ b/packages/ui-templates/public/icons/examples-light.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M49.1971 43.7595C49.1113 43.8209 49.0231 43.8796 48.9325 43.9357L29.0918 56.2117C27.6504 57.1035 25.8212 57.1564 24.3387 56.3439L3.85107 45.1148C2.27157 44.2491 1.14238 42.6366 1.15291 41.0494L1.15293 41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4169 1.73583 26.2139 1.69824 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8136C50.0797 14.6078 50.9898 16.1132 51.026 17.7438L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1834 41.4138 50.4491 42.8635 49.1971 43.7595Z" fill="white" stroke="url(#paint0_linear_2691_4397)" stroke-width="2"/> +<path d="M37.1528 17.9209H15.1528C14.6224 17.9209 14.1137 18.1316 13.7386 18.5067C13.3635 18.8818 13.1528 19.3905 13.1528 19.9209V37.9209C13.1528 38.4513 13.3635 38.96 13.7386 39.3351C14.1137 39.7102 14.6224 39.9209 15.1528 39.9209H37.1528C37.6833 39.9209 38.192 39.7102 38.567 39.3351C38.9421 38.96 39.1528 38.4513 39.1528 37.9209V19.9209C39.1528 19.3905 38.9421 18.8818 38.567 18.5067C38.192 18.1316 37.6833 17.9209 37.1528 17.9209V17.9209ZM15.1528 19.9209H37.1528V24.9209H15.1528V19.9209ZM15.1528 26.9209H22.1528V37.9209H15.1528V26.9209ZM37.1528 37.9209H24.1528V26.9209H37.1528V37.9209Z" fill="url(#paint1_linear_2691_4397)"/> +<defs> +<linearGradient id="paint0_linear_2691_4397" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint1_linear_2691_4397" x1="13.0804" y1="22.6224" x2="37.028" y2="37.847" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/examples.svg b/packages/ui-templates/public/icons/examples.svg new file mode 100644 index 0000000000..4ddf859463 --- /dev/null +++ b/packages/ui-templates/public/icons/examples.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4246 1.73116 26.2124 1.69742 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8137C50.0812 14.6086 50.9896 16.1043 51.026 17.7437L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1856 41.5203 50.346 43.0611 48.9325 43.9357L29.0918 56.2117C27.6424 57.1085 25.8227 57.1572 24.3387 56.3439L3.85107 45.1148C2.26984 44.2481 1.14232 42.646 1.15293 41.0494V41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869Z" fill="#18181B" stroke="url(#paint0_linear_2595_7182)" stroke-width="2"/> +<path d="M37.1528 17.9209H15.1528C14.6224 17.9209 14.1137 18.1316 13.7386 18.5067C13.3635 18.8818 13.1528 19.3905 13.1528 19.9209V37.9209C13.1528 38.4513 13.3635 38.96 13.7386 39.3351C14.1137 39.7102 14.6224 39.9209 15.1528 39.9209H37.1528C37.6833 39.9209 38.192 39.7102 38.567 39.3351C38.9421 38.96 39.1528 38.4513 39.1528 37.9209V19.9209C39.1528 19.3905 38.9421 18.8818 38.567 18.5067C38.192 18.1316 37.6833 17.9209 37.1528 17.9209ZM15.1528 19.9209H37.1528V24.9209H15.1528V19.9209ZM15.1528 26.9209H22.1528V37.9209H15.1528V26.9209ZM37.1528 37.9209H24.1528V26.9209H37.1528V37.9209Z" fill="url(#paint1_linear_2595_7182)"/> +<defs> +<linearGradient id="paint0_linear_2595_7182" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7182" x1="13.0804" y1="22.6224" x2="37.028" y2="37.847" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/get-started-light.svg b/packages/ui-templates/public/icons/get-started-light.svg new file mode 100644 index 0000000000..0e659c7961 --- /dev/null +++ b/packages/ui-templates/public/icons/get-started-light.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="105" height="116" fill="none"><g filter="url(#a)"><path fill="#fff" d="M17.203 33.223 46.9 14.286a8.416 8.416 0 0 1 8.64-.18L87.38 31.97c2.68 1.527 4.365 4.409 4.428 7.571l.191 34.944c.063 3.151-1.491 6.104-4.091 7.776l-30.143 19.383a8.417 8.417 0 0 1-8.75.251l-31.126-17.73C15.135 82.595 12.98 79.6 13 76.35V40.828c.02-3.111 1.614-5.994 4.203-7.605Z"/><path stroke="url(#b)" stroke-width="2" d="M46.9 14.286 17.202 33.223c-2.59 1.61-4.183 4.494-4.203 7.605V76.35m33.9-62.064a8.416 8.416 0 0 1 8.64-.18m-8.64.18a8.435 8.435 0 0 1 8.64-.18M13 76.35c-.02 3.25 2.135 6.246 4.888 7.814M13 76.35c-.02 3.233 2.136 6.247 4.888 7.814m0 0 31.126 17.731m0 0a8.417 8.417 0 0 0 8.75-.251m-8.75.251a8.438 8.438 0 0 0 8.75-.251m0 0 30.143-19.383m0 0c2.598-1.67 4.154-4.627 4.091-7.776m-4.091 7.776c2.6-1.672 4.154-4.625 4.091-7.776m0 0-.19-34.944m0 0c-.064-3.162-1.75-6.044-4.43-7.571m4.43 7.571c-.063-3.147-1.75-6.045-4.43-7.571m0 0L55.54 14.105"/></g><path fill="#fff" d="M32 37h42v42H32z"/><path fill="url(#c)" d="M48.669 67.697c-.886 2.69-3.02 4.659-6.153 5.709-1.41.465-2.88.72-4.364.755a1.313 1.313 0 0 1-1.312-1.313c.035-1.484.29-2.954.754-4.364 1.05-3.134 3.02-5.266 5.71-6.152a1.314 1.314 0 1 1 .836 2.477c-3.232 1.083-4.232 4.577-4.544 6.595 2.018-.311 5.512-1.312 6.595-4.544a1.313 1.313 0 0 1 2.477.837Zm16.39-12.486-1.46 1.477v10.057a2.657 2.657 0 0 1-.772 1.854l-5.316 5.3a2.559 2.559 0 0 1-1.853.77 2.413 2.413 0 0 1-.755-.115 2.626 2.626 0 0 1-1.821-2.001l-1.296-6.48-6.858-6.858-6.48-1.297a2.625 2.625 0 0 1-2.002-1.82 2.609 2.609 0 0 1 .656-2.61l5.3-5.315a2.658 2.658 0 0 1 1.853-.771h10.057l1.477-1.46c4.692-4.692 9.499-4.561 11.353-4.282a2.576 2.576 0 0 1 2.198 2.198c.28 1.854.41 6.661-4.282 11.353Zm-26.103.132 6.185 1.23 6.546-6.546h-7.432l-5.299 5.316ZM47.438 58 53 63.562l10.205-10.204c1.28-1.28 4.2-4.725 3.543-9.106-4.38-.656-7.826 2.264-9.105 3.544L47.438 58Zm13.535 1.313-6.546 6.546 1.23 6.185 5.316-5.299v-7.432Z"/><defs><linearGradient id="b" x1="57.994" x2="92" y1="58" y2="58" gradientUnits="userSpaceOnUse"><stop stop-color="#00DC82"/><stop offset=".5" stop-color="#1DE0B1"/><stop offset="1" stop-color="#36E4DA"/></linearGradient><linearGradient id="c" x1="55.197" x2="69.453" y1="58.108" y2="58.108" gradientUnits="userSpaceOnUse"><stop stop-color="#00DC82"/><stop offset=".5" stop-color="#1DE0B1"/><stop offset="1" stop-color="#36E4DA"/></linearGradient><filter id="a" width="104.897" height="115.897" x=".052" y=".052" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="5.974"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_2726_4054"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_2726_4054" result="shape"/></filter></defs></svg> diff --git a/packages/ui-templates/public/icons/get-started.svg b/packages/ui-templates/public/icons/get-started.svg new file mode 100644 index 0000000000..c03dfddb82 --- /dev/null +++ b/packages/ui-templates/public/icons/get-started.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="105" height="116" fill="none"><g filter="url(#a)" shape-rendering="geometricPrecision"><path fill="#18181B" d="M17.203 33.223 46.9 14.286a8.416 8.416 0 0 1 8.64-.18L87.38 31.97c2.68 1.527 4.365 4.409 4.428 7.571l.191 34.944c.063 3.151-1.491 6.104-4.091 7.776l-30.143 19.383a8.417 8.417 0 0 1-8.75.251l-31.126-17.73C15.135 82.595 12.98 79.6 13 76.35V40.828c.02-3.111 1.614-5.994 4.203-7.605Z"/><path stroke="url(#b)" stroke-width="2" d="M46.9 14.286 17.202 33.223c-2.59 1.61-4.183 4.494-4.203 7.605V76.35m33.9-62.064a8.416 8.416 0 0 1 8.64-.18m-8.64.18a8.435 8.435 0 0 1 8.64-.18M13 76.35c-.02 3.25 2.135 6.246 4.888 7.814M13 76.35c-.02 3.233 2.136 6.247 4.888 7.814m0 0 31.126 17.731m0 0a8.417 8.417 0 0 0 8.75-.251m-8.75.251a8.438 8.438 0 0 0 8.75-.251m0 0 30.143-19.383m0 0c2.598-1.67 4.154-4.627 4.091-7.776m-4.091 7.776c2.6-1.672 4.154-4.625 4.091-7.776m0 0-.19-34.944m0 0c-.064-3.162-1.75-6.044-4.43-7.571m4.43 7.571c-.063-3.147-1.75-6.045-4.43-7.571m0 0L55.54 14.105"/></g><path fill="url(#c)" d="M48.669 67.696c-.886 2.69-3.02 4.659-6.153 5.709-1.41.465-2.88.72-4.364.755a1.313 1.313 0 0 1-1.312-1.313c.035-1.484.29-2.954.754-4.364 1.05-3.133 3.02-5.266 5.71-6.152a1.312 1.312 0 1 1 .836 2.477c-3.232 1.083-4.232 4.577-4.544 6.595 2.018-.311 5.512-1.312 6.595-4.544a1.313 1.313 0 0 1 2.477.837Zm16.39-12.486-1.46 1.477v10.057a2.657 2.657 0 0 1-.772 1.854l-5.316 5.3a2.559 2.559 0 0 1-1.853.77 2.413 2.413 0 0 1-.755-.115 2.624 2.624 0 0 1-1.821-2.001l-1.296-6.48-6.858-6.858-6.48-1.297a2.625 2.625 0 0 1-2.002-1.82 2.609 2.609 0 0 1 .656-2.61l5.3-5.315a2.658 2.658 0 0 1 1.853-.771h10.057l1.477-1.46c4.692-4.692 9.499-4.561 11.353-4.282a2.576 2.576 0 0 1 2.198 2.198c.28 1.854.41 6.661-4.282 11.353Zm-26.103.132 6.185 1.23 6.546-6.546h-7.432l-5.299 5.316Zm8.482 2.657L53 63.561l10.205-10.205c1.28-1.28 4.2-4.724 3.543-9.105-4.38-.656-7.826 2.264-9.105 3.544L47.438 57.999Zm13.535 1.313-6.546 6.546 1.23 6.185 5.316-5.299v-7.432Z" shape-rendering="geometricPrecision"/><defs><linearGradient id="b" x1="57.994" x2="92" y1="58" y2="58" gradientUnits="userSpaceOnUse"><stop stop-color="#00DC82"/><stop offset=".5" stop-color="#1DE0B1"/><stop offset="1" stop-color="#36E4DA"/></linearGradient><linearGradient id="c" x1="55.197" x2="69.453" y1="58.107" y2="58.107" gradientUnits="userSpaceOnUse"><stop stop-color="#00DC82"/><stop offset=".5" stop-color="#1DE0B1"/><stop offset="1" stop-color="#36E4DA"/></linearGradient><filter id="a" width="104.897" height="115.897" x=".052" y=".052" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="5.974"/><feComposite in2="hardAlpha" operator="out"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.07 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_2724_4091"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_2724_4091" result="shape"/></filter></defs></svg> diff --git a/packages/ui-templates/public/icons/github-brands 1.svg b/packages/ui-templates/public/icons/github-brands 1.svg new file mode 100644 index 0000000000..fea66eaa58 --- /dev/null +++ b/packages/ui-templates/public/icons/github-brands 1.svg @@ -0,0 +1 @@ +<svg width="80" height="87" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M31.44 75.289c0 .379-.435.682-.985.682-.625.056-1.061-.247-1.061-.682 0-.38.436-.682.985-.682.569-.057 1.062.246 1.062.682zm-5.893-.853c-.133.379.246.815.815.928.492.19 1.061 0 1.175-.379.113-.378-.247-.814-.815-.985-.493-.132-1.043.057-1.175.436zm8.376-.322c-.55.133-.928.493-.871.928.056.38.55.626 1.118.493.55-.133.928-.493.871-.871-.056-.36-.568-.607-1.118-.55zm12.47-72.598C20.109 1.516 0 21.465 0 47.742c0 21.01 13.228 38.99 32.123 45.317 2.426.436 3.279-1.06 3.279-2.292 0-1.175-.057-7.654-.057-11.632 0 0-13.266 2.841-16.052-5.646 0 0-2.16-5.513-5.269-6.934 0 0-4.34-2.975.303-2.918 0 0 4.72.38 7.316 4.888 4.15 7.313 11.105 5.21 13.816 3.96.435-3.031 1.667-5.134 3.032-6.385-10.594-1.174-21.283-2.709-21.283-20.934 0-5.21 1.44-7.825 4.473-11.16-.493-1.23-2.104-6.308.492-12.863 3.961-1.232 13.077 5.115 13.077 5.115 3.79-1.06 7.865-1.61 11.902-1.61 4.036 0 8.11.55 11.901 1.61 0 0 9.116-6.365 13.077-5.115 2.596 6.574.985 11.632.493 12.864 3.032 3.353 4.89 5.968 4.89 11.159 0 18.282-11.163 19.74-21.757 20.934 1.743 1.497 3.221 4.339 3.221 8.79 0 6.385-.056 14.286-.056 15.84 0 1.23.871 2.727 3.278 2.291C81.151 86.731 94 68.752 94 47.742 94 21.465 72.68 1.516 46.394 1.516zM18.422 66.858c-.246.19-.19.625.133.985.303.303.739.436.985.19.246-.19.19-.626-.133-.986-.303-.303-.739-.435-.985-.189zm-2.047-1.535c-.133.247.057.55.436.74.303.189.682.132.815-.133.133-.247-.057-.55-.436-.74-.379-.113-.682-.056-.815.133zm6.14 6.745c-.303.246-.189.815.247 1.175.436.435.985.492 1.232.189.246-.246.132-.815-.247-1.175-.417-.435-.985-.492-1.231-.189zm-2.16-2.785c-.303.19-.303.682 0 1.118.303.436.815.625 1.061.436.303-.247.303-.74 0-1.175-.265-.436-.758-.625-1.061-.379z" fill="#D1E2E2"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h80v86.761H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui-templates/public/icons/logo.svg b/packages/ui-templates/public/icons/logo.svg new file mode 100644 index 0000000000..619081c4ab --- /dev/null +++ b/packages/ui-templates/public/icons/logo.svg @@ -0,0 +1,3 @@ +<svg width="61" height="42" viewBox="0 0 61 42" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M33.9869 41.2211H56.412C57.1243 41.2212 57.824 41.0336 58.4408 40.6772C59.0576 40.3209 59.5698 39.8083 59.9258 39.191C60.2818 38.5737 60.469 37.8736 60.4687 37.1609C60.4684 36.4482 60.2805 35.7482 59.924 35.1313L44.864 9.03129C44.508 8.41416 43.996 7.90168 43.3793 7.54537C42.7626 7.18906 42.063 7.00147 41.3509 7.00147C40.6387 7.00147 39.9391 7.18906 39.3225 7.54537C38.7058 7.90168 38.1937 8.41416 37.8377 9.03129L33.9869 15.7093L26.458 2.65061C26.1018 2.03354 25.5895 1.52113 24.9726 1.16489C24.3557 0.808639 23.656 0.621094 22.9438 0.621094C22.2316 0.621094 21.5318 0.808639 20.915 1.16489C20.2981 1.52113 19.7858 2.03354 19.4296 2.65061L0.689224 35.1313C0.332704 35.7482 0.144842 36.4482 0.144532 37.1609C0.144222 37.8736 0.331476 38.5737 0.687459 39.191C1.04344 39.8083 1.5556 40.3209 2.17243 40.6772C2.78925 41.0336 3.48899 41.2212 4.20126 41.2211H18.2778C23.8551 41.2211 27.9682 38.7699 30.7984 33.9876L37.6694 22.0813L41.3498 15.7093L52.3951 34.8492H37.6694L33.9869 41.2211ZM18.0484 34.8426L8.2247 34.8404L22.9504 9.32211L30.2979 22.0813L25.3784 30.6092C23.4989 33.7121 21.3637 34.8426 18.0484 34.8426Z" fill="#00DC82"/> +</svg> diff --git a/packages/ui-templates/public/icons/modules-color-light.svg b/packages/ui-templates/public/icons/modules-color-light.svg new file mode 100644 index 0000000000..161ab3921d --- /dev/null +++ b/packages/ui-templates/public/icons/modules-color-light.svg @@ -0,0 +1,19 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2613_3853)"> +<path d="M51.1519 39.8821C51.154 39.9844 51.1527 40.0863 51.148 40.1877C51.0782 41.7091 50.2566 43.1165 48.9325 43.9357L29.0918 56.2117C27.6504 57.1035 25.8212 57.1564 24.3387 56.3439L3.85107 45.1148C2.27157 44.2491 1.14238 42.6366 1.15291 41.0494L1.15293 41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4169 1.73583 26.2139 1.69824 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8136C50.0797 14.6078 50.9898 16.1132 51.026 17.7438L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821Z" fill="white" stroke="url(#paint0_linear_2613_3853)" stroke-width="2"/> +<path d="M33.8193 42.2552H17.8193C16.7585 42.2552 15.7411 41.8337 14.9909 41.0836C14.2408 40.3334 13.8193 39.316 13.8193 38.2552V24.9218C13.8193 23.861 14.2408 22.8435 14.9909 22.0934C15.7411 21.3433 16.7585 20.9218 17.8193 20.9218H19.1527C19.1751 19.792 19.5558 18.6985 20.2399 17.7991C20.924 16.8996 21.8761 16.2407 22.9589 15.9173C24.0416 15.594 25.1992 15.6229 26.2644 16C27.3297 16.377 28.2477 17.0827 28.886 18.0152C29.4839 18.8674 29.8094 19.8808 29.8193 20.9218H33.8193C34.173 20.9218 34.5121 21.0623 34.7621 21.3124C35.0122 21.5624 35.1527 21.9015 35.1527 22.2552V26.2552C36.2825 26.2776 37.376 26.6583 38.2754 27.3424C39.1749 28.0265 39.8338 28.9786 40.1572 30.0613C40.4805 31.1441 40.4516 32.3016 40.0745 33.3669C39.6975 34.4322 38.9918 35.3502 38.0593 35.9885C37.2071 36.5864 36.1937 36.9118 35.1527 36.9218V36.9218V40.9218C35.1527 41.2755 35.0122 41.6146 34.7621 41.8646C34.5121 42.1147 34.173 42.2552 33.8193 42.2552ZM17.8193 23.5885C17.4657 23.5885 17.1266 23.729 16.8765 23.979C16.6265 24.2291 16.486 24.5682 16.486 24.9218V38.2552C16.486 38.6088 16.6265 38.9479 16.8765 39.198C17.1266 39.448 17.4657 39.5885 17.8193 39.5885H32.486V35.3485C32.4849 35.1347 32.5351 34.9238 32.6326 34.7335C32.7301 34.5432 32.8718 34.3792 33.046 34.2552C33.2196 34.1313 33.4204 34.051 33.6316 34.0208C33.8427 33.9907 34.058 34.0116 34.2593 34.0818C34.6393 34.2368 35.0532 34.2901 35.46 34.2363C35.8669 34.1825 36.2527 34.0236 36.5793 33.7752C36.9045 33.5769 37.1834 33.3113 37.3973 32.9962C37.6111 32.6811 37.7551 32.3239 37.8193 31.9485C37.8708 31.5699 37.8402 31.1847 37.7298 30.8189C37.6194 30.4532 37.4317 30.1154 37.1793 29.8285C36.8381 29.414 36.3734 29.1193 35.8529 28.9874C35.3325 28.8555 34.7835 28.8932 34.286 29.0952C34.0846 29.1654 33.8694 29.1863 33.6582 29.1562C33.4471 29.126 33.2463 29.0457 33.0727 28.9218C32.8985 28.7978 32.7567 28.6338 32.6593 28.4435C32.5618 28.2532 32.5115 28.0423 32.5127 27.8285V23.5885H28.246C28.0269 23.6009 27.8081 23.559 27.609 23.4666C27.4099 23.3742 27.2368 23.234 27.1049 23.0586C26.973 22.8832 26.8864 22.6779 26.8529 22.461C26.8194 22.2441 26.8399 22.0222 26.9127 21.8152C27.0677 21.4352 27.1209 21.0213 27.0671 20.6145C27.0134 20.2076 26.8544 19.8218 26.606 19.4952C26.4091 19.1607 26.1395 18.8749 25.8172 18.6588C25.4948 18.4427 25.128 18.3019 24.7438 18.2468C24.3597 18.1917 23.9681 18.2238 23.598 18.3407C23.2279 18.4575 22.8889 18.6561 22.606 18.9218C22.3433 19.1824 22.1377 19.4948 22.0023 19.8391C21.8668 20.1834 21.8045 20.5521 21.8193 20.9218C21.8224 21.2277 21.8812 21.5304 21.9927 21.8152C22.0632 22.0168 22.0842 22.2324 22.054 22.4438C22.0237 22.6553 21.9432 22.8564 21.819 23.0302C21.6949 23.204 21.5308 23.3454 21.3406 23.4426C21.1504 23.5397 20.9396 23.5898 20.726 23.5885H17.8193Z" fill="url(#paint1_linear_2613_3853)"/> +</g> +<defs> +<linearGradient id="paint0_linear_2613_3853" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7D14C"/> +<stop offset="1" stop-color="#A38108"/> +</linearGradient> +<linearGradient id="paint1_linear_2613_3853" x1="13.7453" y1="21.3705" x2="40.3876" y2="35.7024" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7D14C"/> +<stop offset="1" stop-color="#A38108"/> +</linearGradient> +<clipPath id="clip0_2613_3853"> +<rect width="52" height="57" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/modules-color.svg b/packages/ui-templates/public/icons/modules-color.svg new file mode 100644 index 0000000000..3101d01195 --- /dev/null +++ b/packages/ui-templates/public/icons/modules-color.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4246 1.73116 26.2124 1.69742 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8137C50.0812 14.6086 50.9896 16.1043 51.026 17.7437L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1856 41.5204 50.346 43.0611 48.9325 43.9357L29.0918 56.2117C27.6424 57.1085 25.8227 57.1572 24.3387 56.3439L3.85107 45.1148C2.26984 44.2481 1.14232 42.646 1.15293 41.0494V41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869Z" fill="#18181B" stroke="url(#paint0_linear_2595_7337)" stroke-width="2"/> +<path d="M33.8193 42.2542H17.8193C16.7585 42.2542 15.7411 41.8328 14.9909 41.0826C14.2408 40.3325 13.8193 39.3151 13.8193 38.2542V24.9209C13.8193 23.86 14.2408 22.8426 14.9909 22.0924C15.7411 21.3423 16.7585 20.9209 17.8193 20.9209H19.1527C19.1751 19.791 19.5558 18.6975 20.2399 17.7981C20.924 16.8986 21.8761 16.2397 22.9589 15.9164C24.0416 15.593 25.1992 15.6219 26.2644 15.999C27.3297 16.376 28.2477 17.0817 28.886 18.0142C29.4839 18.8664 29.8094 19.8799 29.8193 20.9209H33.8193C34.173 20.9209 34.5121 21.0613 34.7621 21.3114C35.0122 21.5614 35.1527 21.9006 35.1527 22.2542V26.2542C36.2825 26.2766 37.376 26.6573 38.2754 27.3414C39.1749 28.0255 39.8338 28.9776 40.1572 30.0604C40.4805 31.1432 40.4516 32.3007 40.0745 33.366C39.6975 34.4312 38.9918 35.3492 38.0593 35.9875C37.2071 36.5854 36.1937 36.9109 35.1527 36.9209V40.9209C35.1527 41.2745 35.0122 41.6136 34.7621 41.8637C34.5121 42.1137 34.173 42.2542 33.8193 42.2542ZM17.8193 23.5875C17.4657 23.5875 17.1266 23.728 16.8765 23.978C16.6265 24.2281 16.486 24.5672 16.486 24.9209V38.2542C16.486 38.6078 16.6265 38.9469 16.8765 39.197C17.1266 39.447 17.4657 39.5875 17.8193 39.5875H32.486V35.3475C32.4849 35.1337 32.5351 34.9228 32.6326 34.7325C32.7301 34.5422 32.8718 34.3782 33.046 34.2542C33.2196 34.1304 33.4205 34.05 33.6316 34.0198C33.8427 33.9897 34.058 34.0106 34.2593 34.0809C34.6393 34.2359 35.0532 34.2891 35.46 34.2353C35.8669 34.1816 36.2527 34.0226 36.5793 33.7742C36.9045 33.5759 37.1834 33.3103 37.3973 32.9952C37.6111 32.6801 37.7551 32.3229 37.8193 31.9475C37.8708 31.5689 37.8402 31.1837 37.7298 30.8179C37.6194 30.4522 37.4317 30.1144 37.1793 29.8275C36.8381 29.413 36.3734 29.1183 35.8529 28.9864C35.3325 28.8545 34.7835 28.8923 34.286 29.0942C34.0846 29.1644 33.8694 29.1854 33.6582 29.1552C33.4471 29.125 33.2463 29.0447 33.0727 28.9209C32.8985 28.7969 32.7567 28.6328 32.6593 28.4425C32.5618 28.2522 32.5115 28.0413 32.5127 27.8275V23.5875H28.246C28.0269 23.5999 27.8081 23.5581 27.609 23.4656C27.4099 23.3732 27.2368 23.233 27.1049 23.0576C26.973 22.8822 26.8864 22.6769 26.8529 22.46C26.8194 22.2431 26.8399 22.0213 26.9127 21.8142C27.0677 21.4342 27.1209 21.0204 27.0671 20.6135C27.0134 20.2066 26.8544 19.8208 26.606 19.4942C26.4091 19.1597 26.1395 18.8739 25.8172 18.6578C25.4948 18.4417 25.128 18.3009 24.7438 18.2458C24.3597 18.1908 23.9681 18.2228 23.598 18.3397C23.2279 18.4565 22.8889 18.6552 22.606 18.9209C22.3433 19.1814 22.1377 19.4938 22.0023 19.8381C21.8668 20.1824 21.8045 20.5512 21.8193 20.9209C21.8224 21.2267 21.8812 21.5294 21.9927 21.8142C22.0632 22.0158 22.0842 22.2314 22.054 22.4429C22.0237 22.6543 21.9432 22.8554 21.819 23.0292C21.6949 23.203 21.5308 23.3444 21.3406 23.4416C21.1504 23.5388 20.9396 23.5888 20.726 23.5875H17.8193Z" fill="url(#paint1_linear_2595_7337)"/> +<defs> +<linearGradient id="paint0_linear_2595_7337" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7D14C"/> +<stop offset="1" stop-color="#A38108"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7337" x1="13.7453" y1="21.3695" x2="40.3876" y2="35.7015" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7D14C"/> +<stop offset="1" stop-color="#A38108"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/modules-light.svg b/packages/ui-templates/public/icons/modules-light.svg new file mode 100644 index 0000000000..2d478129a8 --- /dev/null +++ b/packages/ui-templates/public/icons/modules-light.svg @@ -0,0 +1,19 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2691_4389)"> +<path d="M51.1519 39.8821C51.154 39.9844 51.1527 40.0863 51.148 40.1877C51.0782 41.7091 50.2566 43.1165 48.9325 43.9357L29.0918 56.2117C27.6504 57.1035 25.8212 57.1564 24.3387 56.3439L3.85107 45.1148C2.27157 44.2491 1.14238 42.6366 1.15291 41.0494L1.15293 41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4169 1.73583 26.2139 1.69824 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8136C50.0797 14.6078 50.9898 16.1132 51.026 17.7438L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821Z" fill="white" stroke="url(#paint0_linear_2691_4389)" stroke-width="2"/> +<path d="M33.8193 42.2542H17.8193C16.7585 42.2542 15.7411 41.8328 14.9909 41.0826C14.2408 40.3325 13.8193 39.3151 13.8193 38.2542V24.9209C13.8193 23.86 14.2408 22.8426 14.9909 22.0924C15.7411 21.3423 16.7585 20.9209 17.8193 20.9209H19.1527C19.1751 19.791 19.5558 18.6975 20.2399 17.7981C20.924 16.8986 21.8761 16.2397 22.9589 15.9164C24.0416 15.593 25.1992 15.6219 26.2644 15.999C27.3297 16.376 28.2477 17.0817 28.886 18.0142C29.4839 18.8664 29.8094 19.8799 29.8193 20.9209H33.8193C34.173 20.9209 34.5121 21.0613 34.7621 21.3114C35.0122 21.5614 35.1527 21.9006 35.1527 22.2542V26.2542C36.2825 26.2766 37.376 26.6573 38.2754 27.3414C39.1749 28.0255 39.8338 28.9776 40.1572 30.0604C40.4805 31.1432 40.4516 32.3007 40.0745 33.366C39.6975 34.4312 38.9918 35.3492 38.0593 35.9875C37.2071 36.5854 36.1937 36.9109 35.1527 36.9209V36.9209V40.9209C35.1527 41.2745 35.0122 41.6136 34.7621 41.8637C34.5121 42.1137 34.173 42.2542 33.8193 42.2542ZM17.8193 23.5875C17.4657 23.5875 17.1266 23.728 16.8765 23.978C16.6265 24.2281 16.486 24.5672 16.486 24.9209V38.2542C16.486 38.6078 16.6265 38.9469 16.8765 39.197C17.1266 39.447 17.4657 39.5875 17.8193 39.5875H32.486V35.3475C32.4849 35.1337 32.5351 34.9228 32.6326 34.7325C32.7301 34.5422 32.8718 34.3782 33.046 34.2542C33.2196 34.1304 33.4204 34.05 33.6316 34.0198C33.8427 33.9897 34.058 34.0106 34.2593 34.0809C34.6393 34.2359 35.0532 34.2891 35.46 34.2353C35.8669 34.1816 36.2527 34.0226 36.5793 33.7742C36.9045 33.5759 37.1834 33.3103 37.3973 32.9952C37.6111 32.6801 37.7551 32.3229 37.8193 31.9475C37.8708 31.5689 37.8402 31.1837 37.7298 30.8179C37.6194 30.4522 37.4317 30.1144 37.1793 29.8275C36.8381 29.413 36.3734 29.1183 35.8529 28.9864C35.3325 28.8545 34.7835 28.8923 34.286 29.0942C34.0846 29.1644 33.8694 29.1854 33.6582 29.1552C33.4471 29.125 33.2463 29.0447 33.0727 28.9209C32.8985 28.7969 32.7567 28.6328 32.6593 28.4425C32.5618 28.2522 32.5115 28.0413 32.5127 27.8275V23.5875H28.246C28.0269 23.5999 27.8081 23.5581 27.609 23.4656C27.4099 23.3732 27.2368 23.233 27.1049 23.0576C26.973 22.8822 26.8864 22.6769 26.8529 22.46C26.8194 22.2431 26.8399 22.0213 26.9127 21.8142C27.0677 21.4342 27.1209 21.0204 27.0671 20.6135C27.0134 20.2066 26.8544 19.8208 26.606 19.4942C26.4091 19.1597 26.1395 18.8739 25.8172 18.6578C25.4948 18.4417 25.128 18.3009 24.7438 18.2458C24.3597 18.1908 23.9681 18.2228 23.598 18.3397C23.2279 18.4565 22.8889 18.6552 22.606 18.9209C22.3433 19.1814 22.1377 19.4938 22.0023 19.8381C21.8668 20.1824 21.8045 20.5512 21.8193 20.9209C21.8224 21.2267 21.8812 21.5294 21.9927 21.8142C22.0632 22.0158 22.0842 22.2314 22.054 22.4429C22.0237 22.6543 21.9432 22.8554 21.819 23.0292C21.6949 23.203 21.5308 23.3444 21.3406 23.4416C21.1504 23.5388 20.9396 23.5888 20.726 23.5875H17.8193Z" fill="url(#paint1_linear_2691_4389)"/> +</g> +<defs> +<linearGradient id="paint0_linear_2691_4389" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint1_linear_2691_4389" x1="13.7453" y1="21.3695" x2="40.3876" y2="35.7015" gradientUnits="userSpaceOnUse"> +<stop stop-color="#D4D4D8"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<clipPath id="clip0_2691_4389"> +<rect width="52" height="57" fill="white" transform="translate(0.152832 0.920898)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/modules.svg b/packages/ui-templates/public/icons/modules.svg new file mode 100644 index 0000000000..496593d5ed --- /dev/null +++ b/packages/ui-templates/public/icons/modules.svg @@ -0,0 +1,14 @@ +<svg width="53" height="58" viewBox="0 0 53 58" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.43319 14.5869L3.43322 14.587L3.44269 14.5812L22.9844 2.59084C24.4246 1.73116 26.2124 1.69742 27.6729 2.49791L27.6729 2.49792L27.6784 2.50094L48.6303 13.8121C48.6313 13.8126 48.6322 13.8131 48.6331 13.8137C50.0812 14.6086 50.9896 16.1043 51.026 17.7437L51.1517 39.8672L51.1517 39.8746L51.1519 39.8821C51.1856 41.5203 50.346 43.0611 48.9325 43.9357L29.0918 56.2117C27.6424 57.1085 25.8227 57.1572 24.3387 56.3439L3.85107 45.1148C2.26984 44.2481 1.14232 42.646 1.15293 41.0494V41.0427L1.153 18.552C1.15301 18.5509 1.15302 18.5499 1.15302 18.5488C1.16485 16.9324 2.02611 15.4289 3.43319 14.5869Z" fill="#18181B" stroke="url(#paint0_linear_2595_7175)" stroke-width="2"/> +<path d="M33.8193 42.2542H17.8193C16.7585 42.2542 15.7411 41.8328 14.9909 41.0826C14.2408 40.3325 13.8193 39.3151 13.8193 38.2542V24.9209C13.8193 23.86 14.2408 22.8426 14.9909 22.0924C15.7411 21.3423 16.7585 20.9209 17.8193 20.9209H19.1527C19.1751 19.791 19.5558 18.6975 20.2399 17.7981C20.924 16.8986 21.8761 16.2397 22.9589 15.9164C24.0416 15.593 25.1992 15.6219 26.2644 15.999C27.3297 16.376 28.2477 17.0817 28.886 18.0142C29.4839 18.8664 29.8094 19.8799 29.8193 20.9209H33.8193C34.173 20.9209 34.5121 21.0613 34.7621 21.3114C35.0122 21.5614 35.1527 21.9006 35.1527 22.2542V26.2542C36.2825 26.2766 37.376 26.6573 38.2754 27.3414C39.1749 28.0255 39.8338 28.9776 40.1572 30.0604C40.4805 31.1432 40.4516 32.3007 40.0745 33.366C39.6975 34.4312 38.9918 35.3492 38.0593 35.9875C37.2071 36.5854 36.1937 36.9109 35.1527 36.9209V40.9209C35.1527 41.2745 35.0122 41.6136 34.7621 41.8637C34.5121 42.1137 34.173 42.2542 33.8193 42.2542ZM17.8193 23.5875C17.4657 23.5875 17.1266 23.728 16.8765 23.978C16.6265 24.2281 16.486 24.5672 16.486 24.9209V38.2542C16.486 38.6078 16.6265 38.9469 16.8765 39.197C17.1266 39.447 17.4657 39.5875 17.8193 39.5875H32.486V35.3475C32.4849 35.1337 32.5351 34.9228 32.6326 34.7325C32.7301 34.5422 32.8718 34.3782 33.046 34.2542C33.2196 34.1304 33.4205 34.05 33.6316 34.0198C33.8427 33.9897 34.058 34.0106 34.2593 34.0809C34.6393 34.2359 35.0532 34.2891 35.46 34.2353C35.8669 34.1816 36.2527 34.0226 36.5793 33.7742C36.9045 33.5759 37.1834 33.3103 37.3973 32.9952C37.6111 32.6801 37.7551 32.3229 37.8193 31.9475C37.8708 31.5689 37.8402 31.1837 37.7298 30.8179C37.6194 30.4522 37.4317 30.1144 37.1793 29.8275C36.8381 29.413 36.3734 29.1183 35.8529 28.9864C35.3325 28.8545 34.7835 28.8923 34.286 29.0942C34.0846 29.1644 33.8694 29.1854 33.6582 29.1552C33.4471 29.125 33.2463 29.0447 33.0727 28.9209C32.8985 28.7969 32.7567 28.6328 32.6593 28.4425C32.5618 28.2522 32.5115 28.0413 32.5127 27.8275V23.5875H28.246C28.0269 23.5999 27.8081 23.5581 27.609 23.4656C27.4099 23.3732 27.2368 23.233 27.1049 23.0576C26.973 22.8822 26.8864 22.6769 26.8529 22.46C26.8194 22.2431 26.8399 22.0213 26.9127 21.8142C27.0677 21.4342 27.1209 21.0204 27.0671 20.6135C27.0134 20.2066 26.8544 19.8208 26.606 19.4942C26.4091 19.1597 26.1395 18.8739 25.8172 18.6578C25.4948 18.4417 25.128 18.3009 24.7438 18.2458C24.3597 18.1908 23.9681 18.2228 23.598 18.3397C23.2279 18.4565 22.8889 18.6552 22.606 18.9209C22.3433 19.1814 22.1377 19.4938 22.0023 19.8381C21.8668 20.1824 21.8045 20.5512 21.8193 20.9209C21.8224 21.2267 21.8812 21.5294 21.9927 21.8142C22.0632 22.0158 22.0842 22.2314 22.054 22.4429C22.0237 22.6543 21.9432 22.8554 21.819 23.0292C21.6949 23.203 21.5308 23.3444 21.3406 23.4416C21.1504 23.5388 20.9396 23.5888 20.726 23.5875H17.8193Z" fill="url(#paint1_linear_2595_7175)"/> +<defs> +<linearGradient id="paint0_linear_2595_7175" x1="0.662695" y1="18.4025" x2="51.7209" y2="44.2212" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +<linearGradient id="paint1_linear_2595_7175" x1="13.7453" y1="21.3695" x2="40.3876" y2="35.7015" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#71717A"/> +</linearGradient> +</defs> +</svg> diff --git a/packages/ui-templates/public/icons/twitter-brands 1.svg b/packages/ui-templates/public/icons/twitter-brands 1.svg new file mode 100644 index 0000000000..f090128a6b --- /dev/null +++ b/packages/ui-templates/public/icons/twitter-brands 1.svg @@ -0,0 +1 @@ +<svg width="80" height="80" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path d="M87.926 29.04c.063.87.063 1.74.063 2.611 0 26.552-20.21 57.146-57.146 57.146-11.38 0-21.95-3.296-30.843-9.016 1.617.186 3.171.248 4.85.248 9.39 0 18.033-3.171 24.935-8.58-8.83-.188-16.23-5.97-18.779-13.93 1.244.187 2.488.31 3.793.31a21.24 21.24 0 0 0 5.286-.683C10.882 55.28 3.98 47.196 3.98 37.434v-.249a20.227 20.227 0 0 0 9.078 2.55c-5.41-3.607-8.954-9.763-8.954-16.727 0-3.731.995-7.151 2.736-10.136C16.727 25.06 31.589 33.019 48.253 33.89a22.677 22.677 0 0 1-.497-4.602c0-11.068 8.954-20.085 20.085-20.085a20.037 20.037 0 0 1 14.675 6.343c4.54-.87 8.892-2.55 12.748-4.85-1.493 4.663-4.664 8.58-8.83 11.068 4.042-.435 7.96-1.555 11.566-3.109a43.177 43.177 0 0 1-10.074 10.384z" fill="#D1E2E2"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h80v80H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui-templates/styles.ts b/packages/ui-templates/styles.ts new file mode 100644 index 0000000000..71cffbe488 --- /dev/null +++ b/packages/ui-templates/styles.ts @@ -0,0 +1,4 @@ +// @ts-expect-error untyped css file +import '@unocss/reset/tailwind.css' +// @ts-expect-error untyped css file +import 'uno.css' diff --git a/packages/ui-templates/templates/error-404/index.html b/packages/ui-templates/templates/error-404/index.html new file mode 100644 index 0000000000..90d47bea79 --- /dev/null +++ b/packages/ui-templates/templates/error-404/index.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html> + <head> + <title>{{ messages.statusCode }} - {{ messages.statusMessage }} | {{ messages.appName }} + + + + + +
+

{{ messages.statusCode }}

+

{{ messages.statusMessage }}

+

{{ messages.description }}

+ +
+ + diff --git a/packages/ui-templates/templates/error-404/messages.json b/packages/ui-templates/templates/error-404/messages.json new file mode 100644 index 0000000000..1cd5a5e62e --- /dev/null +++ b/packages/ui-templates/templates/error-404/messages.json @@ -0,0 +1,6 @@ +{ + "statusCode": 404, + "statusMessage": "Page not found", + "description": "Sorry, the page you are looking for could not be found.", + "backHome": "Go back home" +} diff --git a/packages/ui-templates/templates/error-500/index.html b/packages/ui-templates/templates/error-500/index.html new file mode 100644 index 0000000000..b6616d0510 --- /dev/null +++ b/packages/ui-templates/templates/error-500/index.html @@ -0,0 +1,16 @@ + + + + {{ messages.statusCode }} - {{ messages.statusMessage }} | {{ messages.appName }} + + + + + +
+

{{ messages.statusCode }}

+

{{ messages.statusMessage }}

+

{{ messages.description }}

+
+ + diff --git a/packages/ui-templates/templates/error-500/messages.json b/packages/ui-templates/templates/error-500/messages.json new file mode 100644 index 0000000000..e69ef467e3 --- /dev/null +++ b/packages/ui-templates/templates/error-500/messages.json @@ -0,0 +1,6 @@ +{ + "statusCode": 500, + "statusMessage": "Internal server error", + "description": "This page is temporarily unavailable.", + "refresh": "Refresh this page" +} diff --git a/packages/ui-templates/templates/error-dev/index.html b/packages/ui-templates/templates/error-dev/index.html new file mode 100644 index 0000000000..aa96b9d67c --- /dev/null +++ b/packages/ui-templates/templates/error-dev/index.html @@ -0,0 +1,17 @@ + + + + {{ messages.statusCode }} - {{ messages.statusMessage }} | {{ messages.appName }} + + + + + +

{{ messages.statusCode }}

+

{{ messages.description }}

+ Customize this page +
+
{{{ messages.stack }}}
+
+ + diff --git a/packages/ui-templates/templates/error-dev/messages.json b/packages/ui-templates/templates/error-dev/messages.json new file mode 100644 index 0000000000..4c790e9df3 --- /dev/null +++ b/packages/ui-templates/templates/error-dev/messages.json @@ -0,0 +1,6 @@ +{ + "statusCode": 500, + "statusMessage": "Server error", + "description": "An error occurred in the application and the page could not be served.", + "stack": "" +} diff --git a/packages/ui-templates/templates/loading/index.html b/packages/ui-templates/templates/loading/index.html new file mode 100644 index 0000000000..67b1198581 --- /dev/null +++ b/packages/ui-templates/templates/loading/index.html @@ -0,0 +1,80 @@ + + + + {{ messages.loading }} | {{ messages.appName }} + + + + + + + +
+ + + diff --git a/packages/ui-templates/templates/loading/messages.json b/packages/ui-templates/templates/loading/messages.json new file mode 100644 index 0000000000..382bdcde3a --- /dev/null +++ b/packages/ui-templates/templates/loading/messages.json @@ -0,0 +1,4 @@ +{ + "loading": "Loading", + "version": "4.0" +} diff --git a/packages/ui-templates/templates/messages.json b/packages/ui-templates/templates/messages.json new file mode 100644 index 0000000000..7063043212 --- /dev/null +++ b/packages/ui-templates/templates/messages.json @@ -0,0 +1,3 @@ +{ + "appName": "Nuxt" +} diff --git a/packages/ui-templates/templates/spa-loading-icon/index.html b/packages/ui-templates/templates/spa-loading-icon/index.html new file mode 100644 index 0000000000..c698cfc761 --- /dev/null +++ b/packages/ui-templates/templates/spa-loading-icon/index.html @@ -0,0 +1,29 @@ + + + + diff --git a/packages/ui-templates/templates/spa-loading-icon/messages.json b/packages/ui-templates/templates/spa-loading-icon/messages.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/ui-templates/templates/spa-loading-icon/messages.json @@ -0,0 +1 @@ +{} diff --git a/packages/ui-templates/templates/welcome/index.html b/packages/ui-templates/templates/welcome/index.html new file mode 100644 index 0000000000..95fcfd383e --- /dev/null +++ b/packages/ui-templates/templates/welcome/index.html @@ -0,0 +1,110 @@ + + + + {{ messages.title }} + + + + + + + + + diff --git a/packages/ui-templates/templates/welcome/messages.json b/packages/ui-templates/templates/welcome/messages.json new file mode 100644 index 0000000000..cd03b5fed2 --- /dev/null +++ b/packages/ui-templates/templates/welcome/messages.json @@ -0,0 +1,3 @@ +{ + "title": "Welcome to Nuxt!" +} diff --git a/packages/ui-templates/test/__snapshots__/templates.spec.ts.snap b/packages/ui-templates/test/__snapshots__/templates.spec.ts.snap new file mode 100644 index 0000000000..7b591de4c2 --- /dev/null +++ b/packages/ui-templates/test/__snapshots__/templates.spec.ts.snap @@ -0,0 +1,1473 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`template > produces correct output for error-404 template 1`] = ` +".grid { + display: grid; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.mb-4 { + margin-bottom: 1rem; +} +.max-w-520px { + max-width: 520px; +} +.min-h-screen { + min-height: 100vh; +} +.w-full { + width: 100%; +} +.flex { + display: flex; +} +.place-content-center { + place-content: center; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.overflow-hidden { + overflow: hidden; +} +.bg-white { + --un-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--un-bg-opacity)); +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.text-center { + text-align: center; +} +.text-\\[80px\\] { + font-size: 80px; +} +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.text-\\[\\#020420\\] { + --un-text-opacity: 1; + color: rgb(2 4 32 / var(--un-text-opacity)); +} +.text-\\[\\#64748B\\] { + --un-text-opacity: 1; + color: rgb(100 116 139 / var(--un-text-opacity)); +} +.hover\\:text-\\[\\#00DC82\\]:hover { + --un-text-opacity: 1; + color: rgb(0 220 130 / var(--un-text-opacity)); +} +.font-medium { + font-weight: 500; +} +.font-semibold { + font-weight: 600; +} +.leading-none { + line-height: 1; +} +.tracking-wide { + letter-spacing: 0.025em; +} +.font-sans { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; +} +.tabular-nums { + --un-numeric-spacing: tabular-nums; + font-variant-numeric: var(--un-ordinal) var(--un-slashed-zero) + var(--un-numeric-figure) var(--un-numeric-spacing) + var(--un-numeric-fraction); +} +.underline { + text-decoration-line: underline; +} +.underline-offset-3 { + text-underline-offset: 3px; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (prefers-color-scheme: dark) { + .dark\\:bg-\\[\\#020420\\] { + --un-bg-opacity: 1; + background-color: rgb(2 4 32 / var(--un-bg-opacity)); + } + .dark\\:text-white { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } +} +@media (min-width: 640px) { + .sm\\:text-\\[110px\\] { + font-size: 110px; + } + .sm\\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } +} +" +`; + +exports[`template > produces correct output for error-404 template 2`] = ` +"*, +:after, +:before { + border-color: var(--un-default-border-color, #e5e7eb); + border-style: solid; + border-width: 0; + box-sizing: border-box; +} +:after, +:before { + --un-content: ""; +} +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +h1, +h2 { + font-size: inherit; + font-weight: inherit; +} +a { + color: inherit; + text-decoration: inherit; +} +h1, +h2, +p { + margin: 0; +} +*, +:after, +:before { + --un-rotate: 0; + --un-rotate-x: 0; + --un-rotate-y: 0; + --un-rotate-z: 0; + --un-scale-x: 1; + --un-scale-y: 1; + --un-scale-z: 1; + --un-skew-x: 0; + --un-skew-y: 0; + --un-translate-x: 0; + --un-translate-y: 0; + --un-translate-z: 0; + --un-pan-x: ; + --un-pan-y: ; + --un-pinch-zoom: ; + --un-scroll-snap-strictness: proximity; + --un-ordinal: ; + --un-slashed-zero: ; + --un-numeric-figure: ; + --un-numeric-spacing: ; + --un-numeric-fraction: ; + --un-border-spacing-x: 0; + --un-border-spacing-y: 0; + --un-ring-offset-shadow: 0 0 transparent; + --un-ring-shadow: 0 0 transparent; + --un-shadow-inset: ; + --un-shadow: 0 0 transparent; + --un-ring-inset: ; + --un-ring-offset-width: 0px; + --un-ring-offset-color: #fff; + --un-ring-width: 0px; + --un-ring-color: rgba(147, 197, 253, 0.5); + --un-blur: ; + --un-brightness: ; + --un-contrast: ; + --un-drop-shadow: ; + --un-grayscale: ; + --un-hue-rotate: ; + --un-invert: ; + --un-saturate: ; + --un-sepia: ; + --un-backdrop-blur: ; + --un-backdrop-brightness: ; + --un-backdrop-contrast: ; + --un-backdrop-grayscale: ; + --un-backdrop-hue-rotate: ; + --un-backdrop-invert: ; + --un-backdrop-opacity: ; + --un-backdrop-saturate: ; + --un-backdrop-sepia: ; +} +" +`; + +exports[`template > produces correct output for error-500 template 1`] = ` +".grid { + display: grid; +} +.mb-2 { + margin-bottom: 0.5rem; +} +.mb-4 { + margin-bottom: 1rem; +} +.max-w-520px { + max-width: 520px; +} +.min-h-screen { + min-height: 100vh; +} +.place-content-center { + place-content: center; +} +.overflow-hidden { + overflow: hidden; +} +.bg-white { + --un-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--un-bg-opacity)); +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.text-center { + text-align: center; +} +.text-\\[80px\\] { + font-size: 80px; +} +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} +.text-\\[\\#020420\\] { + --un-text-opacity: 1; + color: rgb(2 4 32 / var(--un-text-opacity)); +} +.text-\\[\\#64748B\\] { + --un-text-opacity: 1; + color: rgb(100 116 139 / var(--un-text-opacity)); +} +.font-semibold { + font-weight: 600; +} +.leading-none { + line-height: 1; +} +.tracking-wide { + letter-spacing: 0.025em; +} +.font-sans { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; +} +.tabular-nums { + --un-numeric-spacing: tabular-nums; + font-variant-numeric: var(--un-ordinal) var(--un-slashed-zero) + var(--un-numeric-figure) var(--un-numeric-spacing) + var(--un-numeric-fraction); +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (prefers-color-scheme: dark) { + .dark\\:bg-\\[\\#020420\\] { + --un-bg-opacity: 1; + background-color: rgb(2 4 32 / var(--un-bg-opacity)); + } + .dark\\:text-white { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } +} +@media (min-width: 640px) { + .sm\\:text-\\[110px\\] { + font-size: 110px; + } + .sm\\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } +} +" +`; + +exports[`template > produces correct output for error-500 template 2`] = ` +"*, +:after, +:before { + border-color: var(--un-default-border-color, #e5e7eb); + border-style: solid; + border-width: 0; + box-sizing: border-box; +} +:after, +:before { + --un-content: ""; +} +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +h1, +h2 { + font-size: inherit; + font-weight: inherit; +} +h1, +h2, +p { + margin: 0; +} +*, +:after, +:before { + --un-rotate: 0; + --un-rotate-x: 0; + --un-rotate-y: 0; + --un-rotate-z: 0; + --un-scale-x: 1; + --un-scale-y: 1; + --un-scale-z: 1; + --un-skew-x: 0; + --un-skew-y: 0; + --un-translate-x: 0; + --un-translate-y: 0; + --un-translate-z: 0; + --un-pan-x: ; + --un-pan-y: ; + --un-pinch-zoom: ; + --un-scroll-snap-strictness: proximity; + --un-ordinal: ; + --un-slashed-zero: ; + --un-numeric-figure: ; + --un-numeric-spacing: ; + --un-numeric-fraction: ; + --un-border-spacing-x: 0; + --un-border-spacing-y: 0; + --un-ring-offset-shadow: 0 0 transparent; + --un-ring-shadow: 0 0 transparent; + --un-shadow-inset: ; + --un-shadow: 0 0 transparent; + --un-ring-inset: ; + --un-ring-offset-width: 0px; + --un-ring-offset-color: #fff; + --un-ring-width: 0px; + --un-ring-color: rgba(147, 197, 253, 0.5); + --un-blur: ; + --un-brightness: ; + --un-contrast: ; + --un-drop-shadow: ; + --un-grayscale: ; + --un-hue-rotate: ; + --un-invert: ; + --un-saturate: ; + --un-sepia: ; + --un-backdrop-blur: ; + --un-backdrop-brightness: ; + --un-backdrop-contrast: ; + --un-backdrop-grayscale: ; + --un-backdrop-hue-rotate: ; + --un-backdrop-invert: ; + --un-backdrop-opacity: ; + --un-backdrop-saturate: ; + --un-backdrop-sepia: ; +} +" +`; + +exports[`template > produces correct output for error-dev template 1`] = ` +".absolute { + position: absolute; +} +.top-6 { + top: 1.5rem; +} +.z-10 { + z-index: 10; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.mb-4 { + margin-bottom: 1rem; +} +.mb-8 { + margin-bottom: 2rem; +} +.inline-block { + display: inline-block; +} +.h-auto { + height: auto; +} +.min-h-screen { + min-height: 100vh; +} +.flex { + display: flex; +} +.flex-1 { + flex: 1 1 0%; +} +.flex-col { + flex-direction: column; +} +.overflow-y-auto { + overflow-y: auto; +} +.border { + border-width: 1px; +} +.border-b-0 { + border-bottom-width: 0; +} +.border-black\\/5 { + border-color: #0000000d; +} +.rounded-t-md { + border-top-left-radius: 0.375rem; + border-top-right-radius: 0.375rem; +} +.bg-gray-50\\/50 { + background-color: #f5f5f580; +} +.bg-white { + --un-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--un-bg-opacity)); +} +.p-8 { + padding: 2rem; +} +.px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; +} +.pt-12 { + padding-top: 3rem; +} +.text-6xl { + font-size: 3.75rem; + line-height: 1; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} +.text-black { + --un-text-opacity: 1; + color: rgb(0 0 0 / var(--un-text-opacity)); +} +.hover\\:text-\\[\\#00DC82\\]:hover { + --un-text-opacity: 1; + color: rgb(0 220 130 / var(--un-text-opacity)); +} +.font-light { + font-weight: 300; +} +.font-medium { + font-weight: 500; +} +.leading-tight { + line-height: 1.25; +} +.font-sans { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; +} +.hover\\:underline:hover { + text-decoration-line: underline; +} +.underline-offset-3 { + text-underline-offset: 3px; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (prefers-color-scheme: dark) { + .dark\\:border-white\\/10 { + border-color: #ffffff1a; + } + .dark\\:bg-\\[\\#020420\\] { + --un-bg-opacity: 1; + background-color: rgb(2 4 32 / var(--un-bg-opacity)); + } + .dark\\:bg-white\\/5 { + background-color: #ffffff0d; + } + .dark\\:text-white { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } +} +@media (min-width: 640px) { + .sm\\:right-6 { + right: 1.5rem; + } + .sm\\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + .sm\\:text-8xl { + font-size: 6rem; + line-height: 1; + } +} +" +`; + +exports[`template > produces correct output for error-dev template 2`] = ` +"*, +:after, +:before { + border-color: var(--un-default-border-color, #e5e7eb); + border-style: solid; + border-width: 0; + box-sizing: border-box; +} +:after, +:before { + --un-content: ""; +} +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +h1 { + font-size: inherit; + font-weight: inherit; +} +a { + color: inherit; + text-decoration: inherit; +} +pre { + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; + font-feature-settings: normal; + font-size: 1em; + font-variation-settings: normal; +} +h1, +p, +pre { + margin: 0; +} +*, +:after, +:before { + --un-rotate: 0; + --un-rotate-x: 0; + --un-rotate-y: 0; + --un-rotate-z: 0; + --un-scale-x: 1; + --un-scale-y: 1; + --un-scale-z: 1; + --un-skew-x: 0; + --un-skew-y: 0; + --un-translate-x: 0; + --un-translate-y: 0; + --un-translate-z: 0; + --un-pan-x: ; + --un-pan-y: ; + --un-pinch-zoom: ; + --un-scroll-snap-strictness: proximity; + --un-ordinal: ; + --un-slashed-zero: ; + --un-numeric-figure: ; + --un-numeric-spacing: ; + --un-numeric-fraction: ; + --un-border-spacing-x: 0; + --un-border-spacing-y: 0; + --un-ring-offset-shadow: 0 0 transparent; + --un-ring-shadow: 0 0 transparent; + --un-shadow-inset: ; + --un-shadow: 0 0 transparent; + --un-ring-inset: ; + --un-ring-offset-width: 0px; + --un-ring-offset-color: #fff; + --un-ring-width: 0px; + --un-ring-color: rgba(147, 197, 253, 0.5); + --un-blur: ; + --un-brightness: ; + --un-contrast: ; + --un-drop-shadow: ; + --un-grayscale: ; + --un-hue-rotate: ; + --un-invert: ; + --un-saturate: ; + --un-sepia: ; + --un-backdrop-blur: ; + --un-backdrop-brightness: ; + --un-backdrop-contrast: ; + --un-backdrop-grayscale: ; + --un-backdrop-hue-rotate: ; + --un-backdrop-invert: ; + --un-backdrop-opacity: ; + --un-backdrop-saturate: ; + --un-backdrop-sepia: ; +} +" +`; + +exports[`template > produces correct output for loading template 1`] = ` +".nuxt-loader-bar { + background: #00dc82; + bottom: 0; + height: 3px; + left: 0; + position: fixed; + right: 0; +} +.triangle-loading { + position: absolute; +} +.triangle-loading > path { + fill: none; + stroke-width: 4px; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 128; + stroke-dashoffset: 128; + animation: nuxt-loading-move 3s linear infinite; +} +.nuxt-logo:hover .triangle-loading > path { + animation-play-state: paused; +} +.relative { + position: relative; +} +.inline-block { + display: inline-block; +} +.min-h-screen { + min-height: 100vh; +} +.flex { + display: flex; +} +.flex-col { + flex-direction: column; +} +.items-end { + align-items: flex-end; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.gap-4 { + gap: 1rem; +} +.overflow-hidden { + overflow: hidden; +} +.border { + border-width: 1px; +} +.border-\\[\\#00DC42\\]\\/50 { + border-color: #00dc4280; +} +.group:hover .group-hover\\:border-\\[\\#00DC42\\] { + --un-border-opacity: 1; + border-color: rgb(0 220 66 / var(--un-border-opacity)); +} +.rounded { + border-radius: 0.25rem; +} +.bg-\\[\\#00DC42\\]\\/10 { + background-color: #00dc421a; +} +.bg-white { + --un-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--un-bg-opacity)); +} +.group:hover .group-hover\\:bg-\\[\\#00DC42\\]\\/15 { + background-color: #00dc4226; +} +.px-2\\.5 { + padding-left: 0.625rem; + padding-right: 0.625rem; +} +.py-1\\.5 { + padding-bottom: 0.375rem; + padding-top: 0.375rem; +} +.text-center { + text-align: center; +} +.text-\\[16px\\] { + font-size: 16px; +} +.group:hover .group-hover\\:text-\\[\\#00DC82\\], +.text-\\[\\#00DC82\\] { + --un-text-opacity: 1; + color: rgb(0 220 130 / var(--un-text-opacity)); +} +.text-\\[\\#00DC82\\]\\/80 { + color: #00dc82cc; +} +.group:hover .group-hover\\:text-\\[\\#020420\\], +.text-\\[\\#020420\\] { + --un-text-opacity: 1; + color: rgb(2 4 32 / var(--un-text-opacity)); +} +.text-\\[\\#020420\\]\\/80 { + color: #020420cc; +} +.font-semibold { + font-weight: 600; +} +.leading-none { + line-height: 1; +} +.font-mono { + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; +} +.font-sans { + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + Segoe UI, + Roboto, + Helvetica Neue, + Arial, + Noto Sans, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +@media (prefers-color-scheme: dark) { + .dark\\:bg-\\[\\#020420\\] { + --un-bg-opacity: 1; + background-color: rgb(2 4 32 / var(--un-bg-opacity)); + } + .dark\\:text-gray-200 { + --un-text-opacity: 1; + color: rgb(224 224 224 / var(--un-text-opacity)); + } + .dark\\:text-white, + .group:hover .dark\\:group-hover\\:text-white { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } +} +" +`; + +exports[`template > produces correct output for loading template 2`] = ` +"@keyframes nuxt-loading-move { + to { + stroke-dashoffset: -128; + } +} +@media (prefers-color-scheme: dark) { + body, + html { + color: #fff; + color-scheme: dark; + } +} +*, +:after, +:before { + border-color: var(--un-default-border-color, #e5e7eb); + border-style: solid; + border-width: 0; + box-sizing: border-box; +} +:after, +:before { + --un-content: ""; +} +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +a { + color: inherit; + text-decoration: inherit; +} +svg { + display: block; + vertical-align: middle; +} +*, +:after, +:before { + --un-rotate: 0; + --un-rotate-x: 0; + --un-rotate-y: 0; + --un-rotate-z: 0; + --un-scale-x: 1; + --un-scale-y: 1; + --un-scale-z: 1; + --un-skew-x: 0; + --un-skew-y: 0; + --un-translate-x: 0; + --un-translate-y: 0; + --un-translate-z: 0; + --un-pan-x: ; + --un-pan-y: ; + --un-pinch-zoom: ; + --un-scroll-snap-strictness: proximity; + --un-ordinal: ; + --un-slashed-zero: ; + --un-numeric-figure: ; + --un-numeric-spacing: ; + --un-numeric-fraction: ; + --un-border-spacing-x: 0; + --un-border-spacing-y: 0; + --un-ring-offset-shadow: 0 0 transparent; + --un-ring-shadow: 0 0 transparent; + --un-shadow-inset: ; + --un-shadow: 0 0 transparent; + --un-ring-inset: ; + --un-ring-offset-width: 0px; + --un-ring-offset-color: #fff; + --un-ring-width: 0px; + --un-ring-color: rgba(147, 197, 253, 0.5); + --un-blur: ; + --un-brightness: ; + --un-contrast: ; + --un-drop-shadow: ; + --un-grayscale: ; + --un-hue-rotate: ; + --un-invert: ; + --un-saturate: ; + --un-sepia: ; + --un-backdrop-blur: ; + --un-backdrop-brightness: ; + --un-backdrop-contrast: ; + --un-backdrop-grayscale: ; + --un-backdrop-hue-rotate: ; + --un-backdrop-invert: ; + --un-backdrop-opacity: ; + --un-backdrop-saturate: ; + --un-backdrop-sepia: ; +} +" +`; + +exports[`template > produces correct output for welcome template 1`] = ` +".sr-only { + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + width: 1px; + clip: rect(0, 0, 0, 0); + border-width: 0; + white-space: nowrap; +} +.absolute, +.sr-only { + position: absolute; +} +.relative { + position: relative; +} +.right-4 { + right: 1rem; +} +.top-4 { + top: 1rem; +} +.grid { + display: grid; +} +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.mb-6 { + margin-bottom: 1.5rem; +} +.mt-1 { + margin-top: 0.25rem; +} +.mt-4 { + margin-top: 1rem; +} +.mt-6 { + margin-top: 1.5rem; +} +.inline-block { + display: inline-block; +} +.size-\\[18px\\] { + height: 18px; + width: 18px; +} +.size-4 { + height: 1rem; + width: 1rem; +} +.group:hover .group-hover\\:size-5, +.size-5 { + height: 1.25rem; + width: 1.25rem; +} +.h-\\[32px\\] { + height: 32px; +} +.h-8 { + height: 2rem; +} +.max-w-\\[980px\\] { + max-width: 980px; +} +.min-h-screen { + min-height: 100vh; +} +.w-\\[32px\\] { + width: 32px; +} +.w-full { + width: 100%; +} +.flex { + display: flex; +} +.inline-flex { + display: inline-flex; +} +.flex-col { + flex-direction: column; +} +.place-content-center { + place-content: center; +} +.items-end { + align-items: flex-end; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.gap-1 { + gap: 0.25rem; +} +.gap-4 { + gap: 1rem; +} +.gap-y-4 { + row-gap: 1rem; +} +.border { + border-width: 1px; +} +.border-\\[\\#00DC42\\]\\/50 { + border-color: #00dc4280; +} +.border-\\[\\#00DC82\\] { + --un-border-opacity: 1; + border-color: rgb(0 220 130 / var(--un-border-opacity)); +} +.border-gray-200 { + --un-border-opacity: 1; + border-color: rgb(224 224 224 / var(--un-border-opacity)); +} +.border-green-600\\/10 { + border-color: #00bb6a1a; +} +.border-green-600\\/20 { + border-color: #00bb6a33; +} +.hover\\:border-\\[\\#00DC82\\]:hover { + --un-border-opacity: 1; + border-color: rgb(0 220 130 / var(--un-border-opacity)); +} +.rounded { + border-radius: 0.25rem; +} +.rounded-lg { + border-radius: 0.5rem; +} +.bg-\\[\\#00DC42\\]\\/10 { + background-color: #00dc421a; +} +.bg-\\[\\#00DC82\\]\\/5 { + background-color: #00dc820d; +} +.bg-gray-50\\/10 { + background-color: #f5f5f51a; +} +.bg-green-50 { + --un-bg-opacity: 1; + background-color: rgb(208 252 222 / var(--un-bg-opacity)); +} +.bg-white { + --un-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--un-bg-opacity)); +} +.p-1 { + padding: 0.25rem; +} +.p-6 { + padding: 1.5rem; +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.py-1 { + padding-bottom: 0.25rem; + padding-top: 0.25rem; +} +.text-\\[12px\\] { + font-size: 12px; +} +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} +.group:hover .group-hover\\:text-\\[\\#00DC82\\], +.text-\\[\\#00DC82\\] { + --un-text-opacity: 1; + color: rgb(0 220 130 / var(--un-text-opacity)); +} +.text-\\[\\#020420\\] { + --un-text-opacity: 1; + color: rgb(2 4 32 / var(--un-text-opacity)); +} +.text-\\[\\#020420\\]\\/20 { + color: #02042033; +} +.text-gray-500 { + --un-text-opacity: 1; + color: rgb(117 117 117 / var(--un-text-opacity)); +} +.text-gray-700 { + --un-text-opacity: 1; + color: rgb(66 66 66 / var(--un-text-opacity)); +} +.text-green-700 { + --un-text-opacity: 1; + color: rgb(0 153 86 / var(--un-text-opacity)); +} +.hover\\:text-\\[\\#020420\\]:hover { + --un-text-opacity: 1; + color: rgb(2 4 32 / var(--un-text-opacity)); +} +.font-bold { + font-weight: 700; +} +.font-semibold { + font-weight: 600; +} +.leading-none { + line-height: 1; +} +.font-mono { + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + Liberation Mono, + Courier New, + monospace; +} +.antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.focus-visible\\:ring-2:focus-visible { + --un-ring-width: 2px; + --un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 + var(--un-ring-offset-width) var(--un-ring-offset-color); + --un-ring-shadow: var(--un-ring-inset) 0 0 0 + calc(var(--un-ring-width) + var(--un-ring-offset-width)) + var(--un-ring-color); + box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), + var(--un-shadow); +} +.transition-all { + transition-duration: 0.15s; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +@media (prefers-color-scheme: dark) { + .dark\\:border-\\[\\#00DC82\\]\\/50 { + border-color: #00dc8280; + } + .dark\\:border-\\[\\#00DC82\\]\\/80, + .group:hover .group-hover\\:dark\\:border-\\[\\#00DC82\\]\\/80 { + border-color: #00dc82cc; + } + .dark\\:border-white\\/10 { + border-color: #ffffff1a; + } + .dark\\:border-white\\/20 { + border-color: #fff3; + } + .hover\\:dark\\:border-\\[\\#00DC82\\]:hover { + --un-border-opacity: 1; + border-color: rgb(0 220 130 / var(--un-border-opacity)); + } + .dark\\:bg-\\[\\#020420\\] { + --un-bg-opacity: 1; + background-color: rgb(2 4 32 / var(--un-bg-opacity)); + } + .dark\\:bg-white\\/5 { + background-color: #ffffff0d; + } + .dark\\:text-\\[\\#00DC82\\] { + --un-text-opacity: 1; + color: rgb(0 220 130 / var(--un-text-opacity)); + } + .dark\\:text-gray-200 { + --un-text-opacity: 1; + color: rgb(224 224 224 / var(--un-text-opacity)); + } + .dark\\:text-gray-400 { + --un-text-opacity: 1; + color: rgb(158 158 158 / var(--un-text-opacity)); + } + .dark\\:text-white { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } + .dark\\:text-white\\/40 { + color: #fff6; + } + .dark\\:hover\\:text-white:hover { + --un-text-opacity: 1; + color: rgb(255 255 255 / var(--un-text-opacity)); + } + .group:hover .group-hover\\:dark\\:text-gray-100 { + --un-text-opacity: 1; + color: rgb(238 238 238 / var(--un-text-opacity)); + } +} +@media (min-width: 640px) { + .sm\\:col-span-2 { + grid-column: span 2 / span 2; + } + .sm\\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .sm\\:mb-0 { + margin-bottom: 0; + } + .sm\\:mt-0 { + margin-top: 0; + } + .sm\\:mt-10 { + margin-top: 2.5rem; + } + .sm\\:mt-6 { + margin-top: 1.5rem; + } + .sm\\:h-12 { + height: 3rem; + } + .sm\\:gap-6 { + gap: 1.5rem; + } + .sm\\:px-2\\.5 { + padding-left: 0.625rem; + padding-right: 0.625rem; + } + .sm\\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + .sm\\:py-1\\.5 { + padding-bottom: 0.375rem; + padding-top: 0.375rem; + } + .sm\\:text-\\[14px\\] { + font-size: 14px; + } + .sm\\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } +} +@media (min-width: 1024px) { + .lg\\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } +} +" +`; + +exports[`template > produces correct output for welcome template 2`] = ` +"*, +:after, +:before { + border-color: var(--un-default-border-color, #e5e7eb); + border-style: solid; + border-width: 0; + box-sizing: border-box; +} +:after, +:before { + --un-content: ""; +} +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: + ui-sans-serif, + system-ui, + sans-serif, + Apple Color Emoji, + Segoe UI Emoji, + Segoe UI Symbol, + Noto Color Emoji; + font-feature-settings: normal; + font-variation-settings: normal; + -moz-tab-size: 4; + tab-size: 4; + -webkit-tap-highlight-color: transparent; +} +body { + line-height: inherit; + margin: 0; +} +h1, +h2 { + font-size: inherit; + font-weight: inherit; +} +a { + color: inherit; + text-decoration: inherit; +} +h1, +h2, +p, +ul { + margin: 0; +} +ul { + list-style: none; + padding: 0; +} +svg { + display: block; + vertical-align: middle; +} +*, +:after, +:before { + --un-rotate: 0; + --un-rotate-x: 0; + --un-rotate-y: 0; + --un-rotate-z: 0; + --un-scale-x: 1; + --un-scale-y: 1; + --un-scale-z: 1; + --un-skew-x: 0; + --un-skew-y: 0; + --un-translate-x: 0; + --un-translate-y: 0; + --un-translate-z: 0; + --un-pan-x: ; + --un-pan-y: ; + --un-pinch-zoom: ; + --un-scroll-snap-strictness: proximity; + --un-ordinal: ; + --un-slashed-zero: ; + --un-numeric-figure: ; + --un-numeric-spacing: ; + --un-numeric-fraction: ; + --un-border-spacing-x: 0; + --un-border-spacing-y: 0; + --un-ring-offset-shadow: 0 0 transparent; + --un-ring-shadow: 0 0 transparent; + --un-shadow-inset: ; + --un-shadow: 0 0 transparent; + --un-ring-inset: ; + --un-ring-offset-width: 0px; + --un-ring-offset-color: #fff; + --un-ring-width: 0px; + --un-ring-color: rgba(147, 197, 253, 0.5); + --un-blur: ; + --un-brightness: ; + --un-contrast: ; + --un-drop-shadow: ; + --un-grayscale: ; + --un-hue-rotate: ; + --un-invert: ; + --un-saturate: ; + --un-sepia: ; + --un-backdrop-blur: ; + --un-backdrop-brightness: ; + --un-backdrop-contrast: ; + --un-backdrop-grayscale: ; + --un-backdrop-hue-rotate: ; + --un-backdrop-invert: ; + --un-backdrop-opacity: ; + --un-backdrop-saturate: ; + --un-backdrop-sepia: ; +} +" +`; diff --git a/packages/ui-templates/test/templates.spec.ts b/packages/ui-templates/test/templates.spec.ts new file mode 100644 index 0000000000..33aa989914 --- /dev/null +++ b/packages/ui-templates/test/templates.spec.ts @@ -0,0 +1,71 @@ +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { exec } from 'tinyexec' +import { format } from 'prettier' +import { createJiti } from 'jiti' +// @ts-expect-error types not valid for bundler resolution +import { HtmlValidate } from 'html-validate' + +const distDir = fileURLToPath(new URL('../node_modules/.temp/dist/templates', import.meta.url)) + +describe('template', () => { + beforeAll(async () => { + await exec('pnpm', ['build'], { + nodeOptions: { + cwd: fileURLToPath(new URL('..', import.meta.url)), + env: { + OUTPUT_DIR: './node_modules/.temp/dist', + }, + }, + }) + }) + afterAll(() => rm(distDir, { force: true, recursive: true })) + + function formatCss (css: string) { + return format(css, { + parser: 'css', + }) + } + + const jiti = createJiti(import.meta.url) + + const validator = new HtmlValidate({ + extends: [ + 'html-validate:document', + 'html-validate:recommended', + 'html-validate:standard', + ], + rules: { + // + 'svg-focusable': 'off', + 'no-unknown-elements': 'error', + // Conflicts or not needed as we use prettier formatting + 'void-style': 'off', + 'no-trailing-whitespace': 'off', + // Conflict with Nuxt defaults + 'require-sri': 'off', + 'attribute-boolean-style': 'off', + 'doctype-style': 'off', + // Unreasonable rule + 'no-inline-style': 'off', + }, + }) + + it.each(['error-404', 'error-500', 'error-dev', 'loading', 'welcome'])('produces correct output for %s template', async (file) => { + const contents = readFileSync(`${distDir}/${file}.vue`, 'utf-8') + + const scopedStyle = contents.match(/