Skip to content

Commit f6fdd8f

Browse files
committed
merge chore/docs-route-crawler (TrakHound#181) into integration
2 parents bf3e755 + d8721ce commit f6fdd8f

9 files changed

Lines changed: 816 additions & 28 deletions

File tree

.github/workflows/docs.yml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@ jobs:
5959
working-directory: docs
6060
run: npm ci
6161

62-
- name: Generate API reference
63-
run: bash docs/scripts/generate-api-ref.sh
64-
65-
- name: Regenerate auto-generated reference pages
66-
run: bash docs/scripts/generate-reference.sh
67-
62+
# Drift gate first, before any regeneration: verifies the committed
63+
# docs/reference/ pages already match what DocsGen would emit from
64+
# the current source tree. The subsequent `npm run build` step
65+
# invokes the `prebuild` hook (in docs/package.json), which runs
66+
# generate-api-ref.sh + generate-reference.sh and overwrites the
67+
# tree on disk — so checking drift after the build would always
68+
# pass. The api/ tree is gitignored and regenerated unconditionally;
69+
# only the reference/ tree carries committed content to drift-check.
6870
- name: Verify reference pages match source (drift gate)
6971
run: bash docs/scripts/generate-reference.sh --check
7072

.github/workflows/dotnet.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ jobs:
6565
8.0.x
6666
9.0.x
6767
68+
# MTConnect.NET-Docs-Tests carries a Category=E2E route walk
69+
# whose [OneTimeSetUp] runs `npm ci && npm run build` from
70+
# docs/ when the dist/ artifact is missing, then drives
71+
# `vitepress preview` through Microsoft.Playwright. Only the
72+
# ubuntu-latest leg runs Category=E2E (the windows-latest leg
73+
# excludes it via testFilter), so Node is only required there.
74+
- name: Setup Node.js (docs e2e prerequisite)
75+
if: matrix.os == 'ubuntu-latest'
76+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
77+
with:
78+
node-version: '20'
79+
cache: npm
80+
cache-dependency-path: docs/package-lock.json
81+
6882
- name: Restore dotnet tools (ReportGenerator)
6983
run: dotnet tool restore
7084

@@ -74,6 +88,24 @@ jobs:
7488
- name: Build (Debug)
7589
run: dotnet build MTConnect.NET.sln --configuration Debug --no-restore
7690

91+
# Cache Playwright browser binaries so the Category=E2E
92+
# [OneTimeSetUp] `playwright install chromium` call is a no-op on
93+
# cache hit. The key includes the test project's csproj hash so
94+
# a Microsoft.Playwright NuGet version bump auto-invalidates.
95+
# Both Linux (~/.cache/ms-playwright) and Windows
96+
# (%USERPROFILE%\AppData\Local\ms-playwright) paths are listed;
97+
# actions/cache restores only the path that exists on the running
98+
# OS, so the multi-path entry is safe for both matrix legs.
99+
- name: Cache Playwright browsers
100+
uses: actions/cache@v4
101+
with:
102+
path: |
103+
~/.cache/ms-playwright
104+
~\AppData\Local\ms-playwright
105+
key: ${{ runner.os }}-playwright-${{ hashFiles('**/tests/MTConnect.NET-Docs-Tests/MTConnect.NET-Docs-Tests.csproj') }}
106+
restore-keys: |
107+
${{ runner.os }}-playwright-
108+
77109
# MTConnect.NET-Integration-Tests is skipped here (its
78110
# IsTestProject is false unless IntegrationCoverage=true) and run
79111
# in the dedicated step below so it can use its own

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ $RECYCLE.BIN/
320320
node_modules/
321321
docs/.vitepress/cache/
322322
docs/.vitepress/dist/
323+
docs/.vitepress/.temp/
323324

324325
# docfx-generated API reference (regenerated by docs/scripts/generate-api-ref.sh
325326
# on every CI run; keep the section index but ignore the per-type pages).

docs/.vitepress/config.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineConfig } from 'vitepress';
22
import { withMermaid } from 'vitepress-plugin-mermaid';
3+
import { apiSidebar, referenceSidebar } from './sidebar';
34

