plotleaf 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -7
- package/dist/assets/base.js +1 -1
- package/dist/assets/charts.js +1 -1
- package/dist/assets/styles.css +71 -0
- package/dist/cli.js +493 -121
- package/examples/catalog/assets/desk-lamp.jpg +0 -0
- package/examples/catalog/assets/field-notebook.png +0 -0
- package/examples/catalog/assets/linen-tote.png +0 -0
- package/examples/catalog/products.json +78 -0
- package/examples/catalog/report.md +39 -0
- package/examples/catalog/showcase-products.json +78 -0
- package/examples/report.md +96 -0
- package/package.json +9 -2
- package/schema/components.json +34 -5
- package/skills/plotleaf/SKILL.md +22 -5
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path6 from "node:path";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
|
|
8
8
|
// src/compiler/compile.ts
|
|
9
|
-
import
|
|
9
|
+
import path4 from "node:path";
|
|
10
10
|
import { realpath as realpath2 } from "node:fs/promises";
|
|
11
11
|
|
|
12
12
|
// src/compiler/diagnostics.ts
|
|
@@ -62,18 +62,10 @@ ${d.hint}` : ""}`).join(`
|
|
|
62
62
|
`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// src/compiler/
|
|
65
|
+
// src/compiler/files.ts
|
|
66
66
|
import path2 from "node:path";
|
|
67
67
|
import { open, realpath, stat } from "node:fs/promises";
|
|
68
68
|
|
|
69
|
-
// src/shared/payload.ts
|
|
70
|
-
var funnelStatuses = {
|
|
71
|
-
"on-target": "On target",
|
|
72
|
-
"below-target": "Below target",
|
|
73
|
-
"at-risk": "At risk",
|
|
74
|
-
neutral: "Neutral"
|
|
75
|
-
};
|
|
76
|
-
|
|
77
69
|
// src/shared/limits.ts
|
|
78
70
|
var limits = {
|
|
79
71
|
markdownBytes: 1024 ** 2,
|
|
@@ -85,24 +77,35 @@ var limits = {
|
|
|
85
77
|
donutSlices: 8,
|
|
86
78
|
funnelStages: 20,
|
|
87
79
|
tableRows: 1000,
|
|
88
|
-
tableColumns: 40
|
|
80
|
+
tableColumns: 40,
|
|
81
|
+
sparklinePoints: 64,
|
|
82
|
+
imageFiles: 64,
|
|
83
|
+
imageBytes: 1024 ** 2,
|
|
84
|
+
imageDimension: 4096,
|
|
85
|
+
imagePixels: 4000000
|
|
89
86
|
};
|
|
90
87
|
|
|
91
|
-
// src/compiler/
|
|
92
|
-
|
|
88
|
+
// src/compiler/files.ts
|
|
89
|
+
class LocalFileError extends Error {
|
|
90
|
+
kind;
|
|
91
|
+
constructor(kind, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.kind = kind;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
93
96
|
function contained(root, filename) {
|
|
94
97
|
const relative = path2.relative(root, filename);
|
|
95
98
|
return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
|
|
96
99
|
}
|
|
97
|
-
async function
|
|
100
|
+
async function readBytesBounded(filename, maximum) {
|
|
98
101
|
if (!(await stat(filename)).isFile())
|
|
99
102
|
throw new Error("Expected a regular file.");
|
|
100
103
|
const file = await open(filename, "r");
|
|
101
104
|
try {
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
105
|
+
const info = await file.stat();
|
|
106
|
+
if (!info.isFile())
|
|
104
107
|
throw new Error("Expected a regular file.");
|
|
105
|
-
if (
|
|
108
|
+
if (info.size > maximum)
|
|
106
109
|
throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
|
|
107
110
|
const chunks = [];
|
|
108
111
|
let bytes = 0;
|
|
@@ -116,14 +119,158 @@ async function readBounded(filename, maximum) {
|
|
|
116
119
|
}
|
|
117
120
|
if (bytes > maximum)
|
|
118
121
|
throw new Error("File grew beyond the size limit while reading.");
|
|
119
|
-
return
|
|
120
|
-
text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, bytes)),
|
|
121
|
-
bytes
|
|
122
|
-
};
|
|
122
|
+
return Buffer.concat(chunks, bytes);
|
|
123
123
|
} finally {
|
|
124
124
|
await file.close();
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
async function readBounded(filename, maximum) {
|
|
128
|
+
const bytes = await readBytesBounded(filename, maximum);
|
|
129
|
+
return {
|
|
130
|
+
text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
131
|
+
bytes: bytes.length
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class ReportFiles {
|
|
136
|
+
root;
|
|
137
|
+
dependencies;
|
|
138
|
+
usedBytes;
|
|
139
|
+
constructor(root, dependencies, usedBytes) {
|
|
140
|
+
this.root = root;
|
|
141
|
+
this.dependencies = dependencies;
|
|
142
|
+
this.usedBytes = usedBytes;
|
|
143
|
+
}
|
|
144
|
+
async resolve(reference, extensions) {
|
|
145
|
+
const fail = (message) => {
|
|
146
|
+
throw new LocalFileError("path", message);
|
|
147
|
+
};
|
|
148
|
+
if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
|
|
149
|
+
fail(`Use a plain relative local ${extensions.join("/")} path.`);
|
|
150
|
+
const candidate = path2.resolve(this.root, reference);
|
|
151
|
+
const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
|
|
152
|
+
if (!contained(this.root, candidate))
|
|
153
|
+
fail("Reference escapes the report directory.");
|
|
154
|
+
if (hidden(candidate))
|
|
155
|
+
fail("Hidden files and directories are not allowed.");
|
|
156
|
+
if (!extensions.includes(path2.extname(candidate).toLowerCase()))
|
|
157
|
+
fail(`Use a ${extensions.join("/")} file.`);
|
|
158
|
+
this.dependencies.add(candidate);
|
|
159
|
+
let canonical;
|
|
160
|
+
try {
|
|
161
|
+
canonical = await realpath(candidate);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
|
|
164
|
+
}
|
|
165
|
+
if (!contained(this.root, canonical) || hidden(canonical))
|
|
166
|
+
fail("The resolved file or symlink escapes the permitted report files.");
|
|
167
|
+
if (!extensions.includes(path2.extname(canonical).toLowerCase()))
|
|
168
|
+
fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
|
|
169
|
+
this.dependencies.add(canonical);
|
|
170
|
+
return canonical;
|
|
171
|
+
}
|
|
172
|
+
async read(filename, maximum) {
|
|
173
|
+
const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
|
|
174
|
+
this.usedBytes += bytes.length;
|
|
175
|
+
return bytes;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/compiler/images.ts
|
|
180
|
+
import { createHash } from "node:crypto";
|
|
181
|
+
import path3 from "node:path";
|
|
182
|
+
import { imageSize } from "image-size";
|
|
183
|
+
function formatOf(bytes) {
|
|
184
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
185
|
+
let offset = 8;
|
|
186
|
+
let data = false;
|
|
187
|
+
while (offset + 12 <= bytes.length) {
|
|
188
|
+
const size = bytes.readUInt32BE(offset);
|
|
189
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
190
|
+
if (offset + size + 12 > bytes.length)
|
|
191
|
+
break;
|
|
192
|
+
if (offset === 8 && (type !== "IHDR" || size !== 13))
|
|
193
|
+
break;
|
|
194
|
+
if (type === "acTL")
|
|
195
|
+
throw new Error("Animated PNG is not supported; provide a static image.");
|
|
196
|
+
if (type === "IDAT" && size > 0)
|
|
197
|
+
data = true;
|
|
198
|
+
offset += size + 12;
|
|
199
|
+
if (type === "IEND") {
|
|
200
|
+
if (data && size === 0 && offset === bytes.length)
|
|
201
|
+
return "png";
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error("Incomplete or invalid PNG container.");
|
|
206
|
+
}
|
|
207
|
+
if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
|
|
208
|
+
return "jpg";
|
|
209
|
+
throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
class ImageResolver {
|
|
213
|
+
files;
|
|
214
|
+
attempted = new Set;
|
|
215
|
+
filesRead = new Map;
|
|
216
|
+
embedded = new Map;
|
|
217
|
+
constructor(files) {
|
|
218
|
+
this.files = files;
|
|
219
|
+
}
|
|
220
|
+
async load(reference) {
|
|
221
|
+
const requested = path3.resolve(this.files.root, reference);
|
|
222
|
+
if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
|
|
223
|
+
throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
|
|
224
|
+
this.attempted.add(requested);
|
|
225
|
+
const canonical = await this.files.resolve(reference, [
|
|
226
|
+
".png",
|
|
227
|
+
".jpg",
|
|
228
|
+
".jpeg"
|
|
229
|
+
]);
|
|
230
|
+
const cached = this.filesRead.get(canonical);
|
|
231
|
+
if (cached) {
|
|
232
|
+
if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
|
|
233
|
+
throw new Error("Image signature does not match its file extension.");
|
|
234
|
+
return cached;
|
|
235
|
+
}
|
|
236
|
+
if (this.filesRead.size >= limits.imageFiles)
|
|
237
|
+
throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
|
|
238
|
+
const bytes = await this.files.read(canonical, limits.imageBytes);
|
|
239
|
+
const format = formatOf(bytes);
|
|
240
|
+
const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
|
|
241
|
+
if (format !== expected(reference) || format !== expected(canonical))
|
|
242
|
+
throw new Error("Image signature does not match its file extension.");
|
|
243
|
+
let size;
|
|
244
|
+
try {
|
|
245
|
+
size = imageSize(bytes);
|
|
246
|
+
} catch {
|
|
247
|
+
throw new Error("Unsupported or damaged PNG/JPEG header.");
|
|
248
|
+
}
|
|
249
|
+
if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
|
|
250
|
+
throw new Error("Image must have valid positive raster dimensions.");
|
|
251
|
+
if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
|
|
252
|
+
throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
|
|
253
|
+
const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
|
|
254
|
+
const image = this.embedded.get(id) ?? {
|
|
255
|
+
id,
|
|
256
|
+
mime: format === "png" ? "image/png" : "image/jpeg",
|
|
257
|
+
base64: bytes.toString("base64")
|
|
258
|
+
};
|
|
259
|
+
this.filesRead.set(canonical, image);
|
|
260
|
+
this.embedded.set(id, image);
|
|
261
|
+
return image;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// src/shared/payload.ts
|
|
265
|
+
var funnelStatuses = {
|
|
266
|
+
"on-target": "On target",
|
|
267
|
+
"below-target": "Below target",
|
|
268
|
+
"at-risk": "At risk",
|
|
269
|
+
neutral: "Neutral"
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// src/compiler/data.ts
|
|
273
|
+
var reserved = new Set(["__proto__", "constructor", "prototype"]);
|
|
127
274
|
function parseDate(value) {
|
|
128
275
|
const date = value.slice(0, 10);
|
|
129
276
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
@@ -143,75 +290,73 @@ class DataResolver {
|
|
|
143
290
|
root;
|
|
144
291
|
diagnostics;
|
|
145
292
|
dependencies;
|
|
146
|
-
totalBytes;
|
|
147
293
|
cache = new Map;
|
|
294
|
+
files;
|
|
295
|
+
images;
|
|
148
296
|
constructor(root, diagnostics, dependencies, totalBytes = 0) {
|
|
149
297
|
this.root = root;
|
|
150
298
|
this.diagnostics = diagnostics;
|
|
151
299
|
this.dependencies = dependencies;
|
|
152
|
-
this.
|
|
300
|
+
this.files = new ReportFiles(root, dependencies, totalBytes);
|
|
301
|
+
this.images = new ImageResolver(this.files);
|
|
153
302
|
}
|
|
154
303
|
async load(reference, block) {
|
|
155
304
|
const fail = (code, message, hint) => {
|
|
156
305
|
this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
|
|
157
306
|
return;
|
|
158
307
|
};
|
|
159
|
-
if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
|
|
160
|
-
return fail("E_DATA_PATH", "Use a plain relative local .json path.");
|
|
161
|
-
const candidate = path2.resolve(this.root, reference);
|
|
162
|
-
if (!contained(this.root, candidate))
|
|
163
|
-
return fail("E_DATA_PATH", "Reference escapes the report directory.", "Place prepared JSON beside the report or in one of its subdirectories.");
|
|
164
|
-
if (path2.relative(this.root, candidate).split(path2.sep).some((segment) => segment.startsWith(".")))
|
|
165
|
-
return fail("E_DATA_PATH", "Hidden files and directories are not allowed.");
|
|
166
|
-
if (path2.extname(candidate).toLowerCase() !== ".json")
|
|
167
|
-
return fail("E_DATA_PATH", "Datasets must be .json files.");
|
|
168
|
-
this.dependencies.add(candidate);
|
|
169
308
|
let canonical;
|
|
170
309
|
try {
|
|
171
|
-
canonical = await
|
|
172
|
-
} catch {
|
|
173
|
-
return fail("E_DATA_READ", "Cannot read the referenced dataset.", "Create the file at this path or correct the data property.");
|
|
174
|
-
}
|
|
175
|
-
if (!contained(this.root, canonical) || path2.relative(this.root, canonical).split(path2.sep).some((segment) => segment.startsWith(".")))
|
|
176
|
-
return fail("E_DATA_PATH", "The resolved file or symlink escapes the permitted report files.");
|
|
177
|
-
if (path2.extname(canonical).toLowerCase() !== ".json")
|
|
178
|
-
return fail("E_DATA_PATH", "The resolved file must also have a .json extension.");
|
|
179
|
-
this.dependencies.add(canonical);
|
|
180
|
-
const cached = this.cache.get(canonical);
|
|
181
|
-
if (cached)
|
|
182
|
-
return cached;
|
|
183
|
-
if (this.cache.size >= limits.datasets)
|
|
184
|
-
return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
|
|
185
|
-
let input;
|
|
186
|
-
try {
|
|
187
|
-
input = await readBounded(canonical, Math.min(limits.jsonBytes, limits.totalBytes - this.totalBytes));
|
|
310
|
+
canonical = await this.files.resolve(reference, [".json"]);
|
|
188
311
|
} catch (error) {
|
|
189
|
-
return fail("E_DATA_READ", error instanceof Error ? error.message : "
|
|
312
|
+
return fail(error instanceof LocalFileError && error.kind === "path" ? "E_DATA_PATH" : "E_DATA_READ", error instanceof Error ? error.message : "Cannot resolve the dataset.", "Place prepared JSON within the report directory and correct the data property.");
|
|
190
313
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (parsed.length > limits.rows)
|
|
201
|
-
return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller aggregate dataset before rendering.");
|
|
202
|
-
for (const [index, row] of parsed.entries()) {
|
|
203
|
-
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
204
|
-
return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
|
|
205
|
-
for (const [key, value] of Object.entries(row)) {
|
|
206
|
-
if (reserved.has(key))
|
|
207
|
-
return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
|
|
208
|
-
if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
|
|
209
|
-
return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain a finite number, string, boolean or null.`, "Prepare flat scalar records. Nested cells and sparkline data are planned separately.");
|
|
314
|
+
let dataset = this.cache.get(canonical);
|
|
315
|
+
if (!dataset) {
|
|
316
|
+
if (this.cache.size >= limits.datasets)
|
|
317
|
+
return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
|
|
318
|
+
let text;
|
|
319
|
+
try {
|
|
320
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
|
|
210
323
|
}
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(text);
|
|
327
|
+
} catch {
|
|
328
|
+
return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
|
|
329
|
+
}
|
|
330
|
+
if (!Array.isArray(parsed))
|
|
331
|
+
return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
|
|
332
|
+
if (parsed.length > limits.rows)
|
|
333
|
+
return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller dataset before rendering.");
|
|
334
|
+
const arrays = new Map;
|
|
335
|
+
for (const [index, row] of parsed.entries()) {
|
|
336
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
337
|
+
return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
|
|
338
|
+
for (const [key, value] of Object.entries(row)) {
|
|
339
|
+
if (reserved.has(key))
|
|
340
|
+
return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
|
|
341
|
+
if (Array.isArray(value)) {
|
|
342
|
+
if (value.length > limits.sparklinePoints)
|
|
343
|
+
return fail("E_DATA_LIMIT", `Row ${index + 1}, field ${JSON.stringify(key)} exceeds ${limits.sparklinePoints} sparkline points.`, "Prepare a bounded sequence; values are never truncated.");
|
|
344
|
+
if (value.some((item) => item !== null && !(typeof item === "number" && Number.isFinite(item))))
|
|
345
|
+
return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain only finite numbers or null gaps.`, "Only declared sparkline columns support sequences; nested objects and sequences are invalid.");
|
|
346
|
+
if (!arrays.has(key))
|
|
347
|
+
arrays.set(key, index);
|
|
348
|
+
} else if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
|
|
349
|
+
return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain a finite number, string, boolean or null.`, "Prepare flat scalar records; use an explicit sparkline column for bounded numeric/null sequences.");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
dataset = { rows: parsed, arrays };
|
|
353
|
+
this.cache.set(canonical, dataset);
|
|
211
354
|
}
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
355
|
+
const declared = new Set(block.name === "table" ? block.children.flatMap((child) => child.type === "component" && child.name === "column" && child.props.kind === "sparkline" ? [child.props.field] : []) : []);
|
|
356
|
+
for (const [field, index] of dataset.arrays)
|
|
357
|
+
if (!declared.has(field))
|
|
358
|
+
return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(field)} is a sequence without an explicit sparkline column.`, 'Use a table column with kind="sparkline" for that field. Charts and scalar tables require flat scalar datasets.');
|
|
359
|
+
return dataset.rows;
|
|
215
360
|
}
|
|
216
361
|
async resolve(blocks) {
|
|
217
362
|
for (const block of blocks) {
|
|
@@ -223,7 +368,7 @@ class DataResolver {
|
|
|
223
368
|
if (!rows.length)
|
|
224
369
|
this.diagnostics.add("W_DATA_EMPTY", `${block.props.data} is empty; a no-data state will be shown.`, block.position, "Field validation requires at least one record.", "warning");
|
|
225
370
|
if (block.name === "table")
|
|
226
|
-
this.resolveTable(block, rows);
|
|
371
|
+
await this.resolveTable(block, rows);
|
|
227
372
|
else if (block.name === "donut" || block.name === "funnel")
|
|
228
373
|
this.resolveParts(block, rows);
|
|
229
374
|
else
|
|
@@ -342,7 +487,7 @@ class DataResolver {
|
|
|
342
487
|
}
|
|
343
488
|
block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
|
|
344
489
|
}
|
|
345
|
-
resolveTable(block, rows) {
|
|
490
|
+
async resolveTable(block, rows) {
|
|
346
491
|
if (rows.length > limits.tableRows) {
|
|
347
492
|
this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
|
|
348
493
|
return;
|
|
@@ -358,24 +503,101 @@ class DataResolver {
|
|
|
358
503
|
this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
|
|
359
504
|
return;
|
|
360
505
|
}
|
|
361
|
-
if (new Set(columns.map((column) => column.field)).size !== columns.length)
|
|
506
|
+
if (new Set(columns.map((column) => column.field)).size !== columns.length) {
|
|
362
507
|
this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
363
510
|
for (const [index, row] of rows.entries())
|
|
364
511
|
if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
|
|
365
512
|
this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
|
|
366
513
|
return;
|
|
367
514
|
}
|
|
515
|
+
const prepared = rows.map(() => ({}));
|
|
368
516
|
for (const column of columns) {
|
|
369
|
-
|
|
517
|
+
const origin = explicit.find((child) => child.props.field === column.field) ?? block;
|
|
518
|
+
const fields = [
|
|
519
|
+
column.field,
|
|
520
|
+
...column.alt ? [column.alt] : [],
|
|
521
|
+
...column.sortBy ? [column.sortBy] : []
|
|
522
|
+
];
|
|
523
|
+
if (fields.some((field) => !this.field(rows, field, origin)))
|
|
370
524
|
continue;
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
525
|
+
const fail = (code, row, message, hint) => this.diagnostics.add(code, `${block.props.data}, row ${row + 1}, field ${JSON.stringify(column.field)}: ${message}`, origin.position, hint);
|
|
526
|
+
const kind = column.kind ?? "scalar";
|
|
527
|
+
if (kind === "scalar") {
|
|
528
|
+
const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
|
|
529
|
+
if (types.size > 1)
|
|
530
|
+
this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, origin.position);
|
|
531
|
+
if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
|
|
532
|
+
this.diagnostics.add("E_TABLE_FORMAT", `Numeric formatting/sign/semantics cannot be applied to ${JSON.stringify(column.field)}.`, origin.position);
|
|
533
|
+
}
|
|
534
|
+
let sortType;
|
|
535
|
+
for (const [index, row] of rows.entries()) {
|
|
536
|
+
const value = row[column.field];
|
|
537
|
+
const sort = column.sortBy ? row[column.sortBy] : undefined;
|
|
538
|
+
if (column.sortBy && (Array.isArray(sort) || sort === undefined)) {
|
|
539
|
+
fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (sort !== undefined && sort !== null) {
|
|
543
|
+
if (sortType && sortType !== typeof sort) {
|
|
544
|
+
fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
sortType = typeof sort;
|
|
548
|
+
}
|
|
549
|
+
if (kind === "scalar") {
|
|
550
|
+
prepared[index][column.field] = value;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
let rich;
|
|
554
|
+
if (kind === "sparkline") {
|
|
555
|
+
if (value !== null && !Array.isArray(value)) {
|
|
556
|
+
fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
rich = {
|
|
560
|
+
kind: "sparkline",
|
|
561
|
+
values: value ?? [],
|
|
562
|
+
...column.sortBy ? { sort } : {}
|
|
563
|
+
};
|
|
564
|
+
} else {
|
|
565
|
+
const alt = row[column.alt];
|
|
566
|
+
if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
|
|
567
|
+
fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (value === null) {
|
|
571
|
+
prepared[index][column.field] = {
|
|
572
|
+
kind: "image",
|
|
573
|
+
imageId: null,
|
|
574
|
+
alt: "",
|
|
575
|
+
...column.sortBy ? { sort } : {}
|
|
576
|
+
};
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
580
|
+
fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
const image = await this.images.load(value);
|
|
585
|
+
rich = {
|
|
586
|
+
kind: "image",
|
|
587
|
+
imageId: image.id,
|
|
588
|
+
alt,
|
|
589
|
+
...column.sortBy ? { sort } : {}
|
|
590
|
+
};
|
|
591
|
+
} catch (error) {
|
|
592
|
+
fail(error instanceof LocalFileError ? error.kind === "path" ? "E_ASSET_PATH" : "E_ASSET_READ" : "E_ASSET_IMAGE", index, `${value}: ${error instanceof Error ? error.message : "Cannot read image."}`, "Use a bounded static PNG/JPEG inside the report directory, with a matching extension.");
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
prepared[index][column.field] = rich;
|
|
597
|
+
}
|
|
376
598
|
}
|
|
377
599
|
block.columns = columns;
|
|
378
|
-
block.rows =
|
|
600
|
+
block.rows = prepared;
|
|
379
601
|
}
|
|
380
602
|
}
|
|
381
603
|
|
|
@@ -515,15 +737,23 @@ var registry = {
|
|
|
515
737
|
column: {
|
|
516
738
|
forms: ["leaf"],
|
|
517
739
|
children: [],
|
|
518
|
-
description: "
|
|
740
|
+
description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
|
|
519
741
|
schema: z.strictObject({
|
|
520
742
|
field: text,
|
|
521
743
|
label: text.optional(),
|
|
744
|
+
kind: z.enum(["scalar", "image", "sparkline"]).optional(),
|
|
745
|
+
alt: text.optional(),
|
|
746
|
+
sortBy: text.optional(),
|
|
522
747
|
format: format.optional(),
|
|
523
748
|
sign: z.enum(["auto", "always"]).default("auto"),
|
|
524
749
|
semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
|
|
525
750
|
}),
|
|
526
|
-
example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}'
|
|
751
|
+
example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
|
|
752
|
+
dataRules: {
|
|
753
|
+
image: "Local static PNG/JPEG path or null. alt names a nonblank string field for each image. Fixed 64 × 48 display box; deduplicated and embedded offline. Paths resolve from the report directory.",
|
|
754
|
+
sparkline: "Only explicitly declared sparkline fields may contain arrays: at most 64 finite numbers/nulls, in sample order. Nulls are gaps. Shared observed y scale per column; no aggregation or resampling.",
|
|
755
|
+
sortBy: "Optional scalar field for image/sparkline sorting. Without it a rich column has a plain header. Scalar columns sort their own raw values. Sort keys are readable in the export."
|
|
756
|
+
}
|
|
527
757
|
},
|
|
528
758
|
callout: {
|
|
529
759
|
forms: ["container"],
|
|
@@ -545,7 +775,7 @@ function componentSpec() {
|
|
|
545
775
|
attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
|
|
546
776
|
limits,
|
|
547
777
|
data: {
|
|
548
|
-
shape: "array of flat JSON records",
|
|
778
|
+
shape: "array of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields",
|
|
549
779
|
resolution: "relative to the report directory; local .json files only",
|
|
550
780
|
percent: "ratios: 0.12 displays as 12%",
|
|
551
781
|
percentagePoints: "point values: 0.8 displays as 0.8 pt",
|
|
@@ -575,7 +805,8 @@ function componentSpec() {
|
|
|
575
805
|
"Line/area null y values create gaps; bar null y values are invalid.",
|
|
576
806
|
"Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
|
|
577
807
|
"Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
|
|
578
|
-
"
|
|
808
|
+
"Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
|
|
809
|
+
"Filters, maps, arbitrary nested data and executable components are not supported in this release."
|
|
579
810
|
]
|
|
580
811
|
};
|
|
581
812
|
}
|
|
@@ -598,6 +829,22 @@ function decodeProps(name, attributes) {
|
|
|
598
829
|
};
|
|
599
830
|
const props = result.data;
|
|
600
831
|
const errors = [];
|
|
832
|
+
if (name === "column") {
|
|
833
|
+
const column = props;
|
|
834
|
+
const kind = column.kind ?? "scalar";
|
|
835
|
+
if (kind === "image" && !column.alt)
|
|
836
|
+
errors.push("image columns require an alt field.");
|
|
837
|
+
if (kind !== "image" && column.alt)
|
|
838
|
+
errors.push("alt is only supported on image columns.");
|
|
839
|
+
if (kind === "image" && column.format)
|
|
840
|
+
errors.push("Image columns cannot use numeric formatting.");
|
|
841
|
+
if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
|
|
842
|
+
errors.push("sign and semantics apply only to scalar columns.");
|
|
843
|
+
if (kind === "scalar" && column.sortBy)
|
|
844
|
+
errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
|
|
845
|
+
if (column.alt === column.field || column.sortBy === column.field)
|
|
846
|
+
errors.push("alt and sortBy must reference fields different from the rich cell field.");
|
|
847
|
+
}
|
|
601
848
|
if (name === "donut" || name === "funnel") {
|
|
602
849
|
const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
|
|
603
850
|
if (new Set(fields).size !== fields.length)
|
|
@@ -644,7 +891,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
|
|
|
644
891
|
if (node.type === "html")
|
|
645
892
|
diagnostics.add("E_RAW_HTML", "Raw HTML is not supported in reports.", node.position, "Use Markdown or a registered directive. Put HTML examples inside code fences.");
|
|
646
893
|
if (node.type === "image" || node.type === "imageReference")
|
|
647
|
-
diagnostics.add("E_IMAGE_UNSUPPORTED", "
|
|
894
|
+
diagnostics.add("E_IMAGE_UNSUPPORTED", "Markdown images are not supported; use an explicit image column in a table.", node.position, 'Use ::column{field="image" kind="image" alt="description"} inside a table for local PNG/JPEG images, or use a text link.');
|
|
648
895
|
if (node.type === "link" || node.type === "definition") {
|
|
649
896
|
const url = node.url ?? "";
|
|
650
897
|
let allowed = url.startsWith("#");
|
|
@@ -783,7 +1030,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
|
|
|
783
1030
|
|
|
784
1031
|
// src/compiler/compile.ts
|
|
785
1032
|
async function compileReport(input) {
|
|
786
|
-
const requested =
|
|
1033
|
+
const requested = path4.resolve(input);
|
|
787
1034
|
const diagnostics = new Diagnostics(displayPath(requested));
|
|
788
1035
|
const dependencies = new Set([requested]);
|
|
789
1036
|
const result = () => ({
|
|
@@ -792,16 +1039,18 @@ async function compileReport(input) {
|
|
|
792
1039
|
filename: requested
|
|
793
1040
|
});
|
|
794
1041
|
try {
|
|
795
|
-
if (
|
|
1042
|
+
if (path4.extname(requested).toLowerCase() !== ".md") {
|
|
796
1043
|
diagnostics.add("E_REPORT_PATH", "A report must be a .md Markdown file.");
|
|
797
1044
|
return result();
|
|
798
1045
|
}
|
|
799
1046
|
const filename = await realpath2(requested);
|
|
800
1047
|
dependencies.add(filename);
|
|
801
1048
|
const { text, bytes } = await readBounded(filename, limits.markdownBytes);
|
|
802
|
-
const report = parseReport(text,
|
|
803
|
-
const resolver = new DataResolver(
|
|
1049
|
+
const report = parseReport(text, path4.basename(filename, path4.extname(filename)), diagnostics);
|
|
1050
|
+
const resolver = new DataResolver(path4.dirname(filename), diagnostics, dependencies, bytes);
|
|
804
1051
|
await resolver.resolve(report.blocks);
|
|
1052
|
+
if (resolver.images.embedded.size)
|
|
1053
|
+
report.images = [...resolver.images.embedded.values()];
|
|
805
1054
|
return { ...result(), ...!diagnostics.hasErrors ? { report } : {} };
|
|
806
1055
|
} catch (error) {
|
|
807
1056
|
diagnostics.add("E_REPORT_READ", error instanceof Error ? error.message.replaceAll(requested, displayPath(requested)) : "Could not compile the report.");
|
|
@@ -810,7 +1059,7 @@ async function compileReport(input) {
|
|
|
810
1059
|
}
|
|
811
1060
|
|
|
812
1061
|
// src/render/html.ts
|
|
813
|
-
import { createHash } from "node:crypto";
|
|
1062
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
814
1063
|
|
|
815
1064
|
// src/render/content.ts
|
|
816
1065
|
import { unified as unified2 } from "unified";
|
|
@@ -818,6 +1067,13 @@ import remarkRehype from "remark-rehype";
|
|
|
818
1067
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
819
1068
|
import rehypeStringify from "rehype-stringify";
|
|
820
1069
|
|
|
1070
|
+
// src/render/tree.ts
|
|
1071
|
+
var t = (value) => ({
|
|
1072
|
+
type: "text",
|
|
1073
|
+
value: String(value)
|
|
1074
|
+
});
|
|
1075
|
+
var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
|
|
1076
|
+
|
|
821
1077
|
// src/shared/format.ts
|
|
822
1078
|
var numbers = new Map;
|
|
823
1079
|
function formatValue(value, format = "number", sign = "auto", compact = false) {
|
|
@@ -851,14 +1107,106 @@ function valueSentiment(value, semantics = "neutral") {
|
|
|
851
1107
|
return value > 0 === (semantics === "higher-is-better") ? "positive" : "negative";
|
|
852
1108
|
}
|
|
853
1109
|
|
|
1110
|
+
// src/render/table-cells.ts
|
|
1111
|
+
function columnScale(rows, field) {
|
|
1112
|
+
let min = Infinity, max = -Infinity;
|
|
1113
|
+
for (const row of rows) {
|
|
1114
|
+
const cell = row[field];
|
|
1115
|
+
if (!cell || typeof cell !== "object" || cell.kind !== "sparkline")
|
|
1116
|
+
continue;
|
|
1117
|
+
for (const value of cell.values)
|
|
1118
|
+
if (value !== null) {
|
|
1119
|
+
min = Math.min(min, value);
|
|
1120
|
+
max = Math.max(max, value);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return Number.isFinite(min) ? { min, max } : undefined;
|
|
1124
|
+
}
|
|
1125
|
+
function richCell(cell, column, scale) {
|
|
1126
|
+
if (cell.kind === "image")
|
|
1127
|
+
return cell.imageId ? [
|
|
1128
|
+
h("svg", {
|
|
1129
|
+
className: ["table-image"],
|
|
1130
|
+
viewBox: "0 0 64 48",
|
|
1131
|
+
width: "64",
|
|
1132
|
+
height: "48",
|
|
1133
|
+
role: "img",
|
|
1134
|
+
ariaLabel: cell.alt
|
|
1135
|
+
}, [h("use", { href: `#${cell.imageId}` })])
|
|
1136
|
+
] : [t("—")];
|
|
1137
|
+
const values = cell.values;
|
|
1138
|
+
const recorded = values.filter((value) => value !== null);
|
|
1139
|
+
const formatted = (value) => value == null ? "—" : formatValue(value, column.format);
|
|
1140
|
+
const name = column.label ?? column.field;
|
|
1141
|
+
const description = `${name}. ${recorded.length} of ${values.length} recorded samples. First sample ${formatted(values[0])}; last sample ${formatted(values.at(-1))}.${scale ? ` Shared column scale ${formatted(scale.min)} to ${formatted(scale.max)}.` : ""}`;
|
|
1142
|
+
const marks = [];
|
|
1143
|
+
if (scale && recorded.length) {
|
|
1144
|
+
const divisor = Math.max(Math.abs(scale.min), Math.abs(scale.max)) || 1;
|
|
1145
|
+
const low = scale.min / divisor, high = scale.max / divisor;
|
|
1146
|
+
const segments = [];
|
|
1147
|
+
let segment = [];
|
|
1148
|
+
for (const [index, value] of values.entries()) {
|
|
1149
|
+
if (value === null) {
|
|
1150
|
+
if (segment.length)
|
|
1151
|
+
segments.push(segment);
|
|
1152
|
+
segment = [];
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const x = values.length === 1 ? 56 : 3 + index / (values.length - 1) * 106;
|
|
1156
|
+
const y = high === low ? 16 : 3 + (1 - (value / divisor - low) / (high - low)) * 26;
|
|
1157
|
+
segment.push([x, y]);
|
|
1158
|
+
}
|
|
1159
|
+
if (segment.length)
|
|
1160
|
+
segments.push(segment);
|
|
1161
|
+
for (const points of segments) {
|
|
1162
|
+
if (points.length === 1)
|
|
1163
|
+
marks.push(h("circle", {
|
|
1164
|
+
cx: points[0][0].toFixed(3),
|
|
1165
|
+
cy: points[0][1].toFixed(3),
|
|
1166
|
+
r: "2"
|
|
1167
|
+
}));
|
|
1168
|
+
else
|
|
1169
|
+
marks.push(h("path", {
|
|
1170
|
+
d: points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(3)} ${y.toFixed(3)}`).join("")
|
|
1171
|
+
}));
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return [
|
|
1175
|
+
h("div", { className: ["sparkline-cell"] }, [
|
|
1176
|
+
...marks.length ? [
|
|
1177
|
+
h("svg", {
|
|
1178
|
+
className: ["sparkline-chart"],
|
|
1179
|
+
viewBox: "0 0 112 32",
|
|
1180
|
+
width: "112",
|
|
1181
|
+
height: "32",
|
|
1182
|
+
role: "img",
|
|
1183
|
+
ariaLabel: description
|
|
1184
|
+
}, marks)
|
|
1185
|
+
] : [
|
|
1186
|
+
h("span", { className: ["sparkline-empty"] }, [
|
|
1187
|
+
t("No recorded values")
|
|
1188
|
+
])
|
|
1189
|
+
],
|
|
1190
|
+
h("span", {
|
|
1191
|
+
className: ["sparkline-last"],
|
|
1192
|
+
ariaLabel: `Last sample: ${formatted(values.at(-1))}`
|
|
1193
|
+
}, [t(formatted(values.at(-1)))]),
|
|
1194
|
+
...values.length ? [
|
|
1195
|
+
h("details", { className: ["sparkline-data"] }, [
|
|
1196
|
+
h("summary", {}, [t(`View ${values.length} values`)]),
|
|
1197
|
+
h("ol", { className: ["sparkline-values"] }, values.map((value) => h("li", {}, [t(value === null ? "—" : String(value))])))
|
|
1198
|
+
])
|
|
1199
|
+
] : []
|
|
1200
|
+
])
|
|
1201
|
+
];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
854
1204
|
// src/shared/serialize.ts
|
|
855
1205
|
function serializeData(value) {
|
|
856
1206
|
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
857
1207
|
}
|
|
858
1208
|
|
|
859
1209
|
// src/render/content.ts
|
|
860
|
-
var t = (value) => ({ type: "text", value: String(value) });
|
|
861
|
-
var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
|
|
862
1210
|
var markdown = unified2().use(remarkRehype);
|
|
863
1211
|
var serializer = unified2().use(rehypeSanitize, {
|
|
864
1212
|
...defaultSchema,
|
|
@@ -869,7 +1217,11 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
869
1217
|
"figcaption",
|
|
870
1218
|
"button",
|
|
871
1219
|
"caption",
|
|
872
|
-
"meter"
|
|
1220
|
+
"meter",
|
|
1221
|
+
"svg",
|
|
1222
|
+
"use",
|
|
1223
|
+
"path",
|
|
1224
|
+
"circle"
|
|
873
1225
|
],
|
|
874
1226
|
attributes: {
|
|
875
1227
|
...defaultSchema.attributes,
|
|
@@ -895,6 +1247,10 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
895
1247
|
button: ["type", "disabled", "ariaLabel", "dataColumn"],
|
|
896
1248
|
section: ["className", "ariaLabel"],
|
|
897
1249
|
meter: ["min", "max", "value", "className", "ariaLabel"],
|
|
1250
|
+
svg: ["className", "viewBox", "width", "height", "role", "ariaLabel"],
|
|
1251
|
+
use: ["href"],
|
|
1252
|
+
path: ["d"],
|
|
1253
|
+
circle: ["cx", "cy", "r"],
|
|
898
1254
|
ul: ["className", "role"],
|
|
899
1255
|
ol: ["start", "className", "role"],
|
|
900
1256
|
li: ["className", "dataStatus"],
|
|
@@ -913,11 +1269,13 @@ function cellText(value, column) {
|
|
|
913
1269
|
return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
|
|
914
1270
|
}
|
|
915
1271
|
function table(rows, columns, title, id, sortable = true) {
|
|
916
|
-
const
|
|
1272
|
+
const scales = columns.map((column) => column.kind === "sparkline" ? columnScale(rows, column.field) : undefined);
|
|
1273
|
+
const canSort = columns.map((column) => sortable && (!column.kind || column.kind === "scalar" || Boolean(column.sortBy)));
|
|
1274
|
+
const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
|
|
917
1275
|
const caption = h("caption", {}, [t(title)]);
|
|
918
1276
|
const headers = columns.map((column, index) => {
|
|
919
1277
|
const name = column.label ?? label(column.field);
|
|
920
|
-
const content =
|
|
1278
|
+
const content = canSort[index] ? h("button", {
|
|
921
1279
|
type: "button",
|
|
922
1280
|
disabled: true,
|
|
923
1281
|
dataColumn: index,
|
|
@@ -931,18 +1289,20 @@ function table(rows, columns, title, id, sortable = true) {
|
|
|
931
1289
|
return h("th", {
|
|
932
1290
|
scope: "col",
|
|
933
1291
|
className: numeric[index] ? ["numeric"] : [],
|
|
934
|
-
...
|
|
1292
|
+
...canSort[index] ? { ariaSort: "none" } : {}
|
|
935
1293
|
}, [content]);
|
|
936
1294
|
});
|
|
937
1295
|
const head = h("thead", {}, [h("tr", {}, headers)]);
|
|
938
1296
|
const tableRows = rows.map((row) => {
|
|
939
1297
|
const cells = columns.map((column, index) => {
|
|
940
1298
|
const value = row[column.field] ?? null;
|
|
1299
|
+
const rich = value !== null && typeof value === "object";
|
|
1300
|
+
const raw = rich ? value.sort ?? null : value;
|
|
941
1301
|
return h("td", {
|
|
942
1302
|
className: numeric[index] ? ["numeric"] : [],
|
|
943
|
-
dataRaw: serializeData(
|
|
1303
|
+
...canSort[index] ? { dataRaw: serializeData(raw) } : {},
|
|
944
1304
|
...typeof value === "number" ? { dataSentiment: valueSentiment(value, column.semantics) } : {}
|
|
945
|
-
}, [t(cellText(value, column))]);
|
|
1305
|
+
}, rich ? richCell(value, column, scales[index]) : [t(cellText(value, column))]);
|
|
946
1306
|
});
|
|
947
1307
|
return h("tr", {}, cells);
|
|
948
1308
|
});
|
|
@@ -1291,7 +1651,7 @@ var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replace
|
|
|
1291
1651
|
var reload = (revision) => `(()=>{const source=new EventSource("/__plotleaf/events");source.addEventListener("ready",event=>{if(Number(event.data)!==${revision})location.reload()});source.addEventListener("reload",()=>location.reload());addEventListener("pagehide",()=>source.close(),{once:true});})();`;
|
|
1292
1652
|
function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
|
|
1293
1653
|
const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
|
|
1294
|
-
const hashes = code.map((script) => `'sha256-${
|
|
1654
|
+
const hashes = code.map((script) => `'sha256-${createHash2("sha256").update(script).digest("base64")}'`).join(" ");
|
|
1295
1655
|
const policy = `default-src 'none'; script-src ${hashes || "'none'"}; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src ${preview ? "'self'" : "'none'"}; base-uri 'none'; object-src 'none'; form-action 'none'`;
|
|
1296
1656
|
return `<!doctype html>
|
|
1297
1657
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHtml(policy)}"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)}</title>${description ? `<meta name="description" content="${escapeHtml(description)}">` : ""}<style>${styles}</style></head><body><a class="skip-link" href="#plotleaf-report">Skip to report</a><main class="report" id="plotleaf-report">${body}</main>${payload ? `<script type="application/json" id="plotleaf-data">${payload}</script>` : ""}${notices ? `<template id="plotleaf-notices"><pre>${escapeHtml(notices)}</pre></template>` : ""}${code.map((script) => `<script>${script}</script>`).join("")}</body></html>
|
|
@@ -1299,7 +1659,8 @@ function documentHtml(title, description, body, styles, scripts, preview, payloa
|
|
|
1299
1659
|
}
|
|
1300
1660
|
function renderHtml(report, assets, preview = false, revision = 0) {
|
|
1301
1661
|
const content = renderContent(report);
|
|
1302
|
-
|
|
1662
|
+
const images = report.images?.length ? `<svg class="image-definitions" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><defs>${report.images.map((image) => `<symbol id="${escapeHtml(image.id)}" viewBox="0 0 64 48"><image href="data:${image.mime};base64,${image.base64}" width="64" height="48" preserveAspectRatio="xMidYMid meet"/></symbol>`).join("")}</defs></svg>` : "";
|
|
1663
|
+
return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, content.hasCharts ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
|
|
1303
1664
|
}
|
|
1304
1665
|
function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
|
|
1305
1666
|
const body = `<h1>Report needs attention</h1><p class="diagnostic-intro">Update the source files to refresh this preview.</p>${diagnostics.map((item) => `<section class="diagnostic"><p class="diagnostic-location">${escapeHtml(item.file)}:${item.line}:${item.column} · ${escapeHtml(item.code)}</p><p>${escapeHtml(item.message)}</p>${item.hint ? `<p class="diagnostic-hint">${escapeHtml(item.hint)}</p>` : ""}</section>`).join("")}`;
|
|
@@ -1307,7 +1668,7 @@ function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
|
|
|
1307
1668
|
}
|
|
1308
1669
|
|
|
1309
1670
|
// src/export.ts
|
|
1310
|
-
import
|
|
1671
|
+
import path5 from "node:path";
|
|
1311
1672
|
import { randomUUID } from "node:crypto";
|
|
1312
1673
|
import {
|
|
1313
1674
|
lstat,
|
|
@@ -1319,8 +1680,8 @@ import {
|
|
|
1319
1680
|
rm
|
|
1320
1681
|
} from "node:fs/promises";
|
|
1321
1682
|
async function writeReport(filename, html, inputs) {
|
|
1322
|
-
const destination =
|
|
1323
|
-
if (
|
|
1683
|
+
const destination = path5.resolve(filename);
|
|
1684
|
+
if (path5.extname(destination).toLowerCase() !== ".html")
|
|
1324
1685
|
throw new Error("Output must have an .html extension.");
|
|
1325
1686
|
let existing;
|
|
1326
1687
|
try {
|
|
@@ -1334,7 +1695,7 @@ async function writeReport(filename, html, inputs) {
|
|
|
1334
1695
|
if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
|
|
1335
1696
|
throw error;
|
|
1336
1697
|
}
|
|
1337
|
-
let directory =
|
|
1698
|
+
let directory = path5.dirname(destination);
|
|
1338
1699
|
const missing = [];
|
|
1339
1700
|
while (true) {
|
|
1340
1701
|
try {
|
|
@@ -1343,16 +1704,16 @@ async function writeReport(filename, html, inputs) {
|
|
|
1343
1704
|
} catch (error) {
|
|
1344
1705
|
if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
|
|
1345
1706
|
throw error;
|
|
1346
|
-
missing.unshift(
|
|
1347
|
-
const parent =
|
|
1707
|
+
missing.unshift(path5.basename(directory));
|
|
1708
|
+
const parent = path5.dirname(directory);
|
|
1348
1709
|
if (parent === directory)
|
|
1349
1710
|
throw error;
|
|
1350
1711
|
directory = parent;
|
|
1351
1712
|
}
|
|
1352
1713
|
}
|
|
1353
|
-
const canonical =
|
|
1714
|
+
const canonical = path5.join(directory, ...missing, path5.basename(destination));
|
|
1354
1715
|
for (const input of inputs) {
|
|
1355
|
-
if (
|
|
1716
|
+
if (path5.resolve(input) === canonical || path5.resolve(input) === destination)
|
|
1356
1717
|
throw new Error("Output would overwrite a report input or dependency.");
|
|
1357
1718
|
if (existing) {
|
|
1358
1719
|
try {
|
|
@@ -1365,8 +1726,8 @@ async function writeReport(filename, html, inputs) {
|
|
|
1365
1726
|
}
|
|
1366
1727
|
}
|
|
1367
1728
|
}
|
|
1368
|
-
await mkdir(
|
|
1369
|
-
const temporary =
|
|
1729
|
+
await mkdir(path5.dirname(destination), { recursive: true });
|
|
1730
|
+
const temporary = path5.join(path5.dirname(destination), `.plotleaf-${randomUUID()}.tmp`);
|
|
1370
1731
|
try {
|
|
1371
1732
|
await writeFile(temporary, html, {
|
|
1372
1733
|
encoding: "utf8",
|
|
@@ -1531,7 +1892,7 @@ data: ${revision}
|
|
|
1531
1892
|
// package.json
|
|
1532
1893
|
var package_default = {
|
|
1533
1894
|
name: "plotleaf",
|
|
1534
|
-
version: "0.
|
|
1895
|
+
version: "0.3.1",
|
|
1535
1896
|
description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
|
|
1536
1897
|
type: "module",
|
|
1537
1898
|
license: "MIT",
|
|
@@ -1557,12 +1918,17 @@ var package_default = {
|
|
|
1557
1918
|
"dist/assets/styles.css",
|
|
1558
1919
|
"dist/assets/notices.txt",
|
|
1559
1920
|
"schema/components.json",
|
|
1921
|
+
"examples/report.md",
|
|
1560
1922
|
"examples/storefront/*.md",
|
|
1561
1923
|
"examples/storefront/*.json",
|
|
1562
1924
|
"examples/adoption/*.md",
|
|
1563
1925
|
"examples/adoption/*.json",
|
|
1564
1926
|
"examples/onboarding/*.md",
|
|
1565
1927
|
"examples/onboarding/*.json",
|
|
1928
|
+
"examples/catalog/*.md",
|
|
1929
|
+
"examples/catalog/*.json",
|
|
1930
|
+
"examples/catalog/assets/*.png",
|
|
1931
|
+
"examples/catalog/assets/*.jpg",
|
|
1566
1932
|
"skills/plotleaf/SKILL.md",
|
|
1567
1933
|
"README.md",
|
|
1568
1934
|
"LICENSE",
|
|
@@ -1577,10 +1943,12 @@ var package_default = {
|
|
|
1577
1943
|
"test:browser": "node node_modules/@playwright/test/cli.js test",
|
|
1578
1944
|
"test:consumer": "node tests/consumer/smoke.mjs",
|
|
1579
1945
|
check: "bun run typecheck && bun run build && bun run test && bun run test:consumer",
|
|
1580
|
-
"audit:package": "node scripts/audit-package.mjs"
|
|
1946
|
+
"audit:package": "node scripts/audit-package.mjs",
|
|
1947
|
+
dev: "bun run build && node dist/cli.js dev examples/report.md"
|
|
1581
1948
|
},
|
|
1582
1949
|
dependencies: {
|
|
1583
1950
|
chokidar: "6.0.0",
|
|
1951
|
+
"image-size": "2.1.0",
|
|
1584
1952
|
open: "11.0.4",
|
|
1585
1953
|
"rehype-sanitize": "6.0.0",
|
|
1586
1954
|
"rehype-stringify": "10.0.1",
|
|
@@ -1620,6 +1988,7 @@ Usage:
|
|
|
1620
1988
|
npx plotleaf dev <report.md> [--no-open] [--port 3000]
|
|
1621
1989
|
npx plotleaf validate <report.md> [--json] Validate syntax and local data
|
|
1622
1990
|
npx plotleaf build <report.md> [--out report.html]
|
|
1991
|
+
npx plotleaf guide Read the report-authoring guide
|
|
1623
1992
|
npx plotleaf schema Print component specifications
|
|
1624
1993
|
|
|
1625
1994
|
Options:
|
|
@@ -1660,14 +2029,17 @@ async function main() {
|
|
|
1660
2029
|
return;
|
|
1661
2030
|
}
|
|
1662
2031
|
const first = positionals[0];
|
|
1663
|
-
const known = ["dev", "validate", "build", "schema"];
|
|
2032
|
+
const known = ["dev", "validate", "build", "schema", "guide"];
|
|
1664
2033
|
const command = known.includes(first) ? first : "dev";
|
|
1665
2034
|
const filename = known.includes(first) ? positionals[1] : first;
|
|
1666
2035
|
const extras = positionals.slice(known.includes(first) ? 2 : 1);
|
|
1667
|
-
if (command === "schema") {
|
|
2036
|
+
if (command === "schema" || command === "guide") {
|
|
1668
2037
|
if (positionals.length !== 1 || Object.keys(values).length)
|
|
1669
|
-
throw new Error(
|
|
1670
|
-
|
|
2038
|
+
throw new Error(`${command} does not accept a report or options. Use plotleaf ${command}.`);
|
|
2039
|
+
if (command === "guide")
|
|
2040
|
+
process.stdout.write(await readFile(new URL("../skills/plotleaf/SKILL.md", import.meta.url), "utf8"));
|
|
2041
|
+
else
|
|
2042
|
+
console.log(JSON.stringify(componentSpec(), null, 2));
|
|
1671
2043
|
return;
|
|
1672
2044
|
}
|
|
1673
2045
|
if (!filename || extras.length)
|
|
@@ -1706,7 +2078,7 @@ async function main() {
|
|
|
1706
2078
|
return;
|
|
1707
2079
|
}
|
|
1708
2080
|
const html = renderHtml(result.report, await assets());
|
|
1709
|
-
const defaultOutput = result.filename.slice(0, -
|
|
2081
|
+
const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
|
|
1710
2082
|
const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
|
|
1711
2083
|
console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
|
|
1712
2084
|
return;
|