hyper-html-api 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/conformance/MANIFEST.json +6 -0
  2. package/conformance/cases/basic-scalar-and-list.expected.json +7 -0
  3. package/conformance/cases/basic-scalar-and-list.html +4 -0
  4. package/conformance/cases/basic-scalar-and-list.meta +4 -0
  5. package/conformance/cases/basic-scalar-and-list.parsed.json +4 -0
  6. package/conformance/cases/basic-scalar-and-list.rules +1 -0
  7. package/conformance/cases/cms-template-skipped-by-default.expected.json +7 -0
  8. package/conformance/cases/cms-template-skipped-by-default.html +7 -0
  9. package/conformance/cases/cms-template-skipped-by-default.meta +7 -0
  10. package/conformance/cases/cms-template-skipped-by-default.parsed.json +5 -0
  11. package/conformance/cases/cms-template-skipped-by-default.rules +1 -0
  12. package/conformance/cases/depth-at-limit-ok.expected.json +41 -0
  13. package/conformance/cases/depth-at-limit-ok.html +3 -0
  14. package/conformance/cases/depth-at-limit-ok.meta +6 -0
  15. package/conformance/cases/depth-at-limit-ok.parsed.json +41 -0
  16. package/conformance/cases/depth-at-limit-ok.rules +1 -0
  17. package/conformance/cases/depth-exceeded-path-contains-selector.error.json +5 -0
  18. package/conformance/cases/depth-exceeded-path-contains-selector.html +3 -0
  19. package/conformance/cases/depth-exceeded-path-contains-selector.meta +9 -0
  20. package/conformance/cases/depth-exceeded-path-contains-selector.parsed.json +43 -0
  21. package/conformance/cases/depth-exceeded-path-contains-selector.rules +1 -0
  22. package/conformance/cases/depth-exceeded.error.json +5 -0
  23. package/conformance/cases/depth-exceeded.html +3 -0
  24. package/conformance/cases/depth-exceeded.meta +6 -0
  25. package/conformance/cases/depth-exceeded.parsed.json +43 -0
  26. package/conformance/cases/depth-exceeded.rules +1 -0
  27. package/conformance/cases/props-boolean-on-any-element.expected.json +5 -0
  28. package/conformance/cases/props-boolean-on-any-element.html +3 -0
  29. package/conformance/cases/props-boolean-on-any-element.meta +7 -0
  30. package/conformance/cases/props-boolean-on-any-element.parsed.json +5 -0
  31. package/conformance/cases/props-boolean-on-any-element.rules +1 -0
  32. package/conformance/cases/props-empty-attr-vs-empty-prop.expected.json +4 -0
  33. package/conformance/cases/props-empty-attr-vs-empty-prop.html +3 -0
  34. package/conformance/cases/props-empty-attr-vs-empty-prop.meta +6 -0
  35. package/conformance/cases/props-empty-attr-vs-empty-prop.parsed.json +4 -0
  36. package/conformance/cases/props-empty-attr-vs-empty-prop.rules +1 -0
  37. package/conformance/cases/props-null-set.expected.json +9 -0
  38. package/conformance/cases/props-null-set.html +3 -0
  39. package/conformance/cases/props-null-set.meta +7 -0
  40. package/conformance/cases/props-null-set.parsed.json +9 -0
  41. package/conformance/cases/props-null-set.rules +1 -0
  42. package/conformance/cases/props-readonly-always-false.expected.json +4 -0
  43. package/conformance/cases/props-readonly-always-false.html +4 -0
  44. package/conformance/cases/props-readonly-always-false.meta +11 -0
  45. package/conformance/cases/props-readonly-always-false.parsed.json +4 -0
  46. package/conformance/cases/props-readonly-always-false.rules +1 -0
  47. package/conformance/cases/props-select-textarea-value-null.expected.json +5 -0
  48. package/conformance/cases/props-select-textarea-value-null.html +5 -0
  49. package/conformance/cases/props-select-textarea-value-null.meta +8 -0
  50. package/conformance/cases/props-select-textarea-value-null.parsed.json +5 -0
  51. package/conformance/cases/props-select-textarea-value-null.rules +1 -0
  52. package/conformance/cases/props-tagname-and-nodetype.expected.json +5 -0
  53. package/conformance/cases/props-tagname-and-nodetype.html +3 -0
  54. package/conformance/cases/props-tagname-and-nodetype.meta +6 -0
  55. package/conformance/cases/props-tagname-and-nodetype.parsed.json +5 -0
  56. package/conformance/cases/props-tagname-and-nodetype.rules +1 -0
  57. package/conformance/cases/props-type-is-node-type.expected.json +5 -0
  58. package/conformance/cases/props-type-is-node-type.html +5 -0
  59. package/conformance/cases/props-type-is-node-type.meta +11 -0
  60. package/conformance/cases/props-type-is-node-type.parsed.json +5 -0
  61. package/conformance/cases/props-type-is-node-type.rules +1 -0
  62. package/conformance/cases/rules-duplicate-keys-last-wins.expected.json +3 -0
  63. package/conformance/cases/rules-duplicate-keys-last-wins.html +4 -0
  64. package/conformance/cases/rules-duplicate-keys-last-wins.meta +5 -0
  65. package/conformance/cases/rules-duplicate-keys-last-wins.parsed.json +3 -0
  66. package/conformance/cases/rules-duplicate-keys-last-wins.rules +1 -0
  67. package/conformance/cases/rules-iterate-dot-leading-works.expected.json +10 -0
  68. package/conformance/cases/rules-iterate-dot-leading-works.html +3 -0
  69. package/conformance/cases/rules-iterate-dot-leading-works.meta +6 -0
  70. package/conformance/cases/rules-iterate-dot-leading-works.parsed.json +8 -0
  71. package/conformance/cases/rules-iterate-dot-leading-works.rules +1 -0
  72. package/conformance/cases/rules-iterate-lookahead-quoted.error.json +5 -0
  73. package/conformance/cases/rules-iterate-lookahead-quoted.html +3 -0
  74. package/conformance/cases/rules-iterate-lookahead-quoted.meta +7 -0
  75. package/conformance/cases/rules-iterate-lookahead-quoted.rules +1 -0
  76. package/conformance/cases/rules-iterate-lookahead-unquoted.error.json +5 -0
  77. package/conformance/cases/rules-iterate-lookahead-unquoted.html +3 -0
  78. package/conformance/cases/rules-iterate-lookahead-unquoted.meta +8 -0
  79. package/conformance/cases/rules-iterate-lookahead-unquoted.parsed.json +3 -0
  80. package/conformance/cases/rules-iterate-lookahead-unquoted.rules +1 -0
  81. package/conformance/cases/rules-lastindexof-at-split.expected.json +3 -0
  82. package/conformance/cases/rules-lastindexof-at-split.html +3 -0
  83. package/conformance/cases/rules-lastindexof-at-split.meta +11 -0
  84. package/conformance/cases/rules-lastindexof-at-split.parsed.json +3 -0
  85. package/conformance/cases/rules-lastindexof-at-split.rules +1 -0
  86. package/conformance/cases/rules-primitives-extract-null.expected.json +5 -0
  87. package/conformance/cases/rules-primitives-extract-null.html +3 -0
  88. package/conformance/cases/rules-primitives-extract-null.meta +6 -0
  89. package/conformance/cases/rules-primitives-extract-null.parsed.json +5 -0
  90. package/conformance/cases/rules-primitives-extract-null.rules +1 -0
  91. package/conformance/cases/rules-proto-key-omitted.expected.json +3 -0
  92. package/conformance/cases/rules-proto-key-omitted.html +3 -0
  93. package/conformance/cases/rules-proto-key-omitted.meta +7 -0
  94. package/conformance/cases/rules-proto-key-omitted.parsed.json +6 -0
  95. package/conformance/cases/rules-proto-key-omitted.rules +1 -0
  96. package/conformance/cases/rules-quoted-attr-in-unquoted-selector.error.json +5 -0
  97. package/conformance/cases/rules-quoted-attr-in-unquoted-selector.html +4 -0
  98. package/conformance/cases/rules-quoted-attr-in-unquoted-selector.meta +5 -0
  99. package/conformance/cases/rules-quoted-attr-in-unquoted-selector.rules +1 -0
  100. package/conformance/cases/rules-trailing-comma-dropped.expected.json +3 -0
  101. package/conformance/cases/rules-trailing-comma-dropped.html +3 -0
  102. package/conformance/cases/rules-trailing-comma-dropped.meta +5 -0
  103. package/conformance/cases/rules-trailing-comma-dropped.parsed.json +3 -0
  104. package/conformance/cases/rules-trailing-comma-dropped.rules +1 -0
  105. package/conformance/cases/rules-unwhitelisted-pseudo-breaks-tokenizer.error.json +5 -0
  106. package/conformance/cases/rules-unwhitelisted-pseudo-breaks-tokenizer.html +3 -0
  107. package/conformance/cases/rules-unwhitelisted-pseudo-breaks-tokenizer.meta +7 -0
  108. package/conformance/cases/rules-unwhitelisted-pseudo-breaks-tokenizer.rules +1 -0
  109. package/conformance/cases/rules-whitespace-clings.expected.json +4 -0
  110. package/conformance/cases/rules-whitespace-clings.html +4 -0
  111. package/conformance/cases/rules-whitespace-clings.meta +6 -0
  112. package/conformance/cases/rules-whitespace-clings.parsed.json +4 -0
  113. package/conformance/cases/rules-whitespace-clings.rules +1 -0
  114. package/conformance/cases/selector-unknown-pseudo-is-500.error.json +5 -0
  115. package/conformance/cases/selector-unknown-pseudo-is-500.html +4 -0
  116. package/conformance/cases/selector-unknown-pseudo-is-500.meta +7 -0
  117. package/conformance/cases/selector-unknown-pseudo-is-500.parsed.json +3 -0
  118. package/conformance/cases/selector-unknown-pseudo-is-500.rules +1 -0
  119. package/conformance/cases/tag-basic.expected.json +7 -0
  120. package/conformance/cases/tag-basic.html +5 -0
  121. package/conformance/cases/tag-basic.meta +6 -0
  122. package/conformance/cases/tag-basic.parsed.json +4 -0
  123. package/conformance/cases/tag-duplicate-names-first-wins.expected.json +3 -0
  124. package/conformance/cases/tag-duplicate-names-first-wins.html +6 -0
  125. package/conformance/cases/tag-duplicate-names-first-wins.meta +7 -0
  126. package/conformance/cases/tag-duplicate-names-first-wins.parsed.json +3 -0
  127. package/conformance/cases/tag-missing.error.json +5 -0
  128. package/conformance/cases/tag-missing.html +4 -0
  129. package/conformance/cases/tag-missing.meta +8 -0
  130. package/conformance/cases/tag-name-is-space-separated.expected.json +3 -0
  131. package/conformance/cases/tag-name-is-space-separated.html +4 -0
  132. package/conformance/cases/tag-name-is-space-separated.meta +6 -0
  133. package/conformance/cases/tag-name-is-space-separated.parsed.json +3 -0
  134. package/conformance/cases/tag-relaxed-body.expected.json +7 -0
  135. package/conformance/cases/tag-relaxed-body.html +5 -0
  136. package/conformance/cases/tag-relaxed-body.meta +7 -0
  137. package/conformance/cases/tag-relaxed-body.parsed.json +4 -0
  138. package/conformance/cases/tag-wrong-version.error.json +5 -0
  139. package/conformance/cases/tag-wrong-version.html +4 -0
  140. package/conformance/cases/tag-wrong-version.meta +6 -0
  141. package/conformance/cases/template-simple-vs-combinator.expected.json +7 -0
  142. package/conformance/cases/template-simple-vs-combinator.html +4 -0
  143. package/conformance/cases/template-simple-vs-combinator.meta +8 -0
  144. package/conformance/cases/template-simple-vs-combinator.parsed.json +4 -0
  145. package/conformance/cases/template-simple-vs-combinator.rules +1 -0
  146. package/conformance/cases/text-root-leaks-rules-tag.expected.json +4 -0
  147. package/conformance/cases/text-root-leaks-rules-tag.html +4 -0
  148. package/conformance/cases/text-root-leaks-rules-tag.meta +7 -0
  149. package/conformance/cases/text-root-leaks-rules-tag.parsed.json +4 -0
  150. package/conformance/cases/text-root-leaks-rules-tag.rules +1 -0
  151. package/conformance/cases/text-trim-asymmetry.expected.json +5 -0
  152. package/conformance/cases/text-trim-asymmetry.html +5 -0
  153. package/conformance/cases/text-trim-asymmetry.meta +6 -0
  154. package/conformance/cases/text-trim-asymmetry.parsed.json +5 -0
  155. package/conformance/cases/text-trim-asymmetry.rules +1 -0
  156. package/dist/hyper-html-api.engine.min.js +1 -1
  157. package/dist/hyper-html-api.min.js +4 -3
  158. package/dist/hyper-html-api.upgrade.min.js +2 -2
  159. package/package.json +13 -5
  160. package/src/cms/form-builder.js +3 -2
  161. package/src/dom-api.js +5 -0
  162. package/src/engine/apply.js +3 -2
  163. package/src/engine/diff.js +70 -13
  164. package/src/engine/extract.js +3 -2
  165. package/src/engine/index.js +1 -0
  166. package/src/engine/rule-syntax.js +43 -0
