@voltro/cli 0.52.0 → 0.53.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 (195) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-CaPfoWku.js} +11 -5
  4. package/dist/apiBuild-DHtLXYx9.js +2 -0
  5. package/dist/bin.js +1 -1
  6. package/dist/build-D-OnvNMf.js +843 -0
  7. package/dist/{checkCommand-COmqc2cB.js → checkCommand-C5elt0tW.js} +6 -6
  8. package/dist/{checkCommand-2SbqzukH.js → checkCommand-D2ZduVlh.js} +1 -1
  9. package/dist/codegen-BWpt3VgF.js +2 -0
  10. package/dist/{codegen-VF479Cnb.js → codegen-FEk8AZHb.js} +2 -2
  11. package/dist/{codegenCommand-DCdG2JN-.js → codegenCommand-BOiWQ5hz.js} +4 -4
  12. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-BjtB2lq6.js} +109 -11
  13. package/dist/{commands-Cc_nV8WI.js → commands-DyxAmhP0.js} +30 -30
  14. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-BdKTyT13.js} +5 -5
  15. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-Bab9X7s8.js} +2 -2
  16. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-06O2finM.js} +277 -236
  17. package/dist/dbCommand-B1EXBC6f.js +2 -0
  18. package/dist/{dev-CRHoCEiy.js → dev-C6LGF4iY.js} +3006 -2426
  19. package/dist/{dev--A3nsxA3.js → dev-GjJWAYo2.js} +1 -1
  20. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  21. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-etMkflRc.js} +101 -69
  22. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  23. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-C70zWHwo.js} +1 -1
  24. package/dist/{envCommand-D4gCrrTZ.js → envCommand-dSyKvRkM.js} +18 -18
  25. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-CG0_ebO5.js} +2 -2
  26. package/dist/fileConventions-DASGEmj-.js +35 -0
  27. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-B7uxipWS.js} +55 -55
  28. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  29. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  30. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  31. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  32. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  33. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  34. package/dist/index.js +1 -1
  35. package/dist/{infoCommand-DXM868o_.js → infoCommand-_53iOc_j.js} +1 -1
  36. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  37. package/dist/inspect-CuoDInfZ.js +2 -0
  38. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  39. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  40. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  41. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-Cn2oboG4.js} +8 -2
  42. package/dist/{migrate-D0F-eTlK.js → migrate-Cko9rswM.js} +2 -2
  43. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-C938S8oC.js} +1 -1
  44. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-DWTQMC6R.js} +2 -2
  45. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  46. package/dist/{renderModeScan-CcH2X1_D.js → renderModeScan-CUbOeOAg.js} +3 -1
  47. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-CskIgAfn.js} +2 -2
  48. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-c0APJz7E.js} +1 -1
  49. package/dist/{sdkgen-Se88ifTd.js → sdkgen-BiQCgIEr.js} +1 -1
  50. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-CueKQgzl.js} +869 -805
  51. package/dist/serveCommand-DsnrVN3U.js +2 -0
  52. package/dist/serveEntry.js +1 -1
  53. package/dist/start-BJzZLbt8.js +3 -0
  54. package/dist/start-ekPan8BT.js +1510 -0
  55. package/dist/startEntry.js +1 -1
  56. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-xlSL-IWk.js} +1 -1
  57. package/dist/{test-f3amja6a.js → test-BWPQcRoB.js} +1 -1
  58. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  59. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-C_8I8Rzo.js} +139 -115
  60. package/dist/webDev-C7jWJ5dX.js +2 -0
  61. package/dist/{webDev-BgWL9gKV.js → webDev-oczpugbx.js} +1455 -922
  62. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-4SVPDjKg.js} +1 -1
  63. package/package.json +60 -19
  64. package/templates/AGENTS.md +5 -3
  65. package/templates/agent-docs/_index.md +5 -3
  66. package/templates/agent-docs/_manifest.json +20 -4
  67. package/templates/agent-docs/ai.md +2 -2
  68. package/templates/agent-docs/authentication.md +1 -1
  69. package/templates/agent-docs/cli.md +2 -2
  70. package/templates/agent-docs/configuration.md +17 -0
  71. package/templates/agent-docs/data.md +470 -26
  72. package/templates/agent-docs/database/advancedqueries.md +7 -7
  73. package/templates/agent-docs/database/columntypes.md +2 -2
  74. package/templates/agent-docs/database/querying.md +1 -1
  75. package/templates/agent-docs/database/schema.md +1 -1
  76. package/templates/agent-docs/database/seedsdialects.md +1 -1
  77. package/templates/agent-docs/database/transactions.md +3 -3
  78. package/templates/agent-docs/deployment.md +8 -0
  79. package/templates/agent-docs/internationalization.md +2 -2
  80. package/templates/agent-docs/introduction.md +7 -1
  81. package/templates/agent-docs/local-first-mobile.md +102 -4
  82. package/templates/agent-docs/observability.md +2 -0
  83. package/templates/agent-docs/plugins/auth.md +1 -1
  84. package/templates/agent-docs/plugins/comments.md +142 -0
  85. package/templates/agent-docs/plugins/notifications.md +47 -4
  86. package/templates/agent-docs/plugins/presence.md +16 -3
  87. package/templates/agent-docs/plugins/prometheus.md +1 -1
  88. package/templates/agent-docs/plugins/queue.md +129 -0
  89. package/templates/agent-docs/plugins.md +11 -7
  90. package/templates/agent-docs/reference.md +16 -4
  91. package/templates/agent-docs/routing.md +527 -3
  92. package/templates/agent-docs/schema-driven-ui.md +214 -3
  93. package/templates/agent-docs/security.md +3 -3
  94. package/templates/agent-docs/whats-new.md +160 -66
  95. package/templates/apps/api-ai/package.json +6 -6
  96. package/templates/apps/api-auth/package.json +8 -8
  97. package/templates/apps/api-backend/package.json +7 -7
  98. package/templates/apps/api-backend-deactivation/package.json +7 -7
  99. package/templates/apps/api-backend-mail/package.json +8 -8
  100. package/templates/apps/api-backend-mariadb/package.json +9 -9
  101. package/templates/apps/api-backend-sqlite/package.json +8 -8
  102. package/templates/apps/api-backend-storage/package.json +8 -8
  103. package/templates/apps/api-cms/package.json +9 -9
  104. package/templates/apps/api-collab/package.json +8 -8
  105. package/templates/apps/api-data-advanced/package.json +8 -8
  106. package/templates/apps/api-durable/package.json +8 -8
  107. package/templates/apps/api-feature-flags/package.json +9 -9
  108. package/templates/apps/api-governance/package.json +8 -8
  109. package/templates/apps/api-kv/package.json +8 -8
  110. package/templates/apps/api-moderation/package.json +8 -8
  111. package/templates/apps/api-observability/package.json +8 -8
  112. package/templates/apps/api-ratelimit/package.json +8 -8
  113. package/templates/apps/api-rbac/package.json +8 -8
  114. package/templates/apps/api-rest/package.json +7 -7
  115. package/templates/apps/api-row-history/package.json +8 -8
  116. package/templates/apps/api-saas/package.json +10 -10
  117. package/templates/apps/api-saas-starter/package.json +10 -10
  118. package/templates/apps/api-search/package.json +8 -8
  119. package/templates/apps/api-status/package.json +8 -8
  120. package/templates/apps/api-webhooks/package.json +9 -9
  121. package/templates/apps/changelog/app.config.ts +26 -2
  122. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  123. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  124. package/templates/apps/changelog/package.json +8 -8
  125. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  126. package/templates/apps/changelog/src/globals.d.ts +1 -1
  127. package/templates/apps/changelog/src/locales/de.ts +1 -1
  128. package/templates/apps/changelog/src/locales/en.ts +1 -1
  129. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  130. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  131. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  132. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  133. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  134. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/package.json +10 -10
  142. package/templates/apps/frontend-contact/package.json +7 -7
  143. package/templates/apps/frontend-dashboard/package.json +7 -7
  144. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  145. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  146. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  147. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  148. package/templates/apps/frontend-docs/package.json +8 -6
  149. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  150. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  151. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  152. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  153. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  154. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  155. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  156. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  157. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  158. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  159. package/templates/apps/frontend-i18n/package.json +6 -6
  160. package/templates/apps/frontend-landing/package.json +6 -6
  161. package/templates/apps/frontend-portal/package.json +8 -8
  162. package/templates/apps/frontend-saas/package.json +8 -8
  163. package/templates/apps/frontend-spa/package.json +7 -7
  164. package/templates/apps/frontend-ssr/package.json +7 -7
  165. package/templates/apps/frontend-ssr-api/package.json +8 -8
  166. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  167. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  168. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  169. package/templates/apps/frontend-static-blog/package.json +8 -6
  170. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  171. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  172. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  173. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  174. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  175. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  176. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  177. package/templates/apps/frontend-status/package.json +8 -8
  178. package/templates/apps/mobile-app/package.json +4 -4
  179. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  180. package/dist/build-CPgcMQug.js +0 -793
  181. package/dist/codegen-CctkDO-1.js +0 -2
  182. package/dist/dbCommand-DNb6yeOG.js +0 -2
  183. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  184. package/dist/fileConventions-DOqD3lPS.js +0 -34
  185. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  186. package/dist/inspect-CuGDYES0.js +0 -2
  187. package/dist/manifestBuild-CPjhvM62.js +0 -2
  188. package/dist/serveCommand-DLc-BznW.js +0 -2
  189. package/dist/start-DfL3fOiN.js +0 -3
  190. package/dist/start-jw89Xbqy.js +0 -1339
  191. package/dist/updateCommand-5gFVfK5q.js +0 -2
  192. package/dist/webDev-CZbTsDcH.js +0 -2
  193. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  194. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  195. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -2,55 +2,50 @@
