diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 784b0871..4a5a3f0d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: 'pnpm'
- name: Install dependencies
@@ -40,6 +40,8 @@ jobs:
- name: Build
run: pnpm -w build
+ env:
+ NODE_OPTIONS: --max-old-space-size=8192
- name: Knip
run: pnpm --filter @btst/stack run knip --reporter github-actions
diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml
index a33deda4..a2e7ab4e 100644
--- a/.github/workflows/examples.yml
+++ b/.github/workflows/examples.yml
@@ -8,6 +8,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'demos/**'
+ - '.github/workflows/examples.yml'
concurrency:
group: demos-${{ github.ref }}
@@ -15,12 +16,8 @@ concurrency:
jobs:
build:
- name: Build — ${{ matrix.demo }}
+ name: Demo Projects
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- demo: [ai-chat, blog, cms, form-builder, kanban, ui-builder]
steps:
- name: Checkout
@@ -34,14 +31,53 @@ jobs:
with:
node-version: 22
cache: 'pnpm'
- cache-dependency-path: demos/${{ matrix.demo }}/pnpm-lock.yaml
- - name: Install dependencies
- working-directory: demos/${{ matrix.demo }}
+ - name: Install — ai-chat
+ working-directory: demos/ai-chat
run: pnpm install
- - name: Build
- working-directory: demos/${{ matrix.demo }}
+ - name: Build — ai-chat
+ working-directory: demos/ai-chat
run: pnpm build
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+
+ - name: Install — blog
+ working-directory: demos/blog
+ run: pnpm install
+
+ - name: Build — blog
+ working-directory: demos/blog
+ run: pnpm build
+
+ - name: Install — cms
+ working-directory: demos/cms
+ run: pnpm install
+
+ - name: Build — cms
+ working-directory: demos/cms
+ run: pnpm build
+
+ - name: Install — form-builder
+ working-directory: demos/form-builder
+ run: pnpm install
+
+ - name: Build — form-builder
+ working-directory: demos/form-builder
+ run: pnpm build
+
+ - name: Install — kanban
+ working-directory: demos/kanban
+ run: pnpm install
+
+ - name: Build — kanban
+ working-directory: demos/kanban
+ run: pnpm build
+
+ - name: Install — ui-builder
+ working-directory: demos/ui-builder
+ run: pnpm install
+
+ - name: Build — ui-builder
+ working-directory: demos/ui-builder
+ run: pnpm build
diff --git a/demos/ai-chat/pnpm-lock.yaml b/demos/ai-chat/pnpm-lock.yaml
index eaa59516..fc732402 100644
--- a/demos/ai-chat/pnpm-lock.yaml
+++ b/demos/ai-chat/pnpm-lock.yaml
@@ -18,8 +18,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(37ea7fbbdb1664cc3e54767443b15e09)
+ specifier: ^2.6.0
+ version: 2.6.0(37ea7fbbdb1664cc3e54767443b15e09)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -355,8 +355,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4188,7 +4188,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(37ea7fbbdb1664cc3e54767443b15e09)':
+ '@btst/stack@2.6.0(37ea7fbbdb1664cc3e54767443b15e09)':
dependencies:
'@ai-sdk/react': 2.0.152(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/demos/blog/pnpm-lock.yaml b/demos/blog/pnpm-lock.yaml
index 8c549975..602b275a 100644
--- a/demos/blog/pnpm-lock.yaml
+++ b/demos/blog/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)
+ specifier: ^2.6.0
+ version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -342,8 +342,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4169,7 +4169,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)':
+ '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)':
dependencies:
'@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/demos/cms/pnpm-lock.yaml b/demos/cms/pnpm-lock.yaml
index c202e5e4..26d6e042 100644
--- a/demos/cms/pnpm-lock.yaml
+++ b/demos/cms/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)
+ specifier: ^2.6.0
+ version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -393,8 +393,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4493,7 +4493,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)':
+ '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)':
dependencies:
'@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/demos/form-builder/pnpm-lock.yaml b/demos/form-builder/pnpm-lock.yaml
index 1ead7954..5c006f7b 100644
--- a/demos/form-builder/pnpm-lock.yaml
+++ b/demos/form-builder/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)
+ specifier: ^2.6.0
+ version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -351,8 +351,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4178,7 +4178,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)':
+ '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)':
dependencies:
'@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/demos/kanban/pnpm-lock.yaml b/demos/kanban/pnpm-lock.yaml
index b3f51ac0..b8811872 100644
--- a/demos/kanban/pnpm-lock.yaml
+++ b/demos/kanban/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)
+ specifier: ^2.6.0
+ version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -330,8 +330,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4179,7 +4179,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)':
+ '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)':
dependencies:
'@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/demos/ui-builder/app/view/[slug]/page.tsx b/demos/ui-builder/app/view/[slug]/page.tsx
index 01796e50..0135a281 100644
--- a/demos/ui-builder/app/view/[slug]/page.tsx
+++ b/demos/ui-builder/app/view/[slug]/page.tsx
@@ -87,14 +87,15 @@ function PageLoadingState() {
);
}
-function PageErrorState({ error }: { error: Error }) {
+function PageErrorState({ error }: { error: unknown }) {
+ const message = error instanceof Error ? error.message : undefined;
return (
Failed to load page
- {error?.message || "An unexpected error occurred."}
+ {message || "An unexpected error occurred."}
diff --git a/demos/ui-builder/pnpm-lock.yaml b/demos/ui-builder/pnpm-lock.yaml
index 4f9f9437..4a6edfd3 100644
--- a/demos/ui-builder/pnpm-lock.yaml
+++ b/demos/ui-builder/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3(@better-auth/core@1.4.5(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-auth@1.4.5(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3)))(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
'@btst/stack':
- specifier: ^2.5.6
- version: 2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)
+ specifier: ^2.6.0
+ version: 2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)
'@btst/yar':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)
@@ -390,8 +390,8 @@ packages:
'@btst/db@2.0.3':
resolution: {integrity: sha512-ZY3+393oPAsFYHpgdCPewwlBNTTsSep66wNLRZnzbGdAmhVluuII56PmDFECD/4MB6EmZ/yZhUrOiGWUHHSWGg==}
- '@btst/stack@2.5.6':
- resolution: {integrity: sha512-zur8xdMIVik2II9W1oR+NeLrlUOIZuG/Pf2M+0PV4utZlqRFpGfN1UK6fWRKcJuLVM3Xfveg1j6n64I5H2/Dqg==}
+ '@btst/stack@2.6.0':
+ resolution: {integrity: sha512-yCimnTAn4gwIaLss2/0n3si1Ywv0zAMeheojZsmkK9K3DtCi5+sfxsw5Kigt4o6ygQxz8N1vN7hEu+IpxFYMuQ==}
peerDependencies:
'@ai-sdk/react': '>=2.0.0'
'@btst/yar': '>=1.2.0'
@@ -4503,7 +4503,7 @@ snapshots:
- svelte
- vue
- '@btst/stack@2.5.6(1b6a0a45c8aa5a923a771880a4bfd1bd)':
+ '@btst/stack@2.6.0(1b6a0a45c8aa5a923a771880a4bfd1bd)':
dependencies:
'@ai-sdk/react': 3.0.118(react@19.2.3)(zod@4.3.6)
'@btst/db': 2.0.3(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@2.0.1(zod@4.3.6))(jose@6.2.0)(kysely@0.28.11)(nanostores@1.1.1)(next@15.4.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.29(typescript@5.9.3))
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index caa39895..dddd0005 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -26,6 +26,7 @@
"---[BookOpenCheck]Concepts---",
"cli",
"api-reference",
+ "standalone-components",
"shadcn-registry",
"---[Play]Demos---",
"demos/index",
diff --git a/docs/content/docs/plugins/ui-builder.mdx b/docs/content/docs/plugins/ui-builder.mdx
index 4343eba0..3c3396d2 100644
--- a/docs/content/docs/plugins/ui-builder.mdx
+++ b/docs/content/docs/plugins/ui-builder.mdx
@@ -373,7 +373,7 @@ Blocks are pre-built component compositions that users can insert as templates.
Create a block registry with reusable templates:
```tsx
-import type { BlockRegistry, ComponentLayer } from "@workspace/ui/components/ui-builder/types"
+import type { BlockRegistry, ComponentLayer } from "@btst/stack/components/ui-builder"
const myBlocks: BlockRegistry = {
"hero-01": {
@@ -461,8 +461,7 @@ const myBlocks: BlockRegistry = {
Pass the block registry to the UIBuilder component:
```tsx
-import UIBuilder from "@workspace/ui/components/ui-builder"
-import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"
+import { UIBuilder, defaultComponentRegistry } from "@btst/stack/components/ui-builder"
function PageEditor() {
return (
@@ -539,8 +538,7 @@ The `LayerRenderer` component renders pre-fetched ComponentLayer data. Use this
```tsx title="app/pages/[slug]/page.tsx"
"use client"
-import { LayerRenderer } from "@workspace/ui/components/ui-builder"
-import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"
+import { LayerRenderer, defaultComponentRegistry } from "@btst/stack/components/ui-builder"
export default function DynamicPage({ page, variables }: {
page: ComponentLayer,
@@ -577,8 +575,7 @@ The `ServerLayerRenderer` is an SSR-friendly renderer that works in React Server
```tsx title="app/pages/[slug]/page.tsx"
// No "use client" needed - this is a Server Component
-import { ServerLayerRenderer } from "@workspace/ui/components/ui-builder"
-import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client"
+import { ServerLayerRenderer, defaultComponentRegistry } from "@btst/stack/components/ui-builder"
export default async function StaticPage({ params }: { params: { slug: string } }) {
// Fetch page data on the server
@@ -908,10 +905,10 @@ const registry = createComponentRegistry({
#### UIBuilder
-The main visual editor component. Import from `@workspace/ui/components/ui-builder`:
+The main visual editor component. Import from `@btst/stack/components/ui-builder`:
```tsx
-import UIBuilder from "@workspace/ui/components/ui-builder"
+import { UIBuilder } from "@btst/stack/components/ui-builder"
```
-### Types (`@workspace/ui/components/ui-builder/types`)
+### Types (`@btst/stack/components/ui-builder`)
#### BlockDefinition
diff --git a/docs/content/docs/standalone-components.mdx b/docs/content/docs/standalone-components.mdx
new file mode 100644
index 00000000..9534b841
--- /dev/null
+++ b/docs/content/docs/standalone-components.mdx
@@ -0,0 +1,223 @@
+---
+title: Standalone Components
+description: Use BTST's UI components independently of any plugin — auto-form, markdown editor, kanban, form builder, UI builder, and more.
+---
+
+import { Callout } from "fumadocs-ui/components/callout";
+import { Tabs, Tab } from "fumadocs-ui/components/tabs";
+
+BTST ships a set of UI components that can be used **without setting up any plugin**. Each is available as its own import path from `@btst/stack`, so you only bundle what you use.
+
+| Import path | CSS export | What you get |
+|---|---|---|
+| `@btst/stack/components/auto-form` | — | `AutoForm`, `AutoFormSubmit` |
+| `@btst/stack/components/stepped-auto-form` | — | `SteppedAutoForm` |
+| `@btst/stack/components/form-builder` | — | `FormBuilder` and schema helpers |
+| `@btst/stack/components/markdown` | `@btst/stack/components/markdown/css` | `MarkdownContent`, `MarkdownEditor` |
+| `@btst/stack/components/multi-select` | — | `MultipleSelector` |
+| `@btst/stack/components/search-select` | — | `SearchSelect` |
+| `@btst/stack/components/empty` | — | `Empty`, `EmptyHeader`, `EmptyTitle`, … |
+
+---
+
+## AutoForm
+
+Generate forms automatically from Zod schemas. [Learn more →](https://github.com/better-stack-ai/form-builder)
+
+```ts
+import { AutoForm, AutoFormSubmit } from "@btst/stack/components/auto-form"
+import type { FieldConfig, Dependency } from "@btst/stack/components/auto-form"
+```
+
+```tsx
+
console.log(values)}
+>
+ Submit
+
+```
+
+### Stepped AutoForm
+
+Multi-step wizard variant. [Learn more →](https://github.com/better-stack-ai/form-builder)
+
+```ts
+import { SteppedAutoForm } from "@btst/stack/components/stepped-auto-form"
+import type { SteppedAutoFormProps, StepperComponentProps } from "@btst/stack/components/stepped-auto-form"
+```
+
+---
+
+## FormBuilder
+
+A JSON-schema-driven form editor — lets users visually create form definitions that can be stored and later rendered. [Learn more →](https://github.com/better-stack-ai/form-builder)
+
+```ts
+import {
+ FormBuilder,
+ defaultComponents,
+ objectFieldDefinition,
+ arrayFieldDefinition,
+ defineComponent,
+ baseMetaSchema,
+ baseMetaSchemaWithPlaceholder,
+} from "@btst/stack/components/form-builder"
+import type {
+ FormBuilderComponentDefinition,
+ FormBuilderField,
+ JSONSchema,
+ JSONSchemaProperty,
+} from "@btst/stack/components/form-builder"
+```
+
+```tsx
+
setSchema(schema)}
+ components={defaultComponents}
+/>
+```
+
+
+The full **Form Builder plugin** (`@btst/stack/plugins/form-builder`) layers data persistence, API routes, and submissions on top of this component. Use the standalone import when you want to manage your own storage.
+
+
+---
+
+## Markdown
+
+### MarkdownContent
+
+Renders Markdown/MDX to HTML with syntax highlighting:
+
+```ts
+import { MarkdownContent } from "@btst/stack/components/markdown"
+import type { MarkdownContentProps } from "@btst/stack/components/markdown"
+```
+
+```tsx
+
+```
+
+Import the stylesheet in your root CSS (or layout):
+
+```css
+@import "@btst/stack/components/markdown/css";
+```
+
+### MarkdownEditor
+
+A rich Markdown editor powered by Milkdown/Crepe with optional image upload:
+
+```ts
+import { MarkdownEditor } from "@btst/stack/components/markdown"
+import type { MarkdownEditorProps } from "@btst/stack/components/markdown"
+```
+
+```tsx
+ setContent(markdown)}
+ placeholder="Write something..."
+ uploadImage={async (file) => {
+ const url = await uploadToStorage(file)
+ return url
+ }}
+/>
+```
+
+#### MarkdownEditor Props
+
+| Prop | Type | Default | Description |
+|---|---|---|---|
+| `value` | `string` | `""` | Current Markdown content |
+| `onChange` | `(markdown: string) => void` | — | Called on every change |
+| `className` | `string` | — | Additional CSS classes |
+| `placeholder` | `string` | `"Write something..."` | Placeholder when empty |
+| `uploadImage` | `(file: File) => Promise` | — | Enables image upload; must return the public URL |
+
+The stylesheet also covers the Markdown editor. Import once from `@btst/stack/components/markdown/css`.
+
+---
+
+## MultipleSelector
+
+A multi-select combobox with search, async options, and tag-style values:
+
+```ts
+import { MultipleSelector } from "@btst/stack/components/multi-select"
+import type { Option, MultipleSelectorRef } from "@btst/stack/components/multi-select"
+```
+
+```tsx
+const options: Option[] = [
+ { value: "react", label: "React" },
+ { value: "vue", label: "Vue" },
+ { value: "svelte", label: "Svelte" },
+]
+
+
+```
+
+---
+
+## SearchSelect
+
+A searchable single-value select powered by Radix + cmdk:
+
+```ts
+import { SearchSelect } from "@btst/stack/components/search-select"
+```
+
+```tsx
+
+```
+
+---
+
+## Empty
+
+Composable empty-state UI components:
+
+```ts
+import {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+} from "@btst/stack/components/empty"
+```
+
+```tsx
+
+
+
+
+
+ No results
+ Try adjusting your search or filters.
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts
index aa69d4ca..693ba112 100644
--- a/packages/stack/build.config.ts
+++ b/packages/stack/build.config.ts
@@ -104,6 +104,17 @@ export default defineBuildConfig({
"./src/plugins/kanban/client/components/index.tsx",
"./src/plugins/kanban/client/hooks/index.tsx",
"./src/plugins/kanban/query-keys.ts",
+ "./src/components/auto-form/index.ts",
+ "./src/components/stepped-auto-form/index.ts",
+ "./src/components/multi-select/index.ts",
+ "./src/components/search-select/index.ts",
+ "./src/components/empty/index.ts",
+ "./src/components/markdown/index.ts",
+ "./src/components/form-builder/index.ts",
+ // standalone component these require >8 GB heap to build if included;
+ // "./src/components/kanban/index.ts",
+ // "./src/components/ui-builder/index.ts",
+ // "./src/components/minimal-tiptap/index.ts",
],
hooks: {
"rollup:options"(_ctx, options) {
@@ -155,8 +166,13 @@ export default defineBuildConfig({
// Add preserve directives plugin last (must be after transform plugins)
// Note: suppressPreserveModulesWarning is set because preserveModules IS set,
// but the plugin checks it before outputs are finalized, causing false warnings
+ // Exclude .d.ts files — they contain TypeScript-only syntax that Rollup's
+ // built-in parser (used by this plugin for AST inspection) cannot handle.
existingPlugins.push(
- preserveDirectives({ suppressPreserveModulesWarning: true }),
+ preserveDirectives({
+ suppressPreserveModulesWarning: true,
+ exclude: ["**/*.d.ts", "**/*.d.cts", "**/*.d.mts"],
+ }),
);
(options as any).plugins = existingPlugins;
diff --git a/packages/stack/package.json b/packages/stack/package.json
index a05befc3..59c4cddd 100644
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
- "version": "2.6.0",
+ "version": "2.6.1",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
@@ -21,8 +21,8 @@
"access": "public"
},
"scripts": {
- "build": "unbuild --clean && node ./scripts/postbuild.cjs",
- "build:analyze": "ANALYZE=1 unbuild --clean && node ./scripts/postbuild.cjs",
+ "build": "NODE_OPTIONS='--max-old-space-size=8192' unbuild --clean && node ./scripts/postbuild.cjs",
+ "build:analyze": "NODE_OPTIONS='--max-old-space-size=8192' ANALYZE=1 unbuild --clean && node ./scripts/postbuild.cjs",
"stub": "unbuild --stub",
"test": "vitest",
"typecheck": "tsc --project tsconfig.json",
@@ -374,6 +374,77 @@
}
},
"./plugins/route-docs/css": "./dist/plugins/route-docs/style.css",
+ "./components/markdown": {
+ "import": {
+ "types": "./dist/components/markdown/index.d.ts",
+ "default": "./dist/components/markdown/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/markdown/index.d.cts",
+ "default": "./dist/components/markdown/index.cjs"
+ }
+ },
+ "./components/markdown/css": "./dist/components/markdown/style.css",
+ "./components/auto-form": {
+ "import": {
+ "types": "./dist/components/auto-form/index.d.ts",
+ "default": "./dist/components/auto-form/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/auto-form/index.d.cts",
+ "default": "./dist/components/auto-form/index.cjs"
+ }
+ },
+ "./components/stepped-auto-form": {
+ "import": {
+ "types": "./dist/components/stepped-auto-form/index.d.ts",
+ "default": "./dist/components/stepped-auto-form/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/stepped-auto-form/index.d.cts",
+ "default": "./dist/components/stepped-auto-form/index.cjs"
+ }
+ },
+ "./components/form-builder": {
+ "import": {
+ "types": "./dist/components/form-builder/index.d.ts",
+ "default": "./dist/components/form-builder/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/form-builder/index.d.cts",
+ "default": "./dist/components/form-builder/index.cjs"
+ }
+ },
+ "./components/multi-select": {
+ "import": {
+ "types": "./dist/components/multi-select/index.d.ts",
+ "default": "./dist/components/multi-select/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/multi-select/index.d.cts",
+ "default": "./dist/components/multi-select/index.cjs"
+ }
+ },
+ "./components/search-select": {
+ "import": {
+ "types": "./dist/components/search-select/index.d.ts",
+ "default": "./dist/components/search-select/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/search-select/index.d.cts",
+ "default": "./dist/components/search-select/index.cjs"
+ }
+ },
+ "./components/empty": {
+ "import": {
+ "types": "./dist/components/empty/index.d.ts",
+ "default": "./dist/components/empty/index.mjs"
+ },
+ "require": {
+ "types": "./dist/components/empty/index.d.cts",
+ "default": "./dist/components/empty/index.cjs"
+ }
+ },
"./dist/*": "./dist/*",
"./ui/css": "./dist/ui/components.css",
"./package.json": "./package.json"
@@ -475,6 +546,27 @@
],
"plugins/route-docs/client": [
"./dist/plugins/route-docs/client/index.d.ts"
+ ],
+ "components/markdown": [
+ "./dist/components/markdown/index.d.ts"
+ ],
+ "components/auto-form": [
+ "./dist/components/auto-form/index.d.ts"
+ ],
+ "components/stepped-auto-form": [
+ "./dist/components/stepped-auto-form/index.d.ts"
+ ],
+ "components/form-builder": [
+ "./dist/components/form-builder/index.d.ts"
+ ],
+ "components/multi-select": [
+ "./dist/components/multi-select/index.d.ts"
+ ],
+ "components/search-select": [
+ "./dist/components/search-select/index.d.ts"
+ ],
+ "components/empty": [
+ "./dist/components/empty/index.d.ts"
]
}
},
diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json
index 93c232e2..e8ebe3d5 100644
--- a/packages/stack/registry/btst-blog.json
+++ b/packages/stack/registry/btst-blog.json
@@ -68,16 +68,22 @@
"content": ".milkdown-custom .milkdown {\n\tborder-radius: calc(var(--radius) - 2px);\n\t--crepe-color-background: var(--background);\n\t--crepe-color-surface: var(--background);\n\t--crepe-color-surface-low: var(--muted);\n\t--crepe-color-on-background: var(--foreground);\n\t--crepe-color-on-surface: var(--secondary-foreground);\n\t--crepe-color-on-surface-variant: var(--muted-foreground);\n\t--crepe-color-primary: var(--primary);\n\t--crepe-color-secondary: var(--secondary);\n\t--crepe-color-on-secondary: var(--secondary-foreground);\n\t--crepe-color-outline: var(--muted-foreground);\n\t--crepe-color-inverse: var(--popover);\n\t--crepe-color-on-inverse: var(--popover-foreground);\n\t--crepe-color-inline-code: var(--secondary-foreground);\n\t--crepe-color-error: var(--destructive);\n\t--crepe-color-hover: var(--accent);\n\t--crepe-color-selected: var(--secondary);\n\t--crepe-color-inline-area: var(--secondary);\n\t--crepe-font-title: var(--font-sans);\n\t--crepe-font-default: var(--font-sans);\n\t--crepe-font-code: var(--font-mono);\n\t--crepe-shadow-1:\n\t\t0px 1px 3px 1px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px\n\t\t2px 0px color-mix(in oklch, var(--ring) 22%, transparent);\n\t--crepe-shadow-2:\n\t\t0px 2px 6px 2px color-mix(in oklch, var(--ring) 28%, transparent), 0px 1px\n\t\t2px 0px color-mix(in oklch, var(--ring) 22%, transparent);\n\theight: inherit;\n}\n",
"target": "src/components/btst/blog/client/components/forms/markdown-editor-styles.css"
},
+ {
+ "path": "btst/blog/client/components/forms/markdown-editor-with-overrides.tsx",
+ "type": "registry:component",
+ "content": "\"use client\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t\"uploadImage\" | \"placeholder\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "target": "src/components/btst/blog/client/components/forms/markdown-editor-with-overrides.tsx"
+ },
{
"path": "btst/blog/client/components/forms/markdown-editor.tsx",
"type": "registry:component",
- "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n}: {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n}) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: localization.BLOG_FORMS_EDITOR_PLACEHOLDER,\n\t\t\t\t},\n\t\t\t\t[CrepeFeature.ImageBlock]: {\n\t\t\t\t\tonUpload: async (file) => {\n\t\t\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\t\t\treturn url;\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t});\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nexport interface MarkdownEditorProps {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n\t/** Optional image upload handler. When provided, enables image upload in the editor. */\n\tuploadImage?: (file: File) => Promise;\n\t/** Placeholder text shown when the editor is empty. */\n\tplaceholder?: string;\n}\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n\tuploadImage,\n\tplaceholder = \"Write something...\",\n}: MarkdownEditorProps) {\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: placeholder,\n\t\t\t\t},\n\t\t\t\t...(uploadImage\n\t\t\t\t\t? {\n\t\t\t\t\t\t\t[CrepeFeature.ImageBlock]: {\n\t\t\t\t\t\t\t\tonUpload: async (file: File) => {\n\t\t\t\t\t\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\t\t\t\t\t\treturn url;\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t});\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\treturn (\n\t\t\n\t);\n}\n",
"target": "src/components/btst/blog/client/components/forms/markdown-editor.tsx"
},
{
"path": "btst/blog/client/components/forms/post-forms.tsx",
"type": "registry:component",
- "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseCreatePost,\n\tuseSuspensePost,\n\tuseUpdatePost,\n\tuseDeletePost,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useMemo, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor\").then((module) => ({\n\t\tdefault: module.MarkdownEditor,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t \n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(content);\n\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_PUBLISHED_DESCRIPTION}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\tconst {\n\t\tmutateAsync: createPost,\n\t\tisPending: isCreatingPost,\n\t\terror: createPostError,\n\t} = useCreatePost();\n\n\ttype AddPostFormValues = z.input;\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\t// Auto-generate slug from title if not provided\n\t\tconst slug = data.slug || slugify(data.title);\n\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst createdPost = await createPost({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\tslug,\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({ published: createdPost?.published ?? false });\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\n\tconst initialData = useMemo(() => {\n\t\tif (!post) return {};\n\t\treturn {\n\t\t\ttitle: post.title,\n\t\t\tcontent: post.content,\n\t\t\texcerpt: post.excerpt,\n\t\t\tslug: post.slug,\n\t\t\tpublished: post.published,\n\t\t\timage: post.image || \"\",\n\t\t\ttags: post.tags.map((tag) => ({\n\t\t\t\tid: tag.id,\n\t\t\t\tname: tag.name,\n\t\t\t\tslug: tag.slug,\n\t\t\t})),\n\t\t};\n\t}, [post]);\n\n\tconst schema = CustomPostUpdateSchema;\n\n\tconst {\n\t\tmutateAsync: updatePost,\n\t\tisPending: isUpdatingPost,\n\t\terror: updatePostError,\n\t} = useUpdatePost();\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\ttype EditPostFormValues = z.input;\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst updatedPost = await updatePost({\n\t\t\tid: post!.id,\n\t\t\tdata: {\n\t\t\t\tid: post!.id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !post?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: post?.publishedAt\n\t\t\t\t\t\t\t? new Date(post.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({\n\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\tpublished: updatedPost?.published ?? false,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\tawait deletePost({ id: post.id });\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: initialData as z.input,\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t>\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n",
+ "content": "\"use client\";\nimport {\n\tcreatePostSchema as PostCreateSchema,\n\tupdatePostSchema as PostUpdateSchema,\n} from \"../../../schemas\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport {\n\tForm,\n\tFormControl,\n\tFormDescription,\n\tFormField,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n\tuseCreatePost,\n\tuseSuspensePost,\n\tuseUpdatePost,\n\tuseDeletePost,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { slugify } from \"../../../utils\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n\tAlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Loader2 } from \"lucide-react\";\nimport { lazy, memo, Suspense, useEffect, useMemo, useState } from \"react\";\nimport {\n\ttype FieldPath,\n\ttype SubmitHandler,\n\ttype UseFormReturn,\n\tuseForm,\n} from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { FeaturedImageField } from \"./image-field\";\n\nconst MarkdownEditor = lazy(() =>\n\timport(\"./markdown-editor-with-overrides\").then((module) => ({\n\t\tdefault: module.MarkdownEditorWithOverrides,\n\t})),\n);\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { TagsMultiSelect } from \"./tags-multiselect\";\n\ntype CommonPostFormValues = {\n\ttitle: string;\n\tcontent: string;\n\texcerpt?: string;\n\tslug?: string;\n\timage?: string;\n\tpublished?: boolean;\n\ttags?: Array<{ name: string } | { id: string; name: string; slug: string }>;\n};\n\nfunction PostFormBody({\n\tform,\n\tonSubmit,\n\tsubmitLabel,\n\tonCancel,\n\tdisabled,\n\terrorMessage,\n\tsetFeaturedImageUploading,\n\tinitialSlugTouched = false,\n}: {\n\tform: UseFormReturn;\n\tonSubmit: SubmitHandler;\n\tsubmitLabel: string;\n\tonCancel: () => void;\n\tdisabled: boolean;\n\terrorMessage?: string;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n\tinitialSlugTouched?: boolean;\n}) {\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst [slugTouched, setSlugTouched] = useState(initialSlugTouched);\n\tconst nameTitle = \"title\" as FieldPath;\n\tconst nameSlug = \"slug\" as FieldPath;\n\tconst nameExcerpt = \"excerpt\" as FieldPath;\n\tconst nameImage = \"image\" as FieldPath;\n\tconst nameTags = \"tags\" as FieldPath;\n\tconst nameContent = \"content\" as FieldPath;\n\tconst namePublished = \"published\" as FieldPath;\n\treturn (\n\t\t\n\t\t\n\t);\n}\n\nconst CustomPostCreateSchema = PostCreateSchema.omit({\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\nconst CustomPostUpdateSchema = PostUpdateSchema.omit({\n\tid: true,\n\tcreatedAt: true,\n\tupdatedAt: true,\n\tpublishedAt: true,\n});\n\ntype AddPostFormProps = {\n\tonClose: () => void;\n\tonSuccess: (post: { published: boolean }) => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst addPostFormPropsAreEqual = (\n\tprevProps: AddPostFormProps,\n\tnextProps: AddPostFormProps,\n): boolean => {\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst AddPostFormComponent = ({\n\tonClose,\n\tonSuccess,\n\tonFormReady,\n}: AddPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\t// const { uploadImage } = useBlogContext()\n\n\tconst schema = CustomPostCreateSchema;\n\n\tconst {\n\t\tmutateAsync: createPost,\n\t\tisPending: isCreatingPost,\n\t\terror: createPostError,\n\t} = useCreatePost();\n\n\ttype AddPostFormValues = z.input;\n\tconst onSubmit = async (data: AddPostFormValues) => {\n\t\t// Auto-generate slug from title if not provided\n\t\tconst slug = data.slug || slugify(data.title);\n\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst createdPost = await createPost({\n\t\t\ttitle: data.title,\n\t\t\tcontent: data.content,\n\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\tslug,\n\t\t\tpublished: data.published ?? false,\n\t\t\tpublishedAt: data.published ? new Date() : undefined,\n\t\t\timage: data.image,\n\t\t\ttags: data.tags || [],\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_CREATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({ published: createdPost?.published ?? false });\n\t};\n\n\t// For compatibility with resolver types that require certain required fields,\n\t// cast the generics to the exact inferred input type to avoid mismatch on optional slug\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: undefined,\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\treturn (\n\t\t\n\t);\n};\n\nexport const AddPostForm = memo(AddPostFormComponent, addPostFormPropsAreEqual);\n\ntype EditPostFormProps = {\n\tpostSlug: string;\n\tonClose: () => void;\n\tonSuccess: (post: { slug: string; published: boolean }) => void;\n\tonDelete?: () => void;\n\t/** Called once with the form instance so parent components can access form state */\n\tonFormReady?: (\n\t\tform: UseFormReturn>,\n\t) => void;\n};\n\nconst editPostFormPropsAreEqual = (\n\tprevProps: EditPostFormProps,\n\tnextProps: EditPostFormProps,\n): boolean => {\n\tif (prevProps.postSlug !== nextProps.postSlug) return false;\n\tif (prevProps.onClose !== nextProps.onClose) return false;\n\tif (prevProps.onSuccess !== nextProps.onSuccess) return false;\n\tif (prevProps.onDelete !== nextProps.onDelete) return false;\n\tif (prevProps.onFormReady !== nextProps.onFormReady) return false;\n\treturn true;\n};\n\nconst EditPostFormComponent = ({\n\tpostSlug,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n\tonFormReady,\n}: EditPostFormProps) => {\n\tconst [featuredImageUploading, setFeaturedImageUploading] = useState(false);\n\tconst [deleteDialogOpen, setDeleteDialogOpen] = useState(false);\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\t// const { uploadImage } = useBlogContext()\n\n\tconst { post } = useSuspensePost(postSlug);\n\n\tconst initialData = useMemo(() => {\n\t\tif (!post) return {};\n\t\treturn {\n\t\t\ttitle: post.title,\n\t\t\tcontent: post.content,\n\t\t\texcerpt: post.excerpt,\n\t\t\tslug: post.slug,\n\t\t\tpublished: post.published,\n\t\t\timage: post.image || \"\",\n\t\t\ttags: post.tags.map((tag) => ({\n\t\t\t\tid: tag.id,\n\t\t\t\tname: tag.name,\n\t\t\t\tslug: tag.slug,\n\t\t\t})),\n\t\t};\n\t}, [post]);\n\n\tconst schema = CustomPostUpdateSchema;\n\n\tconst {\n\t\tmutateAsync: updatePost,\n\t\tisPending: isUpdatingPost,\n\t\terror: updatePostError,\n\t} = useUpdatePost();\n\n\tconst { mutateAsync: deletePost, isPending: isDeletingPost } =\n\t\tuseDeletePost();\n\n\ttype EditPostFormValues = z.input;\n\tconst onSubmit = async (data: EditPostFormValues) => {\n\t\t// Wait for mutation to complete, including refresh\n\t\tconst updatedPost = await updatePost({\n\t\t\tid: post!.id,\n\t\t\tdata: {\n\t\t\t\tid: post!.id,\n\t\t\t\ttitle: data.title,\n\t\t\t\tcontent: data.content,\n\t\t\t\texcerpt: data.excerpt ?? \"\",\n\t\t\t\tslug: data.slug,\n\t\t\t\tpublished: data.published ?? false,\n\t\t\t\tpublishedAt:\n\t\t\t\t\tdata.published && !post?.published\n\t\t\t\t\t\t? new Date()\n\t\t\t\t\t\t: post?.publishedAt\n\t\t\t\t\t\t\t? new Date(post.publishedAt)\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\timage: data.image,\n\t\t\t\ttags: data.tags || [],\n\t\t\t},\n\t\t});\n\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_UPDATE_SUCCESS);\n\n\t\t// Navigate only after mutation completes\n\t\tonSuccess({\n\t\t\tslug: updatedPost?.slug ?? \"\",\n\t\t\tpublished: updatedPost?.published ?? false,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!post?.id) return;\n\n\t\tawait deletePost({ id: post.id });\n\t\ttoast.success(localization.BLOG_FORMS_TOAST_DELETE_SUCCESS);\n\t\tsetDeleteDialogOpen(false);\n\n\t\t// Call onDelete callback if provided, otherwise use onClose\n\t\tif (onDelete) {\n\t\t\tonDelete();\n\t\t} else {\n\t\t\tonClose();\n\t\t}\n\t};\n\n\tconst form = useForm>({\n\t\tresolver: zodResolver(schema),\n\t\tdefaultValues: {\n\t\t\ttitle: \"\",\n\t\t\tcontent: \"\",\n\t\t\texcerpt: \"\",\n\t\t\tslug: \"\",\n\t\t\tpublished: false,\n\t\t\timage: \"\",\n\t\t\ttags: [],\n\t\t},\n\t\tvalues: initialData as z.input,\n\t});\n\n\t// Expose form instance to parent for AI context integration\n\tuseEffect(() => {\n\t\tonFormReady?.(form);\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, []);\n\n\tif (!post) {\n\t\treturn ;\n\t}\n\n\treturn (\n\t\t<>\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_TITLE}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_DESCRIPTION}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_DELETE_DIALOG_CANCEL}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\t\tvoid handleDelete();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tdisabled={isDeletingPost}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isDeletingPost\n\t\t\t\t\t\t\t\t\t? localization.BLOG_FORMS_DELETE_PENDING\n\t\t\t\t\t\t\t\t\t: localization.BLOG_FORMS_DELETE_DIALOG_CONFIRM}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\t\t>\n\t);\n};\n\nexport const EditPostForm = memo(\n\tEditPostFormComponent,\n\teditPostFormPropsAreEqual,\n);\n",
"target": "src/components/btst/blog/client/components/forms/post-forms.tsx"
},
{
diff --git a/packages/stack/scripts/postbuild.cjs b/packages/stack/scripts/postbuild.cjs
index 740b0b65..31ac9f50 100644
--- a/packages/stack/scripts/postbuild.cjs
+++ b/packages/stack/scripts/postbuild.cjs
@@ -2,6 +2,7 @@
/*
Post-build step for BTST package.
- Copies all .css files from src/plugins/** to dist/plugins/** preserving structure
+ - Copies all .css files from src/components/** to dist/components/** preserving structure
- Resolves @workspace/ui/... CSS imports by inlining the referenced content directly
into each dist CSS file — producing fully self-contained files with no workspace
references, so npm consumers and StackBlitz never see unresolvable imports
@@ -16,6 +17,8 @@ const { spawnSync } = require("child_process");
const ROOT = path.resolve(__dirname, "..");
const SRC_PLUGINS_DIR = path.join(ROOT, "src", "plugins");
const DIST_PLUGINS_DIR = path.join(ROOT, "dist", "plugins");
+const SRC_COMPONENTS_DIR = path.join(ROOT, "src", "components");
+const DIST_COMPONENTS_DIR = path.join(ROOT, "dist", "components");
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
@@ -57,6 +60,20 @@ function copyAllPluginCss() {
}
}
+function copyAllComponentCss() {
+ console.log(`@btst/stack: running copyAllComponentCss`);
+ if (!fs.existsSync(SRC_COMPONENTS_DIR)) return;
+ for (const componentName of fs.readdirSync(SRC_COMPONENTS_DIR)) {
+ const srcComponentDir = path.join(SRC_COMPONENTS_DIR, componentName);
+ if (!fs.statSync(srcComponentDir).isDirectory()) continue;
+ for (const filePath of walk(srcComponentDir)) {
+ if (filePath.endsWith(".css")) {
+ copyFilePreserveDirs(filePath, SRC_COMPONENTS_DIR, DIST_COMPONENTS_DIR);
+ }
+ }
+ }
+}
+
/**
* Recursively inline relative @import statements in a CSS file, producing a
* single blob of CSS with no relative imports left. Non-relative imports
@@ -118,48 +135,52 @@ function resolveWorkspaceCssImports() {
// only read and processed once even if referenced by multiple dist files.
const cache = new Map();
- if (!fs.existsSync(DIST_PLUGINS_DIR)) return;
-
- for (const filePath of walk(DIST_PLUGINS_DIR)) {
- if (!filePath.endsWith(".css")) continue;
-
- let content = fs.readFileSync(filePath, "utf8");
- if (!WORKSPACE_IMPORT_RE.test(content)) continue;
- WORKSPACE_IMPORT_RE.lastIndex = 0;
-
- let modified = false;
-
- content = content.replace(WORKSPACE_IMPORT_RE, (match, specifier) => {
- if (!cache.has(specifier)) {
- const resolvedPath = resolveUiSpecifier(specifier);
- if (!resolvedPath || !fs.existsSync(resolvedPath)) {
- console.warn(
- `@btst/stack: could not resolve workspace import: ${specifier}`,
+ function processDistDir(distDir) {
+ if (!fs.existsSync(distDir)) return;
+ for (const filePath of walk(distDir)) {
+ if (!filePath.endsWith(".css")) continue;
+
+ let content = fs.readFileSync(filePath, "utf8");
+ if (!WORKSPACE_IMPORT_RE.test(content)) continue;
+ WORKSPACE_IMPORT_RE.lastIndex = 0;
+
+ let modified = false;
+
+ content = content.replace(WORKSPACE_IMPORT_RE, (match, specifier) => {
+ if (!cache.has(specifier)) {
+ const resolvedPath = resolveUiSpecifier(specifier);
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
+ console.warn(
+ `@btst/stack: could not resolve workspace import: ${specifier}`,
+ );
+ cache.set(specifier, null);
+ return match;
+ }
+ const raw = fs.readFileSync(resolvedPath, "utf8");
+ // Recursively inline any relative sub-imports within the resolved file
+ const inlined = inlineCssImports(raw, path.dirname(resolvedPath));
+ cache.set(specifier, inlined);
+ console.log(
+ `@btst/stack: inlined workspace import "${specifier}" into ${path.relative(distDir, filePath)}`,
);
- cache.set(specifier, null);
- return match;
}
- const raw = fs.readFileSync(resolvedPath, "utf8");
- // Recursively inline any relative sub-imports within the resolved file
- const inlined = inlineCssImports(raw, path.dirname(resolvedPath));
- cache.set(specifier, inlined);
+ const inlined = cache.get(specifier);
+ if (inlined === null) return match; // could not resolve — keep original
+ modified = true;
+ return inlined;
+ });
+
+ if (modified) {
+ fs.writeFileSync(filePath, content);
console.log(
- `@btst/stack: inlined workspace import "${specifier}" into ${path.relative(DIST_PLUGINS_DIR, filePath)}`,
+ `@btst/stack: rewrote workspace imports in ${path.relative(distDir, filePath)}`,
);
}
- const inlined = cache.get(specifier);
- if (inlined === null) return match; // could not resolve — keep original
- modified = true;
- return inlined;
- });
-
- if (modified) {
- fs.writeFileSync(filePath, content);
- console.log(
- `@btst/stack: rewrote workspace imports in ${path.relative(DIST_PLUGINS_DIR, filePath)}`,
- );
}
}
+
+ processDistDir(DIST_PLUGINS_DIR);
+ processDistDir(DIST_COMPONENTS_DIR);
}
function runPerPluginPostbuilds() {
@@ -200,7 +221,9 @@ function runPerPluginPostbuilds() {
function main() {
ensureDir(DIST_PLUGINS_DIR);
+ ensureDir(DIST_COMPONENTS_DIR);
copyAllPluginCss();
+ copyAllComponentCss();
resolveWorkspaceCssImports();
runPerPluginPostbuilds();
}
diff --git a/packages/stack/src/components/auto-form/index.ts b/packages/stack/src/components/auto-form/index.ts
new file mode 100644
index 00000000..4a6f9ac3
--- /dev/null
+++ b/packages/stack/src/components/auto-form/index.ts
@@ -0,0 +1,12 @@
+export {
+ default as AutoForm,
+ AutoFormSubmit,
+} from "@workspace/ui/components/auto-form";
+export type {
+ FieldConfig,
+ FieldConfigItem,
+ Dependency,
+ DependencyType,
+ AutoFormInputComponentProps,
+} from "@workspace/ui/components/auto-form/types";
+export type { ZodObjectOrWrapped } from "@workspace/ui/components/auto-form/helpers";
diff --git a/packages/stack/src/components/empty/index.ts b/packages/stack/src/components/empty/index.ts
new file mode 100644
index 00000000..14b7a68d
--- /dev/null
+++ b/packages/stack/src/components/empty/index.ts
@@ -0,0 +1,8 @@
+export {
+ Empty,
+ EmptyHeader,
+ EmptyTitle,
+ EmptyDescription,
+ EmptyContent,
+ EmptyMedia,
+} from "@workspace/ui/components/empty";
diff --git a/packages/stack/src/components/form-builder/index.ts b/packages/stack/src/components/form-builder/index.ts
new file mode 100644
index 00000000..2a0295d1
--- /dev/null
+++ b/packages/stack/src/components/form-builder/index.ts
@@ -0,0 +1,23 @@
+export {
+ FormBuilder,
+ defaultComponents,
+ objectFieldDefinition,
+ arrayFieldDefinition,
+ defineComponent,
+ baseMetaSchema,
+ baseMetaSchemaWithPlaceholder,
+} from "@workspace/ui/components/form-builder";
+export type {
+ FormBuilderComponentDefinition,
+ FormBuilderField,
+ FormBuilderFieldProps,
+ JSONSchema,
+ JSONSchemaProperty,
+ StringFieldProps,
+} from "@workspace/ui/components/form-builder";
+export type {
+ FormStep,
+ BackingType,
+ ComponentType,
+ TypedFieldProps,
+} from "@workspace/ui/components/form-builder/types";
diff --git a/packages/stack/src/components/kanban/index.ts b/packages/stack/src/components/kanban/index.ts
new file mode 100644
index 00000000..8e33c633
--- /dev/null
+++ b/packages/stack/src/components/kanban/index.ts
@@ -0,0 +1,9 @@
+export {
+ Kanban,
+ KanbanBoard,
+ KanbanColumn,
+ KanbanColumnHandle,
+ KanbanItem,
+ KanbanItemHandle,
+ KanbanOverlay,
+} from "@workspace/ui/components/kanban";
diff --git a/packages/stack/src/components/markdown/index.ts b/packages/stack/src/components/markdown/index.ts
new file mode 100644
index 00000000..b16a6c2c
--- /dev/null
+++ b/packages/stack/src/components/markdown/index.ts
@@ -0,0 +1,5 @@
+export { MarkdownContent } from "@workspace/ui/components/markdown-content";
+export type { MarkdownContentProps } from "@workspace/ui/components/markdown-content";
+
+export { MarkdownEditor } from "../../plugins/blog/client/components/forms/markdown-editor";
+export type { MarkdownEditorProps } from "../../plugins/blog/client/components/forms/markdown-editor";
diff --git a/packages/stack/src/components/markdown/style.css b/packages/stack/src/components/markdown/style.css
new file mode 100644
index 00000000..0ff238e8
--- /dev/null
+++ b/packages/stack/src/components/markdown/style.css
@@ -0,0 +1,3 @@
+@import "@workspace/ui/markdown-content.css";
+@import "@milkdown/crepe/theme/common/style.css";
+@import "../../plugins/blog/client/components/forms/markdown-editor-styles.css";
diff --git a/packages/stack/src/components/minimal-tiptap/index.ts b/packages/stack/src/components/minimal-tiptap/index.ts
new file mode 100644
index 00000000..50de3ea2
--- /dev/null
+++ b/packages/stack/src/components/minimal-tiptap/index.ts
@@ -0,0 +1,5 @@
+export {
+ MinimalTiptapEditor,
+ MainMinimalTiptapEditor,
+} from "@workspace/ui/components/minimal-tiptap";
+export type { MinimalTiptapProps } from "@workspace/ui/components/minimal-tiptap";
diff --git a/packages/stack/src/components/minimal-tiptap/style.css b/packages/stack/src/components/minimal-tiptap/style.css
new file mode 100644
index 00000000..468432c3
--- /dev/null
+++ b/packages/stack/src/components/minimal-tiptap/style.css
@@ -0,0 +1 @@
+@import "@workspace/ui/components/minimal-tiptap/styles.css";
diff --git a/packages/stack/src/components/multi-select/index.ts b/packages/stack/src/components/multi-select/index.ts
new file mode 100644
index 00000000..2a609f54
--- /dev/null
+++ b/packages/stack/src/components/multi-select/index.ts
@@ -0,0 +1,5 @@
+export { default as MultipleSelector } from "@workspace/ui/components/multi-select";
+export type {
+ Option,
+ MultipleSelectorRef,
+} from "@workspace/ui/components/multi-select";
diff --git a/packages/stack/src/components/search-select/index.ts b/packages/stack/src/components/search-select/index.ts
new file mode 100644
index 00000000..e7e0207d
--- /dev/null
+++ b/packages/stack/src/components/search-select/index.ts
@@ -0,0 +1 @@
+export { default as SearchSelect } from "@workspace/ui/components/search-select";
diff --git a/packages/stack/src/components/stepped-auto-form/index.ts b/packages/stack/src/components/stepped-auto-form/index.ts
new file mode 100644
index 00000000..ec59780e
--- /dev/null
+++ b/packages/stack/src/components/stepped-auto-form/index.ts
@@ -0,0 +1,5 @@
+export { SteppedAutoForm } from "@workspace/ui/components/auto-form/stepped-auto-form";
+export type {
+ SteppedAutoFormProps,
+ StepperComponentProps,
+} from "@workspace/ui/components/auto-form/stepped-auto-form";
diff --git a/packages/stack/src/components/ui-builder/index.ts b/packages/stack/src/components/ui-builder/index.ts
new file mode 100644
index 00000000..0799ed7c
--- /dev/null
+++ b/packages/stack/src/components/ui-builder/index.ts
@@ -0,0 +1,50 @@
+// Editor component
+export {
+ default as UIBuilder,
+ PageConfigPanel,
+ defaultConfigTabsContent,
+ LoadingSkeleton,
+ getDefaultPanelConfigValues,
+} from "@workspace/ui/components/ui-builder";
+export type { TabsContentConfig } from "@workspace/ui/components/ui-builder";
+
+// Page renderers
+export { default as LayerRenderer } from "@workspace/ui/components/ui-builder/layer-renderer";
+export { ServerLayerRenderer } from "@workspace/ui/components/ui-builder/server-layer-renderer";
+export type { ServerLayerRendererProps } from "@workspace/ui/components/ui-builder/server-layer-renderer";
+
+// Types
+export type {
+ ComponentLayer,
+ ComponentRegistry,
+ Variable,
+ VariableReference,
+ FunctionRegistry,
+ BlockRegistry,
+ LayerChangeHandler,
+ VariableChangeHandler,
+ RegistryEntry,
+ PropValue,
+ BlockDefinition,
+ FunctionDefinition,
+} from "@workspace/ui/components/ui-builder/types";
+export {
+ isVariableReference,
+ createVariable,
+} from "@workspace/ui/components/ui-builder/types";
+
+// Registry utilities and field override helpers
+export {
+ createComponentRegistry,
+ defaultComponentRegistry,
+ primitiveComponentDefinitions,
+ complexComponentDefinitions,
+} from "../../plugins/ui-builder/client/registry";
+export {
+ classNameFieldOverrides,
+ childrenFieldOverrides,
+ iconNameFieldOverrides,
+ commonFieldOverrides,
+ childrenAsTipTapFieldOverrides,
+ childrenAsTextareaFieldOverrides,
+} from "@workspace/ui/lib/ui-builder/registry/form-field-overrides";
diff --git a/packages/stack/src/components/ui-builder/style.css b/packages/stack/src/components/ui-builder/style.css
new file mode 100644
index 00000000..152630da
--- /dev/null
+++ b/packages/stack/src/components/ui-builder/style.css
@@ -0,0 +1,5 @@
+/* Import minimal-tiptap styles for rich text editor in form field overrides */
+@import "@workspace/ui/components/minimal-tiptap/styles.css";
+
+/* Load Tailwind Typography plugin for prose classes used by Markdown component */
+@plugin "@tailwindcss/typography";
diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx
new file mode 100644
index 00000000..385ac9e1
--- /dev/null
+++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx
@@ -0,0 +1,29 @@
+"use client";
+import { usePluginOverrides } from "@btst/stack/context";
+import type { BlogPluginOverrides } from "../../overrides";
+import { BLOG_LOCALIZATION } from "../../localization";
+import { MarkdownEditor, type MarkdownEditorProps } from "./markdown-editor";
+
+type MarkdownEditorWithOverridesProps = Omit<
+ MarkdownEditorProps,
+ "uploadImage" | "placeholder"
+>;
+
+export function MarkdownEditorWithOverrides(
+ props: MarkdownEditorWithOverridesProps,
+) {
+ const { uploadImage, localization } = usePluginOverrides<
+ BlogPluginOverrides,
+ Partial
+ >("blog", {
+ localization: BLOG_LOCALIZATION,
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx
index c5625ae2..1cebce5e 100644
--- a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx
+++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx
@@ -9,25 +9,24 @@ import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
import { Slice } from "@milkdown/kit/prose/model";
import { Selection } from "@milkdown/kit/prose/state";
import { useLayoutEffect, useRef, useState } from "react";
-import { usePluginOverrides } from "@btst/stack/context";
-import type { BlogPluginOverrides } from "../../overrides";
-import { BLOG_LOCALIZATION } from "../../localization";
+
+export interface MarkdownEditorProps {
+ value?: string;
+ onChange?: (markdown: string) => void;
+ className?: string;
+ /** Optional image upload handler. When provided, enables image upload in the editor. */
+ uploadImage?: (file: File) => Promise;
+ /** Placeholder text shown when the editor is empty. */
+ placeholder?: string;
+}
export function MarkdownEditor({
value,
onChange,
className,
-}: {
- value?: string;
- onChange?: (markdown: string) => void;
- className?: string;
-}) {
- const { uploadImage, localization } = usePluginOverrides<
- BlogPluginOverrides,
- Partial
- >("blog", {
- localization: BLOG_LOCALIZATION,
- });
+ uploadImage,
+ placeholder = "Write something...",
+}: MarkdownEditorProps) {
const containerRef = useRef(null);
const crepeRef = useRef(null);
const isReadyRef = useRef(false);
@@ -52,14 +51,18 @@ export function MarkdownEditor({
defaultValue: initialValueRef.current,
featureConfigs: {
[CrepeFeature.Placeholder]: {
- text: localization.BLOG_FORMS_EDITOR_PLACEHOLDER,
- },
- [CrepeFeature.ImageBlock]: {
- onUpload: async (file) => {
- const url = await uploadImage(file);
- return url;
- },
+ text: placeholder,
},
+ ...(uploadImage
+ ? {
+ [CrepeFeature.ImageBlock]: {
+ onUpload: async (file: File) => {
+ const url = await uploadImage(file);
+ return url;
+ },
+ },
+ }
+ : {}),
},
});
diff --git a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx
index 9fa60717..a98ad202 100644
--- a/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx
+++ b/packages/stack/src/plugins/blog/client/components/forms/post-forms.tsx
@@ -52,8 +52,8 @@ import { z } from "zod";
import { FeaturedImageField } from "./image-field";
const MarkdownEditor = lazy(() =>
- import("./markdown-editor").then((module) => ({
- default: module.MarkdownEditor,
+ import("./markdown-editor-with-overrides").then((module) => ({
+ default: module.MarkdownEditorWithOverrides,
})),
);
import { BLOG_LOCALIZATION } from "../../localization";
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 3027dc46..e62f4df6 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -135,6 +135,7 @@
"./components/ui-builder/internal/form-fields/iconname-field": "./src/components/ui-builder/internal/form-fields/iconname-field.tsx",
"./components/shared-form-types": "./src/components/shared-form-types.ts",
"./components/auto-form": "./src/components/auto-form/index.tsx",
+ "./components/auto-form/stepped-auto-form": "./src/components/auto-form/stepped-auto-form.tsx",
"./components/auto-form/types": "./src/components/auto-form/types.ts",
"./components/auto-form/helpers": "./src/components/auto-form/helpers.tsx",
"./components/form-builder": "./src/components/form-builder/index.tsx",