@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
@@ -3,31 +3,37 @@
3
3
  // `/` — the collaborative editor, driven through `makeVoltroTestClient`
4
4
  // (`@voltro/testing/client`) rather than a hand-rolled `vi.mock('@voltro/client')`.
5
5
  //
6
- // WHY THE HARNESS HERE and not a mock. This page is stateful: `useCrdtText`
7
- // owns a sync client per document cell, seeded from the server body and
8
- // folding every INCOMING body back in. A `vi.mock` can return one snapshot per
9
- // mount, so the fold — the whole point of the page — was untestable, and the
10
- // suite that replaced this one never pushed a second value into a mounted
11
- // tree. `setSubscription` is a real server delta, so the last case below
12
- // asserts convergence.
6
+ // WHY THE HARNESS HERE and not a mock. This page is stateful: `useCrdtDoc` owns
7
+ // a sync client AND a live CRDT document per cell, seeded from the server body
8
+ // and folding every INCOMING body back in. A `vi.mock` can return one snapshot
9
+ // per mount, so the fold — the whole point of the page — would be untestable.
10
+ // `setSubscription` is a real server delta, so the convergence case below is
11
+ // the real thing.
13
12
  //
14
- // Two other things fall out of it: the real `@voltro/client` hooks run (so a
15
- // change in their loading/error semantics is caught here instead of being
16
- // re-implemented by the mock), and the page can be imported statically —
17
- // `await import('./page')` only existed to sequence around `vi.mock`'s hoist.
13
+ // `@voltro/local-first` is NOT faked either: the real `useCrdtDoc` +
14
+ // `useCrdtEditor` run, so what mounts is a real Tiptap editor over a real Y.Doc.
18
15
  //
19
- // `@voltro/local-first` is NOT faked either: the real `useCrdtText` hook
20
- // decodes the seeded body and turns keystrokes into encoded updates (via its
21
- // `crdtTextEdit` span diff), which is the behaviour worth asserting.
16
+ // THE BOUNDARY, stated rather than hidden: these cases drive the page from the
17
+ // SERVER side (rows arriving) and from the user's side only as far as clicking.
18
+ // They do not type into ProseMirror — reaching the mounted editor instance from
19
+ // outside the component would mean poking at ProseMirror internals, and a test
20
+ // that does that asserts the editor library, not this page. The local-edit push
21
+ // path and its echo guard are covered where they live, against the hook:
22
+ // `useCrdtDoc.test.tsx` in `@voltro/local-first`. What IS asserted here is the
23
+ // half a page can get wrong on its own — that a streamed body reaches the
24
+ // editor, and that folding one produces no write back.
22
25
 
23
26
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
24
27
  import { act, createElement, type ReactNode } from 'react'
25
28
  import { createRoot, type Root } from 'react-dom/client'
29
+ import { Editor } from '@tiptap/react'
30
+ import StarterKit from '@tiptap/starter-kit'
31
+ import Collaboration from '@tiptap/extension-collaboration'
26
32
  import { I18nProvider } from '@voltro/i18n'
27
- import { crdtText, type CrdtState } from '@voltro/local-first'
33
+ import { defaultCrdtBackend, type CrdtState } from '@voltro/local-first'
28
34
  import { makeVoltroTestClient, type VoltroTestClient } from '@voltro/testing/client'
29
35
  import enCatalog from '../locales/en'
30
- import IndexPage from './page'
36
+ import IndexPage, { renderMode } from './page'
31
37
 
32
38
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
33
39
 
@@ -37,13 +43,29 @@ let container: HTMLDivElement
37
43
  let root: Root
38
44
  let client: VoltroTestClient
39
45
 