2
2
  // `/docs/<path>` URL (and its `/de/docs/<path>` mirror). `getStaticPaths`
3
3
  // enumerates the doc tree at build time so every page is pre-rendered.
4
4
  //
5
- // Bilingual (Strategy B): the doc SLUG is stable across locales — only the
6
- // /de URL prefix differsso each entry carries a `titleKey` / `bodyKey`
7
- // into the message catalog rather than literal, one-language strings. The
8
- // [locale] mirror (src/pages/[locale]/docs/[...slug].tsx) re-exports this
9
- // page and emits the cross-product of non-default locales × these slugs.
5
+ // Bilingual via the `docs` CONTENT COLLECTION (content/docs/{en,de}/**):
6
+ // the SLUG is stable across locales only the /de URL prefix and the
7
+ // locale tree differ. This bare route reads the DEFAULT locale's tree; the
8
+ // [locale] mirror re-exports the component and loads the requested tree
9
+ // (with fallback to English for untranslated entries).
10
10
 
11
11
  import type { ReactNode } from 'react'
12
12
  import type { PageMeta } from '@voltro/web'
13
- import { useParams } from '@voltro/web'
14
- import { T, useTFn } from '@voltro/i18n'
15
- import { getCatalog, type Messages } from '../../../lib/locale'
13
+ import { useLoaderData, useParams } from '@voltro/web'
14
+ import { T } from '@voltro/i18n'
15
+ import { getCollection, getEntry, type ContentEntry } from '@voltro/content'
16
+ import { docs, type Doc } from '../../../collections/docs.collection'
17
+ import { getCatalog } from '../../../lib/locale'
16
18
 
