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.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `node design.mjs import pen <file> [-o design.html] [--report r.json]`
3
+ *
4
+ * Direction is one way, and the command says so: pen in, design file out. The
5
+ * ledger prints on every run, because an import that loses a gradient and says
6
+ * nothing is worse than one that refuses.
7
+ */
8
+
9
+ import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { dirname, resolve } from "node:path";
11
+
12
+ import { CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, loadExport, out, parseArgs } from "../cli.mjs";
13
+ import { readText } from "../fs.mjs";
14
+
15
+ export const USAGE = `Usage: node design.mjs import pen <file> [options]
16
+
17
+ -o, --out <path> output design file (default: <name>.html)
18
+ --report <path> also write the ledger as JSON
19
+ --title <name> file title (default: the pen document's own name, else the file name)
20
+ --stdout print the design file instead of writing it; the report goes to stderr
21
+ --json print the ledger as JSON
22
+ --quiet print only the ledger line
23
+
24
+ Exit codes: 0 imported, 1 refused, 2 unreadable or bad arguments.
25
+
26
+ The ledger names every loss and every value that was rewritten. A loss is
27
+ something this format has no spelling for, such as a mesh gradient or an inner
28
+ shadow. There is no HTML to pen direction.`;
29
+
30
+ export default async function run(argv) {
31
+ const { values, positionals } = parseArgs(argv, {
32
+ flags: {
33
+ ...SHARED_FLAGS,
34
+ out: { type: "string", alias: ["o"] },
35
+ report: { type: "string" },
36
+ title: { type: "string" },
37
+ stdout: { type: "boolean" },
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 [format, file] = positionals;
49
+ if (format !== "pen") {
50
+ out(`REFUSED There is no ${format} reader. The only input format is pen.`);
51
+ return EXIT_INVALID;
52
+ }
53
+
54
+ const text = readText(file);
55
+ const { importPen, ledgerLine } = await import("../../readers/pen.mjs");
56
+ const parse = await loadExport("parse.mjs", ["parse"], "parse");
57
+ const validateDesign = await loadExport("validate.mjs", ["validateDesign"], "validateDesign");
58
+ // The reader is handed text, so it cannot know the file's name; the verb supplies it as
59
+ // the last resort. Without it a `.pen` with no `name` of its own was titled literally
60
+ // "imported", which tells a reader of the file nothing at all.
61
+ const fallbackTitle = String(file).replace(/\.pen$/i, "").split("/").pop();
62
+ let html;
63
+ let ledger;
64
+ let diagnostics;
65
+ try {
66
+ ({ html, ledger, diagnostics } = importPen(text, { title: values.title, fallbackTitle }));
67
+ } catch (error) {
68
+ // A reader refusal is a result, not a crash: it prints the same way every
69
+ // other refusal does, and it keeps the code, which is what a caller branches
70
+ // on. `design.mjs` would turn it into one line on stderr with the code lost.
71
+ if (typeof error?.code === "string" && error.code.startsWith("pen/")) {
72
+ if (values.json) {
73
+ out(JSON.stringify({ ok: false, verb: "import", format, file, refused: { code: error.code, message: error.message } }));
74
+ } else {
75
+ out(`REFUSED ${error.message}\n ${error.code}`);
76
+ }
77
+ return EXIT_INVALID;
78
+ }
79
+ throw error;
80
+ }
81
+
82
+ // `diagnostics` is the parser's, and this check read as a guarantee while it was
83
+ // only a syntax test: the reader never ran the validator, so a document the next
84
+ // `validate` would refuse imported clean and exited 0. The gate asks the validator
85
+ // directly now, and it asks about the bytes about to be written — `parse(html)`,
86
+ // not a second rendering of the same tree.
87
+ const errors = [
88
+ ...(diagnostics ?? []),
89
+ ...validateDesign(parse(html)),
90
+ ].filter((d) => d.severity === "error");
91
+ if (errors.length) {
92
+ const shown = errors.slice(0, 3).map((d) => `${d.code} — ${d.message}`).join("; ");
93
+ out(`REFUSED The imported file would not validate: ${errors.length} error(s). ${shown}`);
94
+ return EXIT_INVALID;
95
+ }
96
+
97
+ if (values.report) {
98
+ mkdirSync(dirname(resolve(values.report)), { recursive: true });
99
+ writeFileSync(resolve(values.report), `${JSON.stringify({ file, ledger, diagnostics }, null, 2)}\n`, "utf8");
100
+ }
101
+
102
+ if (values.stdout) {
103
+ process.stdout.write(html);
104
+ // stdout carries the document and nothing else. `--stdout` exists to be piped, and a
105
+ // pipe cannot tell a line of HTML from a line of ledger: this wrote the ledger line to
106
+ // stdout, so `import pen x.pen --stdout > design.html` produced a file whose last line
107
+ // was a sentence about pages. The report goes to stderr instead — silencing it would
108
+ // hide the losses, which are the point of this verb.
109
+ out(values.json
110
+ ? JSON.stringify({ ok: true, verb: "import", format, file, out: null, bytes: Buffer.byteLength(html), ledger })
111
+ : ledgerLine(ledger), { quiet: values.quiet, stream: process.stderr });
112
+ return EXIT_OK;
113
+ }
114
+
115
+ const target = resolve(values.out ?? values.o ?? `${String(file).replace(/\.pen$/i, "").split("/").pop()}.html`);
116
+ mkdirSync(dirname(target), { recursive: true });
117
+ writeFileSync(target, html, "utf8");
118
+
119
+ if (values.json) {
120
+ out(JSON.stringify({ ok: true, verb: "import", format, file, out: target, bytes: Buffer.byteLength(html), ledger }));
121
+ } else {
122
+ out(`${file} → ${target} (${Buffer.byteLength(html)} bytes)`);
123
+ out(ledgerLine(ledger));
124
+ }
125
+ return EXIT_OK;
126
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `node design.mjs ls <file> [--pages] [--in <data-id>] [--depth n] [--json]`
3
+ *
4
+ * Two shapes, one line per node:
5
+ *
6
+ * ls home.html every page, in document order
7
+ * ls home.html --in home__root the node tree under one data-id
8
+ *
9
+ * A line is `data-id data-type tag name`, indented two spaces per level
10
+ * beneath the root of the listing.
11
+ */
12
+
13
+ import { CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
14
+ import { attrValue, collectElements, isPageElement } from "../../diagnostics.mjs";
15
+
16
+ export const USAGE = `Usage: node design.mjs ls <file> [options]
17
+
18
+ --pages list pages only (the default when --in is absent)
19
+ --in <data-id> list the node tree under one element
20
+ --depth n how many levels below the root to print (default: all)
21
+ --json print one line of JSON instead of text
22
+ --help print this usage
23
+
24
+ Exit codes: 0 listed, 1 no such data-id, 2 unreadable.`;
25
+
26
+ function rowsOf(node, depth, maxDepth, rows) {
27
+ const id = attrValue(node, "data-id") || "";
28
+ const dataType = attrValue(node, "data-type") || "";
29
+ rows.push({
30
+ dataId: id,
31
+ dataType,
32
+ tag: typeof node.tag === "string" ? node.tag : "",
33
+ name: attrValue(node, "data-name") || "",
34
+ depth,
35
+ });
36
+ if (depth >= maxDepth) return rows;
37
+ const children = Array.isArray(node.children) ? node.children : [];
38
+ for (const child of children) {
39
+ if (!child || typeof child !== "object") continue;
40
+ if (child.kind !== "element" && !(typeof child.tag === "string" && Array.isArray(child.attrs))) continue;
41
+ rowsOf(child, depth + 1, maxDepth, rows);
42
+ }
43
+ return rows;
44
+ }
45
+
46
+ /** One report line: `data-id data-type tag name`, indented by depth. */
47
+ export function lineOf(row) {
48
+ const fields = [row.dataId, row.dataType, row.tag];
49
+ if (row.name) fields.push(row.name);
50
+ return `${" ".repeat(row.depth)}${fields.join(" ")}`;
51
+ }
52
+
53
+ export default async function run(argv) {
54
+ const { values, positionals } = parseArgs(argv, {
55
+ flags: {
56
+ ...SHARED_FLAGS,
57
+ pages: { type: "boolean" },
58
+ in: { type: "string" },
59
+ depth: { type: "number" },
60
+ },
61
+ positionals: { min: 1, max: 1 },
62
+ usage: USAGE,
63
+ });
64
+
65
+ if (values.help) {
66
+ out(USAGE);
67
+ return EXIT_OK;
68
+ }
69
+
70
+ const file = positionals[0];
71
+ const loaded = await readDesignFile(file);
72
+ const index = collectElements(loaded.doc);
73
+
74
+ let rows;
75
+ if (values.in !== undefined && values.in !== null) {
76
+ const root = index.byId.get(String(values.in));
77
+ if (!root) throw new CliError(`No element with data-id ${JSON.stringify(String(values.in))} in ${file}`, EXIT_INVALID);
78
+ if (Number.isFinite(values.depth) && values.depth < 0) throw new CliError("--depth needs a number of 0 or more");
79
+ const maxDepth = Number.isFinite(values.depth) ? Math.trunc(values.depth) : Infinity;
80
+ rows = rowsOf(root, 0, maxDepth, []);
81
+ } else {
82
+ rows = index.elements.filter(isPageElement).map((page) => ({
83
+ dataId: attrValue(page, "data-id") || "",
84
+ dataType: attrValue(page, "data-type") || "page",
85
+ tag: typeof page.tag === "string" ? page.tag : "section",
86
+ name: attrValue(page, "data-name") || "",
87
+ depth: 0,
88
+ }));
89
+ }
90
+
91
+ if (values.json) {
92
+ out(JSON.stringify(rows));
93
+ } else if (!values.quiet) {
94
+ for (const row of rows) out(lineOf(row), { quiet: false });
95
+ }
96
+
97
+ return EXIT_OK;
98
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `node design.mjs migrate <file>`
3
+ *
4
+ * Move a file onto the schema this build reads, by fixing the one thing that
5
+ * carries the version: the `<meta name="design-schema">` marker.
6
+ *
7
+ * Why this exists at all: `version/unsupported` prints the hint
8
+ * "Run `design migrate` to move the file to 3.0", and that command did not
9
+ * exist. A hint that names a command nobody can run is a dead end, and a dead
10
+ * end is the one failure this tool promises not to hand back.
11
+ *
12
+ * There is no older *HTML* schema in this repository, so this is not a format
13
+ * translator and must not pretend to be one. It fixes the marker, revalidates,
14
+ * and reports. If the file has other problems they are reported, not repaired.
15
+ *
16
+ * It does not go through the op layer, and it cannot: an op is addressed by
17
+ * `data-id`, `resolveAddress` accepts nothing else, and a `<meta>` has no
18
+ * `data-id`. So the marker is edited the way the serializer edits anything —
19
+ * one byte range.
20
+ */
21
+
22
+ import { EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
23
+ import { refusedReceipt, reportRefusal } from "../write-verbs.mjs";
24
+ import { revisionOf, unifiedDiff, writeAtomic } from "../fs.mjs";
25
+ import { SCHEMA_META_NAME, SCHEMA_VERSION, findMeta, newErrors, validateDesign } from "../../validate.mjs";
26
+ import { getAttrNode, parse } from "../../parse.mjs";
27
+ import { applyEdits } from "../../spans.mjs";
28
+
29
+ export const USAGE = `Usage: node design.mjs migrate <file> [options]
30
+
31
+ --force write even when the edit introduces a new error
32
+ --dry-run print the diff, write nothing
33
+ --json print the receipt as JSON
34
+ --quiet print nothing
35
+
36
+ Exit codes: 0 already current, or migrated; 1 refused and unchanged; 2 unreadable
37
+ or bad arguments.
38
+
39
+ This fixes the version marker only. It is not a format translator: there is no
40
+ older HTML schema, so there is nothing to translate from. A file that is already
41
+ current is left byte for byte alone, down to its modification time.`;
42
+
43
+ /** The marker, spelled the way `new` and the pen importer spell it. */
44
+ function markerText() {
45
+ return `<meta name="${SCHEMA_META_NAME}" content="${SCHEMA_VERSION}">`;
46
+ }
47
+
48
+ /**
49
+ * The whitespace a line starts with, so an inserted line sits where a person
50
+ * would have put it. Canonical form does not care, but a file someone reads does.
51
+ */
52
+ function indentAt(text, offset) {
53
+ const lineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
54
+ return (/^[ \t]*/.exec(text.slice(lineStart, offset)) ?? [""])[0];
55
+ }
56
+
57
+ /**
58
+ * Where to put the marker when the file has none.
59
+ *
60
+ * Inside `<head>`, after the `<title>` when there is one — that is where every
61
+ * file in this repository already has it, so the result looks like the files a
62
+ * person writes. Falls back outward: end of `<head>`, then a `<head>` of its
63
+ * own, then the top of the file.
64
+ *
65
+ * @returns {{ at: number, text: string }}
66
+ */
67
+ function insertionPoint(doc, text) {
68
+ const head = doc.head;
69
+ if (head) {
70
+ const title = (head.children || []).find((c) => c.kind === "element" && c.tag === "title");
71
+ if (title) return { at: title.end, text: `\n${indentAt(text, title.start)}${markerText()}` };
72
+ const elements = (head.children || []).filter((c) => c.kind === "element");
73
+ const last = elements[elements.length - 1];
74
+ if (last) return { at: last.end, text: `\n${indentAt(text, last.start)}${markerText()}` };
75
+ return { at: head.openEnd, text: `\n${markerText()}\n` };
76
+ }
77
+
78
+ const html = doc.html;
79
+ if (html) {
80
+ // No `<head>` to write into, so make one. A browser invents a head anyway;
81
+ // writing it down means the next reader does not have to.
82
+ return { at: html.openEnd, text: `\n<head>\n${markerText()}\n</head>` };
83
+ }
84
+
85
+ return { at: 0, text: `${markerText()}\n` };
86
+ }
87
+
88
+ /**
89
+ * The new text, or `null` when the marker is already right.
90
+ *
91
+ * @returns {{ text: string, from: string|null, how: string }|null}
92
+ */
93
+ function migrateText(doc, text) {
94
+ const meta = findMeta(doc, SCHEMA_META_NAME);
95
+
96
+ if (!meta) {
97
+ const { at, text: insertion } = insertionPoint(doc, text);
98
+ return { text: applyEdits(text, [{ start: at, end: at, text: insertion }]), from: null, how: "added" };
99
+ }
100
+
101
+ if (meta.value === SCHEMA_VERSION) return null;
102
+
103
+ const content = getAttrNode(meta.node, "content");
104
+ if (content && content.valueStart >= 0) {
105
+ // `valueStart` points at the opening quote when there is one — the parser
106
+ // spans `"3.0` rather than `3.0` — so step past it. An unquoted value has no
107
+ // quote and starts where it says. Leave the tag's own spelling alone.
108
+ const start = content.valueStart + (content.quote ? 1 : 0);
109
+ return {
110
+ text: applyEdits(text, [{ start, end: content.valueEnd, text: SCHEMA_VERSION }]),
111
+ from: meta.value ?? null,
112
+ how: "rewritten",
113
+ };
114
+ }
115
+
116
+ // A marker with no readable `content`: give it one, right after the `name`
117
+ // attribute, rather than rewriting the whole tag.
118
+ const name = getAttrNode(meta.node, "name");
119
+ const at = name ? name.end : meta.node.start + 1 + String(meta.node.rawTag ?? meta.node.tag).length;
120
+ return {
121
+ text: applyEdits(text, [{ start: at, end: at, text: ` content="${SCHEMA_VERSION}"` }]),
122
+ from: null,
123
+ how: "added",
124
+ };
125
+ }
126
+
127
+ /** The one line a human reads, in the house shape. */
128
+ function report(receipt, values, file) {
129
+ if (values.json) {
130
+ out(JSON.stringify(receipt));
131
+ return;
132
+ }
133
+ const { errors, warnings } = receipt.summary;
134
+ const tail = `(${errors} errors, ${warnings} warnings)`;
135
+ if (!receipt.changed) {
136
+ out(`${file}: already ${SCHEMA_VERSION} ${tail}`, { quiet: values.quiet });
137
+ return;
138
+ }
139
+ const from = receipt.from === null ? "no marker" : `schema ${receipt.from}`;
140
+ out(`${file}: ${from} → ${SCHEMA_VERSION}, revision ${receipt.revision} ${tail}`, { quiet: values.quiet });
141
+ if (values["dry-run"] && receipt.diff) out(receipt.diff);
142
+ }
143
+
144
+ export default async function run(argv) {
145
+ const { values, positionals } = parseArgs(argv, {
146
+ flags: { ...SHARED_FLAGS },
147
+ positionals: { min: 1, max: 1 },
148
+ usage: USAGE,
149
+ });
150
+
151
+ if (values.help) {
152
+ out(USAGE);
153
+ return EXIT_OK;
154
+ }
155
+
156
+ const [file] = positionals;
157
+ const loaded = await readDesignFile(file);
158
+
159
+ if (loaded.format !== "html") {
160
+ const receipt = refusedReceipt("migrate", `${file} is not an HTML design file.`, {
161
+ code: "cli/not-a-design-file",
162
+ diagnostics: [],
163
+ hint: "Only .html design files carry a design-schema marker.",
164
+ });
165
+ reportRefusal(receipt, values, loaded.file);
166
+ return EXIT_INVALID;
167
+ }
168
+
169
+ const before = validateDesign(loaded.doc);
170
+ const change = migrateText(loaded.doc, loaded.text);
171
+
172
+ if (!change) {
173
+ // Already current. Report the state and touch nothing: not the bytes, not
174
+ // the modification time, not the inode. Re-running a migration must be free.
175
+ const errors = before.filter((d) => d.severity === "error").length;
176
+ const warnings = before.filter((d) => d.severity === "warning").length;
177
+ const receipt = {
178
+ ok: true,
179
+ verb: "migrate",
180
+ applied: [],
181
+ added: [],
182
+ normalized: [],
183
+ dropped: [],
184
+ diagnostics: before,
185
+ revision: revisionOf(loaded.text),
186
+ changed: false,
187
+ from: SCHEMA_VERSION,
188
+ to: SCHEMA_VERSION,
189
+ summary: { errors, warnings },
190
+ };
191
+ report(receipt, values, loaded.file);
192
+ return EXIT_OK;
193
+ }
194
+
195
+ // Re-read the result the way any other write does, so a migration is held to
196
+ // the same rule: it may not introduce an error. `migrate` refuses every format
197
+ // but HTML above, so the HTML reader is the one to re-read with.
198
+ const reparsed = parse(change.text, { file: loaded.file, format: "html", source: change.text });
199
+ if (typeof reparsed.source !== "string") reparsed.source = change.text;
200
+ const after = validateDesign(reparsed);
201
+ const introduced = newErrors(before, after);
202
+
203
+ if (introduced.length && !values.force) {
204
+ const receipt = refusedReceipt("migrate", "This migration would introduce a new error.", {
205
+ code: "cli/would-introduce-errors",
206
+ diagnostics: introduced,
207
+ hint: introduced[0]?.hint ?? null,
208
+ });
209
+ reportRefusal(receipt, values, loaded.file);
210
+ return EXIT_INVALID;
211
+ }
212
+
213
+ if (!values["dry-run"]) writeAtomic(loaded.file, change.text);
214
+
215
+ const receipt = {
216
+ ok: true,
217
+ verb: "migrate",
218
+ applied: [],
219
+ added: [],
220
+ normalized: [],
221
+ dropped: [],
222
+ diagnostics: after,
223
+ revision: revisionOf(change.text),
224
+ changed: true,
225
+ from: change.from,
226
+ to: SCHEMA_VERSION,
227
+ how: change.how,
228
+ summary: {
229
+ errors: after.filter((d) => d.severity === "error").length,
230
+ warnings: after.filter((d) => d.severity === "warning").length,
231
+ },
232
+ };
233
+ if (values["dry-run"]) receipt.diff = unifiedDiff(loaded.text, change.text, loaded.file, "migrated");
234
+ report(receipt, values, loaded.file);
235
+ return EXIT_OK;
236
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `node design.mjs move <file> <data-id> [--before X] [--after X] [--index n] [--into P]`
3
+ *
4
+ * One op. A move is addressed by data-id, never by a CSS selector, so it is
5
+ * unambiguous and it survives a sibling being inserted or removed.
6
+ */
7
+
8
+ import { EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
9
+ import { applyAndWrite, reportWrite } from "../write-verbs.mjs";
10
+
11
+ export const USAGE = `Usage: node design.mjs move <file> <data-id> [options]
12
+
13
+ --before <data-id> put it immediately before this sibling
14
+ --after <data-id> put it immediately after this sibling
15
+ --index <n> put it at this position (0 is first)
16
+ --into <data-id> put it inside this element, last
17
+ --force write even when the edit introduces a new error
18
+ --dry-run report what would change, write nothing
19
+ --json print the receipt as JSON
20
+ --quiet print nothing
21
+
22
+ Exactly one destination is required.
23
+
24
+ Exit codes: 0 moved, 1 refused, 2 unreadable or bad arguments.
25
+
26
+ A move cannot put an element inside its own subtree, and it cannot make a
27
+ non-container hold children. Both are refused by name.`;
28
+
29
+ export default async function run(argv) {
30
+ const { values, positionals } = parseArgs(argv, {
31
+ flags: {
32
+ ...SHARED_FLAGS,
33
+ before: { type: "string" },
34
+ after: { type: "string" },
35
+ index: { type: "string" },
36
+ into: { type: "string" },
37
+ },
38
+ positionals: { min: 2, max: 2 },
39
+ usage: USAGE,
40
+ });
41
+
42
+ if (values.help) {
43
+ out(USAGE);
44
+ return EXIT_OK;
45
+ }
46
+
47
+ const [file, target] = positionals;
48
+ const destinations = ["before", "after", "index", "into"].filter((key) => values[key] !== undefined);
49
+ if (destinations.length === 0) {
50
+ out("REFUSED Where should it go? Use --before, --after, --index or --into.");
51
+ return 1;
52
+ }
53
+ if (destinations.length > 1) {
54
+ out(`REFUSED One destination at a time, and ${destinations.length} were given: ${destinations.join(", ")}.`);
55
+ return 1;
56
+ }
57
+
58
+ const op = { op: "move", target };
59
+ const key = destinations[0];
60
+ if (key === "index") {
61
+ const index = Number(values.index);
62
+ if (!Number.isInteger(index) || index < 0) {
63
+ out(`REFUSED --index wants a number from 0 up, and got ${JSON.stringify(values.index)}.`);
64
+ return 1;
65
+ }
66
+ op.index = index;
67
+ } else {
68
+ op[key] = values[key];
69
+ }
70
+
71
+ const loaded = await readDesignFile(file);
72
+ const receipt = await applyAndWrite(loaded, [op], { values, verb: "move" });
73
+ if (receipt.refused) return 1;
74
+ reportWrite(receipt, values, file);
75
+ return EXIT_OK;
76
+ }