@nutin/cli 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/README.md +14 -6
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +12 -11
  34. package/templates/base/.gitignore.hbs +1 -0
  35. package/templates/base/AGENTS.md.hbs +45 -43
  36. package/templates/base/GETTING_STARTED.md.hbs +32 -23
  37. package/templates/base/README.md.hbs +7 -4
  38. package/templates/base/nutin.config.js.hbs +1 -1
  39. package/templates/base/src/app/components/.gitkeep +0 -0
  40. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  41. package/templates/base/src/app/guards.ts.hbs +1 -3
  42. package/templates/base/src/app/main.ts.hbs +2 -9
  43. package/templates/base/src/app/routes.ts.hbs +9 -3
  44. package/templates/base/src/app/services/.gitkeep +0 -0
  45. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  47. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  48. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  49. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  50. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  51. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  52. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
  53. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  54. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
  55. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
  56. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  57. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
  58. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  59. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  63. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  64. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  65. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  66. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  67. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  68. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  69. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  70. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  71. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  72. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  73. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  74. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  75. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  76. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  77. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  78. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  79. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  80. package/templates/base/src/core/index.ts.hbs +4 -2
  81. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  82. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  83. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  84. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  85. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  88. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  89. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  90. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  91. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  92. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  93. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  94. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  95. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  96. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  97. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  98. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  99. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
  100. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
  101. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  102. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  103. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  104. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  105. package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
  106. package/templates/base/src/core/services/router/router.ts.hbs +122 -54
  107. package/templates/base/src/index.html.hbs +1 -0
  108. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  109. package/templates/base/src/styles/main.scss.hbs +1 -1
  110. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  111. package/templates/base/tools/builder/builder.js.hbs +26 -8
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  113. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  114. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  115. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  116. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  117. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  118. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  119. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  120. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  121. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  122. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  123. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  124. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  125. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  126. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  127. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  128. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  129. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  130. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  131. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  132. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  133. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  134. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  135. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
  136. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  137. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  138. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  139. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  140. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
  141. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  142. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  143. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
  144. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  145. package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
  146. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  147. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  148. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  149. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  150. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  151. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  152. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
  153. package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
  154. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  155. package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
  156. package/templates/base/tools/dev/serve.js.hbs +152 -22
  157. package/templates/base/tools/dev/watcher.js.hbs +75 -28
  158. package/templates/base/tools/generator/generator.js.hbs +23 -18
  159. package/templates/base/tools/generator/handle-file.js.hbs +4 -24
  160. package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
  161. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  162. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  163. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  164. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  165. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  166. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  167. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  168. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  169. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  170. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  171. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  172. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  173. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  174. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  175. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  176. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  177. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  178. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  179. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  180. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  181. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  182. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  183. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  184. package/templates/base/tools/utils/build-lock.js.hbs +97 -0
  185. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  186. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  187. package/templates/base/tools/utils/format.js.hbs +1 -1
  188. package/templates/base/tools/utils/index.js.hbs +3 -1
  189. package/templates/base/tools/utils/print.js.hbs +2 -2
  190. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  191. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  192. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  193. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  194. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  195. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
  196. package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
  197. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  198. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  199. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  200. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  201. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  202. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  203. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  204. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  205. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
  206. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  207. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  208. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  209. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  210. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  211. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  212. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  213. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  214. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  215. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  216. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  220. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  221. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  222. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  223. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  224. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  225. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  226. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  227. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  228. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  229. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  230. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  231. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  232. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  233. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  234. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  235. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  236. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  237. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  238. package/templates/base/src/app/components/index.ts.hbs +0 -1
  239. package/templates/base/src/app/services/index.ts.hbs +0 -1
  240. package/templates/base/src/app/views/index.ts.hbs +0 -3
  241. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,8 +1,30 @@
1
- import { NavigationManager } from '#root/dist/src/core/services/router/helpers/navigation-manager.helper.js';
1
+ import * as NavigationManager from '#root/dist/src/core/services/router/helpers/navigation-manager.helper.js';
2
2
  import { I18nService } from '#root/dist/src/core/services/index.js';
3
3
  import { CONFIG } from '#root/dist/src/core/config.js';
