diff --git a/.playground/app/pages/[...slug].vue b/.playground/app/pages/[...slug].vue index 4611653..bf6a46f 100644 --- a/.playground/app/pages/[...slug].vue +++ b/.playground/app/pages/[...slug].vue @@ -1,8 +1,9 @@ diff --git a/app/app.config.ts b/app/app.config.ts new file mode 100644 index 0000000..6621b19 --- /dev/null +++ b/app/app.config.ts @@ -0,0 +1,8 @@ +// The layer's app config. seo.labels. 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: {}, + }, +}) diff --git a/app/assets/css/seo-base.css b/app/assets/css/seo-base.css new file mode 100644 index 0000000..3ca4513 --- /dev/null +++ b/app/assets/css/seo-base.css @@ -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; +} diff --git a/app/assets/css/seo-tokens.css b/app/assets/css/seo-tokens.css new file mode 100644 index 0000000..b5c2d62 --- /dev/null +++ b/app/assets/css/seo-tokens.css @@ -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 */ +} diff --git a/app/composables/useSeoLabels.ts b/app/composables/useSeoLabels.ts new file mode 100644 index 0000000..d111d0a --- /dev/null +++ b/app/composables/useSeoLabels.ts @@ -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 { + const answer = useSeoAnswer() + const appConfig = useAppConfig() as { seo?: { labels?: SeoLabelOverrides } } + return computed(() => pickLabels(answer.value?.locale, answer.value?.html_lang, appConfig.seo?.labels ?? {})) +} diff --git a/app/utils/labels.ts b/app/utils/labels.ts new file mode 100644 index 0000000..0ac778a --- /dev/null +++ b/app/utils/labels.ts @@ -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., 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)) +} diff --git a/nuxt.config.ts b/nuxt.config.ts index bbb59d7..1052507 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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 diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt index 8892571..2ebdf93 100644 --- a/testing/expect-seo.txt +++ b/testing/expect-seo.txt @@ -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=

Ścieżka nawigacji

diff --git a/tests/labels.test.ts b/tests/labels.test.ts new file mode 100644 index 0000000..177ba25 --- /dev/null +++ b/tests/labels.test.ts @@ -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}') +})