17
19
  export const renderMode = 'static' as const
18
20
  export const interactive = 'none' as const
19
21
 
20
- interface DocEntry {
21
- readonly slug: string
22
- readonly titleKey: keyof Messages
23
- readonly bodyKey: keyof Messages
24
- }
25
-
26
- // The doc tree — slugs stay identical across locales; the per-locale prose
27
- // lives in the catalogs. Replace with a loader over a markdown folder / CMS
28
- // / database query; the shape stays the same.
29
- export const DOCS: ReadonlyArray<DocEntry> = [
30
- { slug: 'intro/getting-started', titleKey: 'docs.getting-started.title', bodyKey: 'docs.getting-started.body' },
31
- { slug: 'guides/first-page', titleKey: 'docs.first-page.title', bodyKey: 'docs.first-page.body' },
32
- ]
33
-
34
22
  // getStaticPaths returns `{ params: { slug: '<path>' } }` entries — the
35
23
  // framework substitutes each into `/docs/[...slug]` to enumerate the URLs
36
24
  // to pre-render. The captured slug is the raw `/`-joined path (NOT a string
37
25
  // array) so a single deep slug like 'intro/getting-started' renders one file.
38
26
  export const getStaticPaths = async (): Promise<Array<{ params: { slug: string } }>> =>
