@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,23 +1,17 @@
1
- import { View, Navigation, Service, I18nService, AppEventBus } from '../../index.js';
2
- import { Language } from '../i18n/languages.js';
3
- import { Routes, RouteConfig, RouteGuardsManager, GuardResult } from './helpers/route-guard-manager.helper.js';
4
- import { ViewRenderManager } from './helpers/view-render-manager.helper.js';
5
- import { NavigationManager } from './helpers/navigation-manager.helper.js';
1
+ import type { View } from '../../base-classes/view/view.js';
2
+ import { Service } from '../../base-classes/service/service.js';
3
+ import { Navigation } from '../event-bus/navigation.facade.js';
4
+ import { AppEventBus } from '../event-bus/event-bus.js';
5
+ import { I18nService } from '../i18n/i18n.js';
6
+ import * as RouteGuardsManager from './helpers/route-guard-manager.helper.js';
7
+ import type { GuardResult } from './helpers/route-guard-manager.helper.js';
8
+ import * as ViewRenderManager from './helpers/view-render-manager.helper.js';
9
+ import * as NavigationManager from './helpers/navigation-manager.helper.js';
10
+ import type { HistoryMode } from './helpers/navigation-manager.helper.js';
11
+ import { CONFIG } from '../../config.js';
6
12
 
7
13
  // centralized export
8
- export { Routes, RouteGuard } from './helpers/route-guard-manager.helper.js';
9
- export { NavigationManager } from './helpers/navigation-manager.helper.js';
10
-
11
- /**
12
- * IRouter is a type alias for the instance of Router, not a true interface
13
- */
14
- export type IRouter = InstanceType<typeof Router>;
15
-
16
- export interface RouteMatch {
17
- route: RouteConfig;
18
- params: Record<string, string>;
19
- pattern: string;
20
- }
14
+ export * as NavigationManager from './helpers/navigation-manager.helper.js';
21
15
 
