plotleaf 0.1.0 → 0.3.0
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 +42 -6
- package/dist/assets/base.js +1 -1
- package/dist/assets/charts.js +17 -17
- package/dist/assets/styles.css +244 -0
- package/dist/cli.js +798 -124
- 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/onboarding/funnel.json +30 -0
- package/examples/onboarding/plans.json +14 -0
- package/examples/onboarding/report.md +42 -0
- package/examples/onboarding/signups.json +50 -0
- package/examples/report.md +96 -0
- package/package.json +19 -2
- package/schema/components.json +169 -5
- package/skills/plotleaf/SKILL.md +34 -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,7 +62,7 @@ ${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
|
|
|
@@ -74,25 +74,38 @@ var limits = {
|
|
|
74
74
|
datasets: 64,
|
|
75
75
|
rows: 1e5,
|
|
76
76
|
chartRows: 1e4,
|
|
77
|
+
donutSlices: 8,
|
|
78
|
+
funnelStages: 20,
|
|
77
79
|
tableRows: 1000,
|
|
78
|
-
tableColumns: 40
|
|
80
|
+
tableColumns: 40,
|
|
81
|
+
sparklinePoints: 64,
|
|
82
|
+
imageFiles: 64,
|
|
83
|
+
imageBytes: 1024 ** 2,
|
|
84
|
+
imageDimension: 4096,
|
|
85
|
+
imagePixels: 4000000
|
|
79
86
|
};
|
|
80
87
|
|
|
81
|
-
// src/compiler/
|
|
82
|
-
|
|
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
|
+
}
|
|
83
96
|
function contained(root, filename) {
|
|
84
97
|
const relative = path2.relative(root, filename);
|
|
85
98
|
return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
|
|
86
99
|
}
|
|
87
|
-
async function
|
|
100
|
+
async function readBytesBounded(filename, maximum) {
|
|
88
101
|
if (!(await stat(filename)).isFile())
|
|
89
102
|
throw new Error("Expected a regular file.");
|
|
90
103
|
const file = await open(filename, "r");
|
|
91
104
|
try {
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
105
|
+
const info = await file.stat();
|
|
106
|
+
if (!info.isFile())
|
|
94
107
|
throw new Error("Expected a regular file.");
|
|
95
|
-
if (
|
|
108
|
+
if (info.size > maximum)
|
|
96
109
|
throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
|
|
97
110
|
const chunks = [];
|
|
98
111
|
let bytes = 0;
|
|
@@ -106,14 +119,158 @@ async function readBounded(filename, maximum) {
|
|
|
106
119
|
}
|
|
107
120
|
if (bytes > maximum)
|
|
108
121
|
throw new Error("File grew beyond the size limit while reading.");
|
|
109
|
-
return
|
|
110
|
-
text: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, bytes)),
|
|
111
|
-
bytes
|
|
112
|
-
};
|
|
122
|
+
return Buffer.concat(chunks, bytes);
|
|
113
123
|
} finally {
|
|
114
124
|
await file.close();
|
|
115
125
|
}
|
|
116
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"]);
|
|
117
274
|
function parseDate(value) {
|
|
118
275
|
const date = value.slice(0, 10);
|
|
119
276
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
@@ -133,87 +290,87 @@ class DataResolver {
|
|
|
133
290
|
root;
|
|
134
291
|
diagnostics;
|
|
135
292
|
dependencies;
|
|
136
|
-
totalBytes;
|
|
137
293
|
cache = new Map;
|
|
294
|
+
files;
|
|
295
|
+
images;
|
|
138
296
|
constructor(root, diagnostics, dependencies, totalBytes = 0) {
|
|
139
297
|
this.root = root;
|
|
140
298
|
this.diagnostics = diagnostics;
|
|
141
299
|
this.dependencies = dependencies;
|
|
142
|
-
this.
|
|
300
|
+
this.files = new ReportFiles(root, dependencies, totalBytes);
|
|
301
|
+
this.images = new ImageResolver(this.files);
|
|
143
302
|
}
|
|
144
303
|
async load(reference, block) {
|
|
145
304
|
const fail = (code, message, hint) => {
|
|
146
305
|
this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
|
|
147
306
|
return;
|
|
148
307
|
};
|
|
149
|
-
if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
|
|
150
|
-
return fail("E_DATA_PATH", "Use a plain relative local .json path.");
|
|
151
|
-
const candidate = path2.resolve(this.root, reference);
|
|
152
|
-
if (!contained(this.root, candidate))
|
|
153
|
-
return fail("E_DATA_PATH", "Reference escapes the report directory.", "Place prepared JSON beside the report or in one of its subdirectories.");
|
|
154
|
-
if (path2.relative(this.root, candidate).split(path2.sep).some((segment) => segment.startsWith(".")))
|
|
155
|
-
return fail("E_DATA_PATH", "Hidden files and directories are not allowed.");
|
|
156
|
-
if (path2.extname(candidate).toLowerCase() !== ".json")
|
|
157
|
-
return fail("E_DATA_PATH", "Datasets must be .json files.");
|
|
158
|
-
this.dependencies.add(candidate);
|
|
159
308
|
let canonical;
|
|
160
309
|
try {
|
|
161
|
-
canonical = await
|
|
162
|
-
} catch {
|
|
163
|
-
return fail("E_DATA_READ", "Cannot read the referenced dataset.", "Create the file at this path or correct the data property.");
|
|
164
|
-
}
|
|
165
|
-
if (!contained(this.root, canonical) || path2.relative(this.root, canonical).split(path2.sep).some((segment) => segment.startsWith(".")))
|
|
166
|
-
return fail("E_DATA_PATH", "The resolved file or symlink escapes the permitted report files.");
|
|
167
|
-
if (path2.extname(canonical).toLowerCase() !== ".json")
|
|
168
|
-
return fail("E_DATA_PATH", "The resolved file must also have a .json extension.");
|
|
169
|
-
this.dependencies.add(canonical);
|
|
170
|
-
const cached = this.cache.get(canonical);
|
|
171
|
-
if (cached)
|
|
172
|
-
return cached;
|
|
173
|
-
if (this.cache.size >= limits.datasets)
|
|
174
|
-
return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
|
|
175
|
-
let input;
|
|
176
|
-
try {
|
|
177
|
-
input = await readBounded(canonical, Math.min(limits.jsonBytes, limits.totalBytes - this.totalBytes));
|
|
310
|
+
canonical = await this.files.resolve(reference, [".json"]);
|
|
178
311
|
} catch (error) {
|
|
179
|
-
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.");
|
|
180
313
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (parsed.length > limits.rows)
|
|
191
|
-
return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller aggregate dataset before rendering.");
|
|
192
|
-
for (const [index, row] of parsed.entries()) {
|
|
193
|
-
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
194
|
-
return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
|
|
195
|
-
for (const [key, value] of Object.entries(row)) {
|
|
196
|
-
if (reserved.has(key))
|
|
197
|
-
return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
|
|
198
|
-
if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
|
|
199
|
-
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.");
|
|
200
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);
|
|
201
354
|
}
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
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;
|
|
205
360
|
}
|
|
206
361
|
async resolve(blocks) {
|
|
207
362
|
for (const block of blocks) {
|
|
208
363
|
if (block.type !== "component")
|
|
209
364
|
continue;
|
|
210
|
-
if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "table") {
|
|
365
|
+
if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
|
|
211
366
|
const rows = await this.load(block.props.data, block);
|
|
212
367
|
if (rows) {
|
|
213
368
|
if (!rows.length)
|
|
214
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");
|
|
215
370
|
if (block.name === "table")
|
|
216
|
-
this.resolveTable(block, rows);
|
|
371
|
+
await this.resolveTable(block, rows);
|
|
372
|
+
else if (block.name === "donut" || block.name === "funnel")
|
|
373
|
+
this.resolveParts(block, rows);
|
|
217
374
|
else
|
|
218
375
|
this.resolveChart(block, rows);
|
|
219
376
|
}
|
|
@@ -265,7 +422,72 @@ class DataResolver {
|
|
|
265
422
|
}
|
|
266
423
|
block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
|
|
267
424
|
}
|
|
268
|
-
|
|
425
|
+
resolveParts(block, rows) {
|
|
426
|
+
const props = block.props;
|
|
427
|
+
const maximum = block.name === "donut" ? limits.donutSlices : limits.funnelStages;
|
|
428
|
+
const fields = block.name === "donut" ? [block.props.category, props.value] : [
|
|
429
|
+
block.props.stage,
|
|
430
|
+
props.value,
|
|
431
|
+
block.props.total,
|
|
432
|
+
block.props.share,
|
|
433
|
+
...block.props.status ? [block.props.status] : []
|
|
434
|
+
];
|
|
435
|
+
const fail = (code, message, hint) => {
|
|
436
|
+
this.diagnostics.add(code, `${props.data}: ${message}`, block.position, hint);
|
|
437
|
+
};
|
|
438
|
+
if (rows.length > maximum) {
|
|
439
|
+
fail("E_DATA_LIMIT", `${block.name} supports at most ${maximum} rows.`, "Prepare a smaller summary; plotleaf does not group or truncate data.");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (fields.some((field) => !this.field(rows, field, block)))
|
|
443
|
+
return;
|
|
444
|
+
const seen = new Set;
|
|
445
|
+
let sum = 0;
|
|
446
|
+
for (const [index, row] of rows.entries()) {
|
|
447
|
+
const name = row[fields[0]];
|
|
448
|
+
const value = row[props.value];
|
|
449
|
+
if (typeof name !== "string" || !name.trim() || name.length > 1000) {
|
|
450
|
+
fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(fields[0])} must be a nonblank string of at most 1000 characters.`, "Supply a short, explicit label for every category or stage.");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (seen.has(name)) {
|
|
454
|
+
fail("E_DATA_ORDER", `Row ${index + 1}: category/stage labels must be unique.`, "Prepare distinct labels; source order is preserved.");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
seen.add(name);
|
|
458
|
+
if (typeof value !== "number" || value < 0) {
|
|
459
|
+
fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(props.value)} must be a nonnegative number.`, "Use finite JSON numbers, not nulls or numeric strings.");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (block.name === "donut") {
|
|
463
|
+
sum += value;
|
|
464
|
+
if (!Number.isFinite(sum)) {
|
|
465
|
+
fail("E_DATA_VALUE", "The donut total exceeds the finite numeric range.", "Prepare values in smaller units before rendering.");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
const total = row[block.props.total];
|
|
470
|
+
const share = row[block.props.share];
|
|
471
|
+
if (typeof total !== "number" || total <= 0 || value > total || typeof share !== "number" || share < 0 || share > 1) {
|
|
472
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: total must be positive, value must not exceed total, and share must be a number from 0 to 1.`, "Supply each stage's denominator and prepared ratio explicitly; use an empty array when no baseline exists.");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (Math.abs(share - value / total) > 0.0005 + Number.EPSILON) {
|
|
476
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: share does not match value / total.`, "Prepare a ratio consistent with the displayed values; rounding to 0.1 percentage point is supported.");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (block.props.status) {
|
|
480
|
+
const status = row[block.props.status];
|
|
481
|
+
if (typeof status !== "string" || !Object.hasOwn(funnelStatuses, status)) {
|
|
482
|
+
fail("E_DATA_VALUE", `Row ${index + 1}: status must be ${Object.keys(funnelStatuses).join(", ")}.`, "Supply business status explicitly, or omit the status property for neutral styling.");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
|
|
489
|
+
}
|
|
490
|
+
async resolveTable(block, rows) {
|
|
269
491
|
if (rows.length > limits.tableRows) {
|
|
270
492
|
this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
|
|
271
493
|
return;
|
|
@@ -281,24 +503,101 @@ class DataResolver {
|
|
|
281
503
|
this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
|
|
282
504
|
return;
|
|
283
505
|
}
|
|
284
|
-
if (new Set(columns.map((column) => column.field)).size !== columns.length)
|
|
506
|
+
if (new Set(columns.map((column) => column.field)).size !== columns.length) {
|
|
285
507
|
this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
286
510
|
for (const [index, row] of rows.entries())
|
|
287
511
|
if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
|
|
288
512
|
this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
|
|
289
513
|
return;
|
|
290
514
|
}
|
|
515
|
+
const prepared = rows.map(() => ({}));
|
|
291
516
|
for (const column of columns) {
|
|
292
|
-
|
|
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)))
|
|
293
524
|
continue;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
+
}
|
|
299
598
|
}
|
|
300
599
|
block.columns = columns;
|
|
301
|
-
block.rows =
|
|
600
|
+
block.rows = prepared;
|
|
302
601
|
}
|
|
303
602
|
}
|
|
304
603
|
|
|
@@ -330,7 +629,16 @@ var line = z.strictObject({
|
|
|
330
629
|
var registry = {
|
|
331
630
|
grid: {
|
|
332
631
|
forms: ["container"],
|
|
333
|
-
children: [
|
|
632
|
+
children: [
|
|
633
|
+
"metric",
|
|
634
|
+
"line",
|
|
635
|
+
"area",
|
|
636
|
+
"bar",
|
|
637
|
+
"donut",
|
|
638
|
+
"funnel",
|
|
639
|
+
"table",
|
|
640
|
+
"callout"
|
|
641
|
+
],
|
|
334
642
|
description: "A responsive row of one to four components.",
|
|
335
643
|
schema: z.strictObject({
|
|
336
644
|
columns: z.number().int().min(1).max(4).default(2)
|
|
@@ -374,6 +682,48 @@ var registry = {
|
|
|
374
682
|
schema: z.strictObject(chart),
|
|
375
683
|
example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
|
|
376
684
|
},
|
|
685
|
+
donut: {
|
|
686
|
+
forms: ["leaf"],
|
|
687
|
+
children: [],
|
|
688
|
+
description: "A part-to-whole donut with a readable legend and native data table.",
|
|
689
|
+
schema: z.strictObject({
|
|
690
|
+
data: text,
|
|
691
|
+
category: text,
|
|
692
|
+
value: text,
|
|
693
|
+
title: text,
|
|
694
|
+
format: format.default("number")
|
|
695
|
+
}),
|
|
696
|
+
dataRules: {
|
|
697
|
+
category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
698
|
+
value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
|
|
699
|
+
maximumRows: limits.donutSlices
|
|
700
|
+
},
|
|
701
|
+
example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
|
|
702
|
+
},
|
|
703
|
+
funnel: {
|
|
704
|
+
forms: ["leaf"],
|
|
705
|
+
children: [],
|
|
706
|
+
description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
|
|
707
|
+
schema: z.strictObject({
|
|
708
|
+
data: text,
|
|
709
|
+
stage: text,
|
|
710
|
+
value: text,
|
|
711
|
+
total: text,
|
|
712
|
+
share: text,
|
|
713
|
+
status: text.optional(),
|
|
714
|
+
title: text,
|
|
715
|
+
format: format.default("number")
|
|
716
|
+
}),
|
|
717
|
+
dataRules: {
|
|
718
|
+
stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
719
|
+
value: "Finite nonnegative number no greater than its row's total.",
|
|
720
|
+
total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
|
|
721
|
+
share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
|
|
722
|
+
status: Object.keys(funnelStatuses),
|
|
723
|
+
maximumRows: limits.funnelStages
|
|
724
|
+
},
|
|
725
|
+
example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
|
|
726
|
+
},
|
|
377
727
|
table: {
|
|
378
728
|
forms: ["leaf", "container"],
|
|
379
729
|
children: ["column"],
|
|
@@ -387,15 +737,23 @@ var registry = {
|
|
|
387
737
|
column: {
|
|
388
738
|
forms: ["leaf"],
|
|
389
739
|
children: [],
|
|
390
|
-
description: "
|
|
740
|
+
description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
|
|
391
741
|
schema: z.strictObject({
|
|
392
742
|
field: text,
|
|
393
743
|
label: text.optional(),
|
|
744
|
+
kind: z.enum(["scalar", "image", "sparkline"]).optional(),
|
|
745
|
+
alt: text.optional(),
|
|
746
|
+
sortBy: text.optional(),
|
|
394
747
|
format: format.optional(),
|
|
395
748
|
sign: z.enum(["auto", "always"]).default("auto"),
|
|
396
749
|
semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
|
|
397
750
|
}),
|
|
398
|
-
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
|
+
}
|
|
399
757
|
},
|
|
400
758
|
callout: {
|
|
401
759
|
forms: ["container"],
|
|
@@ -417,7 +775,7 @@ function componentSpec() {
|
|
|
417
775
|
attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
|
|
418
776
|
limits,
|
|
419
777
|
data: {
|
|
420
|
-
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",
|
|
421
779
|
resolution: "relative to the report directory; local .json files only",
|
|
422
780
|
percent: "ratios: 0.12 displays as 12%",
|
|
423
781
|
percentagePoints: "point values: 0.8 displays as 0.8 pt",
|
|
@@ -432,7 +790,8 @@ function componentSpec() {
|
|
|
432
790
|
forms: entry.forms,
|
|
433
791
|
children: entry.children,
|
|
434
792
|
props: z.toJSONSchema(entry.schema),
|
|
435
|
-
example: entry.example
|
|
793
|
+
example: entry.example,
|
|
794
|
+
..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
|
|
436
795
|
}
|
|
437
796
|
])),
|
|
438
797
|
rules: [
|
|
@@ -444,7 +803,10 @@ function componentSpec() {
|
|
|
444
803
|
"Dataset fields are literal own-property keys, not expressions.",
|
|
445
804
|
"Charts preserve source order; date/number x values must increase.",
|
|
446
805
|
"Line/area null y values create gaps; bar null y values are invalid.",
|
|
447
|
-
"
|
|
806
|
+
"Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
|
|
807
|
+
"Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
|
|
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."
|
|
448
810
|
]
|
|
449
811
|
};
|
|
450
812
|
}
|
|
@@ -467,6 +829,27 @@ function decodeProps(name, attributes) {
|
|
|
467
829
|
};
|
|
468
830
|
const props = result.data;
|
|
469
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
|
+
}
|
|
848
|
+
if (name === "donut" || name === "funnel") {
|
|
849
|
+
const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
|
|
850
|
+
if (new Set(fields).size !== fields.length)
|
|
851
|
+
errors.push(`${name} field properties must reference different dataset fields.`);
|
|
852
|
+
}
|
|
470
853
|
if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
|
|
471
854
|
errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
|
|
472
855
|
if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
|
|
@@ -508,7 +891,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
|
|
|
508
891
|
if (node.type === "html")
|
|
509
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.");
|
|
510
893
|
if (node.type === "image" || node.type === "imageReference")
|
|
511
|
-
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.');
|
|
512
895
|
if (node.type === "link" || node.type === "definition") {
|
|
513
896
|
const url = node.url ?? "";
|
|
514
897
|
let allowed = url.startsWith("#");
|
|
@@ -647,7 +1030,7 @@ function parseReport(source, fallbackTitle, diagnostics) {
|
|
|
647
1030
|
|
|
648
1031
|
// src/compiler/compile.ts
|
|
649
1032
|
async function compileReport(input) {
|
|
650
|
-
const requested =
|
|
1033
|
+
const requested = path4.resolve(input);
|
|
651
1034
|
const diagnostics = new Diagnostics(displayPath(requested));
|
|
652
1035
|
const dependencies = new Set([requested]);
|
|
653
1036
|
const result = () => ({
|
|
@@ -656,16 +1039,18 @@ async function compileReport(input) {
|
|
|
656
1039
|
filename: requested
|
|
657
1040
|
});
|
|
658
1041
|
try {
|
|
659
|
-
if (
|
|
1042
|
+
if (path4.extname(requested).toLowerCase() !== ".md") {
|
|
660
1043
|
diagnostics.add("E_REPORT_PATH", "A report must be a .md Markdown file.");
|
|
661
1044
|
return result();
|
|
662
1045
|
}
|
|
663
1046
|
const filename = await realpath2(requested);
|
|
664
1047
|
dependencies.add(filename);
|
|
665
1048
|
const { text, bytes } = await readBounded(filename, limits.markdownBytes);
|
|
666
|
-
const report = parseReport(text,
|
|
667
|
-
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);
|
|
668
1051
|
await resolver.resolve(report.blocks);
|
|
1052
|
+
if (resolver.images.embedded.size)
|
|
1053
|
+
report.images = [...resolver.images.embedded.values()];
|
|
669
1054
|
return { ...result(), ...!diagnostics.hasErrors ? { report } : {} };
|
|
670
1055
|
} catch (error) {
|
|
671
1056
|
diagnostics.add("E_REPORT_READ", error instanceof Error ? error.message.replaceAll(requested, displayPath(requested)) : "Could not compile the report.");
|
|
@@ -674,7 +1059,7 @@ async function compileReport(input) {
|
|
|
674
1059
|
}
|
|
675
1060
|
|
|
676
1061
|
// src/render/html.ts
|
|
677
|
-
import { createHash } from "node:crypto";
|
|
1062
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
678
1063
|
|
|
679
1064
|
// src/render/content.ts
|
|
680
1065
|
import { unified as unified2 } from "unified";
|
|
@@ -682,6 +1067,13 @@ import remarkRehype from "remark-rehype";
|
|
|
682
1067
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
683
1068
|
import rehypeStringify from "rehype-stringify";
|
|
684
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
|
+
|
|
685
1077
|
// src/shared/format.ts
|
|
686
1078
|
var numbers = new Map;
|
|
687
1079
|
function formatValue(value, format = "number", sign = "auto", compact = false) {
|
|
@@ -715,14 +1107,106 @@ function valueSentiment(value, semantics = "neutral") {
|
|
|
715
1107
|
return value > 0 === (semantics === "higher-is-better") ? "positive" : "negative";
|
|
716
1108
|
}
|
|
717
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
|
+
|
|
718
1204
|
// src/shared/serialize.ts
|
|
719
1205
|
function serializeData(value) {
|
|
720
1206
|
return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
|
|
721
1207
|
}
|
|
722
1208
|
|
|
723
1209
|
// src/render/content.ts
|
|
724
|
-
var t = (value) => ({ type: "text", value: String(value) });
|
|
725
|
-
var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
|
|
726
1210
|
var markdown = unified2().use(remarkRehype);
|
|
727
1211
|
var serializer = unified2().use(rehypeSanitize, {
|
|
728
1212
|
...defaultSchema,
|
|
@@ -732,7 +1216,12 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
732
1216
|
"figure",
|
|
733
1217
|
"figcaption",
|
|
734
1218
|
"button",
|
|
735
|
-
"caption"
|
|
1219
|
+
"caption",
|
|
1220
|
+
"meter",
|
|
1221
|
+
"svg",
|
|
1222
|
+
"use",
|
|
1223
|
+
"path",
|
|
1224
|
+
"circle"
|
|
736
1225
|
],
|
|
737
1226
|
attributes: {
|
|
738
1227
|
...defaultSchema.attributes,
|
|
@@ -751,10 +1240,20 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
751
1240
|
"dataRaw",
|
|
752
1241
|
"dataColumns",
|
|
753
1242
|
"dataSentiment",
|
|
754
|
-
"dataTone"
|
|
1243
|
+
"dataTone",
|
|
1244
|
+
"dataStatus",
|
|
1245
|
+
"dataSlice"
|
|
755
1246
|
],
|
|
756
1247
|
button: ["type", "disabled", "ariaLabel", "dataColumn"],
|
|
757
1248
|
section: ["className", "ariaLabel"],
|
|
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"],
|
|
1254
|
+
ul: ["className", "role"],
|
|
1255
|
+
ol: ["start", "className", "role"],
|
|
1256
|
+
li: ["className", "dataStatus"],
|
|
758
1257
|
th: ["scope", "ariaSort", "className"],
|
|
759
1258
|
td: ["className", "dataRaw"]
|
|
760
1259
|
}
|
|
@@ -770,11 +1269,13 @@ function cellText(value, column) {
|
|
|
770
1269
|
return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
|
|
771
1270
|
}
|
|
772
1271
|
function table(rows, columns, title, id, sortable = true) {
|
|
773
|
-
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));
|
|
774
1275
|
const caption = h("caption", {}, [t(title)]);
|
|
775
1276
|
const headers = columns.map((column, index) => {
|
|
776
1277
|
const name = column.label ?? label(column.field);
|
|
777
|
-
const content =
|
|
1278
|
+
const content = canSort[index] ? h("button", {
|
|
778
1279
|
type: "button",
|
|
779
1280
|
disabled: true,
|
|
780
1281
|
dataColumn: index,
|
|
@@ -788,18 +1289,20 @@ function table(rows, columns, title, id, sortable = true) {
|
|
|
788
1289
|
return h("th", {
|
|
789
1290
|
scope: "col",
|
|
790
1291
|
className: numeric[index] ? ["numeric"] : [],
|
|
791
|
-
...
|
|
1292
|
+
...canSort[index] ? { ariaSort: "none" } : {}
|
|
792
1293
|
}, [content]);
|
|
793
1294
|
});
|
|
794
1295
|
const head = h("thead", {}, [h("tr", {}, headers)]);
|
|
795
1296
|
const tableRows = rows.map((row) => {
|
|
796
1297
|
const cells = columns.map((column, index) => {
|
|
797
1298
|
const value = row[column.field] ?? null;
|
|
1299
|
+
const rich = value !== null && typeof value === "object";
|
|
1300
|
+
const raw = rich ? value.sort ?? null : value;
|
|
798
1301
|
return h("td", {
|
|
799
1302
|
className: numeric[index] ? ["numeric"] : [],
|
|
800
|
-
dataRaw: serializeData(
|
|
1303
|
+
...canSort[index] ? { dataRaw: serializeData(raw) } : {},
|
|
801
1304
|
...typeof value === "number" ? { dataSentiment: valueSentiment(value, column.semantics) } : {}
|
|
802
|
-
}, [t(cellText(value, column))]);
|
|
1305
|
+
}, rich ? richCell(value, column, scales[index]) : [t(cellText(value, column))]);
|
|
803
1306
|
});
|
|
804
1307
|
return h("tr", {}, cells);
|
|
805
1308
|
});
|
|
@@ -812,6 +1315,15 @@ function table(rows, columns, title, id, sortable = true) {
|
|
|
812
1315
|
])
|
|
813
1316
|
]);
|
|
814
1317
|
}
|
|
1318
|
+
function dataDisclosure(rows, columns, title, id) {
|
|
1319
|
+
return h("details", { className: ["chart-data"] }, [
|
|
1320
|
+
h("summary", {}, [
|
|
1321
|
+
t("View data"),
|
|
1322
|
+
h("span", { className: ["visually-hidden"] }, [t(` for ${title}`)])
|
|
1323
|
+
]),
|
|
1324
|
+
table(rows, columns, title, `${id}-data`, false)
|
|
1325
|
+
]);
|
|
1326
|
+
}
|
|
815
1327
|
function renderContent(report) {
|
|
816
1328
|
let sequence = 0;
|
|
817
1329
|
const payload = { charts: [] };
|
|
@@ -913,6 +1425,10 @@ function renderContent(report) {
|
|
|
913
1425
|
];
|
|
914
1426
|
case "column":
|
|
915
1427
|
return [];
|
|
1428
|
+
case "funnel":
|
|
1429
|
+
return renderFunnel(block, id);
|
|
1430
|
+
case "donut":
|
|
1431
|
+
return renderDonut(block, id);
|
|
916
1432
|
case "line":
|
|
917
1433
|
case "area":
|
|
918
1434
|
case "bar":
|
|
@@ -920,6 +1436,156 @@ function renderContent(report) {
|
|
|
920
1436
|
}
|
|
921
1437
|
});
|
|
922
1438
|
}
|
|
1439
|
+
function renderFunnel(block, id) {
|
|
1440
|
+
const { stage, value, total, share, status, title, format } = block.props;
|
|
1441
|
+
const rows = block.rows ?? [];
|
|
1442
|
+
const columns = [
|
|
1443
|
+
{ field: stage, label: label(stage), sign: "auto", semantics: "neutral" },
|
|
1444
|
+
{
|
|
1445
|
+
field: value,
|
|
1446
|
+
label: label(value),
|
|
1447
|
+
format,
|
|
1448
|
+
sign: "auto",
|
|
1449
|
+
semantics: "neutral"
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
field: total,
|
|
1453
|
+
label: label(total),
|
|
1454
|
+
format,
|
|
1455
|
+
sign: "auto",
|
|
1456
|
+
semantics: "neutral"
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
field: share,
|
|
1460
|
+
label: "Share",
|
|
1461
|
+
format: "percent",
|
|
1462
|
+
sign: "auto",
|
|
1463
|
+
semantics: "neutral"
|
|
1464
|
+
},
|
|
1465
|
+
...status ? [
|
|
1466
|
+
{
|
|
1467
|
+
field: status,
|
|
1468
|
+
label: "Status",
|
|
1469
|
+
sign: "auto",
|
|
1470
|
+
semantics: "neutral"
|
|
1471
|
+
}
|
|
1472
|
+
] : []
|
|
1473
|
+
];
|
|
1474
|
+
return [
|
|
1475
|
+
h("figure", { className: ["chart", "funnel"] }, [
|
|
1476
|
+
h("figcaption", { id: `${id}-title` }, [t(title)]),
|
|
1477
|
+
...rows.length ? [
|
|
1478
|
+
h("ol", { className: ["funnel-stages"], role: "list" }, rows.map((row) => {
|
|
1479
|
+
const state = status ? row[status] : "neutral";
|
|
1480
|
+
const rate = formatValue(row[share], "percent");
|
|
1481
|
+
const amount = formatValue(row[value], format, "auto", true);
|
|
1482
|
+
const denominator = formatValue(row[total], format, "auto", true);
|
|
1483
|
+
return h("li", { className: ["funnel-stage"], dataStatus: state }, [
|
|
1484
|
+
h("div", { className: ["funnel-heading"] }, [
|
|
1485
|
+
h("span", { className: ["funnel-label"] }, [
|
|
1486
|
+
t(row[stage])
|
|
1487
|
+
]),
|
|
1488
|
+
h("span", { className: ["funnel-values"] }, [
|
|
1489
|
+
h("strong", {}, [t(rate)]),
|
|
1490
|
+
h("span", {}, [t(`${amount} / ${denominator}`)])
|
|
1491
|
+
])
|
|
1492
|
+
]),
|
|
1493
|
+
h("meter", {
|
|
1494
|
+
min: "0",
|
|
1495
|
+
max: "1",
|
|
1496
|
+
value: String(row[share]),
|
|
1497
|
+
ariaLabel: `${row[stage]}: ${rate}, ${formatValue(row[value], format)} of ${formatValue(row[total], format)}${status ? `, ${funnelStatuses[state]}` : ""}`
|
|
1498
|
+
}, [t(rate)]),
|
|
1499
|
+
...status ? [
|
|
1500
|
+
h("span", { className: ["funnel-status"] }, [
|
|
1501
|
+
t(funnelStatuses[state])
|
|
1502
|
+
])
|
|
1503
|
+
] : []
|
|
1504
|
+
]);
|
|
1505
|
+
}))
|
|
1506
|
+
] : [
|
|
1507
|
+
h("p", { className: ["empty-state"] }, [
|
|
1508
|
+
t("No data to display.")
|
|
1509
|
+
])
|
|
1510
|
+
],
|
|
1511
|
+
...rows.length ? [
|
|
1512
|
+
dataDisclosure(rows.map((row) => status ? {
|
|
1513
|
+
...row,
|
|
1514
|
+
[status]: funnelStatuses[row[status]]
|
|
1515
|
+
} : row), columns, title, id)
|
|
1516
|
+
] : []
|
|
1517
|
+
])
|
|
1518
|
+
];
|
|
1519
|
+
}
|
|
1520
|
+
function renderDonut(block, id) {
|
|
1521
|
+
const { category, value, title, format } = block.props;
|
|
1522
|
+
const rows = block.rows ?? [];
|
|
1523
|
+
const total = rows.reduce((sum, row) => sum + row[value], 0);
|
|
1524
|
+
if (total > 0)
|
|
1525
|
+
payload.charts.push({
|
|
1526
|
+
id,
|
|
1527
|
+
kind: "donut",
|
|
1528
|
+
title,
|
|
1529
|
+
format,
|
|
1530
|
+
rows: rows.map((row) => [
|
|
1531
|
+
row[category],
|
|
1532
|
+
row[value]
|
|
1533
|
+
])
|
|
1534
|
+
});
|
|
1535
|
+
return [
|
|
1536
|
+
h("figure", { className: ["chart", "donut"] }, [
|
|
1537
|
+
h("figcaption", { id: `${id}-title` }, [t(title)]),
|
|
1538
|
+
...total > 0 ? [
|
|
1539
|
+
h("div", { className: ["donut-layout"] }, [
|
|
1540
|
+
h("div", {
|
|
1541
|
+
className: ["chart-plot", "donut-plot"],
|
|
1542
|
+
id,
|
|
1543
|
+
dataPlotleafChart: "",
|
|
1544
|
+
role: "img",
|
|
1545
|
+
ariaLabel: `${title}. Values and shares follow in the legend and data table.`
|
|
1546
|
+
}),
|
|
1547
|
+
h("ul", { className: ["donut-legend"], role: "list" }, rows.map((row, index) => h("li", {}, [
|
|
1548
|
+
h("span", { className: ["donut-key"] }, [
|
|
1549
|
+
h("span", {
|
|
1550
|
+
className: ["donut-swatch"],
|
|
1551
|
+
dataSlice: index + 1,
|
|
1552
|
+
ariaHidden: "true"
|
|
1553
|
+
}),
|
|
1554
|
+
h("span", {}, [t(row[category])])
|
|
1555
|
+
]),
|
|
1556
|
+
h("span", { className: ["donut-value"] }, [
|
|
1557
|
+
t(formatValue(row[value], format))
|
|
1558
|
+
]),
|
|
1559
|
+
h("span", { className: ["donut-share"] }, [
|
|
1560
|
+
t(formatValue(row[value] / total, "percent"))
|
|
1561
|
+
])
|
|
1562
|
+
])))
|
|
1563
|
+
])
|
|
1564
|
+
] : [
|
|
1565
|
+
h("p", { className: ["empty-state"] }, [
|
|
1566
|
+
t(rows.length ? "No non-zero values to display." : "No data to display.")
|
|
1567
|
+
])
|
|
1568
|
+
],
|
|
1569
|
+
...rows.length ? [
|
|
1570
|
+
dataDisclosure(rows, [
|
|
1571
|
+
{
|
|
1572
|
+
field: category,
|
|
1573
|
+
label: label(category),
|
|
1574
|
+
sign: "auto",
|
|
1575
|
+
semantics: "neutral"
|
|
1576
|
+
},
|
|
1577
|
+
{
|
|
1578
|
+
field: value,
|
|
1579
|
+
label: label(value),
|
|
1580
|
+
format,
|
|
1581
|
+
sign: "auto",
|
|
1582
|
+
semantics: "neutral"
|
|
1583
|
+
}
|
|
1584
|
+
], title, id)
|
|
1585
|
+
] : []
|
|
1586
|
+
])
|
|
1587
|
+
];
|
|
1588
|
+
}
|
|
923
1589
|
function renderChart(block, id) {
|
|
924
1590
|
const { x, y, title, format } = block.props;
|
|
925
1591
|
const xType = block.name === "bar" ? "category" : block.props.xType;
|
|
@@ -964,17 +1630,7 @@ function renderContent(report) {
|
|
|
964
1630
|
t("No data to display.")
|
|
965
1631
|
])
|
|
966
1632
|
],
|
|
967
|
-
...rows.length ? [
|
|
968
|
-
h("details", { className: ["chart-data"] }, [
|
|
969
|
-
h("summary", {}, [
|
|
970
|
-
t("View data"),
|
|
971
|
-
h("span", { className: ["visually-hidden"] }, [
|
|
972
|
-
t(` for ${title}`)
|
|
973
|
-
])
|
|
974
|
-
]),
|
|
975
|
-
table(rows, columns, title, `${id}-data`, false)
|
|
976
|
-
])
|
|
977
|
-
] : []
|
|
1633
|
+
...rows.length ? [dataDisclosure(rows, columns, title, id)] : []
|
|
978
1634
|
])
|
|
979
1635
|
];
|
|
980
1636
|
}
|
|
@@ -995,7 +1651,7 @@ var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replace
|
|
|
995
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});})();`;
|
|
996
1652
|
function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
|
|
997
1653
|
const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
|
|
998
|
-
const hashes = code.map((script) => `'sha256-${
|
|
1654
|
+
const hashes = code.map((script) => `'sha256-${createHash2("sha256").update(script).digest("base64")}'`).join(" ");
|
|
999
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'`;
|
|
1000
1656
|
return `<!doctype html>
|
|
1001
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>
|
|
@@ -1003,7 +1659,8 @@ function documentHtml(title, description, body, styles, scripts, preview, payloa
|
|
|
1003
1659
|
}
|
|
1004
1660
|
function renderHtml(report, assets, preview = false, revision = 0) {
|
|
1005
1661
|
const content = renderContent(report);
|
|
1006
|
-
|
|
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);
|
|
1007
1664
|
}
|
|
1008
1665
|
function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
|
|
1009
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("")}`;
|
|
@@ -1011,7 +1668,7 @@ function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
|
|
|
1011
1668
|
}
|
|
1012
1669
|
|
|
1013
1670
|
// src/export.ts
|
|
1014
|
-
import
|
|
1671
|
+
import path5 from "node:path";
|
|
1015
1672
|
import { randomUUID } from "node:crypto";
|
|
1016
1673
|
import {
|
|
1017
1674
|
lstat,
|
|
@@ -1023,8 +1680,8 @@ import {
|
|
|
1023
1680
|
rm
|
|
1024
1681
|
} from "node:fs/promises";
|
|
1025
1682
|
async function writeReport(filename, html, inputs) {
|
|
1026
|
-
const destination =
|
|
1027
|
-
if (
|
|
1683
|
+
const destination = path5.resolve(filename);
|
|
1684
|
+
if (path5.extname(destination).toLowerCase() !== ".html")
|
|
1028
1685
|
throw new Error("Output must have an .html extension.");
|
|
1029
1686
|
let existing;
|
|
1030
1687
|
try {
|
|
@@ -1038,7 +1695,7 @@ async function writeReport(filename, html, inputs) {
|
|
|
1038
1695
|
if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
|
|
1039
1696
|
throw error;
|
|
1040
1697
|
}
|
|
1041
|
-
let directory =
|
|
1698
|
+
let directory = path5.dirname(destination);
|
|
1042
1699
|
const missing = [];
|
|
1043
1700
|
while (true) {
|
|
1044
1701
|
try {
|
|
@@ -1047,16 +1704,16 @@ async function writeReport(filename, html, inputs) {
|
|
|
1047
1704
|
} catch (error) {
|
|
1048
1705
|
if (!(error && typeof error === "object" && ("code" in error) && error.code === "ENOENT"))
|
|
1049
1706
|
throw error;
|
|
1050
|
-
missing.unshift(
|
|
1051
|
-
const parent =
|
|
1707
|
+
missing.unshift(path5.basename(directory));
|
|
1708
|
+
const parent = path5.dirname(directory);
|
|
1052
1709
|
if (parent === directory)
|
|
1053
1710
|
throw error;
|
|
1054
1711
|
directory = parent;
|
|
1055
1712
|
}
|
|
1056
1713
|
}
|
|
1057
|
-
const canonical =
|
|
1714
|
+
const canonical = path5.join(directory, ...missing, path5.basename(destination));
|
|
1058
1715
|
for (const input of inputs) {
|
|
1059
|
-
if (
|
|
1716
|
+
if (path5.resolve(input) === canonical || path5.resolve(input) === destination)
|
|
1060
1717
|
throw new Error("Output would overwrite a report input or dependency.");
|
|
1061
1718
|
if (existing) {
|
|
1062
1719
|
try {
|
|
@@ -1069,8 +1726,8 @@ async function writeReport(filename, html, inputs) {
|
|
|
1069
1726
|
}
|
|
1070
1727
|
}
|
|
1071
1728
|
}
|
|
1072
|
-
await mkdir(
|
|
1073
|
-
const temporary =
|
|
1729
|
+
await mkdir(path5.dirname(destination), { recursive: true });
|
|
1730
|
+
const temporary = path5.join(path5.dirname(destination), `.plotleaf-${randomUUID()}.tmp`);
|
|
1074
1731
|
try {
|
|
1075
1732
|
await writeFile(temporary, html, {
|
|
1076
1733
|
encoding: "utf8",
|
|
@@ -1235,10 +1892,18 @@ data: ${revision}
|
|
|
1235
1892
|
// package.json
|
|
1236
1893
|
var package_default = {
|
|
1237
1894
|
name: "plotleaf",
|
|
1238
|
-
version: "0.
|
|
1895
|
+
version: "0.3.0",
|
|
1239
1896
|
description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
|
|
1240
1897
|
type: "module",
|
|
1241
1898
|
license: "MIT",
|
|
1899
|
+
repository: {
|
|
1900
|
+
type: "git",
|
|
1901
|
+
url: "git+https://github.com/LoueeD/plotleaf.git"
|
|
1902
|
+
},
|
|
1903
|
+
homepage: "https://github.com/LoueeD/plotleaf#readme",
|
|
1904
|
+
bugs: {
|
|
1905
|
+
url: "https://github.com/LoueeD/plotleaf/issues"
|
|
1906
|
+
},
|
|
1242
1907
|
bin: {
|
|
1243
1908
|
plotleaf: "./dist/cli.js"
|
|
1244
1909
|
},
|
|
@@ -1253,10 +1918,17 @@ var package_default = {
|
|
|
1253
1918
|
"dist/assets/styles.css",
|
|
1254
1919
|
"dist/assets/notices.txt",
|
|
1255
1920
|
"schema/components.json",
|
|
1921
|
+
"examples/report.md",
|
|
1256
1922
|
"examples/storefront/*.md",
|
|
1257
1923
|
"examples/storefront/*.json",
|
|
1258
1924
|
"examples/adoption/*.md",
|
|
1259
1925
|
"examples/adoption/*.json",
|
|
1926
|
+
"examples/onboarding/*.md",
|
|
1927
|
+
"examples/onboarding/*.json",
|
|
1928
|
+
"examples/catalog/*.md",
|
|
1929
|
+
"examples/catalog/*.json",
|
|
1930
|
+
"examples/catalog/assets/*.png",
|
|
1931
|
+
"examples/catalog/assets/*.jpg",
|
|
1260
1932
|
"skills/plotleaf/SKILL.md",
|
|
1261
1933
|
"README.md",
|
|
1262
1934
|
"LICENSE",
|
|
@@ -1271,10 +1943,12 @@ var package_default = {
|
|
|
1271
1943
|
"test:browser": "node node_modules/@playwright/test/cli.js test",
|
|
1272
1944
|
"test:consumer": "node tests/consumer/smoke.mjs",
|
|
1273
1945
|
check: "bun run typecheck && bun run build && bun run test && bun run test:consumer",
|
|
1274
|
-
"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"
|
|
1275
1948
|
},
|
|
1276
1949
|
dependencies: {
|
|
1277
1950
|
chokidar: "6.0.0",
|
|
1951
|
+
"image-size": "2.1.0",
|
|
1278
1952
|
open: "11.0.4",
|
|
1279
1953
|
"rehype-sanitize": "6.0.0",
|
|
1280
1954
|
"rehype-stringify": "10.0.1",
|
|
@@ -1400,7 +2074,7 @@ async function main() {
|
|
|
1400
2074
|
return;
|
|
1401
2075
|
}
|
|
1402
2076
|
const html = renderHtml(result.report, await assets());
|
|
1403
|
-
const defaultOutput = result.filename.slice(0, -
|
|
2077
|
+
const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
|
|
1404
2078
|
const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
|
|
1405
2079
|
console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
|
|
1406
2080
|
return;
|