@nutin/cli 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/CHANGELOG.md +268 -0
  2. package/README.md +13 -5
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +9 -8
  34. package/templates/base/AGENTS.md.hbs +42 -41
  35. package/templates/base/GETTING_STARTED.md.hbs +26 -20
  36. package/templates/base/README.md.hbs +7 -4
  37. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  38. package/templates/base/src/app/guards.ts.hbs +1 -3
  39. package/templates/base/src/app/main.ts.hbs +2 -9
  40. package/templates/base/src/app/routes.ts.hbs +7 -2
  41. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  42. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  43. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  44. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  45. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  46. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  47. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  48. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
  49. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  50. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
  51. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
  52. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  53. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
  54. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  55. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  56. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  57. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  58. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  59. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  60. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  61. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  62. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  63. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  64. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  65. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  66. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  67. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  68. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  69. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  70. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  71. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  72. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  73. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  74. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  75. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  76. package/templates/base/src/core/index.ts.hbs +4 -2
  77. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  78. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  79. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  80. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  81. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  82. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  83. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  84. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  85. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  86. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  87. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  88. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  89. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  90. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  91. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  92. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  93. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  94. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  95. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
  96. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
  97. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  98. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  99. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  100. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  101. package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
  102. package/templates/base/src/core/services/router/router.ts.hbs +113 -53
  103. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  104. package/templates/base/tools/builder/builder.js.hbs +18 -6
  105. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  106. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  107. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  108. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  109. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  110. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  111. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  112. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  113. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  114. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  115. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  116. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  117. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  118. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  119. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  120. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  121. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  122. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  123. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  124. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  125. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  126. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
  127. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  128. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  129. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  130. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  131. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
  132. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  133. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  134. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
  135. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  136. package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
  137. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  138. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  139. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  140. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  141. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  142. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  143. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
  144. package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
  145. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  146. package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
  147. package/templates/base/tools/dev/serve.js.hbs +152 -22
  148. package/templates/base/tools/dev/watcher.js.hbs +34 -3
  149. package/templates/base/tools/generator/generator.js.hbs +1 -1
  150. package/templates/base/tools/generator/handle-file.js.hbs +4 -2
  151. package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
  152. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  153. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  154. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  155. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  156. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  157. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  158. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  159. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  160. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  161. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  162. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  163. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  164. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  165. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  166. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  167. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  168. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  169. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  170. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  171. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  172. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  173. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  174. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  175. package/templates/base/tools/utils/build-lock.js.hbs +30 -2
  176. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  177. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  178. package/templates/base/tools/utils/format.js.hbs +1 -1
  179. package/templates/base/tools/utils/index.js.hbs +2 -1
  180. package/templates/base/tools/utils/print.js.hbs +2 -2
  181. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  182. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  183. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  184. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  185. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  186. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
  187. package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
  188. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  189. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  190. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  191. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  192. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  193. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  194. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  195. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  196. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
  197. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  198. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  199. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  200. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  201. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  202. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  203. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  204. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  205. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  206. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  207. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  208. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  209. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  210. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  211. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  212. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  213. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  214. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  215. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  216. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  220. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  221. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  222. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  223. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  224. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  225. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  226. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  227. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  228. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  229. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,4 +1,4 @@
1
- import { RouteGuardsManager } from '#root/dist/src/core/services/router/helpers/route-guard-manager.helper.js';
1
+ import * as RouteGuardsManager from '#root/dist/src/core/services/router/helpers/route-guard-manager.helper.js';
2
2
  import { View } from '#root/dist/src/core/index.js';
3
3
 
