pivotkit-report 0.0.0-stage → 0.2.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/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +78 -4
- package/src/default-template.js +33 -0
- package/src/html-table.js +104 -0
- package/src/index.js +24 -0
- package/src/operations.js +808 -0
- package/src/render.js +279 -0
- package/src/report-css.js +61 -0
- package/src/svg.js +170 -0
- package/src/template.js +324 -0
- package/src/texts.js +63 -0
- package/src/tokens.js +180 -0
- package/types/default-template.d.ts +2 -0
- package/types/html-table.d.ts +36 -0
- package/types/index.d.ts +13 -0
- package/types/operations.d.ts +362 -0
- package/types/render.d.ts +177 -0
- package/types/report-css.d.ts +14 -0
- package/types/svg.d.ts +39 -0
- package/types/template.d.ts +37 -0
- package/types/texts.d.ts +44 -0
- package/types/tokens.d.ts +114 -0
package/src/template.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The print template: an HTML document with placeholders `{{name}}` from a fixed table (PLACEHOLDERS).
|
|
3
|
+
* A template is trusted like code (the host's file or the CLI user's), so its own markup is not
|
|
4
|
+
* filtered; what pivotkit fills in is not: every text is escaped, and the markup placeholders are built
|
|
5
|
+
* by pivotkit from escaped parts. The check runs once, before anything is rendered: a small tokenizer
|
|
6
|
+
* finds where each placeholder sits, and only two places are allowed:
|
|
7
|
+
*
|
|
8
|
+
* - text content (also inside <title> and <textarea>, whose text the browser never reads as markup;
|
|
9
|
+
* a markup placeholder only in plain text content);
|
|
10
|
+
* - a quoted value of an attribute of ATTRIBUTES (text placeholders only), or the `content` of a
|
|
11
|
+
* <meta name="description">.
|
|
12
|
+
*
|
|
13
|
+
* Anything else is refused with PivotkitError INVALID_TEMPLATE, `details.reason` and the placeholder:
|
|
14
|
+
* a placeholder in a comment or a doctype, right after "<" or "</" (where a tag's name starts), in raw text (<script>, <style>, ...;
|
|
15
|
+
* an element ends at its end tag as HTML reads it: "</script" then HTML whitespace, "/" or ">"), anywhere
|
|
16
|
+
* after a <script> that holds "<!--" or after <plaintext>, in an attribute's name,
|
|
17
|
+
* an unquoted value or any other attribute (href, src, style, on..., srcdoc, ...), an unknown name
|
|
18
|
+
* (with a "did you mean"), a "{{" without "}}", a NUL character, or a template over TEMPLATE_LIMIT
|
|
19
|
+
* characters (LIMIT_EXCEEDED).
|
|
20
|
+
*
|
|
21
|
+
* import { parseTemplate, fillTemplate } from "pivotkit-report/template";
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { PivotkitError, closest } from "pivotkit-core";
|
|
25
|
+
import { BIDI_CONTROLS } from "./texts.js";
|
|
26
|
+
|
|
27
|
+
/** Characters a template may have. */
|
|
28
|
+
export const TEMPLATE_LIMIT = 262_144;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The placeholders: text (escaped) and markup (built by pivotkit from escaped parts).
|
|
32
|
+
* @type {Readonly<Record<string, "text" | "markup">>}
|
|
33
|
+
*/
|
|
34
|
+
export const PLACEHOLDERS = Object.freeze({
|
|
35
|
+
title: "text",
|
|
36
|
+
description: "text",
|
|
37
|
+
source: "text",
|
|
38
|
+
generated: "text",
|
|
39
|
+
rows: "text",
|
|
40
|
+
filters: "text",
|
|
41
|
+
summary: "text",
|
|
42
|
+
lang: "text",
|
|
43
|
+
dir: "text",
|
|
44
|
+
style: "markup",
|
|
45
|
+
chart: "markup",
|
|
46
|
+
table: "markup",
|
|
47
|
+
details: "markup"
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Attributes whose quoted value may hold a text placeholder. */
|
|
51
|
+
export const ATTRIBUTES = Object.freeze(["lang", "dir", "title", "alt", "class", "aria-label", "aria-description"]);
|
|
52
|
+
|
|
53
|
+
/** Elements whose content the browser does not read as markup (or not as text): no placeholder inside. */
|
|
54
|
+
const RAW_TEXT = new Set(["script", "style", "xmp", "iframe", "noembed", "noframes", "noscript", "plaintext", "template"]);
|
|
55
|
+
/** Elements whose content is text, entities read: an escaped text placeholder is safe there. */
|
|
56
|
+
const RCDATA = new Set(["title", "textarea"]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* HTML's whitespace (tab, line feed, form feed, carriage return, space): where a tag's name, an
|
|
60
|
+
* attribute and an end tag stop. Not JavaScript's `\s`, which also matches a no-break space and other
|
|
61
|
+
* characters a browser reads as part of a name: "</script\u00A0>" ends no script.
|
|
62
|
+
*/
|
|
63
|
+
const WS = "\\t\\n\\f\\r ";
|
|
64
|
+
const TAG_NAME = new RegExp(`^<(/?)([a-zA-Z][^${WS}/>]*)`);
|
|
65
|
+
const WS_OR_SLASH = new RegExp(`[${WS}/]`);
|
|
66
|
+
const ATTRIBUTE_NAME = new RegExp(`^[^${WS}/>=]+`);
|
|
67
|
+
const SPACES = new RegExp(`^[${WS}]*`);
|
|
68
|
+
const UNQUOTED_VALUE = new RegExp(`^[^${WS}>]*`);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {{ name: string, kind: "text" | "markup" }} Slot
|
|
72
|
+
* @typedef {{ parts: Array<string | Slot>, names: string[] }} Template the template's text cut at its placeholders
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Escapes text for HTML text content and quoted attribute values; bidi controls are left out. */
|
|
76
|
+
export function escapeHtml(text) {
|
|
77
|
+
return String(text)
|
|
78
|
+
.replace(BIDI_CONTROLS, "")
|
|
79
|
+
.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} reason
|
|
84
|
+
* @param {string} message
|
|
85
|
+
* @param {Record<string, unknown>} [details]
|
|
86
|
+
*/
|
|
87
|
+
function refuse(reason, message, details = {}) {
|
|
88
|
+
return new PivotkitError("INVALID_TEMPLATE", `template: ${message}`, { reason, ...details });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The line of a position, for messages. */
|
|
92
|
+
const lineOf = (source, at) => source.slice(0, at).split("\n").length;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Checks a template and cuts it at its placeholders. Throws INVALID_TEMPLATE (see above).
|
|
96
|
+
* @param {string} source
|
|
97
|
+
* @returns {Template}
|
|
98
|
+
*/
|
|
99
|
+
export function parseTemplate(source) {
|
|
100
|
+
if (typeof source !== "string") {
|
|
101
|
+
throw new PivotkitError("INVALID_ARGUMENT", "parseTemplate(source): source must be a string");
|
|
102
|
+
}
|
|
103
|
+
if (source.length > TEMPLATE_LIMIT) {
|
|
104
|
+
throw new PivotkitError("LIMIT_EXCEEDED", `the template has ${source.length.toLocaleString("en-US")} characters, more than the limit of ${TEMPLATE_LIMIT.toLocaleString("en-US")}`, { limit: "template", value: source.length, max: TEMPLATE_LIMIT });
|
|
105
|
+
}
|
|
106
|
+
if (source.includes("\u0000")) {
|
|
107
|
+
throw refuse("nul", "the template holds a NUL character");
|
|
108
|
+
}
|
|
109
|
+
/** @type {Array<string | Slot>} */
|
|
110
|
+
const parts = [];
|
|
111
|
+
const names = new Set();
|
|
112
|
+
let text = "";
|
|
113
|
+
let i = 0;
|
|
114
|
+
const n = source.length;
|
|
115
|
+
/** A placeholder may not be in `what`: refused when "{{" is in source[from, to). */
|
|
116
|
+
const noSlot = (from, to, reason, what) => {
|
|
117
|
+
const at = source.indexOf("{{", from);
|
|
118
|
+
if (at !== -1 && at < to) {
|
|
119
|
+
const name = /^\{\{\s*([^}\s]*)/.exec(source.slice(at, at + 64))?.[1] ?? "";
|
|
120
|
+
throw refuse(reason, `a placeholder ${name ? `{{${name}}} ` : ""}is not allowed ${what} (line ${lineOf(source, at)})`, { placeholder: name, line: lineOf(source, at) });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/** Reads the placeholder at i ("{{"), checks its name and kind, returns its end. */
|
|
124
|
+
const slotAt = (at, allowMarkup, where) => {
|
|
125
|
+
const end = source.indexOf("}}", at + 2);
|
|
126
|
+
const next = source.indexOf("{{", at + 2);
|
|
127
|
+
if (end === -1 || (next !== -1 && next < end)) {
|
|
128
|
+
throw refuse("unclosed", `"{{" without "}}" (line ${lineOf(source, at)})`, { line: lineOf(source, at) });
|
|
129
|
+
}
|
|
130
|
+
const name = source.slice(at + 2, end).trim();
|
|
131
|
+
if (!Object.hasOwn(PLACEHOLDERS, name)) {
|
|
132
|
+
const near = /^[\w-]{1,64}$/.test(name) ? closest(name, Object.keys(PLACEHOLDERS)) : null;
|
|
133
|
+
throw refuse("unknownPlaceholder", `unknown placeholder {{${name.slice(0, 64)}}} (line ${lineOf(source, at)})${near ? `; did you mean {{${near}}}?` : ""}`, { placeholder: name.slice(0, 64), line: lineOf(source, at), ...(near ? { suggestion: near } : {}) });
|
|
134
|
+
}
|
|
135
|
+
const kind = PLACEHOLDERS[name];
|
|
136
|
+
if (kind === "markup" && !allowMarkup) {
|
|
137
|
+
throw refuse("markupInText", `{{${name}}} is markup and is allowed in the document's text only, not ${where} (line ${lineOf(source, at)})`, { placeholder: name, line: lineOf(source, at) });
|
|
138
|
+
}
|
|
139
|
+
return { slot: { name, kind }, end: end + 2 };
|
|
140
|
+
};
|
|
141
|
+
const flush = () => {
|
|
142
|
+
if (text) {
|
|
143
|
+
parts.push(text);
|
|
144
|
+
text = "";
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const pushSlot = (slot) => {
|
|
148
|
+
flush();
|
|
149
|
+
parts.push(slot);
|
|
150
|
+
names.add(slot.name);
|
|
151
|
+
};
|
|
152
|
+
while (i < n) {
|
|
153
|
+
if (source.startsWith("{{", i)) {
|
|
154
|
+
// right after "<" or "</" the text would start a tag: "<" + "img src=x onerror=..."
|
|
155
|
+
if (/<\/?$/.test(source.slice(Math.max(0, i - 2), i))) {
|
|
156
|
+
throw refuse("tagName", `a placeholder is not allowed where a tag's name starts (line ${lineOf(source, i)})`, { line: lineOf(source, i) });
|
|
157
|
+
}
|
|
158
|
+
const { slot, end } = slotAt(i, true, "here");
|
|
159
|
+
pushSlot(slot);
|
|
160
|
+
i = end;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (source[i] !== "<") {
|
|
164
|
+
text += source[i++];
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (source.startsWith("<!--", i)) {
|
|
168
|
+
const close = source.indexOf("-->", i + 4);
|
|
169
|
+
const end = close === -1 ? n : close + 3;
|
|
170
|
+
noSlot(i, end, "comment", "in a comment");
|
|
171
|
+
text += source.slice(i, end);
|
|
172
|
+
i = end;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (source[i + 1] === "!" || source[i + 1] === "?") {
|
|
176
|
+
const close = source.indexOf(">", i);
|
|
177
|
+
const end = close === -1 ? n : close + 1;
|
|
178
|
+
noSlot(i, end, "markup", "in a doctype or declaration");
|
|
179
|
+
text += source.slice(i, end);
|
|
180
|
+
i = end;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const tag = TAG_NAME.exec(source.slice(i, i + 256));
|
|
184
|
+
if (!tag) {
|
|
185
|
+
// a "<" that starts no tag is text
|
|
186
|
+
text += source[i++];
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const closing = tag[1] === "/";
|
|
190
|
+
const name = tag[2].toLowerCase();
|
|
191
|
+
// the tag is built on its own: its quoted values are marked until every attribute is known
|
|
192
|
+
flush();
|
|
193
|
+
noSlot(i, i + tag[0].length, "tagName", "in a tag's name");
|
|
194
|
+
text += tag[0];
|
|
195
|
+
i += tag[0].length;
|
|
196
|
+
// the attributes, up to the tag's end
|
|
197
|
+
/** @type {Array<{ name: string, value: string | null }>} */
|
|
198
|
+
const attrs = [];
|
|
199
|
+
/** @type {Array<{ attr: string, start: number, end: number, quote: string }>} */
|
|
200
|
+
const valueRanges = [];
|
|
201
|
+
while (i < n && source[i] !== ">") {
|
|
202
|
+
if (WS_OR_SLASH.test(source[i])) {
|
|
203
|
+
text += source[i++];
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const attr = ATTRIBUTE_NAME.exec(source.slice(i, i + 1024))?.[0] ?? source[i];
|
|
207
|
+
noSlot(i, i + attr.length, "attributeName", "in an attribute's name");
|
|
208
|
+
text += attr;
|
|
209
|
+
i += attr.length;
|
|
210
|
+
let ws = SPACES.exec(source.slice(i))?.[0] ?? "";
|
|
211
|
+
if (source[i + ws.length] !== "=") {
|
|
212
|
+
attrs.push({ name: attr.toLowerCase(), value: null });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
text += ws + "=";
|
|
216
|
+
i += ws.length + 1;
|
|
217
|
+
ws = SPACES.exec(source.slice(i))?.[0] ?? "";
|
|
218
|
+
text += ws;
|
|
219
|
+
i += ws.length;
|
|
220
|
+
const quote = source[i];
|
|
221
|
+
if (quote === '"' || quote === "'") {
|
|
222
|
+
const close = source.indexOf(quote, i + 1);
|
|
223
|
+
const end = close === -1 ? n : close;
|
|
224
|
+
valueRanges.push({ attr: attr.toLowerCase(), start: i + 1, end, quote });
|
|
225
|
+
attrs.push({ name: attr.toLowerCase(), value: source.slice(i + 1, end) });
|
|
226
|
+
// the value is copied below, with its placeholders, once the tag is known
|
|
227
|
+
i = Math.min(n, end + 1);
|
|
228
|
+
text += "\u0000" + (valueRanges.length - 1) + "\u0000";
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const value = UNQUOTED_VALUE.exec(source.slice(i))?.[0] ?? "";
|
|
232
|
+
noSlot(i, i + value.length, "unquoted", `in an unquoted attribute value (${attr})`);
|
|
233
|
+
attrs.push({ name: attr.toLowerCase(), value });
|
|
234
|
+
text += value;
|
|
235
|
+
i += value.length;
|
|
236
|
+
}
|
|
237
|
+
// the quoted values, now that every attribute of the tag is known
|
|
238
|
+
// a <meta> with http-equiv (a refresh, a cookie) reads its content as an instruction, not text
|
|
239
|
+
const metaDescription =
|
|
240
|
+
name === "meta" && attrs.some((a) => a.name === "name" && (a.value ?? "").trim().toLowerCase() === "description") && !attrs.some((a) => a.name === "http-equiv");
|
|
241
|
+
const pending = text;
|
|
242
|
+
text = "";
|
|
243
|
+
// eslint-disable-next-line no-control-regex
|
|
244
|
+
const marker = /\u0000(\d+)\u0000/g;
|
|
245
|
+
let last = 0;
|
|
246
|
+
let m;
|
|
247
|
+
while ((m = marker.exec(pending))) {
|
|
248
|
+
text += pending.slice(last, m.index);
|
|
249
|
+
last = m.index + m[0].length;
|
|
250
|
+
const range = valueRanges[Number(m[1])];
|
|
251
|
+
const allowed = ATTRIBUTES.includes(range.attr) || (metaDescription && range.attr === "content");
|
|
252
|
+
if (!allowed) {
|
|
253
|
+
noSlot(range.start, range.end, "attribute", `in the attribute "${range.attr}" (only ${ATTRIBUTES.join(", ")} and the content of <meta name="description"> without http-equiv)`);
|
|
254
|
+
}
|
|
255
|
+
text += range.quote;
|
|
256
|
+
let j = range.start;
|
|
257
|
+
while (j < range.end) {
|
|
258
|
+
if (source.startsWith("{{", j)) {
|
|
259
|
+
const { slot, end } = slotAt(j, false, "in an attribute");
|
|
260
|
+
pushSlot(slot);
|
|
261
|
+
j = end;
|
|
262
|
+
} else {
|
|
263
|
+
text += source[j++];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
text += range.quote;
|
|
267
|
+
}
|
|
268
|
+
text += pending.slice(last);
|
|
269
|
+
if (i < n) {
|
|
270
|
+
text += ">";
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
if (!closing && (RAW_TEXT.has(name) || RCDATA.has(name))) {
|
|
274
|
+
// the element's content up to its end tag
|
|
275
|
+
const endTag = new RegExp(`</${name}[${WS}/>]`, "i");
|
|
276
|
+
const rest = source.slice(i);
|
|
277
|
+
const found = endTag.exec(rest);
|
|
278
|
+
// <plaintext> has no end: the rest of the document is its text
|
|
279
|
+
const end = found && name !== "plaintext" ? i + found.index : n;
|
|
280
|
+
if (name === "script" && source.slice(i, end).includes("<!--")) {
|
|
281
|
+
// "<!--" in a script starts the "escaped" states, in which a "<script" inside it moves
|
|
282
|
+
// the script's end past the next "</script>": not modelled here, so nothing after it
|
|
283
|
+
noSlot(i, n, "scriptComment", 'after a <script> that holds "<!--"');
|
|
284
|
+
}
|
|
285
|
+
if (RAW_TEXT.has(name)) {
|
|
286
|
+
noSlot(i, end, "rawText", `inside <${name}>`);
|
|
287
|
+
text += source.slice(i, end);
|
|
288
|
+
} else {
|
|
289
|
+
let j = i;
|
|
290
|
+
while (j < end) {
|
|
291
|
+
if (source.startsWith("{{", j)) {
|
|
292
|
+
const { slot, end: e } = slotAt(j, false, `inside <${name}>`);
|
|
293
|
+
pushSlot(slot);
|
|
294
|
+
j = e;
|
|
295
|
+
} else {
|
|
296
|
+
text += source[j++];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
i = end;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
flush();
|
|
304
|
+
return { parts, names: [...names] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The template with its placeholders filled: text values escaped, markup values as given (built by
|
|
309
|
+
* pivotkit). A placeholder without a value is empty.
|
|
310
|
+
* @param {Template} template from parseTemplate()
|
|
311
|
+
* @param {Partial<Record<string, string>>} values
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
export function fillTemplate(template, values) {
|
|
315
|
+
return template.parts
|
|
316
|
+
.map((part) => {
|
|
317
|
+
if (typeof part === "string") {
|
|
318
|
+
return part;
|
|
319
|
+
}
|
|
320
|
+
const value = Object.hasOwn(values, part.name) ? values[part.name] ?? "" : "";
|
|
321
|
+
return part.kind === "text" ? escapeHtml(value) : value;
|
|
322
|
+
})
|
|
323
|
+
.join("");
|
|
324
|
+
}
|
package/src/texts.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The words a report writes around the table (English and German): the language of the report's
|
|
3
|
+
* locale picks them, English stands in for any other; a host passes its own as `texts`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The characters that change the direction of text, as pivotkit-core's import leaves them out: a report
|
|
8
|
+
* writes none of them (HTML, SVG), so no name reads as another one ("invoice\u202Etxt.exe").
|
|
9
|
+
*/
|
|
10
|
+
export const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
11
|
+
|
|
12
|
+
export const REPORT_TEXTS = Object.freeze({
|
|
13
|
+
en: Object.freeze({
|
|
14
|
+
untitled: "Report",
|
|
15
|
+
source: "Source",
|
|
16
|
+
rows: "Rows",
|
|
17
|
+
rowsCount: "{count} rows",
|
|
18
|
+
generated: "Generated",
|
|
19
|
+
filters: "Filters",
|
|
20
|
+
noFilters: "None",
|
|
21
|
+
chart: "Chart",
|
|
22
|
+
tableCut: "The table shows the first {shown} of {count} rows. Export it as Excel or CSV for all of them.",
|
|
23
|
+
chartCut: "The chart shows the first {shown} of {count} categories.",
|
|
24
|
+
chartUnavailable: "The chart is left out: ECharts is not available."
|
|
25
|
+
}),
|
|
26
|
+
de: Object.freeze({
|
|
27
|
+
untitled: "Bericht",
|
|
28
|
+
source: "Quelle",
|
|
29
|
+
rows: "Zeilen",
|
|
30
|
+
rowsCount: "{count} Zeilen",
|
|
31
|
+
generated: "Erstellt",
|
|
32
|
+
filters: "Filter",
|
|
33
|
+
noFilters: "Keine",
|
|
34
|
+
chart: "Diagramm",
|
|
35
|
+
tableCut: "Die Tabelle zeigt die ersten {shown} von {count} Zeilen. Für alle als Excel oder CSV exportieren.",
|
|
36
|
+
chartCut: "Das Diagramm zeigt die ersten {shown} von {count} Kategorien.",
|
|
37
|
+
chartUnavailable: "Das Diagramm fehlt: ECharts ist nicht verfügbar."
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** @typedef {typeof REPORT_TEXTS.en} ReportTexts */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The texts for a locale, the host's over them.
|
|
45
|
+
* @param {string} locale
|
|
46
|
+
* @param {Partial<ReportTexts>} [own]
|
|
47
|
+
* @returns {ReportTexts}
|
|
48
|
+
*/
|
|
49
|
+
export function textsFor(locale, own = {}) {
|
|
50
|
+
const lang = String(locale).split("-")[0].toLowerCase();
|
|
51
|
+
const base = Object.hasOwn(REPORT_TEXTS, lang) ? REPORT_TEXTS[lang] : REPORT_TEXTS.en;
|
|
52
|
+
/** @type {any} */
|
|
53
|
+
const out = { ...base };
|
|
54
|
+
for (const [key, value] of Object.entries(own ?? {})) {
|
|
55
|
+
if (Object.hasOwn(base, key) && typeof value === "string") {
|
|
56
|
+
out[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** "{count} rows" with its values. */
|
|
63
|
+
export const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (Object.hasOwn(values, key) ? String(values[key]) : m));
|
package/src/tokens.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The colours of a report on paper and on screen: the --pk-* tokens of pivotkit-react's styles.css
|
|
3
|
+
* that a printed report uses, with the same names and values (a kept copy: test/tokens.test.js
|
|
4
|
+
* compares them with styles.css, so a change there fails here until it is made here too). The chart of
|
|
5
|
+
* an SVG and a report is drawn with the light ones; a host passes its own as `tokens`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The light theme: print, and a screen in light mode. */
|
|
9
|
+
export const LIGHT_TOKENS = Object.freeze({
|
|
10
|
+
"--pk-surface": "#ffffff",
|
|
11
|
+
"--pk-surface-2": "#f6f7f9",
|
|
12
|
+
"--pk-surface-raised": "#ffffff",
|
|
13
|
+
"--pk-text": "#1a1d23",
|
|
14
|
+
"--pk-muted": "#5a6270",
|
|
15
|
+
"--pk-border": "#dde1e7",
|
|
16
|
+
"--pk-border-strong": "#c3c9d2",
|
|
17
|
+
"--pk-grid": "#edf0f3",
|
|
18
|
+
"--pk-total": "#eef1f5",
|
|
19
|
+
"--pk-total-strong": "#e9edf3",
|
|
20
|
+
"--pk-shadow": "0 1px 2px rgb(16 24 40 / 0.06), 0 4px 12px rgb(16 24 40 / 0.08)",
|
|
21
|
+
"--pk-heat-low": "#eaf1fb",
|
|
22
|
+
"--pk-heat-high": "#2a78d6",
|
|
23
|
+
"--pk-scale-low": "var(--pk-heat-low)",
|
|
24
|
+
"--pk-scale-high": "var(--pk-heat-high)",
|
|
25
|
+
"--pk-chart-1": "#2a78d6",
|
|
26
|
+
"--pk-chart-2": "#eb6834",
|
|
27
|
+
"--pk-chart-3": "#1baf7a",
|
|
28
|
+
"--pk-chart-4": "#eda100",
|
|
29
|
+
"--pk-chart-5": "#e87ba4",
|
|
30
|
+
"--pk-chart-6": "#008300",
|
|
31
|
+
"--pk-chart-7": "#6250d6",
|
|
32
|
+
"--pk-chart-8": "#e34948"
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** The dark theme, for a report read on a screen in dark mode (never printed: print is light). */
|
|
36
|
+
export const DARK_TOKENS = Object.freeze({
|
|
37
|
+
"--pk-surface": "#16181d",
|
|
38
|
+
"--pk-surface-2": "#1c1f25",
|
|
39
|
+
"--pk-surface-raised": "#22262d",
|
|
40
|
+
"--pk-text": "#e8eaef",
|
|
41
|
+
"--pk-muted": "#a3aab7",
|
|
42
|
+
"--pk-border": "#2f343e",
|
|
43
|
+
"--pk-border-strong": "#454c58",
|
|
44
|
+
"--pk-grid": "#23272e",
|
|
45
|
+
"--pk-total": "#21252d",
|
|
46
|
+
"--pk-total-strong": "#252a33",
|
|
47
|
+
"--pk-shadow": "0 1px 2px rgb(0 0 0 / 0.4), 0 4px 12px rgb(0 0 0 / 0.3)",
|
|
48
|
+
"--pk-heat-low": "#1d2735",
|
|
49
|
+
"--pk-heat-high": "#3987e5",
|
|
50
|
+
"--pk-scale-low": "var(--pk-heat-low)",
|
|
51
|
+
"--pk-scale-high": "var(--pk-heat-high)",
|
|
52
|
+
"--pk-chart-1": "#3987e5",
|
|
53
|
+
"--pk-chart-2": "#d95926",
|
|
54
|
+
"--pk-chart-3": "#199e70",
|
|
55
|
+
"--pk-chart-4": "#c98500",
|
|
56
|
+
"--pk-chart-5": "#d55181",
|
|
57
|
+
"--pk-chart-6": "#008300",
|
|
58
|
+
"--pk-chart-7": "#9085e9",
|
|
59
|
+
"--pk-chart-8": "#e66767"
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The font weights, the same in both themes: pivotkit-react's scale (test/tokens.test.js compares them). */
|
|
63
|
+
export const WEIGHT_TOKENS = Object.freeze({
|
|
64
|
+
"--pk-weight-normal": "400",
|
|
65
|
+
"--pk-weight-medium": "500",
|
|
66
|
+
"--pk-weight-strong": "600",
|
|
67
|
+
"--pk-weight-bold": "700"
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The corners the report draws, the same in both themes: the chart's card, as pivotkit-react's tooltip
|
|
72
|
+
* (test/tokens.test.js compares them).
|
|
73
|
+
*/
|
|
74
|
+
export const SHAPE_TOKENS = Object.freeze({
|
|
75
|
+
"--pk-radius-card": "8px"
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The report's font sizes, the same in both themes: its text (--pk-font-size, 14 px, the report's own
|
|
80
|
+
* default: pivotkit-react's follows the page, at least 14 px, and its Export menu passes it in px),
|
|
81
|
+
* secondary text (--pk-small, pivotkit-react's: the details, never below 14 px), the title (20 px at a
|
|
82
|
+
* 14 px text) and the printed text, in pt on paper: the table and, for a very wide table, the smaller
|
|
83
|
+
* size (PRINT_WIDE.small, never below 7 pt). A host passes its own as `tokens`.
|
|
84
|
+
*/
|
|
85
|
+
export const SIZE_TOKENS = Object.freeze({
|
|
86
|
+
"--pk-font-size": "14px",
|
|
87
|
+
"--pk-small": "max(14px, 0.93em)",
|
|
88
|
+
"--pk-report-title-size": "calc(var(--pk-font-size) * 10 / 7)",
|
|
89
|
+
"--pk-print-size": "10pt",
|
|
90
|
+
"--pk-print-small": "7pt"
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The report's font: the system's, as the screen's report inherits from most hosts. Without quotes
|
|
95
|
+
* (names of several words are valid unquoted): ECharts 6.1 writes a font into an SVG's style attribute
|
|
96
|
+
* without escaping its quotes, which would end the attribute.
|
|
97
|
+
*/
|
|
98
|
+
export const REPORT_FONT = "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif";
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A token value is written into CSS only when it holds no character that could end the rule, and no
|
|
102
|
+
* comment (a calc()'s * and / are kept apart from each other).
|
|
103
|
+
*/
|
|
104
|
+
const SAFE_VALUE = /^(?![\s\S]*(\/\*|\*\/))[#\w\s.,%()/*+-]*$/;
|
|
105
|
+
/**
|
|
106
|
+
* The CSS functions a token may call: colours, a shadow's lengths and other tokens. Any other, url(),
|
|
107
|
+
* image-set() or src() among them, could load a resource where a report loads none.
|
|
108
|
+
*/
|
|
109
|
+
const SAFE_FUNCTIONS = new Set(["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color", "color-mix", "light-dark", "calc", "min", "max", "clamp", "var"]);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether a value (its quoted strings left out) is plain and calls only SAFE_FUNCTIONS. A line break
|
|
113
|
+
* (LF, CR, FF) is refused before the strings are left out: inside a CSS string it ends the string, so
|
|
114
|
+
* what follows would be read as CSS; and url( is refused anywhere, also inside a string.
|
|
115
|
+
*/
|
|
116
|
+
function safeValue(value) {
|
|
117
|
+
if (/[\n\r\f]|url\(/i.test(value)) return false;
|
|
118
|
+
const plain = value.replace(/"[^"\\<>{};]*"/g, "");
|
|
119
|
+
return SAFE_VALUE.test(plain) && [...plain.matchAll(/([\w-]*)\(/g)].every((m) => SAFE_FUNCTIONS.has(m[1].toLowerCase()));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The tokens as a style the chart's code reads (getPropertyValue), the host's over the light ones.
|
|
124
|
+
* @param {Record<string, string>} [tokens]
|
|
125
|
+
* @returns {{ getPropertyValue(name: string): string }}
|
|
126
|
+
*/
|
|
127
|
+
export function tokenStyle(tokens = {}) {
|
|
128
|
+
const all = { ...LIGHT_TOKENS, ...WEIGHT_TOKENS, ...SHAPE_TOKENS, ...SIZE_TOKENS, "font-family": REPORT_FONT, ...cleanTokens(tokens) };
|
|
129
|
+
// the chart's font without quotes (see REPORT_FONT)
|
|
130
|
+
all["font-family"] = all["font-family"].replace(/["']/g, "");
|
|
131
|
+
return { getPropertyValue: (name) => (Object.hasOwn(all, name) ? all[name] : "") };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The host's tokens that may go into CSS: --pk-* names (and font-family) with plain values that call
|
|
136
|
+
* no function but SAFE_FUNCTIONS; others are left out.
|
|
137
|
+
* @param {Record<string, string>} tokens
|
|
138
|
+
*/
|
|
139
|
+
export function cleanTokens(tokens) {
|
|
140
|
+
/** @type {Record<string, string>} */
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [name, value] of Object.entries(tokens ?? {})) {
|
|
143
|
+
if ((/^--pk-[a-z0-9-]+$/.test(name) || name === "font-family") && typeof value === "string" && value.length <= 200 && safeValue(value)) {
|
|
144
|
+
out[name] = value.trim();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A colour a table cell and a workbook take: #rgb or #rrggbb. */
|
|
151
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The colours of a colour scale and of data bars whose view names none, from the host's tokens over
|
|
155
|
+
* the light ones, as the report on screen resolves them: the scale --pk-scale-low, the host's
|
|
156
|
+
* --pk-scale-mid and --pk-scale-high (by default the heat tokens), the bars --pk-chart-1. A value
|
|
157
|
+
* that is no plain hex colour (another token is followed) keeps pivotkit-core's default.
|
|
158
|
+
* @param {Record<string, string>} [tokens]
|
|
159
|
+
* @returns {{ colorScale?: string[], dataBar?: string }}
|
|
160
|
+
*/
|
|
161
|
+
export function conditionalColors(tokens = {}) {
|
|
162
|
+
const all = { ...LIGHT_TOKENS, ...cleanTokens(tokens) };
|
|
163
|
+
const resolve = (/** @type {string} */ name, depth = 0) => {
|
|
164
|
+
const value = Object.hasOwn(all, name) ? all[name] : "";
|
|
165
|
+
const ref = /^var\((--pk-[a-z0-9-]+)\)$/.exec(value);
|
|
166
|
+
return ref && depth < 4 ? resolve(ref[1], depth + 1) : HEX_COLOR.test(value) ? value : null;
|
|
167
|
+
};
|
|
168
|
+
const [low, mid, high, bar] = ["--pk-scale-low", "--pk-scale-mid", "--pk-scale-high", "--pk-chart-1"].map((name) => resolve(name));
|
|
169
|
+
return {
|
|
170
|
+
...(low && high ? { colorScale: mid ? [low, mid, high] : [low, high] } : {}),
|
|
171
|
+
...(bar ? { dataBar: bar } : {})
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Tokens as CSS declarations. */
|
|
176
|
+
export const declarations = (tokens) =>
|
|
177
|
+
Object.entries(tokens)
|
|
178
|
+
.filter(([name]) => name.startsWith("--pk-"))
|
|
179
|
+
.map(([name, value]) => `${name}:${value}`)
|
|
180
|
+
.join(";");
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare const REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
|
|
2
|
+
export declare const DEFAULT_TEMPLATE = "<!doctype html>\n<html lang=\"{{lang}}\" dir=\"{{dir}}\">\n<head>\n<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'\">\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"generator\" content=\"pivotkit\">\n<meta name=\"description\" content=\"{{summary}}\">\n<title>{{title}}</title>\n{{style}}\n</head>\n<body>\n<main class=\"pk-report\">\n<header>\n<h1>{{title}}</h1>\n<p class=\"pk-report-description\">{{description}}</p>\n</header>\n{{chart}}\n{{table}}\n{{details}}\n</main>\n</body>\n</html>\n";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type HtmlTable = {
|
|
2
|
+
/**
|
|
3
|
+
* the <table>
|
|
4
|
+
*/
|
|
5
|
+
html: string;
|
|
6
|
+
/**
|
|
7
|
+
* the rules of the conditional colours, per cell class
|
|
8
|
+
*/
|
|
9
|
+
css: string;
|
|
10
|
+
/**
|
|
11
|
+
* body rows shown of all, when the table was cut
|
|
12
|
+
*/
|
|
13
|
+
cut: {
|
|
14
|
+
rows: number;
|
|
15
|
+
of: number;
|
|
16
|
+
} | null;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} HtmlTable
|
|
20
|
+
* @property {string} html the <table>
|
|
21
|
+
* @property {string} css the rules of the conditional colours, per cell class
|
|
22
|
+
* @property {{ rows: number, of: number } | null} cut body rows shown of all, when the table was cut
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* @param {import("pivotkit-core").Grid} grid
|
|
26
|
+
* @param {{ maxCells: number, caption?: string, colorScale?: string[], dataBar?: string }} options
|
|
27
|
+
* colorScale, dataBar: the colours of a scale or a bar whose view names none (the host's tokens,
|
|
28
|
+
* tokens.js conditionalColors; else pivotkit-core's DEFAULT_THEME)
|
|
29
|
+
* @returns {HtmlTable}
|
|
30
|
+
*/
|
|
31
|
+
export declare function htmlTable(grid: import("pivotkit-core").Grid, { maxCells, caption, colorScale, dataBar }: {
|
|
32
|
+
maxCells: number;
|
|
33
|
+
caption?: string;
|
|
34
|
+
colorScale?: string[];
|
|
35
|
+
dataBar?: string;
|
|
36
|
+
}): HtmlTable;
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type RenderOptions = import("./render.js").RenderOptions;
|
|
2
|
+
export type RenderResult = import("./render.js").RenderResult;
|
|
3
|
+
export type ReportTexts = import("./texts.js").ReportTexts;
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import("./render.js").RenderOptions} RenderOptions
|
|
6
|
+
* @typedef {import("./render.js").RenderResult} RenderResult
|
|
7
|
+
* @typedef {import("./texts.js").ReportTexts} ReportTexts
|
|
8
|
+
*/
|
|
9
|
+
export { render, RENDER_FORMATS, REPORT_LIMITS, REPORT_ISSUE_CODES, CHART_SIZE } from "./render.js";
|
|
10
|
+
export { cleanSvg } from "./svg.js";
|
|
11
|
+
export { DEFAULT_TEMPLATE, REPORT_CSP } from "./default-template.js";
|
|
12
|
+
export { REPORT_TEXTS } from "./texts.js";
|
|
13
|
+
export { LIGHT_TOKENS, DARK_TOKENS, WEIGHT_TOKENS, SHAPE_TOKENS, SIZE_TOKENS } from "./tokens.js";
|