@voltro/cli 0.52.0 → 0.54.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 (244) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  4. package/dist/agentsMd-DCY1RSs8.js +2 -0
  5. package/dist/apiBuild-CeUN55uk.js +2 -0
  6. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D4ygSbnV.js +843 -0
  9. package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
  10. package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
  11. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  12. package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
  13. package/dist/codegen-DjgxEOnD.js +2 -0
  14. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  15. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
  16. package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
  17. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
  18. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
  19. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
  20. package/dist/dbCommand-CSFWs9ev.js +2 -0
  21. package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
  22. package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
  23. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
  24. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  25. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
  26. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  27. package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
  28. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
  29. package/dist/fileConventions-l-RIXbx8.js +36 -0
  30. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  34. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +2 -2
  38. package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
  39. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  40. package/dist/inspect-CuoDInfZ.js +2 -0
  41. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  42. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  43. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  44. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
  45. package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
  46. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  47. package/dist/mobileCommand-DAum7tsG.js +2 -0
  48. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
  49. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
  50. package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
  51. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  52. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  53. package/dist/renderModeScan-43yQ2opo.js +147 -0
  54. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
  55. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  56. package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
  57. package/dist/serveCommand-BiPe8BJm.js +2 -0
  58. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
  59. package/dist/serveEntry.js +1 -1
  60. package/dist/start-B0bnJgxI.js +3 -0
  61. package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
  62. package/dist/startEntry.js +1 -1
  63. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
  64. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  65. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  66. package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
  67. package/dist/updateCommand-CIoVDKnj.js +2 -0
  68. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
  69. package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
  70. package/dist/webDev-DlvZO30c.js +2 -0
  71. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
  72. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  73. package/package.json +60 -19
  74. package/templates/AGENTS.core.md +2 -0
  75. package/templates/AGENTS.md +8 -4
  76. package/templates/agent-docs/_index.md +6 -4
  77. package/templates/agent-docs/_manifest.json +21 -5
  78. package/templates/agent-docs/ai.md +6 -6
  79. package/templates/agent-docs/authentication.md +73 -1
  80. package/templates/agent-docs/cli.md +6 -3
  81. package/templates/agent-docs/configuration.md +17 -0
  82. package/templates/agent-docs/data.md +522 -29
  83. package/templates/agent-docs/database/advancedqueries.md +8 -8
  84. package/templates/agent-docs/database/columntypes.md +2 -2
  85. package/templates/agent-docs/database/migrations.md +1 -1
  86. package/templates/agent-docs/database/querying.md +1 -1
  87. package/templates/agent-docs/database/schema.md +1 -1
  88. package/templates/agent-docs/database/seedsdialects.md +65 -3
  89. package/templates/agent-docs/database/transactions.md +3 -3
  90. package/templates/agent-docs/deployment.md +8 -0
  91. package/templates/agent-docs/internationalization.md +4 -2
  92. package/templates/agent-docs/introduction.md +32 -1
  93. package/templates/agent-docs/local-first-mobile.md +226 -30
  94. package/templates/agent-docs/observability.md +5 -1
  95. package/templates/agent-docs/plugins/atlassian.md +2 -2
  96. package/templates/agent-docs/plugins/audit.md +2 -2
  97. package/templates/agent-docs/plugins/auth.md +1 -1
  98. package/templates/agent-docs/plugins/billing.md +1 -1
  99. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  100. package/templates/agent-docs/plugins/comments.md +164 -0
  101. package/templates/agent-docs/plugins/notifications.md +47 -4
  102. package/templates/agent-docs/plugins/presence.md +45 -3
  103. package/templates/agent-docs/plugins/prometheus.md +3 -1
  104. package/templates/agent-docs/plugins/queue.md +172 -0
  105. package/templates/agent-docs/plugins.md +17 -13
  106. package/templates/agent-docs/reference.md +35 -4
  107. package/templates/agent-docs/routing.md +585 -7
  108. package/templates/agent-docs/scheduling.md +1 -1
  109. package/templates/agent-docs/schema-driven-ui.md +226 -4
  110. package/templates/agent-docs/security.md +3 -3
  111. package/templates/agent-docs/templates/appshells.md +36 -4
  112. package/templates/agent-docs/whats-new.md +134 -66
  113. package/templates/apps/api-ai/package.json +6 -6
  114. package/templates/apps/api-auth/package.json +8 -8
  115. package/templates/apps/api-backend/package.json +7 -7
  116. package/templates/apps/api-backend-deactivation/package.json +7 -7
  117. package/templates/apps/api-backend-mail/package.json +8 -8
  118. package/templates/apps/api-backend-mariadb/package.json +9 -9
  119. package/templates/apps/api-backend-sqlite/package.json +8 -8
  120. package/templates/apps/api-backend-storage/package.json +8 -8
  121. package/templates/apps/api-cms/package.json +9 -9
  122. package/templates/apps/api-collab/README.md +3 -3
  123. package/templates/apps/api-collab/app.config.ts +1 -1
  124. package/templates/apps/api-collab/database/schema.ts +12 -8
  125. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  126. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  127. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  128. package/templates/apps/api-collab/package.json +8 -8
  129. package/templates/apps/api-collab/template.json +1 -1
  130. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  131. package/templates/apps/api-data-advanced/package.json +8 -8
  132. package/templates/apps/api-durable/package.json +8 -8
  133. package/templates/apps/api-feature-flags/package.json +9 -9
  134. package/templates/apps/api-governance/package.json +8 -8
  135. package/templates/apps/api-kv/package.json +8 -8
  136. package/templates/apps/api-moderation/package.json +8 -8
  137. package/templates/apps/api-observability/package.json +8 -8
  138. package/templates/apps/api-ratelimit/package.json +8 -8
  139. package/templates/apps/api-rbac/package.json +8 -8
  140. package/templates/apps/api-rest/package.json +7 -7
  141. package/templates/apps/api-row-history/package.json +8 -8
  142. package/templates/apps/api-saas/package.json +11 -10
  143. package/templates/apps/api-saas-starter/package.json +10 -10
  144. package/templates/apps/api-search/package.json +8 -8
  145. package/templates/apps/api-status/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/app.config.ts +26 -2
  148. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  149. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  150. package/templates/apps/changelog/package.json +9 -8
  151. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  152. package/templates/apps/changelog/src/globals.d.ts +1 -1
  153. package/templates/apps/changelog/src/locales/de.ts +1 -1
  154. package/templates/apps/changelog/src/locales/en.ts +1 -1
  155. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  156. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  157. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  158. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  159. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  160. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  161. package/templates/apps/edge-functions/package.json +2 -2
  162. package/templates/apps/frontend-admin/package.json +8 -8
  163. package/templates/apps/frontend-app/package.json +9 -9
  164. package/templates/apps/frontend-auth/package.json +8 -8
  165. package/templates/apps/frontend-blank/package.json +7 -7
  166. package/templates/apps/frontend-cms/package.json +9 -9
  167. package/templates/apps/frontend-collab/README.md +43 -24
  168. package/templates/apps/frontend-collab/app.config.ts +3 -3
  169. package/templates/apps/frontend-collab/package.json +14 -10
  170. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  171. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  172. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  173. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  174. package/templates/apps/frontend-collab/template.json +2 -2
  175. package/templates/apps/frontend-contact/package.json +7 -7
  176. package/templates/apps/frontend-dashboard/package.json +7 -7
  177. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  178. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  179. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  180. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  181. package/templates/apps/frontend-docs/package.json +9 -6
  182. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  183. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  184. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  185. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  186. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  187. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  188. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  189. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  190. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  191. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  192. package/templates/apps/frontend-i18n/package.json +6 -6
  193. package/templates/apps/frontend-landing/README.md +48 -0
  194. package/templates/apps/frontend-landing/app.config.ts +28 -0
  195. package/templates/apps/frontend-landing/package.json +7 -6
  196. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  197. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  198. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  199. package/templates/apps/frontend-landing/src/globals.css +15 -0
  200. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  201. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  202. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  203. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  204. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  205. package/templates/apps/frontend-landing/template.json +2 -2
  206. package/templates/apps/frontend-portal/package.json +8 -8
  207. package/templates/apps/frontend-saas/package.json +8 -8
  208. package/templates/apps/frontend-spa/package.json +7 -7
  209. package/templates/apps/frontend-ssr/package.json +7 -7
  210. package/templates/apps/frontend-ssr-api/package.json +8 -8
  211. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  212. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  213. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  214. package/templates/apps/frontend-static-blog/package.json +9 -6
  215. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  216. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  217. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  218. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  219. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  220. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  221. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  222. package/templates/apps/frontend-status/package.json +8 -8
  223. package/templates/apps/mobile-app/package.json +12 -11
  224. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  225. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  226. package/dist/agentsMd-SDDSkyl4.js +0 -2
  227. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  228. package/dist/build-CPgcMQug.js +0 -793
  229. package/dist/codegen-CctkDO-1.js +0 -2
  230. package/dist/codegenCommand-DCdG2JN-.js +0 -137
  231. package/dist/dbCommand-DNb6yeOG.js +0 -2
  232. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  233. package/dist/fileConventions-DOqD3lPS.js +0 -34
  234. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  235. package/dist/inspect-CuGDYES0.js +0 -2
  236. package/dist/manifestBuild-CPjhvM62.js +0 -2
  237. package/dist/renderModeScan-CcH2X1_D.js +0 -120
  238. package/dist/serveCommand-DLc-BznW.js +0 -2
  239. package/dist/start-DfL3fOiN.js +0 -3
  240. package/dist/updateCommand-5gFVfK5q.js +0 -2
  241. package/dist/webDev-CZbTsDcH.js +0 -2
  242. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  243. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  244. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -10,8 +10,11 @@ export default defineCatalog({
10
10
  'nav.home': 'Home',
11
11
  'nav.language': 'Language',
12
12
 
13
- 'hero.tagline': 'Replace this hero copy with your value prop.',
14
- 'hero.cta': 'Learn more →',
13
+ 'hero.tagline': 'Replace this hero copy with your value prop.',
14
+ 'hero.cta': 'Learn more →',
15
+ // `alt` is an attribute, so the page reads this with `useT` (a string), not
16
+ // <T> (an element). Decorative-only images take alt="" instead.
17
+ 'hero.imageAlt': 'Abstract gradient artwork',
15
18
 
16
19
  'features.heading': 'Features',
17
20
  'features.reactive': '<strong>Reactive subscriptions.</strong> Server state streams to clients via WebSocket.',
@@ -10,6 +10,15 @@
10
10
  // @voltro/i18n (<T>); we mock it to resolve against the real catalog for a
11
11
  // controllable "current locale". The hero <h1> carries a `{{capProjectName}}`
12
12
  // scaffold placeholder, so we assert the heading EXISTS, not its text.
13
+ // 3. the hero image renders as a <picture> with the pipeline's variants.
14
+ //
15
+ // WHY THE `?image` IMPORT IS MOCKED. `voltro test` runs vitest WITHOUT the
16
+ // CLI's image plugin (it is a `voltro dev` / `voltro build` transform), so a
17
+ // `?image` specifier resolves to a plain URL STRING under the unit runner —
18
+ // <Image> would then fall back to the loader path and warn about the missing
19
+ // intrinsic size. Substituting the asset OBJECT the pipeline produces keeps the
20
+ // page code idiomatic (no hand-written width/height) and makes the <picture> /
21
+ // blur / srcSet assertions below mean what they say.
13
22
 
14
23
  import { afterEach, describe, expect, test, vi } from 'vitest'
15
24
  import { act, createElement, type ReactNode } from 'react'
@@ -37,8 +46,27 @@ vi.mock('@voltro/i18n', () => ({
37
46
  c,
38
47
  T: ({ id, values }: { id: string; values?: Record<string, unknown> }) =>
39
48
  fill(catalogs[currentLocale]?.[id] ?? id, values),
49
+ // The string form, for message ids used as ATTRIBUTES (the image's `alt`).
50
+ useT: (id: string, values?: Record<string, unknown>): string =>
51
+ fill(catalogs[currentLocale]?.[id] ?? id, values),
40
52
  }))
41
53
 
54
+ // What the `?image` pipeline hands <Image> at build time, in miniature: two
55
+ // modern-format sources, a same-family fallback srcSet, the intrinsic size read
56
+ // off the file, and the inlined blur placeholder.
57
+ const HERO_ASSET = {
58
+ src: '/assets/hero.abc123.jpeg',
59
+ width: 1200,
60
+ height: 630,
61
+ blurDataURL: 'data:image/jpeg;base64,BLUR',
62
+ srcSet: '/assets/hero.640.jpeg 640w, /assets/hero.abc123.jpeg 1200w',
63
+ sources: [
64
+ { type: 'image/avif', srcSet: '/assets/hero.640.avif 640w, /assets/hero.1200.avif 1200w' },
65
+ { type: 'image/webp', srcSet: '/assets/hero.640.webp 640w, /assets/hero.1200.webp 1200w' },
66
+ ],
67
+ } as const
68
+ vi.mock('../assets/hero.jpg?image', () => ({ default: HERO_ASSET }))
69
+
42
70
  const { default: Index, meta, renderMode, interactive } = await import('./page')
43
71
 
44
72
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@@ -108,3 +136,54 @@ describe('index — localized render', () => {
108
136
  expect(container.textContent).toContain('Bereit loszulegen?')
109
137
  })
110
138
  })
