justskill-design 0.0.1 → 3.2.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.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/lib/tokens.mjs
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design tokens: the one place that knows what a token name means.
|
|
3
|
+
*
|
|
4
|
+
* A token lives in the `:root{}` of a `<style>` block, which makes it the only part
|
|
5
|
+
* of a design file that is not an element. Nothing can address it by `data-id`, so
|
|
6
|
+
* every rule about it — the seven groups, the name shape, the unit a numeric group
|
|
7
|
+
* carries, the reference chain — has to be written down somewhere, and this is that
|
|
8
|
+
* place. The alternative is what the tree already suffered once: the same rule
|
|
9
|
+
* written twice, drifting apart.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure and browser-loadable: no `node:*`, no `process`. It reads a
|
|
12
|
+
* parsed document and answers questions. It does not write; the writing is an op
|
|
13
|
+
* (`set-token` / `unset-token` in `lib/ops.mjs`), because every change in this tool
|
|
14
|
+
* goes through one funnel.
|
|
15
|
+
*
|
|
16
|
+
* What a token is, per `docs/format-v3.md` §4.2 and `docs/plan.md` §4.2:
|
|
17
|
+
*
|
|
18
|
+
* - `:root{ --color-accent:#2563eb }` inside a `<style>` in `<head>`.
|
|
19
|
+
* - The name is `--<group>-<name>`, and the group is one of seven.
|
|
20
|
+
* - `space`, `radius` and `size` carry a unit. `color` is a color. `font`,
|
|
21
|
+
* `shadow` and `border` are free-form CSS values, and this module does not
|
|
22
|
+
* invent a rule for them.
|
|
23
|
+
* - A token may reference a token. The chain stops at depth 16, and a cycle is
|
|
24
|
+
* refused rather than followed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
TOKEN_GROUPS,
|
|
29
|
+
TOKEN_GROUP_UNIT,
|
|
30
|
+
TOKEN_NAME_PATTERN,
|
|
31
|
+
TableError,
|
|
32
|
+
normalizeColor,
|
|
33
|
+
normalizeLength,
|
|
34
|
+
} from "./table.mjs";
|
|
35
|
+
|
|
36
|
+
/** How far a `var(--a)` chain may be followed before it is left as written. */
|
|
37
|
+
export const TOKEN_MAX_DEPTH = 16;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Replace every `var(--name)` in a CSS value with the token's value, and follow
|
|
41
|
+
* the chain. A name that the map does not hold stays as written, unless the
|
|
42
|
+
* declaration carries a fallback, which CSS would use in that case.
|
|
43
|
+
*
|
|
44
|
+
* This belongs here, with the depth limit and the cycle rule it obeys. It used to
|
|
45
|
+
* live in `lib/render.mjs`, and `export-html.mjs` then grew its own copy with no
|
|
46
|
+
* depth limit at all: a token cycle emptied the stack during `export` while
|
|
47
|
+
* `validate` called the same file valid.
|
|
48
|
+
*
|
|
49
|
+
* `seen` holds the names on the current chain. A repeat leaves the reference as
|
|
50
|
+
* written, so a cycle cannot recurse.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveTokens(cssValue, tokens, depth = 0, seen = null) {
|
|
53
|
+
if (depth > TOKEN_MAX_DEPTH) return cssValue;
|
|
54
|
+
const text = String(cssValue ?? "");
|
|
55
|
+
if (!text.includes("var(")) return text;
|
|
56
|
+
return text.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => {
|
|
57
|
+
const token = tokens.get(name);
|
|
58
|
+
if (!token) return fallback !== undefined ? fallback.trim() : whole;
|
|
59
|
+
if (seen && seen.has(name)) return fallback !== undefined ? fallback.trim() : whole;
|
|
60
|
+
const next = seen ? new Set(seen) : new Set();
|
|
61
|
+
next.add(name);
|
|
62
|
+
return resolveTokens(token.value, tokens, depth + 1, next);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `var(--name)` or `var(--name, fallback)`. The fallback may itself hold a `var()`. */
|
|
67
|
+
const TOKEN_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
|
68
|
+
|
|
69
|
+
/** The group of a token name: the text between `--` and the first hyphen. */
|
|
70
|
+
export function groupOfToken(name) {
|
|
71
|
+
return String(name ?? "").slice(2).split("-")[0];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What is wrong with a token name, or `null` when nothing is.
|
|
76
|
+
*
|
|
77
|
+
* One function, because there are two callers and they must not disagree: the
|
|
78
|
+
* validator, which reports a name written in a file, and `resolveTokenName`, which
|
|
79
|
+
* refuses a name typed on the command line.
|
|
80
|
+
*
|
|
81
|
+
* The rule has two halves and only one of them used to be checked.
|
|
82
|
+
* `references/schema.md` — the page the skill hands a model — says the group "must be
|
|
83
|
+
* one of" the seven, and the groups are a closed set in `plan.md` §4.2. The check was
|
|
84
|
+
* the shape alone, so `--accent-500` passed validation while `tokens set accent.500`
|
|
85
|
+
* was refused: the file was called valid and its tokens were unaddressable, with
|
|
86
|
+
* nothing saying so. A test named "a token name outside the seven groups is reported"
|
|
87
|
+
* passed only because its sample, `--brand`, happened to break the shape too.
|
|
88
|
+
*
|
|
89
|
+
* @returns {{group:string, message:string, hint:string}|null}
|
|
90
|
+
*/
|
|
91
|
+
export function tokenNameProblem(name) {
|
|
92
|
+
const raw = String(name ?? "");
|
|
93
|
+
const group = groupOfToken(raw);
|
|
94
|
+
const known = TOKEN_GROUPS.includes(group);
|
|
95
|
+
if (TOKEN_NAME_PATTERN.test(raw) && known) return null;
|
|
96
|
+
if (!TOKEN_NAME_PATTERN.test(raw) && known) {
|
|
97
|
+
return {
|
|
98
|
+
group,
|
|
99
|
+
message: `The token ${raw} does not follow --<group>-<name>.`,
|
|
100
|
+
hint: `The group is ${group}. Add a name after it, such as --${group}-base.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!TOKEN_NAME_PATTERN.test(raw)) {
|
|
104
|
+
return {
|
|
105
|
+
group,
|
|
106
|
+
message: `The token ${raw} does not follow --<group>-<name>.`,
|
|
107
|
+
hint: `The group must be one of: ${TOKEN_GROUPS.join(", ")}.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
group,
|
|
112
|
+
message: `The token ${raw} uses the group ${group}, which is not one of the ${TOKEN_GROUPS.length}.`,
|
|
113
|
+
hint: `The groups are: ${TOKEN_GROUPS.join(", ")}.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A token name as it is written in the file, from either spelling.
|
|
119
|
+
*
|
|
120
|
+
* The file spells it `--color-accent`. The CLI spells it `color.accent`, because
|
|
121
|
+
* `docs/plan.md` §5.1 documents `tokens set <组.名> <值>` and because a leading `--`
|
|
122
|
+
* reads as a flag in a shell. Both are accepted here and produce the one name the
|
|
123
|
+
* file uses, so neither spelling can grow its own behaviour.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} spec `color.accent` or `--color-accent`
|
|
126
|
+
* @returns {string} `--color-accent`
|
|
127
|
+
* @throws {TableError} `style/token-name` for a group that is not one of the seven
|
|
128
|
+
*/
|
|
129
|
+
export function resolveTokenName(spec) {
|
|
130
|
+
const raw = String(spec ?? "").trim();
|
|
131
|
+
const groups = `Write <group>.<name>, such as color.accent. The groups are: ${TOKEN_GROUPS.join(", ")}.`;
|
|
132
|
+
if (raw === "") throw new TableError("style/token-name", "A token name is required.", { hint: groups });
|
|
133
|
+
|
|
134
|
+
if (raw.startsWith("--")) {
|
|
135
|
+
const problem = tokenNameProblem(raw);
|
|
136
|
+
if (problem !== null) throw new TableError("style/token-name", problem.message, { hint: problem.hint });
|
|
137
|
+
return raw;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const dot = raw.indexOf(".");
|
|
141
|
+
if (dot === -1) {
|
|
142
|
+
throw new TableError("style/token-name", `The token ${raw} does not name a group.`, { hint: groups });
|
|
143
|
+
}
|
|
144
|
+
const group = raw.slice(0, dot);
|
|
145
|
+
if (!TOKEN_GROUPS.includes(group)) {
|
|
146
|
+
throw new TableError("style/token-name", `${group} is not a token group.`, {
|
|
147
|
+
hint: `The group must be one of: ${TOKEN_GROUPS.join(", ")}.`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const name = `--${group}-${raw.slice(dot + 1)}`;
|
|
151
|
+
const problem = tokenNameProblem(name);
|
|
152
|
+
if (problem !== null) throw new TableError("style/token-name", problem.message, { hint: problem.hint });
|
|
153
|
+
return name;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The value a token declaration should carry.
|
|
158
|
+
*
|
|
159
|
+
* Only the two rules the contract states are enforced, because a rule that is not
|
|
160
|
+
* written down cannot be enforced by surprise:
|
|
161
|
+
*
|
|
162
|
+
* - a numeric group carries a unit, so a bare `16` becomes `16px` and a value
|
|
163
|
+
* that is not a length is refused. Storing `16` would produce `var(--space-md)`
|
|
164
|
+
* → `16`, which is invalid CSS, so the page would silently lose the property.
|
|
165
|
+
* - a color is normalized to the hex the rest of the file uses.
|
|
166
|
+
*
|
|
167
|
+
* `font`, `shadow` and `border` are written as given. `docs/format-v3.md` §4.2 sets
|
|
168
|
+
* no rule for them, and a normalizer invented here would be a rule nobody asked for.
|
|
169
|
+
*
|
|
170
|
+
* @returns {{value:string, changed:boolean}}
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeTokenValue(group, value) {
|
|
173
|
+
const raw = String(value ?? "").trim();
|
|
174
|
+
if (raw === "") throw new TableError("style/empty-value", "A token needs a value.", { hint: "--space-md:16px" });
|
|
175
|
+
const unit = TOKEN_GROUP_UNIT[group];
|
|
176
|
+
if (unit === null || unit === undefined) {
|
|
177
|
+
if (group !== "color") return { value: raw, changed: false };
|
|
178
|
+
const color = normalizeColor(raw);
|
|
179
|
+
return { value: color, changed: color !== raw };
|
|
180
|
+
}
|
|
181
|
+
const normalized = normalizeLength(raw);
|
|
182
|
+
return { value: normalized, changed: normalized !== raw };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Every token name a value references, in order. `var(--a, var(--b))` names both. */
|
|
186
|
+
export function tokenReferenceNames(value) {
|
|
187
|
+
const text = String(value ?? "");
|
|
188
|
+
if (!text.includes("var(")) return [];
|
|
189
|
+
const out = [];
|
|
190
|
+
TOKEN_REFERENCE.lastIndex = 0;
|
|
191
|
+
let m;
|
|
192
|
+
while ((m = TOKEN_REFERENCE.exec(text)) !== null) out.push(m[1]);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every token in the document, and where it is written.
|
|
198
|
+
*
|
|
199
|
+
* One site per name: the first declaration wins the slot, and a later declaration of
|
|
200
|
+
* the same name is reported instead of quietly replacing it. `format-v3.md` §4.1
|
|
201
|
+
* says both style blocks are collected and that the later block must not overwrite
|
|
202
|
+
* the earlier one, so a name declared twice is a conflict the caller has to resolve,
|
|
203
|
+
* not a value this function gets to choose.
|
|
204
|
+
*
|
|
205
|
+
* `offset` is a **file** offset: the block's text starts at `block.textStart`, and a
|
|
206
|
+
* declaration's `start` is relative to that text. Both live here so the addition
|
|
207
|
+
* happens once.
|
|
208
|
+
*
|
|
209
|
+
* @returns {{sites:Map<string,object>, duplicates:Array<{name:string, offsets:number[]}>, blocks:object[]}}
|
|
210
|
+
*/
|
|
211
|
+
export function tokenSites(doc) {
|
|
212
|
+
const sites = new Map();
|
|
213
|
+
const duplicates = [];
|
|
214
|
+
const blocks = [];
|
|
215
|
+
for (const block of doc?.styleBlocks || []) {
|
|
216
|
+
// `inHead` is part of the test because format-v3 §4.1 defines a token block that
|
|
217
|
+
// way ("`<style>` 在 `<head>` 内、内容含 `:root{` 时是 `tokens`"). A `<style>` in
|
|
218
|
+
// `<body>` is a `style/style-in-body` error, and reading tokens out of a block the
|
|
219
|
+
// contract does not recognise would let a broken file look like a working one.
|
|
220
|
+
if (block.inHead !== true) continue;
|
|
221
|
+
if (block.role !== "tokens" && block.role !== "tokens+page-frame") continue;
|
|
222
|
+
blocks.push(block);
|
|
223
|
+
for (const declaration of block.declarations || []) {
|
|
224
|
+
if (!declaration.property.startsWith("--")) continue;
|
|
225
|
+
const offset = (block.textStart ?? 0) + declaration.start;
|
|
226
|
+
const known = sites.get(declaration.property);
|
|
227
|
+
if (known) {
|
|
228
|
+
known.duplicates.push(offset);
|
|
229
|
+
const reported = duplicates.find((entry) => entry.name === declaration.property);
|
|
230
|
+
if (reported) reported.offsets.push(offset);
|
|
231
|
+
else duplicates.push({ name: declaration.property, offsets: [known.offset, offset] });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
sites.set(declaration.property, {
|
|
235
|
+
name: declaration.property,
|
|
236
|
+
value: declaration.value,
|
|
237
|
+
offset,
|
|
238
|
+
block,
|
|
239
|
+
declaration,
|
|
240
|
+
duplicates: [],
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { sites, duplicates, blocks };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Token reference cycles, each reported once.
|
|
249
|
+
*
|
|
250
|
+
* A cycle is not an edge case: `--a:var(--b); --b:var(--a)` makes any consumer that
|
|
251
|
+
* follows the chain recurse until the stack dies — that is what
|
|
252
|
+
* `export --resolve-tokens` did, with `REFUSED Maximum call stack size exceeded` as
|
|
253
|
+
* the whole report, while `validate` called the same file valid. `docs/plan.md` §4.2
|
|
254
|
+
* already promised "循环被拒绝"; this is where the promise is kept.
|
|
255
|
+
*
|
|
256
|
+
* Depth-first with three colours, so a name is walked once and every distinct cycle
|
|
257
|
+
* is reported from its own entry point. A self-reference is a cycle of one.
|
|
258
|
+
*
|
|
259
|
+
* @returns {Array<{names:string[], offsets:number[]}>}
|
|
260
|
+
*/
|
|
261
|
+
export function findTokenCycles(sites) {
|
|
262
|
+
const WHITE = 0;
|
|
263
|
+
const GREY = 1;
|
|
264
|
+
const BLACK = 2;
|
|
265
|
+
const colour = new Map();
|
|
266
|
+
const stack = [];
|
|
267
|
+
const found = [];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
|
|
270
|
+
const visit = (name) => {
|
|
271
|
+
const state = colour.get(name) || WHITE;
|
|
272
|
+
if (state === BLACK) return;
|
|
273
|
+
if (state === GREY) {
|
|
274
|
+
const at = stack.indexOf(name);
|
|
275
|
+
const names = stack.slice(at);
|
|
276
|
+
const key = [...names].sort().join("|");
|
|
277
|
+
if (!seen.has(key)) {
|
|
278
|
+
seen.add(key);
|
|
279
|
+
found.push({ names, offsets: names.map((n) => sites.get(n)?.offset).filter((o) => o !== undefined) });
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
colour.set(name, GREY);
|
|
284
|
+
stack.push(name);
|
|
285
|
+
const site = sites.get(name);
|
|
286
|
+
for (const referenced of tokenReferenceNames(site?.value)) {
|
|
287
|
+
if (sites.has(referenced)) visit(referenced);
|
|
288
|
+
}
|
|
289
|
+
stack.pop();
|
|
290
|
+
colour.set(name, BLACK);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (const name of sites.keys()) visit(name);
|
|
294
|
+
return found;
|
|
295
|
+
}
|