feat: pagination component and composable

- <SeoPagination> (UI-SPEC L2): previous and next (left out at the
  ends), page 1, the last page and five pages around the current one with
  gap items; nothing when last <= 1; props current, last, labels,
  classPrefix, locale
- usePagination(): hrefs in the CMS form (resolve answer, then site
  answer, then seo.pagination), other query parameters kept, page 1
  unnumbered, <link rel=prev/next> in the head
- the CMS head fills %pages% in the title and description once the
  listing's page count is known (seoFillPages)
- seoPaginationWindow, seoPageHref, seoPageNumber; L2 default styles
This commit is contained in:
Jakub Zych
2026-11-04 11:59:51 +01:00
parent ebb0c8084e
commit f2d13d6264
12 changed files with 459 additions and 4 deletions

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
// A listing with numbered pages: 14 pages, or ?pages=N for another count
// (?pages=1 renders no pagination). The CMS answers the title with %pages%
// on page 2 and later; usePagination (through <SeoPagination>) fills it.
const route = useRoute()
const last = computed(() => Math.max(1, Number(route.query.pages ?? 14) || 1))
const current = computed(() => seoPageNumber(route.fullPath) ?? 1)
useSeoMeta({ title: 'Own blog title' })
</script>
<template>
<main>
<h1>Blog</h1>
<SeoPagination :current="current" :last="last" />
</main>
</template>

View File

@@ -56,3 +56,43 @@
color: var(--seo-color-muted);
font-weight: 600;
}
/* L2 pagination: previous, the page list and next in a wrapping row;
44px targets; the current page on the accent colour. */
.seo-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--seo-space-3);
margin-top: var(--seo-space-5);
}
.seo-pagination__pages {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--seo-space-1);
list-style: none;
margin: 0;
padding: 0;
}
.seo-pagination__prev,
.seo-pagination__next,
.seo-pagination__pages a,
.seo-pagination__current,
.seo-pagination__gap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--seo-target-size);
min-height: var(--seo-target-size);
padding-inline: var(--seo-space-2);
box-sizing: border-box;
}
.seo-pagination__current {
background: var(--seo-color-accent);
color: var(--seo-color-on-accent);
font-weight: 600;
}
.seo-pagination__gap {
color: var(--seo-color-muted);
}

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
// Numbered, server-rendered page links (UI-SPEC L2, D-40, D-42): previous
// and next (left out at the ends), page 1, the last page and five pages
// around the current one with gap items, in the CMS-chosen URL form
// (usePagination, which also adds <link rel="prev/next"> to the head and
// fills %pages% in the CMS title). Renders nothing when last ≤ 1.
// classPrefix renames every class, so a layer keeps its own markup classes
// (vue-journal-layer renders it with class-prefix="journal-pagination").
// Links render through NuxtLink's custom slot: client navigation without
// the router's active classes and aria-current, which would otherwise mark
// every page of the same path (the router ignores the query).
import { formatSeoLabel, type SeoLabels } from '../../utils/labels.ts'
import { SEO_PAGINATION_GAP } from '../../utils/paginationWindow.ts'
const props = withDefaults(
defineProps<{
current: number
last: number
/** Overrides the pagination.* labels; page takes {page}. */
labels?: Partial<SeoLabels['pagination']>
classPrefix?: string
/** The listing's locale (labels and the path segment; default: the page's content locale). */
locale?: string
}>(),
{ labels: () => ({}), classPrefix: 'seo-pagination', locale: '' },
)
const dictionary = useSeoLabels(() => props.locale)
const l = computed(() => ({ ...dictionary.value.pagination, ...Object.fromEntries(Object.entries(props.labels ?? {}).filter(([, v]) => typeof v === 'string' && v !== '')) }))
const { current, last, items, href } = usePagination({ current: () => props.current, last: () => props.last, locale: () => props.locale })
const c = (part: string) => `${props.classPrefix}__${part}`
</script>
<template>
<nav v-if="last > 1" :class="classPrefix" :aria-label="l.label">
<NuxtLink v-if="current > 1" v-slot="link" :to="href(current - 1)" custom>
<a :class="c('prev')" :href="link.href" rel="prev" @click="link.navigate">{{ l.previous }}</a>
</NuxtLink>
<ol :class="c('pages')">
<template v-for="(item, i) in items" :key="item === SEO_PAGINATION_GAP ? `gap-${i}` : item">
<li v-if="item === SEO_PAGINATION_GAP" :class="c('gap')" aria-hidden="true">…</li>
<li v-else-if="item === current">
<span :class="c('current')" aria-current="page">{{ item }}</span>
</li>
<li v-else>
<NuxtLink v-slot="link" :to="href(item)" custom>
<a :href="link.href" :aria-label="formatSeoLabel(l.page, { page: item })" @click="link.navigate">{{ item }}</a>
</NuxtLink>
</li>
</template>
</ol>
<NuxtLink v-if="current < last" v-slot="link" :to="href(current + 1)" custom>
<a :class="c('next')" :href="link.href" rel="next" @click="link.navigate">{{ l.next }}</a>
</NuxtLink>
</nav>
</template>

