@nutin/cli 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/CHANGELOG.md +268 -0
  2. package/README.md +13 -5
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +9 -8
  34. package/templates/base/AGENTS.md.hbs +42 -41
  35. package/templates/base/GETTING_STARTED.md.hbs +26 -20
  36. package/templates/base/README.md.hbs +7 -4
  37. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  38. package/templates/base/src/app/guards.ts.hbs +1 -3
  39. package/templates/base/src/app/main.ts.hbs +2 -9
  40. package/templates/base/src/app/routes.ts.hbs +7 -2
  41. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  42. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  43. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  44. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  45. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  46. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  47. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  48. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
  49. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  50. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
  51. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
  52. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  53. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
  54. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  55. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  56. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  57. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  58. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  59. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  60. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  61. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  62. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  63. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  64. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  65. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  66. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  67. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  68. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  69. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  70. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  71. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  72. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  73. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  74. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  75. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  76. package/templates/base/src/core/index.ts.hbs +4 -2
  77. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  78. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  79. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  80. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  81. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  82. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  83. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  84. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  85. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  86. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  87. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  88. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  89. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  90. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  91. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  92. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  93. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  94. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  95. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
  96. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
  97. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  98. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  99. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  100. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  101. package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
  102. package/templates/base/src/core/services/router/router.ts.hbs +113 -53
  103. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  104. package/templates/base/tools/builder/builder.js.hbs +18 -6
  105. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  106. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  107. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  108. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  109. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  110. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  111. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  112. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  113. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  114. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  115. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  116. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  117. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  118. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  119. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  120. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  121. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  122. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  123. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  124. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  125. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  126. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
  127. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  128. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  129. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  130. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  131. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
  132. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  133. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  134. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
  135. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  136. package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
  137. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  138. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  139. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  140. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  141. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  142. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  143. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
  144. package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
  145. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  146. package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
  147. package/templates/base/tools/dev/serve.js.hbs +152 -22
  148. package/templates/base/tools/dev/watcher.js.hbs +34 -3
  149. package/templates/base/tools/generator/generator.js.hbs +1 -1
  150. package/templates/base/tools/generator/handle-file.js.hbs +4 -2
  151. package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
  152. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  153. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  154. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  155. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  156. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  157. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  158. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  159. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  160. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  161. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  162. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  163. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  164. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  165. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  166. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  167. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  168. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  169. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  170. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  171. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  172. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  173. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  174. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  175. package/templates/base/tools/utils/build-lock.js.hbs +30 -2
  176. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  177. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  178. package/templates/base/tools/utils/format.js.hbs +1 -1
  179. package/templates/base/tools/utils/index.js.hbs +2 -1
  180. package/templates/base/tools/utils/print.js.hbs +2 -2
  181. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  182. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  183. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  184. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  185. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  186. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
  187. package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
  188. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  189. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  190. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  191. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  192. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  193. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  194. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  195. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  196. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
  197. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  198. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  199. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  200. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  201. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  202. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  203. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  204. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  205. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  206. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  207. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  208. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  209. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  210. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  211. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  212. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  213. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  214. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  215. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  216. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  220. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  221. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  222. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  223. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  224. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  225. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  226. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  227. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  228. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  229. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,37 +1,62 @@
1
1
  import chokidar from 'chokidar';
2
2
  import { exec } from 'child_process';
3
3
  import path from 'path';
4
- import { print } from './core/index.js'
4
+ import { print } from './core/index.js';
5
+ import config from '#root/nutin.config.js';
5
6
 
