plotleaf 0.1.0 → 0.3.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 CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path5 from "node:path";
4
+ import path6 from "node:path";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { parseArgs } from "node:util";
7
7
 
8
8
  // src/compiler/compile.ts
9
- import path3 from "node:path";
9
+ import path4 from "node:path";
10
10
  import { realpath as realpath2 } from "node:fs/promises";
11
11
 
12
12
  // src/compiler/diagnostics.ts
@@ -62,7 +62,7 @@ ${d.hint}` : ""}`).join(`
62
62
  `);
63
63
  }
64
64
 
65
- // src/compiler/data.ts
65
+ // src/compiler/files.ts
66
66
  import path2 from "node:path";
67
67
  import { open, realpath, stat } from "node:fs/promises";
68
68
 
@@ -74,25 +74,38 @@ var limits = {
74
74
  datasets: 64,
75
75
  rows: 1e5,
76
76
  chartRows: 1e4,
77
+ donutSlices: 8,
78
+ funnelStages: 20,
77
79
  tableRows: 1000,
78
- tableColumns: 40
80
+ tableColumns: 40,
81
+ sparklinePoints: 64,
82
+ imageFiles: 64,
83
+ imageBytes: 1024 ** 2,
84
+ imageDimension: 4096,
85
+ imagePixels: 4000000
79
86
  };
80
87
 
81
- // src/compiler/data.ts
82
- var reserved = new Set(["__proto__", "constructor", "prototype"]);
88
+ // src/compiler/files.ts
89
+ class LocalFileError extends Error {
90
+ kind;
91
+ constructor(kind, message) {
92
+ super(message);
93
+ this.kind = kind;
94
+ }
95
+ }
83
96
  function contained(root, filename) {
84
97
  const relative = path2.relative(root, filename);
85
98
  return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
86
99
  }
87
- async function readBounded(filename, maximum) {
100
+ async function readBytesBounded(filename, maximum) {
88
101
  if (!(await stat(filename)).isFile())
89
102
  throw new Error("Expected a regular file.");
90
103
  const file = await open(filename, "r");
91
104
  try {
92
- const stat = await file.stat();
93
- if (!stat.isFile())
105
+ const info = await file.stat();
106
+ if (!info.isFile())
94
107
  throw new Error("Expected a regular file.");
95
- if (stat.size > maximum)
108
+ if (info.size > maximum)
96
109
  throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
97
110
  const chunks = [];
98
111
  let bytes = 0;
@@ -106,14 +119,158 @@ async function readBounded(filename, maximum) {
106
119
  }
107
120
  if (bytes > maximum)
108
121
  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
- };
122
+ return Buffer.concat(chunks, bytes);
113
123
  } finally {
114
124
  await file.close();
115
125
  }
116
126
  }
