@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
@@ -22,7 +22,7 @@ describe('addTags', () => {
22
22
  const result = await addTags(html, filePath);
23
23
 
24
24
  expect(result).toBe(
25
- '<html><head><title>T</title><link rel="stylesheet" href="/main.css"></head><body><script type="module" src="/app/main.js"></script></body></html>'
25
+ '<html><head><title>T</title><link rel="stylesheet" href="/main.css"></head><body><script type="module" src="/bundle.js"></script></body></html>'
26
26
  );
27
27
  });
28
28
 
@@ -32,7 +32,7 @@ describe('addTags', () => {
32
32
  const result = await addTags(html, filePath);
33
33
 
34
34
  expect(result).toBe(
35
- '<html><link rel="stylesheet" href="/main.css"><title>T</title><body><script type="module" src="/app/main.js"></script></body></html>'
35
+ '<html><link rel="stylesheet" href="/main.css"><title>T</title><body><script type="module" src="/bundle.js"></script></body></html>'
36
36
  );
37
37
  });
38
38
 
@@ -42,7 +42,7 @@ describe('addTags', () => {
42
42
  const result = await addTags(html, filePath);
43
43
 
44
44
  expect(result).toBe(
45
- '<html></html><link rel="stylesheet" href="/main.css"><script type="module" src="/app/main.js"></script>'
45
+ '<html></html><link rel="stylesheet" href="/main.css"><script type="module" src="/bundle.js"></script>'
46
46
  );
47
47
  });
48
48
 
@@ -2,8 +2,12 @@ import { readFile } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { print, errorExit } from '../../../utils/index.js';
4
4
  import { PATHS } from '../app/paths.js';
5
+ import { readRoutesImporters } from '../app/route-keys.js';
6
+ import { builderConfig } from '../../builder.config.js';
5
7
  import { addTags } from './add-tags.js';
6
8
 