4
4
  describe('RouteGuardsManager', () => {
@@ -17,40 +17,39 @@ describe('RouteGuardsManager', () => {
17
17
  expect(result).toBe(mockViewConstructor);
18
18
  });
19
19
 
20
- it('should return empty guards array for function route config', () => {
21
- const result = RouteGuardsManager.getRouteGuards(mockViewConstructor);
22
- expect(result.length).toBe(0)
23
- });
24
-
25
- it('should return guards array from object route config', () => {
26
- const guard = () => true;
27
- const config = { view: mockViewConstructor, guards: [guard] };
28
- const result = RouteGuardsManager.getRouteGuards(config);
29
- expect(JSON.stringify(result)).toBe(JSON.stringify([guard]));
20
+ it('allows a function route config, which has no guards', async () => {
21
+ const result = await RouteGuardsManager.processRouteGuards(mockViewConstructor, '/');
22
+ expect(result.allowed).toBe(true);
23
+ expect(result.viewConstructor).toBe(mockViewConstructor);
30
24
  });
31
25
 
32
- it('should default to an empty guards array when an object route config has no guards', () => {
33
- const config = { view: mockViewConstructor };
34
- const result = RouteGuardsManager.getRouteGuards(config);
35
- expect(result).toEqual([]);
26
+ it('allows an object route config without guards', async () => {
27
+ const result = await RouteGuardsManager.processRouteGuards({ view: mockViewConstructor }, '/');
28
+ expect(result.allowed).toBe(true);
36
29
  });
37
30
 
38
- it('should pass all guards and return true from runGuards', async () => {
39
- const guards = [() => true, () => Promise.resolve(true)];
40
- const result = await RouteGuardsManager.runGuards(guards);
41
- expect(result).toBe(true);
31
+ it('runs every guard, sync or async, and allows when all return true', async () => {
32
+ const calls = [];
33
+ const guards = [() => { calls.push(1); return true; }, () => { calls.push(2); return Promise.resolve(true); }];
34
+ const result = await RouteGuardsManager.processRouteGuards({ view: mockViewConstructor, guards }, '/');
35
+ expect(result.allowed).toBe(true);
36
+ expect(calls).toEqual([1, 2]);
42
37
  });
43
38
 
44
- it('should stop on first false from runGuards', async () => {
45
- const guards = [() => true, () => false, () => true];
46
- const result = await RouteGuardsManager.runGuards(guards);
47
- expect(result).toBe(false);
39
+ it('stops at the first guard returning false', async () => {
40
+ let ranAfter = false;
41
+ const guards = [() => true, () => false, () => { ranAfter = true; return true; }];
42
+ const result = await RouteGuardsManager.processRouteGuards({ view: mockViewConstructor, guards }, '/');
43
+ expect(result.allowed).toBe(false);
44
+ expect(ranAfter).toBe(false);
48
45
  });
49
46
 
50
- it('should stop on redirect string from runGuards', async () => {
51
- const guards = [() => true, () => '/login'];
52
- const result = await RouteGuardsManager.runGuards(guards);
53
- expect(result).toBe('/login');
47
+ it('stops at the first guard returning a redirect', async () => {
48
+ let ranAfter = false;
49
+ const guards = [() => true, () => '/login', () => { ranAfter = true; return true; }];
50
+ const result = await RouteGuardsManager.processRouteGuards({ view: mockViewConstructor, guards }, '/');
51
+ expect(result.redirectTo).toBe('/login');
52
+ expect(ranAfter).toBe(false);
54
53
  });
55
54
 
56
55
  it('should return allowed false when guard returns false in processRouteGuards', async () => {
@@ -1,74 +1,52 @@
1
- import { View } from '../../../index.js';
2
-
3
- /**
4
- * Route guard function that receives route parameters and returns:
5
- * - true to allow navigation
6
- * - false to block it
7
- * - string to redirect to a different route
8
- */
9
- export type RouteGuard = (params: Record<string, string>) => boolean | string | Promise<boolean | string>;
10
-
11
1
  export type GuardResult = {
12
2
  allowed: boolean,
13
3
  redirectTo?: string,
14
- viewConstructor?: () => View,
4
+ viewConstructor?: Nutin.ViewFactory,
15
5
  }
16
6
 
17
- /**
18
- * Route configuration - can be just a view constructor or an object with guards
19
- */
20
- export type RouteConfig = (() => View) | {
21
- view: () => View;
22
- guards?: RouteGuard[];
23
- };
24
-
25
- export type Routes = Record<string, RouteConfig>;
7
+ export function getViewConstructor(routeConfig: Nutin.RouteConfig): Nutin.ViewFactory {
8
+ return typeof routeConfig === 'function' ? routeConfig : routeConfig.view;
9
+ }
26
10
 
27
- export class RouteGuardsManager {
28
- public static getViewConstructor(routeConfig: RouteConfig): () => View {
29
- return typeof routeConfig === 'function' ? routeConfig : routeConfig.view;
11
+ export async function processRouteGuards(
12
+ routeConfig: Nutin.RouteConfig,
13
+ targetPath: string,
14
+ params: Record<string, string> = {}
15
+ ): Promise<GuardResult> {
16
+ const guards = getRouteGuards(routeConfig);
17
+ const guardResult = await runGuards(guards, params);
18
+
19
+ if (guardResult === false) {
20
+ // console.log(`Navigation to ${targetPath} blocked by route guard`);
21
+ return { allowed: false };
30
22
  }
31
23
 
32
- public static async processRouteGuards(
33
- routeConfig: RouteConfig,
34
- targetPath: string,
35
- params: Record<string, string> = {}
36
- ): Promise<GuardResult> {
37
- const guards = this.getRouteGuards(routeConfig);
38
- const guardResult = await this.runGuards(guards, params);
39
-
40
- if (guardResult === false) {
41
- // console.log(`Navigation to ${targetPath} blocked by route guard`);
42
- return { allowed: false };
43
- }
44
-
45
- if (typeof guardResult === 'string') {
46
- // console.log(`Navigation to ${targetPath} redirected to ${guardResult} by route guard`);
47
- return { allowed: false, redirectTo: guardResult };
48
- }
49
-
50
- // Guards passed
51
- const viewConstructor = this.getViewConstructor(routeConfig);
52
- return { allowed: true, viewConstructor };
24
+ if (typeof guardResult === 'string') {
25
+ // console.log(`Navigation to ${targetPath} redirected to ${guardResult} by route guard`);
26
+ return { allowed: false, redirectTo: guardResult };
53
27
  }
54
28
 
55
- private static getRouteGuards(routeConfig: RouteConfig): RouteGuard[] {
56
- return typeof routeConfig === 'function' ? [] : (routeConfig.guards || []);
57
- }
29
+ // Guards passed
30
+ const viewConstructor = getViewConstructor(routeConfig);
31
+ return { allowed: true, viewConstructor };
32
+ }
58
33
 
59
- /**
60
- * Runs all guards in sequence with route parameters and returns the result
61
- */
62
- private static async runGuards(
63
- guards: RouteGuard[],
64
- params: Record<string, string>
65
- ): Promise<boolean | string> {
66
- for (const guard of guards) {
67
- const result = await guard(params);
68
- if (result !== true) {
69
- return result; // false or redirect path
70
- }
34
+ function getRouteGuards(routeConfig: Nutin.RouteConfig): Nutin.RouteGuard[] {
35
+ return typeof routeConfig === 'function' ? [] : (routeConfig.guards || []);
36
+ }
37
+
38
+ /**
39
+ * Runs all guards in sequence with route parameters and returns the result
40
+ */
41
+ async function runGuards(
42
+ guards: Nutin.RouteGuard[],
43
+ params: Record<string, string>
44
+ ): Promise<boolean | string> {
45
+ for (const guard of guards) {
46
+ const result = await guard(params);
47
+ if (result !== true) {
48
+ return result; // false or redirect path
71
49
  }
72
- return true;
73
50
  }
51
+ return true;
74
52
  }
@@ -1,4 +1,4 @@
1
- import { ViewRenderManager } from '#root/dist/src/core/services/router/helpers/view-render-manager.helper.js';
1
+ import * as ViewRenderManager from '#root/dist/src/core/services/router/helpers/view-render-manager.helper.js';
2
2
  import { AppEventBus } from '#root/dist/src/core/index.js';
3
3
 
4
4
  global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
@@ -79,7 +79,7 @@ describe('ViewRenderManager', () => {
79
79
  onEnter: () => calls.push(['onEnter']),
80
80
  };
81
81
 
82
- ViewRenderManager.renderNewView(() => view, { id: '42' });
82
+ ViewRenderManager.renderNewView(view, { id: '42' });
83
83
 
84
84
  expect(calls).toEqual([
85
85
  ['setRouteParams', { id: '42' }],
@@ -101,7 +101,7 @@ describe('ViewRenderManager', () => {
101
101
  onEnter: () => {},
102
102
  };
103
103
 
104
- expect(() => ViewRenderManager.renderNewView(() => view)).not.toThrow();
104
+ expect(() => ViewRenderManager.renderNewView(view)).not.toThrow();
105
105
  });
106
106
 
107
107
  it('should clear stale mount content before rendering the new view', () => {
@@ -121,7 +121,7 @@ describe('ViewRenderManager', () => {
121
121
  onEnter: () => {},
122
122
  };
123
123
 
124
- ViewRenderManager.renderNewView(() => view);
124
+ ViewRenderManager.renderNewView(view);
125
125
 
126
126
  expect(container.children.length).toBe(1);
127
127
  expect(container.contains(stale)).toBe(false);
@@ -1,41 +1,41 @@
1
- import { View, Lifecycle } from '../../../index.js';
1
+ import type { View } from '../../../base-classes/view/view.js';
2
+ import { Lifecycle } from '../../event-bus/lifecycle.facade.js';
2
3
 
3
4
  /**
4
5
  * handles all view rendering.
5
6
  */
6
- export class ViewRenderManager {
7
- public static async transitionOutCurrentView(currentView: View | null): Promise<null> {
8
- if (!currentView) return null;
9
- currentView.destroy();
10
- currentView.onExit();
11
- Lifecycle.viewUnmount(currentView.viewName);
12
- return null;
13
- }
7
+ export async function transitionOutCurrentView(currentView: View | null): Promise<null> {
8
+ if (!currentView) return null;
9
+ currentView.destroy();
10
+ currentView.onExit();
11
+ Lifecycle.viewUnmount(currentView.viewName);
12
+ return null;
13
+ }
14
14
 
15
- public static renderNewView(
16
- viewConstructor: () => View,
17
- params: Record<string, string> = {}
18
- ): View {
19
- const view = viewConstructor();
20
- ViewRenderManager.clearStaleMountContent(view);
15
+ // Takes the view already created by its route's factory: a lazy route's is only
16
+ // available once its chunk has loaded.
17
+ export function renderNewView(
18
+ view: View,
19
+ params: Record<string, string> = {}
20
+ ): View {
21
+ clearStaleMountContent(view);
21
22
 
22
- // Set route parameters before rendering
23
- view.setRouteParams(params);
23
+ // Set route parameters before rendering
24
+ view.setRouteParams(params);
24
25
 
25
- view.render();
26
- view.onEnter();
26
+ view.render();
27
+ view.onEnter();
27
28
 
28
- Lifecycle.viewMount(view.viewName);
29
- return view;
30
- }
29
+ Lifecycle.viewMount(view.viewName);
30
+ return view;
31
+ }
31
32
 
32
- private static clearStaleMountContent(view: View): void {
33
- const viewElement = view.getElement();
34
- const container = viewElement.parentElement;
35
- if (!container) return;
33
+ function clearStaleMountContent(view: View): void {
34
+ const viewElement = view.getElement();
35
+ const container = viewElement.parentElement;
36
+ if (!container) return;
36
37
 
37
- Array.from(container.childNodes).forEach((node) => {
38
- if (node !== viewElement) node.remove();
39
- });
40
- }
38
+ Array.from(container.childNodes).forEach((node) => {
39
+ if (node !== viewElement) node.remove();
40
+ });
41
41
  }
@@ -16,16 +16,54 @@ function makeView(name) {
16
16
  };
17
17
  }
18
18
 
19
+ // Records pushState/replaceState URLs while still applying them to the real history.
20
+ function trackHistory() {
21
+ const originalPush = window.history.pushState;
22
+ const originalReplace = window.history.replaceState;
23
+ const tracked = { pushed: [], replaced: [] };
24
+ window.history.pushState = function (state, title, url) {
25
+ tracked.pushed.push(url);
26
+ return originalPush.call(this, state, title, url);
27
+ };
28
+ window.history.replaceState = function (state, title, url) {
29
+ tracked.replaced.push(url);
30
+ return originalReplace.call(this, state, title, url);
31
+ };
32
+ tracked.restore = () => {
33
+ window.history.pushState = originalPush;
34
+ window.history.replaceState = originalReplace;
35
+ };
36
+ return tracked;
37
+ }
38
+
39
+ // A view whose element is in the document, with an <h1>, so it can take focus. Each gets its
40
+ // own container: rendering a view clears its container's other children.
41
+ function makeFocusableView(name) {
42
+ const view = makeView(name);
43
+ const el = view.getElement();
44
+ el.innerHTML = `<h1>${name}</h1>`;
45
+ const container = document.createElement('div');
46
+ container.appendChild(el);
47
+ document.body.appendChild(container);
48
+ view.cleanup = () => container.remove();
49
+ return view;
50
+ }
51
+
19
52
  describe('Router', () => {
20
53
  let router = null;
21
54
 
22
55
  beforeEach(() => {
56
+ // Fake timers: navigating to a view without an <h1> starts the announcer's timer,
57
+ // which would otherwise keep the test process alive after the last test.
58
+ useFakeTimers();
23
59
  CONFIG.i18n = false;
24
60
  CONFIG.generateSEOFiles = false;
25
61
  window.history.pushState({}, '', '/');
26
62
  });
27
63
 
28
64
  afterEach(() => {
65
+ runAllTimers();
66
+ useRealTimers();
29
67
  if (router) {
30
68
  router.dispose();
31
69
  router = null;
@@ -88,6 +126,52 @@ describe('Router', () => {
88
126
  expect(router.getParam('id')).toBe('42');
89
127
  });
90
128
 
129
+ it('navigate() keeps the query string and decodes route params', async () => {
130
+ const home = makeView('home');
131
+ const userView = makeView('user');
132
+ router = AppRouter({ '/': () => home, '/users/:name': () => userView });
133
+ await flushPromises();
134
+
135
+ await router.navigate('/users/J%C3%B6rg?tab=posts#top');
136
+
137
+ expect(router.getParam('name')).toBe('Jörg');
138
+ expect(window.location.pathname + window.location.search + window.location.hash).toBe('/users/J%C3%B6rg?tab=posts#top');
139
+ });
140
+
141
+ it('reload() keeps the current query string', async () => {
142
+ const home = makeView('home');
143
+ const list = makeView('list');
144
+ router = AppRouter({ '/': () => home, '/list': () => list });
145
+ await flushPromises();
146
+ await router.navigate('/list?page=2');
147
+
148
+ await router.reload();
149
+
150
+ expect(window.location.search).toBe('?page=2');
151
+ });
152
+
153
+ it('drops a navigation whose guard resolves after a newer navigation started', async () => {
154
+ const home = makeView('home');
155
+ const slow = makeView('slow');
156
+ const fast = makeView('fast');
157
+ let releaseGuard;
158
+ router = AppRouter({
159
+ '/': () => home,
160
+ '/slow': { view: () => slow, guards: [() => new Promise((resolve) => { releaseGuard = resolve; })] },
161
+ '/fast': () => fast,
162
+ });
163
+ await flushPromises();
164
+
165
+ const slowNav = router.navigate('/slow');
166
+ await router.navigate('/fast');
167
+ releaseGuard(true);
168
+ await slowNav;
169
+
170
+ expect(slow.calls.length).toBe(0);
171
+ expect(fast.calls.some(c => c[0] === 'render')).toBe(true);
172
+ expect(window.location.pathname).toBe('/fast');
173
+ });
174
+
91
175
  it('navigate() renders the /404 route when no pattern matches', async () => {
92
176
  const home = makeView('home');
93
177
  const notFound = makeView('not-found');
@@ -144,6 +228,91 @@ describe('Router', () => {
144
228
  expect(secret.calls.length).toBe(0);
145
229
  });
146
230
 
231
+ it('navigate() pushes the redirect target when an in-app navigation is redirected by a guard', async () => {
232
+ const home = makeView('home');
233
+ const login = makeView('login');
234
+ const secret = makeView('secret');
235
+ router = AppRouter({
236
+ '/': () => home,
237
+ '/login': () => login,
238
+ '/secret': { view: () => secret, guards: [() => '/login'] },
239
+ });
240
+ await flushPromises();
241
+ const history = trackHistory();
242
+
243
+ await router.navigate('/secret');
244
+ history.restore();
245
+
246
+ expect(history.pushed).toEqual(['/login']);
247
+ expect(history.replaced).toEqual([]);
248
+ expect(window.location.pathname).toBe('/login');
249
+ });
250
+
251
+ it('replaces the guarded URL with the redirect target on first load', async () => {
252
+ const login = makeView('login');
253
+ const secret = makeView('secret');
254
+ window.history.pushState({}, '', '/secret');
255
+ const history = trackHistory();
256
+
257
+ router = AppRouter({
258
+ '/login': () => login,
259
+ '/secret': { view: () => secret, guards: [() => '/login'] },
260
+ });
261
+ await flushPromises();
262
+ history.restore();
263
+
264
+ expect(login.calls.some(c => c[0] === 'render')).toBe(true);
265
+ expect(history.pushed).toEqual([]);
266
+ expect(history.replaced).toEqual(['/login']);
267
+ expect(window.location.pathname).toBe('/login');
268
+ });
269
+
270
+ it('replaces the guarded URL with the redirect target on popstate', async () => {
271
+ const home = makeView('home');
272
+ const login = makeView('login');
273
+ const secret = makeView('secret');
274
+ router = AppRouter({
275
+ '/': () => home,
276
+ '/login': () => login,
277
+ '/secret': { view: () => secret, guards: [() => '/login'] },
278
+ });
279
+ await flushPromises();
280
+
281
+ window.history.pushState({}, '', '/secret');
282
+ const history = trackHistory();
283
+ window.dispatchEvent(new window.PopStateEvent('popstate'));
284
+ await flushPromises();
285
+ history.restore();
286
+
287
+ expect(login.calls.some(c => c[0] === 'render')).toBe(true);
288
+ expect(secret.calls.length).toBe(0);
289
+ expect(history.pushed).toEqual([]);
290
+ expect(history.replaced).toEqual(['/login']);
291
+ expect(window.location.pathname).toBe('/login');
292
+ });
293
+
294
+ it('reload() replaces the current URL when its guard now redirects', async () => {
295
+ let loggedIn = true;
296
+ const login = makeView('login');
297
+ const account = makeView('account');
298
+ window.history.pushState({}, '', '/account');
299
+ router = AppRouter({
300
+ '/login': () => login,
301
+ '/account': { view: () => account, guards: [() => loggedIn || '/login'] },
302
+ });
303
+ await flushPromises();
304
+
305
+ loggedIn = false;
306
+ const history = trackHistory();
307
+ await router.reload();
308
+ history.restore();
309
+
310
+ expect(login.calls.some(c => c[0] === 'render')).toBe(true);
311
+ expect(history.pushed).toEqual([]);
312
+ expect(history.replaced).toEqual(['/login']);
313
+ expect(window.location.pathname).toBe('/login');
314
+ });
315
+
147
316
  it('navigate() renders the view when all guards pass', async () => {
148
317
  const home = makeView('home');
149
318
  const dashboard = makeView('dashboard');
@@ -321,4 +490,141 @@ describe('Router', () => {
321
490
  await flushPromises();
322
491
  expect(about.calls.length).toBe(0);
323
492
  });
493
+
494
+ it('leaves focus alone on the first load', async () => {
495
+ const home = makeFocusableView('home');
496
+ router = AppRouter({ '/': () => home });
497
+ await flushPromises();
498
+ expect(document.activeElement).not.toBe(home.getElement().querySelector('h1'));
499
+ home.cleanup();
500
+ });
501
+
502
+ it('focuses the new view\'s h1 on navigate()', async () => {
503
+ const home = makeFocusableView('home');
504
+ const about = makeFocusableView('about');
505
+ router = AppRouter({ '/': () => home, '/about': () => about });
506
+ await flushPromises();
507
+
508
+ await router.navigate('/about');
509
+ expect(document.activeElement).toBe(about.getElement().querySelector('h1'));
510
+ home.cleanup();
511
+ about.cleanup();
512
+ });
513
+
514
+ it('focuses the view on back/forward (popstate)', async () => {
515
+ const home = makeFocusableView('home');
516
+ const about = makeFocusableView('about');
517
+ router = AppRouter({ '/': () => home, '/about': () => about });
518
+ await flushPromises();
519
+
520
+ window.history.pushState({}, '', '/about');
521
+ window.dispatchEvent(new window.PopStateEvent('popstate'));
522
+ await flushPromises();
523
+ expect(document.activeElement).toBe(about.getElement().querySelector('h1'));
524
+ home.cleanup();
525
+ about.cleanup();
526
+ });
527
+
528
+ it('focuses the /404 view on an in-app navigation to an unknown path', async () => {
529
+ const home = makeFocusableView('home');
530
+ const notFound = makeFocusableView('not-found');
531
+ router = AppRouter({ '/': () => home, '/404': () => notFound });
532
+ await flushPromises();
533
+
534
+ await router.navigate('/nowhere');
535
+ expect(document.activeElement).toBe(notFound.getElement().querySelector('h1'));
536
+ home.cleanup();
537
+ notFound.cleanup();
538
+ });
539
+
540
+ it('does not move focus on reload()', async () => {
541
+ const home = makeFocusableView('home');
542
+ router = AppRouter({ '/': () => home });
543
+ await flushPromises();
544
+ const outside = document.createElement('button');
545
+ document.body.appendChild(outside);
546
+ outside.focus();
547
+
548
+ await router.reload();
549
+ expect(document.activeElement).toBe(outside);
550
+ outside.remove();
551
+ home.cleanup();
552
+ });
553
+
554
+ it('renders a lazy route once its factory resolves, keeping the current view up meanwhile', async () => {
555
+ const home = makeView('home');
556
+ const about = makeView('about');
557
+ let resolveAbout;
558
+ router = AppRouter({ '/': () => home, '/about': () => new Promise((resolve) => { resolveAbout = resolve; }) });
559
+ await flushPromises();
560
+
561
+ const navigation = router.navigate('/about');
562
+ await flushPromises();
563
+ expect(home.calls.some((c) => c[0] === 'destroy')).toBe(false);
564
+ expect(window.location.pathname).toBe('/');
565
+
566
+ resolveAbout(about);
567
+ await navigation;
568
+ expect(home.calls.some((c) => c[0] === 'destroy')).toBe(true);
569
+ expect(about.calls.map((c) => c[0])).toEqual(['setRouteParams', 'render', 'onEnter']);
570
+ expect(window.location.pathname).toBe('/about');
571
+ });
572
+
573
+ it('renders a lazy /404 route', async () => {
574
+ const home = makeView('home');
575
+ const notFound = makeView('not-found');
576
+ router = AppRouter({ '/': () => home, '/404': () => Promise.resolve(notFound) });
577
+ await flushPromises();
578
+
579
+ await router.navigate('/nowhere');
580
+ expect(notFound.calls.some((c) => c[0] === 'render')).toBe(true);
581
+ });
582
+
583
+ it('drops a lazy view that resolves after a newer navigation started', async () => {
584
+ const home = makeView('home');
585
+ const slow = makeView('slow');
586
+ const fast = makeView('fast');
587
+ let resolveSlow;
588
+ router = AppRouter({
589
+ '/': () => home,
590
+ '/slow': () => new Promise((resolve) => { resolveSlow = resolve; }),
591
+ '/fast': () => fast,
592
+ });
593
+ await flushPromises();
594
+
595
+ const slowNavigation = router.navigate('/slow');
596
+ await flushPromises();
597
+ await router.navigate('/fast');
598
+ resolveSlow(slow);
599
+ await slowNavigation;
600
+
601
+ expect(slow.calls.map((c) => c[0])).toEqual(['destroy']);
602
+ expect(fast.calls.some((c) => c[0] === 'render')).toBe(true);
603
+ expect(window.location.pathname).toBe('/fast');
604
+ });
605
+
606
+ it('falls back to a full page load of the target URL when a lazy view fails to load', async () => {
607
+ // The full page load itself (location.assign) is a no-op in jsdom: the record written
608
+ // right before it, in sessionStorage, shows it was attempted, and for which URL.
609
+ sessionStorage.clear();
610
+ const home = makeView('home');
611
+ const record = () => JSON.parse(sessionStorage.getItem('nutin:failed-view-load') ?? 'null');
612
+ try {
613
+ router = AppRouter({ '/': () => home, '/docs': () => Promise.reject(new TypeError('Failed to fetch dynamically imported module')) });
614
+ await flushPromises();
615
+
616
+ await silenceConsole('error', async () => {
617
+ await router.navigate('/docs?page=2#intro');
618
+ expect(record().url).toBe('/docs?page=2#intro');
619
+ expect(home.calls.some((c) => c[0] === 'destroy')).toBe(false);
620
+
621
+ // Failing again right after the reload: logged only, no reload loop (the record is kept as is).
622
+ const first = record().at;
623
+ await router.navigate('/docs?page=2#intro');
624
+ expect(record().at).toBe(first);
625
+ });
626
+ } finally {
627
+ sessionStorage.clear();
628
+ }
629
+ });
324
630
  });