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:
23
app/composables/useJournalItemList.ts
Normal file
23
app/composables/useJournalItemList.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user