hyper-html-api 0.10.0 → 0.11.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.
@@ -1,5 +1,6 @@
1
1
  import { EmptyListInsert } from './errors.js'
2
2
  import { extract } from './extract.js'
3
+ import { finalChildCombinatorLeft } from './rule-syntax.js'
3
4
  import { matchRows } from './row-match.js'
4
5
 
5
6
  /**
@@ -47,10 +48,11 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
47
48
 
48
49
  const matches = matchRows(newItems, oldValues, shape, lockSuppliedRows(adapter, oldNodes, newItems, trace, opts))
49
50
 
50
- const referenceNode = oldNodes[0] || templateSource
51
- const parent = adapter.parent(referenceNode)
51
+ const parent = oldNodes.length > 0
52
+ ? adapter.parent(oldNodes[0])
53
+ : destinationFor(adapter, parentCtx, selector, templateSource, trace, opts)
52
54
  const anchorIdx = oldNodes.length > 0
53
- ? indexInParent(adapter, parent, referenceNode)
55
+ ? indexInParent(adapter, parent, oldNodes[0])
54
56
  : 0
55
57
  const contiguous = isContiguousRun(adapter, oldNodes)
56
58
 
@@ -76,17 +78,7 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
76
78
  })
77
79
 
78
80
  if (contiguous) {
79
- // Place each final node at its target index. If the node is already
80
- // there, do nothing (no-op apply on unchanged data drops zero state).
81
- // Otherwise insertAt moves an attached node (DOM/cheerio both treat
82
- // insertBefore on an attached node as "move to here").
83
- finalNodes.forEach((node, i) => {
84
- const targetIdx = anchorIdx + i
85
- const siblings = adapter.children(parent)
86
- const currentIdx = siblings.findIndex((s) => adapter.sameNode(s, node))
87
- if (currentIdx === targetIdx) return
88
- adapter.insertAt(parent, node, targetIdx)
89
- })
81
+ placeContiguous(adapter, parent, anchorIdx, finalNodes, fresh)
90
82
  } else {
91
83
  // The matched nodes are not a contiguous run of siblings, so this list has
92
84
  // no DOM order to restore: any repositioning would move elements the list
@@ -132,14 +124,13 @@ function lockSuppliedRows(adapter, oldNodes, newItems, trace, opts) {
132
124
  if (!Array.isArray(supplied)) return null
133
125
  const locked = new Array(newItems.length).fill(-1)
134
126
  const taken = new Set()
127
+ const position = new Map(oldNodes.map((n, j) => [adapter.identity(n), j]))
135
128
  for (let i = 0; i < newItems.length; i++) {
136
129
  if (!supplied[i]) continue
137
- for (let j = 0; j < oldNodes.length; j++) {
138
- if (taken.has(j) || !adapter.sameNode(oldNodes[j], supplied[i])) continue
139
- locked[i] = j
140
- taken.add(j)
141
- break
142
- }
130
+ const j = position.get(adapter.identity(supplied[i]))
131
+ if (j === undefined || taken.has(j)) continue
132
+ locked[i] = j
133
+ taken.add(j)
143
134
  }
144
135
  return locked
145
136
  }
@@ -188,19 +179,224 @@ function indexInParent(adapter, parent, targetNode) {
188
179
  // product's variant) is found even when the immediate container has none.
189
180
  function findFallbackTemplate(adapter, parentCtx, selector, opts) {
190
181
  if (!opts.templateAttr) return null
182
+ // A leading ":scope >" anchors a row to its own context, so an ancestor seed
183
+ // bank can never answer that query: no element is a direct child of its own
184
+ // descendant. Dropping just that anchor searches the ancestors for the rest of
185
+ // the selector, so a checklist item seed still cannot pass for a comment seed.
186
+ const anchored = /^\s*:scope\s*>\s*/.test(selector)
187
+ const search = selector.replace(/^\s*:scope\s*>\s*/, '')
188
+ // The stripped anchor makes the rest of the selector far too loose on its own:
189
+ // "p" or "div" matches any seed in any ancestor, including the seed bank root
190
+ // itself. The seed's own selector says how deep its row's context sits and what
191
+ // that node has to look like, and its container has to be the one it names, so
192
+ // a person seed under div.people cannot answer for a checklist.
193
+ const steps = anchored ? childSteps(search) : 0
194
+ const container = finalChildCombinatorLeft(search)
191
195
  let scope = parentCtx