4
4
 
5
+ // The announcer's delay, run on fake timers.
6
+ const ANNOUNCE_DELAY = 100;
7
+
8
+ // A view element inside a <main>, both in the document.
9
+ function mountView(html) {
10
+ const main = document.createElement('main');
11
+ const element = document.createElement('div');
12
+ element.innerHTML = html;
13
+ main.appendChild(element);
14
+ document.body.appendChild(main);
15
+ return { view: { viewName: 'probe', getElement: () => element }, main, element };
16
+ }
17
+
18
+ function cleanupFocusView() {
19
+ document.querySelectorAll('main, [data-nutin-announcer]').forEach((el) => el.remove());
20
+ }
21
+
5
22
  describe('NavigationManager', () => {
23
+ afterEach(() => {
24
+ useRealTimers();
25
+ cleanupFocusView();
26
+ });
27
+
6
28
  it('should normalize paths by removing trailing slashes', () => {
7
29
  expect(NavigationManager.normalizePath('/about/')).toBe('/about');
8
30
  expect(NavigationManager.normalizePath('/about///')).toBe('/about');
@@ -10,6 +32,12 @@ describe('NavigationManager', () => {
10
32
  expect(NavigationManager.normalizePath('')).toBe('/');
11
33
  });
12
34
 
35
+ it('never returns a path that starts with "//" (another host)', () => {
36
+ expect(NavigationManager.normalizePath('/./\\evil.example')).toBe('/evil.example');
37
+ expect(NavigationManager.normalizePath('/a/..\\/evil.example')).toBe('/evil.example');
38
+ expect(NavigationManager.normalizePath('/%2e/\\evil.example/x')).toBe('/evil.example/x');
39
+ });
40
+
13
41
  it('should call pushState when conditions are met', () => {
14
42
  const originalPushState = window.history.pushState;
15
43
  let called = false;
@@ -19,13 +47,13 @@ describe('NavigationManager', () => {
19
47
  expect(url).toBe('/about');
20
48
  };
21
49
 
22
- NavigationManager.updateHistory('/about', '/home', true);
50
+ NavigationManager.updateHistory('/about', '/home', 'push');
23
51
  expect(called).toBe(true);
24
52
 
25
53
  window.history.pushState = originalPushState;
26
54
  });
27
55
 
28
- it('should not call pushState if shouldPushState returns false', () => {
56
+ it("should not touch history in 'none' mode", () => {
29
57
  const originalPushState = window.history.pushState;
30
58
  let called = false;
31
59
 
@@ -33,12 +61,67 @@ describe('NavigationManager', () => {
33
61
  called = true;
34
62
  };
35
63
 
36
- NavigationManager.updateHistory('/about', '/home', false);
64
+ NavigationManager.updateHistory('/about', '/home', 'none');
37
65
 
38
66
  expect(called).toBe(false);
39
67
  window.history.pushState = originalPushState;
40
68
  });
41
69
 
70
+ it("updateHistory in 'replace' mode rewrites the current entry instead of pushing", () => {
71
+ const originalReplaceState = window.history.replaceState;
72
+ const originalPushState = window.history.pushState;
73
+ let pushCalled = false;
74
+ let replacedUrl = null;
75
+
76
+ window.history.pushState = function () {
77
+ pushCalled = true;
78
+ };
79
+ window.history.replaceState = function (state, title, url) {
80
+ replacedUrl = url;
81
+ };
82
+
83
+ NavigationManager.updateHistory('/about', '/home', 'replace', 'team');
84
+
85
+ window.history.pushState = originalPushState;
86
+ window.history.replaceState = originalReplaceState;
87
+ expect(pushCalled).toBe(false);
88
+ expect(replacedUrl).toBe('/about#team');
89
+ });
90
+
91
+ it('replaceState calls history.replaceState (not pushState) with the locale-prefixed path', () => {
92
+ const originalReplaceState = window.history.replaceState;
93
+ const originalPushState = window.history.pushState;
94
+ let pushCalled = false;
95
+ let replaceArgs = null;
96
+
97
+ window.history.pushState = () => { pushCalled = true; };
98
+ window.history.replaceState = (state, title, url) => { replaceArgs = url; };
99
+
100
+ try {
101
+ NavigationManager.replaceState('/section/first-page');
102
+ expect(replaceArgs).toBe('/section/first-page');
103
+ expect(pushCalled).toBe(false);
104
+ } finally {
105
+ window.history.replaceState = originalReplaceState;
106
+ window.history.pushState = originalPushState;
107
+ }
108
+ });
109
+
110
+ it('replaceState preserves an existing hash fragment', () => {
111
+ const originalReplaceState = window.history.replaceState;
112
+ window.history.pushState({}, '', '/section#some-heading');
113
+ let replaceArgs = null;
114
+ window.history.replaceState = (state, title, url) => { replaceArgs = url; };
115
+
116
+ try {
117
+ NavigationManager.replaceState('/section/first-page');
118
+ expect(replaceArgs).toBe('/section/first-page#some-heading');
119
+ } finally {
120
+ window.history.replaceState = originalReplaceState;
121
+ window.history.pushState({}, '', '/');
122
+ }
123
+ });
124
+
42
125
  it('should return current path from location', () => {
43
126
  // This test assumes a DOM-like environment
44
127
  const currentPath = window.location.pathname;
@@ -188,10 +271,47 @@ describe('NavigationManager', () => {
188
271
  .toEqual({ postId: '1', commentId: '2' });
189
272
  });
190
273
 
274
+ it('matchPattern decodes params and keeps a malformed escape as-is', () => {
275
+ expect(NavigationManager.matchPattern('/users/:name', '/users/J%C3%B6rg')).toEqual({ name: 'Jörg' });
276
+ expect(NavigationManager.matchPattern('/users/:name', '/users/100%')).toEqual({ name: '100%' });
277
+ });
278
+
279
+ it('matchPattern matches static segments literally', () => {
280
+ expect(NavigationManager.matchPattern('/files/report.pdf', '/files/report.pdf')).toEqual({});
281
+ expect(NavigationManager.matchPattern('/files/report.pdf', '/files/reportXpdf')).toBe(null);
282
+ expect(NavigationManager.matchPattern('/v1.0/:id', '/v1.0/7')).toEqual({ id: '7' });
283
+ });
284
+
285
+ it('updateHistory keeps the query string', () => {
286
+ const originalPushState = window.history.pushState;
287
+ let pushedUrl = null;
288
+ window.history.pushState = function (state, title, url) {
289
+ pushedUrl = url;
290
+ };
291
+
292
+ NavigationManager.updateHistory('/list', '/home', 'push', 'top', '?page=2');
293
+
294
+ window.history.pushState = originalPushState;
295
+ expect(pushedUrl).toBe('/list?page=2#top');
296
+ });
297
+
191
298
  it('matchPattern returns null when the path does not match the pattern at all', () => {
192
299
  expect(NavigationManager.matchPattern('/posts/:id', '/other/123')).toBe(null);
193
300
  });
194
301
 
302
+ it('updateDocumentTitle prefers the view\'s own documentTitle() over seo.json and viewName', () => {
303
+ CONFIG.generateSEOFiles = true;
304
+ CONFIG.seo = { routes: [{ path: '/docs/:slug?', title: 'Docs' }] };
305
+ try {
306
+ NavigationManager.updateDocumentTitle({ viewName: 'docs', documentTitle: () => 'How do I navigate?' }, '/docs/:slug?');
307
+ expect(document.title).toBe('How do I navigate?');
308
+ NavigationManager.updateDocumentTitle({ viewName: 'docs', documentTitle: () => undefined }, '/docs/:slug?');
309
+ expect(document.title).toBe('Docs');
310
+ } finally {
311
+ CONFIG.generateSEOFiles = false;
312
+ }
313
+ });
314
+
195
315
  it('updateDocumentTitle falls back to the view\'s viewName when generateSEOFiles is disabled', () => {
196
316
  CONFIG.generateSEOFiles = false;
197
317
  CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
@@ -226,6 +346,7 @@ describe('NavigationManager', () => {
226
346
  });
227
347
 
228
348
  it('updateDocumentTitle resolves a per-language seo.json title to the current language', () => {
349
+ CONFIG.i18n = true;
229
350
  CONFIG.generateSEOFiles = true;
230
351
  CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
231
352
  I18nService['_LANGUAGES'] = ['en', 'fr'];
@@ -234,12 +355,26 @@ describe('NavigationManager', () => {
234
355
  NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
235
356
  expect(document.title).toBe('Accueil');
236
357
  } finally {
358
+ CONFIG.i18n = false;
237
359
  CONFIG.generateSEOFiles = false;
238
360
  I18nService['_currentLanguage'] = 'en';
239
361
  }
240
362
  });
241
363
 
364
+ it('updateDocumentTitle uses the first value of a per-language seo.json title when i18n is disabled', () => {
365
+ CONFIG.generateSEOFiles = true;
366
+ CONFIG.seo = { routes: [{ path: '/', title: { fr: 'Accueil', en: 'Home' } }] };
367
+ I18nService['_currentLanguage'] = 'en';
368
+ try {
369
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
370
+ expect(document.title).toBe('Accueil');
371
+ } finally {
372
+ CONFIG.generateSEOFiles = false;
373
+ }
374
+ });
375
+
242
376
  it('updateDocumentTitle falls back to the default language, then any available language, then viewName', () => {
377
+ CONFIG.i18n = true;
243
378
  CONFIG.generateSEOFiles = true;
244
379
  I18nService['_LANGUAGES'] = ['en', 'fr'];
245
380
 
@@ -255,6 +390,7 @@ describe('NavigationManager', () => {
255
390
  NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
256
391
  expect(document.title).toBe('home');
257
392
  } finally {
393
+ CONFIG.i18n = false;
258
394
  CONFIG.generateSEOFiles = false;
259
395
  I18nService['_currentLanguage'] = 'en';
260
396
  }
@@ -299,4 +435,81 @@ describe('NavigationManager', () => {
299
435
  I18nService['_translations'] = {};
300
436
  }
301
437
  });
438
+
439
+ it('focuses the view\'s h1, made focusable with tabindex="-1"', () => {
440
+ const { view, element } = mountView('<p>intro</p><h1>Title</h1>');
441
+ NavigationManager.focusView(view);
442
+ const h1 = element.querySelector('h1');
443
+ expect(document.activeElement).toBe(h1);
444
+ expect(h1.getAttribute('tabindex')).toBe('-1');
445
+ });
446
+
447
+ it('keeps an h1\'s own tabindex and styling', () => {
448
+ const { view, element } = mountView('<h1 tabindex="0">Title</h1>');
449
+ const h1 = element.querySelector('h1');
450
+ NavigationManager.focusView(view);
451
+ expect(h1.getAttribute('tabindex')).toBe('0');
452
+ expect(h1.style.outline).toBe('');
453
+ expect(document.activeElement).toBe(h1);
454
+ });
455
+
456
+ it('hides the focus ring of an element it made focusable, and restores it on blur', () => {
457
+ const { view, element } = mountView('<h1 style="color: red">Title</h1>');
458
+ const h1 = element.querySelector('h1');
459
+ NavigationManager.focusView(view);
460
+ expect(h1.style.outline).toBe('none');
461
+
462
+ h1.blur();
463
+ expect(h1.hasAttribute('tabindex')).toBe(false);
464
+ expect(h1.style.outline).toBe('');
465
+ expect(h1.getAttribute('style')).toBe('color: red;');
466
+ });
467
+
468
+ it('leaves no style attribute behind on an element that had none', () => {
469
+ const { view, element } = mountView('<h1>Title</h1>');
470
+ const h1 = element.querySelector('h1');
471
+ NavigationManager.focusView(view);
472
+ h1.blur();
473
+ expect(h1.hasAttribute('style')).toBe(false);
474
+ });
475
+
476
+ it('falls back to the h1 in <main> when the view has none', () => {
477
+ const { view, main } = mountView('<p>no heading</p>');
478
+ const h1 = document.createElement('h1');
479
+ h1.textContent = 'Layout title';
480
+ main.prepend(h1);
481
+ NavigationManager.focusView(view);
482
+ expect(document.activeElement).toBe(h1);
483
+ });
484
+
485
+ it('focuses the view itself and announces document.title when there is no h1', () => {
486
+ useFakeTimers();
487
+ const { view, element } = mountView('<p>no heading</p>');
488
+ document.title = 'Probe page';
489
+ NavigationManager.focusView(view);
490
+ expect(document.activeElement).toBe(element);
491
+ expect(element.getAttribute('tabindex')).toBe('-1');
492
+
493
+ const region = document.querySelector('[data-nutin-announcer]');
494
+ expect(region.getAttribute('role')).toBe('status');
495
+ expect(region.getAttribute('aria-live')).toBe('polite');
496
+ advanceTimersByTime(ANNOUNCE_DELAY - 1);
497
+ expect(region.textContent).toBe('');
498
+ advanceTimersByTime(1);
499
+ expect(region.textContent).toBe('Probe page');
500
+ });
501
+
502
+ it('reuses one announcer, emptying it first so the same title is announced again', () => {
503
+ useFakeTimers();
504
+ const { view } = mountView('<p>no heading</p>');
505
+ document.title = 'Same';
506
+ NavigationManager.focusView(view);
507
+ advanceTimersByTime(ANNOUNCE_DELAY);
508
+ NavigationManager.focusView(view);
509
+ const regions = document.querySelectorAll('[data-nutin-announcer]');
510
+ expect(regions.length).toBe(1);
511
+ expect(regions[0].textContent).toBe('');
512
+ advanceTimersByTime(ANNOUNCE_DELAY);
513
+ expect(regions[0].textContent).toBe('Same');
514
+ });
302
515
  });
@@ -1,106 +1,251 @@
1
- import { I18nService, View } from "../../../index.js";
2
- import { Language } from "../../i18n/languages.js";
1
+ import type { View } from "../../../base-classes/view/view.js";
2
+ import { I18nService } from "../../i18n/i18n.js";
3
3
  import { CONFIG } from "../../../config.js";
4
4
 
5
+ // How a navigation affects the history stack: add an entry, rewrite the current one,
6
+ // or leave it untouched (back/forward, reload).
7
+ export type HistoryMode = 'push' | 'replace' | 'none';
8
+
9
+ // Long enough for screen readers to notice the live region's content change.
10
+ const ANNOUNCE_DELAY = 100;
11
+
12
+ // See recoverFromFailedViewLoad().
13
+ const RELOAD_KEY = 'nutin:failed-view-load';
14
+ const RELOAD_WINDOW = 10_000;
15
+
5
16
  /**
6
17
  * Navigation - handles path normalization and history management
7
18
  */
8
- export class NavigationManager {
9
- public static extractLocale(path: string): { locale: Language | null; strippedPath: string } {
10
- if (!CONFIG.i18n) return { locale: null, strippedPath: path };
11
-
19
+ // The i18n guards wrap their branch rather than returning early: esbuild only drops
20
+ // a constant-false block, not code after a constant early return, and I18nService
21
+ // must go unreferenced for prod builds to drop it when i18n is off.
22
+ export function extractLocale(path: string): { locale: Nutin.Language | null; strippedPath: string } {
23
+ if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
12
24
  const segments = path.split('/').filter(Boolean);
13
- const first = segments[0] as Language;
25
+ const first = segments[0] as Nutin.Language;
14
26
  if (first && I18nService.languages.includes(first)) {
15
27
  const rest = segments.slice(1).join('/');
16
28
  return { locale: first, strippedPath: rest ? `/${rest}` : '/' };
17
29
  }
18
- return { locale: null, strippedPath: path };
19
30
  }
31
+ return { locale: null, strippedPath: path };
32
+ }
20
33
 
21
- public static addLocalePrefix(strippedPath: string): string {
22
- if (!CONFIG.i18n) return strippedPath;
23
-
34
+ export function addLocalePrefix(strippedPath: string): string {
35
+ if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
24
36
  const lang = I18nService.currentLanguage;
25
37
  return strippedPath === '/' ? `/${lang}` : `/${lang}${strippedPath}`;
26
38
  }
39
+ return strippedPath;
40
+ }
27
41
 
28
- public static getCurrentLocale(): Language | null {
29
- return this.extractLocale(window.location.pathname).locale;
42
+ export function getCurrentLocale(): Nutin.Language | null {
43
+ return extractLocale(window.location.pathname).locale;
44
+ }
45
+
46
+ export function updateLocaleInUrl(): void {
47
+ const rawPathname = (new URL(window.location.pathname, window.location.origin).pathname || '/').replace(/\/+$/, '') || '/';
48
+ const { strippedPath } = extractLocale(rawPathname);
49
+ const newUrl = addLocalePrefix(strippedPath);
50
+ if (newUrl !== window.location.pathname) {
51
+ window.history.pushState({}, '', newUrl + window.location.search + window.location.hash);
30
52
  }
53
+ }
31
54
 
32
- public static updateLocaleInUrl(): void {
33
- const rawPathname = (new URL(window.location.pathname, window.location.origin).pathname || '/').replace(/\/+$/, '') || '/';
34
- const { strippedPath } = this.extractLocale(rawPathname);
35
- const newUrl = this.addLocalePrefix(strippedPath);
36
- if (newUrl !== window.location.pathname) {
37
- window.history.pushState({}, '', newUrl);
38
- }
55
+ export function normalizePath(path: string): string {
56
+ const collapsed = path.replace(/\/\/+/g, '/');
57
+ const url = new URL(collapsed, window.location.origin);
58
+ // Parsing can bring "//" back ("/./\\host" -> "//host"), which the browser reads as another
59
+ // host (pushState throws, a full page load leaves the site): collapse again.
60
+ const pathname = (url.pathname || '/').replace(/\/\/+/g, '/').replace(/\/+$/, '') || '/';
61
+ return extractLocale(pathname).strippedPath;
62
+ }
63
+
64
+ export function updateHistory(
65
+ normalizedPath: string,
66
+ currentPath: string,
67
+ mode: HistoryMode,
68
+ hash?: string,
69
+ search: string = ''
70
+ ): void {
71
+ if (mode === 'none') return;
72
+ const localizedPath = localizedUrl(normalizedPath, search, hash);
73
+ const currentUrl = window.location.pathname + window.location.search + window.location.hash;
74
+ if (currentUrl === localizedPath) return;
75
+ if (mode === 'push') {
76
+ window.history.pushState({}, '', localizedPath);
77
+ } else {
78
+ window.history.replaceState({}, '', localizedPath);
39
79
  }
80
+ }
81
+
82
+ // The URL of a path as it appears in the address bar: locale prefix, query and hash.
83
+ export function localizedUrl(normalizedPath: string, search: string = '', hash?: string): string {
84
+ return addLocalePrefix(normalizedPath) + search + (hash ? `#${hash}` : '');
85
+ }
40
86
 
41
- public static normalizePath(path: string): string {
42
- const collapsed = path.replace(/\/\/+/g, '/');
43
- const url = new URL(collapsed, window.location.origin);
44
- const pathname = (url.pathname || '/').replace(/\/+$/, '') || '/';
45
- return this.extractLocale(pathname).strippedPath;
87
+ // A lazy route's view failed to load: usually a deploy replaced the chunks while this tab
88
+ // was open, or the network dropped. A full page load of the target URL fetches the new
89
+ // build. It happens once per URL within RELOAD_WINDOW (recorded in sessionStorage), so a
90
+ // view that keeps failing (or a broken chunk) can't reload forever: the error is logged
91
+ // and the current page stays. Without sessionStorage, it only logs.
92
+ export function recoverFromFailedViewLoad(url: string, error: unknown): void {
93
+ console.error(`[router] Failed to load the view for "${url}".`, error);
94
+ try {
95
+ const last = JSON.parse(sessionStorage.getItem(RELOAD_KEY) ?? 'null') as { url?: string; at?: number } | null;
96
+ const now = Date.now();
97
+ if (last?.url === url && typeof last.at === 'number' && now - last.at < RELOAD_WINDOW) return;
98
+ sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ url, at: now }));
99
+ } catch {
100
+ return;
46
101
  }
102
+ loadPage(url);
103
+ }
47
104
 
48
- public static updateHistory(
49
- normalizedPath: string,
50
- currentPath: string,
51
- pushState: boolean
52
- ): void {
53
- const localizedPath = this.addLocalePrefix(normalizedPath);
54
- if (pushState && window.location.pathname !== localizedPath) {
55
- window.history.pushState({}, '', localizedPath);
56
- }
105
+ // A full (non-SPA) page load.
106
+ export function loadPage(url: string): void {
107
+ window.location.assign(url);
108
+ }
109
+
110
+ // Rewrites the current history entry's URL to match content already rendered
111
+ // (e.g. a route that fell back to a default sub-page) without adding a
112
+ // new back/forward-navigable entry.
113
+ export function replaceState(path: string): void {
114
+ const localizedPath = addLocalePrefix(path) + window.location.hash;
115
+ window.history.replaceState({}, '', localizedPath);
116
+ }
117
+
118
+ // Scrolls to the element matching `hash` (a bare fragment, no leading "#") if it
119
+ // exists; falls back to the top of the page otherwise (no hash, or a stale/broken
120
+ // id) — matches the browser's own native fallback for an unresolvable fragment.
121
+ export function scrollToHash(hash?: string): void {
122
+ const target = hash ? document.getElementById(hash) : null;
123
+ if (target) {
124
+ target.scrollIntoView({ block: 'start' });
125
+ } else {
126
+ window.scrollTo({ top: 0 });
127
+ }
128
+ }
129
+
130
+ // Moves focus to the new view after an in-app navigation, so keyboard and screen-reader
131
+ // users land on it instead of on <body> (the link they used is gone): its <h1>, else the
132
+ // <h1> in <main>; without one, the view itself, with the page title announced instead.
133
+ // preventScroll keeps scrollToHash()'s position.
134
+ export function focusView(view: View): void {
135
+ const viewElement = view.getElement();
136
+ const heading = viewElement.querySelector('h1') ?? document.querySelector('main h1');
137
+ if (heading instanceof HTMLElement) {
138
+ focusElement(heading);
139
+ return;
57
140
  }
141
+ focusElement(viewElement);
142
+ announce(document.title);
143
+ }
58
144
 
59
- public static getCurrentPath(): string {
60
- return this.normalizePath(window.location.pathname);
145
+ // An element made focusable only for this (a heading, the view) gets no focus ring: it isn't
146
+ // interactive, so the ring would only look like a stray highlight. Screen readers still read it
147
+ // and Tab still continues from it. Once it loses focus, it's restored as it was, so it never
148
+ // becomes a click/Tab target. An element with its own tabindex keeps its own styling.
149
+ function focusElement(element: HTMLElement): void {
150
+ if (!element.hasAttribute('tabindex')) {
151
+ const previousOutline = element.style.outline;
152
+ element.setAttribute('tabindex', '-1');
153
+ element.style.outline = 'none';
154
+ element.addEventListener('blur', () => {
155
+ element.removeAttribute('tabindex');
156
+ element.style.outline = previousOutline;
157
+ if (!element.getAttribute('style')) element.removeAttribute('style');
158
+ }, { once: true });
61
159
  }
160
+ element.focus({ preventScroll: true });
161
+ }
62
162
 
63
- public static updateDocumentTitle(view: View, pattern: string): void {
64
- const route = CONFIG.generateSEOFiles
65
- ? CONFIG.seo?.routes?.find((r) => r.path === pattern)
66
- : undefined;
67
- const seoTitle = route?.title ? this.resolveSeoTitle(route.title) : undefined;
68
- const localeTitle = CONFIG.i18n ? I18nService.getTranslationObject<string>(`${view.viewName}.title`) : null;
69
- document.title = seoTitle || localeTitle || view.viewName;
163
+ let announcer: HTMLElement | null = null;
164
+
165
+ // A visually hidden polite live region, created on first use. Emptied first and filled
166
+ // a moment later, so a title identical to the previous one is still announced (a timer,
167
+ // not requestAnimationFrame: that one doesn't run in a background tab).
168
+ function announce(message: string): void {
169
+ if (!announcer?.isConnected) {
170
+ const region = document.createElement('div');
171
+ region.setAttribute('role', 'status');
172
+ region.setAttribute('aria-live', 'polite');
173
+ region.setAttribute('data-nutin-announcer', '');
174
+ region.style.cssText =
175
+ 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;' +
176
+ 'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;';
177
+ document.body.appendChild(region);
178
+ announcer = region;
70
179
  }
180
+ const region = announcer;
181
+ region.textContent = '';
182
+ setTimeout(() => { region.textContent = message; }, ANNOUNCE_DELAY);
183
+ }
184
+
185
+ export function getCurrentPath(): string {
186
+ return normalizePath(window.location.pathname);
187
+ }
71
188
 
72
- private static resolveSeoTitle(title: string | Record<string, string>): string | undefined {
73
- if (typeof title !== 'object') return title;
189
+ export function updateDocumentTitle(view: View, pattern: string): void {
190
+ const route = CONFIG.generateSEOFiles
191
+ ? CONFIG.seo?.routes?.find((r) => r.path === pattern)
192
+ : undefined;
193
+ const seoTitle = route?.title ? resolveSeoTitle(route.title) : undefined;
194
+ const localeTitle = (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) ? I18nService.getTranslationObject<string>(`${view.viewName}.title`) : null;
195
+ document.title = view.documentTitle?.() || seoTitle || localeTitle || view.viewName;
196
+ }
197
+
198
+ function resolveSeoTitle(title: string | Record<string, string>): string | undefined {
199
+ if (typeof title !== 'object') return title;
200
+ if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
74
201
  const lang = I18nService.currentLanguage;
75
202
  const defaultLang = I18nService.defaultLanguage;
76
203
  return title[lang] ?? title[defaultLang] ?? Object.values(title)[0];
77
204
  }
205
+ return Object.values(title)[0];
206
+ }
78
207
 
79
- public static matchPattern(pattern: string, path: string): Record<string, string> | null {
80
- const paramNames: string[] = [];
208
+ export function matchPattern(pattern: string, path: string): Record<string, string> | null {
209
+ const paramNames: string[] = [];
81
210
 
82
- const regexPattern = pattern
83
- .replace(/\/:([^/?]+)\?/g, (_, paramName) => {
84
- paramNames.push(paramName);
211
+ // Splits on "/:param?" and ":param"; the static parts in between match literally.
212
+ const regexPattern = pattern
213
+ .split(/(\/:[^/?]+\?|:[^/]+)/)
214
+ .map((part) => {
215
+ const optional = part.match(/^\/:([^/?]+)\?$/);
216
+ if (optional) {
217
+ paramNames.push(optional[1]!);
85
218
  return `(?:/([^/]+))?`; // whole "/param" is optional
86
- })
87
- .replace(/:([^/]+)/g, (_, paramName) => {
88
- paramNames.push(paramName);
219
+ }
220
+ const required = part.match(/^:([^/]+)$/);
221
+ if (required) {
222
+ paramNames.push(required[1]!);
89
223
  return `([^/]+)`;
90
- });
224
+ }
225
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
226
+ })
227
+ .join('');
91
228
 
92
- const regex = new RegExp(`^${regexPattern}$`);
93
- const match = path.match(regex);
229
+ const regex = new RegExp(`^${regexPattern}$`);
230
+ const match = path.match(regex);
94
231
 
95
- if (!match) return null;
232
+ if (!match) return null;
96
233
 
97
- const params: Record<string, string> = {};
98
- paramNames.forEach((name, i) => {
99
- const value = match[i + 1];
100
- if (value) {
101
- params[name] = value;
102
- }
103
- });
104
- return params;
234
+ const params: Record<string, string> = {};
235
+ paramNames.forEach((name, i) => {
236
+ const value = match[i + 1];
237
+ if (value) {
238
+ params[name] = decodeParam(value);
239
+ }
240
+ });
241
+ return params;
242
+ }
243
+
244
+ // The path comes percent-encoded from the URL; a malformed escape is kept as-is.
245
+ function decodeParam(value: string): string {
246
+ try {
247
+ return decodeURIComponent(value);
248
+ } catch {
249
+ return value;
105
250
  }
106
251
  }