feat: breadcrumbs component

- <SeoBreadcrumbs> (UI-SPEC L1): the resolve answer's trail as nav > ol
  with server-rendered links and the last item as aria-current text;
  nothing with fewer than two items, with page meta seo.breadcrumbs false
  or with the option seo.breadcrumbs false; props items, label,
  hideCurrent, locale
- one trail per page: the first rendered instance claims it
  (seoClaimBreadcrumbs, kept per path)
- seoSitePath: same-origin CMS URLs become paths, non-http(s) URLs are
  never links; useSeoLabels takes an optional locale
- L1 default styles with CSS-generated separators
This commit is contained in:
Jakub Zych
2026-11-04 11:59:17 +01:00
parent 3abc986f42
commit ebb0c8084e
9 changed files with 222 additions and 6 deletions

View File

@@ -1,3 +1,6 @@
<template>
<!-- A layout instance of the breadcrumbs: the catch-all page renders one
too, and only the first renders its trail.-->
<SeoBreadcrumbs />
<NuxtPage />
</template>

View File

@@ -3,7 +3,8 @@
// a 404 for anything else, as a registry page would. It sets its own title
// 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.
// the page's content locale. It also renders breadcrumbs, as a layer page
// does, next to the instance in app.vue: only one trail renders.
const route = useRoute()
const answer = useSeoAnswer()
if (answer.value?.action !== 'ok') throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
@@ -17,6 +18,7 @@ const slug = computed(() => ([] as string[]).concat(route.params.slug ?? []).joi
<template>
<main>
<SeoBreadcrumbs />
<h1>{{ slug }}</h1>
<p class="seo-visually-hidden">{{ labels.breadcrumbs.label }}</p>
</main>

View File

@@ -1,7 +1,7 @@
/* 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. */
own CSS (loaded after the layers') wins. Long names and titles wrap and
are never truncated: the full text stays in the HTML. */
/* Visually hidden, still read by screen readers (the new-tab hint inside
external links, labels of icon-only controls). */
@@ -26,3 +26,33 @@
outline: 2px solid var(--seo-color-accent);
outline-offset: 2px;
}
/* L1 breadcrumbs: a wrapping row, separators generated here (never read by
screen readers), the current page muted and bold. */
.seo-breadcrumbs {
font-size: var(--seo-font-size-small);
}
.seo-breadcrumbs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
margin: 0 0 var(--seo-space-4);
padding: 0;
}
.seo-breadcrumbs__item {
overflow-wrap: anywhere;
}
.seo-breadcrumbs__item + .seo-breadcrumbs__item::before {
content: var(--seo-breadcrumbs-separator) / '';
color: var(--seo-color-muted);
padding-inline: var(--seo-space-2);
}
.seo-breadcrumbs__link {
display: inline-block;
padding-block: var(--seo-space-1);
}
.seo-breadcrumbs__current {
color: var(--seo-color-muted);
font-weight: 600;
}

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
// The visible breadcrumb trail (UI-SPEC L1, D-86): the resolve answer's
// breadcrumbs, the same list the CMS puts in BreadcrumbList, as nav > ol
// with server-rendered links and the current page as text. Renders nothing
// with fewer than two items (the home page), when the page meta sets
// seo: { breadcrumbs: false } or when the option seo.breadcrumbs is false.
// Only the first instance rendered for a page shows its trail, so an app
// may place one in its layout while a layer's page renders one too.
// Separators are CSS-generated (seo-base.css), never DOM characters. Links
// use NuxtLink's custom slot, so only the last item carries aria-current.
import { seoClaimBreadcrumbs, seoReleaseBreadcrumbs, type SeoBreadcrumbClaim } from '../../utils/breadcrumbClaim.ts'
import { seoSitePath } from '../../utils/sitePath.ts'
import type { SeoBreadcrumb } from '../../utils/types.ts'
const props = withDefaults(
defineProps<{
/** Overrides the resolve answer's trail. */
items?: SeoBreadcrumb[] | null
/** Overrides the breadcrumbs.label label (the nav's aria-label). */
label?: string
/** Leaves the last item (the current page) out of the visible list. */
hideCurrent?: boolean
/** The labels' locale (default: the page's content locale). */
locale?: string
}>(),
{ items: null, label: '', hideCurrent: false, locale: '' },
)
const config = useRuntimeConfig()
const route = useRoute()
const answer = useSeoAnswer()
const labels = useSeoLabels(() => props.locale)
const claim = useState<SeoBreadcrumbClaim | null>('seo-layer:breadcrumbs', () => null)
const id = useId() ?? 'seo-breadcrumbs'
const trail = computed(() => {
const source = props.items ?? (answer.value?.action === 'ok' ? answer.value.breadcrumbs : null) ?? []
const origin = answer.value?.url
return (Array.isArray(source) ? source : [])
.filter((item) => item && typeof item.name === 'string' && item.name !== '')
.map((item) => ({ name: item.name, to: (typeof item.path === 'string' && item.path.startsWith('/') && !item.path.startsWith('//') ? item.path : '') || seoSitePath(item.url, origin) }))
})
const enabled = computed(() => {
const option = (config.public.seo as { breadcrumbs?: unknown } | undefined)?.breadcrumbs
const meta = (route.meta as { seo?: { breadcrumbs?: unknown } }).seo?.breadcrumbs
return option !== false && option !== 'false' && meta !== false
})
const shown = computed(() => enabled.value && trail.value.length >= 2)
const owns = ref(false)
const take = () => {
if (!shown.value) {
claim.value = seoReleaseBreadcrumbs(claim.value, id)
owns.value = false
return
}
const next = seoClaimBreadcrumbs(claim.value, route.path, id)
claim.value = next.state
owns.value = next.owns
}
take()
watch([() => route.path, shown], take)
const visible = computed(() => (props.hideCurrent ? trail.value.slice(0, -1) : trail.value))
</script>
<template>
<nav v-if="owns && shown" class="seo-breadcrumbs" :aria-label="label || labels.breadcrumbs.label">
<ol class="seo-breadcrumbs__list">
<li v-for="(item, i) in visible" :key="i" class="seo-breadcrumbs__item">
<span v-if="!hideCurrent && i === visible.length - 1" class="seo-breadcrumbs__current" aria-current="page">{{ item.name }}</span>
<NuxtLink v-else-if="item.to" v-slot="link" :to="item.to" custom>
<a class="seo-breadcrumbs__link" :href="link.href" @click="link.navigate">{{ item.name }}</a>
</NuxtLink>
<span v-else class="seo-breadcrumbs__text">{{ item.name }}</span>
</li>
</ol>
</nav>
</template>

