lib0 1.0.0-rc.32 → 1.0.0-rc.33

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.
@@ -10,10 +10,10 @@
10
10
  * schema) is coalesced into pass-through runs, so a `$deltaAny` schema is a true zero-overhead identity
11
11
  * and an incremental edit costs O(change), not O(document).
12
12
  *
13
- * `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) is a lightweight reverse: it
14
- * validates each B-side op against `$schema` — throwing on any op that would break conformance — and
15
- * otherwise passes the change through to A verbatim (a conformant B-edit is already valid on A). Full
16
- * drop-aware B→A position remapping is deferred (see {@link ConformTransformer#applyB}).
13
+ * `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) maps a change authored on the
14
+ * conformed view back onto A through the same layout: B positions are remapped across the content A has
15
+ * but B does not (see {@link ConformTransformer#applyB} for the rules), and the change is first validated
16
+ * against `$schema` — any op that would break conformance throws, before any state is touched.
17
17
  * Marks are best-effort — marks on kept children/attrs ride through the position map; marks anchored to
18
18
  * dropped content are dropped.
19
19
  *
@@ -33,11 +33,12 @@ export class Conform<SchemaConf extends delta.DeltaConf, IN extends delta.DeltaC
33
33
  /**
34
34
  * Stateful, recursive transformer produced by {@link Conform}. See {@link Conform} for semantics.
35
35
  *
36
- * The A→B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
36
+ * The A↔B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
37
37
  * coalesced positional map (like `children`'s `childTs`): a `retain(n)` run = pass-through positions, an
38
38
  * `insert([t])` = a child routed through nested conform `t`, a `delete(n)` run = dropped positions
39
- * (width 0 on B). It is rebuilt only on a structural change, carrying the nested transformers by
40
- * reference so their state survives; a change with only retains/modifies is routed in place.
39
+ * (width 0 on B). Both directions walk it with a {@link Cursor} and edit it in place, carrying the nested
40
+ * transformers by reference so their state survives; a change with only retains/modifies is routed
41
+ * without touching the map.
41
42
  *
42
43
  * @extends {Transformer<any,any>}
43
44
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lib0",
3
- "version": "1.0.0-rc.32",
3
+ "version": "1.0.0-rc.33",
4
4
  "description": "isomorphic utility functions",
5
5
  "sideEffects": false,
6
6
  "type": "module",
package/src/decoding.js CHANGED
@@ -103,6 +103,9 @@ export const clone = (decoder, newPos = decoder.pos) => {
103
103
  * @return {Uint8Array<Buf>}
104
104
  */
105
105
  export const readUint8Array = (decoder, len) => {
106
+ if (len < 0 || len > decoder.arr.length - decoder.pos) {
107
+ throw errorUnexpectedEndOfArray
108
+ }
106
109
  const view = new Uint8Array(decoder.arr.buffer, decoder.pos + decoder.arr.byteOffset, len)
107
110
  decoder.pos += len
108
111
  return view
@@ -4,6 +4,17 @@ import * as math from '../../math.js'
4
4
  import * as error from '../../error.js'
5
5
  import { Transformer, Template, createTransformResult } from './core.js'
6
6
 
7
+ /**
8
+ * # `conform` — proof of concept
9
+ *
10
+ * **Warning:** the API of this transformer is fully prototyped, but the implementation is a proof of
11
+ * concept with a real performance impact: every change, in both directions, walks the layout (`cmap`, a
12
+ * linked list) from its start to the edit position, so the per-edit cost grows linearly with the number
13
+ * of layout runs before the edit (~200µs per keystroke at ~6000 runs). A more performant implementation
14
+ * is planned once Yjs v14 matures; until then treat this one as the reference for the semantics, not as
15
+ * the final design.
16
+ */
17
+
7
18
  /**
8
19
  * The literal node-names a `$Delta`'s `$name` schema accepts, or `null` when the name is loose
9
20
  * (`$any`/`$string`) — such a schema acts as a wildcard that matches any node name.
@@ -121,17 +132,278 @@ const needsTransform = (node, allowed) => {
121
132
  return allowed.$scalar.check(node) ? true : null
122
133
  }
123
134
 
135
+ /**
136
+ * Whether `node` — new B-side content (an inserted child or a `setAttr` value) — conforms at a position
137
+ * matched by `allowed`: it validates against the schema AND, for a delta node, is routable by
138
+ * {@link needsTransform} (matched by name, by the wildcard, or passed by `$deltaAny`/`$any`). The second
139
+ * half matters for anonymous nodes: `$Delta.check` skips the name test when `name == null`, yet a
140
+ * re-render (`applyA`) would DROP such a node from a name-matched schema — forwarding it to A would
141
+ * desync the sides. Pure (allocates nothing), so the validation pre-pass can call it freely.
142
+ *
143
+ * @param {any} node
144
+ * @param {Allowed} allowed
145
+ * @return {boolean}
146
+ */
147
+ const conforms = (node, allowed) => allowed.$scalar.check(node) && (!delta.$deltaAny.check(node) || (node.name != null && allowed.byName.has(node.name)) || allowed.wild != null || allowed.passAny)
148
+
124
149
  /**
125
150
  * Whether `allowed` admits at least one delta value at this position — a `$deltaAny`/`$any` member, a
126
151
  * wildcard `$Delta`, or any name-matched `$Delta`. A `modify` / `modifyAttr` op edits a delta child or
127
152
  * delta-valued attribute, so it can only conform where this holds; against a scalar/text-only matcher it
128
- * is necessarily invalid. Used by {@link ConformTransformer#applyB} to reject such modifies.
153
+ * is necessarily invalid. Used by {@link validateB} to reject such modifies.
129
154
  *
130
155
  * @param {Allowed} allowed
131
156
  * @return {boolean}
132
157
  */
133
158
  const admitsDelta = allowed => allowed.passAny || allowed.wild != null || allowed.byName.size > 0
134
159
 
160
+ /**
161
+ * The nested conform a `modifyAttr` of attribute `key` routes through, or `null` when there is nothing
162
+ * nested to route through: a `$deltaAny`/`$any` attr (`passAny` — the change is forwarded verbatim) or a
163
+ * scalar attr (nothing to modify). When no `setAttr` was seen first, the instance is built lazily from the
164
+ * wildcard or the first by-name sub-schema and cached; it starts with an empty layout, i.e. the attr's
165
+ * current content is unseen and passes through (see {@link Cursor}).
166
+ *
167
+ * @param {ConformTransformer} self
168
+ * @param {string|number} key
169
+ * @param {Allowed} allowed
170
+ * @return {ConformTransformer?}
171
+ */
172
+ const attrTransformerOf = (self, key, allowed) => {
173
+ let t = self.transformAttrs.get(key)
174
+ if (t === undefined) {
175
+ const $m = allowed.passAny ? null : (allowed.wild ?? (allowed.byName.size > 0 ? /** @type {delta.$Delta<any>} */ (allowed.byName.values().next().value) : null))
176
+ if ($m === null) return null
177
+ t = nest($m)
178
+ self.transformAttrs.set(key, t)
179
+ }
180
+ return t
181
+ }
182
+
183
+ /**
184
+ * A forward cursor over a conform's layout (its {@link ConformTransformer#cmap}) — the list node `src`
185
+ * and the offset `off` inside it — with the in-place splice helpers `applyA` / `applyB` share, so both
186
+ * directions edit the layout with the same code. One cursor is created per change and walked once, in
187
+ * step with the change's ops. Invariant: `src === null || 0 <= off < src.length`, so a split at the
188
+ * cursor ({@link splitCursor}) always has a legal offset.
189
+ *
190
+ * `src === null` is the end of the layout. Positions past it were never seen by the conform (it was not
191
+ * fed the initial render, or it is a lazily built nested attr conform) and pass through in both
192
+ * directions; walking over them appends a pass-through run so the layout stays aligned with A.
193
+ */
194
+ class Cursor {
195
+ /**
196
+ * @param {delta.DeltaBuilderAny} cmap
197
+ */
198
+ constructor (cmap) {
199
+ this.cmap = cmap
200
+ /** @type {delta.ChildrenOpAny?} */
201
+ this.src = cmap.children.start
202
+ this.off = 0
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Advance `n` positions inside the cursor's run `src` (`n <= src.length - off`), hopping to the next run
208
+ * at its end.
209
+ *
210
+ * @param {Cursor} c
211
+ * @param {delta.ChildrenOpAny} src
212
+ * @param {number} n
213
+ */
214
+ const advance = (c, src, n) => {
215
+ c.off += n
216
+ if (c.off >= src.length) { c.src = src.next; c.off = 0 }
217
+ }
218
+
219
+ /**
220
+ * Move the cursor onto an op boundary (splitting the run it sits inside) so a fresh op can be inserted there.
221
+ *
222
+ * @param {Cursor} c
223
+ */
224
+ const splitCursor = c => { if (c.off > 0 && c.src !== null) { c.src = delta._splitChildAt(c.cmap, c.src, c.off); c.off = 0 } }
225
+
226
+ /**
227
+ * Splice one uniform run into the layout at the cursor: grow the current run when it is the same kind
228
+ * (no split), else split + insert a fresh op + coalesce. Same-kind insertion grows the run in place, so
229
+ * the layout stays coalesced — typing into a pass-through run is O(1) and never fragments. The cursor
230
+ * ends up after the new positions. `$kind`/`make` describe a uniform retain/delete run.
231
+ *
232
+ * @param {Cursor} c
233
+ * @param {import('../../schema.js').Schema<any>} $kind
234
+ * @param {number} n
235
+ * @param {() => delta.RetainOp | delta.DeleteOp<any>} make
236
+ */
237
+ const addUniform = (c, $kind, n, make) => {
238
+ if (c.src !== null && $kind.check(c.src)) { delta._growRun(c.cmap, /** @type {delta.RetainOp | delta.DeleteOp<any>} */ (c.src), n); c.off += n } else { splitCursor(c); delta._mergeChildWithPrev(c.cmap, delta._insertChild(c.cmap, c.src, make())) }
239
+ }
240
+
241
+ /**
242
+ * `n` pass-through positions.
243
+ *
244
+ * @param {Cursor} c
245
+ * @param {number} n
246
+ */
247
+ const addRetain = (c, n) => addUniform(c, delta.$retainOp, n, () => new delta.RetainOp(n, null, null))
248
+
249
+ /**
250
+ * `n` dropped positions (A-only: they have no B width).
251
+ *
252
+ * @param {Cursor} c
253
+ * @param {number} n
254
+ */
255
+ const addDrop = (c, n) => addUniform(c, delta.$deleteOp, n, () => new delta.DeleteOp(n))
256
+
257
+ /**
258
+ * One child routed through nested conform `t`.
259
+ *
260
+ * @param {Cursor} c
261
+ * @param {ConformTransformer} t
262
+ */
263
+ const addTransformed = (c, t) => {
264
+ if (c.src !== null && delta.$insertOp.check(c.src)) { delta._spliceInsert(c.cmap, c.src, c.off, [t]); c.off += 1 } else { splitCursor(c); delta._mergeChildWithPrev(c.cmap, delta._insertChild(c.cmap, c.src, new delta.InsertOp([t], null, null))) }
265
+ }
266
+
267
+ /**
268
+ * Remove `take` positions at the cursor from the layout (`take <= src.length - off`, `src` the cursor's
269
+ * run), leaving the cursor on the position after them. Does not coalesce — see {@link mergeAtCursor}.
270
+ *
271
+ * @param {Cursor} c
272
+ * @param {delta.ChildrenOpAny} src
273
+ * @param {number} take
274
+ */
275
+ const removeAt = (c, src, take) => {
276
+ if (c.off === 0 && take === src.length) { c.src = src.next; delta._removeChild(c.cmap, src) } else { delta._shrinkChild(c.cmap, src, c.off, take); if (c.off >= src.length) { c.src = src.next; c.off = 0 } }
277
+ }
278
+
279
+ /**
280
+ * After a removal, coalesce the run at the cursor into its predecessor when they are the same kind,
281
+ * keeping the cursor on the survivor (inside it, at the old boundary).
282
+ *
283
+ * @param {Cursor} c
284
+ */
285
+ const mergeAtCursor = c => {
286
+ const src = c.src
287
+ if (src !== null) {
288
+ const prev = src.prev
289
+ const prevLen = prev != null ? prev.length : 0
290
+ if (delta._mergeChildWithPrev(c.cmap, src)) { c.src = prev; c.off += prevLen }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Step the cursor over the hidden runs at its position — A content that was dropped on B (B-width 0) —
296
+ * emitting a plain `retain` over them on the A-side change `out`: a B op never touches hidden content, it
297
+ * only reaches past it. Called at the head of every B op (eager, like `inline`'s `skipEmpty`), so a B
298
+ * insert lands AFTER the hidden content at its position. `src.length - off` because a merge
299
+ * ({@link mergeAtCursor}) can leave the cursor inside a hidden run.
300
+ *
301
+ * @param {Cursor} c
302
+ * @param {delta.DeltaBuilderAny} out
303
+ */
304
+ const skipHidden = (c, out) => {
305
+ while (c.src !== null && delta.$deleteOp.check(c.src)) { out.retain(c.src.length - c.off); c.src = c.src.next; c.off = 0 }
306
+ }
307
+
308
+ /**
309
+ * Map an A-side content offset `k` to its B-side offset through the layout (O(#runs)): hidden runs
310
+ * contribute no B width (an offset inside one collapses to its start on B); past the end is pass-through.
311
+ *
312
+ * @param {delta.DeltaBuilderAny} cmap
313
+ * @param {number} k
314
+ * @return {number}
315
+ */
316
+ const offsetToB = (cmap, k) => {
317
+ let a = 0
318
+ let b = 0
319
+ for (const cop of cmap.children) {
320
+ if (a >= k) break
321
+ const take = math.min(cop.length, k - a)
322
+ if (!delta.$deleteOp.check(cop)) b += take
323
+ a += take
324
+ }
325
+ return b + (k - a)
326
+ }
327
+
328
+ /**
329
+ * Map a B-side content offset `k` to its A-side offset through the layout (O(#runs)): every hidden run up
330
+ * to — and at — the reached point is stepped over, matching where a B op at `k` lands (see
331
+ * {@link skipHidden}); past the end is pass-through.
332
+ *
333
+ * @param {delta.DeltaBuilderAny} cmap
334
+ * @param {number} k
335
+ * @return {number}
336
+ */
337
+ const offsetToA = (cmap, k) => {
338
+ let a = 0
339
+ let b = 0
340
+ for (const cop of cmap.children) {
341
+ if (delta.$deleteOp.check(cop)) { a += cop.length; continue }
342
+ if (b >= k) break
343
+ const take = math.min(cop.length, k - b)
344
+ a += take
345
+ b += take
346
+ }
347
+ return a + (k - b)
348
+ }
349
+
350
+ /**
351
+ * Validate a B-side change against the schema of conform `t` **without touching any state** — the
352
+ * throw-before-mutate contract of {@link ConformTransformer#applyB}: a rejected change leaves the
353
+ * transformer (and its nested conforms) exactly as it was, so the caller can drop the change and keep
354
+ * using it. Throws on an unknown attribute key, a `setAttr` value / inserted child that does not
355
+ * {@link conforms conform}, text where the schema forbids it, a `modify` / `modifyAttr` where the schema
356
+ * has no delta, and — recursively, walking `t.cmap` read-only to the nested conform a `modify` lands on
357
+ * — the same inside a modified child / attribute. `retain` / `delete` are structural and always conform.
358
+ *
359
+ * @param {ConformTransformer} t
360
+ * @param {delta.DeltaAny} dB
361
+ */
362
+ const validateB = (t, dB) => {
363
+ const cfg = t.config // never a pass-through config: applyB returns before validating, and a nested conform is always bound to a concrete $Delta
364
+ if (!cfg.attrsLoose) { // loose attrs ($any) accept any key/value — nothing to validate
365
+ for (const op of dB.attrs) {
366
+ const allowed = cfg.attrAllowed.get(op.key)
367
+ if (allowed === undefined) throw error.create('[lib0/delta] conform: unknown attribute "' + op.key + '"')
368
+ if (delta.$setAttrOp.check(op)) {
369
+ if (!conforms(op.value, allowed)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" value does not conform to the schema')
370
+ } else if (delta.$modifyAttrOp.check(op)) {
371
+ if (!admitsDelta(allowed)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" is a scalar and cannot be modified')
372
+ const nt = attrTransformerOf(t, op.key, allowed)
373
+ if (nt !== null) validateB(nt, op.value)
374
+ }
375
+ // deleteAttr: removing an attribute always conforms
376
+ }
377
+ }
378
+ // retain / delete / modify consume B positions; only a modify needs the run it lands on (to validate
379
+ // against that child's nested conform), so the read-only cursor over the layout is advanced lazily —
380
+ // a change without modifies (typing) never walks the layout here
381
+ let src = t.cmap.children.start
382
+ let off = 0
383
+ let pending = 0 // B positions consumed since the cursor was last advanced
384
+ for (const op of dB.children) {
385
+ if (delta.$textOp.check(op)) {
386
+ if (!cfg.hasText) throw error.create('[lib0/delta] conform: text is not allowed by the schema')
387
+ } else if (delta.$insertOp.check(op)) {
388
+ for (const el of op.insert) if (!conforms(el, cfg.childAllowed)) throw error.create('[lib0/delta] conform: inserted content does not conform to the schema')
389
+ } else {
390
+ if (delta.$modifyOp.check(op)) {
391
+ if (!admitsDelta(cfg.childAllowed)) throw error.create('[lib0/delta] conform: the schema has no delta child to modify')
392
+ while (src !== null) { // advance by `pending`, stepping over hidden runs (no B width), onto the run the modify lands on
393
+ if (delta.$deleteOp.check(src)) { src = src.next; off = 0; continue }
394
+ if (pending === 0) break
395
+ const take = math.min(src.length - off, pending)
396
+ off += take
397
+ pending -= take
398
+ if (off >= src.length) { src = src.next; off = 0 }
399
+ }
400
+ if (src !== null && delta.$insertOp.check(src)) validateB(src.insert[off], op.value)
401
+ }
402
+ pending += op.length
403
+ }
404
+ }
405
+ }
406
+
135
407
  /**
136
408
  * Makes the projected (side-B) delta conform to `$schema`, **recursively**: drops attributes, attribute
137
409
  * values, child nodes, and text the schema does not recognize, and descends into kept delta-valued
@@ -144,10 +416,10 @@ const admitsDelta = allowed => allowed.passAny || allowed.wild != null || allowe
144
416
  * schema) is coalesced into pass-through runs, so a `$deltaAny` schema is a true zero-overhead identity
145
417
  * and an incremental edit costs O(change), not O(document).
146
418
  *
147
- * `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) is a lightweight reverse: it
148
- * validates each B-side op against `$schema` — throwing on any op that would break conformance — and
149
- * otherwise passes the change through to A verbatim (a conformant B-edit is already valid on A). Full
150
- * drop-aware B→A position remapping is deferred (see {@link ConformTransformer#applyB}).
419
+ * `applyA` (A → B) does the full recursive filtering. `applyB` (B → A) maps a change authored on the
420
+ * conformed view back onto A through the same layout: B positions are remapped across the content A has
421
+ * but B does not (see {@link ConformTransformer#applyB} for the rules), and the change is first validated
422
+ * against `$schema` — any op that would break conformance throws, before any state is touched.
151
423
  * Marks are best-effort — marks on kept children/attrs ride through the position map; marks anchored to
152
424
  * dropped content are dropped.
153
425
  *
@@ -181,11 +453,12 @@ export class Conform extends Template {
181
453
  /**
182
454
  * Stateful, recursive transformer produced by {@link Conform}. See {@link Conform} for semantics.
183
455
  *
184
- * The A→B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
456
+ * The A↔B layout lives in {@link ConformTransformer#cmap} (the *child* map), a delta reused as a
185
457
  * coalesced positional map (like `children`'s `childTs`): a `retain(n)` run = pass-through positions, an
186
458
  * `insert([t])` = a child routed through nested conform `t`, a `delete(n)` run = dropped positions
187
- * (width 0 on B). It is rebuilt only on a structural change, carrying the nested transformers by
188
- * reference so their state survives; a change with only retains/modifies is routed in place.
459
+ * (width 0 on B). Both directions walk it with a {@link Cursor} and edit it in place, carrying the nested
460
+ * transformers by reference so their state survives; a change with only retains/modifies is routed
461
+ * without touching the map.
189
462
  *
190
463
  * @extends {Transformer<any,any>}
191
464
  */
@@ -215,8 +488,6 @@ export class ConformTransformer extends Transformer {
215
488
  const cfg = this.config
216
489
  if (cfg.passthrough) return createTransformResult(null, dA) // identity fast-path, no walk, no copy
217
490
  const out = /** @type {any} */ (delta.create(/** @type {any} */ (dA.name)))
218
- /** @type {Set<string|number>} */
219
- const keptAttrKeys = new Set()
220
491
  // --- attributes ---
221
492
  for (const op of dA.attrs) {
222
493
  const key = op.key
@@ -224,7 +495,6 @@ export class ConformTransformer extends Transformer {
224
495
  // loose schema attrs ($any): keep every attribute verbatim (`out` is `any`, so the dynamic-key
225
496
  // write is fine; this mirrors how `slice`/`cloneShallow` copy attr ops)
226
497
  out.attrs[key] = op.clone()
227
- keptAttrKeys.add(key)
228
498
  continue
229
499
  }
230
500
  const allowed = cfg.attrAllowed.get(key)
@@ -233,124 +503,90 @@ export class ConformTransformer extends Transformer {
233
503
  const r = needsTransform(op.value, allowed)
234
504
  if (r === null) continue
235
505
  if (r === true) { out.setAttr(key, op.value, op.attribution); this.transformAttrs.delete(key) } else { this.transformAttrs.set(key, r); out.setAttr(key, r.applyA(op.value).b, op.attribution) }
236
- keptAttrKeys.add(key)
237
506
  } else if (delta.$modifyAttrOp.check(op)) {
238
- let t = this.transformAttrs.get(key)
239
- if (t === undefined) {
240
- // no setAttr was seen first: a $deltaAny attr forwards verbatim; a narrowed delta attr builds
241
- // its nested conform lazily; a scalar attr has nothing to modify (drop)
242
- if (allowed.passAny) { out.modifyAttr(key, op.value); keptAttrKeys.add(key); continue }
243
- const $m = allowed.wild ?? (allowed.byName.size > 0 ? /** @type {delta.$Delta<any>} */ (allowed.byName.values().next().value) : null)
244
- if ($m != null) { t = nest($m); this.transformAttrs.set(key, t) }
245
- }
246
- if (t != null) { out.modifyAttr(key, t.applyA(op.value).b); keptAttrKeys.add(key) }
507
+ const t = attrTransformerOf(this, key, allowed)
508
+ // no nested conform: a $deltaAny attr forwards verbatim; a scalar attr has nothing to modify (drop)
509
+ if (t !== null) out.modifyAttr(key, t.applyA(op.value).b, op.attribution)
510
+ else if (allowed.passAny) out.modifyAttr(key, op.value, op.attribution)
247
511
  } else { // deleteAttr (the only remaining attr-op kind)
248
512
  const dop = /** @type {delta.DeleteAttrOp<any>} */ (op)
249
513
  out.deleteAttr(key, dop.attribution)
250
514
  this.transformAttrs.delete(key)
251
- keptAttrKeys.add(key)
252
515
  }
253
516
  }
254
- // --- children: one cursor `(src, off)` walks `this.cmap`, editing it in place ---
255
- // `cmap` is the coalesced A→B child layout: retain = pass-through, insert([t]) = a child routed
256
- // through nested conform `t`, delete = dropped (B-width 0). retain/modify advance the cursor without
257
- // touching the map; insert/text/delete edit it in place via delta's @internal child mutators (which
258
- // keep `cmap.childCnt` in sync). Same-kind insertion grows the run in place (no split), so the map
259
- // stays coalesced — typing into a pass-through run is O(1) and never fragments. The untouched tail
260
- // past the cursor is simply left in place (no copy), so a structural edit costs O(change).
261
- const cmap = this.cmap
262
- let src = cmap.children.start
263
- let off = 0
264
- // move the cursor onto an op boundary so a fresh op can be inserted there
265
- const splitCursor = () => { if (off > 0 && src != null) { src = delta._splitChildAt(cmap, src, off); off = 0 } }
266
- // splice one run into the map at the cursor: grow the current op when it is the same kind (no split),
267
- // else split + insert a fresh op + coalesce. `$kind`/`make` describe a uniform retain/delete run.
268
- /**
269
- * @param {import('../../schema.js').Schema<any>} $kind
270
- * @param {number} n
271
- * @param {() => delta.RetainOp | delta.DeleteOp<any>} make
272
- */
273
- const addUniform = ($kind, n, make) => {
274
- if (src != null && $kind.check(src)) { delta._growRun(cmap, /** @type {delta.RetainOp | delta.DeleteOp<any>} */ (src), n); off += n } else { splitCursor(); delta._mergeChildWithPrev(cmap, delta._insertChild(cmap, src, make())) }
275
- }
276
- /** @param {number} n */
277
- const addRetain = n => addUniform(delta.$retainOp, n, () => new delta.RetainOp(n, null, null)) // n pass-through positions
278
- /** @param {number} n */
279
- const addDrop = n => addUniform(delta.$deleteOp, n, () => new delta.DeleteOp(n)) // n dropped positions
280
- /** @param {ConformTransformer} t */
281
- const addTransformed = t => { // one child routed through nested conform `t`
282
- if (src != null && delta.$insertOp.check(src)) { delta._spliceInsert(cmap, src, off, [t]); off += 1 } else { splitCursor(); delta._mergeChildWithPrev(cmap, delta._insertChild(cmap, src, new delta.InsertOp([t], null, null))) }
283
- }
517
+ // --- children: one cursor walks `this.cmap`, editing it in place ---
518
+ // retain/modify advance the cursor without touching the map; insert/text/delete edit it in place via
519
+ // delta's @internal child mutators (which keep `cmap.childCnt` in sync). The untouched tail past the
520
+ // cursor is simply left in place (no copy), so a structural edit costs O(change).
521
+ const c = new Cursor(this.cmap)
284
522
  for (const op of dA.children) {
285
523
  if (delta.$retainOp.check(op)) {
286
524
  let rem = op.retain
287
- while (rem > 0 && src != null) {
288
- const take = math.min(src.length - off, rem)
525
+ while (rem > 0 && c.src !== null) {
526
+ const src = c.src
527
+ const take = math.min(src.length - c.off, rem)
289
528
  if (!delta.$deleteOp.check(src)) out.retain(take, op.format, op.attribution) // pass & transform: width = take
290
- off += take; rem -= take
291
- if (off >= src.length) { src = src.next; off = 0 }
529
+ advance(c, src, take)
530
+ rem -= take
292
531
  }
532
+ if (rem > 0) { out.retain(rem, op.format, op.attribution); addRetain(c, rem) } // past the layout: unseen content passes through
293
533
  } else if (delta.$modifyOp.check(op)) {
294
- if (src != null) {
295
- if (delta.$insertOp.check(src)) out.modify(/** @type {ConformTransformer} */ (src.insert[off]).applyA(delta.clone(op.value)).b, op.format, op.attribution)
534
+ const src = c.src
535
+ if (src === null) { out.modify(delta.clone(op.value), op.format, op.attribution); addRetain(c, 1) } else { // past the layout: unseen child passes through
536
+ if (delta.$insertOp.check(src)) out.modify(/** @type {ConformTransformer} */ (src.insert[c.off]).applyA(delta.clone(op.value)).b, op.format, op.attribution)
296
537
  else if (delta.$retainOp.check(src)) out.modify(delta.clone(op.value), op.format, op.attribution) // pass-through child
297
538
  // dropped child (delete in map): emit nothing
298
- off += 1; if (off >= src.length) { src = src.next; off = 0 }
539
+ advance(c, src, 1)
299
540
  }
300
541
  } else if (delta.$textOp.check(op)) {
301
- if (cfg.hasText) { out.insert(op.insert, op.format, op.attribution); addRetain(op.insert.length) } else addDrop(op.insert.length)
542
+ if (cfg.hasText) { out.insert(op.insert, op.format, op.attribution); addRetain(c, op.insert.length) } else addDrop(c, op.insert.length)
302
543
  } else if (delta.$insertOp.check(op)) {
303
544
  for (const el of op.insert) {
304
545
  const r = needsTransform(el, cfg.childAllowed)
305
- if (r === null) addDrop(1) // dropped: no name match, no scalar match
306
- else if (r === true) { out.insert([el], op.format, op.attribution); addRetain(1) } else { out.insert([r.applyA(el).b], op.format, op.attribution); addTransformed(r) }
546
+ if (r === null) addDrop(c, 1) // dropped: no name match, no scalar match
547
+ else if (r === true) { out.insert([el], op.format, op.attribution); addRetain(c, 1) } else { out.insert([r.applyA(el).b], op.format, op.attribution); addTransformed(c, r) }
307
548
  }
308
549
  } else { // delete: remove the deleted A-positions from the map
309
550
  let rem = /** @type {delta.DeleteOp<any>} */ (op).delete
310
- while (rem > 0 && src != null) {
311
- const take = math.min(src.length - off, rem)
551
+ while (rem > 0 && c.src !== null) {
552
+ const src = c.src
553
+ const take = math.min(src.length - c.off, rem)
312
554
  if (!delta.$deleteOp.check(src)) out.delete(take) // pass & transform had B-width; dropped had 0
313
- if (off === 0 && take === src.length) { const next = src.next; delta._removeChild(cmap, src); src = next } else { delta._shrinkChild(cmap, src, off, take); if (off >= src.length) { src = src.next; off = 0 } }
555
+ removeAt(c, src, take)
314
556
  rem -= take
315
557
  }
316
- // the deletion may have joined two same-kind runs: merge, keeping the cursor on the survivor
317
- if (src != null) {
318
- const prev = src.prev
319
- const prevLen = prev != null ? prev.length : 0
320
- if (delta._mergeChildWithPrev(cmap, src)) { src = prev; off += prevLen }
321
- }
558
+ if (rem > 0) out.delete(rem) // past the layout: unseen content passes through
559
+ mergeAtCursor(c) // the deletion may have joined two same-kind runs
322
560
  }
323
561
  }
324
- // --- marks (best-effort): map a content offset through the coalesced layout (O(#runs)) ---
562
+ // --- marks (best-effort): an attr mark rides iff the schema knows the attr; a content mark maps through the layout ---
325
563
  if (dA.marks !== null || dA.deleteMarks !== null) {
326
- delta.mergeRootMarks(out, dA, k => {
327
- if (typeof k !== 'number') return keptAttrKeys.has(k) ? k : null // attr mark rides iff kept
328
- let a = 0; let b = 0
329
- for (const cop of this.cmap.children) {
330
- if (a >= k) break
331
- const take = math.min(cop.length, k - a)
332
- if (!delta.$deleteOp.check(cop)) b += take // pass & transform keep width; drop contributes 0
333
- a += take
334
- }
335
- return b
336
- })
564
+ delta.mergeRootMarks(out, dA, k => typeof k !== 'number' ? (cfg.attrsLoose || cfg.attrAllowed.has(k) ? k : null) : offsetToB(this.cmap, k))
337
565
  }
338
566
  out.done(false)
339
567
  return createTransformResult(null, out)
340
568
  }
341
569
 
342
570
  /**
343
- * Map a B-side change back to A. The conformed view B already satisfies `$schema`, so a change
344
- * authored on it is also valid on A and passes straight through — the result is `{ a: dB, b: null }`
345
- * (mirroring `applyA`'s `{ a: null, b: out }`). Before donating it, every op is validated against the
346
- * schema and any op that would break conformance **throws**: text where the schema forbids text, an
347
- * `insert` / `setAttr` whose content fails the schema, an unknown attribute key, or a `modify` /
348
- * `modifyAttr` targeting a position the schema gives no delta.
571
+ * Map a B-side change back to A. The conformed view B is A minus the content the schema rejects, so a
572
+ * change authored on B is valid on A once its positions are remapped across the hidden content —
573
+ * which is what the layout ({@link ConformTransformer#cmap}) records. The result is `{ a: out, b: null }`
574
+ * (mirroring `applyA`'s `{ a: null, b: out }`). Rules, matching `inline`'s handling of zero-width content:
575
+ * - hidden A content (dropped on B) is stepped over eagerly at the head of every B op with a plain
576
+ * `retain` on A: a B insert lands *after* the hidden content at its position, a B `retain`'s
577
+ * format/attribution never touches it, and a B `delete` spanning it keeps it (the view only deletes
578
+ * what it can see);
579
+ * - a `modify` of a kept child / `modifyAttr` of a kept delta attribute routes through that child's
580
+ * nested conform (recursively remapped); a pass-through child forwards verbatim;
581
+ * - B-inserted content is recorded in the layout (a node matched to a narrower `$Delta` gets a nested
582
+ * conform, warmed on the node), so later edits on either side route correctly;
583
+ * - positions past the end of the layout (content the conform never saw) pass through.
349
584
  *
350
- * Two things are intentionally out of scope in v1: the nested change of a `modify` is not
351
- * deep-validated (only that a delta is permissible there), and B→A coordinates are passed through
352
- * verbatim — exact when nothing was dropped (B is structurally identical to A); a full drop-aware
353
- * position remapping through `cmap` is deferred.
585
+ * The change is validated first (see {@link validateB}) and **throws** before any state is touched on
586
+ * any op that would break conformance: text where the schema forbids text, an `insert` / `setAttr`
587
+ * whose content fails the schema (or an anonymous node where the schema names its children), an
588
+ * unknown attribute key, a `modify` / `modifyAttr` targeting a position the schema gives no delta —
589
+ * recursively inside modified children / attributes.
354
590
  *
355
591
  * @param {delta.DeltaBuilderAny} dB
356
592
  * @return {import('./core.js').TransformResultAny}
@@ -358,29 +594,81 @@ export class ConformTransformer extends Transformer {
358
594
  applyB (dB) {
359
595
  const cfg = this.config
360
596
  if (cfg.passthrough) return createTransformResult(dB, null) // identity: every op conforms
361
- if (!cfg.attrsLoose) { // loose attrs ($any) accept any key/value — nothing to validate
362
- for (const op of dB.attrs) {
363
- const allowed = cfg.attrAllowed.get(op.key)
364
- if (allowed === undefined) throw error.create('[lib0/delta] conform: unknown attribute "' + op.key + '"')
365
- if (delta.$setAttrOp.check(op)) {
366
- if (!allowed.$scalar.check(op.value)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" value does not conform to the schema')
367
- } else if (delta.$modifyAttrOp.check(op)) {
368
- if (!admitsDelta(allowed)) throw error.create('[lib0/delta] conform: attribute "' + op.key + '" is a scalar and cannot be modified')
369
- }
370
- // deleteAttr: removing an attribute always conforms
597
+ validateB(this, dB)
598
+ const out = /** @type {any} */ (delta.create(/** @type {any} */ (dB.name)))
599
+ // --- attributes: forwarded verbatim (B's attrs are A's attrs); the nested conforms are kept in step ---
600
+ for (const op of dB.attrs) {
601
+ const key = op.key
602
+ if (cfg.attrsLoose) { out.attrs[key] = op.clone(); continue } // loose schema attrs ($any): verbatim
603
+ if (delta.$setAttrOp.check(op)) {
604
+ // reason: validateB rejected every value needsTransform maps to null
605
+ const r = /** @type {ConformTransformer | true} */ (needsTransform(op.value, /** @type {Allowed} */ (cfg.attrAllowed.get(key))))
606
+ // a narrowed delta value gets a fresh nested conform, warmed on the value so its layout describes
607
+ // it (the output is discarded — the value conforms, so it is the value itself; `applyA` reads its
608
+ // input without mutating it, so a frozen value is fine here)
609
+ if (r === true) this.transformAttrs.delete(key); else { r.applyA(op.value); this.transformAttrs.set(key, r) }
610
+ out.setAttr(key, op.value, op.attribution)
611
+ } else if (delta.$modifyAttrOp.check(op)) {
612
+ // reason: validateB rejected unknown keys
613
+ const t = attrTransformerOf(this, key, /** @type {Allowed} */ (cfg.attrAllowed.get(key)))
614
+ out.modifyAttr(key, t !== null ? t.applyB(op.value).a : op.value, op.attribution) // no nested conform ($deltaAny attr): verbatim
615
+ } else { // deleteAttr
616
+ out.deleteAttr(key, /** @type {delta.DeleteAttrOp<any>} */ (op).attribution)
617
+ this.transformAttrs.delete(key)
371
618
  }
372
619
  }
620
+ // --- children: one cursor walks `this.cmap`; hidden runs are stepped over at the head of every op ---
621
+ const c = new Cursor(this.cmap)
373
622
  for (const op of dB.children) {
374
- if (delta.$textOp.check(op)) {
375
- if (!cfg.hasText) throw error.create('[lib0/delta] conform: text is not allowed by the schema')
376
- } else if (delta.$insertOp.check(op)) {
377
- for (const el of op.insert) if (!cfg.childAllowed.$scalar.check(el)) throw error.create('[lib0/delta] conform: inserted content does not conform to the schema')
623
+ skipHidden(c, out)
624
+ if (delta.$retainOp.check(op)) {
625
+ let rem = op.retain
626
+ while (rem > 0 && c.src !== null) {
627
+ const src = c.src
628
+ const take = math.min(src.length - c.off, rem)
629
+ out.retain(take, op.format, op.attribution)
630
+ advance(c, src, take)
631
+ rem -= take
632
+ skipHidden(c, out)
633
+ }
634
+ if (rem > 0) { out.retain(rem, op.format, op.attribution); addRetain(c, rem) } // past the layout: unseen content passes through
635
+ } else if (delta.$deleteOp.check(op)) {
636
+ let rem = op.delete
637
+ while (rem > 0 && c.src !== null) {
638
+ const src = c.src
639
+ const take = math.min(src.length - c.off, rem)
640
+ out.delete(take)
641
+ removeAt(c, src, take)
642
+ rem -= take
643
+ mergeAtCursor(c) // a hidden run kept inside the range can now sit between two removed runs: re-coalesce after each removal
644
+ skipHidden(c, out)
645
+ }
646
+ if (rem > 0) out.delete(rem) // past the layout: unseen content passes through
378
647
  } else if (delta.$modifyOp.check(op)) {
379
- if (!admitsDelta(cfg.childAllowed)) throw error.create('[lib0/delta] conform: the schema has no delta child to modify')
648
+ const src = c.src
649
+ if (src === null) { out.modify(delta.clone(op.value), op.format, op.attribution); addRetain(c, 1) } else { // past the layout: unseen child passes through
650
+ // a transformed child routes through its nested conform; a pass-through child forwards verbatim
651
+ out.modify(delta.$insertOp.check(src) ? /** @type {ConformTransformer} */ (src.insert[c.off]).applyB(delta.clone(op.value)).a : delta.clone(op.value), op.format, op.attribution)
652
+ advance(c, src, 1)
653
+ }
654
+ } else if (delta.$textOp.check(op)) {
655
+ out.insert(op.insert, op.format, op.attribution)
656
+ addRetain(c, op.insert.length)
657
+ } else { // insert: every element conforms (validated); record each in the layout, then forward verbatim
658
+ for (const el of /** @type {delta.InsertOp<any>} */ (op).insert) {
659
+ // reason: validateB rejected every child needsTransform maps to null
660
+ const r = /** @type {ConformTransformer | true} */ (needsTransform(el, cfg.childAllowed))
661
+ if (r === true) addRetain(c, 1); else { r.applyA(el); addTransformed(c, r) } // warm the nested conform on the node (see setAttr above)
662
+ }
663
+ out.insert(/** @type {delta.InsertOp<any>} */ (op).insert, op.format, op.attribution)
380
664
  }
381
- // retain / delete: structural, no content -> always conform
382
665
  }
383
- return createTransformResult(dB, null) // every op conforms -> donate the change to A verbatim
666
+ // --- marks (best-effort): an attr mark rides verbatim (B's attrs are A's attrs); a content mark maps through the layout ---
667
+ if (dB.marks !== null || dB.deleteMarks !== null) {
668
+ delta.mergeRootMarks(out, dB, k => typeof k !== 'number' ? k : offsetToA(this.cmap, k))
669
+ }
670
+ out.done(false)
671
+ return createTransformResult(out, null)
384
672
  }
385
673
  }
386
674
 
@@ -389,8 +677,9 @@ export class ConformTransformer extends Transformer {
389
677
  * child node, and text run the schema does not recognize and descending into kept delta children /
390
678
  * delta-valued attributes. Content the schema accepts passes through with near-zero overhead;
391
679
  * `conform($d, delta.$deltaAny)` is the identity. Returns a reusable {@link Conform} template (a
392
- * `project` hole, or `.init()` for a standalone transformer). `applyB` (B → A) validates a B-side
393
- * change against `$schema` and passes it through, throwing on any non-conformant op.
680
+ * `project` hole, or `.init()` for a standalone transformer). `applyB` (B → A) maps a change authored on
681
+ * the conformed view back onto A, remapping its positions across the content the schema hid; it validates
682
+ * the change against `$schema` first and throws on any non-conformant op, before touching any state.
394
683
  *
395
684
  * @template {delta.DeltaConf} IN
396
685
  * @template {delta.DeltaConf} SchemaConf