@nutin/cli 1.3.1 → 2.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 (287) hide show
  1. package/CHANGELOG.md +89 -9
  2. package/README.md +20 -35
  3. package/bin/add-feature.mjs +9 -0
  4. package/bin/cli.mjs +8 -0
  5. package/bin/create-app.mjs +5 -4
  6. package/bin/update-app.mjs +9 -0
  7. package/lib/index.mjs +76 -25
  8. package/lib/src/add-feature/feature-adder.mjs +83 -0
  9. package/lib/src/add-feature/feature-context-builder.mjs +25 -0
  10. package/lib/src/add-feature/nutin-config-updater.mjs +29 -0
  11. package/lib/src/add-feature/package-json-updater.mjs +49 -0
  12. package/lib/src/common/feature-registry.mjs +11 -0
  13. package/lib/src/common/file-generator.mjs +159 -0
  14. package/lib/src/common/meta-bootstrap-prompt.mjs +33 -0
  15. package/lib/src/common/package-data.mjs +10 -0
  16. package/lib/src/common/package-json-helper.mjs +103 -0
  17. package/lib/src/common/project-meta.mjs +54 -0
  18. package/lib/src/create-app/context-builder.mjs +21 -0
  19. package/lib/src/{git-manager.mjs → create-app/git-manager.mjs} +2 -2
  20. package/lib/src/create-app/json-manager.mjs +73 -0
  21. package/lib/src/{prompts.mjs → create-app/new-app-prompt.mjs} +6 -6
  22. package/lib/src/{project-generator.mjs → create-app/project-generator.mjs} +10 -8
  23. package/lib/src/create-app/success-message.mjs +14 -0
  24. package/lib/src/update-app/conflict-report-writer.mjs +31 -0
  25. package/lib/src/update-app/fetch-old-templates.mjs +61 -0
  26. package/lib/src/update-app/project-updater.mjs +136 -0
  27. package/lib/src/update-app/template-differ.mjs +124 -0
  28. package/lib/src/update-app/update-context-builder.mjs +25 -0
  29. package/lib/src/update-app/update-summary-printer.mjs +22 -0
  30. package/lib/src/update-app/version-compare.mjs +12 -0
  31. package/lib/src/utils/print.mjs +36 -0
  32. package/lib/src/utils/promise-exec-alias.mjs +4 -0
  33. package/package.json +17 -12
  34. package/templates/base/.gitignore.hbs +6 -1
  35. package/templates/base/AGENTS.md.hbs +169 -0
  36. package/templates/base/GETTING_STARTED.md.hbs +227 -0
  37. package/templates/base/README.md.hbs +8 -17
  38. package/templates/base/config/languages.json.hbs +6 -0
  39. package/templates/base/config/seo.json.hbs +12 -0
  40. package/templates/base/nutin.config.js.hbs +51 -0
  41. package/templates/base/src/app/components/index.ts.hbs +1 -2
  42. package/templates/base/src/app/globals.d.ts.hbs +3 -19
  43. package/templates/base/src/{libs/guards → app}/guards.ts.hbs +1 -5
  44. package/templates/base/src/app/main.ts.hbs +7 -26
  45. package/templates/base/src/app/services/index.ts.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.html.hbs +7 -0
  47. package/templates/base/src/app/views/home/home.view.ts.hbs +1 -56
  48. package/templates/base/src/app/views/index.ts.hbs +1 -0
  49. package/templates/base/src/app/views/not-found/not-found.view.html.hbs +8 -0
  50. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +4 -37
  51. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +158 -0
  52. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +68 -35
  53. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +124 -0
  54. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +9 -8
  55. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +74 -0
  56. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +1 -1
  57. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +145 -0
  58. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +7 -1
  59. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +88 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +2 -2
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +62 -0
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +82 -0
  63. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +142 -0
  64. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +67 -22
  65. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +183 -0
  66. package/templates/base/src/core/base-classes/component/component.test.js.hbs +85 -0
  67. package/templates/base/src/core/base-classes/component/component.ts.hbs +24 -33
  68. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +58 -0
  69. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +75 -0
  70. package/templates/base/src/core/base-classes/service/service.test.js.hbs +150 -0
  71. package/templates/base/src/core/base-classes/service/service.ts.hbs +26 -20
  72. package/templates/base/src/core/base-classes/view/view.test.js.hbs +74 -0
  73. package/templates/base/src/core/base-classes/view/view.ts.hbs +17 -41
  74. package/templates/base/src/core/config.ts.hbs +7 -0
  75. package/templates/base/src/core/index.ts.hbs +0 -3
  76. package/templates/base/src/core/internals.d.ts.hbs +27 -0
  77. package/templates/base/src/core/nutin-config.d.ts.hbs +4 -0
  78. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +67 -3
  79. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +9 -4
  80. package/templates/base/src/core/services/event-bus/index.ts.hbs +3 -0
  81. package/templates/base/src/core/services/event-bus/lifecycle.facade.test.js.hbs +123 -0
  82. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +61 -0
  83. package/templates/base/src/core/services/event-bus/navigation.facade.test.js.hbs +46 -0
  84. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +25 -0
  85. package/templates/base/src/core/services/globals/globals.test.js.hbs +149 -0
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +95 -0
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +25 -0
  88. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +20 -0
  89. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +43 -0
  90. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +270 -0
  91. package/templates/{features/i18n → base}/src/core/services/i18n/i18n.ts.hbs +22 -4
  92. package/templates/base/src/core/services/index.ts.hbs +5 -6
  93. package/templates/base/src/core/services/pipe-registry/pipe-registry.test.js.hbs +60 -1
  94. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +1 -1
  95. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +128 -0
  96. package/templates/base/src/{libs/pipes → core/services/pipe-registry}/pipes.ts.hbs +8 -5
  97. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +149 -6
  98. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +40 -29
  99. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +27 -0
  100. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +83 -17
  101. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +14 -38
  102. package/templates/base/src/core/services/router/router.test.js.hbs +226 -2
  103. package/templates/base/src/core/services/router/router.ts.hbs +40 -8
  104. package/templates/base/src/index.html.hbs +2 -6
  105. package/templates/base/src/styles/_styles.scss.hbs +102 -43
  106. package/templates/base/src/styles/base/_variables.scss.hbs +1 -86
  107. package/templates/base/src/styles/main.scss.hbs +1 -18
  108. package/templates/base/src/styles/tailwind.css.hbs +2 -0
  109. package/templates/base/tools/builder/builder.config.js.hbs +40 -0
  110. package/templates/base/tools/builder/builder.js.hbs +24 -26
  111. package/templates/base/tools/builder/core/app/binary-extensions.test.js.hbs +16 -0
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +16 -0
  113. package/templates/base/tools/builder/core/{copy-static.js.hbs → app/copy-static.js.hbs} +27 -34
  114. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +77 -0
  115. package/templates/base/tools/builder/core/finalize-build.js.hbs +16 -7
  116. package/templates/base/tools/builder/core/{add-tags.js.hbs → html-index/add-tags.js.hbs} +2 -2
  117. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +56 -0
  118. package/templates/base/tools/builder/core/{validate-html.js.hbs → html-index/validate-html.js.hbs} +5 -11
  119. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +152 -0
  120. package/templates/base/tools/builder/core/html-templates/minify-html.js.hbs +15 -0
  121. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +8 -0
  122. package/templates/{features/i18n/tools/builder/core → base/tools/builder/core/i18n}/build-i18n.js.hbs +6 -17
  123. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +37 -0
  124. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +30 -0
  125. package/templates/base/tools/builder/core/{hash-files.js.hbs → prod-bundle/hash-files.js.hbs} +14 -11
  126. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +51 -0
  127. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +16 -0
  128. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +128 -0
  129. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +43 -0
  130. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +99 -0
  131. package/templates/base/tools/builder/core/seo/i18n-resolution.js.hbs +66 -0
  132. package/templates/base/tools/builder/core/seo/route-output.js.hbs +64 -0
  133. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +54 -0
  134. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +132 -0
  135. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +95 -0
  136. package/templates/base/tools/builder/core/styles/sass.js.hbs +40 -0
  137. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +84 -0
  138. package/templates/base/tools/dev/dev-serve.js.hbs +26 -18
  139. package/templates/base/tools/dev/serve.js.hbs +11 -1
  140. package/templates/base/tools/dev/watcher.js.hbs +14 -6
  141. package/templates/base/tools/generator/generator.js.hbs +64 -47
  142. package/templates/base/tools/generator/handle-file.js.hbs +17 -44
  143. package/templates/base/tools/generator/handle-file.test.js.hbs +72 -0
  144. package/templates/base/tools/generator/templates/{component.template.js.hbs → component/component.template.js.hbs} +3 -2
  145. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +23 -0
  146. package/templates/base/tools/generator/templates/html/html.template.js.hbs +2 -0
  147. package/templates/base/tools/generator/templates/html/html.template.test.js.hbs +7 -0
  148. package/templates/base/tools/generator/templates/index.js.hbs +7 -12
  149. package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +4 -0
  150. package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +7 -0
  151. package/templates/base/tools/generator/templates/scss/scss.template.js.hbs +1 -0
  152. package/templates/base/tools/generator/templates/scss/scss.template.test.js.hbs +7 -0
  153. package/templates/base/tools/generator/templates/service/service.template.js.hbs +17 -0
  154. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +22 -0
  155. package/templates/base/tools/generator/templates/test/test.template.js.hbs +10 -0
  156. package/templates/base/tools/generator/templates/test/test.template.test.js.hbs +27 -0
  157. package/templates/base/tools/generator/templates/{view.template.js.hbs → view/view.template.js.hbs} +3 -2
  158. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +24 -0
  159. package/templates/base/tools/testin-nutin/core/coverage/collect-coverage.js.hbs +39 -0
  160. package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.js.hbs +115 -0
  161. package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.test.js.hbs +74 -0
  162. package/templates/base/tools/testin-nutin/core/coverage/write-summary-report.js.hbs +54 -0
  163. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +44 -0
  164. package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/assertion-lib.js.hbs +62 -24
  165. package/templates/base/tools/testin-nutin/core/globals/clock.js.hbs +206 -0
  166. package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/jsdom-setup.js.hbs +19 -6
  167. package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/register-test-globals.js.hbs +13 -4
  168. package/templates/base/tools/testin-nutin/core/globals/silence-console.js.hbs +23 -0
  169. package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/spyon.js.hbs +2 -0
  170. package/templates/base/tools/testin-nutin/core/index.js.hbs +9 -0
  171. package/templates/base/tools/testin-nutin/core/printer.js.hbs +85 -0
  172. package/templates/base/tools/testin-nutin/core/queue/queue.test.js.hbs +46 -0
  173. package/templates/base/tools/testin-nutin/core/queue/test-discovery.test.js.hbs +46 -0
  174. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +154 -0
  175. package/templates/{features/testinNutin → base/tools}/testin-nutin/core/tests/assertions.test.js.hbs +27 -0
  176. package/templates/base/tools/testin-nutin/core/tests/clock.test.js.hbs +118 -0
  177. package/templates/base/tools/testin-nutin/core/tests/globals-each-all.test.js.hbs +28 -0
  178. package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/create-mock-method.js.hbs +2 -1
  179. package/templates/base/tools/testin-nutin/mocks/create-mock-method.test.js.hbs +64 -0
  180. package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-event-bus.js.hbs +3 -2
  181. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +66 -0
  182. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +26 -0
  183. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +61 -0
  184. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +29 -0
  185. package/templates/base/tools/testin-nutin/runner.js.hbs +100 -0
  186. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +37 -0
  187. package/templates/base/tools/utils/error-exit.js.hbs +8 -0
  188. package/templates/base/tools/utils/format.js.hbs +10 -10
  189. package/templates/base/tools/utils/format.test.js.hbs +45 -0
  190. package/templates/base/tools/utils/get-files-recursive.js.hbs +6 -1
  191. package/templates/base/tools/utils/get-files-recursive.test.js.hbs +49 -0
  192. package/templates/base/tools/utils/get-rel-to-core.test.js.hbs +18 -0
  193. package/templates/base/tools/utils/index.js.hbs +1 -3
  194. package/templates/base/tools/utils/languages.js.hbs +15 -0
  195. package/templates/base/tools/utils/languages.test.js.hbs +16 -0
  196. package/templates/base/tools/utils/print.js.hbs +19 -13
  197. package/templates/base/tools/utils/print.test.js.hbs +60 -0
  198. package/templates/base/tools/utils/prompt-boolean.js.hbs +7 -0
  199. package/templates/base/tools/utils/prompt-boolean.test.js.hbs +54 -0
  200. package/templates/base/tools/utils/run-command.js.hbs +4 -2
  201. package/templates/base/tools/utils/run-command.test.js.hbs +19 -0
  202. package/templates/base/tools/utils/run-script.js.hbs +14 -3
  203. package/templates/base/tools/utils/run-script.test.js.hbs +40 -0
  204. package/templates/features/{deployHelper → docker}/.dockerignore.hbs +4 -4
  205. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +51 -0
  206. package/templates/features/{deployHelper/tools/deployment/nginx.conf.hbs → docker/tools/docker/nginx.conf.template} +4 -2
  207. package/templates/features/docker/tools/docker/scripts/docker-build.js.hbs +20 -0
  208. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +48 -0
  209. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +52 -0
  210. package/templates/features/docker/tools/docker/scripts/docker-run.js.hbs +28 -0
  211. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +23 -0
  212. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +60 -0
  213. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +30 -0
  214. package/lib/src/context-builder.mjs +0 -40
  215. package/lib/src/file-generator.mjs +0 -109
  216. package/lib/src/json-generator.mjs +0 -31
  217. package/lib/src/package-manager.mjs +0 -139
  218. package/lib/src/print.mjs +0 -32
  219. package/lib/src/utils.mjs +0 -18
  220. package/lib/src/version.mjs +0 -1
  221. package/templates/base/builder.config.js.hbs +0 -36
  222. package/templates/base/src/app/components/common/anchor/anchor.component.ts.hbs +0 -32
  223. package/templates/base/src/app/components/common/button/button.component.ts.hbs +0 -40
  224. package/templates/base/src/app/dom.test.js.hbs +0 -13
  225. package/templates/base/src/app/services/example/example.service.ts.hbs +0 -18
  226. package/templates/base/src/app/views/home/home.view.test.js.hbs +0 -26
  227. package/templates/base/src/core/services/store/store.test.js.hbs +0 -8
  228. package/templates/base/src/core/services/store/store.ts.hbs +0 -39
  229. package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.test.js.hbs +0 -0
  230. package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.ts.hbs +0 -113
  231. package/templates/base/src/core/utils/button-manager/button-manager.utils.test.js.hbs +0 -249
  232. package/templates/base/src/core/utils/button-manager/button-manager.utils.ts.hbs +0 -131
  233. package/templates/base/src/core/utils/helpers/attributes.helper.ts.hbs +0 -50
  234. package/templates/base/src/core/utils/index.ts.hbs +0 -2
  235. package/templates/base/src/libs/index.ts.hbs +0 -4
  236. package/templates/base/src/libs/popover/helpers/focus-trap.helper.ts.hbs +0 -197
  237. package/templates/base/src/libs/popover/helpers/popover-dom.helper.ts.hbs +0 -75
  238. package/templates/base/src/libs/popover/popover.ts.hbs +0 -138
  239. package/templates/base/src/libs/snackbar/snackbar.ts.hbs +0 -75
  240. package/templates/base/src/styles/base/_functions.scss.hbs +0 -9
  241. package/templates/base/src/styles/core/_anchors.scss.hbs +0 -10
  242. package/templates/base/src/styles/core/_index.scss.hbs +0 -6
  243. package/templates/base/src/styles/core/_popover.scss.hbs +0 -96
  244. package/templates/base/src/styles/core/_snackbar.scss.hbs +0 -50
  245. package/templates/base/tools/builder/core/esbuild.js.hbs +0 -25
  246. package/templates/base/tools/builder/core/minify-html.js.hbs +0 -60
  247. package/templates/base/tools/builder/core/sass.js.hbs +0 -22
  248. package/templates/base/tools/builder/core/tsc.js.hbs +0 -12
  249. package/templates/base/tools/generator/templates/service.template.js.hbs +0 -17
  250. package/templates/base/tools/utils/export-flags.js.hbs +0 -6
  251. package/templates/features/deployHelper/tools/builder/core/compress-files.js.hbs +0 -56
  252. package/templates/features/deployHelper/tools/deployment/Dockerfile.hbs +0 -30
  253. package/templates/features/i18n/src/app/views/home/locales/en.json.hbs +0 -9
  254. package/templates/features/i18n/src/app/views/not-found/locales/en.json +0 -8
  255. package/templates/features/i18n/src/app/views/not-found/locales/fr.json +0 -8
  256. package/templates/features/i18n/src/core/config.ts.hbs +0 -3
  257. package/templates/features/i18n/src/core/services/i18n/i18n.test.js.hbs +0 -125
  258. package/templates/features/i18n/tools/generator/templates/json.template.js.hbs +0 -8
  259. package/templates/features/i18n/tools/utils/languages.js.hbs +0 -7
  260. package/templates/features/stylinNutin/src/styles/_custom-classes.scss.hbs +0 -9
  261. package/templates/features/stylinNutin/src/styles/base/_mixins.scss.hbs +0 -372
  262. package/templates/features/stylinNutin/src/styles/components/_index.scss.hbs +0 -3
  263. package/templates/features/stylinNutin/src/styles/core/_utilities.scss.hbs +0 -254
  264. package/templates/features/stylinNutin/src/styles/views/_index.scss.hbs +0 -0
  265. package/templates/features/stylinNutin/tools/generator/templates/scss.template.js.hbs +0 -4
  266. package/templates/features/template/src/app/views/home/home.view.html.hbs +0 -56
  267. package/templates/features/template/src/app/views/not-found/not-found.view.html.hbs +0 -19
  268. package/templates/features/template/tools/builder/core/merge-templates.js.hbs +0 -49
  269. package/templates/features/template/tools/generator/templates/html.template.js.hbs +0 -2
  270. package/templates/features/testinNutin/testin-nutin/core/index.js.hbs +0 -5
  271. package/templates/features/testinNutin/testin-nutin/core/src/utils/print.js.hbs +0 -64
  272. package/templates/features/testinNutin/testin-nutin/core/src/utils/test-queue.js.hbs +0 -64
  273. package/templates/features/testinNutin/testin-nutin/core/tests/globals-each-all.test.js.hbs +0 -28
  274. package/templates/features/testinNutin/testin-nutin/mocks/mock-store.js.hbs +0 -38
  275. package/templates/features/testinNutin/testin-nutin/runner.js.hbs +0 -36
  276. package/templates/features/testinNutin/testin-nutin/watch-tests.js.hbs +0 -21
  277. package/templates/features/testinNutin/testin-nutin.config.js.hbs +0 -14
  278. /package/lib/src/{template-compiler.mjs → common/template-compiler.mjs} +0 -0
  279. /package/templates/{features/i18n → base}/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +0 -0
  280. /package/templates/{features/i18n → base}/src/core/services/i18n/languages.ts.hbs +0 -0
  281. /package/templates/base/tools/builder/{variables → core/app}/binary-extensions.js.hbs +0 -0
  282. /package/templates/base/tools/builder/core/{paths.js.hbs → app/paths.js.hbs} +0 -0
  283. /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/queue.js.hbs +0 -0
  284. /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/test-discovery.js.hbs +0 -0
  285. /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-http-client.js.hbs +0 -0
  286. /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-i18n.js.hbs +0 -0
  287. /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-router.js.hbs +0 -0