22
16
  class Router extends Service<Router> {
23
17
  private _currentView: View | null = null;
@@ -26,11 +20,16 @@ class Router extends Service<Router> {
26
20
  private onNavigate = (data: { path: string }) => this.navigate(data.path);
27
21
  private onReload = () => this.reload();
28
22
  private _busSubscriptions: Array<() => void> = [];
23
+ // Bumped by every navigation: one whose guards resolve after a newer navigation started
24
+ // is dropped instead of rendering over it.
25
+ private _navId = 0;
26
+ // False until the first view is rendered: the first load leaves focus to the browser.
27
+ private _hasRendered = false;
29
28
 
30
- constructor(private routes: Routes) {
29
+ constructor(private routes: Nutin.Routes) {
31
30
  super();
32
31
  this.initializeEventListeners();
33
- this.navigate(NavigationManager.getCurrentPath() + window.location.hash);
32
+ this.navigate(this.currentLocation());
34
33
  this.registerCleanup(this.removeEventListeners);
35
34
  }
36
35
 
@@ -42,13 +41,34 @@ class Router extends Service<Router> {
42
41
  window.removeEventListener('popstate', this.onPopState);
43
42
  }
44
43
 
44
+ // Re-renders the current page, so focus isn't moved (see focusView()).
45
45
  public async reload(): Promise<void> {
46
- const currentRoute = NavigationManager.getCurrentPath();
47
- await this.navigate(currentRoute + window.location.hash, false);
46
+ await this.navigateWithMode(this.currentLocation(), 'none', false);
48
47
  }
49
48
 
50
49
  public async navigate(path: string | '', pushState: boolean = true): Promise<void> {
51
- const [rawPath = '', hash] = path.split('#');
50
+ await this.navigateWithMode(path, pushState ? 'push' : 'none', this._hasRendered);
51
+ }
52
+
53
+ public getCurrentParams(): Record<string, string> {
54
+ return { ...this._currentParams };
55
+ }
56
+
57
+ public getParam(key: string): string | undefined {
58
+ return this._currentParams[key];
59
+ }
60
+
61
+ // Path, query and hash of the current URL, as navigate() takes them.
62
+ private currentLocation(): string {
63
+ return NavigationManager.getCurrentPath() + window.location.search + window.location.hash;
64
+ }
65
+
66
+ private async navigateWithMode(path: string, mode: HistoryMode, moveFocus: boolean): Promise<void> {
67
+ const navId = ++this._navId;
68
+ const isStale = () => navId !== this._navId;
69
+ const [pathAndQuery = '', hash] = path.split('#');
70
+ const [rawPath = '', query] = pathAndQuery.split('?');
71
+ const search = query ? `?${query}` : '';
52
72
  const normalizedPath = NavigationManager.normalizePath(rawPath);
53
73
  const currentPath = NavigationManager.getCurrentPath();
54
74
 
@@ -56,7 +76,7 @@ class Router extends Service<Router> {
56
76
  const routeMatch = this.matchRoute(normalizedPath);
57
77
 
58
78
  if (!routeMatch) {
59
- await this.handleNotFound(normalizedPath, currentPath, pushState);
79
+ await this.handleNotFound(normalizedPath, currentPath, mode, isStale, moveFocus);
60
80
  return;
61
81
  }
62
82
 
@@ -64,35 +84,58 @@ class Router extends Service<Router> {
64
84
  normalizedPath,
65
85
  routeMatch.route,
66
86
  routeMatch.params,
67
- pushState
87
+ currentPath,
88
+ mode,
89
+ moveFocus
68
90
  );
69
91
 
70
- if (!guardResult) return;
92
+ if (!guardResult || isStale()) return;
93
+
94
+ // Before the current view is torn down: it stays up while a lazy route's chunk loads.
95
+ const view = await this.resolveView(guardResult.viewConstructor!, NavigationManager.localizedUrl(normalizedPath, search, hash), isStale);
96
+ if (!view) return;
71
97
 
72
98
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
73
99
  this._currentParams = routeMatch.params;
74
100
 
75
- // Must run before renderNewView(): a view's onEnter() may rewrite the URL via
76
- // NavigationManager.replaceState (e.g. canonicalizing a bare route to a default
77
- // sub-page), which acts on whatever history entry is current — so that entry needs
78
- // to already be this route's, not the previous view's, before onEnter() fires.
79
- NavigationManager.updateHistory(normalizedPath, currentPath, pushState, hash);
101
+ // Must run before renderNewView(): a view's onEnter() may rewrite the URL via NavigationManager.replaceState(),
102
+ // which acts on whatever history entry is current. This ensures current before onEnter() fires.
103
+ NavigationManager.updateHistory(normalizedPath, currentPath, mode, hash, search);
80
104
 
81
- this._currentView = ViewRenderManager.renderNewView(
82
- guardResult.viewConstructor!,
83
- routeMatch.params
84
- );
105
+ this._currentView = ViewRenderManager.renderNewView(view, routeMatch.params);
85
106
 
86
107
  NavigationManager.updateDocumentTitle(this._currentView, routeMatch.pattern);
87
108
  NavigationManager.scrollToHash(hash);
109
+ this.afterRender(moveFocus);
88
110
  }
89
111
 
90
- public getCurrentParams(): Record<string, string> {
91
- return { ...this._currentParams };
112
+ // Runs a route's view factory. A sync one behaves as it always did (its errors propagate).
113
+ // A lazy one is awaited: if it fails to load, recoverFromFailedViewLoad() takes over; if a
114
+ // newer navigation started meanwhile, the view (already mounted, empty, by its
115
+ // constructor) is destroyed and dropped.
116
+ private async resolveView(factory: Nutin.ViewFactory, url: string, isStale: () => boolean): Promise<View | null> {
117
+ if (isStale()) return null;
118
+ const result = factory();
119
+ if (!isPromise(result)) return result;
120
+
121
+ let view: View;
122
+ try {
123
+ view = await result;
124
+ } catch (error) {
125
+ if (!isStale()) NavigationManager.recoverFromFailedViewLoad(url, error);
126
+ return null;
127
+ }
128
+ if (isStale()) {
129
+ view.destroy();
130
+ return null;
131
+ }
132
+ return view;
92
133
  }
93
134
 
94
- public getParam(key: string): string | undefined {
95
- return this._currentParams[key];
135
+ // After updateDocumentTitle(): focusView() announces document.title when the view has no h1.
136
+ private afterRender(moveFocus: boolean): void {
137
+ if (moveFocus && this._currentView) NavigationManager.focusView(this._currentView);
138
+ this._hasRendered = true;
96
139
  }
97
140
 
98
141
  private initializeEventListeners(): void {
@@ -115,11 +158,13 @@ class Router extends Service<Router> {
115
158
  }
116
159
 
117
160
  private async handlePopState(): Promise<void> {
118
- const newLocale = NavigationManager.getCurrentLocale();
119
- if (newLocale && newLocale !== I18nService.currentLanguage) {
120
- await I18nService.setCurrentLanguage(newLocale as Language);
161
+ if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
162
+ const newLocale = NavigationManager.getCurrentLocale();
163
+ if (newLocale && newLocale !== I18nService.currentLanguage) {
164
+ await I18nService.setCurrentLanguage(newLocale as Nutin.Language);
165
+ }
121
166
  }
122
- this.navigate(NavigationManager.getCurrentPath() + window.location.hash, false);
167
+ this.navigateWithMode(this.currentLocation(), 'none', this._hasRendered);
123
168
  }
124
169
 
125
170
  /**
@@ -128,7 +173,7 @@ class Router extends Service<Router> {
128
173
  * - '/users/:id?' matches '/users' and '/users/123'
129
174
  * - '/posts/:id' matches '/posts/123' but not '/posts'
130
175
  */
131
- private matchRoute(path: string): RouteMatch | null {
176
+ private matchRoute(path: string): Nutin.RouteMatch | null {
132
177
  for (const [pattern, routeConfig] of Object.entries(this.routes)) {
133
178
  const match = NavigationManager.matchPattern(pattern, path);
134
179
  if (match) {
@@ -141,7 +186,9 @@ class Router extends Service<Router> {
141
186
  private async handleNotFound(
142
187
  normalizedPath: string,
143
188
  currentPath: string,
144
- pushState: boolean
189
+ mode: HistoryMode,
190
+ isStale: () => boolean,
191
+ moveFocus: boolean
145
192
  ): Promise<void> {
146
193
  const notFoundRoute = this.routes['/404'];
147
194
 
@@ -150,21 +197,26 @@ class Router extends Service<Router> {
150
197
  return;
151
198
  }
152
199
 
153
- const notFoundConstructor = RouteGuardsManager.getViewConstructor(notFoundRoute);
154
-
200
+ const notFoundFactory = RouteGuardsManager.getViewConstructor(notFoundRoute);
201
+ const view = await this.resolveView(notFoundFactory, NavigationManager.localizedUrl(normalizedPath), isStale);
202
+ if (!view) return;
203
+
155
204
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
156
205
  this._currentParams = {};
157
- this._currentView = ViewRenderManager.renderNewView(notFoundConstructor, {});
206
+ this._currentView = ViewRenderManager.renderNewView(view, {});
158
207
 
159
208
  NavigationManager.updateDocumentTitle(this._currentView, '/404');
160
- NavigationManager.updateHistory(normalizedPath, currentPath, pushState);
209
+ NavigationManager.updateHistory(normalizedPath, currentPath, mode);
210
+ this.afterRender(moveFocus);
161
211
  }
162
212
 
163
213
  private async handleGuards(
164
214
  normalizedPath: string,
165
- routeConfig: RouteConfig,
215
+ routeConfig: Nutin.RouteConfig,
166
216
  params: Record<string, string>,
167
- pushState: boolean
217
+ currentPath: string,
218
+ mode: HistoryMode,
219
+ moveFocus: boolean
168
220
  ): Promise<GuardResult | false> {
169
221
  const guardResult = await RouteGuardsManager.processRouteGuards(
170
222
  routeConfig,
@@ -174,7 +226,11 @@ class Router extends Service<Router> {
174
226
 
175
227
  if (!guardResult.allowed) {
176
228
  if (guardResult.redirectTo) {
177
- await this.navigate(guardResult.redirectTo, pushState);
229
+ // A guarded URL that's already the current history entry (back/forward,
230
+ // reload, first load) is replaced by the redirect target, so the address
231
+ // bar matches what's rendered and Back doesn't land on the guarded URL again.
232
+ const redirectMode = mode === 'push' && normalizedPath !== currentPath ? 'push' : 'replace';
233
+ await this.navigateWithMode(guardResult.redirectTo, redirectMode, moveFocus);
178
234
  }
179
235
  // If no redirect, stay on current route (guard blocked navigation)
180
236
  return false;
@@ -184,4 +240,8 @@ class Router extends Service<Router> {
184
240
  }
185
241
  }
186
242
 
187
- export const AppRouter = (routes: Routes) => Router.getInstance(routes);
243
+ function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
244
+ return typeof (value as Promise<T> | null)?.then === 'function';
245
+ }
246
+
247
+ export const AppRouter = (routes: Nutin.Routes) => Router.getInstance(routes);
@@ -19,6 +19,11 @@ function validateConfig(config) {
19
19
 
20
20
  validateConfig(nutinConfig);
21
21
 
22
+ // `build:prod` passes --prod rather than a NODE_ENV=production prefix, which Windows shells
23
+ // don't understand. Every build step is a separate process that only inherits the env (see
24
+ // runScript), so the flag is turned into NODE_ENV here, for them and for this process.
25
+ if (process.argv.includes('--prod')) process.env.NODE_ENV = 'production';
26
+
22
27
  const wellKnownNonSEORoutes = [
23
28
  '/400',
24
29
  '/401',
@@ -34,6 +39,7 @@ export const builderConfig = {
34
39
  ...nutinConfig.builder,
35
40
  isProd: process.env.NODE_ENV === 'production',
36
41
  i18n: nutinConfig.i18n,
42
+ markdownSources: nutinConfig.markdownSources,
37
43
  tailwind: nutinConfig.tailwind,
38
44
  generateSEO: nutinConfig.generateSEOFiles,
39
45
  WELL_KNOWN_NON_SEO_ROUTES: wellKnownNonSEORoutes,
@@ -13,14 +13,25 @@ const scriptsDir = path.join(process.cwd(), 'tools', 'builder', 'core');
13
13
 
14
14
  print.boldHead(`\nStarting build...`);
15
15
 
16
+ // runScript forwards env vars but not argv, so -y/--yes (auto-install an opt-in
17
+ // feature's missing dependencies, see utils/ensure-deps.js) is passed through the env.
18
+ if (process.argv.includes('-y') || process.argv.includes('--yes')) process.env.NUTIN_ASSUME_YES = '1';
19
+
16
20
  runScript(path.join(scriptsDir, 'app', 'copy-static.js'), 'Copying files...');
21
+ {{#if markdown}}
22
+
23
+ runScript(path.join(scriptsDir, 'markdown', 'generate-markdown-pages.js'), 'Compiling Markdown pages...');
24
+ {{/if}}
17
25
 
18
26
  runScript(path.join(scriptsDir, 'html-index', 'validate-html.js'), 'Processing index.html...');
19
27
  runScript(path.join(scriptsDir, 'app', 'validate-routes.js'), 'Validating app routes...');
20
28
 
21
- runScript(path.join(scriptsDir, 'app', 'compile-ts.js'), 'Compiling TypeScript...');
22
-
23
29
  runScript(path.join(scriptsDir, 'html-templates', 'process-html-templates.js'), 'Processing HTML templates...');
30
+ {{#unless (eq lang "js")}}
31
+ // After the templates are merged, so they're type-checked too.
32
+ runScript(path.join(scriptsDir, 'app', 'compile-ts.js'), 'Compiling TypeScript...');
33
+ {{/unless}}
34
+ runScript(path.join(scriptsDir, 'html-templates', 'minify-templates.js'), 'Minifying HTML templates...');
24
35
 
25
36
  runScript(path.join(scriptsDir, 'styles', 'sass.js'), 'Compiling styles...');
26
37
  if (builderConfig.tailwind) runScript(path.join(scriptsDir, 'styles', 'tailwind.js'), 'Compiling Tailwind CSS...');
@@ -30,12 +41,13 @@ if (builderConfig.i18n) runScript(path.join(scriptsDir, 'i18n', 'build-i18n.js')
30
41
  runScript(path.join(scriptsDir, 'prod-bundle', 'esbuild.js'), 'Running esbuild...');
31
42
 
32
43
  if (builderConfig.isProd) {
33
- runScript(path.join(scriptsDir, 'prod-bundle', 'hash-files.js'), 'Hashing files...');
34
- runScript(path.join(scriptsDir, 'prod-bundle', 'compress-files.js'), 'Compressing files...');
35
- if (builderConfig.generateSEO) runScript(path.join(scriptsDir, 'seo', 'generate-seo-files.js'), 'Generating SEO Files...');
44
+ runScript(path.join(scriptsDir, 'prod-bundle', 'hash-files.js'), 'Hashing files...');
45
+ if (builderConfig.generateSEO) runScript(path.join(scriptsDir, 'seo', 'generate-seo-files.js'), 'Generating SEO Files...');
46
+ // Last, so the prerendered SEO pages, sitemap.xml and robots.txt get compressed too.
47
+ runScript(path.join(scriptsDir, 'prod-bundle', 'compress-files.js'), 'Compressing files...');
36
48
  }
37
49
 
38
- runScript(path.join(scriptsDir, 'finalize-build.js'), 'Finalizing build...')
50
+ runScript(path.join(scriptsDir, 'finalize-build.js'), 'Finalizing build...');
39
51
 
40
52
  print.boldSuccess(`\nBuild successful!\n`);
41
53
 
@@ -1,14 +1,89 @@
1
- import { runCommand, print, errorExit } from "../../../utils/index.js";
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { captureCommand, errorExit } from "../../../utils/index.js";
2
4
  import { builderConfig } from '../../builder.config.js';
5
+ import { PATHS } from './paths.js';
6
+ import { readTemplateMap } from '../html-templates/merge-templates.js';
7
+ import { remapTscOutput } from './ts-errors.js';
8
+ import { injectHandlerRefs, stripHandlerRefs } from './handler-refs.js';
9
+
10
+ // Runs after the .html templates are merged (process-html-templates), on dist-build's copies,
11
+ // so templates are type-checked like the rest of the code. A tsconfig generated there extends
12
+ // the project's: dist-build/src mirrors src, so its include/exclude apply as written.
13
+ const BUILD_TSCONFIG = path.join(PATHS.temp, 'tsconfig.json');
3
14
 
4
15
  async function compileTS() {
5
- // Prod bundles straight from .ts source (see prod-bundle/esbuild.js), so tsc only
6
- // needs to type-check there, not emit — emitting would leave a stale, unmerged .js
7
- // sibling next to each merged .ts view/component's source.
8
- const args = ['--project', 'tsconfig.json'];
16
+ writeBuildTsconfig();
17
+
18
+ // Prod bundles straight from the merged .ts (see prod-bundle/esbuild.js), so tsc only
19
+ // type-checks there. Dev emits each .js next to its .ts, for the dev bundle and testin-nutin.
20
+ const args = ['--project', BUILD_TSCONFIG, '--pretty', 'false'];
9
21
  if (builderConfig.isProd) args.push('--noEmit');
10
22
 
11
- await runCommand('tsc', args);
23
+ // data-event handlers are type-checked through references injected for tsc only, then
24
+ // removed from the .ts copies the prod bundle is built from (see handler-refs.js).
25
+ const handlerRefs = await injectHandlerRefs();
26
+ const { code, output } = await captureCommand('tsc', args);
27
+ await stripHandlerRefs(handlerRefs);
28
+ const report = remapTscOutput(output, {
29
+ cwd: process.cwd(),
30
+ tempDir: PATHS.temp,
31
+ templateMap: await readTemplateMap(),
32
+ handlerRefs,
33
+ }).trim();
34
+
35
+ // On stdout, where tsc itself writes its diagnostics.
36
+ if (report) console.log(report);
37
+ if (code !== 0) throw new Error(`tsc exited with code ${code}`);
38
+ }
39
+
40
+ function writeBuildTsconfig() {
41
+ const project = readTsconfig(path.resolve('tsconfig.json'));
42
+ const config = {
43
+ extends: '../tsconfig.json',
44
+ compilerOptions: { rootDir: 'src', outDir: 'src' },
45
+ include: (project.include ?? ['src']).map(toBuildPath),
46
+ exclude: (project.exclude ?? []).map(toBuildPath),
47
+ };
48
+ if (project.files) config.files = project.files.map(toBuildPath);
49
+ fs.writeFileSync(BUILD_TSCONFIG, JSON.stringify(config, null, 2));
50
+ }
51
+
52
+ // Paths in src/ are mirrored in dist-build/src; anything else stays where it is, one level up.
53
+ function toBuildPath(entry) {
54
+ const normalized = entry.replace(/^\.\//, '');
55
+ return normalized === 'src' || normalized.startsWith('src/') ? normalized : `../${normalized}`;
56
+ }
57
+
58
+ // tsconfig.json is JSONC: comments and trailing commas are allowed.
59
+ function readTsconfig(file) {
60
+ const source = fs.readFileSync(file, 'utf-8');
61
+ let json = '';
62
+ for (let i = 0; i < source.length; i++) {
63
+ const ch = source[i];
64
+ if (ch === '"') {
65
+ const end = endOfString(source, i);
66
+ json += source.slice(i, end);
67
+ i = end - 1;
68
+ } else if (ch === '/' && source[i + 1] === '/') {
69
+ while (i < source.length && source[i] !== '\n') i++;
70
+ json += '\n';
71
+ } else if (ch === '/' && source[i + 1] === '*') {
72
+ i = source.indexOf('*/', i + 2) + 1;
73
+ if (i === 0) break;
74
+ } else {
75
+ json += ch;
76
+ }
77
+ }
78
+ return JSON.parse(json.replace(/,(\s*[}\]])/g, '$1'));
79
+ }
80
+
81
+ function endOfString(source, start) {
82
+ for (let i = start + 1; i < source.length; i++) {
83
+ if (source[i] === '\\') i++;
84
+ else if (source[i] === '"') return i + 1;
85
+ }
86
+ return source.length;
12
87
  }
13
88
 
14
89
  compileTS().catch((err) => {
@@ -7,7 +7,13 @@ import { builderConfig } from '../../builder.config.js';
7
7
 
8
8
  async function copyStatic() {
9
9
  const extensions = [...BINARY_EXTENSIONS, '.html'];
10
- if (builderConfig.isProd) extensions.push('.ts');
10
+ {{#if (eq lang "js")}}
11
+ // No compile step emits .js here, so sources are copied in dev and prod alike.
12
+ extensions.push('.js');
13
+ {{else}}
14
+ // Templates are merged into these copies, then tsc checks them (and emits .js in dev).
15
+ extensions.push('.ts');
16
+ {{/if}}
11
17
 
12
18
  // Every build starts from a clean dist-build: a failed/interrupted prior
13
19
  // build never gets renamed away by finalize-build.js, so a stale sibling
@@ -23,6 +29,11 @@ async function copyStatic() {
23
29
  const files = await getFilesRecursive(PATHS.source, extension);
24
30
 
25
31
  for (const file of files) {
32
+ {{#if (eq lang "js")}}
33
+ // Tests import the build output; they must not end up in it.
34
+ if (file.endsWith('.test.js')) continue;
35
+
36
+ {{/if}}
26
37
  const relative = path.relative(PATHS.source, file);
27
38
  const dest = path.join(PATHS.tempSource, relative);
28
39
 
@@ -0,0 +1,114 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { getFilesRecursive } from '../../../utils/index.js';
4
+ import { findInlineTemplate } from '../html-templates/template-literal.js';
5
+ import { PATHS } from './paths.js';
6
+
7
+ // data-event handlers are looked up by name at runtime, so tsc can't see them: a typo binds
8
+ // nothing, and noUnusedLocals flags every private handler. Before tsc, each component/view's
9
+ // handler names are referenced from its own class, on the class's opening line (no line shifts):
10
+ // class TaskCardComponent extends Component { static { void ((c: InstanceType<typeof TaskCardComponent>) => [c._save]); }
11
+ // A class body may read its own private members, so a missing handler is a tsc error and an
12
+ // existing one counts as used. The arrow is never called; stripHandlerRefs() removes it after tsc.
13
+
14
+ const DATA_EVENT = /\bdata-event\s*=\s*(["'])(.*?)\1/gs;
15
+ const CLASS_OPEN = /\bclass\s+([A-Za-z_$][\w$]*)\b[^{;]*?\bextends\b[^{;]*\{/g;
16
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
17
+
18
+ // Injects the references into every .ts under src/app; returns where they went, by path
19
+ // relative to dist-build (as ts-errors.js receives tsc's paths).
20
+ export async function injectHandlerRefs() {
21
+ const refs = {};
22
+ for (const codePath of await getFilesRecursive(PATHS.tempApp, ['.ts'])) {
23
+ const content = await fs.readFile(codePath, 'utf-8');
24
+ const result = addHandlerRefs(content);
25
+ if (!result) continue;
26
+ await fs.writeFile(codePath, result.content);
27
+ refs[toPosix(path.relative(PATHS.temp, codePath))] = result.entry;
28
+ }
29
+ return refs;
30
+ }
31
+
32
+ export async function stripHandlerRefs(refs) {
33
+ for (const [relPath, entry] of Object.entries(refs)) {
34
+ const codePath = path.join(PATHS.temp, relPath);
35
+ const content = await fs.readFile(codePath, 'utf-8');
36
+ await fs.writeFile(codePath, removeHandlerRefs(content, entry));
37
+ }
38
+ }
39
+
40
+ // The template's handler names, referenced from the class that follows (or precedes) it.
41
+ // Returns null when there's nothing to check. entry: { className, line, col, segment, handlers }
42
+ // with 1-based positions; each handler's col is where it sits in the injected line, and its
43
+ // templateLine/templateCol where its name is written in the template.
44
+ export function addHandlerRefs(content) {
45
+ const template = findInlineTemplate(content);
46
+ if (!template || template.body === null) return null;
47
+
48
+ const handlers = collectHandlers(content, template);
49
+ if (!handlers.length) return null;
50
+
51
+ const classOpen = findClassOpen(content, template);
52
+ if (!classOpen) return null;
53
+
54
+ const prefix = ` static { void ((c: InstanceType<typeof ${classOpen.name}>) => [`;
55
+ let segment = prefix;
56
+ const { line, col } = positionOf(content, classOpen.end);
57
+ const located = handlers.map((handler, i) => {
58
+ if (i > 0) segment += ', ';
59
+ const handlerCol = col + segment.length + 2; // past "c."
60
+ segment += `c.${handler.name}`;
61
+ return { ...handler, col: handlerCol };
62
+ });
63
+ segment += ']); }';
64
+
65
+ return {
66
+ content: content.slice(0, classOpen.end) + segment + content.slice(classOpen.end),
67
+ entry: { className: classOpen.name, line, col, segment, handlers: located },
68
+ };
69
+ }
70
+
71
+ export function removeHandlerRefs(content, entry) {
72
+ const index = content.indexOf(entry.segment);
73
+ return index === -1 ? content : content.slice(0, index) + content.slice(index + entry.segment.length);
74
+ }
75
+
76
+ // Each handler named in a static data-event (one built with ${} is skipped), once, at its
77
+ // first occurrence.
78
+ function collectHandlers(content, template) {
79
+ const handlers = new Map();
80
+ for (const match of template.body.matchAll(DATA_EVENT)) {
81
+ const value = match[2];
82
+ if (value.includes('${')) continue;
83
+ const name = value.split(':')[1]?.trim();
84
+ if (!name || !IDENTIFIER.test(name) || handlers.has(name)) continue;
85
+
86
+ const valueStart = match.index + match[0].indexOf(match[1]) + 1;
87
+ const afterColon = value.indexOf(':') + 1;
88
+ const nameOffset = template.open + valueStart + afterColon + value.slice(afterColon).indexOf(name);
89
+ const { line, col } = positionOf(content, nameOffset);
90
+ handlers.set(name, { name, templateLine: line, templateCol: col });
91
+ }
92
+ return [...handlers.values()];
93
+ }
94
+
95
+ // The first `class X ... extends ... {` outside the template: after it first, else before it.
96
+ function findClassOpen(content, template) {
97
+ const end = template.end ?? template.open;
98
+ let found = null;
99
+ for (const match of content.matchAll(CLASS_OPEN)) {
100
+ const inTemplate = match.index >= template.open && match.index < end;
101
+ if (inTemplate) continue;
102
+ const candidate = { name: match[1], end: match.index + match[0].length };
103
+ if (match.index >= end) return candidate;
104
+ found ??= candidate;
105
+ }
106
+ return found;
107
+ }
108
+
109
+ function positionOf(content, offset) {
110
+ const before = content.slice(0, offset);
111
+ return { line: before.split('\n').length, col: offset - before.lastIndexOf('\n') };
112
+ }
113
+
114
+ const toPosix = (p) => p.split(path.sep).join('/');
@@ -0,0 +1,70 @@
1
+ import { addHandlerRefs, removeHandlerRefs } from './handler-refs.js';
2
+
3
+ const VIEW = [
4
+ "import { View, html } from '../../../core/index.js';",
5
+ '',
6
+ 'const template = html`<div>',
7
+ ' <button data-event="click:_save:@value">Save</button>',
8
+ ' <a data-event=\'click:_open\' data-event-extra="x">Open</a>',
9
+ ' <button data-event="click:_save">Again</button>',
10
+ '</div>`;',
11
+ '',
12
+ 'export class TaskView extends View {',
13
+ ' private _save(): void {}',
14
+ ' private _open(): void {}',
15
+ '}',
16
+ ].join('\n');
17
+
18
+ describe('handler-refs', () => {
19
+ it('references each data-event handler once, from the class\'s opening line', () => {
20
+ const { content, entry } = addHandlerRefs(VIEW);
21
+ const classLine = content.split('\n')[8];
22
+
23
+ expect(classLine).toBe(
24
+ 'export class TaskView extends View { static { void ((c: InstanceType<typeof TaskView>) => [c._save, c._open]); }'
25
+ );
26
+ expect(content.split('\n').length).toBe(VIEW.split('\n').length);
27
+ expect(entry.className).toBe('TaskView');
28
+ expect(entry.line).toBe(9);
29
+ expect(entry.handlers.map((h) => h.name)).toEqual(['_save', '_open']);
30
+ });
31
+
32
+ it('records where each handler is named in the template and in the injected line', () => {
33
+ const { content, entry } = addHandlerRefs(VIEW);
34
+ const [save, open] = entry.handlers;
35
+
36
+ expect(save.templateLine).toBe(4);
37
+ expect(VIEW.split('\n')[3].slice(save.templateCol - 1).startsWith('_save:@value')).toBeTruthy();
38
+ expect(open.templateLine).toBe(5);
39
+ expect(VIEW.split('\n')[4].slice(open.templateCol - 1).startsWith('_open\'')).toBeTruthy();
40
+
41
+ const classLine = content.split('\n')[8];
42
+ expect(classLine.slice(save.col - 1).startsWith('_save,')).toBeTruthy();
43
+ expect(classLine.slice(open.col - 1).startsWith('_open]')).toBeTruthy();
44
+ });
45
+
46
+ it('skips data-event values built with ${}, and files without handlers', () => {
47
+ const dynamic = VIEW
48
+ .replace('click:_save:@value', 'click:${handler}')
49
+ .replace("'click:_open'", "'click:${other}'")
50
+ .replace('"click:_save"', '"${evt}:_save"');
51
+ expect(addHandlerRefs(dynamic)).toBe(null);
52
+ expect(addHandlerRefs("export class A extends View {}")).toBe(null);
53
+ });
54
+
55
+ it('finds the class after the template, even when the markup mentions a class', () => {
56
+ const source = [
57
+ 'const templateFn = (t: T) => html`<p class="x">the class Foo extends Bar { }</p><i data-event="click:_go"></i>`;',
58
+ 'export class CardComponent<T = string> extends Component<T> {',
59
+ '}',
60
+ ].join('\n');
61
+ const { content, entry } = addHandlerRefs(source);
62
+ expect(entry.className).toBe('CardComponent');
63
+ expect(content.split('\n')[1]).toContain('extends Component<T> { static { void ((c: InstanceType<typeof CardComponent>) => [c._go]); }');
64
+ });
65
+
66
+ it('removes exactly the injected references', () => {
67
+ const { content, entry } = addHandlerRefs(VIEW);
68
+ expect(removeHandlerRefs(content, entry)).toBe(VIEW);
69
+ });
70
+ });