Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Fetch docs version refs
run: git fetch --tags origin "main:refs/remotes/origin/main" "develop:refs/remotes/origin/develop"

- uses: actions/setup-node@v4
with:
Expand All @@ -46,7 +51,9 @@ jobs:
working-directory: docs-site
env:
DOCS_BASE_URL: ${{ steps.baseurl.outputs.base_url }}
run: npm run build
run: |
npm run prepare:versions
npm run build:site

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ agent_scratchpad
tools
sflow_output
.sflow_cache
docs-site/.generated/
docs-site/versioned_docs/
docs-site/versioned_sidebars/
docs-site/versions.json
sflow_compute_node_venv/
logs/
workflow_outputs/
Expand Down
43 changes: 28 additions & 15 deletions docs-site/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const path = require("path");
const fs = require("fs");

function readReleasedDocVersions() {
function readVersionedDocIds() {
try {
const p = path.resolve(__dirname, "versions.json");
return JSON.parse(fs.readFileSync(p, "utf8"));
Expand All @@ -12,6 +12,11 @@ function readReleasedDocVersions() {
}
}

function currentDocsPath() {
const generated = path.resolve(__dirname, ".generated", "current-docs");
return fs.existsSync(generated) ? generated : path.resolve(__dirname, "..", "docs");
}

const baseUrl = process.env.DOCS_BASE_URL || "/";

/** @type {import('@docusaurus/types').Config} */
Expand Down Expand Up @@ -41,16 +46,22 @@ const config = {
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
// Reuse the existing markdown under repo-level docs/
path: path.resolve(__dirname, "..", "docs"),
// Builds use generated docs from the develop branch. Local dev falls
// back to the repo-level docs/ directory if generation has not run.
path: currentDocsPath(),
routeBasePath: "docs",
sidebarPath: require.resolve("./sidebars.js"),
// Keep current (unreleased) docs at /docs/... so existing links don't break.
// Released versions will live under /docs/<version>/...
// Keep develop docs at /docs/... so existing links don't break.
// Main and released tags live under /docs/<version>/...
lastVersion: "current",
versions: {
current: {
label: "dev",
label: "develop",
banner: "none",
},
main: {
label: "main",
banner: "none",
},
},
showLastUpdateAuthor: false,
Expand All @@ -74,20 +85,22 @@ const config = {
[
"@docusaurus/plugin-client-redirects",
{
// Add a stable alias path for "dev" so /docs/dev/... redirects to /docs/...
// This is useful for sharing links that always target the latest docs.
// Add a stable alias path for "develop" so /docs/develop/... redirects to /docs/...
// This is useful for sharing links that explicitly target develop docs.
createRedirects(existingPath) {
// Only alias CURRENT docs, not released versions (e.g. /docs/0.1/...).
const released = new Set(readReleasedDocVersions());
// Only alias CURRENT docs, not versioned docs (e.g. /docs/main/...).
const versioned = new Set(readVersionedDocIds());
const parts = existingPath.split("/").filter(Boolean); // ["docs", ...]
if (parts[0] !== "docs") return undefined;
if (parts.length >= 2 && released.has(parts[1])) return undefined;
if (parts.length >= 2 && versioned.has(parts[1])) return undefined;

// Alias /docs/<...> => /docs/dev/<...>
return [existingPath.replace(/^\/docs(\/|$)/, "/docs/dev$1")];
// Alias /docs/<...> => /docs/develop/<...>
return [existingPath.replace(/^\/docs(\/|$)/, "/docs/develop$1")];
},
// /docs is not a real route by default; redirect to an existing doc page.
redirects: [{ from: "/docs/dev", to: "/docs/user/intro" }],
redirects: [
// /docs is not a real route by default; redirect to an existing doc page.
{ from: "/docs/develop", to: "/docs/user/intro" },
],
},
],
],
Expand Down
7 changes: 5 additions & 2 deletions docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
"private": true,
"version": "0.0.0",
"scripts": {
"start": "docusaurus start --host 0.0.0.0 --port 3000",
"build": "docusaurus build",
"prepare:versions": "node scripts/prepare-versioned-docs.js",
"test:versions": "node --test scripts/prepare-versioned-docs.test.js",
"build:site": "docusaurus build",
"start": "npm run prepare:versions && docusaurus start --host 0.0.0.0 --port 3000",
"build": "npm run prepare:versions && npm run build:site",
"serve": "docusaurus serve --host 0.0.0.0 --port 3000",
"clear": "docusaurus clear"
},
Expand Down
240 changes: 240 additions & 0 deletions docs-site/scripts/prepare-versioned-docs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env node

const childProcess = require("child_process");
const fs = require("fs");
const path = require("path");

const DOCS_SITE_DIR = path.resolve(__dirname, "..");
const REPO_ROOT = path.resolve(DOCS_SITE_DIR, "..");
const GENERATED_CURRENT_DIR = path.join(DOCS_SITE_DIR, ".generated", "current-docs");
const VERSIONED_DOCS_DIR = path.join(DOCS_SITE_DIR, "versioned_docs");
const VERSIONED_SIDEBARS_DIR = path.join(DOCS_SITE_DIR, "versioned_sidebars");
const VERSIONS_JSON = path.join(DOCS_SITE_DIR, "versions.json");
const SIDEBAR = {
docs: [{ type: "autogenerated", dirName: "." }],
};

function docsPaths({ repoRoot = REPO_ROOT, docsSiteDir = DOCS_SITE_DIR } = {}) {
return {
repoRoot,
generatedCurrentDir: path.join(docsSiteDir, ".generated", "current-docs"),
versionedDocsDir: path.join(docsSiteDir, "versioned_docs"),
versionedSidebarsDir: path.join(docsSiteDir, "versioned_sidebars"),
versionsJson: path.join(docsSiteDir, "versions.json"),
};
}

function isReleaseTag(ref) {
return /^v\d+\.\d+\.\d+$/.test(ref);
}

function safeVersionDirName(label) {
return `version-${label.replace(/[^A-Za-z0-9._-]/g, "_")}`;
}

function semverParts(tag) {
return tag
.replace(/^v/, "")
.split(".")
.map((part) => Number.parseInt(part, 10));
}

function compareReleaseTagsDesc(a, b) {
const aa = semverParts(a);
const bb = semverParts(b);
for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) {
const delta = (bb[i] || 0) - (aa[i] || 0);
if (delta !== 0) return delta;
}
return a.localeCompare(b);
}

function buildDocVersionPlan({
branches,
tags,
currentRef = "develop",
currentLabel = "develop",
currentSource = currentDocsSource(),
}) {
const branchSet = new Set(branches);
const versioned = [];

if (branchSet.has("main") && currentRef !== "main") {
versioned.push({ label: "main", ref: "main", source: "gitRef" });
}

const releaseVersions = [...new Set(tags)]
.filter(isReleaseTag)
.sort(compareReleaseTagsDesc)
.map((tag) => ({ label: tag, ref: tag, source: "gitRef" }));

versioned.push(...releaseVersions);

return {
current: { label: currentLabel, ...currentSource, ref: currentSource.ref || currentRef },
versioned,
versionsJson: versioned.map((version) => version.label),
};
}

function currentDocsSource(env = process.env) {
if (env.GITLAB_CI || env.GITHUB_ACTIONS) {
return { ref: "origin/develop", source: "gitRef" };
}
return { ref: "develop", source: "workingTree" };
}

function gitRefExists(ref) {
try {
run("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
return true;
} catch {
return false;
}
}

function resolveGitRef(label) {
if (gitRefExists(label)) return label;
const remoteRef = `origin/${label}`;
if (gitRefExists(remoteRef)) return remoteRef;
throw new Error(`Could not resolve git ref for docs version '${label}'`);
}

function run(command, args, options = {}) {
return childProcess.execFileSync(command, args, {
cwd: REPO_ROOT,
encoding: "utf8",
stdio: options.stdio || ["ignore", "pipe", "pipe"],
});
}

function listBranches() {
return ["develop", "main"].filter((branch) => {
try {
resolveGitRef(branch);
return true;
} catch {
return false;
}
});
}

function listReleaseTags() {
const out = run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*"]);
return out
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}

function emptyDir(dir) {
fs.rmSync(dir, { recursive: true, force: true });
fs.mkdirSync(dir, { recursive: true });
}

function copyDocsFromRef(ref, destination, { repoRoot = REPO_ROOT } = {}) {
emptyDir(destination);
const archive = childProcess.spawnSync(
"git",
["archive", "--format=tar", ref, "docs"],
{
cwd: repoRoot,
stdio: ["ignore", "pipe", "inherit"],
},
);
if (archive.status !== 0) {
throw new Error(`Failed to archive docs from ${ref}`);
}
const extract = childProcess.spawnSync("tar", ["-x", "-C", destination, "--strip-components=1"], {
input: archive.stdout,
stdio: ["pipe", "inherit", "inherit"],
});
if (extract.status !== 0) {
throw new Error(`Failed to extract docs from ${ref}`);
}
}

function copyDocsFromWorkingTree(destination, { repoRoot = REPO_ROOT } = {}) {
const source = path.join(repoRoot, "docs");
emptyDir(destination);
fs.cpSync(source, destination, { recursive: true });
}

function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}

function rewriteVersionedDocsLinks(rootDir, versionLabel) {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
rewriteVersionedDocsLinks(entryPath, versionLabel);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
const text = fs.readFileSync(entryPath, "utf8");
const rewritten = text.replaceAll("](/docs/", `](/docs/${versionLabel}/`);
if (rewritten !== text) {
fs.writeFileSync(entryPath, rewritten);
}
}
}
}

