Repository: nuxt-modules/sitemap
Branch: main
Commit: 2ed88043b08b
Files: 422
Total size: 842.4 KB
Directory structure:
gitextract_wdbjvyab/
├── .attw.json
├── .claude/
│ └── skills/
│ ├── nuxt-site-config-skilld/
│ │ └── SKILL.md
│ ├── nuxt-test-utils-skilld/
│ │ └── SKILL.md
│ ├── skilld-lock.yaml
│ └── vitest-skilld/
│ └── SKILL.md
├── .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/
│ ├── nightly.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .npmrc
├── .nuxtrc
├── LICENSE.md
├── README.md
├── SECURITY.md
├── benchmark/
│ ├── app/
│ │ └── app.vue
│ ├── bench.mjs
│ ├── nuxt.config.ts
│ ├── package.json
│ └── server/
│ └── api/
│ └── ping.get.ts
├── build.config.ts
├── devtools/
│ ├── app.config.ts
│ ├── app.vue
│ ├── components/
│ │ └── Source.vue
│ ├── composables/
│ │ ├── rpc.ts
│ │ └── state.ts
│ ├── nuxt.config.ts
│ ├── package.json
│ ├── pages/
│ │ ├── app-sources.vue
│ │ ├── debug.vue
│ │ ├── docs.vue
│ │ ├── index.vue
│ │ └── user-sources.vue
│ └── tsconfig.json
├── docs/
│ └── content/
│ ├── 0.getting-started/
│ │ ├── 0.introduction.md
│ │ ├── 1.installation.md
│ │ ├── 2.data-sources.md
│ │ └── 3.troubleshooting.md
│ ├── 1.guides/
│ │ ├── 0.dynamic-urls.md
│ │ ├── 1.filtering-urls.md
│ │ ├── 2.multi-sitemaps.md
│ │ ├── 3.i18n.md
│ │ ├── 4.content.md
│ │ ├── 5.prerendering.md
│ │ ├── 6.best-practices.md
│ │ ├── 7.submitting-sitemap.md
│ │ └── 8.zero-runtime.md
│ ├── 2.advanced/
│ │ ├── 0.loc-data.md
│ │ ├── 1.images-videos.md
│ │ ├── 2.performance.md
│ │ ├── 3.chunking-sources.md
│ │ └── 4.customising-ui.md
│ ├── 4.api/
│ │ ├── 0.config.md
│ │ └── 1.nuxt-hooks.md
│ ├── 5.nitro-api/
│ │ └── nitro-hooks.md
│ └── 5.releases/
│ ├── 3.v8.md
│ ├── 4.v7.md
│ ├── 5.v6.md
│ ├── 6.v5.md
│ ├── 7.v4.md
│ └── 8.v3.md
├── eslint.config.mjs
├── examples/
│ ├── basic/
│ │ ├── app/
│ │ │ ├── app.vue
│ │ │ └── pages/
│ │ │ ├── about.vue
│ │ │ ├── contact.vue
│ │ │ └── index.vue
│ │ ├── nuxt.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── dynamic-urls/
│ │ ├── app/
│ │ │ ├── app.vue
│ │ │ └── pages/
│ │ │ ├── blog/
│ │ │ │ └── [slug].vue
│ │ │ └── index.vue
│ │ ├── nuxt.config.ts
│ │ ├── package.json
│ │ ├── server/
│ │ │ └── api/
│ │ │ └── _sitemap-urls.ts
│ │ └── tsconfig.json
│ └── i18n/
│ ├── app/
│ │ ├── app.vue
│ │ └── pages/
│ │ ├── about.vue
│ │ ├── contact.vue
│ │ └── index.vue
│ ├── nuxt.config.ts
│ ├── package.json
│ └── tsconfig.json
├── package.json
├── patches/
│ └── @nuxtjs__mdc.patch
├── playground/
│ ├── .nuxtrc
│ ├── app.vue
│ ├── assets/
│ │ └── css/
│ │ └── main.css
│ ├── 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/
│ │ │ ├── [id].vue
│ │ │ ├── categories.vue
│ │ │ ├── index.vue
│ │ │ ├── tags/
│ │ │ │ ├── edit.vue
│ │ │ │ └── new.vue
│ │ │ └── tags.vue
│ │ ├── blog.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
│ └── tsconfig.json
├── 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-production.ts
│ │ │ │ │ ├── debug.ts
│ │ │ │ │ ├── nuxt-content-urls-v2.ts
│ │ │ │ │ └── nuxt-content-urls-v3.ts
│ │ │ │ ├── __zero-runtime/
│ │ │ │ │ ├── sitemap/
│ │ │ │ │ │ └── [sitemap].xml.ts
│ │ │ │ │ ├── sitemap.xml.ts
│ │ │ │ │ └── sitemap_index.xml.ts
│ │ │ │ ├── sitemap/
│ │ │ │ │ └── [sitemap].xml.ts
│ │ │ │ ├── sitemap.xml.ts
│ │ │ │ ├── sitemap.xsl.ts
│ │ │ │ └── sitemap_index.xml.ts
│ │ │ ├── sitemap/
│ │ │ │ ├── builder/
│ │ │ │ │ ├── sitemap-index.ts
│ │ │ │ │ ├── sitemap.ts
│ │ │ │ │ └── xml.ts
│ │ │ │ ├── event-handlers.ts
│ │ │ │ ├── nitro.ts
│ │ │ │ ├── urlset/
│ │ │ │ │ ├── normalise.ts
│ │ │ │ │ ├── sort.ts
│ │ │ │ │ └── sources.ts
│ │ │ │ └── utils/
│ │ │ │ └── chunk.ts
│ │ │ ├── tsconfig.json
│ │ │ └── utils.ts
│ │ ├── types.ts
│ │ └── utils-pure.ts
│ ├── templates.ts
│ ├── utils/
│ │ ├── index.ts
│ │ ├── parseHtmlExtractSitemapMeta.ts
│ │ ├── parseSitemapIndex.ts
│ │ └── parseSitemapXml.ts
│ └── utils-internal/
│ ├── filter.ts
│ ├── i18n.ts
│ ├── kit.ts
│ └── nuxtSitemap.ts
├── test/
│ ├── bench/
│ │ ├── i18n.bench.ts
│ │ ├── normalize.bench.ts
│ │ ├── sitemap.bench.ts
│ │ └── xml.bench.ts
│ ├── e2e/
│ │ ├── chunks/
│ │ │ ├── cache-headers.test.ts
│ │ │ ├── chunk-count.test.ts
│ │ │ ├── default.ts
│ │ │ ├── generate.test.ts
│ │ │ └── memoization.test.ts
│ │ ├── content-v3/
│ │ │ ├── default.test.ts
│ │ │ ├── define-schema.test.ts
│ │ │ ├── filtering.test.ts
│ │ │ ├── i18n.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
│ │ │ ├── custom-sitemaps-i18n.test.ts
│ │ │ ├── domains.test.ts
│ │ │ ├── dynamic-urls.test.ts
│ │ │ ├── filtering-base-url.test.ts
│ │ │ ├── filtering-include.test.ts
│ │ │ ├── filtering-regexp.test.ts
│ │ │ ├── filtering.test.ts
│ │ │ ├── generate-prefix-except-default.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
│ │ ├── issues/
│ │ │ ├── 504-duplicate-api-calls.test.ts
│ │ │ ├── issue-384.test.ts
│ │ │ ├── issue-561.test.ts
│ │ │ ├── issue-564.test.ts
│ │ │ └── issue-588.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
│ │ │ └── issue-514.test.ts
│ │ └── single/
│ │ ├── baseUrl.test.ts
│ │ ├── baseUrlTrailingSlash.test.ts
│ │ ├── changeApiUrl.test.ts
│ │ ├── encodeDynamicUrls.test.ts
│ │ ├── filtering.test.ts
│ │ ├── generate.test.ts
│ │ ├── issue-592.test.ts
│ │ ├── lastmod.test.ts
│ │ ├── news.test.ts
│ │ ├── pageMetaSitemap.test.ts
│ │ ├── queryRoutes.test.ts
│ │ ├── routeRules.ts
│ │ ├── routeRulesTrailingSlash.test.ts
│ │ ├── sitemapName.test.ts
│ │ ├── trailingSlashes.ts
│ │ ├── urlEncoded.test.ts
│ │ ├── video.test.ts
│ │ ├── xsl.test.ts
│ │ ├── zero-runtime-build.test.ts
│ │ └── zero-runtime-dev.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
│ │ ├── chunk-cache/
│ │ │ ├── app.vue
│ │ │ ├── nuxt.config.ts
│ │ │ └── server/
│ │ │ └── api/
│ │ │ ├── posts.ts
│ │ │ └── source-call-count.ts
│ │ ├── chunk-count/
│ │ │ ├── app.vue
│ │ │ ├── nuxt.config.ts
│ │ │ └── server/
│ │ │ └── api/
│ │ │ ├── posts-call-count.ts
│ │ │ └── posts.ts
│ │ ├── chunks/
│ │ │ ├── app.vue
│ │ │ ├── nuxt.config.ts
│ │ │ └── server/
│ │ │ ├── api/
│ │ │ │ └── sitemap/
│ │ │ │ ├── bar.ts
│ │ │ │ └── foo.ts
│ │ │ └── routes/
│ │ │ └── __sitemap.ts
│ │ ├── content-v3/
│ │ │ ├── .nuxtrc
│ │ │ ├── app.vue
│ │ │ ├── content/
│ │ │ │ ├── .navigation.yml
│ │ │ │ ├── _partial.md
│ │ │ │ ├── bar.md
│ │ │ │ ├── foo.md
│ │ │ │ ├── posts/
│ │ │ │ │ ├── .navigation.yml
│ │ │ │ │ ├── bar.md
│ │ │ │ │ ├── fallback.md
│ │ │ │ │ └── foo.md
│ │ │ │ ├── test-json.json
│ │ │ │ └── test-yaml.yml
│ │ │ ├── content.config.ts
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ └── [...slug].vue
│ │ ├── content-v3-define-schema/
│ │ │ ├── app.vue
│ │ │ ├── content/
│ │ │ │ ├── bar.md
│ │ │ │ ├── draft.md
│ │ │ │ ├── foo.md
│ │ │ │ ├── future.md
│ │ │ │ └── published.md
│ │ │ ├── content.config.ts
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ └── [...slug].vue
│ │ ├── content-v3-filtering/
│ │ │ ├── content/
│ │ │ │ ├── bar.md
│ │ │ │ ├── draft.md
│ │ │ │ ├── foo.md
│ │ │ │ ├── future.md
│ │ │ │ └── published.md
│ │ │ ├── content.config.ts
│ │ │ └── nuxt.config.ts
│ │ ├── content-v3-i18n/
│ │ │ ├── .nuxtrc
│ │ │ ├── app.vue
│ │ │ ├── content/
│ │ │ │ ├── en/
│ │ │ │ │ ├── getting-started.md
│ │ │ │ │ └── index.md
│ │ │ │ └── ja/
│ │ │ │ ├── getting-started.md
│ │ │ │ └── index.md
│ │ │ ├── content.config.ts
│ │ │ ├── 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/
│ │ │ ├── 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
│ │ ├── i18n-custom-paths/
│ │ │ ├── app.vue
│ │ │ ├── nuxt.config.ts
│ │ │ └── server/
│ │ │ └── routes/
│ │ │ └── __sitemap.ts
│ │ ├── i18n-generate/
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ └── index.vue
│ │ ├── 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
│ │ ├── issue-384/
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ ├── about.vue
│ │ │ └── index.vue
│ │ ├── issue-504/
│ │ │ ├── nuxt.config.ts
│ │ │ ├── pages/
│ │ │ │ ├── about.vue
│ │ │ │ └── index.vue
│ │ │ └── server/
│ │ │ └── api/
│ │ │ └── __sitemap__/
│ │ │ ├── [s_type].ts
│ │ │ └── call-count.ts
│ │ ├── issue-514/
│ │ │ ├── nuxt.config.ts
│ │ │ ├── pages/
│ │ │ │ ├── about.vue
│ │ │ │ └── index.vue
│ │ │ └── server/
│ │ │ └── api/
│ │ │ └── urls.ts
│ │ ├── issue-561/
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ ├── index.vue
│ │ │ ├── privacy-policy.vue
│ │ │ └── submit-art.vue
│ │ ├── issue-588/
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ ├── about.vue
│ │ │ ├── contact.vue
│ │ │ └── index.vue
│ │ ├── issue-592/
│ │ │ ├── nuxt.config.ts
│ │ │ └── pages/
│ │ │ └── index.vue
│ │ ├── 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
│ ├── types/
│ │ ├── templates.test-d.ts
│ │ └── tsconfig.json
│ └── unit/
│ ├── i18n-disabled-routes.test.ts
│ ├── i18n-dynamic-routes.test.ts
│ ├── i18n.test.ts
│ ├── lastmod.test.ts
│ ├── normalise.test.ts
│ ├── parseHtmlExtractSitemapMeta.test.ts
│ ├── parsePages.test.ts
│ ├── parseSitemapXml.test.ts
│ ├── sitemapIndex.test.ts
│ ├── sorting.test.ts
│ └── sourcesHook.test.ts
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .attw.json
================================================
{
"ignoreRules": ["cjs-resolves-to-esm", "false-export-default", "false-esm"]
}
================================================
FILE: .claude/skills/nuxt-site-config-skilld/SKILL.md
================================================
---
name: nuxt-site-config-skilld
description: "Shared site configuration for Nuxt 3 modules. ALWAYS use when writing code importing \"nuxt-site-config\". Consult for debugging, best practices, or modifying nuxt-site-config, nuxt site config."
metadata:
version: 3.2.21
generated_at: 2026-03-24
---
# harlan-zw/nuxt-site-config `nuxt-site-config`
> Shared site configuration for Nuxt 3 modules.
**Version:** 3.2.21
**Deps:** @nuxt/devtools-kit@^3.2.4, @nuxt/kit@^4.4.2, h3@^1.15.10, pathe@^2.0.3, pkg-types@^2.3.0, sirv@^3.0.2, ufo@^1.6.3, site-config-stack@4.0.0, nuxt-site-config-kit@4.0.0
**Tags:** beta: 0.1.1, latest: 4.0.0
**References:** [package.json](./.skilld/pkg/package.json) — exports, entry points • [Docs](./.skilld/docs/_INDEX.md) — API reference, guides • [GitHub Issues](./.skilld/issues/_INDEX.md) — bugs, workarounds, edge cases • [Releases](./.skilld/releases/_INDEX.md) — changelog, breaking changes, new APIs
## Search
Use `skilld search` instead of grepping `.skilld/` directories — hybrid semantic + keyword search across all indexed docs, issues, and releases. If `skilld` is unavailable, use `npx -y skilld search`.
```bash
skilld search "query" -p nuxt-site-config
skilld search "issues:error handling" -p nuxt-site-config
skilld search "releases:deprecated" -p nuxt-site-config
```
Filters: `docs:`, `issues:`, `releases:` prefix narrows by source type.
================================================
FILE: .claude/skills/nuxt-test-utils-skilld/SKILL.md
================================================
---
name: nuxt-test-utils-skilld
description: "ALWAYS use when writing code importing \"@nuxt/test-utils\". Consult for debugging, best practices, or modifying @nuxt/test-utils, nuxt/test-utils, nuxt test-utils, nuxt test utils, test-utils, test utils."
metadata:
version: 4.0.0
generated_by: cached
generated_at: 2026-03-22
---
# nuxt/test-utils `@nuxt/test-utils`
**Version:** 4.0.0
**Deps:** @clack/prompts@1.0.0, @nuxt/devtools-kit@^2.7.0, @nuxt/kit@^3.21.0, c12@^3.3.3, consola@^3.4.2, defu@^6.1.4, destr@^2.0.5, estree-walker@^3.0.3, exsolve@^1.0.8, fake-indexeddb@^6.2.5, get-port-please@^3.2.0, h3@^1.15.5, h3-next@npm:h3@2.0.1-rc.11, local-pkg@^1.1.2, magic-string@^0.30.21, node-fetch-native@^1.6.7, node-mock-http@^1.0.4, nypm@^0.6.4, ofetch@^1.5.1, pathe@^2.0.3, perfect-debounce@^2.1.0, radix3@^1.1.2, scule@^1.3.0, std-env@^3.10.0, tinyexec@^1.0.2, ufo@^1.6.3, unplugin@^3.0.0, vitest-environment-nuxt@^1.0.1, vue@^3.5.27
**Tags:** alpha: 3.9.0-alpha.3, latest: 4.0.0
**References:** [package.json](./.skilld/pkg/package.json) — exports, entry points • [README](./.skilld/pkg/README.md) — setup, basic usage • [Docs](./.skilld/docs/_INDEX.md) — API reference, guides • [GitHub Issues](./.skilld/issues/_INDEX.md) — bugs, workarounds, edge cases • [GitHub Discussions](./.skilld/discussions/_INDEX.md) — Q&A, patterns, recipes • [Releases](./.skilld/releases/_INDEX.md) — changelog, breaking changes, new APIs
## Search
Use `skilld search` instead of grepping `.skilld/` directories — hybrid semantic + keyword search across all indexed docs, issues, and releases. If `skilld` is unavailable, use `npx -y skilld search`.
```bash
skilld search "query" -p @nuxt/test-utils
skilld search "issues:error handling" -p @nuxt/test-utils
skilld search "releases:deprecated" -p @nuxt/test-utils
```
Filters: `docs:`, `issues:`, `releases:` prefix narrows by source type.
## API Changes
This section documents version-specific API changes — prioritize recent major/minor releases.
- BREAKING: Composables at top-level of `describe` block — v4 moved Nuxt initialization from `setupFiles` to `beforeAll` hook, causing `useRouter()`, `useRoute()`, `useNuxtApp()` and other composables to fail with `[nuxt] instance unavailable` when called outside of `beforeAll`/`beforeEach`/test block. Wrap at-describe-level usage in `beforeAll()` [source](./.skilld/releases/v4.0.0.md#later-environment-setup)
- BREAKING: `vi.mock` stricter exports — v4 (via vitest v4) throws error when accessing exports not returned by factory function, instead of silently returning `undefined`. Use `importOriginal` helper to preserve all exports [source](./.skilld/releases/v4.0.0.md#stricter-mock-exports)
- BREAKING: vitest peer dependency — v4 requires `vitest ^4.0.2` (from `^3.2.0`). Tightened dependency ranges for `happy-dom >=20.0.11`, `jsdom >=27.4.0`, `@jest/globals >=30.0.0`, `@cucumber/cucumber >=11.0.0`, `@testing-library/vue ^8.0.1` [source](./.skilld/releases/v4.0.0.md#peer-dependencies)
- NEW: `mockNuxtImport` original parameter — v4.0 passes original implementation to factory function, enabling natural partial mocking: `mockNuxtImport('useRoute', original => vi.fn(original))` [source](./.skilld/releases/v4.0.0.md#highlights)
- NEW: `registerEndpoint` query parameter support — v4.0 fixed long-standing issue where `registerEndpoint` did not work correctly with query parameters in URLs (#1560) [source](./.skilld/releases/v4.0.0.md#registerendpoint-improvements)
- NEW: `registerEndpoint` `once` option — v3.21 added `once` option to `registerEndpoint` for single-use endpoint registration [source](./.skilld/releases/v3.21.0.md:L18)
- NEW: `renderSuspended` rerender behavior — v3.21 added support for rerender behavior in `renderSuspended` helper (#1466) [source](./.skilld/releases/v3.21.0.md:L17)
- NEW: CSS modules in mount/render helpers — v3.21 added support for CSS modules in `mount` and `render` helpers (#1464) [source](./.skilld/releases/v3.21.0.md:L19)
- NEW: `cleanup` `scoped` option — v3.20 added `scoped` option to `cleanup` components for targeted cleanup (#1389) [source](./.skilld/releases/v3.20.0.md:L19)
- NEW: `registerEndpoint` with native fetch — v3.20 enabled `registerEndpoint` to work with native `fetch` and `$fetch.create` (#1415, #1403) [source](./.skilld/releases/v3.20.0.md:L18)
- NEW: `wrapper.vm` automatic ref unwrapping — v3.20 added automatic ref unwrapping for `wrapper.vm` property, simplifying access to unwrapped reactive values (#1405) [source](./.skilld/releases/v3.20.0.md:L17)
- NEW: `mockNuxtImport` mocked target arguments — v3.21 added support for mocked target arguments in `mockNuxtImport` (#1492) [source](./.skilld/releases/v3.21.0.md:L21)
- NEW: Mocking before Nuxt startup — v4.0 moved Nuxt initialization to `beforeAll` hook, allowing `vi.mock` and `mockNuxtImport` to take effect before Nuxt starts, fixing unreliable mocking of composables used in middleware and plugins (#1516, #750, #836, #1496) [source](./.skilld/releases/v4.0.0.md#better-mocking-support)
- NEW: setupBun timeouts — v4.0 added support for setup and teardown timeouts configuration in `setupBun` (#1578) [source](./.skilld/releases/v4.0.0.md:L137)
**Also changed:** Route sync emulation skipped when `NuxtPage` exists (v3.22) · Initial route change can be skipped via option (v3.22) · h3 v2 support (v3.23) · mount + render helpers unified logic (v3.22) · App context passed across mount + render helpers (v3.21)
## Best Practices
- Move Nuxt composable calls to `beforeAll` or `beforeEach` hooks, not describe block scope — Nuxt initialization moved to `beforeAll` in v4.0.0, causing describe-level composable calls to fail with "instance unavailable" error [source](./.skilld/releases/v4.0.0.md#later-environment-setup)
- Use `mockNuxtImport` with the original implementation parameter for natural partial mocking — v4.0.0 passes the original factory to enable spreading and modifying without infinite loops [source](./.skilld/releases/v4.0.0.md#better-mocking-support)
```ts
mockNuxtImport('useRoute', original =>
vi.fn(original))
```
- Extract `import.meta.server` and `import.meta.client` to a helper module before mocking — direct assignment to `import.meta` doesn't work; wrap in a re-export and mock that instead [source](./.skilld/discussions/discussion-884.md)
- Use `.env.test` file for test-specific environment variables instead of config — Vitest loads `.env.test` automatically for test runs while preserving actual app config [source](./.skilld/discussions/discussion-838.md)
- Use `vi.hoisted()` for mock factories to optimize module graph — avoids eager imports of large dependency trees that `mockNuxtImport` requires [source](./.skilld/discussions/discussion-857.md)
```ts
const mocks = vi.hoisted(() => ({
navigateTo: vi.fn(),
useRouter: vi.fn(),
}))
vi.mock('#app/composables/router', () => mocks)
```
- Place server/API tests in the `nuxt` environment, not `node` — server code needs Nuxt magic (auto-imports, composables); `node` environment is only for pure utilities [source](./.skilld/discussions/discussion-1407.md)
- Mock Pinia stores by wrapping the store import with `createTestingPinia` — avoid Symbol conflicts when using `@pinia/nuxt` module by providing testing instance to store function [source](./.skilld/issues/issue-523.md)
- Use `scoped` option in cleanup for isolated component state — v3.20.0 added `cleanup({ scoped: true })` to prevent test isolation issues with component instances [source](./.skilld/releases/v3.20.0.md#enhancements)
- Enable automatic ref unwrapping with `wrapper.vm` — v3.20.0 unwraps refs automatically, eliminating `.value` calls for cleaner test assertions [source](./.skilld/releases/v3.20.0.md#enhancements)
- Use `registerEndpoint` in setup files for persistent mock routes — v4.0.0 ensures endpoints persist across module resets and supports query parameters [source](./.skilld/releases/v4.0.0.md#registerendpoint-improvements)
================================================
FILE: .claude/skills/skilld-lock.yaml
================================================
skills:
nuxt-test-utils-skilld:
packageName: '@nuxt/test-utils'
version: 4.0.0
repo: nuxt/test-utils
source: 'http://nuxt.com/llms.txt'
syncedAt: 2026-03-22
generator: skilld
vitest-skilld:
packageName: vitest
version: 4.1.0
repo: vitest-dev/vitest
source: 'https://github.com/vitest-dev/vitest/tree/v4.1.0/docs'
syncedAt: 2026-03-22
generator: skilld
nuxt-site-config-skilld:
packageName: nuxt-site-config
version: 3.2.21
repo: harlan-zw/nuxt-site-config
source: 'https://github.com/harlan-zw/nuxt-site-config/tree/v3.2.21/docs'
syncedAt: 2026-03-24
generator: skilld
devtools-layer-skilld:
packageName: nuxtseo-layer-devtools
version: 0.3.0
source: shipped
syncedAt: 2026-03-25
generator: skilld
================================================
FILE: .claude/skills/vitest-skilld/SKILL.md
================================================
---
name: vitest-skilld
description: "ALWAYS use when writing code importing \"vitest\". Consult for debugging, best practices, or modifying vitest."
metadata:
version: 4.1.0
generated_by: cached
generated_at: 2026-03-22
---
# vitest-dev/vitest `vitest`
**Version:** 4.1.0
**Deps:** es-module-lexer@^2.0.0, expect-type@^1.3.0, magic-string@^0.30.21, obug@^2.1.1, pathe@^2.0.3, picomatch@^4.0.3, std-env@^4.0.0-rc.1, tinybench@^2.9.0, tinyexec@^1.0.2, tinyglobby@^0.2.15, tinyrainbow@^3.0.3, vite@^6.0.0 || ^7.0.0 || ^8.0.0-0, why-is-node-running@^2.3.0, @vitest/expect@4.1.0, @vitest/mocker@4.1.0, @vitest/runner@4.1.0, @vitest/snapshot@4.1.0, @vitest/pretty-format@4.1.0, @vitest/spy@4.1.0, @vitest/utils@4.1.0
**Tags:** latest: 4.1.0, beta: 4.1.0-beta.6
**References:** [package.json](./.skilld/pkg/package.json) — exports, entry points • [README](./.skilld/pkg/README.md) — setup, basic usage • [Docs](./.skilld/docs/_INDEX.md) — API reference, guides • [GitHub Issues](./.skilld/issues/_INDEX.md) — bugs, workarounds, edge cases • [GitHub Discussions](./.skilld/discussions/_INDEX.md) — Q&A, patterns, recipes • [Releases](./.skilld/releases/_INDEX.md) — changelog, breaking changes, new APIs
## Search
Use `skilld search` instead of grepping `.skilld/` directories — hybrid semantic + keyword search across all indexed docs, issues, and releases. If `skilld` is unavailable, use `npx -y skilld search`.
```bash
skilld search "query" -p vitest
skilld search "issues:error handling" -p vitest
skilld search "releases:deprecated" -p vitest
```
Filters: `docs:`, `issues:`, `releases:` prefix narrows by source type.
## API Changes
This section documents version-specific API changes — prioritize recent major/minor releases.
### Breaking Changes v4.0
- BREAKING: `test()` and `describe()` third argument — options must be the second argument, not third [source](./.skilld/docs/guide/migration.md:L491:L502)
- BREAKING: Pool configuration options restructured — `maxThreads`/`maxForks` → `maxWorkers`, `singleThread`/`singleFork` → `maxWorkers: 1, isolate: false`, `poolOptions` removed, `vmMemoryLimit` replaces nested config [source](./.skilld/docs/guide/migration.md:L328:L356)
- BREAKING: `@vitest/browser/context` and `@vitest/browser/utils` moved — import from `vitest/browser` instead [source](./.skilld/docs/guide/migration.md:L298:L316)
- BREAKING: Browser provider now accepts factory function instead of string — `provider: 'playwright'` → `provider: playwright({ launchOptions: {...} })` [source](./.skilld/docs/guide/migration.md:L266:L293)
- BREAKING: `workspace` config option renamed to `projects` — move code from `vitest.workspace.js` to `vitest.config.ts` [source](./.skilld/docs/guide/migration.md:L230:L264)
- BREAKING: Module environment now uses `viteEnvironment` property instead of `transformMode` [source](./.skilld/docs/guide/migration.md:L222)
- BREAKING: `vi.fn().getMockName()` returns `'vi.fn()'` by default instead of `'spy'` — affects snapshots with mock names [source](./.skilld/releases/v4.0.0.md:L156)
- BREAKING: `vi.restoreAllMocks` no longer resets automocks — only restores manual `vi.spyOn` spies [source](./.skilld/releases/v4.0.0.md:L157)
- BREAKING: Coverage `coverage.all` and `coverage.extensions` removed — use `coverage.include` to specify source file pattern [source](./.skilld/docs/guide/migration.md:L34:L77)
- BREAKING: Verbose reporter now prints as flat list — use `'tree'` reporter for previous hierarchical output [source](./.skilld/docs/guide/migration.md:L438:L447)
- BREAKING: Removed deprecated config options — `poolMatchGlobs`, `environmentMatchGlobs`, `deps.external`, `deps.inline`, `deps.fallbackCJS` replaced with `projects` and `server.deps.*` [source](./.skilld/docs/guide/migration.md:L486:L488)
- BREAKING: Snapshots with custom elements now include shadow root contents — set `printShadowRoot: false` to restore previous behavior [source](./.skilld/docs/guide/migration.md:L449:L480)
### New Features v4.0
- NEW: `vi.spyOn()` and `vi.fn()` support constructors — can now spy on and mock constructor functions with `new` keyword [source](./.skilld/releases/v4.0.0.md:L121)
- NEW: `toMatchScreenshot()` for visual regression testing in browser mode [source](./.skilld/releases/v4.0.0.md:L69)
- NEW: `toBeInViewport()` browser utility to assert element visibility [source](./.skilld/releases/v4.0.0.md:L67)
- NEW: `onUnhandledError` callback hook for handling unhandled errors [source](./.skilld/releases/v4.0.0.md:L48)
- NEW: `onConsoleLog` callback now receives `entity` parameter [source](./.skilld/releases/v4.0.0.md:L47)
- NEW: `expect.assert()` for type narrowing in assertions [source](./.skilld/releases/v4.0.0.md:L55)
- NEW: Custom screenshot comparison algorithms support in browser mode [source](./.skilld/releases/v4.0.0.md:L76)
- NEW: Module Runner replaces vite-node — provides `moduleRunner` instance injected into test runners instead of `__vitest_executor` [source](./.skilld/docs/guide/migration.md:L215:L228)
- NEW: API method `enableCoverage()` and `disableCoverage()` for dynamic coverage control [source](./.skilld/releases/v4.0.0.md:L62)
- NEW: API method `getGlobalTestNamePattern()` to access current test name filter [source](./.skilld/releases/v4.0.0.md:L63)
- NEW: API method `getSeed()` to retrieve random seed value [source](./.skilld/releases/v4.0.0.md:L65)
- NEW: `experimental_parseSpecifications` API for parsing test specifications [source](./.skilld/releases/v4.0.0.md:L60)
### Deprecation & Removal
- DEPRECATED: Reporter APIs `onCollected`, `onSpecsCollected`, `onPathsCollected`, `onTaskUpdate`, `onFinished` — migrate to new reporter API [source](./.skilld/docs/guide/migration.md:L424)
- DEPRECATED: `--browser.provider` CLI option removed [source](./.skilld/releases/v4.0.16.md:L16)
- DEPRECATED: `test.poolOptions` config — use top-level options instead [source](./.skilld/releases/v4.0.16.md:L16)
**Also changed:** `vi.mockObject()` adds `spy` option · `recordArtifact()` exported from vitest package · `toBeNullable()` matcher · Module graph UI fixes in HTML reporter · Playwright tracing support · Separate browser provider packages (`@vitest/browser-playwright`, etc.)
## Best Practices
- Disable test isolation selectively with `isolate: false` for projects without side effects or that properly cleanup state — reduces test run time by eliminating per-file VM/worker overhead [source](./.skilld/docs/guide/improving-performance.md#test-isolation)
- Use `context.expect` instead of global `expect` when running concurrent snapshot tests — ensures each test's snapshots are tracked independently and prevents conflicts [source](./.skilld/docs/guide/test-context.md#expect)
- Define test tags in configuration to apply shared options (timeout, retry, priority) to grouped tests — enables filtering and automatic configuration without repeating test options [source](./.skilld/docs/guide/test-tags.md#defining-tags)
- Return a cleanup function from `beforeEach` instead of using `afterEach` — simpler syntax and keeps setup/teardown logic in one place [source](./.skilld/docs/api/hooks.md#beforeeach)
```ts
beforeEach(() => {
const resource = setupResource()
return () => resource.cleanup()
})
```
- Use dynamic `import()` syntax with `vi.mock` for better TypeScript support and IDE integration — allows the compiler to validate the module path and type the `importOriginal` helper [source](./.skilld/docs/api/vi.md#vi-mock)
- Use `vi.hoisted` to declare variables referenced in `vi.mock` factories — allows bypassing the hoisting limitation and referencing setup code [source](./.skilld/docs/api/vi.md#vi-mock)
- Choose the `threads` pool over `forks` for larger projects to improve test run time — threads pool is faster for parallelization on multi-core machines [source](./.skilld/docs/guide/improving-performance.md#pool)
- Await `importOriginal()` inside mock factories to properly handle async module loading — mock factory receives an async helper that must be awaited to access the real module [source](./.skilld/docs/guide/mocking/modules.md#mocking-a-module)
- Apply retry conditions to tests with transient failures using regex or function-based matching — enables automatic retry only for specific error patterns without blanket retries [source](./.skilld/docs/config/retry.md#condition)
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .github/FUNDING.yml
================================================
github: [harlan-zw]
================================================
FILE: .github/ISSUE_TEMPLATE/01-feature-suggestion.yml
================================================
name: 🆕 Feature suggestion
description: Suggest an idea!
title: 'feat: '
labels: [enhancement]
body:
- type: textarea
validations:
required: true
attributes:
label: 🆒 Your use case
description: Add a description of your use case, and how this feature would help you.
placeholder: When I do [...] I would expect to be able to do [...]
- type: textarea
validations:
required: true
attributes:
label: 🆕 The solution you'd like
description: Describe what you want to happen.
- type: textarea
attributes:
label: 🔍 Alternatives you've considered
description: Have you considered any alternative solutions or features?
- type: textarea
attributes:
label: ℹ️ Additional info
description: Is there any other context you think would be helpful to know?
================================================
FILE: .github/ISSUE_TEMPLATE/02-bug-report.yml
================================================
name: 🐛 Bug report
description: Something's not working
title: 'fix: '
labels: [bug]
body:
- type: textarea
validations:
required: true
attributes:
label: 🐛 The bug
description: What isn't working? Describe what the bug is.
- type: input
validations:
required: true
attributes:
label: 🛠️ To reproduce
description: |
A reproduction of the bug. Please create a StackBlitz reproduction from one of the starters:
- [Basic](https://stackblitz.com/github/nuxt-modules/sitemap/tree/main/examples/basic)
- [i18n](https://stackblitz.com/github/nuxt-modules/sitemap/tree/main/examples/i18n)
- [Dynamic URLs](https://stackblitz.com/github/nuxt-modules/sitemap/tree/main/examples/dynamic-urls)
placeholder: https://stackblitz.com/[...]
- type: textarea
validations:
required: true
attributes:
label: 🌈 Expected behavior
description: What did you expect to happen? Is there a section in the docs about this?
- type: textarea
attributes:
label: ℹ️ Additional context
description: Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/03-documentation.yml
================================================
name: 📚 Documentation
description: How do I ... ?
title: 'docs: '
labels: [documentation]
body:
- type: textarea
validations:
required: true
attributes:
label: 📚 Is your documentation request related to a problem?
description: A clear and concise description of what the problem is.
placeholder: I feel I should be able to [...] but I can't see how to do it from the docs.
- type: textarea
attributes:
label: 🔍 Where should you find it?
description: What page of the docs do you expect this information to be found on?
- type: textarea
attributes:
label: ℹ️ Additional context
description: Add any other context or information.
================================================
FILE: .github/ISSUE_TEMPLATE/04-help-wanted.yml
================================================
name: 🆘 Help
description: I need help with ...
title: 'help: '
labels: [help wanted]
body:
- type: textarea
validations:
required: true
attributes:
label: 📚 What are you trying to do?
description: A clear and concise description of your objective.
placeholder: I'm not sure how to [...].
- type: textarea
attributes:
label: 🔍 What have you tried?
description: Have you looked through the docs? Tried different approaches? The more detail the better.
- type: textarea
attributes:
label: ℹ️ Additional context
description: Add any other context or information.
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
contact_links:
- name: 📖 Documentation
url: https://nuxtseo.com/sitemap/getting-started/installation
about: Check the documentation for guides and examples.
- name: 💬 Harlan's Discord Server
url: https://discord.com/invite/5jDAMswWwX
about: Join the friendly discord server for help with your issue.
================================================
FILE: .github/pull_request_template.md
================================================
### 🔗 Linked issue
### ❓ Type of change
- [ ] 📖 Documentation (updates to the documentation or readme)
- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)
### 📚 Description
================================================
FILE: .github/renovate.json5
================================================
{
// https://github.com/nuxt/renovate-config-nuxt
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>nuxt/renovate-config-nuxt"]
}
================================================
FILE: .github/workflows/nightly.yml
================================================
name: Nightly
on:
pull_request:
push:
branches:
- main
tags:
- '!**'
permissions:
contents: read
jobs:
build:
uses: harlan-zw/nuxt-seo/.github/workflows/reusable-nightly.yml@main
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
permissions:
contents: write
id-token: write
uses: harlan-zw/nuxt-seo/.github/workflows/reusable-release.yml@main
================================================
FILE: .github/workflows/test.yml
================================================
name: CI
on:
push:
paths-ignore:
- '**/README.md'
- 'docs/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
uses: harlan-zw/nuxt-seo/.github/workflows/reusable-ci.yml@main
================================================
FILE: .gitignore
================================================
node_modules
dist
.output
.nuxt
.temp
.tmp
.cache
.idea
.vscode
*.swp
.DS_Store
*.log
coverage
.env
.env.*
!.env.example
# Nuxt
playground/.nuxt
playground/.output
test/fixtures/**/.nuxt
test/fixtures/**/.output
.vercel_build_output
.build-*
.netlify
.data
# Skilld references (recreated by `skilld install`)
.skilld
================================================
FILE: .npmrc
================================================
shamefully-hoist=true
================================================
FILE: .nuxtrc
================================================
imports.autoImport=false
typescript.includeWorkspace=true
modules.0="@nuxtjs/sitemap"
setups.@nuxt/test-utils="4.0.0"
================================================
FILE: LICENSE.md
================================================
MIT License
Copyright (c) 2024 Harlan Wilton
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:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
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.
================================================
FILE: README.md
================================================
@nuxtjs/sitemap
[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![License][license-src]][license-href]
[![Nuxt][nuxt-src]][nuxt-href]
Nuxt Sitemap is a module for generating best-practice XML sitemaps that are consumed by the robots crawling your site.
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
need these.
## Features
- 🌴 Single `/sitemap.xml` or multiple `/posts-sitemap.xml`, `/pages-sitemap.xml`
- 📊 Fetch your sitemap URLs from anywhere
- 😌 Automatic `lastmod`, image discovery and best practice sitemaps
- 🔄 SWR caching, route rules support
- 🎨 Debug using the Nuxt DevTools integration or the XML Stylesheet
- 🤝 Integrates smoothly with [Nuxt I18n](https://github.com/nuxt-modules/i18n) and [Nuxt Content](https://github.com/nuxt/content)
## Installation
💡 Using Nuxt 2? Use the [nuxt-community/sitemap-module](https://github.com/nuxt-community/sitemap-module) docs.
Install `@nuxtjs/sitemap` dependency to your project:
```bash
npx nuxi@latest module add sitemap
```
> [!TIP]
> Generate an Agent Skill for this package using [skilld](https://github.com/harlan-zw/skilld):
> ```bash
> npx skilld add @nuxtjs/sitemap
> ```
💡 Need a complete SEO solution for Nuxt? Check out [Nuxt SEO](https://nuxtseo.com).
## Documentation
[📖 Read the full documentation](https://nuxtseo.com/sitemap) for more information.
## Demos
- [Dynamic URLs](https://stackblitz.com/edit/nuxt-starter-dyraxc?file=server%2Fapi%2F_sitemap-urls.ts)
- [i18n](https://stackblitz.com/edit/nuxt-starter-jwuie4?file=app.vue)
- [Manual Chunking](https://stackblitz.com/edit/nuxt-starter-umyso3?file=nuxt.config.ts)
- [Nuxt Content Document Driven](https://stackblitz.com/edit/nuxt-starter-a5qk3s?file=nuxt.config.ts)
## Sponsors
## License
Licensed under the [MIT license](https://github.com/nuxt-modules/sitemap/blob/main/LICENSE.md).
[npm-version-src]: https://img.shields.io/npm/v/@nuxtjs/sitemap/latest.svg?style=flat&colorA=18181B&colorB=28CF8D
[npm-version-href]: https://npmjs.com/package/@nuxtjs/sitemap
[npm-downloads-src]: https://img.shields.io/npm/dm/@nuxtjs/sitemap.svg?style=flat&colorA=18181B&colorB=28CF8D
[npm-downloads-href]: https://npmjs.com/package/@nuxtjs/sitemap
[license-src]: https://img.shields.io/github/license/nuxt-modules/sitemap.svg?style=flat&colorA=18181B&colorB=28CF8D
[license-href]: https://github.com/nuxt-modules/sitemap/blob/main/LICENSE.md
[nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt
[nuxt-href]: https://nuxt.com
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Reporting a Vulnerability
I take the security of my Nuxt modules seriously. If you believe you've found a security vulnerability, please follow these steps:
### Option 1: GitHub Security Advisory
1. Go to the GitHub repository of the affected module
2. Navigate to "Security" tab
3. Select "Report a vulnerability"
4. Provide a detailed description of the vulnerability
### Option 2: Email
Alternatively, you can email security concerns directly to:
- harlan@harlanzw.com
## What to Include in Your Report
Please include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any possible mitigations you've identified
## Response Process
When a vulnerability is reported:
1. I will acknowledge receipt within 48 hours
2. I will validate and investigate the report
3. I will work on a fix and coordinate the release process
4. After the fix is released, I will acknowledge your contribution (if desired)
## Scope
This security policy applies to all my Nuxt modules as published on npm.
Thank you for helping keep the Nuxt ecosystem secure!
================================================
FILE: benchmark/app/app.vue
================================================
bench
================================================
FILE: benchmark/bench.mjs
================================================
// Minimal throughput benchmark for @nuxtjs/sitemap
// Usage:
// node benchmark/bench.mjs # all variants
// BENCH_TARGET=/api/ping node benchmark/bench.mjs
//
// Each run gets its own .output dir so builds cannot leak between runs.
// After each build we assert presence/absence of sitemap module artefacts.
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import autocannon from 'autocannon'
const __dirname = dirname(fileURLToPath(import.meta.url))
const cwd = __dirname
const TARGET = process.env.BENCH_TARGET || '/api/ping'
const PORT = Number(process.env.BENCH_PORT || 3777)
const DURATION = Number(process.env.BENCH_DURATION || 10)
const CONNECTIONS = Number(process.env.BENCH_CONNECTIONS || 100)
const SITEMAP_ARTEFACTS = [
'chunks/routes/sitemap.xml.mjs',
'chunks/virtual/global-sources.mjs',
'chunks/virtual/child-sources.mjs',
]
// strings that must NOT appear in baseline server bundle and SHOULD appear with sitemap on
const SITEMAP_MARKERS = ['@nuxtjs/sitemap', 'useSitemapRuntimeConfig', '#sitemap-virtual']
function isolate(label) {
const slug = label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()
return {
nuxtDir: resolve(cwd, `.nuxt-${slug}`),
outDir: resolve(cwd, `.output-${slug}`),
}
}
function assertSitemapPresence({ outDir, expectSitemap, label }) {
const indexPath = resolve(outDir, 'server/index.mjs')
if (!existsSync(indexPath))
throw new Error(`[${label}] missing build: ${indexPath}`)
const presentArtefacts = SITEMAP_ARTEFACTS.filter(p => existsSync(resolve(outDir, 'server', p)))
const grepOut = []
const walker = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) {
walker(full)
}
else if (entry.name.endsWith('.mjs')) {
const txt = readFileSync(full, 'utf8')
for (const m of SITEMAP_MARKERS) {
if (txt.includes(m))
grepOut.push(`${full.slice(outDir.length + 1)}: ${m}`)
}
}
}
}
walker(resolve(outDir, 'server'))
console.log(`[${label}] sitemap artefacts present: ${presentArtefacts.length} -> ${JSON.stringify(presentArtefacts)}`)
console.log(`[${label}] sitemap marker hits in bundle: ${grepOut.length}`)
if (grepOut.length)
console.log(grepOut.slice(0, 5).map(l => ` - ${l}`).join('\n'))
if (expectSitemap) {
if (grepOut.length === 0)
throw new Error(`[${label}] expected sitemap markers but found none`)
}
else {
if (presentArtefacts.length > 0)
throw new Error(`[${label}] BASELINE LEAK: sitemap artefacts present: ${JSON.stringify(presentArtefacts)}`)
if (grepOut.length > 0)
throw new Error(`[${label}] BASELINE LEAK: sitemap markers found in baseline bundle:\n${grepOut.slice(0, 10).join('\n')}`)
}
}
async function run(label, env, expectSitemap) {
const { nuxtDir, outDir } = isolate(label)
console.log(`\n=== ${label} ===`)
console.log(`env: ${JSON.stringify(env)}`)
console.log(`nuxtDir: ${nuxtDir}`)
console.log(`outDir: ${outDir}`)
// wipe per-run dirs
for (const d of [nuxtDir, outDir]) rmSync(d, { recursive: true, force: true })
console.log('building...')
const slug = label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()
const build = spawn(
'npx',
['nuxt', 'build'],
{
cwd,
env: {
...process.env,
...env,
BENCH_SLUG: slug,
NUXT_TELEMETRY_DISABLED: '1',
},
stdio: 'inherit',
},
)
const [code] = await once(build, 'exit')
if (code !== 0)
throw new Error(`build failed (${code})`)
await assertSitemapPresence({ outDir, expectSitemap, label })
const server = spawn('node', [resolve(outDir, 'server/index.mjs')], {
cwd,
env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
let ready = false
server.stdout.on('data', (b) => {
const s = String(b)
process.stdout.write(`[server] ${s}`)
if (/Listening/.test(s))
ready = true
})
server.stderr.on('data', b => process.stderr.write(`[server] ${b}`))
for (let i = 0; i < 200 && !ready; i++) await sleep(100)
if (!ready) {
server.kill('SIGKILL')
throw new Error('server failed to start')
}
await sleep(200)
console.log(`benchmarking http://127.0.0.1:${PORT}${TARGET} for ${DURATION}s, ${CONNECTIONS} conns`)
const result = await autocannon({
url: `http://127.0.0.1:${PORT}${TARGET}`,
connections: CONNECTIONS,
duration: DURATION,
})
server.kill('SIGTERM')
await once(server, 'exit').catch(() => {})
return {
label,
rps: result.requests.average,
rpsMin: result.requests.min,
rpsMax: result.requests.max,
latencyAvg: result.latency.average,
latencyP99: result.latency.p99,
errors: result.errors,
non2xx: result.non2xx,
}
}
const runs = []
runs.push(await run('baseline-no-sitemap', { BENCH_SITEMAP: '0' }, false))
runs.push(await run('sitemap-default', { BENCH_SITEMAP: '1', BENCH_WARMUP: '1' }, true))
runs.push(await run('sitemap-no-warmup', { BENCH_SITEMAP: '1', BENCH_WARMUP: '0' }, true))
runs.push(await run('sitemap-no-xsl', { BENCH_SITEMAP: '1', BENCH_WARMUP: '0', BENCH_XSL: '0' }, true))
runs.push(await run('sitemap-zero-runtime', { BENCH_SITEMAP: '1', BENCH_WARMUP: '0', BENCH_ZERO: '1' }, true))
runs.push(await run('sitemap-rc-stub', { BENCH_SITEMAP: '1', BENCH_WARMUP: '0', BENCH_RC_STUB: '1' }, true))
console.log('\n=== summary ===')
console.table(runs.map(r => ({
'label': r.label,
'req/s avg': r.rps.toFixed(0),
'req/s min': r.rpsMin.toFixed(0),
'req/s max': r.rpsMax.toFixed(0),
'lat avg ms': r.latencyAvg.toFixed(2),
'lat p99 ms': r.latencyP99.toFixed(2),
'errors': r.errors,
'non2xx': r.non2xx,
})))
================================================
FILE: benchmark/nuxt.config.ts
================================================
const enableSitemap = process.env.BENCH_SITEMAP === '1'
const enableWarmUp = process.env.BENCH_WARMUP !== '0'
const enableXsl = process.env.BENCH_XSL !== '0'
const zeroRuntime = process.env.BENCH_ZERO === '1'
const slug = process.env.BENCH_SLUG || 'default'
console.log(`[bench/nuxt.config] sitemap=${enableSitemap} warm=${enableWarmUp} xsl=${enableXsl} zero=${zeroRuntime} slug=${slug}`)
export default defineNuxtConfig({
modules: [
...(enableSitemap ? ['../src/module'] : []),
(_options: any, nuxt: any) => {
nuxt.hook('modules:done', () => {
const names = nuxt.options._installedModules.map((m: any) => m?.meta?.name || m?.entryPath || '?')
console.log(`[bench] installed modules (${names.length}): ${JSON.stringify(names)}`)
})
},
] as any,
site: {
url: 'https://example.com',
},
sitemap: {
enabled: enableSitemap,
excludeAppSources: true,
debug: false,
sitemapsPathPrefix: '/',
discoverImages: false,
discoverVideos: false,
experimentalWarmUp: enableWarmUp,
xsl: enableXsl ? '/__sitemap__/style.xsl' : false,
zeroRuntime,
autoI18n: false,
cacheMaxAgeSeconds: 36000,
},
compatibilityDate: '2025-01-01',
buildDir: `.nuxt-${slug}`,
nitro: {
preset: 'node-server',
output: {
dir: `.output-${slug}`,
},
},
})
================================================
FILE: benchmark/package.json
================================================
{
"name": "sitemap-benchmark",
"type": "module",
"private": true,
"scripts": {
"bench": "node bench.mjs"
},
"dependencies": {
"@nuxtjs/sitemap": "workspace:*",
"autocannon": "catalog:",
"nuxt": "catalog:",
"vue": "catalog:"
}
}
================================================
FILE: benchmark/server/api/ping.get.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => ({ ok: true }))
================================================
FILE: build.config.ts
================================================
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
declaration: true,
entries: [
{ input: 'src/content', name: 'content' },
{ input: 'src/utils', name: 'utils' },
],
externals: [
// Nuxt core
'nuxt',
'nuxt/schema',
'@nuxt/kit',
'@nuxt/schema',
'nitropack',
'nitropack/types',
'h3',
// Vue
'vue',
'vue-router',
'@vue/runtime-core',
// Common deps
'#imports',
// Content subpath export
'@nuxt/content',
'zod',
],
})
================================================
FILE: devtools/app.config.ts
================================================
export default {
ui: {
colors: {
primary: 'green',
neutral: 'neutral',
},
button: {
defaultVariants: {
color: 'neutral',
variant: 'ghost',
size: 'sm',
},
},
badge: {
defaultVariants: {
color: 'neutral',
variant: 'subtle',
size: 'xs',
},
},
tooltip: {
defaultVariants: {
delayDuration: 0,
},
},
},
}
================================================
FILE: devtools/app.vue
================================================
================================================
FILE: devtools/components/Source.vue
================================================
{{ source.context.name }}
{{ source.error }}
{{ source._urlWarnings.length }} URL warning{{ source._urlWarnings.length > 1 ? 's' : '' }}
{{ w.loc }} — {{ w.message }}
================================================
FILE: devtools/composables/rpc.ts
================================================
import { useDevtoolsConnection } from 'nuxtseo-layer-devtools/composables/rpc'
import { refreshSources } from './state'
useDevtoolsConnection({
onConnected: () => refreshSources(),
})
================================================
FILE: devtools/composables/state.ts
================================================
import type { ProductionDebugResponse } from '../../src/runtime/server/routes/__sitemap__/debug-production'
import type { ModuleRuntimeConfig, SitemapDefinition, SitemapSourceResolved } from '../../src/runtime/types'
import { appFetch } from 'nuxtseo-layer-devtools/composables/rpc'
import { isProductionMode, productionUrl } from 'nuxtseo-layer-devtools/composables/state'
import { ref, watch } from 'vue'
export const data = ref<{
nitroOrigin: string
globalSources: SitemapSourceResolved[]
sitemaps: SitemapDefinition[]
runtimeConfig: ModuleRuntimeConfig
siteConfig?: { url?: string }
} | null>(null)
// Production debug data from the remote /__sitemap__/debug.json (requires debug: true in production)
export const productionRemoteDebugData = ref(null)
export const productionData = ref(null)
export const productionLoading = ref(false)
export async function refreshSources() {
if (appFetch.value)
data.value = await appFetch.value('/__sitemap__/debug.json') as typeof data.value
}
export async function refreshProductionData() {
if (!appFetch.value || !productionUrl.value)
return
productionLoading.value = true
productionRemoteDebugData.value = null
// Try fetching the full debug endpoint from production first (proxied through local server)
const remoteDebug = await appFetch.value('/__sitemap__/debug-production.json', {
query: { url: productionUrl.value, mode: 'debug' },
}).catch(() => null) as (typeof data.value & { error?: string }) | null
if (remoteDebug && !remoteDebug.error && remoteDebug.sitemaps && !Array.isArray(remoteDebug.sitemaps)) {
// Response has object sitemaps (debug.json format) rather than array (XML fallback format)
productionRemoteDebugData.value = remoteDebug
productionLoading.value = false
return
}
// Fall back to XML-based validation
productionData.value = await appFetch.value('/__sitemap__/debug-production.json', {
query: { url: productionUrl.value },
}).catch((err: Error) => {
console.error('Failed to fetch production sitemap data:', err)
return null
}) as ProductionDebugResponse | null
productionLoading.value = false
}
// Sync production URL from siteConfig when debug data loads
watch(data, (val) => {
if (val?.siteConfig?.url)
productionUrl.value = val.siteConfig.url
}, { immediate: true })
// Fetch production data when switching to production mode
watch(isProductionMode, (isProd) => {
if (isProd && !productionData.value && !productionRemoteDebugData.value)
refreshProductionData()
})
================================================
FILE: devtools/nuxt.config.ts
================================================
import { resolve } from 'pathe'
export default defineNuxtConfig({
extends: ['nuxtseo-layer-devtools'],
sitemap: false,
imports: {
autoImport: true,
},
nitro: {
prerender: {
routes: ['/', '/user-sources', '/app-sources', '/debug', '/docs'],
},
output: {
publicDir: resolve(__dirname, '../dist/devtools'),
},
},
app: {
baseURL: '/__nuxt-sitemap',
},
})
================================================
FILE: devtools/package.json
================================================
{
"name": "@nuxtjs/sitemap-client",
"private": true,
"devDependencies": {
"@iconify-json/carbon": "catalog:",
"@iconify-json/simple-icons": "catalog:",
"@nuxt/devtools-kit": "catalog:",
"@nuxt/kit": "catalog:",
"@vueuse/core": "catalog:",
"nuxt": "catalog:",
"nuxtseo-layer-devtools": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
}
}
================================================
FILE: devtools/pages/app-sources.vue
================================================
App Sources
Automatic global sources generated from your application.
================================================
FILE: devtools/pages/debug.vue
================================================
Runtime Config
================================================
FILE: devtools/pages/docs.vue
================================================
================================================
FILE: devtools/pages/index.vue
================================================
Production Sitemaps
Fetched from {{ productionUrl }}
with debug mode enabled
.
Re-validate
{{ sitemapPathFromUrl(sitemap.loc) }}
{{ sitemap.loc }}
{{ sitemap.error }}
{{ sitemap.warnings.length }} validation warning{{ sitemap.warnings.length > 1 ? 's' : '' }}
{{ w.context.url }}:
{{ w.message }}
Want to see full source details and URL validation? Deploy with sitemap: { debug: true } to get the same detailed view as development mode.
Sitemaps
The sitemaps generated from your site.
Links to your other sitemaps.
Learn more
Enabled
Disabled
View details
================================================
FILE: devtools/pages/user-sources.vue
================================================
User Sources
Manually provided global sources provided by you.
================================================
FILE: devtools/tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json"
}
================================================
FILE: docs/content/0.getting-started/0.introduction.md
================================================
---
title: 'Nuxt Sitemap'
description: 'Powerfully flexible XML Sitemaps that integrate seamlessly, for Nuxt.'
navigation:
title: 'Introduction'
relatedPages:
- path: /docs/robots/getting-started/installation
title: Nuxt Robots
- path: /docs/site-config/getting-started/installation
title: Nuxt Site Config
- path: /learn/controlling-crawlers
title: Controlling Web Crawlers
---
## Why use Nuxt Sitemap?
Nuxt Sitemap automatically generates XML sitemaps with zero configuration, including image discovery and i18n support.
The module outputs a [sitemap.xml](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview) file that search engines use to understand your site structure and index it more effectively.
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,
especially for larger sites or sites with complex structures.
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
and easy to miss best practices.
Nuxt Sitemap automatically generates the sitemap for you based on your site's content, with support for lastmod, image discovery and more.
Ready to get started? Check out the [installation guide](/docs/sitemap/getting-started/installation) or learn more on the [Controlling Web Crawlers](/learn-seo/nuxt/controlling-crawlers) guide.
## Features
- 🌴 Single /sitemap.xml or multiple /posts-sitemap.xml, /pages-sitemap.xml
- 📊 Fetch your sitemap URLs from anywhere
- 😌 Image discovery, lastmod support, and best practice sitemaps
- 🔄 SWR caching, route rules support
- 🎨 Debug using the Nuxt DevTools integration or the XML Stylesheet
- 🤝 Integrates seamlessly with Nuxt I18n and Nuxt Content
::callout{icon="i-heroicons-wrench" to="/tools/xml-sitemap-validator"}
**Validate your sitemap** - Use our free [XML Sitemap Validator](/tools/xml-sitemap-validator) to check structure and ensure Google compliance.
::
================================================
FILE: docs/content/0.getting-started/1.installation.md
================================================
---
title: 'Install Nuxt Sitemap'
description: 'Get started with Nuxt Sitemap by installing the dependency to your project.'
navigation:
title: 'Installation'
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/robots/getting-started/installation
title: Nuxt Robots
- path: /docs/site-config/getting-started/installation
title: Nuxt Site Config
---
## Setup Module
Want to know why you might need this module? Check out the [introduction](/docs/sitemap/getting-started/introduction).
To get started with Nuxt Sitemap, you need to install the dependency and add it to your Nuxt config.
:ModuleInstall{name="@nuxtjs/sitemap"}
::tip
Generate an Agent Skill for this package using [skilld](https://github.com/harlan-zw/skilld):
```bash
npx skilld add @nuxtjs/sitemap
```
::
## Verifying Installation
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.
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.
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).
You can debug this further in Nuxt DevTools under the Sitemap tab.
## Configuration
At a minimum the module requires a Site URL to be set, this is to ensure only your canonical domain is being used for
the sitemap. A site name can also be provided to customize the sitemap [stylesheet](/docs/sitemap/advanced/customising-ui).
::warning
Without a Site URL, your sitemap will use localhost in production.
::
:SiteConfigQuickSetup
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.
:ModuleCard{slug="robots" class="w-1/2"}
Every site is different and will require their own further unique configuration, to give you a head start:
- [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
generating your site from a CMS.
- [Multi Sitemaps](/docs/sitemap/guides/multi-sitemaps) - If you have 10k+ pages, you may want to split your sitemap into multiple files
so that search engines can process them more efficiently.
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.
## Next Steps
You've successfully installed Nuxt Sitemap. Here's the recommended reading path:
1. **[Data Sources](/docs/sitemap/getting-started/data-sources)** - Understand where your sitemap URLs come from
2. **[Dynamic URLs](/docs/sitemap/guides/dynamic-urls)** - Add URLs from a CMS or database
3. **[Best Practices](/docs/sitemap/guides/best-practices)** - Ensure your sitemap follows SEO guidelines
**Using other Nuxt modules?**
- [Nuxt I18n](/docs/sitemap/guides/i18n) - Automatic locale sitemaps
- [Nuxt Content](/docs/sitemap/guides/content) - Configure sitemap from markdown frontmatter
**Ready to deploy?** Check out [Submitting Your Sitemap](/docs/sitemap/guides/submitting-sitemap).
================================================
FILE: docs/content/0.getting-started/2.data-sources.md
================================================
---
title: Data Sources
description: Understand where your sitemap URLs come from.
navigation:
title: 'Data Sources'
---
## Where do sitemap URLs come from?
After installing the module, you may wonder: where do the URLs in your sitemap come from?
Every URL belongs to a **source**. There are two types:
- **Application Sources** - Automatically discovered from your Nuxt app
- **User Sources** - Manually provided by you
For most sites, application sources handle everything automatically. You only need user sources when you have dynamic routes from a CMS or database.
## Application Sources
Application sources are automatically generated from your Nuxt application. They provide convenience by automatically discovering URLs from your app's structure, but can be disabled if they don't match your needs.
- `nuxt:pages` - Statically analysed pages of your application (including [`definePageMeta`](/docs/sitemap/advanced/loc-data#modify-loc-data-with-page-meta) sitemap config)
- `nuxt:prerender` - URLs that were prerendered
- `nuxt:route-rules` - URLs from your route rules
- `@nuxtjs/i18n:pages` - When using the `pages` config with Nuxt I18n. See [Nuxt I18n](/docs/sitemap/guides/i18n) for more details.
- `nuxt-i18n-micro:pages` - When using the `pages` config with Nuxt I18n Micro. See [Nuxt I18n](/docs/sitemap/guides/i18n) for more details.
- `@nuxt/content@v2:urls` - When using Nuxt Content v2. See [Nuxt Content](/docs/sitemap/guides/content) for more details.
- `@nuxt/content@v3:urls` - When using Nuxt Content v3. See [Nuxt Content](/docs/sitemap/guides/content) for more details.
### Disabling Application Sources
You can disable application sources individually or all at once using the `excludeAppSources` config option.
::code-group
```ts [Disable all app sources]
export default defineNuxtConfig({
sitemap: {
// exclude all app sources
excludeAppSources: true,
}
})
```
```ts [Disable pages app source]
export default defineNuxtConfig({
sitemap: {
// exclude static pages
excludeAppSources: ['nuxt:pages'],
}
})
```
::
## User Sources
User sources allow you to manually configure where your sitemap URLs come from. These are especially useful for dynamic routes that aren't using [prerendering discovery](/docs/sitemap/guides/prerendering).
You have several options for providing user sources:
### 1. Build-time Sources with `urls` Function
For sitemap data that only needs to be updated at build time, the `urls` function is the simplest solution. This function runs once during sitemap generation.
It should return an array of path strings or [URL objects](/docs/sitemap/guides/dynamic-urls#url-structure-reference).
::code-group
```ts [Simple strings]
export default defineNuxtConfig({
sitemap: {
urls: ['/about', '/contact', '/products/special-offer']
}
})
```
```ts [Async function]
export default defineNuxtConfig({
sitemap: {
urls: async () => {
const response = await fetch('https://api.example.com/posts')
const posts = await response.json()
return posts.map(post => ({
loc: `/blog/${post.slug}`,
lastmod: post.updated_at,
}))
}
}
})
```
::
### 2. Runtime Sources with `sources` Array
For sitemap data that must always be up-to-date at runtime, use the `sources` array. Each source is a URL that gets fetched and should return either:
- JSON array of sitemap URL entries
- XML sitemap document
::code-group
```ts [Single Sitemap]
export default defineNuxtConfig({
sitemap: {
sources: [
// create our own API endpoints
'/api/__sitemap__/urls',
// use a static remote file
'https://cdn.example.com/my-urls.json',
// hit a remote API with credentials
['https://api.example.com/pages/urls', { headers: { Authorization: 'Bearer ' } }]
]
}
})
```
```ts [Multiple Sitemaps]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
foo: {
sources: [
'/api/__sitemap__/urls/foo',
]
},
bar: {
sources: [
'/api/__sitemap__/urls/bar',
]
}
}
}
})
```
::
You can provide multiple sources, but consider implementing your own caching strategy for performance.
Learn more about working with dynamic data in the [Dynamic URLs](/docs/sitemap/guides/dynamic-urls) guide.
### 3. Dynamic Sources Using Nitro Hooks
For advanced use cases like forwarding authentication headers or adding sources based on request context, see the [Nitro Hooks documentation](/docs/sitemap/nitro-api/nitro-hooks#sitemap-sources).
================================================
FILE: docs/content/0.getting-started/3.troubleshooting.md
================================================
---
title: "Troubleshooting Nuxt Sitemap"
description: Common issues and debugging tips for Nuxt Sitemap.
navigation:
title: 'Troubleshooting'
relatedPages:
- path: /docs/sitemap/advanced/customising-ui
title: Customising the UI
- path: /docs/sitemap/guides/submitting-sitemap
title: Submitting Your Sitemap
- path: /docs/nuxt-seo/getting-started/troubleshooting
title: Nuxt SEO Troubleshooting
---
## Debugging
### Nuxt DevTools
The best tool for debugging is the Nuxt DevTools integration with Nuxt Sitemap.
This will show you all of your sitemaps and the sources used to generate it.
### Debug Endpoint
If you prefer looking at the raw data, you can use the debug endpoint. This is only enabled in
development unless you enable the `debug` option.
Visit `/__sitemap__/debug.json` within your browser, this is the same data used by Nuxt DevTools.
### Debugging Prerendering
If you're trying to debug the prerendered sitemap, you should enable the `debug` option and check your output
for the file `.output/public/__sitemap__/debug.json`.
## Submitting an Issue
When submitting an issue, it's important to provide as much information as possible.
The easiest way to do this is to create a minimal reproduction using the Stackblitz playgrounds:
- [Dynamic URLs](https://stackblitz.com/edit/nuxt-starter-dyraxc?file=server%2Fapi%2F_sitemap-urls.ts)
- [i18n](https://stackblitz.com/edit/nuxt-starter-jwuie4?file=app.vue)
- [Manual Chunking](https://stackblitz.com/edit/nuxt-starter-umyso3?file=nuxt.config.ts)
- [Nuxt Content Document Driven](https://stackblitz.com/edit/nuxt-starter-a5qk3s?file=nuxt.config.ts)
## Troubleshooting FAQ
### Why is my browser not rendering the XML properly?
When disabling the [XSL](/docs/sitemap/advanced/customising-ui#disabling-the-xls) (XML Stylesheet) in, the XML will
be rendered by the browser.
If you have a i18n integration, then it's likely you'll see your sitemap look raw text instead of XML.

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.
There is no workaround besides re-enabled the XSL.
### Google Search Console shows Error when submitting my Sitemap?
Seeing "Error" when submitting a new sitemap is common. This is because Google previously
crawled your site for a sitemap and found nothing.
If your sitemap is [validating](https://www.xml-sitemaps.com/validate-xml-sitemap.html) correctly, then you're all set.
It's best to wait a few days and check back. In nearly all cases, the error will resolve itself.
### Google Search Console shows "Couldn't fetch" or "Sitemap could not be read"?
This is a well known Google Search Console issue where it reports "Couldn't fetch" or "Sitemap could not be read" even though the sitemap XML is perfectly valid. This is not caused by the module.
**Why it happens:** Google caches sitemap fetch results. When you first submit a sitemap (or resubmit at the same URL), Google may return a stale cached failure instead of actually re-fetching the sitemap. This is especially common when:
- You've just deployed your site for the first time
- You've recently added the sitemap module
- Google previously crawled the URL and found no sitemap
**How to verify your sitemap is fine:**
1. Open the [URL Inspection tool](https://search.google.com/search-console?action=inspect) in Google Search Console
2. Paste your sitemap URL (e.g. `https://example.com/sitemap.xml`)
3. Click **Live test**
4. Expand the **Page availability** section and confirm: Crawl allowed = "Yes", Page fetch = "Successful", Indexing allowed = "Yes"
If the live test passes, your sitemap is valid and the "Couldn't fetch" status is a Google caching issue.
**Workarounds:**
::steps{level="4"}
#### Wait it out
In most cases the error resolves itself within 24 to 72 hours without any changes on your end.
#### Remove and resubmit in Search Console
Remove your sitemap from the Google Search Console Sitemaps report, then resubmit it. Google will discover any sub-sitemaps (e.g. locale-specific sitemaps) automatically from the sitemap index.
#### Change the sitemap URL to force a fresh fetch
Google caches results by URL. Changing the sitemap filename forces Google to treat it as a new sitemap, bypassing any cached failures. You can do this with the `sitemapName` option:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemapName: 'sitemap_index.xml',
},
})
```
After deploying, submit the new URL in Google Search Console. This workaround has consistently resolved the issue immediately for affected users.
::
::note
You can validate your sitemap independently using the [XML Sitemap Validator](/tools/xml-sitemap-validator) or [xml-sitemaps.com](https://www.xml-sitemaps.com/validate-xml-sitemap.html) to confirm the issue is on Google's side.
::
### Getting 404/Error when Pinging Google?
If you are using a script or CI/CD job to "ping" Google with your sitemap URL (e.g., `google.com/ping?sitemap=...`), it will now fail.
Google **deprecated** the sitemap ping endpoint in January 2024. You should remove this step from your deployment process and rely on `robots.txt` discovery or Google Search Console.
### Search Console shows "Invalid character" error?
This happens when URLs contain reserved characters like `$`, `:`, or `@` that aren't properly encoded for XML.
The module automatically encodes unicode characters (emojis, accents) but does not encode RFC-3986 reserved characters.
**Solution:** If your API returns pre-encoded URLs, mark them with `_encoded: true` to prevent double-encoding:
```ts [server/api/__sitemap__/urls.ts]
export default defineSitemapEventHandler(async () => {
const urls = await $fetch('https://api.example.com/pages')
// URLs are already encoded: [{ path: '/products/%24pecial' }]
return urls.map(url => ({
loc: url.path,
_encoded: true,
}))
})
```
See [Handling Pre-Encoded URLs](/docs/sitemap/guides/dynamic-urls#handling-pre-encoded-urls) for more details.
## Debugging Tools
- [XML Sitemap Validator](/tools/xml-sitemap-validator) - Validate sitemap structure, check URL format, and test against Google requirements
================================================
FILE: docs/content/1.guides/0.dynamic-urls.md
================================================
---
title: Dynamic URL Endpoints
description: Use runtime API endpoints to generate dynamic URLs for your sitemap.
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/sitemap/guides/i18n
title: I18n Integration
- path: /docs/sitemap/guides/multi-sitemaps
title: Multi Sitemaps
---
## Introduction
When working with a CMS or external data sources, you may need to generate sitemap URLs dynamically at runtime.
The module supports two types of data sources:
- JSON responses from API endpoints
- XML sitemaps from external sources
## URL Structure Reference
All sitemap URLs follow this structure, whether from JSON endpoints or the `urls` config:
```ts
interface SitemapUrl {
loc: string // Required: The URL path (e.g., '/blog/my-post')
lastmod?: string | Date // Optional: Last modified date (ISO 8601 format or Date object)
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
priority?: 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1 // Optional: 0.0 to 1.0
images?: ImageEntry[] // Optional: Array of image objects
videos?: VideoEntry[] // Optional: Array of video objects
news?: GoogleNewsEntry // Optional: Google News entry
_sitemap?: string // Optional: Specify which sitemap this URL belongs to (for multi-sitemap setups)
_encoded?: boolean // Optional: Mark the URL as already encoded
_i18nTransform?: boolean // Optional: Automatically transform the URL for all locales
alternatives?: Array<{ // Optional: For i18n/alternate language URLs
hreflang: string // Language code (e.g., 'en', 'fr', 'es')
href: string // Full URL to alternative version
}>
}
```
## Using External XML Sitemaps
If you have an existing XML sitemap, you can reference it directly in your configuration:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sources: [
'https://example.com/sitemap.xml',
]
}
})
```
## Dynamic URLs from External APIs
When fetching dynamic URLs from external APIs, you have two main approaches:
1. **Direct source configuration** - Use when the API returns data in the correct format
2. **Custom API endpoint** - Use when you need to transform data or implement caching
### 1. Using Source Configuration
For APIs that require authentication or custom headers, provide sources as an array with fetch options:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sources: [
// Unauthenticated endpoint
'https://api.example.com/pages/urls',
// Authenticated endpoint
[
'https://authenticated-api.example.com/pages/urls',
{ headers: { Authorization: 'Bearer ' } }
]
]
}
})
```
### 2. Creating Custom Endpoints
**Step 1: Create the API endpoint**
Use the [`defineSitemapEventHandler()`{lang="ts"}](/docs/sitemap/nitro-api/nitro-hooks) helper to create type-safe sitemap endpoints:
::code-group
```ts [Simple]
import type { SitemapUrlInput } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about-us',
// Specify which sitemap this URL belongs to
_sitemap: 'pages',
},
] satisfies SitemapUrlInput[]
})
```
```ts [Multiple Sitemaps]
import type { SitemapUrl } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
const [posts, pages] = await Promise.all([
$fetch<{ path: string, slug: string }[]>('https://api.example.com/posts')
.then(posts => posts.map(p => ({
loc: `/blog/${p.slug}`, // Transform to your domain structure
_sitemap: 'posts',
} satisfies SitemapUrl))),
$fetch<{ path: string }[]>('https://api.example.com/pages')
.then(pages => pages.map(p => ({
loc: p.path,
_sitemap: 'pages',
} satisfies SitemapUrl))),
])
return [...posts, ...pages]
})
```
```ts [WordPress Example]
// server/api/__sitemap__/wordpress.ts
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
const posts = await $fetch('https://api.externalwebsite.com/wp-json/wp/v2/posts')
return posts.map(post => ({
// Transform external URL to your domain
loc: `/blog/${post.slug}`, // NOT post.link
lastmod: post.modified,
changefreq: 'weekly',
priority: 0.7,
}))
})
```
```ts [Dynamic i18n]
import type { SitemapUrl } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
const config = useRuntimeConfig()
const baseUrl = config.public.siteUrl
const locales = config.public.i18n.locales.map(locale => locale.code)
const isoLocales = Object.fromEntries(
config.public.i18n.locales.map(locale => ([locale.code, locale.iso]))
)
// Example: Fetch data for each locale
const apiQueries = locales.map(locale =>
$fetch(`${config.public.apiEndpoint}/sitemap/${locale}/products`)
)
const sitemaps = await Promise.all(apiQueries)
return sitemaps.flat().map(entry => ({
// explicit sitemap mapping
_sitemap: isoLocales[entry.locale],
loc: `${baseUrl}/${entry.locale}/product/${entry.url}`,
alternatives: entry.alternates?.map(alt => ({
hreflang: isoLocales[alt.locale],
href: `${baseUrl}/${alt.locale}/product/${alt.url}`
}))
} satisfies SitemapUrl))
})
```
::
**Step 2: Configure the endpoint**
Add your custom endpoint to the sitemap configuration:
::code-group
```ts [Single Sitemap]
export default defineNuxtConfig({
sitemap: {
sources: [
'/api/__sitemap__/urls',
]
}
})
```
```ts [Multiple Sitemaps]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: [
'/api/__sitemap__/urls/posts',
]
},
pages: {
sources: [
'/api/__sitemap__/urls/pages',
]
}
}
}
})
```
::
## Handling Pre-Encoded URLs
By default, the module automatically encodes URL paths. This handles special characters like spaces and unicode (e.g., emojis, accented characters).
If your API or CMS returns URLs that are already encoded, mark them with `_encoded: true` to prevent double-encoding.
```ts [server/api/__sitemap__/urls.ts]
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
// URLs from your API are already encoded
const urls = await $fetch<{ path: string }[]>('https://api.example.com/pages')
// e.g. [{ path: '/products/%24pecial-offer' }, { path: '/blog/%F0%9F%98%85' }]
return urls.map(url => ({
loc: url.path,
_encoded: true,
}))
})
```
::callout{type="info"}
When `_encoded: true` is set, the module skips automatic encoding entirely. Make sure your URLs are properly encoded.
::
================================================
FILE: docs/content/1.guides/1.filtering-urls.md
================================================
---
title: Disabling Indexing
description: How to filter the URLs generated from application sources.
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/robots/getting-started/installation
title: Nuxt Robots
- path: /learn/controlling-crawlers
title: Controlling Web Crawlers
---
## Introduction
When viewing your sitemap.xml for the first time, you may notice some URLs you don't want to be included.
These URLs are most likely coming from [Application Sources](/docs/sitemap/getting-started/data-sources).
If you don't want to disable these sources but want to remove these URLs you have a couple of options.
## Nuxt Robots
The easiest way to block search engines from indexing a URL is to use the [Nuxt Robots](/docs/robots/getting-started/installation) module
and simply block the URL in your robots.txt.
:ModuleCard{slug="robots" class="w-1/2"}
Nuxt Sitemap will honour any blocked pages from being ignored in the sitemap.
## Disabling indexing with Route Rules
If you don't want a page in your sitemap because you don't want search engines to crawl it,
then you can make use of the `robots` route rule. For comprehensive route rules documentation, see [Nuxt Robots route rules](/docs/robots/guides/route-rules).
### Disabling indexing for a pattern of URLs
If you have a pattern of URLs that you want hidden from search you can use route rules.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
routeRules: {
// Don't add any /secret/** URLs to the sitemap.xml
'/secret/**': { robots: false },
}
})
```
### Inline route rules
If you just have some specific pages, you can use the experimental [`defineRouteRules()`{lang="ts"}](https://nuxt.com/docs/api/utils/define-route-rules), which must
be enabled.
```vue
```
## Filter URLs with include / exclude
For all other cases, you can use the `include` and `exclude` module options to filter URLs.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
// exclude all URLs that start with /secret
exclude: ['/secret/**'],
// include all URLs that start with /public
include: ['/public/**'],
}
})
```
Either option supports either an array of strings, RegExp objects or a `{ regex: string }` object.
Providing strings will use the [route rules path matching](https://nuxt.com/docs/guide/concepts/rendering#hybrid-rendering) which
does not support variable path segments in front of static ones.
For example, `/foo/**` will work but `/foo/**/bar` will not. To get around this you should use regex.
### Regex Filtering
Filtering using regex is more powerful and can be used to match more complex patterns. It's recommended to pass a
`RegExp` object explicitly.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
exclude: [
// exclude /foo/**/bar using regex
new RegExp('/foo/.*/bar')
],
}
})
```
================================================
FILE: docs/content/1.guides/2.multi-sitemaps.md
================================================
---
title: Multi Sitemaps
description: Generate multiple sitemaps for different sections of your site.
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/sitemap/guides/dynamic-urls
title: Dynamic URL Endpoints
- path: /docs/sitemap/advanced/chunking-sources
title: Sitemap Chunking
- path: /docs/sitemap/advanced/performance
title: Sitemap Performance
---
## Introduction
By default, the module generates a single `/sitemap.xml` file, which works perfectly for most websites.
For larger sites with thousands of URLs, multiple sitemaps offer several benefits:
- Easier debugging and management
- More efficient search engine crawling
- Better organization of content types
## Enabling Multiple Sitemaps
You can enable multiple sitemaps using the `sitemaps` option in two ways:
1. **Manual Chunking** (`object`): Best for sites with clear content types (pages, posts, etc) or fewer than 1000 URLs
2. **Automatic Chunking** (`true`): Best for sites with more than 1000 URLs without clear content types
::code-group
```ts [Manual Chunking]
export default defineNuxtConfig({
sitemap: {
// manually chunk into multiple sitemaps
sitemaps: {
posts: {
include: [
'/blog/**',
],
// example: give blog posts slightly higher priority (this is optional)
defaults: { priority: 0.7 },
},
pages: {
exclude: [
'/blog/**',
]
},
},
},
})
```
```ts [Automatic Chunking]
export default defineNuxtConfig({
sitemap: {
sitemaps: true,
// modify the chunk size if you need
defaultSitemapsChunkSize: 2000 // default 1000
},
})
```
::
### Customizing Sitemap URLs
By default, all multi-sitemaps are served under the `/__sitemap__/` prefix. You can customize this behavior to create cleaner URLs:
```ts
export default defineNuxtConfig({
sitemap: {
sitemapsPathPrefix: '/', // or false
sitemaps: {
// will be available at /sitemap-foo.xml
'sitemap-foo': {
// ...
}
}
}
})
```
## Manual Chunking
Manual chunking gives you complete control over how your URLs are distributed across sitemaps. This approach is ideal when you have distinct content types or specific organizational needs.
### Setting Default Values
You can provide default values for URLs within each sitemap using the `defaults` option:
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
// posts low priority
defaults: { priority: 0.7 },
},
},
},
})
```
### Extending App Sources
When you already have all URLs in your single sitemap but want to split them into separate sitemaps, you can extend existing [app sources](/docs/sitemap/getting-started/data-sources) and apply filters.
Available options:
- `includeAppSources`: Include URLs from automatic app sources
- `include`: Array of glob patterns to include
- `exclude`: Array of glob patterns to exclude
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
pages: {
// extend the nuxt:pages app source
includeAppSources: true,
// filter the URLs to only include pages
exclude: ['/blog/**'],
},
posts: {
// extend the nuxt:pages app source
includeAppSources: true,
// filter the URLs to only include pages
include: ['/blog/**'],
},
},
},
})
```
#### Using the `_sitemap` Key
When using global sources and need to direct specific URLs to particular sitemaps, use the `_sitemap` key:
::code-group
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sources: [
'/api/sitemap-urls'
],
sitemaps: {
pages: {
includeAppSources: true,
exclude: ['/**']
// ...
},
},
},
})
```
```ts [server/api/sitemap-urls.ts]
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about-us',
// will end up in the pages sitemap
_sitemap: 'pages',
}
]
})
```
::
### Managing Custom Sources
For sitemaps that need to fetch URLs from endpoints, you have two options:
- `urls`: Static URLs to include in the sitemap (avoid for large URL sets)
- `sources`: Endpoints to fetch [dynamic URLs](/docs/sitemap/guides/dynamic-urls) from (JSON or XML)
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
urls() {
// resolved when the sitemap is shown
return ['/foo', '/bar']
},
sources: [
'/api/sitemap-urls'
]
},
},
},
})
```
### Chunking Large Sources
When you have sources that return a large number of URLs, you can enable chunking to split them into multiple XML files:
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'], // returns 10,000 posts
chunks: true, // Enable chunking with default size (1000)
},
products: {
sources: ['/api/products'], // returns 50,000 products
chunks: 5000, // Chunk into files with 5000 URLs each
},
articles: {
sources: ['/api/articles'],
chunks: true,
chunkSize: 2000, // Alternative way to specify chunk size
}
}
},
})
```
This will generate:
- `/sitemap_index.xml` - Lists all sitemaps including chunks
- `/posts-0.xml` - First 1000 posts
- `/posts-1.xml` - Next 1000 posts
- `/products-0.xml` - First 5000 products
- `/products-1.xml` - Next 5000 products
- etc.
### Linking External Sitemaps
Use the special `index` key to add external sitemaps to your sitemap index:
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
// generated sitemaps
posts: {
// ...
},
pages: {
// ...
},
// extending the index sitemap with an external sitemap
index: [
{ sitemap: 'https://www.google.com/sitemap-pages.xml' }
]
}
}
})
```
## Automatic Chunking
Automatic chunking divides your sitemap into multiple files based on URL count. This feature:
- Uses numbered naming convention (`0.xml`, `1.xml`, etc.)
- Chunks based on `defaultSitemapsChunkSize` (default: 1000 URLs per sitemap)
- Should be avoided for sites with fewer than 1000 URLs
```ts
export default defineNuxtConfig({
sitemap: {
// automatically chunk into multiple sitemaps
sitemaps: true,
// optionally customize chunk size
defaultSitemapsChunkSize: 2000 // default: 1000
},
})
```
================================================
FILE: docs/content/1.guides/3.i18n.md
================================================
---
title: I18n
description: Setting up a sitemap with Nuxt I18n and Nuxt I18n Micro.
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/sitemap/guides/dynamic-urls
title: Dynamic URL Endpoints
- path: /docs/sitemap/advanced/customising-ui
title: Customising the UI
---
## Introduction
The sitemap module automatically integrates with [@nuxtjs/i18n](https://i18n.nuxtjs.org/) and [nuxt-i18n-micro](https://github.com/s00d/nuxt-i18n-micro) without any extra configuration.
While the integration works out of the box, you may need to fine-tune some options depending on your i18n setup.
## I18n Modes
The module supports two main modes for handling internationalized sitemaps:
### Automatic I18n Multi Sitemap
The module automatically generates a sitemap for each locale when:
- You're not using the `no_prefix` strategy
- Or you're using [Different Domains](https://i18n.nuxtjs.org/docs/v7/different-domains)
This generates the following structure:
```shell
./sitemap_index.xml
./en-sitemap.xml
./fr-sitemap.xml
# ...additional locales
```
Key features:
- Includes [app sources](/docs/sitemap/getting-started/data-sources) automatically
- The `nuxt:pages` source determines the correct `alternatives` for your pages
- To disable app sources, set `excludeAppSources: true`
#### Custom Sitemaps with I18n
You can add custom sitemaps alongside the automatic i18n multi-sitemap. When any sitemap uses `includeAppSources: true`, the module still generates per-locale sitemaps and merges the `exclude`/`include` filters:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
pages: {
includeAppSources: true,
exclude: ['/admin/**'],
},
posts: {
sources: ['/api/__sitemap__/posts'],
}
}
}
})
```
This generates:
```shell
./sitemap_index.xml
./en-pages.xml # locale sitemap with /admin/** excluded
./fr-pages.xml # locale sitemap with /admin/** excluded
./posts.xml # custom sitemap (kept as-is)
```
The sitemap name is preserved with the format `{locale}-{name}`. Sitemaps without `includeAppSources` (like `posts`) remain as separate sitemaps.
### I18n Pages Mode
When you enable `i18n.pages` in your i18n configuration, the sitemap module generates a single sitemap using that configuration.
Key differences:
- Does not include [app sources](/docs/sitemap/getting-started/data-sources) automatically
- You can add additional URLs using the `sources` option
## Dynamic URLs with i18n
By default, dynamic URLs you provide won't have i18n data and will only appear in the default locale sitemap.
To handle i18n for dynamic URLs, use these special options:
### 1. `_i18nTransform` - Automatic Locale Transformation
Use `_i18nTransform: true` to automatically generate URLs for all locales:
```ts [server/api/__sitemap__/urls.ts]
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about-us',
// automatically creates: /en/about-us, /fr/about-us, etc.
_i18nTransform: true,
}
]
})
```
#### Custom Path Translations
If you have custom path translations defined in your i18n configuration using `pages`, the `_i18nTransform` option will automatically use them:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
i18n: {
pages: {
about: {
en: '/about',
fr: '/a-propos',
es: '/acerca-de',
},
services: {
en: '/services',
fr: '/offres',
es: '/servicios',
},
},
},
})
```
With this configuration, when you set `_i18nTransform: true` on a URL:
```ts [server/api/__sitemap__/urls.ts]
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about', // base path
_i18nTransform: true,
// automatically generates:
// - /about (for en)
// - /fr/a-propos (for fr)
// - /es/acerca-de (for es)
}
]
})
```
### 2. `_sitemap` - Specific Locale Assignment
Use `_sitemap` to assign a URL to a specific locale sitemap:
```ts [server/api/__sitemap__/urls.ts]
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about-us',
// only appears in the English sitemap
_sitemap: 'en',
}
]
})
```
## Debugging Hreflang
By default, hreflang tags aren't visible in the XML stylesheet view. To see them, you'll need to view the page source.
Note: Search engines can still see these tags even if they're not visible in the stylesheet.
To display hreflang tag counts in the visual interface, customize the columns:
```ts
export default defineNuxtConfig({
sitemap: {
xslColumns: [
{ label: 'URL', width: '50%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
{ label: 'Hreflangs', select: 'count(xhtml:link)', width: '25%' },
],
}
})
```
For more customization options, see the [Customising UI guide](/docs/sitemap/advanced/customising-ui).
## Opting Out of I18n Integration
If you're using `@nuxtjs/i18n` or `nuxt-i18n-micro` but want the sitemap module to ignore it entirely, set `autoI18n: false`. This generates a single sitemap without locale prefixes or hreflang tags.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
autoI18n: false,
},
})
```
================================================
FILE: docs/content/1.guides/4.content.md
================================================
---
title: Nuxt Content
description: How to use the Nuxt Sitemap module with Nuxt Content.
relatedPages:
- path: /docs/sitemap/guides/dynamic-urls
title: Dynamic URL Endpoints
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/sitemap/advanced/loc-data
title: Lastmod, Priority, and Changefreq
---
## Introduction
Nuxt Sitemap comes with an integration for Nuxt Content that allows you to configure your sitemap entry straight from your content files directly.
### Supported Content Types
The sitemap integration works with all content file types supported by Nuxt Content:
- Markdown (`.md`)
- YAML (`.yml` / `.yaml`)
- JSON (`.json`)
- CSV (`.csv`)
## Setup Nuxt Content v3
Add `defineSitemapSchema()`{lang="ts"} to your collection's schema to enable the `sitemap` frontmatter key.
```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
content: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
},
})
```
### Filtering Content
Pass a `filter` function to `defineSitemapSchema()` to exclude entries at runtime. This is useful for filtering out draft posts, future content, or any entries that shouldn't appear in the sitemap.
```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
// The `name` option must match the collection key
blog: defineCollection({
type: 'page',
source: 'blog/**/*.md',
schema: z.object({
date: z.string().optional(),
draft: z.boolean().optional(),
sitemap: defineSitemapSchema({
name: 'blog',
filter: (entry) => {
if (entry.draft)
return false
if (entry.date && new Date(entry.date) > new Date())
return false
return true
},
}),
}),
}),
},
})
```
::important
The `name` option must match the collection key exactly (e.g. if your collection key is `blog`, use `name: 'blog'`). This is how the filter is matched to the correct collection at runtime.
::
The `filter` function receives the full content entry including your custom schema fields and should return `true` to include, `false` to exclude.
### Transforming URLs with `onUrl`
Use the `onUrl` callback to transform the sitemap entry for each item in a collection. The callback receives the resolved URL object; mutate it directly to change `loc`, `lastmod`, `priority`, or any other field.
This is especially useful when using per-locale collections with `@nuxtjs/i18n`. If a collection uses `prefix: '/'` or `prefix: ''` to strip the locale directory from content paths, the sitemap URLs will be missing the locale prefix. Use `onUrl` to re-add it:
```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
content_en: defineCollection({
type: 'page',
source: { include: 'en/**', prefix: '/' },
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
content_ja: defineCollection({
type: 'page',
source: { include: 'ja/**', prefix: '/' },
schema: z.object({
sitemap: defineSitemapSchema({
name: 'content_ja',
onUrl(url) {
url.loc = `/ja${url.loc}`
},
}),
}),
}),
},
})
```
Without `onUrl`, both collections would produce `loc: '/about'` for their `about.md` files. With the transform, the ja collection entries correctly produce `loc: '/ja/about'`, allowing the i18n sitemap builder to assign them to the correct per-locale sitemap.
The callback also receives the full content entry and collection name, so you can use any content field to drive sitemap values:
```ts
schema: z.object({
featured: z.boolean().optional(),
sitemap: defineSitemapSchema({
name: 'blog',
onUrl(url, entry, collection) {
url.loc = url.loc.replace('/posts/', '/blog/')
url.priority = entry.featured ? 1.0 : 0.5
},
}),
})
```
::important
The `name` option must match the collection key exactly (e.g. if your collection key is `content_ja`, use `name: 'content_ja'`).
::
Due to current Nuxt Content v3 limitations, you must load the sitemap module before the content module.
```ts
export default defineNuxtConfig({
modules: [
'@nuxtjs/sitemap',
'@nuxt/content' // <-- Must be after @nuxtjs/sitemap
]
})
```
## Setup Nuxt Content v2
In Nuxt Content v2 markdown files require either [Document Driven Mode](https://content.nuxt.com/document-driven/introduction), a `path` key to be set
in the frontmatter or the `strictNuxtContentPaths` option to be enabled.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
// things just work!
content: {
documentDriven: true
}
})
```
If you're not using `documentDriven` mode and your content paths are the same as their real paths,
you can enable `strictNuxtContentPaths` to get the same behaviour.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
strictNuxtContentPaths: true
}
})
```
### Advanced: Nuxt Content App Source
If you'd like to set up a more automated Nuxt Content integration and you're not using Document Driven mode, you can add content to the sitemap as you would with [Dynamic URLs](/docs/sitemap/guides/dynamic-urls).
An example of what this might look like is below, customize to your own needs.
```ts [server/api/__sitemap__/urls.ts]
import type { ParsedContent } from '@nuxt/content/dist/runtime/types'
import { serverQueryContent } from '#content/server'
import { asSitemapUrl, defineSitemapEventHandler } from '#imports'
import { defineEventHandler } from 'h3'
export default defineSitemapEventHandler(async (e) => {
const contentList = (await serverQueryContent(e).find()) as ParsedContent[]
return contentList
.filter(c => c._path.startsWith('_articles'))
.map((c) => {
return asSitemapUrl({
loc: `/blog/${c._path.replace('_articles', '')}`,
lastmod: updatedAt
})
})
})
```
```ts
export default defineNuxtConfig({
sitemap: {
sources: [
'/api/__sitemap__/urls'
]
}
})
```
## Usage
### Frontmatter `sitemap`
Use the `sitemap` key in your frontmatter to add a page to your sitemap.
You can provide any data that you would normally provide in the sitemap configuration.
#### Markdown Example
```md
---
sitemap:
loc: /my-page
lastmod: 2021-01-01
changefreq: monthly
priority: 0.8
---
# My Page
```
#### YAML Example
```yaml [content/pages/about.yml]
title: About Page
description: Learn more about us
sitemap:
lastmod: 2025-05-13
changefreq: monthly
priority: 0.8
content: |
This is the about page content
```
#### JSON Example
```json [content/products/widget.json]
{
"title": "Widget Product",
"price": 99.99,
"sitemap": {
"lastmod": "2025-05-14",
"changefreq": "weekly",
"priority": 0.9
}
}
```
### Exclude from Sitemap
If you'd like to exclude a page from the sitemap, you can set `sitemap: false` in the frontmatter or `robots: false`
if you'd like to exclude it from search engines.
```md
---
sitemap: false
robots: false
---
```
#### Troubleshooting Exclusions
If `sitemap: false` or `robots: false` aren't working, check the following:
**Nuxt Content v3** — Ensure your collection schema includes `defineSitemapSchema()` in `content.config.ts`:
```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
content: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
},
})
```
**Nuxt Content v2** — Ensure you have Document Driven mode or `strictNuxtContentPaths` enabled:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
content: {
documentDriven: true
},
// OR
sitemap: {
strictNuxtContentPaths: true
}
})
```
**Module load order** — The sitemap module must be loaded before the content module:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: [
'@nuxtjs/sitemap', // Must be before @nuxt/content
'@nuxt/content'
]
})
```
If pages still appear after these checks, clear `.nuxt` and rebuild.
================================================
FILE: docs/content/1.guides/5.prerendering.md
================================================
---
title: Nuxt Prerendering
description: Prerender your pages and have them all automatically added to your sitemap.
relatedPages:
- path: /docs/sitemap/advanced/images-videos
title: Images, Videos, News
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
---
## Introduction
When prerendering routes using Nuxt through either `nuxi generate` or using the prerender options, the module
will extract data from the generated HTML and add it to the sitemap.
This can be useful if you have dynamic routes that you want to be included in the sitemap and want to minimise
your configuration.
## Extracted HTML Data
The following data can be extracted from the raw HTML.
- `images` - Adds image entries ``{lang="xml"}.
Passes any ` `{lang="html"} tags within the ``{lang="html"} tag. Opt-out by disabling `discoverImages`.
- `videos` - Adds video entries ``{lang="xml"}.
Passes any ``{lang="html"} tags within the ``{lang="html"} tag. Opt-out by disabling `discoverVideos`.
- `lastmod` - Adds lastmod date ``{lang="xml"}.
Uses the [opengraph](https://ogp.me) `article:modified_time` and `article:published_time` meta tag.
## Enabling Nuxt Prerendering
You will need to use configuration to enable this feature.
```ts
export default defineNuxtConfig({
nitro: {
prerender: {
// enabled by default with nuxt generate, not required
crawlLinks: true,
// add any routes to prerender
routes: ['/']
}
}
})
```
You can also use route rules to enable prerendering for specific routes.
```ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }
}
})
```
### Prerendering the Sitemap on Build
If you're using `nuxi build` and want to prerender the sitemap on build, you can add the sitemap path to the `nitro.prerender.routes` option.
```ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/sitemap.xml']
}
}
})
```
### Customizing the prerender data
If needed, you can customize the prerender data by using the Nitro hooks.
Here is a simple recipe that will extract YouTube video iframes and add them to the sitemap.
```ts
import type { ResolvedSitemapUrl } from '#sitemap/types'
export default defineNuxtConfig({
modules: [
// run this before the sitemap module
(_, nuxt) => {
nuxt.hooks.hook('nitro:init', async (nitro) => {
nitro.hooks.hook('prerender:generate', async (route) => {
const html = route.contents
// check for youtube video iframes and append to the videos array
const matches = html.match(//g)
if (matches) {
const sitemap = route._sitemap || {} as ResolvedSitemapUrl
sitemap.videos = sitemap.videos || []
for (const match of matches) {
const videoId = match.match(/youtube.com\/embed\/(.*?)" /)[1]
sitemap.videos.push({
title: 'YouTube Video',
description: 'A video from YouTube',
content_loc: `https://www.youtube.com/watch?v=${videoId}`,
thumbnail_loc: `https://img.youtube.com/vi/${videoId}/0.jpg`,
})
}
// the sitemap module should be able to pick this up
route._sitemap = sitemap
}
})
})
},
],
})
```
================================================
FILE: docs/content/1.guides/6.best-practices.md
================================================
---
title: Sitemap.xml Best Practices
description: The best practices for generating a sitemap.xml file.
navigation:
title: Best Practices
relatedPages:
- path: /docs/sitemap/advanced/loc-data
title: Lastmod, Priority, and Changefreq
- path: /docs/sitemap/guides/submitting-sitemap
title: Submitting Your Sitemap
- path: /learn/controlling-crawlers
title: Controlling Web Crawlers
---
## Set appropriate lastmod
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.
This should not change based on code changes, only for updating the content.
For example, if you have a blog post, the `lastmod` should be updated when the content of the blog post changes.
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
**Accuracy is Critical**
Google has stated that they may **stop trusting** your `lastmod` dates if they are consistently updated without significant content changes. Ensure your `lastmod` logic is precise.
::
It's recommended not to use `autoLastmod: true` as this will use the last time the page was built, which does
not always reflect content updates.
Learn more in [Google's sitemap lastmod documentation](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping).
## You probably don't need `changefreq` or `priority`
These two fields are not used by search engines, and are only used by crawlers to determine how often to crawl your site.
If you're trying to get your site crawled more often, you should use the `lastmod` field instead.
Learn more in [Google's sitemap best practices](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping).
## Use Zero Runtime when content only changes on deploy
If your pages only change when you commit and deploy (not at runtime), you don't need runtime sitemap generation. Enable `zeroRuntime` to generate sitemaps at build time and remove ~50KB of sitemap code from your server bundle.
```ts
export default defineNuxtConfig({
sitemap: {
zeroRuntime: true
}
})
```
This is ideal for sites using `nuxt build` where content is static between deployments. If you're using a CMS that updates content without redeploying, you'll need runtime generation.
Learn more in the [Zero Runtime](/docs/sitemap/guides/zero-runtime) guide.
::callout{icon="i-heroicons-check-circle" to="/tools/xml-sitemap-validator"}
**Check your sitemap** - Validate your sitemap meets Google requirements with our [XML Sitemap Validator](/tools/xml-sitemap-validator).
::
::checklist{id="sitemap-best-practices" title="Quick Checklist"}
- Set meaningful lastmod dates based on content changes
- Skip changefreq and priority (ignored by search engines)
- Enable zeroRuntime for static sites
- Submit sitemap to Google Search Console
::
================================================
FILE: docs/content/1.guides/7.submitting-sitemap.md
================================================
---
title: 'Submitting Your Sitemap'
description: 'How to submit your sitemap to Google Search Console to start getting indexed.'
relatedPages:
- path: /docs/sitemap/getting-started/troubleshooting
title: Troubleshooting
- path: /docs/sitemap/guides/best-practices
title: Best Practices
- path: /learn/controlling-crawlers
title: Controlling Web Crawlers
---
## Introduction
When going live with a new site and you're looking to get indexed by Google, the best starting point is
to submit your sitemap to Google Search Console.
> Google Search Console is a free service offered by Google that helps you monitor, maintain, and troubleshoot
your site's presence in Google Search results.
::callout{icon="i-heroicons-shield-check" to="/tools/xml-sitemap-validator"}
**Validate before submitting** - Use our [XML Sitemap Validator](/tools/xml-sitemap-validator) to check for errors before submitting to Google Search Console.
::
## Submitting Sitemap
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.
You should index either `/sitemap.xml` or if you're using multiple sitemaps, add `/sitemap_index.xml`.
### Deprecation of Sitemap Pinging
In January 2024, Google [deprecated the "ping" endpoint](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping) (e.g., `http://www.google.com/ping?sitemap=...`).
You should no longer use this method to notify Google of updates. Instead, rely on:
1. **robots.txt reference**: Ensure your `robots.txt` contains the `Sitemap: ...` line (Nuxt Sitemap does this automatically).
2. **Google Search Console**: Submit the sitemap once, and Google will crawl it periodically.
3. **lastmod**: Keep your `lastmod` dates accurate so Google knows when to recrawl specific URLs.
## Requesting Indexing
It's important to know that submitting your sitemap does not guarantee that all your pages will be indexed and that it may take
some time for Google to crawl and index your pages.
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.
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).
## Sitemap Error
When submitting a sitemap for the first time you may get see "Error". This is because Google previously
crawled your site for a sitemap and found nothing.
When encountering this it's best to wait a few days and see if the error resolves itself. If not, you can
try resubmitting the sitemap or making a [GitHub Issue](https://github.com/nuxt-modules/sitemap).
================================================
FILE: docs/content/1.guides/8.zero-runtime.md
================================================
---
title: Zero Runtime
description: Generate sitemaps at build time without runtime overhead.
---
If your sitemap URLs only change when you deploy, you don't need to ship sitemap generation code to production. The `zeroRuntime` option generates sitemaps at build time and tree-shakes the generation code from your server bundle.
## Usage
To enable zero runtime, add the following to your config:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
zeroRuntime: true
}
})
```
When enabled, the module will automatically add `/sitemap.xml` to your prerender routes. The sitemap will be generated during build and served as a static file at runtime.
## How it Works
With `zeroRuntime: true`:
1. Sitemap routes are automatically added to `nitro.prerender.routes`
2. Server handlers use dynamic imports gated by `import.meta.prerender`
3. At build time, the sitemap generation code is tree-shaken from the runtime bundle
4. Static XML files are served directly without any sitemap code execution
## Development Mode
Zero runtime mode still works in development (`nuxt dev`). The sitemap generation code runs normally during development so you can test your configuration.
## Benchmarks
Enabling `zeroRuntime` reduces the server bundle by approximately:
- **~50KB** uncompressed
- **~5KB** gzip
This is the sitemap generation code (XML building, URL normalization, source fetching) being tree-shaken from the bundle.
## Limitations
- Runtime sitemap generation is not available - sitemaps are only generated during build
- Dynamic data sources that require runtime fetching won't work
- Debug endpoints are disabled in zero runtime mode
## When to Use
Zero runtime is ideal when:
- Your pages only change when you commit and deploy
- You're using `nuxt generate` for a fully static site
- You want to minimize your server bundle size for edge/serverless
## When Not to Use
Avoid zero runtime when:
- Your CMS updates content without redeploying
- You have user-generated content that changes frequently
- Your sitemap URLs depend on runtime data
================================================
FILE: docs/content/2.advanced/0.loc-data.md
================================================
---
title: Lastmod, Priority, and Changefreq
description: Configure lastmod, priority, and changefreq values for your sitemap entries.
relatedPages:
- path: /docs/sitemap/guides/best-practices
title: Best Practices
- path: /docs/sitemap/guides/dynamic-urls
title: Dynamic URL Endpoints
- path: /docs/sitemap/guides/prerendering
title: Nuxt Prerendering
---
## Introduction
Changing the ``{lang="xml"} entry data can be useful for a variety of reasons, such as changing the `changefreq`, `priority`, or `lastmod` values.
If you're using [Dynamic URLs](/docs/sitemap/guides/dynamic-urls), you can modify the data in the `sitemap` object, otherwise, you will
need to override the [app sources](/docs/sitemap/getting-started/data-sources) directly.
While modifying these in most cases may be unnecessary, see [Best Practices](/docs/sitemap/guides/best-practices), it can be useful when used right.
## Setting Defaults
While this is not recommended, in special circumstances you may wish to set defaults for your sitemap entries:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
defaults: {
lastmod: new Date().toISOString(),
priority: 0.5,
changefreq: 'weekly'
}
}
})
```
## Data Source Merging
You can provide the page you want to set the `lastmod`, `priority`, or `changefreq` for in your app sources, which includes
the `urls` config.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
urls: [
{
loc: '/about',
lastmod: '2023-01-01',
priority: 0.3,
changefreq: 'daily'
}
]
}
})
```
## Modify Loc Data With Route Rules
To change the behaviour of your sitemap URLs, you can use [Route rules](https://nuxt.com/docs/api/configuration/nuxt-config/#routerules).
```ts [nuxt.config.ts]
export default defineNuxtConfig({
routeRules: {
// Don't add any /secret/** URLs to the sitemap.xml
'/secret/**': { robots: false },
// modify the sitemap.xml entry for specific URLs
'/about': { sitemap: { changefreq: 'daily', priority: 0.3 } }
}
})
```
Alternatively, you can use the experimental macro [`defineRouteRules()`{lang="ts"}](https://nuxt.com/docs/api/utils/define-route-rules), which must
be enabled.
```vue [pages/index.vue]
```
## Modify Loc Data With Page Meta
You can configure sitemap entry data directly in your page components using [`definePageMeta()`{lang="ts"}](https://nuxt.com/docs/api/utils/define-page-meta).
```vue [pages/about.vue]
```
To exclude a page from the sitemap entirely, set `sitemap` to `false`:
```vue [pages/secret.vue]
```
The `sitemap` key is extracted at build time via Nuxt's `scanPageMeta`, so these values are available without runtime overhead.
## Dynamic lastmod from APIs
When fetching dynamic URLs, include lastmod from your data source:
```ts [server/api/__sitemap__/urls.ts]
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
const posts = await $fetch('https://api.example.com/posts')
return posts.map(post => ({
loc: `/blog/${post.slug}`,
lastmod: post.updated_at,
}))
})
```
With Nuxt Content, set `lastmod` in frontmatter:
```md
---
sitemap:
lastmod: 2024-01-15
changefreq: weekly
---
```
The `lastmod` field accepts `Date` objects, ISO 8601 strings (`'2024-01-15T10:30:00Z'`), or simple date strings (`'2024-01-15'`). Only include it if you have accurate update timestamps — avoid using current date/time for all URLs.
## Lastmod: Prerendering Hints
When prerendering your site, you can make use of setting the `article:modified_time` meta tag in your page's head. This
meta tag will be used as the `lastmod` value in your sitemap.
```vue [pages/index.vue]
```
================================================
FILE: docs/content/2.advanced/1.images-videos.md
================================================
---
title: Images, Videos, News
description: Learn how to add images, videos and news in your sitemap.
relatedPages:
- path: /docs/sitemap/guides/prerendering
title: Nuxt Prerendering
- path: /docs/sitemap/guides/best-practices
title: Best Practices
---
## Introduction
The `image`, `video` and `news` namespaces is added to your sitemap by default, allowing you to configure
images, videos and news for your sitemap entries.
When prerendering your app, it's possible for the generated sitemap to automatically infer images and videos from your pages.
## Sitemap Images
To add images to your sitemap, you can use the `images` property on the sitemap entry.
You can learn more about images in sitemaps on the [Google documentation](https://developers.google.com/search/docs/advanced/sitemaps/image-sitemaps).
```ts
export interface ImageEntry {
loc: string | URL
caption?: string
geo_location?: string
title?: string
license?: string | URL
}
```
You can implement this as follows:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
urls: [
{
loc: '/blog/my-post',
images: [
{
loc: 'https://example.com/image.jpg',
caption: 'My image caption',
geo_location: 'My image geo location',
title: 'My image title',
license: 'My image license',
}
]
}
]
}
})
```
### Automatic Image Discovery
The module can discover images in your page and add them to your sitemap automatically.
For this to work:
- The page _must_ be prerendered. These images will not be shown in development or if the page is not prerendered.
- You must wrap your page content with a ``{lang="html"} tag, avoid wrapping shared layouts that include duplicate images.
## Videos
To add videos to your sitemap, you can use the `videos` property on the sitemap entry.
::callout{icon="i-heroicons-exclamation-triangle" color="amber"}
**Google Video Indexing Change**
As of late 2023, Google **only indexes videos if they are the main content of the page**. If a video is supplementary to the text (like a blog post with a video at the bottom), the video itself may not be indexed in video search results, though the page will still be indexed normally.
::
The TypeScript interface for videos is as follows:
```ts
export interface VideoEntry {
title: string
thumbnail_loc: string | URL
description: string
content_loc?: string | URL
player_loc?: string | URL
duration?: number
expiration_date?: Date | string
rating?: number
view_count?: number
publication_date?: Date | string
family_friendly?: 'yes' | 'no' | boolean
restriction?: Restriction
platform?: Platform
price?: ({
price?: number | string
currency?: string
type?: 'rent' | 'purchase' | 'package' | 'subscription'
})[]
requires_subscription?: 'yes' | 'no' | boolean
uploader?: {
uploader: string
info?: string | URL
}
live?: 'yes' | 'no' | boolean
tag?: string | string[]
category?: string
gallery_loc?: string | URL
}
```
You can learn more about videos in sitemaps on the [Google documentation](https://developers.google.com/search/docs/advanced/sitemaps/video-sitemaps).
You can implement this as follows:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
urls: [
{
loc: '/blog/my-post',
videos: [
{
title: 'My video title',
thumbnail_loc: 'https://example.com/video.jpg',
description: 'My video description',
content_loc: 'https://example.com/video.mp4',
player_loc: 'https://example.com/video.mp4',
duration: 600,
expiration_date: '2021-01-01',
rating: 4.2,
view_count: 1000,
publication_date: '2021-01-01',
family_friendly: true,
restriction: {
relationship: 'allow',
restriction: 'US CA',
},
platform: {
relationship: 'allow',
platform: 'web',
},
price: [
{
price: 1.99,
currency: 'USD',
type: 'rent',
}
],
requires_subscription: true,
uploader: {
uploader: 'My video uploader',
info: 'https://example.com/uploader',
},
live: true,
tag: ['tag1', 'tag2'],
}
]
}
]
}
})
```
### Automatic Video Discovery
Like automatic image discovery, you can opt-in to automatic video discovery including video markup in your ``{lang="html"} tag.
You are also required to provide a title and description for your video, this can be done using the `data-title` and `data-description` attributes.
```html [Simple]
Sorry, your browser doesn't support embedded videos. However, you can
download it
and watch it with your favorite video player!
```
```html [Full]
```
Each format would be added to your sitemap in the following format:
```xml
https://archive.org/download/DuckAndCover_185/__ia_thumb.jpg
Duck and Cover
This film, a combination of animated cartoon and live action, shows young children what to do in case of an atomic attack.
https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda_512kb.mp4
```
## News
To add news to your sitemap, you can use the `news` property on the sitemap entry. Only [Google's News sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap) extension is supported.
The TypeScript interface for news is as follows:
```ts
export interface GoogleNewsEntry {
title: string
publication_date: Date | string
publication: {
name: string
language: string
}
}
```
You can implement this as follows:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
urls: [
{
loc: '/news/nuxt-sitemap-turns-6',
news: {
title: 'Nuxt Sitemap Turns 6',
publication_date: '2021-01-01',
publication: {
name: 'Nuxt Sitemap',
language: 'en',
},
}
}
]
}
})
```
## Image & Video Opt-out
To opt-out of this behaviour, you can set the `discoverImages` and `discoverVideos` config to `false` respectively.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
discoverImages: false,
discoverVideos: false,
}
})
```
================================================
FILE: docs/content/2.advanced/2.performance.md
================================================
---
title: Sitemap Performance
description: Use the default cache engine to keep your sitemaps fast.
relatedPages:
- path: /docs/sitemap/guides/multi-sitemaps
title: Multi Sitemaps
- path: /docs/sitemap/advanced/chunking-sources
title: Sitemap Chunking
- path: /docs/site-config/getting-started/installation
title: Nuxt Site Config
---
## Introduction
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.
Nuxt SEO provides a default cache engine to keep your sitemaps fast and recommendations on how to improve performance.
## Performance Recommendations
When dealing with many URLs that are being generated from an external API, the best option is use the `sitemaps`
option to create [Named Sitemap Chunks](/docs/sitemap/guides/multi-sitemaps).
Each sitemap should contain its own `sources`, this allows other sitemaps to be generated without waiting for this request.
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: [
'https://api.something.com/urls'
]
},
},
},
})
```
If you need to split this up further, you should consider chunking by the type and some pagination format. For example,
you can paginate by when posts were created.
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts2020: {
sources: [
'https://api.something.com/urls?filter[yearCreated]=2020'
]
},
posts2021: {
sources: [
'https://api.something.com/urls?filter[yearCreated]=2021'
]
},
},
},
})
```
Additionally, you may want to consider the following experimental options that may help with performance:
- `experimentalCompression` - Gzip's and streams the sitemap
- `experimentalWarmUp` - Creates the sitemaps when Nitro starts
### Very large sites (100k+ URLs)
For sites at this scale, two practices matter most:
1. **Cache the source endpoint.** Use `defineCachedEventHandler` on any `/api/*` route fed into `sources`. Without this, every cache miss (and every fresh chunk) re-hits your backend.
2. **Set generous chunk sizes.** Search engines accept up to 50,000 URLs per file. The default `defaultSitemapsChunkSize` of 1000 generates 50× more chunks than necessary; bumping to `5000`–`50000` directly reduces total work and cache entries.
Within a single sitemap, all chunks share one resolved-URLs computation (sources are fetched, normalised, and sorted once per `cacheMaxAgeSeconds` window — not once per chunk). Splitting one large sitemap into per-shard sitemaps (e.g. one per locale or content type) is still useful when shards have different cache lifetimes or different sources.
## Zero Runtime Mode
If your sitemap URLs only change when you deploy (not at runtime), you can enable `zeroRuntime` to generate sitemaps at build time and eliminate sitemap generation code from your server bundle.
```ts
export default defineNuxtConfig({
sitemap: {
zeroRuntime: true
}
})
```
This reduces server bundle size by ~50KB. The sitemap is generated once at build time and served as a static file.
See the [Zero Runtime](/docs/sitemap/guides/zero-runtime) guide for details.
## Sitemap Caching
Caching your sitemap can help reduce the load on your server and improve performance.
By default, SWR caching is enabled on production environments and sitemaps will be cached for 10 minutes.
This is configured by overriding your route rules and leveraging the native Nuxt caching.
### Cache Time
You can change the cache time by setting the `cacheMaxAgeSeconds` option. This affects the `Cache-Control` header sent to browsers and search engines.
```ts
export default defineNuxtConfig({
sitemap: {
cacheMaxAgeSeconds: 3600 // 1 hour
}
})
```
If you want to disable caching, set `cacheMaxAgeSeconds` to `false` or `0`.
`cacheMaxAgeSeconds` controls both the HTTP `Cache-Control` header and the server-side SWR cache TTL. For high-volume sites, raising it to several hours significantly reduces origin load.
### Cache Driver
The cache engine is set to the Nitro default of the `cache/` path.
If you want to customise the cache engine, you can set the `runtimeCacheStorage` option.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
// cloudflare kv binding example
runtimeCacheStorage: {
driver: 'cloudflare-kv-binding',
binding: 'OG_IMAGE_CACHE'
}
}
})
```
================================================
FILE: docs/content/2.advanced/3.chunking-sources.md
================================================
---
title: Sitemap Chunking
description: Split large sitemap sources into multiple files for performance and search engine limits.
relatedPages:
- path: /docs/sitemap/guides/multi-sitemaps
title: Multi Sitemaps
- path: /docs/sitemap/advanced/performance
title: Sitemap Performance
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
---
## Introduction
When dealing with large datasets, sitemap sources can be chunked into multiple files to:
- Stay within search engine limits (50MB file size, 50,000 URLs)
- Improve generation performance
- Better manage memory usage
## Simple Configuration
Enable chunking on any named sitemap with sources:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: true, // Uses default size of 1000
}
}
}
})
```
This generates:
```
/sitemap_index.xml # Master index
/posts-0.xml # First chunk (1-1000)
/posts-1.xml # Second chunk (1001-2000)
...
```
## Chunk Size Options
Configure chunk sizes using different approaches:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
// Global default
defaultSitemapsChunkSize: 5000,
sitemaps: {
// Using boolean (applies default)
posts: {
sources: ['/api/posts'],
chunks: true,
},
// Using number as size
products: {
sources: ['/api/products'],
chunks: 10000,
},
// Using explicit chunkSize (highest priority)
articles: {
sources: ['/api/articles'],
chunks: true,
chunkSize: 2000,
}
}
}
})
```
## Skipping the index source fetch (`chunkCount`)
By default the sitemap index calls your source to count URLs, so it knows how many `` entries to emit. At very large scale this cold-start fetch is the bottleneck. If you already know the number of chunks, declare it upfront and the index will skip the fetch entirely:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: 5000,
chunkCount: 100, // 100 chunk entries, no source fetch in the index
},
},
},
})
```
Per-chunk renders still fetch on demand and slice. If your data set grows past the declared count, tail entries are unreachable; if it shrinks, trailing chunks render empty. Update the value when your data set changes (or remove it to fall back to fetching).
## Practical Examples
### E-commerce Site
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
defaultSitemapsChunkSize: 10000,
sitemaps: {
products: {
sources: ['/api/products/all'],
chunks: 2000,
},
categories: {
sources: ['/api/categories'],
chunks: true, // Uses default 10k
}
}
}
})
```
### Large Content Site
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
sitemaps: {
'blog-posts': {
sources: ['/api/blog/posts'],
chunks: 5000,
},
'authors': {
sources: ['/api/authors'],
chunks: false, // Explicitly disable
}
}
}
})
```
## Source Implementation
Basic endpoint for sitemap sources:
```ts [server/api/products/all.ts]
export default defineEventHandler(async () => {
const products = await db.products.findAll({
select: ['id', 'slug', 'updatedAt']
})
return products.map(product => ({
loc: `/products/${product.slug}`,
lastmod: product.updatedAt
}))
})
```
For large datasets, use caching and streaming:
```ts [server/api/products/all.ts]
export default defineCachedEventHandler(async () => {
const products = []
const cursor = db.products.cursor({
select: ['slug', 'updatedAt']
})
for await (const product of cursor) {
products.push({
loc: `/products/${product.slug}`,
lastmod: product.updatedAt
})
}
return products
}, {
maxAge: 60 * 60, // 1 hour cache
name: 'sitemap-products'
})
```
## Debugging
Check chunk configuration and performance:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
sitemap: {
debug: true,
sitemaps: {
products: {
sources: ['/api/products'],
chunks: 5000
}
}
}
})
```
Visit `/__sitemap__/debug.json` to see chunk details and generation metrics.
================================================
FILE: docs/content/2.advanced/4.customising-ui.md
================================================
---
title: Customising the UI
description: Change the look and feel of your sitemap.
relatedPages:
- path: /docs/sitemap/guides/i18n
title: I18n Integration
- path: /docs/sitemap/api/config
title: Config Reference
---
## Disabling the XSL
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.
To view the real sitemap.xml, you can view the source of the page.
If you prefer, you can disable the XSL by setting `xsl` to `false`.
```ts
export default defineNuxtConfig({
sitemap: {
xsl: false
}
})
```
## Changing the columns
You can change the columns that are displayed in the sitemap by modifying the `xslColumns` option.
These have no effect on SEO and is purely for developer experience.
Note: You must always have a `URL` column at the start.
```ts
export default defineNuxtConfig({
sitemap: {
xslColumns: [
// URL column must always be set, no value needed
{ label: 'URL', width: '75%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
],
},
})
```
The `select` you provide is an XSL expression that will be evaluated against the sitemap entry.
It's recommended to prefix the value with `sitemap:` if in doubt.
### Example: Adding priority and changefreq
```ts
export default defineNuxtConfig({
sitemap: {
xslColumns: [
{ label: 'URL', width: '50%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
{ label: 'Priority', select: 'sitemap:priority', width: '12.5%' },
{ label: 'Change Frequency', select: 'sitemap:changefreq', width: '12.5%' },
],
},
})
```
### Example: Adding `hreflang`
```ts
export default defineNuxtConfig({
sitemap: {
xslColumns: [
{ label: 'URL', width: '50%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
{ label: 'Hreflangs', select: 'count(xhtml:link)', width: '25%' },
],
},
})
```
## Disabling tips
In development tips are displayed on the sitemap page to help you get started.
You can disable these tips by setting the `xslTips` option to `false`.
```ts
export default defineNuxtConfig({
sitemap: {
xslTips: false,
},
})
```
================================================
FILE: docs/content/4.api/0.config.md
================================================
---
title: Config
description: Configure the sitemap module.
---
## `enabled`
- Type: `boolean`{lang="ts"}
- Default: `true`{lang="ts"}
Whether to generate the sitemap.
## `sortEntries`
- Type: `boolean`{lang="ts"}
- Default: `true`{lang="ts"}
Whether the sitemap entries should be sorted or be shown in the order they were added.
When enabled the entries will be sorted by the `loc`, they will be sorted by the path segment
count and then alphabetically using `String.localeCompare` to ensure numbers are sorted correctly.
## `sources`
- Type: `SitemapSource[]`{lang="ts"}
- Default: `[]`{lang="ts"}
The sources to use for the sitemap. See [Data Sources](/docs/sitemap/getting-started/data-sources) and [Dynamic URL Endpoint](/docs/sitemap/guides/dynamic-urls) for details.
## `excludeAppSources`
- Type: `true | AppSourceContext[]`{lang="ts"}
- Default: `[]`{lang="ts"}
Whether to exclude [app sources](/docs/sitemap/getting-started/data-sources) from the sitemap.
## `appendSitemaps`
- Type: `(string | { sitemap: string, lastmod?: string })[]`{lang="ts"}
- Default: `undefined`{lang="ts"}
Sitemaps to append to the sitemap index.
This will only do anything when using multiple sitemaps.
## `autoLastmod`
- Type: `boolean`{lang="ts"}
- Default: `false`{lang="ts"}
Sets the current date as the default `lastmod` for all entries that don't already have one.
## `sitemaps`
- Type: `boolean | Record & { index?: (string | SitemapIndexEntry)[] }`
- Default: `false`
Whether to generate multiple sitemaps.
Each sitemap can have the following options:
### SitemapConfig
#### `sources`
- Type: `SitemapSource[]`
- Default: `[]`
Data sources for this specific sitemap.
#### `chunks`
- Type: `boolean | number`
- Default: `undefined`
Enable chunking for sitemap sources. This splits large collections of URLs from sources into multiple smaller sitemap files to stay within search engine limits.
- Set to `true` to enable chunking with the default chunk size (from `defaultSitemapsChunkSize` or 1000)
- Set to a positive number to use that as the chunk size (e.g., `5000` for 5000 URLs per chunk)
- Set to `false` or leave undefined to disable chunking
Note: Chunking only applies to URLs from `sources`. Direct URLs in the `urls` property are not chunked.
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
products: {
sources: ['/api/products'],
chunks: 5000 // Split into files with 5000 URLs each
}
}
}
})
```
#### `chunkSize`
- Type: `number`
- Default: `undefined`
Explicitly set the chunk size for this sitemap. Takes precedence over the `chunks` property when both are specified.
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: true, // Enable chunking
chunkSize: 2500 // Use 2500 URLs per chunk
}
}
}
})
```
See the [Chunking Sources](/docs/sitemap/advanced/chunking-sources) guide for more details.
#### `urls`
- Type: `SitemapUrlInput[] | (() => SitemapUrlInput[] | Promise)`
- Default: `[]`
URLs to include in this sitemap. Can be strings or sitemap URL objects.
#### `include`
- Type: `(string | RegExp | { regex: string })[]`
- Default: `undefined`
Filter URLs to include in this sitemap.
#### `exclude`
- Type: `(string | RegExp | { regex: string })[]`
- Default: `undefined`
Filter URLs to exclude from this sitemap.
#### `defaults`
- Type: `SitemapItemDefaults`
- Default: `{}`
Default values for all URLs in this sitemap.
#### `includeAppSources`
- Type: `boolean`
- Default: `false`
Whether to include automatic app sources in this sitemap.
See [Multi Sitemaps](/docs/sitemap/guides/multi-sitemaps) for details.
## `defaultSitemapsChunkSize`
- Type: `number | false`
- Default: `1000`
The default chunk size when chunking is enabled for multi-sitemaps. This value is used when:
- A sitemap has `chunks: true` (without specifying a number)
- No `chunkSize` is explicitly set for the sitemap
Set to `false` to disable chunking by default for all sitemaps.
```ts
export default defineNuxtConfig({
sitemap: {
defaultSitemapsChunkSize: 5000,
sitemaps: {
// These will use 5000 as chunk size
posts: {
sources: ['/api/posts'],
chunks: true
},
// This overrides the default
products: {
sources: ['/api/products'],
chunks: 10000
}
}
}
})
```
## `defaults`
- Type: `object`
- Default: `{}`
Default values for the sitemap.xml entries. See [sitemaps.org](https://www.sitemaps.org/protocol.html) for all available options.
## `urls`
- Type: `MaybeFunction>`
- Default: `[]`
Provide custom URLs to be included in the sitemap.xml.
## `include`
- Type: `(string | RegExp | { regex: string })[]`
- Default: `[]`
Filter routes that match the given rules. If empty, all routes are included. See the [Filtering URLs](/docs/sitemap/guides/filtering-urls) guide for details.
```ts
export default defineNuxtConfig({
sitemap: {
include: [
'/my-hidden-url'
]
}
})
```
## `exclude`
- Type: `(string | RegExp | { regex: string })[]`
- Default: `['/_**']`
Filter routes that match the given rules. The pattern `/_nuxt/**` is always added dynamically from `app.buildAssetsDir`, and `/__nuxt_content/**` is added when Nuxt Content v3 is detected. See the [Filtering URLs](/docs/sitemap/guides/filtering-urls) guide for details.
```ts
export default defineNuxtConfig({
sitemap: {
exclude: [
'/my-secret-section/**'
]
}
})
```
## `xsl`
- Type: `string | false`
- Default: `/__sitemap__/style.xsl`
The path to the XSL stylesheet for the sitemap.xml. Set to `false` to disable.
## `discoverImages`
- Type: `boolean`
- Default: `true`
Whether to discover images from routes when prerendering.
## `discoverVideos`
- Type: `boolean`
- Default: `true`
Whether to discover videos from routes when prerendering.
## `autoI18n`
- Type: `undefined | boolean | { locales: NormalisedLocales; defaultLocale: string; strategy: 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix'; differentDomains?: boolean; pages?: Record> }`
- Default: `undefined`
Automatically add alternative language prefixes for each entry with the given prefixes. Set to `false` to disable.
When using the @nuxtjs/i18n module, this will automatically be set to the configured `locales` when left `undefined`.
## `sitemapName`
- Type: `string`
- Default: `sitemap.xml`
Modify the name of the root sitemap.
Note: This only works when you're not using the multiple `sitemaps` option.
## `strictNuxtContentPaths`
- Type: `boolean`
- Default: `false`
Whether the paths within nuxt/content match their real paths. This is useful when you're using the `nuxt/content` module
without documentDriven mode.
## `cacheMaxAgeSeconds`
- Type: `number | false`
- Default: `60 * 10`
The time in seconds to cache the sitemaps. Set to `false` to disable caching.
## `sitemapsPathPrefix`
- Type: `string | false`
- Default: `/__sitemap__/`
The path prefix for the sitemaps when using multiple sitemaps.
## `runtimeCacheStorage`
- Type: `boolean | (Record & { driver: string })`
- Default: `true`
The storage engine to use for the cache. See [Performance](/docs/sitemap/advanced/performance) for details.
## `xslColumns`
- Type: ``({ label: string; width: `${string}%`; select?: string })[]``
- Default:
```json
[
{ "label": "URL", "width": "50%" },
{ "label": "Images", "width": "25%", "select": "count(image:image)" },
{ "label": "Last Updated", "width": "25%", "select": "concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)),concat(' ', substring(sitemap:lastmod,20,6)))" }
]
```
The columns to display in the XSL stylesheet.
## `xslTips`
- Type: `boolean`
- Default: `true`
Whether to include tips on how to use the sitemap in the XSL stylesheet.
## `experimentalWarmUp`
- Type: `boolean`
- Default: `false`
Should the sitemaps be warmed up when Nitro starts. This can be useful for large sitemaps.
## `experimentalCompression`
- Type: `boolean`
- Default: `false`
Should the sitemaps be compressed and streamed when the request accepts it.
## `credits`
- Type: `boolean`
- Default: `true`
Whether to include a comment on the sitemaps on how it was generated.
## `minify`
- Type: `boolean`{lang="ts"}
- Default: `false`{lang="ts"}
Whether to minify the sitemap.xml.
## `debug`
- Type: `boolean`
- Default: `false`
Enable to see debug logs and API endpoint.
The route at `/__sitemap__/debug.json` will be available in non-production environments.
See the [Troubleshooting](/docs/sitemap/getting-started/troubleshooting) guide for details.
## `zeroRuntime`
- Type: `boolean`
- Default: `false`
When enabled, sitemap generation only runs during prerendering. The sitemap building code is tree-shaken from the runtime bundle, reducing server bundle size by ~50KB.
Requires sitemaps to be prerendered. When enabled, `/sitemap.xml` is automatically added to `nitro.prerender.routes`.
See the [Zero Runtime](/docs/sitemap/guides/zero-runtime) guide for details.
================================================
FILE: docs/content/4.api/1.nuxt-hooks.md
================================================
---
title: Nuxt Hooks
description: Build-time Nuxt hooks provided by @nuxtjs/sitemap.
---
## `'sitemap:prerender:done'`{lang="ts"}
**Type:** `(ctx: { options: ModuleRuntimeConfig, sitemaps: { name: string, readonly content: string }[] }) => void | Promise`{lang="ts"}
Called after sitemap prerendering completes. Useful for modules that need to emit extra files based on the generated sitemaps.
**Context:**
- `options` - The resolved module runtime configuration
- `sitemaps` - Array of rendered sitemaps with their route name and XML content (content is lazily loaded from disk)
```ts [nuxt.config.ts]
export default defineNuxtConfig({
hooks: {
'sitemap:prerender:done': async ({ sitemaps }) => {
// Log sitemap info
for (const sitemap of sitemaps) {
console.log(`Sitemap ${sitemap.name}: ${sitemap.content.length} bytes`)
}
}
}
})
```
::note
This hook only runs at build time during `nuxt generate` or `nuxt build` with prerendering enabled.
::
================================================
FILE: docs/content/5.nitro-api/nitro-hooks.md
================================================
---
title: Nitro Hooks
description: Learn how to use Nitro Hooks to customize your sitemap entries.
relatedPages:
- path: /docs/sitemap/getting-started/data-sources
title: Data Sources
- path: /docs/sitemap/guides/dynamic-urls
title: Dynamic URL Endpoints
- path: /docs/sitemap/guides/multi-sitemaps
title: Multi Sitemaps
---
Nitro hooks can be added to modify the output of your sitemaps at runtime.
## `'sitemap:input'`{lang="ts"}
**Type:** `async (ctx: { event: H3Event; urls: SitemapUrlInput[]; sitemapName: string }) => void | Promise`{lang="ts"}
Triggers once the raw list of URLs is collected from sources.
This hook is best used for inserting new URLs into the sitemap.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:input', async (ctx) => {
// SitemapUrlInput is either a string
ctx.urls.push('/foo')
// or an object with loc, changefreq, and priority
ctx.urls.push({
loc: '/bar',
changefreq: 'daily',
priority: 0.8,
})
})
})
```
## `'sitemap:resolved'`{lang="ts"}
**Type:** `async (ctx: { event: H3Event; urls: ResolvedSitemapUrl[]; sitemapName: string }) => void | Promise`{lang="ts"}
Triggered once the final structure of the XML is generated, provides the URLs as objects.
For new URLs it's recommended to use `sitemap:input` instead. Use this hook for modifying entries or removing them.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:resolved', async (ctx) => {
// single sitemap example - just add the url directly
ctx.urls.push({
loc: '/my-secret-url',
changefreq: 'daily',
priority: 0.8,
})
// multi sitemap example - filter for a sitemap name
if (ctx.sitemapName === 'posts') {
ctx.urls.push({
loc: '/posts/my-post',
changefreq: 'daily',
priority: 0.8,
})
}
})
})
```
## `'sitemap:index-resolved'`{lang="ts"}
**Type:** `async (ctx: { event: H3Event; sitemaps: { sitemap: string, lastmod?: string }[] }) => void | Promise`{lang="ts"}
Triggered once the final structure of the sitemap index is generated, provides the sitemaps as objects.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:index-resolved', async (ctx) => {
// add a new sitemap to the index
ctx.sitemaps.push({
sitemap: 'https://mysite.com/my-sitemap.xml',
lastmod: new Date().toISOString(),
})
})
})
```
## `'sitemap:output'`{lang="ts"}
**Type:** `async (ctx: { event: H3Event; sitemap: string; sitemapName: string }) => void | Promise`{lang="ts"}
Triggered before the sitemap is sent to the client.
It provides the sitemap as a XML string.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:output', async (ctx) => {
// append a comment credit to the footer of the xml
ctx.sitemap = `${ctx.sitemap}\n`
})
})
```
## `'sitemap:sources'`{lang="ts"}
**Type:** `async (ctx: { event: H3Event; sitemapName: string; sources: SitemapSourceInput[] }) => void | Promise`{lang="ts"}
Triggered before resolving sitemap sources. This hook allows you to:
- Add new sources dynamically
- Remove sources
- Modify source configurations including fetch options and headers
This hook runs before sources are resolved, providing full control over the source list.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:sources', async (ctx) => {
// Add a source that will be fetched
ctx.sources.push('/api/dynamic-urls')
// Add a source with fetch options
ctx.sources.push(['/api/authenticated-urls', { headers: { 'X-Api-Key': 'secret' } }])
// Add a resolved source with URLs directly (no fetch needed)
ctx.sources.push({
context: { name: 'my-custom-source' },
urls: ['/page-1', '/page-2', { loc: '/page-3', priority: 0.8 }],
})
// Modify existing sources to add headers
ctx.sources = ctx.sources.map((source) => {
if (typeof source === 'object' && 'fetch' in source && source.fetch) {
const [url, options = {}] = Array.isArray(source.fetch) ? source.fetch : [source.fetch, {}]
// Add headers from original request
const authHeader = ctx.event.node.req.headers.authorization
if (authHeader) {
options.headers = options.headers || {}
options.headers.Authorization = authHeader
}
source.fetch = [url, options]
}
return source
})
// Filter out sources
ctx.sources = ctx.sources.filter((source) => {
if (typeof source === 'string')
return !source.includes('skip-this')
return true
})
})
})
```
## Recipes
### Modify Sitemap `xmlns` attribute
For some search engines, you may need to add a custom `xmlns` attribute to the sitemap. You can do this with a simple
search and replace in the `sitemap:output` hook.
```ts [server/plugins/sitemap.ts]
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:output', async (ctx) => {
ctx.sitemap = ctx.sitemap.replace(' {
nitroApp.hooks.hook('sitemap:resolved', (ctx) => {
ctx.urls.map((url) => {
if (url.videos?.length) {
url.videos = url.videos.filter((video) => {
if (video.content_loc) {
const url = new URL(video.content_loc)
return url.host.startsWith('www.youtube.com')
}
return false
})
}
return url
})
})
})
```
================================================
FILE: docs/content/5.releases/3.v8.md
================================================
---
navigation:
title: v8.0.0
title: Nuxt Sitemap v8.0.0
description: Release notes for v8.0.0 of Nuxt Sitemap.
---
## Introduction
The v8 release focuses on a fully rewritten devtools experience and several quality of life improvements for Nuxt Content v3 and i18n users.
## ⚠️ Breaking Changes
### Site Config v4
Nuxt Site Config is a module used internally by Nuxt Sitemap.
The major update to v4.0.0 shouldn't have any direct effect on your site, however, you may want to double-check
the [breaking changes](https://github.com/harlan-zw/nuxt-site-config/releases/tag/v4.0.0).
### `asSitemapCollection()` Deprecated
The `asSitemapCollection()` composable has been replaced by `defineSitemapSchema()`. The old API still works but will log a deprecation warning.
```diff
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { z } from 'zod'
- import { asSitemapCollection } from '@nuxtjs/sitemap/content'
+ import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
export const collections = {
- content: defineCollection(asSitemapCollection({
- type: 'page',
- source: '**/*.md',
- schema: z.object({ title: z.string() })
- }))
+ content: defineCollection({
+ type: 'page',
+ source: '**/*.md',
+ schema: z.object({
+ title: z.string(),
+ sitemap: defineSitemapSchema()
+ })
+ })
}
```
## 🚀 New Features
### `defineSitemapSchema()` Composable
A new composable for Nuxt Content v3 that provides a cleaner API for integrating sitemap configuration into your content collections. Supports `filter`, `onUrl`, and `name` options.
```ts
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { z } from 'zod'
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
export const collections = {
content: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
sitemap: defineSitemapSchema({
filter: entry => !entry.path?.startsWith('/draft'),
onUrl: (url) => {
// customize URL entries
return url
}
})
})
})
}
```
### `definePageMeta` Sitemap Configuration
You can now configure sitemap options directly in your page components using `definePageMeta`.
```vue
```
### i18n Multi-Sitemap with Custom Sitemaps
Custom sitemaps with `includeAppSources: true` are now automatically expanded per locale, generating `{locale}-{name}` formatted sitemaps.
### Debug Production Endpoint
A new `/__sitemap__/debug-production.json` endpoint is available in development mode, allowing you to inspect what the production sitemap output will look like during development.
## 🐛 Bug Fixes
- **Content v3**: Filter `.navigation` paths from sitemap URL generation
- **Content v3**: Guard `afterParse` hook to prevent silent HMR failures
- **i18n**: Include base URL in multi-sitemap redirect
- **i18n**: Fix exclude filters when base URL and i18n prefixes are present
- **i18n**: Respect `autoI18n: false` to generate single sitemap instead of per-locale sitemaps
- **Types**: Use `robots` instead of `index` in route rules type definition
- **Chunked sitemaps**: Fix path resolution with `sitemapsPathPrefix: '/'`
## ⚡ Performance
- Replaced `chalk` with `consola/utils` for a smaller bundle
- Use `URL.canParse()` instead of try/catch `new URL()` for URL validation
- Use `addPrerenderRoutes()` API instead of manual route pushing
================================================
FILE: docs/content/5.releases/4.v7.md
================================================
---
navigation:
title: v7.0.0
title: Nuxt Sitemap v7.0.0
description: Release notes for v7.0.0 of Nuxt Sitemap.
---
## Introduction
The v7 major of Nuxt Sitemap is a simple release to remove deprecations and add support for the [Nuxt SEO v2 stable](/announcement).
## :icon{name="i-noto-warning"} Breaking Features
### Site Config v3
Nuxt Site Config is a module used internally by Nuxt Sitemap.
The major update to v3.0.0 shouldn't have any direct effect on your site, however, you may want to double-check
the [breaking changes](https://github.com/harlan-zw/nuxt-site-config/releases/tag/v3.0.0).
### Removed `inferStaticPagesAsRoutes` config
If you set this value to `false` previously, you will need to change it to the below:
```diff
export default defineNuxtConfig({
sitemap: {
- inferStaticPagesAsRoutes: false,
+ excludeAppSources: ['nuxt:pages', 'nuxt:route-rules', 'nuxt:prerender']
}
})
```
### Removed `dynamicUrlsApiEndpoint` config
The `sources` config supports multiple API endpoints and allows you to provide custom fetch options, use this instead.
```diff
export default defineNuxtConfig({
sitemap: {
- dynamicUrlsApiEndpoint: '/__sitemap/urls',
+ sources: ['/__sitemap/urls']
}
})
```
### Removed `cacheTtl` config
Please use the `cacheMaxAgeSeconds` as its a clearer config.
```diff
export default defineNuxtConfig({
sitemap: {
- cacheTtl: 10000,
+ cacheMaxAgeSeconds: 10000
}
})
```
### Removed `index` route rule / Nuxt Content support
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.
```diff
export default defineNuxtConfig({
routeRules: {
// use the `index` shortcut for simple rules
- '/secret/**': { index: false },
+ '/secret/**': { robots: false },
}
})
```
================================================
FILE: docs/content/5.releases/5.v6.md
================================================
---
navigation:
title: v6.0.0
title: Nuxt Sitemap v6.0.0
description: Release notes for v6.0.0 of Nuxt Sitemap.
---
## Introduction
The v6 represents hopefully the last major that the module will undergo. It brings many underlying
logic improvements which aim to solve stability and performance issues and set up the module to support
chunked multi-sitemaps in the future.
## 🚨 Breaking Change
### Google Search Console
If you're using multi-sitemaps it's important to check Google Search Console after the update and verify you haven't submitted the old multi-sitemap paths. If so, you should update them
### Sitemap Output
Please verify your sitemap output after the update. Many changes have been made to the underlying logic and it's important to verify that your sitemap is still being generated correctly.
## Changelog
### 🚨 Breaking Changes
- Rewrite i18n resolving and url normalizing - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/319 [(fab7e) ](https://github.com/nuxt-modules/sitemap/commit/fab7e9e)
- New multi sitemaps paths - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/320 [(bb7d9) ](https://github.com/nuxt-modules/sitemap/commit/bb7d9c7)
### 🚀 Features
- `sitemapsPathPrefix` config - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/325 [(4b94c) ](https://github.com/nuxt-modules/sitemap/commit/4b94c3d)
- Add minify xml option - by @Henvy-Mango in https://github.com/nuxt-modules/sitemap/issues/336 [(f9197) ](https://github.com/nuxt-modules/sitemap/commit/f919726)
- **i18n**: Support Nuxt I18n v9 - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/351 [(92d96) ](https://github.com/nuxt-modules/sitemap/commit/92d9610)
### 🐞 Bug Fixes
- Better filtering of file URLs - by @harlan-zw [(27a95) ](https://github.com/nuxt-modules/sitemap/commit/27a95be)
- Check for `robots` route rules - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/321 [(ae455) ](https://github.com/nuxt-modules/sitemap/commit/ae455da)
- Map `include`, `exclude` to i18n pages - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/322 [(a7c04) ](https://github.com/nuxt-modules/sitemap/commit/a7c04bc)
- Fallback to prerender sitemap on vercel edge - by @harlan-zw [(33598) ](https://github.com/nuxt-modules/sitemap/commit/33598c8)
- Support `SERVER_PRESET` to detect env - by @harlan-zw [(295c9) ](https://github.com/nuxt-modules/sitemap/commit/295c98f)
- Handle null `loc`'s - by @harlan-zw [(c0666) ](https://github.com/nuxt-modules/sitemap/commit/c066610)
- `useNitroApp` import warning - by @harlan-zw [(f5ab8) ](https://github.com/nuxt-modules/sitemap/commit/f5ab878)
- Preset not being resolved when using `--target` - by @harlan-zw [(2f6bc) ](https://github.com/nuxt-modules/sitemap/commit/2f6bca8)
- Broken regex for `` components - by @harlan-zw [(469e7) ](https://github.com/nuxt-modules/sitemap/commit/469e7bd)
- Ensure `loc` is always a string - by @harlan-zw [(de9ec) ](https://github.com/nuxt-modules/sitemap/commit/de9ecc2)
- Improve entry `loc` normalizing - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/354 [(6ef8d) ](https://github.com/nuxt-modules/sitemap/commit/6ef8dcd)
- **i18n**:
- Support excluded locales - by @Xenossolitarius and **ipesic** in https://github.com/nuxt-modules/sitemap/issues/331 [(f9ba0) ](https://github.com/nuxt-modules/sitemap/commit/f9ba056)
- Reverse only locales logic - by @Xenossolitarius and **ipesic** in https://github.com/nuxt-modules/sitemap/issues/346 [(cc86a) ](https://github.com/nuxt-modules/sitemap/commit/cc86a0c)
- Broken trailing slashes config when using `differentDomains` - by @harlan-zw [(e8799) ](https://github.com/nuxt-modules/sitemap/commit/e879913)
- Broken dedupe of loc and alternatives - by @harlan-zw in https://github.com/nuxt-modules/sitemap/issues/352 [(2b164) ](https://github.com/nuxt-modules/sitemap/commit/2b16423)
- **module**:
- Prevent false positive warning about ignored root keys - by @madebyfabian in https://github.com/nuxt-modules/sitemap/issues/338 [(e4543) ](https://github.com/nuxt-modules/sitemap/commit/e45432b)
- **prerendering**:
- Prefer runtime site url validation - by @harlan-zw [(779d1) ](https://github.com/nuxt-modules/sitemap/commit/779d100)
##### [View changes on GitHub](https://github.com/nuxt-modules/sitemap/compare/v5.3.5...v6.0.0)
================================================
FILE: docs/content/5.releases/6.v5.md
================================================
---
navigation:
title: v5.0.0
title: Nuxt Sitemap v5.0.0
description: Release notes for v5.0.0 of Nuxt Sitemap.
---
## 🚨 Breaking Changes
### Package Renamed to `@nuxtjs/sitemap`
This module is now the official Sitemap module for Nuxt. To properly
reflect this, the package has been renamed to `@nuxtjs/sitemap` from `nuxt-simple-sitemap` and
the GitHub repository has been moved to [nuxt-modules/sitemap](https://github.com/nuxt-modules/sitemap).
1. Update the dependency
```diff
{
"dependencies": {
- "nuxt-simple-sitemap": "*"
+ "@nuxtjs/sitemap": "^5.0.0"
}
}
```
2. Update your `nuxt.config`.
```diff
export default defineNuxtConfig({
modules: [
- 'nuxt-simple-sitemap'
+ '@nuxtjs/sitemap'
]
})
```
## Features :rocket:
## 🐞 Bug Fixes
### Improved Cache Debugging
- Set browser cache time to match `cacheMaxAgeSeconds` - by @harlan-zw [(00d17) ](https://github.com/nuxt-modules/sitemap/commit/00d176e)
- Iso timestamp for debugging cache - by @harlan-zw [(db3f3) ](https://github.com/nuxt-modules/sitemap/commit/db3f337)
- Cache headers for prerendered sitemap - by @harlan-zw [(57bef) ](https://github.com/nuxt-modules/sitemap/commit/57bef21)
- More explicit caching - by @harlan-zw [(328b7) ](https://github.com/nuxt-modules/sitemap/commit/328b737)
### More Consistent DevTools UI
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)
### Others
- Redirect multi sitemap `sitemap.xml` using route rules - by @harlan-zw [(e1bee) ](https://github.com/nuxt-modules/sitemap/commit/e1bee81)
##### [View changes on GitHub](https://github.com/nuxt-modules/sitemap/compare/v4.4.1...v5.0.0)
================================================
FILE: docs/content/5.releases/7.v4.md
================================================
---
navigation:
title: v4.0.0
title: Nuxt Sitemap v4.0.0
description: Release notes for v4.0.0 of Nuxt Sitemap.
---
## Background
Over the last couple of months I've had many issues reported with similar themes:
- Dynamic URLs are hard to work with
- It's difficult to get multiple sitemaps to show the correct URLs
- I18n has many small issues
I hope this release can resolve these. It has required replacing much of the underlying logic, please test your sitemaps after upgrading.
## Features :rocket:
### 🥫 Sitemap Sources
The v4 introduces the official concept of 'sources' to your sitemaps.
Every URL within your sitemap will belong to a source. A source will either be a User source or a Application source.
This concept existed before v4 in different forms, v4 aims to clean them up and make working with them much easier.
For full documentation see [Sitemap Sources](/docs/sitemap/getting-started/data-sources).
### 🤝 Nuxt Dev Tools Integration
Nuxt Sitemap now has a dedicated tab in Nuxt Dev Tools to help you debug.
nuxt-simple-sitemap-devtools.webm
### 💬 More i18n Improvements
- Locale domain support ([#155](https://github.com/nuxt-modules/sitemap/issues/155))
- Support pages opt-outed using `defineI18nRoute(false)` ([#126](https://github.com/nuxt-modules/sitemap/issues/126))
- Only add trusted i18n routes, will use meta tags when prerendering
- Less aggressive filtering
- Opt-in to transform dynamic URLs `__i18nTransform: true`
See the updated [i18n documentation](/docs/sitemap/guides/i18n)
### 🚀 Caching Improvements
Now utilises native route rules. By default will set up SWR rules for 10 minutes.
Learn more on the [Sitemap Caching](/docs/sitemap/advanced/performance) guide.
## Other Improvements
### Nitro Composables for better types
When creating an API endpoint that returns URLs you should use the new [`defineSitemapEventHandler()`{lang="ts"}](/docs/sitemap/nitro-api/nitro-hooks) function for full TypeScript support.
```ts
// api/sitemap.ts
export default defineSitemapEventHandler(() => {
return ['/foo']
})
```
### Prerendering Improvements
Previously prerendering was done in a Node context, this will now run in a Nitro context which will provide better consistency between prerender and runtime environments.
### Video Support
Video entries are now supported properly. ([#159](https://github.com/nuxt-modules/sitemap/issues/159))
## ⚠️ Deprecations
- `cacheTtl` is deprecated, you should use `cacheMaxAgeSeconds` which is more explicit.
- `inferStaticPagesAsRoutes` is deprecated, if you were using this to opt-out of pages, you should use `excludeAppSources: true`
## ☠️ Breaking Changes
### Nuxt Hooks no longer supported
If you were using Nuxt hooks to modify the prerendered sitemap, you will need to migrate these to Nitro hooks.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
hooks: {
// old - no longer supported
'sitemap:resolved': function (ctx) {},
'sitemap:output': function (ctx) {}
},
})
```
```ts [server/plugins/sitemap]
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:output', async (ctx) => {
// supported!
})
})
```
### Multi Sitemap App Sources
By default, app sources will no longer be included in multi sitemap implementations. You will need to use `includeAppSources: true` to re-enable it. See [Extending App Sources](/docs/sitemap/guides/multi-sitemaps#extending-app-sources) for more information.
### Removed deprecations
- The hook `sitemap:prerender` has been removed. You should use `sitemap:resolved` instead.
- The config `trailingSlash` and `siteUrl` has been removed. You should use site config, see [Setting Site Config](/docs/site-config/guides/how-it-works).
- The config `autoAlternativeLangPrefixes` has been removed. If you'd like to set up automatic alternative language prefixes use `__i18nTransform`.
## Support my work
This release took over 40 hours.
If technical SEO developer experience in Nuxt is important to you, consider [supporting my work](https://github.com/sponsors/harlan-zw) on Nuxt SEO.
================================================
FILE: docs/content/5.releases/8.v3.md
================================================
---
title: v3.0.0
description: Release notes for v3.0.0.
---
## Features :rocket:
### 🤝 Stable I18n Integration
Fully supporting i18n sites through the `sitemap` module has been a long requested feature.
V2 Partially supported it, but in v3 support is fully integrated with build-time macros support and more.
### 🚀 Default Caching
Caching is now enabled by default for production sitemaps. Sitemaps will be cached for 1 hour.
You can change the cache time or disable caching by setting the `cacheTtl` config.
```ts
export default defineNuxtConfig({
sitemap: {
cacheTtl: 5 * 60 * 60 * 1000 // 5 hours
}
})
```
You can also provide your own cache instance by setting the `runtimeCacheStorage` config.
```ts
export default defineNuxtConfig({
sitemap: {
runtimeCacheStorage: {
driver: 'redis',
host: 'localhost',
port: 6379,
db: 0,
}
}
})
```
Learn more on the [Performance](/docs/sitemap/advanced/performance) guide.
### Improved XML Stylesheet
The UI of the sitemap.xml page has been improved slightly. It now features more useful tips and links.
You can also customise the stylesheet with the new following config:
- `xslTips` - Toggle the tips displayed on the sitemap.xml pages.
- `xslColumns` - Customise the columns displayed on the sitemap.xml pages.
For example, you can change the columns to only show the `loc` and `lastmod` columns.
```ts
export default defineNuxtConfig({
sitemap: {
xslColumns: [
// URL column must always be set, no value needed
{ label: 'URL', width: '75%' },
{ label: 'Last Modified', value: 'sitemap:lastmod', width: '25%' },
],
},
})
```
Learn more on the [Customising the UI](/docs/sitemap/advanced/customising-ui) guide.
### Debug Mode
A `debug` config has been added
which will give access to a custom endpoint at `/api/__sitemap__/debug` which will show you
how your sitemap is being generated.
When you build your site with debug on, a `/__sitemap__/debug.json` page will be generated.
This is disabled by default and should only be enabled for debugging purposes.
## Other Improvements
## New Hook: `sitemap:output`
**Type:** `async (ctx: { sitemap: string; sitemapName: string }) => void | Promise`
This will let you modify the string content of the final sitemap before it is returned from the server.
Can be ran in both Nitro (runtime) and Nuxt (prerendering).
## New Hook: `sitemap:resolved`
**Type:** `async (ctx: { sitemap: FullSitemapEntry[]; sitemapName: string }) => void | Promise`
This will let you modify the final sitemap before it is turned into a string.
Can be ran in both Nitro (runtime) and Nuxt (prerendering).
### Individual multi-sitemap API endpoints `dynamicUrlsApiEndpoint`
- Type: `boolean | string`{lang="ts"}
- Default: `false`{lang="ts"}
You can now give each sitemap a unique API endpoint to fetch URLs from.
```ts
export default defineNuxtConfig({
sitemap: {
sitemaps: {
foo: {
dynamicUrlsApiEndpoint: '/api/foo-sitemap'
},
bar: {
dynamicUrlsApiEndpoint: '/api/bar-sitemap'
},
},
}
})
```
### New Config: `strictNuxtContentPaths`
- Type: `boolean`{lang="ts"}
- Default: `false`{lang="ts"}
Enable when the paths of your nuxt/content md files match the routing.
This will automatically add sitemap content to the sitemap.
This is similar behaviour to using `nuxt/content` with `documentDriven: true`.
### New Config: `credits`
- Type: `boolean`{lang="ts"}
- Default: `true`{lang="ts"}
Allows you to remove the "Generate by Nuxt Sitemap" comment from the generated sitemap.
### New Config: `xslTips`
- Type: `boolean`{lang="ts"}
- Default: `true`{lang="ts"}
Toggle the tips displayed on the sitemap.xml pages.
## Deprecation
- `trailingSlash` has been deprecated
- `siteUrl` has been deprecated
- `autoAlternativeLangPrefixes` is now disabled by default. If you want to enable it, you need to set it to `true` explicitly.
================================================
FILE: eslint.config.mjs
================================================
import antfu from '@antfu/eslint-config'
import harlanzw from 'eslint-plugin-harlanzw'
export default antfu(
{
type: 'lib',
ignores: [
'CLAUDE.md',
'test/fixtures/**',
'playground/**',
'docs/**',
'benchmark/**',
],
rules: {
'no-use-before-define': 'off',
'node/prefer-global/process': 'off',
'node/prefer-global/buffer': 'off',
'ts/explicit-function-return-type': 'off',
'e18e/prefer-static-regex': 'off',
},
},
{
files: ['**/test/**/*.ts', '**/test/**/*.js'],
rules: {
'ts/no-unsafe-function-type': 'off',
'no-console': 'off',
'antfu/no-top-level-await': 'off',
},
},
...harlanzw({ link: true, nuxt: true, vue: true }),
{
files: ['**/server/**/*.ts', '**/src/**/*.ts'],
rules: {
'harlanzw/vue-no-faux-composables': 'off',
},
},
{
files: ['examples/**/package.json'],
rules: {
'pnpm/json-enforce-catalog': 'off',
'pnpm/json-valid-catalog': 'off',
'pnpm/json-prefer-workspace-settings': 'off',
},
},
)
================================================
FILE: examples/basic/app/app.vue
================================================
================================================
FILE: examples/basic/app/pages/about.vue
================================================
About
This is the about page.
================================================
FILE: examples/basic/app/pages/contact.vue
================================================
Contact
This is the contact page.
================================================
FILE: examples/basic/app/pages/index.vue
================================================
Home
Welcome to the basic sitemap example.
================================================
FILE: examples/basic/nuxt.config.ts
================================================
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
site: {
url: 'https://example.com',
},
compatibilityDate: '2025-01-01',
})
================================================
FILE: examples/basic/package.json
================================================
{
"name": "nuxtjs-sitemap-basic-example",
"type": "module",
"private": true,
"dependencies": {
"@nuxtjs/sitemap": "latest",
"nuxt": "^4.4.4",
"vue": "^3.5.33"
}
}
================================================
FILE: examples/basic/tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json"
}
================================================
FILE: examples/dynamic-urls/app/app.vue
================================================
Sitemap Dynamic URLs Example
Home
Post 1
Post 2
Post 3
View Sitemap
Dynamic URLs are generated via server/api/_sitemap-urls.ts.
================================================
FILE: examples/dynamic-urls/app/pages/blog/[slug].vue
================================================
Blog Post: {{ $route.params.slug }}
This is a dynamically generated blog post page.
================================================
FILE: examples/dynamic-urls/app/pages/index.vue
================================================
Home
Welcome to the dynamic URLs sitemap example.
================================================
FILE: examples/dynamic-urls/nuxt.config.ts
================================================
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
site: {
url: 'https://example.com',
},
compatibilityDate: '2025-01-01',
})
================================================
FILE: examples/dynamic-urls/package.json
================================================
{
"name": "nuxtjs-sitemap-dynamic-urls-example",
"type": "module",
"private": true,
"dependencies": {
"@nuxtjs/sitemap": "latest",
"nuxt": "^4.4.4",
"vue": "^3.5.33"
}
}
================================================
FILE: examples/dynamic-urls/server/api/_sitemap-urls.ts
================================================
export default defineSitemapEventHandler(() => {
return [
{ loc: '/blog/post-1', lastmod: new Date() },
{ loc: '/blog/post-2', lastmod: new Date() },
{ loc: '/blog/post-3', lastmod: new Date() },
]
})
================================================
FILE: examples/dynamic-urls/tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json"
}
================================================
FILE: examples/i18n/app/app.vue
================================================
Sitemap i18n Example
Home
About
Contact
View Sitemap
Sitemap includes alternate hreflang entries for en, fr, and de locales.
================================================
FILE: examples/i18n/app/pages/about.vue
================================================
About
This is the about page.
================================================
FILE: examples/i18n/app/pages/contact.vue
================================================
Contact
This is the contact page.
================================================
FILE: examples/i18n/app/pages/index.vue
================================================
Home
Welcome to the i18n sitemap example.
================================================
FILE: examples/i18n/nuxt.config.ts
================================================
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap', '@nuxtjs/i18n'],
site: {
url: 'https://example.com',
},
i18n: {
locales: [
{ code: 'en', name: 'English' },
{ code: 'fr', name: 'Français' },
{ code: 'de', name: 'Deutsch' },
],
defaultLocale: 'en',
strategy: 'prefix_except_default',
},
compatibilityDate: '2025-01-01',
})
================================================
FILE: examples/i18n/package.json
================================================
{
"name": "nuxtjs-sitemap-i18n-example",
"type": "module",
"private": true,
"dependencies": {
"@nuxtjs/i18n": "^10.3.0",
"@nuxtjs/sitemap": "latest",
"nuxt": "^4.4.4",
"vue": "^3.5.33"
}
}
================================================
FILE: examples/i18n/tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json"
}
================================================
FILE: package.json
================================================
{
"name": "@nuxtjs/sitemap",
"type": "module",
"version": "8.0.15",
"packageManager": "pnpm@10.33.2",
"description": "Powerfully flexible XML Sitemaps that integrate seamlessly, for Nuxt.",
"author": {
"name": "Harlan Wilton",
"email": "harlan@harlanzw.com",
"url": "https://harlanzw.com/"
},
"license": "MIT",
"funding": "https://github.com/sponsors/harlan-zw",
"homepage": "https://github.com/nuxt-modules/sitemap#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/nuxt-modules/sitemap.git"
},
"bugs": {
"url": "https://github.com/nuxt-modules/sitemap/issues"
},
"exports": {
".": {
"types": "./dist/types.d.mts",
"import": "./dist/module.mjs"
},
"./content": "./dist/content.mjs",
"./utils": "./dist/utils.mjs"
},
"main": "./dist/module.mjs",
"typesVersions": {
"*": {
".": [
"./dist/types.d.mts"
],
"content": [
"./dist/content.d.mts"
],
"utils": [
"./dist/utils.d.mts"
]
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"client:build": "nuxt generate devtools",
"prepack": "pnpm run build",
"devtools": "nuxt dev devtools --port 3030",
"build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build && npm run client:build",
"dev": "nuxt dev playground",
"prepare:fixtures": "nuxt prepare test/fixtures/basic && nuxt prepare test/fixtures/i18n && nuxt prepare test/fixtures/i18n-micro",
"dev:build": "nuxt build playground",
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
"release": "pnpm build && bumpp -x \"npx changelogen --output=CHANGELOG.md\"",
"test": "vitest run && pnpm run test:attw",
"test:unit": "vitest --project=unit",
"test:attw": "attw --pack",
"typecheck": "nuxt typecheck",
"prepare": "skilld prepare || true"
},
"peerDependencies": {
"zod": ">=3"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
},
"dependencies": {
"@nuxt/kit": "catalog:",
"consola": "catalog:",
"defu": "catalog:",
"fast-xml-parser": "catalog:",
"nuxt-site-config": "catalog:",
"nuxtseo-shared": "catalog:",
"ofetch": "catalog:",
"pathe": "catalog:",
"pkg-types": "catalog:",
"radix3": "catalog:",
"ufo": "catalog:",
"ultrahtml": "catalog:"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@arethetypeswrong/cli": "catalog:",
"@nuxt/content": "catalog:",
"@nuxt/devtools-kit": "catalog:",
"@nuxt/module-builder": "catalog:",
"@nuxt/test-utils": "catalog:",
"@nuxt/ui": "catalog:",
"@nuxtjs/i18n": "catalog:",
"@nuxtjs/robots": "catalog:",
"@nuxtjs/sitemap": "workspace:*",
"@vue/test-utils": "catalog:",
"better-sqlite3": "catalog:",
"bumpp": "catalog:",
"eslint": "catalog:",
"eslint-plugin-harlanzw": "catalog:",
"execa": "catalog:",
"happy-dom": "catalog:",
"nuxt": "catalog:",
"nuxt-i18n-micro": "catalog:",
"nuxtseo-layer-devtools": "catalog:",
"semver": "catalog:",
"sirv": "catalog:",
"std-env": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"vue-tsc": "catalog:",
"zod": "catalog:"
}
}
================================================
FILE: patches/@nuxtjs__mdc.patch
================================================
diff --git a/dist/module.mjs b/dist/module.mjs
index e028b1a7fba50c413a0e5e40fd545d998917620a..a74d8a45a98dced151f1fc141f24c16746e0ab25 100644
--- a/dist/module.mjs
+++ b/dist/module.mjs
@@ -350,7 +350,7 @@ const module = defineNuxtModule({
filename: "mdc-image-component.mjs",
write: true,
getContents: ({ app }) => {
- const image = app.components.find((c) => c.pascalName === "NuxtImg" && !c.filePath.includes("nuxt/dist/app"));
+ const image = app.components.find((c) => c.pascalName === "NuxtImg" && !c.filePath.includes("nuxt/dist/app") && !c.filePath.includes("nuxt-nightly/dist/app"));
return image ? `export { default } from "${image.filePath}"` : 'export default "img"';
}
});
================================================
FILE: playground/.nuxtrc
================================================
imports.autoImport=true
================================================
FILE: playground/app.vue
================================================
================================================
FILE: playground/assets/css/main.css
================================================
@import "tailwindcss";
@import "@nuxt/ui";
================================================
FILE: playground/content/_partial.md
================================================
# bar
================================================
FILE: playground/content/bar.md
================================================
# bar
================================================
FILE: playground/content/foo.md
================================================
---
sitemap:
priority: 0.5
---
# foo
================================================
FILE: playground/content/posts/bar.md
================================================
---
sitemap:
loc: /blog/posts/bar
lastmod: 2021-10-20
---
# bar
================================================
FILE: playground/content/posts/foo.md
================================================
# foo
================================================
FILE: playground/nuxt.config.ts
================================================
import { defineNuxtConfig } from 'nuxt/config'
import NuxtSitemap from '../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/robots',
'@nuxtjs/i18n',
'@nuxt/content',
'@nuxt/ui',
],
css: ['~/assets/css/main.css'],
site: {
url: 'https://sitemap-edge-demo.vercel.app/',
},
ignorePrefix: 'ignore-',
routeRules: {
'/api/prerendered': {
prerender: true,
},
'/secret': {
robots: false,
},
'/users-test/*': {
sitemap: {
lastmod: new Date(2023, 1, 21, 4, 50, 52),
changefreq: 'weekly',
priority: 0.3,
images: [],
},
},
'/should-not-be-in-sitemap/*': {},
'/about-redirect': {
redirect: '/about',
},
'/about': {
sitemap: {
lastmod: '2023-01-21',
changefreq: 'daily',
priority: 0.3,
images: [
{
loc: 'https://example.com/image.jpg',
},
{
loc: 'https://example.com/image2.jpg',
},
],
},
},
},
experimental: {
inlineRouteRules: true,
},
compatibilityDate: '2025-01-17',
nitro: {
typescript: {
internalPaths: true,
},
plugins: ['plugins/sitemap.ts'],
prerender: {
routes: [
// '/sitemap_index.xml',
'/prerender',
'/prerender-video',
'/should-be-in-sitemap',
'/foo.bar/',
'/test.doc',
'/api/prerendered',
],
failOnError: false,
},
},
i18n: {
locales: ['en', 'fr'],
defaultLocale: 'en',
},
// app: {
// baseURL: '/base'
// },
sitemap: {
debug: true,
// sitemapName: 'test.xml',
minify: false,
cacheMaxAgeSeconds: 10,
xslColumns: [
{ label: 'URL', width: '50%' },
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
{ label: 'Hreflangs', select: 'count(xhtml:link)', width: '25%' },
],
experimentalWarmUp: true,
urls: [
'/manual-url-test',
{ loc: '/bad-lastmod', lastmod: 'not-a-date' },
{ loc: '/bad-changefreq', changefreq: 'sometimes' as any },
{ loc: '/bad-priority', priority: 5 },
],
sources: [
'/some-invalid-url',
['https://api.example.com/pages/urls', { headers: { Authorization: 'Bearer ' } }],
],
defaultSitemapsChunkSize: 10,
sitemaps: {
posts: {
includeAppSources: true,
urls: async () => {
await new Promise((then) => {
setTimeout(then, 5000)
})
return ['/slow-url']
},
include: ['/slow-url', '/en/blog/**', '/fr/blog/**', '/blog/**'],
},
pages: {
includeAppSources: true,
sources: [
'/api/sitemap-foo',
'https://example.com/invalid.json',
],
exclude: ['/en/blog/**', '/fr/blog/**', '/blog/**', /.*hide-me.*/g],
urls: [
{
loc: '/about',
lastmod: '2023-02-21T08:50:52.000Z',
alternatives: [
{
href: '/fr/about',
hreflang: 'fr',
},
],
images: [
{
loc: 'https://example.com/image-3.jpg',
},
],
},
],
},
index: [
{ sitemap: 'https://www.example.com/sitemap-pages.xml' },
],
},
},
})
================================================
FILE: playground/pages/.ignored/test.vue
================================================
shouldn't be added
================================================
FILE: playground/pages/[...slug].vue
================================================
{{ $route.params.slug }}
================================================
FILE: playground/pages/_dir/robots.txt
================================================
User-agent: *
Disallow: /blocked-by-robots-txt
================================================
FILE: playground/pages/about.vue
================================================
About page
================================================
FILE: playground/pages/api/foo.vue
================================================
hello world
================================================
FILE: playground/pages/blocked-by-robots-txt/foo.vue
================================================
This should be blocked by @nuxtjs/robots integration automatically.
================================================
FILE: playground/pages/blog/[id].vue
================================================
Hello world
================================================
FILE: playground/pages/blog/categories.vue
================================================
temp
================================================
FILE: playground/pages/blog/index.vue
================================================
temp
================================================
FILE: playground/pages/blog/tags/edit.vue
================================================
edit
================================================
FILE: playground/pages/blog/tags/new.vue
================================================
new
================================================
FILE: playground/pages/blog/tags.vue
================================================
temp
================================================
FILE: playground/pages/blog.vue
================================================
temp
================================================
FILE: playground/pages/foo.bar.vue
================================================
foo.bar
================================================
FILE: playground/pages/hidden-path-but-in-sitemap/index.vue
================================================
================================================
FILE: playground/pages/hide-me.vue
================================================
hide-me
================================================
FILE: playground/pages/ignore-foo.vue
================================================
foo
================================================
FILE: playground/pages/index.vue
================================================
Sitemap Playground
About
Blog
New Page
Secret (robots: false)
ignore-foo
Dynamic Pre-render
================================================
FILE: playground/pages/new-page.vue
================================================
New page
================================================
FILE: playground/pages/prerender-video.vue
================================================
Pre-render Video Discovery Page
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
================================================
FILE: playground/pages/prerender.vue
================================================
Pre-render Image Discovery Page
================================================
FILE: playground/pages/secret.vue
================================================
Secret page, not for robots.
================================================
FILE: playground/pages/users-[group]/[id].vue
================================================
Hello world
================================================
FILE: playground/pages/users-[group]/index.vue
================================================
Hello world
================================================
FILE: playground/server/api/_sitemap-urls.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
'/users-lazy/1',
'/users-lazy/2',
'/users-lazy/3',
...posts.map(post => ({
loc: `/blog/post-${post}`,
})),
]
})
================================================
FILE: playground/server/api/fetch.ts
================================================
import type { asSitemapUrl } from '#imports'
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(async () => {
return $fetch[]>('/api/sitemap-urls-to-be-confumsed-by-fetch')
})
================================================
FILE: playground/server/api/multi-sitemap-sources/bar.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/bar/${post}`,
})),
]
})
================================================
FILE: playground/server/api/multi-sitemap-sources/foo.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/foo/${post}`,
})),
]
})
================================================
FILE: playground/server/api/prerendered.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(async () => {
return { foo: 'bar' }
})
================================================
FILE: playground/server/api/sitemap-bar.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
'/bar/1',
'/bar/2',
'/bar/3',
]
})
================================================
FILE: playground/server/api/sitemap-foo.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
'/foo/1',
'/foo/2',
'/foo/3',
]
})
================================================
FILE: playground/server/api/sitemap-urls-to-be-confumsed-by-fetch.ts
================================================
import { withLeadingSlash } from 'ufo'
import { defineEventHandler } from '#imports'
export default defineEventHandler(() => {
return $fetch<{ title: string }[]>('https://jsonplaceholder.typicode.com/posts').then((res) => {
return res.map(post => ({
invalidAttr: 'foo',
loc: withLeadingSlash(post.title.replace(' ', '-')),
}))
})
})
================================================
FILE: playground/server/plugins/sitemap.ts
================================================
import { defineNitroPlugin } from '#imports'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:output', async () => {
// eslint-disable-next-line no-console
console.log('Sitemap SSR hook')
})
nitroApp.hooks.hook('sitemap:index-resolved', (ctx) => {
// eslint-disable-next-line no-console
console.log('Sitemap index resolved hook', ctx)
})
})
================================================
FILE: playground/server/routes/__sitemap.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
const posts = Array.from({ length: 3 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/blog/${post}`,
})),
]
})
================================================
FILE: playground/server/tsconfig.json
================================================
{
// https://v3.nuxtjs.org/concepts/typescript
"extends": "../.nuxt/tsconfig.server.json"
}
================================================
FILE: playground/tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json"
}
================================================
FILE: pnpm-workspace.yaml
================================================
shellEmulator: true
trustPolicy: no-downgrade
trustPolicyExclude:
- chokidar
- semver
packages:
- devtools
- test/fixtures/**
- playground
- benchmark
- '!examples/**'
overrides:
'@vitejs/plugin-vue': ^6.0.6
vite: ^8.0.10
patchedDependencies:
'@nuxtjs/mdc': patches/@nuxtjs__mdc.patch
catalog:
'@antfu/eslint-config': ^8.2.0
'@arethetypeswrong/cli': ^0.18.2
'@iconify-json/carbon': ^1.2.20
'@iconify-json/simple-icons': ^1.2.80
'@nuxt/content': ^3.13.0
'@nuxt/devtools-kit': 4.0.0-alpha.3
'@nuxt/kit': ^4.4.4
'@nuxt/module-builder': ^1.0.2
'@nuxt/test-utils': ^4.0.3
'@nuxt/ui': ^4.7.1
'@nuxtjs/i18n': ^10.3.0
'@nuxtjs/robots': ^6.0.8
'@vue/test-utils': ^2.4.10
'@vueuse/core': ^14.3.0
autocannon: ^8.0.0
better-sqlite3: ^12.9.0
bumpp: ^11.0.1
consola: ^3.4.2
defu: ^6.1.7
eslint: ^10.2.1
eslint-plugin-harlanzw: ^0.12.1
execa: ^9.6.1
fast-xml-parser: ^5.7.2
happy-dom: ^20.9.0
nuxt: ^4.4.4
nuxt-i18n-micro: ^3.17.5
nuxt-site-config: ^4.0.8
nuxtseo-layer-devtools: ^5.1.3
nuxtseo-shared: ^5.1.3
ofetch: ^1.5.1
pathe: ^2.0.3
pkg-types: ^2.3.1
radix3: ^1.1.2
semver: ^7.7.4
sirv: ^3.0.2
std-env: ^4.1.0
typescript: ^6.0.3
ufo: ^1.6.4
ultrahtml: ^1.6.0
vitest: ^4.1.5
vue: ^3.5.33
vue-router: ^5.0.6
vue-tsc: ^3.2.7
zod: ^4.4.1
onlyBuiltDependencies:
- better-sqlite3
- esbuild
trustPolicyIgnoreAfter: 262800
================================================
FILE: src/content.ts
================================================
import type { Collection, PageCollectionItemBase } from '@nuxt/content'
import type { TypeOf } from 'zod'
import { createContentSchemaFactory } from 'nuxtseo-shared/content'
import { z } from 'zod'
declare global {
// eslint-disable-next-line vars-on-top
var __sitemapCollectionFilters: Map boolean> | undefined
// eslint-disable-next-line vars-on-top
var __sitemapCollectionOnUrlFns: Map void> | undefined
}
if (!globalThis.__sitemapCollectionFilters)
globalThis.__sitemapCollectionFilters = new Map()
if (!globalThis.__sitemapCollectionOnUrlFns)
globalThis.__sitemapCollectionOnUrlFns = new Map()
const collectionFilters = globalThis.__sitemapCollectionFilters
const collectionOnUrlFns = globalThis.__sitemapCollectionOnUrlFns
export interface DefineSitemapSchemaOptions> {
z?: typeof z
name?: string
filter?: (entry: PageCollectionItemBase & SitemapSchema & TEntry) => boolean
onUrl?: (
url: { loc: string, lastmod?: string | Date, changefreq?: string, priority?: number, images?: { loc: string }[], videos?: { content_loc: string }[], [key: string]: unknown },
entry: PageCollectionItemBase & SitemapSchema & TEntry,
collection: string,
) => void
}
const { defineSchema, asCollection, schema } = createContentSchemaFactory({
fieldName: 'sitemap',
label: 'sitemap',
docsUrl: 'https://nuxtseo.com/sitemap/guides/content',
buildSchema: _z => buildSitemapObjectSchema(_z),
onDefineSchema: (options: DefineSitemapSchemaOptions) => {
if ('type' in options || 'source' in options)
throw new Error('[sitemap] `defineSitemapSchema()` returns a schema field, not a collection wrapper. Use it inside your schema: `schema: z.object({ sitemap: defineSitemapSchema() })`. See https://nuxtseo.com/sitemap/guides/content')
warnIfZodMismatch(options?.z)
if (options?.filter || options?.onUrl) {
if (!options.name)
throw new Error('[sitemap] `name` is required when using `filter` or `onUrl` in defineSitemapSchema()')
if (options.filter)
collectionFilters.set(options.name, options.filter)
if (options.onUrl)
collectionOnUrlFns.set(options.name, options.onUrl)
}
},
}, z)
export { defineSchema as defineSitemapSchema, schema }
export type SitemapSchema = TypeOf
/** @deprecated Use `defineSitemapSchema()` in your collection schema instead. See https://nuxtseo.com/sitemap/guides/content */
export function asSitemapCollection(collection: Collection, options?: DefineSitemapSchemaOptions): Collection {
if (options?.filter || options?.onUrl) {
if (!options.name)
throw new Error('[sitemap] `name` is required when using `filter` or `onUrl` in asSitemapCollection()')
if (options.filter)
collectionFilters.set(options.name, options.filter)
if (options.onUrl)
collectionOnUrlFns.set(options.name, options.onUrl)
}
try {
return asCollection(collection) as Collection
}
catch (e) {
console.warn(
`[sitemap] Failed to apply sitemap schema to collection. This is likely a Zod version mismatch.`,
`Pass your Zod instance explicitly: \`defineSitemapSchema({ z })\`. See https://nuxtseo.com/sitemap/guides/content`,
`Error: ${(e as Error).message}`,
)
return collection
}
}
let _hasWarnedZodMismatch = false
function warnIfZodMismatch(userZ?: typeof z) {
if (_hasWarnedZodMismatch || userZ)
return
// Detect mixed zod versions: zod 3 uses `_def` without `def`, zod 4 has both
const testSchema = z.object({}) as any
const hasV3 = '_def' in testSchema && !('def' in testSchema)
const hasV4 = 'def' in testSchema
// If both zod 3 and zod 4 are installed, `import { z } from 'zod'` may resolve
// to a different version than what @nuxt/content uses internally
if (hasV3 || hasV4) {
// Only warn if we can detect the version (always true), but the user hasn't passed z
// The real check: does our z produce schemas compatible with @nuxt/content's detectSchemaVendor?
// We can't know for sure without importing content, so just warn if zod 3 is resolved
// since @nuxt/content v3.12+ expects zod 4
if (hasV3) {
_hasWarnedZodMismatch = true
console.warn(
`[sitemap] Zod 3 detected but @nuxt/content v3 expects Zod 4.`,
`Pass your zod instance explicitly: \`defineSitemapSchema({ z })\`.`,
`See https://nuxtseo.com/sitemap/guides/content`,
)
}
}
}
function buildSitemapObjectSchema(_z: typeof z) {
return _z.object({
loc: _z.string().optional(),
lastmod: _z.date().optional(),
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(),
priority: _z.number().optional(),
images: _z.array(_z.object({
loc: _z.string(),
caption: _z.string().optional(),
geo_location: _z.string().optional(),
title: _z.string().optional(),
license: _z.string().optional(),
})).optional(),
videos: _z.array(_z.object({
content_loc: _z.string(),
player_loc: _z.string().optional(),
duration: _z.string().optional(),
expiration_date: _z.date().optional(),
rating: _z.number().optional(),
view_count: _z.number().optional(),
publication_date: _z.date().optional(),
family_friendly: _z.boolean().optional(),
tag: _z.string().optional(),
category: _z.string().optional(),
restriction: _z.object({
relationship: _z.literal('allow').optional(),
value: _z.string().optional(),
}).optional(),
gallery_loc: _z.string().optional(),
price: _z.string().optional(),
requires_subscription: _z.boolean().optional(),
uploader: _z.string().optional(),
})).optional(),
}).optional()
}
================================================
FILE: src/devtools.ts
================================================
import type { Resolver } from '@nuxt/kit'
import type { Nuxt } from 'nuxt/schema'
import type { ModuleOptions } from './module'
import { useNuxt } from '@nuxt/kit'
import { setupDevToolsUI as _setupDevToolsUI } from 'nuxtseo-shared/devtools'
export function setupDevToolsUI(_options: ModuleOptions, resolve: Resolver['resolve'], nuxt: Nuxt = useNuxt()) {
_setupDevToolsUI(
{ route: '/__nuxt-sitemap', name: 'sitemap', title: 'Sitemap', icon: 'carbon:load-balancer-application' },
resolve,
nuxt,
)
}
================================================
FILE: src/module.ts
================================================
import type { FileAfterParseHook } from '@nuxt/content'
import type { NitroRouteConfig } from 'nitropack/types'
import type {
ModuleOptions as _ModuleOptions,
AppSourceContext,
AutoI18nConfig,
FilterInput,
I18nIntegrationOptions,
ModuleRuntimeConfig,
MultiSitemapEntry,
SitemapDefinition,
SitemapSourceBase,
SitemapSourceInput,
SitemapSourceResolved,
SitemapUrl,
} from './runtime/types'
import {
addPrerenderRoutes,
addServerHandler,
addServerImports,
addServerPlugin,
createResolver,
defineNuxtModule,
getNuxtModuleVersion,
hasNuxtModule,
hasNuxtModuleCompatibility,
resolveModule,
useLogger,
} from '@nuxt/kit'
import { defu } from 'defu'
import { installNuxtSiteConfig } from 'nuxt-site-config/kit'
import { isPathFile } from 'nuxt-site-config/urls'
import { dirname } from 'pathe'
import { readPackageJSON } from 'pkg-types'
import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash, withTrailingSlash } from 'ufo'
import { setupDevToolsUI } from './devtools'
import { includesSitemapRoot, setupPrerenderHandler } from './prerender'
import { normaliseDate } from './runtime/server/sitemap/urlset/normalise'
import { registerTypeTemplates } from './templates'
import { normalizeFilters } from './utils-internal/filter'
import {
generatePathForI18nPages,
normalizeLocales,
splitPathForI18nLocales,
} from './utils-internal/i18n'
import { createNitroPromise, createPagesPromise, getNuxtModuleOptions, isNuxtGenerate, resolveNitroPreset, resolveNuxtContentVersion } from './utils-internal/kit'
import { convertNuxtPagesToSitemapEntries, generateExtraRoutesFromNuxtConfig, resolveUrls } from './utils-internal/nuxtSitemap'
declare global {
// eslint-disable-next-line vars-on-top
var __sitemapCollectionFilters: Map boolean> | undefined
// eslint-disable-next-line vars-on-top
var __sitemapCollectionOnUrlFns: Map void> | undefined
}
export type * from './runtime/types'
export interface ModuleOptions extends _ModuleOptions {}
export interface ModuleHooks {
/**
* Hook called after the prerender of the sitemaps is done.
*/
'sitemap:prerender:done': (ctx: {
options: ModuleRuntimeConfig
sitemaps: { name: string, readonly content: string }[]
}) => void | Promise
}
declare module '@nuxt/schema' {
interface NuxtHooks extends ModuleHooks {}
}
export default defineNuxtModule({
meta: {
name: '@nuxtjs/sitemap',
compatibility: {
nuxt: '>=3.9.0',
},
configKey: 'sitemap',
},
moduleDependencies: {
'@nuxtjs/i18n': {
version: '>=8',
optional: true,
},
'nuxt-i18n-micro': {
version: '>=1',
optional: true,
},
'nuxt-site-config': {
version: '>=3.2',
},
'@nuxt/content': {
version: '>=2',
optional: true,
},
'@nuxtjs/robots': {
version: '>=4',
optional: true,
},
},
defaults: {
enabled: true,
credits: true,
cacheMaxAgeSeconds: 60 * 10, // cache for 10 minutes
minify: false,
debug: false,
defaultSitemapsChunkSize: 1000,
autoLastmod: false,
discoverImages: true,
discoverVideos: true,
urls: [],
sortEntries: true,
sitemapsPathPrefix: '/__sitemap__/',
xsl: '/__sitemap__/style.xsl',
xslTips: true,
strictNuxtContentPaths: false,
runtimeCacheStorage: true,
sitemapName: 'sitemap.xml',
// cacheControlHeader: 'max-age=600, must-revalidate',
defaults: {},
// index sitemap options filtering
include: [],
exclude: ['/_**'],
// sources
sources: [],
excludeAppSources: [],
zeroRuntime: false,
},
async setup(config, nuxt) {
const { resolve } = createResolver(import.meta.url)
const { name, version } = await readPackageJSON(resolve('../package.json'))
const logger = useLogger(name)
logger.level = (config.debug || nuxt.options.debug) ? 4 : 3
if (config.enabled === false) {
logger.debug('The module is disabled, skipping setup.')
return
}
// /_nuxt/
config.exclude!.push(`${withTrailingSlash(nuxt.options.app.buildAssetsDir)}**`)
nuxt.options.alias['#sitemap'] = resolve('./runtime')
nuxt.options.nitro.alias = nuxt.options.nitro.alias || {}
nuxt.options.nitro.alias['#sitemap'] = resolve('./runtime')
nuxt.options.experimental.extraPageMetaExtractionKeys = nuxt.options.experimental.extraPageMetaExtractionKeys || []
nuxt.options.experimental.extraPageMetaExtractionKeys.push('sitemap')
config.xslColumns = config.xslColumns || [
{ label: 'URL', width: '50%' },
{ label: 'Images', width: '25%', select: 'count(image:image)' },
{
label: 'Last Updated',
width: '25%',
select: 'concat(substring(sitemap:lastmod,0,11),concat(\' \', substring(sitemap:lastmod,12,5)),concat(\' \', substring(sitemap:lastmod,20,6)))',
},
]
if (config.autoLastmod) {
config.defaults = config.defaults || {}
config.defaults.lastmod = normaliseDate(new Date())
}
// warn about bad config
const normalizedSitemaps = typeof config.sitemaps === 'boolean' ? {} : config.sitemaps || {}
if (!nuxt.options._prepare && Object.keys(normalizedSitemaps).length) {
// if the only key of config.sitemaps is `index` then we can skip this logic
const isSitemapIndexOnly = typeof normalizedSitemaps?.index !== 'undefined' && Object.keys(normalizedSitemaps).length === 1
if (!isSitemapIndexOnly) {
// if the user is doing multi-sitempas using the sitemaps config, we warn when root keys are used as they won't do anything
const warnForIgnoredKey = (key: string) => {
logger.warn(`You are using multiple-sitemaps but have provided \`sitemap.${key}\` in your Nuxt config. This will be ignored, please move it to the child sitemap config.`)
logger.warn('Learn more at: https://nuxtseo.com/sitemap/guides/multi-sitemaps')
}
switch (true) {
case (config?.sources?.length || 0) > 0:
warnForIgnoredKey('sources')
break
case config?.includeAppSources !== undefined:
warnForIgnoredKey('includeAppSources')
break
}
}
}
// for trailing slashes / canonical absolute urls
await installNuxtSiteConfig()
const userGlobalSources: SitemapSourceInput[] = [
...config.sources || [],
]
const appGlobalSources: (SitemapSourceBase | SitemapSourceResolved)[] = []
nuxt.options.nitro.storage = nuxt.options.nitro.storage || {}
// provide cache storage for prerendering
if (config.runtimeCacheStorage && !nuxt.options.dev && typeof config.runtimeCacheStorage === 'object')
nuxt.options.nitro.storage.sitemap = config.runtimeCacheStorage
if (!config.sitemapName.endsWith('xml')) {
const newName = `${config.sitemapName.split('.')[0]}.xml`
logger.warn(`You have provided a \`sitemapName\` that does not end with \`.xml\`. This is not supported by search engines, renaming to \`${newName}\`.`)
config.sitemapName = newName
}
config.sitemapName = withoutLeadingSlash(config.sitemapName)
let usingMultiSitemaps = !!config.sitemaps
let isI18nMapped = false
let nuxtI18nConfig = {} as I18nIntegrationOptions
let resolvedAutoI18n: false | AutoI18nConfig = typeof config.autoI18n === 'boolean' ? false : config.autoI18n || false
const hasDisabledAutoI18n = typeof config.autoI18n === 'boolean' && !config.autoI18n
let normalisedLocales: AutoI18nConfig['locales'] = []
let usingI18nPages = false
const i18nModule = ['@nuxtjs/i18n', 'nuxt-i18n-micro'].find(s => hasNuxtModule(s))
if (i18nModule) {
const i18nVersion = await getNuxtModuleVersion(i18nModule)
if (i18nVersion && i18nModule === '@nuxtjs/i18n' && !await hasNuxtModuleCompatibility(i18nModule, '>=8'))
logger.warn(`You are using ${i18nModule} v${i18nVersion}. For the best compatibility, please upgrade to ${i18nModule} v8.0.0 or higher.`)
nuxtI18nConfig = (await getNuxtModuleOptions(i18nModule) || {}) as I18nIntegrationOptions
normalisedLocales = normalizeLocales(nuxtI18nConfig)
usingI18nPages = !!Object.keys(nuxtI18nConfig.pages || {}).length
if (usingI18nPages && !hasDisabledAutoI18n) {
const i18nPagesSources: SitemapSourceBase = {
context: {
name: `${i18nModule}:pages`,
description: 'Generated from your i18n.pages config.',
tips: [
'You can disable this with `autoI18n: false`.',
],
},
urls: [],
}
for (const pageLocales of Object.values(nuxtI18nConfig?.pages as Record>)) {
for (const localeCode in pageLocales) {
const locale = normalisedLocales.find(l => l.code === localeCode)
// add root entry for default locale and ignore dynamic routes
if (!locale || !pageLocales[localeCode] || pageLocales[localeCode].includes('['))
continue
// add to sitemap
const alternatives = Object.keys(pageLocales)
// @ts-expect-error untyped
.filter(l => pageLocales[l] !== false) // filter out disabled routes
.map(l => ({
hreflang: normalisedLocales.find(nl => nl.code === l)?._hreflang || l,
// @ts-expect-error untyped
href: generatePathForI18nPages({ localeCode: l, pageLocales: pageLocales[l], nuxtI18nConfig, normalisedLocales }),
}))
// @ts-expect-error untyped
if (alternatives.length && nuxtI18nConfig.defaultLocale && pageLocales[nuxtI18nConfig.defaultLocale] && pageLocales[nuxtI18nConfig.defaultLocale] !== false)
// @ts-expect-error untyped
alternatives.push({ hreflang: 'x-default', href: generatePathForI18nPages({ normalisedLocales, localeCode: nuxtI18nConfig.defaultLocale, pageLocales: pageLocales[nuxtI18nConfig.defaultLocale], nuxtI18nConfig }) })
i18nPagesSources.urls!.push({
_sitemap: locale._sitemap,
loc: generatePathForI18nPages({ normalisedLocales, localeCode, pageLocales: pageLocales[localeCode], nuxtI18nConfig }),
alternatives,
})
// add extra loc with the default locale code prefix on prefix and default strategy
if (nuxtI18nConfig.strategy === 'prefix_and_default' && localeCode === nuxtI18nConfig.defaultLocale) {
i18nPagesSources.urls!.push({
_sitemap: locale._sitemap,
loc: generatePathForI18nPages({ normalisedLocales, localeCode, pageLocales: pageLocales[localeCode], nuxtI18nConfig, forcedStrategy: 'prefix' }),
alternatives,
})
}
}
}
appGlobalSources.push(i18nPagesSources)
// pages will be wrong
if (Array.isArray(config.excludeAppSources))
config.excludeAppSources.push('nuxt:pages')
}
else {
if (!normalisedLocales.length)
logger.warn(`You are using ${i18nModule} but have not configured any locales, this will cause issues with ${name}. Please configure \`locales\`.`)
}
const hasSetAutoI18n = typeof config.autoI18n === 'object' && Object.keys(config.autoI18n).length
const hasI18nConfigForAlternatives = nuxtI18nConfig.differentDomains || usingI18nPages || (nuxtI18nConfig.strategy !== 'no_prefix' && nuxtI18nConfig.locales)
if (!hasSetAutoI18n && !hasDisabledAutoI18n && hasI18nConfigForAlternatives) {
resolvedAutoI18n = {
differentDomains: nuxtI18nConfig.differentDomains,
defaultLocale: nuxtI18nConfig.defaultLocale!,
locales: normalisedLocales,
strategy: nuxtI18nConfig.strategy as 'prefix' | 'prefix_except_default' | 'prefix_and_default',
// @ts-expect-error untyped
pages: nuxtI18nConfig.pages,
}
}
let canI18nMap = !hasDisabledAutoI18n && config.sitemaps !== false && nuxtI18nConfig.strategy !== 'no_prefix'
if (typeof config.sitemaps === 'object') {
const sitemapEntries = Object.entries(config.sitemaps).filter(([k]) => k !== 'index')
const isSitemapIndexOnly = sitemapEntries.length === 0
// Allow i18n mapping if any sitemap has includeAppSources
const hasIncludeAppSources = sitemapEntries.some(([_, v]) => v && typeof v === 'object' && (v as SitemapDefinition).includeAppSources)
if (!isSitemapIndexOnly && !hasIncludeAppSources)
canI18nMap = false
}
// if they haven't set `sitemaps` explicitly then we can set it up automatically for them
if (canI18nMap && resolvedAutoI18n) {
const existingSitemaps: Record = typeof config.sitemaps === 'object' ? config.sitemaps : {}
const i18nSitemaps: Array<{ name: string, cfg: SitemapDefinition }> = []
const nonI18nSitemaps: Record = {}
// Process existing sitemaps - separate includeAppSources from others
for (const [name, cfg] of Object.entries(existingSitemaps)) {
if (name === 'index')
continue
if (cfg && typeof cfg === 'object' && (cfg as SitemapDefinition).includeAppSources) {
i18nSitemaps.push({ name, cfg: cfg as SitemapDefinition })
}
else {
// Keep non-includeAppSources sitemaps as-is
nonI18nSitemaps[name] = cfg
}
}
// Build new sitemaps config
const newSitemaps: Record = {
index: [...((existingSitemaps.index as unknown[]) || []), ...(config.appendSitemaps || [])],
}
// Expand each includeAppSources sitemap to per-locale sitemaps
// If no custom sitemaps defined, use standard locale names (e.g., "en")
// If custom sitemaps defined, use "{locale}-{name}" format (e.g., "en-pages")
const hasCustomI18nSitemaps = i18nSitemaps.length > 0
if (hasCustomI18nSitemaps) {
for (const { name, cfg } of i18nSitemaps) {
for (const locale of resolvedAutoI18n.locales) {
newSitemaps[`${locale._sitemap}-${name}`] = {
includeAppSources: true,
...(cfg.exclude?.length && { exclude: cfg.exclude }),
...(cfg.include?.length && { include: cfg.include }),
}
}
}
}
else {
// Default behavior: create standard locale sitemaps
for (const locale of resolvedAutoI18n.locales) {
newSitemaps[locale._sitemap] = { includeAppSources: true }
}
}
// Add back non-i18n sitemaps
Object.assign(newSitemaps, nonI18nSitemaps)
// @ts-expect-error untyped
config.sitemaps = newSitemaps
isI18nMapped = true
usingMultiSitemaps = true
}
}
// @ts-expect-error untyped
nuxt.hooks.hook('robots:config', (robotsConfig) => {
robotsConfig.sitemap.push(usingMultiSitemaps ? '/sitemap_index.xml' : `/${config.sitemapName}`)
})
// avoid issues with module order
nuxt.hooks.hook('modules:done', async () => {
const robotsModuleName = ['nuxt-simple-robots', '@nuxtjs/robots'].find(s => hasNuxtModule(s))
let needsRobotsPolyfill = true
if (robotsModuleName) {
const robotsVersion = await getNuxtModuleVersion(robotsModuleName)
// we want to keep versions in sync
if (robotsVersion && !await hasNuxtModuleCompatibility(robotsModuleName, '>=4'))
logger.warn(`You are using ${robotsModuleName} v${robotsVersion}. For the best compatibility, please upgrade to ${robotsModuleName} v4.0.0 or higher.`)
else
needsRobotsPolyfill = false
}
// this is added in v4 of Nuxt Robots
if (needsRobotsPolyfill) {
nuxt.options.nitro.alias = nuxt.options.nitro.alias || {}
nuxt.options.nitro.alias['#internal/nuxt-robots'] = resolve('./runtime/server/robots-polyfill')
addServerImports([{
name: 'getPathRobotConfig',
as: 'getPathRobotConfig',
from: resolve('./runtime/server/robots-polyfill/getPathRobotConfig'),
}])
}
})
registerTypeTemplates()
// check if the user provided route /api/_sitemap-urls exists
const prerenderedRoutes = (nuxt.options.nitro.prerender?.routes || []) as string[]
let prerenderSitemap = isNuxtGenerate() || includesSitemapRoot(config.sitemapName, prerenderedRoutes)
if (resolveNitroPreset() === 'vercel-edge') {
logger.warn('Runtime sitemaps are not supported on Vercel Edge, falling back to prerendering sitemaps.')
prerenderSitemap = true
}
// zeroRuntime forces prerendering
if (config.zeroRuntime && !prerenderSitemap) {
prerenderSitemap = true
addPrerenderRoutes('/sitemap.xml')
if (!nuxt.options.dev)
logger.info('`zeroRuntime` enabled - sitemap routes will be prerendered.')
}
// base path for route handlers
const routesPath = config.zeroRuntime
? './runtime/server/routes/__zero-runtime'
: './runtime/server/routes'
const routeRules: NitroRouteConfig = {}
nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {}
if (prerenderSitemap) {
// add route rules for sitemap xmls so they're rendered properly
routeRules.headers = {
'Content-Type': 'text/xml; charset=UTF-8',
'Cache-Control': config.cacheMaxAgeSeconds ? `public, max-age=${config.cacheMaxAgeSeconds}, must-revalidate` : 'no-cache, no-store',
'X-Sitemap-Prerendered': new Date().toISOString(),
}
}
// The xsl handler sets its own Content-Type header, so no routeRule needed for it.
// Only register the per-sitemap routeRules entries when they actually carry content.
// An empty {} rule still gets matched on every request via the routeRules matcher,
// adding measurable overhead on unrelated routes for no benefit.
const hasRouteRuleContent = Object.keys(routeRules).length > 0
if (usingMultiSitemaps) {
nuxt.options.nitro.routeRules['/sitemap.xml'] = { redirect: withBase('/sitemap_index.xml', nuxt.options.app.baseURL) }
if (hasRouteRuleContent) {
nuxt.options.nitro.routeRules['/sitemap_index.xml'] = routeRules
if (typeof config.sitemaps === 'object') {
for (const k in config.sitemaps) {
if (k === 'index')
continue
nuxt.options.nitro.routeRules[joinURL(config.sitemapsPathPrefix || '', `/${k}.xml`)] = routeRules
const sitemapConfig = config.sitemaps[k]!
if (sitemapConfig.chunks)
nuxt.options.nitro.routeRules[joinURL(config.sitemapsPathPrefix || '', `/${k}-*.xml`)] = routeRules
}
}
else {
nuxt.options.nitro.routeRules[joinURL(config.sitemapsPathPrefix || '', `/[0-9]+.xml`)] = routeRules
}
}
}
else if (hasRouteRuleContent) {
nuxt.options.nitro.routeRules[`/${config.sitemapName}`] = routeRules
}
// skip experimental runtime plugins in zeroRuntime mode
if (config.zeroRuntime && (config.experimentalWarmUp || config.experimentalCompression))
logger.warn('`experimentalWarmUp` and `experimentalCompression` are ignored in zeroRuntime mode.')
if (!config.zeroRuntime) {
if (config.experimentalWarmUp)
addServerPlugin(resolve('./runtime/server/plugins/warm-up'))
if (config.experimentalCompression)
addServerPlugin(resolve('./runtime/server/plugins/compression'))
}
// @ts-expect-error untyped
const isNuxtContentDocumentDriven = (!!nuxt.options.content?.documentDriven || config.strictNuxtContentPaths)
const contentVersion = await resolveNuxtContentVersion()
const isNuxtContentV3 = contentVersion && contentVersion.version === 3
const nuxtV3Collections = new Set()
const isNuxtContentV2 = contentVersion && contentVersion.version === 2
if (isNuxtContentV3) {
// check if content was loaded first
if (nuxt.options._installedModules.some(m => m.meta.name === 'Content')) {
logger.warn('You have loaded `@nuxt/content` before `@nuxtjs/sitemap`, this may cause issues with the integration. Please ensure `@nuxtjs/sitemap` is loaded first.')
}
// // exclude /__nuxt_content
config.exclude!.push('/__nuxt_content/**')
const needsCustomAlias = await hasNuxtModuleCompatibility('@nuxt/content', '<3.6.0')
if (needsCustomAlias) {
nuxt.options.alias['#sitemap/content-v3-nitro-path'] = resolve(dirname(resolveModule('@nuxt/content')), 'runtime/nitro')
nuxt.options.alias['@nuxt/content/nitro'] = resolve('./runtime/server/content-compat')
}
nuxt.hooks.hook('content:file:afterParse' as any, (ctx: FileAfterParseHook) => {
try {
const content = ctx.content as any as {
body: { value: [string, Record][] }
sitemap?: Partial | false
path: string
updatedAt?: string
} & Record
nuxtV3Collections.add(ctx.collection.name)
// ignore .dot files and paths
if (String(ctx.content.path).includes('/.')) {
ctx.content.sitemap = null
return
}
if (!ctx.collection.fields || !('sitemap' in ctx.collection.fields)) {
ctx.content.sitemap = null
return
}
// support sitemap: false
if (typeof content.sitemap !== 'undefined' && !content.sitemap) {
ctx.content.sitemap = null
return
}
if (ctx.content.robots === false) {
ctx.content.sitemap = null
return
}
// add any top level images
const images: SitemapUrl['images'] = []
if (config.discoverImages) {
images.push(...(content.body?.value
?.filter(c =>
['image', 'img', 'nuxtimg', 'nuxt-img'].includes(c[0]),
)
.filter(c => c[1]?.src)
.map(c => ({ loc: c[1].src })) || []),
)
}
// Note: videos only supported through prerendering for simpler logic
const lastmod = content.seo?.articleModifiedTime || content.updatedAt
const defaults: Partial = {
loc: content.path,
}
if (images.length > 0)
defaults.images = images
if (lastmod)
defaults.lastmod = lastmod
ctx.content.sitemap = defu(typeof content.sitemap === 'object' ? content.sitemap : {}, defaults) as Partial
}
catch (e) {
logger.warn(`Failed to process sitemap data for content file (collection: ${ctx.collection?.name}, path: ${ctx.content?.path}), skipping.`, e)
}
})
// inject filter functions and loc prefixes as virtual modules
nuxt.hook('nitro:config', (nitroConfig) => {
const filterEntries: string[] = []
if (globalThis.__sitemapCollectionFilters) {
for (const [name, filterFn] of globalThis.__sitemapCollectionFilters.entries())
filterEntries.push(`filters.set(${JSON.stringify(name)}, ${filterFn.toString()})`)
}
const onUrlEntries: string[] = []
if (globalThis.__sitemapCollectionOnUrlFns) {
for (const [name, fn] of globalThis.__sitemapCollectionOnUrlFns.entries())
onUrlEntries.push(`onUrlFns.set(${JSON.stringify(name)}, ${fn.toString()})`)
}
nitroConfig.virtual = nitroConfig.virtual || {}
nitroConfig.virtual['#sitemap/content-filters'] = `export const filters = new Map()\n${filterEntries.join('\n')}`
nitroConfig.virtual['#sitemap/content-on-url'] = `export const onUrlFns = new Map()\n${onUrlEntries.join('\n')}`
})
addServerHandler({
route: '/__sitemap__/nuxt-content-urls.json',
handler: resolve('./runtime/server/routes/__sitemap__/nuxt-content-urls-v3'),
})
if (config.strictNuxtContentPaths) {
logger.warn('You have set `strictNuxtContentPaths: true` but are using @nuxt/content v3. This is not required, please remove it.')
}
appGlobalSources.push({
context: {
name: '@nuxt/content@v3:urls',
description: 'Generated from your markdown files.',
tips: nuxtV3Collections.size
? [`Parsing the following collections: ${Array.from(nuxtV3Collections).join(', ')}`]
: ['No collections found. Make sure your content collections have a `path` field.'],
},
fetch: '/__sitemap__/nuxt-content-urls.json',
})
}
else if (isNuxtContentV2) {
addServerPlugin(resolve('./runtime/server/plugins/nuxt-content-v2'))
addServerHandler({
route: '/__sitemap__/nuxt-content-urls.json',
handler: resolve('./runtime/server/routes/__sitemap__/nuxt-content-urls-v2'),
})
const tips: string[] = []
// @ts-expect-error untyped
if (nuxt.options.content?.documentDriven)
tips.push('Enabled because you\'re using `@nuxt/content` with `documentDriven: true`.')
else if (config.strictNuxtContentPaths)
tips.push('Enabled because you\'ve set `config.strictNuxtContentPaths: true`.')
else
tips.push('You can provide a `sitemap` key in your markdown frontmatter to configure specific URLs. Make sure you include a `loc`.')
appGlobalSources.push({
context: {
name: '@nuxt/content@v2:urls',
description: 'Generated from your markdown files.',
tips,
},
fetch: '/__sitemap__/nuxt-content-urls.json',
})
}
// config -> sitemaps
const sitemaps: ModuleRuntimeConfig['sitemaps'] = {}
if (usingMultiSitemaps) {
addServerHandler({
route: '/sitemap_index.xml',
handler: resolve(`${routesPath}/sitemap_index.xml`),
lazy: true,
middleware: false,
})
if (config.sitemapsPathPrefix && config.sitemapsPathPrefix !== '/') {
addServerHandler({
route: joinURL(config.sitemapsPathPrefix, `/**:sitemap`),
handler: resolve(`${routesPath}/sitemap/[sitemap].xml`),
lazy: true,
middleware: false,
})
}
else {
// when prefix is '/' or false, register individual sitemap routes
// and explicit chunk routes since h3 doesn't support wildcard patterns
const sitemapNames = Object.keys(config.sitemaps || {})
let hasChunkedSitemaps = false
for (const sitemapName of sitemapNames) {
if (sitemapName === 'index')
continue
const sitemapConfig = config.sitemaps![sitemapName as keyof typeof config.sitemaps] as MultiSitemapEntry[string]
// Register the base sitemap route
addServerHandler({
route: withLeadingSlash(`${sitemapName}.xml`),
handler: resolve(`${routesPath}/sitemap/[sitemap].xml`),
lazy: true,
middleware: false,
})
if (sitemapConfig.chunks)
hasChunkedSitemaps = true
}
// For chunked sitemaps, register individual routes for each chunk index
// since h3 doesn't support wildcard patterns like /sitemap-*.xml at root level.
// This is a limitation when using sitemapsPathPrefix: '/' - we pre-register routes
// for up to 20 chunks per sitemap (20,000 URLs with default chunk size of 1000).
// For larger sitemaps, use a different prefix like '/sitemaps/' instead of '/'.
if (hasChunkedSitemaps) {
const maxChunks = 20
for (const sitemapName of sitemapNames) {
if (sitemapName === 'index')
continue
const sitemapConfig = config.sitemaps![sitemapName as keyof typeof config.sitemaps] as MultiSitemapEntry[string]
if (sitemapConfig.chunks) {
for (let i = 0; i < maxChunks; i++) {
addServerHandler({
route: `/${sitemapName}-${i}.xml`,
handler: resolve(`${routesPath}/sitemap/[sitemap].xml`),
lazy: true,
middleware: false,
})
}
}
}
}
}
sitemaps.index = {
sitemapName: 'index',
_route: withBase('sitemap_index.xml', nuxt.options.app.baseURL || '/'),
// @ts-expect-error untyped
sitemaps: [...(config.sitemaps!.index || []), ...(config.appendSitemaps || [])],
}
if (typeof config.sitemaps === 'object') {
for (const sitemapName in config.sitemaps) {
if (sitemapName === 'index')
continue
const definition = config.sitemaps[sitemapName] as MultiSitemapEntry[string]
const sitemapConfig = defu(
{
sitemapName,
_route: withBase(joinURL(config.sitemapsPathPrefix || '', `${sitemapName}.xml`), nuxt.options.app.baseURL || '/'),
_hasSourceChunk: typeof definition.urls !== 'undefined' || definition.sources?.length,
},
{ ...definition, urls: undefined, sources: undefined },
{ include: config.include, exclude: config.exclude },
) as ModuleRuntimeConfig['sitemaps'][string]
// Set up chunking if enabled
if (definition.chunks) {
// Validate chunk configuration
let chunkSize = config.defaultSitemapsChunkSize || 1000
if (typeof definition.chunks === 'number') {
if (definition.chunks <= 0) {
logger.warn(`Invalid chunks value (${definition.chunks}) for sitemap "${sitemapName}". Using default.`)
}
else {
chunkSize = definition.chunks
}
}
if (definition.chunkSize !== undefined) {
if (typeof definition.chunkSize !== 'number' || definition.chunkSize <= 0) {
logger.warn(`Invalid chunkSize value (${definition.chunkSize}) for sitemap "${sitemapName}". Using default.`)
}
else {
chunkSize = definition.chunkSize // chunkSize takes precedence
}
}
sitemapConfig._isChunking = true
sitemapConfig._chunkSize = chunkSize
sitemapConfig.chunks = definition.chunks
sitemapConfig.chunkSize = definition.chunkSize
}
sitemaps[sitemapName as keyof typeof sitemaps] = sitemapConfig
}
}
else {
// we have to register it as a middleware we can't match the URL pattern
sitemaps.chunks = {
sitemapName: 'chunks',
defaults: config.defaults,
include: config.include,
exclude: config.exclude,
includeAppSources: true,
}
}
}
else {
// note: we don't need urls for the root sitemap, only child sitemaps
sitemaps[config.sitemapName] = {
sitemapName: config.sitemapName,
route: withBase(config.sitemapName, nuxt.options.app.baseURL || '/'), // will contain the xml
defaults: config.defaults,
include: config.include,
exclude: config.exclude,
includeAppSources: true,
}
}
// for each sitemap, we need to transform the include and exclude
// if the include or exclude has a URL without a locale prefix, then we insert all locale prefixes
if (resolvedAutoI18n && usingI18nPages && !hasDisabledAutoI18n) {
const pages = nuxtI18nConfig?.pages || {} as Record>
for (const sitemapName in sitemaps) {
if (['index', 'chunks'].includes(sitemapName))
continue
const sitemap = sitemaps[sitemapName]!
function mapToI18nPages(path: FilterInput): FilterInput[] {
if (typeof path !== 'string')
return [path]
const withoutSlashes = withoutTrailingSlash(withoutLeadingSlash(path)).replace('/index', '')
if (pages && withoutSlashes in pages) {
const pageLocales = pages[withoutSlashes]
if (pageLocales) {
return Object.keys(pageLocales).map(localeCode => withLeadingSlash(generatePathForI18nPages({
localeCode,
pageLocales: pageLocales[localeCode] as string,
nuxtI18nConfig,
normalisedLocales,
})))
}
}
let match = [path]
// alternatively see if the path matches the default locale within
Object.values(pages).forEach((pageLocales) => {
// @ts-expect-error untyped
if (pageLocales && nuxtI18nConfig.defaultLocale in pageLocales && pageLocales[nuxtI18nConfig.defaultLocale] === path)
match = Object.keys(pageLocales).map(localeCode => withLeadingSlash(generatePathForI18nPages({ localeCode, pageLocales: pageLocales[localeCode], nuxtI18nConfig, normalisedLocales })))
})
return match
}
sitemap.include = (sitemap.include || []).flatMap(path => mapToI18nPages(path))
sitemap.exclude = (sitemap.exclude || []).flatMap(path => mapToI18nPages(path))
}
}
if (resolvedAutoI18n && resolvedAutoI18n.locales && resolvedAutoI18n.strategy !== 'no_prefix') {
const i18n = resolvedAutoI18n
for (const sitemapName in sitemaps) {
if (['index', 'chunks'].includes(sitemapName))
continue
const sitemap = sitemaps[sitemapName]!
sitemap.include = (sitemap.include || []).map(path => splitPathForI18nLocales(path, i18n)).flat()
sitemap.exclude = (sitemap.exclude || []).map(path => splitPathForI18nLocales(path, i18n)).flat()
}
}
for (const sitemapName in sitemaps) {
const sitemap = sitemaps[sitemapName]!
// we need to normalize the RegExp to a string because of the useRuntimeConfig can't jsonify it
// note: this needs to occur after i18n has extended the rules
sitemap.include = normalizeFilters(sitemap.include)
sitemap.exclude = normalizeFilters(sitemap.exclude)
}
const runtimeConfig: ModuleRuntimeConfig = {
isI18nMapped,
sitemapName: config.sitemapName,
isMultiSitemap: usingMultiSitemaps,
excludeAppSources: config.excludeAppSources,
cacheMaxAgeSeconds: nuxt.options.dev ? 0 : config.cacheMaxAgeSeconds,
autoLastmod: config.autoLastmod,
defaultSitemapsChunkSize: config.defaultSitemapsChunkSize,
minify: config.minify,
sortEntries: config.sortEntries,
debug: config.debug,
// needed for nuxt/content integration and prerendering
discoverImages: config.discoverImages,
discoverVideos: config.discoverVideos,
sitemapsPathPrefix: config.sitemapsPathPrefix,
/* @nuxt/content */
isNuxtContentDocumentDriven,
/* xsl styling */
xsl: config.xsl,
xslTips: config.xslTips,
xslColumns: config.xslColumns,
credits: config.credits,
version: version!,
sitemaps,
}
if (resolvedAutoI18n)
runtimeConfig.autoI18n = resolvedAutoI18n
if (hasDisabledAutoI18n)
runtimeConfig.hasDisabledAutoI18n = true
// Split into a small dynamic slice (kept in runtimeConfig for env-var overrides)
// and a large static slice (emitted as a virtual module). Nitro deep-clones the
// entire runtimeConfig on the first useRuntimeConfig(event) per request, so anything
// sitting in there is per-request overhead for every route in the app, not just sitemap routes.
const dynamicRuntimeConfig = {
cacheMaxAgeSeconds: runtimeConfig.cacheMaxAgeSeconds,
debug: runtimeConfig.debug,
}
// cacheMaxAgeSeconds is duplicated: dynamic copy lets users override the HTTP cache header via
// env vars at runtime; static copy is read at server startup to size the in-memory cache layer
// (defineCachedFunction takes maxAge as a static option, not a runtime callback).
const { debug: _d, ...staticRuntimeConfig } = runtimeConfig
// @ts-expect-error untyped
nuxt.options.runtimeConfig.sitemap = dynamicRuntimeConfig
nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.virtual = nitroConfig.virtual || {}
nitroConfig.virtual['#sitemap-virtual/static-config.mjs']
= `export default ${JSON.stringify(staticRuntimeConfig)}`
})
// debug endpoints - skip in production zeroRuntime as they pull in full sitemap code
if ((config.debug || nuxt.options.dev) && !(config.zeroRuntime && !nuxt.options.dev)) {
addServerHandler({
route: '/__sitemap__/debug.json',
handler: resolve('./runtime/server/routes/__sitemap__/debug'),
})
if (nuxt.options.dev) {
addServerHandler({
route: '/__sitemap__/debug-production.json',
handler: resolve('./runtime/server/routes/__sitemap__/debug-production'),
})
}
// Register handlers for all sitemaps in dev/debug mode
if (usingMultiSitemaps) {
addServerHandler({
route: '/__sitemap__/**:sitemap',
handler: resolve('./runtime/server/routes/sitemap/[sitemap].xml'),
lazy: true,
middleware: true,
})
}
setupDevToolsUI(config, resolve)
}
const imports: typeof nuxt.options.imports.imports = [
{
from: resolve('./runtime/server/composables/defineSitemapEventHandler'),
name: 'defineSitemapEventHandler',
},
{
from: resolve('./runtime/server/composables/asSitemapUrl'),
name: 'asSitemapUrl',
},
]
addServerImports(imports)
// we may not have pages
const pagesPromise = createPagesPromise()
const nitroPromise = createNitroPromise()
let resolvedConfigUrls = false
const isValidPrerenderRoute = (r: any) => {
// avoid adding fallback pages to sitemap
if (['/200.html', '/404.html', '/index.html'].includes(r.route) || r.error || isPathFile(r.route))
return false
return r.contentType?.includes('text/html')
}
const generateGlobalSources = async () => {
const { routeRules } = generateExtraRoutesFromNuxtConfig()
const nitro = await nitroPromise
const prerenderedRoutes = nitro._prerenderedRoutes || []
const prerenderUrlsFinal = [
...prerenderedRoutes
.filter(isValidPrerenderRoute)
.map(r => r._sitemap)
.filter(entry => entry && (typeof entry === 'string' || entry._sitemap !== false)),
]
if (config.debug) {
logger.info('Prerendered routes:', prerenderUrlsFinal)
}
const pageSource = convertNuxtPagesToSitemapEntries(await pagesPromise, {
isI18nMapped,
autoLastmod: config.autoLastmod,
defaultLocale: nuxtI18nConfig.defaultLocale || 'en',
strategy: nuxtI18nConfig.strategy || 'no_prefix',
routesNameSeparator: nuxtI18nConfig.routesNameSeparator,
normalisedLocales,
filter: {
include: normalizeFilters(config.include) as (string | RegExp)[],
exclude: normalizeFilters(config.exclude) as (string | RegExp)[],
},
isI18nMicro: i18nModule === 'nuxt-i18n-micro',
autoI18n: !!resolvedAutoI18n,
})
if (!pageSource.length) {
pageSource.push(nuxt.options.app.baseURL || '/')
}
// Dedupe: remove pages that were prerendered (prerender data takes precedence)
// but merge page meta sitemap data (from definePageMeta) into prerendered entries
const allPrerenderedPaths = new Set(
prerenderedRoutes
.filter(isValidPrerenderRoute)
.map(r => r.route),
)
const pageSourceByPath = new Map()
for (const p of pageSource) {
if (typeof p !== 'string' && p.loc)
pageSourceByPath.set(p.loc, p)
}
// merge definePageMeta sitemap data into prerendered entries
for (let i = 0; i < prerenderUrlsFinal.length; i++) {
const entry = prerenderUrlsFinal[i]
if (!entry || typeof entry === 'string')
continue
const pageEntry = pageSourceByPath.get(entry.loc)
if (pageEntry && typeof pageEntry !== 'string') {
prerenderUrlsFinal[i] = defu(entry, pageEntry) as typeof entry
}
}
const dedupedPageSource = pageSource.filter((p) => {
const path = typeof p === 'string' ? p : p.loc
return !allPrerenderedPaths.has(path)
})
if (!resolvedConfigUrls && config.urls) {
const urls = await resolveUrls(config.urls, { path: 'sitemap:urls', logger })
if (urls.length) {
userGlobalSources.push({
context: {
name: 'sitemap:urls',
description: 'Set with the `sitemap.urls` config.',
},
urls,
})
}
resolvedConfigUrls = true
}
const globalSources: SitemapSourceInput[] = [
...userGlobalSources.map((s) => {
if (typeof s === 'string' || Array.isArray(s)) {
return {
sourceType: 'user',
fetch: s,
}
}
s.sourceType = 'user'
return s
}),
...(config.excludeAppSources === true
? []
: [
...appGlobalSources,
{
context: {
name: 'nuxt:pages',
description: 'Generated from your static page files.',
tips: [
'Can be disabled with `{ excludeAppSources: [\'nuxt:pages\'] }`.',
],
},
urls: dedupedPageSource,
},
{
context: {
name: 'nuxt:route-rules',
description: 'Generated from your route rules config.',
tips: [
'Can be disabled with `{ excludeAppSources: [\'nuxt:route-rules\'] }`.',
],
},
urls: routeRules,
},
{
context: {
name: 'nuxt:prerender',
description: 'Generated at build time when prerendering.',
tips: [
'Can be disabled with `{ excludeAppSources: [\'nuxt:prerender\'] }`.',
],
},
urls: prerenderUrlsFinal,
},
])
.filter(s =>
!(config.excludeAppSources as AppSourceContext[]).includes(s.context.name as AppSourceContext)
&& (!!s.urls?.length || !!s.fetch))
.map((s) => {
s.sourceType = 'app'
return s
}),
]
return globalSources
}
const extraSitemapModules = typeof config.sitemaps == 'object' ? Object.keys(config.sitemaps).filter(n => n !== 'index') : []
const sitemapSources: Record = {}
const generateChildSources = async () => {
for (const sitemapName of extraSitemapModules) {
sitemapSources[sitemapName] = sitemapSources[sitemapName] || []
const definition = (config.sitemaps as Record)[sitemapName] as SitemapDefinition
if (!sitemapSources[sitemapName].length) {
if (definition.urls) {
sitemapSources[sitemapName].push({
context: {
name: `sitemaps:${sitemapName}:urls`,
description: 'Set with the `sitemap.urls` config.',
},
urls: await resolveUrls(definition.urls, { path: `sitemaps:${sitemapName}:urls`, logger }),
})
}
sitemapSources[sitemapName].push(...(definition.sources || [])
.map((s) => {
if (typeof s === 'string' || Array.isArray(s)) {
return {
sourceType: 'user',
fetch: s,
}
}
s.sourceType = 'user'
return s
}),
)
}
}
return sitemapSources
}
nuxt.hooks.hook('nitro:config', (nitroConfig) => {
nitroConfig.virtual = nitroConfig.virtual || {}
// Always provide read-sources module stub (real implementation added by prerender.ts when needed)
if (!nitroConfig.virtual['#sitemap-virtual/read-sources.mjs']) {
nitroConfig.virtual['#sitemap-virtual/read-sources.mjs'] = `
export async function readSourcesFromFilesystem() {
return null
}
`
}
// Skip virtual templates when prerendering - sources are written to filesystem instead
// In dev mode, always generate sources even if prerenderSitemap is true (e.g. zeroRuntime)
if (prerenderSitemap && !nuxt.options.dev) {
nitroConfig.virtual['#sitemap-virtual/global-sources.mjs'] = `export const sources = []`
nitroConfig.virtual[`#sitemap-virtual/child-sources.mjs`] = `export const sources = {}`
}
else {
// Virtual templates generate sources data - will be cached in storage on first use
nitroConfig.virtual['#sitemap-virtual/global-sources.mjs'] = async () => {
const globalSources = await generateGlobalSources()
return `export const sources = ${JSON.stringify(globalSources, null, 4)}`
}
nitroConfig.virtual![`#sitemap-virtual/child-sources.mjs`] = async () => {
const childSources = await generateChildSources()
return `export const sources = ${JSON.stringify(childSources, null, 4)}`
}
}
})
// always add the styles
if (config.xsl === '/__sitemap__/style.xsl') {
addServerHandler({
route: config.xsl,
handler: resolve('./runtime/server/routes/sitemap.xsl'),
})
config.xsl = withBase(config.xsl, nuxt.options.app.baseURL)
if (prerenderSitemap)
addPrerenderRoutes(config.xsl)
}
// either this will redirect to sitemap_index or will render the main sitemap.xml
addServerHandler({
route: `/${config.sitemapName}`,
handler: resolve(`${routesPath}/sitemap.xml`),
})
setupPrerenderHandler({ runtimeConfig, logger, generateGlobalSources, generateChildSources, prerenderSitemap })
// suggest zeroRuntime when no dynamic sources detected
if (!config.zeroRuntime && !nuxt.options.dev && !nuxt.options._prepare) {
const hasDynamicSource = (source: SitemapSourceInput) =>
typeof source === 'string' || Array.isArray(source) || !!(source as SitemapSourceBase).fetch
const globalHasFetch = (config.sources || []).some(hasDynamicSource)
const sitemapsHaveFetch = typeof config.sitemaps === 'object'
&& Object.values(config.sitemaps).some(s => s && 'sources' in s && (s.sources || []).some(hasDynamicSource))
if (!globalHasFetch && !sitemapsHaveFetch)
logger.info('No dynamic sources detected. Consider enabling `zeroRuntime` to reduce server bundle size. See https://nuxtseo.com/sitemap/guides/zero-runtime')
}
},
})
================================================
FILE: src/prerender.ts
================================================
import type { Nuxt } from '@nuxt/schema'
import type { ConsolaInstance } from 'consola'
import type { Nitro, PrerenderRoute } from 'nitropack'
import type { ModuleRuntimeConfig, SitemapUrl } from './runtime/types'
import { readFileSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { useNuxt } from '@nuxt/kit'
import { colors } from 'consola/utils'
import { defu } from 'defu'
import { withSiteUrl } from 'nuxt-site-config/kit'
import { dirname } from 'pathe'
import { withBase } from 'ufo'
import { splitForLocales } from './runtime/utils-pure'
import { isNuxtGenerate } from './utils-internal/kit'
import { parseHtmlExtractSitemapMeta } from './utils/parseHtmlExtractSitemapMeta'
function formatPrerenderRoute(route: PrerenderRoute) {
let str = ` ├─ ${route.route} (${route.generateTimeMS}ms)`
if (route.error) {
const errorColor = colors[route.error.statusCode === 404 ? 'yellow' : 'red']
const errorLead = '└──'
str += `\n │ ${errorLead} ${errorColor(route.error.message)}`
}
return colors.gray(str)
}
export function includesSitemapRoot(sitemapName: string, routes: string[]) {
return routes.includes(`/__sitemap__/`) || routes.includes(`/sitemap.xml`) || routes.includes(`/${sitemapName}`) || routes.includes('/sitemap_index.xml')
}
const NuxtRedirectHtmlRegex = / <\/head><\/html>/ // eslint-disable-line regexp/no-unused-capturing-group
export function setupPrerenderHandler(_options: { runtimeConfig: ModuleRuntimeConfig, logger: ConsolaInstance, generateGlobalSources: () => Promise, generateChildSources: () => Promise, prerenderSitemap: boolean }, nuxt: Nuxt = useNuxt()) {
const { runtimeConfig: options, logger, generateGlobalSources, generateChildSources, prerenderSitemap } = _options
nuxt.options.nitro.prerender = nuxt.options.nitro.prerender || {}
nuxt.options.nitro.prerender.routes = nuxt.options.nitro.prerender.routes || []
const shouldHookIntoPrerender = prerenderSitemap || (nuxt.options.nitro.prerender.routes.length && nuxt.options.nitro.prerender.crawlLinks)
if (isNuxtGenerate() && options.debug) {
nuxt.options.nitro.prerender.routes.push('/__sitemap__/debug.json')
logger.info('Adding debug route for sitemap generation:', colors.cyan('/__sitemap__/debug.json'))
}
// need to filter it out of the config as we render it after all other routes
if (!shouldHookIntoPrerender) {
return
}
nuxt.options.nitro.prerender.routes = nuxt.options.nitro.prerender.routes.filter(r => r && !includesSitemapRoot(options.sitemapName, [r]))
const runtimeAssetsPath = join(nuxt.options.rootDir, 'node_modules/.cache/nuxt/sitemap')
// Setup virtual module for reading sources - must be in nitro:config to be bundled
nuxt.hooks.hook('nitro:config', (nitroConfig) => {
nitroConfig.virtual = nitroConfig.virtual || {}
nitroConfig.virtual['#sitemap-virtual/read-sources.mjs'] = `
import { readFile } from 'node:fs/promises'
import { join } from 'pathe'
export async function readSourcesFromFilesystem(filename) {
if (!import.meta.prerender) {
return null
}
const path = join(${JSON.stringify(runtimeAssetsPath)}, filename)
const data = await readFile(path, 'utf-8').catch(() => null)
return data ? JSON.parse(data) : null
}
`
})
nuxt.hooks.hook('nitro:init', async (nitro) => {
nitro.hooks.hook('prerender:generate', async (route) => {
const html = route.contents
// extract alternatives from the html
if (!route.fileName?.endsWith('.html') || !html || ['/200.html', '/404.html'].includes(route.route))
return
// ignore redirects
if (NuxtRedirectHtmlRegex.test(html)) {
return
}
const extractedMeta = parseHtmlExtractSitemapMeta(html, {
images: options.discoverImages,
videos: options.discoverVideos,
// TODO configurable?
lastmod: true,
// when autoI18n is enabled, let the sitemap builder generate alternatives
// based on i18n config instead of extracting from HTML (which can be incomplete)
// when autoI18n is explicitly disabled, don't extract alternatives from HTML at all
alternatives: !options.autoI18n && !options.hasDisabledAutoI18n,
resolveUrl(s) {
// if the match is relative
return s.startsWith('/') ? withSiteUrl(s) : s
},
})
// skip if route is blocked from indexing
if (extractedMeta === null) {
route._sitemap = {
loc: route.route,
_sitemap: false,
}
return
}
// maybe the user already provided a _sitemap on the route
route._sitemap = defu(route._sitemap, {
loc: route.route,
})
// we need to figure out which sitemap this belongs to
if (options.autoI18n && Object.keys(options.sitemaps).length > 1) {
const path = route.route
const match = splitForLocales(path, options.autoI18n.locales.map(l => l.code))
// if it's missing a locale then we put it in the default locale sitemap
const locale = match[0] || options.autoI18n.defaultLocale
if (options.isI18nMapped) {
const { _sitemap } = options.autoI18n.locales.find(l => l.code === locale) || { _sitemap: locale }
// this will filter the results to only the sitemap that matches the locale
route._sitemap._sitemap = _sitemap
}
}
route._sitemap = defu(extractedMeta, route._sitemap) as SitemapUrl
})
nitro.hooks.hook('prerender:done', async () => {
const globalSources = await generateGlobalSources()
const childSources = await generateChildSources()
// Write to filesystem for prerender consumption
await mkdir(runtimeAssetsPath, { recursive: true })
await writeFile(join(runtimeAssetsPath, 'global-sources.json'), JSON.stringify(globalSources))
await writeFile(join(runtimeAssetsPath, 'child-sources.json'), JSON.stringify(childSources))
const sitemapEntry = options.isMultiSitemap
? '/sitemap_index.xml' // this route adds prerender hints for child sitemaps
: `/${Object.keys(options.sitemaps)[0]}`
const sitemaps = await prerenderSitemapsFromEntry(nitro, sitemapEntry)
await nuxt.hooks.callHook('sitemap:prerender:done' as any, { options, sitemaps })
})
})
}
async function prerenderSitemapsFromEntry(nitro: Nitro, entry: string) {
const sitemaps: { name: string, get content(): string }[] = []
const queue = [entry]
const processed = new Set()
while (queue.length) {
const route = queue.shift()!
if (processed.has(route))
continue
processed.add(route)
const { filePath, prerenderUrls } = await prerenderRoute(nitro, route)
sitemaps.push({
name: route,
get content() {
return readFileSync(filePath, { encoding: 'utf8' })
},
})
queue.push(...prerenderUrls)
}
return sitemaps
}
export async function prerenderRoute(nitro: Nitro, route: string) {
const start = Date.now()
const _route: PrerenderRoute = { route, fileName: route }
const encodedRoute = encodeURI(route)
const fetchUrl = withBase(encodedRoute, nitro.options.baseURL)
const res = await globalThis.$fetch.raw(
fetchUrl,
{
headers: { 'x-nitro-prerender': encodedRoute },
retry: nitro.options.prerender.retry,
retryDelay: nitro.options.prerender.retryDelay,
},
)
const header = (res.headers.get('x-nitro-prerender') || '') as string
const prerenderUrls = header
.split(',')
.map(i => decodeURIComponent(i.trim()))
.filter(Boolean)
const filePath = join(nitro.options.output.publicDir, _route.fileName!)
await mkdir(dirname(filePath), { recursive: true })
const data = res._data
if (data === undefined)
throw new Error(`No data returned from '${fetchUrl}'`)
const content = filePath.endsWith('json') || typeof data === 'object'
? JSON.stringify(data)
: data as string
await writeFile(filePath, content, 'utf8')
_route.generateTimeMS = Date.now() - start
nitro._prerenderedRoutes!.push(_route)
nitro.logger.log(formatPrerenderRoute(_route))
return { filePath, prerenderUrls }
}
================================================
FILE: src/runtime/server/composables/asSitemapUrl.ts
================================================
import type { SitemapUrlInput } from '../../types'
export function asSitemapUrl(url: SitemapUrlInput | Record): SitemapUrlInput {
return url as SitemapUrlInput
}
================================================
FILE: src/runtime/server/composables/defineSitemapEventHandler.ts
================================================
import type { EventHandlerRequest, EventHandlerResponse } from 'h3'
import type { SitemapUrlInput } from '../../types'
import { defineEventHandler } from 'h3'
export const defineSitemapEventHandler: typeof defineEventHandler> = defineEventHandler
================================================
FILE: src/runtime/server/content-compat.ts
================================================
// @ts-expect-error untyped
import { queryCollectionWithEvent } from '#sitemap/content-v3-nitro-path'
export const queryCollection = queryCollectionWithEvent
================================================
FILE: src/runtime/server/kit.ts
================================================
import type { NitroRouteRules } from 'nitropack'
import { defu } from 'defu'
import { useRuntimeConfig } from 'nitropack/runtime'
import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
import { parseURL, withoutBase, withoutTrailingSlash } from 'ufo'
function withoutQuery(path: string): string {
return path.split('?')[0]!
}
export function createNitroRouteRuleMatcher(): (pathOrUrl: string) => NitroRouteRules {
const { nitro, app } = useRuntimeConfig()
const _routeRulesMatcher = toRouteMatcher(
createRadixRouter({
routes: Object.fromEntries(
Object.entries(nitro?.routeRules || {})
.map(([path, rules]) => [withoutTrailingSlash(path), rules]),
),
}),
)
return (pathOrUrl: string) => {
const path = pathOrUrl[0] === '/' ? pathOrUrl : parseURL(pathOrUrl, app.baseURL).pathname
return defu({}, ..._routeRulesMatcher.matchAll(
withoutBase(withoutTrailingSlash(withoutQuery(path)), app.baseURL),
).reverse()) as NitroRouteRules
}
}
================================================
FILE: src/runtime/server/plugins/compression.ts
================================================
import type { H3Event } from 'h3'
import { getRequestHeader, setResponseHeader } from 'h3'
import { defineNitroPlugin } from 'nitropack/runtime'
function getPreferredEncoding(event: H3Event): 'gzip' | 'deflate' | null {
const acceptEncoding = getRequestHeader(event, 'accept-encoding') || ''
if (acceptEncoding.includes('gzip'))
return 'gzip'
if (acceptEncoding.includes('deflate'))
return 'deflate'
return null
}
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('beforeResponse', async (event, response) => {
if (!event.context._isSitemap || !response.body)
return
const encoding = getPreferredEncoding(event)
if (!encoding)
return
const body = typeof response.body === 'string' ? response.body : JSON.stringify(response.body)
const stream = new Blob([body]).stream().pipeThrough(new CompressionStream(encoding))
response.body = Buffer.from(await new Response(stream).arrayBuffer())
setResponseHeader(event, 'Content-Encoding', encoding)
})
})
================================================
FILE: src/runtime/server/plugins/nuxt-content-v2.ts
================================================
import type { NitroApp } from 'nitropack/types'
import type { SitemapUrl } from '../../types'
import { defu } from 'defu'
import { defineNitroPlugin } from 'nitropack/runtime'
import { useSitemapRuntimeConfig } from '../utils'
interface NuxtContentDocument {
sitemap?: Partial | boolean
_draft?: boolean
_extension?: string
_partial?: boolean
_path?: string
path?: string
robots?: boolean
body?: {
children?: Array<{ tag?: string, props?: { src?: string } }>
}
modifiedAt?: string | Date
updatedAt?: string | Date
}
export default defineNitroPlugin((nitroApp: NitroApp) => {
const { discoverImages, isNuxtContentDocumentDriven } = useSitemapRuntimeConfig()
// @ts-expect-error untyped hook
nitroApp.hooks.hook('content:file:afterParse', async (content: NuxtContentDocument) => {
const validExtensions = ['md', 'mdx']
if (content.sitemap === false || content._draft || !validExtensions.includes(content._extension || '') || content._partial || content.robots === false)
return
// add any top level images
let images: SitemapUrl['images'] = []
if (discoverImages) {
const children = content.body?.children || []
images = children
.filter(c => c.tag && c.props?.src && ['image', 'img', 'nuxtimg', 'nuxt-img'].includes(c.tag.toLowerCase()))
.map(i => ({ loc: i.props!.src! }))
}
const sitemapConfig: Partial = typeof content.sitemap === 'object' ? content.sitemap : {}
const lastmod = content.modifiedAt || content.updatedAt
const defaults: Partial = {}
if (isNuxtContentDocumentDriven && typeof content._path === 'string')
defaults.loc = content._path
if (typeof content.path === 'string') // automatically set when document driven
defaults.loc = content.path
if (images?.length)
defaults.images = images
if (typeof lastmod === 'string' || lastmod instanceof Date)
defaults.lastmod = lastmod
const definition = defu(sitemapConfig, defaults) as Partial
if (!definition.loc) {
// user hasn't provided a loc... lets fallback to a relative path
if (typeof content.path === 'string' && content.path.startsWith('/'))
definition.loc = content.path
// otherwise let's warn them
if (Object.keys(sitemapConfig).length > 0 && import.meta.dev)
console.warn(`[@nuxtjs/content] The @nuxt/content file \`${content._path}\` is missing a sitemap \`loc\`.`)
}
content.sitemap = definition
// loc is required
if (!definition.loc)
delete content.sitemap
return content
})
})
================================================
FILE: src/runtime/server/plugins/warm-up.ts
================================================
import { defineNitroPlugin } from 'nitropack/runtime'
import { joinURL, withLeadingSlash } from 'ufo'
import { useSitemapRuntimeConfig } from '../utils'
export default defineNitroPlugin((nitroApp) => {
const { sitemaps, sitemapsPathPrefix } = useSitemapRuntimeConfig()
const queue: (() => Promise)[] = []
const timeoutIds: NodeJS.Timeout[] = []
const enqueue = (path: string) => {
queue.push(() => nitroApp.localFetch(withLeadingSlash(path), {}))
}
for (const [name, sitemap] of Object.entries(sitemaps)) {
if (!sitemap._route)
continue
if (name === 'index') {
enqueue(sitemap._route)
continue
}
// Chunked sitemaps don't expose the base route — the catch-all serves a non-chunked variant
// that bypasses chunk slicing. Warm chunk-0 instead so the shared resolved-URLs cache is
// populated with the correct filter pass; sibling chunk requests then hit that cache.
const def = sitemap as { chunks?: unknown, _isChunking?: boolean, _route: string }
if (def.chunks || def._isChunking) {
enqueue(joinURL(sitemapsPathPrefix || '/', `${name}-0.xml`))
}
else {
enqueue(sitemap._route)
}
}
// run async
const initialTimeout = setTimeout(() => {
// work the queue step by step await the promise from each task, delay 1s after each task ends
const next = async () => {
if (queue.length === 0) {
// Clear timeout references when done
timeoutIds.length = 0
return
}
try {
await queue.shift()!()
}
catch (error) {
console.error('[sitemap:warm-up] Error warming up sitemap:', error)
}
// Only schedule next if we have more items
if (queue.length > 0) {
const nextTimeout = setTimeout(next, 1000) // arbitrary delay to avoid throttling
timeoutIds.push(nextTimeout)
}
}
next()
}, 2500 /* https://github.com/unjs/nitro/pull/1906 */)
timeoutIds.push(initialTimeout)
// Clean up on app shutdown
nitroApp.hooks.hook('close', () => {
// Clear all pending timeouts
timeoutIds.forEach(id => clearTimeout(id))
timeoutIds.length = 0
queue.length = 0
})
})
================================================
FILE: src/runtime/server/robots-polyfill/getPathRobotConfig.ts
================================================
import type { H3Event } from 'h3'
export function getPathRobotConfig(_e: H3Event, _options: any) {
return { indexable: true, rule: 'index, follow' }
}
================================================
FILE: src/runtime/server/routes/__sitemap__/debug-production.ts
================================================
import type { SitemapWarning } from '@nuxtjs/sitemap/utils'
import { isSitemapIndex, parseSitemapIndex, parseSitemapXml } from '@nuxtjs/sitemap/utils'
import { defineEventHandler, getQuery } from 'h3'
export interface ProductionSitemapEntry {
loc: string
urlCount: number
warnings: SitemapWarning[]
error?: string
lastmod?: string
}
export interface ProductionDebugResponse {
url: string
isIndex: boolean
sitemaps: ProductionSitemapEntry[]
warnings: SitemapWarning[]
error?: string
}
async function fetchXml(url: string): Promise {
const response = await fetch(url, {
headers: { Accept: 'application/xml, text/xml' },
signal: AbortSignal.timeout(15000),
})
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
return response.text()
}
export default defineEventHandler(async (e): Promise> => {
const { url, mode } = getQuery(e) as { url?: string, mode?: string }
if (!url || typeof url !== 'string')
return { url: '', isIndex: false, sitemaps: [], warnings: [], error: 'Missing url query parameter' }
// Try fetching the production debug.json endpoint (requires debug: true in production config)
if (mode === 'debug') {
const debugUrl = `${url.replace(/\/$/, '')}/__sitemap__/debug.json`
const response = await fetch(debugUrl, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
}).catch(() => null)
if (response?.ok) {
const json = await response.json().catch(() => null)
if (json?.sitemaps)
return json
}
// Fall through to XML-based approach
}
// Determine the sitemap URL to fetch
const sitemapUrl = url.endsWith('/') ? `${url}sitemap.xml` : url
const xml = await fetchXml(sitemapUrl).catch((err: Error) => {
return err
})
if (xml instanceof Error)
return { url: sitemapUrl, isIndex: false, sitemaps: [], warnings: [], error: `Failed to fetch sitemap: ${xml.message}` }
if (isSitemapIndex(xml)) {
const { entries, warnings } = await parseSitemapIndex(xml)
const sitemaps: ProductionSitemapEntry[] = await Promise.all(
entries.map(async (entry) => {
const childXml = await fetchXml(entry.loc).catch((err: Error) => err)
if (childXml instanceof Error) {
return {
loc: entry.loc,
urlCount: 0,
warnings: [],
error: childXml.message,
lastmod: entry.lastmod,
}
}
const result = await parseSitemapXml(childXml).catch((err: Error) => ({
urls: [],
warnings: [{ type: 'validation' as const, message: err.message }],
}))
return {
loc: entry.loc,
urlCount: result.urls.length,
warnings: result.warnings,
lastmod: entry.lastmod,
}
}),
)
return { url: sitemapUrl, isIndex: true, sitemaps, warnings }
}
// Single sitemap
const result = await parseSitemapXml(xml).catch((err: Error) => ({
urls: [],
warnings: [{ type: 'validation' as const, message: err.message }],
}))
return {
url: sitemapUrl,
isIndex: false,
sitemaps: [{
loc: sitemapUrl,
urlCount: result.urls.length,
warnings: result.warnings,
}],
warnings: [],
}
})
================================================
FILE: src/runtime/server/routes/__sitemap__/debug.ts
================================================
import type { SitemapDefinition, SitemapSourceResolved } from '../../../types'
import { defineEventHandler } from 'h3'
import { getNitroOrigin, getSiteConfig } from '#site-config/server/composables'
import { validateSitemapUrl } from '../../sitemap/urlset/normalise'
import {
childSitemapSources,
globalSitemapSources,
resolveSitemapSources,
} from '../../sitemap/urlset/sources'
import { useSitemapRuntimeConfig } from '../../utils'
function attachUrlWarnings(sources: SitemapSourceResolved[]) {
for (const source of sources) {
if (!source.urls?.length)
continue
const warnings: SitemapSourceResolved['_urlWarnings'] = []
for (const url of source.urls) {
const msgs = validateSitemapUrl(url)
if (msgs.length) {
const loc = typeof url === 'string' ? url : (url.loc || '')
for (const message of msgs)
warnings.push({ loc, message })
}
}
if (warnings.length)
source._urlWarnings = warnings
}
return sources
}
export default defineEventHandler(async (e) => {
const _runtimeConfig = useSitemapRuntimeConfig()
const siteConfig = getSiteConfig(e)
const { sitemaps: _sitemaps } = _runtimeConfig
const runtimeConfig = { ..._runtimeConfig }
// @ts-expect-error hack
delete runtimeConfig.sitemaps
const globalSources = await globalSitemapSources()
const nitroOrigin = getNitroOrigin(e)
const sitemaps: Record = {}
for (const s of Object.keys(_sitemaps)) {
const sitemap = _sitemaps[s]!
// resolve the sources
sitemaps[s] = {
...sitemap,
sources: attachUrlWarnings(await resolveSitemapSources(await childSitemapSources(sitemap), e)),
} as SitemapDefinition
}
return {
nitroOrigin,
sitemaps,
runtimeConfig,
globalSources: attachUrlWarnings(await resolveSitemapSources(globalSources, e)),
siteConfig: { ...siteConfig },
}
})
================================================
FILE: src/runtime/server/routes/__sitemap__/nuxt-content-urls-v2.ts
================================================
import { defineEventHandler } from 'h3'
// @ts-expect-error alias module
import { serverQueryContent } from '#content/server'
interface ContentWithSitemap {
sitemap?: unknown
}
export default defineEventHandler(async (e) => {
const contentList = (await serverQueryContent(e).find()) as ContentWithSitemap[]
return contentList.map(c => c.sitemap).filter(Boolean)
})
================================================
FILE: src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts
================================================
import { queryCollection } from '@nuxt/content/server'
import { defineEventHandler } from 'h3'
import manifest from '#content/manifest'
import { filters } from '#sitemap/content-filters'
import { onUrlFns } from '#sitemap/content-on-url'
interface ContentEntry {
path?: string
sitemap?: object | boolean
}
export default defineEventHandler(async (e) => {
const collections: string[] = []
// each collection in the manifest has a key => with fields which has a `sitemap`, we want to get all those
for (const collection in manifest) {
// @ts-expect-error nuxt content v3
if (manifest[collection].fields.sitemap)
collections.push(collection)
}
// now we need to handle multiple queries here, we want to run the requests in parallel
const contentList: Promise<{ collection: string, entries: ContentEntry[] }>[] = []
for (const collection of collections) {
const needsAllFields = filters?.has(collection) || onUrlFns?.has(collection)
// @ts-expect-error dynamic collection name
const query = queryCollection(e, collection)
.where('path', 'IS NOT NULL')
.where('sitemap', 'IS NOT NULL')
// only select specific fields if no filter/onUrl, otherwise get all fields
if (!needsAllFields)
// @ts-expect-error dynamic field names
query.select('path', 'sitemap')
contentList.push(
query.all()
.then((results) => {
// apply runtime filter if available
const filter = filters?.get(collection)
return { collection, entries: filter ? results.filter(filter) : results }
}),
)
}
// we need to wait for all the queries to finish
const results = await Promise.all(contentList)
// we need to flatten the results
return results
.flatMap(({ collection, entries }) => {
const onUrl = onUrlFns?.get(collection)
return entries
.filter(c => c.sitemap !== false && c.path && !c.path.endsWith('.navigation'))
.map((c) => {
const url: Record = {
loc: c.path,
...(typeof c.sitemap === 'object' ? c.sitemap : {}),
}
onUrl?.(url, c, collection)
return url
})
})
.filter(Boolean)
})
================================================
FILE: src/runtime/server/routes/__zero-runtime/sitemap/[sitemap].xml.ts
================================================
import { createError, defineEventHandler } from 'h3'
export default defineEventHandler(async (e) => {
if (import.meta.dev || import.meta.prerender) {
const { sitemapChildXmlEventHandler } = await import('../../../sitemap/event-handlers')
return sitemapChildXmlEventHandler(e)
}
throw createError({ statusCode: 500, message: 'Sitemap not prerendered. zeroRuntime requires prerendering.' })
})
================================================
FILE: src/runtime/server/routes/__zero-runtime/sitemap.xml.ts
================================================
import { createError, defineEventHandler } from 'h3'
export default defineEventHandler(async (e) => {
if (import.meta.dev || import.meta.prerender) {
const { sitemapXmlEventHandler } = await import('../../sitemap/event-handlers')
return sitemapXmlEventHandler(e)
}
throw createError({ statusCode: 500, message: 'Sitemap not prerendered. zeroRuntime requires prerendering.' })
})
================================================
FILE: src/runtime/server/routes/__zero-runtime/sitemap_index.xml.ts
================================================
import { createError, defineEventHandler } from 'h3'
export default defineEventHandler(async (e) => {
if (import.meta.dev || import.meta.prerender) {
const { sitemapIndexXmlEventHandler } = await import('../../sitemap/event-handlers')
return sitemapIndexXmlEventHandler(e)
}
throw createError({ statusCode: 500, message: 'Sitemap not prerendered. zeroRuntime requires prerendering.' })
})
================================================
FILE: src/runtime/server/routes/sitemap/[sitemap].xml.ts
================================================
import { defineEventHandler } from 'h3'
import { sitemapChildXmlEventHandler } from '../../sitemap/event-handlers'
export default defineEventHandler(sitemapChildXmlEventHandler)
================================================
FILE: src/runtime/server/routes/sitemap.xml.ts
================================================
import { defineEventHandler } from 'h3'
import { sitemapXmlEventHandler } from '../sitemap/event-handlers'
export default defineEventHandler(sitemapXmlEventHandler)
================================================
FILE: src/runtime/server/routes/sitemap.xsl.ts
================================================
import { defineEventHandler, getHeader, getQuery as h3GetQuery, setHeader } from 'h3'
import { getQuery, parseURL, withQuery } from 'ufo'
import { getSiteConfig } from '#site-config/server/composables'
import { createSitePathResolver } from '#site-config/server/composables/utils'
import { useSitemapRuntimeConfig, xmlEscape } from '../utils'
export default defineEventHandler(async (e) => {
const fixPath = createSitePathResolver(e, { absolute: false, withBase: true })
const { sitemapName: fallbackSitemapName, cacheMaxAgeSeconds, version, xslColumns, xslTips } = useSitemapRuntimeConfig()
setHeader(e, 'Content-Type', 'application/xslt+xml')
if (cacheMaxAgeSeconds)
setHeader(e, 'Cache-Control', `public, max-age=${cacheMaxAgeSeconds}, must-revalidate`)
else
setHeader(e, 'Cache-Control', `no-cache, no-store`)
const { name: siteName, url: siteUrl } = getSiteConfig(e)
const referrer = getHeader(e, 'Referer')! || '/'
const referrerPath = parseURL(referrer).pathname
const isNotIndexButHasIndex = referrerPath !== '/sitemap.xml' && referrerPath !== '/sitemap_index.xml' && referrerPath.endsWith('.xml')
const sitemapName = parseURL(referrer).pathname.split('/').pop()?.split('-sitemap')[0] || fallbackSitemapName
const title = `${siteName}${sitemapName !== 'sitemap.xml' ? ` - ${sitemapName === 'sitemap_index.xml' ? 'index' : sitemapName}` : ''}`.replace(/&/g, '&')
const isIndexPage = referrerPath === '/sitemap.xml' || referrerPath === '/sitemap_index.xml'
const canonicalQuery = getQuery(referrer).canonical
const isShowingCanonical = typeof canonicalQuery !== 'undefined' && canonicalQuery !== 'false'
// Build action URLs
const debugUrl = xmlEscape(withQuery('/__sitemap__/debug.json', { sitemap: sitemapName }))
const devUrl = xmlEscape(referrerPath)
const prodUrl = xmlEscape(withQuery(referrerPath, { canonical: '' }))
// Fetch errors from query params
const fetchErrors: string[] = []
const xslQuery = h3GetQuery(e)
if (xslQuery.error_messages) {
const errorMessages = xslQuery.error_messages
const errorUrls = xslQuery.error_urls
if (errorMessages) {
const messages = Array.isArray(errorMessages) ? errorMessages : [errorMessages]
const urls = Array.isArray(errorUrls) ? errorUrls : (errorUrls ? [errorUrls] : [])
messages.forEach((msg, i) => {
const errorParts = [xmlEscape(msg)]
if (urls[i])
errorParts.push(xmlEscape(urls[i]))
fetchErrors.push(`${errorParts.join(' — ')} `)
})
}
}
const hasRuntimeErrors = fetchErrors.length > 0
const showDevTools = import.meta.dev && xslTips !== false
const hints = [
`This is an XSL sitemap (CSS for XML). Disable with xsl: false`,
`View the raw XML by adding ?canonical to the URL`,
`Check /__sitemap__/debug.json for full sitemap diagnostics`,
]
const hint = hints[Math.floor(Math.random() * hints.length)]
let columns = [...xslColumns!]
if (!columns.length) {
columns = [
{ label: 'URL', width: '50%' },
{ label: 'Images', width: '25%', select: 'count(image:image)' },
{ label: 'Last Updated', width: '25%', select: 'concat(substring(sitemap:lastmod,0,11),concat(\' \', substring(sitemap:lastmod,12,5)),concat(\' \', substring(sitemap:lastmod,20,6)))' },
]
}
return `
XML Sitemap
${hasRuntimeErrors
? ``
: ''}
${columns!.map(c => `${c.label} `).join('\n')}
${showDevTools
? `
`
: ''}
${columns.filter(c => c.label !== 'URL').map(c => ` `).join('\n')}
${showDevTools
? `
Sitemap Debug Bar
v${version} · ${xmlEscape(siteUrl)}
Hint: ${hint}
${isIndexPage
? `
${isShowingCanonical ? 'Prod' : 'Dev'}
`
: ''}
Debug
`
: ''}
`
})
================================================
FILE: src/runtime/server/routes/sitemap_index.xml.ts
================================================
import { defineEventHandler } from 'h3'
import { sitemapIndexXmlEventHandler } from '../sitemap/event-handlers'
export default defineEventHandler(sitemapIndexXmlEventHandler)
================================================
FILE: src/runtime/server/sitemap/builder/sitemap-index.ts
================================================
import type { H3Event } from 'h3'
import type { NitroApp } from 'nitropack/types'
import type {
ModuleRuntimeConfig,
NitroUrlResolvers,
SitemapIndexEntry,
} from '../../../types'
import { getHeader } from 'h3'
import { defineCachedFunction } from 'nitropack/runtime'
import { joinURL, withQuery } from 'ufo'
// @ts-expect-error virtual module
import staticConfig from '#sitemap-virtual/static-config.mjs'
import { normaliseDate } from '../urlset/normalise'
import { getResolvedSitemapUrls } from './sitemap'
import { escapeValueForXml } from './xml'
const SERVER_CACHE_MAX_AGE = (staticConfig.cacheMaxAgeSeconds as number | false) || 60 * 10
// Create cached wrapper for sitemap index building
const buildSitemapIndexCached = defineCachedFunction(
async (event: H3Event, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) => {
return buildSitemapIndexInternal(resolvers, runtimeConfig, nitro)
},
{
name: 'sitemap:index',
group: 'sitemap',
maxAge: SERVER_CACHE_MAX_AGE,
base: 'sitemap', // Use the sitemap storage
getKey: (event: H3Event) => {
// Include headers that could affect the output in the cache key
const host = getHeader(event, 'host') || getHeader(event, 'x-forwarded-host') || ''
const proto = getHeader(event, 'x-forwarded-proto') || 'https'
return `sitemap-index-${proto}-${host}`
},
swr: true, // Enable stale-while-revalidate
},
)
async function buildSitemapIndexInternal(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp): Promise<{ entries: SitemapIndexEntry[], failedSources: Array<{ url: string, error: string }> }> {
const {
sitemaps,
autoLastmod,
defaultSitemapsChunkSize,
sitemapsPathPrefix,
} = runtimeConfig
if (!sitemaps)
throw new Error('Attempting to build a sitemap index without required `sitemaps` configuration.')
const nonChunkedNames: string[] = []
const allFailedSources: Array<{ url: string, error: string }> = []
// Process all sitemaps to determine chunks
for (const sitemapName in sitemaps) {
if (sitemapName === 'index' || sitemapName === 'chunks')
continue
const sitemapConfig = sitemaps[sitemapName]!
// Check if this sitemap should be chunked
if (sitemapConfig.chunks || sitemapConfig._isChunking) {
// Mark as chunking for later processing
sitemapConfig._isChunking = true
sitemapConfig._chunkSize = sitemapConfig.chunkSize || (typeof sitemapConfig.chunks === 'number' ? sitemapConfig.chunks : (defaultSitemapsChunkSize || 1000))
}
else {
nonChunkedNames.push(sitemapName)
}
}
// sitemap.org defines index as the file's modification time, not the max of URL
// lastmods inside it. Our default sort is by `loc`, so per-chunk URL lastmods were already
// misleading. Emit `new Date()` when autoLastmod is on, otherwise no . This avoids
// a slice/filter/sort pass per chunk and lets us count without holding URLs in memory.
const indexLastmod = autoLastmod ? normaliseDate(new Date()) : undefined
const entries: SitemapIndexEntry[] = []
// Auto-chunking: count URLs to know how many chunk entries to emit. Shares cache with the
// chunk handler (matchName 'sitemap', isChunked true) so the source fetch is one-shot.
if (typeof sitemaps.chunks !== 'undefined') {
const sitemap = sitemaps.chunks
const resolved = await getResolvedSitemapUrls(sitemap, 'sitemap', true, resolvers, runtimeConfig, nitro)
allFailedSources.push(...resolved.failedSources)
const chunkCount = Math.ceil(resolved.urls.length / (defaultSitemapsChunkSize as number))
for (let i = 0; i < chunkCount; i++) {
const entry: SitemapIndexEntry = {
_sitemapName: String(i),
sitemap: resolvers.canonicalUrlResolver(joinURL(sitemapsPathPrefix || '', `/${i}.xml`)),
}
if (indexLastmod)
entry.lastmod = indexLastmod
entries.push(entry)
}
}
// Non-chunked named sitemaps: just emit one entry each, no fetch.
for (const name of nonChunkedNames) {
const entry: SitemapIndexEntry = {
_sitemapName: name,
sitemap: resolvers.canonicalUrlResolver(joinURL(sitemapsPathPrefix || '', `/${name}.xml`)),
}
if (indexLastmod)
entry.lastmod = indexLastmod
entries.push(entry)
}
// Chunked named sitemaps. Skip the source fetch when `chunkCount` is declared upfront.
for (const sitemapName in sitemaps) {
const sitemapConfig = sitemaps[sitemapName]!
if (sitemapName !== 'index' && sitemapConfig._isChunking) {
const chunkSize = sitemapConfig._chunkSize || defaultSitemapsChunkSize || 1000
let chunkCount: number
if (typeof sitemapConfig.chunkCount === 'number' && sitemapConfig.chunkCount > 0) {
chunkCount = sitemapConfig.chunkCount
}
else {
const resolved = await getResolvedSitemapUrls(sitemapConfig, sitemapName, true, resolvers, runtimeConfig, nitro)
allFailedSources.push(...resolved.failedSources)
chunkCount = Math.ceil(resolved.urls.length / chunkSize)
}
sitemapConfig._chunkCount = chunkCount
for (let i = 0; i < chunkCount; i++) {
const chunkName = `${sitemapName}-${i}`
const entry: SitemapIndexEntry = {
_sitemapName: chunkName,
sitemap: resolvers.canonicalUrlResolver(joinURL(sitemapsPathPrefix || '', `/${chunkName}.xml`)),
}
if (indexLastmod)
entry.lastmod = indexLastmod
entries.push(entry)
}
}
}
// allow extending the index sitemap
if (sitemaps.index) {
entries.push(...sitemaps.index.sitemaps.map((entry) => {
return typeof entry === 'string' ? { sitemap: entry } : entry
}))
}
return { entries, failedSources: allFailedSources }
}
export function urlsToIndexXml(sitemaps: SitemapIndexEntry[], resolvers: NitroUrlResolvers, { version, xsl, credits, minify }: Pick, errorInfo?: { messages: string[], urls: string[] }) {
const sitemapXml = sitemaps.map(e => [
' ',
` ${escapeValueForXml(e.sitemap)} `,
// lastmod is optional
e.lastmod ? ` ${escapeValueForXml(e.lastmod)} ` : false,
' ',
].filter(Boolean).join('\n')).join('\n')
const xmlParts = [
'',
]
// Add XSL if enabled
if (xsl) {
let relativeBaseUrl = resolvers.relativeBaseUrlResolver?.(xsl) ?? xsl
// Add error information to XSL URL if available
if (errorInfo && errorInfo.messages.length > 0) {
relativeBaseUrl = withQuery(relativeBaseUrl, {
errors: 'true',
error_messages: errorInfo.messages,
error_urls: errorInfo.urls,
})
}
xmlParts.push(``)
}
// Add sitemap index content
xmlParts.push(
'',
sitemapXml,
' ',
)
// Add credits if enabled
if (credits) {
xmlParts.push(``)
}
// Join with appropriate separator
return minify
? xmlParts.join('').replace(/(?]*)\s(?![^<]*>)/g, '')
: xmlParts.join('\n')
}
export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) {
// Check if should use cached version.
// Skip caching during prerender: sources are written to disk by `prerender:done`, so
// an early crawl would otherwise poison the cache with an empty result.
if (!import.meta.dev && !import.meta.prerender && typeof runtimeConfig.cacheMaxAgeSeconds === 'number' && runtimeConfig.cacheMaxAgeSeconds > 0 && resolvers.event) {
return buildSitemapIndexCached(resolvers.event, resolvers, runtimeConfig, nitro)
}
return buildSitemapIndexInternal(resolvers, runtimeConfig, nitro)
}
================================================
FILE: src/runtime/server/sitemap/builder/sitemap.ts
================================================
import type { H3Event } from 'h3'
import type { NitroApp } from 'nitropack/types'
import type {
AlternativeEntry,
AutoI18nConfig,
ModuleRuntimeConfig,
NitroUrlResolvers,
ResolvedSitemapUrl,
SitemapDefinition,
SitemapInputCtx,
SitemapSourcesHookCtx,
SitemapUrl,
SitemapUrlInput,
} from '../../../types'
import { getHeader } from 'h3'
import { defineCachedFunction, useRuntimeConfig } from 'nitropack/runtime'
import { resolveSitePath } from 'nuxt-site-config/urls'
import { joinURL, withHttps } from 'ufo'
// @ts-expect-error virtual module
import staticConfig from '#sitemap-virtual/static-config.mjs'
import { applyDynamicParams, createPathFilter, findPageMapping, logger, splitForLocales } from '../../../utils-pure'
import { preNormalizeEntry } from '../urlset/normalise'
import { sortInPlace } from '../urlset/sort'
import { childSitemapSources, globalSitemapSources, resolveSitemapSources } from '../urlset/sources'
import { parseChunkInfo, sliceUrlsForChunk } from '../utils/chunk'
const SERVER_CACHE_MAX_AGE = (staticConfig.cacheMaxAgeSeconds as number | false) || 60 * 10
export interface NormalizedI18n extends ResolvedSitemapUrl {
_pathWithoutPrefix: string
_locale: AutoI18nConfig['locales'][number]
_index?: number
}
export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapUrlInput[], runtimeConfig: Pick, resolvers?: NitroUrlResolvers, baseURL?: string): ResolvedSitemapUrl[] {
const {
autoI18n,
isI18nMapped,
} = runtimeConfig
const filterPath = createPathFilter({
include: sitemap.include,
exclude: sitemap.exclude,
}, baseURL || '/')
// 1. normalise
const _urls = urls.map((_e) => {
const e = preNormalizeEntry(_e, resolvers)
if (!e.loc || !filterPath(e.loc))
return false
return e
}).filter(Boolean) as ResolvedSitemapUrl[]
let validI18nUrlsForTransform: NormalizedI18n[] = []
const withoutPrefixPaths: Record = {}
if (autoI18n && autoI18n.strategy !== 'no_prefix') {
const localeCodes = autoI18n.locales.map(l => l.code)
// Create locale lookup Map for O(1) access
const localeByCode = new Map(autoI18n.locales.map(l => [l.code, l]))
// Pre-check strategy once
const isPrefixStrategy = autoI18n.strategy === 'prefix'
const isPrefixExceptOrAndDefault = autoI18n.strategy === 'prefix_and_default' || autoI18n.strategy === 'prefix_except_default'
// Pre-create x-default + locales array for alternatives
const xDefaultAndLocales = [{ code: 'x-default', _hreflang: 'x-default' }, ...autoI18n.locales] as Array<{ code: string, _hreflang: string }>
// Cache frequently accessed values
const defaultLocale = autoI18n.defaultLocale
const hasPages = !!autoI18n.pages
const hasDifferentDomains = !!autoI18n.differentDomains
validI18nUrlsForTransform = _urls.map((_e, i) => {
if (_e._abs)
return false
const split = splitForLocales(_e._relativeLoc, localeCodes)
let localeCode = split[0]
const pathWithoutPrefix = split[1]
if (!localeCode)
localeCode = defaultLocale
const e = _e as NormalizedI18n
e._pathWithoutPrefix = pathWithoutPrefix
// Use Map instead of find for O(1) lookup
const locale = localeByCode.get(localeCode)
if (!locale)
return false
e._locale = locale
e._index = i
e._key = `${e._sitemap || ''}${e._path?.pathname || '/'}${e._path?.search || ''}`
withoutPrefixPaths[pathWithoutPrefix] = withoutPrefixPaths[pathWithoutPrefix] || []
// need to make sure the locale doesn't already exist
if (!withoutPrefixPaths[pathWithoutPrefix].some(e => e._locale.code === locale.code))
withoutPrefixPaths[pathWithoutPrefix].push(e)
return e
}).filter(Boolean) as NormalizedI18n[]
for (const e of validI18nUrlsForTransform) {
// let's try and find other urls that we can use for alternatives
if (!e._i18nTransform && !e.alternatives?.length) {
const alternatives = (withoutPrefixPaths[e._pathWithoutPrefix] || [])
.map((u) => {
const entries: AlternativeEntry[] = []
if (u._locale.code === defaultLocale) {
entries.push({
href: u.loc,
hreflang: 'x-default',
})
}
entries.push({
href: u.loc,
hreflang: u._locale._hreflang || defaultLocale,
})
return entries
})
.flat()
.filter(Boolean) as AlternativeEntry[]
if (alternatives.length)
e.alternatives = alternatives
}
else if (e._i18nTransform) {
delete e._i18nTransform
// keep single entry, just add alternatvies
if (hasDifferentDomains) {
// Use Map instead of find with array creation
const defLocale = localeByCode.get(defaultLocale)
e.alternatives = [
{
...defLocale,
code: 'x-default',
},
...autoI18n.locales
.filter(l => !!l.domain),
]
.map((locale) => {
return {
hreflang: locale._hreflang!,
href: joinURL(withHttps(locale.domain!), e._pathWithoutPrefix),
}
})
}
else {
// Find page mapping with support for dynamic routes
const pageMatch = hasPages ? findPageMapping(e._pathWithoutPrefix, autoI18n.pages!) : null
const pathSearch = e._path?.search || ''
const pathWithoutPrefix = e._pathWithoutPrefix
// need to add urls for all other locales
for (const l of autoI18n.locales) {
let loc = pathWithoutPrefix
// Check if there's a custom mapping in i18n pages config
if (pageMatch && pageMatch.mappings[l.code] !== undefined) {
const customPath = pageMatch.mappings[l.code]
// If customPath is false, skip this locale
if (customPath === false)
continue
// If customPath is a string, use it (applying dynamic params if present)
if (typeof customPath === 'string') {
loc = customPath[0] === '/' ? customPath : `/${customPath}`
loc = applyDynamicParams(loc, pageMatch.paramSegments)
// Add locale prefix for non-default locales
if (isPrefixStrategy || (isPrefixExceptOrAndDefault && l.code !== defaultLocale))
loc = joinURL(`/${l.code}`, loc)
}
}
else if (!hasDifferentDomains && !(isPrefixExceptOrAndDefault && l.code === defaultLocale)) {
// No custom mapping found, use default behavior
loc = joinURL(`/${l.code}`, pathWithoutPrefix)
}
const _sitemap = isI18nMapped ? l._sitemap : undefined
// Build alternatives array with loop instead of map().filter()
const alternatives: AlternativeEntry[] = []
for (const locale of xDefaultAndLocales) {
const code = locale.code === 'x-default' ? defaultLocale : locale.code
const isDefault = locale.code === 'x-default' || locale.code === defaultLocale
let href = pathWithoutPrefix
// Check for custom path mapping
if (pageMatch && pageMatch.mappings[code] !== undefined) {
const customPath = pageMatch.mappings[code]
if (customPath === false)
continue
if (typeof customPath === 'string') {
href = customPath[0] === '/' ? customPath : `/${customPath}`
href = applyDynamicParams(href, pageMatch.paramSegments)
// Add locale prefix for non-default locales
if (isPrefixStrategy || (isPrefixExceptOrAndDefault && !isDefault))
href = joinURL('/', code, href)
}
}
else if (isPrefixStrategy) {
href = joinURL('/', code, pathWithoutPrefix)
}
else if (isPrefixExceptOrAndDefault && !isDefault) {
href = joinURL('/', code, pathWithoutPrefix)
}
if (!filterPath(href))
continue
alternatives.push({
hreflang: locale._hreflang,
href,
})
}
const { _index: _, ...rest } = e
const newEntry = preNormalizeEntry({
_sitemap,
...rest,
_key: `${_sitemap || ''}${loc || '/'}${pathSearch}`,
_locale: l,
loc,
alternatives,
} as SitemapUrl, resolvers) as NormalizedI18n
if (e._locale.code === newEntry._locale.code) {
// replace
_urls[e._index!] = newEntry
// avoid getting re-replaced
e._index = undefined
}
else {
_urls.push(newEntry)
}
}
}
}
if (isI18nMapped) {
e._sitemap = e._sitemap || e._locale._sitemap
e._key = `${e._sitemap || ''}${e.loc || '/'}${e._path?.search || ''}`
}
if (e._index)
_urls[e._index] = e
}
}
return _urls
}
export interface ResolvedSitemapUrlsResult {
urls: ResolvedSitemapUrl[]
failedSources: Array<{ url: string, error: string }>
}
// Chunk-agnostic computation: fetch sources, run hooks, normalise, filter, sort.
// Returns the full sorted array; chunked sitemaps slice from this on the way out.
// All chunks of the same base sitemap share one cache entry.
export async function buildResolvedSitemapUrls(
effectiveSitemap: SitemapDefinition,
matchName: string,
isChunked: boolean,
resolvers: NitroUrlResolvers,
runtimeConfig: ModuleRuntimeConfig,
nitro?: NitroApp,
): Promise {
const { sitemaps, autoI18n, isI18nMapped, isMultiSitemap, sortEntries } = runtimeConfig
let sourcesInput = effectiveSitemap.includeAppSources
? [...await globalSitemapSources(), ...await childSitemapSources(effectiveSitemap)]
: await childSitemapSources(effectiveSitemap)
if (nitro && resolvers.event) {
const ctx: SitemapSourcesHookCtx = {
event: resolvers.event,
sitemapName: matchName,
sources: sourcesInput,
}
await nitro.hooks.callHook('sitemap:sources', ctx)
sourcesInput = ctx.sources
}
const sources = await resolveSitemapSources(sourcesInput, resolvers.event)
const failedSources = sources
.filter(source => source.error && source._isFailure)
.map(source => ({
url: typeof source.fetch === 'string' ? source.fetch : (source.fetch?.[0] || 'unknown'),
error: source.error || 'Unknown error',
}))
const resolvedCtx: SitemapInputCtx = {
urls: sources.flatMap(s => s.urls),
sitemapName: matchName,
event: resolvers.event,
}
await nitro?.hooks.callHook('sitemap:input', resolvedCtx)
const enhancedUrls = resolveSitemapEntries(effectiveSitemap, resolvedCtx.urls, { autoI18n, isI18nMapped }, resolvers, useRuntimeConfig().app.baseURL)
if (isMultiSitemap) {
const sitemapNames = Object.keys(sitemaps).filter(k => k !== 'index')
// @ts-expect-error loose typing
const warnedSitemaps = nitro?._sitemapWarnedSitemaps || new Set()
for (const e of enhancedUrls) {
const hasMatchingSitemap = typeof e._sitemap === 'string'
&& (sitemapNames.includes(e._sitemap) || (isI18nMapped && sitemapNames.some(name => name.startsWith(`${e._sitemap}-`))))
if (typeof e._sitemap === 'string' && !hasMatchingSitemap) {
if (!warnedSitemaps.has(e._sitemap)) {
warnedSitemaps.add(e._sitemap)
logger.error(`Sitemap \`${e._sitemap}\` not found in sitemap config. Available sitemaps: ${sitemapNames.join(', ')}. Entry \`${e.loc}\` will be omitted.`)
}
}
}
if (nitro) {
// @ts-expect-error loose typing
nitro._sitemapWarnedSitemaps = warnedSitemaps
}
}
const filteredUrls = enhancedUrls.filter((e) => {
if (e._sitemap === false)
return false
if (isMultiSitemap && e._sitemap && matchName) {
if (isChunked)
return e._sitemap === matchName
return e._sitemap === matchName || (isI18nMapped && matchName.startsWith(`${e._sitemap}-`))
}
return true
})
const urls = sortEntries ? sortInPlace(filteredUrls) : filteredUrls
return { urls, failedSources }
}
export const buildResolvedSitemapUrlsCached = defineCachedFunction(
async (
_event: H3Event,
effectiveSitemap: SitemapDefinition,
matchName: string,
isChunked: boolean,
resolvers: NitroUrlResolvers,
runtimeConfig: ModuleRuntimeConfig,
nitro?: NitroApp,
) => buildResolvedSitemapUrls(effectiveSitemap, matchName, isChunked, resolvers, runtimeConfig, nitro),
{
name: 'sitemap:resolved-urls',
group: 'sitemap',
base: 'sitemap',
maxAge: SERVER_CACHE_MAX_AGE,
getKey: (event, _effectiveSitemap, matchName, isChunked) => {
const host = getHeader(event, 'host') || getHeader(event, 'x-forwarded-host') || ''
const proto = getHeader(event, 'x-forwarded-proto') || 'https'
return `resolved-${isChunked ? 'chunked-' : ''}${matchName}-${proto}-${host}`
},
swr: true,
},
)
// Routes between Nitro's storage-backed cache (production) and direct execution. Chunks of the
// same base sitemap share one cache entry so the source fetch + normalize + sort runs once per
// `cacheMaxAgeSeconds` window. Edge-runtime safe: relies on Nitro's storage layer, no module
// state. Dev and prerender skip the cache (prerender to avoid poisoning from early empty-source
// reads; dev to keep iteration fast).
export async function getResolvedSitemapUrls(
effectiveSitemap: SitemapDefinition,
matchName: string,
isChunked: boolean,
resolvers: NitroUrlResolvers,
runtimeConfig: ModuleRuntimeConfig,
nitro?: NitroApp,
): Promise {
const event = resolvers.event
const shouldCache = !import.meta.dev && !import.meta.prerender && typeof runtimeConfig.cacheMaxAgeSeconds === 'number' && runtimeConfig.cacheMaxAgeSeconds > 0
if (shouldCache && event) {
return buildResolvedSitemapUrlsCached(event, effectiveSitemap, matchName, isChunked, resolvers, runtimeConfig, nitro)
}
return buildResolvedSitemapUrls(effectiveSitemap, matchName, isChunked, resolvers, runtimeConfig, nitro)
}
export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp): Promise {
const { sitemaps, autoI18n, defaultSitemapsChunkSize } = runtimeConfig
const chunkSize = defaultSitemapsChunkSize || undefined
const chunkInfo = parseChunkInfo(sitemap.sitemapName, sitemaps, chunkSize)
if (autoI18n?.differentDomains) {
const domain = autoI18n.locales.find(e => e.language === sitemap.sitemapName || e.code === sitemap.sitemapName)?.domain
if (domain) {
const _tester = resolvers.canonicalUrlResolver
resolvers.canonicalUrlResolver = (path: string) => resolveSitePath(path, {
absolute: true,
withBase: false,
siteUrl: withHttps(domain),
trailingSlash: _tester('/test/').endsWith('/'),
base: '/',
})
}
}
// For chunked sitemaps the base sitemap config holds the sources; all chunks share one cache entry.
let effectiveSitemap = sitemap
const baseSitemapName = chunkInfo.baseSitemapName
if (chunkInfo.isChunked && baseSitemapName !== sitemap.sitemapName && sitemaps[baseSitemapName]) {
effectiveSitemap = sitemaps[baseSitemapName]
}
const matchName = chunkInfo.isChunked ? baseSitemapName : sitemap.sitemapName
const resolved = await getResolvedSitemapUrls(effectiveSitemap, matchName, chunkInfo.isChunked, resolvers, runtimeConfig, nitro)
// Slice last so all chunks of the same base reuse the cached sorted array.
const urls = sliceUrlsForChunk(resolved.urls, sitemap.sitemapName, sitemaps, chunkSize)
return { urls, failedSources: resolved.failedSources }
}
export { urlsToXml } from './xml'
================================================
FILE: src/runtime/server/sitemap/builder/xml.ts
================================================
import type { ModuleRuntimeConfig, NitroUrlResolvers, ResolvedSitemapUrl } from '../../../types'
import { withQuery } from 'ufo'
import { xmlEscape } from '../../utils'
export function escapeValueForXml(value: boolean | string | number): string {
if (value === true || value === false)
return value ? 'yes' : 'no'
return xmlEscape(String(value))
}
function yesNo(v: boolean | string) {
return v === 'yes' || v === true ? 'yes' : 'no'
}
const URLSET_OPENING_TAG = ''
function buildUrlXml(url: ResolvedSitemapUrl, NL: string, I1: string, I2: string, I3: string, I4: string): string {
let xml = `${I1}${NL}`
if (url.loc)
xml += `${I2}${xmlEscape(url.loc)} ${NL}`
if (url.lastmod)
xml += `${I2}${xmlEscape(url.lastmod)} ${NL}`
if (url.changefreq)
xml += `${I2}${xmlEscape(url.changefreq)} ${NL}`
if (url.priority !== undefined) {
const p = typeof url.priority === 'number' ? url.priority : Number.parseFloat(url.priority)
xml += `${I2}${p.toFixed(1)} ${NL}`
}
if (url.alternatives) {
for (const alt of url.alternatives) {
let attrs = ''
for (const [k, v] of Object.entries(alt)) attrs += ` ${k}="${xmlEscape(String(v))}"`
xml += `${I2} ${NL}`
}
}
if (url.images) {
for (const img of url.images) {
xml += `${I2}${NL}${I3}${xmlEscape(img.loc as string)} ${NL}`
if (img.title)
xml += `${I3}${xmlEscape(img.title)} ${NL}`
if (img.caption)
xml += `${I3}${xmlEscape(img.caption)} ${NL}`
if (img.geo_location)
xml += `${I3}${xmlEscape(img.geo_location)} ${NL}`
if (img.license)
xml += `${I3}${xmlEscape(img.license as string)} ${NL}`
xml += `${I2} ${NL}`
}
}
if (url.videos) {
for (const video of url.videos) {
xml += `${I2}${NL}${I3}${xmlEscape(video.title)} ${NL}`
if (video.thumbnail_loc)
xml += `${I3}${xmlEscape(video.thumbnail_loc as string)} ${NL}`
xml += `${I3}${xmlEscape(video.description)} ${NL}`
if (video.content_loc)
xml += `${I3}${xmlEscape(video.content_loc as string)} ${NL}`
if (video.player_loc)
xml += `${I3}${xmlEscape(video.player_loc as string)} ${NL}`
if (video.duration !== undefined)
xml += `${I3}${escapeValueForXml(video.duration)} ${NL}`
if (video.expiration_date)
xml += `${I3}${xmlEscape(video.expiration_date)} ${NL}`
if (video.rating !== undefined)
xml += `${I3}${escapeValueForXml(video.rating)} ${NL}`
if (video.view_count !== undefined)
xml += `${I3}${escapeValueForXml(video.view_count)} ${NL}`
if (video.publication_date)
xml += `${I3}${xmlEscape(video.publication_date)} ${NL}`
if (video.family_friendly !== undefined)
xml += `${I3}${yesNo(video.family_friendly)} ${NL}`
if (video.restriction)
xml += `${I3}${xmlEscape(video.restriction.restriction)} ${NL}`
if (video.platform)
xml += `${I3}${xmlEscape(video.platform.platform)} ${NL}`
if (video.requires_subscription !== undefined)
xml += `${I3}${yesNo(video.requires_subscription)} ${NL}`
if (video.price) {
for (const price of video.price) {
const c = price.currency ? ` currency="${xmlEscape(price.currency)}"` : ''
const t = price.type ? ` type="${xmlEscape(price.type)}"` : ''
xml += `${I3}${xmlEscape(String(price.price ?? ''))} ${NL}`
}
}
if (video.uploader) {
const info = video.uploader.info ? ` info="${xmlEscape(video.uploader.info as string)}"` : ''
xml += `${I3}${xmlEscape(video.uploader.uploader)} ${NL}`
}
if (video.live !== undefined)
xml += `${I3}${yesNo(video.live)} ${NL}`
if (video.tag) {
const tags = Array.isArray(video.tag) ? video.tag : [video.tag]
for (const t of tags) xml += `${I3}${xmlEscape(t)} ${NL}`
}
if (video.category)
xml += `${I3}${xmlEscape(video.category)} ${NL}`
if (video.gallery_loc)
xml += `${I3}${xmlEscape(video.gallery_loc as string)} ${NL}`
xml += `${I2} ${NL}`
}
}
if (url.news) {
xml += `${I2}${NL}${I3}${NL}`
xml += `${I4}${xmlEscape(url.news.publication.name)} ${NL}`
xml += `${I4}${xmlEscape(url.news.publication.language)} ${NL}`
xml += `${I3} ${NL}`
if (url.news.title)
xml += `${I3}${xmlEscape(url.news.title)} ${NL}`
if (url.news.publication_date)
xml += `${I3}${xmlEscape(url.news.publication_date)} ${NL}`
xml += `${I2} ${NL}`
}
if (import.meta.dev && url._warnings?.length) {
for (const w of url._warnings)
xml += `${I2}${NL}`
}
xml += `${I1} `
return xml
}
export function urlsToXml(
urls: ResolvedSitemapUrl[],
resolvers: NitroUrlResolvers,
{ version, xsl, credits, minify }: Pick,
errorInfo?: { messages: string[], urls: string[] },
): string {
let xslHref = xsl ? resolvers.relativeBaseUrlResolver(xsl) : false
if (xslHref && errorInfo?.messages.length) {
xslHref = withQuery(xslHref, {
errors: 'true',
error_messages: errorInfo.messages,
error_urls: errorInfo.urls,
})
}
const NL = minify ? '' : '\n'
const I1 = minify ? '' : ' '
const I2 = minify ? '' : ' '
const I3 = minify ? '' : ' '
const I4 = minify ? '' : ' '
let xml = xslHref
? `${NL}`
: `${NL}`
xml += URLSET_OPENING_TAG + NL
for (const url of urls) {
xml += buildUrlXml(url, NL, I1, I2, I3, I4) + NL
}
xml += ' '
if (credits) {
xml += `${NL}`
}
return xml
}
================================================
FILE: src/runtime/server/sitemap/event-handlers.ts
================================================
import type { H3Event } from 'h3'
import { appendHeader, createError, getRouterParam, sendRedirect, setHeader } from 'h3'
import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime'
import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo'
import { useSitemapRuntimeConfig } from '../utils'
import { buildSitemapIndex, urlsToIndexXml } from './builder/sitemap-index'
import { createSitemap, useNitroUrlResolvers } from './nitro'
import { getSitemapConfig, parseChunkInfo } from './utils/chunk'
export async function sitemapXmlEventHandler(e: H3Event) {
const runtimeConfig = useSitemapRuntimeConfig()
const { sitemaps } = runtimeConfig
if ('index' in sitemaps)
return sendRedirect(e, withBase('/sitemap_index.xml', useRuntimeConfig().app.baseURL), import.meta.dev ? 302 : 301)
return createSitemap(e, Object.values(sitemaps)[0]!, runtimeConfig)
}
export async function sitemapIndexXmlEventHandler(e: H3Event) {
const runtimeConfig = useSitemapRuntimeConfig()
const nitro = useNitroApp()
const resolvers = useNitroUrlResolvers(e)
const { entries: sitemaps, failedSources } = await buildSitemapIndex(resolvers, runtimeConfig, nitro)
if (import.meta.prerender) {
appendHeader(
e,
'x-nitro-prerender',
sitemaps.filter(entry => !!entry._sitemapName)
.map(entry => encodeURIComponent(joinURL(runtimeConfig.sitemapsPathPrefix || '', `/${entry._sitemapName}.xml`))).join(', '),
)
}
const indexResolvedCtx = { sitemaps, event: e }
await nitro.hooks.callHook('sitemap:index-resolved', indexResolvedCtx)
const errorInfo = failedSources.length > 0
? { messages: failedSources.map(f => f.error), urls: failedSources.map(f => f.url) }
: undefined
const output = urlsToIndexXml(indexResolvedCtx.sitemaps, resolvers, runtimeConfig, errorInfo)
const ctx = { sitemap: output, sitemapName: 'sitemap', event: e }
await nitro.hooks.callHook('sitemap:output', ctx)
setHeader(e, 'Content-Type', 'text/xml; charset=UTF-8')
if (runtimeConfig.cacheMaxAgeSeconds) {
setHeader(e, 'Cache-Control', `public, max-age=${runtimeConfig.cacheMaxAgeSeconds}, s-maxage=${runtimeConfig.cacheMaxAgeSeconds}, stale-while-revalidate=3600`)
const now = new Date()
setHeader(e, 'X-Sitemap-Generated', now.toISOString())
setHeader(e, 'X-Sitemap-Cache-Duration', `${runtimeConfig.cacheMaxAgeSeconds}s`)
const expiryTime = new Date(now.getTime() + (runtimeConfig.cacheMaxAgeSeconds * 1000))
setHeader(e, 'X-Sitemap-Cache-Expires', expiryTime.toISOString())
const remainingSeconds = Math.floor((expiryTime.getTime() - now.getTime()) / 1000)
setHeader(e, 'X-Sitemap-Cache-Remaining', `${remainingSeconds}s`)
}
else {
setHeader(e, 'Cache-Control', `no-cache, no-store`)
}
return ctx.sitemap
}
export async function sitemapChildXmlEventHandler(e: H3Event) {
// Only process .xml requests - pass through for other paths
if (!e.path.endsWith('.xml'))
return
const runtimeConfig = useSitemapRuntimeConfig(e)
const { sitemaps } = runtimeConfig
let sitemapName = getRouterParam(e, 'sitemap')
if (!sitemapName) {
const path = e.path
const match = path.match(/(?:\/__sitemap__\/)?(.+)\.xml$/)
if (match)
sitemapName = match[1]
}
if (!sitemapName)
throw createError({ statusCode: 400, message: 'Invalid sitemap request' })
sitemapName = sitemapName.replace(/\.xml$/, '')
sitemapName = withLeadingSlash(sitemapName)
if (sitemapName.startsWith('/__sitemap__/'))
sitemapName = sitemapName.replace('/__sitemap__/', '/')
if (runtimeConfig.sitemapsPathPrefix) {
const prefix = withLeadingSlash(runtimeConfig.sitemapsPathPrefix)
if (sitemapName.startsWith(prefix))
sitemapName = sitemapName.replace(prefix, '/')
}
sitemapName = withoutLeadingSlash(withoutTrailingSlash(sitemapName))
const chunkInfo = parseChunkInfo(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize)
const isAutoChunked = typeof sitemaps.chunks !== 'undefined' && !Number.isNaN(Number(sitemapName))
const sitemapExists = sitemapName in sitemaps || chunkInfo.baseSitemapName in sitemaps || isAutoChunked
if (!sitemapExists)
throw createError({ statusCode: 404, message: `Sitemap "${sitemapName}" not found.` })
if (chunkInfo.isChunked && chunkInfo.chunkIndex !== undefined) {
const baseSitemap = sitemaps[chunkInfo.baseSitemapName]
if (baseSitemap && !baseSitemap.chunks && !baseSitemap._isChunking)
throw createError({ statusCode: 404, message: `Sitemap "${chunkInfo.baseSitemapName}" does not support chunking.` })
if (baseSitemap?._chunkCount !== undefined && chunkInfo.chunkIndex >= baseSitemap._chunkCount)
throw createError({ statusCode: 404, message: `Chunk ${chunkInfo.chunkIndex} does not exist for sitemap "${chunkInfo.baseSitemapName}".` })
}
const sitemapConfig = getSitemapConfig(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize || undefined)
return createSitemap(e, sitemapConfig, runtimeConfig)
}
================================================
FILE: src/runtime/server/sitemap/nitro.ts
================================================
import type { H3Event } from 'h3'
import type { NitroApp } from 'nitropack/types'
import type {
ModuleRuntimeConfig,
NitroUrlResolvers,
ResolvedSitemapUrl,
SitemapDefinition,
SitemapRenderCtx,
} from '../../types'
import { defu } from 'defu'
import { createError, getHeader, getQuery, setHeader } from 'h3'
import { defineCachedFunction, useNitroApp } from 'nitropack/runtime'
import { fixSlashes } from 'nuxt-site-config/urls'
// @ts-expect-error virtual
import { getPathRobotConfig } from '#internal/nuxt-robots/getPathRobotConfig' // can't solve this
import { getSiteConfig } from '#site-config/server/composables/getSiteConfig'
import { createSitePathResolver } from '#site-config/server/composables/utils'
// @ts-expect-error virtual module
import staticConfig from '#sitemap-virtual/static-config.mjs'
import { logger, mergeOnKey, splitForLocales } from '../../utils-pure'
import { createNitroRouteRuleMatcher } from '../kit'
import { buildSitemapUrls, urlsToXml } from './builder/sitemap'
import { normaliseEntry, preNormalizeEntry } from './urlset/normalise'
import { sortInPlace } from './urlset/sort'
// Read at module init: defineCachedFunction takes a static maxAge. Falls back to 10 minutes
// when caching is disabled in static config (still bypassed at request time via shouldCache).
const SERVER_CACHE_MAX_AGE = (staticConfig.cacheMaxAgeSeconds as number | false) || 60 * 10
interface SitemapNitroApp extends NitroApp {
_sitemapWarned?: boolean
}
export function useNitroUrlResolvers(e: H3Event): NitroUrlResolvers {
const canonicalQuery = getQuery(e).canonical
const isShowingCanonical = typeof canonicalQuery !== 'undefined' && canonicalQuery !== 'false'
const siteConfig = getSiteConfig(e)
return {
event: e,
fixSlashes: (path: string) => fixSlashes(siteConfig.trailingSlash, path),
// we need these as they depend on the nitro event
canonicalUrlResolver: createSitePathResolver(e, {
canonical: isShowingCanonical || !import.meta.dev,
absolute: true,
withBase: true,
}),
relativeBaseUrlResolver: createSitePathResolver(e, { absolute: false, withBase: true }),
}
}
// Shared sitemap building logic
async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig) {
const { sitemapName } = definition
const nitro = useNitroApp() as SitemapNitroApp
if (import.meta.prerender) {
const config = getSiteConfig(event)
if (!config.url && !nitro._sitemapWarned) {
nitro._sitemapWarned = true
logger.error('Sitemap Site URL missing!')
logger.info('To fix this please add `{ site: { url: \'site.com\' } }` to your Nuxt config or a `NUXT_PUBLIC_SITE_URL=site.com` to your .env. Learn more at https://nuxtseo.com/site-config/getting-started/how-it-works')
throw createError({
statusMessage: 'You must provide a site URL to prerender a sitemap.',
statusCode: 500,
})
}
}
const { urls: sitemapUrls, failedSources } = await buildSitemapUrls(definition, resolvers, runtimeConfig, nitro)
if (import.meta.prerender && failedSources.length) {
throw createError({
statusCode: 500,
message: `Sitemap generation failed due to ${failedSources.length} failed sources: ${failedSources.map(s => `"${s.url}" (${s.error})`).join(', ')}`,
})
}
const routeRuleMatcher = createNitroRouteRuleMatcher()
const { autoI18n } = runtimeConfig
// Process in place to avoid creating intermediate arrays
let validCount = 0
for (let i = 0; i < sitemapUrls.length; i++) {
const u = sitemapUrls[i]!
const path = u._path?.pathname || u.loc
// Early continue for robots blocked paths
if (!getPathRobotConfig(event, { path, skipSiteIndexable: true }).indexable)
continue
let routeRules = routeRuleMatcher(path)
// Apply top-level path without prefix
if (autoI18n?.locales && autoI18n?.strategy !== 'no_prefix') {
const match = splitForLocales(path, autoI18n.locales.map(l => l.code))
const pathWithoutPrefix = match[1]
if (pathWithoutPrefix && pathWithoutPrefix !== path)
routeRules = defu(routeRules, routeRuleMatcher(pathWithoutPrefix))
}
// Skip invalid entries
if (routeRules.sitemap === false)
continue
if (typeof routeRules.robots !== 'undefined' && !routeRules.robots)
continue
const hasRobotsDisabled = Object.entries(routeRules.headers || {})
.some(([name, value]) => name.toLowerCase() === 'x-robots-tag' && value.toLowerCase().includes('noindex'))
if (routeRules.redirect || hasRobotsDisabled)
continue
// Move valid entries to the front of the array
sitemapUrls[validCount++] = (routeRules.sitemap ? defu(u, routeRules.sitemap) : u) as ResolvedSitemapUrl
}
// Truncate array to valid entries only
sitemapUrls.length = validCount
if (import.meta.dev && validCount === 0 && sitemapUrls.length > 0) {
logger.warn(`Sitemap had ${sitemapUrls.length} that were all filtered out. This may be due to a robots rules blocking these URLs from indexing. Check your /** route rules or robots.txt configuration.`)
}
// 6. nitro hooks
const locSize = sitemapUrls.length
const resolvedCtx: SitemapRenderCtx = {
urls: sitemapUrls,
sitemapName,
event,
}
await nitro.hooks.callHook('sitemap:resolved', resolvedCtx)
// we need to normalize any new urls otherwise they won't appear in the final sitemap
// Note this is risky and users should be using the sitemap:input hook for additions
if (resolvedCtx.urls.length !== locSize) {
resolvedCtx.urls = resolvedCtx.urls.map(e => preNormalizeEntry(e, resolvers))
}
const maybeSort = (urls: ResolvedSitemapUrl[]) => runtimeConfig.sortEntries ? sortInPlace(urls) : urls
// final urls
const defaults = definition.defaults || {}
const normalizedPreDedupe = resolvedCtx.urls.map(e => normaliseEntry(e, defaults, resolvers))
const urls = maybeSort(mergeOnKey(normalizedPreDedupe, '_key').map(e => normaliseEntry(e, defaults, resolvers)))
// Check if this is a chunk request that would be empty
if (definition._isChunking && definition.sitemapName.includes('-')) {
const parts = definition.sitemapName.split('-')
const lastPart = parts.pop()
if (!Number.isNaN(Number(lastPart))) {
const chunkIndex = Number(lastPart)
const baseSitemapName = parts.join('-')
// If this is a chunk and we have no URLs, it means the chunk doesn't exist
if (urls.length === 0 && chunkIndex > 0) {
throw createError({
statusCode: 404,
message: `Sitemap chunk ${chunkIndex} for "${baseSitemapName}" does not exist.`,
})
}
}
}
// Prepare error information for XSL if there are failed sources
const errorInfo = failedSources.length > 0
? {
messages: failedSources.map(f => f.error),
urls: failedSources.map(f => f.url),
}
: undefined
const sitemap = urlsToXml(urls, resolvers, runtimeConfig, errorInfo)
const ctx = { sitemap, sitemapName, event }
await nitro.hooks.callHook('sitemap:output', ctx)
return ctx.sitemap
}
// Create cached function for building sitemap XML
const buildSitemapXmlCached = defineCachedFunction(
buildSitemapXml,
{
name: 'sitemap:xml',
group: 'sitemap',
maxAge: SERVER_CACHE_MAX_AGE,
base: 'sitemap', // Use the sitemap storage
getKey: (event: H3Event, definition: SitemapDefinition) => {
// Include headers that could affect the output in the cache key
const host = getHeader(event, 'host') || getHeader(event, 'x-forwarded-host') || ''
const proto = getHeader(event, 'x-forwarded-proto') || 'https'
const sitemapName = definition.sitemapName || 'default'
return `${sitemapName}-${proto}-${host}`
},
swr: true, // Enable stale-while-revalidate
},
)
export async function createSitemap(event: H3Event, definition: SitemapDefinition, runtimeConfig: ModuleRuntimeConfig) {
const resolvers = useNitroUrlResolvers(event)
// Choose between cached or direct generation.
// Skip caching during prerender: the crawl may run before `prerender:done` has written
// `global-sources.json`, so an early empty result would poison the cache and be returned
// on the follow-up render, shipping an empty sitemap.
const shouldCache = !import.meta.dev && !import.meta.prerender && typeof runtimeConfig.cacheMaxAgeSeconds === 'number' && runtimeConfig.cacheMaxAgeSeconds > 0
const xml = shouldCache
? await buildSitemapXmlCached(event, definition, resolvers, runtimeConfig)
: await buildSitemapXml(event, definition, resolvers, runtimeConfig)
// Set headers
setHeader(event, 'Content-Type', 'text/xml; charset=UTF-8')
if (runtimeConfig.cacheMaxAgeSeconds) {
setHeader(event, 'Cache-Control', `public, max-age=${runtimeConfig.cacheMaxAgeSeconds}, s-maxage=${runtimeConfig.cacheMaxAgeSeconds}, stale-while-revalidate=3600`)
// Add debug headers when caching is enabled
const now = new Date()
setHeader(event, 'X-Sitemap-Generated', now.toISOString())
setHeader(event, 'X-Sitemap-Cache-Duration', `${runtimeConfig.cacheMaxAgeSeconds}s`)
// Calculate expiry time
const expiryTime = new Date(now.getTime() + (runtimeConfig.cacheMaxAgeSeconds * 1000))
setHeader(event, 'X-Sitemap-Cache-Expires', expiryTime.toISOString())
// Calculate remaining time
const remainingSeconds = Math.floor((expiryTime.getTime() - now.getTime()) / 1000)
setHeader(event, 'X-Sitemap-Cache-Remaining', `${remainingSeconds}s`)
}
else {
setHeader(event, 'Cache-Control', `no-cache, no-store`)
}
event.context._isSitemap = true
return xml
}
================================================
FILE: src/runtime/server/sitemap/urlset/normalise.ts
================================================
import type {
NitroUrlResolvers,
ResolvedSitemapUrl,
SitemapUrl,
SitemapUrlInput,
} from '../../../types'
import { defu } from 'defu'
import {
encodePath,
hasProtocol,
parsePath,
parseQuery,
parseURL,
stringifyParsedURL,
stringifyQuery,
withoutTrailingSlash,
} from 'ufo'
import { mergeOnKey } from '../../../utils-pure'
const VALID_CHANGEFREQ = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
export function validateSitemapUrl(url: SitemapUrlInput): string[] {
if (typeof url === 'string')
return []
const warnings: string[] = []
if (url.lastmod) {
const d = typeof url.lastmod === 'string' ? url.lastmod : undefined
if (d && !isValidW3CDate(d))
warnings.push(`lastmod "${d}" is not a valid W3C date`)
}
if (url.changefreq && !VALID_CHANGEFREQ.includes(url.changefreq))
warnings.push(`changefreq "${url.changefreq}" is not valid (expected: always|hourly|daily|weekly|monthly|yearly|never)`)
if (url.priority !== undefined) {
const p = typeof url.priority === 'number' ? url.priority : Number.parseFloat(String(url.priority))
if (Number.isNaN(p) || p < 0 || p > 1)
warnings.push(`priority "${url.priority}" is not valid (expected: number between 0.0 and 1.0)`)
}
return warnings
}
function resolve(s: string | URL, resolvers?: NitroUrlResolvers): string
function resolve(s: string | URL | undefined, resolvers?: NitroUrlResolvers): string | undefined
function resolve(s: string | URL | undefined, resolvers?: NitroUrlResolvers): string | undefined {
if (typeof s === 'undefined')
return undefined
// convert url to string
const str = typeof s === 'string' ? s : s.toString()
if (!resolvers)
return str
// avoid transforming remote urls and urls already resolved
if (hasProtocol(str, { acceptRelative: true, strict: false }))
return resolvers.fixSlashes(str)
return resolvers.canonicalUrlResolver(str)
}
function removeTrailingSlash(s: string) {
// need to account for query strings and hashes
// this assumes the URL is normalised
return s.replace(/\/(\?|#|$)/, '$1')
}
export function preNormalizeEntry(_e: SitemapUrl | string, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl {
// Normalize url → loc before casting to ResolvedSitemapUrl
const input = typeof _e === 'string' ? { loc: _e } : { ..._e }
if (input.url && !input.loc) {
input.loc = input.url
}
delete input.url
if (typeof input.loc !== 'string') {
input.loc = ''
}
// Check if URL is marked as already encoded
const skipEncoding = input._encoded === true
const e = input as ResolvedSitemapUrl
// we want a uniform loc so we can dedupe using it, remove slashes and only get the path
e.loc = removeTrailingSlash(e.loc)
e._abs = hasProtocol(e.loc, { acceptRelative: false, strict: false })
try {
e._path = e._abs ? parseURL(e.loc) : parsePath(e.loc)
}
catch {
e._path = null
}
if (e._path) {
const search = e._path.search
// Skip parse/stringify if no query string
const qs = search && search.length > 1
? stringifyQuery(parseQuery(search))
: ''
const pathname = skipEncoding ? e._path.pathname : encodePath(e._path.pathname)
e._relativeLoc = `${pathname}${qs.length ? `?${qs}` : ''}`
if (e._path.host) {
e.loc = stringifyParsedURL(e._path)
}
else {
e.loc = e._relativeLoc
}
}
else if (!skipEncoding && !isEncoded(e.loc)) {
e.loc = encodeURI(e.loc)
}
if (e.loc === '')
e.loc = `/`
e.loc = resolve(e.loc, resolvers)
e._key = `${e._sitemap || ''}${withoutTrailingSlash(e.loc)}`
return e as ResolvedSitemapUrl
}
export function isEncoded(url: string) {
// checks, if an url is already decoded
try {
return url !== decodeURIComponent(url)
}
catch {
return false
}
}
export function normaliseEntry(_e: ResolvedSitemapUrl, defaults: Omit, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl {
const e = defu(_e, defaults) as ResolvedSitemapUrl
if (import.meta.dev) {
const warnings = validateSitemapUrl(e)
if (warnings.length)
e._warnings = (e._warnings || []).concat(warnings)
}
if (e.lastmod) {
const date = normaliseDate(e.lastmod)
if (date)
e.lastmod = date
else
delete e.lastmod
}
// make sure it's valid
if (!e.lastmod)
delete e.lastmod
// need to make sure siteURL doesn't have the base on the end
e.loc = resolve(e.loc, resolvers)
// correct alternative hrefs
if (e.alternatives) {
const alternatives = e.alternatives.map(a => ({ ...a }))
for (const alt of alternatives) {
if (typeof alt.href === 'string') {
alt.href = resolve(alt.href, resolvers)
}
else if (typeof alt.href === 'object' && alt.href) {
alt.href = resolve(alt.href.href, resolvers)
}
}
e.alternatives = mergeOnKey(alternatives, 'hreflang')
}
if (e.images) {
const images = e.images.map(i => ({ ...i }))
for (const img of images) {
img.loc = resolve(img.loc, resolvers)
}
e.images = mergeOnKey(images, 'loc')
}
if (e.videos) {
const videos = e.videos.map(v => ({ ...v }))
for (const video of videos) {
if (video.content_loc) {
video.content_loc = resolve(video.content_loc, resolvers)
}
}
e.videos = mergeOnKey(videos, 'content_loc')
}
return e
}
const IS_VALID_W3C_DATE = [
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/,
/^\d{4}-[01]\d-[0-3]\d$/,
/^\d{4}-[01]\d$/,
/^\d{4}$/,
]
export function isValidW3CDate(d: string) {
return IS_VALID_W3C_DATE.some(r => r.test(d))
}
export function normaliseDate(date: string | Date): string
export function normaliseDate(d: Date | string) {
// lastmod must adhere to W3C Datetime encoding rules
if (typeof d === 'string') {
// correct a time component without a timezone
const tIdx = d.indexOf('T')
if (tIdx !== -1) {
const t = d.slice(tIdx + 1)
if (!t.includes('+') && !t.includes('-') && !t.includes('Z')) {
// add UTC timezone
d += 'Z'
}
}
// skip invalid w3c date
if (!isValidW3CDate(d))
return false
// otherwise we need to parse it
d = new Date(d)
d.setMilliseconds(0)
// check for invalid date
if (Number.isNaN(d.getTime()))
return false
}
const z = (n: number) => (`0${n}`).slice(-2)
// need to normalise for google sitemap spec
const date = `${d.getUTCFullYear()
}-${
z(d.getUTCMonth() + 1)
}-${
z(d.getUTCDate())
}`
// check if we have a time set
if (d.getUTCHours() > 0 || d.getUTCMinutes() > 0 || d.getUTCSeconds() > 0) {
return (
`${date}T${
z(d.getUTCHours())
}:${
z(d.getUTCMinutes())
}:${
z(d.getUTCSeconds())
}Z`
)
}
return date
}
================================================
FILE: src/runtime/server/sitemap/urlset/sort.ts
================================================
import type {
ResolvedSitemapUrl,
SitemapUrlInput,
} from '../../../types'
export function sortInPlace(urls: T): T {
// In-place sort to avoid creating new arrays
urls.sort((a, b) => {
const aLoc = typeof a === 'string' ? a : a.loc
const bLoc = typeof b === 'string' ? b : b.loc
// First sort by path segments
const aSegments = aLoc.split('/').length
const bSegments = bLoc.split('/').length
if (aSegments !== bSegments) {
return aSegments - bSegments
}
// Then sort by locale compare with numeric
return aLoc.localeCompare(bLoc, undefined, { numeric: true })
})
return urls
}
================================================
FILE: src/runtime/server/sitemap/urlset/sources.ts
================================================
import type { H3Event } from 'h3'
import type { FetchError } from 'ofetch'
import type {
ModuleRuntimeConfig,
SitemapSourceBase,
SitemapSourceInput,
SitemapSourceResolved,
SitemapUrlInput,
} from '../../../types'
import { parseSitemapXml } from '@nuxtjs/sitemap/utils'
import { defu } from 'defu'
import { getRequestHost } from 'h3'
import { parseURL } from 'ufo'
import { logger } from '../../../utils-pure'
export function normalizeSourceInput(source: SitemapSourceInput): SitemapSourceBase | SitemapSourceResolved {
// string -> { fetch: string, context: { name: 'hook' } }
if (typeof source === 'string') {
return { context: { name: 'hook' }, fetch: source }
}
// [string, FetchOptions] -> { fetch: [string, FetchOptions], context: { name: 'hook' } }
if (Array.isArray(source)) {
return { context: { name: 'hook' }, fetch: source }
}
return source
}
async function tryFetchWithFallback(url: string, options: any, event?: H3Event): Promise {
const isExternalUrl = !url.startsWith('/')
// For external URLs, try different fetch strategies
if (isExternalUrl) {
const strategies = [
// Strategy 1: Use globalThis.$fetch (original approach)
() => globalThis.$fetch(url, options),
// Strategy 2: If event is available, try using event context even for external URLs
event ? () => event.$fetch(url, options) : null,
// Strategy 3: Use native fetch as last resort
() => $fetch(url, options),
].filter(Boolean)
let lastError: Error | null = null
for (const strategy of strategies) {
try {
return await strategy!()
}
catch (error) {
lastError = error as Error
continue
}
}
throw lastError
}
// For internal URLs, use the original logic
const fetchContainer = (url.startsWith('/') && event) ? event : globalThis
return await fetchContainer.$fetch(url, options)
}
export async function fetchDataSource(input: SitemapSourceBase | SitemapSourceResolved, event?: H3Event): Promise {
const context = typeof input.context === 'string' ? { name: input.context } : input.context || { name: 'fetch' }
const url = typeof input.fetch === 'string' ? input.fetch : input.fetch![0]
const options = typeof input.fetch === 'string' ? {} : input.fetch![1]
const start = Date.now()
// Get external source configuration
const isExternalUrl = !url.startsWith('/')
// Use external source timeout if it's an external URL, otherwise use original timeout
const timeout = isExternalUrl ? 10000 : (options.timeout || 5000)
const timeoutController = new AbortController()
const abortRequestTimeout = setTimeout(() => timeoutController.abort(), timeout)
try {
let isMaybeErrorResponse = false
const isXmlRequest = parseURL(url).pathname.endsWith('.xml')
// Merge external source headers with request headers
const mergedHeaders = defu(
options?.headers,
{
Accept: isXmlRequest ? 'text/xml' : 'application/json',
},
(event && !isExternalUrl) ? { host: getRequestHost(event, { xForwardedHost: true }) } : {},
)
const fetchOptions = {
...options,
responseType: isXmlRequest ? 'text' : 'json',
signal: timeoutController.signal,
headers: mergedHeaders,
// Use ofetch's built-in retry for external sources
...(isExternalUrl && {
retry: 2,
retryDelay: 200,
}),
// @ts-expect-error untyped
onResponse({ response }) {
if (typeof response._data === 'string' && response._data.startsWith(''))
isMaybeErrorResponse = true
},
}
const res = await tryFetchWithFallback(url, fetchOptions, event)
const timeTakenMs = Date.now() - start
if (isMaybeErrorResponse) {
return {
...input,
context,
urls: [],
timeTakenMs,
error: 'Received HTML response instead of JSON',
}
}
let urls = []
if (typeof res === 'object') {
urls = res.urls || res
}
else if (typeof res === 'string' && parseURL(url).pathname.endsWith('.xml')) {
const result = await parseSitemapXml(res)
urls = result.urls
}
return {
...input,
context,
timeTakenMs,
urls: urls as SitemapUrlInput[],
}
}
catch (_err) {
const error = _err as FetchError
// Enhanced error logging for external sources
if (isExternalUrl) {
const errorInfo = {
url,
timeout,
error: error.message,
statusCode: error.response?.status,
statusText: error.response?.statusText,
method: options?.method || 'GET',
}
logger.error('Failed to fetch external source.', errorInfo)
}
else {
logger.error('Failed to fetch source.', { url, error: error.message })
}
return {
...input,
context,
urls: [],
error: error.message,
_isFailure: true, // Mark as failure to prevent caching
}
}
finally {
if (abortRequestTimeout) {
clearTimeout(abortRequestTimeout)
}
}
}
export async function globalSitemapSources() {
if (import.meta.prerender) {
const { readSourcesFromFilesystem } = await import('#sitemap-virtual/read-sources.mjs')
const sources = await readSourcesFromFilesystem('global-sources.json')
if (sources) {
// Spread to create a copy since the cached module returns a mutable reference
return [...sources]
}
}
const m = await import('#sitemap-virtual/global-sources.mjs')
// Spread to create a copy since the cached module returns a mutable reference
return [...m.sources]
}
export async function childSitemapSources(definition: ModuleRuntimeConfig['sitemaps'][string]) {
if (!definition?._hasSourceChunk)
return []
if (import.meta.prerender) {
const { readSourcesFromFilesystem } = await import('#sitemap-virtual/read-sources.mjs')
const allSources = await readSourcesFromFilesystem('child-sources.json')
if (allSources) {
// Spread to create a copy since the cached module returns a mutable reference
return [...(allSources[definition.sitemapName] || [])]
}
}
const m = await import('#sitemap-virtual/child-sources.mjs')
// Spread to create a copy since the cached module returns a mutable reference
return [...(m.sources[definition.sitemapName] || [])]
}
export async function resolveSitemapSources(sources: SitemapSourceInput[], event?: H3Event) {
return (await Promise.all(
sources.map((source) => {
const normalized = normalizeSourceInput(source)
if ('urls' in normalized) {
return {
timeTakenMs: 0,
...normalized,
urls: normalized.urls,
}
}
if (normalized.fetch)
return fetchDataSource(normalized, event)
return {
...normalized,
error: 'Invalid source',
}
}),
)).flat()
}
================================================
FILE: src/runtime/server/sitemap/utils/chunk.ts
================================================
import type { ModuleRuntimeConfig, SitemapDefinition } from '../../../types'
export interface ChunkInfo {
isChunked: boolean
baseSitemapName: string
chunkIndex?: number
chunkSize: number
}
export function parseChunkInfo(
sitemapName: string,
sitemaps: ModuleRuntimeConfig['sitemaps'],
defaultChunkSize?: number | false,
): ChunkInfo {
defaultChunkSize = defaultChunkSize || 1000
// Check if this is an auto-chunked sitemap (numeric name)
if (typeof sitemaps.chunks !== 'undefined' && !Number.isNaN(Number(sitemapName))) {
return {
isChunked: true,
baseSitemapName: 'sitemap',
chunkIndex: Number(sitemapName),
chunkSize: defaultChunkSize,
}
}
// Check if this is a chunked named sitemap (format: name-number)
if (sitemapName.includes('-')) {
const parts = sitemapName.split('-')
const lastPart = parts.pop()
if (!Number.isNaN(Number(lastPart))) {
const baseSitemapName = parts.join('-')
const baseSitemap = sitemaps[baseSitemapName]
if (baseSitemap && (baseSitemap.chunks || baseSitemap._isChunking)) {
const chunkSize = typeof baseSitemap.chunks === 'number'
? baseSitemap.chunks
: (baseSitemap.chunkSize || defaultChunkSize)
return {
isChunked: true,
baseSitemapName,
chunkIndex: Number(lastPart),
chunkSize,
}
}
}
}
// Not a chunked sitemap
return {
isChunked: false,
baseSitemapName: sitemapName,
chunkIndex: undefined,
chunkSize: defaultChunkSize,
}
}
export function getSitemapConfig(
sitemapName: string,
sitemaps: ModuleRuntimeConfig['sitemaps'],
defaultChunkSize: number = 1000,
): SitemapDefinition {
const chunkInfo = parseChunkInfo(sitemapName, sitemaps, defaultChunkSize)
if (chunkInfo.isChunked) {
// For auto-chunked sitemaps
if (chunkInfo.baseSitemapName === 'sitemap' && typeof sitemaps.chunks !== 'undefined') {
return {
...sitemaps.chunks,
sitemapName,
_isChunking: true,
_chunkSize: chunkInfo.chunkSize,
}
}
// For named chunked sitemaps
const baseSitemap = sitemaps[chunkInfo.baseSitemapName]
if (baseSitemap) {
return {
...baseSitemap,
sitemapName, // Use the full name with chunk index
_isChunking: true,
_chunkSize: chunkInfo.chunkSize,
}
}
}
// Regular sitemap
return sitemaps[sitemapName]!
}
export function sliceUrlsForChunk(
urls: T[],
sitemapName: string,
sitemaps: ModuleRuntimeConfig['sitemaps'],
defaultChunkSize: number = 1000,
): T[] {
const chunkInfo = parseChunkInfo(sitemapName, sitemaps, defaultChunkSize)
if (chunkInfo.isChunked && chunkInfo.chunkIndex !== undefined) {
const startIndex = chunkInfo.chunkIndex * chunkInfo.chunkSize
const endIndex = (chunkInfo.chunkIndex + 1) * chunkInfo.chunkSize
return urls.slice(startIndex, endIndex)
}
return urls
}
================================================
FILE: src/runtime/server/tsconfig.json
================================================
{
"extends": "../../../.nuxt/tsconfig.server.json"
}
================================================
FILE: src/runtime/server/utils.ts
================================================
import type { H3Event } from 'h3'
import type { ModuleRuntimeConfig } from '../types'
import { useRuntimeConfig } from 'nitropack/runtime'
// @ts-expect-error virtual module
import staticConfig from '#sitemap-virtual/static-config.mjs'
import { normalizeRuntimeFilters } from '../utils-pure'
export * from '../utils-pure'
// XML escape function for content inserted into XML/XSL
export function xmlEscape(str: string | number | boolean | Date): string {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
export function useSitemapRuntimeConfig(e?: H3Event): ModuleRuntimeConfig {
// Static fields live in a virtual module; only env-overridable fields go through runtimeConfig.
// we still need to clone so callers can mutate without affecting the shared module-scope copy
const clone = JSON.parse(JSON.stringify(staticConfig)) as ModuleRuntimeConfig
for (const k in clone.sitemaps) {
const sitemap = clone.sitemaps[k]!
sitemap.include = normalizeRuntimeFilters(sitemap.include)
sitemap.exclude = normalizeRuntimeFilters(sitemap.exclude)
clone.sitemaps[k] = sitemap
}
Object.assign(clone, useRuntimeConfig(e).sitemap)
return Object.freeze(clone)
}
================================================
FILE: src/runtime/types.ts
================================================
import type { NuxtI18nOptions } from '@nuxtjs/i18n'
import type { H3Event } from 'h3'
import type { FetchOptions } from 'ofetch'
import type { ParsedURL } from 'ufo'
// we need to have the module options within the runtime entry
// as we don't want to depend on the module entry as it can cause
// weird nitro issues
export interface ModuleOptions extends SitemapDefinition {
/**
* Whether the sitemap.xml should be generated.
*
* @default true
*/
enabled: boolean
/**
* Enables debug logs and a debug endpoint.
*
* @default false
*/
debug: boolean
/**
* Minify the sitemap xml
*
* @default false
*/
minify: boolean
/**
* Should lastmod be automatically added to the sitemap.
*
* Warning: This may not be following best practices for sitemaps.
*
* @see https://nuxtseo.com/sitemap/guides/best-practices.
* @default false
*/
autoLastmod: boolean
/**
* Sources to exclude from the sitemap.
*/
excludeAppSources: true | (AppSourceContext[])
/**
* Multiple sitemap support for large sites.
*
* @default false
*/
sitemaps?: boolean | MultiSitemapsInput
/**
* The path prefix for the sitemaps.
*
* @default /__sitemap__/
*/
sitemapsPathPrefix: string | false
/**
* Sitemaps to append to the sitemap index.
*
* This will only do anything when using multiple sitemaps.
*/
appendSitemaps?: (string | SitemapIndexEntry)[]
/**
* Path to the xsl that styles sitemap.xml.
*
* Set to `false` to disable styling.
*
* @default /__sitemap__/style.xsl
*/
xsl: string | false
/**
* Toggle the tips displayed in the xsl.
*
* @default true
*/
xslTips: boolean
/**
* Customised the columns displayed in the xsl.
*
* @default [{ label: 'URL', width: '50%' }, { label: 'Images', width: '25%', select: 'count(image:image)' }, { label: 'Last Updated', width: '25%', select: 'concat(substring(sitemap:lastmod,0,11),concat(\' \', substring(sitemap:lastmod,12,5)),concat(\' \', substring(sitemap:lastmod,20,6)))' }]
*/
xslColumns?: { label: string, width: `${string}%`, select?: string }[]
/**
* When prerendering, should images be automatically be discovered and added to the sitemap.
*
* @default true
*/
discoverImages: boolean
/**
* When prerendering, should videos be automatically be discovered and added to the sitemap.
*
* @default true
*/
discoverVideos: boolean
/**
* When chunking the sitemaps into multiple files, how many entries should each file contain.
*
* Set to `false` to disabling chunking completely.
*
* @default 1000
*/
defaultSitemapsChunkSize: number | false
/**
* Modify the cache behavior.
*
* Passing a boolean will enable or disable the runtime cache with the default options.
*
* Providing a record will allow you to configure the runtime cache fully.
*
* @default true
* @see https://nitro.unjs.io/guide/storage#mountpoints
* @example { driver: 'redis', host: 'localhost', port: 6379, password: 'password' }
*/
runtimeCacheStorage: boolean | (Record & {
driver: string
})
/**
* Automatically add alternative links to the sitemap based on a prefix list.
* Is used by @nuxtjs/i18n to automatically add alternative links to the sitemap.
*/
autoI18n?: boolean | AutoI18nConfig
/**
* Enable when your nuxt/content files match your pages. This will automatically add sitemap content to the sitemap.
*
* This is similar behavior to using `nuxt/content` with `documentDriven: true`.
*/
strictNuxtContentPaths: boolean
/**
* Should the sitemap.xml display credits for the module.
*
* @default true
*/
credits: boolean
/**
* How long, in seconds, should the sitemap be cached for.
*
* @default 600
*/
cacheMaxAgeSeconds: number | false
/**
* Should the entries be sorted by loc.
*
* @default true
*/
sortEntries: boolean
/**
* Warm up the sitemap route(s) cache when Nitro starts.
*
* May be implemented by default in a future minor version.
*
* @experimental Will be enabled by default in v5 (if stable)
*/
experimentalWarmUp?: boolean
/**
* Send the Sitemap as a compressed stream supporting gzip, brolti, etc.
*
* @experimental Will be enabled by default in v5 (if stable)
*/
experimentalCompression?: boolean
/**
* When enabled, sitemap generation only runs during prerendering.
* The sitemap building code is tree-shaken from the runtime bundle.
*
* Requires sitemaps to be prerendered (e.g., `nuxt generate` or `nitro.prerender.routes` includes sitemap).
*
* @default false
*/
zeroRuntime?: boolean
}
export interface IndexSitemapRemotes {
index?: (string | SitemapIndexEntry)[]
}
export interface MultiSitemapEntry {
[key: string]: Partial
}
export type MultiSitemapsInput = Partial & Partial
export type MaybeFunction = T | (() => T)
export type MaybePromise = T | Promise
export type SitemapUrlInput = SitemapUrl | string
export interface SitemapSourceBase {
context: {
name: string
description?: string
tips?: string[]
}
fetch?: string | [string, FetchOptions]
urls?: SitemapUrlInput[]
sourceType?: 'app' | 'user'
}
export interface SitemapSourceResolved extends Omit {
urls: SitemapUrlInput[]
error?: any
timeTakenMs?: number
_isFailure?: boolean
_urlWarnings?: { loc: string, message: string }[]
}
export type AppSourceContext = 'nuxt:pages' | 'nuxt:prerender' | 'nuxt:route-rules' | '@nuxtjs/i18n:pages' | 'nuxt-i18n-micro:pages' | '@nuxt/content@v2:urls' | '@nuxt/content@v3:urls'
export type SitemapSourceInput = string | [string, FetchOptions] | SitemapSourceBase | SitemapSourceResolved
// copied from @nuxtjs/i18n, types do not appear to be working
interface LocaleObject extends Record {
code: string
name?: string
dir?: 'ltr' | 'rtl' | 'auto'
domain?: string
domains?: string[]
defaultForDomains?: string[]
file?: string | {
path: string
cache?: boolean
}
files?: (string | {
path: string
cache?: boolean
})[]
isCatchallLocale?: boolean
/**
* @deprecated in v9, use `language` instead
*/
iso?: string
language?: string
}
export interface AutoI18nConfig {
differentDomains?: boolean
locales: (LocaleObject & { _sitemap: string, _hreflang: string })[]
defaultLocale: string
strategy: 'prefix' | 'prefix_except_default' | 'prefix_and_default' | 'no_prefix'
pages?: Record>
}
export interface ModuleRuntimeConfig extends Pick {
version: string
isNuxtContentDocumentDriven: boolean
sitemaps: { index?: Pick & { sitemaps: SitemapIndexEntry[] } } & Record & { _hasSourceChunk?: boolean }>
autoI18n?: AutoI18nConfig
hasDisabledAutoI18n?: boolean
isMultiSitemap: boolean
isI18nMapped: boolean
}
export interface SitemapIndexEntry {
sitemap: string
lastmod?: string
/**
* @internal
*/
_sitemapName?: string
}
export type FilterInput = (string | RegExp | {
regex: string
})
export type ResolvedSitemapUrl = Omit & Required> & {
/**
* @internal
*/
_key: string
/**
* @internal
*/
_path: ParsedURL | null
/**
* @internal
*/
_relativeLoc: string
/**
* @internal
*/
_abs: boolean
/**
* @internal
*/
_warnings?: string[]
}
export interface SitemapDefinition {
/**
* A collection include patterns for filtering which URLs end up in the sitemap.
*/
include?: FilterInput[]
/**
* A collection exclude patterns for filtering which URLs end up in the sitemap.
*/
exclude?: FilterInput[]
/**
* Should the sitemap be generated using global sources.
*
* This is enabled by default when using a single sitemap. Otherwise, it will be opt-in.
*/
includeAppSources?: boolean
/**
* The root sitemap name.
* Only works when multiple sitemaps option `sitemaps` isn't used.
*
* @default `sitemap.xml`
*/
sitemapName: string
/**
* A resolvable collection of URLs to include in the sitemap.
*
* Will be resolved when the sitemap is generated.
*/
urls?: MaybeFunction>
/**
* Default options for all URLs in the sitemap.
*/
defaults?: Omit
/**
* Additional sources of URLs to include in the sitemap.
*/
sources?: SitemapSourceInput[]
/**
* Whether to enable chunking for this sitemap.
*
* - `true`: Enable with default chunk size from `defaultSitemapsChunkSize`
* - `number`: Enable with specific chunk size (must be > 0)
* - `false` or `undefined`: Disable chunking
*
* Note: Chunking only applies to sitemaps with sources. URLs provided directly
* are not chunked.
*
* @default false
* @example true
* @example 5000
*/
chunks?: boolean | number
/**
* The maximum number of URLs per chunk when chunking is enabled.
* Takes precedence over the `chunks` property when both are specified.
* Also overrides the global `defaultSitemapsChunkSize`.
*
* Must be a positive integer.
*
* @default 1000
* @example 500
* @example 10000
*/
chunkSize?: number
/**
* Pre-declare the number of chunks this sitemap will produce. When set, the sitemap index
* renders this many chunk entries without fetching the source data — useful at very large
* scale where the cold-start fetch is the bottleneck. Per-chunk renders still fetch on
* demand and slice. If the actual data produces fewer URLs than declared, tail chunks render
* empty; if more, the extras are unreachable. Update this when your data set grows.
*
* @example 100
*/
chunkCount?: number
/**
* @internal
*/
_route?: string
/**
* @internal
*/
_isChunking?: boolean
/**
* @internal
*/
_chunkSize?: number
/**
* @internal
*/
_chunkCount?: number
}
interface NitroBaseHook {
event: H3Event
}
export interface SitemapIndexRenderCtx extends NitroBaseHook {
sitemaps: SitemapIndexEntry[]
}
export interface SitemapRenderCtx extends NitroBaseHook {
sitemapName: string
urls: ResolvedSitemapUrl[]
}
export interface SitemapInputCtx extends NitroBaseHook {
sitemapName: string
urls: SitemapUrlInput[]
}
export interface SitemapOutputHookCtx extends NitroBaseHook {
sitemapName: string
sitemap: string
}
export interface SitemapSourcesHookCtx extends NitroBaseHook {
sitemapName: string
sources: SitemapSourceInput[]
}
export type Changefreq
= | 'always'
| 'hourly'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly'
| 'never'
export interface SitemapUrl {
loc: string
/**
* Alias for `loc`. Will be normalized to `loc`.
*/
url?: string
lastmod?: string | Date
changefreq?: Changefreq
priority?: 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1
alternatives?: Array
news?: GoogleNewsEntry
images?: Array
videos?: Array
_i18nTransform?: boolean
_sitemap?: string | false
/**
* Mark the URL as already encoded.
*
* When true, the loc will not be automatically encoded, preventing double-encoding
* when you've already applied encodeURIComponent() to path segments.
*
* @example
* ```ts
* {
* loc: `/${encodeURIComponent('$pecial-char')}`,
* _encoded: true
* }
* ```
*/
_encoded?: boolean
}
export type SitemapItemDefaults = Omit
export type SitemapStrict = Required
export interface AlternativeEntry {
hreflang: string
href: string | URL
}
export interface GoogleNewsEntry {
/**
* The title of the news article.
* @example "Companies A, B in Merger Talks"
*/
title: string
/**
* The article publication date in W3C format. Specify the original date and time when the article was first
* published on your site. Don't specify the time when you added the article to your sitemap.
* @example "2008-12-23"
*/
publication_date: Date | string
publication: {
/**
* The tag is the name of the news publication.
* It must exactly match the name as it appears on your articles on news.google.com, omitting anything in parentheses.
* @example "The Example Times"
*/
name: string
/**
* The tag is the language of your publication. Use an ISO 639 language code (two or three letters).
* @example en
*/
language: string
}
}
export interface ImageEntry {
loc: string | URL
caption?: string
geo_location?: string
title?: string
license?: string | URL
}
export interface VideoEntry {
title: string
thumbnail_loc: string | URL
description: string
content_loc?: string | URL
player_loc?: string | URL
duration?: number
expiration_date?: Date | string
rating?: number
view_count?: number
publication_date?: Date | string
family_friendly?: 'yes' | 'no' | boolean
restriction?: Restriction
platform?: Platform
price?: ({
price?: number | string
currency?: string
type?: 'rent' | 'purchase' | 'package' | 'subscription'
})[]
requires_subscription?: 'yes' | 'no' | boolean
uploader?: {
uploader: string
info?: string | URL
}
live?: 'yes' | 'no' | boolean
tag?: string | string[]
category?: string
gallery_loc?: string | URL
}
export interface Restriction {
relationship: 'allow' | 'deny'
restriction: string
}
export interface Platform {
relationship: 'allow' | 'deny'
platform: string
}
export interface NitroUrlResolvers {
event: H3Event
canonicalUrlResolver: (path: string) => string
relativeBaseUrlResolver: (path: string) => string
fixSlashes: (path: string) => string
}
export type I18nIntegrationOptions = NuxtI18nOptions
================================================
FILE: src/runtime/utils-pure.ts
================================================
import type { FilterInput } from './types'
import { createConsola } from 'consola'
import { createDefu } from 'defu'
import { createFilter } from 'nuxtseo-shared/utils'
import { parseURL, withLeadingSlash, withoutBase } from 'ufo'
export { createFilter, type CreateFilterOptions } from 'nuxtseo-shared/utils'
export const logger = createConsola({
defaults: {
tag: '@nuxt/sitemap',
},
})
const merger = createDefu((obj, key, value) => {
// merge arrays using a set
if (Array.isArray(obj[key]) && Array.isArray(value))
// @ts-expect-error untyped
obj[key] = Array.from(new Set([...obj[key], ...value]))
return obj[key]
})
export function mergeOnKey(arr: T[], key: K): T[] {
const seen = new Map()
// Pre-allocate result array to avoid resizing
let resultLength = 0
const result: T[] = Array.from({ length: arr.length })
for (const item of arr) {
const k = item[key] as string
if (seen.has(k)) {
const existingIndex = seen.get(k)!
// @ts-expect-error untyped
result[existingIndex] = merger(item, result[existingIndex])
}
else {
seen.set(k, resultLength)
result[resultLength++] = item
}
}
// Truncate in-place instead of creating a copy via slice
result.length = resultLength
return result
}
export function splitForLocales(path: string, locales: string[]): [string | null, string] {
// we only want to use the first path segment otherwise we can end up turning "/ending" into "/en/ding"
const prefix = withLeadingSlash(path).split('/')[1]
// make sure prefix is a valid locale
if (prefix && locales.includes(prefix))
return [prefix, path.replace(`/${prefix}`, '')]
return [null, path]
}
const StringifiedRegExpPattern = /\/(.*?)\/([gimsuy]*)$/
/**
* Transform a literal notation string regex to RegExp
*/
export function normalizeRuntimeFilters(input?: FilterInput[]): (RegExp | string)[] {
return (input || []).map((rule) => {
if (rule instanceof RegExp || typeof rule === 'string')
return rule
// regex is already validated
const match = rule.regex.match(StringifiedRegExpPattern)
if (match)
return new RegExp(match[1]!, match[2])
return false
}).filter(Boolean) as (RegExp | string)[]
}
export function createPathFilter(options: { include?: (FilterInput | string | RegExp)[], exclude?: (FilterInput | string | RegExp)[] } = {}, baseURL?: string) {
const urlFilter = createFilter({
include: normalizeRuntimeFilters(options.include),
exclude: normalizeRuntimeFilters(options.exclude),
})
const hasBase = baseURL && baseURL !== '/'
return (loc: string) => {
let path = loc
try {
// e.loc is absolute here
path = parseURL(loc).pathname
}
catch {
// invalid URL
return false
}
if (hasBase)
path = withoutBase(path, baseURL)
return urlFilter(path)
}
}
export interface PageMatch {
mappings: Record
paramSegments: string[]
}
export function findPageMapping(pathWithoutPrefix: string, pages: Record>): PageMatch | null {
const stripped = pathWithoutPrefix[0] === '/' ? pathWithoutPrefix.slice(1) : pathWithoutPrefix
const pageKey = stripped.endsWith('/index') ? stripped.slice(0, -6) || 'index' : stripped || 'index'
// exact match
if (pages[pageKey])
return { mappings: pages[pageKey], paramSegments: [] }
// prefix matching for dynamic routes (e.g., 'posts/2' matches 'posts' key)
// sort by length desc to match most specific first
const sortedKeys = Object.keys(pages).sort((a, b) => b.length - a.length)
for (const key of sortedKeys) {
if (pageKey.startsWith(`${key}/`)) {
const paramPath = pageKey.slice(key.length + 1)
return { mappings: pages[key]!, paramSegments: paramPath.split('/') }
}
}
return null
}
export function applyDynamicParams(customPath: string, paramSegments: string[]): string {
if (!paramSegments.length)
return customPath
let i = 0
return customPath.replace(/\[[^\]]+\]/g, () => paramSegments[i++] || '')
}
================================================
FILE: src/templates.ts
================================================
import { addTemplate, addTypeTemplate, hasNuxtModule } from '@nuxt/kit'
export function registerTypeTemplates() {
const hasRobotsModule = hasNuxtModule('@nuxtjs/robots') || hasNuxtModule('nuxt-simple-robots')
// Type augmentations for existing modules
addTypeTemplate({
filename: 'types/nuxt-sitemap-augments.d.ts',
getContents: () => {
const robotsType = hasRobotsModule ? '' : ' robots?: boolean\n'
return `// Generated by @nuxtjs/sitemap
///
import type { SitemapUrl, SitemapItemDefaults, SitemapIndexRenderCtx, SitemapInputCtx, SitemapRenderCtx, SitemapOutputHookCtx, SitemapSourcesHookCtx } from '@nuxtjs/sitemap'
declare module 'nitropack' {
interface PrerenderRoute {
_sitemap?: SitemapUrl
}
interface NitroRouteRules {
${robotsType} sitemap?: SitemapItemDefaults | false
}
interface NitroRouteConfig {
${robotsType} sitemap?: SitemapItemDefaults | false
}
interface NitroRuntimeHooks {
'sitemap:index-resolved': (ctx: SitemapIndexRenderCtx) => void | Promise
'sitemap:input': (ctx: SitemapInputCtx) => void | Promise
'sitemap:resolved': (ctx: SitemapRenderCtx) => void | Promise
'sitemap:output': (ctx: SitemapOutputHookCtx) => void | Promise
'sitemap:sources': (ctx: SitemapSourcesHookCtx) => void | Promise
}
}
declare module 'nitropack/types' {
interface PrerenderRoute {
_sitemap?: SitemapUrl
}
interface NitroRouteRules {
${robotsType} sitemap?: SitemapItemDefaults | false
}
interface NitroRouteConfig {
${robotsType} sitemap?: SitemapItemDefaults | false
}
interface NitroRuntimeHooks {
'sitemap:index-resolved': (ctx: SitemapIndexRenderCtx) => void | Promise
'sitemap:input': (ctx: SitemapInputCtx) => void | Promise
'sitemap:resolved': (ctx: SitemapRenderCtx) => void | Promise
'sitemap:output': (ctx: SitemapOutputHookCtx) => void | Promise
'sitemap:sources': (ctx: SitemapSourcesHookCtx) => void | Promise
}
}
declare module 'vue-router' {
interface RouteMeta {
sitemap?: SitemapItemDefaults | false
}
}
declare module '#app' {
interface PageMeta {
sitemap?: SitemapItemDefaults | false
}
}
declare module 'nuxt/app' {
interface PageMeta {
sitemap?: SitemapItemDefaults | false
}
}
export {}
`
},
})
// Type definitions for virtual modules
addTemplate({
filename: 'types/nuxt-sitemap-virtual.d.ts',
getContents: () => `declare module '#sitemap-virtual/read-sources.mjs' {
export function readSourcesFromFilesystem(filename: string): Promise
}
declare module '#sitemap-virtual/global-sources.mjs' {
import type { SitemapSourceBase, SitemapSourceResolved } from '#sitemap/types'
export const sources: (SitemapSourceBase | SitemapSourceResolved)[]
}
declare module '#sitemap-virtual/child-sources.mjs' {
import type { SitemapSourceBase, SitemapSourceResolved } from '#sitemap/types'
export const sources: Record
}
declare module '#sitemap/content-filters' {
export const filters: Map boolean>
}
declare module '#sitemap/content-on-url' {
export const onUrlFns: Map, entry: any, collection: string) => void>
}
`,
})
}
================================================
FILE: src/utils/index.ts
================================================
export type * from '../runtime/types'
export { parseHtmlExtractSitemapMeta } from './parseHtmlExtractSitemapMeta'
export { isSitemapIndex, parseSitemapIndex } from './parseSitemapIndex'
export type { SitemapIndexEntry, SitemapIndexParseResult } from './parseSitemapIndex'
export { parseSitemapXml } from './parseSitemapXml'
export type { SitemapParseResult, SitemapWarning } from './parseSitemapXml'
================================================
FILE: src/utils/parseHtmlExtractSitemapMeta.ts
================================================
import type { ElementNode } from 'ultrahtml'
import type { ResolvedSitemapUrl, SitemapUrl, VideoEntry } from '../runtime/types'
import { parseURL } from 'ufo'
import { ELEMENT_NODE, parse, walkSync } from 'ultrahtml'
// Validation helpers
function isValidUrl(url: string): boolean {
if (!url || typeof url !== 'string')
return false
const trimmed = url.trim()
if (!trimmed)
return false
// Reject data URLs, blob URLs, and other non-http(s) protocols for sitemap content
if (trimmed.startsWith('data:') || trimmed.startsWith('blob:') || trimmed.startsWith('file:')) {
return false
}
try {
const parsed = parseURL(trimmed)
// Allow both absolute URLs (with protocol/host) and relative paths (with pathname)
return !!(parsed.protocol && parsed.host) || !!parsed.pathname
}
catch {
return false
}
}
function isValidString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function sanitizeString(value: unknown): string {
if (!isValidString(value))
return ''
// eslint-disable-next-line no-control-regex
return String(value).trim().replace(/[\x00-\x1F\x7F-\x9F]/g, '') // Remove control characters
}
function isValidDate(dateString: string): boolean {
if (!dateString)
return false
const date = new Date(dateString)
return !Number.isNaN(date.getTime()) && date.getFullYear() > 1900 && date.getFullYear() < 3000
}
export function parseHtmlExtractSitemapMeta(html: string, options?: { images?: boolean, videos?: boolean, lastmod?: boolean, alternatives?: boolean, resolveUrl?: (s: string) => string }): Partial | null {
options = options || { images: true, videos: true, lastmod: true, alternatives: true }
const payload: Partial = {}
const resolveUrl = options?.resolveUrl || ((s: string) => s)
let doc: any
try {
doc = parse(html)
}
catch (error) {
console.warn('Failed to parse HTML:', error)
return payload
}
// Collect all needed data in a single traversal
let mainElement: ElementNode | null = null
const images = new Set()
const videos: Array<{ videoObj: VideoEntry, element: ElementNode }> = []
const videoSources = new Map()
let articleModifiedTime: string | undefined
const alternatives: ResolvedSitemapUrl['alternatives'] = []
let isBlocked = false
// First pass: find main element and collect document-level elements
walkSync(doc, (node) => {
if (node.type === ELEMENT_NODE) {
const element = node as ElementNode
const attrs = element.attributes || {}
// Find main element
if (element.name === 'main' && !mainElement) {
mainElement = element
}
// Check for blocking meta tags
if (element.name === 'meta') {
const name = sanitizeString(attrs.name).toLowerCase()
const content = sanitizeString(attrs.content).toLowerCase()
if (name === 'robots' && (content.includes('noindex') || content.includes('none'))) {
isBlocked = true
}
}
// Collect lastmod meta tags (document-level)
if (options?.lastmod && element.name === 'meta') {
const property = sanitizeString(attrs.property)
const content = sanitizeString(attrs.content)
if ((property === 'article:modified_time' || property === 'article:published_time') && content && isValidDate(content)) {
// prioritize modified_time
if (property === 'article:modified_time' || !articleModifiedTime) {
articleModifiedTime = content
}
}
}
// Collect alternative links (document-level)
if (options?.alternatives && element.name === 'link') {
const rel = sanitizeString(attrs.rel)
const href = sanitizeString(attrs.href)
const hreflang = sanitizeString(attrs.hreflang)
if (rel === 'alternate' && href && hreflang && isValidUrl(href)) {
// Validate hreflang format (language codes)
const hreflangPattern = /^[a-z]{2}(?:-[A-Z]{2})?$|^x-default$/
if (hreflangPattern.test(hreflang)) {
try {
const parsed = parseURL(href)
if (parsed.pathname) {
alternatives.push({
hreflang,
href: parsed.pathname,
})
}
}
catch {
// Skip invalid URLs
}
}
}
}
}
})
// Second pass: traverse search scope for content elements
const searchScope = mainElement || doc
walkSync(searchScope, (node) => {
if (node.type === ELEMENT_NODE) {
const element = node as ElementNode
const attrs = element.attributes || {}
// Collect images
if (options?.images && element.name === 'img') {
const src = sanitizeString(attrs.src)
if (src && isValidUrl(src)) {
const resolvedUrl = resolveUrl(src)
if (isValidUrl(resolvedUrl)) {
images.add(resolvedUrl)
}
}
}
// Collect videos
if (options?.videos && element.name === 'video') {
const content_loc = sanitizeString(attrs.src)
const thumbnail_loc = sanitizeString(attrs.poster)
const title = sanitizeString(attrs['data-title'])
const description = sanitizeString(attrs['data-description'])
// Skip videos with invalid required fields
if (!title || !description) {
return
}
const videoObj: VideoEntry = {
content_loc,
thumbnail_loc,
title,
description,
}
// Handle optional video attributes with validation
const player_loc = sanitizeString(attrs['data-player-loc'])
if (player_loc && isValidUrl(player_loc)) {
videoObj.player_loc = player_loc
}
const duration = sanitizeString(attrs['data-duration'])
if (duration) {
const parsedDuration = Number.parseInt(duration, 10)
if (!Number.isNaN(parsedDuration) && parsedDuration > 0 && parsedDuration <= 28800) { // Max 8 hours
videoObj.duration = parsedDuration
}
}
const expiration_date = sanitizeString(attrs['data-expiration-date'])
if (expiration_date && isValidDate(expiration_date)) {
videoObj.expiration_date = expiration_date
}
const rating = sanitizeString(attrs['data-rating'])
if (rating) {
const parsedRating = Number.parseFloat(rating)
if (!Number.isNaN(parsedRating) && parsedRating >= 0 && parsedRating <= 5) {
videoObj.rating = parsedRating
}
}
const view_count = sanitizeString(attrs['data-view-count'])
if (view_count) {
const parsedViewCount = Number.parseInt(view_count, 10)
if (!Number.isNaN(parsedViewCount) && parsedViewCount >= 0) {
videoObj.view_count = parsedViewCount
}
}
const publication_date = sanitizeString(attrs['data-publication-date'])
if (publication_date && isValidDate(publication_date)) {
videoObj.publication_date = publication_date
}
const family_friendly = sanitizeString(attrs['data-family-friendly'])
if (family_friendly && ['yes', 'no'].includes(family_friendly.toLowerCase())) {
videoObj.family_friendly = family_friendly.toLowerCase() as VideoEntry['family_friendly']
}
const requires_subscription = sanitizeString(attrs['data-requires-subscription'])
if (requires_subscription && ['yes', 'no'].includes(requires_subscription.toLowerCase())) {
videoObj.requires_subscription = requires_subscription.toLowerCase() as VideoEntry['requires_subscription']
}
const live = sanitizeString(attrs['data-live'])
if (live && ['yes', 'no'].includes(live.toLowerCase())) {
videoObj.live = live.toLowerCase() as VideoEntry['live']
}
const tag = sanitizeString(attrs['data-tag'])
if (tag && tag.length <= 256) { // Reasonable tag length limit
videoObj.tag = tag
}
// Store video element for later source processing
videos.push({ videoObj, element })
}
// Collect video sources
if (options?.videos && element.name === 'source' && element.parent && element.parent.name === 'video') {
const videoElement = element.parent as ElementNode
const src = sanitizeString(attrs.src)
if (src && isValidUrl(src)) {
if (!videoSources.has(videoElement)) {
videoSources.set(videoElement, [])
}
videoSources.get(videoElement)!.push(src)
}
}
}
})
// Process collected data
if (options?.images && images.size > 0) {
payload.images = Array.from(images, i => ({ loc: i }))
}
if (options?.videos) {
const processedVideos: VideoEntry[] = []
for (const { videoObj, element } of videos) {
const sources = videoSources.get(element) || []
if (sources.length > 0) {
// Video has source elements - create one video entry per source
for (const source of sources) {
const resolvedVideoObj = { ...videoObj }
if (resolvedVideoObj.thumbnail_loc) {
resolvedVideoObj.thumbnail_loc = resolveUrl(String(resolvedVideoObj.thumbnail_loc))
}
processedVideos.push({
...resolvedVideoObj,
content_loc: resolveUrl(source),
})
}
}
else {
// Video has no source elements - use the video element directly
processedVideos.push(videoObj)
}
}
const validVideos = processedVideos.filter((v) => {
return (
isValidString(v.title)
&& isValidString(v.description)
&& isValidString(v.content_loc) && isValidUrl(v.content_loc)
&& isValidString(v.thumbnail_loc) && isValidUrl(v.thumbnail_loc)
&& v.title.length <= 2048 // Google's title limit
&& v.description.length <= 2048 // Google's description limit
)
})
if (validVideos.length > 0) {
payload.videos = validVideos
}
}
if (options?.lastmod && articleModifiedTime) {
payload.lastmod = articleModifiedTime
}
if (options?.alternatives && alternatives.length > 0 && (alternatives.length > 1 || alternatives[0]?.hreflang !== 'x-default')) {
payload.alternatives = alternatives
}
// Return null if blocked from indexing
if (isBlocked) {
return null
}
return payload
}
================================================
FILE: src/utils/parseSitemapIndex.ts
================================================
import type { SitemapWarning } from './parseSitemapXml'
export interface SitemapIndexEntry {
loc: string
lastmod?: string
}
export interface SitemapIndexParseResult {
entries: SitemapIndexEntry[]
warnings: SitemapWarning[]
}
interface ParsedSitemap {
loc?: string
lastmod?: string
}
interface ParsedSitemapIndex {
sitemap?: ParsedSitemap | ParsedSitemap[]
}
interface ParsedRoot {
sitemapindex?: ParsedSitemapIndex
}
function isValidUrl(value: string): boolean {
return URL.canParse(value)
}
export async function parseSitemapIndex(xml: string): Promise {
if (!xml)
throw new Error('Empty XML input provided')
const { XMLParser } = await import('fast-xml-parser')
const parser = new XMLParser({
isArray: (tagName: string) => tagName === 'sitemap',
removeNSPrefix: true,
trimValues: true,
})
const parsed = parser.parse(xml) as ParsedRoot
if (parsed?.sitemapindex === undefined)
throw new Error('XML does not contain a valid sitemapindex element')
if (!parsed.sitemapindex || !parsed.sitemapindex.sitemap)
return { entries: [], warnings: [] }
const sitemaps = Array.isArray(parsed.sitemapindex.sitemap)
? parsed.sitemapindex.sitemap
: [parsed.sitemapindex.sitemap]
const warnings: SitemapWarning[] = []
const entries: SitemapIndexEntry[] = []
for (const s of sitemaps) {
if (typeof s.loc !== 'string' || !s.loc.trim().length) {
warnings.push({
type: 'validation',
message: 'Sitemap entry missing required loc element',
})
continue
}
const loc = s.loc.trim()
if (!isValidUrl(loc)) {
warnings.push({
type: 'validation',
message: 'Sitemap entry has invalid URL',
context: { url: loc },
})
continue
}
entries.push({
loc,
...(s.lastmod && { lastmod: s.lastmod.trim() }),
})
}
return { entries, warnings }
}
export function isSitemapIndex(xml: string): boolean {
return xml.includes('')
}
================================================
FILE: src/utils/parseSitemapXml.ts
================================================
import type { AlternativeEntry, GoogleNewsEntry, ImageEntry, SitemapStrict, SitemapUrl, SitemapUrlInput, VideoEntry } from '../runtime/types'
interface ParsedUrl {
loc?: string
lastmod?: string
changefreq?: string
priority?: string | number
image?: ParsedImage | ParsedImage[]
video?: ParsedVideo | ParsedVideo[]
link?: ParsedLink | ParsedLink[]
news?: ParsedNews
}
interface ParsedImage {
loc?: string
}
interface ParsedVideo {
title?: string
thumbnail_loc?: string
description?: string
content_loc?: string
player_loc?: string
duration?: string | number
expiration_date?: string
rating?: string | number
view_count?: string | number
publication_date?: string
family_friendly?: string
requires_subscription?: string
live?: string
restriction?: {
'relationship'?: string
'#text'?: string
}
platform?: {
'relationship'?: string
'#text'?: string
}
price?: ParsedPrice | ParsedPrice[]
uploader?: {
'info'?: string
'#text'?: string
}
tag?: string | string[]
}
interface ParsedPrice {
'#text'?: string
'currency'?: string
'type'?: string
}
interface ParsedLink {
rel?: string
hreflang?: string
href?: string
}
interface ParsedNews {
title?: string
publication_date?: string
publication?: {
name?: string
language?: string
}
}
interface ParsedUrlset {
url?: ParsedUrl | ParsedUrl[]
}
interface ParsedRoot {
urlset?: ParsedUrlset
}
export interface SitemapWarning {
type: 'validation'
message: string
context?: {
url?: string
field?: string
value?: unknown
}
}
export interface SitemapParseResult {
urls: SitemapUrlInput[]
warnings: SitemapWarning[]
}
function isValidString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function parseNumber(value: unknown): number | undefined {
if (typeof value === 'number')
return value
if (typeof value === 'string' && value.trim()) {
const num = Number.parseFloat(value.trim())
return Number.isNaN(num) ? undefined : num
}
return undefined
}
function parseInteger(value: unknown): number | undefined {
if (typeof value === 'number')
return Math.floor(value)
if (typeof value === 'string' && value.trim()) {
const num = Number.parseInt(value.trim(), 10)
return Number.isNaN(num) ? undefined : num
}
return undefined
}
function extractUrlFromParsedElement(
urlElement: ParsedUrl,
warnings: SitemapWarning[],
): SitemapUrlInput | null {
if (!isValidString(urlElement.loc)) {
warnings.push({
type: 'validation',
message: 'URL entry missing required loc element',
context: { url: String(urlElement.loc || 'undefined') },
})
return null
}
const urlObj: Partial & { loc: string } = { loc: urlElement.loc }
// Handle optional fields with validation
if (isValidString(urlElement.lastmod)) {
urlObj.lastmod = urlElement.lastmod
}
if (isValidString(urlElement.changefreq)) {
const validFreqs = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
if (validFreqs.includes(urlElement.changefreq)) {
urlObj.changefreq = urlElement.changefreq as SitemapStrict['changefreq']
}
else {
warnings.push({
type: 'validation',
message: 'Invalid changefreq value',
context: { url: urlElement.loc, field: 'changefreq', value: urlElement.changefreq },
})
}
}
const priority = parseNumber(urlElement.priority)
if (priority !== undefined && !Number.isNaN(priority)) {
if (priority < 0 || priority > 1) {
warnings.push({
type: 'validation',
message: 'Priority value should be between 0.0 and 1.0, clamping to valid range',
context: { url: urlElement.loc, field: 'priority', value: priority },
})
}
// Clamp priority to valid sitemap range
urlObj.priority = Math.max(0, Math.min(1, priority)) as SitemapStrict['priority']
}
else if (urlElement.priority !== undefined) {
warnings.push({
type: 'validation',
message: 'Invalid priority value',
context: { url: urlElement.loc, field: 'priority', value: urlElement.priority },
})
}
// Handle images
if (urlElement.image) {
const images = Array.isArray(urlElement.image) ? urlElement.image : [urlElement.image]
const validImages: ImageEntry[] = images
.map((img: ParsedImage): ImageEntry | null => {
if (isValidString(img.loc)) {
return { loc: img.loc }
}
else {
warnings.push({
type: 'validation',
message: 'Image missing required loc element',
context: { url: urlElement.loc, field: 'image.loc' },
})
return null
}
})
.filter((img): img is ImageEntry => img !== null)
if (validImages.length > 0) {
urlObj.images = validImages
}
}
// Handle videos
if (urlElement.video) {
const videos = Array.isArray(urlElement.video) ? urlElement.video : [urlElement.video]
const validVideos: VideoEntry[] = videos
.map((video: ParsedVideo): VideoEntry | null => {
// Check required fields
const missingFields: string[] = []
if (!isValidString(video.title))
missingFields.push('title')
if (!isValidString(video.thumbnail_loc))
missingFields.push('thumbnail_loc')
if (!isValidString(video.description))
missingFields.push('description')
if (!isValidString(video.content_loc))
missingFields.push('content_loc')
if (missingFields.length > 0) {
warnings.push({
type: 'validation',
message: `Video missing required fields: ${missingFields.join(', ')}`,
context: { url: urlElement.loc, field: 'video' },
})
return null
}
const videoObj: VideoEntry = {
title: video.title!,
thumbnail_loc: video.thumbnail_loc!,
description: video.description!,
content_loc: video.content_loc!,
}
// Handle optional video fields
if (isValidString(video.player_loc)) {
videoObj.player_loc = video.player_loc
}
const duration = parseInteger(video.duration)
if (duration !== undefined) {
videoObj.duration = duration
}
else if (video.duration !== undefined) {
warnings.push({
type: 'validation',
message: 'Invalid video duration value',
context: { url: urlElement.loc, field: 'video.duration', value: video.duration },
})
}
if (isValidString(video.expiration_date)) {
videoObj.expiration_date = video.expiration_date
}
const rating = parseNumber(video.rating)
if (rating !== undefined) {
if (rating < 0 || rating > 5) {
warnings.push({
type: 'validation',
message: 'Video rating should be between 0.0 and 5.0',
context: { url: urlElement.loc, field: 'video.rating', value: rating },
})
}
videoObj.rating = rating
}
else if (video.rating !== undefined) {
warnings.push({
type: 'validation',
message: 'Invalid video rating value',
context: { url: urlElement.loc, field: 'video.rating', value: video.rating },
})
}
const viewCount = parseInteger(video.view_count)
if (viewCount !== undefined) {
videoObj.view_count = viewCount
}
else if (video.view_count !== undefined) {
warnings.push({
type: 'validation',
message: 'Invalid video view_count value',
context: { url: urlElement.loc, field: 'video.view_count', value: video.view_count },
})
}
if (isValidString(video.publication_date)) {
videoObj.publication_date = video.publication_date
}
if (isValidString(video.family_friendly)) {
const validValues = ['yes', 'no']
if (validValues.includes(video.family_friendly)) {
videoObj.family_friendly = video.family_friendly as VideoEntry['family_friendly']
}
else {
warnings.push({
type: 'validation',
message: 'Invalid video family_friendly value, should be "yes" or "no"',
context: { url: urlElement.loc, field: 'video.family_friendly', value: video.family_friendly },
})
}
}
if (isValidString(video.requires_subscription)) {
const validValues = ['yes', 'no']
if (validValues.includes(video.requires_subscription)) {
videoObj.requires_subscription = video.requires_subscription as VideoEntry['requires_subscription']
}
else {
warnings.push({
type: 'validation',
message: 'Invalid video requires_subscription value, should be "yes" or "no"',
context: { url: urlElement.loc, field: 'video.requires_subscription', value: video.requires_subscription },
})
}
}
if (isValidString(video.live)) {
const validValues = ['yes', 'no']
if (validValues.includes(video.live)) {
videoObj.live = video.live as VideoEntry['live']
}
else {
warnings.push({
type: 'validation',
message: 'Invalid video live value, should be "yes" or "no"',
context: { url: urlElement.loc, field: 'video.live', value: video.live },
})
}
}
// Handle restriction (element-based, not attribute-based)
if (video.restriction && typeof video.restriction === 'object') {
const restriction = video.restriction
if (isValidString(restriction.relationship) && isValidString(restriction['#text'])) {
const validRelationships = ['allow', 'deny']
if (validRelationships.includes(restriction.relationship)) {
videoObj.restriction = {
relationship: restriction.relationship as 'allow' | 'deny',
restriction: restriction['#text'],
}
}
else {
warnings.push({
type: 'validation',
message: 'Invalid video restriction relationship, should be "allow" or "deny"',
context: { url: urlElement.loc, field: 'video.restriction.relationship', value: restriction.relationship },
})
}
}
}
// Handle platform (element-based, not attribute-based)
if (video.platform && typeof video.platform === 'object') {
const platform = video.platform
if (isValidString(platform.relationship) && isValidString(platform['#text'])) {
const validRelationships = ['allow', 'deny']
if (validRelationships.includes(platform.relationship)) {
videoObj.platform = {
relationship: platform.relationship as 'allow' | 'deny',
platform: platform['#text'],
}
}
else {
warnings.push({
type: 'validation',
message: 'Invalid video platform relationship, should be "allow" or "deny"',
context: { url: urlElement.loc, field: 'video.platform.relationship', value: platform.relationship },
})
}
}
}
// Handle price - keep as strings to maintain precision
if (video.price) {
const prices = Array.isArray(video.price) ? video.price : [video.price]
const validPrices = prices
.map((price: ParsedPrice) => {
const priceValue = price['#text']
if (priceValue == null || (typeof priceValue !== 'string' && typeof priceValue !== 'number')) {
warnings.push({
type: 'validation',
message: 'Video price missing value',
context: { url: urlElement.loc, field: 'video.price' },
})
return null
}
const validTypes = ['rent', 'purchase', 'package', 'subscription']
if (price.type && !validTypes.includes(price.type)) {
warnings.push({
type: 'validation',
message: `Invalid video price type "${price.type}", should be one of: ${validTypes.join(', ')}`,
context: { url: urlElement.loc, field: 'video.price.type', value: price.type },
})
}
return {
price: String(priceValue),
currency: price.currency,
type: price.type as NonNullable[0]['type'],
}
})
.filter((p): p is NonNullable => p !== null)
if (validPrices.length > 0) {
videoObj.price = validPrices
}
}
// Handle uploader (element-based)
if (video.uploader && typeof video.uploader === 'object') {
const uploader = video.uploader
if (isValidString(uploader.info) && isValidString(uploader['#text'])) {
videoObj.uploader = {
uploader: uploader['#text'],
info: uploader.info,
}
}
else {
warnings.push({
type: 'validation',
message: 'Video uploader missing required info or name',
context: { url: urlElement.loc, field: 'video.uploader' },
})
}
}
// Handle tags
if (video.tag) {
const tags = Array.isArray(video.tag) ? video.tag : [video.tag]
const validTags = tags.filter(isValidString)
if (validTags.length > 0) {
videoObj.tag = validTags
}
}
return videoObj
})
.filter((video): video is VideoEntry => video !== null)
if (validVideos.length > 0) {
urlObj.videos = validVideos
}
}
// Handle alternatives (element-based xhtml:link)
if (urlElement.link) {
const links = Array.isArray(urlElement.link) ? urlElement.link : [urlElement.link]
const alternatives: AlternativeEntry[] = links
.map((link: ParsedLink): AlternativeEntry | null => {
if (link.rel === 'alternate' && isValidString(link.hreflang) && isValidString(link.href)) {
return {
hreflang: link.hreflang,
href: link.href,
}
}
else {
warnings.push({
type: 'validation',
message: 'Alternative link missing required rel="alternate", hreflang, or href',
context: { url: urlElement.loc, field: 'link' },
})
return null
}
})
.filter((alt): alt is AlternativeEntry => alt !== null)
if (alternatives.length > 0) {
urlObj.alternatives = alternatives
}
}
// Handle news
if (urlElement.news && typeof urlElement.news === 'object') {
const news = urlElement.news as ParsedNews
if (
isValidString(news.title)
&& isValidString(news.publication_date)
&& news.publication
&& isValidString(news.publication.name)
&& isValidString(news.publication.language)
) {
urlObj.news = {
title: news.title!,
publication_date: news.publication_date!,
publication: {
name: news.publication.name!,
language: news.publication.language!,
},
} as GoogleNewsEntry
}
else {
warnings.push({
type: 'validation',
message: 'News entry missing required fields (title, publication_date, publication.name, publication.language)',
context: { url: urlElement.loc, field: 'news' },
})
}
}
// Filter out undefined values and empty arrays - Object.fromEntries loses type info so cast is necessary
return Object.fromEntries(
Object.entries(urlObj).filter(([_, value]) =>
value != null && (!Array.isArray(value) || value.length > 0),
),
) as unknown as SitemapUrl
}
export async function parseSitemapXml(xml: string): Promise {
const warnings: SitemapWarning[] = []
if (!xml) {
throw new Error('Empty XML input provided')
}
const { XMLParser } = await import('fast-xml-parser')
const parser = new XMLParser({
isArray: (tagName: string): boolean =>
['url', 'image', 'video', 'link', 'tag', 'price'].includes(tagName),
removeNSPrefix: true,
parseAttributeValue: false,
ignoreAttributes: false,
attributeNamePrefix: '',
trimValues: true,
})
try {
const parsed = parser.parse(xml) as ParsedRoot
if (!parsed?.urlset) {
throw new Error('XML does not contain a valid urlset element')
}
if (!parsed.urlset.url) {
throw new Error('Sitemap contains no URL entries')
}
const urls = Array.isArray(parsed.urlset.url) ? parsed.urlset.url : [parsed.urlset.url]
const validUrls = urls
.map((url: ParsedUrl) => extractUrlFromParsedElement(url, warnings))
.filter((url): url is SitemapUrlInput => url !== null)
if (validUrls.length === 0 && urls.length > 0) {
warnings.push({
type: 'validation',
message: 'No valid URLs found in sitemap after validation',
})
}
return { urls: validUrls, warnings }
}
catch (error) {
if (error instanceof Error && (
error.message === 'Empty XML input provided'
|| error.message === 'XML does not contain a valid urlset element'
|| error.message === 'Sitemap contains no URL entries'
)) {
throw error
}
throw new Error(`Failed to parse XML: ${error instanceof Error ? error.message : String(error)}`)
}
}
================================================
FILE: src/utils-internal/filter.ts
================================================
import type { FilterInput } from '../runtime/types'
/**
* Check if a filter is valid, otherwise exclude it
* @param filter string | RegExp | RegexObjectType
*
*/
function isValidFilter(filter: FilterInput): boolean {
if (typeof filter === 'string')
return true
if (filter instanceof RegExp)
return true
if (typeof filter === 'object' && typeof filter.regex === 'string')
return true
// check if the object has a toString() function
return false
}
/**
* Transform the RegeExp into RegexObjectType
*/
export function normalizeFilters(filters: FilterInput[] | undefined) {
return (filters || []).map((filter) => {
if (!isValidFilter(filter)) {
console.warn(`[@nuxtjs/sitemap] You have provided an invalid filter: ${filter}, ignoring.`)
return false
}
// regex needs to be converted into an object that can be serialized
return filter instanceof RegExp ? { regex: filter.toString() } : filter
}).filter(Boolean) as FilterInput[]
}
================================================
FILE: src/utils-internal/i18n.ts
================================================
import type { AutoI18nConfig } from 'nuxtseo-shared/i18n'
import type { FilterInput } from '../runtime/types'
import { splitPathForI18nLocales as _splitPathForI18nLocales } from 'nuxtseo-shared/i18n'
export { generatePathForI18nPages, normalizeLocales } from 'nuxtseo-shared/i18n'
export type { AutoI18nConfig, Strategies, StrategyProps } from 'nuxtseo-shared/i18n'
export function splitPathForI18nLocales(path: FilterInput, autoI18n: AutoI18nConfig): FilterInput | FilterInput[] {
if (typeof path !== 'string')
return path
return _splitPathForI18nLocales(path, autoI18n)
}
================================================
FILE: src/utils-internal/kit.ts
================================================
export { createNitroPromise, createPagesPromise, detectTarget, getNuxtModuleOptions, isNuxtGenerate, resolveNitroPreset, resolveNuxtContentVersion } from 'nuxtseo-shared/kit'
================================================
FILE: src/utils-internal/nuxtSitemap.ts
================================================
import type { Nuxt } from '@nuxt/schema'
import type { ConsolaInstance } from 'consola'
import type { NuxtPage } from 'nuxt/schema'
import type { AutoI18nConfig, SitemapDefinition, SitemapUrl, SitemapUrlInput } from '../runtime/types'
import type { CreateFilterOptions } from '../runtime/utils-pure'
import { statSync } from 'node:fs'
import { useNuxt } from '@nuxt/kit'
import { defu } from 'defu'
import { extname } from 'pathe'
import { withBase, withHttps } from 'ufo'
import { createPathFilter } from '../runtime/utils-pure'
export async function resolveUrls(urls: Required['urls'], ctx: { logger: ConsolaInstance, path: string }): Promise {
try {
if (typeof urls === 'function')
urls = urls()
// resolve promise
urls = await urls
}
catch (e) {
ctx.logger.error(`Failed to resolve ${typeof urls} urls.`)
ctx.logger.error(e)
return []
}
// we need to validate that the urls can be serialised properly for example to avoid circular references
try {
urls = JSON.parse(JSON.stringify(urls))
}
catch (e) {
ctx.logger.error(`Failed to serialize ${typeof urls} \`${ctx.path}\`, please make sure that the urls resolve as a valid array without circular dependencies.`)
ctx.logger.error(e)
return []
}
return urls as SitemapUrlInput[]
}
export interface NuxtPagesToSitemapEntriesOptions {
normalisedLocales: AutoI18nConfig['locales']
routesNameSeparator?: string
autoLastmod: boolean
defaultLocale: string
strategy: 'no_prefix' | 'prefix_except_default' | 'prefix' | 'prefix_and_default'
isI18nMapped: boolean
isI18nMicro: boolean
filter: CreateFilterOptions
autoI18n: boolean
}
interface PageEntry extends SitemapUrl {
page?: NuxtPage
locale?: string
depth?: number
}
function deepForEachPage(
pages: NuxtPage[],
callback: (page: NuxtPage, fullpath: string, depth: number) => void,
opts: NuxtPagesToSitemapEntriesOptions,
fullpath: string | undefined | null = null,
depth: number = 0,
) {
pages.forEach((page) => {
let currentPath
if (page.path.startsWith('/')) {
currentPath = page.path
}
else {
currentPath = page.path === '' ? fullpath : `${fullpath!.replace(/\/$/, '')}/${page.path}`
}
let didCallback = false
if (opts.isI18nMicro) {
const localePattern = /\/:locale\(([^)]+)\)/
const match = localePattern.exec(currentPath || '')
if (match && match[1]) {
const locales = match[1].split('|')
locales.forEach((locale) => {
const subPage = { ...page }
const localizedPath = (currentPath || '').replace(localePattern, `/${locale}`)
subPage.name += opts.routesNameSeparator + locale
subPage.path = localizedPath
callback(subPage, localizedPath || '', depth)
didCallback = true
})
}
}
if (!didCallback) {
callback(page, currentPath || '', depth)
}
if (page.children) {
deepForEachPage(page.children, callback, opts, currentPath, depth + 1)
}
})
}
export function convertNuxtPagesToSitemapEntries(pages: NuxtPage[], config: NuxtPagesToSitemapEntriesOptions) {
const pathFilter = createPathFilter(config.filter)
const routesNameSeparator = config.routesNameSeparator || '___'
let flattenedPages: PageEntry[] = []
deepForEachPage(
pages,
(page, loc, depth) => {
flattenedPages.push({ page, loc, depth })
},
{
...config,
routesNameSeparator: config.routesNameSeparator || '___',
},
)
flattenedPages = flattenedPages
// Removing dynamic routes
.filter(page => !page.loc.includes(':'))
// Removing duplicates
.filter((page, idx, arr) => {
return !arr.some((p) => {
return p.loc === page.loc && p.depth! > page.depth!
})
})
.map((p) => {
delete p.depth
return p
})
if (config.strategy === 'prefix_and_default') {
// filter out any pages started with the default locale
flattenedPages = flattenedPages.filter((p) => {
if (p.page?.name) {
const [, locale] = p.page.name.split(routesNameSeparator)
return locale !== config.defaultLocale || p.page.name.endsWith('__default')
}
return true
})
}
const pagesWithMeta = flattenedPages.map((p) => {
if (config.autoLastmod && p.page!.file) {
const stats = statSync(p.page!.file, { throwIfNoEntry: false })
if (stats?.mtime)
p.lastmod = stats.mtime
}
if (p.page?.meta?.sitemap) {
// merge in page meta
p = defu(p.page.meta.sitemap, p)
}
return p
})
const localeGroups: Record = {}
pagesWithMeta.reduce((acc: Record, e) => {
if (e.page!.name?.includes(routesNameSeparator)) {
const [name, locale] = e.page!.name.split(routesNameSeparator)
if (!name)
return acc
if (!acc[name])
acc[name] = []
const { _sitemap } = config.normalisedLocales.find(l => l.code === locale) || { _sitemap: locale }
acc[name].push({ ...e, _sitemap: config.isI18nMapped ? _sitemap : undefined, locale })
}
else {
acc.default = acc.default || []
acc.default.push(e)
}
return acc
}, localeGroups)
// now need to convert to alternatives
return Object.entries(localeGroups).map(([locale, entries]) => {
if (locale === 'default') {
// we add pages without a prefix, they may have disabled i18n
return entries.map((e) => {
const [name] = (e.page?.name || '').split(routesNameSeparator)
if (!name)
return false
// we need to check if the same page with a prefix exists within the default locale
// for example this will fix the `/` if the configuration is set to `prefix`
if (localeGroups[name]?.some(a => a.locale === config.defaultLocale))
return false
const defaultLocale = config.normalisedLocales.find(l => l.code === config.defaultLocale)
if (defaultLocale && config.isI18nMapped)
e._sitemap = defaultLocale._sitemap
delete e.page
delete e.locale
return { ...e }
}).filter(Boolean)
}
return entries.map((entry) => {
const alternatives = config.autoI18n
? entries.map((entry) => {
const locale = config.normalisedLocales.find(l => l.code === entry.locale)
// check if the locale has a iso code
if (!pathFilter(entry.loc))
return false
const href = locale?.domain ? withHttps(withBase(entry.loc, locale?.domain)) : entry.loc
return {
hreflang: locale?._hreflang,
href,
}
}).filter(Boolean)
: []
if (config.autoI18n) {
const xDefault = entries.find(a => a.locale === config.defaultLocale)
if (xDefault && alternatives.length && pathFilter(xDefault.loc)) {
const locale = config.normalisedLocales.find(l => l.code === xDefault.locale)
const href = locale?.domain ? withHttps(withBase(xDefault.loc, locale?.domain)) : xDefault.loc
alternatives.push({
hreflang: 'x-default',
href,
})
}
}
const e = { ...entry }
if (config.isI18nMapped) {
const { _sitemap } = config.normalisedLocales.find(l => l.code === entry.locale) || { _sitemap: locale }
e._sitemap = _sitemap
}
delete e.page
delete e.locale
return {
...e,
...(alternatives.length ? { alternatives } : {}),
}
})
}).filter(Boolean).flat() as SitemapUrlInput[]
}
export function generateExtraRoutesFromNuxtConfig(nuxt: Nuxt = useNuxt()) {
const filterForValidPage = (p: unknown): p is string => typeof p === 'string' && !!p && !extname(p) && !p.startsWith('/api/') && !p.startsWith('/_')
const routeRules = Object.entries(nuxt.options.routeRules || {})
.filter(([k, v]) => {
// make sure key doesn't use a wildcard and its not for a file
if (k.includes('*') || k.includes('.') || k.includes(':'))
return false
if ('robots' in v && typeof v.robots === 'boolean' && !v.robots)
return false
// make sure that we're not redirecting
return !v.redirect
})
.map(([k]) => k)
.filter(filterForValidPage)
// don't support files
const prerenderUrls = (nuxt.options.nitro.prerender?.routes || [])
.filter(filterForValidPage) as string[]
return { routeRules, prerenderUrls }
}
================================================
FILE: test/bench/i18n.bench.ts
================================================
import type { SitemapSourceResolved } from '#sitemap'
import { bench, describe } from 'vitest'
import { resolveSitemapEntries } from '../../src/runtime/server/sitemap/builder/sitemap'
const sources: SitemapSourceResolved[] = [
{
urls: Array.from({ length: 3000 }, (_, i) => ({
loc: `/foo-${i}`,
})),
context: {
name: 'foo',
},
sourceType: 'user',
},
]
describe('i18n', () => {
bench('normaliseI18nSources', () => {
resolveSitemapEntries({
sitemapName: 'sitemap.xml',
}, sources.flatMap(s => s.urls), {
autoI18n: {
locales: [
{ code: 'en', iso: 'en' },
{ code: 'fr', iso: 'fr' },
// add 22 more locales
...Array.from({ length: 22 }, (_, i) => ({
code: `code-${i}`,
iso: `iso-${i}`,
})),
],
strategy: 'prefix',
defaultLocale: 'en',
},
isI18nMapped: true,
})
}, {
iterations: 1000,
})
})
================================================
FILE: test/bench/normalize.bench.ts
================================================
import type { SitemapSourceResolved } from '#sitemap'
import { bench, describe } from 'vitest'
import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
const sources: SitemapSourceResolved[] = [
{
urls: Array.from({ length: 3000 }, (_, i) => ({
loc: `/foo-${i}`,
})),
context: {
name: 'foo',
},
sourceType: 'user',
},
]
describe('normalize', () => {
bench('preNormalizeEntry', () => {
const urls = sources.flatMap(s => s.urls)
urls.map(u => preNormalizeEntry(u))
}, {
iterations: 1000,
})
})
================================================
FILE: test/bench/sitemap.bench.ts
================================================
import type { AlternativeEntry, AutoI18nConfig, ModuleRuntimeConfig, NitroUrlResolvers, ResolvedSitemapUrl, SitemapDefinition, SitemapUrl, SitemapUrlInput } from '../../src/runtime/types'
import { joinURL, withHttps } from 'ufo'
import { bench, describe } from 'vitest'
import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
import { createPathFilter, splitForLocales } from '../../src/runtime/utils-pure'
interface NormalizedI18n extends ResolvedSitemapUrl {
_pathWithoutPrefix: string
_locale: AutoI18nConfig['locales'][number]
_index?: number
}
function getPageKey(pathWithoutPrefix: string): string {
const stripped = pathWithoutPrefix[0] === '/' ? pathWithoutPrefix.slice(1) : pathWithoutPrefix
return stripped.endsWith('/index') ? stripped.slice(0, -6) || 'index' : stripped || 'index'
}
// Implementation matching src/runtime/server/sitemap/builder/sitemap.ts
function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapUrlInput[], runtimeConfig: Pick, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl[] {
const {
autoI18n,
isI18nMapped,
} = runtimeConfig
const filterPath = createPathFilter({
include: sitemap.include,
exclude: sitemap.exclude,
})
const _urls = urls.map((_e) => {
const e = preNormalizeEntry(_e, resolvers)
if (!e.loc || !filterPath(e.loc))
return false
return e
}).filter(Boolean) as ResolvedSitemapUrl[]
let validI18nUrlsForTransform: NormalizedI18n[] = []
const withoutPrefixPaths: Record = {}
if (autoI18n && autoI18n.strategy !== 'no_prefix') {
const localeCodes = autoI18n.locales.map(l => l.code)
const localeByCode = new Map(autoI18n.locales.map(l => [l.code, l]))
const isPrefixStrategy = autoI18n.strategy === 'prefix'
const isPrefixExceptOrAndDefault = autoI18n.strategy === 'prefix_and_default' || autoI18n.strategy === 'prefix_except_default'
const xDefaultAndLocales = [{ code: 'x-default', _hreflang: 'x-default' }, ...autoI18n.locales] as Array<{ code: string, _hreflang: string }>
const defaultLocale = autoI18n.defaultLocale
const hasPages = !!autoI18n.pages
const hasDifferentDomains = !!autoI18n.differentDomains
validI18nUrlsForTransform = _urls.map((_e, i) => {
if (_e._abs)
return false
const split = splitForLocales(_e._relativeLoc, localeCodes)
let localeCode = split[0]
const pathWithoutPrefix = split[1]
if (!localeCode)
localeCode = defaultLocale
const e = _e as NormalizedI18n
e._pathWithoutPrefix = pathWithoutPrefix
const locale = localeByCode.get(localeCode)
if (!locale)
return false
e._locale = locale
e._index = i
e._key = `${e._sitemap || ''}${e._path?.pathname || '/'}${e._path?.search || ''}`
withoutPrefixPaths[pathWithoutPrefix] = withoutPrefixPaths[pathWithoutPrefix] || []
if (!withoutPrefixPaths[pathWithoutPrefix].some(e => e._locale.code === locale.code))
withoutPrefixPaths[pathWithoutPrefix].push(e)
return e
}).filter(Boolean) as NormalizedI18n[]
for (const e of validI18nUrlsForTransform) {
if (!e._i18nTransform && !e.alternatives?.length) {
const alternatives = (withoutPrefixPaths[e._pathWithoutPrefix] || [])
.map((u) => {
const entries: AlternativeEntry[] = []
if (u._locale.code === defaultLocale) {
entries.push({
href: u.loc,
hreflang: 'x-default',
})
}
entries.push({
href: u.loc,
hreflang: u._locale._hreflang || defaultLocale,
})
return entries
})
.flat()
.filter(Boolean) as AlternativeEntry[]
if (alternatives.length)
e.alternatives = alternatives
}
else if (e._i18nTransform) {
delete e._i18nTransform
if (hasDifferentDomains) {
const defLocale = localeByCode.get(defaultLocale)
e.alternatives = [
{
...defLocale,
code: 'x-default',
},
...autoI18n.locales
.filter(l => !!l.domain),
]
.map((locale) => {
return {
hreflang: locale._hreflang!,
href: joinURL(withHttps(locale.domain!), e._pathWithoutPrefix),
}
})
}
else {
const pageKey = hasPages ? getPageKey(e._pathWithoutPrefix) : ''
const pageMappings = hasPages ? autoI18n.pages![pageKey] : undefined
const pathSearch = e._path?.search || ''
const pathWithoutPrefix = e._pathWithoutPrefix
for (const l of autoI18n.locales) {
let loc = pathWithoutPrefix
if (hasPages && pageMappings && pageMappings[l.code] !== undefined) {
const customPath = pageMappings[l.code]
if (customPath === false)
continue
if (typeof customPath === 'string')
loc = customPath[0] === '/' ? customPath : `/${customPath}`
}
else if (!hasDifferentDomains && !(isPrefixExceptOrAndDefault && l.code === defaultLocale)) {
loc = joinURL(`/${l.code}`, pathWithoutPrefix)
}
const _sitemap = isI18nMapped ? l._sitemap : undefined
const alternatives: AlternativeEntry[] = []
for (const locale of xDefaultAndLocales) {
const code = locale.code === 'x-default' ? defaultLocale : locale.code
const isDefault = locale.code === 'x-default' || locale.code === defaultLocale
let href = pathWithoutPrefix
if (hasPages && pageMappings && pageMappings[code] !== undefined) {
const customPath = pageMappings[code]
if (customPath === false)
continue
if (typeof customPath === 'string')
href = customPath[0] === '/' ? customPath : `/${customPath}`
}
else if (isPrefixStrategy) {
href = joinURL('/', code, pathWithoutPrefix)
}
else if (isPrefixExceptOrAndDefault && !isDefault) {
href = joinURL('/', code, pathWithoutPrefix)
}
if (!filterPath(href))
continue
alternatives.push({
hreflang: locale._hreflang,
href,
})
}
const { _index: _, ...rest } = e
const newEntry = preNormalizeEntry({
_sitemap,
...rest,
_key: `${_sitemap || ''}${loc || '/'}${pathSearch}`,
_locale: l,
loc,
alternatives,
} as SitemapUrl, resolvers) as NormalizedI18n
if (e._locale.code === newEntry._locale.code) {
_urls[e._index!] = newEntry
e._index = undefined
}
else {
_urls.push(newEntry)
}
}
}
}
if (isI18nMapped) {
e._sitemap = e._sitemap || e._locale._sitemap
e._key = `${e._sitemap || ''}${e.loc || '/'}${e._path?.search || ''}`
}
if (e._index)
_urls[e._index] = e
}
}
return _urls
}
const resolvers: NitroUrlResolvers = {
canonicalUrlResolver: (url: string) => `https://example.com${url}`,
relativeBaseUrlResolver: (url: string) => url,
fixSlashes: (url: string) => url,
}
const sitemap: SitemapDefinition = {
sitemapName: 'default',
include: undefined,
exclude: undefined,
}
const locales = ['en', 'fr', 'de', 'es', 'it', 'pt', 'nl', 'pl', 'ru', 'ja'].map(code => ({
code,
language: code,
_sitemap: code,
_hreflang: code,
}))
const autoI18nPrefix: AutoI18nConfig = {
locales,
defaultLocale: 'en',
strategy: 'prefix',
}
const autoI18nPrefixExceptDefault: AutoI18nConfig = {
locales,
defaultLocale: 'en',
strategy: 'prefix_except_default',
}
// URLs with i18n prefixes (1000 urls across 10 locales)
const i18nUrls: SitemapUrlInput[] = locales.flatMap(locale =>
Array.from({ length: 100 }, (_, i) => ({
loc: `/${locale.code}/page-${i}`,
lastmod: '2024-01-01',
})),
)
// URLs that need _i18nTransform (each expands to 10 locale variants)
const transformUrls: SitemapUrlInput[] = Array.from({ length: 200 }, (_, i) => ({
loc: `/page-${i}`,
lastmod: '2024-01-01',
_i18nTransform: true,
}))
// Simple URLs without i18n
const simpleUrls: SitemapUrlInput[] = Array.from({ length: 1000 }, (_, i) => ({
loc: `/page-${i}`,
lastmod: '2024-01-01',
}))
// Large URL set for filtering benchmarks
const largeUrls: SitemapUrlInput[] = Array.from({ length: 5000 }, (_, i) => ({
loc: `/category-${i % 10}/product-${i}`,
lastmod: '2024-01-01',
}))
// Mixed URLs with various features
const mixedUrls: SitemapUrlInput[] = Array.from({ length: 1000 }, (_, i) => ({
loc: `/page-${i}?foo=bar`,
lastmod: '2024-01-01',
changefreq: 'weekly' as const,
priority: 0.8,
}))
// Sitemap with string pattern filtering (glob-style)
const sitemapWithStringFilters: SitemapDefinition = {
sitemapName: 'filtered',
include: ['/category-0/**', '/category-1/**', '/category-2/**'],
exclude: ['/category-*/product-0', '/category-*/product-1'],
}
// Sitemap with regex filtering
const sitemapWithRegexFilters: SitemapDefinition = {
sitemapName: 'regex-filtered',
include: [/^\/category-[0-2]\//, /^\/category-5\//],
exclude: [/product-\d$/, /product-1\d$/],
}
// Sitemap with many filter rules (stress test)
const sitemapWithManyFilters: SitemapDefinition = {
sitemapName: 'many-filters',
include: Array.from({ length: 20 }, (_, i) => `/category-${i % 10}/**`),
exclude: Array.from({ length: 10 }, (_, i) => `/category-*/product-${i}`),
}
describe('resolveSitemapEntries', () => {
bench('1000 simple urls (no i18n)', () => {
resolveSitemapEntries(sitemap, simpleUrls, { autoI18n: undefined, isI18nMapped: false }, resolvers)
}, { iterations: 100 })
bench('1000 mixed urls with query (no i18n)', () => {
resolveSitemapEntries(sitemap, mixedUrls, { autoI18n: undefined, isI18nMapped: false }, resolvers)
}, { iterations: 100 })
bench('1000 i18n urls (prefix)', () => {
resolveSitemapEntries(sitemap, i18nUrls, { autoI18n: autoI18nPrefix, isI18nMapped: false }, resolvers)
}, { iterations: 50 })
bench('1000 i18n urls (prefix_except_default)', () => {
resolveSitemapEntries(sitemap, i18nUrls, { autoI18n: autoI18nPrefixExceptDefault, isI18nMapped: false }, resolvers)
}, { iterations: 50 })
bench('200 urls _i18nTransform (prefix)', () => {
resolveSitemapEntries(sitemap, transformUrls, { autoI18n: autoI18nPrefix, isI18nMapped: false }, resolvers)
}, { iterations: 20 })
bench('200 urls _i18nTransform (prefix_except_default)', () => {
resolveSitemapEntries(sitemap, transformUrls, { autoI18n: autoI18nPrefixExceptDefault, isI18nMapped: false }, resolvers)
}, { iterations: 20 })
})
describe('createPathFilter performance', () => {
bench('5000 urls with string pattern filters', () => {
resolveSitemapEntries(sitemapWithStringFilters, largeUrls, { autoI18n: undefined, isI18nMapped: false }, resolvers)
}, { iterations: 20 })
bench('5000 urls with regex filters', () => {
resolveSitemapEntries(sitemapWithRegexFilters, largeUrls, { autoI18n: undefined, isI18nMapped: false }, resolvers)
}, { iterations: 20 })
bench('5000 urls with many filter rules', () => {
resolveSitemapEntries(sitemapWithManyFilters, largeUrls, { autoI18n: undefined, isI18nMapped: false }, resolvers)
}, { iterations: 20 })
bench('createPathFilter - isolated filter calls (1000x)', () => {
const filter = createPathFilter({
include: ['/category-0/**', '/category-1/**'],
exclude: ['/category-*/product-0'],
})
for (let i = 0; i < 1000; i++) {
filter(`/category-${i % 10}/product-${i}`)
}
}, { iterations: 100 })
})
================================================
FILE: test/bench/xml.bench.ts
================================================
import type { ResolvedSitemapUrl } from '../../src/runtime/types'
import { bench, describe } from 'vitest'
import { urlsToXml } from '../../src/runtime/server/sitemap/builder/xml'
const resolvers = {
canonicalUrlResolver: (url: string) => `https://example.com${url}`,
relativeBaseUrlResolver: (url: string) => url,
fixSlashes: (url: string) => url,
}
const simpleUrls: ResolvedSitemapUrl[] = Array.from({ length: 1000 }, (_, i) => ({
loc: `https://example.com/page-${i}`,
lastmod: '2024-01-01',
changefreq: 'weekly' as const,
priority: 0.8,
_key: `page-${i}`,
_path: null,
}))
const urlsWithImages: ResolvedSitemapUrl[] = Array.from({ length: 1000 }, (_, i) => ({
loc: `https://example.com/page-${i}`,
lastmod: '2024-01-01',
images: [
{ loc: `https://example.com/img-${i}-1.jpg`, title: 'Image 1', caption: 'A caption' },
{ loc: `https://example.com/img-${i}-2.jpg`, title: 'Image 2' },
],
_key: `page-${i}`,
_path: null,
}))
const urlsWithVideos: ResolvedSitemapUrl[] = Array.from({ length: 500 }, (_, i) => ({
loc: `https://example.com/video-${i}`,
videos: [{
title: `Video ${i}`,
description: 'A video description',
thumbnail_loc: `https://example.com/thumb-${i}.jpg`,
content_loc: `https://example.com/video-${i}.mp4`,
duration: 300,
rating: 4.5,
view_count: 1000,
family_friendly: true,
live: false,
tag: ['tag1', 'tag2'],
}],
_key: `video-${i}`,
_path: null,
}))
const mixedUrls: ResolvedSitemapUrl[] = [
...simpleUrls.slice(0, 500),
...urlsWithImages.slice(0, 300),
...urlsWithVideos.slice(0, 200),
]
const config = { version: '7.0.0', xsl: false, credits: false, minify: false }
const configMinify = { version: '7.0.0', xsl: false, credits: false, minify: true }
describe('xml generation', () => {
bench('1000 simple urls', () => {
urlsToXml(simpleUrls, resolvers, config)
}, { iterations: 100 })
bench('1000 urls with images', () => {
urlsToXml(urlsWithImages, resolvers, config)
}, { iterations: 100 })
bench('500 urls with videos', () => {
urlsToXml(urlsWithVideos, resolvers, config)
}, { iterations: 100 })
bench('1000 mixed urls', () => {
urlsToXml(mixedUrls, resolvers, config)
}, { iterations: 100 })
bench('1000 simple urls (minified)', () => {
urlsToXml(simpleUrls, resolvers, configMinify)
}, { iterations: 100 })
bench('1000 mixed urls (minified)', () => {
urlsToXml(mixedUrls, resolvers, configMinify)
}, { iterations: 100 })
})
================================================
FILE: test/e2e/chunks/cache-headers.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// Set up chunked sitemaps
await setup({
rootDir: resolve('../../fixtures/chunks'),
nuxtConfig: {
sitemap: {
// Global automatic chunking
chunks: true,
defaultSitemapsChunkSize: 100,
cacheMaxAgeSeconds: 900, // 15 minutes
runtimeCacheStorage: {
driver: 'memory', // Use memory driver to avoid Redis connection issues
},
},
},
})
describe('chunked sitemap caching with headers', () => {
it('should return proper cache headers for sitemap index', async () => {
const response = await fetch('/sitemap_index.xml')
expect(response.headers.get('content-type')).toMatch(/xml/)
// Check cache headers
const cacheControl = response.headers.get('cache-control')
expect(cacheControl).toBeDefined()
expect(cacheControl).toContain('max-age=900')
expect(cacheControl).toContain('s-maxage=900')
expect(cacheControl).toContain('public')
expect(cacheControl).toContain('stale-while-revalidate')
// Check debug headers
expect(response.headers.get('X-Sitemap-Generated')).toBeDefined()
expect(response.headers.get('X-Sitemap-Cache-Duration')).toBe('900s')
expect(response.headers.get('X-Sitemap-Cache-Expires')).toBeDefined()
expect(response.headers.get('X-Sitemap-Cache-Remaining')).toBeDefined()
const xml = await response.text()
expect(xml).toContain('')
expect(xml).toContain('')
}, 10000)
it('should return proper cache headers for first chunk', async () => {
const response = await fetch('/__sitemap__/0.xml')
expect(response.headers.get('content-type')).toMatch(/xml/)
// Check cache headers
const cacheControl = response.headers.get('cache-control')
expect(cacheControl).toBeDefined()
expect(cacheControl).toContain('max-age=900')
expect(cacheControl).toContain('s-maxage=900')
expect(cacheControl).toContain('public')
expect(cacheControl).toContain('stale-while-revalidate')
// Check debug headers
expect(response.headers.get('X-Sitemap-Generated')).toBeDefined()
expect(response.headers.get('X-Sitemap-Cache-Duration')).toBe('900s')
expect(response.headers.get('X-Sitemap-Cache-Expires')).toBeDefined()
expect(response.headers.get('X-Sitemap-Cache-Remaining')).toBeDefined()
})
it('should properly generate chunked sitemaps in index', async () => {
const response = await fetch('/sitemap_index.xml')
const xml = await response.text()
expect(xml).toContain(' {
it('renders the index from the declared count without hitting the source', async () => {
const before = (await $fetch<{ count: number }>('/api/posts-call-count')).count
const indexXml = await $fetch('/sitemap_index.xml')
const after = (await $fetch<{ count: number }>('/api/posts-call-count')).count
expect(after - before).toBe(0)
for (let i = 0; i < 4; i++) {
expect(indexXml).toContain(`/__sitemap__/posts-${i}.xml`)
}
expect(indexXml).not.toContain('/__sitemap__/posts-4.xml')
}, 30000)
it('chunks fetch sources on demand and the data is correct', async () => {
const chunk0 = await $fetch('/__sitemap__/posts-0.xml')
expect(chunk0).toContain('/posts/1')
expect(chunk0).toContain('/posts/5')
}, 30000)
})
================================================
FILE: test/e2e/chunks/default.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/chunks'),
})
describe.skipIf(process.env.CI)('multi chunks', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap_index.xml')
// remove lastmods before tresting
sitemap = sitemap.replace(/lastmod>(.*?)<')
// basic test to make sure we get a valid response
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/0.xml
https://nuxtseo.com/__sitemap__/1.xml
https://nuxtseo.com/__sitemap__/2.xml
https://nuxtseo.com/__sitemap__/3.xml
"
`)
const sitemap0 = await $fetch('/__sitemap__/0.xml')
expect(sitemap0).toMatchInlineSnapshot(`
"
https://nuxtseo.com/foo/1
https://nuxtseo.com/foo/2
https://nuxtseo.com/foo/3
https://nuxtseo.com/foo/4
https://nuxtseo.com/foo/5
"
`)
}, 60000)
})
================================================
FILE: test/e2e/chunks/generate.test.ts
================================================
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { describe, expect, it } from 'vitest'
describe.skipIf(process.env.CI)('generate', () => {
it('basic', async () => {
process.env.NODE_ENV = 'production'
// @ts-expect-error untyped
process.env.prerender = true
process.env.NITRO_PRESET = 'static'
process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
const { resolve } = createResolver(import.meta.url)
const rootDir = resolve('../../fixtures/chunks')
const nuxt = await loadNuxt({
rootDir,
overrides: {
nitro: {
preset: 'static',
},
_generate: true,
},
})
await buildNuxt(nuxt)
await new Promise(resolve => setTimeout(resolve, 1000))
const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap_index.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
// ignore lastmod entries
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/0.xml
https://nuxtseo.com/__sitemap__/1.xml
https://nuxtseo.com/__sitemap__/2.xml
https://nuxtseo.com/__sitemap__/3.xml
"
`)
const sitemapEn = (await readFile(resolve(rootDir, '.output/public/__sitemap__/0.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
expect(sitemapEn).toMatchInlineSnapshot(`
"
https://nuxtseo.com/foo/1
https://nuxtseo.com/foo/2
https://nuxtseo.com/foo/3
https://nuxtseo.com/foo/4
https://nuxtseo.com/foo/5
"
`)
}, 1200000)
})
================================================
FILE: test/e2e/chunks/memoization.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/chunk-cache'),
})
describe('chunk resolved-urls memoization', () => {
it('all chunks of the same base share one source fetch', async () => {
// 17 entries × chunk size 5 → 4 chunks (0..3)
await $fetch('/__sitemap__/posts-0.xml')
await $fetch('/__sitemap__/posts-1.xml')
await $fetch('/__sitemap__/posts-2.xml')
await $fetch('/__sitemap__/posts-3.xml')
const { count } = await $fetch<{ count: number }>('/api/source-call-count')
expect(count).toBe(1)
}, 30000)
it('chunked output reflects the shared sorted slice', async () => {
const chunk0 = await $fetch('/__sitemap__/posts-0.xml')
const chunk3 = await $fetch('/__sitemap__/posts-3.xml')
expect(chunk0).toContain('/posts/1')
expect(chunk0).toContain('/posts/5')
expect(chunk0).not.toContain('/posts/6')
expect(chunk3).toContain('/posts/16')
expect(chunk3).toContain('/posts/17')
expect(chunk3).not.toContain('/posts/15')
}, 30000)
})
================================================
FILE: test/e2e/content-v3/default.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/content-v3'),
})
describe('nuxt/content v3 default', () => {
it('basic', async () => {
const nuxtContentUrls = await $fetch('/__sitemap__/nuxt-content-urls.json')
expect(nuxtContentUrls).toMatchInlineSnapshot(`
[
{
"changefreq": "daily",
"images": [
{
"loc": "https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg",
},
],
"lastmod": "2021-10-20",
"loc": "/bar",
"priority": 0.5,
},
{
"loc": "/foo",
"priority": 0.5,
},
{
"lastmod": "2021-10-20",
"loc": "/posts/bar",
},
{
"lastmod": "2021-10-20",
"loc": "/posts/fallback",
},
{
"loc": "/posts/foo",
},
{
"changefreq": "weekly",
"lastmod": "2025-05-14",
"loc": "/test-json",
"priority": 0.9,
},
{
"changefreq": "monthly",
"lastmod": "2025-05-13",
"loc": "/test-yaml",
"priority": 0.8,
},
]
`)
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/bar
2021-10-20
daily
0.5
https://raw.githubusercontent.com/harlan-zw/static/main/sponsors.svg
https://nuxtseo.com/foo
0.5
https://nuxtseo.com/test-json
2025-05-14
weekly
0.9
https://nuxtseo.com/test-yaml
2025-05-13
monthly
0.8
https://nuxtseo.com/posts/bar
2021-10-20
https://nuxtseo.com/posts/fallback
2021-10-20
https://nuxtseo.com/posts/foo
"
`)
}, 60000)
})
================================================
FILE: test/e2e/content-v3/define-schema.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/content-v3-define-schema'),
build: true,
})
describe('nuxt/content v3 defineSitemapSchema', () => {
it('includes content with sitemap schema', async () => {
const urls = await $fetch('/__sitemap__/nuxt-content-urls.json')
const paths = urls.map(u => u.loc)
expect(paths).toContain('/foo')
expect(paths).toContain('/bar')
expect(paths).toContain('/published')
})
it('filters content entries using defineSitemapSchema filter', async () => {
const urls = await $fetch('/__sitemap__/nuxt-content-urls.json')
const paths = urls.map(u => u.loc)
// draft.md (draft: true) should be excluded
expect(paths).not.toContain('/draft')
// future.md (date: 2099-01-01) should be excluded
expect(paths).not.toContain('/future')
})
it('preserves sitemap frontmatter values', async () => {
const urls = await $fetch('/__sitemap__/nuxt-content-urls.json')
const foo = urls.find(u => u.loc === '/foo')
expect(foo).toBeDefined()
expect(foo.priority).toBe(0.5)
})
})
================================================
FILE: test/e2e/content-v3/filtering.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/content-v3-filtering'),
build: true,
})
describe('nuxt/content v3 filtering', () => {
it('filters content entries using collection filter', async () => {
const urls = await $fetch('/__sitemap__/nuxt-content-urls.json')
const paths = urls.map(u => u.loc)
// draft.md (draft: true) should be excluded
expect(paths).not.toContain('/draft')
// future.md (date: 2099-01-01) should be excluded
expect(paths).not.toContain('/future')
// published.md (date in past, draft: false) should be included
expect(paths).toContain('/published')
// regular posts without draft/date fields should be included
expect(paths).toContain('/foo')
expect(paths).toContain('/bar')
})
})
================================================
FILE: test/e2e/content-v3/i18n.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/content-v3-i18n'),
})
describe('nuxt/content v3 + i18n', () => {
it('content URLs have correct locale prefixes', async () => {
const urls = await $fetch('/__sitemap__/nuxt-content-urls.json')
// en collection should produce un-prefixed paths (default locale)
// ja collection should produce /ja/ prefixed paths (from collection prefix config)
const locs = (urls as { loc: string }[]).map(u => u.loc).sort()
expect(locs).toContain('/')
expect(locs).toContain('/getting-started')
expect(locs).toContain('/ja')
expect(locs).toContain('/ja/getting-started')
}, 60000)
it('en sitemap contains only en URLs', async () => {
const sitemap = await $fetch('/__sitemap__/en-US.xml')
// should contain en URLs (dev mode uses local origin)
expect(sitemap).toContain('')
expect(sitemap).toContain('/getting-started')
}, 60000)
it('ja sitemap contains only ja URLs', async () => {
const sitemap = await $fetch('/__sitemap__/ja-JP.xml')
// should contain ja URLs (dev mode uses local origin)
expect(sitemap).toContain('/ja')
expect(sitemap).toContain('/ja/getting-started')
}, 60000)
it('sitemap index lists both locale sitemaps', async () => {
const index = await $fetch('/sitemap_index.xml')
expect(index).toContain('en-US.xml')
expect(index).toContain('ja-JP.xml')
}, 60000)
}, 120000)
================================================
FILE: test/e2e/content-v3/yaml-json.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/content-v3'),
nuxtConfig: {
sitemap: {
xsl: false,
},
site: {
url: 'https://nuxtseo.com',
},
},
})
describe('content-v3 YAML/JSON', () => {
it('basic', async () => {
const sitemapContents = await $fetch('/sitemap.xml', { responseType: 'text' })
// Check that YAML content with sitemap metadata is included
expect(sitemapContents).toMatch('https://nuxtseo.com/test-yaml')
// Check YAML sitemap metadata is extracted
const yamlMatch = sitemapContents.match(/.*?https:\/\/nuxtseo\.com\/test-yaml<\/loc>.*?<\/url>/s)
expect(yamlMatch).toBeTruthy()
if (yamlMatch) {
expect(yamlMatch[0]).toMatch('2025-05-13')
expect(yamlMatch[0]).toMatch('monthly ')
expect(yamlMatch[0]).toMatch('0.8 ')
}
// Check that JSON content with sitemap metadata is included
expect(sitemapContents).toMatch('https://nuxtseo.com/test-json')
// Check JSON sitemap metadata is extracted
const jsonMatch = sitemapContents.match(/.*?https:\/\/nuxtseo\.com\/test-json<\/loc>.*?<\/url>/s)
expect(jsonMatch).toBeTruthy()
if (jsonMatch) {
expect(jsonMatch[0]).toMatch('2025-05-14')
expect(jsonMatch[0]).toMatch('weekly ')
expect(jsonMatch[0]).toMatch('0.9 ')
}
})
})
================================================
FILE: test/e2e/global-setup.ts
================================================
import fsp from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const fixturesDir = fileURLToPath(new URL('../fixtures', import.meta.url))
export default async function setup() {
for (const project of await fsp.readdir(fixturesDir)) {
await fsp.rm(join(fixturesDir, project, 'node_modules/.cache'), {
recursive: true,
force: true,
})
await fsp.rm(join(fixturesDir, project, '.data'), {
recursive: true,
force: true,
})
}
}
================================================
FILE: test/e2e/hooks/sources-hook-simple.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
describe('sitemap:sources hook', async () => {
await setup({
rootDir: resolve('../../fixtures/sources-hook'),
server: true,
})
it('can add new sources dynamically', async () => {
const sitemap = await $fetch('/sitemap.xml')
// Should have URLs from the dynamically added source
expect(sitemap).toContain('https://example.com/dynamic-source-url ')
})
it('can modify existing sources', async () => {
const sitemap = await $fetch('/sitemap.xml')
// Should have URLs showing the headers were modified
expect(sitemap).toContain('https://example.com/hook-modified ')
})
it('can filter out sources', async () => {
const sitemap = await $fetch('/sitemap.xml')
// The skipped source should not appear in the sitemap
expect(sitemap).not.toContain('https://example.com/should-be-filtered ')
})
})
================================================
FILE: test/e2e/i18n/custom-paths-no-prefix.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n-no-prefix'),
server: true,
sitemap: {
urls: [
// test custom path mapping with no_prefix - should warn
{
loc: '/test',
_i18nTransform: true,
},
{
loc: '/about',
_i18nTransform: true,
},
],
},
})
describe('i18n custom paths with no_prefix strategy', () => {
it('should generate alternatives with custom paths even with no_prefix when pages config is present', async () => {
// The actual behavior is that _i18nTransform works with no_prefix when pages config is present
// This is counter-intuitive but is the current implementation
const sitemap = await $fetch('/sitemap.xml')
// The implementation still creates all locale variants with custom paths
expect(sitemap).toContain('https://nuxtseo.com/test ')
expect(sitemap).toContain('https://nuxtseo.com/about ')
expect(sitemap).toContain('https://nuxtseo.com/prueba ')
expect(sitemap).toContain('https://nuxtseo.com/teste ')
expect(sitemap).toContain('https://nuxtseo.com/acerca-de ')
expect(sitemap).toContain('https://nuxtseo.com/a-propos ')
// And it includes hreflang alternatives
expect(sitemap).toContain('xhtml:link')
expect(sitemap).toContain('hreflang')
// The warning is still issued because this is not recommended behavior
})
it('should have warning in dev mode for _i18nTransform with no_prefix', async () => {
// The warning is important because while the transformation works,
// it's not recommended with no_prefix strategy
const sitemap = await $fetch('/sitemap.xml')
// Even though it transforms, the warning tells users this is not intended behavior
expect(sitemap).toContain('https://nuxtseo.com/test ')
expect(sitemap).toContain('https://nuxtseo.com/about ')
})
})
================================================
FILE: test/e2e/i18n/custom-paths.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// Use dedicated fixture with custom path config including dynamic routes
await setup({
rootDir: resolve('../../fixtures/i18n-custom-paths'),
server: true,
})
describe('i18n custom paths with _i18nTransform', () => {
it('should use custom paths from pages config for _i18nTransform', async () => {
// With prefix_except_default, we get separate sitemaps per locale
const enSitemap = await $fetch('/__sitemap__/en-US.xml')
const esSitemap = await $fetch('/__sitemap__/es-ES.xml')
const frSitemap = await $fetch('/__sitemap__/fr-FR.xml')
// Test that /test with _i18nTransform generates custom paths
expect(enSitemap).toContain('https://nuxtseo.com/test ')
expect(esSitemap).toContain('https://nuxtseo.com/es/prueba ')
expect(frSitemap).toContain('https://nuxtseo.com/fr/teste ')
// Test about with custom paths
expect(enSitemap).toContain('https://nuxtseo.com/about ')
expect(esSitemap).toContain('https://nuxtseo.com/es/acerca-de ')
expect(frSitemap).toContain('https://nuxtseo.com/fr/a-propos ')
// Check that alternatives use custom paths in the English sitemap
expect(enSitemap).toContain('href="https://nuxtseo.com/es/prueba"')
expect(enSitemap).toContain('href="https://nuxtseo.com/fr/teste"')
})
it('should generate correct alternatives for URLs with _i18nTransform', async () => {
const enSitemap = await $fetch('/__sitemap__/en-US.xml')
// Check the test URL entry
const testUrlMatch = enSitemap.match(/[\s\S]*?https:\/\/nuxtseo\.com\/test<\/loc>[\s\S]*?<\/url>/g)
expect(testUrlMatch).toBeDefined()
const testUrl = testUrlMatch![0]
// Verify it has the correct alternatives with custom paths
expect(testUrl).toContain(' ')
expect(testUrl).toContain(' ')
expect(testUrl).toContain(' ')
// Check the about URL entry
const aboutUrlMatch = enSitemap.match(/[\s\S]*?https:\/\/nuxtseo\.com\/about<\/loc>[\s\S]*?<\/url>/g)
expect(aboutUrlMatch).toBeDefined()
const aboutUrl = aboutUrlMatch![0]
// Verify it has the correct alternatives with custom paths
expect(aboutUrl).toContain(' ')
expect(aboutUrl).toContain(' ')
expect(aboutUrl).toContain(' ')
})
// Issue #542: dynamic route with parameters should apply custom path transformation
it('should apply custom paths to dynamic routes with single parameter', async () => {
const enSitemap = await $fetch('/__sitemap__/en-US.xml')
const esSitemap = await $fetch('/__sitemap__/es-ES.xml')
const frSitemap = await $fetch('/__sitemap__/fr-FR.xml')
// Test that /posts/my-slug with _i18nTransform generates custom paths with parameter substitution
expect(enSitemap).toContain('https://nuxtseo.com/posts/my-slug ')
expect(esSitemap).toContain('https://nuxtseo.com/es/articulos/my-slug ')
expect(frSitemap).toContain('https://nuxtseo.com/fr/article/my-slug ')
})
it('should generate correct alternatives for dynamic routes with parameter', async () => {
const enSitemap = await $fetch('/__sitemap__/en-US.xml')
// Check the posts URL entry - should have parameter substitution in alternatives
const postsUrlMatch = enSitemap.match(/[\s\S]*?https:\/\/nuxtseo\.com\/posts\/my-slug<\/loc>[\s\S]*?<\/url>/g)
expect(postsUrlMatch).toBeDefined()
const postsUrl = postsUrlMatch![0]
expect(postsUrl).toContain(' ')
expect(postsUrl).toContain(' ')
expect(postsUrl).toContain(' ')
})
it('should apply custom paths to dynamic routes with multiple parameters', async () => {
const enSitemap = await $fetch('/__sitemap__/en-US.xml')
const esSitemap = await $fetch('/__sitemap__/es-ES.xml')
const frSitemap = await $fetch('/__sitemap__/fr-FR.xml')
// Test that /products/electronics/laptop-123 generates custom paths with both parameters
expect(enSitemap).toContain('https://nuxtseo.com/products/electronics/laptop-123 ')
expect(esSitemap).toContain('https://nuxtseo.com/es/productos/electronics/laptop-123 ')
expect(frSitemap).toContain('https://nuxtseo.com/fr/produits/electronics/laptop-123 ')
})
})
================================================
FILE: test/e2e/i18n/custom-sitemaps-i18n.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// Test for issue #486: Automatic I18n Multi Sitemap + custom sitemaps not working
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
sitemap: {
sitemaps: {
pages: {
// This should be expanded to per-locale sitemaps (en-US, es-ES, fr-FR)
includeAppSources: true,
exclude: ['/secret/**'],
},
custom: {
// This should stay as a single sitemap
sources: ['/__sitemap'],
},
},
},
},
})
describe('i18n with custom sitemaps (#486)', () => {
it('generates sitemap index with locale-prefixed sitemaps and custom sitemap', async () => {
const index = await $fetch('/sitemap_index.xml')
// Should have locale-prefixed sitemaps: {locale}-{name} format
expect(index).toContain('en-US-pages.xml')
expect(index).toContain('es-ES-pages.xml')
expect(index).toContain('fr-FR-pages.xml')
expect(index).toContain('custom.xml')
// Should NOT have unprefixed "pages" or plain locale sitemaps
expect(index).not.toMatch(/\/pages\.xml/)
expect(index).not.toMatch(/\/en-US\.xml[^-]/)
})
it('locale sitemap inherits exclude config from custom sitemap', async () => {
const enSitemap = await $fetch('/__sitemap__/en-US-pages.xml')
// Should have normal pages
expect(enSitemap).toContain('/en')
// The exclude pattern should be applied (no /secret/** URLs)
expect(enSitemap).not.toContain('/secret')
})
it('custom sitemap without includeAppSources stays separate', async () => {
const customSitemap = await $fetch('/__sitemap__/custom.xml')
// Should have content from the source
expect(customSitemap).toContain('urlset')
})
it('locale sitemaps have proper i18n alternatives', async () => {
const frSitemap = await $fetch('/__sitemap__/fr-FR-pages.xml')
// Should have French URLs with alternatives
expect(frSitemap).toContain('/fr')
expect(frSitemap).toContain('hreflang')
expect(frSitemap).toContain('x-default')
})
}, 60000)
================================================
FILE: test/e2e/i18n/domains.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
differentDomains: true,
locales: [
{
code: 'en',
iso: 'en-US',
domain: 'nuxtseo.com',
},
{
code: 'es',
iso: 'es-ES',
domain: 'es.nuxtseo.com',
},
{
code: 'fr',
iso: 'fr-FR',
domain: 'fr.nuxtseo.com',
},
],
},
sitemap: {
},
},
})
describe('i18n domains', () => {
it('basic', async () => {
const index = await $fetch('/sitemap.xml')
expect(index).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/en-US.xml
https://nuxtseo.com/__sitemap__/es-ES.xml
https://nuxtseo.com/__sitemap__/fr-FR.xml
"
`)
const fr = await $fetch('/__sitemap__/fr-FR.xml')
expect(fr).toMatchInlineSnapshot(`
"
https://fr.nuxtseo.com/fr
https://fr.nuxtseo.com/fr/test
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/dynamic-urls.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
i18n: {
strategy: 'prefix_except_default',
locales: [
{ code: 'en', iso: 'en-US' },
{ code: 'fr', iso: 'fr-FR' },
],
},
sitemap: {
excludeAppSources: true,
sources: [
'/i18n-urls',
],
},
},
})
describe('i18n dynamic urls', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/en-US.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/endless-dungeon
https://nuxtseo.com/english-url
https://nuxtseo.com/__sitemap/url
weekly
https://www.somedomain.com/abc/def
https://nuxtseo.com/en/dynamic/foo
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/filtering-base-url.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
app: {
baseURL: '/base',
},
sitemap: {
exclude: [
'/test',
],
},
},
})
describe('i18n filtering with base url', () => {
it('excludes /test', async () => {
let sitemap = await $fetch('/base/__sitemap__/en-US.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).not.toContain('/base/en/test')
expect(sitemap).not.toContain('/base/test')
}, 60000)
})
================================================
FILE: test/e2e/i18n/filtering-include.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// With i18n + includeAppSources, sitemaps are automatically expanded to per-locale sitemaps
// The include filter is applied to each locale sitemap
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
sitemap: {
sitemaps: {
main: {
includeAppSources: true,
include: ['/', '/test'],
},
},
},
},
})
describe('i18n filtering with include', () => {
it('generates per-locale sitemaps with include filter applied', async () => {
// With the fix for #486, includeAppSources sitemaps are expanded to {locale}-{name} sitemaps
const index = await $fetch('/sitemap_index.xml')
expect(index).toContain('en-US-main.xml')
expect(index).toContain('fr-FR-main.xml')
expect(index).toContain('es-ES-main.xml')
// main.xml should NOT exist - it's expanded to locale sitemaps
expect(index).not.toContain('/main.xml')
// English sitemap should have filtered URLs with alternatives
const enSitemap = await $fetch('/__sitemap__/en-US-main.xml')
expect(enSitemap).toContain('/en')
expect(enSitemap).toContain('/en/test')
expect(enSitemap).toContain('hreflang')
expect(enSitemap).toContain('x-default')
// French sitemap should have filtered URLs with alternatives
const frSitemap = await $fetch('/__sitemap__/fr-FR-main.xml')
expect(frSitemap).toContain('/fr')
expect(frSitemap).toContain('/fr/test')
expect(frSitemap).toContain('hreflang')
}, 60000)
})
================================================
FILE: test/e2e/i18n/filtering-regexp.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
sitemap: {
exclude: [
/.*test.*/g,
/.no-i18n/,
'/en/__sitemap/**',
'/__sitemap/**',
// exclude fr
'/fr',
],
},
},
})
describe('i18n filtering with regexp', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/en-US.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/filtering.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
sitemap: {
exclude: [
'/test',
],
},
},
})
describe('i18n filtering', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/en-US.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en
https://nuxtseo.com/no-i18n
https://nuxtseo.com/en/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/generate-prefix-except-default.test.ts
================================================
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { describe, expect, it } from 'vitest'
describe('generate prefix_except_default', () => {
it('root path should have all alternatives when prerendered', async () => {
process.env.NODE_ENV = 'production'
// @ts-expect-error untyped
process.env.prerender = true
process.env.NITRO_PRESET = 'static'
process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
const { resolve } = createResolver(import.meta.url)
const rootDir = resolve('../../fixtures/i18n-generate')
const nuxt = await loadNuxt({
rootDir,
overrides: {
_generate: true,
nitro: {
preset: 'static',
},
},
})
await buildNuxt(nuxt)
await new Promise(resolve => setTimeout(resolve, 1000))
// Multi-sitemap mode creates per-locale sitemaps
const sitemap = (await readFile(resolve(rootDir, '.output/public/__sitemap__/en-US.xml'), 'utf-8'))
.replace(/lastmod>(.*?)<')
// Check root path has all alternatives
// With prefix_except_default: / is en (default), /de is de
expect(sitemap).toContain('https://nuxtseo.com/ ')
// Root path should have en-US alternate pointing to /
expect(sitemap).toContain('hreflang="en-US"')
expect(sitemap).toContain('href="https://nuxtseo.com/"')
// Root path should have de-DE alternate
expect(sitemap).toContain('hreflang="de-DE"')
expect(sitemap).toContain('href="https://nuxtseo.com/de"')
// Root path should have x-default alternate pointing to /
expect(sitemap).toContain('hreflang="x-default"')
}, 120000)
})
================================================
FILE: test/e2e/i18n/generate.test.ts
================================================
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { describe, expect, it } from 'vitest'
describe('generate', () => {
it('basic', async () => {
process.env.NODE_ENV = 'production'
// @ts-expect-error untyped
process.env.prerender = true
process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
const { resolve } = createResolver(import.meta.url)
const rootDir = resolve('../../fixtures/i18n')
const nuxt = await loadNuxt({
rootDir,
overrides: {
_generate: true,
nitro: { static: true },
},
})
await buildNuxt(nuxt)
await new Promise(resolve => setTimeout(resolve, 1000))
const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap_index.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
// ignore lastmod entries
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/en-US.xml
https://nuxtseo.com/__sitemap__/es-ES.xml
https://nuxtseo.com/__sitemap__/fr-FR.xml
"
`)
const sitemapEn = (await readFile(resolve(rootDir, '.output/public/__sitemap__/en-US.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
expect(sitemapEn).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en
https://nuxtseo.com/no-i18n
https://nuxtseo.com/en/test
https://nuxtseo.com/en/__sitemap/url
weekly
"
`)
}, 1200000)
})
================================================
FILE: test/e2e/i18n/no-prefix.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
locales: [
'en',
'fr',
],
strategy: 'no_prefix',
},
sitemap: {
urls: ['/extra'],
sitemaps: false,
},
},
})
describe('i18n prefix', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/extra
https://nuxtseo.com/no-i18n
https://nuxtseo.com/test
https://nuxtseo.com/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages-multi.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
server: true,
nuxtConfig: {
i18n: {
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
'random': {
en: '/random',
fr: false,
},
},
},
},
})
describe('i18n pages multi', () => {
it('basic', async () => {
const index = await $fetch('/sitemap_index.xml')
expect(index).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/en-US.xml
https://nuxtseo.com/__sitemap__/es-ES.xml
https://nuxtseo.com/__sitemap__/fr-FR.xml
"
`)
const fr = await $fetch('/__sitemap__/fr-FR.xml')
expect(fr).toMatchInlineSnapshot(`
"
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.disabled-routes.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
baseUrl: 'https://i18n-locale-test.com',
locales: [
{ code: 'en', iso: 'en-US', name: 'English' },
{ code: 'fr', iso: 'fr-FR', name: 'Français' },
],
defaultLocale: 'en',
strategy: 'no_prefix',
pages: {
'/about': {
en: '/about',
fr: false, // Disabled route
},
'/contact': {
en: '/contact',
fr: '/contact',
},
},
},
},
})
describe('i18n pages with disabled routes', () => {
it('handles disabled routes properly with no_prefix strategy', async () => {
const xml = await $fetch('/sitemap.xml')
// Should not throw error and contain urlset
expect(xml).toContain('https://i18n-locale-test.com/about ')
expect(xml).toContain('https://i18n-locale-test.com/contact ')
// Disabled routes should not have alternatives pointing to them
expect(xml).not.toContain('/fr/about')
// Alternatives should only include enabled routes
const urlPattern = /(.*?)<\/url>/gs
const urls = xml.match(urlPattern) || []
// Find the about URL
const aboutUrl = urls.find((url: string) => url.includes('/about '))
if (aboutUrl) {
// There should be only one alternate link for the English version
const alternateLinks = aboutUrl.match(/]*\/>/g) || []
expect(alternateLinks.length).toBeLessThanOrEqual(2) // at most en-US and x-default
expect(aboutUrl).not.toContain('hreflang="fr-FR"') // French alternative should not exist
}
})
})
================================================
FILE: test/e2e/i18n/pages.no-prefix.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
sitemap: { sitemaps: false },
i18n: {
strategy: 'no_prefix',
locales: [
'en',
'fr',
],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
},
},
},
})
describe('i18n pages with no prefix strategy', () => {
it('no_prefix', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/a-propos
https://nuxtseo.com/about
https://nuxtseo.com/offres
https://nuxtseo.com/services
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/offres/developement
https://nuxtseo.com/offres/formation
https://nuxtseo.com/services/coaching
https://nuxtseo.com/services/development
https://nuxtseo.com/offres/developement/app
https://nuxtseo.com/offres/developement/site-web
https://nuxtseo.com/services/development/app
https://nuxtseo.com/services/development/website
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.only-locales.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
server: true,
nuxtConfig: {
i18n: {
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
bundle: {
onlyLocales: ['en', 'fr'],
},
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
'random': {
en: '/random',
fr: false,
},
},
},
},
})
describe('i18n pages only locale', () => {
it('basic', async () => {
const index = await $fetch('/sitemap_index.xml')
expect(index).toMatchInlineSnapshot(`
"
https://nuxtseo.com/__sitemap__/en-US.xml
https://nuxtseo.com/__sitemap__/fr-FR.xml
"
`)
const fr = await $fetch('/__sitemap__/fr-FR.xml')
expect(fr).toMatchInlineSnapshot(`
"
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.prefix-and-default.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
sitemap: { sitemaps: false },
i18n: {
strategy: 'prefix_and_default',
defaultLocale: 'en',
locales: [
'en',
'fr',
],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
},
},
},
})
describe('i18n pages with prefix and default strategy', () => {
it('prefix_and_default', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/about
https://nuxtseo.com/services
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/en/about
https://nuxtseo.com/en/services
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/services/coaching
https://nuxtseo.com/services/development
https://nuxtseo.com/en/services/coaching
https://nuxtseo.com/en/services/development
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/services/development/app
https://nuxtseo.com/services/development/website
https://nuxtseo.com/en/services/development/app
https://nuxtseo.com/en/services/development/website
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.prefix-except-default.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
sitemap: { sitemaps: false },
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'en',
locales: [
'en',
'fr',
],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
},
},
},
})
describe('i18n pages with prefix except default strategy', () => {
it('prefix_except_default', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/about
https://nuxtseo.com/services
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/services/coaching
https://nuxtseo.com/services/development
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/services/development/app
https://nuxtseo.com/services/development/website
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.prefix.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
sitemap: { sitemaps: false },
i18n: {
strategy: 'prefix',
locales: ['en', 'fr'],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
},
},
},
})
describe('i18n pages with prefix strategy', () => {
it('prefix', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en/about
https://nuxtseo.com/en/services
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/en/__sitemap/url
weekly
https://nuxtseo.com/en/services/coaching
https://nuxtseo.com/en/services/development
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/en/services/development/app
https://nuxtseo.com/en/services/development/website
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/pages.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
sitemap: { sitemaps: false },
i18n: {
locales: [
'en',
'fr',
],
pages: {
'about': {
en: '/about',
fr: '/a-propos',
},
'services/index': {
en: '/services',
fr: '/offres',
},
'services/development/index': {
en: '/services/development',
fr: '/offres/developement',
},
'services/development/app/index': {
en: '/services/development/app',
fr: '/offres/developement/app',
},
'services/development/website/index': {
en: '/services/development/website',
fr: '/offres/developement/site-web',
},
'services/coaching/index': {
en: '/services/coaching',
fr: '/offres/formation',
},
},
},
},
})
describe('i18n', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en/about
https://nuxtseo.com/en/services
https://nuxtseo.com/fr/a-propos
https://nuxtseo.com/fr/offres
https://nuxtseo.com/en/__sitemap/url
weekly
https://nuxtseo.com/en/services/coaching
https://nuxtseo.com/en/services/development
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/fr/offres/developement
https://nuxtseo.com/fr/offres/formation
https://nuxtseo.com/en/services/development/app
https://nuxtseo.com/en/services/development/website
https://nuxtseo.com/fr/offres/developement/app
https://nuxtseo.com/fr/offres/developement/site-web
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/prefix-and-default.test.ts
================================================
import type { SitemapUrlInput } from '../../../src/runtime/types'
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
locales: [
'en',
'fr',
],
strategy: 'prefix_and_default',
},
sitemap: {
autoI18n: true,
urls: [
{
loc: '/extra',
_i18nTransform: true,
},
],
sitemaps: false,
},
},
})
describe('i18n prefix and default', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/es
https://nuxtseo.com/extra
https://nuxtseo.com/fr
https://nuxtseo.com/no-i18n
https://nuxtseo.com/test
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/es/extra
https://nuxtseo.com/es/test
https://nuxtseo.com/fr/extra
https://nuxtseo.com/fr/test
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/prefix-except-default.test.ts
================================================
import type { SitemapUrlInput } from '../../../src/runtime/types'
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
locales: [
'en',
'fr',
],
strategy: 'prefix_except_default',
},
sitemap: {
autoI18n: true,
urls: [
{
loc: '/extra',
_i18nTransform: true,
},
],
sitemaps: false,
},
},
})
describe('i18n prefix except default', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/es
https://nuxtseo.com/extra
https://nuxtseo.com/fr
https://nuxtseo.com/no-i18n
https://nuxtseo.com/test
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/es/extra
https://nuxtseo.com/es/test
https://nuxtseo.com/fr/extra
https://nuxtseo.com/fr/test
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/prefix-iso.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
locales: [
{
code: 'fr',
iso: 'fr-FR',
},
{
code: 'en',
iso: 'en-US',
},
],
strategy: 'prefix',
},
sitemap: {
autoI18n: true,
urls: ['/extra'],
sitemaps: false,
},
},
})
describe('i18n prefix', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en
https://nuxtseo.com/es
https://nuxtseo.com/extra
https://nuxtseo.com/fr
https://nuxtseo.com/no-i18n
https://nuxtseo.com/en/test
https://nuxtseo.com/es/test
https://nuxtseo.com/fr/test
https://nuxtseo.com/en/__sitemap/url
weekly
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/prefix-simple.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
i18n: {
locales: [
'en',
'fr',
],
strategy: 'prefix',
},
sitemap: {
urls: ['/extra'],
sitemaps: false,
},
},
})
describe('i18n prefix', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en
https://nuxtseo.com/es
https://nuxtseo.com/extra
https://nuxtseo.com/fr
https://nuxtseo.com/no-i18n
https://nuxtseo.com/en/test
https://nuxtseo.com/es/test
https://nuxtseo.com/fr/test
https://nuxtseo.com/en/__sitemap/url
weekly
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/route-rules.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
nuxtConfig: {
i18n: {
strategy: 'prefix_except_default',
locales: [
{ code: 'en', iso: 'en-US' },
{ code: 'fr', iso: 'fr-FR' },
],
},
sitemap: {
excludeAppSources: true,
sitemaps: false,
urls: [
// simply matching
'/hidden',
'/defaults',
'/wildcard/defaults/foo',
'/wildcard/hidden/foo',
// i18n matching, should inherit the top level rules (without the locale)
'/fr/hidden',
'/fr/defaults',
'/fr/wildcard/defaults/foo',
'/fr/wildcard/hidden/foo',
],
},
routeRules: {
'/hidden': {
// @ts-expect-error untyped
robots: false,
},
'/defaults': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/defaults/**': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/hidden/**': {
// @ts-expect-error untyped
robots: false,
},
},
},
})
describe('i18n route rules', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/defaults
daily
1.0
https://nuxtseo.com/__sitemap/url
weekly
https://nuxtseo.com/fr/defaults
daily
1.0
https://nuxtseo.com/es/__sitemap/url
weekly
https://nuxtseo.com/fr/__sitemap/url
weekly
https://nuxtseo.com/wildcard/defaults/foo
daily
1.0
https://nuxtseo.com/fr/wildcard/defaults/foo
daily
1.0
"
`)
}, 60000)
})
================================================
FILE: test/e2e/i18n/simple-trailing.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
site: {
trailingSlash: true,
},
i18n: {
locales: [
'en',
'fr',
],
trailingSlash: true,
},
sitemap: {
urls: ['/extra'],
sitemaps: false,
},
},
})
describe('i18n prefix', () => {
it('basic', async () => {
const posts = await $fetch('/sitemap.xml')
expect(posts).toMatchInlineSnapshot(`
"
https://nuxtseo.com/en/
https://nuxtseo.com/es/
https://nuxtseo.com/extra/
https://nuxtseo.com/fr/
https://nuxtseo.com/no-i18n/
https://nuxtseo.com/en/test/
https://nuxtseo.com/es/test/
https://nuxtseo.com/fr/test/
https://nuxtseo.com/en/__sitemap/url/
weekly
https://nuxtseo.com/es/__sitemap/url/
weekly
https://nuxtseo.com/fr/__sitemap/url/
weekly
"
`)
}, 60000)
})
================================================
FILE: test/e2e/issues/504-duplicate-api-calls.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/issue-504'),
server: true,
})
describe('issue #504 - duplicate API calls with includeAppSources', () => {
it('should only call API source once per sitemap request', async () => {
// Get initial count before first request
const initial = await $fetch<{ count: number }>('/api/__sitemap__/call-count')
const startCount = initial.count
// First request to sitemap - should only increment by 1
await $fetch('/test.xml')
const after1 = await $fetch<{ count: number }>('/api/__sitemap__/call-count')
expect(after1.count - startCount).toBe(1)
// Second request to sitemap - should only increment by 1, not N+1
await $fetch('/test.xml')
const after2 = await $fetch<{ count: number }>('/api/__sitemap__/call-count')
expect(after2.count - after1.count).toBe(1)
// Third request to sitemap - should only increment by 1, not N+1
await $fetch('/test.xml')
const after3 = await $fetch<{ count: number }>('/api/__sitemap__/call-count')
expect(after3.count - after2.count).toBe(1)
}, 60000)
})
================================================
FILE: test/e2e/issues/issue-384.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/issue-384'),
})
describe('issue #384 - sitemap with robots disallow /', () => {
it('should still generate sitemap URLs when robots disallows everything', async () => {
// robots.txt disallow should NOT prevent URLs from appearing in sitemaps
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toContain('')
expect(sitemap).toContain('/about')
}, 60000)
it('should block crawlers from the sitemap via X-Robots-Tag', async () => {
const res = await fetch('/sitemap.xml')
expect(res.headers.get('x-robots-tag')).toBe('noindex, nofollow')
}, 60000)
})
================================================
FILE: test/e2e/issues/issue-561.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/issue-561'),
server: true,
dev: false,
})
describe('issue #561 - autoI18n: false generates empty sitemap', () => {
it('should generate a single sitemap.xml (not redirect to sitemap_index)', async () => {
const sitemap = await $fetch('/sitemap.xml')
// should be a sitemap, not a redirect to sitemap_index
expect(sitemap).toContain(' {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod for cleaner assertions
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
// should contain URL entries - not empty
expect(sitemap).toContain('')
expect(sitemap).toContain('')
// should contain the homepage and locale variants
expect(sitemap).toContain('https://example.com/')
expect(sitemap).toContain('/en')
// should contain custom i18n page routes
expect(sitemap).toContain('/envoyer-tableau')
expect(sitemap).toContain('/en/submit-art')
expect(sitemap).toContain('/politique-de-confidentialite')
expect(sitemap).toContain('/en/privacy-policy')
// autoI18n: false should suppress hreflang alternatives (#586)
expect(sitemap).not.toContain('xhtml:link')
expect(sitemap).not.toContain('hreflang')
}, 60000)
})
================================================
FILE: test/e2e/issues/issue-564.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
app: {
baseURL: '/test',
},
sitemap: {
sitemaps: true,
},
},
})
describe('issue 564 - base URL in sitemap redirect with multi sitemaps', () => {
it('redirects /test/sitemap.xml to /test/sitemap_index.xml', async () => {
const response = await fetch('/test/sitemap.xml', { redirect: 'manual' })
expect(response.status).toBe(307)
const location = response.headers.get('location')
expect(location).toContain('/test/sitemap_index.xml')
expect(location).not.toBe('/sitemap_index.xml')
}, 60000)
})
================================================
FILE: test/e2e/issues/issue-588.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/issue-588'),
server: true,
dev: false,
})
describe('issue #588 - useHead hreflang should not leak into sitemap when autoI18n: false', () => {
it('should not contain hreflang alternates from useHead()', async () => {
const sitemap = await $fetch('/sitemap.xml')
// should contain all pages
expect(sitemap).toContain('https://example.com/')
expect(sitemap).toContain('https://example.com/about')
expect(sitemap).toContain('https://example.com/contact')
// autoI18n: false should suppress hreflang alternatives even when added via useHead()
expect(sitemap).not.toContain('xhtml:link')
expect(sitemap).not.toContain('hreflang')
expect(sitemap).not.toContain('example.de')
expect(sitemap).not.toContain('example.fr')
expect(sitemap).not.toContain('example.it')
}, 60000)
})
================================================
FILE: test/e2e/multi/cache-filesystem.test.ts
================================================
import fs from 'node:fs'
import path from 'node:path'
import { createResolver } from '@nuxt/kit'
import { fetch, setup } from '@nuxt/test-utils'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// Create a temporary directory for cache storage
const cacheDir = resolve('../../fixtures/.cache-test')
// Ensure cache directory exists
beforeAll(() => {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true })
}
})
// Clean up cache directory after tests
afterAll(() => {
if (fs.existsSync(cacheDir)) {
fs.rmSync(cacheDir, { recursive: true, force: true })
}
})
// Basic multi-sitemap fixture with filesystem cache
await setup({
rootDir: resolve('../../fixtures/multi-with-chunks'),
dev: false, // Run in production mode to enable caching
nuxtConfig: {
sitemap: {
sitemaps: {
pages: {
includeAppSources: true,
},
posts: {
includeAppSources: false,
urls: [
{ url: '/post-1' },
{ url: '/post-2' },
],
},
},
cacheMaxAgeSeconds: 600, // 10 minutes
runtimeCacheStorage: {
driver: 'fs',
base: cacheDir,
},
},
},
})
describe('multi-sitemap filesystem caching', () => {
it('should cache sitemap files to filesystem', async () => {
// Clear cache directory
const files = fs.readdirSync(cacheDir)
for (const file of files) {
fs.rmSync(path.join(cacheDir, file), { recursive: true, force: true })
}
// First request - should create cache files
const response1 = await fetch('/sitemap_index.xml')
expect(response1.status).toBe(200)
// Give it a moment to write to filesystem
await new Promise(resolve => setTimeout(resolve, 500))
// Check that cache files were created
const cacheFiles = fs.readdirSync(cacheDir)
expect(cacheFiles.length).toBeGreaterThan(0)
// Should have the sitemap group directory
const sitemapCacheDir = path.join(cacheDir, 'sitemap')
expect(fs.existsSync(sitemapCacheDir)).toBe(true)
// Check for specific cache files
const sitemapFiles = fs.readdirSync(sitemapCacheDir)
// We should have cache files with keys based on our sitemap structure
const hasCacheFiles = sitemapFiles.length > 0
expect(hasCacheFiles).toBe(true)
// Second request - should hit cache
const response2 = await fetch('/sitemap_index.xml')
expect(response2.status).toBe(200)
// Content should be the same
const content1 = await response1.text()
const content2 = await response2.text()
expect(content1).toBe(content2)
})
it('should cache individual sitemap files', async () => {
// Request individual sitemap
const response = await fetch('/__sitemap__/pages.xml')
expect(response.status).toBe(200)
// Give it a moment to write to filesystem
await new Promise(resolve => setTimeout(resolve, 1000))
// Check cache structure
const cacheFiles = fs.readdirSync(cacheDir)
const sitemapCacheDir = path.join(cacheDir, 'sitemap')
if (fs.existsSync(sitemapCacheDir)) {
const sitemapFiles = fs.readdirSync(sitemapCacheDir)
// The cache structure seems to be different, let's check if we have more files after the request
expect(sitemapFiles.length).toBeGreaterThan(0)
}
else {
// Cache might be at the root level
const hasSitemapCache = cacheFiles.some(file => file.includes('sitemap'))
expect(hasSitemapCache).toBe(true)
}
})
it('should respect cache expiration', async () => {
// Note: This test is conceptual - we can't easily test actual expiration
// without mocking time or waiting for the cache to expire
// Request a sitemap
const response = await fetch('/__sitemap__/posts.xml')
expect(response.status).toBe(200)
// Check that cache headers indicate proper expiration
const cacheControl = response.headers.get('cache-control')
expect(cacheControl).toContain('max-age=600')
expect(cacheControl).toContain('s-maxage=600')
// Debug headers should show expiration info
expect(response.headers.get('X-Sitemap-Cache-Duration')).toBe('600s')
expect(response.headers.get('X-Sitemap-Cache-Expires')).toBeDefined()
})
})
================================================
FILE: test/e2e/multi/cache-swr.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { fetch, setup } from '@nuxt/test-utils'
import { isCI } from 'std-env'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
// Set up with SWR enabled and very short cache time
await setup({
rootDir: resolve('../../fixtures/multi-with-chunks'),
dev: false, // Run in production mode to enable caching
nuxtConfig: {
sitemap: {
sitemaps: {
pages: {
includeAppSources: true,
},
posts: {
includeAppSources: false,
urls: [
{ url: '/post-1' },
{ url: '/post-2' },
],
},
},
cacheMaxAgeSeconds: 2, // 2 seconds for fast testing
runtimeCacheStorage: {
driver: 'memory',
},
},
},
})
describe.skipIf(isCI)('multi-sitemap SWR behavior with cache expiration', () => {
it('should return SWR cache headers for sitemap index', async () => {
const response = await fetch('/sitemap_index.xml')
expect(response.headers.get('content-type')).toMatch(/xml/)
// Check cache headers - when SWR is enabled, we should see stale-while-revalidate directive
const cacheControl = response.headers.get('cache-control')
expect(cacheControl).toBeDefined()
expect(cacheControl).toContain('max-age=2')
expect(cacheControl).toContain('public')
expect(cacheControl).toContain('s-maxage=2')
expect(cacheControl).toContain('stale-while-revalidate=3600')
const xml = await response.text()
expect(xml).toContain(' {
// First request to populate cache
const response1 = await fetch('/__sitemap__/pages.xml')
const generated1 = response1.headers.get('X-Sitemap-Generated')
expect(generated1).toBeDefined()
// Immediate second request - should be from cache
const response2 = await fetch('/__sitemap__/pages.xml')
const generated2 = response2.headers.get('X-Sitemap-Generated')
// Timestamps should be very close (within 5ms) since it's cached
const time1 = new Date(generated1!).getTime()
const time2 = new Date(generated2!).getTime()
const diff = Math.abs(time2 - time1)
// TODO possibly this is broken
expect(diff).toBeLessThanOrEqual(7) // Allow up to 5ms difference for cached response
const xml = await response2.text()
expect(xml).toContain(' {
// First request to populate cache
const response1 = await fetch('/__sitemap__/posts.xml')
const generated1 = response1.headers.get('X-Sitemap-Generated')
const expires1 = response1.headers.get('X-Sitemap-Cache-Expires')
expect(generated1).toBeDefined()
expect(expires1).toBeDefined()
// Wait for cache to expire (3 seconds to be safe)
await new Promise(resolve => setTimeout(resolve, 3000))
// After expiration - should get new content with new timestamp
const response2 = await fetch('/__sitemap__/posts.xml')
const generated2 = response2.headers.get('X-Sitemap-Generated')
const expires2 = response2.headers.get('X-Sitemap-Cache-Expires')
// With SWR, we might get either stale or fresh content
// The key is that the response should be successful
expect(response2.status).toBe(200)
// Check that cache headers are still present
const cacheControl = response2.headers.get('cache-control')
expect(cacheControl).toContain('stale-while-revalidate')
// If we got fresh content, timestamps should be different
if (generated2 !== generated1) {
expect(expires2).not.toBe(expires1)
}
const xml = await response2.text()
expect(xml).toContain('/post-1')
expect(xml).toContain('/post-2')
}, 10000) // Increase timeout for this test
it('should update cache after expiration', async () => {
// Unique sitemap to avoid conflicts with other tests
const response1 = await fetch('/__sitemap__/products.xml')
const generated1 = response1.headers.get('X-Sitemap-Generated')
// Wait for cache to expire
await new Promise(resolve => setTimeout(resolve, 3000))
// Request after expiration
await fetch('/__sitemap__/products.xml')
// Give it a moment to update cache
await new Promise(resolve => setTimeout(resolve, 100))
// Third request should get the updated cache
const response3 = await fetch('/__sitemap__/products.xml')
const generated3 = response3.headers.get('X-Sitemap-Generated')
// First and third should be different (cache was updated)
expect(generated3).not.toBe(generated1)
// Second and third might be the same if second got fresh content
// or different if second got stale content
expect(response3.status).toBe(200)
}, 10000)
it('should verify debug headers show correct expiration', async () => {
const response = await fetch('/sitemap_index.xml')
// Check debug headers
const duration = response.headers.get('X-Sitemap-Cache-Duration')
const generated = response.headers.get('X-Sitemap-Generated')
const expires = response.headers.get('X-Sitemap-Cache-Expires')
const remaining = response.headers.get('X-Sitemap-Cache-Remaining')
expect(duration).toBe('2s')
expect(generated).toBeDefined()
expect(expires).toBeDefined()
expect(remaining).toBeDefined()
// Parse timestamps
const generatedTime = new Date(generated!).getTime()
const expiresTime = new Date(expires!).getTime()
// Expiration should be 2 seconds after generation (allow 1ms tolerance)
const diff = expiresTime - generatedTime
expect(diff).toBeGreaterThanOrEqual(1999) // Allow 1ms tolerance
expect(diff).toBeLessThanOrEqual(2001) // Allow 1ms tolerance
// Remaining should be a positive number less than or equal to 2
const remainingSeconds = Number.parseInt(remaining!.replace('s', ''))
expect(remainingSeconds).toBeLessThanOrEqual(2)
expect(remainingSeconds).toBeGreaterThanOrEqual(0)
})
it('should vary cache based on headers', async () => {
// First request with default headers
const response1 = await fetch('/sitemap_index.xml')
const generated1 = response1.headers.get('X-Sitemap-Generated')
expect(response1.status).toBe(200)
expect(generated1).toBeDefined()
// Wait for cache to expire plus buffer
await new Promise(resolve => setTimeout(resolve, 2500))
// Second request with different host header - should create new cache entry
const response2 = await fetch('/sitemap_index.xml', {
headers: {
Host: 'example.com',
},
})
const generated2 = response2.headers.get('X-Sitemap-Generated')
expect(response2.status).toBe(200)
expect(generated2).toBeDefined()
// If headers properly vary the cache, the timestamps can be different
// Note: In test environments, headers might not pass through correctly
// but we at least verify the responses are valid
// Third request with default headers again - within cache window
await new Promise(resolve => setTimeout(resolve, 100))
const response3 = await fetch('/sitemap_index.xml')
const generated3 = response3.headers.get('X-Sitemap-Generated')
expect(response3.status).toBe(200)
expect(generated3).toBeDefined()
// This should be from cache (either first or a fresh regeneration)
// We verify it's valid rather than checking exact match due to test environment
expect(new Date(generated3!).getTime()).toBeGreaterThan(0)
// Verify that different headers can generate different keys (if supported)
const response4 = await fetch('/sitemap_index.xml', {
headers: {
'X-Forwarded-Proto': 'http',
},
})
const generated4 = response4.headers.get('X-Sitemap-Generated')
expect(response4.status).toBe(200)
expect(generated4).toBeDefined()
// The cache key mechanism is implemented correctly
// but the test environment might not distinguish headers properly
// So we just verify all responses are successful
}, 5000)
})
================================================
FILE: test/e2e/multi/chunking-edge-cases.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/multi-with-chunks'),
server: true,
nuxtConfig: {
hooks: {
'nitro:config': function (config) {
config.runtimeConfig ??= {}
config.runtimeConfig.public ??= {}
config.runtimeConfig.public.siteUrl = 'https://nuxtseo.com'
},
},
},
})
describe('chunking edge cases', () => {
describe('empty chunks', () => {
it('returns 404 for non-existent chunk', async () => {
// The posts sitemap has 12 posts with chunkSize: 3, so it should have chunks 0-3
// Chunk 4 should not exist
try {
await $fetch('/__sitemap__/posts-4.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
it('returns 404 for chunk of non-chunked sitemap', async () => {
// pages sitemap doesn't have chunking enabled
try {
await $fetch('/__sitemap__/pages-0.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
})
describe('chunk boundary validation', () => {
it('handles last valid chunk', async () => {
// posts has 12 items with chunkSize: 3, so chunk 3 (the 4th chunk) is the last valid one
const chunk = await $fetch('/__sitemap__/posts-3.xml')
expect(chunk).toContain('https://nuxtseo.com/posts/10 ')
expect(chunk).toContain('https://nuxtseo.com/posts/11 ')
expect(chunk).toContain('https://nuxtseo.com/posts/12 ')
})
it('handles products chunk boundaries', async () => {
// products has 25 items with chunkSize: 10
// chunk 0: 1-10, chunk 1: 11-20, chunk 2: 21-25
const chunk2 = await $fetch('/__sitemap__/products-2.xml')
expect(chunk2).toContain('https://nuxtseo.com/products/21 ')
expect(chunk2).toContain('https://nuxtseo.com/products/25 ')
// chunk 3 should not exist
try {
await $fetch('/__sitemap__/products-3.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
})
})
================================================
FILE: test/e2e/multi/chunking.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/multi-with-chunks'),
server: true,
nuxtConfig: {
hooks: {
'nitro:config': function (config) {
config.runtimeConfig ??= {}
config.runtimeConfig.public ??= {}
config.runtimeConfig.public.siteUrl = 'https://nuxtseo.com'
},
},
},
})
describe('multi sitemaps with chunking', () => {
it('basic index', async () => {
const index = await $fetch('/sitemap_index.xml')
expect(index).toContain('https://nuxtseo.com/__sitemap__/pages.xml ')
// Should have 4 chunks for posts (12 posts / 3 per chunk)
expect(index).toContain('https://nuxtseo.com/__sitemap__/posts-0.xml ')
expect(index).toContain('https://nuxtseo.com/__sitemap__/posts-1.xml ')
expect(index).toContain('https://nuxtseo.com/__sitemap__/posts-2.xml ')
expect(index).toContain('https://nuxtseo.com/__sitemap__/posts-3.xml ')
// Should have 3 chunks for products (25 products / 10 per chunk)
expect(index).toContain('https://nuxtseo.com/__sitemap__/products-0.xml ')
expect(index).toContain('https://nuxtseo.com/__sitemap__/products-1.xml ')
expect(index).toContain('https://nuxtseo.com/__sitemap__/products-2.xml ')
})
// Debug test
it('posts sources', async () => {
const posts = await $fetch('/api/posts')
expect(posts).toHaveLength(12)
expect(posts[0]).toEqual({
loc: '/posts/1',
lastmod: expect.any(String),
})
})
it('posts chunk 0', async () => {
const chunk = await $fetch('/__sitemap__/posts-0.xml')
expect(chunk).toContain('https://nuxtseo.com/posts/1 ')
expect(chunk).toContain('https://nuxtseo.com/posts/2 ')
expect(chunk).toContain('https://nuxtseo.com/posts/3 ')
expect(chunk).not.toContain('https://nuxtseo.com/posts/4 ')
})
it('posts chunk 1', async () => {
const chunk = await $fetch('/__sitemap__/posts-1.xml')
expect(chunk).toContain('https://nuxtseo.com/posts/4 ')
expect(chunk).toContain('https://nuxtseo.com/posts/5 ')
expect(chunk).toContain('https://nuxtseo.com/posts/6 ')
expect(chunk).not.toContain('https://nuxtseo.com/posts/3 ')
expect(chunk).not.toContain('https://nuxtseo.com/posts/7 ')
})
it('posts chunk 3 (last)', async () => {
const chunk = await $fetch('/__sitemap__/posts-3.xml')
expect(chunk).toContain('https://nuxtseo.com/posts/10 ')
expect(chunk).toContain('https://nuxtseo.com/posts/11 ')
expect(chunk).toContain('https://nuxtseo.com/posts/12 ')
expect(chunk).not.toContain('https://nuxtseo.com/posts/9 ')
})
it('products chunk 0', async () => {
const chunk = await $fetch('/__sitemap__/products-0.xml')
expect(chunk).toContain('https://nuxtseo.com/products/1 ')
expect(chunk).toContain('https://nuxtseo.com/products/10 ')
expect(chunk).not.toContain('https://nuxtseo.com/products/11 ')
})
it('products chunk 2 (last)', async () => {
const chunk = await $fetch('/__sitemap__/products-2.xml')
expect(chunk).toContain('https://nuxtseo.com/products/21 ')
expect(chunk).toContain('https://nuxtseo.com/products/25 ')
expect(chunk).not.toContain('https://nuxtseo.com/products/20 ')
})
it('non-chunked pages sitemap', async () => {
const pages = await $fetch('/__sitemap__/pages.xml')
expect(pages).toContain('https://nuxtseo.com/page/1')
expect(pages).toContain('https://nuxtseo.com/page/20 ')
})
it('404 for non-existent chunk', async () => {
// Should return 404 for chunks that don't exist
try {
await $fetch('/__sitemap__/posts-4.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
it('404 for non-existent chunked sitemap', async () => {
// Should return 404 for sitemap that doesn't support chunking
try {
await $fetch('/__sitemap__/pages-0.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
})
================================================
FILE: test/e2e/multi/defaults.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
sitemaps: {
foo: {
include: ['/foo/*'],
urls: [
'/foo/1',
'/foo/2',
],
defaults: {
changefreq: 'weekly',
priority: 0.7,
},
},
bar: {
urls: [
'/bar/1',
'/bar/2',
],
defaults: {
changefreq: 'monthly',
priority: 0.5,
},
},
},
},
},
})
describe('mutli defaults', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/foo.xml')
// remove lastmods before tresting
sitemap = sitemap.replace(/lastmod>(.*?)<')
// basic test to make sure we get a valid response
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/foo/1
weekly
0.7
https://nuxtseo.com/foo/2
weekly
0.7
"
`)
}, 60000)
})
================================================
FILE: test/e2e/multi/endpoints.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
sitemaps: {
foo: {
sources: ['/api/sitemap/foo'],
defaults: {
changefreq: 'weekly',
priority: 0.7,
},
},
bar: {
sources: ['/api/sitemap/bar'],
},
},
},
},
})
describe('multi endpoints', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/foo.xml')
// remove lastmods before tresting
sitemap = sitemap.replace(/lastmod>(.*?)<')
// basic test to make sure we get a valid response
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/foo/1
weekly
0.7
https://nuxtseo.com/foo/2
weekly
0.7
https://nuxtseo.com/foo/3
weekly
0.7
https://nuxtseo.com/foo/4
weekly
0.7
https://nuxtseo.com/foo/5
weekly
0.7
"
`)
}, 60000)
})
================================================
FILE: test/e2e/multi/filtering.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
sitemaps: {
foo: {
urls: [
// default blocked routes
'/_nuxt',
'/_nuxt/foo',
'/api',
'/api/foo',
'/api/foo/bar',
// custom blocked routes
'/admin',
'/admin/foo',
'/admin/foo/bar',
// should be only route
'/valid',
],
exclude: [
'/api',
'/api/**',
'/admin/**',
],
},
},
},
},
})
describe('multi filtering', () => {
it('basic', async () => {
let sitemap = await $fetch('/__sitemap__/foo.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/valid
"
`)
}, 60000)
})
================================================
FILE: test/e2e/multi/issue-514.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/issue-514'),
server: true,
nuxtConfig: {
hooks: {
'nitro:config': function (config) {
config.runtimeConfig ??= {}
config.runtimeConfig.public ??= {}
config.runtimeConfig.public.siteUrl = 'https://example.com'
},
},
},
})
describe('issue 514 - multi sitemap with chunks and / prefix', () => {
it('sitemap index contains chunked sitemaps', async () => {
const index = await $fetch('/sitemap_index.xml')
expect(index).toContain('https://example.com/pages.xml')
// 15 urls with chunk size 10 = 2 chunks
expect(index).toContain('https://example.com/dynamic-0.xml ')
expect(index).toContain('https://example.com/dynamic-1.xml ')
})
it('pages sitemap works', async () => {
const pages = await $fetch('/pages.xml')
expect(pages).toContain('https://example.com/')
})
it('dynamic chunk 0 works', async () => {
const chunk = await $fetch('/dynamic-0.xml')
expect(chunk).toContain('https://example.com/dynamic/1')
expect(chunk).toContain('https://example.com/dynamic/10 ')
expect(chunk).not.toContain('https://example.com/dynamic/11 ')
})
it('dynamic chunk 1 works', async () => {
const chunk = await $fetch('/dynamic-1.xml')
expect(chunk).toContain('https://example.com/dynamic/11')
expect(chunk).toContain('https://example.com/dynamic/15 ')
expect(chunk).not.toContain('https://example.com/dynamic/10 ')
})
it('non-existent chunk returns 404', async () => {
try {
await $fetch('/dynamic-2.xml')
throw new Error('Should have thrown 404')
}
catch (error: any) {
expect(error.data?.statusCode || error.statusCode).toBe(404)
}
})
it('regular page routes still work', async () => {
const about = await $fetch('/about')
expect(about).toContain('About page')
})
})
================================================
FILE: test/e2e/single/baseUrl.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
app: {
baseURL: '/base',
},
},
})
describe('base', () => {
it('basic', async () => {
let sitemap = await $fetch('/base/sitemap.xml')
expect(sitemap).not.match(/\/base\/base\//g)
sitemap = sitemap.replace(/lastmod>(.*?)<')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/base
https://nuxtseo.com/base/about
daily
0.8
https://nuxtseo.com/base/crawled
https://nuxtseo.com/base/sub/page
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/baseUrlTrailingSlash.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
app: {
baseURL: '/subdir/',
},
site: {
trailingSlash: true,
},
},
})
describe('base url trailing slash', () => {
it('basic', async () => {
const sitemap = await $fetch('/subdir/sitemap.xml')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/subdir/
https://nuxtseo.com/subdir/about/
daily
0.8
https://nuxtseo.com/subdir/crawled/
https://nuxtseo.com/subdir/sub/page/
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/changeApiUrl.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
sources: ['/__sitemap'],
},
},
})
describe('base', () => {
it('basic', async () => {
const posts = await $fetch('/__sitemap')
expect(posts).toMatchInlineSnapshot(`
[
"/__sitemap/url",
{
"loc": "/__sitemap/loc",
},
{
"loc": "https://nuxtseo.com/__sitemap/abs",
},
]
`)
}, 60000)
})
================================================
FILE: test/e2e/single/encodeDynamicUrls.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls: [
// Pre-encoded URL with reserved characters - marked as encoded
{
loc: `/${encodeURIComponent('$-:)')}`,
_encoded: true,
},
// Pre-encoded emoji - marked as encoded
{
loc: `/${encodeURIComponent('😅')}`,
_encoded: true,
},
// Regular path without _encoded - will be auto-encoded
'/Bücher',
],
},
},
})
describe('_encoded: true', () => {
it('should preserve pre-encoded URLs without double-encoding', async () => {
const sitemap = await $fetch('/sitemap.xml')
// Pre-encoded reserved characters should stay encoded ($ and : stay encoded, ) is safe so gets decoded)
expect(sitemap).toContain('https://nuxtseo.com/%24-%3A) ')
// Pre-encoded emoji should stay encoded
expect(sitemap).toContain('https://nuxtseo.com/%F0%9F%98%85 ')
// Regular URL should be auto-encoded
expect(sitemap).toContain('https://nuxtseo.com/B%C3%BCcher ')
}, 60000)
})
================================================
FILE: test/e2e/single/filtering.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
excludeAppSources: true,
urls: [
// default blocked routes
'/_nuxt',
'/_nuxt/foo',
'/api',
'/api/foo',
'/api/foo/bar',
// custom blocked routes
'/admin',
'/admin/foo',
'/admin/foo/bar',
// should be only route
'/valid',
],
exclude: [
'/api/**',
'/admin/**',
],
},
},
})
describe('filtering', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/valid
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/generate.test.ts
================================================
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { describe, expect, it } from 'vitest'
describe.skipIf(process.env.CI)('generate', () => {
it('basic', async () => {
process.env.NODE_ENV = 'production'
// @ts-expect-error untyped
process.env.prerender = true
process.env.NITRO_PRESET = 'static'
process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
const { resolve } = createResolver(import.meta.url)
const rootDir = resolve('../../fixtures/generate')
const nuxt = await loadNuxt({
rootDir,
overrides: {
nitro: {
preset: 'static',
},
_generate: true,
},
})
await buildNuxt(nuxt)
await new Promise(resolve => setTimeout(resolve, 1000))
const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
// verify /noindex is not in the sitemap
expect(sitemap).not.toContain('/noindex')
// #568: verify definePageMeta sitemap data is preserved during generate
expect(sitemap).toContain('https://nuxtseo.com/about ')
expect(sitemap).toContain('daily ')
expect(sitemap).toContain('0.8 ')
// #568: verify route rules sitemap data is applied during generate
expect(sitemap).toContain('https://nuxtseo.com/sub/page ')
expect(sitemap).toContain('weekly ')
expect(sitemap).toContain('0.5 ')
}, 1200000)
})
================================================
FILE: test/e2e/single/issue-592.test.ts
================================================
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { describe, expect, it } from 'vitest'
describe('issue #592: zeroRuntime should prerender sitemaps without manual nitro.prerender.routes', () => {
it('generates sitemap index and child sitemaps with zeroRuntime and i18n', async () => {
process.env.NODE_ENV = 'production'
process.env.NUXT_PUBLIC_SITE_URL = 'https://nuxtseo.com'
const { resolve } = createResolver(import.meta.url)
const rootDir = resolve('../../fixtures/issue-592')
const nuxt = await loadNuxt({
rootDir,
overrides: {
// SSR build, not nuxt generate: _generate is false, preset is node-server
_generate: false,
nitro: {
preset: 'node-server',
prerender: {
// no manual routes, zeroRuntime should handle it
crawlLinks: false,
},
},
},
})
await buildNuxt(nuxt)
await new Promise(resolve => setTimeout(resolve, 1000))
const outputDir = resolve(rootDir, '.output/public')
// sitemap_index.xml should exist
expect(existsSync(resolve(outputDir, 'sitemap_index.xml'))).toBe(true)
const sitemapIndex = await readFile(resolve(outputDir, 'sitemap_index.xml'), 'utf-8')
expect(sitemapIndex).toContain('__sitemap__/en-US.xml')
expect(sitemapIndex).toContain('__sitemap__/de-DE.xml')
// child sitemaps should exist
expect(existsSync(resolve(outputDir, '__sitemap__/en-US.xml'))).toBe(true)
expect(existsSync(resolve(outputDir, '__sitemap__/de-DE.xml'))).toBe(true)
}, 120000)
})
================================================
FILE: test/e2e/single/lastmod.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls: [
{
loc: '/foo',
// valid but with milliseconds, should be removed
lastmod: '2023-12-21T13:49:27.963745',
},
{
loc: 'bar',
lastmod: '2023-12-21', // valid - no timezone
},
{
loc: 'baz',
lastmod: '2023-12-21T13:49:27', // valid - timezone
},
{
loc: 'qux',
lastmod: '2023-12-21T13:49:27Z',
},
{
loc: 'quux',
lastmod: '2023 tuesday 3rd march', // very broken
},
{
loc: '/issue/206',
lastmod: '2023-12-21T22:46:58.441+00:00',
},
],
},
},
})
describe('lastmod', () => {
it('basic', async () => {
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/bar
2023-12-21
https://nuxtseo.com/baz
2023-12-21T13:49:27Z
https://nuxtseo.com/crawled
https://nuxtseo.com/foo
2023-12-21T13:49:27Z
https://nuxtseo.com/quux
https://nuxtseo.com/qux
2023-12-21T13:49:27Z
https://nuxtseo.com/issue/206
2023-12-21T22:46:58Z
https://nuxtseo.com/sub/page
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/news.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls() {
return [
{
loc: 'https://nuxtseo.com/',
news: {
publication: {
name: 'Nuxt SEO',
language: 'en',
},
title: 'Nuxt SEO',
publication_date: '2008-12-23',
},
},
{
loc: 'https://harlanzw.com/',
news: {
publication: {
name: 'Harlan Wilton',
language: 'en',
},
title: 'Sitemap test',
publication_date: '2008-12-23',
},
},
]
},
},
},
})
describe('news', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://harlanzw.com/
Harlan Wilton
en
Sitemap test
2008-12-23
https://nuxtseo.com/
Nuxt SEO
en
Nuxt SEO
2008-12-23
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/crawled
https://nuxtseo.com/sub/page
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/pageMetaSitemap.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
})
describe('definePageMeta sitemap', () => {
it('applies sitemap meta from definePageMeta to output', async () => {
const sitemap = await $fetch('/sitemap.xml')
// about.vue has definePageMeta({ sitemap: { priority: 0.8, changefreq: 'daily' } })
expect(sitemap).toContain('https://nuxtseo.com/about ')
expect(sitemap).toContain('daily ')
expect(sitemap).toContain('0.8 ')
}, 60000)
})
================================================
FILE: test/e2e/single/queryRoutes.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls: [
'/',
'/query-no-slash?foo=bar',
'/query-slash/?foo=bar',
'/query-slash-hash/?foo=bar#hash',
],
},
},
})
describe('query routes', () => {
it('basic', async () => {
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toContain('https://nuxtseo.com/query-no-slash?foo=bar ')
expect(sitemap).toContain('https://nuxtseo.com/query-slash?foo=bar ')
expect(sitemap).not.toContain('https://nuxtseo.com/query-slash-hash?foo=bar#hash ')
}, 60000)
})
================================================
FILE: test/e2e/single/routeRules.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
excludeAppSources: true,
urls: ['/x-robots-tag', '/redirect', '/hidden', '/defaults', '/wildcard/defaults/foo', '/wildcard/hidden/foo'],
},
routeRules: {
'/x-robots-tag': {
headers: {
'x-robots-tag': 'noindex',
},
},
// won't be indexed
'/redirect': {
redirect: '/defaults',
},
'/hidden': {
// @ts-expect-error untyped
robots: false,
},
'/defaults': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/defaults/**': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/hidden/**': {
// @ts-expect-error untyped
robots: false,
},
},
},
})
describe('route rules', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/defaults
daily
1.0
https://nuxtseo.com/wildcard/defaults/foo
daily
1.0
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/routeRulesTrailingSlash.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
site: {
trailingSlash: true,
},
sitemap: {
excludeAppSources: true,
urls: ['/hidden/', '/defaults/', '/wildcard/defaults/foo/', '/wildcard/hidden/foo/'],
},
routeRules: {
'/hidden': {
// @ts-expect-error untyped
robots: false,
},
'/hidden/': {
// @ts-expect-error untyped
robots: false,
},
'/defaults': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/defaults/**': {
sitemap: {
changefreq: 'daily',
priority: 1,
},
},
'/wildcard/hidden/**': {
// @ts-expect-error untyped
robots: false,
},
},
},
})
describe('route rules', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/defaults/
daily
1.0
https://nuxtseo.com/wildcard/defaults/foo/
daily
1.0
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/sitemapName.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
sitemapName: 'test.xml',
},
},
})
describe('sitemapName', () => {
it('basic', async () => {
let sitemap = await $fetch('/test.xml')
// remove lastmods before tresting
sitemap = sitemap.replace(/lastmod>(.*?)<')
// basic test to make sure we get a valid response
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/crawled
https://nuxtseo.com/sub/page
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/trailingSlashes.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
site: {
url: 'https://nuxtseo.com',
trailingSlash: true,
},
sitemap: {
// test from endpoint as well
sources: ['/__sitemap'],
},
},
})
describe('trailing slashes', () => {
it('basic', async () => {
const sitemap = await $fetch('/sitemap.xml')
// extract the URLs from loc using regex
// @ts-expect-error untyped
const sitemapUrls = sitemap.match(/(.*?)<\/loc>/g)!.map(url => url.replace(/<\/?loc>/g, ''))
// @ts-expect-error untyped
sitemapUrls.forEach((url) => {
expect(url.endsWith('/')).toBeTruthy()
})
}, 60000)
})
================================================
FILE: test/e2e/single/urlEncoded.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls: [
'/Bücher',
'/Bibliothèque',
],
},
},
})
describe('query routes', () => {
it('should be url encoded', async () => {
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toContain('https://nuxtseo.com/B%C3%BCcher ')
expect(sitemap).toContain('https://nuxtseo.com/Biblioth%C3%A8que ')
expect(sitemap).not.toContain('https://nuxtseo.com/Bücher')
expect(sitemap).not.toContain('https://nuxtseo.com/Bibliothèque')
}, 60000)
})
================================================
FILE: test/e2e/single/video.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
urls: [
{
loc: 'https://www.example.com/videos/some_video_landing_page.html',
videos: [
{
title: 'Grilling steaks for summer',
thumbnail_loc: 'https://www.example.com/thumbs/123.jpg',
description: 'Alkis shows you how to get perfectly done steaks every time',
content_loc: 'https://streamserver.example.com/video123.mp4',
player_loc: 'https://www.example.com/videoplayer.php?video=123',
duration: 600,
expiration_date: '2022-12-12T00:00:00+00:00',
rating: 4.2,
view_count: 12345,
publication_date: '2007-11-05T19:00:00+00:00',
family_friendly: 'yes',
restriction: {
relationship: 'allow',
restriction: 'IE GB US CA',
},
platform: {
relationship: 'allow',
platform: 'web mobile',
},
requires_subscription: 'yes',
price: [
{
currency: 'EUR',
type: 'rent',
price: 3.99,
},
],
uploader: {
uploader: 'GrillyMcGrillerson',
info: 'https://example.com/users/grillymcgrillerson',
},
live: 'no',
tag: ['steak', 'grilling', 'summer'],
},
],
},
],
},
},
})
describe('video', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/crawled
https://nuxtseo.com/sub/page
https://www.example.com/videos/some_video_landing_page.html
Grilling steaks for summer
https://www.example.com/thumbs/123.jpg
Alkis shows you how to get perfectly done steaks every time
https://streamserver.example.com/video123.mp4
https://www.example.com/videoplayer.php?video=123
600
2022-12-12T00:00:00+00:00
4.2
12345
2007-11-05T19:00:00+00:00
yes
IE GB US CA
web mobile
yes
3.99
GrillyMcGrillerson
no
steak
grilling
summer
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/xsl.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
nuxtConfig: {
sitemap: {
xsl: false,
},
},
})
describe('xsl false', () => {
it('basic', async () => {
let sitemap = await $fetch('/sitemap.xml')
// strip lastmod
sitemap = sitemap.replace(/.*<\/lastmod>/g, '')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/crawled
https://nuxtseo.com/sub/page
"
`)
}, 60000)
})
================================================
FILE: test/e2e/single/zero-runtime-build.test.ts
================================================
import { readFile } from 'node:fs/promises'
import { buildNuxt, createResolver, loadNuxt } from '@nuxt/kit'
import { setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
dev: true,
nuxtConfig: {
sitemap: {
zeroRuntime: true,
},
},
})
describe('zeroRuntime', () => {
describe.skipIf(process.env.CI)('prerender', () => {
it('generates sitemap during prerender', async () => {
const rootDir = resolve('../../fixtures/generate')
const nuxt = await loadNuxt({
rootDir,
overrides: {
sitemap: {
zeroRuntime: true,
autoLastmod: false,
credits: false,
},
},
})
await buildNuxt(nuxt)
const sitemap = (await readFile(resolve(rootDir, '.output/public/sitemap.xml'), 'utf-8')).replace(/lastmod>(.*?)<')
expect(sitemap).toMatchInlineSnapshot(`
"
https://nuxtseo.com/
https://nuxtseo.com/about
daily
0.8
https://nuxtseo.com/crawled
https://nuxtseo.com/dynamic/crawled
https://nuxtseo.com/sub/page
weekly
0.5
"
`)
expect(sitemap).not.toContain('/noindex')
}, 1200000)
})
})
================================================
FILE: test/e2e/single/zero-runtime-dev.test.ts
================================================
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
const { resolve } = createResolver(import.meta.url)
await setup({
rootDir: resolve('../../fixtures/basic'),
dev: true,
nuxtConfig: {
sitemap: {
zeroRuntime: true,
},
},
})
describe.skipIf(process.env.CI)('zero runtime dev', () => {
it('serves sitemap in dev mode', async () => {
// zeroRuntime handlers still work in dev (import.meta.dev === true)
// In dev mode, URLs use the local origin rather than the configured site URL
const sitemap = await $fetch('/sitemap.xml')
expect(sitemap).toContain('')
expect(sitemap).toContain('/about')
}, 60000)
})
================================================
FILE: test/fixtures/basic/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
routeRules: {
'/foo-redirect': {
redirect: '/foo',
},
},
compatibilityDate: '2025-01-15',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/basic/pages/about.vue
================================================
About
================================================
FILE: test/fixtures/basic/pages/crawled.vue
================================================
================================================
FILE: test/fixtures/basic/pages/dynamic/[slug].vue
================================================
Hello world
================================================
FILE: test/fixtures/basic/pages/index.vue
================================================
Hello World
crawled
should be ignored as its a redirect
sitemap.xml
================================================
FILE: test/fixtures/basic/pages/sub/page.vue
================================================
Hello world
================================================
FILE: test/fixtures/basic/server/api/sitemap/bar.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/bar/${post}`,
})),
]
})
================================================
FILE: test/fixtures/basic/server/api/sitemap/foo.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/foo/${post}`,
})),
]
})
================================================
FILE: test/fixtures/basic/server/routes/__sitemap.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return [
'/__sitemap/url',
{
loc: '/__sitemap/loc',
},
{
loc: 'https://nuxtseo.com/__sitemap/abs',
},
]
})
================================================
FILE: test/fixtures/chunk-cache/app.vue
================================================
chunk cache fixture
================================================
FILE: test/fixtures/chunk-cache/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [NuxtSitemap],
site: { url: 'https://nuxtseo.com' },
sitemap: {
autoLastmod: false,
credits: false,
cacheMaxAgeSeconds: 600,
runtimeCacheStorage: { driver: 'memory' },
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: 5,
},
},
},
})
================================================
FILE: test/fixtures/chunk-cache/server/api/posts.ts
================================================
import { defineEventHandler } from 'h3'
declare global {
// eslint-disable-next-line vars-on-top, no-var
var __postsSourceCallCount: number
}
globalThis.__postsSourceCallCount ??= 0
export default defineEventHandler(() => {
globalThis.__postsSourceCallCount++
return Array.from({ length: 17 }, (_, i) => ({
loc: `/posts/${i + 1}`,
}))
})
================================================
FILE: test/fixtures/chunk-cache/server/api/source-call-count.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return { count: globalThis.__postsSourceCallCount ?? 0 }
})
================================================
FILE: test/fixtures/chunk-count/app.vue
================================================
chunk count fixture
================================================
FILE: test/fixtures/chunk-count/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [NuxtSitemap],
site: { url: 'https://nuxtseo.com' },
sitemap: {
autoLastmod: false,
credits: false,
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: 5,
chunkCount: 4,
},
},
},
})
================================================
FILE: test/fixtures/chunk-count/server/api/posts-call-count.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => ({ count: globalThis.__chunkCountPostsCalls ?? 0 }))
================================================
FILE: test/fixtures/chunk-count/server/api/posts.ts
================================================
import { defineEventHandler } from 'h3'
declare global {
// eslint-disable-next-line vars-on-top, no-var
var __chunkCountPostsCalls: number
}
globalThis.__chunkCountPostsCalls ??= 0
export default defineEventHandler(() => {
globalThis.__chunkCountPostsCalls++
return Array.from({ length: 17 }, (_, i) => ({ loc: `/posts/${i + 1}` }))
})
================================================
FILE: test/fixtures/chunks/app.vue
================================================
hello world
================================================
FILE: test/fixtures/chunks/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
defaultSitemapsChunkSize: 5,
sitemaps: true,
urls: Array.from({ length: 20 }, (_, i) => `/foo/${i + 1}`),
excludeAppSources: true,
},
})
================================================
FILE: test/fixtures/chunks/server/api/sitemap/bar.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/bar/${post}`,
})),
]
})
================================================
FILE: test/fixtures/chunks/server/api/sitemap/foo.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/foo/${post}`,
})),
]
})
================================================
FILE: test/fixtures/chunks/server/routes/__sitemap.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return [
'/__sitemap/url',
{
loc: '/__sitemap/loc',
},
{
loc: 'https://nuxtseo.com/__sitemap/abs',
},
]
})
================================================
FILE: test/fixtures/content-v3/.nuxtrc
================================================
imports.autoImport=true
typescript.includeWorkspace=true
================================================
FILE: test/fixtures/content-v3/app.vue
================================================
================================================
FILE: test/fixtures/content-v3/content/.navigation.yml
================================================
title: 'test'
================================================
FILE: test/fixtures/content-v3/content/_partial.md
================================================
---
sitemap: false
---
# bar
================================================
FILE: test/fixtures/content-v3/content/bar.md
================================================
---
sitemap:
lastmod: 2021-10-20
priority: 0.5
changefreq: daily
---
# bar
================================================
FILE: test/fixtures/content-v3/content/foo.md
================================================
---
sitemap:
priority: 0.5
---
# foo
================================================
FILE: test/fixtures/content-v3/content/posts/.navigation.yml
================================================
title: 'test'
================================================
FILE: test/fixtures/content-v3/content/posts/bar.md
================================================
---
sitemap:
lastmod: 2021-10-20
---
# bar
================================================
FILE: test/fixtures/content-v3/content/posts/fallback.md
================================================
---
sitemap:
lastmod: 2021-10-20
---
# foo
no sitemap config
================================================
FILE: test/fixtures/content-v3/content/posts/foo.md
================================================
# foo
no sitemap config
================================================
FILE: test/fixtures/content-v3/content/test-json.json
================================================
{
"title": "Test JSON Content",
"description": "This is a test JSON file for sitemap",
"sitemap": {
"lastmod": "2025-05-14",
"changefreq": "weekly",
"priority": 0.9
},
"content": "This is some JSON content"
}
================================================
FILE: test/fixtures/content-v3/content/test-yaml.yml
================================================
title: Test YAML Content
description: This is a test YAML file for sitemap
sitemap:
lastmod: 2025-05-13
changefreq: monthly
priority: 0.8
content: This is some YAML content
================================================
FILE: test/fixtures/content-v3/content.config.ts
================================================
import { resolve, dirname } from 'node:path'
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { asSitemapCollection } from '../../../src/content'
import { z } from 'zod'
// conjvert file path to url
const dirName = dirname(import.meta.url.replace('file://', ''))
export default defineContentConfig({
collections: {
content: defineCollection(
asSitemapCollection({
type: 'page',
source: {
include: '**/*',
cwd: resolve(dirName, 'content'),
},
schema: z.object({
date: z.string().optional(),
}),
}),
),
},
})
================================================
FILE: test/fixtures/content-v3/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxt/content',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-12-06',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/content-v3/pages/[...slug].vue
================================================
================================================
FILE: test/fixtures/content-v3-define-schema/app.vue
================================================
================================================
FILE: test/fixtures/content-v3-define-schema/content/bar.md
================================================
---
sitemap:
priority: 0.5
---
# Bar
================================================
FILE: test/fixtures/content-v3-define-schema/content/draft.md
================================================
---
draft: true
sitemap:
priority: 0.5
---
# Draft Post
This should be filtered from the sitemap.
================================================
FILE: test/fixtures/content-v3-define-schema/content/foo.md
================================================
---
sitemap:
priority: 0.5
---
# Foo
================================================
FILE: test/fixtures/content-v3-define-schema/content/future.md
================================================
---
date: '2099-01-01'
sitemap:
priority: 0.5
---
# Future Post
This should be filtered from the sitemap.
================================================
FILE: test/fixtures/content-v3-define-schema/content/published.md
================================================
---
date: '2024-01-01'
draft: false
sitemap:
priority: 0.5
---
# Published Post
This should appear in the sitemap.
================================================
FILE: test/fixtures/content-v3-define-schema/content.config.ts
================================================
import { resolve, dirname } from 'node:path'
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '../../../src/content'
import { z } from 'zod'
const dirName = dirname(import.meta.url.replace('file://', ''))
export default defineContentConfig({
collections: {
content: defineCollection({
type: 'page',
source: {
include: '**/*',
cwd: resolve(dirName, 'content'),
},
schema: z.object({
date: z.string().optional(),
draft: z.boolean().optional(),
sitemap: defineSitemapSchema({
name: 'content',
filter: (entry) => {
if (entry.draft)
return false
if (entry.date && new Date(entry.date) > new Date())
return false
return true
},
}),
}),
}),
},
})
================================================
FILE: test/fixtures/content-v3-define-schema/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxt/content',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-12-06',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/content-v3-define-schema/pages/[...slug].vue
================================================
================================================
FILE: test/fixtures/content-v3-filtering/content/bar.md
================================================
---
sitemap:
priority: 0.5
---
# Bar
================================================
FILE: test/fixtures/content-v3-filtering/content/draft.md
================================================
---
draft: true
sitemap:
priority: 0.5
---
# Draft Post
This should be filtered from the sitemap.
================================================
FILE: test/fixtures/content-v3-filtering/content/foo.md
================================================
---
sitemap:
priority: 0.5
---
# Foo
================================================
FILE: test/fixtures/content-v3-filtering/content/future.md
================================================
---
date: '2099-01-01'
sitemap:
priority: 0.5
---
# Future Post
This should be filtered from the sitemap.
================================================
FILE: test/fixtures/content-v3-filtering/content/published.md
================================================
---
date: '2024-01-01'
draft: false
sitemap:
priority: 0.5
---
# Published Post
This should appear in the sitemap.
================================================
FILE: test/fixtures/content-v3-filtering/content.config.ts
================================================
import { resolve, dirname } from 'node:path'
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { asSitemapCollection } from '../../../src/content'
import { z } from 'zod'
const dirName = dirname(import.meta.url.replace('file://', ''))
export default defineContentConfig({
collections: {
content: defineCollection(
asSitemapCollection({
type: 'page',
source: {
include: '**/*',
cwd: resolve(dirName, 'content'),
},
schema: z.object({
date: z.string().optional(),
draft: z.boolean().optional(),
}),
}, {
name: 'content',
filter: (entry) => {
if (entry.draft)
return false
if (entry.date && new Date(entry.date) > new Date())
return false
return true
},
}),
),
},
})
================================================
FILE: test/fixtures/content-v3-filtering/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxt/content',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-12-06',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/content-v3-i18n/.nuxtrc
================================================
imports.autoImport=true
typescript.includeWorkspace=true
================================================
FILE: test/fixtures/content-v3-i18n/app.vue
================================================
================================================
FILE: test/fixtures/content-v3-i18n/content/en/getting-started.md
================================================
---
sitemap: {}
---
# Getting Started
================================================
FILE: test/fixtures/content-v3-i18n/content/en/index.md
================================================
---
sitemap: {}
---
# Home
================================================
FILE: test/fixtures/content-v3-i18n/content/ja/getting-started.md
================================================
---
sitemap: {}
---
# はじめに
================================================
FILE: test/fixtures/content-v3-i18n/content/ja/index.md
================================================
---
sitemap: {}
---
# ホーム
================================================
FILE: test/fixtures/content-v3-i18n/content.config.ts
================================================
import { dirname, resolve } from 'node:path'
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { defineSitemapSchema } from '../../../src/content'
import { z } from 'zod'
const dirName = dirname(import.meta.url.replace('file://', ''))
export default defineContentConfig({
collections: {
content_en: defineCollection({
type: 'page',
source: {
include: 'en/**',
prefix: '/',
cwd: resolve(dirName, 'content'),
},
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
content_ja: defineCollection({
type: 'page',
source: {
include: 'ja/**',
prefix: '/ja',
cwd: resolve(dirName, 'content'),
},
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
},
})
================================================
FILE: test/fixtures/content-v3-i18n/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxt/content',
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-12-06',
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'ja',
iso: 'ja-JP',
},
],
},
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/content-v3-i18n/pages/[...slug].vue
================================================
================================================
FILE: test/fixtures/generate/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
routeRules: {
'/foo-redirect': {
redirect: '/foo',
},
'/sub/page': {
sitemap: {
changefreq: 'weekly',
priority: 0.5,
},
},
},
compatibilityDate: '2025-01-15',
nitro: {
prerender: {
crawlLinks: true,
routes: ['/', '/about', '/noindex', '/sub/page'],
},
},
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/generate/pages/about.vue
================================================
About
================================================
FILE: test/fixtures/generate/pages/crawled.vue
================================================
================================================
FILE: test/fixtures/generate/pages/dynamic/[slug].vue
================================================
Hello world
================================================
FILE: test/fixtures/generate/pages/index.vue
================================================
Hello World
crawled
should be ignored as its a redirect
================================================
FILE: test/fixtures/generate/pages/noindex.vue
================================================
This page should not be in the sitemap
================================================
FILE: test/fixtures/generate/pages/sub/page.vue
================================================
Hello world
================================================
FILE: test/fixtures/generate/server/api/sitemap/bar.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/bar/${post}`,
})),
]
})
================================================
FILE: test/fixtures/generate/server/api/sitemap/foo.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const posts = Array.from({ length: 5 }, (_, i) => i + 1)
return [
...posts.map(post => ({
loc: `/foo/${post}`,
})),
]
})
================================================
FILE: test/fixtures/generate/server/routes/__sitemap.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return [
'/__sitemap/url',
{
loc: '/__sitemap/loc',
},
{
loc: 'https://nuxtseo.com/__sitemap/abs',
},
]
})
================================================
FILE: test/fixtures/hooks/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
routeRules: {
'/foo-redirect': {
redirect: '/foo',
},
},
compatibilityDate: '2025-01-15',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/hooks/pages/index.vue
================================================
================================================
FILE: test/fixtures/hooks/server/plugins/sitemap.ts
================================================
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:input', async (ctx) => {
ctx.urls.push({
loc: '/test-1',
})
ctx.urls.push({
loc: '/test-2',
})
})
})
================================================
FILE: test/fixtures/hooks/server/routes/__sitemap.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return [
'/__sitemap/url',
{
loc: '/__sitemap/loc',
},
{
loc: 'https://nuxtseo.com/__sitemap/abs',
},
]
})
================================================
FILE: test/fixtures/i18n/locales/en.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n/locales/hr.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n/locales/ja.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n/locales/nl.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n/locales/zh.ts
================================================
export default {
welcome: '欢迎光临',
}
================================================
FILE: test/fixtures/i18n/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
nitro: {
prerender: {
failOnError: false,
ignore: ['/'],
},
},
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
},
sitemap: {
sources: ['/__sitemap'],
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/i18n/pages/dynamic/[page].vue
================================================
{{ $route.params.page }}
================================================
FILE: test/fixtures/i18n/pages/index.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n/pages/no-i18n.vue
================================================
hello
================================================
FILE: test/fixtures/i18n/pages/test.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n/server/routes/__sitemap.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/__sitemap/url',
changefreq: 'weekly',
_i18nTransform: true,
},
]
})
================================================
FILE: test/fixtures/i18n/server/routes/i18n-urls.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/en/dynamic/foo',
},
{
loc: '/fr/dynamic/foo',
},
{
loc: 'endless-dungeon', // issue with en being picked up as the locale
_i18nTransform: true,
},
{
loc: 'english-url', // issue with en being picked up as the locale
},
// absolute URL issue
{ loc: 'https://www.somedomain.com/abc/def' },
]
})
================================================
FILE: test/fixtures/i18n-custom-paths/app.vue
================================================
================================================
FILE: test/fixtures/i18n-custom-paths/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
nitro: {
prerender: {
failOnError: false,
ignore: ['/'],
},
},
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
pages: {
test: {
en: '/test',
es: '/prueba',
fr: '/teste',
},
about: {
en: '/about',
es: '/acerca-de',
fr: '/a-propos',
},
// dynamic route with single parameter (issue #542)
posts: {
en: '/posts/[slug]',
es: '/articulos/[slug]',
fr: '/article/[slug]',
},
// dynamic route with multiple parameters
products: {
en: '/products/[category]/[id]',
es: '/productos/[category]/[id]',
fr: '/produits/[category]/[id]',
},
},
},
sitemap: {
sources: ['/__sitemap'],
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/i18n-custom-paths/server/routes/__sitemap.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
// Static routes
{
loc: '/test',
_i18nTransform: true,
},
{
loc: '/about',
_i18nTransform: true,
},
{
loc: '/__sitemap/url',
changefreq: 'weekly',
},
// Dynamic route with single parameter (issue #542)
{
loc: '/posts/my-slug',
_i18nTransform: true,
},
// Dynamic route with multiple parameters
{
loc: '/products/electronics/laptop-123',
_i18nTransform: true,
},
]
})
================================================
FILE: test/fixtures/i18n-generate/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
nitro: {
prerender: {
routes: ['/', '/de'],
crawlLinks: false,
},
},
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'de',
iso: 'de-DE',
},
],
},
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/i18n-generate/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/i18n-micro/locales/en.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n-micro/locales/hr.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n-micro/locales/ja.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n-micro/locales/nl.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n-micro/locales/zh.ts
================================================
export default {
welcome: '欢迎光临',
}
================================================
FILE: test/fixtures/i18n-micro/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'nuxt-i18n-micro',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
nitro: {
prerender: {
failOnError: false,
ignore: ['/'],
},
},
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
meta: true,
},
sitemap: {
sources: ['/__sitemap'],
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/i18n-micro/pages/dynamic/[page].vue
================================================
{{ $route.params.page }}
================================================
FILE: test/fixtures/i18n-micro/pages/index.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n-micro/pages/test.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n-micro/server/routes/__sitemap.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/__sitemap/url',
changefreq: 'weekly',
_i18nTransform: true,
},
]
})
================================================
FILE: test/fixtures/i18n-micro/server/routes/i18n-urls.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/en/dynamic/foo',
},
{
loc: '/fr/dynamic/foo',
},
{
loc: 'endless-dungeon', // issue with en being picked up as the locale
_i18nTransform: true,
},
{
loc: 'english-url', // issue with en being picked up as the locale
},
// absolute URL issue
{ loc: 'https://www.somedomain.com/abc/def' },
]
})
================================================
FILE: test/fixtures/i18n-no-prefix/locales/en.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n-no-prefix/locales/hr.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n-no-prefix/locales/ja.ts
================================================
export default {
welcome: 'ようこそ',
}
================================================
FILE: test/fixtures/i18n-no-prefix/locales/nl.ts
================================================
export default {
welcome: 'Welcome',
}
================================================
FILE: test/fixtures/i18n-no-prefix/locales/zh.ts
================================================
export default {
welcome: '欢迎光临',
}
================================================
FILE: test/fixtures/i18n-no-prefix/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
nitro: {
prerender: {
failOnError: false,
ignore: ['/'],
},
},
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'no_prefix',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'es',
iso: 'es-ES',
},
{
code: 'fr',
iso: 'fr-FR',
},
],
pages: {
test: {
en: '/test',
es: '/prueba',
fr: '/teste',
},
about: {
en: '/about',
es: '/acerca-de',
fr: '/a-propos',
},
},
},
sitemap: {
sources: ['/__sitemap'],
autoLastmod: false,
credits: false,
debug: true,
discoverImages: false,
discoverVideos: false,
},
})
================================================
FILE: test/fixtures/i18n-no-prefix/pages/dynamic/[page].vue
================================================
{{ $route.params.page }}
================================================
FILE: test/fixtures/i18n-no-prefix/pages/index.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n-no-prefix/pages/test.vue
================================================
{{ $t('welcome') }}
================================================
FILE: test/fixtures/i18n-no-prefix/server/routes/__sitemap.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/__sitemap/url',
changefreq: 'weekly',
_i18nTransform: true,
},
]
})
================================================
FILE: test/fixtures/i18n-no-prefix/server/routes/i18n-urls.ts
================================================
import { defineSitemapEventHandler } from '#imports'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/en/dynamic/foo',
},
{
loc: '/fr/dynamic/foo',
},
{
loc: 'endless-dungeon', // issue with en being picked up as the locale
_i18nTransform: true,
},
{
loc: 'english-url', // issue with en being picked up as the locale
},
// absolute URL issue
{ loc: 'https://www.somedomain.com/abc/def' },
]
})
================================================
FILE: test/fixtures/issue-384/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
'@nuxtjs/robots',
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
robots: {
groups: [
{
userAgent: '*',
disallow: '/',
},
],
},
compatibilityDate: '2025-01-15',
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/issue-384/pages/about.vue
================================================
About
================================================
FILE: test/fixtures/issue-384/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/issue-504/nuxt.config.ts
================================================
import { defineNuxtConfig } from 'nuxt/config'
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://example.com',
},
sitemap: {
cacheMaxAgeSeconds: 0,
sitemapsPathPrefix: false,
sitemaps: {
test: {
includeAppSources: true,
sources: ['/api/__sitemap__/test'],
},
},
},
})
================================================
FILE: test/fixtures/issue-504/pages/about.vue
================================================
About
================================================
FILE: test/fixtures/issue-504/pages/index.vue
================================================
Index
================================================
FILE: test/fixtures/issue-504/server/api/__sitemap__/[s_type].ts
================================================
import { defineEventHandler, getRouterParam } from 'h3'
// Track call count at module level
let callCount = 0
export default defineEventHandler((event) => {
const category = getRouterParam(event, 's_type')
callCount++
// eslint-disable-next-line no-console
console.log(`sitemap: ${category} (call ${callCount})`)
// Store count in app context for test retrieval
const storage = (globalThis as any).__sitemapTestStorage = (globalThis as any).__sitemapTestStorage || {}
storage.callCount = callCount
return [
{ loc: '/dynamic-page-1' },
{ loc: '/dynamic-page-2' },
]
})
================================================
FILE: test/fixtures/issue-504/server/api/__sitemap__/call-count.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
const storage = (globalThis as any).__sitemapTestStorage || {}
return { count: storage.callCount || 0 }
})
================================================
FILE: test/fixtures/issue-514/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://example.com',
},
sitemap: {
cacheMaxAgeSeconds: 0,
sitemapsPathPrefix: '/',
sitemaps: {
pages: {
includeAppSources: true,
},
dynamic: {
sources: ['/api/urls'],
chunks: 10,
},
},
},
})
================================================
FILE: test/fixtures/issue-514/pages/about.vue
================================================
About page
================================================
FILE: test/fixtures/issue-514/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/issue-514/server/api/urls.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return Array.from({ length: 15 }, (_, i) => ({
loc: `/dynamic/${i + 1}`,
lastmod: new Date(2024, 0, i + 1).toISOString(),
}))
})
================================================
FILE: test/fixtures/issue-561/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://example.com',
},
compatibilityDate: '2024-07-22',
i18n: {
baseUrl: 'https://example.com',
strategy: 'prefix_except_default',
defaultLocale: 'fr',
detectBrowserLanguage: false,
locales: [
{
code: 'en',
iso: 'en-CA',
language: 'en',
},
{
code: 'fr',
iso: 'fr-CA',
language: 'fr',
},
],
trailingSlash: true,
customRoutes: 'config',
pages: {
'submit-art': {
fr: '/envoyer-tableau',
en: '/submit-art',
},
'privacy-policy': {
fr: '/politique-de-confidentialite',
en: '/privacy-policy',
},
},
},
sitemap: {
autoI18n: false,
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/issue-561/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/issue-561/pages/privacy-policy.vue
================================================
Privacy Policy
================================================
FILE: test/fixtures/issue-561/pages/submit-art.vue
================================================
Submit Art
================================================
FILE: test/fixtures/issue-588/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://example.com',
},
compatibilityDate: '2024-07-22',
sitemap: {
autoI18n: false,
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/issue-588/pages/about.vue
================================================
About
================================================
FILE: test/fixtures/issue-588/pages/contact.vue
================================================
Contact
================================================
FILE: test/fixtures/issue-588/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/issue-592/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
'@nuxtjs/i18n',
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2024-07-22',
i18n: {
baseUrl: 'https://nuxtseo.com',
detectBrowserLanguage: false,
defaultLocale: 'en',
strategy: 'prefix_except_default',
locales: [
{
code: 'en',
iso: 'en-US',
},
{
code: 'de',
iso: 'de-DE',
},
],
},
sitemap: {
zeroRuntime: true,
autoLastmod: false,
credits: false,
},
})
================================================
FILE: test/fixtures/issue-592/pages/index.vue
================================================
Home
================================================
FILE: test/fixtures/multi-with-chunks/app.vue
================================================
Multi Sitemap Chunking Test
================================================
FILE: test/fixtures/multi-with-chunks/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
sitemap: {
autoLastmod: false,
credits: false,
debug: true,
defaultSitemapsChunkSize: 5,
sitemaps: {
pages: {
urls: Array.from({ length: 20 }, (_, i) => `/page/${i + 1}`),
excludeAppSources: true,
},
posts: {
sources: [
'/api/posts',
],
chunks: true,
chunkSize: 3,
},
products: {
sources: [
'/api/products',
],
chunks: 10, // use 10 as chunk size
},
},
},
})
================================================
FILE: test/fixtures/multi-with-chunks/server/api/posts.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
// Generate 12 posts to test chunking with chunkSize: 3 (should create 4 chunks)
return Array.from({ length: 12 }, (_, i) => ({
loc: `/posts/${i + 1}`,
lastmod: new Date(2024, 0, i + 1).toISOString(),
}))
})
================================================
FILE: test/fixtures/multi-with-chunks/server/api/products.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
// Generate 25 products to test chunking with chunkSize: 10 (should create 3 chunks)
return Array.from({ length: 25 }, (_, i) => ({
loc: `/products/${i + 1}`,
lastmod: new Date(2024, 1, i + 1).toISOString(),
}))
})
================================================
FILE: test/fixtures/no-pages/app.vue
================================================
hello world
================================================
FILE: test/fixtures/no-pages/nuxt.config.ts
================================================
import NuxtSitemap from '../../../src/module'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://nuxtseo.com',
},
compatibilityDate: '2025-03-14',
sitemap: {
sources: ['/__sitemap'],
autoLastmod: false,
credits: false,
debug: true,
},
})
================================================
FILE: test/fixtures/sources-hook/nuxt.config.ts
================================================
import { defineNuxtConfig } from 'nuxt/config'
import NuxtSitemap from '../../../src/module'
export default defineNuxtConfig({
modules: [
NuxtSitemap,
],
site: {
url: 'https://example.com',
},
nitro: {
plugins: ['~/server/plugins/sources-hook.ts'],
},
sitemap: {
sources: [
'/api/initial-source',
],
},
})
================================================
FILE: test/fixtures/sources-hook/pages/index.vue
================================================
Test fixture for sources hook
================================================
FILE: test/fixtures/sources-hook/server/api/dynamic-source.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler(() => {
return [
{ loc: '/dynamic-source-url' },
]
})
================================================
FILE: test/fixtures/sources-hook/server/api/initial-source.ts
================================================
import { defineEventHandler } from 'h3'
export default defineEventHandler((event) => {
const headers = event.node.req.headers
// Return different URLs based on whether headers were modified by hook
if (headers['x-hook-modified'] === 'true') {
return [
{ loc: '/hook-modified' },
]
}
return [
{ loc: '/initial-source-default' },
]
})
================================================
FILE: test/fixtures/sources-hook/server/plugins/sources-hook.ts
================================================
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:sources', async (ctx) => {
// Add a new source dynamically using simple string syntax
ctx.sources.push('/api/dynamic-source')
// Add a source to be filtered (also using simple string syntax)
ctx.sources.push('/api/skip-this')
// Modify existing sources to add headers
ctx.sources = ctx.sources.map((source) => {
if (typeof source === 'object' && 'fetch' in source && source.fetch === '/api/initial-source') {
// Modify fetch to add headers
source.fetch = ['/api/initial-source', { headers: { 'X-Hook-Modified': 'true' } }]
}
return source
})
// Filter out sources we don't want
ctx.sources = ctx.sources.filter((source) => {
if (typeof source === 'string')
return !source.includes('skip-this')
if (typeof source === 'object' && 'fetch' in source && source.fetch) {
const fetchUrl = Array.isArray(source.fetch) ? source.fetch[0] : source.fetch
return !fetchUrl.includes('skip-this')
}
return true
})
})
})
================================================
FILE: test/types/templates.test-d.ts
================================================
import type {
SitemapIndexRenderCtx,
SitemapInputCtx,
SitemapItemDefaults,
SitemapOutputHookCtx,
SitemapRenderCtx,
SitemapSourceBase,
SitemapSourceResolved,
SitemapSourcesHookCtx,
SitemapUrl,
} from '@nuxtjs/sitemap'
import type { NitroRouteConfig, NitroRouteRules, NitroRuntimeHooks, PrerenderRoute } from 'nitropack'
import type { NitroRouteConfig as NitroRouteConfigTypes, NitroRouteRules as NitroRouteRulesTypes, NitroRuntimeHooks as NitroRuntimeHooksTypes, PrerenderRoute as PrerenderRouteTypes } from 'nitropack/types'
import type { RouteMeta } from 'vue-router'
import type { PageMeta } from '#app'
import type { sources as childSources } from '#sitemap-virtual/child-sources.mjs'
import type { sources as globalSources } from '#sitemap-virtual/global-sources.mjs'
import type { readSourcesFromFilesystem } from '#sitemap-virtual/read-sources.mjs'
import { describe, expectTypeOf, it } from 'vitest'
// Tests the actual generated type augmentations from .nuxt/types/nuxt-sitemap-augments.d.ts
// and virtual module declarations from .nuxt/types/nuxt-sitemap-virtual.d.ts.
// Requires `nuxi prepare` to have been run so .nuxt/ exists.
describe('nitropack augmentations', () => {
it('PrerenderRoute._sitemap is SitemapUrl', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRouteRules.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRouteConfig.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRuntimeHooks has all sitemap hooks', () => {
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapIndexRenderCtx) => void | Promise>()
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapInputCtx) => void | Promise>()
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapRenderCtx) => void | Promise>()
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapOutputHookCtx) => void | Promise>()
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapSourcesHookCtx) => void | Promise>()
})
})
describe('nitropack/types augmentations', () => {
it('PrerenderRoute._sitemap is SitemapUrl', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRouteRules.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRouteConfig.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
it('NitroRuntimeHooks has sitemap hooks', () => {
expectTypeOf()
.toEqualTypeOf<(ctx: SitemapRenderCtx) => void | Promise>()
})
})
describe('vue-router augmentations', () => {
it('RouteMeta.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
})
describe('#app augmentations', () => {
it('PageMeta.sitemap is SitemapItemDefaults | false', () => {
expectTypeOf().toEqualTypeOf()
})
})
describe('#sitemap-virtual/read-sources.mjs', () => {
it('exports readSourcesFromFilesystem(filename: string) => Promise', () => {
expectTypeOf().toBeFunction()
expectTypeOf().parameter(0).toBeString()
expectTypeOf().returns.toEqualTypeOf>()
})
})
describe('#sitemap-virtual/global-sources.mjs', () => {
it('exports sources as (SitemapSourceBase | SitemapSourceResolved)[]', () => {
expectTypeOf().toEqualTypeOf<(SitemapSourceBase | SitemapSourceResolved)[]>()
})
})
describe('#sitemap-virtual/child-sources.mjs', () => {
it('exports sources as Record', () => {
expectTypeOf().toEqualTypeOf>()
})
})
================================================
FILE: test/types/tsconfig.json
================================================
{
"extends": "../../.nuxt/tsconfig.json",
"include": [
"../../.nuxt/nuxt.d.ts",
"./**/*.ts",
"./**/*.d.ts"
]
}
================================================
FILE: test/unit/i18n-disabled-routes.test.ts
================================================
import { expect, it } from 'vitest'
import { generatePathForI18nPages } from '../../src/utils-internal/i18n'
it('should handle string paths for generatePathForI18nPages', () => {
const result = generatePathForI18nPages({
localeCode: 'en',
pageLocales: '/about',
nuxtI18nConfig: {
locales: ['en', 'fr'],
defaultLocale: 'en',
strategy: 'no_prefix',
},
normalisedLocales: [
{ code: 'en', _hreflang: 'en-US', _sitemap: 'en' },
{ code: 'fr', _hreflang: 'fr-FR', _sitemap: 'fr' },
],
})
expect(result).toBe('/about')
})
it('handles false values in generatePathForI18nPages', () => {
// When false is passed, the function treats it as a path value
// The fix in the module prevents false from reaching this function
const result = generatePathForI18nPages({
localeCode: 'en',
pageLocales: false as any, // Intentionally passing wrong type
nuxtI18nConfig: {
locales: ['en', 'fr'],
defaultLocale: 'en',
strategy: 'no_prefix',
},
normalisedLocales: [
{ code: 'en', _hreflang: 'en-US', _sitemap: 'en' },
{ code: 'fr', _hreflang: 'fr-FR', _sitemap: 'fr' },
],
})
// It returns false value as-is for no_prefix strategy
expect(result).toBe(false)
})
================================================
FILE: test/unit/i18n-dynamic-routes.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { applyDynamicParams, findPageMapping } from '../../src/runtime/utils-pure'
describe('i18n dynamic routes', () => {
const pages = {
'about': { en: '/about', fr: '/a-propos' },
'posts': { en: '/posts/[slug]', fr: '/article/[slug]', es: '/articulo/[slug]' },
'products': { en: '/products/[category]/[id]', fr: '/produits/[category]/[id]' },
'blog/posts': { en: '/blog/posts/[slug]', fr: '/blog/articles/[slug]' },
}
describe('findPageMapping', () => {
it('exact match for static route', () => {
const result = findPageMapping('/about', pages)
expect(result).toEqual({ mappings: pages.about, paramSegments: [] })
})
it('prefix match for single param route', () => {
const result = findPageMapping('/posts/my-slug', pages)
expect(result).toEqual({ mappings: pages.posts, paramSegments: ['my-slug'] })
})
it('prefix match for multi param route', () => {
const result = findPageMapping('/products/electronics/laptop-123', pages)
expect(result).toEqual({ mappings: pages.products, paramSegments: ['electronics', 'laptop-123'] })
})
it('matches most specific key first', () => {
const result = findPageMapping('/blog/posts/hello', pages)
expect(result).toEqual({ mappings: pages['blog/posts'], paramSegments: ['hello'] })
})
it('returns null for no match', () => {
const result = findPageMapping('/unknown/path', pages)
expect(result).toBeNull()
})
it('handles path without leading slash', () => {
const result = findPageMapping('posts/test', pages)
expect(result).toEqual({ mappings: pages.posts, paramSegments: ['test'] })
})
})
describe('applyDynamicParams', () => {
it('replaces single param', () => {
expect(applyDynamicParams('/article/[slug]', ['my-post'])).toBe('/article/my-post')
})
it('replaces multiple params', () => {
expect(applyDynamicParams('/produits/[category]/[id]', ['tech', 'item-1'])).toBe('/produits/tech/item-1')
})
it('returns path unchanged when no params', () => {
expect(applyDynamicParams('/about', [])).toBe('/about')
})
it('handles missing params gracefully', () => {
expect(applyDynamicParams('/[a]/[b]/[c]', ['x', 'y'])).toBe('/x/y/')
})
})
})
================================================
FILE: test/unit/i18n.test.ts
================================================
import type { AutoI18nConfig } from '../../src/runtime/types'
import { describe, expect, it } from 'vitest'
import { normalizeLocales, splitPathForI18nLocales } from '../../src/utils-internal/i18n'
const EnFrAutoI18n = {
locales: normalizeLocales({ locales: [{
code: 'en',
iso: 'en-US',
}, {
code: 'fr',
iso: 'fr-FR',
}] }),
defaultLocale: 'en',
strategy: 'prefix_except_default',
} as AutoI18nConfig
describe('i18n', () => {
it('filtering prefix_except_default', async () => {
const data = splitPathForI18nLocales('/about', EnFrAutoI18n)
expect(data).toMatchInlineSnapshot(`
[
"/about",
"/fr/about",
]
`)
const data2 = splitPathForI18nLocales('/fr/about', EnFrAutoI18n)
expect(data2).toMatchInlineSnapshot('"/fr/about"')
})
it('filtering prefix_and_default', async () => {
const data = splitPathForI18nLocales('/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
expect(data).toMatchInlineSnapshot(`
[
"/about",
"/en/about",
"/fr/about",
]
`)
const data2 = splitPathForI18nLocales('/fr/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
expect(data2).toMatchInlineSnapshot('"/fr/about"')
const data3 = splitPathForI18nLocales('/en/about', { ...EnFrAutoI18n, strategy: 'prefix_and_default' })
expect(data3).toMatchInlineSnapshot('"/en/about"')
})
it('filtering prefix', async () => {
const data = splitPathForI18nLocales('/about', { ...EnFrAutoI18n, strategy: 'prefix' })
expect(data).toMatchInlineSnapshot(`
[
"/about",
"/en/about",
"/fr/about",
]
`)
const data2 = splitPathForI18nLocales('/fr/about', { ...EnFrAutoI18n, strategy: 'prefix' })
expect(data2).toMatchInlineSnapshot('"/fr/about"')
})
it('normalizes locales', () => {
const locales = [{
code: 'en',
iso: 'en-US',
}, {
code: 'fr',
iso: 'fr-FR',
}, {
code: 'es',
}, 'br', {
code: 'xx',
language: 'xx-XX',
}]
// @ts-expect-error untyped
const data = normalizeLocales({ locales })
expect(data).toMatchInlineSnapshot(`
[
{
"_hreflang": "en-US",
"_sitemap": "en-US",
"code": "en",
"iso": "en-US",
"language": "en-US",
},
{
"_hreflang": "fr-FR",
"_sitemap": "fr-FR",
"code": "fr",
"iso": "fr-FR",
"language": "fr-FR",
},
{
"_hreflang": "es",
"_sitemap": "es",
"code": "es",
},
{
"_hreflang": "br",
"_sitemap": "br",
"code": "br",
},
{
"_hreflang": "xx-XX",
"_sitemap": "xx-XX",
"code": "xx",
"language": "xx-XX",
},
]
`)
})
})
================================================
FILE: test/unit/lastmod.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { isValidW3CDate, normaliseDate } from '../../src/runtime/server/sitemap/urlset/normalise'
describe('lastmod', () => {
it('w3c validate', () => {
expect(isValidW3CDate('2023-12-21')).toBeTruthy()
expect(isValidW3CDate('2023-12-21T22:46:58Z')).toBeTruthy()
expect(isValidW3CDate('2023-12-21T22:46:58+00:00')).toBeTruthy()
expect(isValidW3CDate('2023-12-21T22:46:58.441+00:00')).toBeTruthy()
expect(isValidW3CDate('1994-11-05T13:15:30Z')).toBeTruthy()
expect(isValidW3CDate('1994-11-05T08:15:30-05:00')).toBeTruthy()
expect(isValidW3CDate('1994-11-05T08:15:30-05:00')).toBeTruthy()
})
it('date create', () => {
// time without timezone
expect(normaliseDate('2023-12-21T13:49:27.963745')).toMatchInlineSnapshot(`"2023-12-21T13:49:27Z"`)
})
})
================================================
FILE: test/unit/normalise.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
describe('normalise', () => {
it('query', async () => {
const normalisedWithoutSlash = preNormalizeEntry({ loc: '/query?foo=bar' })
expect(normalisedWithoutSlash).toMatchInlineSnapshot(`
{
"_abs": false,
"_key": "/query?foo=bar",
"_path": {
"hash": "",
"pathname": "/query",
"search": "?foo=bar",
},
"_relativeLoc": "/query?foo=bar",
"loc": "/query?foo=bar",
}
`)
const normalisedWithSlash = preNormalizeEntry({ loc: '/query/?foo=bar' })
expect(normalisedWithSlash).toMatchInlineSnapshot(`
{
"_abs": false,
"_key": "/query?foo=bar",
"_path": {
"hash": "",
"pathname": "/query",
"search": "?foo=bar",
},
"_relativeLoc": "/query?foo=bar",
"loc": "/query?foo=bar",
}
`)
})
it('encoding', () => {
const normalisedWithoutSlash = preNormalizeEntry({ loc: '/this/is a test' })
expect(normalisedWithoutSlash).toMatchInlineSnapshot(`
{
"_abs": false,
"_key": "/this/is%20a%20test",
"_path": {
"hash": "",
"pathname": "/this/is a test",
"search": "",
},
"_relativeLoc": "/this/is%20a%20test",
"loc": "/this/is%20a%20test",
}
`)
const withQuery = preNormalizeEntry({ loc: '/this/is a test?withAQuery=foo' })
expect(withQuery).toMatchInlineSnapshot(`
{
"_abs": false,
"_key": "/this/is%20a%20test?withAQuery=foo",
"_path": {
"hash": "",
"pathname": "/this/is a test",
"search": "?withAQuery=foo",
},
"_relativeLoc": "/this/is%20a%20test?withAQuery=foo",
"loc": "/this/is%20a%20test?withAQuery=foo",
}
`)
const withQueryWeird = preNormalizeEntry({ loc: '/this/is a test?with A some weirdformat=foo' })
expect(withQueryWeird).toMatchInlineSnapshot(`
{
"_abs": false,
"_key": "/this/is%20a%20test?with+A+some+weirdformat=foo",
"_path": {
"hash": "",
"pathname": "/this/is a test",
"search": "?with A some weirdformat=foo",
},
"_relativeLoc": "/this/is%20a%20test?with+A+some+weirdformat=foo",
"loc": "/this/is%20a%20test?with+A+some+weirdformat=foo",
}
`)
})
it('_encoded: true preserves pre-encoded URLs', () => {
// Test reserved characters - user pre-encoded with encodeURIComponent
const reservedChars = preNormalizeEntry({ loc: '/%24-%3A%29', _encoded: true })
expect(reservedChars.loc).toBe('/%24-%3A%29')
// Test pre-encoded emoji stays intact
const emoji = preNormalizeEntry({ loc: '/%F0%9F%98%85', _encoded: true })
expect(emoji.loc).toBe('/%F0%9F%98%85')
// Test unencoded URL stays as-is when _encoded: true (user's responsibility)
const unencoded = preNormalizeEntry({ loc: '/😅', _encoded: true })
expect(unencoded.loc).toBe('/😅')
})
it('default encoding behavior', () => {
// Emoji should be encoded
const emoji = preNormalizeEntry({ loc: '/😅' })
expect(emoji.loc).toBe('/%F0%9F%98%85')
// Space should be encoded
const space = preNormalizeEntry({ loc: '/hello world' })
expect(space.loc).toBe('/hello%20world')
// Reserved chars like $ and : are NOT encoded by encodePath (per RFC-3986)
const reserved = preNormalizeEntry({ loc: '/$-:)' })
expect(reserved.loc).toBe('/$-:)')
})
})
================================================
FILE: test/unit/parseHtmlExtractSitemapMeta.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { parseHtmlExtractSitemapMeta } from '../../src/utils/parseHtmlExtractSitemapMeta'
describe('parseHtmlExtractSitemapMeta', () => {
it('lastmod', async () => {
// test article meta
const output = parseHtmlExtractSitemapMeta(`
`)
expect(output).toMatchInlineSnapshot(`
{
"lastmod": "2021-04-02T00:00:00Z",
}
`)
// test article meta
const output2 = parseHtmlExtractSitemapMeta(`
`)
expect(output2).toMatchInlineSnapshot(`
{
"lastmod": "2021-04-02T00:00:00Z",
}
`)
})
it('extracts images from HTML', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableImageHTML = `
`
const excludeImageDataHTML = `
`
const excludeImageBlobHTML = `
`
const excludeImageFileHTML = `
`
// Test case 1 - Single discoverable image
const html1 = `${mainTag}${discoverableImageHTML}${mainClosingTag}`
const testcase1 = parseHtmlExtractSitemapMeta(html1)
expect(testcase1).toMatchInlineSnapshot(`
{
"images": [
{
"loc": "https://res.cloudinary.com/dl6o1xpyq/image/upload/f_jpg,q_auto:best,dpr_auto,w_240,h_240/images/harlan-wilton",
},
],
}
`)
// Test case 2 - Single discoverable image with excluded image values
const html2 = `${mainTag}${discoverableImageHTML}${excludeImageDataHTML}${excludeImageBlobHTML}${excludeImageFileHTML}${mainClosingTag}`
const testcase2 = parseHtmlExtractSitemapMeta(html2)
expect(testcase2).toMatchInlineSnapshot(`
{
"images": [
{
"loc": "https://res.cloudinary.com/dl6o1xpyq/image/upload/f_jpg,q_auto:best,dpr_auto,w_240,h_240/images/harlan-wilton",
},
],
}
`)
const html3 = `
`
const testcase3 = parseHtmlExtractSitemapMeta(html3)
expect(testcase3).toMatchInlineSnapshot(`
{
"images": [
{
"loc": "/_ipx/_/logo.svg",
},
],
}
`)
})
it('video: ignores invalid markup', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableVideoSrcHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
// Test case 1 - Single discoverable video src element
const html1 = `${mainTag}${discoverableVideoSrcHTML}${mainClosingTag}`
const testcase1 = parseHtmlExtractSitemapMeta(html1)
expect(testcase1).toMatchInlineSnapshot(`{}`)
})
it('video: simple valid markup', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableVideoWithPosterSrcHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
// Test case 2 - Single discoverable video src element with poster
const html2 = `${mainTag}${discoverableVideoWithPosterSrcHTML}${mainClosingTag}`
const testcase2 = parseHtmlExtractSitemapMeta(html2)
expect(testcase2).toMatchInlineSnapshot(`
{
"videos": [
{
"content_loc": "https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4",
"description": "Big Buck Bunny in DivX 720p.",
"thumbnail_loc": "https://archive.org/download/BigBuckBunny_124/__ia_thumb.jpg",
"title": "Big Buck Bunny",
},
],
}
`)
})
it('extracts videos from HTML #3', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableVideoSourcesHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
// Test case 3 - Multiple discoverable video sources
const html3 = `${mainTag}${discoverableVideoSourcesHTML}${mainClosingTag}`
const testcase3 = parseHtmlExtractSitemapMeta(html3)
expect(testcase3).toMatchInlineSnapshot(`{}`)
})
it('extracts videos from HTML #4', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableVideoSourcesWithPosterHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
// Test case 4 - Multiple discoverable video sources
const html4 = `${mainTag}${discoverableVideoSourcesWithPosterHTML}${mainClosingTag}`
const testcase4 = parseHtmlExtractSitemapMeta(html4)
expect(testcase4).toMatchInlineSnapshot(`
{
"videos": [
{
"content_loc": "https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda_512kb.mp4",
"description": "This film, a combination of animated cartoon and live action, shows young children what to do in case of an atomic attack.",
"thumbnail_loc": "https://archive.org/download/DuckAndCover_185/__ia_thumb.jpg",
"title": "Duck and Cover",
},
{
"content_loc": "https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda.avi",
"description": "This film, a combination of animated cartoon and live action, shows young children what to do in case of an atomic attack.",
"thumbnail_loc": "https://archive.org/download/DuckAndCover_185/__ia_thumb.jpg",
"title": "Duck and Cover",
},
],
}
`)
})
it('extracts videos from HTML #5', async () => {
const mainTag = ''
const mainClosingTag = ' '
const discoverableVideoWithPosterSrcHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
const discoverableVideoSourcesWithPosterHTML = `
Sorry, your browser doesn't support embedded videos, but don't worry, you
can
download it
and watch it with your favorite video player!
`
// Test case 4 - Mixture of single video src and multiple discoverable video sources
const html5 = `${mainTag}${discoverableVideoWithPosterSrcHTML}${discoverableVideoSourcesWithPosterHTML}${mainClosingTag}`
const testcase5 = parseHtmlExtractSitemapMeta(html5)
expect(testcase5).toMatchInlineSnapshot(`
{
"videos": [
{
"content_loc": "https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4",
"description": "Big Buck Bunny in DivX 720p.",
"thumbnail_loc": "https://archive.org/download/BigBuckBunny_124/__ia_thumb.jpg",
"title": "Big Buck Bunny",
},
{
"content_loc": "https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda_512kb.mp4",
"description": "This film, a combination of animated cartoon and live action, shows young children what to do in case of an atomic attack.",
"thumbnail_loc": "https://archive.org/download/DuckAndCover_185/__ia_thumb.jpg",
"title": "Duck and Cover",
},
{
"content_loc": "https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda.avi",
"description": "This film, a combination of animated cartoon and live action, shows young children what to do in case of an atomic attack.",
"thumbnail_loc": "https://archive.org/download/DuckAndCover_185/__ia_thumb.jpg",
"title": "Duck and Cover",
},
],
}
`)
})
it('extracts relative poster as absolute', async () => {
const testcase5 = parseHtmlExtractSitemapMeta(`
`, {
videos: true,
resolveUrl(s) {
return s.startsWith('/') ? `https://example.com${s}` : s
},
})
expect(testcase5).toMatchInlineSnapshot(`
{
"videos": [
{
"content_loc": "https://archive.org/download/DuckAndCover_185/CivilDefenseFilm-DuckAndCoverColdWarNuclearPropaganda_512kb.mp4",
"description": "Big Buck Bunny in DivX 720p.",
"thumbnail_loc": "https://example.com/poster.jpg",
"title": "Big Buck Bunny",
},
],
}
`)
})
it('blocks pages with noindex meta tag', async () => {
const noindex = parseHtmlExtractSitemapMeta(`
`)
expect(noindex).toBe(null)
const noindexFollow = parseHtmlExtractSitemapMeta(`
`)
expect(noindexFollow).toBe(null)
const none = parseHtmlExtractSitemapMeta(`
`)
expect(none).toBe(null)
})
it('extracts alternatives from hreflang links', () => {
const output = parseHtmlExtractSitemapMeta(`
`, { alternatives: true })
expect(output?.alternatives).toEqual([
{ hreflang: 'de-DE', href: '/about' },
{ hreflang: 'fr-FR', href: '/about' },
])
})
it('skips alternatives when alternatives option is false', () => {
const output = parseHtmlExtractSitemapMeta(`
`, { alternatives: false })
expect(output?.alternatives).toBeUndefined()
})
})
================================================
FILE: test/unit/parsePages.test.ts
================================================
import type { NuxtPage } from 'nuxt/schema'
import { describe, expect, it } from 'vitest'
import { normalizeLocales } from '../../src/utils-internal/i18n'
import { convertNuxtPagesToSitemapEntries } from '../../src/utils-internal/nuxtSitemap'
const payload: NuxtPage[] = [
{
name: 'index___en',
path: '/',
file: 'playground/pages/index.vue',
children: [],
},
{
name: 'index___fr',
path: '/fr',
file: 'playground/pages/index.vue',
children: [],
},
{
name: 'custom',
path: '/custom',
file: 'playground/pages/[...slug].vue',
meta: { sitemap: { lastmod: '2021-08-24T14:00:00.000Z' } },
children: [],
},
{
name: 'slug___en',
path: '/:slug(.*)*',
file: 'playground/pages/[...slug].vue',
children: [],
},
{
name: 'slug___fr',
path: '/fr/:slug(.*)*',
file: 'playground/pages/[...slug].vue',
children: [],
},
{
name: 'about___en',
path: '/about',
file: 'playground/pages/about.vue',
children: [],
},
{
name: 'about___fr',
path: '/fr/a-propos',
file: 'playground/pages/about.vue',
children: [],
},
{
path: '/blog',
file: 'playground/pages/blog.vue',
children: [{
name: 'blog-id___en',
path: ':id()',
file: 'playground/pages/blog/[id].vue',
children: [],
}, {
name: 'blog-categories___en',
path: 'categories',
file: 'playground/pages/blog/categories.vue',
children: [],
}, {
name: 'blog___en',
path: '',
file: 'playground/pages/blog/index.vue',
children: [],
}, {
name: 'blog-tags___en',
path: 'tags',
file: 'playground/pages/blog/tags.vue',
children: [{
name: 'blog-tags-edit___en',
path: 'edit',
file: 'playground/pages/blog/tags/edit.vue',
children: [],
}, {
name: 'blog-tags-new___en',
path: 'new',
file: 'playground/pages/blog/tags/new.vue',
children: [],
}],
}],
},
{
path: '/fr/blog',
file: 'playground/pages/blog.vue',
children: [{
name: 'blog-id___fr',
path: ':id()',
file: 'playground/pages/blog/[id].vue',
children: [],
}, {
name: 'blog-categories___fr',
path: 'categories',
file: 'playground/pages/blog/categories.vue',
children: [],
}, {
name: 'blog___fr',
path: '',
file: 'playground/pages/blog/index.vue',
children: [],
}, {
name: 'blog-tags___fr',
path: 'tags',
file: 'playground/pages/blog/tags.vue',
children: [{
name: 'blog-tags-edit___fr',
path: 'edit',
file: 'playground/pages/blog/tags/edit.vue',
children: [],
}, {
name: 'blog-tags-new___fr',
path: 'new',
file: 'playground/pages/blog/tags/new.vue',
children: [],
}],
}],
},
{
name: 'hidden-path-but-in-sitemap___en',
path: '/hidden-path-but-in-sitemap',
file: 'playground/pages/hidden-path-but-in-sitemap/index.vue',
children: [],
},
{
name: 'hidden-path-but-in-sitemap___fr',
path: '/fr/hidden-path-but-in-sitemap',
file: 'playground/pages/hidden-path-but-in-sitemap/index.vue',
children: [],
},
{
name: 'index___en',
path: '/',
file: 'playground/pages/index.vue',
children: [],
},
{
name: 'index___fr',
path: '/fr',
file: 'playground/pages/index.vue',
children: [],
},
{
name: 'new-page___en',
path: '/new-page',
file: 'playground/pages/new-page.vue',
children: [],
},
{
name: 'new-page___fr',
path: '/fr/new-page',
file: 'playground/pages/new-page.vue',
children: [],
},
{
name: 'secret___en',
path: '/secret',
file: 'playground/pages/secret.vue',
children: [],
},
{
name: 'secret___fr',
path: '/fr/secret',
file: 'playground/pages/secret.vue',
children: [],
},
{
name: 'users-group-id___en',
path: '/users-:group()/:id()',
file: 'playground/pages/users-[group]/[id].vue',
children: [],
},
{
name: 'users-group-id___fr',
path: '/fr/users-:group()/:id()',
file: 'playground/pages/users-[group]/[id].vue',
children: [],
},
{
name: 'users-group___en',
path: '/users-:group()',
file: 'playground/pages/users-[group]/index.vue',
children: [],
},
{
name: 'users-group___fr',
path: '/fr/users-:group()',
file: 'playground/pages/users-[group]/index.vue',
children: [],
},
]
describe('page parser', () => {
it('is parsed', () => {
expect(convertNuxtPagesToSitemapEntries(payload, {
filter: {
include: [],
exclude: [],
},
isI18nMapped: true,
autoLastmod: false,
defaultLocale: 'en',
normalisedLocales: normalizeLocales({ locales: [{ code: 'en' }, { code: 'fr' }] }),
strategy: 'no_prefix',
isI18nMicro: false,
autoI18n: true,
})).toMatchInlineSnapshot(`
[
{
"_sitemap": "en",
"alternatives": [
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "x-default",
},
],
"loc": "/",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "x-default",
},
],
"loc": "/fr",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "x-default",
},
],
"loc": "/",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "en",
},
{
"href": "/fr",
"hreflang": "fr",
},
{
"href": "/",
"hreflang": "x-default",
},
],
"loc": "/fr",
},
{
"_sitemap": "en",
"lastmod": "2021-08-24T14:00:00.000Z",
"loc": "/custom",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/about",
"hreflang": "en",
},
{
"href": "/fr/a-propos",
"hreflang": "fr",
},
{
"href": "/about",
"hreflang": "x-default",
},
],
"loc": "/about",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/about",
"hreflang": "en",
},
{
"href": "/fr/a-propos",
"hreflang": "fr",
},
{
"href": "/about",
"hreflang": "x-default",
},
],
"loc": "/fr/a-propos",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/blog/categories",
"hreflang": "en",
},
{
"href": "/fr/blog/categories",
"hreflang": "fr",
},
{
"href": "/blog/categories",
"hreflang": "x-default",
},
],
"loc": "/blog/categories",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/blog/categories",
"hreflang": "en",
},
{
"href": "/fr/blog/categories",
"hreflang": "fr",
},
{
"href": "/blog/categories",
"hreflang": "x-default",
},
],
"loc": "/fr/blog/categories",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/blog",
"hreflang": "en",
},
{
"href": "/fr/blog",
"hreflang": "fr",
},
{
"href": "/blog",
"hreflang": "x-default",
},
],
"loc": "/blog",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/blog",
"hreflang": "en",
},
{
"href": "/fr/blog",
"hreflang": "fr",
},
{
"href": "/blog",
"hreflang": "x-default",
},
],
"loc": "/fr/blog",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/blog/tags",
"hreflang": "en",
},
{
"href": "/fr/blog/tags",
"hreflang": "fr",
},
{
"href": "/blog/tags",
"hreflang": "x-default",
},
],
"loc": "/blog/tags",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/blog/tags",
"hreflang": "en",
},
{
"href": "/fr/blog/tags",
"hreflang": "fr",
},
{
"href": "/blog/tags",
"hreflang": "x-default",
},
],
"loc": "/fr/blog/tags",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/blog/tags/edit",
"hreflang": "en",
},
{
"href": "/fr/blog/tags/edit",
"hreflang": "fr",
},
{
"href": "/blog/tags/edit",
"hreflang": "x-default",
},
],
"loc": "/blog/tags/edit",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/blog/tags/edit",
"hreflang": "en",
},
{
"href": "/fr/blog/tags/edit",
"hreflang": "fr",
},
{
"href": "/blog/tags/edit",
"hreflang": "x-default",
},
],
"loc": "/fr/blog/tags/edit",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/blog/tags/new",
"hreflang": "en",
},
{
"href": "/fr/blog/tags/new",
"hreflang": "fr",
},
{
"href": "/blog/tags/new",
"hreflang": "x-default",
},
],
"loc": "/blog/tags/new",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/blog/tags/new",
"hreflang": "en",
},
{
"href": "/fr/blog/tags/new",
"hreflang": "fr",
},
{
"href": "/blog/tags/new",
"hreflang": "x-default",
},
],
"loc": "/fr/blog/tags/new",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/hidden-path-but-in-sitemap",
"hreflang": "en",
},
{
"href": "/fr/hidden-path-but-in-sitemap",
"hreflang": "fr",
},
{
"href": "/hidden-path-but-in-sitemap",
"hreflang": "x-default",
},
],
"loc": "/hidden-path-but-in-sitemap",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/hidden-path-but-in-sitemap",
"hreflang": "en",
},
{
"href": "/fr/hidden-path-but-in-sitemap",
"hreflang": "fr",
},
{
"href": "/hidden-path-but-in-sitemap",
"hreflang": "x-default",
},
],
"loc": "/fr/hidden-path-but-in-sitemap",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/new-page",
"hreflang": "en",
},
{
"href": "/fr/new-page",
"hreflang": "fr",
},
{
"href": "/new-page",
"hreflang": "x-default",
},
],
"loc": "/new-page",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/new-page",
"hreflang": "en",
},
{
"href": "/fr/new-page",
"hreflang": "fr",
},
{
"href": "/new-page",
"hreflang": "x-default",
},
],
"loc": "/fr/new-page",
},
{
"_sitemap": "en",
"alternatives": [
{
"href": "/secret",
"hreflang": "en",
},
{
"href": "/fr/secret",
"hreflang": "fr",
},
{
"href": "/secret",
"hreflang": "x-default",
},
],
"loc": "/secret",
},
{
"_sitemap": "fr",
"alternatives": [
{
"href": "/secret",
"hreflang": "en",
},
{
"href": "/fr/secret",
"hreflang": "fr",
},
{
"href": "/secret",
"hreflang": "x-default",
},
],
"loc": "/fr/secret",
},
]
`)
})
it ('i18n micro', () => {
expect(convertNuxtPagesToSitemapEntries([
{
name: 'index',
path: '/:locale(de|ja)/page',
file: 'playground/pages/index.vue',
children: [],
},
], {
filter: {
include: [],
exclude: [],
},
isI18nMapped: true,
autoLastmod: false,
defaultLocale: 'en',
normalisedLocales: normalizeLocales({ locales: [
{ code: 'en', iso: 'en_EN' },
{ code: 'de', iso: 'de_DE' },
{ code: 'ru', iso: 'ru_RU' },
] }),
strategy: 'prefix_except_default',
isI18nMicro: true,
autoI18n: true,
})).toMatchInlineSnapshot(`
[
{
"_sitemap": "de_DE",
"alternatives": [
{
"href": "/de/page",
"hreflang": "de_DE",
},
{
"href": "/ja/page",
"hreflang": undefined,
},
],
"loc": "/de/page",
},
{
"_sitemap": "index",
"alternatives": [
{
"href": "/de/page",
"hreflang": "de_DE",
},
{
"href": "/ja/page",
"hreflang": undefined,
},
],
"loc": "/ja/page",
},
]
`)
})
it('autoI18n false disables hreflang alternatives', () => {
const result = convertNuxtPagesToSitemapEntries(payload, {
filter: {
include: [],
exclude: [],
},
isI18nMapped: false,
autoLastmod: false,
defaultLocale: 'en',
normalisedLocales: normalizeLocales({ locales: [{ code: 'en' }, { code: 'fr' }] }),
strategy: 'no_prefix',
isI18nMicro: false,
autoI18n: false,
})
// no entry should have alternatives when autoI18n is false
for (const entry of result) {
if (typeof entry === 'string')
continue
expect(entry).not.toHaveProperty('alternatives')
}
})
})
================================================
FILE: test/unit/parseSitemapXml.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { parseSitemapXml } from '../../src/utils/parseSitemapXml'
describe('parseSitemapXml', () => {
it('should extract loc, lastmod, changefreq, priority, images, videos, alternatives, and news from XML', async () => {
const xml = `
http://example.com/
2023-01-01
daily
0.8
http://example.com/image1.jpg
Example Video
http://example.com/thumbnail.jpg
Example Description
http://example.com/video1.mp4
600
Example News
2023-01-01
Example Publication
en
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"alternatives": [
{
"href": "http://example.com/en",
"hreflang": "en",
},
],
"changefreq": "daily",
"images": [
{
"loc": "http://example.com/image1.jpg",
},
],
"lastmod": "2023-01-01",
"loc": "http://example.com/",
"news": {
"publication": {
"language": "en",
"name": "Example Publication",
},
"publication_date": "2023-01-01",
"title": "Example News",
},
"priority": 0.8,
"videos": [
{
"content_loc": "http://example.com/video1.mp4",
"description": "Example Description",
"duration": 600,
"thumbnail_loc": "http://example.com/thumbnail.jpg",
"title": "Example Video",
},
],
},
]
`)
})
it('should handle missing optional fields', async () => {
const xml = `
http://example.com/
2023-01-01
daily
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"changefreq": "daily",
"lastmod": "2023-01-01",
"loc": "http://example.com/",
},
]
`)
})
it('should handle multiple images and videos', async () => {
const xml = `
http://example.com/
http://example.com/image1.jpg
http://example.com/image2.jpg
Example Video 1
http://example.com/thumbnail1.jpg
Example Description 1
http://example.com/video1.mp4
Example Video 2
http://example.com/thumbnail2.jpg
Example Description 2
http://example.com/video2.mp4
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"images": [
{
"loc": "http://example.com/image1.jpg",
},
{
"loc": "http://example.com/image2.jpg",
},
],
"loc": "http://example.com/",
"videos": [
{
"content_loc": "http://example.com/video1.mp4",
"description": "Example Description 1",
"thumbnail_loc": "http://example.com/thumbnail1.jpg",
"title": "Example Video 1",
},
{
"content_loc": "http://example.com/video2.mp4",
"description": "Example Description 2",
"thumbnail_loc": "http://example.com/thumbnail2.jpg",
"title": "Example Video 2",
},
],
},
]
`)
})
it('should handle missing loc, lastmod, and changefreq', async () => {
const xml = `
http://example.com/image1.jpg
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`[]`)
})
it('should throw error if no URLs are found', async () => {
const xml = ' '
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should handle malformed XML', async () => {
const xml = `
http://example.com/
2023-01-01
daily
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`[]`)
})
it('should handle XML with unexpected tags', async () => {
const xml = `
http://example.com/
unexpectedValue
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"loc": "http://example.com/",
},
]
`)
})
describe('malformed XML and edge cases', () => {
it('should throw error for completely invalid XML', async () => {
const xml = 'not xml at all'
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should throw error for XML with invalid structure', async () => {
const xml = ''
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should throw error for XML without urlset', async () => {
const xml = 'content '
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should throw error for empty XML', async () => {
const xml = ''
await expect(() => parseSitemapXml(xml)).rejects.toThrow('Empty XML input provided')
})
it('should handle XML with unclosed tags', async () => {
const xml = `
http://example.com/
2023-01-01
`
const result = await parseSitemapXml(xml)
expect(result.urls).toEqual([])
})
it('should handle XML with CDATA sections', async () => {
const xml = `
2023-01-01
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"lastmod": "2023-01-01",
"loc": "http://example.com/special&chars",
},
]
`)
})
it('should handle XML with HTML entities', async () => {
const xml = `
http://example.com/path?param1=value¶m2=value
2023-01-01
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"lastmod": "2023-01-01",
"loc": "http://example.com/path?param1=value¶m2=value",
},
]
`)
})
})
describe('xML namespace handling', () => {
it('should handle mixed namespace prefixes', async () => {
const xml = `
http://example.com/
http://example.com/image.jpg
Test Video
http://example.com/thumb.jpg
Test Description
http://example.com/video.mp4
`
const result = await parseSitemapXml(xml)
expect(result.urls[0]).toMatchObject({
loc: 'http://example.com/',
images: [{ loc: 'http://example.com/image.jpg' }],
videos: [{
title: 'Test Video',
thumbnail_loc: 'http://example.com/thumb.jpg',
description: 'Test Description',
content_loc: 'http://example.com/video.mp4',
}],
})
})
it('should handle different namespace prefixes', async () => {
const xml = `
http://example.com/
http://example.com/image.jpg
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"images": [
{
"loc": "http://example.com/image.jpg",
},
],
"loc": "http://example.com/",
},
]
`)
})
})
describe('complex video attributes and nested elements', () => {
it('should handle video with all optional attributes', async () => {
const xml = `
http://example.com/
Complete Video
http://example.com/thumb.jpg
Complete description
http://example.com/video.mp4
http://example.com/player
3600
2024-12-31
4.5
10000
2023-01-01
yes
no
no
action
adventure
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].videos[0]).toMatchObject({
title: 'Complete Video',
thumbnail_loc: 'http://example.com/thumb.jpg',
description: 'Complete description',
content_loc: 'http://example.com/video.mp4',
player_loc: 'http://example.com/player',
duration: 3600,
expiration_date: '2024-12-31',
rating: 4.5,
view_count: 10000,
publication_date: '2023-01-01',
family_friendly: 'yes',
requires_subscription: 'no',
live: 'no',
tag: ['action', 'adventure'],
})
})
it('should handle video with restrictions and platform attributes', async () => {
const xml = `
http://example.com/
Restricted Video
http://example.com/thumb.jpg
Restricted content
http://example.com/video.mp4
allow
US CA
deny
mobile
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].videos[0]).toMatchObject({
title: 'Restricted Video',
restriction: {
relationship: 'allow',
restriction: 'US CA',
},
platform: {
relationship: 'deny',
platform: 'mobile',
},
})
})
it('should handle video with price information', async () => {
const xml = `
http://example.com/
Paid Video
http://example.com/thumb.jpg
Premium content
http://example.com/video.mp4
USD
rent
3.99
USD
purchase
9.99
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].videos[0].price).toEqual([
{ price: '3.99', currency: 'USD', type: 'rent' },
{ price: '9.99', currency: 'USD', type: 'purchase' },
])
})
it('should handle video with uploader information', async () => {
const xml = `
http://example.com/
User Video
http://example.com/thumb.jpg
User generated content
http://example.com/video.mp4
http://example.com/user
John Doe
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].videos[0].uploader).toEqual({
uploader: 'John Doe',
info: 'http://example.com/user',
})
})
it('should filter out invalid videos missing required fields', async () => {
const xml = `
http://example.com/
Incomplete Video 1
Complete Video
http://example.com/thumb.jpg
Complete description
http://example.com/video.mp4
http://example.com/thumb2.jpg
Missing title
http://example.com/video2.mp4
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].videos).toHaveLength(1)
expect(result.urls[0].videos[0].title).toBe('Complete Video')
})
})
describe('empty and null values', () => {
it('should handle empty values gracefully', async () => {
const xml = `
http://example.com/
`
const result = await parseSitemapXml(xml)
expect(result.urls).toMatchInlineSnapshot(`
[
{
"loc": "http://example.com/",
},
]
`)
})
it('should handle whitespace-only values', async () => {
const xml = `
http://example.com/
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].loc).toBe('http://example.com/') // trimValues is true so whitespace is trimmed
expect(result.urls[0]).not.toHaveProperty('lastmod')
expect(result.urls[0]).not.toHaveProperty('changefreq')
})
it('should handle multiple URLs with mixed validity', async () => {
const xml = `
http://example.com/1
2023-01-01
2023-01-02
http://example.com/3
0.5
2023-01-04
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(2)
expect(result.urls[0].loc).toBe('http://example.com/1')
expect(result.urls[1].loc).toBe('http://example.com/3')
})
it('should handle special characters in URLs', async () => {
const xml = `
http://example.com/path with spaces
2023-01-01
http://example.com/中文路径
2023-01-02
http://example.com/émoji-🚀-path
2023-01-03
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(3)
expect(result.urls[0].loc).toBe('http://example.com/path with spaces')
expect(result.urls[1].loc).toBe('http://example.com/中文路径')
expect(result.urls[2].loc).toBe('http://example.com/émoji-🚀-path')
})
it('should handle images without loc', async () => {
const xml = `
http://example.com/
http://example.com/valid.jpg
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].images).toHaveLength(1)
expect(result.urls[0].images[0].loc).toBe('http://example.com/valid.jpg')
})
it('should handle news with missing required fields', async () => {
const xml = `
http://example.com/
Valid News
2023-01-01
Example News
en
http://example.com/2
Incomplete News
`
const result = await parseSitemapXml(xml)
expect(result.urls[0]).toHaveProperty('news')
expect(result.urls[1]).not.toHaveProperty('news')
})
})
describe('priority and changefreq edge cases', () => {
it('should handle various priority formats', async () => {
const xml = `
http://example.com/1
0.0
http://example.com/2
1.0
http://example.com/3
0.5
http://example.com/4
invalid
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].priority).toBe(0.0)
expect(result.urls[1].priority).toBe(1.0)
expect(result.urls[2].priority).toBe(0.5)
expect(result.urls[3]).not.toHaveProperty('priority') // Invalid priority is filtered out
})
it('should handle all valid changefreq values', async () => {
const frequencies = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
const xml = `
${frequencies.map((freq, i) => `
http://example.com/${i}
${freq}
`).join('')}
`
const result = await parseSitemapXml(xml)
frequencies.forEach((freq, i) => {
expect(result.urls[i].changefreq).toBe(freq)
})
})
})
describe('alternatives handling', () => {
it('should handle multiple alternatives with different hreflang values', async () => {
const xml = `
http://example.com/
alternate
en
http://example.com/en
alternate
fr
http://example.com/fr
alternate
es
http://example.com/es
alternate
x-default
http://example.com/
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].alternatives).toHaveLength(4)
expect(result.urls[0].alternatives).toEqual([
{ hreflang: 'en', href: 'http://example.com/en' },
{ hreflang: 'fr', href: 'http://example.com/fr' },
{ hreflang: 'es', href: 'http://example.com/es' },
{ hreflang: 'x-default', href: 'http://example.com/' },
])
})
it('should filter out invalid alternative links', async () => {
const xml = `
http://example.com/
`
const result = await parseSitemapXml(xml)
expect(result.urls[0].alternatives).toHaveLength(1)
expect(result.urls[0].alternatives[0]).toEqual({ hreflang: 'en', href: 'http://example.com/en' })
})
})
describe('parseSitemapXml with warnings', () => {
it('should throw error for invalid XML', async () => {
const xml = 'not xml at all'
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should throw error for empty XML', async () => {
const xml = ''
await expect(() => parseSitemapXml(xml)).rejects.toThrow('Empty XML input provided')
})
it('should throw error for XML without urlset', async () => {
const xml = 'content '
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should throw error for sitemap with no URL entries', async () => {
const xml = ' '
await expect(() => parseSitemapXml(xml)).rejects.toThrow('XML does not contain a valid urlset element')
})
it('should return warnings for URLs missing loc', async () => {
const xml = `
2023-01-01
`
const result = await parseSitemapXml(xml)
expect(result.urls).toEqual([])
expect(result.warnings).toHaveLength(2)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toBe('URL entry missing required loc element')
expect(result.warnings[1].message).toBe('No valid URLs found in sitemap after validation')
})
it('should return warnings for invalid changefreq values', async () => {
const xml = `
http://example.com/
invalid
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toBe('Invalid changefreq value')
expect(result.warnings[0].context?.field).toBe('changefreq')
})
it('should return warnings for out-of-range priority values', async () => {
const xml = `
http://example.com/
1.5
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.urls[0].priority).toBe(1.0) // clamped
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toBe('Priority value should be between 0.0 and 1.0, clamping to valid range')
})
it('should return warnings for videos missing required fields', async () => {
const xml = `
http://example.com/
Incomplete Video
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.urls[0]).not.toHaveProperty('videos')
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toContain('Video missing required fields')
})
it('should return warnings for invalid video rating values', async () => {
const xml = `
http://example.com/
Test Video
http://example.com/thumb.jpg
Test Description
http://example.com/video.mp4
10
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toBe('Video rating should be between 0.0 and 5.0')
})
it('should return warnings for invalid video family_friendly values', async () => {
const xml = `
http://example.com/
Test Video
http://example.com/thumb.jpg
Test Description
http://example.com/video.mp4
maybe
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toBe('Invalid video family_friendly value, should be "yes" or "no"')
})
it('should return warnings for news entries missing required fields', async () => {
const xml = `
http://example.com/
Incomplete News
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(1)
expect(result.urls[0]).not.toHaveProperty('news')
expect(result.warnings).toHaveLength(1)
expect(result.warnings[0].type).toBe('validation')
expect(result.warnings[0].message).toContain('News entry missing required fields')
})
it('should collect multiple warnings for different issues', async () => {
const xml = `
http://example.com/1
invalid
2.0
2023-01-01
http://example.com/3
Incomplete Video
`
const result = await parseSitemapXml(xml)
expect(result.urls).toHaveLength(2) // Only valid URLs
expect(result.warnings.length).toBeGreaterThan(3) // Multiple warnings
const warningTypes = result.warnings.map(w => w.type)
expect(warningTypes).toContain('validation')
})
})
})
================================================
FILE: test/unit/sitemapIndex.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { isSitemapIndex, parseSitemapIndex } from '../../src/utils'
describe('isSitemapIndex', () => {
it('detects sitemap index with opening tag', () => {
expect(isSitemapIndex('')).toBe(true)
})
it('detects sitemap index with namespace', () => {
expect(isSitemapIndex('')).toBe(true)
})
it('detects sitemap index with closing tag only', () => {
expect(isSitemapIndex(' ')).toBe(true)
})
it('returns false for urlset sitemap', () => {
expect(isSitemapIndex('https://example.com ')).toBe(false)
})
it('returns false for empty string', () => {
expect(isSitemapIndex('')).toBe(false)
})
})
describe('parseSitemapIndex', () => {
it('parses basic sitemap index', async () => {
const xml = `
https://example.com/sitemap-1.xml
https://example.com/sitemap-2.xml
`
const { entries, warnings } = await parseSitemapIndex(xml)
expect(entries).toEqual([
{ loc: 'https://example.com/sitemap-1.xml' },
{ loc: 'https://example.com/sitemap-2.xml' },
])
expect(warnings).toEqual([])
})
it('parses sitemap index with lastmod', async () => {
const xml = `
https://example.com/sitemap-1.xml
2024-01-15
`
const { entries, warnings } = await parseSitemapIndex(xml)
expect(entries).toEqual([
{ loc: 'https://example.com/sitemap-1.xml', lastmod: '2024-01-15' },
])
expect(warnings).toEqual([])
})
it('handles single sitemap entry', async () => {
const xml = `
https://example.com/sitemap.xml
`
const { entries } = await parseSitemapIndex(xml)
expect(entries).toHaveLength(1)
expect(entries[0].loc).toBe('https://example.com/sitemap.xml')
})
it('returns empty array for empty sitemapindex', async () => {
const xml = `
`
const { entries, warnings } = await parseSitemapIndex(xml)
expect(entries).toEqual([])
expect(warnings).toEqual([])
})
it('warns on entries without loc', async () => {
const xml = `
2024-01-15
https://example.com/valid.xml
`
const { entries, warnings } = await parseSitemapIndex(xml)
expect(entries).toEqual([
{ loc: 'https://example.com/valid.xml' },
])
expect(warnings).toHaveLength(1)
expect(warnings[0].message).toBe('Sitemap entry missing required loc element')
})
it('warns on invalid URLs', async () => {
const xml = `
not-a-url
https://example.com/valid.xml
`
const { entries, warnings } = await parseSitemapIndex(xml)
expect(entries).toEqual([
{ loc: 'https://example.com/valid.xml' },
])
expect(warnings).toHaveLength(1)
expect(warnings[0].message).toBe('Sitemap entry has invalid URL')
expect(warnings[0].context?.url).toBe('not-a-url')
})
it('trims whitespace from values', async () => {
const xml = `
https://example.com/sitemap.xml
2024-01-15
`
const { entries } = await parseSitemapIndex(xml)
expect(entries[0].loc).toBe('https://example.com/sitemap.xml')
expect(entries[0].lastmod).toBe('2024-01-15')
})
it('throws on empty input', async () => {
await expect(parseSitemapIndex('')).rejects.toThrow('Empty XML input provided')
})
it('throws on non-sitemapindex XML', async () => {
const xml = `
https://example.com
`
await expect(parseSitemapIndex(xml)).rejects.toThrow('XML does not contain a valid sitemapindex element')
})
})
================================================
FILE: test/unit/sorting.test.ts
================================================
import { describe, expect, it } from 'vitest'
import { sortInPlace } from '../../src/runtime/server/sitemap/urlset/sort'
describe('sorting', () => {
it('default', async () => {
const data = sortInPlace([
{ loc: '/a' },
{ loc: '/b' },
{ loc: '/c' },
{ loc: '/1' },
{ loc: '/2' },
{ loc: '/10' },
])
expect(data).toMatchInlineSnapshot(`
[
{
"loc": "/1",
},
{
"loc": "/2",
},
{
"loc": "/10",
},
{
"loc": "/a",
},
{
"loc": "/b",
},
{
"loc": "/c",
},
]
`)
})
})
================================================
FILE: test/unit/sourcesHook.test.ts
================================================
import type { H3Event } from 'h3'
import type { SitemapSourcesHookCtx } from '../../src/runtime/types'
import { describe, expect, it } from 'vitest'
describe('sitemap:sources hook', () => {
it('hook context is correctly typed', () => {
// This is a type test to ensure our hook context is properly structured
const mockEvent: Partial = {
node: {
req: {
headers: {
authorization: 'Bearer test-token',
},
} as any,
} as any,
}
const ctx: SitemapSourcesHookCtx = {
event: mockEvent as H3Event,
sitemapName: 'test-sitemap',
sources: [
'/api/test1',
['/api/test2', { headers: { 'X-Original': 'original' } }],
],
}
// Type checks - ensuring the structure is correct
expect(ctx.event).toBeDefined()
expect(ctx.sitemapName).toBe('test-sitemap')
expect(ctx.sources).toBeDefined()
expect(ctx.sources).toHaveLength(2)
})
it('hook can add new sources', () => {
const mockEvent: Partial = {
node: {
req: {
headers: {
authorization: 'Bearer test-token',
},
} as any,
} as any,
}
const ctx: SitemapSourcesHookCtx = {
event: mockEvent as H3Event,
sitemapName: 'test-sitemap',
sources: ['/api/existing'],
}
// Simulate adding a new source
ctx.sources.push('/api/new-source')
expect(ctx.sources).toHaveLength(2)
expect(ctx.sources).toContain('/api/new-source')
})
it('hook can modify source headers', () => {
const mockEvent: Partial = {
node: {
req: {
headers: {
authorization: 'Bearer test-token',
},
} as any,
} as any,
}
const ctx: SitemapSourcesHookCtx = {
event: mockEvent as H3Event,
sitemapName: 'test-sitemap',
sources: [
{ fetch: ['/api/test', { headers: { 'X-Original': 'original' } }] } as any,
],
}
// Simulate what a hook would do
ctx.sources = ctx.sources.map((source) => {
if (typeof source === 'object' && source.fetch) {
const [url, options = {}] = Array.isArray(source.fetch) ? source.fetch : [source.fetch, {}]
options.headers = options.headers || {}
options.headers['X-Custom'] = 'custom-value'
const authHeader = ctx.event.node?.req?.headers?.authorization
if (authHeader) {
options.headers.Authorization = authHeader
}
return { ...source, fetch: [url, options] }
}
return source
})
// Verify the modifications
const modifiedSource = ctx.sources[0] as any
const headers = modifiedSource.fetch[1].headers
expect(headers['X-Original']).toBe('original')
expect(headers['X-Custom']).toBe('custom-value')
expect(headers.Authorization).toBe('Bearer test-token')
})
it('hook can filter sources', () => {
const mockEvent: Partial = {
node: {
req: {
headers: {},
} as any,
} as any,
}
const ctx: SitemapSourcesHookCtx = {
event: mockEvent as H3Event,
sitemapName: 'test-sitemap',
sources: [
'/api/keep-this',
'/api/skip-this',
'/api/also-keep',
],
}
// Simulate filtering sources
ctx.sources = ctx.sources.filter((source) => {
if (typeof source === 'string') {
return !source.includes('skip-this')
}
return true
})
expect(ctx.sources).toHaveLength(2)
expect(ctx.sources).not.toContain('/api/skip-this')
expect(ctx.sources).toContain('/api/keep-this')
expect(ctx.sources).toContain('/api/also-keep')
})
})
================================================
FILE: tsconfig.json
================================================
{
"extends": "./.nuxt/tsconfig.json",
"exclude": [
"dist",
"test/**",
"playground",
"examples",
"benchmark",
"src/runtime/server/routes/__sitemap__/nuxt-content-urls-v3.ts"
]
}
================================================
FILE: vitest.config.ts
================================================
import { defineConfig, defineProject } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
reporters: 'dot',
projects: [
// utils folders as *.test.ts in either test/unit or in src/**/*.test.ts
defineProject({
test: {
name: 'unit',
environment: 'node',
include: [
'./test/unit/**/*.test.ts',
'./src/**/*.test.ts',
],
exclude: [
'**/node_modules/**',
],
},
}),
// type-level tests via vitest typecheck
defineProject({
test: {
name: 'typecheck',
typecheck: {
enabled: true,
tsconfig: './test/types/tsconfig.json',
},
include: [
'./test/types/**/*.test-d.ts',
],
exclude: [
'**/node_modules/**',
'**/.claude/**',
],
},
}),
// e2e tests in test/e2e
defineProject({
test: {
name: 'e2e',
environment: 'node',
include: [
'./test/e2e/**/*.test.ts',
],
exclude: [
'**/node_modules/**',
],
globalSetup: './test/e2e/global-setup.ts',
},
}),
],
},
})