diff --git a/docs-site/docusaurus.config.js b/docs-site/docusaurus.config.js index db1708b..4dc2508 100644 --- a/docs-site/docusaurus.config.js +++ b/docs-site/docusaurus.config.js @@ -121,6 +121,9 @@ const config = { }, items: [ { type: "doc", docId: "user/intro", label: "Docs", position: "left" }, + // Agent Skills is a peer of Docs, not a subsection of it. Targets the + // standalone `agents` sidebar so the entry follows the version dropdown. + { type: "docSidebar", sidebarId: "agents", label: "Agent Skills", position: "left" }, { type: "search", position: "left" }, { type: "docsVersionDropdown", position: "right" }, { diff --git a/docs-site/scripts/prepare-versioned-docs.js b/docs-site/scripts/prepare-versioned-docs.js index c66c1c2..d9a47f8 100644 --- a/docs-site/scripts/prepare-versioned-docs.js +++ b/docs-site/scripts/prepare-versioned-docs.js @@ -5,7 +5,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const { buildDocsSidebar } = require("../sidebarsConfig"); +const { buildDocsSidebar, buildAgentsSidebar } = require("../sidebarsConfig"); const { mirrorSkillsToAgents } = require("./mirror-skills"); const DOCS_SITE_DIR = path.resolve(__dirname, ".."); @@ -281,9 +281,15 @@ function prepareVersionedDocs(plan, options = {}) { handwrittenDir: paths.agentsSrcDir, destDir: path.join(versionDir, "agents"), }); + // Versions that predate the skills feature get no `agents` sidebar at all -- + // see buildAgentsSidebar. The navbar entry is guarded to match. + const agentsSidebar = buildAgentsSidebar(versionDir); writeJson( path.join(paths.versionedSidebarsDir, `${safeVersionDirName(version.label)}-sidebars.json`), - { docs: buildDocsSidebar(versionDir) }, + { + docs: buildDocsSidebar(versionDir), + ...(agentsSidebar ? { agents: agentsSidebar } : {}), + }, ); } diff --git a/docs-site/sidebars.js b/docs-site/sidebars.js index dbc187e..a02c00f 100644 --- a/docs-site/sidebars.js +++ b/docs-site/sidebars.js @@ -1,6 +1,6 @@ const path = require("path"); const fs = require("fs"); -const { buildDocsSidebar } = require("./sidebarsConfig"); +const { buildDocsSidebar, buildAgentsSidebar } = require("./sidebarsConfig"); // Mirror docusaurus.config.js: prefer the generated develop snapshot, otherwise // fall back to the repo-level docs/ directory for local dev. @@ -10,8 +10,14 @@ function currentDocsPath() { } /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ +const currentDocs = currentDocsPath(); +const agents = buildAgentsSidebar(currentDocs); + const sidebars = { - docs: buildDocsSidebar(currentDocsPath()), + docs: buildDocsSidebar(currentDocs), + // Spread rather than assign: Docusaurus rejects an empty/undefined sidebar, so a + // snapshot without skills must omit the key entirely. + ...(agents ? { agents } : {}), }; module.exports = sidebars; diff --git a/docs-site/sidebarsConfig.js b/docs-site/sidebarsConfig.js index 9befa05..83f3663 100644 --- a/docs-site/sidebarsConfig.js +++ b/docs-site/sidebarsConfig.js @@ -96,16 +96,9 @@ function buildDocsSidebar(docsDir) { if (categories.length) { sidebar.push({ type: "category", label: "Sflow User Guide", collapsed: false, items: categories }); } - // Agent Skills: a labeled category wrapping the autogenerated agents/ tree, - // present only when this snapshot ships the skills mirror. - if (dirHasDocs(docsDir, "agents")) { - sidebar.push({ - type: "category", - label: "Agent Skills", - collapsed: false, - items: [{ type: "autogenerated", dirName: "agents" }], - }); - } + // Agent Skills deliberately does NOT appear here -- it is its own top-level + // sidebar (see buildAgentsSidebar) reached from its own navbar entry, so it is + // a peer of the user guide rather than a subsection buried inside it. for (const dir of AUTOGEN_SECTIONS) { if (!EXCLUDED_DIRS.has(dir) && dirHasDocs(docsDir, dir)) { sidebar.push({ type: "autogenerated", dirName: dir }); @@ -120,6 +113,20 @@ function buildDocsSidebar(docsDir) { return sidebar; } +// Build the standalone `agents` sidebar for the Agent Skills navbar entry. +// The mirrored agents/ tree already carries sidebar_position / _category_.json +// ordering, so plain autogeneration is enough -- no wrapper category, which would +// only repeat the navbar label back at the reader. +// +// Returns null when this snapshot ships no skills (e.g. v0.1.0, which predates the +// feature). Callers must omit the sidebar entirely in that case: Docusaurus rejects +// an empty sidebar array, and a version that never had agent skills should not +// advertise them. +function buildAgentsSidebar(docsDir) { + if (!dirHasDocs(docsDir, "agents")) return null; + return [{ type: "autogenerated", dirName: "agents" }]; +} + module.exports = { USER_GUIDE_CATEGORIES, AUTOGEN_SECTIONS, @@ -127,4 +134,5 @@ module.exports = { listUserDocIds, dirHasDocs, buildDocsSidebar, + buildAgentsSidebar, }; diff --git a/docs-site/sidebarsConfig.test.js b/docs-site/sidebarsConfig.test.js index f828a91..b078eb5 100644 --- a/docs-site/sidebarsConfig.test.js +++ b/docs-site/sidebarsConfig.test.js @@ -4,7 +4,7 @@ const fs = require("fs"); const os = require("os"); const path = require("path"); -const { buildDocsSidebar } = require("./sidebarsConfig"); +const { buildDocsSidebar, buildAgentsSidebar } = require("./sidebarsConfig"); function makeDocs(files) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sflow-sidebar-")); @@ -43,16 +43,26 @@ test("groups user docs, keeps only existing ones, and buckets the rest under Mor { type: "category", label: "More", collapsed: false, items: ["user/zzz-extra"] }, ], }, - { - type: "category", - label: "Agent Skills", - collapsed: false, - items: [{ type: "autogenerated", dirName: "agents" }], - }, { type: "autogenerated", dirName: "release_notes" }, ]); }); +test("agent skills live in their own sidebar, not inside the docs sidebar", () => { + const dir = makeDocs(["user/intro.md", "agents/intro.md", "agents/writing-sflow-yaml/index.md"]); + + // The navbar entry owns the "Agent Skills" label, so the sidebar itself is just + // the autogenerated tree -- and it must not also appear under the docs sidebar. + assert.deepEqual(buildAgentsSidebar(dir), [{ type: "autogenerated", dirName: "agents" }]); + assert.equal(JSON.stringify(buildDocsSidebar(dir)).includes("agents"), false); +}); + +test("omits the agents sidebar entirely for snapshots that predate agent skills", () => { + // Docusaurus rejects an empty sidebar, and a version that never shipped skills + // should not advertise them -- so the key has to be absent, not empty. + assert.equal(buildAgentsSidebar(makeDocs(["user/intro.md"])), null); + assert.equal(buildAgentsSidebar(makeDocs(["agents/_category_.json"])), null); +}); + test("never includes the excluded plc or developer directories and falls back when nothing matches", () => { const dir = makeDocs(["plc/sflow_srd.md", "developer/note.md"]); assert.deepEqual(buildDocsSidebar(dir), [{ type: "autogenerated", dirName: "." }]); diff --git a/docs-site/src/pages/index.js b/docs-site/src/pages/index.js index 58e5f78..c27a600 100644 --- a/docs-site/src/pages/index.js +++ b/docs-site/src/pages/index.js @@ -1,36 +1,114 @@ import React from "react"; import Head from "@docusaurus/Head"; import useBaseUrl from "@docusaurus/useBaseUrl"; +import useIsBrowser from "@docusaurus/useIsBrowser"; +import { useLocation } from "@docusaurus/router"; + +// The homepage is a full-viewport frame around the static intro deck. Language is +// carried in the query string (`/?lang=zh`) rather than by swapping the iframe in +// place, so the Chinese deck is shareable, bookmarkable and survives a reload -- +// an in-place swap leaves the address bar on "/" and silently reverts on refresh. +// +// The deck itself drives this: when it detects it is framed, its language toggle +// rewrites the TOP url instead of navigating its own document. See the +// `langToggle` handler in static/sflow_intro*.html. +const DECKS = { + en: { + file: "/sflow_intro.html", + title: "NV-sflow — Declarative Workflow Descriptor", + description: + "Declarative workflow descriptor with swappable backends. Describe once, run anywhere.", + frameTitle: "NV-sflow Introduction", + htmlLang: "en", + }, + zh: { + file: "/sflow_intro_zh.html", + title: "NV-sflow — 面向大规模 GPU 集群的声明式工作流描述器", + description: "声明式工作流描述器,后端可自由切换。一次描述,随处运行。", + frameTitle: "NV-sflow 介绍", + htmlLang: "zh-CN", + }, +}; + +const DECK_BG = "#060a10"; // matches the deck's own background, so the pre-load frame is invisible export default function Home() { - const introUrl = useBaseUrl("/sflow_intro.html"); + const { search, hash } = useLocation(); + const isBrowser = useIsBrowser(); + + const lang = new URLSearchParams(search).get("lang") === "zh" ? "zh" : "en"; + const deck = DECKS[lang]; + + // Both are resolved unconditionally: useBaseUrl is a hook and cannot be called + // behind a branch without breaking the rules of hooks. + const enUrl = useBaseUrl(DECKS.en.file); + const zhUrl = useBaseUrl(DECKS.zh.file); + + // Forward the slide anchor (#s7) so switching language deep in the deck lands + // on the same slide instead of resetting to the title. + const slide = /^#s[0-9a-z]+$/i.test(hash) ? hash : ""; + const src = (lang === "zh" ? zhUrl : enUrl) + slide; return ( <> - NV-sflow — Declarative Workflow Descriptor - + + {deck.title} + + + -
-