Files
vue-seo-layer/server/lib/htmlSitemap.ts
Jakub Zych aee6957bd6 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
2026-11-04 12:00:00 +01:00

60 lines
2.6 KiB
TypeScript

// 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
}