hyper-html-api 0.10.1 → 0.11.1

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,184 @@ 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
+ // A row selector that excludes seeds by hand (":not([cms-template])") would hide
188
+ // the very seed this lookup is for.
189
+ const exclusion = new RegExp(':not\\(\\[' + opts.templateAttr.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&') + '\\]\\)', 'g')
190
+ const search = selector.replace(/^\s*:scope\s*>\s*/, '').replace(exclusion, '')
191
+ // The stripped anchor makes the rest of the selector far too loose on its own:
192
+ // "p" or "div" matches any seed in any ancestor, including the seed bank root
193
+ // itself. The seed's own selector says how deep its row's context sits and what
194
+ // that node has to look like, and its container has to be the one it names, so
195
+ // a person seed under div.people cannot answer for a checklist.
196
+ const steps = anchored ? childSteps(search) : 0
197
+ const container = finalChildCombinatorLeft(search)
180
198
  let scope = parentCtx
181
199
  while (scope) {
182
200
  // templateAttr:null opts out of the adapter's default cms-template skip — the
183
201
  // 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 })
202
+ const candidates = adapter.find(scope, search, { includeRulesTag: false, templateAttr: null })
185
203
  for (const n of candidates) {
186
- if (adapter.attr(n, opts.templateAttr) != null) return n
204
+ if (adapter.attr(n, opts.templateAttr) == null) continue
205
+ if (anchored && !matchesContextShape(adapter, parentCtx, n, steps)) continue
206
+ if (container && !adapter.matches(adapter.parent(n), container)) continue
207
+ return n
187
208
  }
188
209
  scope = adapter.parent(scope)
189
210
  }
190
211
  return null
191
212
  }
192
213
 
