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:
parent
a5179df39b
commit
ae36026695
5 changed files with 819 additions and 134 deletions
138
scripts/finalize-docs-site.mjs
Normal file
138
scripts/finalize-docs-site.mjs
Normal 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>…</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);
|
||||
});
|
||||
21
scripts/get-docs-version-slug.mjs
Normal file
21
scripts/get-docs-version-slug.mjs
Normal 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`);
|
||||
116
scripts/inject-version-picker.mjs
Normal file
116
scripts/inject-version-picker.mjs
Normal 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
17
scripts/list-versions.mjs
Normal 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
16
scripts/manifest-path.mjs
Normal 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"
|
||||
);
|
||||
Loading…
Reference in a new issue