Files
vue-journal-layer/app/utils/seo.ts
Jakub Zych 289d031201 feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)
- extend vue-seo-layer (link dependency during development) and drop
  @nuxtjs/seo: the page head, canonical and the one JSON-LD graph come
  from the CMS resolve answer; the post's own title, excerpt and image
  apply only while there is no CMS answer
- remove the journal sitemap source and the copied shared purge route,
  library and plugin (the SEO layer serves POST /api/__site__/cache/purge);
  the deprecated POST /api/__journal__/cache/purge keeps working
- post title is h1 while the Journal API reports content_h1_to_h2 on
  (or absent) and p.journal-post__title while off; journal.postTitleTag
  ('auto' | 'h1' | 'p') overrides it; body h1 headings are demoted to h2
  while the setting is on (gateJournalHtml/sanitizeJournalHtml demoteH1)
- journal.breadcrumbs option and post-title CSS tokens
- smoke harness: count=, header=, noheader= and resolves lines; the
  fixture API also answers the SEO resolve and site endpoints

BREAKING CHANGE: the layer no longer installs @nuxtjs/seo (no
nuxt-site-config, nuxt-schema-org, robots or sitemap module, no
/api/__sitemap__/journal source, no useSiteConfig()); apps take their
head, sitemaps and robots from the SEO plugin through vue-seo-layer.
2026-11-04 13:37:11 +01:00

106 lines
4.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(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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 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
}