function prepareVersionedDocs(plan, options = {}) {
const paths = docsPaths(options);
if (plan.current.source === "workingTree") {
copyDocsFromWorkingTree(paths.generatedCurrentDir, paths);
} else {
copyDocsFromRef(plan.current.ref, paths.generatedCurrentDir, paths);
}
emptyDir(paths.versionedDocsDir);
emptyDir(paths.versionedSidebarsDir);

for (const version of plan.versioned) {
const versionDir = path.join(paths.versionedDocsDir, safeVersionDirName(version.label));
copyDocsFromRef(
version.ref,
versionDir,
paths,
);
rewriteVersionedDocsLinks(versionDir, version.label);
writeJson(
path.join(paths.versionedSidebarsDir, `${safeVersionDirName(version.label)}-sidebars.json`),
SIDEBAR,
);
}

writeJson(paths.versionsJson, plan.versionsJson);
}

function main() {
const branches = listBranches();
const tags = listReleaseTags();
const plan = buildDocVersionPlan({ branches, tags });
if (plan.current.source === "gitRef") {
plan.current.ref = resolveGitRef(plan.current.ref);
}
plan.versioned = plan.versioned.map((version) => ({
...version,
ref: isReleaseTag(version.label) ? version.ref : resolveGitRef(version.label),
}));
prepareVersionedDocs(plan);
console.log(
`Prepared docs versions: current=${plan.current.label}; versions=${plan.versionsJson.join(", ") || "(none)"}`,
);
}

if (require.main === module) {
main();
}

module.exports = {
buildDocVersionPlan,
currentDocsSource,
docsPaths,
isReleaseTag,
safeVersionDirName,
resolveGitRef,
prepareVersionedDocs,
};
Loading
Loading