fix: HTML sitemap pages once per locale, never over a catch-all by default

- seoSitemapPages (lib/sitemapPages.ts) builds the routes per locale; the
  module adds them from a pages:resolved hook registered on ready, after
  @nuxtjs/i18n has localised the app's routes, so no cross-locale copies
  exist and each name ends in the locale's i18n suffix (L4 WR-05)
- a path an app catch-all covers is left to it and logged at build unless
  seo.htmlSitemap.overCatchAll is true (L4 WR-04)
- SitemapLink falls back to the i18n locale and renders only when the path
  resolves to the layer's sitemap page of that locale
- an app with only app.vue no longer gets its router turned on by the pages
This commit is contained in:
Jakub Zych
2026-11-04 22:47:58 +01:00
parent 7588aa53b0
commit a651de4675
8 changed files with 270 additions and 34 deletions

View File

@@ -19,6 +19,9 @@ export default defineNuxtConfig({
},
seo: {
noindexRoutes: ['/prywatne/**'],
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/en/sitemap' } },
// The playground renders everything else through a root [...slug].vue,
// so the layer's sitemap pages would be left to it (L4 WR-04);
// overCatchAll serves them ahead of the catch-all for the smoke.
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/en/sitemap' }, overCatchAll: true },
},
})

View File

@@ -70,7 +70,7 @@ Module options under the `seo` key of the app's `nuxt.config.ts` (v0.2.0). They
| `indexable` | `true` | `false` for a site that is never indexed: the build guard checks nothing. |
| `routeReport` | `true` | Report the static routes to the plugin's page registry at server start, with the static paths served noindex in the same request's `noindex` list (needs `seo.routeReportToken` or `seo.notfoundToken`). |
| `pagination` | `{ form: 'query', param: 'page', segments: { pl: 'strona', en: 'page' } }` | The pagination URL form the site is built for: `query` (`/blog?page=2`) or `path` (`/blog/strona/2`, the segment per locale). The resolve and site answers win at run time; the drift check logs a difference. |
| `htmlSitemap` | `{ paths: { pl: '/mapa-strony', en: '/sitemap' } }` | The HTML sitemap pages by locale (full paths, overview at the path, groups at `<path>/<group>`); `false` registers none. A path an app page already has stays the app's. An app without pages of its own (only `app.vue`) sets `false`: the sitemap pages would be its only routes, which turns Nuxt's router on and makes every other path answer 404. |
| `htmlSitemap` | `{ paths: { pl: '/mapa-strony', en: '/sitemap' }, overCatchAll: false }` | The HTML sitemap pages by locale (full paths, overview at the path, groups at `<path>/<group>`); `false` registers none. A path an app page already has stays the app's. A path an app catch-all page covers (a root `[...slug].vue`, or a prefixed one such as `pages/pl/[...slug].vue` for `/pl/mapa-strony`) stays the catch-all's too, so a CMS page at `/sitemap` or `/mapa-strony` is not shadowed: the layer registers no sitemap pages for that locale and logs it at build. `overCatchAll: true` serves the layer's pages ahead of the catch-all. With `@nuxtjs/i18n` each locale's pages exist once, at its configured path, named for that locale (`seo-sitemap-pl___pl`), never copied under another locale's prefix. An app without pages of its own (only `app.vue`) gets none: the layer adds them to the app's resolved pages, which such an app does not have, so Nuxt's router stays off (`false` is no longer needed there). |
## Configuration
@@ -95,8 +95,8 @@ All server-rendered (no `<ClientOnly>`, no `.client.vue`, no `onMounted` data);
- **`<SeoBreadcrumbs>`** (props `items?`, `label?`, `hideCurrent?`, `locale?`): the answer's `breadcrumbs` as `nav.seo-breadcrumbs > ol.seo-breadcrumbs__list`, links in `a.seo-breadcrumbs__link`, the last item as `span.seo-breadcrumbs__current` with `aria-current="page"`; separators are CSS-generated (`--seo-breadcrumbs-separator`). It renders nothing with fewer than two items (the home page), when the page meta sets `seo: { breadcrumbs: false }` or when the option `breadcrumbs` is `false`. Only the first instance rendered for a page shows its trail, so an app may place one in its layout while a layer's page renders one too.
- **`<SeoPagination>`** (props `current`, `last`, `labels?` with `label`, `previous`, `next`, `page`, `classPrefix?` default `seo-pagination`, `locale?`): previous and next (left out at the ends), page 1, the last page and five pages around the current one with `…` gap items, the current page as `span` with `aria-current="page"`. Nothing when `last` is 1 or less. `classPrefix` renames every class, so a layer keeps its own class names.
- **`usePagination({ current, last, locale? })`**: what `<SeoPagination>` uses: `items`, `href(page)` and the resolved `form`. Hrefs follow the resolve answer's `pagination`, else the site answer's (when `useSeoSite()` ran on the page), else the option; other query parameters stay and page 1 is the unnumbered URL. It adds `<link rel="prev">` and `<link rel="next">` to the head and hands the page count to the CMS head, which replaces `%pages%` in the CMS title and description.
- **HTML sitemap pages**: the overview at each locale's `htmlSitemap.paths` entry (route name `seo-sitemap-<locale>`) and group pages at `<path>/<group>` (`seo-sitemap-group-<locale>`; in the path pagination form also `<path>/<group>/<segment>/<n>`). They render the global component **`SeoHtmlSitemapPage`** (props `locale`, `group?`, `pathPage?`): an H1 (`sitemap.title`), one `section` with an H2 per non-empty group, the category tree as nested lists, a large group's first entries plus a `sitemap.all` link to its group page, and `sitemap.empty` when nothing is listed; a group page has the breadcrumbs Home › Sitemap › group (built by the layer, the resolver does not know these pages), the H1 `sitemap.groupTitle` with `· sitemap.pageSuffix` on page 2 and later, the list and `<SeoPagination>`. An unknown group or a page past the last answers 404, `?page=1` a 301 to the unnumbered URL, and a failed sitemap API call 503, so no empty page is cached. The pages answer 404 while the CMS has no HTML sitemap for the locale (or answers no site, as before SEO v0.2.0). Data comes from `GET /api/__seo__/sitemap?locale=&group=&page=`, which proxies the sitemap plugin's `GET /_sitemap/api/v1/html` and `/_sitemap/api/v1/html/{group}` on `backendBase`. An app replaces the page markup by registering its own component named `SeoHtmlSitemapPage` (for example `components/global/SeoHtmlSitemapPage.vue`) with the same props. In an app with `@nuxtjs/i18n`, give each locale its full path in `htmlSitemap.paths`.
- **`<SeoSitemapLink>`** (prop `locale?`): `a.seo-sitemap-link` to the locale's HTML sitemap with the `sitemapLink` label, only while the CMS has the HTML sitemap on for that locale and the layer serves it. Place it in the footer.
- **HTML sitemap pages**: the overview at each locale's `htmlSitemap.paths` entry (route name `seo-sitemap-<locale>`) and group pages at `<path>/<group>` (`seo-sitemap-group-<locale>`; in the path pagination form also `<path>/<group>/<segment>/<n>` with the locale's segment; with `@nuxtjs/i18n` each name ends in `___<locale>`). They render the global component **`SeoHtmlSitemapPage`** (props `locale`, `group?`, `pathPage?`): an H1 (`sitemap.title`), one `section` with an H2 per non-empty group, the category tree as nested lists, a large group's first entries plus a `sitemap.all` link to its group page, and `sitemap.empty` when nothing is listed; a group page has the breadcrumbs Home › Sitemap › group (built by the layer, the resolver does not know these pages), the H1 `sitemap.groupTitle` with `· sitemap.pageSuffix` on page 2 and later, the list and `<SeoPagination>`. An unknown group or a page past the last answers 404, `?page=1` (and `/<segment>/1` in the path form) a 301 to the unnumbered URL, never to the page's own URL, and a failed sitemap API call 503, so no empty page is cached. The pages answer 404 while the CMS has no HTML sitemap for the locale (or answers no site, as before SEO v0.2.0). Data comes from `GET /api/__seo__/sitemap?locale=&group=&page=`, which proxies the sitemap plugin's `GET /_sitemap/api/v1/html` and `/_sitemap/api/v1/html/{group}` on `backendBase`. An app replaces the page markup by registering its own component named `SeoHtmlSitemapPage` (for example `components/global/SeoHtmlSitemapPage.vue`) with the same props. In an app with `@nuxtjs/i18n`, give each locale its full path in `htmlSitemap.paths` (`{ en: '/sitemap', pl: '/pl/mapa-strony' }` with `prefix_except_default`): the layer adds the pages after i18n has localised the app's routes, so i18n does not prefix them again.
- **`<SeoSitemapLink>`** (prop `locale?`): `a.seo-sitemap-link` to the locale's HTML sitemap with the `sitemapLink` label, only while the CMS has the HTML sitemap on for that locale and the layer serves it: the path must resolve to the layer's sitemap page of that locale, so no link appears where an app page or a catch-all keeps the path. The locale is the prop, else the resolve answer's, else the `@nuxtjs/i18n` current locale where the app has i18n, else the site default. Place it in the footer.
## Resolve answer

