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,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
|
+
}
|