@voltro/cli 0.29.0 → 0.30.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 +377 -0
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/{apiBuild-BESZbTjA.js → apiBuild-BceikpQf.js} +2 -2
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/{checkCommand-BPAtyWs8.js → checkCommand-BMr-X48w.js} +23 -16
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/{commands-82BDEktg.js → commands-D4Y1-Nlu.js} +1936 -1339
- package/dist/{dbCommand-bCBbk6tz.js → dbCommand-C7hbl5pk.js} +344 -265
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DeEJj5T4.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-Cg4BKLmi.js → dev-DmnkH-wr.js} +2660 -1742
- package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-BzAajuUv.js} +114 -98
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-BMvQpwqy.js → inspect-BUUjt773.js} +150 -14
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-DHYygE4s.js → inspectMetrics-BeJdP_3b.js} +564 -512
- package/dist/{manifestBuild-C4R1EdHn.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/{sdkgen-CW5NbYF0.js → sdkgen-CqpOq_fy.js} +1 -1
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-CsCIaW4-.js → start-ocqB1seX.js} +19 -19
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +53 -2
- package/templates/agent-docs/_index.md +3 -2
- package/templates/agent-docs/_manifest.json +16 -4
- package/templates/agent-docs/ai.md +56 -0
- package/templates/agent-docs/cli.md +19 -0
- package/templates/agent-docs/internationalization.md +8 -7
- package/templates/agent-docs/local-first-mobile.md +18 -6
- package/templates/agent-docs/routing.md +1 -1
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +209 -0
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +256 -157
- package/templates/agent-docs/workflows.md +365 -0
- package/templates/apps/api-ai/package.json +7 -7
- 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 +10 -10
- package/templates/apps/api-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/tsconfig.json +5 -0
- 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-saas/package.json +11 -11
- 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-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-auth/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-cms/src/locales/index.ts +1 -1
- package/templates/apps/frontend-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-portal/src/locales/index.ts +1 -1
- package/templates/apps/frontend-saas/README.md +1 -1
- package/templates/apps/frontend-saas/app.config.ts +1 -1
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-saas/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/frontend-status/src/locales/index.ts +1 -1
- package/templates/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-C8d74T6g.js +0 -2
- package/dist/checkCommand-DBvZvCh1.js +0 -2
- package/dist/dbCommand-CaIVTp-a.js +0 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +0 -2
- package/dist/inspect-DHeaMZak.js +0 -2
- package/dist/manifestBuild-Ck_90gZy.js +0 -2
- package/dist/serveCommand-BXl8mhZD.js +0 -1489
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Web app for the {{projectName}} project. Read by `voltro dev` / `voltro start`.
|
|
2
|
+
//
|
|
3
|
+
// This is the COLLABORATIVE-EDITING frontend — a textarea bound to a
|
|
4
|
+
// `crdtText()` document body over the framework's reactive loop. Open it in two
|
|
5
|
+
// browser tabs and type in both: the edits CONVERGE (no last-write-wins loser)
|
|
6
|
+
// because the api's server-authoritative CRDT merge folds every update into the
|
|
7
|
+
// stored state, then the reactive engine broadcasts the merged row back. Zero
|
|
8
|
+
// infra — the merge + broadcast are in-process on a single `voltro dev`.
|
|
9
|
+
//
|
|
10
|
+
// `port` is allocated by `voltro create-project` / `voltro add-app` from the
|
|
11
|
+
// project's portRange (in project.json), hard-pinned per app.
|
|
12
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
13
|
+
|
|
14
|
+
// Typed PUBLIC environment — public vars are validated + baked into the browser
|
|
15
|
+
// bundle at build time and read via `publicEnv` from '@voltro/env/public'.
|
|
16
|
+
// NEVER put a secret here (it ships to every visitor).
|
|
17
|
+
export const env = defineEnv({
|
|
18
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
type: 'web' as const,
|
|
23
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
24
|
+
port: {{port}},
|
|
25
|
+
env,
|
|
26
|
+
// Bilingual by default — the framework auto-wires a cookie-driven
|
|
27
|
+
// <I18nProvider> from these codes and loads the matching
|
|
28
|
+
// `src/locales/<code>.ts` catalog, so `<T id="…" />` / `useT()` work app-wide.
|
|
29
|
+
locales: ['en', 'de'] as const,
|
|
30
|
+
defaultLocale: 'en' as const,
|
|
31
|
+
// The api this frontend consumes. The NAME ('app') is the lookup key the
|
|
32
|
+
// hooks use — `useSubscription('app', 'documents.list')`, `useMutation('app',
|
|
33
|
+
// 'documents.setBody')`. The `package` resolves the sibling api in this
|
|
34
|
+
// project (scaffold it with `--api=api-collab`).
|
|
35
|
+
apis: {
|
|
36
|
+
app: { package: '@{{projectName}}/api' },
|
|
37
|
+
},
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@{{projectName}}/{{appName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "voltro dev .",
|
|
8
|
+
"lint": "voltro doctor .",
|
|
9
|
+
"build": "voltro build .",
|
|
10
|
+
"start": "voltro start .",
|
|
11
|
+
"test": "voltro test",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@{{projectName}}/api": "0.30.0",
|
|
16
|
+
"@voltro/cli": "0.30.0",
|
|
17
|
+
"@voltro/client": "0.30.0",
|
|
18
|
+
"@voltro/env": "0.30.0",
|
|
19
|
+
"@voltro/i18n": "0.30.0",
|
|
20
|
+
"@voltro/local-first": "0.30.0",
|
|
21
|
+
"@voltro/ui-shadcn": "0.30.0",
|
|
22
|
+
"@voltro/web": "0.30.0",
|
|
23
|
+
"react": "^19.0.0",
|
|
24
|
+
"react-dom": "^19.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@voltro/devtools": "0.30.0",
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"@types/react-dom": "^19.0.0",
|
|
30
|
+
"typescript": "^6.0.3",
|
|
31
|
+
"vitest": "^4.1.10"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// German catalog. Mirrors every key in `en.ts` — `defineLocale<typeof en>()`
|
|
2
|
+
// fails the build if a key is missing or misspelled. Translate the prose only;
|
|
3
|
+
// keep ICU placeholders (`{tenant}`, `{revision}`, `{title}`, `{error}`) and
|
|
4
|
+
// rich tags (`<code>`, `<strong>`) identical to `en.ts`.
|
|
5
|
+
|
|
6
|
+
import { defineLocale } from '@voltro/i18n'
|
|
7
|
+
import en from './en'
|
|
8
|
+
|
|
9
|
+
export default defineLocale<typeof en>()({
|
|
10
|
+
'meta.editor.title': 'Kollaborativer Editor',
|
|
11
|
+
|
|
12
|
+
'nav.editor': 'Editor',
|
|
13
|
+
'nav.tagline': 'Local-First · CRDT · reaktiv',
|
|
14
|
+
'lang.label': 'Sprache',
|
|
15
|
+
|
|
16
|
+
'editor.heading': 'Kollaborativer Editor',
|
|
17
|
+
'editor.intro': 'Öffne diese Seite in <strong>zwei Browser-Tabs</strong> und tippe in beiden. Die Änderungen <strong>konvergieren</strong> — kein Last-Write-Wins-Verlierer — weil die API jedes Update serverseitig in den gemeinsamen <code>crdtText()</code>-Body mergt und live zurücksendet.',
|
|
18
|
+
'editor.status.tenant': 'Mandant <code>{tenant}</code> · Revision {revision}',
|
|
19
|
+
'editor.status.saving': ' · synchronisiere…',
|
|
20
|
+
'editor.status.loading': ' · lädt…',
|
|
21
|
+
'editor.error': 'Subscription-Fehler — läuft die API? {error}',
|
|
22
|
+
'editor.empty.heading': 'Noch kein Dokument',
|
|
23
|
+
'editor.empty.body': 'Erstelle eines, um kollaborativ zu bearbeiten.',
|
|
24
|
+
'editor.create': 'Dokument erstellen',
|
|
25
|
+
'editor.creating': 'Wird erstellt…',
|
|
26
|
+
'editor.docLabel': 'Bearbeite <strong>{title}</strong>',
|
|
27
|
+
'editor.placeholder': 'Fang an zu tippen — jeder Tastendruck synchronisiert und mergt.',
|
|
28
|
+
'editor.hint': 'Tipp: Öffne einen zweiten Tab und tippe gleichzeitig. Beide Änderungen bleiben erhalten.',
|
|
29
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Base catalog — the source of truth for this app's UI strings. Every key here
|
|
2
|
+
// MUST be mirrored in `de.ts`; `defineLocale<typeof en>()` enforces that parity
|
|
3
|
+
// at build time, so a missing translation fails typecheck rather than silently
|
|
4
|
+
// falling back.
|
|
5
|
+
|
|
6
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
7
|
+
|
|
8
|
+
export default defineCatalog({
|
|
9
|
+
// Browser tab title — locale-aware via the page's `meta({ locale })`.
|
|
10
|
+
'meta.editor.title': 'Collaborative editor',
|
|
11
|
+
|
|
12
|
+
// ---- Layout shell (pages/layout.tsx) ----
|
|
13
|
+
'nav.editor': 'Editor',
|
|
14
|
+
'nav.tagline': 'local-first · CRDT · reactive',
|
|
15
|
+
'lang.label': 'Language',
|
|
16
|
+
|
|
17
|
+
// ---- Editor page (pages/page.tsx) ----
|
|
18
|
+
'editor.heading': 'Collaborative editor',
|
|
19
|
+
'editor.intro': 'Open this page in <strong>two browser tabs</strong> and type in both. Edits <strong>converge</strong> — no last-write-wins loser — because the api merges every update into the shared <code>crdtText()</code> body server-side, then broadcasts it back live.',
|
|
20
|
+
'editor.status.tenant': 'Tenant <code>{tenant}</code> · revision {revision}',
|
|
21
|
+
'editor.status.saving': ' · syncing…',
|
|
22
|
+
'editor.status.loading': ' · loading…',
|
|
23
|
+
'editor.error': 'subscription error — is the api running? {error}',
|
|
24
|
+
'editor.empty.heading': 'No document yet',
|
|
25
|
+
'editor.empty.body': 'Create one to start editing collaboratively.',
|
|
26
|
+
'editor.create': 'Create document',
|
|
27
|
+
'editor.creating': 'Creating…',
|
|
28
|
+
'editor.docLabel': 'Editing <strong>{title}</strong>',
|
|
29
|
+
'editor.placeholder': 'Start typing — every keystroke syncs and merges.',
|
|
30
|
+
'editor.hint': 'Tip: open a second tab and type at the same time. Both edits survive.',
|
|
31
|
+
} as const)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Catalog lookup OUTSIDE React — for the page's `meta({ locale })` locale-aware
|
|
2
|
+
// <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
|
|
3
|
+
// hands `meta` the active locale from the `voltro:locale` cookie. Inside React,
|
|
4
|
+
// use `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
|
|
5
|
+
|
|
6
|
+
import en from './en'
|
|
7
|
+
import de from './de'
|
|
8
|
+
|
|
9
|
+
// Both catalogs share `en`'s keys (defineLocale enforces the mirror), so a
|
|
10
|
+
// known-key lookup returns `string` — which is what `PageMeta.title` requires
|
|
11
|
+
// under exactOptionalPropertyTypes.
|
|
12
|
+
export type Messages = Record<keyof typeof en, string>
|
|
13
|
+
|
|
14
|
+
export const getCatalog = (locale?: string): Messages =>
|
|
15
|
+
(locale === 'de' ? de : en) as Messages
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Root layout that wraps every page in this app. `<html>` / `<head>` /
|
|
2
|
+
// `<body>` are owned by the framework's index.html shell — don't render them
|
|
3
|
+
// here. Use a page's `meta` export for title, and `theme` in app.config.ts for
|
|
4
|
+
// the dark class.
|
|
5
|
+
//
|
|
6
|
+
// The app is bilingual: nav labels come from the catalog via `<T>`, and
|
|
7
|
+
// <LocaleSwitcher> writes the `voltro:locale` cookie + reloads so the framework
|
|
8
|
+
// re-renders in the chosen language.
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from 'react'
|
|
11
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
12
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
13
|
+
|
|
14
|
+
interface LayoutProps {
|
|
15
|
+
readonly children: ReactNode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LOCALES = [
|
|
19
|
+
{ code: 'en', label: 'English' },
|
|
20
|
+
{ code: 'de', label: 'Deutsch' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export default function Layout({ children }: LayoutProps): ReactNode {
|
|
24
|
+
const locale = useLocale()
|
|
25
|
+
const langLabel = useT('lang.label')
|
|
26
|
+
return (
|
|
27
|
+
<div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', fontFamily: 'system-ui, sans-serif' }}>
|
|
28
|
+
<header style={{ display: 'flex', gap: 16, alignItems: 'baseline', marginBottom: 24 }}>
|
|
29
|
+
<strong style={{ fontSize: 16 }}>{{capProjectName}}</strong>
|
|
30
|
+
<nav style={{ display: 'flex', gap: 12, fontSize: 13 }}>
|
|
31
|
+
<a href="/"><T id="nav.editor" /></a>
|
|
32
|
+
</nav>
|
|
33
|
+
<span style={{ color: '#999', fontSize: 12, marginLeft: 'auto' }}><T id="nav.tagline" /></span>
|
|
34
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
35
|
+
</header>
|
|
36
|
+
<main>{children}</main>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// `/` — the collaborative editor. It binds `useSubscription('app',
|
|
4
|
+
// 'documents.list')` + two `useMutation`s ('documents.create',
|
|
5
|
+
// 'documents.setBody') from @voltro/client, which we mock so the render is
|
|
6
|
+
// deterministic. `@voltro/local-first` is NOT mocked — the editor's real local
|
|
7
|
+
// CRDT handle decodes the seeded body and turns keystrokes into encoded updates,
|
|
8
|
+
// which is the behaviour worth asserting.
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
11
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
12
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
13
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
14
|
+
import { crdtText } from '@voltro/local-first'
|
|
15
|
+
import enCatalog from '../locales/en'
|
|
16
|
+
|
|
17
|
+
interface SubState {
|
|
18
|
+
data: unknown
|
|
19
|
+
revision?: number
|
|
20
|
+
error?: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const useSubscription = vi.fn<() => SubState>()
|
|
24
|
+
const createMutate = vi.fn<(input: unknown) => Promise<unknown>>()
|
|
25
|
+
const setBodyMutate = vi.fn<(input: unknown) => Promise<unknown>>()
|
|
26
|
+
|
|
27
|
+
// useMutation is called once per tag — dispatch to the right mock by tag.
|
|
28
|
+
const useMutation = vi.fn((_api: unknown, tag: unknown) =>
|
|
29
|
+
tag === 'documents.create'
|
|
30
|
+
? { mutate: createMutate, pending: false, error: undefined, data: undefined }
|
|
31
|
+
: { mutate: setBodyMutate, pending: false, error: undefined, data: undefined },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
vi.mock('@voltro/client', () => ({
|
|
35
|
+
useSubscription: (...a: unknown[]) => useSubscription(...(a as [])),
|
|
36
|
+
useMutation: (...a: unknown[]) => useMutation(...(a as Parameters<typeof useMutation>)),
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
const { default: IndexPage } = await import('./page')
|
|
40
|
+
|
|
41
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
42
|
+
|
|
43
|
+
let container: HTMLDivElement
|
|
44
|
+
let root: Root
|
|
45
|
+
|
|
46
|
+
const render = (node: ReactNode): void => {
|
|
47
|
+
container = document.createElement('div')
|
|
48
|
+
document.body.appendChild(container)
|
|
49
|
+
act(() => {
|
|
50
|
+
root = createRoot(container)
|
|
51
|
+
root.render(
|
|
52
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const setTextarea = (el: HTMLTextAreaElement, value: string): void => {
|
|
58
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!
|
|
59
|
+
act(() => {
|
|
60
|
+
setter.call(el, value)
|
|
61
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A real encoded CRDT body seeded with `text`, exactly what the subscription
|
|
66
|
+
// streams for a document that already has content.
|
|
67
|
+
const bodyWith = (text: string): Uint8Array => crdtText().insert(0, text).encode()
|
|
68
|
+
|
|
69
|
+
const docRow = (over: Partial<{ id: string; title: string; body: Uint8Array | null }> = {}) => ({
|
|
70
|
+
id: over.id ?? 'doc_1',
|
|
71
|
+
title: over.title ?? 'Shared document',
|
|
72
|
+
body: over.body ?? null,
|
|
73
|
+
tenantId: 'acme',
|
|
74
|
+
createdAt: new Date(0),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
useSubscription.mockReset()
|
|
79
|
+
createMutate.mockReset()
|
|
80
|
+
createMutate.mockResolvedValue(undefined)
|
|
81
|
+
setBodyMutate.mockReset()
|
|
82
|
+
setBodyMutate.mockResolvedValue(undefined)
|
|
83
|
+
useMutation.mockClear()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
if (root) act(() => root.unmount())
|
|
88
|
+
container?.remove()
|
|
89
|
+
document.body.innerHTML = ''
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('collab editor — subscription states', () => {
|
|
93
|
+
test('shows the loading hint before the first snapshot (data undefined)', () => {
|
|
94
|
+
useSubscription.mockReturnValue({ data: undefined, revision: -1 })
|
|
95
|
+
render(createElement(IndexPage))
|
|
96
|
+
expect(container.textContent).toContain('loading…')
|
|
97
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('shows the empty state + create button once a snapshot arrives with no documents', () => {
|
|
101
|
+
useSubscription.mockReturnValue({ data: [], revision: 0 })
|
|
102
|
+
render(createElement(IndexPage))
|
|
103
|
+
expect(container.textContent).toContain('No document yet')
|
|
104
|
+
expect(container.querySelector('button')).not.toBeNull()
|
|
105
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('renders the error banner when the subscription errors', () => {
|
|
109
|
+
useSubscription.mockReturnValue({ data: undefined, error: 'boom', revision: -1 })
|
|
110
|
+
render(createElement(IndexPage))
|
|
111
|
+
expect(container.textContent).toContain('subscription error')
|
|
112
|
+
expect(container.textContent).toContain('boom')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('collab editor — create', () => {
|
|
117
|
+
test('clicking Create document calls documents.create with the tenant + a title', async () => {
|
|
118
|
+
useSubscription.mockReturnValue({ data: [], revision: 0 })
|
|
119
|
+
render(createElement(IndexPage))
|
|
120
|
+
const button = container.querySelector('button') as HTMLButtonElement
|
|
121
|
+
await act(async () => {
|
|
122
|
+
button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
123
|
+
})
|
|
124
|
+
expect(createMutate).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(createMutate).toHaveBeenCalledWith({ tenantId: 'acme', title: 'Shared document' })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('collab editor — editing a document', () => {
|
|
130
|
+
test('binds the textarea to the decoded CRDT body', () => {
|
|
131
|
+
useSubscription.mockReturnValue({ data: [docRow({ body: bodyWith('Hello') })], revision: 3 })
|
|
132
|
+
render(createElement(IndexPage))
|
|
133
|
+
const textarea = container.querySelector('textarea') as HTMLTextAreaElement
|
|
134
|
+
expect(textarea).not.toBeNull()
|
|
135
|
+
expect(textarea.value).toBe('Hello')
|
|
136
|
+
expect(container.textContent).toContain('Shared document')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('typing sends an encoded CRDT update via documents.setBody', () => {
|
|
140
|
+
useSubscription.mockReturnValue({ data: [docRow({ body: bodyWith('Hello') })], revision: 3 })
|
|
141
|
+
render(createElement(IndexPage))
|
|
142
|
+
const textarea = container.querySelector('textarea') as HTMLTextAreaElement
|
|
143
|
+
setTextarea(textarea, 'Hello!')
|
|
144
|
+
|
|
145
|
+
expect(setBodyMutate).toHaveBeenCalledTimes(1)
|
|
146
|
+
const arg = setBodyMutate.mock.calls[0]![0] as { id: string; update: unknown }
|
|
147
|
+
expect(arg.id).toBe('doc_1')
|
|
148
|
+
expect(arg.update).toBeInstanceOf(Uint8Array)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `/` — the collaborative document editor. Auto-mapped from src/pages/page.tsx
|
|
2
|
+
// by the framework's file-convention page discovery.
|
|
3
|
+
//
|
|
4
|
+
// THIS is local-first collaboration over the reactive loop. The api's
|
|
5
|
+
// `documents` table has a `crdtText()` `body`; this page:
|
|
6
|
+
// 1. `useSubscription('app','documents.list')` — streams the shared document
|
|
7
|
+
// (with its MERGED body) live over WebSocket,
|
|
8
|
+
// 2. keeps a LOCAL CRDT handle (`@voltro/local-first`'s `crdtText()`) seeded
|
|
9
|
+
// from the server state — every keystroke applies a local insert/delete
|
|
10
|
+
// and sends the encoded update via `useMutation('app','documents.setBody')`,
|
|
11
|
+
// 3. folds every incoming server body back into that handle.
|
|
12
|
+
//
|
|
13
|
+
// The convergence is authoritative and SERVER-SIDE: the api merges each update
|
|
14
|
+
// into the stored state before broadcasting, so two tabs typing at once both
|
|
15
|
+
// survive — no last-write-wins loser. Open this page in two tabs to see it.
|
|
16
|
+
//
|
|
17
|
+
// This is the app-level binding the docs describe as the SyncClient transport:
|
|
18
|
+
// `push` = the setBody mutation, `onRemoteState` = the documents subscription.
|
|
19
|
+
|
|
20
|
+
import { useMutation, useSubscription } from '@voltro/client'
|
|
21
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
22
|
+
import type { PageMeta } from '@voltro/web'
|
|
23
|
+
import { T, useTFn } from '@voltro/i18n'
|
|
24
|
+
import { crdtText, emptyCrdtState, type CrdtState, type CrdtText } from '@voltro/local-first'
|
|
25
|
+
import { getCatalog } from '../locales'
|
|
26
|
+
|
|
27
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
28
|
+
title: getCatalog(locale)['meta.editor.title'],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// The dev AuthMiddleware resolves the tenant from the `x-tenant` header,
|
|
32
|
+
// defaulting to 'acme'; the `documents` table's `tenant()` mixin auto-scopes
|
|
33
|
+
// reads/writes to it.
|
|
34
|
+
const TENANT = 'acme'
|
|
35
|
+
|
|
36
|
+
interface DocRow {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly title: string
|
|
39
|
+
// The encoded CRDT state. The api declares `body` as
|
|
40
|
+
// `Schema.Uint8ArrayFromBase64`, so the rpc client hands it back as a
|
|
41
|
+
// `Uint8Array`; we still accept a raw base64 `string` defensively.
|
|
42
|
+
readonly body: Uint8Array | string | null
|
|
43
|
+
readonly tenantId: string
|
|
44
|
+
readonly createdAt: Date
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const base64ToBytes = (b64: string): Uint8Array =>
|
|
48
|
+
Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
|
|
49
|
+
|
|
50
|
+
// A null/absent body decodes to the empty document — the CRDT merge identity.
|
|
51
|
+
const toState = (body: Uint8Array | string | null): CrdtState =>
|
|
52
|
+
body instanceof Uint8Array ? body : typeof body === 'string' ? base64ToBytes(body) : emptyCrdtState()
|
|
53
|
+
|
|
54
|
+
// Turn a whole-string edit into a single contiguous CRDT insert/delete: the
|
|
55
|
+
// common prefix + suffix are unchanged; the middle is what the user replaced.
|
|
56
|
+
// Covers ordinary typing, deletion, and paste (one contiguous run).
|
|
57
|
+
const applyDiff = (handle: CrdtText, prev: string, next: string): void => {
|
|
58
|
+
if (prev === next) return
|
|
59
|
+
let start = 0
|
|
60
|
+
const shorter = Math.min(prev.length, next.length)
|
|
61
|
+
while (start < shorter && prev[start] === next[start]) start += 1
|
|
62
|
+
let end = 0
|
|
63
|
+
while (
|
|
64
|
+
end < shorter - start &&
|
|
65
|
+
prev[prev.length - 1 - end] === next[next.length - 1 - end]
|
|
66
|
+
) {
|
|
67
|
+
end += 1
|
|
68
|
+
}
|
|
69
|
+
const deleteLen = prev.length - start - end
|
|
70
|
+
const inserted = next.slice(start, next.length - end)
|
|
71
|
+
if (deleteLen > 0) handle.delete(start, deleteLen)
|
|
72
|
+
if (inserted.length > 0) handle.insert(start, inserted)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One editor bound to ONE document. Remounted (via `key={doc.id}`) when the
|
|
76
|
+
// bound document changes, so the local CRDT handle's lifecycle is scoped to it.
|
|
77
|
+
const DocumentEditor = ({
|
|
78
|
+
doc,
|
|
79
|
+
saving,
|
|
80
|
+
onUpdate,
|
|
81
|
+
}: {
|
|
82
|
+
readonly doc: DocRow
|
|
83
|
+
readonly saving: boolean
|
|
84
|
+
readonly onUpdate: (update: CrdtState) => void
|
|
85
|
+
}): ReactNode => {
|
|
86
|
+
const t = useTFn()
|
|
87
|
+
// One local CRDT handle per mount, seeded from the current server state.
|
|
88
|
+
const handleRef = useRef<CrdtText | null>(null)
|
|
89
|
+
if (handleRef.current === null) handleRef.current = crdtText().merge(toState(doc.body))
|
|
90
|
+
const [text, setText] = useState<string>(() => handleRef.current!.toString())
|
|
91
|
+
|
|
92
|
+
// Fold every incoming server body into the local handle — remote edits appear
|
|
93
|
+
// and converge. Idempotent, so re-merging our own echoed update is a no-op.
|
|
94
|
+
const serverBody = doc.body
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const handle = handleRef.current!
|
|
97
|
+
handle.merge(toState(serverBody))
|
|
98
|
+
const merged = handle.toString()
|
|
99
|
+
setText((prev) => (prev === merged ? prev : merged))
|
|
100
|
+
}, [serverBody])
|
|
101
|
+
|
|
102
|
+
const handleChange = (next: string): void => {
|
|
103
|
+
const handle = handleRef.current!
|
|
104
|
+
applyDiff(handle, handle.toString(), next)
|
|
105
|
+
setText(handle.toString())
|
|
106
|
+
onUpdate(handle.encode()) // full encoded state; the server folds it in
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<section>
|
|
111
|
+
<p style={{ color: '#666', fontSize: 13, margin: '0 0 8px' }}>
|
|
112
|
+
<T id="editor.docLabel" values={{ title: doc.title, strong: (c: ReactNode) => <strong>{c}</strong> }} />
|
|
113
|
+
{saving ? <T id="editor.status.saving" /> : null}
|
|
114
|
+
</p>
|
|
115
|
+
<textarea
|
|
116
|
+
value={text}
|
|
117
|
+
onChange={(event) => handleChange(event.target.value)}
|
|
118
|
+
placeholder={t('editor.placeholder')}
|
|
119
|
+
rows={12}
|
|
120
|
+
style={{ width: '100%', padding: '10px 12px', fontSize: 14, fontFamily: 'ui-monospace, monospace', resize: 'vertical', boxSizing: 'border-box' }}
|
|
121
|
+
/>
|
|
122
|
+
<p style={{ color: '#999', fontSize: 12, marginTop: 8 }}><T id="editor.hint" /></p>
|
|
123
|
+
</section>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const IndexPage = (): ReactNode => {
|
|
128
|
+
const { data, revision, error } = useSubscription<ReadonlyArray<DocRow>>('app', 'documents.list')
|
|
129
|
+
const create = useMutation<{ tenantId: string; title: string }>('app', 'documents.create')
|
|
130
|
+
const setBody = useMutation<{ id: string; update: CrdtState }>('app', 'documents.setBody')
|
|
131
|
+
|
|
132
|
+
const docs = data ?? []
|
|
133
|
+
const doc = docs[0]
|
|
134
|
+
|
|
135
|
+
const handleCreate = async (): Promise<void> => {
|
|
136
|
+
try {
|
|
137
|
+
await create.mutate({ tenantId: TENANT, title: 'Shared document' })
|
|
138
|
+
} catch (mutationError) {
|
|
139
|
+
console.error('documents.create failed:', mutationError)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handleUpdate = (id: string) => (update: CrdtState): void => {
|
|
144
|
+
setBody.mutate({ id, update }).catch((mutationError) => {
|
|
145
|
+
console.error('documents.setBody failed:', mutationError)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
<h1 style={{ fontSize: 24, marginBottom: 4 }}><T id="editor.heading" /></h1>
|
|
152
|
+
<p style={{ color: '#444', marginTop: 0, fontSize: 14 }}>
|
|
153
|
+
<T
|
|
154
|
+
id="editor.intro"
|
|
155
|
+
values={{ strong: (c: ReactNode) => <strong>{c}</strong>, code: (c: ReactNode) => <code>{c}</code> }}
|
|
156
|
+
/>
|
|
157
|
+
</p>
|
|
158
|
+
<p style={{ color: '#666', marginTop: 0, fontSize: 13 }}>
|
|
159
|
+
<T id="editor.status.tenant" values={{ tenant: TENANT, revision, code: (c: ReactNode) => <code>{c}</code> }} />
|
|
160
|
+
{data === undefined && error === undefined && <T id="editor.status.loading" />}
|
|
161
|
+
</p>
|
|
162
|
+
|
|
163
|
+
{error !== undefined && (
|
|
164
|
+
<p style={{ color: '#c00', fontSize: 13 }}>
|
|
165
|
+
<T id="editor.error" values={{ error: String(error) }} />
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{doc === undefined && data !== undefined ? (
|
|
170
|
+
<section style={{ border: '1px dashed #ccc', borderRadius: 8, padding: 24, textAlign: 'center' }}>
|
|
171
|
+
<h2 style={{ fontSize: 16, margin: '0 0 4px' }}><T id="editor.empty.heading" /></h2>
|
|
172
|
+
<p style={{ color: '#777', fontSize: 13, marginTop: 0 }}><T id="editor.empty.body" /></p>
|
|
173
|
+
<button type="button" onClick={handleCreate} disabled={create.pending}>
|
|
174
|
+
{create.pending ? <T id="editor.creating" /> : <T id="editor.create" />}
|
|
175
|
+
</button>
|
|
176
|
+
</section>
|
|
177
|
+
) : null}
|
|
178
|
+
|
|
179
|
+
{doc !== undefined ? (
|
|
180
|
+
<DocumentEditor key={doc.id} doc={doc} saving={setBody.pending} onUpdate={handleUpdate(doc.id)} />
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default IndexPage
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "frontend-collab",
|
|
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"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"jsx": "react-jsx",
|
|
5
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler"
|
|
8
|
+
},
|
|
9
|
+
"include": ["src/**/*"],
|
|
10
|
+
"exclude": ["node_modules", "dist", ".framework"]
|
|
11
|
+
}
|
|
@@ -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.30.0",
|
|
19
|
+
"@voltro/client": "0.30.0",
|
|
20
|
+
"@voltro/env": "0.30.0",
|
|
21
|
+
"@voltro/i18n": "0.30.0",
|
|
22
|
+
"@voltro/serverless": "0.30.0",
|
|
23
|
+
"@voltro/web": "0.30.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.30.0",
|
|
30
30
|
"@types/react": "^19.0.0",
|
|
31
31
|
"@types/react-dom": "^19.0.0",
|
|
32
32
|
"typescript": "^6.0.3",
|
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
// <I18nProvider> from these codes and loads the matching
|
|
33
33
|
// `src/locales/<code>.ts` catalog — so `<T id="…" />` / `useT()` /
|
|
34
34
|
// `useLocale()` work app-wide with no manual setup. The <LocaleSwitcher>
|
|
35
|
-
// in the layout writes the `voltro:
|
|
35
|
+
// in the layout writes the `voltro:locale` cookie + reloads. Grow the app to
|
|
36
36
|
// more languages by adding a code here + a `src/locales/<code>.ts`.
|
|
37
37
|
locales: ['en', 'de'] as const,
|
|
38
38
|
defaultLocale: 'en' as const,
|
|
@@ -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.30.0",
|
|
16
|
+
"@voltro/client": "0.30.0",
|
|
17
|
+
"@voltro/env": "0.30.0",
|
|
18
|
+
"@voltro/i18n": "0.30.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.30.0",
|
|
20
|
+
"@voltro/web": "0.30.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.30.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Catalog lookup OUTSIDE React — for a page's `meta({ locale })` locale-aware
|
|
2
2
|
// <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
|
|
3
|
-
// hands `meta` the active locale from the `voltro:
|
|
3
|
+
// hands `meta` the active locale from the `voltro:locale` cookie. Inside React,
|
|
4
4
|
// use `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
|
|
5
5
|
|
|
6
6
|
import en from './en'
|
|
@@ -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.30.0",
|
|
16
|
+
"@voltro/client": "0.30.0",
|
|
17
|
+
"@voltro/env": "0.30.0",
|
|
18
|
+
"@voltro/i18n": "0.30.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.30.0",
|
|
20
|
+
"@voltro/web": "0.30.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.30.0",
|
|
26
26
|
"@types/react": "^19.0.0",
|
|
27
27
|
"@types/react-dom": "^19.0.0",
|
|
28
28
|
"typescript": "^6.0.3",
|