feat: HTML sitemap pages and footer link

- the module registers seo-sitemap-<locale> at seo.htmlSitemap.paths
  (defaults pl /mapa-strony, en /sitemap) and seo-sitemap-group-<locale>
  at <path>/:group (plus <path>/:group/<segment>/:page in the path form);
  a path an app page already has stays the app's
- SeoHtmlSitemapPage (global, overridable): H1, an H2 per non-empty
  group, categories nested, a large group's link to its group page,
  sitemap.empty; group pages with a built trail, page suffix and
  <SeoPagination>; 404 for an unknown group or a page past the last,
  301 for ?page=1, 503 when the sitemap API fails
- GET /api/__seo__/sitemap proxies the sitemap plugin's HTML API (only
  while the CMS has the HTML sitemap on for the locale)
- <SeoSitemapLink>: the footer link while the HTML sitemap is on
- L3 default styles
This commit is contained in:
Jakub Zych
2026-11-04 12:00:00 +01:00
parent f2d13d6264
commit aee6957bd6
18 changed files with 600 additions and 3 deletions

View File

@@ -1,6 +1,8 @@
<template>
<!-- A layout instance of the breadcrumbs: the catch-all page renders one
too, and only the first renders its trail.-->
too, and only the first renders its trail. The footer carries the
HTML sitemap link. -->
<SeoBreadcrumbs />
<NuxtPage />
<footer><SeoSitemapLink /></footer>
</template>

View File

@@ -96,3 +96,33 @@
.seo-pagination__gap {
color: var(--seo-color-muted);
}
/* L3 HTML sitemap: lists in columns, room between groups; headings keep
the site's styles (margin only). */
.seo-sitemap__title {
margin-block: 0 var(--seo-space-4);
}
.seo-sitemap__group + .seo-sitemap__group {
margin-top: var(--seo-space-4);
}
.seo-sitemap__group-title {
margin-block: 0 var(--seo-space-3);
}
.seo-sitemap__list {
columns: var(--seo-sitemap-column-width);
column-gap: var(--seo-space-3);
margin: 0;
padding-inline-start: var(--seo-space-4);
}
.seo-sitemap__list .seo-sitemap__list {
columns: auto;
}
.seo-sitemap__item {
break-inside: avoid;
margin-bottom: var(--seo-space-1);
overflow-wrap: anywhere;
}
.seo-sitemap__more,
.seo-sitemap__empty {
margin-block: var(--seo-space-3) 0;
}

View File

