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.
@@ -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`)
@@ -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
- function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
31
- if (source.nodeType === 1 && (
32
- (inherit
33
- ? hasCapability(source, capability)
34
- : matchesWithin(source, capabilityMatch, boundary, false)) ||
35
- (exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
36
- )) return null
37
- const copy = targetDocument.importNode(source, false)
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
- const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
41
- const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
42
- if (sourceChildren !== source) {
43
- maps.cloneToLive.set(copyChildren, sourceChildren)
44
- maps.liveToClone.set(sourceChildren, copyChildren)
45
- }
46
- for (const child of sourceChildren.childNodes || []) {
47
- const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
48
- if (childCopy) {
49
- copyChildren.appendChild(childCopy)
50
- if (child.nodeType === 1) copyControlState(child, childCopy)
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 root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
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)
@@ -23,6 +23,7 @@ export const POLICY_TOKENS = Object.freeze([
23
23
  'no-data',
24
24
  'freeze',
25
25
  'editor-ui',
26
+ 'live',
26
27
  ])
27
28
 
28
29
  export function hasPolicyToken(element, token) {
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
+ }