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
package/lib/fragment.mjs
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/fragment.mjs — the modifier. A model sends an HTML snippet; this turns it into
|
|
3
|
+
* one op, or refuses it with a reason.
|
|
4
|
+
*
|
|
5
|
+
* PURE ESM. No `node:` imports, no DOM, no dependencies beyond `lib/`.
|
|
6
|
+
*
|
|
7
|
+
* ## What "compile" means here
|
|
8
|
+
*
|
|
9
|
+
* The snippet is not trusted and not written as it arrives. It is parsed, its
|
|
10
|
+
* structure is checked against the element contract, its attributes are filtered
|
|
11
|
+
* through the format table, every value is normalized, and only then is it written
|
|
12
|
+
* back out as the `html` of one `insert` op. Anything the table does not accept is
|
|
13
|
+
* reported — in `dropped`, or as a refusal. Nothing is discarded in silence.
|
|
14
|
+
*
|
|
15
|
+
* ## Where the rules come from (docs/format-v3.md)
|
|
16
|
+
*
|
|
17
|
+
* §3 the element contract: which tag may carry which `data-type`
|
|
18
|
+
* §7 the format table in `lib/table.mjs` — the single source of truth
|
|
19
|
+
* §8 the semantic normalization of values
|
|
20
|
+
*
|
|
21
|
+
* ## Assumptions where docs/format-v3.md is silent
|
|
22
|
+
*
|
|
23
|
+
* F1. An out-of-table *property* is dropped and reported (`cursor`, `margin`, …).
|
|
24
|
+
* An allowed property with a value the table cannot read *refuses* the whole
|
|
25
|
+
* fragment: dropping it would throw away intent, and writing it would produce a
|
|
26
|
+
* file the validator rejects. A table reason is printed as the drop reason,
|
|
27
|
+
* because that is how the model learns what to write instead.
|
|
28
|
+
* F2. An allowed attribute is `data-id`, `data-type`, any of `COMMON_OPTIONAL`, or
|
|
29
|
+
* one listed for the type in `ATTR_BY_TYPE`. Every other attribute — `class`,
|
|
30
|
+
* `id`, `onclick`, `width` — is dropped and reported.
|
|
31
|
+
* F3. A missing `data-id` is filled in as `<page>__<name-or-tag>`; a `data-name`
|
|
32
|
+
* that cannot be an id (Chinese, spaces, a leading digit) falls back to the tag.
|
|
33
|
+
* A missing `data-type` is always refused: it is the one attribute that says what
|
|
34
|
+
* the element *is*, and guessing a type is how a `<div>` becomes a `frame` when
|
|
35
|
+
* the author meant a `shape`.
|
|
36
|
+
* F4. A collision is refused, including one the auto-filled id creates. Inventing a
|
|
37
|
+
* suffix would rename the model's element behind its back. The refusal carries the
|
|
38
|
+
* nearest existing ids.
|
|
39
|
+
* F5. `options.as` fills in the root's missing id. If the root already carries a
|
|
40
|
+
* different id, that disagreement is refused rather than silently resolved.
|
|
41
|
+
* F6. `options.in` names the parent. Without it, a `page` snippet lands in `<body>`
|
|
42
|
+
* and anything else lands in the resolved page. `options.index` defaults to the
|
|
43
|
+
* end of the parent's children.
|
|
44
|
+
* F7. An address that names nothing is `ops/node-not-found`, the same code the op
|
|
45
|
+
* engine uses, so a caller can treat "no such parent" once.
|
|
46
|
+
* F8. Missing required attributes (`src`, `alt`, `data-kind`, …) are *not* refused
|
|
47
|
+
* here. §6 gives those findings to `lib/validate.mjs`, which sees the written file
|
|
48
|
+
* anyway; reporting them twice would double every `<img>`.
|
|
49
|
+
* F9. Values are normalized by `normalizeValue` from `lib/table.mjs` — one
|
|
50
|
+
* implementation for the compiler, the validator and the normalizer, as §7
|
|
51
|
+
* requires. The compiler never rewrites a value the table cannot read; it refuses
|
|
52
|
+
* (F1) and prints the table's own hint.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import {
|
|
56
|
+
RESERVED_CONTAINERS,
|
|
57
|
+
VOID_TAGS,
|
|
58
|
+
buildIdIndex,
|
|
59
|
+
escapeAttrValue,
|
|
60
|
+
escapeTextValue,
|
|
61
|
+
parseFragmentHtml,
|
|
62
|
+
parseStyleDeclarations,
|
|
63
|
+
} from "./ops.mjs";
|
|
64
|
+
import {
|
|
65
|
+
ATTR_BY_TYPE,
|
|
66
|
+
COMMON_OPTIONAL,
|
|
67
|
+
CSS_ALIASES,
|
|
68
|
+
ID_PATTERN,
|
|
69
|
+
REJECTED_PROPERTIES,
|
|
70
|
+
TAG_TO_TYPE,
|
|
71
|
+
TYPE_HAS_CHILDREN,
|
|
72
|
+
TYPE_TAGS,
|
|
73
|
+
TableError,
|
|
74
|
+
normalizeValue,
|
|
75
|
+
specFor,
|
|
76
|
+
} from "./table.mjs";
|
|
77
|
+
import { distance } from "./levenshtein.mjs";
|
|
78
|
+
import { shorthandHint } from "./validate.mjs";
|
|
79
|
+
|
|
80
|
+
const ID_MAX = 64;
|
|
81
|
+
|
|
82
|
+
function isObject(value) {
|
|
83
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isElement(node) {
|
|
87
|
+
return isObject(node) && (node.kind === "element" || typeof node.tag === "string");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function attrOf(node, name) {
|
|
91
|
+
const wanted = String(name).toLowerCase();
|
|
92
|
+
for (const attr of Array.isArray(node.attrs) ? node.attrs : []) {
|
|
93
|
+
if (attr && typeof attr.name === "string" && attr.name.toLowerCase() === wanted) return attr;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attrValueOf(node, name) {
|
|
99
|
+
const attr = attrOf(node, name);
|
|
100
|
+
return attr === null ? null : String(attr.value ?? "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function dataIdOf(node) {
|
|
104
|
+
const value = attrValueOf(node, "data-id");
|
|
105
|
+
return value === null || value === "" ? null : value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dataTypeOf(node) {
|
|
109
|
+
const value = attrValueOf(node, "data-type");
|
|
110
|
+
return value === null || value === "" ? null : value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Thrown by the walker, caught once by `compileFragment`. */
|
|
114
|
+
class Refusal extends Error {
|
|
115
|
+
constructor(code, message, extra = {}) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "Refusal";
|
|
118
|
+
this.code = code;
|
|
119
|
+
this.hint = extra.hint ?? null;
|
|
120
|
+
this.suggestion = extra.suggestion ?? null;
|
|
121
|
+
this.target = extra.target ?? null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ *
|
|
126
|
+
* Reading the document
|
|
127
|
+
* ------------------------------------------------------------------ */
|
|
128
|
+
|
|
129
|
+
function rootsOf(doc) {
|
|
130
|
+
const found = [];
|
|
131
|
+
const push = (value) => {
|
|
132
|
+
if (isObject(value) && !found.includes(value)) found.push(value);
|
|
133
|
+
};
|
|
134
|
+
if (Array.isArray(doc.children)) for (const child of doc.children) push(child);
|
|
135
|
+
for (const key of ["html", "head", "body"]) push(doc[key]);
|
|
136
|
+
return found;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Every element in document order, with the elements above it. */
|
|
140
|
+
function walkElements(doc) {
|
|
141
|
+
const out = [];
|
|
142
|
+
const visit = (node, ancestry) => {
|
|
143
|
+
for (const child of Array.isArray(node.children) ? node.children : []) {
|
|
144
|
+
if (!isElement(child)) continue;
|
|
145
|
+
out.push({ node: child, ancestry });
|
|
146
|
+
visit(child, ancestry.concat([child]));
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
for (const root of rootsOf(doc)) {
|
|
150
|
+
out.push({ node: root, ancestry: [] });
|
|
151
|
+
visit(root, [root]);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Pages in document order. */
|
|
157
|
+
export function pagesOf(doc) {
|
|
158
|
+
const pages = [];
|
|
159
|
+
for (const entry of walkElements(doc)) {
|
|
160
|
+
if (dataTypeOf(entry.node) === "page" && dataIdOf(entry.node) !== null) pages.push(entry.node);
|
|
161
|
+
}
|
|
162
|
+
return pages;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A data-id, or one of the four reserved container words. */
|
|
166
|
+
function findNode(doc, address) {
|
|
167
|
+
if (typeof address !== "string" || address === "") return null;
|
|
168
|
+
for (const entry of walkElements(doc)) {
|
|
169
|
+
if (dataIdOf(entry.node) === address) return entry.node;
|
|
170
|
+
}
|
|
171
|
+
if (!RESERVED_CONTAINERS.includes(address)) return null;
|
|
172
|
+
if (address === "document") return doc;
|
|
173
|
+
const slot = address === "html" ? doc.html : address === "head" ? doc.head : doc.body;
|
|
174
|
+
if (isObject(slot)) return slot;
|
|
175
|
+
for (const entry of walkElements(doc)) if (entry.node.tag === address) return entry.node;
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The innermost page above an element, or the element itself. */
|
|
180
|
+
function pageOfElement(doc, node) {
|
|
181
|
+
for (const entry of walkElements(doc)) {
|
|
182
|
+
if (entry.node !== node) continue;
|
|
183
|
+
for (let i = entry.ancestry.length - 1; i >= 0; i -= 1) {
|
|
184
|
+
if (dataTypeOf(entry.ancestry[i]) === "page") return entry.ancestry[i];
|
|
185
|
+
}
|
|
186
|
+
return dataTypeOf(node) === "page" ? node : null;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ *
|
|
192
|
+
* Ids
|
|
193
|
+
* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/** The first run of id characters in a display name, or null. */
|
|
196
|
+
function nameToken(value) {
|
|
197
|
+
if (typeof value !== "string") return null;
|
|
198
|
+
const match = /[A-Za-z_][A-Za-z0-9_-]*/.exec(value);
|
|
199
|
+
return match === null ? null : match[0];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function autoId(page, element) {
|
|
203
|
+
const prefix = nameToken(page === null ? null : dataIdOf(page)) ?? "page";
|
|
204
|
+
const token = nameToken(attrValueOf(element, "data-name")) ?? element.tag;
|
|
205
|
+
let id = `${prefix}__${token}`;
|
|
206
|
+
if (id.length > ID_MAX) id = id.slice(0, ID_MAX);
|
|
207
|
+
return ID_PATTERN.test(id) ? id : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The nearest ids in an index to `id`, closest first.
|
|
212
|
+
*
|
|
213
|
+
* `index` is whatever holds the ids: the `Map` from `buildIdIndex`, an array, a
|
|
214
|
+
* `Set`, or an object used as a set. Ties break alphabetically so the suggestion is
|
|
215
|
+
* the same on every run.
|
|
216
|
+
*/
|
|
217
|
+
export function nearestIds(index, id, limit = 3) {
|
|
218
|
+
const candidates = [];
|
|
219
|
+
if (index instanceof Map) candidates.push(...index.keys());
|
|
220
|
+
else if (index instanceof Set) candidates.push(...index);
|
|
221
|
+
else if (Array.isArray(index)) candidates.push(...index);
|
|
222
|
+
else if (isObject(index)) candidates.push(...Object.keys(index));
|
|
223
|
+
const target = String(id ?? "");
|
|
224
|
+
return candidates
|
|
225
|
+
.map((candidate) => ({ candidate: String(candidate), score: distance(target, String(candidate)) }))
|
|
226
|
+
.sort((a, b) => a.score - b.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
|
|
227
|
+
.slice(0, Math.max(0, limit))
|
|
228
|
+
.map((entry) => entry.candidate);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* ------------------------------------------------------------------ *
|
|
232
|
+
* Writing the fragment back
|
|
233
|
+
* ------------------------------------------------------------------ */
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Serialize nodes as HTML.
|
|
237
|
+
*
|
|
238
|
+
* Only what this module produces is ever read back by `parseFragmentHtml`, so the
|
|
239
|
+
* escaper covers exactly the entities that parser decodes. Attribute order is the
|
|
240
|
+
* order the compiler chose — `data-id`, `data-type`, the rest as written, `style`
|
|
241
|
+
* last — which is also the order §8 normalizes to.
|
|
242
|
+
*/
|
|
243
|
+
export function writeFragment(nodes) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const write = (list) => {
|
|
246
|
+
for (const node of Array.isArray(list) ? list : []) {
|
|
247
|
+
if (!isObject(node)) continue;
|
|
248
|
+
if (node.kind === "text") {
|
|
249
|
+
out.push(escapeTextValue(node.value ?? node.raw ?? ""));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (node.kind === "comment") {
|
|
253
|
+
out.push(typeof node.raw === "string" ? node.raw : `<!--${node.value ?? ""}-->`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (node.kind === "doctype") {
|
|
257
|
+
out.push(typeof node.raw === "string" ? node.raw : "<!doctype html>");
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!isElement(node)) {
|
|
261
|
+
if (typeof node.raw === "string") out.push(node.raw);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const attrs = (Array.isArray(node.attrs) ? node.attrs : [])
|
|
265
|
+
.map((attr) => ` ${attr.name}="${escapeAttrValue(attr.value)}"`)
|
|
266
|
+
.join("");
|
|
267
|
+
out.push(`<${node.tag}${attrs}>`);
|
|
268
|
+
if (!VOID_TAGS.has(node.tag)) {
|
|
269
|
+
write(node.children);
|
|
270
|
+
out.push(`</${node.tag}>`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
write(nodes);
|
|
275
|
+
return out.join("");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------------------ *
|
|
279
|
+
* Attributes and declarations
|
|
280
|
+
* ------------------------------------------------------------------ */
|
|
281
|
+
|
|
282
|
+
function isAllowedAttr(type, name) {
|
|
283
|
+
if (COMMON_OPTIONAL.includes(name)) return true;
|
|
284
|
+
const spec = ATTR_BY_TYPE[type];
|
|
285
|
+
if (!spec) return false;
|
|
286
|
+
return (spec.required ?? []).includes(name) || (spec.optional ?? []).includes(name);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Filter, normalize and rewrite one `style` attribute.
|
|
291
|
+
*
|
|
292
|
+
* @returns {string} the rebuilt attribute text — empty when nothing survived
|
|
293
|
+
* @throws {Refusal} when a value of an allowed property cannot be read (F1)
|
|
294
|
+
*/
|
|
295
|
+
function compileStyle(value, target, result) {
|
|
296
|
+
const kept = [];
|
|
297
|
+
for (const declaration of parseStyleDeclarations(String(value ?? ""))) {
|
|
298
|
+
const written = declaration.property.toLowerCase();
|
|
299
|
+
if (written === "") continue;
|
|
300
|
+
|
|
301
|
+
let property = written;
|
|
302
|
+
let alias = null;
|
|
303
|
+
if (!Object.prototype.hasOwnProperty.call(REJECTED_PROPERTIES, written) && specFor(written) === null) {
|
|
304
|
+
const renamed = CSS_ALIASES[written];
|
|
305
|
+
if (typeof renamed === "string" && specFor(renamed) !== null) {
|
|
306
|
+
alias = written;
|
|
307
|
+
property = renamed;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (Object.prototype.hasOwnProperty.call(REJECTED_PROPERTIES, written)) {
|
|
312
|
+
const rejected = REJECTED_PROPERTIES[written] ?? {};
|
|
313
|
+
// The hint is worked out from the value, so a one-sided margin is told to
|
|
314
|
+
// write `margin: 16px 0 0 0` rather than a fixed example with the wrong
|
|
315
|
+
// number in it.
|
|
316
|
+
const hint = shorthandHint(written, declaration.value)
|
|
317
|
+
?? (rejected.replacement ? `Use ${rejected.replacement} instead.` : null);
|
|
318
|
+
const reason = [rejected.reason ?? "Not in the format.", hint].filter(Boolean).join(" ");
|
|
319
|
+
result.dropped.push({ target, prop: written, value: declaration.value, reason, replacement: hint });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (specFor(property) === null) {
|
|
323
|
+
result.dropped.push({ target, prop: written, value: declaration.value, reason: "not in the format table" });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let normalized;
|
|
328
|
+
try {
|
|
329
|
+
normalized = normalizeValue(property, declaration.value);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (err instanceof TableError) {
|
|
332
|
+
throw new Refusal(err.code, `${written}: ${err.message}`, {
|
|
333
|
+
target,
|
|
334
|
+
hint: err.hint ?? specFor(property)?.hint ?? null,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
throw err;
|
|
338
|
+
}
|
|
339
|
+
if (alias !== null || normalized.changed) {
|
|
340
|
+
const entry = { target, prop: property, from: declaration.value, to: normalized.value };
|
|
341
|
+
if (alias !== null) entry.alias = alias;
|
|
342
|
+
result.normalized.push(entry);
|
|
343
|
+
}
|
|
344
|
+
kept.push(`${property}:${normalized.value}`);
|
|
345
|
+
}
|
|
346
|
+
return kept.join(";");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ------------------------------------------------------------------ *
|
|
350
|
+
* Compilation
|
|
351
|
+
* ------------------------------------------------------------------ */
|
|
352
|
+
|
|
353
|
+
function readOptions(options) {
|
|
354
|
+
const source = isObject(options) ? options : {};
|
|
355
|
+
const string = (value) => (typeof value === "string" && value !== "" ? value : null);
|
|
356
|
+
return {
|
|
357
|
+
in: string(source.in),
|
|
358
|
+
as: string(source.as),
|
|
359
|
+
page: string(source.page),
|
|
360
|
+
index: Number.isInteger(source.index) && source.index >= 0 ? source.index : null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Build the element the op will carry: the ids and types made explicit, the
|
|
366
|
+
* attributes filtered, the style rewritten.
|
|
367
|
+
*/
|
|
368
|
+
function compileElement(element, depth, context) {
|
|
369
|
+
const { page, options, used, result } = context;
|
|
370
|
+
|
|
371
|
+
const type = dataTypeOf(element);
|
|
372
|
+
if (type === null) {
|
|
373
|
+
throw new Refusal("structure/missing-type", `A <${element.tag}> in the fragment has no data-type.`, {
|
|
374
|
+
hint: `Add data-type, for example data-type="${TAG_TO_TYPE[element.tag] ?? "frame"}".`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (!Object.prototype.hasOwnProperty.call(TYPE_TAGS, type)) {
|
|
378
|
+
throw new Refusal("structure/unknown-type", `${JSON.stringify(type)} is not a data-type.`, {
|
|
379
|
+
target: dataIdOf(element) ?? undefined,
|
|
380
|
+
hint: `One of: ${Object.keys(TYPE_TAGS).join(", ")}`,
|
|
381
|
+
suggestion: nearestIds(TYPE_TAGS, type, 2).join(", ") || null,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (!TYPE_TAGS[type].includes(element.tag)) {
|
|
385
|
+
throw new Refusal(
|
|
386
|
+
"structure/type-tag-mismatch",
|
|
387
|
+
`A <${element.tag}> cannot be a ${type}; ${type} is <${TYPE_TAGS[type].join(">, <")}>.`,
|
|
388
|
+
{
|
|
389
|
+
target: dataIdOf(element) ?? undefined,
|
|
390
|
+
hint: `Write <${TYPE_TAGS[type][0]} data-type="${type}">, or change data-type.`,
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (type === "page" && depth > 0) {
|
|
395
|
+
throw new Refusal("structure/page-misplaced", "A page cannot sit inside another element.", {
|
|
396
|
+
target: dataIdOf(element) ?? undefined,
|
|
397
|
+
hint: "A fragment holds a page at its top level, or no page at all.",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const elementChildren = (Array.isArray(element.children) ? element.children : []).filter(isElement);
|
|
402
|
+
if (elementChildren.length > 0 && !TYPE_HAS_CHILDREN.has(type) && type !== "icon") {
|
|
403
|
+
// An icon is the one exception, and it is not really an exception: an
|
|
404
|
+
// `<svg>` holds its own geometry. Every real icon is a `<path>` or a
|
|
405
|
+
// `<circle>` inside `<svg>`, so refusing children here would refuse every
|
|
406
|
+
// glyph a model can actually write.
|
|
407
|
+
const article = /^[aeiou]/i.test(type) ? "An" : "A";
|
|
408
|
+
throw new Refusal("structure/children-not-allowed", `${article} ${type} element cannot hold elements.`, {
|
|
409
|
+
target: dataIdOf(element) ?? undefined,
|
|
410
|
+
hint: type === "text"
|
|
411
|
+
? "A text element holds one run of text. Wrap the parts in a frame, or use a span for inline emphasis."
|
|
412
|
+
: "Only page, frame and icon take children. Put the content in a frame.",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// -- the id ----------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
let id = dataIdOf(element);
|
|
419
|
+
if (id === null) {
|
|
420
|
+
if (depth === 0 && options.as !== null) {
|
|
421
|
+
id = options.as;
|
|
422
|
+
} else {
|
|
423
|
+
id = autoId(type === "page" ? element : page, element);
|
|
424
|
+
if (id === null) {
|
|
425
|
+
throw new Refusal("structure/bad-id", `Cannot build a data-id for <${element.tag}>.`, {
|
|
426
|
+
hint: `Ids match ${ID_PATTERN.source}`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else if (depth === 0 && options.as !== null && options.as !== id) {
|
|
431
|
+
throw new Refusal(
|
|
432
|
+
"structure/id-conflict",
|
|
433
|
+
`The fragment root says data-id=${JSON.stringify(id)}, but the call asked for ${JSON.stringify(options.as)}.`,
|
|
434
|
+
{ target: id, hint: "Send one id, or none." },
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (!ID_PATTERN.test(id)) {
|
|
438
|
+
throw new Refusal("structure/bad-id", `${JSON.stringify(id)} is not a usable data-id.`, {
|
|
439
|
+
hint: `Ids match ${ID_PATTERN.source}`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (used.has(id)) {
|
|
443
|
+
throw new Refusal("structure/duplicate-id", `data-id ${JSON.stringify(id)} is already taken.`, {
|
|
444
|
+
target: id,
|
|
445
|
+
hint: "Every element in the file has its own data-id.",
|
|
446
|
+
suggestion: nearestIds(used, id).join(", ") || null,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
used.add(id);
|
|
450
|
+
result.added.push(id);
|
|
451
|
+
|
|
452
|
+
// -- attributes ------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
const kept = [];
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
let styleText = null;
|
|
457
|
+
for (const attr of Array.isArray(element.attrs) ? element.attrs : []) {
|
|
458
|
+
const name = String(attr.name).toLowerCase();
|
|
459
|
+
if (seen.has(name)) continue;
|
|
460
|
+
seen.add(name);
|
|
461
|
+
if (name === "data-id") {
|
|
462
|
+
kept.push({ name, value: id });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (name === "data-type") {
|
|
466
|
+
kept.push({ name, value: type });
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (name === "style") {
|
|
470
|
+
const text = compileStyle(attr.value, id, result);
|
|
471
|
+
if (text !== "") styleText = text;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (isAllowedAttr(type, name)) {
|
|
475
|
+
// Write the author's spelling, not the parser's normalized one. The parser
|
|
476
|
+
// lowercases `name` and keeps the original in `rawName`, and attribute case
|
|
477
|
+
// is load-bearing outside HTML: `export --format svg` emits real XML, where
|
|
478
|
+
// `viewbox` is not `viewBox` and the glyph silently loses its coordinate
|
|
479
|
+
// system. `normalize.mjs` never rewrites attribute case, so whatever is
|
|
480
|
+
// written here is what the file keeps. `data-id` and `data-type` are the
|
|
481
|
+
// format's own vocabulary and stay lowercase, above.
|
|
482
|
+
kept.push({ name: attr.rawName ?? name, value: String(attr.value ?? "") });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
result.dropped.push({
|
|
486
|
+
target: id,
|
|
487
|
+
prop: name,
|
|
488
|
+
value: String(attr.value ?? ""),
|
|
489
|
+
reason: "not in the format table",
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (!seen.has("data-id")) kept.unshift({ name: "data-id", value: id });
|
|
493
|
+
if (!seen.has("data-type")) kept.unshift({ name: "data-type", value: type });
|
|
494
|
+
if (styleText !== null) kept.push({ name: "style", value: styleText });
|
|
495
|
+
|
|
496
|
+
// The written order is §8's: `data-id`, `data-type`, the rest as they were sent,
|
|
497
|
+
// `style` last. Writing it here means the op carries bytes that are already in the
|
|
498
|
+
// file's canonical shape, whatever order the model happened to use.
|
|
499
|
+
const rest = kept.filter((entry) => entry.name !== "data-id" && entry.name !== "data-type" && entry.name !== "style");
|
|
500
|
+
const style = kept.find((entry) => entry.name === "style");
|
|
501
|
+
const ordered = [{ name: "data-id", value: id }, { name: "data-type", value: type }, ...rest];
|
|
502
|
+
if (style !== undefined) ordered.push(style);
|
|
503
|
+
|
|
504
|
+
// -- children --------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
const children = [];
|
|
507
|
+
for (const child of Array.isArray(element.children) ? element.children : []) {
|
|
508
|
+
if (isElement(child) && type === "icon") {
|
|
509
|
+
// Inside an `<svg>` is the opaque layer. The geometry is kept exactly as
|
|
510
|
+
// written: it is not a design element, so it needs no data-type, gets no
|
|
511
|
+
// data-id, and is never addressed by an op. A real glyph is a `<path>`,
|
|
512
|
+
// and requiring a type on it would refuse every icon a model writes.
|
|
513
|
+
children.push({ kind: "element", tag: child.tag, rawTag: child.rawTag ?? child.tag, attrs: child.attrs ?? [], children: [], selfClosing: Boolean(child.selfClosing) });
|
|
514
|
+
} else if (isElement(child)) {
|
|
515
|
+
children.push(compileElement(child, depth + 1, context));
|
|
516
|
+
} else if (isObject(child) && child.kind === "text") {
|
|
517
|
+
children.push({ kind: "text", value: child.value ?? "", raw: child.raw ?? child.value ?? "" });
|
|
518
|
+
} else if (isObject(child) && child.kind === "comment") {
|
|
519
|
+
children.push({ kind: "comment", raw: child.raw ?? "" });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
kind: "element",
|
|
525
|
+
tag: element.tag,
|
|
526
|
+
rawTag: element.rawTag ?? element.tag,
|
|
527
|
+
attrs: ordered.map((entry) => ({
|
|
528
|
+
kind: "attr",
|
|
529
|
+
name: entry.name,
|
|
530
|
+
rawName: entry.name,
|
|
531
|
+
value: entry.value,
|
|
532
|
+
quote: '"',
|
|
533
|
+
})),
|
|
534
|
+
children,
|
|
535
|
+
selfClosing: false,
|
|
536
|
+
parent: null,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Compile one HTML fragment into one `insert` op.
|
|
542
|
+
*
|
|
543
|
+
* @param {object} doc the document the fragment will be inserted into
|
|
544
|
+
* @param {string} html the fragment
|
|
545
|
+
* @param {{in?: string, index?: number, as?: string, page?: string}} [options]
|
|
546
|
+
* @returns {{ops: object[], added: string[], normalized: object[], dropped: object[],
|
|
547
|
+
* diagnostics: object[],
|
|
548
|
+
* refused: null | {code: string, message: string, hint: string|null,
|
|
549
|
+
* suggestion: string|null}}}
|
|
550
|
+
*/
|
|
551
|
+
export function compileFragment(doc, html, options = {}) {
|
|
552
|
+
const result = {
|
|
553
|
+
ops: [],
|
|
554
|
+
added: [],
|
|
555
|
+
normalized: [],
|
|
556
|
+
dropped: [],
|
|
557
|
+
diagnostics: [],
|
|
558
|
+
refused: null,
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const refuseWith = (refusal) => {
|
|
562
|
+
result.refused = {
|
|
563
|
+
code: refusal.code,
|
|
564
|
+
message: refusal.message,
|
|
565
|
+
hint: refusal.hint ?? null,
|
|
566
|
+
suggestion: refusal.suggestion ?? null,
|
|
567
|
+
};
|
|
568
|
+
result.ops = [];
|
|
569
|
+
const diagnostic = { severity: "error", code: refusal.code, message: refusal.message };
|
|
570
|
+
if (refusal.target !== null && refusal.target !== undefined) diagnostic.target = refusal.target;
|
|
571
|
+
if (refusal.hint) diagnostic.hint = refusal.hint;
|
|
572
|
+
if (refusal.suggestion) diagnostic.fix = refusal.suggestion;
|
|
573
|
+
result.diagnostics.push(diagnostic);
|
|
574
|
+
return result;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
if (!isObject(doc)) {
|
|
578
|
+
return refuseWith(new Refusal("ops/no-doc", `Expected a document, got ${doc === null ? "null" : typeof doc}.`));
|
|
579
|
+
}
|
|
580
|
+
if (typeof html !== "string" || html.trim() === "") {
|
|
581
|
+
return refuseWith(
|
|
582
|
+
new Refusal("structure/empty-fragment", "The fragment is empty.", {
|
|
583
|
+
hint: 'Send at least one element, such as <div data-id="n_card" data-type="frame"></div>.',
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const roots = parseFragmentHtml(html);
|
|
589
|
+
const elementRoots = roots.filter(isElement);
|
|
590
|
+
if (elementRoots.length === 0) {
|
|
591
|
+
return refuseWith(
|
|
592
|
+
new Refusal("structure/empty-fragment", "The fragment has no elements, only text or comments.", {
|
|
593
|
+
hint: 'Send at least one element, such as <div data-id="n_card" data-type="frame"></div>.',
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const options_ = readOptions(options);
|
|
599
|
+
const index = buildIdIndex(doc);
|
|
600
|
+
const pages = pagesOf(doc);
|
|
601
|
+
|
|
602
|
+
// -- which page names the ids that have none --------------------------------
|
|
603
|
+
|
|
604
|
+
let page = null;
|
|
605
|
+
if (options_.page !== null) {
|
|
606
|
+
page = pages.find((candidate) => dataIdOf(candidate) === options_.page) ?? null;
|
|
607
|
+
if (page === null) {
|
|
608
|
+
return refuseWith(
|
|
609
|
+
new Refusal("structure/unknown-page", `There is no page with data-id ${JSON.stringify(options_.page)}.`, {
|
|
610
|
+
target: options_.page,
|
|
611
|
+
hint: pages.length === 0 ? "This document has no pages." : `Pages: ${pages.map(dataIdOf).join(", ")}`,
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
} else if (options_.in !== null) {
|
|
616
|
+
const named = findNode(doc, options_.in);
|
|
617
|
+
if (named !== null && named !== doc) page = pageOfElement(doc, named);
|
|
618
|
+
}
|
|
619
|
+
if (page === null && pages.length > 0) page = pages[pages.length - 1];
|
|
620
|
+
|
|
621
|
+
// -- the parent -------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
const rootIsPage = elementRoots.some((element) => dataTypeOf(element) === "page");
|
|
624
|
+
let parentAddress = options_.in;
|
|
625
|
+
if (parentAddress === null) {
|
|
626
|
+
// A page snippet has a home with no page: `<body>`. Anything else needs a
|
|
627
|
+
// page to land in. Falling back to `<body>` for an element was the silent
|
|
628
|
+
// hole the old `add` guard covered: the node validates, `ls` and `export`
|
|
629
|
+
// never show it, and only the file knows it is there.
|
|
630
|
+
if (rootIsPage) parentAddress = isObject(doc.body) ? "body" : "document";
|
|
631
|
+
else if (page !== null) parentAddress = dataIdOf(page);
|
|
632
|
+
}
|
|
633
|
+
if (parentAddress === null) {
|
|
634
|
+
return refuseWith(
|
|
635
|
+
new Refusal("structure/no-page", "The document has no page to add this fragment to.", {
|
|
636
|
+
hint: "Add a page, or name a parent with in.",
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const parent = findNode(doc, parentAddress);
|
|
641
|
+
if (parent === null) {
|
|
642
|
+
return refuseWith(
|
|
643
|
+
new Refusal("ops/node-not-found", `There is no node with data-id ${JSON.stringify(parentAddress)}.`, {
|
|
644
|
+
target: parentAddress,
|
|
645
|
+
hint: "Name the parent with in, or leave in out to use the last page.",
|
|
646
|
+
suggestion: nearestIds(index, parentAddress).join(", ") || null,
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const parentType = dataTypeOf(parent);
|
|
651
|
+
if (parent !== doc && parentType !== null && !TYPE_HAS_CHILDREN.has(parentType)) {
|
|
652
|
+
return refuseWith(
|
|
653
|
+
new Refusal("structure/children-not-allowed", `A ${parentType} element cannot hold children.`, {
|
|
654
|
+
target: parentAddress,
|
|
655
|
+
hint: "Only page and frame take children.",
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
if (rootIsPage && parent !== doc && parentType !== null && parentType !== "page") {
|
|
660
|
+
return refuseWith(
|
|
661
|
+
new Refusal("structure/page-misplaced", `A page cannot sit inside a ${parentType}.`, {
|
|
662
|
+
target: parentAddress,
|
|
663
|
+
hint: "Send the page without in, or name body.",
|
|
664
|
+
}),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (rootIsPage && parentType === "page") {
|
|
668
|
+
return refuseWith(
|
|
669
|
+
new Refusal("structure/page-misplaced", "A page cannot sit inside another page.", {
|
|
670
|
+
target: parentAddress,
|
|
671
|
+
hint: "Send the page without in, or name body.",
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// -- compile ----------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
const context = { page, options: options_, used: new Set(index.keys()), result };
|
|
679
|
+
let nodes;
|
|
680
|
+
try {
|
|
681
|
+
nodes = elementRoots.map((element) => compileElement(element, 0, context));
|
|
682
|
+
} catch (err) {
|
|
683
|
+
if (err instanceof Refusal) return refuseWith(err);
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// -- emit -------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
const siblings = Array.isArray(parent.children) ? parent.children : [];
|
|
690
|
+
const at = options_.index === null ? siblings.length : options_.index;
|
|
691
|
+
if (at > siblings.length) {
|
|
692
|
+
return refuseWith(
|
|
693
|
+
new Refusal(
|
|
694
|
+
"ops/bad-index",
|
|
695
|
+
`Cannot insert at index ${at}: ${JSON.stringify(parentAddress)} has ${siblings.length} children.`,
|
|
696
|
+
{ target: parentAddress, hint: `Use an index from 0 to ${siblings.length}.` },
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
result.ops = [{ op: "insert", target: parentAddress, index: at, html: writeFragment(nodes) }];
|
|
701
|
+
return result;
|
|
702
|
+
}
|