Skip to content

Commit 8aa0358

Browse files
authored
feat(FR-2715): F3 information architecture (sidebar grouping, right-rail TOC, breadcrumbs) (#7009)
1 parent b1c6187 commit 8aa0358

9 files changed

Lines changed: 1251 additions & 292 deletions

File tree

packages/backend.ai-docs-toolkit/ARCHITECTURE.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,3 +534,128 @@ The same lookup also drives `<link rel="alternate" hreflang="…">` in the `<hea
534534
- **Air-gapped** — the language picker script is inline; no CDN, no fetch, no eval.
535535
- **JS budget** — the picker script is a few hundred bytes; the per-page switcher adds zero JS (links are plain `<a>`).
536536
- **PDF non-regression** — the cover page still receives the multi-line title via `titleMultiline`; the `<title>` element remains single-line via the existing `.trim().replace(/\n/g, " ")` safety net.
537+
538+
## F3 — Information architecture (sidebar grouping + right-rail TOC + breadcrumbs)
539+
540+
### Navigation schema (backward compatible)
541+
542+
`book.config.yaml`'s `navigation.<lang>` accepts two forms:
543+
544+
```yaml
545+
# Legacy (F1 and earlier; still accepted)
546+
navigation:
547+
en:
548+
- { title: Quickstart, path: quickstart.md }
549+
- { title: Overview, path: overview/overview.md }
550+
551+
# Grouped (F3)
552+
navigation:
553+
en:
554+
- category: Getting Started
555+
items:
556+
- { title: Quickstart, path: quickstart.md }
557+
- category: Workloads
558+
items:
559+
- { title: Sessions, path: sessions/sessions.md }
560+
```
561+
562+
`loadBookConfig` normalizes both forms into:
563+
564+
| Field | Shape | Used by |
565+
|------------------------|------------------------------------|---------------------------------------------------------------------------------|
566+
| `navigation` | `Record<lang, NavItem[]>` (flat) | PDF pipeline; web cross-language slug map; backward-compat consumers |
567+
| `navigationGroups` | `Record<lang, NavGroup[]>` | F3 web sidebar groups, breadcrumb category resolution |
568+
569+
When the input is the legacy flat form, `navigationGroups[lang]` contains a single group with `category: ""` (anonymous). The web sidebar renders an anonymous group as a flat list (no `<details>` drawer) so a config that hasn't migrated to grouped form looks identical to F1's sidebar — no breadcrumb middle segment, no group headers.
570+
571+
The two forms cannot be **mixed within a single language** (e.g. `en: [grouped, grouped, flat]`). The loader inspects only the first entry to decide the form; mismatched siblings are dropped with a warning. Each language can independently choose its form, however — `en: grouped, th: flat` is fine while a translator works through categorization.
572+
573+
Soft-fail policy on grouped-form structural problems:
574+
575+
- Group with empty `category:` → dropped with warning, items skipped.
576+
- Group with no valid `items` → dropped with warning.
577+
- Item missing `title` or `path` → dropped with warning, group keeps remaining items.
578+
579+
The build never crashes on these — matches F5's diagnostics-sink philosophy.
580+
581+
### Sidebar render (CSS-only collapsible groups)
582+
583+
The web sidebar renders one `<details class="doc-sidebar-group">` per category, each containing a `<ul class="doc-sidebar-nav doc-sidebar-nav--grouped">` of nav items. No JavaScript is involved — `<details>` natively handles the open/close state. The active page's group is `<details open>` on first load (computed at build time by checking which group contains the current item's path); other groups are collapsed by default.
584+
585+
The pre-F3 inline H2 sub-list under the active sidebar item is gone; that data lives in the right-rail TOC now.
586+
587+
### Right-rail "On this page" TOC
588+
589+
Every chapter page renders an `<aside class="doc-toc">` in the third grid column on desktop. The list shows H2 + H3 headings only (H4+ are excluded so the rail stays short on long chapters). The aside is sticky-positioned with `position: sticky; top: 0; height: 100vh; overflow-y: auto`.
590+
591+
**Scroll-spy**: `templates/assets/toc-scrollspy.js` (~1 KB) uses a single `IntersectionObserver` with `rootMargin: "-25% 0px -75% 0px"` (a 25%–75% spy band of the viewport). The link whose target heading is currently in the band gets `.is-active`. When no heading is intersecting (between two sections), the script falls back to picking the last heading whose top is above the spy line. On TOC link click, the active state syncs immediately so it doesn't lag behind the smooth-scroll animation.
592+
593+
The script ships only when `templates/assets/toc-scrollspy.js` exists — `website-generator.ts`'s asset pipeline registers it the same way as F4's optional `code-copy.js`. Pages with no H2 headings render an empty `<aside data-empty="true">`; CSS hides the heading and the script is a cheap no-op (no targets to observe).
594+
595+
### Breadcrumbs
596+
597+
A `<nav class="breadcrumb">` block appears between the page-header-bar (language switcher) and the chapter content. Format:
598+
599+
```
600+
Home › Category › Chapter Title
601+
```
602+
603+
- "Home" links to the per-language `index.html` landing page.
604+
- "Category" is the F3 group containing the page (rendered as plain text — no per-category landing exists).
605+
- "Chapter Title" is the current page (plain text, marked `aria-current="page"`).
606+
607+
When the page belongs to the synthetic anonymous group (legacy flat config), the middle segment is dropped — `Home › Chapter Title`. Separators (`›`, U+203A) are CSS pseudo-elements, so the structural HTML stays semantic (`<ol>` of `<li>` segments).
608+
609+
The breadcrumb is intentionally **not** placed inside the `<header class="page-header-bar">` flex container. The header is reserved for site-level controls (lang switcher, F6's incoming version selector). Keeping the breadcrumb as a separate block above the chapter avoids tight coupling with F6's layout.
610+
611+
### Layout grid
612+
613+
`.doc-page` is a 3-column CSS grid:
614+
615+
| Column | Width |
616+
|----------------|--------------------------------------------------|
617+
| `.doc-sidebar` | `var(--doc-sidebar-width)` (260px) |
618+
| `.doc-main` | `minmax(0, 1fr)` — fills remaining width, max 960px content |
619+
| `.doc-toc` | `var(--doc-toc-width)` (220px) |
620+
621+
Responsive breakpoints:
622+
623+
- **≤1100px**: drop the third column. `.doc-toc` is hidden via `display: none`. Headings remain anchor-reachable from inline `#` links — the TOC content is recoverable, just not always visible. (Moving the rail inline would require runtime JS to relocate the `<aside>`; out of scope for F3.)
624+
- **≤768px**: collapse to a single column. Sidebar becomes a top strip (`max-height: 40vh`), main content flows below.
625+
626+
### Localization
627+
628+
WEBSITE_LABELS (`config.ts`) gains three keys:
629+
630+
| Key | en | ja | ko | th |
631+
|---------------|-----------------|------------------|-------------------|-------------------|
632+
| `home` | "Home" | "ホーム" | "홈" | "หน้าแรก" |
633+
| `onThisPage` | "On this page" | "このページの目次" | "이 페이지의 목차" | "หัวข้อในหน้านี้" |
634+
| `tocToggle` | (same) | (same) | (same) | (same) |
635+
636+
Category labels themselves are localized **inline in `book.config.yaml`** — each language declares its own `category:` strings. There's no per-category lookup table; the localized label for the breadcrumb is whatever the matching nav-group's `category:` field says.
637+
638+
### PDF pipeline non-regression
639+
640+
The PDF pipeline reads `bookConfig.navigation[lang]` (the flat list). Since the flat list is constructed by walking `navigationGroups` in order and concatenating items, the PDF chapter ordering follows the grouped sequence. For the WebUI docs this means the legacy `Vfolder/Sessions All/.../Cluster Session` interleave is replaced by a clean `Getting Started → Workloads → Storage & Data → Administration → Reference` order — a small, deliberate ordering change documented in the F3 PR. The PDF pipeline itself never sees the categories, so the cover page, TOC entries, and chapter outline structure are unaffected.
641+
642+
### Files touched
643+
644+
| File | Role |
645+
|------------------------------------------------|---------------------------------------------------------------------------------------------|
646+
| `src/book-config.ts` | `RawNavigation` type union (flat \| grouped); `NavGroup`/`NavItem` exports; normalization |
647+
| `src/website-builder.ts` | `buildBreadcrumb`, `buildRightRailToc`, grouped sidebar, `tocScrollspy` asset slot |
648+
| `src/website-generator.ts` | Resolve `categoryByPath` per language; write `toc-scrollspy.js`; pass `navGroups`+`category` |
649+
| `src/styles-web.ts` | 3-column grid; sidebar `<details>` styles; breadcrumb; right-rail TOC; responsive breakpoints |
650+
| `src/config.ts` | New WEBSITE_LABELS keys: `home`, `onThisPage`, `tocToggle` |
651+
| `src/index.ts` | Re-export `NavGroup`, `NavItem`, `RawNavigation` |
652+
| `templates/assets/toc-scrollspy.js` (new) | IntersectionObserver scroll-spy (~1 KB) |
653+
| `packages/backend.ai-webui-docs/src/book.config.yaml` | Author the default 5-category mapping for all 29 chapters in 4 languages |
654+
655+
### Constraints honoured (F3)
656+
657+
- **Air-gapped** — scroll-spy is a small bundled script using native IntersectionObserver; no CDN, no fetch.
658+
- **JS budget** — `toc-scrollspy.js` source is ~3 KB unminified, well under the per-page 25 KB budget.
659+
- **CSS-only collapse** — sidebar groups use native `<details>`/`<summary>`; no runtime JS for collapse.
660+
- **PDF non-regression** — flat `navigation` shape preserved for the PDF pipeline; only the chapter ordering changes (intentionally).
661+
- **Backward compat** — flat `navigation` form still loads and renders identically to F1's sidebar.

packages/backend.ai-docs-toolkit/src/book-config.ts

Lines changed: 201 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,89 @@
1818
* only for the PDF cover page where line breaks are
1919
* load-bearing for visual layout.
2020
*
21-
* Callers that need the layout-friendly multi-line form (currently only the
22-
* PDF cover renderer) should opt into `titleMultiline`. Everything else
23-
* should use `title`.
21+
* F3 extends the `navigation` schema to accept either the legacy flat list
22+
* form or a list of `{ category, items }` groups — see the doc-comment on
23+
* `RawNavigation` below. The loader normalizes whichever form it sees into
24+
* BOTH:
25+
*
26+
* - `navigation` — flat `Record<lang, NavItem[]>`. Backward-
27+
* compatible shape; what the PDF pipeline reads
28+
* and what the web cross-language slug map joins
29+
* on. Group ordering is preserved when flattening.
30+
* - `navigationGroups` — categorized `Record<lang, NavGroup[]>`. The web
31+
* sidebar reads this to render collapsible groups
32+
* and to compute the per-page breadcrumb category.
33+
* Flat input is wrapped in a single anonymous
34+
* group with `category: ""` (rendered as an
35+
* uncategorized flat list — visually identical to
36+
* the legacy sidebar).
37+
*
38+
* Soft-failure on grouped-form structural problems (empty category strings,
39+
* empty `items`) — a warning is printed and the offending group is dropped,
40+
* but the build does not crash. The PDF pipeline never sees the categories
41+
* (it consumes the flat `navigation`), so structural issues in the grouped
42+
* form never affect PDF builds.
2443
*/
2544

2645
import fs from "fs";
2746
import path from "path";
2847
import { parse as parseYaml } from "yaml";
2948

49+
/** Single navigation item — same in both flat and grouped forms. */
50+
export interface NavItem {
51+
title: string;
52+
path: string;
53+
}
54+
55+
/** A category group (F3 grouped form). */
56+
export interface NavGroup {
57+
/**
58+
* Localized category label shown in the sidebar and breadcrumb. Empty
59+
* string is reserved for the synthetic "uncategorized" group produced
60+
* when the input was the legacy flat form. Authors should never write
61+
* an empty `category:` themselves — the loader drops such groups with a
62+
* warning to avoid silently rendering an unlabeled drawer.
63+
*/
64+
category: string;
65+
items: NavItem[];
66+
}
67+
68+
/**
69+
* Per-language `navigation` value — either the legacy flat list or the F3
70+
* grouped form.
71+
*
72+
* Legacy (F1 and earlier; still accepted):
73+
*
74+
* navigation:
75+
* en:
76+
* - { title: Quickstart, path: quickstart.md }
77+
* - { title: Overview, path: overview/overview.md }
78+
*
79+
* Grouped (F3):
80+
*
81+
* navigation:
82+
* en:
83+
* - category: Getting Started
84+
* items:
85+
* - { title: Quickstart, path: quickstart.md }
86+
* - category: Workloads
87+
* items:
88+
* - { title: Sessions, path: sessions/sessions.md }
89+
*
90+
* The two forms cannot be mixed within the same language — a non-grouped
91+
* entry inside an otherwise grouped list is dropped with a warning. Each
92+
* language can independently choose its form (e.g., `en` grouped, `th`
93+
* flat) so a translator who hasn't categorized yet doesn't block the rest
94+
* of the site.
95+
*/
96+
export type RawNavigation = NavItem[] | RawNavGroup[];
97+
98+
/** Grouped form before validation/normalization. */
99+
interface RawNavGroup {
100+
category: string;
101+
items: NavItem[];
102+
}
103+
30104
/**
31105
* Raw shape of `book.config.yaml`. Kept intentionally minimal — pipelines
32106
* that care about extra fields cast through `Record<string, unknown>` from
@@ -36,7 +110,7 @@ export interface RawBookConfig {
36110
title: string;
37111
description?: string;
38112
languages: string[];
39-
navigation: Record<string, Array<{ title: string; path: string }>>;
113+
navigation: Record<string, RawNavigation>;
40114
}
41115

42116
/**
@@ -45,6 +119,11 @@ export interface RawBookConfig {
45119
* `titleMultiline` always equals the raw value (post-`trim`, with newlines
46120
* preserved) so pipelines that need the visual line breaks have a stable
47121
* field to read. `title` is always single-line.
122+
*
123+
* `navigation` is always the flat per-language list (group ordering is
124+
* preserved when flattening). `navigationGroups` is the same data partitioned
125+
* into the F3 group form — pipelines that don't care about grouping (PDF,
126+
* cross-language slug map) keep using `navigation` unchanged.
48127
*/
49128
export interface NormalizedBookConfig {
50129
/** Single-line, whitespace-collapsed. Safe for `<title>` and headers. */
@@ -53,7 +132,14 @@ export interface NormalizedBookConfig {
53132
titleMultiline: string;
54133
description: string;
55134
languages: string[];
56-
navigation: Record<string, Array<{ title: string; path: string }>>;
135+
/** Flat per-language nav list (backward-compatible, used by PDF pipeline). */
136+
navigation: Record<string, NavItem[]>;
137+
/**
138+
* Per-language nav grouped into categories (F3). Always present — flat
139+
* input becomes a single group with `category: ""`. Web pipeline reads
140+
* this; PDF ignores it.
141+
*/
142+
navigationGroups: Record<string, NavGroup[]>;
57143
}
58144

59145
/**
@@ -64,6 +150,105 @@ export function normalizeTitle(raw: string): string {
64150
return raw.replace(/\s+/g, " ").trim();
65151
}
66152

153+
/**
154+
* Type guard — does this look like a grouped entry (has `category` and
155+
* `items`)? Soft check: if either field is missing the entry is treated as
156+
* a flat NavItem, which the validator below will reject if it lacks `path`.
157+
*/
158+
function isGroupedEntry(entry: unknown): entry is RawNavGroup {
159+
if (typeof entry !== "object" || entry === null) return false;
160+
const obj = entry as Record<string, unknown>;
161+
return "category" in obj && "items" in obj;
162+
}
163+
164+
/**
165+
* Normalize a per-language navigation value into both the flat list (for
166+
* PDF / slug-map consumers) and the grouped list (for the F3 web sidebar).
167+
*
168+
* Soft-fails on per-entry problems: structurally invalid entries are
169+
* dropped with a console warning but never crash the build. This matches
170+
* F5's diagnostics-sink philosophy — the build keeps going so authors see
171+
* the maximum amount of feedback per run.
172+
*/
173+
function normalizePerLangNavigation(
174+
lang: string,
175+
raw: RawNavigation | undefined,
176+
): { flat: NavItem[]; groups: NavGroup[] } {
177+
if (!raw || !Array.isArray(raw) || raw.length === 0) {
178+
return { flat: [], groups: [] };
179+
}
180+
181+
// Decide form by inspecting the first entry. The two forms cannot be
182+
// mixed — entries that don't match the chosen form are dropped.
183+
const grouped = isGroupedEntry(raw[0]);
184+
185+
if (grouped) {
186+
const groups: NavGroup[] = [];
187+
const flat: NavItem[] = [];
188+
for (const entry of raw) {
189+
if (!isGroupedEntry(entry)) {
190+
console.warn(
191+
`[book.config.yaml] navigation.${lang}: ignoring entry with no "category" inside a grouped list (mixed forms are not allowed): ${JSON.stringify(entry)}`,
192+
);
193+
continue;
194+
}
195+
const category = (entry.category ?? "").toString().trim();
196+
const items = Array.isArray(entry.items) ? entry.items : [];
197+
if (!category) {
198+
console.warn(
199+
`[book.config.yaml] navigation.${lang}: dropping group with empty category (${items.length} item(s) skipped)`,
200+
);
201+
continue;
202+
}
203+
const validItems = items.filter((it): it is NavItem => {
204+
if (
205+
typeof it !== "object" ||
206+
it === null ||
207+
typeof (it as NavItem).title !== "string" ||
208+
typeof (it as NavItem).path !== "string"
209+
) {
210+
console.warn(
211+
`[book.config.yaml] navigation.${lang}/${category}: dropping item missing title/path: ${JSON.stringify(it)}`,
212+
);
213+
return false;
214+
}
215+
return true;
216+
});
217+
if (validItems.length === 0) {
218+
console.warn(
219+
`[book.config.yaml] navigation.${lang}: dropping group "${category}" with no valid items`,
220+
);
221+
continue;
222+
}
223+
groups.push({ category, items: validItems });
224+
for (const it of validItems) flat.push(it);
225+
}
226+
return { flat, groups };
227+
}
228+
229+
// Legacy flat form. Wrap in a single anonymous group ("") so the F3 web
230+
// sidebar has a uniform shape to render — empty-string category gets a
231+
// visually-flat fallback path in `buildWebsiteSidebar`.
232+
const flat: NavItem[] = [];
233+
for (const entry of raw) {
234+
if (
235+
typeof entry !== "object" ||
236+
entry === null ||
237+
typeof (entry as NavItem).title !== "string" ||
238+
typeof (entry as NavItem).path !== "string"
239+
) {
240+
console.warn(
241+
`[book.config.yaml] navigation.${lang}: dropping flat entry missing title/path: ${JSON.stringify(entry)}`,
242+
);
243+
continue;
244+
}
245+
flat.push(entry as NavItem);
246+
}
247+
const groups: NavGroup[] =
248+
flat.length > 0 ? [{ category: "", items: flat }] : [];
249+
return { flat, groups };
250+
}
251+
67252
/**
68253
* Read `book.config.yaml` from the language-source root and return both the
69254
* normalized title (used everywhere except the PDF cover) and the original
@@ -76,11 +261,21 @@ export function loadBookConfig(srcDir: string): NormalizedBookConfig {
76261
const titleMultiline = (raw.title ?? "").trimEnd();
77262
const title = normalizeTitle(titleMultiline);
78263

264+
const navigation: Record<string, NavItem[]> = {};
265+
const navigationGroups: Record<string, NavGroup[]> = {};
266+
const rawNav = raw.navigation ?? {};
267+
for (const [lang, value] of Object.entries(rawNav)) {
268+
const { flat, groups } = normalizePerLangNavigation(lang, value);
269+
navigation[lang] = flat;
270+
navigationGroups[lang] = groups;
271+
}
272+
79273
return {
80274
title,
81275
titleMultiline,
82276
description: raw.description ?? "",
83277
languages: raw.languages ?? [],
84-
navigation: raw.navigation ?? {},
278+
navigation,
279+
navigationGroups,
85280
};
86281
}

0 commit comments

Comments
 (0)