@@ -0,0 +1,149 @@
1
+ import { registerGlobals, hideGlobals, revealGlobals, Globals } from '#root/dist/src/core/services/globals/globals.js';
2
+
3
+ function makeComponent(tag = 'div') {
4
+ return class {
5
+ constructor(mountTarget) {
6
+ this.mountTarget = mountTarget;
7
+ }
8
+ render() {
9
+ return document.createElement(tag);
10
+ }
11
+ };
12
+ }
13
+
14
+ describe('globals (registerGlobals)', () => {
15
+ afterEach(() => {
16
+ // registerGlobals mounts directly onto document.body (a sibling of the
17
+ // pre-existing <main id="app">), so only strip what each test added.
18
+ [...document.body.children].forEach((el) => {
19
+ if (el.id !== 'app') el.remove();
20
+ });
21
+ // Globals is a process-wide singleton (like every other Service) - drop
22
+ // its registered-id state so a later test's ids can't collide with an
23
+ // earlier one's.
24
+ Globals.testingResetAll();
25
+ });
26
+
27
+ it('is defined', () => {
28
+ expect(registerGlobals).toBeDefined();
29
+ });
30
+
31
+ it('touches nothing when called with no options', () => {
32
+ registerGlobals({});
33
+ expect(document.body.children.length).toBe(1); // just #app
34
+ });
35
+
36
+ it('prepends a single "before" entry ahead of the existing #app, stamping its id', () => {
37
+ registerGlobals({ before: [{ component: makeComponent('header'), id: 'header' }] });
38
+ expect(document.body.firstElementChild.tagName).toBe('HEADER');
39
+ expect(document.body.firstElementChild.id).toBe('header');
40
+ expect(document.body.children[1].id).toBe('app');
41
+ });
42
+
43
+ it('appends a single "after" entry behind the existing #app, stamping its id', () => {
44
+ registerGlobals({ after: [{ component: makeComponent('footer'), id: 'footer' }] });
45
+ expect(document.body.lastElementChild.tagName).toBe('FOOTER');
46
+ expect(document.body.lastElementChild.id).toBe('footer');
47
+ });
48
+
49
+ it('mounts multiple "before" entries in the given order (not reversed)', () => {
50
+ registerGlobals({
51
+ before: [
52
+ { component: makeComponent('div'), id: 'first' },
53
+ { component: makeComponent('div'), id: 'second' },
54
+ ],
55
+ });
56
+ const ids = [...document.body.children].filter((el) => el.id !== 'app').map((el) => el.id);
57
+ expect(ids).toEqual(['first', 'second']);
58
+ });
59
+
60
+ it('mounts multiple "after" entries in the given order (not reversed)', () => {
61
+ registerGlobals({
62
+ after: [
63
+ { component: makeComponent('div'), id: 'first' },
64
+ { component: makeComponent('div'), id: 'second' },
65
+ ],
66
+ });
67
+ const ids = [...document.body.children].filter((el) => el.id !== 'app').map((el) => el.id);
68
+ expect(ids).toEqual(['first', 'second']);
69
+ });
70
+
71
+ it('passes the same mountTarget value through to every constructed component', () => {
72
+ const seen = [];
73
+ class TrackingComponent {
74
+ constructor(mountTarget) {
75
+ seen.push(mountTarget);
76
+ }
77
+ render() {
78
+ return document.createElement('div');
79
+ }
80
+ }
81
+ registerGlobals({
82
+ before: [{ component: TrackingComponent, id: 'a' }],
83
+ after: [{ component: TrackingComponent, id: 'b' }],
84
+ });
85
+ expect(seen).toEqual(['body', 'body']);
86
+ });
87
+
88
+ it('registering a duplicate id warns and skips the second mount', () => {
89
+ const warnSpy = spyOn(console, 'warn');
90
+ warnSpy.andCallFake(() => {});
91
+
92
+ registerGlobals({ before: [{ component: makeComponent('header'), id: 'dup' }] });
93
+ registerGlobals({ after: [{ component: makeComponent('footer'), id: 'dup' }] });
94
+
95
+ const matches = [...document.body.children].filter((el) => el.id === 'dup');
96
+ expect(matches.length).toBe(1);
97
+ expect(matches[0].tagName).toBe('HEADER');
98
+ expect(warnSpy.callCount).toBe(1);
99
+
100
+ warnSpy.restore();
101
+ });
102
+ });
103
+
104
+ describe('globals (hideGlobals/revealGlobals)', () => {
105
+ afterEach(() => {
106
+ [...document.body.children].forEach((el) => {
107
+ if (el.id !== 'app') el.remove();
108
+ });
109
+ });
110
+
111
+ it('toggle display for every given id that is present', () => {
112
+ const header = document.createElement('div');
113
+ header.id = 'header';
114
+ document.body.appendChild(header);
115
+ const footer = document.createElement('div');
116
+ footer.id = 'footer';
117
+ document.body.appendChild(footer);
118
+
119
+ hideGlobals(['header', 'footer']);
120
+ expect(header.style.display).toBe('none');
121
+ expect(footer.style.display).toBe('none');
122
+
123
+ revealGlobals(['header', 'footer']);
124
+ expect(header.style.display).toBe('block');
125
+ expect(footer.style.display).toBe('block');
126
+ });
127
+
128
+ it('only touch the display property, leaving other inline styles intact', () => {
129
+ const header = document.createElement('div');
130
+ header.id = 'header';
131
+ header.style.cssText = 'background: coral; padding: 8px';
132
+ document.body.appendChild(header);
133
+
134
+ hideGlobals(['header']);
135
+ expect(header.style.display).toBe('none');
136
+ expect(header.style.background).toBe('coral');
137
+ expect(header.style.padding).toBe('8px');
138
+
139
+ revealGlobals(['header']);
140
+ expect(header.style.display).toBe('block');
141
+ expect(header.style.background).toBe('coral');
142
+ expect(header.style.padding).toBe('8px');
143
+ });
144
+
145
+ it('are no-ops for ids that are absent', () => {
146
+ expect(() => hideGlobals(['missing'])).not.toThrow();
147
+ expect(() => revealGlobals(['missing'])).not.toThrow();
148
+ });
149
+ });
@@ -0,0 +1,95 @@
1
+ import { Service } from '../../base-classes/index.js';
2
+
3
+ export interface GlobalMountable {
4
+ render(): HTMLElement;
5
+ destroy?(): void;
6
+ }
7
+
8
+ export interface GlobalConfig<T extends GlobalMountable = GlobalMountable> {
9
+ /** The component's own class — constructed internally as `new component(mountTarget)`. */
10
+ component: new (mountTarget: HTMLElement) => T;
11
+ /** Stamped onto the mounted root element; pass this same id to `hideGlobals`/`revealGlobals`. */
12
+ id: string;
13
+ }
14
+
15
+ export interface RegisterGlobalsOptions {
16
+ /** Rendered and prepended to `<body>`, in the given order (e.g. a header). */
17
+ before?: GlobalConfig[];
18
+ /** Rendered and appended to `<body>`, in the given order (e.g. a footer). */
19
+ after?: GlobalConfig[];
20
+ }
21
+
22
+ export class Globals extends Service<Globals> {
23
+ private _mounted: Record<string, GlobalMountable> = {};
24
+
25
+ constructor() { super(); }
26
+
27
+ public register({ before = [], after = [] }: RegisterGlobalsOptions): void {
28
+ // Component subclasses generated via `nutin generate component` type their
29
+ // mountTarget param as HTMLElement, but DomHelper.mountElement only
30
+ // *appends* (rather than replacing) the target when mountTarget is a string
31
+ // selector. This cast satisfies the stricter constructor type while still
32
+ // passing the string 'body' through at runtime, so components append into
33
+ // <body> instead of replacing it.
34
+ const mountTarget = 'body' as unknown as HTMLElement;
35
+ const isElement = (el: HTMLElement | null): el is HTMLElement => el !== null;
36
+
37
+ const mount = ({ component: Component, id }: GlobalConfig): HTMLElement | null => {
38
+ if (this._mounted[id]) {
39
+ console.warn(`Global "${id}" is already registered - skipping.`);
40
+ return null;
41
+ }
42
+ const instance = new Component(mountTarget);
43
+ const element = instance.render();
44
+ element.id = id;
45
+ this._mounted[id] = instance;
46
+ return element;
47
+ };
48
+
49
+ document.body.prepend(...before.map(mount).filter(isElement));
50
+ document.body.append(...after.map(mount).filter(isElement));
51
+ }
52
+
53
+ public hide(globalIds: string[]): void {
54
+ this.setDisplay(globalIds, 'none');
55
+ }
56
+
57
+ public reveal(globalIds: string[]): void {
58
+ this.setDisplay(globalIds, 'block');
59
+ }
60
+
61
+ private setDisplay(globalIds: string[], display: 'none' | 'block'): void {
62
+ globalIds.forEach((id) => {
63
+ const el = document.getElementById(id) as HTMLElement | null;
64
+ if (el) el.style.display = display;
65
+ });
66
+ }
67
+
68
+ protected onDestroy(): void {
69
+ Object.values(this._mounted).forEach((instance) => instance.destroy?.());
70
+ this._mounted = {};
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Mounts components outside the router's render target (`<main id="app">`),
76
+ * e.g. a header/footer that should persist across every route. Each entry's
77
+ * `component` is constructed internally — callers never see or handle
78
+ * `mountTarget` themselves — and its rendered root element is given the
79
+ * entry's `id`, so `hideGlobals(ids)`/`revealGlobals(ids)` can target it
80
+ * later by that same id. Registering an `id` that's already registered logs
81
+ * a `console.warn` and skips it.
82
+ */
83
+ export const registerGlobals = (options: RegisterGlobalsOptions): void => {
84
+ Globals.getInstance().register(options);
85
+ };
86
+
87
+ /** Hide the elements with the given ids (as registered via `registerGlobals`). */
88
+ export const hideGlobals = (globalIds: string[]): void => {
89
+ Globals.getInstance().hide(globalIds);
90
+ };
91
+
92
+ /** Reveal the elements with the given ids (as registered via `registerGlobals`). */
93
+ export const revealGlobals = (globalIds: string[]): void => {
94
+ Globals.getInstance().reveal(globalIds);
95
+ };
@@ -75,6 +75,11 @@ describe('HttpBuilder', () => {
75
75
  expect(url.search).toBe('?page=1&limit=10');
76
76
  });
77
77
 
78
+ it('buildRequestUrl leaves an existing query in the endpoint untouched when no new params are passed', () => {
79
+ const url = HttpBuilder.buildRequestUrl('https://api.example.com?page=1');
80
+ expect(url.search).toBe('?page=1');
81
+ });
82
+
78
83
  // Tests for buildRequestOptions
79
84
  it('buildRequestOptions sets GET without body', () => {
80
85
  const options = HttpBuilder.buildRequestOptions(
@@ -106,4 +111,24 @@ describe('HttpBuilder', () => {
106
111
  expect(options.body).toBeUndefined();
107
112
  expect(options.headers).toBeUndefined();
108
113
  });
114
+
115
+ it('buildRequestOptions sets PUT with body', () => {
116
+ const data = { id: 1, name: 'Updated' };
117
+ const options = HttpBuilder.buildRequestOptions('PUT', data, {}, mockAbortSignal);
118
+ expect(options.method).toBe('PUT');
119
+ expect(options.body).toBe(JSON.stringify(data));
120
+ });
121
+
122
+ it('buildRequestOptions sets PATCH with body', () => {
123
+ const data = { name: 'Patched' };
124
+ const options = HttpBuilder.buildRequestOptions('PATCH', data, {}, mockAbortSignal);
125
+ expect(options.method).toBe('PATCH');
126
+ expect(options.body).toBe(JSON.stringify(data));
127
+ });
128
+
129
+ it('buildRequestOptions sets DELETE without body', () => {
130
+ const options = HttpBuilder.buildRequestOptions('DELETE', undefined, {}, mockAbortSignal);
131
+ expect(options.method).toBe('DELETE');
132
+ expect(options.body).toBeUndefined();
133
+ });
109
134
  })
@@ -55,6 +55,26 @@ describe('HttpManager', () => {
55
55
  expect(thrown).toBe(true);
56
56
  });
57
57
 
58
+ it('should throw HttpError with a null response body when the error body is not valid JSON', async () => {
59
+ const mockResponse = new Response('plain text failure', {
60
+ status: 500,
61
+ statusText: 'Internal Server Error',
62
+ headers: { 'Content-Type': 'text/plain' }
63
+ });
64
+
65
+ let thrown = false;
66
+ try {
67
+ await HttpManager.validateResponse(mockResponse);
68
+ } catch (e) {
69
+ thrown = true;
70
+ expect(e).toBeInstanceOf(HttpError);
71
+ expect(e.status).toBe(500);
72
+ expect(e.statusText).toBe('Internal Server Error');
73
+ expect(e.response).toBe(null);
74
+ }
75
+ expect(thrown).toBe(true);
76
+ });
77
+
58
78
  it('should detect JSON content types', () => {
59
79
  expect(HttpManager.isJsonResponse('application/json')).toBe(true);
60
80
  expect(HttpManager.isJsonResponse('application/json; charset=utf-8')).toBe(true);
@@ -204,6 +204,49 @@ describe('HttpClient', () => {
204
204
  expect(lastCall.options.headers['Authorization']).toBe('Bearer custom-token');
205
205
  });
206
206
 
207
+ it('should invoke request interceptors with the final url and options before sending', async () => {
208
+ fetchMock.mockJsonResponse('https://api.example.com/users', []);
209
+ const calls = [];
210
+ httpClient.addRequestInterceptor((url, options) => calls.push({ url, options }));
211
+
212
+ await httpClient.get('https://api.example.com/users');
213
+
214
+ expect(calls.length).toBe(1);
215
+ expect(calls[0].url).toBe('https://api.example.com/users');
216
+ expect(calls[0].options.method).toBe('GET');
217
+ });
218
+
219
+ it('should invoke response interceptors with the raw fetch response', async () => {
220
+ fetchMock.mockJsonResponse('https://api.example.com/users', []);
221
+ const received = [];
222
+ httpClient.addResponseInterceptor((response) => received.push(response));
223
+
224
+ await httpClient.get('https://api.example.com/users');
225
+
226
+ expect(received.length).toBe(1);
227
+ expect(received[0].ok).toBe(true);
228
+ });
229
+
230
+ it('should propagate a non-timeout error thrown while parsing the response', async () => {
231
+ fetchMock.mockResponse('https://api.example.com/broken', {
232
+ ok: true,
233
+ status: 200,
234
+ statusText: 'OK',
235
+ headers: { get: () => 'application/json' },
236
+ json: () => Promise.reject(new Error('Malformed JSON')),
237
+ text: () => Promise.resolve('not json'),
238
+ });
239
+
240
+ let thrown = false;
241
+ try {
242
+ await httpClient.get('https://api.example.com/broken');
243
+ } catch (error) {
244
+ thrown = true;
245
+ expect(error.message).toBe('Malformed JSON');
246
+ }
247
+ expect(thrown).toBe(true);
248
+ });
249
+
207
250
  it('should handle HTTP errors', async () => {
208
251
  fetchMock.mockError('https://api.example.com/users', 404, { message: 'Not found' });
209
252
 
@@ -0,0 +1,270 @@
1
+ import { I18nService, AppEventBus } from '#root/dist/src/core/services/index.js';
2
+ import { CONFIG } from '#root/dist/src/core/config.js';
3
+
4
+ class FetchMock {
5
+ constructor() {
6
+ this.originalFetch = global.fetch;
7
+ this.mockResponses = new Map();
8
+ }
9
+
10
+ mockTranslation(lang, data) {
11
+ this.mockResponses.set(`/locales/${lang}.json`, {
12
+ ok: true,
13
+ status: 200,
14
+ json: () => Promise.resolve(data)
15
+ });
16
+ return this;
17
+ }
18
+
19
+ mockError(url, status = 404) {
20
+ this.mockResponses.set(url, {
21
+ ok: false,
22
+ status,
23
+ json: () => Promise.reject(new Error(`HTTP ${status}`))
24
+ });
25
+ return this;
26
+ }
27
+
28
+ install() {
29
+ global.fetch = (url) => {
30
+ if (this.mockResponses.has(url)) {
31
+ const response = this.mockResponses.get(url);
32
+ return Promise.resolve(response);
33
+ }
34
+
35
+ return this.originalFetch ? this.originalFetch(url) : Promise.reject(new Error('fetch not available'));
36
+ };
37
+ return this;
38
+ }
39
+
40
+ restore() {
41
+ global.fetch = this.originalFetch;
42
+ this.mockResponses.clear();
43
+ }
44
+ }
45
+
46
+ const fetchMock = new FetchMock();
47
+
48
+ describe('i18n module', async () => {
49
+ let originalLanguage;
50
+
51
+ beforeEach(async () => {
52
+ I18nService.resetTranslations();
53
+ fetchMock
54
+ .mockTranslation('en', { home: { title : 'My App', subtitle: "English subtitle" }})
55
+ .mockTranslation('fr', { home: { title : 'Mon App' }})
56
+ .install();
57
+ });
58
+ afterEach(() => {
59
+ fetchMock.restore();
60
+ localStorage.removeItem(I18nService.localStorageKey)
61
+ });
62
+
63
+ beforeAll(() =>{
64
+ originalLanguage = navigator.language;
65
+ Object.defineProperty(navigator, 'language', {
66
+ writable: true,
67
+ configurable: true,
68
+ value: "en-US"
69
+ });
70
+ I18nService['_LANGUAGES'] = [ 'en', 'fr' ];
71
+ I18nService['_DEFAULT_LANGUAGE'] = 'en';
72
+ });
73
+ afterAll(() => {
74
+ Object.defineProperty(navigator, 'language', {
75
+ writable: true,
76
+ configurable: true,
77
+ value: originalLanguage
78
+ });
79
+ });
80
+
81
+ it('should load and use English translations', async () => {
82
+ await I18nService.initTranslations();
83
+ expect(I18nService.currentLanguage).toBe('en');
84
+ expect(I18nService.translate('home.title')).toBe('My App');
85
+ });
86
+
87
+ it('should load and use French translations', async () => {
88
+ Object.defineProperty(navigator, 'language', {
89
+ writable: true,
90
+ configurable: true,
91
+ value: "fr-FR"
92
+ });
93
+ await I18nService.initTranslations();
94
+ expect(I18nService.currentLanguage).toBe('fr');
95
+ expect(I18nService.translate('home.title')).toBe('Mon App');
96
+ });
97
+
98
+ it('should default to English translations', async () => {
99
+ // missing locale
100
+ Object.defineProperty(navigator, 'language', {
101
+ writable: true,
102
+ configurable: true,
103
+ value: "de-DE"
104
+ });
105
+ await I18nService.initTranslations();
106
+ expect(navigator.language).toBe('de-DE');
107
+ expect(I18nService.currentLanguage).toBe('en');
108
+ expect(I18nService.translate('home.title')).toBe('My App');
109
+ I18nService.resetTranslations();
110
+
111
+ // missing translation
112
+ Object.defineProperty(navigator, 'language', {
113
+ writable: true,
114
+ configurable: true,
115
+ value: "fr-FR"
116
+ });
117
+ await I18nService.initTranslations();
118
+ expect(navigator.language).toBe('fr-FR');
119
+ expect(I18nService.currentLanguage).toBe('fr');
120
+ expect(I18nService.translate('home.title')).toBe('Mon App');
121
+ expect(I18nService.translate('home.subtitle')).toBe('English subtitle');
122
+ });
123
+
124
+ it('should return key when translation is missing', () => {
125
+ expect(I18nService.translate('non.existent.key')).toBe('non.existent.key');
126
+ });
127
+
128
+ it('setCurrentLanguage updates the language, persists it and loads its translations', async () => {
129
+ await I18nService.setCurrentLanguage('fr');
130
+
131
+ expect(I18nService.currentLanguage).toBe('fr');
132
+ expect(I18nService.translate('home.title')).toBe('Mon App');
133
+ expect(localStorage.getItem(I18nService.localStorageKey)).toBe('fr');
134
+ });
135
+
136
+ it('setCurrentLanguage emits a language-changed event with the new language', async () => {
137
+ const received = [];
138
+ const callback = (data) => received.push(data);
139
+ I18nService.onLanguageChange(callback);
140
+
141
+ await I18nService.setCurrentLanguage('fr');
142
+
143
+ expect(received).toEqual([{ lang: 'fr' }]);
144
+ AppEventBus.off('language-changed', callback);
145
+ });
146
+
147
+ it('getTranslationObject returns the nested value for the current language', async () => {
148
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'en-US' });
149
+ await I18nService.initTranslations();
150
+ expect(I18nService.getTranslationObject('home')).toEqual({ title: 'My App', subtitle: 'English subtitle' });
151
+ });
152
+
153
+ it('getTranslationObject falls back to the default language when missing in the current language', async () => {
154
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'fr-FR' });
155
+ await I18nService.initTranslations();
156
+ expect(I18nService.getTranslationObject('home.subtitle')).toBe('English subtitle');
157
+ });
158
+
159
+ it('getTranslationObject returns null when the key is not found anywhere', async () => {
160
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'en-US' });
161
+ await I18nService.initTranslations();
162
+ expect(I18nService.getTranslationObject('does.not.exist')).toBe(null);
163
+ });
164
+
165
+ it('honors a locale prefix in the URL path when CONFIG.i18n is enabled', async () => {
166
+ CONFIG.i18n = true;
167
+ window.history.pushState({}, '', '/fr/some/page');
168
+ try {
169
+ await I18nService.initTranslations();
170
+ expect(I18nService.currentLanguage).toBe('fr');
171
+ } finally {
172
+ CONFIG.i18n = false;
173
+ window.history.pushState({}, '', '/');
174
+ }
175
+ });
176
+
177
+ it('ignores a locale prefix in the URL path when CONFIG.i18n is disabled (default)', async () => {
178
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'en-US' });
179
+ window.history.pushState({}, '', '/fr/some/page');
180
+ try {
181
+ await I18nService.initTranslations();
182
+ expect(I18nService.currentLanguage).toBe('en');
183
+ } finally {
184
+ window.history.pushState({}, '', '/');
185
+ }
186
+ });
187
+
188
+ it('initTranslations falls back to the default language when the resolved preferred language is unsupported', async () => {
189
+ const original = I18nService.getPreferredLanguage;
190
+ I18nService.getPreferredLanguage = () => 'zz';
191
+ try {
192
+ await I18nService.initTranslations();
193
+ expect(I18nService.currentLanguage).toBe('en');
194
+ } finally {
195
+ I18nService.getPreferredLanguage = original;
196
+ }
197
+ });
198
+
199
+ it('getLocaleFromUrl returns null when the URL locale segment is not supported', () => {
200
+ CONFIG.i18n = true;
201
+ window.history.pushState({}, '', '/xx/page');
202
+ try {
203
+ expect(I18nService.getLocaleFromUrl()).toBe(null);
204
+ } finally {
205
+ CONFIG.i18n = false;
206
+ window.history.pushState({}, '', '/');
207
+ }
208
+ });
209
+
210
+ it('loadDefaultTranslations logs and leaves default translations empty when the fetch fails', async () => {
211
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'fr-FR' });
212
+ const errSpy = spyOn(console, 'error');
213
+ errSpy.andCallFake(() => {});
214
+ fetchMock.mockError('/locales/en.json', 500);
215
+
216
+ await I18nService.initTranslations();
217
+
218
+ // loadDefaultTranslations is attempted twice: once explicitly by initTranslations,
219
+ // and again from within loadTranslations('fr') since _defaultTranslations is still
220
+ // empty after the first failure — each attempt logs its own error.
221
+ expect(errSpy.callCount).toBe(2);
222
+ expect(I18nService.currentLanguage).toBe('fr');
223
+
224
+ errSpy.restore();
225
+ });
226
+
227
+ it('honors a previously saved language preference from localStorage', async () => {
228
+ localStorage.setItem(I18nService.localStorageKey, 'fr');
229
+ await I18nService.initTranslations();
230
+ expect(I18nService.currentLanguage).toBe('fr');
231
+ });
232
+
233
+ it('loadTranslations logs and leaves the language unchanged when the fetch rejects', async () => {
234
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'en-US' });
235
+ const errSpy = spyOn(console, 'error');
236
+ errSpy.andCallFake(() => {});
237
+ global.fetch = () => Promise.reject(new Error('network down'));
238
+
239
+ await I18nService.initTranslations();
240
+
241
+ expect(errSpy.callCount).toBe(1);
242
+ expect(I18nService.currentLanguage).toBe('en');
243
+
244
+ errSpy.restore();
245
+ });
246
+
247
+ it('loadTranslations logs and leaves state unchanged when the response is not ok', async () => {
248
+ Object.defineProperty(navigator, 'language', { writable: true, configurable: true, value: 'en-US' });
249
+ const errSpy = spyOn(console, 'error');
250
+ errSpy.andCallFake(() => {});
251
+ fetchMock.mockError('/locales/en.json', 500);
252
+
253
+ await I18nService.initTranslations();
254
+
255
+ expect(errSpy.callCount).toBe(1);
256
+ expect(I18nService.currentLanguage).toBe('en');
257
+
258
+ errSpy.restore();
259
+ });
260
+
261
+ it('onDestroy resets translations and disposes the instance', async () => {
262
+ await I18nService.initTranslations();
263
+ expect(I18nService.translate('home.title')).toBe('My App');
264
+
265
+ I18nService.onDestroy();
266
+
267
+ expect(I18nService.translate('home.title')).toBe('home.title');
268
+ expect(localStorage.getItem(I18nService.localStorageKey)).toBe(null);
269
+ });
270
+ });
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_LANGUAGE, LANGUAGES, Language, Translations } from './languages.js';
2
2
  import { AppEventBus, Service } from '../../index.js';
