// 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. // The keys the CMS never sets (og:type, article:*) come from // journalArticleMeta on every post page. 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 } /** * The post-page head keys the CMS head never carries (vue-seo-layer writes * no og:type and no article:* tags): og:type article and the publish and * update times, set on every post page whether or not the CMS answers * (L4 WR-02). A missing time is left out. */ export function journalArticleMeta(post: Pick): Pick { const meta: Pick = { ogType: 'article' } 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 #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, 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 }