@voltro/cli 0.53.0 → 0.55.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 +335 -0
- package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
- package/dist/agentsMd-DCY1RSs8.js +2 -0
- package/dist/{apiBuild-CaPfoWku.js → apiBuild-CMvLJM_K.js} +2 -2
- package/dist/apiBuild-Cl0IDx8c.js +2 -0
- package/dist/bin.js +1 -1
- package/dist/{build-D-OnvNMf.js → build-S0QOzqPT.js} +115 -115
- package/dist/{checkCommand-D2ZduVlh.js → checkCommand-DNkY5kwF.js} +1 -1
- package/dist/{checkCommand-C5elt0tW.js → checkCommand-fbj9GDjN.js} +6 -6
- package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
- package/dist/codegen-CN6vMM4J.js +2 -0
- package/dist/{codegen-FEk8AZHb.js → codegen-SIepQtUl.js} +76 -65
- package/dist/codegenCommand-3TDJezom.js +42 -0
- package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-C2zxZUIw.js} +64 -9
- package/dist/{commands-DyxAmhP0.js → commands-BBYJ7Q3B.js} +96 -73
- package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-D2kmyCLL.js} +3 -3
- package/dist/{dataCommand-Bab9X7s8.js → dataCommand-BEPPQiTl.js} +267 -195
- package/dist/dbCommand-BTyBGhIA.js +2 -0
- package/dist/{dbCommand-06O2finM.js → dbCommand-DZTmOFT4.js} +3 -3
- package/dist/{dev-C6LGF4iY.js → dev-Ca_A_S9v.js} +2439 -2397
- package/dist/{dev-GjJWAYo2.js → dev-DfVZaoys.js} +1 -1
- package/dist/{doctorCommand-etMkflRc.js → doctorCommand-CGZJK_4o.js} +21 -21
- package/dist/doctorCommand-djmqEcDC.js +2 -0
- package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-DY2rYpTa.js} +1 -1
- package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-BoCqZsgp.js} +1 -1
- package/dist/{envCommand-dSyKvRkM.js → envCommand-Bxy2fOjc.js} +15 -15
- package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BsbZ-XDg.js} +2 -2
- package/dist/fileConventions-l-RIXbx8.js +36 -0
- package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
- package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
- package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-Do-cf6RJ.js} +96 -102
- package/dist/index.js +2 -2
- package/dist/{infoCommand-_53iOc_j.js → infoCommand-EmM3jPKD.js} +1 -1
- package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
- package/dist/inspect-DGJwpOAb.js +2 -0
- package/dist/interruptedReplace-CwnkBb2X.js +41 -0
- package/dist/interruptedReplace-qzmFI020.js +2 -0
- package/dist/manifestBuild-CJ2zvPvT.js +2 -0
- package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
- package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
- package/dist/{migrate-Cko9rswM.js → migrate-CGFZS-1a.js} +2 -2
- package/dist/mobileCommand-D9O6iq3D.js +428 -0
- package/dist/mobileCommand-DAum7tsG.js +2 -0
- package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
- package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
- package/dist/{probeCommand-DkGGLknv.js → probeCommand-Bs3iVBSL.js} +1 -1
- 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-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
- package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-C1BTpHGQ.js} +1 -1
- package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CXMwLg9n.js} +1 -1
- package/dist/{serveCommand-CueKQgzl.js → serveCommand-C7IrCD58.js} +899 -897
- package/dist/serveCommand-Cjt5S9hD.js +2 -0
- package/dist/serveEntry.js +1 -1
- package/dist/start-DH7cat4-.js +3 -0
- package/dist/{start-ekPan8BT.js → start-EOV7s1NZ.js} +544 -527
- package/dist/startEntry.js +1 -1
- package/dist/{staticCommand-xlSL-IWk.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-BWPQcRoB.js → test-DO27-x2P.js} +1 -1
- package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-C_jN1w18.js} +1 -1
- package/dist/updateCommand-nnFjDbl4.js +2 -0
- package/dist/{webDev-C7jWJ5dX.js → webDev-1XpVnYkW.js} +1 -1
- package/dist/{webDev-oczpugbx.js → webDev-B7vNj4Bq.js} +1231 -1186
- package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-B1LVcyO3.js} +1 -1
- package/dist/workspaceDeps-RKEkX92S.js +45 -0
- package/package.json +31 -19
- package/templates/AGENTS.core.md +2 -0
- package/templates/AGENTS.md +4 -2
- package/templates/agent-docs/_index.md +2 -2
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/ai.md +4 -4
- package/templates/agent-docs/authentication.md +115 -0
- package/templates/agent-docs/cli.md +98 -12
- package/templates/agent-docs/data.md +121 -21
- package/templates/agent-docs/database/advancedqueries.md +1 -1
- package/templates/agent-docs/database/migrations.md +1 -1
- package/templates/agent-docs/database/seedsdialects.md +64 -2
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/introduction.md +25 -0
- package/templates/agent-docs/local-first-mobile.md +139 -41
- package/templates/agent-docs/observability.md +4 -2
- package/templates/agent-docs/plugins/atlassian.md +2 -2
- package/templates/agent-docs/plugins/audit.md +2 -2
- 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 +22 -0
- package/templates/agent-docs/plugins/presence.md +32 -3
- package/templates/agent-docs/plugins/prometheus.md +2 -0
- package/templates/agent-docs/plugins/queue.md +47 -4
- package/templates/agent-docs/plugins.md +52 -14
- package/templates/agent-docs/reference.md +25 -4
- package/templates/agent-docs/routing.md +81 -9
- package/templates/agent-docs/scheduling.md +1 -1
- package/templates/agent-docs/schema-driven-ui.md +137 -1
- package/templates/agent-docs/templates/appshells.md +36 -4
- package/templates/agent-docs/whats-new.md +75 -158
- 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/package.json +7 -6
- 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/package.json +8 -7
- 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/package.json +8 -7
- 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-DHtLXYx9.js +0 -2
- package/dist/codegen-BWpt3VgF.js +0 -2
- package/dist/codegenCommand-BOiWQ5hz.js +0 -137
- package/dist/dbCommand-B1EXBC6f.js +0 -2
- package/dist/doctorCommand-B0hX0tdz.js +0 -2
- package/dist/fileConventions-DASGEmj-.js +0 -35
- package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
- package/dist/inspect-CuoDInfZ.js +0 -2
- package/dist/interruptedReplace-C3O3M1MM.js +0 -28
- package/dist/interruptedReplace-CvmiAM9K.js +0 -2
- package/dist/manifestBuild-C4-J1-m_.js +0 -2
- package/dist/renderModeScan-CUbOeOAg.js +0 -122
- package/dist/serveCommand-DsnrVN3U.js +0 -2
- package/dist/start-BJzZLbt8.js +0 -3
- package/dist/updateCommand-Bqql_rsQ.js +0 -2
|
@@ -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.55.0",
|
|
19
|
+
"@voltro/client": "0.55.0",
|
|
20
|
+
"@voltro/env": "0.55.0",
|
|
21
|
+
"@voltro/i18n": "0.55.0",
|
|
22
|
+
"@voltro/serverless": "0.55.0",
|
|
23
|
+
"@voltro/web": "0.55.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.55.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.55.0",
|
|
16
|
+
"@voltro/client": "0.55.0",
|
|
17
|
+
"@voltro/env": "0.55.0",
|
|
18
|
+
"@voltro/i18n": "0.55.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.55.0",
|
|
20
|
+
"@voltro/web": "0.55.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.55.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,19 @@
|
|
|
12
12
|
"test": "voltro test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/
|
|
15
|
+
"@voltro/cli": "0.55.0",
|
|
16
|
+
"@voltro/client": "0.55.0",
|
|
17
|
+
"@voltro/content": "0.55.0",
|
|
18
|
+
"@voltro/env": "0.55.0",
|
|
19
|
+
"@voltro/i18n": "0.55.0",
|
|
20
|
+
"@voltro/ui-shadcn": "0.55.0",
|
|
21
|
+
"@voltro/web": "0.55.0",
|
|
16
22
|
"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",
|
|
22
23
|
"react": "^19.0.0",
|
|
23
24
|
"react-dom": "^19.0.0"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
|
-
"@voltro/devtools": "0.
|
|
27
|
+
"@voltro/devtools": "0.55.0",
|
|
27
28
|
"@types/react": "^19.0.0",
|
|
28
29
|
"@types/react-dom": "^19.0.0",
|
|
29
30
|
"jsdom": "^30.0.0",
|
|
@@ -12,16 +12,16 @@
|
|
|
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/web": "0.
|
|
15
|
+
"@voltro/cli": "0.55.0",
|
|
16
|
+
"@voltro/client": "0.55.0",
|
|
17
|
+
"@voltro/env": "0.55.0",
|
|
18
|
+
"@voltro/i18n": "0.55.0",
|
|
19
|
+
"@voltro/web": "0.55.0",
|
|
20
20
|
"react": "^19.0.0",
|
|
21
21
|
"react-dom": "^19.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/devtools": "0.
|
|
24
|
+
"@voltro/devtools": "0.55.0",
|
|
25
25
|
"@types/react": "^19.0.0",
|
|
26
26
|
"@types/react-dom": "^19.0.0",
|
|
27
27
|
"jsdom": "^30.0.0",
|
|
@@ -15,3 +15,51 @@ pnpm --filter @{{projectName}}/{{appName}} dev
|
|
|
15
15
|
When you need a contact form / sign-up flow, switch
|
|
16
16
|
`interactive: 'islands'` on the page and mark the interactive
|
|
17
17
|
component with the `*.island.tsx` suffix so only that bundle ships.
|
|
18
|
+
|
|
19
|
+
## The hero image — `?image` + `<Image>`
|
|
20
|
+
|
|
21
|
+
`src/assets/hero.jpg` is imported with the **`?image` suffix**, which is the
|
|
22
|
+
explicit opt-in to the build-time pipeline: the import resolves to an optimized
|
|
23
|
+
**asset object** instead of vite's plain hashed URL, and `<Image src={hero}>`
|
|
24
|
+
renders it as a `<picture>` with one `<source>` per modern format.
|
|
25
|
+
|
|
26
|
+
- **`voltro build`** encodes AVIF + WebP + a jpeg fallback at every ladder width
|
|
27
|
+
up to the intrinsic 1200px into `dist/assets/` under content hashes, through a
|
|
28
|
+
persistent cache (`.framework/image-cache/`) — a rebuild re-encodes nothing.
|
|
29
|
+
- **`voltro dev`** transforms on demand from `/_voltro/image/<assetId>`.
|
|
30
|
+
- **`voltro start`** serves build artifacts only; there is deliberately no
|
|
31
|
+
production transform endpoint.
|
|
32
|
+
|
|
33
|
+
`width`, `height` and the blur placeholder are **not props** — they are read off
|
|
34
|
+
the asset, which is what reserves the box (CLS ≈ 0) without hand-written
|
|
35
|
+
numbers. `priority` marks it the LCP image (eager + high fetch priority).
|
|
36
|
+
|
|
37
|
+
Encoding runs on [sharp](https://sharp.pixelplumbing.com), an optional
|
|
38
|
+
dependency of `@voltro/cli` — nothing to install. If your installer omits
|
|
39
|
+
optional dependencies the pipeline serves the original with one loud warning
|
|
40
|
+
naming the fix; `voltro doctor` tells "not installed" from "binary missing".
|
|
41
|
+
|
|
42
|
+
A **dynamic** `src` (a URL from a loader or CMS frontmatter) cannot be seen at
|
|
43
|
+
build time — use the loader seam instead: `<Image src={url} loader={cdn} />`.
|
|
44
|
+
|
|
45
|
+
## The font — self-hosted, no CDN
|
|
46
|
+
|
|
47
|
+
`app.config.ts` declares one family under `fonts:`, pointing at the woff2
|
|
48
|
+
committed in `src/fonts/`. The build content-hashes it and serves it from **your
|
|
49
|
+
origin**, emits `@font-face`, computes a **size-adjusted fallback face** from the
|
|
50
|
+
file's real metrics so the swap moves no text, and puts a `<link rel="preload">`
|
|
51
|
+
in the one shell every render mode serves. `subsets: ['latin']` ships only the
|
|
52
|
+
range the page's characters need.
|
|
53
|
+
|
|
54
|
+
Nothing talks to Google Fonts (or any font host) at runtime — the visitor's IP
|
|
55
|
+
never reaches a font CDN, because no request leaves your domain.
|
|
56
|
+
|
|
57
|
+
Reference the family from CSS via the `--font-geist` variable the shell defines
|
|
58
|
+
(`src/globals.css` points the kit's `--font-sans` at it), or from TSX via
|
|
59
|
+
`localFont('Geist')` — a pure name mapper, so the slug rule stays the
|
|
60
|
+
pipeline's.
|
|
61
|
+
|
|
62
|
+
**Swapping in your own face:** drop the `woff2` **and its licence file** into
|
|
63
|
+
`src/fonts/`, then change `family` + `path` in `app.config.ts`. The framework
|
|
64
|
+
ships no font downloader on purpose — licence terms differ per family. The
|
|
65
|
+
bundled Geist is SIL OFL 1.1 (see `src/fonts/LICENSE-Geist.txt`).
|
|
@@ -21,5 +21,33 @@ export default {
|
|
|
21
21
|
// drives the [locale] mirror routing + the language switcher.
|
|
22
22
|
locales: ['en', 'de'] as const,
|
|
23
23
|
defaultLocale: 'en' as const,
|
|
24
|
+
// Self-hosted local font. The file is committed next to this config
|
|
25
|
+
// (src/fonts/) WITH its licence — Geist is SIL OFL 1.1 (Vercel /
|
|
26
|
+
// basement.studio), see src/fonts/LICENSE-Geist.txt. The framework ships no
|
|
27
|
+
// font DOWNLOADER on purpose (licence terms differ per family), so swapping
|
|
28
|
+
// in your own brand face is: drop the woff2 + its licence here, change
|
|
29
|
+
// `family` + `path`.
|
|
30
|
+
//
|
|
31
|
+
// What `voltro build` / `voltro dev` do with this: content-hash the file and
|
|
32
|
+
// serve it from YOUR origin (no font-CDN request ever leaves the browser —
|
|
33
|
+
// the GDPR answer), emit `@font-face` with `font-display`, compute a
|
|
34
|
+
// SIZE-ADJUSTED `'Geist Fallback'` face from the file's real metrics so the
|
|
35
|
+
// swap moves no text (CLS ≈ 0), and put a `<link rel="preload">` in the one
|
|
36
|
+
// generated shell every render mode serves.
|
|
37
|
+
//
|
|
38
|
+
// ONE variable face covers the whole 100–900 range, so the page makes a
|
|
39
|
+
// single font request. `subsets: ['latin']` (U+0000–00FF) covers both
|
|
40
|
+
// shipped locales — German umlauts and ß live inside that range.
|
|
41
|
+
//
|
|
42
|
+
// src/globals.css points the kit's `--font-sans` at the `--font-geist`
|
|
43
|
+
// variable the shell defines; src/pages/page.tsx reads the same variable
|
|
44
|
+
// from TSX via `localFont('Geist')`.
|
|
45
|
+
fonts: [{
|
|
46
|
+
family: 'Geist',
|
|
47
|
+
src: [{ path: 'src/fonts/Geist-Variable.woff2', weight: '100 900' }],
|
|
48
|
+
display: 'swap' as const,
|
|
49
|
+
subsets: ['latin'] as ReadonlyArray<'latin' | 'latin-ext'>,
|
|
50
|
+
fallback: 'Arial' as const,
|
|
51
|
+
}],
|
|
24
52
|
env,
|
|
25
53
|
}
|
|
@@ -12,16 +12,17 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@voltro/cli": "0.
|
|
16
|
-
"@voltro/client": "0.
|
|
17
|
-
"@voltro/env": "0.
|
|
18
|
-
"@voltro/i18n": "0.
|
|
19
|
-
"@voltro/
|
|
15
|
+
"@voltro/cli": "0.55.0",
|
|
16
|
+
"@voltro/client": "0.55.0",
|
|
17
|
+
"@voltro/env": "0.55.0",
|
|
18
|
+
"@voltro/i18n": "0.55.0",
|
|
19
|
+
"@voltro/ui-shadcn": "0.55.0",
|
|
20
|
+
"@voltro/web": "0.55.0",
|
|
20
21
|
"react": "^19.0.0",
|
|
21
22
|
"react-dom": "^19.0.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@voltro/devtools": "0.
|
|
25
|
+
"@voltro/devtools": "0.55.0",
|
|
25
26
|
"@types/react": "^19.0.0",
|
|
26
27
|
"@types/react-dom": "^19.0.0",
|
|
27
28
|
"jsdom": "^30.0.0",
|
|
Binary file
|
|
Binary file
|