@nutin/cli 2.1.0 → 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 (241) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/README.md +14 -6
  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 +12 -11
  34. package/templates/base/.gitignore.hbs +1 -0
  35. package/templates/base/AGENTS.md.hbs +45 -43
  36. package/templates/base/GETTING_STARTED.md.hbs +32 -23
  37. package/templates/base/README.md.hbs +7 -4
  38. package/templates/base/nutin.config.js.hbs +1 -1
  39. package/templates/base/src/app/components/.gitkeep +0 -0
  40. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  41. package/templates/base/src/app/guards.ts.hbs +1 -3
  42. package/templates/base/src/app/main.ts.hbs +2 -9
  43. package/templates/base/src/app/routes.ts.hbs +9 -3
  44. package/templates/base/src/app/services/.gitkeep +0 -0
  45. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  47. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  48. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  49. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  50. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  51. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  52. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
  53. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  54. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
  55. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
  56. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  57. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
  58. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  59. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  63. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  64. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  65. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  66. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  67. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  68. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  69. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  70. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  71. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  72. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  73. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  74. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  75. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  76. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  77. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  78. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  79. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  80. package/templates/base/src/core/index.ts.hbs +4 -2
  81. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  82. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  83. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  84. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  85. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  88. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  89. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  90. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  91. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  92. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  93. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  94. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  95. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  96. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  97. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  98. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  99. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
  100. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
  101. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  102. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  103. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  104. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  105. package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
  106. package/templates/base/src/core/services/router/router.ts.hbs +122 -54
  107. package/templates/base/src/index.html.hbs +1 -0
  108. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  109. package/templates/base/src/styles/main.scss.hbs +1 -1
  110. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  111. package/templates/base/tools/builder/builder.js.hbs +26 -8
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  113. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  114. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  115. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  116. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  117. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  118. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  119. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  120. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  121. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  122. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  123. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  124. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  125. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  126. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  127. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  128. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  129. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  130. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  131. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  132. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  133. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  134. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  135. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
  136. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  137. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  138. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  139. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  140. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
  141. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  142. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  143. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
  144. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  145. package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
  146. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  147. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  148. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  149. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  150. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  151. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  152. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
  153. package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
  154. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  155. package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
  156. package/templates/base/tools/dev/serve.js.hbs +152 -22
  157. package/templates/base/tools/dev/watcher.js.hbs +75 -28
  158. package/templates/base/tools/generator/generator.js.hbs +23 -18
  159. package/templates/base/tools/generator/handle-file.js.hbs +4 -24
  160. package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
  161. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  162. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  163. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  164. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  165. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  166. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  167. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  168. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  169. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  170. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  171. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  172. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  173. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  174. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  175. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  176. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  177. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  178. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  179. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  180. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  181. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  182. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  183. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  184. package/templates/base/tools/utils/build-lock.js.hbs +97 -0
  185. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  186. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  187. package/templates/base/tools/utils/format.js.hbs +1 -1
  188. package/templates/base/tools/utils/index.js.hbs +3 -1
  189. package/templates/base/tools/utils/print.js.hbs +2 -2
  190. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  191. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  192. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  193. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  194. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  195. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
  196. package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
  197. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  198. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  199. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  200. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  201. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  202. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  203. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  204. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  205. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
  206. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  207. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  208. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  209. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  210. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  211. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  212. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  213. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  214. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  215. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  216. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  220. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  221. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  222. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  223. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  224. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  225. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  226. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  227. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  228. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  229. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  230. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  231. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  232. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  233. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  234. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  235. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  236. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  237. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  238. package/templates/base/src/app/components/index.ts.hbs +0 -1
  239. package/templates/base/src/app/services/index.ts.hbs +0 -1
  240. package/templates/base/src/app/views/index.ts.hbs +0 -3
  241. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,19 +1,24 @@
