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
package/lib/detect.mjs ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Which reader opens this file?
3
+ *
4
+ * The extension decides first, because a caller who named a file `.html` means
5
+ * it. Content sniffing is the fallback for a file with no useful extension —
6
+ * `design` from stdin, or a fixture in a temp directory.
7
+ *
8
+ * PURE ESM — no `node:` imports, no filesystem. The caller reads the text; this
9
+ * module only decides what it is and which reader to load.
10
+ */
11
+
12
+ export class DetectError extends Error {
13
+ /**
14
+ * @param {string} code `detect/unknown-format`
15
+ * @param {string} message
16
+ */
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.name = "DetectError";
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Format name -> a lazy loader for its reader.
26
+ *
27
+ * Lazy so that `design.mjs --help` and an HTML-only session never load the pen
28
+ * reader, and so that a missing reader is an error at use, not at import.
29
+ */
30
+ export const READERS = Object.freeze({
31
+ html: () => import("./parse.mjs"),
32
+ pen: () => import("./readers/pen.mjs"),
33
+ });
34
+
35
+ /**
36
+ * The loader for `format`.
37
+ *
38
+ * @param {"html"|"pen"|string} format
39
+ * @returns {() => Promise<object>}
40
+ * @throws {DetectError} `detect/unknown-format`
41
+ */
42
+ export function readerFor(format) {
43
+ const load = READERS[format];
44
+ if (typeof load !== "function") {
45
+ const known = Object.keys(READERS).join(", ");
46
+ throw new DetectError(
47
+ "detect/unknown-format",
48
+ `No reader for format ${JSON.stringify(String(format))}: expected one of ${known}`,
49
+ );
50
+ }
51
+ return load;
52
+ }
53
+
54
+ /** The lower-case extension of a path, `.html` style, or `""`. */
55
+ export function extensionOf(file) {
56
+ if (typeof file !== "string" || file === "") return "";
57
+ const base = file.slice(file.lastIndexOf("/") + 1);
58
+ const dot = base.lastIndexOf(".");
59
+ if (dot <= 0) return "";
60
+ return base.slice(dot).toLowerCase();
61
+ }
62
+
63
+ const PEN_EXTENSIONS = new Set([".pen"]);
64
+ const HTML_EXTENSIONS = new Set([".html", ".htm"]);
65
+
66
+ /** True when the text is JSON with both a `version` and a `children` field. */
67
+ export function looksLikePen(text) {
68
+ if (typeof text !== "string") return false;
69
+ const trimmed = text.trimStart();
70
+ if (!trimmed.startsWith("{")) return false;
71
+ let value;
72
+ try {
73
+ value = JSON.parse(trimmed);
74
+ } catch {
75
+ return false;
76
+ }
77
+ return (
78
+ value !== null &&
79
+ typeof value === "object" &&
80
+ !Array.isArray(value) &&
81
+ value.version !== undefined &&
82
+ value.children !== undefined
83
+ );
84
+ }
85
+
86
+ /** True when a `<` appears before the first `{`. */
87
+ export function looksLikeHtml(text) {
88
+ if (typeof text !== "string") return false;
89
+ const angle = text.indexOf("<");
90
+ if (angle === -1) return false;
91
+ const brace = text.indexOf("{");
92
+ return brace === -1 || angle < brace;
93
+ }
94
+
95
+ /**
96
+ * `"html"`, `"pen"` or `"unknown"`.
97
+ *
98
+ * Rules, in order:
99
+ * 1. `.pen` extension -> pen.
100
+ * 2. `.html` / `.htm` extension -> html.
101
+ * 3. text that starts with `{` and parses as JSON with a `version` and a
102
+ * `children` field -> pen.
103
+ * 4. a `<` before the first `{` -> html.
104
+ * 5. otherwise unknown.
105
+ *
106
+ * The extension beats the content in both directions, which is the rule a
107
+ * renamed file depends on: `page.html` that happens to hold pen JSON is still
108
+ * read as HTML, and a `.pen` file with markup is still read as pen.
109
+ *
110
+ * @param {string} path
111
+ * @param {string} text
112
+ * @returns {"html"|"pen"|"unknown"}
113
+ */
114
+ export function detectFormat(path, text) {
115
+ const extension = extensionOf(path);
116
+ if (PEN_EXTENSIONS.has(extension)) return "pen";
117
+ if (HTML_EXTENSIONS.has(extension)) return "html";
118
+
119
+ const source = typeof text === "string" ? text : "";
120
+ if (looksLikePen(source)) return "pen";
121
+ if (looksLikeHtml(source)) return "html";
122
+ return "unknown";
123
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Diagnostics: grouping them, filtering them, sorting them, and finding the
3
+ * element one is about.
4
+ *
5
+ * PURE ESM — no `node:` imports, no DOM. Everything a report decides is decided
6
+ * here, so a test can reach it.
7
+ *
8
+ * A v3 diagnostic looks like this (docs/format-v3.md §6):
9
+ *
10
+ * { severity: "error", code: "structure/duplicate-id", target: "home__title",
11
+ * path: "body > section > div > p", offset: 1234, line: 42, column: 7,
12
+ * message: "…", hint: "…", fix: "…" }
13
+ *
14
+ * `target` is a `data-id`; `offset` is a character offset into the source. A
15
+ * finding is located by either, and a finding that names no element inside a
16
+ * page is answered by that page's root.
17
+ */
18
+
19
+ const SEVERITY_RANK = { error: 0, warning: 1 };
20
+
21
+ /** Fold a severity to the two values the format defines. */
22
+ export function normalizeSeverity(severity) {
23
+ return severity === "error" ? "error" : "warning";
24
+ }
25
+
26
+ function rankOf(diagnostic) {
27
+ return SEVERITY_RANK[normalizeSeverity(diagnostic && diagnostic.severity)];
28
+ }
29
+
30
+ function offsetOf(diagnostic) {
31
+ const value = diagnostic && diagnostic.offset;
32
+ return Number.isFinite(value) ? value : Infinity;
33
+ }
34
+
35
+ /* ------------------------------------------------------------------ *
36
+ * Reading the document model
37
+ * ------------------------------------------------------------------ */
38
+
39
+ function asElement(node) {
40
+ if (!node || typeof node !== "object") return false;
41
+ if (node.kind === "element") return true;
42
+ // Tolerant of a plain `{ tag, attrs }` object: the element contract needs both.
43
+ return typeof node.tag === "string" && Array.isArray(node.attrs);
44
+ }
45
+
46
+ /**
47
+ * The value of one attribute, or null.
48
+ *
49
+ * `attrs` is an array of `{ kind: "attr", name, rawName, value, … }` in document
50
+ * order. A Map or a plain object is accepted too, so an older tree still reads.
51
+ *
52
+ * @param {object} node
53
+ * @param {string} name
54
+ * @returns {string|null}
55
+ */
56
+ export function attrValue(node, name) {
57
+ if (!node || typeof node !== "object") return null;
58
+ const attrs = node.attrs;
59
+ if (!attrs) return null;
60
+ if (Array.isArray(attrs)) {
61
+ for (const attr of attrs) {
62
+ if (!attr) continue;
63
+ const key = typeof attr.name === "string" ? attr.name : attr.rawName;
64
+ if (key !== name) continue;
65
+ if (attr.value === undefined || attr.value === null) return "";
66
+ return typeof attr.value === "string" ? attr.value : String(attr.value);
67
+ }
68
+ return null;
69
+ }
70
+ if (typeof attrs.get === "function") {
71
+ const value = attrs.get(name);
72
+ return value === undefined || value === null ? null : String(value);
73
+ }
74
+ if (typeof attrs === "object" && Object.prototype.hasOwnProperty.call(attrs, name)) {
75
+ const value = attrs[name];
76
+ return value === undefined || value === null ? null : String(value);
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** The `data-id` of a node, or null. */
82
+ export function dataIdOf(node) {
83
+ const value = attrValue(node, "data-id");
84
+ return value === null || value === "" ? null : value;
85
+ }
86
+
87
+ /** The `data-type` of a node, or null. */
88
+ export function dataTypeOf(node) {
89
+ const value = attrValue(node, "data-type");
90
+ return value === null || value === "" ? null : value;
91
+ }
92
+
93
+ /** True for `<section data-type="page">`. */
94
+ export function isPageElement(node) {
95
+ return asElement(node) && dataTypeOf(node) === "page";
96
+ }
97
+
98
+ function rootsOf(doc) {
99
+ if (!doc || typeof doc !== "object") return [];
100
+ if (Array.isArray(doc.children) && doc.children.length > 0) return doc.children;
101
+ const roots = [];
102
+ if (doc.html) roots.push(doc.html);
103
+ else {
104
+ if (doc.head) roots.push(doc.head);
105
+ if (doc.body) roots.push(doc.body);
106
+ }
107
+ return roots;
108
+ }
109
+
110
+ /**
111
+ * Every element of a document, in document order, plus the lookups a report
112
+ * needs: parent links, and `data-id` -> element (first wins, so a duplicate id
113
+ * resolves to the element that owns it first).
114
+ *
115
+ * @param {object} doc
116
+ * @returns {{ elements: object[], parents: Map<object, object|null>, byId: Map<string, object> }}
117
+ */
118
+ export function collectElements(doc) {
119
+ const elements = [];
120
+ const parents = new Map();
121
+ const byId = new Map();
122
+
123
+ const visit = (node, parent) => {
124
+ if (!node || typeof node !== "object") return;
125
+ if (Array.isArray(node)) {
126
+ for (const child of node) visit(child, parent);
127
+ return;
128
+ }
129
+ if (asElement(node)) {
130
+ elements.push(node);
131
+ const declared = node.parent && typeof node.parent === "object" ? node.parent : null;
132
+ parents.set(node, declared || parent || null);
133
+ const id = dataIdOf(node);
134
+ if (id !== null && !byId.has(id)) byId.set(id, node);
135
+ const children = Array.isArray(node.children) ? node.children : [];
136
+ for (const child of children) visit(child, node);
137
+ return;
138
+ }
139
+ const children = Array.isArray(node.children) ? node.children : [];
140
+ for (const child of children) visit(child, parent);
141
+ };
142
+
143
+ for (const root of rootsOf(doc)) visit(root, null);
144
+ return { elements, parents, byId };
145
+ }
146
+
147
+ /** Every page of a document, in document order. */
148
+ export function pagesOf(doc, index) {
149
+ const elements = index ? index.elements : collectElements(doc).elements;
150
+ return elements.filter(isPageElement);
151
+ }
152
+
153
+ function enclosingPage(element, parents, fallback) {
154
+ let current = element;
155
+ const seen = new Set();
156
+ while (current && typeof current === "object" && !seen.has(current)) {
157
+ seen.add(current);
158
+ if (isPageElement(current)) return current;
159
+ current = parents.get(current) || null;
160
+ }
161
+ return fallback || null;
162
+ }
163
+
164
+ function deepestElementAt(elements, offset) {
165
+ let best = null;
166
+ for (const element of elements) {
167
+ const start = Number.isFinite(element.start) ? element.start : null;
168
+ const end = Number.isFinite(element.end) ? element.end : null;
169
+ if (start === null || end === null) continue;
170
+ if (offset < start || offset >= end) continue;
171
+ if (best === null || start >= best.start) best = element;
172
+ }
173
+ return best;
174
+ }
175
+
176
+ /* ------------------------------------------------------------------ *
177
+ * Sorting, grouping, filtering
178
+ * ------------------------------------------------------------------ */
179
+
180
+ /**
181
+ * Errors before warnings, then by source offset, then by the order the
182
+ * validator produced. A finding with no offset sorts last inside its severity.
183
+ *
184
+ * @param {object[]} list
185
+ * @returns {object[]} a new array
186
+ */
187
+ export function sortDiagnostics(list) {
188
+ const source = Array.isArray(list) ? list : [];
189
+ return source
190
+ .map((diagnostic, index) => ({ diagnostic, index }))
191
+ .sort((a, b) => {
192
+ const bySeverity = rankOf(a.diagnostic) - rankOf(b.diagnostic);
193
+ if (bySeverity !== 0) return bySeverity;
194
+ const byOffset = offsetOf(a.diagnostic) - offsetOf(b.diagnostic);
195
+ if (byOffset !== 0) return byOffset;
196
+ return a.index - b.index;
197
+ })
198
+ .map((entry) => entry.diagnostic);
199
+ }
200
+
201
+ /**
202
+ * One row per `code`: the worst severity in the group, how many findings it has,
203
+ * the distinct `data-id`s it points at, and the message of its first finding.
204
+ *
205
+ * Sorted by severity, then by count descending, then by code, so the order never
206
+ * depends on the order findings arrived in.
207
+ *
208
+ * @param {object[]} list
209
+ * @returns {{code: string, severity: string, count: number, targets: string[], message: string}[]}
210
+ */
211
+ export function groupDiagnostics(list) {
212
+ const groups = new Map();
213
+ for (const diagnostic of Array.isArray(list) ? list : []) {
214
+ if (!diagnostic) continue;
215
+ const code = typeof diagnostic.code === "string" && diagnostic.code !== "" ? diagnostic.code : "(no code)";
216
+ const severity = normalizeSeverity(diagnostic.severity);
217
+ let group = groups.get(code);
218
+ if (!group) {
219
+ group = {
220
+ code,
221
+ severity,
222
+ count: 0,
223
+ targets: [],
224
+ message: typeof diagnostic.message === "string" ? diagnostic.message : "",
225
+ };
226
+ groups.set(code, group);
227
+ }
228
+ // A code normally has one severity; if it ever has two, the group is as bad
229
+ // as its worst finding rather than as good as its first.
230
+ if (SEVERITY_RANK[severity] < SEVERITY_RANK[group.severity]) group.severity = severity;
231
+ group.count += 1;
232
+ const target = typeof diagnostic.target === "string" && diagnostic.target !== "" ? diagnostic.target : null;
233
+ if (target !== null && !group.targets.includes(target)) group.targets.push(target);
234
+ }
235
+
236
+ return [...groups.values()].sort((a, b) => {
237
+ const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
238
+ if (bySeverity !== 0) return bySeverity;
239
+ if (a.count !== b.count) return b.count - a.count;
240
+ return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Findings of one severity and/or one code.
246
+ *
247
+ * @param {object[]} list
248
+ * @param {{severity?: string, code?: string|string[]}|string} [options]
249
+ * A string is read as `severity`, which is the v1 signature.
250
+ * @returns {object[]} a copy, so a caller cannot mutate the document's list
251
+ */
252
+ export function filterDiagnostics(list, options = {}) {
253
+ const source = Array.isArray(list) ? list : [];
254
+ const settings = typeof options === "string" ? { severity: options } : options || {};
255
+
256
+ const severity = settings.severity === undefined || settings.severity === "all" ? null : String(settings.severity);
257
+ const rawCode = settings.code;
258
+ const codes =
259
+ rawCode === undefined || rawCode === null || rawCode === "all"
260
+ ? null
261
+ : new Set((Array.isArray(rawCode) ? rawCode : [rawCode]).map(String));
262
+
263
+ return source.filter((diagnostic) => {
264
+ if (!diagnostic) return false;
265
+ if (severity !== null && normalizeSeverity(diagnostic.severity) !== severity) return false;
266
+ if (codes !== null && !codes.has(String(diagnostic.code))) return false;
267
+ return true;
268
+ });
269
+ }
270
+
271
+ /** `{ errors, warnings, total }` for a list of diagnostics. */
272
+ export function summarize(list) {
273
+ const source = Array.isArray(list) ? list : [];
274
+ let errors = 0;
275
+ let warnings = 0;
276
+ for (const diagnostic of source) {
277
+ if (normalizeSeverity(diagnostic && diagnostic.severity) === "error") errors += 1;
278
+ else warnings += 1;
279
+ }
280
+ return { errors, warnings, total: source.length, ok: errors === 0 };
281
+ }
282
+
283
+ /**
284
+ * The identity of a diagnostic — docs/format-v3.md §6. Used to refuse only on a
285
+ * finding that was not there before.
286
+ *
287
+ * Four fields, and `target` earns its place: a diagnostic with no `offset` is
288
+ * common, and without `target` two errors that differ only in which element they
289
+ * are about collapse into one key. The gate would still refuse the edit, and the
290
+ * report would then show one row where there were two — a silent drop, which is
291
+ * the one thing this format does not do.
292
+ *
293
+ * This is the only implementation. `lib/validate.mjs` imports and re-exports it
294
+ * so the gate, the report and the documented contract cannot drift apart again.
295
+ *
296
+ * @param {object} diagnostic
297
+ * @returns {string}
298
+ */
299
+ export function diagnosticKey(diagnostic) {
300
+ if (!diagnostic) return "|||";
301
+ const code = typeof diagnostic.code === "string" ? diagnostic.code : "";
302
+ const offset = Number.isFinite(diagnostic.offset) ? diagnostic.offset : "";
303
+ const target = typeof diagnostic.target === "string" ? diagnostic.target : "";
304
+ const message = typeof diagnostic.message === "string" ? diagnostic.message : "";
305
+ return `${code}|${offset}|${target}|${message}`;
306
+ }
307
+
308
+ /** Drop duplicates that share a `diagnosticKey`, keeping the first. */
309
+ export function dedupeDiagnostics(list) {
310
+ const seen = new Set();
311
+ const out = [];
312
+ for (const diagnostic of Array.isArray(list) ? list : []) {
313
+ if (!diagnostic) continue;
314
+ const key = diagnosticKey(diagnostic);
315
+ if (seen.has(key)) continue;
316
+ seen.add(key);
317
+ out.push(diagnostic);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ *
323
+ * Locating a finding
324
+ * ------------------------------------------------------------------ */
325
+
326
+ function looksLikeDocument(value) {
327
+ if (!value || typeof value !== "object") return false;
328
+ if (value.kind === "document") return true;
329
+ return Array.isArray(value.children) && (value.body !== undefined || value.html !== undefined || value.children.length > 0);
330
+ }
331
+
332
+ /**
333
+ * Where on the canvas a diagnostic lives.
334
+ *
335
+ * The walk starts at the element named by `diagnostic.target` (a `data-id`), or
336
+ * failing that at the deepest element whose `[start, end)` contains
337
+ * `diagnostic.offset`. From there it walks up to the enclosing page. When the
338
+ * start element carries no `data-id` — an unrecognised tag, a text finding — the
339
+ * answer is the page root, which is the node that stands for the page.
340
+ *
341
+ * When the finding names no element and the file has exactly one page, that page
342
+ * is the answer; with several pages and no location there is nothing honest to
343
+ * point at, and the result is null.
344
+ *
345
+ * @param {object} diagnostic
346
+ * @param {object} doc
347
+ * @returns {{pageId: string|null, nodeId: string|null}|null}
348
+ */
349
+ export function resolveDiagnosticTarget(diagnostic, doc) {
350
+ // The contract is (diagnostic, doc); a caller that swapped them is understood
351
+ // rather than answered with a wrong location.
352
+ if (looksLikeDocument(diagnostic) && !looksLikeDocument(doc)) {
353
+ const swap = diagnostic;
354
+ diagnostic = doc;
355
+ doc = swap;
356
+ }
357
+ if (!diagnostic || !doc) return null;
358
+ const index = collectElements(doc);
359
+ const pages = index.elements.filter(isPageElement);
360
+
361
+ let element = null;
362
+ if (typeof diagnostic.target === "string" && diagnostic.target !== "") {
363
+ element = index.byId.get(diagnostic.target) || null;
364
+ }
365
+ if (!element && Number.isFinite(diagnostic.offset)) {
366
+ element = deepestElementAt(index.elements, diagnostic.offset);
367
+ }
368
+
369
+ if (element) {
370
+ const page = enclosingPage(element, index.parents, pages.length === 1 ? pages[0] : null);
371
+ if (!page) return null;
372
+ const pageId = dataIdOf(page);
373
+ const nodeId = dataIdOf(element) || pageId;
374
+ return { pageId, nodeId };
375
+ }
376
+
377
+ if (pages.length === 1) {
378
+ const pageId = dataIdOf(pages[0]);
379
+ return { pageId, nodeId: pageId };
380
+ }
381
+ return null;
382
+ }