plotleaf 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -4,39 +4,24 @@ Beautiful, interactive reports from Markdown and prepared JSON. Built for coding
4
4
 
5
5
  **The agent does the thinking. We do the rendering.**
6
6
 
7
- [Source](https://github.com/LoueeD/plotleaf) · [Issues](https://github.com/LoueeD/plotleaf/issues) · [Verification](https://github.com/LoueeD/plotleaf/actions/workflows/ci.yml) · [Contributing](https://github.com/LoueeD/plotleaf/blob/main/CONTRIBUTING.md)
7
+ [Website](https://plotleaf-site.loueed.workers.dev) · [Docs](https://plotleaf-site.loueed.workers.dev/docs/) · [Examples](https://plotleaf-site.loueed.workers.dev/examples/) · [Playground](https://plotleaf-site.loueed.workers.dev/playground/) · [Source](https://github.com/LoueeD/plotleaf) · [Issues](https://github.com/LoueeD/plotleaf/issues) · [Verification](https://github.com/LoueeD/plotleaf/actions/workflows/ci.yml) · [Contributing](https://github.com/LoueeD/plotleaf/blob/main/CONTRIBUTING.md)
8
8
 
9
9
  A report is one Markdown file, optionally accompanied by local JSON datasets. No application, configuration file, account, database or hosted service. Export one HTML file that keeps working offline.
10
10
 
11
11
  ## Start with your coding agent
12
12
 
13
- Copy this into your agent and replace the report brief:
13
+ Copy this into your coding agent and describe your report:
14
14
 
15
15
  ```text
16
- Create a polished, interactive report with plotleaf.
17
-
18
- Report brief: [Describe the report and point to the data you want to use.]
19
-
20
- Use Node 22+ and npx; no app scaffolding, Bun or account is needed.
21
- First run `npx --yes plotleaf --help`. If it lists `guide`, read
22
- `npx --yes plotleaf guide`. Otherwise read the authoring guide at:
23
- https://raw.githubusercontent.com/LoueeD/plotleaf/v0.3.0/skills/plotleaf/SKILL.md
24
- Then run `npx --yes plotleaf schema` for the installed component API.
25
-
26
- Use the data I provide; don't invent values or findings. If the brief or
27
- data is missing, ask for what's needed. Prepare one report.md and any local
28
- JSON/assets it needs, following the guide and installed schema.
29
-
30
- Run `npx --yes plotleaf validate report.md --json` and fix all errors.
31
- Start `npx --yes plotleaf dev report.md --no-open` and give me the preview URL.
32
- Build `npx --yes plotleaf build report.md --out report.html` and give me
33
- the source and portable HTML paths, with any data limitations.
16
+ Build a beautiful, interactive report with Plotleaf.
17
+ Here's what I want to explore: [Your brief and data]
18
+ Follow https://plotleaf-site.loueed.workers.dev/agent and give me a preview and a standalone HTML report.
34
19
  ```
35
20
 
36
- The `guide` command is available in **0.3.1 or newer**. The prompt also works with 0.3.0 through the linked authoring guide.
37
-
38
21
  ## Status
39
22
 
23
+ **0.3.2** adds the website links and updated agent setup prompt. Read the [docs](https://plotleaf-site.loueed.workers.dev/docs/), explore [example reports](https://plotleaf-site.loueed.workers.dev/examples/), or try the [browser playground](https://plotleaf-site.loueed.workers.dev/playground/) without installing anything. The playground supports Markdown and JSON; local image imports and larger reports use the CLI.
24
+
40
25
  The initial MVP includes validation, live preview/reload, self-contained HTML export and a generated component specification. All bundled examples use fictional data.
41
26
 
42
27
  **0.3.0** adds local PNG/JPEG image cells, static sparklines and a cumulative example report. Donut charts and prepared funnel/progress views are available since 0.2.0. Check your installed CLI version or schema before using newer components.
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { realpath as realpath2 } from "node:fs/promises";
12
12
  // src/compiler/diagnostics.ts
13
13
  import path from "node:path";
14
14
 
15
+ // src/compiler/diagnostic-core.ts
15
16
  class Diagnostics {
16
17
  file;
17
18
  items = [];
@@ -51,9 +52,6 @@ class Diagnostics {
51
52
  return [...this.items].sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line || a.column - b.column || a.code.localeCompare(b.code, "en"));
52
53
  }
53
54
  }
54
- function displayPath(filename) {
55
- return path.relative(process.cwd(), filename) || path.basename(filename);
56
- }
57
55
  function humanDiagnostics(items) {
58
56
  return items.map((d) => `${d.file}:${d.line}:${d.column} ${d.code}${d.severity === "warning" ? " (warning)" : ""}
59
57
  ${d.message}${d.hint ? `
@@ -62,30 +60,12 @@ ${d.hint}` : ""}`).join(`
62
60
  `);
63
61
  }
64
62
 
65
- // src/compiler/files.ts
66
- import path2 from "node:path";
67
- import { open, realpath, stat } from "node:fs/promises";
68
-
69
- // src/shared/limits.ts
70
- var limits = {
71
- markdownBytes: 1024 ** 2,
72
- jsonBytes: 10 * 1024 ** 2,
73
- totalBytes: 25 * 1024 ** 2,
74
- datasets: 64,
75
- rows: 1e5,
76
- chartRows: 1e4,
77
- donutSlices: 8,
78
- funnelStages: 20,
79
- tableRows: 1000,
80
- tableColumns: 40,
81
- sparklinePoints: 64,
82
- imageFiles: 64,
83
- imageBytes: 1024 ** 2,
84
- imageDimension: 4096,
85
- imagePixels: 4000000
86
- };
63
+ // src/compiler/diagnostics.ts
64
+ function displayPath(filename) {
65
+ return path.relative(process.cwd(), filename) || path.basename(filename);
66
+ }
87
67
 
88
- // src/compiler/files.ts
68
+ // src/compiler/sources.ts
89
69
  class LocalFileError extends Error {
90
70
  kind;
91
71
  constructor(kind, message) {
@@ -93,184 +73,8 @@ class LocalFileError extends Error {
93
73
  this.kind = kind;
94
74
  }
95
75
  }
96
- function contained(root, filename) {
97
- const relative = path2.relative(root, filename);
98
- return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
99
- }
100
- async function readBytesBounded(filename, maximum) {
101
- if (!(await stat(filename)).isFile())
102
- throw new Error("Expected a regular file.");
103
- const file = await open(filename, "r");
104
- try {
105
- const info = await file.stat();
106
- if (!info.isFile())
107
- throw new Error("Expected a regular file.");
108
- if (info.size > maximum)
109
- throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
110
- const chunks = [];
111
- let bytes = 0;
112
- while (bytes <= maximum) {
113
- const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
114
- const result = await file.read(buffer, 0, buffer.length, null);
115
- if (!result.bytesRead)
116
- break;
117
- chunks.push(buffer.subarray(0, result.bytesRead));
118
- bytes += result.bytesRead;
119
- }
120
- if (bytes > maximum)
121
- throw new Error("File grew beyond the size limit while reading.");
122
- return Buffer.concat(chunks, bytes);
123
- } finally {
124
- await file.close();
125
- }
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
76
 
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"]);
77
+ // src/shared/date.ts
274
78
  function parseDate(value) {
275
79
  const date = value.slice(0, 10);
276
80
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
@@ -286,19 +90,45 @@ function parseDate(value) {
286
90
  return Number.isFinite(timestamp) ? timestamp : undefined;
287
91
  }
288
92
 
93
+ // src/shared/payload.ts
94
+ var funnelStatuses = {
95
+ "on-target": "On target",
96
+ "below-target": "Below target",
97
+ "at-risk": "At risk",
98
+ neutral: "Neutral"
99
+ };
100
+
101
+ // src/shared/limits.ts
102
+ var limits = {
103
+ markdownBytes: 1024 ** 2,
104
+ jsonBytes: 10 * 1024 ** 2,
105
+ totalBytes: 25 * 1024 ** 2,
106
+ datasets: 64,
107
+ rows: 1e5,
108
+ chartRows: 1e4,
109
+ donutSlices: 8,
110
+ funnelStages: 20,
111
+ tableRows: 1000,
112
+ tableColumns: 40,
113
+ sparklinePoints: 64,
114
+ imageFiles: 64,
115
+ imageBytes: 1024 ** 2,
116
+ imageDimension: 4096,
117
+ imagePixels: 4000000
118
+ };
119
+
120
+ // src/compiler/resolve-data.ts
121
+ var reserved = new Set(["__proto__", "constructor", "prototype"]);
122
+
289
123
  class DataResolver {
290
- root;
291
124
  diagnostics;
292
- dependencies;
293
- cache = new Map;
294
125
  files;
295
126
  images;
296
- constructor(root, diagnostics, dependencies, totalBytes = 0) {
297
- this.root = root;
127
+ cache = new Map;
128
+ constructor(diagnostics, files, images) {
298
129
  this.diagnostics = diagnostics;
299
- this.dependencies = dependencies;
300
- this.files = new ReportFiles(root, dependencies, totalBytes);
301
- this.images = new ImageResolver(this.files);
130
+ this.files = files;
131
+ this.images = images;
302
132
  }
303
133
  async load(reference, block) {
304
134
  const fail = (code, message, hint) => {
@@ -601,6 +431,188 @@ class DataResolver {
601
431
  }
602
432
  }
603
433
 
434
+ // src/compiler/files.ts
435
+ import path2 from "node:path";
436
+ import { open, realpath, stat } from "node:fs/promises";
437
+ function contained(root, filename) {
438
+ const relative = path2.relative(root, filename);
439
+ return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
440
+ }
441
+ async function readBytesBounded(filename, maximum) {
442
+ if (!(await stat(filename)).isFile())
443
+ throw new Error("Expected a regular file.");
444
+ const file = await open(filename, "r");
445
+ try {
446
+ const info = await file.stat();
447
+ if (!info.isFile())
448
+ throw new Error("Expected a regular file.");
449
+ if (info.size > maximum)
450
+ throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
451
+ const chunks = [];
452
+ let bytes = 0;
453
+ while (bytes <= maximum) {
454
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
455
+ const result = await file.read(buffer, 0, buffer.length, null);
456
+ if (!result.bytesRead)
457
+ break;
458
+ chunks.push(buffer.subarray(0, result.bytesRead));
459
+ bytes += result.bytesRead;
460
+ }
461
+ if (bytes > maximum)
462
+ throw new Error("File grew beyond the size limit while reading.");
463
+ return Buffer.concat(chunks, bytes);
464
+ } finally {
465
+ await file.close();
466
+ }
467
+ }
468
+ async function readBounded(filename, maximum) {
469
+ const bytes = await readBytesBounded(filename, maximum);
470
+ return {
471
+ text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
472
+ bytes: bytes.length
473
+ };
474
+ }
475
+
476
+ class ReportFiles {
477
+ root;
478
+ dependencies;
479
+ usedBytes;
480
+ constructor(root, dependencies, usedBytes) {
481
+ this.root = root;
482
+ this.dependencies = dependencies;
483
+ this.usedBytes = usedBytes;
484
+ }
485
+ async resolve(reference, extensions) {
486
+ const fail = (message) => {
487
+ throw new LocalFileError("path", message);
488
+ };
489
+ if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
490
+ fail(`Use a plain relative local ${extensions.join("/")} path.`);
491
+ const candidate = path2.resolve(this.root, reference);
492
+ const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
493
+ if (!contained(this.root, candidate))
494
+ fail("Reference escapes the report directory.");
495
+ if (hidden(candidate))
496
+ fail("Hidden files and directories are not allowed.");
497
+ if (!extensions.includes(path2.extname(candidate).toLowerCase()))
498
+ fail(`Use a ${extensions.join("/")} file.`);
499
+ this.dependencies.add(candidate);
500
+ let canonical;
501
+ try {
502
+ canonical = await realpath(candidate);
503
+ } catch {
504
+ throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
505
+ }
506
+ if (!contained(this.root, canonical) || hidden(canonical))
507
+ fail("The resolved file or symlink escapes the permitted report files.");
508
+ if (!extensions.includes(path2.extname(canonical).toLowerCase()))
509
+ fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
510
+ this.dependencies.add(canonical);
511
+ return canonical;
512
+ }
513
+ async read(filename, maximum) {
514
+ const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
515
+ this.usedBytes += bytes.length;
516
+ return bytes;
517
+ }
518
+ }
519
+
520
+ // src/compiler/images.ts
521
+ import { createHash } from "node:crypto";
522
+ import path3 from "node:path";
523
+ import { imageSize } from "image-size";
524
+ function formatOf(bytes) {
525
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
526
+ let offset = 8;
527
+ let data = false;
528
+ while (offset + 12 <= bytes.length) {
529
+ const size = bytes.readUInt32BE(offset);
530
+ const type = bytes.toString("ascii", offset + 4, offset + 8);
531
+ if (offset + size + 12 > bytes.length)
532
+ break;
533
+ if (offset === 8 && (type !== "IHDR" || size !== 13))
534
+ break;
535
+ if (type === "acTL")
536
+ throw new Error("Animated PNG is not supported; provide a static image.");
537
+ if (type === "IDAT" && size > 0)
538
+ data = true;
539
+ offset += size + 12;
540
+ if (type === "IEND") {
541
+ if (data && size === 0 && offset === bytes.length)
542
+ return "png";
543
+ break;
544
+ }
545
+ }
546
+ throw new Error("Incomplete or invalid PNG container.");
547
+ }
548
+ if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
549
+ return "jpg";
550
+ throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
551
+ }
552
+
553
+ class ImageResolver {
554
+ files;
555
+ attempted = new Set;
556
+ filesRead = new Map;
557
+ embedded = new Map;
558
+ constructor(files) {
559
+ this.files = files;
560
+ }
561
+ async load(reference) {
562
+ const requested = path3.resolve(this.files.root, reference);
563
+ if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
564
+ throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
565
+ this.attempted.add(requested);
566
+ const canonical = await this.files.resolve(reference, [
567
+ ".png",
568
+ ".jpg",
569
+ ".jpeg"
570
+ ]);
571
+ const cached = this.filesRead.get(canonical);
572
+ if (cached) {
573
+ if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
574
+ throw new Error("Image signature does not match its file extension.");
575
+ return cached;
576
+ }
577
+ if (this.filesRead.size >= limits.imageFiles)
578
+ throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
579
+ const bytes = await this.files.read(canonical, limits.imageBytes);
580
+ const format = formatOf(bytes);
581
+ const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
582
+ if (format !== expected(reference) || format !== expected(canonical))
583
+ throw new Error("Image signature does not match its file extension.");
584
+ let size;
585
+ try {
586
+ size = imageSize(bytes);
587
+ } catch {
588
+ throw new Error("Unsupported or damaged PNG/JPEG header.");
589
+ }
590
+ if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
591
+ throw new Error("Image must have valid positive raster dimensions.");
592
+ if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
593
+ throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
594
+ const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
595
+ const image = this.embedded.get(id) ?? {
596
+ id,
597
+ mime: format === "png" ? "image/png" : "image/jpeg",
598
+ base64: bytes.toString("base64")
599
+ };
600
+ this.filesRead.set(canonical, image);
601
+ this.embedded.set(id, image);
602
+ return image;
603
+ }
604
+ }
605
+
606
+ // src/compiler/data.ts
607
+ class DataResolver2 extends DataResolver {
608
+ root;
609
+ constructor(root, diagnostics, dependencies, totalBytes = 0) {
610
+ const files = new ReportFiles(root, dependencies, totalBytes);
611
+ super(diagnostics, files, new ImageResolver(files));
612
+ this.root = root;
613
+ }
614
+ }
615
+
604
616
  // src/compiler/parse.ts
605
617
  import { unified } from "unified";
606
618
  import remarkParse from "remark-parse";
@@ -1047,7 +1059,7 @@ async function compileReport(input) {
1047
1059
  dependencies.add(filename);
1048
1060
  const { text, bytes } = await readBounded(filename, limits.markdownBytes);
1049
1061
  const report = parseReport(text, path4.basename(filename, path4.extname(filename)), diagnostics);
1050
- const resolver = new DataResolver(path4.dirname(filename), diagnostics, dependencies, bytes);
1062
+ const resolver = new DataResolver2(path4.dirname(filename), diagnostics, dependencies, bytes);
1051
1063
  await resolver.resolve(report.blocks);
1052
1064
  if (resolver.images.embedded.size)
1053
1065
  report.images = [...resolver.images.embedded.values()];
@@ -1645,26 +1657,35 @@ function renderContent(report) {
1645
1657
  };
1646
1658
  }
1647
1659
 
1648
- // src/render/html.ts
1660
+ // src/render/document.ts
1649
1661
  var escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1650
1662
  var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replaceAll("<!--", "\\x3c!--");
1651
1663
  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});})();`;
1652
- function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
1664
+ function documentHtml(title, description, body, styles, scripts, preview, hash, payload, notices = "", revision = 0) {
1653
1665
  const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
1654
- const hashes = code.map((script) => `'sha256-${createHash2("sha256").update(script).digest("base64")}'`).join(" ");
1666
+ const hashes = code.map((script) => `'sha256-${hash(script)}'`).join(" ");
1655
1667
  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'`;
1656
1668
  return `<!doctype html>
1657
1669
  <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>
1658
1670
  `;
1659
1671
  }
1660
- function renderHtml(report, assets, preview = false, revision = 0) {
1672
+ function renderHtml(report, assets, hash, preview = false, revision = 0) {
1661
1673
  const content = renderContent(report);
1662
1674
  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);
1675
+ return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, hash, content.hasCharts ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
1664
1676
  }
1665
- function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
1677
+ function renderDiagnostics(diagnostics, assets, hash, preview = true, revision = 0) {
1666
1678
  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("")}`;
1667
- return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, undefined, "", revision);
1679
+ return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, hash, undefined, "", revision);
1680
+ }
1681
+
1682
+ // src/render/html.ts
1683
+ var hash = (script) => createHash2("sha256").update(script).digest("base64");
1684
+ function renderHtml2(report, assets, preview = false, revision = 0) {
1685
+ return renderHtml(report, assets, hash, preview, revision);
1686
+ }
1687
+ function renderDiagnostics2(diagnostics, assets, preview = true, revision = 0) {
1688
+ return renderDiagnostics(diagnostics, assets, hash, preview, revision);
1668
1689
  }
1669
1690
 
1670
1691
  // src/export.ts
@@ -1749,7 +1770,7 @@ async function startPreview(filename, assets, options = {}) {
1749
1770
  const log = options.log ?? console.log;
1750
1771
  let compilation = options.initial ?? await compileReport(filename);
1751
1772
  let revision = 0;
1752
- const render = () => compilation.report ? renderHtml(compilation.report, assets, true, revision) : renderDiagnostics(compilation.diagnostics, assets, true, revision);
1773
+ const render = () => compilation.report ? renderHtml2(compilation.report, assets, true, revision) : renderDiagnostics2(compilation.diagnostics, assets, true, revision);
1753
1774
  let html = render();
1754
1775
  let closed = false;
1755
1776
  let running = false;
@@ -1892,7 +1913,7 @@ data: ${revision}
1892
1913
  // package.json
1893
1914
  var package_default = {
1894
1915
  name: "plotleaf",
1895
- version: "0.3.1",
1916
+ version: "0.3.2",
1896
1917
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1897
1918
  type: "module",
1898
1919
  license: "MIT",
@@ -1900,7 +1921,7 @@ var package_default = {
1900
1921
  type: "git",
1901
1922
  url: "git+https://github.com/LoueeD/plotleaf.git"
1902
1923
  },
1903
- homepage: "https://github.com/LoueeD/plotleaf#readme",
1924
+ homepage: "https://plotleaf-site.loueed.workers.dev",
1904
1925
  bugs: {
1905
1926
  url: "https://github.com/LoueeD/plotleaf/issues"
1906
1927
  },
@@ -2077,7 +2098,7 @@ async function main() {
2077
2098
  process.exitCode = 1;
2078
2099
  return;
2079
2100
  }
2080
- const html = renderHtml(result.report, await assets());
2101
+ const html = renderHtml2(result.report, await assets());
2081
2102
  const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
2082
2103
  const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
2083
2104
  console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plotleaf",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/LoueeD/plotleaf.git"
10
10
  },
11
- "homepage": "https://github.com/LoueeD/plotleaf#readme",
11
+ "homepage": "https://plotleaf-site.loueed.workers.dev",
12
12
  "bugs": {
13
13
  "url": "https://github.com/LoueeD/plotleaf/issues"
14
14
  },
@@ -7,7 +7,7 @@ description: Create and revise plotleaf Markdown reports from prepared JSON, the
7
7
 
8
8
  Create one Markdown file with optional local JSON datasets. The agent retrieves, analyses and prepares data; plotleaf renders it. Do not scaffold an application or put analysis code, SQL, JavaScript expressions or scripts into the report.
9
9
 
10
- Use an installed `plotleaf` CLI or `npx plotleaf`. In an unpublished source checkout, build it with `bun run build` and use `node dist/cli.js` from the package root instead of fetching an npm release.
10
+ Use an installed `plotleaf` CLI or `npx plotleaf`. The CLI requires Node 22 or newer; Bun is not required for report authors. In an unpublished source checkout, build it with `bun run build` and use `node dist/cli.js` from the package root instead of fetching an npm release.
11
11
 
12
12
  ## Discover the contract
13
13