214
+ // How many top-level child steps a stripped selector walks through ("p" is one,
215
+ // "ul.checklist-items > li" is two), or null when a descendant, "+" or "~"
216
+ // combinator makes the row's own context uncountable. Whitespace around a
217
+ // combinator is part of it, not a descendant step of its own.
218
+ function childSteps(selector) {
219
+ let depth = 0
220
+ let quote = null
221
+ let steps = 1
222
+ for (let i = 0; i < selector.length; i++) {
223
+ const ch = selector[i]
224
+ if (ch === '\\') {
225
+ i++
226
+ } else if (quote) {
227
+ if (ch === quote) quote = null
228
+ } else if (ch === '"' || ch === "'") {
229
+ quote = ch
230
+ } else if (ch === '[' || ch === '(') {
231
+ depth++
232
+ } else if (ch === ']' || ch === ')') {
233
+ if (depth > 0) depth--
234
+ } else if (depth === 0 && ch === '>') {
235
+ steps++
236
+ } else if (depth === 0 && (ch === '+' || ch === '~')) {
237
+ return null
238
+ } else if (depth === 0 && /\s/.test(ch)) {
239
+ if (!combinatorSpace(selector, i)) return null
240
+ while (i + 1 < selector.length && /\s/.test(selector[i + 1])) i++
241
+ }
242
+ }
243
+ return steps
244
+ }
245
+
246
+ // Whitespace written next to ">", "+" or "~" belongs to that combinator; with a
247
+ // compound on both sides it is a descendant step of its own.
248
+ function combinatorSpace(selector, index) {
249
+ let end = index
250
+ while (end < selector.length && /\s/.test(selector[end])) end++
251
+ const before = index > 0 ? selector[index - 1] : ''
252
+ const after = end < selector.length ? selector[end] : ''
253
+ return isCombinator(before) || isCombinator(after)
254
+ }
255
+
256
+ function isCombinator(ch) {
257
+ return ch === '>' || ch === '+' || ch === '~'
258
+ }
259
+
260
+ // The node a stripped ":scope > ..." seed's own selector climbs to has to have
261
+ // this context's shape: the same tag, and every class this context carries. A
262
+ // bank's row seed reaches a seed of its own kind, while an unrelated seed
263
+ // (a person seed's div.people, the bank root's body) reaches something else.
264
+ function matchesContextShape(adapter, parentCtx, candidate, steps) {
265
+ if (steps === null) return true
266
+ let node = candidate
267
+ for (let i = 0; i < steps; i++) {
268
+ node = adapter.parent(node)
269
+ if (!node) return false
270
+ }
271
+ const contextTag = adapter.prop(parentCtx, 'tagName')
272
+ // A document or fragment context has no tag or class of its own to compare.
273
+ if (!contextTag) return true
274
+ const tag = adapter.prop(node, 'tagName')
275
+ if (!tag || String(tag).toLowerCase() !== String(contextTag).toLowerCase()) return false
276
+ const own = new Set(classNames(adapter, node))
277
+ return classNames(adapter, parentCtx).every((name) => own.has(name))
278
+ }
279
+
280
+ function classNames(adapter, node) {
281
+ const value = adapter.attr(node, 'class')
282
+ return value ? String(value).split(/\s+/).filter(Boolean) : []
283
+ }
284
+
285
+ // Where the rows of an empty list go. The clone source says what a row looks
286
+ // like; the selector's container inside the context says where rows live. A seed
287
+ // that sits in a container of this context knows its own list, and stands in for
288
+ // the selector whatever it looks like: the last ">" in a selector can trail a
289
+ // descendant or sibling combinator, and then the text left of it is not the
290
+ // row's parent. A seed from outside the context (a hidden bank) says nothing
291
+ // about where the rows live, so the selector has to name the container.
292
+ function destinationFor(adapter, parentCtx, selector, templateSource, trace, opts) {
293
+ const seedParent = adapter.parent(templateSource)
294
+ if (seedParent && isWithin(adapter, parentCtx, seedParent) && !isSeedOrNoData(adapter, seedParent, opts)) {
295
+ return seedParent
296
+ }
297
+ const container = finalChildCombinatorLeft(selector)
298
+ if (!container) {
299
+ throw new EmptyListInsert(
300
+ trace.path,
301
+ `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`,
302
+ )
303
+ }
304
+ // ":scope" names the context itself. A document or a fragment is not a
305
+ // container that can hold rows, and placing a row at index zero of one would
306
+ // put it outside <html>, so it counts as no destination at all.
307
+ // find() already scopes the query to the context, so every candidate belongs
308
+ // to it; a candidate inside a seed bank or a no-data region does not.
309
+ const found = container === ':scope'
310
+ ? (adapter.prop(parentCtx, 'tagName') ? [parentCtx] : [])
311
+ : adapter.find(parentCtx, container, opts)
312
+ const destinations = found.filter((node) => !isSeedOrNoData(adapter, node, opts))
313
+ if (destinations.length === 0) {
314
+ throw new EmptyListInsert(
315
+ trace.path,
316
+ `no container matching "${container}" inside this context to hold the new rows`,
317
+ )
318
+ }
319
+ if (destinations.length > 1) {
320
+ throw new EmptyListInsert(
321
+ trace.path,
322
+ `"${container}" matches ${destinations.length} containers inside this context, so the new rows have no single destination`,
323
+ )
324
+ }
325
+ return destinations[0]
326
+ }
327
+
328
+ function isWithin(adapter, ancestor, node) {
329
+ let current = node
330
+ while (current) {
331
+ if (adapter.sameNode(current, ancestor)) return true
332
+ current = adapter.parent(current)
333
+ }
334
+ // A document (or cheerio's root) is not an element, so a parentElement walk
335
+ // from inside it stops at <html> and never reaches it. Ask the adapter whether
336
+ // it sees the node from there instead; a detached node in another tree stays
337
+ // outside, because that query cannot see it either.
338
+ if (adapter.parent(ancestor)) return false
339
+ return adapter
340
+ .find(ancestor, '*', { includeRulesTag: true, templateAttr: null })
341
+ .some((n) => adapter.sameNode(n, node))
342
+ }
343
+
344
+ // The no-data tokens the adapters already filter reads with, read here through
345
+ // the adapter so both of them answer the same way.
346
+ const NO_DATA_ATTRS = ['no-data', 'editor-ui']
347
+
348
+ function isSeedOrNoData(adapter, node, opts) {
349
+ let current = node
350
+ while (current) {
351
+ if (opts.templateAttr && adapter.attr(current, opts.templateAttr) != null) return true
352
+ if (NO_DATA_ATTRS.some((name) => adapter.attr(current, name) != null)) return true
353
+ const clay = adapter.attr(current, 'clay')
354
+ if (clay && clay.split(/\s+/).some((token) => NO_DATA_ATTRS.includes(token))) return true
355
+ current = adapter.parent(current)
356
+ }
357
+ return false
358
+ }
359
+
193
360
  // Puts finalNodes at anchorIdx onward among the parent's content children and leaves every other
194
361
  // child in its order. A node already in place is not touched, so an unchanged apply moves nothing.
195
362
  // 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
+ }