From aee6957bd63593751a8544f63eea6303226f549c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 12:00:00 +0100 Subject: [PATCH] feat: HTML sitemap pages and footer link - the module registers seo-sitemap- at seo.htmlSitemap.paths (defaults pl /mapa-strony, en /sitemap) and seo-sitemap-group- at /:group (plus /:group//: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 ; 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) - : the footer link while the HTML sitemap is on - L3 default styles --- .playground/app/app.vue | 4 +- app/assets/css/seo-base.css | 30 +++++ app/components/Seo/SitemapLink.vue | 26 ++++ app/components/Seo/SitemapList.vue | 24 ++++ app/components/global/SeoHtmlSitemapPage.vue | 97 ++++++++++++++ app/seo-pages/SitemapGroupPage.vue | 15 +++ app/seo-pages/SitemapPage.vue | 11 ++ app/utils/types.ts | 32 +++++ lib/seo-module.ts | 27 +++- server/api/__seo__/sitemap.get.ts | 62 +++++++++ server/lib/htmlSitemap.ts | 59 +++++++++ testing/expect-seo-down.txt | 2 + testing/expect-seo-drift.txt | 4 + testing/expect-seo-off.txt | 3 + testing/expect-seo.txt | 25 +++- testing/fixture-api.mjs | 16 +++ testing/fixtures/seo/html.json | 127 +++++++++++++++++++ tests/htmlSitemap.test.ts | 39 ++++++ 18 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 app/components/Seo/SitemapLink.vue create mode 100644 app/components/Seo/SitemapList.vue create mode 100644 app/components/global/SeoHtmlSitemapPage.vue create mode 100644 app/seo-pages/SitemapGroupPage.vue create mode 100644 app/seo-pages/SitemapPage.vue create mode 100644 server/api/__seo__/sitemap.get.ts create mode 100644 server/lib/htmlSitemap.ts create mode 100644 testing/fixtures/seo/html.json create mode 100644 tests/htmlSitemap.test.ts diff --git a/.playground/app/app.vue b/.playground/app/app.vue index 4ef9b10..c76921f 100644 --- a/.playground/app/app.vue +++ b/.playground/app/app.vue @@ -1,6 +1,8 @@ diff --git a/app/assets/css/seo-base.css b/app/assets/css/seo-base.css index efe2061..3e30f2f 100644 --- a/app/assets/css/seo-base.css +++ b/app/assets/css/seo-base.css @@ -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; +} diff --git a/app/components/Seo/SitemapLink.vue b/app/components/Seo/SitemapLink.vue new file mode 100644 index 0000000..30738c1 --- /dev/null +++ b/app/components/Seo/SitemapLink.vue @@ -0,0 +1,26 @@ + + + diff --git a/app/components/Seo/SitemapList.vue b/app/components/Seo/SitemapList.vue new file mode 100644 index 0000000..6f591de --- /dev/null +++ b/app/components/Seo/SitemapList.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/components/global/SeoHtmlSitemapPage.vue b/app/components/global/SeoHtmlSitemapPage.vue new file mode 100644 index 0000000..7cca349 --- /dev/null +++ b/app/components/global/SeoHtmlSitemapPage.vue @@ -0,0 +1,97 @@ + + + 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= notcontains=href="javascript: notcontains=", + "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" + } + ] + } + } + } + } +} diff --git a/tests/htmlSitemap.test.ts b/tests/htmlSitemap.test.ts new file mode 100644 index 0000000..834be94 --- /dev/null +++ b/tests/htmlSitemap.test.ts @@ -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) +})