hyper-html-api 0.10.1 → 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
 
@@ -177,19 +179,181 @@ function indexInParent(adapter, parent, targetNode) {
177
179
  // product's variant) is found even when the immediate container has none.
178
180
  function findFallbackTemplate(adapter, parentCtx, selector, opts) {
179
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)
180
195
  let scope = parentCtx
181
196
  while (scope) {
182
197
  // templateAttr:null opts out of the adapter's default cms-template skip — the
183
198
  // fallback lookup MUST see the seed element to clone it for grow-from-zero.
184
- const candidates = adapter.find(scope, selector, { includeRulesTag: false, templateAttr: null })
199
+ const candidates = adapter.find(scope, search, { includeRulesTag: false, templateAttr: null })
185
200
  for (const n of candidates) {
186
- 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
187
205
  }
188
206
  scope = adapter.parent(scope)
189
207
  }
190
208
  return null
191
209
  }
192
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
+
193
357
  // Puts finalNodes at anchorIdx onward among the parent's content children and leaves every other
194
358
  // child in its order. A node already in place is not touched, so an unchanged apply moves nothing.
195
359
  // The children are read once and each move is mirrored in a local copy: reading them per row, as
@@ -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
+ }
@@ -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
+ }
@@ -105,6 +105,9 @@ export function guardAdapter(adapter, policy = WRITE_POLICY) {
105
105
  if (value === undefined) return adapter.attr(node, name)
106
106
  if (allowAttr(node, name, value)) adapter.attr(node, name, value)
107
107
  },
108
+ removeAttr(node, name) {
109
+ if (allowAttr(node, name, null)) adapter.removeAttr(node, name)
110
+ },
108
111
  prop(node, name, value) {
109
112
  if (value === undefined) return adapter.prop(node, name)
110
113
  if (policy.refusedRuleForms.includes(name)) return refuse(node, `"@${name}" writes HTML, only text is allowed`)
package/src/write.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import cheerioAdapter from './adapters/cheerio.js'
2
2
  import { apply } from './engine/apply.js'
3
+ import { extract } from './engine/extract.js'
3
4
  import { findRulesIn } from './engine/rules-tag.js'
4
5
  import { guardAdapter } from './engine/write-policy.js'
5
6
  import { planWrite } from './engine/write-plan.js'
7
+ import { assertRoundTrip } from './engine/round-trip.js'
6
8
  import { trackAdapter, spliceDocument } from './engine/splice.js'
7
9
  import { NoRulesTag, RulesParseError, WriteRejected } from './engine/errors.js'
8
10
 
@@ -10,8 +12,8 @@ import { NoRulesTag, RulesParseError, WriteRejected } from './engine/errors.js'
10
12
  // (options.rules) or the document's own rules tag, under the content-only
11
13
  // policy. `load` is cheerio.load, passed in so this package keeps cheerio out
12
14
  // of its browser bundles. Returns the new source and whether anything changed.
13
- // Throws NoRulesTag, RulesParseError, WriteRejected, WriteRefused, ShapeMismatch
14
- // or EmptyListInsert, always before producing any output.
15
+ // Throws NoRulesTag, RulesParseError, WriteRejected, WriteRefused, ShapeMismatch,
16
+ // EmptyListInsert or WriteNotRoundTripped, always before producing any output.
15
17
  export function writeDocument(load, src, data, options = {}) {
16
18
  const { token = 'api' } = options
17
19
  const $ = load(src, { sourceCodeLocationInfo: true })
@@ -29,11 +31,41 @@ export function writeDocument(load, src, data, options = {}) {
29
31
  }
30
32
  const plan = planWrite(cheerioAdapter, root, rules, data)
31
33
  if (plan.unknownKeys.length || plan.unmatched.length) throw new WriteRejected(plan.unknownKeys, plan.unmatched)
34
+ const opts = { templateAttr: 'cms-template' }
32
35
  const tracker = trackAdapter(cheerioAdapter)
33
36
  const guarded = guardAdapter(tracker)
34
- apply(guarded, root, rules, data, { templateAttr: 'cms-template' })
37
+ apply(guarded, root, rules, data, opts)
35
38
  guarded.assertClean()
36
- if (tracker.dirty.size === 0) return { html: src, changed: false, spliced: true }
37
- const { html, spliced } = spliceDocument(load, src, $, tracker)
39
+ const { html, spliced } = tracker.dirty.size === 0
40
+ ? { html: src, spliced: true }
41
+ : spliceDocument(load, src, $, tracker)
42
+ // The in-memory tree is not the contract: only the source the caller gets
43
+ // back is. Re-parse it and compare the caller's projection, so a write that
44
+ // serialization dropped throws instead of returning a false success. Only the
45
+ // rules the payload names are re-extracted: a one-field write on a large page
46
+ // pays for the field it wrote, not for every rule in the document.
47
+ const written = writtenRules(rules, data)
48
+ const writtenRoot = load(html).root()
49
+ assertRoundTrip(written, data, extract(cheerioAdapter, writtenRoot, written, opts), {
50
+ adapter: cheerioAdapter,
51
+ root: writtenRoot,
52
+ opts,
53
+ })
38
54
  return { html, changed: html !== src, spliced }
39
55
  }
56
+
57
+ // The top-level subset of `rules` that the payload names, which is exactly what
58
+ // the guard checks: an omitted rule is not part of this write and must not be
59
+ // able to fail it. A scalar or list rule already IS the whole projection.
60
+ function writtenRules(rules, data) {
61
+ if (!isPlainObject(rules) || !isPlainObject(data)) return rules
62
+ const subset = {}
63
+ for (const key of Object.keys(data)) {
64
+ if (Object.prototype.hasOwnProperty.call(rules, key)) subset[key] = rules[key]
65
+ }
66
+ return subset
67
+ }
68
+
69
+ function isPlainObject(value) {
70
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
71
+ }