hyper-html-api 0.8.0 → 0.9.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.
Files changed (222) hide show
  1. package/README.md +38 -0
  2. package/conformance/MANIFEST.json +2 -2
  3. package/conformance/cases/no-data-list-skipped.expected.json +6 -0
  4. package/conformance/cases/no-data-list-skipped.html +1 -0
  5. package/conformance/cases/no-data-list-skipped.meta +4 -0
  6. package/conformance/cases/no-data-list-skipped.parsed.json +3 -0
  7. package/conformance/cases/no-data-list-skipped.rules +1 -0
  8. package/conformance/cases/no-data-text-projected.expected.json +3 -0
  9. package/conformance/cases/no-data-text-projected.html +1 -0
  10. package/conformance/cases/no-data-text-projected.meta +4 -0
  11. package/conformance/cases/no-data-text-projected.parsed.json +3 -0
  12. package/conformance/cases/no-data-text-projected.rules +1 -0
  13. package/conformance/cases/props-type-is-attribute.expected.json +5 -0
  14. package/conformance/cases/props-type-is-attribute.meta +4 -0
  15. package/conformance/cases/write-attr-app-scheme.after.html +1 -0
  16. package/conformance/cases/write-attr-app-scheme.after.json +3 -0
  17. package/conformance/cases/write-attr-app-scheme.data.json +1 -0
  18. package/conformance/cases/write-attr-app-scheme.html +1 -0
  19. package/conformance/cases/write-attr-app-scheme.meta +4 -0
  20. package/conformance/cases/write-attr-app-scheme.write.json +4 -0
  21. package/conformance/cases/write-attr-href.after.html +1 -0
  22. package/conformance/cases/write-attr-href.after.json +3 -0
  23. package/conformance/cases/write-attr-href.data.json +1 -0
  24. package/conformance/cases/write-attr-href.html +1 -0
  25. package/conformance/cases/write-attr-href.meta +4 -0
  26. package/conformance/cases/write-attr-href.write.json +4 -0
  27. package/conformance/cases/write-checked-false.after.html +1 -0
  28. package/conformance/cases/write-checked-false.after.json +3 -0
  29. package/conformance/cases/write-checked-false.data.json +1 -0
  30. package/conformance/cases/write-checked-false.html +1 -0
  31. package/conformance/cases/write-checked-false.meta +4 -0
  32. package/conformance/cases/write-checked-false.write.json +4 -0
  33. package/conformance/cases/write-checked-true.after.html +1 -0
  34. package/conformance/cases/write-checked-true.after.json +3 -0
  35. package/conformance/cases/write-checked-true.data.json +1 -0
  36. package/conformance/cases/write-checked-true.html +1 -0
  37. package/conformance/cases/write-checked-true.meta +4 -0
  38. package/conformance/cases/write-checked-true.write.json +4 -0
  39. package/conformance/cases/write-crlf-multi.after.html +12 -0
  40. package/conformance/cases/write-crlf-multi.after.json +8 -0
  41. package/conformance/cases/write-crlf-multi.data.json +1 -0
  42. package/conformance/cases/write-crlf-multi.html +12 -0
  43. package/conformance/cases/write-crlf-multi.meta +4 -0
  44. package/conformance/cases/write-crlf-multi.write.json +4 -0
  45. package/conformance/cases/write-fallback-implied.after.html +1 -0
  46. package/conformance/cases/write-fallback-implied.after.json +3 -0
  47. package/conformance/cases/write-fallback-implied.data.json +1 -0
  48. package/conformance/cases/write-fallback-implied.html +1 -0
  49. package/conformance/cases/write-fallback-implied.meta +4 -0
  50. package/conformance/cases/write-fallback-implied.write.json +4 -0
  51. package/conformance/cases/write-flatten.after.html +1 -0
  52. package/conformance/cases/write-flatten.after.json +3 -0
  53. package/conformance/cases/write-flatten.data.json +1 -0
  54. package/conformance/cases/write-flatten.html +1 -0
  55. package/conformance/cases/write-flatten.meta +4 -0
  56. package/conformance/cases/write-flatten.write.json +4 -0
  57. package/conformance/cases/write-list-empty-no-seed.data.json +1 -0
  58. package/conformance/cases/write-list-empty-no-seed.error.json +8 -0
  59. package/conformance/cases/write-list-empty-no-seed.html +1 -0
  60. package/conformance/cases/write-list-empty-no-seed.meta +4 -0
  61. package/conformance/cases/write-list-grow-from-template.after.html +1 -0
  62. package/conformance/cases/write-list-grow-from-template.after.json +5 -0
  63. package/conformance/cases/write-list-grow-from-template.data.json +1 -0
  64. package/conformance/cases/write-list-grow-from-template.html +1 -0
  65. package/conformance/cases/write-list-grow-from-template.meta +4 -0
  66. package/conformance/cases/write-list-grow-from-template.write.json +4 -0
  67. package/conformance/cases/write-list-object-clone.after.html +1 -0
  68. package/conformance/cases/write-list-object-clone.after.json +12 -0
  69. package/conformance/cases/write-list-object-clone.data.json +1 -0
  70. package/conformance/cases/write-list-object-clone.html +1 -0
  71. package/conformance/cases/write-list-object-clone.meta +4 -0
  72. package/conformance/cases/write-list-object-clone.write.json +4 -0
  73. package/conformance/cases/write-list-scalar-grow.after.html +4 -0
  74. package/conformance/cases/write-list-scalar-grow.after.json +7 -0
  75. package/conformance/cases/write-list-scalar-grow.data.json +1 -0
  76. package/conformance/cases/write-list-scalar-grow.html +4 -0
  77. package/conformance/cases/write-list-scalar-grow.meta +4 -0
  78. package/conformance/cases/write-list-scalar-grow.write.json +4 -0
  79. package/conformance/cases/write-list-scalar-shrink.after.html +4 -0
  80. package/conformance/cases/write-list-scalar-shrink.after.json +5 -0
  81. package/conformance/cases/write-list-scalar-shrink.data.json +1 -0
  82. package/conformance/cases/write-list-scalar-shrink.html +4 -0
  83. package/conformance/cases/write-list-scalar-shrink.meta +4 -0
  84. package/conformance/cases/write-list-scalar-shrink.write.json +4 -0
  85. package/conformance/cases/write-list-shrink-zero.after.html +1 -0
  86. package/conformance/cases/write-list-shrink-zero.after.json +3 -0
  87. package/conformance/cases/write-list-shrink-zero.data.json +1 -0
  88. package/conformance/cases/write-list-shrink-zero.html +1 -0
  89. package/conformance/cases/write-list-shrink-zero.meta +4 -0
  90. package/conformance/cases/write-list-shrink-zero.write.json +4 -0
  91. package/conformance/cases/write-no-data-kept.after.html +1 -0
  92. package/conformance/cases/write-no-data-kept.after.json +3 -0
  93. package/conformance/cases/write-no-data-kept.data.json +1 -0
  94. package/conformance/cases/write-no-data-kept.html +1 -0
  95. package/conformance/cases/write-no-data-kept.meta +4 -0
  96. package/conformance/cases/write-no-data-kept.write.json +4 -0
  97. package/conformance/cases/write-no-rules-tag.data.json +1 -0
  98. package/conformance/cases/write-no-rules-tag.error.json +6 -0
  99. package/conformance/cases/write-no-rules-tag.html +1 -0
  100. package/conformance/cases/write-no-rules-tag.meta +4 -0
  101. package/conformance/cases/write-noop.after.html +3 -0
  102. package/conformance/cases/write-noop.after.json +3 -0
  103. package/conformance/cases/write-noop.data.json +1 -0
  104. package/conformance/cases/write-noop.html +3 -0
  105. package/conformance/cases/write-noop.meta +4 -0
  106. package/conformance/cases/write-noop.write.json +4 -0
  107. package/conformance/cases/write-outerhtml-unchanged.after.html +1 -0
  108. package/conformance/cases/write-outerhtml-unchanged.after.json +3 -0
  109. package/conformance/cases/write-outerhtml-unchanged.data.json +1 -0
  110. package/conformance/cases/write-outerhtml-unchanged.html +1 -0
  111. package/conformance/cases/write-outerhtml-unchanged.meta +4 -0
  112. package/conformance/cases/write-outerhtml-unchanged.write.json +4 -0
  113. package/conformance/cases/write-readonly-changed.data.json +1 -0
  114. package/conformance/cases/write-readonly-changed.error.json +6 -0
  115. package/conformance/cases/write-readonly-changed.html +1 -0
  116. package/conformance/cases/write-readonly-changed.meta +4 -0
  117. package/conformance/cases/write-readonly-unchanged.after.html +1 -0
  118. package/conformance/cases/write-readonly-unchanged.after.json +4 -0
  119. package/conformance/cases/write-readonly-unchanged.data.json +1 -0
  120. package/conformance/cases/write-readonly-unchanged.html +1 -0
  121. package/conformance/cases/write-readonly-unchanged.meta +4 -0
  122. package/conformance/cases/write-readonly-unchanged.write.json +4 -0
  123. package/conformance/cases/write-refused-animate.data.json +1 -0
  124. package/conformance/cases/write-refused-animate.error.json +11 -0
  125. package/conformance/cases/write-refused-animate.html +1 -0
  126. package/conformance/cases/write-refused-animate.meta +4 -0
  127. package/conformance/cases/write-refused-attr-name.data.json +1 -0
  128. package/conformance/cases/write-refused-attr-name.error.json +11 -0
  129. package/conformance/cases/write-refused-attr-name.html +1 -0
  130. package/conformance/cases/write-refused-attr-name.meta +4 -0
  131. package/conformance/cases/write-refused-data-url.data.json +1 -0
  132. package/conformance/cases/write-refused-data-url.error.json +11 -0
  133. package/conformance/cases/write-refused-data-url.html +1 -0
  134. package/conformance/cases/write-refused-data-url.meta +4 -0
  135. package/conformance/cases/write-refused-innerhtml.data.json +1 -0
  136. package/conformance/cases/write-refused-innerhtml.error.json +11 -0
  137. package/conformance/cases/write-refused-innerhtml.html +1 -0
  138. package/conformance/cases/write-refused-innerhtml.meta +4 -0
  139. package/conformance/cases/write-refused-javascript-url.data.json +1 -0
  140. package/conformance/cases/write-refused-javascript-url.error.json +11 -0
  141. package/conformance/cases/write-refused-javascript-url.html +1 -0
  142. package/conformance/cases/write-refused-javascript-url.meta +4 -0
  143. package/conformance/cases/write-refused-list-removes-rules-tag.data.json +1 -0
  144. package/conformance/cases/write-refused-list-removes-rules-tag.error.json +11 -0
  145. package/conformance/cases/write-refused-list-removes-rules-tag.html +1 -0
  146. package/conformance/cases/write-refused-list-removes-rules-tag.meta +4 -0
  147. package/conformance/cases/write-refused-multiple.data.json +1 -0
  148. package/conformance/cases/write-refused-multiple.error.json +15 -0
  149. package/conformance/cases/write-refused-multiple.html +1 -0
  150. package/conformance/cases/write-refused-multiple.meta +4 -0
  151. package/conformance/cases/write-refused-onclick.data.json +1 -0
  152. package/conformance/cases/write-refused-onclick.error.json +11 -0
  153. package/conformance/cases/write-refused-onclick.html +1 -0
  154. package/conformance/cases/write-refused-onclick.meta +4 -0
  155. package/conformance/cases/write-refused-rules-tag-ancestor.data.json +1 -0
  156. package/conformance/cases/write-refused-rules-tag-ancestor.error.json +11 -0
  157. package/conformance/cases/write-refused-rules-tag-ancestor.html +1 -0
  158. package/conformance/cases/write-refused-rules-tag-ancestor.meta +4 -0
  159. package/conformance/cases/write-refused-script.data.json +1 -0
  160. package/conformance/cases/write-refused-script.error.json +11 -0
  161. package/conformance/cases/write-refused-script.html +1 -0
  162. package/conformance/cases/write-refused-script.meta +4 -0
  163. package/conformance/cases/write-shape-mismatch.data.json +1 -0
  164. package/conformance/cases/write-shape-mismatch.error.json +12 -0
  165. package/conformance/cases/write-shape-mismatch.html +1 -0
  166. package/conformance/cases/write-shape-mismatch.meta +4 -0
  167. package/conformance/cases/write-text-basic.after.html +3 -0
  168. package/conformance/cases/write-text-basic.after.json +3 -0
  169. package/conformance/cases/write-text-basic.data.json +1 -0
  170. package/conformance/cases/write-text-basic.html +3 -0
  171. package/conformance/cases/write-text-basic.meta +4 -0
  172. package/conformance/cases/write-text-basic.write.json +4 -0
  173. package/conformance/cases/write-text-escape.after.html +1 -0
  174. package/conformance/cases/write-text-escape.after.json +3 -0
  175. package/conformance/cases/write-text-escape.data.json +1 -0
  176. package/conformance/cases/write-text-escape.html +1 -0
  177. package/conformance/cases/write-text-escape.meta +4 -0
  178. package/conformance/cases/write-text-escape.write.json +4 -0
  179. package/conformance/cases/write-text-null-clears.after.html +1 -0
  180. package/conformance/cases/write-text-null-clears.after.json +3 -0
  181. package/conformance/cases/write-text-null-clears.data.json +1 -0
  182. package/conformance/cases/write-text-null-clears.html +1 -0
  183. package/conformance/cases/write-text-null-clears.meta +4 -0
  184. package/conformance/cases/write-text-null-clears.write.json +4 -0
  185. package/conformance/cases/write-type-attribute.after.html +1 -0
  186. package/conformance/cases/write-type-attribute.after.json +3 -0
  187. package/conformance/cases/write-type-attribute.data.json +1 -0
  188. package/conformance/cases/write-type-attribute.html +1 -0
  189. package/conformance/cases/write-type-attribute.meta +4 -0
  190. package/conformance/cases/write-type-attribute.write.json +4 -0
  191. package/conformance/cases/write-unknown-key.data.json +1 -0
  192. package/conformance/cases/write-unknown-key.error.json +11 -0
  193. package/conformance/cases/write-unknown-key.html +3 -0
  194. package/conformance/cases/write-unknown-key.meta +4 -0
  195. package/conformance/cases/write-unmatched.data.json +1 -0
  196. package/conformance/cases/write-unmatched.error.json +14 -0
  197. package/conformance/cases/write-unmatched.html +1 -0
  198. package/conformance/cases/write-unmatched.meta +4 -0
  199. package/conformance/write-policy.json +78 -0
  200. package/dist/hyper-html-api.engine.min.js +1 -1
  201. package/dist/hyper-html-api.min.js +5 -4
  202. package/dist/hyper-html-api.upgrade.min.js +2 -2
  203. package/package.json +5 -3
  204. package/packed-contract.json +1 -0
  205. package/src/adapters/cheerio.js +109 -10
  206. package/src/adapters/dom.js +191 -0
  207. package/src/dom-api.js +17 -6
  208. package/src/engine/apply.js +16 -2
  209. package/src/engine/errors.js +25 -0
  210. package/src/engine/extract.js +3 -0
  211. package/src/engine/index.js +4 -1
  212. package/src/engine/splice.js +118 -0
  213. package/src/engine/write-plan.js +52 -0
  214. package/src/engine/write-policy.js +136 -0
  215. package/src/lib/content-dom.js +110 -0
  216. package/src/lib/region-capabilities.js +71 -0
  217. package/src/write.js +29 -0
  218. package/conformance/cases/props-type-is-node-type.expected.json +0 -5
  219. package/conformance/cases/props-type-is-node-type.meta +0 -11
  220. /package/conformance/cases/{props-type-is-node-type.html → props-type-is-attribute.html} +0 -0
  221. /package/conformance/cases/{props-type-is-node-type.parsed.json → props-type-is-attribute.parsed.json} +0 -0
  222. /package/conformance/cases/{props-type-is-node-type.rules → props-type-is-attribute.rules} +0 -0
