├── .attw.json
├── .editorconfig
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── 01-feature-suggestion.yml
│ ├── 02-bug-report.yml
│ ├── 03-documentation.yml
│ ├── 04-help-wanted.yml
│ └── config.yml
├── pull_request_template.md
├── renovate.json5
└── workflows
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .npmrc
├── .nuxtrc
├── LICENSE.md
├── README.md
├── SECURITY.md
├── build.config.ts
├── client
├── .nuxtrc
├── app.vue
├── components
│ ├── NuxtSeoLogo.vue
│ ├── OCodeBlock.vue
│ ├── OSectionBlock.vue
│ └── Source.vue
├── composables
│ ├── rpc.ts
│ ├── shiki.ts
│ └── state.ts
├── nuxt.config.ts
├── package.json
└── plugins
│ └── floating-vue.ts
├── docs
└── content
│ ├── 0.getting-started
│ ├── 0.introduction.md
│ ├── 1.installation.md
│ └── 3.troubleshooting.md
│ ├── 2.guides
│ ├── 0.data-sources.md
│ ├── 0.multi-sitemaps.md
│ ├── 1.i18n.md
│ ├── 2.dynamic-urls.md
│ ├── 2.images-videos.md
│ ├── 3.filtering-urls.md
│ ├── 3.performance.md
│ ├── 4.loc-data.md
│ ├── 5.content.md
│ ├── 5.prerendering.md
│ ├── 6.customising-ui.md
│ ├── 8.best-practices.md
│ ├── 9.chunking-sources.md
│ └── 9.submitting-sitemap.md
│ ├── 4.api
│ └── 0.config.md
│ ├── 5.nitro-api
│ └── nitro-hooks.md
│ └── 5.releases
│ ├── 4.v7.md
│ ├── 5.v6.md
│ ├── 6.v5.md
│ ├── 7.v4.md
│ └── 8.v3.md
├── eslint.config.js
├── package.json
├── patches
└── @nuxtjs__mdc.patch
├── playground
├── app.vue
├── content
│ ├── _partial.md
│ ├── bar.md
│ ├── foo.md
│ └── posts
│ │ ├── bar.md
│ │ └── foo.md
├── nuxt.config.ts
├── pages
│ ├── .ignored
│ │ └── test.vue
│ ├── [...slug].vue
│ ├── _dir
│ │ └── robots.txt
│ ├── about.vue
│ ├── api
│ │ └── foo.vue
│ ├── blocked-by-robots-txt
│ │ └── foo.vue
│ ├── blog.vue
│ ├── blog
│ │ ├── [id].vue
│ │ ├── categories.vue
│ │ ├── index.vue
│ │ ├── tags.vue
│ │ └── tags
│ │ │ ├── edit.vue
│ │ │ └── new.vue
│ ├── foo.bar.vue
│ ├── hidden-path-but-in-sitemap
│ │ └── index.vue
│ ├── hide-me.vue
│ ├── ignore-foo.vue
│ ├── index.vue
│ ├── new-page.vue
│ ├── prerender-video.vue
│ ├── prerender.vue
│ ├── secret.vue
│ └── users-[group]
│ │ ├── [id].vue
│ │ └── index.vue
├── server
│ ├── api
│ │ ├── _sitemap-urls.ts
│ │ ├── fetch.ts
│ │ ├── multi-sitemap-sources
│ │ │ ├── bar.ts
│ │ │ └── foo.ts
│ │ ├── prerendered.ts
│ │ ├── sitemap-bar.ts
│ │ ├── sitemap-foo.ts
│ │ └── sitemap-urls-to-be-confumsed-by-fetch.ts
│ ├── plugins
│ │ └── sitemap.ts
│ ├── routes
│ │ └── __sitemap.ts
│ └── tsconfig.json
└── tailwind.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── src
├── content.ts
├── devtools.ts
├── module.ts
├── prerender.ts
├── runtime
│ ├── server
│ │ ├── composables
│ │ │ ├── asSitemapUrl.ts
│ │ │ └── defineSitemapEventHandler.ts
│ │ ├── content-compat.ts
│ │ ├── kit.ts
│ │ ├── plugins
│ │ │ ├── compression.ts
│ │ │ ├── nuxt-content-v2.ts
│ │ │ └── warm-up.ts
│ │ ├── robots-polyfill
│ │ │ └── getPathRobotConfig.ts
│ │ ├── routes
│ │ │ ├── __sitemap__
│ │ │ │ ├── debug.ts
│ │ │ │ ├── nuxt-content-urls-v2.ts
│ │ │ │ └── nuxt-content-urls-v3.ts
│ │ │ ├── sitemap.xml.ts
│ │ │ ├── sitemap.xsl.ts
│ │ │ ├── sitemap
│ │ │ │ └── [sitemap].xml.ts
│ │ │ └── sitemap_index.xml.ts
│ │ ├── sitemap
│ │ │ ├── builder
│ │ │ │ ├── sitemap-index.ts
│ │ │ │ ├── sitemap.ts
│ │ │ │ └── xml.ts
│ │ │ ├── nitro.ts
│ │ │ ├── urlset
│ │ │ │ ├── normalise.ts
│ │ │ │ ├── sort.ts
│ │ │ │ └── sources.ts
│ │ │ └── utils
│ │ │ │ └── chunk.ts
│ │ ├── tsconfig.json
│ │ └── utils.ts
│ ├── types.ts
│ └── utils-pure.ts
├── utils-internal
│ ├── filter.ts
│ ├── i18n.ts
│ ├── kit.ts
│ └── nuxtSitemap.ts
└── utils
│ ├── index.ts
│ ├── parseHtmlExtractSitemapMeta.ts
│ └── parseSitemapXml.ts
├── test
├── bench
│ ├── i18n.bench.ts
│ └── normalize.bench.ts
├── e2e
│ ├── chunks
│ │ ├── cache-headers.test.ts
│ │ ├── default.ts
│ │ └── generate.test.ts
│ ├── content-v3
│ │ ├── default.test.ts
│ │ └── yaml-json.test.ts
│ ├── global-setup.ts
│ ├── hooks
│ │ └── sources-hook-simple.test.ts
│ ├── i18n
│ │ ├── custom-paths-no-prefix.test.ts
│ │ ├── custom-paths.test.ts
│ │ ├── domains.test.ts
│ │ ├── dynamic-urls.test.ts
│ │ ├── filtering-include.test.ts
│ │ ├── filtering-regexp.test.ts
│ │ ├── filtering.test.ts
│ │ ├── generate.test.ts
│ │ ├── no-prefix.test.ts
│ │ ├── pages-multi.test.ts
│ │ ├── pages.disabled-routes.test.ts
│ │ ├── pages.no-prefix.test.ts
│ │ ├── pages.only-locales.test.ts
│ │ ├── pages.prefix-and-default.test.ts
│ │ ├── pages.prefix-except-default.test.ts
│ │ ├── pages.prefix.test.ts
│ │ ├── pages.test.ts
│ │ ├── prefix-and-default.test.ts
│ │ ├── prefix-except-default.test.ts
│ │ ├── prefix-iso.test.ts
│ │ ├── prefix-simple.test.ts
│ │ ├── route-rules.test.ts
│ │ └── simple-trailing.test.ts
│ ├── multi
│ │ ├── cache-filesystem.test.ts
│ │ ├── cache-swr.test.ts
│ │ ├── chunking-edge-cases.test.ts
│ │ ├── chunking.test.ts
│ │ ├── defaults.ts
│ │ ├── endpoints.ts
│ │ └── filtering.test.ts
│ └── single
│ │ ├── baseUrl.test.ts
│ │ ├── baseUrlTrailingSlash.test.ts
│ │ ├── changeApiUrl.test.ts
│ │ ├── filtering.test.ts
│ │ ├── generate.test.ts
│ │ ├── lastmod.test.ts
│ │ ├── news.test.ts
│ │ ├── queryRoutes.test.ts
│ │ ├── routeRules.ts
│ │ ├── routeRulesTrailingSlash.test.ts
│ │ ├── sitemapName.test.ts
│ │ ├── trailingSlashes.ts
│ │ ├── urlEncoded.test.ts
│ │ ├── video.test.ts
│ │ └── xsl.test.ts
├── fixtures
│ ├── basic
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ ├── about.vue
│ │ │ ├── crawled.vue
│ │ │ ├── dynamic
│ │ │ │ └── [slug].vue
│ │ │ ├── index.vue
│ │ │ └── sub
│ │ │ │ └── page.vue
│ │ └── server
│ │ │ ├── api
│ │ │ └── sitemap
│ │ │ │ ├── bar.ts
│ │ │ │ └── foo.ts
│ │ │ └── routes
│ │ │ └── __sitemap.ts
│ ├── chunks
│ │ ├── app.vue
│ │ ├── nuxt.config.ts
│ │ └── server
│ │ │ ├── api
│ │ │ └── sitemap
│ │ │ │ ├── bar.ts
│ │ │ │ └── foo.ts
│ │ │ └── routes
│ │ │ └── __sitemap.ts
│ ├── content-v3
│ │ ├── .nuxtrc
│ │ ├── app.vue
│ │ ├── content.config.ts
│ │ ├── content
│ │ │ ├── .navigation.yml
│ │ │ ├── _partial.md
│ │ │ ├── bar.md
│ │ │ ├── foo.md
│ │ │ ├── posts
│ │ │ │ ├── .navigation.yml
│ │ │ │ ├── bar.md
│ │ │ │ ├── fallback.md
│ │ │ │ └── foo.md
│ │ │ ├── test-json.json
│ │ │ └── test-yaml.yml
│ │ ├── nuxt.config.ts
│ │ └── pages
│ │ │ └── [...slug].vue
│ ├── generate
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ ├── about.vue
│ │ │ ├── crawled.vue
│ │ │ ├── dynamic
│ │ │ │ └── [slug].vue
│ │ │ ├── index.vue
│ │ │ ├── noindex.vue
│ │ │ └── sub
│ │ │ │ └── page.vue
│ │ └── server
│ │ │ ├── api
│ │ │ └── sitemap
│ │ │ │ ├── bar.ts
│ │ │ │ └── foo.ts
│ │ │ └── routes
│ │ │ └── __sitemap.ts
│ ├── hooks
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ └── index.vue
│ │ └── server
│ │ │ ├── plugins
│ │ │ └── sitemap.ts
│ │ │ └── routes
│ │ │ └── __sitemap.ts
│ ├── i18n-micro
│ │ ├── locales
│ │ │ ├── en.ts
│ │ │ ├── hr.ts
│ │ │ ├── ja.ts
│ │ │ ├── nl.ts
│ │ │ └── zh.ts
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ ├── dynamic
│ │ │ │ └── [page].vue
│ │ │ ├── index.vue
│ │ │ └── test.vue
│ │ └── server
│ │ │ └── routes
│ │ │ ├── __sitemap.ts
│ │ │ └── i18n-urls.ts
│ ├── i18n-no-prefix
│ │ ├── locales
│ │ │ ├── en.ts
│ │ │ ├── hr.ts
│ │ │ ├── ja.ts
│ │ │ ├── nl.ts
│ │ │ └── zh.ts
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ ├── dynamic
│ │ │ │ └── [page].vue
│ │ │ ├── index.vue
│ │ │ └── test.vue
│ │ └── server
│ │ │ └── routes
│ │ │ ├── __sitemap.ts
│ │ │ └── i18n-urls.ts
│ ├── i18n
│ │ ├── locales
│ │ │ ├── en.ts
│ │ │ ├── hr.ts
│ │ │ ├── ja.ts
│ │ │ ├── nl.ts
│ │ │ └── zh.ts
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ │ ├── dynamic
│ │ │ │ └── [page].vue
│ │ │ ├── index.vue
│ │ │ ├── no-i18n.vue
│ │ │ └── test.vue
│ │ └── server
│ │ │ └── routes
│ │ │ ├── __sitemap.ts
│ │ │ └── i18n-urls.ts
│ ├── multi-with-chunks
│ │ ├── app.vue
│ │ ├── nuxt.config.ts
│ │ └── server
│ │ │ └── api
│ │ │ ├── posts.ts
│ │ │ └── products.ts
│ ├── no-pages
│ │ ├── app.vue
│ │ └── nuxt.config.ts
│ └── sources-hook
│ │ ├── nuxt.config.ts
│ │ ├── pages
│ │ └── index.vue
│ │ └── server
│ │ ├── api
│ │ ├── dynamic-source.ts
│ │ └── initial-source.ts
│ │ └── plugins
│ │ └── sources-hook.ts
└── unit
│ ├── i18n-disabled-routes.test.ts
│ ├── i18n.test.ts
│ ├── lastmod.test.ts
│ ├── normalise.test.ts
│ ├── parseHtmlExtractSitemapMeta.test.ts
│ ├── parsePages.test.ts
│ ├── parseSitemapXml.test.ts
│ ├── sorting.test.ts
│ └── sourcesHook.test.ts
├── tsconfig.json
├── virtual.d.ts
└── vitest.config.ts
/.attw.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignoreRules": ["cjs-resolves-to-esm", "false-export-default", "false-esm"]
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_size = 2
5 | indent_style = space
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [harlan-zw]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/01-feature-suggestion.yml:
--------------------------------------------------------------------------------
1 | name: 🆕 Feature suggestion
2 | description: Suggest an idea!
3 | title: 'feat: '
4 | labels: [enhancement]
5 | body:
6 | - type: textarea
7 | validations:
8 | required: true
9 | attributes:
10 | label: 🆒 Your use case
11 | description: Add a description of your use case, and how this feature would help you.
12 | placeholder: When I do [...] I would expect to be able to do [...]
13 | - type: textarea
14 | validations:
15 | required: true
16 | attributes:
17 | label: 🆕 The solution you'd like
18 | description: Describe what you want to happen.
19 | - type: textarea
20 | attributes:
21 | label: 🔍 Alternatives you've considered
22 | description: Have you considered any alternative solutions or features?
23 | - type: textarea
24 | attributes:
25 | label: ℹ️ Additional info
26 | description: Is there any other context you think would be helpful to know?
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/02-bug-report.yml:
--------------------------------------------------------------------------------
1 | name: 🐛 Bug report
2 | description: Something's not working
3 | title: 'fix: '
4 | labels: [bug]
5 | body:
6 | - type: textarea
7 | validations:
8 | required: true
9 | attributes:
10 | label: 🐛 The bug
11 | description: What isn't working? Describe what the bug is.
12 | - type: input
13 | validations:
14 | required: true
15 | attributes:
16 | label: 🛠️ To reproduce
17 | description: 'A reproduction of the bug. Check out the Stackblitz starters https://nuxtseo.com/docs/sitemap/getting-started/troubleshooting#submitting-an-issue'
18 | placeholder: https://stackblitz.com/[...]
19 | - type: textarea
20 | validations:
21 | required: true
22 | attributes:
23 | label: 🌈 Expected behavior
24 | description: What did you expect to happen? Is there a section in the docs about this?
25 | - type: textarea
26 | attributes:
27 | label: ℹ️ Additional context
28 | description: Add any other context about the problem here.
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/03-documentation.yml:
--------------------------------------------------------------------------------
1 | name: 📚 Documentation
2 | description: How do I ... ?
3 | title: 'docs: '
4 | labels: [documentation]
5 | body:
6 | - type: textarea
7 | validations:
8 | required: true
9 | attributes:
10 | label: 📚 Is your documentation request related to a problem?
11 | description: A clear and concise description of what the problem is.
12 | placeholder: I feel I should be able to [...] but I can't see how to do it from the docs.
13 | - type: textarea
14 | attributes:
15 | label: 🔍 Where should you find it?
16 | description: What page of the docs do you expect this information to be found on?
17 | - type: textarea
18 | attributes:
19 | label: ℹ️ Additional context
20 | description: Add any other context or information.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/04-help-wanted.yml:
--------------------------------------------------------------------------------
1 | name: 🆘 Help
2 | description: I need help with ...
3 | title: 'help: '
4 | labels: [help wanted]
5 | body:
6 | - type: textarea
7 | validations:
8 | required: true
9 | attributes:
10 | label: 📚 What are you trying to do?
11 | description: A clear and concise description of your objective.
12 | placeholder: I'm not sure how to [...].
13 | - type: textarea
14 | attributes:
15 | label: 🔍 What have you tried?
16 | description: Have you looked through the docs? Tried different approaches? The more detail the better.
17 | - type: textarea
18 | attributes:
19 | label: ℹ️ Additional context
20 | description: Add any other context or information.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | contact_links:
2 | - name: 📖 Documentation
3 | url: https://nuxtseo.com/sitemap/getting-started/installation
4 | about: Check the documentation for guides and examples.
5 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | ### 🔗 Linked issue
6 |
7 |
8 |
9 | ### ❓ Type of change
10 |
11 |
12 |
13 | - [ ] 📖 Documentation (updates to the documentation or readme)
14 | - [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
15 | - [ ] 👌 Enhancement (improving an existing functionality)
16 | - [ ] ✨ New feature (a non-breaking change that adds functionality)
17 | - [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
18 | - [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)
19 |
20 | ### 📚 Description
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/renovate.json5:
--------------------------------------------------------------------------------
1 | {
2 | // https://github.com/nuxt/renovate-config-nuxt
3 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
4 | "extends": ["github>nuxt/renovate-config-nuxt"]
5 | }
6 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | permissions:
4 | contents: write
5 | id-token: write
6 |
7 | on:
8 | push:
9 | tags:
10 | - 'v*'
11 |
12 | jobs:
13 | release:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v5
17 | with:
18 | fetch-depth: 0
19 |
20 | - name: Install pnpm
21 | uses: pnpm/action-setup@v4
22 |
23 | - name: Set node
24 | uses: actions/setup-node@v5
25 | with:
26 | node-version: latest
27 | cache: pnpm
28 | registry-url: 'https://registry.npmjs.org'
29 |
30 | - name: Force Set pnpm Registry
31 | run: pnpm config set registry https://registry.npmjs.org
32 |
33 | - run: npx changelogithub
34 | env:
35 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
36 |
37 | - name: Install Dependencies
38 | run: pnpm i
39 |
40 | - run: pnpm publish -r --access public --no-git-checks
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | permissions: {contents: read}
4 |
5 | on:
6 | push:
7 | branches:
8 | - main
9 | paths-ignore:
10 | - '**/README.md'
11 | pull_request_target:
12 | branches:
13 | - main
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 | - uses: actions/checkout@v5
21 |
22 | - name: Install pnpm
23 | uses: pnpm/action-setup@v4.1.0
24 |
25 | - name: Use Node.js 22.x
26 | uses: actions/setup-node@v5
27 | with:
28 | node-version: 22.x
29 | registry-url: https://registry.npmjs.org/
30 | cache: pnpm
31 |
32 | - run: pnpm i
33 |
34 | - name: Build
35 | run: pnpm run build
36 |
37 | - name: Test
38 | run: pnpm run test
39 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 |
4 | # Logs
5 | *.log*
6 |
7 | # Temp directories
8 | .temp
9 | .tmp
10 | .cache
11 |
12 | # Yarn
13 | **/.yarn/cache
14 | **/.yarn/*state*
15 |
16 | playground/nuxt-runtime
17 |
18 | # Generated dirs
19 | dist
20 |
21 | # Nuxt
22 | .nuxt
23 | .output
24 | .vercel_build_output
25 | .build-*
26 | .env
27 | .netlify
28 |
29 | # Env
30 | .env
31 |
32 | # Testing
33 | reports
34 | coverage
35 | *.lcov
36 | .nyc_output
37 |
38 | # VSCode
39 | .vscode
40 |
41 | # Intellij idea
42 | *.iml
43 | .idea
44 |
45 | # OSX
46 | .DS_Store
47 | .AppleDouble
48 | .LSOverride
49 | .AppleDB
50 | .AppleDesktop
51 | Network Trash Folder
52 | Temporary Items
53 | .apdisk
54 |
55 | .data
56 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 |
--------------------------------------------------------------------------------
/.nuxtrc:
--------------------------------------------------------------------------------
1 | imports.autoImport=false
2 | typescript.includeWorkspace=true
3 | modules[]=@nuxtjs/sitemap
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Harlan Wilton
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
@nuxtjs/sitemap
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![License][license-src]][license-href]
6 | [![Nuxt][nuxt-src]][nuxt-href]
7 |
8 | Nuxt Sitemap is a module for generating best-practice XML sitemaps that are consumed by the robots crawling your site.
9 |
10 | New to XML sitemaps or SEO? Check out the [Controlling Web Crawlers](https://nuxtseo.com/learn/controlling-crawlers) guide to learn more about why you might
11 | need these.
12 |
13 |
14 |
21 |
22 |
23 | ## Features
24 |
25 | - 🌴 Single `/sitemap.xml` or multiple `/posts-sitemap.xml`, `/pages-sitemap.xml`
26 | - 📊 Fetch your sitemap URLs from anywhere
27 | - 😌 Automatic `lastmod`, image discovery and best practice sitemaps
28 | - 🔄 SWR caching, route rules support
29 | - 🎨 Debug using the Nuxt DevTools integration or the XML Stylesheet
30 | - 🤝 Integrates seamlessly with [Nuxt I18n](https://github.com/nuxt-modules/i18n) and [Nuxt Content](https://github.com/nuxt/content)
31 |
32 | ## Installation
33 |
34 | 💡 Using Nuxt 2? Use the [nuxt-community/sitemap-module](https://github.com/nuxt-community/sitemap-module) docs.
35 |
36 | Install `@nuxtjs/sitemap` dependency to your project:
37 |
38 | ```bash
39 | npx nuxi@latest module add sitemap
40 | ```
41 |
42 | 💡 Need a complete SEO solution for Nuxt? Check out [Nuxt SEO](https://nuxtseo.com).
43 |
44 | ## Documentation
45 |
46 | [📖 Read the full documentation](https://nuxtseo.com/sitemap) for more information.
47 |
48 | ## Demos
49 |
50 | - [Dynamic URLs](https://stackblitz.com/edit/nuxt-starter-dyraxc?file=server%2Fapi%2F_sitemap-urls.ts)
51 | - [i18n](https://stackblitz.com/edit/nuxt-starter-jwuie4?file=app.vue)
52 | - [Manual Chunking](https://stackblitz.com/edit/nuxt-starter-umyso3?file=nuxt.config.ts)
53 | - [Nuxt Content Document Driven](https://stackblitz.com/edit/nuxt-starter-a5qk3s?file=nuxt.config.ts)
54 |
55 | ## Sponsors
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | ## License
64 |
65 | Licensed under the [MIT license](https://github.com/nuxt-modules/sitemap/blob/main/LICENSE.md).
66 |
67 |
68 | [npm-version-src]: https://img.shields.io/npm/v/@nuxtjs/sitemap/latest.svg?style=flat&colorA=18181B&colorB=28CF8D
69 | [npm-version-href]: https://npmjs.com/package/@nuxtjs/sitemap
70 |
71 | [npm-downloads-src]: https://img.shields.io/npm/dm/@nuxtjs/sitemap.svg?style=flat&colorA=18181B&colorB=28CF8D
72 | [npm-downloads-href]: https://npmjs.com/package/@nuxtjs/sitemap
73 |
74 | [license-src]: https://img.shields.io/github/license/nuxt-modules/sitemap.svg?style=flat&colorA=18181B&colorB=28CF8D
75 | [license-href]: https://github.com/nuxt-modules/sitemap/blob/main/LICENSE.md
76 |
77 | [nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js
78 | [nuxt-href]: https://nuxt.com
79 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | I take the security of my Nuxt modules seriously. If you believe you've found a security vulnerability, please follow these steps:
6 |
7 | ### Option 1: GitHub Security Advisory
8 |
9 | 1. Go to the GitHub repository of the affected module
10 | 2. Navigate to "Security" tab
11 | 3. Select "Report a vulnerability"
12 | 4. Provide a detailed description of the vulnerability
13 |
14 | ### Option 2: Email
15 |
16 | Alternatively, you can email security concerns directly to:
17 | - harlan@harlanzw.com
18 |
19 | ## What to Include in Your Report
20 |
21 | Please include:
22 |
23 | - Description of the vulnerability
24 | - Steps to reproduce
25 | - Potential impact
26 | - Any possible mitigations you've identified
27 |
28 | ## Response Process
29 |
30 | When a vulnerability is reported:
31 |
32 | 1. I will acknowledge receipt within 48 hours
33 | 2. I will validate and investigate the report
34 | 3. I will work on a fix and coordinate the release process
35 | 4. After the fix is released, I will acknowledge your contribution (if desired)
36 |
37 | ## Scope
38 |
39 | This security policy applies to all my Nuxt modules as published on npm.
40 |
41 | Thank you for helping keep the Nuxt ecosystem secure!
42 |
--------------------------------------------------------------------------------
/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild'
2 |
3 | export default defineBuildConfig({
4 | declaration: true,
5 | entries: [
6 | { input: 'src/content', name: 'content' },
7 | { input: 'src/utils', name: 'utils' },
8 | ],
9 | externals: [
10 | // needed for content subpath export
11 | '@nuxt/content',
12 | 'zod',
13 | ],
14 | })
15 |
--------------------------------------------------------------------------------
/client/.nuxtrc:
--------------------------------------------------------------------------------
1 | imports.autoImport=true
2 |
--------------------------------------------------------------------------------
/client/components/OCodeBlock.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
29 |
30 |
31 |
36 |
--------------------------------------------------------------------------------
/client/components/OSectionBlock.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
34 |
38 |
45 |
46 |
47 |
48 | {{ text }}
49 |
50 |
51 |
56 |
57 | {{ description }}
58 |
59 |
60 |
61 |
62 |
63 |
74 |
75 |
76 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
116 |
--------------------------------------------------------------------------------
/client/components/Source.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
32 |
36 |
40 | {{ source.timeTakenMs }}ms
41 |
42 |
43 |
44 | {{ source.context.name }}
45 |
46 |
47 | {{ source.urls?.length || 0 }}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
59 | {{ source.fetch }}
60 |
61 |
62 |
66 | {{ source.context.description }}
67 |
68 |
69 |
70 |
71 | {{ source.error }}
75 |
76 |
82 |
86 |
87 | Hints
88 |
89 |
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/client/composables/rpc.ts:
--------------------------------------------------------------------------------
1 | import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client'
2 | import type { $Fetch } from 'nitropack'
3 | import { ref, watchEffect } from 'vue'
4 | import type { NuxtDevtoolsClient } from '@nuxt/devtools-kit/types'
5 | import { refreshSources } from './state'
6 |
7 | export const appFetch = ref<$Fetch>()
8 |
9 | export const devtools = ref()
10 |
11 | export const colorMode = ref<'dark' | 'light'>()
12 |
13 | onDevtoolsClientConnected(async (client) => {
14 | appFetch.value = client.host.app.$fetch
15 | watchEffect(() => {
16 | colorMode.value = client.host.app.colorMode.value
17 | })
18 | devtools.value = client.devtools
19 | refreshSources()
20 | })
21 |
--------------------------------------------------------------------------------
/client/composables/shiki.ts:
--------------------------------------------------------------------------------
1 | import type { HighlighterCore } from 'shiki'
2 | import { createHighlighterCore } from 'shiki/core'
3 | import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
4 | import { computed, ref, toValue } from 'vue'
5 | import type { MaybeRef } from '@vueuse/core'
6 | import { devtools } from './rpc'
7 |
8 | export const shiki = ref()
9 |
10 | export function loadShiki() {
11 | // Only loading when needed
12 | return createHighlighterCore({
13 | themes: [
14 | import('@shikijs/themes/vitesse-light'),
15 | import('@shikijs/themes/vitesse-dark'),
16 | ],
17 | langs: [
18 | import('@shikijs/langs/json'),
19 | ],
20 | engine: createJavaScriptRegexEngine(),
21 | }).then((i) => {
22 | shiki.value = i
23 | })
24 | }
25 |
26 | export function renderCodeHighlight(code: MaybeRef, lang: 'json') {
27 | return computed(() => {
28 | const colorMode = devtools.value?.colorMode || 'light'
29 | return shiki.value!.codeToHtml(toValue(code), {
30 | lang,
31 | theme: colorMode === 'dark' ? 'vitesse-dark' : 'vitesse-light',
32 | }) || ''
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/client/composables/state.ts:
--------------------------------------------------------------------------------
1 | import { ref } from 'vue'
2 | import type { ModuleRuntimeConfig, SitemapDefinition, SitemapSourceResolved } from '../../src/runtime/types'
3 | import { appFetch } from './rpc'
4 |
5 | export const data = ref<{
6 | nitroOrigin: string
7 | globalSources: SitemapSourceResolved[]
8 | sitemaps: SitemapDefinition[]
9 | runtimeConfig: ModuleRuntimeConfig
10 | } | null>(null)
11 |
12 | export async function refreshSources() {
13 | if (appFetch.value)
14 | data.value = await appFetch.value('/__sitemap__/debug.json')
15 | }
16 |
--------------------------------------------------------------------------------
/client/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'pathe'
2 | import DevtoolsUIKit from '@nuxt/devtools-ui-kit'
3 |
4 | export default defineNuxtConfig({
5 | modules: [
6 | DevtoolsUIKit,
7 | ],
8 |
9 | ssr: false,
10 |
11 | devtools: {
12 | enabled: false,
13 | },
14 |
15 | app: {
16 | baseURL: '/__sitemap__/devtools',
17 | },
18 |
19 | compatibilityDate: '2025-03-13',
20 |
21 | nitro: {
22 | output: {
23 | publicDir: resolve(__dirname, '../dist/client'),
24 | },
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@nuxtjs/sitemap-client",
3 | "private": true,
4 | "devDependencies": {
5 | "@iconify-json/carbon": "^1.2.14",
6 | "@nuxt/devtools-kit": "^2.6.5",
7 | "@nuxt/devtools-ui-kit": "^2.6.5",
8 | "@nuxt/kit": "^4.1.3",
9 | "floating-vue": "^5.2.2",
10 | "nuxt": "^4.1.3",
11 | "shiki": "^3.13.0",
12 | "vue": "^3.5.22",
13 | "vue-router": "^4.6.3"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/client/plugins/floating-vue.ts:
--------------------------------------------------------------------------------
1 | import FloatingVue from 'floating-vue'
2 | import { defineNuxtPlugin } from '#imports'
3 |
4 | export default defineNuxtPlugin((nuxtApp) => {
5 | nuxtApp.vueApp.use(FloatingVue)
6 | })
7 |
--------------------------------------------------------------------------------
/docs/content/0.getting-started/0.introduction.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Nuxt Sitemap'
3 | description: 'Powerfully flexible XML Sitemaps that integrate seamlessly, for Nuxt.'
4 | navigation:
5 | title: 'Introduction'
6 | ---
7 |
8 | ## Why use Nuxt Sitemap?
9 |
10 | Nuxt Sitemap is a module for generating XML Sitemaps with minimal configuration and best practice defaults.
11 |
12 | The core output of this module is a [sitemap.xml](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview) file, which is used by search engines to understand the structure of your site and index it more effectively.
13 |
14 | While it's not required to have a sitemap, it can be a powerful tool in getting your content indexed more frequently and more accurately,
15 | especially for larger sites or sites with complex structures.
16 |
17 | While it's simple to create your own sitemap.xml file, it can be time-consuming to keep it up-to-date with your site's content
18 | and easy to miss best practices.
19 |
20 | Nuxt Sitemap automatically generates the sitemap for you based on your site's content, including lastmod, image discovery and more.
21 |
22 | Ready to get started? Check out the [installation guide](/docs/sitemap/getting-started/installation) or learn more on the [Controlling Web Crawlers](https://nuxtseo.com/learn/controlling-crawlers) guide.
23 |
24 | ## Features
25 |
26 | - 🌴 Single /sitemap.xml or multiple /posts-sitemap.xml, /pages-sitemap.xml
27 | - 📊 Fetch your sitemap URLs from anywhere
28 | - 😌 Automatic lastmod, image discovery and best practice sitemaps
29 | - 🔄 SWR caching, route rules support
30 | - 🎨 Debug using the Nuxt DevTools integration or the XML Stylesheet
31 | - 🤝 Integrates seamlessly with Nuxt I18n and Nuxt Content
32 |
--------------------------------------------------------------------------------
/docs/content/0.getting-started/1.installation.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Install Nuxt Sitemap'
3 | description: 'Get started with Nuxt Sitemap by installing the dependency to your project.'
4 | navigation:
5 | title: 'Installation'
6 | ---
7 |
8 | ## Setup Module
9 |
10 | Want to know why you might need this module? Check out the [introduction](/docs/sitemap/getting-started/introduction).
11 |
12 | To get started with Nuxt Sitemap, you need to install the dependency and add it to your Nuxt config.
13 |
14 | :ModuleInstall{name="@nuxtjs/sitemap"}
15 |
16 | ## Verifying Installation
17 |
18 | After you've set up the module with the minimal config, you should be able to visit [`/sitemap.xml`](http://localhost:3000/sitemap.xml) to see the generated sitemap.
19 |
20 | You may notice that the URLs point to your `localhost` domain, this is to make navigating your local site easier, and will be updated when you deploy your site.
21 |
22 | All pages preset are discovered from your [Application Sources](/docs/sitemap/getting-started/data-sources), for dynamic URLs see [Dynamic URLs](/docs/sitemap/guides/dynamic-urls).
23 |
24 | You can debug this further in Nuxt DevTools under the Sitemap tab.
25 |
26 | ## Configuration
27 |
28 | At a minimum the module requires a Site URL to be set, this is to only your canonical domain is being used for
29 | the sitemap. A site name can also be provided to customize the sitemap [stylesheet](/docs/sitemap/guides/customising-ui).
30 |
31 | :SiteConfigQuickSetup
32 |
33 | To ensure search engines find your sitemap, you will need to add it to your robots.txt. It's recommended to use the [Nuxt Robots](/docs/robots/getting-started/installation) module for this.
34 |
35 | :ModuleCard{slug="robots" class="w-1/2"}
36 |
37 | Every site is different and will require their own further unique configuration, to give you a head start:
38 |
39 | - [Dynamic URL Endpoint](/docs/sitemap/guides/dynamic-urls) - If you have dynamic URLs you need to add to the sitemap, you can use a runtime API endpoint. For example, if your
40 | generating your site from a CMS.
41 | - [Multi Sitemaps](/docs/sitemap/guides/multi-sitemaps) - If you have 10k+ pages, you may want to split your sitemap into multiple files
42 | so that search engines can process them more efficiently.
43 |
44 | You do not need to worry about any further configuration in most cases, check the [best practices](/docs/sitemap/guides/best-practices) guide for more information.
45 |
46 | ## Next Steps
47 |
48 | You've successfully installed Nuxt Sitemap and configured it for your project.
49 |
50 | Documentation is provided for module integrations, check them out if you're using them.
51 | - [Nuxt I18n](/docs/sitemap/guides/i18n) - Automatic locale sitemaps.
52 | - [Nuxt Content](/docs/sitemap/guides/content) - Configure your sitemap entry from your markdown.
53 |
54 | Once you're ready to go live, check out the [Submitting Your Sitemap](/docs/sitemap/guides/submitting-sitemap) guide.
55 |
--------------------------------------------------------------------------------
/docs/content/0.getting-started/3.troubleshooting.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: "Troubleshooting Nuxt Sitemap"
3 | description: Create minimal reproductions for Nuxt Sitemap or just experiment with the module.
4 | navigation:
5 | title: 'Troubleshooting'
6 | ---
7 |
8 | ## Debugging
9 |
10 | ### Nuxt DevTools
11 |
12 | The best tool for debugging is the Nuxt DevTools integration with Nuxt Sitemap.
13 |
14 | This will show you all of your sitemaps and the sources used to generate it.
15 |
16 | ### Debug Endpoint
17 |
18 | If you prefer looking at the raw data, you can use the debug endpoint. This is only enabled in
19 | development unless you enable the `debug` option.
20 |
21 | Visit `/__sitemap__/debug.json` within your browser, this is the same data used by Nuxt DevTools.
22 |
23 | ### Debugging Prerendering
24 |
25 | If you're trying to debug the prerendered sitemap, you should enable the `debug` option and check your output
26 | for the file `.output/public/__sitemap__/debug.json`.
27 |
28 | ## Submitting an Issue
29 |
30 | When submitting an issue, it's important to provide as much information as possible.
31 |
32 | The easiest way to do this is to create a minimal reproduction using the Stackblitz playgrounds:
33 |
34 | - [Dynamic URLs](https://stackblitz.com/edit/nuxt-starter-dyraxc?file=server%2Fapi%2F_sitemap-urls.ts)
35 | - [i18n](https://stackblitz.com/edit/nuxt-starter-jwuie4?file=app.vue)
36 | - [Manual Chunking](https://stackblitz.com/edit/nuxt-starter-umyso3?file=nuxt.config.ts)
37 | - [Nuxt Content Document Driven](https://stackblitz.com/edit/nuxt-starter-a5qk3s?file=nuxt.config.ts)
38 |
39 | ## Troubleshooting FAQ
40 |
41 | ### Why is my browser not rendering the XML properly?
42 |
43 | When disabling the [XSL](/docs/sitemap/guides/customising-ui#disabling-the-xls) (XML Stylesheet) in, the XML will
44 | be rendered by the browser.
45 |
46 | If you have a i18n integration, then it's likely you'll see your sitemap look raw text instead of XML.
47 |
48 | 
49 |
50 | This is a [browser bug](https://bugs.chromium.org/p/chromium/issues/detail?id=580033) in parsing the `xhtml` namespace which is required to add localised URLs to your sitemap.
51 | There is no workaround besides re-enabled the XSL.
52 |
53 | ### Google Search Console shows Error when submitting my Sitemap?
54 |
55 | Seeing "Error" when submitting a new sitemap is common. This is because Google previously
56 | crawled your site for a sitemap and found nothing.
57 |
58 | If your sitemap is [validating](https://www.xml-sitemaps.com/validate-xml-sitemap.html) correctly, then you're all set.
59 | It's best to way a few days and check back. In nearly all cases, the error will resolve itself.
60 |
--------------------------------------------------------------------------------
/docs/content/2.guides/3.filtering-urls.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Disabling Indexing
3 | description: How to filter the URLs generated from application sources.
4 | ---
5 |
6 | ## Introduction
7 |
8 | When viewing your sitemap.xml for the first time, you may notice some URLs you don't want to be included.
9 | These URLs are most likely coming from [Application Sources](/docs/sitemap/getting-started/data-sources).
10 |
11 | If you don't want to disable these sources but want to remove these URLs you have a couple of options.
12 |
13 | ## Nuxt Robots
14 |
15 | The easiest way to block search engines from indexing a URL is to use the [Nuxt Robots](/docs/robots/getting-started/installation) module
16 | and simply block the URL in your robots.txt.
17 |
18 | :ModuleCard{slug="robots" class="w-1/2"}
19 |
20 | Nuxt Sitemap will honour any blocked pages from being ignored in the sitemap.
21 |
22 | ## Disabling indexing with Route Rules
23 |
24 | If you don't want a page in your sitemap because you don't want search engines to crawl it,
25 | then you can make use of the `robots` route rule.
26 |
27 | ### Disabling indexing for a pattern of URLs
28 |
29 | If you have a pattern of URLs that you want hidden from search you can use route rules.
30 |
31 | ```ts [nuxt.config.ts]
32 | export default defineNuxtConfig({
33 | routeRules: {
34 | // Don't add any /secret/** URLs to the sitemap.xml
35 | '/secret/**': { robots: false },
36 | }
37 | })
38 | ```
39 |
40 | ### Inline route rules
41 |
42 | If you just have some specific pages, you can use the experimental [`defineRouteRules`](https://nuxt.com/docs/api/utils/define-route-rules), which must
43 | be enabled.
44 |
45 | ```vue
46 |
51 | ```
52 |
53 | ## Filter URLs with include / exclude
54 |
55 | For all other cases, you can use the `include` and `exclude` module options to filter URLs.
56 |
57 | ```ts [nuxt.config.ts]
58 | export default defineNuxtConfig({
59 | sitemap: {
60 | // exclude all URLs that start with /secret
61 | exclude: ['/secret/**'],
62 | // include all URLs that start with /public
63 | include: ['/public/**'],
64 | }
65 | })
66 | ```
67 |
68 | Either option supports either an array of strings, RegExp objects or a `{ regex: string }` object.
69 |
70 | Providing strings will use the [route rules path matching](https://nuxt.com/docs/guide/concepts/rendering#hybrid-rendering) which
71 | does not support variable path segments in front of static ones.
72 |
73 | For example, `/foo/**` will work but `/foo/**/bar` will not. To get around this you should use regex.
74 |
75 | ### Regex Filtering
76 |
77 | Filtering using regex is more powerful and can be used to match more complex patterns. It's recommended to pass a
78 | `RegExp` object explicitly.
79 |
80 | ```ts [nuxt.config.ts]
81 | export default defineNuxtConfig({
82 | sitemap: {
83 | exclude: [
84 | // exclude /foo/**/bar using regex
85 | new RegExp('/foo/.*/bar')
86 | ],
87 | }
88 | })
89 | ```
90 |
--------------------------------------------------------------------------------
/docs/content/2.guides/3.performance.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Sitemap Performance
3 | description: Use the default cache engine to keep your sitemaps fast.
4 | ---
5 |
6 | ## Introduction
7 |
8 | For apps with 100k+ pages, generating a sitemap can be a slow process. As robots will request your sitemap frequently, it's important to keep it fast.
9 |
10 | Nuxt SEO provides a default cache engine to keep your sitemaps fast and recommendations on how to improve performance.
11 |
12 | ## Performance Recommendations
13 |
14 | When dealing with many URLs that are being generated from an external API, the best option is use the `sitemaps`
15 | option to create [Named Sitemap Chunks](/docs/sitemap/guides/multi-sitemaps).
16 |
17 | Each sitemap should contain its own `sources`, this allows other sitemaps to be generated without waiting for this request.
18 |
19 | ```ts
20 | export default defineNuxtConfig({
21 | sitemap: {
22 | sitemaps: {
23 | posts: {
24 | sources: [
25 | 'https://api.something.com/urls'
26 | ]
27 | },
28 | },
29 | },
30 | })
31 | ```
32 |
33 | If you need to split this up further, you should consider chunking by the type and some pagination format. For example,
34 | you can paginate by when posts were created.
35 |
36 | ```ts
37 | export default defineNuxtConfig({
38 | sitemap: {
39 | sitemaps: {
40 | posts2020: {
41 | sources: [
42 | 'https://api.something.com/urls?filter[yearCreated]=2020'
43 | ]
44 | },
45 | posts2021: {
46 | sources: [
47 | 'https://api.something.com/urls?filter[yearCreated]=2021'
48 | ]
49 | },
50 | },
51 | },
52 | })
53 | ```
54 |
55 | Additionally, you may want to consider the following experimental options that may help with performance:
56 | - `experimentalCompression` - Gzip's and streams the sitemap
57 | - `experimentalWarmUp` - Creates the sitemaps when Nitro starts
58 |
59 | ## Sitemap Caching
60 |
61 | Caching your sitemap can help reduce the load on your server and improve performance.
62 |
63 | By default, SWR caching is enabled on production environments and sitemaps will be cached for 10 minutes.
64 |
65 | This is configured by overriding your route rules and leveraging the native Nuxt caching.
66 |
67 | ### Cache Time
68 |
69 | You can change the cache time by setting the `cacheMaxAgeSeconds` option.
70 |
71 | ```ts
72 | export default defineNuxtConfig({
73 | sitemap: {
74 | cacheMaxAgeSeconds: 3600 // 1 hour
75 | }
76 | })
77 | ```
78 |
79 | If you want to disable caching, set the `cacheMaxAgeSeconds` to `0`.
80 |
81 | ### Cache Driver
82 |
83 | The cache engine is set to the Nitro default of the `cache/` path.
84 |
85 | If you want to customise the cache engine, you can set the `runtimeCacheStorage` option.
86 |
87 | ```ts [nuxt.config.ts]
88 | export default defineNuxtConfig({
89 | sitemap: {
90 | // cloudflare kv binding example
91 | runtimeCacheStorage: {
92 | driver: 'cloudflare-kv-binding',
93 | binding: 'OG_IMAGE_CACHE'
94 | }
95 | }
96 | })
97 | ```
98 |
--------------------------------------------------------------------------------
/docs/content/2.guides/4.loc-data.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Lastmod, Priority, and Changefreq
3 | description: Configure your sitemap entries with route rules.
4 | ---
5 |
6 | ## Introduction
7 |
8 | Changing the `` entry data can be useful for a variety of reasons, such as changing the `changefreq`, `priority`, or `lastmod` values.
9 |
10 | If you're using [Dynamic URLs](/docs/sitemap/guides/dynamic-urls), you can modify the data in the `sitemap` object, otherwise, you will
11 | need to override the [app sources](/docs/sitemap/getting-started/data-sources) directly.
12 |
13 | While modifying these in most cases may be unnecessary, see [Best Practices](/docs/sitemap/guides/best-practices), it can be useful when used right.
14 |
15 | ## Setting Defaults
16 |
17 | While this is not recommended, in special circumstances you may wish to set defaults for your sitemap entries:
18 |
19 | ```ts [nuxt.config.ts]
20 | export default defineNuxtConfig({
21 | sitemap: {
22 | defaults: {
23 | lastmod: new Date().toISOString(),
24 | priority: 0.5,
25 | changefreq: 'weekly'
26 | }
27 | }
28 | })
29 | ```
30 |
31 | ## Data Source Merging
32 |
33 | You can provide the page you want to set the `lastmod`, `priority`, or `changefreq` for in your app sources, which includes
34 | the `urls` config.
35 |
36 | ```ts [nuxt.config.ts]
37 | export default defineNuxtConfig({
38 | sitemap: {
39 | urls: [
40 | {
41 | loc: '/about',
42 | lastmod: '2023-01-01',
43 | priority: 0.3,
44 | changefreq: 'daily'
45 | }
46 | ]
47 | }
48 | })
49 | ```
50 |
51 | ## Modify Loc Data With Route Rules
52 |
53 | To change the behaviour of your sitemap URLs, you can use [Route rules](https://nuxt.com/docs/api/configuration/nuxt-config/#routerules).
54 |
55 | ```ts [nuxt.config.ts]
56 | export default defineNuxtConfig({
57 | routeRules: {
58 | // Don't add any /secret/** URLs to the sitemap.xml
59 | '/secret/**': { robots: false },
60 | // modify the sitemap.xml entry for specific URLs
61 | '/about': { sitemap: { changefreq: 'daily', priority: 0.3 } }
62 | }
63 | })
64 | ```
65 |
66 | Alternatively, you can use the experimental macro [`defineRouteRules`](https://nuxt.com/docs/api/utils/define-route-rules), which must
67 | be enabled.
68 |
69 | ```vue [pages/index.vue]
70 |
78 | ```
79 |
80 |
81 | ## Lastmod: Prerendering Hints
82 |
83 | When prerendering your site, you can make use of setting the `article:modified_time` meta tag in your page's head. This
84 | meta tag will be used as the `lastmod` value in your sitemap.
85 |
86 | ```vue [pages/index.vue]
87 |
93 | ```
94 |
--------------------------------------------------------------------------------
/docs/content/2.guides/6.customising-ui.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Customising the UI
3 | description: Change the look and feel of your sitemap.
4 | ---
5 |
6 | ## Disabling the XSL
7 |
8 | What you're looking at when you view the sitemap.xml is a XSL file, think of it just like you would a CSS file for HTML.
9 |
10 | To view the real sitemap.xml, you can view the source of the page.
11 | If you prefer, you can disable the XSL by setting `xsl` to `false`.
12 |
13 | ```ts
14 | export default defineNuxtConfig({
15 | sitemap: {
16 | xsl: false
17 | }
18 | })
19 | ````
20 |
21 | ## Changing the columns
22 |
23 | You can change the columns that are displayed in the sitemap by modifying the `xslColumns` option.
24 |
25 | These have no effect on SEO and is purely for developer experience.
26 |
27 | Note: You must always have a `URL` column at the start.
28 |
29 | ```ts
30 | export default defineNuxtConfig({
31 | sitemap: {
32 | xslColumns: [
33 | // URL column must always be set, no value needed
34 | { label: 'URL', width: '75%' },
35 | { label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
36 | ],
37 | },
38 | })
39 | ```
40 |
41 | The `select` you provide is an XSL expression that will be evaluated against the sitemap entry.
42 | It's recommended to prefix the value with `sitemap:` if in doubt.
43 |
44 | ### Example: Adding priority and changefreq
45 |
46 | ```ts
47 | export default defineNuxtConfig({
48 | sitemap: {
49 | xslColumns: [
50 | { label: 'URL', width: '50%' },
51 | { label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
52 | { label: 'Priority', select: 'sitemap:priority', width: '12.5%' },
53 | { label: 'Change Frequency', select: 'sitemap:changefreq', width: '12.5%' },
54 | ],
55 | },
56 | })
57 | ```
58 |
59 | ### Example: Adding `hreflang`
60 |
61 | _Requires >= 3.3.2_
62 |
63 | ```ts
64 | export default defineNuxtConfig({
65 | sitemap: {
66 | xslColumns: [
67 | { label: 'URL', width: '50%' },
68 | { label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
69 | { label: 'Hreflangs', select: 'count(xhtml:link)', width: '25%' },
70 | ],
71 | },
72 | })
73 | ```
74 |
75 | ## Disabling tips
76 |
77 | In development tips are displayed on the sitemap page to help you get started.
78 |
79 | You can disable these tips by setting the `xslTips` option to `false`.
80 |
81 | ```ts
82 | export default defineNuxtConfig({
83 | sitemap: {
84 | xslTips: false,
85 | },
86 | })
87 | ```
88 |
--------------------------------------------------------------------------------
/docs/content/2.guides/8.best-practices.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Sitemap.xml Best Practices
3 | description: The best practices for generating a sitemap.xml file.
4 | navigation:
5 | title: Best Practices
6 | ---
7 |
8 | ## Set appropriate lastmod
9 |
10 | The `lastmod` field is used to indicate when a page was last updated. This is used by search engines to determine how often to crawl your site.
11 |
12 | This should not change based on code changes, only for updating the content.
13 |
14 | For example, if you have a blog post, the `lastmod` should be updated when the content of the blog post changes.
15 |
16 | It's recommended not to use `autoLastmod: true` as this will use the last time the page was built, which does
17 | not always reflect content updates.
18 |
19 | Learn more https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping
20 |
21 | ## You probably don't need `changefreq` or `priority`
22 |
23 | These two fields are not used by search engines, and are only used by crawlers to determine how often to crawl your site.
24 |
25 | If you're trying to get your site crawled more often, you should use the `lastmod` field instead.
26 |
27 | Learn more https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping
28 |
--------------------------------------------------------------------------------
/docs/content/2.guides/9.submitting-sitemap.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: 'Submitting Your Sitemap'
3 | description: 'How to submit your sitemap to Google Search Console to start getting indexed.'
4 | ---
5 |
6 | ## Introduction
7 |
8 | When going live with a new site and you're looking to get indexed by Google, the best starting point is
9 | to submit your sitemap to Google Search Console.
10 |
11 | > Google Search Console is a free service offered by Google that helps you monitor, maintain, and troubleshoot
12 | your site's presence in Google Search results.
13 |
14 | ## Submitting Sitemap
15 |
16 | Google provides a guide on [Submitting your Sitemap to Google](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) which is a great starting point.
17 |
18 | You should index either `/sitemap.xml` or if you're using multiple sitemaps, add `/sitemap_index.xml`.
19 |
20 | ## Requesting Indexing
21 |
22 | It's important to know that submitting your sitemap does not guarantee that all your pages will be indexed and that it may take
23 | some time for Google to crawl and index your pages.
24 |
25 | To speed up the process, you can use the [URL Inspection Tool](https://support.google.com/webmasters/answer/9012289) to request indexing of a specific URL.
26 |
27 | In some cases you may want to expedite the indexing process, for this, you can try out my free open-source tool [Request Indexing](https://requestindexing.com).
28 |
29 | ## Sitemap Error
30 |
31 | When submitting a sitemap for the first time you may get see "Error". This is because Google previously
32 | crawled your site for a sitemap and found nothing.
33 |
34 | When encountering this it's best to wait a few days and see if the error resolves itself. If not, you can
35 | try resubmitting the sitemap or making a [GitHub Issue](https://github.com/nuxt-modules/sitemap).
36 |
--------------------------------------------------------------------------------
/docs/content/5.releases/4.v7.md:
--------------------------------------------------------------------------------
1 | ---
2 | navigation:
3 | title: v7.0.0
4 | title: Nuxt Sitemap v7.0.0
5 | description: Release notes for v7.0.0 of Nuxt Sitemap.
6 | ---
7 |
8 | ## Introduction
9 |
10 | The v4 major of Nuxt Sitemap is a simple release to remove deprecations and add support for the [Nuxt SEO v2 stable](https://nuxtseo.com/announcement).
11 |
12 | ## :icon{name="i-noto-warning"} Breaking Features
13 |
14 | ### Site Config v3
15 |
16 | Nuxt Site Config is a module used internally by Nuxt Sitemap.
17 |
18 | The major update to v3.0.0 shouldn't have any direct effect on your site, however, you may want to double-check
19 | the [breaking changes](https://github.com/harlan-zw/nuxt-site-config/releases/tag/v3.0.0).
20 |
21 | ### Removed `inferStaticPagesAsRoutes` config
22 |
23 | If you set this value to `false` previously, you will need to change it to the below:
24 |
25 | ```diff
26 | export default defineNuxtConfig({
27 | sitemap: {
28 | - inferStaticPagesAsRoutes: false,
29 | + excludeAppSources: ['pages', 'route-rules', 'prerender']
30 | }
31 | })
32 | ```
33 |
34 | ### Removed `dynamicUrlsApiEndpoint` config
35 |
36 | The `sources` config supports multiple API endpoints and allows you to provide custom fetch options, use this instead.
37 |
38 | ```diff
39 | export default defineNuxtConfig({
40 | sitemap: {
41 | - dynamicUrlsApiEndpoint: '/__sitemap/urls',
42 | + sources: ['/__sitemap/urls']
43 | }
44 | })
45 | ```
46 |
47 | ### Removed `cacheTtl` config
48 |
49 | Please use the `cacheMaxAgeSeconds` as its a clearer config.
50 |
51 | ```diff
52 | export default defineNuxtConfig({
53 | sitemap: {
54 | - cacheTtl: 10000,
55 | + cacheMaxAgeSeconds: 10000
56 | }
57 | })
58 | ```
59 |
60 | ### Removed `index` route rule / Nuxt Content support
61 |
62 | If you were using the `index: false` in either route rules or your Nuxt Content markdown files, you will need to update this to use the `robots` key.
63 |
64 | ```diff
65 | export default defineNuxtConfig({
66 | routeRules: {
67 | // use the `index` shortcut for simple rules
68 | - '/secret/**': { index: false },
69 | + '/secret/**': { robots: false },
70 | }
71 | })
72 | ```
73 |
--------------------------------------------------------------------------------
/docs/content/5.releases/6.v5.md:
--------------------------------------------------------------------------------
1 | ---
2 | navigation:
3 | title: v5.0.0
4 | title: Nuxt Sitemap v5.0.0
5 | description: Release notes for v5.0.0 of Nuxt Sitemap.
6 | ---
7 |
8 | ## 🚨 Breaking Changes
9 |
10 | ### Package Renamed to `@nuxtjs/sitemap`
11 |
12 | This module is now the official Sitemap module for Nuxt. To properly
13 | reflect this, the package has been renamed to `@nuxtjs/sitemap` from `nuxt-simple-sitemap` and
14 | the GitHub repository has been moved to [nuxt-modules/sitemap](https://github.com/nuxt-modules/sitemap).
15 |
16 | 1. Update the dependency
17 |
18 | ```diff
19 | {
20 | "dependencies": {
21 | - "nuxt-simple-sitemap": "*"
22 | + "@nuxtjs/sitemap": "^5.0.0"
23 | }
24 | }
25 | ```
26 |
27 | 2. Update your `nuxt.config`.
28 |
29 | ```diff
30 | export default defineNuxtConfig({
31 | modules: [
32 | - 'nuxt-simple-sitemap'
33 | + '@nuxtjs/sitemap'
34 | ]
35 | })
36 | ```
37 |
38 | ## Features :rocket:
39 |
40 | ## 🐞 Bug Fixes
41 |
42 | ### Improved Cache Debugging
43 |
44 | - Set browser cache time to match `cacheMaxAgeSeconds` - by @harlan-zw [(00d17) ](https://github.com/nuxt-modules/sitemap/commit/00d176e)
45 | - Iso timestamp for debugging cache - by @harlan-zw [(db3f3) ](https://github.com/nuxt-modules/sitemap/commit/db3f337)
46 | - Cache headers for prerendered sitemap - by @harlan-zw [(57bef) ](https://github.com/nuxt-modules/sitemap/commit/57bef21)
47 | - More explicit caching - by @harlan-zw [(328b7) ](https://github.com/nuxt-modules/sitemap/commit/328b737)
48 |
49 | ### More Consistent DevTools UI
50 |
51 | The DevTools has been updated to match the branding of the other Nuxt SEO module DevTools. [(bc4ae) ](https://github.com/nuxt-modules/sitemap/commit/bc4aebc)
52 |
53 | ### Others
54 |
55 | - Redirect multi sitemap `sitemap.xml` using route rules - by @harlan-zw [(e1bee) ](https://github.com/nuxt-modules/sitemap/commit/e1bee81)
56 |
57 | ##### [View changes on GitHub](https://github.com/nuxt-modules/sitemap/compare/v4.4.1...v5.0.0)
58 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
3 | import pluginNode from 'eslint-plugin-n'
4 |
5 | export default createConfigForNuxt({
6 | features: {
7 | stylistic: true,
8 | tooling: true,
9 | },
10 | dirs: {
11 | src: ['./src', './client'],
12 | },
13 | })
14 | .override('nuxt/typescript/rules', {
15 | rules: {
16 | '@typescript-eslint/ban-ts-comment': [
17 | 'error',
18 | {
19 | 'ts-expect-error': 'allow-with-description',
20 | 'ts-ignore': true,
21 | },
22 | ],
23 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
24 | // TODO: Discuss if we want to enable this
25 | '@typescript-eslint/no-explicit-any': 'off',
26 | },
27 | })
28 | .override('nuxt/vue/rules', {
29 | rules: {
30 | 'vue/multi-word-component-names': 'off',
31 | 'vue/no-v-html': 'off',
32 | },
33 | })
34 | .append({
35 | rules: {
36 | 'no-console': ['error', { allow: ['warn', 'error'] }],
37 | },
38 | })
39 | .append({
40 | plugins: {
41 | node: pluginNode,
42 | },
43 | rules: {
44 | 'node/handle-callback-err': ['error', '^(err|error)$'],
45 | 'node/no-deprecated-api': 'error',
46 | 'node/no-exports-assign': 'error',
47 | 'node/no-new-require': 'error',
48 | 'node/no-path-concat': 'error',
49 | 'node/process-exit-as-throw': 'error',
50 | },
51 | })
52 |
--------------------------------------------------------------------------------
/patches/@nuxtjs__mdc.patch:
--------------------------------------------------------------------------------
1 | diff --git a/dist/module.mjs b/dist/module.mjs
2 | index e028b1a7fba50c413a0e5e40fd545d998917620a..a74d8a45a98dced151f1fc141f24c16746e0ab25 100644
3 | --- a/dist/module.mjs
4 | +++ b/dist/module.mjs
5 | @@ -350,7 +350,7 @@ const module = defineNuxtModule({
6 | filename: "mdc-image-component.mjs",
7 | write: true,
8 | getContents: ({ app }) => {
9 | - const image = app.components.find((c) => c.pascalName === "NuxtImg" && !c.filePath.includes("nuxt/dist/app"));
10 | + const image = app.components.find((c) => c.pascalName === "NuxtImg" && !c.filePath.includes("nuxt/dist/app") && !c.filePath.includes("nuxt-nightly/dist/app"));
11 | return image ? `export { default } from "${image.filePath}"` : 'export default "img"';
12 | }
13 | });
14 |
--------------------------------------------------------------------------------
/playground/app.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
33 |
37 | Nuxt
38 |
39 | {{ siteConfig.name }}
40 |
41 |
42 |
46 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Made by Harlan Wilton
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/playground/content/_partial.md:
--------------------------------------------------------------------------------
1 | # bar
2 |
--------------------------------------------------------------------------------
/playground/content/bar.md:
--------------------------------------------------------------------------------
1 | # bar
2 |
--------------------------------------------------------------------------------
/playground/content/foo.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | priority: 0.5
4 | ---
5 |
6 | # foo
7 |
--------------------------------------------------------------------------------
/playground/content/posts/bar.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | loc: /blog/posts/bar
4 | lastmod: 2021-10-20
5 | ---
6 | # bar
7 |
--------------------------------------------------------------------------------
/playground/content/posts/foo.md:
--------------------------------------------------------------------------------
1 | # foo
2 |
--------------------------------------------------------------------------------
/playground/pages/.ignored/test.vue:
--------------------------------------------------------------------------------
1 |
2 | shouldn't be added
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/[...slug].vue:
--------------------------------------------------------------------------------
1 |
2 | {{ $route.params.slug }}
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/_dir/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /blocked-by-robots-txt
3 |
--------------------------------------------------------------------------------
/playground/pages/about.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 | About page
7 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/playground/pages/api/foo.vue:
--------------------------------------------------------------------------------
1 |
2 | hello world
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blocked-by-robots-txt/foo.vue:
--------------------------------------------------------------------------------
1 |
2 | This should be blocked by @nuxtjs/robots integration automatically.
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog.vue:
--------------------------------------------------------------------------------
1 |
2 | temp
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/[id].vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/categories.vue:
--------------------------------------------------------------------------------
1 |
2 | temp
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/index.vue:
--------------------------------------------------------------------------------
1 |
2 | temp
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/tags.vue:
--------------------------------------------------------------------------------
1 |
2 | temp
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/tags/edit.vue:
--------------------------------------------------------------------------------
1 |
2 | edit
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/blog/tags/new.vue:
--------------------------------------------------------------------------------
1 |
2 | new
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/foo.bar.vue:
--------------------------------------------------------------------------------
1 |
2 | foo.bar
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/hidden-path-but-in-sitemap/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/playground/pages/hide-me.vue:
--------------------------------------------------------------------------------
1 |
2 | hide-me
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/ignore-foo.vue:
--------------------------------------------------------------------------------
1 |
2 | foo
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/index.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 | Sitemap
16 |
17 |
18 |
19 | ignore-foo
20 |
21 |
22 | new page
23 |
24 |
25 | about
26 |
27 |
28 | secret
29 |
30 |
31 | dynamic pre-render link
32 |
33 |
34 | blog
35 |
36 |
37 |
38 |
39 |
40 |
47 |
--------------------------------------------------------------------------------
/playground/pages/new-page.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | New page
4 |
5 |
6 |
--------------------------------------------------------------------------------
/playground/pages/prerender-video.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Pre-render Video Discovery Page
6 |
7 |
8 |
16 | Sorry, your browser doesn't support embedded videos, but don't worry, you
17 | can
18 | download it
19 | and watch it with your favorite video player!
20 |
21 |
22 |
23 |
30 |
34 |
38 | Sorry, your browser doesn't support embedded videos, but don't worry, you
39 | can
40 | download it
41 | and watch it with your favorite video player!
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/playground/pages/secret.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 | Secret page, not for robots.
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/playground/pages/users-[group]/[id].vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/playground/pages/users-[group]/index.vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/playground/server/api/_sitemap-urls.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | '/users-lazy/1',
7 | '/users-lazy/2',
8 | '/users-lazy/3',
9 | ...posts.map(post => ({
10 | loc: `/blog/post-${post}`,
11 | })),
12 | ]
13 | })
14 |
--------------------------------------------------------------------------------
/playground/server/api/fetch.ts:
--------------------------------------------------------------------------------
1 | import type { asSitemapUrl } from '#imports'
2 | import { defineSitemapEventHandler } from '#imports'
3 |
4 | export default defineSitemapEventHandler(async () => {
5 | return $fetch[]>('/api/sitemap-urls-to-be-confumsed-by-fetch')
6 | })
7 |
--------------------------------------------------------------------------------
/playground/server/api/multi-sitemap-sources/bar.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/bar/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/playground/server/api/multi-sitemap-sources/foo.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/foo/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/playground/server/api/prerendered.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(async () => {
4 | return { foo: 'bar' }
5 | })
6 |
--------------------------------------------------------------------------------
/playground/server/api/sitemap-bar.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | '/bar/1',
6 | '/bar/2',
7 | '/bar/3',
8 | ]
9 | })
10 |
--------------------------------------------------------------------------------
/playground/server/api/sitemap-foo.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | '/foo/1',
6 | '/foo/2',
7 | '/foo/3',
8 | ]
9 | })
10 |
--------------------------------------------------------------------------------
/playground/server/api/sitemap-urls-to-be-confumsed-by-fetch.ts:
--------------------------------------------------------------------------------
1 | import { withLeadingSlash } from 'ufo'
2 | import { defineEventHandler } from '#imports'
3 |
4 | export default defineEventHandler(() => {
5 | return $fetch<{ title: string }[]>('https://jsonplaceholder.typicode.com/posts').then((res) => {
6 | return res.map(post => ({
7 | invalidAttr: 'foo',
8 | loc: withLeadingSlash(post.title.replace(' ', '-')),
9 | }))
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/playground/server/plugins/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineNitroPlugin } from '#imports'
2 |
3 | export default defineNitroPlugin((nitroApp) => {
4 | nitroApp.hooks.hook('sitemap:output', async () => {
5 | // eslint-disable-next-line no-console
6 | console.log('Sitemap SSR hook')
7 | })
8 | nitroApp.hooks.hook('sitemap:index-resolved', (ctx) => {
9 | // eslint-disable-next-line no-console
10 | console.log('Sitemap index resolved hook', ctx)
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/playground/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | const posts = Array.from({ length: 3 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/blog/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/playground/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // https://v3.nuxtjs.org/concepts/typescript
3 | "extends": "../.nuxt/tsconfig.server.json"
4 | }
5 |
--------------------------------------------------------------------------------
/playground/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | export default {}
2 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - client
3 | - test/fixtures/**
4 | - playground
5 |
--------------------------------------------------------------------------------
/src/content.ts:
--------------------------------------------------------------------------------
1 | import type { Collection } from '@nuxt/content'
2 | import type { TypeOf } from 'zod'
3 | import { z } from '@nuxt/content'
4 |
5 | export const schema = z.object({
6 | sitemap: z.object({
7 | loc: z.string().optional(),
8 | lastmod: z.date().optional(),
9 | changefreq: z.union([z.literal('always'), z.literal('hourly'), z.literal('daily'), z.literal('weekly'), z.literal('monthly'), z.literal('yearly'), z.literal('never')]).optional(),
10 | priority: z.number().optional(),
11 | images: z.array(z.object({
12 | loc: z.string(),
13 | caption: z.string().optional(),
14 | geo_location: z.string().optional(),
15 | title: z.string().optional(),
16 | license: z.string().optional(),
17 | })).optional(),
18 | videos: z.array(z.object({
19 | content_loc: z.string(),
20 | player_loc: z.string().optional(),
21 | duration: z.string().optional(),
22 | expiration_date: z.date().optional(),
23 | rating: z.number().optional(),
24 | view_count: z.number().optional(),
25 | publication_date: z.date().optional(),
26 | family_friendly: z.boolean().optional(),
27 | tag: z.string().optional(),
28 | category: z.string().optional(),
29 | restriction: z.object({
30 | relationship: z.literal('allow').optional(),
31 | value: z.string().optional(),
32 | }).optional(),
33 | gallery_loc: z.string().optional(),
34 | price: z.string().optional(),
35 | requires_subscription: z.boolean().optional(),
36 | uploader: z.string().optional(),
37 | })).optional(),
38 | }).optional(),
39 | })
40 |
41 | export type SitemapSchema = TypeOf
42 |
43 | export function asSitemapCollection(collection: Collection): Collection {
44 | if (collection.type === 'page') {
45 | // @ts-expect-error untyped
46 | collection.schema = collection.schema ? schema.extend(collection.schema.shape) : schema
47 | }
48 | return collection
49 | }
50 |
--------------------------------------------------------------------------------
/src/devtools.ts:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'node:fs'
2 | import type { Nuxt } from 'nuxt/schema'
3 | import type { Resolver } from '@nuxt/kit'
4 | import { useNuxt } from '@nuxt/kit'
5 | import type { ModuleOptions } from './module'
6 |
7 | const DEVTOOLS_UI_ROUTE = '/__sitemap__/devtools'
8 | const DEVTOOLS_UI_LOCAL_PORT = 3030
9 |
10 | export function setupDevToolsUI(options: ModuleOptions, resolve: Resolver['resolve'], nuxt: Nuxt = useNuxt()) {
11 | const clientPath = resolve('./client')
12 | const isProductionBuild = existsSync(clientPath)
13 |
14 | // Serve production-built client (used when package is published)
15 | if (isProductionBuild) {
16 | nuxt.hook('vite:serverCreated', async (server) => {
17 | const sirv = await import('sirv').then(r => r.default || r)
18 | server.middlewares.use(
19 | DEVTOOLS_UI_ROUTE,
20 | sirv(clientPath, { dev: true, single: true }),
21 | )
22 | })
23 | }
24 | // In local development, start a separate Nuxt Server and proxy to serve the client
25 | else {
26 | nuxt.hook('vite:extendConfig', (config) => {
27 | config.server = config.server || {}
28 | config.server.proxy = config.server.proxy || {}
29 | config.server.proxy[DEVTOOLS_UI_ROUTE] = {
30 | target: `http://localhost:${DEVTOOLS_UI_LOCAL_PORT}${DEVTOOLS_UI_ROUTE}`,
31 | changeOrigin: true,
32 | followRedirects: true,
33 | rewrite: path => path.replace(DEVTOOLS_UI_ROUTE, ''),
34 | }
35 | })
36 | }
37 |
38 | nuxt.hook('devtools:customTabs', (tabs) => {
39 | tabs.push({
40 | // unique identifier
41 | name: 'sitemap',
42 | // title to display in the tab
43 | title: 'Sitemap',
44 | // any icon from Iconify, or a URL to an image
45 | icon: 'carbon:load-balancer-application',
46 | // iframe view
47 | view: {
48 | type: 'iframe',
49 | src: DEVTOOLS_UI_ROUTE,
50 | },
51 | })
52 | })
53 | }
54 |
--------------------------------------------------------------------------------
/src/runtime/server/composables/asSitemapUrl.ts:
--------------------------------------------------------------------------------
1 | import type { SitemapUrlInput } from '../../types'
2 |
3 | export function asSitemapUrl(url: SitemapUrlInput | Record): SitemapUrlInput {
4 | return url as SitemapUrlInput
5 | }
6 |
--------------------------------------------------------------------------------
/src/runtime/server/composables/defineSitemapEventHandler.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 | import type { EventHandlerRequest, EventHandlerResponse } from 'h3'
3 | import type { SitemapUrlInput } from '../../types'
4 |
5 | export const defineSitemapEventHandler: typeof defineEventHandler> = defineEventHandler
6 |
--------------------------------------------------------------------------------
/src/runtime/server/content-compat.ts:
--------------------------------------------------------------------------------
1 | // @ts-expect-error untyped
2 | import { queryCollectionWithEvent } from '#sitemap/content-v3-nitro-path'
3 |
4 | export const queryCollection = queryCollectionWithEvent
5 |
--------------------------------------------------------------------------------
/src/runtime/server/kit.ts:
--------------------------------------------------------------------------------
1 | import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
2 | import { defu } from 'defu'
3 | import { parseURL, withoutBase, withoutTrailingSlash } from 'ufo'
4 | import type { NitroRouteRules } from 'nitropack'
5 | import { useRuntimeConfig } from 'nitropack/runtime'
6 |
7 | export function withoutQuery(path: string) {
8 | return path.split('?')[0]
9 | }
10 |
11 | export function createNitroRouteRuleMatcher() {
12 | const { nitro, app } = useRuntimeConfig()
13 | const _routeRulesMatcher = toRouteMatcher(
14 | createRadixRouter({
15 | routes: Object.fromEntries(
16 | Object.entries(nitro?.routeRules || {})
17 | .map(([path, rules]) => [path === '/' ? path : withoutTrailingSlash(path), rules]),
18 | ),
19 | }),
20 | )
21 | return (pathOrUrl: string) => {
22 | const path = pathOrUrl[0] === '/' ? pathOrUrl : parseURL(pathOrUrl, app.baseURL).pathname
23 | const pathWithoutQuery = withoutQuery(path)
24 | return defu({}, ..._routeRulesMatcher.matchAll(
25 | // radix3 does not support trailing slashes
26 | withoutBase(pathWithoutQuery === '/' ? pathWithoutQuery : withoutTrailingSlash(pathWithoutQuery), app.baseURL),
27 | ).reverse()) as NitroRouteRules
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/runtime/server/plugins/compression.ts:
--------------------------------------------------------------------------------
1 | import { useCompressionStream } from 'h3-compression'
2 | import { defineNitroPlugin } from 'nitropack/runtime'
3 |
4 | export default defineNitroPlugin((nitro) => {
5 | nitro.hooks.hook('beforeResponse', async (event, response) => {
6 | if (event.context._isSitemap)
7 | await useCompressionStream(event, response)
8 | })
9 | })
10 |
--------------------------------------------------------------------------------
/src/runtime/server/plugins/nuxt-content-v2.ts:
--------------------------------------------------------------------------------
1 | import { defu } from 'defu'
2 | import type { ParsedContentv2 } from '@nuxt/content'
3 | import type { NitroApp } from 'nitropack/types'
4 | import { defineNitroPlugin } from 'nitropack/runtime'
5 | import type { SitemapUrl } from '../../types'
6 | import { useSitemapRuntimeConfig } from '../utils'
7 |
8 | export default defineNitroPlugin((nitroApp: NitroApp) => {
9 | const { discoverImages, isNuxtContentDocumentDriven } = useSitemapRuntimeConfig()
10 | // @ts-expect-error untyped
11 | nitroApp.hooks.hook('content:file:afterParse', async (content: ParsedContentv2) => {
12 | const validExtensions = ['md', 'mdx']
13 | if (content.sitemap === false || content._draft || !validExtensions.includes(content._extension || '') || content._partial || content.robots === false)
14 | return
15 |
16 | // add any top level images
17 | let images: SitemapUrl['images'] = []
18 | if (discoverImages) {
19 | images = (content.body?.children
20 | ?.filter(c =>
21 | c.tag && c.props?.src && ['image', 'img', 'nuxtimg', 'nuxt-img'].includes(c.tag.toLowerCase()),
22 | )
23 | .map(i => ({ loc: i.props!.src })) || [])
24 | }
25 |
26 | const sitemapConfig = typeof content.sitemap === 'object' ? content.sitemap : {}
27 | const lastmod = content.modifiedAt || content.updatedAt
28 | const defaults: Partial = {}
29 | if (isNuxtContentDocumentDriven)
30 | defaults.loc = content._path
31 | if (content.path) // automatically set when document driven
32 | defaults.loc = content.path
33 | if (images?.length)
34 | defaults.images = images
35 | if (lastmod)
36 | defaults.lastmod = lastmod
37 | const definition = defu(sitemapConfig, defaults) as Partial
38 | if (!definition.loc) {
39 | // user hasn't provided a loc... lets fallback to a relative path
40 | if (content.path && content.path && content.path.startsWith('/'))
41 | definition.loc = content.path
42 | // otherwise let's warn them
43 | if (Object.keys(sitemapConfig).length > 0 && import.meta.dev)
44 | console.warn(`[@nuxtjs/content] The @nuxt/content file \`${content._path}\` is missing a sitemap \`loc\`.`)
45 | }
46 | content.sitemap = definition
47 | // loc is required
48 | if (!definition.loc)
49 | delete content.sitemap
50 |
51 | return content
52 | })
53 | })
54 |
--------------------------------------------------------------------------------
/src/runtime/server/plugins/warm-up.ts:
--------------------------------------------------------------------------------
1 | import { withLeadingSlash } from 'ufo'
2 | import { defineNitroPlugin } from 'nitropack/runtime'
3 | import { useSitemapRuntimeConfig } from '../utils'
4 |
5 | export default defineNitroPlugin((nitroApp) => {
6 | const { sitemaps } = useSitemapRuntimeConfig()
7 | const queue: (() => Promise)[] = []
8 | const timeoutIds: NodeJS.Timeout[] = []
9 |
10 | const sitemapsWithRoutes = Object.entries(sitemaps)
11 | .filter(([, sitemap]) => sitemap._route)
12 |
13 | for (const [, sitemap] of sitemapsWithRoutes)
14 | queue.push(() => nitroApp.localFetch(withLeadingSlash(sitemap._route), {}))
15 |
16 | // run async
17 | const initialTimeout = setTimeout(() => {
18 | // work the queue step by step await the promise from each task, delay 1s after each task ends
19 | const next = async () => {
20 | if (queue.length === 0) {
21 | // Clear timeout references when done
22 | timeoutIds.length = 0
23 | return
24 | }
25 |
26 | try {
27 | await queue.shift()!()
28 | }
29 | catch (error) {
30 | console.error('[sitemap:warm-up] Error warming up sitemap:', error)
31 | }
32 |
33 | // Only schedule next if we have more items
34 | if (queue.length > 0) {
35 | const nextTimeout = setTimeout(next, 1000) // arbitrary delay to avoid throttling
36 | timeoutIds.push(nextTimeout)
37 | }
38 | }
39 | next()
40 | }, 2500 /* https://github.com/unjs/nitro/pull/1906 */)
41 |
42 | timeoutIds.push(initialTimeout)
43 |
44 | // Clean up on app shutdown
45 | nitroApp.hooks.hook('close', () => {
46 | // Clear all pending timeouts
47 | timeoutIds.forEach(id => clearTimeout(id))
48 | timeoutIds.length = 0
49 | queue.length = 0
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/src/runtime/server/robots-polyfill/getPathRobotConfig.ts:
--------------------------------------------------------------------------------
1 | import type { H3Event } from 'h3'
2 |
3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
4 | export function getPathRobotConfig(e: H3Event, options: any) {
5 | return { indexable: true, rule: 'index, follow' }
6 | }
7 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/__sitemap__/debug.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 | import type { SitemapDefinition } from '../../../types'
3 | import { useSitemapRuntimeConfig } from '../../utils'
4 | import {
5 | childSitemapSources,
6 | globalSitemapSources,
7 | resolveSitemapSources,
8 | } from '../../sitemap/urlset/sources'
9 | import { getNitroOrigin, getSiteConfig } from '#site-config/server/composables'
10 |
11 | export default defineEventHandler(async (e) => {
12 | const _runtimeConfig = useSitemapRuntimeConfig()
13 | const siteConfig = getSiteConfig(e)
14 | const { sitemaps: _sitemaps } = _runtimeConfig
15 | const runtimeConfig = { ..._runtimeConfig }
16 | // @ts-expect-error hack
17 | delete runtimeConfig.sitemaps
18 | const globalSources = await globalSitemapSources()
19 | const nitroOrigin = getNitroOrigin(e)
20 | const sitemaps: Record = {}
21 | for (const s of Object.keys(_sitemaps)) {
22 | // resolve the sources
23 | sitemaps[s] = {
24 | ..._sitemaps[s],
25 | sources: await resolveSitemapSources(await childSitemapSources(_sitemaps[s]), e),
26 | }
27 | }
28 | return {
29 | nitroOrigin,
30 | sitemaps,
31 | runtimeConfig,
32 | globalSources: await resolveSitemapSources(globalSources, e),
33 | siteConfig: { ...siteConfig },
34 | }
35 | })
36 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 | import type { ParsedContent } from '@nuxt/content'
3 |
4 | // @ts-expect-error alias module
5 | import { serverQueryContent } from '#content/server'
6 |
7 | export default defineEventHandler(async (e) => {
8 | const contentList = (await serverQueryContent(e).find()) as ParsedContent[]
9 | return contentList.map(c => c.sitemap).filter(Boolean)
10 | })
11 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 | import { queryCollection } from '@nuxt/content/server'
3 | // @ts-expect-error alias
4 | import manifest from '#content/manifest'
5 |
6 | export default defineEventHandler(async (e) => {
7 | const collections = []
8 | // each collection in the manifest has a key => with fields which has a `sitemap`, we want to get all those
9 | for (const collection in manifest) {
10 | if (manifest[collection].fields.sitemap) {
11 | collections.push(collection)
12 | }
13 | }
14 | // now we need to handle multiple queries here, we want to run the requests in parralel
15 | const contentList = []
16 | for (const collection of collections) {
17 | contentList.push(
18 | queryCollection(e, collection)
19 | .select('path', 'sitemap')
20 | .where('path', 'IS NOT NULL')
21 | .where('sitemap', 'IS NOT NULL')
22 | .all(),
23 | )
24 | }
25 | // we need to wait for all the queries to finish
26 | const results = await Promise.all(contentList)
27 | // we need to flatten the results
28 | return results
29 | .flatMap((c) => {
30 | return c
31 | .filter(c => c.sitemap !== false && c.path)
32 | .flatMap(c => ({
33 | loc: c.path,
34 | ...(c.sitemap || {}),
35 | }))
36 | })
37 | .filter(Boolean)
38 | })
39 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/sitemap.xml.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler, sendRedirect } from 'h3'
2 | import { withBase } from 'ufo'
3 | import { useRuntimeConfig } from 'nitropack/runtime'
4 | import { useSitemapRuntimeConfig } from '../utils'
5 | import { createSitemap } from '../sitemap/nitro'
6 |
7 | export default defineEventHandler(async (e) => {
8 | const runtimeConfig = useSitemapRuntimeConfig()
9 | const { sitemaps } = runtimeConfig
10 | // we need to check if we're rendering multiple sitemaps from the index sitemap
11 | if ('index' in sitemaps) {
12 | // redirect to sitemap_index.xml (302 in dev to avoid caching issues)
13 | return sendRedirect(e, withBase('/sitemap_index.xml', useRuntimeConfig().app.baseURL), import.meta.dev ? 302 : 301)
14 | }
15 |
16 | return createSitemap(e, Object.values(sitemaps)[0], runtimeConfig)
17 | })
18 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/sitemap/[sitemap].xml.ts:
--------------------------------------------------------------------------------
1 | import { createError, defineEventHandler, getRouterParam } from 'h3'
2 | import { withoutLeadingSlash, withoutTrailingSlash } from 'ufo'
3 | import { useSitemapRuntimeConfig } from '../../utils'
4 | import { createSitemap } from '../../sitemap/nitro'
5 | import { parseChunkInfo, getSitemapConfig } from '../../sitemap/utils/chunk'
6 |
7 | export default defineEventHandler(async (e) => {
8 | const runtimeConfig = useSitemapRuntimeConfig(e)
9 | const { sitemaps } = runtimeConfig
10 |
11 | // Extract the sitemap name from the path
12 | let sitemapName = getRouterParam(e, 'sitemap')
13 | if (!sitemapName) {
14 | // Use the path to extract the sitemap name
15 | const path = e.path
16 | // Handle both regular paths and debug prefix
17 | const match = path.match(/(?:\/__sitemap__\/)?([^/]+)\.xml$/)
18 | if (match) {
19 | sitemapName = match[1]
20 | }
21 | }
22 |
23 | if (!sitemapName) {
24 | return createError({
25 | statusCode: 400,
26 | message: 'Invalid sitemap request',
27 | })
28 | }
29 |
30 | // Clean up the sitemap name
31 | sitemapName = withoutLeadingSlash(withoutTrailingSlash(sitemapName.replace('.xml', '')
32 | .replace('__sitemap__/', '')
33 | .replace(runtimeConfig.sitemapsPathPrefix || '', '')))
34 |
35 | // Parse chunk information and get appropriate config
36 | const chunkInfo = parseChunkInfo(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize)
37 |
38 | // Validate that the sitemap or its base exists
39 | const isAutoChunked = typeof sitemaps.chunks !== 'undefined' && !Number.isNaN(Number(sitemapName))
40 | const sitemapExists = sitemapName in sitemaps || chunkInfo.baseSitemapName in sitemaps || isAutoChunked
41 |
42 | if (!sitemapExists) {
43 | return createError({
44 | statusCode: 404,
45 | message: `Sitemap "${sitemapName}" not found.`,
46 | })
47 | }
48 |
49 | // If trying to access a chunk of a non-chunked sitemap, return 404
50 | if (chunkInfo.isChunked && chunkInfo.chunkIndex !== undefined) {
51 | const baseSitemap = sitemaps[chunkInfo.baseSitemapName]
52 | if (baseSitemap && !baseSitemap.chunks && !baseSitemap._isChunking) {
53 | return createError({
54 | statusCode: 404,
55 | message: `Sitemap "${chunkInfo.baseSitemapName}" does not support chunking.`,
56 | })
57 | }
58 |
59 | // Validate chunk index if count is available
60 | if (baseSitemap?._chunkCount !== undefined && chunkInfo.chunkIndex >= baseSitemap._chunkCount) {
61 | return createError({
62 | statusCode: 404,
63 | message: `Chunk ${chunkInfo.chunkIndex} does not exist for sitemap "${chunkInfo.baseSitemapName}".`,
64 | })
65 | }
66 | }
67 |
68 | // Get the appropriate sitemap configuration
69 | const sitemapConfig = getSitemapConfig(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize)
70 |
71 | return createSitemap(e, sitemapConfig, runtimeConfig)
72 | })
73 |
--------------------------------------------------------------------------------
/src/runtime/server/routes/sitemap_index.xml.ts:
--------------------------------------------------------------------------------
1 | import { appendHeader, defineEventHandler, setHeader } from 'h3'
2 | import { joinURL } from 'ufo'
3 | import { useNitroApp } from 'nitropack/runtime'
4 | import { useSitemapRuntimeConfig } from '../utils'
5 | import { buildSitemapIndex, urlsToIndexXml } from '../sitemap/builder/sitemap-index'
6 | import type { SitemapIndexRenderCtx, SitemapOutputHookCtx } from '../../types'
7 | import { useNitroUrlResolvers } from '../sitemap/nitro'
8 |
9 | export default defineEventHandler(async (e) => {
10 | const runtimeConfig = useSitemapRuntimeConfig()
11 | const nitro = useNitroApp()
12 | const resolvers = useNitroUrlResolvers(e)
13 | const { entries: sitemaps, failedSources } = await buildSitemapIndex(resolvers, runtimeConfig, nitro)
14 |
15 | // tell the prerender to render the other sitemaps (if we prerender this one)
16 | // this solves the dynamic chunking sitemap issue
17 | if (import.meta.prerender) {
18 | appendHeader(
19 | e,
20 | 'x-nitro-prerender',
21 | sitemaps.filter(entry => !!entry._sitemapName)
22 | .map(entry => encodeURIComponent(joinURL(runtimeConfig.sitemapsPathPrefix || '', `/${entry._sitemapName}.xml`))).join(', '),
23 | )
24 | }
25 |
26 | const indexResolvedCtx: SitemapIndexRenderCtx = { sitemaps, event: e }
27 | await nitro.hooks.callHook('sitemap:index-resolved', indexResolvedCtx)
28 |
29 | // Prepare error information for XSL if there are failed sources
30 | const errorInfo = failedSources.length > 0
31 | ? {
32 | messages: failedSources.map(f => f.error),
33 | urls: failedSources.map(f => f.url),
34 | }
35 | : undefined
36 |
37 | const output = urlsToIndexXml(indexResolvedCtx.sitemaps, resolvers, runtimeConfig, errorInfo)
38 | const ctx: SitemapOutputHookCtx = { sitemap: output, sitemapName: 'sitemap', event: e }
39 | await nitro.hooks.callHook('sitemap:output', ctx)
40 |
41 | setHeader(e, 'Content-Type', 'text/xml; charset=UTF-8')
42 | if (runtimeConfig.cacheMaxAgeSeconds) {
43 | setHeader(e, 'Cache-Control', `public, max-age=${runtimeConfig.cacheMaxAgeSeconds}, s-maxage=${runtimeConfig.cacheMaxAgeSeconds}, stale-while-revalidate=3600`)
44 |
45 | // Add debug headers when caching is enabled
46 | const now = new Date()
47 | setHeader(e, 'X-Sitemap-Generated', now.toISOString())
48 | setHeader(e, 'X-Sitemap-Cache-Duration', `${runtimeConfig.cacheMaxAgeSeconds}s`)
49 |
50 | // Calculate expiry time
51 | const expiryTime = new Date(now.getTime() + (runtimeConfig.cacheMaxAgeSeconds * 1000))
52 | setHeader(e, 'X-Sitemap-Cache-Expires', expiryTime.toISOString())
53 |
54 | // Calculate remaining time
55 | const remainingSeconds = Math.floor((expiryTime.getTime() - now.getTime()) / 1000)
56 | setHeader(e, 'X-Sitemap-Cache-Remaining', `${remainingSeconds}s`)
57 | }
58 | else {
59 | setHeader(e, 'Cache-Control', `no-cache, no-store`)
60 | }
61 | return ctx.sitemap
62 | })
63 |
--------------------------------------------------------------------------------
/src/runtime/server/sitemap/urlset/sort.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ResolvedSitemapUrl,
3 | SitemapUrlInput,
4 | } from '../../../types'
5 |
6 | export function sortInPlace(urls: T): T {
7 | // In-place sort to avoid creating new arrays
8 | urls.sort((a, b) => {
9 | const aLoc = typeof a === 'string' ? a : a.loc
10 | const bLoc = typeof b === 'string' ? b : b.loc
11 |
12 | // First sort by path segments
13 | const aSegments = aLoc.split('/').length
14 | const bSegments = bLoc.split('/').length
15 | if (aSegments !== bSegments) {
16 | return aSegments - bSegments
17 | }
18 |
19 | // Then sort by locale compare with numeric
20 | return aLoc.localeCompare(bLoc, undefined, { numeric: true })
21 | })
22 |
23 | return urls
24 | }
25 |
--------------------------------------------------------------------------------
/src/runtime/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../.nuxt/tsconfig.server.json"
3 | }
4 |
--------------------------------------------------------------------------------
/src/runtime/server/utils.ts:
--------------------------------------------------------------------------------
1 | import type { H3Event } from 'h3'
2 | import { useRuntimeConfig } from 'nitropack/runtime'
3 | import type { ModuleRuntimeConfig } from '../types'
4 | import { normalizeRuntimeFilters } from '../utils-pure'
5 |
6 | export * from '../utils-pure'
7 |
8 | // XML escape function for content inserted into XML/XSL
9 | export function xmlEscape(str: string): string {
10 | return String(str)
11 | .replace(/&/g, '&')
12 | .replace(//g, '>')
14 | .replace(/"/g, '"')
15 | .replace(/'/g, ''')
16 | }
17 |
18 | export function useSitemapRuntimeConfig(e?: H3Event): ModuleRuntimeConfig {
19 | // we need to clone with this hack so that we can write to the config
20 | const clone = JSON.parse(JSON.stringify(useRuntimeConfig(e).sitemap)) as any as ModuleRuntimeConfig
21 | // normalize the filters for runtime
22 | for (const k in clone.sitemaps) {
23 | const sitemap = clone.sitemaps[k]
24 | sitemap.include = normalizeRuntimeFilters(sitemap.include)
25 | sitemap.exclude = normalizeRuntimeFilters(sitemap.exclude)
26 | clone.sitemaps[k] = sitemap
27 | }
28 | // avoid mutation
29 | return Object.freeze(clone)
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils-internal/filter.ts:
--------------------------------------------------------------------------------
1 | import type { FilterInput } from '../runtime/types'
2 |
3 | /**
4 | * Check if a filter is valid, otherwise exclude it
5 | * @param filter string | RegExp | RegexObjectType
6 | *
7 | */
8 | function isValidFilter(filter: FilterInput): boolean {
9 | if (typeof filter === 'string')
10 | return true
11 | if (filter instanceof RegExp)
12 | return true
13 | if (typeof filter === 'object' && typeof filter.regex === 'string')
14 | return true
15 | // check if the object has a toString() function
16 | return false
17 | }
18 |
19 | /**
20 | * Transform the RegeExp into RegexObjectType
21 | */
22 | export function normalizeFilters(filters: FilterInput[] | undefined) {
23 | return (filters || []).map((filter) => {
24 | if (!isValidFilter(filter)) {
25 | console.warn(`[@nuxtjs/sitemap] You have provided an invalid filter: ${filter}, ignoring.`)
26 | return false
27 | }
28 | // regex needs to be converted into an object that can be serialized
29 | return filter instanceof RegExp ? { regex: filter.toString() } : filter
30 | }).filter(Boolean) as FilterInput[]
31 | }
32 |
--------------------------------------------------------------------------------
/src/utils-internal/i18n.ts:
--------------------------------------------------------------------------------
1 | import type { NuxtI18nOptions, LocaleObject } from '@nuxtjs/i18n'
2 | import type { Strategies } from 'vue-i18n-routing'
3 | import { joinURL, withBase, withHttps } from 'ufo'
4 | import type { AutoI18nConfig, FilterInput } from '../runtime/types'
5 | import { mergeOnKey, splitForLocales } from '../runtime/utils-pure'
6 |
7 | export interface StrategyProps {
8 | localeCode: string
9 | pageLocales: string
10 | nuxtI18nConfig: NuxtI18nOptions
11 | forcedStrategy?: Strategies
12 | normalisedLocales: AutoI18nConfig['locales']
13 | }
14 |
15 | export function splitPathForI18nLocales(path: FilterInput, autoI18n: AutoI18nConfig) {
16 | const locales = autoI18n.strategy === 'prefix_except_default' ? autoI18n.locales.filter(l => l.code !== autoI18n.defaultLocale) : autoI18n.locales
17 | if (typeof path !== 'string' || path.startsWith('/_'))
18 | return path
19 | const match = splitForLocales(path, locales.map(l => l.code))
20 | const locale = match[0]
21 | // only accept paths without locale
22 | if (locale)
23 | return path
24 | return [
25 | path,
26 | ...locales.map(l => `/${l.code}${path}`),
27 | ]
28 | }
29 |
30 | export function generatePathForI18nPages(ctx: StrategyProps): string {
31 | const { localeCode, pageLocales, nuxtI18nConfig, forcedStrategy, normalisedLocales } = ctx
32 | const locale = normalisedLocales.find(l => l.code === localeCode)
33 | let path = pageLocales
34 | switch (forcedStrategy ?? nuxtI18nConfig.strategy) {
35 | case 'prefix_except_default':
36 | case 'prefix_and_default':
37 | path = localeCode === nuxtI18nConfig.defaultLocale ? pageLocales : joinURL(localeCode, pageLocales)
38 | break
39 | case 'prefix':
40 | path = joinURL(localeCode, pageLocales)
41 | break
42 | }
43 | return locale?.domain ? withHttps(withBase(path, locale.domain)) : path
44 | }
45 |
46 | export function normalizeLocales(nuxtI18nConfig: NuxtI18nOptions): AutoI18nConfig['locales'] {
47 | let locales = nuxtI18nConfig.locales || []
48 | let onlyLocales = nuxtI18nConfig?.bundle?.onlyLocales || []
49 | onlyLocales = typeof onlyLocales === 'string' ? [onlyLocales] : onlyLocales
50 | locales = mergeOnKey(locales.map((locale: any) => typeof locale === 'string' ? { code: locale } : locale), 'code')
51 | if (onlyLocales.length) {
52 | locales = locales.filter((locale: LocaleObject) => onlyLocales.includes(locale.code))
53 | }
54 | return locales.map((locale) => {
55 | // we prefer i18n v9 config
56 | if (locale.iso && !locale.language) {
57 | locale.language = locale.iso
58 | }
59 | locale._hreflang = locale.language || locale.code
60 | locale._sitemap = locale.language || locale.code
61 | return locale
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/src/utils-internal/kit.ts:
--------------------------------------------------------------------------------
1 | import type { NuxtModule, NuxtPage } from 'nuxt/schema'
2 | import type { Nuxt } from '@nuxt/schema'
3 | import { extendPages, loadNuxtModuleInstance, useNuxt, tryUseNuxt } from '@nuxt/kit'
4 | import type { Nitro } from 'nitropack'
5 | import { env, provider } from 'std-env'
6 | import type { NitroConfig } from 'nitropack/types'
7 |
8 | /**
9 | * Get the user provided options for a Nuxt module.
10 | *
11 | * These options may not be the resolved options that the module actually uses.
12 | * @param module
13 | * @param nuxt
14 | */
15 | export async function getNuxtModuleOptions(module: string | NuxtModule, nuxt: Nuxt = useNuxt()) {
16 | const moduleMeta = (typeof module === 'string' ? { name: module } : await module.getMeta?.()) || {}
17 | const { nuxtModule } = (await loadNuxtModuleInstance(module, nuxt))
18 |
19 | let moduleEntry: [string | NuxtModule, Record] | undefined
20 | for (const m of nuxt.options.modules) {
21 | if (Array.isArray(m) && m.length >= 2) {
22 | const _module = m[0]
23 | const _moduleEntryName = typeof _module === 'string'
24 | ? _module
25 | : (await (_module as any as NuxtModule).getMeta?.())?.name || ''
26 | if (_moduleEntryName === moduleMeta.name)
27 | moduleEntry = m as [string | NuxtModule, Record]
28 | }
29 | }
30 |
31 | let inlineOptions = {}
32 | if (moduleEntry)
33 | inlineOptions = moduleEntry[1]
34 | if (nuxtModule.getOptions)
35 | return nuxtModule.getOptions(inlineOptions, nuxt)
36 | return inlineOptions
37 | }
38 |
39 | export function createPagesPromise(nuxt: Nuxt = useNuxt()) {
40 | return new Promise((resolve) => {
41 | nuxt.hooks.hook('modules:done', () => {
42 | if ((typeof nuxt.options.pages === 'boolean' && nuxt.options.pages === false) || (typeof nuxt.options.pages === 'object' && !nuxt.options.pages.enabled)) {
43 | return resolve([])
44 | }
45 | extendPages(resolve)
46 | })
47 | })
48 | }
49 |
50 | export function createNitroPromise(nuxt: Nuxt = useNuxt()) {
51 | return new Promise((resolve) => {
52 | nuxt.hooks.hook('nitro:init', (nitro) => {
53 | resolve(nitro)
54 | })
55 | })
56 | }
57 |
58 | const autodetectableProviders = {
59 | azure_static: 'azure',
60 | cloudflare_pages: 'cloudflare-pages',
61 | netlify: 'netlify',
62 | stormkit: 'stormkit',
63 | vercel: 'vercel',
64 | cleavr: 'cleavr',
65 | stackblitz: 'stackblitz',
66 | }
67 |
68 | const autodetectableStaticProviders = {
69 | netlify: 'netlify-static',
70 | vercel: 'vercel-static',
71 | }
72 |
73 | export function detectTarget(options: { static?: boolean } = {}) {
74 | // @ts-expect-error untyped
75 | return options?.static ? autodetectableStaticProviders[provider] : autodetectableProviders[provider]
76 | }
77 |
78 | export function resolveNitroPreset(nitroConfig?: NitroConfig): string {
79 | nitroConfig = nitroConfig || tryUseNuxt()?.options?.nitro
80 | if (provider === 'stackblitz')
81 | return 'stackblitz'
82 | let preset
83 | if (nitroConfig && nitroConfig?.preset)
84 | preset = nitroConfig.preset
85 | if (!preset)
86 | preset = env.NITRO_PRESET || env.SERVER_PRESET || detectTarget() || 'node-server'
87 | return preset.replace('_', '-') // sometimes they are different
88 | }
89 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export { parseSitemapXml } from './parseSitemapXml'
2 | export type { SitemapWarning, SitemapParseResult } from './parseSitemapXml'
3 | export { parseHtmlExtractSitemapMeta } from './parseHtmlExtractSitemapMeta'
4 | export type * from '../runtime/types'
5 |
--------------------------------------------------------------------------------
/test/bench/i18n.bench.ts:
--------------------------------------------------------------------------------
1 | import { bench, describe } from 'vitest'
2 | import { resolveSitemapEntries } from '../../src/runtime/server/sitemap/builder/sitemap'
3 | import type { SitemapSourceResolved } from '#sitemap'
4 |
5 | const sources: SitemapSourceResolved[] = [
6 | {
7 | urls: Array.from({ length: 3000 }, (_, i) => ({
8 | loc: `/foo-${i}`,
9 | })),
10 | context: {
11 | name: 'foo',
12 | },
13 | sourceType: 'user',
14 | },
15 | ]
16 |
17 | describe('i18n', () => {
18 | bench('normaliseI18nSources', () => {
19 | resolveSitemapEntries({
20 | sitemapName: 'sitemap.xml',
21 | }, sources.flatMap(s => s.urls), {
22 | autoI18n: {
23 | locales: [
24 | { code: 'en', iso: 'en' },
25 | { code: 'fr', iso: 'fr' },
26 | // add 22 more locales
27 | ...Array.from({ length: 22 }, (_, i) => ({
28 | code: `code-${i}`,
29 | iso: `iso-${i}`,
30 | })),
31 | ],
32 | strategy: 'prefix',
33 | defaultLocale: 'en',
34 | },
35 | isI18nMapped: true,
36 | })
37 | }, {
38 | iterations: 1000,
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/test/bench/normalize.bench.ts:
--------------------------------------------------------------------------------
1 | import { bench, describe } from 'vitest'
2 | import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
3 | import type { SitemapSourceResolved } from '#sitemap'
4 |
5 | const sources: SitemapSourceResolved[] = [
6 | {
7 | urls: Array.from({ length: 3000 }, (_, i) => ({
8 | loc: `/foo-${i}`,
9 | })),
10 | context: {
11 | name: 'foo',
12 | },
13 | sourceType: 'user',
14 | },
15 | ]
16 |
17 | describe('normalize', () => {
18 | bench('preNormalizeEntry', () => {
19 | const urls = sources.flatMap(s => s.urls)
20 | urls.map(u => preNormalizeEntry(u))
21 | }, {
22 | iterations: 1000,
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/test/e2e/chunks/cache-headers.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | // Set up chunked sitemaps
8 | await setup({
9 | rootDir: resolve('../../fixtures/chunks'),
10 | nuxtConfig: {
11 | sitemap: {
12 | // Global automatic chunking
13 | chunks: true,
14 | defaultSitemapsChunkSize: 100,
15 | cacheMaxAgeSeconds: 900, // 15 minutes
16 | runtimeCacheStorage: {
17 | driver: 'memory', // Use memory driver to avoid Redis connection issues
18 | },
19 | },
20 | },
21 | })
22 |
23 | describe('chunked sitemap caching with headers', () => {
24 | it('should return proper cache headers for sitemap index', async () => {
25 | const response = await fetch('/sitemap_index.xml')
26 |
27 | expect(response.headers.get('content-type')).toMatch(/xml/)
28 |
29 | // Check cache headers
30 | const cacheControl = response.headers.get('cache-control')
31 | expect(cacheControl).toBeDefined()
32 | expect(cacheControl).toContain('max-age=900')
33 | expect(cacheControl).toContain('s-maxage=900')
34 | expect(cacheControl).toContain('public')
35 | expect(cacheControl).toContain('stale-while-revalidate')
36 |
37 | // Check debug headers
38 | expect(response.headers.get('X-Sitemap-Generated')).toBeDefined()
39 | expect(response.headers.get('X-Sitemap-Cache-Duration')).toBe('900s')
40 | expect(response.headers.get('X-Sitemap-Cache-Expires')).toBeDefined()
41 | expect(response.headers.get('X-Sitemap-Cache-Remaining')).toBeDefined()
42 |
43 | const xml = await response.text()
44 | expect(xml).toContain('')
46 | expect(xml).toContain('')
47 | }, 10000)
48 |
49 | it('should return proper cache headers for first chunk', async () => {
50 | const response = await fetch('/__sitemap__/0.xml')
51 |
52 | expect(response.headers.get('content-type')).toMatch(/xml/)
53 |
54 | // Check cache headers
55 | const cacheControl = response.headers.get('cache-control')
56 | expect(cacheControl).toBeDefined()
57 | expect(cacheControl).toContain('max-age=900')
58 | expect(cacheControl).toContain('s-maxage=900')
59 | expect(cacheControl).toContain('public')
60 | expect(cacheControl).toContain('stale-while-revalidate')
61 |
62 | // Check debug headers
63 | expect(response.headers.get('X-Sitemap-Generated')).toBeDefined()
64 | expect(response.headers.get('X-Sitemap-Cache-Duration')).toBe('900s')
65 | expect(response.headers.get('X-Sitemap-Cache-Expires')).toBeDefined()
66 | expect(response.headers.get('X-Sitemap-Cache-Remaining')).toBeDefined()
67 | })
68 |
69 | it('should properly generate chunked sitemaps in index', async () => {
70 | const response = await fetch('/sitemap_index.xml')
71 |
72 | const xml = await response.text()
73 | expect(xml).toContain(' {
11 | it('basic', async () => {
12 | let sitemap = await $fetch('/sitemap_index.xml')
13 | // remove lastmods before tresting
14 | sitemap = sitemap.replace(/lastmod>(.*?)<')
15 | // basic test to make sure we get a valid response
16 | expect(sitemap).toMatchInlineSnapshot(`
17 | "
18 |
19 |
20 |
21 | https://nuxtseo.com/__sitemap__/0.xml
22 |
23 |
24 | https://nuxtseo.com/__sitemap__/1.xml
25 |
26 |
27 | https://nuxtseo.com/__sitemap__/2.xml
28 |
29 |
30 | https://nuxtseo.com/__sitemap__/3.xml
31 |
32 | "
33 | `)
34 | const sitemap0 = await $fetch('/__sitemap__/0.xml')
35 | expect(sitemap0).toMatchInlineSnapshot(`
36 | "
37 |
38 |
39 | https://nuxtseo.com/foo/1
40 |
41 |
42 | https://nuxtseo.com/foo/2
43 |
44 |
45 | https://nuxtseo.com/foo/3
46 |
47 |
48 | https://nuxtseo.com/foo/4
49 |
50 |
51 | https://nuxtseo.com/foo/5
52 |
53 | "
54 | `)
55 | }, 60000)
56 | })
57 |
--------------------------------------------------------------------------------
/test/e2e/chunks/generate.test.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { describe, expect, it } from 'vitest'
3 | import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
4 |
5 | describe.skipIf(process.env.CI)('generate', () => {
6 | it('basic', async () => {
7 | process.env.NODE_ENV = 'production'
8 | // @ts-expect-error untyped
9 | process.env.prerender = true
10 | process.env.NITRO_PRESET = 'static'
11 | process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
12 | const { resolve } = createResolver(import.meta.url)
13 | const rootDir = resolve('../../fixtures/chunks')
14 | const nuxt = await loadNuxt({
15 | rootDir,
16 | overrides: {
17 | nitro: {
18 | preset: 'static',
19 | },
20 | _generate: true,
21 | },
22 | })
23 |
24 | await buildNuxt(nuxt)
25 |
26 | await new Promise(resolve => setTimeout(resolve, 1000))
27 |
28 | const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap_index.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
29 | // ignore lastmod entries
30 | expect(sitemap).toMatchInlineSnapshot(`
31 | "
32 |
33 |
34 |
35 | https://nuxtseo.com/__sitemap__/0.xml
36 |
37 |
38 | https://nuxtseo.com/__sitemap__/1.xml
39 |
40 |
41 | https://nuxtseo.com/__sitemap__/2.xml
42 |
43 |
44 | https://nuxtseo.com/__sitemap__/3.xml
45 |
46 | "
47 | `)
48 | const sitemapEn = (await readFile(resolve(rootDir, '.output/public/__sitemap__/0.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
49 | expect(sitemapEn).toMatchInlineSnapshot(`
50 | "
51 |
52 |
53 | https://nuxtseo.com/foo/1
54 |
55 |
56 | https://nuxtseo.com/foo/2
57 |
58 |
59 | https://nuxtseo.com/foo/3
60 |
61 |
62 | https://nuxtseo.com/foo/4
63 |
64 |
65 | https://nuxtseo.com/foo/5
66 |
67 | "
68 | `)
69 | }, 1200000)
70 | })
71 |
--------------------------------------------------------------------------------
/test/e2e/content-v3/yaml-json.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/content-v3'),
9 | nuxtConfig: {
10 | sitemap: {
11 | xsl: false,
12 | },
13 | site: {
14 | url: 'https://nuxtseo.com',
15 | },
16 | },
17 | })
18 |
19 | describe('content-v3 YAML/JSON', () => {
20 | it('basic', async () => {
21 | const sitemapContents = await $fetch('/sitemap.xml', { responseType: 'text' })
22 |
23 | // Check that YAML content with sitemap metadata is included
24 | expect(sitemapContents).toMatch('https://nuxtseo.com/test-yaml')
25 |
26 | // Check YAML sitemap metadata is extracted
27 | const yamlMatch = sitemapContents.match(/.*?https:\/\/nuxtseo\.com\/test-yaml<\/loc>.*?<\/url>/s)
28 | expect(yamlMatch).toBeTruthy()
29 | if (yamlMatch) {
30 | expect(yamlMatch[0]).toMatch('2025-05-13')
31 | expect(yamlMatch[0]).toMatch('monthly ')
32 | expect(yamlMatch[0]).toMatch('0.8 ')
33 | }
34 |
35 | // Check that JSON content with sitemap metadata is included
36 | expect(sitemapContents).toMatch('https://nuxtseo.com/test-json')
37 |
38 | // Check JSON sitemap metadata is extracted
39 | const jsonMatch = sitemapContents.match(/.*?https:\/\/nuxtseo\.com\/test-json<\/loc>.*?<\/url>/s)
40 | expect(jsonMatch).toBeTruthy()
41 | if (jsonMatch) {
42 | expect(jsonMatch[0]).toMatch('2025-05-14')
43 | expect(jsonMatch[0]).toMatch('weekly ')
44 | expect(jsonMatch[0]).toMatch('0.9 ')
45 | }
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/test/e2e/global-setup.ts:
--------------------------------------------------------------------------------
1 | import fsp from 'node:fs/promises'
2 | import { join } from 'node:path'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | const fixturesDir = fileURLToPath(new URL('../fixtures', import.meta.url))
6 |
7 | export default async function setup() {
8 | for (const project of await fsp.readdir(fixturesDir)) {
9 | await fsp.rm(join(fixturesDir, project, 'node_modules/.cache'), {
10 | recursive: true,
11 | force: true,
12 | })
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/test/e2e/hooks/sources-hook-simple.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { setup, $fetch } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | describe('sitemap:sources hook', async () => {
8 | await setup({
9 | rootDir: resolve('../../fixtures/sources-hook'),
10 | server: true,
11 | })
12 |
13 | it('can add new sources dynamically', async () => {
14 | const sitemap = await $fetch('/sitemap.xml')
15 |
16 | // Should have URLs from the dynamically added source
17 | expect(sitemap).toContain('https://example.com/dynamic-source-url ')
18 | })
19 |
20 | it('can modify existing sources', async () => {
21 | const sitemap = await $fetch('/sitemap.xml')
22 |
23 | // Should have URLs showing the headers were modified
24 | expect(sitemap).toContain('https://example.com/hook-modified ')
25 | })
26 |
27 | it('can filter out sources', async () => {
28 | const sitemap = await $fetch('/sitemap.xml')
29 |
30 | // The skipped source should not appear in the sitemap
31 | expect(sitemap).not.toContain('https://example.com/should-be-filtered ')
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/test/e2e/i18n/custom-paths-no-prefix.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n-no-prefix'),
9 | server: true,
10 | sitemap: {
11 | urls: [
12 | // test custom path mapping with no_prefix - should warn
13 | {
14 | loc: '/test',
15 | _i18nTransform: true,
16 | },
17 | {
18 | loc: '/about',
19 | _i18nTransform: true,
20 | },
21 | ],
22 | },
23 | })
24 |
25 | describe('i18n custom paths with no_prefix strategy', () => {
26 | it('should generate alternatives with custom paths even with no_prefix when pages config is present', async () => {
27 | // The actual behavior is that _i18nTransform works with no_prefix when pages config is present
28 | // This is counter-intuitive but is the current implementation
29 | const sitemap = await $fetch('/sitemap.xml')
30 |
31 | // The implementation still creates all locale variants with custom paths
32 | expect(sitemap).toContain('https://nuxtseo.com/test ')
33 | expect(sitemap).toContain('https://nuxtseo.com/about ')
34 | expect(sitemap).toContain('https://nuxtseo.com/prueba ')
35 | expect(sitemap).toContain('https://nuxtseo.com/teste ')
36 | expect(sitemap).toContain('https://nuxtseo.com/acerca-de ')
37 | expect(sitemap).toContain('https://nuxtseo.com/a-propos ')
38 |
39 | // And it includes hreflang alternatives
40 | expect(sitemap).toContain('xhtml:link')
41 | expect(sitemap).toContain('hreflang')
42 |
43 | // The warning is still issued because this is not recommended behavior
44 | })
45 |
46 | it('should have warning in dev mode for _i18nTransform with no_prefix', async () => {
47 | // The warning is important because while the transformation works,
48 | // it's not recommended with no_prefix strategy
49 | const sitemap = await $fetch('/sitemap.xml')
50 |
51 | // Even though it transforms, the warning tells users this is not intended behavior
52 | expect(sitemap).toContain('https://nuxtseo.com/test ')
53 | expect(sitemap).toContain('https://nuxtseo.com/about ')
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/test/e2e/i18n/filtering-include.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sitemaps: {
12 | main: {
13 | includeAppSources: true,
14 | include: ['/fr', '/en', '/fr/test', '/en/test'],
15 | },
16 | },
17 | },
18 | },
19 | })
20 | describe('i18n filtering with include', () => {
21 | it('basic', async () => {
22 | const sitemap = await $fetch('/__sitemap__/main.xml')
23 |
24 | expect(sitemap).toMatchInlineSnapshot(`
25 | "
26 |
27 |
28 | https://nuxtseo.com/en
29 |
30 |
31 |
32 |
33 |
34 |
35 | https://nuxtseo.com/fr
36 |
37 |
38 |
39 |
40 |
41 |
42 | https://nuxtseo.com/en/test
43 |
44 |
45 |
46 |
47 |
48 |
49 | https://nuxtseo.com/fr/test
50 |
51 |
52 |
53 |
54 |
55 | "
56 | `)
57 | }, 60000)
58 | })
59 |
--------------------------------------------------------------------------------
/test/e2e/i18n/filtering-regexp.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n'),
9 | nuxtConfig: {
10 | sitemap: {
11 | exclude: [
12 | /.*test.*/g,
13 | /.no-i18n/,
14 | '/en/__sitemap/**',
15 | '/__sitemap/**',
16 | // exclude fr
17 | '/fr',
18 | ],
19 | },
20 | },
21 | })
22 | describe('i18n filtering with regexp', () => {
23 | it('basic', async () => {
24 | let sitemap = await $fetch('/__sitemap__/en-US.xml')
25 |
26 | // strip lastmod
27 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
28 |
29 | expect(sitemap).toMatchInlineSnapshot(`
30 | "
31 |
32 |
33 | https://nuxtseo.com/en
34 |
35 |
36 |
37 |
38 | "
39 | `)
40 | }, 60000)
41 | })
42 |
--------------------------------------------------------------------------------
/test/e2e/i18n/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n'),
9 | nuxtConfig: {
10 | sitemap: {
11 | exclude: [
12 | '/test',
13 | ],
14 | },
15 | },
16 | })
17 | describe('i18n filtering', () => {
18 | it('basic', async () => {
19 | let sitemap = await $fetch('/__sitemap__/en-US.xml')
20 |
21 | // strip lastmod
22 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
23 |
24 | expect(sitemap).toMatchInlineSnapshot(`
25 | "
26 |
27 |
28 | https://nuxtseo.com/en
29 |
30 |
31 |
32 |
33 |
34 |
35 | https://nuxtseo.com/no-i18n
36 |
37 |
38 |
39 |
40 | https://nuxtseo.com/en/__sitemap/url
41 | weekly
42 |
43 |
44 |
45 |
46 |
47 | "
48 | `)
49 | }, 60000)
50 | })
51 |
--------------------------------------------------------------------------------
/test/e2e/i18n/no-prefix.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n'),
9 | build: true,
10 | server: true,
11 | nuxtConfig: {
12 | i18n: {
13 | locales: [
14 | 'en',
15 | 'fr',
16 | ],
17 | strategy: 'no_prefix',
18 | },
19 | sitemap: {
20 | urls: ['/extra'],
21 | sitemaps: false,
22 | },
23 | },
24 | })
25 | describe('i18n prefix', () => {
26 | it('basic', async () => {
27 | const posts = await $fetch('/sitemap.xml')
28 |
29 | expect(posts).toMatchInlineSnapshot(`
30 | "
31 |
32 |
33 | https://nuxtseo.com/
34 |
35 |
36 | https://nuxtseo.com/extra
37 |
38 |
39 | https://nuxtseo.com/no-i18n
40 |
41 |
42 | https://nuxtseo.com/test
43 |
44 |
45 | https://nuxtseo.com/__sitemap/url
46 | weekly
47 |
48 | "
49 | `)
50 | }, 60000)
51 | })
52 |
--------------------------------------------------------------------------------
/test/e2e/i18n/pages.disabled-routes.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/i18n'),
9 | build: true,
10 | server: true,
11 | nuxtConfig: {
12 | i18n: {
13 | baseUrl: 'https://i18n-locale-test.com',
14 | locales: [
15 | { code: 'en', iso: 'en-US', name: 'English' },
16 | { code: 'fr', iso: 'fr-FR', name: 'Français' },
17 | ],
18 | defaultLocale: 'en',
19 | strategy: 'no_prefix',
20 | pages: {
21 | '/about': {
22 | en: '/about',
23 | fr: false, // Disabled route
24 | },
25 | '/contact': {
26 | en: '/contact',
27 | fr: '/contact',
28 | },
29 | },
30 | },
31 | },
32 | })
33 |
34 | describe('i18n pages with disabled routes', () => {
35 | it('handles disabled routes properly with no_prefix strategy', async () => {
36 | const xml = await $fetch('/sitemap.xml')
37 |
38 | // Should not throw error and contain urlset
39 | expect(xml).toContain('https://i18n-locale-test.com/about ')
43 | expect(xml).toContain('https://i18n-locale-test.com/contact ')
44 |
45 | // Disabled routes should not have alternatives pointing to them
46 | expect(xml).not.toContain('/fr/about')
47 |
48 | // Alternatives should only include enabled routes
49 | const urlPattern = /(.*?)<\/url>/gs
50 | const urls = xml.match(urlPattern) || []
51 |
52 | // Find the about URL
53 | const aboutUrl = urls.find((url: string) => url.includes('/about '))
54 | if (aboutUrl) {
55 | // There should be only one alternate link for the English version
56 | const alternateLinks = aboutUrl.match(/]*\/>/g) || []
57 | expect(alternateLinks.length).toBeLessThanOrEqual(2) // at most en-US and x-default
58 | expect(aboutUrl).not.toContain('hreflang="fr-FR"') // French alternative should not exist
59 | }
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/test/e2e/multi/chunking-edge-cases.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/multi-with-chunks'),
9 | server: true,
10 | nuxtConfig: {
11 | hooks: {
12 | 'nitro:config': function (config) {
13 | config.runtimeConfig ??= {}
14 | config.runtimeConfig.public ??= {}
15 | config.runtimeConfig.public.siteUrl = 'https://nuxtseo.com'
16 | },
17 | },
18 | },
19 | })
20 |
21 | describe('chunking edge cases', () => {
22 | describe('empty chunks', () => {
23 | it('returns 404 for non-existent chunk', async () => {
24 | // The posts sitemap has 12 posts with chunkSize: 3, so it should have chunks 0-3
25 | // Chunk 4 should not exist
26 | try {
27 | await $fetch('/__sitemap__/posts-4.xml')
28 | throw new Error('Should have thrown 404')
29 | }
30 | catch (error: any) {
31 | expect(error.data?.statusCode || error.statusCode).toBe(404)
32 | }
33 | })
34 |
35 | it('returns 404 for chunk of non-chunked sitemap', async () => {
36 | // pages sitemap doesn't have chunking enabled
37 | try {
38 | await $fetch('/__sitemap__/pages-0.xml')
39 | throw new Error('Should have thrown 404')
40 | }
41 | catch (error: any) {
42 | expect(error.data?.statusCode || error.statusCode).toBe(404)
43 | }
44 | })
45 | })
46 |
47 | describe('chunk boundary validation', () => {
48 | it('handles last valid chunk', async () => {
49 | // posts has 12 items with chunkSize: 3, so chunk 3 (the 4th chunk) is the last valid one
50 | const chunk = await $fetch('/__sitemap__/posts-3.xml')
51 | expect(chunk).toContain('https://nuxtseo.com/posts/10 ')
53 | expect(chunk).toContain('https://nuxtseo.com/posts/11 ')
54 | expect(chunk).toContain('https://nuxtseo.com/posts/12 ')
55 | })
56 |
57 | it('handles products chunk boundaries', async () => {
58 | // products has 25 items with chunkSize: 10
59 | // chunk 0: 1-10, chunk 1: 11-20, chunk 2: 21-25
60 |
61 | const chunk2 = await $fetch('/__sitemap__/products-2.xml')
62 | expect(chunk2).toContain('https://nuxtseo.com/products/21 ')
64 | expect(chunk2).toContain('https://nuxtseo.com/products/25 ')
65 |
66 | // chunk 3 should not exist
67 | try {
68 | await $fetch('/__sitemap__/products-3.xml')
69 | throw new Error('Should have thrown 404')
70 | }
71 | catch (error: any) {
72 | expect(error.data?.statusCode || error.statusCode).toBe(404)
73 | }
74 | })
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/test/e2e/multi/defaults.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sitemaps: {
12 | foo: {
13 | include: ['/foo/*'],
14 | urls: [
15 | '/foo/1',
16 | '/foo/2',
17 | ],
18 | defaults: {
19 | changefreq: 'weekly',
20 | priority: 0.7,
21 | },
22 | },
23 | bar: {
24 | urls: [
25 | '/bar/1',
26 | '/bar/2',
27 | ],
28 | defaults: {
29 | changefreq: 'monthly',
30 | priority: 0.5,
31 | },
32 | },
33 | },
34 | },
35 | },
36 | })
37 | describe('mutli defaults', () => {
38 | it('basic', async () => {
39 | let sitemap = await $fetch('/__sitemap__/foo.xml')
40 | // remove lastmods before tresting
41 | sitemap = sitemap.replace(/lastmod>(.*?)<')
42 | // basic test to make sure we get a valid response
43 | expect(sitemap).toMatchInlineSnapshot(`
44 | "
45 |
46 |
47 | https://nuxtseo.com/foo/1
48 | weekly
49 | 0.7
50 |
51 |
52 | https://nuxtseo.com/foo/2
53 | weekly
54 | 0.7
55 |
56 | "
57 | `)
58 | }, 60000)
59 | })
60 |
--------------------------------------------------------------------------------
/test/e2e/multi/endpoints.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sitemaps: {
12 | foo: {
13 | sources: ['/api/sitemap/foo'],
14 | defaults: {
15 | changefreq: 'weekly',
16 | priority: 0.7,
17 | },
18 | },
19 | bar: {
20 | sources: ['/api/sitemap/bar'],
21 | },
22 | },
23 | },
24 | },
25 | })
26 | describe('multi endpoints', () => {
27 | it('basic', async () => {
28 | let sitemap = await $fetch('/__sitemap__/foo.xml')
29 | // remove lastmods before tresting
30 | sitemap = sitemap.replace(/lastmod>(.*?)<')
31 | // basic test to make sure we get a valid response
32 | expect(sitemap).toMatchInlineSnapshot(`
33 | "
34 |
35 |
36 | https://nuxtseo.com/foo/1
37 | weekly
38 | 0.7
39 |
40 |
41 | https://nuxtseo.com/foo/2
42 | weekly
43 | 0.7
44 |
45 |
46 | https://nuxtseo.com/foo/3
47 | weekly
48 | 0.7
49 |
50 |
51 | https://nuxtseo.com/foo/4
52 | weekly
53 | 0.7
54 |
55 |
56 | https://nuxtseo.com/foo/5
57 | weekly
58 | 0.7
59 |
60 | "
61 | `)
62 | }, 60000)
63 | })
64 |
--------------------------------------------------------------------------------
/test/e2e/multi/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sitemaps: {
12 | foo: {
13 | urls: [
14 | // default blocked routes
15 | '/_nuxt',
16 | '/_nuxt/foo',
17 | '/api',
18 | '/api/foo',
19 | '/api/foo/bar',
20 | // custom blocked routes
21 | '/admin',
22 | '/admin/foo',
23 | '/admin/foo/bar',
24 | // should be only route
25 | '/valid',
26 | ],
27 | exclude: [
28 | '/api',
29 | '/api/**',
30 | '/admin/**',
31 | ],
32 | },
33 | },
34 | },
35 | },
36 | })
37 | describe('multi filtering', () => {
38 | it('basic', async () => {
39 | let sitemap = await $fetch('/__sitemap__/foo.xml')
40 |
41 | // strip lastmod
42 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
43 |
44 | expect(sitemap).toMatchInlineSnapshot(`
45 | "
46 |
47 |
48 | https://nuxtseo.com/valid
49 |
50 | "
51 | `)
52 | }, 60000)
53 | })
54 |
--------------------------------------------------------------------------------
/test/e2e/single/baseUrl.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | app: {
11 | baseURL: '/base',
12 | },
13 | },
14 | })
15 | describe('base', () => {
16 | it('basic', async () => {
17 | let sitemap = await $fetch('/base/sitemap.xml')
18 | expect(sitemap).not.match(/\/base\/base\//g)
19 | sitemap = sitemap.replace(/lastmod>(.*?)<')
20 | expect(sitemap).toMatchInlineSnapshot(`
21 | "
22 |
23 |
24 | https://nuxtseo.com/base
25 |
26 |
27 | https://nuxtseo.com/base/about
28 |
29 |
30 | https://nuxtseo.com/base/crawled
31 |
32 |
33 | https://nuxtseo.com/base/sub/page
34 |
35 | "
36 | `)
37 | }, 60000)
38 | })
39 |
--------------------------------------------------------------------------------
/test/e2e/single/baseUrlTrailingSlash.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | app: {
11 | baseURL: '/subdir/',
12 | },
13 | site: {
14 | trailingSlash: true,
15 | },
16 | },
17 | })
18 | describe('base url trailing slash', () => {
19 | it('basic', async () => {
20 | const sitemap = await $fetch('/subdir/sitemap.xml')
21 | expect(sitemap).toMatchInlineSnapshot(`
22 | "
23 |
24 |
25 | https://nuxtseo.com/subdir/
26 |
27 |
28 | https://nuxtseo.com/subdir/about/
29 |
30 |
31 | https://nuxtseo.com/subdir/crawled/
32 |
33 |
34 | https://nuxtseo.com/subdir/sub/page/
35 |
36 | "
37 | `)
38 | }, 60000)
39 | })
40 |
--------------------------------------------------------------------------------
/test/e2e/single/changeApiUrl.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sources: ['/__sitemap'],
12 | },
13 | },
14 | })
15 | describe('base', () => {
16 | it('basic', async () => {
17 | const posts = await $fetch('/__sitemap')
18 |
19 | expect(posts).toMatchInlineSnapshot(`
20 | [
21 | "/__sitemap/url",
22 | {
23 | "loc": "/__sitemap/loc",
24 | },
25 | {
26 | "loc": "https://nuxtseo.com/__sitemap/abs",
27 | },
28 | ]
29 | `)
30 | }, 60000)
31 | })
32 |
--------------------------------------------------------------------------------
/test/e2e/single/filtering.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | excludeAppSources: true,
12 | urls: [
13 | // default blocked routes
14 | '/_nuxt',
15 | '/_nuxt/foo',
16 | '/api',
17 | '/api/foo',
18 | '/api/foo/bar',
19 | // custom blocked routes
20 | '/admin',
21 | '/admin/foo',
22 | '/admin/foo/bar',
23 | // should be only route
24 | '/valid',
25 | ],
26 | exclude: [
27 | '/api/**',
28 | '/admin/**',
29 | ],
30 | },
31 | },
32 | })
33 | describe('filtering', () => {
34 | it('basic', async () => {
35 | let sitemap = await $fetch('/sitemap.xml')
36 |
37 | // strip lastmod
38 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
39 |
40 | expect(sitemap).toMatchInlineSnapshot(`
41 | "
42 |
43 |
44 | https://nuxtseo.com/valid
45 |
46 | "
47 | `)
48 | }, 60000)
49 | })
50 |
--------------------------------------------------------------------------------
/test/e2e/single/generate.test.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { describe, expect, it } from 'vitest'
3 | import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
4 |
5 | describe.skipIf(process.env.CI)('generate', () => {
6 | it('basic', async () => {
7 | process.env.NODE_ENV = 'production'
8 | // @ts-expect-error untyped
9 | process.env.prerender = true
10 | process.env.NITRO_PRESET = 'static'
11 | process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
12 | const { resolve } = createResolver(import.meta.url)
13 | const rootDir = resolve('../../fixtures/generate')
14 | const nuxt = await loadNuxt({
15 | rootDir,
16 | overrides: {
17 | nitro: {
18 | preset: 'static',
19 | },
20 | _generate: true,
21 | },
22 | })
23 | await buildNuxt(nuxt)
24 |
25 | await new Promise(resolve => setTimeout(resolve, 1000))
26 |
27 | const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
28 | // ignore lastmod entries
29 | expect(sitemap).toMatchInlineSnapshot(`
30 | "
31 |
32 |
33 | https://nuxtseo.com/
34 |
35 |
36 | https://nuxtseo.com/about
37 |
38 |
39 | https://nuxtseo.com/crawled
40 |
41 |
42 | https://nuxtseo.com/noindex
43 |
44 |
45 | https://nuxtseo.com/dynamic/crawled
46 |
47 |
48 | https://nuxtseo.com/sub/page
49 |
50 | "
51 | `)
52 | // verify /noindex is not in the sitemap
53 | expect(sitemap).not.toContain('/noindex')
54 | }, 1200000)
55 | })
56 |
--------------------------------------------------------------------------------
/test/e2e/single/lastmod.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | urls: [
12 | {
13 | loc: '/foo',
14 | // valid but with milliseconds, should be removed
15 | lastmod: '2023-12-21T13:49:27.963745',
16 | },
17 | {
18 | loc: 'bar',
19 | lastmod: '2023-12-21', // valid - no timezone
20 | },
21 | {
22 | loc: 'baz',
23 | lastmod: '2023-12-21T13:49:27', // valid - timezone
24 | },
25 | {
26 | loc: 'qux',
27 | lastmod: '2023-12-21T13:49:27Z',
28 | },
29 | {
30 | loc: 'quux',
31 | lastmod: '2023 tuesday 3rd march', // very broken
32 | },
33 | {
34 | loc: '/issue/206',
35 | lastmod: '2023-12-21T22:46:58.441+00:00',
36 | },
37 | ],
38 | },
39 | },
40 | })
41 | describe('lastmod', () => {
42 | it('basic', async () => {
43 | const sitemap = await $fetch('/sitemap.xml')
44 |
45 | expect(sitemap).toMatchInlineSnapshot(`
46 | "
47 |
48 |
49 | https://nuxtseo.com/
50 |
51 |
52 | https://nuxtseo.com/about
53 |
54 |
55 | https://nuxtseo.com/bar
56 | 2023-12-21
57 |
58 |
59 | https://nuxtseo.com/baz
60 | 2023-12-21T13:49:27Z
61 |
62 |
63 | https://nuxtseo.com/crawled
64 |
65 |
66 | https://nuxtseo.com/foo
67 | 2023-12-21T13:49:27Z
68 |
69 |
70 | https://nuxtseo.com/quux
71 |
72 |
73 | https://nuxtseo.com/qux
74 | 2023-12-21T13:49:27Z
75 |
76 |
77 | https://nuxtseo.com/issue/206
78 | 2023-12-21T22:46:58Z
79 |
80 |
81 | https://nuxtseo.com/sub/page
82 |
83 | "
84 | `)
85 | }, 60000)
86 | })
87 |
--------------------------------------------------------------------------------
/test/e2e/single/news.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | urls() {
12 | return [
13 | {
14 | loc: 'https://nuxtseo.com/',
15 | news: {
16 | publication: {
17 | name: 'Nuxt SEO',
18 | language: 'en',
19 | },
20 | title: 'Nuxt SEO',
21 | publication_date: '2008-12-23',
22 | },
23 | },
24 | {
25 | loc: 'https://harlanzw.com/',
26 | news: {
27 | publication: {
28 | name: 'Harlan Wilton',
29 | language: 'en',
30 | },
31 | title: 'Sitemap test',
32 | publication_date: '2008-12-23',
33 | },
34 | },
35 | ]
36 | },
37 | },
38 | },
39 | })
40 | describe('news', () => {
41 | it('basic', async () => {
42 | let sitemap = await $fetch('/sitemap.xml')
43 |
44 | // strip lastmod
45 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
46 |
47 | expect(sitemap).toMatchInlineSnapshot(`
48 | "
49 |
50 |
51 | https://harlanzw.com/
52 |
53 |
54 | Harlan Wilton
55 | en
56 |
57 | Sitemap test
58 | 2008-12-23
59 |
60 |
61 |
62 | https://nuxtseo.com/
63 |
64 |
65 | Nuxt SEO
66 | en
67 |
68 | Nuxt SEO
69 | 2008-12-23
70 |
71 |
72 |
73 | https://nuxtseo.com/about
74 |
75 |
76 | https://nuxtseo.com/crawled
77 |
78 |
79 | https://nuxtseo.com/sub/page
80 |
81 | "
82 | `)
83 | }, 60000)
84 | })
85 |
--------------------------------------------------------------------------------
/test/e2e/single/queryRoutes.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | urls: [
12 | '/',
13 | '/query-no-slash?foo=bar',
14 | '/query-slash/?foo=bar',
15 | '/query-slash-hash/?foo=bar#hash',
16 | ],
17 | },
18 | },
19 | })
20 | describe('query routes', () => {
21 | it('basic', async () => {
22 | const sitemap = await $fetch('/sitemap.xml')
23 |
24 | expect(sitemap).toContain('https://nuxtseo.com/query-no-slash?foo=bar ')
25 | expect(sitemap).toContain('https://nuxtseo.com/query-slash?foo=bar ')
26 | expect(sitemap).not.toContain('https://nuxtseo.com/query-slash-hash?foo=bar#hash ')
27 | }, 60000)
28 | })
29 |
--------------------------------------------------------------------------------
/test/e2e/single/routeRules.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | excludeAppSources: true,
12 | urls: ['/x-robots-tag', '/redirect', '/hidden', '/defaults', '/wildcard/defaults/foo', '/wildcard/hidden/foo'],
13 | },
14 | routeRules: {
15 | '/x-robots-tag': {
16 | headers: {
17 | 'x-robots-tag': 'noindex',
18 | },
19 | },
20 | // won't be indexed
21 | '/redirect': {
22 | redirect: '/defaults',
23 | },
24 | '/hidden': {
25 | // @ts-expect-error untyped
26 | robots: false,
27 | },
28 | '/defaults': {
29 | sitemap: {
30 | changefreq: 'daily',
31 | priority: 1,
32 | },
33 | },
34 | '/wildcard/defaults/**': {
35 | sitemap: {
36 | changefreq: 'daily',
37 | priority: 1,
38 | },
39 | },
40 | '/wildcard/hidden/**': {
41 | // @ts-expect-error untyped
42 | robots: false,
43 | },
44 | },
45 | },
46 | })
47 | describe('route rules', () => {
48 | it('basic', async () => {
49 | let sitemap = await $fetch('/sitemap.xml')
50 |
51 | // strip lastmod
52 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
53 |
54 | expect(sitemap).toMatchInlineSnapshot(`
55 | "
56 |
57 |
58 | https://nuxtseo.com/defaults
59 | daily
60 | 1
61 |
62 |
63 | https://nuxtseo.com/wildcard/defaults/foo
64 | daily
65 | 1
66 |
67 | "
68 | `)
69 | }, 60000)
70 | })
71 |
--------------------------------------------------------------------------------
/test/e2e/single/routeRulesTrailingSlash.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | site: {
11 | trailingSlash: true,
12 | },
13 | sitemap: {
14 | excludeAppSources: true,
15 | urls: ['/hidden/', '/defaults/', '/wildcard/defaults/foo/', '/wildcard/hidden/foo/'],
16 | },
17 | routeRules: {
18 | '/hidden': {
19 | // @ts-expect-error untyped
20 | robots: false,
21 | },
22 | '/hidden/': {
23 | // @ts-expect-error untyped
24 | robots: false,
25 | },
26 | '/defaults': {
27 | sitemap: {
28 | changefreq: 'daily',
29 | priority: 1,
30 | },
31 | },
32 | '/wildcard/defaults/**': {
33 | sitemap: {
34 | changefreq: 'daily',
35 | priority: 1,
36 | },
37 | },
38 | '/wildcard/hidden/**': {
39 | // @ts-expect-error untyped
40 | robots: false,
41 | },
42 | },
43 | },
44 | })
45 | describe('route rules', () => {
46 | it('basic', async () => {
47 | let sitemap = await $fetch('/sitemap.xml')
48 |
49 | // strip lastmod
50 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
51 |
52 | expect(sitemap).toMatchInlineSnapshot(`
53 | "
54 |
55 |
56 | https://nuxtseo.com/defaults/
57 | daily
58 | 1
59 |
60 |
61 | https://nuxtseo.com/wildcard/defaults/foo/
62 | daily
63 | 1
64 |
65 | "
66 | `)
67 | }, 60000)
68 | })
69 |
--------------------------------------------------------------------------------
/test/e2e/single/sitemapName.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | sitemapName: 'test.xml',
12 | },
13 | },
14 | })
15 | describe('sitemapName', () => {
16 | it('basic', async () => {
17 | let sitemap = await $fetch('/test.xml')
18 | // remove lastmods before tresting
19 | sitemap = sitemap.replace(/lastmod>(.*?)<')
20 | // basic test to make sure we get a valid response
21 | expect(sitemap).toMatchInlineSnapshot(`
22 | "
23 |
24 |
25 | https://nuxtseo.com/
26 |
27 |
28 | https://nuxtseo.com/about
29 |
30 |
31 | https://nuxtseo.com/crawled
32 |
33 |
34 | https://nuxtseo.com/sub/page
35 |
36 | "
37 | `)
38 | }, 60000)
39 | })
40 |
--------------------------------------------------------------------------------
/test/e2e/single/trailingSlashes.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | site: {
11 | url: 'https://nuxtseo.com',
12 | trailingSlash: true,
13 | },
14 | sitemap: {
15 | // test from endpoint as well
16 | sources: ['/__sitemap'],
17 | },
18 | },
19 | })
20 | describe('trailing slashes', () => {
21 | it('basic', async () => {
22 | const sitemap = await $fetch('/sitemap.xml')
23 | // extract the URLs from loc using regex
24 | // @ts-expect-error untyped
25 | const sitemapUrls = sitemap.match(/(.*?)<\/loc>/g)!.map(url => url.replace(/<\/?loc>/g, ''))
26 | // @ts-expect-error untyped
27 | sitemapUrls.forEach((url) => {
28 | expect(url.endsWith('/')).toBeTruthy()
29 | })
30 | }, 60000)
31 | })
32 |
--------------------------------------------------------------------------------
/test/e2e/single/urlEncoded.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | urls: [
12 | '/Bücher',
13 | '/Bibliothèque',
14 | ],
15 | },
16 | },
17 | })
18 | describe('query routes', () => {
19 | it('should be url encoded', async () => {
20 | const sitemap = await $fetch('/sitemap.xml')
21 |
22 | expect(sitemap).toContain('https://nuxtseo.com/B%C3%BCcher ')
23 | expect(sitemap).toContain('https://nuxtseo.com/Biblioth%C3%A8que ')
24 | expect(sitemap).not.toContain('https://nuxtseo.com/Bücher')
25 | expect(sitemap).not.toContain('https://nuxtseo.com/Bibliothèque')
26 | }, 60000)
27 | })
28 |
--------------------------------------------------------------------------------
/test/e2e/single/xsl.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { createResolver } from '@nuxt/kit'
3 | import { $fetch, setup } from '@nuxt/test-utils'
4 |
5 | const { resolve } = createResolver(import.meta.url)
6 |
7 | await setup({
8 | rootDir: resolve('../../fixtures/basic'),
9 | nuxtConfig: {
10 | sitemap: {
11 | xsl: false,
12 | },
13 | },
14 | })
15 | describe('xsl false', () => {
16 | it('basic', async () => {
17 | let sitemap = await $fetch('/sitemap.xml')
18 |
19 | // strip lastmod
20 | sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
21 |
22 | expect(sitemap).toMatchInlineSnapshot(`
23 | "
24 |
25 |
26 | https://nuxtseo.com/
27 |
28 |
29 | https://nuxtseo.com/about
30 |
31 |
32 | https://nuxtseo.com/crawled
33 |
34 |
35 | https://nuxtseo.com/sub/page
36 |
37 | "
38 | `)
39 | }, 60000)
40 | })
41 |
--------------------------------------------------------------------------------
/test/fixtures/basic/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 |
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 |
13 | routeRules: {
14 | '/foo-redirect': {
15 | redirect: '/foo',
16 | },
17 | },
18 |
19 | compatibilityDate: '2025-01-15',
20 |
21 | sitemap: {
22 | autoLastmod: false,
23 | credits: false,
24 | debug: true,
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/test/fixtures/basic/pages/about.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 | About
6 |
7 |
--------------------------------------------------------------------------------
/test/fixtures/basic/pages/crawled.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
hi
4 |
5 | dynamic crawl
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/basic/pages/dynamic/[slug].vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/basic/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Hello World
4 |
5 | crawled
6 |
7 |
8 | should be ignored as its a redirect
9 |
10 |
11 |
12 | sitemap.xml
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/test/fixtures/basic/pages/sub/page.vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/basic/server/api/sitemap/bar.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/bar/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/basic/server/api/sitemap/foo.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/foo/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/basic/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | return [
5 | '/__sitemap/url',
6 | {
7 | loc: '/__sitemap/loc',
8 | },
9 | {
10 | loc: 'https://nuxtseo.com/__sitemap/abs',
11 | },
12 | ]
13 | })
14 |
--------------------------------------------------------------------------------
/test/fixtures/chunks/app.vue:
--------------------------------------------------------------------------------
1 |
2 | hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/chunks/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 | site: {
9 | url: 'https://nuxtseo.com',
10 | },
11 | sitemap: {
12 | autoLastmod: false,
13 | credits: false,
14 | debug: true,
15 | defaultSitemapsChunkSize: 5,
16 | sitemaps: true,
17 | urls: Array.from({ length: 20 }, (_, i) => `/foo/${i + 1}`),
18 | excludeAppSources: true,
19 | },
20 | })
21 |
--------------------------------------------------------------------------------
/test/fixtures/chunks/server/api/sitemap/bar.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/bar/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/chunks/server/api/sitemap/foo.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/foo/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/chunks/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | return [
5 | '/__sitemap/url',
6 | {
7 | loc: '/__sitemap/loc',
8 | },
9 | {
10 | loc: 'https://nuxtseo.com/__sitemap/abs',
11 | },
12 | ]
13 | })
14 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/.nuxtrc:
--------------------------------------------------------------------------------
1 | imports.autoImport=true
2 | typescript.includeWorkspace=true
3 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve, dirname } from 'node:path'
2 | import { defineCollection, defineContentConfig, z } from '@nuxt/content'
3 | import { asSitemapCollection } from '../../../src/content'
4 |
5 | // conjvert file path to url
6 | const dirName = dirname(import.meta.url.replace('file://', ''))
7 |
8 | export default defineContentConfig({
9 | collections: {
10 | content: defineCollection(
11 | asSitemapCollection({
12 | type: 'page',
13 | source: {
14 | include: '**/*',
15 | cwd: resolve(dirName, 'content'),
16 | },
17 | schema: z.object({
18 | date: z.string().optional(),
19 | }),
20 | }),
21 | ),
22 | },
23 | })
24 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/.navigation.yml:
--------------------------------------------------------------------------------
1 |
2 | title: 'test'
3 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/_partial.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap: false
3 | ---
4 |
5 | # bar
6 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/bar.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | lastmod: 2021-10-20
4 | priority: 0.5
5 | changefreq: daily
6 | ---
7 |
8 | # bar
9 |
10 |
11 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/foo.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | priority: 0.5
4 | ---
5 |
6 | # foo
7 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/posts/.navigation.yml:
--------------------------------------------------------------------------------
1 | title: 'test'
2 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/posts/bar.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | lastmod: 2021-10-20
4 | ---
5 | # bar
6 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/posts/fallback.md:
--------------------------------------------------------------------------------
1 | ---
2 | sitemap:
3 | lastmod: 2021-10-20
4 | ---
5 |
6 | # foo
7 |
8 | no sitemap config
9 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/posts/foo.md:
--------------------------------------------------------------------------------
1 | # foo
2 |
3 | no sitemap config
4 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/test-json.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Test JSON Content",
3 | "description": "This is a test JSON file for sitemap",
4 | "sitemap": {
5 | "lastmod": "2025-05-14",
6 | "changefreq": "weekly",
7 | "priority": 0.9
8 | },
9 | "content": "This is some JSON content"
10 | }
--------------------------------------------------------------------------------
/test/fixtures/content-v3/content/test-yaml.yml:
--------------------------------------------------------------------------------
1 | title: Test YAML Content
2 | description: This is a test YAML file for sitemap
3 | sitemap:
4 | lastmod: 2025-05-13
5 | changefreq: monthly
6 | priority: 0.8
7 | content: This is some YAML content
--------------------------------------------------------------------------------
/test/fixtures/content-v3/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | export default defineNuxtConfig({
4 | modules: [
5 | NuxtSitemap,
6 | '@nuxt/content',
7 | ],
8 |
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 | compatibilityDate: '2024-12-06',
13 |
14 | sitemap: {
15 | autoLastmod: false,
16 | credits: false,
17 | debug: true,
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/test/fixtures/content-v3/pages/[...slug].vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
17 |
18 | Page not found
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/test/fixtures/generate/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 |
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 |
13 | routeRules: {
14 | '/foo-redirect': {
15 | redirect: '/foo',
16 | },
17 | },
18 |
19 | compatibilityDate: '2025-01-15',
20 |
21 | nitro: {
22 | prerender: {
23 | crawlLinks: true,
24 | routes: ['/', '/about', '/noindex', '/sub/page'],
25 | },
26 | },
27 |
28 | sitemap: {
29 | autoLastmod: false,
30 | credits: false,
31 | debug: true,
32 | },
33 | })
34 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/about.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 | About
6 |
7 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/crawled.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
hi
4 |
5 | dynamic crawl
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/dynamic/[slug].vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Hello World
4 |
5 | crawled
6 |
7 |
8 | should be ignored as its a redirect
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/noindex.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | This page should not be in the sitemap
13 |
14 |
--------------------------------------------------------------------------------
/test/fixtures/generate/pages/sub/page.vue:
--------------------------------------------------------------------------------
1 |
2 | Hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/generate/server/api/sitemap/bar.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/bar/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/generate/server/api/sitemap/foo.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | const posts = Array.from({ length: 5 }, (_, i) => i + 1)
5 | return [
6 | ...posts.map(post => ({
7 | loc: `/foo/${post}`,
8 | })),
9 | ]
10 | })
11 |
--------------------------------------------------------------------------------
/test/fixtures/generate/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | return [
5 | '/__sitemap/url',
6 | {
7 | loc: '/__sitemap/loc',
8 | },
9 | {
10 | loc: 'https://nuxtseo.com/__sitemap/abs',
11 | },
12 | ]
13 | })
14 |
--------------------------------------------------------------------------------
/test/fixtures/hooks/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 |
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 |
13 | routeRules: {
14 | '/foo-redirect': {
15 | redirect: '/foo',
16 | },
17 | },
18 |
19 | compatibilityDate: '2025-01-15',
20 |
21 | sitemap: {
22 | autoLastmod: false,
23 | credits: false,
24 | debug: true,
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/test/fixtures/hooks/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
--------------------------------------------------------------------------------
/test/fixtures/hooks/server/plugins/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineNitroPlugin } from 'nitropack/runtime'
2 |
3 | export default defineNitroPlugin((nitroApp) => {
4 | nitroApp.hooks.hook('sitemap:input', async (ctx) => {
5 | ctx.urls.push({
6 | loc: '/test-1',
7 | })
8 |
9 | ctx.urls.push({
10 | loc: '/test-2',
11 | })
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/test/fixtures/hooks/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | return [
5 | '/__sitemap/url',
6 | {
7 | loc: '/__sitemap/loc',
8 | },
9 | {
10 | loc: 'https://nuxtseo.com/__sitemap/abs',
11 | },
12 | ]
13 | })
14 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/locales/en.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/locales/hr.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/locales/ja.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/locales/nl.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/locales/zh.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: '欢迎光临',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | export default defineNuxtConfig({
4 | modules: [
5 | NuxtSitemap,
6 | 'nuxt-i18n-micro',
7 | ],
8 | site: {
9 | url: 'https://nuxtseo.com',
10 | },
11 |
12 | compatibilityDate: '2024-07-22',
13 | nitro: {
14 | prerender: {
15 | failOnError: false,
16 | ignore: ['/'],
17 | },
18 | },
19 | i18n: {
20 | baseUrl: 'https://nuxtseo.com',
21 | detectBrowserLanguage: false,
22 | defaultLocale: 'en',
23 | strategy: 'prefix',
24 | locales: [
25 | {
26 | code: 'en',
27 | iso: 'en-US',
28 | },
29 | {
30 | code: 'es',
31 | iso: 'es-ES',
32 | },
33 | {
34 | code: 'fr',
35 | iso: 'fr-FR',
36 | },
37 | ],
38 | meta: true,
39 | },
40 | sitemap: {
41 | sources: ['/__sitemap'],
42 | autoLastmod: false,
43 | credits: false,
44 | debug: true,
45 | },
46 | })
47 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/pages/dynamic/[page].vue:
--------------------------------------------------------------------------------
1 |
2 | {{ $route.params.page }}
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/pages/index.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
{{ $t('welcome') }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/pages/test.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ $t('welcome') }}
4 |
5 |
6 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/__sitemap/url',
7 | changefreq: 'weekly',
8 | _i18nTransform: true,
9 | },
10 | ]
11 | })
12 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-micro/server/routes/i18n-urls.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/en/dynamic/foo',
7 | },
8 | {
9 | loc: '/fr/dynamic/foo',
10 | },
11 | {
12 | loc: 'endless-dungeon', // issue with en being picked up as the locale
13 | _i18nTransform: true,
14 | },
15 | {
16 | loc: 'english-url', // issue with en being picked up as the locale
17 | },
18 | // absolute URL issue
19 | { loc: 'https://www.somedomain.com/abc/def' },
20 | ]
21 | })
22 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/locales/en.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/locales/hr.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/locales/ja.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/locales/nl.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/locales/zh.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: '欢迎光临',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | export default defineNuxtConfig({
4 | modules: [
5 | NuxtSitemap,
6 | '@nuxtjs/i18n',
7 | ],
8 | site: {
9 | url: 'https://nuxtseo.com',
10 | },
11 |
12 | compatibilityDate: '2024-07-22',
13 | nitro: {
14 | prerender: {
15 | failOnError: false,
16 | ignore: ['/'],
17 | },
18 | },
19 | i18n: {
20 | baseUrl: 'https://nuxtseo.com',
21 | detectBrowserLanguage: false,
22 | defaultLocale: 'en',
23 | strategy: 'no_prefix',
24 | locales: [
25 | {
26 | code: 'en',
27 | iso: 'en-US',
28 | },
29 | {
30 | code: 'es',
31 | iso: 'es-ES',
32 | },
33 | {
34 | code: 'fr',
35 | iso: 'fr-FR',
36 | },
37 | ],
38 | pages: {
39 | test: {
40 | en: '/test',
41 | es: '/prueba',
42 | fr: '/teste',
43 | },
44 | about: {
45 | en: '/about',
46 | es: '/acerca-de',
47 | fr: '/a-propos',
48 | },
49 | },
50 | },
51 | sitemap: {
52 | sources: ['/__sitemap'],
53 | autoLastmod: false,
54 | credits: false,
55 | debug: true,
56 | discoverImages: false,
57 | discoverVideos: false,
58 | },
59 | })
60 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/pages/dynamic/[page].vue:
--------------------------------------------------------------------------------
1 |
2 | {{ $route.params.page }}
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/pages/index.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
{{ $t('welcome') }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/pages/test.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ $t('welcome') }}
4 |
5 |
6 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/__sitemap/url',
7 | changefreq: 'weekly',
8 | _i18nTransform: true,
9 | },
10 | ]
11 | })
12 |
--------------------------------------------------------------------------------
/test/fixtures/i18n-no-prefix/server/routes/i18n-urls.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/en/dynamic/foo',
7 | },
8 | {
9 | loc: '/fr/dynamic/foo',
10 | },
11 | {
12 | loc: 'endless-dungeon', // issue with en being picked up as the locale
13 | _i18nTransform: true,
14 | },
15 | {
16 | loc: 'english-url', // issue with en being picked up as the locale
17 | },
18 | // absolute URL issue
19 | { loc: 'https://www.somedomain.com/abc/def' },
20 | ]
21 | })
22 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/locales/en.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/locales/hr.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/locales/ja.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'ようこそ',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/locales/nl.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: 'Welcome',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/locales/zh.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | welcome: '欢迎光临',
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | '@nuxtjs/i18n',
8 | ],
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 |
13 | compatibilityDate: '2024-07-22',
14 | nitro: {
15 | prerender: {
16 | failOnError: false,
17 | ignore: ['/'],
18 | },
19 | },
20 | i18n: {
21 | baseUrl: 'https://nuxtseo.com',
22 | detectBrowserLanguage: false,
23 | defaultLocale: 'en',
24 | strategy: 'prefix',
25 | locales: [
26 | {
27 | code: 'en',
28 | iso: 'en-US',
29 | },
30 | {
31 | code: 'es',
32 | iso: 'es-ES',
33 | },
34 | {
35 | code: 'fr',
36 | iso: 'fr-FR',
37 | },
38 | ],
39 | },
40 | sitemap: {
41 | sources: ['/__sitemap'],
42 | autoLastmod: false,
43 | credits: false,
44 | debug: true,
45 | },
46 | })
47 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/pages/dynamic/[page].vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 | {{ $route.params.page }}
24 |
25 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/pages/index.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
{{ $t('welcome') }}
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/pages/no-i18n.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | hello
7 |
8 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/pages/test.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
{{ $t('welcome') }}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/server/routes/__sitemap.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/__sitemap/url',
7 | changefreq: 'weekly',
8 | _i18nTransform: true,
9 | },
10 | ]
11 | })
12 |
--------------------------------------------------------------------------------
/test/fixtures/i18n/server/routes/i18n-urls.ts:
--------------------------------------------------------------------------------
1 | import { defineSitemapEventHandler } from '#imports'
2 |
3 | export default defineSitemapEventHandler(() => {
4 | return [
5 | {
6 | loc: '/en/dynamic/foo',
7 | },
8 | {
9 | loc: '/fr/dynamic/foo',
10 | },
11 | {
12 | loc: 'endless-dungeon', // issue with en being picked up as the locale
13 | _i18nTransform: true,
14 | },
15 | {
16 | loc: 'english-url', // issue with en being picked up as the locale
17 | },
18 | // absolute URL issue
19 | { loc: 'https://www.somedomain.com/abc/def' },
20 | ]
21 | })
22 |
--------------------------------------------------------------------------------
/test/fixtures/multi-with-chunks/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Multi Sitemap Chunking Test
4 |
5 |
6 |
--------------------------------------------------------------------------------
/test/fixtures/multi-with-chunks/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 | site: {
9 | url: 'https://nuxtseo.com',
10 | },
11 | sitemap: {
12 | autoLastmod: false,
13 | credits: false,
14 | debug: true,
15 | defaultSitemapsChunkSize: 5,
16 | sitemaps: {
17 | pages: {
18 | urls: Array.from({ length: 20 }, (_, i) => `/page/${i + 1}`),
19 | excludeAppSources: true,
20 | },
21 | posts: {
22 | sources: [
23 | '/api/posts',
24 | ],
25 | chunks: true,
26 | chunkSize: 3,
27 | },
28 | products: {
29 | sources: [
30 | '/api/products',
31 | ],
32 | chunks: 10, // use 10 as chunk size
33 | },
34 | },
35 | },
36 | })
37 |
--------------------------------------------------------------------------------
/test/fixtures/multi-with-chunks/server/api/posts.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | // Generate 12 posts to test chunking with chunkSize: 3 (should create 4 chunks)
5 | return Array.from({ length: 12 }, (_, i) => ({
6 | loc: `/posts/${i + 1}`,
7 | lastmod: new Date(2024, 0, i + 1).toISOString(),
8 | }))
9 | })
10 |
--------------------------------------------------------------------------------
/test/fixtures/multi-with-chunks/server/api/products.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | // Generate 25 products to test chunking with chunkSize: 10 (should create 3 chunks)
5 | return Array.from({ length: 25 }, (_, i) => ({
6 | loc: `/products/${i + 1}`,
7 | lastmod: new Date(2024, 1, i + 1).toISOString(),
8 | }))
9 | })
10 |
--------------------------------------------------------------------------------
/test/fixtures/no-pages/app.vue:
--------------------------------------------------------------------------------
1 |
2 | hello world
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/no-pages/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import NuxtSitemap from '../../../src/module'
2 |
3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 |
9 | site: {
10 | url: 'https://nuxtseo.com',
11 | },
12 |
13 | compatibilityDate: '2025-03-14',
14 |
15 | sitemap: {
16 | sources: ['/__sitemap'],
17 | autoLastmod: false,
18 | credits: false,
19 | debug: true,
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/test/fixtures/sources-hook/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import { defineNuxtConfig } from 'nuxt/config'
2 | import NuxtSitemap from '../../../src/module'
3 |
4 | export default defineNuxtConfig({
5 | modules: [
6 | NuxtSitemap,
7 | ],
8 | site: {
9 | url: 'https://example.com',
10 | },
11 | nitro: {
12 | plugins: ['~/server/plugins/sources-hook.ts'],
13 | },
14 | sitemap: {
15 | sources: [
16 | '/api/initial-source',
17 | ],
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/test/fixtures/sources-hook/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 | Test fixture for sources hook
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/sources-hook/server/api/dynamic-source.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler(() => {
4 | return [
5 | { loc: '/dynamic-source-url' },
6 | ]
7 | })
8 |
--------------------------------------------------------------------------------
/test/fixtures/sources-hook/server/api/initial-source.ts:
--------------------------------------------------------------------------------
1 | import { defineEventHandler } from 'h3'
2 |
3 | export default defineEventHandler((event) => {
4 | const headers = event.node.req.headers
5 |
6 | // Return different URLs based on whether headers were modified by hook
7 | if (headers['x-hook-modified'] === 'true') {
8 | return [
9 | { loc: '/hook-modified' },
10 | ]
11 | }
12 |
13 | return [
14 | { loc: '/initial-source-default' },
15 | ]
16 | })
17 |
--------------------------------------------------------------------------------
/test/fixtures/sources-hook/server/plugins/sources-hook.ts:
--------------------------------------------------------------------------------
1 | import { defineNitroPlugin } from 'nitropack/runtime'
2 |
3 | export default defineNitroPlugin((nitroApp) => {
4 | nitroApp.hooks.hook('sitemap:sources', async (ctx) => {
5 | // Add a new source dynamically
6 | ctx.sources.push({ sourceType: 'user', fetch: '/api/dynamic-source' })
7 |
8 | // Add a source to be filtered
9 | ctx.sources.push({ sourceType: 'user', fetch: '/api/skip-this' })
10 |
11 | // Modify existing sources to add headers
12 | ctx.sources = ctx.sources.map((source) => {
13 | if (typeof source === 'object' && source.fetch === '/api/initial-source') {
14 | // Modify fetch to add headers
15 | source.fetch = ['/api/initial-source', { headers: { 'X-Hook-Modified': 'true' } }]
16 | }
17 | return source
18 | })
19 |
20 | // Filter out sources we don't want
21 | ctx.sources = ctx.sources.filter((source) => {
22 | if (typeof source === 'object' && source.fetch) {
23 | return !source.fetch.includes('skip-this')
24 | }
25 | return true
26 | })
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/test/unit/i18n-disabled-routes.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, it } from 'vitest'
2 | import { generatePathForI18nPages } from '../../src/utils-internal/i18n'
3 |
4 | it('should handle string paths for generatePathForI18nPages', () => {
5 | const result = generatePathForI18nPages({
6 | localeCode: 'en',
7 | pageLocales: '/about',
8 | nuxtI18nConfig: {
9 | locales: ['en', 'fr'],
10 | defaultLocale: 'en',
11 | strategy: 'no_prefix',
12 | },
13 | normalisedLocales: [
14 | { code: 'en', _hreflang: 'en-US', _sitemap: 'en' },
15 | { code: 'fr', _hreflang: 'fr-FR', _sitemap: 'fr' },
16 | ],
17 | })
18 |
19 | expect(result).toBe('/about')
20 | })
21 |
22 | it('handles false values in generatePathForI18nPages', () => {
23 | // When false is passed, the function treats it as a path value
24 | // The fix in the module prevents false from reaching this function
25 | const result = generatePathForI18nPages({
26 | localeCode: 'en',
27 | pageLocales: false as any, // Intentionally passing wrong type
28 | nuxtI18nConfig: {
29 | locales: ['en', 'fr'],
30 | defaultLocale: 'en',
31 | strategy: 'no_prefix',
32 | },
33 | normalisedLocales: [
34 | { code: 'en', _hreflang: 'en-US', _sitemap: 'en' },
35 | { code: 'fr', _hreflang: 'fr-FR', _sitemap: 'fr' },
36 | ],
37 | })
38 |
39 | // It returns false value as-is for no_prefix strategy
40 | expect(result).toBe(false)
41 | })
42 |
--------------------------------------------------------------------------------
/test/unit/i18n.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { normalizeLocales, splitPathForI18nLocales } from '../../src/utils-internal/i18n'
3 | import type { AutoI18nConfig } from '../../src/runtime/types'
4 |
5 | const EnFrAutoI18n = {
6 | locales: normalizeLocales({ locales: [{
7 | code: 'en',
8 | iso: 'en-US',
9 | }, {
10 | code: 'fr',
11 | iso: 'fr-FR',
12 | }] }),
13 | defaultLocale: 'en',
14 | strategy: 'prefix_except_default',
15 | } as AutoI18nConfig
16 |
17 | describe('i18n', () => {
18 | it('filtering prefix_except_default', async () => {
19 | const data = splitPathForI18nLocales('/about', EnFrAutoI18n)
20 | expect(data).toMatchInlineSnapshot(`
21 | [
22 | "/about",
23 | "/fr/about",
24 | ]
25 | `)
26 | const data2 = splitPathForI18nLocales('/fr/about', EnFrAutoI18n)
27 | expect(data2).toMatchInlineSnapshot('"/fr/about"')
28 | })
29 | it('filtering prefix_and_default', async () => {
30 | const data = splitPathForI18nLocales('/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
31 | expect(data).toMatchInlineSnapshot(`
32 | [
33 | "/about",
34 | "/en/about",
35 | "/fr/about",
36 | ]
37 | `)
38 | const data2 = splitPathForI18nLocales('/fr/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
39 | expect(data2).toMatchInlineSnapshot('"/fr/about"')
40 | const data3 = splitPathForI18nLocales('/en/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
41 | expect(data3).toMatchInlineSnapshot('"/en/about"')
42 | })
43 | it('filtering prefix', async () => {
44 | const data = splitPathForI18nLocales('/about', { ...EnFrAutoI18n, strategy: 'prefix' })
45 | expect(data).toMatchInlineSnapshot(`
46 | [
47 | "/about",
48 | "/en/about",
49 | "/fr/about",
50 | ]
51 | `)
52 | const data2 = splitPathForI18nLocales('/fr/about', { ...EnFrAutoI18n, strategy: 'prefix' })
53 | expect(data2).toMatchInlineSnapshot('"/fr/about"')
54 | })
55 | it('normalizes locales', () => {
56 | const locales = [{
57 | code: 'en',
58 | iso: 'en-US',
59 | }, {
60 | code: 'fr',
61 | iso: 'fr-FR',
62 | }, {
63 | code: 'es',
64 | },
65 | 'br',
66 | {
67 | code: 'xx',
68 | language: 'xx-XX',
69 | }]
70 | // @ts-expect-error untyped
71 | const data = normalizeLocales({ locales })
72 | expect(data).toMatchInlineSnapshot(`
73 | [
74 | {
75 | "_hreflang": "en-US",
76 | "_sitemap": "en-US",
77 | "code": "en",
78 | "iso": "en-US",
79 | "language": "en-US",
80 | },
81 | {
82 | "_hreflang": "fr-FR",
83 | "_sitemap": "fr-FR",
84 | "code": "fr",
85 | "iso": "fr-FR",
86 | "language": "fr-FR",
87 | },
88 | {
89 | "_hreflang": "es",
90 | "_sitemap": "es",
91 | "code": "es",
92 | },
93 | {
94 | "_hreflang": "br",
95 | "_sitemap": "br",
96 | "code": "br",
97 | },
98 | {
99 | "_hreflang": "xx-XX",
100 | "_sitemap": "xx-XX",
101 | "code": "xx",
102 | "language": "xx-XX",
103 | },
104 | ]
105 | `)
106 | })
107 | })
108 |
--------------------------------------------------------------------------------
/test/unit/lastmod.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { isValidW3CDate, normaliseDate } from '../../src/runtime/server/sitemap/urlset/normalise'
3 |
4 | describe('lastmod', () => {
5 | it('w3c validate', () => {
6 | expect(isValidW3CDate('2023-12-21')).toBeTruthy()
7 | expect(isValidW3CDate('2023-12-21T22:46:58Z')).toBeTruthy()
8 | expect(isValidW3CDate('2023-12-21T22:46:58+00:00')).toBeTruthy()
9 | expect(isValidW3CDate('2023-12-21T22:46:58.441+00:00')).toBeTruthy()
10 | expect(isValidW3CDate('1994-11-05T13:15:30Z')).toBeTruthy()
11 | expect(isValidW3CDate('1994-11-05T08:15:30-05:00')).toBeTruthy()
12 | expect(isValidW3CDate('1994-11-05T08:15:30-05:00')).toBeTruthy()
13 | })
14 | it('date create', () => {
15 | // time without timezone
16 | expect(normaliseDate('2023-12-21T13:49:27.963745')).toMatchInlineSnapshot(`"2023-12-21T13:49:27Z"`)
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/test/unit/normalise.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
3 |
4 | describe('normalise', () => {
5 | it('query', async () => {
6 | const normalisedWithoutSlash = preNormalizeEntry({ loc: '/query?foo=bar' })
7 | expect(normalisedWithoutSlash).toMatchInlineSnapshot(`
8 | {
9 | "_abs": false,
10 | "_key": "/query?foo=bar",
11 | "_path": {
12 | "hash": "",
13 | "pathname": "/query",
14 | "search": "?foo=bar",
15 | },
16 | "_relativeLoc": "/query?foo=bar",
17 | "loc": "/query?foo=bar",
18 | }
19 | `)
20 | const normalisedWithSlash = preNormalizeEntry({ loc: '/query/?foo=bar' })
21 | expect(normalisedWithSlash).toMatchInlineSnapshot(`
22 | {
23 | "_abs": false,
24 | "_key": "/query?foo=bar",
25 | "_path": {
26 | "hash": "",
27 | "pathname": "/query",
28 | "search": "?foo=bar",
29 | },
30 | "_relativeLoc": "/query?foo=bar",
31 | "loc": "/query?foo=bar",
32 | }
33 | `)
34 | })
35 |
36 | it('encoding', () => {
37 | const normalisedWithoutSlash = preNormalizeEntry({ loc: '/this/is a test' })
38 | expect(normalisedWithoutSlash).toMatchInlineSnapshot(`
39 | {
40 | "_abs": false,
41 | "_key": "/this/is%20a%20test",
42 | "_path": {
43 | "hash": "",
44 | "pathname": "/this/is a test",
45 | "search": "",
46 | },
47 | "_relativeLoc": "/this/is%20a%20test",
48 | "loc": "/this/is%20a%20test",
49 | }
50 | `)
51 | const withQuery = preNormalizeEntry({ loc: '/this/is a test?withAQuery=foo' })
52 | expect(withQuery).toMatchInlineSnapshot(`
53 | {
54 | "_abs": false,
55 | "_key": "/this/is%20a%20test?withAQuery=foo",
56 | "_path": {
57 | "hash": "",
58 | "pathname": "/this/is a test",
59 | "search": "?withAQuery=foo",
60 | },
61 | "_relativeLoc": "/this/is%20a%20test?withAQuery=foo",
62 | "loc": "/this/is%20a%20test?withAQuery=foo",
63 | }
64 | `)
65 | const withQueryWeird = preNormalizeEntry({ loc: '/this/is a test?with A some weirdformat=foo' })
66 | expect(withQueryWeird).toMatchInlineSnapshot(`
67 | {
68 | "_abs": false,
69 | "_key": "/this/is%20a%20test?with+A+some+weirdformat=foo",
70 | "_path": {
71 | "hash": "",
72 | "pathname": "/this/is a test",
73 | "search": "?with A some weirdformat=foo",
74 | },
75 | "_relativeLoc": "/this/is%20a%20test?with+A+some+weirdformat=foo",
76 | "loc": "/this/is%20a%20test?with+A+some+weirdformat=foo",
77 | }
78 | `)
79 | })
80 | })
81 |
--------------------------------------------------------------------------------
/test/unit/sorting.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 | import { sortInPlace } from '../../src/runtime/server/sitemap/urlset/sort'
3 |
4 | describe('sorting', () => {
5 | it('default', async () => {
6 | const data = sortInPlace([
7 | { loc: '/a' },
8 | { loc: '/b' },
9 | { loc: '/c' },
10 | { loc: '/1' },
11 | { loc: '/2' },
12 | { loc: '/10' },
13 | ])
14 | expect(data).toMatchInlineSnapshot(`
15 | [
16 | {
17 | "loc": "/1",
18 | },
19 | {
20 | "loc": "/2",
21 | },
22 | {
23 | "loc": "/10",
24 | },
25 | {
26 | "loc": "/a",
27 | },
28 | {
29 | "loc": "/b",
30 | },
31 | {
32 | "loc": "/c",
33 | },
34 | ]
35 | `)
36 | })
37 | })
38 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.nuxt/tsconfig.json",
3 | "exclude": [
4 | "test/**",
5 | "playground"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/virtual.d.ts:
--------------------------------------------------------------------------------
1 | declare module '#sitemap-virtual/read-sources.mjs' {
2 | export function readSourcesFromFilesystem(filename: string): Promise
3 | }
4 |
5 | declare module '#sitemap-virtual/global-sources.mjs' {
6 | import type { SitemapSourceBase, SitemapSourceResolved } from '#sitemap/types'
7 |
8 | export const sources: (SitemapSourceBase | SitemapSourceResolved)[]
9 | }
10 |
11 | declare module '#sitemap-virtual/child-sources.mjs' {
12 | import type { SitemapSourceBase, SitemapSourceResolved } from '#sitemap/types'
13 |
14 | export const sources: Record
15 | }
16 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, defineProject } from 'vitest/config'
2 | import { defineVitestProject } from '@nuxt/test-utils/config'
3 |
4 | export default defineConfig({
5 | test: {
6 | projects: [
7 | // utils folders as *.test.ts in either test/unit or in src/**/*.test.ts
8 | defineProject({
9 | test: {
10 | name: 'unit',
11 | environment: 'node',
12 | include: [
13 | './test/unit/**/*.test.ts',
14 | './src/**/*.test.ts',
15 | ],
16 | exclude: [
17 | '**/node_modules/**',
18 | ],
19 | },
20 | }),
21 | // e2e tests in test/e2e
22 | defineVitestProject({
23 | test: {
24 | name: 'e2e',
25 | include: [
26 | './test/e2e/**/*.test.ts',
27 | ],
28 | exclude: [
29 | '**/node_modules/**',
30 | ],
31 | globalSetup: './test/e2e/global-setup.ts',
32 | },
33 | }),
34 | ],
35 | },
36 | })
37 |
--------------------------------------------------------------------------------