View File

@@ -6,11 +6,19 @@
//
// Component props override both.
import type { ComputedRef } from 'vue'
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
import { pickLabels, type SeoLabelOverrides, type SeoLabels } from '../utils/labels.ts'
export function useSeoLabels(): ComputedRef<SeoLabels> {
/**
* The labels of the page's content locale, or of `locale` when given and
* not empty (the HTML sitemap pages know theirs from the route).
*/
export function useSeoLabels(locale?: MaybeRefOrGetter<string | undefined | null>): 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 ?? {}))
return computed(() => {
const own = toValue(locale)
if (own) return pickLabels(own, '', appConfig.seo?.labels ?? {})
return pickLabels(answer.value?.locale, answer.value?.html_lang, appConfig.seo?.labels ?? {})
})
}

View File

@@ -0,0 +1,27 @@
// One visible breadcrumb trail per page (UI-SPEC L1): an app may place
// <SeoBreadcrumbs> in its layout while a layer's page renders one too; the
// first instance that renders in a request claims the page and later ones
// render nothing. The claim is kept per path, so a client navigation hands
// it out again (the layout instance, set up first, claims first). Pure, so
// `node --test` drives it; names start with `seo` (app/utils is
// auto-imported).
export interface SeoBreadcrumbClaim {
path: string
id: string
}
/**
* Whether instance `id` owns the trail of `path`, and the claim to keep:
* no claim or one for another path is taken over; the claim of this path
* belongs to the instance that made it.
*/
export function seoClaimBreadcrumbs(state: SeoBreadcrumbClaim | null | undefined, path: string, id: string): { state: SeoBreadcrumbClaim; owns: boolean } {
if (!state || state.path !== path) return { state: { path, id }, owns: true }
return { state, owns: state.id === id }
}
/** Releases the claim of `id` (an instance that stops rendering its trail). */
export function seoReleaseBreadcrumbs(state: SeoBreadcrumbClaim | null | undefined, id: string): SeoBreadcrumbClaim | null {
return state && state.id === id ? null : (state ?? null)
}

