plotleaf 0.2.0 → 0.3.1

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,18 +62,10 @@ ${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
 
69
- // src/shared/payload.ts
70
- var funnelStatuses = {
71
- "on-target": "On target",
72
- "below-target": "Below target",
73
- "at-risk": "At risk",
74
- neutral: "Neutral"
75
- };
76
-
77
69
  // src/shared/limits.ts
78
70
  var limits = {
79
71
  markdownBytes: 1024 ** 2,
@@ -85,24 +77,35 @@ var limits = {
85
77
  donutSlices: 8,
86
78
  funnelStages: 20,
87
79
  tableRows: 1000,
88
- tableColumns: 40
80
+ tableColumns: 40,
81
+ sparklinePoints: 64,
82
+ imageFiles: 64,
83
+ imageBytes: 1024 ** 2,
84
+ imageDimension: 4096,
85
+ imagePixels: 4000000
89
86
  };
90
87
 
91
- // src/compiler/data.ts
92
- 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
+ }
93
96
  function contained(root, filename) {
94
97
  const relative = path2.relative(root, filename);
95
98
  return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
96
99
  }
97
- async function readBounded(filename, maximum) {
100
+ async function readBytesBounded(filename, maximum) {
98
101
  if (!(await stat(filename)).isFile())
99
102
  throw new Error("Expected a regular file.");
100
103
  const file = await open(filename, "r");
101
104
  try {
102
- const stat = await file.stat();
103
- if (!stat.isFile())
105
+ const info = await file.stat();
106
+ if (!info.isFile())
104
107
  throw new Error("Expected a regular file.");
105
- if (stat.size > maximum)
108
+ if (info.size > maximum)
106
109
  throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
107
110
  const chunks = [];
108
111
  let bytes = 0;
@@ -116,14 +119,158 @@ async function readBounded(filename, maximum) {
116
119
  }
117
120
  if (bytes > maximum)
118
121
  throw new Error("File grew beyond the size limit while reading.");
119
- return {
120
- text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, bytes)),
121
- bytes
122
- };
122
+ return Buffer.concat(chunks, bytes);
123
123
  } finally {
124
124
  await file.close();
125
125
  }
126
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"]);
127
274
  function parseDate(value) {
128
275
  const date = value.slice(0, 10);
129
276
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
@@ -143,75 +290,73 @@ class DataResolver {
143
290
  root;
144
291
  diagnostics;
145
292
  dependencies;
146
- totalBytes;
147
293
  cache = new Map;
294
+ files;
295
+ images;
148
296
  constructor(root, diagnostics, dependencies, totalBytes = 0) {
149
297
  this.root = root;
150
298
  this.diagnostics = diagnostics;
151
299
  this.dependencies = dependencies;
152
- this.totalBytes = totalBytes;
300
+ this.files = new ReportFiles(root, dependencies, totalBytes);
301
+ this.images = new ImageResolver(this.files);
153
302
  }
154
303
  async load(reference, block) {
155
304
  const fail = (code, message, hint) => {
156
305
  this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
157
306
  return;
158
307
  };
159
- if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
160
- return fail("E_DATA_PATH", "Use a plain relative local .json path.");
161
- const candidate = path2.resolve(this.root, reference);
162
- if (!contained(this.root, candidate))
163
- return fail("E_DATA_PATH", "Reference escapes the report directory.", "Place prepared JSON beside the report or in one of its subdirectories.");
164
- if (path2.relative(this.root, candidate).split(path2.sep).some((segment) => segment.startsWith(".")))
165
- return fail("E_DATA_PATH", "Hidden files and directories are not allowed.");
166
- if (path2.extname(candidate).toLowerCase() !== ".json")
167
- return fail("E_DATA_PATH", "Datasets must be .json files.");
168
- this.dependencies.add(candidate);
169
308
  let canonical;
170
309
  try {
171
- canonical = await realpath(candidate);
172
- } catch {
173
- return fail("E_DATA_READ", "Cannot read the referenced dataset.", "Create the file at this path or correct the data property.");
174
- }
175
- if (!contained(this.root, canonical) || path2.relative(this.root, canonical).split(path2.sep).some((segment) => segment.startsWith(".")))
176
- return fail("E_DATA_PATH", "The resolved file or symlink escapes the permitted report files.");
177
- if (path2.extname(canonical).toLowerCase() !== ".json")
178
- return fail("E_DATA_PATH", "The resolved file must also have a .json extension.");
179
- this.dependencies.add(canonical);
180
- const cached = this.cache.get(canonical);
181
- if (cached)
182
- return cached;
183
- if (this.cache.size >= limits.datasets)
184
- return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
185
- let input;
186
- try {
187
- input = await readBounded(canonical, Math.min(limits.jsonBytes, limits.totalBytes - this.totalBytes));
310
+ canonical = await this.files.resolve(reference, [".json"]);
188
311
  } catch (error) {
189
- 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.");
190
313
  }
191
- this.totalBytes += input.bytes;
192
- let parsed;
193
- try {
194
- parsed = JSON.parse(input.text);
195
- } catch {
196
- return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of flat records, without comments or trailing commas.");
197
- }
198
- if (!Array.isArray(parsed))
199
- return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
200
- if (parsed.length > limits.rows)
201
- return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller aggregate dataset before rendering.");
202
- for (const [index, row] of parsed.entries()) {
203
- if (!row || typeof row !== "object" || Array.isArray(row))
204
- return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
205
- for (const [key, value] of Object.entries(row)) {
206
- if (reserved.has(key))
207
- return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
208
- if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
209
- 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.");
210
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);
211
354
  }
212
- const rows = parsed;
213
- this.cache.set(canonical, rows);
214
- 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;
215
360
  }
216
361
  async resolve(blocks) {
217
362
  for (const block of blocks) {
@@ -223,7 +368,7 @@ class DataResolver {
223
368
  if (!rows.length)
224
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");
225
370
  if (block.name === "table")
226
- this.resolveTable(block, rows);
371
+ await this.resolveTable(block, rows);
227
372
  else if (block.name === "donut" || block.name === "funnel")
228
373
  this.resolveParts(block, rows);
229
374
  else
@@ -342,7 +487,7 @@ class DataResolver {
342
487
  }
343
488
  block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
344
489
  }
345
- resolveTable(block, rows) {
490
+ async resolveTable(block, rows) {
346
491
  if (rows.length > limits.tableRows) {
347
492
  this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
348
493
  return;
@@ -358,24 +503,101 @@ class DataResolver {
358
503
  this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
359
504
  return;
360
505
  }
361
- if (new Set(columns.map((column) => column.field)).size !== columns.length)
506
+ if (new Set(columns.map((column) => column.field)).size !== columns.length) {
362
507
  this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
508
+ return;
509
+ }
363
510
  for (const [index, row] of rows.entries())
364
511
  if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
365
512
  this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
366
513
  return;
367
514
  }
515
+ const prepared = rows.map(() => ({}));
368
516
  for (const column of columns) {
369
- 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)))
370
524
  continue;
371
- const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
372
- if (types.size > 1)
373
- this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, block.position);
374
- if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
375
- 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
+ }
376
598
  }
377
599
  block.columns = columns;
378
- block.rows = rows.map((row) => Object.fromEntries(columns.map((column) => [column.field, row[column.field]])));
600
+ block.rows = prepared;
379
601
  }
380
602
  }
