// The layer's label dictionary (UI-SPEC L8): the strings of the public // layer components in en and pl, without an i18n dependency. useSeoLabels() // picks the page's content locale; an app overrides or adds locales in // app.config.ts under seo.labels., and component props override // both. Breadcrumb names and sitemap group headings come from the CMS and // are never translated here. Pure, so `node --test` drives it directly. export interface SeoLabels { breadcrumbs: { label: string } pagination: { label: string; previous: string; next: string; page: string } sitemap: { title: string; groupTitle: string; pageSuffix: string; all: string; empty: string } sitemapLink: string links: { newTab: string } } export type SeoLabelOverrides = Record> type DeepPartial = { [K in keyof T]?: T[K] extends object ? DeepPartial : T[K] } /** The built-in dictionaries. {name} marks a placeholder (formatSeoLabel). */ export const SEO_LABELS: Readonly> = Object.freeze({ en: { breadcrumbs: { label: 'Breadcrumbs' }, pagination: { label: 'Pagination', previous: 'Previous page', next: 'Next page', page: 'Page {page}' }, sitemap: { title: 'Sitemap', groupTitle: 'Sitemap: {group}', pageSuffix: 'Page {page}', all: '{group}: all ({count})', empty: 'There are no pages to list yet.', }, sitemapLink: 'Sitemap', links: { newTab: '(opens in a new tab)' }, }, pl: { breadcrumbs: { label: 'Ścieżka nawigacji' }, pagination: { label: 'Stronicowanie', previous: 'Poprzednia strona', next: 'Następna strona', page: 'Strona {page}' }, sitemap: { title: 'Mapa strony', groupTitle: 'Mapa strony: {group}', pageSuffix: 'Strona {page}', all: '{group}: wszystkie ({count})', empty: 'Nie ma jeszcze stron do wyświetlenia.', }, sitemapLink: 'Mapa strony', links: { newTab: '(otwiera się w nowej karcie)' }, }, }) /** A copy of `base` with every string of `over` at a key `base` has. */ function merge(base: T, over: unknown): T { if (!over || typeof over !== 'object' || Array.isArray(over)) return base const out = { ...(base as Record) } for (const [key, value] of Object.entries(over as Record)) { if (!(key in out)) continue const current = out[key] if (typeof current === 'string') { if (typeof value === 'string') out[key] = value } else if (current && typeof current === 'object') { out[key] = merge(current, value) } } return out as T } const primary = (code: string) => code.toLowerCase().split(/[-_]/)[0] ?? '' /** * The dictionary for a page: the first of the resolve answer's locale, its * primary subtag and html_lang's primary subtag that the layer or the app * overrides know, else en. Missing keys come from en; an app override for * the chosen locale wins key by key. */ export function pickLabels(locale: string | undefined | null, htmlLang: string | undefined | null, overrides: SeoLabelOverrides = {}): SeoLabels { const known = (code: string) => code !== '' && (code in SEO_LABELS || (overrides && Object.prototype.hasOwnProperty.call(overrides, code))) const l = (locale ?? '').toLowerCase() const candidates = [l, primary(l), primary(htmlLang ?? '')] const code = candidates.find(known) ?? 'en' const builtIn = (SEO_LABELS as Record)[code] const base = code === 'en' ? SEO_LABELS.en : merge(SEO_LABELS.en, builtIn) return merge(base, overrides?.[code]) } /** `template` with each {name} replaced by values[name]; unknown placeholders stay. */ export function formatSeoLabel(template: string, values: Record): string { return template.replace(/\{(\w+)\}/g, (m, name: string) => (name in values ? String(values[name]) : m)) }