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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 22:28:11 +00:00
co-authored by Claude Opus 5.5
parent fcce38b79a
commit f7bc273af8
4 changed files with 67 additions and 7 deletions
+13
View File
@@ -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))
});
}
+47 -5
View File
@@ -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}
<main class="shell py-10">
@@ -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}
<main class="shell py-10">
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
+5 -2
View File
@@ -72,7 +72,7 @@ export const merch = `
</section>
`;
export const shellHead = ({ title, desc, image, url, type = "website", noindex = false }) => `<!doctype html>
export const shellHead = ({ title, desc, image, url, type = "website", noindex = false, published, modified, jsonLd }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -85,7 +85,10 @@ export const shellHead = ({ title, desc, image, url, type = "website", noindex =
${url ? `<meta property="og:url" content="${url}" />` : ""}
${image ? `<meta property="og:image" content="${image}" />` : ""}
<meta name="theme-color" content="#fcfcfa" />
${noindex ? `<meta name="robots" content="noindex" />` : ""}
<meta name="robots" content="${noindex ? "noindex" : "max-image-preview:large"}" />
${published ? `<meta property="article:published_time" content="${published}" />` : ""}
${modified ? `<meta property="article:modified_time" content="${modified}" />` : ""}
${jsonLd ? `<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>` : ""}
${url ? `<link rel="canonical" href="${url}" />` : ""}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="apophenia.news RSS" href="${SITE_URL}/rss.xml" />
+2
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]));