Skip to content

Commit 0cec225

Browse files
dwijbavisiCopilot
andcommitted
UI refresh
Co-authored-by: Copilot <copilot@github.com>
1 parent 1de5fa8 commit 0cec225

14 files changed

Lines changed: 659 additions & 66 deletions

File tree

site/modules/md/components/BlockNodes.tsx

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,64 @@ import type {
77
ListNode,
88
MathBlockNode,
99
CodeBlockNode,
10+
InlineNode,
1011
} from "../types";
11-
import { RenderInline } from "./InlineNodes";
12+
import { RenderInline, renderInlineNode } from "./InlineNodes";
13+
import { slugify, inlineToText } from "../utils";
14+
15+
const Marker = ({ children }: { children: React.ReactNode }) => (
16+
<span className="md-marker">{children}</span>
17+
);
18+
19+
// Split inline nodes on <br> boundaries and render each segment as a > prefixed line.
20+
function renderBlockquoteLines(nodes: InlineNode[]): React.ReactNode {
21+
const segments: InlineNode[][] = [];
22+
let current: InlineNode[] = [];
23+
for (const node of nodes) {
24+
if (node.type === "br") {
25+
segments.push(current);
26+
current = [];
27+
} else {
28+
current.push(node);
29+
}
30+
}
31+
segments.push(current);
32+
return segments.map((seg, i) => (
33+
<span key={i} className="bq-line">
34+
<Marker>{">"}&#32;</Marker>
35+
{seg.map((n, j) => renderInlineNode(n, j))}
36+
</span>
37+
));
38+
}
39+
40+
function renderBlockquoteChild(node: BlockNode, key: number): React.ReactNode {
41+
if (node.type === "paragraph") {
42+
return <p key={key}>{renderBlockquoteLines((node as ParagraphNode).children)}</p>;
43+
}
44+
// Non-paragraph blocks (headings, nested blockquotes, etc.) get a single > prefix
45+
return (
46+
<React.Fragment key={key}>
47+
<Marker>{">"}&#32;</Marker>
48+
{renderBlockNode(node, key)}
49+
</React.Fragment>
50+
);
51+
}
1252