26
app/utils/sitePath.ts Normal file
View File

@@ -0,0 +1,26 @@
// Links to the site's own pages from absolute CMS URLs: a URL on the site
// URL's origin (or on the request origin) becomes its path, so it renders
// as a NuxtLink; another http(s) URL stays as it is, and anything else
// (javascript:, data: …) becomes '' so it is never rendered as a link.
// Pure; names start with `seo` because app/utils is auto-imported.
export function seoSitePath(url: string, ...origins: Array<string | undefined | null>): string {
if (typeof url !== 'string' || url === '') return ''
if (url.startsWith('/') && !url.startsWith('//')) return url
let parsed: URL
try {
parsed = new URL(url)
} catch {
return ''
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return ''
for (const o of origins) {
if (!o) continue
try {
if (new URL(o).origin === parsed.origin) return `${parsed.pathname}${parsed.search}` || '/'
} catch {
// not a URL
}
}
return url
}

View File

@@ -84,3 +84,10 @@ noreport /stary
# noindex route. The build matches the site answer: no drift warning.
routereport 1 has=/ has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne
log [vue-seo-layer] drift::0
# L1 breadcrumbs: a layout instance (app.vue) and a page instance render
# exactly one trail, server-rendered links, the last item as text with
# aria-current; none on the home page (one item) or a page without a trail.
200 /blog/lwia-grzywa count=<nav class="seo-breadcrumbs":1 contains=<a class="seo-breadcrumbs__link" href="/">Start</a> contains=<span class="seo-breadcrumbs__current" aria-current="page">Lwia grzywa</span> count=aria-current="page":1
200 / count=seo-breadcrumbs:0
200 /plain count=seo-breadcrumbs:0

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { seoClaimBreadcrumbs, seoReleaseBreadcrumbs } from '../app/utils/breadcrumbClaim.ts'
import { seoSitePath } from '../app/utils/sitePath.ts'
test('seoClaimBreadcrumbs: the first instance of a path owns the trail (layout plus page: one trail)', () => {
const layout = seoClaimBreadcrumbs(null, '/blog/x', 'layout')
assert.equal(layout.owns, true)
const page = seoClaimBreadcrumbs(layout.state, '/blog/x', 'page')
assert.equal(page.owns, false)
assert.deepEqual(page.state, { path: '/blog/x', id: 'layout' })
assert.equal(seoClaimBreadcrumbs(page.state, '/blog/x', 'layout').owns, true, 'a re-render keeps the claim')
})
test('seoClaimBreadcrumbs: a new path hands the claim out again', () => {
const first = seoClaimBreadcrumbs({ path: '/a', id: 'page-a' }, '/b', 'layout')
assert.equal(first.owns, true)
assert.deepEqual(first.state, { path: '/b', id: 'layout' })
})
test('seoReleaseBreadcrumbs: only the owner releases', () => {
assert.equal(seoReleaseBreadcrumbs({ path: '/a', id: 'x' }, 'x'), null)
assert.deepEqual(seoReleaseBreadcrumbs({ path: '/a', id: 'x' }, 'y'), { path: '/a', id: 'x' })
})
test('seoSitePath: same-origin URLs become paths', () => {
assert.equal(seoSitePath('https://example.test/blog/x?a=1', 'https://example.test'), '/blog/x?a=1')
assert.equal(seoSitePath('https://example.test/', 'https://example.test/'), '/')
assert.equal(seoSitePath('https://other.test/x', 'https://example.test'), 'https://other.test/x')
assert.equal(seoSitePath('/x', undefined), '/x')
assert.equal(seoSitePath('javascript:alert(1)', 'https://example.test'), '')
assert.equal(seoSitePath('not a url', 'https://example.test'), '')
})