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