Publish multi-version documentation site (#1727)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bb4b44f0-f505-49c5-9878-76e2b40c7a15
Copilot-Session: 8eac1204-10ae-4319-9333-aee5dd43dcc8
This commit is contained in:
Jeff Handley 2026-07-24 15:55:35 -07:00 • committed by GitHub
commit ae36026695
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 819 additions and 134 deletions

View file

@ -0,0 +1,138 @@
// Finalize the combined multi-version docs site:
// * copy the picker assets to <combinedDir>/assets/
// * publish a cleaned docs-versions.json at the site root
// * write a root index.html that redirects to the default version
// * generate unversioned redirect pages for the deployed v1 documentation
//
// Usage:
// node scripts/finalize-docs-site.mjs <combinedDir> [--versions <path>]
import { readFile, writeFile, mkdir, copyFile, readdir } from "node:fs/promises";
import path from "node:path";
import { manifestPath } from "./manifest-path.mjs";
function parseArgs(argv) {
const positional = [];
const opts = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) opts[a.slice(2)] = argv[++i];
else positional.push(a);
}
return { positional, opts };
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
async function* htmlFiles(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* htmlFiles(full);
else if (entry.isFile() && /\.html?$/i.test(entry.name)) yield full;
}
}
function redirectPage(target, title) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${escapeHtml(title)}</title>
<meta http-equiv="refresh" content="0; url=${escapeHtml(target)}">
<link rel="canonical" href="${escapeHtml(target)}">
<script>location.replace(${JSON.stringify(target)} + location.search + location.hash);</script>
</head>
<body>
<p>Redirecting to <a href="${escapeHtml(target)}">${escapeHtml(title)}</a>&hellip;</p>
</body>
</html>
`;
}
async function generateV1Redirects(combinedDir) {
const v1Dir = path.join(combinedDir, "v1");
const redirects = new Map();
for await (const sourceFile of htmlFiles(v1Dir)) {
const relativePath = path.relative(v1Dir, sourceFile).split(path.sep).join("/");
if (relativePath === "index.html") continue;
redirects.set(relativePath, `v1/${relativePath}`);
}
const publicMap = {};
const redirectEntries = [...redirects].sort(([left], [right]) => left.localeCompare(right));
for (const [sourcePath, targetPath] of redirectEntries) {
const destination = path.join(combinedDir, sourcePath);
const relativeTarget = path.relative(path.dirname(destination), path.join(combinedDir, targetPath))
.split(path.sep)
.join("/");
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, redirectPage(relativeTarget, "MCP C# SDK documentation"));
publicMap[`/${sourcePath}`] = `/${targetPath}`;
}
await writeFile(
path.join(combinedDir, "v1-redirects.json"),
JSON.stringify(publicMap, null, 2) + "\n"
);
return redirects.size;
}
async function main() {
const { positional, opts } = parseArgs(process.argv.slice(2));
const combinedDir = positional[0];
if (!combinedDir) {
console.error("usage: finalize-docs-site.mjs <combinedDir> [--versions <path>]");
process.exit(2);
}
const pickerDir = new URL("../docs/version-picker/", import.meta.url);
const versionsPath = opts.versions
? path.resolve(opts.versions)
: manifestPath;
const manifest = JSON.parse(await readFile(versionsPath, "utf8"));
// 1. Copy picker assets.
const assetsDir = path.join(combinedDir, "assets");
await mkdir(assetsDir, { recursive: true });
for (const name of ["version-picker.js", "version-picker.css"]) {
await copyFile(new URL(name, pickerDir), path.join(assetsDir, name));
}
// 2. Public docs-versions.json (drop build-only fields like `ref` and `$comment`).
const publicManifest = {
default: manifest.default,
versions: manifest.versions.map((v) => ({
slug: v.slug,
label: v.label,
prerelease: !!v.prerelease,
})),
};
await writeFile(
path.join(combinedDir, "docs-versions.json"),
JSON.stringify(publicManifest, null, 2) + "\n"
);
// 3. Root redirect to the default version (relative -> works under any base).
const def = manifest.default;
const target = `./${def}/`;
await writeFile(
path.join(combinedDir, "index.html"),
redirectPage(target, "MCP C# SDK documentation")
);
const redirectCount = await generateV1Redirects(combinedDir);
console.log(`[finalize] assets + docs-versions.json written; root redirects to ${target}; ${redirectCount} v1 redirects written`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,21 @@
// Print the docs URL slug for a source tree's VersionPrefix.
//
// Usage:
// node scripts/get-docs-version-slug.mjs <src/Directory.Build.props>
import { readFile } from "node:fs/promises";
const propsPath = process.argv[2];
if (!propsPath) {
console.error("usage: get-docs-version-slug.mjs <src/Directory.Build.props>");
process.exit(2);
}
const props = await readFile(propsPath, "utf8");
const match = props.match(/<VersionPrefix>\s*([1-9][0-9]*)\.\d+\.\d+\s*<\/VersionPrefix>/);
if (!match) {
console.error(`error: unable to determine VersionPrefix from ${propsPath}`);
process.exit(1);
}
process.stdout.write(`v${match[1]}\n`);

View file

@ -0,0 +1,116 @@
// Inject the version-picker widget into every .html page of a built docs site.
//
// Usage:
// node scripts/inject-version-picker.mjs <siteDir> <slug> [--base /] [--versions <path>]
//
// <siteDir> is a single version's built output (e.g. combined/2.0).
// <slug> is that version's slug (must match an entry in docs-versions.json).
// The widget config is derived from docs-versions.json and written into each page's
// <head>. The picker assets are referenced relative to each generated page so they
// work from both a custom domain and a project Pages subpath.
import { readFile, writeFile, readdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
import { manifestPath } from "./manifest-path.mjs";
const MARKER = "<!--dv-picker-->";
function parseArgs(argv) {
const positional = [];
const opts = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) opts[a.slice(2)] = argv[++i];
else positional.push(a);
}
return { positional, opts };
}
function normalizeBase(base) {
let b = (base || "/").trim();
if (!b.startsWith("/")) b = "/" + b;
if (!b.endsWith("/")) b += "/";
return b.replace(/\/{2,}/g, "/");
}
async function* htmlFiles(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) yield* htmlFiles(full);
else if (entry.isFile() && /\.html?$/i.test(entry.name)) yield full;
}
}
async function assetRevision(name) {
const contents = await readFile(new URL(`../docs/version-picker/${name}`, import.meta.url));
return createHash("sha256").update(contents).digest("hex").slice(0, 12);
}
async function main() {
const { positional, opts } = parseArgs(process.argv.slice(2));
const [siteDir, slug] = positional;
if (!siteDir || !slug) {
console.error("usage: inject-version-picker.mjs <siteDir> <slug> [--base /] [--versions <path>]");
process.exit(2);
}
const base = normalizeBase(opts.base);
const versionsPath = opts.versions
? path.resolve(opts.versions)
: manifestPath;
const manifest = JSON.parse(await readFile(versionsPath, "utf8"));
if (!manifest.versions.some((v) => v.slug === slug)) {
console.error(`error: slug "${slug}" is not present in docs-versions.json`);
process.exit(1);
}
const config = {
version: slug,
base,
default: manifest.default,
versions: manifest.versions.map((v) => ({
slug: v.slug,
label: v.label,
prerelease: !!v.prerelease,
})),
};
const json = JSON.stringify(config).replace(/</g, "\\u003c");
const [cssRevision, jsRevision] = await Promise.all([
assetRevision("version-picker.css"),
assetRevision("version-picker.js"),
]);
let injected = 0;
let skipped = 0;
const assetsDir = path.resolve(siteDir, "..", "assets");
for await (const file of htmlFiles(siteDir)) {
const html = await readFile(file, "utf8");
if (html.includes(MARKER)) {
skipped++;
continue;
}
const idx = html.search(/<\/head>/i);
if (idx === -1) {
skipped++;
continue;
}
const assetBase = path.relative(path.dirname(file), assetsDir).split(path.sep).join("/") + "/";
const snippet =
`\n${MARKER}\n` +
`<script>window.__DOCS__=${json};</script>\n` +
`<link rel="stylesheet" href="${assetBase}version-picker.css?v=${cssRevision}">\n` +
`<script type="module" src="${assetBase}version-picker.js?v=${jsRevision}"></script>\n`;
await writeFile(file, html.slice(0, idx) + snippet + html.slice(idx));
injected++;
}
console.log(`[inject] ${slug}: injected into ${injected} page(s), skipped ${skipped}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

