feat: ItemList nodes on listing pages

The index, category, tag and author pages add an ItemList node
(@id <page URL>#itemlist, a ListItem with the absolute URL of each post
shown, positions continuing across pages) through vue-seo-layer's
useSchemaNode, so the page keeps one JSON-LD script with one @graph
(useJournalItemList, journalItemList).
This commit is contained in:
Jakub Zych
2026-11-04 13:55:55 +01:00
parent 98904b8fbd
commit 642c25a0d0
8 changed files with 74 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
// The ItemList node of a listing page (index, category, tag, author; D-43):
// the posts shown, added to the page's one JSON-LD @graph through
// vue-seo-layer's useSchemaNode, so the page still has one JSON-LD script.
// The URLs are absolute on the CMS's site URL when the page has a resolve
// answer, else on the request's origin.
import type { JournalPageMeta, JournalPost } from '../utils/types.ts'
import { journalItemList, journalSiteOrigin } from '../utils/seo.ts'
export function useJournalItemList(posts: () => readonly JournalPost[] | null | undefined, meta: () => JournalPageMeta | null | undefined): void {
const answer = useSeoAnswer()
const route = useRoute()
const links = useJournalLinks()
const request = useRequestURL()
useSchemaNode(() => {
const a = answer.value?.action === 'ok' ? answer.value : null
const origin = journalSiteOrigin(a?.url, request.origin)
const pageUrl = a?.url && /^https?:\/\//i.test(a.url) ? a.url : `${origin}${route.fullPath}`
const m = meta()
const first = m ? (Math.max(1, m.current_page) - 1) * Math.max(1, m.per_page) + 1 : 1
return journalItemList(pageUrl, origin, (posts() ?? []).map((p) => links.postLink(p.slug)), first)
})
}

View File

@@ -40,6 +40,7 @@ const name = `${author.first_name} ${author.last_name}`.trim() || author.slug
const title = listTitle(t('journal.author.title', { name }), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title, ogType: 'profile' })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
if (author.slug !== param) {
const path = links.authorLink(author.slug)

View File

@@ -46,6 +46,7 @@ const title = listTitle(data.value.category.name, page, t('journal.pagination.pa
const description = data.value.category.description ?? undefined
useSeoMeta({ title, ogTitle: title, description, ogDescription: description })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script>
<template>

View File

@@ -32,6 +32,7 @@ if (pageOutOfRange(page, data.value.meta.last_page)) {
const title = listTitle(t('journal.index.title'), page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.data, () => data.value?.meta)
</script>
<template>

View File

@@ -35,6 +35,7 @@ const heading = t('journal.tag.title', { name: data.value.tag.name })
const title = listTitle(heading, page, t('journal.pagination.pageTitle', { page }))
useSeoMeta({ title, ogTitle: title })
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
useJournalItemList(() => data.value?.posts.data, () => data.value?.posts.meta)
</script>
<template>

View File

@@ -99,6 +99,29 @@ export function journalPostTitleTag(option: unknown, contentH1ToH2: boolean | nu
return contentH1ToH2 === false ? 'p' : 'h1'
}
/** A schema.org ItemList node of a listing page (D-43). */
export interface JournalItemListNode {
'@type': 'ItemList'
'@id': string
itemListElement: Array<{ '@type': 'ListItem'; position: number; url: string }>
[key: string]: unknown
}
/**
* The ItemList node of a listing page for its one JSON-LD graph (D-43):
* @id <pageUrl>#itemlist and a ListItem with the absolute URL of each post
* shown, positions continuing across pages from `first` (1 on page 1). Posts
* without a link (a disabled post view) are left out; none gives null.
*/
export function journalItemList(pageUrl: string, origin: string, paths: ReadonlyArray<string | null | undefined>, first: number = 1): JournalItemListNode | null {
const items: JournalItemListNode['itemListElement'] = []
for (const path of paths) {
if (typeof path === 'string' && path !== '') items.push({ '@type': 'ListItem', position: first + items.length, url: absoluteUrl(path, origin) })
}
if (items.length === 0) return null
return { '@type': 'ItemList', '@id': `${pageUrl.split('#')[0]}#itemlist`, itemListElement: items }
}
/** A listing title; pages after the first append the "Page N" string. */
export function listTitle(base: string, page: number, pageTitle: string): string {
return page > 1 ? `${base} · ${pageTitle}` : base

View File

@@ -125,3 +125,12 @@
200 /blog/category/news?page=6 contains=<a class="journal-pagination__prev" href="/blog/category/news?page=5" rel="prev">Previous page</a> contains=<a class="journal-pagination__next" href="/blog/category/news?page=7" rel="next">Next page</a> contains=<link rel="prev" href="/blog/category/news?page=5" contains=<link rel="next" href="/blog/category/news?page=7"
200 /blog/category/news?page=2 contains=<a class="journal-pagination__prev" href="/blog/category/news" rel="prev">
404 /blog/category/news?page=13
# Listing pages add an ItemList node to the one JSON-LD graph (D-43): with
# the CMS graph on /blog, from scratch on pages without a resolve answer.
200 /blog count=application/ld+json:1 contains="@id":"https://acme.test/blog#itemlist" contains={"@type":"ListItem","position":1,"url":"https://acme.test/blog/hello-acme"} contains="@id":"https://acme.test/blog#webpage"
200 /blog?page=2 count=application/ld+json:1 contains="@id":"https://acme.test/blog?page=2#itemlist" contains="position":10,
200 /blog/category/news count=application/ld+json:1 contains=#itemlist" contains=ItemList
200 /blog/tag/release count=application/ld+json:1 contains=ItemList
200 /blog/author/jane-doe count=application/ld+json:1 contains=ItemList
200 /blog/hello-acme notcontains=ItemList

View File

@@ -2,7 +2,7 @@
// list titles.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { absoluteUrl, cmsHeadPresent, htmlToText, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import { absoluteUrl, cmsHeadPresent, htmlToText, journalItemList, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
import type { JournalPost } from '../app/utils/types.ts'
function post(extra: Partial<JournalPost> = {}): JournalPost {
@@ -119,3 +119,17 @@ test('journalSiteOrigin prefers the CMS page URL origin over the request origin'
assert.equal(journalSiteOrigin(null, 'http://127.0.0.1:3000'), 'http://127.0.0.1:3000')
assert.equal(journalSiteOrigin('javascript:alert(1)', 'http://h.test'), 'http://h.test')
})
test('journalItemList: one ListItem per linked post, absolute URLs, positions continuing across pages', () => {
assert.deepEqual(journalItemList('https://acme.test/blog?page=2', 'https://acme.test', ['/blog/a', null, '/blog/b'], 10), {
'@type': 'ItemList',
'@id': 'https://acme.test/blog?page=2#itemlist',
itemListElement: [
{ '@type': 'ListItem', position: 10, url: 'https://acme.test/blog/a' },
{ '@type': 'ListItem', position: 11, url: 'https://acme.test/blog/b' },
],
})
assert.equal(journalItemList('https://acme.test/blog', 'https://acme.test', []), null)
assert.equal(journalItemList('https://acme.test/blog', 'https://acme.test', [null, '']), null)
assert.equal(journalItemList('https://acme.test/blog#x', 'https://acme.test', ['/p'])?.['@id'], 'https://acme.test/blog#itemlist')
})