@@ -0,0 +1,26 @@
<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.
const props = withDefaults(defineProps<{ locale?: string }>(), { locale: '' })
const config = useRuntimeConfig()
const answer = useSeoAnswer()
const { data: site } = await useSeoSite()
const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
const locale = computed(() => props.locale || answer.value?.locale || primary(answer.value?.html_lang) || 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] ?? ''
})
</script>
<template>
<NuxtLink v-if="href" class="seo-sitemap-link" :to="href">{{ labels.sitemapLink }}</NuxtLink>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
// One list of the HTML sitemap (UI-SPEC L3): an entry per link, children
// (the category tree) nested as a list inside their parent's item, no
// headings below the group's H2.
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoSitemapEntry } from '../../utils/types.ts'
const props = defineProps<{ entries: SeoSitemapEntry[]; origin?: string }>()
const rows = computed(() =>
(Array.isArray(props.entries) ? props.entries : [])
.filter((e) => e && typeof e.title === 'string' && e.title !== '')
.map((e) => ({ title: e.title, to: seoSitePath(e.url, props.origin), children: Array.isArray(e.children) ? e.children : [] })),
)
</script>
<template>
<ul v-if="rows.length > 0" class="seo-sitemap__list">
<li v-for="(row, i) in rows" :key="i" class="seo-sitemap__item">
<NuxtLink v-if="row.to" :to="row.to">{{ row.title }}</NuxtLink>
<span v-else>{{ row.title }}</span>
<SitemapList v-if="row.children.length > 0" :entries="row.children" :origin="origin" />
</li>
</ul>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
// The HTML sitemap (UI-SPEC L3, D-34), rendered by the layer's sitemap
// pages (app/seo-pages). Without group: the overview (an H1, an H2 per
// non-empty group, categories nested, a large group's first entries plus
// a link to its group page, sitemap.empty when nothing is listed). With
// group: one page of that group (breadcrumbs Home › Sitemap › group, the
// H1 with the page suffix on page 2 and later, the list, <SeoPagination>).
// An unknown group or a page past the last is a 404, ?page=1 a 301 to the
// unnumbered URL, a failed API call a 503, so no empty page is cached.
//
// Registered globally under the stable name SeoHtmlSitemapPage: an app
// overrides it by registering its own component of that name.
import { formatSeoLabel } from '../../utils/labels.ts'
import { seoPageHref, seoPageNumber } from '../../utils/pageHref.ts'
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoBreadcrumb, SeoSitemapGroupPage, SeoSitemapOverview } from '../../utils/types.ts'
const props = withDefaults(defineProps<{ locale: string; group?: string; pathPage?: string }>(), { group: '', pathPage: '' })
const route = useRoute()
const config = useRuntimeConfig()
const { data: site } = await useSeoSite()
const labels = useSeoLabels(() => props.locale)
const own = (config.public.seo as { htmlSitemap?: { paths?: Record<string, string> } | false } | undefined)?.htmlSitemap
const basePath = (own && typeof own === 'object' ? own.paths?.[props.locale] : '') || route.path
// The page number of a group page, in the site's pagination form.
const form = site.value?.pagination?.form === 'path' ? 'path' : 'query'
const param = site.value?.pagination?.param || 'page'
let page = 1
if (props.group) {
const parsed = props.pathPage ? (/^\d{1,6}$/.test(props.pathPage) ? Number(props.pathPage) : null) : seoPageNumber(route.fullPath, 'query', '', param)
if (parsed === null || parsed < 1) throw createError({ statusCode: 404, statusMessage: 'Page Not Found', fatal: true })
page = parsed
const explicitFirst = props.pathPage === '1' || route.query[param] === '1'
if (explicitFirst) await navigateTo(seoPageHref(route.fullPath, 1, form, '', param), { redirectCode: 301 })
}
const { data, error } = await useFetch<SeoSitemapOverview | SeoSitemapGroupPage>('/api/__seo__/sitemap', {
key: `seo-layer:sitemap:${props.locale}:${props.group}:${page}`,
query: props.group ? { locale: props.locale, group: props.group, page } : { locale: props.locale },
})
if (error.value || !data.value) {
const status = (error.value as { statusCode?: number } | null)?.statusCode
throw createError({ statusCode: status === 404 ? 404 : 503, statusMessage: status === 404 ? 'Page Not Found' : 'Sitemap unavailable', fatal: true })
}
const origin = site.value?.site_url || useRequestURL().origin
const overview = computed(() => (props.group ? null : (data.value as SeoSitemapOverview)))
const groupPage = computed(() => (props.group ? (data.value as SeoSitemapGroupPage) : null))
const groups = computed(() => overview.value?.groups ?? [])
const title = computed(() => {
if (!groupPage.value) return labels.value.sitemap.title
const base = formatSeoLabel(labels.value.sitemap.groupTitle, { group: groupPage.value.title })
return page >= 2 ? `${base} · ${formatSeoLabel(labels.value.sitemap.pageSuffix, { page })}` : base
})
useSeoMeta({ title })
// The resolver does not know the group pages, so their trail is built here.
const crumbs = computed((): SeoBreadcrumb[] => {
if (!groupPage.value) return []
const out: SeoBreadcrumb[] = []
const home = site.value?.home_crumb
if (home && typeof home.name === 'string' && home.name) {
const url = typeof home.url === 'string' ? home.url : '/'
out.push({ name: home.name, url, path: seoSitePath(url, origin) || '/' })
}
out.push({ name: labels.value.sitemap.title, url: basePath, path: basePath })
out.push({ name: groupPage.value.title, url: route.path, path: route.path })
return out
})
</script>
<template>
<div>
<SeoBreadcrumbs v-if="groupPage" :items="crumbs" :locale="locale" />
<div class="seo-sitemap">
<h1 class="seo-sitemap__title">{{ title }}</h1>
<template v-if="overview">
<section v-for="g in groups" :key="g.key" class="seo-sitemap__group" :aria-labelledby="`seo-sitemap-${g.slug}`">
<h2 :id="`seo-sitemap-${g.slug}`" class="seo-sitemap__group-title">{{ g.title }}</h2>
<SeoSitemapList :entries="g.entries" :origin="origin" />
<p v-if="g.more" class="seo-sitemap__more">
<NuxtLink :to="`${basePath}/${g.slug}`">{{ formatSeoLabel(labels.sitemap.all, { group: g.title, count: g.total }) }}</NuxtLink>
</p>
</section>
<p v-if="groups.length === 0" class="seo-sitemap__empty">{{ labels.sitemap.empty }}</p>
</template>
<template v-else-if="groupPage">
<SeoSitemapList :entries="groupPage.entries" :origin="origin" />
<SeoPagination :current="groupPage.page" :last="groupPage.pages" :locale="locale" />
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
// One group page of the HTML sitemap (registered by lib/seo-module.ts at
// <path>/:group, and in the path pagination form also at
// <path>/:group/<segment>/:page; names seo-sitemap-group-<locale>). It
// renders the global component SeoHtmlSitemapPage, which an app may
// override.
const route = useRoute()
const locale = String((route.meta.seoSitemap as { locale?: string } | undefined)?.locale ?? '')
const group = computed(() => String(route.params.group ?? ''))
const pathPage = computed(() => String(route.params.page ?? ''))
</script>
<template>
<SeoHtmlSitemapPage :key="`${group}:${pathPage}:${route.query.page ?? ''}`" :locale="locale" :group="group" :path-page="pathPage" />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// The HTML sitemap overview of one locale (registered by lib/seo-module.ts
// at seo.htmlSitemap.paths[locale], name seo-sitemap-<locale>). It renders
// the global component SeoHtmlSitemapPage, which an app may override.
const route = useRoute()
const locale = String((route.meta.seoSitemap as { locale?: string } | undefined)?.locale ?? '')
</script>
<template>
<SeoHtmlSitemapPage :locale="locale" />
</template>