@@ -49,3 +49,28 @@ export class RuleTargetReadOnly extends Error {
49
49
  this.target = name
50
50
  }
51
51
  }
52
+
53
+ export class WriteRefused extends Error {
54
+ constructor(refusals) {
55
+ super(`write refused: ${refusals.length} write(s) broke the content-only policy`)
56
+ this.name = 'WriteRefused'
57
+ this.refusals = refusals
58
+ }
59
+ }
60
+
61
+ export class NoRulesTag extends Error {
62
+ constructor(token) {
63
+ super(`no <script data-rules-name~="${token}"> rules tag in this document`)
64
+ this.name = 'NoRulesTag'
65
+ this.token = token
66
+ }
67
+ }
68
+
69
+ export class WriteRejected extends Error {
70
+ constructor(unknownKeys, unmatched) {
71
+ super(`write rejected: ${unknownKeys.length} unknown key(s), ${unmatched.length} rule(s) with no matching element`)
72
+ this.name = 'WriteRejected'
73
+ this.unknownKeys = unknownKeys
74
+ this.unmatched = unmatched
75
+ }
76
+ }
@@ -3,6 +3,9 @@ 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
 
@@ -8,7 +8,10 @@ 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
+ export { WRITE_POLICY, guardAdapter, checkAttribute, urlScheme } from './write-policy.js'
13
+ export { planWrite } from './write-plan.js'
14
+ export { trackAdapter, spliceDocument } from './splice.js'
12
15
 