1
- import { SecurityHelper } from './security.helper.js';
1
+ // Tokens resolve to raw values: escaping happens at output, in html`` templates.
2
+ function readInputValue(el: HTMLElement): string {
3
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return el.value;
4
+ if (el.hasAttribute('contenteditable')) return el.innerText;
5
+ return '';
6
+ }
2
7
 
3
8
  // Exact token resolvers
4
9
  const EXACT_TOKEN_RESOLVERS: Record<string, (el: HTMLElement, ev: Event) => any> = {
5
- '@id': (el) => SecurityHelper.escapeHtml(el.id),
6
- '@class': (el) => SecurityHelper.escapeHtml(el.className),
7
- '@name': (el) => SecurityHelper.escapeHtml((el as any).name ?? ''),
8
- '@tag': (el) => SecurityHelper.escapeHtml(el.tagName),
10
+ '@id': (el) => el.id,
11
+ '@class': (el) => el.className,
12
+ '@name': (el) => (el as any).name ?? '',
13
+ '@tag': (el) => el.tagName,
9
14
 
10
- '@value': (el) => SecurityHelper.sanitizeInputElement(el as any),
15
+ '@value': (el) => readInputValue(el),
11
16
  '@checked': (el) => (el as HTMLInputElement).checked,
12
17
  '@selected': (el) => (el as HTMLOptionElement).selected,
13
18
 
14
- '@textContent': (el) => SecurityHelper.escapeHtml(el.textContent ?? ''),
15
- '@innerText': (el) => SecurityHelper.escapeHtml(el.innerText ?? ''),
16
- '@html': (el) => SecurityHelper.escapeHtml(el.innerHTML ?? ''),
19
+ '@textContent': (el) => el.textContent ?? '',
20
+ '@innerText': (el) => el.innerText ?? '',
21
+ '@html': (el) => el.innerHTML ?? '',
17
22
 
18
23
  '@event': (_el, ev) => ev,
19
24
  '@target': (_el, ev) => ev.target,
@@ -21,79 +26,76 @@ const EXACT_TOKEN_RESOLVERS: Record<string, (el: HTMLElement, ev: Event) => any>
21
26
  '@x': (_el, ev) => (ev as MouseEvent).clientX ?? 0,
22
27
  '@y': (_el, ev) => (ev as MouseEvent).clientY ?? 0,
23
28
 
24
- '@key': (_el, ev) => SecurityHelper.escapeHtml((ev as KeyboardEvent).key ?? ''),
25
- '@code': (_el, ev) => SecurityHelper.escapeHtml((ev as KeyboardEvent).code ?? ''),
29
+ '@key': (_el, ev) => (ev as KeyboardEvent).key ?? '',
30
+ '@code': (_el, ev) => (ev as KeyboardEvent).code ?? '',
26
31
  };
27
32
 
28
33
  // Prefix-based token resolvers
29
34
  const PREFIXED_TOKEN_RESOLVERS: Record<string, (suffix: string, el: HTMLElement) => any> = {
30
- '@attr:': (suffix, el) => SecurityHelper.escapeHtml(el.getAttribute(suffix) ?? ''),
31
- '@dataset:': (suffix, el) => SecurityHelper.escapeHtml((el.dataset as any)[suffix] ?? ''),
35
+ '@attr:': (suffix, el) => el.getAttribute(suffix) ?? '',
36
+ '@dataset:': (suffix, el) => (el.dataset as any)[suffix] ?? '',
32
37
  };
33
38
 