View File

@@ -109,3 +109,35 @@ export interface SeoSiteAnswer {
pagination?: { form?: 'query' | 'path'; param?: string; segments?: Record<string, string> }
home_crumb?: { name?: string; url?: string } | null
}
/** One link of the HTML sitemap (GET /_sitemap/api/v1/html); children nest the category tree. */
export interface SeoSitemapEntry {
title: string
url: string
children?: SeoSitemapEntry[]
}
/** A group of the HTML sitemap overview. */
export interface SeoSitemapGroup {
key: string
slug: string
title: string
total: number
entries: SeoSitemapEntry[]
/** True when the group has more than the overview lists: link its group page. */
more: boolean
}
/** The overview (GET /_sitemap/api/v1/html?locale=). */
export interface SeoSitemapOverview {
groups: SeoSitemapGroup[]
}
/** One page of a group (GET /_sitemap/api/v1/html/{group}?locale=&page=). */
export interface SeoSitemapGroupPage {
group: string
title: string
page: number
pages: number
entries: SeoSitemapEntry[]
}

View File

@@ -8,7 +8,8 @@
// app still uses it: each module reads only its own keys, so the layer's
// options never use a name nuxt-seo-utils has (enabled, fallbackTitle …).
import { addServerTemplate, defineNuxtModule, useLogger } from 'nuxt/kit'
import { fileURLToPath } from 'node:url'
import { addServerTemplate, defineNuxtModule, extendPages, useLogger } from 'nuxt/kit'
import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts'
import { htmlSitemapOption, paginationOption, type SeoHtmlSitemapOption, type SeoPaginationOption } from './options.ts'
import { routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts'
@@ -150,6 +151,30 @@ 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.
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 })
}
}
})
}
// The route artefact (#seo-routes) for the route report and the drift
// check: written from the resolved pages when Nitro bundles the
// server, so no network call happens at build time.

View File

