@nutin/cli 2.1.1 → 3.0.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 (229) hide show
  1. package/CHANGELOG.md +268 -0
  2. package/README.md +13 -5
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +9 -8
  34. package/templates/base/AGENTS.md.hbs +42 -41
  35. package/templates/base/GETTING_STARTED.md.hbs +26 -20
  36. package/templates/base/README.md.hbs +7 -4
  37. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  38. package/templates/base/src/app/guards.ts.hbs +1 -3
  39. package/templates/base/src/app/main.ts.hbs +2 -9
  40. package/templates/base/src/app/routes.ts.hbs +7 -2
  41. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  42. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  43. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  44. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  45. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  46. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  47. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  48. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
  49. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  50. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
  51. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
  52. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  53. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
  54. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  55. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  56. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  57. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  58. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  59. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  60. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  61. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  62. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  63. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  64. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  65. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  66. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  67. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  68. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  69. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  70. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  71. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  72. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  73. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  74. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  75. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  76. package/templates/base/src/core/index.ts.hbs +4 -2
  77. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  78. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  79. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  80. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  81. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  82. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  83. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  84. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  85. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  86. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  87. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  88. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  89. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  90. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  91. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  92. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  93. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  94. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  95. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
  96. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
  97. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  98. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  99. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  100. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  101. package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
  102. package/templates/base/src/core/services/router/router.ts.hbs +113 -53
  103. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  104. package/templates/base/tools/builder/builder.js.hbs +18 -6
  105. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  106. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  107. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  108. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  109. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  110. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  111. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  112. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  113. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  114. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  115. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  116. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  117. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  118. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  119. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  120. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  121. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  122. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  123. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  124. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  125. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  126. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
  127. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  128. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  129. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  130. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  131. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
  132. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  133. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  134. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
  135. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  136. package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
  137. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  138. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  139. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  140. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  141. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  142. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  143. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
  144. package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
  145. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  146. package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
  147. package/templates/base/tools/dev/serve.js.hbs +152 -22
  148. package/templates/base/tools/dev/watcher.js.hbs +34 -3
  149. package/templates/base/tools/generator/generator.js.hbs +1 -1
  150. package/templates/base/tools/generator/handle-file.js.hbs +4 -2
  151. package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
  152. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  153. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  154. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  155. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  156. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  157. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  158. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  159. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  160. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  161. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  162. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  163. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  164. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  165. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  166. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  167. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  168. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  169. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  170. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  171. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  172. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  173. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  174. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  175. package/templates/base/tools/utils/build-lock.js.hbs +30 -2
  176. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  177. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  178. package/templates/base/tools/utils/format.js.hbs +1 -1
  179. package/templates/base/tools/utils/index.js.hbs +2 -1
  180. package/templates/base/tools/utils/print.js.hbs +2 -2
  181. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  182. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  183. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  184. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  185. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  186. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
  187. package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
  188. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  189. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  190. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  191. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  192. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  193. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  194. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  195. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  196. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
  197. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  198. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  199. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  200. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  201. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  202. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  203. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  204. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  205. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  206. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  207. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  208. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  209. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  210. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  211. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  212. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  213. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  214. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  215. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  216. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  220. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  221. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  222. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  223. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  224. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  225. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  226. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  227. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  228. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  229. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,4 +1,4 @@
1
- import { SecurityHelper } from '#root/dist/src/core/base-classes/base-component/helpers/security.helper.js';
1
+ import * as SecurityHelper from '#root/dist/src/core/base-classes/base-component/helpers/security.helper.js';
2
2
 
