@ferax564/noma-cli 0.11.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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/noma.mjs +8 -0
- package/dist/ast.d.ts +111 -0
- package/dist/ast.js +23 -0
- package/dist/ast.js.map +1 -0
- package/dist/book.d.ts +56 -0
- package/dist/book.js +120 -0
- package/dist/book.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +573 -0
- package/dist/cli.js.map +1 -0
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +77 -0
- package/dist/diff.js.map +1 -0
- package/dist/fmt.d.ts +1 -0
- package/dist/fmt.js +105 -0
- package/dist/fmt.js.map +1 -0
- package/dist/ids.d.ts +15 -0
- package/dist/ids.js +27 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/inline.d.ts +14 -0
- package/dist/inline.js +83 -0
- package/dist/inline.js.map +1 -0
- package/dist/loader.d.ts +12 -0
- package/dist/loader.js +59 -0
- package/dist/loader.js.map +1 -0
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +434 -0
- package/dist/parser.js.map +1 -0
- package/dist/patch.d.ts +61 -0
- package/dist/patch.js +530 -0
- package/dist/patch.js.map +1 -0
- package/dist/renderer-html.d.ts +44 -0
- package/dist/renderer-html.js +929 -0
- package/dist/renderer-html.js.map +1 -0
- package/dist/renderer-json.d.ts +5 -0
- package/dist/renderer-json.js +4 -0
- package/dist/renderer-json.js.map +1 -0
- package/dist/renderer-llm.d.ts +29 -0
- package/dist/renderer-llm.js +275 -0
- package/dist/renderer-llm.js.map +1 -0
- package/dist/renderer-noma.d.ts +10 -0
- package/dist/renderer-noma.js +179 -0
- package/dist/renderer-noma.js.map +1 -0
- package/dist/renderer-site.d.ts +11 -0
- package/dist/renderer-site.js +175 -0
- package/dist/renderer-site.js.map +1 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +699 -0
- package/dist/validator.js.map +1 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.js +141 -0
- package/dist/verify.js.map +1 -0
- package/package.json +83 -0
- package/schemas/ast.schema.json +187 -0
- package/schemas/capability.schema.json +70 -0
- package/schemas/patch-op.schema.json +92 -0
- package/schemas/patch-transaction.schema.json +28 -0
- package/schemas/transcript.schema.json +95 -0
- package/src/ast.ts +152 -0
- package/src/book.ts +162 -0
- package/src/cli.ts +595 -0
- package/src/diff.ts +108 -0
- package/src/fmt.ts +126 -0
- package/src/ids.ts +42 -0
- package/src/index.ts +20 -0
- package/src/inline.ts +92 -0
- package/src/loader.ts +55 -0
- package/src/parser.ts +501 -0
- package/src/patch.ts +646 -0
- package/src/renderer-html.ts +1047 -0
- package/src/renderer-json.ts +9 -0
- package/src/renderer-llm.ts +320 -0
- package/src/renderer-noma.ts +220 -0
- package/src/renderer-site.ts +245 -0
- package/src/validator.ts +733 -0
- package/src/verify.ts +157 -0
- package/themes/dark.css +382 -0
- package/themes/default.css +537 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
import type { DirectiveNode, DocumentNode, Node, SectionNode } from "./ast.js";
|
|
3
|
+
import { walk } from "./ast.js";
|
|
4
|
+
import { escapeAttr, escapeHtml, inlineToHtml, splitPipeRow } from "./inline.js";
|
|
5
|
+
|
|
6
|
+
export interface HtmlRenderOptions {
|
|
7
|
+
/** When true, wrap output in a full HTML document with the default theme. */
|
|
8
|
+
standalone?: boolean;
|
|
9
|
+
/** Override page title (defaults to meta.title or the first H1). */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Inline CSS injected into <head> when standalone. */
|
|
12
|
+
themeCss?: string;
|
|
13
|
+
/**
|
|
14
|
+
* When set, the standalone HTML head emits `<link rel="stylesheet" href="...">`
|
|
15
|
+
* pointing here, and `themeCss` is ignored. Used by multi-page site rendering
|
|
16
|
+
* to deduplicate theme bytes across pages.
|
|
17
|
+
*/
|
|
18
|
+
stylesheetHref?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Allow `::html`, `::svg`, `::script` escape hatches to emit raw markup.
|
|
21
|
+
* Default: `true` (artifact mode). Set `false` for trusted-publishing
|
|
22
|
+
* contexts where unfiltered HTML is unsafe.
|
|
23
|
+
*/
|
|
24
|
+
allowEscapeHatches?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Math rendering. `katex` injects KaTeX CDN assets in standalone HTML and
|
|
27
|
+
* configures auto-render for `$..$`, `$$..$$`, `\(..\)`, `\[..\]`. Default
|
|
28
|
+
* is auto-detect: enabled when the doc uses `::math` or `$$..$$` delimiters,
|
|
29
|
+
* or `meta.math` is truthy.
|
|
30
|
+
*/
|
|
31
|
+
math?: "katex" | "none";
|
|
32
|
+
/**
|
|
33
|
+
* When false, standalone HTML does not inject external CDN assets for math,
|
|
34
|
+
* diagrams, or Plotly. The source/placeholder markup still renders.
|
|
35
|
+
*/
|
|
36
|
+
externalAssets?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface DatasetTable {
|
|
40
|
+
columns: string[];
|
|
41
|
+
rows: unknown[][];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface RenderCtx {
|
|
45
|
+
allowEscapeHatches: boolean;
|
|
46
|
+
datasets: Map<string, DatasetTable>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildDatasetRegistry(doc: DocumentNode): Map<string, DatasetTable> {
|
|
50
|
+
const out = new Map<string, DatasetTable>();
|
|
51
|
+
for (const node of walk(doc)) {
|
|
52
|
+
if (node.type !== "directive" || node.name !== "dataset" || !node.id) continue;
|
|
53
|
+
const table = parseDatasetBody(node);
|
|
54
|
+
if (table) out.set(node.id, table);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseDatasetBody(node: DirectiveNode): DatasetTable | null {
|
|
60
|
+
const body = node.body ?? "";
|
|
61
|
+
if (!body.trim()) return null;
|
|
62
|
+
const format = String(node.attrs.format ?? "").toLowerCase();
|
|
63
|
+
if (format === "csv" || format === "tsv") {
|
|
64
|
+
return parseDelimited(body, format === "tsv" ? "\t" : ",");
|
|
65
|
+
}
|
|
66
|
+
if (format === "json") return parseJsonDataset(body, node);
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
try {
|
|
69
|
+
parsed = yaml.load(body);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
74
|
+
const obj = parsed as Record<string, unknown>;
|
|
75
|
+
const schema = obj.schema;
|
|
76
|
+
const rows = obj.rows;
|
|
77
|
+
if (!Array.isArray(rows)) return null;
|
|
78
|
+
let columns: string[] = [];
|
|
79
|
+
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
|
80
|
+
columns = Object.keys(schema as Record<string, unknown>);
|
|
81
|
+
} else if (typeof node.attrs.columns === "string") {
|
|
82
|
+
columns = node.attrs.columns.split(/[,\s]+/).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
const cleanRows: unknown[][] = rows
|
|
85
|
+
.filter((r): r is unknown[] => Array.isArray(r))
|
|
86
|
+
.map((r) => [...r]);
|
|
87
|
+
return { columns, rows: cleanRows };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseDelimited(body: string, delim: string): DatasetTable | null {
|
|
91
|
+
const lines = body.replace(/\r\n?/g, "\n").split("\n").filter((l) => l.length > 0);
|
|
92
|
+
if (lines.length === 0) return null;
|
|
93
|
+
const split = (s: string) => s.split(delim).map((c) => c.trim());
|
|
94
|
+
const columns = split(lines[0]!);
|
|
95
|
+
const rows: unknown[][] = lines.slice(1).map((l) => {
|
|
96
|
+
const cells = split(l);
|
|
97
|
+
return cells.map((c) => {
|
|
98
|
+
if (c === "") return null;
|
|
99
|
+
const n = Number(c);
|
|
100
|
+
return Number.isFinite(n) && /^-?\d/.test(c) ? n : c;
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
return { columns, rows };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJsonDataset(body: string, node: DirectiveNode): DatasetTable | null {
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(body);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(parsed)) {
|
|
114
|
+
if (parsed.length === 0) return { columns: [], rows: [] };
|
|
115
|
+
if (typeof parsed[0] === "object" && parsed[0] !== null && !Array.isArray(parsed[0])) {
|
|
116
|
+
const columns = Object.keys(parsed[0] as Record<string, unknown>);
|
|
117
|
+
const rows = (parsed as Record<string, unknown>[]).map((r) =>
|
|
118
|
+
columns.map((c) => r[c] ?? null),
|
|
119
|
+
);
|
|
120
|
+
return { columns, rows };
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(parsed[0])) {
|
|
123
|
+
let columns: string[] = [];
|
|
124
|
+
if (typeof node.attrs.columns === "string") {
|
|
125
|
+
columns = node.attrs.columns.split(/[,\s]+/).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
return { columns, rows: parsed as unknown[][] };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (parsed && typeof parsed === "object") {
|
|
131
|
+
const obj = parsed as Record<string, unknown>;
|
|
132
|
+
if (Array.isArray(obj.rows)) {
|
|
133
|
+
const columns = Array.isArray(obj.columns)
|
|
134
|
+
? (obj.columns as string[])
|
|
135
|
+
: typeof node.attrs.columns === "string"
|
|
136
|
+
? node.attrs.columns.split(/[,\s]+/).filter(Boolean)
|
|
137
|
+
: [];
|
|
138
|
+
return { columns, rows: obj.rows as unknown[][] };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolvePlotData(
|
|
145
|
+
table: DatasetTable,
|
|
146
|
+
column?: string,
|
|
147
|
+
): { values: number[]; column: string } | null {
|
|
148
|
+
if (table.rows.length === 0) return null;
|
|
149
|
+
let idx = -1;
|
|
150
|
+
let name = column ?? "";
|
|
151
|
+
if (column) {
|
|
152
|
+
idx = table.columns.indexOf(column);
|
|
153
|
+
if (idx === -1) return null;
|
|
154
|
+
} else {
|
|
155
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
156
|
+
const sample = table.rows[0]?.[i];
|
|
157
|
+
if (typeof sample === "number") {
|
|
158
|
+
idx = i;
|
|
159
|
+
name = table.columns[i] ?? `col${i}`;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (idx === -1) return null;
|
|
164
|
+
}
|
|
165
|
+
const values = table.rows
|
|
166
|
+
.map((r) => Number(r[idx]))
|
|
167
|
+
.filter((n) => Number.isFinite(n));
|
|
168
|
+
return values.length >= 2 ? { values, column: name } : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resolvePlotLabels(
|
|
172
|
+
table: DatasetTable,
|
|
173
|
+
column: string,
|
|
174
|
+
): string[] | null {
|
|
175
|
+
const idx = table.columns.indexOf(column);
|
|
176
|
+
if (idx === -1) return null;
|
|
177
|
+
return table.rows.map((r) => String(r[idx] ?? ""));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function renderHtml(doc: DocumentNode, options: HtmlRenderOptions = {}): string {
|
|
181
|
+
const ctx: RenderCtx = {
|
|
182
|
+
allowEscapeHatches: options.allowEscapeHatches !== false,
|
|
183
|
+
datasets: buildDatasetRegistry(doc),
|
|
184
|
+
};
|
|
185
|
+
const body = doc.children.map((c) => renderNode(c, ctx)).join("\n");
|
|
186
|
+
if (!options.standalone) return body;
|
|
187
|
+
|
|
188
|
+
const title =
|
|
189
|
+
options.title ||
|
|
190
|
+
(typeof doc.meta.title === "string" ? doc.meta.title : undefined) ||
|
|
191
|
+
extractFirstHeading(doc) ||
|
|
192
|
+
"Noma Document";
|
|
193
|
+
|
|
194
|
+
const themeCss = options.themeCss ?? "";
|
|
195
|
+
const stylesheetHref = options.stylesheetHref;
|
|
196
|
+
const styleHead = stylesheetHref
|
|
197
|
+
? `<link rel="stylesheet" href="${escapeAttr(stylesheetHref)}" />`
|
|
198
|
+
: `<style>${themeCss}</style>`;
|
|
199
|
+
const allowExternalAssets = options.externalAssets !== false;
|
|
200
|
+
const mathMode = allowExternalAssets ? resolveMathMode(doc, options.math) : "none";
|
|
201
|
+
const mathHead = mathMode === "katex" ? KATEX_HEAD : "";
|
|
202
|
+
const mathFoot = mathMode === "katex" ? KATEX_FOOT : "";
|
|
203
|
+
|
|
204
|
+
const diagramKinds = resolveDiagramKinds(doc);
|
|
205
|
+
const diagramFoot = allowExternalAssets ? diagramScripts(diagramKinds) : "";
|
|
206
|
+
|
|
207
|
+
return `<!doctype html>
|
|
208
|
+
<html lang="en">
|
|
209
|
+
<head>
|
|
210
|
+
<meta charset="utf-8" />
|
|
211
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
212
|
+
<meta name="generator" content="noma" />
|
|
213
|
+
<title>${escapeHtml(title)}</title>
|
|
214
|
+
${styleHead}${mathHead}
|
|
215
|
+
</head>
|
|
216
|
+
<body>
|
|
217
|
+
<main class="noma-doc">
|
|
218
|
+
${body}
|
|
219
|
+
</main>${mathFoot}${diagramFoot}
|
|
220
|
+
</body>
|
|
221
|
+
</html>`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const MERMAID_VERSION = "11.4.0";
|
|
225
|
+
const VIZ_VERSION = "3.11.0";
|
|
226
|
+
const DRAWIO_VIEWER = "https://viewer.diagrams.net/js/viewer-static.min.js";
|
|
227
|
+
const PLOTLY_VERSION = "2.35.2";
|
|
228
|
+
|
|
229
|
+
function resolveDiagramKinds(doc: DocumentNode): Set<string> {
|
|
230
|
+
const kinds = new Set<string>();
|
|
231
|
+
for (const node of walk(doc)) {
|
|
232
|
+
if (node.type !== "directive") continue;
|
|
233
|
+
if (node.name === "diagram") {
|
|
234
|
+
const k = String(node.attrs.kind ?? "mermaid").toLowerCase();
|
|
235
|
+
if (k) kinds.add(k);
|
|
236
|
+
}
|
|
237
|
+
if (node.name === "plotly") kinds.add("plotly");
|
|
238
|
+
}
|
|
239
|
+
return kinds;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function diagramScripts(kinds: Set<string>): string {
|
|
243
|
+
const out: string[] = [];
|
|
244
|
+
if (kinds.has("mermaid")) {
|
|
245
|
+
out.push(MERMAID_FOOT);
|
|
246
|
+
}
|
|
247
|
+
if (kinds.has("graphviz") || kinds.has("dot")) {
|
|
248
|
+
out.push(VIZ_FOOT);
|
|
249
|
+
}
|
|
250
|
+
if (kinds.has("drawio")) {
|
|
251
|
+
out.push(`<script src="${DRAWIO_VIEWER}"></script>`);
|
|
252
|
+
}
|
|
253
|
+
if (kinds.has("plotly")) {
|
|
254
|
+
out.push(PLOTLY_FOOT);
|
|
255
|
+
}
|
|
256
|
+
return out.join("");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const MERMAID_FOOT = `
|
|
260
|
+
<script type="module">
|
|
261
|
+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@${MERMAID_VERSION}/dist/mermaid.esm.min.mjs";
|
|
262
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "loose" });
|
|
263
|
+
const els = document.querySelectorAll(".noma-diagram-mermaid");
|
|
264
|
+
for (let i = 0; i < els.length; i++) {
|
|
265
|
+
const el = els[i];
|
|
266
|
+
const src = el.getAttribute("data-noma-source");
|
|
267
|
+
if (!src) continue;
|
|
268
|
+
try {
|
|
269
|
+
const out = await mermaid.render("noma-mermaid-" + i, src);
|
|
270
|
+
el["inn" + "erHTML"] = out.svg;
|
|
271
|
+
} catch (e) { el.textContent = String(e); }
|
|
272
|
+
}
|
|
273
|
+
</script>`;
|
|
274
|
+
|
|
275
|
+
const VIZ_FOOT = `
|
|
276
|
+
<script type="module">
|
|
277
|
+
import("https://cdn.jsdelivr.net/npm/@viz-js/viz@${VIZ_VERSION}/lib/viz-standalone.mjs").then(({ instance }) => instance().then((viz) => {
|
|
278
|
+
document.querySelectorAll(".noma-diagram-graphviz, .noma-diagram-dot").forEach((el) => {
|
|
279
|
+
const src = el.getAttribute("data-noma-source");
|
|
280
|
+
if (!src) return;
|
|
281
|
+
try { el["inn" + "erHTML"] = viz.renderString(src, { format: "svg" }); }
|
|
282
|
+
catch (e) { el.textContent = String(e); }
|
|
283
|
+
});
|
|
284
|
+
}));
|
|
285
|
+
</script>`;
|
|
286
|
+
|
|
287
|
+
const PLOTLY_FOOT = `
|
|
288
|
+
<script src="https://cdn.plot.ly/plotly-${PLOTLY_VERSION}.min.js" charset="utf-8"></script>
|
|
289
|
+
<script>
|
|
290
|
+
document.querySelectorAll(".noma-plotly").forEach((el) => {
|
|
291
|
+
const src = el.getAttribute("data-noma-source");
|
|
292
|
+
if (!src) return;
|
|
293
|
+
try {
|
|
294
|
+
const spec = JSON.parse(src);
|
|
295
|
+
Plotly.newPlot(el, spec.data || [], spec.layout || {}, Object.assign({ responsive: true }, spec.config || {}));
|
|
296
|
+
} catch (e) { el.textContent = String(e); }
|
|
297
|
+
});
|
|
298
|
+
</script>`;
|
|
299
|
+
|
|
300
|
+
const KATEX_VERSION = "0.16.11";
|
|
301
|
+
const KATEX_HEAD = `
|
|
302
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css" crossorigin="anonymous" />`;
|
|
303
|
+
const KATEX_FOOT = `
|
|
304
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.js" crossorigin="anonymous"></script>
|
|
305
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/contrib/auto-render.min.js" crossorigin="anonymous" onload="renderMathInElement(document.body, {delimiters: [{left: '$$', right: '$$', display: true}, {left: '\\\\[', right: '\\\\]', display: true}, {left: '\\\\(', right: '\\\\)', display: false}, {left: '$', right: '$', display: false}], throwOnError: false});"></script>`;
|
|
306
|
+
|
|
307
|
+
function resolveMathMode(doc: DocumentNode, override?: "katex" | "none"): "katex" | "none" {
|
|
308
|
+
if (override === "katex" || override === "none") return override;
|
|
309
|
+
if (typeof doc.meta.math === "string") {
|
|
310
|
+
return doc.meta.math === "katex" ? "katex" : "none";
|
|
311
|
+
}
|
|
312
|
+
if (doc.meta.math === true) return "katex";
|
|
313
|
+
for (const node of walk(doc)) {
|
|
314
|
+
if (node.type === "directive" && node.name === "math") return "katex";
|
|
315
|
+
const text = textForMathScan(node);
|
|
316
|
+
if (text && /\$\$[^$]+\$\$|\\\(|\\\[/.test(text)) return "katex";
|
|
317
|
+
}
|
|
318
|
+
return "none";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function textForMathScan(node: Node): string | null {
|
|
322
|
+
if (node.type === "paragraph" || node.type === "quote") return node.content;
|
|
323
|
+
if (node.type === "list_item") return node.content;
|
|
324
|
+
if (node.type === "section") return node.title;
|
|
325
|
+
if (node.type === "directive" && node.body) return node.body;
|
|
326
|
+
if (node.type === "code") return null;
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function renderNode(node: Node, ctx: RenderCtx): string {
|
|
331
|
+
switch (node.type) {
|
|
332
|
+
case "document":
|
|
333
|
+
return node.children.map((c) => renderNode(c, ctx)).join("\n");
|
|
334
|
+
case "section":
|
|
335
|
+
return renderSection(node, ctx);
|
|
336
|
+
case "paragraph":
|
|
337
|
+
return `<p>${inlineToHtml(node.content)}</p>`;
|
|
338
|
+
case "code": {
|
|
339
|
+
const langClass = node.lang ? ` class="lang-${escapeAttr(node.lang)}"` : "";
|
|
340
|
+
return `<pre><code${langClass}>${escapeHtml(node.content)}</code></pre>`;
|
|
341
|
+
}
|
|
342
|
+
case "list": {
|
|
343
|
+
const tag = node.ordered ? "ol" : "ul";
|
|
344
|
+
const items = node.items
|
|
345
|
+
.map((item) => ` <li>${inlineToHtml(item.content)}</li>`)
|
|
346
|
+
.join("\n");
|
|
347
|
+
return `<${tag}>\n${items}\n</${tag}>`;
|
|
348
|
+
}
|
|
349
|
+
case "list_item":
|
|
350
|
+
return `<li>${inlineToHtml(node.content)}</li>`;
|
|
351
|
+
case "quote":
|
|
352
|
+
return `<blockquote>${inlineToHtml(node.content)}</blockquote>`;
|
|
353
|
+
case "thematic_break":
|
|
354
|
+
return `<hr />`;
|
|
355
|
+
case "table": {
|
|
356
|
+
const head = node.header
|
|
357
|
+
.map((cell, idx) => {
|
|
358
|
+
const align = node.align[idx];
|
|
359
|
+
const styleAttr = align ? ` style="text-align: ${align}"` : "";
|
|
360
|
+
return `<th${styleAttr}>${inlineToHtml(cell)}</th>`;
|
|
361
|
+
})
|
|
362
|
+
.join("");
|
|
363
|
+
const body = node.rows
|
|
364
|
+
.map((row) => {
|
|
365
|
+
const cells = row
|
|
366
|
+
.map((cell, idx) => {
|
|
367
|
+
const align = node.align[idx];
|
|
368
|
+
const styleAttr = align ? ` style="text-align: ${align}"` : "";
|
|
369
|
+
return `<td${styleAttr}>${inlineToHtml(cell)}</td>`;
|
|
370
|
+
})
|
|
371
|
+
.join("");
|
|
372
|
+
return `<tr>${cells}</tr>`;
|
|
373
|
+
})
|
|
374
|
+
.join("\n");
|
|
375
|
+
return `<table class="noma-table">\n<thead><tr>${head}</tr></thead>\n<tbody>\n${body}\n</tbody>\n</table>`;
|
|
376
|
+
}
|
|
377
|
+
case "directive":
|
|
378
|
+
return renderDirective(node, ctx);
|
|
379
|
+
case "frontmatter":
|
|
380
|
+
return "";
|
|
381
|
+
default: {
|
|
382
|
+
const _exhaustive: never = node;
|
|
383
|
+
void _exhaustive;
|
|
384
|
+
return "";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderSection(node: SectionNode, ctx: RenderCtx): string {
|
|
390
|
+
const idAttr = node.id ? ` id="${escapeAttr(node.id)}"` : "";
|
|
391
|
+
const aliasAnchors = (node.aliases ?? [])
|
|
392
|
+
.map((a) => `<a class="noma-alias" id="${escapeAttr(a)}" aria-hidden="true"></a>`)
|
|
393
|
+
.join("");
|
|
394
|
+
const heading = `<h${node.level}>${inlineToHtml(node.title)}</h${node.level}>`;
|
|
395
|
+
const inner = node.children.map((c) => renderNode(c, ctx)).join("\n");
|
|
396
|
+
return `<section${idAttr} data-level="${node.level}">\n${aliasAnchors}${heading}\n${inner}\n</section>`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function variantAttr(node: DirectiveNode): string {
|
|
400
|
+
const v = node.attrs.variant;
|
|
401
|
+
return typeof v === "string" && v.length > 0
|
|
402
|
+
? ` data-variant="${escapeAttr(v)}"`
|
|
403
|
+
: "";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderDirective(node: DirectiveNode, ctx: RenderCtx): string {
|
|
407
|
+
const name = node.name;
|
|
408
|
+
const idAttr = node.id ? ` id="${escapeAttr(node.id)}"` : "";
|
|
409
|
+
const variant = variantAttr(node);
|
|
410
|
+
const dataAttrs = Object.entries(node.attrs)
|
|
411
|
+
.filter(([k]) => k !== "id")
|
|
412
|
+
.map(([k, v]) => ` data-${escapeAttr(k)}="${escapeAttr(String(v))}"`)
|
|
413
|
+
.join("");
|
|
414
|
+
|
|
415
|
+
switch (name) {
|
|
416
|
+
case "summary":
|
|
417
|
+
case "abstract":
|
|
418
|
+
return wrap("div", `noma-${name}`, idAttr + dataAttrs, renderChildren(node, ctx));
|
|
419
|
+
|
|
420
|
+
case "callout":
|
|
421
|
+
case "note":
|
|
422
|
+
case "warning":
|
|
423
|
+
case "tip": {
|
|
424
|
+
const tone = name === "callout" ? String(node.attrs.tone ?? "info") : name;
|
|
425
|
+
return `<aside class="noma-callout noma-callout-${escapeAttr(tone)}"${idAttr}${variant}>${renderChildren(node, ctx)}</aside>`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
case "claim":
|
|
429
|
+
case "evidence":
|
|
430
|
+
case "counterevidence":
|
|
431
|
+
case "assumption":
|
|
432
|
+
case "risk":
|
|
433
|
+
case "hypothesis":
|
|
434
|
+
case "result":
|
|
435
|
+
case "limitation":
|
|
436
|
+
case "open_question":
|
|
437
|
+
case "decision":
|
|
438
|
+
case "adr":
|
|
439
|
+
return renderResearchBlock(node, ctx);
|
|
440
|
+
|
|
441
|
+
case "export_button": {
|
|
442
|
+
const format = node.attrs.format ? String(node.attrs.format) : "text";
|
|
443
|
+
const target = node.attrs.target ? String(node.attrs.target) : "";
|
|
444
|
+
const label =
|
|
445
|
+
(node.attrs.Label && String(node.attrs.Label)) ||
|
|
446
|
+
(node.attrs.label && String(node.attrs.label)) ||
|
|
447
|
+
node.body?.trim() ||
|
|
448
|
+
`Copy as ${format}`;
|
|
449
|
+
const cleanLabel = label.replace(/^Label:\s*/, "");
|
|
450
|
+
return `<button type="button" class="noma-export-button" data-format="${escapeAttr(format)}" data-target="${escapeAttr(target)}"${idAttr}>${escapeHtml(cleanLabel)}</button>`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
case "control": {
|
|
454
|
+
const ctype = node.attrs.type ? String(node.attrs.type) : "text";
|
|
455
|
+
const min = node.attrs.min ?? "";
|
|
456
|
+
const max = node.attrs.max ?? "";
|
|
457
|
+
const def = node.attrs.default ?? "";
|
|
458
|
+
const label = node.body ?? "";
|
|
459
|
+
return `<div class="noma-control"${idAttr}><label>${escapeHtml(label)}<input type="${escapeAttr(ctype)}" min="${escapeAttr(String(min))}" max="${escapeAttr(String(max))}" value="${escapeAttr(String(def))}" /></label></div>`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case "grid": {
|
|
463
|
+
const cols = Number(node.attrs.columns ?? 2);
|
|
464
|
+
return `<div class="noma-grid"${idAttr} style="--noma-cols: ${cols};"${dataAttrs}>${renderChildren(node, ctx)}</div>`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "card": {
|
|
468
|
+
const title = node.attrs.title ? String(node.attrs.title) : undefined;
|
|
469
|
+
const icon = node.attrs.icon ? String(node.attrs.icon) : undefined;
|
|
470
|
+
const head = title
|
|
471
|
+
? `<header class="noma-card-head">${icon ? `<span class="noma-icon" aria-hidden="true">◆</span>` : ""}<h3>${escapeHtml(title)}</h3></header>`
|
|
472
|
+
: "";
|
|
473
|
+
return `<article class="noma-card"${idAttr}${variant}>${head}<div class="noma-card-body">${renderChildren(node, ctx)}</div></article>`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case "hero":
|
|
477
|
+
return `<section class="noma-hero"${idAttr}>${renderChildren(node, ctx)}</section>`;
|
|
478
|
+
|
|
479
|
+
case "button": {
|
|
480
|
+
const href = node.attrs.href ? String(node.attrs.href) : "#";
|
|
481
|
+
return `<a class="noma-button" href="${escapeAttr(href)}"${idAttr}>${renderChildren(node, ctx) || escapeHtml(node.body ?? "")}</a>`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case "figure": {
|
|
485
|
+
const caption = node.attrs.caption ? String(node.attrs.caption) : undefined;
|
|
486
|
+
const src = node.attrs.src ? String(node.attrs.src) : undefined;
|
|
487
|
+
const alt = node.attrs.alt ? String(node.attrs.alt) : "";
|
|
488
|
+
const img = src ? `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" />` : renderChildren(node, ctx);
|
|
489
|
+
return `<figure${idAttr}>${img}${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ""}</figure>`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
case "plot":
|
|
493
|
+
return renderPlotPlaceholder(node, idAttr, ctx);
|
|
494
|
+
|
|
495
|
+
case "diagram":
|
|
496
|
+
return renderDiagram(node, idAttr);
|
|
497
|
+
|
|
498
|
+
case "plotly":
|
|
499
|
+
return renderPlotly(node, idAttr);
|
|
500
|
+
|
|
501
|
+
case "dataset": {
|
|
502
|
+
const summary = `Dataset: ${escapeHtml(String(node.attrs.id ?? "dataset"))}`;
|
|
503
|
+
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
|
|
504
|
+
const inline = node.body ?? "";
|
|
505
|
+
const body =
|
|
506
|
+
inline.trim()
|
|
507
|
+
? escapeHtml(inline)
|
|
508
|
+
: src
|
|
509
|
+
? `<a class="noma-dataset-src" href="${escapeAttr(src)}">${escapeHtml(src)}</a>`
|
|
510
|
+
: "";
|
|
511
|
+
return `<details class="noma-dataset"${idAttr}${src ? ` data-src="${escapeAttr(src)}"` : ""}><summary>${summary}</summary><pre>${body}</pre></details>`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
case "agent_task":
|
|
515
|
+
case "todo":
|
|
516
|
+
return renderAgentTask(node, idAttr, ctx);
|
|
517
|
+
|
|
518
|
+
case "state_change":
|
|
519
|
+
return renderStateChange(node, idAttr, ctx);
|
|
520
|
+
|
|
521
|
+
case "table":
|
|
522
|
+
return renderTableDirective(node, idAttr);
|
|
523
|
+
|
|
524
|
+
case "math": {
|
|
525
|
+
const body = (node.body ?? "").trim();
|
|
526
|
+
const display = node.attrs.display !== "inline";
|
|
527
|
+
const wrapped = display ? `\\[${body}\\]` : `\\(${body}\\)`;
|
|
528
|
+
const cls = display ? "noma-math noma-math-display" : "noma-math noma-math-inline";
|
|
529
|
+
return `<div class="${cls}"${idAttr}>${escapeHtml(wrapped)}</div>`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case "tabs":
|
|
533
|
+
case "accordion":
|
|
534
|
+
case "sidebar":
|
|
535
|
+
case "columns":
|
|
536
|
+
return wrap("div", `noma-${name}`, idAttr + dataAttrs, renderChildren(node, ctx));
|
|
537
|
+
|
|
538
|
+
case "citation":
|
|
539
|
+
return `<cite class="noma-citation"${idAttr}>${renderChildren(node, ctx) || escapeHtml(node.body ?? "")}</cite>`;
|
|
540
|
+
|
|
541
|
+
case "html":
|
|
542
|
+
return ctx.allowEscapeHatches
|
|
543
|
+
? `<div class="noma-raw-html"${idAttr}>${node.body ?? ""}</div>`
|
|
544
|
+
: `<aside class="noma-blocked-escape" data-kind="html"${idAttr}>[raw HTML escape hatch disabled]</aside>`;
|
|
545
|
+
|
|
546
|
+
case "svg":
|
|
547
|
+
return ctx.allowEscapeHatches
|
|
548
|
+
? `<div class="noma-raw-svg"${idAttr}>${node.body ?? ""}</div>`
|
|
549
|
+
: `<aside class="noma-blocked-escape" data-kind="svg"${idAttr}>[raw SVG escape hatch disabled]</aside>`;
|
|
550
|
+
|
|
551
|
+
case "script": {
|
|
552
|
+
if (!ctx.allowEscapeHatches) {
|
|
553
|
+
return `<aside class="noma-blocked-escape" data-kind="script"${idAttr}>[script escape hatch disabled]</aside>`;
|
|
554
|
+
}
|
|
555
|
+
const runtime = String(node.attrs.runtime ?? "browser");
|
|
556
|
+
if (runtime !== "browser") {
|
|
557
|
+
return `<!-- noma:script runtime="${escapeAttr(runtime)}" omitted -->`;
|
|
558
|
+
}
|
|
559
|
+
return `<script${idAttr}>${node.body ?? ""}</script>`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
default:
|
|
563
|
+
return wrap(
|
|
564
|
+
"div",
|
|
565
|
+
`noma-block noma-block-${escapeAttr(name)}`,
|
|
566
|
+
idAttr + dataAttrs,
|
|
567
|
+
renderChildren(node, ctx),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderResearchBlock(node: DirectiveNode, ctx: RenderCtx): string {
|
|
573
|
+
const idAttr = node.id ? ` id="${escapeAttr(node.id)}"` : "";
|
|
574
|
+
const variant = variantAttr(node);
|
|
575
|
+
const confidence =
|
|
576
|
+
typeof node.attrs.confidence === "number" ? node.attrs.confidence : undefined;
|
|
577
|
+
const meta: string[] = [];
|
|
578
|
+
if (typeof node.attrs.for === "string") {
|
|
579
|
+
meta.push(`<span class="noma-meta-key">for</span> <a href="#${escapeAttr(node.attrs.for)}">${escapeHtml(node.attrs.for)}</a>`);
|
|
580
|
+
}
|
|
581
|
+
if (typeof node.attrs.source === "string") {
|
|
582
|
+
meta.push(`<span class="noma-meta-key">source</span> ${escapeHtml(node.attrs.source)}`);
|
|
583
|
+
}
|
|
584
|
+
if (typeof node.attrs.severity === "string") {
|
|
585
|
+
meta.push(`<span class="noma-meta-key">severity</span> ${escapeHtml(node.attrs.severity)}`);
|
|
586
|
+
}
|
|
587
|
+
const confidenceBar =
|
|
588
|
+
confidence !== undefined
|
|
589
|
+
? `<div class="noma-confidence" title="confidence ${confidence}"><div class="noma-confidence-bar" style="width: ${Math.round(confidence * 100)}%"></div></div>`
|
|
590
|
+
: "";
|
|
591
|
+
const metaHtml = meta.length ? `<div class="noma-meta">${meta.join(" · ")}</div>` : "";
|
|
592
|
+
return `<aside class="noma-research noma-${escapeAttr(node.name)}"${idAttr}${variant}>
|
|
593
|
+
<header class="noma-research-head"><span class="noma-tag">${escapeHtml(node.name)}</span>${confidenceBar}</header>
|
|
594
|
+
<div class="noma-research-body">${renderChildren(node, ctx)}</div>
|
|
595
|
+
${metaHtml}
|
|
596
|
+
</aside>`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function parseAlignSpec(raw: string, columns: number): (string | null)[] {
|
|
600
|
+
const codes = raw.split(/[,\s]+/).map((c) => c.trim().toLowerCase());
|
|
601
|
+
const out: (string | null)[] = [];
|
|
602
|
+
for (let i = 0; i < columns; i++) {
|
|
603
|
+
const c = codes[i] ?? "-";
|
|
604
|
+
if (c === "l" || c === "left") out.push("left");
|
|
605
|
+
else if (c === "c" || c === "center") out.push("center");
|
|
606
|
+
else if (c === "r" || c === "right") out.push("right");
|
|
607
|
+
else out.push(null);
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const splitTableLine = splitPipeRow;
|
|
613
|
+
|
|
614
|
+
function renderTableDirective(node: DirectiveNode, idAttr: string): string {
|
|
615
|
+
const body = node.body ?? "";
|
|
616
|
+
const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
617
|
+
if (lines.length === 0) return `<div class="noma-block noma-block-table"${idAttr}></div>`;
|
|
618
|
+
const rows = lines.map(splitTableLine);
|
|
619
|
+
const columns = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
620
|
+
for (const r of rows) while (r.length < columns) r.push("");
|
|
621
|
+
const wantsHeader = node.attrs.header === true || node.attrs.header === "true";
|
|
622
|
+
const headerRow = wantsHeader ? rows.shift() : undefined;
|
|
623
|
+
const align = typeof node.attrs.align === "string"
|
|
624
|
+
? parseAlignSpec(node.attrs.align, columns)
|
|
625
|
+
: new Array<string | null>(columns).fill(null);
|
|
626
|
+
|
|
627
|
+
const renderCell = (tag: "th" | "td", cell: string, idx: number): string => {
|
|
628
|
+
const a = align[idx];
|
|
629
|
+
const styleAttr = a ? ` style="text-align: ${a}"` : "";
|
|
630
|
+
return `<${tag}${styleAttr}>${inlineToHtml(cell)}</${tag}>`;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const head = headerRow
|
|
634
|
+
? `<thead><tr>${headerRow.map((c, i) => renderCell("th", c, i)).join("")}</tr></thead>\n`
|
|
635
|
+
: "";
|
|
636
|
+
const bodyRows = rows
|
|
637
|
+
.map((r) => `<tr>${r.map((c, i) => renderCell("td", c, i)).join("")}</tr>`)
|
|
638
|
+
.join("\n");
|
|
639
|
+
return `<table class="noma-table"${idAttr}>\n${head}<tbody>\n${bodyRows}\n</tbody>\n</table>`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function renderStateChange(
|
|
643
|
+
node: DirectiveNode,
|
|
644
|
+
idAttr: string,
|
|
645
|
+
ctx: RenderCtx,
|
|
646
|
+
): string {
|
|
647
|
+
const block = node.attrs.block ? String(node.attrs.block) : undefined;
|
|
648
|
+
const attribute = node.attrs.attribute ? String(node.attrs.attribute) : undefined;
|
|
649
|
+
const from = node.attrs.from !== undefined ? String(node.attrs.from) : undefined;
|
|
650
|
+
const to = node.attrs.to !== undefined ? String(node.attrs.to) : undefined;
|
|
651
|
+
const reason = node.attrs.reason ? String(node.attrs.reason) : undefined;
|
|
652
|
+
const at = node.attrs.at ? String(node.attrs.at) : undefined;
|
|
653
|
+
const target = block
|
|
654
|
+
? `<a class="noma-ref" href="#${escapeAttr(block)}">${escapeHtml(block)}</a>`
|
|
655
|
+
: "—";
|
|
656
|
+
const attrLabel = attribute ? `<code>${escapeHtml(attribute)}</code>` : "";
|
|
657
|
+
const fromTo =
|
|
658
|
+
from !== undefined && to !== undefined
|
|
659
|
+
? `<span class="noma-state-from">${escapeHtml(from)}</span> <span class="noma-state-arrow" aria-hidden="true">→</span> <span class="noma-state-to">${escapeHtml(to)}</span>`
|
|
660
|
+
: "";
|
|
661
|
+
const meta: string[] = [];
|
|
662
|
+
if (at) meta.push(`<span class="noma-meta-key">at</span> ${escapeHtml(at)}`);
|
|
663
|
+
if (reason) meta.push(`<span class="noma-meta-key">why</span> ${escapeHtml(reason)}`);
|
|
664
|
+
const metaHtml = meta.length ? `<div class="noma-meta">${meta.join(" · ")}</div>` : "";
|
|
665
|
+
const body = renderChildren(node, ctx);
|
|
666
|
+
return `<aside class="noma-state-change"${idAttr}>
|
|
667
|
+
<header class="noma-state-change-head"><span class="noma-tag">state_change</span> ${target}${attribute ? ` · ${attrLabel}` : ""}</header>
|
|
668
|
+
${fromTo ? `<div class="noma-state-change-delta">${fromTo}</div>` : ""}
|
|
669
|
+
${body}
|
|
670
|
+
${metaHtml}
|
|
671
|
+
</aside>`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function renderAgentTask(node: DirectiveNode, idAttr: string, ctx: RenderCtx): string {
|
|
675
|
+
const checked = node.attrs.done === true ? " checked" : "";
|
|
676
|
+
return `<div class="noma-agent-task"${idAttr}>
|
|
677
|
+
<label><input type="checkbox" disabled${checked} /> <span class="noma-tag">${escapeHtml(node.name)}</span></label>
|
|
678
|
+
<div class="noma-agent-body">${renderChildren(node, ctx)}</div>
|
|
679
|
+
</div>`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function renderDiagram(node: DirectiveNode, idAttr: string): string {
|
|
683
|
+
const kind = String(node.attrs.kind ?? "mermaid").toLowerCase();
|
|
684
|
+
const body = node.body ?? "";
|
|
685
|
+
const caption = typeof node.attrs.caption === "string" ? node.attrs.caption : "";
|
|
686
|
+
if (kind === "drawio") {
|
|
687
|
+
const config = JSON.stringify({
|
|
688
|
+
highlight: "#0066cc",
|
|
689
|
+
nav: true,
|
|
690
|
+
resize: true,
|
|
691
|
+
toolbar: "zoom layers tags lightbox",
|
|
692
|
+
edit: "_blank",
|
|
693
|
+
xml: body,
|
|
694
|
+
});
|
|
695
|
+
const fig = `<div class="mxgraph" data-mxgraph="${escapeAttr(config)}"></div>`;
|
|
696
|
+
return wrapDiagram("drawio", idAttr, fig, caption);
|
|
697
|
+
}
|
|
698
|
+
const cls = `noma-diagram noma-diagram-${escapeAttr(kind)}`;
|
|
699
|
+
const placeholder = `<pre class="noma-diagram-source">${escapeHtml(body)}</pre>`;
|
|
700
|
+
const figure = `<div class="${cls}" data-noma-source="${escapeAttr(body)}">${placeholder}</div>`;
|
|
701
|
+
return wrapDiagram(kind, idAttr, figure, caption);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function wrapDiagram(kind: string, idAttr: string, inner: string, caption: string): string {
|
|
705
|
+
const cap = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "";
|
|
706
|
+
return `<figure class="noma-diagram-wrap" data-kind="${escapeAttr(kind)}"${idAttr}>${inner}${cap}</figure>`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function renderPlotly(node: DirectiveNode, idAttr: string): string {
|
|
710
|
+
const body = node.body ?? "";
|
|
711
|
+
const caption = typeof node.attrs.caption === "string" ? node.attrs.caption : "";
|
|
712
|
+
const cap = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "";
|
|
713
|
+
return `<figure class="noma-plotly-wrap"${idAttr}><div class="noma-plotly" data-noma-source="${escapeAttr(body)}"></div>${cap}</figure>`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function renderPlotPlaceholder(
|
|
717
|
+
node: DirectiveNode,
|
|
718
|
+
idAttr: string,
|
|
719
|
+
ctx: RenderCtx,
|
|
720
|
+
): string {
|
|
721
|
+
const title = node.attrs.title ? String(node.attrs.title) : "Plot";
|
|
722
|
+
const dataSrc = node.attrs.data ?? node.attrs.dataset ?? "—";
|
|
723
|
+
const type = String(node.attrs.type ?? "line");
|
|
724
|
+
const w = Number(node.attrs.width ?? 320);
|
|
725
|
+
const h = Number(node.attrs.height ?? 140);
|
|
726
|
+
|
|
727
|
+
// Multi-series via `columns="a,b,c"`. Falls back to single-series via
|
|
728
|
+
// `column="a"` (legacy form) or inline body data.
|
|
729
|
+
const multi = resolveFromDatasetMulti(node, ctx);
|
|
730
|
+
let seriesList: Array<{ name: string; values: number[] }>;
|
|
731
|
+
let labels: string[];
|
|
732
|
+
|
|
733
|
+
if (multi) {
|
|
734
|
+
seriesList = multi.series;
|
|
735
|
+
labels = multi.labels;
|
|
736
|
+
} else {
|
|
737
|
+
const single = resolveFromDataset(node, ctx);
|
|
738
|
+
const values = single?.values ?? parseSeries(node);
|
|
739
|
+
seriesList = values.length >= 2
|
|
740
|
+
? [{ name: single?.column ?? String(node.attrs.column ?? ""), values }]
|
|
741
|
+
: [];
|
|
742
|
+
labels = single?.labels ?? parseLabels(node);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const totalPoints = seriesList.reduce((s, ser) => s + ser.values.length, 0);
|
|
746
|
+
const svg = seriesList.length > 0 && seriesList[0]!.values.length >= 2
|
|
747
|
+
? renderChartSvg(seriesList, type, w, h, labels)
|
|
748
|
+
: `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
749
|
+
<polyline points="0,${h - 20} ${w * 0.13},${h - 40} ${w * 0.25},${h - 30} ${w * 0.38},${h - 60} ${w * 0.5},${h - 65} ${w * 0.63},${h - 80} ${w * 0.75},${h - 85} ${w * 0.88},${h - 100} ${w},${h - 105}"
|
|
750
|
+
fill="none" stroke="currentColor" stroke-width="2" />
|
|
751
|
+
</svg>`;
|
|
752
|
+
|
|
753
|
+
const sourceLabel = totalPoints >= 2
|
|
754
|
+
? `${seriesList[0]!.values.length} points${seriesList.length > 1 ? ` × ${seriesList.length} series` : ""}`
|
|
755
|
+
: String(dataSrc);
|
|
756
|
+
return `<figure class="noma-plot"${idAttr}>
|
|
757
|
+
<div class="noma-plot-canvas" data-type="${escapeAttr(type)}" data-source="${escapeAttr(String(dataSrc))}">
|
|
758
|
+
${svg}
|
|
759
|
+
</div>
|
|
760
|
+
<figcaption>${escapeHtml(title)} <span class="noma-meta-key">type</span> ${escapeHtml(type)} · <span class="noma-meta-key">source</span> ${escapeHtml(sourceLabel)}</figcaption>
|
|
761
|
+
</figure>`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function resolveFromDataset(
|
|
765
|
+
node: DirectiveNode,
|
|
766
|
+
ctx: RenderCtx,
|
|
767
|
+
): { values: number[]; labels: string[]; column: string } | null {
|
|
768
|
+
const dsId = node.attrs.dataset;
|
|
769
|
+
if (typeof dsId !== "string") return null;
|
|
770
|
+
const table = ctx.datasets.get(dsId);
|
|
771
|
+
if (!table) return null;
|
|
772
|
+
const column = typeof node.attrs.column === "string" ? node.attrs.column : undefined;
|
|
773
|
+
const resolved = resolvePlotData(table, column);
|
|
774
|
+
if (!resolved) return null;
|
|
775
|
+
const xColumn = typeof node.attrs.xcolumn === "string" ? node.attrs.xcolumn : undefined;
|
|
776
|
+
const labels = xColumn ? (resolvePlotLabels(table, xColumn) ?? []) : parseLabels(node);
|
|
777
|
+
return { values: resolved.values, labels, column: resolved.column };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function resolveFromDatasetMulti(
|
|
781
|
+
node: DirectiveNode,
|
|
782
|
+
ctx: RenderCtx,
|
|
783
|
+
): { series: Array<{ name: string; values: number[] }>; labels: string[] } | null {
|
|
784
|
+
const dsId = node.attrs.dataset;
|
|
785
|
+
if (typeof dsId !== "string") return null;
|
|
786
|
+
const table = ctx.datasets.get(dsId);
|
|
787
|
+
if (!table) return null;
|
|
788
|
+
const colsAttr = node.attrs.columns;
|
|
789
|
+
if (typeof colsAttr !== "string") return null;
|
|
790
|
+
const colNames = colsAttr.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
791
|
+
if (colNames.length === 0) return null;
|
|
792
|
+
const series: Array<{ name: string; values: number[] }> = [];
|
|
793
|
+
for (const name of colNames) {
|
|
794
|
+
const resolved = resolvePlotData(table, name);
|
|
795
|
+
if (!resolved) continue;
|
|
796
|
+
series.push({ name: resolved.column, values: resolved.values });
|
|
797
|
+
}
|
|
798
|
+
if (series.length === 0) return null;
|
|
799
|
+
const xColumn = typeof node.attrs.xcolumn === "string" ? node.attrs.xcolumn : undefined;
|
|
800
|
+
const labels = xColumn ? (resolvePlotLabels(table, xColumn) ?? []) : parseLabels(node);
|
|
801
|
+
return { series, labels };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseSeries(node: DirectiveNode): number[] {
|
|
805
|
+
const tryParse = (raw: string): number[] => {
|
|
806
|
+
const parts = raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
807
|
+
const nums = parts.map(Number);
|
|
808
|
+
if (nums.length >= 2 && nums.every((n) => Number.isFinite(n))) return nums;
|
|
809
|
+
return [];
|
|
810
|
+
};
|
|
811
|
+
const data = node.attrs.data;
|
|
812
|
+
if (typeof data === "string" && !data.includes("/") && !data.endsWith(".csv")) {
|
|
813
|
+
const fromAttr = tryParse(data);
|
|
814
|
+
if (fromAttr.length) return fromAttr;
|
|
815
|
+
}
|
|
816
|
+
if (typeof data === "number") return [];
|
|
817
|
+
if (node.body) {
|
|
818
|
+
const lines = node.body
|
|
819
|
+
.split("\n")
|
|
820
|
+
.map((l) => l.trim())
|
|
821
|
+
.filter((l) => l && !l.startsWith("#") && !/^[a-zA-Z_]+\s*:/.test(l));
|
|
822
|
+
const inline = tryParse(lines.join(" "));
|
|
823
|
+
if (inline.length) return inline;
|
|
824
|
+
}
|
|
825
|
+
return [];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function parseLabels(node: DirectiveNode): string[] {
|
|
829
|
+
const raw = node.attrs.xlabels;
|
|
830
|
+
if (typeof raw !== "string") return [];
|
|
831
|
+
return raw.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Categorical palette tuned for distinguishability on a near-white background.
|
|
835
|
+
// First color is the existing "currentColor" deep blue used by single-series
|
|
836
|
+
// charts (kept identical so single-series renders are byte-stable across the
|
|
837
|
+
// multi-series refactor).
|
|
838
|
+
const PLOT_COLORS = [
|
|
839
|
+
"currentColor",
|
|
840
|
+
"#cf6037",
|
|
841
|
+
"#2e8b57",
|
|
842
|
+
"#8b6c1a",
|
|
843
|
+
"#5a6071",
|
|
844
|
+
"#a8362e",
|
|
845
|
+
];
|
|
846
|
+
|
|
847
|
+
function renderChartSvg(
|
|
848
|
+
seriesList: Array<{ name: string; values: number[] }>,
|
|
849
|
+
type: string,
|
|
850
|
+
w: number,
|
|
851
|
+
h: number,
|
|
852
|
+
labels: string[],
|
|
853
|
+
): string {
|
|
854
|
+
// Bar plots reserve half a slot of margin so end bars don't run past the
|
|
855
|
+
// data-area edge. Line/area plots anchor to the edges.
|
|
856
|
+
const isBar = type === "bar";
|
|
857
|
+
const nSeries = seriesList.length;
|
|
858
|
+
const N = seriesList[0]?.values.length ?? 0;
|
|
859
|
+
const showLegend = nSeries > 1;
|
|
860
|
+
|
|
861
|
+
// Decide bar-label rotation up-front: rotated labels need a taller bottom
|
|
862
|
+
// gutter. We rotate when the longest label is wider than the per-bar slot.
|
|
863
|
+
const FONT_PX = 9;
|
|
864
|
+
const CHAR_W = 5.5; // approx avg width of a 9pt sans char
|
|
865
|
+
const innerWProbe = w - 28 - (isBar ? 12 : 6);
|
|
866
|
+
const slotW = labels.length ? innerWProbe / Math.max(1, N) : 0;
|
|
867
|
+
const longest = labels.reduce(
|
|
868
|
+
(m, l) => Math.max(m, (l ?? "").length * CHAR_W),
|
|
869
|
+
0,
|
|
870
|
+
);
|
|
871
|
+
const rotateBarLabels = isBar && labels.length > 1 && longest > slotW * 0.95;
|
|
872
|
+
|
|
873
|
+
const padL = 28;
|
|
874
|
+
const padR = isBar ? 12 : 6;
|
|
875
|
+
const padT = showLegend ? 22 : 8;
|
|
876
|
+
const padB = labels.length
|
|
877
|
+
? rotateBarLabels
|
|
878
|
+
? Math.min(70, Math.ceil(longest * Math.sin(0.45)) + 16)
|
|
879
|
+
: 22
|
|
880
|
+
: 8;
|
|
881
|
+
const innerW = w - padL - padR;
|
|
882
|
+
const innerH = h - padT - padB;
|
|
883
|
+
const allValues = seriesList.flatMap((s) => s.values);
|
|
884
|
+
const min = Math.min(...allValues);
|
|
885
|
+
const max = Math.max(...allValues);
|
|
886
|
+
const span = max - min || 1;
|
|
887
|
+
const x = (i: number) => {
|
|
888
|
+
if (N === 1) return padL + innerW / 2;
|
|
889
|
+
if (isBar) return padL + ((i + 0.5) / N) * innerW;
|
|
890
|
+
return padL + (i / (N - 1)) * innerW;
|
|
891
|
+
};
|
|
892
|
+
const y = (v: number) => padT + innerH - ((v - min) / span) * innerH;
|
|
893
|
+
|
|
894
|
+
const gridY = [0, 0.25, 0.5, 0.75, 1]
|
|
895
|
+
.map(
|
|
896
|
+
(t) =>
|
|
897
|
+
`<line x1="${padL}" x2="${w - padR}" y1="${padT + t * innerH}" y2="${padT + t * innerH}" stroke="currentColor" stroke-opacity="0.12" />`,
|
|
898
|
+
)
|
|
899
|
+
.join("");
|
|
900
|
+
|
|
901
|
+
let plot = "";
|
|
902
|
+
if (type === "bar") {
|
|
903
|
+
// Cluster bars within each x-slot when multi-series.
|
|
904
|
+
const slotInner = (innerW / N) * 0.85;
|
|
905
|
+
const barW = slotInner / nSeries;
|
|
906
|
+
plot = seriesList
|
|
907
|
+
.map((ser, sIdx) =>
|
|
908
|
+
ser.values
|
|
909
|
+
.map((v, i) => {
|
|
910
|
+
const slotCenter = x(i);
|
|
911
|
+
const cx = slotCenter - slotInner / 2 + sIdx * barW + barW / 2;
|
|
912
|
+
const top = y(v);
|
|
913
|
+
return `<rect x="${(cx - barW / 2).toFixed(1)}" y="${top.toFixed(1)}" width="${barW.toFixed(1)}" height="${(padT + innerH - top).toFixed(1)}" fill="${PLOT_COLORS[sIdx % PLOT_COLORS.length]}" opacity="0.85" />`;
|
|
914
|
+
})
|
|
915
|
+
.join(""),
|
|
916
|
+
)
|
|
917
|
+
.join("");
|
|
918
|
+
} else {
|
|
919
|
+
// Line/area: one polyline per series. Area-fill only on the first series
|
|
920
|
+
// so multi-series doesn't get visually muddled.
|
|
921
|
+
const showMarkers = N <= 30;
|
|
922
|
+
plot = seriesList
|
|
923
|
+
.map((ser, sIdx) => {
|
|
924
|
+
const color = PLOT_COLORS[sIdx % PLOT_COLORS.length]!;
|
|
925
|
+
const points = ser.values
|
|
926
|
+
.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
|
927
|
+
.join(" ");
|
|
928
|
+
const areaFill = sIdx === 0 && nSeries === 1
|
|
929
|
+
? `<path d="M ${x(0).toFixed(1)},${(padT + innerH).toFixed(1)} L ${points
|
|
930
|
+
.split(" ")
|
|
931
|
+
.join(" L ")} L ${x(N - 1).toFixed(1)},${(padT + innerH).toFixed(1)} Z" fill="${color}" opacity="0.12" />`
|
|
932
|
+
: "";
|
|
933
|
+
const line = `<polyline points="${points}" fill="none" stroke="${color}" stroke-width="2" />`;
|
|
934
|
+
const markers = showMarkers
|
|
935
|
+
? ser.values
|
|
936
|
+
.map(
|
|
937
|
+
(v, i) =>
|
|
938
|
+
`<circle cx="${x(i).toFixed(1)}" cy="${y(v).toFixed(1)}" r="2.5" fill="${color}" />`,
|
|
939
|
+
)
|
|
940
|
+
.join("")
|
|
941
|
+
: "";
|
|
942
|
+
return areaFill + line + markers;
|
|
943
|
+
})
|
|
944
|
+
.join("");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Y-axis labels: 5 ticks matching the gridY rule count.
|
|
948
|
+
const yTickVals = [0, 0.25, 0.5, 0.75, 1].map((t) => max - t * span);
|
|
949
|
+
const yLabels = yTickVals
|
|
950
|
+
.map(
|
|
951
|
+
(v, idx) =>
|
|
952
|
+
`<text x="${padL - 4}" y="${(padT + idx * innerH * 0.25 + 3).toFixed(1)}" text-anchor="end" font-size="${FONT_PX}" fill="currentColor" opacity="0.7">${escapeHtml(formatNum(v))}</text>`,
|
|
953
|
+
)
|
|
954
|
+
.join("");
|
|
955
|
+
|
|
956
|
+
// X-axis labels.
|
|
957
|
+
// - bar: one label per bar, optionally rotated -35° to fit.
|
|
958
|
+
// - line: at most 6 evenly-spaced ticks sampled from the labels[] array
|
|
959
|
+
// (typical case: dates from xcolumn). Suppress when labels are absent.
|
|
960
|
+
let xLabels = "";
|
|
961
|
+
if (labels.length) {
|
|
962
|
+
if (isBar) {
|
|
963
|
+
xLabels = Array.from({ length: N })
|
|
964
|
+
.map((_, i) => {
|
|
965
|
+
const lbl = labels[i] ?? "";
|
|
966
|
+
if (!lbl) return "";
|
|
967
|
+
const cx = x(i);
|
|
968
|
+
const yPos = padT + innerH + 12;
|
|
969
|
+
if (rotateBarLabels) {
|
|
970
|
+
return `<text transform="translate(${cx.toFixed(1)} ${yPos}) rotate(-35)" text-anchor="end" font-size="${FONT_PX}" fill="currentColor" opacity="0.7">${escapeHtml(lbl)}</text>`;
|
|
971
|
+
}
|
|
972
|
+
return `<text x="${cx.toFixed(1)}" y="${yPos}" text-anchor="middle" font-size="${FONT_PX}" fill="currentColor" opacity="0.7">${escapeHtml(lbl)}</text>`;
|
|
973
|
+
})
|
|
974
|
+
.join("");
|
|
975
|
+
} else {
|
|
976
|
+
const T = Math.min(6, N);
|
|
977
|
+
const idxs = Array.from({ length: T }, (_, k) =>
|
|
978
|
+
Math.round((k * (N - 1)) / Math.max(1, T - 1)),
|
|
979
|
+
);
|
|
980
|
+
xLabels = idxs
|
|
981
|
+
.map((i, k) => {
|
|
982
|
+
const lbl = labels[i] ?? "";
|
|
983
|
+
if (!lbl) return "";
|
|
984
|
+
const cx = x(i);
|
|
985
|
+
const anchor = k === 0 ? "start" : k === T - 1 ? "end" : "middle";
|
|
986
|
+
return `<text x="${cx.toFixed(1)}" y="${(padT + innerH + 12).toFixed(1)}" text-anchor="${anchor}" font-size="${FONT_PX}" fill="currentColor" opacity="0.7">${escapeHtml(lbl)}</text>`;
|
|
987
|
+
})
|
|
988
|
+
.join("");
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Legend: one swatch + label per series, laid out left-to-right at top.
|
|
993
|
+
let legend = "";
|
|
994
|
+
if (showLegend) {
|
|
995
|
+
let cursor = padL;
|
|
996
|
+
legend = seriesList
|
|
997
|
+
.map((ser, sIdx) => {
|
|
998
|
+
const color = PLOT_COLORS[sIdx % PLOT_COLORS.length]!;
|
|
999
|
+
const swatchX = cursor;
|
|
1000
|
+
const textX = cursor + 14;
|
|
1001
|
+
const labelW = ser.name.length * CHAR_W + 22;
|
|
1002
|
+
cursor += labelW;
|
|
1003
|
+
return (
|
|
1004
|
+
`<rect x="${swatchX}" y="6" width="10" height="10" fill="${color}" opacity="0.85" />` +
|
|
1005
|
+
`<text x="${textX}" y="14" font-size="${FONT_PX}" fill="currentColor" opacity="0.85">${escapeHtml(ser.name)}</text>`
|
|
1006
|
+
);
|
|
1007
|
+
})
|
|
1008
|
+
.join("");
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" role="img">
|
|
1012
|
+
${gridY}
|
|
1013
|
+
${plot}
|
|
1014
|
+
${yLabels}
|
|
1015
|
+
${xLabels}
|
|
1016
|
+
${legend}
|
|
1017
|
+
</svg>`;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function formatNum(n: number): string {
|
|
1021
|
+
const a = Math.abs(n);
|
|
1022
|
+
// Compact notation keeps axis labels narrow enough not to overflow the
|
|
1023
|
+
// 24-px left gutter of the chart SVG. Without this, six-digit NAV values
|
|
1024
|
+
// (e.g. 245406) clip past the SVG's left edge.
|
|
1025
|
+
if (a >= 1_000_000) return (n / 1_000_000).toFixed(a >= 10_000_000 ? 0 : 1) + "M";
|
|
1026
|
+
if (a >= 1_000) return (n / 1_000).toFixed(a >= 10_000 ? 0 : 1) + "k";
|
|
1027
|
+
if (a >= 10) return n.toFixed(1);
|
|
1028
|
+
return n.toFixed(2);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function renderChildren(node: DirectiveNode, ctx: RenderCtx): string {
|
|
1032
|
+
if (node.children.length === 0 && node.body !== undefined) {
|
|
1033
|
+
return `<p>${inlineToHtml(node.body)}</p>`;
|
|
1034
|
+
}
|
|
1035
|
+
return node.children.map((c) => renderNode(c, ctx)).join("\n");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function wrap(tag: string, className: string, idAndAttrs: string, inner: string): string {
|
|
1039
|
+
return `<${tag} class="${className}"${idAndAttrs}>${inner}</${tag}>`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function extractFirstHeading(doc: DocumentNode): string | undefined {
|
|
1043
|
+
for (const n of doc.children) {
|
|
1044
|
+
if (n.type === "section") return n.title;
|
|
1045
|
+
}
|
|
1046
|
+
return undefined;
|
|
1047
|
+
}
|