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
This commit is contained in:
Jakub Zych
2026-11-04 09:27:57 +01:00
parent d1629439c7
commit aa7480e003
9 changed files with 239 additions and 3 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
// A CMS-driven catch-all page: it renders what the SEO answer knows and is
// a 404 for anything else, as a registry page would. It sets its own title
// so the smoke can see the CMS title win, and adds a layer node to the one
// JSON-LD graph (merged by @id with the CMS's nodes).
// so the smoke can see the CMS title win, adds a layer node to the one
// JSON-LD graph (merged by @id with the CMS's nodes) and prints a label of
// the page's content locale.
const route = useRoute()
const answer = useSeoAnswer()
if (answer.value?.action !== 'ok') throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
@@ -10,9 +11,13 @@ useSeoMeta({ title: 'Own catch-all title' })
const url = answer.value.url
useSchemaNode({ '@id': `${url}#webpage`, name: 'Layer name loses', inLanguage: 'pl' })
useSchemaNode({ '@type': 'ItemList', '@id': `${url}#itemlist`, itemListElement: [] })
const labels = useSeoLabels()
const slug = computed(() => ([] as string[]).concat(route.params.slug ?? []).join('/'))
</script>
<template>
<main><h1>{{ slug }}</h1></main>
<main>
<h1>{{ slug }}</h1>
<p class="seo-visually-hidden">{{ labels.breadcrumbs.label }}</p>
</main>
</template>

8
app/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
// The layer's app config. seo.labels.<locale> overrides or adds label
// dictionaries (UI-SPEC L8, app/utils/labels.ts); an app sets its own in
// its app.config.ts, which Nuxt merges over this one.
export default defineAppConfig({
seo: {
labels: {},
},
})

View File

@@ -0,0 +1,28 @@
/* SEO layer base styles. Only .seo-* classes, never a bare element or
another layer's classes; values come from seo-tokens.css, and the app's
own CSS (loaded after the layers') wins. The breadcrumb, pagination and
HTML sitemap rules land with their components. */
/* Visually hidden, still read by screen readers (the new-tab hint inside
external links, labels of icon-only controls). */
.seo-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* The focus ring of every layer link: 2px in the accent colour. */
.seo-breadcrumbs a:focus-visible,
.seo-pagination a:focus-visible,
.seo-sitemap a:focus-visible,
.seo-sitemap-link:focus-visible {
outline: 2px solid var(--seo-color-accent);
outline-offset: 2px;
}

View File

@@ -0,0 +1,28 @@
/* SEO layer design tokens (UI-SPEC L8). Every visual value of the layer's
public components comes from these. The defaults match the journal and
menu layer defaults, so an untouched site looks consistent. An app
overrides any of them in its own CSS, e.g. on :root:root. The layer sets
no font family, heading size or body size: everything else inherits from
the site. */
:root {
/* Spacing */
--seo-space-1: 0.25rem; /* gap between pagination items; breadcrumb link padding (24px target) */
--seo-space-2: 0.5rem; /* room around the breadcrumb separator; pagination link inline padding */
--seo-space-3: 1rem; /* gap between previous, the page list and next; sitemap list gaps */
--seo-space-4: 1.5rem; /* margin under the breadcrumbs; gap between sitemap groups */
--seo-space-5: 2.5rem; /* margin above the pagination (copies --journal-space-5) */
--seo-target-size: 2.75rem; /* minimum pagination link size (WCAG 2.5.5; copies --menu-toggle-size) */
--seo-sitemap-column-width: 16rem; /* HTML sitemap column width */
/* Colour */
--seo-color-muted: #59636e; /* separators, current breadcrumb, pagination gap, sitemap counts (6.2:1 on white) */
--seo-color-accent: #0969da; /* current pagination page, focus ring of layer links */
--seo-color-on-accent: #ffffff; /* text on the accent colour (5.0:1) */
--seo-color-border: #d1d9e0; /* not used by default; reserved for themes */
/* Type */
--seo-font-size-small: 0.875em; /* breadcrumbs */
/* Breadcrumbs */
--seo-breadcrumbs-separator: '›'; /* CSS-generated, never read by screen readers */
}

View File

