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).
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
// SEO values for Journal pages. The page head comes from the CMS through
|
|
// vue-seo-layer (useCmsSeo); these values are the fallback a page applies
|
|
// only while there is no CMS answer for it (the SEO layer off with seo.cms
|
|
// false, the CMS unreachable, or a page the CMS does not know), so a site
|
|
// without the SEO plugin keeps its titles and no page gets two of them.
|
|
|
|
import type { JournalPost } from './types.ts'
|
|
|
|
/** Plain text of an HTML fragment: tags dropped, common entities decoded, whitespace collapsed. */
|
|
export function htmlToText(html: string | null | undefined): string {
|
|
if (!html) return ''
|
|
return html
|
|
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
|
|
.replace(/<[^>]*>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/"/g, '"')
|
|
.replace(/'|'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
/** Cuts `text` to at most `max` characters, at a word boundary when one is near, with an ellipsis. */
|
|
export function truncateText(text: string, max: number = 160): string {
|
|
if (text.length <= max) return text
|
|
const cut = text.slice(0, max - 1)
|
|
const space = cut.lastIndexOf(' ')
|
|
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
|
|
}
|
|
|
|
/** Makes `url` absolute against `siteUrl`; already absolute URLs and a missing siteUrl pass through. */
|
|
export function absoluteUrl(url: string, siteUrl?: string | null): string {
|
|
if (/^https?:\/\//i.test(url) || !siteUrl) return url
|
|
return `${siteUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
|
|
}
|
|
|
|
export interface JournalPostSeo {
|
|
title: string
|
|
ogTitle: string
|
|
description: string
|
|
ogDescription: string
|
|
ogType: 'article'
|
|
ogImage?: string
|
|
articlePublishedTime?: string
|
|
articleModifiedTime?: string
|
|
}
|
|
|
|
/**
|
|
* Meta for a post page: the post title, the excerpt (or the first 160
|
|
* characters of the body text) as description, the featured image as
|
|
* og:image, and the publish and update times.
|
|
*/
|
|
export function postSeoMeta(post: JournalPost, siteUrl?: string | null): JournalPostSeo {
|
|
const description = truncateText(htmlToText(post.excerpt) || htmlToText(post.content_html), 160)
|
|
const meta: JournalPostSeo = {
|
|
title: post.title,
|
|
ogTitle: post.title,
|
|
description,
|
|
ogDescription: description,
|
|
ogType: 'article',
|
|
}
|
|
if (post.featured_image_url) meta.ogImage = absoluteUrl(post.featured_image_url, siteUrl)
|
|
if (post.published_at) meta.articlePublishedTime = post.published_at
|
|
if (post.updated_at) meta.articleModifiedTime = post.updated_at
|
|
return meta
|
|
}
|
|
|
|
/** True when the resolve answer sets the page head (an ok answer with head values). */
|
|
export function cmsHeadPresent(answer: { action?: string; seo?: unknown } | null | undefined): boolean {
|
|
return answer?.action === 'ok' && !!answer.seo && typeof answer.seo === 'object'
|
|
}
|
|
|
|
/**
|
|
* The origin to make page URLs absolute on: the CMS's page URL (the site
|
|
* URL the backend knows) when there is an answer, else `fallback` (the
|
|
* request's origin).
|
|
*/
|
|
export function journalSiteOrigin(answerUrl: string | null | undefined, fallback: string): string {
|
|
if (typeof answerUrl === 'string' && /^https?:\/\//i.test(answerUrl)) {
|
|
try {
|
|
return new URL(answerUrl).origin
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
/**
|
|
* The post title's element (UI-SPEC L6): the option journal.postTitleTag
|
|
* when it is 'h1' or 'p', otherwise ('auto') h1 while the Journal H1 setting
|
|
* is on (content_h1_to_h2 true or absent) and p while it is off, so the
|
|
* page keeps exactly one H1 either way.
|
|
*/
|
|
export function journalPostTitleTag(option: unknown, contentH1ToH2: boolean | null | undefined): 'h1' | 'p' {
|
|
if (option === 'h1' || option === 'p') return option
|
|
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
|
|
}
|