hyper-html-api 0.1.0 → 0.2.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.
@@ -1,47 +1,63 @@
1
- import { DOM_PROPERTIES_SET } from './dom-properties.js'
2
- import { MaxRuleDepthExceeded, ShapeMismatch } from './errors.js'
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
- export function applyAt(adapter, ctx, rule, value, trace) {
18
- if (trace.depth > MAX_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
19
- if (value === undefined) return
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
- return listDiff(adapter, ctx, selector, shape, value, trace, applyAt)
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
- return listDiff(adapter, ctx, selector, null, value, trace, applyAt)
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 [selector, name] = rule.split('@')
53
- const matches = selector ? adapter.find(ctx, selector) : [ctx]
54
- if (matches.length === 0) return
55
- return writePropOrAttr(adapter, matches[0], name, value)
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 (DOM_PROPERTIES_SET.has(name)) {
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
- } else {
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
- if (value === undefined || value === null || value === '') return
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
- if (typeof value === 'object') {
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
  }
@@ -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
- if (needsTemplate && oldNodes.length === 0) throw new EmptyListInsert(trace.path)
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 (oldNodes.length > 0) {
29
- template = adapter.clone(oldNodes[0])
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 parent = adapter.parent(oldNodes[0])
42
- const anchorIdx = indexInParent(adapter, parent, oldNodes[0])
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
- used.forEach((i) => adapter.remove(oldNodes[i]))
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
- adapter.insertAt(parent, node, anchorIdx + i)
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
- adapter.text(node, v == null ? '' : String(v))
87
+ const target = v == null ? '' : String(v)
88
+ if (adapter.text(node) !== target) adapter.text(node, target)
70
89
  } else {
71
- applyItem(adapter, node, shape, newItems[i], {
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/written via the DOM/cheerio .prop() interface rather
3
- * than .attr(). Copied verbatim from hyperclay/server-lib/data-extractor.js
4
- * so the new engine preserves byte-identical output.
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
+ ])
@@ -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 20 at path: ${path.join('.')}`)
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.')
@@ -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
- const MAX_DEPTH = 20
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 > MAX_DEPTH) throw new MaxRuleDepthExceeded(trace.path)
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 [selector, name] = rule.split('@')
52
- const matches = selector ? adapter.find(ctx, selector) : [ctx]
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
 
@@ -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
+ }
@@ -1,18 +1,40 @@
1
- import { parseStrict } from './rules.js'
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
- export function findRulesIn(adapter, root) {
8
- const candidates = adapter.find(root, `script#${RULES_TAG_ID}`, { includeRulesTag: true })
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
- const body = adapter.text(tagNode)
16
- const rules = parseStrict(body)
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
  }
@@ -1,16 +1,17 @@
1
1
  import { RulesParseError } from './errors.js'
2
2
 
3
3
  /**
4
- * Strict JSON parser for script-tag rules bodies.
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
- 'hyper-html-api script tags require strict JSON. ' +
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
- result += `"${token.value}"`
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
 
@@ -1,18 +1,27 @@
1
1
  import * as engineMod from './engine/index.js'
2
+ import * as cmsMod from './cms/index.js'
2
3
  import * as upgradeMod from './upgrade/index.js'
3
4
  import domAdapter from './adapters/dom.js'
4
5
 
5
6
  const engine = {
6
- extract: (root, rules) => engineMod.extract(domAdapter, root, rules),
7
- apply: (root, rules, data) => engineMod.apply(domAdapter, root, rules, data),
8
- findRulesIn: (root) => engineMod.findRulesIn(domAdapter, root),
7
+ extract: (root, rules, opts) => engineMod.extract(domAdapter, root, rules, opts),
8
+ apply: (root, rules, data, opts) => engineMod.apply(domAdapter, root, rules, data, opts),
9
+ findRulesIn: (root, token) => engineMod.findRulesIn(domAdapter, root, token),
10
+ findRules: (root, source) => engineMod.resolveRules(domAdapter, root, source),
11
+ bind: (root, source, opts) => engineMod.bind(domAdapter, root, source, opts),
9
12
  parseStrict: engineMod.parseStrict,
10
13
  parseRelaxed: engineMod.parseRelaxed,
11
14
  errors: engineMod.errors,
12
15
  DOM_PROPERTIES: engineMod.DOM_PROPERTIES,
13
16
  }
14
17
 
15
- const cms = {}
18
+ const cms = {
19
+ buildForm: cmsMod.buildForm,
20
+ bindFormEvents: cmsMod.bindFormEvents,
21
+ scaffold: cmsMod.scaffold,
22
+ morphForm: cmsMod.morphForm,
23
+ path: cmsMod.path,
24
+ }
16
25
 
17
26
  const upgrade = {
18
27
  registerUpgrade: upgradeMod.registerUpgrade,