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,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/node/export-svg.mjs — the half of image export that touches the machine.
|
|
3
|
+
*
|
|
4
|
+
* `lib/export-svg.mjs` builds an SVG document and decides how big a page renders; this
|
|
5
|
+
* writes bytes and may start a browser. The split is a purity boundary, not a taste
|
|
6
|
+
* boundary: every file directly under `lib/` is served to the canvas over HTTP, so a
|
|
7
|
+
* module that imports `node:fs` is a module the browser downloads and then dies on. The
|
|
8
|
+
* old single file dodged that with `await import("node:fs")` inside a function — which
|
|
9
|
+
* works, and hides the violation from a reader and from every check that looks at the top
|
|
10
|
+
* of a file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { ExportFormatError, clampScale, pageToSvg, renderSize } from "../export-svg.mjs";
|
|
17
|
+
|
|
18
|
+
/** A filename-safe fragment of a page id, for the intermediate file. */
|
|
19
|
+
function slug(value) {
|
|
20
|
+
return String(value).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 40) || "page";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write one export in whatever format was asked for.
|
|
25
|
+
*
|
|
26
|
+
* The format decision itself lives in `lib/raster.mjs`, which is why a missing browser
|
|
27
|
+
* produces a refusal naming the alternatives rather than a silent substitution.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} options `{ html, page, format, out, scale, size, raster, tmpDir }`
|
|
30
|
+
*/
|
|
31
|
+
export async function exportFile(options) {
|
|
32
|
+
const format = String(options.format).toLowerCase();
|
|
33
|
+
const out = options.out;
|
|
34
|
+
|
|
35
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
36
|
+
|
|
37
|
+
if (format === "svg") {
|
|
38
|
+
const svg = pageToSvg(options);
|
|
39
|
+
fs.writeFileSync(out, svg, "utf8");
|
|
40
|
+
return { path: out, bytes: Buffer.byteLength(svg), route: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!["png", "jpeg", "jpg", "webp"].includes(format)) {
|
|
44
|
+
throw new ExportFormatError(
|
|
45
|
+
"export/unknown-format",
|
|
46
|
+
`There is no ${format} writer.`,
|
|
47
|
+
"Formats: html, svg, png, jpeg, webp, pen.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Everything below needs pixels, so the HTML has to exist somewhere the browser can
|
|
52
|
+
// open it.
|
|
53
|
+
const workDir = options.tmpDir ?? path.join(process.cwd(), ".justskill-design-tmp");
|
|
54
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
55
|
+
const htmlPath = path.join(workDir, `${slug(options.page?.id ?? "page")}-${Date.now()}.html`);
|
|
56
|
+
fs.writeFileSync(htmlPath, options.html, "utf8");
|
|
57
|
+
|
|
58
|
+
const { width, height } = renderSize(options.page, options.size);
|
|
59
|
+
const { renderToImage } = await import("./raster-run.mjs");
|
|
60
|
+
try {
|
|
61
|
+
const result = await renderToImage({
|
|
62
|
+
htmlPath,
|
|
63
|
+
outPath: out,
|
|
64
|
+
width,
|
|
65
|
+
height,
|
|
66
|
+
scale: clampScale(options.scale),
|
|
67
|
+
raster: options.raster,
|
|
68
|
+
canvasOnline: options.canvasOnline === true,
|
|
69
|
+
});
|
|
70
|
+
return { path: result.path, bytes: result.bytes, route: `${result.route} (${result.browser})` };
|
|
71
|
+
} finally {
|
|
72
|
+
// The intermediate page is scaffolding, not a deliverable.
|
|
73
|
+
try { fs.rmSync(htmlPath, { force: true }); } catch { /* best effort */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
package/lib/node/fs.mjs
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only file helpers: reading, atomic writing, hashing, watching.
|
|
3
|
+
*
|
|
4
|
+
* This module and the rest of `lib/node/` are the only places that import
|
|
5
|
+
* `node:*`. Keep it that way — the browser canvas imports the other `lib/`
|
|
6
|
+
* modules directly.
|
|
7
|
+
*
|
|
8
|
+
* Every failure is an `FsError` with a code the CLI can map to an exit code:
|
|
9
|
+
* `fs/not-found`, `fs/unreadable`, `fs/no-write`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export const MIN_NODE_MAJOR = 18;
|
|
17
|
+
|
|
18
|
+
export class FsError extends Error {
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} code `fs/not-found` | `fs/unreadable` | `fs/no-write` | …
|
|
21
|
+
* @param {string} message
|
|
22
|
+
* @param {string} [file]
|
|
23
|
+
*/
|
|
24
|
+
constructor(code, message, file) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "FsError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
if (file !== undefined) this.file = file;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errnoOf(error) {
|
|
33
|
+
return error && typeof error.code === "string" ? error.code : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Node's major version, as a number. */
|
|
37
|
+
export function nodeMajor(version = process.versions.node) {
|
|
38
|
+
return Number(String(version).split(".")[0]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when the runtime is new enough for this tool. */
|
|
42
|
+
export function nodeVersionOk(version = process.versions.node) {
|
|
43
|
+
return nodeMajor(version) >= MIN_NODE_MAJOR;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Throw when the runtime is too old. */
|
|
47
|
+
export function assertNodeVersion() {
|
|
48
|
+
if (nodeVersionOk()) return;
|
|
49
|
+
throw new FsError(
|
|
50
|
+
"fs/node-version",
|
|
51
|
+
`justSkill needs Node ${MIN_NODE_MAJOR} or newer, but this is Node ${process.versions.node}.\n` +
|
|
52
|
+
"Install a current Node release (https://nodejs.org) and run the command again.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a text file.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} file
|
|
60
|
+
* @returns {string}
|
|
61
|
+
* @throws {FsError} `fs/not-found` when it does not exist, `fs/unreadable`
|
|
62
|
+
* otherwise (a directory, a permission problem, a broken link)
|
|
63
|
+
*/
|
|
64
|
+
export function readText(file) {
|
|
65
|
+
try {
|
|
66
|
+
return fs.readFileSync(file, "utf8");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const errno = errnoOf(error);
|
|
69
|
+
if (errno === "ENOENT" || errno === "ENOTDIR") {
|
|
70
|
+
throw new FsError("fs/not-found", `No such file: ${file}`, file);
|
|
71
|
+
}
|
|
72
|
+
if (errno === "EISDIR") {
|
|
73
|
+
throw new FsError("fs/unreadable", `Cannot read ${file}: it is a directory`, file);
|
|
74
|
+
}
|
|
75
|
+
if (errno === "EACCES") {
|
|
76
|
+
throw new FsError("fs/unreadable", `Cannot read ${file}: permission denied`, file);
|
|
77
|
+
}
|
|
78
|
+
throw new FsError("fs/unreadable", `Cannot read ${file}: ${error.message}`, file);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when anything exists at `file` (a file or a directory). */
|
|
83
|
+
export function fileExists(file) {
|
|
84
|
+
try {
|
|
85
|
+
fs.statSync(file);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The content hash of a text: the first 8 hex digits of sha256.
|
|
94
|
+
*
|
|
95
|
+
* Short on purpose — it labels a revision in a message, it does not guard a
|
|
96
|
+
* download.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function hashText(text) {
|
|
102
|
+
return crypto.createHash("sha256").update(String(text), "utf8").digest("hex").slice(0, 8);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The revision of a design file's text.
|
|
107
|
+
*
|
|
108
|
+
* Accepts a string, and tolerates a document object by hashing its `source` —
|
|
109
|
+
* the text the document was parsed from.
|
|
110
|
+
*
|
|
111
|
+
* @param {string|{source?: string}} text
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function revisionOf(text) {
|
|
115
|
+
if (typeof text === "string") return hashText(text);
|
|
116
|
+
if (text && typeof text.source === "string") return hashText(text.source);
|
|
117
|
+
return hashText(String(text));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Write `text` to `file` atomically: write a sibling temp file, fsync it,
|
|
122
|
+
* rename it over the target. A rename within one directory is atomic on POSIX,
|
|
123
|
+
* so a reader never sees a half-written design file, and the file's mode is the
|
|
124
|
+
* usual 0644 whatever the process umask says.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} file
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @returns {{file: string, bytes: number}}
|
|
129
|
+
* @throws {FsError} `fs/no-write`
|
|
130
|
+
*/
|
|
131
|
+
export function writeAtomic(file, text) {
|
|
132
|
+
const target = String(file);
|
|
133
|
+
const dir = path.dirname(target);
|
|
134
|
+
const base = path.basename(target);
|
|
135
|
+
const tmp = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`);
|
|
136
|
+
const body = String(text);
|
|
137
|
+
|
|
138
|
+
let fd = null;
|
|
139
|
+
try {
|
|
140
|
+
fd = fs.openSync(tmp, "wx", 0o644);
|
|
141
|
+
fs.writeFileSync(fd, body, "utf8");
|
|
142
|
+
fs.fsyncSync(fd);
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
fd = null;
|
|
145
|
+
fs.renameSync(tmp, target);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (fd !== null) {
|
|
148
|
+
try {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
} catch {
|
|
151
|
+
/* the descriptor may already be gone */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
fs.unlinkSync(tmp);
|
|
156
|
+
} catch {
|
|
157
|
+
/* the temp file may never have been created */
|
|
158
|
+
}
|
|
159
|
+
const errno = errnoOf(error);
|
|
160
|
+
if (errno === "ENOENT") {
|
|
161
|
+
throw new FsError("fs/no-write", `Cannot write ${target}: no such directory ${dir}`, target);
|
|
162
|
+
}
|
|
163
|
+
if (errno === "EACCES" || errno === "EPERM" || errno === "EROFS") {
|
|
164
|
+
throw new FsError("fs/no-write", `Cannot write ${target}: permission denied`, target);
|
|
165
|
+
}
|
|
166
|
+
if (errno === "EISDIR") {
|
|
167
|
+
throw new FsError("fs/no-write", `Cannot write ${target}: it is a directory`, target);
|
|
168
|
+
}
|
|
169
|
+
throw new FsError("fs/no-write", `Cannot write ${target}: ${error.message}`, target);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { file: target, bytes: Buffer.byteLength(body, "utf8") };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A small line diff, enough for a CLI preview.
|
|
177
|
+
*
|
|
178
|
+
* The common prefix and suffix are found, and everything between them becomes
|
|
179
|
+
* one hunk of `-` and `+` lines with `context` unchanged lines around it. This
|
|
180
|
+
* is not a general-purpose diff: a preview of a targeted edit is what it is for.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} before
|
|
183
|
+
* @param {string} after
|
|
184
|
+
* @param {string} [labelA]
|
|
185
|
+
* @param {string} [labelB]
|
|
186
|
+
* @returns {string} `""` when nothing changed
|
|
187
|
+
*/
|
|
188
|
+
export function unifiedDiff(before, after, labelA = "before", labelB = "after") {
|
|
189
|
+
if (labelA && typeof labelA === "object") {
|
|
190
|
+
const options = labelA;
|
|
191
|
+
labelA = options.labelA || options.label || "before";
|
|
192
|
+
labelB = options.labelB || options.label || "after";
|
|
193
|
+
}
|
|
194
|
+
const a = String(before).split("\n");
|
|
195
|
+
const b = String(after).split("\n");
|
|
196
|
+
if (String(before) === String(after)) return "";
|
|
197
|
+
const context = 3;
|
|
198
|
+
|
|
199
|
+
let head = 0;
|
|
200
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
|
|
201
|
+
|
|
202
|
+
let aEnd = a.length;
|
|
203
|
+
let bEnd = b.length;
|
|
204
|
+
while (aEnd > head && bEnd > head && a[aEnd - 1] === b[bEnd - 1]) {
|
|
205
|
+
aEnd -= 1;
|
|
206
|
+
bEnd -= 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const from = Math.max(0, head - context);
|
|
210
|
+
const aTo = Math.min(a.length, aEnd + context);
|
|
211
|
+
const bTo = Math.min(b.length, bEnd + context);
|
|
212
|
+
|
|
213
|
+
const lines = [
|
|
214
|
+
`--- ${labelA}`,
|
|
215
|
+
`+++ ${labelB}`,
|
|
216
|
+
`@@ -${from + 1},${aTo - from} +${from + 1},${bTo - from} @@`,
|
|
217
|
+
];
|
|
218
|
+
for (let i = from; i < head; i += 1) lines.push(` ${a[i]}`);
|
|
219
|
+
for (let i = head; i < aEnd; i += 1) lines.push(`-${a[i]}`);
|
|
220
|
+
for (let i = head; i < bEnd; i += 1) lines.push(`+${b[i]}`);
|
|
221
|
+
for (let i = aEnd; i < aTo; i += 1) lines.push(` ${a[i]}`);
|
|
222
|
+
|
|
223
|
+
return `${lines.join("\n")}\n`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Watch a directory, debounced.
|
|
228
|
+
*
|
|
229
|
+
* `onChange` is called at most once per 100 ms of quiet with the batch of
|
|
230
|
+
* events that arrived in that window:
|
|
231
|
+
*
|
|
232
|
+
* onChange([{ eventType: "change", filename: "home.html", file: "/abs/home.html" }])
|
|
233
|
+
*
|
|
234
|
+
* Recursive watching is used where the platform supports it (macOS, Windows,
|
|
235
|
+
* and Linux on Node 20+) and falls back to the immediate directory otherwise.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} dir
|
|
238
|
+
* @param {(events: {eventType: string, filename: string, file: string}[]) => void} onChange
|
|
239
|
+
* @returns {() => void} close the watcher
|
|
240
|
+
* @throws {FsError} `fs/not-found` / `fs/unreadable`
|
|
241
|
+
*/
|
|
242
|
+
export function walkWatch(dir, onChange) {
|
|
243
|
+
const root = String(dir);
|
|
244
|
+
|
|
245
|
+
// `fs.watch` reports a missing directory differently per platform: macOS
|
|
246
|
+
// throws ENOENT synchronously, Linux hands back a watcher that emits the
|
|
247
|
+
// error later. The caller cannot act on an async error, so ask the
|
|
248
|
+
// filesystem first and answer the same way everywhere.
|
|
249
|
+
try {
|
|
250
|
+
fs.statSync(root);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (errnoOf(error) === "ENOENT") throw new FsError("fs/not-found", `No such directory: ${root}`, root);
|
|
253
|
+
throw new FsError("fs/unreadable", `Cannot watch ${root}: ${error.message}`, root);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const pending = new Map();
|
|
257
|
+
let timer = null;
|
|
258
|
+
let watcher = null;
|
|
259
|
+
|
|
260
|
+
const flush = () => {
|
|
261
|
+
timer = null;
|
|
262
|
+
const events = [...pending.values()];
|
|
263
|
+
pending.clear();
|
|
264
|
+
if (events.length === 0) return;
|
|
265
|
+
try {
|
|
266
|
+
onChange(events);
|
|
267
|
+
} catch {
|
|
268
|
+
/* a listener that throws must not kill the watcher */
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const listener = (eventType, filename) => {
|
|
273
|
+
const name = filename === null || filename === undefined ? "" : String(filename);
|
|
274
|
+
pending.set(name, { eventType: String(eventType), filename: name, file: name ? path.join(root, name) : root });
|
|
275
|
+
if (timer === null) timer = setTimeout(flush, 100);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const open = (options) => fs.watch(root, options, listener);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
watcher = open({ recursive: true });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (errnoOf(error) === "ENOENT") throw new FsError("fs/not-found", `No such directory: ${root}`, root);
|
|
284
|
+
try {
|
|
285
|
+
watcher = open({});
|
|
286
|
+
} catch (fallbackError) {
|
|
287
|
+
const errno = errnoOf(fallbackError);
|
|
288
|
+
if (errno === "ENOENT") throw new FsError("fs/not-found", `No such directory: ${root}`, root);
|
|
289
|
+
throw new FsError("fs/unreadable", `Cannot watch ${root}: ${fallbackError.message}`, root);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
watcher.on("error", () => {
|
|
294
|
+
/* a watcher error is not a reason to crash the process */
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return function close() {
|
|
298
|
+
if (timer !== null) {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
timer = null;
|
|
301
|
+
}
|
|
302
|
+
pending.clear();
|
|
303
|
+
try {
|
|
304
|
+
watcher.close();
|
|
305
|
+
} catch {
|
|
306
|
+
/* already closed */
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/node/op-json.mjs — an op list as it can leave this process.
|
|
3
|
+
*
|
|
4
|
+
* An op list is not JSON, and the reason is worth writing down. An inverse — the
|
|
5
|
+
* op list that undoes a gesture — carries the removed or replaced content as
|
|
6
|
+
* *live* node objects, so `applyOps` can put the exact object back: same source
|
|
7
|
+
* offsets, which is what keeps a later save minimal, same attribute order. Those
|
|
8
|
+
* objects hold `parent` links (and the serializer's `_raw` snapshot), so they
|
|
9
|
+
* close a cycle through `children` and `JSON.stringify` throws on them.
|
|
10
|
+
*
|
|
11
|
+
* Two callers need the same projection, and neither should own it: the CLI's
|
|
12
|
+
* `--json` output, and the canvas service, whose response is JSON. The server
|
|
13
|
+
* used to send `result.inverse` as it stood, which meant a *text* edit — the one
|
|
14
|
+
* op whose inverse is a whole node — wrote the file, failed to serialize the
|
|
15
|
+
* response, and answered 500; the canvas then said "Refused: internal" about an
|
|
16
|
+
* edit that had landed.
|
|
17
|
+
*
|
|
18
|
+
* Only the fields that define an op survive. A node's content is already
|
|
19
|
+
* available as its `html` bytes, so nothing a caller needs is lost, and the
|
|
20
|
+
* receipt stays a document a reader can act on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The fields that may survive.
|
|
25
|
+
*
|
|
26
|
+
* This is a whitelist rather than a blacklist on purpose: the point is to keep
|
|
27
|
+
* `parent` and `_raw` out, and a node carries a closed set of fields, so a
|
|
28
|
+
* whitelist cannot leak a cycle. It must include an attribute's `name` and
|
|
29
|
+
* `value`, or an inverse that reinserts a removed subtree would produce
|
|
30
|
+
* attributes with no names.
|
|
31
|
+
*/
|
|
32
|
+
const OP_FIELDS = new Set([
|
|
33
|
+
"op", "target", "prop", "value", "index", "html", "children", "node",
|
|
34
|
+
"kind", "raw", "tag", "attrs", "selfClosing", "before", "after", "into",
|
|
35
|
+
"name", "rawName", "quote",
|
|
36
|
+
// A token op's exact bytes. Without these the receipt would print an inverse that
|
|
37
|
+
// no longer says where a removed token came from, and re-applying it would put the
|
|
38
|
+
// declaration back at the end of the block instead of where it was read.
|
|
39
|
+
"at", "span", "restore",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A text or comment node inside a subtree keeps its content.
|
|
44
|
+
*
|
|
45
|
+
* The op fields above are a whitelist, and a text node's content lives in
|
|
46
|
+
* `value` and `raw` — which the op's own `value` shadows, so the key has to be
|
|
47
|
+
* kept for the node's sake. Dropping these does not lose a declaration, it loses
|
|
48
|
+
* the indentation between elements, and an undo that restores the structure but
|
|
49
|
+
* not the whitespace is not an undo.
|
|
50
|
+
*/
|
|
51
|
+
const ALWAYS_KEEP = new Set(["value", "raw"]);
|
|
52
|
+
|
|
53
|
+
/** Deep limits: a subtree is a few levels, and a cycle guard costs nothing. */
|
|
54
|
+
const MAX_DEPTH = 12;
|
|
55
|
+
|
|
56
|
+
export function jsonOps(ops) {
|
|
57
|
+
if (ops === undefined || ops === null) return null;
|
|
58
|
+
const walk = (value, depth) => {
|
|
59
|
+
if (depth > MAX_DEPTH) return null;
|
|
60
|
+
if (value === null || typeof value !== "object") {
|
|
61
|
+
return typeof value === "function" ? null : value;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) return value.map((entry) => walk(entry, depth + 1));
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
66
|
+
// `parent` closes a cycle through children; `_raw` is the parse-time
|
|
67
|
+
// snapshot, which is internal to the serializer.
|
|
68
|
+
if (key === "parent" || key === "_raw") continue;
|
|
69
|
+
if (depth > 0 && !OP_FIELDS.has(key) && !ALWAYS_KEEP.has(key)) continue;
|
|
70
|
+
out[key] = walk(entry, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
};
|
|
74
|
+
return walk(ops, 0);
|
|
75
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/node/raster-run.mjs — the part of rasterisation that starts a process.
|
|
3
|
+
*
|
|
4
|
+
* `lib/raster.mjs` decides; this runs. The split is what keeps the policy
|
|
5
|
+
* testable without launching a browser in a test.
|
|
6
|
+
*
|
|
7
|
+
* The browser is given a `--screenshot` path and a window size, waits for fonts,
|
|
8
|
+
* and exits. Nothing is installed and nothing is left running.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync, statSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
|
|
16
|
+
import { findBrowser, planRaster, screenshotArgs, RasterError } from "../raster.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The machine, as `lib/raster.mjs` needs to see it.
|
|
20
|
+
*
|
|
21
|
+
* That module is served to the canvas, so it may not import `node:fs` or read `process`.
|
|
22
|
+
* The three facts it needs to look for a browser are handed to it here instead, and this
|
|
23
|
+
* is the only place in the tree that answers them — which is what keeps "is there a
|
|
24
|
+
* browser" a policy question with one implementation.
|
|
25
|
+
*/
|
|
26
|
+
const MACHINE = {
|
|
27
|
+
exists: existsSync,
|
|
28
|
+
platform: process.platform,
|
|
29
|
+
which(name) {
|
|
30
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
31
|
+
const candidate = path.join(dir, name);
|
|
32
|
+
if (existsSync(candidate)) return candidate;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** `findBrowser` bound to this machine, in the shape `planRaster` asks for. */
|
|
39
|
+
const onThisMachine = () => findBrowser(MACHINE);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render one HTML file to one image.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} options `{ htmlPath, outPath, width, height, scale, raster, canvasOnline, timeoutMs }`
|
|
45
|
+
* @returns {Promise<{ path: string, bytes: number, route: string, browser: string }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function renderToImage(options) {
|
|
48
|
+
const plan = planRaster(
|
|
49
|
+
{ raster: options.raster, canvasOnline: options.canvasOnline },
|
|
50
|
+
{ findBrowser: onThisMachine },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (plan.route === "canvas") {
|
|
54
|
+
// The canvas is a browser that already has the page mounted. It rasterises
|
|
55
|
+
// and posts the bytes back, which is the same route the service offers for a
|
|
56
|
+
// machine with no Chrome-family browser installed.
|
|
57
|
+
throw new RasterError(
|
|
58
|
+
"raster/canvas-not-wired",
|
|
59
|
+
"The canvas route needs a canvas to be open and reachable.",
|
|
60
|
+
"Run `design.mjs serve <file>` and leave the tab open, or install a Chrome-family browser.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const args = screenshotArgs({
|
|
65
|
+
file: options.htmlPath,
|
|
66
|
+
out: options.outPath,
|
|
67
|
+
width: options.width,
|
|
68
|
+
height: options.height,
|
|
69
|
+
scale: options.scale ?? 1,
|
|
70
|
+
budgetMs: options.budgetMs ?? 6000,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = await run(plan.browser.path, args, options.timeoutMs ?? 60000);
|
|
74
|
+
if (result.code !== 0 && !existsSync(options.outPath)) {
|
|
75
|
+
throw new RasterError(
|
|
76
|
+
"raster/browser-failed",
|
|
77
|
+
`${plan.browser.name} exited ${result.code} without writing an image.`,
|
|
78
|
+
result.stderr.trim().split("\n").slice(-3).join(" ") || "Run the same command by hand to see the browser's output.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!existsSync(options.outPath)) {
|
|
82
|
+
throw new RasterError("raster/no-output", `${plan.browser.name} wrote no image.`, "Check that the page is a file the browser can open.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
path: options.outPath,
|
|
87
|
+
bytes: statSync(options.outPath).size,
|
|
88
|
+
route: plan.route,
|
|
89
|
+
browser: plan.browser.name,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Spawn, collect, never reject on a non-zero exit: the caller reads the code. */
|
|
94
|
+
function run(command, args, timeoutMs) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
97
|
+
let stdout = "";
|
|
98
|
+
let stderr = "";
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
reject(new RasterError("raster/timeout", `${command} did not finish within ${timeoutMs} ms.`, "Raise the timeout, or check the page for a request that never settles."));
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
|
|
104
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
105
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
106
|
+
child.on("error", (error) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
reject(new RasterError("raster/spawn-failed", `Cannot run ${command}: ${error.message}`, "Check the browser path with `which google-chrome`."));
|
|
109
|
+
});
|
|
110
|
+
child.on("close", (code) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve({ code, stdout, stderr });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|