mirror of
https://github.com/apophenia-news/apophenia.news.git
synced 2026-10-29 14:54:54 +00:00
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:
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fg from "fast-glob";
|
import fg from "fast-glob";
|
||||||
import matter from "gray-matter";
|
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 copyArticleAssets = async (file, slug) => {
|
||||||
const dir = path.dirname(file);
|
const dir = path.dirname(file);
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
@@ -54,6 +66,7 @@ const run = async () => {
|
|||||||
sourceFile: file,
|
sourceFile: file,
|
||||||
markdown: md,
|
markdown: md,
|
||||||
isUnlisted: unlisted.has(data.slug),
|
isUnlisted: unlisted.has(data.slug),
|
||||||
|
modified: lastCommit(path.dirname(file)),
|
||||||
html: fixInternalLinks(marked.parse(content))
|
html: fixInternalLinks(marked.parse(content))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,45 @@
|
|||||||
import { SITE_URL, TURNSTILE_SITE_KEY } from "./constants.mjs";
|
import { SITE_URL, TURNSTILE_SITE_KEY } from "./constants.mjs";
|
||||||
import { footer, merch, nav, shellHead } from "./templates.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) => `
|
export const renderHome = (articles) => `
|
||||||
${shellHead({
|
${shellHead({
|
||||||
@@ -8,7 +47,8 @@ ${shellHead({
|
|||||||
desc: "Signals, anomalies, civilization trajectories, and deep pattern analysis.",
|
desc: "Signals, anomalies, civilization trajectories, and deep pattern analysis.",
|
||||||
image: "https://free.direct-img.link/celestial+map?i=1",
|
image: "https://free.direct-img.link/celestial+map?i=1",
|
||||||
url: `${SITE_URL}/`,
|
url: `${SITE_URL}/`,
|
||||||
type: "website"
|
type: "website",
|
||||||
|
jsonLd: homeLd
|
||||||
})}
|
})}
|
||||||
${nav}
|
${nav}
|
||||||
<main class="shell py-10">
|
<main class="shell py-10">
|
||||||
@@ -54,13 +94,15 @@ ${nav}
|
|||||||
${footer}
|
${footer}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const renderArticle = (article) => `
|
export const renderArticle = (article, dates = articleDates(article)) => `
|
||||||
${shellHead({
|
${shellHead({
|
||||||
title: `${article.title} — apophenia.news`,
|
title: `${article.title} — apophenia.news`,
|
||||||
desc: article.description,
|
desc: article.description,
|
||||||
image: article.header_image,
|
image: article.header_image,
|
||||||
url: `${SITE_URL}/${article.slug}/`,
|
url: `${SITE_URL}/${article.slug}/`,
|
||||||
type: "article"
|
type: "article",
|
||||||
|
...dates,
|
||||||
|
jsonLd: articleLd(article, dates)
|
||||||
})}
|
})}
|
||||||
${nav}
|
${nav}
|
||||||
<main class="shell py-10">
|
<main class="shell py-10">
|
||||||
@@ -408,7 +450,7 @@ export const renderSitemap = (articles, authors = []) => {
|
|||||||
{ loc: `${SITE_URL}/newsletter/`, lastmod: now },
|
{ loc: `${SITE_URL}/newsletter/`, lastmod: now },
|
||||||
{ loc: `${SITE_URL}/write/`, lastmod: now },
|
{ loc: `${SITE_URL}/write/`, lastmod: now },
|
||||||
...authors.map((a) => ({ loc: `${SITE_URL}/author/${a.slug}/`, 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"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const merch = `
|
|||||||
</section>
|
</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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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}" />` : ""}
|
${url ? `<meta property="og:url" content="${url}" />` : ""}
|
||||||
${image ? `<meta property="og:image" content="${image}" />` : ""}
|
${image ? `<meta property="og:image" content="${image}" />` : ""}
|
||||||
<meta name="theme-color" content="#fcfcfa" />
|
<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}" />` : ""}
|
${url ? `<link rel="canonical" href="${url}" />` : ""}
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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" />
|
<link rel="alternate" type="application/rss+xml" title="apophenia.news RSS" href="${SITE_URL}/rss.xml" />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export const fmtDate = (date) =>
|
|||||||
|
|
||||||
export const toISODate = (d) => new Date(d).toISOString().split("T")[0];
|
export const toISODate = (d) => new Date(d).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
export const toISO = (d) => new Date(d).toISOString();
|
||||||
|
|
||||||
export const escapeHtml = (s = "") =>
|
export const escapeHtml = (s = "") =>
|
||||||
String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user