17
scripts/list-versions.mjs Normal file
View file

@ -0,0 +1,17 @@
// Print the versions to build, one per line, as "<slug>\t<ref>".
// Consumed by the multi-version docs workflow to drive its build loop.
//
// Usage: node scripts/list-versions.mjs [--versions <path>]
import { readFile } from "node:fs/promises";
import path from "node:path";
import { manifestPath } from "./manifest-path.mjs";
const arg = process.argv.slice(2);
const i = arg.indexOf("--versions");
const versionsPath = i !== -1 ? path.resolve(arg[i + 1]) : manifestPath;
const manifest = JSON.parse(await readFile(versionsPath, "utf8"));
for (const v of manifest.versions) {
process.stdout.write(`${v.slug}\t${v.ref}\n`);
}

16
scripts/manifest-path.mjs Normal file
View file

@ -0,0 +1,16 @@
// Location of the docs-versions manifest.
//
// This manifest is a build-time artifact produced during docs publishing, which
// only ever runs in CI -- never on a developer's machine. It therefore lives in
// the runner's temporary directory (RUNNER_TEMP on GitHub Actions, falling back
// to the OS temp dir for local testing) so it never touches the repository tree
// and needs no .gitignore entry. Every script agrees on this path, so the
// workflow does not need to thread it between steps.
import os from "node:os";
import path from "node:path";
export const manifestPath = path.join(
process.env.RUNNER_TEMP || os.tmpdir(),
"docs-versions.json"
);