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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs set <file> <data-id> [--style 'a:b;c:d'] [--text T] [--attr name=value]`
|
|
3
|
+
*
|
|
4
|
+
* One op per declaration, per attribute and per text change. Each op is one
|
|
5
|
+
* undo step and one byte range, which is what makes an edit reviewable and
|
|
6
|
+
* cheap to reverse.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
10
|
+
import { applyAndWrite, refusedReceipt, reportRefusal, reportWrite, styleOps, canonicalStyleOp } from "../write-verbs.mjs";
|
|
11
|
+
import { normalizeValue, CSS_ALIASES, CSS_SET } from "../../table.mjs";
|
|
12
|
+
import { shorthandHint } from "../../validate.mjs";
|
|
13
|
+
|
|
14
|
+
export const USAGE = `Usage: node design.mjs set <file> <data-id> [options]
|
|
15
|
+
|
|
16
|
+
--style 'a:b;c:d' set style declarations, one op each
|
|
17
|
+
--text <value> replace the element's text
|
|
18
|
+
--attr name=value set an attribute (repeatable)
|
|
19
|
+
--unset name remove an attribute or a style declaration (repeatable)
|
|
20
|
+
--force write even when the edit introduces a new error
|
|
21
|
+
--dry-run report what would change, write nothing
|
|
22
|
+
--json print the receipt as JSON
|
|
23
|
+
--quiet print nothing
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 changed, 1 refused, 2 unreadable or bad arguments.
|
|
26
|
+
|
|
27
|
+
The data-id is the address. To change an id, use \`rename\`, which also updates
|
|
28
|
+
every reference to it.`;
|
|
29
|
+
|
|
30
|
+
export default async function run(argv) {
|
|
31
|
+
const { values, positionals } = parseArgs(argv, {
|
|
32
|
+
flags: {
|
|
33
|
+
...SHARED_FLAGS,
|
|
34
|
+
style: { type: "string" },
|
|
35
|
+
text: { type: "string" },
|
|
36
|
+
attr: { type: "list" },
|
|
37
|
+
unset: { type: "list" },
|
|
38
|
+
},
|
|
39
|
+
positionals: { min: 2, max: 2 },
|
|
40
|
+
usage: USAGE,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (values.help) {
|
|
44
|
+
out(USAGE);
|
|
45
|
+
return EXIT_OK;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [file, target] = positionals;
|
|
49
|
+
const loaded = await readDesignFile(file);
|
|
50
|
+
|
|
51
|
+
const ops = [];
|
|
52
|
+
try {
|
|
53
|
+
if (values.style !== undefined) ops.push(...styleOps(target, values.style, normalizeValue));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// A property the format does not use is a refusal, not a crash: report it
|
|
56
|
+
// the same way the funnel reports one, so the shape is identical. The hint
|
|
57
|
+
// is generated from the value, so `margin-top:16px` is told to write
|
|
58
|
+
// `margin: 16px 0 0 0` and not a fixed example.
|
|
59
|
+
if (error.property && error.hint === undefined) {
|
|
60
|
+
error.hint = shorthandHint(error.property, error.value) ?? null;
|
|
61
|
+
}
|
|
62
|
+
const receipt = {
|
|
63
|
+
ok: false, verb: "set", applied: [], added: [], normalized: [], dropped: [],
|
|
64
|
+
diagnostics: error.hint ? [{ severity: "error", code: error.code, message: error.message, hint: error.hint }] : [],
|
|
65
|
+
revision: null, changed: false,
|
|
66
|
+
refused: { code: error.code ?? "cli/bad-style", message: error.message, hint: error.hint ?? null },
|
|
67
|
+
};
|
|
68
|
+
if (values.json) out(JSON.stringify(receipt));
|
|
69
|
+
else {
|
|
70
|
+
out([`REFUSED ${error.message}`, error.hint ? ` ${error.hint}` : null, `${file} was not changed.`].filter(Boolean).join("\n"));
|
|
71
|
+
}
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
if (values.text !== undefined) ops.push({ op: "set", target, prop: "text", value: values.text });
|
|
75
|
+
|
|
76
|
+
for (const pair of asList(values.attr)) {
|
|
77
|
+
const equals = String(pair).indexOf("=");
|
|
78
|
+
if (equals === -1) {
|
|
79
|
+
out(`REFUSED --attr wants name=value, and got ${JSON.stringify(pair)}`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
const name = String(pair).slice(0, equals).trim();
|
|
83
|
+
// An id is an address, not a property: `set` edits what a node is, and this
|
|
84
|
+
// attribute would change what it is called. It refuses and names the verb that
|
|
85
|
+
// owns an address. Case-insensitive because `applySet` lowercases before it
|
|
86
|
+
// compares.
|
|
87
|
+
if (name.toLowerCase() === "data-id") {
|
|
88
|
+
const receipt = refusedReceipt("set", "data-id is the address, so `set` cannot change it.", {
|
|
89
|
+
code: "cli/data-id-is-the-address",
|
|
90
|
+
diagnostics: [],
|
|
91
|
+
hint: `Use \`node design.mjs rename ${file} <old-id> <new-id>\`.`,
|
|
92
|
+
});
|
|
93
|
+
reportRefusal(receipt, values, file);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
ops.push({ op: "set", target, prop: name, value: String(pair).slice(equals + 1) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const name of asList(values.unset)) {
|
|
100
|
+
const text = String(name);
|
|
101
|
+
if (text.toLowerCase() === "data-id") {
|
|
102
|
+
const receipt = refusedReceipt("set", "data-id is the address, so `set --unset` cannot remove it.", {
|
|
103
|
+
code: "cli/data-id-is-the-address",
|
|
104
|
+
diagnostics: [],
|
|
105
|
+
hint: `Use \`node design.mjs rename ${file} <old-id> <new-id>\` to give it a different one.`,
|
|
106
|
+
});
|
|
107
|
+
reportRefusal(receipt, values, file);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
if (isCssProperty(text)) {
|
|
111
|
+
// The same alias rule applies to an unset, or removing `background` would
|
|
112
|
+
// leave the `background-color` it was meant to remove.
|
|
113
|
+
const property = CSS_ALIASES[text.toLowerCase()] ?? text.toLowerCase();
|
|
114
|
+
ops.push({ op: "unset", target, prop: `style:${property}` });
|
|
115
|
+
} else {
|
|
116
|
+
ops.push({ op: "unset", target, prop: text });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (ops.length === 0) {
|
|
121
|
+
out(`REFUSED Nothing to set. Use --style, --text, --attr or --unset.`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const receipt = await applyAndWrite(loaded, ops, { values, verb: "set" });
|
|
126
|
+
if (receipt.refused) return 1;
|
|
127
|
+
reportWrite(receipt, values, file);
|
|
128
|
+
return EXIT_OK;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function asList(value) {
|
|
132
|
+
if (value === undefined || value === null) return [];
|
|
133
|
+
return Array.isArray(value) ? value : [value];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A bare `--unset background-color` is a style declaration, and a bare
|
|
138
|
+
* `--unset data-name` is an attribute. Style properties have no `data-` prefix,
|
|
139
|
+
* which is the whole difference.
|
|
140
|
+
*
|
|
141
|
+
* The table decides, not the spelling. "Has a hyphen" reads well until
|
|
142
|
+
* `--unset top`, `--unset width` or `--unset display`: those are single words,
|
|
143
|
+
* so the old rule called them attributes and refused the edit with `has no
|
|
144
|
+
* "top" attribute` — for a declaration the file was carrying.
|
|
145
|
+
*/
|
|
146
|
+
function isCssProperty(name) {
|
|
147
|
+
const text = String(name).toLowerCase();
|
|
148
|
+
if (text.startsWith("data-")) return false;
|
|
149
|
+
return CSS_SET.has(text) || Object.hasOwn(CSS_ALIASES, text);
|
|
150
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs show <file> <data-id> [--json]` — one element, in full.
|
|
3
|
+
*
|
|
4
|
+
* Prints the element's open tag exactly as the file has it, its attribute list,
|
|
5
|
+
* its text, and how many element children it has.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
9
|
+
import { attrValue, collectElements } from "../../diagnostics.mjs";
|
|
10
|
+
import { byteOffsetToLineCol } from "../../spans.mjs";
|
|
11
|
+
|
|
12
|
+
export const USAGE = `Usage: node design.mjs show <file> <data-id> [options]
|
|
13
|
+
|
|
14
|
+
--json print one line of JSON instead of the human report
|
|
15
|
+
--quiet print nothing (with --json it still prints the payload)
|
|
16
|
+
--help print this usage
|
|
17
|
+
|
|
18
|
+
Exit codes: 0 shown, 1 no such data-id, 2 unreadable.`;
|
|
19
|
+
|
|
20
|
+
function isElement(node) {
|
|
21
|
+
if (!node || typeof node !== "object") return false;
|
|
22
|
+
if (node.kind === "element") return true;
|
|
23
|
+
return typeof node.tag === "string" && Array.isArray(node.attrs);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The element's own text, without the indentation that surrounds its children. */
|
|
27
|
+
function textOf(node) {
|
|
28
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
29
|
+
const text = children
|
|
30
|
+
.filter((child) => child && child.kind === "text")
|
|
31
|
+
.map((child) => (typeof child.value === "string" ? child.value : typeof child.raw === "string" ? child.raw : ""))
|
|
32
|
+
.join("");
|
|
33
|
+
return text.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The data `show` prints, for one element.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} node
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @returns {object}
|
|
42
|
+
*/
|
|
43
|
+
export function describeNode(node, source) {
|
|
44
|
+
const start = Number.isFinite(node.start) ? node.start : 0;
|
|
45
|
+
const end = Number.isFinite(node.end) ? node.end : start;
|
|
46
|
+
const openStart = Number.isFinite(node.openStart) ? node.openStart : start;
|
|
47
|
+
const openEnd = Number.isFinite(node.openEnd) ? node.openEnd : end;
|
|
48
|
+
const text = String(source === undefined || source === null ? "" : source);
|
|
49
|
+
const at = byteOffsetToLineCol(text, start);
|
|
50
|
+
|
|
51
|
+
const attrs = (Array.isArray(node.attrs) ? node.attrs : []).map((attr) => {
|
|
52
|
+
const attrStart = Number.isFinite(attr.attrStart) ? attr.attrStart : Number.isFinite(attr.start) ? attr.start : 0;
|
|
53
|
+
const attrEnd = Number.isFinite(attr.end) ? attr.end : attrStart;
|
|
54
|
+
return {
|
|
55
|
+
name: typeof attr.name === "string" ? attr.name : String(attr.rawName || ""),
|
|
56
|
+
rawName: typeof attr.rawName === "string" ? attr.rawName : String(attr.name || ""),
|
|
57
|
+
value: attr.value === undefined || attr.value === null ? "" : String(attr.value),
|
|
58
|
+
quote: typeof attr.quote === "string" ? attr.quote : '"',
|
|
59
|
+
raw: text.slice(attrStart, attrEnd),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const children = (Array.isArray(node.children) ? node.children : []).filter(isElement);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
tag: typeof node.tag === "string" ? node.tag : "",
|
|
67
|
+
rawTag: typeof node.rawTag === "string" ? node.rawTag : typeof node.tag === "string" ? node.tag : "",
|
|
68
|
+
dataId: attrValue(node, "data-id"),
|
|
69
|
+
dataType: attrValue(node, "data-type"),
|
|
70
|
+
name: attrValue(node, "data-name"),
|
|
71
|
+
openTag: text.slice(openStart, openEnd),
|
|
72
|
+
attrs,
|
|
73
|
+
text: textOf(node),
|
|
74
|
+
childCount: children.length,
|
|
75
|
+
start,
|
|
76
|
+
end,
|
|
77
|
+
line: at.line,
|
|
78
|
+
column: at.column,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function humanReport(file, dataId, described) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
lines.push(`${dataId} <${described.tag}>${described.dataType ? ` ${described.dataType}` : ""}`);
|
|
85
|
+
lines.push(`open tag ${described.openTag}`);
|
|
86
|
+
lines.push(`attributes ${described.attrs.length}`);
|
|
87
|
+
for (const attr of described.attrs) {
|
|
88
|
+
lines.push(` ${attr.name} = ${JSON.stringify(attr.value)}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push(`text ${described.text === "" ? "(none)" : described.text}`);
|
|
91
|
+
lines.push(`children ${described.childCount}`);
|
|
92
|
+
lines.push(`at ${file} line ${described.line}, column ${described.column}`);
|
|
93
|
+
return `${lines.join("\n")}\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default async function run(argv) {
|
|
97
|
+
const { values, positionals } = parseArgs(argv, {
|
|
98
|
+
flags: SHARED_FLAGS,
|
|
99
|
+
positionals: { min: 2, max: 2 },
|
|
100
|
+
usage: USAGE,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (values.help) {
|
|
104
|
+
out(USAGE);
|
|
105
|
+
return EXIT_OK;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const [file, dataId] = positionals;
|
|
109
|
+
const loaded = await readDesignFile(file);
|
|
110
|
+
const index = collectElements(loaded.doc);
|
|
111
|
+
const node = index.byId.get(String(dataId));
|
|
112
|
+
if (!node) throw new CliError(`No element with data-id ${JSON.stringify(String(dataId))} in ${file}`, EXIT_INVALID);
|
|
113
|
+
|
|
114
|
+
const described = describeNode(node, typeof loaded.doc.source === "string" ? loaded.doc.source : loaded.text);
|
|
115
|
+
|
|
116
|
+
if (values.json) {
|
|
117
|
+
out(JSON.stringify({ file, id: String(dataId), ...described }));
|
|
118
|
+
} else {
|
|
119
|
+
out(humanReport(file, String(dataId), described), { quiet: values.quiet, newline: false });
|
|
120
|
+
}
|
|
121
|
+
return EXIT_OK;
|
|
122
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs tokens <file> ls | set <组.名> <值> | rm <组.名>`
|
|
3
|
+
*
|
|
4
|
+
* A design token is not an element, so no other verb can reach it. Without this one,
|
|
5
|
+
* "make the brand color purple" meant rewriting the whole file, which is the one thing
|
|
6
|
+
* `SKILL.md` tells a model never to do ("Write it, do not rewrite it"). v1 could
|
|
7
|
+
* address a token as `/tokens/color/bg`; this is that ability, on a file whose tokens
|
|
8
|
+
* are CSS.
|
|
9
|
+
*
|
|
10
|
+
* Two spellings of a name reach the same token, and `lib/tokens.mjs` is where they
|
|
11
|
+
* become one: `color.accent` (what `plan.md` §5.1 documents, and what a shell can
|
|
12
|
+
* accept without reading `--` as a flag) and `--color-accent` (what the file says, and
|
|
13
|
+
* what `tokens ls` prints).
|
|
14
|
+
*
|
|
15
|
+
* `set` and `rm` go through the same funnel as every other write: re-read, validate,
|
|
16
|
+
* apply, validate, refuse on a NEW error, write atomically, report. So a `set` that
|
|
17
|
+
* would introduce a cycle is refused by name rather than written and discovered later.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
21
|
+
import { applyAndWrite, refusedReceipt, reportRefusal, reportWrite } from "../write-verbs.mjs";
|
|
22
|
+
import { groupOfToken, normalizeTokenValue, resolveTokenName, tokenSites } from "../../tokens.mjs";
|
|
23
|
+
import { suggest } from "../../levenshtein.mjs";
|
|
24
|
+
|
|
25
|
+
export const USAGE = `Usage: node design.mjs tokens <file> <subcommand> [options]
|
|
26
|
+
|
|
27
|
+
ls list the token block, one token per line
|
|
28
|
+
set <组.名> <值> set a token, adding it when it is not there
|
|
29
|
+
rm <组.名> remove a token
|
|
30
|
+
|
|
31
|
+
A name is written either way, and both mean the same token:
|
|
32
|
+
|
|
33
|
+
color.accent the seven groups are color font space radius size shadow border
|
|
34
|
+
--color-accent needs a \`--\` first, because a leading dash reads as a flag
|
|
35
|
+
|
|
36
|
+
node design.mjs tokens t.html set -- --color-accent '#7c3aed'
|
|
37
|
+
|
|
38
|
+
Everything after \`--\` is a name or a value, never a flag.
|
|
39
|
+
|
|
40
|
+
A numeric group carries a unit. \`set space.md 16\` writes 16px, and a value that is not
|
|
41
|
+
a length is refused. A color is written as the hex the rest of the file uses. font,
|
|
42
|
+
shadow and border values are written as given.
|
|
43
|
+
|
|
44
|
+
--dry-run report what would change, write nothing
|
|
45
|
+
--json print the receipt as JSON
|
|
46
|
+
--quiet print nothing
|
|
47
|
+
--help print this usage
|
|
48
|
+
|
|
49
|
+
Exit codes: 0 changed, 1 refused, 2 unreadable or bad arguments.`;
|
|
50
|
+
|
|
51
|
+
/** The file's tokens, plus every name that is declared more than once. */
|
|
52
|
+
function sitesOf(loaded) {
|
|
53
|
+
const { sites, duplicates } = tokenSites(loaded.doc);
|
|
54
|
+
return { sites, duplicates };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function refuse(loaded, values, message, extra) {
|
|
58
|
+
const receipt = refusedReceipt("tokens", message, { diagnostics: [], ...extra });
|
|
59
|
+
reportRefusal(receipt, values, loaded.file);
|
|
60
|
+
return EXIT_INVALID;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function list(loaded, values) {
|
|
64
|
+
const { sites, duplicates } = sitesOf(loaded);
|
|
65
|
+
const rows = [...sites.values()].map((site) => ({ name: site.name, value: site.value, offset: site.offset }));
|
|
66
|
+
if (values.json) {
|
|
67
|
+
out(JSON.stringify({
|
|
68
|
+
file: loaded.file,
|
|
69
|
+
tokens: rows,
|
|
70
|
+
duplicates: duplicates.map((entry) => entry.name),
|
|
71
|
+
}));
|
|
72
|
+
return EXIT_OK;
|
|
73
|
+
}
|
|
74
|
+
if (rows.length === 0) {
|
|
75
|
+
out(`${loaded.file}: no tokens`, { quiet: false });
|
|
76
|
+
return EXIT_OK;
|
|
77
|
+
}
|
|
78
|
+
out(`${loaded.file}: ${rows.length} token${rows.length === 1 ? "" : "s"}`, { quiet: false });
|
|
79
|
+
for (const row of rows) out(` ${row.name} ${row.value}`, { quiet: false });
|
|
80
|
+
for (const entry of duplicates) {
|
|
81
|
+
out(` ${entry.name} is declared ${entry.offsets.length} times`, { quiet: false });
|
|
82
|
+
}
|
|
83
|
+
return EXIT_OK;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default async function run(argv) {
|
|
87
|
+
const { values, positionals } = parseArgs(argv, {
|
|
88
|
+
flags: { ...SHARED_FLAGS },
|
|
89
|
+
positionals: { min: 2, max: 4 },
|
|
90
|
+
usage: USAGE,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (values.help) {
|
|
94
|
+
out(USAGE);
|
|
95
|
+
return EXIT_OK;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [file, sub, spec, raw] = positionals;
|
|
99
|
+
if (!["ls", "set", "rm"].includes(sub)) {
|
|
100
|
+
throw new CliError(`Unknown tokens subcommand: ${sub}\n\n${USAGE}`);
|
|
101
|
+
}
|
|
102
|
+
if (sub === "set" && (spec === undefined || raw === undefined)) {
|
|
103
|
+
throw new CliError(`tokens set needs a name and a value\n\n${USAGE}`);
|
|
104
|
+
}
|
|
105
|
+
if (sub === "rm" && spec === undefined) {
|
|
106
|
+
throw new CliError(`tokens rm needs a name\n\n${USAGE}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const loaded = await readDesignFile(file);
|
|
110
|
+
if (sub === "ls") return list(loaded, values);
|
|
111
|
+
|
|
112
|
+
let name;
|
|
113
|
+
try {
|
|
114
|
+
name = resolveTokenName(spec);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// The same code the validator reports for the same problem, so a model that reads
|
|
117
|
+
// one message and writes the other is not being told two different things.
|
|
118
|
+
return refuse(loaded, values, error.message, { code: error.code ?? "style/token-name", hint: error.hint ?? null });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { sites, duplicates } = sitesOf(loaded);
|
|
122
|
+
const clash = duplicates.find((entry) => entry.name === name);
|
|
123
|
+
if (clash !== undefined) {
|
|
124
|
+
// `findDeclaration` returns the first declaration and the file has two. A `set`
|
|
125
|
+
// would write to one of them while the other kept winning, and an `rm` would leave
|
|
126
|
+
// the token in place while reporting that it removed it. Both are silent wrong
|
|
127
|
+
// answers, so neither is allowed to happen.
|
|
128
|
+
return refuse(loaded, values, `${name} is declared ${clash.offsets.length} times, so which one to change has no answer.`, {
|
|
129
|
+
code: "cli/duplicate-token",
|
|
130
|
+
hint: "Keep one declaration per token, then run this again.",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (sub === "rm") {
|
|
135
|
+
if (!sites.has(name)) {
|
|
136
|
+
const known = [...sites.keys()];
|
|
137
|
+
const closest = suggest(name, known);
|
|
138
|
+
return refuse(loaded, values, `There is no token ${name}.`, {
|
|
139
|
+
code: "cli/no-such-token",
|
|
140
|
+
hint: closest ? `Did you mean ${closest}?` : (known.length === 0 ? "This design has no tokens." : `Declared: ${known.join(", ")}.`),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const receipt = await applyAndWrite(loaded, [{ op: "unset-token", name }], { values, verb: "tokens" });
|
|
144
|
+
if (receipt.refused) return EXIT_INVALID;
|
|
145
|
+
reportWrite(receipt, values, file);
|
|
146
|
+
return EXIT_OK;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let normalized;
|
|
150
|
+
try {
|
|
151
|
+
normalized = normalizeTokenValue(groupOfToken(name), raw);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return refuse(loaded, values, error.message, { code: error.code ?? "style/bad-value", hint: error.hint ?? null });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const receipt = await applyAndWrite(loaded, [{ op: "set-token", name, value: normalized.value }], { values, verb: "tokens" });
|
|
157
|
+
if (receipt.refused) return EXIT_INVALID;
|
|
158
|
+
reportWrite(receipt, values, file);
|
|
159
|
+
if (normalized.changed && receipt.changed && !values.json && !values.quiet) {
|
|
160
|
+
// Zero silent drops covers a value this tool rewrote as much as one it discarded:
|
|
161
|
+
// `set space.md 16` writes 16px, and the line says so. Same shape as `add`. Not
|
|
162
|
+
// printed when the file did not move — a line about a rewrite that did not happen
|
|
163
|
+
// is the opposite of a report a reader can trust.
|
|
164
|
+
out(`normalized: ${String(raw)} → ${normalized.value}`);
|
|
165
|
+
}
|
|
166
|
+
return EXIT_OK;
|
|
167
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs validate <file> [--json] [--strict]` — the four-layer report.
|
|
3
|
+
*
|
|
4
|
+
* The validator is `lib/validate.mjs`; this verb only reads the file, runs it,
|
|
5
|
+
* and prints. Exit codes: 0 no errors, 1 errors (or warnings under --strict),
|
|
6
|
+
* 2 the file is unreadable or the arguments are wrong.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EXIT_INVALID, EXIT_OK, EXIT_UNREADABLE, SHARED_FLAGS, loadExport, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
10
|
+
import { dedupeDiagnostics, groupDiagnostics, sortDiagnostics, summarize } from "../../diagnostics.mjs";
|
|
11
|
+
import { byteOffsetToLineCol } from "../../spans.mjs";
|
|
12
|
+
|
|
13
|
+
export const USAGE = `Usage: node design.mjs validate <file> [options]
|
|
14
|
+
|
|
15
|
+
--json print one line of JSON instead of the human report
|
|
16
|
+
--strict exit 1 when there are warnings as well as errors
|
|
17
|
+
--quiet print nothing when the file is valid
|
|
18
|
+
--help print this usage
|
|
19
|
+
|
|
20
|
+
Exit codes: 0 valid, 1 invalid, 2 unreadable.`;
|
|
21
|
+
|
|
22
|
+
function codeOf(diagnostic) {
|
|
23
|
+
return typeof diagnostic.code === "string" && diagnostic.code !== "" ? diagnostic.code : "(no code)";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function whereOf(diagnostic, source) {
|
|
27
|
+
let line = Number.isFinite(diagnostic.line) ? diagnostic.line : null;
|
|
28
|
+
let column = Number.isFinite(diagnostic.column) ? diagnostic.column : null;
|
|
29
|
+
if ((line === null || column === null) && Number.isFinite(diagnostic.offset)) {
|
|
30
|
+
const at = byteOffsetToLineCol(source, diagnostic.offset);
|
|
31
|
+
if (line === null) line = at.line;
|
|
32
|
+
if (column === null) column = at.column;
|
|
33
|
+
}
|
|
34
|
+
if (line === null) return "";
|
|
35
|
+
return `line ${line}, column ${column === null ? 1 : column}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The grouped human report: one header per code, then that group's findings.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} file
|
|
42
|
+
* @param {object[]} diagnostics
|
|
43
|
+
* @param {string} source
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function humanReport(file, diagnostics, source) {
|
|
47
|
+
const sorted = sortDiagnostics(diagnostics);
|
|
48
|
+
const lines = [];
|
|
49
|
+
|
|
50
|
+
for (const group of groupDiagnostics(sorted)) {
|
|
51
|
+
const label = group.severity === "error" ? "ERROR " : "WARNING";
|
|
52
|
+
lines.push(`${label} ${group.code} x${group.count}`);
|
|
53
|
+
for (const diagnostic of sorted) {
|
|
54
|
+
if (codeOf(diagnostic) !== group.code) continue;
|
|
55
|
+
const target = typeof diagnostic.target === "string" && diagnostic.target !== "" ? diagnostic.target : "";
|
|
56
|
+
const path = typeof diagnostic.path === "string" ? diagnostic.path : "";
|
|
57
|
+
const where = whereOf(diagnostic, source);
|
|
58
|
+
const position = [target, path].filter(Boolean).join(" ");
|
|
59
|
+
lines.push(` ${position}${where ? ` (${where})` : ""}`.trimEnd());
|
|
60
|
+
if (diagnostic.message) lines.push(` ${diagnostic.message}`);
|
|
61
|
+
if (diagnostic.hint) lines.push(` hint ${diagnostic.hint}`);
|
|
62
|
+
if (diagnostic.fix) lines.push(` fix ${diagnostic.fix}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { errors, warnings } = summarize(sorted);
|
|
67
|
+
lines.push(`${file}: ${errors} errors, ${warnings} warnings${errors === 0 ? " — valid" : " — invalid"}`);
|
|
68
|
+
return `${lines.join("\n")}\n`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default async function run(argv) {
|
|
72
|
+
const { values, positionals } = parseArgs(argv, {
|
|
73
|
+
flags: { ...SHARED_FLAGS, strict: { type: "boolean" } },
|
|
74
|
+
positionals: { min: 1, max: 1 },
|
|
75
|
+
usage: USAGE,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (values.help) {
|
|
79
|
+
out(USAGE);
|
|
80
|
+
return EXIT_OK;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const file = positionals[0];
|
|
84
|
+
|
|
85
|
+
let loaded;
|
|
86
|
+
try {
|
|
87
|
+
loaded = await readDesignFile(file);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (values.json && error && (error.name === "FsError" || error.name === "CliError")) {
|
|
90
|
+
out(JSON.stringify({ file, ok: false, reason: error.code || "cli/error", message: error.message }));
|
|
91
|
+
return EXIT_UNREADABLE;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const validateDesign = await loadExport("validate.mjs", ["validateDesign", "validate", "default"], "validateDesign");
|
|
97
|
+
const parserDiagnostics = Array.isArray(loaded.doc.diagnostics) ? loaded.doc.diagnostics : [];
|
|
98
|
+
// `strict` is passed through so the report and the exit code agree: under
|
|
99
|
+
// --strict the validator itself promotes a warning to an error.
|
|
100
|
+
const produced = validateDesign(loaded.doc, {
|
|
101
|
+
file,
|
|
102
|
+
text: loaded.text,
|
|
103
|
+
format: loaded.format,
|
|
104
|
+
strict: values.strict === true,
|
|
105
|
+
});
|
|
106
|
+
const diagnostics = sortDiagnostics(
|
|
107
|
+
dedupeDiagnostics([...parserDiagnostics, ...(Array.isArray(produced) ? produced : [])]),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const counts = summarize(diagnostics);
|
|
111
|
+
const ok = counts.errors === 0 && (!values.strict || counts.warnings === 0);
|
|
112
|
+
|
|
113
|
+
if (values.json) {
|
|
114
|
+
out(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
file,
|
|
117
|
+
format: loaded.format,
|
|
118
|
+
revision: loaded.revision,
|
|
119
|
+
ok,
|
|
120
|
+
counts: { errors: counts.errors, warnings: counts.warnings },
|
|
121
|
+
groups: groupDiagnostics(diagnostics),
|
|
122
|
+
diagnostics,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
} else if (!values.quiet || !ok) {
|
|
126
|
+
out(humanReport(file, diagnostics, loaded.text), { newline: false });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return ok ? EXIT_OK : EXIT_INVALID;
|
|
130
|
+
}
|