@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.
- package/CHANGELOG.md +424 -0
- package/THIRD-PARTY-NOTICES.md +8311 -3318
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/apiBuild-CeUN55uk.js +2 -0
- package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
- package/dist/bin.js +1 -1
- package/dist/build-D4ygSbnV.js +843 -0
- package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
- package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
- package/dist/codegen-DjgxEOnD.js +2 -0
- package/dist/codegenCommand-CG_Vx4lc.js +41 -0
- package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
- package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
- package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
- package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
- package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
- package/dist/dbCommand-CSFWs9ev.js +2 -0
- package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
- package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
- package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
- package/dist/doctorCommand-J3qu4E0Y.js +2 -0
- package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
- package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
- package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
- package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
- package/dist/fontPipeline-LxIHa1vo.js +2 -0
- package/dist/fontPipeline-Tsh8kZfA.js +152 -0
- package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
- package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
- package/dist/imagePipeline-B_GVJgm6.js +2 -0
- package/dist/imagePipeline-CBZmjT4i.js +127 -0
- package/dist/index.js +2 -2
- package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
- package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
- package/dist/inspect-CuoDInfZ.js +2 -0
- package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
- package/dist/manifestBuild-C4-J1-m_.js +2 -0
- package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
- package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
- package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
- package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
- package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
- package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
- package/dist/renderModeScan-43yQ2opo.js +147 -0
- package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
- package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
- package/dist/serveCommand-BiPe8BJm.js +2 -0
- package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
- package/dist/serveEntry.js +1 -1
- package/dist/start-B0bnJgxI.js +3 -0
- package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
- package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
- package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
- package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
- package/dist/updateCommand-CIoVDKnj.js +2 -0
- package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
- package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
- package/dist/webDev-DlvZO30c.js +2 -0
- package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +60 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +8 -4
- package/templates/agent-docs/_index.md +6 -4
- package/templates/agent-docs/_manifest.json +21 -5
- package/templates/agent-docs/ai.md +6 -6
- package/templates/agent-docs/authentication.md +73 -1
- package/templates/agent-docs/cli.md +6 -3
- package/templates/agent-docs/configuration.md +17 -0
- package/templates/agent-docs/data.md +522 -29
- package/templates/agent-docs/database/advancedqueries.md +8 -8
- package/templates/agent-docs/database/columntypes.md +2 -2
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/querying.md +1 -1
- package/templates/agent-docs/database/schema.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +65 -3
- package/templates/agent-docs/database/transactions.md +3 -3
- package/templates/agent-docs/deployment.md +8 -0
- package/templates/agent-docs/internationalization.md +4 -2
- package/templates/agent-docs/introduction.md +32 -1
- package/templates/agent-docs/local-first-mobile.md +226 -30
- package/templates/agent-docs/observability.md +5 -1
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- package/templates/agent-docs/plugins/auth.md +1 -1
- package/templates/agent-docs/plugins/billing.md +1 -1
- package/templates/agent-docs/plugins/cdc-out.md +8 -3
- package/templates/agent-docs/plugins/comments.md +164 -0
- package/templates/agent-docs/plugins/notifications.md +47 -4
- package/templates/agent-docs/plugins/presence.md +45 -3
- package/templates/agent-docs/plugins/prometheus.md +3 -1
- package/templates/agent-docs/plugins/queue.md +172 -0
- package/templates/agent-docs/plugins.md +17 -13
- package/templates/agent-docs/reference.md +35 -4
- package/templates/agent-docs/routing.md +585 -7
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +226 -4
- package/templates/agent-docs/security.md +3 -3
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +134 -66
- package/templates/apps/api-ai/package.json +6 -6
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/README.md +3 -3
- package/templates/apps/api-collab/app.config.ts +1 -1
- package/templates/apps/api-collab/database/schema.ts +12 -8
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-collab/template.json +1 -1
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -10
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/app.config.ts +26 -2
- package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
- package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
- package/templates/apps/changelog/package.json +9 -8
- package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
- package/templates/apps/changelog/src/globals.d.ts +1 -1
- package/templates/apps/changelog/src/locales/de.ts +1 -1
- package/templates/apps/changelog/src/locales/en.ts +1 -1
- package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
- package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
- package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
- package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
- package/templates/apps/changelog/src/pages/page.tsx +18 -12
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +9 -9
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/README.md +43 -24
- package/templates/apps/frontend-collab/app.config.ts +3 -3
- package/templates/apps/frontend-collab/package.json +14 -10
- package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
- package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
- package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
- package/templates/apps/frontend-collab/template.json +2 -2
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
- package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
- package/templates/apps/frontend-docs/package.json +9 -6
- package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
- package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
- package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
- package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
- package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
- package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/README.md +48 -0
- package/templates/apps/frontend-landing/app.config.ts +28 -0
- package/templates/apps/frontend-landing/package.json +7 -6
- package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
- package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
- package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
- package/templates/apps/frontend-landing/src/globals.css +15 -0
- package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
- package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
- package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
- package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
- package/templates/apps/frontend-landing/template.json +2 -2
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
- package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
- package/templates/apps/frontend-static-blog/package.json +9 -6
- package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
- package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
- package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +12 -11
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
- package/dist/agentsMd-SDDSkyl4.js +0 -2
- package/dist/apiBuild-BYBpL7Pz.js +0 -2
- package/dist/build-CPgcMQug.js +0 -793
- package/dist/codegen-CctkDO-1.js +0 -2
- package/dist/codegenCommand-DCdG2JN-.js +0 -137
- package/dist/dbCommand-DNb6yeOG.js +0 -2
- package/dist/doctorCommand-CqoWA2p5.js +0 -2
- package/dist/fileConventions-DOqD3lPS.js +0 -34
- package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
- package/dist/inspect-CuGDYES0.js +0 -2
- package/dist/manifestBuild-CPjhvM62.js +0 -2
- package/dist/renderModeScan-CcH2X1_D.js +0 -120
- package/dist/serveCommand-DLc-BznW.js +0 -2
- package/dist/start-DfL3fOiN.js +0 -3
- package/dist/updateCommand-5gFVfK5q.js +0 -2
- package/dist/webDev-CZbTsDcH.js +0 -2
- package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
- package/templates/apps/changelog/src/lib/releases.ts +0 -21
- 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: `
|
|
7
|
-
//
|
|
8
|
-
// folding every INCOMING body back in. A `vi.mock` can return one snapshot
|
|
9
|
-
// mount, so the fold — the whole point of the page —
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
145
|
-
expect(
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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(
|
|
180
|
+
expect(surface().textContent).toContain('Hello')
|
|
170
181
|
|
|
171
182
|
act(() => {
|
|
172
|
-
client.setSubscription(LIST, [docRow({ body:
|
|
183
|
+
client.setSubscription(LIST, [docRow({ body: bodyWith('<p>Hello world</p>', base) })])
|
|
173
184
|
})
|
|
174
185
|
|
|
175
|
-
expect(
|
|
176
|
-
// A fold is
|
|
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 `
|
|
6
|
-
//
|
|
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` —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
21
|
-
//
|
|
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 {
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
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 —
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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.
|
|
19
|
-
"@voltro/client": "0.
|
|
20
|
-
"@voltro/env": "0.
|
|
21
|
-
"@voltro/i18n": "0.
|
|
22
|
-
"@voltro/serverless": "0.
|
|
23
|
-
"@voltro/web": "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.
|
|
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.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/ui-shadcn": "0.
|
|
20
|
-
"@voltro/web": "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.
|
|
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,16 +12,19 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/
|
|
18
|
-
"@voltro/
|
|
19
|
-
"@voltro/
|
|
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.
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
21
|
+
> => {
|
|
22
|
+
const entries = await getCollection(docs.name)
|
|
23
|
+
return SUPPORTED_LOCALES
|
|
17
24
|
.filter((l) => l !== DEFAULT_LOCALE)
|
|
18
|
-
.flatMap((locale) =>
|
|
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
|
-
|
|
62
|
-
|
|
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)
|