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,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/serialize.mjs — document tree to text.
|
|
3
|
+
*
|
|
4
|
+
* Three levels, and the difference between them matters:
|
|
5
|
+
*
|
|
6
|
+
* 1. Byte fidelity — when nothing changed, `serialize(doc)` returns
|
|
7
|
+
* `doc.source` untouched. Not "an equivalent string". The same bytes.
|
|
8
|
+
* 2. Minimal change — one op rewrites one byte range. A style edit rewrites
|
|
9
|
+
* the `style` value. A text edit rewrites the text. Nothing else moves.
|
|
10
|
+
* 3. Canonical text — `serializeCanonical` writes the one spelling per
|
|
11
|
+
* intent. It is used by `format` and by `export`, not by an ordinary save.
|
|
12
|
+
*
|
|
13
|
+
* The practical consequence: a hand-written file keeps its own formatting,
|
|
14
|
+
* comments and attribute order forever, until the user asks for `format`.
|
|
15
|
+
*
|
|
16
|
+
* Pure. No node: imports.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { RAW_TEXT_ELEMENTS, parseDeclarations, VOID_ELEMENTS } from "./parse.mjs";
|
|
20
|
+
|
|
21
|
+
// -- escaping ----------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const TEXT_ESCAPES = { "&": "&", "<": "<", ">": ">" };
|
|
24
|
+
const ATTR_ESCAPES = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
25
|
+
|
|
26
|
+
export function escapeText(value) {
|
|
27
|
+
return String(value).replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function escapeAttr(value) {
|
|
31
|
+
return String(value).replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// -- spans -------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export class SerializeError extends Error {
|
|
37
|
+
constructor(code, message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "SerializeError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function spansOverlap(a, b) {
|
|
45
|
+
return a.start < b.end && b.start < a.end;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply range replacements to a source string.
|
|
50
|
+
* Edits are applied from the end backwards, so no offset shifts.
|
|
51
|
+
* Overlapping edits are a programming error, not a guess.
|
|
52
|
+
*/
|
|
53
|
+
export function applyEdits(source, edits) {
|
|
54
|
+
const list = (edits || []).filter((e) => e && e.start !== e.end || (e && e.text !== undefined && e.text !== null && e.text !== ""));
|
|
55
|
+
const sorted = [...list].sort((a, b) => b.start - a.start || b.end - a.end);
|
|
56
|
+
let previous = null;
|
|
57
|
+
for (const edit of sorted) {
|
|
58
|
+
if (edit.start < 0 || edit.end > source.length || edit.start > edit.end) {
|
|
59
|
+
throw new SerializeError("serialize/edit-out-of-range", `Edit ${edit.start}..${edit.end} is outside the file (0..${source.length}).`);
|
|
60
|
+
}
|
|
61
|
+
if (previous && previous.start < edit.end) {
|
|
62
|
+
throw new SerializeError("serialize/edits-overlap", `Edit ${edit.start}..${edit.end} overlaps ${previous.start}..${previous.end}.`);
|
|
63
|
+
}
|
|
64
|
+
previous = edit;
|
|
65
|
+
}
|
|
66
|
+
let out = source;
|
|
67
|
+
for (const edit of sorted) {
|
|
68
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove edits that another edit fully covers.
|
|
75
|
+
* A parent replacement makes every child edit inside it redundant.
|
|
76
|
+
*/
|
|
77
|
+
export function outermostEdits(edits) {
|
|
78
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const edit of sorted) {
|
|
81
|
+
const covering = out.find((kept) => kept.start <= edit.start && kept.end >= edit.end);
|
|
82
|
+
if (covering) continue;
|
|
83
|
+
out.push(edit);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// -- node text ---------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The canonical attribute order: `data-id`, `data-type`, then the other
|
|
92
|
+
* `data-*` alphabetically, then plain attributes, and `style` last.
|
|
93
|
+
*
|
|
94
|
+
* This lives here as a copy of the rule in lib/normalize.mjs, because the
|
|
95
|
+
* serializer may not import the normalizer — the normalizer uses the serializer.
|
|
96
|
+
* `test/normalize.test.mjs` pins the two against each other, so a change to one
|
|
97
|
+
* that is not made to the other fails a test rather than producing two
|
|
98
|
+
* canonical forms.
|
|
99
|
+
*/
|
|
100
|
+
function attributeRank(attr) {
|
|
101
|
+
if (attr.name === "data-id") return 0;
|
|
102
|
+
if (attr.name === "data-type") return 1;
|
|
103
|
+
if (attr.name === "style") return 3;
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function compareAttrs(a, b) {
|
|
108
|
+
const ra = attributeRank(a);
|
|
109
|
+
const rb = attributeRank(b);
|
|
110
|
+
if (ra !== rb) return ra - rb;
|
|
111
|
+
return (a.name || "") < (b.name || "") ? -1 : (a.name || "") > (b.name || "") ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Attributes in canonical order, as fresh entries. */
|
|
115
|
+
export function orderAttributes(node) {
|
|
116
|
+
return (node.attrs || [])
|
|
117
|
+
.map((attr, index) => ({ attr, index }))
|
|
118
|
+
.sort((a, b) => compareAttrs(a.attr, b.attr) || a.index - b.index)
|
|
119
|
+
.map((entry) => entry.attr);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function attrEntryToText(attr) {
|
|
123
|
+
if (attr.value === null || attr.value === undefined) return attr.rawName || attr.name;
|
|
124
|
+
const name = attr.rawName || attr.name;
|
|
125
|
+
// `attr.value` holds the decoded form, exactly as the parser holds it. The
|
|
126
|
+
// escape here mirrors the parser's decode, so a value that survives a parse
|
|
127
|
+
// and a write comes back with the same bytes. Decoding a second time is what
|
|
128
|
+
// corrupted `"` into `&quot;` on the second normalization.
|
|
129
|
+
return `${name}="${escapeAttr(attr.value)}"`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The open tag, canonical form: rebuilt from the attribute list. */
|
|
133
|
+
export function serializeOpenTag(node, options = {}) {
|
|
134
|
+
const name = options.keepRawTag ? node.rawTag || node.tag : node.tag;
|
|
135
|
+
// Canonical output sorts the attributes. Byte-faithful output does not: a file
|
|
136
|
+
// nobody edited must come back exactly as it was, attribute order included.
|
|
137
|
+
const source = options.canonical === true ? orderAttributes(node) : (node.attrs || []);
|
|
138
|
+
const parts = source.map(attrEntryToText);
|
|
139
|
+
const head = parts.length ? `<${name} ${parts.join(" ")}` : `<${name}`;
|
|
140
|
+
if (node.selfClosing) return `${head} />`;
|
|
141
|
+
return `${head}>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function serializeCloseTag(node) {
|
|
145
|
+
return `</${node.tag}>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Emit one node as text.
|
|
150
|
+
* `raw` is returned verbatim: comments, doctypes, unknown elements, and any
|
|
151
|
+
* subtree the parser could not model must survive unchanged.
|
|
152
|
+
*/
|
|
153
|
+
export function serializeNode(node, options = {}) {
|
|
154
|
+
if (node === null || node === undefined) return "";
|
|
155
|
+
if (options.canonical !== true && typeof node.raw === "string") return node.raw;
|
|
156
|
+
|
|
157
|
+
if (node.kind === "comment" || node.kind === "doctype") return node.raw ?? "";
|
|
158
|
+
|
|
159
|
+
if (node.kind === "text") {
|
|
160
|
+
if (options.canonical !== true && typeof node.raw === "string" && node.value === node.raw) return node.raw;
|
|
161
|
+
// `<style>` and `<script>` hold raw text, not character data. Escaping them
|
|
162
|
+
// is how `svg:not([fill]) > path` becomes `svg:not([fill]) > path`, which
|
|
163
|
+
// is not a selector and not a warning either: the browser drops the rule and
|
|
164
|
+
// the page renders differently with nothing to point at.
|
|
165
|
+
if (options.rawText === true) return node.value ?? "";
|
|
166
|
+
return escapeText(node.value ?? "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (node.kind !== "element") return "";
|
|
170
|
+
|
|
171
|
+
const open = serializeOpenTag(node, options);
|
|
172
|
+
const childOptions = RAW_TEXT_ELEMENTS.has(node.tag) ? { ...options, rawText: true } : options;
|
|
173
|
+
const body = node.children.map((child) => serializeNode(child, childOptions)).join("");
|
|
174
|
+
|
|
175
|
+
// A void element never takes a closing tag, however it was written. `<img …>`
|
|
176
|
+
// and `<img … />` are both void, and emitting `</img>` for the first one is a
|
|
177
|
+
// syntax error this tool would have introduced. The check has to be the tag
|
|
178
|
+
// table, not the `/>` the author happened to type.
|
|
179
|
+
const isVoid = VOID_ELEMENTS.has(node.tag) && node.children.length === 0;
|
|
180
|
+
if (isVoid) return open;
|
|
181
|
+
if (node.selfClosing && node.children.length === 0) return open;
|
|
182
|
+
return `${open}${body}${serializeCloseTag(node)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -- canonical document ------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Canonical text for a whole document.
|
|
189
|
+
* Used by `format` and by `export`. Not used by an ordinary save.
|
|
190
|
+
*/
|
|
191
|
+
export function serializeCanonical(doc, options = {}) {
|
|
192
|
+
const opts = { ...options, canonical: true };
|
|
193
|
+
return doc.children.map((child) => serializeNode(child, opts)).join("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// -- byte-exact save ---------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Structural equality between an original node and a possibly edited node.
|
|
200
|
+
*
|
|
201
|
+
* `a` may carry a `_raw` snapshot from parse time. That snapshot is the truth
|
|
202
|
+
* about the file. A caller edits the tree in place, so `a.value` may already be
|
|
203
|
+
* the new value, and comparing `a` against `b` directly would report "equal"
|
|
204
|
+
* for every edit.
|
|
205
|
+
*/
|
|
206
|
+
export function nodesEqual(a, b) {
|
|
207
|
+
if (!a || !b) return false;
|
|
208
|
+
|
|
209
|
+
// The same object on both sides: the answer comes from its own snapshot,
|
|
210
|
+
// because the caller may have edited it in place. A node with no snapshot was
|
|
211
|
+
// made by an edit, so it cannot be equal to anything that is already on disk.
|
|
212
|
+
if (a === b) return a._raw ? nodeMatchesSnapshot(a) : false;
|
|
213
|
+
|
|
214
|
+
// A snapshot has no `_raw` of its own: it IS the file's state. So a node
|
|
215
|
+
// that carries a snapshot is compared snapshot-first, and the snapshot is
|
|
216
|
+
// the side whose values are trustworthy.
|
|
217
|
+
const snap = a._raw;
|
|
218
|
+
const original = snap || a;
|
|
219
|
+
if (original.kind !== b.kind) return false;
|
|
220
|
+
|
|
221
|
+
if (original.kind === "text") return String(original.value) === String(b.value);
|
|
222
|
+
if (original.kind === "comment" || original.kind === "doctype") return original.raw === b.raw;
|
|
223
|
+
if (original.kind !== "element") return false;
|
|
224
|
+
|
|
225
|
+
if (original.tag !== b.tag) return false;
|
|
226
|
+
if (Boolean(original.selfClosing) !== Boolean(b.selfClosing)) return false;
|
|
227
|
+
if (!attrsEqual(original.attrs, b.attrs)) return false;
|
|
228
|
+
|
|
229
|
+
const originKids = Array.isArray(original.children) ? original.children : [];
|
|
230
|
+
const nextKids = b.children || [];
|
|
231
|
+
if (originKids.length !== nextKids.length) return false;
|
|
232
|
+
for (let i = 0; i < originKids.length; i += 1) {
|
|
233
|
+
if (!nodesEqual(originKids[i], nextKids[i])) return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Compare a live node against its own parse-time snapshot.
|
|
240
|
+
* This answers "do the bytes on disk still describe this node".
|
|
241
|
+
*/
|
|
242
|
+
export function nodeMatchesSnapshot(node) {
|
|
243
|
+
const raw = node._raw;
|
|
244
|
+
if (!raw) return true;
|
|
245
|
+
if (node.kind === "text") return String(raw.value) === String(node.value);
|
|
246
|
+
if (node.kind === "comment" || node.kind === "doctype") return raw.raw === node.raw;
|
|
247
|
+
if (node.kind !== "element") return true;
|
|
248
|
+
if (!attrsEqual(raw.attrs, node.attrs)) return false;
|
|
249
|
+
if (!Array.isArray(raw.children)) return true;
|
|
250
|
+
const live = node.children || [];
|
|
251
|
+
if (raw.children.length !== live.length) return false;
|
|
252
|
+
for (let i = 0; i < raw.children.length; i += 1) {
|
|
253
|
+
if (raw.children[i] !== live[i]) return false;
|
|
254
|
+
if (!nodeMatchesSnapshot(live[i])) return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** True when the next child sequence would produce the same bytes. */
|
|
260
|
+
function childrenUnchanged(originKids, nextKids) {
|
|
261
|
+
if (originKids.length !== nextKids.length) return false;
|
|
262
|
+
for (let i = 0; i < originKids.length; i += 1) {
|
|
263
|
+
if (!nodesEqual(originKids[i], nextKids[i])) return false;
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read a node's original child sequence, as captured at parse time. */
|
|
269
|
+
export function originChildrenOf(node) {
|
|
270
|
+
const raw = node._raw;
|
|
271
|
+
if (raw && Array.isArray(raw.children)) return raw.children;
|
|
272
|
+
return node.children || [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function attrsEqual(a, b) {
|
|
276
|
+
if (a === b) return true;
|
|
277
|
+
if (!a || !b) return false;
|
|
278
|
+
if (a.length !== b.length) return false;
|
|
279
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
280
|
+
const x = a[i];
|
|
281
|
+
const y = b[i];
|
|
282
|
+
if (x.name !== y.name) return false;
|
|
283
|
+
if ((x.value ?? null) !== (y.value ?? null)) return false;
|
|
284
|
+
if ((x.rawName || x.name) !== (y.rawName || y.name)) return false;
|
|
285
|
+
}
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Emit the smallest set of edits that turns `origin` into `next`. */
|
|
290
|
+
function diffNode(origin, next, edits, rawText = false) {
|
|
291
|
+
if (nodesEqual(origin, next)) return;
|
|
292
|
+
if (!origin || origin.start === undefined) return;
|
|
293
|
+
|
|
294
|
+
if (origin.kind === "text") {
|
|
295
|
+
// Inside `<style>` or `<script>` the text is raw: an escaped `>` would turn
|
|
296
|
+
// a selector into something the browser silently drops.
|
|
297
|
+
const text = rawText ? (next.value ?? "") : escapeText(next.value ?? "");
|
|
298
|
+
edits.push({ start: origin.start, end: origin.end, text, reason: "text" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (origin.kind !== "element") {
|
|
303
|
+
edits.push({ start: origin.start, end: origin.end, text: serializeNode(next, { canonical: true }), reason: "node" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const originalAttrs = (origin._raw && origin._raw.attrs) || origin.attrs;
|
|
308
|
+
const originalKids = originChildrenOf(origin);
|
|
309
|
+
const nextKids = next.children || [];
|
|
310
|
+
const sameAttrs = attrsEqual(originalAttrs, next.attrs);
|
|
311
|
+
|
|
312
|
+
// The tightest edit is inside a child: one child changed and the rest match.
|
|
313
|
+
// Both the parent's own open tag and that child can differ at the same time,
|
|
314
|
+
// so the child pass must not pre-empt the parent pass. Child edits collected
|
|
315
|
+
// here are discarded again when the parent edit covers them.
|
|
316
|
+
if (originalKids.length === nextKids.length) {
|
|
317
|
+
const childEdits = [];
|
|
318
|
+
let changed = -1;
|
|
319
|
+
let count = 0;
|
|
320
|
+
for (let i = 0; i < originalKids.length; i += 1) {
|
|
321
|
+
if (!nodesEqual(originalKids[i], nextKids[i])) {
|
|
322
|
+
count += 1;
|
|
323
|
+
changed = i;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (count === 1) {
|
|
327
|
+
if (changed === -1 && nextKids.length === 0) {
|
|
328
|
+
// No child to recurse into.
|
|
329
|
+
} else {
|
|
330
|
+
diffNode(originalKids[changed], nextKids[changed], childEdits, RAW_TEXT_ELEMENTS.has(origin.tag));
|
|
331
|
+
}
|
|
332
|
+
if (childEdits.length && sameAttrs) {
|
|
333
|
+
edits.push(...childEdits);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} else if (nextKids.length > originalKids.length) {
|
|
338
|
+
const surviving = originalKids.every((child, i) => child === nextKids[i]);
|
|
339
|
+
if (surviving && sameAttrs) {
|
|
340
|
+
const added = nextKids.slice(originalKids.length)
|
|
341
|
+
.map((child) => serializeNode(child, { canonical: true })).join("");
|
|
342
|
+
if (added) {
|
|
343
|
+
const anchor = originalKids.length
|
|
344
|
+
? originalKids[originalKids.length - 1].end
|
|
345
|
+
: origin.openEnd;
|
|
346
|
+
edits.push({ start: anchor, end: anchor, text: added, reason: "children-append" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The element's own open tag changed and the children did not: one span.
|
|
353
|
+
if (sameAttrs !== true && childrenUnchanged(originalKids, nextKids) && origin.openStart !== origin.openEnd) {
|
|
354
|
+
edits.push({ start: origin.openStart, end: origin.openEnd, text: serializeOpenTag(next), reason: "attributes" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Fall back to rewriting the element. The open tag stays when it matches.
|
|
359
|
+
const bodyStart = origin.openEnd;
|
|
360
|
+
const bodyEnd = origin.closeStart >= 0 ? origin.closeStart : origin.end;
|
|
361
|
+
if (bodyStart <= bodyEnd && bodyStart <= origin.end) {
|
|
362
|
+
const bodyText = nextKids.map((child) => serializeNode(child, { canonical: true })).join("");
|
|
363
|
+
if (sameAttrs && origin.closeStart >= 0) {
|
|
364
|
+
edits.push({ start: bodyStart, end: bodyEnd, text: bodyText, reason: "body" });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
edits.push({
|
|
368
|
+
start: origin.openStart,
|
|
369
|
+
end: origin.end,
|
|
370
|
+
text: `${serializeOpenTag(next)}${bodyText}${origin.closeStart >= 0 || !next.selfClosing ? serializeCloseTag(next) : ""}`,
|
|
371
|
+
reason: "element",
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
edits.push({ start: origin.start, end: origin.end, text: serializeNode(next, { canonical: true }), reason: "element" });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Serialize `next` against its `origin`.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} origin the document as it was read from disk
|
|
383
|
+
* @param {object} next the same document after edits. May be `origin`.
|
|
384
|
+
* @returns {string} the text to write to disk
|
|
385
|
+
*/
|
|
386
|
+
export function serialize(origin, next) {
|
|
387
|
+
if (next === undefined || next === null) next = origin;
|
|
388
|
+
// No identity shortcut here. A caller edits the tree in place and calls
|
|
389
|
+
// `serialize(doc, doc)`: the diff below is what decides whether any byte
|
|
390
|
+
// changed. Returning early on `origin === next` would make every in-place
|
|
391
|
+
// edit invisible.
|
|
392
|
+
|
|
393
|
+
const edits = [];
|
|
394
|
+
const originChildren = originChildrenOf(origin);
|
|
395
|
+
const nextChildren = next.children || [];
|
|
396
|
+
if (originChildren.length === nextChildren.length) {
|
|
397
|
+
for (let i = 0; i < originChildren.length; i += 1) {
|
|
398
|
+
diffNode(originChildren[i], nextChildren[i], edits);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
// Top level shape changed (a page added or removed). Rewrite the region
|
|
402
|
+
// that differs, keeping the identical head and tail of the file.
|
|
403
|
+
let head = 0;
|
|
404
|
+
while (head < originChildren.length && head < nextChildren.length
|
|
405
|
+
&& nodesEqual(originChildren[head], nextChildren[head])) head += 1;
|
|
406
|
+
const leftOver = originChildren.length - head;
|
|
407
|
+
const rightOver = nextChildren.length - head;
|
|
408
|
+
let tail = 0;
|
|
409
|
+
while (tail < leftOver && tail < rightOver
|
|
410
|
+
&& nodesEqual(originChildren[originChildren.length - 1 - tail], nextChildren[nextChildren.length - 1 - tail])) {
|
|
411
|
+
tail += 1;
|
|
412
|
+
}
|
|
413
|
+
// Replace exactly the region that neither the common head nor the common
|
|
414
|
+
// tail covers. A pure append in a one-page file is then a zero-width insert
|
|
415
|
+
// at the end of the last page, and every other byte is untouched.
|
|
416
|
+
const start = head < originChildren.length
|
|
417
|
+
? originChildren[head].start
|
|
418
|
+
: (originChildren.length
|
|
419
|
+
? originChildren[originChildren.length - 1].end
|
|
420
|
+
: origin.source.length);
|
|
421
|
+
const end = tail >= 1 && originChildren.length - tail >= 0 && originChildren.length - tail < originChildren.length
|
|
422
|
+
? originChildren[originChildren.length - tail].start
|
|
423
|
+
: origin.source.length;
|
|
424
|
+
const text = nextChildren.slice(head, nextChildren.length - tail)
|
|
425
|
+
.map((child) => serializeNode(child, { canonical: true })).join("");
|
|
426
|
+
edits.push({ start, end, text, reason: "top-level" });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (edits.length === 0) return origin.source;
|
|
430
|
+
return applyEdits(origin.source, outermostEdits(edits));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Convenience: the canonical text of one element subtree. */
|
|
434
|
+
export function serializeSubtree(node) {
|
|
435
|
+
return serializeNode(node, { canonical: true });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Declarations of a `style` attribute value, for editors. */
|
|
439
|
+
export function declarationsOf(value) {
|
|
440
|
+
return parseDeclarations(value ?? "");
|
|
441
|
+
}
|
package/lib/spans.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-exact interval arithmetic.
|
|
3
|
+
*
|
|
4
|
+
* One rule every caller depends on: `applyEdits` never drifts. Edits are sorted
|
|
5
|
+
* by `start` descending and spliced from the end of the text backwards, so
|
|
6
|
+
* replacing an earlier range can never move a later one.
|
|
7
|
+
*
|
|
8
|
+
* PURE ESM — no `node:` imports, no DOM. Runs in the browser.
|
|
9
|
+
*
|
|
10
|
+
* Offsets are character offsets into a JavaScript string (UTF-16 code units),
|
|
11
|
+
* which is what the parser records. `byteOffsetToLineCol` keeps its historical
|
|
12
|
+
* name, but it counts characters, not UTF-8 bytes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** An edit that cannot be applied: `code` is `spans/overlap`, `spans/range` or `spans/invalid`. */
|
|
16
|
+
export class SpansError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} code `spans/overlap`, `spans/range`, `spans/invalid`
|
|
19
|
+
* @param {string} message
|
|
20
|
+
*/
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "SpansError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function startOf(span) {
|
|
29
|
+
const value = span && span.start;
|
|
30
|
+
return Number.isFinite(value) ? value : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endOf(span) {
|
|
34
|
+
const value = span && span.end;
|
|
35
|
+
if (Number.isFinite(value)) return value;
|
|
36
|
+
return startOf(span);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A new array, sorted by `start` ascending and then by `end`. The input is not
|
|
41
|
+
* touched, so a caller can keep its own order.
|
|
42
|
+
*
|
|
43
|
+
* @param {{start: number, end?: number}[]} spans
|
|
44
|
+
* @returns {object[]}
|
|
45
|
+
*/
|
|
46
|
+
export function sortSpans(spans) {
|
|
47
|
+
const list = Array.isArray(spans) ? spans : [];
|
|
48
|
+
return list
|
|
49
|
+
.map((span, index) => ({ span, index }))
|
|
50
|
+
.sort((a, b) => {
|
|
51
|
+
const byStart = startOf(a.span) - startOf(b.span);
|
|
52
|
+
if (byStart !== 0) return byStart;
|
|
53
|
+
const byEnd = endOf(a.span) - endOf(b.span);
|
|
54
|
+
if (byEnd !== 0) return byEnd;
|
|
55
|
+
return a.index - b.index;
|
|
56
|
+
})
|
|
57
|
+
.map((entry) => entry.span);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when two half-open ranges share at least one offset.
|
|
62
|
+
*
|
|
63
|
+
* Adjacent ranges (`[0,3)` and `[3,6)`) do not overlap: that is the case a
|
|
64
|
+
* caller uses to replace two neighbouring regions in one pass.
|
|
65
|
+
*
|
|
66
|
+
* A zero-length range is an insertion point. It overlaps a non-empty range it
|
|
67
|
+
* sits on or inside (`start <= point <= end`), because the order of an
|
|
68
|
+
* insertion at the boundary of a replacement is not defined; it overlaps
|
|
69
|
+
* another zero-length range only when the two share the same point.
|
|
70
|
+
*
|
|
71
|
+
* @param {{start: number, end?: number}} a
|
|
72
|
+
* @param {{start: number, end?: number}} b
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function spansOverlap(a, b) {
|
|
76
|
+
const aStart = startOf(a);
|
|
77
|
+
const aEnd = endOf(a);
|
|
78
|
+
const bStart = startOf(b);
|
|
79
|
+
const bEnd = endOf(b);
|
|
80
|
+
const aEmpty = aStart === aEnd;
|
|
81
|
+
const bEmpty = bStart === bEnd;
|
|
82
|
+
|
|
83
|
+
if (aEmpty && bEmpty) return aStart === bStart;
|
|
84
|
+
if (aEmpty) return aStart >= bStart && aStart <= bEnd;
|
|
85
|
+
if (bEmpty) return bStart >= aStart && bStart <= aEnd;
|
|
86
|
+
return aStart < bEnd && bStart < aEnd;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeEdit(edit, index, length) {
|
|
90
|
+
if (!edit || typeof edit !== "object") {
|
|
91
|
+
throw new SpansError("spans/invalid", `Edit ${index} is not an object`);
|
|
92
|
+
}
|
|
93
|
+
const start = edit.start;
|
|
94
|
+
const end = edit.end === undefined ? start : edit.end;
|
|
95
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
|
96
|
+
throw new SpansError("spans/invalid", `Edit ${index} needs integer offsets, got ${String(start)}..${String(end)}`);
|
|
97
|
+
}
|
|
98
|
+
if (start < 0 || end < start || end > length) {
|
|
99
|
+
throw new SpansError(
|
|
100
|
+
"spans/range",
|
|
101
|
+
`Edit ${index} is out of range: ${start}..${end} does not fit inside 0..${length}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const text = edit.text === undefined || edit.text === null ? "" : edit.text;
|
|
105
|
+
if (typeof text !== "string") {
|
|
106
|
+
throw new SpansError("spans/invalid", `Edit ${index} needs a string replacement, got ${typeof text}`);
|
|
107
|
+
}
|
|
108
|
+
return { start, end, text, index };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Replace every range in `source`, back to front.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} source
|
|
115
|
+
* @param {{start: number, end?: number, text?: string}[]} edits
|
|
116
|
+
* @returns {string}
|
|
117
|
+
* @throws {SpansError} `spans/overlap` when two edits overlap, `spans/range`
|
|
118
|
+
* when an edit falls outside the text, `spans/invalid` when an edit is
|
|
119
|
+
* malformed
|
|
120
|
+
*/
|
|
121
|
+
export function applyEdits(source, edits) {
|
|
122
|
+
const text = source === undefined || source === null ? "" : String(source);
|
|
123
|
+
const list = Array.isArray(edits) ? edits : [];
|
|
124
|
+
if (list.length === 0) return text;
|
|
125
|
+
|
|
126
|
+
const normalized = list.map((edit, index) => normalizeEdit(edit, index, text.length));
|
|
127
|
+
const sorted = sortSpans(normalized);
|
|
128
|
+
|
|
129
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
130
|
+
const previous = sorted[i - 1];
|
|
131
|
+
const current = sorted[i];
|
|
132
|
+
if (spansOverlap(previous, current)) {
|
|
133
|
+
throw new SpansError(
|
|
134
|
+
"spans/overlap",
|
|
135
|
+
`Edits ${previous.index} and ${current.index} overlap: ` +
|
|
136
|
+
`${previous.start}..${previous.end} and ${current.start}..${current.end}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let out = text;
|
|
142
|
+
for (let i = sorted.length - 1; i >= 0; i -= 1) {
|
|
143
|
+
const edit = sorted[i];
|
|
144
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The 1-based line and column of `offset`.
|
|
151
|
+
*
|
|
152
|
+
* Counts characters (UTF-16 code units), not bytes. `\n` ends a line; a `\r`
|
|
153
|
+
* before it belongs to the line it terminates, so a CRLF file reports column 1
|
|
154
|
+
* on the line after the break and never counts the pair twice. An offset past
|
|
155
|
+
* either end of the text is clamped.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} source
|
|
158
|
+
* @param {number} offset
|
|
159
|
+
* @returns {{line: number, column: number}}
|
|
160
|
+
*/
|
|
161
|
+
export function byteOffsetToLineCol(source, offset) {
|
|
162
|
+
const text = source === undefined || source === null ? "" : String(source);
|
|
163
|
+
const numeric = Number.isFinite(offset) ? Math.trunc(offset) : 0;
|
|
164
|
+
const limit = Math.max(0, Math.min(numeric, text.length));
|
|
165
|
+
|
|
166
|
+
let line = 1;
|
|
167
|
+
let column = 1;
|
|
168
|
+
for (let i = 0; i < limit; i += 1) {
|
|
169
|
+
if (text.charCodeAt(i) === 10) {
|
|
170
|
+
line += 1;
|
|
171
|
+
column = 1;
|
|
172
|
+
} else {
|
|
173
|
+
column += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { line, column };
|
|
177
|
+
}
|