192
196
  while (scope) {
193
197
  // templateAttr:null opts out of the adapter's default cms-template skip — the
194
198
  // fallback lookup MUST see the seed element to clone it for grow-from-zero.
195
- const candidates = adapter.find(scope, selector, { includeRulesTag: false, templateAttr: null })
199
+ const candidates = adapter.find(scope, search, { includeRulesTag: false, templateAttr: null })
196
200
  for (const n of candidates) {
197
- if (adapter.attr(n, opts.templateAttr) != null) return n
201
+ if (adapter.attr(n, opts.templateAttr) == null) continue
202
+ if (anchored && !matchesContextShape(adapter, parentCtx, n, steps)) continue
203
+ if (container && !adapter.matches(adapter.parent(n), container)) continue
204
+ return n
198
205
  }
199
206
  scope = adapter.parent(scope)
200
207
  }
201
208
  return null
202
209
  }
203
210
 
211
+ // How many top-level child steps a stripped selector walks through ("p" is one,
212
+ // "ul.checklist-items > li" is two), or null when a descendant, "+" or "~"
213
+ // combinator makes the row's own context uncountable. Whitespace around a
214
+ // combinator is part of it, not a descendant step of its own.
215
+ function childSteps(selector) {
216
+ let depth = 0
217
+ let quote = null
218
+ let steps = 1
219
+ for (let i = 0; i < selector.length; i++) {
220
+ const ch = selector[i]
221
+ if (ch === '\\') {
222
+ i++
223
+ } else if (quote) {
224
+ if (ch === quote) quote = null
225
+ } else if (ch === '"' || ch === "'") {
226
+ quote = ch
227
+ } else if (ch === '[' || ch === '(') {
228
+ depth++
229
+ } else if (ch === ']' || ch === ')') {
230
+ if (depth > 0) depth--
231
+ } else if (depth === 0 && ch === '>') {
232
+ steps++
233
+ } else if (depth === 0 && (ch === '+' || ch === '~')) {
234
+ return null
235
+ } else if (depth === 0 && /\s/.test(ch)) {
236
+ if (!combinatorSpace(selector, i)) return null
237
+ while (i + 1 < selector.length && /\s/.test(selector[i + 1])) i++
238
+ }
239
+ }
240
+ return steps
241
+ }
242
+
243
+ // Whitespace written next to ">", "+" or "~" belongs to that combinator; with a
244
+ // compound on both sides it is a descendant step of its own.
245
+ function combinatorSpace(selector, index) {
246
+ let end = index
247
+ while (end < selector.length && /\s/.test(selector[end])) end++
248
+ const before = index > 0 ? selector[index - 1] : ''
249
+ const after = end < selector.length ? selector[end] : ''
250
+ return isCombinator(before) || isCombinator(after)
251
+ }
252
+
253
+ function isCombinator(ch) {
254
+ return ch === '>' || ch === '+' || ch === '~'
255
+ }
256
+
257
+ // The node a stripped ":scope > ..." seed's own selector climbs to has to have
258
+ // this context's shape: the same tag, and every class this context carries. A
259
+ // bank's row seed reaches a seed of its own kind, while an unrelated seed
260
+ // (a person seed's div.people, the bank root's body) reaches something else.
261
+ function matchesContextShape(adapter, parentCtx, candidate, steps) {
262
+ if (steps === null) return true
263
+ let node = candidate
264
+ for (let i = 0; i < steps; i++) {
265
+ node = adapter.parent(node)
266
+ if (!node) return false
267
+ }
268
+ const contextTag = adapter.prop(parentCtx, 'tagName')
269
+ // A document or fragment context has no tag or class of its own to compare.
270
+ if (!contextTag) return true
271
+ const tag = adapter.prop(node, 'tagName')
272
+ if (!tag || String(tag).toLowerCase() !== String(contextTag).toLowerCase()) return false
273
+ const own = new Set(classNames(adapter, node))
274
+ return classNames(adapter, parentCtx).every((name) => own.has(name))
275
+ }
276
+
277
+ function classNames(adapter, node) {
278
+ const value = adapter.attr(node, 'class')
279
+ return value ? String(value).split(/\s+/).filter(Boolean) : []
280
+ }
281
+
282
+ // Where the rows of an empty list go. The clone source says what a row looks
283
+ // like; the selector's container inside the context says where rows live. A seed
284
+ // that sits in a container of this context knows its own list, and stands in for
285
+ // the selector whatever it looks like: the last ">" in a selector can trail a
286
+ // descendant or sibling combinator, and then the text left of it is not the
287
+ // row's parent. A seed from outside the context (a hidden bank) says nothing
288
+ // about where the rows live, so the selector has to name the container.
289
+ function destinationFor(adapter, parentCtx, selector, templateSource, trace, opts) {
290
+ const seedParent = adapter.parent(templateSource)
291
+ if (seedParent && isWithin(adapter, parentCtx, seedParent) && !isSeedOrNoData(adapter, seedParent, opts)) {
292
+ return seedParent
293
+ }
294
+ const container = finalChildCombinatorLeft(selector)
295
+ if (!container) {
296
+ throw new EmptyListInsert(
297
+ trace.path,
298
+ `the list selector "${selector}" names no container to hold the new rows — write it as "container > row" (for example "ol.comments > li") so the cloned row lands in the list`,
299
+ )
300
+ }
301
+ // ":scope" names the context itself. A document or a fragment is not a
302
+ // container that can hold rows, and placing a row at index zero of one would
303
+ // put it outside <html>, so it counts as no destination at all.
304
+ // find() already scopes the query to the context, so every candidate belongs
305
+ // to it; a candidate inside a seed bank or a no-data region does not.
306
+ const found = container === ':scope'
307
+ ? (adapter.prop(parentCtx, 'tagName') ? [parentCtx] : [])
308
+ : adapter.find(parentCtx, container, opts)
309
+ const destinations = found.filter((node) => !isSeedOrNoData(adapter, node, opts))
310
+ if (destinations.length === 0) {
311
+ throw new EmptyListInsert(
312
+ trace.path,
313
+ `no container matching "${container}" inside this context to hold the new rows`,
314
+ )
315
+ }
316
+ if (destinations.length > 1) {
317
+ throw new EmptyListInsert(
318
+ trace.path,
319
+ `"${container}" matches ${destinations.length} containers inside this context, so the new rows have no single destination`,
320
+ )
321
+ }
322
+ return destinations[0]
323
+ }
324
+
325
+ function isWithin(adapter, ancestor, node) {
326
+ let current = node
327
+ while (current) {
328
+ if (adapter.sameNode(current, ancestor)) return true
329
+ current = adapter.parent(current)
330
+ }
331
+ // A document (or cheerio's root) is not an element, so a parentElement walk
332
+ // from inside it stops at <html> and never reaches it. Ask the adapter whether
333
+ // it sees the node from there instead; a detached node in another tree stays
334
+ // outside, because that query cannot see it either.
335
+ if (adapter.parent(ancestor)) return false
336
+ return adapter
337
+ .find(ancestor, '*', { includeRulesTag: true, templateAttr: null })
338
+ .some((n) => adapter.sameNode(n, node))
339
+ }
340
+
341
+ // The no-data tokens the adapters already filter reads with, read here through
342
+ // the adapter so both of them answer the same way.
343
+ const NO_DATA_ATTRS = ['no-data', 'editor-ui']
344
+
345
+ function isSeedOrNoData(adapter, node, opts) {
346
+ let current = node
347
+ while (current) {
348
+ if (opts.templateAttr && adapter.attr(current, opts.templateAttr) != null) return true
349
+ if (NO_DATA_ATTRS.some((name) => adapter.attr(current, name) != null)) return true
350
+ const clay = adapter.attr(current, 'clay')
351
+ if (clay && clay.split(/\s+/).some((token) => NO_DATA_ATTRS.includes(token))) return true
352
+ current = adapter.parent(current)
353
+ }
354
+ return false
355
+ }
356
+
357
+ // Puts finalNodes at anchorIdx onward among the parent's content children and leaves every other
358
+ // child in its order. A node already in place is not touched, so an unchanged apply moves nothing.
359
+ // The children are read once and each move is mirrored in a local copy: reading them per row, as
360
+ // this used to, made a list write cost the square of its length.
361
+ //
362
+ // Every surviving node sits at or after its target when its turn comes, because the run started at
363
+ // anchorIdx and the earlier targets are already filled. So removing a node from the copy never
364
+ // shifts the target it is moving to.
365
+ function placeContiguous(adapter, parent, anchorIdx, finalNodes, fresh) {
366
+ const siblings = adapter.children(parent)
367
+ const order = siblings.map((s) => adapter.identity(s))
368
+ // Past the end of the content children, insertAt appends to the very end of the
369
+ // parent when the parent has no no-data child, which is past any trailing text:
370
+ // "after the last content child" is NOT what it does there, and the document
371
+ // has to come out byte-identical. So the first row that lands past the end still
372
+ // goes through insertAt, and the rows after it follow that node, which is now
373
+ // the parent's last child. Only one row ever pays for the index insert, because
374
+ // the targets only move forward and the local copy grows with them.
375
+ let appended = false
376
+ finalNodes.forEach((node, i) => {
377
+ const id = adapter.identity(node)
378
+ const target = anchorIdx + i
379
+ if (order[target] === id) return
380
+ if (!fresh[i]) {
381
+ const current = order.indexOf(id)
382
+ if (current !== -1) {
383
+ order.splice(current, 1)
384
+ siblings.splice(current, 1)
385
+ }
386
+ }
387
+ const at = Math.min(target, siblings.length)
388
+ if (at < siblings.length) adapter.insertBefore(siblings[at], node)
389
+ else if (!siblings.length) adapter.insertAt(parent, node, 0)
390
+ else if (appended) adapter.insertAfter(siblings[siblings.length - 1], node)
391
+ else {
392
+ adapter.insertAt(parent, node, target)
393
+ appended = true
394
+ }
395
+ order.splice(at, 0, id)
396
+ siblings.splice(at, 0, node)
397
+ })
398
+ }
399
+
204
400
  // Repositioning only means something when the list occupies a contiguous run of
