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/ops.mjs
ADDED
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/ops.mjs — the operation engine.
|
|
3
|
+
*
|
|
4
|
+
* PURE ESM. No `node:` imports, no DOM, no dependencies. `lib/` top-level files are
|
|
5
|
+
* loaded in the browser too (format-v3 §10.2).
|
|
6
|
+
*
|
|
7
|
+
* One op is at once the sync payload, the undo unit, the change-log entry and the
|
|
8
|
+
* patch command. Five kinds:
|
|
9
|
+
*
|
|
10
|
+
* { op: "set", target: "n_cta", prop: "border-radius", value: "12px" }
|
|
11
|
+
* { op: "unset", target: "n_cta", prop: "border-radius" }
|
|
12
|
+
* { op: "insert", target: "n_hero", index: 0, html: "<div …></div>" }
|
|
13
|
+
* { op: "remove", target: "n_card" }
|
|
14
|
+
* { op: "move", target: "n_card", before: "n_cta" } // or after / index / into
|
|
15
|
+
*
|
|
16
|
+
* ## Addressing
|
|
17
|
+
*
|
|
18
|
+
* By `data-id`, not by JSON Pointer and not by CSS selector. A data-id survives a
|
|
19
|
+
* sibling being inserted or reordered, which is exactly when a positional path
|
|
20
|
+
* breaks. The id index walks the document in document order and the first id wins
|
|
21
|
+
* on duplicates.
|
|
22
|
+
*
|
|
23
|
+
* ## Assumptions where docs/format-v3.md is silent
|
|
24
|
+
*
|
|
25
|
+
* A1. `set` prop forms. `"style:<css-property>"` writes one declaration inside the
|
|
26
|
+
* `style` attribute; `"text"` replaces the element's content with one text node;
|
|
27
|
+
* any other prop name is an attribute name (`"data-name"`, `"data-id"`, `"style"`).
|
|
28
|
+
* A2. Values are written verbatim. Normalization is a separate layer (§8), so an op
|
|
29
|
+
* never rewrites more than the thing it names.
|
|
30
|
+
* A3. Errors use only the six codes listed in the brief. `ops/node-not-found` is "no
|
|
31
|
+
* node carries that data-id"; `ops/no-target` is "the node exists but the thing
|
|
32
|
+
* you named inside it does not".
|
|
33
|
+
* A4. Container addresses. A target is a data-id. Containers that cannot carry one
|
|
34
|
+
* (`document`, `html`, `head`, `body`) are addressable by those four reserved
|
|
35
|
+
* words, and only when no data-id matches. Without this, inserting a page or
|
|
36
|
+
* inverting a cross-parent `move` would be unaddressable.
|
|
37
|
+
* A5. Exact-restore forms. Some inverse ops cannot be expressed with the plain op
|
|
38
|
+
* shapes, so they carry extra fields that only the inverse uses, and `applyOps`
|
|
39
|
+
* accepts them:
|
|
40
|
+
* - `children: [...]` names the child list a parent must end up with. It is the
|
|
41
|
+
* only way back once a write has merged two text nodes: the file no longer
|
|
42
|
+
* says where the node sat between them, so the op has to. Elements are named
|
|
43
|
+
* by `data-id` and text is inline, which makes the list plain data — it
|
|
44
|
+
* survives JSON, where a live node object does not. `set`, `insert`, `remove`
|
|
45
|
+
* and `move` all accept it; `insert` and `move` need no `html`/`node`
|
|
46
|
+
* alongside it;
|
|
47
|
+
* - `set` on an attribute with `index: n` puts the attribute back at position n
|
|
48
|
+
* (attribute order is part of byte fidelity, §4 invariant 5);
|
|
49
|
+
* - `set` on `"style"` writes the whole `style` attribute, which is how adding
|
|
50
|
+
* or removing one declaration is undone without reflowing the other ones;
|
|
51
|
+
* - `insert` with `node: <subtree>` inserts that tree verbatim (the inverse of
|
|
52
|
+
* `remove` — re-parsing HTML would lose bytes the model cannot express);
|
|
53
|
+
* - `remove` with `index: n` removes `target`'s child at that index (the inverse
|
|
54
|
+
* of an insert whose node has no data-id).
|
|
55
|
+
* `writeFragment`-compatible `html` is set alongside `node` whenever
|
|
56
|
+
* `doc.source` carries the original bytes.
|
|
57
|
+
* The list is carried only when the sequence can actually change shape on the way
|
|
58
|
+
* to disk — that is, when the write would leave two text nodes side by side. Ops
|
|
59
|
+
* that cannot merge anything keep the small index-based inverse.
|
|
60
|
+
* A6. `insert` parses its HTML with the local mini parser below. `lib/parse.mjs` owns
|
|
61
|
+
* the document parser and `parseFragmentHtml` may or may not exist there; this
|
|
62
|
+
* module never imports it, so `insert` works before, during and after that file
|
|
63
|
+
* lands. The inserted top-level node keeps `raw`, its exact bytes, so the
|
|
64
|
+
* serializer can write the fragment back verbatim.
|
|
65
|
+
* A7. `applyOps` returns a document that shares no object with its input, and every
|
|
66
|
+
* `parent` pointer in it is consistent with the returned tree.
|
|
67
|
+
* A8. `setOpFor` returns an op *list*. With the node present it is one `set`. With
|
|
68
|
+
* the node absent the smallest list that still works is an `insert` of a minimal
|
|
69
|
+
* node plus the `set`; the parent is the id before `__` when that id exists
|
|
70
|
+
* (`home__cta` → `home`), else the last page.
|
|
71
|
+
* A9. `parseStyleDeclarations` lives in this file. §4.2 fixes the shape of a
|
|
72
|
+
* declaration but does not say where the scanner lives, and `set style:x` needs
|
|
73
|
+
* the byte range of the *value* — which the `CssDeclaration` shape does not carry.
|
|
74
|
+
* Consumers that only need `{property, value}` can read `lib/parse.mjs` instead.
|
|
75
|
+
* A10. `insert` refuses a target that cannot hold children (page, frame, or the
|
|
76
|
+
* document — format-v3 §3). The two type names are repeated here rather than
|
|
77
|
+
* imported from `lib/table.mjs`, so this module — which every other one
|
|
78
|
+
* depends on — keeps loading even if a table entry is renamed.
|
|
79
|
+
* A11. `set-token` and `unset-token` are the two op kinds that change a design token.
|
|
80
|
+
* A token is not an element and has no `data-id`, so it is addressed by its CSS
|
|
81
|
+
* custom-property name (`--color-accent`, with the `--`) plus the position of the
|
|
82
|
+
* one token block — see `tokenBlockOf`. The seven groups and the unit a numeric
|
|
83
|
+
* group carries are `lib/tokens.mjs`'s business: this module imports `lib/parse.mjs`
|
|
84
|
+
* — which itself imports nothing — and still does not depend on the table.
|
|
85
|
+
* Each kind can also carry the exact bytes the other one removed (`set-token`:
|
|
86
|
+
* `at` + `restore`; `unset-token`: `at` + `span`). A token block's separators are
|
|
87
|
+
* the file's bytes, not this module's to choose: `--b:2; --c:3` and `--b:2;\n --c:3`
|
|
88
|
+
* are both legal, and an undo that re-typesets the seam it removed would turn a
|
|
89
|
+
* byte-faithful file into an almost-faithful one. `insert` carries its fragment and
|
|
90
|
+
* `remove` carries the child list as it was, for the same reason.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
import { parseRootDeclarations, rootSpans } from "./parse.mjs";
|
|
94
|
+
|
|
95
|
+
export const OP_KINDS = ["set", "unset", "insert", "remove", "move", "set-token", "unset-token"];
|
|
96
|
+
|
|
97
|
+
export class OpsError extends Error {
|
|
98
|
+
constructor(code, message, extra = {}) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "OpsError";
|
|
101
|
+
this.code = code;
|
|
102
|
+
Object.assign(this, extra);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ------------------------------------------------------------------ *
|
|
107
|
+
* Small local helpers. No imports: this module has no dependencies.
|
|
108
|
+
* ------------------------------------------------------------------ */
|
|
109
|
+
|
|
110
|
+
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
111
|
+
|
|
112
|
+
function isObject(value) {
|
|
113
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isElement(node) {
|
|
117
|
+
return isObject(node) && (node.kind === "element" || typeof node.tag === "string");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDocumentLike(node) {
|
|
121
|
+
return isObject(node) && (node.kind === "document" || Array.isArray(node.children));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The slots that hold child nodes. Nothing else is walked. */
|
|
125
|
+
const DOCUMENT_SLOTS = ["children", "doctype", "html", "head", "body"];
|
|
126
|
+
|
|
127
|
+
/** The document slots that are aliases for elements that also sit in `children`. */
|
|
128
|
+
const ALIAS_SLOTS = ["html", "head", "body"];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The `data-type` values that may carry children. format-v3 §3 is the contract; this
|
|
132
|
+
* engine repeats the three names rather than import the table, so the module that
|
|
133
|
+
* every other one depends on keeps loading even if a table entry is renamed.
|
|
134
|
+
*/
|
|
135
|
+
const CHILD_TYPES = new Set(["page", "frame"]);
|
|
136
|
+
|
|
137
|
+
function slotKeys(node) {
|
|
138
|
+
if (!isObject(node)) return [];
|
|
139
|
+
if (node.kind === "document") return DOCUMENT_SLOTS;
|
|
140
|
+
if (isElement(node)) return ["children"];
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Containers that cannot carry a data-id and are therefore named literally (A4). */
|
|
145
|
+
export const RESERVED_CONTAINERS = ["document", "html", "head", "body"];
|
|
146
|
+
|
|
147
|
+
function attrOf(node, name) {
|
|
148
|
+
const list = Array.isArray(node.attrs) ? node.attrs : [];
|
|
149
|
+
const wanted = String(name).toLowerCase();
|
|
150
|
+
for (const attr of list) {
|
|
151
|
+
if (attr && typeof attr.name === "string" && attr.name.toLowerCase() === wanted) return attr;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function attrValue(node, name) {
|
|
157
|
+
const attr = attrOf(node, name);
|
|
158
|
+
return attr ? String(attr.value ?? "") : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `data-id` when it is a usable id, else null. */
|
|
162
|
+
export function dataIdOf(node) {
|
|
163
|
+
if (!isElement(node)) return null;
|
|
164
|
+
const value = attrValue(node, "data-id");
|
|
165
|
+
return value !== null && value !== "" ? value : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `data-type` when present and non-empty, else null. */
|
|
169
|
+
export function dataTypeOf(node) {
|
|
170
|
+
if (!isElement(node)) return null;
|
|
171
|
+
const value = attrValue(node, "data-type");
|
|
172
|
+
return value !== null && value !== "" ? value : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ------------------------------------------------------------------ *
|
|
176
|
+
* Walking the tree
|
|
177
|
+
* ------------------------------------------------------------------ */
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every design-layer node in document order, with the path that reaches it.
|
|
181
|
+
*
|
|
182
|
+
* A path step is `{ key, index }`: `index` is -1 for an object slot (`html`, `body`,
|
|
183
|
+
* `doctype`) and the array position otherwise. Paths are how this module mutates: a
|
|
184
|
+
* path is resolved once, then rebuilt, so no lookup is ever done against a tree that
|
|
185
|
+
* a previous step already changed.
|
|
186
|
+
*/
|
|
187
|
+
function collectEntries(doc) {
|
|
188
|
+
const out = [];
|
|
189
|
+
const walk = (node, path) => {
|
|
190
|
+
for (const key of slotKeys(node)) {
|
|
191
|
+
const value = node[key];
|
|
192
|
+
if (Array.isArray(value)) {
|
|
193
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
194
|
+
const child = value[i];
|
|
195
|
+
if (child === null || child === undefined) continue;
|
|
196
|
+
const childPath = path.concat([{ key, index: i }]);
|
|
197
|
+
out.push({ node: child, parent: node, index: i, path: childPath });
|
|
198
|
+
walk(child, childPath);
|
|
199
|
+
}
|
|
200
|
+
} else if (isObject(value)) {
|
|
201
|
+
const childPath = path.concat([{ key, index: -1 }]);
|
|
202
|
+
out.push({ node: value, parent: node, index: -1, path: childPath });
|
|
203
|
+
walk(value, childPath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
walk(doc, []);
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Rebuild the spine from `root` down `path`, replacing the node at the end with
|
|
213
|
+
* `apply(node)`. Nodes off the path are shared, never copied, and never mutated.
|
|
214
|
+
* `apply` may return `REMOVE` to delete the node from an array slot.
|
|
215
|
+
*
|
|
216
|
+
* `docRoot` and `patch` exist for one reason: a document keeps `html`, `head` and
|
|
217
|
+
* `body` as slots over nodes that also sit in `children`. Replacing the body deep
|
|
218
|
+
* inside `<html>` must move the document's own `body` slot too, or `doc.body` keeps
|
|
219
|
+
* answering from the tree as it was — including ids that were just removed. The
|
|
220
|
+
* patch is applied once, at the root, by `applyToPath`.
|
|
221
|
+
*/
|
|
222
|
+
const REMOVE = Symbol("ops.remove");
|
|
223
|
+
|
|
224
|
+
function rebuild(root, path, apply, docRoot, patch) {
|
|
225
|
+
if (path.length === 0) return apply(root);
|
|
226
|
+
const step = path[0];
|
|
227
|
+
const rest = path.slice(1);
|
|
228
|
+
const copy = { ...root };
|
|
229
|
+
if (step.index >= 0) {
|
|
230
|
+
const source = root[step.key];
|
|
231
|
+
const list = Array.isArray(source) ? source.slice() : [];
|
|
232
|
+
const before = list[step.index];
|
|
233
|
+
const next = rebuild(before, rest, apply, docRoot, patch);
|
|
234
|
+
if (next === REMOVE) list.splice(step.index, 1);
|
|
235
|
+
else list[step.index] = next;
|
|
236
|
+
copy[step.key] = list;
|
|
237
|
+
if (docRoot !== undefined && before !== undefined) {
|
|
238
|
+
for (const alias of ALIAS_SLOTS) {
|
|
239
|
+
if (before === docRoot[alias]) patch[alias] = next === REMOVE ? null : next;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
const before = root[step.key];
|
|
244
|
+
const next = rebuild(before, rest, apply, docRoot, patch);
|
|
245
|
+
copy[step.key] = next === REMOVE ? null : next;
|
|
246
|
+
// The other direction: the path came down an alias slot, so `children` still
|
|
247
|
+
// holds the node it used to point at.
|
|
248
|
+
if (docRoot !== undefined && ALIAS_SLOTS.includes(step.key) && Array.isArray(root.children)) {
|
|
249
|
+
const at = root.children.indexOf(before);
|
|
250
|
+
if (at >= 0) {
|
|
251
|
+
const list = copy.children.slice();
|
|
252
|
+
if (next === REMOVE) list.splice(at, 1);
|
|
253
|
+
else list[at] = next;
|
|
254
|
+
copy.children = list;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return copy;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `rebuild`, plus the one write that keeps a document's alias slots honest. */
|
|
262
|
+
function applyToPath(doc, path, apply) {
|
|
263
|
+
const patch = {};
|
|
264
|
+
const next = rebuild(doc, path, apply, doc, patch);
|
|
265
|
+
if (next === doc) return next;
|
|
266
|
+
for (const [alias, value] of Object.entries(patch)) next[alias] = value;
|
|
267
|
+
return next;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ------------------------------------------------------------------ *
|
|
271
|
+
* Node-id index
|
|
272
|
+
* ------------------------------------------------------------------ */
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `Map<data-id, node>` over the whole document.
|
|
276
|
+
*
|
|
277
|
+
* Pages come in document order, so a plain document-order walk visits them in that
|
|
278
|
+
* order and then their subtrees, which is exactly the required order. The first id
|
|
279
|
+
* wins on duplicates: a second node claiming an id is a validation error, and
|
|
280
|
+
* addressing must stay deterministic while the validator reports it.
|
|
281
|
+
*/
|
|
282
|
+
export function buildIdIndex(doc) {
|
|
283
|
+
const index = new Map();
|
|
284
|
+
if (!isObject(doc)) return index;
|
|
285
|
+
for (const entry of collectEntries(doc)) {
|
|
286
|
+
if (!isElement(entry.node)) continue;
|
|
287
|
+
const id = dataIdOf(entry.node);
|
|
288
|
+
if (id === null || index.has(id)) continue;
|
|
289
|
+
index.set(id, entry.node);
|
|
290
|
+
}
|
|
291
|
+
return index;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The first entry whose element carries `id`, matching `buildIdIndex`'s rule. */
|
|
295
|
+
function entryOfId(doc, id) {
|
|
296
|
+
for (const entry of collectEntries(doc)) {
|
|
297
|
+
if (isElement(entry.node) && dataIdOf(entry.node) === id) return entry;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve an address to an entry. A data-id wins; the four reserved words resolve
|
|
304
|
+
* the containers that cannot carry one (A4).
|
|
305
|
+
*/
|
|
306
|
+
function resolveAddress(doc, address) {
|
|
307
|
+
const entries = collectEntries(doc);
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (isElement(entry.node) && dataIdOf(entry.node) === address) return entry;
|
|
310
|
+
}
|
|
311
|
+
if (!RESERVED_CONTAINERS.includes(address)) return null;
|
|
312
|
+
if (address === "document") return { node: doc, parent: null, index: -1, path: [] };
|
|
313
|
+
const slot = address === "html" ? doc.html : address === "head" ? doc.head : doc.body;
|
|
314
|
+
if (isObject(slot)) {
|
|
315
|
+
for (const entry of entries) if (entry.node === slot) return entry;
|
|
316
|
+
return { node: slot, parent: doc, index: -1, path: [{ key: address, index: -1 }] };
|
|
317
|
+
}
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
if (isElement(entry.node) && entry.node.tag === address) return entry;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function requireEntry(doc, address, op) {
|
|
325
|
+
const entry = resolveAddress(doc, address);
|
|
326
|
+
if (entry === null) {
|
|
327
|
+
throw new OpsError("ops/node-not-found", `No node with data-id ${JSON.stringify(address)}`, {
|
|
328
|
+
target: address,
|
|
329
|
+
op,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return entry;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function requireContainer(doc, address, op) {
|
|
336
|
+
const entry = requireEntry(doc, address, op);
|
|
337
|
+
if (entry.node === doc) return entry;
|
|
338
|
+
assertHoldsChildren(entry.node, address, op);
|
|
339
|
+
return entry;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Only page and frame take children (format-v3 §3). */
|
|
343
|
+
function assertHoldsChildren(node, address, op) {
|
|
344
|
+
if (!isElement(node)) {
|
|
345
|
+
throw new OpsError(
|
|
346
|
+
"ops/bad-op",
|
|
347
|
+
`${JSON.stringify(address)} cannot hold children: it is a ${node.kind || "non-element"} node`,
|
|
348
|
+
{ target: address, op },
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const type = dataTypeOf(node);
|
|
352
|
+
if (type !== null && !CHILD_TYPES.has(type)) {
|
|
353
|
+
throw new OpsError(
|
|
354
|
+
"ops/bad-op",
|
|
355
|
+
`${JSON.stringify(address)} is a ${type}: only page and frame take children`,
|
|
356
|
+
{ target: address, op },
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ------------------------------------------------------------------ *
|
|
362
|
+
* Attribute and style-text editing
|
|
363
|
+
* ------------------------------------------------------------------ */
|
|
364
|
+
|
|
365
|
+
function withAttr(node, name, value, insertIndex) {
|
|
366
|
+
const attrs = Array.isArray(node.attrs) ? node.attrs.slice() : [];
|
|
367
|
+
const wanted = String(name).toLowerCase();
|
|
368
|
+
const at = attrs.findIndex((attr) => attr && String(attr.name).toLowerCase() === wanted);
|
|
369
|
+
if (at >= 0) {
|
|
370
|
+
attrs[at] = { ...attrs[at], value };
|
|
371
|
+
} else {
|
|
372
|
+
const fresh = {
|
|
373
|
+
kind: "attr",
|
|
374
|
+
name,
|
|
375
|
+
rawName: name,
|
|
376
|
+
value,
|
|
377
|
+
quote: '"',
|
|
378
|
+
start: -1,
|
|
379
|
+
end: -1,
|
|
380
|
+
attrStart: -1,
|
|
381
|
+
};
|
|
382
|
+
const position = Number.isInteger(insertIndex)
|
|
383
|
+
? Math.max(0, Math.min(insertIndex, attrs.length))
|
|
384
|
+
: attrs.length;
|
|
385
|
+
attrs.splice(position, 0, fresh);
|
|
386
|
+
}
|
|
387
|
+
return { ...node, attrs };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function withoutAttr(node, name) {
|
|
391
|
+
const wanted = String(name).toLowerCase();
|
|
392
|
+
const attrs = (Array.isArray(node.attrs) ? node.attrs : []).filter(
|
|
393
|
+
(attr) => !(attr && String(attr.name).toLowerCase() === wanted),
|
|
394
|
+
);
|
|
395
|
+
return { ...node, attrs };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function attrIndexOf(node, name) {
|
|
399
|
+
const list = Array.isArray(node.attrs) ? node.attrs : [];
|
|
400
|
+
const wanted = String(name).toLowerCase();
|
|
401
|
+
return list.findIndex((attr) => attr && String(attr.name).toLowerCase() === wanted);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function scalarText(value, op) {
|
|
405
|
+
if (typeof value === "string") return value;
|
|
406
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
407
|
+
throw new OpsError(
|
|
408
|
+
"ops/bad-op",
|
|
409
|
+
`A "set" value must be a string, number or boolean, got ${value === null ? "null" : typeof value}`,
|
|
410
|
+
{ op },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Split a `style` attribute into declarations.
|
|
416
|
+
*
|
|
417
|
+
* §4.2 forbids splitting on `;`: a data URI contains one, and so does `content:"a;b"`.
|
|
418
|
+
* This walks characters and tracks four states — single quote, double quote, paren
|
|
419
|
+
* depth, comment — and only treats `;` and `:` as separators when all four are at
|
|
420
|
+
* rest. Escapes inside a string do not take part in state changes.
|
|
421
|
+
*
|
|
422
|
+
* Runs inside `style` attributes and inside the two `<style>` blocks, which is why
|
|
423
|
+
* it lives here rather than in a module of its own.
|
|
424
|
+
*
|
|
425
|
+
* @returns {{property: string, value: string, rawValue: string, start: number,
|
|
426
|
+
* end: number, valueStart: number, valueEnd: number}[]}
|
|
427
|
+
* `start`/`end` bound the declaration without its separator or padding;
|
|
428
|
+
* `valueStart`/`valueEnd` bound exactly the value.
|
|
429
|
+
*/
|
|
430
|
+
export function parseStyleDeclarations(text) {
|
|
431
|
+
const source = String(text ?? "");
|
|
432
|
+
const raw = [];
|
|
433
|
+
let start = 0;
|
|
434
|
+
let colon = -1;
|
|
435
|
+
let depth = 0;
|
|
436
|
+
let quote = null;
|
|
437
|
+
let comment = false;
|
|
438
|
+
|
|
439
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
440
|
+
const ch = source[i];
|
|
441
|
+
if (comment) {
|
|
442
|
+
if (ch === "*" && source[i + 1] === "/") {
|
|
443
|
+
comment = false;
|
|
444
|
+
i += 1;
|
|
445
|
+
}
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (quote !== null) {
|
|
449
|
+
if (ch === "\\") {
|
|
450
|
+
i += 1;
|
|
451
|
+
} else if (ch === quote) {
|
|
452
|
+
quote = null;
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (depth > 0) {
|
|
457
|
+
if (ch === "(") depth += 1;
|
|
458
|
+
else if (ch === ")") depth -= 1;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (ch === "/" && source[i + 1] === "*") {
|
|
462
|
+
comment = true;
|
|
463
|
+
i += 1;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (ch === '"' || ch === "'") {
|
|
467
|
+
quote = ch;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (ch === "(") {
|
|
471
|
+
depth += 1;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (ch === ";") {
|
|
475
|
+
raw.push({ start, end: i, colon });
|
|
476
|
+
start = i + 1;
|
|
477
|
+
colon = -1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (ch === ":" && colon < 0) colon = i;
|
|
481
|
+
}
|
|
482
|
+
raw.push({ start, end: source.length, colon });
|
|
483
|
+
|
|
484
|
+
const out = [];
|
|
485
|
+
for (const piece of raw) {
|
|
486
|
+
let from = piece.start;
|
|
487
|
+
let to = piece.end;
|
|
488
|
+
while (from < to && /\s/.test(source[from])) from += 1;
|
|
489
|
+
while (to > from && /\s/.test(source[to - 1])) to -= 1;
|
|
490
|
+
if (from >= to) continue;
|
|
491
|
+
const head = piece.colon >= 0 && piece.colon < to ? source.slice(from, piece.colon) : source.slice(from, to);
|
|
492
|
+
const property = head.trim();
|
|
493
|
+
let valueStart = to;
|
|
494
|
+
let value = "";
|
|
495
|
+
if (piece.colon >= 0 && piece.colon < to) {
|
|
496
|
+
valueStart = piece.colon + 1;
|
|
497
|
+
while (valueStart < to && /\s/.test(source[valueStart])) valueStart += 1;
|
|
498
|
+
value = source.slice(valueStart, to);
|
|
499
|
+
}
|
|
500
|
+
out.push({
|
|
501
|
+
property,
|
|
502
|
+
rawProperty: property,
|
|
503
|
+
value,
|
|
504
|
+
rawValue: piece.colon >= 0 && piece.colon < to ? source.slice(piece.colon + 1, to) : "",
|
|
505
|
+
start: from,
|
|
506
|
+
end: to,
|
|
507
|
+
valueStart,
|
|
508
|
+
valueEnd: valueStart + value.length,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function findDeclaration(text, property) {
|
|
515
|
+
const wanted = String(property).toLowerCase();
|
|
516
|
+
for (const declaration of parseStyleDeclarations(text)) {
|
|
517
|
+
if (declaration.property.toLowerCase() === wanted) return declaration;
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function replaceDeclarationValue(text, declaration, value) {
|
|
523
|
+
return `${text.slice(0, declaration.valueStart)}${value}${text.slice(declaration.valueEnd)}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function appendDeclaration(text, property, value) {
|
|
527
|
+
const trimmed = String(text ?? "").replace(/\s+$/, "");
|
|
528
|
+
if (trimmed === "") return `${property}:${value}`;
|
|
529
|
+
const separator = trimmed.endsWith(";") ? "" : ";";
|
|
530
|
+
return `${trimmed}${separator}${property}:${value}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function removeDeclaration(text, declaration) {
|
|
534
|
+
const after = text.indexOf(";", declaration.end);
|
|
535
|
+
let from;
|
|
536
|
+
let to;
|
|
537
|
+
if (after >= 0) {
|
|
538
|
+
from = declaration.start;
|
|
539
|
+
to = after + 1;
|
|
540
|
+
} else {
|
|
541
|
+
const before = text.lastIndexOf(";", declaration.start - 1);
|
|
542
|
+
from = before >= 0 ? before : 0;
|
|
543
|
+
to = declaration.end;
|
|
544
|
+
}
|
|
545
|
+
const next = `${text.slice(0, from)}${text.slice(to)}`.trim();
|
|
546
|
+
return next.endsWith(";") ? next.slice(0, -1) : next;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ------------------------------------------------------------------ *
|
|
550
|
+
* Fragment parsing (A6)
|
|
551
|
+
* ------------------------------------------------------------------ */
|
|
552
|
+
|
|
553
|
+
/** Tags that never take a closing tag. Shared with `lib/fragment.mjs`. */
|
|
554
|
+
export const VOID_TAGS = new Set([
|
|
555
|
+
"area",
|
|
556
|
+
"base",
|
|
557
|
+
"br",
|
|
558
|
+
"col",
|
|
559
|
+
"embed",
|
|
560
|
+
"hr",
|
|
561
|
+
"img",
|
|
562
|
+
"input",
|
|
563
|
+
"link",
|
|
564
|
+
"meta",
|
|
565
|
+
"param",
|
|
566
|
+
"source",
|
|
567
|
+
"track",
|
|
568
|
+
"wbr",
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
const ENTITIES = [
|
|
572
|
+
["<", "<"],
|
|
573
|
+
[">", ">"],
|
|
574
|
+
[""", '"'],
|
|
575
|
+
["'", "'"],
|
|
576
|
+
["'", "'"],
|
|
577
|
+
[" ", "\u00a0"],
|
|
578
|
+
["&", "&"],
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
export function escapeAttrValue(value) {
|
|
582
|
+
return String(value ?? "")
|
|
583
|
+
.replace(/&/g, "&")
|
|
584
|
+
.replace(/"/g, """)
|
|
585
|
+
.replace(/</g, "<")
|
|
586
|
+
.replace(/>/g, ">");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function escapeTextValue(value) {
|
|
590
|
+
return String(value ?? "")
|
|
591
|
+
.replace(/&/g, "&")
|
|
592
|
+
.replace(/</g, "<")
|
|
593
|
+
.replace(/>/g, ">");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function decodeEntities(value) {
|
|
597
|
+
let out = value;
|
|
598
|
+
for (const [entity, char] of ENTITIES) out = out.split(entity).join(char);
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function skipSpace(source, from, to) {
|
|
603
|
+
let i = from;
|
|
604
|
+
while (i < to && /\s/.test(source[i])) i += 1;
|
|
605
|
+
return i;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Parse an HTML fragment into the document model's element shape.
|
|
610
|
+
*
|
|
611
|
+
* This is deliberately *not* `lib/parse.mjs`. It never sees a whole file: no
|
|
612
|
+
* doctype, no `<head>`, no `<style>` blocks, no byte offsets that anything else
|
|
613
|
+
* depends on. What it must do is turn `<div data-id="n_x" data-type="frame">…</div>`
|
|
614
|
+
* into a node so a later op can address `n_x` — which is why the raw string alone is
|
|
615
|
+
* not enough and why this parser exists at all.
|
|
616
|
+
*
|
|
617
|
+
* Offsets are relative to `html`. A mismatched closing tag pops to the matching open
|
|
618
|
+
* element when there is one and is otherwise ignored, so a malformed fragment can
|
|
619
|
+
* never corrupt a tree. No implicit tag completion happens beyond that.
|
|
620
|
+
*
|
|
621
|
+
* @returns {object[]} the top-level nodes, in order
|
|
622
|
+
*/
|
|
623
|
+
export function parseFragmentHtml(html) {
|
|
624
|
+
const source = typeof html === "string" ? html : "";
|
|
625
|
+
const roots = [];
|
|
626
|
+
const stack = [];
|
|
627
|
+
|
|
628
|
+
const push = (node) => {
|
|
629
|
+
const parent = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
630
|
+
node.parent = parent;
|
|
631
|
+
if (parent) parent.children.push(node);
|
|
632
|
+
else roots.push(node);
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
let i = 0;
|
|
636
|
+
while (i < source.length) {
|
|
637
|
+
const lt = source.indexOf("<", i);
|
|
638
|
+
if (lt === -1) {
|
|
639
|
+
push({
|
|
640
|
+
kind: "text",
|
|
641
|
+
value: decodeEntities(source.slice(i)),
|
|
642
|
+
raw: source.slice(i),
|
|
643
|
+
parent: null,
|
|
644
|
+
start: i,
|
|
645
|
+
end: source.length,
|
|
646
|
+
});
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
if (lt > i) {
|
|
650
|
+
push({
|
|
651
|
+
kind: "text",
|
|
652
|
+
value: decodeEntities(source.slice(i, lt)),
|
|
653
|
+
raw: source.slice(i, lt),
|
|
654
|
+
parent: null,
|
|
655
|
+
start: i,
|
|
656
|
+
end: lt,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
if (source.startsWith("<!--", lt)) {
|
|
660
|
+
const close = source.indexOf("-->", lt + 4);
|
|
661
|
+
const stop = close === -1 ? source.length : close + 3;
|
|
662
|
+
push({ kind: "comment", raw: source.slice(lt, stop), parent: null, start: lt, end: stop });
|
|
663
|
+
i = stop;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
if (source.startsWith("<!", lt) || source.startsWith("<?", lt)) {
|
|
667
|
+
const gt = source.indexOf(">", lt);
|
|
668
|
+
const stop = gt === -1 ? source.length : gt + 1;
|
|
669
|
+
push({ kind: "doctype", raw: source.slice(lt, stop), parent: null, start: lt, end: stop });
|
|
670
|
+
i = stop;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (source.startsWith("</", lt)) {
|
|
674
|
+
const gt = source.indexOf(">", lt);
|
|
675
|
+
const stop = gt === -1 ? source.length : gt + 1;
|
|
676
|
+
const name = source.slice(lt + 2, gt === -1 ? source.length : gt).trim().toLowerCase();
|
|
677
|
+
for (let depth = stack.length - 1; depth >= 0; depth -= 1) {
|
|
678
|
+
if (stack[depth].tag === name) {
|
|
679
|
+
stack[depth].closeStart = lt;
|
|
680
|
+
stack[depth].closeEnd = stop;
|
|
681
|
+
stack[depth].end = stop;
|
|
682
|
+
stack.length = depth;
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
i = stop;
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const nameMatch = /^<([A-Za-z][^\s/>]*)/.exec(source.slice(lt));
|
|
691
|
+
if (nameMatch === null) {
|
|
692
|
+
push({
|
|
693
|
+
kind: "text",
|
|
694
|
+
value: source[lt],
|
|
695
|
+
raw: source[lt],
|
|
696
|
+
parent: null,
|
|
697
|
+
start: lt,
|
|
698
|
+
end: lt + 1,
|
|
699
|
+
});
|
|
700
|
+
i = lt + 1;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const rawTag = nameMatch[1];
|
|
705
|
+
const tag = rawTag.toLowerCase();
|
|
706
|
+
const attrs = [];
|
|
707
|
+
let cursor = lt + 1 + rawTag.length;
|
|
708
|
+
let selfClosing = false;
|
|
709
|
+
let openEnd = source.length;
|
|
710
|
+
while (cursor < source.length) {
|
|
711
|
+
const ch = source[cursor];
|
|
712
|
+
if (ch === ">") {
|
|
713
|
+
openEnd = cursor + 1;
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
if (ch === "/" && source[cursor + 1] === ">") {
|
|
717
|
+
selfClosing = true;
|
|
718
|
+
openEnd = cursor + 2;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (/\s/.test(ch)) {
|
|
722
|
+
cursor += 1;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const nameStart = cursor;
|
|
726
|
+
while (cursor < source.length && !/[\s=/>]/.test(source[cursor])) cursor += 1;
|
|
727
|
+
const rawName = source.slice(nameStart, cursor);
|
|
728
|
+
const name = rawName.toLowerCase();
|
|
729
|
+
let value = "";
|
|
730
|
+
let quote = "";
|
|
731
|
+
let valueStart = cursor;
|
|
732
|
+
let valueEnd = cursor;
|
|
733
|
+
let after = skipSpace(source, cursor, source.length);
|
|
734
|
+
if (source[after] === "=") {
|
|
735
|
+
after = skipSpace(source, after + 1, source.length);
|
|
736
|
+
const first = source[after];
|
|
737
|
+
if (first === '"' || first === "'") {
|
|
738
|
+
const close = source.indexOf(first, after + 1);
|
|
739
|
+
const stop = close === -1 ? source.length : close;
|
|
740
|
+
quote = first;
|
|
741
|
+
value = decodeEntities(source.slice(after + 1, stop));
|
|
742
|
+
valueStart = after;
|
|
743
|
+
valueEnd = close === -1 ? source.length : close + 1;
|
|
744
|
+
cursor = valueEnd;
|
|
745
|
+
} else {
|
|
746
|
+
let stop = after;
|
|
747
|
+
while (stop < source.length && !/[\s>]/.test(source[stop])) stop += 1;
|
|
748
|
+
value = decodeEntities(source.slice(after, stop));
|
|
749
|
+
valueStart = after;
|
|
750
|
+
valueEnd = stop;
|
|
751
|
+
cursor = stop;
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
cursor = after;
|
|
755
|
+
}
|
|
756
|
+
attrs.push({
|
|
757
|
+
kind: "attr",
|
|
758
|
+
name,
|
|
759
|
+
rawName,
|
|
760
|
+
value,
|
|
761
|
+
quote,
|
|
762
|
+
start: valueStart,
|
|
763
|
+
end: valueEnd,
|
|
764
|
+
attrStart: nameStart,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const node = {
|
|
769
|
+
kind: "element",
|
|
770
|
+
tag,
|
|
771
|
+
rawTag,
|
|
772
|
+
attrs,
|
|
773
|
+
children: [],
|
|
774
|
+
selfClosing,
|
|
775
|
+
parent: null,
|
|
776
|
+
start: lt,
|
|
777
|
+
end: openEnd,
|
|
778
|
+
openStart: lt,
|
|
779
|
+
openEnd,
|
|
780
|
+
closeStart: -1,
|
|
781
|
+
closeEnd: -1,
|
|
782
|
+
};
|
|
783
|
+
push(node);
|
|
784
|
+
i = openEnd;
|
|
785
|
+
if (!selfClosing && !VOID_TAGS.has(tag)) stack.push(node);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return roots;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The exact bytes of a node, when the document carries its own source. */
|
|
792
|
+
function rawBytesOf(doc, node) {
|
|
793
|
+
if (!isObject(doc) || typeof doc.source !== "string" || !isObject(node)) return null;
|
|
794
|
+
const { start, end } = node;
|
|
795
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
|
|
796
|
+
if (start < 0 || end <= start || end > doc.source.length) return null;
|
|
797
|
+
return doc.source.slice(start, end);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/* ------------------------------------------------------------------ *
|
|
801
|
+
* Detaching the result (A7)
|
|
802
|
+
* ------------------------------------------------------------------ */
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Deep-copy a tree, re-linking every `parent` pointer to the copy.
|
|
806
|
+
*
|
|
807
|
+
* `applyOps` ends with this so the returned document shares no object with its
|
|
808
|
+
* input, which is what "the input must never be mutated" has to mean once a caller
|
|
809
|
+
* holds both. A memo keeps shared nodes shared — `styleBlocks[i].node` is the very
|
|
810
|
+
* `<style>` element that sits in `<head>`, and splitting it into two objects would
|
|
811
|
+
* break that.
|
|
812
|
+
*/
|
|
813
|
+
function detach(node, parent, memo) {
|
|
814
|
+
if (node === null || typeof node !== "object") return node;
|
|
815
|
+
if (Array.isArray(node)) return node.map((item) => detach(item, parent, memo));
|
|
816
|
+
const seen = memo.get(node);
|
|
817
|
+
if (seen !== undefined) return seen;
|
|
818
|
+
|
|
819
|
+
const out = {};
|
|
820
|
+
memo.set(node, out);
|
|
821
|
+
const keys = Object.keys(node);
|
|
822
|
+
for (const key of keys) out[key] = undefined;
|
|
823
|
+
|
|
824
|
+
const isTree = isElement(node) || node.kind === "document";
|
|
825
|
+
const childParent = isElement(node) ? out : null;
|
|
826
|
+
for (const key of keys) {
|
|
827
|
+
if (key === "parent") continue;
|
|
828
|
+
const value = node[key];
|
|
829
|
+
if (isTree && slotKeys(node).includes(key)) {
|
|
830
|
+
out[key] = Array.isArray(value)
|
|
831
|
+
? value.map((item) => detach(item, childParent, memo))
|
|
832
|
+
: isObject(value)
|
|
833
|
+
? detach(value, childParent, memo)
|
|
834
|
+
: value;
|
|
835
|
+
} else {
|
|
836
|
+
out[key] = detach(value, parent, memo);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (keys.includes("parent")) out.parent = parent === undefined ? null : parent;
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/* ------------------------------------------------------------------ *
|
|
844
|
+
* Exact-restore child lists
|
|
845
|
+
* ------------------------------------------------------------------ */
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* The parent's child list, as plain data.
|
|
849
|
+
*
|
|
850
|
+
* HTML has no room for two adjacent text nodes: a write merges them, and every index
|
|
851
|
+
* after the merge moves up by one. So an op that lifts a node out from between two
|
|
852
|
+
* text nodes cannot be undone by an index once the file has been written and read
|
|
853
|
+
* back — and the write path does exactly that between two ops ("apply, serialize,
|
|
854
|
+
* write; the next op comes from a fresh parse").
|
|
855
|
+
*
|
|
856
|
+
* The inverse therefore carries the parent's child list as it was. An element is
|
|
857
|
+
* referenced by `data-id` (in the entry's `target`), because a document that came back
|
|
858
|
+
* from disk holds new node objects; text and comments are inline, because they are the
|
|
859
|
+
* very nodes a write merges. Every entry is plain data, so the list survives JSON —
|
|
860
|
+
* including the receipt's own field whitelist — where a live node object does not.
|
|
861
|
+
*/
|
|
862
|
+
function snapshotChildren(children, keep) {
|
|
863
|
+
const out = [];
|
|
864
|
+
for (const child of Array.isArray(children) ? children : []) {
|
|
865
|
+
if (!isObject(child)) continue;
|
|
866
|
+
if (keep !== undefined && keep.has(child)) {
|
|
867
|
+
// Leaving the tree means no later lookup can find it, so it has to travel whole.
|
|
868
|
+
out.push({ kind: "node", node: child });
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (child.kind === "text") {
|
|
872
|
+
out.push({
|
|
873
|
+
kind: "text",
|
|
874
|
+
value: String(child.value ?? ""),
|
|
875
|
+
raw: String(child.raw ?? child.value ?? ""),
|
|
876
|
+
});
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (child.kind === "comment" || child.kind === "doctype") {
|
|
880
|
+
out.push({ kind: child.kind, raw: String(child.raw ?? "") });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const id = dataIdOf(child);
|
|
884
|
+
if (id !== null) {
|
|
885
|
+
// `target`, not `id`: the receipt keeps an op's own fields and drops the rest
|
|
886
|
+
// (`lib/node/write-verbs.mjs`), and a ref that lost its id would come back as
|
|
887
|
+
// an empty entry. `target` already means "the data-id this op addresses".
|
|
888
|
+
out.push({ kind: "ref", target: id });
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
// No data-id: after a re-read there is nothing to look it up by, so the only
|
|
892
|
+
// thing that can carry it is the node itself.
|
|
893
|
+
out.push({ kind: "node", node: child });
|
|
894
|
+
}
|
|
895
|
+
return out;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* The nodes a `children` field names.
|
|
900
|
+
*
|
|
901
|
+
* Both shapes are accepted: the descriptor above, and the live nodes `set` writes.
|
|
902
|
+
* Inline entries are materialized fresh (they carry no bytes of their own — the
|
|
903
|
+
* serializer edits the origin's range, which is where the old bytes still are).
|
|
904
|
+
* `known` supplies nodes the tree no longer holds: the element a `remove` took out.
|
|
905
|
+
*/
|
|
906
|
+
function materializeChildren(doc, descriptor, known, op) {
|
|
907
|
+
const out = [];
|
|
908
|
+
for (const entry of Array.isArray(descriptor) ? descriptor : []) {
|
|
909
|
+
if (!isObject(entry)) continue;
|
|
910
|
+
if (entry.kind === "ref") {
|
|
911
|
+
const id = entry.target ?? entry.id;
|
|
912
|
+
const fromKnown = known === undefined ? undefined : known.get(id);
|
|
913
|
+
const node = fromKnown !== undefined ? fromKnown : entryOfId(doc, id)?.node ?? null;
|
|
914
|
+
if (node === null) {
|
|
915
|
+
// A list that cannot be produced in full is not an exact restore, and
|
|
916
|
+
// restoring part of it would drop a node in silence. Refuse instead.
|
|
917
|
+
throw new OpsError(
|
|
918
|
+
"ops/no-target",
|
|
919
|
+
`The child list names ${JSON.stringify(id)}, which is not in the document`,
|
|
920
|
+
{ target: id, op },
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
out.push(node);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (entry.kind === "node") {
|
|
927
|
+
if (isObject(entry.node)) out.push(entry.node);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (isElement(entry)) {
|
|
931
|
+
out.push(entry);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (entry.kind === "text") {
|
|
935
|
+
out.push({
|
|
936
|
+
kind: "text",
|
|
937
|
+
value: String(entry.value ?? ""),
|
|
938
|
+
raw: String(entry.raw ?? entry.value ?? ""),
|
|
939
|
+
parent: null,
|
|
940
|
+
start: -1,
|
|
941
|
+
end: -1,
|
|
942
|
+
});
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (entry.kind === "comment" || entry.kind === "doctype") {
|
|
946
|
+
out.push({ kind: entry.kind, raw: String(entry.raw ?? ""), parent: null, start: -1, end: -1 });
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
throw new OpsError(
|
|
950
|
+
"ops/bad-op",
|
|
951
|
+
`A child list entry must be text, comment, doctype, ref or node, got ${JSON.stringify(entry.kind)}`,
|
|
952
|
+
{ op },
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* True when writing this list would merge two of its entries: two text nodes side by
|
|
960
|
+
* side have no bytes between them, so a re-read sees one node where there were two,
|
|
961
|
+
* and every later index refers to a different node.
|
|
962
|
+
*/
|
|
963
|
+
function hasAdjacentText(children) {
|
|
964
|
+
for (let i = 1; i < children.length; i += 1) {
|
|
965
|
+
const left = children[i - 1];
|
|
966
|
+
const right = children[i];
|
|
967
|
+
if (isObject(left) && left.kind === "text" && isObject(right) && right.kind === "text") return true;
|
|
968
|
+
}
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** The same list with one node gone, by identity. */
|
|
973
|
+
function withoutNode(children, node) {
|
|
974
|
+
return (Array.isArray(children) ? children : []).filter((child) => child !== node);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Apply a `children` field: the parent's child list becomes exactly this, whatever the
|
|
979
|
+
* whitespace did on the way to disk.
|
|
980
|
+
*
|
|
981
|
+
* @returns {{doc: object, inverse: object[], at: number}}
|
|
982
|
+
*/
|
|
983
|
+
function restoreChildren(doc, op, container, known) {
|
|
984
|
+
const before = Array.isArray(container.node.children) ? container.node.children : [];
|
|
985
|
+
const restored = materializeChildren(doc, op.children, known, op);
|
|
986
|
+
const added = restored.findIndex((node) => !before.includes(node));
|
|
987
|
+
// A node this restore drops cannot be looked up afterwards, so the undo carries it.
|
|
988
|
+
const dropped = new Set(before.filter((node) => !restored.includes(node)));
|
|
989
|
+
const next = applyToPath(doc, container.path, () => ({ ...container.node, children: restored }));
|
|
990
|
+
const inverse = [
|
|
991
|
+
{
|
|
992
|
+
op: "insert",
|
|
993
|
+
target: op.target,
|
|
994
|
+
index: added < 0 ? before.length : added,
|
|
995
|
+
children: snapshotChildren(before, dropped),
|
|
996
|
+
},
|
|
997
|
+
];
|
|
998
|
+
return { doc: next, inverse };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/* ------------------------------------------------------------------ *
|
|
1002
|
+
* Op normalization
|
|
1003
|
+
* ------------------------------------------------------------------ */
|
|
1004
|
+
|
|
1005
|
+
function requireString(op, key) {
|
|
1006
|
+
if (typeof op[key] !== "string" || op[key] === "") {
|
|
1007
|
+
throw new OpsError("ops/bad-op", `A ${JSON.stringify(op.op)} op needs a non-empty "${key}" string`, {
|
|
1008
|
+
op,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Check one op's shape and return the op with only its known fields, in a stable
|
|
1015
|
+
* order. Every rejection is `ops/bad-op`: the caller sent something the engine
|
|
1016
|
+
* cannot read, which is not the same as naming a node that is not there.
|
|
1017
|
+
*/
|
|
1018
|
+
export function normalizeOp(op) {
|
|
1019
|
+
if (!isObject(op)) {
|
|
1020
|
+
throw new OpsError("ops/bad-op", `Each op must be an object, got ${JSON.stringify(op)}`);
|
|
1021
|
+
}
|
|
1022
|
+
switch (op.op) {
|
|
1023
|
+
case "set": {
|
|
1024
|
+
requireString(op, "target");
|
|
1025
|
+
requireString(op, "prop");
|
|
1026
|
+
if (!hasOwn(op, "value")) {
|
|
1027
|
+
throw new OpsError("ops/bad-op", `A "set" op needs a "value"`, { op });
|
|
1028
|
+
}
|
|
1029
|
+
const out = { op: "set", target: op.target, prop: op.prop, value: op.value };
|
|
1030
|
+
if (Array.isArray(op.children)) out.children = op.children;
|
|
1031
|
+
if (Number.isInteger(op.index) && op.index >= 0) out.index = op.index;
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
case "unset": {
|
|
1035
|
+
requireString(op, "target");
|
|
1036
|
+
requireString(op, "prop");
|
|
1037
|
+
return { op: "unset", target: op.target, prop: op.prop };
|
|
1038
|
+
}
|
|
1039
|
+
case "insert": {
|
|
1040
|
+
requireString(op, "target");
|
|
1041
|
+
if (!Number.isInteger(op.index) || op.index < 0) {
|
|
1042
|
+
throw new OpsError("ops/bad-op", `An "insert" op needs a non-negative integer "index"`, { op });
|
|
1043
|
+
}
|
|
1044
|
+
const hasHtml = typeof op.html === "string";
|
|
1045
|
+
const hasNode = isObject(op.node) || Array.isArray(op.node);
|
|
1046
|
+
const hasChildren = Array.isArray(op.children);
|
|
1047
|
+
if (!hasHtml && !hasNode && !hasChildren) {
|
|
1048
|
+
throw new OpsError("ops/bad-op", `An "insert" op needs an "html" string`, { op });
|
|
1049
|
+
}
|
|
1050
|
+
const out = { op: "insert", target: op.target, index: op.index };
|
|
1051
|
+
if (hasHtml) out.html = op.html;
|
|
1052
|
+
if (hasNode) out.node = op.node;
|
|
1053
|
+
if (hasChildren) out.children = op.children;
|
|
1054
|
+
return out;
|
|
1055
|
+
}
|
|
1056
|
+
case "remove": {
|
|
1057
|
+
requireString(op, "target");
|
|
1058
|
+
const out = { op: "remove", target: op.target };
|
|
1059
|
+
if (Array.isArray(op.children)) out.children = op.children;
|
|
1060
|
+
if (hasOwn(op, "index")) {
|
|
1061
|
+
if (!Number.isInteger(op.index) || op.index < 0) {
|
|
1062
|
+
throw new OpsError("ops/bad-op", `A "remove" op's "index" must be a non-negative integer`, { op });
|
|
1063
|
+
}
|
|
1064
|
+
out.index = op.index;
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
case "move": {
|
|
1069
|
+
requireString(op, "target");
|
|
1070
|
+
const named = ["before", "after"].filter((key) => typeof op[key] === "string" && op[key] !== "");
|
|
1071
|
+
if (named.length > 1) {
|
|
1072
|
+
throw new OpsError("ops/bad-op", `A "move" op takes "before" or "after", not both`, { op });
|
|
1073
|
+
}
|
|
1074
|
+
const hasInto = typeof op.into === "string" && op.into !== "";
|
|
1075
|
+
if (hasInto && named.length > 0) {
|
|
1076
|
+
throw new OpsError("ops/bad-op", `A "move" op takes "into" or a sibling, not both`, { op });
|
|
1077
|
+
}
|
|
1078
|
+
const hasIndex = hasOwn(op, "index");
|
|
1079
|
+
if (hasIndex && (!Number.isInteger(op.index) || op.index < 0)) {
|
|
1080
|
+
throw new OpsError("ops/bad-op", `A "move" op's "index" must be a non-negative integer`, { op });
|
|
1081
|
+
}
|
|
1082
|
+
if (!hasInto && named.length === 0 && !hasIndex) {
|
|
1083
|
+
throw new OpsError(
|
|
1084
|
+
"ops/bad-op",
|
|
1085
|
+
`A "move" op needs one of "before", "after", "index" or "into"`,
|
|
1086
|
+
{ op },
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
if (named.length === 0 && !hasInto && !hasIndex) {
|
|
1090
|
+
throw new OpsError("ops/bad-op", `A "move" op needs a destination`, { op });
|
|
1091
|
+
}
|
|
1092
|
+
const out = { op: "move", target: op.target };
|
|
1093
|
+
if (Array.isArray(op.children)) out.children = op.children;
|
|
1094
|
+
if (named.length === 1) {
|
|
1095
|
+
const key = named[0];
|
|
1096
|
+
out[key] = op[key];
|
|
1097
|
+
} else if (hasInto) {
|
|
1098
|
+
out.into = op.into;
|
|
1099
|
+
if (hasIndex) out.index = op.index;
|
|
1100
|
+
} else {
|
|
1101
|
+
out.index = op.index;
|
|
1102
|
+
}
|
|
1103
|
+
return out;
|
|
1104
|
+
}
|
|
1105
|
+
case "set-token": {
|
|
1106
|
+
requireString(op, "name");
|
|
1107
|
+
requireTokenName(op);
|
|
1108
|
+
if (!hasOwn(op, "value")) {
|
|
1109
|
+
throw new OpsError("ops/bad-op", `A "set-token" op needs a "value"`, { op });
|
|
1110
|
+
}
|
|
1111
|
+
const out = { op: "set-token", name: op.name, value: op.value };
|
|
1112
|
+
// Set only by the inverse of `unset-token`: the bytes that were removed from the
|
|
1113
|
+
// `:root{}` body, and where in that body they were. `restore` without `at` would
|
|
1114
|
+
// be a way to prepend to the block, so `at` is required with it.
|
|
1115
|
+
if (typeof op.restore === "string") {
|
|
1116
|
+
if (!Number.isInteger(op.at) || op.at < 0) {
|
|
1117
|
+
throw new OpsError("ops/bad-op", `A "set-token" with "restore" needs a non-negative integer "at"`, { op });
|
|
1118
|
+
}
|
|
1119
|
+
out.at = op.at;
|
|
1120
|
+
out.restore = op.restore;
|
|
1121
|
+
}
|
|
1122
|
+
return out;
|
|
1123
|
+
}
|
|
1124
|
+
case "unset-token": {
|
|
1125
|
+
requireString(op, "name");
|
|
1126
|
+
requireTokenName(op);
|
|
1127
|
+
const out = { op: "unset-token", name: op.name };
|
|
1128
|
+
// Set only by the inverse of `set-token`: the bytes that were added, and where.
|
|
1129
|
+
// Both or neither, or `at` would be a way to cut an arbitrary range.
|
|
1130
|
+
if (typeof op.span === "string") {
|
|
1131
|
+
if (!Number.isInteger(op.at) || op.at < 0) {
|
|
1132
|
+
throw new OpsError("ops/bad-op", `An "unset-token" with "span" needs a non-negative integer "at"`, { op });
|
|
1133
|
+
}
|
|
1134
|
+
out.at = op.at;
|
|
1135
|
+
out.span = op.span;
|
|
1136
|
+
if (hasOwn(op, "value")) out.value = op.value;
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
}
|
|
1140
|
+
default:
|
|
1141
|
+
throw new OpsError(
|
|
1142
|
+
"ops/bad-op",
|
|
1143
|
+
`Unknown op ${JSON.stringify(op.op)}; expected one of ${OP_KINDS.join(", ")}`,
|
|
1144
|
+
{ op },
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* A token op names a CSS custom property, and nothing else.
|
|
1151
|
+
*
|
|
1152
|
+
* The seven groups are `lib/table.mjs`'s business and this engine deliberately does
|
|
1153
|
+
* not import that table — it has to keep loading even if a table entry is renamed. So
|
|
1154
|
+
* the check here is the one that needs no table: a custom property starts with `--`.
|
|
1155
|
+
* A name that passes here and is still wrong (a group that is not one of the seven)
|
|
1156
|
+
* is a `style/token-name` error, which the write funnel refuses on its own.
|
|
1157
|
+
*/
|
|
1158
|
+
function requireTokenName(op) {
|
|
1159
|
+
if (!String(op.name).startsWith("--")) {
|
|
1160
|
+
throw new OpsError(
|
|
1161
|
+
"ops/bad-op",
|
|
1162
|
+
`A token op's "name" is a CSS custom property, such as --color-accent, got ${JSON.stringify(op.name)}`,
|
|
1163
|
+
{ op },
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/* ------------------------------------------------------------------ *
|
|
1169
|
+
* Applying one op
|
|
1170
|
+
* ------------------------------------------------------------------ */
|
|
1171
|
+
|
|
1172
|
+
function insertNodesFor(op) {
|
|
1173
|
+
if (Array.isArray(op.node)) return op.node.slice();
|
|
1174
|
+
if (isObject(op.node)) return [op.node];
|
|
1175
|
+
return parseFragmentHtml(op.html);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function idsWithin(nodes) {
|
|
1179
|
+
const ids = [];
|
|
1180
|
+
const seen = new Set();
|
|
1181
|
+
const walk = (node) => {
|
|
1182
|
+
for (const entry of collectEntries(node)) {
|
|
1183
|
+
if (!isElement(entry.node)) continue;
|
|
1184
|
+
const id = dataIdOf(entry.node);
|
|
1185
|
+
if (id !== null && !seen.has(id)) {
|
|
1186
|
+
seen.add(id);
|
|
1187
|
+
ids.push(id);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
for (const node of nodes) {
|
|
1192
|
+
if (entryIsElement(node)) {
|
|
1193
|
+
const id = dataIdOf(node);
|
|
1194
|
+
if (id !== null && !seen.has(id)) {
|
|
1195
|
+
seen.add(id);
|
|
1196
|
+
ids.push(id);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
walk(node);
|
|
1200
|
+
}
|
|
1201
|
+
return ids;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function entryIsElement(node) {
|
|
1205
|
+
return isElement(node);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function applySet(doc, op) {
|
|
1209
|
+
const entry = requireEntry(doc, op.target, op);
|
|
1210
|
+
const node = entry.node;
|
|
1211
|
+
const inverse = [];
|
|
1212
|
+
let next;
|
|
1213
|
+
|
|
1214
|
+
if (op.prop === "text") {
|
|
1215
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
1216
|
+
if (Array.isArray(op.children)) {
|
|
1217
|
+
next = { ...node, children: op.children.map((child) => ({ ...child })) };
|
|
1218
|
+
} else {
|
|
1219
|
+
next = {
|
|
1220
|
+
...node,
|
|
1221
|
+
children: [
|
|
1222
|
+
{
|
|
1223
|
+
kind: "text",
|
|
1224
|
+
value: scalarText(op.value, op),
|
|
1225
|
+
raw: scalarText(op.value, op),
|
|
1226
|
+
parent: null,
|
|
1227
|
+
start: -1,
|
|
1228
|
+
end: -1,
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// Undo needs the old children verbatim: an element may hold several text nodes,
|
|
1234
|
+
// or text mixed with elements, and no single string brings that back (A5).
|
|
1235
|
+
inverse.push(
|
|
1236
|
+
children.length === 0
|
|
1237
|
+
? { op: "unset", target: op.target, prop: "text" }
|
|
1238
|
+
: { op: "set", target: op.target, prop: "text", value: textOf(children), children },
|
|
1239
|
+
);
|
|
1240
|
+
return { doc: applyToPath(doc, entry.path, () => next), inverse };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (op.prop.startsWith("style:")) {
|
|
1244
|
+
const property = op.prop.slice("style:".length);
|
|
1245
|
+
if (property === "") {
|
|
1246
|
+
throw new OpsError("ops/bad-op", `A "set" op needs a CSS property after "style:"`, { op });
|
|
1247
|
+
}
|
|
1248
|
+
const attr = attrOf(node, "style");
|
|
1249
|
+
const text = attr ? String(attr.value ?? "") : "";
|
|
1250
|
+
const declaration = findDeclaration(text, property);
|
|
1251
|
+
if (declaration !== null) {
|
|
1252
|
+
next = withAttr(node, "style", replaceDeclarationValue(text, declaration, scalarText(op.value, op)));
|
|
1253
|
+
inverse.push({ op: "set", target: op.target, prop: op.prop, value: declaration.value });
|
|
1254
|
+
} else {
|
|
1255
|
+
next = withAttr(node, "style", appendDeclaration(text, property, scalarText(op.value, op)));
|
|
1256
|
+
// Writing the whole attribute back is the only exact undo: dropping the new
|
|
1257
|
+
// declaration again would also reflow the separators around it (A5).
|
|
1258
|
+
inverse.push(
|
|
1259
|
+
attr ? { op: "set", target: op.target, prop: "style", value: text } : { op: "unset", target: op.target, prop: "style" },
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
return { doc: applyToPath(doc, entry.path, () => next), inverse };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const value = scalarText(op.value, op);
|
|
1266
|
+
if (op.prop.toLowerCase() === "data-id") {
|
|
1267
|
+
if (value === "") {
|
|
1268
|
+
throw new OpsError("ops/bad-op", `A "set" on "data-id" needs a non-empty value`, { op });
|
|
1269
|
+
}
|
|
1270
|
+
const clash = entryOfId(doc, value);
|
|
1271
|
+
if (clash !== null && clash.node !== node) {
|
|
1272
|
+
throw new OpsError("ops/duplicate-id", `data-id ${JSON.stringify(value)} is already taken`, {
|
|
1273
|
+
target: op.target,
|
|
1274
|
+
duplicate: value,
|
|
1275
|
+
op,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
next = withAttr(node, op.prop, value);
|
|
1279
|
+
inverse.push({ op: "set", target: value, prop: op.prop, value: op.target });
|
|
1280
|
+
return { doc: applyToPath(doc, entry.path, () => next), inverse };
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const existing = attrOf(node, op.prop);
|
|
1284
|
+
next = withAttr(node, op.prop, value, op.index);
|
|
1285
|
+
if (existing !== null) {
|
|
1286
|
+
inverse.push({ op: "set", target: op.target, prop: op.prop, value: String(existing.value ?? "") });
|
|
1287
|
+
} else {
|
|
1288
|
+
// Removing the attribute again restores the original list exactly, wherever the
|
|
1289
|
+
// write put it.
|
|
1290
|
+
inverse.push({ op: "unset", target: op.target, prop: op.prop });
|
|
1291
|
+
}
|
|
1292
|
+
return { doc: applyToPath(doc, entry.path, () => next), inverse };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function textOf(children) {
|
|
1296
|
+
return children
|
|
1297
|
+
.filter((child) => isObject(child) && child.kind === "text")
|
|
1298
|
+
.map((child) => String(child.value ?? child.raw ?? ""))
|
|
1299
|
+
.join("");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function applyUnset(doc, op) {
|
|
1303
|
+
const entry = requireEntry(doc, op.target, op);
|
|
1304
|
+
const node = entry.node;
|
|
1305
|
+
const inverse = [];
|
|
1306
|
+
|
|
1307
|
+
if (op.prop === "text") {
|
|
1308
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
1309
|
+
if (children.length === 0) {
|
|
1310
|
+
throw new OpsError("ops/no-target", `${JSON.stringify(op.target)} has no text to unset`, {
|
|
1311
|
+
target: op.target,
|
|
1312
|
+
prop: op.prop,
|
|
1313
|
+
op,
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
inverse.push({ op: "set", target: op.target, prop: "text", value: textOf(children), children });
|
|
1317
|
+
return { doc: applyToPath(doc, entry.path, () => ({ ...node, children: [] })), inverse };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (op.prop.startsWith("style:")) {
|
|
1321
|
+
const property = op.prop.slice("style:".length);
|
|
1322
|
+
const attr = attrOf(node, "style");
|
|
1323
|
+
const text = attr ? String(attr.value ?? "") : "";
|
|
1324
|
+
const declaration = findDeclaration(text, property);
|
|
1325
|
+
if (declaration === null) {
|
|
1326
|
+
throw new OpsError(
|
|
1327
|
+
"ops/no-target",
|
|
1328
|
+
`${JSON.stringify(op.target)} has no "${property}" declaration`,
|
|
1329
|
+
{ target: op.target, prop: op.prop, op },
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
const trimmed = removeDeclaration(text, declaration);
|
|
1333
|
+
const next = trimmed === "" ? withoutAttr(node, "style") : withAttr(node, "style", trimmed);
|
|
1334
|
+
inverse.push({ op: "set", target: op.target, prop: "style", value: text });
|
|
1335
|
+
return { doc: applyToPath(doc, entry.path, () => next), inverse };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const existing = attrOf(node, op.prop);
|
|
1339
|
+
if (existing === null) {
|
|
1340
|
+
throw new OpsError(
|
|
1341
|
+
"ops/no-target",
|
|
1342
|
+
`${JSON.stringify(op.target)} has no ${JSON.stringify(op.prop)} attribute`,
|
|
1343
|
+
{ target: op.target, prop: op.prop, op },
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
const at = attrIndexOf(node, op.prop);
|
|
1347
|
+
inverse.push({ op: "set", target: op.target, prop: op.prop, value: String(existing.value ?? ""), index: at });
|
|
1348
|
+
return { doc: applyToPath(doc, entry.path, () => withoutAttr(node, op.prop)), inverse };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function applyInsert(doc, op) {
|
|
1352
|
+
const container = requireContainer(doc, op.target, op);
|
|
1353
|
+
const parent = container.node;
|
|
1354
|
+
const children = Array.isArray(parent.children) ? parent.children : [];
|
|
1355
|
+
|
|
1356
|
+
// The exact-restore form: the op names the child list the parent must end up with.
|
|
1357
|
+
if (Array.isArray(op.children)) {
|
|
1358
|
+
const known = new Map();
|
|
1359
|
+
for (const node of insertNodesFor(op)) {
|
|
1360
|
+
const id = dataIdOf(node);
|
|
1361
|
+
if (id !== null && !known.has(id)) known.set(id, node);
|
|
1362
|
+
}
|
|
1363
|
+
return restoreChildren(doc, op, container, known);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (op.index > children.length) {
|
|
1367
|
+
throw new OpsError(
|
|
1368
|
+
"ops/bad-index",
|
|
1369
|
+
`Cannot insert at index ${op.index}: ${JSON.stringify(op.target)} has ${children.length} children`,
|
|
1370
|
+
{ target: op.target, index: op.index, op },
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const incoming = insertNodesFor(op).map((node) => ({ ...node }));
|
|
1375
|
+
if (incoming.length === 0) {
|
|
1376
|
+
throw new OpsError("ops/bad-op", `An "insert" op's html has no nodes`, { op });
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const index = buildIdIndex(doc);
|
|
1380
|
+
const claimed = new Set();
|
|
1381
|
+
for (const node of incoming) {
|
|
1382
|
+
for (const id of idsWithin([node])) {
|
|
1383
|
+
if (index.has(id) || claimed.has(id)) {
|
|
1384
|
+
throw new OpsError(
|
|
1385
|
+
"ops/duplicate-id",
|
|
1386
|
+
`Inserting data-id ${JSON.stringify(id)} would duplicate an existing id`,
|
|
1387
|
+
{ target: op.target, duplicate: id, op },
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
claimed.add(id);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const nextChildren = [
|
|
1395
|
+
...children.slice(0, op.index),
|
|
1396
|
+
...incoming,
|
|
1397
|
+
...children.slice(op.index),
|
|
1398
|
+
];
|
|
1399
|
+
const next = { ...parent, children: nextChildren };
|
|
1400
|
+
const inverse = [];
|
|
1401
|
+
if (hasAdjacentText(nextChildren)) {
|
|
1402
|
+
// A text entry of the fragment can fuse with a text neighbour on the way to
|
|
1403
|
+
// disk. Then no index of the written file names where it was, so the undo
|
|
1404
|
+
// restores the whole list instead.
|
|
1405
|
+
inverse.push({ op: "remove", target: op.target, children: snapshotChildren(children) });
|
|
1406
|
+
} else {
|
|
1407
|
+
for (let i = incoming.length - 1; i >= 0; i -= 1) {
|
|
1408
|
+
const id = dataIdOf(incoming[i]);
|
|
1409
|
+
if (id !== null) inverse.push({ op: "remove", target: id });
|
|
1410
|
+
else inverse.push({ op: "remove", target: op.target, index: op.index + i });
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return { doc: applyToPath(doc, container.path, () => next), inverse };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function applyRemove(doc, op) {
|
|
1417
|
+
// The exact-restore form: the op names the child list the parent must end up with,
|
|
1418
|
+
// and the node it names is simply not in it.
|
|
1419
|
+
if (Array.isArray(op.children)) {
|
|
1420
|
+
const container = requireContainer(doc, op.target, op);
|
|
1421
|
+
return restoreChildren(doc, op, container, new Map());
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
let entry;
|
|
1425
|
+
if (hasOwn(op, "index")) {
|
|
1426
|
+
const container = requireContainer(doc, op.target, op);
|
|
1427
|
+
const children = Array.isArray(container.node.children) ? container.node.children : [];
|
|
1428
|
+
if (op.index >= children.length) {
|
|
1429
|
+
throw new OpsError(
|
|
1430
|
+
"ops/bad-index",
|
|
1431
|
+
`Cannot remove index ${op.index}: ${JSON.stringify(op.target)} has ${children.length} children`,
|
|
1432
|
+
{ target: op.target, index: op.index, op },
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
const child = children[op.index];
|
|
1436
|
+
entry = { node: child, parent: container.node, index: op.index, path: container.path.concat([{ key: "children", index: op.index }]) };
|
|
1437
|
+
} else {
|
|
1438
|
+
entry = requireEntry(doc, op.target, op);
|
|
1439
|
+
if (entry.parent === null || entry.index < 0) {
|
|
1440
|
+
throw new OpsError(
|
|
1441
|
+
"ops/bad-op",
|
|
1442
|
+
`Cannot remove ${JSON.stringify(op.target)}: it has no parent to be removed from`,
|
|
1443
|
+
{ target: op.target, op },
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const removed = entry.node;
|
|
1449
|
+
const parentAddress = addressOfEntry(doc, entry.parent);
|
|
1450
|
+
if (parentAddress === null) {
|
|
1451
|
+
throw new OpsError(
|
|
1452
|
+
"ops/bad-op",
|
|
1453
|
+
`Cannot remove ${JSON.stringify(op.target)}: its parent carries no data-id, so the removal cannot be undone`,
|
|
1454
|
+
{ target: op.target, op },
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
const raw = rawBytesOf(doc, removed);
|
|
1458
|
+
const undo = { op: "insert", target: parentAddress, index: entry.index, node: removed };
|
|
1459
|
+
if (raw !== null) undo.html = raw;
|
|
1460
|
+
const parentChildren = Array.isArray(entry.parent.children) ? entry.parent.children : [];
|
|
1461
|
+
if (hasAdjacentText(withoutNode(parentChildren, removed))) {
|
|
1462
|
+
// Taking this node out leaves two text nodes side by side, and the write merges
|
|
1463
|
+
// them: `index` above would name a different boundary on the way back.
|
|
1464
|
+
undo.children = snapshotChildren(parentChildren);
|
|
1465
|
+
}
|
|
1466
|
+
return {
|
|
1467
|
+
doc: applyToPath(doc, entry.path, () => REMOVE),
|
|
1468
|
+
inverse: [undo],
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** The address that reaches `node`: its data-id, or a reserved container word. */
|
|
1473
|
+
function addressOfEntry(doc, node) {
|
|
1474
|
+
if (node === doc) return "document";
|
|
1475
|
+
const id = dataIdOf(node);
|
|
1476
|
+
if (id !== null) return id;
|
|
1477
|
+
if (isElement(node) && RESERVED_CONTAINERS.includes(node.tag)) {
|
|
1478
|
+
const slot = node.tag === "html" ? doc.html : node.tag === "head" ? doc.head : node.tag === "body" ? doc.body : null;
|
|
1479
|
+
if (slot === node) return node.tag;
|
|
1480
|
+
for (const entry of collectEntries(doc)) {
|
|
1481
|
+
if (entry.node === node) return node.tag;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function applyMove(doc, op) {
|
|
1488
|
+
const source = requireEntry(doc, op.target, op);
|
|
1489
|
+
|
|
1490
|
+
// The exact-restore form: the op names the child list the parent must end up with,
|
|
1491
|
+
// and the node it names is already in that list.
|
|
1492
|
+
if (Array.isArray(op.children)) {
|
|
1493
|
+
return restoreMove(doc, op, source);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const sourceParent = source.parent;
|
|
1497
|
+
const sourceIndex = source.index;
|
|
1498
|
+
if (sourceParent === null || sourceIndex < 0) {
|
|
1499
|
+
throw new OpsError(
|
|
1500
|
+
"ops/bad-op",
|
|
1501
|
+
`Cannot move ${JSON.stringify(op.target)}: it is not a child of anything`,
|
|
1502
|
+
{ target: op.target, op },
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
const parentAddress = addressOfEntry(doc, sourceParent);
|
|
1506
|
+
if (parentAddress === null) {
|
|
1507
|
+
throw new OpsError(
|
|
1508
|
+
"ops/bad-op",
|
|
1509
|
+
`Cannot move ${JSON.stringify(op.target)}: its parent carries no data-id, so the move cannot be undone`,
|
|
1510
|
+
{ target: op.target, op },
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const sourcePath = source.path;
|
|
1515
|
+
const insideSource = (path) =>
|
|
1516
|
+
path.length > sourcePath.length &&
|
|
1517
|
+
sourcePath.every((step, i) => step.key === path[i].key && step.index === path[i].index);
|
|
1518
|
+
|
|
1519
|
+
// Where the node goes, expressed in the array *after* the removal, because that is
|
|
1520
|
+
// the array `move` inserts into. `before`/`after` name a sibling in the array as it
|
|
1521
|
+
// looks now, so a sibling that sat behind the source shifts down by one.
|
|
1522
|
+
let destAddress;
|
|
1523
|
+
let destIndex;
|
|
1524
|
+
let sameParent;
|
|
1525
|
+
let limit;
|
|
1526
|
+
|
|
1527
|
+
if (op.into !== undefined) {
|
|
1528
|
+
const dest = resolveAddress(doc, op.into);
|
|
1529
|
+
if (dest === null) {
|
|
1530
|
+
throw new OpsError("ops/node-not-found", `No node with data-id ${JSON.stringify(op.into)}`, {
|
|
1531
|
+
target: op.into,
|
|
1532
|
+
op,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
if (dest.node === source.node || insideSource(dest.path)) {
|
|
1536
|
+
throw new OpsError("ops/bad-op", `Cannot move ${JSON.stringify(op.target)} inside itself`, {
|
|
1537
|
+
target: op.target,
|
|
1538
|
+
op,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
if (dest.node !== doc) assertHoldsChildren(dest.node, op.into, op);
|
|
1542
|
+
sameParent = dest.node === sourceParent;
|
|
1543
|
+
const children = Array.isArray(dest.node.children) ? dest.node.children : [];
|
|
1544
|
+
limit = sameParent ? children.length - 1 : children.length;
|
|
1545
|
+
destIndex = hasOwn(op, "index") ? op.index : limit;
|
|
1546
|
+
destAddress = op.into;
|
|
1547
|
+
} else if (op.before !== undefined || op.after !== undefined) {
|
|
1548
|
+
const siblingAddress = op.before !== undefined ? op.before : op.after;
|
|
1549
|
+
const sibling = requireEntry(doc, siblingAddress, op);
|
|
1550
|
+
if (sibling.parent === null || sibling.index < 0) {
|
|
1551
|
+
throw new OpsError("ops/bad-op", `${JSON.stringify(siblingAddress)} has no parent`, { op });
|
|
1552
|
+
}
|
|
1553
|
+
if (sibling.node === source.node) {
|
|
1554
|
+
throw new OpsError("ops/bad-op", `Cannot move ${JSON.stringify(op.target)} relative to itself`, {
|
|
1555
|
+
target: op.target,
|
|
1556
|
+
op,
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
if (insideSource(sibling.path)) {
|
|
1560
|
+
throw new OpsError("ops/bad-op", `Cannot move ${JSON.stringify(op.target)} inside itself`, {
|
|
1561
|
+
target: op.target,
|
|
1562
|
+
op,
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
sameParent = sibling.parent === sourceParent;
|
|
1566
|
+
destAddress = addressOfEntry(doc, sibling.parent);
|
|
1567
|
+
if (destAddress === null) {
|
|
1568
|
+
throw new OpsError(
|
|
1569
|
+
"ops/bad-op",
|
|
1570
|
+
`Cannot move ${JSON.stringify(op.target)} next to ${JSON.stringify(siblingAddress)}: that parent carries no data-id`,
|
|
1571
|
+
{ target: op.target, op },
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
const siblings = Array.isArray(sibling.parent.children) ? sibling.parent.children : [];
|
|
1575
|
+
limit = sameParent ? siblings.length - 1 : siblings.length;
|
|
1576
|
+
destIndex = op.before !== undefined ? sibling.index : sibling.index + 1;
|
|
1577
|
+
if (sameParent && sourceIndex < destIndex) destIndex -= 1;
|
|
1578
|
+
} else {
|
|
1579
|
+
sameParent = true;
|
|
1580
|
+
destAddress = parentAddress;
|
|
1581
|
+
const siblings = Array.isArray(sourceParent.children) ? sourceParent.children : [];
|
|
1582
|
+
limit = siblings.length - 1;
|
|
1583
|
+
destIndex = op.index;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (destIndex > limit) {
|
|
1587
|
+
throw new OpsError(
|
|
1588
|
+
"ops/bad-index",
|
|
1589
|
+
`Cannot move to index ${destIndex}: ${JSON.stringify(destAddress)} has ${limit} slot${limit === 1 ? "" : "s"} after the removal`,
|
|
1590
|
+
{ target: op.target, index: destIndex, op },
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Remove, then insert: resolving the destination again after the removal is what
|
|
1595
|
+
// keeps the second half honest when the source sat in the same array.
|
|
1596
|
+
const stripped = applyToPath(doc, sourcePath, () => REMOVE);
|
|
1597
|
+
const container = resolveAddress(stripped, destAddress);
|
|
1598
|
+
if (container === null) {
|
|
1599
|
+
throw new OpsError(
|
|
1600
|
+
"ops/node-not-found",
|
|
1601
|
+
`Cannot re-reach ${JSON.stringify(destAddress)} after removing ${JSON.stringify(op.target)}`,
|
|
1602
|
+
{ target: op.target, op },
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
const children = Array.isArray(container.node.children) ? container.node.children : [];
|
|
1606
|
+
if (destIndex > children.length) {
|
|
1607
|
+
throw new OpsError(
|
|
1608
|
+
"ops/bad-index",
|
|
1609
|
+
`Cannot move to index ${destIndex}: ${JSON.stringify(destAddress)} has ${children.length} children left`,
|
|
1610
|
+
{ target: op.target, index: destIndex, op },
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
const nextChildren = [...children.slice(0, destIndex), source.node, ...children.slice(destIndex)];
|
|
1614
|
+
const rebuilt = applyToPath(stripped, container.path, () => ({ ...container.node, children: nextChildren }));
|
|
1615
|
+
|
|
1616
|
+
const inverse = [{ op: "move", target: op.target, into: parentAddress, index: sourceIndex }];
|
|
1617
|
+
if (hasAdjacentText(withoutNode(sourceParent.children, source.node))) {
|
|
1618
|
+
// Lifting this node out leaves two text nodes side by side, and the write merges
|
|
1619
|
+
// them: the index above would name a different boundary on the way back.
|
|
1620
|
+
inverse[0].children = snapshotChildren(sourceParent.children);
|
|
1621
|
+
}
|
|
1622
|
+
return { doc: rebuilt, inverse };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* Put a node back into a parent whose child list the op names outright.
|
|
1627
|
+
*
|
|
1628
|
+
* The list is authoritative: it says where the node goes, text and all. That is the
|
|
1629
|
+
* only way back once a write has merged the two text nodes the node used to sit
|
|
1630
|
+
* between — the information is not in the file any more, so it has to travel in the
|
|
1631
|
+
* op.
|
|
1632
|
+
*/
|
|
1633
|
+
function restoreMove(doc, op, source) {
|
|
1634
|
+
const parentAddress = typeof op.into === "string" && op.into !== ""
|
|
1635
|
+
? op.into
|
|
1636
|
+
: addressOfEntry(doc, source.parent);
|
|
1637
|
+
if (parentAddress === null) {
|
|
1638
|
+
throw new OpsError(
|
|
1639
|
+
"ops/bad-op",
|
|
1640
|
+
`Cannot restore ${JSON.stringify(op.target)}: its parent carries no data-id`,
|
|
1641
|
+
{ target: op.target, op },
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
requireContainer(doc, parentAddress, op);
|
|
1645
|
+
|
|
1646
|
+
// Undoing this op means putting the node back where it is now, so record that
|
|
1647
|
+
// place — and its child list *with* the node — before anything moves.
|
|
1648
|
+
const sourceAddress = addressOfEntry(doc, source.parent);
|
|
1649
|
+
if (sourceAddress === null) {
|
|
1650
|
+
throw new OpsError(
|
|
1651
|
+
"ops/bad-op",
|
|
1652
|
+
`Cannot restore ${JSON.stringify(op.target)}: its parent carries no data-id, so the restore cannot be undone`,
|
|
1653
|
+
{ target: op.target, op },
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
const sourceChildren = Array.isArray(source.parent.children) ? source.parent.children.slice() : [];
|
|
1657
|
+
|
|
1658
|
+
const known = new Map([[op.target, source.node]]);
|
|
1659
|
+
const stripped = applyToPath(doc, source.path, () => REMOVE);
|
|
1660
|
+
const container = resolveAddress(stripped, parentAddress);
|
|
1661
|
+
if (container === null) {
|
|
1662
|
+
throw new OpsError(
|
|
1663
|
+
"ops/node-not-found",
|
|
1664
|
+
`Cannot re-reach ${JSON.stringify(parentAddress)} after removing ${JSON.stringify(op.target)}`,
|
|
1665
|
+
{ target: op.target, op },
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
const restored = materializeChildren(stripped, op.children, known, op);
|
|
1669
|
+
const next = applyToPath(stripped, container.path, () => ({ ...container.node, children: restored }));
|
|
1670
|
+
return {
|
|
1671
|
+
doc: next,
|
|
1672
|
+
inverse: [
|
|
1673
|
+
{
|
|
1674
|
+
op: "move",
|
|
1675
|
+
target: op.target,
|
|
1676
|
+
into: sourceAddress,
|
|
1677
|
+
index: source.index,
|
|
1678
|
+
children: snapshotChildren(sourceChildren),
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/* ------------------------------------------------------------------ *
|
|
1685
|
+
* Tokens (B4)
|
|
1686
|
+
* ------------------------------------------------------------------ */
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* The one token block, and the one `:root{}` rule a token write goes into.
|
|
1690
|
+
*
|
|
1691
|
+
* A token is not an element, so it cannot be addressed by `data-id` and there is
|
|
1692
|
+
* nothing for `resolveAddress` to resolve. What it has instead is a position inside a
|
|
1693
|
+
* `<style>` in `<head>`, and that is all this needs.
|
|
1694
|
+
*
|
|
1695
|
+
* Exactly one candidate, deliberately. `format-v3.md` §4.1 says both style blocks are
|
|
1696
|
+
* collected and that the later one must not overwrite the earlier, and `plan.md` §4.2
|
|
1697
|
+
* says a design has one token block; when a file has two, "which one does `tokens set`
|
|
1698
|
+
* write to" has no answer this engine is entitled to invent. Refusing is the honest
|
|
1699
|
+
* result, and the validator is already reporting the file.
|
|
1700
|
+
*
|
|
1701
|
+
* `inHead` is part of the test because §4.1 defines a token block that way. A
|
|
1702
|
+
* `<style>` in `<body>` is a `style/style-in-body` error, and writing a brand color
|
|
1703
|
+
* into a block the contract does not read would be a silent loss.
|
|
1704
|
+
*/
|
|
1705
|
+
function tokenBlockOf(doc, op) {
|
|
1706
|
+
const blocks = (doc && Array.isArray(doc.styleBlocks) ? doc.styleBlocks : []).filter(
|
|
1707
|
+
(block) => block.inHead === true && (block.role === "tokens" || block.role === "tokens+page-frame"),
|
|
1708
|
+
);
|
|
1709
|
+
if (blocks.length === 0) {
|
|
1710
|
+
throw new OpsError("ops/no-token-block", "This file has no token block to write to.", {
|
|
1711
|
+
hint: 'Add <style>:root{--color-ink:#111827}</style> in <head>.',
|
|
1712
|
+
op,
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
const spans = blocks.length === 1 ? blocks[0].rootSpans || [] : [];
|
|
1716
|
+
if (blocks.length > 1 || spans.length !== 1) {
|
|
1717
|
+
throw new OpsError(
|
|
1718
|
+
"ops/ambiguous-token-block",
|
|
1719
|
+
blocks.length > 1
|
|
1720
|
+
? `The file has ${blocks.length} token blocks, so a token write would have to choose one`
|
|
1721
|
+
: "This token block has no single :root{...} rule to write into",
|
|
1722
|
+
{ hint: "A design has one token block holding one :root{} rule.", op },
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
return { block: blocks[0], span: spans[0] };
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* Drop a declaration, and the one separator that belongs to it.
|
|
1730
|
+
*
|
|
1731
|
+
* Not `removeDeclaration` above: that one trims its result, which is right for a
|
|
1732
|
+
* `style` attribute — the whole value is about to be written anyway — and wrong for a
|
|
1733
|
+
* token block, whose text *is* the file's bytes. Trimming there would make
|
|
1734
|
+
* `unset-token` eat the whitespace around the token it removed, so restoring the token
|
|
1735
|
+
* could not put the block back.
|
|
1736
|
+
*
|
|
1737
|
+
* The removed bytes and where they were go back to the caller, because they are the
|
|
1738
|
+
* only exact undo. A `:root{}` body is hand-wrapped as often as not — `;\n ` between
|
|
1739
|
+
* two tokens is as legal as `; ` — and an undo that re-typesets the seam it took out
|
|
1740
|
+
* turns a byte-faithful file into an almost-faithful one. The offset is the start of
|
|
1741
|
+
* the deletion, which is the same number before and after the edit, so it is valid in
|
|
1742
|
+
* the document the inverse will be applied to.
|
|
1743
|
+
*/
|
|
1744
|
+
function dropDeclaration(text, declaration) {
|
|
1745
|
+
const after = text.indexOf(";", declaration.end);
|
|
1746
|
+
if (after !== -1) {
|
|
1747
|
+
let end = after + 1;
|
|
1748
|
+
while (end < text.length && /\s/.test(text[end])) end += 1;
|
|
1749
|
+
return {
|
|
1750
|
+
text: `${text.slice(0, declaration.start)}${text.slice(end)}`,
|
|
1751
|
+
at: declaration.start,
|
|
1752
|
+
restore: text.slice(declaration.start, end),
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
const before = text.lastIndexOf(";", declaration.start - 1);
|
|
1756
|
+
if (before !== -1) {
|
|
1757
|
+
return {
|
|
1758
|
+
text: `${text.slice(0, before)}${text.slice(declaration.end)}`,
|
|
1759
|
+
at: before,
|
|
1760
|
+
restore: text.slice(before, declaration.end),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
return {
|
|
1764
|
+
text: `${text.slice(0, declaration.start)}${text.slice(declaration.end)}`,
|
|
1765
|
+
at: declaration.start,
|
|
1766
|
+
restore: text.slice(declaration.start, declaration.end),
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Append a declaration to a `:root{}` body, and say exactly what was appended.
|
|
1772
|
+
*
|
|
1773
|
+
* The separator is `; ` and no `;` is left hanging before the closing brace, which is
|
|
1774
|
+
* the shape `design new` writes and `references/schema.md` documents. When the body
|
|
1775
|
+
* already ends with a `;`, that one is the separator: `--b:2;` plus `--c:3` is
|
|
1776
|
+
* `--b:2; --c:3`, not `--b:2;; --c:3`.
|
|
1777
|
+
*
|
|
1778
|
+
* `at` and `span` are the added bytes and where they went. An append that shares a
|
|
1779
|
+
* separator with the declaration before it — which is what the trailing-`;` case does —
|
|
1780
|
+
* cannot be undone by "remove the declaration and one separator": one of the two
|
|
1781
|
+
* directions ends up owning the `;` and the other does not. Handing the exact bytes
|
|
1782
|
+
* back is what makes the undo byte for byte, and it doubles as a check: an inverse
|
|
1783
|
+
* applied to a document it does not belong to no longer finds its own bytes there.
|
|
1784
|
+
*/
|
|
1785
|
+
function insertDeclarationAt(text, name, value) {
|
|
1786
|
+
const declarations = parseStyleDeclarations(text);
|
|
1787
|
+
const declaration = `${name}:${value}`;
|
|
1788
|
+
if (declarations.length === 0) return { text: declaration, at: 0, span: declaration };
|
|
1789
|
+
const at = declarations[declarations.length - 1].end;
|
|
1790
|
+
const span = `; ${declaration}`;
|
|
1791
|
+
return { text: `${text.slice(0, at)}${span}${text.slice(at)}`, at, span };
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* The `:root{}` body of a token block, split into what is written and what is padding.
|
|
1796
|
+
*
|
|
1797
|
+
* The padding is the whitespace before the closing brace, and it is kept out of the
|
|
1798
|
+
* edit because `appendDeclaration` and `dropDeclaration` both work on the text they
|
|
1799
|
+
* are given: handed ` --a:1 ` they would move the space that belongs to `}`. Keeping
|
|
1800
|
+
* it aside is what lets `:root{ --a:1; --b:2 }` round trip byte for byte.
|
|
1801
|
+
*/
|
|
1802
|
+
function splitTokenBody(body) {
|
|
1803
|
+
const padding = /(\s*)$/.exec(body)[1];
|
|
1804
|
+
return { text: body.slice(0, body.length - padding.length), padding };
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Write a new `:root{}` body back into the document.
|
|
1809
|
+
*
|
|
1810
|
+
* The `<style>` element holds plain text, so the edit is a copy of the spine from the
|
|
1811
|
+
* document down to that text node — the same copy-on-write every other op does, which
|
|
1812
|
+
* is why the input document is still untouched afterwards.
|
|
1813
|
+
*
|
|
1814
|
+
* `block.text`, `block.declarations` and `block.rootSpans` are refreshed with it. A
|
|
1815
|
+
* version that rewrote only the text node left the block describing the file as it
|
|
1816
|
+
* used to be, and the funnel validates the document this function returns: a
|
|
1817
|
+
* `set-token` that introduced a cycle would have been checked against the tokens from
|
|
1818
|
+
* before the op, and passed.
|
|
1819
|
+
*/
|
|
1820
|
+
function withTokenBody(doc, block, body) {
|
|
1821
|
+
const span = block.rootSpans[0];
|
|
1822
|
+
const text = `${block.text.slice(0, span.bodyStart)}${body}${block.text.slice(span.bodyEnd)}`;
|
|
1823
|
+
const entry = collectEntries(doc).find((item) => item.node === block.node);
|
|
1824
|
+
if (entry === undefined) {
|
|
1825
|
+
throw new OpsError("ops/no-token-block", "The token block is not in the document tree", {});
|
|
1826
|
+
}
|
|
1827
|
+
let rebuilt = null;
|
|
1828
|
+
const next = applyToPath(doc, entry.path, (node) => {
|
|
1829
|
+
rebuilt = {
|
|
1830
|
+
...node,
|
|
1831
|
+
children: (node.children || []).map((child) => (child.kind === "text" ? { ...child, value: text } : child)),
|
|
1832
|
+
};
|
|
1833
|
+
return rebuilt;
|
|
1834
|
+
});
|
|
1835
|
+
const styleBlocks = (next.styleBlocks || []).map((candidate) => (
|
|
1836
|
+
candidate === block || candidate.node === block.node
|
|
1837
|
+
? {
|
|
1838
|
+
...candidate,
|
|
1839
|
+
// The block record has to point at the `<style>` element that is in the
|
|
1840
|
+
// tree *now*. The parser establishes that (`styleBlocks[i].node` is the very
|
|
1841
|
+
// element in `<head>`) and `detach` relies on it to keep one object where
|
|
1842
|
+
// there is one element. Leaving the old node here meant the second token op
|
|
1843
|
+
// in a row could not find its own block, and threw.
|
|
1844
|
+
node: rebuilt,
|
|
1845
|
+
text,
|
|
1846
|
+
declarations: parseRootDeclarations(text),
|
|
1847
|
+
rootSpans: rootSpans(text),
|
|
1848
|
+
}
|
|
1849
|
+
: candidate
|
|
1850
|
+
));
|
|
1851
|
+
return { ...next, styleBlocks };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function applySetToken(doc, op) {
|
|
1855
|
+
const { block, span } = tokenBlockOf(doc, op);
|
|
1856
|
+
const body = block.text.slice(span.bodyStart, span.bodyEnd);
|
|
1857
|
+
|
|
1858
|
+
// The inverse of a removal. `unset-token` hands back the exact bytes it took out and
|
|
1859
|
+
// where they were, and this puts them back — the seam included. Every other op that
|
|
1860
|
+
// cannot be rebuilt from its own description does the same thing: `insert` carries
|
|
1861
|
+
// its fragment, `remove` carries the child list as it was.
|
|
1862
|
+
if (typeof op.restore === "string") {
|
|
1863
|
+
if (!Number.isInteger(op.at) || op.at < 0 || op.at > body.length) {
|
|
1864
|
+
// Not clamped. Putting the bytes somewhere else than where they came from is a
|
|
1865
|
+
// silent wrong answer, and this format does not allow those.
|
|
1866
|
+
throw new OpsError("ops/stale-inverse", `The token block is shorter than offset ${op.at}`, { op });
|
|
1867
|
+
}
|
|
1868
|
+
const spliced = `${body.slice(0, op.at)}${op.restore}${body.slice(op.at)}`;
|
|
1869
|
+
return { doc: withTokenBody(doc, block, spliced), inverse: [{ op: "unset-token", name: op.name }] };
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const { text: core, padding } = splitTokenBody(body);
|
|
1873
|
+
const declaration = findDeclaration(core, op.name);
|
|
1874
|
+
const value = String(op.value);
|
|
1875
|
+
if (declaration !== null) {
|
|
1876
|
+
const next = withTokenBody(doc, block, `${replaceDeclarationValue(core, declaration, value)}${padding}`);
|
|
1877
|
+
// One declaration, one span replaced: the inverse is the same op with the old
|
|
1878
|
+
// value, and it puts back byte for byte.
|
|
1879
|
+
return { doc: next, inverse: [{ op: "set-token", name: op.name, value: declaration.value }] };
|
|
1880
|
+
}
|
|
1881
|
+
const added = insertDeclarationAt(core, op.name, value);
|
|
1882
|
+
const next = withTokenBody(doc, block, `${added.text}${padding}`);
|
|
1883
|
+
return { doc: next, inverse: [{ op: "unset-token", name: op.name, at: added.at, span: added.span }] };
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function applyUnsetToken(doc, op) {
|
|
1887
|
+
const { block, span } = tokenBlockOf(doc, op);
|
|
1888
|
+
const body = block.text.slice(span.bodyStart, span.bodyEnd);
|
|
1889
|
+
|
|
1890
|
+
// The inverse of an addition: cut the bytes that were added, and nothing else. The
|
|
1891
|
+
// `span` is checked before it is cut, so an inverse applied to a document it does not
|
|
1892
|
+
// belong to refuses instead of deleting whatever happens to sit at that offset.
|
|
1893
|
+
if (typeof op.span === "string") {
|
|
1894
|
+
const at = Number.isInteger(op.at) ? op.at : -1;
|
|
1895
|
+
if (at < 0 || at + op.span.length > body.length || body.slice(at, at + op.span.length) !== op.span) {
|
|
1896
|
+
throw new OpsError("ops/stale-inverse", `The bytes this op removes are not at offset ${at} any more`, { op });
|
|
1897
|
+
}
|
|
1898
|
+
const cut = `${body.slice(0, at)}${body.slice(at + op.span.length)}`;
|
|
1899
|
+
return { doc: withTokenBody(doc, block, cut), inverse: [{ op: "set-token", name: op.name, value: op.value ?? "" }] };
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
const { text: core, padding } = splitTokenBody(body);
|
|
1903
|
+
const declaration = findDeclaration(core, op.name);
|
|
1904
|
+
if (declaration === null) {
|
|
1905
|
+
const known = parseStyleDeclarations(core).map((entry) => entry.property).join(", ");
|
|
1906
|
+
throw new OpsError("ops/no-such-token", `There is no token ${op.name} to remove`, {
|
|
1907
|
+
hint: known === "" ? "This token block is empty." : `Declared: ${known}.`,
|
|
1908
|
+
op,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
const dropped = dropDeclaration(core, declaration);
|
|
1912
|
+
const next = withTokenBody(doc, block, `${dropped.text}${padding}`);
|
|
1913
|
+
return {
|
|
1914
|
+
doc: next,
|
|
1915
|
+
inverse: [{ op: "set-token", name: op.name, value: declaration.value, at: dropped.at, restore: dropped.restore }],
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function applyOne(doc, op) {
|
|
1920
|
+
switch (op.op) {
|
|
1921
|
+
case "set":
|
|
1922
|
+
return applySet(doc, op);
|
|
1923
|
+
case "unset":
|
|
1924
|
+
return applyUnset(doc, op);
|
|
1925
|
+
case "insert":
|
|
1926
|
+
return applyInsert(doc, op);
|
|
1927
|
+
case "remove":
|
|
1928
|
+
return applyRemove(doc, op);
|
|
1929
|
+
case "move":
|
|
1930
|
+
return applyMove(doc, op);
|
|
1931
|
+
case "set-token":
|
|
1932
|
+
return applySetToken(doc, op);
|
|
1933
|
+
case "unset-token":
|
|
1934
|
+
return applyUnsetToken(doc, op);
|
|
1935
|
+
default:
|
|
1936
|
+
throw new OpsError("ops/bad-op", `Unknown op ${JSON.stringify(op.op)}`, { op });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/* ------------------------------------------------------------------ *
|
|
1941
|
+
* Public API
|
|
1942
|
+
* ------------------------------------------------------------------ */
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Apply ops to a document and return the ops that undo them.
|
|
1946
|
+
*
|
|
1947
|
+
* `doc` is untouched; the returned document shares no object with it (A7). The
|
|
1948
|
+
* inverse list is flattened in reverse order, so
|
|
1949
|
+
* `applyOps(applyOps(doc, ops).doc, inverse).doc` is the original document again.
|
|
1950
|
+
*
|
|
1951
|
+
* Every op runs against the tree the previous op produced: an `insert` followed by a
|
|
1952
|
+
* `set` on the inserted id works, and an id renamed by one op is the id the next op
|
|
1953
|
+
* must use.
|
|
1954
|
+
*
|
|
1955
|
+
* @returns {{doc: object, inverse: object[], applied: object[]}}
|
|
1956
|
+
*/
|
|
1957
|
+
export function applyOps(doc, ops) {
|
|
1958
|
+
if (!isDocumentLike(doc)) {
|
|
1959
|
+
throw new OpsError(
|
|
1960
|
+
"ops/no-doc",
|
|
1961
|
+
`Expected a document, got ${doc === null ? "null" : typeof doc}`,
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
if (!Array.isArray(ops)) {
|
|
1965
|
+
throw new OpsError("ops/bad-op", `Ops must be an array, got ${typeof ops}`);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
let current = doc;
|
|
1969
|
+
const applied = [];
|
|
1970
|
+
const inverses = [];
|
|
1971
|
+
|
|
1972
|
+
for (let i = 0; i < ops.length; i += 1) {
|
|
1973
|
+
const op = normalizeOp(ops[i]);
|
|
1974
|
+
let result;
|
|
1975
|
+
try {
|
|
1976
|
+
result = applyOne(current, op);
|
|
1977
|
+
} catch (err) {
|
|
1978
|
+
if (err instanceof OpsError) {
|
|
1979
|
+
err.opIndex = i;
|
|
1980
|
+
err.op = op;
|
|
1981
|
+
}
|
|
1982
|
+
throw err;
|
|
1983
|
+
}
|
|
1984
|
+
current = result.doc;
|
|
1985
|
+
inverses.push(result.inverse);
|
|
1986
|
+
applied.push(op);
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const flat = [];
|
|
1990
|
+
for (let i = inverses.length - 1; i >= 0; i -= 1) flat.push(...inverses[i]);
|
|
1991
|
+
return { doc: detach(current, undefined, new WeakMap()), inverse: flat, applied };
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/* ------------------------------------------------------------------ *
|
|
1995
|
+
* Helpers the canvas and the fragment compiler use
|
|
1996
|
+
* ------------------------------------------------------------------ */
|
|
1997
|
+
|
|
1998
|
+
function childCount(doc, address) {
|
|
1999
|
+
const entry = resolveAddress(doc, address);
|
|
2000
|
+
if (entry === null) return 0;
|
|
2001
|
+
return Array.isArray(entry.node.children) ? entry.node.children.length : 0;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function inferParentId(doc, target) {
|
|
2005
|
+
const index = buildIdIndex(doc);
|
|
2006
|
+
const marker = target.indexOf("__");
|
|
2007
|
+
if (marker > 0) {
|
|
2008
|
+
const prefix = target.slice(0, marker);
|
|
2009
|
+
if (index.has(prefix)) return prefix;
|
|
2010
|
+
}
|
|
2011
|
+
let lastPage = null;
|
|
2012
|
+
for (const entry of collectEntries(doc)) {
|
|
2013
|
+
if (isElement(entry.node) && dataTypeOf(entry.node) === "page") {
|
|
2014
|
+
const id = dataIdOf(entry.node);
|
|
2015
|
+
if (id !== null) lastPage = id;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
return lastPage;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* The smallest op list that writes `value` at `target`'s `prop`.
|
|
2023
|
+
*
|
|
2024
|
+
* With the node present that is one `set`: `set` creates the attribute or the
|
|
2025
|
+
* declaration when it is not there yet, so there is no "parent path" to build.
|
|
2026
|
+
*
|
|
2027
|
+
* With the node absent there is nothing to set, and the smallest list that still
|
|
2028
|
+
* works is the node itself plus the write: an `insert` of a minimal element carrying
|
|
2029
|
+
* that `data-id` and a `data-type`, then the `set`. A `text` write makes a `<p>`,
|
|
2030
|
+
* anything else a `<div>`. The parent is the id before `__` when it exists
|
|
2031
|
+
* (`home__cta` → `home`), else the last page. With no page anywhere the target
|
|
2032
|
+
* cannot be placed and this throws `ops/no-target` (A8).
|
|
2033
|
+
*
|
|
2034
|
+
* @returns {object[]} ops, ready for `applyOps`
|
|
2035
|
+
*/
|
|
2036
|
+
export function setOpFor(doc, target, prop, value) {
|
|
2037
|
+
if (typeof target !== "string" || target === "") {
|
|
2038
|
+
throw new OpsError("ops/bad-op", `A target must be a non-empty data-id string`);
|
|
2039
|
+
}
|
|
2040
|
+
if (typeof prop !== "string" || prop === "") {
|
|
2041
|
+
throw new OpsError("ops/bad-op", `A prop must be a non-empty string`);
|
|
2042
|
+
}
|
|
2043
|
+
const index = buildIdIndex(doc);
|
|
2044
|
+
if (index.has(target)) return [{ op: "set", target, prop, value }];
|
|
2045
|
+
|
|
2046
|
+
const parent = inferParentId(doc, target);
|
|
2047
|
+
if (parent === null) {
|
|
2048
|
+
throw new OpsError(
|
|
2049
|
+
"ops/no-target",
|
|
2050
|
+
`No node with data-id ${JSON.stringify(target)}, and no page to create it in`,
|
|
2051
|
+
{ target },
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
const id = escapeAttrValue(target);
|
|
2055
|
+
const html =
|
|
2056
|
+
prop === "text"
|
|
2057
|
+
? `<p data-id="${id}" data-type="text"></p>`
|
|
2058
|
+
: `<div data-id="${id}" data-type="frame"></div>`;
|
|
2059
|
+
return [
|
|
2060
|
+
{ op: "insert", target: parent, index: childCount(doc, parent), html },
|
|
2061
|
+
{ op: "set", target, prop, value },
|
|
2062
|
+
];
|
|
2063
|
+
}
|