39
- DOCS.map((d) => ({ params: { slug: d.slug } }))
27
+ (await getCollection(docs.name)).map((d) => ({ params: { slug: d.slug } }))
28
+
29
+ // Loads THIS doc's entry at build time — frontmatter decoded against the
30
+ // collection schema, body rendered to HTML (code fences highlighted
31
+ // server-side). The [locale] mirror passes the URL locale instead.
32
+ export const loader = async ({ params, locale }: {
33
+ readonly params: { readonly slug: string }
34
+ readonly locale?: string
35
+ }): Promise<ContentEntry<Doc> | null> =>
36
+ await getEntry<Doc>(docs.name, params.slug, locale !== undefined ? { locale } : {})
40
37
 
41
- // Per-locale, per-doc <title>: reads the active locale + the captured slug
42
- // (both threaded in at SSG time) so each variant's head is localised.
38
+ // Per-locale, per-doc <title> from the entry's frontmatter.
43
39
  export const meta = ({
44
40
  locale,
45
- params,
41
+ loaderData,
46
42
  }: {
47
43
  locale: string
48
- params: Readonly<Record<string, string>>
44
+ loaderData: ContentEntry<Doc> | null
49
45
  }): PageMeta => {
50
46
  const c = getCatalog(locale)
51
- const doc = DOCS.find((d) => d.slug === params.slug)
52
47
  return {
53
- title: doc ? c[doc.titleKey] : c['docs.notFound.title'],
48
+ title: loaderData?.data.title ?? c['docs.notFound.title'],
54
49
  description: c['meta.docs.description'],
55
50
  }
56
51
  }
@@ -59,8 +54,7 @@ const code = (chunks: ReactNode): ReactNode => <code>{chunks}</code>
59
54
 
