@brunosouzas/design-system 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,272 @@
1
+ // Cover specs (spec cover-cli; design §6): validation of the JSON a cover is
2
+ // written in, and the 800x420 frame that draws it from the pieces.
3
+ //
4
+ // Why here and not in scripts/: the validation and the frame are pure, so the
5
+ // tests and a future site build can use them without the command line.
6
+ //
7
+ // What can go wrong: an invalid spec returns every problem with its JSON path
8
+ // and draws nothing. A free-form object is limited to drawing tags and safe
9
+ // attributes, because the cover is published as SVG.
10
+ import { tokens } from "../../tokens/generated/tokens.js";
11
+ import { PIECE_NAMES, PIECES, rings } from "./library.js";
12
+ import { el, g } from "./node.js";
13
+ import { COLOUR_TOKENS, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
14
+ export const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
+ const TOP_LEVEL = ["id", "title", "description", "background", "rings", "pieces", "custom"];
16
+ const CUSTOM_TAGS = ["g", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
17
+ // Attributes a free-form object may use (design §6): geometry, paint, text and transform.
18
+ const CUSTOM_ATTRS = new Set([
19
+ "x", "y", "width", "height", "rx", "ry", "cx", "cy", "r", "x1", "y1", "x2", "y2", "points", "d",
20
+ "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray", "opacity", "fill-opacity", "stroke-opacity",
21
+ "font-size", "font-weight", "font-family", "text-anchor", "transform",
22
+ ]);
23
+ const COLOUR_ATTRS = new Set(["fill", "stroke"]);
24
+ // Attributes whose value is a grammar, never a bare number.
25
+ const STRING_ONLY_ATTRS = new Set(["transform", "d", "points", "font-family", "text-anchor", "stroke-linecap", "stroke-linejoin"]);
26
+ const TRANSFORM = /^\s*(?:(?:translate|rotate|scale)\(\s*-?[\d.]+(?:[\s,]+-?[\d.]+){0,2}\s*\)\s*)+$/;
27
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
28
+ const describe = (value) => (value === undefined ? "nothing" : JSON.stringify(value));
29
+ export const backgroundNames = () => Object.keys(tokens.coverBackground);
30
+ /** Every string in the spec that looks like a colour literal, by path. */
31
+ const colourLiterals = (value, path, out) => {
32
+ if (typeof value === "string") {
33
+ if (isColourLiteral(value))
34
+ out.push({ path, message: "colour literals are not allowed; use a token path" });
35
+ }
36
+ else if (Array.isArray(value)) {
37
+ value.forEach((item, index) => colourLiterals(item, `${path}[${index}]`, out));
38
+ }
39
+ else if (isPlainObject(value)) {
40
+ for (const [key, child] of Object.entries(value))
41
+ colourLiterals(child, path ? `${path}.${key}` : key, out);
42
+ }
43
+ };
44
+ const checkCustom = (node, path, out) => {
45
+ if (!isPlainObject(node))
46
+ return void out.push({ path, message: `expected a node object, found ${describe(node)}` });
47
+ for (const key of Object.keys(node)) {
48
+ if (!["tag", "attrs", "children"].includes(key))
49
+ out.push({ path: `${path}.${key}`, message: "unknown key (expected tag, attrs, children)" });
50
+ }
51
+ if (typeof node.tag !== "string" || !CUSTOM_TAGS.includes(node.tag)) {
52
+ out.push({ path: `${path}.tag`, message: `tag not allowed in a free-form object (allowed: ${CUSTOM_TAGS.join(", ")}), found ${describe(node.tag)}` });
53
+ }
54
+ if (node.attrs !== undefined) {
55
+ if (!isPlainObject(node.attrs))
56
+ out.push({ path: `${path}.attrs`, message: "expected an object" });
57
+ else {
58
+ for (const [name, value] of Object.entries(node.attrs)) {
59
+ const at = `${path}.attrs.${name}`;
60
+ if (!CUSTOM_ATTRS.has(name)) {
61
+ out.push({ path: at, message: "attribute not allowed in a free-form object" });
62
+ continue;
63
+ }
64
+ if (typeof value === "number") {
65
+ if (COLOUR_ATTRS.has(name) || STRING_ONLY_ATTRS.has(name))
66
+ out.push({ path: at, message: "expected a string" });
67
+ else if (!Number.isFinite(value))
68
+ out.push({ path: at, message: "expected a finite number" });
69
+ continue;
70
+ }
71
+ if (typeof value !== "string") {
72
+ out.push({ path: at, message: `expected a string or a number, found ${describe(value)}` });
73
+ continue;
74
+ }
75
+ if (/url\(|\/\//i.test(value)) {
76
+ out.push({ path: at, message: "references are not allowed (url( or //)" });
77
+ continue;
78
+ }
79
+ if (COLOUR_ATTRS.has(name) && value !== "none" && !isColourLiteral(value) && !COLOUR_TOKENS.has(value)) {
80
+ out.push({ path: at, message: `expected none or a colour token path, found ${describe(value)}` });
81
+ }
82
+ else if (name === "transform" && !TRANSFORM.test(value)) {
83
+ out.push({ path: at, message: "only translate, rotate and scale are allowed" });
84
+ }
85
+ else if ((name === "d" || name === "points") && !PATH_DATA.test(value)) {
86
+ out.push({ path: at, message: "expected path characters only (commands, digits, signs, dots, commas, spaces)" });
87
+ }
88
+ }
89
+ }
90
+ }
91
+ if (node.children !== undefined) {
92
+ if (!Array.isArray(node.children))
93
+ out.push({ path: `${path}.children`, message: "expected an array" });
94
+ else
95
+ node.children.forEach((child, index) => {
96
+ const at = `${path}.children[${index}]`;
97
+ if (typeof child === "string") {
98
+ if (node.tag !== "text")
99
+ out.push({ path: at, message: "text is only allowed inside a text node" });
100
+ }
101
+ else
102
+ checkCustom(child, at, out);
103
+ });
104
+ }
105
+ };
106
+ /** Orders strings by Unicode code point (JS `<` compares UTF-16 code units). */
107
+ const compareCodePoints = (a, b) => {
108
+ const left = Array.from(a);
109
+ const right = Array.from(b);
110
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
111
+ const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
112
+ if (diff !== 0)
113
+ return diff;
114
+ }
115
+ return left.length - right.length;
116
+ };
117
+ /** Compares JSON paths: segment by segment, array indexes as numbers, names by code point. */
118
+ export const comparePaths = (a, b) => {
119
+ const split = (path) => path.match(/[^.[\]]+|\[\d+\]/g) ?? [];
120
+ const left = split(a);
121
+ const right = split(b);
122
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
123
+ const x = left[i];
124
+ const y = right[i];
125
+ if (x === y)
126
+ continue;
127
+ const xi = /^\[\d+\]$/.test(x);
128
+ const yi = /^\[\d+\]$/.test(y);
129
+ if (xi && yi)
130
+ return Number(x.slice(1, -1)) - Number(y.slice(1, -1));
131
+ return compareCodePoints(x, y);
132
+ }
133
+ return left.length - right.length;
134
+ };
135
+ /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. */
136
+ export const validateCoverSpec = (spec) => {
137
+ if (!isPlainObject(spec))
138
+ return [{ path: "(root)", message: `a cover spec must be a JSON object, found ${Array.isArray(spec) ? "an array" : describe(spec)}` }];
139
+ const out = [];
140
+ colourLiterals(spec, "", out);
141
+ for (const key of Object.keys(spec)) {
142
+ if (!TOP_LEVEL.includes(key))
143
+ out.push({ path: key, message: `unknown key (expected ${TOP_LEVEL.join(", ")})` });
144
+ }
145
+ if (typeof spec.id !== "string" || !SAFE_ID.test(spec.id))
146
+ out.push({ path: "id", message: `expected a safe slug (lowercase letters, digits, single hyphens), found ${describe(spec.id)}` });
147
+ for (const key of ["title", "description"]) {
148
+ if (typeof spec[key] !== "string" || spec[key].trim() === "")
149
+ out.push({ path: key, message: "expected a non-empty string" });
150
+ }
151
+ if (typeof spec.background !== "string" || !backgroundNames().includes(spec.background)) {
152
+ out.push({ path: "background", message: `expected one of ${backgroundNames().join(", ")}, found ${describe(spec.background)}` });
153
+ }
154
+ if (!isPlainObject(spec.rings))
155
+ out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
156
+ else {
157
+ const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color");
158
+ out.push(...ringProblems);
159
+ // The piece takes any finite centre; a cover spec keeps 0.1.0's ±10000, which bounds a banner's ring count.
160
+ for (const axis of ["cx", "cy"]) {
161
+ const value = spec.rings[axis];
162
+ if (ringProblems.some((p) => p.path === `rings.${axis}`) || typeof value !== "number")
163
+ continue;
164
+ if (value < -MAX_SIZE)
165
+ out.push({ path: `rings.${axis}`, message: `expected a number at least -${MAX_SIZE}, found ${value}` });
166
+ else if (value > MAX_SIZE)
167
+ out.push({ path: `rings.${axis}`, message: `expected a number at most ${MAX_SIZE}, found ${value}` });
168
+ }
169
+ }
170
+ if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "color"))
171
+ out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
172
+ if (!Array.isArray(spec.pieces))
173
+ out.push({ path: "pieces", message: "missing or not an array" });
174
+ else
175
+ spec.pieces.forEach((entry, index) => {
176
+ const at = `pieces[${index}]`;
177
+ if (!isPlainObject(entry))
178
+ return void out.push({ path: at, message: "expected an object with piece and its parameters" });
179
+ const { piece, ...params } = entry;
180
+ if (typeof piece !== "string" || !PIECE_NAMES.includes(piece)) {
181
+ return void out.push({ path: `${at}.piece`, message: `unknown piece ${describe(piece)} (expected one of ${PIECE_NAMES.join(", ")})` });
182
+ }
183
+ if (piece === "rings")
184
+ return void out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
185
+ const definition = PIECES[piece];
186
+ const own = checkParams(definition.schema, params, at);
187
+ out.push(...own);
188
+ // Limits between parameters are reported with the schema's problems, except on a parameter
189
+ // already reported; a relation that cannot read a broken parameter reports nothing.
190
+ if (definition.relations) {
191
+ let related = [];
192
+ try {
193
+ related = definition.relations(params);
194
+ }
195
+ catch {
196
+ related = [];
197
+ }
198
+ const reported = new Set(own.map((problem) => problem.path));
199
+ out.push(...related.map((problem) => ({ path: `${at}.${problem.path}`, message: problem.message })).filter((problem) => !reported.has(problem.path)));
200
+ }
201
+ // A valid entry is drawn once: an overflow is a spec problem at the entry, not a crash.
202
+ if (own.length === 0 && !(definition.relations?.(params) ?? []).length) {
203
+ try {
204
+ PIECES[piece](params);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof RangeError && error.message.endsWith(": the drawing overflows"))
208
+ out.push({ path: at, message: "the drawing overflows" });
209
+ }
210
+ }
211
+ });
212
+ if (spec.custom !== undefined) {
213
+ if (Array.isArray(spec.custom))
214
+ out.push({ path: "custom", message: "one free-form object per cover; a repeated object must be a piece" });
215
+ else
216
+ checkCustom(spec.custom, "custom", out);
217
+ }
218
+ // A colour literal is reported once, by the literal check, not again as a bad token path.
219
+ // A container with the wrong shape is one problem: nothing inside it is reported.
220
+ const blocked = [];
221
+ if (!Array.isArray(spec.pieces) && spec.pieces !== undefined)
222
+ blocked.push("pieces");
223
+ if (Array.isArray(spec.pieces)) {
224
+ spec.pieces.forEach((entry, index) => {
225
+ const known = isPlainObject(entry) && typeof entry.piece === "string" && PIECE_NAMES.includes(entry.piece) && entry.piece !== "rings";
226
+ if (!known)
227
+ blocked.push(`pieces[${index}]`);
228
+ });
229
+ }
230
+ if (Array.isArray(spec.custom))
231
+ blocked.push("custom");
232
+ if (!isPlainObject(spec.rings) && spec.rings !== undefined)
233
+ blocked.push("rings");
234
+ const insideBlocked = (path) => blocked.some((b) => (path.startsWith(`${b}.`) || path.startsWith(`${b}[`)) && path !== `${b}.piece`);
235
+ out.splice(0, out.length, ...out.filter((p) => !insideBlocked(p.path)));
236
+ const literalPaths = new Set(out.filter((p) => p.message.startsWith("colour literals")).map((p) => p.path));
237
+ const seen = new Set();
238
+ const unique = out.filter((p) => {
239
+ const key = `${p.path}\u0000${p.message}`;
240
+ if (seen.has(key) || (literalPaths.has(p.path) && !p.message.startsWith("colour literals")))
241
+ return false;
242
+ seen.add(key);
243
+ return true;
244
+ });
245
+ return unique.sort((a, b) => comparePaths(a.path, b.path) || compareCodePoints(a.message, b.message));
246
+ };
247
+ const resolveCustom = (node) => {
248
+ const attrs = {};
249
+ for (const [name, value] of Object.entries(node.attrs ?? {})) {
250
+ attrs[name] = typeof value === "string" && COLOUR_ATTRS.has(name) && value !== "none" ? COLOUR_TOKENS.get(value) : value;
251
+ }
252
+ // The outlined group would stroke the letters; free-form words are never outlined.
253
+ if (node.tag === "text")
254
+ attrs.stroke = "none";
255
+ return el(node.tag, attrs, ...(node.children ?? []).map((child) => (typeof child === "string" ? child : resolveCustom(child))));
256
+ };
257
+ /** The cover's node tree. The spec must be valid (validateCoverSpec returned no problem). */
258
+ export const coverTree = (spec) => {
259
+ const { width, height } = tokens.cover;
260
+ const pair = tokens.coverBackground[spec.background];
261
+ const drawnPieces = spec.pieces.map(({ piece, ...params }) => PIECES[piece](params));
262
+ const ringPath = `coverBackground.${spec.background}.ring`;
263
+ return el("svg", {
264
+ xmlns: "http://www.w3.org/2000/svg",
265
+ width,
266
+ height,
267
+ viewBox: `0 0 ${width} ${height}`,
268
+ role: "img",
269
+ "aria-labelledby": `${spec.id}-title ${spec.id}-desc`,
270
+ "font-family": tokens.font.sans,
271
+ }, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height }))), el("rect", { width, height, fill: pair.fill }), g({ "clip-path": `url(#${spec.id}-frame)` }, ...rings({ ...spec.rings, color: ringPath }).body), g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...drawnPieces.flatMap((d) => d.body), spec.custom ? resolveCustom(spec.custom) : null), g({ "font-size": tokens.cover.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text)));
272
+ };
@@ -0,0 +1,12 @@
1
+ import type { SvgNode } from "./node.ts";
2
+ export type EipIcon = {
3
+ readonly name: string;
4
+ readonly title: string;
5
+ readonly description: string;
6
+ readonly nodes: readonly SvgNode[];
7
+ };
8
+ /** Every icon draws in a 64x40 box. */
9
+ export declare const EIP_ICON_WIDTH = 64;
10
+ export declare const EIP_ICON_HEIGHT = 40;
11
+ export declare const EIP_ICONS: readonly EipIcon[];
12
+ export declare const EIP_ICON_NAMES: readonly string[];