127
+ async function readBounded(filename, maximum) {
128
+ const bytes = await readBytesBounded(filename, maximum);
129
+ return {
130
+ text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
131
+ bytes: bytes.length
132
+ };
133
+ }
134
+
135
+ class ReportFiles {
136
+ root;
137
+ dependencies;
138
+ usedBytes;
139
+ constructor(root, dependencies, usedBytes) {
140
+ this.root = root;
141
+ this.dependencies = dependencies;
142
+ this.usedBytes = usedBytes;
143
+ }
144
+ async resolve(reference, extensions) {
145
+ const fail = (message) => {
146
+ throw new LocalFileError("path", message);
147
+ };
148
+ if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
149
+ fail(`Use a plain relative local ${extensions.join("/")} path.`);
150
+ const candidate = path2.resolve(this.root, reference);
151
+ const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
152
+ if (!contained(this.root, candidate))
153
+ fail("Reference escapes the report directory.");
154
+ if (hidden(candidate))
155
+ fail("Hidden files and directories are not allowed.");
156
+ if (!extensions.includes(path2.extname(candidate).toLowerCase()))
157
+ fail(`Use a ${extensions.join("/")} file.`);
158
+ this.dependencies.add(candidate);
159
+ let canonical;
160
+ try {
161
+ canonical = await realpath(candidate);
162
+ } catch {
163
+ throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
164
+ }
165
+ if (!contained(this.root, canonical) || hidden(canonical))
166
+ fail("The resolved file or symlink escapes the permitted report files.");
167
+ if (!extensions.includes(path2.extname(canonical).toLowerCase()))
168
+ fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
169
+ this.dependencies.add(canonical);
170
+ return canonical;
171
+ }
172
+ async read(filename, maximum) {
173
+ const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
174
+ this.usedBytes += bytes.length;
175
+ return bytes;
176
+ }
177
+ }
178
+
179
+ // src/compiler/images.ts
180
+ import { createHash } from "node:crypto";
181
+ import path3 from "node:path";
182
+ import { imageSize } from "image-size";
183
+ function formatOf(bytes) {
184
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
185
+ let offset = 8;
186
+ let data = false;
187
+ while (offset + 12 <= bytes.length) {
188
+ const size = bytes.readUInt32BE(offset);
189
+ const type = bytes.toString("ascii", offset + 4, offset + 8);
190
+ if (offset + size + 12 > bytes.length)
191
+ break;
192
+ if (offset === 8 && (type !== "IHDR" || size !== 13))
193
+ break;
194
+ if (type === "acTL")
195
+ throw new Error("Animated PNG is not supported; provide a static image.");
196
+ if (type === "IDAT" && size > 0)
197
+ data = true;
198
+ offset += size + 12;
199
+ if (type === "IEND") {
200
+ if (data && size === 0 && offset === bytes.length)
201
+ return "png";
202
+ break;
203
+ }
204
+ }
205
+ throw new Error("Incomplete or invalid PNG container.");
206
+ }
207
+ if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
208
+ return "jpg";
209
+ throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
210
+ }
211
+
212
+ class ImageResolver {
213
+ files;
214
+ attempted = new Set;
215
+ filesRead = new Map;
216
+ embedded = new Map;
217
+ constructor(files) {
218
+ this.files = files;
219
+ }
220
+ async load(reference) {
221
+ const requested = path3.resolve(this.files.root, reference);
222
+ if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
223
+ throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
224
+ this.attempted.add(requested);
225
+ const canonical = await this.files.resolve(reference, [
226
+ ".png",
227
+ ".jpg",
228
+ ".jpeg"
229
+ ]);
230
+ const cached = this.filesRead.get(canonical);
231
+ if (cached) {
232
+ if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
233
+ throw new Error("Image signature does not match its file extension.");
234
+ return cached;
235
+ }
236
+ if (this.filesRead.size >= limits.imageFiles)
237
+ throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
238
+ const bytes = await this.files.read(canonical, limits.imageBytes);
239
+ const format = formatOf(bytes);
240
+ const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
241
+ if (format !== expected(reference) || format !== expected(canonical))
242
+ throw new Error("Image signature does not match its file extension.");
243
+ let size;
244
+ try {
245
+ size = imageSize(bytes);
246
+ } catch {
247
+ throw new Error("Unsupported or damaged PNG/JPEG header.");
248
+ }
249
+ if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
250
+ throw new Error("Image must have valid positive raster dimensions.");
251
+ if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
252
+ throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
253
+ const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
254
+ const image = this.embedded.get(id) ?? {
255
+ id,
256
+ mime: format === "png" ? "image/png" : "image/jpeg",
257
+ base64: bytes.toString("base64")
258
+ };
259
+ this.filesRead.set(canonical, image);
260
+ this.embedded.set(id, image);
261
+ return image;
262
+ }
263
+ }
264
+ // src/shared/payload.ts
265
+ var funnelStatuses = {
266
+ "on-target": "On target",
267
+ "below-target": "Below target",
268
+ "at-risk": "At risk",
269
+ neutral: "Neutral"
270
+ };
271
+
272
+ // src/compiler/data.ts
273
+ var reserved = new Set(["__proto__", "constructor", "prototype"]);
117
274
  function parseDate(value) {
118
275
  const date = value.slice(0, 10);
119
276
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
@@ -133,87 +290,87 @@ class DataResolver {
133
290
  root;
134
291
  diagnostics;
135
292
  dependencies;
136
- totalBytes;
137
293
  cache = new Map;
294
+ files;
295
+ images;
138
296
  constructor(root, diagnostics, dependencies, totalBytes = 0) {
139
297
  this.root = root;
140
298
  this.diagnostics = diagnostics;
141
299
  this.dependencies = dependencies;
142
- this.totalBytes = totalBytes;
300
+ this.files = new ReportFiles(root, dependencies, totalBytes);
301
+ this.images = new ImageResolver(this.files);
143
302
  }
144
303
  async load(reference, block) {
145
304
  const fail = (code, message, hint) => {
146
305
  this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
147
306
  return;
148
307
  };
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
308
  let canonical;
160
309
  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));
310
+ canonical = await this.files.resolve(reference, [".json"]);
178
311
  } catch (error) {
179
- return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
312
+ return fail(error instanceof LocalFileError && error.kind === "path" ? "E_DATA_PATH" : "E_DATA_READ", error instanceof Error ? error.message : "Cannot resolve the dataset.", "Place prepared JSON within the report directory and correct the data property.");
180
313
  }
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.");
314
+ let dataset = this.cache.get(canonical);
315
+ if (!dataset) {
316
+ if (this.cache.size >= limits.datasets)
317
+ return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
318
+ let text;
319
+ try {
320
+ text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
321
+ } catch (error) {
322
+ return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
200
323
  }
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(text);
327
+ } catch {
328
+ return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
329
+ }
330
+ if (!Array.isArray(parsed))
331
+ return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
332
+ if (parsed.length > limits.rows)
333
+ return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller dataset before rendering.");
334
+ const arrays = new Map;
335
+ for (const [index, row] of parsed.entries()) {
336
+ if (!row || typeof row !== "object" || Array.isArray(row))
337
+ return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
338
+ for (const [key, value] of Object.entries(row)) {
339
+ if (reserved.has(key))
340
+ return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
341
+ if (Array.isArray(value)) {
342
+ if (value.length > limits.sparklinePoints)
343
+ return fail("E_DATA_LIMIT", `Row ${index + 1}, field ${JSON.stringify(key)} exceeds ${limits.sparklinePoints} sparkline points.`, "Prepare a bounded sequence; values are never truncated.");
344
+ if (value.some((item) => item !== null && !(typeof item === "number" && Number.isFinite(item))))
345
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain only finite numbers or null gaps.`, "Only declared sparkline columns support sequences; nested objects and sequences are invalid.");
346
+ if (!arrays.has(key))
347
+ arrays.set(key, index);
348
+ } else if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
349
+ 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; use an explicit sparkline column for bounded numeric/null sequences.");
350
+ }
351
+ }
352
+ dataset = { rows: parsed, arrays };
353
+ this.cache.set(canonical, dataset);
201
354
  }
202
- const rows = parsed;
203
- this.cache.set(canonical, rows);
204
- return rows;
355
+ const declared = new Set(block.name === "table" ? block.children.flatMap((child) => child.type === "component" && child.name === "column" && child.props.kind === "sparkline" ? [child.props.field] : []) : []);
356
+ for (const [field, index] of dataset.arrays)
357
+ if (!declared.has(field))
358
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(field)} is a sequence without an explicit sparkline column.`, 'Use a table column with kind="sparkline" for that field. Charts and scalar tables require flat scalar datasets.');
359
+ return dataset.rows;
205
360
  }
206
361
  async resolve(blocks) {
207
362
  for (const block of blocks) {
208
363
  if (block.type !== "component")
209
364
  continue;
210
- if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "table") {
365
+ if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
211
366
  const rows = await this.load(block.props.data, block);
212
367
  if (rows) {
213
368
  if (!rows.length)
214
369
  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
370
  if (block.name === "table")
216
- this.resolveTable(block, rows);
371
+ await this.resolveTable(block, rows);
372
+ else if (block.name === "donut" || block.name === "funnel")
373
+ this.resolveParts(block, rows);
217
374
  else
218
375
  this.resolveChart(block, rows);
219
376
  }
@@ -265,7 +422,72 @@ class DataResolver {
265
422
  }
266
423
  block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
267
424
  }
268
- resolveTable(block, rows) {
425
+ resolveParts(block, rows) {
426
+ const props = block.props;
427
+ const maximum = block.name === "donut" ? limits.donutSlices : limits.funnelStages;
428
+ const fields = block.name === "donut" ? [block.props.category, props.value] : [
429
+ block.props.stage,
430
+ props.value,
431
+ block.props.total,
432
+ block.props.share,
433
+ ...block.props.status ? [block.props.status] : []
434
+ ];
435
+ const fail = (code, message, hint) => {
436
+ this.diagnostics.add(code, `${props.data}: ${message}`, block.position, hint);
437
+ };
438
+ if (rows.length > maximum) {
439
+ fail("E_DATA_LIMIT", `${block.name} supports at most ${maximum} rows.`, "Prepare a smaller summary; plotleaf does not group or truncate data.");
440
+ return;
441
+ }
442
+ if (fields.some((field) => !this.field(rows, field, block)))
443
+ return;
444
+ const seen = new Set;
445
+ let sum = 0;
446
+ for (const [index, row] of rows.entries()) {
447
+ const name = row[fields[0]];
448
+ const value = row[props.value];
449
+ if (typeof name !== "string" || !name.trim() || name.length > 1000) {
450
+ fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(fields[0])} must be a nonblank string of at most 1000 characters.`, "Supply a short, explicit label for every category or stage.");
451
+ return;
452
+ }
453
+ if (seen.has(name)) {
454
+ fail("E_DATA_ORDER", `Row ${index + 1}: category/stage labels must be unique.`, "Prepare distinct labels; source order is preserved.");
455
+ return;
456
+ }
457
+ seen.add(name);
458
+ if (typeof value !== "number" || value < 0) {
459
+ fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(props.value)} must be a nonnegative number.`, "Use finite JSON numbers, not nulls or numeric strings.");
460
+ return;
461
+ }
462
+ if (block.name === "donut") {
463
+ sum += value;
464
+ if (!Number.isFinite(sum)) {
465
+ fail("E_DATA_VALUE", "The donut total exceeds the finite numeric range.", "Prepare values in smaller units before rendering.");
466
+ return;
467
+ }
468
+ } else {
469
+ const total = row[block.props.total];
470
+ const share = row[block.props.share];
471
+ if (typeof total !== "number" || total <= 0 || value > total || typeof share !== "number" || share < 0 || share > 1) {
472
+ fail("E_DATA_VALUE", `Row ${index + 1}: total must be positive, value must not exceed total, and share must be a number from 0 to 1.`, "Supply each stage's denominator and prepared ratio explicitly; use an empty array when no baseline exists.");
473
+ return;
474
+ }
475
+ if (Math.abs(share - value / total) > 0.0005 + Number.EPSILON) {
476
+ fail("E_DATA_VALUE", `Row ${index + 1}: share does not match value / total.`, "Prepare a ratio consistent with the displayed values; rounding to 0.1 percentage point is supported.");
477
+ return;
478
+ }
479
+ if (block.props.status) {
480
+ const status = row[block.props.status];
481
+ if (typeof status !== "string" || !Object.hasOwn(funnelStatuses, status)) {
482
+ fail("E_DATA_VALUE", `Row ${index + 1}: status must be ${Object.keys(funnelStatuses).join(", ")}.`, "Supply business status explicitly, or omit the status property for neutral styling.");
483
+ return;
484
+ }
485
+ }
486
+ }
487
+ }
488
+ block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
489
+ }
490
+ async resolveTable(block, rows) {
269
491
  if (rows.length > limits.tableRows) {
270
492
  this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
271
493
  return;
@@ -281,24 +503,101 @@ class DataResolver {
281
503
  this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
282
504
  return;
283
505
  }
284
- if (new Set(columns.map((column) => column.field)).size !== columns.length)
506
+ if (new Set(columns.map((column) => column.field)).size !== columns.length) {
285
507
  this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
508
+ return;
509
+ }
286
510
  for (const [index, row] of rows.entries())
287
511
  if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
288
512
  this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
289
513
  return;
290
514
  }
515
+ const prepared = rows.map(() => ({}));
291
516
  for (const column of columns) {
292
- if (!this.field(rows, column.field, block))
517
+ const origin = explicit.find((child) => child.props.field === column.field) ?? block;
518
+ const fields = [
519
+ column.field,
520
+ ...column.alt ? [column.alt] : [],
521
+ ...column.sortBy ? [column.sortBy] : []
522
+ ];
523
+ if (fields.some((field) => !this.field(rows, field, origin)))
293
524
  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);
525
+ const fail = (code, row, message, hint) => this.diagnostics.add(code, `${block.props.data}, row ${row + 1}, field ${JSON.stringify(column.field)}: ${message}`, origin.position, hint);
526
+ const kind = column.kind ?? "scalar";
527
+ if (kind === "scalar") {
528
+ const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
529
+ if (types.size > 1)
530
+ this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, origin.position);
531
+ if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
532
+ this.diagnostics.add("E_TABLE_FORMAT", `Numeric formatting/sign/semantics cannot be applied to ${JSON.stringify(column.field)}.`, origin.position);
533
+ }
534
+ let sortType;
535
+ for (const [index, row] of rows.entries()) {
536
+ const value = row[column.field];
537
+ const sort = column.sortBy ? row[column.sortBy] : undefined;
538
+ if (column.sortBy && (Array.isArray(sort) || sort === undefined)) {
539
+ fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
540
+ continue;
541
+ }
542
+ if (sort !== undefined && sort !== null) {
543
+ if (sortType && sortType !== typeof sort) {
544
+ fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
545
+ continue;
546
+ }
547
+ sortType = typeof sort;
548
+ }
549
+ if (kind === "scalar") {
550
+ prepared[index][column.field] = value;
551
+ continue;
552
+ }
553
+ let rich;
554
+ if (kind === "sparkline") {
555
+ if (value !== null && !Array.isArray(value)) {
556
+ fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
557
+ continue;
558
+ }
559
+ rich = {
560
+ kind: "sparkline",
561
+ values: value ?? [],
562
+ ...column.sortBy ? { sort } : {}
563
+ };
564
+ } else {
565
+ const alt = row[column.alt];
566
+ if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
567
+ fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
568
+ continue;
569
+ }
570
+ if (value === null) {
571
+ prepared[index][column.field] = {
572
+ kind: "image",
573
+ imageId: null,
574
+ alt: "",
575
+ ...column.sortBy ? { sort } : {}
576
+ };
577
+ continue;
578
+ }
579
+ if (typeof value !== "string" || !value.trim()) {
580
+ fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
581
+ continue;
582
+ }
583
+ try {
584
+ const image = await this.images.load(value);
585
+ rich = {
586
+ kind: "image",
587
+ imageId: image.id,
588
+ alt,
589
+ ...column.sortBy ? { sort } : {}
590
+ };
591
+ } catch (error) {
592
+ fail(error instanceof LocalFileError ? error.kind === "path" ? "E_ASSET_PATH" : "E_ASSET_READ" : "E_ASSET_IMAGE", index, `${value}: ${error instanceof Error ? error.message : "Cannot read image."}`, "Use a bounded static PNG/JPEG inside the report directory, with a matching extension.");
593
+ continue;
594
+ }
595
+ }
596
+ prepared[index][column.field] = rich;
597
+ }
299
598
  }
