a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1import { createHash } from "node:crypto";
2
3/**
4 * Represents a section extracted from markdown
5 */
6export type Section = { heading: string; content: string; hash: string };
7
8/**
9 * Result of diffing two sets of sections
10 */
11export type SectionDiff = { added: number; removed: number; edited: number };
12
13/**
14 * Extract all ## and ### headings from markdown content
15 */
16export function extractSections(markdown: string): Section[] {
17 const lines = markdown.split("\n");
18 const sections: Section[] = [];
19 let currentSection: { heading: string; lines: string[] } | undefined = undefined;
20
21 for (const line of lines) {
22 const trimmed = line.trim();
23
24 if (trimmed.startsWith("## ") || trimmed.startsWith("### ")) {
25 if (currentSection) {
26 const content = currentSection.lines.join("\n").trim();
27 sections.push({ heading: currentSection.heading, content, hash: hashContent(content) });
28 }
29
30 currentSection = { heading: trimmed, lines: [] };
31 } else if (currentSection) {
32 currentSection.lines.push(line);
33 }
34 }
35
36 if (currentSection) {
37 const content = currentSection.lines.join("\n").trim();
38 sections.push({ heading: currentSection.heading, content, hash: hashContent(content) });
39 }
40
41 return sections;
42}
43
44/**
45 * Compare two sets of sections (matched by heading text) and calculate the diff
46 */
47export function diffSections(oldSections: Section[], newSections: Section[]): SectionDiff {
48 const oldMap = new Map(oldSections.map((s) => [s.heading, s]));
49 const newMap = new Map(newSections.map((s) => [s.heading, s]));
50
51 let added = 0;
52 let removed = 0;
53 let edited = 0;
54
55 for (const [heading, newSection] of newMap) {
56 const oldSection = oldMap.get(heading);
57
58 if (!oldSection) {
59 added++;
60 } else if (oldSection.hash !== newSection.hash) {
61 edited++;
62 }
63 }
64
65 // Find removed sections
66 for (const heading of oldMap.keys()) {
67 if (!newMap.has(heading)) {
68 removed++;
69 }
70 }
71
72 return { added, removed, edited };
73}
74
75/**
76 * Hash content using SHA-256
77 */
78function hashContent(content: string): string {
79 return createHash("sha256").update(content).digest("hex");
80}
81
82/**
83 * Extract section headings as a simple string array
84 */
85export function extractHeadings(markdown: string): string[] {
86 return extractSections(markdown).map((s) => s.heading);
87}
88
89/**
90 * Hash entire markdown content (without frontmatter)
91 */
92export function hashMarkdown(markdown: string): string {
93 const withoutFrontmatter = stripFrontmatter(markdown);
94 return hashContent(withoutFrontmatter);
95}
96
97/**
98 * Remove YAML frontmatter from markdown
99 */
100function stripFrontmatter(markdown: string): string {
101 const lines = markdown.split("\n");
102
103 if (lines[0]?.trim() !== "---") {
104 return markdown;
105 }
106
107 for (let i = 1; i < lines.length; i++) {
108 if (lines[i].trim() === "---") {
109 return lines.slice(i + 1).join("\n");
110 }
111 }
112
113 return markdown;
114}