hyper-html-api 0.9.1 → 0.10.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.
Files changed (61) hide show
  1. package/README.md +18 -5
  2. package/conformance/MANIFEST.json +2 -2
  3. package/conformance/cases/write-query-attribute.after.html +1 -0
  4. package/conformance/cases/write-query-attribute.after.json +3 -0
  5. package/conformance/cases/write-query-attribute.data.json +1 -0
  6. package/conformance/cases/write-query-attribute.html +1 -0
  7. package/conformance/cases/write-query-attribute.meta +4 -0
  8. package/conformance/cases/write-query-attribute.parsed.json +3 -0
  9. package/conformance/cases/write-query-attribute.rules +1 -0
  10. package/conformance/cases/write-query-attribute.write.json +4 -0
  11. package/conformance/cases/write-query-list.after.html +1 -0
  12. package/conformance/cases/write-query-list.after.json +10 -0
  13. package/conformance/cases/write-query-list.data.json +1 -0
  14. package/conformance/cases/write-query-list.html +1 -0
  15. package/conformance/cases/write-query-list.meta +6 -0
  16. package/conformance/cases/write-query-list.parsed.json +8 -0
  17. package/conformance/cases/write-query-list.rules +1 -0
  18. package/conformance/cases/write-query-list.write.json +4 -0
  19. package/conformance/cases/write-query-noop.after.html +1 -0
  20. package/conformance/cases/write-query-noop.after.json +3 -0
  21. package/conformance/cases/write-query-noop.data.json +1 -0
  22. package/conformance/cases/write-query-noop.html +1 -0
  23. package/conformance/cases/write-query-noop.meta +4 -0
  24. package/conformance/cases/write-query-noop.parsed.json +3 -0
  25. package/conformance/cases/write-query-noop.rules +1 -0
  26. package/conformance/cases/write-query-noop.write.json +4 -0
  27. package/conformance/cases/write-query-override.after.html +1 -0
  28. package/conformance/cases/write-query-override.after.json +3 -0
  29. package/conformance/cases/write-query-override.data.json +1 -0
  30. package/conformance/cases/write-query-override.html +1 -0
  31. package/conformance/cases/write-query-override.meta +4 -0
  32. package/conformance/cases/write-query-override.parsed.json +3 -0
  33. package/conformance/cases/write-query-override.rules +1 -0
  34. package/conformance/cases/write-query-override.write.json +4 -0
  35. package/conformance/cases/write-query-refused.data.json +1 -0
  36. package/conformance/cases/write-query-refused.error.json +11 -0
  37. package/conformance/cases/write-query-refused.html +1 -0
  38. package/conformance/cases/write-query-refused.meta +4 -0
  39. package/conformance/cases/write-query-refused.parsed.json +4 -0
  40. package/conformance/cases/write-query-refused.rules +1 -0
  41. package/conformance/cases/write-query-text.after.html +1 -0
  42. package/conformance/cases/write-query-text.after.json +3 -0
  43. package/conformance/cases/write-query-text.data.json +1 -0
  44. package/conformance/cases/write-query-text.html +1 -0
  45. package/conformance/cases/write-query-text.meta +4 -0
  46. package/conformance/cases/write-query-text.parsed.json +3 -0
  47. package/conformance/cases/write-query-text.rules +1 -0
  48. package/conformance/cases/write-query-text.write.json +4 -0
  49. package/dist/hyper-html-api.engine.min.js +1 -1
  50. package/dist/hyper-html-api.min.js +8 -6
  51. package/dist/hyper-html-api.upgrade.min.js +2 -2
  52. package/package.json +2 -2
  53. package/src/adapters/cheerio.js +12 -0
  54. package/src/adapters/dom.js +47 -0
  55. package/src/dom-api.js +149 -23
  56. package/src/engine/diff.js +52 -20
  57. package/src/engine/row-match.js +16 -10
  58. package/src/engine/splice.js +14 -0
  59. package/src/lib/content-dom.js +61 -31
  60. package/src/lib/region-capabilities.js +1 -0
  61. package/src/write.js +20 -10
@@ -1,18 +1,17 @@
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
 
7
7
  function copyControlState(source, copy) {
8
8
  if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
9
9
  const type = (source.getAttribute?.('type') || '').toLowerCase()
10
- if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
11
- if ('checked' in source && 'checked' in copy) copy.checked = source.checked
12
- if ('selected' in source && 'selected' in copy) 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
- }
10
+ if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
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.
14
+ if (source.tagName === 'OPTION') copy.selected = source.selected
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
@@ -4,19 +4,29 @@ import { findRulesIn } from './engine/rules-tag.js'
4
4
  import { guardAdapter } from './engine/write-policy.js'
5
5
  import { planWrite } from './engine/write-plan.js'
6
6
  import { trackAdapter, spliceDocument } from './engine/splice.js'
7
- import { NoRulesTag, WriteRejected } from './engine/errors.js'
7
+ import { NoRulesTag, RulesParseError, WriteRejected } from './engine/errors.js'
8
8
 
9
- // Applies a JSON body to a document's source through the document's own rules
10
- // tag, under the content-only policy. `load` is cheerio.load, passed in so this
11
- // package keeps cheerio out of its browser bundles. Returns the new source and
12
- // whether anything changed. Throws NoRulesTag, WriteRejected, WriteRefused,
13
- // ShapeMismatch or EmptyListInsert, always before producing any output.
14
- export function writeDocument(load, src, data, { token = 'api' } = {}) {
9
+ // Applies a JSON body to a document's source through caller-provided rules
10
+ // (options.rules) or the document's own rules tag, under the content-only
11
+ // policy. `load` is cheerio.load, passed in so this package keeps cheerio out
12
+ // 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
+ export function writeDocument(load, src, data, options = {}) {
16
+ const { token = 'api' } = options
15
17
  const $ = load(src, { sourceCodeLocationInfo: true })
16
18
  const root = $.root()
17
- const found = findRulesIn(cheerioAdapter, root, token)
18
- if (!found) throw new NoRulesTag(token)
19
- const { rules } = found
19
+ let rules
20
+ if (Object.prototype.hasOwnProperty.call(options, 'rules')) {
21
+ rules = options.rules
22
+ if (rules === null || !['string', 'object'].includes(typeof rules)) {
23
+ throw new RulesParseError('Invalid extraction rules: expected a selector, array, or object.')
24
+ }
25
+ } else {
26
+ const found = findRulesIn(cheerioAdapter, root, token)
27
+ if (!found) throw new NoRulesTag(token)
28
+ rules = found.rules
29
+ }
20
30
  const plan = planWrite(cheerioAdapter, root, rules, data)
21
31
  if (plan.unknownKeys.length || plan.unmatched.length) throw new WriteRejected(plan.unknownKeys, plan.unmatched)
22
32
  const tracker = trackAdapter(cheerioAdapter)