View File

@@ -1,23 +1,39 @@
<script setup lang="ts">
// The footer link to the HTML sitemap of the page's locale (UI-SPEC L4,
// D-34): rendered only while the CMS has the HTML sitemap on for that
// locale and the layer serves it (seo.htmlSitemap). No style of its own
// beyond the inherited link colour.
// locale and the layer serves it (seo.htmlSitemap), that is while the path
// resolves to the layer's sitemap page of that locale (not taken by an app
// page or a catch-all, L4 WR-04, WR-05). The locale: the prop, the resolve
// answer, its html_lang, the @nuxtjs/i18n current locale where the app has
// it (the layer does not depend on i18n), the site default. No style of
// its own beyond the inherited link colour.
const props = withDefaults(defineProps<{ locale?: string }>(), { locale: '' })
const config = useRuntimeConfig()
const router = useRouter()
const answer = useSeoAnswer()
const { data: site } = await useSeoSite()
const i18n = (useNuxtApp() as { $i18n?: { locale?: { value?: unknown } } }).$i18n
const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
const i18nLocale = () => {
const value = i18n?.locale?.value
return typeof value === 'string' ? value : ''
}
const locale = computed(() => props.locale || answer.value?.locale || primary(answer.value?.html_lang) || site.value?.default_locale || '')
const locale = computed(() => props.locale || answer.value?.locale || primary(answer.value?.html_lang) || i18nLocale() || site.value?.default_locale || '')
const labels = useSeoLabels(locale)
const href = computed(() => {
const html = site.value?.html_sitemap
if (!html || html.enabled !== true || !html.paths || !html.paths[locale.value]) return ''
const own = (config.public.seo as { htmlSitemap?: { paths?: Record<string, string> } | false } | undefined)?.htmlSitemap
if (!own || typeof own !== 'object') return ''
return own.paths?.[locale.value] ?? ''
const path = own.paths?.[locale.value] ?? ''
if (!path) return ''
// Only the layer's sitemap page of this locale, never an app page or a
// catch-all at that path, nor another locale's copy.
const target = router.resolve(path)
const served = target.matched.some((r) => (r.meta?.seoSitemap as { locale?: string } | undefined)?.locale === locale.value)
return served ? path : ''
})
</script>

