diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 845e5b8c5..8e06cc274 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -59,15 +59,25 @@ jobs: working-directory: docs run: npm ci + # Drift gate runs before regen — rationale lives in generate-reference.sh. + - name: Verify reference pages match source (drift gate) + run: bash docs/scripts/generate-reference.sh --check + + # Generate the docfx-driven /api/ tree and the Roslyn-driven + # /reference/ tree HERE — in the only job that has both the .NET SDK + # and docfx installed. The downstream Build VitePress site and + # Check internal links jobs receive the populated tree via the + # `generated-docs` artefact; they neither install dotnet nor docfx, + # so they cannot regenerate. The `prebuild` npm hook in + # docs/package.json is for local dev only (where `npm run build` + # implicitly regenerates); the workflow's Build step invokes + # VitePress directly to skip that hook. - name: Generate API reference run: bash docs/scripts/generate-api-ref.sh - name: Regenerate auto-generated reference pages run: bash docs/scripts/generate-reference.sh - - name: Verify reference pages match source (drift gate) - run: bash docs/scripts/generate-reference.sh --check - # upload-artifact@v4 strips the longest common prefix from the path list # (here: 'docs/'), so the artifact root contains 'api/' + 'reference/' # rather than 'docs/api/' + 'docs/reference/'. The downstream @@ -118,9 +128,15 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + # Invoke VitePress directly rather than `npm run build` to skip the + # `prebuild` npm hook in docs/package.json. That hook regenerates + # the /api/ + /reference/ trees, which requires the .NET SDK and + # docfx — neither of which is installed in this job. The + # `generated-docs` artefact downloaded above already contains the + # freshly regenerated content from the prepare-docs job. - name: Build site working-directory: docs - run: npm run build + run: npx vitepress build env: DOCS_BASE: /MTConnect.NET/ diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 8484837cf..c8d7e41de 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -65,6 +65,35 @@ jobs: 8.0.x 9.0.x + # MTConnect.NET-Docs-Tests carries a Category=E2E route walk + # whose [OneTimeSetUp] runs `npm ci && npm run build` from + # docs/ when the dist/ artifact is missing, then drives + # `vitepress preview` through Microsoft.Playwright. Only the + # ubuntu-latest leg runs Category=E2E (the windows-latest leg + # excludes it via testFilter), so Node is only required there. + - name: Setup Node.js (docs e2e prerequisite) + if: matrix.os == 'ubuntu-latest' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + cache: npm + cache-dependency-path: docs/package-lock.json + + # `npm run build` in docs/ invokes the `prebuild` hook + # (`npm run regen`), which calls `bash scripts/generate-api-ref.sh` + # → `docfx metadata` to produce the /api/ reference tree the + # VitePress site consumes. The Docs-site workflow installs docfx + # in its Prepare job; this workflow's E2E leg needs it for the + # same reason. Only the ubuntu-latest leg runs Category=E2E + # (windows-latest excludes it via testFilter), so docfx is only + # required there. Without this, RouteCheckTests.OneTimeSetUp + # fails with "docfx not found on PATH and ~/.dotnet/tools/docfx + # is missing" — see PR #181 run 26831500675 job 79113784371. + - name: Install docfx (docs e2e prerequisite) + if: matrix.os == 'ubuntu-latest' + run: dotnet tool install -g docfx + shell: bash + - name: Restore dotnet tools (ReportGenerator) run: dotnet tool restore @@ -74,6 +103,34 @@ jobs: - name: Build (Debug) run: dotnet build MTConnect.NET.sln --configuration Debug --no-restore + # Cache Playwright browser binaries so the Category=E2E + # [OneTimeSetUp] `playwright install chromium` call is a no-op on + # cache hit. The key hashes the `playwright-core` package.json that + # the Microsoft.Playwright NuGet copies into the build output — + # that file's `version` field directly pins the chromium revision + # the .NET binding drives. Hashing the csproj instead would only + # invalidate by coincidence (a future PR adding an unrelated + # PackageReference would invalidate; an SDK-side change to the + # chromium revision would not). The step therefore runs AFTER + # `dotnet build` so the file exists — placement before build + # would force the key off a workspace-internal artefact (hashFiles + # cannot reach the NuGet cache under ~/.nuget), so the + # micro-optimisation of overlapping cache restore with build is + # sacrificed for key correctness. + # Both Linux (~/.cache/ms-playwright) and Windows + # (%USERPROFILE%\AppData\Local\ms-playwright) paths are listed; + # actions/cache restores only the path that exists on the running + # OS, so the multi-path entry is safe for both matrix legs. + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright + ~\AppData\Local\ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/tests/MTConnect.NET-Docs-Tests/bin/Debug/net8.0/.playwright/package/package.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + # MTConnect.NET-Integration-Tests is skipped here (its # IsTestProject is false unless IntegrationCoverage=true) and run # in the dedicated step below so it can use its own diff --git a/.gitignore b/.gitignore index 899782835..dfbbed279 100644 --- a/.gitignore +++ b/.gitignore @@ -320,6 +320,7 @@ $RECYCLE.BIN/ node_modules/ docs/.vitepress/cache/ docs/.vitepress/dist/ +docs/.vitepress/.temp/ # docfx-generated API reference (regenerated by docs/scripts/generate-api-ref.sh # on every CI run; keep the section index but ignore the per-type pages). diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 58f9cde66..558e3c406 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vitepress'; import { withMermaid } from 'vitepress-plugin-mermaid'; +import { apiSidebar, referenceSidebar } from './sidebar'; /** * VitePress config for the MTConnect.NET documentation site. @@ -13,10 +14,15 @@ import { withMermaid } from 'vitepress-plugin-mermaid'; * * @remarks * The auto-generated reference section (`/reference/`) is produced by - * `build/MTConnect.NET-DocsGen` at docs-build time; do not edit those - * pages by hand. Adding a new section here that should also feed the - * sidebar — extend both the top `nav:` list and the matching entry in - * `sidebar:`. + * `build/MTConnect.NET-DocsGen` at docs-build time; the docfx-driven + * API reference (`/api/`) is produced by `docs/scripts/generate-api-ref.sh`. + * Do not edit those pages by hand. Both sidebars are derived from + * the generated output by `./sidebar.ts` — adding a new generated + * page surfaces automatically once the regen scripts have produced + * it (the npm `predev` / `prebuild` hooks run them on every dev + * server boot and build). Hand-curated narrative sections still + * extend the top `nav:` list plus the matching `sidebar:` entry + * below. * * @see {@link https://vitepress.dev/reference/site-config} */ @@ -124,24 +130,14 @@ export default withMermaid( ], }, ], - '/api/': [ - { - text: 'API reference', - items: [{ text: 'Overview', link: '/api/' }], - }, - ], - '/reference/': [ - { - text: 'Auto-generated reference', - items: [ - { text: 'Overview', link: '/reference/' }, - { text: 'HTTP API', link: '/reference/http-api' }, - { text: 'Environment variables', link: '/reference/environment-variables' }, - { text: 'Configuration schema', link: '/reference/configuration' }, - { text: 'CLI reference', link: '/reference/cli' }, - ], - }, - ], + // Auto-derived from docfx's toc.yml under docs/api/. Hierarchical + // by namespace dots, collapsed by default so the 1800-odd type + // pages do not flood the viewport. + '/api/': apiSidebar(), + // Auto-derived from the .md files under docs/reference/. Flat + // alphabetical list so a future page emitted by DocsGen surfaces + // without a config-side edit. + '/reference/': referenceSidebar(), '/wire-formats/': [ { text: 'Wire formats', diff --git a/docs/.vitepress/sidebar.ts b/docs/.vitepress/sidebar.ts new file mode 100644 index 000000000..3d3462606 --- /dev/null +++ b/docs/.vitepress/sidebar.ts @@ -0,0 +1,306 @@ +import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Auto-derived sidebars for the generated docs sections. + * + * Two trees feed this module: + * + * - `docs/api/` — docfx-generated reference for the public surface of + * every shipped MTConnect.NET library. ~1800 type pages keyed by + * `MTConnect...md`, plus a docfx-produced `toc.yml` + * that lists each namespace and the types under it. We parse `toc.yml` + * and build a hierarchical sidebar where namespace dots fold into + * nested collapsible groups — so `MTConnect.Devices.DataItems.SampleDataItem` + * lives at `MTConnect > Devices > DataItems > SampleDataItem`. + * + * - `docs/reference/` — Roslyn-generated narrative reference (CLI flags, + * environment variables, configuration schema, HTTP API). A flat + * alphabetical list keyed off whatever `.md` files the generator + * emits, so a future page added by `MTConnect.NET-DocsGen` surfaces + * in the sidebar without a config-side edit. + * + * Both functions are pure-fs reads; they run at VitePress config-load + * time (which is before the dev server boots and before the build + * traverses pages). The npm `predev` / `prebuild` hooks regenerate + * both trees before VitePress reads them, so a fresh clone produces + * a fully-populated sidebar on the first `npm run dev`. + */ + +// VitePress sidebar shapes (loosely typed — VitePress accepts plain +// objects, and pulling the official types would require an import that +// VitePress' own config doesn't enforce). +type SidebarItem = { + text: string; + link?: string; + collapsed?: boolean; + items?: SidebarItem[]; +}; + +// Resolve paths relative to this file so the module works whether +// VitePress is invoked from `docs/` or from the repo root. +const here = dirname(fileURLToPath(import.meta.url)); +const docsRoot = resolve(here, '..'); + +// ─── /api/ — docfx hierarchy ──────────────────────────────────────────────── + +// Internal tree node used while building the namespace hierarchy. +// Each node represents one dot-segment in a namespace path. `types` +// holds the leaf entries (sidebar items pointing at type pages); +// `overview` is the namespace landing page link if one exists. +type ApiNode = { + children: Map; + types: SidebarItem[]; + overview?: string; +}; + +/** Allocate an empty namespace-tree node — `children` empty, `types` empty, + * no overview link yet. */ +const makeNode = (): ApiNode => ({ children: new Map(), types: [] }); + +/** Strip the `.md` extension and any leading `/` so the result is a clean + * VitePress route, e.g. `MTConnect.Adapters.AgentClient.md` → + * `/api/MTConnect.Adapters.AgentClient`. Input: docfx href; output: + * VitePress route string. */ +const hrefToRoute = (href: string): string => + `/api/${href.replace(/\.md$/, '')}`; + +// Minimal `toc.yml` parser. The docfx-emitted file is regular enough +// that a line-oriented parse is robust and avoids pulling in a YAML +// dependency. Schema: +// +// - name: +// href: .md +// items: +// - name: Classes # section divider (no href) +// - name: +// href: ..md +// - name: Structs # next divider +// ... +// - name: +// ... +/** Parse the docfx-emitted `toc.yml` body into a flat list of namespace + * entries, each with its overview href and an ordered list of type + * entries. Tolerates section dividers (`- name: Classes` without href) + * by treating them as no-op pending entries. */ +const parseToc = (content: string) => { + const namespaces: Array<{ + name: string; + href: string; + types: Array<{ name: string; href: string }>; + }> = []; + + let current: (typeof namespaces)[number] | null = null; + let pending: { name: string; href?: string } | null = null; + let inItems = false; + + /** Commit the buffered type entry to `current.types` if it has an href; + * drop it silently if it was a section-divider (`- name: Classes`). */ + const flushPending = () => { + if (!pending || !current || !pending.href) return; + current.types.push({ name: pending.name, href: pending.href }); + pending = null; + }; + + for (const raw of content.split('\n')) { + const line = raw.replace(/\r$/, ''); + // Top-level namespace entry. + let m = /^- name: (.+)$/.exec(line); + if (m) { + flushPending(); + if (current) namespaces.push(current); + current = { name: m[1], href: '', types: [] }; + pending = null; + inItems = false; + continue; + } + // Top-level href for the namespace block currently being parsed. + m = /^ {2}href: (.+)$/.exec(line); + if (m && current && !inItems) { + current.href = m[1]; + continue; + } + if (/^ {2}items:$/.test(line)) { + inItems = true; + continue; + } + // Item-level entry inside the current namespace block. + m = /^ {2}- name: (.+)$/.exec(line); + if (m && inItems) { + flushPending(); + pending = { name: m[1] }; + continue; + } + m = /^ {4}href: (.+)$/.exec(line); + if (m && pending) { + pending.href = m[1]; + continue; + } + } + flushPending(); + if (current) namespaces.push(current); + return namespaces; +}; + +/** Build the nested namespace tree by walking each namespace's + * dot-separated path. Each segment becomes a child node; the final + * segment receives the namespace's overview href and the type list. + * Returns the synthetic root whose children are the top-level segments + * (`MTConnect`, …). */ +const buildApiTree = ( + namespaces: ReturnType, +): ApiNode => { + const root = makeNode(); + for (const ns of namespaces) { + const segments = ns.name.split('.'); + let node = root; + for (const segment of segments) { + let child = node.children.get(segment); + if (!child) { + child = makeNode(); + node.children.set(segment, child); + } + node = child; + } + if (ns.href) node.overview = hrefToRoute(ns.href); + for (const t of ns.types) { + node.types.push({ text: t.name, link: hrefToRoute(t.href) }); + } + } + return root; +}; + +/** Case-insensitive locale comparator so groups and types sort predictably + * regardless of underlying string ordering quirks (e.g. uppercase ASCII + * grouping ahead of lowercase). Stable on equal-keyed inputs. */ +const byTextCI = (a: SidebarItem, b: SidebarItem) => + a.text.localeCompare(b.text, 'en', { sensitivity: 'base' }); + +/** Recursively project the tree into VitePress sidebar items. A node with + * children becomes a collapsible group; types are sorted into the group + * alongside any nested child groups. The namespace overview (if present) + * leads the group as an "Overview" entry. Input: the path segment whose + * node we are projecting + the node itself; output: one SidebarItem. */ +const projectNode = (segment: string, node: ApiNode): SidebarItem => { + const items: SidebarItem[] = []; + if (node.overview) { + items.push({ text: 'Overview', link: node.overview }); + } + const typeItems = [...node.types].sort(byTextCI); + const childItems = [...node.children.entries()] + .map(([s, n]) => projectNode(s, n)) + .sort(byTextCI); + // Children (sub-namespaces) listed before types so the hierarchy + // reads top-down: nested namespaces first, then the types declared + // directly in this namespace. + items.push(...childItems, ...typeItems); + return { + text: segment, + collapsed: true, + items, + }; +}; + +// Module-level cache keyed on toc.yml mtime. VitePress hot-reloads the +// config on any edit to config.ts; without the cache, each hot-reload +// re-reads + re-parses the ~1800-namespace toc.yml. The mtime check +// keeps the cache correct when `npm run regen` rewrites toc.yml in the +// same Node process. +let apiSidebarCache: { mtimeMs: number; sidebar: SidebarItem[] } | undefined; + +/** + * Build the `/api/` sidebar from docfx's `toc.yml`. Returns a single + * top-level "API reference" group whose items are the nested namespace + * tree. Falls back to a one-entry "Overview" sidebar when the docfx + * output is missing (e.g. a tree without `npm run regen` first). + */ +export const apiSidebar = (): SidebarItem[] => { + const tocPath = resolve(docsRoot, 'api', 'toc.yml'); + const overview: SidebarItem = { text: 'Overview', link: '/api/' }; + if (!existsSync(tocPath)) { + return [{ text: 'API reference', items: [overview] }]; + } + const mtimeMs = statSync(tocPath).mtimeMs; + if (apiSidebarCache && apiSidebarCache.mtimeMs === mtimeMs) { + return apiSidebarCache.sidebar; + } + const tocBody = readFileSync(tocPath, 'utf8'); + const namespaces = parseToc(tocBody); + + // Self-check: every top-level `- name:` line should produce exactly one + // parsed namespace. A mismatch means the hand-rolled YAML parser silently + // dropped entries — most likely a docfx output reformat (extra indent, + // BOM, quoted strings). Fail loudly rather than letting the sidebar + // surface a partial tree. + const nameLineCount = tocBody.split('\n').filter((l) => /^- name: /.test(l)).length; + if (nameLineCount !== namespaces.length) { + throw new Error( + `sidebar.ts: parsed ${namespaces.length} namespace(s) from api/toc.yml but counted ${nameLineCount} top-level entries. ` + + `The hand-rolled parser likely needs updating — check for indentation or quoting changes from docfx.`, + ); + } + + const tree = buildApiTree(namespaces); + const topLevel = [...tree.children.entries()] + .map(([s, n]) => projectNode(s, n)) + .sort(byTextCI); + const sidebar: SidebarItem[] = [ + { + text: 'API reference', + items: [overview, ...topLevel], + }, + ]; + apiSidebarCache = { mtimeMs, sidebar }; + return sidebar; +}; + +// ─── /reference/ — Roslyn-generated narrative ───────────────────────────── + +/** Convert a file name like `environment-variables.md` to a sidebar label + * like `Environment variables` (lower-case-with-hyphens to sentence case, + * keeping mid-word capitals as-is for HTTP/CLI/etc.). Overrides cover + * acronyms only; no trailing-word suffix is added — the parent group + * ("Auto-generated reference") already supplies the "reference" context, + * so adding it per-leaf is redundant and the half-applied policy (only on + * `cli` and `configuration`) was the worst of both worlds. */ +const labelFor = (slug: string): string => { + const overrides: Record = { + cli: 'CLI', + 'http-api': 'HTTP API', + 'environment-variables': 'Environment variables', + configuration: 'Configuration', + }; + if (overrides[slug]) return overrides[slug]; + const spaced = slug.replace(/-/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +}; + +/** + * Build the `/reference/` sidebar by listing every `.md` file under + * `docs/reference/` except `index.md` (which is wired in as the + * "Overview" entry). Pages sort alphabetically by displayed label, + * so a new generator output appears without a config-side edit. + */ +export const referenceSidebar = (): SidebarItem[] => { + const refRoot = resolve(docsRoot, 'reference'); + const items: SidebarItem[] = [ + { text: 'Overview', link: '/reference/' }, + ]; + if (existsSync(refRoot)) { + const pages = readdirSync(refRoot) + .filter((f) => f.endsWith('.md') && f !== 'index.md') + .map((f) => { + const slug = f.replace(/\.md$/, ''); + return { text: labelFor(slug), link: `/reference/${slug}` }; + }) + .sort(byTextCI); + items.push(...pages); + } + return [ + { + text: 'Auto-generated reference', + items, + }, + ]; +}; diff --git a/docs/development/docs-site.md b/docs/development/docs-site.md index dfdad9271..73dd2497e 100644 --- a/docs/development/docs-site.md +++ b/docs/development/docs-site.md @@ -59,6 +59,29 @@ A fork or third-party deploy that hosts the same documentation under a different The classic symptom of a base mismatch is a deployed page that renders as raw HTML: title and tagline run together without spacing, the `Search` button reads `SearchK` because the keyboard-shortcut hint sits adjacent to the label without CSS spacing, and the nav links concatenate without separators. Check the page's HTML source for `` and `