┌─ ooknet-design/src/lib/notes.ts ───────────────────────────────────────────┐│ diff --git a/ooknet-design/src/lib/notes.ts b/ooknet-design/src/lib/notes.ts ││ index 8cb12cb..2ce3edc 100644 ││ --- a/ooknet-design/src/lib/notes.ts ││ +++ b/ooknet-design/src/lib/notes.ts ││ @@ -1,5 +1,6 @@ ││ // Pure note helpers. astro:content stays in the pages so this module ││ // is unit-testable. ││ +import type { TreeNode } from "../components/Tree/Tree"; ││ ││ export const published = ({ data }: { data: { draft?: boolean } }) => !data.draft ││ ; ││ ││ @@ -11,3 +12,41 @@ type HasNote = { data: { note: string } }; ││ /** Ascending by note number: N0, N1, ..., N217. */ ││ export const byNoteNum = (a: HasNote, b: HasNote): number => ││ noteNum(a.data.note) - noteNum(b.data.note); ││ + ││ +export interface FiledNote { ││ + id: string; ││ + note: string; ││ + filed: string; ││ +} ││ + ││ +/** Builds a Tree from the notes' filed paths: branches are path ││ + * segments, leaves link to the note. */ ││ +export function filedTree(notes: FiledNote[]): TreeNode[] { ││ + type Branch = { children: Map<string, Branch>; leaves: TreeNode[] }; ││ + const root: Branch = { children: new Map(), leaves: [] }; ││ + ││ + for (const n of notes) { ││ + const segments = n.filed.split("/").filter(Boolean); ││ + if (!segments.length) segments.push("UNFILED"); ││ + let branch = root; ││ + for (const seg of segments.slice(0, -1)) { ││ + if (!branch.children.has(seg)) branch.children.set(seg, { children: new Map ││ (), leaves: [] }); ││ + branch = branch.children.get(seg)!; ││ + } ││ + branch.leaves.push({ ││ + label: `${segments[segments.length - 1]} - ${n.note}`, ││ + href: `/notes/${n.id}/`, ││ + }); ││ + } ││ + ││ + const toNodes = (b: Branch, depth: number): TreeNode[] => [ ││ + ...[...b.children.entries()] ││ + .sort(([a], [c]) => a.localeCompare(c)) ││ + .map(([seg, child]) => ({ ││ + label: depth === 0 ? `/${seg}` : seg, ││ + children: toNodes(child, depth + 1), ││ + })), ││ + ...b.leaves.sort((a, c) => a.label.localeCompare(c.label)), ││ + ]; ││ + return toNodes(root, 0); ││ +} │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/notes.ts ─────┐│ diff --git a/ooknet-design/src/lib/notes.ts ││ b/ooknet-design/src/lib/notes.ts ││ index 8cb12cb..2ce3edc 100644 ││ --- a/ooknet-design/src/lib/notes.ts ││ +++ b/ooknet-design/src/lib/notes.ts ││ @@ -1,5 +1,6 @@ ││ // Pure note helpers. astro:content stays i ││ n the pages so this module ││ // is unit-testable. ││ +import type { TreeNode } from "../component ││ s/Tree/Tree"; ││ ││ export const published = ({ data }: { data: ││ { draft?: boolean } }) => !data.draft; ││ ││ @@ -11,3 +12,41 @@ type HasNote = { data: { ││ note: string } }; ││ /** Ascending by note number: N0, N1, ..., ││ N217. */ ││ export const byNoteNum = (a: HasNote, b: Ha ││ sNote): number => ││ noteNum(a.data.note) - noteNum(b.data.not ││ e); ││ + ││ +export interface FiledNote { ││ + id: string; ││ + note: string; ││ + filed: string; ││ +} ││ + ││ +/** Builds a Tree from the notes' filed pat ││ hs: branches are path ││ + * segments, leaves link to the note. */ ││ +export function filedTree(notes: FiledNote[ ││ ]): TreeNode[] { ││ + type Branch = { children: Map<string, Bra ││ nch>; leaves: TreeNode[] }; ││ + const root: Branch = { children: new Map( ││ ), leaves: [] }; ││ + ││ + for (const n of notes) { ││ + const segments = n.filed.split("/").fil ││ ter(Boolean); ││ + if (!segments.length) segments.push("UN ││ FILED"); ││ + let branch = root; ││ + for (const seg of segments.slice(0, -1) ││ ) { ││ + if (!branch.children.has(seg)) branch ││ .children.set(seg, { children: new Map(), le ││ aves: [] }); ││ + branch = branch.children.get(seg)!; ││ + } ││ + branch.leaves.push({ ││ + label: `${segments[segments.length - ││ 1]} - ${n.note}`, ││ + href: `/notes/${n.id}/`, ││ + }); ││ + } ││ + ││ + const toNodes = (b: Branch, depth: number ││ ): TreeNode[] => [ ││ + ...[...b.children.entries()] ││ + .sort(([a], [c]) => a.localeCompare(c ││ )) ││ + .map(([seg, child]) => ({ ││ + label: depth === 0 ? `/${seg}` : se ││ g, ││ + children: toNodes(child, depth + 1) ││ , ││ + })), ││ + ...b.leaves.sort((a, c) => a.label.loca ││ leCompare(c.label)), ││ + ]; ││ + return toNodes(root, 0); ││ +} │└──────────────────────────────────────────────┘
┌─ ooknet-design/src/lib/rehype-ascii.ts ────────────────────────────────────┐│ diff --git a/ooknet-design/src/lib/rehype-ascii.ts b/ooknet-design/src/lib/rehype- ││ ascii.ts ││ index c781d6d..4e8152a 100644 ││ --- a/ooknet-design/src/lib/rehype-ascii.ts ││ +++ b/ooknet-design/src/lib/rehype-ascii.ts ││ @@ -4,7 +4,8 @@ ││ // gets a head-rule. Everything emits both the desktop and mobile ││ // variants in the same tree; CSS toggles visibility per viewport. ││ import type { Root, Element, ElementContent, RootContent, Properties } from "hast ││ "; ││ -import { G, checkGrid, len, pad, wrap, type PadSide } from "./ascii"; ││ +import { G, checkGrid, len, wrap, type PadSide } from "./ascii"; ││ +import { buildAsciiTable, type Cell } from "./ascii-table"; ││ import { FRAME_W, MOBILE_FRAME_W } from "./config"; ││ import { mermaidToAscii } from "./mermaid"; ││ ││ @@ -122,8 +123,6 @@ function makeCodePre(text: string, lang: string, frameW: numbe ││ r, extraClass: str ││ ││ // --------------------------------------------------------------- table ││ ││ -export type Cell = { text: string; align: PadSide }; ││ - ││ function tableCells(tbl: Element): { rows: Cell[][]; headerRows: number } { ││ const rows: Cell[][] = []; ││ let headerRows = 0; ││ @@ -146,50 +145,6 @@ function tableCells(tbl: Element): { rows: Cell[][]; headerRo ││ ws: number } { ││ return { rows, headerRows }; ││ } ││ ││ -export function buildAsciiTable(rows: Cell[][], headerRows: number, width: number ││ ): string { ││ - const nCols = Math.max(...rows.map((r) => r.length)); ││ - const norm = rows.map((r) => { ││ - const out = [...r]; ││ - while (out.length < nCols) out.push({ text: "", align: "end" }); ││ - return out; ││ - }); ││ - ││ - // Natural column widths, then shave the widest until the table fits. ││ - // Cells wrap to their final column width. ││ - const colW = Array.from({ length: nCols }, (_, i) => ││ - Math.max(1, ...norm.map((r) => len(r[i].text))), ││ - ); ││ - const total = () => 1 + colW.reduce((s, w) => s + w + 3, 0); ││ - const MIN_COL = 5; ││ - while (total() > width && colW.some((w) => w > MIN_COL)) { ││ - colW[colW.indexOf(Math.max(...colW))]--; ││ - } ││ - ││ - const edge = (l: string, m: string, r: string, h = G.H) => ││ - l + colW.map((w) => h.repeat(w + 2)).join(m) + r; ││ - ││ - // When any row wraps, dashed separators between body rows keep the ││ - // logical rows readable; all-single-line tables stay compact. ││ - const cellLines = norm.map((row) => row.map((c, i) => wrap(c.text, colW[i]).spl ││ it("\n"))); ││ - const anyWrapped = cellLines.some((row) => row.some((l) => l.length > 1)); ││ - ││ - const out: string[] = [edge(G.TL, G.TJ, G.TR)]; ││ - norm.forEach((row, ri) => { ││ - if (anyWrapped && ri > headerRows) out.push(edge(G.LJ, G.X, G.RJ, G.HD)); ││ - const h = Math.max(...cellLines[ri].map((l) => l.length)); ││ - for (let k = 0; k < h; k++) { ││ - out.push( ││ - G.V + ││ - row.map((c, i) => ` ${pad(cellLines[ri][i][k] ?? "", colW[i], c.align)} ││ `).join(G.V) + ││ - G.V, ││ - ); ││ - } ││ - if (ri === headerRows - 1) out.push(edge(G.LJ, G.X, G.RJ)); ││ - }); ││ - out.push(edge(G.BL, G.BJ, G.BR)); ││ - return checkGrid(out.join("\n"), width, "table"); ││ -} ││ - ││ function makeTablePre(text: string, extraClass: string): Element { ││ return { ││ type: "element", │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...esign/src/lib/rehype-ascii.ts ───┐│ diff --git a/ooknet-design/src/lib/rehype-as ││ cii.ts b/ooknet-design/src/lib/rehype-ascii. ││ ts ││ index c781d6d..4e8152a 100644 ││ --- a/ooknet-design/src/lib/rehype-ascii.ts ││ +++ b/ooknet-design/src/lib/rehype-ascii.ts ││ @@ -4,7 +4,8 @@ ││ // gets a head-rule. Everything emits both ││ the desktop and mobile ││ // variants in the same tree; CSS toggles v ││ isibility per viewport. ││ import type { Root, Element, ElementContent ││ , RootContent, Properties } from "hast"; ││ -import { G, checkGrid, len, pad, wrap, type ││ PadSide } from "./ascii"; ││ +import { G, checkGrid, len, wrap, type PadS ││ ide } from "./ascii"; ││ +import { buildAsciiTable, type Cell } from ││ "./ascii-table"; ││ import { FRAME_W, MOBILE_FRAME_W } from "./ ││ config"; ││ import { mermaidToAscii } from "./mermaid"; ││ ││ @@ -122,8 +123,6 @@ function makeCodePre(tex ││ t: string, lang: string, frameW: number, ext ││ raClass: str ││ ││ // ---------------------------------------- ││ ----------------------- table ││ ││ -export type Cell = { text: string; align: P ││ adSide }; ││ - ││ function tableCells(tbl: Element): { rows: ││ Cell[][]; headerRows: number } { ││ const rows: Cell[][] = []; ││ let headerRows = 0; ││ @@ -146,50 +145,6 @@ function tableCells(tbl ││ : Element): { rows: Cell[][]; headerRows: nu ││ mber } { ││ return { rows, headerRows }; ││ } ││ ││ -export function buildAsciiTable(rows: Cell[ ││ ][], headerRows: number, width: number): str ││ ing { ││ - const nCols = Math.max(...rows.map((r) => ││ r.length)); ││ - const norm = rows.map((r) => { ││ - const out = [...r]; ││ - while (out.length < nCols) out.push({ t ││ ext: "", align: "end" }); ││ - return out; ││ - }); ││ - ││ - // Natural column widths, then shave the ││ widest until the table fits. ││ - // Cells wrap to their final column width ││ . ││ - const colW = Array.from({ length: nCols } ││ , (_, i) => ││ - Math.max(1, ...norm.map((r) => len(r[i] ││ .text))), ││ - ); ││ - const total = () => 1 + colW.reduce((s, w ││ ) => s + w + 3, 0); ││ - const MIN_COL = 5; ││ - while (total() > width && colW.some((w) = ││ > w > MIN_COL)) { ││ - colW[colW.indexOf(Math.max(...colW))]-- ││ ; ││ - } ││ - ││ - const edge = (l: string, m: string, r: st ││ ring, h = G.H) => ││ - l + colW.map((w) => h.repeat(w + 2)).jo ││ in(m) + r; ││ - ││ - // When any row wraps, dashed separators ││ between body rows keep the ││ - // logical rows readable; all-single-line ││ tables stay compact. ││ - const cellLines = norm.map((row) => row.m ││ ap((c, i) => wrap(c.text, colW[i]).split("\n ││ "))); ││ - const anyWrapped = cellLines.some((row) = ││ > row.some((l) => l.length > 1)); ││ - ││ - const out: string[] = [edge(G.TL, G.TJ, G ││ .TR)]; ││ - norm.forEach((row, ri) => { ││ - if (anyWrapped && ri > headerRows) out. ││ push(edge(G.LJ, G.X, G.RJ, G.HD)); ││ - const h = Math.max(...cellLines[ri].map ││ ((l) => l.length)); ││ - for (let k = 0; k < h; k++) { ││ - out.push( ││ - G.V + ││ - row.map((c, i) => ` ${pad(cellLin ││ es[ri][i][k] ?? "", colW[i], c.align)} `).jo ││ in(G.V) + ││ - G.V, ││ - ); ││ - } ││ - if (ri === headerRows - 1) out.push(edg ││ e(G.LJ, G.X, G.RJ)); ││ - }); ││ - out.push(edge(G.BL, G.BJ, G.BR)); ││ - return checkGrid(out.join("\n"), width, " ││ table"); ││ -} ││ - ││ function makeTablePre(text: string, extraCl ││ ass: string): Element { ││ return { ││ type: "element", │└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/about.astro ──────────────────────────────────────┐│ diff --git a/ooknet-design/src/pages/about.astro b/ooknet-design/src/pages/about.a ││ stro ││ new file mode 100644 ││ index 0000000..95c4cda ││ --- /dev/null ││ +++ b/ooknet-design/src/pages/about.astro ││ @@ -0,0 +1,49 @@ ││ +--- ││ +import ArticleLayout from "../layouts/ArticleLayout/ArticleLayout.astro"; ││ +import TopHeader from "../components/TopHeader/TopHeader.astro"; ││ +import Frame from "../components/Frame/Frame.astro"; ││ +import Pre from "../components/Pre/Pre.astro"; ││ +import Prose from "../components/Prose/Prose.astro"; ││ +import ArticleFooter from "../components/ArticleFooter/ArticleFooter.astro"; ││ +import { kv, row2, rule, ruleD } from "../lib/ascii"; ││ + ││ +const buildHeader = (w: number) => ││ + [row2("ABOUT THIS INSTALLATION", "OOKNET", w), ruleD(w)].join("\n"); ││ + ││ +const buildColophon = (w: number) => ││ + [ ││ + row2("COLOPHON", "", w), ││ + rule(w), ││ + kv("OPERATOR", "LIAM W", 12, w), ││ + kv("ENGINE", "astro, rendered static", 12, w), ││ + kv("FRAMES", "strings, built at compile time", 12, w), ││ + kv("BODY TYPE", "IBM Plex Mono", 12, w), ││ + kv("FRAME TYPE", "Cascadia Mono", 12, w), ││ + kv("GRID", "86 cells / 48 on paper tape", 12, w), ││ + ruleD(w), ││ + ].join("\n"); ││ +--- ││ +<ArticleLayout title="OOKNET KB - About"> ││ + <TopHeader /> ││ + <Frame build={buildHeader} /> ││ + <Prose> ││ + <p> ││ + OOKNET is a personal knowledge base kept the way records used to ││ + be kept: as numbered technical sheets, filed under a taxonomy, ││ + printed on an endless roll of tractor-feed paper. Notes get a ││ + number, a status, and a place in the filing system; the archive ││ + grows from the bottom of the index. ││ + </p> ││ + <p> ││ + The site is also its own subject. Every frame on these pages is a ││ + string composed at build time on a strict monospace grid - no ││ + client-side rendering, no images of boxes, just characters. A ││ + validator walks every line at compile time and refuses to ship a ││ + bent border. The whole apparatus is documented on the style sheet ││ + at note N0 and demonstrated on the components page. ││ + </p> ││ + </Prose> ││ + <Frame build={buildColophon} /> ││ + <Pre>{" "}</Pre> ││ + <ArticleFooter page={4} /> ││ +</ArticleLayout> │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...-design/src/pages/about.astro ───┐│ diff --git a/ooknet-design/src/pages/about.a ││ stro b/ooknet-design/src/pages/about.astro ││ new file mode 100644 ││ index 0000000..95c4cda ││ --- /dev/null ││ +++ b/ooknet-design/src/pages/about.astro ││ @@ -0,0 +1,49 @@ ││ +--- ││ +import ArticleLayout from "../layouts/Artic ││ leLayout/ArticleLayout.astro"; ││ +import TopHeader from "../components/TopHea ││ der/TopHeader.astro"; ││ +import Frame from "../components/Frame/Fram ││ e.astro"; ││ +import Pre from "../components/Pre/Pre.astr ││ o"; ││ +import Prose from "../components/Prose/Pros ││ e.astro"; ││ +import ArticleFooter from "../components/Ar ││ ticleFooter/ArticleFooter.astro"; ││ +import { kv, row2, rule, ruleD } from "../l ││ ib/ascii"; ││ + ││ +const buildHeader = (w: number) => ││ + [row2("ABOUT THIS INSTALLATION", "OOKNET" ││ , w), ruleD(w)].join("\n"); ││ + ││ +const buildColophon = (w: number) => ││ + [ ││ + row2("COLOPHON", "", w), ││ + rule(w), ││ + kv("OPERATOR", "LIAM W", ││ 12, w), ││ + kv("ENGINE", "astro, rendered stati ││ c", 12, w), ││ + kv("FRAMES", "strings, built at com ││ pile time", 12, w), ││ + kv("BODY TYPE", "IBM Plex Mono", ││ 12, w), ││ + kv("FRAME TYPE", "Cascadia Mono", ││ 12, w), ││ + kv("GRID", "86 cells / 48 on pape ││ r tape", 12, w), ││ + ruleD(w), ││ + ].join("\n"); ││ +--- ││ +<ArticleLayout title="OOKNET KB - About"> ││ + <TopHeader /> ││ + <Frame build={buildHeader} /> ││ + <Prose> ││ + <p> ││ + OOKNET is a personal knowledge base k ││ ept the way records used to ││ + be kept: as numbered technical sheets ││ , filed under a taxonomy, ││ + printed on an endless roll of tractor ││ -feed paper. Notes get a ││ + number, a status, and a place in the ││ filing system; the archive ││ + grows from the bottom of the index. ││ + </p> ││ + <p> ││ + The site is also its own subject. Eve ││ ry frame on these pages is a ││ + string composed at build time on a st ││ rict monospace grid - no ││ + client-side rendering, no images of b ││ oxes, just characters. A ││ + validator walks every line at compile ││ time and refuses to ship a ││ + bent border. The whole apparatus is d ││ ocumented on the style sheet ││ + at note N0 and demonstrated on the co ││ mponents page. ││ + </p> ││ + </Prose> ││ + <Frame build={buildColophon} /> ││ + <Pre>{" "}</Pre> ││ + <ArticleFooter page={4} /> ││ +</ArticleLayout> │└──────────────────────────────────────────────┘
┌─ ooknet-design/src/pages/projects.astro ───────────────────────────────────┐│ diff --git a/ooknet-design/src/pages/projects.astro b/ooknet-design/src/pages/proj ││ ects.astro ││ new file mode 100644 ││ index 0000000..78b36b1 ││ --- /dev/null ││ +++ b/ooknet-design/src/pages/projects.astro ││ @@ -0,0 +1,33 @@ ││ +--- ││ +import ArticleLayout from "../layouts/ArticleLayout/ArticleLayout.astro"; ││ +import TopHeader from "../components/TopHeader/TopHeader.astro"; ││ +import Frame from "../components/Frame/Frame.astro"; ││ +import Pre from "../components/Pre/Pre.astro"; ││ +import Prose from "../components/Prose/Prose.astro"; ││ +import Table from "../components/Table/Table.astro"; ││ +import ArticleFooter from "../components/ArticleFooter/ArticleFooter.astro"; ││ +import { row2, ruleD } from "../lib/ascii"; ││ + ││ +const buildHeader = (w: number) => ││ + [row2("PROJECT LEDGER", "DEPT. OF OOKS", w), ruleD(w)].join("\n"); ││ + ││ +const PROJECTS = [ ││ + ["OOKNET-DESIGN", "this site and the ascii design system behind it", "ACTIVE"], ││ + ["OOKNET-KB", "the notes archive: numbered technical sheets", "ONGOING"], ││ + ["NIX-FIELD-GUIDE", "climbing the NixOS mountain without a sherpa", "DRAFT"], ││ +]; ││ +--- ││ +<ArticleLayout title="OOKNET KB - Projects"> ││ + <TopHeader /> ││ + <Frame build={buildHeader} /> ││ + <Pre>{" "}</Pre> ││ + <Table head={["PROJECT", "DESCRIPTION", "STATUS"]} rows={PROJECTS} /> ││ + <Prose> ││ + <p> ││ + Entries move DRAFT ? ACTIVE ? ONGOING as they stabilise. Completed ││ + work is retired into the archive as numbered notes rather than ││ + kept on the ledger. ││ + </p> ││ + </Prose> ││ + <ArticleFooter page={2} /> ││ +</ArticleLayout> │└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...sign/src/pages/projects.astro ───┐│ diff --git a/ooknet-design/src/pages/project ││ s.astro b/ooknet-design/src/pages/projects.a ││ stro ││ new file mode 100644 ││ index 0000000..78b36b1 ││ --- /dev/null ││ +++ b/ooknet-design/src/pages/projects.astro ││ @@ -0,0 +1,33 @@ ││ +--- ││ +import ArticleLayout from "../layouts/Artic ││ leLayout/ArticleLayout.astro"; ││ +import TopHeader from "../components/TopHea ││ der/TopHeader.astro"; ││ +import Frame from "../components/Frame/Fram ││ e.astro"; ││ +import Pre from "../components/Pre/Pre.astr ││ o"; ││ +import Prose from "../components/Prose/Pros ││ e.astro"; ││ +import Table from "../components/Table/Tabl ││ e.astro"; ││ +import ArticleFooter from "../components/Ar ││ ticleFooter/ArticleFooter.astro"; ││ +import { row2, ruleD } from "../lib/ascii"; ││ + ││ +const buildHeader = (w: number) => ││ + [row2("PROJECT LEDGER", "DEPT. OF OOKS", ││ w), ruleD(w)].join("\n"); ││ + ││ +const PROJECTS = [ ││ + ["OOKNET-DESIGN", "this site and the asci ││ i design system behind it", "ACTIVE"], ││ + ["OOKNET-KB", "the notes archive: numbere ││ d technical sheets", "ONGOING"], ││ + ["NIX-FIELD-GUIDE", "climbing the NixOS m ││ ountain without a sherpa", "DRAFT"], ││ +]; ││ +--- ││ +<ArticleLayout title="OOKNET KB - Projects" ││ > ││ + <TopHeader /> ││ + <Frame build={buildHeader} /> ││ + <Pre>{" "}</Pre> ││ + <Table head={["PROJECT", "DESCRIPTION", " ││ STATUS"]} rows={PROJECTS} /> ││ + <Prose> ││ + <p> ││ + Entries move DRAFT ? ACTIVE ? ONGOING ││ as they stabilise. Completed ││ + work is retired into the archive as n ││ umbered notes rather than ││ + kept on the ledger. ││ + </p> ││ + </Prose> ││ + <ArticleFooter page={2} /> ││ +</ArticleLayout> │└──────────────────────────────────────────────┘