wry-docs/scripts/sync-specs.mjs
2026-06-06 23:08:48 -04:00

353 lines
11 KiB
JavaScript

import { execFileSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile, copyFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const root = process.cwd();
const wryRoot = path.resolve(process.env.WRY_REPO_PATH ?? path.join(root, "..", "wry"));
const specDir = path.join(wryRoot, "crates", "toml-spec", "spec");
const schemaPath = path.join(specDir, "spec.generated.json");
const docsDir = path.join(root, "src", "content", "docs");
const referenceDir = path.join(docsDir, "reference");
const typesDir = path.join(referenceDir, "types");
const publicSpecDir = path.join(root, "public", "spec");
const copiedSpecs = ["spec.generated.json", "spec.generated.md", "spec.yaml"];
function frontmatter({ title, description }) {
return [
"---",
`title: ${JSON.stringify(title)}`,
description ? `description: ${JSON.stringify(description)}` : undefined,
"---",
""
]
.filter(Boolean)
.join("\n");
}
function slugify(name) {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/[^A-Za-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.toLowerCase();
}
function trimDescription(description) {
return typeof description === "string" ? description.trim() : "";
}
function escapePipe(value) {
return String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
}
function inlineCode(value) {
return `\`${String(value).replaceAll("`", "\\`")}\``;
}
function refName(ref) {
const prefix = "#/$defs/";
return typeof ref === "string" && ref.startsWith(prefix) ? ref.slice(prefix.length) : null;
}
function linkRef(ref) {
const name = refName(ref);
if (!name) {
return inlineCode(ref);
}
return `[${name}](/reference/types/${slugify(name)}/)`;
}
function schemaType(schema) {
if (!schema || typeof schema !== "object") {
return "unknown";
}
if (schema.$ref) {
return linkRef(schema.$ref);
}
if (schema.const !== undefined) {
return `constant ${inlineCode(schema.const)}`;
}
if (Array.isArray(schema.enum)) {
return "enum";
}
if (schema.type === "array") {
return `array of ${schemaType(schema.items)}`;
}
if (schema.type) {
return Array.isArray(schema.type) ? schema.type.map(inlineCode).join(" or ") : inlineCode(schema.type);
}
if (schema.anyOf) {
return "one of several forms";
}
if (schema.oneOf) {
return "one of several forms";
}
if (schema.allOf) {
return "combination";
}
if (schema.properties) {
return inlineCode("object");
}
return "unspecified";
}
function describeConstraints(schema) {
const rows = [];
if (schema.pattern) rows.push(["Pattern", inlineCode(schema.pattern)]);
if (schema.minimum !== undefined) rows.push(["Minimum", inlineCode(schema.minimum)]);
if (schema.maximum !== undefined) rows.push(["Maximum", inlineCode(schema.maximum)]);
if (schema.exclusiveMinimum !== undefined) rows.push(["Exclusive minimum", inlineCode(schema.exclusiveMinimum)]);
if (schema.exclusiveMaximum !== undefined) rows.push(["Exclusive maximum", inlineCode(schema.exclusiveMaximum)]);
if (schema.minLength !== undefined) rows.push(["Minimum length", inlineCode(schema.minLength)]);
if (schema.maxLength !== undefined) rows.push(["Maximum length", inlineCode(schema.maxLength)]);
if (schema.minItems !== undefined) rows.push(["Minimum items", inlineCode(schema.minItems)]);
if (schema.maxItems !== undefined) rows.push(["Maximum items", inlineCode(schema.maxItems)]);
return rows;
}
function heading(level, text) {
return `${"#".repeat(Math.min(level, 6))} ${text}`;
}
function variantLabel(schema, index) {
const tag = schema?.properties?.type?.const;
if (tag !== undefined) {
return `${inlineCode(`type = "${tag}"`)}`;
}
if (schema?.const !== undefined) {
return inlineCode(schema.const);
}
if (schema?.$ref) {
return linkRef(schema.$ref);
}
if (schema?.type === "array") {
return "array";
}
if (schema?.type) {
return Array.isArray(schema.type) ? schema.type.join(" or ") : schema.type;
}
return `form ${index + 1}`;
}
function renderSchema(schema, level = 2, seen = new Set()) {
if (!schema || typeof schema !== "object") {
return "";
}
const out = [];
const description = trimDescription(schema.description);
if (description) {
out.push(description, "");
}
const constraints = describeConstraints(schema);
if (schema.$ref || schema.type || schema.const !== undefined || constraints.length || Array.isArray(schema.enum)) {
out.push('<div class="schema-meta">');
out.push(`<div><strong>Type:</strong> ${schemaType(schema)}</div>`);
for (const [label, value] of constraints) {
out.push(`<div><strong>${label}:</strong> ${value}</div>`);
}
out.push("</div>", "");
}
if (Array.isArray(schema.enum)) {
out.push(heading(level, "Allowed Values"), "");
for (const value of schema.enum) {
out.push(`- ${inlineCode(value)}`);
}
out.push("");
}
const union = schema.anyOf ?? schema.oneOf;
if (Array.isArray(union)) {
out.push(heading(level, "Accepted Forms"), "");
union.forEach((variant, index) => {
out.push(heading(level + 1, variantLabel(variant, index)), "");
out.push(renderSchema(variant, level + 2, seen));
});
}
if (Array.isArray(schema.allOf)) {
out.push(heading(level, "Combined Schemas"), "");
schema.allOf.forEach((variant, index) => {
out.push(heading(level + 1, `Part ${index + 1}`), "");
out.push(renderSchema(variant, level + 2, seen));
});
}
if (schema.type === "array" && schema.items) {
out.push(heading(level, "Items"), "");
if (schema.items.$ref) {
out.push(`Each item should be ${linkRef(schema.items.$ref)}.`, "");
} else {
out.push(renderSchema(schema.items, level + 1, seen));
}
}
if (schema.properties && !seen.has(schema)) {
seen.add(schema);
const required = new Set(schema.required ?? []);
const entries = Object.entries(schema.properties).filter(([name]) => name !== "type" || schema.properties.type.const === undefined);
if (entries.length) {
out.push(heading(level, "Fields"), "");
for (const [name, property] of entries) {
out.push('<div class="schema-field">');
out.push(`<p><strong>${inlineCode(name)}</strong> ${required.has(name) ? "(required)" : "(optional)"}</p>`);
out.push(`<p><strong>Type:</strong> ${schemaType(property)}</p>`);
const propertyDescription = trimDescription(property.description);
if (propertyDescription) {
out.push("", propertyDescription);
}
const propertyConstraints = describeConstraints(property);
if (propertyConstraints.length) {
out.push("", "| Constraint | Value |", "| --- | --- |");
for (const [label, value] of propertyConstraints) {
out.push(`| ${escapePipe(label)} | ${escapePipe(value)} |`);
}
}
if (property.items?.$ref) {
out.push("", `Each item should be ${linkRef(property.items.$ref)}.`);
}
if ((property.anyOf || property.oneOf || property.properties) && !property.$ref) {
out.push("", renderSchema(property, level + 1, seen));
}
out.push("</div>", "");
}
}
}
return out.join("\n").replace(/\n{4,}/g, "\n\n\n");
}
function tryGit(args) {
try {
return execFileSync("git", ["-C", wryRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
} catch {
return "";
}
}
function buildInfo() {
const commit = tryGit(["rev-parse", "--short=12", "HEAD"]);
const branch = tryGit(["branch", "--show-current"]);
return {
sourcePath: wryRoot,
sourceCommit: commit || null,
sourceBranch: branch || null,
generatedAt: new Date().toISOString()
};
}
function sourceSummary(info) {
const rows = [];
if (info.sourceBranch) rows.push(`- Source branch: \`${info.sourceBranch}\``);
if (info.sourceCommit) rows.push(`- Source commit: \`${info.sourceCommit}\``);
rows.push(`- Generated at: \`${info.generatedAt}\``);
return rows.join("\n");
}
async function main() {
const rawSchema = await readFile(schemaPath, "utf8");
const schema = JSON.parse(rawSchema);
const defs = schema.$defs ?? {};
const typeEntries = Object.keys(defs).sort((a, b) => a.localeCompare(b));
const info = buildInfo();
await rm(typesDir, { recursive: true, force: true });
await mkdir(typesDir, { recursive: true });
await mkdir(publicSpecDir, { recursive: true });
for (const name of copiedSpecs) {
await copyFile(path.join(specDir, name), path.join(publicSpecDir, name));
}
await writeFile(path.join(publicSpecDir, "build-info.json"), `${JSON.stringify(info, null, 2)}\n`);
const index = `${frontmatter({
title: "Wry Config Docs",
description: "Generated documentation for the Wry TOML configuration format."
})}
# Wry Config Docs
This site is generated from Wry's TOML configuration specs.
${sourceSummary(info)}
## Reference
- [Top-Level Config](/reference/config/) starts at the schema root.
- [Configuration Types](/reference/types/) lists every generated type.
- [Raw Generated Specs](/reference/raw-specs/) exposes the source artifacts copied from Wry.
`;
await writeFile(path.join(docsDir, "index.md"), index);
const configSchema = defs.Config;
if (!configSchema) {
throw new Error("spec.generated.json does not contain $defs.Config");
}
const configPage = `${frontmatter({
title: "Top-Level Config",
description: "The root Wry TOML configuration schema."
})}
# Top-Level Config
This page is generated from \`$defs.Config\` in \`spec.generated.json\`.
${renderSchema(configSchema, 2)}
`;
await writeFile(path.join(referenceDir, "config.md"), configPage);
const rawPage = `${frontmatter({
title: "Raw Generated Specs",
description: "Downloadable generated Wry configuration spec artifacts."
})}
# Raw Generated Specs
These files are copied directly from Wry during \`pnpm run sync:specs\`.
<ul class="raw-links">
<li><a href="/spec/spec.generated.json">spec.generated.json</a><br />Machine-readable JSON Schema.</li>
<li><a href="/spec/spec.generated.md">spec.generated.md</a><br />Generated Markdown reference from Wry.</li>
<li><a href="/spec/spec.yaml">spec.yaml</a><br />Canonical TOML spec input.</li>
<li><a href="/spec/build-info.json">build-info.json</a><br />Source checkout metadata for this generated build.</li>
</ul>
`;
await writeFile(path.join(referenceDir, "raw-specs.md"), rawPage);
const typeIndex = `${frontmatter({
title: "Configuration Types",
description: "Alphabetized generated type reference."
})}
# Configuration Types
This index is generated from the \`$defs\` section of \`spec.generated.json\`.
<ul class="schema-list">
${typeEntries.map((name) => ` <li><a href="/reference/types/${slugify(name)}/">${name}</a></li>`).join("\n")}
</ul>
`;
await writeFile(path.join(typesDir, "index.md"), typeIndex);
for (const name of typeEntries) {
const type = defs[name];
const page = `${frontmatter({
title: name,
description: trimDescription(type.description).split("\n")[0] || `Generated schema reference for ${name}.`
})}
# \`${name}\`
Generated from \`$defs.${name}\` in \`spec.generated.json\`.
${renderSchema(type, 2)}
`;
await writeFile(path.join(typesDir, `${slugify(name)}.md`), page);
}
console.log(`Generated ${typeEntries.length} type pages from ${schemaPath}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});