+
diff --git a/app/seo-pages/SitemapGroupPage.vue b/app/seo-pages/SitemapGroupPage.vue
new file mode 100644
index 0000000..228b886
--- /dev/null
+++ b/app/seo-pages/SitemapGroupPage.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/seo-pages/SitemapPage.vue b/app/seo-pages/SitemapPage.vue
new file mode 100644
index 0000000..fad66bc
--- /dev/null
+++ b/app/seo-pages/SitemapPage.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/utils/types.ts b/app/utils/types.ts
index d532867..8e003d7 100644
--- a/app/utils/types.ts
+++ b/app/utils/types.ts
@@ -109,3 +109,35 @@ export interface SeoSiteAnswer {
pagination?: { form?: 'query' | 'path'; param?: string; segments?: Record }
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[]
+}
diff --git a/lib/seo-module.ts b/lib/seo-module.ts
index 5344aee..08babbd 100644
--- a/lib/seo-module.ts
+++ b/lib/seo-module.ts
@@ -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({
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.
diff --git a/server/api/__seo__/sitemap.get.ts b/server/api/__seo__/sitemap.get.ts
new file mode 100644
index 0000000..cd6f325
--- /dev/null
+++ b/server/api/__seo__/sitemap.get.ts
@@ -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 = 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)
+})
diff --git a/server/lib/htmlSitemap.ts b/server/lib/htmlSitemap.ts
new file mode 100644
index 0000000..ad630f6
--- /dev/null
+++ b/server/lib/htmlSitemap.ts
@@ -0,0 +1,59 @@
+// The HTML sitemap answers of the sitemap plugin (GET
+// /_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
+ 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
+ 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) : {}
+ 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
+}
diff --git a/testing/expect-seo-down.txt b/testing/expect-seo-down.txt
index 6c850da..071bc2d 100644
--- a/testing/expect-seo-down.txt
+++ b/testing/expect-seo-down.txt
@@ -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
diff --git a/testing/expect-seo-drift.txt b/testing/expect-seo-drift.txt
index 6120e89..ca9777a 100644
--- a/testing/expect-seo-drift.txt
+++ b/testing/expect-seo-drift.txt
@@ -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=
diff --git a/testing/expect-seo-off.txt b/testing/expect-seo-off.txt
index 9745a68..9a79351 100644
--- a/testing/expect-seo-off.txt
+++ b/testing/expect-seo-off.txt
@@ -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
diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt
index 113d863..678eb09 100644
--- a/testing/expect-seo.txt
+++ b/testing/expect-seo.txt
@@ -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=Blog – strona 5 z 14 | Example contains=content="Wpisy o grzybach, strona 5 z 14" notcontains=z %pages% | notcontains=content="Wpisy o grzybach, strona 5 z %pages%"
200 /blog?page=14&sort=new contains= 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=