View File

@@ -2,7 +2,8 @@
// canonical, Open Graph (with og:locale and its alternates), X/Twitter,
// verification meta, <html lang>, hreflang links and exactly one JSON-LD
// script holding one @graph (the CMS nodes plus those added through
// useSchemaNode, merged by @id). One entry with tagPriority 'critical', so
// useSchemaNode, merged by @id). A listing's %pages% in the CMS title and
// description becomes its number of pages once usePagination() knows it. One entry with tagPriority 'critical', so
// its tags win over the same tags a page, a layer or a module sets; a value
// the answer lacks adds no tag and the page's own stays. The layer's app
// plugin (seo-head) is the only caller: pages and other layers never set CMS
@@ -10,15 +11,16 @@
import type { Ref } from 'vue'
import { mergeGraph } from '../utils/graph.ts'
import { seoHeadInput } from '../utils/seo.ts'
import { seoFillPages, seoHeadInput } from '../utils/seo.ts'
import type { SeoResolveAnswer } from '../utils/types.ts'
export function useCmsSeo(answer: Ref<SeoResolveAnswer | null>): void {
const origin = useRequestURL().origin
const nodes = useSchemaNodes()
const pages = useSeoPages()
useHead(
computed(() => {
const head = seoHeadInput(answer.value, origin)
const head = seoFillPages(seoHeadInput(answer.value, origin), pages.value)
const cmsGraph = answer.value?.action === 'ok' ? answer.value.jsonld : null
const graph = mergeGraph(cmsGraph, nodes())
// unhead serializes the object and escapes "<" inside it, so a value

View File

@@ -0,0 +1,71 @@
// Page links of a listing in the CMS-chosen URL form (D-40, D-42, UI-SPEC
// L2). The form comes from the resolve answer's pagination (a request with
// a page number), else the site answer's (when useSeoSite() ran on this
// page), else the layer's seo.pagination option; the path segment is the
// one of the page's locale. Page 1 is the unnumbered URL; the other query
// parameters stay.
//
// It also adds <link rel="prev"> and <link rel="next"> to the head and
// hands the number of pages to the CMS head (useCmsSeo), which replaces
// %pages% in the CMS title and description (D-41). Robots, canonical and
// the title and description suffixes come from the resolver.
//
// const { items, href } = usePagination({ current: page, last: lastPage })
import type { MaybeRefOrGetter } from 'vue'
import { paginationOption } from '../../lib/options.ts'
import { seoPageHref, type SeoPaginationForm } from '../utils/pageHref.ts'
import { seoPaginationWindow } from '../utils/paginationWindow.ts'
import type { SeoSiteAnswer } from '../utils/types.ts'
import { SEO_SITE_KEY } from './useSeoSite.ts'
/** The number of pages of the current listing, for the CMS head's %pages%. */
export function useSeoPages() {
return useState<number | null>('seo-layer:pages', () => null)
}
const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
export interface UsePaginationOptions {
current: MaybeRefOrGetter<number>
last: MaybeRefOrGetter<number>
/** The listing's locale, for the path segment (default: the page's content locale). */
locale?: MaybeRefOrGetter<string | undefined>
}
export function usePagination(opts: UsePaginationOptions) {
const route = useRoute()
const answer = useSeoAnswer()
const config = useRuntimeConfig()
const { data: site } = useNuxtData<SeoSiteAnswer | null>(SEO_SITE_KEY)
const option = paginationOption((config.public.seo as { pagination?: unknown } | undefined)?.pagination)
const current = computed(() => Math.max(1, Math.trunc(Number(toValue(opts.current)) || 1)))
const last = computed(() => Math.max(0, Math.trunc(Number(toValue(opts.last)) || 0)))
const form = computed((): { form: SeoPaginationForm; param: string; segment: string } => {
const locale = toValue(opts.locale) || answer.value?.locale || primary(answer.value?.html_lang) || site.value?.default_locale || ''
const a = answer.value?.pagination
if (a && (a.form === 'query' || a.form === 'path')) return { form: a.form, param: a.param || option.param, segment: a.segment || '' }
const s = site.value?.pagination
if (s && (s.form === 'query' || s.form === 'path')) return { form: s.form, param: s.param || option.param, segment: s.segments?.[locale] || s.segments?.[primary(locale)] || '' }
return { form: option.form, param: option.param, segment: option.segments[locale] || option.segments[primary(locale)] || '' }
})
const href = (page: number) => seoPageHref(route.fullPath, page, form.value.form, form.value.segment, form.value.param)
const items = computed(() => seoPaginationWindow(current.value, last.value))
const pages = useSeoPages()
watch(last, (n) => (pages.value = n > 0 ? n : null), { immediate: true })
useHead(
computed(() => {
const link: Array<{ rel: string; href: string; key: string }> = []
if (last.value > 1 && current.value > 1 && current.value <= last.value) link.push({ rel: 'prev', href: href(current.value - 1), key: 'seo-pagination-prev' })
if (last.value > 1 && current.value < last.value) link.push({ rel: 'next', href: href(current.value + 1), key: 'seo-pagination-next' })
return { link }
}),
)
return { current, last, items, href, form }
}

51
app/utils/pageHref.ts Normal file
View File

@@ -0,0 +1,51 @@
// Page URLs in the CMS-chosen pagination form (D-40, UI-SPEC L2): query
// (/blog?page=3) or path (/blog/strona/3, the segment per locale). Page 1
// is always the unnumbered URL; other query parameters stay. Built with
// URLSearchParams, never by string concatenation of request input, and
// rendered through Vue bindings (T-33-35). Pure, so `node --test` drives
// it; names start with `seo` because app/utils is auto-imported.
export type SeoPaginationForm = 'query' | 'path'
const PAGE_PATH = /^(.*?)\/([^/]+)\/(\d+)$/
/**
* The URL of page `page` for the listing at `url` (a path with an optional
* query; a page number already in it, in either form, is replaced). `param`
* is the query parameter, `segment` the path segment of the page's locale
* (the param when empty).
*/
export function seoPageHref(url: string, page: number, form: SeoPaginationForm = 'query', segment = '', param = 'page'): string {
const [rawPath = '/', rawQuery = ''] = (url.split('#')[0] ?? '').split('?')
const query = new URLSearchParams(rawQuery)
query.delete(param)
const seg = segment || param
let path = rawPath || '/'
const numbered = PAGE_PATH.exec(path)
if (form === 'path' && numbered && numbered[2] === seg) path = numbered[1] || '/'
if (form === 'path' && page > 1) path = `${path.replace(/\/+$/, '')}/${encodeURIComponent(seg)}/${page}`
if (form === 'query' && page > 1) query.set(param, String(page))
const qs = query.toString()
return qs ? `${path}?${qs}` : path
}
/**
* The page number a URL carries in the given form: 1 without one, the
* number for a positive integer, null for anything else (a 404).
*/
export function seoPageNumber(url: string, form: SeoPaginationForm = 'query', segment = '', param = 'page'): number | null {
const [rawPath = '/', rawQuery = ''] = (url.split('#')[0] ?? '').split('?')
let raw: string | null = null
if (form === 'path') {
const m = PAGE_PATH.exec(rawPath)
if (m && m[2] === (segment || param)) raw = m[3] ?? null
} else {
const values = new URLSearchParams(rawQuery).getAll(param)
if (values.length > 1) return null
raw = values[0] ?? null
}
if (raw === null) return 1
if (!/^\d+$/.test(raw)) return null
const n = Number(raw)
return Number.isSafeInteger(n) && n >= 1 ? n : null
}

View File

@@ -0,0 +1,35 @@
// The page links of <SeoPagination> (UI-SPEC L2, D-42): five consecutive
// pages around the current one, plus page 1 and the last page when they
// fall outside, with a gap item where pages are skipped, so every page is
// within two hops of page 1 for a crawler. Pure, so `node --test` drives
// it. Names start with `seo` because app/utils is auto-imported into the
// consuming app (vue-journal-layer has its own paginationWindow).
export const SEO_PAGINATION_GAP = 'gap' as const
export type SeoPaginationItem = number | typeof SEO_PAGINATION_GAP
/**
* The items for page `current` of `last`: page numbers in order and 'gap'
* between page 1 or the last page and the window when pages are skipped.
* Empty when there is nothing to paginate (last < 1).
*/
export function seoPaginationWindow(current: number, last: number, size = 5): SeoPaginationItem[] {
if (!Number.isInteger(last) || last < 1) return []
const cur = Math.min(Math.max(Number.isInteger(current) ? current : 1, 1), last)
const width = Math.min(Math.max(1, size), last)
let start = Math.max(1, cur - Math.floor(width / 2))
if (start + width - 1 > last) start = last - width + 1
const end = start + width - 1
const out: SeoPaginationItem[] = []
if (start > 1) {
out.push(1)
if (start > 2) out.push(SEO_PAGINATION_GAP)
}
for (let p = start; p <= end; p++) out.push(p)
if (end < last) {
if (end < last - 1) out.push(SEO_PAGINATION_GAP)
out.push(last)
}
return out
}

View File

@@ -174,3 +174,19 @@ export function seoVerificationName(path: string, reserved: readonly string[]):
if (!m || !m[1]) return null
return reserved.includes(m[1].toLowerCase()) ? null : m[1]
}
/**
* The head with the CMS's %pages% placeholder (the number of pages of a
* listing, which only the frontend knows; D-41) replaced in the title and
* every meta content. Unchanged while `pages` is unknown (null): only a
* page that renders <SeoPagination> or calls usePagination() sets it.
*/
export function seoFillPages<T extends { title?: string; meta: Array<{ content: string }> }>(head: T, pages: number | null | undefined): T {
if (typeof pages !== 'number' || !Number.isInteger(pages) || pages < 1) return head
const fill = (v: string) => v.split('%pages%').join(String(pages))
return {
...head,
...(typeof head.title === 'string' ? { title: fill(head.title) } : {}),
meta: head.meta.map((m) => (m.content.includes('%pages%') ? { ...m, content: fill(m.content) } : m)),
}
}

View File

@@ -82,7 +82,7 @@ noreport /stary
# The route report at server start: the static paths with the token, not
# the signed-in page, the page with a noindex seo.robots or the declared
# noindex route. The build matches the site answer: no drift warning.
routereport 1 has=/ has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne
routereport 1 has=/ has=/blog 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
@@ -91,3 +91,14 @@ log [vue-seo-layer] drift::0
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
# L2 pagination: numbered links in the CMS form, page 1 unnumbered, five
# pages around the current one plus the first and the last with two gaps,
# rel prev/next anchors and head links, and %pages% filled in the CMS title
# and description (the payload keeps the raw answer); one page renders
# none. aria-current: the current page and the breadcrumbs' last item.
200 /blog?page=5 count=<nav class="seo-pagination":1 contains=<a class="seo-pagination__prev" href="/blog?page=4" rel="prev"> contains=<a class="seo-pagination__next" href="/blog?page=6" rel="next"> contains=<span class="seo-pagination__current" aria-current="page">5</span> count=aria-current="page":2
200 /blog?page=5 contains=<a href="/blog" aria-label="Strona 1">1</a> contains=<a href="/blog?page=14" aria-label="Strona 14">14</a> count=seo-pagination__gap:2
200 /blog?page=5 contains=<link rel="prev" href="/blog?page=4" contains=<link rel="next" href="/blog?page=6" contains=<title>Blog – strona 5 z 14 | Example</title> contains=content="Wpisy o grzybach, strona 5 z 14" notcontains=z %pages% |</title> notcontains=content="Wpisy o grzybach, strona 5 z %pages%"
200 /blog?page=14&sort=new contains=<a class="seo-pagination__prev" href="/blog?sort=new&amp;page=13" rel="prev"> count=seo-pagination__next:0 count=rel="next":0
200 /blog?pages=1 count=seo-pagination:0 count=rel="prev":0

View File

@@ -179,5 +179,96 @@
"path": "/blog/lwia-grzywa"
}
]
},
"/blog": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog",
"locale": "pl",
"staging": false,
"seo": {
"title": "Blog | Example",
"description": "Wpisy o grzybach",
"canonical": "https://example.test/blog",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Blog | Example",
"description": "Wpisy o grzybach",
"image": "",
"url": "https://example.test/blog",
"locale": "pl_PL",
"locale_alternates": []
},
"twitter": {
"card": "summary",
"site": ""
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
}
]
},
"/blog?page=5": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog?page=5",
"locale": "pl",
"staging": false,
"seo": {
"title": "Blog – strona 5 z %pages% | Example",
"description": "Wpisy o grzybach, strona 5 z %pages%",
"canonical": "https://example.test/blog?page=5",
"robots": "index, follow",
"x_robots_tag": "",
"og": {
"title": "Blog – strona 5 z %pages% | Example",
"description": "Wpisy o grzybach, strona 5 z %pages%",
"image": "",
"url": "https://example.test/blog?page=5",
"locale": "pl_PL",
"locale_alternates": []
},
"twitter": {
"card": "summary",
"site": ""
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
}
],
"pagination": {
"form": "query",
"param": "page",
"segment": "",
"page": 5,
"invalid": false
}
}
}

