├── .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 | 15 | 16 | 19 | 20 |
17 | Made possible by my Sponsor Program 💖
Follow me @harlan_zw 🐦 • Join Discord for help

18 |
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 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /client/components/OSectionBlock.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 92 | 93 | 116 | -------------------------------------------------------------------------------- /client/components/Source.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 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 | ![Broken XML because of xhtml namespace.](/docs/sitemap/formatting-error.png) 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 | 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 | 4 | -------------------------------------------------------------------------------- /playground/pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/_dir/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /blocked-by-robots-txt 3 | -------------------------------------------------------------------------------- /playground/pages/about.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 13 | -------------------------------------------------------------------------------- /playground/pages/api/foo.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blocked-by-robots-txt/foo.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/[id].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/categories.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/tags.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/tags/edit.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/blog/tags/new.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/foo.bar.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/hidden-path-but-in-sitemap/index.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/pages/hide-me.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/ignore-foo.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | 40 | 47 | -------------------------------------------------------------------------------- /playground/pages/new-page.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/pages/prerender-video.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45 | -------------------------------------------------------------------------------- /playground/pages/secret.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 11 | -------------------------------------------------------------------------------- /playground/pages/users-[group]/[id].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/pages/users-[group]/index.vue: -------------------------------------------------------------------------------- 1 | 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 | 7 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/crawled.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/dynamic/[slug].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/index.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/sub/page.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 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 | Sponsors 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 | 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 | 7 | -------------------------------------------------------------------------------- /test/fixtures/generate/pages/crawled.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /test/fixtures/generate/pages/dynamic/[slug].vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /test/fixtures/generate/pages/index.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /test/fixtures/generate/pages/noindex.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | -------------------------------------------------------------------------------- /test/fixtures/generate/pages/sub/page.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 4 | -------------------------------------------------------------------------------- /test/fixtures/i18n-micro/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /test/fixtures/i18n-micro/pages/test.vue: -------------------------------------------------------------------------------- 1 | 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 | 4 | -------------------------------------------------------------------------------- /test/fixtures/i18n-no-prefix/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /test/fixtures/i18n-no-prefix/pages/test.vue: -------------------------------------------------------------------------------- 1 | 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 | 25 | -------------------------------------------------------------------------------- /test/fixtures/i18n/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /test/fixtures/i18n/pages/no-i18n.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /test/fixtures/i18n/pages/test.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 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 | 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 | 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 | 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 | --------------------------------------------------------------------------------