From f7bc273af87ea2975b85cc7a8f2fc67828213e34 Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Fri, 23 Oct 2026 22:28:11 +0000 Subject: [PATCH] Feat: Add NewsArticle JSON-LD, large image previews and automatic modified dates Articles get NewsArticle structured data (headline, image, dates, author, publisher) plus article:published_time and article:modified_time, and the homepage gets WebSite and Organization data. Every indexable page allows max-image-preview:large. The modified date is the article folder's last commit, so editing an article updates it, the sitemap lastmod included. Co-Authored-By: Claude Opus 5.5 --- scripts/generate-pages.mjs | 13 ++++++++++ scripts/lib/renderers.mjs | 52 ++++++++++++++++++++++++++++++++++---- scripts/lib/templates.mjs | 7 +++-- scripts/lib/utils.mjs | 2 ++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/scripts/generate-pages.mjs b/scripts/generate-pages.mjs index a0dd85f..b8c002f 100644 --- a/scripts/generate-pages.mjs +++ b/scripts/generate-pages.mjs @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import fg from "fast-glob"; import matter from "gray-matter"; @@ -21,6 +22,17 @@ const getUnlistedSlugs = async () => { } }; +// When the article's folder was last committed, so editing an article (text or images) updates its modified date on +// its own. CI checks out full history for this. "" for an uncommitted article, which then counts as unmodified. +const lastCommit = (dir) => { + try { + const d = execFileSync("git", ["log", "-1", "--format=%aI", "--", dir], { encoding: "utf8" }).trim(); + return d && new Date(d).toISOString(); + } catch { + return ""; + } +}; + const copyArticleAssets = async (file, slug) => { const dir = path.dirname(file); const entries = await fs.readdir(dir, { withFileTypes: true }); @@ -54,6 +66,7 @@ const run = async () => { sourceFile: file, markdown: md, isUnlisted: unlisted.has(data.slug), + modified: lastCommit(path.dirname(file)), html: fixInternalLinks(marked.parse(content)) }); } diff --git a/scripts/lib/renderers.mjs b/scripts/lib/renderers.mjs index 56acb89..ab42008 100644 --- a/scripts/lib/renderers.mjs +++ b/scripts/lib/renderers.mjs @@ -1,6 +1,45 @@ import { SITE_URL, TURNSTILE_SITE_KEY } from "./constants.mjs"; import { footer, merch, nav, shellHead } from "./templates.mjs"; -import { escapeHtml, escapeXml, fmtDate, renderAuthorInline, toISODate } from "./utils.mjs"; +import { escapeHtml, escapeXml, fmtDate, renderAuthorInline, toISO, toISODate } from "./utils.mjs"; + +// Structured data (JSON-LD) so Google reads the headline, image, dates and authors directly instead of guessing +const absUrl = (u = "") => (/^https?:\/\//.test(u) ? u : `${SITE_URL}/${u.replace(/^\/+/, "")}`); +const publisher = { "@type": "Organization", name: "apophenia.news", url: `${SITE_URL}/`, logo: `${SITE_URL}/favicon.svg` }; + +// Linked names in the byline (people with an author page) become Person authors. Unlinked co-authors are AI models, +// which schema.org has no type for, so they stay in the visible byline only. +const authorsLd = (author = "") => { + const people = [...String(author).matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)].map(([, name, href]) => ({ "@type": "Person", name: name.trim(), url: absUrl(href.trim()) })); + return people.length ? people : [publisher]; +}; + +// Published is the frontmatter date. Modified is the article folder's last commit (see generate-pages.mjs), never +// earlier than published. +const articleDates = (a) => { + const published = toISO(a.date); + return { published, modified: a.modified > published ? a.modified : published }; +}; + +const articleLd = (a, { published, modified }) => ({ + "@context": "https://schema.org", + "@type": "NewsArticle", + headline: a.title, + description: a.description, + image: [absUrl(a.header_image)], + datePublished: published, + dateModified: modified, + author: authorsLd(a.author), + publisher, + mainEntityOfPage: `${SITE_URL}/${a.slug}/` +}); + +const homeLd = { + "@context": "https://schema.org", + "@graph": [ + { "@type": "WebSite", name: "apophenia.news", url: `${SITE_URL}/` }, + { ...publisher, sameAs: ["https://youtube.com/@ApopheniaLoop"] } + ] +}; export const renderHome = (articles) => ` ${shellHead({ @@ -8,7 +47,8 @@ ${shellHead({ desc: "Signals, anomalies, civilization trajectories, and deep pattern analysis.", image: "https://free.direct-img.link/celestial+map?i=1", url: `${SITE_URL}/`, - type: "website" + type: "website", + jsonLd: homeLd })} ${nav}
@@ -54,13 +94,15 @@ ${nav} ${footer} `; -export const renderArticle = (article) => ` +export const renderArticle = (article, dates = articleDates(article)) => ` ${shellHead({ title: `${article.title} — apophenia.news`, desc: article.description, image: article.header_image, url: `${SITE_URL}/${article.slug}/`, - type: "article" + type: "article", + ...dates, + jsonLd: articleLd(article, dates) })} ${nav}
@@ -408,7 +450,7 @@ export const renderSitemap = (articles, authors = []) => { { loc: `${SITE_URL}/newsletter/`, lastmod: now }, { loc: `${SITE_URL}/write/`, lastmod: now }, ...authors.map((a) => ({ loc: `${SITE_URL}/author/${a.slug}/`, lastmod: now })), - ...articles.map((a) => ({ loc: `${SITE_URL}/${a.slug}/`, lastmod: toISODate(a.date || new Date()) })) + ...articles.map((a) => ({ loc: `${SITE_URL}/${a.slug}/`, lastmod: toISODate(articleDates(a).modified) })) ]; return ` diff --git a/scripts/lib/templates.mjs b/scripts/lib/templates.mjs index e343ecd..1a308d5 100644 --- a/scripts/lib/templates.mjs +++ b/scripts/lib/templates.mjs @@ -72,7 +72,7 @@ export const merch = ` `; -export const shellHead = ({ title, desc, image, url, type = "website", noindex = false }) => ` +export const shellHead = ({ title, desc, image, url, type = "website", noindex = false, published, modified, jsonLd }) => ` @@ -85,7 +85,10 @@ export const shellHead = ({ title, desc, image, url, type = "website", noindex = ${url ? `` : ""} ${image ? `` : ""} - ${noindex ? `` : ""} + + ${published ? `` : ""} + ${modified ? `` : ""} + ${jsonLd ? `` : ""} ${url ? `` : ""} diff --git a/scripts/lib/utils.mjs b/scripts/lib/utils.mjs index ff494db..5fbcc6e 100644 --- a/scripts/lib/utils.mjs +++ b/scripts/lib/utils.mjs @@ -3,6 +3,8 @@ export const fmtDate = (date) => export const toISODate = (d) => new Date(d).toISOString().split("T")[0]; +export const toISO = (d) => new Date(d).toISOString(); + export const escapeHtml = (s = "") => String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));