205
401
  // element siblings under one parent. Two containers, or an unowned sibling
206
402
  // sitting between two owned nodes, has no list order to restore.
@@ -208,11 +404,11 @@ function isContiguousRun(adapter, nodes) {
208
404
  if (nodes.length <= 1) return true
209
405
  const parent = adapter.parent(nodes[0])
210
406
  if (!parent) return false
211
- const siblings = adapter.children(parent)
407
+ const position = new Map(adapter.children(parent).map((s, i) => [adapter.identity(s), i]))
212
408
  const indices = []
213
409
  for (const node of nodes) {
214
- const i = siblings.findIndex((s) => adapter.sameNode(s, node))
215
- if (i === -1) return false
410
+ const i = position.get(adapter.identity(node))
411
+ if (i === undefined) return false
216
412
  indices.push(i)
217
413
  }
218
414
  indices.sort((a, b) => a - b)
@@ -97,3 +97,19 @@ export const DOM_PROPERTIES_READ_ONLY_SET = new Set([
97
97
  'paused',
98
98
  'dataset',
99
99
  ])
100
+
101
+ /**
102
+ * Form properties whose value is a boolean rather than a string. Shared by
103
+ * apply() and the round-trip guard so there is one conversion, not two.
104
+ */
105
+ export const BOOLEAN_PROPS = new Set(['checked', 'selected', 'disabled', 'readOnly', 'paused'])
106
+
107
+ export function coercePropValue(name, value) {
108
+ if (value === null || value === undefined) {
109
+ return BOOLEAN_PROPS.has(name) ? false : ''
110
+ }
111
+ // Form state and attribute round-trips deliver booleans as strings, and
112
+ // Boolean("false") is true: writing "false" would CHECK an unchecked box.
113
+ if (BOOLEAN_PROPS.has(name)) return value === 'false' ? false : Boolean(value)
114
+ return value
115
+ }
@@ -33,9 +33,11 @@ export class ShapeMismatch extends Error {
33
33
  }
34
34
 
35
35
  export class EmptyListInsert extends Error {
36
- constructor(path) {
36
+ constructor(path, reason) {
37
37
  super(
38
- `cannot add items to empty list at "${path.join('.')}" — no sibling to clone as template. Seed the list with a hidden item first.`,
38
+ reason
39
+ ? `cannot add items to empty list at "${path.join('.')}" — ${reason}`
40
+ : `cannot add items to empty list at "${path.join('.')}" — no sibling to clone as template. Seed the list with a hidden item first.`,
39
41
  )
40
42
  this.name = 'EmptyListInsert'
41
43
  this.path = path
@@ -58,6 +60,22 @@ export class WriteRefused extends Error {
58
60
  }
59
61
  }
60
62
 
63
+ function pathLabel(path) {
64
+ const label = path.reduce(
65
+ (out, part) => (typeof part === 'number' ? `${out}[${part}]` : out ? `${out}.${part}` : String(part)),
66
+ '',
67
+ )
68
+ return label || '(root)'
69
+ }
70
+
71
+ export class WriteNotRoundTripped extends Error {
72
+ constructor(path) {
73
+ super(`write did not round-trip at "${pathLabel(path)}"`)
74
+ this.name = 'WriteNotRoundTripped'
75
+ this.path = path
76
+ }
77
+ }
78
+
61
79
  export class NoRulesTag extends Error {
62
80
  constructor(token) {
63
81
  super(`no <script data-rules-name~="${token}"> rules tag in this document`)
@@ -93,5 +93,5 @@ function readPropOrAttr(adapter, node, name) {
93
93
  return v == null ? null : String(v)
94
94
  }
95
95
  const v = adapter.attr(node, name)
96
- return v ? v : null
96
+ return v == null ? null : v
97
97
  }
@@ -11,6 +11,7 @@ export * as errors from './errors.js'
11
11
  export { DOM_PROPERTIES, DOM_PROPERTIES_SET, DOM_PROPERTIES_READ_ONLY_SET } from './dom-properties.js'
12
12
  export { WRITE_POLICY, guardAdapter, checkAttribute, urlScheme } from './write-policy.js'
13
13
  export { planWrite } from './write-plan.js'
14
+ export { assertRoundTrip } from './round-trip.js'
14
15
  export { trackAdapter, spliceDocument } from './splice.js'
15
16
 
16
17
  // source: object literal → use as-is (no tag); string → look up by token.
@@ -0,0 +1,171 @@
1
+ import {
2
+ DOM_PROPERTIES_SET,
3
+ DOM_PROPERTIES_WRITE_SET,
4
+ coercePropValue,
5
+ } from './dom-properties.js'
6
+ import { WriteNotRoundTripped } from './errors.js'
7
+ import { ruleAttrIndex } from './rule-syntax.js'
8
+
9
+ // Props whose read-side value keeps its whitespace. Every other text rule reads
10
+ // trimmed, which is what writeText compares against.
11
+ const UNTRIMMED_PROPS = new Set(['textContent', 'innerText'])
12
+
13
+ // Elements whose content loses one leading LF to the HTML parser: the tokenizer
14
+ // drops a newline that immediately follows the start tag.
15
+ const NEWLINE_DROPPING_TAGS = new Set(['pre', 'textarea'])
16
+
17
+ // Compares what the caller asked for against a fresh extraction of the
18
+ // serialized source, so a write that serialized away or never landed throws
19
+ // WriteNotRoundTripped instead of returning a false success. Only the supplied
20
+ // projection is checked: omitted keys and undefined are skipped exactly as the
21
+ // writer skips them, which is what keeps partial updates working. `source` is
22
+ // the adapter, root and options that extraction read, so the guard can see
23
+ // which element a text rule matched instead of guessing from its selector.
24
+ export function assertRoundTrip(rules, requested, actual, source) {
25
+ check(rules, requested, actual, [], source ? { ...source, rules } : null)
26
+ }
27
+
28
+ function check(rule, want, got, path, source) {
29
+ if (want === undefined) return
30
+ if (typeof rule === 'string') {
31
+ checkScalarRule(rule, want, got, path, source)
32
+ return
33
+ }
34
+ if (Array.isArray(rule)) {
35
+ checkList(rule[1], want, got, path, source)
36
+ return
37
+ }
38
+ if (typeof rule === 'object' && rule !== null) {
39
+ checkObject(rule, want, got, path, source)
40
+ }
41
+ }
42
+
43
+ function checkScalarRule(rule, want, got, path, source) {
44
+ if (rule.endsWith('[]')) {
45
+ checkTextList(want, got, path)
46
+ return
47
+ }
48
+ if (rule.startsWith('@')) {
49
+ checkPropOrAttr(rule.slice(1), want, got, path, '', source)
50
+ return
51
+ }
52
+ const at = ruleAttrIndex(rule)
53
+ if (at !== -1) {
54
+ checkPropOrAttr(rule.slice(at + 1), want, got, path, rule.slice(0, at), source)
55
+ return
56
+ }
57
+ checkText(want, got, path)
58
+ }
59
+
60
+ function checkText(want, got, path) {
61
+ if (textOf(want) !== textOf(got)) fail(path)
62
+ }
63
+
64
+ function checkTextList(want, got, path) {
65
+ if (!Array.isArray(want) || !Array.isArray(got) || want.length !== got.length) fail(path)
66
+ for (let i = 0; i < want.length; i++) checkText(want[i], got[i], [...path, i])
67
+ }
68
+
69
+ function checkObject(rule, want, got, path, source) {
70
+ if (!isPlainObject(want) || !isPlainObject(got)) fail(path)
71
+ for (const [key, sub] of Object.entries(rule)) {
72
+ check(sub, want[key], got[key], [...path, key], source)
73
+ }
74
+ }
75
+
76
+ function checkList(shape, want, got, path, source) {
77
+ if (!Array.isArray(want) || !Array.isArray(got) || want.length !== got.length) fail(path)
78
+ for (let i = 0; i < want.length; i++) check(shape, want[i], got[i], [...path, i], source)
79
+ }
80
+
81
+ function checkPropOrAttr(name, want, got, path, selector, source) {
82
+ if (!DOM_PROPERTIES_SET.has(name)) {
83
+ if (want == null) {
84
+ if (got != null) fail(path)
85
+ return
86
+ }
87
+ if (!sameString(String(want), got)) fail(path)
88
+ return
89
+ }
90
+ if (UNTRIMMED_PROPS.has(name)) {
91
+ if (!sameUntrimmed(String(want ?? ''), got, selector, path, source)) fail(path)
92
+ return
93
+ }
94
+ if (DOM_PROPERTIES_WRITE_SET.has(name)) {
95
+ if (got !== String(coercePropValue(name, want))) fail(path)
96
+ return
97
+ }
98
+ if (want == null) {
99
+ if (got != null) fail(path)
100
+ return
101
+ }
102
+ if (!sameString(String(want), got)) fail(path)
103
+ }
104
+
105
+ function textOf(value) {
106
+ return normalizeNewlines(String(value ?? '').trim())
107
+ }
108
+
109
+ // A document normalizes newlines: parsing turns CRLF and a lone CR into LF, so
110
+ // a value that differs only by that is the value the source carries.
111
+ function normalizeNewlines(value) {
112
+ return value.replace(/\r\n?/g, '\n')
113
+ }
114
+
115
+ function sameString(want, got) {
116
+ return got != null && normalizeNewlines(want) === normalizeNewlines(got)
117
+ }
118
+
119
+ // @textContent compares exactly, with one exception: a leading newline after a
120
+ // pre/textarea start tag never reaches the DOM, so it cannot come back.
121
+ function sameUntrimmed(want, got, selector, path, source) {
122
+ if (got == null) return false
123
+ const wantText = normalizeNewlines(want)
124
+ const gotText = normalizeNewlines(got)
125
+ if (wantText === gotText) return true
126
+ return wantText.startsWith('\n') && wantText.slice(1) === gotText && dropsLeadingNewline(selector, path, source)
127
+ }
128
+
129
+ // Which element the rule matched decides the allowance, not how its selector is
130
+ // spelled: an ID or class selector names a pre just as "pre" does. An element
131
+ // that cannot be found refuses, which is the safe answer: the caller compares
132
+ // exactly.
133
+ function dropsLeadingNewline(selector, path, source) {
134
+ const node = matchedNode(selector, path, source)
135
+ if (!node) return false
136
+ const tag = source.adapter.prop(node, 'tagName')
137
+ return tag != null && NEWLINE_DROPPING_TAGS.has(String(tag).toLowerCase())
138
+ }
139
+
140
+ // The element a scalar rule reads, found the way extraction found it: walk the
141
+ // rule tree by the reported path to the rule's own context, then match the
142
+ // rule's selector there. Only called for a text rule whose value lost a leading
143
+ // newline, so an ordinary write pays nothing for it.
144
+ function matchedNode(selector, path, source) {
145
+ if (!source || !source.adapter || !source.root) return null
146
+ const { adapter, root, opts } = source
147
+ let rule = source.rules
148
+ let ctx = root
149
+ for (const key of path) {
150
+ if (Array.isArray(rule)) {
151
+ const match = adapter.find(ctx, rule[0], opts)[key]
152
+ if (!match) return null
153
+ ctx = match
154
+ rule = rule[1]
155
+ } else if (isPlainObject(rule)) {
156
+ rule = rule[key]
157
+ } else {
158
+ return null
159
+ }
160
+ }
161
+ if (!selector) return ctx
162
+ return adapter.find(ctx, selector, opts)[0] || null
163
+ }
164
+
165
+ function isPlainObject(value) {
166
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
167
+ }
168
+
169
+ function fail(path) {
170
+ throw new WriteNotRoundTripped(path)
171
+ }
@@ -76,7 +76,8 @@ export function matchRows(newItems, oldValues, shape, locked) {
76
76
  // Changed content outranks displacement: the scale is larger than any total
77
77
  // displacement this list can produce, so position only ever breaks a tie.
78
78
  const scale = n * m + 1
79
- const cost = (i, j) => differingFields(newItems[i], oldValues[j], shape) * scale + Math.abs(i - j)
79
+ const differing = fieldDistance(newItems, oldValues, shape)
80
+ const cost = (i, j) => differing(i, j) * scale + Math.abs(i - j)
80
81
  for (const [i, j] of alignInOrder(leftNew, leftOld, cost)) matches[i] = j
81
82
  return matches
82
83
  }
@@ -97,17 +98,22 @@ function itemKey(item, shape) {
97
98
  )
98
99
  }
99
100
 
100
- function differingFields(a, b, shape) {
101
- if (!isObjectShape(shape)) return a === b ? 0 : 1
101
+ // How many fields differ between new item i and old item j. Each item's fields are encoded once
102
+ // here: the alignment table asks this for every pair, and encoding per pair made it the dominant
103
+ // cost of a large list.
104
+ function fieldDistance(newItems, oldValues, shape) {
105
+ if (!isObjectShape(shape)) return (i, j) => (newItems[i] === oldValues[j] ? 0 : 1)
102
106
  const fields = Object.keys(shape)
103
- if (fields.length === 0) return 0
104
- let differing = 0
105
- for (const f of fields) {
106
- const av = JSON.stringify(a == null ? undefined : a[f])
107
- const bv = JSON.stringify(b == null ? undefined : b[f])
108
- if (av !== bv) differing++
107
+ const encode = (item) => fields.map((f) => JSON.stringify(item == null ? undefined : item[f]))
108
+ const newCodes = newItems.map(encode)
109
+ const oldCodes = oldValues.map(encode)
110
+ return (i, j) => {
111
+ const a = newCodes[i]
112
+ const b = oldCodes[j]
113
+ let differing = 0
114
+ for (let k = 0; k < fields.length; k++) if (a[k] !== b[k]) differing++
115
+ return differing
109
116
  }
110
- return differing
111
117
  }
112
118
 
113
119
  // Pair every entry of the shorter list with one of the longer, keeping both in
@@ -41,3 +41,74 @@ export function splitRule(rule) {
41
41
  if (at === -1) return { selector: rule, prop: null }
42
42
  return { selector: rule.slice(0, at), prop: rule.slice(at + 1) || null }
43
43
  }
44
+
45
+ // The list grammar needs a second split: "container > row" says where a row
46
+ // goes. Only the FINAL top-level ">" separates them, and a ">" inside brackets,
47
+ // parens or quotes is not a combinator at all: a[data-note="a > b"] and
48
+ // :is(a > b) both carry one, and splitting there names a container that cannot
49
+ // exist. A top-level "," is a group whose members may name different
50
+ // containers, so the fallback refuses it rather than guessing one of them.
51
+ //
52
+ // What follows that ">" also has to be the ONE compound selector it introduces.
53
+ // "ul > li span" and "ul > li + li" put the row below that ">", so its left
54
+ // side is not the row's parent and cannot stand in as the row's container.
55
+ //
56
+ // Left side of that final top-level ">", or null when the selector names no
57
+ // container for the row.
58
+ export function finalChildCombinatorLeft(selector) {
59
+ let depth = 0
60
+ let quote = null
61
+ let found = -1
62
+ let group = false
63
+ for (let i = 0; i < selector.length; i++) {
64
+ const ch = selector[i]
65
+ if (ch === '\\') {
66
+ i++
67
+ } else if (quote) {
68
+ if (ch === quote) quote = null
69
+ } else if (ch === '"' || ch === "'") {
70
+ quote = ch
71
+ } else if (ch === '[' || ch === '(') {
72
+ depth++
73
+ } else if (ch === ']' || ch === ')') {
74
+ if (depth > 0) depth--
75
+ } else if (depth === 0 && ch === '>') {
76
+ found = i
77
+ } else if (depth === 0 && ch === ',') {
78
+ group = true
79
+ }
80
+ }
81
+ if (group || found === -1) return null
82
+ if (!isSingleCompound(selector, found + 1)) return null
83
+ return selector.slice(0, found).trim() || null
84
+ }
85
+
86
+ // The text after the ">", ignoring the whitespace that belongs to the
87
+ // combinator itself, is a single compound selector when no top-level
88
+ // descendant, "+" or "~" follows it. Depth and quotes are tracked the same way
89
+ // as above, so whitespace inside a[title="a b"] or :is(a b) is not a
90
+ // combinator.
91
+ function isSingleCompound(selector, start) {
92
+ let i = start
93
+ const end = selector.replace(/\s+$/, '').length
94
+ while (i < end && /\s/.test(selector[i])) i++
95
+ let depth = 0
96
+ let quote = null
97
+ for (; i < end; i++) {
98
+ const ch = selector[i]
99
+ if (ch === '\\') {
100
+ i++
101
+ } else if (quote) {
102
+ if (ch === quote) quote = null
103
+ } else if (ch === '"' || ch === "'") {
104
+ quote = ch
105
+ } else if (ch === '[' || ch === '(') {
106
+ depth++
107
+ } else if (ch === ']' || ch === ')') {
108
+ if (depth > 0) depth--
109
+ } else if (depth === 0 && (ch === '+' || ch === '~' || /\s/.test(ch))) {
110
+ return false
111
+ }
112
+ }
113
+ return true
114
+ }