139
+
140
+ describe('index — the hero image + the self-hosted font', () => {
141
+ test('renders a <picture> with one <source> per modern format, avif first', () => {
142
+ render(createElement(Index))
143
+ const picture = container.querySelector('picture')
144
+ expect(picture).toBeTruthy()
145
+ const types = Array.from(picture!.querySelectorAll('source')).map((s) => s.getAttribute('type'))
146
+ expect(types).toEqual(['image/avif', 'image/webp'])
147
+ })
148
+
149
+ test('the <img> carries the fallback srcSet + the intrinsic size off the asset', () => {
150
+ render(createElement(Index))
151
+ const img = container.querySelector('picture img') as HTMLImageElement
152
+ // width/height are NOT props on the page — they come off the imported
153
+ // asset, which is what reserves the box (CLS) without hand-written numbers.
154
+ expect(img.getAttribute('width')).toBe('1200')
155
+ expect(img.getAttribute('height')).toBe('630')
156
+ expect(img.getAttribute('srcset')).toBe(HERO_ASSET.srcSet)
157
+ expect(img.getAttribute('src')).toBe(HERO_ASSET.src)
158
+ })
159
+
160
+ test('priority makes it the LCP image: eager, not lazy', () => {
161
+ render(createElement(Index))
162
+ const img = container.querySelector('picture img') as HTMLImageElement
163
+ expect(img.getAttribute('loading')).toBe('eager')
164
+ expect(img.getAttribute('fetchpriority')).toBe('high')
165
+ })
166
+
167
+ test('the blur placeholder is painted behind it until it loads', () => {
168
+ render(createElement(Index))
169
+ const img = container.querySelector('picture img') as HTMLImageElement
170
+ expect(img.style.backgroundImage).toContain(HERO_ASSET.blurDataURL)
171
+ })
172
+
173
+ test('alt text is localized — it goes through useT, not <T>', () => {
174
+ currentLocale = 'de'
175
+ render(createElement(Index))
176
+ const img = container.querySelector('picture img') as HTMLImageElement
177
+ expect(img.getAttribute('alt')).toBe(de['hero.imageAlt'])
178
+ expect(img.getAttribute('alt')).not.toBe(en['hero.imageAlt'])
179
+ })
180
+
181
+ test('the hero heading references the declared family through its CSS variable', () => {
182
+ render(createElement(Index))
183
+ // `localFont('Geist')` maps the family declared in app.config.ts `fonts:`
184
+ // to the variable the generated shell defines. Asserting the VARIABLE (not
185
+ // a literal 'Geist') is the point: the slug rule stays the pipeline's.
186
+ const h1 = container.querySelector('main h1') as HTMLElement
187
+ expect(h1.style.fontFamily).toBe('var(--font-geist)')
188
+ })
189
+ })
@@ -8,9 +8,17 @@
8
8
  // so each variant's <title> / <description> is localised in the emitted HTML.