13
16
  // source: object literal → use as-is (no tag); string → look up by token.
14
17
  // For an element root, search the whole document (root.ownerDocument) so a
@@ -0,0 +1,118 @@
1
+ function raw(node) {
2
+ return node && node[0] ? node[0] : node
3
+ }
4
+
5
+ // Records which nodes a write touched. Structural changes mark the parent,
6
+ // since the parent's children are what changed. Clones are recorded so their
7
+ // copied source locations are never trusted.
8
+ export function trackAdapter(adapter) {
9
+ const dirty = new Set()
10
+ const cloned = new WeakSet()
11
+ const mark = (node) => {
12
+ const r = raw(node)
13
+ if (r) dirty.add(r)
14
+ }
15
+ const markTree = (r) => {
16
+ if (!r) return
17
+ cloned.add(r)
18
+ for (const c of r.children || []) markTree(c)
19
+ }
20
+ return {
21
+ ...adapter,
22
+ text(node, value) {
23
+ if (value === undefined) return adapter.text(node)
24
+ mark(node)
25
+ return adapter.text(node, value)
26
+ },
27
+ attr(node, name, value) {
28
+ if (value === undefined) return adapter.attr(node, name)
29
+ mark(node)
30
+ return adapter.attr(node, name, value)
31
+ },
32
+ removeAttr(node, name) {
33
+ mark(node)
34
+ return adapter.removeAttr(node, name)
35
+ },
36
+ prop(node, name, value) {
37
+ if (value === undefined) return adapter.prop(node, name)
38
+ mark(node)
39
+ return adapter.prop(node, name, value)
40
+ },
41
+ replaceWith(node, html) {
42
+ const p = adapter.parent(node)
43
+ if (p) mark(p)
44
+ return adapter.replaceWith(node, html)
45
+ },
46
+ clone(node) {
47
+ const c = adapter.clone(node)
48
+ markTree(raw(c))
49
+ return c
50
+ },
51
+ insertAt(parent, node, index) {
52
+ mark(parent)
53
+ const from = adapter.parent(node)
54
+ if (from) mark(from)
55
+ return adapter.insertAt(parent, node, index)
56
+ },
57
+ remove(node) {
58
+ const p = adapter.parent(node)
59
+ if (p) mark(p)
60
+ return adapter.remove(node)
61
+ },
62
+ dirty,
63
+ cloned,
64
+ }
65
+ }
66
+
67
+ function attached(r, docRoot) {
68
+ let n = r
69
+ while (n && n.parent) n = n.parent
70
+ return n === docRoot
71
+ }
72
+
73
+ function located(r, cloned) {
74
+ let n = r
75
+ while (n && n.type !== 'root') {
76
+ const loc = n.sourceCodeLocation
77
+ if (!cloned.has(n) && loc && loc.startOffset != null && loc.endOffset != null) return n
78
+ n = n.parent
79
+ }
80
+ return null
81
+ }
82
+
83
+ function hasAncestorIn(r, set) {
84
+ let n = r.parent
85
+ while (n) {
86
+ if (set.has(n)) return true
87
+ n = n.parent
88
+ }
89
+ return false
90
+ }
91
+
92
+ // Replaces the source span of each topmost changed element with its fresh
93
+ // serialization, then proves the result by reparsing: the spliced text must
94
+ // render exactly like the mutated tree. Anything unprovable falls back to the
95
+ // whole-document render.
96
+ export function spliceDocument(load, src, $, tracker) {
97
+ const full = $.html()
98
+ const fallback = { html: full, spliced: false }
99
+ const docRoot = raw($.root())
100
+ const targets = new Set()
101
+ for (const r of tracker.dirty) {
102
+ if (!attached(r, docRoot)) continue
103
+ const at = located(r, tracker.cloned)
104
+ if (!at) return fallback
105
+ targets.add(at)
106
+ }
107
+ const top = [...targets].filter((r) => !hasAncestorIn(r, targets))
108
+ top.sort((a, b) => b.sourceCodeLocation.startOffset - a.sourceCodeLocation.startOffset)
109
+ let out = src
110
+ let prevStart = Infinity
111
+ for (const r of top) {
112
+ const { startOffset, endOffset } = r.sourceCodeLocation
113
+ if (endOffset > prevStart) return fallback
114
+ out = out.slice(0, startOffset) + $.html(r) + out.slice(endOffset)
115
+ prevStart = startOffset
116
+ }
117
+ return load(out).html() === full ? { html: out, spliced: true } : fallback
118
+ }
@@ -0,0 +1,52 @@
1
+ import { ruleAttrIndex } from './rule-syntax.js'
2
+
3
+ function isPlainObject(v) {
4
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
5
+ }
6
+
7
+ function label(path) {
8
+ return path.reduce((out, part) => (typeof part === 'number' ? `${out}[${part}]` : out ? `${out}.${part}` : part), '')
9
+ }
10
+
11
+ // Walks rules against data without touching the document. Reports data keys
12
+ // the rules do not define, and scalar rules whose selector matches nothing.
13
+ // Shape problems are left to apply(), which already reports them. Inside list
14
+ // items only unknown keys are checked, because new rows do not exist yet.
15
+ export function planWrite(adapter, root, rules, data, opts = {}) {
16
+ const unknownKeys = []
17
+ const unmatched = []
18
+
19
+ function checkKeys(rule, value, path) {
20
+ if (Array.isArray(rule)) {
21
+ const [, shape] = rule
22
+ if (!Array.isArray(value) || !isPlainObject(shape)) return
23
+ value.forEach((item, i) => checkKeys(shape, item, [...path, i]))
24
+ return
25
+ }
26
+ if (!isPlainObject(rule) || !isPlainObject(value)) return
27
+ for (const key of Object.keys(value)) {
28
+ if (!Object.prototype.hasOwnProperty.call(rule, key)) unknownKeys.push(label([...path, key]))
29
+ else checkKeys(rule[key], value[key], [...path, key])
30
+ }
31
+ }
32
+
33
+ function checkTargets(ctx, rule, value, path) {
34
+ if (value === undefined) return
35
+ if (typeof rule === 'string') {
36
+ if (rule.endsWith('[]') || rule === '.' || rule.startsWith('@')) return
37
+ const at = ruleAttrIndex(rule)
38
+ const selector = at === -1 ? rule : rule.slice(0, at)
39
+ if (!selector) return
40
+ if (adapter.find(ctx, selector, opts).length === 0) unmatched.push({ path: label(path), selector })
41
+ return
42
+ }
43
+ if (Array.isArray(rule)) return
44
+ if (isPlainObject(rule) && isPlainObject(value)) {
45
+ for (const [key, sub] of Object.entries(rule)) checkTargets(ctx, sub, value[key], [...path, key])
46
+ }
47
+ }
48
+
49
+ checkKeys(rules, data, [])
50
+ checkTargets(root, rules, data, [])
51
+ return { unknownKeys, unmatched }
52
+ }
@@ -0,0 +1,136 @@
1
+ import { POLICY_TOKENS } from '../lib/region-capabilities.js'
2
+ import { WriteRefused } from './errors.js'
3
+
4
+ export const WRITE_POLICY = Object.freeze({
5
+ refusedRuleForms: ['innerHTML', 'outerHTML'],
6
+ refusedTargets: ['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'xmp', 'plaintext', 'noembed', 'noframes', 'meta', 'link', 'base', 'animate', 'set', 'animatemotion', 'animatetransform'],
7
+ refusedAttributes: [
8
+ 'style', 'srcdoc', 'is', 'http-equiv', 'sandbox', 'allow', 'nonce', 'integrity', 'charset',
9
+ 'htmlclaytoken', 'htmlclayid', 'data-rules-name', 'data-rules-version', 'clay', 'contenteditable',
10
+ ...POLICY_TOKENS,
11
+ ],
12
+ refusedAttributePrefixes: ['on'],
13
+ urlAttributes: ['href', 'src', 'srcset', 'action', 'formaction', 'poster', 'cite', 'data', 'ping', 'background', 'longdesc', 'usemap', 'xlink:href', 'manifest', 'codebase', 'icon'],
14
+ refusedSchemes: ['javascript', 'vbscript', 'data'],
15
+ })
16
+
17
+ const TEXT_PROPS = new Set(['textContent', 'innerText'])
18
+
19
+ export function urlScheme(value) {
20
+ const cleaned = String(value).replace(/^[\u0000- ]+|[\u0000- ]+$/g, '').replace(/[\t\n\r]/g, '')
21
+ const m = cleaned.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)
22
+ return m ? m[1].toLowerCase() : null
23
+ }
24
+
25
+ const VALID_ATTR_NAME = /^[^\t\n\f\r "'>/=\u0000-\u001f\u007f]+$/
26
+
27
+ export function checkAttribute(name, value, policy = WRITE_POLICY) {
28
+ if (!VALID_ATTR_NAME.test(String(name))) return `attribute name ${JSON.stringify(String(name))} is not valid`
29
+ const lower = String(name).toLowerCase()
30
+ if (policy.refusedAttributePrefixes.some((p) => lower.startsWith(p))) return `attribute "${lower}" can run script`
31
+ if (policy.refusedAttributes.includes(lower)) return `attribute "${lower}" is not content`
32
+ if (policy.urlAttributes.includes(lower) && value != null) {
33
+ const urls = lower === 'srcset'
34
+ ? String(value).split(',').map((c) => c.trim().split(/\s+/)[0]).filter(Boolean)
35
+ : [String(value)]
36
+ for (const url of urls) {
37
+ const scheme = urlScheme(url)
38
+ if (scheme && policy.refusedSchemes.includes(scheme)) return `"${scheme}:" URLs are not allowed in "${lower}"`
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ function tagOf(adapter, node) {
45
+ const t = adapter.prop(node, 'tagName')
46
+ return t ? String(t).toLowerCase() : ''
47
+ }
48
+
49
+ function describe(adapter, node) {
50
+ const tag = tagOf(adapter, node) || 'node'
51
+ const id = adapter.attr(node, 'id')
52
+ return id ? `<${tag}#${id}>` : `<${tag}>`
53
+ }
54
+
55
+ function refusedAncestor(adapter, node, policy) {
56
+ let cur = node
57
+ while (cur) {
58
+ const tag = tagOf(adapter, cur)
59
+ if (tag && policy.refusedTargets.includes(tag)) return tag
60
+ cur = adapter.parent(cur)
61
+ }
62
+ return null
63
+ }
64
+
65
+ function holdsRulesTag(adapter, node) {
66
+ return adapter.find(node, 'script[data-rules-name]', { includeRulesTag: true, templateAttr: null }).length > 0
67
+ }
68
+
69
+ function isOrHoldsRulesTag(adapter, node) {
70
+ if (tagOf(adapter, node) === 'script' && adapter.attr(node, 'data-rules-name') != null) return true
71
+ return holdsRulesTag(adapter, node)
72
+ }
73
+
74
+ // Wraps an adapter so every write is checked against the policy first. A write
75
+ // that breaks it is skipped and recorded; call assertClean() after apply() to
76
+ // throw one WriteRefused listing every violation.
77
+ export function guardAdapter(adapter, policy = WRITE_POLICY) {
78
+ const refusals = []
79
+ const refuse = (node, reason) => {
80
+ refusals.push({ target: describe(adapter, node), reason })
81
+ return false
82
+ }
83
+ const allowTarget = (node) => {
84
+ const bad = refusedAncestor(adapter, node, policy)
85
+ return bad ? refuse(node, `inside <${bad}>, which is not content`) : true
86
+ }
87
+ const allowText = (node) => {
88
+ if (!allowTarget(node)) return false
89
+ if (holdsRulesTag(adapter, node)) return refuse(node, 'a text write here would delete the data rules tag')
90
+ return true
91
+ }
92
+ const allowAttr = (node, name, value) => {
93
+ if (!allowTarget(node)) return false
94
+ const reason = checkAttribute(name, value, policy)
95
+ return reason ? refuse(node, reason) : true
96
+ }
97
+
98
+ const guarded = {
99
+ ...adapter,
100
+ text(node, value) {
101
+ if (value === undefined) return adapter.text(node)
102
+ if (allowText(node)) adapter.text(node, value)
103
+ },
104
+ attr(node, name, value) {
105
+ if (value === undefined) return adapter.attr(node, name)
106
+ if (allowAttr(node, name, value)) adapter.attr(node, name, value)
107
+ },
108
+ prop(node, name, value) {
109
+ if (value === undefined) return adapter.prop(node, name)
110
+ if (policy.refusedRuleForms.includes(name)) return refuse(node, `"@${name}" writes HTML, only text is allowed`)
111
+ if (TEXT_PROPS.has(name)) {
112
+ if (allowText(node)) adapter.prop(node, name, value)
113
+ return
114
+ }
115
+ const attrName = name === 'className' ? 'class' : name
116
+ if (allowAttr(node, attrName, value)) adapter.prop(node, name, value)
117
+ },
118
+ remove(node) {
119
+ if (isOrHoldsRulesTag(adapter, node)) {
120
+ refuse(node, 'removing this would delete the data rules tag')
121
+ return
122
+ }
123
+ adapter.remove(node)
124
+ },
125
+ replaceWith(node, html) {
126
+ if (html === adapter.prop(node, 'outerHTML')) return node
127
+ refuse(node, '"@outerHTML" writes HTML, only text is allowed')
128
+ return node
129
+ },
130
+ refusals,
131
+ assertClean() {
132
+ if (refusals.length) throw new WriteRefused(refusals.slice())
133
+ },
134
+ }
135
+ return guarded
136
+ }
@@ -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
+ }
package/src/write.js ADDED
@@ -0,0 +1,29 @@
1
+ import cheerioAdapter from './adapters/cheerio.js'
2
+ import { apply } from './engine/apply.js'
3
+ import { findRulesIn } from './engine/rules-tag.js'
4
+ import { guardAdapter } from './engine/write-policy.js'
5
+ import { planWrite } from './engine/write-plan.js'
6
+ import { trackAdapter, spliceDocument } from './engine/splice.js'
7
+ import { NoRulesTag, WriteRejected } from './engine/errors.js'
8
+
9
+ // Applies a JSON body to a document's source through the document's own rules
10
+ // tag, under the content-only policy. `load` is cheerio.load, passed in so this
11
+ // package keeps cheerio out of its browser bundles. Returns the new source and
12
+ // whether anything changed. Throws NoRulesTag, WriteRejected, WriteRefused,
13
+ // ShapeMismatch or EmptyListInsert, always before producing any output.
14
+ export function writeDocument(load, src, data, { token = 'api' } = {}) {
15
+ const $ = load(src, { sourceCodeLocationInfo: true })
16
+ const root = $.root()
17
+ const found = findRulesIn(cheerioAdapter, root, token)
18
+ if (!found) throw new NoRulesTag(token)
19
+ const { rules } = found
20
+ const plan = planWrite(cheerioAdapter, root, rules, data)
21
+ if (plan.unknownKeys.length || plan.unmatched.length) throw new WriteRejected(plan.unknownKeys, plan.unmatched)
22
+ const tracker = trackAdapter(cheerioAdapter)
23
+ const guarded = guardAdapter(tracker)
24
+ apply(guarded, root, rules, data, { templateAttr: 'cms-template' })
25
+ guarded.assertClean()
26
+ if (tracker.dirty.size === 0) return { html: src, changed: false, spliced: true }
27
+ const { html, spliced } = spliceDocument(load, src, $, tracker)
28
+ return { html, changed: html !== src, spliced }
29
+ }
@@ -1,5 +0,0 @@
1
- {
2
- "input": "tag",
3
- "script": "script",
4
- "style": "style"
5
- }
@@ -1,11 +0,0 @@
1
- tier: 3
2
- face: query
3
- expect: ok
4
- skip: htmlclay=ships @type fixed; fixed behavior asserted in htmlclay dataapi/props_test.go
5
- note: @type is NOT the type attribute. `type` is on the DOM property whitelist, and domhandler
6
- note: nodes already carry a `type` field naming the node kind, so cheerio's prop() returns that
7
- note: instead: "tag" for a normal element, "script" for a script, "style" for a style. Three
8
- note: values, none of them the author's type="text".
9
- note: DIVERGENCE, direction JS-only. This case pins the WART so an upstream fix gets noticed.
10
- note: htmlclay deliberately does not reproduce it and returns the type attribute, because that API
11
- note: has no existing htmlclay users and preserving the bug would buy nothing.