40
- /** A CRDT state holding `text`, from a fresh document. */
41
- const bodyWith = (text: string): CrdtState => crdtText().insert(0, text).encode()
42
-
43
- /** A REMOTE edit derived from `base`, so merging it back converges instead of
44
- * interleaving two independently-created runs of the same characters. */
45
- const remoteEditOf = (base: CrdtState, at: number, insert: string): CrdtState =>
46
- crdtText().merge(base).insert(at, insert).encode()
46
+ /**
47
+ * An authoritative body holding `html`, authored the way the app authors one:
48
+ * through a Tiptap editor over a CRDT document. It has to be — the editor reads
49
+ * the document's XML fragment, so a state written by a plain text handle would
50
+ * decode fine and render as an EMPTY editor.
51
+ *
52
+ * `base` continues an existing document, which is what makes a second call
53
+ * converge with the first instead of interleaving two independent runs of the
54
+ * same characters.
55
+ */
56
+ const bodyWith = (html: string, base?: CrdtState): CrdtState => {
57
+ const handle = defaultCrdtBackend.createDoc(base)
58
+ const editor = new Editor({
59
+ extensions: [
60
+ StarterKit.configure({ undoRedo: false }),
61
+ Collaboration.configure({ document: handle.raw as never }),
62
+ ],
63
+ })
64
+ editor.commands.setContent(html)
65
+ const state = handle.encodeState()
66
+ editor.destroy()
67
+ return state
68
+ }
47
69
 
48
70
  const docRow = (over: Partial<{ id: string; title: string; body: CrdtState | null }> = {}) => ({
49
71
  id: over.id ?? 'doc_1',
@@ -69,15 +91,8 @@ const render = (): void => {
69
91
  })
70
92
  }
71
93
 
72
- const setTextarea = (el: HTMLTextAreaElement, value: string): void => {
73
- const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!
74
- act(() => {
75
- setter.call(el, value)
76
- el.dispatchEvent(new Event('input', { bubbles: true }))
77
- })
78
- }
79
-
80
- const textarea = (): HTMLTextAreaElement => container.querySelector('textarea') as HTMLTextAreaElement
94
+ /** The editor's contenteditable surface, once `useCrdtDoc` has built its doc. */
95
+ const surface = (): HTMLElement => container.querySelector('.ProseMirror') as HTMLElement
81
96
 
82
97
  /** The harness with both writes wired. Handlers return void; the assertions
83
98
  * read `client.calls`, which records tag + input in order. */
@@ -98,13 +113,22 @@ afterEach(() => {
98
113
  document.body.innerHTML = ''
99
114
  })
100
115
 
