@brunosouzas/design-system 0.3.2 → 0.4.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/README.md +19 -5
- package/dist/scripts/cover.d.ts +3 -2
- package/dist/scripts/cover.js +24 -12
- package/dist/src/pieces/cover.d.ts +54 -15
- package/dist/src/pieces/cover.js +327 -108
- package/dist/src/pieces/diagram.d.ts +9 -0
- package/dist/src/pieces/diagram.js +7 -0
- package/dist/src/pieces/index.d.ts +1 -1
- package/dist/src/pieces/index.js +1 -1
- package/dist/src/pieces/library.d.ts +1 -0
- package/dist/src/pieces/library.js +2 -1
- package/dist/tokens/generated/tokens.d.ts +7 -0
- package/dist/tokens/generated/tokens.js +7 -0
- package/package.json +1 -1
- package/tokens/generated/tokens.css +5 -0
- package/tokens/generated/tokens.json +7 -0
- package/tokens/generated/tokens.ts +7 -0
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ npm run stills -- publish-subscribe # 24 phase/final PNGs (six per
|
|
|
90
90
|
npm run stills -- publish-subscribe --dry-run # print 24 commands, no effects
|
|
91
91
|
npm run render -- publish-subscribe --dry-run # print eight commands; real video waits for owner stills approval
|
|
92
92
|
npm run build # compile the package into dist/ (the package test needs it)
|
|
93
|
-
npm run cover -- cover.json --out card.svg # build a blog cover (or a banner) from its spec
|
|
93
|
+
npm run cover -- cover.json --out card.svg # build a blog cover (or a banner, diagram or icon) from its spec
|
|
94
94
|
npm run pieces:sheet -- --out out/pieces.svg # every piece once, labelled, for review
|
|
95
95
|
npm run studio # Remotion Studio, for frame-by-frame review
|
|
96
96
|
```
|
|
@@ -131,7 +131,7 @@ The components take every colour and size from the tokens, and what is visible a
|
|
|
131
131
|
- `toSvg(node)` writes a tree as SVG text; `toReact(node)` turns the same tree into React elements. Both describe the same elements, attributes and text, which the tests check.
|
|
132
132
|
- `pose(node, { x, y, tilt, squash, hop, scale, shake, opacity })` animates any piece around its base point; a scene recomputes the numbers every frame.
|
|
133
133
|
- Every colour parameter is a token path (`"color.healthy"`, `"illustration.accent"`), never a hex value, except the three data fills of the map pieces below. Each piece checks its parameters before drawing and names the first wrong one. Positions and points take any finite number, because a map has no edge; sizes stay within 10000. A drawing that would hold a non-finite number throws `<piece>: the drawing overflows`.
|
|
134
|
-
- The
|
|
134
|
+
- The 52 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons) and in [design §3 to §5](openspec/changes/archive/2026-10-23-body-diagram-pieces/design.md) of `body-diagram-pieces` (the ten body diagram pieces), plus `stateStart` ([design §4](openspec/changes/archive/2026-10-24-designer-formats/design.md) of `designer-formats`); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
|
|
135
135
|
|
|
136
136
|
### Maps and diagrams
|
|
137
137
|
|
|
@@ -160,7 +160,8 @@ Ten pieces draw what the blog's body diagrams need beyond the map pieces: `diagr
|
|
|
160
160
|
- The shapes take one to three centred lines (`text`), an optional `subtitle` and `textSize`. Without `fill` the face is the surface's panel; with a fill, every word is ink.
|
|
161
161
|
- Backticks mark code: `` "adopts a `correlationId`" `` draws `correlationId` in the monospace stack, as a `tspan`. An odd number of backticks is drawn as is.
|
|
162
162
|
- Widths the piece estimates (`tagWidth`, `conditionWidth`, a label's `width`) can be passed by a caller that measures its text.
|
|
163
|
-
- A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8).
|
|
163
|
+
- A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8). The cover command does this for you with `"format": "diagram"` (below).
|
|
164
|
+
- `stateStart({ cx, cy, r, surface })` draws the filled start dot of a state machine (7.7 units, 11 at 800), in the surface's text colour, with no outline and no shadow.
|
|
164
165
|
|
|
165
166
|
### A cover
|
|
166
167
|
|
|
@@ -180,10 +181,10 @@ A cover is a JSON spec kept with its article, outside this repository: drafts ar
|
|
|
180
181
|
}
|
|
181
182
|
```
|
|
182
183
|
|
|
183
|
-
- `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers).
|
|
184
|
+
- `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers). Which covers may share a pair is the `designer` skill's rule, not the package's.
|
|
184
185
|
- `pieces` are drawn in order; their words go on top.
|
|
185
186
|
- The `rings` centre stays within ±10000 in a cover spec, and a piece that overflows is reported at its entry (`pieces[1]: the drawing overflows`).
|
|
186
|
-
- `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead.
|
|
187
|
+
- `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead. It is painted after every piece's shapes; `customAt: <i>` paints it before `pieces[i]` instead, so a pill can sit on top of it (words always stay on top).
|
|
187
188
|
- The three published covers that proved the pieces are the test fixtures in `test/fixtures/covers/`.
|
|
188
189
|
|
|
189
190
|
### Banners
|
|
@@ -195,6 +196,19 @@ A spec with `"format": "banner"` (the default is `"cover"`) builds a home banner
|
|
|
195
196
|
|
|
196
197
|
The banner is one image that scales down as a whole. Words in it become small on a phone (a 19px label is about 6px at 375px wide), so keep them few or none. `test/fixtures/covers/home-banner.json` is the brunosouzas.com home banner redrawn from the pieces.
|
|
197
198
|
|
|
199
|
+
### Body diagrams and icons from a spec
|
|
200
|
+
|
|
201
|
+
The same command draws the blog's body diagrams and icons, so a consumer needs no script and no local install:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
npx --yes --package @brunosouzas/design-system@<version> brunosouzas-cover design/<topic>.json --lang pt --out images/<topic>-pt.svg
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `"format": "diagram"` takes a `height` in layout units (1 to 10000) and no `background`: the spec is laid out 560 wide and written 800 wide, on the `color.panel` panel with rounded corners (`bodyDiagram.radius`, 14 at 800) and rings in `color.ring` that show at step 28 and width 2, out to the farthest corner. Pieces get the map outline (3) and label size (13), which show at 4.3 and 18.6. `test/fixtures/covers/circuit-breaker-states.json` is the circuit breaker article's state diagram.
|
|
208
|
+
- `"format": "icon"` holds one `eipIcon`, `uiIcon` or `linkIcon` at 0,0 and writes it in a box of its own size (64x40 for an EIP icon). An EIP icon takes its title and description, with the CC BY 4.0 credit, from the icon's data, so its spec has neither; a UI or link icon spec needs both. No `background`, `rings` or `custom`.
|
|
209
|
+
- **Text tables.** `"text": { "pt": { "open": ["taxa de falha", "> limite"] }, "en": { ... } }` holds one table per language with the same keys; any string `"@open"` elsewhere in the spec (pieces, `title`, `description`, even `id`) is replaced by that key's value for the language passed with `--lang <code>`. Every language must define every key, and the ids in the output gain the language (`<id>-pt`). Without `text`, strings are drawn as written, so `"@home"` stays a label.
|
|
210
|
+
- Like a cover, a diagram or icon is a standalone image: `uiIcon` `colour: "currentColor"` is refused in every format.
|
|
211
|
+
|
|
198
212
|
### A new piece
|
|
199
213
|
|
|
200
214
|
Add it to `src/pieces/library.ts` (or `src/pieces/metro.ts` for a map or diagram piece) with `definePiece(name, schema, draw)`, take its colours and sizes from the tokens (a new colour goes to `tokens/source.json` first), register it in `PIECES`, and give it a sample in `scripts/pieces-sheet.ts`. The catalogue test then checks that it draws with only its required parameters and uses token colours only.
|
package/dist/scripts/cover.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
|
|
2
|
+
export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
|
|
3
3
|
export type CoverIo = {
|
|
4
4
|
cwd: string;
|
|
5
5
|
log: (line: string) => void;
|
|
@@ -10,12 +10,13 @@ export declare const parseCoverArgs: (args: string[], cwd: string) => {
|
|
|
10
10
|
ok: true;
|
|
11
11
|
spec: string;
|
|
12
12
|
out: string;
|
|
13
|
+
lang?: string;
|
|
13
14
|
} | {
|
|
14
15
|
ok: false;
|
|
15
16
|
problem: string;
|
|
16
17
|
};
|
|
17
18
|
/** Builds the cover text for a spec file's contents, or the problem lines. */
|
|
18
|
-
export declare const buildCover: (text: string, label: string) => {
|
|
19
|
+
export declare const buildCover: (text: string, label: string, lang?: string) => {
|
|
19
20
|
ok: true;
|
|
20
21
|
svg: string;
|
|
21
22
|
} | {
|
package/dist/scripts/cover.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `npm run cover -- <spec.json> --out <file.svg
|
|
2
|
+
// `npm run cover -- <spec.json> --out <file.svg> [--lang <code>]`, installed as the bin
|
|
3
3
|
// `brunosouzas-cover` (spec cover-cli; design §6):
|
|
4
4
|
// builds one blog cover from a cover spec kept with its article in the vault.
|
|
5
5
|
//
|
|
@@ -14,23 +14,29 @@ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync
|
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { dirname, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { coverTree, validateCoverSpec } from "../src/pieces/cover.js";
|
|
17
|
+
import { coverTree, LANGUAGE, validateCoverSpec } from "../src/pieces/cover.js";
|
|
18
18
|
import { toSvg } from "../src/pieces/render.js";
|
|
19
|
-
export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
|
|
19
|
+
export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
|
|
20
20
|
/** The first invocation problem, in the order of the spec, or the two paths. */
|
|
21
21
|
export const parseCoverArgs = (args, cwd) => {
|
|
22
22
|
const specs = [];
|
|
23
23
|
const outs = [];
|
|
24
|
+
const langs = [];
|
|
24
25
|
let unknown;
|
|
25
26
|
let outWithoutValue = false;
|
|
27
|
+
let langWithoutValue = false;
|
|
26
28
|
for (let i = 0; i < args.length; i++) {
|
|
27
29
|
const arg = args[i];
|
|
28
|
-
if (arg === "--out") {
|
|
30
|
+
if (arg === "--out" || arg === "--lang") {
|
|
29
31
|
const value = args[i + 1];
|
|
30
|
-
if (value === undefined || value.startsWith("--"))
|
|
31
|
-
|
|
32
|
+
if (value === undefined || value.startsWith("--")) {
|
|
33
|
+
if (arg === "--out")
|
|
34
|
+
outWithoutValue = true;
|
|
35
|
+
else
|
|
36
|
+
langWithoutValue = true;
|
|
37
|
+
}
|
|
32
38
|
else {
|
|
33
|
-
outs.push(value);
|
|
39
|
+
(arg === "--out" ? outs : langs).push(value);
|
|
34
40
|
i++;
|
|
35
41
|
}
|
|
36
42
|
}
|
|
@@ -56,11 +62,17 @@ export const parseCoverArgs = (args, cwd) => {
|
|
|
56
62
|
const target = resolve(cwd, out);
|
|
57
63
|
if (existsSync(target) && statSync(target).isDirectory())
|
|
58
64
|
return { ok: false, problem: `the output is a folder: ${out}` };
|
|
65
|
+
if (langWithoutValue)
|
|
66
|
+
return { ok: false, problem: "--lang needs a value: <code>" };
|
|
67
|
+
if (langs.length > 1)
|
|
68
|
+
return { ok: false, problem: "--lang given more than once" };
|
|
69
|
+
if (langs.length === 1 && !LANGUAGE.test(langs[0]))
|
|
70
|
+
return { ok: false, problem: `--lang expects a language code such as pt or en, found ${langs[0]}` };
|
|
59
71
|
if (specs.length === 0)
|
|
60
72
|
return { ok: false, problem: "missing the cover spec: <spec.json>" };
|
|
61
73
|
if (specs.length > 1)
|
|
62
74
|
return { ok: false, problem: `one cover spec at a time, found ${specs.length}` };
|
|
63
|
-
return { ok: true, spec: specs[0], out };
|
|
75
|
+
return { ok: true, spec: specs[0], out, ...(langs.length === 1 ? { lang: langs[0] } : {}) };
|
|
64
76
|
};
|
|
65
77
|
/** Parses the spec text; a syntax error names the line and column. */
|
|
66
78
|
const parseJson = (text, label) => {
|
|
@@ -82,14 +94,14 @@ const parseJson = (text, label) => {
|
|
|
82
94
|
}
|
|
83
95
|
};
|
|
84
96
|
/** Builds the cover text for a spec file's contents, or the problem lines. */
|
|
85
|
-
export const buildCover = (text, label) => {
|
|
97
|
+
export const buildCover = (text, label, lang) => {
|
|
86
98
|
const parsed = parseJson(text, label);
|
|
87
99
|
if ("problem" in parsed)
|
|
88
100
|
return { ok: false, lines: [parsed.problem] };
|
|
89
|
-
const problems = validateCoverSpec(parsed.value);
|
|
101
|
+
const problems = validateCoverSpec(parsed.value, { lang });
|
|
90
102
|
if (problems.length > 0)
|
|
91
103
|
return { ok: false, lines: problems.map((p) => `${p.path}: ${p.message}`) };
|
|
92
|
-
return { ok: true, svg: `${toSvg(coverTree(parsed.value))}\n` };
|
|
104
|
+
return { ok: true, svg: `${toSvg(coverTree(parsed.value, { lang }))}\n` };
|
|
93
105
|
};
|
|
94
106
|
export const runCover = (args, io) => {
|
|
95
107
|
const invocation = parseCoverArgs(args, io.cwd);
|
|
@@ -108,7 +120,7 @@ export const runCover = (args, io) => {
|
|
|
108
120
|
io.error(COVER_USAGE);
|
|
109
121
|
return 1;
|
|
110
122
|
}
|
|
111
|
-
const result = buildCover(text, invocation.spec);
|
|
123
|
+
const result = buildCover(text, invocation.spec, invocation.lang);
|
|
112
124
|
if (!result.ok) {
|
|
113
125
|
for (const line of result.lines)
|
|
114
126
|
io.error(line);
|
|
@@ -2,8 +2,9 @@ import { type PieceName } from "./library.ts";
|
|
|
2
2
|
import { type SvgNode } from "./node.ts";
|
|
3
3
|
import { type Problem } from "./params.ts";
|
|
4
4
|
export declare const SAFE_ID: RegExp;
|
|
5
|
-
/** Cover (800x420, the default)
|
|
6
|
-
|
|
5
|
+
/** Cover (800x420, the default), banner (1200x420, rounded corners), body diagram (560 laid out, 800 shown) or icon:
|
|
6
|
+
* spec cover-cli, "Banner output", "Diagram output", "Icon output". */
|
|
7
|
+
export declare const FORMATS: readonly ["cover", "banner", "diagram", "icon"];
|
|
7
8
|
export type CoverFormat = (typeof FORMATS)[number];
|
|
8
9
|
export type CoverPiece = {
|
|
9
10
|
piece: PieceName;
|
|
@@ -13,26 +14,64 @@ export type CustomNode = {
|
|
|
13
14
|
attrs?: Record<string, unknown>;
|
|
14
15
|
children?: (CustomNode | string)[];
|
|
15
16
|
};
|
|
16
|
-
export type
|
|
17
|
+
export type CoverRings = {
|
|
18
|
+
cx: number;
|
|
19
|
+
cy: number;
|
|
20
|
+
radius?: number;
|
|
21
|
+
};
|
|
22
|
+
type SpecCommon = {
|
|
17
23
|
id: string;
|
|
24
|
+
pieces: CoverPiece[];
|
|
25
|
+
/** One table per language; `"@key"` strings elsewhere are replaced for the language asked. */
|
|
26
|
+
text?: Record<string, Record<string, string | string[]>>;
|
|
27
|
+
};
|
|
28
|
+
type Framed = SpecCommon & {
|
|
18
29
|
title: string;
|
|
19
30
|
description: string;
|
|
20
|
-
|
|
21
|
-
background: string;
|
|
22
|
-
rings: {
|
|
23
|
-
cx: number;
|
|
24
|
-
cy: number;
|
|
25
|
-
radius?: number;
|
|
26
|
-
};
|
|
27
|
-
pieces: CoverPiece[];
|
|
31
|
+
rings: CoverRings;
|
|
28
32
|
custom?: CustomNode;
|
|
33
|
+
/** `custom` is drawn before `pieces[customAt]`; default after every piece. */
|
|
34
|
+
customAt?: number;
|
|
29
35
|
};
|
|
36
|
+
/** A cover spec, by format (spec cover-cli, "Cover spec format" and each format's keys). */
|
|
37
|
+
export type CoverSpec = (Framed & {
|
|
38
|
+
format?: "cover" | "banner";
|
|
39
|
+
background: string;
|
|
40
|
+
height?: never;
|
|
41
|
+
}) | (Framed & {
|
|
42
|
+
format: "diagram"; /** Layout units. */
|
|
43
|
+
height: number;
|
|
44
|
+
background?: never;
|
|
45
|
+
}) | (SpecCommon & {
|
|
46
|
+
format: "icon";
|
|
47
|
+
/** Not in an eipIcon spec: the icon's own title and description, with the credit, are drawn. */
|
|
48
|
+
title?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
background?: never;
|
|
51
|
+
rings?: never;
|
|
52
|
+
custom?: never;
|
|
53
|
+
customAt?: never;
|
|
54
|
+
height?: never;
|
|
55
|
+
});
|
|
30
56
|
export declare const backgroundNames: () => string[];
|
|
31
57
|
/** Compares JSON paths: segment by segment, array indexes as numbers, names by code point. */
|
|
32
58
|
export declare const comparePaths: (a: string, b: string) => number;
|
|
33
|
-
/**
|
|
34
|
-
export declare const
|
|
59
|
+
/** A language of a text table and of `--lang`: `pt`, `en`, `pt-br`. */
|
|
60
|
+
export declare const LANGUAGE: RegExp;
|
|
61
|
+
export type CoverOptions = {
|
|
62
|
+
lang?: string;
|
|
63
|
+
};
|
|
64
|
+
/** Every problem of a parsed spec, sorted by path; empty when the spec is valid. `lang` picks a text table. */
|
|
65
|
+
export declare const validateCoverSpec: (spec: unknown, options?: CoverOptions) => Problem[];
|
|
35
66
|
/** Rings a banner draws: out to the frame's farthest corner (spec cover-cli, "Banner rings reach the corners"). */
|
|
36
67
|
export declare const bannerRingCount: (cx: number, cy: number, radius?: number) => number;
|
|
37
|
-
/**
|
|
38
|
-
export declare const
|
|
68
|
+
/** Rings of a diagram (spec cover-cli, "Diagram rings at the shown scale"): the cover's step and width at the shown scale. */
|
|
69
|
+
export declare const diagramRings: (cx: number, cy: number, height: number, radius?: number) => {
|
|
70
|
+
count: number;
|
|
71
|
+
step: number;
|
|
72
|
+
width: number;
|
|
73
|
+
radius: number;
|
|
74
|
+
};
|
|
75
|
+
/** The cover's (banner's, diagram's, icon's) node tree. The spec must be valid for `lang` (validateCoverSpec returned no problem). */
|
|
76
|
+
export declare const coverTree: (input: CoverSpec, options?: CoverOptions) => SvgNode;
|
|
77
|
+
export {};
|
package/dist/src/pieces/cover.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Cover specs (spec cover-cli; design §6): validation of the JSON a cover is
|
|
2
|
-
// written in, and the
|
|
2
|
+
// written in, and the frames that draw it from the pieces: the 800x420 cover,
|
|
3
|
+
// the banner, the body diagram and the icon (change designer-formats).
|
|
3
4
|
//
|
|
4
5
|
// Why here and not in scripts/: the validation and the frame are pure, so the
|
|
5
6
|
// tests and a future site build can use them without the command line.
|
|
@@ -9,12 +10,16 @@
|
|
|
9
10
|
// attributes, because the cover is published as SVG.
|
|
10
11
|
import { tokens } from "../../tokens/generated/tokens.js";
|
|
11
12
|
import { PIECE_NAMES, PIECES, rings } from "./library.js";
|
|
12
|
-
import {
|
|
13
|
+
import { EIP_ICON_HEIGHT, EIP_ICON_WIDTH, EIP_ICONS } from "./eip-icons.js";
|
|
14
|
+
import { LINK_ICON_SIZE } from "./link-icons.js";
|
|
15
|
+
import { el, g, r2 } from "./node.js";
|
|
16
|
+
import { UI_ICON_SIZE } from "./ui-icons.js";
|
|
13
17
|
import { COLOUR_TOKENS, CURRENT_COLOUR, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
|
|
14
18
|
export const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
|
-
const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom"];
|
|
16
|
-
/** Cover (800x420, the default)
|
|
17
|
-
|
|
19
|
+
const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom", "customAt", "height", "text"];
|
|
20
|
+
/** Cover (800x420, the default), banner (1200x420, rounded corners), body diagram (560 laid out, 800 shown) or icon:
|
|
21
|
+
* spec cover-cli, "Banner output", "Diagram output", "Icon output". */
|
|
22
|
+
export const FORMATS = ["cover", "banner", "diagram", "icon"];
|
|
18
23
|
const CUSTOM_TAGS = ["g", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
|
|
19
24
|
// Attributes a free-form object may use (design §6): geometry, paint, text and transform.
|
|
20
25
|
const CUSTOM_ATTRS = new Set([
|
|
@@ -134,118 +139,278 @@ export const comparePaths = (a, b) => {
|
|
|
134
139
|
}
|
|
135
140
|
return left.length - right.length;
|
|
136
141
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
// ---------- text tables (spec cover-cli "Text tables", "Text references", "Language choice"; design §3) ----------
|
|
143
|
+
/** A language of a text table and of `--lang`: `pt`, `en`, `pt-br`. */
|
|
144
|
+
export const LANGUAGE = /^[a-z]{2}(-[a-z]{2})?$/;
|
|
145
|
+
const TEXT_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
146
|
+
const REFERENCE = /^@([A-Za-z][A-Za-z0-9_-]*)$/;
|
|
147
|
+
const isTextValue = (value) => (typeof value === "string" && value !== "") ||
|
|
148
|
+
(Array.isArray(value) && value.length > 0 && Array.from({ length: value.length }, (_, i) => value[i]).every((item) => typeof item === "string" && item !== ""));
|
|
149
|
+
/** The table `lang` picks, or the problems of the tables and of the language choice. */
|
|
150
|
+
const pickTable = (spec, lang) => {
|
|
151
|
+
if (spec.text === undefined)
|
|
152
|
+
return { problems: lang === undefined ? [] : [{ path: "text", message: "--lang needs text tables" }] };
|
|
153
|
+
const text = spec.text;
|
|
154
|
+
if (!isPlainObject(text) || Object.keys(text).length === 0)
|
|
155
|
+
return { problems: [{ path: "text", message: "expected an object of languages (such as en, pt), each a table of text keys" }] };
|
|
156
|
+
const problems = [];
|
|
157
|
+
const tables = new Map();
|
|
158
|
+
for (const [code, table] of Object.entries(text)) {
|
|
159
|
+
if (!LANGUAGE.test(code))
|
|
160
|
+
problems.push({ path: `text.${code}`, message: "expected a language code such as pt or en" });
|
|
161
|
+
else if (!isPlainObject(table))
|
|
162
|
+
problems.push({ path: `text.${code}`, message: "expected an object of text keys" });
|
|
163
|
+
else
|
|
164
|
+
tables.set(code, table);
|
|
165
|
+
}
|
|
166
|
+
const keys = new Set();
|
|
167
|
+
for (const [code, table] of tables) {
|
|
168
|
+
for (const [key, value] of Object.entries(table)) {
|
|
169
|
+
keys.add(key);
|
|
170
|
+
if (!TEXT_KEY.test(key))
|
|
171
|
+
problems.push({ path: `text.${code}.${key}`, message: "expected a text key (a letter, then letters, digits, _ or -)" });
|
|
172
|
+
else if (!isTextValue(value))
|
|
173
|
+
problems.push({ path: `text.${code}.${key}`, message: "expected a non-empty string or a non-empty array of non-empty strings" });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const [code, table] of tables) {
|
|
177
|
+
for (const key of keys)
|
|
178
|
+
if (!Object.hasOwn(table, key))
|
|
179
|
+
problems.push({ path: `text.${code}.${key}`, message: "missing: another language defines it" });
|
|
180
|
+
}
|
|
181
|
+
if (problems.length > 0)
|
|
182
|
+
return { problems };
|
|
183
|
+
const codes = [...tables.keys()].sort().join(", ");
|
|
184
|
+
if (lang === undefined)
|
|
185
|
+
return { problems: [{ path: "text", message: `choose a language with --lang (${codes})` }] };
|
|
186
|
+
if (!tables.has(lang))
|
|
187
|
+
return { problems: [{ path: "text", message: `no table for ${lang} (${codes})` }] };
|
|
188
|
+
return { table: tables.get(lang), problems: [] };
|
|
189
|
+
};
|
|
190
|
+
/** The spec with every `@key` outside `text` replaced once by the table's value; unknown keys by path. */
|
|
191
|
+
const resolveText = (spec, table) => {
|
|
192
|
+
const unknown = [];
|
|
193
|
+
const visit = (value, path) => {
|
|
194
|
+
if (typeof value === "string") {
|
|
195
|
+
const match = REFERENCE.exec(value);
|
|
196
|
+
if (!match)
|
|
197
|
+
return value;
|
|
198
|
+
if (!Object.hasOwn(table, match[1])) {
|
|
199
|
+
unknown.push({ path, message: `unknown text key ${match[1]}` });
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
const found = table[match[1]];
|
|
203
|
+
return Array.isArray(found) ? [...found] : found;
|
|
204
|
+
}
|
|
205
|
+
if (Array.isArray(value))
|
|
206
|
+
return Array.from({ length: value.length }, (_, i) => visit(value[i], `${path}[${i}]`));
|
|
207
|
+
if (isPlainObject(value))
|
|
208
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, visit(child, path ? `${path}.${key}` : key)]));
|
|
209
|
+
return value;
|
|
210
|
+
};
|
|
211
|
+
const { text, ...rest } = spec;
|
|
212
|
+
return { spec: { ...visit(rest, ""), text }, unknown };
|
|
213
|
+
};
|
|
214
|
+
/** The spec the frame draws: resolved for `lang`, with the language in its id (decision 4). */
|
|
215
|
+
const forLanguage = (spec, lang) => {
|
|
216
|
+
if (spec.text === undefined || lang === undefined)
|
|
217
|
+
return spec;
|
|
218
|
+
const resolved = resolveText(spec, spec.text[lang]).spec;
|
|
219
|
+
// The id may be a reference too: the language goes on the resolved id.
|
|
220
|
+
return { ...resolved, id: `${resolved.id}-${lang}` };
|
|
221
|
+
};
|
|
222
|
+
// ---------- validation ----------
|
|
223
|
+
/** Icon pieces and the box each draws in (spec cover-cli, "Icon output"). */
|
|
224
|
+
const ICON_PIECES = ["eipIcon", "uiIcon", "linkIcon"];
|
|
225
|
+
const iconBox = (entry) => {
|
|
226
|
+
if (entry.piece === "eipIcon") {
|
|
227
|
+
const scale = entry.scale ?? 1;
|
|
228
|
+
return [EIP_ICON_WIDTH * scale, EIP_ICON_HEIGHT * scale];
|
|
229
|
+
}
|
|
230
|
+
const side = entry.size ?? (entry.piece === "uiIcon" ? UI_ICON_SIZE : LINK_ICON_SIZE);
|
|
231
|
+
return [side, side];
|
|
232
|
+
};
|
|
233
|
+
/** Problems of one `pieces` entry against its piece's parameters (spec cover-cli, "Piece parameters checked"). */
|
|
234
|
+
const checkEntry = (piece, params, at, out) => {
|
|
235
|
+
const definition = PIECES[piece];
|
|
236
|
+
const own = checkParams(definition.schema, params, at);
|
|
237
|
+
// Every output is a standalone image: currentColor (accepted by uiIcon for inline page icons) is refused.
|
|
238
|
+
for (const [name, spec] of Object.entries(definition.schema)) {
|
|
239
|
+
if (spec.kind === "color" && spec.currentColor && params[name] === CURRENT_COLOUR) {
|
|
240
|
+
own.push({ path: `${at}.${name}`, message: "currentColor is for icons inline in a page; a cover uses a token path" });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
out.push(...own);
|
|
244
|
+
// Limits between parameters are reported with the schema's problems, except on a parameter
|
|
245
|
+
// already reported; a relation that cannot read a broken parameter reports nothing.
|
|
246
|
+
let related = [];
|
|
247
|
+
if (definition.relations) {
|
|
248
|
+
try {
|
|
249
|
+
// A relation that reads a broken parameter may return anything: keep only real problems.
|
|
250
|
+
const found = definition.relations(params);
|
|
251
|
+
related = Array.isArray(found) ? found.filter((p) => isPlainObject(p) && typeof p.path === "string" && typeof p.message === "string") : [];
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
related = [];
|
|
255
|
+
}
|
|
256
|
+
const reported = new Set(own.map((problem) => problem.path));
|
|
257
|
+
out.push(...related.map((problem) => ({ path: `${at}.${problem.path}`, message: problem.message })).filter((problem) => !reported.has(problem.path)));
|
|
258
|
+
}
|
|
259
|
+
// A valid entry is drawn once: an overflow is a spec problem at the entry, not a crash.
|
|
260
|
+
if (own.length === 0 && related.length === 0) {
|
|
261
|
+
try {
|
|
262
|
+
PIECES[piece](params);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof RangeError && error.message.endsWith(": the drawing overflows"))
|
|
266
|
+
out.push({ path: at, message: "the drawing overflows" });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const checkRings = (spec, out) => {
|
|
271
|
+
if (!isPlainObject(spec.rings))
|
|
272
|
+
return void out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
|
|
273
|
+
const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color" && problem.path !== "rings.count");
|
|
274
|
+
out.push(...ringProblems);
|
|
275
|
+
// The piece takes any finite centre; a spec keeps 0.1.0's ±10000, which bounds a banner's and a diagram's ring count.
|
|
276
|
+
for (const axis of ["cx", "cy"]) {
|
|
277
|
+
const value = spec.rings[axis];
|
|
278
|
+
if (ringProblems.some((p) => p.path === `rings.${axis}`) || typeof value !== "number")
|
|
279
|
+
continue;
|
|
280
|
+
if (value < -MAX_SIZE)
|
|
281
|
+
out.push({ path: `rings.${axis}`, message: `expected a number at least -${MAX_SIZE}, found ${value}` });
|
|
282
|
+
else if (value > MAX_SIZE)
|
|
283
|
+
out.push({ path: `rings.${axis}`, message: `expected a number at most ${MAX_SIZE}, found ${value}` });
|
|
284
|
+
}
|
|
285
|
+
if (Object.hasOwn(spec.rings, "color"))
|
|
286
|
+
out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
|
|
287
|
+
if (Object.hasOwn(spec.rings, "count"))
|
|
288
|
+
out.push({ path: "rings.count", message: "unknown parameter: the ring count comes from the format" });
|
|
289
|
+
};
|
|
290
|
+
const NOT_FOR_ICONS = "not used by the icon format";
|
|
291
|
+
const EIP_TEXT = "comes from the EIP icon, with its CC BY 4.0 credit";
|
|
292
|
+
/** Every problem of a resolved spec (no text references left to resolve). */
|
|
293
|
+
const validateResolved = (spec) => {
|
|
141
294
|
const out = [];
|
|
295
|
+
const blocked = [];
|
|
142
296
|
colourLiterals(spec, "", out);
|
|
143
297
|
for (const key of Object.keys(spec)) {
|
|
144
298
|
if (!TOP_LEVEL.includes(key))
|
|
145
299
|
out.push({ path: key, message: `unknown key (expected ${TOP_LEVEL.join(", ")})` });
|
|
146
300
|
}
|
|
301
|
+
if (spec.format !== undefined && (typeof spec.format !== "string" || !FORMATS.includes(spec.format))) {
|
|
302
|
+
out.push({ path: "format", message: `expected one of ${FORMATS.join(", ")}` });
|
|
303
|
+
}
|
|
304
|
+
// An unknown format is checked as a cover, as before this change.
|
|
305
|
+
const format = FORMATS.includes(spec.format) ? spec.format : "cover";
|
|
147
306
|
if (typeof spec.id !== "string" || !SAFE_ID.test(spec.id))
|
|
148
307
|
out.push({ path: "id", message: `expected a safe slug (lowercase letters, digits, single hyphens), found ${describe(spec.id)}` });
|
|
308
|
+
const pieces = spec.pieces;
|
|
309
|
+
const eipIconSpec = format === "icon" && Array.isArray(pieces) && pieces.length === 1 && isPlainObject(pieces[0]) && pieces[0].piece === "eipIcon";
|
|
149
310
|
for (const key of ["title", "description"]) {
|
|
150
|
-
if (
|
|
311
|
+
if (eipIconSpec) {
|
|
312
|
+
if (spec[key] !== undefined)
|
|
313
|
+
out.push({ path: key, message: EIP_TEXT });
|
|
314
|
+
}
|
|
315
|
+
else if (typeof spec[key] !== "string" || spec[key].trim() === "")
|
|
151
316
|
out.push({ path: key, message: "expected a non-empty string" });
|
|
152
317
|
}
|
|
153
|
-
if (
|
|
154
|
-
|
|
318
|
+
if (format === "icon") {
|
|
319
|
+
for (const key of ["background", "rings", "custom", "customAt", "height"])
|
|
320
|
+
if (spec[key] !== undefined)
|
|
321
|
+
out.push({ path: key, message: NOT_FOR_ICONS });
|
|
322
|
+
for (const key of ["background", "rings", "custom"])
|
|
323
|
+
if (spec[key] !== undefined)
|
|
324
|
+
blocked.push(key);
|
|
155
325
|
}
|
|
156
|
-
if (!isPlainObject(spec.rings))
|
|
157
|
-
out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
|
|
158
326
|
else {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
327
|
+
if (format === "diagram") {
|
|
328
|
+
if (spec.background !== undefined)
|
|
329
|
+
out.push({ path: "background", message: "a diagram is drawn on color.panel; background is for covers and banners" });
|
|
330
|
+
const height = spec.height;
|
|
331
|
+
if (typeof height !== "number" || !Number.isFinite(height) || height < 1 || height > MAX_SIZE)
|
|
332
|
+
out.push({ path: "height", message: `expected a number from 1 to ${MAX_SIZE}` });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
if (typeof spec.background !== "string" || !backgroundNames().includes(spec.background)) {
|
|
336
|
+
out.push({ path: "background", message: `expected one of ${backgroundNames().join(", ")}, found ${describe(spec.background)}` });
|
|
337
|
+
}
|
|
338
|
+
if (spec.height !== undefined)
|
|
339
|
+
out.push({ path: "height", message: "only a diagram takes a height" });
|
|
340
|
+
}
|
|
341
|
+
checkRings(spec, out);
|
|
342
|
+
if (!isPlainObject(spec.rings) && spec.rings !== undefined)
|
|
343
|
+
blocked.push("rings");
|
|
344
|
+
if (spec.custom !== undefined) {
|
|
345
|
+
if (Array.isArray(spec.custom)) {
|
|
346
|
+
out.push({ path: "custom", message: "one free-form object per cover; a repeated object must be a piece" });
|
|
347
|
+
blocked.push("custom");
|
|
348
|
+
}
|
|
349
|
+
else
|
|
350
|
+
checkCustom(spec.custom, "custom", out);
|
|
351
|
+
}
|
|
352
|
+
if (spec.customAt !== undefined) {
|
|
353
|
+
const at = spec.customAt;
|
|
354
|
+
if (spec.custom === undefined)
|
|
355
|
+
out.push({ path: "customAt", message: "needs custom" });
|
|
356
|
+
else if (Array.isArray(pieces)) {
|
|
357
|
+
if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at > pieces.length)
|
|
358
|
+
out.push({ path: "customAt", message: `expected an integer from 0 to ${pieces.length}` });
|
|
359
|
+
}
|
|
360
|
+
else if (typeof at !== "number" || !Number.isInteger(at) || at < 0) {
|
|
361
|
+
// Without a pieces array the count is unknown: the type is still checked.
|
|
362
|
+
out.push({ path: "customAt", message: "expected an integer from 0 to the number of pieces" });
|
|
363
|
+
}
|
|
170
364
|
}
|
|
171
365
|
}
|
|
172
|
-
if (
|
|
173
|
-
out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
|
|
174
|
-
if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "count"))
|
|
175
|
-
out.push({ path: "rings.count", message: "unknown parameter: the ring count comes from the format" });
|
|
176
|
-
if (spec.format !== undefined && (typeof spec.format !== "string" || !FORMATS.includes(spec.format))) {
|
|
177
|
-
out.push({ path: "format", message: `expected one of ${FORMATS.join(", ")}` });
|
|
178
|
-
}
|
|
179
|
-
if (!Array.isArray(spec.pieces))
|
|
366
|
+
if (!Array.isArray(pieces)) {
|
|
180
367
|
out.push({ path: "pieces", message: "missing or not an array" });
|
|
181
|
-
|
|
182
|
-
|
|
368
|
+
if (pieces !== undefined)
|
|
369
|
+
blocked.push("pieces");
|
|
370
|
+
}
|
|
371
|
+
else if (format === "icon") {
|
|
372
|
+
const entry = pieces[0];
|
|
373
|
+
if (pieces.length !== 1 || !isPlainObject(entry) || !ICON_PIECES.includes(entry.piece)) {
|
|
374
|
+
out.push({ path: "pieces", message: "an icon spec holds exactly one piece: eipIcon, uiIcon or linkIcon" });
|
|
375
|
+
blocked.push("pieces");
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const { piece, ...params } = entry;
|
|
379
|
+
for (const axis of ["x", "y"])
|
|
380
|
+
if (params[axis] !== undefined && params[axis] !== 0)
|
|
381
|
+
out.push({ path: `pieces[0].${axis}`, message: "an icon is drawn at 0, 0" });
|
|
382
|
+
checkEntry(piece, { ...params, x: 0, y: 0 }, "pieces[0]", out);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
for (let index = 0; index < pieces.length; index++) {
|
|
387
|
+
const entry = pieces[index];
|
|
183
388
|
const at = `pieces[${index}]`;
|
|
184
|
-
if (!isPlainObject(entry))
|
|
185
|
-
|
|
389
|
+
if (!isPlainObject(entry)) {
|
|
390
|
+
out.push({ path: at, message: "expected an object with piece and its parameters" });
|
|
391
|
+
blocked.push(at);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
186
394
|
const { piece, ...params } = entry;
|
|
187
395
|
if (typeof piece !== "string" || !PIECE_NAMES.includes(piece)) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return void out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
|
|
192
|
-
const definition = PIECES[piece];
|
|
193
|
-
const own = checkParams(definition.schema, params, at);
|
|
194
|
-
// A cover is a standalone image: currentColor (accepted by uiIcon for inline page icons) is refused.
|
|
195
|
-
for (const [name, spec] of Object.entries(definition.schema)) {
|
|
196
|
-
if (spec.kind === "color" && spec.currentColor && params[name] === CURRENT_COLOUR) {
|
|
197
|
-
own.push({ path: `${at}.${name}`, message: "currentColor is for icons inline in a page; a cover uses a token path" });
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
out.push(...own);
|
|
201
|
-
// Limits between parameters are reported with the schema's problems, except on a parameter
|
|
202
|
-
// already reported; a relation that cannot read a broken parameter reports nothing.
|
|
203
|
-
if (definition.relations) {
|
|
204
|
-
let related = [];
|
|
205
|
-
try {
|
|
206
|
-
related = definition.relations(params);
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
related = [];
|
|
210
|
-
}
|
|
211
|
-
const reported = new Set(own.map((problem) => problem.path));
|
|
212
|
-
out.push(...related.map((problem) => ({ path: `${at}.${problem.path}`, message: problem.message })).filter((problem) => !reported.has(problem.path)));
|
|
396
|
+
out.push({ path: `${at}.piece`, message: `unknown piece ${describe(piece)} (expected one of ${PIECE_NAMES.join(", ")})` });
|
|
397
|
+
blocked.push(at);
|
|
398
|
+
continue;
|
|
213
399
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
if (error instanceof RangeError && error.message.endsWith(": the drawing overflows"))
|
|
221
|
-
out.push({ path: at, message: "the drawing overflows" });
|
|
222
|
-
}
|
|
400
|
+
if (piece === "rings") {
|
|
401
|
+
out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
|
|
402
|
+
blocked.push(at);
|
|
403
|
+
continue;
|
|
223
404
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (Array.isArray(spec.custom))
|
|
227
|
-
out.push({ path: "custom", message: "one free-form object per cover; a repeated object must be a piece" });
|
|
228
|
-
else
|
|
229
|
-
checkCustom(spec.custom, "custom", out);
|
|
230
|
-
}
|
|
231
|
-
// A colour literal is reported once, by the literal check, not again as a bad token path.
|
|
232
|
-
// A container with the wrong shape is one problem: nothing inside it is reported.
|
|
233
|
-
const blocked = [];
|
|
234
|
-
if (!Array.isArray(spec.pieces) && spec.pieces !== undefined)
|
|
235
|
-
blocked.push("pieces");
|
|
236
|
-
if (Array.isArray(spec.pieces)) {
|
|
237
|
-
spec.pieces.forEach((entry, index) => {
|
|
238
|
-
const known = isPlainObject(entry) && typeof entry.piece === "string" && PIECE_NAMES.includes(entry.piece) && entry.piece !== "rings";
|
|
239
|
-
if (!known)
|
|
240
|
-
blocked.push(`pieces[${index}]`);
|
|
241
|
-
});
|
|
405
|
+
checkEntry(piece, params, at, out);
|
|
406
|
+
}
|
|
242
407
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
408
|
+
return { problems: out, blocked };
|
|
409
|
+
};
|
|
410
|
+
/** Under a path (the path itself included): `a` covers `a`, `a.b` and `a[0]`. */
|
|
411
|
+
const under = (path, base) => path === base || path.startsWith(`${base}.`) || path.startsWith(`${base}[`);
|
|
412
|
+
/** Sorted, one per path and message; a colour literal replaces any other problem at its path. */
|
|
413
|
+
const finish = (out) => {
|
|
249
414
|
const literalPaths = new Set(out.filter((p) => p.message.startsWith("colour literals")).map((p) => p.path));
|
|
250
415
|
const seen = new Set();
|
|
251
416
|
const unique = out.filter((p) => {
|
|
@@ -257,6 +422,26 @@ export const validateCoverSpec = (spec) => {
|
|
|
257
422
|
});
|
|
258
423
|
return unique.sort((a, b) => comparePaths(a.path, b.path) || compareCodePoints(a.message, b.message));
|
|
259
424
|
};
|
|
425
|
+
/** Every problem of a parsed spec, sorted by path; empty when the spec is valid. `lang` picks a text table. */
|
|
426
|
+
export const validateCoverSpec = (spec, options = {}) => {
|
|
427
|
+
if (!isPlainObject(spec))
|
|
428
|
+
return [{ path: "(root)", message: `a cover spec must be a JSON object, found ${Array.isArray(spec) ? "an array" : describe(spec)}` }];
|
|
429
|
+
// Text problems stop the check: references cannot be resolved, so the rest would report false problems.
|
|
430
|
+
const { table, problems: textProblems } = pickTable(spec, options.lang);
|
|
431
|
+
if (textProblems.length > 0)
|
|
432
|
+
return finish(textProblems);
|
|
433
|
+
const { spec: resolved, unknown } = table ? resolveText(spec, table) : { spec, unknown: [] };
|
|
434
|
+
const { problems, blocked } = validateResolved(resolved);
|
|
435
|
+
// A container with the wrong shape is one problem: nothing inside it is reported (its `piece` problem stays).
|
|
436
|
+
// An unknown text key replaces every other problem at its path.
|
|
437
|
+
const insideBlocked = (path) => blocked.some((b) => under(path, b) && path !== b && path !== `${b}.piece`);
|
|
438
|
+
// Unknown references inside a wrong container are not reported either: the container is one problem.
|
|
439
|
+
// A reference in an entry's `piece` names the piece: it is reported even when its container is refused.
|
|
440
|
+
const unknownKept = unknown.filter((u) => !insideBlocked(u.path) || /^pieces\[\d+\]\.piece$/.test(u.path));
|
|
441
|
+
const kept = problems.filter((p) => !insideBlocked(p.path) && !unknownKept.some((u) => under(p.path, u.path)));
|
|
442
|
+
return finish([...kept, ...unknownKept]);
|
|
443
|
+
};
|
|
444
|
+
// ---------- drawing ----------
|
|
260
445
|
const resolveCustom = (node) => {
|
|
261
446
|
const attrs = {};
|
|
262
447
|
for (const [name, value] of Object.entries(node.attrs ?? {})) {
|
|
@@ -273,28 +458,62 @@ export const bannerRingCount = (cx, cy, radius = tokens.cover.ringRadius) => {
|
|
|
273
458
|
const far = Math.max(...[[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => Math.hypot(x - cx, y - cy)));
|
|
274
459
|
return Math.max(1, Math.ceil((far - radius) / tokens.cover.ringStep) + 1);
|
|
275
460
|
};
|
|
276
|
-
/**
|
|
277
|
-
|
|
461
|
+
/** Layout units per shown pixel of a body diagram (560 / 800). */
|
|
462
|
+
const LAYOUT_SCALE = tokens.bodyDiagram.layoutWidth / tokens.bodyDiagram.width;
|
|
463
|
+
/** Rings of a diagram (spec cover-cli, "Diagram rings at the shown scale"): the cover's step and width at the shown scale. */
|
|
464
|
+
export const diagramRings = (cx, cy, height, radius = tokens.bodyDiagram.ringRadius * LAYOUT_SCALE) => {
|
|
465
|
+
const width = tokens.bodyDiagram.layoutWidth;
|
|
466
|
+
const step = tokens.cover.ringStep * LAYOUT_SCALE;
|
|
467
|
+
const far = Math.max(...[[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => Math.hypot(x - cx, y - cy)));
|
|
468
|
+
return { count: Math.max(1, Math.ceil((far - radius) / step) + 1), step, width: tokens.cover.ringWidth * LAYOUT_SCALE, radius };
|
|
469
|
+
};
|
|
470
|
+
const draw = (entry) => {
|
|
471
|
+
const { piece, ...params } = entry;
|
|
472
|
+
return PIECES[piece](params);
|
|
473
|
+
};
|
|
474
|
+
/** The outlined group's children: every body in paint order, `custom` before `pieces[customAt]` (default: last). */
|
|
475
|
+
const bodies = (spec, drawnPieces) => {
|
|
476
|
+
const custom = spec.custom ? resolveCustom(spec.custom) : null;
|
|
477
|
+
const at = spec.customAt ?? drawnPieces.length;
|
|
478
|
+
return [...drawnPieces.slice(0, at).flatMap((d) => d.body), custom, ...drawnPieces.slice(at).flatMap((d) => d.body)];
|
|
479
|
+
};
|
|
480
|
+
const root = (id, width, height, viewBox, ...children) => el("svg", { xmlns: "http://www.w3.org/2000/svg", width, height, viewBox, role: "img", "aria-labelledby": `${id}-title ${id}-desc`, "font-family": tokens.font.sans }, ...children);
|
|
481
|
+
/** An icon (spec cover-cli, "Icon output"): the piece at 0,0 in a box of its own size. */
|
|
482
|
+
const iconTree = (spec) => {
|
|
483
|
+
const entry = spec.pieces[0];
|
|
484
|
+
const [width, height] = iconBox(entry);
|
|
485
|
+
const eip = entry.piece === "eipIcon" ? EIP_ICONS.find((icon) => icon.name === entry.name) : undefined;
|
|
486
|
+
return root(spec.id, width, height, `0 0 ${width} ${height}`, el("title", { id: `${spec.id}-title` }, eip?.title ?? spec.title), el("desc", { id: `${spec.id}-desc` }, eip?.description ?? spec.description), ...draw({ ...entry, x: 0, y: 0 }).body);
|
|
487
|
+
};
|
|
488
|
+
/** A body diagram (spec cover-cli, "Diagram output"): laid out 560 wide, shown 800 wide, on the panel. */
|
|
489
|
+
const diagramTree = (spec) => {
|
|
490
|
+
const width = tokens.bodyDiagram.layoutWidth;
|
|
491
|
+
const height = spec.height;
|
|
492
|
+
const rx = r2(tokens.bodyDiagram.radius * LAYOUT_SCALE);
|
|
493
|
+
const frame = `url(#${spec.id}-frame)`;
|
|
494
|
+
const ring = diagramRings(spec.rings.cx, spec.rings.cy, height, spec.rings.radius);
|
|
495
|
+
const drawnPieces = spec.pieces.map(draw);
|
|
496
|
+
return root(spec.id, tokens.bodyDiagram.width, r2(height / LAYOUT_SCALE), `0 0 ${width} ${height}`, 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, rx }))), el("rect", { width, height, rx, fill: tokens.color.panel }), g({ "clip-path": frame }, g({ fill: "none", stroke: tokens.color.ring, "stroke-width": r2(ring.width) }, ...Array.from({ length: ring.count }, (_, i) => el("circle", { cx: spec.rings.cx, cy: spec.rings.cy, r: r2(ring.radius + i * ring.step) })))), g({ "clip-path": frame }, g({ stroke: tokens.color.ink, "stroke-width": tokens.map.outline, "stroke-linejoin": "round" }, ...bodies(spec, drawnPieces)), g({ "font-size": tokens.map.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text))));
|
|
497
|
+
};
|
|
498
|
+
/** The cover's (banner's, diagram's, icon's) node tree. The spec must be valid for `lang` (validateCoverSpec returned no problem). */
|
|
499
|
+
export const coverTree = (input, options = {}) => {
|
|
500
|
+
const spec = forLanguage(input, options.lang);
|
|
501
|
+
if (spec.format === "icon")
|
|
502
|
+
return iconTree(spec);
|
|
503
|
+
if (spec.format === "diagram")
|
|
504
|
+
return diagramTree(spec);
|
|
278
505
|
const banner = spec.format === "banner";
|
|
279
506
|
const { width, height } = banner ? tokens.banner : tokens.cover;
|
|
280
507
|
// A banner's corners are rounded; a cover keeps today's square frame, attribute for attribute.
|
|
281
508
|
const corner = banner ? { rx: tokens.banner.radius } : {};
|
|
282
509
|
const pair = tokens.coverBackground[spec.background];
|
|
283
|
-
const drawnPieces = spec.pieces.map(
|
|
510
|
+
const drawnPieces = spec.pieces.map(draw);
|
|
284
511
|
const ringPath = `coverBackground.${spec.background}.ring`;
|
|
285
512
|
const ringCount = banner ? { count: bannerRingCount(spec.rings.cx, spec.rings.cy, spec.rings.radius) } : {};
|
|
286
513
|
const frame = `url(#${spec.id}-frame)`;
|
|
287
|
-
const outlined = g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...
|
|
514
|
+
const outlined = g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...bodies(spec, drawnPieces));
|
|
288
515
|
const labels = g({ "font-size": tokens.cover.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text));
|
|
289
|
-
return el("
|
|
290
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
291
|
-
width,
|
|
292
|
-
height,
|
|
293
|
-
viewBox: `0 0 ${width} ${height}`,
|
|
294
|
-
role: "img",
|
|
295
|
-
"aria-labelledby": `${spec.id}-title ${spec.id}-desc`,
|
|
296
|
-
"font-family": tokens.font.sans,
|
|
297
|
-
}, 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, ...corner }))), el("rect", { width, height, ...corner, fill: pair.fill }), g({ "clip-path": frame }, ...rings({ ...spec.rings, color: ringPath, ...ringCount }).body),
|
|
516
|
+
return root(spec.id, width, height, `0 0 ${width} ${height}`, 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, ...corner }))), el("rect", { width, height, ...corner, fill: pair.fill }), g({ "clip-path": frame }, ...rings({ ...spec.rings, color: ringPath, ...ringCount }).body),
|
|
298
517
|
// On a banner, pieces and labels are clipped too, so nothing pokes past a rounded corner.
|
|
299
518
|
...(banner ? [g({ "clip-path": frame }, outlined, labels)] : [outlined, labels]));
|
|
300
519
|
};
|
|
@@ -101,6 +101,15 @@ export type EdgeLabelParams = {
|
|
|
101
101
|
};
|
|
102
102
|
/** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
|
|
103
103
|
export declare const edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
|
|
104
|
+
export type StateStartParams = {
|
|
105
|
+
cx: number;
|
|
106
|
+
cy: number;
|
|
107
|
+
r?: number;
|
|
108
|
+
surface?: SurfaceName;
|
|
109
|
+
};
|
|
110
|
+
/** The initial state of a state machine (change designer-formats, design §4): a filled dot in the surface's text
|
|
111
|
+
* colour, no outline (its own stroke none, so a cover's outlined group does not grow it), no shadow, no text. */
|
|
112
|
+
export declare const stateStart: import("./piece.ts").Piece<StateStartParams>;
|
|
104
113
|
/** The body diagram pieces, in catalogue order (design §3). */
|
|
105
114
|
export declare const DIAGRAM_PIECES: {
|
|
106
115
|
readonly diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
|
|
@@ -194,5 +194,12 @@ export const edgeLabel = definePiece("edgeLabel", { x: pos(), y: pos(), lines: l
|
|
|
194
194
|
: [];
|
|
195
195
|
return drawn(body, rows.map((row, i) => words(x, y + i * 13 * k, row, { fill: s.text, "font-size": size_, "font-weight": bold, "text-anchor": anchor })));
|
|
196
196
|
});
|
|
197
|
+
/** Default start radius in layout units: the published dot (bodyDiagram.startRadius at the shown width) laid out at 560. */
|
|
198
|
+
const START_RADIUS = (tokens.bodyDiagram.startRadius * tokens.bodyDiagram.layoutWidth) / tokens.bodyDiagram.width;
|
|
199
|
+
/** The initial state of a state machine (change designer-formats, design §4): a filled dot in the surface's text
|
|
200
|
+
* colour, no outline (its own stroke none, so a cover's outlined group does not grow it), no shadow, no text. */
|
|
201
|
+
export const stateStart = definePiece("stateStart", { cx: pos(), cy: pos(), r: size(), surface: surfaceParam }, ({ cx, cy, r = START_RADIUS, surface = "light" }) =>
|
|
202
|
+
// The radius is written as given (a size, never rounded away); the centre is a position.
|
|
203
|
+
drawn([el("circle", { cx: r2(cx), cy: r2(cy), r, fill: tokens.surface[surface].text, stroke: "none" })]));
|
|
197
204
|
/** The body diagram pieces, in catalogue order (design §3). */
|
|
198
205
|
export const DIAGRAM_PIECES = { diagramBox, decisionNode, storeNode, queueNode, exchangeNode, taggedContainer, lifeline, phaseBand, altFrame, edgeLabel };
|
|
@@ -5,5 +5,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
|
|
|
5
5
|
export { definePiece, type Piece } from "./piece.ts";
|
|
6
6
|
export * from "./library.ts";
|
|
7
7
|
export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH, type EipIcon } from "./eip-icons.ts";
|
|
8
|
-
export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec, type CoverFormat, type CoverPiece, type CoverSpec, type CustomNode } from "./cover.ts";
|
|
8
|
+
export { FORMATS, LANGUAGE, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, diagramRings, validateCoverSpec, type CoverFormat, type CoverOptions, type CoverPiece, type CoverRings, type CoverSpec, type CustomNode } from "./cover.ts";
|
|
9
9
|
export { tokens, type Tokens } from "../../tokens/generated/tokens.ts";
|
package/dist/src/pieces/index.js
CHANGED
|
@@ -10,5 +10,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
|
|
|
10
10
|
export { definePiece } from "./piece.js";
|
|
11
11
|
export * from "./library.js";
|
|
12
12
|
export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH } from "./eip-icons.js";
|
|
13
|
-
export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec } from "./cover.js";
|
|
13
|
+
export { FORMATS, LANGUAGE, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, diagramRings, validateCoverSpec } from "./cover.js";
|
|
14
14
|
export { tokens } from "../../tokens/generated/tokens.js";
|
|
@@ -325,6 +325,7 @@ export declare const PIECES: {
|
|
|
325
325
|
readonly eipIcon: import("./piece.ts").Piece<EipIconParams>;
|
|
326
326
|
readonly uiIcon: import("./piece.ts").Piece<import("./ui-icons.ts").UiIconParams>;
|
|
327
327
|
readonly linkIcon: import("./piece.ts").Piece<import("./link-icons.ts").LinkIconParams>;
|
|
328
|
+
readonly stateStart: import("./piece.ts").Piece<import("./diagram.ts").StateStartParams>;
|
|
328
329
|
};
|
|
329
330
|
export type PieceName = keyof typeof PIECES;
|
|
330
331
|
export declare const PIECE_NAMES: PieceName[];
|
|
@@ -12,7 +12,7 @@ import { drawn, el, g, r2, shadowed } from "./node.js";
|
|
|
12
12
|
import { col, colour, count, label, MAX_SIZE, PATH_DATA, pos, size } from "./params.js";
|
|
13
13
|
import { definePiece } from "./piece.js";
|
|
14
14
|
import { METRO_PIECES } from "./metro.js";
|
|
15
|
-
import { DIAGRAM_PIECES } from "./diagram.js";
|
|
15
|
+
import { DIAGRAM_PIECES, stateStart } from "./diagram.js";
|
|
16
16
|
import { uiIcon } from "./ui-icons.js";
|
|
17
17
|
import { linkIcon } from "./link-icons.js";
|
|
18
18
|
export * from "./metro.js";
|
|
@@ -384,5 +384,6 @@ export const PIECES = {
|
|
|
384
384
|
uiIcon,
|
|
385
385
|
linkIcon,
|
|
386
386
|
...DIAGRAM_PIECES,
|
|
387
|
+
stateStart,
|
|
387
388
|
};
|
|
388
389
|
export const PIECE_NAMES = Object.keys(PIECES);
|
|
@@ -4,6 +4,13 @@ export declare const tokens: {
|
|
|
4
4
|
readonly radius: 20;
|
|
5
5
|
readonly width: 1200;
|
|
6
6
|
};
|
|
7
|
+
readonly bodyDiagram: {
|
|
8
|
+
readonly layoutWidth: 560;
|
|
9
|
+
readonly radius: 14;
|
|
10
|
+
readonly ringRadius: 70;
|
|
11
|
+
readonly startRadius: 11;
|
|
12
|
+
readonly width: 800;
|
|
13
|
+
};
|
|
7
14
|
readonly color: {
|
|
8
15
|
readonly backend: "#FF6B6B";
|
|
9
16
|
readonly blocked: "#FF8A3D";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brunosouzas/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Design tokens and illustration pieces of brunosouzas.com: tokens as CSS, JSON and TypeScript, pieces that draw SVG (and React), and a cover command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
--ds-banner-height: 420px;
|
|
4
4
|
--ds-banner-radius: 20px;
|
|
5
5
|
--ds-banner-width: 1200px;
|
|
6
|
+
--ds-body-diagram-layout-width: 560px;
|
|
7
|
+
--ds-body-diagram-radius: 14px;
|
|
8
|
+
--ds-body-diagram-ring-radius: 70px;
|
|
9
|
+
--ds-body-diagram-start-radius: 11px;
|
|
10
|
+
--ds-body-diagram-width: 800px;
|
|
6
11
|
--ds-color-backend: #FF6B6B;
|
|
7
12
|
--ds-color-blocked: #FF8A3D;
|
|
8
13
|
--ds-color-caller: #8FB2FF;
|