View File

@@ -8,7 +8,12 @@ export interface SeoPaginationOption {
segments: Record<string, string>
}
export type SeoHtmlSitemapOption = { paths: Record<string, string> } | false
/**
* The HTML sitemap pages by locale, or false for none. overCatchAll: serve
* the layer's pages ahead of an app catch-all page that covers the path
* (default false: the catch-all keeps the path, L4 WR-04).
*/
export type SeoHtmlSitemapOption = { paths: Record<string, string>; overCatchAll: boolean } | false
/** The pagination option with its defaults, as the runtime config carries it. */
export function paginationOption(value: unknown): SeoPaginationOption {
@@ -23,7 +28,7 @@ export function paginationOption(value: unknown): SeoPaginationOption {
}
}
/** The HTML sitemap option: false, or the paths (each '/' plus at least one character). */
/** The HTML sitemap option: false, or the paths (each '/' plus at least one character) and overCatchAll. */
export function htmlSitemapOption(value: unknown): SeoHtmlSitemapOption {
if (value === false) return false
const v = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
@@ -32,5 +37,5 @@ export function htmlSitemapOption(value: unknown): SeoHtmlSitemapOption {
for (const [locale, path] of Object.entries(raw)) {
if (typeof path === 'string' && /^\/[^?#\s]+$/.test(path) && !path.includes(':') && !path.includes('*')) paths[locale] = path.replace(/\/+$/, '')
}
return { paths }
return { paths, overCatchAll: v.overCatchAll === true }
}

View File

@@ -9,10 +9,11 @@
// options never use a name nuxt-seo-utils has (enabled, fallbackTitle …).
import { fileURLToPath } from 'node:url'
import { addServerTemplate, defineNuxtModule, extendPages, useLogger } from 'nuxt/kit'
import { addServerTemplate, defineNuxtModule, useLogger } from 'nuxt/kit'
import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts'
import { htmlSitemapOption, paginationOption, type SeoHtmlSitemapOption, type SeoPaginationOption } from './options.ts'
import { localeCopySuffixes, noindexStaticRoutes, routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts'
import { isSitemapRoute, seoSitemapPages, sitemapSkipMessage } from './sitemapPages.ts'
export interface SeoModuleOptions {
/**
@@ -64,7 +65,9 @@ export interface SeoModuleOptions {
/**
* The HTML sitemap pages (D-34), by locale: the overview at the path, a
* group at <path>/<group>. false registers none. A path an app page
* already has is left to the app.
* already has is left to the app, and so is a path an app catch-all page
* covers unless overCatchAll is true. With @nuxtjs/i18n each locale's
* pages exist once, at its configured path.
*/
htmlSitemap: SeoHtmlSitemapOption
}
@@ -151,27 +154,32 @@ export default defineNuxtModule<SeoModuleOptions>({
guard()
})
// The HTML sitemap pages (D-34, P33-32), one overview and one group
// page per locale, each rendering the global SeoHtmlSitemapPage. A path
// an app page already has stays the app's.
// The HTML sitemap pages (D-34, P33-32, P33-68), one overview and one
// group page per locale, each rendering the global SeoHtmlSitemapPage
// (lib/sitemapPages.ts). Added in a pages:resolved hook that runs after
// @nuxtjs/i18n has localised the app's routes, in either scanPageMeta
// mode, so i18n never copies these routes into other locales (L4 WR-05).
// i18n registers its page hook from its modules:done handler, which runs
// after this module's (this layer's module is set up first), so the hook
// is registered on ready: Nuxt calls it once every modules:done handler
// has run and before the build resolves the pages. A path an app page
// already has stays the app's, a path an app catch-all covers stays the
// catch-all's unless htmlSitemap.overCatchAll is true (L4 WR-04).
if (htmlSitemap !== false) {
const overviewFile = fileURLToPath(new URL('../app/seo-pages/SitemapPage.vue', import.meta.url))
const groupFile = fileURLToPath(new URL('../app/seo-pages/SitemapGroupPage.vue', import.meta.url))
extendPages((list) => {
const taken = new Set(list.map((p) => p.path))
for (const [locale, path] of Object.entries(htmlSitemap.paths)) {
if (taken.has(path)) {
logger.info(`HTML sitemap: the app has its own page at ${path}; the layer's ${locale} sitemap page is not registered`)
continue
}
const meta = { seoSitemap: { locale } }
list.push({ name: `seo-sitemap-${locale}`, path, file: overviewFile, meta })
list.push({ name: `seo-sitemap-group-${locale}`, path: `${path}/:group`, file: groupFile, meta })
if (pagination.form === 'path') {
const segment = pagination.segments[locale] || pagination.param
list.push({ name: `seo-sitemap-group-page-${locale}`, path: `${path}/:group/${segment}/:page(\\d+)`, file: groupFile, meta })
}
}
const files = {
overview: fileURLToPath(new URL('../app/seo-pages/SitemapPage.vue', import.meta.url)),
group: fileURLToPath(new URL('../app/seo-pages/SitemapGroupPage.vue', import.meta.url)),
}
nuxt.hook('ready', () => {
nuxt.hook('pages:resolved', (resolved) => {
const list = resolved as unknown as RoutePage[]
// A dev regeneration may hand back the routes added before.
for (let i = list.length - 1; i >= 0; i--) if (isSitemapRoute(list[i]!, files)) list.splice(i, 1)
const i18n = (nuxt.options as { i18n?: unknown }).i18n
const { routes, skipped } = seoSitemapPages({ htmlSitemap, pagination, pages: list, i18n, files })
for (const skip of skipped) logger.info(sitemapSkipMessage(skip))
list.push(...(routes as unknown as RoutePage[]))
})
})
}

94
lib/sitemapPages.ts Normal file
View File

@@ -0,0 +1,94 @@
// The HTML sitemap pages the layer registers (D-34, P33-68): one overview
// at seo.htmlSitemap.paths[locale], one group page at <path>/:group and, in
// the path pagination form, <path>/:group/<segment>/:page with the locale's
// segment. The module (lib/seo-module.ts) calls this from a pages:resolved
// hook that runs after @nuxtjs/i18n has localised the app's routes, so
// i18n never copies these routes into other locales (L4 WR-05): with i18n
// (any strategy but no_prefix) each route is named
// <name><routesNameSeparator><locale>, the name i18n reads the route's
// locale from, and exists once, at the configured path. A path an app page
// already has is the app's ('taken'); a path an app catch-all page covers
// ('[...slug].vue' at the root, or under a prefix) stays the catch-all's
// unless overCatchAll is true (L4 WR-04), because a static route would win
// over it in vue-router and shadow the CMS page there. Pure TypeScript, so
// `node --test` drives it.
import { flattenPages, pagePattern, patternCovers } from './guard.ts'
import type { SeoHtmlSitemapOption, SeoPaginationOption } from './options.ts'
import type { RoutePage } from './routes.ts'
/** Every route the layer adds starts with this name. */
export const SITEMAP_PAGE_PREFIX = 'seo-sitemap-'
export interface SitemapPagesInput {
htmlSitemap: SeoHtmlSitemapOption
pagination: SeoPaginationOption
/** The resolved pages (after @nuxtjs/i18n), the layer's own routes left out. */
pages: readonly RoutePage[]
/** nuxt.options.i18n when the app configures @nuxtjs/i18n. */
i18n?: unknown
/** The overview and group page components. */
files: { overview: string; group: string }
}
export interface SitemapRoute {
name: string
path: string
file: string
meta: { seoSitemap: { locale: string } }
}
export interface SitemapSkip {
locale: string
path: string
reason: 'taken' | 'catch-all'
}
/** True for a route the layer added (a former run of the hook). */
export const isSitemapRoute = (page: { name?: unknown; file?: unknown }, files: { overview: string; group: string }) =>
typeof page.name === 'string' && page.name.startsWith(SITEMAP_PAGE_PREFIX) && (page.file === files.overview || page.file === files.group)
/** The route-name suffix of a locale with @nuxtjs/i18n active, '' without. */
function localeSuffix(i18n: unknown, locale: string): string {
if (!i18n || typeof i18n !== 'object') return ''
const o = i18n as { strategy?: unknown; routesNameSeparator?: unknown }
if (o.strategy === 'no_prefix') return ''
const sep = typeof o.routesNameSeparator === 'string' && o.routesNameSeparator !== '' ? o.routesNameSeparator : '___'
return `${sep}${locale}`
}
/** The routes to add and the locales left out, with the reason. */
export function seoSitemapPages(input: SitemapPagesInput): { routes: SitemapRoute[]; skipped: SitemapSkip[] } {
const routes: SitemapRoute[] = []
const skipped: SitemapSkip[] = []
const html = input.htmlSitemap
if (html === false) return { routes, skipped }
const pages = flattenPages(input.pages).filter((p) => !isSitemapRoute(p as RoutePage, input.files))
const taken = new Set(pages.map((p) => p.fullPath))
const catchAlls = pages.map((p) => pagePattern(p.fullPath)).filter((p) => p.endsWith('/**'))
for (const [locale, path] of Object.entries(html.paths)) {
if (taken.has(path)) {
skipped.push({ locale, path, reason: 'taken' })
continue
}
if (!html.overCatchAll && catchAlls.some((pattern) => patternCovers(pattern, path))) {
skipped.push({ locale, path, reason: 'catch-all' })
continue
}
const suffix = localeSuffix(input.i18n, locale)
const meta = { seoSitemap: { locale } }
routes.push({ name: `${SITEMAP_PAGE_PREFIX}${locale}${suffix}`, path, file: input.files.overview, meta })
routes.push({ name: `${SITEMAP_PAGE_PREFIX}group-${locale}${suffix}`, path: `${path}/:group`, file: input.files.group, meta })
if (input.pagination.form === 'path') {
const segment = input.pagination.segments[locale] || input.pagination.param
routes.push({ name: `${SITEMAP_PAGE_PREFIX}group-page-${locale}${suffix}`, path: `${path}/:group/${segment}/:page(\\d+)`, file: input.files.group, meta })
}
}
return { routes, skipped }
}
/** The build log line of a skipped locale. */
export function sitemapSkipMessage(skip: SitemapSkip): string {
if (skip.reason === 'taken') return `HTML sitemap: the app has its own page at ${skip.path}; the layer's ${skip.locale} sitemap page is not registered`
return `HTML sitemap: ${skip.locale} ${skip.path} is rendered by the app's catch-all page; the layer's page is not registered (set seo.htmlSitemap.overCatchAll to serve it ahead of the catch-all)`
}

View File

@@ -143,7 +143,7 @@ test('options: pagination and HTML sitemap with their checks', () => {
assert.deepEqual(paginationOption({ form: 'path', param: 'p', segments: { pl: 'strona', en: 'a b' } }), { form: 'path', param: 'p', segments: { pl: 'strona' } })
assert.deepEqual(paginationOption(undefined), { form: 'query', param: 'page', segments: {} })
assert.equal(htmlSitemapOption(false), false)
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony/', en: 'sitemap', de: '/x/:id' } }), { paths: { pl: '/mapa-strony' } })
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony/', en: 'sitemap', de: '/x/:id' } }), { paths: { pl: '/mapa-strony' }, overCatchAll: false })
})
const layer = { pagination: { form: 'query' as const, param: 'page', segments: { pl: 'strona', en: 'page' } }, htmlSitemap: { pl: '/mapa-strony', en: '/en/sitemap' } }

110
tests/sitemapPages.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { htmlSitemapOption } from '../lib/options.ts'
import { SITEMAP_PAGE_PREFIX, seoSitemapPages, type SitemapPagesInput } from '../lib/sitemapPages.ts'
const files = { overview: '/layer/SitemapPage.vue', group: '/layer/SitemapGroupPage.vue' }
const query = { form: 'query' as const, param: 'page', segments: { pl: 'strona', en: 'page' } }
const path = { form: 'path' as const, param: 'page', segments: { pl: 'strona', en: 'page' } }
const input = (over: Partial<SitemapPagesInput> = {}): SitemapPagesInput => ({
htmlSitemap: htmlSitemapOption({ paths: { pl: '/mapa-strony', en: '/sitemap' } }),
pagination: query,
pages: [{ path: '/', name: 'index' }],
files,
...over,
})
const summary = (routes: Array<{ name?: string; path: string }>) => routes.map((r) => `${r.name} ${r.path}`)
test('seoSitemapPages: without i18n, one overview and one group page per locale', () => {
const out = seoSitemapPages(input())
assert.deepEqual(summary(out.routes), [
'seo-sitemap-pl /mapa-strony',
'seo-sitemap-group-pl /mapa-strony/:group',
'seo-sitemap-en /sitemap',
'seo-sitemap-group-en /sitemap/:group',
])
assert.deepEqual(out.skipped, [])
assert.deepEqual(out.routes[0]!.meta, { seoSitemap: { locale: 'pl' } })
assert.equal(out.routes[0]!.file, files.overview)
assert.equal(out.routes[1]!.file, files.group)
assert.ok(out.routes.every((r) => r.name!.startsWith(SITEMAP_PAGE_PREFIX)))
})
test('seoSitemapPages: the path form adds the page route with the locale segment', () => {
const out = seoSitemapPages(input({ pagination: path }))
assert.deepEqual(summary(out.routes).filter((s) => s.includes('group-page')), [
'seo-sitemap-group-page-pl /mapa-strony/:group/strona/:page(\\d+)',
'seo-sitemap-group-page-en /sitemap/:group/page/:page(\\d+)',
])
// a locale without a segment falls back to the param
const de = seoSitemapPages(input({ pagination: path, htmlSitemap: htmlSitemapOption({ paths: { de: '/seitenuebersicht' } }) }))
assert.ok(summary(de.routes).includes('seo-sitemap-group-page-de /seitenuebersicht/:group/page/:page(\\d+)'))
})
test('seoSitemapPages: with i18n each route is named for its own locale, once, at the configured path (L4 WR-05)', () => {
const i18n = { strategy: 'prefix_except_default', defaultLocale: 'en', locales: [{ code: 'en' }, { code: 'pl' }] }
const out = seoSitemapPages(input({ i18n, htmlSitemap: htmlSitemapOption({ paths: { en: '/sitemap', pl: '/pl/mapa-strony' } }) }))
assert.deepEqual(summary(out.routes), [
'seo-sitemap-en___en /sitemap',
'seo-sitemap-group-en___en /sitemap/:group',
'seo-sitemap-pl___pl /pl/mapa-strony',
'seo-sitemap-group-pl___pl /pl/mapa-strony/:group',
])
// no copy under another locale's name or prefix
assert.ok(!out.routes.some((r) => /___pl$/.test(r.name!) && r.meta.seoSitemap.locale !== 'pl'))
assert.ok(!out.routes.some((r) => r.path.startsWith('/pl/sitemap')))
// a custom separator; no_prefix leaves the names alone
const sep = seoSitemapPages(input({ i18n: { ...i18n, routesNameSeparator: '__' } }))
assert.ok(sep.routes.every((r) => r.name!.endsWith(`__${r.meta.seoSitemap.locale}`)))
const noPrefix = seoSitemapPages(input({ i18n: { ...i18n, strategy: 'no_prefix' } }))
assert.deepEqual(noPrefix.routes.map((r) => r.name), ['seo-sitemap-pl', 'seo-sitemap-group-pl', 'seo-sitemap-en', 'seo-sitemap-group-en'])
})
test('seoSitemapPages: an app page at the path keeps it (taken)', () => {
const out = seoSitemapPages(input({ pages: [{ path: '/', name: 'index' }, { path: '/mapa-strony', name: 'mapa-strony' }] }))
assert.deepEqual(out.skipped, [{ locale: 'pl', path: '/mapa-strony', reason: 'taken' }])
assert.deepEqual(out.routes.map((r) => r.name), ['seo-sitemap-en', 'seo-sitemap-group-en'])
// a nested page with the same full path is taken too
const nested = seoSitemapPages(input({ pages: [{ path: '/sitemap', name: 'sm', children: [{ path: '', name: 'sm-index' }] }] }))
assert.deepEqual(nested.skipped.map((s) => s.locale), ['en'])
})
test('seoSitemapPages: a root catch-all skips every locale unless overCatchAll (L4 WR-04)', () => {
const pages = [{ path: '/', name: 'index' }, { path: '/:slug(.*)*', name: 'slug' }]
const out = seoSitemapPages(input({ pages }))
assert.deepEqual(out.routes, [])
assert.deepEqual(out.skipped, [
{ locale: 'pl', path: '/mapa-strony', reason: 'catch-all' },
{ locale: 'en', path: '/sitemap', reason: 'catch-all' },
])
const over = seoSitemapPages(input({ pages, htmlSitemap: htmlSitemapOption({ paths: { pl: '/mapa-strony', en: '/sitemap' }, overCatchAll: true }) }))
assert.equal(over.routes.length, 4)
assert.deepEqual(over.skipped, [])
})
test('seoSitemapPages: a prefixed catch-all skips only the paths it covers', () => {
const pages = [{ path: '/pl/:slug(.*)*', name: 'pl-slug' }]
const out = seoSitemapPages(input({ pages, htmlSitemap: htmlSitemapOption({ paths: { pl: '/pl/mapa-strony', en: '/sitemap' } }) }))
assert.deepEqual(out.skipped, [{ locale: 'pl', path: '/pl/mapa-strony', reason: 'catch-all' }])
assert.deepEqual(out.routes.map((r) => r.name), ['seo-sitemap-en', 'seo-sitemap-group-en'])
// the i18n copy of a root catch-all (/pl/... and /...) covers both
const localized = [{ path: '/:slug(.*)*', name: 'slug___en' }, { path: '/pl/:slug(.*)*', name: 'slug___pl' }]
assert.equal(seoSitemapPages(input({ pages: localized })).routes.length, 0)
// a page with one parameter is not a catch-all
assert.equal(seoSitemapPages(input({ pages: [{ path: '/:slug', name: 'slug' }] })).routes.length, 4)
})
test('seoSitemapPages: htmlSitemap false answers nothing; earlier layer routes are not "taken"', () => {
assert.deepEqual(seoSitemapPages(input({ htmlSitemap: false })), { routes: [], skipped: [] })
const again = seoSitemapPages(input({ pages: [{ path: '/mapa-strony', name: 'seo-sitemap-pl', file: files.overview }] }))
assert.equal(again.routes.length, 4)
assert.deepEqual(again.skipped, [])
})
test('htmlSitemapOption: overCatchAll is a boolean, default false', () => {
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony' } }), { paths: { pl: '/mapa-strony' }, overCatchAll: false })
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony' }, overCatchAll: true }), { paths: { pl: '/mapa-strony' }, overCatchAll: true })
assert.deepEqual(htmlSitemapOption({ paths: {}, overCatchAll: 'yes' }), { paths: {}, overCatchAll: false })
})