Skip to content

Commit 85180ec

Browse files
committed
feat(docs): collapse sidebar navigation
1 parent f6c01da commit 85180ec

2 files changed

Lines changed: 84 additions & 18 deletions

File tree

scripts/build-docs.mjs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -266,20 +266,24 @@ function documentationSidebar(pages, currentHref) {
266266
const rightIndex = navigationOrder.indexOf(right.href);
267267
return (leftIndex < 0 ? Number.MAX_SAFE_INTEGER : leftIndex) - (rightIndex < 0 ? Number.MAX_SAFE_INTEGER : rightIndex);
268268
});
269+
const containsCurrent = (page) => page.href === currentHref
270+
|| (childrenByHref.get(page.href) ?? []).some(containsCurrent);
269271
const renderLink = (page) => `<a href="${page.href}"${page.href === currentHref ? ' aria-current="page"' : ""}>${escapeHtml(page.title.replace(/`/g, ""))}</a>`;
270272
const renderItem = (page) => {
271273
const children = sortPages(childrenByHref.get(page.href) ?? []);
272-
const nested = children.length > 0 ? `<div class="docs-nav-children">${children.map(renderItem).join("")}</div>` : "";
273-
return `<div class="docs-nav-item">${renderLink(page)}${nested}</div>`;
274+
if (children.length === 0) return `<div class="docs-nav-item">${renderLink(page)}</div>`;
275+
const open = containsCurrent(page) ? " open" : "";
276+
return `<details class="docs-nav-item docs-nav-branch"${open}><summary>${renderLink(page)}</summary><div class="docs-nav-children">${children.map(renderItem).join("")}</div></details>`;
274277
};
275278
const rootPages = pages.filter((page) => !parentFor(page));
276279
const groups = navigationGroups.map((group) => {
277280
const groupPages = sortPages(rootPages.filter(group.include));
278281
if (groupPages.length === 0) return "";
279-
return `<section class="docs-nav-section"><h2>${group.label}</h2>${groupPages.map(renderItem).join("")}</section>`;
282+
const open = groupPages.some(containsCurrent) ? " open" : "";
283+
return `<details class="docs-nav-section"${open}><summary>${group.label}</summary><div class="docs-nav-section-items">${groupPages.map(renderItem).join("")}</div></details>`;
280284
}).join("");
281285
const overviewLink = overview ? `<a class="docs-nav-overview" href="${overview.href}"${overview.href === currentHref ? ' aria-current="page"' : ""}>${escapeHtml(overview.title)}</a>` : "";
282-
return `<aside class="docs-sidebar"><details open><summary>Documentation navigation</summary><nav aria-label="Documentation navigation">${overviewLink}${groups}</nav></details></aside>`;
286+
return `<aside class="docs-sidebar"><details class="docs-sidebar-disclosure" open><summary>Documentation navigation</summary><nav aria-label="Documentation navigation">${overviewLink}${groups}</nav></details></aside>`;
283287
}
284288

285289
function layout({ title, body, breadcrumb, sidebar, hasMermaid }) {
@@ -296,17 +300,23 @@ function layout({ title, body, breadcrumb, sidebar, hasMermaid }) {
296300
.docs-layout { display: grid; grid-template-columns: minmax(13.5rem, 17rem) minmax(0, 48rem); justify-content: center; gap: clamp(2rem, 5vw, 5.5rem); align-items: start; }
297301
.docs-sidebar { position: sticky; top: 6.25rem; max-height: calc(100vh - 7.5rem); overflow-y: auto; padding: 0 1.5rem 1.5rem 0; border-right: 1px solid var(--border); }
298302
.docs-sidebar details { margin: 0; }
299-
.docs-sidebar summary { display: none; }
300-
.docs-sidebar nav { display: grid; gap: 1.45rem; }
303+
.docs-sidebar-disclosure > summary { display: none; }
304+
.docs-sidebar nav { display: grid; gap: 0.22rem; }
301305
.docs-sidebar a { display: block; padding: 0.34rem 0.62rem; border-left: 2px solid transparent; color: var(--muted); font-size: 0.88rem; line-height: 1.35; text-decoration: none; }
302306
.docs-sidebar a:hover { color: var(--ink); background: var(--card-hover); }
303307
.docs-sidebar a[aria-current="page"] { border-left-color: var(--accent-strong); color: var(--accent-strong); font-weight: 700; background: rgba(167, 83, 66, 0.1); }
304308
.docs-sidebar .docs-nav-overview { margin-bottom: 0.3rem; color: var(--ink); font-weight: 700; }
305-
.docs-nav-section { display: grid; gap: 0.12rem; }
309+
.docs-nav-section > summary, .docs-nav-branch > summary { list-style: none; cursor: pointer; }
310+
.docs-nav-section > summary::-webkit-details-marker, .docs-nav-branch > summary::-webkit-details-marker { display: none; }
311+
.docs-nav-section > summary { display: flex; align-items: center; gap: 0.4rem; padding: 0.42rem 0.62rem; color: var(--ink); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
312+
.docs-nav-section > summary::before, .docs-nav-branch > summary::before { content: ""; width: 0; height: 0; flex: 0 0 auto; border-top: 0.25rem solid transparent; border-bottom: 0.25rem solid transparent; border-left: 0.35rem solid currentColor; transition: transform 120ms ease; }
313+
.docs-nav-section[open] > summary::before, .docs-nav-branch[open] > summary::before { transform: rotate(90deg); }
314+
.docs-nav-section-items { display: grid; gap: 0.12rem; padding-bottom: 0.35rem; }
306315
.docs-nav-item { display: grid; gap: 0.12rem; }
316+
.docs-nav-branch > summary { display: flex; align-items: center; color: var(--muted); }
317+
.docs-nav-branch > summary > a { min-width: 0; flex: 1; }
307318
.docs-nav-children { display: grid; gap: 0.08rem; margin: 0.05rem 0 0.2rem 0.82rem; padding-left: 0.36rem; border-left: 1px solid var(--border); }
308319
.docs-nav-children a { font-size: 0.8rem; }
309-
.docs-nav-section h2 { margin: 0 0 0.35rem 0.62rem; color: var(--ink); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; }
310320
.docs-page { min-width: 0; }
311321
.doc-shell.docs-page { max-width: none; border: 0; border-radius: 0; background: transparent; box-shadow: none; padding: 0; }
312322
.doc-shell.docs-page h1 { margin: 0 0 0.625rem; font-size: clamp(1.3rem, 3vw, 2.8rem); line-height: 1.08; letter-spacing: -0.045em; }
@@ -345,7 +355,7 @@ function layout({ title, body, breadcrumb, sidebar, hasMermaid }) {
345355
.docs-main { width: min(100% - 1.25rem, 48rem); padding-top: 1.25rem; }
346356
.docs-layout { display: block; }
347357
.docs-sidebar { position: static; max-height: none; overflow: visible; margin-bottom: 1.75rem; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-strong); }
348-
.docs-sidebar summary { display: block; padding: 0.9rem 1rem; color: var(--ink); cursor: pointer; font-size: 0.9rem; font-weight: 700; }
358+
.docs-sidebar-disclosure > summary { display: block; padding: 0.9rem 1rem; color: var(--ink); cursor: pointer; font-size: 0.9rem; font-weight: 700; }
349359
.docs-sidebar nav { padding: 0 0.5rem 0.75rem; }
350360
}
351361
</style>

tests/docs-build.test.mjs

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,60 @@ test("documentation examples are tracked source files with recorded successful v
7878
}
7979
});
8080

81+
test("documentation sidebar expands only the current route path", async () => {
82+
run("node", ["scripts/build-blog.mjs"]);
83+
run("node", ["scripts/build-docs.mjs"]);
84+
85+
const cases = [
86+
{
87+
route: "../public/docs/index.html",
88+
openGroup: null,
89+
openBranches: [],
90+
active: "RustScript Documentation",
91+
},
92+
{
93+
route: "../public/docs/learn/embed-pd-vm/index.html",
94+
openGroup: "Getting Started",
95+
openBranches: [],
96+
active: "Use in Rust projects",
97+
},
98+
{
99+
route: "../public/docs/reference/rss/builtins/global/index.html",
100+
openGroup: "Reference",
101+
openBranches: ["Syntax Cheatsheet", "Builtins"],
102+
active: "Global functions",
103+
},
104+
{
105+
route: "../public/docs/reference/ironrust/internals/index.html",
106+
openGroup: "Ecosystem",
107+
openBranches: ["IronRust"],
108+
active: "Internals",
109+
},
110+
];
111+
112+
for (const expected of cases) {
113+
const html = await readFile(new URL(expected.route, import.meta.url), "utf8");
114+
const window = new Window();
115+
window.document.write(html);
116+
117+
const groups = [...window.document.querySelectorAll("details.docs-nav-section")];
118+
assert.deepEqual(groups.map((group) => group.firstElementChild.textContent.trim()), [
119+
"Getting Started", "Reference", "Ecosystem", "Contribute", "About",
120+
]);
121+
assert.deepEqual(
122+
groups.filter((group) => group.hasAttribute("open")).map((group) => group.firstElementChild.textContent.trim()),
123+
expected.openGroup ? [expected.openGroup] : [],
124+
);
125+
assert.deepEqual(
126+
[...window.document.querySelectorAll("details.docs-nav-branch[open] > summary > a")]
127+
.map((link) => link.textContent.trim()),
128+
expected.openBranches,
129+
);
130+
assert.equal(window.document.querySelector('[aria-current="page"]')?.textContent.trim(), expected.active);
131+
window.close();
132+
}
133+
});
134+
81135
test("Use in Rust projects renders verified Rust and highlighted TOML", async () => {
82136
run("node", ["scripts/build-blog.mjs"]);
83137
run("node", ["scripts/build-docs.mjs"]);
@@ -159,9 +213,10 @@ test("API reference covers every catalog entry and renders nested module navigat
159213
const globalHtml = await readFile(new URL("../public/docs/reference/rss/builtins/global/index.html", import.meta.url), "utf8");
160214
const window = new Window();
161215
window.document.write(globalHtml);
162-
const syntaxLink = [...window.document.querySelectorAll(".docs-nav-item > a")].find((link) => link.textContent === "Syntax Cheatsheet");
216+
const syntaxLink = [...window.document.querySelectorAll(".docs-nav-item a")].find((link) => link.textContent === "Syntax Cheatsheet");
163217
assert.ok(syntaxLink);
164-
const syntaxChildren = [...syntaxLink.parentElement.children].find((element) => element.classList.contains("docs-nav-children"));
218+
const syntaxBranch = syntaxLink.closest("details.docs-nav-branch");
219+
const syntaxChildren = [...syntaxBranch.children].find((element) => element.classList.contains("docs-nav-children"));
165220
const sectionItems = [...syntaxChildren.children];
166221
assert.deepEqual(sectionItems.map((item) => item.firstElementChild.textContent), ["Builtins", "Stdlibs"]);
167222
const builtinsChildren = [...sectionItems[0].children].find((element) => element.classList.contains("docs-nav-children"));
@@ -214,10 +269,11 @@ test("IronRust documents the checked-in WinForms example and its implementation
214269
assert.ok(internalsText.includes(expected), `IronRust internals missing ${expected}`);
215270
}
216271

217-
const ironRustLink = [...window.document.querySelectorAll(".docs-nav-item > a")]
272+
const ironRustLink = [...window.document.querySelectorAll(".docs-nav-item a")]
218273
.find((link) => link.textContent === "IronRust");
219274
assert.ok(ironRustLink);
220-
const children = [...ironRustLink.parentElement.children]
275+
const ironRustBranch = ironRustLink.closest("details.docs-nav-branch");
276+
const children = [...ironRustBranch.children]
221277
.find((element) => element.classList.contains("docs-nav-children"));
222278
assert.deepEqual(
223279
[...children.children].map((item) => item.firstElementChild.textContent),
@@ -263,11 +319,11 @@ test("documentation generator emits the main routes", async () => {
263319
assert.match(rssHtml, /aria-label="Documentation navigation"/);
264320
assert.match(rssHtml, /href="\/docs\/reference\/rss\/" aria-current="page">Syntax Cheatsheet<\/a>/);
265321
assert.match(rssHtml, /<h1 id="syntax-cheatsheet">Syntax Cheatsheet<\/h1>/);
266-
assert.match(rssHtml, /<h2>Getting Started<\/h2>/);
267-
assert.doesNotMatch(rssHtml, /<h2>Learn<\/h2>/);
268-
assert.match(rssHtml, /<h2>Ecosystem<\/h2>/);
269-
assert.match(rssHtml, /<div class="docs-nav-item"><a href="\/docs\/reference\/pd-edge\/">pd-edge<\/a><div class="docs-nav-children">[\s\S]*href="\/docs\/reference\/pd-edge\/full-dag\/">Full DAG Graphs<\/a>/);
270-
assert.match(rssHtml, /<div class="docs-nav-item"><a href="\/docs\/reference\/rustscript\/">RustScript<\/a><div class="docs-nav-children">[\s\S]*href="\/docs\/reference\/rustscript\/development\/">Development and tooling<\/a>/);
322+
assert.match(rssHtml, /<details class="docs-nav-section"><summary>Getting Started<\/summary>/);
323+
assert.doesNotMatch(rssHtml, /<summary>Learn<\/summary>/);
324+
assert.match(rssHtml, /<details class="docs-nav-section"><summary>Ecosystem<\/summary>/);
325+
assert.match(rssHtml, /<details class="docs-nav-item docs-nav-branch"><summary><a href="\/docs\/reference\/pd-edge\/">pd-edge<\/a><\/summary><div class="docs-nav-children">[\s\S]*href="\/docs\/reference\/pd-edge\/full-dag\/">Full DAG Graphs<\/a>/);
326+
assert.match(rssHtml, /<details class="docs-nav-item docs-nav-branch"><summary><a href="\/docs\/reference\/rustscript\/">RustScript<\/a><\/summary><div class="docs-nav-children">[\s\S]*href="\/docs\/reference\/rustscript\/development\/">Development and tooling<\/a>/);
271327
assert.doesNotMatch(rssHtml, />[^<]*reference<\/h1>/i);
272328
assert.match(rssHtml, /<table class="docs-table">/);
273329
assert.match(rssHtml, /<span class="tok-kw">use<\/span>/);

0 commit comments

Comments
 (0)