47
tests/pageHref.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { seoPageHref, seoPageNumber } from '../app/utils/pageHref.ts'
import { seoFillPages } from '../app/utils/seo.ts'
test('seoPageHref: the UI-SPEC L2 examples', () => {
assert.equal(seoPageHref('/blog', 1, 'query'), '/blog')
assert.equal(seoPageHref('/blog?sort=new', 3, 'query'), '/blog?sort=new&page=3')
assert.equal(seoPageHref('/blog', 3, 'path', 'strona'), '/blog/strona/3')
assert.equal(seoPageHref('/en/blog', 1, 'path', 'page'), '/en/blog')
})
test('seoPageHref: a page already in the URL is replaced', () => {
assert.equal(seoPageHref('/blog?page=5&sort=new', 6, 'query'), '/blog?sort=new&page=6')
assert.equal(seoPageHref('/blog?page=5', 1, 'query'), '/blog')
assert.equal(seoPageHref('/blog/strona/5', 4, 'path', 'strona'), '/blog/strona/4')
assert.equal(seoPageHref('/blog/strona/5?sort=new', 1, 'path', 'strona'), '/blog?sort=new')
assert.equal(seoPageHref('/strona/5', 1, 'path', 'strona'), '/')
assert.equal(seoPageHref('/docs/page/3', 2, 'query'), '/docs/page/3?page=2', 'a path segment is left alone in the query form')
assert.equal(seoPageHref('/blog?p=2', 3, 'query', '', 'p'), '/blog?p=3')
assert.equal(seoPageHref('/blog', 2, 'path'), '/blog/page/2', 'the param is the segment when none is given')
})
test('seoPageHref: query values are encoded, never concatenated raw', () => {
assert.equal(seoPageHref('/blog?q=%22%3E%3Cscript%3E', 2, 'query'), '/blog?q=%22%3E%3Cscript%3E&page=2')
assert.equal(seoPageHref('/blog#top', 2, 'query'), '/blog?page=2')
})
test('seoPageNumber: 1 without a page, null for an invalid one', () => {
assert.equal(seoPageNumber('/blog'), 1)
assert.equal(seoPageNumber('/blog?page=3'), 3)
assert.equal(seoPageNumber('/blog?page=abc'), null)
assert.equal(seoPageNumber('/blog?page=0'), null)
assert.equal(seoPageNumber('/blog?page=1&page=2'), null)
assert.equal(seoPageNumber('/blog/strona/4', 'path', 'strona'), 4)
assert.equal(seoPageNumber('/blog/strona/x', 'path', 'strona'), 1)
})
test('seoFillPages: %pages% in the title and meta, only once the count is known', () => {
const head = { title: 'Blog – strona 5 z %pages%', meta: [{ name: 'description', content: 'strona 5 z %pages%' }, { name: 'robots', content: 'index' }] }
const out = seoFillPages(head, 14)
assert.equal(out.title, 'Blog – strona 5 z 14')
assert.equal(out.meta[0]!.content, 'strona 5 z 14')
assert.equal(out.meta[1], head.meta[1])
assert.equal(seoFillPages(head, null), head)
assert.equal(seoFillPages(head, 0), head)
})

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { seoPaginationWindow } from '../app/utils/paginationWindow.ts'
test('seoPaginationWindow: the UI-SPEC L2 examples', () => {
assert.deepEqual(seoPaginationWindow(5, 14), [1, 'gap', 3, 4, 5, 6, 7, 'gap', 14])
assert.deepEqual(seoPaginationWindow(1, 3), [1, 2, 3])
assert.deepEqual(seoPaginationWindow(14, 14), [1, 'gap', 10, 11, 12, 13, 14])
})
test('seoPaginationWindow: near the ends and small listings', () => {
assert.deepEqual(seoPaginationWindow(1, 14), [1, 2, 3, 4, 5, 'gap', 14])
assert.deepEqual(seoPaginationWindow(4, 14), [1, 2, 3, 4, 5, 6, 'gap', 14], 'no gap when only page 1 lies outside')
assert.deepEqual(seoPaginationWindow(11, 14), [1, 'gap', 9, 10, 11, 12, 13, 14])
assert.deepEqual(seoPaginationWindow(1, 1), [1])
assert.deepEqual(seoPaginationWindow(3, 6), [1, 2, 3, 4, 5, 6])
assert.deepEqual(seoPaginationWindow(1, 0), [])
assert.deepEqual(seoPaginationWindow(99, 3), [1, 2, 3], 'the current page is clamped')
})