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.
- package/conformance/MANIFEST.json +1 -1
- package/conformance/cases/props-empty-attr-vs-empty-prop.expected.json +1 -1
- package/conformance/write-policy.json +2 -1
- package/dist/hyper-html-api.engine.esm.js +1 -0
- package/dist/hyper-html-api.engine.min.js +1 -1
- package/dist/hyper-html-api.min.js +8 -8
- package/dist/hyper-html-api.upgrade.min.js +2 -2
- package/package.json +6 -5
- package/src/adapters/cheerio.js +10 -2
- package/src/adapters/dom.js +44 -2
- package/src/cms/form-builder.js +1 -1
- package/src/cms/scaffold.js +71 -3
- package/src/engine/apply.js +7 -14
- package/src/engine/diff.js +172 -5
- package/src/engine/dom-properties.js +16 -0
- package/src/engine/errors.js +20 -2
- package/src/engine/extract.js +1 -1
- package/src/engine/index.js +1 -0
- package/src/engine/round-trip.js +171 -0
- package/src/engine/rule-syntax.js +71 -0
- package/src/engine/write-policy.js +3 -0
- package/src/write.js +37 -5
package/src/engine/diff.js
CHANGED
|
@@ -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
|
|
51
|
-
|
|
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,
|
|
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,
|
|
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)
|
|
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
|
+
}
|
package/src/engine/errors.js
CHANGED
|
@@ -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
|
-
|
|
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`)
|
package/src/engine/extract.js
CHANGED
package/src/engine/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
37
|
+
apply(guarded, root, rules, data, opts)
|
|
35
38
|
guarded.assertClean()
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|