@shivaedev/work-board 0.6.0 → 0.7.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +18 -0
  3. package/dist/cli.js +1 -1
  4. package/dist/page/diagrams.d.ts +1 -1
  5. package/dist/page/diagrams.d.ts.map +1 -1
  6. package/dist/page/diagrams.js +1 -0
  7. package/dist/page/diagrams.js.map +1 -1
  8. package/dist/page/style.d.ts.map +1 -1
  9. package/dist/page/style.js +25 -1
  10. package/dist/page/style.js.map +1 -1
  11. package/dist/render/board.d.ts.map +1 -1
  12. package/dist/render/board.js +5 -2
  13. package/dist/render/board.js.map +1 -1
  14. package/dist/render/callouts.d.ts +3 -0
  15. package/dist/render/callouts.d.ts.map +1 -0
  16. package/dist/render/callouts.js +27 -0
  17. package/dist/render/callouts.js.map +1 -0
  18. package/dist/render/code.d.ts +2 -8
  19. package/dist/render/code.d.ts.map +1 -1
  20. package/dist/render/code.js +26 -18
  21. package/dist/render/code.js.map +1 -1
  22. package/dist/render/documentHeadings.d.ts +2 -658
  23. package/dist/render/documentHeadings.d.ts.map +1 -1
  24. package/dist/render/documentHeadings.js +20 -23
  25. package/dist/render/documentHeadings.js.map +1 -1
  26. package/dist/render/documentLinks.d.ts +2 -659
  27. package/dist/render/documentLinks.d.ts.map +1 -1
  28. package/dist/render/documentLinks.js +22 -25
  29. package/dist/render/documentLinks.js.map +1 -1
  30. package/dist/render/footnotes.d.ts +2 -18
  31. package/dist/render/footnotes.d.ts.map +1 -1
  32. package/dist/render/footnotes.js +9 -11
  33. package/dist/render/footnotes.js.map +1 -1
  34. package/dist/render/markdown.d.ts.map +1 -1
  35. package/dist/render/markdown.js +16 -4
  36. package/dist/render/markdown.js.map +1 -1
  37. package/dist/render/textContent.d.ts +3 -0
  38. package/dist/render/textContent.d.ts.map +1 -0
  39. package/dist/render/textContent.js +7 -0
  40. package/dist/render/textContent.js.map +1 -0
  41. package/dist/render/visual.d.ts +3 -0
  42. package/dist/render/visual.d.ts.map +1 -0
  43. package/dist/render/visual.js +90 -0
  44. package/dist/render/visual.js.map +1 -0
  45. package/dist/render/visualDocuments.d.ts +5 -0
  46. package/dist/render/visualDocuments.d.ts.map +1 -0
  47. package/dist/render/visualDocuments.js +57 -0
  48. package/dist/render/visualDocuments.js.map +1 -0
  49. package/dist/render/visualFields.d.ts +12 -0
  50. package/dist/render/visualFields.d.ts.map +1 -0
  51. package/dist/render/visualFields.js +13 -0
  52. package/dist/render/visualFields.js.map +1 -0
  53. package/dist/search/collector.d.ts +2 -8
  54. package/dist/search/collector.d.ts.map +1 -1
  55. package/dist/search/collector.js +29 -25
  56. package/dist/search/collector.js.map +1 -1
  57. package/dist/search/entries.d.ts.map +1 -1
  58. package/dist/search/entries.js +10 -3
  59. package/dist/search/entries.js.map +1 -1
  60. package/dist/search/linkCollector.d.ts +2 -7
  61. package/dist/search/linkCollector.d.ts.map +1 -1
  62. package/dist/search/linkCollector.js +12 -13
  63. package/dist/search/linkCollector.js.map +1 -1
  64. package/package.json +13 -2
  65. package/src/cli.ts +1 -1
  66. package/src/page/diagrams.ts +1 -0
  67. package/src/page/style.ts +26 -1
  68. package/src/render/board.ts +5 -2
  69. package/src/render/callouts.ts +29 -0
  70. package/src/render/code.ts +29 -19
  71. package/src/render/documentHeadings.ts +21 -23
  72. package/src/render/documentLinks.ts +23 -25
  73. package/src/render/footnotes.ts +11 -14
  74. package/src/render/markdown.ts +20 -4
  75. package/src/render/textContent.ts +8 -0
  76. package/src/render/visual.ts +100 -0
  77. package/src/render/visualDocuments.ts +68 -0
  78. package/src/render/visualFields.ts +17 -0
  79. package/src/search/collector.ts +30 -27
  80. package/src/search/entries.ts +10 -3
  81. package/src/search/linkCollector.ts +13 -13
