diff --git a/build.mjs b/build.mjs index 5e30517..a1dd053 100644 --- a/build.mjs +++ b/build.mjs @@ -16,7 +16,7 @@ const SITE_URL = ( ).replace(/\/?$/, "/"); const SITE_NAME = "Refract"; const SITE_DESCRIPTION = - "Refract — the open claim-history layer for public knowledge. Deterministic event stream of claims, sources, and disputes across revision histories."; + "A deterministic observation engine for revision histories. It reads a page's edits and emits a typed event for each change."; const SOURCE_REPOSITORY = "https://github.com/refract-org/refract"; function assetVersion() { @@ -65,7 +65,7 @@ function escapeMarkup(text) { .replace(/'/g, "'"); } -function structuredData(title, slug) { +function structuredData(title, description, slug) { const url = slugHref(slug, SITE_URL); const website = { "@type": "WebSite", @@ -79,7 +79,7 @@ function structuredData(title, slug) { "@graph": [ { ...website, - description: SITE_DESCRIPTION, + description, inLanguage: "en", about: { "@id": `${SITE_URL}#source` }, }, @@ -99,7 +99,7 @@ function structuredData(title, slug) { "@type": "TechArticle", "@id": `${url}#article`, headline: title, - description: SITE_DESCRIPTION, + description, url, mainEntityOfPage: url, inLanguage: "en", @@ -126,18 +126,17 @@ function structuredData(title, slug) { }; } -function renderSeoHead(title, slug) { +function renderSeoHead(title, description, slug) { const url = escapeMarkup(slugHref(slug, SITE_URL)); // "<" is escaped so no title can close the script element early. - const jsonLd = JSON.stringify(structuredData(title, slug)).replace( - / - + `; @@ -179,15 +178,69 @@ function renderNav(currentSlug) { return html; } -function plainText(tokens) { +function plainText(tokens, skipTypes = []) { return tokens .map((token) => { - if (token.tokens) return plainText(token.tokens); + if (skipTypes.includes(token.type)) return ""; + if (token.tokens) return plainText(token.tokens, skipTypes); return token.text ?? token.raw ?? ""; }) .join(""); } +// Inline text of a heading or paragraph with markdown, HTML tags and images +// removed and whitespace collapsed. +function inlineText(tokens) { + return plainText(tokens, ["html", "image"]).replace(/\s+/g, " ").trim(); +} + +function pageTitle(tokens, slug) { + const h1 = tokens.find((t) => t.type === "heading" && t.depth === 1); + return h1 ? inlineText(h1.tokens) : resolveTitle(slug); +} + +const DESCRIPTION_LENGTH = 155; + +function truncateAtWord(text, max) { + if (text.length <= max) return text; + const cut = text.slice(0, max + 1); + const end = cut.lastIndexOf(" "); + return `${cut.slice(0, end > 0 ? end : max).replace(/[\s,;:—–-]+$/, "")}…`; +} + +// A paragraph can stand in for the page when it is prose that ends a sentence. +// That rules out lead-ins to the next block ("Use environment variables +// instead:"), bold-label callouts ("**Note**: …", glossary-style +// "**Term** — …"), parenthetical asides, "See [page]" pointers and images. +function isSummaryParagraph(token, text) { + if (!/[.!?]["'”’)]*$/.test(text)) return false; + if (/^\(.*\)$/.test(text)) return false; + const [first, second] = token.tokens; + if ( + first?.type === "strong" && + second && + (/:$/.test(first.text) || /^\s*[:—–]|^\s+-\s/.test(second.raw)) + ) { + return false; + } + return !(/^See\s/.test(text) && token.tokens.some((t) => t.type === "link")); +} + +// The first prose paragraph after the H1, trimmed for a meta description. +// Only top-level paragraphs count, so lists, tables, code blocks, blockquotes +// and HTML blocks are skipped. Returns "" when the page has none. +function pageSummary(tokens) { + const h1 = tokens.findIndex((t) => t.type === "heading" && t.depth === 1); + for (const token of tokens.slice(h1 + 1)) { + if (token.type !== "paragraph") continue; + const text = inlineText(token.tokens); + if (isSummaryParagraph(token, text)) { + return truncateAtWord(text, DESCRIPTION_LENGTH); + } + } + return ""; +} + function slugifyHeading(text) { return text .toLowerCase() @@ -197,28 +250,12 @@ function slugifyHeading(text) { .replace(/\s/g, "-"); } -function getExcerpt(markdown) { - // Remove code blocks - let text = markdown.replace(/```[\s\S]*?```/g, ""); - // Remove inline code tags - text = text.replace(/`([^`]+)`/g, "$1"); - // Remove HTML tags - text = text.replace(/<[^>]*>/g, ""); - // Remove markdown links - text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); - // Remove headings - text = text.replace(/^#+\s+.+$/gm, ""); - // Get first non-empty lines - const lines = text - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - if (lines.length === 0) return ""; - const firstLine = lines[0]; - return firstLine.length > 140 ? `${firstLine.slice(0, 137)}...` : firstLine; +// A title that already leads with the name doesn't repeat it as a suffix. +function documentTitle(title) { + return title.startsWith(SITE_NAME) ? title : `${title} — ${SITE_NAME}`; } -function wrapHTML(title, content, currentSlug, headings = []) { +function wrapHTML(title, description, content, currentSlug, headings = []) { let tocHtml = ""; if (headings.length > 0) { tocHtml += `
On this page
`; @@ -237,11 +274,11 @@ function wrapHTML(title, content, currentSlug, headings = []) { - ${title} — Refract + ${documentTitle(title)} - - ${renderSeoHead(title, currentSlug)} + + ${renderSeoHead(title, description, currentSlug)} @@ -250,7 +287,7 @@ function wrapHTML(title, content, currentSlug, headings = []) {