@nutin/cli 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/README.md +14 -6
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +12 -11
  34. package/templates/base/.gitignore.hbs +1 -0
  35. package/templates/base/AGENTS.md.hbs +45 -43
  36. package/templates/base/GETTING_STARTED.md.hbs +32 -23
  37. package/templates/base/README.md.hbs +7 -4
  38. package/templates/base/nutin.config.js.hbs +1 -1
  39. package/templates/base/src/app/components/.gitkeep +0 -0
  40. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  41. package/templates/base/src/app/guards.ts.hbs +1 -3
  42. package/templates/base/src/app/main.ts.hbs +2 -9
  43. package/templates/base/src/app/routes.ts.hbs +9 -3
  44. package/templates/base/src/app/services/.gitkeep +0 -0
  45. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  47. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  48. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  49. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  50. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  51. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  52. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
  53. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  54. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
  55. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
  56. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  57. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
  58. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  59. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  63. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  64. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  65. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  66. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  67. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  68. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  69. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  70. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  71. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  72. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  73. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  74. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  75. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  76. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  77. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  78. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  79. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  80. package/templates/base/src/core/index.ts.hbs +4 -2
  81. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  82. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  83. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  84. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  85. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  88. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  89. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  90. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  91. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  92. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  93. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  94. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  95. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  96. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  97. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  98. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  99. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
  100. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
  101. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  102. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  103. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  104. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  105. package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
  106. package/templates/base/src/core/services/router/router.ts.hbs +122 -54
  107. package/templates/base/src/index.html.hbs +1 -0
  108. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  109. package/templates/base/src/styles/main.scss.hbs +1 -1
  110. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  111. package/templates/base/tools/builder/builder.js.hbs +26 -8
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  113. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  114. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  115. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  116. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  117. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  118. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  119. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  120. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  121. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  122. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  123. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  124. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  125. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  126. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  127. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  128. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  129. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  130. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  131. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  132. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  133. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  134. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  135. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
  136. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  137. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  138. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  139. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  140. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
  141. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  142. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  143. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
  144. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  145. package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
  146. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  147. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  148. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  149. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  150. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  151. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  152. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
  153. package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
  154. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  155. package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
  156. package/templates/base/tools/dev/serve.js.hbs +152 -22
  157. package/templates/base/tools/dev/watcher.js.hbs +75 -28
  158. package/templates/base/tools/generator/generator.js.hbs +23 -18
  159. package/templates/base/tools/generator/handle-file.js.hbs +4 -24
  160. package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
  161. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  162. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  163. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  164. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  165. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  166. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  167. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  168. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  169. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  170. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  171. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  172. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  173. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  174. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  175. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  176. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  177. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  178. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  179. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  180. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  181. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  182. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  183. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  184. package/templates/base/tools/utils/build-lock.js.hbs +97 -0
  185. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  186. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  187. package/templates/base/tools/utils/format.js.hbs +1 -1
  188. package/templates/base/tools/utils/index.js.hbs +3 -1
  189. package/templates/base/tools/utils/print.js.hbs +2 -2
  190. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  191. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  192. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  193. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  194. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  195. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
  196. package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
  197. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  198. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  199. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  200. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  201. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  202. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  203. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  204. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  205. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
  206. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  207. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  208. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  209. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  210. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  211. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  212. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  213. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  214. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  215. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  216. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  220. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  221. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  222. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  223. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  224. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  225. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  226. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  227. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  228. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  229. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  230. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  231. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  232. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  233. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  234. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  235. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  236. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  237. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  238. package/templates/base/src/app/components/index.ts.hbs +0 -1
  239. package/templates/base/src/app/services/index.ts.hbs +0 -1
  240. package/templates/base/src/app/views/index.ts.hbs +0 -3
  241. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,8 +1,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 },
@@ -1,5 +1,5 @@
1
- ARG NODE_VERSION=22.23.2
2
- ARG ALPINE_VERSION=3.20
1
+ ARG NODE_VERSION=24.21.0
2
+ ARG ALPINE_VERSION=3.24
3
3
  ARG PORT="__PORTS_PLACEHOLDER__"
4
4
 
5
5
  FROM node:${NODE_VERSION}-alpine AS builder