@@ -1,16 +1,15 @@
1
- import { defineHastPlugin } from "satteri";
1
+ import { visit as visitElements } from "unist-util-visit";
2
2
  export function linkCollector(links) {
3
- return defineHastPlugin({
4
- element: {
5
- filter: ["a"],
6
- visit: (element) => {
7
- const href = element.properties.href;
8
- if (typeof href === "string" && !href.startsWith("#")) {
9
- links.add(href);
10
- }
11
- },
12
- },
13
- name: "work-board-link-collector",
14
- });
3
+ return () => (tree) => {
4
+ visitElements(tree, "element", (element) => {
5
+ if (element.tagName !== "a") {
6
+ return;
7
+ }
8
+ const href = element.properties.href;
9
+ if (typeof href === "string" && !href.startsWith("#")) {
10
+ links.add(href);
11
+ }
12
+ });
13
+ };
15
14
  }
16
15
  //# sourceMappingURL=linkCollector.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"linkCollector.js","sourceRoot":"","sources":["../../src/search/linkCollector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAE3C,MAAM,UAAU,aAAa,CAAC,KAAkB;IAC/C,OAAO,gBAAgB,CAAC;QACvB,OAAO,EAAE;YACR,MAAM,EAAE,CAAC,GAAG,CAAC;YACb,KAAK,EAAE,CAAC,OAAO,EAAE,EAAE;gBAClB,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC;gBACrC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;oBACvD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBACjB,CAAC;YACF,CAAC;SACD;QACD,IAAI,EAAE,2BAA2B;KACjC,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"linkCollector.js","sourceRoot":"","sources":["../../src/search/linkCollector.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,IAAI,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAE1D,MAAM,UAAU,aAAa,CAAC,KAAkB;IAC/C,OAAO,GAAG,EAAE,CAAC,CAAC,IAAU,EAAE,EAAE;QAC3B,aAAa,CAAC,IAAI,EAAE,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YAC1C,IAAI,OAAO,CAAC,OAAO,KAAK,GAAG,EAAE,CAAC;gBAC7B,OAAO;YACR,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC;YACrC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;gBACvD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACjB,CAAC;QACF,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/work-board",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Serve a folder of markdown files as a live local page that updates in place when a file changes",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/work-board#readme",
6
6
  "bugs": {
@@ -46,9 +46,18 @@
46
46
  ],
47
47
  "dependencies": {
48
48
  "@shivaedev/effect-contract": "0.4.0",
49
+ "mdast-util-directive": "3.1.0",
49
50
  "mermaid": "11.16.1",
50
- "satteri": "0.9.5",
51
+ "react": "19.2.8",
52
+ "react-dom": "19.2.8",
53
+ "react-markdown": "10.1.0",
54
+ "rehype-raw": "7.0.0",
55
+ "remark-directive": "4.0.0",
56
+ "remark-gfm": "4.0.1",
57
+ "remark-parse": "11.0.0",
51
58
  "shiki": "4.4.2",
59
+ "unified": "11.0.5",
60
+ "unist-util-visit": "5.0.0",
52
61
  "yaml": "2.9.0"
53
62
  },
