@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
@@ -7,90 +7,120 @@ export function escape(str) {
7
7
  .replace(/'/g, ''');
8
8
  }
9
9
 
10
+ // Replacements go through functions: a replacement *string* would expand `$&`, `$'`
11
+ // etc. found in a title/description, copying raw page HTML into the tag.
12
+ // `replacement` receives the match's capture groups.
10
13
  export function upsertInHead(html, pattern, replacement, fullTag) {
11
14
  if (pattern.test(html)) {
12
- return html.replace(pattern, replacement);
15
+ return html.replace(pattern, (_match, ...groups) => replacement(...groups));
13
16
  }
14
- return html.replace('</head>', `\n ${fullTag}\n </head>`);
17
+ return insertBeforeHeadEnd(html, fullTag);
15
18
  }
16
19
 
17
- export function applySubstitutions(template, lang, title, description, pageUrl, ogImage) {
20
+ function insertBeforeHeadEnd(html, tag) {
21
+ return html.replace('</head>', () => `\n ${tag}\n </head>`);
22
+ }
23
+
24
+ export function applySubstitutions(template, lang, title, description, pageUrl, ogImage, hreflangLinks = []) {
18
25
  let html = template;
19
26
 
20
27
  // lang attribute on <html>
21
28
  if (/(<html[^>]*\slang=)["'][^"']*["']/.test(html)) {
22
- html = html.replace(/(<html[^>]*\slang=)["'][^"']*["']/, `$1"${lang}"`);
29
+ html = html.replace(/(<html[^>]*\slang=)["'][^"']*["']/, (_m, start) => `${start}"${escape(lang)}"`);
23
30
  } else {
24
- html = html.replace(/<html/, `<html lang="${lang}"`);
31
+ html = html.replace(/<html/, () => `<html lang="${escape(lang)}"`);
25
32
  }
26
33
 
27
34
  // <title>
28
35
  html = upsertInHead(html,
29
36
  /(<title>)[^<]*(<\/title>)/,
30
- `$1${escape(title)}$2`,
37
+ (start, end) => `${start}${escape(title)}${end}`,
31
38
  `<title>${escape(title)}</title>`
32
39
  );
33
40
 
34
41
  // <meta name="description"> — replace all occurrences, or insert one
35
42
  const descPattern = /(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/;
36
43
  if (descPattern.test(html)) {
37
- html = html.replace(/(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/g, `$1"${escape(description)}"`);
44
+ html = html.replace(/(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/g, (_m, start) => `${start}"${escape(description)}"`);
38
45
  } else {
39
- html = html.replace('</head>', `\n <meta name="description" content="${escape(description)}" />\n </head>`);
46
+ html = insertBeforeHeadEnd(html, `<meta name="description" content="${escape(description)}" />`);
40
47
  }
41
48
 
42
49
  // <link rel="canonical">
43
50
  html = upsertInHead(html,
44
51
  /(<link\s+rel=["']canonical["']\s+href=)["'][^"']*["']/,
45
- `$1"${pageUrl}/"`,
46
- `<link rel="canonical" href="${pageUrl}/" />`
52
+ (start) => `${start}"${escape(pageUrl)}/"`,
53
+ `<link rel="canonical" href="${escape(pageUrl)}/" />`
47
54
  );
48
55
 
49
56
  // og:url
50
57
  html = upsertInHead(html,
51
58
  /(<meta\s+property=["']og:url["']\s+content=)["'][^"']*["']/,
52
- `$1"${pageUrl}/"`,
53
- `<meta property="og:url" content="${pageUrl}/" />`
59
+ (start) => `${start}"${escape(pageUrl)}/"`,
60
+ `<meta property="og:url" content="${escape(pageUrl)}/" />`
54
61
  );
55
62
 
63
+ // hreflang alternates — one per active language plus x-default, so crawlers see
64
+ // these URLs as language variants of the same page rather than duplicate content
65
+ if (hreflangLinks.length > 0) {
66
+ const altTags = hreflangLinks
67
+ .map(({ hreflang, href }) => `<link rel="alternate" hreflang="${escape(hreflang)}" href="${escape(href)}" />`)
68
+ .join('\n ');
69
+ html = insertBeforeHeadEnd(html, altTags);
70
+ }
71
+
56
72
  // og:title
57
73
  html = upsertInHead(html,
58
74
  /(<meta\s+property=["']og:title["']\s+content=)["'][^"']*["']/,
59
- `$1"${escape(title)}"`,
75
+ (start) => `${start}"${escape(title)}"`,
60
76
  `<meta property="og:title" content="${escape(title)}" />`
61
77
  );
62
78
 
63
79
  // og:description
64
80
  html = upsertInHead(html,
65
81
  /(<meta\s+property=["']og:description["']\s+content=)["'][^"']*["']/,
66
- `$1"${escape(description)}"`,
82
+ (start) => `${start}"${escape(description)}"`,
67
83
  `<meta property="og:description" content="${escape(description)}" />`
68
84
  );
69
85
 
70
86
  // twitter:title
71
87
  html = upsertInHead(html,
72
88
  /(<meta\s+name=["']twitter:title["']\s+content=)["'][^"']*["']/,
73
- `$1"${escape(title)}"`,
89
+ (start) => `${start}"${escape(title)}"`,
74
90
  `<meta name="twitter:title" content="${escape(title)}" />`
75
91
  );
76
92
 
77
93
  // twitter:description
78
94
  html = upsertInHead(html,
79
95
  /(<meta\s+name=["']twitter:description["']\s+content=)["'][^"']*["']/,
80
- `$1"${escape(description)}"`,
96
+ (start) => `${start}"${escape(description)}"`,
81
97
  `<meta name="twitter:description" content="${escape(description)}" />`
82
98
  );
83
99
 
100
+ // og:type
101
+ html = upsertInHead(html,
102
+ /(<meta\s+property=["']og:type["']\s+content=)["'][^"']*["']/,
103
+ (start) => `${start}"website"`,
104
+ `<meta property="og:type" content="website" />`
105
+ );
106
+
107
+ // twitter:card — required for Twitter/X to render an image card at all
108
+ html = upsertInHead(html,
109
+ /(<meta\s+name=["']twitter:card["']\s+content=)["'][^"']*["']/,
110
+ (start) => `${start}"summary_large_image"`,
111
+ `<meta name="twitter:card" content="summary_large_image" />`
112
+ );
113
+
84
114
  // og:image + twitter:image — only when explicitly provided in route config
85
115
  if (ogImage) {
86
116
  html = upsertInHead(html,
87
117
  /(<meta\s+property=["']og:image["']\s+content=)["'][^"']*["']/,
88
- `$1"${escape(ogImage)}"`,
118
+ (start) => `${start}"${escape(ogImage)}"`,
89
119
  `<meta property="og:image" content="${escape(ogImage)}" />`
90
120
  );
91
121
  html = upsertInHead(html,
92
122
  /(<meta\s+name=["']twitter:image["']\s+content=)["'][^"']*["']/,
93
- `$1"${escape(ogImage)}"`,
123
+ (start) => `${start}"${escape(ogImage)}"`,
94
124
  `<meta name="twitter:image" content="${escape(ogImage)}" />`
95
125
  );
96
126
  }
@@ -0,0 +1,45 @@
1
+ import { applySubstitutions, upsertInHead } from './html-substitutions.js';
2
+
3
+ const TEMPLATE = '<html lang="en"><head><title>x</title><meta name="description" content="d"></head><body></body></html>';
4
+
5
+ describe('applySubstitutions', () => {
6
+ it('applySubstitutions sets title, description, canonical and og tags', () => {
7
+ const html = applySubstitutions(TEMPLATE, 'fr', 'Home', 'Welcome', 'https://site.test/fr', '/og.png');
8
+ expect(html).toContain('<html lang="fr">');
9
+ expect(html).toContain('<title>Home</title>');
10
+ expect(html).toContain('<meta name="description" content="Welcome">');
11
+ expect(html).toContain('<link rel="canonical" href="https://site.test/fr/" />');
12
+ expect(html).toContain('<meta property="og:image" content="/og.png" />');
13
+ });
14
+
15
+ it('applySubstitutions keeps $ replacement patterns in values literal', () => {
16
+ const title = "Only $' left, $` and $& and $1";
17
+ const html = applySubstitutions(TEMPLATE, 'en', title, 'Save $$ now', 'https://site.test', '');
18
+ expect(html).toContain("<title>Only $&#39; left, $` and $&amp; and $1</title>");
19
+ expect(html).toContain('<meta name="description" content="Save $$ now">');
20
+ expect(html).toContain('<meta property="og:title" content="Only $&#39; left, $` and $&amp; and $1" />');
21
+ expect(html.match(/<html/g).length).toBe(1);
22
+ });
23
+
24
+ it('applySubstitutions escapes markup in title, description and URLs', () => {
25
+ const html = applySubstitutions(TEMPLATE, 'en', '<b>"T"</b>', 'a & b', 'https://site.test/?a=1&b=2', '', [
26
+ { hreflang: 'en', href: 'https://site.test/en?x="y"' },
27
+ ]);
28
+ expect(html).toContain('<title>&lt;b&gt;&quot;T&quot;&lt;/b&gt;</title>');
29
+ expect(html).toContain('content="a &amp; b"');
30
+ expect(html).toContain('href="https://site.test/?a=1&amp;b=2/"');
31
+ expect(html).toContain('href="https://site.test/en?x=&quot;y&quot;"');
32
+ });
33
+
34
+ it('applySubstitutions inserts missing tags before </head>', () => {
35
+ const html = applySubstitutions('<html><head></head><body></body></html>', 'en', 'T $&', 'D', 'https://site.test', '');
36
+ expect(html).toContain('<html lang="en">');
37
+ expect(html).toContain('<title>T $&amp;</title>');
38
+ expect(html).toContain('<meta name="description" content="D" />');
39
+ });
40
+
41
+ it('upsertInHead passes capture groups to the replacement function', () => {
42
+ const html = upsertInHead('<head><title>a</title></head>', /(<title>)[^<]*(<\/title>)/, (start, end) => `${start}$'${end}`, '');
43
+ expect(html).toBe("<head><title>$'</title></head>");
44
+ });
45
+ });
@@ -6,6 +6,19 @@ import { applySubstitutions } from './html-substitutions.js';
6
6
  import { getAppRoutePaths } from './ssr/ssr-render.js';
7
7
  import { builderConfig } from '../../builder.config.js';
8
8
 
9
+ const APP_CONTAINER = /(<([a-zA-Z][\w-]*)\b[^>]*\sid=["']app["'][^>]*>)[\s\S]*?<\/\2>/;
10
+
11
+ // The concrete URL path a seo.json route is prerendered at: `outputPath` when given,
12
+ // otherwise `path` with each `:param` filled from `mockParams` (an unset optional
13
+ // `:param?` segment is dropped). '/' maps to '' so it can be appended to baseUrl.
14
+ export function routeSuffixOf(route) {
15
+ const resolved = route.outputPath ?? route.path.replace(/\/:([^/?]+)\??/g, (_match, name) => {
16
+ const value = route.mockParams?.[name];
17
+ return value === undefined || value === '' ? '' : `/${encodeURIComponent(value)}`;
18
+ });
19
+ return resolved === '/' || resolved === '' ? '' : resolved.replace(/\/$/, '');
20
+ }
21
+
9
22
  export function segmentsOf(routeSuffix) {
10
23
  return routeSuffix ? routeSuffix.split('/').filter(Boolean) : [];
11
24
  }
@@ -29,17 +42,20 @@ export function validateMockParams(route) {
29
42
  }
30
43
  }
31
44
 
32
- export async function writeRouteHtml({ template, lang, title, description, pageUrl, ogImage, body, outputSegments, routePath }) {
33
- let html = applySubstitutions(template, lang, title, description, pageUrl, ogImage);
45
+ export async function writeRouteHtml({ template, lang, title, description, pageUrl, ogImage, body, outputSegments, routePath, hreflangLinks }) {
46
+ let html = applySubstitutions(template, lang, title, description, pageUrl, ogImage, hreflangLinks);
34
47
 
35
48
  if (html === template) {
36
49
  errorExit(`Failed to apply any changes for ${routePath} (lang "${lang}") — index.html may be missing a </head> tag`, 'generate-seo-html');
37
50
  }
38
51
 
39
- html = html.replace(
40
- /<main\s+id=["']app["'][^>]*>[\s\S]*?<\/main>/,
41
- `<main id="app">\n${body}\n </main>`
42
- );
52
+ // Any element can be the #app mount (validate-html.js only checks for id="app"), and
53
+ // its opening tag is kept as authored. The replacement goes through a function so `$&`,
54
+ // `$'`… in the rendered markup stay literal.
55
+ if (!APP_CONTAINER.test(html)) {
56
+ errorExit(`index.html has no closed id="app" container to render ${routePath} into`, 'generate-seo-html');
57
+ }
58
+ html = html.replace(APP_CONTAINER, (_match, openTag, tagName) => `${openTag}\n${body}\n </${tagName}>`);
43
59
 
44
60
  const outputDir = path.join(PATHS.tempSource, ...outputSegments);
45
61
  const outputPath = path.join(outputDir, 'index.html');
@@ -48,9 +64,11 @@ export async function writeRouteHtml({ template, lang, title, description, pageU
48
64
  fs.writeFileSync(outputPath, html, 'utf-8');
49
65
  }
50
66
 
51
- export async function warnForRoutesMissingSeoConfig(bundleUrl, seoRoutes, defaultLanguage, baseUrl) {
67
+ // `optOutPaths`: app route patterns deliberately without SEO pages (e.g. a Markdown source
68
+ // with "seo: false").
69
+ export async function warnForRoutesMissingSeoConfig(bundleUrl, seoRoutes, defaultLanguage, baseUrl, optOutPaths = []) {
52
70
  const appRoutePaths = await getAppRoutePaths(bundleUrl, defaultLanguage, baseUrl);
53
- const seoRoutePaths = new Set(seoRoutes.map((route) => route.path));
71
+ const seoRoutePaths = new Set([...seoRoutes.map((route) => route.path), ...optOutPaths]);
54
72
 
55
73
  for (const path of appRoutePaths) {
56
74
  if (seoRoutePaths.has(path)) continue;
@@ -0,0 +1,31 @@
1
+ import { routeSuffixOf, segmentsOf } from './route-output.js';
2
+
3
+ describe('routeSuffixOf', () => {
4
+ it('maps "/" to an empty suffix and keeps static paths as-is', () => {
5
+ expect(routeSuffixOf({ path: '/' })).toBe('');
6
+ expect(routeSuffixOf({ path: '/about' })).toBe('/about');
7
+ });
8
+
9
+ it('fills dynamic segments from mockParams', () => {
10
+ expect(routeSuffixOf({ path: '/blog/:slug', mockParams: { slug: 'hello-world' } })).toBe('/blog/hello-world');
11
+ });
12
+
13
+ it('drops an unset optional segment', () => {
14
+ expect(routeSuffixOf({ path: '/docs/:page?' })).toBe('/docs');
15
+ });
16
+
17
+ it('encodes param values', () => {
18
+ expect(routeSuffixOf({ path: '/tags/:tag', mockParams: { tag: 'a b' } })).toBe('/tags/a%20b');
19
+ });
20
+
21
+ it('prefers an explicit outputPath', () => {
22
+ expect(routeSuffixOf({ path: '/demo/:page?', outputPath: '/demo/overlays/', mockParams: { page: 'x' } })).toBe('/demo/overlays');
23
+ });
24
+ });
25
+
26
+ describe('segmentsOf', () => {
27
+ it('splits a suffix into directory segments', () => {
28
+ expect(segmentsOf('/blog/hello-world')).toEqual(['blog', 'hello-world']);
29
+ expect(segmentsOf('')).toEqual([]);
30
+ });
31
+ });
@@ -0,0 +1,29 @@
1
+ {{#if markdown}}
2
+ import { markdownSeoRoutes, markdownSeoOptOutPaths } from '../markdown/markdown-seo-routes.js';
3
+ {{/if}}
4
+ import { routeSuffixOf } from './route-output.js';
5
+
6
+ // Appends generated routes to config/seo.json's, skipping any whose output URL a
7
+ // seo.json entry already covers: a hand-written entry always wins.
8
+ export function mergeSeoRoutes(configRoutes, generatedRoutes) {
9
+ const covered = new Set(configRoutes.map(routeSuffixOf));
10
+ return [...configRoutes, ...generatedRoutes.filter((route) => !covered.has(routeSuffixOf(route)))];
11
+ }
12
+
13
+ // Every route to prerender: config/seo.json's, plus those generated by features.
14
+ export function resolveSeoRoutes(seoConfig) {
15
+ let routes = seoConfig.routes ?? [];
16
+ {{#if markdown}}
17
+ routes = mergeSeoRoutes(routes, markdownSeoRoutes());
18
+ {{/if}}
19
+ return routes;
20
+ }
21
+
22
+ // App route patterns deliberately left without SEO pages: no "missing seo.json entry" warning.
23
+ export function resolveSeoOptOutPaths() {
24
+ let paths = [];
25
+ {{#if markdown}}
26
+ paths = [...paths, ...markdownSeoOptOutPaths()];
27
+ {{/if}}
28
+ return paths;
29
+ }
@@ -0,0 +1,21 @@
1
+ import { mergeSeoRoutes } from './seo-routes.js';
2
+
3
+ describe('mergeSeoRoutes', () => {
4
+ it('appends generated routes after the seo.json ones', () => {
5
+ const merged = mergeSeoRoutes([{ path: '/' }], [{ path: '/docs/:slug?', mockParams: { slug: 'intro' } }]);
6
+
7
+ expect(merged.map((route) => route.path)).toEqual(['/', '/docs/:slug?']);
8
+ });
9
+
10
+ it('keeps the seo.json entry when both produce the same URL', () => {
11
+ const fromConfig = { path: '/docs/:slug?', mockParams: { slug: 'intro' }, title: 'Hand-written' };
12
+ const generated = [
13
+ { path: '/docs/:slug?', mockParams: { slug: 'intro' }, title: 'Generated' },
14
+ { path: '/docs/:slug?', mockParams: { slug: 'next' }, title: 'Next' },
15
+ ];
16
+
17
+ const merged = mergeSeoRoutes([fromConfig], generated);
18
+
19
+ expect(merged.map((route) => route.title)).toEqual(['Hand-written', 'Next']);
20
+ });
21
+ });
@@ -4,14 +4,15 @@ import path from 'path';
4
4
  import { pathToFileURL } from 'url';
5
5
  import { PATHS } from '../../app/paths.js';
6
6
  import { builderConfig } from '../../../builder.config.js';
7
+ import { sharedDefine } from '../../prod-bundle/bundle-options.js';
7
8
 
8
9
  const SSR_DIR = path.join(PATHS.temp, '.ssr');
9
- const ENTRY_FILE = path.join(PATHS.tempApp, '__ssr-entry.ts');
10
+ const ENTRY_FILE = path.join(PATHS.tempApp, '__ssr-entry.{{#if (eq lang "js")}}js{{else}}ts{{/if}}');
10
11
  const OUT_FILE = path.join(SSR_DIR, 'ssr-bundle.mjs');
11
12
 
12
13
  const ENTRY_CONTENT = `export { appRoutes } from './routes.js';
13
- export { I18nService, Service, registerPipes } from '../core/index.js';
14
- export { RouteGuardsManager } from '../core/services/router/helpers/route-guard-manager.helper.js';
14
+ export { I18nService, Service, SecurityHelper, registerPipes } from '../core/index.js';
15
+ export * as RouteGuardsManager from '../core/services/router/helpers/route-guard-manager.helper.js';
15
16
  `;
16
17
 
17
18
  /**
@@ -28,16 +29,14 @@ export async function buildSsrBundle() {
28
29
  ...builderConfig.esbuild,
29
30
  platform: 'node',
30
31
  format: 'esm',
31
- target: ['node22'],
32
+ target: ['node24'],
32
33
  minify: false,
33
34
  drop: [],
34
35
  legalComments: 'none',
35
36
  loader: {
36
37
  '.json': 'json',
37
38
  },
38
- define: {
39
- 'process.env.NODE_ENV': '"production"',
40
- },
39
+ define: sharedDefine({ ...builderConfig, isProd: true }),
41
40
  entryPoints: [ENTRY_FILE],
42
41
  outfile: OUT_FILE,
43
42
  keepNames: true,
@@ -3,7 +3,7 @@ import path from 'path';
3
3
 
4
4
  /**
5
5
  * Installs the minimum browser globals the framework's own base classes touch
6
- * unconditionally (Service's `beforeunload` listener, I18n's `navigator`/`window.location`
6
+ * unconditionally (the Router's `popstate` listener, I18n's `navigator`/`window.location`
7
7
  * reads), plus a couple of cheap defensive stubs (matchMedia) for common patterns
8
8
  * downstream projects add (e.g. dark-mode toggles).
9
9
  *
@@ -52,7 +52,7 @@ function createMemoryStorage() {
52
52
  };
53
53
  }
54
54
 
55
- export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}) {
55
+ export function installGlobals(window, { lang, mockFetch = {}, localesDir, staticDir } = {}) {
56
56
  for (const key of Object.getOwnPropertyNames(window)) {
57
57
  setGlobal(globalThis, key, window[key]);
58
58
  }
@@ -90,7 +90,7 @@ export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}
90
90
 
91
91
  const fetchShim = (input) => {
92
92
  const url = typeof input === 'string' ? input : input?.url;
93
- const promise = resolveFetch(url, { mockFetch, localesDir });
93
+ const promise = resolveFetch(url, { mockFetch, localesDir, staticDir });
94
94
  trackedFetches.push(promise);
95
95
  return promise;
96
96
  };
@@ -100,7 +100,7 @@ export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}
100
100
  return { trackedFetches };
101
101
  }
102
102
 
103
- async function resolveFetch(url, { mockFetch, localesDir }) {
103
+ export async function resolveFetch(url, { mockFetch = {}, localesDir, staticDir } = {}) {
104
104
  if (Object.prototype.hasOwnProperty.call(mockFetch, url)) {
105
105
  return jsonResponse(mockFetch[url]);
106
106
  }
@@ -116,12 +116,41 @@ async function resolveFetch(url, { mockFetch, localesDir }) {
116
116
  return jsonResponse({});
117
117
  }
118
118
 
119
+ const staticFile = resolveStaticFile(url, staticDir);
120
+ if (staticFile) {
121
+ const text = fs.readFileSync(staticFile, 'utf-8');
122
+ return staticFile.endsWith('.json') ? jsonResponse(JSON.parse(text)) : textResponse(text);
123
+ }
124
+
119
125
  throw new Error(
120
126
  `Unexpected fetch("${url}") during SSR — no "mockFetch" entry for this endpoint and it isn't a ` +
121
- `/locales/*.json request. Add a "mockFetch" entry for this route in config/seo.json if this is intentional.`
127
+ `/locales/*.json request or a file built into dist. Add a "mockFetch" entry for this route in config/seo.json if this is intentional.`
122
128
  );
123
129
  }
124
130
 
131
+ // A root-relative URL naming a file the build already wrote (e.g. /generated/<id>.json,
132
+ // fetched by the markdown feature's route guards). Paths escaping staticDir don't count.
133
+ function resolveStaticFile(url, staticDir) {
134
+ if (!staticDir || typeof url !== 'string' || !url.startsWith('/')) return null;
135
+ const root = path.resolve(staticDir);
136
+ try {
137
+ const filePath = path.resolve(root, '.' + decodeURIComponent(url.split(/[?#]/)[0]));
138
+ if (!filePath.startsWith(root + path.sep)) return null;
139
+ return fs.statSync(filePath).isFile() ? filePath : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function textResponse(body) {
146
+ return {
147
+ ok: true,
148
+ status: 200,
149
+ json: async () => JSON.parse(body),
150
+ text: async () => body,
151
+ };
152
+ }
153
+
125
154
  function jsonResponse(body) {
126
155
  return {
127
156
  ok: true,
@@ -0,0 +1,48 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { resolveFetch } from './ssr-polyfills.js';
5
+
6
+ describe('resolveFetch (SSR fetch shim)', () => {
7
+ let staticDir;
8
+
9
+ beforeEach(() => {
10
+ staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nutin-ssr-fetch-'));
11
+ fs.mkdirSync(path.join(staticDir, 'generated'));
12
+ fs.writeFileSync(path.join(staticDir, 'generated', 'docs.json'), '{"pages":{"a":1}}');
13
+ fs.writeFileSync(path.join(staticDir, 'notes.txt'), 'plain text');
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(staticDir, { recursive: true, force: true });
18
+ });
19
+
20
+ it('serves a mockFetch entry first', async () => {
21
+ const response = await resolveFetch('/generated/docs.json', { mockFetch: { '/generated/docs.json': { mocked: true } }, staticDir });
22
+
23
+ expect(await response.json()).toEqual({ mocked: true });
24
+ });
25
+
26
+ it('serves a file the build wrote, parsed as JSON for .json', async () => {
27
+ const response = await resolveFetch('/generated/docs.json?v=1', { staticDir });
28
+
29
+ expect(response.ok).toBeTruthy();
30
+ expect(await response.json()).toEqual({ pages: { a: 1 } });
31
+ });
32
+
33
+ it('serves other built files as text', async () => {
34
+ const response = await resolveFetch('/notes.txt', { staticDir });
35
+
36
+ expect(await response.text()).toBe('plain text');
37
+ });
38
+
39
+ it('refuses paths escaping the build folder', async () => {
40
+ await expect(() => resolveFetch('/../../etc/hosts', { staticDir })).toThrow('Unexpected fetch');
41
+ await expect(() => resolveFetch('/%2e%2e/%2e%2e/etc/hosts', { staticDir })).toThrow('Unexpected fetch');
42
+ });
43
+
44
+ it('still throws for an unknown endpoint', async () => {
45
+ await expect(() => resolveFetch('/api/users', { staticDir })).toThrow('Unexpected fetch');
46
+ await expect(() => resolveFetch('https://api.test/users', { staticDir })).toThrow('Unexpected fetch');
47
+ });
48
+ });
@@ -15,24 +15,28 @@ export async function getAppRoutePaths(bundleUrl, lang, pageUrl) {
15
15
  // window.location.pathname, so a location-less window crashes as soon as the
16
16
  // bundle's module-load side effects instantiate I18n.getInstance().
17
17
  const { window } = parseHTML('<!doctype html><html><body></body></html>', { location: new URL(pageUrl) });
18
- installGlobals(window, { lang });
18
+ installGlobals(window, { lang, localesDir: path.join(PATHS.tempSource, 'locales') });
19
19
  const bundle = await import(`${bundleUrl}?ssr=routes-check`);
20
20
  return Object.keys(bundle.appRoutes);
21
21
  }
22
22
 
23
23
  /**
24
- * Renders one (route, lang) pair by instantiating the real View via appRoutes and calling
25
- * .render(), returning the resulting real markup. Fresh linkedom window + cache-busted
24
+ * Renders one (route, lang) pair by running the route's guards, then instantiating the real
25
+ * View via appRoutes and calling .render(). Returns `{ html }`, or `{ blocked, redirectTo }`
26
+ * when a guard refuses the route. Fresh linkedom window + cache-busted
26
27
  * dynamic import per call, so no singleton state (I18nService, etc.) leaks between renders.
27
28
  */
28
29
  export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFetch, lang, pageUrl, i18nEnabled }) {
29
30
  // Passing a real URL instance as `location` gives `.pathname`/`.href`
30
31
  // which is what I18n's getLocaleFromUrl() actually reads.
31
- const { window } = parseHTML('<!doctype html><html><body></body></html>', { location: new URL(pageUrl) });
32
+ // <main id="app"> mirrors index.html's real mount target — View defaults to mounting
33
+ // there (view.ts), so the rendered tree is attached to `document` as in the browser.
34
+ const { window } = parseHTML('<!doctype html><html><body><main id="app"></main></body></html>', { location: new URL(pageUrl) });
32
35
  const { trackedFetches } = installGlobals(window, {
33
36
  lang,
34
37
  mockFetch: mockFetch ?? {},
35
38
  localesDir: path.join(PATHS.tempSource, 'locales'),
39
+ staticDir: PATHS.tempSource,
36
40
  });
37
41
 
38
42
  let Service;
@@ -41,7 +45,7 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
41
45
  try {
42
46
  const bundle = await import(`${bundleUrl}?ssr=${renderIndex++}`);
43
47
  ({ Service } = bundle);
44
- const { appRoutes, I18nService, RouteGuardsManager, registerPipes } = bundle;
48
+ const { appRoutes, I18nService, RouteGuardsManager, SecurityHelper, registerPipes } = bundle;
45
49
 
46
50
  const routeConfig = appRoutes[appRoutesKey];
47
51
  if (!routeConfig) {
@@ -55,8 +59,17 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
55
59
 
56
60
  if (i18nEnabled) await I18nService.setCurrentLanguage(lang);
57
61
 
58
- const viewConstructor = RouteGuardsManager.getViewConstructor(routeConfig);
59
- const view = viewConstructor();
62
+ // The route's guards run as they would for an anonymous first visit (empty storage,
63
+ // no session): a blocked or redirected route must not ship as public static HTML.
64
+ // Running them in this bundle instance also keeps what loader guards fetched (e.g.
65
+ // a Markdown manifest) available to the view rendered below.
66
+ const guardResult = await RouteGuardsManager.processRouteGuards(routeConfig, pageUrl, mockParams ?? {});
67
+ if (!guardResult.allowed) {
68
+ return { blocked: true, redirectTo: guardResult.redirectTo };
69
+ }
70
+
71
+ // A lazy route's factory returns a Promise (its import() is inlined in this bundle).
72
+ const view = await guardResult.viewConstructor();
60
73
  constructorName = view.constructor.name;
61
74
 
62
75
  if (mockParams) view.setRouteParams(mockParams);
@@ -66,7 +79,11 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
66
79
  await Promise.all(trackedFetches);
67
80
  await Promise.resolve();
68
81
 
69
- return element.outerHTML;
82
+ // Nothing binds a prerendered page (the app re-renders it on load): Nutin's binding
83
+ // attributes (data-event, data-i18n, ...) are dropped, on the DOM so text is untouched.
84
+ SecurityHelper.stripBindingsFrom(element);
85
+
86
+ return { html: element.outerHTML };
70
87
  } catch (err) {
71
88
  throw new Error(
72
89
  `[ssr] Failed to render route "${appRoutesKey}" (lang "${lang}", view "${constructorName}"): ${err.message}${hintForError(err)}`,
@@ -88,7 +105,7 @@ function hintForError(err) {
88
105
  return (
89
106
  `\n Hint: this usually means the component tree touches an unguarded browser global ` +
90
107
  `(e.g. "window", "navigator", "matchMedia") that isn't polyfilled for SSR — see ` +
91
- `tools/builder/core/ssr/ssr-polyfills.js.`
108
+ `tools/builder/core/seo/ssr/ssr-polyfills.js.`
92
109
  );
93
110
  }
94
111
  return '';
@@ -1,7 +1,7 @@
1
1
  import * as sass from 'sass';
2
2
  import * as fs from 'fs';
3
3
  import path from 'path';
4
- import { getFilesRecursive, print, errorExit } from '../../../utils/index.js';
4
+ import { getFilesRecursive, errorExit } from '../../../utils/index.js';
5
5
  import { PATHS } from '../app/paths.js';
6
6
  import { builderConfig } from '../../builder.config.js';
7
7
 
@@ -13,24 +13,26 @@ const scssOrigins = builderConfig.sass.paths;
13
13
  const scssPath = (origin) => path.join(stylesPath, origin);
14
14
  const stylesInput = scssOrigins.map(origin => scssPath(origin));
15
15
  const pathsToLoad = [ ...stylesInput ];
16
+ // resolves `@use "pkg:<package>/<subpath>"` through node_modules (package exports)
17
+ const importers = [ new sass.NodePackageImporter() ];
16
18
 
17
19
  try {
18
20
  const mainResult = await sass.compileAsync(path.join(stylesPath, 'main.scss'), {
19
21
  loadPaths: [ ...pathsToLoad ],
22
+ importers,
20
23
  style: 'compressed'
21
24
  });
22
25
  fs.writeFileSync(stylesOutput, mainResult.css);
23
26
 
24
27
  // features styles
25
28
  const appInput = path.join(PATHS.source, 'app');
26
- const appStyles = [
27
- ...getFilesRecursive(path.join(appInput, 'components'), 'scss'),
28
- ...getFilesRecursive(path.join(appInput, 'views'), 'scss')
29
- ];
29
+ // Partials (_name.scss) only exist to be @use'd — compiling them standalone would duplicate their CSS.
30
+ const appStyles = getFilesRecursive(appInput, 'scss').filter((file) => !path.basename(file).startsWith('_'));
30
31
 
31
32
  for (const style of appStyles) {
32
33
  const result = await sass.compileAsync(style, {
33
34
  loadPaths: [ ...pathsToLoad ],
35
+ importers,
34
36
  style: 'compressed'
35
37
  })
36
38
  fs.appendFileSync(stylesOutput, result.css);