116
+ describe('collab editor — the page contract', () => {
117
+ test('renders in the browser only — a rich-text editor cannot be prerendered', () => {
118
+ // Pinned because the failure is a BUILD failure with an unhelpful message:
119
+ // `renderMode` defaults to 'static', and Tiptap's `new Editor()` has no
120
+ // `window` during prerender.
121
+ expect(renderMode).toBe('client')
122
+ })
123
+ })
124
+
101
125
  describe('collab editor — subscription states', () => {
102
126
  test('shows the loading hint before the first snapshot', () => {
103
127
  // A tag with NO fixture is the harness's cold state — the real thing a
104
128
  // component sees before the first delta arrives.
105
129
  render()
106
130
  expect(container.textContent).toContain('loading…')
107
- expect(container.querySelector('textarea')).toBeNull()
131
+ expect(surface()).toBeNull()
108
132
  })
109
133
 
110
134
  test('shows the empty state + create button once a snapshot arrives with no documents', () => {
@@ -112,7 +136,7 @@ describe('collab editor — subscription states', () => {
112
136
  render()
113
137
  expect(container.textContent).toContain('No document yet')
114
138
  expect(container.querySelector('button')).not.toBeNull()
115
- expect(container.querySelector('textarea')).toBeNull()
139
+ expect(surface()).toBeNull()
116
140
  })
117
141
 
118
142
  test('renders the error banner when the subscription errors', () => {
@@ -138,58 +162,30 @@ describe('collab editor — create', () => {
138
162
  })
139
163
 
140
164
  describe('collab editor — editing a document', () => {
141
- test('binds the textarea to the decoded CRDT body', () => {
142
- client = harness({ [LIST]: [docRow({ body: bodyWith('Hello') })] })
165
+ test('mounts an editable surface bound to the streamed CRDT body', () => {
166
+ client = harness({ [LIST]: [docRow({ body: bodyWith('<p>Hello</p>') })] })
143
167
  render()
144
- expect(textarea()).not.toBeNull()
145
- expect(textarea().value).toBe('Hello')
168
+ expect(surface()).not.toBeNull()
169
+ expect(surface().getAttribute('contenteditable')).toBe('true')
170
+ expect(surface().textContent).toContain('Hello')
146
171
  expect(container.textContent).toContain('Shared document')
147
172
  })
148
173
 
149
- test('typing sends an encoded CRDT update via documents.setBody', () => {
150
- client = harness({ [LIST]: [docRow({ body: bodyWith('Hello') })] })
151
- render()
152
- setTextarea(textarea(), 'Hello!')
153
-
154
- expect(client.calls).toHaveLength(1)
155
- const call = client.calls[0]!
156
- expect(call.tag).toBe('documents.setBody')
157
- const input = call.input as { id: string; update: unknown }
158
- expect(input.id).toBe('doc_1')
159
- expect(input.update).toBeInstanceOf(Uint8Array)
160
- })
161
-
162
- // The case the hand-rolled mock could not express, and the page's whole
163
- // reason to exist: a body edited in ANOTHER tab arrives as a delta, and the
164
- // local handle folds it in without remounting the editor.
165
- test('a remote edit pushed by the server converges into the open editor', () => {
166
- const base = bodyWith('Hello')
174
+ // The page's whole reason to exist: a body edited in ANOTHER tab arrives as a
175
+ // delta and folds into the OPEN editor without remounting it.
176
+ test('a remote edit pushed by the server converges into the open editor, and is not echoed back', () => {
177
+ const base = bodyWith('<p>Hello</p>')
167
178
  client = harness({ [LIST]: [docRow({ body: base })] })
168
179
  render()
169
- expect(textarea().value).toBe('Hello')
180
+ expect(surface().textContent).toContain('Hello')
170
181
 
171
182
  act(() => {
172
- client.setSubscription(LIST, [docRow({ body: remoteEditOf(base, 5, ' world') })])
183
+ client.setSubscription(LIST, [docRow({ body: bodyWith('<p>Hello world</p>', base) })])
173
184
  })
174
185
 
175
- expect(textarea().value).toBe('Hello world')
176
- // A fold is not a write nothing is echoed back to the server.
186
+ expect(surface().textContent).toContain('Hello world')
187
+ // A fold is NOT a write. Without the hook's echo guard this is one
188
+ // `documents.setBody` per fold, per open tab.
177
189
  expect(client.calls).toEqual([])
178
190
  })
179
-
180
- test('a local edit survives a concurrent remote edit — both are in the merged text', () => {
181
- const base = bodyWith('Hello')
182
- client = harness({ [LIST]: [docRow({ body: base })] })
183
- render()
184
- setTextarea(textarea(), 'Hello!')
185
- expect(textarea().value).toBe('Hello!')
186
-
187
- // The other tab branched from the SAME base and appended elsewhere.
188
- act(() => {
189
- client.setSubscription(LIST, [docRow({ body: remoteEditOf(base, 0, 'Oh, ') })])
190
- })
191
-
192
- expect(textarea().value).toContain('Oh, ')
193
- expect(textarea().value).toContain('!')
194
- })
195
191
  })
@@ -2,14 +2,15 @@
2
2
  // by the framework's file-convention page discovery.
3
3
  //
4
4
  // THIS is local-first collaboration over the reactive loop. The api's
5
- // `documents` table has a `crdtText()` `body`; this page binds ONE
6
- // `useCrdtText` cell (`@voltro/local-first/react`) to it:
5
+ // `documents` table has a `crdtDoc()` `body`; this page binds ONE `useCrdtDoc`
6
+ // cell (`@voltro/local-first/react`) to it and hands the resulting document to
7
+ // `useCrdtEditor` (`@voltro/local-first/editor`), the Tiptap binding:
7
8
  // 1. `useSubscription('app','documents.list')` — streams the shared document
8
9
  // (with its MERGED body) live over WebSocket; the streamed body is the
9
- // hook's `remote`, folded into the local CRDT on every change,
10
- // 2. `useMutation('app','documents.setBody')` is the hook's `push` — every
11
- // keystroke goes through `setText`, which diffs to a minimal single-span
12
- // edit (the framework's `crdtTextEdit`) and queues the encoded update.
10
+ // hook's `remote`, folded into the local CRDT document on every change,
11
+ // 2. `useMutation('app','documents.setBody')` is the hook's `push` — the
12
+ // editor's own edits surface as `onUpdate` callbacks and are queued as
13
+ // INCREMENTAL updates (a keystroke is bytes, not the whole document).
13
14
  //
14
15
  // The convergence is authoritative and SERVER-SIDE: the api merges each update
15
16
  // into the stored state before broadcasting, so two tabs typing at once both
@@ -17,17 +18,28 @@
17
18
  //
18
19
  // This is the app-level binding the local-first docs describe — the two names
19
20
  // nothing can derive: `push` = the setBody mutation, `remote` = the documents
20
- // subscription. Everything else (sync client lifecycle, offline queue, edit
21
- // encoding) lives in the hook.
21
+ // subscription. Everything else (sync-client lifecycle, offline queue, retry,
22
+ // and the ECHO GUARD that keeps a folded remote update from being pushed back)
23
+ // lives in the hook.
22
24
 
23
25
  import { useMutation, useSubscription } from '@voltro/client'
24
26
  import { useMemo, type ReactNode } from 'react'
25
27
  import type { PageMeta } from '@voltro/web'
26
- import { T, useTFn } from '@voltro/i18n'
27
- import type { CrdtState } from '@voltro/local-first'
28
- import { useCrdtText } from '@voltro/local-first/react'
28
+ import { EditorContent } from '@tiptap/react'
29
+ import { T } from '@voltro/i18n'
30
+ import type { CrdtDocHandle, CrdtState } from '@voltro/local-first'
31
+ import { useCrdtDoc } from '@voltro/local-first/react'
32
+ import { useCrdtEditor } from '@voltro/local-first/editor'
29
33
  import { getCatalog } from '../locales'
30
34
 
35
+ // A rich-text editor needs a BROWSER. `renderMode` defaults to `'static'`,
36
+ // which pre-renders this page at build time — and Tiptap's `new Editor()` has
37
+ // no `window` there, so the build fails outright. `'client'` renders the page
38
+ // in the browser only, which is what a collaborative editor wants anyway: there
39
+ // is nothing meaningful to pre-render for a document that arrives over a live
40
+ // subscription.
41
+ export const renderMode = 'client' as const
42
+
31
43
  export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
32
44
  title: getCatalog(locale)['meta.editor.title'],
33
45
  })
@@ -51,6 +63,20 @@ interface DocRow {
51
63
  const base64ToBytes = (b64: string): Uint8Array =>
52
64
  Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
53
65
 
66
+ // The Tiptap surface, in its OWN component. `useCrdtDoc` builds its document in
67
+ // an effect (never during render — that is what crashed a prerender and leaked
68
+ // an editor under StrictMode), so `doc` is `null` on the first pass. Splitting
69
+ // the editor out keeps `useCrdtEditor` an unconditional hook call instead of one
70
+ // guarded by `doc !== null`.
71
+ const RichTextSurface = ({ doc }: { readonly doc: CrdtDocHandle }): ReactNode => {
72
+ const editor = useCrdtEditor({ doc })
73
+ return (
74
+ <div style={{ border: '1px solid #ddd', borderRadius: 6, padding: '10px 12px', minHeight: 220, fontSize: 14 }}>
75
+ <EditorContent editor={editor} />
76
+ </div>
77
+ )
78
+ }
79
+
54
80
  // One editor bound to ONE document. Remounted (via `key={doc.id}`) when the
55
81
  // bound document changes, so the hook's sync-client lifecycle is scoped to it.
56
82
  const DocumentEditor = ({
@@ -62,9 +88,8 @@ const DocumentEditor = ({
62
88
  readonly saving: boolean
63
89
  readonly onUpdate: (update: CrdtState) => Promise<unknown>
64
90
  }): ReactNode => {
65
- const t = useTFn()
66
91
  // The authoritative state as the subscription streams it. `null` while the
67
- // body is absent — useCrdtText folds nothing then, NOT an empty document.
92
+ // body is absent — useCrdtDoc folds nothing then, NOT an empty document.
68
93
  const remote = useMemo(
69
94
  () =>
70
95
  doc.body instanceof Uint8Array
@@ -74,7 +99,7 @@ const DocumentEditor = ({
74
99
  : null,
75
100
  [doc.body],
76
101
  )
77
- const body = useCrdtText({
102
+ const shared = useCrdtDoc({
78
103
  cell: { table: 'documents', id: doc.id, column: 'body' },
79
104
  remote,
80
105
  // A rejected push re-drives the queued write; log it and keep the rejection.
@@ -89,15 +114,13 @@ const DocumentEditor = ({
89
114
  <section>
90
115
  <p style={{ color: '#666', fontSize: 13, margin: '0 0 8px' }}>
91
116
  <T id="editor.docLabel" values={{ title: doc.title, strong: (c: ReactNode) => <strong>{c}</strong> }} />
92
- {saving ? <T id="editor.status.saving" /> : null}
117
+ {saving || !shared.synced ? <T id="editor.status.saving" /> : null}
93
118
  </p>
94
- <textarea
95
- value={body.text}
96
- onChange={(event) => body.setText(event.target.value)}
97
- placeholder={t('editor.placeholder')}
98
- rows={12}
99
- style={{ width: '100%', padding: '10px 12px', fontSize: 14, fontFamily: 'ui-monospace, monospace', resize: 'vertical', boxSizing: 'border-box' }}
100
- />
119
+ {shared.doc !== null ? (
120
+ <RichTextSurface doc={shared.doc} />
121
+ ) : (
122
+ <p style={{ color: '#999', fontSize: 13 }}><T id="editor.status.loading" /></p>
123
+ )}
101
124
  <p style={{ color: '#999', fontSize: 12, marginTop: 8 }}><T id="editor.hint" /></p>
102
125
  </section>
103
126
  )
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "frontend-collab",
3
3
  "kind": "web",
4
- "summary": "A collaborative document editor — a textarea bound to a `crdtText()` body over the reactive engine. Type in two browser tabs at once and the edits CONVERGE (server-authoritative CRDT merge), no last-write-wins loser. Pairs with api-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
- "tags": ["web", "frontend", "fullstack", "local-first", "crdt", "collaborative", "realtime", "reactive", "api-collab"]
4
+ "summary": "A collaborative rich-text editor — Tiptap (`useCrdtEditor`) bound to a `crdtDoc()` body through `useCrdtDoc` over the reactive engine. Type in two browser tabs at once and the edits CONVERGE (server-authoritative CRDT merge), no last-write-wins loser. Pairs with api-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
+ "tags": ["web", "frontend", "fullstack", "local-first", "crdt", "collaborative", "realtime", "reactive", "rich-text", "tiptap", "api-collab"]
6
6
  }
@@ -15,18 +15,18 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@effect/platform": "^0.97.0",
18
- "@voltro/cli": "0.52.0",
19
- "@voltro/client": "0.52.0",
20
- "@voltro/env": "0.52.0",
21
- "@voltro/i18n": "0.52.0",
22
- "@voltro/serverless": "0.52.0",
23
- "@voltro/web": "0.52.0",
18
+ "@voltro/cli": "0.54.0",
19
+ "@voltro/client": "0.54.0",
20
+ "@voltro/env": "0.54.0",
21
+ "@voltro/i18n": "0.54.0",
22
+ "@voltro/serverless": "0.54.0",
23
+ "@voltro/web": "0.54.0",
24
24
  "effect": "^3.22.0",
25
25
  "react": "^19.0.0",
26
26
  "react-dom": "^19.0.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@voltro/devtools": "0.52.0",
29
+ "@voltro/devtools": "0.54.0",
30
30
  "@types/react": "^19.0.0",
31
31
  "@types/react-dom": "^19.0.0",
32
32
  "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",
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: Deine erste Seite
3
+ ---
4
+ Lege eine `*.tsx`-Datei in src/pages/ ab.
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: Erste Schritte
3
+ ---
4
+ Willkommen in der {{capProjectName}}-Dokumentation.
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: Your first page
3
+ ---
4
+ Drop a `*.tsx` file into src/pages/.
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: Getting started
3
+ ---
4
+ Welcome to the {{capProjectName}} docs.
@@ -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,21 @@
1
+ // The docs content collection — bilingual markdown under
2
+ // content/docs/{en,de}/**/*.md. The SLUG comes from the path below the
3
+ // locale segment (`en/intro/getting-started.md` → `intro/getting-started`),
4
+ // identical across locales, so the /de mirror serves the same slugs from the
5
+ // German tree. A missing translation falls back to the default locale's
6
+ // entry (marked `fallback: true`) instead of 404ing — incomplete
7
+ // translations are the normal case.
8
+
9
+ import { Schema } from 'effect'
10
+ import { defineCollection } from '@voltro/content'
11
+
12
+ export const docs = defineCollection({
13
+ name: 'docs',
14
+ directory: 'content/docs',
15
+ schema: Schema.Struct({
16
+ title: Schema.String,
17
+ }),
18
+ i18n: { locales: ['en', 'de'], defaultLocale: 'en', missing: 'fallback' },
19
+ })
20
+
21
+ export type Doc = Schema.Schema.Type<typeof docs.schema>
@@ -10,11 +10,6 @@ export default defineLocale<typeof en>()({
10
10
 
11
11
  'home.title': '{project} Dokumentation',
12
12
 
13
- 'docs.getting-started.title': 'Erste Schritte',
14
- 'docs.getting-started.body': 'Willkommen in der {{capProjectName}}-Dokumentation.',
15
- 'docs.first-page.title': 'Deine erste Seite',
16
- 'docs.first-page.body': 'Lege eine `*.tsx`-Datei in src/pages/ ab.',
17
-
18
13
  'docs.notFound.title': 'Nicht gefunden',
19
14
  'docs.notFound.body': 'Kein Dokument unter <code>{slug}</code>.',
20
15
 
@@ -14,11 +14,6 @@ export default defineCatalog({
14
14
  // `{project}` is the scaffold-substituted app name, passed as a <T> value.
15
15
  'home.title': '{project} docs',
16
16
 
17
- 'docs.getting-started.title': 'Getting started',
18
- 'docs.getting-started.body': 'Welcome to the {{capProjectName}} docs.',
19
- 'docs.first-page.title': 'Your first page',
20
- 'docs.first-page.body': 'Drop a `*.tsx` file into src/pages/.',
21
-
22
17
  'docs.notFound.title': 'Not found',
23
18
  'docs.notFound.body': 'No doc at <code>{slug}</code>.',
24
19
 
@@ -1,18 +1,26 @@
1
1
  // Locale mirror of the catch-all docs route → renders `/de/docs/<slug>`.
2
- // Re-exports the bare `[...slug]` page (the component + its per-locale meta
3
- // read locale + slug from the URL / params). This is a DYNAMIC route mirrored
4
- // under a DYNAMIC locale segment, so `getStaticPaths` must emit the
5
- // CROSS-PRODUCT of every non-default locale × every doc slug — otherwise the
6
- // German docs URLs are never enumerated for pre-render.
2
+ // Re-exports the bare `[...slug]` page's component + meta; the LOADER here
3
+ // reads the URL locale's tree of the `docs` collection (with fallback to
4
+ // the default locale for untranslated entries). This is a DYNAMIC route
5
+ // mirrored under a DYNAMIC locale segment, so `getStaticPaths` emits the
6
+ // CROSS-PRODUCT of every non-default locale × every doc slug.
7
+ import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
8
+ import { docs, type Doc } from '../../../../collections/docs.collection'
7
9
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../../lib/locale'
8
- import { DOCS } from '../../../docs/[...slug]/page'
9
10
 
10
11
  export { default } from '../../../docs/[...slug]/page'
11
12
  export { renderMode, interactive, meta } from '../../../docs/[...slug]/page'
12
13
 
14
+ export const loader = async ({ params }: {
15
+ readonly params: { readonly slug: string; readonly locale: string }
16
+ }): Promise<ContentEntry<Doc> | null> =>
17
+ await getEntry<Doc>(docs.name, params.slug, { locale: params.locale })
18
+
13
19
  export const getStaticPaths = async (): Promise<
14
20
  Array<{ params: { locale: string; slug: string } }>
15
- > =>
16
- SUPPORTED_LOCALES
21
+ > => {
22
+ const entries = await getCollection(docs.name)
23
+ return SUPPORTED_LOCALES
17
24
  .filter((l) => l !== DEFAULT_LOCALE)
18
- .flatMap((locale) => DOCS.map((d) => ({ params: { locale, slug: d.slug } })))
25
+ .flatMap((locale) => entries.map((d) => ({ params: { locale, slug: d.slug } })))
26
+ }
@@ -8,6 +8,11 @@
8
8
  // non-default locale × every doc slug.
9
9
 
10
10
  import { describe, expect, test, vi } from 'vitest'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { setContentRoot } from '@voltro/content'
14
+
15
+ setContentRoot(join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'))
11
16
 
12
17
  // The mirrors transitively import the base pages, which import @voltro/i18n
13
18
  // and @voltro/web. Stub both so importing the modules doesn't require the
@@ -50,17 +55,17 @@ describe('home mirror — getStaticPaths', () => {
50
55
  describe('docs [...slug] mirror — getStaticPaths (locale × slug cross-product)', () => {
51
56
  test('emits every non-default locale paired with every doc slug', async () => {
52
57
  const paths = await slugMirror.getStaticPaths()
58
+ // getCollection sorts by slug.
53
59
  expect(paths).toEqual([
54
- { params: { locale: 'de', slug: 'intro/getting-started' } },
55
60
  { params: { locale: 'de', slug: 'guides/first-page' } },
61
+ { params: { locale: 'de', slug: 'intro/getting-started' } },
56
62
  ])
57
63
  })
58
64
 
59
- test('re-exports the base page component + its per-locale meta function', () => {
65
+ test('re-exports the base page component + its per-locale meta function', async () => {
60
66
  expect(typeof slugMirror.default).toBe('function')
61
- expect(
62
- slugMirror.meta({ locale: 'de', params: { slug: 'guides/first-page' } }).title,
63
- ).toBe('Deine erste Seite')
67
+ const deDoc = await slugMirror.loader({ params: { slug: 'guides/first-page', locale: 'de' } })
68
+ expect(slugMirror.meta({ locale: 'de', loaderData: deDoc }).title).toBe('Deine erste Seite')
64
69
  expect(slugMirror.renderMode).toBe('static')
65
70
  expect(slugMirror.interactive).toBe('none')
66
71
  })
@@ -2,11 +2,19 @@
2
2
  // (the component reads its locale from the URL via the layout's provider);
3
3
  // `getStaticPaths` tells `voltro build` which locale-prefixed paths to emit.
4
4
  // The default locale is NOT listed here — it lives at the bare `/`.
5
+ import { getCollection, type ContentEntry } from '@voltro/content'
6
+ import { docs, type Doc } from '../../collections/docs.collection'
5
7
  import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../lib/locale'
6
8
 
7
9
  export { default } from '../page'
8
10
  export { renderMode, interactive, meta } from '../page'
9
11
 
12
+ // The URL locale selects the collection tree (German titles on /de).
13
+ export const loader = async ({ params }: {
14
+ readonly params: { readonly locale: string }
15
+ }): Promise<ReadonlyArray<ContentEntry<Doc>>> =>
16
+ await getCollection<Doc>(docs.name, { locale: params.locale })
17
+
10
18
  export const getStaticPaths = async (): Promise<Array<{ params: { locale: string } }>> =>
11
19
  SUPPORTED_LOCALES
12
20
  .filter((l) => l !== DEFAULT_LOCALE)