54
63
  "devDependencies": {
@@ -58,6 +67,8 @@
58
67
  "@types/hast": "3.0.5",
59
68
  "@types/mdast": "4.0.4",
60
69
  "@types/node": "24.10.1",
70
+ "@types/react": "19.2.18",
71
+ "@types/react-dom": "19.2.4",
61
72
  "@typescript/native": "npm:typescript@7.0.2",
62
73
  "effect": "4.0.0-rc.112",
63
74
  "esbuild": "0.28.1",
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { Effect, Layer, Option, Path } from "effect";
4
4
  import { Argument, Command, Flag } from "effect/unstable/cli";
5
5
  import { HOST, serveBoard } from "./serve.ts";
6
6
 
7
- const VERSION = "0.6.0";
7
+ const VERSION = "0.7.0";
8
8
 
9
9
  const serve = Effect.fn("WorkBoard.serve")(function* (input: { readonly dir: string; readonly port: number; readonly home: Option.Option<string> }) {
10
10
  const path = yield* Path.Path;
@@ -11,6 +11,7 @@ const load = async () => {
11
11
  mermaid ??= (await import("/_board/mermaid/mermaid.esm.min.mjs")).default;
12
12
  mermaid.initialize({
13
13
  startOnLoad: false,
14
+ suppressErrorRendering: true,
14
15
  securityLevel: "strict",
15
16
  theme: "base",
16
17
  themeVariables: {
package/src/page/style.ts CHANGED
@@ -58,5 +58,30 @@ figure.diagram[data-state="failed"] .diagram-source { display: block; }
58
58
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
59
59
  `;
60
60
 
61
+ const documents = `
62
+ .visual-document, .visual-callout { border: 1px solid var(--border); border-radius: 12px; padding: 1em 1.2em; margin: 1.2em 0; overflow-wrap: anywhere; }
63
+ .visual-document > :last-child, .visual-callout > :last-child { margin-bottom: 0; }
64
+ .visual-label { font-weight: 650; margin-top: 0; }
65
+ .visual-value { font-size: 1.35em; font-variant-numeric: tabular-nums; }
66
+ .visual-document progress { display: block; width: 100%; height: 1em; accent-color: var(--link); }
67
+ .visual-timeline-entries { border-left: 2px solid var(--border); padding-left: 1.6em; }
68
+ .visual-timeline-entries > li { padding: .3em 0 .7em .2em; }
69
+ .visual-callout { border-left: 4px solid var(--link); background: var(--surface); }
70
+ .visual-warning, .visual-caution { border-left-color: #b97720; }
71
+ .visual-diagnostic { font-weight: 600; color: var(--muted); }
72
+ @media print { .visual-document, .visual-callout { break-inside: avoid; } }
73
+ `;
74
+
61
75
  export const style =
62
- theme + layout + prose + board + diagrams + readingStyles() + searchStyles + metadataStyles + workStyle() + attentionStyle() + visualStyles;
76
+ theme
77
+ + layout
78
+ + prose
79
+ + board
80
+ + diagrams
81
+ + readingStyles()
82
+ + searchStyles
83
+ + metadataStyles
84
+ + workStyle()
85
+ + attentionStyle()
86
+ + visualStyles
87
+ + documents;
@@ -1,5 +1,8 @@
1
1
  import type { RootContent } from "mdast";
2
- import { markdownToMdast } from "satteri";
2
+ import remarkDirective from "remark-directive";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkParse from "remark-parse";
5
+ import { unified } from "unified";
3
6
 
4
7
  export interface Section {
5
8
  readonly heading: string;
@@ -58,7 +61,7 @@ const joined = (blocks: readonly string[]): string => blocks.join("\n\n");
58
61
 
59
62
  export const boardOf = (source: string): Board => {
60
63
  const draft: Draft = { definitions: [], footer: undefined, intro: [], sections: [], title: "" };
61
- const root = markdownToMdast(source);
64
+ const root = unified().use(remarkParse).use(remarkGfm).use(remarkDirective).parse(source);
62
65
  const nodes = root.type === "root" ? root.children : [];
63
66
  const lastHeading = nodes.findLastIndex((node) => node.type === "heading");
64
67
  nodes.forEach((node, index) => {
@@ -0,0 +1,29 @@
1
+ import type { Root } from "mdast";
2
+ import { visit as visitNodes } from "unist-util-visit";
3
+
4
+ const alerts = new Set(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]);
5
+
6
+ export function callouts() {
7
+ return function transform(tree: Root) {
8
+ visitNodes(tree, "blockquote", (node) => {
9
+ const [first] = node.children;
10
+ const text = first?.type === "paragraph" ? first.children.at(0) : undefined;
11
+ if (text?.type !== "text") {
12
+ return;
13
+ }
14
+ const match = /^\[!(?<kind>[A-Z]+)\](?:\n|$)/u.exec(text.value);
15
+ const named = match?.groups?.kind;
16
+ if (!(named && alerts.has(named))) {
17
+ return;
18
+ }
19
+ const kind = named.toLowerCase();
20
+ text.value = text.value.slice(match?.[0].length ?? 0);
21
+ node.children.unshift({
22
+ children: [{ type: "text", value: named[0] + kind.slice(1) }],
23
+ data: { hProperties: { className: ["visual-label"] } },
24
+ type: "paragraph",
25
+ });
26
+ node.data = { ...node.data, hName: "aside", hProperties: { className: ["visual-callout", `visual-${kind}`], role: "note" } };
27
+ });
28
+ };
29
+ }
@@ -1,6 +1,6 @@
1
- import type { Element, ElementContent } from "hast";
2
- import { defineHastPlugin } from "satteri";
1
+ import type { Element, ElementContent, Root } from "hast";
3
2
  import { type BundledLanguage, bundledLanguages, type Highlighter as Shiki } from "shiki";
3
+ import { visit as visitElements } from "unist-util-visit";
4
4
  import { THEMES } from "./highlighter.ts";
5
5
 
6
6
  const languageOf = (code: Element): string | undefined => {
@@ -33,22 +33,32 @@ const highlighted = async (highlighter: Shiki, source: string, language: string)
33
33
  return highlighter.codeToHast(source, { defaultColor: false, lang: language, themes: THEMES }).children.at(0);
34
34
  };
35
35
 
36
- export const codeBlocks = (highlighter: Shiki) =>
37
- defineHastPlugin({
38
- element: {
39
- filter: ["pre"],
40
- visit: async (pre) => {
41
- const [code] = pre.children;
42
- if (code?.type !== "element" || code.tagName !== "code") {
43
- return undefined;
44
- }
45
- const language = languageOf(code);
46
- const source = textOf(code);
47
- if (language === "mermaid") {
48
- return diagram(source);
36
+ async function renderedCode(highlighter: Shiki, pre: Element) {
37
+ const [code] = pre.children;
38
+ if (code?.type !== "element" || code.tagName !== "code") {
39
+ return undefined;
40
+ }
41
+ const language = languageOf(code);
42
+ const source = textOf(code);
43
+ if (language === "mermaid") {
44
+ return diagram(source);
45
+ }
46
+ return language === undefined ? undefined : await highlighted(highlighter, source, language);
47
+ }
48
+
49
+ export const codeBlocks = (highlighter: Shiki) => () => async (tree: Root) => {
50
+ const work: Promise<void>[] = [];
51
+ visitElements(tree, "element", (pre, index, parent) => {
52
+ if (pre.tagName !== "pre" || index === undefined || parent === undefined) {
53
+ return;
54
+ }
55
+ work.push(
56
+ renderedCode(highlighter, pre).then((result) => {
57
+ if (result?.type === "element") {
58
+ parent.children[index] = result;
49
59
  }
50
- return language === undefined ? undefined : await highlighted(highlighter, source, language);
51
- },
52
- },
53
- name: "work-board-code",
60
+ }),
61
+ );
54
62
  });
63
+ await Promise.all(work);
64
+ };
@@ -1,4 +1,6 @@
1
- import { defineHastPlugin } from "satteri";
1
+ import type { Root } from "hast";
2
+ import { visit as visitElements } from "unist-util-visit";
3
+ import { textContent } from "#render/textContent.ts";
2
4
 
3
5
  export interface Heading {
4
6
  readonly depth: number;
@@ -9,14 +11,16 @@ export interface Heading {
9
11
  export function documentHeadings() {
10
12
  const entries: Heading[] = [];
11
13
  const used = new Set<string>();
12
- const plugin = defineHastPlugin({
13
- element: {
14
- filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
15
- visit: (element, context) => {
14
+ function plugin() {
15
+ return function transform(tree: Root) {
16
+ visitElements(tree, "element", (element) => {
17
+ if (!["h1", "h2", "h3", "h4", "h5", "h6"].includes(element.tagName)) {
18
+ return;
19
+ }
16
20
  if (element.properties.id !== undefined) {
17
21
  return;
18
22
  }
19
- const title = context.textContent(element).trim();
23
+ const title = element.children.map(textContent).join("").trim();
20
24
  const slug =
21
25
  title
22
26
  .normalize("NFKC")
@@ -31,22 +35,16 @@ export function documentHeadings() {
31
35
  }
32
36
  used.add(id);
33
37
  entries.push({ depth: Number(element.tagName.slice(1)), id, title });
34
- return {
35
- ...element,
36
- children: [
37
- ...element.children,
38
- {
39
- children: [],
40
- properties: { ariaLabel: `Link to ${title || "section"}`, className: ["heading-anchor"], href: `#${id}` },
41
- tagName: "a",
42
- type: "element" as const,
43
- },
44
- ],
45
- properties: { ...element.properties, id, tabIndex: -1 },
46
- };
47
- },
48
- },
49
- name: "work-board-headings",
50
- });
38
+ element.children.push({
39
+ children: [],
40
+ properties: { ariaLabel: `Link to ${title || "section"}`, className: ["heading-anchor"], href: `#${id}` },
41
+ tagName: "a",
42
+ type: "element",
43
+ });
44
+ element.properties.id = id;
45
+ element.properties.tabIndex = -1;
46
+ });
47
+ };
48
+ }
51
49
  return { entries, plugin };