34
- export class TokenHelper {
35
- private static customResolvers: Record<string, (el: HTMLElement, ev: Event) => any> = {};
39
+ const customResolvers: Record<string, (el: HTMLElement, ev: Event) => any> = {};
36
40
 
37
- public static resolve(token: string, el: Element, event: Event): any {
38
- const htmlEl = el as HTMLElement;
39
- event.preventDefault();
41
+ export function resolve(token: string, el: Element, event: Event): any {
42
+ const htmlEl = el as HTMLElement;
40
43
 
41
- return (
42
- this.resolveExact(token, htmlEl, event) ??
43
- this.resolvePrefixed(token, htmlEl) ??
44
- this.resolveCustom(token, htmlEl, event) ??
45
- this.resolveLiteral(token) ??
46
- token
47
- );
48
- }
44
+ return (
45
+ resolveExact(token, htmlEl, event) ??
46
+ resolvePrefixed(token, htmlEl) ??
47
+ resolveCustom(token, htmlEl, event) ??
48
+ resolveLiteral(token) ??
49
+ token
50
+ );
51
+ }
49
52
 
50
53
  /** Register a new exact token like "@foo" */
51
- public static registerCustomToken(
52
- name: string,
53
- resolver: (el: HTMLElement, ev: Event) => any
54
- ): void {
55
- this.customResolvers[name] = resolver;
56
- }
54
+ export function registerCustomToken(
55
+ name: string,
56
+ resolver: (el: HTMLElement, ev: Event) => any
57
+ ): void {
58
+ customResolvers[name] = resolver;
59
+ }
57
60
 
58
61
  /** Register a new prefixed token like "@style:" */
59
- public static registerPrefixedToken(
60
- prefix: string,
61
- resolver: (suffix: string, el: HTMLElement) => any
62
- ): void {
63
- PREFIXED_TOKEN_RESOLVERS[prefix] = resolver;
64
- }
62
+ export function registerPrefixedToken(
63
+ prefix: string,
64
+ resolver: (suffix: string, el: HTMLElement) => any
65
+ ): void {
66
+ PREFIXED_TOKEN_RESOLVERS[prefix] = resolver;
67
+ }
65
68
 
66
- private static resolveExact(token: string, el: HTMLElement, ev: Event): any | null {
67
- const resolver = EXACT_TOKEN_RESOLVERS[token];
68
- return resolver ? resolver(el, ev) : null;
69
- }
69
+ function resolveExact(token: string, el: HTMLElement, ev: Event): any | null {
70
+ const resolver = EXACT_TOKEN_RESOLVERS[token];
71
+ return resolver ? resolver(el, ev) : null;
72
+ }
70
73
 
71
- private static resolvePrefixed(token: string, el: HTMLElement): any | null {
72
- for (const [prefix, resolver] of Object.entries(PREFIXED_TOKEN_RESOLVERS)) {
73
- if (token.startsWith(prefix)) {
74
- return resolver(token.slice(prefix.length), el);
75
- }
74
+ function resolvePrefixed(token: string, el: HTMLElement): any | null {
75
+ for (const [prefix, resolver] of Object.entries(PREFIXED_TOKEN_RESOLVERS)) {
76
+ if (token.startsWith(prefix)) {
77
+ return resolver(token.slice(prefix.length), el);
76
78
  }
77
- return null;
78
79
  }
80
+ return null;
81
+ }
79
82
 
80
- private static resolveCustom(token: string, el: HTMLElement, ev: Event): any | null {
81
- const resolver = this.customResolvers[token];
82
- return resolver ? resolver(el, ev) : null;
83
- }
83
+ function resolveCustom(token: string, el: HTMLElement, ev: Event): any | null {
84
+ const resolver = customResolvers[token];
85
+ return resolver ? resolver(el, ev) : null;
86
+ }
84
87
 
85
- private static resolveLiteral(token: string): any | null {
86
- // string literal
87
- if (
88
- (token.startsWith('"') && token.endsWith('"')) ||
89
- (token.startsWith("'") && token.endsWith("'"))
90
- ) {
91
- return token.slice(1, -1);
92
- }
93
- // number literal
94
- if (!isNaN(Number(token))) {
95
- return Number(token);
96
- }
97
- return null;
88
+ function resolveLiteral(token: string): any | null {
89
+ // string literal
90
+ if (
91
+ (token.startsWith('"') && token.endsWith('"')) ||
92
+ (token.startsWith("'") && token.endsWith("'"))
93
+ ) {
94
+ return token.slice(1, -1);
95
+ }
96
+ // number literal
97
+ if (!isNaN(Number(token))) {
98
+ return Number(token);
98
99
  }
100
+ return null;
99
101
  }
@@ -1,5 +1,4 @@
1
1
  import { Component } from '#root/dist/src/core/index.js';
2
- import { DataBindingHelper } from '#root/dist/src/core/base-classes/component/helpers/data-binding.helper.js';
3
2
 
4
3
  class TestComponent extends Component {
5
4
  constructor(options = {}) {
@@ -44,6 +43,13 @@ describe('Component', () => {
44
43
  expect(component.getElement().classList.contains('highlight')).toBe(true);
45
44
  });
46
45
 
46
+ it('onBeforeRender applies a space-separated props.className as several classes', () => {
47
+ const component = new TestComponent({ props: { className: ' btn primary ' } });
48
+ component.render();
49
+ expect(component.getElement().classList.contains('btn')).toBe(true);
50
+ expect(component.getElement().classList.contains('primary')).toBe(true);
51
+ });
52
+
47
53
  it('onBeforeRender applies props.style as cssText on render', () => {
48
54
  const component = new TestComponent({ props: { style: 'color: red;' } });
49
55
  component.render();
@@ -61,25 +67,19 @@ describe('Component', () => {
61
67
  expect(component.getElement().querySelector('span').textContent).toBe('Ada');
62
68
  });
63
69
 
64
- it('onAfterRender calls DataBindingHelper.applyDataBindings with the element and props, after the template has been written', () => {
65
- // onAfterRender runs after render() sets innerHTML from generateTemplate(),
66
- // so the template's own [data-bind] nodes already exist by the time this fires.
67
- const calls = [];
68
- const spy = spyOn(DataBindingHelper, 'applyDataBindings');
69
- spy.andCallFake((element, props) => {
70
- calls.push({ props, htmlAtCallTime: element.innerHTML });
71
- });
72
-
73
- const component = new TestComponent({
70
+ it('applies data-bindings in onAfterRender, so a subclass sees them once it calls super', () => {
71
+ let seen = null;
72
+ class BoundComponent extends Component {
73
+ onAfterRender() {
74
+ super.onAfterRender();
75
+ seen = this.getElement().querySelector('span').textContent;
76
+ }
77
+ }
78
+ const component = new BoundComponent({
74
79
  templateFn: () => '<span data-bind="name"></span>',
75
80
  props: { name: 'Ada' },
76
81
  });
77
82
  component.render();
78
-
79
- expect(calls.length).toBe(1);
80
- expect(calls[0].props).toEqual({ name: 'Ada' });
81
- expect(calls[0].htmlAtCallTime).toBe('<span data-bind="name"></span>');
82
-
83
- spy.restore();
83
+ expect(seen).toBe('Ada');
84
84
  });
85
85
  });
@@ -1,44 +1,11 @@
1
- import { BaseComponent, BaseComponentOptions } from '../../index.js';
2
- import { ConfigHelper } from './helpers/config.helper.js';
3
- import { DataBindingHelper } from './helpers/data-binding.helper.js';
1
+ import { BaseComponent } from '../base-component/base-component.js';
2
+ import * as ConfigHelper from './helpers/config.helper.js';
3
+ import * as DataBindingHelper from './helpers/data-binding.helper.js';
4
4
 
5
- export interface ComponentProps {
6
- // Common HTML attributes - extend as needed
7
- className?: string;
8
- style?: string;
9
- textContent?: string;
10
-
11
- // Form field bindings - extend as needed
12
- name?: string;
13
- email?: string;
14
-
15
- // Allow for additional data-bind attributes
16
- [key: string]: any;
17
- }
18
-
19
- export interface ComponentOptions<K = any> extends BaseComponentOptions {
20
- props?: ComponentProps;
21
- config?: K;
22
- defaults?: Partial<K>;
23
- templateFn?: (config?: K) => string;
24
- normalizeKeys?: (keyof K)[];
25
- }
26
-
27
- /**
28
- * ```typescript
29
- interface ComponentOptions<K = any> extends BaseComponentOptions {
30
- props?: ComponentProps;
31
- config?: K;
32
- defaults?: Partial<K>;
33
- templateFn?: (config?: K) => string;
34
- normalizeKeys?: (keyof K)[];
35
- }
36
- ```
37
- */
38
5
  export abstract class Component<T extends HTMLElement = HTMLElement, K = any> extends BaseComponent<T> {
39
6
  protected config: K;
40
- protected props: ComponentProps;
41
- private _templateFn: (config?: K) => string;
7
+ protected props: Nutin.ComponentProps;
8
+ private _templateFn: (config?: K) => Nutin.Template;
42
9
  private _normalizeKeys: (keyof K)[];
43
10
  private _defaults: Partial<K>;
44
11
 
@@ -51,7 +18,7 @@ export abstract class Component<T extends HTMLElement = HTMLElement, K = any> ex
51
18
  normalizeKeys = [],
52
19
  defaults = {},
53
20
  trustLevel,
54
- }: ComponentOptions) {
21
+ }: Nutin.ComponentOptions) {
55
22
  super({ mountTarget, tagName, trustLevel });
56
23
 
57
24
  this.config = ConfigHelper.setConfigValue(config, normalizeKeys);
@@ -65,7 +32,7 @@ export abstract class Component<T extends HTMLElement = HTMLElement, K = any> ex
65
32
  return DataBindingHelper.getDataBindingValues(this.element);
66
33
  }
67
34
 
68
- protected override generateTemplate(): string {
35
+ protected override generateTemplate(): Nutin.Template {
69
36
  return ConfigHelper.createNormalizedTemplate({
70
37
  config: this.config,
71
38
  defaults: this._defaults,
@@ -84,7 +51,7 @@ export abstract class Component<T extends HTMLElement = HTMLElement, K = any> ex
84
51
 
85
52
  private applyProps(): void {
86
53
  if (this.props.className) {
87
- this.element.classList.add(this.props.className);
54
+ this.element.classList.add(...this.props.className.split(/\s+/).filter(Boolean));
88
55
  }
89
56
  if (this.props.style) {
90
57
  this.element.style.cssText = this.props.style;
@@ -1,4 +1,4 @@
1
- import { ConfigHelper } from '#root/dist/src/core/base-classes/component/helpers/config.helper.js';
1
+ import * as ConfigHelper from '#root/dist/src/core/base-classes/component/helpers/config.helper.js';
2
2
 
3
3
  describe('ConfigHelper', () => {
4
4
  it('normalize shallow-merges defaults under config, letting config win', () => {
@@ -1,43 +1,41 @@
1
- export class ConfigHelper {
2
- public static normalize<T extends Record<string, any>>(
3
- config: T,
4
- defaults: Partial<T>
5
- ): T {
6
- return {
7
- ...defaults,
8
- ...config
9
- } as T;
10
- }
1
+ export function normalize<T extends Record<string, any>>(
2
+ config: T,
3
+ defaults: Partial<T>
4
+ ): T {
5
+ return {
6
+ ...defaults,
7
+ ...config
8
+ } as T;
9
+ }
11
10
 
12
- public static normalizeStrings<T extends Record<string, any>>(
13
- config: T,
14
- stringKeys: (keyof T)[]
15
- ): T {
16
- const normalized = { ...config };
17
-
18
- stringKeys.forEach(key => {
19
- if (normalized[key] === undefined || normalized[key] === null) {
20
- normalized[key] = '' as T[keyof T];
21
- }
22
- });
23
-
24
- return normalized;
25
- }
11
+ export function normalizeStrings<T extends Record<string, any>>(
12
+ config: T,
13
+ stringKeys: (keyof T)[]
14
+ ): T {
15
+ const normalized = { ...config };
16
+
17
+ stringKeys.forEach(key => {
18
+ if (normalized[key] === undefined || normalized[key] === null) {
19
+ normalized[key] = '' as T[keyof T];
20
+ }
21
+ });
26
22
 
27
- public static createNormalizedTemplate<T extends Record<string, any>>({
28
- config,
29
- defaults = {},
30
- normalizeKeys = [],
31
- templateFn = () => ''
32
- }: T) {
33
- let normalizedConfig = ConfigHelper.normalize(config, defaults);
34
- normalizedConfig = ConfigHelper.normalizeStrings(normalizedConfig, normalizeKeys);
35
- return templateFn(normalizedConfig);
36
- }
23
+ return normalized;
24
+ }
25
+
26
+ export function createNormalizedTemplate<T extends Record<string, any>>({
27
+ config,
28
+ defaults = {},
29
+ normalizeKeys = [],
30
+ templateFn = () => ''
31
+ }: T) {
32
+ let normalizedConfig = normalize(config, defaults);
33
+ normalizedConfig = normalizeStrings(normalizedConfig, normalizeKeys);
34
+ return templateFn(normalizedConfig);
35
+ }
37
36
 
38
- public static setConfigValue<T extends Record<string, any>>(config: T, normalizeKeys: (keyof T)[]): T {
39
- if (normalizeKeys) return ConfigHelper.normalizeStrings(config, normalizeKeys) as T;
40
- else if (config) return config as T
41
- else return ({} as T);
42
- }
37
+ export function setConfigValue<T extends Record<string, any>>(config: T, normalizeKeys: (keyof T)[]): T {
38
+ if (normalizeKeys) return normalizeStrings(config, normalizeKeys) as T;
39
+ else if (config) return config as T
40
+ else return ({} as T);
43
41
  }
@@ -1,4 +1,5 @@
1
- import { DataBindingHelper } from '#root/dist/src/core/base-classes/component/helpers/data-binding.helper.js';
1
+ import * as DataBindingHelper from '#root/dist/src/core/base-classes/component/helpers/data-binding.helper.js';
2
+ import * as DomHelper from '#root/dist/src/core/base-classes/base-component/helpers/dom.helper.js';
2
3
 
3
4
  describe('DataBindingHelper', () => {
4
5
  let container;
@@ -72,4 +73,16 @@ describe('DataBindingHelper', () => {
72
73
  const values = DataBindingHelper.getDataBindingValues(container);
73
74
  expect(values).toEqual({});
74
75
  });
76
+
77
+ it('skips fields inside a nested component root in both directions', () => {
78
+ container.innerHTML = '<input class="own" data-bind="name"><div class="child"><input class="nested" data-bind="name" value="child"></div>';
79
+ DomHelper.markComponentRoot(container.querySelector('.child'));
80
+
81
+ DataBindingHelper.applyDataBindings(container, { name: 'Ada' });
82
+
83
+ expect(container.querySelector('.own').value).toBe('Ada');
84
+ expect(container.querySelector('.nested').value).toBe('child');
85
+ container.querySelector('.own').value = 'parent';
86
+ expect(DataBindingHelper.getDataBindingValues(container)).toEqual({ name: 'parent' });
87
+ });
75
88
  });
@@ -1,35 +1,37 @@
1
- import { ComponentProps } from "../component.js";
1
+ import * as DomHelper from '../../base-component/helpers/dom.helper.js';
2
2
 
3
- export class DataBindingHelper {
4
- public static applyDataBindings(element: HTMLElement, props: ComponentProps): void {
5
- const bindEls = element.querySelectorAll('[data-bind]');
6
- bindEls.forEach(el => {
7
- const key = el.getAttribute('data-bind');
8
- if (!key) return;
9
-
10
- const value = props[key];
11
- if (value === undefined) return;
3
+ // A nested component's data-bind fields belong to that component: they are neither
4
+ // filled from this one's props nor returned by its getValues().
5
+ export function applyDataBindings(element: HTMLElement, props: Nutin.ComponentProps): void {
6
+ const bindEls = element.querySelectorAll('[data-bind]');
7
+ bindEls.forEach(el => {
8
+ if (DomHelper.isInsideNestedComponent(el, element)) return;
9
+ const key = el.getAttribute('data-bind');
10
+ if (!key) return;
11
+
12
+ const value = props[key];
13
+ if (value === undefined) return;
12
14
 
13
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
14
- el.value = String(value);
15
- } else {
16
- el.textContent = String(value);
17
- }
18
- });
19
- }
15
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
16
+ el.value = String(value);
17
+ } else {
18
+ el.textContent = String(value);
19
+ }
20
+ });
21
+ }
20
22
 
21
- public static getDataBindingValues(element: HTMLElement): Record<string, string> {
22
- const values: Record<string, string> = {};
23
- element.querySelectorAll('[data-bind]').forEach(el => {
24
- const key = el.getAttribute('data-bind');
25
- if (!key) return;
26
-
27
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
28
- values[key] = el.value;
29
- } else {
30
- values[key] = el.textContent || '';
31
- }
32
- });
33
- return values;
34
- }
23
+ export function getDataBindingValues(element: HTMLElement): Record<string, string> {
24
+ const values: Record<string, string> = {};
25
+ element.querySelectorAll('[data-bind]').forEach(el => {
26
+ if (DomHelper.isInsideNestedComponent(el, element)) return;
27
+ const key = el.getAttribute('data-bind');
28
+ if (!key) return;
29
+
30
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
31
+ values[key] = el.value;
32
+ } else {
33
+ values[key] = el.textContent || '';
34
+ }
35
+ });
36
+ return values;
35
37
  }
@@ -99,6 +99,38 @@ describe('Service', () => {
99
99
  expect(Service.hasInstance(TestService)).toBe(false);
100
100
  });
101
101
 
102
+ it('destroy runs registerCleanup callbacks once', async () => {
103
+ const instance = TestService.getInstance();
104
+ let calls = 0;
105
+ instance.addCleanup(() => { calls++; });
106
+
107
+ await TestService.destroy();
108
+ instance.dispose();
109
+
110
+ expect(calls).toBe(1);
111
+ });
112
+
113
+ it('destroyAll runs every instance\'s registerCleanup callbacks', async () => {
114
+ const instance = TestService.getInstance();
115
+ let cleaned = false;
116
+ instance.addCleanup(() => { cleaned = true; });
117
+
118
+ await Service.destroyAll();
119
+
120
+ expect(cleaned).toBe(true);
121
+ });
122
+
123
+ it('does not tear anything down on page unload', () => {
124
+ const instance = TestService.getInstance();
125
+ let cleaned = false;
126
+ instance.addCleanup(() => { cleaned = true; });
127
+
128
+ window.dispatchEvent(new window.Event('beforeunload'));
129
+
130
+ expect(cleaned).toBe(false);
131
+ expect(Service.hasInstance(TestService)).toBe(true);
132
+ });
133
+
102
134
  it('destroy is a no-op when no instance exists', async () => {
103
135
  expect(Service.hasInstance(TestService)).toBe(false);
104
136
  await TestService.destroy();
@@ -3,11 +3,11 @@
3
3
  * Supports constructor parameters for initialization.
4
4
  */
5
5
  export abstract class Service<T extends Service<T>> {
6
- private static _instances = new Map<Function, any>();
6
+ private static _instances = new Map<Function, Service<any>>();
7
7
  private static _instantiating = new Set<Function>();
8
8
  private _cleanupCallbacks: (() => void)[] = [];
9
9
 
10
- constructor(...args: any[]) {
10
+ constructor() {
11
11
  const constructor = this.constructor as new (...args: any[]) => T;
12
12
 
13
13
  if (!Service._instantiating.has(constructor)) {
@@ -16,7 +16,6 @@ export abstract class Service<T extends Service<T>> {
16
16
  );
17
17
  }
18
18
  this.autoBindMethods();
19
- window.addEventListener('beforeunload', this.dispose);
20
19
  }
21
20
 
22
21
  /**
@@ -24,10 +23,10 @@ export abstract class Service<T extends Service<T>> {
24
23
  * doesn't exist yet. Throws if args are passed to an already-initialized instance,
25
24
  * preventing silent re-initialization mismatches.
26
25
  */
27
- public static getInstance<T extends Service<T>>(
28
- this: new (...args: any[]) => T,
29
- ...args: any[]
30
- ): T {
26
+ public static getInstance<C extends new (...args: any) => Service<any>>(
27
+ this: C,
28
+ ...args: ConstructorParameters<C> | []
29
+ ): InstanceType<C> {
31
30
  const alreadyExists = Service._instances.has(this);
32
31
 
33
32
  if (alreadyExists && args.length > 0) {
@@ -45,7 +44,7 @@ export abstract class Service<T extends Service<T>> {
45
44
  Service._instantiating.delete(this);
46
45
  }
47
46
 
48
- return Service._instances.get(this);
47
+ return Service._instances.get(this) as InstanceType<C>;
49
48
  }
50
49
 
51
50
  public static hasInstance<T extends Service<T>>(
@@ -60,21 +59,24 @@ export abstract class Service<T extends Service<T>> {
60
59
  const instance = Service._instances.get(this);
61
60
  if (instance) {
62
61
  await instance.onDestroy();
62
+ instance.runCleanup();
63
63
  Service._instances.delete(this);
64
64
  }
65
65
  }
66
66
 
67
+ // No page-unload hook: the browser frees everything on unload, and tearing down on
68
+ // `beforeunload` left a page restored from the back/forward cache (or kept after a
69
+ // cancelled "Leave site?" prompt) with every service already disposed.
67
70
  public dispose = (): void => {
68
- this._cleanupCallbacks.forEach(fn => fn());
69
- this._cleanupCallbacks = [];
71
+ this.runCleanup();
70
72
  Service._instances.delete(this.constructor);
71
73
  };
72
74
 
73
75
  public static async destroyAll(): Promise<void> {
74
- const destroyPromises = Array.from(Service._instances.values())
75
- .map(instance => instance.onDestroy());
76
+ const instances = Array.from(Service._instances.values());
76
77
 
77
- await Promise.all(destroyPromises);
78
+ await Promise.all(instances.map(instance => instance.onDestroy()));
79
+ instances.forEach(instance => instance.runCleanup());
78
80
  Service._instances.clear();
79
81
  Service._instantiating.clear();
80
82
  }
@@ -83,6 +85,12 @@ export abstract class Service<T extends Service<T>> {
83
85
  this._cleanupCallbacks.push(callback);
84
86
  }
85
87
 
88
+ private runCleanup(): void {
89
+ const callbacks = this._cleanupCallbacks;
90
+ this._cleanupCallbacks = [];
91
+ callbacks.forEach(fn => fn());
92
+ }
93
+
86
94
  /**
87
95
  * For async and custom cleanup operations.
88
96
  */
@@ -23,6 +23,10 @@ describe('View', () => {
23
23
  expect(() => new HomeView({ viewName: '' })).toThrow();
24
24
  });
25
25
 
26
+ it('has no document title of its own by default', () => {
27
+ expect(new NamedView({ viewName: 'custom-name' }).documentTitle()).toBe(undefined);
28
+ });
29
+
26
30
  it('uses the explicit viewName option', () => {
27
31
  const view = new NamedView({ viewName: 'custom-name' });
28
32
  expect(view.viewName).toBe('custom-name');