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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs new <file> [flags]` — write a starter design file.
|
|
3
|
+
*
|
|
4
|
+
* The starter is the smallest file that satisfies docs/format-v3.md §1: one
|
|
5
|
+
* page, one frame, one text element, a token block and a page-frame block in
|
|
6
|
+
* `<head>`. It is canonical by construction — attribute order is `data-id`,
|
|
7
|
+
* `data-type`, then the rest alphabetically, then `style` — so `format --check`
|
|
8
|
+
* on a fresh file is quiet.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
|
|
13
|
+
import { CliError, EXIT_INVALID, SHARED_FLAGS, out, parseArgs } from "../cli.mjs";
|
|
14
|
+
import { baseCss } from "../../base-css.mjs";
|
|
15
|
+
import { fileExists, readText, writeAtomic } from "../fs.mjs";
|
|
16
|
+
|
|
17
|
+
export const USAGE = `Usage: node design.mjs new <file> [options]
|
|
18
|
+
|
|
19
|
+
--name N page id and file name to start from (default: the file name)
|
|
20
|
+
--title T <title> and the heading text (default: the name)
|
|
21
|
+
--tokens neutral|brand|dark
|
|
22
|
+
the token block to start from (default: neutral)
|
|
23
|
+
--tokens-file P read the token block from P instead (":root{ --x:1px }" or bare declarations)
|
|
24
|
+
--size WxH page size in px (default: 390x844)
|
|
25
|
+
|
|
26
|
+
--dry-run print the file and write nothing
|
|
27
|
+
--force overwrite an existing file
|
|
28
|
+
--quiet print nothing on success
|
|
29
|
+
--help print this usage
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 written, 1 refused, 2 bad arguments or an unreadable tokens file.`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Three starting token sets.
|
|
35
|
+
*
|
|
36
|
+
* Each one carries the seven groups, because a design that cannot name its
|
|
37
|
+
* colours, its spacing, its radii and its type sizes is a design where every
|
|
38
|
+
* element hardcodes a value and the user cannot restyle it in one edit. The
|
|
39
|
+
* groups are the closed set from the format, so these names are the vocabulary
|
|
40
|
+
* a page is written against.
|
|
41
|
+
*/
|
|
42
|
+
const TOKEN_PRESETS = {
|
|
43
|
+
neutral: [
|
|
44
|
+
"--color-bg:#f5f5f7", "--color-surface:#ffffff", "--color-ink:#111827",
|
|
45
|
+
"--color-muted:#6b7280", "--color-line:#e5e7eb", "--color-accent:#4c8dff",
|
|
46
|
+
"--space-xs:4px", "--space-sm:8px", "--space-md:16px", "--space-lg:24px", "--space-xl:32px",
|
|
47
|
+
"--radius-sm:8px", "--radius-md:12px", "--radius-lg:16px", "--radius-full:9999px",
|
|
48
|
+
"--size-caption:12px", "--size-label:14px", "--size-body:16px",
|
|
49
|
+
"--size-title:20px", "--size-headline:24px", "--size-display:32px",
|
|
50
|
+
"--font-body:Inter, system-ui, sans-serif",
|
|
51
|
+
"--shadow-card:0 1px 2px #00000014",
|
|
52
|
+
],
|
|
53
|
+
brand: [
|
|
54
|
+
"--color-bg:#ffffff", "--color-surface:#ffffff", "--color-ink:#1e1b4b",
|
|
55
|
+
"--color-muted:#6366f1", "--color-line:#e0e7ff", "--color-accent:#2563eb",
|
|
56
|
+
"--space-xs:4px", "--space-sm:8px", "--space-md:16px", "--space-lg:24px", "--space-xl:32px",
|
|
57
|
+
"--radius-sm:6px", "--radius-md:10px", "--radius-lg:16px", "--radius-full:9999px",
|
|
58
|
+
"--size-caption:12px", "--size-label:14px", "--size-body:16px",
|
|
59
|
+
"--size-title:20px", "--size-headline:24px", "--size-display:32px",
|
|
60
|
+
"--font-body:Inter, system-ui, sans-serif",
|
|
61
|
+
"--shadow-card:0 1px 2px #1e1b4b14",
|
|
62
|
+
],
|
|
63
|
+
dark: [
|
|
64
|
+
"--color-bg:#0b0d12", "--color-surface:#151922", "--color-ink:#f5f7fa",
|
|
65
|
+
"--color-muted:#9aa3b2", "--color-line:#2a2f38", "--color-accent:#4c8dff",
|
|
66
|
+
"--space-xs:4px", "--space-sm:8px", "--space-md:16px", "--space-lg:24px", "--space-xl:32px",
|
|
67
|
+
"--radius-sm:8px", "--radius-md:12px", "--radius-lg:16px", "--radius-full:9999px",
|
|
68
|
+
"--size-caption:12px", "--size-label:14px", "--size-body:16px",
|
|
69
|
+
"--size-title:20px", "--size-headline:24px", "--size-display:32px",
|
|
70
|
+
"--font-body:Inter, system-ui, sans-serif",
|
|
71
|
+
"--shadow-card:0 1px 2px #00000066",
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The page-frame block, verbatim from the contract's example. */
|
|
76
|
+
const PAGE_FRAME_BLOCK = 'section[data-type="page"]{ margin:0 0 24px }';
|
|
77
|
+
|
|
78
|
+
const ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
79
|
+
|
|
80
|
+
function escapeText(value) {
|
|
81
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeAttr(value) {
|
|
85
|
+
return escapeText(value).replace(/"/g, """);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function basenameWithoutExtension(file) {
|
|
89
|
+
const base = String(file).slice(String(file).lastIndexOf("/") + 1);
|
|
90
|
+
const dot = base.lastIndexOf(".");
|
|
91
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A usable `data-id` from whatever the caller typed. */
|
|
95
|
+
function slugId(raw, fallback = "home") {
|
|
96
|
+
const ascii = String(raw)
|
|
97
|
+
.normalize("NFKD")
|
|
98
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
99
|
+
.replace(/^[-_]+/, "")
|
|
100
|
+
.replace(/[-_]+$/, "")
|
|
101
|
+
.slice(0, 64);
|
|
102
|
+
return ID_PATTERN.test(ascii) ? ascii : fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Split CSS declarations on `;`, ignoring separators inside quotes, parens or comments. */
|
|
106
|
+
function splitDeclarations(text) {
|
|
107
|
+
const source = String(text);
|
|
108
|
+
const parts = [];
|
|
109
|
+
let start = 0;
|
|
110
|
+
let quote = null;
|
|
111
|
+
let depth = 0;
|
|
112
|
+
let comment = false;
|
|
113
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
114
|
+
const ch = source[i];
|
|
115
|
+
if (comment) {
|
|
116
|
+
if (ch === "*" && source[i + 1] === "/") {
|
|
117
|
+
comment = false;
|
|
118
|
+
i += 1;
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (quote !== null) {
|
|
123
|
+
if (ch === "\\") i += 1;
|
|
124
|
+
else if (ch === quote) quote = null;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
128
|
+
comment = true;
|
|
129
|
+
i += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (ch === '"' || ch === "'") {
|
|
133
|
+
quote = ch;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "(") {
|
|
137
|
+
depth += 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === ")") {
|
|
141
|
+
depth = Math.max(0, depth - 1);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === ";" && depth === 0) {
|
|
145
|
+
parts.push(source.slice(start, i));
|
|
146
|
+
start = i + 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
parts.push(source.slice(start));
|
|
150
|
+
return parts;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A `:root{ … }` token block built from a tokens file's text. */
|
|
154
|
+
function tokenBlockFromFile(text, file) {
|
|
155
|
+
const raw = String(text);
|
|
156
|
+
const match = raw.match(/:root\s*\{([\s\S]*?)\}/);
|
|
157
|
+
const body = match ? match[1] : raw;
|
|
158
|
+
|
|
159
|
+
const declarations = [];
|
|
160
|
+
for (const part of splitDeclarations(body)) {
|
|
161
|
+
const trimmed = part.trim().replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
162
|
+
if (trimmed === "") continue;
|
|
163
|
+
const colon = trimmed.indexOf(":");
|
|
164
|
+
if (colon === -1) continue;
|
|
165
|
+
const property = trimmed.slice(0, colon).trim();
|
|
166
|
+
const value = trimmed.slice(colon + 1).trim();
|
|
167
|
+
if (!/^--[A-Za-z0-9_-]+$/.test(property) || value === "") {
|
|
168
|
+
throw new CliError(`${file}: ${JSON.stringify(trimmed)} is not a custom property declaration`, EXIT_INVALID);
|
|
169
|
+
}
|
|
170
|
+
declarations.push(`${property}:${value}`);
|
|
171
|
+
}
|
|
172
|
+
if (declarations.length === 0) {
|
|
173
|
+
throw new CliError(`${file}: no custom properties found`, EXIT_INVALID);
|
|
174
|
+
}
|
|
175
|
+
return `:root{ ${declarations.join("; ")} }`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseSize(raw) {
|
|
179
|
+
const match = String(raw).trim().match(/^(\d+(?:\.\d+)?)\s*[xX*]\s*(\d+(?:\.\d+)?)$/);
|
|
180
|
+
if (!match) throw new CliError(`--size needs WxH in px, for example 390x844, got ${JSON.stringify(String(raw))}`);
|
|
181
|
+
const width = Number(match[1]);
|
|
182
|
+
const height = Number(match[2]);
|
|
183
|
+
if (!(width > 0) || !(height > 0)) throw new CliError(`--size needs positive numbers, got ${JSON.stringify(String(raw))}`);
|
|
184
|
+
const trim = (value) => (Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))));
|
|
185
|
+
return { width: trim(width), height: trim(height) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build the starter file's text.
|
|
190
|
+
*
|
|
191
|
+
* @param {{name?: string, title?: string, tokens?: string, tokensFile?: string, width?: string, height?: string, file?: string}} options
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function starterText(options = {}) {
|
|
195
|
+
const file = options.file || "design.html";
|
|
196
|
+
const pageId = slugId(options.name || basenameWithoutExtension(file));
|
|
197
|
+
const title = options.title || options.name || "design";
|
|
198
|
+
|
|
199
|
+
let tokenBlock;
|
|
200
|
+
if (options.tokensFile) {
|
|
201
|
+
tokenBlock = tokenBlockFromFile(readText(options.tokensFile), options.tokensFile);
|
|
202
|
+
} else {
|
|
203
|
+
const preset = options.tokens || "neutral";
|
|
204
|
+
const declarations = TOKEN_PRESETS[preset];
|
|
205
|
+
if (!declarations) {
|
|
206
|
+
throw new CliError(`--tokens needs neutral, brand or dark, got ${JSON.stringify(String(preset))}`);
|
|
207
|
+
}
|
|
208
|
+
tokenBlock = `:root{ ${declarations.join("; ")} }`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const width = options.width || "390";
|
|
212
|
+
const height = options.height || "844";
|
|
213
|
+
const heading = escapeText(title);
|
|
214
|
+
const documentTitle = escapeText(title);
|
|
215
|
+
|
|
216
|
+
return `<!doctype html>
|
|
217
|
+
<html lang="zh">
|
|
218
|
+
<head>
|
|
219
|
+
<meta charset="utf-8">
|
|
220
|
+
<title>${documentTitle}</title>
|
|
221
|
+
<meta name="design-schema" content="3.0">
|
|
222
|
+
<style>${baseCss()}\n${tokenBlock}</style>
|
|
223
|
+
<style>${PAGE_FRAME_BLOCK}</style>
|
|
224
|
+
</head>
|
|
225
|
+
<body>
|
|
226
|
+
<section data-id="${pageId}" data-type="page" data-canvas="0,0" style="width:${width}px;height:${height}px;overflow:hidden">
|
|
227
|
+
<div data-id="${pageId}__root" data-type="frame" style="display:flex;flex-direction:column;width:100%;height:100%">
|
|
228
|
+
<p data-id="${pageId}__title" data-type="text" style="font-size:32px;font-weight:700">${heading}</p>
|
|
229
|
+
</div>
|
|
230
|
+
</section>
|
|
231
|
+
</body>
|
|
232
|
+
</html>
|
|
233
|
+
`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default async function run(argv) {
|
|
237
|
+
const { values, positionals } = parseArgs(argv, {
|
|
238
|
+
flags: {
|
|
239
|
+
...SHARED_FLAGS,
|
|
240
|
+
name: { type: "string" },
|
|
241
|
+
title: { type: "string" },
|
|
242
|
+
tokens: { type: "string" },
|
|
243
|
+
"tokens-file": { type: "string" },
|
|
244
|
+
size: { type: "string" },
|
|
245
|
+
},
|
|
246
|
+
positionals: { min: 1, max: 1 },
|
|
247
|
+
usage: USAGE,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (values.help) {
|
|
251
|
+
out(USAGE);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const file = positionals[0];
|
|
256
|
+
if (values.tokens && values["tokens-file"]) {
|
|
257
|
+
throw new CliError("--tokens and --tokens-file cannot be combined");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const size = values.size ? parseSize(values.size) : { width: "390", height: "844" };
|
|
261
|
+
const text = starterText({
|
|
262
|
+
file,
|
|
263
|
+
name: values.name,
|
|
264
|
+
title: values.title,
|
|
265
|
+
tokens: values.tokens,
|
|
266
|
+
tokensFile: values["tokens-file"],
|
|
267
|
+
width: size.width,
|
|
268
|
+
height: size.height,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (values["dry-run"]) {
|
|
272
|
+
// The dry run's whole value is the text, so it prints even under --quiet.
|
|
273
|
+
process.stdout.write(text);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (fileExists(file) && !values.force) {
|
|
278
|
+
throw new CliError(`Refusing to overwrite ${file}: pass --force`, EXIT_INVALID);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const written = writeAtomic(file, text);
|
|
282
|
+
out(`${file}: wrote ${written.bytes} bytes`, { quiet: values.quiet });
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs remove <file> <data-id>`
|
|
3
|
+
*
|
|
4
|
+
* One op. The element and its subtree go, and the inverse op carries the whole
|
|
5
|
+
* removed subtree back, so undo restores the exact bytes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
9
|
+
import { applyAndWrite, reportWrite } from "../write-verbs.mjs";
|
|
10
|
+
|
|
11
|
+
export const USAGE = `Usage: node design.mjs remove <file> <data-id> [options]
|
|
12
|
+
|
|
13
|
+
--force write even when the edit introduces a new error
|
|
14
|
+
--dry-run report what would change, write nothing
|
|
15
|
+
--json print the receipt as JSON
|
|
16
|
+
--quiet print nothing
|
|
17
|
+
|
|
18
|
+
Exit codes: 0 removed, 1 refused, 2 unreadable or bad arguments.
|
|
19
|
+
|
|
20
|
+
Removing a page removes its whole subtree. Nothing is exempt: an edit that would
|
|
21
|
+
leave the file with a new error is refused, and --force is the way past it.`;
|
|
22
|
+
|
|
23
|
+
export default async function run(argv) {
|
|
24
|
+
const { values, positionals } = parseArgs(argv, {
|
|
25
|
+
flags: { ...SHARED_FLAGS },
|
|
26
|
+
positionals: { min: 2, max: 2 },
|
|
27
|
+
usage: USAGE,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (values.help) {
|
|
31
|
+
out(USAGE);
|
|
32
|
+
return EXIT_OK;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [file, target] = positionals;
|
|
36
|
+
const loaded = await readDesignFile(file);
|
|
37
|
+
const receipt = await applyAndWrite(loaded, [{ op: "remove", target }], { values, verb: "remove" });
|
|
38
|
+
if (receipt.refused) return 1;
|
|
39
|
+
reportWrite(receipt, values, file);
|
|
40
|
+
return EXIT_OK;
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs rename <file> <old-id> <new-id>`
|
|
3
|
+
*
|
|
4
|
+
* An id is an address. `set --attr data-id=…` can write the attribute, and it is
|
|
5
|
+
* refused so that the one documented way to change an id is this verb: `set`
|
|
6
|
+
* edits the thing a node is, and this verb is the only place that changes what a
|
|
7
|
+
* node is *called*. Nothing else in the document points at an id.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EXIT_INVALID, EXIT_OK, SHARED_FLAGS, out, parseArgs, readDesignFile } from "../cli.mjs";
|
|
11
|
+
import { applyAndWrite, refusedReceipt, reportRefusal, reportWrite } from "../write-verbs.mjs";
|
|
12
|
+
import { ID_PATTERN } from "../../table.mjs";
|
|
13
|
+
import { buildIdIndex } from "../../ops.mjs";
|
|
14
|
+
import { suggest } from "../../levenshtein.mjs";
|
|
15
|
+
|
|
16
|
+
export const USAGE = `Usage: node design.mjs rename <file> <old-id> <new-id> [options]
|
|
17
|
+
|
|
18
|
+
--force write even when the edit introduces a new error
|
|
19
|
+
--dry-run report what would change, write nothing
|
|
20
|
+
--json print the receipt as JSON
|
|
21
|
+
--quiet print nothing
|
|
22
|
+
|
|
23
|
+
Exit codes: 0 renamed, 1 refused, 2 unreadable or bad arguments.
|
|
24
|
+
|
|
25
|
+
An id is an address, so it is not a property you edit: \`set --attr data-id=…\` is
|
|
26
|
+
refused and this verb is the way through. It checks the new id against the one
|
|
27
|
+
pattern the format accepts, then writes it.`;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The op one rename needs.
|
|
31
|
+
*
|
|
32
|
+
* One op, and no cascade: an id is unique across the file, and nothing else in
|
|
33
|
+
* the document names another element by id.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} from
|
|
36
|
+
* @param {string} to
|
|
37
|
+
* @returns {Array<object>}
|
|
38
|
+
*/
|
|
39
|
+
export function renameOps(from, to) {
|
|
40
|
+
return [{ op: "set", target: from, prop: "data-id", value: to }];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default async function run(argv) {
|
|
44
|
+
const { values, positionals } = parseArgs(argv, {
|
|
45
|
+
flags: { ...SHARED_FLAGS },
|
|
46
|
+
positionals: { min: 3, max: 3 },
|
|
47
|
+
usage: USAGE,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (values.help) {
|
|
51
|
+
out(USAGE);
|
|
52
|
+
return EXIT_OK;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const [file, from, to] = positionals;
|
|
56
|
+
const loaded = await readDesignFile(file);
|
|
57
|
+
|
|
58
|
+
// Checked here, not by the funnel, because the funnel's only guard is the
|
|
59
|
+
// new-error rule and `--force` turns that off. Without this, `--force` would
|
|
60
|
+
// write an id the next `validate` refuses.
|
|
61
|
+
if (!ID_PATTERN.test(to)) {
|
|
62
|
+
const receipt = refusedReceipt("rename", `The id ${JSON.stringify(to)} is not a legal id.`, {
|
|
63
|
+
code: "structure/bad-id",
|
|
64
|
+
diagnostics: [],
|
|
65
|
+
hint: "Start with a letter or _, then letters, digits, _ and - only. 64 characters at most.",
|
|
66
|
+
});
|
|
67
|
+
reportRefusal(receipt, values, file);
|
|
68
|
+
return EXIT_INVALID;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const index = buildIdIndex(loaded.doc);
|
|
72
|
+
if (!index.has(from)) {
|
|
73
|
+
const near = suggest(from, [...index.keys()]);
|
|
74
|
+
const receipt = refusedReceipt("rename", `No node with data-id ${JSON.stringify(from)}.`, {
|
|
75
|
+
code: "ops/node-not-found",
|
|
76
|
+
diagnostics: [],
|
|
77
|
+
hint: near ? `Did you mean ${JSON.stringify(near)}?` : "Run `design ls` to list the ids in this file.",
|
|
78
|
+
});
|
|
79
|
+
reportRefusal(receipt, values, file);
|
|
80
|
+
return EXIT_INVALID;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const receipt = await applyAndWrite(loaded, renameOps(from, to), { values, verb: "rename" });
|
|
84
|
+
if (receipt.refused) return EXIT_INVALID;
|
|
85
|
+
reportWrite(receipt, values, file);
|
|
86
|
+
return EXIT_OK;
|
|
87
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `node design.mjs serve [<file>] [--port 0] [--ttl 30] [--no-open] [--force]`
|
|
3
|
+
*
|
|
4
|
+
* Opens the canvas: a transient local service on 127.0.0.1 that serves the
|
|
5
|
+
* design file as text, applies the ops the browser sends through the same write
|
|
6
|
+
* funnel as `add`, and closes when the browser says goodbye, when it has been
|
|
7
|
+
* idle for the TTL, or on SIGINT/SIGTERM.
|
|
8
|
+
*
|
|
9
|
+
* Invariants this verb upholds:
|
|
10
|
+
*
|
|
11
|
+
* - It prints the tokenised URL and nothing else; the service itself prints
|
|
12
|
+
* nothing, so a URL can never be lost inside a log line.
|
|
13
|
+
* - It stays in the foreground until the service closes, and a clean close —
|
|
14
|
+
* bye, TTL or signal — exits 0.
|
|
15
|
+
* - Opening the browser is best effort. A missing opener is not a failure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
CliError, EXIT_OK, EXIT_UNREADABLE, SHARED_FLAGS, out, parseArgs, readDesignFile,
|
|
23
|
+
} from "../cli.mjs";
|
|
24
|
+
import { fileExists, readText, revisionOf } from "../fs.mjs";
|
|
25
|
+
import { startServer } from "../server.mjs";
|
|
26
|
+
|
|
27
|
+
export const USAGE = `Usage: node design.mjs serve [<file>] [options]
|
|
28
|
+
|
|
29
|
+
--port <n> the port to listen on (default 0: the OS picks a free one)
|
|
30
|
+
--ttl <min> close after this many idle minutes (default 30; 0 never closes)
|
|
31
|
+
--no-open do not open the browser
|
|
32
|
+
--open open the browser even with --json
|
|
33
|
+
--force write even when an edit introduces a new error
|
|
34
|
+
--log append a JSONL change log beside the design file
|
|
35
|
+
--verbose print the service's log lines on stderr
|
|
36
|
+
--json print one line: { url, port, file, revision }
|
|
37
|
+
--quiet print only the URL
|
|
38
|
+
--help print this usage
|
|
39
|
+
|
|
40
|
+
The default file is design.html in the working directory.
|
|
41
|
+
|
|
42
|
+
Exit codes: 0 closed, 1 refused, 2 unreadable or bad arguments.`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Open `url` in the platform's browser.
|
|
46
|
+
*
|
|
47
|
+
* Detached and silent on purpose: the browser outlives this process, and a
|
|
48
|
+
* machine with no opener (a container, a bare CI runner) must not turn a
|
|
49
|
+
* working canvas into a failed command.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} url
|
|
52
|
+
*/
|
|
53
|
+
export function openBrowser(url) {
|
|
54
|
+
const platform = process.platform;
|
|
55
|
+
let command;
|
|
56
|
+
let args;
|
|
57
|
+
if (platform === "darwin") {
|
|
58
|
+
command = "open";
|
|
59
|
+
args = [url];
|
|
60
|
+
} else if (platform === "win32") {
|
|
61
|
+
// `start` is a cmd builtin, so it needs a shell to run it.
|
|
62
|
+
command = "cmd";
|
|
63
|
+
args = ["/c", "start", "", url];
|
|
64
|
+
} else {
|
|
65
|
+
command = "xdg-open";
|
|
66
|
+
args = [url];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
71
|
+
child.on("error", () => {
|
|
72
|
+
/* no opener on this machine */
|
|
73
|
+
});
|
|
74
|
+
child.unref();
|
|
75
|
+
} catch {
|
|
76
|
+
/* no opener on this machine */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default async function run(argv) {
|
|
81
|
+
const { values, positionals } = parseArgs(argv, {
|
|
82
|
+
flags: {
|
|
83
|
+
...SHARED_FLAGS,
|
|
84
|
+
port: { type: "number" },
|
|
85
|
+
ttl: { type: "number" },
|
|
86
|
+
open: { type: "boolean" },
|
|
87
|
+
log: { type: "boolean" },
|
|
88
|
+
verbose: { type: "boolean" },
|
|
89
|
+
},
|
|
90
|
+
positionals: { min: 0, max: 1 },
|
|
91
|
+
usage: USAGE,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (values.help) {
|
|
95
|
+
out(USAGE);
|
|
96
|
+
return EXIT_OK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const file = positionals[0] ?? "design.html";
|
|
100
|
+
const port = values.port === undefined ? 0 : values.port;
|
|
101
|
+
const ttl = values.ttl === undefined ? 30 : values.ttl;
|
|
102
|
+
|
|
103
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
104
|
+
throw new CliError(`--port takes an integer from 0 to 65535, got ${JSON.stringify(values.port)}`, EXIT_UNREADABLE, "serve/bad-port");
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isFinite(ttl) || ttl < 0) {
|
|
107
|
+
throw new CliError(`--ttl takes a number of minutes, 0 or more, got ${JSON.stringify(values.ttl)}`, EXIT_UNREADABLE, "serve/bad-ttl");
|
|
108
|
+
}
|
|
109
|
+
if (!fileExists(file)) {
|
|
110
|
+
throw new CliError(
|
|
111
|
+
`No such file: ${file}\nCreate one first: node design.mjs new ${file} --name home`,
|
|
112
|
+
EXIT_UNREADABLE,
|
|
113
|
+
"fs/not-found",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The file must be readable to serve it; an invalid file only makes the canvas
|
|
118
|
+
// read-only, so a parse failure here is reported and then ignored.
|
|
119
|
+
const source = readText(file);
|
|
120
|
+
let revision = revisionOf(source);
|
|
121
|
+
let warning = null;
|
|
122
|
+
try {
|
|
123
|
+
const loaded = await readDesignFile(file);
|
|
124
|
+
revision = loaded.revision;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error && error.name === "FsError") throw error;
|
|
127
|
+
warning = error && error.message ? error.message : String(error);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const server = await startServer({
|
|
131
|
+
file,
|
|
132
|
+
port,
|
|
133
|
+
ttlMinutes: ttl,
|
|
134
|
+
force: values.force === true,
|
|
135
|
+
log: values.log === true,
|
|
136
|
+
onLog: (line) => {
|
|
137
|
+
if (values.verbose) process.stderr.write(`${line}\n`);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (values.json) {
|
|
142
|
+
// The machine-readable line stays exactly one line; a problem with the file
|
|
143
|
+
// goes to stderr, where a caller can see it without parsing it.
|
|
144
|
+
if (warning) process.stderr.write(`warning: ${warning}\n`);
|
|
145
|
+
out(JSON.stringify({ url: server.url, port: server.port, file, revision }));
|
|
146
|
+
} else if (values.quiet) {
|
|
147
|
+
out(server.url);
|
|
148
|
+
} else {
|
|
149
|
+
const lines = [`justSkill canvas ${server.url}`, ` ${file} revision ${revision}`];
|
|
150
|
+
if (server.state().readOnly) lines.push(` read only ${server.state().readOnlyReason}`);
|
|
151
|
+
if (warning) lines.push(` warning ${warning}`);
|
|
152
|
+
if (ttl > 0) lines.push(` closes after ${ttl} idle minute${ttl === 1 ? "" : "s"}, or with Ctrl-C`);
|
|
153
|
+
out(lines.join("\n"));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shouldOpen = values.json === true || values.quiet === true
|
|
157
|
+
? values.open === true
|
|
158
|
+
: values.open !== false;
|
|
159
|
+
if (shouldOpen) openBrowser(server.url);
|
|
160
|
+
|
|
161
|
+
const reason = await server.closed;
|
|
162
|
+
if (!values.json) out(`closed: ${reason}`, { quiet: values.quiet });
|
|
163
|
+
return EXIT_OK;
|
|
164
|
+
}
|