9
9
 
10
10
  import type { ReactNode } from 'react'
11
- import type { PageMeta } from '@voltro/web'
12
- import { T } from '@voltro/i18n'
11
+ import { Image, localFont, type PageMeta } from '@voltro/web'
12
+ import { T, useT } from '@voltro/i18n'
13
13
  import { getCatalog } from '../lib/locale'
14
+ // The `?image` suffix opts this import into the BUILD-TIME pipeline: instead of
15
+ // vite's plain hashed URL it resolves to an optimized-asset object — AVIF+WebP
16
+ // variants at every ladder width up to the intrinsic 1200px plus a jpeg
17
+ // fallback, the intrinsic width/height, and an inlined 16px blur data URI.
18
+ // `voltro build` encodes them into dist/assets/ under content hashes (through a
19
+ // persistent cache, so a rebuild re-encodes nothing); `voltro dev` transforms on
20
+ // demand. Nothing here requires JS on the page — it renders as a <picture>.
21
+ import hero from '../assets/hero.jpg?image'
14
22
 
15
23
  // Pre-render at build time. Removes the runtime dependency entirely
16
24
  // for plain-content pages — no rpc client, no react hydration. If you
@@ -32,14 +40,29 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
32
40
  const strong = (chunks: ReactNode): ReactNode => <strong>{chunks}</strong>
