plotleaf 0.3.0 → 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,12 +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
+ ## Start with your coding agent
12
+
13
+ Copy this into your coding agent and describe your report:
14
+
15
+ ```text
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.
19
+ ```
20
+
11
21
  ## Status
12
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
+
13
25
  The initial MVP includes validation, live preview/reload, self-contained HTML export and a generated component specification. All bundled examples use fictional data.
14
26
 
15
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.
@@ -30,6 +42,8 @@ npx plotleaf schema
30
42
 
31
43
  `plotleaf report.md` is an alias for `dev`. `build` defaults to `report.html` beside the source. Relative dataset paths resolve from the Markdown file's directory; an explicit `--out` resolves from the caller's working directory. Exports contain no preview connection and need no source files at viewing time.
32
44
 
45
+ For agent discovery, `npx plotleaf guide` (0.3.1+) prints the bundled report-authoring guide as Markdown, and `npx plotleaf schema` prints the component specification as JSON. The guide is the same `skills/plotleaf/SKILL.md` shipped with the package. These commands print documentation without creating files, changing project configuration or starting a server; once installed, they read local package content.
46
+
33
47
  ## A report
34
48
 
35
49
  ```markdown
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
76
 
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"]);
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.0",
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
  },
@@ -1988,6 +2009,7 @@ Usage:
1988
2009
  npx plotleaf dev <report.md> [--no-open] [--port 3000]
1989
2010
  npx plotleaf validate <report.md> [--json] Validate syntax and local data
1990
2011
  npx plotleaf build <report.md> [--out report.html]
2012
+ npx plotleaf guide Read the report-authoring guide
1991
2013
  npx plotleaf schema Print component specifications
1992
2014
 
1993
2015
  Options:
@@ -2028,14 +2050,17 @@ async function main() {
2028
2050
  return;
2029
2051
  }
2030
2052
  const first = positionals[0];
2031
- const known = ["dev", "validate", "build", "schema"];
2053
+ const known = ["dev", "validate", "build", "schema", "guide"];
2032
2054
  const command = known.includes(first) ? first : "dev";
2033
2055
  const filename = known.includes(first) ? positionals[1] : first;
2034
2056
  const extras = positionals.slice(known.includes(first) ? 2 : 1);
2035
- if (command === "schema") {
2057
+ if (command === "schema" || command === "guide") {
2036
2058
  if (positionals.length !== 1 || Object.keys(values).length)
2037
- throw new Error("schema does not accept a report or options. Use plotleaf schema.");
2038
- console.log(JSON.stringify(componentSpec(), null, 2));
2059
+ throw new Error(`${command} does not accept a report or options. Use plotleaf ${command}.`);
2060
+ if (command === "guide")
2061
+ process.stdout.write(await readFile(new URL("../skills/plotleaf/SKILL.md", import.meta.url), "utf8"));
2062
+ else
2063
+ console.log(JSON.stringify(componentSpec(), null, 2));
2039
2064
  return;
2040
2065
  }
2041
2066
  if (!filename || extras.length)
@@ -2073,7 +2098,7 @@ async function main() {
2073
2098
  process.exitCode = 1;
2074
2099
  return;
2075
2100
  }
2076
- const html = renderHtml(result.report, await assets());
2101
+ const html = renderHtml2(result.report, await assets());
2077
2102
  const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
2078
2103
  const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
2079
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.0",
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