@capybara-hq/plugin-sdk 0.1.0-rc.6 → 0.1.0-rc.8
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/agent-docs/connector-templates.md +1 -0
- package/agent-docs/plugin-format.md +17 -2
- package/dist/cards.d.ts +7 -0
- package/dist/cards.js +4 -4
- package/dist/channel.d.ts +20 -1
- package/dist/connector-auth.d.ts +2 -0
- package/dist/connector-auth.js +15 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/marketplace.d.ts +26 -0
- package/dist/marketplace.js +43 -1
- package/dist/plugin-manifest.d.ts +22 -3
- package/dist/plugin-manifest.js +85 -5
- package/dist/setup-guide.d.ts +94 -0
- package/dist/setup-guide.js +135 -0
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +449 -0
- package/dist/theme-readability.d.ts +37 -0
- package/dist/theme-readability.js +145 -0
- package/dist/theme-tokens.generated.d.ts +275 -0
- package/dist/theme-tokens.generated.js +167 -0
- package/dist/theme.d.ts +152 -0
- package/dist/theme.js +392 -0
- package/package.json +2 -1
- package/schemas/connector-template.v1.schema.json +3 -0
- package/schemas/marketplace.v1.schema.json +14 -0
- package/schemas/plugin.v1.schema.json +155 -0
- package/schemas/theme.v1.schema.json +225 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { SaxesParser } from "saxes";
|
|
2
|
+
import { isSafePackagePath } from "./plugin-package.js";
|
|
3
|
+
import { THEME_LIMITS } from "./theme.js";
|
|
4
|
+
/**
|
|
5
|
+
* Checks of a theme's asset files (themes spec §4.2-§4.4): woff2 fonts, PNG logos and the SVG
|
|
6
|
+
* sanitizer. The sanitized SVG is served to every browser, so {@link sanitizeThemeSvg} is a trust
|
|
7
|
+
* boundary: the output is re-serialized from the parsed tree (never the input bytes), elements are
|
|
8
|
+
* allowlisted, every attribute value and text node is escaped on output.
|
|
9
|
+
*/
|
|
10
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
11
|
+
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
12
|
+
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
13
|
+
const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
|
|
14
|
+
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
|
15
|
+
/** SVG elements a logo may use: shapes, structure, paint servers, filters and animation. */
|
|
16
|
+
const SVG_ELEMENTS = new Set([
|
|
17
|
+
"svg",
|
|
18
|
+
"g",
|
|
19
|
+
"defs",
|
|
20
|
+
"symbol",
|
|
21
|
+
"use",
|
|
22
|
+
"title",
|
|
23
|
+
"desc",
|
|
24
|
+
"metadata",
|
|
25
|
+
"a",
|
|
26
|
+
"switch",
|
|
27
|
+
"view",
|
|
28
|
+
"path",
|
|
29
|
+
"rect",
|
|
30
|
+
"circle",
|
|
31
|
+
"ellipse",
|
|
32
|
+
"line",
|
|
33
|
+
"polyline",
|
|
34
|
+
"polygon",
|
|
35
|
+
"text",
|
|
36
|
+
"tspan",
|
|
37
|
+
"textPath",
|
|
38
|
+
"image",
|
|
39
|
+
"marker",
|
|
40
|
+
"linearGradient",
|
|
41
|
+
"radialGradient",
|
|
42
|
+
"stop",
|
|
43
|
+
"pattern",
|
|
44
|
+
"clipPath",
|
|
45
|
+
"mask",
|
|
46
|
+
"filter",
|
|
47
|
+
"feBlend",
|
|
48
|
+
"feColorMatrix",
|
|
49
|
+
"feComponentTransfer",
|
|
50
|
+
"feComposite",
|
|
51
|
+
"feConvolveMatrix",
|
|
52
|
+
"feDiffuseLighting",
|
|
53
|
+
"feDisplacementMap",
|
|
54
|
+
"feDistantLight",
|
|
55
|
+
"feDropShadow",
|
|
56
|
+
"feFlood",
|
|
57
|
+
"feFuncA",
|
|
58
|
+
"feFuncB",
|
|
59
|
+
"feFuncG",
|
|
60
|
+
"feFuncR",
|
|
61
|
+
"feGaussianBlur",
|
|
62
|
+
"feImage",
|
|
63
|
+
"feMerge",
|
|
64
|
+
"feMergeNode",
|
|
65
|
+
"feMorphology",
|
|
66
|
+
"feOffset",
|
|
67
|
+
"fePointLight",
|
|
68
|
+
"feSpecularLighting",
|
|
69
|
+
"feSpotLight",
|
|
70
|
+
"feTile",
|
|
71
|
+
"feTurbulence",
|
|
72
|
+
"animate",
|
|
73
|
+
"animateMotion",
|
|
74
|
+
"animateTransform",
|
|
75
|
+
"set",
|
|
76
|
+
"mpath",
|
|
77
|
+
]);
|
|
78
|
+
/** `xml:` attributes kept; `xml:base` and the rest are dropped. */
|
|
79
|
+
const XML_ATTRS = new Set(["space", "lang"]);
|
|
80
|
+
/** Root attributes dropped: the design system sizes the logo to its slot. */
|
|
81
|
+
const ROOT_DROPPED = new Set(["width", "height", "class"]);
|
|
82
|
+
/** Characters of a viewBox value; longer values are refused before they are split. */
|
|
83
|
+
const VIEWBOX_MAX_CHARS = 128;
|
|
84
|
+
/** Characters a viewBox number may use (checked per character class, no backtracking). */
|
|
85
|
+
const VIEWBOX_NUMBER_RE = /^[-+.0-9eE]+$/;
|
|
86
|
+
/**
|
|
87
|
+
* The only functions an attribute value may call: colors and transforms. `url` is allowed only as
|
|
88
|
+
* `url(#id)` (checked separately); everything else (`image-set`, `cross-fade`, `element`,
|
|
89
|
+
* `src`, `var`, ...) is refused, since some of them load resources from quoted strings.
|
|
90
|
+
*/
|
|
91
|
+
const ALLOWED_FUNCTIONS = new Set([
|
|
92
|
+
"url",
|
|
93
|
+
"rgb",
|
|
94
|
+
"rgba",
|
|
95
|
+
"hsl",
|
|
96
|
+
"hsla",
|
|
97
|
+
"translate",
|
|
98
|
+
"rotate",
|
|
99
|
+
"scale",
|
|
100
|
+
"matrix",
|
|
101
|
+
"skewx",
|
|
102
|
+
"skewy",
|
|
103
|
+
]);
|
|
104
|
+
const isIdentChar = (c) => /[A-Za-z0-9_-]/.test(c);
|
|
105
|
+
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f";
|
|
106
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: whitespace and control characters are stripped before matching schemes
|
|
107
|
+
const IGNORED_IN_SCHEME_RE = /[\u0000- \u007f]/g;
|
|
108
|
+
class SvgRejected extends Error {
|
|
109
|
+
}
|
|
110
|
+
const reject = (message) => {
|
|
111
|
+
throw new SvgRejected(message);
|
|
112
|
+
};
|
|
113
|
+
const escapeText = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
114
|
+
const escapeAttr = (s) => escapeText(s)
|
|
115
|
+
.replace(/"/g, """)
|
|
116
|
+
.replace(/\t/g, "	")
|
|
117
|
+
.replace(/\n/g, " ")
|
|
118
|
+
.replace(/\r/g, " ");
|
|
119
|
+
/** Rejects an attribute value that could run script or load a resource outside the document. */
|
|
120
|
+
function checkValue(el, attr, value) {
|
|
121
|
+
const squeezed = value.replace(IGNORED_IN_SCHEME_RE, "").toLowerCase();
|
|
122
|
+
if (squeezed.includes("javascript:") || squeezed.includes("vbscript:")) {
|
|
123
|
+
reject(`attribute ${attr} on <${el}> must not contain javascript: or vbscript: URLs`);
|
|
124
|
+
}
|
|
125
|
+
// CSS escapes could spell a function name; quoted strings are how CSS functions take URLs.
|
|
126
|
+
if (value.includes("\\"))
|
|
127
|
+
reject(`attribute ${attr} on <${el}> must not contain "\\"`);
|
|
128
|
+
if (value.includes("'") || value.includes('"')) {
|
|
129
|
+
reject(`attribute ${attr} on <${el}> must not contain quotes`);
|
|
130
|
+
}
|
|
131
|
+
// Linear scan: at every "(", walk back over spaces and the function name.
|
|
132
|
+
for (let i = value.indexOf("("); i !== -1; i = value.indexOf("(", i + 1)) {
|
|
133
|
+
let end = i;
|
|
134
|
+
while (end > 0 && isSpace(value.charAt(end - 1)))
|
|
135
|
+
end -= 1;
|
|
136
|
+
let start = end;
|
|
137
|
+
while (start > 0 && isIdentChar(value.charAt(start - 1)))
|
|
138
|
+
start -= 1;
|
|
139
|
+
const fn = value.slice(start, end).toLowerCase();
|
|
140
|
+
if (!ALLOWED_FUNCTIONS.has(fn)) {
|
|
141
|
+
reject(`attribute ${attr} on <${el}> must not call ${fn === "" ? "a bare (" : `${fn}()`}; only url(#id), colors and transforms are allowed`);
|
|
142
|
+
}
|
|
143
|
+
if (fn === "url") {
|
|
144
|
+
let j = i + 1;
|
|
145
|
+
while (j < value.length && isSpace(value.charAt(j)))
|
|
146
|
+
j += 1;
|
|
147
|
+
if (value.charAt(j) !== "#") {
|
|
148
|
+
reject(`attribute ${attr} on <${el}> may only reference url(#id) inside the document`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** `min-x min-y width height`, width and height > 0; undefined when malformed. */
|
|
154
|
+
function parseViewBox(v) {
|
|
155
|
+
if (v.length > VIEWBOX_MAX_CHARS)
|
|
156
|
+
return undefined;
|
|
157
|
+
const parts = v
|
|
158
|
+
.trim()
|
|
159
|
+
.split(/[\s,]+/)
|
|
160
|
+
.filter((p) => p !== "");
|
|
161
|
+
if (parts.length !== 4 || !parts.every((p) => VIEWBOX_NUMBER_RE.test(p)))
|
|
162
|
+
return undefined;
|
|
163
|
+
const n = parts.map(Number);
|
|
164
|
+
if (!n.every(Number.isFinite) || n[2] <= 0 || n[3] <= 0)
|
|
165
|
+
return undefined;
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
/** The serialized attributes of an allowed SVG element; rejects the forbidden ones. */
|
|
169
|
+
function attributesOf(tag, isRoot, usesXlink) {
|
|
170
|
+
const el = tag.local;
|
|
171
|
+
let out = "";
|
|
172
|
+
for (const a of Object.values(tag.attributes)) {
|
|
173
|
+
const lower = a.local.toLowerCase();
|
|
174
|
+
if (a.uri === XMLNS_NS || a.name === "xmlns")
|
|
175
|
+
continue;
|
|
176
|
+
let name;
|
|
177
|
+
if (a.uri === "") {
|
|
178
|
+
if (lower === "style")
|
|
179
|
+
reject(`attribute style on <${el}> is not allowed`);
|
|
180
|
+
if (lower.startsWith("on"))
|
|
181
|
+
reject(`event handler attribute ${a.name} on <${el}> is not allowed`);
|
|
182
|
+
if (isRoot && ROOT_DROPPED.has(a.local))
|
|
183
|
+
continue;
|
|
184
|
+
name = a.local;
|
|
185
|
+
}
|
|
186
|
+
else if (a.uri === XLINK_NS) {
|
|
187
|
+
usesXlink.value = true;
|
|
188
|
+
name = `xlink:${a.local}`;
|
|
189
|
+
}
|
|
190
|
+
else if (a.uri === XML_NS) {
|
|
191
|
+
if (!XML_ATTRS.has(a.local))
|
|
192
|
+
continue;
|
|
193
|
+
name = `xml:${a.local}`;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// Editor metadata (inkscape:*, sodipodi:*, …) in foreign namespaces is dropped.
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (lower === "href" && !a.value.startsWith("#")) {
|
|
200
|
+
reject(`attribute ${name} on <${el}> must reference an element of the document (#id)`);
|
|
201
|
+
}
|
|
202
|
+
if (lower === "attributename") {
|
|
203
|
+
const target = (a.value.split(":").pop() ?? "").trim().toLowerCase();
|
|
204
|
+
if (target === "href" || target === "style" || target.startsWith("on")) {
|
|
205
|
+
reject(`<${el}> must not animate ${a.value}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
checkValue(el, name, a.value);
|
|
209
|
+
out += ` ${name}="${escapeAttr(a.value)}"`;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Parses a logo SVG (UTF-8, ≤ {@link THEME_LIMITS}`.svgBytes`) with a strict, namespace-aware
|
|
215
|
+
* XML parser and re-serializes it. The root must be an `svg` element in the SVG namespace with a
|
|
216
|
+
* numeric `viewBox`; its `width`, `height` and `class` are dropped. Rejected: a DOCTYPE (and so
|
|
217
|
+
* any entity declaration), processing instructions, a non-UTF-8 encoding, elements outside the
|
|
218
|
+
* SVG allowlist (`script`, `foreignObject`, `style`, …) or in the XHTML namespace, `style` and
|
|
219
|
+
* `on*` attributes, `href` / `xlink:href` not starting with `#`, `javascript:` / `vbscript:` in
|
|
220
|
+
* any value, quotes or a backslash in attribute values, any function other than `url(#id)`, colors
|
|
221
|
+
* (`rgb`, `rgba`, `hsl`, `hsla`) and transforms (`translate`, `rotate`, `scale`, `matrix`,
|
|
222
|
+
* `skewX`, `skewY`), a viewBox longer than 128 characters, and animation of `href`, `style` or event
|
|
223
|
+
* handlers. Comments, the XML declaration and elements / attributes in other namespaces (editor
|
|
224
|
+
* metadata) are dropped; CDATA becomes escaped text. Never throws.
|
|
225
|
+
*/
|
|
226
|
+
export function sanitizeThemeSvg(bytes) {
|
|
227
|
+
const r = sanitize(bytes);
|
|
228
|
+
return "error" in r ? r : { svg: r.svg };
|
|
229
|
+
}
|
|
230
|
+
/** {@link sanitizeThemeSvg} plus the root viewBox (for the favicon's squareness). */
|
|
231
|
+
function sanitize(bytes) {
|
|
232
|
+
if (bytes.length > THEME_LIMITS.svgBytes) {
|
|
233
|
+
return { error: `SVG is larger than ${THEME_LIMITS.svgBytes} bytes` };
|
|
234
|
+
}
|
|
235
|
+
let source;
|
|
236
|
+
try {
|
|
237
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return { error: "SVG is not valid UTF-8" };
|
|
241
|
+
}
|
|
242
|
+
const parts = [];
|
|
243
|
+
const usesXlink = { value: false };
|
|
244
|
+
let xlinkAt = -1;
|
|
245
|
+
let depth = 0;
|
|
246
|
+
/** Depth at which a dropped foreign element started; 0 when not inside one. */
|
|
247
|
+
let skipFrom = 0;
|
|
248
|
+
/** The last emitted start tag still lacks its `>`. */
|
|
249
|
+
let openPending = false;
|
|
250
|
+
let sawRoot = false;
|
|
251
|
+
let rootBox = [0, 0, 0, 0];
|
|
252
|
+
const closePending = () => {
|
|
253
|
+
if (openPending)
|
|
254
|
+
parts.push(">");
|
|
255
|
+
openPending = false;
|
|
256
|
+
};
|
|
257
|
+
const parser = new SaxesParser({ xmlns: true, position: true });
|
|
258
|
+
parser.on("error", (e) => reject(`SVG is not valid XML: ${e.message}`));
|
|
259
|
+
parser.on("doctype", () => reject("SVG must not contain a DOCTYPE or entity declarations"));
|
|
260
|
+
parser.on("processinginstruction", () => reject("SVG must not contain processing instructions"));
|
|
261
|
+
parser.on("xmldecl", (d) => {
|
|
262
|
+
if (d.encoding !== undefined && !/^utf-?8$/i.test(d.encoding)) {
|
|
263
|
+
reject(`SVG must be encoded as UTF-8, not ${d.encoding}`);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
parser.on("opentag", (tag) => {
|
|
267
|
+
depth += 1;
|
|
268
|
+
if (skipFrom > 0)
|
|
269
|
+
return;
|
|
270
|
+
const isRoot = !sawRoot;
|
|
271
|
+
sawRoot = true;
|
|
272
|
+
if (tag.uri === XHTML_NS)
|
|
273
|
+
reject(`XHTML element <${tag.name}> is not allowed`);
|
|
274
|
+
if (isRoot) {
|
|
275
|
+
if (tag.local !== "svg")
|
|
276
|
+
reject("the root element must be <svg>");
|
|
277
|
+
if (tag.uri !== SVG_NS)
|
|
278
|
+
reject(`the root <svg> must be in the SVG namespace (${SVG_NS})`);
|
|
279
|
+
const viewBox = Object.values(tag.attributes).find((a) => a.uri === "" && a.local === "viewBox");
|
|
280
|
+
if (viewBox === undefined)
|
|
281
|
+
reject("the root <svg> must have a viewBox");
|
|
282
|
+
else {
|
|
283
|
+
const box = parseViewBox(viewBox.value);
|
|
284
|
+
if (box === undefined) {
|
|
285
|
+
reject("the root <svg> viewBox must be four numbers with a positive width and height");
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
rootBox = box;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (tag.uri !== SVG_NS) {
|
|
292
|
+
skipFrom = depth;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!SVG_ELEMENTS.has(tag.local))
|
|
296
|
+
reject(`element <${tag.local}> is not allowed`);
|
|
297
|
+
closePending();
|
|
298
|
+
parts.push(`<${tag.local}`);
|
|
299
|
+
if (isRoot) {
|
|
300
|
+
parts.push(` xmlns="${SVG_NS}"`);
|
|
301
|
+
xlinkAt = parts.length;
|
|
302
|
+
parts.push("");
|
|
303
|
+
}
|
|
304
|
+
parts.push(attributesOf(tag, isRoot, usesXlink));
|
|
305
|
+
openPending = true;
|
|
306
|
+
});
|
|
307
|
+
parser.on("closetag", (tag) => {
|
|
308
|
+
depth -= 1;
|
|
309
|
+
if (skipFrom > 0) {
|
|
310
|
+
if (depth < skipFrom)
|
|
311
|
+
skipFrom = 0;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (openPending) {
|
|
315
|
+
parts.push("/>");
|
|
316
|
+
openPending = false;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
parts.push(`</${tag.local}>`);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const onText = (t) => {
|
|
323
|
+
if (depth === 0 || skipFrom > 0 || t === "")
|
|
324
|
+
return;
|
|
325
|
+
closePending();
|
|
326
|
+
parts.push(escapeText(t));
|
|
327
|
+
};
|
|
328
|
+
parser.on("text", onText);
|
|
329
|
+
parser.on("cdata", onText);
|
|
330
|
+
try {
|
|
331
|
+
parser.write(source).close();
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
if (e instanceof SvgRejected)
|
|
335
|
+
return { error: e.message };
|
|
336
|
+
return { error: `SVG is not valid XML: ${e instanceof Error ? e.message : String(e)}` };
|
|
337
|
+
}
|
|
338
|
+
if (!sawRoot)
|
|
339
|
+
return { error: "the root element must be <svg>" };
|
|
340
|
+
if (usesXlink.value)
|
|
341
|
+
parts[xlinkAt] = ` xmlns:xlink="${XLINK_NS}"`;
|
|
342
|
+
return { svg: parts.join(""), viewBox: rootBox };
|
|
343
|
+
}
|
|
344
|
+
const startsWith = (bytes, sig) => bytes.length >= sig.length && sig.every((b, i) => bytes[i] === b);
|
|
345
|
+
const WOFF2_SIG = [0x77, 0x4f, 0x46, 0x32]; // "wOF2"
|
|
346
|
+
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
347
|
+
const IHDR = [0x49, 0x48, 0x44, 0x52];
|
|
348
|
+
/** A woff2 font: the `wOF2` signature and at most {@link THEME_LIMITS}`.fontBytes`. */
|
|
349
|
+
export function checkWoff2(bytes) {
|
|
350
|
+
if (bytes.length > THEME_LIMITS.fontBytes) {
|
|
351
|
+
return `font is larger than ${THEME_LIMITS.fontBytes} bytes`;
|
|
352
|
+
}
|
|
353
|
+
if (!startsWith(bytes, WOFF2_SIG))
|
|
354
|
+
return "font is not a woff2 file (no wOF2 signature)";
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* A PNG logo: the PNG signature, an `IHDR` first chunk with a height of at least
|
|
359
|
+
* {@link THEME_LIMITS}`.pngMinHeight` pixels, and at most {@link THEME_LIMITS}`.pngBytes`.
|
|
360
|
+
*/
|
|
361
|
+
export function checkPng(bytes) {
|
|
362
|
+
const r = readPng(bytes);
|
|
363
|
+
return typeof r === "string" ? r : undefined;
|
|
364
|
+
}
|
|
365
|
+
/** {@link checkPng}, returning the IHDR size when the PNG passes. */
|
|
366
|
+
function readPng(bytes) {
|
|
367
|
+
if (bytes.length > THEME_LIMITS.pngBytes) {
|
|
368
|
+
return `PNG is larger than ${THEME_LIMITS.pngBytes} bytes`;
|
|
369
|
+
}
|
|
370
|
+
if (!startsWith(bytes, PNG_SIG))
|
|
371
|
+
return "image is not a PNG file (no PNG signature)";
|
|
372
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
373
|
+
if (bytes.length < 24 || view.getUint32(8) !== 13 || !IHDR.every((b, i) => bytes[12 + i] === b)) {
|
|
374
|
+
return "PNG has no valid IHDR header";
|
|
375
|
+
}
|
|
376
|
+
const width = view.getUint32(16);
|
|
377
|
+
const height = view.getUint32(20);
|
|
378
|
+
if (width === 0)
|
|
379
|
+
return "PNG has a width of 0";
|
|
380
|
+
if (height < THEME_LIMITS.pngMinHeight) {
|
|
381
|
+
return `PNG must be at least ${THEME_LIMITS.pngMinHeight} px high, not ${height}`;
|
|
382
|
+
}
|
|
383
|
+
return { width, height };
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Resolves and checks the asset files of a parsed theme: each `fonts[].file` ({@link checkWoff2})
|
|
387
|
+
* and `logo.mark` / `logo.favicon` (`.svg` through {@link sanitizeThemeSvg}, `.png` through
|
|
388
|
+
* {@link checkPng}); the favicon must be square (SVG viewBox or PNG width = height). `files` holds the plugin's files keyed by their path without a leading
|
|
389
|
+
* `./`; the returned `assets` (the sanitized SVG bytes, the other files unchanged) use the same
|
|
390
|
+
* keys. Errors are `<field>: <reason>`, English; the total input size of the theme's distinct
|
|
391
|
+
* assets must stay within {@link THEME_LIMITS}`.assetsBytes`. That limit is per plugin, so a
|
|
392
|
+
* caller reading several themes also checks the sum over all of them (distinct paths).
|
|
393
|
+
*/
|
|
394
|
+
export function validateThemeAssets(theme, files) {
|
|
395
|
+
const assets = new Map();
|
|
396
|
+
const errors = [];
|
|
397
|
+
const sizes = new Map();
|
|
398
|
+
const resolve = (field, ref, check) => {
|
|
399
|
+
const path = ref.replace(/^\.\//, "");
|
|
400
|
+
if (!isSafePackagePath(path)) {
|
|
401
|
+
errors.push(`${field}: ${ref} must be a relative path inside the plugin`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const bytes = files.get(path);
|
|
405
|
+
if (bytes === undefined) {
|
|
406
|
+
errors.push(`${field}: ${ref} not found in the plugin`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const r = check(bytes);
|
|
410
|
+
if (typeof r === "string") {
|
|
411
|
+
errors.push(`${field}: ${ref}: ${r}`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
sizes.set(path, bytes.length);
|
|
415
|
+
assets.set(path, r);
|
|
416
|
+
};
|
|
417
|
+
const font = (b) => checkWoff2(b) ?? b;
|
|
418
|
+
const image = (ref, square) => (b) => {
|
|
419
|
+
const notSquare = (w, h) => `favicon must be square, not ${w} x ${h}`;
|
|
420
|
+
if (/\.png$/i.test(ref)) {
|
|
421
|
+
const r = readPng(b);
|
|
422
|
+
if (typeof r === "string")
|
|
423
|
+
return r;
|
|
424
|
+
return square && r.width !== r.height ? notSquare(r.width, r.height) : b;
|
|
425
|
+
}
|
|
426
|
+
if (!/\.svg$/i.test(ref))
|
|
427
|
+
return "must be a .svg or .png file";
|
|
428
|
+
const r = sanitize(b);
|
|
429
|
+
if ("error" in r)
|
|
430
|
+
return r.error;
|
|
431
|
+
const [, , w, h] = r.viewBox;
|
|
432
|
+
return square && w !== h ? notSquare(w, h) : new TextEncoder().encode(r.svg);
|
|
433
|
+
};
|
|
434
|
+
theme.fonts?.forEach((f, i) => {
|
|
435
|
+
resolve(`fonts.${i}.file`, f.file, font);
|
|
436
|
+
});
|
|
437
|
+
for (const key of ["mark", "favicon"]) {
|
|
438
|
+
const ref = theme.logo?.[key];
|
|
439
|
+
if (ref !== undefined)
|
|
440
|
+
resolve(`logo.${key}`, ref, image(ref, key === "favicon"));
|
|
441
|
+
}
|
|
442
|
+
let total = 0;
|
|
443
|
+
for (const n of sizes.values())
|
|
444
|
+
total += n;
|
|
445
|
+
if (total > THEME_LIMITS.assetsBytes) {
|
|
446
|
+
errors.push(`theme assets are larger than ${THEME_LIMITS.assetsBytes} bytes in total (${total})`);
|
|
447
|
+
}
|
|
448
|
+
return { assets, errors };
|
|
449
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type ThemeScheme = "dark" | "light";
|
|
2
|
+
/** A theme scheme after merging its overrides onto the built-in values. */
|
|
3
|
+
export type MergedScheme = {
|
|
4
|
+
color: Record<string, string>;
|
|
5
|
+
shadow: Record<string, string>;
|
|
6
|
+
radius: Record<string, number>;
|
|
7
|
+
};
|
|
8
|
+
export type ReadabilityIssue = {
|
|
9
|
+
kind: "contrast" | "separation" | "separation-order" | "unevaluable";
|
|
10
|
+
/** `<fg> on <bg>` (with ` over <surface>` for a surface pair) or `<ground> on <surface>`. */
|
|
11
|
+
pair: string;
|
|
12
|
+
/** The measured value: contrast ratio, or OKLab lightness difference x100. */
|
|
13
|
+
ratio: number;
|
|
14
|
+
/** The floor it had to reach (for `separation-order`: the hover difference selected must beat). */
|
|
15
|
+
floor: number;
|
|
16
|
+
/** The composited colors compared, or for `unevaluable` the offending value. */
|
|
17
|
+
detail?: string;
|
|
18
|
+
};
|
|
19
|
+
/** WCAG 2 contrast ratio of two #RRGGBB colors. */
|
|
20
|
+
export declare function contrastRatio(fg: string, bg: string): number;
|
|
21
|
+
/** OKLab lightness (x100) of a #RRGGBB color. */
|
|
22
|
+
export declare function oklabL(hex: string): number;
|
|
23
|
+
/**
|
|
24
|
+
* The opaque #RRGGBB of `color` drawn over the opaque `ground`: a hex color passes through, an
|
|
25
|
+
* rgba(...) color is alpha-blended (per channel, rounded), anything else (color-mix) is undefined.
|
|
26
|
+
*/
|
|
27
|
+
export declare function compositeOver(color: string, ground: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Evaluates the readability rules (every contrast pair and separation entry of the design tokens)
|
|
30
|
+
* on one merged scheme. An rgba(...) value is composited over the scheme's `bg` first, or over a
|
|
31
|
+
* pair's surface token; pairs with a color-mix value are skipped. `selected` must stand further
|
|
32
|
+
* from a surface than `hover` on the same surface. `rules` defaults to the built-in lists.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkThemeReadability(_scheme: ThemeScheme, merged: MergedScheme, rules?: {
|
|
35
|
+
contrast: readonly (readonly [string, string, number, string?])[];
|
|
36
|
+
separation: readonly (readonly [string, string, number])[];
|
|
37
|
+
}): ReadabilityIssue[];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { THEME_CONTRAST, THEME_SEPARATION } from "./theme-tokens.generated.js";
|
|
2
|
+
const HEX_RE = /^#[0-9A-F]{6}$/;
|
|
3
|
+
const LOOSE_HEX_RE = /^#[0-9A-Fa-f]{6}$/;
|
|
4
|
+
const RGBA_PARTS_RE = /^rgba\((\d{1,3}),(\d{1,3}),(\d{1,3}),(0|1|0?\.\d+)\)$/;
|
|
5
|
+
function luminance(hex) {
|
|
6
|
+
const ch = [1, 3, 5].map((i) => {
|
|
7
|
+
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
8
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
9
|
+
});
|
|
10
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
11
|
+
}
|
|
12
|
+
/** WCAG 2 contrast ratio of two #RRGGBB colors. */
|
|
13
|
+
export function contrastRatio(fg, bg) {
|
|
14
|
+
const [hi, lo] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
|
|
15
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
16
|
+
}
|
|
17
|
+
/** OKLab lightness (x100) of a #RRGGBB color. */
|
|
18
|
+
export function oklabL(hex) {
|
|
19
|
+
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
20
|
+
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
21
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
22
|
+
});
|
|
23
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
24
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
25
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
26
|
+
return (0.2104542553 * l + 0.793617785 * m - 0.0040720958 * s) * 100;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The opaque #RRGGBB of `color` drawn over the opaque `ground`: a hex color passes through, an
|
|
30
|
+
* rgba(...) color is alpha-blended (per channel, rounded), anything else (color-mix) is undefined.
|
|
31
|
+
*/
|
|
32
|
+
export function compositeOver(color, ground) {
|
|
33
|
+
if (HEX_RE.test(color))
|
|
34
|
+
return color;
|
|
35
|
+
const m = RGBA_PARTS_RE.exec(color);
|
|
36
|
+
if (!m || !HEX_RE.test(ground))
|
|
37
|
+
return undefined;
|
|
38
|
+
const a = Number(m[4]);
|
|
39
|
+
const hex = [1, 2, 3]
|
|
40
|
+
.map((i) => {
|
|
41
|
+
const fg = Number(m[i]);
|
|
42
|
+
const bg = Number.parseInt(ground.slice(2 * i - 1, 2 * i + 1), 16);
|
|
43
|
+
return Math.round(fg * a + bg * (1 - a))
|
|
44
|
+
.toString(16)
|
|
45
|
+
.padStart(2, "0");
|
|
46
|
+
})
|
|
47
|
+
.join("");
|
|
48
|
+
return `#${hex.toUpperCase()}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Evaluates the readability rules (every contrast pair and separation entry of the design tokens)
|
|
52
|
+
* on one merged scheme. An rgba(...) value is composited over the scheme's `bg` first, or over a
|
|
53
|
+
* pair's surface token; pairs with a color-mix value are skipped. `selected` must stand further
|
|
54
|
+
* from a surface than `hover` on the same surface. `rules` defaults to the built-in lists.
|
|
55
|
+
*/
|
|
56
|
+
export function checkThemeReadability(_scheme, merged, rules = { contrast: THEME_CONTRAST, separation: THEME_SEPARATION }) {
|
|
57
|
+
const issues = [];
|
|
58
|
+
// Hex case is not significant in CSS: evaluate `#rrggbb` like `#RRGGBB`.
|
|
59
|
+
const color = Object.fromEntries(Object.entries(merged.color).map(([n, v]) => [n, LOOSE_HEX_RE.test(v) ? v.toUpperCase() : v]));
|
|
60
|
+
const bg = color.bg ?? "";
|
|
61
|
+
// A value the rules read that cannot be evaluated is reported, never silently skipped. A
|
|
62
|
+
// color-mix value is legitimate (it derives) and is skipped; `bg` must be a plain hex ground.
|
|
63
|
+
const used = new Set(["bg"]);
|
|
64
|
+
for (const [fg, back, , surface] of rules.contrast) {
|
|
65
|
+
for (const n of [fg, back, surface])
|
|
66
|
+
if (n !== undefined)
|
|
67
|
+
used.add(n);
|
|
68
|
+
}
|
|
69
|
+
for (const [ground, surface] of rules.separation) {
|
|
70
|
+
used.add(ground);
|
|
71
|
+
used.add(surface);
|
|
72
|
+
}
|
|
73
|
+
for (const name of used) {
|
|
74
|
+
const v = color[name];
|
|
75
|
+
if (v === undefined || v.startsWith("color-mix("))
|
|
76
|
+
continue;
|
|
77
|
+
const ok = name === "bg" ? HEX_RE.test(v) : HEX_RE.test(v) || RGBA_PARTS_RE.test(v);
|
|
78
|
+
if (!ok) {
|
|
79
|
+
issues.push({ kind: "unevaluable", pair: name, ratio: Number.NaN, floor: 0, detail: v });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** The opaque value of a surface token: an rgba surface is composited over `bg`. */
|
|
83
|
+
const surfaceOf = (name) => {
|
|
84
|
+
const raw = color[name];
|
|
85
|
+
return raw === undefined ? undefined : compositeOver(raw, bg);
|
|
86
|
+
};
|
|
87
|
+
for (const [fg, back, floor, surface] of rules.contrast) {
|
|
88
|
+
const ground = surfaceOf(surface ?? "bg");
|
|
89
|
+
if (ground === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
const rawA = color[fg];
|
|
92
|
+
const rawB = color[back];
|
|
93
|
+
if (rawA === undefined || rawB === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
const a = compositeOver(rawA, ground);
|
|
96
|
+
const b = compositeOver(rawB, ground);
|
|
97
|
+
if (a === undefined || b === undefined)
|
|
98
|
+
continue;
|
|
99
|
+
const ratio = contrastRatio(a, b);
|
|
100
|
+
if (ratio + 1e-9 < floor) {
|
|
101
|
+
const composited = a !== rawA || b !== rawB ? ` (rgba composited over ${surface ?? "bg"})` : "";
|
|
102
|
+
issues.push({
|
|
103
|
+
kind: "contrast",
|
|
104
|
+
pair: `${fg} on ${back}${surface === undefined ? "" : ` over ${surface}`}`,
|
|
105
|
+
ratio,
|
|
106
|
+
floor,
|
|
107
|
+
detail: `${a} on ${b}${composited}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const delta = new Map();
|
|
112
|
+
for (const [ground, surface, floor] of rules.separation) {
|
|
113
|
+
const s = surfaceOf(surface);
|
|
114
|
+
const raw = color[ground];
|
|
115
|
+
const g = s === undefined || raw === undefined ? undefined : compositeOver(raw, s);
|
|
116
|
+
if (s === undefined || g === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
const d = Math.abs(oklabL(g) - oklabL(s));
|
|
119
|
+
delta.set(`${ground}@${surface}`, d);
|
|
120
|
+
if (d + 1e-9 < floor) {
|
|
121
|
+
issues.push({
|
|
122
|
+
kind: "separation",
|
|
123
|
+
pair: `${ground} on ${surface}`,
|
|
124
|
+
ratio: d,
|
|
125
|
+
floor,
|
|
126
|
+
detail: `${g} on ${s}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const [k, hover] of delta) {
|
|
131
|
+
if (!k.startsWith("hover@"))
|
|
132
|
+
continue;
|
|
133
|
+
const surface = k.slice("hover@".length);
|
|
134
|
+
const selected = delta.get(`selected@${surface}`);
|
|
135
|
+
if (selected !== undefined && selected <= hover) {
|
|
136
|
+
issues.push({
|
|
137
|
+
kind: "separation-order",
|
|
138
|
+
pair: `selected on ${surface}`,
|
|
139
|
+
ratio: selected,
|
|
140
|
+
floor: hover,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return issues;
|
|
145
|
+
}
|