33
41
  const code = (chunks: ReactNode): ReactNode => <code>{chunks}</code>
34
42
 
43
+ // The typed way to name a family declared in app.config.ts `fonts:` from TSX.
44
+ // It is a pure NAME MAPPER — `{ variable: '--font-geist', fontFamily:
45
+ // 'var(--font-geist)' }` — so it never has to be kept in sync with the slug
46
+ // rule by hand. src/globals.css points the kit's `--font-sans` at the same
47
+ // variable app-wide; this is the per-component form.
48
+ const brand = localFont('Geist')
49
+
35
50
  export default function Index(): ReactNode {
51
+ // `alt` is an ATTRIBUTE, not children, so it needs a plain string — that is
52
+ // what `useT` is for; `<T>` returns an element and cannot be used here.
53
+ const heroAlt = useT('hero.imageAlt')
36
54
  return (
37
55
  <main>
38
56
  <section>
39
- <h1>{{capProjectName}}</h1>
57
+ <h1 style={{ fontFamily: brand.fontFamily }}>{{capProjectName}}</h1>
40
58
  <p>
41
59
  <T id="hero.tagline" />
42
60
  </p>
61
+ {/* `priority` marks this the LCP image: eager load + high fetch
62
+ * priority instead of the lazy default. width/height/blur are NOT
63
+ * hand-written — they come off the imported asset, which is what
64
+ * reserves the box and keeps CLS at zero. */}
65
+ <Image src={hero} alt={heroAlt} priority sizes="(max-width: 900px) 100vw, 900px" />
43
66
  <a href="#features">
44
67
  <T id="hero.cta" />
45
68
  </a>
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "frontend-landing",
3
3
  "kind": "web",
4
- "summary": "Bilingual (en/de) marketing landing page: hero, features grid, CTA. Static-render mode with zero JS on the wire; URL-prefix i18n (`/` + `/de`) pre-renders one HTML file per locale. Flip interactive: 'islands' for interactive pieces.",
5
- "tags": ["web", "frontend", "landing", "marketing", "static", "i18n", "intl", "localization"]
4
+ "summary": "Bilingual (en/de) marketing landing page: hero, features grid, CTA. Static-render mode with zero JS on the wire; URL-prefix i18n (`/` + `/de`) pre-renders one HTML file per locale. Ships the asset pipelines a marketing page actually needs: a local hero image through `?image` + `<Image>` (AVIF/WebP srcSet, inferred dimensions, blur placeholder) and a self-hosted woff2 declared under `fonts:` (hashed, size-adjusted fallback face, preload — no font CDN). Flip interactive: 'islands' for interactive pieces.",
5
+ "tags": ["web", "frontend", "landing", "marketing", "static", "i18n", "intl", "localization", "images", "fonts", "assets"]
6
6
  }
@@ -12,18 +12,18 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@{{projectName}}/api": "0.52.0",
16
- "@voltro/cli": "0.52.0",
17
- "@voltro/client": "0.52.0",
18
- "@voltro/env": "0.52.0",
19
- "@voltro/i18n": "0.52.0",
20
- "@voltro/ui-shadcn": "0.52.0",
21
- "@voltro/web": "0.52.0",
15
+ "@{{projectName}}/api": "0.54.0",
16
+ "@voltro/cli": "0.54.0",
17
+ "@voltro/client": "0.54.0",
18
+ "@voltro/env": "0.54.0",
19
+ "@voltro/i18n": "0.54.0",
20
+ "@voltro/ui-shadcn": "0.54.0",
21
+ "@voltro/web": "0.54.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/devtools": "0.52.0",
26
+ "@voltro/devtools": "0.54.0",
27
27
  "@types/react": "^19.0.0",
28
28
  "@types/react-dom": "^19.0.0",
29
29
  "jsdom": "^30.0.0",
@@ -12,18 +12,18 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@{{projectName}}/api": "0.52.0",
16
- "@voltro/cli": "0.52.0",
17
- "@voltro/client": "0.52.0",
18
- "@voltro/env": "0.52.0",
19
- "@voltro/i18n": "0.52.0",
20
- "@voltro/ui-shadcn": "0.52.0",
21
- "@voltro/web": "0.52.0",
15
+ "@{{projectName}}/api": "0.54.0",
16
+ "@voltro/cli": "0.54.0",
17
+ "@voltro/client": "0.54.0",
18
+ "@voltro/env": "0.54.0",
19
+ "@voltro/i18n": "0.54.0",
20
+ "@voltro/ui-shadcn": "0.54.0",
21
+ "@voltro/web": "0.54.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/devtools": "0.52.0",
26
+ "@voltro/devtools": "0.54.0",
27
27
  "@types/react": "^19.0.0",
28
28
  "@types/react-dom": "^19.0.0",
29
29
  "jsdom": "^30.0.0",
@@ -12,17 +12,17 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.52.0",
16
- "@voltro/client": "0.52.0",
17
- "@voltro/env": "0.52.0",
18
- "@voltro/i18n": "0.52.0",
19
- "@voltro/ui-shadcn": "0.52.0",
20
- "@voltro/web": "0.52.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/env": "0.54.0",
18
+ "@voltro/i18n": "0.54.0",
19
+ "@voltro/ui-shadcn": "0.54.0",
20
+ "@voltro/web": "0.54.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.52.0",
25
+ "@voltro/devtools": "0.54.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "jsdom": "^30.0.0",
@@ -12,17 +12,17 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.52.0",
16
- "@voltro/client": "0.52.0",
17
- "@voltro/env": "0.52.0",
18
- "@voltro/i18n": "0.52.0",
19
- "@voltro/ui-shadcn": "0.52.0",
20
- "@voltro/web": "0.52.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/env": "0.54.0",
18
+ "@voltro/i18n": "0.54.0",
19
+ "@voltro/ui-shadcn": "0.54.0",
20
+ "@voltro/web": "0.54.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.52.0",
25
+ "@voltro/devtools": "0.54.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "jsdom": "^30.0.0",
@@ -12,18 +12,18 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@{{projectName}}/api": "0.52.0",
16
- "@voltro/cli": "0.52.0",
17
- "@voltro/client": "0.52.0",
18
- "@voltro/env": "0.52.0",
19
- "@voltro/i18n": "0.52.0",
20
- "@voltro/ui-shadcn": "0.52.0",
21
- "@voltro/web": "0.52.0",
15
+ "@{{projectName}}/api": "0.54.0",
16
+ "@voltro/cli": "0.54.0",
17
+ "@voltro/client": "0.54.0",
18
+ "@voltro/env": "0.54.0",
19
+ "@voltro/i18n": "0.54.0",
20
+ "@voltro/ui-shadcn": "0.54.0",
21
+ "@voltro/web": "0.54.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/devtools": "0.52.0",
26
+ "@voltro/devtools": "0.54.0",
27
27
  "@types/react": "^19.0.0",
28
28
  "@types/react-dom": "^19.0.0",
29
29
  "jsdom": "^30.0.0",
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: From CMS to static pages with getStaticPaths
3
+ date: 2025-02-03
4
+ excerpt: Enumerate your content once; the build emits one pre-rendered page per entry.
5
+ readingMinutes: 3
6
+ ---
7
+
8
+ A dynamic route like `src/pages/blog/[slug].tsx` matches infinitely many URLs. To pre-render it statically, the build needs to know WHICH slugs exist — that is what `getStaticPaths` answers.
9
+
10
+ In this template, `getStaticPaths` maps over the `posts` array in `src/content/posts.ts`. Point it at a CMS API, a database query, or a folder of markdown files instead, and the build will pre-render exactly those pages — the Next.js "static generation from a content source" pattern, with no extra config.
11
+
12
+ URLs you do not enumerate simply are not built. If a page ALSO sets `renderMode = 'ssr' | 'isr'`, `voltro start` renders the un-enumerated ones on demand; a pure `'static'` page 404s for unknown slugs, which is exactly what you want for a fixed content set.
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: Why this blog ships zero JavaScript
3
+ date: 2025-01-15
4
+ excerpt: Every page is pre-rendered at build time, so the browser downloads HTML and nothing else.
5
+ readingMinutes: 3
6
+ ---
7
+
8
+ This page was rendered ONCE, when you ran `voltro build`. The result is a flat `dist/blog/hello-static/index.html` that any CDN can serve in single-digit milliseconds.
9
+
10
+ There is no server rendering per request, no React hydration on the list page, no rpc client on the wire. The list page even declares `interactive = 'none'`, which strips every `<script>` the framework would otherwise emit.
11
+
12
+ When you DO need a sprinkle of interactivity — a reading-progress bar, a copy button, a comment box — you reach for an island instead of hydrating the whole tree. Read the next post for how that works.
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: Islands: hydrate the 5% that moves
3
+ date: 2025-01-22
4
+ excerpt: A static page with one interactive widget should ship one widget of JavaScript — not the whole page.
5
+ readingMinutes: 4
6
+ ---
7
+
8
+ Look at the top of this page: a thin progress bar tracks how far you have scrolled. That bar is an ISLAND — a single component the framework hydrates inside otherwise-static HTML.
9
+
10
+ This page declares `interactive = 'islands'`, so the framework renders the article once and only attaches React to the `island()`-wrapped component. The surrounding HTML has zero React lifecycle running.
11
+
12
+ Each island picks WHEN it wakes up via a hydrate strategy: `load` (immediately), `idle`, `visible` (on scroll-into-view), or `interaction` (on first click). The progress bar uses `load` because it must track scrolling from the very first frame.
13
+
14
+ The lesson: don't default to `islands` because it sounds fast. Use it when most of the page is static and only a few widgets move. A dashboard where everything is interactive wants `full` hydration instead.
@@ -12,16 +12,19 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.52.0",
16
- "@voltro/client": "0.52.0",
17
- "@voltro/env": "0.52.0",
18
- "@voltro/i18n": "0.52.0",
19
- "@voltro/web": "0.52.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/content": "0.54.0",
18
+ "@voltro/env": "0.54.0",
19
+ "@voltro/i18n": "0.54.0",
20
+ "@voltro/ui-shadcn": "0.54.0",
21
+ "@voltro/web": "0.54.0",
22
+ "effect": "^3.22.0",
20
23
  "react": "^19.0.0",
21
24
  "react-dom": "^19.0.0"
22
25
  },
