diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs
index c77a8de..e6323d0 100644
--- a/scripts/build-site.mjs
+++ b/scripts/build-site.mjs
@@ -11,6 +11,7 @@ import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { analyzeMarkdown } from "./markdown.mjs";
+import { renderOverviewTree } from "./tree.mjs";
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
const siteSrcDir = path.join(repoRoot, "site");
@@ -106,6 +107,11 @@ async function main() {
html = html.replace(/(href|src)="\.\/(style\.css|app\.js|favicon\.svg|vendor\/[^"]+)"/g, `$1="./$2?v=${buildId}"`);
await writeFile(outIndex, html, "utf8");
+ const outApp = path.join(outDir, "app.js");
+ let app = await readFile(outApp, "utf8");
+ app = app.replace('"./sidebar-tree.js"', `"./sidebar-tree.js?v=${buildId}"`);
+ await writeFile(outApp, app, "utf8");
+
await mkdir(outDocsDir, { recursive: true });
// sources/ is hosted but not navigable: raw captures, not notes. copy the
@@ -159,20 +165,11 @@ async function main() {
const children = buildTree(section, files, titles);
- // a section with no README gets a generated index mirroring the tree:
- // directories become nested groups (linked via their README when they
- // have one), not a flat dump of every leaf. links are relative to the
- // section index's own location. (the generated index isn't a note)
+ // A section with no README gets a generated, progressively disclosed
+ // index. Parent groups start closed; their own README remains available
+ // through a small overview link. (The generated index isn't a note.)
if (!sectionIndex) {
- const renderTree = (nodes, depth) =>
- nodes.flatMap((n) => {
- const indent = " ".repeat(depth);
- const label = n.path
- ? `[${n.title}](${n.path.slice(section.length + 1)})`
- : `**${n.title}**`;
- return [`${indent}- ${label}`, ...renderTree(n.children || [], depth + 1)];
- });
- const indexMd = [`# ${section}`, "", ...renderTree(children, 0), ""].join("\n");
+ const indexMd = [`# ${section}`, "", renderOverviewTree(children, section), ""].join("\n");
sectionIndex = `${section}/README.md`;
await writeFile(path.join(outDocsDir, section, "README.md"), indexMd, "utf8");
}
diff --git a/scripts/tree.mjs b/scripts/tree.mjs
new file mode 100644
index 0000000..ed5a53d
--- /dev/null
+++ b/scripts/tree.mjs
@@ -0,0 +1,30 @@
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+export function renderOverviewTree(nodes, section) {
+ const renderNodes = (items) =>
+ `
${items
+ .map((node) => {
+ const children = Array.isArray(node.children) ? node.children : [];
+ const title = escapeHtml(node.title);
+ const href = node.path ? escapeHtml(node.path.slice(section.length + 1)) : null;
+
+ if (!children.length) {
+ return `- ${title}
`;
+ }
+
+ const pageLink = href
+ ? `overview`
+ : "";
+ return `${title}${pageLink}
${renderNodes(children)} `;
+ })
+ .join("")}
`;
+
+ return `${renderNodes(nodes)}
`;
+}
diff --git a/scripts/tree.test.mjs b/scripts/tree.test.mjs
new file mode 100644
index 0000000..51f8dd2
--- /dev/null
+++ b/scripts/tree.test.mjs
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "bun:test";
+import { renderOverviewTree } from "./tree.mjs";
+import { renderTreeNodes } from "../site/sidebar-tree.js";
+
+const nodes = [
+ {
+ title: "python",
+ path: "languages/python/README.md",
+ children: [
+ {
+ title: "ecosystem",
+ children: [{ title: "tooling", path: "languages/python/ecosystem/tooling.md" }],
+ },
+ ],
+ },
+];
+
+const routeFor = (path) => path.replace(/\/README\.md$|\.md$/g, "");
+const renderLeaf = (node, activePath, cls) =>
+ `${node.title}`;
+
+describe("progressive note trees", () => {
+ test("the generated overview starts every parent closed", () => {
+ const html = renderOverviewTree(nodes, "languages");
+
+ expect(html).toContain("python");
+ expect(html).toContain("ecosystem");
+ expect(html).not.toContain(" {
+ const inactive = renderTreeNodes(nodes, "languages/typescript", {
+ routeFor,
+ renderLeaf,
+ escapeHtml: String,
+ });
+ const active = renderTreeNodes(nodes, "languages/python/ecosystem/tooling", {
+ routeFor,
+ renderLeaf,
+ escapeHtml: String,
+ });
+
+ expect(inactive).not.toContain(" open");
+ expect(active.match(//g)).toHaveLength(2);
+ });
+});
diff --git a/site/app.js b/site/app.js
index 669bda3..2ae02a5 100644
--- a/site/app.js
+++ b/site/app.js
@@ -1,3 +1,5 @@
+import { renderTreeNodes, subtreeContains } from "./sidebar-tree.js";
+
const navEl = document.getElementById("nav");
const sidebarEl = document.getElementById("sidebar");
const contentEl = document.getElementById("content");
@@ -127,15 +129,10 @@ function sidebarLabel(page) {
return routeFor(page.path) === "" ? "Overview" : page.title || page.path;
}
-// The left tree, rendered recursively: sections are collapsible
-// (open when the active page lives inside), directories are sub-groups
-// (linked when they have a README), notes are leaves. Page headings live in
+// The left tree is progressively disclosed: every parent is a , and
+// only the active page's ancestor chain opens automatically. Directories link
+// to their README when they have one; notes are leaves. Page headings live in
// the right-hand #toc column, never in this tree.
-function subtreeContains(node, activePath) {
- if (node.path && routeFor(node.path) === activePath) return true;
- return (node.children || []).some((c) => subtreeContains(c, activePath));
-}
-
function renderLeaf(node, activePath, cls) {
const p = routeFor(node.path);
const active = p === activePath;
@@ -144,22 +141,6 @@ function renderLeaf(node, activePath, cls) {
} href="${pageHref(p)}">${escapeHtml(node.title || p)}`;
}
-function renderNodes(nodes, activePath) {
- return nodes
- .map((node) => {
- const kids = Array.isArray(node.children) ? node.children : [];
- if (!kids.length) {
- return `${renderLeaf(node, activePath, "s-child")}`;
- }
- // a directory: its label links to its README when it has one
- const label = node.path
- ? renderLeaf(node, activePath, "s-dir")
- : `${escapeHtml(node.title)}`;
- return `${label}${renderNodes(kids, activePath)}
`;
- })
- .join("");
-}
-
function renderSidebar(pages, activePath) {
const items = pages
.map((p) => {
@@ -170,11 +151,15 @@ function renderSidebar(pages, activePath) {
active ? ' aria-current="page"' : ""
} href="${pageHref(p.path)}">${escapeHtml(sidebarLabel(p))}`;
if (!kids.length) return `${link}`;
- const open = active || subtreeContains(p, activePath);
+ const open = active || subtreeContains(p, activePath, routeFor);
return (
`` +
`${link}
` +
- `${renderNodes(kids, activePath)}
` +
+ `${renderTreeNodes(kids, activePath, {
+ routeFor,
+ renderLeaf,
+ escapeHtml,
+ })}
` +
` `
);
})
diff --git a/site/sidebar-tree.js b/site/sidebar-tree.js
new file mode 100644
index 0000000..dd156c2
--- /dev/null
+++ b/site/sidebar-tree.js
@@ -0,0 +1,30 @@
+export function subtreeContains(node, activePath, routeFor) {
+ if (node.path && routeFor(node.path) === activePath) return true;
+ return (node.children || []).some((child) => subtreeContains(child, activePath, routeFor));
+}
+
+export function renderTreeNodes(nodes, activePath, { routeFor, renderLeaf, escapeHtml }) {
+ return nodes
+ .map((node) => {
+ const children = Array.isArray(node.children) ? node.children : [];
+ if (!children.length) {
+ return `${renderLeaf(node, activePath, "s-child")}`;
+ }
+
+ const label = node.path
+ ? renderLeaf(node, activePath, "s-dir")
+ : `${escapeHtml(node.title)}`;
+ const open = subtreeContains(node, activePath, routeFor);
+ return (
+ `` +
+ `${label}
` +
+ `${renderTreeNodes(children, activePath, {
+ routeFor,
+ renderLeaf,
+ escapeHtml,
+ })}
` +
+ ` `
+ );
+ })
+ .join("");
+}
diff --git a/site/style.css b/site/style.css
index 92bc817..734e0d0 100644
--- a/site/style.css
+++ b/site/style.css
@@ -328,18 +328,18 @@ a:hover {
font-weight: 600;
background: var(--accent);
}
-/* collapsible sections: the caret is the toggle, the summary link navigates */
-.sidebar details.s-section > summary {
+/* collapsible tree: only the active page's ancestry opens automatically */
+.sidebar details > summary {
display: flex;
align-items: center;
gap: 2px;
cursor: pointer;
list-style: none;
}
-.sidebar details.s-section > summary::-webkit-details-marker {
+.sidebar details > summary::-webkit-details-marker {
display: none;
}
-.sidebar details.s-section > summary::before {
+.sidebar details > summary::before {
content: "";
flex-shrink: 0;
width: 14px;
@@ -349,12 +349,15 @@ a:hover {
clip-path: polygon(35% 25%, 70% 50%, 35% 75%);
transition: transform 0.12s;
}
-.sidebar details.s-section[open] > summary::before {
+.sidebar details[open] > summary::before {
transform: rotate(90deg);
}
.sidebar details.s-section > summary .s-page {
flex: 1;
}
+.sidebar details.s-group > summary .s-dir {
+ flex: 1;
+}
/* directory group labels inside a section */
.sidebar .s-dir {
display: block;
@@ -529,6 +532,41 @@ a:hover {
padding: 0 14px;
}
+/* Generated section indexes are trees, not a wall of nested bullets. */
+.content .overview-tree > ul,
+.content .overview-tree ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.content .overview-tree details {
+ margin: 6px 0;
+}
+.content .overview-tree details > summary {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 600;
+}
+.content .overview-tree details > summary span {
+ flex: 1;
+}
+.content .overview-tree details > ul {
+ margin: 4px 14px 10px;
+ padding: 2px 0 2px 18px;
+ border-left: 1px solid var(--border);
+}
+.content .overview-tree li > a {
+ display: block;
+ padding: 5px 8px;
+}
+.content .overview-tree .overview-page-link {
+ flex: none;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 500;
+}
+
.content hr {
border: none;
border-top: 1px solid var(--border);