@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.
- package/LICENSE +202 -0
- package/README.md +211 -2
- package/dist/scripts/cover.d.ts +25 -0
- package/dist/scripts/cover.js +157 -0
- package/dist/src/pieces/cover.d.ts +32 -0
- package/dist/src/pieces/cover.js +272 -0
- package/dist/src/pieces/eip-icons.d.ts +12 -0
- package/dist/src/pieces/eip-icons.js +525 -0
- package/dist/src/pieces/index.d.ts +9 -0
- package/dist/src/pieces/index.js +14 -0
- package/dist/src/pieces/library.d.ts +297 -0
- package/dist/src/pieces/library.js +377 -0
- package/dist/src/pieces/metro.d.ts +93 -0
- package/dist/src/pieces/metro.js +205 -0
- package/dist/src/pieces/node.d.ts +27 -0
- package/dist/src/pieces/node.js +52 -0
- package/dist/src/pieces/params.d.ts +77 -0
- package/dist/src/pieces/params.js +149 -0
- package/dist/src/pieces/piece.d.ts +9 -0
- package/dist/src/pieces/piece.js +28 -0
- package/dist/src/pieces/pose.d.ts +16 -0
- package/dist/src/pieces/pose.js +30 -0
- package/dist/src/pieces/react.d.ts +4 -0
- package/dist/src/pieces/react.js +13 -0
- package/dist/src/pieces/render.d.ts +2 -0
- package/dist/src/pieces/render.js +21 -0
- package/dist/tokens/generated/tokens.d.ts +244 -0
- package/dist/tokens/generated/tokens.js +244 -0
- package/package.json +78 -4
- package/tokens/generated/tokens.css +160 -0
- package/tokens/generated/tokens.json +243 -0
- package/tokens/generated/tokens.ts +247 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Metro and diagram pieces (spec illustration-pieces, change
|
|
2
|
+
// metro-and-diagram-pieces; design §3). They paint what the caller lays out:
|
|
3
|
+
// positions, text measurement and truncation stay with the consumer
|
|
4
|
+
// (stackledger's map engine). Each draws on a surface, light or dark, with the
|
|
5
|
+
// ink sticker of stackledger's map: hard shadow in the surface's shadow colour,
|
|
6
|
+
// a light rim on dark, then the face outlined in ink.
|
|
7
|
+
//
|
|
8
|
+
// What can go wrong: every parameter is checked by its schema first; the
|
|
9
|
+
// cross-parameter limits (room for the outline, width against height, arrow
|
|
10
|
+
// length) are checked here and thrown as a RangeError naming the parameter.
|
|
11
|
+
import { tokens } from "../../tokens/generated/tokens.js";
|
|
12
|
+
import { drawn, el, g, r2 } from "./node.js";
|
|
13
|
+
import { col, colour, label, MAX_SIZE, PATH_DATA, paint, pos, size } from "./params.js";
|
|
14
|
+
import { definePiece } from "./piece.js";
|
|
15
|
+
export const SURFACES = ["light", "dark"];
|
|
16
|
+
const ink = tokens.color.ink;
|
|
17
|
+
const white = tokens.color.white;
|
|
18
|
+
const font = tokens.font.sans;
|
|
19
|
+
const bold = tokens.font.weightBold;
|
|
20
|
+
const M = tokens.map;
|
|
21
|
+
const surfaceParam = { kind: "enum", values: SURFACES };
|
|
22
|
+
const inkScaleParam = { kind: "number", min: 0.25, max: 8 };
|
|
23
|
+
const dataColour = (required = true) => ({ kind: "dataColor", required });
|
|
24
|
+
const point = (required = true) => ({ kind: "point", required });
|
|
25
|
+
const inkSchema = { surface: surfaceParam, inkScale: inkScaleParam };
|
|
26
|
+
/** Collects the limits between parameters (piece.ts, `relations`). */
|
|
27
|
+
const limits = (...checks) => checks.filter((check) => check !== false);
|
|
28
|
+
/** The surface's colours and the ink measures at a scale. */
|
|
29
|
+
const inkOf = ({ surface = "light", inkScale = 1 }) => {
|
|
30
|
+
const s = tokens.surface[surface];
|
|
31
|
+
return { s, outline: M.outline * inkScale, rim: s.rimWidth * inkScale, offset: M.shadow * inkScale };
|
|
32
|
+
};
|
|
33
|
+
/** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
|
|
34
|
+
const roomFor = (name, smallest, inkParams) => {
|
|
35
|
+
const need = 4 * inkOf(inkParams).outline;
|
|
36
|
+
return smallest < need && { path: name, message: `leaves no room for the outline: needs at least ${r2(need)} at this ink scale` };
|
|
37
|
+
};
|
|
38
|
+
const atLeast = (name, value, least, what) => value < least && { path: name, message: `must be at least ${what} (${r2(least)}), found ${r2(value)}` };
|
|
39
|
+
/** The ink sticker (design §3): shadow copy, rim on dark, then the face. */
|
|
40
|
+
const sticker = (shape, face, inkParams) => {
|
|
41
|
+
const { s, outline, rim, offset } = inkOf(inkParams);
|
|
42
|
+
const wide = r2(outline + 2 * rim);
|
|
43
|
+
return [
|
|
44
|
+
// Joins are set here, so a sticker draws the same inside any group.
|
|
45
|
+
shape(offset, offset, { fill: s.shadow, stroke: s.shadow, "stroke-width": wide, "stroke-linejoin": "round" }),
|
|
46
|
+
...(s.rimWidth > 0 ? [shape(0, 0, { fill: s.rim, stroke: s.rim, "stroke-width": wide, "stroke-linejoin": "round" })] : []),
|
|
47
|
+
shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" }),
|
|
48
|
+
];
|
|
49
|
+
};
|
|
50
|
+
// Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
|
|
51
|
+
const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
|
|
52
|
+
const pill = (x, y, w, h) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(h / 2), ...attrs });
|
|
53
|
+
const badgeWidth = ({ width, text }) => width ?? Math.max(30, 7.6 * Array.from(text).length + 18);
|
|
54
|
+
/** A line's terminal badge: a pill in the line colour with its code in white, centred on (x, y). */
|
|
55
|
+
export const lineBadge = definePiece("lineBadge", { x: pos(), y: pos(), text: label(), fill: dataColour(), width: size(), height: size(), flat: { kind: "boolean" }, ...inkSchema }, (params) => {
|
|
56
|
+
const { x, y, text, fill, height = M.badgeHeight, flat = false } = params;
|
|
57
|
+
const width = badgeWidth(params);
|
|
58
|
+
const face = paint(fill);
|
|
59
|
+
const { outline } = inkOf(params);
|
|
60
|
+
const inset = flat ? outline / 2 : 0;
|
|
61
|
+
const shape = pill(x - width / 2 + inset, y - height / 2 + inset, width - 2 * inset, height - 2 * inset);
|
|
62
|
+
const body = flat ? [shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" })] : sticker(shape, face, params);
|
|
63
|
+
return drawn(body, [words(x, y + 4.5, text, { fill: white, "font-size": M.badgeTextSize, "font-weight": bold, "text-anchor": "middle" })]);
|
|
64
|
+
}, (params) => {
|
|
65
|
+
const height = params.height ?? M.badgeHeight;
|
|
66
|
+
return limits(atLeast("width", badgeWidth(params), height, "the height"), roomFor("height", height, params));
|
|
67
|
+
});
|
|
68
|
+
/** Closed capsule round the segment a-b (stackledger's capsulePath). */
|
|
69
|
+
const capsule = (a, b, r, dx, dy) => {
|
|
70
|
+
// The normal comes from the segment itself; the shadow's offset moves the result, so a very short
|
|
71
|
+
// segment never collapses to zero length once moved.
|
|
72
|
+
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
73
|
+
const n = [(-(b[1] - a[1]) / length) * r, ((b[0] - a[0]) / length) * r];
|
|
74
|
+
const p = (q, k) => `${r2(q[0] + dx + n[0] * k)},${r2(q[1] + dy + n[1] * k)}`;
|
|
75
|
+
const radius = r2(r);
|
|
76
|
+
return `M${p(a, 1)} L${p(b, 1)} A${radius},${radius} 0 0 0 ${p(b, -1)} L${p(a, -1)} A${radius},${radius} 0 0 0 ${p(a, 1)} Z`;
|
|
77
|
+
};
|
|
78
|
+
const markerRadius = ({ r, to }) => r ?? (to ? M.stopRadius + M.interchangeExtra : M.stopRadius);
|
|
79
|
+
/** A stop (disc), or with `to` an interchange (capsule from the centre to `to`); deprecated is muted. */
|
|
80
|
+
export const stationMarker = definePiece("stationMarker", { cx: pos(), cy: pos(), to: point(false), r: size(), state: { kind: "enum", values: ["normal", "deprecated"] }, ...inkSchema }, (params) => {
|
|
81
|
+
const { cx, cy, to, state = "normal" } = params;
|
|
82
|
+
const r = markerRadius(params);
|
|
83
|
+
const face = state === "deprecated" ? inkOf(params).s.muted : white;
|
|
84
|
+
const long = to !== undefined && Math.hypot(to[0] - cx, to[1] - cy) > 0;
|
|
85
|
+
const shape = long
|
|
86
|
+
? (dx, dy, attrs) => el("path", { d: capsule([cx, cy], to, r, dx, dy), ...attrs })
|
|
87
|
+
: (dx, dy, attrs) => el("circle", { cx: r2(cx + dx), cy: r2(cy + dy), r: r2(r), ...attrs });
|
|
88
|
+
return drawn(sticker(shape, face, params));
|
|
89
|
+
}, (params) => limits(roomFor("r", 2 * markerRadius(params), params)));
|
|
90
|
+
const PIPE_PATH = { kind: "string", required: true, nonEmpty: true, pattern: new RegExp(`^\\s*[Mm]${PATH_DATA.source.slice(1, -2)}*$`), patternName: "SVG path data starting with M (commands, numbers, commas and spaces only)" };
|
|
91
|
+
/** A line: its colour over an ink casing, round caps and joins, no shadow. */
|
|
92
|
+
export const pipe = definePiece("pipe", { d: PIPE_PATH, fill: dataColour(), width: { kind: "number", min: 0, exclusiveMin: true, max: 100 }, casing: { kind: "number", min: 0, max: 50 } }, ({ d, fill, width = M.lineWidth, casing = M.casing }) => {
|
|
93
|
+
const line = { d, fill: "none", "stroke-linecap": "round", "stroke-linejoin": "round" };
|
|
94
|
+
return drawn([
|
|
95
|
+
// Stroke widths as given: a valid hairline must not round to zero.
|
|
96
|
+
...(casing > 0 ? [el("path", { ...line, stroke: ink, "stroke-width": width + casing })] : []),
|
|
97
|
+
el("path", { ...line, stroke: paint(fill), "stroke-width": width }),
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
const countWidth = ({ width, text, height = M.countHeight }) => {
|
|
101
|
+
const characters = Array.from(text).length;
|
|
102
|
+
return width ?? (characters === 1 ? height : Math.max(height, 6.4 * characters + 8));
|
|
103
|
+
};
|
|
104
|
+
/** A count (rules of a skill): a circle when as wide as high, a pill otherwise, in the surface's panel colour. */
|
|
105
|
+
export const countBadge = definePiece("countBadge", { cx: pos(), cy: pos(), text: label(), width: size(), height: size(), ...inkSchema }, (params) => {
|
|
106
|
+
const { cx, cy, text, height = M.countHeight } = params;
|
|
107
|
+
const width = countWidth(params);
|
|
108
|
+
const { s } = inkOf(params);
|
|
109
|
+
const shape = width === height ? (dx, dy, attrs) => el("circle", { cx: r2(cx + dx), cy: r2(cy + dy), r: r2(height / 2), ...attrs }) : pill(cx - width / 2, cy - height / 2, width, height);
|
|
110
|
+
return drawn(sticker(shape, s.panel, params), [words(cx, cy + 4, text, { fill: s.text, "font-size": M.countTextSize, "font-weight": bold, "text-anchor": "middle" })]);
|
|
111
|
+
}, (params) => {
|
|
112
|
+
const height = params.height ?? M.countHeight;
|
|
113
|
+
return limits(atLeast("width", countWidth(params), height, "the height"), roomFor("height", height, params));
|
|
114
|
+
});
|
|
115
|
+
const nodeSchema = {
|
|
116
|
+
x: pos(),
|
|
117
|
+
y: pos(),
|
|
118
|
+
width: size(true),
|
|
119
|
+
height: size(),
|
|
120
|
+
title: label(),
|
|
121
|
+
subtitle: label(false),
|
|
122
|
+
state: { kind: "enum", values: ["normal", "focus", "placeholder"] },
|
|
123
|
+
...inkSchema,
|
|
124
|
+
};
|
|
125
|
+
const nodeLimits = (kind) => (params) => {
|
|
126
|
+
const { width, height = M.nodeHeight, subtitle, state = "normal" } = params;
|
|
127
|
+
const radius = params.radius ?? M.nodeRadius;
|
|
128
|
+
return limits(atLeast("width", width, 2 * height, "twice the height"), kind === "box" && radius > height / 2 && { path: "radius", message: `must be at most half the height (${r2(height / 2)}), found ${r2(radius)}` }, state === "placeholder" && subtitle !== undefined && { path: "subtitle", message: "a placeholder has no subtitle" }, state !== "placeholder" && roomFor("height", height, params));
|
|
129
|
+
};
|
|
130
|
+
const drawNode = (kind, params) => {
|
|
131
|
+
const { x, y, width, height = M.nodeHeight, title, subtitle, state = "normal" } = params;
|
|
132
|
+
const radius = kind === "pill" ? height / 2 : (params.radius ?? M.nodeRadius);
|
|
133
|
+
const shape = kind === "hex"
|
|
134
|
+
? (dx, dy, attrs) => {
|
|
135
|
+
const [l, t, c] = [x + dx, y + dy, height / 2];
|
|
136
|
+
const points = [[l + c, t], [l + width - c, t], [l + width, t + c], [l + width - c, t + height], [l + c, t + height], [l, t + c]];
|
|
137
|
+
return el("polygon", { points: points.map(([px, py]) => `${r2(px)},${r2(py)}`).join(" "), ...attrs });
|
|
138
|
+
}
|
|
139
|
+
: (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(width), height: r2(height), rx: r2(radius), ...attrs });
|
|
140
|
+
const { s } = inkOf(params);
|
|
141
|
+
const middle = y + height / 2;
|
|
142
|
+
if (state === "placeholder") {
|
|
143
|
+
return drawn([shape(0, 0, { fill: "none", stroke: s.muted, "stroke-width": 1.5, "stroke-dasharray": "5 4", "stroke-linejoin": "round" })], [words(x + width / 2, middle + 4.5, title, { fill: s.muted, "font-size": M.labelSize, "font-weight": 400, "text-anchor": "middle" })]);
|
|
144
|
+
}
|
|
145
|
+
const focus = state === "focus";
|
|
146
|
+
const inset = kind === "box" ? 12 : height / 2;
|
|
147
|
+
const titleFill = focus ? ink : s.text;
|
|
148
|
+
const text = subtitle === undefined
|
|
149
|
+
? [words(x + inset, middle + 4.5, title, { fill: titleFill, "font-size": M.labelSize, "font-weight": bold, "text-anchor": "start" })]
|
|
150
|
+
: [
|
|
151
|
+
words(x + inset, middle - 3, title, { fill: titleFill, "font-size": M.labelSize, "font-weight": bold, "text-anchor": "start" }),
|
|
152
|
+
words(x + inset, middle + 14, subtitle, { fill: focus ? ink : s.muted, "font-size": 11, "font-weight": 400, "text-anchor": "start" }),
|
|
153
|
+
];
|
|
154
|
+
return drawn(sticker(shape, focus ? tokens.color.caller : s.panel, params), text);
|
|
155
|
+
};
|
|
156
|
+
/** A diagram box (a skill, a software item): rounded rect with a title and an optional subtitle. */
|
|
157
|
+
export const nodeBlock = definePiece("nodeBlock", { ...nodeSchema, radius: { kind: "number", min: 0, max: MAX_SIZE } }, (p) => drawNode("box", p), nodeLimits("box"));
|
|
158
|
+
/** A diagram pill (a playbook). */
|
|
159
|
+
export const pillNode = definePiece("pillNode", nodeSchema, (p) => drawNode("pill", p), nodeLimits("pill"));
|
|
160
|
+
/** A diagram hexagon (an agent). */
|
|
161
|
+
export const hexNode = definePiece("hexNode", nodeSchema, (p) => drawNode("hex", p), nodeLimits("hex"));
|
|
162
|
+
/** An edge with a head at `to`: curved (leaves and enters horizontally) or straight, with an optional label. */
|
|
163
|
+
export const messageArrow = definePiece("messageArrow", {
|
|
164
|
+
from: point(),
|
|
165
|
+
to: point(),
|
|
166
|
+
curve: { kind: "enum", values: ["bezier", "straight"] },
|
|
167
|
+
head: { kind: "boolean" },
|
|
168
|
+
label: label(false),
|
|
169
|
+
width: { kind: "number", min: 1, max: 12 },
|
|
170
|
+
surface: surfaceParam,
|
|
171
|
+
}, ({ from, to, curve = "bezier", head = true, label: text, width = M.edgeWidth, surface = "light" }) => {
|
|
172
|
+
const s = tokens.surface[surface];
|
|
173
|
+
const length = 5 * width;
|
|
174
|
+
const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
175
|
+
const dx = to[0] - from[0];
|
|
176
|
+
// A curve whose ends are almost above each other has no horizontal entry: draw it straight.
|
|
177
|
+
const bent = curve === "bezier" && Math.abs(dx) > 2 * length;
|
|
178
|
+
const direction = bent ? [Math.sign(dx), 0] : [dx / distance, (to[1] - from[1]) / distance];
|
|
179
|
+
const end = head ? [to[0] - direction[0] * length, to[1] - direction[1] * length] : to;
|
|
180
|
+
const mx = (from[0] + to[0]) / 2;
|
|
181
|
+
const at = (p) => `${r2(p[0])},${r2(p[1])}`;
|
|
182
|
+
const d = bent ? `M${at(from)} C${r2(mx)},${r2(from[1])} ${r2(mx)},${r2(to[1])} ${at(end)}` : `M${at(from)} L${at(end)}`;
|
|
183
|
+
const body = [el("path", { d, fill: "none", stroke: s.text, "stroke-width": r2(width), "stroke-linecap": "round" })];
|
|
184
|
+
if (head) {
|
|
185
|
+
const normal = [-direction[1] * 2 * width, direction[0] * 2 * width];
|
|
186
|
+
const points = [to, [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]]];
|
|
187
|
+
body.push(el("polygon", { points: points.map(at).join(" "), fill: s.text, stroke: s.text, "stroke-width": 1, "stroke-linejoin": "round" }));
|
|
188
|
+
}
|
|
189
|
+
const words_ = [];
|
|
190
|
+
if (text !== undefined) {
|
|
191
|
+
const middle = [mx, (from[1] + to[1]) / 2];
|
|
192
|
+
const w = 6.4 * Array.from(text).length + 16;
|
|
193
|
+
body.push(el("rect", { x: r2(middle[0] - w / 2), y: r2(middle[1] - 9), width: r2(w), height: 18, rx: 9, fill: s.background, stroke: s.text, "stroke-width": 1.5 }));
|
|
194
|
+
words_.push(words(middle[0], middle[1] + 4, text, { fill: s.text, "font-size": 11, "font-weight": 400, "text-anchor": "middle" }));
|
|
195
|
+
}
|
|
196
|
+
return drawn(body, words_);
|
|
197
|
+
}, ({ from, to, head = true, width = M.edgeWidth }) => {
|
|
198
|
+
const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
|
199
|
+
return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` });
|
|
200
|
+
});
|
|
201
|
+
/** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`. */
|
|
202
|
+
export const ledgerMark = definePiece("ledgerMark", { x: pos(), y: pos(), size: { kind: "number", min: 12, max: 512 }, line: col() }, ({ x, y, size: side = 32, line = "illustration.accent" }) => drawn([
|
|
203
|
+
g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / 32})` }, el("rect", { x: 0, y: 0, width: 32, height: 32, rx: 7, fill: ink, stroke: "none" }), el("path", { d: "M7 23 L14 16 L25 16", fill: "none", stroke: colour(line, "line"), "stroke-width": 4.5, "stroke-linecap": "round", "stroke-linejoin": "round" }), el("circle", { cx: 14, cy: 16, r: 4, fill: white, stroke: ink, "stroke-width": 2 }), el("circle", { cx: 25, cy: 16, r: 2.6, fill: white, stroke: ink, "stroke-width": 1.6 })),
|
|
204
|
+
]));
|
|
205
|
+
export const METRO_PIECES = { lineBadge, stationMarker, pipe, countBadge, nodeBlock, pillNode, hexNode, messageArrow, ledgerMark };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const SVG_TAGS: readonly ["svg", "g", "title", "desc", "defs", "clipPath", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
|
|
2
|
+
export type SvgTag = (typeof SVG_TAGS)[number];
|
|
3
|
+
export type AttrValue = string | number;
|
|
4
|
+
export type Attrs = Readonly<Record<string, AttrValue>>;
|
|
5
|
+
export type SvgNode = {
|
|
6
|
+
readonly tag: SvgTag;
|
|
7
|
+
readonly attrs: Attrs;
|
|
8
|
+
readonly children: readonly (SvgNode | string)[];
|
|
9
|
+
};
|
|
10
|
+
/** What a piece draws: shapes go in the outlined group, words and glyphs after every shape. */
|
|
11
|
+
export type Drawn = {
|
|
12
|
+
readonly body: readonly SvgNode[];
|
|
13
|
+
readonly text: readonly SvgNode[];
|
|
14
|
+
};
|
|
15
|
+
type Child = SvgNode | string | null | false | undefined;
|
|
16
|
+
export declare const el: (tag: SvgTag, attrs?: Attrs, ...children: Child[]) => SvgNode;
|
|
17
|
+
export declare const g: (attrs: Attrs, ...children: Child[]) => SvgNode;
|
|
18
|
+
export declare const drawn: (body: readonly SvgNode[], text?: readonly SvgNode[]) => Drawn;
|
|
19
|
+
/**
|
|
20
|
+
* Hard offset shadow (STY-1): an ink copy of the shape translated by `offset`,
|
|
21
|
+
* then the shape. Inside the outlined group the copy keeps the outline too, as
|
|
22
|
+
* the motion components' shadow does.
|
|
23
|
+
*/
|
|
24
|
+
export declare const shadowed: (shape: SvgNode, offset?: number) => SvgNode[];
|
|
25
|
+
/** Rounds to two decimals so computed geometry stays short and stable in the output. */
|
|
26
|
+
export declare const r2: (value: number) => number;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SVG node contract of the illustration pieces (spec illustration-pieces;
|
|
2
|
+
// design §3 of the illustration-pieces change).
|
|
3
|
+
//
|
|
4
|
+
// Why: a piece returns plain data, not markup or React, so the same drawing
|
|
5
|
+
// becomes an SVG string for covers and diagrams and React elements for
|
|
6
|
+
// Remotion scenes, and tests can inspect it without a browser.
|
|
7
|
+
//
|
|
8
|
+
// What can go wrong: nothing here reads files or the clock; helpers only build
|
|
9
|
+
// objects. Colour and number guards live in params.ts.
|
|
10
|
+
import { tokens } from "../../tokens/generated/tokens.js";
|
|
11
|
+
export const SVG_TAGS = [
|
|
12
|
+
"svg",
|
|
13
|
+
"g",
|
|
14
|
+
"title",
|
|
15
|
+
"desc",
|
|
16
|
+
"defs",
|
|
17
|
+
"clipPath",
|
|
18
|
+
"rect",
|
|
19
|
+
"circle",
|
|
20
|
+
"ellipse",
|
|
21
|
+
"line",
|
|
22
|
+
"polyline",
|
|
23
|
+
"polygon",
|
|
24
|
+
"path",
|
|
25
|
+
"text",
|
|
26
|
+
];
|
|
27
|
+
export const el = (tag, attrs = {}, ...children) => ({
|
|
28
|
+
tag,
|
|
29
|
+
attrs,
|
|
30
|
+
children: children.filter((child) => child !== null && child !== false && child !== undefined),
|
|
31
|
+
});
|
|
32
|
+
export const g = (attrs, ...children) => el("g", attrs, ...children);
|
|
33
|
+
export const drawn = (body, text = []) => ({ body, text });
|
|
34
|
+
/**
|
|
35
|
+
* Hard offset shadow (STY-1): an ink copy of the shape translated by `offset`,
|
|
36
|
+
* then the shape. Inside the outlined group the copy keeps the outline too, as
|
|
37
|
+
* the motion components' shadow does.
|
|
38
|
+
*/
|
|
39
|
+
export const shadowed = (shape, offset = tokens.shadow.block) => [
|
|
40
|
+
{
|
|
41
|
+
...shape,
|
|
42
|
+
attrs: {
|
|
43
|
+
...shape.attrs,
|
|
44
|
+
fill: tokens.color.ink,
|
|
45
|
+
// The offset applies after the shape's own transform, so a turned shape casts a turned shadow.
|
|
46
|
+
transform: shape.attrs.transform === undefined ? `translate(${offset} ${offset})` : `translate(${offset} ${offset}) ${shape.attrs.transform}`,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
shape,
|
|
50
|
+
];
|
|
51
|
+
/** Rounds to two decimals so computed geometry stays short and stable in the output. */
|
|
52
|
+
export const r2 = (value) => (Math.abs(value) < 1e12 ? Math.round(value * 100) / 100 : value);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type ParamSpec = {
|
|
2
|
+
kind: "number";
|
|
3
|
+
required?: boolean;
|
|
4
|
+
min?: number;
|
|
5
|
+
exclusiveMin?: boolean;
|
|
6
|
+
max?: number;
|
|
7
|
+
integer?: boolean;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "color";
|
|
10
|
+
required?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** A token path, or a colour from the consumer's data (#RRGGBB): pipe and lineBadge fills only. */
|
|
13
|
+
| {
|
|
14
|
+
kind: "dataColor";
|
|
15
|
+
required?: boolean;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "string";
|
|
18
|
+
required?: boolean;
|
|
19
|
+
nonEmpty?: boolean;
|
|
20
|
+
pattern?: RegExp;
|
|
21
|
+
patternName?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "enum";
|
|
24
|
+
required?: boolean;
|
|
25
|
+
values: readonly string[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: "boolean";
|
|
28
|
+
required?: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "point";
|
|
31
|
+
required?: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "array";
|
|
34
|
+
required?: boolean;
|
|
35
|
+
minItems?: number;
|
|
36
|
+
maxItems?: number;
|
|
37
|
+
of: ParamSpec;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "object";
|
|
40
|
+
required?: boolean;
|
|
41
|
+
fields: Schema;
|
|
42
|
+
};
|
|
43
|
+
export type Schema = Readonly<Record<string, ParamSpec>>;
|
|
44
|
+
export type Problem = {
|
|
45
|
+
path: string;
|
|
46
|
+
message: string;
|
|
47
|
+
};
|
|
48
|
+
/** A data colour: exactly six hex digits after the sign, nothing else. */
|
|
49
|
+
export declare const DATA_COLOUR: RegExp;
|
|
50
|
+
/** The hex value of a data colour parameter: a token path resolved, a data colour as given. */
|
|
51
|
+
export declare const paint: (value: string, name?: string) => string;
|
|
52
|
+
/** Every colour token, by its dotted path: any token whose value is #RRGGBB. */
|
|
53
|
+
export declare const COLOUR_TOKENS: ReadonlyMap<string, string>;
|
|
54
|
+
export declare const isColourLiteral: (value: string) => boolean;
|
|
55
|
+
/** The hex value of a colour token path; throws a RangeError for anything else. */
|
|
56
|
+
export declare const colour: (path: string, name?: string) => string;
|
|
57
|
+
/** Every problem of `params` against `schema`; paths are `prefix.name`. */
|
|
58
|
+
export declare const checkParams: (schema: Schema, params: Record<string, unknown>, prefix?: string) => Problem[];
|
|
59
|
+
/** Validates params for a call from code; the first problem becomes a RangeError. */
|
|
60
|
+
export declare const assertParams: (piece: string, schema: Schema, params: Record<string, unknown>) => void;
|
|
61
|
+
export declare const num: (extra?: Omit<Extract<ParamSpec, {
|
|
62
|
+
kind: "number";
|
|
63
|
+
}>, "kind">) => ParamSpec;
|
|
64
|
+
/** Largest size a piece accepts, far above any banner. Positions and points have no bound. */
|
|
65
|
+
export declare const MAX_SIZE = 10000;
|
|
66
|
+
/** Most items in any list parameter (rows, lines, nodes, tiles). */
|
|
67
|
+
export declare const MAX_ITEMS = 50;
|
|
68
|
+
/** A count of repeated parts (units, steps, teeth): an integer from `min` to `max`. */
|
|
69
|
+
export declare const count: (min: number, max: number) => ParamSpec;
|
|
70
|
+
/** A size: greater than 0 (or at least `min`, where fixed insets need room), at most MAX_SIZE. */
|
|
71
|
+
export declare const size: (required?: boolean, min?: number) => ParamSpec;
|
|
72
|
+
/** A position: any finite number, because a map position has no maximum. */
|
|
73
|
+
export declare const pos: () => ParamSpec;
|
|
74
|
+
export declare const col: () => ParamSpec;
|
|
75
|
+
/** SVG path data: commands, digits, signs, dots, commas and spaces only. */
|
|
76
|
+
export declare const PATH_DATA: RegExp;
|
|
77
|
+
export declare const label: (required?: boolean) => ParamSpec;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Parameter schemas of the pieces: one description per piece, used both when
|
|
2
|
+
// code calls a piece (first problem thrown as a RangeError) and by the cover
|
|
3
|
+
// command (every problem collected, with its JSON path).
|
|
4
|
+
//
|
|
5
|
+
// Why one schema: the cover spec and a scene must accept exactly the same
|
|
6
|
+
// parameters, or a cover that validates could fail to draw.
|
|
7
|
+
import { tokens } from "../../tokens/generated/tokens.js";
|
|
8
|
+
/** A data colour: exactly six hex digits after the sign, nothing else. */
|
|
9
|
+
export const DATA_COLOUR = /^#[0-9A-Fa-f]{6}$/;
|
|
10
|
+
/** The hex value of a data colour parameter: a token path resolved, a data colour as given. */
|
|
11
|
+
export const paint = (value, name = "fill") => (DATA_COLOUR.test(value) ? value : colour(value, name));
|
|
12
|
+
const COLOUR_LITERAL = /^\s*(#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\()/;
|
|
13
|
+
/** Every colour token, by its dotted path: any token whose value is #RRGGBB. */
|
|
14
|
+
export const COLOUR_TOKENS = (() => {
|
|
15
|
+
const found = new Map();
|
|
16
|
+
const visit = (prefix, node) => {
|
|
17
|
+
if (typeof node === "string") {
|
|
18
|
+
if (/^#[0-9a-fA-F]{6}$/.test(node))
|
|
19
|
+
found.set(prefix, node);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (typeof node === "object" && node !== null) {
|
|
23
|
+
for (const [name, child] of Object.entries(node))
|
|
24
|
+
visit(prefix ? `${prefix}.${name}` : name, child);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
visit("", tokens);
|
|
28
|
+
return found;
|
|
29
|
+
})();
|
|
30
|
+
export const isColourLiteral = (value) => COLOUR_LITERAL.test(value);
|
|
31
|
+
/** The hex value of a colour token path; throws a RangeError for anything else. */
|
|
32
|
+
export const colour = (path, name = "colour") => {
|
|
33
|
+
const value = COLOUR_TOKENS.get(path);
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
throw new RangeError(`${name}: expected a colour token path such as color.healthy, found ${JSON.stringify(path)}`);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const describe = (value) => (value === undefined ? "nothing" : JSON.stringify(value));
|
|
39
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
const checkValue = (spec, value, path, out) => {
|
|
41
|
+
const report = (message) => {
|
|
42
|
+
out.push({ path, message });
|
|
43
|
+
};
|
|
44
|
+
switch (spec.kind) {
|
|
45
|
+
case "number": {
|
|
46
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
47
|
+
return report(`expected a finite number, found ${describe(value)}`);
|
|
48
|
+
if (spec.integer && !Number.isInteger(value))
|
|
49
|
+
return report(`expected an integer, found ${value}`);
|
|
50
|
+
if (spec.min !== undefined && (spec.exclusiveMin ? value <= spec.min : value < spec.min)) {
|
|
51
|
+
return report(`expected a number ${spec.exclusiveMin ? "greater than" : "at least"} ${spec.min}, found ${value}`);
|
|
52
|
+
}
|
|
53
|
+
if (spec.max !== undefined && value > spec.max)
|
|
54
|
+
return report(`expected a number at most ${spec.max}, found ${value}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
case "color":
|
|
58
|
+
if (typeof value !== "string")
|
|
59
|
+
return report(`expected a colour token path, found ${describe(value)}`);
|
|
60
|
+
if (isColourLiteral(value))
|
|
61
|
+
return report("colour literals are not allowed; use a token path");
|
|
62
|
+
if (!COLOUR_TOKENS.has(value))
|
|
63
|
+
return report(`expected a colour token path such as color.healthy, found ${describe(value)}`);
|
|
64
|
+
return;
|
|
65
|
+
case "dataColor":
|
|
66
|
+
if (typeof value !== "string")
|
|
67
|
+
return report(`expected a colour token path or #RRGGBB, found ${describe(value)}`);
|
|
68
|
+
if (DATA_COLOUR.test(value) || COLOUR_TOKENS.has(value))
|
|
69
|
+
return;
|
|
70
|
+
return report(`expected a colour token path or a data colour #RRGGBB, found ${describe(value)}`);
|
|
71
|
+
case "string":
|
|
72
|
+
if (typeof value !== "string")
|
|
73
|
+
return report(`expected a string, found ${describe(value)}`);
|
|
74
|
+
if (spec.nonEmpty && value.trim() === "")
|
|
75
|
+
return report("expected a non-empty string");
|
|
76
|
+
if (spec.pattern && !spec.pattern.test(value))
|
|
77
|
+
return report(`expected ${spec.patternName ?? spec.pattern.source}`);
|
|
78
|
+
return;
|
|
79
|
+
case "enum":
|
|
80
|
+
if (typeof value !== "string" || !spec.values.includes(value)) {
|
|
81
|
+
return report(`expected one of ${spec.values.join(", ")}, found ${describe(value)}`);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
case "boolean":
|
|
85
|
+
if (typeof value !== "boolean")
|
|
86
|
+
return report(`expected true or false, found ${describe(value)}`);
|
|
87
|
+
return;
|
|
88
|
+
case "point":
|
|
89
|
+
// Any finite number: a map position has no maximum (spec illustration-pieces, "Positions of any size").
|
|
90
|
+
if (!Array.isArray(value) || value.length !== 2 || !value.every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
91
|
+
return report(`expected a point [x, y] of finite numbers, found ${describe(value)}`);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
case "array":
|
|
95
|
+
if (!Array.isArray(value))
|
|
96
|
+
return report(`expected an array, found ${describe(value)}`);
|
|
97
|
+
if (spec.minItems !== undefined && value.length < spec.minItems)
|
|
98
|
+
return report(`expected at least ${spec.minItems} item(s)`);
|
|
99
|
+
if (value.length > (spec.maxItems ?? MAX_ITEMS))
|
|
100
|
+
return report(`expected at most ${spec.maxItems ?? MAX_ITEMS} item(s)`);
|
|
101
|
+
value.forEach((item, index) => checkValue(spec.of, item, `${path}[${index}]`, out));
|
|
102
|
+
return;
|
|
103
|
+
case "object":
|
|
104
|
+
if (!isPlainObject(value))
|
|
105
|
+
return report(`expected an object, found ${describe(value)}`);
|
|
106
|
+
out.push(...checkParams(spec.fields, value, path));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/** Every problem of `params` against `schema`; paths are `prefix.name`. */
|
|
111
|
+
export const checkParams = (schema, params, prefix = "") => {
|
|
112
|
+
const out = [];
|
|
113
|
+
const at = (name) => (prefix ? `${prefix}.${name}` : name);
|
|
114
|
+
for (const name of Object.keys(params)) {
|
|
115
|
+
if (!Object.hasOwn(schema, name))
|
|
116
|
+
out.push({ path: at(name), message: `unknown parameter (expected ${Object.keys(schema).join(", ")})` });
|
|
117
|
+
}
|
|
118
|
+
for (const [name, spec] of Object.entries(schema)) {
|
|
119
|
+
if (!Object.hasOwn(params, name) || params[name] === undefined) {
|
|
120
|
+
if (spec.required)
|
|
121
|
+
out.push({ path: at(name), message: "missing required parameter" });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
checkValue(spec, params[name], at(name), out);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
/** Validates params for a call from code; the first problem becomes a RangeError. */
|
|
129
|
+
export const assertParams = (piece, schema, params) => {
|
|
130
|
+
const problems = checkParams(schema, params);
|
|
131
|
+
if (problems.length > 0)
|
|
132
|
+
throw new RangeError(`${piece}: ${problems[0].path}: ${problems[0].message}`);
|
|
133
|
+
};
|
|
134
|
+
// Shorthands for schemas.
|
|
135
|
+
export const num = (extra = {}) => ({ kind: "number", ...extra });
|
|
136
|
+
/** Largest size a piece accepts, far above any banner. Positions and points have no bound. */
|
|
137
|
+
export const MAX_SIZE = 10000;
|
|
138
|
+
/** Most items in any list parameter (rows, lines, nodes, tiles). */
|
|
139
|
+
export const MAX_ITEMS = 50;
|
|
140
|
+
/** A count of repeated parts (units, steps, teeth): an integer from `min` to `max`. */
|
|
141
|
+
export const count = (min, max) => ({ kind: "number", integer: true, min, max });
|
|
142
|
+
/** A size: greater than 0 (or at least `min`, where fixed insets need room), at most MAX_SIZE. */
|
|
143
|
+
export const size = (required = false, min) => min === undefined ? { kind: "number", min: 0, exclusiveMin: true, max: MAX_SIZE, required } : { kind: "number", min, max: MAX_SIZE, required };
|
|
144
|
+
/** A position: any finite number, because a map position has no maximum. */
|
|
145
|
+
export const pos = () => ({ kind: "number", required: true });
|
|
146
|
+
export const col = () => ({ kind: "color" });
|
|
147
|
+
/** SVG path data: commands, digits, signs, dots, commas and spaces only. */
|
|
148
|
+
export const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]+$/;
|
|
149
|
+
export const label = (required = true) => ({ kind: "string", nonEmpty: true, required });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Drawn } from "./node.ts";
|
|
2
|
+
import { type Problem, type Schema } from "./params.ts";
|
|
3
|
+
export type Relations<P> = (params: P) => Problem[];
|
|
4
|
+
export type Piece<P extends object> = ((params: P) => Drawn) & {
|
|
5
|
+
readonly pieceName: string;
|
|
6
|
+
readonly schema: Schema;
|
|
7
|
+
readonly relations?: Relations<P>;
|
|
8
|
+
};
|
|
9
|
+
export declare const definePiece: <P extends object>(name: string, schema: Schema, draw: (params: P) => Drawn, relations?: Relations<P>) => Piece<P>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { assertParams } from "./params.js";
|
|
2
|
+
// A number the piece computed and would draw: finite, or the drawing overflows
|
|
3
|
+
// (spec illustration-pieces, "Drawing overflow"). Strings are the piece's own
|
|
4
|
+
// path data and transforms; caller path data (road.d, pipe.d) has its own check
|
|
5
|
+
// and only ever holds path characters, never the words Infinity or NaN.
|
|
6
|
+
const NON_FINITE = /Infinity|NaN/;
|
|
7
|
+
const overflows = (node) => {
|
|
8
|
+
if (typeof node === "string")
|
|
9
|
+
return false;
|
|
10
|
+
for (const value of Object.values(node.attrs)) {
|
|
11
|
+
if (typeof value === "number" ? !Number.isFinite(value) : NON_FINITE.test(value))
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
return node.children.some(overflows);
|
|
15
|
+
};
|
|
16
|
+
export const definePiece = (name, schema, draw, relations) => {
|
|
17
|
+
const piece = (params) => {
|
|
18
|
+
assertParams(name, schema, params);
|
|
19
|
+
const problems = relations?.(params) ?? [];
|
|
20
|
+
if (problems.length > 0)
|
|
21
|
+
throw new RangeError(`${name}: ${problems[0].path}: ${problems[0].message}`);
|
|
22
|
+
const drawn = draw(params);
|
|
23
|
+
if ([...drawn.body, ...drawn.text].some(overflows))
|
|
24
|
+
throw new RangeError(`${name}: the drawing overflows`);
|
|
25
|
+
return drawn;
|
|
26
|
+
};
|
|
27
|
+
return Object.assign(piece, { pieceName: name, schema, relations });
|
|
28
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type SvgNode } from "./node.ts";
|
|
2
|
+
export type Pose = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
/** Degrees, clockwise. */
|
|
6
|
+
tilt?: number;
|
|
7
|
+
/** Vertical stretch; the width shrinks by its square root, so volume reads constant. */
|
|
8
|
+
squash?: number;
|
|
9
|
+
/** Pixels above the base. */
|
|
10
|
+
hop?: number;
|
|
11
|
+
scale?: number;
|
|
12
|
+
/** Horizontal offset in pixels. */
|
|
13
|
+
shake?: number;
|
|
14
|
+
opacity?: number;
|
|
15
|
+
};
|
|
16
|
+
export declare const pose: (node: SvgNode, params: Pose) => SvgNode;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Animation by parameter (spec illustration-pieces, "Animation by
|
|
2
|
+
// parameter"): the wrapper of the approved cast motion test. A scene computes
|
|
3
|
+
// the numbers from the frame and wraps the piece; the anchor (x, y) is the
|
|
4
|
+
// piece's base, usually its feet, so squash and tilt keep it on the ground.
|
|
5
|
+
import { g } from "./node.js";
|
|
6
|
+
const finite = (name, value) => {
|
|
7
|
+
if (!Number.isFinite(value))
|
|
8
|
+
throw new RangeError(`pose: ${name} must be a finite number, found ${value}`);
|
|
9
|
+
};
|
|
10
|
+
export const pose = (node, params) => {
|
|
11
|
+
const { x, y, tilt = 0, squash = 1, hop = 0, scale = 1, shake = 0, opacity = 1 } = params;
|
|
12
|
+
// Each parameter is checked completely (finite, then range) before the next, in this order.
|
|
13
|
+
for (const [name, value] of Object.entries({ x, y, tilt, squash, hop, scale, shake, opacity })) {
|
|
14
|
+
finite(name, value);
|
|
15
|
+
if ((name === "squash" || name === "scale") && value <= 0)
|
|
16
|
+
throw new RangeError(`pose: ${name} must be greater than 0, found ${value}`);
|
|
17
|
+
if (name === "opacity" && (value < 0 || value > 1))
|
|
18
|
+
throw new RangeError(`pose: opacity must be within 0 and 1, found ${value}`);
|
|
19
|
+
}
|
|
20
|
+
if (tilt === 0 && squash === 1 && hop === 0 && scale === 1 && shake === 0 && opacity === 1)
|
|
21
|
+
return node;
|
|
22
|
+
// Finite inputs can still overflow once combined (scale 1e308 with squash 4).
|
|
23
|
+
const parts = { "x + shake": x + shake, "y - hop": y - hop, "horizontal scale": scale / Math.sqrt(squash), "vertical scale": scale * squash };
|
|
24
|
+
for (const [name, value] of Object.entries(parts)) {
|
|
25
|
+
if (!Number.isFinite(value))
|
|
26
|
+
throw new RangeError(`pose: ${name} overflows, found ${value}`);
|
|
27
|
+
}
|
|
28
|
+
const transform = `translate(${parts["x + shake"]} ${parts["y - hop"]}) rotate(${tilt}) scale(${parts["horizontal scale"]} ${parts["vertical scale"]}) translate(${-x} ${-y})`;
|
|
29
|
+
return g(opacity < 1 ? { transform, opacity } : { transform }, node);
|
|
30
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// React renderer of the node tree (spec illustration-pieces, "React
|
|
2
|
+
// renderer"): the same drawing as toSvg, as React elements for Remotion scenes.
|
|
3
|
+
// Hyphenated SVG attributes take React's camelCase names; aria-* and data-*
|
|
4
|
+
// stay as they are, because React expects them hyphenated.
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
export const reactName = (name) => name.startsWith("aria-") || name.startsWith("data-") ? name : name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
7
|
+
export const toReact = (node, key = 0) => {
|
|
8
|
+
const props = { key };
|
|
9
|
+
for (const [name, value] of Object.entries(node.attrs))
|
|
10
|
+
props[reactName(name)] = value;
|
|
11
|
+
const children = node.children.map((child, index) => (typeof child === "string" ? child : toReact(child, index)));
|
|
12
|
+
return createElement(node.tag, props, ...children);
|
|
13
|
+
};
|