@@ -0,0 +1,62 @@
// GET /api/__seo__/sitemap?locale=&group=&page=: the HTML sitemap (D-34)
// from the sitemap plugin, for the layer's HTML sitemap pages
// (SeoHtmlSitemapPage). Without group: the overview of the locale; with
// group: one page of that group.
//
// 404 the layer is off, the backend has no site answer (an older SEO
// plugin) or the HTML sitemap is off or has no path for the
// locale, a malformed parameter, an unknown group or a page past
// the last (the plugin's 404 is passed through)
// 503 the backend did not answer or answered anything else (with
// Retry-After: 60), so the page renders an error, never an empty
// sitemap that a route cache could keep
//
// The target is the server-only backend base plus a checked group slug;
// only locale and page travel as query values. Never route-cached
// ('/api/__seo__/**' cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { HTML_SITEMAP_GROUP, HTML_SITEMAP_LOCALE, htmlSitemapGroupPage, htmlSitemapOverview, htmlSitemapPage } from '../../lib/htmlSitemap.ts'
import { siteAnswer } from '../../lib/site.ts'
const notFound = () => createError({ statusCode: 404, statusMessage: 'Not Found' })
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
const unavailable = () => {
setResponseHeader(event, 'retry-after', '60')
return createError({ statusCode: 503, statusMessage: 'Sitemap unavailable' })
}
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) throw notFound()
const q = getQuery(event)
const locale = typeof q.locale === 'string' ? q.locale : ''
const group = typeof q.group === 'string' ? q.group : ''
const page = htmlSitemapPage(q.page)
if (!HTML_SITEMAP_LOCALE.test(locale) || (group !== '' && !HTML_SITEMAP_GROUP.test(group)) || page === null) throw notFound()
const forwarded = event.context.seoForwardedFor as string | undefined
const headers: Record<string, string> = forwarded ? { 'x-forwarded-for': forwarded } : {}
const state = await siteAnswer(cfg, headers)
if (state.state === 'unknown') throw unavailable()
if (state.state === 'absent') throw notFound()
const html = state.site.html_sitemap
if (!html || html.enabled !== true || !html.paths || !html.paths[locale]) throw notFound()
const path = group ? `/_sitemap/api/v1/html/${encodeURIComponent(group)}` : '/_sitemap/api/v1/html'
let res: { status: number; _data?: unknown }
try {
res = await $fetch.raw(path, {
baseURL: cfg.backendBase,
query: group ? { locale, page } : { locale },
headers,
timeout: Math.max(cfg.timeoutMs, 3000),
ignoreResponseError: true,
})
} catch {
throw unavailable()
}
if (res.status === 404) throw notFound()
if (res.status !== 200 || !res._data || typeof res._data !== 'object') throw unavailable()
return group ? htmlSitemapGroupPage(res._data, group) : htmlSitemapOverview(res._data)
})

59
server/lib/htmlSitemap.ts Normal file
View File

@@ -0,0 +1,59 @@
// The HTML sitemap answers of the sitemap plugin (GET
// <backendBase>/_sitemap/api/v1/html?locale= and
// /_sitemap/api/v1/html/{group}?locale=&page=) reduced to the fields the
// layer renders, plus the checks of the layer's proxy route. Pure, so
// `node --test` drives it.
import type { SeoSitemapEntry, SeoSitemapGroup, SeoSitemapGroupPage, SeoSitemapOverview } from '../../app/utils/types.ts'
export const HTML_SITEMAP_LOCALE = /^[a-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/
export const HTML_SITEMAP_GROUP = /^[a-z0-9][a-z0-9-]{0,63}$/
/** Nesting deeper than this (a category tree) is cut off. */
const MAX_DEPTH = 8
const str = (v: unknown) => (typeof v === 'string' ? v : '')
const int = (v: unknown) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : 0)
function entries(value: unknown, depth = 0): SeoSitemapEntry[] {
if (!Array.isArray(value) || depth > MAX_DEPTH) return []
const out: SeoSitemapEntry[] = []
for (const e of value) {
if (!e || typeof e !== 'object') continue
const o = e as Record<string, unknown>
const title = str(o.title)
if (!title) continue
const children = entries(o.children, depth + 1)
out.push(children.length > 0 ? { title, url: str(o.url), children } : { title, url: str(o.url) })
}
return out
}
/** The overview with only its non-empty groups. */
export function htmlSitemapOverview(value: unknown): SeoSitemapOverview {
const groups: SeoSitemapGroup[] = []
const raw = value && typeof value === 'object' ? (value as { groups?: unknown }).groups : null
for (const g of Array.isArray(raw) ? raw : []) {
if (!g || typeof g !== 'object') continue
const o = g as Record<string, unknown>
const slug = str(o.slug)
const list = entries(o.entries)
if (!HTML_SITEMAP_GROUP.test(slug) || list.length === 0) continue
groups.push({ key: str(o.key) || slug, slug, title: str(o.title) || slug, total: int(o.total) || list.length, entries: list, more: o.more === true })
}
return { groups }
}
/** One page of a group. */
export function htmlSitemapGroupPage(value: unknown, group: string): SeoSitemapGroupPage {
const o = value && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const page = int(o.page) || 1
return { group, title: str(o.title) || group, page, pages: Math.max(int(o.pages), page), entries: entries(o.entries) }
}
/** A page query value: 1 when absent, the number for 1 to 999999, else null. */
export function htmlSitemapPage(raw: unknown): number | null {
if (raw === undefined || raw === null || raw === '') return 1
if (typeof raw !== 'string' || !/^\d{1,6}$/.test(raw)) return null
const n = Number(raw)
return n >= 1 ? n : null
}