300
599
  block.columns = columns;
301
- block.rows = rows.map((row) => Object.fromEntries(columns.map((column) => [column.field, row[column.field]])));
600
+ block.rows = prepared;
302
601
  }
303
602
  }
304
603
 
@@ -330,7 +629,16 @@ var line = z.strictObject({
330
629
  var registry = {
331
630
  grid: {
332
631
  forms: ["container"],
333
- children: ["metric", "line", "area", "bar", "table", "callout"],
632
+ children: [
633
+ "metric",
634
+ "line",
635
+ "area",
636
+ "bar",
637
+ "donut",
638
+ "funnel",
639
+ "table",
640
+ "callout"
641
+ ],
334
642
  description: "A responsive row of one to four components.",
335
643
  schema: z.strictObject({
336
644
  columns: z.number().int().min(1).max(4).default(2)
@@ -374,6 +682,48 @@ var registry = {
374
682
  schema: z.strictObject(chart),
375
683
  example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
376
684
  },
685
+ donut: {
686
+ forms: ["leaf"],
687
+ children: [],
688
+ description: "A part-to-whole donut with a readable legend and native data table.",
689
+ schema: z.strictObject({
690
+ data: text,
691
+ category: text,
692
+ value: text,
693
+ title: text,
694
+ format: format.default("number")
695
+ }),
696
+ dataRules: {
697
+ category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
698
+ value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
699
+ maximumRows: limits.donutSlices
700
+ },
701
+ example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
702
+ },
703
+ funnel: {
704
+ forms: ["leaf"],
705
+ children: [],
706
+ description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
707
+ schema: z.strictObject({
708
+ data: text,
709
+ stage: text,
710
+ value: text,
711
+ total: text,
712
+ share: text,
713
+ status: text.optional(),
714
+ title: text,
715
+ format: format.default("number")
716
+ }),
717
+ dataRules: {
718
+ stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
719
+ value: "Finite nonnegative number no greater than its row's total.",
720
+ total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
721
+ share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
722
+ status: Object.keys(funnelStatuses),
723
+ maximumRows: limits.funnelStages
724
+ },
725
+ example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
726
+ },
377
727
  table: {
378
728
  forms: ["leaf", "container"],
379
729
  children: ["column"],
@@ -387,15 +737,23 @@ var registry = {
387
737
  column: {
388
738
  forms: ["leaf"],
389
739
  children: [],
390
- description: "An explicit field, label and format inside a table.",
740
+ description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
391
741
  schema: z.strictObject({
392
742
  field: text,
393
743
  label: text.optional(),
744
+ kind: z.enum(["scalar", "image", "sparkline"]).optional(),
745
+ alt: text.optional(),
746
+ sortBy: text.optional(),
394
747
  format: format.optional(),
395
748
  sign: z.enum(["auto", "always"]).default("auto"),
396
749
  semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
397
750
  }),
398
- example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}'
751
+ example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
752
+ dataRules: {
753
+ image: "Local static PNG/JPEG path or null. alt names a nonblank string field for each image. Fixed 64 × 48 display box; deduplicated and embedded offline. Paths resolve from the report directory.",
754
+ sparkline: "Only explicitly declared sparkline fields may contain arrays: at most 64 finite numbers/nulls, in sample order. Nulls are gaps. Shared observed y scale per column; no aggregation or resampling.",
755
+ sortBy: "Optional scalar field for image/sparkline sorting. Without it a rich column has a plain header. Scalar columns sort their own raw values. Sort keys are readable in the export."
756
+ }
399
757
  },
400
758
  callout: {
401
759
  forms: ["container"],
@@ -417,7 +775,7 @@ function componentSpec() {
417
775
  attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
418
776
  limits,
419
777
  data: {
420
- shape: "array of flat JSON records",
778
+ shape: "array of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields",
421
779
  resolution: "relative to the report directory; local .json files only",
422
780
  percent: "ratios: 0.12 displays as 12%",
423
781
  percentagePoints: "point values: 0.8 displays as 0.8 pt",
@@ -432,7 +790,8 @@ function componentSpec() {
432
790
  forms: entry.forms,
433
791
  children: entry.children,
434
792
  props: z.toJSONSchema(entry.schema),
435
- example: entry.example
793
+ example: entry.example,
794
+ ..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
436
795
  }
437
796
  ])),
438
797
  rules: [
@@ -444,7 +803,10 @@ function componentSpec() {
444
803
  "Dataset fields are literal own-property keys, not expressions.",
445
804
  "Charts preserve source order; date/number x values must increase.",
446
805
  "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."
806
+ "Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
807
+ "Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
808
+ "Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
809
+ "Filters, maps, arbitrary nested data and executable components are not supported in this release."
448
810
  ]
449
811
  };
450
812
  }
@@ -467,6 +829,27 @@ function decodeProps(name, attributes) {
467
829
  };
468
830
  const props = result.data;
469
831
  const errors = [];
832
+ if (name === "column") {
833
+ const column = props;
834
+ const kind = column.kind ?? "scalar";
835
+ if (kind === "image" && !column.alt)
836
+ errors.push("image columns require an alt field.");
837
+ if (kind !== "image" && column.alt)
838
+ errors.push("alt is only supported on image columns.");
839
+ if (kind === "image" && column.format)
840
+ errors.push("Image columns cannot use numeric formatting.");
841
+ if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
842
+ errors.push("sign and semantics apply only to scalar columns.");
843
+ if (kind === "scalar" && column.sortBy)
844
+ errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
845
+ if (column.alt === column.field || column.sortBy === column.field)
846
+ errors.push("alt and sortBy must reference fields different from the rich cell field.");
847
+ }
848
+ if (name === "donut" || name === "funnel") {
849
+ const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
850
+ if (new Set(fields).size !== fields.length)
851
+ errors.push(`${name} field properties must reference different dataset fields.`);
852
+ }
470
853
  if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
471
854
  errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
472
855
  if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
@@ -508,7 +891,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
508
891
  if (node.type === "html")
509
892
  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
893
  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.");
894
+ diagnostics.add("E_IMAGE_UNSUPPORTED", "Markdown images are not supported; use an explicit image column in a table.", node.position, 'Use ::column{field="image" kind="image" alt="description"} inside a table for local PNG/JPEG images, or use a text link.');
512
895
  if (node.type === "link" || node.type === "definition") {
513
896
  const url = node.url ?? "";
514
897
  let allowed = url.startsWith("#");
@@ -647,7 +1030,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
647
1030
 
648
1031
  // src/compiler/compile.ts
649
1032
  async function compileReport(input) {
650
- const requested = path3.resolve(input);
1033
+ const requested = path4.resolve(input);
651
1034
  const diagnostics = new Diagnostics(displayPath(requested));
652
1035
  const dependencies = new Set([requested]);
653
1036
  const result = () => ({
@@ -656,16 +1039,18 @@ async function compileReport(input) {
656
1039
  filename: requested
657
1040
  });
658
1041
  try {
659
- if (path3.extname(requested).toLowerCase() !== ".md") {
1042
+ if (path4.extname(requested).toLowerCase() !== ".md") {
660
1043
  diagnostics.add("E_REPORT_PATH", "A report must be a .md Markdown file.");
661
1044
  return result();
662
1045
  }
663
1046
  const filename = await realpath2(requested);
664
1047
  dependencies.add(filename);
665
1048
  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);
1049
+ const report = parseReport(text, path4.basename(filename, path4.extname(filename)), diagnostics);
1050
+ const resolver = new DataResolver(path4.dirname(filename), diagnostics, dependencies, bytes);
668
1051
  await resolver.resolve(report.blocks);
1052
+ if (resolver.images.embedded.size)
1053
+ report.images = [...resolver.images.embedded.values()];
669
1054
  return { ...result(), ...!diagnostics.hasErrors ? { report } : {} };
670
1055
  } catch (error) {
671
1056
  diagnostics.add("E_REPORT_READ", error instanceof Error ? error.message.replaceAll(requested, displayPath(requested)) : "Could not compile the report.");
@@ -674,7 +1059,7 @@ async function compileReport(input) {
674
1059
  }
675
1060
 
676
1061
  // src/render/html.ts
677
- import { createHash } from "node:crypto";
1062
+ import { createHash as createHash2 } from "node:crypto";
678
1063
 
679
1064
  // src/render/content.ts
680
1065
  import { unified as unified2 } from "unified";
@@ -682,6 +1067,13 @@ import remarkRehype from "remark-rehype";
682
1067
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
683
1068
  import rehypeStringify from "rehype-stringify";
684
1069
 
1070
+ // src/render/tree.ts
1071
+ var t = (value) => ({
1072
+ type: "text",
1073
+ value: String(value)
1074
+ });
1075
+ var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
1076
+
685
1077
  // src/shared/format.ts
686
1078
  var numbers = new Map;
687
1079
  function formatValue(value, format = "number", sign = "auto", compact = false) {
@@ -715,14 +1107,106 @@ function valueSentiment(value, semantics = "neutral") {
715
1107
  return value > 0 === (semantics === "higher-is-better") ? "positive" : "negative";
716
1108
  }
717
1109
 
1110
+ // src/render/table-cells.ts
1111
+ function columnScale(rows, field) {
1112
+ let min = Infinity, max = -Infinity;
1113
+ for (const row of rows) {
1114
+ const cell = row[field];
1115
+ if (!cell || typeof cell !== "object" || cell.kind !== "sparkline")
1116
+ continue;
1117
+ for (const value of cell.values)
1118
+ if (value !== null) {
1119
+ min = Math.min(min, value);
1120
+ max = Math.max(max, value);
1121
+ }
1122
+ }
1123
+ return Number.isFinite(min) ? { min, max } : undefined;
1124
+ }
1125
+ function richCell(cell, column, scale) {
1126
+ if (cell.kind === "image")
1127
+ return cell.imageId ? [
1128
+ h("svg", {
1129
+ className: ["table-image"],
1130
+ viewBox: "0 0 64 48",
1131
+ width: "64",
1132
+ height: "48",
1133
+ role: "img",
1134
+ ariaLabel: cell.alt
1135
+ }, [h("use", { href: `#${cell.imageId}` })])
1136
+ ] : [t("—")];
1137
+ const values = cell.values;
1138
+ const recorded = values.filter((value) => value !== null);
1139
+ const formatted = (value) => value == null ? "—" : formatValue(value, column.format);
1140
+ const name = column.label ?? column.field;
1141
+ const description = `${name}. ${recorded.length} of ${values.length} recorded samples. First sample ${formatted(values[0])}; last sample ${formatted(values.at(-1))}.${scale ? ` Shared column scale ${formatted(scale.min)} to ${formatted(scale.max)}.` : ""}`;
1142
+ const marks = [];
1143
+ if (scale && recorded.length) {
1144
+ const divisor = Math.max(Math.abs(scale.min), Math.abs(scale.max)) || 1;
1145
+ const low = scale.min / divisor, high = scale.max / divisor;
1146
+ const segments = [];
1147
+ let segment = [];
1148
+ for (const [index, value] of values.entries()) {
1149
+ if (value === null) {
1150
+ if (segment.length)
1151
+ segments.push(segment);
1152
+ segment = [];
1153
+ continue;
1154
+ }
1155
+ const x = values.length === 1 ? 56 : 3 + index / (values.length - 1) * 106;
1156
+ const y = high === low ? 16 : 3 + (1 - (value / divisor - low) / (high - low)) * 26;
1157
+ segment.push([x, y]);
1158
+ }
1159
+ if (segment.length)
1160
+ segments.push(segment);
1161
+ for (const points of segments) {
1162
+ if (points.length === 1)
1163
+ marks.push(h("circle", {
1164
+ cx: points[0][0].toFixed(3),
1165
+ cy: points[0][1].toFixed(3),
1166
+ r: "2"
1167
+ }));
1168
+ else
1169
+ marks.push(h("path", {
1170
+ d: points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(3)} ${y.toFixed(3)}`).join("")
1171
+ }));
1172
+ }
1173
+ }
1174
+ return [
1175
+ h("div", { className: ["sparkline-cell"] }, [
1176
+ ...marks.length ? [
1177
+ h("svg", {
1178
+ className: ["sparkline-chart"],
1179
+ viewBox: "0 0 112 32",
1180
+ width: "112",
1181
+ height: "32",
1182
+ role: "img",
1183
+ ariaLabel: description
1184
+ }, marks)
1185
+ ] : [
1186
+ h("span", { className: ["sparkline-empty"] }, [
1187
+ t("No recorded values")
1188
+ ])
1189
+ ],
1190
+ h("span", {
1191
+ className: ["sparkline-last"],
1192
+ ariaLabel: `Last sample: ${formatted(values.at(-1))}`
1193
+ }, [t(formatted(values.at(-1)))]),
1194
+ ...values.length ? [
1195
+ h("details", { className: ["sparkline-data"] }, [
1196
+ h("summary", {}, [t(`View ${values.length} values`)]),
1197
+ h("ol", { className: ["sparkline-values"] }, values.map((value) => h("li", {}, [t(value === null ? "—" : String(value))])))
1198
+ ])
1199
+ ] : []
1200
+ ])
1201
+ ];
1202
+ }
1203
+
718
1204
  // src/shared/serialize.ts
719
1205
  function serializeData(value) {
720
1206
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
721
1207
  }
722
1208
 
723
1209
  // 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
1210
  var markdown = unified2().use(remarkRehype);
727
1211
  var serializer = unified2().use(rehypeSanitize, {
728
1212
  ...defaultSchema,
@@ -732,7 +1216,12 @@ var serializer = unified2().use(rehypeSanitize, {
732
1216
  "figure",
733
1217
  "figcaption",
734
1218
  "button",
735
- "caption"
1219
+ "caption",
1220
+ "meter",
1221
+ "svg",
1222
+ "use",
1223
+ "path",
1224
+ "circle"
736
1225
  ],
737
1226
  attributes: {
738
1227
  ...defaultSchema.attributes,
@@ -751,10 +1240,20 @@ var serializer = unified2().use(rehypeSanitize, {
751
1240
  "dataRaw",
752
1241
  "dataColumns",
753
1242
  "dataSentiment",
754
- "dataTone"
1243
+ "dataTone",
1244
+ "dataStatus",
1245
+ "dataSlice"
755
1246
  ],
756
1247
  button: ["type", "disabled", "ariaLabel", "dataColumn"],
757
1248
  section: ["className", "ariaLabel"],
1249
+ meter: ["min", "max", "value", "className", "ariaLabel"],
1250
+ svg: ["className", "viewBox", "width", "height", "role", "ariaLabel"],
1251
+ use: ["href"],
1252
+ path: ["d"],
1253
+ circle: ["cx", "cy", "r"],
1254
+ ul: ["className", "role"],
1255
+ ol: ["start", "className", "role"],
1256
+ li: ["className", "dataStatus"],
758
1257
  th: ["scope", "ariaSort", "className"],
759
1258
  td: ["className", "dataRaw"]
760
1259
  }
@@ -770,11 +1269,13 @@ function cellText(value, column) {
770
1269
  return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
771
1270
  }
772
1271
  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));
1272
+ const scales = columns.map((column) => column.kind === "sparkline" ? columnScale(rows, column.field) : undefined);
1273
+ const canSort = columns.map((column) => sortable && (!column.kind || column.kind === "scalar" || Boolean(column.sortBy)));
1274
+ const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
774
1275
  const caption = h("caption", {}, [t(title)]);
775
1276
  const headers = columns.map((column, index) => {
776
1277
  const name = column.label ?? label(column.field);
777
- const content = sortable ? h("button", {
1278
+ const content = canSort[index] ? h("button", {
778
1279
  type: "button",
779
1280
  disabled: true,
780
1281
  dataColumn: index,
@@ -788,18 +1289,20 @@ function table(rows, columns, title, id, sortable = true) {
788
1289
  return h("th", {
789
1290
  scope: "col",
790
1291
  className: numeric[index] ? ["numeric"] : [],
791
- ...sortable ? { ariaSort: "none" } : {}
1292
+ ...canSort[index] ? { ariaSort: "none" } : {}
792
1293
  }, [content]);
793
1294
  });
794
1295
  const head = h("thead", {}, [h("tr", {}, headers)]);
795
1296
  const tableRows = rows.map((row) => {
796
1297
  const cells = columns.map((column, index) => {
797
1298
  const value = row[column.field] ?? null;
1299
+ const rich = value !== null && typeof value === "object";
1300
+ const raw = rich ? value.sort ?? null : value;
798
1301
  return h("td", {
799
1302
  className: numeric[index] ? ["numeric"] : [],
800
- dataRaw: serializeData(value),
1303
+ ...canSort[index] ? { dataRaw: serializeData(raw) } : {},
801
1304
  ...typeof value === "number" ? { dataSentiment: valueSentiment(value, column.semantics) } : {}
802
- }, [t(cellText(value, column))]);
1305
+ }, rich ? richCell(value, column, scales[index]) : [t(cellText(value, column))]);
803
1306
  });
804
1307
  return h("tr", {}, cells);
805
1308
  });
@@ -812,6 +1315,15 @@ function table(rows, columns, title, id, sortable = true) {
812
1315
  ])
813
1316
  ]);
814
1317
  }
1318
+ function dataDisclosure(rows, columns, title, id) {
1319
+ return h("details", { className: ["chart-data"] }, [
1320
+ h("summary", {}, [
1321
+ t("View data"),
1322
+ h("span", { className: ["visually-hidden"] }, [t(` for ${title}`)])
1323
+ ]),
1324
+ table(rows, columns, title, `${id}-data`, false)
1325
+ ]);
1326
+ }
815
1327
  function renderContent(report) {
816
1328
  let sequence = 0;
817
1329
  const payload = { charts: [] };
@@ -913,6 +1425,10 @@ function renderContent(report) {
913
1425
  ];
914
1426
  case "column":
915
1427
  return [];
1428
+ case "funnel":
1429
+ return renderFunnel(block, id);
1430
+ case "donut":
1431
+ return renderDonut(block, id);
916
1432
  case "line":
917
1433
  case "area":
918
1434
  case "bar":
@@ -920,6 +1436,156 @@ function renderContent(report) {
920
1436
  }
921
1437
  });
922
1438
  }
1439
+ function renderFunnel(block, id) {
1440
+ const { stage, value, total, share, status, title, format } = block.props;
1441
+ const rows = block.rows ?? [];
1442
+ const columns = [
1443
+ { field: stage, label: label(stage), sign: "auto", semantics: "neutral" },
1444
+ {
1445
+ field: value,
1446
+ label: label(value),
1447
+ format,
1448
+ sign: "auto",
1449
+ semantics: "neutral"
1450
+ },
1451
+ {
1452
+ field: total,
1453
+ label: label(total),
1454
+ format,
1455
+ sign: "auto",
1456
+ semantics: "neutral"
1457
+ },
1458
+ {
1459
+ field: share,
1460
+ label: "Share",
1461
+ format: "percent",
1462
+ sign: "auto",
1463
+ semantics: "neutral"
1464
+ },
1465
+ ...status ? [
1466
+ {
1467
+ field: status,
1468
+ label: "Status",
1469
+ sign: "auto",
1470
+ semantics: "neutral"
1471
+ }
1472
+ ] : []
1473
+ ];
1474
+ return [
1475
+ h("figure", { className: ["chart", "funnel"] }, [
1476
+ h("figcaption", { id: `${id}-title` }, [t(title)]),
1477
+ ...rows.length ? [
1478
+ h("ol", { className: ["funnel-stages"], role: "list" }, rows.map((row) => {
1479
+ const state = status ? row[status] : "neutral";
1480
+ const rate = formatValue(row[share], "percent");
1481
+ const amount = formatValue(row[value], format, "auto", true);
1482
+ const denominator = formatValue(row[total], format, "auto", true);
1483
+ return h("li", { className: ["funnel-stage"], dataStatus: state }, [
1484
+ h("div", { className: ["funnel-heading"] }, [
1485
+ h("span", { className: ["funnel-label"] }, [
1486
+ t(row[stage])
1487
+ ]),
1488
+ h("span", { className: ["funnel-values"] }, [
1489
+ h("strong", {}, [t(rate)]),
1490
+ h("span", {}, [t(`${amount} / ${denominator}`)])
1491
+ ])
1492
+ ]),
1493
+ h("meter", {
1494
+ min: "0",
1495
+ max: "1",
1496
+ value: String(row[share]),
1497
+ ariaLabel: `${row[stage]}: ${rate}, ${formatValue(row[value], format)} of ${formatValue(row[total], format)}${status ? `, ${funnelStatuses[state]}` : ""}`
1498
+ }, [t(rate)]),
1499
+ ...status ? [
1500
+ h("span", { className: ["funnel-status"] }, [
1501
+ t(funnelStatuses[state])
1502
+ ])
1503
+ ] : []
1504
+ ]);
1505
+ }))
1506
+ ] : [
1507
+ h("p", { className: ["empty-state"] }, [
1508
+ t("No data to display.")
1509
+ ])
1510
+ ],
1511
+ ...rows.length ? [
1512
+ dataDisclosure(rows.map((row) => status ? {
1513
+ ...row,
1514
+ [status]: funnelStatuses[row[status]]
1515
+ } : row), columns, title, id)
1516
+ ] : []
1517
+ ])
1518
+ ];
1519
+ }
1520
+ function renderDonut(block, id) {
1521
+ const { category, value, title, format } = block.props;
1522
+ const rows = block.rows ?? [];
1523
+ const total = rows.reduce((sum, row) => sum + row[value], 0);
1524
+ if (total > 0)
1525
+ payload.charts.push({
1526
+ id,
1527
+ kind: "donut",
1528
+ title,
1529
+ format,
1530
+ rows: rows.map((row) => [
1531
+ row[category],
1532
+ row[value]
1533
+ ])
1534
+ });
1535
+ return [
1536
+ h("figure", { className: ["chart", "donut"] }, [
1537
+ h("figcaption", { id: `${id}-title` }, [t(title)]),
1538
+ ...total > 0 ? [
1539
+ h("div", { className: ["donut-layout"] }, [
1540
+ h("div", {
1541
+ className: ["chart-plot", "donut-plot"],
1542
+ id,
1543
+ dataPlotleafChart: "",
1544
+ role: "img",
1545
+ ariaLabel: `${title}. Values and shares follow in the legend and data table.`
1546
+ }),
1547
+ h("ul", { className: ["donut-legend"], role: "list" }, rows.map((row, index) => h("li", {}, [
1548
+ h("span", { className: ["donut-key"] }, [
1549
+ h("span", {
1550
+ className: ["donut-swatch"],
1551
+ dataSlice: index + 1,
1552
+ ariaHidden: "true"
1553
+ }),
1554
+ h("span", {}, [t(row[category])])
1555
+ ]),
1556
+ h("span", { className: ["donut-value"] }, [
1557
+ t(formatValue(row[value], format))
1558
+ ]),
1559
+ h("span", { className: ["donut-share"] }, [
1560
+ t(formatValue(row[value] / total, "percent"))
1561
+ ])
1562
+ ])))
1563
+ ])
1564
+ ] : [
1565
+ h("p", { className: ["empty-state"] }, [
1566
+ t(rows.length ? "No non-zero values to display." : "No data to display.")
1567
+ ])
1568
+ ],
1569
+ ...rows.length ? [
1570
+ dataDisclosure(rows, [
1571
+ {
1572
+ field: category,
1573
+ label: label(category),
1574
+ sign: "auto",
1575
+ semantics: "neutral"
1576
+ },
1577
+ {
1578
+ field: value,
1579
+ label: label(value),
1580
+ format,
1581
+ sign: "auto",
1582
+ semantics: "neutral"
1583
+ }
1584
+ ], title, id)
1585
+ ] : []
1586
+ ])
1587
+ ];
1588
+ }
923
1589
  function renderChart(block, id) {
924
1590
  const { x, y, title, format } = block.props;
925
1591
  const xType = block.name === "bar" ? "category" : block.props.xType;
@@ -964,17 +1630,7 @@ function renderContent(report) {
964
1630
  t("No data to display.")
965
1631
  ])
966
1632
  ],
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
- ] : []
1633
+ ...rows.length ? [dataDisclosure(rows, columns, title, id)] : []
978
1634
  ])
979
1635
  ];
980
1636
  }
@@ -995,7 +1651,7 @@ var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replace
995
1651
  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
1652
  function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
997
1653
  const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
998
- const hashes = code.map((script) => `'sha256-${createHash("sha256").update(script).digest("base64")}'`).join(" ");
1654
+ const hashes = code.map((script) => `'sha256-${createHash2("sha256").update(script).digest("base64")}'`).join(" ");
999
1655
  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
1656
  return `<!doctype html>
1001
1657
  <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>
@@ -1003,7 +1659,8 @@ function documentHtml(title, description, body, styles, scripts, preview, payloa
1003
1659
  }
1004
1660
  function renderHtml(report, assets, preview = false, revision = 0) {
1005
1661
  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);
1662
+ const images = report.images?.length ? `<svg class="image-definitions" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><defs>${report.images.map((image) => `<symbol id="${escapeHtml(image.id)}" viewBox="0 0 64 48"><image href="data:${image.mime};base64,${image.base64}" width="64" height="48" preserveAspectRatio="xMidYMid meet"/></symbol>`).join("")}</defs></svg>` : "";
1663
+ return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, content.hasCharts ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
1007
1664
  }
1008
1665
  function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1009
1666
  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("")}`;
@@ -1011,7 +1668,7 @@ function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1011
1668
  }
1012
1669
 
1013
1670
  // src/export.ts
1014
- import path4 from "node:path";
1671
+ import path5 from "node:path";
1015
1672
  import { randomUUID } from "node:crypto";
1016
1673
  import {
1017
1674
  lstat,
@@ -1023,8 +1680,8 @@ import {
1023
1680
  rm
1024
1681
  } from "node:fs/promises";
1025
1682
  async function writeReport(filename, html, inputs) {
1026
- const destination = path4.resolve(filename);
1027
- if (path4.extname(destination).toLowerCase() !== ".html")
1683
+ const destination = path5.resolve(filename);
1684
+ if (path5.extname(destination).toLowerCase() !== ".html")
1028
1685
  throw new Error("Output must have an .html extension.");
1029
1686
  let existing;
1030
1687
  try {
@@ -1038,7 +1695,7 @@ async function writeReport(filename, html, inputs) {
1038
1695
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1039
1696
  throw error;
1040
1697
  }
1041
- let directory = path4.dirname(destination);
1698
+ let directory = path5.dirname(destination);
1042
1699
  const missing = [];
1043
1700
  while (true) {
1044
1701
  try {
@@ -1047,16 +1704,16 @@ async function writeReport(filename, html, inputs) {
1047
1704
  } catch (error) {
1048
1705
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1049
1706
  throw error;
1050
- missing.unshift(path4.basename(directory));
1051
- const parent = path4.dirname(directory);
1707
+ missing.unshift(path5.basename(directory));
1708
+ const parent = path5.dirname(directory);
1052
1709
  if (parent === directory)
1053
1710
  throw error;
1054
1711
  directory = parent;
1055
1712
  }
1056
1713
  }
1057
- const canonical = path4.join(directory, ...missing, path4.basename(destination));
1714
+ const canonical = path5.join(directory, ...missing, path5.basename(destination));
1058
1715
  for (const input of inputs) {
1059
- if (path4.resolve(input) === canonical || path4.resolve(input) === destination)
1716
+ if (path5.resolve(input) === canonical || path5.resolve(input) === destination)
1060
1717
  throw new Error("Output would overwrite a report input or dependency.");
1061
1718
  if (existing) {
1062
1719
  try {
@@ -1069,8 +1726,8 @@ async function writeReport(filename, html, inputs) {
1069
1726
  }
1070
1727
  }
1071
1728
  }
1072
- await mkdir(path4.dirname(destination), { recursive: true });
1073
- const temporary = path4.join(path4.dirname(destination), `.plotleaf-${randomUUID()}.tmp`);
1729
+ await mkdir(path5.dirname(destination), { recursive: true });
1730
+ const temporary = path5.join(path5.dirname(destination), `.plotleaf-${randomUUID()}.tmp`);
1074
1731
  try {
1075
1732
  await writeFile(temporary, html, {
1076
1733
  encoding: "utf8",
@@ -1235,10 +1892,18 @@ data: ${revision}
1235
1892
  // package.json
1236
1893
  var package_default = {
1237
1894
  name: "plotleaf",
1238
- version: "0.1.0",
1895
+ version: "0.3.0",
1239
1896
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1240
1897
  type: "module",
1241
1898
  license: "MIT",
1899
+ repository: {
1900
+ type: "git",
1901
+ url: "git+https://github.com/LoueeD/plotleaf.git"
1902
+ },
1903
+ homepage: "https://github.com/LoueeD/plotleaf#readme",
1904
+ bugs: {
1905
+ url: "https://github.com/LoueeD/plotleaf/issues"
1906
+ },
1242
1907
  bin: {
1243
1908
  plotleaf: "./dist/cli.js"
1244
1909
  },
@@ -1253,10 +1918,17 @@ var package_default = {
1253
1918
  "dist/assets/styles.css",
1254
1919
  "dist/assets/notices.txt",
1255
1920
  "schema/components.json",
1921
+ "examples/report.md",
1256
1922
  "examples/storefront/*.md",
1257
1923
  "examples/storefront/*.json",
1258
1924
  "examples/adoption/*.md",
1259
1925
  "examples/adoption/*.json",
1926
+ "examples/onboarding/*.md",
1927
+ "examples/onboarding/*.json",
1928
+ "examples/catalog/*.md",
1929
+ "examples/catalog/*.json",
1930
+ "examples/catalog/assets/*.png",
1931
+ "examples/catalog/assets/*.jpg",
1260
1932
  "skills/plotleaf/SKILL.md",
1261
1933
  "README.md",
1262
1934
  "LICENSE",
@@ -1271,10 +1943,12 @@ var package_default = {
1271
1943
  "test:browser": "node node_modules/@playwright/test/cli.js test",
1272
1944
  "test:consumer": "node tests/consumer/smoke.mjs",
1273
1945
  check: "bun run typecheck && bun run build && bun run test && bun run test:consumer",
1274
- "audit:package": "node scripts/audit-package.mjs"
1946
+ "audit:package": "node scripts/audit-package.mjs",
1947
+ dev: "bun run build && node dist/cli.js dev examples/report.md"
1275
1948
  },
1276
1949
  dependencies: {
1277
1950
  chokidar: "6.0.0",
1951
+ "image-size": "2.1.0",
1278
1952
  open: "11.0.4",
1279
1953
  "rehype-sanitize": "6.0.0",
1280
1954
  "rehype-stringify": "10.0.1",
@@ -1400,7 +2074,7 @@ async function main() {
1400
2074
  return;
1401
2075
  }
1402
2076
  const html = renderHtml(result.report, await assets());
1403
- const defaultOutput = result.filename.slice(0, -path5.extname(result.filename).length) + ".html";
2077
+ const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
1404
2078
  const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
1405
2079
  console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
1406
2080
  return;