3
+ import { CONFIG } from '../../config.js';
3
4
 
4
5
  export class I18n extends Service<I18n> {
5
6
  private readonly _DEFAULT_LANGUAGE: Language = DEFAULT_LANGUAGE;
@@ -35,7 +36,11 @@ export class I18n extends Service<I18n> {
35
36
  this._currentLanguage = lang;
36
37
  this.savePreferences();
37
38
  await this.loadTranslations(lang);
38
- AppEventBus.emit('language-changed');
39
+ AppEventBus.emit('language-changed', { lang });
40
+ }
41
+
42
+ public onLanguageChange(callback: (payload: { lang: string }) => void) {
43
+ AppEventBus.subscribe('language-changed', callback);
39
44
  }
40
45
 
41
46
  public async loadTranslations(lang: Language): Promise<void> {
@@ -99,6 +104,7 @@ export class I18n extends Service<I18n> {
99
104
  public resetTranslations(): void {
100
105
  this._translations = {};
101
106
  this._defaultTranslations = {};
107
+ localStorage.removeItem(this._localStorageKey);
102
108
  this._currentLanguage = this._DEFAULT_LANGUAGE;
103
109
  }
104
110
 
@@ -107,9 +113,21 @@ export class I18n extends Service<I18n> {
107
113
  this.dispose();
108
114
  }
109
115
 
110
- private getPreferredLanguage() {
111
- return this.getPreferences() || navigator.language.split('-')[0] as Language;
112
- };
116
+ private getPreferredLanguage(): Language {
117
+ const urlLocale = this.getLocaleFromUrl();
118
+ if (urlLocale) return urlLocale;
119
+ const stored = this.getPreferences();
120
+ if (stored && this._LANGUAGES.includes(stored as Language)) return stored as Language;
121
+ const browserLang = navigator.language.split('-')[0] as Language;
122
+ return this._LANGUAGES.includes(browserLang) ? browserLang : this._DEFAULT_LANGUAGE;
123
+ }
124
+
125
+ private getLocaleFromUrl(): Language | null {
126
+ if (!CONFIG.i18n) return null;
127
+
128
+ const first = window.location.pathname.split('/').filter(Boolean)[0] as Language;
129
+ return first && this._LANGUAGES.includes(first) ? first : null;
130
+ }
113
131
 
114
132
  private async loadDefaultTranslations(): Promise<void> {
115
133
  try {