52
50
  }
@@ -1,30 +1,28 @@
1
- import { defineHastPlugin } from "satteri";
1
+ import type { Element, Root } from "hast";
2
+ import { visit as visitElements } from "unist-util-visit";
2
3
  import { fileUrl } from "#files/url.ts";
3
4
  import { attachmentHref } from "#path/attachment.ts";
4
5
 
5
6
  export function documentLinks(file: string) {
6
- return defineHastPlugin({
7
- element: {
8
- filter: ["a", "img"],
9
- visit: (element) => {
10
- const attribute = element.tagName === "img" ? "src" : "href";
11
- const href = element.properties[attribute];
12
- if (typeof href !== "string" || href.startsWith("#") || href.startsWith("//") || /^[a-z][a-z\d+.-]*:/iu.exec(href) !== null) {
13
- return;
14
- }
15
- const target = new URL(href, `http://work-board.local${fileUrl(file)}`);
16
- const resolved = target.pathname + target.search + target.hash;
17
- const image = attachmentHref(resolved);
18
- return {
19
- ...element,
20
- properties: {
21
- ...element.properties,
22
- [attribute]: image ?? resolved,
23
- ...(element.tagName === "img" && image ? { dataLocalImage: image } : {}),
24
- },
25
- };
26
- },
27
- },
28
- name: "work-board-document-links",
29
- });
7
+ function resolve(element: Element) {
8
+ const attribute = element.tagName === "img" ? "src" : "href";
9
+ const href = element.properties[attribute];
10
+ if (typeof href !== "string" || href.startsWith("#") || href.startsWith("//") || /^[a-z][a-z\d+.-]*:/iu.exec(href) !== null) {
11
+ return;
12
+ }
13
+ const target = new URL(href, `http://work-board.local${fileUrl(file)}`);
14
+ const resolved = target.pathname + target.search + target.hash;
15
+ const image = attachmentHref(resolved);
16
+ element.properties[attribute] = image ?? resolved;
17
+ if (element.tagName === "img" && image) {
18
+ element.properties.dataLocalImage = image;
19
+ }
20
+ }
21
+ return () => (tree: Root) => {
22
+ visitElements(tree, "element", (element) => {
23
+ if (element.tagName === "a" || element.tagName === "img") {
24
+ resolve(element);
25
+ }
26
+ });
27
+ };
30
28
  }
