hyper-html-api 0.6.5 → 0.8.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,7 +1,6 @@
1
1
  import { EmptyListInsert } from './errors.js'
2
2
  import { extract } from './extract.js'
3
-
4
- const SIMILARITY_THRESHOLD = 0.5
3
+ import { matchRows } from './row-match.js'
5
4
 
6
5
  /**
7
6
  * Reconcile `newItems` (array of incoming items) into the DOM subtree matched
@@ -16,6 +15,8 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
16
15
 
17
16
  if (newItems.length === 0) {
18
17
  oldNodes.forEach((n) => adapter.remove(n))
18
+ // Still once per list: a consumer keyed by path has to learn it settled empty.
19
+ callHook(opts, 'onRowsApplied', trace.path, [])
19
20
  return
20
21
  }
21
22
 
@@ -28,8 +29,11 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
28
29
 
29
30
  const oldValues = oldNodes.map((n) => extractItem(adapter, n, shape, opts))
30
31
 
32
+ // Only a list that grew can need a clone, because matchRows pairs every row
33
+ // it can. Cloning unconditionally made a plain edit strip the ids off a
34
+ // template it never used, and warn about it, on every keystroke.
31
35
  let template = null
32
- if (templateSource) {
36
+ if (needsTemplate && templateSource) {
33
37
  template = adapter.clone(templateSource)
34
38
  if (opts.templateAttr) adapter.removeAttr(template, opts.templateAttr)
35
39
  const stripped = adapter.stripIds(template)
@@ -41,7 +45,7 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
41
45
  }
42
46
  }
43
47
 
44
- const matches = greedyMatch(newItems, oldValues, shape)
48
+ const matches = matchRows(newItems, oldValues, shape, lockSuppliedRows(adapter, oldNodes, newItems, trace, opts))
45
49
 
46
50
  const referenceNode = oldNodes[0] || templateSource
47
51
  const parent = adapter.parent(referenceNode)
@@ -83,19 +87,61 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
83
87
  if (currentIdx === targetIdx) return
84
88
  adapter.insertAt(parent, node, targetIdx)
85
89
  })
86
- writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts)
87
- return
90
+ } else {
91
+ // The matched nodes are not a contiguous run of siblings, so this list has
92
+ // no DOM order to restore: any repositioning would move elements the list
93
+ // does not own. Anchoring every node to parent(oldNodes[0]) is what
94
+ // reparents a cross-parent list into its first container and compacts past
95
+ // unowned siblings, and both destroy content on a write that changed
96
+ // nothing. Leave every surviving node exactly where the author put it and
97
+ // only place the grown ones.
98
+ placeGrownItems(adapter, finalNodes, fresh, parent, anchorIdx)
88
99
  }
89
100
 
90
- // The matched nodes are not a contiguous run of siblings, so this list has no
91
- // DOM order to restore: any repositioning would move elements the list does
92
- // not own. Anchoring every node to parent(oldNodes[0]) is what reparents a
93
- // cross-parent list into its first container and compacts past unowned
94
- // siblings, and both destroy content on a write that changed nothing. Leave
95
- // every surviving node exactly where the author put it and only place the
96
- // grown ones.
97
- placeGrownItems(adapter, finalNodes, fresh, parent, anchorIdx)
98
101
  writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts)
102
+
103
+ // writeItems can replace a node (@outerHTML on the item itself) and updates
104
+ // finalNodes in place, so this reports the nodes the list actually ended with.
105
+ callHook(opts, 'onRowsApplied', trace.path, finalNodes)
106
+ }
107
+
108
+ // Consumer code runs in the middle of an engine write, and onRowsApplied runs
109
+ // after the list is already written. Letting it throw left the page written and
110
+ // the apply reported as failed, which in hypercms meant an error banner, a stale
111
+ // fingerprint, and a page that no longer matched it. A hook is an optional
112
+ // report; a broken one is the consumer's bug to see, not a reason to abandon a
113
+ // finished write.
114
+ function callHook(opts, name, path, arg) {
115
+ if (typeof opts[name] !== 'function') return undefined
116
+ try {
117
+ return opts[name](path.slice(), arg)
118
+ } catch (err) {
119
+ // eslint-disable-next-line no-console
120
+ console.warn(`[hyper-html-api] ${name} threw at "${path.join('.') || '(root)'}"`, err)
121
+ return undefined
122
+ }
123
+ }
124
+
125
+ // A caller that already knows which node each incoming item belongs to can say
126
+ // so, and content matching cannot tell two byte-identical rows apart however
127
+ // good it gets. `identifyRows` returns an array parallel to newItems of
128
+ // Node | null. A node that is not in this list right now is ignored, so a stale
129
+ // handle degrades to matching rather than corrupting the list.
130
+ function lockSuppliedRows(adapter, oldNodes, newItems, trace, opts) {
131
+ const supplied = callHook(opts, 'identifyRows', trace.path, newItems)
132
+ if (!Array.isArray(supplied)) return null
133
+ const locked = new Array(newItems.length).fill(-1)
134
+ const taken = new Set()
135
+ for (let i = 0; i < newItems.length; i++) {
136
+ if (!supplied[i]) continue
137
+ for (let j = 0; j < oldNodes.length; j++) {
138
+ if (taken.has(j) || !adapter.sameNode(oldNodes[j], supplied[i])) continue
139
+ locked[i] = j
140
+ taken.add(j)
141
+ break
142
+ }
143
+ }
144
+ return locked
99
145
  }
100
146
 
101
147
  // Apply per-item content. Skip when the existing value already matches
@@ -120,46 +166,13 @@ function writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts
120
166
 
121
167
  function extractItem(adapter, node, shape, opts) {
122
168
  if (shape === null) return adapter.text(node)
123
- return extract(adapter, node, shape, opts)
124
- }
125
-
126
- function greedyMatch(newItems, oldValues, shape) {
127
- const matches = new Array(newItems.length).fill(-1)
128
- const taken = new Set()
129
- newItems.forEach((newItem, i) => {
130
- let bestIdx = -1
131
- let bestScore = -1
132
- oldValues.forEach((oldVal, j) => {
133
- if (taken.has(j)) return
134
- const score = similarity(newItem, oldVal, shape)
135
- const closerIdx =
136
- score === bestScore && bestIdx >= 0
137
- ? Math.abs(j - i) < Math.abs(bestIdx - i)
138
- : false
139
- if (score > bestScore || closerIdx) {
140
- bestScore = score
141
- bestIdx = j
142
- }
143
- })
144
- if (bestScore >= SIMILARITY_THRESHOLD) {
145
- matches[i] = bestIdx
146
- taken.add(bestIdx)
147
- }
148
- })
149
- return matches
150
- }
151
-
152
- function similarity(a, b, shape) {
153
- if (shape === null) return a === b ? 1 : 0
154
- const fields = Object.keys(shape || {})
155
- if (fields.length === 0) return 0
156
- let equal = 0
157
- for (const f of fields) {
158
- if (JSON.stringify(a == null ? undefined : a[f]) === JSON.stringify(b == null ? undefined : b[f])) {
159
- equal++
160
- }
161
- }
162
- return equal / fields.length
169
+ // This is a per-row read whose path restarts at the row, so a caller's
170
+ // onRowsRead would see a nested list under a truncated path and bind it to the
171
+ // wrong parent. The read hook belongs to a top-level extract only, and
172
+ // engine.bind() hands one opts object to both halves, so the caller cannot
173
+ // always keep them apart. Drop it here instead of documenting a rule the API
174
+ // does not let everyone follow.
175
+ return extract(adapter, node, shape, opts.onRowsRead ? { ...opts, onRowsRead: undefined } : opts)
163
176
  }
164
177
 
165
178
  function indexInParent(adapter, parent, targetNode) {
@@ -3,17 +3,27 @@ import { MaxRuleDepthExceeded, MAX_RULE_DEPTH } from './errors.js'
3
3
  import { ruleAttrIndex } from './rule-syntax.js'
4
4
 
5
5
  export function extract(adapter, root, rules, opts = {}) {
6
+ if (Object.prototype.hasOwnProperty.call(opts, 'exclude') && adapter.semanticExclude === false) {
7
+ throw new Error('This adapter does not support semantic exclude queries; omit exclude or use the DOM adapter.')
8
+ }
6
9
  return extractAt(adapter, root, rules, { depth: 0, path: [] }, opts)
7
10
  }
8
11
 
9
12
  function extractAt(adapter, ctx, rule, trace, opts) {
10
13
  if (trace.depth > MAX_RULE_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
11
14
 
12
- if (typeof rule === 'string') return extractScalar(adapter, ctx, rule, opts)
15
+ if (typeof rule === 'string') return extractScalar(adapter, ctx, rule, trace, opts)
13
16
 
14
17
  if (Array.isArray(rule)) {
15
18
  const [selector, shape] = rule
16
19
  const matches = adapter.find(ctx, selector, opts)
20
+ // The read-side mirror of apply's onRowsApplied: it tells a caller which
21
+ // node each item came from, so identity can be established before anything
22
+ // is written. Only pass this to a TOP-LEVEL extract. listDiff extracts each
23
+ // existing row through this same function with a fresh path, so an opts
24
+ // carrying this hook into apply() would report nested lists under a
25
+ // truncated path.
26
+ reportRows(opts, trace, matches)
17
27
  return matches.map((node, i) =>
18
28
  extractAt(adapter, node, shape, {
19
29
  depth: trace.depth + 1,
@@ -36,10 +46,14 @@ function extractAt(adapter, ctx, rule, trace, opts) {
36
46
  return null
37
47
  }
38
48
 
39
- function extractScalar(adapter, ctx, rule, opts) {
49
+ function extractScalar(adapter, ctx, rule, trace, opts) {
40
50
  if (rule.endsWith('[]')) {
41
51
  const selector = rule.slice(0, -2)
42
- return adapter.find(ctx, selector, opts).map((n) => adapter.text(n))
52
+ const matches = adapter.find(ctx, selector, opts)
53
+ // A scalar list is still a list, and its rows still need identity. Reported
54
+ // here as well as in the tuple branch, because this one returns before it.
55
+ reportRows(opts, trace, matches)
56
+ return matches.map((n) => adapter.text(n))
43
57
  }
44
58
 
45
59
  if (rule.startsWith('@')) {
@@ -61,6 +75,18 @@ function extractScalar(adapter, ctx, rule, opts) {
61
75
  return matches.length === 0 ? null : adapter.text(matches[0])
62
76
  }
63
77
 
78
+ // The path is copied because the caller is handed it and every nested path is
79
+ // built from the same array. A throwing hook must not abandon the extract.
80
+ function reportRows(opts, trace, nodes) {
81
+ if (typeof opts.onRowsRead !== 'function') return
82
+ try {
83
+ opts.onRowsRead(trace.path.slice(), nodes)
84
+ } catch (err) {
85
+ // eslint-disable-next-line no-console
86
+ console.warn(`[hyper-html-api] onRowsRead threw at "${trace.path.join('.') || '(root)'}"`, err)
87
+ }
88
+ }
89
+
64
90
  function readPropOrAttr(adapter, node, name) {
65
91
  if (DOM_PROPERTIES_SET.has(name)) {
66
92
  const v = adapter.prop(node, name)
@@ -8,7 +8,7 @@ export { findRulesIn } from './rules-tag.js'
8
8
  export { parseStrict, parseRelaxed } from './rules.js'
9
9
  export { ruleAttrIndex, splitRule } from './rule-syntax.js'
10
10
  export * as errors from './errors.js'
11
- export { DOM_PROPERTIES, DOM_PROPERTIES_SET } from './dom-properties.js'
11
+ export { DOM_PROPERTIES, DOM_PROPERTIES_SET, DOM_PROPERTIES_READ_ONLY_SET } from './dom-properties.js'
12
12
 
13
13
  // source: object literal → use as-is (no tag); string → look up by token.
14
14
  // For an element root, search the whole document (root.ownerDocument) so a
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Map incoming list items onto the existing rows a list already has.
3
+ *
4
+ * A list arrives as plain JSON with no ids, so identity has to be inferred.
5
+ * The rule this file follows is that a list creates a node only when it grows
6
+ * and destroys one only when it shrinks: `matchRows` always pairs
7
+ * min(newItems.length, oldValues.length) rows. Content decides WHICH old row a
8
+ * new item is, never WHETHER it is one, because the field a person is editing
9
+ * is the same field that would have to prove who the row is.
10
+ *
11
+ * Two passes:
12
+ * A. anchors, content appearing exactly once on each side is unambiguous and
13
+ * matches wherever it moved to (this is what carries a reorder)
14
+ * B. everything left over, aligned in order at minimum cost, so surviving
15
+ * rows keep their relative order and edits stay put
16
+ */
17
+
18
+ export function matchRows(newItems, oldValues, shape, locked) {
19
+ const n = newItems.length
20
+ const m = oldValues.length
21
+ const matches = new Array(n).fill(-1)
22
+ // A caller that knows which old row an item is keeps that pairing; the passes
23
+ // below only fill in what it did not answer for. Range-checked and deduped so
24
+ // the min(n, m) guarantee in the header holds for any `locked`, not only the
25
+ // sanitised one lockSuppliedRows produces.
26
+ if (locked) {
27
+ const seen = new Set()
28
+ for (let i = 0; i < n; i++) {
29
+ const j = locked[i]
30
+ if (!(j >= 0 && j < m) || seen.has(j)) continue
31
+ matches[i] = j
32
+ seen.add(j)
33
+ }
34
+ }
35
+ if (n === 0 || m === 0) return matches
36
+
37
+ const newKeys = newItems.map((item) => itemKey(item, shape))
38
+ const oldKeys = oldValues.map((item) => itemKey(item, shape))
39
+
40
+ const takenOld = new Array(m).fill(false)
41
+ for (const j of matches) if (j >= 0) takenOld[j] = true
42
+
43
+ // Uniqueness is counted over the rows still in play, never over the locked
44
+ // ones. A value can be duplicated across the whole list and still be the only
45
+ // unlocked row of its kind, and that is exactly the case a lock should make
46
+ // decidable: locking one of two identical rows leaves the other unambiguous.
47
+ // Counting the locked rows too left it looking as ambiguous as before, and
48
+ // since alignInOrder only ever produces order-preserving pairs, losing the
49
+ // anchor loses the ability to express a crossing move at all.
50
+ const oldByKey = new Map()
51
+ oldKeys.forEach((key, j) => {
52
+ if (takenOld[j]) return
53
+ oldByKey.set(key, oldByKey.has(key) ? -1 : j)
54
+ })
55
+ const newCounts = new Map()
56
+ newKeys.forEach((key, i) => {
57
+ if (matches[i] >= 0) return
58
+ newCounts.set(key, (newCounts.get(key) || 0) + 1)
59
+ })
60
+
61
+ newKeys.forEach((key, i) => {
62
+ if (matches[i] >= 0) return
63
+ if (newCounts.get(key) !== 1) return
64
+ const j = oldByKey.get(key)
65
+ if (j === undefined || j === -1 || takenOld[j]) return
66
+ matches[i] = j
67
+ takenOld[j] = true
68
+ })
69
+
70
+ const leftNew = []
71
+ for (let i = 0; i < n; i++) if (matches[i] < 0) leftNew.push(i)
72
+ const leftOld = []
73
+ for (let j = 0; j < m; j++) if (!takenOld[j]) leftOld.push(j)
74
+ if (leftNew.length === 0 || leftOld.length === 0) return matches
75
+
76
+ // Changed content outranks displacement: the scale is larger than any total
77
+ // displacement this list can produce, so position only ever breaks a tie.
78
+ const scale = n * m + 1
79
+ const cost = (i, j) => differingFields(newItems[i], oldValues[j], shape) * scale + Math.abs(i - j)
80
+ for (const [i, j] of alignInOrder(leftNew, leftOld, cost)) matches[i] = j
81
+ return matches
82
+ }
83
+
84
+ // An item is an object only when its shape is one. Both `".r[]"` and
85
+ // `[".r", "."]` produce string items, and Object.keys('.') is ["0"], which
86
+ // compared the first CHARACTER of two strings as though it were a field.
87
+ const isObjectShape = (shape) => typeof shape === 'object' && shape !== null
88
+
89
+ function itemKey(item, shape) {
90
+ if (!isObjectShape(shape)) return item == null ? ' null' : String(item)
91
+ const fields = Object.keys(shape)
92
+ return JSON.stringify(
93
+ fields.map((f) => {
94
+ const encoded = JSON.stringify(item == null ? undefined : item[f])
95
+ return encoded === undefined ? ' undef' : encoded
96
+ }),
97
+ )
98
+ }
99
+
100
+ function differingFields(a, b, shape) {
101
+ if (!isObjectShape(shape)) return a === b ? 0 : 1
102
+ const fields = Object.keys(shape)
103
+ if (fields.length === 0) return 0
104
+ let differing = 0
105
+ for (const f of fields) {
106
+ const av = JSON.stringify(a == null ? undefined : a[f])
107
+ const bv = JSON.stringify(b == null ? undefined : b[f])
108
+ if (av !== bv) differing++
109
+ }
110
+ return differing
111
+ }
112
+
113
+ // Pair every entry of the shorter list with one of the longer, keeping both in
114
+ // order and minimising total cost. Returns [newIndex, oldIndex] pairs.
115
+ function alignInOrder(newIdx, oldIdx, cost) {
116
+ if (newIdx.length <= oldIdx.length) return align(newIdx, oldIdx, cost, false)
117
+ return align(oldIdx, newIdx, (j, i) => cost(i, j), true)
118
+ }
119
+
120
+ function align(short, long, cost, flipped) {
121
+ const s = short.length
122
+ const l = long.length
123
+ const table = []
124
+ for (let i = 0; i <= s; i++) table.push(new Float64Array(l + 1).fill(Infinity))
125
+ const takeHere = []
126
+ for (let i = 0; i <= s; i++) takeHere.push(new Uint8Array(l + 1))
127
+ for (let j = 0; j <= l; j++) table[s][j] = 0
128
+
129
+ for (let i = s - 1; i >= 0; i--) {
130
+ for (let j = l - 1; j >= 0; j--) {
131
+ const take = cost(short[i], long[j]) + table[i + 1][j + 1]
132
+ const skip = table[i][j + 1]
133
+ if (take <= skip) {
134
+ table[i][j] = take
135
+ takeHere[i][j] = 1
136
+ } else {
137
+ table[i][j] = skip
138
+ }
139
+ }
140
+ }
141
+
142
+ const pairs = []
143
+ let i = 0
144
+ let j = 0
145
+ while (i < s && j < l) {
146
+ if (takeHere[i][j]) {
147
+ pairs.push(flipped ? [long[j], short[i]] : [short[i], long[j]])
148
+ i++
149
+ j++
150
+ } else {
151
+ j++
152
+ }
153
+ }
154
+ return pairs
155
+ }
@@ -0,0 +1,110 @@
1
+ // GENERATED from clayjs/src/lib/content-dom.js. Edit ClayJS and run `npm run propagate:foundation`.
2
+
3
+ import { capabilitySelector, hasCapability } from './region-capabilities.js'
4
+
5
+ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
6
+
7
+ function copyControlState(source, copy) {
8
+ if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
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
+ }
16
+ if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
17
+ }
18
+
19
+ function matchesWithin(node, selector, boundary, inherit) {
20
+ if (inherit) return !!node.closest?.(selector)
21
+ let current = node
22
+ while (current?.nodeType === 1) {
23
+ if (current.matches(selector)) return true
24
+ if (current === boundary) break
25
+ current = current.parentElement
26
+ }
27
+ return false
28
+ }
29
+
30
+ function importTree(source, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps) {
31
+ if (source.nodeType === 1 && (
32
+ (inherit ? hasCapability(source, capability) : matchesWithin(source, capabilityMatch, boundary, false)) ||
33
+ (exclude && matchesWithin(source, exclude, boundary, inherit))
34
+ )) return null
35
+ const copy = targetDocument.importNode(source, false)
36
+ maps.cloneToLive.set(copy, source)
37
+ maps.liveToClone.set(source, copy)
38
+ const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
39
+ const copyChildren = copy.nodeType === 1 && copy.tagName === 'TEMPLATE' ? copy.content : copy
40
+ if (sourceChildren !== source) {
41
+ maps.cloneToLive.set(copyChildren, sourceChildren)
42
+ maps.liveToClone.set(sourceChildren, copyChildren)
43
+ }
44
+ for (const child of sourceChildren.childNodes || []) {
45
+ const childCopy = importTree(child, targetDocument, capability, capabilityMatch, exclude, boundary, inherit, maps)
46
+ if (childCopy) {
47
+ copyChildren.appendChild(childCopy)
48
+ if (child.nodeType === 1) copyControlState(child, childCopy)
49
+ }
50
+ }
51
+ if (source.nodeType === 1) copyControlState(source, copy)
52
+ return copy
53
+ }
54
+
55
+ export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
56
+ if (!context) throw new TypeError('createContentView requires a DOM context')
57
+ const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
58
+ if (!sourceDocument?.implementation?.createHTMLDocument) {
59
+ throw new TypeError('createContentView requires an HTML DOM implementation')
60
+ }
61
+ const inertDocument = sourceDocument.implementation.createHTMLDocument('')
62
+ const cloneToLive = new WeakMap()
63
+ const liveToClone = new WeakMap()
64
+ const sourceRoot = context.nodeType === 9 ? context.documentElement : context
65
+ if (exclude) inertDocument.documentElement.matches(exclude)
66
+ const capabilityMatch = capabilitySelector(capability)
67
+ const root = importTree(sourceRoot, inertDocument, capability, capabilityMatch, exclude, sourceRoot, inherit, { cloneToLive, liveToClone })
68
+ if (context.nodeType === 9 && root) {
69
+ inertDocument.replaceChild(root, inertDocument.documentElement)
70
+ liveToClone.set(context, inertDocument)
71
+ cloneToLive.set(inertDocument, context)
72
+ }
73
+
74
+ const assertSelector = selector => {
75
+ if (UNSUPPORTED_SELECTOR.test(selector)) {
76
+ throw new Error(`Filtered content queries do not support stateful selector: ${selector}`)
77
+ }
78
+ }
79
+
80
+ return {
81
+ root,
82
+ document: inertDocument,
83
+ capability,
84
+ selector: capabilitySelector(capability),
85
+ cloneToLive,
86
+ liveToClone,
87
+ original(node) { return cloneToLive.get(node) || null },
88
+ cloneOf(node) { return liveToClone.get(node) || null },
89
+ query(selector, queryRoot = root) {
90
+ assertSelector(selector)
91
+ return Array.from(queryRoot.querySelectorAll(selector), node => cloneToLive.get(node) || node)
92
+ },
93
+ text(node = root) {
94
+ const projected = node === root ? root : liveToClone.get(node)
95
+ return projected?.textContent || ''
96
+ },
97
+ html(node = root) {
98
+ const projected = node === root ? root : liveToClone.get(node)
99
+ return projected?.innerHTML ?? ''
100
+ },
101
+ clone(node = root) {
102
+ const projected = node === root ? root : liveToClone.get(node)
103
+ return projected ? inertDocument.importNode(projected, true) : null
104
+ },
105
+ }
106
+ }
107
+
108
+ export function cleanContentClone(node, options) {
109
+ return createContentView(node, options).root
110
+ }
@@ -0,0 +1,71 @@
1
+ // GENERATED from clayjs/src/lib/region-capabilities.js. Edit ClayJS and run `npm run propagate:foundation`.
2
+
3
+ export const CAPABILITIES = Object.freeze({
4
+ data: Object.freeze({ tokens: ['no-data'], bundles: ['editor-ui'] }),
5
+ save: Object.freeze({ tokens: ['no-save'], bundles: ['editor-ui'] }),
6
+ snapshot: Object.freeze({ tokens: ['no-snapshot'], bundles: ['editor-ui'] }),
7
+ watch: Object.freeze({ tokens: ['no-watch'], bundles: ['editor-ui'] }),
8
+ undo: Object.freeze({ tokens: ['no-undo'], bundles: ['editor-ui'] }),
9
+ history: Object.freeze({ tokens: [], bundles: ['editor-ui'] }),
10
+ })
11
+
12
+ export const BUNDLES = Object.freeze({
13
+ 'editor-ui': Object.freeze(['no-data', 'no-save', 'no-snapshot', 'no-watch', 'no-undo']),
14
+ })
15
+
16
+ export const POLICY_TOKENS = Object.freeze([
17
+ 'no-save',
18
+ 'no-snapshot',
19
+ 'no-trigger-autosave',
20
+ 'no-dirty',
21
+ 'no-watch',
22
+ 'no-undo',
23
+ 'no-data',
24
+ 'freeze',
25
+ 'editor-ui',
26
+ ])
27
+
28
+ export function hasPolicyToken(element, token) {
29
+ if (!element || element.nodeType !== 1) return false
30
+ const clay = element.getAttribute?.('clay')
31
+ return !!(
32
+ (clay && clay.split(/\s+/).includes(token)) ||
33
+ element.hasAttribute?.(token)
34
+ )
35
+ }
36
+
37
+ export function expandsTo(element, token) {
38
+ if (hasPolicyToken(element, token)) return true
39
+ for (const [bundle, members] of Object.entries(BUNDLES)) {
40
+ if (members.includes(token) && hasPolicyToken(element, bundle)) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ export function capabilitySelector(capability) {
46
+ const definition = CAPABILITIES[capability]
47
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
48
+ return [...definition.tokens, ...definition.bundles]
49
+ .flatMap(token => [`[clay~="${token}"]`, `[${token}]`])
50
+ .join(', ')
51
+ }
52
+
53
+ export function closestWithCapability(node, capability) {
54
+ let element = node && node.nodeType === 1 ? node : node?.parentElement
55
+ while (element && element.nodeType === 1) {
56
+ const definition = CAPABILITIES[capability]
57
+ if (!definition) throw new Error(`Unknown region capability: ${capability}`)
58
+ if (definition.tokens.some(token => expandsTo(element, token)) ||
59
+ definition.bundles.some(bundle => hasPolicyToken(element, bundle))) return element
60
+ element = element.parentElement
61
+ }
62
+ return null
63
+ }
64
+
65
+ export function hasCapability(node, capability) {
66
+ return !!closestWithCapability(node, capability)
67
+ }
68
+
69
+ export function isPolicyAttribute(name) {
70
+ return name === 'clay' || POLICY_TOKENS.includes(name)
71
+ }