3
3
  describe('SecurityHelper', () => {
4
4
  it('sanitizeTemplate returns an empty string for null and undefined', () => {
@@ -102,28 +102,146 @@ describe('SecurityHelper', () => {
102
102
  expect(result.includes('javascript:')).toBe(false);
103
103
  });
104
104
 
105
- it('sanitizeInputElement escapes the value of an input element', () => {
106
- const input = document.createElement('input');
107
- input.value = '<script>alert(1)</script>';
108
- expect(SecurityHelper.sanitizeInputElement(input)).toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
105
+ it('sanitizeTemplate "normal" level strips javascript: hrefs, including whitespace-obfuscated ones', () => {
106
+ const result = SecurityHelper.sanitizeTemplate(
107
+ '<a href="javascript:alert(1)">a</a><a href=" java\tscript:alert(1)">b</a>',
108
+ 'normal'
109
+ );
110
+ expect(result).toBe('<a>a</a><a>b</a>');
111
+ });
112
+
113
+ it('sanitizeTemplate "normal" level strips javascript: from form action and formaction', () => {
114
+ const result = SecurityHelper.sanitizeTemplate(
115
+ '<form action="javascript:alert(1)"><button formaction="javascript:alert(1)">x</button></form>',
116
+ 'normal'
117
+ );
118
+ expect(result.includes('javascript:')).toBe(false);
119
+ });
120
+
121
+ it('sanitizeTemplate "normal" level strips javascript: from svg xlink:href', () => {
122
+ const result = SecurityHelper.sanitizeTemplate(
123
+ '<svg><a xlink:href="javascript:alert(1)"><text>x</text></a></svg>',
124
+ 'normal'
125
+ );
126
+ expect(result.includes('javascript:')).toBe(false);
109
127
  });
110
128
 
111
- it('sanitizeInputElement escapes the value of a textarea element', () => {
112
- const textarea = document.createElement('textarea');
113
- textarea.value = '<b>bold</b>';
114
- expect(SecurityHelper.sanitizeInputElement(textarea)).toBe('&lt;b&gt;bold&lt;/b&gt;');
129
+ it('sanitizeTemplate "normal" level keeps safe and data: URLs', () => {
130
+ const result = SecurityHelper.sanitizeTemplate(
131
+ '<a href="https://example.com">a</a><img src="data:image/png;base64,AAAA">',
132
+ 'normal'
133
+ );
134
+ expect(result).toContain('href="https://example.com"');
135
+ expect(result).toContain('src="data:image/png;base64,AAAA"');
136
+ });
137
+
138
+ it('sanitizeTemplate "normal" and "strict" levels strip srcdoc, even when its markup is escaped', () => {
139
+ const template = '<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>';
140
+ expect(SecurityHelper.sanitizeTemplate(template, 'normal')).toBe('<iframe></iframe>');
141
+ expect(SecurityHelper.sanitizeTemplate(template, 'strict')).toBe('');
142
+ });
143
+
144
+ it('sanitizeTemplate "trusted" level keeps javascript: URLs and srcdoc', () => {
145
+ const template = '<a href="javascript:void(0)">a</a><iframe srcdoc="x"></iframe>';
146
+ expect(SecurityHelper.sanitizeTemplate(template, 'trusted')).toBe(template);
115
147
  });
116
148
 
117
- it('sanitizeInputElement escapes innerText for contenteditable elements', () => {
118
- const div = document.createElement('div');
119
- div.setAttribute('contenteditable', 'true');
120
- div.innerText = '<i>hi</i>';
121
- expect(SecurityHelper.sanitizeInputElement(div)).toBe('&lt;i&gt;hi&lt;/i&gt;');
149
+ it('sanitizeTemplate "normal" level strips svg <set>/<animate> that rewrite href', () => {
150
+ const result = SecurityHelper.sanitizeTemplate(
151
+ '<svg><a>' +
152
+ '<set attributeName="href" to="javascript:alert(1)"/>' +
153
+ '<animate attributeName=" HREF " values="javascript:alert(1)"/>' +
154
+ '<animate attributeName="xlink:href" from="javascript:alert(1)" to="x"/>' +
155
+ '<text>x</text></a></svg>',
156
+ 'normal'
157
+ );
158
+ expect(result.includes('javascript:')).toBe(false);
159
+ expect(result).toContain('<text>x</text>');
160
+ });
161
+
162
+ it('sanitizeTemplate "normal" level keeps svg animations of non-URL attributes', () => {
163
+ const result = SecurityHelper.sanitizeTemplate(
164
+ '<svg><circle r="5"><animate attributeName="opacity" values="0;1" dur="1s"></animate></circle></svg>',
165
+ 'normal'
166
+ );
167
+ expect(result).toContain('attributeName="opacity"');
168
+ });
169
+
170
+ it('sanitizeTemplate "normal" level keeps an svg <set> without attributeName', () => {
171
+ const result = SecurityHelper.sanitizeTemplate('<svg><set to="1"></set></svg>', 'normal');
172
+ expect(result).toContain('<set to="1">');
173
+ });
174
+
175
+ it('stripBindings removes Nutin binding attributes and keeps the rest', () => {
176
+ const result = SecurityHelper.stripBindings(
177
+ '<button class="b" data-event="click:_remove" data-component="x" data-catalog="y" data-bind="z" ' +
178
+ 'data-i18n="k" data-pipe="upper" data-pipe-source="s" data-id="7">x</button>'
179
+ );
180
+ expect(result).toBe('<button class="b" data-id="7">x</button>');
181
+ });
182
+
183
+ it('stripBindingsFrom strips an element in place, keeping class, style, other data-* and text', () => {
184
+ const root = document.createElement('section');
185
+ root.innerHTML = '<a href="/x" class="link" style="color: red" data-event="click:_go" data-i18n="nav.x" data-card-description>X</a>'
186
+ + '<div data-component="child" data-catalog="items" data-bind="name" data-pipe="upper" data-pipe-source="v"></div>'
187
+ + '<pre><code>&lt;div data-component="hello"&gt;&lt;/div&gt;</code></pre>'
188
+ + '<template><i data-event="click:_x">i</i></template>';
189
+
190
+ SecurityHelper.stripBindingsFrom(root);
191
+
192
+ const link = root.querySelector('a');
193
+ expect(link.getAttribute('class')).toBe('link');
194
+ expect(link.getAttribute('style')).toBe('color: red');
195
+ expect(link.hasAttribute('data-card-description')).toBe(true);
196
+ expect(link.hasAttribute('data-event') || link.hasAttribute('data-i18n')).toBe(false);
197
+ expect(root.querySelector('div').attributes.length).toBe(0);
198
+ expect(root.querySelector('code').textContent).toBe('<div data-component="hello"></div>');
199
+ expect(root.querySelector('template').innerHTML).toBe('<i>i</i>');
200
+ });
201
+
202
+ it('stripBindings also strips nested <template> content', () => {
203
+ const result = SecurityHelper.stripBindings('<template><i data-event="click:_x">i</i></template>');
204
+ expect(result).toBe('<template><i>i</i></template>');
205
+ });
206
+
207
+ it('sanitizeToFragment returns sanitized nodes without re-serializing them', () => {
208
+ const fragment = SecurityHelper.sanitizeToFragment('<p onclick="x()">a</p><script>x()</script>', 'normal');
209
+ expect(fragment.childNodes.length).toBe(1);
210
+ expect(fragment.firstChild.outerHTML).toBe('<p>a</p>');
211
+ });
212
+
213
+ it('sanitizeToFragment keeps everything at "trusted" and handles null', () => {
214
+ const fragment = SecurityHelper.sanitizeToFragment('<p onclick="x()">a</p>', 'trusted');
215
+ expect(fragment.firstChild.getAttribute('onclick')).toBe('x()');
216
+ expect(SecurityHelper.sanitizeToFragment(null).childNodes.length).toBe(0);
217
+ });
218
+
219
+ it('sanitizeTemplate "normal" level strips style, link, base and meta elements', () => {
220
+ const result = SecurityHelper.sanitizeTemplate(
221
+ '<style>p{}</style><link rel="stylesheet" href="/x.css"><base href="/x/"><meta http-equiv="refresh" content="0"><p>ok</p>',
222
+ 'normal'
223
+ );
224
+ expect(result).toBe('<p>ok</p>');
225
+ });
226
+
227
+ it('sanitizeTemplate "normal" level strips script and style inside svg', () => {
228
+ const result = SecurityHelper.sanitizeTemplate('<svg><script>x()</script><style>a{}</style><circle r="1"></circle></svg>', 'normal');
229
+ expect(result).toBe('<svg><circle r="1"></circle></svg>');
230
+ });
231
+
232
+ it('sanitizeTemplate "normal" level strips data: and javascript: documents from frames but keeps https ones', () => {
233
+ const result = SecurityHelper.sanitizeTemplate(
234
+ '<iframe src="data:text/html,x"></iframe><object data="data:text/html,x"></object>' +
235
+ '<embed src="javascript:x()"><iframe src="https://example.com"></iframe>',
236
+ 'normal'
237
+ );
238
+ expect(result).toBe('<iframe></iframe><object></object><embed><iframe src="https://example.com"></iframe>');
122
239
  });
123
240
 
124
- it('sanitizeInputElement returns an empty string for a plain, non-editable element', () => {
125
- const div = document.createElement('div');
126
- expect(SecurityHelper.sanitizeInputElement(div)).toBe('');
241
+ it('sanitizeTemplate and stripBindings do not throw on a <template> inside svg', () => {
242
+ const markup = '<svg><template><text data-event="click:_x" onclick="x()">t</text></template></svg>';
243
+ expect(SecurityHelper.sanitizeTemplate(markup, 'normal')).toBe('<svg><template><text data-event="click:_x">t</text></template></svg>');
244
+ expect(SecurityHelper.stripBindings(markup)).toBe('<svg><template><text onclick="x()">t</text></template></svg>');
127
245
  });
128
246
 
129
247
  it('escapeHtml escapes all special characters', () => {
@@ -1,101 +1,219 @@
1
- export type TrustLevel = 'strict' | 'normal' | 'trusted';
2
-
3
- export class SecurityHelper {
4
- private static readonly STRICT_STRIPPED_TAGS = new Set(['IFRAME', 'OBJECT', 'EMBED']);
5
- private static readonly URL_ATTRS = new Set([
6
- 'href',
7
- 'src',
8
- 'action',
9
- 'formaction',
10
- 'poster',
11
- 'background',
12
- ]);
13
- private static readonly DANGEROUS_URL_SCHEMES = /^(javascript|data):/i;
14
-
15
- public static sanitizeTemplate(value: unknown, trustLevel: TrustLevel = 'normal'): string {
16
- if (value === null || value === undefined) return '';
17
- let template = String(value);
18
-
19
- return this.sanitizeInputTemplate(template, trustLevel);
20
- }
1
+ // Compared against localName, so SVG/MathML elements of the same name are caught too.
2
+ // style/link restyle the whole page, base moves every relative URL, meta can refresh/navigate.
3
+ const STRIPPED_TAGS = new Set(['script', 'style', 'link', 'base', 'meta']);
4
+ const STRICT_STRIPPED_TAGS = new Set(['iframe', 'object', 'embed', 'frame']);
21
5
 
22
- public static sanitizeInputElement(
23
- el: HTMLInputElement | HTMLTextAreaElement | HTMLElement
24
- ): string {
25
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
26
- return SecurityHelper.escapeHtml(el.value);
27
- }
28
- if (el.hasAttribute('contenteditable')) {
29
- return SecurityHelper.escapeHtml(el.innerText);
30
- }
31
- return '';
6
+ // A data: document in a frame runs its own scripts (opaque origin): only allowed at 'trusted'.
7
+ const FRAME_TAGS = new Set(['iframe', 'object', 'embed', 'frame']);
8
+ const FRAME_URL_ATTRS = new Set(['src', 'data']);
9
+ const URL_ATTRS = new Set([
10
+ 'href',
11
+ 'src',
12
+ 'action',
13
+ 'formaction',
14
+ 'poster',
15
+ 'background',
16
+ 'xlink:href',
17
+ ]);
18
+
19
+ // Attributes Nutin acts on after render: in injected markup they bind to the component's methods/children.
20
+ const BINDING_ATTRS = [
21
+ 'data-event',
22
+ 'data-component',
23
+ 'data-catalog',
24
+ 'data-bind',
25
+ 'data-i18n',
26
+ 'data-pipe',
27
+ 'data-pipe-source',
28
+ ];
29
+
30
+ // SVG <animate>/<set> can rewrite an attribute after sanitization: drop those targeting a URL attribute.
31
+ const ATTR_ANIMATION_TAGS = new Set(['animate', 'set']);
32
+
33
+ // javascript: is stripped from 'normal' up; data: only under 'strict' (data: images are legitimate).
34
+ const SCRIPT_URL_SCHEME = /^javascript:/i;
35
+ const STRICT_URL_SCHEMES = /^(javascript|data):/i;
36
+
37
+ // String form of sanitizeToFragment(). Rendering inserts the fragment instead: assigning
38
+ // this string to innerHTML parses it a second time.
39
+ export function sanitizeTemplate(value: unknown, trustLevel: Nutin.TrustLevel = 'normal'): string {
40
+ if (value === null || value === undefined) return '';
41
+ if (trustLevel === 'trusted' && !rawsOf(value)) return String(value);
42
+
43
+ const holder = document.createElement('template');
44
+ holder.content.append(sanitizeToFragment(value, trustLevel));
45
+ return holder.innerHTML;
46
+ }
47
+
48
+ export function escapeHtml(value: unknown): string {
49
+ if (value === null || value === undefined) return '';
50
+
51
+ return String(value)
52
+ .replace(/&/g, '&amp;')
53
+ .replace(/</g, '&lt;')
54
+ .replace(/>/g, '&gt;')
55
+ .replace(/"/g, '&quot;')
56
+ .replace(/'/g, '&#039;');
57
+ }
58
+
59
+ // Parses once and returns the sanitized nodes: inserting them directly means the browser never re-parses the
60
+ // cleaned markup, which is where mutation XSS turns inert text back into elements.
61
+ // A SafeHtml from html`` may carry raw() sources behind placeholders: each is parsed once,
62
+ // in its real parent context, stripped of bindings and sanitized as nodes, then put in place.
63
+ export function sanitizeToFragment(value: unknown, trustLevel: Nutin.TrustLevel = 'normal'): DocumentFragment {
64
+ const holder = document.createElement('template');
65
+ const raws = rawsOf(value);
66
+ holder.innerHTML = value === null || value === undefined ? '' : raws ? (value as { value: string }).value : String(value);
67
+ if (trustLevel !== 'trusted') sanitizeNode(holder.content, trustLevel);
68
+ if (raws) resolveRaws(holder.content, raws, trustLevel);
69
+ return holder.content;
70
+ }
71
+
72
+ // raw() in attribute position (`<a ${raw('aria-current="page"')}>`): rebuilt from the parsed
73
+ // attributes, minus event handlers, srcdoc, bindings and script URLs.
74
+ export function sanitizeAttributes(source: string): string {
75
+ const holder = document.createElement('template');
76
+ holder.innerHTML = `<span ${source}></span>`;
77
+ // An unterminated quote makes the parser drop the whole tag: nothing to keep.
78
+ const el = holder.content.firstElementChild;
79
+ if (!el) return '';
80
+ return Array.from(el.attributes)
81
+ .filter((attr) => isAllowedRawAttribute(attr.name.toLowerCase(), attr.value))
82
+ .map((attr) => `${attr.name}="${escapeHtml(attr.value)}"`)
83
+ .join(' ');
84
+ }
85
+
86
+ // A javascript: URL in a URL attribute (name lowercased). html`` checks values from data with it at
87
+ // every trust level, since the render sanitizer doesn't run at 'trusted'.
88
+ export function isScriptUrl(name: string, value: string): boolean {
89
+ return URL_ATTRS.has(name) && isDangerousUrl(value, 'normal');
90
+ }
91
+
92
+ export function stripBindings(markup: string): string {
93
+ const holder = document.createElement('template');
94
+ holder.innerHTML = markup;
95
+ stripBindingsFrom(holder.content);
96
+ return holder.innerHTML;
97
+ }
98
+
99
+ // In place, inside <template> content too. Also used by SEO prerendering: nothing binds a
100
+ // prerendered page (the app re-renders it), so its binding attributes are dead weight.
101
+ export function stripBindingsFrom(root: ParentNode): void {
102
+ const selector = BINDING_ATTRS.map((attr) => `[${attr}]`).join(',');
103
+ root.querySelectorAll(selector).forEach((el) => {
104
+ BINDING_ATTRS.forEach((attr) => el.removeAttribute(attr));
105
+ });
106
+ root.querySelectorAll('template').forEach((el) => {
107
+ const content = templateContent(el);
108
+ if (content) stripBindingsFrom(content);
109
+ });
110
+ }
111
+
112
+ function rawsOf(value: unknown): ReadonlyMap<string, string> | null {
113
+ const raws = (value as { raws?: unknown } | null)?.raws;
114
+ return raws instanceof Map ? raws : null;
115
+ }
116
+
117
+ function resolveRaws(root: ParentNode, raws: ReadonlyMap<string, string>, trustLevel: Nutin.TrustLevel): void {
118
+ // Snapshot first: content put in place is never searched for placeholders again.
119
+ for (const placeholder of collectPlaceholders(root)) {
120
+ const source = raws.get(placeholder.getAttribute('data-nutin-raw') ?? '');
121
+ const parsed = parseInContext(source ?? '', placeholder.parentNode);
122
+ if (trustLevel !== 'trusted') sanitizeNode(parsed, trustLevel);
123
+ stripBindingsFrom(parsed);
124
+ placeholder.replaceWith(...Array.from(parsed.childNodes));
32
125
  }
126
+ }
33
127
 
34
- public static escapeHtml(value: unknown): string {
35
- if (value === null || value === undefined) return '';
128
+ function collectPlaceholders(root: ParentNode): Element[] {
129
+ const found: Element[] = [];
130
+ root.querySelectorAll('template').forEach((el) => {
131
+ if (el.hasAttribute('data-nutin-raw')) found.push(el);
132
+ const content = templateContent(el);
133
+ if (content) found.push(...collectPlaceholders(content));
134
+ });
135
+ return found;
136
+ }
36
137
 
37
- return String(value)
38
- .replace(/&/g, '&amp;')
39
- .replace(/</g, '&lt;')
40
- .replace(/>/g, '&gt;')
41
- .replace(/"/g, '&quot;')
42
- .replace(/'/g, '&#039;');
138
+ // Parses inside the inert document behind <template> (nothing loads or runs there), using an
139
+ // element like the placeholder's parent as context so <svg>/<math>/<table> content parses as
140
+ // it will once inserted.
141
+ function parseInContext(source: string, parent: Node | null): ParentNode {
142
+ const holder = document.createElement('template');
143
+ const parentEl = parent && parent.nodeType === 1 ? (parent as Element) : null;
144
+ if (parentEl && !templateContent(parentEl)) {
145
+ const context = holder.content.ownerDocument.createElementNS(parentEl.namespaceURI, parentEl.localName);
146
+ context.innerHTML = source;
147
+ return context;
43
148
  }
149
+ holder.innerHTML = source;
150
+ return holder.content;
151
+ }
44
152
 
45
- private static sanitizeInputTemplate(template: string, trustLevel: TrustLevel): string {
46
- // Trusted: no template sanitization
47
- if (trustLevel === 'trusted') {
48
- return template;
49
- }
153
+ function isAllowedRawAttribute(name: string, value: string): boolean {
154
+ if (!/^[a-z_:][-a-z0-9_:.]*$/.test(name)) return false;
155
+ if (name.startsWith('on') || name === 'srcdoc' || BINDING_ATTRS.includes(name)) return false;
156
+ return !isScriptUrl(name, value);
157
+ }
50
158
 
51
- const holder = document.createElement('template');
52
- holder.innerHTML = template;
53
- this.sanitizeNode(holder.content, trustLevel);
159
+ // Only an HTML <template> has a content fragment; <svg><template> is a plain element.
160
+ function templateContent(el: Element): DocumentFragment | null {
161
+ const content = (el as HTMLTemplateElement).content;
162
+ return content && typeof content.querySelectorAll === 'function' ? content : null;
163
+ }
54
164
 
55
- return holder.innerHTML;
56
- }
165
+ function sanitizeNode(root: ParentNode, trustLevel: Nutin.TrustLevel): void {
166
+ // Snapshot children before mutating — removing an element from a live
167
+ // collection while iterating it would skip its next sibling.
168
+ const children = Array.from(root.children);
57
169
 
58
- private static sanitizeNode(root: ParentNode, trustLevel: TrustLevel): void {
59
- // Snapshot children before mutating — removing an element from a live
60
- // collection while iterating it would skip its next sibling.
61
- const children = Array.from(root.children);
170
+ for (const el of children) {
171
+ const tag = el.localName.toLowerCase();
62
172
 
63
- for (const el of children) {
64
- const tag = el.tagName;
173
+ if (STRIPPED_TAGS.has(tag)) {
174
+ el.remove();
175
+ continue;
176
+ }
177
+ if (trustLevel === 'strict' && STRICT_STRIPPED_TAGS.has(tag)) {
178
+ el.remove();
179
+ continue;
180
+ }
181
+ if (animatesUrlAttribute(el)) {
182
+ el.remove();
183
+ continue;
184
+ }
65
185
 
66
- if (tag === 'SCRIPT') {
67
- el.remove();
186
+ for (const attr of Array.from(el.attributes)) {
187
+ const name = attr.name.toLowerCase();
188
+ // srcdoc is parsed as a same-origin document: escaped markup in it is decoded and runs.
189
+ if (name.startsWith('on') || name === 'srcdoc') {
190
+ el.removeAttribute(attr.name);
68
191
  continue;
69
192
  }
70
- if (trustLevel === 'strict' && this.STRICT_STRIPPED_TAGS.has(tag)) {
71
- el.remove();
193
+ if (URL_ATTRS.has(name) && isDangerousUrl(attr.value, trustLevel)) {
194
+ el.removeAttribute(attr.name);
72
195
  continue;
73
196
  }
74
-
75
- for (const attr of Array.from(el.attributes)) {
76
- const name = attr.name.toLowerCase();
77
- if (name.startsWith('on')) {
78
- el.removeAttribute(attr.name);
79
- continue;
80
- }
81
- if (trustLevel === 'strict' && this.URL_ATTRS.has(name) && this.isDangerousUrl(attr.value)) {
82
- el.removeAttribute(attr.name);
83
- }
84
- }
85
-
86
- if (tag === 'TEMPLATE') {
87
- this.sanitizeNode((el as HTMLTemplateElement).content, trustLevel);
88
- } else {
89
- this.sanitizeNode(el, trustLevel);
197
+ if (FRAME_TAGS.has(tag) && FRAME_URL_ATTRS.has(name) && isDangerousUrl(attr.value, 'strict')) {
198
+ el.removeAttribute(attr.name);
90
199
  }
91
200
  }
92
- }
93
201
 
94
- private static isDangerousUrl(value: string): boolean {
95
- // Browsers strip ASCII tab/newline/CR from a URL (and trim leading C0
96
- // control/space) before scheme-sniffing, so "java\tscript:" still runs
97
- // as javascript: — normalize the same way before checking the scheme.
98
- const normalized = value.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+/, '');
99
- return this.DANGEROUS_URL_SCHEMES.test(normalized);
202
+ sanitizeNode(templateContent(el) ?? el, trustLevel);
100
203
  }
101
204
  }
205
+
206
+ function animatesUrlAttribute(el: Element): boolean {
207
+ if (!ATTR_ANIMATION_TAGS.has(el.localName.toLowerCase())) return false;
208
+ const target = (el.getAttribute('attributeName') ?? '').trim().toLowerCase();
209
+ return URL_ATTRS.has(target) || URL_ATTRS.has(target.replace(/^xlink:/, ''));
210
+ }
211
+
212
+ function isDangerousUrl(value: string, trustLevel: Nutin.TrustLevel): boolean {
213
+ // Browsers strip ASCII tab/newline/CR from a URL (and trim leading C0
214
+ // control/space) before scheme-sniffing, so "java\tscript:" still runs
215
+ // as javascript: — normalize the same way before checking the scheme.
216
+ const normalized = value.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+/, '');
217
+ const schemes = trustLevel === 'strict' ? STRICT_URL_SCHEMES : SCRIPT_URL_SCHEME;
218
+ return schemes.test(normalized);
219
+ }
@@ -1,4 +1,4 @@
1
- import { TokenHelper } from '#root/dist/src/core/base-classes/base-component/helpers/token.helper.js';
1
+ import * as TokenHelper from '#root/dist/src/core/base-classes/base-component/helpers/token.helper.js';
2
2
 
3
3
  describe('TokenHelper', () => {
4
4
  const makeEvent = (overrides = {}) => ({
@@ -11,26 +11,26 @@ describe('TokenHelper', () => {
11
11
  ...overrides,
12
12
  });
13
13
 
14
- it('resolve always calls event.preventDefault()', () => {
14
+ it('resolve leaves the event default alone', () => {
15
15
  let called = false;
16
16
  const ev = makeEvent({ preventDefault: () => { called = true; } });
17
17
  TokenHelper.resolve('@id', document.createElement('div'), ev);
18
- expect(called).toBe(true);
18
+ expect(called).toBe(false);
19
19
  });
20
20
 
21
- it('@id resolves and escapes the element id', () => {
21
+ it('@id resolves the raw element id', () => {
22
22
  const el = document.createElement('div');
23
23
  el.id = '<x>';
24
- expect(TokenHelper.resolve('@id', el, makeEvent())).toBe('&lt;x&gt;');
24
+ expect(TokenHelper.resolve('@id', el, makeEvent())).toBe('<x>');
25
25
  });
26
26
 
27
- it('@class resolves the escaped className', () => {
27
+ it('@class resolves the className', () => {
28
28
  const el = document.createElement('div');
29
29
  el.className = 'a b';
30
30
  expect(TokenHelper.resolve('@class', el, makeEvent())).toBe('a b');
31
31
  });
32
32
 
33
- it('@name resolves the escaped name attribute', () => {
33
+ it('@name resolves the name attribute', () => {
34
34
  const el = document.createElement('input');
35
35
  el.name = 'field';
36
36
  expect(TokenHelper.resolve('@name', el, makeEvent())).toBe('field');
@@ -41,15 +41,32 @@ describe('TokenHelper', () => {
41
41
  expect(TokenHelper.resolve('@name', el, makeEvent())).toBe('');
42
42
  });
43
43
 
44
- it('@tag resolves the escaped tag name', () => {
44
+ it('@tag resolves the tag name', () => {
45
45
  const el = document.createElement('div');
46
46
  expect(TokenHelper.resolve('@tag', el, makeEvent())).toBe('DIV');
47
47
  });
48
48
 
49
- it('@value resolves the sanitized input value', () => {
49
+ it('@value resolves the raw input value', () => {
50
50
  const el = document.createElement('input');
51
- el.value = '<b>';
52
- expect(TokenHelper.resolve('@value', el, makeEvent())).toBe('&lt;b&gt;');
51
+ el.value = '<b> & "c"';
52
+ expect(TokenHelper.resolve('@value', el, makeEvent())).toBe('<b> & "c"');
53
+ });
54
+
55
+ it('@value resolves the raw value of a textarea', () => {
56
+ const el = document.createElement('textarea');
57
+ el.value = '<b>bold</b>';
58
+ expect(TokenHelper.resolve('@value', el, makeEvent())).toBe('<b>bold</b>');
59
+ });
60
+
61
+ it('@value resolves innerText for contenteditable elements', () => {
62
+ const el = document.createElement('div');
63
+ el.setAttribute('contenteditable', 'true');
64
+ el.innerText = '<i>hi</i>';
65
+ expect(TokenHelper.resolve('@value', el, makeEvent())).toBe('<i>hi</i>');
66
+ });
67
+
68
+ it('@value resolves an empty string for a plain, non-editable element', () => {
69
+ expect(TokenHelper.resolve('@value', document.createElement('div'), makeEvent())).toBe('');
53
70
  });
54
71
 
55
72
  it('@checked resolves the checked state', () => {
@@ -67,30 +84,30 @@ describe('TokenHelper', () => {
67
84
  expect(TokenHelper.resolve('@selected', option, makeEvent())).toBe(true);
68
85
  });
69
86
 
70
- it('@textContent resolves escaped textContent', () => {
87
+ it('@textContent resolves raw textContent', () => {
71
88
  const el = document.createElement('div');
72
89
  el.textContent = '<i>';
73
- expect(TokenHelper.resolve('@textContent', el, makeEvent())).toBe('&lt;i&gt;');
90
+ expect(TokenHelper.resolve('@textContent', el, makeEvent())).toBe('<i>');
74
91
  });
75
92
 
76
93
  it('@textContent falls back to an empty string when el has no textContent', () => {
77
94
  expect(TokenHelper.resolve('@textContent', {}, makeEvent())).toBe('');
78
95
  });
79
96
 
80
- it('@innerText resolves escaped innerText', () => {
97
+ it('@innerText resolves raw innerText', () => {
81
98
  const el = document.createElement('div');
82
99
  el.innerText = '<i>';
83
- expect(TokenHelper.resolve('@innerText', el, makeEvent())).toBe('&lt;i&gt;');
100
+ expect(TokenHelper.resolve('@innerText', el, makeEvent())).toBe('<i>');
84
101
  });
85
102
 
86
103
  it('@innerText falls back to an empty string when el has no innerText', () => {
87
104
  expect(TokenHelper.resolve('@innerText', {}, makeEvent())).toBe('');
88
105
  });
89
106
 
90
- it('@html resolves escaped innerHTML', () => {
107
+ it('@html resolves raw innerHTML', () => {
91
108
  const el = document.createElement('div');
92
109
  el.innerHTML = '<i>x</i>';
93
- expect(TokenHelper.resolve('@html', el, makeEvent())).toBe('&lt;i&gt;x&lt;/i&gt;');
110
+ expect(TokenHelper.resolve('@html', el, makeEvent())).toBe('<i>x</i>');
94
111
  });
95
112
 
96
113
  it('@html falls back to an empty string when el has no innerHTML', () => {
@@ -120,9 +137,9 @@ describe('TokenHelper', () => {
120
137
  expect(TokenHelper.resolve('@y', document.createElement('div'), ev)).toBe(0);
121
138
  });
122
139
 
123
- it('@key and @code resolve to escaped keyboard event properties', () => {
140
+ it('@key and @code resolve to raw keyboard event properties', () => {
124
141
  const ev = makeEvent({ key: '<', code: 'KeyA' });
125
- expect(TokenHelper.resolve('@key', document.createElement('div'), ev)).toBe('&lt;');
142
+ expect(TokenHelper.resolve('@key', document.createElement('div'), ev)).toBe('<');
126
143
  expect(TokenHelper.resolve('@code', document.createElement('div'), ev)).toBe('KeyA');
127
144
  });
128
145
 
@@ -132,10 +149,10 @@ describe('TokenHelper', () => {
132
149
  expect(TokenHelper.resolve('@code', document.createElement('div'), ev)).toBe('');
133
150
  });
134
151
 
135
- it('@attr: resolves an escaped attribute value', () => {
152
+ it('@attr: resolves the raw attribute value', () => {
136
153
  const el = document.createElement('div');
137
154
  el.setAttribute('title', '<t>');
138
- expect(TokenHelper.resolve('@attr:title', el, makeEvent())).toBe('&lt;t&gt;');
155
+ expect(TokenHelper.resolve('@attr:title', el, makeEvent())).toBe('<t>');
139
156
  });
140
157
 
141
158
  it('@attr: falls back to an empty string when the attribute is missing', () => {
@@ -143,10 +160,10 @@ describe('TokenHelper', () => {
143
160
  expect(TokenHelper.resolve('@attr:title', el, makeEvent())).toBe('');
144
161
  });
145
162
 
146
- it('@dataset: resolves an escaped dataset value', () => {
163
+ it('@dataset: resolves the raw dataset value', () => {
147
164
  const el = document.createElement('div');
148
165
  el.dataset.foo = '<f>';
149
- expect(TokenHelper.resolve('@dataset:foo', el, makeEvent())).toBe('&lt;f&gt;');
166
+ expect(TokenHelper.resolve('@dataset:foo', el, makeEvent())).toBe('<f>');
150
167
  });
151
168
 
152
169
  it('@dataset: falls back to an empty string when the dataset key is missing', () => {