Files
scriptc/docs/scripts/limitations-partials.mjs
T
Chris Tate 9f2e9b00c9 Write the limitations partials one sentence per line
Each limitations partial held its whole text on one line (map-and-set.mdx
was a single 1,080-character line), so any two edits to the same partial
conflicted. The partials now put one sentence per line, with a bullet's
continuation lines indented two spaces. Markdown joins the lines, so the
rendered pages are unchanged.

scripts/limitations-partials.mjs reads a labeled bullet's indented
continuation lines as part of the bullet when it splits and joins the page,
and AGENTS.md states the convention.
2026-10-19 00:11:31 -05:00

184 lines
7.4 KiB
JavaScript

/* Splits the Limitations page into partials and joins them back.
*
* content/docs/limitations.mdx keeps the frontmatter, the headings and the
* anchor spans. Everything else lives in content/limitations/:
*
* introduction.mdx prose before the first heading
* <section>.mdx a section's prose (paragraphs, code
* blocks, unlabeled lists)
* <section>/<label>.mdx one labeled bullet (`- **Label:** ...`),
* one sentence per line, continuation
* lines indented two spaces
*
* <section> is the slug of the nearest heading and <label> the slug of the
* bullet's bold label. The page includes each partial with
* `<include>../limitations/<path>.mdx</include>` in page order; the remark
* step in src/lib/remark-docs.ts joins consecutive bullet partials back into
* one list.
*
* Usage (from docs/):
* node scripts/limitations-partials.mjs join [page] print the page with partials inlined
* node scripts/limitations-partials.mjs split <file> [page] write the page and partials from one file
* node scripts/limitations-partials.mjs merge <base> <theirs>
* three-way merge a branch's single-file page (<theirs>, forked from
* <base>) into the current partials, then split the result */
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const docsRoot = fileURLToPath(new URL("../", import.meta.url));
const defaultPage = join(docsRoot, "content/docs/limitations.mdx");
const partialsDir = join(docsRoot, "content/limitations");
const includeLine = /^<include>(\.\.\/limitations\/[\w/-]+\.mdx)<\/include>$/;
const labeledBullet = /^- \*\*([^*]+?):\*\*/;
const bulletContinuation = /^ {2}\S/;
export function slug(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** The page with every partial include replaced by the partial's text. */
export function joinPage(page = defaultPage) {
const out = [];
for (const line of readFileSync(page, "utf8").split("\n")) {
const include = line.match(includeLine);
out.push(include ? readFileSync(resolve(dirname(page), include[1]), "utf8").replace(/\n$/, "") : line);
}
// Consecutive bullet partials sit between blank lines in the page; the
// single file has them as one list.
let text = out.join("\n");
for (let prev; prev !== text; ) {
prev = text;
text = text.replace(/^(- \*\*[^\n]*(?:\n {2}\S[^\n]*)*)\n\n(?=- \*\*)/gm, "$1\n");
}
return text.replace(/\n*$/, "\n");
}
/** Page text and partials (path relative to content/limitations → text). */
export function splitPage(text) {
const lines = text.replace(/\n$/, "").split("\n");
if (lines[0] !== "---") throw new Error("the page has no frontmatter");
const fmEnd = lines.indexOf("---", 1);
const page = lines.slice(0, fmEnd + 1);
const partials = new Map();
let section = "introduction";
let prose = [];
let proseCount = 0;
const flushProse = () => {
while (prose.length && prose[prose.length - 1] === "") prose.pop();
while (prose.length && prose[0] === "") prose.shift();
if (!prose.length) return;
const name = `${section}${proseCount ? `-${proseCount + 1}` : ""}.mdx`;
proseCount++;
add(name, prose.join("\n"));
prose = [];
};
const add = (name, body) => {
if (partials.has(name)) throw new Error(`two partials named ${name}`);
partials.set(name, body + "\n");
page.push("", `<include>../limitations/${name}</include>`);
};
let inFence = false;
let bulletName = null;
let bulletLines = [];
const flushBullet = () => {
if (bulletName !== null) add(bulletName, bulletLines.join("\n"));
bulletName = null;
bulletLines = [];
};
for (const line of lines.slice(fmEnd + 1)) {
if (bulletName !== null && bulletContinuation.test(line)) {
bulletLines.push(line);
continue;
}
if (bulletName !== null && line !== "" && !labeledBullet.test(line) && !/^#{2,6} /.test(line))
throw new Error(`a labeled bullet is followed by a line that is not a blank line, bullet, heading or indented continuation: ${line.slice(0, 60)}`);
flushBullet();
if (/^(```|~~~)/.test(line)) inFence = !inFence;
const heading = !inFence && line.match(/^#{2,6} (.*)$/);
const anchor = !inFence && /^<span id="[\w-]+" \/>$/.test(line);
const bullet = !inFence && line.match(labeledBullet);
if (heading || anchor) {
flushProse();
page.push("", line);
if (heading) {
section = slug(heading[1]);
proseCount = 0;
}
} else if (bullet) {
flushProse();
bulletName = `${section}/${slug(bullet[1])}.mdx`;
bulletLines = [line];
} else {
prose.push(line);
}
}
flushBullet();
flushProse();
// Collapse the blank-line runs the pushes above produce.
const pageText = page.join("\n").replace(/\n{3,}/g, "\n\n") + "\n";
return { page: pageText, partials };
}
function write(text, page = defaultPage) {
const { page: pageText, partials } = splitPage(text);
rmSync(partialsDir, { recursive: true, force: true });
for (const [name, body] of partials) {
const file = join(partialsDir, name);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, body);
}
writeFileSync(page, pageText);
const roundTrip = joinPage(page);
if (roundTrip !== text) throw new Error("joining the partials does not reproduce the input");
console.error(`${relative(docsRoot, page)}: ${partials.size} partials in ${relative(docsRoot, partialsDir)}/`);
}
function listPartials(dir = partialsDir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
entry.isDirectory() ? listPartials(join(dir, entry.name)) : [join(dir, entry.name)],
);
}
const [command, ...args] = process.argv.slice(2);
if (command === "join") {
process.stdout.write(joinPage(args[0] ?? defaultPage));
} else if (command === "split") {
write(readFileSync(args[0], "utf8"), args[1] ?? defaultPage);
} else if (command === "merge") {
const [base, theirs] = args;
const work = mkdtempSync(join(tmpdir(), "scriptc-limitations-"));
try {
const ours = join(work, "ours.mdx");
writeFileSync(ours, joinPage());
let conflicted = false;
try {
execFileSync("git", ["merge-file", "-L", "main", "-L", "base", "-L", "branch", ours, base, theirs]);
} catch (error) {
if (error.status > 0 && error.status < 128) conflicted = true;
else throw error;
}
const merged = readFileSync(ours, "utf8");
if (conflicted) {
const out = join(docsRoot, "limitations.merged.mdx");
writeFileSync(out, merged);
console.error(`conflicts: resolve them in ${relative(process.cwd(), out)}, then run: node scripts/limitations-partials.mjs split ${relative(docsRoot, out)}`);
process.exitCode = 1;
} else {
write(merged);
}
} finally {
rmSync(work, { recursive: true, force: true });
}
} else if (command === "list") {
for (const file of listPartials().sort()) console.log(relative(docsRoot, file));
} else {
console.error("usage: limitations-partials.mjs join [page] | split <file> [page] | merge <base> <theirs> | list");
process.exitCode = 2;
}