plotleaf 0.0.0-stage → 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/dist/cli.js ADDED
@@ -0,0 +1,1431 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import path5 from "node:path";
5
+ import { readFile } from "node:fs/promises";
6
+ import { parseArgs } from "node:util";
7
+
8
+ // src/compiler/compile.ts
9
+ import path3 from "node:path";
10
+ import { realpath as realpath2 } from "node:fs/promises";
11
+
12
+ // src/compiler/diagnostics.ts
13
+ import path from "node:path";
14
+
15
+ class Diagnostics {
16
+ file;
17
+ items = [];
18
+ errors = false;
19
+ constructor(file) {
20
+ this.file = file;
21
+ }
22
+ add(code, message, position, hint, severity = "error") {
23
+ if (severity === "error")
24
+ this.errors = true;
25
+ if (this.items.length >= 100) {
26
+ this.errors = true;
27
+ this.items[99] = {
28
+ code: "E_DIAGNOSTIC_LIMIT",
29
+ severity: "error",
30
+ file: this.file,
31
+ line: position?.start.line ?? 1,
32
+ column: position?.start.column ?? 1,
33
+ message: "Report exceeds the limit of 100 diagnostics. Fix the reported issues first."
34
+ };
35
+ return;
36
+ }
37
+ this.items.push({
38
+ code,
39
+ severity,
40
+ file: this.file,
41
+ line: position?.start.line ?? 1,
42
+ column: position?.start.column ?? 1,
43
+ message: message.length > 2000 ? message.slice(0, 1999) + "…" : message,
44
+ ...hint ? { hint: hint.length > 1500 ? hint.slice(0, 1499) + "…" : hint } : {}
45
+ });
46
+ }
47
+ get hasErrors() {
48
+ return this.errors;
49
+ }
50
+ sorted() {
51
+ return [...this.items].sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line || a.column - b.column || a.code.localeCompare(b.code, "en"));
52
+ }
53
+ }
54
+ function displayPath(filename) {
55
+ return path.relative(process.cwd(), filename) || path.basename(filename);
56
+ }
57
+ function humanDiagnostics(items) {
58
+ return items.map((d) => `${d.file}:${d.line}:${d.column} ${d.code}${d.severity === "warning" ? " (warning)" : ""}
59
+ ${d.message}${d.hint ? `
60
+ ${d.hint}` : ""}`).join(`
61
+
62
+ `);
63
+ }
64
+
65
+ // src/compiler/data.ts
66
+ import path2 from "node:path";
67
+ import { open, realpath, stat } from "node:fs/promises";
68
+
69
+ // src/shared/limits.ts
70
+ var limits = {
71
+ markdownBytes: 1024 ** 2,
72
+ jsonBytes: 10 * 1024 ** 2,
73
+ totalBytes: 25 * 1024 ** 2,
74
+ datasets: 64,
75
+ rows: 1e5,
76
+ chartRows: 1e4,
77
+ tableRows: 1000,
78
+ tableColumns: 40
79
+ };
80
+
81
+ // src/compiler/data.ts
82
+ var reserved = new Set(["__proto__", "constructor", "prototype"]);
83
+ function contained(root, filename) {
84
+ const relative = path2.relative(root, filename);
85
+ return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
86
+ }
87
+ async function readBounded(filename, maximum) {
88
+ if (!(await stat(filename)).isFile())
89
+ throw new Error("Expected a regular file.");
90
+ const file = await open(filename, "r");
91
+ try {
92
+ const stat = await file.stat();
93
+ if (!stat.isFile())
94
+ throw new Error("Expected a regular file.");
95
+ if (stat.size > maximum)
96
+ throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
97
+ const chunks = [];
98
+ let bytes = 0;
99
+ while (bytes <= maximum) {
100
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
101
+ const result = await file.read(buffer, 0, buffer.length, null);
102
+ if (!result.bytesRead)
103
+ break;
104
+ chunks.push(buffer.subarray(0, result.bytesRead));
105
+ bytes += result.bytesRead;
106
+ }
107
+ if (bytes > maximum)
108
+ throw new Error("File grew beyond the size limit while reading.");
109
+ return {
110
+ text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, bytes)),
111
+ bytes
112
+ };
113
+ } finally {
114
+ await file.close();
115
+ }
116
+ }
117
+ function parseDate(value) {
118
+ const date = value.slice(0, 10);
119
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
120
+ return;
121
+ const day = Date.parse(`${date}T00:00:00.000Z`);
122
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== date)
123
+ return;
124
+ if (value.length === 10)
125
+ return day;
126
+ if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value))
127
+ return;
128
+ const timestamp = Date.parse(value);
129
+ return Number.isFinite(timestamp) ? timestamp : undefined;
130
+ }
131
+
132
+ class DataResolver {
133
+ root;
134
+ diagnostics;
135
+ dependencies;
136
+ totalBytes;
137
+ cache = new Map;
138
+ constructor(root, diagnostics, dependencies, totalBytes = 0) {
139
+ this.root = root;
140
+ this.diagnostics = diagnostics;
141
+ this.dependencies = dependencies;
142
+ this.totalBytes = totalBytes;
143
+ }
144
+ async load(reference, block) {
145
+ const fail = (code, message, hint) => {
146
+ this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
147
+ return;
148
+ };
149
+ if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
150
+ return fail("E_DATA_PATH", "Use a plain relative local .json path.");
151
+ const candidate = path2.resolve(this.root, reference);
152
+ if (!contained(this.root, candidate))
153
+ return fail("E_DATA_PATH", "Reference escapes the report directory.", "Place prepared JSON beside the report or in one of its subdirectories.");
154
+ if (path2.relative(this.root, candidate).split(path2.sep).some((segment) => segment.startsWith(".")))
155
+ return fail("E_DATA_PATH", "Hidden files and directories are not allowed.");
156
+ if (path2.extname(candidate).toLowerCase() !== ".json")
157
+ return fail("E_DATA_PATH", "Datasets must be .json files.");
158
+ this.dependencies.add(candidate);
159
+ let canonical;
160
+ try {
161
+ canonical = await realpath(candidate);
162
+ } catch {
163
+ return fail("E_DATA_READ", "Cannot read the referenced dataset.", "Create the file at this path or correct the data property.");
164
+ }
165
+ if (!contained(this.root, canonical) || path2.relative(this.root, canonical).split(path2.sep).some((segment) => segment.startsWith(".")))
166
+ return fail("E_DATA_PATH", "The resolved file or symlink escapes the permitted report files.");
167
+ if (path2.extname(canonical).toLowerCase() !== ".json")
168
+ return fail("E_DATA_PATH", "The resolved file must also have a .json extension.");
169
+ this.dependencies.add(canonical);
170
+ const cached = this.cache.get(canonical);
171
+ if (cached)
172
+ return cached;
173
+ if (this.cache.size >= limits.datasets)
174
+ return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
175
+ let input;
176
+ try {
177
+ input = await readBounded(canonical, Math.min(limits.jsonBytes, limits.totalBytes - this.totalBytes));
178
+ } catch (error) {
179
+ return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
180
+ }
181
+ this.totalBytes += input.bytes;
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(input.text);
185
+ } catch {
186
+ return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of flat records, without comments or trailing commas.");
187
+ }
188
+ if (!Array.isArray(parsed))
189
+ return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
190
+ if (parsed.length > limits.rows)
191
+ return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller aggregate dataset before rendering.");
192
+ for (const [index, row] of parsed.entries()) {
193
+ if (!row || typeof row !== "object" || Array.isArray(row))
194
+ return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
195
+ for (const [key, value] of Object.entries(row)) {
196
+ if (reserved.has(key))
197
+ return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
198
+ if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
199
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain a finite number, string, boolean or null.`, "Prepare flat scalar records. Nested cells and sparkline data are planned separately.");
200
+ }
201
+ }
202
+ const rows = parsed;
203
+ this.cache.set(canonical, rows);
204
+ return rows;
205
+ }
206
+ async resolve(blocks) {
207
+ for (const block of blocks) {
208
+ if (block.type !== "component")
209
+ continue;
210
+ if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "table") {
211
+ const rows = await this.load(block.props.data, block);
212
+ if (rows) {
213
+ if (!rows.length)
214
+ this.diagnostics.add("W_DATA_EMPTY", `${block.props.data} is empty; a no-data state will be shown.`, block.position, "Field validation requires at least one record.", "warning");
215
+ if (block.name === "table")
216
+ this.resolveTable(block, rows);
217
+ else
218
+ this.resolveChart(block, rows);
219
+ }
220
+ }
221
+ await this.resolve(block.children);
222
+ }
223
+ }
224
+ field(rows, name, block) {
225
+ const index = rows.findIndex((row) => !Object.hasOwn(row, name));
226
+ if (index < 0)
227
+ return true;
228
+ const available = Object.keys(rows[index] ?? {});
229
+ this.diagnostics.add("E_DATA_FIELD", `${block.name} references missing field ${JSON.stringify(name)} in ${block.props && "data" in block.props ? block.props.data : "dataset"}, row ${index + 1}.`, block.position, `Available fields: ${available.slice(0, 12).join(", ") || "none"}${available.length > 12 ? ` … (${available.length - 12} more)` : ""}.`);
230
+ return false;
231
+ }
232
+ resolveChart(block, rows) {
233
+ const { x, y } = block.props;
234
+ if (rows.length > limits.chartRows) {
235
+ this.diagnostics.add("E_DATA_LIMIT", `Chart exceeds ${limits.chartRows} points/categories.`, block.position, "Prepare a smaller chart aggregate.");
236
+ return;
237
+ }
238
+ if (!this.field(rows, x, block) || !this.field(rows, y, block))
239
+ return;
240
+ const kind = block.name === "bar" ? "category" : block.props.xType;
241
+ const seen = new Set;
242
+ let previous = -Infinity;
243
+ for (const [index, row] of rows.entries()) {
244
+ const xv = row[x];
245
+ const yv = row[y];
246
+ const validY = typeof yv === "number" && Number.isFinite(yv) || block.name !== "bar" && yv === null;
247
+ let normalized;
248
+ if (kind === "category" && (typeof xv === "string" || typeof xv === "number"))
249
+ normalized = String(xv);
250
+ if (kind === "number" && typeof xv === "number")
251
+ normalized = xv;
252
+ if (kind === "date" && typeof xv === "string")
253
+ normalized = parseDate(xv);
254
+ if (normalized === undefined || !validY) {
255
+ this.diagnostics.add("E_DATA_TYPE", `${block.props.data}, row ${index + 1}: ${JSON.stringify(x)} must be ${kind === "date" ? "an ISO date or zoned timestamp" : `a ${kind === "number" ? "number" : "string or number category"}`} and ${JSON.stringify(y)} must be ${block.name === "bar" ? "a number" : "a number or null"}.`, block.position);
256
+ return;
257
+ }
258
+ if (seen.has(normalized) || typeof normalized === "number" && kind !== "category" && normalized <= previous) {
259
+ this.diagnostics.add("E_DATA_ORDER", `${block.props.data}, row ${index + 1}: x values must be unique${kind !== "category" ? " and increasing" : ""}.`, block.position, "Sort or aggregate the prepared dataset; plotleaf does not silently change chart data.");
260
+ return;
261
+ }
262
+ seen.add(normalized);
263
+ if (typeof normalized === "number")
264
+ previous = normalized;
265
+ }
266
+ block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
267
+ }
268
+ resolveTable(block, rows) {
269
+ if (rows.length > limits.tableRows) {
270
+ this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
271
+ return;
272
+ }
273
+ const inferred = Object.keys(rows[0] ?? {});
274
+ const explicit = block.children.filter((child) => child.type === "component" && child.name === "column");
275
+ const columns = explicit.length ? explicit.map((column) => column.props) : inferred.map((field) => ({
276
+ field,
277
+ sign: "auto",
278
+ semantics: "neutral"
279
+ }));
280
+ if (columns.length > limits.tableColumns) {
281
+ this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
282
+ return;
283
+ }
284
+ if (new Set(columns.map((column) => column.field)).size !== columns.length)
285
+ this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
286
+ for (const [index, row] of rows.entries())
287
+ if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
288
+ this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
289
+ return;
290
+ }
291
+ for (const column of columns) {
292
+ if (!this.field(rows, column.field, block))
293
+ continue;
294
+ const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
295
+ if (types.size > 1)
296
+ this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, block.position);
297
+ if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
298
+ this.diagnostics.add("E_TABLE_FORMAT", `Numeric formatting/sign/semantics cannot be applied to ${JSON.stringify(column.field)}.`, block.position);
299
+ }
300
+ block.columns = columns;
301
+ block.rows = rows.map((row) => Object.fromEntries(columns.map((column) => [column.field, row[column.field]])));
302
+ }
303
+ }
304
+
305
+ // src/compiler/parse.ts
306
+ import { unified } from "unified";
307
+ import remarkParse from "remark-parse";
308
+ import remarkDirective from "remark-directive";
309
+ import remarkFrontmatter from "remark-frontmatter";
310
+ import { parseDocument, visit } from "yaml";
311
+ import * as z2 from "zod";
312
+
313
+ // src/compiler/registry.ts
314
+ import * as z from "zod";
315
+ var text = z.string().min(1).max(1000);
316
+ var format = z.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
317
+ var chart = {
318
+ data: text,
319
+ x: text,
320
+ y: text,
321
+ title: text,
322
+ format: format.default("number")
323
+ };
324
+ var line = z.strictObject({
325
+ ...chart,
326
+ xType: z.enum(["category", "date", "number"]).default("category"),
327
+ target: z.number().finite().optional(),
328
+ targetLabel: text.optional()
329
+ });
330
+ var registry = {
331
+ grid: {
332
+ forms: ["container"],
333
+ children: ["metric", "line", "area", "bar", "table", "callout"],
334
+ description: "A responsive row of one to four components.",
335
+ schema: z.strictObject({
336
+ columns: z.number().int().min(1).max(4).default(2)
337
+ }),
338
+ example: `:::grid{columns="4"}
339
+ ::metric{label="Teams" value="1492"}
340
+ :::`
341
+ },
342
+ metric: {
343
+ forms: ["leaf"],
344
+ children: [],
345
+ description: "A prepared numeric value with optional comparison, direction and sentiment.",
346
+ schema: z.strictObject({
347
+ label: text,
348
+ value: z.number().finite(),
349
+ format: format.default("number"),
350
+ comparison: text.optional(),
351
+ direction: z.enum(["up", "down", "flat"]).optional(),
352
+ sentiment: z.enum(["positive", "negative", "neutral"]).default("neutral")
353
+ }),
354
+ example: '::metric{label="Conversion" value="0.103" format="percent"}'
355
+ },
356
+ line: {
357
+ forms: ["leaf"],
358
+ children: [],
359
+ description: "An interactive single-series line chart from local JSON.",
360
+ schema: line,
361
+ example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
362
+ },
363
+ area: {
364
+ forms: ["leaf"],
365
+ children: [],
366
+ description: "A line chart with a restrained area fill.",
367
+ schema: line,
368
+ example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
369
+ },
370
+ bar: {
371
+ forms: ["leaf"],
372
+ children: [],
373
+ description: "An interactive category bar chart with a zero baseline.",
374
+ schema: z.strictObject(chart),
375
+ example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
376
+ },
377
+ table: {
378
+ forms: ["leaf", "container"],
379
+ children: ["column"],
380
+ description: "A semantic sortable table, with inferred or explicitly formatted columns.",
381
+ schema: z.strictObject({ data: text, title: text.optional() }),
382
+ example: `:::table{data="./plans.json" title="By plan"}
383
+ ::column{field="plan" label="Plan"}
384
+ ::column{field="change" label="Δ QoQ" format="percent" sign="always"}
385
+ :::`
386
+ },
387
+ column: {
388
+ forms: ["leaf"],
389
+ children: [],
390
+ description: "An explicit field, label and format inside a table.",
391
+ schema: z.strictObject({
392
+ field: text,
393
+ label: text.optional(),
394
+ format: format.optional(),
395
+ sign: z.enum(["auto", "always"]).default("auto"),
396
+ semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
397
+ }),
398
+ example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}'
399
+ },
400
+ callout: {
401
+ forms: ["container"],
402
+ children: ["markdown"],
403
+ description: "A contextual note or warning containing ordinary Markdown.",
404
+ schema: z.strictObject({
405
+ tone: z.enum(["note", "warning"]).default("note"),
406
+ title: text.optional()
407
+ }),
408
+ example: `:::callout{title="Coverage"}
409
+ Seven complete days of prepared data.
410
+ :::`
411
+ }
412
+ };
413
+ function componentSpec() {
414
+ return {
415
+ version: 1,
416
+ syntax: "markdown-directives",
417
+ attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
418
+ limits,
419
+ data: {
420
+ shape: "array of flat JSON records",
421
+ resolution: "relative to the report directory; local .json files only",
422
+ percent: "ratios: 0.12 displays as 12%",
423
+ percentagePoints: "point values: 0.8 displays as 0.8 pt",
424
+ currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
425
+ locale: "en-GB",
426
+ timeZone: "UTC"
427
+ },
428
+ components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
429
+ name,
430
+ {
431
+ description: entry.description,
432
+ forms: entry.forms,
433
+ children: entry.children,
434
+ props: z.toJSONSchema(entry.schema),
435
+ example: entry.example
436
+ }
437
+ ])),
438
+ rules: [
439
+ "Use explicit closing fences for containers.",
440
+ "Components are standalone blocks, not inline/list/blockquote content.",
441
+ "column is only allowed inside table; grids cannot nest.",
442
+ "direction and explicitly supplied sentiment require metric comparison text.",
443
+ "targetLabel requires target.",
444
+ "Dataset fields are literal own-property keys, not expressions.",
445
+ "Charts preserve source order; date/number x values must increase.",
446
+ "Line/area null y values create gaps; bar null y values are invalid.",
447
+ "Filters, maps, images, nested data and executable components are not supported in this release."
448
+ ]
449
+ };
450
+ }
451
+ var specs = componentSpec().components;
452
+ var numericLiteral = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
453
+ function decodeProps(name, attributes) {
454
+ const decoded = Object.create(null);
455
+ const properties = specs[name].props.properties ?? {};
456
+ for (const [key, value] of Object.entries(attributes)) {
457
+ const property = properties[key];
458
+ if (property?.type === "number" || property?.type === "integer")
459
+ decoded[key] = typeof value === "string" && numericLiteral.test(value) ? Number(value) : value;
460
+ else
461
+ decoded[key] = value;
462
+ }
463
+ const result = registry[name].schema.safeParse(decoded);
464
+ if (!result.success)
465
+ return {
466
+ errors: result.error.issues.map((issue) => `${issue.path.join(".") || name}: ${issue.message}`)
467
+ };
468
+ const props = result.data;
469
+ const errors = [];
470
+ if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
471
+ errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
472
+ if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
473
+ errors.push("direction and sentiment require comparison text.");
474
+ if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
475
+ errors.push("targetLabel requires a numeric target.");
476
+ return { props, errors };
477
+ }
478
+
479
+ // src/compiler/parse.ts
480
+ var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
481
+ var metadataSchema = z2.strictObject({
482
+ title: z2.string().min(1).max(500).optional(),
483
+ description: z2.string().max(2000).optional()
484
+ });
485
+ function textOf(node) {
486
+ return node.value ?? node.children?.map(textOf).join("") ?? "";
487
+ }
488
+ function parseReport(source, fallbackTitle, diagnostics) {
489
+ const tree = parser.parse(source);
490
+ const lines = source.split(/\r?\n/);
491
+ const recognized = new Set;
492
+ const inert = new Set;
493
+ const definitions = new Map;
494
+ let metadata = { title: fallbackTitle };
495
+ const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
496
+ if (firstHeading)
497
+ metadata.title = textOf(firstHeading);
498
+ function inspect(node, depth = 0) {
499
+ if (depth > 64) {
500
+ diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
501
+ return;
502
+ }
503
+ if (node.type === "code" || node.type === "yaml") {
504
+ for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
505
+ inert.add(line);
506
+ return;
507
+ }
508
+ if (node.type === "html")
509
+ diagnostics.add("E_RAW_HTML", "Raw HTML is not supported in reports.", node.position, "Use Markdown or a registered directive. Put HTML examples inside code fences.");
510
+ if (node.type === "image" || node.type === "imageReference")
511
+ diagnostics.add("E_IMAGE_UNSUPPORTED", "Report images are not supported in this initial release.", node.position, "Use a text link; local raster images are planned for the rich-table increment.");
512
+ if (node.type === "link" || node.type === "definition") {
513
+ const url = node.url ?? "";
514
+ let allowed = url.startsWith("#");
515
+ try {
516
+ allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
517
+ } catch {}
518
+ if (!allowed)
519
+ diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
520
+ if (node.type === "definition")
521
+ definitions.set(String(node.identifier), url);
522
+ }
523
+ node.children?.forEach((child) => inspect(child, depth + 1));
524
+ }
525
+ inspect(tree);
526
+ function blocks(nodes, parent = "root", depth = 0) {
527
+ if (depth > 32) {
528
+ diagnostics.add("E_REPORT_DEPTH", "Component nesting exceeds 32 levels.");
529
+ return [];
530
+ }
531
+ const output = [];
532
+ for (const node of nodes) {
533
+ if (node.type === "yaml") {
534
+ try {
535
+ const document = parseDocument(node.value ?? "", {
536
+ uniqueKeys: true,
537
+ strict: true
538
+ });
539
+ if (document.errors.length || document.warnings.length)
540
+ throw new Error([...document.errors, ...document.warnings].map((error) => error.message).join("; "));
541
+ visit(document, {
542
+ Alias() {
543
+ throw new Error("YAML aliases are not supported.");
544
+ }
545
+ });
546
+ const result = metadataSchema.safeParse(document.toJS({ maxAliasCount: 0 }) ?? {});
547
+ if (!result.success)
548
+ throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
549
+ metadata = { ...metadata, ...result.data };
550
+ } catch (error) {
551
+ diagnostics.add("E_FRONTMATTER", error instanceof Error ? error.message : "Invalid frontmatter.", node.position);
552
+ }
553
+ continue;
554
+ }
555
+ if (["containerDirective", "leafDirective", "textDirective"].includes(node.type)) {
556
+ const name = node.name ?? "";
557
+ recognized.add(node.position?.start.line ?? 0);
558
+ if (node.type === "containerDirective") {
559
+ const closingLine = node.position?.end.line ?? 0;
560
+ const closing = lines[closingLine - 1]?.trim() ?? "";
561
+ if (!/^:{3,}$/.test(closing))
562
+ diagnostics.add("E_DIRECTIVE_CLOSE", `:::${name} needs an explicit closing colon fence.`, node.position, "Close the component with ::: on its own line. Use a longer outer fence when nesting containers.");
563
+ else
564
+ recognized.add(closingLine);
565
+ }
566
+ if (!Object.hasOwn(registry, name)) {
567
+ diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
568
+ continue;
569
+ }
570
+ const componentName = name;
571
+ const entry = registry[componentName];
572
+ const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
573
+ if (!entry.forms.includes(form))
574
+ diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
575
+ const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
576
+ if (!allowed)
577
+ diagnostics.add("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${parent}.`, node.position, name === "column" ? "Place column directives inside a table container." : "Place standalone components at the report root or inside a grid.");
578
+ const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
579
+ const names = new Set;
580
+ for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
581
+ if (!match[1])
582
+ continue;
583
+ if (names.has(match[1]))
584
+ diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
585
+ names.add(match[1]);
586
+ }
587
+ if (node.type === "leafDirective" && node.children?.length)
588
+ diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
589
+ const decoded = decodeProps(componentName, node.attributes ?? {});
590
+ for (const message of decoded.errors)
591
+ diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
592
+ const children = blocks(node.children ?? [], name, depth + 1);
593
+ if (decoded.props)
594
+ output.push({
595
+ type: "component",
596
+ name: componentName,
597
+ props: decoded.props,
598
+ children,
599
+ position: node.position
600
+ });
601
+ continue;
602
+ }
603
+ if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
604
+ diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
605
+ const nestedDirectives = (candidate, nesting = 0) => {
606
+ if (nesting > 64)
607
+ return;
608
+ if (candidate.type.endsWith("Directive"))
609
+ diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
610
+ candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
611
+ };
612
+ nestedDirectives(node);
613
+ const last = output.at(-1);
614
+ if (last?.type === "markdown")
615
+ last.tree.children.push(node);
616
+ else
617
+ output.push({
618
+ type: "markdown",
619
+ tree: { type: "root", children: [node] }
620
+ });
621
+ }
622
+ return output;
623
+ }
624
+ const reportBlocks = blocks(tree.children);
625
+ function attachDefinitions(items) {
626
+ for (const block of items) {
627
+ if (block.type === "markdown") {
628
+ for (const [identifier, url] of definitions)
629
+ if (!block.tree.children.some((node) => node.type === "definition" && node.identifier === identifier))
630
+ block.tree.children.push({ type: "definition", identifier, url });
631
+ } else
632
+ attachDefinitions(block.children);
633
+ }
634
+ }
635
+ attachDefinitions(reportBlocks);
636
+ for (let index = 0;index < lines.length; index++) {
637
+ if (/^\s*::/.test(lines[index] ?? "") && !recognized.has(index + 1) && !inert.has(index + 1)) {
638
+ const position = {
639
+ start: { line: index + 1, column: 1 },
640
+ end: { line: index + 1, column: 1 }
641
+ };
642
+ diagnostics.add("E_DIRECTIVE_SYNTAX", "Malformed or misplaced component directive.", position, 'Use ::name{key="literal"} on one line, or a balanced :::name container. JavaScript expressions are not supported.');
643
+ }
644
+ }
645
+ return { metadata, blocks: reportBlocks };
646
+ }
647
+
648
+ // src/compiler/compile.ts
649
+ async function compileReport(input) {
650
+ const requested = path3.resolve(input);
651
+ const diagnostics = new Diagnostics(displayPath(requested));
652
+ const dependencies = new Set([requested]);
653
+ const result = () => ({
654
+ diagnostics: diagnostics.sorted(),
655
+ dependencies,
656
+ filename: requested
657
+ });
658
+ try {
659
+ if (path3.extname(requested).toLowerCase() !== ".md") {
660
+ diagnostics.add("E_REPORT_PATH", "A report must be a .md Markdown file.");
661
+ return result();
662
+ }
663
+ const filename = await realpath2(requested);
664
+ dependencies.add(filename);
665
+ const { text, bytes } = await readBounded(filename, limits.markdownBytes);
666
+ const report = parseReport(text, path3.basename(filename, path3.extname(filename)), diagnostics);
667
+ const resolver = new DataResolver(path3.dirname(filename), diagnostics, dependencies, bytes);
668
+ await resolver.resolve(report.blocks);
669
+ return { ...result(), ...!diagnostics.hasErrors ? { report } : {} };
670
+ } catch (error) {
671
+ diagnostics.add("E_REPORT_READ", error instanceof Error ? error.message.replaceAll(requested, displayPath(requested)) : "Could not compile the report.");
672
+ return result();
673
+ }
674
+ }
675
+
676
+ // src/render/html.ts
677
+ import { createHash } from "node:crypto";
678
+
679
+ // src/render/content.ts
680
+ import { unified as unified2 } from "unified";
681
+ import remarkRehype from "remark-rehype";
682
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
683
+ import rehypeStringify from "rehype-stringify";
684
+
685
+ // src/shared/format.ts
686
+ var numbers = new Map;
687
+ function formatValue(value, format = "number", sign = "auto", compact = false) {
688
+ const key = `${format}:${sign}:${compact}`;
689
+ let formatter = numbers.get(key);
690
+ if (!formatter) {
691
+ const options = {
692
+ maximumFractionDigits: 2,
693
+ signDisplay: sign
694
+ };
695
+ if (format === "percent")
696
+ Object.assign(options, { style: "percent", maximumFractionDigits: 1 });
697
+ if (format.startsWith("currency:"))
698
+ Object.assign(options, {
699
+ style: "currency",
700
+ currency: format.slice(9),
701
+ minimumFractionDigits: 2,
702
+ maximumFractionDigits: 2,
703
+ trailingZeroDisplay: "stripIfInteger"
704
+ });
705
+ if (compact && format === "number")
706
+ Object.assign(options, { notation: "compact", maximumFractionDigits: 1 });
707
+ formatter = new Intl.NumberFormat("en-GB", options);
708
+ numbers.set(key, formatter);
709
+ }
710
+ return formatter.format(value) + (format === "percentage-points" ? " pt" : "");
711
+ }
712
+ function valueSentiment(value, semantics = "neutral") {
713
+ if (value === 0 || semantics === "neutral")
714
+ return "neutral";
715
+ return value > 0 === (semantics === "higher-is-better") ? "positive" : "negative";
716
+ }
717
+
718
+ // src/shared/serialize.ts
719
+ function serializeData(value) {
720
+ return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
721
+ }
722
+
723
+ // src/render/content.ts
724
+ var t = (value) => ({ type: "text", value: String(value) });
725
+ var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
726
+ var markdown = unified2().use(remarkRehype);
727
+ var serializer = unified2().use(rehypeSanitize, {
728
+ ...defaultSchema,
729
+ clobberPrefix: "",
730
+ tagNames: [
731
+ ...defaultSchema.tagNames ?? [],
732
+ "figure",
733
+ "figcaption",
734
+ "button",
735
+ "caption"
736
+ ],
737
+ attributes: {
738
+ ...defaultSchema.attributes,
739
+ "*": [
740
+ ...defaultSchema.attributes?.["*"] ?? [],
741
+ "className",
742
+ "id",
743
+ "role",
744
+ "ariaLabel",
745
+ "ariaLabelledBy",
746
+ "ariaHidden",
747
+ "ariaSort",
748
+ "dataPlotleafChart",
749
+ "dataPlotleafTable",
750
+ "dataColumn",
751
+ "dataRaw",
752
+ "dataColumns",
753
+ "dataSentiment",
754
+ "dataTone"
755
+ ],
756
+ button: ["type", "disabled", "ariaLabel", "dataColumn"],
757
+ section: ["className", "ariaLabel"],
758
+ th: ["scope", "ariaSort", "className"],
759
+ td: ["className", "dataRaw"]
760
+ }
761
+ }).use(rehypeStringify);
762
+ function label(field) {
763
+ const value = field.replaceAll("_", " ");
764
+ return value.charAt(0).toUpperCase() + value.slice(1);
765
+ }
766
+ function slugOf(text) {
767
+ return text.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-") || "section";
768
+ }
769
+ function cellText(value, column) {
770
+ return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
771
+ }
772
+ function table(rows, columns, title, id, sortable = true) {
773
+ const numeric = columns.map((column) => rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
774
+ const caption = h("caption", {}, [t(title)]);
775
+ const headers = columns.map((column, index) => {
776
+ const name = column.label ?? label(column.field);
777
+ const content = sortable ? h("button", {
778
+ type: "button",
779
+ disabled: true,
780
+ dataColumn: index,
781
+ ariaLabel: `Sort by ${name}`
782
+ }, [
783
+ t(name),
784
+ h("span", { className: ["sort-indicator"], ariaHidden: "true" }, [
785
+ t("↕")
786
+ ])
787
+ ]) : t(name);
788
+ return h("th", {
789
+ scope: "col",
790
+ className: numeric[index] ? ["numeric"] : [],
791
+ ...sortable ? { ariaSort: "none" } : {}
792
+ }, [content]);
793
+ });
794
+ const head = h("thead", {}, [h("tr", {}, headers)]);
795
+ const tableRows = rows.map((row) => {
796
+ const cells = columns.map((column, index) => {
797
+ const value = row[column.field] ?? null;
798
+ return h("td", {
799
+ className: numeric[index] ? ["numeric"] : [],
800
+ dataRaw: serializeData(value),
801
+ ...typeof value === "number" ? { dataSentiment: valueSentiment(value, column.semantics) } : {}
802
+ }, [t(cellText(value, column))]);
803
+ });
804
+ return h("tr", {}, cells);
805
+ });
806
+ const body = h("tbody", {}, tableRows);
807
+ return h("div", { className: ["table-scroll"] }, [
808
+ h("table", { id, ...sortable ? { dataPlotleafTable: "" } : {} }, [
809
+ caption,
810
+ head,
811
+ body
812
+ ])
813
+ ]);
814
+ }
815
+ function renderContent(report) {
816
+ let sequence = 0;
817
+ const payload = { charts: [] };
818
+ const headings = new Map;
819
+ let hasH1 = false;
820
+ const headingId = (text) => {
821
+ const slug = slugOf(text);
822
+ const count = headings.get(slug) ?? 0;
823
+ headings.set(slug, count + 1);
824
+ return `plotleaf-heading-${slug}${count ? `-${count + 1}` : ""}`;
825
+ };
826
+ function decorate(node) {
827
+ if (node.type !== "element")
828
+ return;
829
+ if (/^h[1-6]$/.test(node.tagName)) {
830
+ const text = (child) => child.type === "text" ? child.value : child.children.map((n) => text(n)).join("");
831
+ node.properties.id = headingId(text(node));
832
+ if (node.tagName === "h1")
833
+ hasH1 = true;
834
+ }
835
+ if (node.tagName === "a" && typeof node.properties.href === "string") {
836
+ if (node.properties.href.startsWith("#") && !node.properties.href.startsWith("#plotleaf-heading-")) {
837
+ let fragment = node.properties.href.slice(1);
838
+ try {
839
+ fragment = decodeURIComponent(fragment);
840
+ } catch {}
841
+ node.properties.href = `#plotleaf-heading-${slugOf(fragment)}`;
842
+ }
843
+ if (/^https?:/.test(node.properties.href))
844
+ node.properties.rel = ["noopener", "noreferrer"];
845
+ }
846
+ node.children.forEach((child) => decorate(child));
847
+ }
848
+ function render(blocks) {
849
+ return blocks.flatMap((block) => {
850
+ if (block.type === "markdown") {
851
+ const root = markdown.runSync(block.tree);
852
+ root.children.forEach((child) => decorate(child));
853
+ return root.children;
854
+ }
855
+ const id = `plotleaf-component-${String(++sequence).padStart(4, "0")}`;
856
+ switch (block.name) {
857
+ case "grid":
858
+ return [
859
+ h("div", {
860
+ className: [
861
+ "component-grid",
862
+ ...block.children.length > 0 && block.children.every((child) => child.type === "component" && child.name === "metric") ? ["metric-grid"] : []
863
+ ],
864
+ dataColumns: block.props.columns
865
+ }, render(block.children))
866
+ ];
867
+ case "metric": {
868
+ const props = block.props;
869
+ const comparison = props.comparison ? h("p", {
870
+ className: ["metric-comparison"],
871
+ dataSentiment: props.sentiment,
872
+ ariaLabel: `${props.direction === "up" ? "Increased: " : props.direction === "down" ? "Decreased: " : ""}${props.comparison}`
873
+ }, [
874
+ ...props.direction ? [
875
+ h("span", {
876
+ ariaHidden: "true",
877
+ className: ["metric-direction"]
878
+ }, [
879
+ t(props.direction === "up" ? "▲" : props.direction === "down" ? "▼" : "—")
880
+ ])
881
+ ] : [],
882
+ t(props.comparison)
883
+ ]) : null;
884
+ return [
885
+ h("section", { className: ["metric"], ariaLabel: props.label }, [
886
+ h("p", { className: ["metric-label"] }, [t(props.label)]),
887
+ h("p", { className: ["metric-value"] }, [
888
+ t(formatValue(props.value, props.format))
889
+ ]),
890
+ ...comparison ? [comparison] : []
891
+ ])
892
+ ];
893
+ }
894
+ case "callout":
895
+ return [
896
+ h("aside", { className: ["callout"], dataTone: block.props.tone }, [
897
+ ...block.props.title ? [
898
+ h("p", { className: ["callout-title"] }, [
899
+ t(block.props.title)
900
+ ])
901
+ ] : [],
902
+ ...render(block.children)
903
+ ])
904
+ ];
905
+ case "table":
906
+ return [
907
+ table(block.rows ?? [], block.columns ?? [], block.props.title ?? "Data", id),
908
+ ...!block.rows?.length ? [
909
+ h("p", { className: ["empty-state"] }, [
910
+ t("No data to display.")
911
+ ])
912
+ ] : []
913
+ ];
914
+ case "column":
915
+ return [];
916
+ case "line":
917
+ case "area":
918
+ case "bar":
919
+ return renderChart(block, id);
920
+ }
921
+ });
922
+ }
923
+ function renderChart(block, id) {
924
+ const { x, y, title, format } = block.props;
925
+ const xType = block.name === "bar" ? "category" : block.props.xType;
926
+ const rows = block.rows ?? [];
927
+ if (rows.length) {
928
+ const chart = {
929
+ id,
930
+ kind: block.name,
931
+ title,
932
+ xType,
933
+ x,
934
+ y,
935
+ format,
936
+ rows: rows.map((row) => [
937
+ xType === "date" ? parseDate(row[x]) : xType === "category" ? String(row[x]) : row[x],
938
+ row[y]
939
+ ])
940
+ };
941
+ if (block.name !== "bar") {
942
+ chart.target = block.props.target;
943
+ chart.targetLabel = block.props.targetLabel;
944
+ }
945
+ payload.charts.push(chart);
946
+ }
947
+ const columns = [
948
+ { field: x, label: label(x), sign: "auto", semantics: "neutral" },
949
+ { field: y, label: label(y), format, sign: "auto", semantics: "neutral" }
950
+ ];
951
+ return [
952
+ h("figure", { className: ["chart"] }, [
953
+ h("figcaption", { id: `${id}-title` }, [t(title)]),
954
+ ...rows.length ? [
955
+ h("div", {
956
+ className: ["chart-plot"],
957
+ id,
958
+ dataPlotleafChart: "",
959
+ role: "img",
960
+ ariaLabel: `${title}. Data is available in the table below.`
961
+ })
962
+ ] : [
963
+ h("p", { className: ["empty-state"] }, [
964
+ t("No data to display.")
965
+ ])
966
+ ],
967
+ ...rows.length ? [
968
+ h("details", { className: ["chart-data"] }, [
969
+ h("summary", {}, [
970
+ t("View data"),
971
+ h("span", { className: ["visually-hidden"] }, [
972
+ t(` for ${title}`)
973
+ ])
974
+ ]),
975
+ table(rows, columns, title, `${id}-data`, false)
976
+ ])
977
+ ] : []
978
+ ])
979
+ ];
980
+ }
981
+ const children = render(report.blocks);
982
+ if (!hasH1)
983
+ children.unshift(h("h1", { id: "plotleaf-heading-title" }, [t(report.metadata.title)]));
984
+ const tree = { type: "root", children };
985
+ return {
986
+ html: serializer.stringify(serializer.runSync(tree)),
987
+ payload,
988
+ hasCharts: payload.charts.length > 0
989
+ };
990
+ }
991
+
992
+ // src/render/html.ts
993
+ var escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
994
+ var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replaceAll("<!--", "\\x3c!--");
995
+ var reload = (revision) => `(()=>{const source=new EventSource("/__plotleaf/events");source.addEventListener("ready",event=>{if(Number(event.data)!==${revision})location.reload()});source.addEventListener("reload",()=>location.reload());addEventListener("pagehide",()=>source.close(),{once:true});})();`;
996
+ function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
997
+ const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
998
+ const hashes = code.map((script) => `'sha256-${createHash("sha256").update(script).digest("base64")}'`).join(" ");
999
+ const policy = `default-src 'none'; script-src ${hashes || "'none'"}; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src ${preview ? "'self'" : "'none'"}; base-uri 'none'; object-src 'none'; form-action 'none'`;
1000
+ return `<!doctype html>
1001
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHtml(policy)}"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)}</title>${description ? `<meta name="description" content="${escapeHtml(description)}">` : ""}<style>${styles}</style></head><body><a class="skip-link" href="#plotleaf-report">Skip to report</a><main class="report" id="plotleaf-report">${body}</main>${payload ? `<script type="application/json" id="plotleaf-data">${payload}</script>` : ""}${notices ? `<template id="plotleaf-notices"><pre>${escapeHtml(notices)}</pre></template>` : ""}${code.map((script) => `<script>${script}</script>`).join("")}</body></html>
1002
+ `;
1003
+ }
1004
+ function renderHtml(report, assets, preview = false, revision = 0) {
1005
+ const content = renderContent(report);
1006
+ return documentHtml(report.metadata.title, report.metadata.description, content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, content.hasCharts ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
1007
+ }
1008
+ function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1009
+ const body = `<h1>Report needs attention</h1><p class="diagnostic-intro">Update the source files to refresh this preview.</p>${diagnostics.map((item) => `<section class="diagnostic"><p class="diagnostic-location">${escapeHtml(item.file)}:${item.line}:${item.column} · ${escapeHtml(item.code)}</p><p>${escapeHtml(item.message)}</p>${item.hint ? `<p class="diagnostic-hint">${escapeHtml(item.hint)}</p>` : ""}</section>`).join("")}`;
1010
+ return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, undefined, "", revision);
1011
+ }
1012
+
1013
+ // src/export.ts
1014
+ import path4 from "node:path";
1015
+ import { randomUUID } from "node:crypto";
1016
+ import {
1017
+ lstat,
1018
+ stat as stat2,
1019
+ realpath as realpath3,
1020
+ mkdir,
1021
+ writeFile,
1022
+ rename,
1023
+ rm
1024
+ } from "node:fs/promises";
1025
+ async function writeReport(filename, html, inputs) {
1026
+ const destination = path4.resolve(filename);
1027
+ if (path4.extname(destination).toLowerCase() !== ".html")
1028
+ throw new Error("Output must have an .html extension.");
1029
+ let existing;
1030
+ try {
1031
+ const info = await lstat(destination);
1032
+ if (info.isSymbolicLink())
1033
+ throw new Error("Refusing a symbolic-link output destination. Choose a regular .html path.");
1034
+ if (!info.isFile())
1035
+ throw new Error("Output destination is not a regular file.");
1036
+ existing = info;
1037
+ } catch (error) {
1038
+ if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1039
+ throw error;
1040
+ }
1041
+ let directory = path4.dirname(destination);
1042
+ const missing = [];
1043
+ while (true) {
1044
+ try {
1045
+ directory = await realpath3(directory);
1046
+ break;
1047
+ } catch (error) {
1048
+ if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1049
+ throw error;
1050
+ missing.unshift(path4.basename(directory));
1051
+ const parent = path4.dirname(directory);
1052
+ if (parent === directory)
1053
+ throw error;
1054
+ directory = parent;
1055
+ }
1056
+ }
1057
+ const canonical = path4.join(directory, ...missing, path4.basename(destination));
1058
+ for (const input of inputs) {
1059
+ if (path4.resolve(input) === canonical || path4.resolve(input) === destination)
1060
+ throw new Error("Output would overwrite a report input or dependency.");
1061
+ if (existing) {
1062
+ try {
1063
+ const source = await stat2(input);
1064
+ if (source.dev === existing.dev && source.ino === existing.ino)
1065
+ throw new Error("Output is a hard link to a report input or dependency.");
1066
+ } catch (error) {
1067
+ if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1068
+ throw error;
1069
+ }
1070
+ }
1071
+ }
1072
+ await mkdir(path4.dirname(destination), { recursive: true });
1073
+ const temporary = path4.join(path4.dirname(destination), `.plotleaf-${randomUUID()}.tmp`);
1074
+ try {
1075
+ await writeFile(temporary, html, {
1076
+ encoding: "utf8",
1077
+ mode: 384,
1078
+ flag: "wx"
1079
+ });
1080
+ await rename(temporary, destination);
1081
+ } finally {
1082
+ await rm(temporary, { force: true });
1083
+ }
1084
+ return destination;
1085
+ }
1086
+
1087
+ // src/preview.ts
1088
+ import { createServer } from "node:http";
1089
+ import chokidar from "chokidar";
1090
+ import open2 from "open";
1091
+ async function startPreview(filename, assets, options = {}) {
1092
+ const log = options.log ?? console.log;
1093
+ let compilation = options.initial ?? await compileReport(filename);
1094
+ let revision = 0;
1095
+ const render = () => compilation.report ? renderHtml(compilation.report, assets, true, revision) : renderDiagnostics(compilation.diagnostics, assets, true, revision);
1096
+ let html = render();
1097
+ let closed = false;
1098
+ let running = false;
1099
+ let dirty = false;
1100
+ let timer;
1101
+ let opened = false;
1102
+ const clients = new Set;
1103
+ let url = "";
1104
+ const server = createServer((request, response) => {
1105
+ const port = server.address().port;
1106
+ const authority = request.headers.host?.toLowerCase();
1107
+ const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
1108
+ response.setHeader("X-Content-Type-Options", "nosniff");
1109
+ response.setHeader("Cache-Control", "no-store");
1110
+ if (!authority || !allowed.has(authority) || request.headers.origin && ![...allowed].map((host) => `http://${host}`).includes(request.headers.origin)) {
1111
+ response.writeHead(403).end("Forbidden");
1112
+ return;
1113
+ }
1114
+ if (request.method !== "GET" && request.method !== "HEAD") {
1115
+ response.writeHead(405, { Allow: "GET, HEAD" }).end("Method not allowed");
1116
+ return;
1117
+ }
1118
+ let pathname;
1119
+ try {
1120
+ pathname = new URL(request.url ?? "/", `http://127.0.0.1:${port}`).pathname;
1121
+ } catch {
1122
+ response.writeHead(400).end("Bad request");
1123
+ return;
1124
+ }
1125
+ if (pathname === "/") {
1126
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(request.method === "HEAD" ? undefined : html);
1127
+ return;
1128
+ }
1129
+ if (pathname === "/favicon.ico") {
1130
+ response.writeHead(204).end();
1131
+ return;
1132
+ }
1133
+ if (pathname === "/__plotleaf/events" && request.method === "GET") {
1134
+ response.writeHead(200, {
1135
+ "Content-Type": "text/event-stream",
1136
+ Connection: "keep-alive",
1137
+ "X-Accel-Buffering": "no"
1138
+ });
1139
+ response.write(`event: ready
1140
+ data: ${revision}
1141
+
1142
+ `);
1143
+ clients.add(response);
1144
+ request.on("close", () => clients.delete(response));
1145
+ return;
1146
+ }
1147
+ response.writeHead(404).end("Not found");
1148
+ });
1149
+ await new Promise((resolve, reject) => {
1150
+ server.once("error", reject);
1151
+ server.listen(options.port ?? 0, "127.0.0.1", () => {
1152
+ server.off("error", reject);
1153
+ resolve();
1154
+ });
1155
+ });
1156
+ url = `http://127.0.0.1:${server.address().port}`;
1157
+ const watcher = chokidar.watch([...compilation.dependencies], {
1158
+ ignoreInitial: true,
1159
+ followSymlinks: false,
1160
+ atomic: 100,
1161
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 }
1162
+ });
1163
+ await new Promise((resolve) => watcher.once("ready", resolve));
1164
+ const openOnce = () => {
1165
+ if (!options.openBrowser || opened || !compilation.report)
1166
+ return;
1167
+ opened = true;
1168
+ open2(url).catch(() => log(`Open ${url} in your browser.`));
1169
+ };
1170
+ async function rebuild() {
1171
+ dirty = true;
1172
+ if (running || closed)
1173
+ return;
1174
+ running = true;
1175
+ try {
1176
+ while (dirty && !closed) {
1177
+ dirty = false;
1178
+ const next = await compileReport(filename);
1179
+ if (closed)
1180
+ break;
1181
+ const previous = compilation.dependencies;
1182
+ compilation = next;
1183
+ watcher.add([...next.dependencies]);
1184
+ const removed = [...previous].filter((file) => !next.dependencies.has(file));
1185
+ if (removed.length)
1186
+ watcher.unwatch(removed);
1187
+ revision++;
1188
+ html = render();
1189
+ for (const client of clients)
1190
+ client.write(`event: reload
1191
+ data: ${revision}
1192
+
1193
+ `);
1194
+ log(compilation.report ? "Report updated." : humanDiagnostics(compilation.diagnostics));
1195
+ openOnce();
1196
+ }
1197
+ } finally {
1198
+ running = false;
1199
+ }
1200
+ }
1201
+ watcher.on("all", () => {
1202
+ if (timer)
1203
+ clearTimeout(timer);
1204
+ timer = setTimeout(() => {
1205
+ rebuild();
1206
+ }, 35);
1207
+ });
1208
+ watcher.on("error", (error) => log(`File watcher: ${error instanceof Error ? error.message : String(error)}`));
1209
+ const heartbeat = setInterval(() => {
1210
+ for (const client of clients)
1211
+ client.write(`: heartbeat
1212
+
1213
+ `);
1214
+ }, 15000);
1215
+ heartbeat.unref();
1216
+ async function close() {
1217
+ if (closed)
1218
+ return;
1219
+ closed = true;
1220
+ if (timer)
1221
+ clearTimeout(timer);
1222
+ clearInterval(heartbeat);
1223
+ for (const client of clients)
1224
+ client.end();
1225
+ clients.clear();
1226
+ await watcher.close();
1227
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
1228
+ }
1229
+ log(`plotleaf preview: ${url}`);
1230
+ if (!compilation.report)
1231
+ log(humanDiagnostics(compilation.diagnostics));
1232
+ openOnce();
1233
+ return { url, close, server };
1234
+ }
1235
+ // package.json
1236
+ var package_default = {
1237
+ name: "plotleaf",
1238
+ version: "0.1.0",
1239
+ description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1240
+ type: "module",
1241
+ license: "MIT",
1242
+ bin: {
1243
+ plotleaf: "./dist/cli.js"
1244
+ },
1245
+ engines: {
1246
+ node: ">=22"
1247
+ },
1248
+ packageManager: "bun@1.4.4",
1249
+ files: [
1250
+ "dist/cli.js",
1251
+ "dist/assets/base.js",
1252
+ "dist/assets/charts.js",
1253
+ "dist/assets/styles.css",
1254
+ "dist/assets/notices.txt",
1255
+ "schema/components.json",
1256
+ "examples/storefront/*.md",
1257
+ "examples/storefront/*.json",
1258
+ "examples/adoption/*.md",
1259
+ "examples/adoption/*.json",
1260
+ "skills/plotleaf/SKILL.md",
1261
+ "README.md",
1262
+ "LICENSE",
1263
+ "THIRD_PARTY_NOTICES.txt"
1264
+ ],
1265
+ scripts: {
1266
+ format: "bun fmt src scripts tests playwright.config.ts package.json tsconfig.json tsconfig.browser.json tsconfig.dev.json bunfig.toml .github/workflows/ci.yml",
1267
+ build: "bun scripts/build.ts",
1268
+ typecheck: "bun node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && bun node_modules/typescript/bin/tsc --noEmit -p tsconfig.browser.json && bun node_modules/typescript/bin/tsc --noEmit -p tsconfig.dev.json",
1269
+ "typecheck:tsrs": "bun node_modules/tsrs/bin/tsrs --noEmit --fast false -p tsconfig.json && bun node_modules/tsrs/bin/tsrs --noEmit --fast false -p tsconfig.browser.json && bun node_modules/tsrs/bin/tsrs --noEmit --fast false -p tsconfig.dev.json",
1270
+ test: "bun test",
1271
+ "test:browser": "node node_modules/@playwright/test/cli.js test",
1272
+ "test:consumer": "node tests/consumer/smoke.mjs",
1273
+ check: "bun run typecheck && bun run build && bun run test && bun run test:consumer",
1274
+ "audit:package": "node scripts/audit-package.mjs"
1275
+ },
1276
+ dependencies: {
1277
+ chokidar: "6.0.0",
1278
+ open: "11.0.4",
1279
+ "rehype-sanitize": "6.0.0",
1280
+ "rehype-stringify": "10.0.1",
1281
+ "remark-directive": "4.0.0",
1282
+ "remark-frontmatter": "5.0.0",
1283
+ "remark-parse": "11.0.0",
1284
+ "remark-rehype": "11.1.2",
1285
+ unified: "11.0.5",
1286
+ yaml: "2.9.1",
1287
+ zod: "4.6.7"
1288
+ },
1289
+ devDependencies: {
1290
+ "@playwright/test": "1.65.0",
1291
+ "@types/bun": "1.4.4",
1292
+ "@types/hast": "3.0.5",
1293
+ "@types/mdast": "4.0.4",
1294
+ "@types/node": "22.20.5",
1295
+ echarts: "6.1.0",
1296
+ tsrs: "0.23.0-ts7.1.0-beta",
1297
+ typescript: "7.0.2"
1298
+ },
1299
+ keywords: [
1300
+ "markdown",
1301
+ "reports",
1302
+ "data-visualization",
1303
+ "charts",
1304
+ "cli",
1305
+ "agents"
1306
+ ]
1307
+ };
1308
+
1309
+ // src/cli.ts
1310
+ var help = `plotleaf — beautiful reports from Markdown and JSON
1311
+
1312
+ Usage:
1313
+ npx plotleaf <report.md> Open a live preview
1314
+ npx plotleaf dev <report.md> [--no-open] [--port 3000]
1315
+ npx plotleaf validate <report.md> [--json] Validate syntax and local data
1316
+ npx plotleaf build <report.md> [--out report.html]
1317
+ npx plotleaf schema Print component specifications
1318
+
1319
+ Options:
1320
+ -h, --help Show this help
1321
+ -v, --version Show the version
1322
+
1323
+ Reports run without application setup. Exports work offline in a browser.
1324
+ `;
1325
+ async function assets() {
1326
+ const base = new URL("./assets/", import.meta.url);
1327
+ const [styles, runtime, charts, notices] = await Promise.all(["styles.css", "base.js", "charts.js", "notices.txt"].map((file) => readFile(new URL(file, base), "utf8")));
1328
+ return {
1329
+ styles,
1330
+ base: runtime,
1331
+ charts,
1332
+ notices
1333
+ };
1334
+ }
1335
+ async function main() {
1336
+ const { values, positionals } = parseArgs({
1337
+ allowPositionals: true,
1338
+ strict: true,
1339
+ options: {
1340
+ help: { type: "boolean", short: "h" },
1341
+ version: { type: "boolean", short: "v" },
1342
+ json: { type: "boolean" },
1343
+ "no-open": { type: "boolean" },
1344
+ port: { type: "string" },
1345
+ out: { type: "string", short: "o" }
1346
+ }
1347
+ });
1348
+ if (values.version) {
1349
+ console.log(package_default.version);
1350
+ return;
1351
+ }
1352
+ if (values.help || !positionals.length) {
1353
+ process.stdout.write(help);
1354
+ return;
1355
+ }
1356
+ const first = positionals[0];
1357
+ const known = ["dev", "validate", "build", "schema"];
1358
+ const command = known.includes(first) ? first : "dev";
1359
+ const filename = known.includes(first) ? positionals[1] : first;
1360
+ const extras = positionals.slice(known.includes(first) ? 2 : 1);
1361
+ if (command === "schema") {
1362
+ if (positionals.length !== 1 || Object.keys(values).length)
1363
+ throw new Error("schema does not accept a report or options. Use plotleaf schema.");
1364
+ console.log(JSON.stringify(componentSpec(), null, 2));
1365
+ return;
1366
+ }
1367
+ if (!filename || extras.length)
1368
+ throw new Error("Specify exactly one report.md. Run plotleaf --help for usage.");
1369
+ if (values.json && command !== "validate")
1370
+ throw new Error("--json is only supported by validate.");
1371
+ if (values.out && command !== "build")
1372
+ throw new Error("--out is only supported by build.");
1373
+ if ((values.port !== undefined || values["no-open"]) && command !== "dev")
1374
+ throw new Error("--port and --no-open are only supported by dev/preview.");
1375
+ let port;
1376
+ if (values.port !== undefined) {
1377
+ if (!/^\d+$/.test(values.port) || Number(values.port) > 65535)
1378
+ throw new Error("--port must be an integer from 0 to 65535.");
1379
+ port = Number(values.port);
1380
+ }
1381
+ const result = await compileReport(filename);
1382
+ if (command === "validate") {
1383
+ if (values.json)
1384
+ console.log(JSON.stringify({ valid: Boolean(result.report), diagnostics: result.diagnostics }, null, 2));
1385
+ else {
1386
+ if (result.diagnostics.length)
1387
+ console.error(humanDiagnostics(result.diagnostics));
1388
+ if (result.report)
1389
+ console.log(`Valid: ${displayPath(result.filename)}`);
1390
+ }
1391
+ if (!result.report)
1392
+ process.exitCode = 1;
1393
+ return;
1394
+ }
1395
+ if (command === "build") {
1396
+ if (result.diagnostics.length)
1397
+ console.error(humanDiagnostics(result.diagnostics));
1398
+ if (!result.report) {
1399
+ process.exitCode = 1;
1400
+ return;
1401
+ }
1402
+ const html = renderHtml(result.report, await assets());
1403
+ const defaultOutput = result.filename.slice(0, -path5.extname(result.filename).length) + ".html";
1404
+ const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
1405
+ console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
1406
+ return;
1407
+ }
1408
+ const preview = await startPreview(filename, await assets(), {
1409
+ initial: result,
1410
+ port,
1411
+ openBrowser: !values["no-open"],
1412
+ log: (message) => process.stderr.write(message + `
1413
+ `)
1414
+ });
1415
+ let closing = false;
1416
+ const close = () => {
1417
+ if (closing)
1418
+ return;
1419
+ closing = true;
1420
+ preview.close().catch((error) => {
1421
+ console.error(error instanceof Error ? error.message : error);
1422
+ process.exitCode = 2;
1423
+ });
1424
+ };
1425
+ process.once("SIGINT", close);
1426
+ process.once("SIGTERM", close);
1427
+ }
1428
+ main().catch((error) => {
1429
+ console.error(`plotleaf: ${error instanceof Error ? error.message : String(error)}`);
1430
+ process.exitCode = 2;
1431
+ });