- 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
60 lines
2.6 KiB
TypeScript
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
|
|
}
|