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\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\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_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\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\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\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\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\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\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_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\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)}\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{localization.BLOG_FORMS_TAGS_LABEL}\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\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_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\t\t\t\t\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\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\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\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\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\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\t\n\t\t\t\t{errorMessage && (\n\t\t\t\t\t
\n\t\t\t\t\t\t{errorMessage}\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_TITLE_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\t\t\tconst newTitle = e.target.value;\n\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t// Auto-slugify title if slug is not yet set\n\t\t\t\t\t\t\t\t\t\tif (!slugTouched) {\n\t\t\t\t\t\t\t\t\t\t\t// @ts-expect-error - slugify returns string which is compatible with slug field type\n\t\t\t\t\t\t\t\t\t\t\tform.setValue(nameSlug, slugify(newTitle));\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\n\t\t\t\t\t)}\n\t\t\t\t/>\n\n\t\t\t\t {\n\t\t\t\t\t\tconst currentTitle = form.getValues(nameTitle);\n\t\t\t\t\t\tconst autoGeneratedSlug = slugify(String(currentTitle ?? \"\"));\n\t\t\t\t\t\tconst currentSlug = String(field.value ?? \"\");\n\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_SLUG_LABEL}\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\tconst newSlug = e.target.value;\n\t\t\t\t\t\t\t\t\t\t\tfield.onChange(e);\n\t\t\t\t\t\t\t\t\t\t\t// Only mark as touched if the user manually edited to something different from auto-generated\n\t\t\t\t\t\t\t\t\t\t\t// This allows auto-generation to continue if the slug matches what would be generated\n\t\t\t\t\t\t\t\t\t\t\tif (newSlug !== autoGeneratedSlug) {\n\t\t\t\t\t\t\t\t\t\t\t\tsetSlugTouched(true);\n\t\t\t\t\t\t\t\t\t\t\t}\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\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_EXCERPT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\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)}\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{localization.BLOG_FORMS_TAGS_LABEL}\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\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_CONTENT_LABEL}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_REQUIRED_ASTERISK}\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\t\t\t\t\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\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\t\t{localization.BLOG_FORMS_CANCEL_BUTTON}\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\t\t{localization.BLOG_FORMS_DELETE_BUTTON}\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",