plotleaf 0.3.1 → 0.4.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 +31 -23
- package/dist/assets/base.js +1 -1
- package/dist/assets/charts.js +17 -17
- package/dist/assets/styles.css +131 -0
- package/dist/cli.js +1193 -748
- package/examples/interactions/control-views.json +412 -0
- package/examples/interactions/regional-views.json +409 -0
- package/examples/interactions/report.md +30 -0
- package/examples/report.md +11 -1
- package/package.json +4 -2
- package/schema/components.json +377 -2
- package/skills/plotleaf/SKILL.md +62 -2
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { realpath as realpath2 } from "node:fs/promises";
|
|
|
12
12
|
// src/compiler/diagnostics.ts
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
|
|
15
|
+
// src/compiler/diagnostic-core.ts
|
|
15
16
|
class Diagnostics {
|
|
16
17
|
file;
|
|
17
18
|
items = [];
|
|
@@ -51,9 +52,6 @@ class Diagnostics {
|
|
|
51
52
|
return [...this.items].sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line || a.column - b.column || a.code.localeCompare(b.code, "en"));
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
|
-
function displayPath(filename) {
|
|
55
|
-
return path.relative(process.cwd(), filename) || path.basename(filename);
|
|
56
|
-
}
|
|
57
55
|
function humanDiagnostics(items) {
|
|
58
56
|
return items.map((d) => `${d.file}:${d.line}:${d.column} ${d.code}${d.severity === "warning" ? " (warning)" : ""}
|
|
59
57
|
${d.message}${d.hint ? `
|
|
@@ -62,9 +60,89 @@ ${d.hint}` : ""}`).join(`
|
|
|
62
60
|
`);
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
// src/compiler/
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
// src/compiler/diagnostics.ts
|
|
64
|
+
function displayPath(filename) {
|
|
65
|
+
return path.relative(process.cwd(), filename) || path.basename(filename);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/compiler/exploration-schema.ts
|
|
69
|
+
import * as z from "zod";
|
|
70
|
+
|
|
71
|
+
// src/shared/exploration.ts
|
|
72
|
+
var valueKey = (value) => typeof value === "object" ? `${value.start}/${value.end}` : `${typeof value}:${String(value)}`;
|
|
73
|
+
var explorationLimits = {
|
|
74
|
+
inputs: 6,
|
|
75
|
+
options: 20,
|
|
76
|
+
views: 32,
|
|
77
|
+
explorations: 4,
|
|
78
|
+
components: 512,
|
|
79
|
+
rows: 20000,
|
|
80
|
+
dataBytes: 8 * 1024 ** 2,
|
|
81
|
+
markdownBytes: 2 * 1024 ** 2,
|
|
82
|
+
jsonDepth: 32,
|
|
83
|
+
jsonContainers: 1e5
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/compiler/exploration-schema.ts
|
|
87
|
+
var id = z.string().regex(/^[a-z][a-z0-9_-]{0,47}$/).refine((value) => !["constructor", "prototype"].includes(value), "Reserved identifier.");
|
|
88
|
+
var label = z.string().min(1).max(160);
|
|
89
|
+
var date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
|
|
90
|
+
const timestamp = Date.parse(`${value}T00:00:00Z`);
|
|
91
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
|
|
92
|
+
}, "Use a real ISO calendar date.");
|
|
93
|
+
var range = z.strictObject({ start: date, end: date }).refine((value) => value.start <= value.end, "Range start must not follow its end.");
|
|
94
|
+
var common = { id, label };
|
|
95
|
+
var explorationInputSchema = z.discriminatedUnion("type", [
|
|
96
|
+
z.strictObject({
|
|
97
|
+
...common,
|
|
98
|
+
type: z.enum(["select", "segmented", "text"]),
|
|
99
|
+
default: z.string().max(120),
|
|
100
|
+
options: z.array(z.strictObject({ value: z.string().max(120), label })).min(1).max(explorationLimits.options)
|
|
101
|
+
}),
|
|
102
|
+
z.strictObject({
|
|
103
|
+
...common,
|
|
104
|
+
type: z.literal("number"),
|
|
105
|
+
default: z.number().finite(),
|
|
106
|
+
options: z.array(z.strictObject({ value: z.number().finite(), label })).min(1).max(explorationLimits.options)
|
|
107
|
+
}),
|
|
108
|
+
z.strictObject({
|
|
109
|
+
...common,
|
|
110
|
+
type: z.literal("toggle"),
|
|
111
|
+
default: z.boolean()
|
|
112
|
+
}),
|
|
113
|
+
z.strictObject({
|
|
114
|
+
...common,
|
|
115
|
+
type: z.literal("date-range"),
|
|
116
|
+
default: range,
|
|
117
|
+
options: z.array(z.strictObject({ value: range, label })).min(1).max(explorationLimits.options)
|
|
118
|
+
})
|
|
119
|
+
]);
|
|
120
|
+
var explorationSchema = z.strictObject({
|
|
121
|
+
inputs: z.array(explorationInputSchema).min(1).max(explorationLimits.inputs),
|
|
122
|
+
views: z.array(z.strictObject({
|
|
123
|
+
id,
|
|
124
|
+
label,
|
|
125
|
+
when: z.record(id, z.union([
|
|
126
|
+
z.string().max(120),
|
|
127
|
+
z.number().finite(),
|
|
128
|
+
z.boolean(),
|
|
129
|
+
range
|
|
130
|
+
])),
|
|
131
|
+
markdown: z.string().min(1).max(1024 ** 2),
|
|
132
|
+
datasets: z.record(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\.json$/), z.array(z.record(z.string(), z.unknown())).max(1e4)).default({})
|
|
133
|
+
})).min(1).max(explorationLimits.views)
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// src/compiler/parse.ts
|
|
137
|
+
import { unified } from "unified";
|
|
138
|
+
import remarkParse from "remark-parse";
|
|
139
|
+
import remarkDirective from "remark-directive";
|
|
140
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
141
|
+
import { parseDocument, visit } from "yaml";
|
|
142
|
+
import * as z3 from "zod";
|
|
143
|
+
|
|
144
|
+
// src/compiler/registry.ts
|
|
145
|
+
import * as z2 from "zod";
|
|
68
146
|
|
|
69
147
|
// src/shared/limits.ts
|
|
70
148
|
var limits = {
|
|
@@ -85,182 +163,6 @@ var limits = {
|
|
|
85
163
|
imagePixels: 4000000
|
|
86
164
|
};
|
|
87
165
|
|
|
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
|
-
}
|
|
96
|
-
function contained(root, filename) {
|
|
97
|
-
const relative = path2.relative(root, filename);
|
|
98
|
-
return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
|
|
99
|
-
}
|
|
100
|
-
async function readBytesBounded(filename, maximum) {
|
|
101
|
-
if (!(await stat(filename)).isFile())
|
|
102
|
-
throw new Error("Expected a regular file.");
|
|
103
|
-
const file = await open(filename, "r");
|
|
104
|
-
try {
|
|
105
|
-
const info = await file.stat();
|
|
106
|
-
if (!info.isFile())
|
|
107
|
-
throw new Error("Expected a regular file.");
|
|
108
|
-
if (info.size > maximum)
|
|
109
|
-
throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
|
|
110
|
-
const chunks = [];
|
|
111
|
-
let bytes = 0;
|
|
112
|
-
while (bytes <= maximum) {
|
|
113
|
-
const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
|
|
114
|
-
const result = await file.read(buffer, 0, buffer.length, null);
|
|
115
|
-
if (!result.bytesRead)
|
|
116
|
-
break;
|
|
117
|
-
chunks.push(buffer.subarray(0, result.bytesRead));
|
|
118
|
-
bytes += result.bytesRead;
|
|
119
|
-
}
|
|
120
|
-
if (bytes > maximum)
|
|
121
|
-
throw new Error("File grew beyond the size limit while reading.");
|
|
122
|
-
return Buffer.concat(chunks, bytes);
|
|
123
|
-
} finally {
|
|
124
|
-
await file.close();
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
async function readBounded(filename, maximum) {
|
|
128
|
-
const bytes = await readBytesBounded(filename, maximum);
|
|
129
|
-
return {
|
|
130
|
-
text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
131
|
-
bytes: bytes.length
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
class ReportFiles {
|
|
136
|
-
root;
|
|
137
|
-
dependencies;
|
|
138
|
-
usedBytes;
|
|
139
|
-
constructor(root, dependencies, usedBytes) {
|
|
140
|
-
this.root = root;
|
|
141
|
-
this.dependencies = dependencies;
|
|
142
|
-
this.usedBytes = usedBytes;
|
|
143
|
-
}
|
|
144
|
-
async resolve(reference, extensions) {
|
|
145
|
-
const fail = (message) => {
|
|
146
|
-
throw new LocalFileError("path", message);
|
|
147
|
-
};
|
|
148
|
-
if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
|
|
149
|
-
fail(`Use a plain relative local ${extensions.join("/")} path.`);
|
|
150
|
-
const candidate = path2.resolve(this.root, reference);
|
|
151
|
-
const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
|
|
152
|
-
if (!contained(this.root, candidate))
|
|
153
|
-
fail("Reference escapes the report directory.");
|
|
154
|
-
if (hidden(candidate))
|
|
155
|
-
fail("Hidden files and directories are not allowed.");
|
|
156
|
-
if (!extensions.includes(path2.extname(candidate).toLowerCase()))
|
|
157
|
-
fail(`Use a ${extensions.join("/")} file.`);
|
|
158
|
-
this.dependencies.add(candidate);
|
|
159
|
-
let canonical;
|
|
160
|
-
try {
|
|
161
|
-
canonical = await realpath(candidate);
|
|
162
|
-
} catch {
|
|
163
|
-
throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
|
|
164
|
-
}
|
|
165
|
-
if (!contained(this.root, canonical) || hidden(canonical))
|
|
166
|
-
fail("The resolved file or symlink escapes the permitted report files.");
|
|
167
|
-
if (!extensions.includes(path2.extname(canonical).toLowerCase()))
|
|
168
|
-
fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
|
|
169
|
-
this.dependencies.add(canonical);
|
|
170
|
-
return canonical;
|
|
171
|
-
}
|
|
172
|
-
async read(filename, maximum) {
|
|
173
|
-
const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
|
|
174
|
-
this.usedBytes += bytes.length;
|
|
175
|
-
return bytes;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// src/compiler/images.ts
|
|
180
|
-
import { createHash } from "node:crypto";
|
|
181
|
-
import path3 from "node:path";
|
|
182
|
-
import { imageSize } from "image-size";
|
|
183
|
-
function formatOf(bytes) {
|
|
184
|
-
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
185
|
-
let offset = 8;
|
|
186
|
-
let data = false;
|
|
187
|
-
while (offset + 12 <= bytes.length) {
|
|
188
|
-
const size = bytes.readUInt32BE(offset);
|
|
189
|
-
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
190
|
-
if (offset + size + 12 > bytes.length)
|
|
191
|
-
break;
|
|
192
|
-
if (offset === 8 && (type !== "IHDR" || size !== 13))
|
|
193
|
-
break;
|
|
194
|
-
if (type === "acTL")
|
|
195
|
-
throw new Error("Animated PNG is not supported; provide a static image.");
|
|
196
|
-
if (type === "IDAT" && size > 0)
|
|
197
|
-
data = true;
|
|
198
|
-
offset += size + 12;
|
|
199
|
-
if (type === "IEND") {
|
|
200
|
-
if (data && size === 0 && offset === bytes.length)
|
|
201
|
-
return "png";
|
|
202
|
-
break;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
throw new Error("Incomplete or invalid PNG container.");
|
|
206
|
-
}
|
|
207
|
-
if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
|
|
208
|
-
return "jpg";
|
|
209
|
-
throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
|
|
210
|
-
}
|
|
211
|
-
|
|
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
166
|
// src/shared/payload.ts
|
|
265
167
|
var funnelStatuses = {
|
|
266
168
|
"on-target": "On target",
|
|
@@ -269,62 +171,682 @@ var funnelStatuses = {
|
|
|
269
171
|
neutral: "Neutral"
|
|
270
172
|
};
|
|
271
173
|
|
|
272
|
-
// src/compiler/
|
|
273
|
-
var
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
174
|
+
// src/compiler/registry.ts
|
|
175
|
+
var text = z2.string().min(1).max(1000);
|
|
176
|
+
var format = z2.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
|
|
177
|
+
var chart = {
|
|
178
|
+
data: text,
|
|
179
|
+
x: text,
|
|
180
|
+
y: text,
|
|
181
|
+
title: text,
|
|
182
|
+
format: format.default("number")
|
|
183
|
+
};
|
|
184
|
+
var line = z2.strictObject({
|
|
185
|
+
...chart,
|
|
186
|
+
xType: z2.enum(["category", "date", "number"]).default("category"),
|
|
187
|
+
target: z2.number().finite().optional(),
|
|
188
|
+
targetLabel: text.optional()
|
|
189
|
+
});
|
|
190
|
+
var registry = {
|
|
191
|
+
explore: {
|
|
192
|
+
forms: ["leaf"],
|
|
193
|
+
children: [],
|
|
194
|
+
description: "Scoped native controls select complete agent-prepared views. No analytics or report JavaScript.",
|
|
195
|
+
schema: z2.strictObject({ data: text, title: text }),
|
|
196
|
+
dataRules: {
|
|
197
|
+
schema: z2.toJSONSchema(explorationSchema),
|
|
198
|
+
resolution: "Relative JSON bundle; inline view datasets override the same plain filename only within that view.",
|
|
199
|
+
limits: explorationLimits,
|
|
200
|
+
binding: "Every input affects exactly this exploration. Every view declares all input values. No matching combination hides the previous view."
|
|
201
|
+
},
|
|
202
|
+
example: '::explore{data="./regional-views.json" title="Regional performance"}'
|
|
203
|
+
},
|
|
204
|
+
grid: {
|
|
205
|
+
forms: ["container"],
|
|
206
|
+
children: [
|
|
207
|
+
"metric",
|
|
208
|
+
"line",
|
|
209
|
+
"area",
|
|
210
|
+
"bar",
|
|
211
|
+
"donut",
|
|
212
|
+
"funnel",
|
|
213
|
+
"table",
|
|
214
|
+
"callout"
|
|
215
|
+
],
|
|
216
|
+
description: "A responsive row of one to four components.",
|
|
217
|
+
schema: z2.strictObject({
|
|
218
|
+
columns: z2.number().int().min(1).max(4).default(2)
|
|
219
|
+
}),
|
|
220
|
+
example: `:::grid{columns="4"}
|
|
221
|
+
::metric{label="Teams" value="1492"}
|
|
222
|
+
:::`
|
|
223
|
+
},
|
|
224
|
+
metric: {
|
|
225
|
+
forms: ["leaf"],
|
|
226
|
+
children: [],
|
|
227
|
+
description: "A prepared numeric value with optional comparison, direction and sentiment.",
|
|
228
|
+
schema: z2.strictObject({
|
|
229
|
+
label: text,
|
|
230
|
+
value: z2.number().finite(),
|
|
231
|
+
format: format.default("number"),
|
|
232
|
+
comparison: text.optional(),
|
|
233
|
+
direction: z2.enum(["up", "down", "flat"]).optional(),
|
|
234
|
+
sentiment: z2.enum(["positive", "negative", "neutral"]).default("neutral")
|
|
235
|
+
}),
|
|
236
|
+
example: '::metric{label="Conversion" value="0.103" format="percent"}'
|
|
237
|
+
},
|
|
238
|
+
line: {
|
|
239
|
+
forms: ["leaf"],
|
|
240
|
+
children: [],
|
|
241
|
+
description: "An interactive single-series line chart from local JSON.",
|
|
242
|
+
schema: line,
|
|
243
|
+
example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
|
|
244
|
+
},
|
|
245
|
+
area: {
|
|
246
|
+
forms: ["leaf"],
|
|
247
|
+
children: [],
|
|
248
|
+
description: "A line chart with a restrained area fill.",
|
|
249
|
+
schema: line,
|
|
250
|
+
example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
|
|
251
|
+
},
|
|
252
|
+
bar: {
|
|
253
|
+
forms: ["leaf"],
|
|
254
|
+
children: [],
|
|
255
|
+
description: "An interactive category bar chart with a zero baseline.",
|
|
256
|
+
schema: z2.strictObject(chart),
|
|
257
|
+
example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
|
|
258
|
+
},
|
|
259
|
+
donut: {
|
|
260
|
+
forms: ["leaf"],
|
|
261
|
+
children: [],
|
|
262
|
+
description: "A part-to-whole donut with a readable legend and native data table.",
|
|
263
|
+
schema: z2.strictObject({
|
|
264
|
+
data: text,
|
|
265
|
+
category: text,
|
|
266
|
+
value: text,
|
|
267
|
+
title: text,
|
|
268
|
+
format: format.default("number")
|
|
269
|
+
}),
|
|
270
|
+
dataRules: {
|
|
271
|
+
category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
272
|
+
value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
|
|
273
|
+
maximumRows: limits.donutSlices
|
|
274
|
+
},
|
|
275
|
+
example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
|
|
276
|
+
},
|
|
277
|
+
funnel: {
|
|
278
|
+
forms: ["leaf"],
|
|
279
|
+
children: [],
|
|
280
|
+
description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
|
|
281
|
+
schema: z2.strictObject({
|
|
282
|
+
data: text,
|
|
283
|
+
stage: text,
|
|
284
|
+
value: text,
|
|
285
|
+
total: text,
|
|
286
|
+
share: text,
|
|
287
|
+
status: text.optional(),
|
|
288
|
+
title: text,
|
|
289
|
+
format: format.default("number")
|
|
290
|
+
}),
|
|
291
|
+
dataRules: {
|
|
292
|
+
stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
|
|
293
|
+
value: "Finite nonnegative number no greater than its row's total.",
|
|
294
|
+
total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
|
|
295
|
+
share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
|
|
296
|
+
status: Object.keys(funnelStatuses),
|
|
297
|
+
maximumRows: limits.funnelStages
|
|
298
|
+
},
|
|
299
|
+
example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
|
|
300
|
+
},
|
|
301
|
+
table: {
|
|
302
|
+
forms: ["leaf", "container"],
|
|
303
|
+
children: ["column"],
|
|
304
|
+
description: "A semantic sortable table, with inferred or explicitly formatted columns.",
|
|
305
|
+
schema: z2.strictObject({ data: text, title: text.optional() }),
|
|
306
|
+
example: `:::table{data="./plans.json" title="By plan"}
|
|
307
|
+
::column{field="plan" label="Plan"}
|
|
308
|
+
::column{field="change" label="Δ QoQ" format="percent" sign="always"}
|
|
309
|
+
:::`
|
|
310
|
+
},
|
|
311
|
+
column: {
|
|
312
|
+
forms: ["leaf"],
|
|
313
|
+
children: [],
|
|
314
|
+
description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
|
|
315
|
+
schema: z2.strictObject({
|
|
316
|
+
field: text,
|
|
317
|
+
label: text.optional(),
|
|
318
|
+
kind: z2.enum(["scalar", "image", "sparkline"]).optional(),
|
|
319
|
+
alt: text.optional(),
|
|
320
|
+
sortBy: text.optional(),
|
|
321
|
+
format: format.optional(),
|
|
322
|
+
sign: z2.enum(["auto", "always"]).default("auto"),
|
|
323
|
+
semantics: z2.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
|
|
324
|
+
}),
|
|
325
|
+
example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
|
|
326
|
+
dataRules: {
|
|
327
|
+
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.",
|
|
328
|
+
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.",
|
|
329
|
+
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."
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
callout: {
|
|
333
|
+
forms: ["container"],
|
|
334
|
+
children: ["markdown"],
|
|
335
|
+
description: "A contextual note or warning containing ordinary Markdown.",
|
|
336
|
+
schema: z2.strictObject({
|
|
337
|
+
tone: z2.enum(["note", "warning"]).default("note"),
|
|
338
|
+
title: text.optional()
|
|
339
|
+
}),
|
|
340
|
+
example: `:::callout{title="Coverage"}
|
|
341
|
+
Seven complete days of prepared data.
|
|
342
|
+
:::`
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function componentSpec() {
|
|
346
|
+
return {
|
|
347
|
+
version: 1,
|
|
348
|
+
syntax: "markdown-directives",
|
|
349
|
+
attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
|
|
350
|
+
limits,
|
|
351
|
+
data: {
|
|
352
|
+
shape: "component datasets are arrays of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields; explore bundles use their component-specific inputs/views schema",
|
|
353
|
+
resolution: "relative to the report directory; local .json files only",
|
|
354
|
+
percent: "ratios: 0.12 displays as 12%",
|
|
355
|
+
percentagePoints: "point values: 0.8 displays as 0.8 pt",
|
|
356
|
+
currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
|
|
357
|
+
locale: "en-GB",
|
|
358
|
+
timeZone: "UTC"
|
|
359
|
+
},
|
|
360
|
+
components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
|
|
361
|
+
name,
|
|
362
|
+
{
|
|
363
|
+
description: entry.description,
|
|
364
|
+
forms: entry.forms,
|
|
365
|
+
children: entry.children,
|
|
366
|
+
props: z2.toJSONSchema(entry.schema),
|
|
367
|
+
example: entry.example,
|
|
368
|
+
..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
|
|
369
|
+
}
|
|
370
|
+
])),
|
|
371
|
+
rules: [
|
|
372
|
+
"Use explicit closing fences for containers.",
|
|
373
|
+
"Components are standalone blocks, not inline/list/blockquote content.",
|
|
374
|
+
"column is only allowed inside table; grids cannot nest.",
|
|
375
|
+
"direction and explicitly supplied sentiment require metric comparison text.",
|
|
376
|
+
"targetLabel requires target.",
|
|
377
|
+
"Dataset fields are literal own-property keys, not expressions.",
|
|
378
|
+
"Charts preserve source order; date/number x values must increase.",
|
|
379
|
+
"Line/area null y values create gaps; bar null y values are invalid.",
|
|
380
|
+
"Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
|
|
381
|
+
"Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
|
|
382
|
+
"Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
|
|
383
|
+
"explore selects finite agent-prepared views using scoped native controls; nested explorations, arbitrary row queries, maps and executable components are not supported."
|
|
384
|
+
]
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
var specs = componentSpec().components;
|
|
388
|
+
var numericLiteral = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
389
|
+
function decodeProps(name, attributes) {
|
|
390
|
+
const decoded = Object.create(null);
|
|
391
|
+
const properties = specs[name].props.properties ?? {};
|
|
392
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
393
|
+
const property = properties[key];
|
|
394
|
+
if (property?.type === "number" || property?.type === "integer")
|
|
395
|
+
decoded[key] = typeof value === "string" && numericLiteral.test(value) ? Number(value) : value;
|
|
396
|
+
else
|
|
397
|
+
decoded[key] = value;
|
|
398
|
+
}
|
|
399
|
+
const result = registry[name].schema.safeParse(decoded);
|
|
400
|
+
if (!result.success)
|
|
401
|
+
return {
|
|
402
|
+
errors: result.error.issues.map((issue) => `${issue.path.join(".") || name}: ${issue.message}`)
|
|
403
|
+
};
|
|
404
|
+
const props = result.data;
|
|
405
|
+
const errors = [];
|
|
406
|
+
if (name === "column") {
|
|
407
|
+
const column = props;
|
|
408
|
+
const kind = column.kind ?? "scalar";
|
|
409
|
+
if (kind === "image" && !column.alt)
|
|
410
|
+
errors.push("image columns require an alt field.");
|
|
411
|
+
if (kind !== "image" && column.alt)
|
|
412
|
+
errors.push("alt is only supported on image columns.");
|
|
413
|
+
if (kind === "image" && column.format)
|
|
414
|
+
errors.push("Image columns cannot use numeric formatting.");
|
|
415
|
+
if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
|
|
416
|
+
errors.push("sign and semantics apply only to scalar columns.");
|
|
417
|
+
if (kind === "scalar" && column.sortBy)
|
|
418
|
+
errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
|
|
419
|
+
if (column.alt === column.field || column.sortBy === column.field)
|
|
420
|
+
errors.push("alt and sortBy must reference fields different from the rich cell field.");
|
|
421
|
+
}
|
|
422
|
+
if (name === "donut" || name === "funnel") {
|
|
423
|
+
const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
|
|
424
|
+
if (new Set(fields).size !== fields.length)
|
|
425
|
+
errors.push(`${name} field properties must reference different dataset fields.`);
|
|
426
|
+
}
|
|
427
|
+
if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
|
|
428
|
+
errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
|
|
429
|
+
if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
|
|
430
|
+
errors.push("direction and sentiment require comparison text.");
|
|
431
|
+
if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
|
|
432
|
+
errors.push("targetLabel requires a numeric target.");
|
|
433
|
+
return { props, errors };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/compiler/parse.ts
|
|
437
|
+
var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
|
|
438
|
+
var metadataSchema = z3.strictObject({
|
|
439
|
+
title: z3.string().min(1).max(500).optional(),
|
|
440
|
+
description: z3.string().max(2000).optional()
|
|
441
|
+
});
|
|
442
|
+
function textOf(node) {
|
|
443
|
+
return node.value ?? node.children?.map(textOf).join("") ?? "";
|
|
444
|
+
}
|
|
445
|
+
function parseReport(source, fallbackTitle, diagnostics) {
|
|
446
|
+
const tree = parser.parse(source);
|
|
447
|
+
const lines = source.split(/\r?\n/);
|
|
448
|
+
const recognized = new Set;
|
|
449
|
+
const inert = new Set;
|
|
450
|
+
const definitions = new Map;
|
|
451
|
+
let metadata = { title: fallbackTitle };
|
|
452
|
+
const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
|
|
453
|
+
if (firstHeading)
|
|
454
|
+
metadata.title = textOf(firstHeading);
|
|
455
|
+
function inspect(node, depth = 0) {
|
|
456
|
+
if (depth > 64) {
|
|
457
|
+
diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (node.type === "code" || node.type === "yaml") {
|
|
461
|
+
for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
|
|
462
|
+
inert.add(line);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (node.type === "html")
|
|
466
|
+
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.");
|
|
467
|
+
if (node.type === "image" || node.type === "imageReference")
|
|
468
|
+
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.');
|
|
469
|
+
if (node.type === "link" || node.type === "definition") {
|
|
470
|
+
const url = node.url ?? "";
|
|
471
|
+
let allowed = url.startsWith("#");
|
|
472
|
+
try {
|
|
473
|
+
allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
|
|
474
|
+
} catch {}
|
|
475
|
+
if (!allowed)
|
|
476
|
+
diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
|
|
477
|
+
if (node.type === "definition")
|
|
478
|
+
definitions.set(String(node.identifier), url);
|
|
479
|
+
}
|
|
480
|
+
node.children?.forEach((child) => inspect(child, depth + 1));
|
|
481
|
+
}
|
|
482
|
+
inspect(tree);
|
|
483
|
+
function blocks(nodes, parent = "root", depth = 0) {
|
|
484
|
+
if (depth > 32) {
|
|
485
|
+
diagnostics.add("E_REPORT_DEPTH", "Component nesting exceeds 32 levels.");
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
const output = [];
|
|
489
|
+
for (const node of nodes) {
|
|
490
|
+
if (node.type === "yaml") {
|
|
491
|
+
try {
|
|
492
|
+
const document = parseDocument(node.value ?? "", {
|
|
493
|
+
uniqueKeys: true,
|
|
494
|
+
strict: true
|
|
495
|
+
});
|
|
496
|
+
if (document.errors.length || document.warnings.length)
|
|
497
|
+
throw new Error([...document.errors, ...document.warnings].map((error) => error.message).join("; "));
|
|
498
|
+
visit(document, {
|
|
499
|
+
Alias() {
|
|
500
|
+
throw new Error("YAML aliases are not supported.");
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
const result = metadataSchema.safeParse(document.toJS({ maxAliasCount: 0 }) ?? {});
|
|
504
|
+
if (!result.success)
|
|
505
|
+
throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
|
|
506
|
+
metadata = { ...metadata, ...result.data };
|
|
507
|
+
} catch (error) {
|
|
508
|
+
diagnostics.add("E_FRONTMATTER", error instanceof Error ? error.message : "Invalid frontmatter.", node.position);
|
|
509
|
+
}
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (["containerDirective", "leafDirective", "textDirective"].includes(node.type)) {
|
|
513
|
+
const name = node.name ?? "";
|
|
514
|
+
recognized.add(node.position?.start.line ?? 0);
|
|
515
|
+
if (node.type === "containerDirective") {
|
|
516
|
+
const closingLine = node.position?.end.line ?? 0;
|
|
517
|
+
const closing = lines[closingLine - 1]?.trim() ?? "";
|
|
518
|
+
if (!/^:{3,}$/.test(closing))
|
|
519
|
+
diagnostics.add("E_DIRECTIVE_CLOSE", `:::${name} needs an explicit closing colon fence.`, node.position, "Close the component with ::: on its own line. Use a longer outer fence when nesting containers.");
|
|
520
|
+
else
|
|
521
|
+
recognized.add(closingLine);
|
|
522
|
+
}
|
|
523
|
+
if (!Object.hasOwn(registry, name)) {
|
|
524
|
+
diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const componentName = name;
|
|
528
|
+
const entry = registry[componentName];
|
|
529
|
+
const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
|
|
530
|
+
if (!entry.forms.includes(form))
|
|
531
|
+
diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
|
|
532
|
+
const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
|
|
533
|
+
if (!allowed)
|
|
534
|
+
diagnostics.add("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${parent}.`, node.position, name === "column" ? "Place column directives inside a table container." : "Place standalone components at the report root or inside a grid.");
|
|
535
|
+
const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
|
|
536
|
+
const names = new Set;
|
|
537
|
+
for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
|
|
538
|
+
if (!match[1])
|
|
539
|
+
continue;
|
|
540
|
+
if (names.has(match[1]))
|
|
541
|
+
diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
|
|
542
|
+
names.add(match[1]);
|
|
543
|
+
}
|
|
544
|
+
if (node.type === "leafDirective" && node.children?.length)
|
|
545
|
+
diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
|
|
546
|
+
const decoded = decodeProps(componentName, node.attributes ?? {});
|
|
547
|
+
for (const message of decoded.errors)
|
|
548
|
+
diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
|
|
549
|
+
const children = blocks(node.children ?? [], name, depth + 1);
|
|
550
|
+
if (decoded.props)
|
|
551
|
+
output.push({
|
|
552
|
+
type: "component",
|
|
553
|
+
name: componentName,
|
|
554
|
+
props: decoded.props,
|
|
555
|
+
children,
|
|
556
|
+
position: node.position
|
|
557
|
+
});
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
|
|
561
|
+
diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
|
|
562
|
+
const nestedDirectives = (candidate, nesting = 0) => {
|
|
563
|
+
if (nesting > 64)
|
|
564
|
+
return;
|
|
565
|
+
if (candidate.type.endsWith("Directive"))
|
|
566
|
+
diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
|
|
567
|
+
candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
|
|
568
|
+
};
|
|
569
|
+
nestedDirectives(node);
|
|
570
|
+
const last = output.at(-1);
|
|
571
|
+
if (last?.type === "markdown")
|
|
572
|
+
last.tree.children.push(node);
|
|
573
|
+
else
|
|
574
|
+
output.push({
|
|
575
|
+
type: "markdown",
|
|
576
|
+
tree: { type: "root", children: [node] }
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return output;
|
|
580
|
+
}
|
|
581
|
+
const reportBlocks = blocks(tree.children);
|
|
582
|
+
function attachDefinitions(items) {
|
|
583
|
+
for (const block of items) {
|
|
584
|
+
if (block.type === "markdown") {
|
|
585
|
+
for (const [identifier, url] of definitions)
|
|
586
|
+
if (!block.tree.children.some((node) => node.type === "definition" && node.identifier === identifier))
|
|
587
|
+
block.tree.children.push({ type: "definition", identifier, url });
|
|
588
|
+
} else
|
|
589
|
+
attachDefinitions(block.children);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
attachDefinitions(reportBlocks);
|
|
593
|
+
for (let index = 0;index < lines.length; index++) {
|
|
594
|
+
if (/^\s*::/.test(lines[index] ?? "") && !recognized.has(index + 1) && !inert.has(index + 1)) {
|
|
595
|
+
const position = {
|
|
596
|
+
start: { line: index + 1, column: 1 },
|
|
597
|
+
end: { line: index + 1, column: 1 }
|
|
598
|
+
};
|
|
599
|
+
diagnostics.add("E_DIRECTIVE_SYNTAX", "Malformed or misplaced component directive.", position, 'Use ::name{key="literal"} on one line, or a balanced :::name container. JavaScript expressions are not supported.');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return { metadata, blocks: reportBlocks };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/compiler/sources.ts
|
|
606
|
+
class LocalFileError extends Error {
|
|
607
|
+
kind;
|
|
608
|
+
constructor(kind, message) {
|
|
609
|
+
super(message);
|
|
610
|
+
this.kind = kind;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// src/compiler/exploration.ts
|
|
615
|
+
var expansionBudget = () => ({
|
|
616
|
+
explorations: 0,
|
|
617
|
+
components: 0,
|
|
618
|
+
rows: 0,
|
|
619
|
+
markdownBytes: 0,
|
|
620
|
+
dataBytes: 0
|
|
621
|
+
});
|
|
622
|
+
async function resolveExploration(block, files, diagnostics, resolve, budget) {
|
|
623
|
+
const fail = (message, code = "E_EXPLORE_SCHEMA") => diagnostics.add(code, `${block.props.data}: ${message}`, block.position, "Correct the prepared-view bundle; every view must declare all input values.");
|
|
624
|
+
try {
|
|
625
|
+
if (++budget.explorations > explorationLimits.explorations) {
|
|
626
|
+
fail("At most four explorations are supported.", "E_EXPLORE_LIMIT");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const filename = await files.resolve(block.props.data, [".json"]);
|
|
630
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(await files.read(filename, limits.jsonBytes));
|
|
631
|
+
let raw;
|
|
632
|
+
try {
|
|
633
|
+
raw = JSON.parse(text);
|
|
634
|
+
} catch {
|
|
635
|
+
fail("Invalid JSON.", "E_EXPLORE_JSON");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const pending = [
|
|
639
|
+
{ value: raw, depth: 0 }
|
|
640
|
+
];
|
|
641
|
+
let containers = 0;
|
|
642
|
+
while (pending.length) {
|
|
643
|
+
const { value, depth } = pending.pop();
|
|
644
|
+
if (value === null || typeof value !== "object")
|
|
645
|
+
continue;
|
|
646
|
+
if (depth > explorationLimits.jsonDepth || ++containers > explorationLimits.jsonContainers) {
|
|
647
|
+
fail("JSON exceeds 32 nesting levels or 100,000 containers.", "E_EXPLORE_LIMIT");
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
for (const [key, child] of Object.entries(value)) {
|
|
651
|
+
if (["__proto__", "constructor", "prototype"].includes(key)) {
|
|
652
|
+
fail(`Reserved object key ${JSON.stringify(key)}.`, "E_EXPLORE_KEY");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (child !== null && typeof child === "object")
|
|
656
|
+
pending.push({ value: child, depth: depth + 1 });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const parsed = explorationSchema.safeParse(raw);
|
|
660
|
+
if (!parsed.success) {
|
|
661
|
+
for (const issue of parsed.error.issues.slice(0, 12))
|
|
662
|
+
fail(`${issue.path.join(".")}: ${issue.message}`);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const bundle = parsed.data;
|
|
666
|
+
const ids = bundle.inputs.map((input) => input.id);
|
|
667
|
+
if (new Set(ids).size !== ids.length) {
|
|
668
|
+
fail("Input IDs must be unique.");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const domains = new Map;
|
|
672
|
+
for (const input of bundle.inputs) {
|
|
673
|
+
const values = input.type === "toggle" ? [false, true] : input.options.map((option) => option.value);
|
|
674
|
+
const domain = new Set(values.map(valueKey));
|
|
675
|
+
if (domain.size !== values.length || !domain.has(valueKey(input.default))) {
|
|
676
|
+
fail(`Input ${input.id} has duplicate choices or an unsupported default.`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
domains.set(input.id, domain);
|
|
680
|
+
}
|
|
681
|
+
const combinations = new Set, viewIds = new Set;
|
|
682
|
+
const key = (when) => JSON.stringify(ids.map((id) => valueKey(when[id])));
|
|
683
|
+
const initial = Object.fromEntries(bundle.inputs.map((input) => [input.id, input.default]));
|
|
684
|
+
for (const view of bundle.views) {
|
|
685
|
+
if (viewIds.has(view.id)) {
|
|
686
|
+
fail(`Duplicate view ID ${view.id}.`);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
viewIds.add(view.id);
|
|
690
|
+
if (Object.keys(view.when).length !== ids.length || ids.some((id) => !Object.hasOwn(view.when, id)) || Object.keys(view.when).some((id) => !domains.has(id))) {
|
|
691
|
+
fail(`View ${view.id} must declare exactly the scoped input IDs.`);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (ids.some((id) => !domains.get(id).has(valueKey(view.when[id])))) {
|
|
695
|
+
fail(`View ${view.id} contains an unsupported input value or value type.`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const combination = key(view.when);
|
|
699
|
+
if (combinations.has(combination)) {
|
|
700
|
+
fail(`View ${view.id} duplicates another input combination.`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
combinations.add(combination);
|
|
704
|
+
}
|
|
705
|
+
const defaultView = bundle.views.find((view) => key(view.when) === key(initial));
|
|
706
|
+
if (!defaultView) {
|
|
707
|
+
fail("Declared defaults must select one prepared view.");
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const views = [];
|
|
711
|
+
for (const view of bundle.views) {
|
|
712
|
+
let inspect = function(blocks) {
|
|
713
|
+
for (const child of blocks) {
|
|
714
|
+
if (child.type !== "component")
|
|
715
|
+
continue;
|
|
716
|
+
if (child.name === "explore") {
|
|
717
|
+
fail(`View ${view.id}: nested explorations are unsupported.`);
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
if (++budget.components > explorationLimits.components) {
|
|
721
|
+
fail("Expanded components exceed 512.", "E_EXPLORE_LIMIT");
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
if (!inspect(child.children))
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
return true;
|
|
728
|
+
}, countRows = function(blocks) {
|
|
729
|
+
for (const child of blocks)
|
|
730
|
+
if (child.type === "component") {
|
|
731
|
+
budget.rows += child.rows?.length ?? 0;
|
|
732
|
+
budget.dataBytes += child.rows ? new TextEncoder().encode(JSON.stringify(child.rows)).length : 0;
|
|
733
|
+
countRows(child.children);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
budget.markdownBytes += new TextEncoder().encode(view.markdown).length;
|
|
737
|
+
if (budget.markdownBytes > explorationLimits.markdownBytes) {
|
|
738
|
+
fail("Expanded Markdown exceeds 2 MiB.", "E_EXPLORE_LIMIT");
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const local = new Diagnostics(`${block.props.data}#${view.id}`);
|
|
742
|
+
const report = parseReport(view.markdown, view.label, local);
|
|
743
|
+
if (!inspect(report.blocks))
|
|
744
|
+
return;
|
|
745
|
+
const inline = new Map(Object.entries(view.datasets).map(([name, rows]) => [
|
|
746
|
+
`plotleaf-inline:${budget.explorations}-${view.id}:${name}`,
|
|
747
|
+
new TextEncoder().encode(JSON.stringify(rows))
|
|
748
|
+
]));
|
|
749
|
+
const virtual = {
|
|
750
|
+
async resolve(reference, extensions) {
|
|
751
|
+
const name = reference.startsWith("./") ? reference.slice(2) : reference;
|
|
752
|
+
const key = `plotleaf-inline:${budget.explorations}-${view.id}:${name}`;
|
|
753
|
+
return inline.has(key) ? key : files.resolve(reference, extensions);
|
|
754
|
+
},
|
|
755
|
+
async read(name, maximum) {
|
|
756
|
+
const bytes = inline.get(name);
|
|
757
|
+
if (!bytes)
|
|
758
|
+
return files.read(name, maximum);
|
|
759
|
+
if (bytes.length > maximum)
|
|
760
|
+
throw new Error("Prepared dataset exceeds the file size limit.");
|
|
761
|
+
return bytes;
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
await resolve(report.blocks, virtual, local);
|
|
765
|
+
countRows(report.blocks);
|
|
766
|
+
if (budget.rows > explorationLimits.rows) {
|
|
767
|
+
fail("Expanded component rows exceed 20,000.", "E_EXPLORE_LIMIT");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (budget.dataBytes > explorationLimits.dataBytes) {
|
|
771
|
+
fail("Expanded prepared data exceeds 8 MiB.", "E_EXPLORE_LIMIT");
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
for (const item of local.sorted())
|
|
775
|
+
diagnostics.add(item.code, `View ${view.id}, Markdown ${item.line}:${item.column}: ${item.message}`, block.position, item.hint, item.severity);
|
|
776
|
+
views.push({
|
|
777
|
+
id: view.id,
|
|
778
|
+
label: view.label,
|
|
779
|
+
when: view.when,
|
|
780
|
+
blocks: report.blocks
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
block.exploration = {
|
|
784
|
+
inputs: bundle.inputs,
|
|
785
|
+
defaultView: defaultView.id,
|
|
786
|
+
views
|
|
787
|
+
};
|
|
788
|
+
} catch (error) {
|
|
789
|
+
fail(error instanceof Error ? error.message : "Could not read the exploration.", error instanceof LocalFileError && error.kind === "path" ? "E_EXPLORE_PATH" : "E_EXPLORE_READ");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/shared/date.ts
|
|
794
|
+
function parseDate(value) {
|
|
795
|
+
const date = value.slice(0, 10);
|
|
796
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
797
|
+
return;
|
|
798
|
+
const day = Date.parse(`${date}T00:00:00.000Z`);
|
|
799
|
+
if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== date)
|
|
800
|
+
return;
|
|
801
|
+
if (value.length === 10)
|
|
802
|
+
return day;
|
|
803
|
+
if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value))
|
|
804
|
+
return;
|
|
805
|
+
const timestamp = Date.parse(value);
|
|
806
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/compiler/resolve-data.ts
|
|
810
|
+
var reserved = new Set(["__proto__", "constructor", "prototype"]);
|
|
811
|
+
|
|
812
|
+
class DataResolver {
|
|
813
|
+
diagnostics;
|
|
814
|
+
files;
|
|
815
|
+
images;
|
|
816
|
+
expansion;
|
|
817
|
+
cache;
|
|
818
|
+
constructor(diagnostics, files, images, expansion = expansionBudget(), cache = new Map) {
|
|
819
|
+
this.diagnostics = diagnostics;
|
|
820
|
+
this.files = files;
|
|
821
|
+
this.images = images;
|
|
822
|
+
this.expansion = expansion;
|
|
823
|
+
this.cache = cache;
|
|
824
|
+
}
|
|
825
|
+
async load(reference, block) {
|
|
826
|
+
const fail = (code, message, hint) => {
|
|
827
|
+
this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
|
|
828
|
+
return;
|
|
829
|
+
};
|
|
830
|
+
let canonical;
|
|
831
|
+
try {
|
|
832
|
+
canonical = await this.files.resolve(reference, [".json"]);
|
|
833
|
+
} catch (error) {
|
|
834
|
+
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.");
|
|
835
|
+
}
|
|
836
|
+
let dataset = this.cache.get(canonical);
|
|
837
|
+
if (!dataset) {
|
|
838
|
+
if (this.cache.size >= limits.datasets)
|
|
839
|
+
return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
|
|
840
|
+
let text;
|
|
841
|
+
try {
|
|
842
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
|
|
843
|
+
} catch (error) {
|
|
844
|
+
return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
|
|
845
|
+
}
|
|
846
|
+
let parsed;
|
|
847
|
+
try {
|
|
848
|
+
parsed = JSON.parse(text);
|
|
849
|
+
} catch {
|
|
328
850
|
return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
|
|
329
851
|
}
|
|
330
852
|
if (!Array.isArray(parsed))
|
|
@@ -362,6 +884,10 @@ class DataResolver {
|
|
|
362
884
|
for (const block of blocks) {
|
|
363
885
|
if (block.type !== "component")
|
|
364
886
|
continue;
|
|
887
|
+
if (block.name === "explore") {
|
|
888
|
+
await resolveExploration(block, this.files, this.diagnostics, (children, files, diagnostics) => new DataResolver(diagnostics, files, this.images, this.expansion, this.cache).resolve(children), this.expansion);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
365
891
|
if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
|
|
366
892
|
const rows = await this.load(block.props.data, block);
|
|
367
893
|
if (rows) {
|
|
@@ -539,493 +1065,248 @@ class DataResolver {
|
|
|
539
1065
|
fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
|
|
540
1066
|
continue;
|
|
541
1067
|
}
|
|
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
|
-
}
|
|
598
|
-
}
|
|
599
|
-
block.columns = columns;
|
|
600
|
-
block.rows = prepared;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// src/compiler/parse.ts
|
|
605
|
-
import { unified } from "unified";
|
|
606
|
-
import remarkParse from "remark-parse";
|
|
607
|
-
import remarkDirective from "remark-directive";
|
|
608
|
-
import remarkFrontmatter from "remark-frontmatter";
|
|
609
|
-
import { parseDocument, visit } from "yaml";
|
|
610
|
-
import * as z2 from "zod";
|
|
611
|
-
|
|
612
|
-
// src/compiler/registry.ts
|
|
613
|
-
import * as z from "zod";
|
|
614
|
-
var text = z.string().min(1).max(1000);
|
|
615
|
-
var format = z.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
|
|
616
|
-
var chart = {
|
|
617
|
-
data: text,
|
|
618
|
-
x: text,
|
|
619
|
-
y: text,
|
|
620
|
-
title: text,
|
|
621
|
-
format: format.default("number")
|
|
622
|
-
};
|
|
623
|
-
var line = z.strictObject({
|
|
624
|
-
...chart,
|
|
625
|
-
xType: z.enum(["category", "date", "number"]).default("category"),
|
|
626
|
-
target: z.number().finite().optional(),
|
|
627
|
-
targetLabel: text.optional()
|
|
628
|
-
});
|
|
629
|
-
var registry = {
|
|
630
|
-
grid: {
|
|
631
|
-
forms: ["container"],
|
|
632
|
-
children: [
|
|
633
|
-
"metric",
|
|
634
|
-
"line",
|
|
635
|
-
"area",
|
|
636
|
-
"bar",
|
|
637
|
-
"donut",
|
|
638
|
-
"funnel",
|
|
639
|
-
"table",
|
|
640
|
-
"callout"
|
|
641
|
-
],
|
|
642
|
-
description: "A responsive row of one to four components.",
|
|
643
|
-
schema: z.strictObject({
|
|
644
|
-
columns: z.number().int().min(1).max(4).default(2)
|
|
645
|
-
}),
|
|
646
|
-
example: `:::grid{columns="4"}
|
|
647
|
-
::metric{label="Teams" value="1492"}
|
|
648
|
-
:::`
|
|
649
|
-
},
|
|
650
|
-
metric: {
|
|
651
|
-
forms: ["leaf"],
|
|
652
|
-
children: [],
|
|
653
|
-
description: "A prepared numeric value with optional comparison, direction and sentiment.",
|
|
654
|
-
schema: z.strictObject({
|
|
655
|
-
label: text,
|
|
656
|
-
value: z.number().finite(),
|
|
657
|
-
format: format.default("number"),
|
|
658
|
-
comparison: text.optional(),
|
|
659
|
-
direction: z.enum(["up", "down", "flat"]).optional(),
|
|
660
|
-
sentiment: z.enum(["positive", "negative", "neutral"]).default("neutral")
|
|
661
|
-
}),
|
|
662
|
-
example: '::metric{label="Conversion" value="0.103" format="percent"}'
|
|
663
|
-
},
|
|
664
|
-
line: {
|
|
665
|
-
forms: ["leaf"],
|
|
666
|
-
children: [],
|
|
667
|
-
description: "An interactive single-series line chart from local JSON.",
|
|
668
|
-
schema: line,
|
|
669
|
-
example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
|
|
670
|
-
},
|
|
671
|
-
area: {
|
|
672
|
-
forms: ["leaf"],
|
|
673
|
-
children: [],
|
|
674
|
-
description: "A line chart with a restrained area fill.",
|
|
675
|
-
schema: line,
|
|
676
|
-
example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
|
|
677
|
-
},
|
|
678
|
-
bar: {
|
|
679
|
-
forms: ["leaf"],
|
|
680
|
-
children: [],
|
|
681
|
-
description: "An interactive category bar chart with a zero baseline.",
|
|
682
|
-
schema: z.strictObject(chart),
|
|
683
|
-
example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
|
|
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
|
-
},
|
|
727
|
-
table: {
|
|
728
|
-
forms: ["leaf", "container"],
|
|
729
|
-
children: ["column"],
|
|
730
|
-
description: "A semantic sortable table, with inferred or explicitly formatted columns.",
|
|
731
|
-
schema: z.strictObject({ data: text, title: text.optional() }),
|
|
732
|
-
example: `:::table{data="./plans.json" title="By plan"}
|
|
733
|
-
::column{field="plan" label="Plan"}
|
|
734
|
-
::column{field="change" label="Δ QoQ" format="percent" sign="always"}
|
|
735
|
-
:::`
|
|
736
|
-
},
|
|
737
|
-
column: {
|
|
738
|
-
forms: ["leaf"],
|
|
739
|
-
children: [],
|
|
740
|
-
description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
|
|
741
|
-
schema: z.strictObject({
|
|
742
|
-
field: text,
|
|
743
|
-
label: text.optional(),
|
|
744
|
-
kind: z.enum(["scalar", "image", "sparkline"]).optional(),
|
|
745
|
-
alt: text.optional(),
|
|
746
|
-
sortBy: text.optional(),
|
|
747
|
-
format: format.optional(),
|
|
748
|
-
sign: z.enum(["auto", "always"]).default("auto"),
|
|
749
|
-
semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
|
|
750
|
-
}),
|
|
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."
|
|
1068
|
+
if (sort !== undefined && sort !== null) {
|
|
1069
|
+
if (sortType && sortType !== typeof sort) {
|
|
1070
|
+
fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
sortType = typeof sort;
|
|
1074
|
+
}
|
|
1075
|
+
if (kind === "scalar") {
|
|
1076
|
+
prepared[index][column.field] = value;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
let rich;
|
|
1080
|
+
if (kind === "sparkline") {
|
|
1081
|
+
if (value !== null && !Array.isArray(value)) {
|
|
1082
|
+
fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
rich = {
|
|
1086
|
+
kind: "sparkline",
|
|
1087
|
+
values: value ?? [],
|
|
1088
|
+
...column.sortBy ? { sort } : {}
|
|
1089
|
+
};
|
|
1090
|
+
} else {
|
|
1091
|
+
const alt = row[column.alt];
|
|
1092
|
+
if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
|
|
1093
|
+
fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (value === null) {
|
|
1097
|
+
prepared[index][column.field] = {
|
|
1098
|
+
kind: "image",
|
|
1099
|
+
imageId: null,
|
|
1100
|
+
alt: "",
|
|
1101
|
+
...column.sortBy ? { sort } : {}
|
|
1102
|
+
};
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1106
|
+
fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
try {
|
|
1110
|
+
const image = await this.images.load(value);
|
|
1111
|
+
rich = {
|
|
1112
|
+
kind: "image",
|
|
1113
|
+
imageId: image.id,
|
|
1114
|
+
alt,
|
|
1115
|
+
...column.sortBy ? { sort } : {}
|
|
1116
|
+
};
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
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.");
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
prepared[index][column.field] = rich;
|
|
1123
|
+
}
|
|
756
1124
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
forms: ["container"],
|
|
760
|
-
children: ["markdown"],
|
|
761
|
-
description: "A contextual note or warning containing ordinary Markdown.",
|
|
762
|
-
schema: z.strictObject({
|
|
763
|
-
tone: z.enum(["note", "warning"]).default("note"),
|
|
764
|
-
title: text.optional()
|
|
765
|
-
}),
|
|
766
|
-
example: `:::callout{title="Coverage"}
|
|
767
|
-
Seven complete days of prepared data.
|
|
768
|
-
:::`
|
|
1125
|
+
block.columns = columns;
|
|
1126
|
+
block.rows = prepared;
|
|
769
1127
|
}
|
|
770
|
-
}
|
|
771
|
-
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/compiler/files.ts
|
|
1131
|
+
import path2 from "node:path";
|
|
1132
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
1133
|
+
function contained(root, filename) {
|
|
1134
|
+
const relative = path2.relative(root, filename);
|
|
1135
|
+
return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
|
|
1136
|
+
}
|
|
1137
|
+
async function readBytesBounded(filename, maximum) {
|
|
1138
|
+
if (!(await stat(filename)).isFile())
|
|
1139
|
+
throw new Error("Expected a regular file.");
|
|
1140
|
+
const file = await open(filename, "r");
|
|
1141
|
+
try {
|
|
1142
|
+
const info = await file.stat();
|
|
1143
|
+
if (!info.isFile())
|
|
1144
|
+
throw new Error("Expected a regular file.");
|
|
1145
|
+
if (info.size > maximum)
|
|
1146
|
+
throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
|
|
1147
|
+
const chunks = [];
|
|
1148
|
+
let bytes = 0;
|
|
1149
|
+
while (bytes <= maximum) {
|
|
1150
|
+
const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
|
|
1151
|
+
const result = await file.read(buffer, 0, buffer.length, null);
|
|
1152
|
+
if (!result.bytesRead)
|
|
1153
|
+
break;
|
|
1154
|
+
chunks.push(buffer.subarray(0, result.bytesRead));
|
|
1155
|
+
bytes += result.bytesRead;
|
|
1156
|
+
}
|
|
1157
|
+
if (bytes > maximum)
|
|
1158
|
+
throw new Error("File grew beyond the size limit while reading.");
|
|
1159
|
+
return Buffer.concat(chunks, bytes);
|
|
1160
|
+
} finally {
|
|
1161
|
+
await file.close();
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
async function readBounded(filename, maximum) {
|
|
1165
|
+
const bytes = await readBytesBounded(filename, maximum);
|
|
772
1166
|
return {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
|
|
776
|
-
limits,
|
|
777
|
-
data: {
|
|
778
|
-
shape: "array of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields",
|
|
779
|
-
resolution: "relative to the report directory; local .json files only",
|
|
780
|
-
percent: "ratios: 0.12 displays as 12%",
|
|
781
|
-
percentagePoints: "point values: 0.8 displays as 0.8 pt",
|
|
782
|
-
currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
|
|
783
|
-
locale: "en-GB",
|
|
784
|
-
timeZone: "UTC"
|
|
785
|
-
},
|
|
786
|
-
components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
|
|
787
|
-
name,
|
|
788
|
-
{
|
|
789
|
-
description: entry.description,
|
|
790
|
-
forms: entry.forms,
|
|
791
|
-
children: entry.children,
|
|
792
|
-
props: z.toJSONSchema(entry.schema),
|
|
793
|
-
example: entry.example,
|
|
794
|
-
..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
|
|
795
|
-
}
|
|
796
|
-
])),
|
|
797
|
-
rules: [
|
|
798
|
-
"Use explicit closing fences for containers.",
|
|
799
|
-
"Components are standalone blocks, not inline/list/blockquote content.",
|
|
800
|
-
"column is only allowed inside table; grids cannot nest.",
|
|
801
|
-
"direction and explicitly supplied sentiment require metric comparison text.",
|
|
802
|
-
"targetLabel requires target.",
|
|
803
|
-
"Dataset fields are literal own-property keys, not expressions.",
|
|
804
|
-
"Charts preserve source order; date/number x values must increase.",
|
|
805
|
-
"Line/area null y values create gaps; bar null y values are invalid.",
|
|
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."
|
|
810
|
-
]
|
|
1167
|
+
text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
1168
|
+
bytes: bytes.length
|
|
811
1169
|
};
|
|
812
1170
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
else
|
|
823
|
-
decoded[key] = value;
|
|
1171
|
+
|
|
1172
|
+
class ReportFiles {
|
|
1173
|
+
root;
|
|
1174
|
+
dependencies;
|
|
1175
|
+
usedBytes;
|
|
1176
|
+
constructor(root, dependencies, usedBytes) {
|
|
1177
|
+
this.root = root;
|
|
1178
|
+
this.dependencies = dependencies;
|
|
1179
|
+
this.usedBytes = usedBytes;
|
|
824
1180
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
errors: result.error.issues.map((issue) => `${issue.path.join(".") || name}: ${issue.message}`)
|
|
1181
|
+
async resolve(reference, extensions) {
|
|
1182
|
+
const fail = (message) => {
|
|
1183
|
+
throw new LocalFileError("path", message);
|
|
829
1184
|
};
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
}
|
|
853
|
-
if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
|
|
854
|
-
errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
|
|
855
|
-
if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
|
|
856
|
-
errors.push("direction and sentiment require comparison text.");
|
|
857
|
-
if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
|
|
858
|
-
errors.push("targetLabel requires a numeric target.");
|
|
859
|
-
return { props, errors };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// src/compiler/parse.ts
|
|
863
|
-
var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
|
|
864
|
-
var metadataSchema = z2.strictObject({
|
|
865
|
-
title: z2.string().min(1).max(500).optional(),
|
|
866
|
-
description: z2.string().max(2000).optional()
|
|
867
|
-
});
|
|
868
|
-
function textOf(node) {
|
|
869
|
-
return node.value ?? node.children?.map(textOf).join("") ?? "";
|
|
870
|
-
}
|
|
871
|
-
function parseReport(source, fallbackTitle, diagnostics) {
|
|
872
|
-
const tree = parser.parse(source);
|
|
873
|
-
const lines = source.split(/\r?\n/);
|
|
874
|
-
const recognized = new Set;
|
|
875
|
-
const inert = new Set;
|
|
876
|
-
const definitions = new Map;
|
|
877
|
-
let metadata = { title: fallbackTitle };
|
|
878
|
-
const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
|
|
879
|
-
if (firstHeading)
|
|
880
|
-
metadata.title = textOf(firstHeading);
|
|
881
|
-
function inspect(node, depth = 0) {
|
|
882
|
-
if (depth > 64) {
|
|
883
|
-
diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
if (node.type === "code" || node.type === "yaml") {
|
|
887
|
-
for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
|
|
888
|
-
inert.add(line);
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
if (node.type === "html")
|
|
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.");
|
|
893
|
-
if (node.type === "image" || node.type === "imageReference")
|
|
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.');
|
|
895
|
-
if (node.type === "link" || node.type === "definition") {
|
|
896
|
-
const url = node.url ?? "";
|
|
897
|
-
let allowed = url.startsWith("#");
|
|
898
|
-
try {
|
|
899
|
-
allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
|
|
900
|
-
} catch {}
|
|
901
|
-
if (!allowed)
|
|
902
|
-
diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
|
|
903
|
-
if (node.type === "definition")
|
|
904
|
-
definitions.set(String(node.identifier), url);
|
|
1185
|
+
if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
|
|
1186
|
+
fail(`Use a plain relative local ${extensions.join("/")} path.`);
|
|
1187
|
+
const candidate = path2.resolve(this.root, reference);
|
|
1188
|
+
const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
|
|
1189
|
+
if (!contained(this.root, candidate))
|
|
1190
|
+
fail("Reference escapes the report directory.");
|
|
1191
|
+
if (hidden(candidate))
|
|
1192
|
+
fail("Hidden files and directories are not allowed.");
|
|
1193
|
+
if (!extensions.includes(path2.extname(candidate).toLowerCase()))
|
|
1194
|
+
fail(`Use a ${extensions.join("/")} file.`);
|
|
1195
|
+
this.dependencies.add(candidate);
|
|
1196
|
+
let canonical;
|
|
1197
|
+
try {
|
|
1198
|
+
canonical = await realpath(candidate);
|
|
1199
|
+
} catch {
|
|
1200
|
+
throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
|
|
905
1201
|
}
|
|
906
|
-
|
|
1202
|
+
if (!contained(this.root, canonical) || hidden(canonical))
|
|
1203
|
+
fail("The resolved file or symlink escapes the permitted report files.");
|
|
1204
|
+
if (!extensions.includes(path2.extname(canonical).toLowerCase()))
|
|
1205
|
+
fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
|
|
1206
|
+
this.dependencies.add(canonical);
|
|
1207
|
+
return canonical;
|
|
907
1208
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const name = node.name ?? "";
|
|
940
|
-
recognized.add(node.position?.start.line ?? 0);
|
|
941
|
-
if (node.type === "containerDirective") {
|
|
942
|
-
const closingLine = node.position?.end.line ?? 0;
|
|
943
|
-
const closing = lines[closingLine - 1]?.trim() ?? "";
|
|
944
|
-
if (!/^:{3,}$/.test(closing))
|
|
945
|
-
diagnostics.add("E_DIRECTIVE_CLOSE", `:::${name} needs an explicit closing colon fence.`, node.position, "Close the component with ::: on its own line. Use a longer outer fence when nesting containers.");
|
|
946
|
-
else
|
|
947
|
-
recognized.add(closingLine);
|
|
948
|
-
}
|
|
949
|
-
if (!Object.hasOwn(registry, name)) {
|
|
950
|
-
diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
|
|
951
|
-
continue;
|
|
952
|
-
}
|
|
953
|
-
const componentName = name;
|
|
954
|
-
const entry = registry[componentName];
|
|
955
|
-
const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
|
|
956
|
-
if (!entry.forms.includes(form))
|
|
957
|
-
diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
|
|
958
|
-
const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
|
|
959
|
-
if (!allowed)
|
|
960
|
-
diagnostics.add("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${parent}.`, node.position, name === "column" ? "Place column directives inside a table container." : "Place standalone components at the report root or inside a grid.");
|
|
961
|
-
const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
|
|
962
|
-
const names = new Set;
|
|
963
|
-
for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
|
|
964
|
-
if (!match[1])
|
|
965
|
-
continue;
|
|
966
|
-
if (names.has(match[1]))
|
|
967
|
-
diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
|
|
968
|
-
names.add(match[1]);
|
|
969
|
-
}
|
|
970
|
-
if (node.type === "leafDirective" && node.children?.length)
|
|
971
|
-
diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
|
|
972
|
-
const decoded = decodeProps(componentName, node.attributes ?? {});
|
|
973
|
-
for (const message of decoded.errors)
|
|
974
|
-
diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
|
|
975
|
-
const children = blocks(node.children ?? [], name, depth + 1);
|
|
976
|
-
if (decoded.props)
|
|
977
|
-
output.push({
|
|
978
|
-
type: "component",
|
|
979
|
-
name: componentName,
|
|
980
|
-
props: decoded.props,
|
|
981
|
-
children,
|
|
982
|
-
position: node.position
|
|
983
|
-
});
|
|
984
|
-
continue;
|
|
1209
|
+
async read(filename, maximum) {
|
|
1210
|
+
const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
|
|
1211
|
+
this.usedBytes += bytes.length;
|
|
1212
|
+
return bytes;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// src/compiler/images.ts
|
|
1217
|
+
import { createHash } from "node:crypto";
|
|
1218
|
+
import path3 from "node:path";
|
|
1219
|
+
import { imageSize } from "image-size";
|
|
1220
|
+
function formatOf(bytes) {
|
|
1221
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
1222
|
+
let offset = 8;
|
|
1223
|
+
let data = false;
|
|
1224
|
+
while (offset + 12 <= bytes.length) {
|
|
1225
|
+
const size = bytes.readUInt32BE(offset);
|
|
1226
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
1227
|
+
if (offset + size + 12 > bytes.length)
|
|
1228
|
+
break;
|
|
1229
|
+
if (offset === 8 && (type !== "IHDR" || size !== 13))
|
|
1230
|
+
break;
|
|
1231
|
+
if (type === "acTL")
|
|
1232
|
+
throw new Error("Animated PNG is not supported; provide a static image.");
|
|
1233
|
+
if (type === "IDAT" && size > 0)
|
|
1234
|
+
data = true;
|
|
1235
|
+
offset += size + 12;
|
|
1236
|
+
if (type === "IEND") {
|
|
1237
|
+
if (data && size === 0 && offset === bytes.length)
|
|
1238
|
+
return "png";
|
|
1239
|
+
break;
|
|
985
1240
|
}
|
|
986
|
-
if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
|
|
987
|
-
diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
|
|
988
|
-
const nestedDirectives = (candidate, nesting = 0) => {
|
|
989
|
-
if (nesting > 64)
|
|
990
|
-
return;
|
|
991
|
-
if (candidate.type.endsWith("Directive"))
|
|
992
|
-
diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
|
|
993
|
-
candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
|
|
994
|
-
};
|
|
995
|
-
nestedDirectives(node);
|
|
996
|
-
const last = output.at(-1);
|
|
997
|
-
if (last?.type === "markdown")
|
|
998
|
-
last.tree.children.push(node);
|
|
999
|
-
else
|
|
1000
|
-
output.push({
|
|
1001
|
-
type: "markdown",
|
|
1002
|
-
tree: { type: "root", children: [node] }
|
|
1003
|
-
});
|
|
1004
1241
|
}
|
|
1005
|
-
|
|
1242
|
+
throw new Error("Incomplete or invalid PNG container.");
|
|
1006
1243
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1244
|
+
if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
|
|
1245
|
+
return "jpg";
|
|
1246
|
+
throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
class ImageResolver {
|
|
1250
|
+
files;
|
|
1251
|
+
attempted = new Set;
|
|
1252
|
+
filesRead = new Map;
|
|
1253
|
+
embedded = new Map;
|
|
1254
|
+
constructor(files) {
|
|
1255
|
+
this.files = files;
|
|
1017
1256
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1257
|
+
async load(reference) {
|
|
1258
|
+
const requested = path3.resolve(this.files.root, reference);
|
|
1259
|
+
if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
|
|
1260
|
+
throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
|
|
1261
|
+
this.attempted.add(requested);
|
|
1262
|
+
const canonical = await this.files.resolve(reference, [
|
|
1263
|
+
".png",
|
|
1264
|
+
".jpg",
|
|
1265
|
+
".jpeg"
|
|
1266
|
+
]);
|
|
1267
|
+
const cached = this.filesRead.get(canonical);
|
|
1268
|
+
if (cached) {
|
|
1269
|
+
if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
|
|
1270
|
+
throw new Error("Image signature does not match its file extension.");
|
|
1271
|
+
return cached;
|
|
1272
|
+
}
|
|
1273
|
+
if (this.filesRead.size >= limits.imageFiles)
|
|
1274
|
+
throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
|
|
1275
|
+
const bytes = await this.files.read(canonical, limits.imageBytes);
|
|
1276
|
+
const format = formatOf(bytes);
|
|
1277
|
+
const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
|
|
1278
|
+
if (format !== expected(reference) || format !== expected(canonical))
|
|
1279
|
+
throw new Error("Image signature does not match its file extension.");
|
|
1280
|
+
let size;
|
|
1281
|
+
try {
|
|
1282
|
+
size = imageSize(bytes);
|
|
1283
|
+
} catch {
|
|
1284
|
+
throw new Error("Unsupported or damaged PNG/JPEG header.");
|
|
1026
1285
|
}
|
|
1286
|
+
if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
|
|
1287
|
+
throw new Error("Image must have valid positive raster dimensions.");
|
|
1288
|
+
if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
|
|
1289
|
+
throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
|
|
1290
|
+
const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
|
|
1291
|
+
const image = this.embedded.get(id) ?? {
|
|
1292
|
+
id,
|
|
1293
|
+
mime: format === "png" ? "image/png" : "image/jpeg",
|
|
1294
|
+
base64: bytes.toString("base64")
|
|
1295
|
+
};
|
|
1296
|
+
this.filesRead.set(canonical, image);
|
|
1297
|
+
this.embedded.set(id, image);
|
|
1298
|
+
return image;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/compiler/data.ts
|
|
1303
|
+
class DataResolver2 extends DataResolver {
|
|
1304
|
+
root;
|
|
1305
|
+
constructor(root, diagnostics, dependencies, totalBytes = 0) {
|
|
1306
|
+
const files = new ReportFiles(root, dependencies, totalBytes);
|
|
1307
|
+
super(diagnostics, files, new ImageResolver(files));
|
|
1308
|
+
this.root = root;
|
|
1027
1309
|
}
|
|
1028
|
-
return { metadata, blocks: reportBlocks };
|
|
1029
1310
|
}
|
|
1030
1311
|
|
|
1031
1312
|
// src/compiler/compile.ts
|
|
@@ -1047,7 +1328,7 @@ async function compileReport(input) {
|
|
|
1047
1328
|
dependencies.add(filename);
|
|
1048
1329
|
const { text, bytes } = await readBounded(filename, limits.markdownBytes);
|
|
1049
1330
|
const report = parseReport(text, path4.basename(filename, path4.extname(filename)), diagnostics);
|
|
1050
|
-
const resolver = new
|
|
1331
|
+
const resolver = new DataResolver2(path4.dirname(filename), diagnostics, dependencies, bytes);
|
|
1051
1332
|
await resolver.resolve(report.blocks);
|
|
1052
1333
|
if (resolver.images.embedded.size)
|
|
1053
1334
|
report.images = [...resolver.images.embedded.values()];
|
|
@@ -1061,12 +1342,6 @@ async function compileReport(input) {
|
|
|
1061
1342
|
// src/render/html.ts
|
|
1062
1343
|
import { createHash as createHash2 } from "node:crypto";
|
|
1063
1344
|
|
|
1064
|
-
// src/render/content.ts
|
|
1065
|
-
import { unified as unified2 } from "unified";
|
|
1066
|
-
import remarkRehype from "remark-rehype";
|
|
1067
|
-
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
1068
|
-
import rehypeStringify from "rehype-stringify";
|
|
1069
|
-
|
|
1070
1345
|
// src/render/tree.ts
|
|
1071
1346
|
var t = (value) => ({
|
|
1072
1347
|
type: "text",
|
|
@@ -1074,6 +1349,122 @@ var t = (value) => ({
|
|
|
1074
1349
|
});
|
|
1075
1350
|
var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
|
|
1076
1351
|
|
|
1352
|
+
// src/render/exploration.ts
|
|
1353
|
+
function exploration(block, id, render, configs) {
|
|
1354
|
+
const data = block.exploration;
|
|
1355
|
+
if (!data)
|
|
1356
|
+
return [];
|
|
1357
|
+
configs.push({
|
|
1358
|
+
id,
|
|
1359
|
+
inputs: data.inputs,
|
|
1360
|
+
views: data.views.map(({ id, label, when }) => ({ id, label, when }))
|
|
1361
|
+
});
|
|
1362
|
+
const controls = data.inputs.map((input, inputIndex) => {
|
|
1363
|
+
const controlId = `${id}-input-${inputIndex}`;
|
|
1364
|
+
const attrs = {
|
|
1365
|
+
disabled: true,
|
|
1366
|
+
dataPlotleafInput: input.id,
|
|
1367
|
+
id: controlId
|
|
1368
|
+
};
|
|
1369
|
+
if (input.type === "segmented")
|
|
1370
|
+
return h("fieldset", { className: ["explore-segmented"] }, [
|
|
1371
|
+
h("legend", {}, [t(input.label)]),
|
|
1372
|
+
h("div", { className: ["explore-segment-options"] }, input.options.map((option, index) => h("label", {}, [
|
|
1373
|
+
h("input", {
|
|
1374
|
+
...attrs,
|
|
1375
|
+
id: `${controlId}-${index}`,
|
|
1376
|
+
type: "radio",
|
|
1377
|
+
name: controlId,
|
|
1378
|
+
value: String(index),
|
|
1379
|
+
checked: valueKey(option.value) === valueKey(input.default)
|
|
1380
|
+
}),
|
|
1381
|
+
t(option.label)
|
|
1382
|
+
])))
|
|
1383
|
+
]);
|
|
1384
|
+
if (input.type === "date-range")
|
|
1385
|
+
return h("fieldset", { className: ["explore-dates"] }, [
|
|
1386
|
+
h("legend", {}, [t(input.label)]),
|
|
1387
|
+
...["start", "end"].map((part) => h("label", {}, [
|
|
1388
|
+
t(part === "start" ? "From" : "Through"),
|
|
1389
|
+
h("input", {
|
|
1390
|
+
...attrs,
|
|
1391
|
+
id: `${controlId}-${part}`,
|
|
1392
|
+
type: "date",
|
|
1393
|
+
dataRangePart: part,
|
|
1394
|
+
ariaDescribedBy: [`${controlId}-ranges`],
|
|
1395
|
+
value: input.default[part]
|
|
1396
|
+
})
|
|
1397
|
+
]))
|
|
1398
|
+
]);
|
|
1399
|
+
if (input.type === "toggle")
|
|
1400
|
+
return h("label", { className: ["explore-toggle"] }, [
|
|
1401
|
+
h("input", { ...attrs, type: "checkbox", checked: input.default }),
|
|
1402
|
+
t(input.label)
|
|
1403
|
+
]);
|
|
1404
|
+
if (input.type === "select")
|
|
1405
|
+
return h("div", { className: ["explore-field"] }, [
|
|
1406
|
+
h("label", { htmlFor: [controlId] }, [t(input.label)]),
|
|
1407
|
+
h("select", attrs, input.options.map((option, index) => h("option", {
|
|
1408
|
+
value: String(index),
|
|
1409
|
+
selected: valueKey(option.value) === valueKey(input.default)
|
|
1410
|
+
}, [t(option.label)])))
|
|
1411
|
+
]);
|
|
1412
|
+
return h("div", { className: ["explore-field"] }, [
|
|
1413
|
+
h("label", { htmlFor: [controlId] }, [t(input.label)]),
|
|
1414
|
+
h("input", {
|
|
1415
|
+
...attrs,
|
|
1416
|
+
type: input.type === "number" ? "number" : "text",
|
|
1417
|
+
value: String(input.default),
|
|
1418
|
+
...input.type === "number" ? { step: "any" } : {},
|
|
1419
|
+
list: `${controlId}-choices`
|
|
1420
|
+
}),
|
|
1421
|
+
h("datalist", { id: `${controlId}-choices` }, input.options.map((option) => h("option", { value: String(option.value) }, [t(option.label)])))
|
|
1422
|
+
]);
|
|
1423
|
+
});
|
|
1424
|
+
const selected = data.views.find((view) => view.id === data.defaultView);
|
|
1425
|
+
return [
|
|
1426
|
+
h("section", {
|
|
1427
|
+
className: ["exploration"],
|
|
1428
|
+
dataPlotleafExplore: id,
|
|
1429
|
+
ariaLabel: block.props.title
|
|
1430
|
+
}, [
|
|
1431
|
+
h("h2", { id: `${id}-title` }, [t(block.props.title)]),
|
|
1432
|
+
h("div", { className: ["explore-controls"] }, [
|
|
1433
|
+
...controls,
|
|
1434
|
+
h("button", { type: "button", disabled: true, dataPlotleafReset: true }, [t("Reset")])
|
|
1435
|
+
]),
|
|
1436
|
+
...data.inputs.flatMap((input, inputIndex) => input.type === "date-range" ? [
|
|
1437
|
+
h("p", {
|
|
1438
|
+
id: `${id}-input-${inputIndex}-ranges`,
|
|
1439
|
+
className: ["explore-supported"]
|
|
1440
|
+
}, [
|
|
1441
|
+
t(`${input.label} — prepared ranges: ${input.options.map((option) => option.label).join(" · ")}`)
|
|
1442
|
+
])
|
|
1443
|
+
] : []),
|
|
1444
|
+
h("p", {
|
|
1445
|
+
className: ["explore-status"],
|
|
1446
|
+
dataPlotleafStatus: true,
|
|
1447
|
+
role: "status",
|
|
1448
|
+
ariaLive: "polite"
|
|
1449
|
+
}, [t(`Showing ${selected.label} · Prepared view`)]),
|
|
1450
|
+
h("noscript", {}, [
|
|
1451
|
+
t("JavaScript is disabled. The default prepared view is shown; controls are unavailable.")
|
|
1452
|
+
]),
|
|
1453
|
+
...data.views.map((view) => h("section", {
|
|
1454
|
+
dataPlotleafView: view.id,
|
|
1455
|
+
ariaLabel: view.label,
|
|
1456
|
+
hidden: view.id !== data.defaultView
|
|
1457
|
+
}, render(view.blocks, `${id}-${view.id}-`)))
|
|
1458
|
+
])
|
|
1459
|
+
];
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/render/content.ts
|
|
1463
|
+
import { unified as unified2 } from "unified";
|
|
1464
|
+
import remarkRehype from "remark-rehype";
|
|
1465
|
+
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
1466
|
+
import rehypeStringify from "rehype-stringify";
|
|
1467
|
+
|
|
1077
1468
|
// src/shared/format.ts
|
|
1078
1469
|
var numbers = new Map;
|
|
1079
1470
|
function formatValue(value, format = "number", sign = "auto", compact = false) {
|
|
@@ -1213,6 +1604,14 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
1213
1604
|
clobberPrefix: "",
|
|
1214
1605
|
tagNames: [
|
|
1215
1606
|
...defaultSchema.tagNames ?? [],
|
|
1607
|
+
"select",
|
|
1608
|
+
"option",
|
|
1609
|
+
"input",
|
|
1610
|
+
"label",
|
|
1611
|
+
"fieldset",
|
|
1612
|
+
"legend",
|
|
1613
|
+
"datalist",
|
|
1614
|
+
"noscript",
|
|
1216
1615
|
"figure",
|
|
1217
1616
|
"figcaption",
|
|
1218
1617
|
"button",
|
|
@@ -1234,6 +1633,11 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
1234
1633
|
"ariaLabelledBy",
|
|
1235
1634
|
"ariaHidden",
|
|
1236
1635
|
"ariaSort",
|
|
1636
|
+
"hidden",
|
|
1637
|
+
"ariaLive",
|
|
1638
|
+
"dataPlotleafExplore",
|
|
1639
|
+
"dataPlotleafView",
|
|
1640
|
+
"dataPlotleafStatus",
|
|
1237
1641
|
"dataPlotleafChart",
|
|
1238
1642
|
"dataPlotleafTable",
|
|
1239
1643
|
"dataColumn",
|
|
@@ -1244,8 +1648,36 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
1244
1648
|
"dataStatus",
|
|
1245
1649
|
"dataSlice"
|
|
1246
1650
|
],
|
|
1247
|
-
|
|
1248
|
-
|
|
1651
|
+
input: [
|
|
1652
|
+
"id",
|
|
1653
|
+
"type",
|
|
1654
|
+
"name",
|
|
1655
|
+
"value",
|
|
1656
|
+
"checked",
|
|
1657
|
+
"disabled",
|
|
1658
|
+
"step",
|
|
1659
|
+
"list",
|
|
1660
|
+
"dataPlotleafInput",
|
|
1661
|
+
"dataRangePart",
|
|
1662
|
+
"ariaDescribedBy"
|
|
1663
|
+
],
|
|
1664
|
+
select: ["id", "disabled", "dataPlotleafInput"],
|
|
1665
|
+
option: ["value", "selected"],
|
|
1666
|
+
label: ["htmlFor", "className"],
|
|
1667
|
+
button: [
|
|
1668
|
+
"type",
|
|
1669
|
+
"disabled",
|
|
1670
|
+
"ariaLabel",
|
|
1671
|
+
"dataColumn",
|
|
1672
|
+
"dataPlotleafReset"
|
|
1673
|
+
],
|
|
1674
|
+
section: [
|
|
1675
|
+
"className",
|
|
1676
|
+
"ariaLabel",
|
|
1677
|
+
"dataPlotleafExplore",
|
|
1678
|
+
"dataPlotleafView",
|
|
1679
|
+
"hidden"
|
|
1680
|
+
],
|
|
1249
1681
|
meter: ["min", "max", "value", "className", "ariaLabel"],
|
|
1250
1682
|
svg: ["className", "viewBox", "width", "height", "role", "ariaLabel"],
|
|
1251
1683
|
use: ["href"],
|
|
@@ -1258,7 +1690,7 @@ var serializer = unified2().use(rehypeSanitize, {
|
|
|
1258
1690
|
td: ["className", "dataRaw"]
|
|
1259
1691
|
}
|
|
1260
1692
|
}).use(rehypeStringify);
|
|
1261
|
-
function
|
|
1693
|
+
function label2(field) {
|
|
1262
1694
|
const value = field.replaceAll("_", " ");
|
|
1263
1695
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1264
1696
|
}
|
|
@@ -1274,7 +1706,7 @@ function table(rows, columns, title, id, sortable = true) {
|
|
|
1274
1706
|
const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
|
|
1275
1707
|
const caption = h("caption", {}, [t(title)]);
|
|
1276
1708
|
const headers = columns.map((column, index) => {
|
|
1277
|
-
const name = column.label ??
|
|
1709
|
+
const name = column.label ?? label2(column.field);
|
|
1278
1710
|
const content = canSort[index] ? h("button", {
|
|
1279
1711
|
type: "button",
|
|
1280
1712
|
disabled: true,
|
|
@@ -1335,13 +1767,13 @@ function renderContent(report) {
|
|
|
1335
1767
|
headings.set(slug, count + 1);
|
|
1336
1768
|
return `plotleaf-heading-${slug}${count ? `-${count + 1}` : ""}`;
|
|
1337
1769
|
};
|
|
1338
|
-
function decorate(node) {
|
|
1770
|
+
function decorate(node, namespace = "") {
|
|
1339
1771
|
if (node.type !== "element")
|
|
1340
1772
|
return;
|
|
1341
1773
|
if (/^h[1-6]$/.test(node.tagName)) {
|
|
1342
1774
|
const text = (child) => child.type === "text" ? child.value : child.children.map((n) => text(n)).join("");
|
|
1343
|
-
node.properties.id = headingId(text(node));
|
|
1344
|
-
if (node.tagName === "h1")
|
|
1775
|
+
node.properties.id = headingId(namespace + text(node));
|
|
1776
|
+
if (node.tagName === "h1" && !namespace)
|
|
1345
1777
|
hasH1 = true;
|
|
1346
1778
|
}
|
|
1347
1779
|
if (node.tagName === "a" && typeof node.properties.href === "string") {
|
|
@@ -1350,22 +1782,24 @@ function renderContent(report) {
|
|
|
1350
1782
|
try {
|
|
1351
1783
|
fragment = decodeURIComponent(fragment);
|
|
1352
1784
|
} catch {}
|
|
1353
|
-
node.properties.href = `#plotleaf-heading-${slugOf(fragment)}`;
|
|
1785
|
+
node.properties.href = `#plotleaf-heading-${slugOf(namespace + fragment)}`;
|
|
1354
1786
|
}
|
|
1355
1787
|
if (/^https?:/.test(node.properties.href))
|
|
1356
1788
|
node.properties.rel = ["noopener", "noreferrer"];
|
|
1357
1789
|
}
|
|
1358
|
-
node.children.forEach((child) => decorate(child));
|
|
1790
|
+
node.children.forEach((child) => decorate(child, namespace));
|
|
1359
1791
|
}
|
|
1360
|
-
function render(blocks) {
|
|
1792
|
+
function render(blocks, namespace = "") {
|
|
1361
1793
|
return blocks.flatMap((block) => {
|
|
1362
1794
|
if (block.type === "markdown") {
|
|
1363
1795
|
const root = markdown.runSync(block.tree);
|
|
1364
|
-
root.children.forEach((child) => decorate(child));
|
|
1796
|
+
root.children.forEach((child) => decorate(child, namespace));
|
|
1365
1797
|
return root.children;
|
|
1366
1798
|
}
|
|
1367
1799
|
const id = `plotleaf-component-${String(++sequence).padStart(4, "0")}`;
|
|
1368
1800
|
switch (block.name) {
|
|
1801
|
+
case "explore":
|
|
1802
|
+
return exploration(block, id, render, payload.explorations ??= []);
|
|
1369
1803
|
case "grid":
|
|
1370
1804
|
return [
|
|
1371
1805
|
h("div", {
|
|
@@ -1374,7 +1808,7 @@ function renderContent(report) {
|
|
|
1374
1808
|
...block.children.length > 0 && block.children.every((child) => child.type === "component" && child.name === "metric") ? ["metric-grid"] : []
|
|
1375
1809
|
],
|
|
1376
1810
|
dataColumns: block.props.columns
|
|
1377
|
-
}, render(block.children))
|
|
1811
|
+
}, render(block.children, namespace))
|
|
1378
1812
|
];
|
|
1379
1813
|
case "metric": {
|
|
1380
1814
|
const props = block.props;
|
|
@@ -1411,7 +1845,7 @@ function renderContent(report) {
|
|
|
1411
1845
|
t(block.props.title)
|
|
1412
1846
|
])
|
|
1413
1847
|
] : [],
|
|
1414
|
-
...render(block.children)
|
|
1848
|
+
...render(block.children, namespace)
|
|
1415
1849
|
])
|
|
1416
1850
|
];
|
|
1417
1851
|
case "table":
|
|
@@ -1440,17 +1874,17 @@ function renderContent(report) {
|
|
|
1440
1874
|
const { stage, value, total, share, status, title, format } = block.props;
|
|
1441
1875
|
const rows = block.rows ?? [];
|
|
1442
1876
|
const columns = [
|
|
1443
|
-
{ field: stage, label:
|
|
1877
|
+
{ field: stage, label: label2(stage), sign: "auto", semantics: "neutral" },
|
|
1444
1878
|
{
|
|
1445
1879
|
field: value,
|
|
1446
|
-
label:
|
|
1880
|
+
label: label2(value),
|
|
1447
1881
|
format,
|
|
1448
1882
|
sign: "auto",
|
|
1449
1883
|
semantics: "neutral"
|
|
1450
1884
|
},
|
|
1451
1885
|
{
|
|
1452
1886
|
field: total,
|
|
1453
|
-
label:
|
|
1887
|
+
label: label2(total),
|
|
1454
1888
|
format,
|
|
1455
1889
|
sign: "auto",
|
|
1456
1890
|
semantics: "neutral"
|
|
@@ -1570,13 +2004,13 @@ function renderContent(report) {
|
|
|
1570
2004
|
dataDisclosure(rows, [
|
|
1571
2005
|
{
|
|
1572
2006
|
field: category,
|
|
1573
|
-
label:
|
|
2007
|
+
label: label2(category),
|
|
1574
2008
|
sign: "auto",
|
|
1575
2009
|
semantics: "neutral"
|
|
1576
2010
|
},
|
|
1577
2011
|
{
|
|
1578
2012
|
field: value,
|
|
1579
|
-
label:
|
|
2013
|
+
label: label2(value),
|
|
1580
2014
|
format,
|
|
1581
2015
|
sign: "auto",
|
|
1582
2016
|
semantics: "neutral"
|
|
@@ -1611,8 +2045,8 @@ function renderContent(report) {
|
|
|
1611
2045
|
payload.charts.push(chart);
|
|
1612
2046
|
}
|
|
1613
2047
|
const columns = [
|
|
1614
|
-
{ field: x, label:
|
|
1615
|
-
{ field: y, label:
|
|
2048
|
+
{ field: x, label: label2(x), sign: "auto", semantics: "neutral" },
|
|
2049
|
+
{ field: y, label: label2(y), format, sign: "auto", semantics: "neutral" }
|
|
1616
2050
|
];
|
|
1617
2051
|
return [
|
|
1618
2052
|
h("figure", { className: ["chart"] }, [
|
|
@@ -1645,26 +2079,35 @@ function renderContent(report) {
|
|
|
1645
2079
|
};
|
|
1646
2080
|
}
|
|
1647
2081
|
|
|
1648
|
-
// src/render/
|
|
2082
|
+
// src/render/document.ts
|
|
1649
2083
|
var escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1650
2084
|
var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replaceAll("<!--", "\\x3c!--");
|
|
1651
2085
|
var reload = (revision) => `(()=>{const source=new EventSource("/__plotleaf/events");source.addEventListener("ready",event=>{if(Number(event.data)!==${revision})location.reload()});source.addEventListener("reload",()=>location.reload());addEventListener("pagehide",()=>source.close(),{once:true});})();`;
|
|
1652
|
-
function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
|
|
2086
|
+
function documentHtml(title, description, body, styles, scripts, preview, hash, payload, notices = "", revision = 0) {
|
|
1653
2087
|
const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
|
|
1654
|
-
const hashes = code.map((script) => `'sha256-${
|
|
2088
|
+
const hashes = code.map((script) => `'sha256-${hash(script)}'`).join(" ");
|
|
1655
2089
|
const policy = `default-src 'none'; script-src ${hashes || "'none'"}; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src ${preview ? "'self'" : "'none'"}; base-uri 'none'; object-src 'none'; form-action 'none'`;
|
|
1656
2090
|
return `<!doctype html>
|
|
1657
2091
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHtml(policy)}"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)}</title>${description ? `<meta name="description" content="${escapeHtml(description)}">` : ""}<style>${styles}</style></head><body><a class="skip-link" href="#plotleaf-report">Skip to report</a><main class="report" id="plotleaf-report">${body}</main>${payload ? `<script type="application/json" id="plotleaf-data">${payload}</script>` : ""}${notices ? `<template id="plotleaf-notices"><pre>${escapeHtml(notices)}</pre></template>` : ""}${code.map((script) => `<script>${script}</script>`).join("")}</body></html>
|
|
1658
2092
|
`;
|
|
1659
2093
|
}
|
|
1660
|
-
function renderHtml(report, assets, preview = false, revision = 0) {
|
|
2094
|
+
function renderHtml(report, assets, hash, preview = false, revision = 0) {
|
|
1661
2095
|
const content = renderContent(report);
|
|
1662
2096
|
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);
|
|
2097
|
+
return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, hash, content.hasCharts || content.payload.explorations?.length ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
|
|
1664
2098
|
}
|
|
1665
|
-
function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
|
|
2099
|
+
function renderDiagnostics(diagnostics, assets, hash, preview = true, revision = 0) {
|
|
1666
2100
|
const body = `<h1>Report needs attention</h1><p class="diagnostic-intro">Update the source files to refresh this preview.</p>${diagnostics.map((item) => `<section class="diagnostic"><p class="diagnostic-location">${escapeHtml(item.file)}:${item.line}:${item.column} · ${escapeHtml(item.code)}</p><p>${escapeHtml(item.message)}</p>${item.hint ? `<p class="diagnostic-hint">${escapeHtml(item.hint)}</p>` : ""}</section>`).join("")}`;
|
|
1667
|
-
return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, undefined, "", revision);
|
|
2101
|
+
return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, hash, undefined, "", revision);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// src/render/html.ts
|
|
2105
|
+
var hash = (script) => createHash2("sha256").update(script).digest("base64");
|
|
2106
|
+
function renderHtml2(report, assets, preview = false, revision = 0) {
|
|
2107
|
+
return renderHtml(report, assets, hash, preview, revision);
|
|
2108
|
+
}
|
|
2109
|
+
function renderDiagnostics2(diagnostics, assets, preview = true, revision = 0) {
|
|
2110
|
+
return renderDiagnostics(diagnostics, assets, hash, preview, revision);
|
|
1668
2111
|
}
|
|
1669
2112
|
|
|
1670
2113
|
// src/export.ts
|
|
@@ -1749,7 +2192,7 @@ async function startPreview(filename, assets, options = {}) {
|
|
|
1749
2192
|
const log = options.log ?? console.log;
|
|
1750
2193
|
let compilation = options.initial ?? await compileReport(filename);
|
|
1751
2194
|
let revision = 0;
|
|
1752
|
-
const render = () => compilation.report ?
|
|
2195
|
+
const render = () => compilation.report ? renderHtml2(compilation.report, assets, true, revision) : renderDiagnostics2(compilation.diagnostics, assets, true, revision);
|
|
1753
2196
|
let html = render();
|
|
1754
2197
|
let closed = false;
|
|
1755
2198
|
let running = false;
|
|
@@ -1892,7 +2335,7 @@ data: ${revision}
|
|
|
1892
2335
|
// package.json
|
|
1893
2336
|
var package_default = {
|
|
1894
2337
|
name: "plotleaf",
|
|
1895
|
-
version: "0.
|
|
2338
|
+
version: "0.4.0",
|
|
1896
2339
|
description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
|
|
1897
2340
|
type: "module",
|
|
1898
2341
|
license: "MIT",
|
|
@@ -1900,7 +2343,7 @@ var package_default = {
|
|
|
1900
2343
|
type: "git",
|
|
1901
2344
|
url: "git+https://github.com/LoueeD/plotleaf.git"
|
|
1902
2345
|
},
|
|
1903
|
-
homepage: "https://
|
|
2346
|
+
homepage: "https://plotleaf-site.loueed.workers.dev",
|
|
1904
2347
|
bugs: {
|
|
1905
2348
|
url: "https://github.com/LoueeD/plotleaf/issues"
|
|
1906
2349
|
},
|
|
@@ -1919,6 +2362,8 @@ var package_default = {
|
|
|
1919
2362
|
"dist/assets/notices.txt",
|
|
1920
2363
|
"schema/components.json",
|
|
1921
2364
|
"examples/report.md",
|
|
2365
|
+
"examples/interactions/*.md",
|
|
2366
|
+
"examples/interactions/*.json",
|
|
1922
2367
|
"examples/storefront/*.md",
|
|
1923
2368
|
"examples/storefront/*.json",
|
|
1924
2369
|
"examples/adoption/*.md",
|
|
@@ -2077,7 +2522,7 @@ async function main() {
|
|
|
2077
2522
|
process.exitCode = 1;
|
|
2078
2523
|
return;
|
|
2079
2524
|
}
|
|
2080
|
-
const html =
|
|
2525
|
+
const html = renderHtml2(result.report, await assets());
|
|
2081
2526
|
const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
|
|
2082
2527
|
const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
|
|
2083
2528
|
console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);
|