hyper-html-api 0.1.0 → 0.3.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/README.md +22 -8
- package/dist/hyper-html-api.engine.min.js +1 -0
- package/dist/hyper-html-api.min.js +3 -2
- package/package.json +11 -5
- package/src/adapters/cheerio.js +47 -7
- package/src/adapters/dom.js +31 -6
- package/src/cms/form-builder.js +431 -0
- package/src/cms/index.js +4 -0
- package/src/cms/morph.js +44 -0
- package/src/cms/path.js +105 -0
- package/src/cms/scaffold.js +24 -0
- package/src/cms/widget-handles.js +17 -0
- package/src/data.js +10 -0
- package/src/dom-api.js +60 -0
- package/src/engine/apply.js +63 -27
- package/src/engine/diff.js +54 -16
- package/src/engine/dom-properties.js +53 -6
- package/src/engine/errors.js +11 -1
- package/src/engine/extract.js +16 -16
- package/src/engine/index.js +34 -0
- package/src/engine/rules-tag.js +28 -6
- package/src/engine/rules.js +24 -5
- package/src/hyper-html-api.js +13 -17
package/src/dom-api.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as engineMod from './engine/index.js'
|
|
2
|
+
import domAdapter from './adapters/dom.js'
|
|
3
|
+
|
|
4
|
+
// The DOM-bound engine: every engine call pre-wired with the DOM adapter. Lives
|
|
5
|
+
// here (instead of inline in hyper-html-api.js) so both the full CDN bundle and
|
|
6
|
+
// the lean `data.js` entry share one definition.
|
|
7
|
+
export const engine = {
|
|
8
|
+
extract: (root, rules, opts) => engineMod.extract(domAdapter, root, rules, opts),
|
|
9
|
+
apply: (root, rules, data, opts) => engineMod.apply(domAdapter, root, rules, data, opts),
|
|
10
|
+
findRulesIn: (root, token) => engineMod.findRulesIn(domAdapter, root, token),
|
|
11
|
+
findRules: (root, source) => engineMod.resolveRules(domAdapter, root, source),
|
|
12
|
+
bind: (root, source, opts) => engineMod.bind(domAdapter, root, source, opts),
|
|
13
|
+
parseStrict: engineMod.parseStrict,
|
|
14
|
+
parseRelaxed: engineMod.parseRelaxed,
|
|
15
|
+
errors: engineMod.errors,
|
|
16
|
+
DOM_PROPERTIES: engineMod.DOM_PROPERTIES,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isNode = (x) => !!x && typeof x.nodeType === 'number'
|
|
20
|
+
|
|
21
|
+
// Autodetect the page's single rules tag when no `source` is given.
|
|
22
|
+
// engine.findRulesIn(root) (no token) silently takes the FIRST of many tags, so
|
|
23
|
+
// the sugar counts the tags itself to enforce the one-tag rule with clear,
|
|
24
|
+
// distinct errors for "none" vs "multiple".
|
|
25
|
+
function autodetect(root) {
|
|
26
|
+
const searchRoot = (root && root.ownerDocument) ? root.ownerDocument : root
|
|
27
|
+
const tags = domAdapter.find(searchRoot, 'script[data-rules-name]', { includeRulesTag: true })
|
|
28
|
+
if (tags.length === 0) {
|
|
29
|
+
throw new Error('hyper-html-api: no rules tag found. Add <script type="application/json" data-rules-name="…" data-rules-version="1"> or pass rules.')
|
|
30
|
+
}
|
|
31
|
+
if (tags.length > 1) {
|
|
32
|
+
throw new Error('hyper-html-api: multiple rules tags found; pass a name, e.g. extractData("api").')
|
|
33
|
+
}
|
|
34
|
+
// findRulesIn (no token) returns the one tag's rules and enforces data-rules-version="1".
|
|
35
|
+
return engineMod.findRulesIn(domAdapter, searchRoot).rules
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// extractData() → document + autodetect
|
|
39
|
+
// extractData(el) → el + autodetect
|
|
40
|
+
// extractData('api' | {title:'h1'}) → document + named/inline rules
|
|
41
|
+
// extractData(el, 'api' | {...}) → explicit root + source
|
|
42
|
+
// One-shot read; returns the extracted data.
|
|
43
|
+
export function extractData(a, b) {
|
|
44
|
+
const root = isNode(a) ? a : document
|
|
45
|
+
const source = isNode(a) ? b : a
|
|
46
|
+
if (source === undefined) return engineMod.extract(domAdapter, root, autodetect(root))
|
|
47
|
+
return engine.bind(root, source).get()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// applyData(root, data, source?) — writes `data` INTO the live DOM under `root`,
|
|
51
|
+
// using a named/inline `source` or (when omitted) the page's single rules tag.
|
|
52
|
+
// MUTATES the DOM and returns `root`.
|
|
53
|
+
export function applyData(root, data, source) {
|
|
54
|
+
if (!isNode(root)) {
|
|
55
|
+
throw new Error('hyper-html-api: applyData(root, data, source?) needs a DOM root as the first argument.')
|
|
56
|
+
}
|
|
57
|
+
if (source === undefined) engineMod.apply(domAdapter, root, autodetect(root), data)
|
|
58
|
+
else engine.bind(root, source).set(data)
|
|
59
|
+
return root
|
|
60
|
+
}
|
package/src/engine/apply.js
CHANGED
|
@@ -1,47 +1,63 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
DOM_PROPERTIES_WRITE_SET,
|
|
3
|
+
DOM_PROPERTIES_READ_ONLY_SET,
|
|
4
|
+
} from './dom-properties.js'
|
|
5
|
+
import {
|
|
6
|
+
MaxRuleDepthExceeded,
|
|
7
|
+
MAX_RULE_DEPTH,
|
|
8
|
+
RuleTargetReadOnly,
|
|
9
|
+
ShapeMismatch,
|
|
10
|
+
} from './errors.js'
|
|
3
11
|
import { listDiff } from './diff.js'
|
|
4
12
|
|
|
5
|
-
const MAX_DEPTH = 20
|
|
6
|
-
|
|
7
13
|
const BOOLEAN_PROPS = new Set(['checked', 'selected', 'disabled', 'readOnly', 'paused'])
|
|
8
14
|
|
|
9
|
-
export function apply(adapter, root, rules, data) {
|
|
15
|
+
export function apply(adapter, root, rules, data, opts = {}) {
|
|
10
16
|
const mismatches = []
|
|
11
17
|
validateShape(rules, data, [], mismatches)
|
|
12
18
|
if (mismatches.length) throw new ShapeMismatch(mismatches)
|
|
13
19
|
|
|
14
|
-
applyAt(adapter, root, rules, data, { depth: 0, path: [] })
|
|
20
|
+
applyAt(adapter, root, rules, data, { depth: 0, path: [] }, opts)
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
// applyAt returns the (possibly new) ctx node. Most rules don't change
|
|
24
|
+
// ctx, but writes targeting `@outerHTML` on ctx itself replace the node;
|
|
25
|
+
// downstream code (object-rule sub-walks, listDiff) needs to refresh its
|
|
26
|
+
// pointer from the return value.
|
|
27
|
+
export function applyAt(adapter, ctx, rule, value, trace, opts = {}) {
|
|
28
|
+
if (trace.depth > MAX_RULE_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
|
|
29
|
+
if (value === undefined) return ctx
|
|
20
30
|
|
|
21
|
-
if (typeof rule === 'string') return applyScalar(adapter, ctx, rule, value, trace)
|
|
31
|
+
if (typeof rule === 'string') return applyScalar(adapter, ctx, rule, value, trace, opts)
|
|
22
32
|
|
|
23
33
|
if (Array.isArray(rule)) {
|
|
24
34
|
const [selector, shape] = rule
|
|
25
|
-
|
|
35
|
+
listDiff(adapter, ctx, selector, shape, value, trace, applyAt, opts)
|
|
36
|
+
return ctx
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
if (typeof rule === 'object' && rule !== null) {
|
|
29
40
|
for (const [key, sub] of Object.entries(rule)) {
|
|
30
|
-
applyAt(
|
|
41
|
+
const newCtx = applyAt(
|
|
31
42
|
adapter,
|
|
32
43
|
ctx,
|
|
33
44
|
sub,
|
|
34
45
|
value == null ? value : value[key],
|
|
35
46
|
{ depth: trace.depth + 1, path: [...trace.path, key] },
|
|
47
|
+
opts,
|
|
36
48
|
)
|
|
49
|
+
if (newCtx && newCtx !== ctx) ctx = newCtx
|
|
37
50
|
}
|
|
51
|
+
return ctx
|
|
38
52
|
}
|
|
53
|
+
return ctx
|
|
39
54
|
}
|
|
40
55
|
|
|
41
|
-
function applyScalar(adapter, ctx, rule, value, trace) {
|
|
56
|
+
function applyScalar(adapter, ctx, rule, value, trace, opts) {
|
|
42
57
|
if (rule.endsWith('[]')) {
|
|
43
58
|
const selector = rule.slice(0, -2)
|
|
44
|
-
|
|
59
|
+
listDiff(adapter, ctx, selector, null, value, trace, applyAt, opts)
|
|
60
|
+
return ctx
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
if (rule.startsWith('@')) {
|
|
@@ -49,28 +65,43 @@ function applyScalar(adapter, ctx, rule, value, trace) {
|
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
if (rule.includes('@')) {
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
const at = rule.lastIndexOf('@')
|
|
69
|
+
const selector = rule.slice(0, at)
|
|
70
|
+
const name = rule.slice(at + 1)
|
|
71
|
+
const matches = selector ? adapter.find(ctx, selector, opts) : [ctx]
|
|
72
|
+
if (matches.length === 0) return ctx
|
|
73
|
+
writePropOrAttr(adapter, matches[0], name, value)
|
|
74
|
+
return ctx
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
if (rule === '.') {
|
|
59
78
|
adapter.text(ctx, value == null ? '' : String(value))
|
|
60
|
-
return
|
|
79
|
+
return ctx
|
|
61
80
|
}
|
|
62
81
|
|
|
63
|
-
const matches = adapter.find(ctx, rule)
|
|
64
|
-
if (matches.length === 0) return
|
|
82
|
+
const matches = adapter.find(ctx, rule, opts)
|
|
83
|
+
if (matches.length === 0) return ctx
|
|
65
84
|
adapter.text(matches[0], value == null ? '' : String(value))
|
|
85
|
+
return ctx
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
// Returns the (possibly new) node. For outerHTML, the original is detached
|
|
89
|
+
// and a freshly parsed element takes its place; that new node is returned.
|
|
90
|
+
// For every other write, returns the original node unchanged.
|
|
68
91
|
function writePropOrAttr(adapter, node, name, value) {
|
|
69
|
-
if (
|
|
92
|
+
if (DOM_PROPERTIES_READ_ONLY_SET.has(name)) {
|
|
93
|
+
throw new RuleTargetReadOnly(name)
|
|
94
|
+
}
|
|
95
|
+
if (name === 'outerHTML') {
|
|
96
|
+
const html = value == null ? '' : String(value)
|
|
97
|
+
return adapter.replaceWith(node, html)
|
|
98
|
+
}
|
|
99
|
+
if (DOM_PROPERTIES_WRITE_SET.has(name)) {
|
|
70
100
|
adapter.prop(node, name, coercePropValue(name, value))
|
|
71
|
-
|
|
72
|
-
adapter.attr(node, name, value == null ? '' : String(value))
|
|
101
|
+
return node
|
|
73
102
|
}
|
|
103
|
+
adapter.attr(node, name, value == null ? '' : String(value))
|
|
104
|
+
return node
|
|
74
105
|
}
|
|
75
106
|
|
|
76
107
|
function coercePropValue(name, value) {
|
|
@@ -82,7 +113,10 @@ function coercePropValue(name, value) {
|
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
function validateShape(rule, value, path, mismatches) {
|
|
85
|
-
|
|
116
|
+
// undefined = key omitted by caller; treat as "skip this rule entirely".
|
|
117
|
+
// null / '' are valid for scalar rules (clears the value), but NOT for
|
|
118
|
+
// list or object rules — those mismatch explicitly.
|
|
119
|
+
if (value === undefined) return
|
|
86
120
|
|
|
87
121
|
if (typeof rule === 'string') {
|
|
88
122
|
if (rule.endsWith('[]')) {
|
|
@@ -101,7 +135,9 @@ function validateShape(rule, value, path, mismatches) {
|
|
|
101
135
|
}
|
|
102
136
|
return
|
|
103
137
|
}
|
|
104
|
-
|
|
138
|
+
// Scalar rule. null / '' are valid (clears text/attribute). Reject
|
|
139
|
+
// only nested objects/arrays.
|
|
140
|
+
if (value !== null && typeof value === 'object') {
|
|
105
141
|
mismatches.push({ path: pathStr(path), expected: 'scalar', got: typeofX(value) })
|
|
106
142
|
}
|
|
107
143
|
return
|
|
@@ -118,7 +154,7 @@ function validateShape(rule, value, path, mismatches) {
|
|
|
118
154
|
}
|
|
119
155
|
|
|
120
156
|
if (typeof rule === 'object' && rule !== null) {
|
|
121
|
-
if (Array.isArray(value) || typeof value !== 'object') {
|
|
157
|
+
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
|
122
158
|
mismatches.push({ path: pathStr(path), expected: 'object', got: typeofX(value) })
|
|
123
159
|
return
|
|
124
160
|
}
|
package/src/engine/diff.js
CHANGED
|
@@ -11,8 +11,8 @@ const SIMILARITY_THRESHOLD = 0.5
|
|
|
11
11
|
* applyItem is passed in (normally `applyAt` from apply.js) to avoid a
|
|
12
12
|
* circular import; object-list items use it to recursively apply their shape.
|
|
13
13
|
*/
|
|
14
|
-
export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, applyItem) {
|
|
15
|
-
const oldNodes = adapter.find(parentCtx, selector)
|
|
14
|
+
export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, applyItem, opts = {}) {
|
|
15
|
+
const oldNodes = adapter.find(parentCtx, selector, opts)
|
|
16
16
|
|
|
17
17
|
if (newItems.length === 0) {
|
|
18
18
|
oldNodes.forEach((n) => adapter.remove(n))
|
|
@@ -20,13 +20,18 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const needsTemplate = newItems.length > oldNodes.length
|
|
23
|
-
|
|
23
|
+
let templateSource = oldNodes[0] || null
|
|
24
|
+
if (needsTemplate && !templateSource) {
|
|
25
|
+
templateSource = findFallbackTemplate(adapter, parentCtx, selector, opts)
|
|
26
|
+
if (!templateSource) throw new EmptyListInsert(trace.path)
|
|
27
|
+
}
|
|
24
28
|
|
|
25
|
-
const oldValues = oldNodes.map((n) => extractItem(adapter, n, shape))
|
|
29
|
+
const oldValues = oldNodes.map((n) => extractItem(adapter, n, shape, opts))
|
|
26
30
|
|
|
27
31
|
let template = null
|
|
28
|
-
if (
|
|
29
|
-
template = adapter.clone(
|
|
32
|
+
if (templateSource) {
|
|
33
|
+
template = adapter.clone(templateSource)
|
|
34
|
+
if (opts.templateAttr) adapter.removeAttr(template, opts.templateAttr)
|
|
30
35
|
const stripped = adapter.stripIds(template)
|
|
31
36
|
if (stripped > 0) {
|
|
32
37
|
// eslint-disable-next-line no-console
|
|
@@ -38,8 +43,11 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
|
|
|
38
43
|
|
|
39
44
|
const matches = greedyMatch(newItems, oldValues, shape)
|
|
40
45
|
|
|
41
|
-
const
|
|
42
|
-
const
|
|
46
|
+
const referenceNode = oldNodes[0] || templateSource
|
|
47
|
+
const parent = adapter.parent(referenceNode)
|
|
48
|
+
const anchorIdx = oldNodes.length > 0
|
|
49
|
+
? indexInParent(adapter, parent, referenceNode)
|
|
50
|
+
: 0
|
|
43
51
|
|
|
44
52
|
const used = new Set()
|
|
45
53
|
const finalNodes = newItems.map((_, i) => {
|
|
@@ -53,32 +61,46 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
|
|
|
53
61
|
return cloned
|
|
54
62
|
})
|
|
55
63
|
|
|
64
|
+
// Remove only unmatched old nodes. Matched nodes stay attached so DOM
|
|
65
|
+
// identity (focus, observers, animations) survives the reorder.
|
|
56
66
|
oldNodes.forEach((n, i) => {
|
|
57
67
|
if (!used.has(i)) adapter.remove(n)
|
|
58
68
|
})
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
// Place each final node at its target index. If the node is already
|
|
71
|
+
// there, do nothing (no-op apply on unchanged data drops zero state).
|
|
72
|
+
// Otherwise insertAt moves an attached node (DOM/cheerio both treat
|
|
73
|
+
// insertBefore on an attached node as "move to here").
|
|
62
74
|
finalNodes.forEach((node, i) => {
|
|
63
|
-
|
|
75
|
+
const targetIdx = anchorIdx + i
|
|
76
|
+
const siblings = adapter.children(parent)
|
|
77
|
+
const currentIdx = siblings.findIndex((s) => adapter.sameNode(s, node))
|
|
78
|
+
if (currentIdx === targetIdx) return
|
|
79
|
+
adapter.insertAt(parent, node, targetIdx)
|
|
64
80
|
})
|
|
65
81
|
|
|
82
|
+
// Apply per-item content. Skip when the existing value already matches
|
|
83
|
+
// — saves a write on no-op applies and avoids spurious mutation events.
|
|
66
84
|
finalNodes.forEach((node, i) => {
|
|
67
85
|
if (shape === null) {
|
|
68
86
|
const v = newItems[i]
|
|
69
|
-
|
|
87
|
+
const target = v == null ? '' : String(v)
|
|
88
|
+
if (adapter.text(node) !== target) adapter.text(node, target)
|
|
70
89
|
} else {
|
|
71
|
-
applyItem
|
|
90
|
+
// applyItem may replace the node (e.g. @outerHTML on the item itself);
|
|
91
|
+
// capture the return so finalNodes stays current for later passes.
|
|
92
|
+
const newNode = applyItem(adapter, node, shape, newItems[i], {
|
|
72
93
|
depth: trace.depth + 1,
|
|
73
94
|
path: [...trace.path, i],
|
|
74
|
-
})
|
|
95
|
+
}, opts)
|
|
96
|
+
if (newNode && newNode !== node) finalNodes[i] = newNode
|
|
75
97
|
}
|
|
76
98
|
})
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
function extractItem(adapter, node, shape) {
|
|
101
|
+
function extractItem(adapter, node, shape, opts) {
|
|
80
102
|
if (shape === null) return adapter.text(node)
|
|
81
|
-
return extract(adapter, node, shape)
|
|
103
|
+
return extract(adapter, node, shape, opts)
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
function greedyMatch(newItems, oldValues, shape) {
|
|
@@ -127,3 +149,19 @@ function indexInParent(adapter, parent, targetNode) {
|
|
|
127
149
|
}
|
|
128
150
|
return -1
|
|
129
151
|
}
|
|
152
|
+
|
|
153
|
+
// Look for a template-marked node matching the selector. Walks up from
|
|
154
|
+
// parentCtx so a template defined once in an ancestor (e.g. on a sibling
|
|
155
|
+
// product's variant) is found even when the immediate container has none.
|
|
156
|
+
function findFallbackTemplate(adapter, parentCtx, selector, opts) {
|
|
157
|
+
if (!opts.templateAttr) return null
|
|
158
|
+
let scope = parentCtx
|
|
159
|
+
while (scope) {
|
|
160
|
+
const candidates = adapter.find(scope, selector, { includeRulesTag: false })
|
|
161
|
+
for (const n of candidates) {
|
|
162
|
+
if (adapter.attr(n, opts.templateAttr) != null) return n
|
|
163
|
+
}
|
|
164
|
+
scope = adapter.parent(scope)
|
|
165
|
+
}
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Names that are read
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Names that are read via the DOM/cheerio .prop() interface rather than
|
|
3
|
+
* .attr(). The original allowlist is preserved for read parity with the
|
|
4
|
+
* legacy hyperclay/server-lib/data-extractor.js, with two exceptions
|
|
5
|
+
* (`href`, `src`, `action`) that are now READ via .attr() too so the
|
|
6
|
+
* round-trip stays byte-stable (writes always go through .attr() for these
|
|
7
|
+
* — see DOM_PROPERTIES_WRITE_SET).
|
|
5
8
|
*/
|
|
6
9
|
export const DOM_PROPERTIES = [
|
|
7
10
|
'textContent',
|
|
@@ -26,9 +29,6 @@ export const DOM_PROPERTIES = [
|
|
|
26
29
|
'className',
|
|
27
30
|
'classList',
|
|
28
31
|
|
|
29
|
-
'href',
|
|
30
|
-
'src',
|
|
31
|
-
'action',
|
|
32
32
|
'baseURI',
|
|
33
33
|
|
|
34
34
|
'offsetWidth',
|
|
@@ -50,3 +50,50 @@ export const DOM_PROPERTIES = [
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
export const DOM_PROPERTIES_SET = new Set(DOM_PROPERTIES)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subset of DOM_PROPERTIES that is safe to WRITE via .prop(). Everything
|
|
56
|
+
* else routes through .attr() (literal attribute) or throws
|
|
57
|
+
* RuleTargetReadOnly. Notably absent: href/src/action (URL normalization)
|
|
58
|
+
* and outerHTML (handled via adapter.replaceWith).
|
|
59
|
+
*/
|
|
60
|
+
export const DOM_PROPERTIES_WRITE_SET = new Set([
|
|
61
|
+
'textContent',
|
|
62
|
+
'innerText',
|
|
63
|
+
'innerHTML',
|
|
64
|
+
'value',
|
|
65
|
+
'checked',
|
|
66
|
+
'selected',
|
|
67
|
+
'disabled',
|
|
68
|
+
'readOnly',
|
|
69
|
+
'type',
|
|
70
|
+
'id',
|
|
71
|
+
'className',
|
|
72
|
+
'title',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Props that throw on write (DOM enforces read-only, our engine catches
|
|
77
|
+
* before reaching the adapter so the error type is consistent).
|
|
78
|
+
*/
|
|
79
|
+
export const DOM_PROPERTIES_READ_ONLY_SET = new Set([
|
|
80
|
+
'tagName',
|
|
81
|
+
'nodeName',
|
|
82
|
+
'nodeType',
|
|
83
|
+
'nodeValue',
|
|
84
|
+
'childElementCount',
|
|
85
|
+
'classList',
|
|
86
|
+
'baseURI',
|
|
87
|
+
'documentURI',
|
|
88
|
+
'contentType',
|
|
89
|
+
'offsetWidth',
|
|
90
|
+
'offsetHeight',
|
|
91
|
+
'clientWidth',
|
|
92
|
+
'clientHeight',
|
|
93
|
+
'scrollWidth',
|
|
94
|
+
'scrollHeight',
|
|
95
|
+
'currentSrc',
|
|
96
|
+
'duration',
|
|
97
|
+
'paused',
|
|
98
|
+
'dataset',
|
|
99
|
+
])
|
package/src/engine/errors.js
CHANGED
|
@@ -14,9 +14,11 @@ export class UnknownRulesVersion extends Error {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export const MAX_RULE_DEPTH = 20
|
|
18
|
+
|
|
17
19
|
export class MaxRuleDepthExceeded extends Error {
|
|
18
20
|
constructor(path) {
|
|
19
|
-
super(`rule depth exceeded
|
|
21
|
+
super(`rule depth exceeded ${MAX_RULE_DEPTH} at path: ${path.join('.')}`)
|
|
20
22
|
this.name = 'MaxRuleDepthExceeded'
|
|
21
23
|
this.path = path
|
|
22
24
|
}
|
|
@@ -40,6 +42,14 @@ export class EmptyListInsert extends Error {
|
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
export class RuleTargetReadOnly extends Error {
|
|
46
|
+
constructor(name) {
|
|
47
|
+
super(`cannot write to read-only DOM property "${name}"`)
|
|
48
|
+
this.name = 'RuleTargetReadOnly'
|
|
49
|
+
this.target = name
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
export class UpgradeAlreadyRegistered extends Error {
|
|
44
54
|
constructor() {
|
|
45
55
|
super('upgrade transform already registered; only one registration is allowed per page.')
|
package/src/engine/extract.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { DOM_PROPERTIES_SET } from './dom-properties.js'
|
|
2
|
-
import { MaxRuleDepthExceeded } from './errors.js'
|
|
2
|
+
import { MaxRuleDepthExceeded, MAX_RULE_DEPTH } from './errors.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export function extract(adapter, root, rules) {
|
|
7
|
-
return extractAt(adapter, root, rules, { depth: 0, path: [] })
|
|
4
|
+
export function extract(adapter, root, rules, opts = {}) {
|
|
5
|
+
return extractAt(adapter, root, rules, { depth: 0, path: [] }, opts)
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
function extractAt(adapter, ctx, rule, trace) {
|
|
11
|
-
if (trace.depth >
|
|
8
|
+
function extractAt(adapter, ctx, rule, trace, opts) {
|
|
9
|
+
if (trace.depth > MAX_RULE_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
|
|
12
10
|
|
|
13
|
-
if (typeof rule === 'string') return extractScalar(adapter, ctx, rule)
|
|
11
|
+
if (typeof rule === 'string') return extractScalar(adapter, ctx, rule, opts)
|
|
14
12
|
|
|
15
13
|
if (Array.isArray(rule)) {
|
|
16
14
|
const [selector, shape] = rule
|
|
17
|
-
const matches = adapter.find(ctx, selector)
|
|
15
|
+
const matches = adapter.find(ctx, selector, opts)
|
|
18
16
|
return matches.map((node, i) =>
|
|
19
17
|
extractAt(adapter, node, shape, {
|
|
20
18
|
depth: trace.depth + 1,
|
|
21
19
|
path: [...trace.path, i],
|
|
22
|
-
}),
|
|
20
|
+
}, opts),
|
|
23
21
|
)
|
|
24
22
|
}
|
|
25
23
|
|
|
@@ -29,7 +27,7 @@ function extractAt(adapter, ctx, rule, trace) {
|
|
|
29
27
|
result[key] = extractAt(adapter, ctx, sub, {
|
|
30
28
|
depth: trace.depth + 1,
|
|
31
29
|
path: [...trace.path, key],
|
|
32
|
-
})
|
|
30
|
+
}, opts)
|
|
33
31
|
}
|
|
34
32
|
return result
|
|
35
33
|
}
|
|
@@ -37,10 +35,10 @@ function extractAt(adapter, ctx, rule, trace) {
|
|
|
37
35
|
return null
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
function extractScalar(adapter, ctx, rule) {
|
|
38
|
+
function extractScalar(adapter, ctx, rule, opts) {
|
|
41
39
|
if (rule.endsWith('[]')) {
|
|
42
40
|
const selector = rule.slice(0, -2)
|
|
43
|
-
return adapter.find(ctx, selector).map((n) => adapter.text(n))
|
|
41
|
+
return adapter.find(ctx, selector, opts).map((n) => adapter.text(n))
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
if (rule.startsWith('@')) {
|
|
@@ -48,15 +46,17 @@ function extractScalar(adapter, ctx, rule) {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
if (rule.includes('@')) {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
49
|
+
const at = rule.lastIndexOf('@')
|
|
50
|
+
const selector = rule.slice(0, at)
|
|
51
|
+
const name = rule.slice(at + 1)
|
|
52
|
+
const matches = selector ? adapter.find(ctx, selector, opts) : [ctx]
|
|
53
53
|
if (matches.length === 0) return null
|
|
54
54
|
return readPropOrAttr(adapter, matches[0], name)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
if (rule === '.') return adapter.text(ctx)
|
|
58
58
|
|
|
59
|
-
const matches = adapter.find(ctx, rule)
|
|
59
|
+
const matches = adapter.find(ctx, rule, opts)
|
|
60
60
|
return matches.length === 0 ? null : adapter.text(matches[0])
|
|
61
61
|
}
|
|
62
62
|
|
package/src/engine/index.js
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
|
+
import { extract } from './extract.js'
|
|
2
|
+
import { apply } from './apply.js'
|
|
3
|
+
import { findRulesIn } from './rules-tag.js'
|
|
4
|
+
|
|
1
5
|
export { extract } from './extract.js'
|
|
2
6
|
export { apply } from './apply.js'
|
|
3
7
|
export { findRulesIn } from './rules-tag.js'
|
|
4
8
|
export { parseStrict, parseRelaxed } from './rules.js'
|
|
5
9
|
export * as errors from './errors.js'
|
|
6
10
|
export { DOM_PROPERTIES, DOM_PROPERTIES_SET } from './dom-properties.js'
|
|
11
|
+
|
|
12
|
+
// source: object literal → use as-is (no tag); string → look up by token.
|
|
13
|
+
// For an element root, search the whole document (root.ownerDocument) so a
|
|
14
|
+
// head-mounted tag is found; get/set still scope to the passed root.
|
|
15
|
+
// For a Document root or the cheerio $.root(), ownerDocument is absent → no-op.
|
|
16
|
+
export function resolveRules(adapter, root, source) {
|
|
17
|
+
if (source && typeof source === 'object') {
|
|
18
|
+
return { rules: source, tagNode: null }
|
|
19
|
+
}
|
|
20
|
+
if (typeof source === 'string') {
|
|
21
|
+
const searchRoot = (root && root.ownerDocument) ? root.ownerDocument : root
|
|
22
|
+
return findRulesIn(adapter, searchRoot, source)
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function bind(adapter, root, source, opts) {
|
|
28
|
+
const found = resolveRules(adapter, root, source)
|
|
29
|
+
if (!found) {
|
|
30
|
+
const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
|
|
31
|
+
throw new Error(`hyper-html-api: could not resolve rules for ${what}`)
|
|
32
|
+
}
|
|
33
|
+
const { rules, tagNode } = found
|
|
34
|
+
return {
|
|
35
|
+
rules,
|
|
36
|
+
tagNode, // informational only; null for a literal object
|
|
37
|
+
get: () => extract(adapter, root, rules, opts), // opts (skip/templateAttr) forwarded
|
|
38
|
+
set: (data) => apply(adapter, root, rules, data, opts),
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/engine/rules-tag.js
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseRelaxed } from './rules.js'
|
|
2
2
|
import { UnknownRulesVersion } from './errors.js'
|
|
3
3
|
|
|
4
|
-
const RULES_TAG_ID = 'hyper-html-api'
|
|
5
4
|
const SUPPORTED_VERSION = '1'
|
|
5
|
+
const TOKEN_RE = /^[a-zA-Z0-9_-]+$/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// findRulesIn(adapter, root) → first script[data-rules-name] (tooling/no-token)
|
|
8
|
+
// findRulesIn(adapter, root, token) → first script[data-rules-name~="token"]
|
|
9
|
+
export function findRulesIn(adapter, root, token) {
|
|
10
|
+
let selector
|
|
11
|
+
if (token === undefined) {
|
|
12
|
+
selector = 'script[data-rules-name]'
|
|
13
|
+
} else {
|
|
14
|
+
// Validate BEFORE interpolating into the selector (rejects, never sanitizes).
|
|
15
|
+
// The charset also forecloses selector injection through the ~= match.
|
|
16
|
+
if (typeof token !== 'string' || !TOKEN_RE.test(token)) {
|
|
17
|
+
throw new Error(`hyper-html-api: invalid rules token ${JSON.stringify(token)} (must match ${TOKEN_RE})`)
|
|
18
|
+
}
|
|
19
|
+
selector = `script[data-rules-name~="${token}"]`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// includeRulesTag:true is REQUIRED: isRulesTag now matches any data-rules-name
|
|
23
|
+
// script, so without the flag adapter.find would filter out the very tag we select.
|
|
24
|
+
const candidates = adapter.find(root, selector, { includeRulesTag: true })
|
|
9
25
|
if (candidates.length === 0) return null
|
|
26
|
+
if (token !== undefined && candidates.length > 1) {
|
|
27
|
+
console.warn(`hyper-html-api: ${candidates.length} rules tags match data-rules-name~="${token}"; using the first.`)
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
const tagNode = candidates[0]
|
|
12
31
|
const version = adapter.attr(tagNode, 'data-rules-version')
|
|
13
32
|
if (version !== SUPPORTED_VERSION) throw new UnknownRulesVersion(version)
|
|
14
33
|
|
|
15
|
-
|
|
16
|
-
|
|
34
|
+
// Script tag bodies accept the same relaxed JSON syntax as the ?data=
|
|
35
|
+
// URL parameter: unquoted keys, single-quoted strings, trailing commas.
|
|
36
|
+
// parseRelaxed tries strict JSON.parse first, so valid JSON keeps
|
|
37
|
+
// parsing identically.
|
|
38
|
+
const rules = parseRelaxed(adapter.text(tagNode))
|
|
17
39
|
return { rules, tagNode }
|
|
18
40
|
}
|
package/src/engine/rules.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { RulesParseError } from './errors.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Strict JSON parser for
|
|
4
|
+
* Strict JSON parser. Public export kept for callers that explicitly want
|
|
5
|
+
* strict semantics (no unquoted keys, no single quotes, no trailing
|
|
6
|
+
* commas). The script-tag path uses parseRelaxed instead — see
|
|
7
|
+
* rules-tag.js.
|
|
5
8
|
*/
|
|
6
9
|
export function parseStrict(body) {
|
|
7
10
|
try {
|
|
8
11
|
return JSON.parse(body)
|
|
9
12
|
} catch (e) {
|
|
10
13
|
throw new RulesParseError(
|
|
11
|
-
|
|
12
|
-
'Relaxed JSON (unquoted keys, single quotes, trailing commas) is only supported in the ?data= URL parameter. ' +
|
|
13
|
-
`Original error: ${e.message}`,
|
|
14
|
+
`Invalid strict JSON: ${e.message}`,
|
|
14
15
|
e,
|
|
15
16
|
)
|
|
16
17
|
}
|
|
@@ -106,6 +107,7 @@ export function parseRelaxed(queryString) {
|
|
|
106
107
|
type: TokenType.STRING,
|
|
107
108
|
value: input.substring(i + 1, j),
|
|
108
109
|
quoted: true,
|
|
110
|
+
sourceQuote: quote,
|
|
109
111
|
})
|
|
110
112
|
i = j + 1
|
|
111
113
|
continue
|
|
@@ -210,12 +212,29 @@ export function parseRelaxed(queryString) {
|
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
if (token.type === TokenType.COMMA) {
|
|
215
|
+
// Drop trailing commas. JSON.parse rejects them; relaxed authors
|
|
216
|
+
// expect them to work.
|
|
217
|
+
const next = tokens[i + 1]
|
|
218
|
+
if (next && (next.type === '}' || next.type === ']')) {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
213
221
|
result += token.value
|
|
214
222
|
continue
|
|
215
223
|
}
|
|
216
224
|
|
|
217
225
|
if (token.type === TokenType.STRING && token.quoted) {
|
|
218
|
-
|
|
226
|
+
let v = token.value
|
|
227
|
+
if (token.sourceQuote === "'") {
|
|
228
|
+
// Single-quoted source: \' was escaping the outer quote in the
|
|
229
|
+
// source; once we re-wrap in double quotes it's a plain '.
|
|
230
|
+
v = v.replace(/\\'/g, "'")
|
|
231
|
+
// Unescaped " inside the value needs to be \"-escaped for JSON.
|
|
232
|
+
// "Unescaped" = preceded by an even number of backslashes.
|
|
233
|
+
v = v.replace(/(\\*)"/g, (m, slashes) =>
|
|
234
|
+
slashes.length % 2 === 0 ? slashes + '\\"' : m,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
result += `"${v}"`
|
|
219
238
|
continue
|
|
220
239
|
}
|
|
221
240
|
|