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:
@@ -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
8
app/app.config.ts
Normal 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: {},
|
||||
},
|
||||
})
|
||||
28
app/assets/css/seo-base.css
Normal file
28
app/assets/css/seo-base.css
Normal 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;
|
||||
}
|
||||
28
app/assets/css/seo-tokens.css
Normal file
28
app/assets/css/seo-tokens.css
Normal 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 */
|
||||
}
|
||||
16
app/composables/useSeoLabels.ts
Normal file
16
app/composables/useSeoLabels.ts
Normal 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
87
app/utils/labels.ts
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
57
tests/labels.test.ts
Normal 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}')
|
||||
})
|
||||
Reference in New Issue
Block a user