@@ -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 {
@@ -42,6 +42,11 @@ server {
42
42
  __PORTS_PLACEHOLDER__
43
43
  server_name _;
44
44
 
45
+ # Keep nginx's own automatic redirects (e.g. the missing-trailing-slash 301 on a
46
+ # prerendered route directory) relative, so they never leak this container's
47
+ # internal port/scheme when served behind a reverse proxy or a remapped port.
48
+ absolute_redirect off;
49
+
45
50
  # ---------------------------
46
51
  # ROOT + ROUTING
47
52
  # ---------------------------
@@ -51,6 +56,11 @@ server {
51
56
  # Hide nginx version
52
57
  server_tokens off;
53
58
 
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).
62
+ __ROOT_REDIRECT_PLACEHOLDER__
63
+
54
64
  # SPA routing: serve index.html for unknown paths
55
65
  location / {
56
66
  try_files $uri $uri/ /index.html;
@@ -100,7 +110,8 @@ server {
100
110
  # ---------------------------
101
111
  # HEALTH ENDPOINT
102
112
  # ---------------------------
103
- location /health {
113
+ # Exact match: it must win over the i18n unprefixed-path redirect (a regex location).
114
+ location = /health {
104
115
  access_log off;
105
116
  add_header Content-Type text/plain always;
106
117
  add_header Cache-Control "no-store" always;
@@ -112,6 +123,12 @@ server {
112
123
  # --------------------------
113
124
  error_page 404 /404.html;
114
125
 
126
+ # A route directory that exists on disk but has no index.html of its own (e.g. a
127
+ # parent route whose only prerendered pages are its children) makes try_files'
128
+ # $uri/ candidate match the directory and stop there, returning 403 instead of
129
+ # falling through to the SPA shell. Serve the shell for that case too.
130
+ error_page 403 = /index.html;
131
+
115
132
  location = /404.html {
116
133
  internal;
117
134
  try_files /404.html =404;
@@ -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
 
@@ -1,11 +1,24 @@
1
+ import fs from 'fs';
1
2
  import path from 'path';
2
3
  import { errorExit } from '../../utils/index.js';
3
4
  import nutinConfig from '../../../nutin.config.js';
4
5
  import { validateDockerPorts } from './docker-ports.js';
5
6
  import { renderFromTemplate } from './render-docker-template.js';
7
+ import { markdownSourcesCopy } from './markdown-sources-copy.js';
8
+ import { localeRedirects } from './locale-redirects.js';
6
9
 
7
10
  const DOCKER_DIR = path.join(process.cwd(), 'tools', 'docker');
8
11
  const PLACEHOLDER = '__PORTS_PLACEHOLDER__';
12
+ const ROOT_REDIRECT_PLACEHOLDER = '__ROOT_REDIRECT_PLACEHOLDER__';
13
+ const MARKDOWN_SOURCES_PLACEHOLDER = '__MARKDOWN_SOURCES_PLACEHOLDER__';
14
+
15
+ function rootRedirect() {
16
+ if (!nutinConfig.i18n) return '';
17
+
18
+ const languagesConfigPath = path.join(process.cwd(), 'config', 'languages.json');
19
+ const { languages, defaultLanguage } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
20
+ return localeRedirects({ i18n: true, languages, defaultLanguage });
21
+ }
9
22
 
10
23
  async function validateDocker() {
11
24
  let ports;
@@ -15,13 +28,29 @@ async function validateDocker() {
15
28
  errorExit(err.message);
16
29
  }
17
30
 
31
+ let markdownCopy;
32
+ try {
33
+ markdownCopy = markdownSourcesCopy(nutinConfig.markdownSources?.sourceFolders);
34
+ } catch (err) {
35
+ errorExit(err.message);
36
+ }
37
+
18
38
  await renderFromTemplate(DOCKER_DIR, 'Dockerfile.template', 'Dockerfile', [
19
39
  [PLACEHOLDER, ports.join(' ')],
20
40
  ['__PRIMARY_PORT_PLACEHOLDER__', String(ports[0])],
41
+ [MARKDOWN_SOURCES_PLACEHOLDER, markdownCopy],
21
42
  ]).catch((err) => errorExit(err.message));
22
43
 
44
+ let redirects;
45
+ try {
46
+ redirects = rootRedirect();
47
+ } catch (err) {
48
+ errorExit(err.message);
49
+ }
50
+
23
51
  await renderFromTemplate(DOCKER_DIR, 'nginx.conf.template', 'nginx.conf', [
24
52
  [PLACEHOLDER, ports.map((port) => `listen ${port};`).join('\n ')],
53
+ [ROOT_REDIRECT_PLACEHOLDER, redirects],
25
54
  ]).catch((err) => errorExit(err.message));
26
55
  }
27
56
 
@@ -0,0 +1,28 @@
1
+ ---
2
+ title: Getting started
3
+ description: Pages in this folder are compiled to /generated/markdown-content.json at build time.
4
+ order: 1
5
+ ---
6
+
7
+ # Getting started
8
+
9
+ Pages in this folder are compiled to `/generated/markdown-content.json` at build time, then served at `/markdown-content` by `MarkdownView`.
10
+
11
+ ## Adding a page
12
+
13
+ Create a `.md` file anywhere in `markdown-content/`. Its YAML frontmatter is optional:
14
+
15
+ ```yaml
16
+ ---
17
+ title: My page # defaults to the first "# Heading"
18
+ description: Summary # defaults to the first paragraph
19
+ slug: my-page # defaults to the file name
20
+ order: 2 # pages are sorted by order, then by path
21
+ group: Guides # groups pages under a heading in the navigation
22
+ ---
23
+ ```
24
+
25
+ ## Linking pages
26
+
27
+ Link to another page with its relative path, like [writing pages](./writing-pages.md) —
28
+ the link becomes an in-app route. A link to a missing page fails the build.
@@ -0,0 +1,19 @@
1
+ ---
2
+ order: 2
3
+ group: Guides
4
+ ---
5
+
6
+ # Writing pages
7
+
8
+ Markdown is compiled with [marked](https://marked.js.org/) (GitHub-flavored), so tables,
9
+ fenced code blocks and task lists all work.
10
+
11
+ ## Headings
12
+
13
+ Every `##` to `######` heading gets an `id`, and pages with two or more of them show an
14
+ "On this page" table of contents.
15
+
16
+ ## Checking your content
17
+
18
+ Run the `markdown:check` script to compile every folder without building the app: it fails
19
+ on duplicate slugs and broken links between pages.
@@ -0,0 +1,75 @@
1
+ // :where() keeps these rules at zero specificity: see markdown.view.scss.
2
+ // A landing (.markdown-landing, .markdown-landing__body) shares the page's rules, so it
3
+ // looks like the folder's pages and any restyling of them applies to it too.
4
+ :where(.markdown-content, .markdown-landing) {
5
+ display: flex;
6
+ gap: var(--markdown-gap, 2rem);
7
+ flex: 1 1 auto;
8
+ min-width: 0;
9
+ }
10
+
11
+ :where(.markdown-content__body, .markdown-landing__body) {
12
+ flex: 1 1 auto;
13
+ min-width: 0;
14
+ line-height: var(--markdown-line-height, 1.7);
15
+ }
16
+
17
+ :where(.markdown-content__body pre) {
18
+ overflow-x: auto;
19
+ }
20
+
21
+ :where(.markdown-toc) {
22
+ flex: 0 0 var(--markdown-toc-width, 14rem);
23
+ font-size: 0.875rem;
24
+ }
25
+
26
+ :where(.markdown-toc ul) {
27
+ list-style: none;
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+
32
+ :where(.markdown-toc__title) {
33
+ display: block;
34
+ font-weight: bold;
35
+ margin-bottom: 0.5rem;
36
+ }
37
+
38
+ :where(.markdown-toc__item--depth-3) {
39
+ padding-left: 1rem;
40
+ }
41
+
42
+ :where(.markdown-toc__item--depth-4, .markdown-toc__item--depth-5, .markdown-toc__item--depth-6) {
43
+ padding-left: 2rem;
44
+ }
45
+
46
+ // The landing's index: its sections and pages, with their descriptions.
47
+ :where(.markdown-landing__pages, .markdown-landing__sections) {
48
+ list-style: none;
49
+ margin: 0;
50
+ padding: 0;
51
+ }
52
+
53
+ :where(.markdown-landing__page, .markdown-landing__sections > .markdown-landing__section) {
54
+ margin-bottom: 0.75rem;
55
+ }
56
+
57
+ :where(.markdown-landing__link) {
58
+ font-weight: bold;
59
+ }
60
+
61
+ :where(.markdown-landing__page-description, .markdown-landing__section-description) {
62
+ margin: 0.25rem 0 0;
63
+ opacity: 0.75;
64
+ }
65
+
66
+ @media (max-width: 900px) {
67
+ :where(.markdown-content, .markdown-landing) {
68
+ flex-direction: column;
69
+ }
70
+
71
+ :where(.markdown-toc) {
72
+ order: -1;
73
+ flex-basis: auto;
74
+ }
75
+ }
@@ -0,0 +1,40 @@
1
+ import { MarkdownContentComponent } from '#root/dist/src/app/markdown/components/markdown-content/markdown-content.component.js';
2
+
3
+ function mount(config) {
4
+ const target = document.createElement('div');
5
+ document.body.appendChild(target);
6
+ const component = new MarkdownContentComponent(target, config);
7
+ component.render();
8
+ return component;
9
+ }
10
+
11
+ describe('MarkdownContentComponent', () => {
12
+ it('shows the empty state when the folder has no page to show', () => {
13
+ const component = mount({});
14
+ expect(component.element.querySelector('.markdown-content__empty')).toBeTruthy();
15
+ expect(component.element.querySelector('.markdown-content__error')).toBe(null);
16
+ component.destroy();
17
+ });
18
+
19
+ it('shows an error, not the empty state, when the manifest failed to load', () => {
20
+ const component = mount({ loadFailed: true });
21
+ const error = component.element.querySelector('.markdown-content__error');
22
+ expect(error.getAttribute('role')).toBe('alert');
23
+ expect(error.textContent.trim()).not.toBe('');
24
+ expect(component.element.querySelector('.markdown-content__empty')).toBe(null);
25
+ component.destroy();
26
+ });
27
+
28
+ it('renders the page with its table of contents', () => {
29
+ const component = mount({
30
+ page: {
31
+ slug: 'a', title: 'A', description: '', section: 'index', group: null, order: 0, source: 'a.md',
32
+ html: '<h1 id="a">A</h1><h2 id="one">One</h2><h2 id="two">Two</h2>',
33
+ headings: [{ depth: 2, text: 'One', id: 'one' }, { depth: 2, text: 'Two', id: 'two' }],
34
+ },
35
+ });
36
+ expect(component.element.querySelector('.markdown-content__body h2#one')).toBeTruthy();
37
+ expect([...component.element.querySelectorAll('.markdown-toc a')].map((a) => a.getAttribute('href'))).toEqual(['#one', '#two']);
38
+ component.destroy();
39
+ });
40
+ });
@@ -0,0 +1,65 @@
1
+ import { Component, Navigation, html, trustedRaw, SafeHtml } from '../../../../core/index.js';
2
+ import { IMarkdownHeading, IMarkdownPage } from '../../markdown-manifest.js';
3
+ import { markdownText } from '../../markdown-i18n.js';
4
+
5
+ export interface IMarkdownContentConfig {
6
+ page?: IMarkdownPage;
7
+ // The folder's manifest failed to load: shown as an error, not as an empty folder.
8
+ loadFailed?: boolean;
9
+ // Called after each render that shows a page (MarkdownView's "onContentRendered" option).
10
+ onRendered?: (element: HTMLElement, page: IMarkdownPage) => void;
11
+ }
12
+
13
+ // Exported so a custom template can reuse it.
14
+ export function renderToc(headings: IMarkdownHeading[]): SafeHtml | string {
15
+ // A single heading adds nothing next to the page title — only worth a TOC at 2+.
16
+ if (headings.length <= 1) return '';
17
+
18
+ const items = headings
19
+ .map((heading) => html`
20
+ <li class="markdown-toc__item markdown-toc__item--depth-${heading.depth}">
21
+ <a href="#${heading.id}">${heading.text}</a>
22
+ </li>
23
+ `);
24
+
25
+ return html`
26
+ <nav class="markdown-toc" aria-label="${markdownText('onThisPage', 'On this page')}">
27
+ <span class="markdown-toc__title">${markdownText('onThisPage', 'On this page')}</span>
28
+ <ul>${items}</ul>
29
+ </nav>
30
+ `;
31
+ }
32
+
33
+ // page.html is compiled at build time from the project's own Markdown files (not user
34
+ // input), hence trustLevel 'trusted'. With generateSEOFiles, every page is also prerendered
35
+ // (tools/builder/core/markdown/markdown-seo-routes.js).
36
+ export const markdownContentTemplate = (_config: IMarkdownContentConfig) => {
37
+ if (!_config.page) {
38
+ return _config.loadFailed
39
+ ? html`<p class="markdown-content__error" role="alert">${markdownText('loadError', "This page couldn't be loaded.")}</p>`
40
+ : html`<p class="markdown-content__empty">${markdownText('empty', 'Nothing here yet.')}</p>`;
41
+ }
42
+
43
+ return html`
44
+ <article class="markdown-content__body">${trustedRaw(_config.page.html)}</article>
45
+ ${renderToc(_config.page.headings)}
46
+ `;
47
+ };
48
+
49
+ export class MarkdownContentComponent extends Component<HTMLElement, IMarkdownContentConfig> {
50
+ // templateFn: your own template, e.g. built from renderToc() and the page's html.
51
+ constructor(mountTarget: HTMLElement, config: IMarkdownContentConfig, templateFn: (config: IMarkdownContentConfig) => Nutin.Template = markdownContentTemplate) {
52
+ // Mounting replaces the placeholder element, so its class is re-applied here.
53
+ super({ templateFn, mountTarget, config, trustLevel: 'trusted', props: { className: 'markdown-content' } });
54
+ }
55
+
56
+ protected override onAfterRender(): void {
57
+ if (this.config.page) this.config.onRendered?.(this.element, this.config.page);
58
+ super.onAfterRender();
59
+ }
60
+
61
+ // Internal links between pages carry data-event="click:_navigateTo:@attr:href".
62
+ protected _navigateTo(href: string): void {
63
+ Navigation.navigateTo(href);
64
+ }
65
+ }