381
603
 
@@ -515,15 +737,23 @@ var registry = {
515
737
  column: {
516
738
  forms: ["leaf"],
517
739
  children: [],
518
- description: "An explicit field, label and format inside a table.",
740
+ description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
519
741
  schema: z.strictObject({
520
742
  field: text,
521
743
  label: text.optional(),
744
+ kind: z.enum(["scalar", "image", "sparkline"]).optional(),
745
+ alt: text.optional(),
746
+ sortBy: text.optional(),
522
747
  format: format.optional(),
523
748
  sign: z.enum(["auto", "always"]).default("auto"),
524
749
  semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
525
750
  }),
526
- 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
+ }
527
757
  },
528
758
  callout: {
529
759
  forms: ["container"],
@@ -545,7 +775,7 @@ function componentSpec() {
545
775
  attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
546
776
  limits,
547
777
  data: {
548
- 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",
549
779
  resolution: "relative to the report directory; local .json files only",
550
780
  percent: "ratios: 0.12 displays as 12%",
551
781
  percentagePoints: "point values: 0.8 displays as 0.8 pt",
@@ -575,7 +805,8 @@ function componentSpec() {
575
805
  "Line/area null y values create gaps; bar null y values are invalid.",
576
806
  "Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
577
807
  "Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
578
- "Filters, maps, images, nested data and executable components are not supported in this release."
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."
579
810
  ]
580
811
  };
581
812
  }
@@ -598,6 +829,22 @@ function decodeProps(name, attributes) {
598
829
  };
599
830
  const props = result.data;
600
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
+ }
601
848
  if (name === "donut" || name === "funnel") {
602
849
  const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
603
850
  if (new Set(fields).size !== fields.length)
@@ -644,7 +891,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
644
891
  if (node.type === "html")
645
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.");
646
893
  if (node.type === "image" || node.type === "imageReference")
647
- 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.');
648
895
  if (node.type === "link" || node.type === "definition") {
649
896
  const url = node.url ?? "";
650
897
  let allowed = url.startsWith("#");
@@ -783,7 +1030,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
783
1030
 
784
1031
  // src/compiler/compile.ts
785
1032
  async function compileReport(input) {
786
- const requested = path3.resolve(input);
1033
+ const requested = path4.resolve(input);
787
1034
  const diagnostics = new Diagnostics(displayPath(requested));
788
1035
  const dependencies = new Set([requested]);
789
1036
  const result = () => ({
@@ -792,16 +1039,18 @@ async function compileReport(input) {
792
1039
  filename: requested
793
1040
  });
794
1041
  try {
795
- if (path3.extname(requested).toLowerCase() !== ".md") {
1042
+ if (path4.extname(requested).toLowerCase() !== ".md") {
796
1043
  diagnostics.add("E_REPORT_PATH", "A report must be a .md Markdown file.");
797
1044
  return result();
798
1045
  }
799
1046
  const filename = await realpath2(requested);
800
1047
  dependencies.add(filename);
801
1048
  const { text, bytes } = await readBounded(filename, limits.markdownBytes);
802
- const report = parseReport(text, path3.basename(filename, path3.extname(filename)), diagnostics);
803
- 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);
804
1051
  await resolver.resolve(report.blocks);
1052
+ if (resolver.images.embedded.size)
1053
+ report.images = [...resolver.images.embedded.values()];
805
1054
  return { ...result(), ...!diagnostics.hasErrors ? { report } : {} };
806
1055
  } catch (error) {
807
1056
  diagnostics.add("E_REPORT_READ", error instanceof Error ? error.message.replaceAll(requested, displayPath(requested)) : "Could not compile the report.");
@@ -810,7 +1059,7 @@ async function compileReport(input) {
810
1059
  }
811
1060
 
812
1061
  // src/render/html.ts
813
- import { createHash } from "node:crypto";
1062
+ import { createHash as createHash2 } from "node:crypto";
814
1063
 
815
1064
  // src/render/content.ts
816
1065
  import { unified as unified2 } from "unified";
@@ -818,6 +1067,13 @@ import remarkRehype from "remark-rehype";
818
1067
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
819
1068
  import rehypeStringify from "rehype-stringify";
820
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
+
821
1077
  // src/shared/format.ts
822
1078
  var numbers = new Map;
823
1079
  function formatValue(value, format = "number", sign = "auto", compact = false) {
@@ -851,14 +1107,106 @@ function valueSentiment(value, semantics = "neutral") {
851
1107
  return value > 0 === (semantics === "higher-is-better") ? "positive" : "negative";
852
1108
  }
853
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
+
854
1204
  // src/shared/serialize.ts
855
1205
  function serializeData(value) {
856
1206
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
857
1207
  }
858
1208
 
859
1209
  // src/render/content.ts
860
- var t = (value) => ({ type: "text", value: String(value) });
861
- var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
862
1210
  var markdown = unified2().use(remarkRehype);
863
1211
  var serializer = unified2().use(rehypeSanitize, {
864
1212
  ...defaultSchema,
@@ -869,7 +1217,11 @@ var serializer = unified2().use(rehypeSanitize, {
869
1217
  "figcaption",
870
1218
  "button",
871
1219
  "caption",
872
- "meter"
1220
+ "meter",
1221
+ "svg",
1222
+ "use",
1223
+ "path",
1224
+ "circle"
873
1225
  ],
874
1226
  attributes: {
875
1227
  ...defaultSchema.attributes,
@@ -895,6 +1247,10 @@ var serializer = unified2().use(rehypeSanitize, {
895
1247
  button: ["type", "disabled", "ariaLabel", "dataColumn"],
896
1248
  section: ["className", "ariaLabel"],
897
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"],
898
1254
  ul: ["className", "role"],
899
1255
  ol: ["start", "className", "role"],
900
1256
  li: ["className", "dataStatus"],
@@ -913,11 +1269,13 @@ function cellText(value, column) {
913
1269
  return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
914
1270
  }
915
1271
  function table(rows, columns, title, id, sortable = true) {
916
- 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));
917
1275
  const caption = h("caption", {}, [t(title)]);
918
1276
  const headers = columns.map((column, index) => {
919
1277
  const name = column.label ?? label(column.field);
920
- const content = sortable ? h("button", {
1278
+ const content = canSort[index] ? h("button", {
921
1279
  type: "button",
922
1280
  disabled: true,
923
1281
  dataColumn: index,
@@ -931,18 +1289,20 @@ function table(rows, columns, title, id, sortable = true) {
931
1289
  return h("th", {
932
1290
  scope: "col",
933
1291
  className: numeric[index] ? ["numeric"] : [],
934
- ...sortable ? { ariaSort: "none" } : {}
1292
+ ...canSort[index] ? { ariaSort: "none" } : {}
935
1293
  }, [content]);
936
1294
  });
937
1295
  const head = h("thead", {}, [h("tr", {}, headers)]);
938
1296
  const tableRows = rows.map((row) => {
939
1297
  const cells = columns.map((column, index) => {
940
1298
  const value = row[column.field] ?? null;
1299
+ const rich = value !== null && typeof value === "object";
1300
+ const raw = rich ? value.sort ?? null : value;
941
1301
  return h("td", {
942
1302
  className: numeric[index] ? ["numeric"] : [],
943
- dataRaw: serializeData(value),
1303
+ ...canSort[index] ? { dataRaw: serializeData(raw) } : {},
944
1304
  ...typeof value === "number" ? { dataSentiment: valueSentiment(value, column.semantics) } : {}
945
- }, [t(cellText(value, column))]);
1305
+ }, rich ? richCell(value, column, scales[index]) : [t(cellText(value, column))]);
946
1306
  });
947
1307
  return h("tr", {}, cells);
948
1308
  });
@@ -1291,7 +1651,7 @@ var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replace
1291
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});})();`;
1292
1652
  function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
1293
1653
  const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
1294
- 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(" ");
1295
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'`;
1296
1656
  return `<!doctype html>
1297
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>
@@ -1299,7 +1659,8 @@ function documentHtml(title, description, body, styles, scripts, preview, payloa
1299
1659
  }
1300
1660
  function renderHtml(report, assets, preview = false, revision = 0) {
1301
1661
  const content = renderContent(report);
1302
- 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);
1303
1664
  }
1304
1665
  function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1305
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("")}`;
@@ -1307,7 +1668,7 @@ function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1307
1668
  }
1308
1669
 
1309
1670
  // src/export.ts
1310
- import path4 from "node:path";
1671
+ import path5 from "node:path";
1311
1672
  import { randomUUID } from "node:crypto";
1312
1673
  import {
1313
1674
  lstat,
@@ -1319,8 +1680,8 @@ import {
1319
1680
  rm
1320
1681
  } from "node:fs/promises";
1321
1682
  async function writeReport(filename, html, inputs) {
1322
- const destination = path4.resolve(filename);
1323
- if (path4.extname(destination).toLowerCase() !== ".html")
1683
+ const destination = path5.resolve(filename);
1684
+ if (path5.extname(destination).toLowerCase() !== ".html")
1324
1685
  throw new Error("Output must have an .html extension.");
1325
1686
  let existing;
1326
1687
  try {
@@ -1334,7 +1695,7 @@ async function writeReport(filename, html, inputs) {
1334
1695
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1335
1696
  throw error;
1336
1697
  }
1337
- let directory = path4.dirname(destination);
1698
+ let directory = path5.dirname(destination);
1338
1699
  const missing = [];
1339
1700
  while (true) {
1340
1701
  try {
@@ -1343,16 +1704,16 @@ async function writeReport(filename, html, inputs) {
1343
1704
  } catch (error) {
1344
1705
  if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
1345
1706
  throw error;
1346
- missing.unshift(path4.basename(directory));
1347
- const parent = path4.dirname(directory);
1707
+ missing.unshift(path5.basename(directory));
1708
+ const parent = path5.dirname(directory);
1348
1709
  if (parent === directory)
1349
1710
  throw error;
1350
1711
  directory = parent;
1351
1712
  }
1352
1713
  }
1353
- const canonical = path4.join(directory, ...missing, path4.basename(destination));
1714
+ const canonical = path5.join(directory, ...missing, path5.basename(destination));
1354
1715
  for (const input of inputs) {
1355
- if (path4.resolve(input) === canonical || path4.resolve(input) === destination)
1716
+ if (path5.resolve(input) === canonical || path5.resolve(input) === destination)
1356
1717
  throw new Error("Output would overwrite a report input or dependency.");
1357
1718
  if (existing) {
1358
1719
  try {
@@ -1365,8 +1726,8 @@ async function writeReport(filename, html, inputs) {
1365
1726
  }
1366
1727
  }
1367
1728
  }
1368
- await mkdir(path4.dirname(destination), { recursive: true });
1369
- 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`);
1370
1731
  try {
1371
1732
  await writeFile(temporary, html, {
1372
1733
  encoding: "utf8",
@@ -1531,7 +1892,7 @@ data: ${revision}
1531
1892
  // package.json
1532
1893
  var package_default = {
1533
1894
  name: "plotleaf",
1534
- version: "0.2.0",
1895
+ version: "0.3.1",
1535
1896
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1536
1897
  type: "module",
1537
1898
  license: "MIT",
@@ -1557,12 +1918,17 @@ var package_default = {
1557
1918
  "dist/assets/styles.css",
1558
1919
  "dist/assets/notices.txt",
1559
1920
  "schema/components.json",
1921
+ "examples/report.md",
1560
1922
  "examples/storefront/*.md",
1561
1923
  "examples/storefront/*.json",
1562
1924
  "examples/adoption/*.md",
1563
1925
  "examples/adoption/*.json",
1564
1926
  "examples/onboarding/*.md",
1565
1927
  "examples/onboarding/*.json",
1928
+ "examples/catalog/*.md",
1929
+ "examples/catalog/*.json",
1930
+ "examples/catalog/assets/*.png",
1931
+ "examples/catalog/assets/*.jpg",
1566
1932
  "skills/plotleaf/SKILL.md",
1567
1933
  "README.md",
1568
1934
  "LICENSE",
@@ -1577,10 +1943,12 @@ var package_default = {
1577
1943
  "test:browser": "node node_modules/@playwright/test/cli.js test",
1578
1944
  "test:consumer": "node tests/consumer/smoke.mjs",
1579
1945
  check: "bun run typecheck && bun run build && bun run test && bun run test:consumer",
1580
- "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"
1581
1948
  },
1582
1949
  dependencies: {
1583
1950
  chokidar: "6.0.0",
1951
+ "image-size": "2.1.0",
1584
1952
  open: "11.0.4",
1585
1953
  "rehype-sanitize": "6.0.0",
1586
1954
  "rehype-stringify": "10.0.1",
@@ -1620,6 +1988,7 @@ Usage:
1620
1988
  npx plotleaf dev <report.md> [--no-open] [--port 3000]
1621
1989
  npx plotleaf validate <report.md> [--json] Validate syntax and local data
1622
1990
  npx plotleaf build <report.md> [--out report.html]
1991
+ npx plotleaf guide Read the report-authoring guide
1623
1992
  npx plotleaf schema Print component specifications
1624
1993
 
1625
1994
  Options:
@@ -1660,14 +2029,17 @@ async function main() {
1660
2029
  return;
1661
2030
  }
1662
2031
  const first = positionals[0];
1663
- const known = ["dev", "validate", "build", "schema"];
2032
+ const known = ["dev", "validate", "build", "schema", "guide"];
1664
2033
  const command = known.includes(first) ? first : "dev";
1665
2034
  const filename = known.includes(first) ? positionals[1] : first;
1666
2035
  const extras = positionals.slice(known.includes(first) ? 2 : 1);
1667
- if (command === "schema") {
2036
+ if (command === "schema" || command === "guide") {
1668
2037
  if (positionals.length !== 1 || Object.keys(values).length)
1669
- throw new Error("schema does not accept a report or options. Use plotleaf schema.");
1670
- console.log(JSON.stringify(componentSpec(), null, 2));
2038
+ throw new Error(`${command} does not accept a report or options. Use plotleaf ${command}.`);
2039
+ if (command === "guide")
2040
+ process.stdout.write(await readFile(new URL("../skills/plotleaf/SKILL.md", import.meta.url), "utf8"));
2041
+ else
2042
+ console.log(JSON.stringify(componentSpec(), null, 2));
1671
2043
  return;
1672
2044
  }
1673
2045
  if (!filename || extras.length)
@@ -1706,7 +2078,7 @@ async function main() {
1706
2078
  return;
1707
2079
  }
1708
2080
  const html = renderHtml(result.report, await assets());
1709
- const defaultOutput = result.filename.slice(0, -path5.extname(result.filename).length) + ".html";
2081
+ const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
1710
2082
  const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
1711
2083
  console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
1712
2084
  return;