@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
@@ -0,0 +1,582 @@
1
+ import * as fs from 'fs';
2
+ import path from 'path';
3
+
4
+ // Compiles one `markdownSources.sourceFolders` entry into a manifest:
5
+ // { sections: [{ id, title, description, groups? | pages? }], pages: { [slug]: page } }
6
+ //
7
+ // Two layouts are supported per folder:
8
+ // - Hub mode: one or more hub files (a Markdown TOC: H1, description, "## Table of
9
+ // Contents", optional "### Group" headings and "- [Title](./path.md)" bullets). Each hub
10
+ // is one section; only the pages it lists are compiled, in its order.
11
+ // - Frontmatter mode (no hub file): every .md file under the folder, in a single "index"
12
+ // section, sorted by frontmatter `order`, then path. Pages are grouped by frontmatter
13
+ // `group`, else by the first-level subfolder they're in (see folderTitle()); pages at the
14
+ // folder's root are ungrouped.
15
+ //
16
+ // Page metadata comes from YAML frontmatter first (title, description, slug, order, group, ogImage),
17
+ // then the hub entry, then the Markdown itself (first H1, first prose line, file name).
18
+ //
19
+ // `marked` (Marked class) and `gray-matter` are injected: they are installed on demand by
20
+ // generate-markdown-pages.js, so this module must not import them.
21
+
22
+ export class MarkdownCompileError extends Error {}
23
+
24
+ function fail(message) {
25
+ throw new MarkdownCompileError(message);
26
+ }
27
+
28
+ export function slugify(text) {
29
+ return String(text)
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '');
33
+ }
34
+
35
+ export function slugFromFilename(fileName, prefixReplacements = []) {
36
+ const base = fileName.replace(/\.md$/i, '');
37
+ const [prefix, replacement] = prefixReplacements.find(([p]) => base.startsWith(p)) ?? ['', ''];
38
+ const rest = base.slice(prefix.length).toLowerCase().replace(/_/g, '-');
39
+ return replacement ? `${replacement}${rest}` : slugify(rest);
40
+ }
41
+
42
+ // Heading ids: accents become their base letter (é → e) instead of being dropped, and a
43
+ // repeated heading gets a -1, -2, ... suffix (GitHub style). `seen` is scoped to one page.
44
+ export function headingId(text, seen) {
45
+ const base = slugify(String(text).normalize('NFKD').replace(/[\u0300-\u036f]/g, ''));
46
+ const count = seen.get(base) ?? 0;
47
+ seen.set(base, count + 1);
48
+ return count ? `${base}-${count}` : base;
49
+ }
50
+
51
+ const escapeAttr = (value) => String(value).replace(/&/g, '&').replace(/"/g, '"');
52
+ const toPosix = (p) => p.split(path.sep).join('/');
53
+
54
+ // Accepts a `sourceFolders` entry (a folder path, or an options object) and resolves defaults.
55
+ export function normalizeSource(entry, root) {
56
+ const options = typeof entry === 'string' ? { folder: entry } : entry;
57
+ if (!options || typeof options.folder !== 'string' || !options.folder.trim()) {
58
+ fail(`Invalid "markdownSources.sourceFolders" entry ${JSON.stringify(entry)}: expected a folder path or { folder, ... }`);
59
+ }
60
+
61
+ const dir = path.resolve(root, options.folder);
62
+ // The id names the manifest (/generated/<id>.json) and its routes. src/app/markdown/
63
+ // markdown-sources.ts derives it again at runtime from nutin.config.js: keep both rules identical.
64
+ const id = options.routePrefix ?? slugify(path.basename(dir));
65
+ if (!/^[a-z0-9-]+$/.test(id)) {
66
+ fail(`"routePrefix" for "${options.folder}" must only contain lowercase letters, digits and dashes (got "${id}")`);
67
+ }
68
+
69
+ // Convention: a hub named after the folder, e.g. articles/ARTICLES.md.
70
+ const conventionalHub = `${path.basename(dir).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}.md`;
71
+ let hubFiles = options.hubFiles;
72
+ if (hubFiles === undefined) {
73
+ hubFiles = fs.existsSync(path.join(dir, conventionalHub)) ? [conventionalHub] : [];
74
+ } else if (!Array.isArray(hubFiles)) {
75
+ fail(`"hubFiles" for "${options.folder}" must be an array of file names`);
76
+ }
77
+
78
+ const prefixReplacements = options.prefixReplacements ?? [];
79
+ if (!Array.isArray(prefixReplacements) || !prefixReplacements.every((pair) => Array.isArray(pair) && pair.length === 2)) {
80
+ fail(`"prefixReplacements" for "${options.folder}" must be an array of [prefix, replacement] pairs`);
81
+ }
82
+
83
+ if (options.seo !== undefined && typeof options.seo !== 'boolean') {
84
+ fail(`"seo" for "${options.folder}" must be true or false`);
85
+ }
86
+
87
+ if (options.ogImage !== undefined && (typeof options.ogImage !== 'string' || !options.ogImage.trim())) {
88
+ fail(`"ogImage" for "${options.folder}" must be an image path, e.g. "/assets/images/og.jpg"`);
89
+ }
90
+
91
+ let landing = null;
92
+ if (options.landing === true) {
93
+ landing = {};
94
+ } else if (options.landing !== undefined && options.landing !== false) {
95
+ const valid = typeof options.landing === 'object'
96
+ && ['title', 'description'].every((key) => options.landing[key] === undefined || typeof options.landing[key] === 'string');
97
+ if (!valid) fail(`"landing" for "${options.folder}" must be true, false or { title?, description? }`);
98
+ landing = { title: options.landing.title, description: options.landing.description };
99
+ }
100
+
101
+ return {
102
+ folder: options.folder,
103
+ dir,
104
+ id,
105
+ hubFiles,
106
+ sectionInPath: options.sectionInPath === true,
107
+ prefixReplacements,
108
+ // With generateSEOFiles, every page gets prerendered HTML and a sitemap entry unless false.
109
+ seo: options.seo !== false,
110
+ // Default og:image of the folder's SEO pages; a page's frontmatter "ogImage" wins.
111
+ ogImage: options.ogImage,
112
+ // Opt-in: the bare route shows an index of the hub(s) instead of the first page.
113
+ landing,
114
+ // Kept to resolve hub files again inside each language subfolder (see languageSource()).
115
+ hubFilesOption: options.hubFiles,
116
+ conventionalHub,
117
+ };
118
+ }
119
+
120
+ // --- Hub parsing ---
121
+
122
+ function parseHub(source, hubFile, context) {
123
+ const hubPath = path.join(source.dir, hubFile);
124
+ if (!fs.existsSync(hubPath)) fail(`Hub file "${hubFile}" not found in "${source.folder}"`);
125
+
126
+ const raw = fs.readFileSync(hubPath, 'utf8');
127
+ const { data, content } = context.matter(raw);
128
+ // Line numbers in warnings count from the top of the file, frontmatter included.
129
+ const lineOffset = raw.split('\n').length - content.split('\n').length;
130
+ const warnings = [];
131
+ const id = data.slug ?? slugify(path.basename(hubFile, path.extname(hubFile)).replace(/_/g, ' '));
132
+ let title = null;
133
+ const descriptionLines = [];
134
+ const groups = [];
135
+ let currentGroup = null;
136
+ let sawToc = false;
137
+
138
+ content.split('\n').forEach((line, index) => {
139
+ const h1 = line.match(/^#\s+(.+?)\s*$/);
140
+ const h2Toc = line.match(/^##\s+Table of Contents\s*$/i);
141
+ const h3 = line.match(/^###\s+(.+?)\s*$/);
142
+ const item = line.match(/^[-*+]\s+\[(.+?)\]\((.+?)\)\s*$/);
143
+
144
+ if (h1 && title === null) {
145
+ title = h1[1].trim();
146
+ return;
147
+ }
148
+ if (h2Toc) {
149
+ sawToc = true;
150
+ return;
151
+ }
152
+ if (!sawToc) {
153
+ descriptionLines.push(line);
154
+ return;
155
+ }
156
+ if (h3) {
157
+ currentGroup = { id: slugify(h3[1]), title: h3[1].trim(), pages: [] };
158
+ groups.push(currentGroup);
159
+ return;
160
+ }
161
+ if (item) {
162
+ const [, linkTitle, relPath] = item;
163
+ const target = currentGroup ?? (currentGroup = { id: null, title: null, pages: [] });
164
+ if (!groups.includes(target)) groups.push(target);
165
+ target.pages.push({ title: linkTitle.trim(), source: path.normalize(path.join(source.dir, relPath)) });
166
+ return;
167
+ }
168
+ // Anything else that looks like a list item under the TOC isn't a page: say so.
169
+ if (/^\s*(?:[-*+]|\d+[.)])\s+\S/.test(line)) {
170
+ warnings.push(`"${hubFile}" line ${lineOffset + index + 1} is not a page entry ("- [Title](./page.md)") and was ignored: ${line.trim()}`);
171
+ }
172
+ });
173
+
174
+ return {
175
+ id,
176
+ title: data.title ?? title ?? id,
177
+ description: data.description ?? firstProse(descriptionLines.join('\n'), context.Marked),
178
+ groups,
179
+ warnings,
180
+ };
181
+ }
182
+
183
+ // --- Page reading & rendering ---
184
+
185
+ function listMarkdownFiles(dir) {
186
+ const files = [];
187
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
188
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
189
+ const fullPath = path.join(dir, entry.name);
190
+ if (entry.isDirectory()) files.push(...listMarkdownFiles(fullPath));
191
+ else if (/\.md$/i.test(entry.name)) files.push(fullPath);
192
+ }
193
+ return files;
194
+ }
195
+
196
+ function readPage(sourcePath, source, matter) {
197
+ if (!fs.existsSync(sourcePath)) fail(`Page "${path.relative(source.dir, sourcePath)}" listed in "${source.folder}" does not exist`);
198
+ const { data, content } = matter(fs.readFileSync(sourcePath, 'utf8'));
199
+ return { data, body: content };
200
+ }
201
+
202
+ // Plain text of one line of inline Markdown: link/image text, no emphasis or code marks.
203
+ // Derived descriptions end up in <meta name="description"> and nav text.
204
+ export function stripInlineMarkdown(text) {
205
+ return text
206
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
207
+ .replace(/\*\*(.+?)\*\*/g, '$1')
208
+ .replace(/\*(.+?)\*/g, '$1')
209
+ // Underscores only emphasize at word boundaries: snake_case_names stay intact.
210
+ .replace(/(^|\W)__(.+?)__(?=\W|$)/g, '$1$2')
211
+ .replace(/(^|\W)_(.+?)_(?=\W|$)/g, '$1$2')
212
+ .replace(/`([^`]+)`/g, '$1')
213
+ .replace(/\s+/g, ' ')
214
+ .trim();
215
+ }
216
+
217
+ // Plain text of inline Markdown that may also contain HTML tags. Tags inside `code spans`
218
+ // are text, not markup: they are kept.
219
+ export function plainText(text) {
220
+ const withoutTags = String(text)
221
+ .split(/(`[^`]*`)/)
222
+ .map((part, index) => (index % 2 ? part : part.replace(/<[^>]*>/g, ' ')))
223
+ .join('');
224
+ return stripInlineMarkdown(withoutTags);
225
+ }
226
+
227
+ // Plain text of the first prose block of some Markdown: a paragraph, or a list's first item,
228
+ // whichever comes first. Headings, code, tables and HTML blocks are skipped.
229
+ export function firstProse(markdown, Marked) {
230
+ const block = new Marked({ gfm: true }).lexer(markdown).find((token) => token.type === 'paragraph' || token.type === 'list');
231
+ if (!block) return '';
232
+ return plainText(block.type === 'list' ? block.items[0]?.text ?? '' : block.text);
233
+ }
234
+
235
+ // Title = first H1; description = the first paragraph or list item after it.
236
+ function deriveMeta(body, sourcePath, Marked) {
237
+ const lines = body.split('\n');
238
+ const h1Index = lines.findIndex((l) => /^#\s+/.test(l));
239
+ const title = h1Index >= 0 ? lines[h1Index].replace(/^#\s+/, '').trim() : path.basename(sourcePath, path.extname(sourcePath));
240
+ return { title, description: firstProse(lines.slice(h1Index + 1).join('\n'), Marked) };
241
+ }
242
+
243
+ function renderHtml(body, sourcePath, hrefMap, context) {
244
+ const dir = path.dirname(sourcePath);
245
+ const headings = [];
246
+ const seenIds = new Map();
247
+ // Reported after parsing: marked appends "Please report this to marked" to errors
248
+ // thrown from a renderer, and one pass can list every broken link of the page.
249
+ const unresolvedLinks = [];
250
+ const marked = new context.Marked({ gfm: true });
251
+
252
+ marked.use({
253
+ renderer: {
254
+ heading(token) {
255
+ const text = this.parser.parseInline(token.tokens);
256
+ const id = headingId(plainText(token.text), seenIds);
257
+ headings.push({ depth: token.depth, text: plainText(token.text), id });
258
+ return `<h${token.depth} id="${id}">${text}</h${token.depth}>\n`;
259
+ },
260
+ link(token) {
261
+ const text = this.parser.parseInline(token.tokens);
262
+ let href = token.href;
263
+ let isInternal = false;
264
+
265
+ // Relative links to other .md pages become SPA routes.
266
+ if (!/^[a-z]+:/i.test(href) && href.includes('.md')) {
267
+ const [rawPath, fragment] = href.split('#');
268
+ const resolved = path.normalize(path.join(dir, rawPath));
269
+ // fallbackHref: a translation linking to a page it doesn't have points at the
270
+ // default-language page instead (see compileLanguages()).
271
+ const pageHref = hrefMap.get(resolved) ?? context.fallbackHref?.(resolved);
272
+
273
+ if (pageHref) {
274
+ isInternal = true;
275
+ href = fragment ? `${pageHref}#${fragment}` : pageHref;
276
+ } else {
277
+ unresolvedLinks.push(`"${href}" (resolved to "${toPosix(path.relative(context.root, resolved))}")`);
278
+ }
279
+ }
280
+
281
+ const titleAttr = token.title ? ` title="${escapeAttr(token.title)}"` : '';
282
+ // Lets MarkdownContentComponent route the click through the SPA router.
283
+ const navAttrs = isInternal ? ' data-event="click:_navigateTo:@attr:href"' : '';
284
+ return `<a href="${href}"${titleAttr}${navAttrs}>${text}</a>`;
285
+ },
286
+ },
287
+ });
288
+
289
+ const html = marked.parse(body).trim();
290
+ if (unresolvedLinks.length) {
291
+ fail(
292
+ `Unresolvable internal link ${unresolvedLinks.join(', ')} in "${toPosix(path.relative(context.root, sourcePath))}" ` +
293
+ `— ${unresolvedLinks.length > 1 ? 'these are not compiled pages' : 'not a compiled page'}`
294
+ );
295
+ }
296
+ return { html, headings: headings.filter((h) => h.depth > 1) };
297
+ }
298
+
299
+ // --- Section layout (hub mode vs frontmatter mode) ---
300
+
301
+ // Returns [{ id, title, description, groups: [{ id, title, pages: [{ source, data, body, hubTitle? }] }] }]
302
+ function collectHubSections(source, context) {
303
+ const hubBySource = new Map();
304
+ return source.hubFiles.map((hubFile) => {
305
+ const hub = parseHub(source, hubFile, context);
306
+ for (const ref of hub.groups.flatMap((group) => group.pages)) {
307
+ const other = hubBySource.get(ref.source);
308
+ if (other) {
309
+ const page = toPosix(path.relative(context.root, ref.source));
310
+ fail(`"${page}" is listed in ${other === hubFile ? `"${hubFile}" twice` : `both "${other}" and "${hubFile}"`} in "${source.folder}" — a page belongs to one place`);
311
+ }
312
+ hubBySource.set(ref.source, hubFile);
313
+ }
314
+ return {
315
+ ...hub,
316
+ groups: hub.groups.map((group) => ({
317
+ ...group,
318
+ pages: group.pages.map((ref) => ({ source: ref.source, hubTitle: ref.title, ...readPage(ref.source, source, context.matter) })),
319
+ })),
320
+ };
321
+ });
322
+ }
323
+
324
+ function collectFrontmatterSection(source, context) {
325
+ const pages = listMarkdownFiles(source.dir)
326
+ .map((file) => ({ source: file, ...readPage(file, source, context.matter) }))
327
+ .sort((a, b) => {
328
+ const orderA = typeof a.data.order === 'number' ? a.data.order : Infinity;
329
+ const orderB = typeof b.data.order === 'number' ? b.data.order : Infinity;
330
+ if (orderA !== orderB) return orderA < orderB ? -1 : 1;
331
+ return a.source.localeCompare(b.source);
332
+ });
333
+
334
+ // Ungrouped pages first, then groups in order of their first page.
335
+ const groups = [];
336
+ const byId = new Map();
337
+ for (const page of pages) {
338
+ const groupTitle = page.data.group ? String(page.data.group) : subfolderTitle(page.source, source.dir);
339
+ const groupId = groupTitle ? slugify(groupTitle) : null;
340
+ if (!byId.has(groupId)) {
341
+ const group = { id: groupId, title: groupTitle, pages: [] };
342
+ byId.set(groupId, group);
343
+ if (groupId === null) groups.unshift(group);
344
+ else groups.push(group);
345
+ }
346
+ byId.get(groupId).pages.push(page);
347
+ }
348
+
349
+ return [{ id: 'index', title: source.sectionTitle ?? path.basename(source.dir), description: '', groups }];
350
+ }
351
+
352
+ // The navigation group of a page in a subfolder: its first-level folder's title.
353
+ function subfolderTitle(file, dir) {
354
+ const [first, ...rest] = path.relative(dir, file).split(path.sep);
355
+ return rest.length ? folderTitle(first) : null;
356
+ }
357
+
358
+ // A folder name as a group title: a leading order prefix ("01-", "2_", "3.") lets folders be
359
+ // ordered on disk and is dropped, "-" and "_" become spaces, and an all-lowercase name gets
360
+ // a capital first letter ("getting-started" -> "Getting started"; "API" stays "API").
361
+ export function folderTitle(name) {
362
+ const title = name.replace(/^\d+[-_.\s]+(?=\S)/, '').replace(/[-_]+/g, ' ').trim();
363
+ return /[A-Z]/.test(title) ? title : title.charAt(0).toUpperCase() + title.slice(1);
364
+ }
365
+
366
+ // --- Landing ---
367
+
368
+ // The landing's title and description, written into the manifest of a source with "landing".
369
+ // A single section (one hub, or frontmatter mode) provides both; with several sections, the
370
+ // folder's id and an empty description, unless the "landing" option sets them.
371
+ export function landingFor(source, sections) {
372
+ if (!source.landing) return undefined;
373
+ const single = sections.length === 1 ? sections[0] : null;
374
+ return {
375
+ title: source.landing.title ?? single?.title ?? source.id,
376
+ description: source.landing.description ?? single?.description ?? '',
377
+ };
378
+ }
379
+
380
+ // --- Entry point ---
381
+
382
+ export function compileSource(source, context) {
383
+ if (!fs.existsSync(source.dir) || !fs.statSync(source.dir).isDirectory()) {
384
+ fail(`Markdown folder "${source.folder}" not found`);
385
+ }
386
+
387
+ const sections = source.hubFiles.length ? collectHubSections(source, context) : collectFrontmatterSection(source, context);
388
+ const warnings = sections.flatMap((section) => section.warnings ?? []);
389
+
390
+ // Pass 1: slugs + hrefs for every page, so links can be resolved across sections.
391
+ const slugBySource = new Map();
392
+ const sourceBySlug = new Map();
393
+ const hrefMap = new Map();
394
+ for (const section of sections) {
395
+ // linkPrefix: '/<lang>' with i18n, so compiled links (and prerendered HTML) carry the locale.
396
+ const base = `${context.linkPrefix ?? ''}/${source.id}`;
397
+ const prefix = source.sectionInPath ? `${base}/${section.id}` : base;
398
+ for (const group of section.groups) {
399
+ for (const page of group.pages) {
400
+ const slug = page.data.slug ? String(page.data.slug) : slugFromFilename(path.basename(page.source), source.prefixReplacements);
401
+ const owner = sourceBySlug.get(slug);
402
+ if (owner && owner !== page.source) {
403
+ fail(`Duplicate slug "${slug}" in "${source.folder}", produced by both "${toPosix(path.relative(context.root, owner))}" and "${toPosix(path.relative(context.root, page.source))}"`);
404
+ }
405
+ sourceBySlug.set(slug, page.source);
406
+ slugBySource.set(page.source, slug);
407
+ hrefMap.set(page.source, `${prefix}/${slug}`);
408
+ }
409
+ }
410
+ }
411
+
412
+ // Pass 2: render.
413
+ const manifestSections = [];
414
+ const pages = {};
415
+ for (const section of sections) {
416
+ const hasNamedGroups = section.groups.some((g) => g.id);
417
+ manifestSections.push({
418
+ id: section.id,
419
+ title: section.title,
420
+ description: section.description,
421
+ groups: hasNamedGroups
422
+ ? section.groups.map((g) => ({ id: g.id, title: g.title, pages: g.pages.map((p) => slugBySource.get(p.source)) }))
423
+ : undefined,
424
+ pages: hasNamedGroups ? undefined : section.groups.flatMap((g) => g.pages.map((p) => slugBySource.get(p.source))),
425
+ });
426
+
427
+ let order = 0;
428
+ for (const group of section.groups) {
429
+ for (const page of group.pages) {
430
+ const slug = slugBySource.get(page.source);
431
+ const derived = deriveMeta(page.body, page.source, context.Marked);
432
+ const { html, headings } = renderHtml(page.body, page.source, hrefMap, context);
433
+
434
+ if (page.hubTitle && !page.data.title && page.hubTitle !== derived.title) {
435
+ warnings.push(`Title mismatch for "${source.id}/${slug}": hub says "${page.hubTitle}", H1 says "${derived.title}"`);
436
+ }
437
+
438
+ pages[slug] = {
439
+ slug,
440
+ title: page.data.title ?? page.hubTitle ?? derived.title,
441
+ description: page.data.description ?? derived.description,
442
+ section: section.id,
443
+ group: group.id,
444
+ order: order++,
445
+ source: toPosix(path.relative(context.root, page.source)),
446
+ headings,
447
+ html,
448
+ ...(page.data.ogImage ? { ogImage: String(page.data.ogImage) } : {}),
449
+ };
450
+ }
451
+ }
452
+ }
453
+
454
+ // Hub mode only compiles listed pages — flag the ones no hub links to.
455
+ if (source.hubFiles.length) {
456
+ const hubPaths = new Set(source.hubFiles.map((hub) => path.normalize(path.join(source.dir, hub))));
457
+ for (const file of listMarkdownFiles(source.dir)) {
458
+ if (!hubPaths.has(file) && !slugBySource.has(file)) {
459
+ warnings.push(`"${toPosix(path.relative(context.root, file))}" is not listed in any hub file of "${source.folder}" and was skipped`);
460
+ }
461
+ }
462
+ }
463
+
464
+ const landing = landingFor(source, manifestSections);
465
+ return { manifest: { sections: manifestSections, pages, ...(landing ? { landing } : {}) }, warnings, hrefBySource: hrefMap };
466
+ }
467
+
468
+ // --- Languages (i18n) ---
469
+
470
+ const isDirectory = (dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory();
471
+
472
+ // A source is localized when it has a subfolder named after the default language,
473
+ // e.g. markdown-content/en/ (+ markdown-content/fr/, ...).
474
+ export function isLocalized(source, defaultLanguage) {
475
+ return isDirectory(path.join(source.dir, defaultLanguage));
476
+ }
477
+
478
+ // The source restricted to one language subfolder. Hub files are resolved again inside it,
479
+ // and a frontmatter-mode section keeps the source folder's name as its title.
480
+ function languageSource(source, lang) {
481
+ const dir = path.join(source.dir, lang);
482
+ const hubFiles = source.hubFilesOption ?? (fs.existsSync(path.join(dir, source.conventionalHub)) ? [source.conventionalHub] : []);
483
+ return { ...source, dir, folder: `${source.folder}/${lang}`, hubFiles, sectionTitle: path.basename(source.dir) };
484
+ }
485
+
486
+ // One translation laid over the default-language manifest. The default language defines the
487
+ // pages, their order and sections; a translation may only translate them. Missing pages keep
488
+ // the default-language page, flagged `fallback: true`.
489
+ function mergeTranslation(base, own, { source, lang, defaultLanguage }) {
490
+ const baseSectionIds = new Set(base.sections.map((section) => section.id));
491
+ const extraSection = own.sections.find((section) => !baseSectionIds.has(section.id));
492
+ if (extraSection) {
493
+ fail(`Section "${extraSection.id}" exists in "${source.folder}/${lang}" but not in "${source.folder}/${defaultLanguage}" — the default language defines the sections`);
494
+ }
495
+ const extraPage = Object.values(own.pages).find((page) => !base.pages[page.slug]);
496
+ if (extraPage) {
497
+ fail(`"${extraPage.source}" has no "${defaultLanguage}" counterpart (slug "${extraPage.slug}") — add it to "${source.folder}/${defaultLanguage}" too`);
498
+ }
499
+
500
+ // Translated groups are matched by the pages they hold, since their ids come from
501
+ // their (translated) titles.
502
+ const translatedGroupTitle = (ownSection, group) =>
503
+ ownSection?.groups?.find((ownGroup) => ownGroup.pages.some((slug) => group.pages.includes(slug)))?.title ?? group.title;
504
+
505
+ const sections = base.sections.map((section) => {
506
+ const ownSection = own.sections.find((candidate) => candidate.id === section.id);
507
+ return {
508
+ ...section,
509
+ title: ownSection?.title ?? section.title,
510
+ description: ownSection?.description || section.description,
511
+ groups: section.groups?.map((group) => ({ ...group, title: translatedGroupTitle(ownSection, group) })),
512
+ };
513
+ });
514
+
515
+ const pages = {};
516
+ const missing = [];
517
+ for (const [slug, basePage] of Object.entries(base.pages)) {
518
+ const ownPage = own.pages[slug];
519
+ if (ownPage) {
520
+ pages[slug] = { ...ownPage, section: basePage.section, group: basePage.group, order: basePage.order };
521
+ } else {
522
+ pages[slug] = { ...basePage, fallback: true };
523
+ missing.push(slug);
524
+ }
525
+ }
526
+
527
+ const landing = landingFor(source, sections);
528
+ return { manifest: { sections, pages, ...(landing ? { landing } : {}) }, missing };
529
+ }
530
+
531
+ /**
532
+ * Compiles a source for the build. Without i18n: one manifest (a localized folder then
533
+ * only compiles its default language). With i18n: one manifest per language, each with
534
+ * '/<lang>'-prefixed links; a localized folder translates the default language's pages
535
+ * (see mergeTranslation), any other folder serves the same pages to every language.
536
+ * Returns { manifests: { [lang | '']: manifest }, warnings }.
537
+ */
538
+ export function compileLanguages(source, context, { i18n, languages, defaultLanguage }) {
539
+ const localized = isLocalized(source, defaultLanguage);
540
+ const warnings = [];
541
+ const keep = (result, report) => {
542
+ if (report) warnings.push(...result.warnings);
543
+ return result;
544
+ };
545
+
546
+ if (!i18n) {
547
+ const result = keep(compileSource(localized ? languageSource(source, defaultLanguage) : source, context), true);
548
+ return { manifests: { '': result.manifest }, warnings };
549
+ }
550
+
551
+ const manifests = {};
552
+ for (const lang of languages) {
553
+ const langContext = { ...context, linkPrefix: `/${lang}` };
554
+ // The same content is compiled once per language: only the link prefix differs, so its
555
+ // warnings are reported once.
556
+ const base = keep(compileSource(localized ? languageSource(source, defaultLanguage) : source, langContext), lang === languages[0]);
557
+
558
+ if (!localized || lang === defaultLanguage) {
559
+ manifests[lang] = base.manifest;
560
+ continue;
561
+ }
562
+
563
+ const langDir = path.join(source.dir, lang);
564
+ if (!isDirectory(langDir)) {
565
+ manifests[lang] = mergeTranslation(base.manifest, { sections: [], pages: {} }, { source, lang, defaultLanguage }).manifest;
566
+ warnings.push(`"${source.folder}" has no "${lang}" folder — every page shows its "${defaultLanguage}" version`);
567
+ continue;
568
+ }
569
+
570
+ const defaultDir = path.join(source.dir, defaultLanguage);
571
+ const fallbackHref = (resolved) => base.hrefBySource.get(path.join(defaultDir, path.relative(langDir, resolved)));
572
+ const own = keep(compileSource(languageSource(source, lang), { ...langContext, fallbackHref }), true);
573
+ const { manifest, missing } = mergeTranslation(base.manifest, own.manifest, { source, lang, defaultLanguage });
574
+ if (missing.length) {
575
+ warnings.push(`"${source.folder}/${lang}" has no translation for: ${missing.join(', ')} — showing their "${defaultLanguage}" version`);
576
+ }
577
+ manifests[lang] = manifest;
578
+ }
579
+
580
+ return { manifests, warnings };
581
+ }
582
+