@@ -48,14 +48,18 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
48
48
  const anchorIdx = oldNodes.length > 0
49
49
  ? indexInParent(adapter, parent, referenceNode)
50
50
  : 0
51
+ const contiguous = isContiguousRun(adapter, oldNodes)
51
52
 
52
53
  const used = new Set()
54
+ const fresh = []
53
55
  const finalNodes = newItems.map((_, i) => {
54
56
  const oldIdx = matches[i]
55
57
  if (oldIdx >= 0) {
56
58
  used.add(oldIdx)
59
+ fresh.push(false)
57
60
  return oldNodes[oldIdx]
58
61
  }
62
+ fresh.push(true)
59
63
  const cloned = adapter.clone(template)
60
64
  adapter.stripIds(cloned)
61
65
  return cloned
@@ -67,20 +71,36 @@ export function listDiff(adapter, parentCtx, selector, shape, newItems, trace, a
67
71
  if (!used.has(i)) adapter.remove(n)
68
72
  })
69
73
 
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").
74
- finalNodes.forEach((node, 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)
80
- })
74
+ if (contiguous) {
75
+ // Place each final node at its target index. If the node is already
76
+ // there, do nothing (no-op apply on unchanged data drops zero state).
77
+ // Otherwise insertAt moves an attached node (DOM/cheerio both treat
78
+ // insertBefore on an attached node as "move to here").
79
+ finalNodes.forEach((node, i) => {
80
+ const targetIdx = anchorIdx + i
81
+ const siblings = adapter.children(parent)
82
+ const currentIdx = siblings.findIndex((s) => adapter.sameNode(s, node))
83
+ if (currentIdx === targetIdx) return
84
+ adapter.insertAt(parent, node, targetIdx)
85
+ })
86
+ writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts)
87
+ return
88
+ }
89
+
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
+ writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts)
99
+ }
81
100
 
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.
101
+ // Apply per-item content. Skip when the existing value already matches
102
+ // — saves a write on no-op applies and avoids spurious mutation events.
103
+ function writeItems(adapter, finalNodes, shape, newItems, trace, applyItem, opts) {
84
104
  finalNodes.forEach((node, i) => {
85
105
  if (shape === null) {
86
106
  const v = newItems[i]
@@ -167,3 +187,40 @@ function findFallbackTemplate(adapter, parentCtx, selector, opts) {
167
187
  }
168
188
  return null
169
189
  }
190
+
191
+ // Repositioning only means something when the list occupies a contiguous run of
192
+ // element siblings under one parent. Two containers, or an unowned sibling
193
+ // sitting between two owned nodes, has no list order to restore.
194
+ function isContiguousRun(adapter, nodes) {
195
+ if (nodes.length <= 1) return true
196
+ const parent = adapter.parent(nodes[0])
197
+ if (!parent) return false
198
+ const siblings = adapter.children(parent)
199
+ const indices = []
200
+ for (const node of nodes) {
201
+ const i = siblings.findIndex((s) => adapter.sameNode(s, node))
202
+ if (i === -1) return false
203
+ indices.push(i)
204
+ }
205
+ indices.sort((a, b) => a - b)
206
+ return indices[indices.length - 1] - indices[0] === indices.length - 1
207
+ }
208
+
209
+ // Grown items follow the surviving node before them, so a new row lands next to
210
+ // its neighbour rather than being teleported into the first container. With no
211
+ // surviving node before it, fall back to where the list started.
212
+ function placeGrownItems(adapter, finalNodes, fresh, fallbackParent, fallbackIdx) {
213
+ let anchor = null
214
+ let nextFallbackIdx = fallbackIdx
215
+ for (let i = 0; i < finalNodes.length; i++) {
216
+ if (!fresh[i]) {
217
+ anchor = finalNodes[i]
218
+ continue
219
+ }
220
+ const parent = anchor ? adapter.parent(anchor) : fallbackParent
221
+ if (!parent) continue
222
+ const at = anchor ? indexInParent(adapter, parent, anchor) + 1 : nextFallbackIdx++
223
+ adapter.insertAt(parent, finalNodes[i], at)
224
+ anchor = finalNodes[i]
225
+ }
226
+ }
@@ -1,5 +1,6 @@
1
1
  import { DOM_PROPERTIES_SET } from './dom-properties.js'
2
2
  import { MaxRuleDepthExceeded, MAX_RULE_DEPTH } from './errors.js'
3
+ import { ruleAttrIndex } from './rule-syntax.js'
3
4
 
4
5
  export function extract(adapter, root, rules, opts = {}) {
5
6
  return extractAt(adapter, root, rules, { depth: 0, path: [] }, opts)
@@ -45,8 +46,8 @@ function extractScalar(adapter, ctx, rule, opts) {
45
46
  return readPropOrAttr(adapter, ctx, rule.slice(1))
46
47
  }
47
48
 
48
- if (rule.includes('@')) {
49
- const at = rule.lastIndexOf('@')
49
+ const at = ruleAttrIndex(rule)
50
+ if (at !== -1) {
50
51
  const selector = rule.slice(0, at)
51
52
  const name = rule.slice(at + 1)
52
53
  const matches = selector ? adapter.find(ctx, selector, opts) : [ctx]
@@ -6,6 +6,7 @@ export { extract } from './extract.js'
6
6
  export { apply } from './apply.js'
7
7
  export { findRulesIn } from './rules-tag.js'
8
8
  export { parseStrict, parseRelaxed } from './rules.js'
9
+ export { ruleAttrIndex, splitRule } from './rule-syntax.js'
9
10
  export * as errors from './errors.js'
10
11
  export { DOM_PROPERTIES, DOM_PROPERTIES_SET } from './dom-properties.js'
11
12
 
@@ -0,0 +1,43 @@
1
+ // The scalar rule grammar: "<selector>@<prop>", plus a trailing "[]" for a list
2
+ // of values, a leading "@" for a prop on the context node, and "." for its text.
3
+ //
4
+ // Locating the "@" is the only subtle part of it. It is the LAST one, because a
5
+ // prop name never contains "@" but a selector often does: a mailto link
6
+ // (a[href="mailto:hi@example.com"]), a container query, a Tailwind arbitrary
7
+ // variant. Every one of those carries its "@" inside brackets, parens or
8
+ // quotes, so a separator is an "@" at bracket depth zero and outside any quote.
9
+ // Scanning with a plain lastIndexOf finds the one in the mailto and splits the
10
+ // rule into `a[href="mailto:hi` and `example.com"]`, which reads downstream as
11
+ // an invalid selector when the rule the author wrote was correct.
12
+
13
+ // Index of the separator "@", or -1 when the rule has no prop part.
14
+ export function ruleAttrIndex(rule) {
15
+ let depth = 0
16
+ let quote = null
17
+ let found = -1
18
+ for (let i = 0; i < rule.length; i++) {
19
+ const ch = rule[i]
20
+ if (ch === '\\') {
21
+ i++
22
+ } else if (quote) {
23
+ if (ch === quote) quote = null
24
+ } else if (ch === '"' || ch === "'") {
25
+ quote = ch
26
+ } else if (ch === '[' || ch === '(') {
27
+ depth++
28
+ } else if (ch === ']' || ch === ')') {
29
+ if (depth > 0) depth--
30
+ } else if (ch === '@' && depth === 0) {
31
+ found = i
32
+ }
33
+ }
34
+ return found
35
+ }
36
+
37
+ // "<selector>@<prop>" split at that separator. selector is "" for a leading "@"
38
+ // (the context node itself); prop is null when the rule names no prop.
39
+ export function splitRule(rule) {
40
+ const at = ruleAttrIndex(rule)
41
+ if (at === -1) return { selector: rule, prop: null }
42
+ return { selector: rule.slice(0, at), prop: rule.slice(at + 1) || null }
43
+ }