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 +15 -1
- package/dist/cli.js +252 -227
- package/package.json +2 -2
- package/skills/plotleaf/SKILL.md +1 -1
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/
|
|
66
|
-
|
|
67
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
127
|
+
cache = new Map;
|
|
128
|
+
constructor(diagnostics, files, images) {
|
|
298
129
|
this.diagnostics = diagnostics;
|
|
299
|
-
this.
|
|
300
|
-
this.
|
|
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
|
|
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/
|
|
1660
|
+
// src/render/document.ts
|
|
1649
1661
|
var escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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-${
|
|
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 ?
|
|
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.
|
|
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://
|
|
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(
|
|
2038
|
-
|
|
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 =
|
|
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.
|
|
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://
|
|
11
|
+
"homepage": "https://plotleaf-site.loueed.workers.dev",
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/LoueeD/plotleaf/issues"
|
|
14
14
|
},
|
package/skills/plotleaf/SKILL.md
CHANGED
|
@@ -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
|
|