@shivaedev/work-board 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/board.d.ts +8 -0
- package/dist/board.d.ts.map +1 -0
- package/dist/board.js +27 -0
- package/dist/board.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +17 -0
- package/dist/cli.js.map +1 -0
- package/dist/files/changes.d.ts +17 -0
- package/dist/files/changes.d.ts.map +1 -0
- package/dist/files/changes.js +38 -0
- package/dist/files/changes.js.map +1 -0
- package/dist/files/home.d.ts +11 -0
- package/dist/files/home.d.ts.map +1 -0
- package/dist/files/home.js +33 -0
- package/dist/files/home.js.map +1 -0
- package/dist/files/list.d.ts +9 -0
- package/dist/files/list.d.ts.map +1 -0
- package/dist/files/list.js +38 -0
- package/dist/files/list.js.map +1 -0
- package/dist/http/assets.d.ts +15 -0
- package/dist/http/assets.d.ts.map +1 -0
- package/dist/http/assets.js +32 -0
- package/dist/http/assets.js.map +1 -0
- package/dist/http/events.d.ts +5 -0
- package/dist/http/events.d.ts.map +1 -0
- package/dist/http/events.js +17 -0
- package/dist/http/events.js.map +1 -0
- package/dist/http/loopback.d.ts +4 -0
- package/dist/http/loopback.d.ts.map +1 -0
- package/dist/http/loopback.js +22 -0
- package/dist/http/loopback.js.map +1 -0
- package/dist/http/page.d.ts +9 -0
- package/dist/http/page.d.ts.map +1 -0
- package/dist/http/page.js +43 -0
- package/dist/http/page.js.map +1 -0
- package/dist/http/respond.d.ts +8 -0
- package/dist/http/respond.d.ts.map +1 -0
- package/dist/http/respond.js +15 -0
- package/dist/http/respond.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/page/board.d.ts +4 -0
- package/dist/page/board.d.ts.map +1 -0
- package/dist/page/board.js +32 -0
- package/dist/page/board.js.map +1 -0
- package/dist/page/client.d.ts +2 -0
- package/dist/page/client.d.ts.map +1 -0
- package/dist/page/client.js +91 -0
- package/dist/page/client.js.map +1 -0
- package/dist/page/diagrams.d.ts +2 -0
- package/dist/page/diagrams.d.ts.map +1 -0
- package/dist/page/diagrams.js +73 -0
- package/dist/page/diagrams.js.map +1 -0
- package/dist/page/document.d.ts +4 -0
- package/dist/page/document.d.ts.map +1 -0
- package/dist/page/document.js +9 -0
- package/dist/page/document.js.map +1 -0
- package/dist/page/escape.d.ts +2 -0
- package/dist/page/escape.d.ts.map +1 -0
- package/dist/page/escape.js +3 -0
- package/dist/page/escape.js.map +1 -0
- package/dist/page/nav.d.ts +3 -0
- package/dist/page/nav.d.ts.map +1 -0
- package/dist/page/nav.js +22 -0
- package/dist/page/nav.js.map +1 -0
- package/dist/page/shell.d.ts +2 -0
- package/dist/page/shell.d.ts.map +1 -0
- package/dist/page/shell.js +11 -0
- package/dist/page/shell.js.map +1 -0
- package/dist/page/style.d.ts +2 -0
- package/dist/page/style.d.ts.map +1 -0
- package/dist/page/style.js +75 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page/swap.d.ts +2 -0
- package/dist/page/swap.d.ts.map +1 -0
- package/dist/page/swap.js +123 -0
- package/dist/page/swap.js.map +1 -0
- package/dist/page/updated.d.ts +2 -0
- package/dist/page/updated.d.ts.map +1 -0
- package/dist/page/updated.js +2 -0
- package/dist/page/updated.js.map +1 -0
- package/dist/render/board.d.ts +20 -0
- package/dist/render/board.d.ts.map +1 -0
- package/dist/render/board.js +54 -0
- package/dist/render/board.js.map +1 -0
- package/dist/render/code.d.ts +10 -0
- package/dist/render/code.d.ts.map +1 -0
- package/dist/render/code.js +47 -0
- package/dist/render/code.js.map +1 -0
- package/dist/render/failed.d.ts +9 -0
- package/dist/render/failed.d.ts.map +1 -0
- package/dist/render/failed.js +4 -0
- package/dist/render/failed.js.map +1 -0
- package/dist/render/footnotes.d.ts +19 -0
- package/dist/render/footnotes.d.ts.map +1 -0
- package/dist/render/footnotes.js +23 -0
- package/dist/render/footnotes.js.map +1 -0
- package/dist/render/highlighter.d.ts +13 -0
- package/dist/render/highlighter.d.ts.map +1 -0
- package/dist/render/highlighter.js +11 -0
- package/dist/render/highlighter.js.map +1 -0
- package/dist/render/markdown.d.ts +5 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +15 -0
- package/dist/render/markdown.js.map +1 -0
- package/dist/serve.d.ts +9 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +9 -0
- package/dist/serve.js.map +1 -0
- package/package.json +60 -3
- package/src/board.ts +44 -0
- package/src/cli.ts +24 -0
- package/src/files/changes.ts +71 -0
- package/src/files/home.ts +37 -0
- package/src/files/list.ts +55 -0
- package/src/http/assets.ts +38 -0
- package/src/http/events.ts +29 -0
- package/src/http/loopback.ts +29 -0
- package/src/http/page.ts +54 -0
- package/src/http/respond.ts +18 -0
- package/src/index.ts +1 -0
- package/src/page/board.ts +37 -0
- package/src/page/client.ts +90 -0
- package/src/page/diagrams.ts +72 -0
- package/src/page/document.ts +10 -0
- package/src/page/escape.ts +3 -0
- package/src/page/nav.ts +28 -0
- package/src/page/shell.ts +12 -0
- package/src/page/style.ts +79 -0
- package/src/page/swap.ts +122 -0
- package/src/page/updated.ts +1 -0
- package/src/render/board.ts +82 -0
- package/src/render/code.ts +54 -0
- package/src/render/failed.ts +3 -0
- package/src/render/footnotes.ts +32 -0
- package/src/render/highlighter.ts +17 -0
- package/src/render/markdown.ts +16 -0
- package/src/serve.ts +16 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const tokens = `
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: light dark;
|
|
4
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
5
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
6
|
+
--bg: #ffffff; --surface: #f6f8fa; --text: #1f2328; --muted: #59636e; --border: #d1d9e0; --link: #0969da; --danger: #b3261e;
|
|
7
|
+
}
|
|
8
|
+
@media (prefers-color-scheme: dark) {
|
|
9
|
+
:root { --bg: #1e1e1e; --surface: #272727; --text: #e8e8e8; --muted: #a8a8a8; --border: #3d3d3d; --link: #58a6ff; --danger: #ff8a80; }
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
const page = `
|
|
14
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.65 var(--sans); }
|
|
15
|
+
a { color: var(--link); }
|
|
16
|
+
.page { box-sizing: content-box; max-width: 72ch; margin: 0 auto; padding: 0 16px 64px; }
|
|
17
|
+
.bar { display: flex; align-items: flex-start; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
18
|
+
.files { flex: 1; display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline; }
|
|
19
|
+
.files a { color: var(--text); text-decoration: none; }
|
|
20
|
+
.files a:hover, .files a[aria-current="page"] { color: var(--link); text-decoration: underline; }
|
|
21
|
+
.files details[open] { flex-basis: 100%; }
|
|
22
|
+
.files summary { cursor: pointer; color: var(--muted); }
|
|
23
|
+
.files ul { list-style: none; margin: 4px 0 8px; padding: 0 0 0 12px; border-left: 1px solid var(--border); }
|
|
24
|
+
.age { color: var(--muted); font: 12px var(--mono); margin-left: 6px; }
|
|
25
|
+
.live { color: var(--muted); font: 12px/1.9 var(--mono); }
|
|
26
|
+
.live[data-state="down"], .live[data-state="stale"] { color: var(--danger); }
|
|
27
|
+
.meta { color: var(--muted); font-size: 14px; margin: 0; }
|
|
28
|
+
.meta .age { margin: 0; }
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const prose = `
|
|
32
|
+
h1, h2, h3, h4 { font-weight: 600; line-height: 1.25; margin: 1.6em 0 0.5em; }
|
|
33
|
+
h1 { font-size: 1.9em; margin-top: 1em; }
|
|
34
|
+
h2 { font-size: 1.45em; }
|
|
35
|
+
h3 { font-size: 1.15em; }
|
|
36
|
+
h1 strong, h2 strong, h3 strong { font-weight: inherit; }
|
|
37
|
+
p, ul, ol, pre, table, blockquote, details, figure { margin: 0 0 1.1em; }
|
|
38
|
+
li + li { margin-top: 0.25em; }
|
|
39
|
+
.contains-task-list { list-style: none; padding-left: 0.4em; }
|
|
40
|
+
hr { border: 0; border-top: 1px solid var(--border); margin: 2em 0; }
|
|
41
|
+
blockquote { padding-left: 1em; border-left: 3px solid var(--border); color: var(--muted); }
|
|
42
|
+
code { font: 0.875em var(--mono); background: var(--surface); padding: 0.1em 0.35em; border-radius: 4px; overflow-wrap: anywhere; }
|
|
43
|
+
pre { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
44
|
+
pre code { background: none; padding: 0; font-size: 0.85em; }
|
|
45
|
+
.shiki span { color: var(--shiki-light); }
|
|
46
|
+
@media (prefers-color-scheme: dark) { .shiki span { color: var(--shiki-dark); } }
|
|
47
|
+
table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: 0.925em; }
|
|
48
|
+
th, td { text-align: left; vertical-align: top; padding: 6px 10px; border: 1px solid var(--border); }
|
|
49
|
+
tr:nth-child(2n) td { background: var(--surface); }
|
|
50
|
+
@media (max-width: 600px) { table { table-layout: fixed; } td, th { overflow-wrap: anywhere; } }
|
|
51
|
+
summary { cursor: pointer; color: var(--muted); }
|
|
52
|
+
details[open] > summary { margin-bottom: 0.5em; }
|
|
53
|
+
img, svg { max-width: 100%; height: auto; }
|
|
54
|
+
.empty { color: var(--muted); font-style: italic; }
|
|
55
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const board = `
|
|
59
|
+
.board-head .intro { color: var(--muted); }
|
|
60
|
+
.counts { margin: 0; }
|
|
61
|
+
.counts b { font-variant-numeric: tabular-nums; }
|
|
62
|
+
.board section { margin-top: 2.2em; }
|
|
63
|
+
.board section > h2 { margin: 0 0 0.2em; }
|
|
64
|
+
.item, .notes { padding: 0.9em 0 0.1em; border-top: 1px solid var(--border); }
|
|
65
|
+
.item > h3 { margin: 0 0 0.4em; }
|
|
66
|
+
.item > h3 > code:first-child { background: none; padding: 0; margin-right: 0.6em; color: var(--muted); font-weight: 400; }
|
|
67
|
+
footer { margin-top: 2.5em; padding-top: 1em; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.925em; }
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const diagrams = `
|
|
71
|
+
figure.diagram { margin-left: 0; margin-right: 0; }
|
|
72
|
+
figure.diagram[data-state="pending"] { min-height: 12rem; background: var(--surface); border-radius: 6px; }
|
|
73
|
+
figure.diagram .diagram-source { display: none; }
|
|
74
|
+
figure.diagram[data-state="failed"] .diagram-source { display: block; }
|
|
75
|
+
figure.diagram[data-state="failed"]::after { content: attr(data-error); color: var(--danger); font-size: 0.875em; }
|
|
76
|
+
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
export const style = tokens + page + prose + board + diagrams;
|
package/src/page/swap.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const swap = `
|
|
2
|
+
const containers = new Set(["DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER", "MAIN", "NAV"]);
|
|
3
|
+
|
|
4
|
+
const structural = (element) => [...element.childNodes].every((node) => node.nodeType === 1 || node.textContent.trim() === "");
|
|
5
|
+
|
|
6
|
+
const pristine = new WeakMap();
|
|
7
|
+
|
|
8
|
+
export const remember = (element) => {
|
|
9
|
+
pristine.set(element, element.outerHTML);
|
|
10
|
+
if (containers.has(element.tagName)) for (const child of element.children) remember(child);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const normalized = (element) => {
|
|
14
|
+
const copy = element.cloneNode(true);
|
|
15
|
+
for (const node of [copy, ...copy.querySelectorAll("*")]) {
|
|
16
|
+
if (node.tagName === "DETAILS") node.removeAttribute("open");
|
|
17
|
+
if (node.matches("figure.diagram")) {
|
|
18
|
+
node.querySelector(".diagram-svg")?.remove();
|
|
19
|
+
node.dataset.state = "pending";
|
|
20
|
+
delete node.dataset.error;
|
|
21
|
+
}
|
|
22
|
+
if (node.matches("[data-modified]")) node.textContent = "";
|
|
23
|
+
}
|
|
24
|
+
return copy.outerHTML;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const signature = (element) => {
|
|
28
|
+
if (!pristine.has(element)) pristine.set(element, normalized(element));
|
|
29
|
+
return pristine.get(element);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const sameShell = (old, next) =>
|
|
33
|
+
old.tagName === next.tagName && containers.has(old.tagName) && old.cloneNode(false).outerHTML === next.cloneNode(false).outerHTML;
|
|
34
|
+
|
|
35
|
+
const matches = (a, b) => {
|
|
36
|
+
let start = 0;
|
|
37
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
38
|
+
let tail = 0;
|
|
39
|
+
while (tail < a.length - start && tail < b.length - start && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
|
|
40
|
+
const n = a.length - start - tail;
|
|
41
|
+
const m = b.length - start - tail;
|
|
42
|
+
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
43
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
44
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
45
|
+
table[i][j] = a[start + i] === b[start + j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const pairs = Array.from({ length: start }, (_, index) => [index, index]);
|
|
49
|
+
for (let i = 0, j = 0; i < n && j < m; ) {
|
|
50
|
+
if (a[start + i] === b[start + j]) pairs.push([start + i++, start + j++]);
|
|
51
|
+
else if (table[i + 1][j] >= table[i][j + 1]) i++;
|
|
52
|
+
else j++;
|
|
53
|
+
}
|
|
54
|
+
for (let index = tail; index > 0; index--) pairs.push([a.length - index, b.length - index]);
|
|
55
|
+
return [...pairs, [a.length, b.length]];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const patch = (current, incoming) => {
|
|
59
|
+
if (!structural(current) || !structural(incoming)) {
|
|
60
|
+
current.replaceChildren(...incoming.childNodes);
|
|
61
|
+
for (const child of current.children) remember(child);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const olds = [...current.children];
|
|
65
|
+
const news = [...incoming.children];
|
|
66
|
+
const signatures = news.map((next) => next.outerHTML);
|
|
67
|
+
const arrive = (index) => {
|
|
68
|
+
remember(news[index]);
|
|
69
|
+
return news[index];
|
|
70
|
+
};
|
|
71
|
+
let i = 0;
|
|
72
|
+
let j = 0;
|
|
73
|
+
for (const [kept, arrived] of matches(olds.map(signature), signatures)) {
|
|
74
|
+
const anchor = olds[kept] ?? null;
|
|
75
|
+
for (; i < kept && j < arrived; i++, j++) {
|
|
76
|
+
if (sameShell(olds[i], news[j])) {
|
|
77
|
+
patch(olds[i], news[j]);
|
|
78
|
+
pristine.set(olds[i], signatures[j]);
|
|
79
|
+
} else olds[i].replaceWith(arrive(j));
|
|
80
|
+
}
|
|
81
|
+
for (; i < kept; i++) olds[i].remove();
|
|
82
|
+
for (; j < arrived; j++) current.insertBefore(arrive(j), anchor);
|
|
83
|
+
i++;
|
|
84
|
+
j++;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const keysOf = (root) => {
|
|
89
|
+
const seen = new Map();
|
|
90
|
+
return [...root.querySelectorAll("details")].map((details) => {
|
|
91
|
+
const base =
|
|
92
|
+
details.dataset.key ??
|
|
93
|
+
(details.closest(".item")?.querySelector("h3")?.textContent ?? "") + "|" + (details.querySelector("summary")?.textContent ?? "");
|
|
94
|
+
const count = seen.get(base) ?? 0;
|
|
95
|
+
seen.set(base, count + 1);
|
|
96
|
+
return [base + "#" + count, details];
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const carryDrawings = (drawings, figures) => {
|
|
101
|
+
if (drawings.length !== figures.length) return;
|
|
102
|
+
figures.forEach((figure, index) => {
|
|
103
|
+
const drawing = drawings[index];
|
|
104
|
+
if (figure.dataset.state !== "pending" || drawing === null || drawing.isConnected) return;
|
|
105
|
+
figure.prepend(drawing);
|
|
106
|
+
figure.dataset.state = "redrawing";
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const swap = (current, incoming) => {
|
|
111
|
+
const before = keysOf(current);
|
|
112
|
+
const open = new Map(before.map(([key, details]) => [key, details.open]));
|
|
113
|
+
const existing = new Set(before.map(([, details]) => details));
|
|
114
|
+
const drawings = [...current.querySelectorAll("figure.diagram")].map((figure) => figure.querySelector(".diagram-svg"));
|
|
115
|
+
patch(current, incoming);
|
|
116
|
+
carryDrawings(drawings, [...current.querySelectorAll("figure.diagram")]);
|
|
117
|
+
for (const [key, details] of keysOf(current)) {
|
|
118
|
+
const was = open.get(key);
|
|
119
|
+
if (was !== undefined && !existing.has(details)) details.open = was;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const updated = (modified: number): string => `<span class="age" data-modified="${modified}">updated</span>`;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RootContent } from "mdast";
|
|
2
|
+
import { markdownToMdast } from "satteri";
|
|
3
|
+
|
|
4
|
+
export interface Section {
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly heading: string;
|
|
7
|
+
readonly notes: string;
|
|
8
|
+
readonly items: ReadonlyArray<string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Board {
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly intro: string;
|
|
14
|
+
readonly sections: ReadonlyArray<Section>;
|
|
15
|
+
readonly footer: string;
|
|
16
|
+
readonly definitions: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Draft {
|
|
20
|
+
title: string;
|
|
21
|
+
intro: string[];
|
|
22
|
+
sections: Array<{ title: string; heading: string; notes: string[]; items: string[][] }>;
|
|
23
|
+
footer: string[] | undefined;
|
|
24
|
+
definitions: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const textOf = (node: RootContent): string => {
|
|
28
|
+
if ("value" in node) {
|
|
29
|
+
return node.value;
|
|
30
|
+
}
|
|
31
|
+
return "children" in node ? node.children.map((child) => textOf(child)).join("") : "";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isSection = (node: RootContent): boolean => node.type === "heading" && node.depth === 2;
|
|
35
|
+
|
|
36
|
+
const place = (draft: Draft, node: RootContent, source: string, closing: boolean): void => {
|
|
37
|
+
const section = draft.sections.at(-1);
|
|
38
|
+
if (node.type === "definition" || node.type === "footnoteDefinition") {
|
|
39
|
+
draft.definitions.push(source);
|
|
40
|
+
} else if (draft.footer !== undefined) {
|
|
41
|
+
draft.footer.push(source);
|
|
42
|
+
} else if (node.type === "heading" && node.depth === 1 && draft.title === "") {
|
|
43
|
+
draft.title = source;
|
|
44
|
+
} else if (isSection(node)) {
|
|
45
|
+
draft.sections.push({ title: textOf(node), heading: source, notes: [], items: [] });
|
|
46
|
+
} else if (section === undefined) {
|
|
47
|
+
draft.intro.push(source);
|
|
48
|
+
} else if (node.type === "heading" && node.depth === 3) {
|
|
49
|
+
section.items.push([source]);
|
|
50
|
+
} else if (node.type === "thematicBreak" && closing) {
|
|
51
|
+
draft.footer = [];
|
|
52
|
+
} else {
|
|
53
|
+
(section.items.at(-1) ?? section.notes).push(source);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const joined = (blocks: ReadonlyArray<string>): string => blocks.join("\n\n");
|
|
58
|
+
|
|
59
|
+
export const boardOf = (source: string): Board => {
|
|
60
|
+
const draft: Draft = { title: "", intro: [], sections: [], footer: undefined, definitions: [] };
|
|
61
|
+
const root = markdownToMdast(source);
|
|
62
|
+
const nodes = root.type === "root" ? root.children : [];
|
|
63
|
+
const lastHeading = nodes.findLastIndex((node) => node.type === "heading");
|
|
64
|
+
nodes.forEach((node, index) => {
|
|
65
|
+
place(draft, node, source.slice(node.position?.start.offset, node.position?.end.offset), index > lastHeading);
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
title: draft.title,
|
|
69
|
+
intro: joined(draft.intro),
|
|
70
|
+
sections: draft.sections.map((section) => ({ ...section, notes: joined(section.notes), items: section.items.map(joined) })),
|
|
71
|
+
footer: joined(draft.footer ?? []),
|
|
72
|
+
definitions: joined(draft.definitions),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface Count {
|
|
77
|
+
readonly title: string;
|
|
78
|
+
readonly count: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const countsOf = (board: Board): ReadonlyArray<Count> =>
|
|
82
|
+
board.sections.flatMap((section) => (section.items.length === 0 ? [] : [{ title: section.title, count: section.items.length }]));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Element, ElementContent } from "hast";
|
|
2
|
+
import { defineHastPlugin } from "satteri";
|
|
3
|
+
import { type BundledLanguage, bundledLanguages, type Highlighter as Shiki } from "shiki";
|
|
4
|
+
import { THEMES } from "./highlighter.ts";
|
|
5
|
+
|
|
6
|
+
const languageOf = (code: Element): string | undefined => {
|
|
7
|
+
const classes = code.properties.className;
|
|
8
|
+
const named = Array.isArray(classes) ? classes.find((name) => String(name).startsWith("language-")) : undefined;
|
|
9
|
+
return named === undefined ? undefined : String(named).slice("language-".length);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const textOf = (node: ElementContent): string => {
|
|
13
|
+
if (node.type === "text") {
|
|
14
|
+
return node.value;
|
|
15
|
+
}
|
|
16
|
+
return node.type === "element" ? node.children.map(textOf).join("") : "";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const diagram = (source: string): Element => ({
|
|
20
|
+
type: "element",
|
|
21
|
+
tagName: "figure",
|
|
22
|
+
properties: { className: ["diagram"], dataState: "pending" },
|
|
23
|
+
children: [{ type: "element", tagName: "pre", properties: { className: ["diagram-source"] }, children: [{ type: "text", value: source }] }],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const isBundled = (language: string): language is BundledLanguage => Object.hasOwn(bundledLanguages, language);
|
|
27
|
+
|
|
28
|
+
const highlighted = async (highlighter: Shiki, source: string, language: string) => {
|
|
29
|
+
if (!isBundled(language)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
await highlighter.loadLanguage(language);
|
|
33
|
+
return highlighter.codeToHast(source, { lang: language, themes: THEMES, defaultColor: false }).children[0];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const codeBlocks = (highlighter: Shiki) =>
|
|
37
|
+
defineHastPlugin({
|
|
38
|
+
name: "work-board-code",
|
|
39
|
+
element: {
|
|
40
|
+
filter: ["pre"],
|
|
41
|
+
visit: async (pre) => {
|
|
42
|
+
const [code] = pre.children;
|
|
43
|
+
if (code?.type !== "element" || code.tagName !== "code") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const language = languageOf(code);
|
|
47
|
+
const source = textOf(code);
|
|
48
|
+
if (language === "mermaid") {
|
|
49
|
+
return diagram(source);
|
|
50
|
+
}
|
|
51
|
+
return language === undefined ? undefined : await highlighted(highlighter, source, language);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Element } from "hast";
|
|
2
|
+
import { defineHastPlugin } from "satteri";
|
|
3
|
+
|
|
4
|
+
const FOOTNOTE_IDS = /^(?:user-content-fn|footnote-label)/;
|
|
5
|
+
|
|
6
|
+
type Value = Element["properties"][string];
|
|
7
|
+
|
|
8
|
+
const SCOPED = new Set(["id", "href", "ariaDescribedBy"]);
|
|
9
|
+
|
|
10
|
+
const scoped = (prefix: string, value: Value): Value => {
|
|
11
|
+
if (typeof value === "string" && FOOTNOTE_IDS.test(value)) {
|
|
12
|
+
return `${prefix}${value}`;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "string" && value.startsWith("#") && FOOTNOTE_IDS.test(value.slice(1))) {
|
|
15
|
+
return `#${prefix}${value.slice(1)}`;
|
|
16
|
+
}
|
|
17
|
+
return Array.isArray(value) ? value.map((each) => (typeof each === "string" ? String(scoped(prefix, each)) : each)) : value;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const footnoteIds = (prefix: string) =>
|
|
21
|
+
defineHastPlugin({
|
|
22
|
+
name: "work-board-footnote-ids",
|
|
23
|
+
element: {
|
|
24
|
+
filter: ["a", "h2", "li"],
|
|
25
|
+
visit: (element) => ({
|
|
26
|
+
...element,
|
|
27
|
+
properties: Object.fromEntries(
|
|
28
|
+
Object.entries(element.properties).map(([name, value]) => [name, SCOPED.has(name) ? scoped(prefix, value) : value]),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { createHighlighter, type Highlighter as Shiki } from "shiki";
|
|
3
|
+
import { RenderFailed } from "./failed.ts";
|
|
4
|
+
|
|
5
|
+
export const THEMES = { light: "github-light", dark: "github-dark" } as const;
|
|
6
|
+
|
|
7
|
+
export class Highlighter extends Context.Service<Highlighter, Shiki>()("WorkBoard/Highlighter") {
|
|
8
|
+
static readonly layer = Layer.effect(Highlighter)(
|
|
9
|
+
Effect.acquireRelease(
|
|
10
|
+
Effect.tryPromise({
|
|
11
|
+
try: () => createHighlighter({ themes: Object.values(THEMES), langs: [] }),
|
|
12
|
+
catch: (cause) => new RenderFailed({ cause }),
|
|
13
|
+
}),
|
|
14
|
+
(highlighter) => Effect.sync(() => highlighter.dispose()),
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { markdownToHtml } from "satteri";
|
|
3
|
+
import { codeBlocks } from "./code.ts";
|
|
4
|
+
import { RenderFailed } from "./failed.ts";
|
|
5
|
+
import { footnoteIds } from "./footnotes.ts";
|
|
6
|
+
import { Highlighter } from "./highlighter.ts";
|
|
7
|
+
|
|
8
|
+
export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (source: string, idPrefix = "") {
|
|
9
|
+
const highlighter = yield* Highlighter;
|
|
10
|
+
const { html } = yield* Effect.tryPromise({
|
|
11
|
+
try: async () =>
|
|
12
|
+
markdownToHtml(source, { hastPlugins: idPrefix === "" ? [codeBlocks(highlighter)] : [codeBlocks(highlighter), footnoteIds(idPrefix)] }),
|
|
13
|
+
catch: (cause) => new RenderFailed({ cause }),
|
|
14
|
+
});
|
|
15
|
+
return html;
|
|
16
|
+
});
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
3
|
+
import { Layer } from "effect";
|
|
4
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
5
|
+
import { type BoardOptions, boardLayer } from "./board.ts";
|
|
6
|
+
|
|
7
|
+
export const HOST = "127.0.0.1";
|
|
8
|
+
|
|
9
|
+
export interface ServeOptions extends BoardOptions {
|
|
10
|
+
readonly port: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const listenOn = (port: number) => NodeHttpServer.layer(createServer, { host: HOST, port, disablePreemptiveShutdown: true });
|
|
14
|
+
|
|
15
|
+
export const serveBoard = (options: ServeOptions) =>
|
|
16
|
+
HttpRouter.serve(boardLayer(options), { disableLogger: true }).pipe(Layer.provideMerge(listenOn(options.port)));
|