justskill-design 0.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * lib/export-html.mjs — a design file to a deliverable.
3
+ *
4
+ * A design file is already valid HTML, so "export" is mostly subtraction: take
5
+ * one page out, drop the design layer's bookkeeping, and make the result stand
6
+ * on its own.
7
+ *
8
+ * What the deliverable keeps:
9
+ * - the design's own tags, text and inline styles
10
+ * - the token block, unless `resolveTokens` bakes the values into the styles
11
+ * - the page's size, as a viewport meta so a browser renders it at that size
12
+ *
13
+ * What it drops:
14
+ * - `data-id`, `data-type` and every other `data-*` marker. The design layer
15
+ * is scaffolding for editing, not part of the page.
16
+ * - the page-frame rule, which exists so a file with several pages reads as a
17
+ * list. One page does not need it.
18
+ * - the page's place on the canvas, which is `data-canvas` and, in a file a
19
+ * canvas drag has touched, may also be spelled as `position:absolute` plus
20
+ * `left`/`top`. A standalone page is the whole document, so it has nothing
21
+ * to be positioned against.
22
+ * - `<title>` and `<meta name="design-schema">` are replaced: a deliverable
23
+ * needs the page's name, not the file's schema marker.
24
+ *
25
+ * What the deliverable takes from the page, rather than drops:
26
+ * - the page's own `background-color`, on `<body>`, so the area the page does
27
+ * not cover is not a colour the design never chose. See
28
+ * `documentBackground`.
29
+ *
30
+ * The renderer is shared with the canvas, so what a user dragged is what lands
31
+ * in the file. There is no second implementation to drift.
32
+ *
33
+ * Pure. No node: imports.
34
+ */
35
+
36
+ import { renderModel, escapeText, escapeAttr, webfontLinks, withoutRootRule, resolveTokens as resolveTokenValue } from "./render.mjs";
37
+ import { hasBaseRules } from "./base-css.mjs";
38
+ import { parseDeclarations } from "./parse.mjs";
39
+ import { styleMapOf } from "./render.mjs";
40
+ import { compareProperties, PAGE_PLACEMENT } from "./table.mjs";
41
+
42
+ const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
43
+ const ATTR_ESCAPES = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
44
+ const TEXT_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
45
+
46
+ /** Attributes that belong to the design layer, not to the deliverable. */
47
+ const DESIGN_ATTRS = /^data-(id|type|canvas|name|hidden|locked|kind|icon|icon-library|credit)$/;
48
+
49
+ export class ExportError extends Error {
50
+ constructor(code, message, hint) {
51
+ super(message);
52
+ this.name = "ExportError";
53
+ this.code = code;
54
+ this.hint = hint;
55
+ }
56
+ }
57
+
58
+ export function renderPageHtml(model, page, options = {}) {
59
+ const resolveTokens = options.resolveTokens === true;
60
+ const renderOptions = { resolveTokens };
61
+ const body = renderNodeHtml(deliverableRoot(page.node), model.tokens, renderOptions, options);
62
+
63
+ const head = [];
64
+ head.push('<meta charset="utf-8">');
65
+ head.push(`<title>${escapeText(page.name)}</title>`);
66
+ head.push(`<meta name="viewport" content="width=${cssLength(page.width)}, height=${cssLength(page.height)}">`);
67
+
68
+ // A deliverable declares the fonts it uses. Without this the browser falls
69
+ // back to a different family, the text wraps at different words, and the
70
+ // layout below it moves — so a page that looks right on the author's machine
71
+ // looks wrong on the reviewer's. Only the families the page actually uses are
72
+ // requested, and only the weights that appear.
73
+ const fontHrefs = options.fonts === false ? [] : webfontLinks(model, page);
74
+ for (const href of fontHrefs) head.push(`<link rel="stylesheet" href="${escapeAttr(href)}">`);
75
+
76
+ const styles = [];
77
+ if (!resolveTokens) {
78
+ // The tokens live in the file's <head>, not on the page, and every
79
+ // `var(--x)` in the page refers to them. Dropping the block would leave the
80
+ // deliverable with a broken color on every element that used one — and it
81
+ // would look fine in the canvas, which is the worst kind of bug.
82
+ const declarations = tokenDeclarations(model, page.node);
83
+ if (declarations) styles.push(`:root{${declarations}}`);
84
+ }
85
+ styles.push(fileCss(model));
86
+ head.push(`<style>${styles.join("\n")}</style>`);
87
+
88
+ return `<!doctype html>
89
+ <html lang="${escapeAttr(options.lang ?? "zh")}">
90
+ <head>
91
+ ${head.map((line) => ` ${line}`).join("\n")}
92
+ </head>
93
+ <body${documentBackground(page, model, resolveTokens)}>
94
+ ${body}
95
+ </body>
96
+ </html>
97
+ `;
98
+ }
99
+
100
+ /**
101
+ * The document's own surface, taken from the page.
102
+ *
103
+ * A deliverable is one page in a document, and the page covers its own box and
104
+ * nothing more. A viewport taller than the page shows whatever is behind it, and
105
+ * left white that is a colour the design never chose: in the reference file 13 of
106
+ * 26 pages are shorter than an 812px screen, the shortest by 339px — 42% of the
107
+ * screen. Pen's own export writes the frame's fill on `<body>` for the same
108
+ * reason, and a `.pen` file gives it nowhere else to read one: the format states
109
+ * no canvas colour.
110
+ *
111
+ * The value is copied, not invented, and only when the page states one. A page
112
+ * whose surface is a gradient or a photograph keeps the plain document: how a
113
+ * gradient covers an area larger than the page is a second decision, and this is
114
+ * not the place to make it.
115
+ */
116
+ function documentBackground(page, model, bakeTokens) {
117
+ const declared = styleMapOf(page.node).get("background-color");
118
+ if (!declared) return "";
119
+ const value = bakeTokens ? resolveTokenValue(declared, model.tokens) : declared;
120
+ if (!value || value === "transparent") return "";
121
+ return ` style="background-color:${escapeAttr(value)}"`;
122
+ }
123
+
124
+ /**
125
+ * The page as the deliverable sees it: without the canvas's placement.
126
+ *
127
+ * `data-canvas` is where a page sits, and the canvas positions it from there. A
128
+ * canvas drag also used to write that placement as `position:absolute` plus
129
+ * `left`/`top` on the page itself, which the canvas ignores and the deliverable
130
+ * does not: the page shipped absolutely positioned at (-85,-352), content
131
+ * pushed off the top-left corner of its own document. A standalone page is the
132
+ * whole document, so it has nothing to be positioned against.
133
+ */
134
+ function deliverableRoot(node) {
135
+ const raw = (node.attrs ?? []).find((a) => a.name === "style")?.value ?? "";
136
+ const declarations = parseDeclarations(raw);
137
+ // A page cannot carry a rotation (`style/transform-on-page`), but export does
138
+ // not validate the file it is handed, so the declaration is dropped here as
139
+ // well: the deliverable is the page's own box, and a transform on it would
140
+ // ship an artboard turned inside a viewport that does not turn with it.
141
+ const canvasChrome = new Set([...PAGE_PLACEMENT, "transform"]);
142
+ if (!declarations.some((d) => canvasChrome.has(d.property))) return node;
143
+ const kept = declarations.filter((d) => !canvasChrome.has(d.property));
144
+ const style = kept.map((d) => `${d.property}:${d.value}`).join(";");
145
+ return {
146
+ ...node,
147
+ attrs: (node.attrs ?? []).map((a) => (a.name === "style" ? { ...a, value: style } : a)),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The `:root` declarations for a deliverable: the file's token block, plus any
153
+ * custom property declared on the page itself. The ones the page mentions win,
154
+ * because a page may override a token for its own subtree.
155
+ */
156
+ function tokenDeclarations(model, pageNode) {
157
+ const found = new Map();
158
+ for (const [name, token] of model.tokens ?? new Map()) found.set(name, token.value);
159
+ const styleAttr = (pageNode.attrs ?? []).find((a) => a.name === "style");
160
+ if (styleAttr && /--/.test(styleAttr.value ?? "")) {
161
+ for (const declaration of parseDeclarations(styleAttr.value)) {
162
+ if (declaration.property.startsWith("--")) found.set(declaration.property, declaration.value);
163
+ }
164
+ }
165
+ return [...found.entries()].map(([name, value]) => `${name}:${value}`).join(";");
166
+ }
167
+
168
+ /**
169
+ * The rules the deliverable carries.
170
+ *
171
+ * These are the FILE's rules, copied. A deliverable is evidence about its
172
+ * source, and a deliverable that quietly adds a reset its source does not have
173
+ * proves the wrong thing: it looks right while the file it came from does not.
174
+ *
175
+ * The fallback is for a file written before the base block existed. A deliverable
176
+ * with no box model at all is a broken page, so one is added — and `validate`
177
+ * reports `style/no-base-rules` on the source, which is where the fix belongs.
178
+ */
179
+ function fileCss(model) {
180
+ const rules = withoutRootRule(model.styleText ?? "");
181
+ if (hasBaseRules(rules)) return rules;
182
+ return resetCss();
183
+ }
184
+
185
+ function resetCss() {
186
+ return [
187
+ "*,*::before,*::after{box-sizing:border-box}",
188
+ "html,body{margin:0;padding:0}",
189
+ "p,h1,h2,h3,h4,h5,h6{margin:0}",
190
+ "img{display:block;max-width:100%}",
191
+ // An icon's geometry is stroke-based and carries no fill of its own: the 67
192
+ // built-in glyphs are paths and circles with no `fill` and no `stroke`, so
193
+ // the SVG defaults apply and the browser paints every icon as a solid black
194
+ // shape. These attributes belong on the icon's own `<svg>`, and pen's export
195
+ // writes them there; they are also what makes `color` on the element tint
196
+ // the icon.
197
+ //
198
+ // The selector matches an icon and nothing else: a built-in glyph is always
199
+ // `<svg><path|circle>`, with no attribute of its own and no text. A caller
200
+ // can opt out with `svg[data-fill]`.
201
+ "svg:not([data-fill]) > :not([fill]):not([stroke]){fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}",
202
+ ].join("");
203
+ }
204
+
205
+ function cssLength(value) {
206
+ const text = String(value ?? "auto");
207
+ const match = /^(-?\d+(?:\.\d+)?)(px)?$/.exec(text.trim());
208
+ if (!match) return text.trim();
209
+ return `${match[1]}`;
210
+ }
211
+
212
+ /**
213
+ * Render one node for a deliverable.
214
+ *
215
+ * This is the renderer's node writer with the design attributes removed. It is a
216
+ * second walk rather than a post-processing regular expression, because a
217
+ * regular expression over HTML is how a `data-id` ends up inside a text node and
218
+ * a document gets corrupted.
219
+ */
220
+ export function renderNodeHtml(node, tokens, renderOptions = {}, options = {}) {
221
+ if (node.kind === "text") return escapeText(node.value);
222
+ if (node.kind === "comment") return options.keepComments ? (node.raw ?? "") : "";
223
+ if (node.kind !== "element") return "";
224
+
225
+ const attrs = [];
226
+ for (const attr of node.attrs ?? []) {
227
+ if (attr.name === "style") continue;
228
+ if (DESIGN_ATTRS.test(attr.name)) continue;
229
+ if (attr.value === null || attr.value === undefined) {
230
+ attrs.push(attr.rawName || attr.name);
231
+ continue;
232
+ }
233
+ attrs.push(`${attr.rawName || attr.name}="${escapeAttr(attr.value)}"`);
234
+ }
235
+
236
+ const styleText = serializeStyle(node, tokens, renderOptions);
237
+ if (styleText) attrs.push(`style="${escapeAttr(styleText)}"`);
238
+
239
+ const head = attrs.length ? `<${node.tag} ${attrs.join(" ")}>` : `<${node.tag}>`;
240
+ const body = (node.children ?? []).map((child) => renderNodeHtml(child, tokens, renderOptions, options)).join("");
241
+ if (VOID.has(node.tag)) return `${head}${body}`;
242
+ return `${head}${body}</${node.tag}>`;
243
+ }
244
+
245
+ /** A node's declarations, in table order, with tokens resolved or kept. */
246
+ export function serializeStyle(node, tokens, renderOptions = {}) {
247
+ const styleAttr = (node.attrs ?? []).find((a) => a.name === "style");
248
+ if (!styleAttr || !styleAttr.value) return "";
249
+ const declarations = parseDeclarations(styleAttr.value);
250
+ if (declarations.length === 0) return "";
251
+ const kept = declarations
252
+ .filter((d) => d.property !== "" && d.value !== "")
253
+ .map((d, index) => ({
254
+ property: d.property,
255
+ value: renderOptions.resolveTokens === false ? d.value : resolveTokenValue(d.value, tokens),
256
+ index,
257
+ }))
258
+ .sort((a, b) => compareProperties(a.property, b.property) || a.index - b.index);
259
+ return kept.map(({ property, value }) => `${property}:${value}`).join(";");
260
+ }
261
+
262
+ /**
263
+ * One page of a design file, as a standalone document.
264
+ *
265
+ * @param {object} doc parsed design file
266
+ * @param {object} options `{ page, resolveTokens, keepComments, lang }`
267
+ */
268
+ export function exportPage(doc, options = {}) {
269
+ const model = renderModel(doc, { resolveTokens: options.resolveTokens === true });
270
+ const pages = model.pages;
271
+ if (pages.length === 0) {
272
+ throw new ExportError("export/no-page", "The file has no page to export.", 'Add <section data-id="home" data-type="page">.');
273
+ }
274
+
275
+ let page;
276
+ if (options.page) {
277
+ page = pages.find((p) => p.id === options.page);
278
+ if (!page) {
279
+ throw new ExportError(
280
+ "export/unknown-page",
281
+ `There is no page with data-id ${JSON.stringify(options.page)}.`,
282
+ `Pages: ${pages.map((p) => p.id).join(", ")}.`,
283
+ );
284
+ }
285
+ } else {
286
+ page = pages[0];
287
+ }
288
+
289
+ return { html: renderPageHtml(model, page, options), page, model };
290
+ }
291
+
292
+ /** Every page, each written to a file named after its `data-id`. */
293
+ export function exportAllPages(doc, options = {}) {
294
+ const model = renderModel(doc, { resolveTokens: options.resolveTokens === true });
295
+ const wanted = model.pages;
296
+ if (wanted.length === 0) {
297
+ throw new ExportError("export/no-page", "The file has no page to export.", null);
298
+ }
299
+ return wanted.map((page) => ({
300
+ page,
301
+ file: `${page.id}.html`,
302
+ html: renderPageHtml(model, page, options),
303
+ }));
304
+ }
305
+
306
+ /** True when the request asks for something only a browser can produce. */
307
+ export function needsBrowser(format) {
308
+ return ["png", "jpeg", "jpg", "webp"].includes(String(format).toLowerCase());
309
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * lib/export-svg.mjs — a page as SVG, and as an image.
3
+ *
4
+ * ## SVG
5
+ *
6
+ * A page becomes one `<svg>` wrapping the HTML in `<foreignObject>`. This needs
7
+ * no browser, which makes it the only image-ish format that works on a machine
8
+ * with nothing installed.
9
+ *
10
+ * Be honest about what can open it: a browser renders `<foreignObject>` and
11
+ * Figma, Illustrator and Sketch show a blank rectangle. That is a property of
12
+ * the format, not a bug to work around, so the export says so.
13
+ *
14
+ * ## Raster
15
+ *
16
+ * PNG, JPEG and WebP need a browser, and `lib/node/raster-run.mjs` finds one.
17
+ * When none can be found the export fails and names three ways forward. It does
18
+ * NOT quietly write an SVG instead: a different format is a different
19
+ * deliverable, and nobody asked for it.
20
+ *
21
+ * Browser-safe for the SVG half. The raster half is in `lib/node/`.
22
+ */
23
+
24
+ import { escapeAttr, escapeText } from "./render.mjs";
25
+
26
+ export class ExportFormatError extends Error {
27
+ constructor(code, message, hint) {
28
+ super(message);
29
+ this.name = "ExportFormatError";
30
+ this.code = code;
31
+ this.hint = hint;
32
+ }
33
+ }
34
+
35
+ export const IMAGE_FORMATS = new Set(["svg", "png", "jpeg", "jpg", "webp"]);
36
+
37
+ /**
38
+ * One page as an SVG document.
39
+ *
40
+ * @param {object} options `{ html, page, title }`
41
+ */
42
+ export function pageToSvg(options) {
43
+ const html = String(options.html ?? "");
44
+ const page = options.page ?? {};
45
+ const width = numberOr(page.width, 390);
46
+ const height = numberOr(page.height, 844);
47
+ const title = options.title ?? page.name ?? "design";
48
+
49
+ // The body's own markup goes inside the foreignObject. A full document cannot
50
+ // nest inside an SVG, so the parts that carry meaning are lifted: the styles,
51
+ // the font links, and the body.
52
+ const styles = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n");
53
+ const links = [...html.matchAll(/<link[^>]*>/g)].map((m) => m[0]).join("\n");
54
+ const body = /<body[^>]*>([\s\S]*?)<\/body>/.exec(html)?.[1] ?? "";
55
+
56
+ return `<?xml version="1.0" encoding="UTF-8"?>
57
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xhtml="http://www.w3.org/1999/xhtml" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
58
+ <title>${escapeText(title)}</title>
59
+ <desc>Rendered from a justSkill design file. A browser draws this; Figma, Illustrator and Sketch show an empty rectangle because they do not implement foreignObject.</desc>
60
+ ${links ? ` ${links}\n` : ""} <style>${styles}
61
+ .sd-page{width:${width}px;height:${height}px;overflow:hidden;background:#ffffff}
62
+ </style>
63
+ <foreignObject x="0" y="0" width="${width}" height="${height}">
64
+ <xhtml:div class="sd-page" xmlns="http://www.w3.org/1999/xhtml">${body}</xhtml:div>
65
+ </foreignObject>
66
+ </svg>
67
+ `;
68
+ }
69
+
70
+ function numberOr(value, fallback) {
71
+ const parsed = parseFloat(String(value ?? ""));
72
+ return Number.isFinite(parsed) ? parsed : fallback;
73
+ }
74
+
75
+ /** A filename-safe extension for a format name. */
76
+ export function extensionFor(format) {
77
+ const name = String(format).toLowerCase();
78
+ return name === "jpg" ? "jpeg" : name;
79
+ }
80
+
81
+ /**
82
+ * The pixel size one page renders at.
83
+ *
84
+ * `--size` wins, then the page's own width and height, then the default. It lives here,
85
+ * in the pure half, because it is a policy about the document and not about files: the
86
+ * node half that writes the image should not be where the shape of a page is decided.
87
+ */
88
+ export function renderSize(page, sizeOption) {
89
+ const size = parseSize(sizeOption);
90
+ return {
91
+ width: size?.width ?? numberOr(page?.width, 390),
92
+ height: size?.height ?? numberOr(page?.height, 844),
93
+ };
94
+ }
95
+
96
+ /** `--size WxH`, or null when it is absent or unreadable. */
97
+ export function parseSize(value) {
98
+ if (value === undefined || value === null || value === "") return null;
99
+ const match = /^\s*(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*$/i.exec(String(value));
100
+ if (!match) {
101
+ throw new ExportFormatError("export/bad-size", `--size wants WxH, and got ${JSON.stringify(value)}.`, 'Such as --size 390x844.');
102
+ }
103
+ return { width: Number(match[1]), height: Number(match[2]) };
104
+ }
105
+
106
+ /** A scale the browser accepts, so a typo cannot produce a 100× image. */
107
+ export function clampScale(value) {
108
+ const parsed = Number(value);
109
+ if (!Number.isFinite(parsed)) return 1;
110
+ return Math.min(3, Math.max(1, Math.round(parsed)));
111
+ }