View File

@@ -7,4 +7,6 @@
404 /robots.txt noheader=Retry-After
404 /sitemap-posts-1.xml
404 /google0123.html
# The HTML sitemap with the backend down is a 503 (never an empty page).
503 /mapa-strony
apicalls 0

View File

@@ -10,3 +10,7 @@ log [vue-seo-layer] drift: the CMS's journal-post URLs (/wpisy/:slug) match no N
log [vue-seo-layer] drift: the CMS paginates in the path form, the layer was built with query (seo.pagination.form):1
log [vue-seo-layer] drift: the CMS serves the pl HTML sitemap at /mapa, the layer at /mapa-strony (seo.htmlSitemap.paths):1
routereport 0
# The site answer here has no en path either way; the pl link follows the
# layer's path.
200 /blog/lwia-grzywa contains=<a href="/mapa-strony" class="seo-sitemap-link">

View File

@@ -9,5 +9,8 @@
404 /google1234.html
404 /google0123.html
404 /kontakt
# The layer's HTML sitemap pages and footer link need the CMS.
404 /mapa-strony
200 /plain notcontains=seo-sitemap-link
apicalls 0
noreport /kontakt

View File

@@ -82,7 +82,7 @@ noreport /stary
# The route report at server start: the static paths with the token, not
# the signed-in page, the page with a noindex seo.robots or the declared
# noindex route. The build matches the site answer: no drift warning.
routereport 1 has=/ has=/blog has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne
routereport 1 has=/ has=/blog has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne lacks=/mapa-strony
log [vue-seo-layer] drift::0
# L1 breadcrumbs: a layout instance (app.vue) and a page instance render
@@ -102,3 +102,26 @@ log [vue-seo-layer] drift::0
200 /blog?page=5 contains=<link rel="prev" href="/blog?page=4" contains=<link rel="next" href="/blog?page=6" contains=<title>Blog – strona 5 z 14 | Example</title> contains=content="Wpisy o grzybach, strona 5 z 14" notcontains=z %pages% |</title> notcontains=content="Wpisy o grzybach, strona 5 z %pages%"
200 /blog?page=14&sort=new contains=<a class="seo-pagination__prev" href="/blog?sort=new&amp;page=13" rel="prev"> count=seo-pagination__next:0 count=rel="next":0
200 /blog?pages=1 count=seo-pagination:0 count=rel="prev":0
# L3 HTML sitemap: H1, one H2 per non-empty group (the empty one left out),
# the category tree nested, a large group's link to its group page; a URL
# that is not http(s) renders as text; long titles stay whole.
200 /mapa-strony contains=<h1 class="seo-sitemap__title">Mapa strony</h1> count=<h2:3 notcontains=Tagi contains=<a href="/mapa-strony/posts" class="">Wpisy: wszystkie (1234)</a> notcontains=href="javascript: notcontains=<script>alert
200 /mapa-strony contains=Grzyby</a><ul class="seo-sitemap__list"> contains=<a href="/grzyby/lecznicze" contains=musi się zawinąć</a> count=<h1:1
# A group page: built trail, H1 with the page suffix, pagination; ?page=1
# is a 301 to the unnumbered URL; an unknown group or a page past the last
# is a 404; a failing sitemap API is a 503 (never an empty cached page).
200 /mapa-strony/posts?page=2 contains=<h1 class="seo-sitemap__title">Mapa strony: Wpisy · Strona 2</h1> contains=<a class="seo-breadcrumbs__link" href="/mapa-strony">Mapa strony</a> contains=<span class="seo-breadcrumbs__current" aria-current="page">Wpisy</span> count=<nav class="seo-breadcrumbs":1 count=<nav class="seo-pagination":1 contains=href="/mapa-strony/posts?page=3"
200 /mapa-strony/posts contains=<h1 class="seo-sitemap__title">Mapa strony: Wpisy</h1> contains=Lwia grzywa
301 /mapa-strony/posts?page=1 header=Location: /mapa-strony/posts
404 /mapa-strony/nope
404 /mapa-strony/posts?page=9
404 /mapa-strony/posts?page=abc
503 /mapa-strony/fail
# Every group empty: the empty message (en labels on the en path).
200 /en/sitemap contains=<h1 class="seo-sitemap__title">Sitemap</h1> contains=<p class="seo-sitemap__empty">There are no pages to list yet.</p> count=<h2:0
# A locale the CMS has no HTML sitemap path for.
404 /sitemap
# L4: the footer link to the locale's HTML sitemap.
200 /blog/lwia-grzywa contains=<footer><a href="/mapa-strony" class="seo-sitemap-link">Mapa strony</a></footer>