6
- const watcher = chokidar.watch(['src', 'test', 'unit', 'e2e'], {
7
+ // Tests import the build output (dist/), so every run rebuilds first.
8
+ const COMMAND = '{{packageManager}} run testin-nutin';
9
+
10
+ const watched = ['src'];
11
+ if (config.testinNutin.includeTools) watched.push('tools');
12
+
13
+ const watcher = chokidar.watch(watched, {
7
14
  ignored: /(^|[/\\])\../,
8
15
  persistent: true,
16
+ ignoreInitial: true,
9
17
  });
10
18
 
11
- print.boldHead('Watching for test changes...')
19
+ let isRunning = false;
20
+ let pendingRun = false;
21
+ let runTimeout = null;
22
+ let lastChangedPath = null;
23
+
24
+ function runTests() {
25
+ isRunning = true;
26
+ pendingRun = false;
27
+
28
+ console.clear();
29
+ print.boldInfo(`\n🔄 File changed: ${path.relative(process.cwd(), lastChangedPath)}\n`);
30
+
31
+ exec(COMMAND, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
32
+ if (stdout) process.stdout.write(stdout);
33
+ if (stderr) process.stderr.write(stderr);
34
+ if (err) print.boldError(`\nTest run failed: ${err.message}`);
35
+ print.boldHead('Watching for test changes...');
36
+ isRunning = false;
37
+
38
+ // A change arrived mid-run — run again instead of dropping it.
39
+ if (pendingRun) runTests();
40
+ });
41
+ }
12
42
 
13
43
  watcher.on('error', (err) => {
14
44
  print.boldError(`\nWatcher error: ${err.message}`);
15
45
  });
16
46
 
17
- let isRunning = false;
18
- let runTimeout = null;
47
+ watcher.on('all', (event, filePath) => {
48
+ if (!['add', 'change', 'unlink'].includes(event)) return;
49
+ lastChangedPath = filePath;
19
50
 
20
- watcher.on('change', (filePath) => {
21
- if (runTimeout) clearTimeout(runTimeout);
51
+ if (isRunning) {
52
+ pendingRun = true;
53
+ return;
54
+ }
22
55
 
23
- runTimeout = setTimeout(() => {
24
- if (isRunning) return;
25
- isRunning = true;
26
-
27
- console.clear();
28
- print.boldInfo(`\n🔄 File changed: ${path.relative(process.cwd(), filePath)}\n`);
56
+ if (runTimeout) clearTimeout(runTimeout);
57
+ runTimeout = setTimeout(runTests, 100);
58
+ });
29
59
 
30
- exec('node --experimental-modules testin-nutin/runner.js', { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
31
- if (stdout) process.stdout.write(stdout);
32
- if (stderr) process.stderr.write(stderr);
33
- if (err) print.boldError(`\nTest run failed: ${err.message}`);
34
- isRunning = false;
35
- });
36
- }, 100);
60
+ watcher.on('ready', () => {
61
+ print.boldHead('Watching for test changes...');
37
62
  });
@@ -25,12 +25,32 @@ function tryAcquire() {
25
25
  }
26
26
 
27
27
  function isStale() {
28
+ let mtimeMs;
28
29
  try {
29
- const { mtimeMs } = fs.statSync(LOCK_DIR);
30
- return Date.now() - mtimeMs > STALE_MS;
30
+ ({ mtimeMs } = fs.statSync(LOCK_DIR));
31
31
  } catch {
32
32
  return true; // lock vanished mid-check (released concurrently) — treat as free
33
33
  }
34
+ if (Date.now() - mtimeMs > STALE_MS) return true;
35
+ return isHolderDead();
36
+ }
37
+
38
+ // A build killed hard (kill -9, a crash) never releases — free the lock right away
39
+ // when the pid that wrote it is gone instead of waiting out STALE_MS.
40
+ function isHolderDead() {
41
+ let pid;
42
+ try {
43
+ pid = Number(fs.readFileSync(path.join(LOCK_DIR, 'pid'), 'utf-8'));
44
+ } catch {
45
+ return false; // pid not written yet (holder is mid-acquire) — not provably dead
46
+ }
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return false;
51
+ } catch (err) {
52
+ return err.code === 'ESRCH';
53
+ }
34
54
  }
35
55
 
36
56
  function release() {
@@ -66,4 +86,12 @@ export async function acquireBuildLock() {
66
86
  }
67
87
 
68
88
  process.on('exit', release);
89
+ // 'exit' doesn't fire when a signal terminates the process (Ctrl-C, dev-serve's
90
+ // group kill), so release explicitly there too, then exit the way the signal would have.
91
+ for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGHUP', 129]]) {
92
+ process.once(signal, () => {
93
+ release();
94
+ process.exit(code);
95
+ });
96
+ }
69
97
  }
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { print } from './print.js';
4
+ import { errorExit } from './error-exit.js';
5
+ import { runCommand } from './run-command.js';
6
+ import { promptBoolean } from './prompt-boolean.js';
7
+
8
+ export function isInstalled(depName) {
9
+ return fs.existsSync(path.join(process.cwd(), 'node_modules', depName, 'package.json'));
10
+ }
11
+
12
+ export function detectPackageManager() {
13
+ const has = (file) => fs.existsSync(path.join(process.cwd(), file));
14
+ if (has('pnpm-lock.yaml')) return 'pnpm';
15
+ if (has('yarn.lock')) return 'yarn';
16
+ if (has('bun.lock') || has('bun.lockb')) return 'bun';
17
+ return 'npm';
18
+ }
19
+
20
+ export function getDevInstallArgs(packageManager, deps) {
21
+ const pkgList = deps.map((dep) => `${dep.name}@${dep.version}`);
22
+ return packageManager === 'npm'
23
+ ? ['npm', ['install', '-D', ...pkgList]]
24
+ : [packageManager, ['add', '-D', ...pkgList]];
25
+ }
26
+
27
+ // builder.js forwards -y/--yes through the env, since runScript doesn't forward argv.
28
+ function assumeYes() {
29
+ return process.env.NUTIN_ASSUME_YES === '1' || process.argv.includes('-y') || process.argv.includes('--yes');
30
+ }
31
+
32
+ /**
33
+ * Makes sure an opt-in feature's devDependencies are installed before its build step
34
+ * imports them. Prod builds never install. Dev builds ask on a TTY; elsewhere (CI, the
35
+ * dev watcher) they only install with -y/--yes, never silently.
36
+ */
37
+ export async function ensureDeps({ feature, deps, isProd, origin }) {
38
+ const missing = deps.filter((dep) => !isInstalled(dep.name));
39
+ if (!missing.length) return;
40
+
41
+ const [command, args] = getDevInstallArgs(detectPackageManager(), missing);
42
+ const installCommand = [command, ...args].join(' ');
43
+
44
+ if (isProd) {
45
+ errorExit(`${feature} is enabled but its dependencies are missing.\nRun "${installCommand}" before rebuilding.`, origin);
46
+ }
47
+
48
+ print.warn(`${feature} is enabled but its dependencies are missing.`);
49
+ print.gray('Required packages:');
50
+ missing.forEach((dep) => print.gray(` - ${dep.name}@${dep.version}`));
51
+
52
+ let shouldInstall;
53
+ if (assumeYes()) {
54
+ print.gray(`-y/--yes passed. Installing automatically: ${installCommand}`);
55
+ shouldInstall = true;
56
+ } else if (process.stdin.isTTY) {
57
+ shouldInstall = await promptBoolean('Install them now?');
58
+ } else {
59
+ errorExit(`Non-interactive shell detected. Run "${installCommand}", or rebuild with "-- -y" to install automatically.`, origin);
60
+ }
61
+
62
+ if (!shouldInstall) {
63
+ print.boldError(`Aborting. Run manually: ${installCommand}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ try {
68
+ await runCommand(command, args);
69
+ } catch (err) {
70
+ errorExit(err, `${origin}-install`);
71
+ }
72
+ }
@@ -0,0 +1,17 @@
1
+ import { isInstalled, getDevInstallArgs } from './ensure-deps.js';
2
+
3
+ describe('ensure-deps', () => {
4
+ it('getDevInstallArgs uses "install -D" for npm and "add -D" for the others', () => {
5
+ const deps = [{ name: 'a', version: '^1.0.0' }, { name: '@scope/b', version: '^2.0.0' }];
6
+
7
+ expect(getDevInstallArgs('npm', deps)).toEqual(['npm', ['install', '-D', 'a@^1.0.0', '@scope/b@^2.0.0']]);
8
+ expect(getDevInstallArgs('pnpm', deps)).toEqual(['pnpm', ['add', '-D', 'a@^1.0.0', '@scope/b@^2.0.0']]);
9
+ expect(getDevInstallArgs('yarn', deps)[1][0]).toBe('add');
10
+ expect(getDevInstallArgs('bun', deps)[1][0]).toBe('add');
11
+ });
12
+
13
+ it('isInstalled checks node_modules/<name>/package.json', () => {
14
+ expect(isInstalled('esbuild')).toBeTruthy();
15
+ expect(isInstalled('surely-not-an-installed-package-xyz')).toBeFalsy();
16
+ });
17
+ });
@@ -8,7 +8,7 @@ export function capitalized(input) {
8
8
  const formatted = input.replace(
9
9
  /\w*/g,
10
10
  function(txt) {
11
- return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
11
+ return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
12
12
  })
13
13
  return formatted.replace(/,/g, '');
14
14
  }
@@ -4,6 +4,7 @@ export * from './get-files-recursive.js';
4
4
  export * from './run-command.js';
5
5
  export * from './run-script.js';
6
6
  export * from './prompt-boolean.js';
7
- export * from './languages.js';
8
7
  export * from './error-exit.js';
9
8
  export * from './build-lock.js';
9
+ export * from './resolve-port.js';
10
+ export * from './ensure-deps.js';
@@ -16,7 +16,7 @@ export const chalk = {
16
16
  };
17
17
 
18
18
  export const print = {
19
- head: (s) => console.info(`${chalk.purple(s)}`),
19
+ head: (s) => console.info(`${chalk.purple(s)}`),
20
20
  boldHead : (s) => console.info(`${chalk.boldPurple(s)}`),
21
21
  section: (s) => console.info(`${chalk.yellow(s)}`),
22
22
  boldSection: (s) => console.info(`${chalk.boldYellow(s)}`),
@@ -32,6 +32,6 @@ export const print = {
32
32
  boldError: (s) => console.error(`${chalk.boldRed(s)}`),
33
33
  grayError: (s) => console.error(`${chalk.boldGray(s)}`),
34
34
  warn: (s) => console.warn(`${chalk.yellow(s)}`),
35
- boldWarn: (s) => console.warn(`${chalk.yellow(s)}`),
35
+ boldWarn: (s) => console.warn(`${chalk.boldYellow(s)}`),
36
36
  debug: (s) => console.debug(`${chalk.gray(s)}`),
37
37
  }
@@ -0,0 +1,30 @@
1
+ export const DEFAULT_PORT = 9090;
2
+
3
+ // Precedence: --port flag > npm_config_port > PORT env > DEFAULT_PORT.
4
+ // npm_config_port covers older npm versions turning `npm run dev --port=3000`
5
+ // (no `--`) into config instead of argv; npm >= 11 rejects that with EUNKNOWNCONFIG.
6
+ export function resolvePort(argv = process.argv.slice(2), env = process.env) {
7
+ const raw = readPortFlag(argv) ?? env.npm_config_port ?? env.PORT;
8
+ if (raw === undefined || raw === '') return DEFAULT_PORT;
9
+
10
+ const port = Number(raw);
11
+ if (!/^\d+$/.test(String(raw)) || port < 1 || port > 65535) {
12
+ throw new Error(`Invalid port "${raw}" — expected an integer between 1 and 65535.`);
13
+ }
14
+ return port;
15
+ }
16
+
17
+ function readPortFlag(argv) {
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const arg = argv[i];
20
+ if (arg.startsWith('--port=')) return arg.slice('--port='.length);
21
+ if (arg === '--port') {
22
+ const next = argv[i + 1];
23
+ if (next === undefined || next.startsWith('--')) {
24
+ throw new Error('Missing value for --port (e.g. --port 3000).');
25
+ }
26
+ return next;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
@@ -0,0 +1,47 @@
1
+ import { resolvePort, DEFAULT_PORT } from './resolve-port.js';
2
+
3
+ describe('resolvePort', () => {
4
+ it('defaults to 9090 with no flag or env', () => {
5
+ expect(resolvePort([], {})).toBe(DEFAULT_PORT);
6
+ expect(DEFAULT_PORT).toBe(9090);
7
+ });
8
+
9
+ it('reads --port <n>', () => {
10
+ expect(resolvePort(['--port', '3000'], {})).toBe(3000);
11
+ });
12
+
13
+ it('reads --port=<n>', () => {
14
+ expect(resolvePort(['--silent', '--port=3001'], {})).toBe(3001);
15
+ });
16
+
17
+ it('reads npm_config_port (npm rewrites --port into config)', () => {
18
+ expect(resolvePort([], { npm_config_port: '3002' })).toBe(3002);
19
+ });
20
+
21
+ it('reads PORT', () => {
22
+ expect(resolvePort([], { PORT: '3003' })).toBe(3003);
23
+ });
24
+
25
+ it('prefers --port over npm_config_port over PORT', () => {
26
+ expect(resolvePort(['--port', '1111'], { npm_config_port: '2222', PORT: '3333' })).toBe(1111);
27
+ expect(resolvePort([], { npm_config_port: '2222', PORT: '3333' })).toBe(2222);
28
+ });
29
+
30
+ it('ignores an empty PORT', () => {
31
+ expect(resolvePort([], { PORT: '' })).toBe(DEFAULT_PORT);
32
+ });
33
+
34
+ it('throws on a non-numeric port', () => {
35
+ expect(() => resolvePort(['--port', 'abc'], {})).toThrow('Invalid port "abc"');
36
+ });
37
+
38
+ it('throws on out-of-range ports', () => {
39
+ expect(() => resolvePort(['--port=0'], {})).toThrow('Invalid port "0"');
40
+ expect(() => resolvePort([], { PORT: '70000' })).toThrow('Invalid port "70000"');
41
+ });
42
+
43
+ it('throws when --port has no value', () => {
44
+ expect(() => resolvePort(['--port'], {})).toThrow('Missing value for --port');
45
+ expect(() => resolvePort(['--port', '--silent'], {})).toThrow('Missing value for --port');
46
+ });
47
+ });
@@ -1,8 +1,19 @@
1
1
  import { spawn } from 'child_process';
2
2
 
3
+ const isWindows = process.platform === 'win32';
4
+
5
+ // Windows needs a shell to launch .cmd shims (npm, tsc, ...); a single pre-quoted command
6
+ // string avoids Node's deprecated args-array-with-shell form. Elsewhere no shell is
7
+ // involved, so arguments containing spaces or shell characters are passed through as-is.
8
+ function quoteForCmd(arg) {
9
+ return /[\s"&|<>^]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg;
10
+ }
11
+
3
12
  export function runCommand(command, args = [], options = {}) {
4
13
  return new Promise((resolve, reject) => {
5
- const child = spawn([command, ...args].join(' '), { stdio: 'inherit', shell: true, ...options });
14
+ const child = isWindows
15
+ ? spawn([command, ...args].map(quoteForCmd).join(' '), { stdio: 'inherit', shell: true, ...options })
16
+ : spawn(command, args, { stdio: 'inherit', ...options });
6
17
 
7
18
  child.on('error', (err) => reject(err));
8
19
 
@@ -12,3 +23,21 @@ export function runCommand(command, args = [], options = {}) {
12
23
  });
13
24
  });
14
25
  }
26
+
27
+ // Like runCommand(), but collects the child's stdout and stderr (interleaved, in arrival order)
28
+ // instead of printing them, and resolves with its exit code rather than rejecting: for a caller
29
+ // that rewrites the output, e.g. compile-ts mapping tsc errors back to source files.
30
+ export function captureCommand(command, args = [], options = {}) {
31
+ return new Promise((resolve, reject) => {
32
+ const stdio = ['inherit', 'pipe', 'pipe'];
33
+ const child = isWindows
34
+ ? spawn([command, ...args].map(quoteForCmd).join(' '), { stdio, shell: true, ...options })
35
+ : spawn(command, args, { stdio, ...options });
36
+
37
+ let output = '';
38
+ child.stdout.on('data', (chunk) => { output += chunk; });
39
+ child.stderr.on('data', (chunk) => { output += chunk; });
40
+ child.on('error', (err) => reject(err));
41
+ child.on('close', (code) => resolve({ code, output }));
42
+ });
43
+ }
@@ -1,4 +1,4 @@
1
- import { runCommand } from './run-command.js';
1
+ import { runCommand, captureCommand } from './run-command.js';
2
2
 
3
3
  describe('runCommand', () => {
4
4
  it('resolves when the child process exits with code 0', async () => {
@@ -16,4 +16,15 @@ describe('runCommand', () => {
16
16
  expect(error).toBeDefined();
17
17
  expect(error.message).toContain('7');
18
18
  });
19
+
20
+ it('passes arguments containing spaces through unsplit', async () => {
21
+ await runCommand('node', ['-e', 'if (process.argv[1] !== "a b") process.exitCode = 1', 'a b']);
22
+ });
23
+
24
+ it('captureCommand resolves with the exit code and the collected stdout and stderr', async () => {
25
+ const { code, output } = await captureCommand('node', ['-e', 'console.log("out"); console.error("err"); process.exitCode = 2']);
26
+ expect(code).toBe(2);
27
+ expect(output).toContain('out');
28
+ expect(output).toContain('err');
29
+ });
19
30
  });
@@ -1,7 +1,7 @@
1
1
  import { resolve } from 'path';
2
2
  import { spawnSync } from 'child_process';
3
3
  import process from 'process';
4
- import { print } from './index.js';
4
+ import { print } from './print.js';
5
5
 
6
6
  export function runScript(path, message) {
7
7
  if (!message) print.boldSection(`Running ${path}...`);
@@ -9,7 +9,7 @@ export function runScript(path, message) {
9
9
 
10
10
  const args = [resolve(path), '--silent'];
11
11
 
12
- const result = spawnSync('node', args,
12
+ const result = spawnSync(process.execPath, args,
13
13
  {
14
14
  stdio: 'inherit',
15
15
  env: { ...process.env },
@@ -23,11 +23,14 @@ RUN {{ciCommand}} || pnpm approve-builds --all
23
23
  {{else}}
24
24
  RUN {{ciCommand}}
25
25
  {{/if}}
26
+ {{#unless (eq lang "js")}}
26
27
  COPY tsconfig.json .
28
+ {{/unless}}
27
29
  COPY ./src ./src
28
30
  COPY ./public ./public
29
31
  COPY ./nutin.config.js .
30
32
  COPY ./config ./config
33
+ __MARKDOWN_SOURCES_PLACEHOLDER__
31
34
  COPY ./tools/builder ./tools/builder
32
35
  COPY ./tools/docker ./tools/docker
33
36
  COPY ./tools/utils ./tools/utils
@@ -10,7 +10,7 @@ map $uri $content_type_options {
10
10
  }
11
11
 
12
12
  map $uri $xss_protection {
13
- default "1; mode=block";
13
+ default "0";
14
14
  }
15
15
 
16
16
  map $uri $referrer_policy {
@@ -56,8 +56,9 @@ server {
56
56
  # Hide nginx version
57
57
  server_tokens off;
58
58
 
59
- # With i18n enabled, redirects bare "/" to the default language so the served URL
60
- # always matches each page's canonical/og:url (filled in by validate-docker).
59
+ # With i18n enabled, redirects "/" and unprefixed paths ("/docs") to the default
60
+ # language, so every served URL matches a page's canonical/og:url instead of
61
+ # returning a duplicate SPA shell (filled in by validate-docker).
61
62
  __ROOT_REDIRECT_PLACEHOLDER__
62
63
 
63
64
  # SPA routing: serve index.html for unknown paths
@@ -109,7 +110,8 @@ server {
109
110
  # ---------------------------
110
111
  # HEALTH ENDPOINT
111
112
  # ---------------------------
112
- location /health {
113
+ # Exact match: it must win over the i18n unprefixed-path redirect (a regex location).
114
+ location = /health {
113
115
  access_log off;
114
116
  add_header Content-Type text/plain always;
115
117
  add_header Cache-Control "no-store" always;
@@ -31,7 +31,7 @@ export function validateDockerPorts(rawPorts) {
31
31
  }
32
32
 
33
33
  if (port > MAX_PORT) {
34
- throw new Error(`nutin.config.js: "dockerPorts[${i}]" (${port}) is in the OS ephemeral port range (49152-65535) — use a registered port between ${MIN_PORT} and ${MAX_PORT}.`);
34
+ throw new Error(`nutin.config.js: "dockerPorts[${i}]": use a registered port between ${MIN_PORT} and ${MAX_PORT}.`);
35
35
  }
36
36
 
37
37
  if (port < MIN_PORT || port > MAX_PORT) {
@@ -29,8 +29,8 @@ describe('validateDockerPorts', () => {
29
29
  });
30
30
 
31
31
  it('throws for ephemeral ports at or above 49152', () => {
32
- expect(() => validateDockerPorts([49152])).toThrow('ephemeral port range');
33
- expect(() => validateDockerPorts([60000])).toThrow('ephemeral port range');
32
+ expect(() => validateDockerPorts([49152])).toThrow('use a registered port');
33
+ expect(() => validateDockerPorts([60000])).toThrow('use a registered port');
34
34
  });
35
35
 
36
36
  it('throws for duplicate ports', () => {
@@ -0,0 +1,28 @@
1
+ // Language codes end up in an nginx regex and redirect target: letters, digits and "-" only (e.g. "pt-BR").
2
+ const LANGUAGE_CODE = /^[A-Za-z0-9-]+$/;
3
+
4
+ // Only i18n builds serve every page under a language prefix — without it, "/" and every
5
+ // other path are real pages, and nothing is redirected.
6
+ export function localeRedirects({ i18n, languages, defaultLanguage }) {
7
+ if (!i18n) return '';
8
+
9
+ [defaultLanguage, ...languages].forEach((lang) => {
10
+ if (typeof lang !== 'string' || !LANGUAGE_CODE.test(lang)) {
11
+ throw new Error(`config/languages.json: invalid language code ${JSON.stringify(lang)} — use letters, digits and "-" only (e.g. "en", "pt-BR").`);
12
+ }
13
+ });
14
+
15
+ // Unprefixed app paths (old or external links like "/docs") get a 301 to the default
16
+ // language instead of a 200 SPA shell that duplicates the real page. Paths with a "."
17
+ // (robots.txt, sitemap.xml, assets) are left to the other locations. $request_uri is
18
+ // the raw request (query included): the decoded $uri could inject headers via %0D%0A.
19
+ return [
20
+ `location = / {`,
21
+ ` return 301 /${defaultLanguage}/$is_args$args;`,
22
+ ` }`,
23
+ ``,
24
+ ` location ~ ^/(?!(?:${languages.join('|')})(?:/|$))[^.]*$ {`,
25
+ ` return 301 /${defaultLanguage}$request_uri;`,
26
+ ` }`,
27
+ ].join('\n');
28
+ }
@@ -0,0 +1,34 @@
1
+ import { localeRedirects } from './locale-redirects.js';
2
+
3
+ describe('localeRedirects', () => {
4
+ it('redirects nothing without i18n', () => {
5
+ expect(localeRedirects({ i18n: false, languages: ['en'], defaultLanguage: 'en' })).toBe('');
6
+ });
7
+
8
+ it('redirects "/" to the default language', () => {
9
+ const conf = localeRedirects({ i18n: true, languages: ['en', 'fr'], defaultLanguage: 'fr' });
10
+ expect(conf).toContain('location = / {\n return 301 /fr/$is_args$args;\n }');
11
+ });
12
+
13
+ it('redirects unprefixed, extensionless paths with the raw request URI', () => {
14
+ const conf = localeRedirects({ i18n: true, languages: ['en', 'fr'], defaultLanguage: 'en' });
15
+ expect(conf).toContain('location ~ ^/(?!(?:en|fr)(?:/|$))[^.]*$ {\n return 301 /en$request_uri;\n }');
16
+ });
17
+
18
+ it('only matches unprefixed, extensionless paths', () => {
19
+ const conf = localeRedirects({ i18n: true, languages: ['en', 'pt-BR'], defaultLanguage: 'en' });
20
+ const pattern = new RegExp(conf.match(/location ~ (\S+) \{/)[1]);
21
+
22
+ ['/docs', '/docs/', '/docs/api/x', '/english', '/enx/docs', '/pt-BRx'].forEach((path) => {
23
+ expect(pattern.test(path)).toBe(true);
24
+ });
25
+ ['/en', '/en/', '/en/docs/', '/pt-BR/docs', '/robots.txt', '/main.1a2b.css', '/assets/og.jpg'].forEach((path) => {
26
+ expect(pattern.test(path)).toBe(false);
27
+ });
28
+ });
29
+
30
+ it('throws on a language code that could break the nginx config', () => {
31
+ expect(() => localeRedirects({ i18n: true, languages: ['en', 'f r'], defaultLanguage: 'en' })).toThrow('invalid language code');
32
+ expect(() => localeRedirects({ i18n: true, languages: ['en'], defaultLanguage: 'en|.*' })).toThrow('invalid language code');
33
+ });
34
+ });
@@ -0,0 +1,25 @@
1
+ import path from 'path';
2
+
3
+ // Markdown folders live outside src/ but build:prod compiles them, so the Docker
4
+ // builder stage needs them too. Returns one COPY line per folder ('' without markdown).
5
+ export function markdownSourcesCopy(sourceFolders, cwd = process.cwd()) {
6
+ const folders = (sourceFolders ?? [])
7
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.folder))
8
+ .filter(Boolean);
9
+
10
+ const relativePaths = folders.map((folder) => {
11
+ const relative = path.relative(cwd, path.resolve(cwd, folder));
12
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
13
+ throw new Error(`Markdown folder "${folder}" is outside the project, so it can't be copied into the Docker image — move it inside the project.`);
14
+ }
15
+ return relative.split(path.sep).join('/');
16
+ });
17
+
18
+ // JSON form keeps folder names with spaces intact.
19
+ return [...new Set(relativePaths)]
20
+ .map((relative) => {
21
+ const target = `./${relative}`;
22
+ return `COPY ${JSON.stringify([target, target])}`;
23
+ })
24
+ .join('\n');
25
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'path';
2
+ import { markdownSourcesCopy } from './markdown-sources-copy.js';
3
+
4
+ describe('markdownSourcesCopy', () => {
5
+ const cwd = path.resolve('/project');
6
+
7
+ it('returns an empty string without markdown folders', () => {
8
+ expect(markdownSourcesCopy(undefined, cwd)).toBe('');
9
+ expect(markdownSourcesCopy([], cwd)).toBe('');
10
+ });
11
+
12
+ it('returns one COPY line per folder, for string and object entries', () => {
13
+ expect(markdownSourcesCopy(['markdown-content', { folder: 'docs/guides' }], cwd)).toBe(
14
+ 'COPY ["./markdown-content","./markdown-content"]\nCOPY ["./docs/guides","./docs/guides"]'
15
+ );
16
+ });
17
+
18
+ it('keeps folder names with spaces in a single path', () => {
19
+ expect(markdownSourcesCopy(['my docs'], cwd)).toBe('COPY ["./my docs","./my docs"]');
20
+ });
21
+
22
+ it('copies a folder once when it is listed with different spellings', () => {
23
+ expect(markdownSourcesCopy(['docs', './docs/', { folder: 'docs' }], cwd)).toBe('COPY ["./docs","./docs"]');
24
+ });
25
+
26
+ it('skips entries without a folder', () => {
27
+ expect(markdownSourcesCopy([null, {}, 'docs'], cwd)).toBe('COPY ["./docs","./docs"]');
28
+ });
29
+
30
+ it('throws for a folder outside the project', () => {
31
+ expect(() => markdownSourcesCopy(['../shared-docs'], cwd)).toThrow('"../shared-docs" is outside the project');
32
+ expect(() => markdownSourcesCopy([path.resolve('/elsewhere/docs')], cwd)).toThrow('is outside the project');
33
+ });
34
+ });
@@ -14,7 +14,9 @@ export async function renderFromTemplate(dockerDir, templateName, outputName, re
14
14
 
15
15
  for (const [placeholder, value] of replacements) {
16
16
  if (!content.includes(placeholder)) {
17
- throw new Error(`tools/docker/${templateName} is missing the "${placeholder}" token — validate-docker has nothing to substitute. Restore the token or re-run "nutin-add docker".`);
17
+ // Nothing to insert: a template from before this token was added still renders.
18
+ if (value === '') continue;
19
+ throw new Error(`tools/docker/${templateName} is missing the "${placeholder}" token — validate-docker has nothing to substitute. Add the token back — "nutin-update" reports the current template's changes.`);
18
20
  }
19
21
  content = content.replace(placeholder, value);
20
22
  }
@@ -46,6 +46,18 @@ describe('renderFromTemplate', () => {
46
46
  ).toThrow('missing.template');
47
47
  });
48
48
 
49
+ it('skips a missing placeholder token when its value is empty', async () => {
50
+ fs.writeFileSync(path.join(tmpDir, 'Dockerfile.template'), 'ARG PORT="__PORTS_PLACEHOLDER__"\n');
51
+
52
+ await renderFromTemplate(tmpDir, 'Dockerfile.template', 'Dockerfile', [
53
+ ['__PORTS_PLACEHOLDER__', '8080'],
54
+ ['__MARKDOWN_SOURCES_PLACEHOLDER__', ''],
55
+ ]);
56
+
57
+ const output = fs.readFileSync(path.join(tmpDir, 'Dockerfile'), 'utf-8');
58
+ expect(output).toBe('ARG PORT="8080"\n');
59
+ });
60
+
49
61
  it('throws when a placeholder token is missing from the template, without writing the output', async () => {
50
62
  fs.writeFileSync(path.join(tmpDir, 'nginx.conf.template'), 'listen 9090;\n');
51
63