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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
26
app/components/Seo/SitemapLink.vue
Normal file
26
app/components/Seo/SitemapLink.vue
Normal 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>
|
||||
24
app/components/Seo/SitemapList.vue
Normal file
24
app/components/Seo/SitemapList.vue
Normal 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>
|
||||
97
app/components/global/SeoHtmlSitemapPage.vue
Normal file
97
app/components/global/SeoHtmlSitemapPage.vue
Normal 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>
|
||||
15
app/seo-pages/SitemapGroupPage.vue
Normal file
15
app/seo-pages/SitemapGroupPage.vue
Normal 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>
|
||||
11
app/seo-pages/SitemapPage.vue
Normal file
11
app/seo-pages/SitemapPage.vue
Normal 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>
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
62
server/api/__seo__/sitemap.get.ts
Normal file
62
server/api/__seo__/sitemap.get.ts
Normal 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
59
server/lib/htmlSitemap.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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&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>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
127
testing/fixtures/seo/html.json
Normal file
127
testing/fixtures/seo/html.json
Normal 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
39
tests/htmlSitemap.test.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user