45
/**
56
* VitePress config for the MTConnect.NET documentation site.
@@ -13,10 +14,15 @@ import { withMermaid } from 'vitepress-plugin-mermaid';
1314
*
1415
* @remarks
1516
* The auto-generated reference section (`/reference/`) is produced by
16-
* `build/MTConnect.NET-DocsGen` at docs-build time; do not edit those
17-
* pages by hand. Adding a new section here that should also feed the
18-
* sidebar — extend both the top `nav:` list and the matching entry in
19-
* `sidebar:`.
17+
* `build/MTConnect.NET-DocsGen` at docs-build time; the docfx-driven
18+
* API reference (`/api/`) is produced by `docs/scripts/generate-api-ref.sh`.
19+
* Do not edit those pages by hand. Both sidebars are derived from
20+
* the generated output by `./sidebar.ts` — adding a new generated
21+
* page surfaces automatically once the regen scripts have produced
22+
* it (the npm `predev` / `prebuild` hooks run them on every dev
23+
* server boot and build). Hand-curated narrative sections still
24+
* extend the top `nav:` list plus the matching `sidebar:` entry
25+
* below.
2026
*
2127
* @see {@link https://vitepress.dev/reference/site-config}
2228
*/
@@ -124,24 +130,14 @@ export default withMermaid(
124130
],
125131
},
126132
],
127-
'/api/': [
128-
{
129-
text: 'API reference',
130-
items: [{ text: 'Overview', link: '/api/' }],
131-
},
132-
],
133-
'/reference/': [
134-
{
135-
text: 'Auto-generated reference',
136-
items: [
137-
{ text: 'Overview', link: '/reference/' },
138-
{ text: 'HTTP API', link: '/reference/http-api' },
139-
{ text: 'Environment variables', link: '/reference/environment-variables' },
140-
{ text: 'Configuration schema', link: '/reference/configuration' },
141-
{ text: 'CLI reference', link: '/reference/cli' },
142-
],
143-
},
144-
],
133+
// Auto-derived from docfx's toc.yml under docs/api/. Hierarchical
134+
// by namespace dots, collapsed by default so the 1800-odd type
135+
// pages do not flood the viewport.
136+
'/api/': apiSidebar(),
137+
// Auto-derived from the .md files under docs/reference/. Flat
138+
// alphabetical list so a future page emitted by DocsGen surfaces
139+
// without a config-side edit.
140+
'/reference/': referenceSidebar(),
145141
'/wire-formats/': [
146142
{
147143
text: 'Wire formats',

docs/.vitepress/sidebar.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
2+
import { resolve, dirname } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
/**
6+
* Auto-derived sidebars for the generated docs sections.
7+
*
8+
* Two trees feed this module:
9+
*
10+
* - `docs/api/` — docfx-generated reference for the public surface of
11+
* every shipped MTConnect.NET library. ~1800 type pages keyed by
12+
* `MTConnect.<Namespace>.<Type>.md`, plus a docfx-produced `toc.yml`
13+
* that lists each namespace and the types under it. We parse `toc.yml`
14+
* and build a hierarchical sidebar where namespace dots fold into
15+
* nested collapsible groups — so `MTConnect.Devices.DataItems.SampleDataItem`
16+
* lives at `MTConnect > Devices > DataItems > SampleDataItem`.
17+
*
18+
* - `docs/reference/` — Roslyn-generated narrative reference (CLI flags,
19+
* environment variables, configuration schema, HTTP API). A flat
20+
* alphabetical list keyed off whatever `.md` files the generator
21+
* emits, so a future page added by `MTConnect.NET-DocsGen` surfaces
22+
* in the sidebar without a config-side edit.
23+
*
24+
* Both functions are pure-fs reads; they run at VitePress config-load
25+
* time (which is before the dev server boots and before the build
26+
* traverses pages). The npm `predev` / `prebuild` hooks regenerate
27+
* both trees before VitePress reads them, so a fresh clone produces
28+
* a fully-populated sidebar on the first `npm run dev`.
29+
*/
30+
31+
// VitePress sidebar shapes (loosely typed — VitePress accepts plain
32+
// objects, and pulling the official types would require an import that
33+
// VitePress' own config doesn't enforce).
34+
type SidebarItem = {
35+
text: string;
36+
link?: string;
37+
collapsed?: boolean;
38+
items?: SidebarItem[];
39+
};
40+
41+
// Resolve paths relative to this file so the module works whether
42+
// VitePress is invoked from `docs/` or from the repo root.
43+
const here = dirname(fileURLToPath(import.meta.url));
44+
const docsRoot = resolve(here, '..');
45+
46+
// ─── /api/ — docfx hierarchy ────────────────────────────────────────────────
47+
48+
// Internal tree node used while building the namespace hierarchy.
49+
// Each node represents one dot-segment in a namespace path. `types`
50+
// holds the leaf entries (sidebar items pointing at type pages);
51+
// `overview` is the namespace landing page link if one exists.
52+
type ApiNode = {
53+
children: Map<string, ApiNode>;
54+
types: SidebarItem[];
55+
overview?: string;
56+
};
57+
58+
const makeNode = (): ApiNode => ({ children: new Map(), types: [] });
59+
60+
// Strip the `.md` extension and any leading `/` so the result is a
61+
// clean VitePress route, e.g. `MTConnect.Adapters.AgentClient.md` ->
62+
// `/api/MTConnect.Adapters.AgentClient`.
63+
const hrefToRoute = (href: string): string =>
64+
`/api/${href.replace(/\.md$/, '')}`;
65+
66+
// Minimal `toc.yml` parser. The docfx-emitted file is regular enough
67+
// that a line-oriented parse is robust and avoids pulling in a YAML
68+
// dependency. Schema:
69+
//
70+
// - name: <namespace>
71+
// href: <namespace>.md
72+
// items:
73+
// - name: Classes # section divider (no href)
74+
// - name: <type>
75+
// href: <namespace>.<type>.md
76+
// - name: Structs # next divider
77+
// ...
78+
// - name: <next namespace>
79+
// ...
80+
const parseToc = (content: string) => {
81+
const namespaces: Array<{
82+
name: string;
83+
href: string;
84+
types: Array<{ name: string; href: string }>;
85+
}> = [];
86+
87+
let current: (typeof namespaces)[number] | null = null;
88+
let pending: { name: string; href?: string } | null = null;
89+
let inItems = false;
90+
91+
const flushPending = () => {
92+
if (!pending || !current || !pending.href) return;
93+
current.types.push({ name: pending.name, href: pending.href });
94+
pending = null;
95+
};
96+
97+
for (const raw of content.split('\n')) {
98+
const line = raw.replace(/\r$/, '');
99+
// Top-level namespace entry.
100+
let m = /^- name: (.+)$/.exec(line);
101+
if (m) {
102+
flushPending();
103+
if (current) namespaces.push(current);
104+
current = { name: m[1], href: '', types: [] };
105+
pending = null;
106+
inItems = false;
107+
continue;
108+
}
109+
// Top-level href for the namespace block currently being parsed.
110+
m = /^ {2}href: (.+)$/.exec(line);
111+
if (m && current && !inItems) {
112+
current.href = m[1];
113+
continue;
114+
}
115+
if (/^ {2}items:$/.test(line)) {
116+
inItems = true;
117+
continue;
118+
}
119+
// Item-level entry inside the current namespace block.
120+
m = /^ {2}- name: (.+)$/.exec(line);
121+
if (m && inItems) {
122+
flushPending();
123+
pending = { name: m[1] };
124+
continue;
125+
}
126+
m = /^ {4}href: (.+)$/.exec(line);
127+
if (m && pending) {
128+
pending.href = m[1];
129+
continue;
130+
}
131+
}
132+
flushPending();
133+
if (current) namespaces.push(current);
134+
return namespaces;
135+
};
136+
137+
// Build the nested namespace tree by walking each namespace's
138+
// dot-separated path. Each segment becomes a child node; the final
139+
// segment receives the namespace's overview href and the type list.
140+
const buildApiTree = (
141+
namespaces: ReturnType<typeof parseToc>,
142+
): ApiNode => {
143+
const root = makeNode();
144+
for (const ns of namespaces) {
145+
const segments = ns.name.split('.');
146+
let node = root;
147+
for (const segment of segments) {
148+
let child = node.children.get(segment);
149+
if (!child) {
150+
child = makeNode();
151+
node.children.set(segment, child);
152+
}
153+
node = child;
154+
}
155+
if (ns.href) node.overview = hrefToRoute(ns.href);
156+
for (const t of ns.types) {
157+
node.types.push({ text: t.name, link: hrefToRoute(t.href) });
158+
}
159+
}
160+
return root;
161+
};
162+
163+
// Case-insensitive locale comparator so groups and types sort
164+
// predictably regardless of underlying string ordering quirks
165+
// (e.g. uppercase ASCII grouping ahead of lowercase).
166+
const byTextCI = (a: SidebarItem, b: SidebarItem) =>
167+
a.text.localeCompare(b.text, 'en', { sensitivity: 'base' });
168+
169+
// Recursively project the tree into VitePress sidebar items. A node
170+
// with children becomes a collapsible group; types are sorted into
171+
// the group alongside any nested child groups. The namespace overview
172+
// (if present) leads the group as an "Overview" entry.
173+
const projectNode = (segment: string, node: ApiNode): SidebarItem => {
174+
const items: SidebarItem[] = [];
175+
if (node.overview) {
176+
items.push({ text: 'Overview', link: node.overview });
177+
}
178+
const typeItems = [...node.types].sort(byTextCI);
179+
const childItems = [...node.children.entries()]
180+
.map(([s, n]) => projectNode(s, n))
181+
.sort(byTextCI);
182+
// Children (sub-namespaces) listed before types so the hierarchy
183+
// reads top-down: nested namespaces first, then the types declared
184+
// directly in this namespace.
185+
items.push(...childItems, ...typeItems);
186+
return {
187+
text: segment,
188+
collapsed: true,
189+
items,
190+
};
191+
};
192+
193+
/**
194+
* Build the `/api/` sidebar from docfx's `toc.yml`. Returns a single
195+
* top-level "API reference" group whose items are the nested namespace
196+
* tree. Falls back to a one-entry "Overview" sidebar when the docfx
197+
* output is missing (e.g. a tree without `npm run regen` first).
198+
*/
199+
export const apiSidebar = (): SidebarItem[] => {
200+
const tocPath = resolve(docsRoot, 'api', 'toc.yml');
201+
const overview: SidebarItem = { text: 'Overview', link: '/api/' };
202+
if (!existsSync(tocPath)) {
203+
return [{ text: 'API reference', items: [overview] }];
204+
}
205+
const namespaces = parseToc(readFileSync(tocPath, 'utf8'));
206+
const tree = buildApiTree(namespaces);
207+
const topLevel = [...tree.children.entries()]
208+
.map(([s, n]) => projectNode(s, n))
209+
.sort(byTextCI);
210+
return [
211+
{
212+
text: 'API reference',
213+
items: [overview, ...topLevel],
214+
},
215+
];
216+
};
217+
218+
// ─── /reference/ — Roslyn-generated narrative ─────────────────────────────
219+
220+
// Convert a file name like `environment-variables.md` to a sidebar
221+
// label like `Environment variables` (lower-case-with-hyphens to
222+
// sentence case, keeping mid-word capitals as-is for HTTP/CLI/etc.).
223+
const labelFor = (slug: string): string => {
224+
// Hand-tuned overrides for common acronyms / multi-cap labels.
225+
const overrides: Record<string, string> = {
226+
cli: 'CLI reference',
227+
'http-api': 'HTTP API',
228+
'environment-variables': 'Environment variables',
229+
configuration: 'Configuration schema',
230+
};
231+
if (overrides[slug]) return overrides[slug];
232+
const spaced = slug.replace(/-/g, ' ');
233+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
234+
};
235+
236+
/**
237+
* Build the `/reference/` sidebar by listing every `.md` file under
238+
* `docs/reference/` except `index.md` (which is wired in as the
239+
* "Overview" entry). Pages sort alphabetically by displayed label,
240+
* so a new generator output appears without a config-side edit.
241+
*/
242+
export const referenceSidebar = (): SidebarItem[] => {
243+
const refRoot = resolve(docsRoot, 'reference');
244+
const items: SidebarItem[] = [
245+
{ text: 'Overview', link: '/reference/' },
246+
];
247+
if (existsSync(refRoot)) {
248+
const pages = readdirSync(refRoot)
249+
.filter((f) => f.endsWith('.md') && f !== 'index.md')
250+
.map((f) => {
251+
const slug = f.replace(/\.md$/, '');
252+
return { text: labelFor(slug), link: `/reference/${slug}` };
253+
})
254+
.sort(byTextCI);
255+
items.push(...pages);
256+
}
257+
return [
258+
{
259+
text: 'Auto-generated reference',
260+
items,
261+
},
262+
];
263+
};

0 commit comments

Comments
 (0)