From ad720b593d360f40d16d47fd310581a8a4ec84ea Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 15 Aug 2026 21:00:56 -0500 Subject: [PATCH] fix(ui): collapse nested note groups --- scripts/build-site.mjs | 23 +++++++++----------- scripts/tree.mjs | 30 ++++++++++++++++++++++++++ scripts/tree.test.mjs | 48 ++++++++++++++++++++++++++++++++++++++++++ site/app.js | 37 ++++++++++---------------------- site/sidebar-tree.js | 30 ++++++++++++++++++++++++++ site/style.css | 48 +++++++++++++++++++++++++++++++++++++----- 6 files changed, 172 insertions(+), 44 deletions(-) create mode 100644 scripts/tree.mjs create mode 100644 scripts/tree.test.mjs create mode 100644 site/sidebar-tree.js 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) => + ``; + + 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); -- 2.51.2