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,206 @@
1
+ /**
2
+ * lib/node/static.mjs — the served-file whitelist for the canvas service.
3
+ *
4
+ * Two rules, and they are the whole file:
5
+ *
6
+ * 1. A route is a KEY in a Map, and a request path is only ever *looked up* in
7
+ * it. A request therefore never becomes a filesystem path, which is why
8
+ * `/lib/../package.json`, `/lib/%2e%2e%2fpackage.json` and every other
9
+ * escaping spelling miss the table instead of escaping a directory.
10
+ * 2. The table is built from two literal sources: the list below, and a
11
+ * listing of three literal directories (`canvas/`, `lib/`, `lib/readers/`).
12
+ * Nothing under `lib/node/` is ever listed — those modules import `node:*`
13
+ * and must never cross the wire — and no request can add an entry.
14
+ *
15
+ * The browser parses the design file itself, so `lib/parse.mjs`, `lib/render.mjs`
16
+ * and their neighbours are imported by the canvas as ES modules. That is why the
17
+ * route list is explicit: the canvas can only fetch what this file names.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ /** The repository root: `lib/node/static.mjs` → `../../`. */
25
+ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
26
+
27
+ /** The canvas shell. `/` and this file are the same asset. */
28
+ export const SHELL_ROUTE = "/";
29
+ export const SHELL_FILE = "canvas/index.html";
30
+
31
+ /**
32
+ * The explicit table: route → path relative to the package root.
33
+ *
34
+ * Every target here exists, and `test/serve.test.mjs` holds it that way: a route whose
35
+ * file is not written yet belongs in `PLACEHOLDER_ROUTES`, where it answers 404 and says
36
+ * so. Listing a missing file here used to be allowed — it 404s the same way — but then
37
+ * the table could not be read as a promise, and nothing told the difference between "the
38
+ * module landed" and "the module was renamed".
39
+ *
40
+ * The canvas modules listed are the names the canvas agent is known to use; the directory
41
+ * listing in `buildRoutes` picks up any others it adds. That listing covers all of `lib/`,
42
+ * so an entry here is documentation rather than a requirement: a module the canvas imports
43
+ * is served whether or not it has a line in this table. Nothing needs a line added to
44
+ * become reachable, and no test would fail if one were left out.
45
+ */
46
+ export const STATIC_ROUTES = new Map([
47
+ [SHELL_ROUTE, SHELL_FILE],
48
+ ["/canvas/index.html", "canvas/index.html"],
49
+ ["/canvas/styles.css", "canvas/styles.css"],
50
+ ["/canvas/app.mjs", "canvas/app.mjs"],
51
+
52
+ // The browser-side modules of the format. The design rule is that the canvas
53
+ // parses and renders the file itself, so it imports these directly.
54
+ ["/lib/parse.mjs", "lib/parse.mjs"],
55
+ ["/lib/serialize.mjs", "lib/serialize.mjs"],
56
+ ["/lib/render.mjs", "lib/render.mjs"],
57
+ ["/lib/table.mjs", "lib/table.mjs"],
58
+ ["/lib/validate.mjs", "lib/validate.mjs"],
59
+ ["/lib/geometry.mjs", "lib/geometry.mjs"],
60
+ ["/lib/normalize.mjs", "lib/normalize.mjs"],
61
+ ["/lib/diagnostics.mjs", "lib/diagnostics.mjs"],
62
+ ["/lib/spans.mjs", "lib/spans.mjs"],
63
+ ["/lib/detect.mjs", "lib/detect.mjs"],
64
+ ["/lib/levenshtein.mjs", "lib/levenshtein.mjs"],
65
+ ["/lib/ops.mjs", "lib/ops.mjs"],
66
+ ["/lib/fragment.mjs", "lib/fragment.mjs"],
67
+ ["/lib/receipt.mjs", "lib/receipt.mjs"],
68
+ ["/lib/tokens.mjs", "lib/tokens.mjs"],
69
+ ["/lib/icons.mjs", "lib/icons.mjs"],
70
+ ["/lib/readers/pen.mjs", "lib/readers/pen.mjs"],
71
+ ]);
72
+
73
+ /**
74
+ * Routes with no file behind them yet.
75
+ *
76
+ * They answer 404 like any unlisted path, and they are listed because the contract names
77
+ * them: a reader of this file learns which modules are planned, and `test/serve.test.mjs`
78
+ * requires every entry here to still be missing, so one that lands has to move up into
79
+ * `STATIC_ROUTES` instead of quietly working.
80
+ *
81
+ * Empty on purpose. `lib/pointer.mjs` (RFC 6901 helpers, seventy lines nobody called) was
82
+ * in the real table and is gone; the entries that were waiting here — `lib/icons.mjs` and
83
+ * `lib/readers/pen.mjs` — both landed.
84
+ */
85
+ export const PLACEHOLDER_ROUTES = new Map([]);
86
+
87
+ /**
88
+ * Directories that are listed at start-up, literally.
89
+ *
90
+ * `lib/` is listed without recursion, so `lib/node/` stays unreachable. A file
91
+ * is only added when its name is a plain file name (no separators, no leading
92
+ * dot) and its resolved path stays inside the package root.
93
+ */
94
+ export const SCANNED_DIRS = ["canvas", "lib", "lib/readers"];
95
+
96
+ /** A plain file name: the only shape a scanned entry may have. */
97
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
98
+
99
+ /** True when `file` is `root` itself or lives under it, symlinks included. */
100
+ export function isInside(root, file) {
101
+ const base = path.resolve(String(root));
102
+ const target = path.resolve(String(file));
103
+ if (target !== base && !target.startsWith(base + path.sep)) return false;
104
+ try {
105
+ const real = fs.realpathSync(target);
106
+ if (real !== base && !real.startsWith(base + path.sep)) return false;
107
+ } catch {
108
+ // The file does not exist yet. The path check above already holds, and the
109
+ // route is re-checked at serve time, so a later symlink cannot slip through.
110
+ }
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Build the served map: the explicit table first, then whatever the three
116
+ * literal directories hold. A request never contributes an entry.
117
+ *
118
+ * @param {string} [root] the package root, for tests that serve another tree
119
+ * @returns {Map<string, string>} route → absolute file path
120
+ */
121
+ export function buildRoutes(root = PACKAGE_ROOT) {
122
+ const base = path.resolve(root);
123
+ const routes = new Map();
124
+
125
+ for (const [route, relative] of STATIC_ROUTES) {
126
+ const file = path.resolve(base, relative);
127
+ if (isInside(base, file)) routes.set(route, file);
128
+ }
129
+
130
+ for (const dir of SCANNED_DIRS) {
131
+ let entries;
132
+ try {
133
+ entries = fs.readdirSync(path.join(base, dir), { withFileTypes: true });
134
+ } catch {
135
+ continue; // the directory does not exist yet
136
+ }
137
+ for (const entry of entries) {
138
+ if (!entry.isFile()) continue; // no subdirectories, so lib/node/ stays out
139
+ if (!SAFE_NAME.test(entry.name)) continue;
140
+ const relative = `${dir}/${entry.name}`;
141
+ const route = `/${relative}`;
142
+ if (routes.has(route)) continue; // the explicit table wins
143
+ const file = path.resolve(base, relative);
144
+ if (!isInside(base, file)) continue;
145
+ routes.set(route, file);
146
+ }
147
+ }
148
+
149
+ return routes;
150
+ }
151
+
152
+ /**
153
+ * Resolve a request path against the map. A miss is `null`, never a path.
154
+ *
155
+ * The path arrives percent-encoded; it is decoded once so an escaped name still
156
+ * matches an entry, and then it is only used as a Map key. A decoded `..`, a
157
+ * backslash or a NUL simply does not match anything.
158
+ *
159
+ * @param {Map<string, string>} routes
160
+ * @param {string} pathname
161
+ * @returns {{route: string, file: string} | null}
162
+ */
163
+ export function routeFor(routes, pathname) {
164
+ if (!routes || typeof pathname !== "string" || pathname === "") return null;
165
+ let decoded;
166
+ try {
167
+ decoded = decodeURIComponent(pathname);
168
+ } catch {
169
+ return null; // a malformed escape is not a route
170
+ }
171
+ if (decoded.includes("\0") || decoded.includes("\\")) return null;
172
+ const file = routes.get(decoded);
173
+ return file ? { route: decoded, file } : null;
174
+ }
175
+
176
+ const CONTENT_TYPES = {
177
+ ".html": "text/html; charset=utf-8",
178
+ ".htm": "text/html; charset=utf-8",
179
+ ".css": "text/css; charset=utf-8",
180
+ ".mjs": "text/javascript; charset=utf-8",
181
+ ".js": "text/javascript; charset=utf-8",
182
+ ".json": "application/json; charset=utf-8",
183
+ ".map": "application/json; charset=utf-8",
184
+ ".svg": "image/svg+xml",
185
+ ".png": "image/png",
186
+ ".jpg": "image/jpeg",
187
+ ".jpeg": "image/jpeg",
188
+ ".gif": "image/gif",
189
+ ".webp": "image/webp",
190
+ ".ico": "image/x-icon",
191
+ ".woff": "font/woff",
192
+ ".woff2": "font/woff2",
193
+ ".ttf": "font/ttf",
194
+ ".txt": "text/plain; charset=utf-8",
195
+ };
196
+
197
+ /**
198
+ * The MIME type for a served file. `.mjs` must be a JavaScript type or the
199
+ * browser refuses the module.
200
+ *
201
+ * @param {string} file
202
+ * @returns {string}
203
+ */
204
+ export function contentTypeOf(file) {
205
+ return CONTENT_TYPES[path.extname(String(file)).toLowerCase()] || "application/octet-stream";
206
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `node design.mjs add <file> --html-file f | --html - [--in <parent>] [--as <id>] [--index n]`
3
+ *
4
+ * The modifier. This is the path a model uses: it writes an HTML fragment, the
5
+ * tool checks it, and only then does a byte of the file move.
6
+ *
7
+ * The funnel, in order:
8
+ *
9
+ * 1. read the file again (another writer may have changed it)
10
+ * 2. validate it (so a pre-existing error is not blamed here)
11
+ * 3. compile the fragment to one op (parse, check, normalize, name, index)
12
+ * 4. apply the op
13
+ * 5. validate again, and refuse unless the edit made no NEW error
14
+ * 6. normalize the changed nodes only, then write atomically
15
+ * 7. print the receipt
16
+ *
17
+ * Step 5 is the important one. A file may already have errors: the model is
18
+ * allowed to fix one of them, and it is not allowed to add another. Refusing on
19
+ * any error at all would make a broken file impossible to repair through this
20
+ * tool.
21
+ */
22
+
23
+ import process from "node:process";
24
+
25
+ import {
26
+ CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile, loadExport,
27
+ } from "../cli.mjs";
28
+ import { readText, writeAtomic, revisionOf } from "../fs.mjs";
29
+ import { refusedReceipt } from "../write-verbs.mjs";
30
+
31
+ export const USAGE = `Usage: node design.mjs add <file> [options]
32
+
33
+ --html-file <path> read the fragment from a file
34
+ --html - read the fragment from stdin
35
+ --in <data-id> the parent to insert into (default: the resolved page;
36
+ a page fragment goes into <body>)
37
+ --page <data-id> the page to insert into (default: inferred from --in,
38
+ else the last page)
39
+ --as <data-id> rename the fragment's root to this id
40
+ --index <n> insert at this position (default: last)
41
+ --force write even when the edit introduces a new error
42
+ --dry-run compile and validate, print the receipt, write nothing
43
+ --json print the receipt as JSON
44
+ --quiet print nothing
45
+
46
+ Exit codes: 0 added, 1 refused or invalid, 2 unreadable or bad arguments.`;
47
+
48
+ async function readFragment(values) {
49
+ if (values["html-file"]) {
50
+ const text = readText(values["html-file"]);
51
+ return { html: text, from: values["html-file"] };
52
+ }
53
+ if (values.html === "-" || values.html === true) {
54
+ const chunks = [];
55
+ for await (const chunk of process.stdin) chunks.push(chunk);
56
+ return { html: Buffer.concat(chunks).toString("utf8"), from: "stdin" };
57
+ }
58
+ if (typeof values.html === "string") return { html: values.html, from: "--html" };
59
+ throw new CliError(
60
+ "No fragment given.",
61
+ EXIT_INVALID,
62
+ "cli/no-fragment",
63
+ "Use --html-file <path>, or --html - to read from stdin.",
64
+ );
65
+ }
66
+
67
+ export default async function run(argv) {
68
+ const { values, positionals } = parseArgs(argv, {
69
+ flags: {
70
+ ...SHARED_FLAGS,
71
+ html: { type: "string" },
72
+ "html-file": { type: "string" },
73
+ in: { type: "string" },
74
+ as: { type: "string" },
75
+ index: { type: "string" },
76
+ page: { type: "string" },
77
+ },
78
+ positionals: { min: 1, max: 1 },
79
+ usage: USAGE,
80
+ });
81
+
82
+ if (values.help) {
83
+ out(USAGE);
84
+ return EXIT_OK;
85
+ }
86
+
87
+ const file = positionals[0];
88
+ const fragment = await readFragment(values);
89
+ const loaded = await readDesignFile(file);
90
+
91
+ const compileFragment = await loadExport("fragment.mjs", ["compileFragment"], "compileFragment");
92
+ const validateDesign = await loadExport("validate.mjs", ["validateDesign"], "validateDesign");
93
+ const newErrors = await loadExport("validate.mjs", ["newErrors"], "newErrors");
94
+ const applyOps = await loadExport("ops.mjs", ["applyOps"], "applyOps");
95
+ const serialize = await loadExport("serialize.mjs", ["serialize"], "serialize");
96
+
97
+ const before = validateDesign(loaded.doc);
98
+
99
+ // The parent is the fragment compiler's decision, not this verb's. A page
100
+ // fragment with no `--in` belongs in `<body>`; anything else belongs in the
101
+ // resolved page (`--page` or `--in` names it, else the last page). Pre-filling
102
+ // `--in` with the first page made the CLI refuse the exact command the
103
+ // compiler documents — and the refusal's own hint says "Send the page without
104
+ // in" — so the default lives in one place now.
105
+ const compiled = compileFragment(loaded.doc, fragment.html, {
106
+ in: values.in,
107
+ as: values.as,
108
+ index: values.index === undefined ? undefined : Number(values.index),
109
+ page: values.page,
110
+ });
111
+
112
+ if (compiled.refused) {
113
+ return refuse(compiled, values, file);
114
+ }
115
+
116
+ const { doc: next, inverse } = applyOps(loaded.doc, compiled.ops);
117
+ const after = validateDesign(next);
118
+ const introduced = newErrors(before, after);
119
+
120
+ if (introduced.length && !values.force) {
121
+ const receipt = {
122
+ ok: false,
123
+ verb: "add",
124
+ applied: [],
125
+ added: [],
126
+ normalized: compiled.normalized,
127
+ dropped: compiled.dropped,
128
+ diagnostics: introduced,
129
+ revision: loaded.revision,
130
+ changed: false,
131
+ };
132
+ return refuse({ ...compiled, diagnostics: introduced, refused: { code: "cli/would-introduce-errors", message: "This edit would introduce a new error." } }, values, file, receipt);
133
+ }
134
+
135
+ const text = serialize(loaded.doc, next);
136
+
137
+ if (values["dry-run"]) {
138
+ const receipt = buildReceipt(compiled, { revision: revisionOf(text), changed: text !== loaded.text, inv: inverse, diagnostics: after });
139
+ print(receipt, values, file);
140
+ return EXIT_OK;
141
+ }
142
+
143
+ writeAtomic(file, text);
144
+ const receipt = buildReceipt(compiled, { revision: revisionOf(text), changed: text !== loaded.text, inverse, diagnostics: after });
145
+ print(receipt, values, file);
146
+ return EXIT_OK;
147
+ }
148
+
149
+ function buildReceipt(compiled, extra) {
150
+ return {
151
+ ok: true,
152
+ verb: "add",
153
+ applied: compiled.ops,
154
+ added: compiled.added,
155
+ normalized: compiled.normalized,
156
+ dropped: compiled.dropped,
157
+ // The file's diagnostics after the write, not the fragment's. `set` reports
158
+ // the file's, and the two must answer the same question the same way: a
159
+ // receipt that says "0 warnings" about a file that has 22 of them tells the
160
+ // model the opposite of the truth, and the receipt is all the model sees.
161
+ diagnostics: extra.diagnostics ?? compiled.diagnostics,
162
+ revision: extra.revision,
163
+ changed: extra.changed,
164
+ };
165
+ }
166
+
167
+ function print(receipt, values, file) {
168
+ if (values.json) {
169
+ out(JSON.stringify(receipt));
170
+ return;
171
+ }
172
+ const lines = [];
173
+ if (receipt.added.length) lines.push(`added: ${receipt.added.join(", ")} (${receipt.added.length} elements)`);
174
+ for (const item of receipt.normalized) lines.push(`normalized: ${item.from} → ${item.to}`);
175
+ for (const item of receipt.dropped) {
176
+ lines.push(`dropped: ${item.prop}${item.value === undefined ? "" : `:${item.value}`} — ${item.reason}`);
177
+ if (item.replacement) lines.push(` use ${item.replacement}`);
178
+ }
179
+ lines.push(`applied ${receipt.applied.length} op, revision ${receipt.revision} (${receipt.diagnostics.filter((d) => d.severity === "error").length} errors, ${receipt.diagnostics.filter((d) => d.severity === "warning").length} warnings)`);
180
+ out(lines.join("\n"), { quiet: values.quiet });
181
+ }
182
+
183
+ function refuse(compiled, values, file, receipt) {
184
+ // The JSON payload carries the same `refused` block the human text prints.
185
+ //
186
+ // It used to carry neither: a refusal from the compiler produced
187
+ // `{ok:false, applied:[], changed:false}` with no code and no message, so a caller
188
+ // reading `--json` could not tell "refused because the fragment declares a
189
+ // `<label data-type="text">`" from "the edit happened to change nothing". The human
190
+ // branch read `compiled.refused` and said `REFUSED …`, so the two readers of the same
191
+ // result disagreed about what happened. Format-v3 §6 and `lib/receipt.mjs` R2 both say
192
+ // a failure carries the block.
193
+ const refusal = compiled.refused || { code: "cli/refused", message: "Refused." };
194
+ const payload = receipt || {
195
+ ...refusedReceipt("add", refusal.message, {
196
+ code: refusal.code,
197
+ hint: refusal.hint,
198
+ suggestion: refusal.suggestion,
199
+ diagnostics: compiled.diagnostics,
200
+ }),
201
+ normalized: compiled.normalized,
202
+ dropped: compiled.dropped,
203
+ };
204
+ if (values.json) {
205
+ out(JSON.stringify(payload));
206
+ } else {
207
+ const lines = [`REFUSED ${refusal.message}`];
208
+ if (refusal.hint) lines.push(` ${refusal.hint}`);
209
+ if (refusal.suggestion) lines.push(` ${refusal.suggestion}`);
210
+ for (const item of compiled.dropped) lines.push(` dropped: ${item.prop} — ${item.reason}`);
211
+ for (const d of payload.diagnostics) {
212
+ if (d.severity !== "error") continue;
213
+ lines.push(` error: ${d.code}${d.target ? ` at ${d.target}` : ""} — ${d.message}`);
214
+ if (d.hint) lines.push(` ${d.hint}`);
215
+ }
216
+ lines.push(`${file} was not changed.`);
217
+ out(lines.join("\n"));
218
+ }
219
+ return EXIT_INVALID;
220
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `node design.mjs export <file> [--page <id>] [--all] [-o path] [--resolve-tokens]`
3
+ *
4
+ * A deliverable, not a design file. The output is a page a browser renders on
5
+ * its own: the design's markup and inline styles, the token block so the custom
6
+ * properties still resolve, and a viewport sized to the page.
7
+ *
8
+ * `--all` writes one file per page.
9
+ */
10
+
11
+ import { mkdirSync, writeFileSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+
14
+ import { CliError, EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
15
+
16
+ export const USAGE = `Usage: node design.mjs export <file> [options]
17
+
18
+ --page <data-id> export this page (default: the first page)
19
+ --all write one file per page
20
+ --format <name> html (default), svg, png, jpeg or webp
21
+ -o, --out <path> output file, or output directory with --all
22
+ --scale <n> image scale, 1 to 3 (image formats)
23
+ --size WxH override the image size (image formats)
24
+ --raster auto|chrome use a headless browser (default: auto)
25
+ --resolve-tokens bake token values into the styles and drop the token block
26
+ --stdout print the HTML instead of writing a file
27
+ --json print a report as JSON
28
+ --quiet print nothing
29
+
30
+ Exit codes: 0 written, 1 refused, 2 unreadable or bad arguments.
31
+
32
+ Only pages can be exported as HTML. A deliverable is a page or a site, not a
33
+ subtree; use \`show <data-id>\` to read one element.`;
34
+
35
+ export default async function run(argv) {
36
+ const { values, positionals } = parseArgs(argv, {
37
+ flags: {
38
+ ...SHARED_FLAGS,
39
+ page: { type: "string" },
40
+ all: { type: "boolean" },
41
+ out: { type: "string", alias: ["o"] },
42
+ "resolve-tokens": { type: "boolean" },
43
+ format: { type: "string" },
44
+ scale: { type: "string" },
45
+ size: { type: "string" },
46
+ raster: { type: "string" },
47
+ stdout: { type: "boolean" },
48
+ "keep-comments": { type: "boolean" },
49
+ },
50
+ positionals: { min: 1, max: 1 },
51
+ usage: USAGE,
52
+ });
53
+
54
+ if (values.help) {
55
+ out(USAGE);
56
+ return EXIT_OK;
57
+ }
58
+
59
+ const file = positionals[0];
60
+ const loaded = await readDesignFile(file);
61
+ const module = await import("../../export-html.mjs");
62
+ const options = {
63
+ page: values.page,
64
+ resolveTokens: values["resolve-tokens"] === true,
65
+ keepComments: values["keep-comments"] === true,
66
+ };
67
+
68
+ const outPath = values.out ?? values.o;
69
+
70
+ if (values.all) {
71
+ let results;
72
+ try {
73
+ results = module.exportAllPages(loaded.doc, options);
74
+ } catch (error) {
75
+ return refuse(error);
76
+ }
77
+ const dir = resolve(outPath ?? "dist");
78
+ mkdirSync(dir, { recursive: true });
79
+ const written = [];
80
+ for (const item of results) {
81
+ const target = join(dir, item.file);
82
+ writeFileSync(target, item.html, "utf8");
83
+ written.push({ file: target, page: item.page.id, bytes: Buffer.byteLength(item.html) });
84
+ }
85
+ report(written, values, `${written.length} page${written.length === 1 ? "" : "s"} written to ${dir}`);
86
+ return EXIT_OK;
87
+ }
88
+
89
+ let result;
90
+ try {
91
+ result = module.exportPage(loaded.doc, options);
92
+ } catch (error) {
93
+ return refuse(error);
94
+ }
95
+
96
+ const format = String(values.format ?? "html").toLowerCase();
97
+ if (format !== "html") {
98
+ return exportOtherFormat({ format, result, file, values, outPath, options });
99
+ }
100
+
101
+ if (values.stdout) {
102
+ process.stdout.write(result.html);
103
+ return EXIT_OK;
104
+ }
105
+
106
+ const target = resolve(outPath ?? `${result.page.id}.html`);
107
+ mkdirSync(dirname(target), { recursive: true });
108
+ writeFileSync(target, result.html, "utf8");
109
+ report([{ file: target, page: result.page.id, bytes: Buffer.byteLength(result.html) }], values, `${file} → ${target} (${result.page.id}, ${Buffer.byteLength(result.html)} bytes)`);
110
+ return EXIT_OK;
111
+ }
112
+
113
+ /**
114
+ * Everything that is not HTML.
115
+ *
116
+ * `svg` needs no browser: the page is wrapped in `<svg><foreignObject>`, which
117
+ * browsers render and other tools generally do not. The raster formats need a
118
+ * browser, and when none can be found the command FAILS rather than writing a
119
+ * different format than the one that was asked for.
120
+ */
121
+ async function exportOtherFormat({ format, result, file, values, outPath, options }) {
122
+ const module = await import("../export-svg.mjs");
123
+ const target = resolve(outPath ?? `${result.page.id}.${format}`);
124
+
125
+ let written;
126
+ try {
127
+ written = await module.exportFile({
128
+ html: result.html,
129
+ page: result.page,
130
+ format,
131
+ out: target,
132
+ scale: values.scale === undefined ? 1 : Number(values.scale),
133
+ size: values.size,
134
+ raster: values.raster,
135
+ tmpDir: resolve(".justskill-design-tmp"),
136
+ });
137
+ } catch (error) {
138
+ return refuse(error);
139
+ }
140
+
141
+ report(
142
+ [{ file: written.path, page: result.page.id, bytes: written.bytes, route: written.route }],
143
+ values,
144
+ `${file} → ${written.path} (${result.page.id}, ${format}, ${written.bytes} bytes${written.route ? `, via ${written.route}` : ""})`,
145
+ );
146
+ return EXIT_OK;
147
+ }
148
+
149
+ function report(written, values, line) {
150
+ if (values.json) {
151
+ out(JSON.stringify({ ok: true, verb: "export", written }));
152
+ return;
153
+ }
154
+ out(line, { quiet: values.quiet });
155
+ }
156
+
157
+ function refuse(error) {
158
+ const hint = error.hint ? `\n ${error.hint}` : "";
159
+ out(`REFUSED ${error.message}${hint}`);
160
+ return EXIT_INVALID;
161
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `node design.mjs format <file> [--check] [--dry-run]` — canonical text.
3
+ *
4
+ * `lib/normalize.mjs` decides what has to change; `lib/serialize.mjs` renders
5
+ * the result. `--check` writes nothing and exits 1 when the file is not
6
+ * canonical, which is what CI and a pre-commit hook use.
7
+ */
8
+
9
+ import process from "node:process";
10
+
11
+ import { EXIT_INVALID, EXIT_OK, SHARED_FLAGS, loadExport, out, parseArgs, readDesignFile } from "../cli.mjs";
12
+ import { writeAtomic } from "../fs.mjs";
13
+
14
+ export const USAGE = `Usage: node design.mjs format <file> [options]
15
+
16
+ --check write nothing; exit 1 when the file is not canonical
17
+ --dry-run print the canonical text and write nothing
18
+ --json print one line of JSON instead of a human line
19
+ --quiet print nothing on success
20
+ --help print this usage
21
+
22
+ Exit codes: 0 canonical, 1 not canonical, 2 unreadable.`;
23
+
24
+ /**
25
+ * Run both normalization layers and return the text to write.
26
+ *
27
+ * Layer one is semantic and lives in `lib/normalize.mjs`; it edits the document
28
+ * in place. Layer two is textual — LF line endings, one final newline — and is
29
+ * applied to the serialized result. `changed` is decided by comparing the final
30
+ * text with what is on disk, never by trusting a flag: that way a file whose
31
+ * only problem is a CRLF still counts as changed.
32
+ */
33
+ async function normalize(loaded) {
34
+ const serialize = await loadExport("serialize.mjs", ["serialize", "default"], "serialize");
35
+ const normalizeDocument = await loadExport(
36
+ "normalize.mjs",
37
+ ["normalizeDocument", "normalize", "default"],
38
+ "normalizeDocument",
39
+ );
40
+ const normalizeText = await loadExport("normalize.mjs", ["normalizeText"], "normalizeText");
41
+
42
+ // `serialize(origin, next)` diffs `next` against the `_raw` snapshots on the
43
+ // document that was read, which is what keeps the edit minimal.
44
+ const produced = await normalizeDocument(loaded.doc, {
45
+ file: loaded.file,
46
+ text: loaded.text,
47
+ format: loaded.format,
48
+ serialize: (origin, next) => serialize(origin, next),
49
+ });
50
+
51
+ const nextDoc = produced && typeof produced === "object" && produced.doc ? produced.doc : produced;
52
+ if (!nextDoc || typeof nextDoc !== "object") {
53
+ throw new Error("normalizeDocument() returned no document");
54
+ }
55
+
56
+ let text = produced && typeof produced.text === "string" ? produced.text : serialize(loaded.doc, nextDoc);
57
+ if (typeof text !== "string") text = loaded.text;
58
+
59
+ const layered = normalizeText(text);
60
+ text = typeof layered === "string" ? layered : layered && typeof layered.text === "string" ? layered.text : text;
61
+
62
+ return { text, changed: text !== loaded.text };
63
+ }
64
+
65
+ export default async function run(argv) {
66
+ const { values, positionals } = parseArgs(argv, {
67
+ flags: { ...SHARED_FLAGS, check: { type: "boolean" } },
68
+ positionals: { min: 1, max: 1 },
69
+ usage: USAGE,
70
+ });
71
+
72
+ if (values.help) {
73
+ out(USAGE);
74
+ return EXIT_OK;
75
+ }
76
+
77
+ const file = positionals[0];
78
+ const loaded = await readDesignFile(file);
79
+ const { text, changed } = await normalize(loaded);
80
+ const canonical = !changed && text === loaded.text;
81
+
82
+ if (values.check) {
83
+ if (values.json) {
84
+ out(JSON.stringify({ file, canonical, changed: !canonical, revision: loaded.revision }));
85
+ } else {
86
+ out(`${file}: ${canonical ? "canonical" : "not canonical"}`);
87
+ }
88
+ return canonical ? EXIT_OK : EXIT_INVALID;
89
+ }
90
+
91
+ if (values["dry-run"]) {
92
+ // The dry run's whole value is the text, so it prints even under --quiet.
93
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
94
+ out(`${file}: ${canonical ? "already canonical" : "would change"}`, { quiet: values.quiet });
95
+ return EXIT_OK;
96
+ }
97
+
98
+ if (canonical) {
99
+ out(`${file}: already canonical`, { quiet: values.quiet });
100
+ return EXIT_OK;
101
+ }
102
+
103
+ const written = writeAtomic(file, text);
104
+ out(`${file}: formatted (${written.bytes} bytes)`, { quiet: values.quiet });
105
+ return EXIT_OK;
106
+ }