60
55
  export default function DocPage(): ReactNode {
61
56
  const { slug } = useParams<{ slug: string }>()
62
- const t = useTFn()
63
- const doc = DOCS.find((d) => d.slug === slug)
57
+ const doc = useLoaderData<ContentEntry<Doc> | null>()
64
58
  if (!doc) {
65
59
  return (
66
60
  <article>
@@ -75,8 +69,8 @@ export default function DocPage(): ReactNode {
75
69
  }
76
70
  return (
77
71
  <article>
78
- <h1>{t(doc.titleKey)}</h1>
79
- <p>{t(doc.bodyKey)}</p>
72
+ <h1>{doc.data.title}</h1>
73
+ <div dangerouslySetInnerHTML={{ __html: doc.html ?? '' }} />
80
74
  </article>
81
75
  )
82
76
  }
@@ -10,15 +10,25 @@
10
10
  import { afterEach, describe, expect, test, vi } from 'vitest'
11
11
  import { act, createElement, type ReactNode } from 'react'
12
12
  import { createRoot, type Root } from 'react-dom/client'
13
+ import { dirname, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { setContentRoot } from '@voltro/content'
13
16
  import en from '../locales/en'
14
17
  import de from '../locales/de'
15
18
 
19
+ // The link list comes from the docs CONTENT COLLECTION — real files.
20
+ setContentRoot(join(dirname(fileURLToPath(import.meta.url)), '..', '..'))
21
+
16
22
  const catalogs: Record<string, Record<string, string>> = { en, de }
17
23
  let currentLocale = 'en'
18
24
 
19
25
  const fill = (msg: string, values?: Record<string, unknown>): string =>
20
26
  values ? msg.replace(/\{(\w+)\}/g, (_, k: string) => String(values[k] ?? `{${k}}`)) : msg
21
27
 
28
+ vi.mock('@voltro/web', () => ({
29
+ useLoaderData: () => entriesByLocale[currentLocale] ?? entriesByLocale['en'],
30
+ }))
31
+
22
32
  vi.mock('@voltro/i18n', () => ({
23
33
  // The locale catalogs call these at module top-level; identity mirrors the
24
34
  // real (compile-time-only) implementations so importing them doesn't throw.
@@ -32,7 +42,11 @@ vi.mock('@voltro/i18n', () => ({
32
42
  fill(catalogs[currentLocale]?.[id] ?? id, values),
33
43
  }))
34
44
 
35
- const { default: Index, meta, renderMode, interactive } = await import('./page')
45
+ const { default: Index, meta, renderMode, interactive, loader } = await import('./page')
46
+ const entriesByLocale: Record<string, Awaited<ReturnType<typeof loader>>> = {
47
+ en: await loader(),
48
+ de: await loader({ locale: 'de' }),
49
+ }
36
50
 
37
51
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
38
52
 
@@ -75,7 +89,7 @@ describe('index — localized render', () => {
75
89
  test('links to both doc pages with bare (en) URLs and English titles', () => {
76
90
  currentLocale = 'en'
77
91
  render(createElement(Index))
78
- expect(hrefs()).toEqual(['/docs/intro/getting-started', '/docs/guides/first-page'])
92
+ expect(hrefs()).toEqual(['/docs/guides/first-page', '/docs/intro/getting-started'])
79
93
  expect(container.textContent).toContain('Getting started')
80
94
  expect(container.textContent).toContain('Your first page')
81
95
  })
@@ -83,7 +97,7 @@ describe('index — localized render', () => {
83
97
  test('prefixes the doc links with /de and shows German titles for locale de', () => {
84
98
  currentLocale = 'de'
85
99
  render(createElement(Index))
86
- expect(hrefs()).toEqual(['/de/docs/intro/getting-started', '/de/docs/guides/first-page'])
100
+ expect(hrefs()).toEqual(['/de/docs/guides/first-page', '/de/docs/intro/getting-started'])
87
101
  expect(container.textContent).toContain('Erste Schritte')
88
102
  expect(container.textContent).toContain('Deine erste Seite')
89
103
  })
@@ -2,12 +2,17 @@
2
2
  // themselves live under `src/pages/docs/[...slug].tsx`.
3
3
  //
4
4
  // Bilingual (Strategy B): mounted at `/` (default locale) and mirrored at
5
- // `/de` by src/pages/[locale]/index.tsx. The links are locale-prefixed off
6
- // the active locale so /de's landing points at the /de docs.
5
+ // `/de` by src/pages/[locale]/index.tsx. The link list comes from the `docs`
6
+ // CONTENT COLLECTION titles are the entries' frontmatter, per locale tree
7
+ // (the /de mirror passes its locale into the loader), so a new .md file
8
+ // appears here without touching this page.
7
9
 
8
10
  import type { ReactNode } from 'react'
9
11
  import type { PageMeta } from '@voltro/web'
12
+ import { useLoaderData } from '@voltro/web'
13
+ import { getCollection, type ContentEntry } from '@voltro/content'
10
14
  import { T, useLocale } from '@voltro/i18n'
15
+ import { docs, type Doc } from '../collections/docs.collection'
11
16
  import { getCatalog, withLocalePrefix } from '../lib/locale'
12
17
 
13
18
  export const renderMode = 'static' as const
@@ -21,7 +26,11 @@ export const meta = ({ locale }: { locale: string }): PageMeta => {
21
26
  }
22
27
  }
23
28
 
29
+ export const loader = async ({ locale }: { readonly locale?: string } = {}): Promise<ReadonlyArray<ContentEntry<Doc>>> =>
30
+ await getCollection<Doc>(docs.name, locale !== undefined ? { locale } : {})
31
+
24
32
  export default function Index(): ReactNode {
33
+ const entries = useLoaderData<ReadonlyArray<ContentEntry<Doc>>>()
25
34
  const locale = useLocale()
26
35
  return (
27
36
  <main>
@@ -29,16 +38,11 @@ export default function Index(): ReactNode {
29
38
  <T id="home.title" values={{ project: '{{capProjectName}}' }} />
30
39
  </h1>
31
40
  <ul>
32
- <li>
33
- <a href={withLocalePrefix('/docs/intro/getting-started', locale)}>
34
- <T id="docs.getting-started.title" />
35
- </a>
36
- </li>
37
- <li>
38
- <a href={withLocalePrefix('/docs/guides/first-page', locale)}>
39
- <T id="docs.first-page.title" />
40
- </a>
41
- </li>
41
+ {entries.map((doc) => (
42
+ <li key={doc.slug}>
43
+ <a href={withLocalePrefix(`/docs/${doc.slug}`, locale)}>{doc.data.title}</a>
44
+ </li>
45
+ ))}
42
46
  </ul>
43
47
  </main>
44
48
  )
@@ -12,16 +12,16 @@
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.53.0",
16
+ "@voltro/client": "0.53.0",
17
+ "@voltro/env": "0.53.0",
18
+ "@voltro/i18n": "0.53.0",
19
+ "@voltro/web": "0.53.0",
20
20
  "react": "^19.0.0",
21
21
  "react-dom": "^19.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/devtools": "0.52.0",
24
+ "@voltro/devtools": "0.53.0",
25
25
  "@types/react": "^19.0.0",
26
26
  "@types/react-dom": "^19.0.0",
27
27
  "jsdom": "^30.0.0",
@@ -12,16 +12,16 @@
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/web": "0.52.0",
15
+ "@voltro/cli": "0.53.0",
16
+ "@voltro/client": "0.53.0",
17
+ "@voltro/env": "0.53.0",
18
+ "@voltro/i18n": "0.53.0",
19
+ "@voltro/web": "0.53.0",
20
20
  "react": "^19.0.0",
21
21
  "react-dom": "^19.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/devtools": "0.52.0",
24
+ "@voltro/devtools": "0.53.0",
25
25
  "@types/react": "^19.0.0",
26
26
  "@types/react-dom": "^19.0.0",
27
27
  "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.53.0",
16
+ "@voltro/cli": "0.53.0",
17
+ "@voltro/client": "0.53.0",
18
+ "@voltro/env": "0.53.0",
19
+ "@voltro/i18n": "0.53.0",
20
+ "@voltro/ui-shadcn": "0.53.0",
21
+ "@voltro/web": "0.53.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.53.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.53.0",
16
+ "@voltro/cli": "0.53.0",
17
+ "@voltro/client": "0.53.0",
18
+ "@voltro/env": "0.53.0",
19
+ "@voltro/i18n": "0.53.0",
20
+ "@voltro/ui-shadcn": "0.53.0",
21
+ "@voltro/web": "0.53.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.53.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.53.0",
16
+ "@voltro/client": "0.53.0",
17
+ "@voltro/env": "0.53.0",
18
+ "@voltro/i18n": "0.53.0",
19
+ "@voltro/ui-shadcn": "0.53.0",
20
+ "@voltro/web": "0.53.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.53.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.53.0",
16
+ "@voltro/client": "0.53.0",
17
+ "@voltro/env": "0.53.0",
18
+ "@voltro/i18n": "0.53.0",
19
+ "@voltro/ui-shadcn": "0.53.0",
20
+ "@voltro/web": "0.53.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.53.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.53.0",
16
+ "@voltro/cli": "0.53.0",
17
+ "@voltro/client": "0.53.0",
18
+ "@voltro/env": "0.53.0",
19
+ "@voltro/i18n": "0.53.0",
20
+ "@voltro/ui-shadcn": "0.53.0",
21
+ "@voltro/web": "0.53.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.53.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,18 @@
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/content": "0.53.0",
16
+ "effect": "^3.22.0",
17
+ "@voltro/cli": "0.53.0",
18
+ "@voltro/client": "0.53.0",
19
+ "@voltro/env": "0.53.0",
20
+ "@voltro/i18n": "0.53.0",
21
+ "@voltro/web": "0.53.0",
20
22
  "react": "^19.0.0",
21
23
  "react-dom": "^19.0.0"
22
24
  },
23
25
  "devDependencies": {
24
- "@voltro/devtools": "0.52.0",
26
+ "@voltro/devtools": "0.53.0",
25
27
  "@types/react": "^19.0.0",
26
28
  "@types/react-dom": "^19.0.0",
27
29
  "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
  })