@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
@@ -0,0 +1,54 @@
1
+ import path from 'path';
2
+ import * as fsExtra from 'fs-extra';
3
+ import { JsonManager } from '../create-app/json-manager.mjs';
4
+
5
+ const fs = fsExtra.default;
6
+
7
+ // package.json and tsconfig.json aren't rendered from templates (JsonManager writes them
8
+ // once, at creation), so the template diff never sees them. They're compared with what
9
+ // this CLI would generate instead, and only reported.
10
+ // Only the entries nutin generates are compared; anything the user added is ignored.
11
+ const PACKAGE_JSON_SECTIONS = ['scripts', 'devDependencies'];
12
+
13
+ function same(a, b) {
14
+ return JSON.stringify(a) === JSON.stringify(b);
15
+ }
16
+
17
+ function compareEntries(file, section, yours = {}, generated = {}) {
18
+ return Object.entries(generated)
19
+ .filter(([key, value]) => !same(yours[key], value))
20
+ .map(([key, value]) => ({ file, key: section ? `${section}.${key}` : key, yours: yours[key], nutin: value }));
21
+ }
22
+
23
+ async function readJsonIfExists(filePath) {
24
+ if (!(await fs.pathExists(filePath))) return null;
25
+ try {
26
+ return await fs.readJSON(filePath);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export async function findJsonDrift(projectPath, context) {
33
+ const jsonManager = new JsonManager();
34
+ const drift = [];
35
+
36
+ const packageJson = await readJsonIfExists(path.join(projectPath, 'package.json'));
37
+ if (packageJson) {
38
+ const generated = jsonManager.buildPackageJson(context);
39
+ for (const section of PACKAGE_JSON_SECTIONS) {
40
+ drift.push(...compareEntries('package.json', section, packageJson[section], generated[section]));
41
+ }
42
+ }
43
+
44
+ if (context.lang !== 'js') {
45
+ const tsconfig = await readJsonIfExists(path.join(projectPath, 'tsconfig.json'));
46
+ if (tsconfig) {
47
+ const generated = jsonManager.buildTsconfigJson();
48
+ drift.push(...compareEntries('tsconfig.json', 'compilerOptions', tsconfig.compilerOptions, generated.compilerOptions));
49
+ drift.push(...compareEntries('tsconfig.json', null, { include: tsconfig.include, exclude: tsconfig.exclude }, { include: generated.include, exclude: generated.exclude }));
50
+ }
51
+ }
52
+
53
+ return drift;
54
+ }
@@ -8,12 +8,16 @@ import { fetchOldTemplates } from './fetch-old-templates.mjs';
8
8
  import { parseVersion, compareVersions } from './version-compare.mjs';
9
9
  import { bootstrapProjectMeta } from '../common/meta-bootstrap-prompt.mjs';
10
10
  import { printUpdateSummary } from './update-summary-printer.mjs';
11
- import { writeConflictReport } from './conflict-report-writer.mjs';
11
+ import { writeUpdateReport, REPORT_FILE_NAME } from './conflict-report-writer.mjs';
12
+ import { mergeFile, hasConflictMarkers, CONFLICT_MARKER } from './three-way-merge.mjs';
13
+ import { isGitAvailable, getWorkingTreeStatus } from '../common/git.mjs';
12
14
  import { UpdateContextBuilder } from './update-context-builder.mjs';
13
15
  import { TemplateDiffer } from './template-differ.mjs';
16
+ import { findJsonDrift } from './json-drift.mjs';
14
17
 
15
18
  const fs = fsExtra.default;
16
- const REPORT_FILE_NAME = 'NUTIN-UPDATE-REPORT.md';
19
+
20
+ const UNDO_COMMAND = 'git checkout -- . && git clean -fd';
17
21
 
18
22
  export class ProjectUpdater {
19
23
  constructor() {
@@ -22,8 +26,12 @@ export class ProjectUpdater {
22
26
  }
23
27
 
24
28
  async updateProject(projectPath, options = {}) {
25
- const { yes = false, from } = options;
29
+ const { yes = false, from, allowDirty = false } = options;
30
+ // Read before anything here writes .nutin-meta.json (bootstrap, resolved conflicts), so only your
31
+ // own changes count. Enforced right before the first file write.
32
+ const gitStatus = allowDirty ? null : await getWorkingTreeStatus(projectPath);
26
33
  const meta = await this.resolveMeta(projectPath);
34
+ await this.assertNoConflictMarkers(projectPath, meta);
27
35
 
28
36
  if (this.isUpToDate(meta)) {
29
37
  print.boldSuccess(`✅ Already up to date (nutin v${packageVersion}).`);
@@ -37,7 +45,7 @@ export class ProjectUpdater {
37
45
 
38
46
  try {
39
47
  const changeSet = await this.computeChangeSet(projectPath, meta, oldTemplatesRoot);
40
- await this.applyChangeSet(projectPath, meta, changeSet, { yes });
48
+ await this.applyChangeSet(projectPath, meta, changeSet, { yes, gitStatus });
41
49
  } finally {
42
50
  await cleanup();
43
51
  }
@@ -62,6 +70,28 @@ export class ProjectUpdater {
62
70
  }
63
71
  }
64
72
 
73
+ // Files the last update left with conflict markers (meta.unmergedFiles): nothing is updated until
74
+ // they're resolved, so a merge never runs on top of markers and a re-run still reports them.
75
+ async assertNoConflictMarkers(projectPath, meta) {
76
+ const unmerged = meta.unmergedFiles ?? [];
77
+ if (unmerged.length === 0) return;
78
+
79
+ const stillMarked = [];
80
+ for (const relPath of unmerged) {
81
+ if (await hasConflictMarkers(path.join(projectPath, relPath))) stillMarked.push(relPath);
82
+ }
83
+ if (stillMarked.length > 0) {
84
+ throw new Error(
85
+ `${stillMarked.length} file(s) still have conflict markers from the last update — the project won't build until ` +
86
+ `they're resolved. Search for "${CONFLICT_MARKER}", merge, then run nutin-update again:\n` +
87
+ stillMarked.map((relPath) => ` - ${relPath}`).join('\n'),
88
+ );
89
+ }
90
+
91
+ await updateProjectMeta(projectPath, { unmergedFiles: [] });
92
+ print.info('Conflict markers from the last update are resolved.');
93
+ }
94
+
65
95
  isUpToDate(meta) {
66
96
  return compareVersions(meta.version, packageVersion) === 0;
67
97
  }
@@ -84,12 +114,13 @@ export class ProjectUpdater {
84
114
 
85
115
  async computeChangeSet(projectPath, meta, oldTemplatesRoot) {
86
116
  const { oldContext, newContext } = await this.contextBuilder.buildContexts(projectPath, meta);
87
- return this.differ.diff(projectPath, oldTemplatesRoot, oldContext, newContext);
117
+ const changeSet = await this.differ.diff(projectPath, oldTemplatesRoot, oldContext, newContext);
118
+ return { ...changeSet, jsonDrift: await findJsonDrift(projectPath, newContext) };
88
119
  }
89
120
 
90
- async applyChangeSet(projectPath, meta, changeSet, { yes }) {
91
- const { toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated } = changeSet;
92
- printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated });
121
+ async applyChangeSet(projectPath, meta, changeSet, { yes, gitStatus = null }) {
122
+ const { toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift } = changeSet;
123
+ printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift });
93
124
 
94
125
  if (toUpdate.length === 0 && toAdd.length === 0 && conflicts.length === 0) {
95
126
  print.boldSuccess('\n✅ Nothing to update.');
@@ -97,18 +128,32 @@ export class ProjectUpdater {
97
128
  return;
98
129
  }
99
130
 
100
- if (!yes && (toUpdate.length > 0 || toAdd.length > 0)) {
131
+ // Binary files can't be merged: they're kept as they are and listed in the report.
132
+ const toMerge = conflicts.filter(({ newEntry, actualContent }) => !newEntry.isBinary && !Buffer.isBuffer(actualContent));
133
+ const binaryConflicts = conflicts.filter((conflict) => !toMerge.includes(conflict));
134
+
135
+ if (gitStatus) this.assertPristine(gitStatus);
136
+
137
+ if (toMerge.length > 0 && !(await isGitAvailable())) {
138
+ throw new Error(
139
+ `nutin-update needs git to merge the ${toMerge.length} file(s) you edited, but git was not found. ` +
140
+ 'Install git and run nutin-update again. No files were changed.',
141
+ );
142
+ }
143
+
144
+ if (!yes && (toUpdate.length > 0 || toAdd.length > 0 || toMerge.length > 0)) {
101
145
  if (!process.stdin.isTTY) {
102
146
  print.warn('\n⚠️ Non-interactive shell detected — skipping the confirmation prompt.');
103
147
  print.boldInfo('Re-run with --yes to apply these updates automatically. No files were changed.');
104
148
  return;
105
149
  }
106
150
 
151
+ const merges = toMerge.length > 0 ? ` and merge ${toMerge.length} file(s) you edited` : '';
107
152
  const { proceed } = await inquirer.prompt([
108
153
  {
109
154
  type: 'confirm',
110
155
  name: 'proceed',
111
- message: `\nApply ${toUpdate.length + toAdd.length} safe file update(s)?`,
156
+ message: `\nApply ${toUpdate.length + toAdd.length} file update(s)${merges}?`,
112
157
  default: true,
113
158
  },
114
159
  ]);
@@ -124,15 +169,79 @@ export class ProjectUpdater {
124
169
  await fs.writeFile(outputPath, newEntry.content);
125
170
  }
126
171
 
127
- if (conflicts.length > 0) {
128
- await writeConflictReport(projectPath, conflicts, meta.version, packageVersion);
129
- print.boldError(`\n⚠️ ${conflicts.length} file(s) you modified need manual attention — see ${REPORT_FILE_NAME}`);
172
+ const { merged, conflicted } = await this.mergeEditedFiles(projectPath, meta, toMerge);
173
+
174
+ if (merged.length > 0 || conflicted.length > 0 || binaryConflicts.length > 0) {
175
+ await writeUpdateReport(projectPath, { merged, conflicted, binaryConflicts }, meta.version, packageVersion);
130
176
  }
131
177
 
132
- await updateProjectMeta(projectPath, { version: packageVersion });
178
+ await updateProjectMeta(projectPath, {
179
+ version: packageVersion,
180
+ unmergedFiles: conflicted.map(({ relPath }) => relPath),
181
+ });
182
+
183
+ if (merged.length > 0) {
184
+ print.info(`\n${merged.length} file(s) you edited were merged with the new version — review them (listed in ${REPORT_FILE_NAME}).`);
185
+ }
186
+ if (binaryConflicts.length > 0) {
187
+ print.boldError(`\n⚠️ ${binaryConflicts.length} binary file(s) you modified were left as they are — see ${REPORT_FILE_NAME}`);
188
+ }
189
+ if (conflicted.length > 0) {
190
+ print.boldError(
191
+ `\n⚠️ ${conflicted.length} file(s) have conflict markers — the project won't build until they're resolved ` +
192
+ `(search for "${CONFLICT_MARKER}"):`,
193
+ );
194
+ conflicted.forEach(({ relPath }) => print.boldError(` - ${relPath}`));
195
+ }
196
+ if (gitStatus) {
197
+ print.info(`\nTo undo this update, run from the project folder: ${UNDO_COMMAND}`);
198
+ print.info(`Important: this will also remove any untracked folder.`);
199
+ }
133
200
 
134
201
  print.boldSuccess(`\n✅ Updated to nutin v${packageVersion}.`);
135
202
  }
203
+
204
+ // The update must be undoable with git: no uncommitted changes and no untracked files in the
205
+ // project folder, so checkout + clean only reverts what the update wrote. --allow-dirty skips this.
206
+ assertPristine({ repo, gitFound, changes }) {
207
+ if (!gitFound) {
208
+ throw new Error('git was not found: nutin-update uses it to make the update undoable. Install git, or pass --allow-dirty. No files were changed.');
209
+ }
210
+ if (!repo) {
211
+ throw new Error(
212
+ 'This project isn\'t in a git repository, so the update couldn\'t be undone. Run "git init" and commit, ' +
213
+ 'or pass --allow-dirty. No files were changed.',
214
+ );
215
+ }
216
+ if (changes.length > 0) {
217
+ throw new Error(
218
+ 'Commit or stash your changes first (untracked files included), so the update can be undone with git, ' +
219
+ `or pass --allow-dirty. No files were changed:\n${changes.map((line) => ` ${line}`).join('\n')}`,
220
+ );
221
+ }
222
+ }
223
+
224
+ // Three-way merge of each file you edited: the old nutin version is the base, so your edits and
225
+ // nutin's changes are both kept, and only overlapping edits get conflict markers.
226
+ async mergeEditedFiles(projectPath, meta, toMerge) {
227
+ const merged = [];
228
+ const conflicted = [];
229
+ const labels = { base: `nutin v${meta.version}`, theirs: `nutin v${packageVersion}` };
230
+
231
+ for (const { relPath, actualContent, baseContent, newEntry } of toMerge) {
232
+ const { content, conflictCount } = await mergeFile({
233
+ base: baseContent,
234
+ yours: actualContent,
235
+ theirs: newEntry.content,
236
+ labels,
237
+ });
238
+ await fs.writeFile(path.join(projectPath, relPath), content);
239
+ if (conflictCount > 0) conflicted.push({ relPath, conflictCount });
240
+ else merged.push({ relPath });
241
+ }
242
+
243
+ return { merged, conflicted };
244
+ }
136
245
  }
137
246
 
138
247
  export const projectUpdater = new ProjectUpdater();
@@ -11,8 +11,8 @@ function contentEquals(a, b) {
11
11
  return a === b;
12
12
  }
13
13
 
14
- // Paths the user is expected to hand-edit right after `create-app` — their generated
15
- // content is just placeholder/starter scaffolding, so nutin-update must never rewrite
14
+ // Paths the user is expected to hand-edit right after `create-app`.
15
+ // Their generated content is just placeholder/starter scaffolding, so nutin-update must never rewrite
16
16
  // or conflict-flag them.
17
17
  const FULLY_EXCLUDED_PATTERNS = [
18
18
  /^config\/[^/]+\.json$/,
@@ -21,7 +21,7 @@ const FULLY_EXCLUDED_PATTERNS = [
21
21
  ];
22
22
 
23
23
  // Like FULLY_EXCLUDED_PATTERNS, but only for files that already existed before this
24
- // update — a brand new file introduced by a newer template version should still be added.
24
+ // update. A brand new file introduced by a newer template version should still be added.
25
25
  const ADD_ONLY_PATTERNS = [
26
26
  /^src\/styles(\/|$)/,
27
27
  ];
@@ -114,8 +114,8 @@ export class TemplateDiffer {
114
114
  if (contentEquals(actualContent, oldEntry.content)) {
115
115
  toUpdate.push({ relPath, newEntry });
116
116
  } else if (!contentEquals(actualContent, newEntry.content)) {
117
- // Differs from both baselines — a real conflict.
118
- conflicts.push({ relPath, actualContent, newEntry });
117
+ // Differs from both baselines: a real conflict, merged from the old baseline at apply time.
118
+ conflicts.push({ relPath, actualContent, baseContent: oldEntry.content, newEntry });
119
119
  }
120
120
  }
121
121
 
@@ -0,0 +1,42 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import * as fsExtra from 'fs-extra';
4
+ import { execFileAsync } from '../common/git.mjs';
5
+
6
+ const fs = fsExtra.default;
7
+
8
+ export const CONFLICT_MARKER = '<<<<<<< yours';
9
+
10
+ // Merges nutin's changes (base -> theirs) into your file with `git merge-file`, which needs the git
11
+ // binary but no repository. Overlapping edits get diff3 markers: yours, the old nutin version, the new one.
12
+ export async function mergeFile({ base, yours, theirs, labels }) {
13
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nutin-merge-'));
14
+ try {
15
+ const [yoursPath, basePath, theirsPath] = ['yours', 'base', 'theirs'].map((name) => path.join(tmpDir, name));
16
+ await Promise.all([
17
+ fs.writeFile(yoursPath, yours),
18
+ fs.writeFile(basePath, base),
19
+ fs.writeFile(theirsPath, theirs),
20
+ ]);
21
+
22
+ const args = ['merge-file', '-p', '--diff3', '-L', 'yours', '-L', labels.base, '-L', labels.theirs, yoursPath, basePath, theirsPath];
23
+ try {
24
+ const { stdout } = await execFileAsync('git', args, { maxBuffer: 1024 * 1024 * 50 });
25
+ return { content: stdout, conflictCount: 0 };
26
+ } catch (error) {
27
+ // A positive exit code below 255 is the number of conflicts; anything else is a real failure.
28
+ if (Number.isInteger(error.code) && error.code > 0 && error.code < 255) {
29
+ return { content: error.stdout, conflictCount: error.code };
30
+ }
31
+ throw error;
32
+ }
33
+ } finally {
34
+ await fs.remove(tmpDir);
35
+ }
36
+ }
37
+
38
+ export async function hasConflictMarkers(filePath) {
39
+ if (!(await fs.pathExists(filePath))) return false;
40
+ const content = await fs.readFile(filePath, 'utf8');
41
+ return content.split('\n').some((line) => line.startsWith(CONFLICT_MARKER));
42
+ }
@@ -13,6 +13,7 @@ export class UpdateContextBuilder {
13
13
  const baseContext = {
14
14
  projectName: packageJson.name,
15
15
  packageManager,
16
+ lang: meta.lang ?? 'ts',
16
17
  ...meta.features,
17
18
  ciCommand: getCiCommand(packageManager),
18
19
  };
@@ -1,11 +1,13 @@
1
1
  import { print } from '../utils/print.mjs';
2
2
 
3
- export function printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated }) {
3
+ const describeValue = (value) => (value === undefined ? '(missing)' : JSON.stringify(value));
4
+
5
+ export function printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift = [] }) {
4
6
  print.section('\nSummary:');
5
7
  print.info(` ${toUpdate.length} file(s) will be updated (untouched since generation)`);
6
8
  print.info(` ${toAdd.length} new file(s) will be added`);
7
9
  if (conflicts.length > 0) {
8
- print.boldError(` ${conflicts.length} file(s) you modified need manual attention`);
10
+ print.boldError(` ${conflicts.length} file(s) you edited will be merged with the new version`);
9
11
  }
10
12
  if (noLongerGenerated.length > 0) {
11
13
  print.boldError(` ${noLongerGenerated.length} file(s) nutin no longer generates — left in place, review manually:`);
@@ -19,4 +21,10 @@ export function printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, remove
19
21
  print.section(` ${removedByUser.length} file(s) nutin used to generate but you removed — not recreated:`);
20
22
  removedByUser.forEach(({ relPath }) => print.section(` - ${relPath}`));
21
23
  }
24
+ if (jsonDrift.length > 0) {
25
+ print.section(` ${jsonDrift.length} package.json/tsconfig.json value(s) differ from what this nutin version generates — never changed automatically, apply by hand if wanted:`);
26
+ jsonDrift.forEach(({ file, key, yours, nutin }) =>
27
+ print.section(` - ${file} ${key}: yours ${describeValue(yours)}, nutin ${describeValue(nutin)}`)
28
+ );
29
+ }
22
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nutin/cli",
3
- "version": "2.1.1",
3
+ "version": "3.0.0",
4
4
  "description": "The structure of a framework. The freedom of vanilla.",
5
5
  "preferGlobal": true,
6
6
  "main": "lib/index.mjs",
@@ -30,15 +30,16 @@
30
30
  "author": "Loïs Kouninef <loiskouninef@gmail.com>",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "commander": "^9.0.0",
33
+ "commander": "^15.0.0",
34
34
  "diff": "^9.0.0",
35
- "fs-extra": "^11.0.0",
36
- "handlebars": "^4.7.8",
37
- "inquirer": "^9.0.0",
38
- "tar": "^7.5.22"
35
+ "fs-extra": "^11.4.1",
36
+ "handlebars": "^4.7.10",
37
+ "inquirer": "^14.2.3",
38
+ "tar": "^7.5.22",
39
+ "typescript": "5.9.3"
39
40
  },
40
41
  "engines": {
41
- "node": ">=22"
42
+ "node": ">=24"
42
43
  },
43
44
  "repository": {
44
45
  "type": "git",
@@ -47,5 +48,5 @@
47
48
  "bugs": {
48
49
  "url": "https://github.com/LoisKOUNINEF/nutin/issues"
49
50
  },
50
- "homepage": "www.nutin.org"
51
+ "homepage": "www.nutin.dev"
51
52
  }
@@ -20,14 +20,18 @@ nutin.config.js build/feature config — see below
20
20
  - Favor small, composable components.
21
21
  - One `View` = one route.
22
22
  - Import framework code from the `core/index.js` barrel — do not deep-import into `core/`. App elements have no barrels: import them by file path.
23
+ {{#if (eq lang "ts")}}
24
+ - Framework types (`Nutin.ComponentConfig`, `Nutin.ComponentOptions`, `Nutin.CatalogConfig`, `Nutin.Template`, `Nutin.Routes`, `Nutin.RouteGuard`, `Nutin.RequestConfig`, …) live in the global `Nutin` namespace declared in `core/internals.d.ts` — use them without importing. App events extend it in `src/app/globals.d.ts` (`declare namespace Nutin { interface AppEventMap { … } }`).
25
+ {{/if}}
23
26
  - `src/app/` layout is free; `components/`, `views/`, `services/` is the default suggestion.
24
27
 
25
28
  ## Commands
26
29
 
27
- - Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode).
28
- - Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production).
30
+ - Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:prod` (prod build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode, reloads the page after each rebuild). Port defaults to 9090; override with `-- --port <n>` or `PORT=<n>`.
31
+ - Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production). Opt-in dependencies (Tailwind, `markdown`) are only installed automatically in an interactive terminal; elsewhere pass `-- -y` (or set `NUTIN_ASSUME_YES`).
29
32
  - Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service`; `ELEMENT_NAME` may be a nested path, generated in `src/app/<path>/`; fails if that folder already exists and isn't empty).
30
- - Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`.
33
+ - Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`, `{{packageManager}} run testin-nutin:coverage`, `{{packageManager}} run testin-nutin:verbose`.
34
+ - Update Nutin: `nutin-update` refuses to run unless the project is committed (no changes, no untracked files; `--allow-dirty` skips the check). Files edited here that also changed upstream are merged three-way; overlapping edits get `diff3` conflict markers, and the project won't build, nor will `nutin-update` run again, until they're resolved (listed in `.nutin-meta.json`'s `unmergedFiles` and `NUTIN-UPDATE-REPORT.md`).
31
35
 
32
36
  ## Base classes
33
37
 
@@ -38,15 +42,16 @@ BaseComponent<T> — render/destroy engine, data-* parsing, children
38
42
  └── View — one per route; mounted/unmounted by the Router
39
43
  ```
40
44
 
41
- - `render()`: `onBeforeRender()` → sanitize + set `innerHTML` → mount children (`data-component`/`data-catalog`) → parse `data-i18n`/`data-pipe` → bind `data-event` listeners → `onAfterRender()`. Runs in full on every `render()` call — no virtual DOM/diffing.
45
+ - `render()`: `onBeforeRender()` → sanitize the template and insert the resulting nodes (`SecurityHelper.sanitizeToFragment()`, never assigned to `innerHTML`) → mount children (`data-component`/`data-catalog`) → parse `data-i18n`/`data-pipe` → bind `data-event` listeners → `onAfterRender()`. Runs in full on every `render()` call — no virtual DOM/diffing; only children with `keep`/`keepBy` are kept (see "Children components").
42
46
  - `destroy()`: unbinds events, unsubscribes bus listeners, destroys children, removes the element.
43
- - `Service`: direct `new` throws — always use `MyService.getInstance()` (or the exported const). Auto-binds all methods to the instance. `registerCleanup(fn)` for teardown; `Service.destroyAll()` runs on `beforeunload`.
44
- - Overridable hooks (all no-op by default): `onBeforeRender`, `onAfterRender`, `onBeforeDestroy`, `onAfterDestroy`, `generateTemplate()`, `registerChildren()`. `View` adds router-only `onEnter()`/`onExit()`.
47
+ - `Service`: direct `new` throws — always use `MyService.getInstance()` (or the exported const). Auto-binds all methods to the instance. `registerCleanup(fn)` callbacks run on `dispose()`, `MyService.destroy()` and `Service.destroyAll()` (tests, SSR) — nothing is torn down on page unload.
48
+ - Overridable hooks (all no-op by default): `onBeforeRender`, `onAfterRender`, `onBeforeDestroy`, `onAfterDestroy`, `onKept`, `generateTemplate()`, `registerChildren()`. `View` adds router-only `onEnter()`/`onExit()`.
45
49
 
46
50
  ## Children components
47
51
 
48
- - Override `registerChildren(): ComponentConfig[]` to declare children — matched against `[data-component="selector"]` elements in the rendered template. ComponentConfig: `{ selector: 'my-selector', factory: (el) => new MyComponent(el, {...} )}`.
49
- - For repeated lists, use the inherited `createCatalogComponents({ items, elementName, selector, component })` inside `registerChildren()` instead of hand-writing one entry per item — targets `[data-catalog="selector"]`.
52
+ - Override `registerChildren(): Nutin.ComponentConfig[]` to declare children — matched against `[data-component="selector"]` elements in the rendered template. ComponentConfig: `{ selector: 'my-selector', factory: (el) => new MyComponent(el, {...} ), keep?: string | number }`.
53
+ - Children are destroyed and recreated on every parent render unless they have a `keep` value: with the same value on the next render, the child is kept as-is (factory not called, not re-rendered, DOM state preserved) and only its `onKept()` hook runs. Put in `keep` whatever should force a recreate.
54
+ - For repeated lists, use the inherited `createCatalogComponents({ items, elementName, selector, component })` inside `registerChildren()` instead of hand-writing one entry per item — targets `[data-catalog="selector"]`. Add `keepBy: (item, index) => id` to keep items whose id is unchanged and whose item is shallow-equal; without it the catalog is rebuilt every render.
50
55
 
51
56
  ## `data-*` attributes
52
57
 
@@ -63,7 +68,7 @@ BaseComponent<T> — render/destroy engine, data-* parsing, children
63
68
 
64
69
  ## DOM events binding
65
70
 
66
- Handlers are (re-)bound on every `render()`, not at build time. Always use private methods prefixed with underscore:
71
+ Handlers are (re-)bound on every `render()`, not at build time. Always use private methods prefixed with underscore{{#if (eq lang "ts")}} (`protected` for a handler a subclass's template uses){{/if}}:
67
72
 
68
73
  ```html
69
74
  <div data-event="event:_myFunction:@value,@id">
@@ -72,17 +77,21 @@ Handlers are (re-)bound on every `render()`, not at build time. Always use priva
72
77
  private _myFunction(value: string, id: string) { doStuff() }
73
78
  ```
74
79
 
75
- Tokens: `@value`, `@id`, `@class`, `@textContent`, `@event`, `@target`, `@attr:name`, `@dataset:key`, plus string/number literals.
80
+ {{#if (eq lang "ts")}}The build type-checks each handler name written in a template against the class: a missing one fails `tsc` at the template line, and handlers count as used for `noUnusedLocals` (on in this project). Names built with `${}` aren't checked. {{/if}}At runtime, a missing handler binds nothing and logs a `console.warn` in development.
81
+
82
+ Tokens: `@value`, `@id`, `@class`, `@textContent`, `@event`, `@target`, `@attr:name`, `@dataset:key`, plus string/number literals (quoted literals may contain commas). Token values are raw (not HTML-escaped): escaping happens when they are rendered through an `html` template.
83
+
84
+ Only defaults that leave the page are cancelled: link clicks (`<a href>`, unless modified, e.g. ctrl+click), form `submit` and submit-button clicks inside a form. Other events keep their default (typing, checking a box); take `@event` and call `preventDefault()` yourself if needed. A component only binds its own `data-event`/`data-bind`/`data-i18n` elements, not those inside nested child components.
76
85
 
77
86
  ## Core services
78
87
 
79
- - `AppEventBus` — `subscribe`/`once`/`emit`/`off`. Inside a component, prefer `this.listen(event, cb)` (auto-unsubscribes on `destroy()`) or `this.listenToRenderEvents([event1, event2, ...])` (re-runs `this.render()` whenever any of the given events fire) over calling the bus directly. Typed facades — each `on*` subscriber returns a closure you call directly to unsubscribe (`const off = Navigation.onNavigate(cb); ...; off();`), no need to keep the original callback reference around for `off()`:
88
+ - `AppEventBus` — `subscribe`/`once`/`emit`/`off`.{{#if (eq lang "ts")}} Declare app events in `src/app/globals.d.ts`'s `Nutin.AppEventMap`: `emit()` requires the declared payload, and an event declared `undefined` takes none.{{/if}} Inside a component, prefer `this.listen(event, cb)` (auto-unsubscribes on `destroy()`) or `this.listenToRenderEvents([event1, event2, ...])` (re-runs `this.render()` whenever any of the given events fire) over calling the bus directly. Typed facades — each `on*` subscriber returns a closure you call directly to unsubscribe (`const off = Navigation.onNavigate(cb); ...; off();`), no need to keep the original callback reference around for `off()`:
80
89
  - `Navigation` — `navigateTo(path)`/`onNavigate`, `reload()`/`onReload`.
81
90
  - `Lifecycle` — `viewMount(viewName)`/`onViewMount`, `viewUnmount(viewName)`/`onViewUnmount`, plus generic `beforeRender`/`afterRender`/`beforeDestroy`/`afterDestroy`.
82
- - `AppRouter(routes)` — call once in `main.ts`; routes support `:param`/`:param?` and per-route guards.
91
+ - `AppRouter(routes)` — call once in `main.ts` (optional: without it, mount components into any element with `new X(el).render()`; the build then skips the `routes` and `#app` checks, unless `generateSEOFiles` is on; a mount target that isn't found logs a `console.error` in development; testin-nutin's framework tests still mount into `#app`, see "Testing"); routes support `:param`/`:param?`, per-route guards, and lazy views: a factory returning `import('./views/x/x.view.js').then((m) => new m.XView())` loads the view on first visit (import it by its own file, not through a barrel{{#if (eq lang "ts")}}; factory type: global `Nutin.ViewFactory`{{/if}}). Keep `/` and `/404` eager. After each in-app navigation (not the first load or `Navigation.reload()`), focus moves to the view's `<h1>`, else the `<h1>` in `<main>`, else the view element, with `document.title` announced in a polite live region in that last case only (a focused heading is read by screen readers). Give each view an `<h1>`. `document.title` comes from `View.documentTitle()` if overridden, else `config/seo.json`'s title (with `generateSEOFiles`), the `<viewName>.title` translation, then `viewName`.
83
92
  - `registerGlobals({ before?, after? })` — each entry is `{ component, id }` (a bare class, constructed internally); mounts them outside `<main id="app">` (`before`/`after` control placement relative to it), stamping each root element with `id`. `hideGlobals(ids)`/`revealGlobals(ids)` (same module) toggle visibility by those ids from any component or view. Call once in `main.ts`.
84
- - `AppHttpClient` — `get/post/put/patch/delete<T>(endpoint, data?, config?)`; non-OK responses throw `HttpError`.
85
- - `I18nService` — `translate(key, fallback?)`, `setCurrentLanguage(lang)`; language list comes from `config/languages.json`.
93
+ - `AppHttpClient` — `get/post/put/patch/delete<T>(endpoint, data?, config?)`; non-OK responses throw `HttpError`. Relative endpoints resolve against the page origin; a subclass with a base URL rejects endpoints leaving it. Build paths from values with ``path`/users/${id}` `` (encoded `/` and `\` in paths are rejected unless the API is in the `trustedAPIs` option). FormData/Blob bodies are sent as-is; 204 → `undefined`.
94
+ - `I18nService` — `translate(key, fallback?)`, `setCurrentLanguage(lang)`; language list comes from `config/languages.json`. `initI18n()` (same module) loads the translations when `i18n` is enabled and does nothing otherwise; `main.ts` awaits it before starting the app. With `i18n: false`, prod builds drop the i18n code unless app code references `I18nService`.
86
95
  - `AppPipeRegistry` — `register(name, fn)` / `apply(name, value, args)`; backs `data-pipe`.
87
96
 
88
97
  ## Styling
@@ -92,40 +101,27 @@ Tokens: `@value`, `@id`, `@class`, `@textContent`, `@event`, `@target`, `@attr:n
92
101
 
93
102
  ## Pipes
94
103
 
95
- ```
96
- // currency
97
- AppPipeRegistry.register('currency');
98
-
99
- // date
100
- AppPipeRegistry.register('date');
101
-
102
- // number (toFixed)
103
- AppPipeRegistry.register('number');
104
-
105
- // string pipes
106
- AppPipeRegistry.register('uppercase');
107
- AppPipeRegistry.register('lowercase');
108
- AppPipeRegistry.register('capitalize');
109
- AppPipeRegistry.register('capitalizeAll');
110
- AppPipeRegistry.register('truncate');
111
-
112
- // utility pipes
113
- AppPipeRegistry.register('default');
114
- AppPipeRegistry.register('json');
115
- ```
104
+ Built-in pipes, registered by `registerPipes()` in `main.ts` (args in `data-pipe="name:arg1,arg2"`):
105
+
106
+ - `currency:currency,locale` (defaults `USD`, `en-US`), `date:locale,format,time` (defaults browser language, `long`), `number:decimals` (`toFixed`, default `0`)
107
+ - `uppercase`, `lowercase`, `capitalize`, `capitalizeAll`, `truncate:length,suffix` (defaults `50`, `...`)
108
+ - `default:fallback`, `json`
109
+
110
+ Add your own with `AppPipeRegistry.register(name, (value, ...args) => string)`; args arrive as strings.
116
111
 
117
112
  ## Configuration
118
113
 
119
114
  ### `nutin.config.js`
120
115
 
121
116
  Central options/tools config (see the inline comments for details):
122
- - `i18n`, `tailwind`, `generateSEO`, `dockerPorts` — top-level feature toggles (`dockerPorts` only applies once the docker feature is added).
117
+ - `i18n`, `tailwind`, `generateSEOFiles` — top-level feature toggles.
118
+ - `dockerPorts`, `markdownSources` — options of the `docker`/`markdown` features, only present once added with `nutin-add <feature>`.
123
119
  - `builder.{sass.paths, esbuild}` — build pipeline behavior.
124
120
  - `testinNutin.{includeFramework, includeTools, includeApp, jsdomOptions}` — test discovery/environment.
125
121
 
126
122
  ### `config/seo.json`
127
123
 
128
- Consumed by builder (production only) to generate static HTML per route. Unused if `nutin.config.js -> generateSEO` is disabled.
124
+ Consumed by builder (production only) to generate static HTML per route. Unused if `nutin.config.js -> generateSEOFiles` is disabled. Dynamic routes (`/blog/:slug`) are written at their real URL from `mockParams` (or `outputPath`). Route guards run first, as an anonymous visitor: a route they block or redirect isn't prerendered nor listed in `sitemap.xml`.
129
125
 
130
126
  ### `config/languages.json`
131
127
 
@@ -133,17 +129,22 @@ Consumed by i18nService and builder. Unused if i18n is disabled.
133
129
 
134
130
  ## Security
135
131
 
136
- `SecurityHelper.sanitizeTemplate()` runs on every `render()` before `innerHTML` is set — `trustLevel` (`'strict' | 'normal' | 'trusted'`, default `'normal'`) controls how much is stripped (`<script>`/inline handlers always; `<iframe>`/`javascript:`/`data:` under `strict` only). Use `SecurityHelper.escapeHtml()` when manually interpolating strings into templates.
132
+ Write every template with the `html` tag (`import { html, raw } from` core): `templateFn = (task) => html\`<li>${task.name}</li>\``. Every `${}` is HTML-escaped, whatever the data's source (input, HTTP, localStorage). Nested `html` results are inserted as-is, and arrays are inserted item by item (no `.join('')`). For lists, use `createCatalogComponents()` (see "Children components") rather than mapping items to markup in the template: each item becomes a child component with its own events and state, kept across renders with `keepBy`. Unquoted attribute values (`title=${x}`) are quoted automatically. A plain value in tag position (`<input ${x}>`) is filtered like `raw()` there: plain attributes stay, handlers, bindings and script URLs are dropped. Wrap HTML you want rendered in `raw()`: it's parsed in place at render (works inside `<svg>`/`<table>`) and loses Nutin's binding attributes (`data-event`, `data-component`, `data-catalog`, `data-bind`, `data-i18n`, `data-pipe*`). `trustedRaw()` keeps them, for markup you wrote yourself (e.g. compiled Markdown). An untagged template literal escapes nothing, and the build warns about each one containing `${}`.
133
+
134
+ Every `render()` also sanitizes the template (`SecurityHelper.sanitizeToFragment()`) and inserts the resulting nodes directly — never re-parsed from a string. `trustLevel` (`'strict' | 'normal' | 'trusted'`, default `'normal'`) controls how much is stripped (`<script>`/`<style>`/`<link>`/`<base>`/`<meta>`, inline handlers, `srcdoc`, `javascript:` URLs, `data:` frames and SVG `<animate>`/`<set>` targeting URL attributes from `normal` up; `<iframe>`/`<object>`/`<embed>` and all `data:` URLs under `strict` only). Don't interpolate data into `data-event` args or `style`: use `data-x="${v}"` + `@dataset:x`. URL attributes (`href`, `src`, `action`, …) whose interpolated value makes them a `javascript:` URL get `about:invalid#nutin-blocked` instead, at every trust level. It is a second layer for `raw()` content, not a substitute for `html`.
137
135
 
138
136
  ## Testing (testin-nutin)
139
137
 
140
- Nutin's own hand-built test runner + jsdom - not Jest. `describe`/`it` only (**no `test()` alias**), `beforeAll`/`beforeEach`/`afterEach`/`afterAll` (one of each per suite, no stacking - inside `describe` statement, not top-level). All globals — no imports needed in test files.
138
+ Nutin's own hand-built test runner + jsdom - not Jest. Tests are plain JavaScript (`*.test.js` importing the compiled `#root/dist/...` output){{#if (eq lang "ts")}}, so they aren't type-checked{{/if}}. `describe`/`it` only (**no `test()` alias**), `beforeAll`/`beforeEach`/`afterEach`/`afterAll` (one of each per suite, no stacking - anywhere inside the `describe` statement, not top-level). `it.todo(name)` lists a pending test. All globals — no imports needed in test files.
141
139
 
142
140
  - Matchers: `toBe`, `toEqual`, `toContain`, `toBeTruthy`/`toBeFalsy`, `toBeDefined`/`toBeUndefined`, `toBeInstanceOf`, `toHaveBeenCalled`/`toHaveBeenCalledWith`, `toBeLessThan`/`toBeGreaterThan`, `toThrow` — each has a `.not` counterpart.
143
141
  - `spyOn(obj, 'method')` returns `.calls`/`.callCount`/`.lastCall`, `.andCallFake(fn)`/`.andReturn(v)`, `.restore()` — unlike mocks, calls pass through by default.
144
142
  - `tools/testin-nutin/mocks/` has ready-made `MockEventBus`/`MockHttpClient`/`MockI18n`/`MockRouter` for constructor injection.
145
- - `useFakeTimers()`/`useRealTimers()`/`advanceTimersByTime(ms)`/`runAllTimers()` for time-dependent code.
143
+ - `useFakeTimers()`/`useRealTimers()`/`advanceTimersByTime(ms)`/`runAllTimers()` for time-dependent code; `flushPromises()` for pending promises.
144
+ - `silenceConsole(method | methods, fn)` runs `fn` with those `console` methods muted (sync or async). Use it in any test that triggers an expected warning/error.
145
+ - `$(selector)`/`$$(selector)` — `document.querySelector`/`querySelectorAll` (as an array).
146
146
  - Test discovery/scope controlled by `nutin.config.js`'s `testinNutin.includeApp`/`includeFramework`.
147
+ - Tests run against the built `dist/src/index.html`, and the framework tests (`includeFramework`) mount into its `#app` element. An app without the router that has no `#app` keeps an empty `<div id="app"></div>` in `src/index.html` (not `hidden`: focus tests need it focusable), or sets `includeFramework: false`.
147
148
 
148
149
  ## Nutin
149
150
 
@@ -166,4 +167,4 @@ When modifying Nutin `core/` files:
166
167
 
167
168
  ## Documentation
168
169
 
169
- [Nutin Documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs)
170
+ [Nutin Documentation](https://www.nutin.dev/docs)