@@ -0,0 +1,16 @@
// The layer's labels (UI-SPEC L8) in the page's content locale: the resolve
// answer's locale, then html_lang's primary subtag, then en. An app
// overrides or adds locales in its app.config.ts:
//
// export default defineAppConfig({ seo: { labels: { pl: { sitemap: { title: 'Spis stron' } } } } })
//
// Component props override both.
import type { ComputedRef } from 'vue'
import { pickLabels, type SeoLabelOverrides, type SeoLabels } from '../utils/labels.ts'
export function useSeoLabels(): ComputedRef<SeoLabels> {
const answer = useSeoAnswer()
const appConfig = useAppConfig() as { seo?: { labels?: SeoLabelOverrides } }
return computed(() => pickLabels(answer.value?.locale, answer.value?.html_lang, appConfig.seo?.labels ?? {}))
}

87
app/utils/labels.ts Normal file
View File

@@ -0,0 +1,87 @@
// 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))
}

View File

@@ -23,6 +23,10 @@ export default defineNuxtConfig({
modules: [`${currentDir}lib/seo-module.ts`],
// Tokens first, then the base styles that use them (only .seo-* classes).
// The app's own css array loads after these, so its overrides win.
css: [`${currentDir}app/assets/css/seo-tokens.css`, `${currentDir}app/assets/css/seo-base.css`],
runtimeConfig: {
seo: {
// Server-only base for SSR calls (NUXT_SEO_API_BASE). It never comes

View File

@@ -63,3 +63,6 @@ report /kontakt
report /nieznana-strona
noreport /usuniety
noreport /stary
# Labels in the page's content locale (pl answer: the pl dictionary).
200 /blog/lwia-grzywa contains=<p class="seo-visually-hidden">Ścieżka nawigacji</p>

57
tests/labels.test.ts Normal file
View File

@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { SEO_LABELS, formatSeoLabel, pickLabels } from '../app/utils/labels.ts'
test('labels: en and pl carry every L8 key', () => {
const keys = (o: object, prefix = ''): string[] =>
Object.entries(o).flatMap(([k, v]) => (v && typeof v === 'object' ? keys(v, `${prefix}${k}.`) : [`${prefix}${k}`]))
const want = [
'breadcrumbs.label',
'pagination.label',
'pagination.previous',
'pagination.next',
'pagination.page',
'sitemap.title',
'sitemap.groupTitle',
'sitemap.pageSuffix',
'sitemap.all',
'sitemap.empty',
'sitemapLink',
'links.newTab',
].sort()
assert.deepEqual(keys(SEO_LABELS.en).sort(), want)
assert.deepEqual(keys(SEO_LABELS.pl).sort(), want)
assert.equal(SEO_LABELS.pl.breadcrumbs.label, 'Ścieżka nawigacji')
assert.equal(SEO_LABELS.en.links.newTab, '(opens in a new tab)')
})
test('pickLabels: the resolve locale first, then html_lang, then en', () => {
assert.equal(pickLabels('pl', 'en').pagination.next, 'Następna strona')
assert.equal(pickLabels('pl-PL', '').pagination.next, 'Następna strona')
assert.equal(pickLabels('', 'pl-PL').pagination.next, 'Następna strona')
assert.equal(pickLabels('de', 'pl').pagination.next, 'Następna strona')
assert.equal(pickLabels('de', 'fr').pagination.next, 'Next page')
assert.equal(pickLabels(undefined, undefined).sitemap.title, 'Sitemap')
})
test('pickLabels: app overrides change a key, add a locale and fill gaps from en', () => {
const overrides = { pl: { sitemap: { title: 'Spis stron' } }, de: { pagination: { next: 'Nächste Seite' } } }
const pl = pickLabels('pl', '', overrides)
assert.equal(pl.sitemap.title, 'Spis stron')
assert.equal(pl.sitemap.empty, 'Nie ma jeszcze stron do wyświetlenia.')
const de = pickLabels('de', '', overrides)
assert.equal(de.pagination.next, 'Nächste Seite')
assert.equal(de.pagination.previous, 'Previous page')
assert.equal(SEO_LABELS.pl.sitemap.title, 'Mapa strony', 'the built-in dictionary is not mutated')
})
test('pickLabels: a non-string override value is ignored', () => {
const l = pickLabels('en', '', { en: { sitemapLink: 42 as unknown as string, pagination: 'x' as unknown as object } })
assert.equal(l.sitemapLink, 'Sitemap')
assert.equal(l.pagination.label, 'Pagination')
})
test('formatSeoLabel: fills {name} placeholders and leaves unknown ones', () => {
assert.equal(formatSeoLabel(SEO_LABELS.pl.sitemap.all, { group: 'Wpisy', count: 12 }), 'Wpisy: wszystkie (12)')
assert.equal(formatSeoLabel('Page {page} of {pages}', { page: 2 }), 'Page 2 of {pages}')
})