View File

@@ -19,6 +19,12 @@
// GET /_sitemap/files/<file> <dir>/seo/root/<file>, else 404;
// sitemap-fail-*.xml answers 500
// GET /_sitemap/indexnow/<key>.txt <dir>/seo/indexnow/<key>.txt, else 404
// GET /_sitemap/api/v1/html?locale= the HTML sitemap overview from
// <dir>/seo/html.json (overview.<locale>),
// else 404
// GET /_sitemap/api/v1/html/<group> one group page (groups.<locale>.<group>.<page>,
// ?locale=&page= page 1 by default), else 404; the
// group fail answers 500
//
// It records every request (with the parsed JSON body of a POST) so a smoke
// run can assert header forwarding, resolve counts and the 404 reports.
@@ -65,7 +71,10 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
let answers = {}
/** @type {unknown} */
let siteAnswer = null
/** @type {any} */
let html = {}
const load = () => {
html = readJson(join(dir, 'seo', 'html.json')) ?? {}
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
}
@@ -90,6 +99,13 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
if (files && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', files[1]), files[1])
const key = /^\/_sitemap\/indexnow\/([A-Za-z0-9-]{8,128}\.txt)$/.exec(url.pathname)
if (key && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'indexnow', key[1]), key[1])
const htmlGroup = /^\/_sitemap\/api\/v1\/html(?:\/([a-z0-9-]+))?$/.exec(url.pathname)
if (htmlGroup && req.method === 'GET') {
const locale = url.searchParams.get('locale') ?? ''
if (htmlGroup[1] === 'fail') return { status: 500, body: { error: 'backend failure' } }
const hit = htmlGroup[1] ? html.groups?.[locale]?.[htmlGroup[1]]?.[url.searchParams.get('page') ?? '1'] : html.overview?.[locale]
return hit ? { status: 200, body: hit } : { status: 404, body: { error: 'not_found' } }
}
if (!url.pathname.startsWith(PREFIX + '/')) return { status: 404, body: { error: 'not_found' } }
const path = url.pathname.slice(PREFIX.length)
if (path === '/resolve' && req.method === 'GET') {

View File

@@ -0,0 +1,127 @@
{
"overview": {
"pl": {
"groups": [
{
"key": "pages",
"slug": "strony",
"title": "Strony",
"total": 2,
"more": false,
"entries": [
{
"title": "O nas",
"url": "https://example.test/about"
},
{
"title": "Kontakt",
"url": "https://example.test/kontakt"
}
]
},
{
"key": "categories",
"slug": "kategorie",
"title": "Kategorie",
"total": 3,
"more": false,
"entries": [
{
"title": "Grzyby",
"url": "https://example.test/grzyby",
"children": [
{
"title": "Lecznicze",
"url": "https://example.test/grzyby/lecznicze"
},
{
"title": "Bardzo długa nazwa kategorii, która nie mieści się w jednej kolumnie i musi się zawinąć",
"url": "https://example.test/grzyby/dluga"
}
]
}
]
},
{
"key": "posts",
"slug": "posts",
"title": "Wpisy",
"total": 1234,
"more": true,
"entries": [
{
"title": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa"
},
{
"title": "<script>alert(1)</script>",
"url": "javascript:alert(1)"
}
]
},
{
"key": "tags",
"slug": "tagi",
"title": "Tagi",
"total": 0,
"more": false,
"entries": []
}
]
},
"en": {
"groups": [
{
"key": "pages",
"slug": "pages",
"title": "Pages",
"total": 0,
"more": false,
"entries": []
}
]
}
},
"groups": {
"pl": {
"posts": {
"1": {
"group": "posts",
"title": "Wpisy",
"page": 1,
"pages": 3,
"entries": [
{
"title": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa"
}
]
},
"2": {
"group": "posts",
"title": "Wpisy",
"page": 2,
"pages": 3,
"entries": [
{
"title": "Soplówka",
"url": "https://example.test/blog/soplowka"
}
]
},
"3": {
"group": "posts",
"title": "Wpisy",
"page": 3,
"pages": 3,
"entries": [
{
"title": "Reishi",
"url": "https://example.test/blog/reishi"
}
]
}
}
}
}
}

39
tests/htmlSitemap.test.ts Normal file
View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { htmlSitemapGroupPage, htmlSitemapOverview, htmlSitemapPage } from '../server/lib/htmlSitemap.ts'
test('htmlSitemapOverview: empty groups and malformed entries are left out', () => {
const out = htmlSitemapOverview({
groups: [
{ key: 'posts', slug: 'posts', title: 'Wpisy', total: 1234, more: true, entries: [{ title: 'A', url: 'https://x.test/a', extra: 1 }, { url: '/no-title' }] },
{ key: 'tags', slug: 'tagi', title: 'Tagi', total: 0, entries: [] },
{ key: 'bad', slug: '../x', title: 'Bad', entries: [{ title: 'B', url: '/b' }] },
{ key: 'cats', slug: 'kategorie', title: 'Kategorie', entries: [{ title: 'C', url: '/c', children: [{ title: 'D', url: '/d' }] }] },
],
})
assert.deepEqual(out.groups.map((g) => g.slug), ['posts', 'kategorie'])
assert.deepEqual(out.groups[0], { key: 'posts', slug: 'posts', title: 'Wpisy', total: 1234, entries: [{ title: 'A', url: 'https://x.test/a' }], more: true })
assert.deepEqual(out.groups[1]!.entries, [{ title: 'C', url: '/c', children: [{ title: 'D', url: '/d' }] }])
assert.equal(out.groups[1]!.total, 1, 'a missing total counts the entries')
assert.deepEqual(htmlSitemapOverview(null), { groups: [] })
})
test('htmlSitemapGroupPage: page and pages', () => {
assert.deepEqual(htmlSitemapGroupPage({ title: 'Wpisy', page: 2, pages: 3, entries: [{ title: 'A', url: '/a' }] }, 'posts'), {
group: 'posts',
title: 'Wpisy',
page: 2,
pages: 3,
entries: [{ title: 'A', url: '/a' }],
})
assert.equal(htmlSitemapGroupPage({ page: 4 }, 'posts').pages, 4)
})
test('htmlSitemapPage: 1 by default, null for anything but a page number', () => {
assert.equal(htmlSitemapPage(undefined), 1)
assert.equal(htmlSitemapPage('3'), 3)
assert.equal(htmlSitemapPage('0'), null)
assert.equal(htmlSitemapPage('abc'), null)
assert.equal(htmlSitemapPage(['1', '2']), null)
assert.equal(htmlSitemapPage('1234567'), null)
})