@@ -1,5 +1,5 @@
1
- import type { Element } from "hast";
2
- import { defineHastPlugin } from "satteri";
1
+ import type { Element, Root } from "hast";
2
+ import { visit as visitElements } from "unist-util-visit";
3
3
 
4
4
  const FOOTNOTE_IDS = /^(?:user-content-fn|footnote-label)/u;
5
5
 
@@ -17,16 +17,13 @@ const scoped = (prefix: string, value: Value): Value => {
17
17
  return Array.isArray(value) ? value.map((each) => (typeof each === "string" ? String(scoped(prefix, each)) : each)) : value;
18
18
  };
19
19
 
20
- export const footnoteIds = (prefix: string) =>
21
- defineHastPlugin({
22
- element: {
23
- filter: ["a", "h2", "li"],
24
- visit: (element) => ({
25
- ...element,
26
- properties: Object.fromEntries(
27
- Object.entries(element.properties).map(([name, value]) => [name, SCOPED.has(name) ? scoped(prefix, value) : value]),
28
- ),
29
- }),
30
- },
31
- name: "work-board-footnote-ids",
20
+ export const footnoteIds = (prefix: string) => () => (tree: Root) => {
21
+ visitElements(tree, "element", (element) => {
22
+ if (!["a", "h2", "li"].includes(element.tagName)) {
23
+ return;
24
+ }
25
+ element.properties = Object.fromEntries(
26
+ Object.entries(element.properties).map(([name, value]) => [name, SCOPED.has(name) ? scoped(prefix, value) : value]),
27
+ );
32
28
  });
29
+ };
@@ -1,5 +1,13 @@
1
1
  import { Effect } from "effect";
2
- import { type HastPluginInput, markdownToHtml } from "satteri";
2
+ import { createElement } from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { MarkdownAsync } from "react-markdown";
5
+ import rehypeRaw from "rehype-raw";
6
+ import remarkDirective from "remark-directive";
7
+ import remarkGfm from "remark-gfm";
8
+ import type { PluggableList } from "unified";
9
+ import { callouts } from "#render/callouts.ts";
10
+ import { visualDocuments } from "#render/visualDocuments.ts";
3
11
  import { codeBlocks } from "./code.ts";
4
12
  import type { documentHeadings } from "./documentHeadings.ts";
5
13
  import { documentLinks } from "./documentLinks.ts";
@@ -15,7 +23,7 @@ export interface RenderOptions {
15
23
 
16
24
  export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (source: string, options: RenderOptions = {}) {
17
25
  const highlighter = yield* Highlighter;
18
- const plugins: HastPluginInput[] = [codeBlocks(highlighter)];
26
+ const plugins: PluggableList = [rehypeRaw, codeBlocks(highlighter)];
19
27
  if (options.idPrefix !== undefined) {
20
28
  plugins.push(footnoteIds(options.idPrefix));
21
29
  }
@@ -25,9 +33,17 @@ export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (s
25
33
  if (options.headings !== undefined) {
26
34
  plugins.push(options.headings.plugin);
27
35
  }
28
- const { html } = yield* Effect.tryPromise({
36
+ const html = yield* Effect.tryPromise({
29
37
  catch: (cause) => new RenderFailed({ cause }),
30
- try: async () => markdownToHtml(source, { hastPlugins: plugins }),
38
+ try: async () =>
39
+ renderToStaticMarkup(
40
+ await MarkdownAsync({
41
+ children: source,
42
+ components: { img: ({ node: _node, ...props }) => createElement("img", { ...props, loading: "lazy" }) },
43
+ rehypePlugins: plugins,
44
+ remarkPlugins: [remarkGfm, remarkDirective, visualDocuments, callouts],
45
+ }),
46
+ ),
31
47
  });
32
48
  return html;
33
49
  });
@@ -0,0 +1,8 @@
1
+ import type { ElementContent } from "hast";
2
+
3
+ export function textContent(node: ElementContent): string {
4
+ if (node.type === "text") {
5
+ return node.value;
6
+ }
7
+ return node.type === "element" ? node.children.map(textContent).join("") : "";
8
+ }
@@ -0,0 +1,100 @@
1
+ import { Option, Schema } from "effect";
2
+ import type { Paragraph, RootContent } from "mdast";
3
+ import type { ContainerDirective } from "mdast-util-directive";
4
+ import { MetricFields, ProgressFields } from "./visualFields.ts";
5
+
6
+ function textOf(node: RootContent): string {
7
+ if ("value" in node) {
8
+ return node.value;
9
+ }
10
+ return "children" in node ? node.children.map((child) => textOf(child)).join("") : "";
11
+ }
12
+
13
+ function paragraph(value: string, className?: string): Paragraph {
14
+ return { children: [{ type: "text", value }], data: { hProperties: { className: className ? [className] : [] } }, type: "paragraph" };
15
+ }
16
+
17
+ function hasLink(node: RootContent): boolean {
18
+ if (node.type === "link") {
19
+ return node.url.trim().length > 0;
20
+ }
21
+ if (node.type === "linkReference") {
22
+ return node.identifier.trim().length > 0;
23
+ }
24
+ return "children" in node && node.children.some((child) => hasLink(child));
25
+ }
26
+
27
+ function provenance(node: ContainerDirective): boolean {
28
+ return node.children.some((child) => child.type === "paragraph" && textOf(child).toLowerCase().startsWith("source:") && hasLink(child));
29
+ }
30
+
31
+ function fields<A>(schema: Schema.ConstraintDecoder<A, never>, node: ContainerDirective): A {
32
+ const parsed = Schema.decodeUnknownOption(schema, { onExcessProperty: "error" })(node.attributes ?? {});
33
+ if (Option.isNone(parsed)) {
34
+ throw new Error("Invalid or unknown attributes.");
35
+ }
36
+ return parsed.value;
37
+ }
38
+
39
+ function metric(node: ContainerDirective) {
40
+ const { unit, value } = fields(MetricFields, node);
41
+ if (!provenance(node)) {
42
+ throw new Error("Name a source with a Source: Markdown link.");
43
+ }
44
+ node.children.splice(1, 0, paragraph(value === undefined || value === "unknown" ? "Not recorded" : `${value} ${unit}`, "visual-value"));
45
+ }
46
+
47
+ function progress(node: ContainerDirective, label: string) {
48
+ const { completed, total } = fields(ProgressFields, node);
49
+ if (!provenance(node)) {
50
+ throw new Error("Name a source with a Source: Markdown link.");
51
+ }
52
+ const known = typeof completed === "number" && typeof total === "number";
53
+ if (known && completed > total) {
54
+ throw new Error("Completed count exceeds total.");
55
+ }
56
+ function display(value: number | "unknown" | undefined) {
57
+ return typeof value === "number" ? String(value) : "Not recorded";
58
+ }
59
+ const tally = `Recorded tally: ${display(completed)} of ${display(total)}`;
60
+ node.children.splice(1, 0, paragraph(tally + (known && total > 0 ? "" : ". Percentage unavailable."), "visual-value"));
61
+ if (known && total > 0) {
62
+ node.children.splice(2, 0, {
63
+ children: [{ type: "text", value: tally }],
64
+ data: { hName: "progress", hProperties: { ariaLabel: `${label}: ${tally}`, max: String(total), value: String(completed) } },
65
+ type: "paragraph",
66
+ });
67
+ }
68
+ }
69
+
70
+ function timeline(node: ContainerDirective) {
71
+ fields(Schema.Struct({}), node);
72
+ const lists = node.children.filter((child) => child.type === "list");
73
+ const [list] = lists;
74
+ if (lists.length !== 1 || !list?.ordered || list.children.length === 0 || list.children.length > 100) {
75
+ throw new Error("Use one ordered list with 1–100 entries.");
76
+ }
77
+ if (list.children.some((item) => !hasLink(item) || textOf(item).trim().length === 0)) {
78
+ throw new Error("Each timeline entry needs text and a source link.");
79
+ }
80
+ list.data = { ...list.data, hProperties: { className: ["visual-timeline-entries"] } };
81
+ }
82
+
83
+ export function renderVisual(node: ContainerDirective) {
84
+ const [first] = node.children;
85
+ if (first?.type !== "paragraph" || textOf(first).trim().length === 0 || textOf(first).toLowerCase().startsWith("source:")) {
86
+ throw new Error("Start with a Markdown label paragraph.");
87
+ }
88
+ const label = textOf(first).trim();
89
+ if (node.name === "metric") {
90
+ metric(node);
91
+ }
92
+ if (node.name === "progress") {
93
+ progress(node, label);
94
+ }
95
+ if (node.name === "timeline") {
96
+ timeline(node);
97
+ }
98
+ first.data = { ...first.data, hProperties: { className: ["visual-label"] } };
99
+ node.data = { ...node.data, hName: "section", hProperties: { ariaLabel: label, className: ["visual-document", `visual-${node.name}`] } };
100
+ }
@@ -0,0 +1,68 @@
1
+ import type { Root, RootContent } from "mdast";
2
+ import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive";
3
+ import { SKIP, visit as visitNodes } from "unist-util-visit";
4
+ import { renderVisual } from "./visual.ts";
5
+
6
+ type Directive = ContainerDirective | LeafDirective | TextDirective;
7
+ const names = new Set(["metric", "progress", "timeline"]);
8
+
9
+ function isDirective(node: { readonly type: string }): node is Directive {
10
+ return node.type === "containerDirective" || node.type === "leafDirective" || node.type === "textDirective";
11
+ }
12
+
13
+ function nested(node: RootContent): boolean {
14
+ return isDirective(node) || ("children" in node && node.children.some((child) => nested(child)));
15
+ }
16
+
17
+ function failureOf(node: Directive, source: string): string | undefined {
18
+ try {
19
+ if (!names.has(node.name)) {
20
+ throw new Error(`Unknown visual directive ${node.name}.`);
21
+ }
22
+ if (node.type !== "containerDirective") {
23
+ throw new Error("Use a ::: container directive.");
24
+ }
25
+ const length = new TextEncoder().encode(source.slice(node.position?.start.offset, node.position?.end.offset)).byteLength;
26
+ if (length > 65_536) {
27
+ throw new Error("Visual directive exceeds 64 KiB.");
28
+ }
29
+ if (node.children.some((child) => nested(child))) {
30
+ throw new Error("Nested visual directives are not supported.");
31
+ }
32
+ renderVisual(node);
33
+ return undefined;
34
+ } catch (error) {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+ }
38
+
39
+ export function visualDocuments() {
40
+ return function transform(tree: Root, file: { readonly value: unknown }) {
41
+ const source = String(file.value);
42
+ visitNodes(tree, ["containerDirective", "leafDirective", "textDirective"], (directive, index, parent) => {
43
+ if (!isDirective(directive) || index === undefined || parent === undefined) {
44
+ return;
45
+ }
46
+ const failure = failureOf(directive, source);
47
+ if (failure === undefined) {
48
+ return;
49
+ }
50
+ const original = source.slice(directive.position?.start.offset, directive.position?.end.offset);
51
+ if (directive.type === "textDirective") {
52
+ parent.children.splice(index, 1, { type: "text", value: original });
53
+ return [SKIP, index + 1] as const;
54
+ }
55
+ parent.children.splice(
56
+ index,
57
+ 1,
58
+ {
59
+ children: [{ type: "text", value: `Visual component could not be rendered: ${failure}` }],
60
+ data: { hProperties: { className: ["visual-diagnostic"] } },
61
+ type: "paragraph",
62
+ },
63
+ { type: "code", value: original },
64
+ );
65
+ return [SKIP, index + 2] as const;
66
+ });
67
+ };
68
+ }
@@ -0,0 +1,17 @@
1
+ import { Schema, SchemaTransformation } from "effect";
2
+
3
+ const NumberValue = Schema.String.check(Schema.isPattern(/\S/u)).pipe(Schema.decodeTo(Schema.Finite, SchemaTransformation.numberFromString));
4
+ const Count = NumberValue.check(Schema.makeFilter((value) => (Number.isSafeInteger(value) && value >= 0) || "Expected a nonnegative safe integer"));
5
+ const UnknownValue = Schema.Literal("unknown");
6
+
7
+ export const MetricFields = Schema.Struct({
8
+ unit: Schema.String.check(Schema.isPattern(/\S/u)),
9
+ value: Schema.optional(Schema.Union([NumberValue, UnknownValue])),
10
+ });
11
+ export const ProgressFields = Schema.Struct({
12
+ completed: Schema.optional(Schema.Union([Count, UnknownValue])),
13
+ total: Schema.optional(Schema.Union([Count, UnknownValue])),
14
+ });
15
+
16
+ export type MetricFields = typeof MetricFields.Type;
17
+ export type ProgressFields = typeof ProgressFields.Type;