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.
- package/README.md +10 -0
- package/dist/hyper-html-api.engine.min.js +1 -1
- package/dist/hyper-html-api.min.js +4 -4
- package/dist/hyper-html-api.upgrade.min.js +2 -2
- package/package.json +4 -3
- package/packed-contract.json +1 -0
- package/src/adapters/cheerio.js +4 -0
- package/src/adapters/dom.js +191 -0
- package/src/dom-api.js +17 -6
- package/src/engine/apply.js +28 -4
- package/src/engine/diff.js +67 -54
- package/src/engine/extract.js +29 -3
- package/src/engine/index.js +1 -1
- package/src/engine/row-match.js +155 -0
- package/src/lib/content-dom.js +110 -0
- package/src/lib/region-capabilities.js +71 -0
package/src/engine/diff.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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) {
|
package/src/engine/extract.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/engine/index.js
CHANGED
|
@@ -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
|
+
}
|