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:
16
.playground/app/pages/blog/index.vue
Normal file
16
.playground/app/pages/blog/index.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
56
app/components/Seo/Pagination.vue
Normal file
56
app/components/Seo/Pagination.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
71
app/composables/usePagination.ts
Normal file
71
app/composables/usePagination.ts
Normal 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
51
app/utils/pageHref.ts
Normal 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
|
||||
}
|
||||
35
app/utils/paginationWindow.ts
Normal file
35
app/utils/paginationWindow.ts
Normal 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
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&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
|
||||
|
||||
@@ -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
47
tests/pageHref.test.ts
Normal 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)
|
||||
})
|
||||
19
tests/paginationWindow.test.ts
Normal file
19
tests/paginationWindow.test.ts
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user