+
+
+
diff --git a/app/assets/css/seo-base.css b/app/assets/css/seo-base.css
index b80a7e6..efe2061 100644
--- a/app/assets/css/seo-base.css
+++ b/app/assets/css/seo-base.css
@@ -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);
+}
diff --git a/app/components/Seo/Pagination.vue b/app/components/Seo/Pagination.vue
new file mode 100644
index 0000000..c656f93
--- /dev/null
+++ b/app/components/Seo/Pagination.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
diff --git a/app/composables/useCmsSeo.ts b/app/composables/useCmsSeo.ts
index b568c78..547e4bd 100644
--- a/app/composables/useCmsSeo.ts
+++ b/app/composables/useCmsSeo.ts
@@ -2,7 +2,8 @@
// canonical, Open Graph (with og:locale and its alternates), X/Twitter,
// verification meta, , 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): 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
diff --git a/app/composables/usePagination.ts b/app/composables/usePagination.ts
new file mode 100644
index 0000000..3d6c111
--- /dev/null
+++ b/app/composables/usePagination.ts
@@ -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 and 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('seo-layer:pages', () => null)
+}
+
+const primary = (code: string | undefined | null) => (code ?? '').toLowerCase().split(/[-_]/)[0] ?? ''
+
+export interface UsePaginationOptions {
+ current: MaybeRefOrGetter
+ last: MaybeRefOrGetter
+ /** The listing's locale, for the path segment (default: the page's content locale). */
+ locale?: MaybeRefOrGetter
+}
+
+export function usePagination(opts: UsePaginationOptions) {
+ const route = useRoute()
+ const answer = useSeoAnswer()
+ const config = useRuntimeConfig()
+ const { data: site } = useNuxtData(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 }
+}
diff --git a/app/utils/pageHref.ts b/app/utils/pageHref.ts
new file mode 100644
index 0000000..f061223
--- /dev/null
+++ b/app/utils/pageHref.ts
@@ -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
+}
diff --git a/app/utils/paginationWindow.ts b/app/utils/paginationWindow.ts
new file mode 100644
index 0000000..7d38d8c
--- /dev/null
+++ b/app/utils/paginationWindow.ts
@@ -0,0 +1,35 @@
+// The page links of (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
+}
diff --git a/app/utils/seo.ts b/app/utils/seo.ts
index 7cb83ee..99ac31e 100644
--- a/app/utils/seo.ts
+++ b/app/utils/seo.ts
@@ -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 or calls usePagination() sets it.
+ */
+export function seoFillPages }>(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)),
+ }
+}
diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt
index f977d09..113d863 100644
--- a/testing/expect-seo.txt
+++ b/testing/expect-seo.txt
@@ -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=