9
+ const ROUTES_FILE = path.join(PATHS.sourceApp, 'routes.{{#if (eq lang "js")}}js{{else}}ts{{/if}}');
10
+
7
11
  async function validateHtml() {
8
12
  const filePath = path.join(PATHS.tempSource, 'index.html');
9
13
 
@@ -14,8 +18,12 @@ async function validateHtml() {
14
18
  errorExit(`Failed to read ${filePath}`);
15
19
  }
16
20
 
17
- if (!htmlContent.includes('id="app"')) {
18
- errorExit('Missing #app container in HTML');
21
+ // The router mounts views into #app, and generateSEOFiles prerenders into it. An app without
22
+ // the router mounts its components wherever it wants: a missing target is reported at runtime.
23
+ const needsApp = builderConfig.generateSEO || readRoutesImporters(PATHS.sourceApp, ROUTES_FILE).length > 0;
24
+ if (needsApp && !htmlContent.includes('id="app"')) {
25
+ const reason = builderConfig.generateSEO ? 'generateSEOFiles prerenders pages into it' : 'the router mounts views into it';
26
+ errorExit(`Missing #app container in index.html: ${reason}.`);
19
27
  }
20
28
 
21
29
  htmlContent = await addTags(htmlContent, filePath).catch(err => errorExit(err.message));
@@ -1,41 +1,70 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import esbuild from 'esbuild';
3
4
  import { getFilesRecursive, print, pascalCased, capitalized } from '../../../utils/index.js';
4
5
  import { PATHS } from '../app/paths.js';
5
- import { minifyHTML } from './minify-html.js';
6
+ import { findInlineTemplate, minifyTemplateBody } from './template-literal.js';
6
7
 
7
8
  const PLACEHOLDER = '__TEMPLATE_PLACEHOLDER__';
8
9
  const TARGET_EXTENSIONS = ['.ts', '.js'];
9
- const TEMPLATE_REGEX = /const\s+(?:template|templateFn)\s*=?\s*(?:\(.*?\)\s*=>\s*)?`([\s\S]*?)`/;
10
+
11
+ // Where each merged .html template sits in its code file (merge-templates writes it,
12
+ // compile-ts reads it to report type errors at the .html line). Keyed by path relative to
13
+ // dist-build (`src/app/...`), which is also the path of the source file in the project.
14
+ export const TEMPLATE_MAP_FILE = path.join(PATHS.temp, '.template-map.json');
10
15
 
11
16
  // A component/view's template can live inline in its .ts/.js file or in a
12
17
  // colocated .html sibling merged in via `__TEMPLATE_PLACEHOLDER__` — resolved
13
18
  // automatically per file rather than through a global config flag:
14
19
  // external + placeholder -> merge external in
15
20
  // external + real inline markup -> conflict, fail
16
- // no external + real inline markup -> minify inline
21
+ // no external + real inline markup -> keep inline
17
22
  // no external + still the placeholder -> nothing to use, fail
18
- export async function resolveTemplates() {
23
+ // Templates are merged as written, so tsc type-checks them with readable line numbers
24
+ // (see compile-ts); minifyTemplates() minifies every template afterwards.
25
+ export async function mergeTemplates() {
19
26
  const htmlFiles = await getFilesRecursive(PATHS.tempApp, 'html');
20
27
  const resolvedPaths = new Set();
21
28
  const failures = [];
29
+ const templateMap = {};
22
30
 
23
31
  for (const htmlPath of htmlFiles) {
24
- await resolveExternalTemplate(htmlPath, resolvedPaths, failures);
32
+ await mergeExternalTemplate(htmlPath, resolvedPaths, failures, templateMap);
25
33
  }
26
34
 
27
- await resolveRemainingTemplates(resolvedPaths, failures);
35
+ await checkRemainingTemplates(resolvedPaths, failures);
36
+ await fs.writeFile(TEMPLATE_MAP_FILE, JSON.stringify(templateMap));
28
37
 
29
38
  if (failures.length) {
30
39
  throw new Error(`Failed to resolve ${failures.length} template(s).`);
31
40
  }
32
41
  }
33
42
 
34
- // Normally a view/component has exactly one sibling here — `.js` in dev (tsc
35
- // output), `.ts` in prod (esbuild bundles straight from source, tsc runs
36
- // --noEmit there). Checking whichever exist(s) rather than assuming one, so
37
- // this can't silently regress again if that dev/prod split ever changes.
38
- async function resolveExternalTemplate(htmlPath, resolvedPaths, failures) {
43
+ // Minifies the template of every code file, merged or inline. Anything that can't be
44
+ // minified safely is kept as written.
45
+ export async function minifyTemplates() {
46
+ const templateMap = await readTemplateMap();
47
+ const codeFiles = await getFilesRecursive(PATHS.tempApp, TARGET_EXTENSIONS);
48
+
49
+ for (const codePath of codeFiles) {
50
+ const content = await fs.readFile(codePath, 'utf-8');
51
+ const template = findMergedTemplate(content, codePath, templateMap) ?? findInlineTemplate(content);
52
+ if (!template || template.body === null || template.body === PLACEHOLDER) continue;
53
+
54
+ const minified = await minifyTemplateBody(template.body);
55
+ if (minified === null) {
56
+ print.gray(describeUnminified(codePath));
57
+ continue;
58
+ }
59
+ const updated = content.slice(0, template.open) + minified + content.slice(template.end);
60
+ await fs.writeFile(codePath, await keepIfValid(codePath, updated, content));
61
+ }
62
+ }
63
+
64
+ // Normally a view/component has exactly one sibling here: its copied `.ts` (TypeScript
65
+ // projects; tsc compiles it once merged) or `.js` (JS-only projects). Checking whichever
66
+ // exist(s) rather than assuming one, so this can't silently regress if that ever changes.
67
+ async function mergeExternalTemplate(htmlPath, resolvedPaths, failures, templateMap) {
39
68
  const htmlFilename = path.basename(htmlPath);
40
69
 
41
70
  let htmlContent;
@@ -47,7 +76,6 @@ async function resolveExternalTemplate(htmlPath, resolvedPaths, failures) {
47
76
  return;
48
77
  }
49
78
 
50
- const minifiedHtml = await minifyHTML(htmlContent);
51
79
  let mergedCount = 0;
52
80
  let conflictCount = 0;
53
81
 
@@ -66,9 +94,18 @@ async function resolveExternalTemplate(htmlPath, resolvedPaths, failures) {
66
94
  }
67
95
 
68
96
  resolvedPaths.add(codePath);
97
+ warnIfUntagged(codePath, content, htmlContent);
69
98
 
70
99
  if (content.includes(PLACEHOLDER)) {
71
- await fs.writeFile(codePath, content.replace(PLACEHOLDER, minifiedHtml));
100
+ templateMap[path.relative(PATHS.temp, codePath)] = {
101
+ html: path.relative(PATHS.temp, htmlPath),
102
+ ...placeholderPosition(content),
103
+ lines: htmlContent.split('\n').length,
104
+ template: htmlContent,
105
+ };
106
+ // Replacements go through functions: a replacement *string* expands `$$`, `$&`,
107
+ // `$'`… found in the markup (e.g. `$${price}` would lose its `$`).
108
+ await fs.writeFile(codePath, content.replace(PLACEHOLDER, () => htmlContent));
72
109
  mergedCount++;
73
110
  continue;
74
111
  }
@@ -86,29 +123,69 @@ async function resolveExternalTemplate(htmlPath, resolvedPaths, failures) {
86
123
  }
87
124
  }
88
125
 
89
- async function resolveRemainingTemplates(resolvedPaths, failures) {
126
+ // Inline templates: a placeholder with no .html to fill it fails; untagged ones warn.
127
+ async function checkRemainingTemplates(resolvedPaths, failures) {
90
128
  const codeFiles = await getFilesRecursive(PATHS.tempApp, TARGET_EXTENSIONS);
91
129
 
92
130
  for (const codePath of codeFiles) {
93
131
  if (resolvedPaths.has(codePath)) continue;
94
132
 
95
133
  const content = await fs.readFile(codePath, 'utf-8');
96
- const match = content.match(TEMPLATE_REGEX);
97
- if (!match) continue;
98
-
99
- const [full, body] = match;
134
+ const template = findInlineTemplate(content);
135
+ if (!template || template.body === null) continue;
136
+ warnIfUntagged(codePath, content, template.body);
100
137
 
101
- if (body === PLACEHOLDER) {
138
+ if (template.body === PLACEHOLDER) {
102
139
  print.warn(describeMissingTemplate(codePath));
103
140
  failures.push(path.basename(codePath));
104
- continue;
105
141
  }
142
+ }
143
+ }
106
144
 
107
- const minified = await minifyHTML(body);
108
- await fs.writeFile(codePath, content.replace(full, full.replace(body, minified)));
145
+ export async function readTemplateMap() {
146
+ try {
147
+ return JSON.parse(await fs.readFile(TEMPLATE_MAP_FILE, 'utf-8'));
148
+ } catch {
149
+ return {};
109
150
  }
110
151
  }
111
152
 
153
+ // A merged template, found by its markup: it's wherever the placeholder was, whatever the
154
+ // variable holding it is called, in the copied .ts or in tsc's .js emitted from it.
155
+ function findMergedTemplate(content, codePath, templateMap) {
156
+ const relPath = path.relative(PATHS.temp, codePath);
157
+ const entry = templateMap[relPath] ?? templateMap[relPath.replace(/\.js$/, '.ts')];
158
+ if (!entry) return null;
159
+ const open = content.indexOf(entry.template);
160
+ if (open === -1) return null;
161
+ return { open, end: open + entry.template.length, body: entry.template };
162
+ }
163
+
164
+ // 1-based line and column of the placeholder in a code file.
165
+ export function placeholderPosition(content) {
166
+ const index = content.indexOf(PLACEHOLDER);
167
+ const before = content.slice(0, index);
168
+ return { line: before.split('\n').length, col: index - before.lastIndexOf('\n') };
169
+ }
170
+
171
+ // Last safety net: minified code that no longer parses is replaced by the code as written.
172
+ async function keepIfValid(codePath, minified, asWritten) {
173
+ if (minified === asWritten) return minified;
174
+ try {
175
+ await esbuild.transform(minified, { loader: codePath.endsWith('.ts') ? 'ts' : 'js' });
176
+ return minified;
177
+ } catch {
178
+ print.gray(describeUnminified(codePath));
179
+ return asWritten;
180
+ }
181
+ }
182
+
183
+ // Templates are escaped by the html`` tag; an untagged literal with ${} interpolates raw.
184
+ function warnIfUntagged(codePath, content, markup) {
185
+ const template = findInlineTemplate(content);
186
+ if (template && !template.tagged && markup.includes('${')) print.warn(describeUntagged(codePath));
187
+ }
188
+
112
189
  function describeCodeFile(codePath) {
113
190
  const filename = path.basename(codePath);
114
191
  const basename = filename.replace(/\.(ts|js)$/, '');
@@ -143,10 +220,21 @@ Expected:
143
220
  Add ${basename}.html, or replace the placeholder in ${filename} with inline markup.`;
144
221
  }
145
222
 
223
+ function describeUnminified(filePath) {
224
+ return `Kept the template of ${path.basename(filePath)} unminified: its markup couldn't be minified safely.`;
225
+ }
226
+
227
+ function describeUntagged(codePath) {
228
+ const { filename, kind, className } = describeCodeFile(codePath);
229
+
230
+ return `${kind} "${className}" has an untagged template: its \${} values are inserted unescaped.
231
+ Tag it in ${filename}: html\`...\` (import html from core), and wrap trusted markup in raw().`;
232
+ }
233
+
146
234
  function describeOrphanHtml(htmlPath) {
147
235
  const htmlFilename = path.basename(htmlPath);
148
236
  const basename = htmlFilename.replace(/\.html$/, '');
149
237
 
150
238
  return `"${htmlFilename}" has no matching component or view (expected ${basename}.ts or ${basename}.js).
151
- Remove ${htmlFilename}, or add ${basename}.ts with a template placeholder to merge it into.`;
239
+ Remove ${htmlFilename}, or add ${basename}.{{#if (eq lang "js")}}js{{else}}ts{{/if}} with a template placeholder to merge it into.`;
152
240
  }
@@ -0,0 +1,8 @@
1
+ import { errorExit } from '../../../utils/index.js';
2
+ import { minifyTemplates } from './merge-templates.js';
3
+
4
+ try {
5
+ await minifyTemplates();
6
+ } catch (err) {
7
+ errorExit(err, 'minify-templates');
8
+ }
@@ -1,8 +1,10 @@
1
1
  import { errorExit } from '../../../utils/index.js';
2
- import { resolveTemplates } from './merge-templates.js';
2
+ import { mergeTemplates } from './merge-templates.js';
3
3
 
4
+ // Merges each .html template into its code file, as written: TypeScript projects type-check
5
+ // them next (compile-ts), and minify-templates.js minifies them after that.
4
6
  try {
5
- await resolveTemplates();
7
+ await mergeTemplates();
6
8
  } catch (err) {
7
- errorExit(err);
9
+ errorExit(err, 'process-html-templates');
8
10
  }
@@ -0,0 +1,193 @@
1
+ import { minifyHTML } from './minify-html.js';
2
+
3
+ // Minifies the markup of html`` template literals without ever handing JavaScript to the
4
+ // HTML minifier: each ${…} is swapped for a token, the markup is minified, and the
5
+ // expressions are put back as written (html`` templates nested inside them are minified
6
+ // the same way). Anything the scanner can't follow, or a token the minifier drops or
7
+ // duplicates (e.g. a whole style="${…}" emptied as invalid CSS), leaves the template as
8
+ // written: the worst case is an unminified template, never a broken one.
9
+
10
+ const TOKEN_PREFIX = '__nutin_expr_';
11
+ const token = (n) => `${TOKEN_PREFIX}${n}__`;
12
+
13
+ // After these, a "/" starts a regex literal rather than a division.
14
+ const REGEX_PRECEDING_WORDS = new Set([
15
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void',
16
+ 'throw', 'case', 'do', 'else', 'yield', 'await',
17
+ ]);
18
+ const REGEX_PRECEDING_PUNCTUATION = new Set('(,=:[!&|?{};+-*%<>~^'.split(''));
19
+
20
+ const isIdentifierChar = (ch) => /[\w$]/.test(ch);
21
+
22
+ // Index just past the closing quote of the string starting at `i`, or -1.
23
+ function skipString(src, i) {
24
+ const quote = src[i];
25
+ for (i++; i < src.length; i++) {
26
+ if (src[i] === '\\') i++;
27
+ else if (src[i] === quote) return i + 1;
28
+ else if (src[i] === '\n') return -1;
29
+ }
30
+ return -1;
31
+ }
32
+
33
+ // Index just past the regex literal (and its flags) starting at `i`, or -1.
34
+ function skipRegex(src, i) {
35
+ let inClass = false;
36
+ for (i++; i < src.length; i++) {
37
+ const ch = src[i];
38
+ if (ch === '\\') i++;
39
+ else if (ch === '\n') return -1;
40
+ else if (inClass) inClass = ch !== ']';
41
+ else if (ch === '[') inClass = true;
42
+ else if (ch === '/') {
43
+ i++;
44
+ while (i < src.length && isIdentifierChar(src[i])) i++;
45
+ return i;
46
+ }
47
+ }
48
+ return -1;
49
+ }
50
+
51
+ /**
52
+ * Scans a template literal from `start`, the index just past its opening backtick.
53
+ * Returns `{ end, expressions, nested }`: `end` is the index of the closing backtick,
54
+ * `expressions` the top-level `${…}` spans (`start` at "$", `end` past "}"), `nested` the
55
+ * html``-tagged literals inside those expressions (`open` past their opening backtick,
56
+ * `end` at their closing one). null when the literal isn't closed or can't be followed.
57
+ */
58
+ export function scanTemplate(src, start) {
59
+ const expressions = [];
60
+ const nested = [];
61
+
62
+ for (let i = start; i < src.length; i++) {
63
+ const ch = src[i];
64
+ if (ch === '\\') {
65
+ i++;
66
+ } else if (ch === '`') {
67
+ return { end: i, expressions, nested };
68
+ } else if (ch === '$' && src[i + 1] === '{') {
69
+ const end = scanExpression(src, i + 2, nested);
70
+ if (end === -1) return null;
71
+ expressions.push({ start: i, end });
72
+ i = end - 1;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // Index just past the "}" closing the expression that starts at `i`, or -1.
79
+ function scanExpression(src, i, nested) {
80
+ let depth = 0;
81
+ let previous = '('; // the expression start behaves like an opening parenthesis
82
+
83
+ while (i < src.length) {
84
+ const ch = src[i];
85
+
86
+ if (/\s/.test(ch)) {
87
+ i++;
88
+ } else if (ch === '/' && src[i + 1] === '/') {
89
+ const newline = src.indexOf('\n', i);
90
+ if (newline === -1) return -1;
91
+ i = newline + 1;
92
+ } else if (ch === '/' && src[i + 1] === '*') {
93
+ const close = src.indexOf('*/', i + 2);
94
+ if (close === -1) return -1;
95
+ i = close + 2;
96
+ } else if (ch === '"' || ch === "'") {
97
+ i = skipString(src, i);
98
+ if (i === -1) return -1;
99
+ previous = 'value';
100
+ } else if (ch === '`') {
101
+ const inner = scanTemplate(src, i + 1);
102
+ if (!inner) return -1;
103
+ if (previous === 'html') nested.push({ open: i + 1, end: inner.end });
104
+ i = inner.end + 1;
105
+ previous = 'value';
106
+ } else if (ch === '/' && (REGEX_PRECEDING_PUNCTUATION.has(previous) || REGEX_PRECEDING_WORDS.has(previous))) {
107
+ i = skipRegex(src, i);
108
+ if (i === -1) return -1;
109
+ previous = 'value';
110
+ } else if (isIdentifierChar(ch)) {
111
+ let end = i;
112
+ while (end < src.length && isIdentifierChar(src[end])) end++;
113
+ previous = src.slice(i, end);
114
+ i = end;
115
+ } else {
116
+ if (ch === '{') depth++;
117
+ if (ch === '}') {
118
+ if (depth === 0) return i + 1;
119
+ depth--;
120
+ }
121
+ previous = ch;
122
+ i++;
123
+ }
124
+ }
125
+ return -1;
126
+ }
127
+
128
+ /**
129
+ * Minifies a template body (the text between the backticks, or an external .html file).
130
+ * Returns the minified body, or null when it is left as written.
131
+ */
132
+ export async function minifyTemplateBody(body) {
133
+ const scan = scanTemplate(`${body}\``, 0);
134
+ if (!scan || scan.end !== body.length || body.includes(TOKEN_PREFIX)) return null;
135
+
136
+ // Expressions (with their own nested html`` minified first), in source order.
137
+ const expressions = [];
138
+ for (const { start, end } of scan.expressions) {
139
+ const nestedInside = scan.nested.filter((n) => n.open > start && n.end < end);
140
+ expressions.push(await minifyNested(body, start, end, nestedInside));
141
+ }
142
+
143
+ let markup = '';
144
+ let last = 0;
145
+ scan.expressions.forEach(({ start, end }, n) => {
146
+ markup += body.slice(last, start) + token(n);
147
+ last = end;
148
+ });
149
+ markup += body.slice(last);
150
+
151
+ let minified;
152
+ try {
153
+ minified = await minifyHTML(markup);
154
+ } catch {
155
+ return null;
156
+ }
157
+
158
+ for (let n = 0; n < expressions.length; n++) {
159
+ if (minified.split(token(n)).length !== 2) return null;
160
+ }
161
+ return minified.replace(/__nutin_expr_(\d+)__/g, (_match, n) => expressions[Number(n)]);
162
+ }
163
+
164
+ // The source of body[start, end) with each nested html`` body minified (kept when it can't be).
165
+ async function minifyNested(body, start, end, nested) {
166
+ let out = '';
167
+ let last = start;
168
+ for (const { open, end: close } of nested) {
169
+ const inner = body.slice(open, close);
170
+ out += body.slice(last, open) + ((await minifyTemplateBody(inner)) ?? inner);
171
+ last = close;
172
+ }
173
+ return out + body.slice(last, end);
174
+ }
175
+
176
+ // Up to the opening backtick; scanTemplate() finds the closing one, past any nested html``.
177
+ const TEMPLATE_START = /const\s+(?:template|templateFn)\s*=?\s*(?:\(.*?\)\s*=>\s*)?(html\s*)?`/;
178
+
179
+ // The first `const template`/`templateFn` literal: whether it is html``-tagged, where its
180
+ // body starts and ends, and the body (null when its closing backtick can't be found).
181
+ export function findInlineTemplate(content) {
182
+ const match = TEMPLATE_START.exec(content);
183
+ if (!match) return null;
184
+
185
+ const open = match.index + match[0].length;
186
+ const scan = scanTemplate(content, open);
187
+ return {
188
+ tagged: Boolean(match[1]),
189
+ open,
190
+ end: scan?.end ?? null,
191
+ body: scan ? content.slice(open, scan.end) : null,
192
+ };
193
+ }
@@ -0,0 +1,69 @@
1
+ import { scanTemplate, minifyTemplateBody } from './template-literal.js';
2
+
3
+ // Scans `source` from just past its first backtick and returns the literal's body.
4
+ function bodyOf(source) {
5
+ const open = source.indexOf('`') + 1;
6
+ const scan = scanTemplate(source, open);
7
+ return scan && source.slice(open, scan.end);
8
+ }
9
+
10
+ describe('template-literal', () => {
11
+ it('scanTemplate finds the closing backtick past a nested html`` in a .map()', () => {
12
+ const source = 'const t = html`<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>`; after();';
13
+ expect(bodyOf(source)).toBe('<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>');
14
+ });
15
+
16
+ it('scanTemplate is not fooled by backticks or braces in strings, comments and regex literals', () => {
17
+ const body = "<p>${s.replace(/`/g, '')}${'}`'}${x /* ` } */}${\"{\"}${y // `}\n}</p>";
18
+ expect(bodyOf('html`' + body + '`; x;')).toBe(body);
19
+ });
20
+
21
+ it('scanTemplate reads a "/" after a value as a division, not a regex', () => {
22
+ const body = '<p>${total / count} of ${(a) / 2} / ${b[0] / 3}</p>';
23
+ expect(bodyOf('html`' + body + '`; x;')).toBe(body);
24
+ });
25
+
26
+ it('scanTemplate skips escaped backticks and escaped ${', () => {
27
+ const body = '<p>\\` and \\${not} and ${real}</p>';
28
+ const scan = scanTemplate('html`' + body + '`', 5);
29
+ expect(scan.end).toBe(5 + body.length);
30
+ expect(scan.expressions.length).toBe(1);
31
+ });
32
+
33
+ it('scanTemplate returns null for an unclosed literal or expression', () => {
34
+ expect(scanTemplate('html`<p>${a</p>', 5)).toBe(null);
35
+ expect(scanTemplate('html`<p>', 5)).toBe(null);
36
+ });
37
+
38
+ it('minifyTemplateBody minifies the markup and the nested html`` bodies', async () => {
39
+ const body = '\n<ul class="list">\n ${items.map((i) => html`\n <li>\n ${i}\n </li>\n `)}\n</ul>\n<p>\n after nested\n</p>\n';
40
+ expect(await minifyTemplateBody(body)).toBe('<ul class="list">${items.map((i) => html`<li>${i}</li>`)}</ul><p>after nested</p>');
41
+ });
42
+
43
+ it('minifyTemplateBody puts every expression back exactly as written', async () => {
44
+ const body = '<p title="${t}"> ${\'a b\'} ${a < b ? x : y} </p>';
45
+ expect(await minifyTemplateBody(body)).toBe('<p title="${t}">${\'a b\'} ${a < b ? x : y}</p>');
46
+ });
47
+
48
+ it('minifyTemplateBody keeps expressions in attribute-name position', async () => {
49
+ expect(await minifyTemplateBody('<input ${attrs} ${more}> <b> x </b>')).toBe('<input ${attrs} ${more}> <b>x</b>');
50
+ });
51
+
52
+ it('minifyTemplateBody leaves a template with a dynamic tag name as written', async () => {
53
+ expect(await minifyTemplateBody('<${tag}> x </${tag}>')).toBe(null);
54
+ });
55
+
56
+ it('minifyTemplateBody leaves a template as written when the minifier would drop an expression', async () => {
57
+ expect(await minifyTemplateBody('<div style="${css}">x</div>')).toBe(null);
58
+ });
59
+
60
+ it('minifyTemplateBody leaves a template as written when the scanner cannot follow it', async () => {
61
+ expect(await minifyTemplateBody('<p>${a</p>')).toBe(null);
62
+ expect(await minifyTemplateBody('<p>__nutin_expr_0__ ${a}</p>')).toBe(null);
63
+ });
64
+
65
+ it('minifyTemplateBody keeps a nested html`` it cannot minify as written', async () => {
66
+ const body = '<ul> ${items.map((i) => html`<li style="${i}">x</li>`)} </ul>';
67
+ expect(await minifyTemplateBody(body)).toBe('<ul>${items.map((i) => html`<li style="${i}">x</li>`)}</ul>');
68
+ });
69
+ });
@@ -33,13 +33,21 @@ function getKeyForFile(filePath) {
33
33
  }
34
34
  }
35
35
 
36
- function combineJsonFiles(files) {
36
+ function combineJsonFiles(locale, files) {
37
37
  const combined = {};
38
+ const sources = {};
38
39
 
39
40
  files.forEach(file => {
40
41
  const key = getKeyForFile(file);
41
- const content = JSON.parse(fs.readFileSync(file, 'utf8'));
42
- combined[key] = content;
42
+ // Keys are folder names, so e.g. admin/user/ and public/user/ would share "user".
43
+ if (sources[key]) {
44
+ throw new Error(
45
+ `Translation key "${key}" (${locale}) is defined by two folders:\n ${path.relative(process.cwd(), sources[key])}\n ${path.relative(process.cwd(), file)}\n` +
46
+ `One would silently overwrite the other — rename one of the folders.`
47
+ );
48
+ }
49
+ sources[key] = file;
50
+ combined[key] = JSON.parse(fs.readFileSync(file, 'utf8'));
43
51
  });
44
52
 
45
53
  return combined;
@@ -107,9 +115,9 @@ async function mergeJson() {
107
115
 
108
116
  const localeFilesMap = findLocaleFiles();
109
117
  for (const [locale, files] of Object.entries(localeFilesMap)) {
110
- const combined = combineJsonFiles(files);
118
+ const combined = combineJsonFiles(locale, files);
111
119
  fs.writeFileSync(
112
- `${destDir}/${locale}.json`,
120
+ path.join(destDir, `${locale}.json`),
113
121
  JSON.stringify(combined, null, 2)
114
122
  );
115
123
  }
@@ -117,5 +125,5 @@ async function mergeJson() {
117
125
  }
118
126
 
119
127
  mergeJson().catch((err) => {
120
- errorExit(err)
128
+ errorExit(err, 'build-i18n');
121
129
  });