Files
vue-seo-layer/app/utils/labels.ts
Jakub Zych aa7480e003 feat: SEO labels, tokens and base styles
- useSeoLabels(): the L8 dictionary (en, pl) of the page's content locale
  (resolve locale, then html_lang, then en), overridable and extendable in
  app.config.ts under seo.labels; no i18n dependency
- seo-tokens.css (spacing, colour, small type, breadcrumb separator) and
  seo-base.css (.seo-visually-hidden, the focus ring of layer links), loaded
  before the app's CSS and styling only .seo-* classes
2026-11-04 09:27:57 +01:00

88 lines
3.8 KiB
TypeScript

// 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.<locale>, 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<string, DeepPartial<SeoLabels>>
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }
/** The built-in dictionaries. {name} marks a placeholder (formatSeoLabel). */
export const SEO_LABELS: Readonly<Record<'en' | 'pl', SeoLabels>> = 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<T>(base: T, over: unknown): T {
if (!over || typeof over !== 'object' || Array.isArray(over)) return base
const out = { ...(base as Record<string, unknown>) }
for (const [key, value] of Object.entries(over as Record<string, unknown>)) {
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<string, SeoLabels>)[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, string | number>): string {
return template.replace(/\{(\w+)\}/g, (m, name: string) => (name in values ? String(values[name]) : m))
}