#!/usr/bin/env node
/** Render a bilingual Markdown topic into an existing, staged Riff website. */
import assert from "node:assert/strict";
import { publicMarkdownTokens } from "./public_links.mjs";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repository = resolve(here, "../..");
const requireDocumentation = createRequire(resolve(here, "../documentation/package.json"));
const MarkdownIt = requireDocumentation("markdown-it");
const { values } = parseArgs({
options: {
site: { type: "string" },
topic: { type: "string" },
},
});
assert(values.site && values.topic, "Provide --site and --topic .");
const site = resolve(values.site);
const topic = JSON.parse(readFileSync(resolve(values.topic), "utf8"));
assert(/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(topic.slug), "Invalid topic slug.");
const escapeHtml = (text) =>
text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
const escapePattern = (text) => text.replace(/[.*+?^$()|[\]\\]/g, "\\$&");
const route = (language) => "/tldr/" + (language === "ru" ? "ru/" : "") + topic.slug + "/";
const changed = [];
function write(relative, content) {
const path = resolve(site, relative);
assert(path.startsWith(site + "/"));
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
changed.push(relative);
}
function replaceOnce(text, pattern, replacement) {
const matches = [
...text.matchAll(new RegExp(pattern.source, pattern.flags.replaceAll("g", "") + "g")),
];
assert.equal(matches.length, 1, "Expected one match: " + pattern);
return text.replace(pattern, typeof replacement === "function" ? replacement : () => replacement);
}
// Topic-owned assets are copied before their immutable URLs enter generated pages.
for (const asset of topic.assets ?? []) {
const source = resolve(repository, asset.source);
assert(source.startsWith(repository + "/"), "Asset must belong to the repository.");
assert(/^assets\/[a-z0-9.-]+$/.test(asset.target), "Invalid asset target.");
write(asset.target, readFileSync(source, "utf8"));
}
for (const stylesheet of topic.stylesheets ?? []) {
assert((topic.assets ?? []).some((asset) => "/" + asset.target === stylesheet));
}
for (const language of ["en", "ru"]) {
const markdown = readFileSync(resolve(repository, topic.sources[language]), "utf8");
const parser = new MarkdownIt({ html: false, linkify: false });
const tokens = publicMarkdownTokens(parser.parse(markdown, {}));
const headings = [];
let title;
for (let index = 0; index < tokens.length; index += 1) {
if (tokens[index].type !== "heading_open") continue;
if (tokens[index].tag === "h1") {
assert.equal(title, undefined, "A topic must have one title.");
title = tokens[index + 1].content;
} else if (tokens[index].tag === "h2") {
const anchor = "question-" + (headings.length + 1);
tokens[index].attrSet("id", anchor);
headings.push({ anchor, title: tokens[index + 1].content });
}
}
assert(title && headings.length > 0, "A topic must have a title and sections.");
if (topic.homepageTerms) assert.equal(headings.length, topic.homepageTerms[language].length);
let body = parser.renderer.render(tokens, parser.options, {});
assert(!/(?:271\.pics|freki:8080)/i.test(body), "Internal URL remains in public article.");
for (const asset of topic.assets ?? []) {
body = body.replaceAll("https://roadio.link/" + asset.target, "/" + asset.target);
}
const contents =
'";
const firstParagraphEnd = body.indexOf("
");
assert(firstParagraphEnd >= 0, "A topic must have an introduction.");
body = body.slice(0, firstParagraphEnd + 4) + contents + body.slice(firstParagraphEnd + 4);
const directory = language === "ru" ? "tldr/ru" : "tldr";
let template = readFileSync(resolve(site, directory + "/player-indexer/index.html"), "utf8");
template = template.replaceAll("/player-indexer/", "/" + topic.slug + "/");
template = replaceOnce(
template,
/.*?<\/title>/s,
"" + escapeHtml(title) + " — Riff",
);
template = replaceOnce(
template,
//,
'',
);
template = replaceOnce(
template,
/().*?(<\/main>)/s,
(_match, opening, closing) => opening + body + closing,
);
for (const stylesheet of topic.stylesheets ?? []) {
template = replaceOnce(
template,
/<\/head>/,
'',
);
}
write(directory + "/" + topic.slug + "/index.html", template);
const indexPath = directory + "/index.html";
let index = readFileSync(resolve(site, indexPath), "utf8");
const existingCard = new RegExp(
']*>.*?',
"s",
);
index = index.replace(existingCard, "");
const card =
'' +
escapeHtml(title) +
"
" +
escapeHtml(topic.summaries[language]) +
"
";
assert(index.includes('' +
escapeHtml(term) +
"";
const previous = content.translations[translationKey][language];
if (previous.includes(link)) continue;
assert(previous.includes(term), "Missing homepage term: " + term);
content.translations[translationKey][language] = previous.replace(term, link);
}
}
}
if (topic.homepageTopicSummary) {
content.translations.tldrTopic = topic.homepageTopicSummary;
}
const nextScript =
"const content=" + JSON.stringify(content) + ";\n" + script.slice(header[0].length);
const scriptName =
"site." + createHash("sha256").update(nextScript).digest("hex").slice(0, 12) + ".js";
write("assets/site.js", nextScript);
write("assets/" + scriptName, nextScript);
let homepage = readFileSync(resolve(site, "index.html"), "utf8");
homepage = replaceOnce(homepage, /\/assets\/site\.[a-f0-9]+\.js/, "/assets/" + scriptName);
for (const key of [translationKey, topic.homepageTopicSummary ? "tldrTopic" : undefined].filter(
Boolean,
)) {
const pattern = new RegExp('().*?(
)', "s");
homepage = replaceOnce(
homepage,
pattern,
(_match, opening, closing) => opening + content.translations[key].en + closing,
);
}
write("index.html", homepage);
console.log(JSON.stringify({ topic: topic.slug, files: changed, script: scriptName }, null, 2));