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.
- 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 -6
- package/dist/hyper-html-api.upgrade.min.js +2 -2
- package/package.json +6 -5
- package/src/adapters/cheerio.js +22 -2
- package/src/adapters/dom.js +91 -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 +221 -25
- 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/row-match.js +16 -10
- package/src/engine/rule-syntax.js +71 -0
- package/src/engine/splice.js +14 -0
- package/src/engine/write-policy.js +3 -0
- package/src/lib/content-dom.js +58 -28
- package/src/lib/region-capabilities.js +1 -0
- package/src/write.js +37 -5
package/src/engine/splice.js
CHANGED
|
@@ -54,6 +54,20 @@ export function trackAdapter(adapter) {
|
|
|
54
54
|
if (from) mark(from)
|
|
55
55
|
return adapter.insertAt(parent, node, index)
|
|
56
56
|
},
|
|
57
|
+
insertBefore(ref, node) {
|
|
58
|
+
const p = adapter.parent(ref)
|
|
59
|
+
if (p) mark(p)
|
|
60
|
+
const from = adapter.parent(node)
|
|
61
|
+
if (from) mark(from)
|
|
62
|
+
return adapter.insertBefore(ref, node)
|
|
63
|
+
},
|
|
64
|
+
insertAfter(ref, node) {
|
|
65
|
+
const p = adapter.parent(ref)
|
|
66
|
+
if (p) mark(p)
|
|
67
|
+
const from = adapter.parent(node)
|
|
68
|
+
if (from) mark(from)
|
|
69
|
+
return adapter.insertAfter(ref, node)
|
|
70
|
+
},
|
|
57
71
|
remove(node) {
|
|
58
72
|
const p = adapter.parent(node)
|
|
59
73
|
if (p) mark(p)
|
|
@@ -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/lib/content-dom.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED from clayjs/src/lib/content-dom.js. Edit ClayJS and run `npm run propagate:foundation`.
|
|
2
2
|
|
|
3
|
-
import { capabilitySelector, hasCapability } from './region-capabilities.js'
|
|
3
|
+
import { capabilitySelector, hasCapability, CAPABILITIES, expandsTo, hasPolicyToken } from './region-capabilities.js'
|
|
4
4
|
|
|
5
5
|
const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
|
|
6
6
|
|
|
@@ -9,10 +9,9 @@ function copyControlState(source, copy) {
|
|
|
9
9
|
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
10
10
|
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
|
|
11
11
|
if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
|
|
12
|
+
// Each surviving option carries its own selection, so a <select> whose excluded
|
|
13
|
+
// options were removed from the copy keeps the rest in step with the page.
|
|
12
14
|
if (source.tagName === 'OPTION') copy.selected = source.selected
|
|
13
|
-
if (source.tagName === 'SELECT') {
|
|
14
|
-
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
15
|
-
}
|
|
16
15
|
if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
|
|
17
16
|
}
|
|
18
17
|
|
|
@@ -27,46 +26,77 @@ function matchesWithin(node, selector, boundary, inherit) {
|
|
|
27
26
|
return false
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
// One native deep clone, then a lockstep walk of the two isomorphic trees that
|
|
30
|
+
// records provenance, collects controls, and marks excluded subtrees for removal.
|
|
31
|
+
// The browser's clone is far cheaper than importing node by node. The root answers
|
|
32
|
+
// for its ancestors once; below it the walk is top-down, so with inheritance each
|
|
33
|
+
// element answers only for its own markers: an excluded ancestor is never entered.
|
|
34
|
+
function excluded(source, capability, capabilityMatch, exclude, boundary, inherit) {
|
|
35
|
+
return (inherit
|
|
36
|
+
? hasCapability(source, capability)
|
|
37
|
+
: matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
38
|
+
!!(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The test closestWithCapability makes at each level, for this element alone.
|
|
42
|
+
function hasOwnCapability(element, capability) {
|
|
43
|
+
const definition = CAPABILITIES[capability]
|
|
44
|
+
return definition.tokens.some(token => expandsTo(element, token)) ||
|
|
45
|
+
definition.bundles.some(bundle => hasPolicyToken(element, bundle))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'])
|
|
49
|
+
|
|
50
|
+
function pairTree(source, copy, maps, drop) {
|
|
51
|
+
if (source.nodeType === 1 && drop(source)) { maps.dropped.push(copy); return }
|
|
38
52
|
maps.cloneToLive.set(copy, source)
|
|
39
53
|
maps.liveToClone.set(source, copy)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
maps.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
copyChildren.
|
|
50
|
-
|
|
54
|
+
let sourceChildren = source
|
|
55
|
+
let copyChildren = copy
|
|
56
|
+
if (source.nodeType === 1) {
|
|
57
|
+
if (CONTROL_TAGS.has(source.tagName)) maps.controls.push(source, copy)
|
|
58
|
+
if (source.tagName === 'TEMPLATE') {
|
|
59
|
+
// The deep clone also copied the template's own child nodes, which are not its
|
|
60
|
+
// content and were never part of the view.
|
|
61
|
+
while (copy.firstChild) copy.removeChild(copy.firstChild)
|
|
62
|
+
sourceChildren = source.content
|
|
63
|
+
copyChildren = copy.content
|
|
64
|
+
maps.cloneToLive.set(copyChildren, sourceChildren)
|
|
65
|
+
maps.liveToClone.set(sourceChildren, copyChildren)
|
|
51
66
|
}
|
|
67
|
+
} else if (source.nodeType !== 11) return
|
|
68
|
+
let c = copyChildren.firstChild
|
|
69
|
+
for (let s = sourceChildren.firstChild; s && c; s = s.nextSibling) {
|
|
70
|
+
pairTree(s, c, maps, drop)
|
|
71
|
+
c = c.nextSibling
|
|
52
72
|
}
|
|
53
|
-
if (source.nodeType === 1) copyControlState(source, copy)
|
|
54
|
-
return copy
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
|
|
75
|
+
export function createContentView(context, { capability = 'data', exclude = null, inherit = true, provenance = null } = {}) {
|
|
58
76
|
if (!context) throw new TypeError('createContentView requires a DOM context')
|
|
59
77
|
const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
|
|
60
78
|
if (!sourceDocument?.implementation?.createHTMLDocument) {
|
|
61
79
|
throw new TypeError('createContentView requires an HTML DOM implementation')
|
|
62
80
|
}
|
|
63
81
|
const inertDocument = sourceDocument.implementation.createHTMLDocument('')
|
|
64
|
-
const cloneToLive = new WeakMap()
|
|
82
|
+
const cloneToLive = provenance || new WeakMap()
|
|
65
83
|
const liveToClone = new WeakMap()
|
|
66
84
|
const sourceRoot = context.nodeType === 9 ? context.documentElement : context
|
|
67
85
|
if (exclude) inertDocument.documentElement.matches(exclude)
|
|
68
86
|
const capabilityMatch = capabilitySelector(capability)
|
|
69
|
-
const
|
|
87
|
+
const drop = inherit
|
|
88
|
+
? (el) => hasOwnCapability(el, capability) || !!(exclude && el.matches(exclude))
|
|
89
|
+
: (el) => excluded(el, capability, capabilityMatch, exclude, sourceRoot, false)
|
|
90
|
+
let root = null
|
|
91
|
+
if (sourceRoot.nodeType !== 1 || !excluded(sourceRoot, capability, capabilityMatch, exclude, sourceRoot, inherit)) {
|
|
92
|
+
root = inertDocument.importNode(sourceRoot, true)
|
|
93
|
+
const maps = { cloneToLive, liveToClone, dropped: [], controls: [] }
|
|
94
|
+
pairTree(sourceRoot, root, maps, drop)
|
|
95
|
+
for (const node of maps.dropped) node.remove()
|
|
96
|
+
// Control state is copied once the copy holds only what the view keeps, so a
|
|
97
|
+
// <select> sees exactly the options the old node-by-node import gave it.
|
|
98
|
+
for (let i = 0; i < maps.controls.length; i += 2) copyControlState(maps.controls[i], maps.controls[i + 1])
|
|
99
|
+
}
|
|
70
100
|
if (context.nodeType === 9 && root) {
|
|
71
101
|
inertDocument.replaceChild(root, inertDocument.documentElement)
|
|
72
102
|
liveToClone.set(context, inertDocument)
|
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
|
+
}
|