1353
export function renderBlockNode(node: BlockNode, key: string | number): React.ReactNode {
1454
switch (node.type) {
1555
case "heading": {
1656
const n = node as HeadingNode;
57+
const marker = "#".repeat(n.level) + " ";
1758
const inner = <RenderInline nodes={n.children} />;
59+
const id = slugify(inlineToText(n.children));
60+
const anchor = <a href={`#${id}`} className="heading-anchor" aria-label="Link to section">§</a>;
1861
switch (n.level) {
19-
case 1: return <h1 key={key}>{inner}</h1>;
20-
case 2: return <h2 key={key}>{inner}</h2>;
21-
case 3: return <h3 key={key}>{inner}</h3>;
22-
case 4: return <h4 key={key}>{inner}</h4>;
23-
case 5: return <h5 key={key}>{inner}</h5>;
24-
case 6: return <h6 key={key}>{inner}</h6>;
62+
case 1: return <h1 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h1>;
63+
case 2: return <h2 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h2>;
64+
case 3: return <h3 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h3>;
65+
case 4: return <h4 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h4>;
66+
case 5: return <h5 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h5>;
67+
case 6: return <h6 key={key} id={id}><Marker>{marker}</Marker>{inner}{anchor}</h6>;
2568
}
2669
break;
2770
}
@@ -36,14 +79,15 @@ export function renderBlockNode(node: BlockNode, key: string | number): React.Re
3679
case "blockquote":
3780
return (
3881
<blockquote key={key}>
39-
{(node as BlockquoteNode).children.map((c, i) => renderBlockNode(c, i))}
82+
{(node as BlockquoteNode).children.map((c, i) => renderBlockquoteChild(c, i))}
4083
</blockquote>
4184
);
4285

4386
case "list": {
4487
const n = node as ListNode;
4588
const items = n.items.map((item, i) => (
4689
<li key={i}>
90+
<span className="md-marker">{n.ordered ? `${i + 1}. ` : "- "}</span>
4791
<RenderInline nodes={item.children} />
4892
</li>
4993
));

site/modules/md/components/InlineNodes.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,40 @@ export function renderInlineNode(node: InlineNode, key: string | number): React.
2020
case "bold":
2121
return (
2222
<strong key={key}>
23+
<span className="md-marker">**</span>
2324
{(node as BoldNode).children.map((c, i) => renderInlineNode(c, i))}
25+
<span className="md-marker">**</span>
2426
</strong>
2527
);
2628

2729
case "italic":
2830
return (
2931
<em key={key}>
32+
<span className="md-marker">*</span>
3033
{(node as ItalicNode).children.map((c, i) => renderInlineNode(c, i))}
34+
<span className="md-marker">*</span>
3135
</em>
3236
);
3337

3438
case "link": {
3539
const n = node as LinkNode;
3640
return (
3741
<a key={key} href={n.href}>
42+
<span className="md-marker">[</span>
3843
{n.children.map((c, i) => renderInlineNode(c, i))}
44+
<span className="md-marker">]</span>
3945
</a>
4046
);
4147
}
4248

4349
case "inline-code":
44-
return <code key={key}>{(node as InlineCodeNode).value}</code>;
50+
return (
51+
<code key={key}>
52+
<span className="md-marker">`</span>
53+
{(node as InlineCodeNode).value}
54+
<span className="md-marker">`</span>
55+
</code>
56+
);
4557

4658
case "math-inline":
4759
return (

site/modules/md/parser.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,15 @@ function tokenizeBlocks(lines: string[]): RawBlock[] {
308308
// Continuation line for the last item
309309
rawItems[rawItems.length - 1].push(l.trim());
310310
i++;
311+
} else if (l.trim() === "") {
312+
// Blank line — look ahead: if next non-blank is another list item, skip it
313+
let j = i + 1;
314+
while (j < lines.length && lines[j].trim() === "") j++;
315+
if (j < lines.length && /^- /.test(lines[j])) {
316+
i = j; // skip blank(s), continue to next item
317+
} else {
318+
break;
319+
}
311320
} else {
312321
break;
313322
}
@@ -329,6 +338,15 @@ function tokenizeBlocks(lines: string[]): RawBlock[] {
329338
} else if (rawItems.length > 0 && l.trim() !== "" && !/^(- |\d+\. |#{1,6} |> |```|-{3,}|\$\$)/.test(l)) {
330339
rawItems[rawItems.length - 1].push(l.trim());
331340
i++;
341+
} else if (l.trim() === "") {
342+
// Blank line — look ahead: if next non-blank is another ordered item, skip it
343+
let j = i + 1;
344+
while (j < lines.length && lines[j].trim() === "") j++;
345+
if (j < lines.length && /^\d+\. /.test(lines[j])) {
346+
i = j; // skip blank(s), continue to next item
347+
} else {
348+
break;
349+
}
332350
} else {
333351
break;
334352
}

site/modules/md/utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { InlineNode } from "./types";
2+
3+
/**
4+
* Convert a heading's plain text into a URL-safe id.
5+
* e.g. "## The Fuel of Intent" → "the-fuel-of-intent"
6+
*/
7+
export function slugify(text: string): string {
8+
return text
9+
.toLowerCase()
10+
.replace(/[^a-z0-9\s-]/g, "")
11+
.trim()
12+
.replace(/\s+/g, "-");
13+
}
14+
15+
/**
16+
* Recursively extract plain text from an array of InlineNodes.
17+
*/
18+
export function inlineToText(nodes: InlineNode[]): string {
19+
return nodes
20+
.map((n) => {
21+
if (n.type === "text") return n.value;
22+
if (n.type === "inline-code") return n.value;
23+
if ("children" in n && Array.isArray((n as { children: InlineNode[] }).children)) {
24+
return inlineToText((n as { children: InlineNode[] }).children);
25+
}
26+
return "";
27+
})
28+
.join("");
29+
}

site/scripts/build-static.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3+
import esbuild from "esbuild";
34
import React from "react";
45
import { renderToStaticMarkup } from "react-dom/server";
56
import { loadSiteData } from "../src/contentLoader";
@@ -53,15 +54,25 @@ async function run(): Promise<void> {
5354

5455
log("Copying static assets...");
5556
await fs.copyFile(path.resolve(ROOT, "src/styles.css"), path.resolve(DIST, "styles.css"));
57+
58+
log("Building interaction.ts...");
59+
await esbuild.build({
60+
entryPoints: [path.resolve(ROOT, "src/interaction.ts")],
61+
outfile: path.resolve(DIST, "interaction.js"),
62+
bundle: true,
63+
format: "iife",
64+
platform: "browser",
65+
minify: true,
66+
});
67+
5668
await copyIfExists(WWW_DIR, DIST);
5769

5870
const latestArticles = siteData.articles.slice(0, 6);
59-
const keyPages = siteData.pages.slice(0, 8);
6071

6172
log("Rendering index...");
6273
await writeRoute(
6374
"/",
64-
documentFromElement(React.createElement(IndexTemplate, { latestArticles, keyPages, introNodes }))
75+
documentFromElement(React.createElement(IndexTemplate, { latestArticles, introNodes }))
6576
);
6677

6778
log("Rendering article list...");

site/src/contentLoader.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { parse } from "../modules/md/index";
44
import type { BlockNode, BlockquoteNode, HeadingNode, InlineNode, LinkNode, ListNode, ParagraphNode, TextNode, BoldNode, ItalicNode, SupNode, SubNode } from "../modules/md/types";
55
import { ContentItem, ContentKind, SiteData } from "./lib/types";
66
import { relativeRouteHref } from "./lib/paths";
7+
import { extractToc } from "./lib/toc";
78

89
const ARTICLE_ROOT = path.resolve(process.cwd(), "../content/articles");
910
const PAGE_ROOT = path.resolve(process.cwd(), "../content/pages");
@@ -91,6 +92,18 @@ function toRoute(kind: ContentKind, slug: string): string {
9192
return `/${kind === "article" ? "articles" : "pages"}/${slug}/`;
9293
}
9394

95+
function toCanonicalPath(slug: string): string {
96+
const parts = slug.split("/");
97+
const last = parts[parts.length - 1];
98+
if (
99+
last.toLowerCase() === "readme" ||
100+
(parts.length >= 2 && last === parts[parts.length - 2])
101+
) {
102+
return parts.slice(0, -1).join("/");
103+
}
104+
return slug;
105+
}
106+
94107
// ─── Link Resolution ──────────────────────────────────────────────────────────
95108

96109
/**
@@ -165,6 +178,7 @@ async function loadKind(kind: ContentKind, rootDir: string): Promise<ContentItem
165178
const fileText = await fs.readFile(filePath, "utf8");
166179
const { nodes, metadata } = parse(fileText);
167180
const slug = relativePath.replace(/\\/g, "/").replace(/\.md$/i, "");
181+
const canonicalPath = toCanonicalPath(slug);
168182

169183
const headings = nodes.filter((n) => n.type === "heading") as HeadingNode[];
170184
const title = inferTitle(headings, filePath);
@@ -175,13 +189,16 @@ async function loadKind(kind: ContentKind, rootDir: string): Promise<ContentItem
175189
kind,
176190
title,
177191
slug,
192+
canonicalPath,
178193
route,
179194
sourcePath: relativePath.replace(/\\/g, "/"),
180195
summary: deriveSummary(nodes as ParagraphNode[]),
181196
date: inferDate(metadata.published, relativePath),
182197
author: metadata.author,
183198
conceivedDate: metadata.conceived,
184199
nodes: resolvedNodes,
200+
toc: extractToc(resolvedNodes),
201+
children: [],
185202
});
186203
}
187204

@@ -192,6 +209,16 @@ async function loadKind(kind: ContentKind, rootDir: string): Promise<ContentItem
192209
return a.title.localeCompare(b.title);
193210
});
194211

212+
// Wire up direct children based on canonicalPath hierarchy
213+
for (const item of items) {
214+
item.children = items.filter((other) => {
215+
if (other === item) return false;
216+
if (!other.canonicalPath.startsWith(item.canonicalPath + "/")) return false;
217+
const remainder = other.canonicalPath.slice(item.canonicalPath.length + 1);
218+
return !remainder.includes("/");
219+
});
220+
}
221+
195222
log(`Finished loading ${items.length} ${kind}(s).`);
196223
return items;
197224
}

site/src/interaction.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Interaction controller — binds named behaviors to elements via `data-interaction`.
3+
*
4+
* Usage in HTML:
5+
* <details data-interaction="dismiss-outside">...</details>
6+
*
7+
* Usage in TypeScript (to add custom behaviors before DOMContentLoaded fires):
8+
* import { register } from "./interaction";
9+
* register("my-behavior", (el) => { ... });
10+
*/
11+
12+
type Teardown = () => void;
13+
type BehaviorSetup = (el: Element) => Teardown | void;
14+
15+
const _behaviors = new Map<string, BehaviorSetup>();
16+
17+
/** Register a named behavior. The setup function is called for each element that declares it. */
18+
export function register(name: string, setup: BehaviorSetup): void {
19+
_behaviors.set(name, setup);
20+
}
21+
22+
/** Apply all registered behaviors to elements with `data-interaction` attributes. */
23+
export function init(): void {
24+
document.querySelectorAll<HTMLElement>("[data-interaction]").forEach((el) => {
25+
const names = (el.dataset.interaction ?? "").split(/\s+/).filter(Boolean);
26+
for (const name of names) {
27+
_behaviors.get(name)?.(el);
28+
}
29+
});
30+
}
31+
32+
// ─── Built-in: dismiss-outside ────────────────────────────────────────────────
33+
// Closes a <details> element when the user clicks anywhere outside of it.
34+
35+
register("dismiss-outside", (el) => {
36+
if (!(el instanceof HTMLDetailsElement)) return;
37+
38+
const handler = (e: MouseEvent): void => {
39+
if (el.open && !el.contains(e.target as Node)) {
40+
el.open = false;
41+
}
42+
};
43+
44+
// Capture phase so we intercept before the click reaches children.
45+
document.addEventListener("click", handler, true);
46+
});
47+
48+
// ─── Auto-init ────────────────────────────────────────────────────────────────
49+
50+
document.addEventListener("DOMContentLoaded", init);

site/src/lib/toc.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { BlockNode, HeadingNode } from "../../modules/md/types";
2+
import { slugify, inlineToText } from "../../modules/md/utils";
3+
4+
export interface TocItem {
5+
id: string;
6+
level: 1 | 2 | 3 | 4 | 5 | 6;
7+
text: string;
8+
}
9+
10+
export { slugify };
11+
12+
/**
13+
* Extract all headings from a parsed block list, up to level 3.
14+
* Returns an empty array if there are fewer than 2 headings (not worth a ToC).
15+
*/
16+
export function extractToc(nodes: BlockNode[]): TocItem[] {
17+
const items = nodes
18+
.filter((n) => n.type === "heading" && (n as HeadingNode).level <= 3)
19+
.map((n) => {
20+
const h = n as HeadingNode;
21+
const text = inlineToText(h.children);
22+
return { id: slugify(text), level: h.level, text };
23+
});
24+
25+
return items.length >= 2 ? items : [];
26+
}

site/src/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import type { BlockNode } from "../../modules/md/types";
2+
import type { TocItem } from "./toc";
23

34
export type ContentKind = "article" | "page";
45

56
export interface ContentItem {
67
kind: ContentKind;
78
title: string;
89
slug: string;
10+
canonicalPath: string;
911
route: string;
1012
sourcePath: string;
1113
summary?: string;
1214
date?: string;
1315
author?: string;
1416
conceivedDate?: string;
1517
nodes: BlockNode[];
18+
toc: TocItem[];
19+
children: ContentItem[];
1620
}
1721

1822

0 commit comments

Comments
 (0)