23
26
  "devDependencies": {
24
- "@voltro/devtools": "0.52.0",
27
+ "@voltro/devtools": "0.54.0",
25
28
  "@types/react": "^19.0.0",
26
29
  "@types/react-dom": "^19.0.0",
27
30
  "jsdom": "^30.0.0",
@@ -0,0 +1,27 @@
1
+ // The blog's content collection — content/posts/**/*.md through the
2
+ // framework's content pipeline (@voltro/content). Frontmatter is
3
+ // schema-checked at build time (a violation FAILS the build naming the file),
4
+ // bodies render to HTML with syntax-highlighted code fences, and no markdown
5
+ // engine ships to the browser: SSG reads the filesystem; SPA navigations
6
+ // fetch the build-emitted JSON artifacts under /assets/content/posts/.
7
+ //
8
+ // Point a CMS/DB-backed site at @voltro/cms instead — files are versioned
9
+ // DEVELOPER content, a CMS is editorial content.
10
+
11
+ import { Schema } from 'effect'
12
+ import { defineCollection } from '@voltro/content'
13
+
14
+ export const posts = defineCollection({
15
+ name: 'posts',
16
+ directory: 'content/posts',
17
+ schema: Schema.Struct({
18
+ title: Schema.String,
19
+ /** ISO date, shown + used for ordering. */
20
+ date: Schema.String,
21
+ /** One-line summary — feeds the list + `<meta name="description">`. */
22
+ excerpt: Schema.String,
23
+ readingMinutes: Schema.Union(Schema.NumberFromString, Schema.Number),
24
+ }),
25
+ })
26
+
27
+ export type Post = Schema.Schema.Type<typeof posts.schema>
@@ -7,14 +7,17 @@
7
7
  // once per non-default locale). `voltro build` produces one HTML file per
8
8
  // pair, e.g. /de/blog/hello-static.
9
9
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../../lib/locale'
10
- import { posts } from '../../../../content/posts'
10
+ import { getCollection } from '@voltro/content'
11
+ import { posts } from '../../../../collections/posts.collection'
11
12
 
12
13
  export { default } from '../../../blog/[slug]/page'
13
14
  export { renderMode, interactive, loader, meta } from '../../../blog/[slug]/page'
14
15
 
15
16
  export const getStaticPaths = async (): Promise<
16
17
  Array<{ params: { locale: string; slug: string } }>
17
- > =>
18
- SUPPORTED_LOCALES
18
+ > => {
19
+ const entries = await getCollection(posts.name)
20
+ return SUPPORTED_LOCALES
19
21
  .filter((l) => l !== DEFAULT_LOCALE)
20
- .flatMap((locale) => posts.map((post) => ({ params: { locale, slug: post.slug } })))
22
+ .flatMap((locale) => entries.map((post) => ({ params: { locale, slug: post.slug } })))
23
+ }
@@ -8,7 +8,13 @@
8
8
 
9
9
  import { describe, expect, test, vi } from 'vitest'
10
10
  import type { ComponentType } from 'react'
11
- import { posts } from '../../content/posts'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { setContentRoot, getCollection } from '@voltro/content'
14
+ import { posts as postsCollection } from '../../collections/posts.collection'
15
+
16
+ setContentRoot(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'))
17
+ const posts = await getCollection(postsCollection.name)
12
18
 
13
19
  // The mirrors transitively import the base pages, which import @voltro/web
14
20
  // (island / loader / notFound / useLocation via lib/locale) and @voltro/i18n.
@@ -10,10 +10,16 @@
10
10
  import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
11
11
  import { act, createElement, type ComponentType, type ReactNode } from 'react'
12
12
  import { createRoot, type Root } from 'react-dom/client'
13
- import { posts, type Post } from '../../../content/posts'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { setContentRoot, getCollection, type ContentEntry } from '@voltro/content'
16
+ import { posts as postsCollection, type Post } from '../../../collections/posts.collection'
14
17
  import en from '../../../locales/en'
15
18
 
16
- const loaderData = vi.fn<() => Post>()
19
+ // Real content: the collection reads this app's content/posts/*.md.
20
+ setContentRoot(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..'))
21
+
22
+ const loaderData = vi.fn<() => ContentEntry<Post>>()
17
23
 
18
24
  const fill = (msg: string, values?: Record<string, unknown>): string =>
19
25
  values ? msg.replace(/\{(\w+)\}/g, (_, k: string) => String(values[k] ?? `{${k}}`)) : msg
@@ -48,6 +54,7 @@ vi.mock('@voltro/i18n', () => ({
48
54
  }))
49
55
 
50
56
  const { default: BlogPost, getStaticPaths, loader, meta } = await import('./page')
57
+ const posts = await getCollection<Post>(postsCollection.name)
51
58
 
52
59
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
53
60
 
@@ -76,6 +83,7 @@ afterEach(() => {
76
83
  describe('blog [slug] — getStaticPaths', () => {
77
84
  test('enumerates one path per post', async () => {
78
85
  const paths = await getStaticPaths()
86
+ expect(posts.length).toBeGreaterThan(0)
79
87
  expect(paths.map((p) => p.params.slug)).toEqual(posts.map((p) => p.slug))
80
88
  })
81
89
  })
@@ -84,7 +92,9 @@ describe('blog [slug] — loader', () => {
84
92
  test('returns the matching post for a known slug', async () => {
85
93
  const post = await loader({ params: { slug: 'hello-static' } } as never)
86
94
  expect(post.slug).toBe('hello-static')
87
- expect(post.title).toContain('zero JavaScript')
95
+ expect(post.data.title).toContain('zero JavaScript')
96
+ // The loader returns RENDERED html — the component never sees markdown.
97
+ expect(post.html).toContain('<p>')
88
98
  })
89
99
 
90
100
  test('throws notFound for an unknown slug', async () => {
@@ -98,8 +108,8 @@ describe('blog [slug] — meta', () => {
98
108
  test('builds a per-post title + description from the loader data', () => {
99
109
  const post = posts[0]!
100
110
  const m = meta({ loaderData: post })
101
- expect(m.title).toContain(post.title)
102
- expect(m.description).toBe(post.excerpt)
111
+ expect(m.title).toContain(post.data.title)
112
+ expect(m.description).toBe(post.data.excerpt)
103
113
  })
104
114
  })
105
115
 
@@ -108,9 +118,9 @@ describe('blog [slug] — render', () => {
108
118
  const post = posts[1]!
109
119
  loaderData.mockReturnValue(post)
110
120
  render(createElement(BlogPost))
111
- expect(container.querySelector('article h1')?.textContent).toBe(post.title)
112
- expect(container.textContent).toContain(`${post.readingMinutes} min read`)
113
- const paras = post.body.split('\n\n')
114
- expect(container.querySelectorAll('article p').length).toBeGreaterThanOrEqual(paras.length)
121
+ expect(container.querySelector('article h1')?.textContent).toBe(post.data.title)
122
+ expect(container.textContent).toContain(`${post.data.readingMinutes} min read`)
123
+ // The body arrives as rendered html paragraphs.
124
+ expect(container.querySelectorAll('article div p').length).toBeGreaterThanOrEqual(2)
115
125
  })
116
126
  })
@@ -1,9 +1,11 @@
1
1
  // A dynamic route — `/blog/<slug>` — pre-rendered STATICALLY, one HTML
2
2
  // file per post. Three exports make that work:
3
3
  //
4
- // • getStaticPaths → enumerate which slugs to build (from the content
5
- // source; point it at a CMS/DB in a real app).
6
- // • loader → fetch THIS post's data; runs at build per slug.
4
+ // • getStaticPaths → enumerate which slugs to build (from the `posts`
5
+ // content collection one .md file per post).
6
+ // • loader → fetch THIS post's entry; runs at build per slug and
7
+ // returns the RENDERED html (code fences highlighted
8
+ // server-side; nothing markdown-shaped in the bundle).
7
9
  // • meta(loaderData)→ per-post <title>/<description> baked into the HTML.
8
10
  //
9
11
  // `interactive: 'islands'` hydrates ONLY the <ReadingProgress> island; the
@@ -11,8 +13,9 @@
11
13
 
12
14
  import type { ReactNode } from 'react'
13
15
  import { useLoaderData, notFound, type LoaderFn, type PageMeta } from '@voltro/web'
16
+ import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
14
17
  import { T, useLocale } from '@voltro/i18n'
15
- import { posts, type Post } from '../../../content/posts'
18
+ import { posts, type Post } from '../../../collections/posts.collection'
16
19
  import { withLocalePrefix } from '../../../lib/locale'
17
20
  import ReadingProgress from '../../../components/ReadingProgress.island'
18
21
 
@@ -22,14 +25,13 @@ export const interactive = 'islands' as const
22
25
  // Which concrete paths to pre-render. Every returned `params` becomes one
23
26
  // built HTML file; un-enumerated slugs are simply not built (→ 404).
24
27
  export const getStaticPaths = async (): Promise<Array<{ params: { slug: string } }>> =>
25
- posts.map((post) => ({ params: { slug: post.slug } }))
28
+ (await getCollection(posts.name)).map((post) => ({ params: { slug: post.slug } }))
26
29
 
27
30
  // Runs at build time for each enumerated slug. Returning `notFound()` skips
28
- // the artifact at build / 404s at runtime — defensive, though getStaticPaths
29
- // only ever feeds us slugs that exist.
30
- export const loader: LoaderFn<Post> = async ({ params }) => {
31
- const post = posts.find((p) => p.slug === params.slug)
32
- if (!post) return notFound(`post ${params.slug}`)
31
+ // the artifact at build / 404s at runtime.
32
+ export const loader: LoaderFn<ContentEntry<Post>> = async ({ params }) => {
33
+ const post = await getEntry<Post>(posts.name, params.slug)
34
+ if (post === null) return notFound(`post ${params.slug}`)
33
35
  return post
34
36
  }
35
37
 
@@ -37,15 +39,13 @@ export const loader: LoaderFn<Post> = async ({ params }) => {
37
39
  // pre-rendered HTML (great for SEO + social cards). The title/description are
38
40
  // POST CONTENT, not chrome, so they are the same on `/blog/x` and `/de/blog/x`
39
41
  // — no per-locale variant needed here.
40
- export const meta = ({ loaderData }: { loaderData: Post }): PageMeta => ({
41
- title: `${loaderData.title} — {{capProjectName}}`,
42
- description: loaderData.excerpt,
42
+ export const meta = ({ loaderData }: { loaderData: ContentEntry<Post> }): PageMeta => ({
43
+ title: `${loaderData.data.title} — {{capProjectName}}`,
44
+ description: loaderData.data.excerpt,
43
45
  })
44
46
 
45
47
  export default function BlogPost(): ReactNode {
46
- const post = useLoaderData<Post>()
47
- // The active locale (from the layout's URL provider) keeps the "all posts"
48
- // link on the current language.
48
+ const post = useLoaderData<ContentEntry<Post>>()
49
49
  const locale = useLocale()
50
50
  return (
51
51
  <main>
@@ -56,13 +56,11 @@ export default function BlogPost(): ReactNode {
56
56
  </a>
57
57
  </p>
58
58
  <article>
59
- <h1>{post.title}</h1>
59
+ <h1>{post.data.title}</h1>
60
60
  <p className="muted">
61
- {post.date} · <T id="post.readingTimeRead" values={{ minutes: post.readingMinutes }} />
61
+ {post.data.date} · <T id="post.readingTimeRead" values={{ minutes: post.data.readingMinutes }} />
62
62
  </p>
63
- {post.body.split('\n\n').map((para, i) => (
64
- <p key={i}>{para}</p>
65
- ))}
63
+ <div dangerouslySetInnerHTML={{ __html: post.html ?? '' }} />
66
64
  </article>
67
65
  </main>
68
66
  )