diff --git a/.playground/app/pages/blog/index.vue b/.playground/app/pages/blog/index.vue new file mode 100644 index 0000000..4eff60d --- /dev/null +++ b/.playground/app/pages/blog/index.vue @@ -0,0 +1,16 @@ + + + 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=