pivotkit-report 0.0.0-stage → 0.1.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 +51 -0
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/package.json +74 -4
- package/src/default-template.js +33 -0
- package/src/html-table.js +104 -0
- package/src/index.js +24 -0
- package/src/render.js +278 -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/render.d.ts +176 -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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The report's stylesheet ({{style}}): the tokens (light, dark on a dark screen, always light on
|
|
3
|
+
* paper), the table as the screen's report draws it (pivotkit-react's styles.css: totals shaded and
|
|
4
|
+
* bold, colour scales and data bars from --pk-cell-bg, --pk-bar and --pk-bar-color) and the print
|
|
5
|
+
* rules: margins, the header row on every page, rows not split, the chart kept whole, landscape for a
|
|
6
|
+
* wide table and a smaller font (--pk-print-small) for a very wide one. Every font size is a token
|
|
7
|
+
* (SIZE_TOKENS), so a host's sizes reach it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { DARK_TOKENS, LIGHT_TOKENS, REPORT_FONT, SHAPE_TOKENS, SIZE_TOKENS, WEIGHT_TOKENS, declarations } from "./tokens.js";
|
|
11
|
+
|
|
12
|
+
/** Columns from which a printed table is turned to landscape, and from which its font gets smaller. */
|
|
13
|
+
export const PRINT_WIDE = Object.freeze({ landscape: 8, small: 16 });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ columns: number, tokens?: Record<string, string>, extra?: string }} options
|
|
17
|
+
* columns: the table's; tokens: the host's (cleaned), over the light ones; extra: the cells' colour rules
|
|
18
|
+
*/
|
|
19
|
+
export function reportCss({ columns, tokens = {}, extra = "" }) {
|
|
20
|
+
const font = tokens["font-family"] ?? REPORT_FONT;
|
|
21
|
+
const light = declarations({ ...LIGHT_TOKENS, ...WEIGHT_TOKENS, ...SHAPE_TOKENS, ...SIZE_TOKENS, ...tokens });
|
|
22
|
+
// the chart is drawn in the light colours (the host's), on a screen in dark mode too: its card keeps
|
|
23
|
+
// the light surface the chart's image is drawn on
|
|
24
|
+
const chartSurface = { ...LIGHT_TOKENS, ...tokens }["--pk-surface"];
|
|
25
|
+
const page = columns > PRINT_WIDE.landscape ? "size:landscape;" : "";
|
|
26
|
+
return `:root{${light};color-scheme:light dark}
|
|
27
|
+
@media screen and (prefers-color-scheme:dark){:root{${declarations(DARK_TOKENS)}}}
|
|
28
|
+
*{box-sizing:border-box}
|
|
29
|
+
body{margin:0;background:var(--pk-surface);color:var(--pk-text);font:var(--pk-font-size)/1.45 ${font}}
|
|
30
|
+
.pk-report{padding:24px}
|
|
31
|
+
.pk-report h1{font-size:var(--pk-report-title-size);font-weight:var(--pk-weight-bold);line-height:1.3;margin:0 0 4px}
|
|
32
|
+
.pk-report-description{margin:0 0 12px;color:var(--pk-muted)}
|
|
33
|
+
.pk-report-description:empty{display:none}
|
|
34
|
+
.pk-report-chart{margin:12px 0 16px;padding:8px;background:${chartSurface};border:1px solid var(--pk-border);border-radius:var(--pk-radius-card);break-inside:avoid;width:max-content;max-width:100%}
|
|
35
|
+
.pk-report-chart img,.pk-report-chart svg{display:block;max-width:100%;height:auto}
|
|
36
|
+
.pk-table{border-collapse:collapse;font-variant-numeric:tabular-nums;margin:12px 0 0}
|
|
37
|
+
.pk-table th,.pk-table td{border:1px solid var(--pk-border);padding:4px 8px;vertical-align:top}
|
|
38
|
+
.pk-th{background:var(--pk-surface-2);text-align:left;font-weight:var(--pk-weight-strong)}
|
|
39
|
+
.pk-th.pk-row{font-weight:var(--pk-weight-normal);background:var(--pk-surface)}
|
|
40
|
+
.pk-num{text-align:right;white-space:nowrap}
|
|
41
|
+
.pk-table .pk-sub{background:var(--pk-total);font-weight:var(--pk-weight-strong)}
|
|
42
|
+
.pk-table .pk-grand{background:var(--pk-total-strong);font-weight:var(--pk-weight-bold)}
|
|
43
|
+
.pk-collapsed::before{content:"+ ";color:var(--pk-muted)}
|
|
44
|
+
.pk-td.pk-has-scale{background:color-mix(in srgb,var(--pk-cell-bg) 48%,var(--pk-surface))}
|
|
45
|
+
.pk-td.pk-has-bar{background-image:linear-gradient(90deg,color-mix(in srgb,var(--pk-bar-color,var(--pk-chart-1)) 32%,transparent) var(--pk-bar),transparent var(--pk-bar));background-repeat:no-repeat;background-size:100% 64%;background-position:left center}
|
|
46
|
+
.pk-report-note{color:var(--pk-muted);margin:8px 0}
|
|
47
|
+
.pk-report-details{display:grid;grid-template-columns:max-content 1fr;gap:2px 12px;margin:16px 0 0;color:var(--pk-muted);font-size:var(--pk-small)}
|
|
48
|
+
.pk-report-details dt{font-weight:var(--pk-weight-strong)}
|
|
49
|
+
.pk-report-details dd{margin:0}
|
|
50
|
+
@page{margin:12mm;${page}}
|
|
51
|
+
@media print{
|
|
52
|
+
body{font-size:var(--pk-print-size);-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
53
|
+
.pk-report{padding:0}
|
|
54
|
+
.pk-report-chart{border:none;padding:0}
|
|
55
|
+
thead{display:table-header-group}
|
|
56
|
+
tr{break-inside:avoid}
|
|
57
|
+
.pk-table th,.pk-table td{padding:2px 6px}
|
|
58
|
+
${columns > PRINT_WIDE.small ? ".pk-table{font-size:var(--pk-print-small)}" : ""}
|
|
59
|
+
}
|
|
60
|
+
${extra}`;
|
|
61
|
+
}
|
package/src/svg.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The chart as SVG: pivotkit-core's ECharts option with the report's marks (pivotkit-core/chart, the
|
|
3
|
+
* same look as on the screen) drawn by ECharts' server-side SVG renderer (`init(null, null, { renderer:
|
|
4
|
+
* "svg", ssr: true })` and `renderToSVGString()`, ECharts 5.3 and later), then checked and cleaned by
|
|
5
|
+
* cleanSvg: the text of a chart comes from the data, and an SVG opened on its own can run script, so
|
|
6
|
+
* nothing is trusted to the renderer's escaping alone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { PivotkitError, chartPlan, chartThemeFrom, computePivot, swapAxes, toEChartsOption, toSeries } from "pivotkit-core";
|
|
10
|
+
import { decorateOption } from "pivotkit-core/chart";
|
|
11
|
+
import { BIDI_CONTROLS } from "./texts.js";
|
|
12
|
+
import { LIGHT_TOKENS } from "./tokens.js";
|
|
13
|
+
|
|
14
|
+
/** Presentation attributes a `style` attribute's declarations become (other declarations are dropped). */
|
|
15
|
+
const PRESENTATION = new Set(["fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "opacity", "font-size", "font-family", "font-weight", "font-style", "text-anchor", "dominant-baseline", "paint-order", "visibility"]);
|
|
16
|
+
/** Elements an ECharts SVG may hold. */
|
|
17
|
+
const ELEMENTS = new Set(["svg", "g", "rect", "path", "text", "tspan", "circle", "ellipse", "line", "polyline", "polygon", "defs", "clipPath", "linearGradient", "radialGradient", "stop", "pattern", "image", "title", "desc"]);
|
|
18
|
+
|
|
19
|
+
/** Control characters XML does not allow (an SVG with one is not shown at all): left out. */
|
|
20
|
+
// eslint-disable-next-line no-control-regex
|
|
21
|
+
const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
22
|
+
|
|
23
|
+
/** Whether XML 1.0 allows a character (Char): a numeric reference to any other makes the SVG unreadable. */
|
|
24
|
+
const xmlChar = (code) => code === 0x9 || code === 0xa || code === 0xd || (code >= 0x20 && code <= 0xd7ff && code !== 0x7f) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
|
|
25
|
+
|
|
26
|
+
/** Whether a numeric reference stays: a character XML allows that is no bidi control. */
|
|
27
|
+
const keptReference = (code) => xmlChar(code) && !Number.isNaN(code) && !String.fromCodePoint(code).match(BIDI_CONTROLS);
|
|
28
|
+
|
|
29
|
+
/** An attribute name as XML writes one: a quote, "=" or "<" in a name would break the SVG as XML. */
|
|
30
|
+
const ATTRIBUTE_NAME = /^[A-Za-z_][\w:.-]*$/;
|
|
31
|
+
/** The namespaces an ECharts SVG declares: the default one is SVG's, xlink: for old links. */
|
|
32
|
+
const NAMESPACES = Object.freeze({ xmlns: "http://www.w3.org/2000/svg", "xmlns:xlink": "http://www.w3.org/1999/xlink" });
|
|
33
|
+
|
|
34
|
+
const decode = (text) => text.replace(/&(quot|#39|apos|lt|gt|amp);/g, (_, e) => ({ quot: '"', "#39": "'", apos: "'", lt: "<", gt: ">", amp: "&" })[e]);
|
|
35
|
+
const encode = (text) => text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
36
|
+
|
|
37
|
+
/** @param {string} reason */
|
|
38
|
+
const unsafe = (reason) => new PivotkitError("INVALID_ARGUMENT", `the chart's SVG was refused: ${reason}`, { reason });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks an SVG string and makes it safe to open and to show under a strict Content-Security-Policy:
|
|
42
|
+
* only the elements an ECharts chart draws, no event handler attribute, no link but to a fragment or
|
|
43
|
+
* a data: image, no attribute name but a plain one, no namespace but SVG's and xlink's, no text that is not escaped; every `style` attribute becomes presentation attributes
|
|
44
|
+
* (a policy without 'unsafe-inline' blocks style attributes, not those) and a <style> element (ECharts'
|
|
45
|
+
* hover states) and characters XML does not allow, also as numeric references, are left out. Throws INVALID_ARGUMENT
|
|
46
|
+
* (`details.reason`) for anything else.
|
|
47
|
+
* @param {string} svg
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function cleanSvg(svg) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const tag = /<(\/?)([a-zA-Z][\w:-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*(\/?)>/y;
|
|
53
|
+
const attribute = /\s+([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
54
|
+
let i = svg.startsWith("<?xml") ? svg.indexOf("?>") + 2 : 0;
|
|
55
|
+
let depth = 0;
|
|
56
|
+
while (i < svg.length) {
|
|
57
|
+
const lt = svg.indexOf("<", i);
|
|
58
|
+
const text = svg.slice(i, lt === -1 ? svg.length : lt);
|
|
59
|
+
if (/[<>]/.test(text) || /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/.test(text)) {
|
|
60
|
+
throw unsafe("text that is not escaped");
|
|
61
|
+
}
|
|
62
|
+
out.push(text.replace(CONTROL, "").replace(BIDI_CONTROLS, "").replace(/&#(x[0-9a-fA-F]+|\d+);/g, (entity, number) => (keptReference(number[0] === "x" ? parseInt(number.slice(1), 16) : Number(number)) ? entity : "")));
|
|
63
|
+
if (lt === -1) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
// ECharts writes the hover states of its marks as a <style> of CSS classes: a static image has
|
|
67
|
+
// no hover, and an SVG shown under a strict Content-Security-Policy may hold no <style>
|
|
68
|
+
if (/^<style[\s>]/i.test(svg.slice(lt, lt + 7))) {
|
|
69
|
+
const end = svg.search(/<\/style\s*>/i);
|
|
70
|
+
if (end === -1 || end < lt) {
|
|
71
|
+
throw unsafe("a <style> element that is not closed");
|
|
72
|
+
}
|
|
73
|
+
i = svg.indexOf(">", end) + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (svg.startsWith("<!--", lt)) {
|
|
77
|
+
const end = svg.indexOf("-->", lt);
|
|
78
|
+
i = end === -1 ? svg.length : end + 3;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
tag.lastIndex = lt;
|
|
82
|
+
const m = tag.exec(svg);
|
|
83
|
+
if (!m) {
|
|
84
|
+
throw unsafe("markup that is not a plain element");
|
|
85
|
+
}
|
|
86
|
+
const [, closing, name, attrs, selfClosing] = m;
|
|
87
|
+
if (!ELEMENTS.has(name)) {
|
|
88
|
+
throw unsafe(`the element <${name}>`);
|
|
89
|
+
}
|
|
90
|
+
let written = `<${closing}${name}`;
|
|
91
|
+
const presentation = new Map();
|
|
92
|
+
for (const [, attrName, double, single] of attrs.matchAll(attribute)) {
|
|
93
|
+
const value = decode(double ?? single ?? "").replace(CONTROL, "").replace(BIDI_CONTROLS, "");
|
|
94
|
+
const lower = attrName.toLowerCase();
|
|
95
|
+
if (!ATTRIBUTE_NAME.test(attrName)) {
|
|
96
|
+
throw unsafe("an attribute name that is not a plain name");
|
|
97
|
+
}
|
|
98
|
+
if ((lower === "xmlns" || lower.startsWith("xmlns:")) && NAMESPACES[lower] !== value) {
|
|
99
|
+
throw unsafe(`the namespace ${attrName}`);
|
|
100
|
+
}
|
|
101
|
+
if (lower.startsWith("on")) {
|
|
102
|
+
throw unsafe(`the event attribute ${attrName}`);
|
|
103
|
+
}
|
|
104
|
+
if ((lower === "href" || lower === "xlink:href" || lower === "src") && !(/^#[\w-]+$/.test(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]*$/.test(value))) {
|
|
105
|
+
throw unsafe(`a link in ${attrName}`);
|
|
106
|
+
}
|
|
107
|
+
if (lower === "style") {
|
|
108
|
+
for (const declaration of value.split(";")) {
|
|
109
|
+
const colon = declaration.indexOf(":");
|
|
110
|
+
const prop = declaration.slice(0, colon).trim().toLowerCase();
|
|
111
|
+
const v = declaration.slice(colon + 1).trim();
|
|
112
|
+
if (colon > 0 && PRESENTATION.has(prop) && !/url\s*\(|expression\s*\(/i.test(v)) {
|
|
113
|
+
presentation.set(prop, v);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (/url\s*\(\s*['"]?\s*(?!#)/i.test(value)) {
|
|
119
|
+
throw unsafe(`an address in ${attrName}`);
|
|
120
|
+
}
|
|
121
|
+
written += ` ${attrName}="${encode(value)}"`;
|
|
122
|
+
}
|
|
123
|
+
for (const [prop, v] of presentation) {
|
|
124
|
+
if (!new RegExp(`\\s${prop}=`).test(written)) {
|
|
125
|
+
written += ` ${prop}="${encode(v)}"`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
written += `${selfClosing}>`;
|
|
129
|
+
out.push(written);
|
|
130
|
+
if (name === "svg") {
|
|
131
|
+
depth += closing ? -1 : selfClosing ? 0 : 1;
|
|
132
|
+
}
|
|
133
|
+
i = tag.lastIndex;
|
|
134
|
+
}
|
|
135
|
+
if (depth !== 0) {
|
|
136
|
+
throw unsafe("an <svg> element that is not closed");
|
|
137
|
+
}
|
|
138
|
+
return out.join("");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The chart of a pivot as SVG, as pivotkit-react draws it: chartPlan's type (the view's, or what
|
|
143
|
+
* "auto" picks; rows and columns swapped for the chart where it says so), the theme's colours from
|
|
144
|
+
* `style`, the axis labels laid out for the size, at most `maxCategories` categories.
|
|
145
|
+
* @param {import("pivotkit-core").PivotResult} result
|
|
146
|
+
* @param {{ echarts: { init: Function }, style: { getPropertyValue(name: string): string }, width: number, height: number,
|
|
147
|
+
* locale: string, labels?: object, limits?: object, now?: any, maxCategories: number }} options
|
|
148
|
+
* @returns {{ svg: string, categories: number, of: number }}
|
|
149
|
+
*/
|
|
150
|
+
export function chartSvg(result, { echarts, style, width, height, locale, labels, limits, now, maxCategories }) {
|
|
151
|
+
const view = result.view;
|
|
152
|
+
const plan = chartPlan(view, result.encoded);
|
|
153
|
+
const drawn = plan.swap ? computePivot(result.encoded, swapAxes(view), { locale, limits, ...(now !== undefined ? { now } : {}) }) : result;
|
|
154
|
+
const series = toSeries(drawn, { labels, limits, maxCategories });
|
|
155
|
+
const option = toEChartsOption(drawn, { theme: chartThemeFrom(style), locale, labels, limits, maxCategories, series, chart: plan.chart });
|
|
156
|
+
const decorated = decorateOption(option, style, { width, height, categoryNames: series.categories, axisLabels: view.chart.axisLabels });
|
|
157
|
+
const surface = style.getPropertyValue("--pk-surface").trim() || LIGHT_TOKENS["--pk-surface"];
|
|
158
|
+
const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width, height });
|
|
159
|
+
let svg;
|
|
160
|
+
try {
|
|
161
|
+
// axisEvery is the report's own note, not ECharts'
|
|
162
|
+
const { axisEvery, ...option2 } = decorated;
|
|
163
|
+
void axisEvery;
|
|
164
|
+
chart.setOption({ ...option2, backgroundColor: surface, animation: false });
|
|
165
|
+
svg = chart.renderToSVGString();
|
|
166
|
+
} finally {
|
|
167
|
+
chart.dispose();
|
|
168
|
+
}
|
|
169
|
+
return { svg: cleanSvg(svg), categories: series.categories.length, of: series.categoryCount };
|
|
170
|
+
}
|
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));
|