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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
80
app/components/Seo/Breadcrumbs.vue
Normal file
80
app/components/Seo/Breadcrumbs.vue
Normal 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>
|
||||
@@ -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 ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
27
app/utils/breadcrumbClaim.ts
Normal file
27
app/utils/breadcrumbClaim.ts
Normal 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
26
app/utils/sitePath.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
33
tests/breadcrumbClaim.test.ts
Normal file
33
tests/breadcrumbClaim.test.ts
Normal 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'), '')
|
||||
})
|
||||
Reference in New Issue
Block a user