From 7eb24c594a47988fe3bf1e9c23d0a36340ad5016 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:07:42 +0530 Subject: [PATCH 01/48] feat(react): new react SDK --- packages/react/package.json | 17 ++++ .../react/src/v2/client/StoryblokPreview.tsx | 50 +++++++++++ packages/react/src/v2/client/index.ts | 1 + packages/react/src/v2/component-registry.tsx | 56 ++++++++++++ packages/react/src/v2/index.ts | 15 ++++ .../react/src/v2/rsc/StoryblokPreview.tsx | 88 +++++++++++++++++++ packages/react/src/v2/rsc/index.ts | 1 + packages/react/vite.config.ts | 9 +- pnpm-lock.yaml | 53 +++++++++-- 9 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 packages/react/src/v2/client/StoryblokPreview.tsx create mode 100644 packages/react/src/v2/client/index.ts create mode 100644 packages/react/src/v2/component-registry.tsx create mode 100644 packages/react/src/v2/index.ts create mode 100644 packages/react/src/v2/rsc/StoryblokPreview.tsx create mode 100644 packages/react/src/v2/rsc/index.ts diff --git a/packages/react/package.json b/packages/react/package.json index a680416e8..4eaa80956 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,6 +30,21 @@ "types": "./dist/rsc.d.ts", "import": "./dist/rsc.mjs", "require": "./dist/rsc.js" + }, + "./v2": { + "types": "./dist/v2.d.ts", + "import": "./dist/v2.mjs", + "require": "./dist/v2.js" + }, + "./v2/client": { + "types": "./dist/v2/client.d.ts", + "import": "./dist/v2/client.mjs", + "require": "./dist/v2/client.js" + }, + "./v2/rsc": { + "types": "./dist/v2/rsc.d.ts", + "import": "./dist/v2/rsc.mjs", + "require": "./dist/v2/rsc.js" } }, "main": "./dist/index.js", @@ -68,7 +83,9 @@ } }, "dependencies": { + "@storyblok/api-client": "workspace:*", "@storyblok/js": "workspace:*", + "@storyblok/live-preview": "workspace:*", "@storyblok/richtext": "workspace:*" }, "devDependencies": { diff --git a/packages/react/src/v2/client/StoryblokPreview.tsx b/packages/react/src/v2/client/StoryblokPreview.tsx new file mode 100644 index 000000000..f2f79548e --- /dev/null +++ b/packages/react/src/v2/client/StoryblokPreview.tsx @@ -0,0 +1,50 @@ +import { onStoryblokEditorEvent } from '@storyblok/live-preview'; +import type { Story } from '@storyblok/api-client'; +import { + type ReactNode, + useEffect, + useState, +} from 'react'; + +export interface StoryblokPreviewProps { + /** + * Initial story fetched by the application. + */ + story: Story; + + /** + * Render function that receives the latest story. + */ + children: (story: Story) => ReactNode; +} + +export function StoryblokPreview({ + story, + children, +}: StoryblokPreviewProps) { + const [currentStory, setCurrentStory] = useState(story); + useEffect(() => { + let mounted = true; + let unsubscribe: (() => void) | undefined; + + const setup = async () => { + unsubscribe = await onStoryblokEditorEvent((updatedStory) => { + if (!mounted) { + return; + } + setCurrentStory(updatedStory as Story); + }); + }; + + setup(); + + return () => { + mounted = false; + unsubscribe?.(); + }; + }, []); + + return <>{children(currentStory)}; +} + +export default StoryblokPreview; diff --git a/packages/react/src/v2/client/index.ts b/packages/react/src/v2/client/index.ts new file mode 100644 index 000000000..373bd9554 --- /dev/null +++ b/packages/react/src/v2/client/index.ts @@ -0,0 +1 @@ +export { default as StoryblokPreview } from './StoryblokPreview'; diff --git a/packages/react/src/v2/component-registry.tsx b/packages/react/src/v2/component-registry.tsx new file mode 100644 index 000000000..de5671498 --- /dev/null +++ b/packages/react/src/v2/component-registry.tsx @@ -0,0 +1,56 @@ +import type { ComponentType, ReactNode } from 'react'; + +export interface SbBlokData { + _uid: string; + component: string; + _editable?: string; + [key: string]: unknown; +} + +type StoryblokComponentType = ComponentType<{ blok: any }>; + +export interface RegistryConfig { + components: Record; + fallback?: StoryblokComponentType; +} + +export interface RegistryResult { + StoryblokComponent: ComponentType<{ blok: SbBlokData }>; + StoryblokBlocks: ComponentType<{ blocks: SbBlokData[] }>; + resolve: (name: string) => StoryblokComponentType | null; +} + +export function createRegistry(config: RegistryConfig): RegistryResult { + const resolve = (name: string): StoryblokComponentType | null => { + return config.components[name] ?? config.fallback ?? null; + }; + + function StoryblokComponent({ blok }: { blok: SbBlokData }): ReactNode { + const Component = resolve(blok.component); + if (!Component) { + console.warn(`[Storyblok] Unknown component: ${blok.component}`); + return null; + } + return ; + } + + function StoryblokBlocks({ blocks }: { blocks: SbBlokData[] }): ReactNode { + if (!blocks || blocks.length === 0) { + return null; + } + + return ( + <> + {blocks.map(blok => ( + + ))} + + ); + } + + return { + StoryblokComponent, + StoryblokBlocks, + resolve, + }; +} diff --git a/packages/react/src/v2/index.ts b/packages/react/src/v2/index.ts new file mode 100644 index 000000000..40b76d7a5 --- /dev/null +++ b/packages/react/src/v2/index.ts @@ -0,0 +1,15 @@ +export interface SbBlokData { + _uid: string; + component: string; + _editable?: string; + [key: string]: unknown; +} + +export { createRegistry } from './component-registry'; +export { + type ContentApiClientConfig, + createApiClient, + type Story, +} from '@storyblok/api-client'; + +export { storyblokEditable } from '@storyblok/live-preview'; diff --git a/packages/react/src/v2/rsc/StoryblokPreview.tsx b/packages/react/src/v2/rsc/StoryblokPreview.tsx new file mode 100644 index 000000000..3e3917aa2 --- /dev/null +++ b/packages/react/src/v2/rsc/StoryblokPreview.tsx @@ -0,0 +1,88 @@ +import { onStoryblokEditorEvent } from '@storyblok/live-preview'; +import type { Story } from '@storyblok/api-client'; +import { + type ReactNode, + useEffect, + useState, + useTransition, +} from 'react'; + +export interface StoryblokPreviewProps { + /** + * Server action responsible for rendering updated content. + */ + renderContent: ( + story: Story, + ) => Promise; + /** + * Initial server-rendered content. + */ + initialContent: ReactNode; +} + +export function StoryblokPreview({ + renderContent, + initialContent, +}: StoryblokPreviewProps) { + const [isPending, startTransition] = useTransition(); + + const [content, setContent] = useState(initialContent); + + useEffect(() => { + let mounted = true; + let unsubscribe: (() => void) | undefined; + + const setup = async () => { + unsubscribe = await onStoryblokEditorEvent((updatedStory) => { + if (!mounted) { + return; + } + + startTransition(async () => { + try { + const next = await renderContent(updatedStory as Story); + + if (mounted) { + setContent(next); + } + } + catch (err) { + console.error( + '[StoryblokPreview] Failed to render preview:', + err, + ); + } + }); + }); + }; + + setup(); + + return () => { + mounted = false; + unsubscribe?.(); + }; + }, [renderContent]); + + return ( + <> + {isPending && ( +
+ )} + + {content} + + ); +} + +export default StoryblokPreview; diff --git a/packages/react/src/v2/rsc/index.ts b/packages/react/src/v2/rsc/index.ts new file mode 100644 index 000000000..373bd9554 --- /dev/null +++ b/packages/react/src/v2/rsc/index.ts @@ -0,0 +1 @@ +export { default as StoryblokPreview } from './StoryblokPreview'; diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index c7dec2018..b75023618 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -21,9 +21,12 @@ export default defineConfig({ build: { lib: { entry: { - index: resolve(__dirname, 'src/index.ts'), - ssr: resolve(__dirname, 'src/ssr/index.ts'), - rsc: resolve(__dirname, 'src/rsc/index.ts'), + 'index': resolve(__dirname, 'src/index.ts'), + 'ssr': resolve(__dirname, 'src/ssr/index.ts'), + 'rsc': resolve(__dirname, 'src/rsc/index.ts'), + 'v2': resolve(__dirname, 'src/v2/index.ts'), + 'v2/client': resolve(__dirname, 'src/v2/client/index.ts'), + 'v2/rsc': resolve(__dirname, 'src/v2/rsc/index.ts'), }, name: 'storyblokReact', fileName: (format, entry) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0b03fbe..d822fe893 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,7 +228,7 @@ importers: version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) '@astrojs/vercel': specifier: ^11.0.0 - version: 11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) + version: 11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) '@astrojs/vue': specifier: ^7.0.0 version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) @@ -842,9 +842,15 @@ importers: packages/react: dependencies: + '@storyblok/api-client': + specifier: workspace:* + version: link:../capi-client '@storyblok/js': specifier: workspace:* version: link:../js + '@storyblok/live-preview': + specifier: workspace:* + version: link:../live-preview '@storyblok/richtext': specifier: workspace:* version: link:../richtext @@ -16863,10 +16869,10 @@ snapshots: is-wsl: 3.1.1 which-pm-runs: 1.1.0 - '@astrojs/vercel@11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': + '@astrojs/vercel@11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': dependencies: '@astrojs/internal-helpers': 0.10.0 - '@vercel/analytics': 1.6.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) + '@vercel/analytics': 1.6.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) '@vercel/functions': 3.4.3 '@vercel/nft': 1.3.2(rollup@4.60.2) '@vercel/routing-utils': 5.3.3 @@ -21926,7 +21932,9 @@ snapshots: '@storyblok/react@file:packages/react(next@13.5.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.99.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: + '@storyblok/api-client': link:packages/capi-client '@storyblok/js': link:packages/js + '@storyblok/live-preview': link:packages/live-preview '@storyblok/richtext': link:packages/richtext react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -23059,10 +23067,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': + '@vercel/analytics@1.6.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': optionalDependencies: '@sveltejs/kit': 2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - next: 16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) + next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0) react: 19.2.4 svelte: 5.55.0 vue: 3.5.30(typescript@6.0.3) @@ -29695,6 +29703,33 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001775 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + '@opentelemetry/api': 1.9.0 + sass: 1.99.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + optional: true + next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0): dependencies: '@next/env': 16.1.6 @@ -32751,6 +32786,14 @@ snapshots: optionalDependencies: '@babel/core': 7.29.7 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + optional: true + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): dependencies: client-only: 0.0.1 From 5f61c7d35a4fcec7c6656e2bd7d9f1c6e5488e95 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:07:57 +0530 Subject: [PATCH 02/48] fix(react): fix broken type declarations for v2 subentries - Remove rollupTypes which doesn't work with nested entry points - Remove preserveModules which leaked internal workspace paths - Add fixDtsExports plugin to remove erroneous 'export {}' from .d.ts files --- packages/react/vite.config.ts | 43 ++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index b75023618..e7622cb4d 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,17 +1,49 @@ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'node:path'; -import preserveDirectives from 'rollup-plugin-preserve-directives'; +import { defineConfig, type Plugin } from 'vitest/config'; +import { join, resolve } from 'node:path'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import dts from 'vite-plugin-dts'; import react from '@vitejs/plugin-react'; +/** + * Fixes vite-plugin-dts generating `export {}` at the end of re-export files, + * which negates the `export * from '...'` statement. + */ +function fixDtsExports(): Plugin { + return { + name: 'fix-dts-exports', + closeBundle() { + const distDir = resolve(__dirname, 'dist'); + const fixFile = (filePath: string) => { + const content = readFileSync(filePath, 'utf-8'); + // Fix: `export * from './foo'\nexport {}\n` -> `export * from './foo'\n` + if (content.includes('export {}') && content.includes('export *')) { + const fixed = content.replace(/\nexport \{\}\s*$/, '\n'); + writeFileSync(filePath, fixed); + } + }; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + if (statSync(fullPath).isDirectory()) { + walk(fullPath); + } + else if (entry.endsWith('.d.ts') && !entry.endsWith('.d.ts.map')) { + fixFile(fullPath); + } + } + }; + walk(distDir); + }, + }; +} + export default defineConfig({ plugins: [ react(), dts({ insertTypesEntry: true, - rollupTypes: true, }), - preserveDirectives(), + fixDtsExports(), ], resolve: { alias: { @@ -49,7 +81,6 @@ export default defineConfig({ /^next\//, ], output: { - preserveModules: true, globals: { react: 'React' }, }, }, From 006d57c12dd756f502c22fc7e1aa0610b5da4ace Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Thu, 2 Jul 2026 19:13:35 +0530 Subject: [PATCH 03/48] fix(cli): replace Angular env placeholders with actual values (#663) ## Summary Updates Angular environment file handling in the `create` command to use simple string replacement for placeholders instead of regex-based value matching. ## Changes - Add `updateAngularEnvironmentFiles` function for Angular-specific environment file handling - Pass `template` parameter to `handleEnvFileCreation` to detect Angular projects - Use `replaceAll` to replace `STORYBLOK_DELIVERY_API_TOKEN` and `STORYBLOK_REGION` placeholders with actual values ## Before The Angular environment files kept the placeholder names as literal strings. ## After ```ts // environment.ts export const environment = { production: true, accessToken: 'actual-token-here', // Replace with your Storyblok Delivery API token region: 'eu', // Replace with your Storyblok region }; ``` ## Related Works with the Angular blueprint update: https://github.com/storyblok/blueprint-core-angular/pull/18 --- .../cli/src/commands/create/actions.test.ts | 306 ++++++++++-------- packages/cli/src/commands/create/actions.ts | 85 ++++- .../cli/src/commands/create/index.test.ts | 36 +-- packages/cli/src/commands/create/index.ts | 6 +- 4 files changed, 273 insertions(+), 160 deletions(-) diff --git a/packages/cli/src/commands/create/actions.test.ts b/packages/cli/src/commands/create/actions.test.ts index 4cde18384..6d879ccab 100644 --- a/packages/cli/src/commands/create/actions.test.ts +++ b/packages/cli/src/commands/create/actions.test.ts @@ -1,21 +1,13 @@ import { spawn } from 'node:child_process'; -import fs from 'node:fs/promises'; import { vol } from 'memfs'; import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; import open from 'open'; -import { createEnvFile, extractPortFromTopics, fetchBlueprintRepositories, generateProject, handleEnvFileCreation, openSpaceInBrowser, repositoryToTemplate } from './actions'; -import * as filesystem from '../../utils/filesystem'; +import { createEnvFile, extractPortFromTopics, fetchBlueprintRepositories, generateProject, handleEnvFileCreation, openSpaceInBrowser, repositoryToTemplate, updateAngularEnvironmentFiles } from './actions'; import type { RegionCode } from '../../constants'; import { appDomains } from '../../constants'; vi.mock('node:child_process'); -vi.mock('node:fs/promises', () => ({ - default: { - access: vi.fn(), - }, -})); vi.mock('open'); -vi.mock('../../utils/filesystem'); vi.mock('../../github', () => ({ createOctokit: vi.fn(), })); @@ -33,8 +25,6 @@ vi.mock('../../utils/ui', () => ({ const mockedSpawn = vi.mocked(spawn); const mockedOpen = open as MockedFunction; -const mockedSaveToFile = filesystem.saveToFile as MockedFunction; -const mockedFsAccess = vi.mocked(fs.access); // Import the mocked modules const { createOctokit } = await import('../../github'); @@ -48,10 +38,7 @@ describe('create actions', () => { describe('generateProject', () => { it('should generate project successfully when directory does not exist', async () => { - const accessError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; - accessError.code = 'ENOENT'; - mockedFsAccess.mockRejectedValueOnce(accessError); - + // Directory does not exist - vol is empty const mockProcess = { on: vi.fn((event: string, callback: (code: number) => void) => { if (event === 'close') { @@ -63,7 +50,6 @@ describe('create actions', () => { await expect(generateProject('react', 'my-project', '/test/path')).resolves.toBeUndefined(); - expect(mockedFsAccess).toHaveBeenCalledWith('/test/path/my-project'); expect(mockedSpawn).toHaveBeenCalledWith( 'npx', ['degit', 'storyblok/blueprint-core-react', '/test/path/my-project'], @@ -75,37 +61,22 @@ describe('create actions', () => { }); it('should throw FileSystemError when directory already exists', async () => { - mockedFsAccess.mockResolvedValueOnce(undefined); + // Create existing directory in memfs + vol.fromJSON({ + '/test/path/existing-project/.gitkeep': '', + }); - await expect(generateProject('vue', 'existing-project')).rejects.toThrow( + await expect(generateProject('vue', 'existing-project', '/test/path')).rejects.toThrow( expect.objectContaining({ name: 'File System Error', errorId: 'directory_not_empty', code: 'ENOTEMPTY', }), ); - - expect(mockedFsAccess).toHaveBeenCalledWith(expect.stringContaining('existing-project')); - }); - - it('should handle filesystem errors other than ENOENT', async () => { - const accessError = new Error('EACCES: permission denied') as NodeJS.ErrnoException; - accessError.code = 'EACCES'; - mockedFsAccess.mockRejectedValueOnce(accessError); - - await expect(generateProject('svelte', 'test-project')).rejects.toThrow( - expect.objectContaining({ - name: 'File System Error', - errorId: 'permission_denied', - }), - ); }); it('should handle spawn process failure', async () => { - const accessError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; - accessError.code = 'ENOENT'; - mockedFsAccess.mockRejectedValueOnce(accessError); - + // Directory does not exist - vol is empty const mockProcess = { on: vi.fn((event: string, callback: (code: number) => void) => { if (event === 'close') { @@ -115,16 +86,13 @@ describe('create actions', () => { }; mockedSpawn.mockReturnValue(mockProcess as any); - await expect(generateProject('react', 'failed-project')).rejects.toThrow( + await expect(generateProject('react', 'failed-project', '/test/path')).rejects.toThrow( 'Failed to clone template. Process exited with code 1', ); }); it('should handle spawn process error', async () => { - const accessError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; - accessError.code = 'ENOENT'; - mockedFsAccess.mockRejectedValueOnce(accessError); - + // Directory does not exist - vol is empty const mockProcess = { on: vi.fn((event: string, callback: (error: Error) => void) => { if (event === 'error') { @@ -134,16 +102,13 @@ describe('create actions', () => { }; mockedSpawn.mockReturnValue(mockProcess as any); - await expect(generateProject('react', 'error-project')).rejects.toThrow( + await expect(generateProject('react', 'error-project', '/test/path')).rejects.toThrow( 'Failed to spawn degit process: spawn failed', ); }); it('should use current working directory as default target path', async () => { - const accessError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; - accessError.code = 'ENOENT'; - mockedFsAccess.mockRejectedValueOnce(accessError); - + // Directory does not exist - vol is empty const mockProcess = { on: vi.fn((event: string, callback: (code: number) => void) => { if (event === 'close') { @@ -157,7 +122,14 @@ describe('create actions', () => { await generateProject('react', 'my-project'); - expect(mockedFsAccess).toHaveBeenCalledWith('/current/dir/my-project'); + expect(mockedSpawn).toHaveBeenCalledWith( + 'npx', + ['degit', 'storyblok/blueprint-core-react', '/current/dir/my-project'], + { + stdio: 'inherit', + shell: true, + }, + ); vi.mocked(process.cwd).mockRestore(); }); @@ -165,18 +137,20 @@ describe('create actions', () => { describe('createEnvFile', () => { it('should create .env file successfully with access token only', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); await createEnvFile('/test/project', { STORYBLOK_DELIVERY_API_TOKEN: 'test-token-123' }); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringContaining('STORYBLOK_DELIVERY_API_TOKEN=test-token-123'), - ); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toContain('STORYBLOK_DELIVERY_API_TOKEN=test-token-123'); }); it('should create .env file with additional variables', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); const additionalVars = { CUSTOM_VAR: 'custom-value', @@ -185,36 +159,20 @@ describe('create actions', () => { await createEnvFile('/test/project', { STORYBLOK_DELIVERY_API_TOKEN: 'test-token-123' }, additionalVars); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringMatching(/STORYBLOK_DELIVERY_API_TOKEN=test-token-123/), - ); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringMatching(/CUSTOM_VAR=custom-value/), - ); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringMatching(/ANOTHER_VAR=another-value/), - ); - }); - - it('should handle filesystem errors when creating .env file', async () => { - const saveError = new Error('Permission denied'); - mockedSaveToFile.mockRejectedValue(saveError); - - await expect(createEnvFile('/test/project', { STORYBLOK_DELIVERY_API_TOKEN: 'test-token-123' })).rejects.toThrow( - 'Failed to create .env file: Permission denied', - ); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toMatch(/STORYBLOK_DELIVERY_API_TOKEN=test-token-123/); + expect(content).toMatch(/CUSTOM_VAR=custom-value/); + expect(content).toMatch(/ANOTHER_VAR=another-value/); }); it('should create proper .env file content structure', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); await createEnvFile('/test/project', { STORYBLOK_DELIVERY_API_TOKEN: 'test-token-123' }, { CUSTOM: 'value' }); - const [[, content]] = mockedSaveToFile.mock.calls; - + const content = vol.readFileSync('/test/project/.env', 'utf-8'); expect(content).toMatch(/^# Storyblok Configuration/); expect(content).toMatch(/STORYBLOK_DELIVERY_API_TOKEN=test-token-123/); expect(content).toMatch(/# Additional Configuration/); @@ -224,32 +182,29 @@ describe('create actions', () => { describe('handleEnvFileCreation', () => { it('should create .env file with token and region successfully', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); const result = await handleEnvFileCreation('/test/project', 'test-token-123', 'us'); expect(result).toBe(true); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringContaining('STORYBLOK_DELIVERY_API_TOKEN=test-token-123'), - ); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringContaining('STORYBLOK_REGION=us'), - ); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toContain('STORYBLOK_DELIVERY_API_TOKEN=test-token-123'); + expect(content).toContain('STORYBLOK_REGION=us'); expect(mockedUI.ok).toHaveBeenCalledWith(expect.stringContaining('Created .env file with'), true); }); it('should create .env file with only token', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); const result = await handleEnvFileCreation('/test/project', 'test-token-456'); expect(result).toBe(true); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringContaining('STORYBLOK_DELIVERY_API_TOKEN=test-token-456'), - ); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toContain('STORYBLOK_DELIVERY_API_TOKEN=test-token-456'); expect(mockedUI.ok).toHaveBeenCalledWith( expect.stringContaining('Created .env file with'), true, @@ -257,15 +212,15 @@ describe('create actions', () => { }); it('should create .env file with only region', async () => { - mockedSaveToFile.mockResolvedValue(undefined); + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); const result = await handleEnvFileCreation('/test/project', undefined, 'ap'); expect(result).toBe(true); - expect(mockedSaveToFile).toHaveBeenCalledWith( - '/test/project/.env', - expect.stringContaining('STORYBLOK_REGION=ap'), - ); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toContain('STORYBLOK_REGION=ap'); expect(mockedUI.ok).toHaveBeenCalledWith(expect.stringContaining('Created .env file with'), true); }); @@ -274,62 +229,139 @@ describe('create actions', () => { expect(result).toBe(true); expect(mockedUI.info).toHaveBeenCalledWith('No environment variables to write'); - expect(mockedSaveToFile).not.toHaveBeenCalled(); + }); + }); + + describe('updateAngularEnvironmentFiles', () => { + const angularEnvTemplate = `export const environment = { + production: true, + accessToken: 'STORYBLOK_DELIVERY_API_TOKEN', // Replace with your Storyblok Delivery API token + region: 'STORYBLOK_REGION', // Replace with your Storyblok region +};`; + + it('should replace both token and region placeholders', async () => { + vol.fromJSON({ + '/test/project/src/environments/environment.ts': angularEnvTemplate, + '/test/project/src/environments/environment.development.ts': angularEnvTemplate, + }); + + const result = await updateAngularEnvironmentFiles('/test/project', 'my-actual-token', 'eu'); + + expect(result.updatedFiles).toEqual([ + '/test/project/src/environments/environment.ts', + '/test/project/src/environments/environment.development.ts', + ]); + + const envContent = vol.readFileSync('/test/project/src/environments/environment.ts', 'utf-8'); + expect(envContent).toContain('accessToken: \'my-actual-token\''); + expect(envContent).toContain('region: \'eu\''); + + const devEnvContent = vol.readFileSync('/test/project/src/environments/environment.development.ts', 'utf-8'); + expect(devEnvContent).toContain('accessToken: \'my-actual-token\''); + expect(devEnvContent).toContain('region: \'eu\''); }); - it('should handle errors gracefully and return false', async () => { - const saveError = new Error('Permission denied'); - mockedSaveToFile.mockRejectedValue(saveError); + it('should replace only token placeholder when region not provided', async () => { + vol.fromJSON({ + '/test/project/src/environments/environment.ts': angularEnvTemplate, + '/test/project/src/environments/environment.development.ts': angularEnvTemplate, + }); - const result = await handleEnvFileCreation('/test/project', 'test-token-789', 'eu'); + await updateAngularEnvironmentFiles('/test/project', 'my-token'); - expect(result).toBe(false); - expect(mockedUI.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to create .env file: Permission denied'), - ); - expect(mockedUI.info).toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_DELIVERY_API_TOKEN'), - ); - expect(mockedUI.info).toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_REGION'), - ); + const content = vol.readFileSync('/test/project/src/environments/environment.ts', 'utf-8'); + expect(content).toContain('accessToken: \'my-token\''); + // Region placeholder should remain unchanged + expect(content).toContain('STORYBLOK_REGION'); }); - it('should show only token message when only token fails', async () => { - const saveError = new Error('Disk full'); - mockedSaveToFile.mockRejectedValue(saveError); + it('should replace only region placeholder when token not provided', async () => { + vol.fromJSON({ + '/test/project/src/environments/environment.ts': angularEnvTemplate, + '/test/project/src/environments/environment.development.ts': angularEnvTemplate, + }); - const result = await handleEnvFileCreation('/test/project', 'test-token'); + await updateAngularEnvironmentFiles('/test/project', undefined, 'us'); - expect(result).toBe(false); - expect(mockedUI.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to create .env file'), - ); - expect(mockedUI.info).toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_DELIVERY_API_TOKEN'), - ); - expect(mockedUI.info).not.toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_REGION'), - ); + const content = vol.readFileSync('/test/project/src/environments/environment.ts', 'utf-8'); + expect(content).toContain('region: \'us\''); + // Token placeholder should remain unchanged + expect(content).toContain('STORYBLOK_DELIVERY_API_TOKEN'); }); - it('should show only region message when only region fails', async () => { - const saveError = new Error('Access denied'); - mockedSaveToFile.mockRejectedValue(saveError); + it('should skip missing environment files silently and return empty array', async () => { + // No files exist in memfs + const result = await updateAngularEnvironmentFiles('/test/project', 'token', 'eu'); - const result = await handleEnvFileCreation('/test/project', undefined, 'ca'); + expect(result.updatedFiles).toEqual([]); + }); - expect(result).toBe(false); - expect(mockedUI.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to create .env file'), - ); - expect(mockedUI.info).not.toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_DELIVERY_API_TOKEN'), + it('should return only the files that exist', async () => { + vol.fromJSON({ + '/test/project/src/environments/environment.ts': angularEnvTemplate, + // environment.development.ts does not exist + }); + + const result = await updateAngularEnvironmentFiles('/test/project', 'token', 'eu'); + + expect(result.updatedFiles).toEqual(['/test/project/src/environments/environment.ts']); + }); + }); + + describe('handleEnvFileCreation for Angular', () => { + const angularEnvTemplate = `export const environment = { + production: true, + accessToken: 'STORYBLOK_DELIVERY_API_TOKEN', + region: 'STORYBLOK_REGION', +};`; + + it('should update Angular environment files when template is angular', async () => { + vol.fromJSON({ + '/test/project/src/environments/environment.ts': angularEnvTemplate, + '/test/project/src/environments/environment.development.ts': angularEnvTemplate, + }); + + const result = await handleEnvFileCreation('/test/project', 'my-token', 'eu', 'angular'); + + expect(result).toBe(true); + expect(mockedUI.ok).toHaveBeenCalledWith( + expect.stringContaining('Updated Angular environment files'), + true, ); + // Verify files were actually updated + const content = vol.readFileSync('/test/project/src/environments/environment.ts', 'utf-8'); + expect(content).toContain('accessToken: \'my-token\''); + expect(content).toContain('region: \'eu\''); + }); + + it('should return true and log info when no vars provided for angular', async () => { + const result = await handleEnvFileCreation('/test/project', undefined, undefined, 'angular'); + + expect(result).toBe(true); + expect(mockedUI.info).toHaveBeenCalledWith('No environment variables to write'); + }); + + it('should log info when environment files do not exist', async () => { + // No Angular environment files exist + const result = await handleEnvFileCreation('/test/project', 'token', 'eu', 'angular'); + + expect(result).toBe(true); expect(mockedUI.info).toHaveBeenCalledWith( - expect.stringContaining('You can manually add STORYBLOK_REGION'), + 'No Angular environment files found to update', ); }); + + it('should create .env file for non-angular templates', async () => { + vol.fromJSON({ + '/test/project/.gitkeep': '', + }); + + const result = await handleEnvFileCreation('/test/project', 'token', 'eu', 'react'); + + expect(result).toBe(true); + const content = vol.readFileSync('/test/project/.env', 'utf-8'); + expect(content).toContain('STORYBLOK_DELIVERY_API_TOKEN=token'); + }); }); it('should contain the correct region domains', () => { diff --git a/packages/cli/src/commands/create/actions.ts b/packages/cli/src/commands/create/actions.ts index b5438e2fb..76375c426 100644 --- a/packages/cli/src/commands/create/actions.ts +++ b/packages/cli/src/commands/create/actions.ts @@ -115,8 +115,89 @@ ${Object.entries(storyblokVars).map(([key, value]) => `${key}=${value}`).join('\ } }; -// Helper to create .env file and handle errors -export async function handleEnvFileCreation(resolvedPath: string, token?: string, region?: RegionCode): Promise { +/** + * Updates Angular environment files with Storyblok configuration + * Angular uses TypeScript environment files instead of .env files + * @param projectPath - The absolute path to the project directory + * @param token - The Storyblok access token + * @param region - The Storyblok region code + * @returns Object containing array of files that were actually updated + */ +export const updateAngularEnvironmentFiles = async ( + projectPath: string, + token?: string, + region?: RegionCode, +): Promise<{ updatedFiles: string[] }> => { + const environmentsDir = join(projectPath, 'src', 'environments'); + const envFiles = ['environment.ts', 'environment.development.ts']; + const updatedFiles: string[] = []; + + for (const envFile of envFiles) { + const filePath = join(environmentsDir, envFile); + try { + let content = await fs.readFile(filePath, 'utf-8'); + + // Replace placeholder values with actual values + if (token) { + content = content.replaceAll('STORYBLOK_DELIVERY_API_TOKEN', token); + } + if (region) { + content = content.replaceAll('STORYBLOK_REGION', region); + } + + await saveToFile(filePath, content); + updatedFiles.push(filePath); + } + catch (error) { + const fsError = error as NodeJS.ErrnoException; + // If file doesn't exist, skip it silently + if (fsError.code === 'ENOENT') { + continue; + } + throw new Error(`Failed to update ${envFile}: ${(error as Error).message}`); + } + } + + return { updatedFiles }; +}; + +// Helper to create .env file (or Angular environment files) and handle errors +export async function handleEnvFileCreation(resolvedPath: string, token?: string, region?: RegionCode, template?: string): Promise { + // Angular uses TypeScript environment files instead of .env + if (template === 'angular') { + if (!token && !region) { + ui.info('No environment variables to write'); + return true; + } + try { + const { updatedFiles } = await updateAngularEnvironmentFiles(resolvedPath, token, region); + + if (updatedFiles.length === 0) { + ui.info('No Angular environment files found to update'); + return true; + } + + const writtenVars = [token && 'accessToken', region && 'region'].filter(Boolean).join(', '); + ui.ok(`Updated Angular environment files with: ${writtenVars}`, true); + return true; + } + catch (error) { + ui.warn(`Failed to update Angular environment files: ${(error as Error).message}`); + if (token) { + ui.info( + `You can manually add accessToken to src/environments/environment.ts and src/environments/environment.development.ts`, + ); + } + if (region) { + ui.info( + `You can manually add region to src/environments/environment.ts and src/environments/environment.development.ts`, + ); + } + return false; + } + } + + // Default behavior for other frameworks: create .env file const envVars: Record = {}; if (token) { envVars.STORYBLOK_DELIVERY_API_TOKEN = token; diff --git a/packages/cli/src/commands/create/index.test.ts b/packages/cli/src/commands/create/index.test.ts index 687187001..bb3dc3a16 100644 --- a/packages/cli/src/commands/create/index.test.ts +++ b/packages/cli/src/commands/create/index.test.ts @@ -143,7 +143,7 @@ describe('createCommand', () => { // Should generate project expect(generateProject).toHaveBeenCalledWith('react', 'my-project', expect.any(String)); // Should create .env file with provided token and session region - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'eu', 'react'); // Should NOT create space or open browser expect(createSpace).not.toHaveBeenCalled(); expect(openSpaceInBrowser).not.toHaveBeenCalled(); @@ -167,7 +167,7 @@ describe('createCommand', () => { // Should generate project expect(generateProject).toHaveBeenCalledWith('react', 'my-project', expect.any(String)); // Should create .env file with provided token and region - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'us'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'us', 'react'); // Should NOT create space, require authentication, or prompt for login expect(createSpace).not.toHaveBeenCalled(); expect(requireAuthentication).not.toHaveBeenCalled(); @@ -411,7 +411,7 @@ describe('createCommand', () => { }); // Verify .env file creation - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu', 'react'); // Verify browser opening expect(openSpaceInBrowser).toHaveBeenCalledWith(12345, 'eu'); @@ -470,7 +470,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react']); // Should call handleEnvFileCreation - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu', 'react'); // Should continue with browser opening even if .env creation fails expect(openSpaceInBrowser).toHaveBeenCalled(); }); @@ -587,7 +587,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', './projects/my-project', '--template', 'react']); expect(generateProject).toHaveBeenCalledWith('react', 'my-project', expect.stringContaining('projects')); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.stringContaining('my-project'), 'space-token-123', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.stringContaining('my-project'), 'space-token-123', 'eu', 'react'); }); it('should handle absolute paths correctly', async () => { @@ -630,7 +630,7 @@ describe('createCommand', () => { // Verify space creation is skipped expect(createSpace).not.toHaveBeenCalled(); // handleEnvFileCreation IS called with session region (eu from mock) - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'eu', 'react'); expect(openSpaceInBrowser).not.toHaveBeenCalled(); // Verify success message still shows @@ -665,7 +665,7 @@ describe('createCommand', () => { // Verify space-related operations are skipped expect(createSpace).not.toHaveBeenCalled(); // handleEnvFileCreation IS called with session region (eu from mock) - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'eu', 'vue'); expect(openSpaceInBrowser).not.toHaveBeenCalled(); }); @@ -1081,7 +1081,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--token', 'my-access-token', '--region', 'us']); // Should create .env file with provided token and region - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'us'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'us', 'react'); }); it('should validate region and show error for invalid region', async () => { @@ -1117,7 +1117,7 @@ describe('createCommand', () => { expect(handleError).not.toHaveBeenCalled(); expect(generateProject).toHaveBeenCalled(); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'token', region); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'token', region, 'react'); } }); @@ -1135,7 +1135,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react']); // Should create .env file with space token and session region (eu by default in tests) - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu', 'react'); }); it('should not include region in .env when --token is provided without --region', async () => { @@ -1148,7 +1148,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--token', 'my-access-token']); // Should create .env file with session region (eu from mock) when --region not provided - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-access-token', 'eu', 'react'); }); it('should work with --region and --skip-space flags together', async () => { @@ -1165,7 +1165,7 @@ describe('createCommand', () => { // Should not create space but should call handleEnvFileCreation with only the region (no token) expect(createSpace).not.toHaveBeenCalled(); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'ca'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'ca', 'react'); }); it('should throw error when provided region does not match user account region during space creation', async () => { @@ -1206,7 +1206,7 @@ describe('createCommand', () => { expect(handleError).not.toHaveBeenCalled(); expect(generateProject).toHaveBeenCalled(); expect(createSpace).toHaveBeenCalled(); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'space-token-123', 'eu', 'react'); }); it('should not throw region mismatch error when --token is provided with different region', async () => { @@ -1222,7 +1222,7 @@ describe('createCommand', () => { // Should proceed without region mismatch error expect(handleError).not.toHaveBeenCalled(); expect(generateProject).toHaveBeenCalled(); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'us'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'us', 'react'); // Should not create space expect(createSpace).not.toHaveBeenCalled(); @@ -1244,7 +1244,7 @@ describe('createCommand', () => { // Should not create space but should call handleEnvFileCreation with undefined token and region expect(createSpace).not.toHaveBeenCalled(); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'us'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'us', 'react'); }); describe('session region fallback behavior', () => { @@ -1258,7 +1258,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--token', 'my-token']); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'us'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'us', 'react'); }); it('should use CA session region as fallback when no --region provided with --skip-space', async () => { @@ -1271,7 +1271,7 @@ describe('createCommand', () => { await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--skip-space']); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'ca'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), undefined, 'ca', 'react'); }); it('should prioritize user-provided --region over session region', async () => { @@ -1285,7 +1285,7 @@ describe('createCommand', () => { // User provides 'ap' region, should use that instead of 'us' session region await createCommand.parseAsync(['node', 'test', 'my-project', '--template', 'react', '--token', 'my-token', '--region', 'ap']); - expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'ap'); + expect(handleEnvFileCreation).toHaveBeenCalledWith(expect.any(String), 'my-token', 'ap', 'react'); }); }); }); diff --git a/packages/cli/src/commands/create/index.ts b/packages/cli/src/commands/create/index.ts index 6c0b8db3a..4a1cfe67b 100644 --- a/packages/cli/src/commands/create/index.ts +++ b/packages/cli/src/commands/create/index.ts @@ -209,14 +209,14 @@ export const createCommand = program let userData: User; let whereToCreateSpace = 'personal'; if (token) { - await handleEnvFileCreation(resolvedPath, token, options.region || region); + await handleEnvFileCreation(resolvedPath, token, options.region || region, technologyTemplate); showNextSteps(technologyTemplate!, finalProjectPath); return; } if (options.skipSpace) { // Only create .env file if region is available (useful for configuring SDK) if (options.region || region) { - await handleEnvFileCreation(resolvedPath, undefined, options.region || region); + await handleEnvFileCreation(resolvedPath, undefined, options.region || region, technologyTemplate); } showNextSteps(technologyTemplate!, finalProjectPath); return; @@ -303,7 +303,7 @@ export const createCommand = program // Create .env file with the Storyblok token if (createdSpace?.first_token) { - await handleEnvFileCreation(resolvedPath, createdSpace.first_token, region!); + await handleEnvFileCreation(resolvedPath, createdSpace.first_token, region!, technologyTemplate); } // Open the space in the browser From e67f6699e58f534b3d946f1a7fc31271423b928e Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:22:34 +0530 Subject: [PATCH 04/48] Revert "fix(react): fix broken type declarations for v2 subentries" This reverts commit 5f61c7d35a4fcec7c6656e2bd7d9f1c6e5488e95. --- packages/react/vite.config.ts | 43 +++++------------------------------ 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index e7622cb4d..b75023618 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,49 +1,17 @@ -import { defineConfig, type Plugin } from 'vitest/config'; -import { join, resolve } from 'node:path'; -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; +import preserveDirectives from 'rollup-plugin-preserve-directives'; import dts from 'vite-plugin-dts'; import react from '@vitejs/plugin-react'; -/** - * Fixes vite-plugin-dts generating `export {}` at the end of re-export files, - * which negates the `export * from '...'` statement. - */ -function fixDtsExports(): Plugin { - return { - name: 'fix-dts-exports', - closeBundle() { - const distDir = resolve(__dirname, 'dist'); - const fixFile = (filePath: string) => { - const content = readFileSync(filePath, 'utf-8'); - // Fix: `export * from './foo'\nexport {}\n` -> `export * from './foo'\n` - if (content.includes('export {}') && content.includes('export *')) { - const fixed = content.replace(/\nexport \{\}\s*$/, '\n'); - writeFileSync(filePath, fixed); - } - }; - const walk = (dir: string) => { - for (const entry of readdirSync(dir)) { - const fullPath = join(dir, entry); - if (statSync(fullPath).isDirectory()) { - walk(fullPath); - } - else if (entry.endsWith('.d.ts') && !entry.endsWith('.d.ts.map')) { - fixFile(fullPath); - } - } - }; - walk(distDir); - }, - }; -} - export default defineConfig({ plugins: [ react(), dts({ insertTypesEntry: true, + rollupTypes: true, }), - fixDtsExports(), + preserveDirectives(), ], resolve: { alias: { @@ -81,6 +49,7 @@ export default defineConfig({ /^next\//, ], output: { + preserveModules: true, globals: { react: 'React' }, }, }, From 7ee548923fac9eff27fcd87f2fd20f552871bb51 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:35:09 +0530 Subject: [PATCH 05/48] feat(react): add /next subpath exports for v2 SDK - Export v2 SDK as @storyblok/react/next, /next/rsc, /next/client - Remove rollupTypes (incompatible with nested entries) - Remove preserveModules (leaked workspace paths) - Keep source in src/v2/, export as /next for experimentation --- packages/react/package.json | 24 ++++++++++++------------ packages/react/vite.config.ts | 9 ++++----- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 4eaa80956..5f9db77df 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -31,20 +31,20 @@ "import": "./dist/rsc.mjs", "require": "./dist/rsc.js" }, - "./v2": { - "types": "./dist/v2.d.ts", - "import": "./dist/v2.mjs", - "require": "./dist/v2.js" + "./next": { + "types": "./dist/next.d.ts", + "import": "./dist/next.mjs", + "require": "./dist/next.js" }, - "./v2/client": { - "types": "./dist/v2/client.d.ts", - "import": "./dist/v2/client.mjs", - "require": "./dist/v2/client.js" + "./next/rsc": { + "types": "./dist/next/rsc.d.ts", + "import": "./dist/next/rsc.mjs", + "require": "./dist/next/rsc.js" }, - "./v2/rsc": { - "types": "./dist/v2/rsc.d.ts", - "import": "./dist/v2/rsc.mjs", - "require": "./dist/v2/rsc.js" + "./next/client": { + "types": "./dist/next/client.d.ts", + "import": "./dist/next/client.mjs", + "require": "./dist/next/client.js" } }, "main": "./dist/index.js", diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index b75023618..ebb49c0b3 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -9,7 +9,6 @@ export default defineConfig({ react(), dts({ insertTypesEntry: true, - rollupTypes: true, }), preserveDirectives(), ], @@ -24,9 +23,10 @@ export default defineConfig({ 'index': resolve(__dirname, 'src/index.ts'), 'ssr': resolve(__dirname, 'src/ssr/index.ts'), 'rsc': resolve(__dirname, 'src/rsc/index.ts'), - 'v2': resolve(__dirname, 'src/v2/index.ts'), - 'v2/client': resolve(__dirname, 'src/v2/client/index.ts'), - 'v2/rsc': resolve(__dirname, 'src/v2/rsc/index.ts'), + // v2 experimental entries + 'next': resolve(__dirname, 'src/v2/index.ts'), + 'next/rsc': resolve(__dirname, 'src/v2/rsc/index.ts'), + 'next/client': resolve(__dirname, 'src/v2/client/index.ts'), }, name: 'storyblokReact', fileName: (format, entry) => { @@ -49,7 +49,6 @@ export default defineConfig({ /^next\//, ], output: { - preserveModules: true, globals: { react: 'React' }, }, }, From e1f25771861344a27337be5bac35149e84089cad Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:43:24 +0530 Subject: [PATCH 06/48] chore(react): add use client directive --- packages/react/src/v2/client/StoryblokPreview.tsx | 2 ++ packages/react/src/v2/rsc/StoryblokPreview.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/react/src/v2/client/StoryblokPreview.tsx b/packages/react/src/v2/client/StoryblokPreview.tsx index f2f79548e..6bb37c668 100644 --- a/packages/react/src/v2/client/StoryblokPreview.tsx +++ b/packages/react/src/v2/client/StoryblokPreview.tsx @@ -1,3 +1,5 @@ +'use client'; + import { onStoryblokEditorEvent } from '@storyblok/live-preview'; import type { Story } from '@storyblok/api-client'; import { diff --git a/packages/react/src/v2/rsc/StoryblokPreview.tsx b/packages/react/src/v2/rsc/StoryblokPreview.tsx index 3e3917aa2..cd099ab6b 100644 --- a/packages/react/src/v2/rsc/StoryblokPreview.tsx +++ b/packages/react/src/v2/rsc/StoryblokPreview.tsx @@ -1,3 +1,4 @@ +'use client'; import { onStoryblokEditorEvent } from '@storyblok/live-preview'; import type { Story } from '@storyblok/api-client'; import { From 9ba6f58c21063b3c2428265ba390c22fa4b56a26 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:55:40 +0530 Subject: [PATCH 07/48] chore(react): preserve use client directive --- packages/react/vite.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index ebb49c0b3..ad2782ab5 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -50,6 +50,8 @@ export default defineConfig({ ], output: { globals: { react: 'React' }, + preserveModules: true, + preserveModulesRoot: 'src', }, }, }, From 14281114b3e6f23828b2030a62789ebfbd5273c4 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Mon, 6 Jul 2026 08:37:04 +0200 Subject: [PATCH 08/48] fix(cli,mapi-client): preserve image dimensions in asset URLs on push (#672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets migrated between spaces with `storyblok assets push` lost the `x` dimensions folder in their CDN URL (e.g. `.../f/SPACE/2048x1820/hash/image.jpg` became `.../f/SPACE/hash/image.jpg`), even when the source asset had it. Root cause: Storyblok's backend only appends the dimensions folder to the S3 key when the sign request (`POST /v1/spaces/{id}/assets`) includes a `size` field — it does not derive it from the actual image. Two gaps combined to drop it: - `@storyblok/management-api-client`'s `assets.create()` explicitly whitelisted only `short_filename`, `asset_folder_id`, `is_private` when calling `upload()`, silently discarding `size` (and `validate_upload`) even though both are part of its declared input type. - The CLI's `assets push` never derived a `size` for pushed assets in the first place. ## Changes - `packages/mapi-client/src/resources/assets.ts`: `create()` now forwards `size`/`validate_upload` to the sign request. - `packages/cli/src/commands/assets/streams.ts`: when creating a pushed asset, reuse the dimensions already present in the source asset's filename URL (parsed via a new `extractAssetSizeFromFilename` helper) so pushed assets keep them. - Added regression tests in both packages. Fixes WDX-480 --- .../src/commands/assets/push/index.test.ts | 28 ++++++++++++ packages/cli/src/commands/assets/streams.ts | 14 +++--- .../cli/src/commands/assets/utils.test.ts | 17 ++++++- packages/cli/src/commands/assets/utils.ts | 19 ++++++++ .../mapi-client/src/resources/assets.test.ts | 44 +++++++++++++++++++ packages/mapi-client/src/resources/assets.ts | 2 + 6 files changed, 118 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/assets/push/index.test.ts b/packages/cli/src/commands/assets/push/index.test.ts index 2fb84f164..5921c13aa 100644 --- a/packages/cli/src/commands/assets/push/index.test.ts +++ b/packages/cli/src/commands/assets/push/index.test.ts @@ -490,6 +490,34 @@ describe('assets push command', () => { expect(process.exitCode).toBe(0); }); + it('should carry the dimensions segment from the source filename into the create payload as `size`', async () => { + const targetSpace = '54321'; + const asset = makeMockAsset({ filename: `https://a.storyblok.com/f/${DEFAULT_SPACE}/2048x1820/7fb286a4c5/photo.jpg` }); + preconditions.canLoadAssets([asset]); + preconditions.canUpsertRemoteAssets([asset], { space: targetSpace }); + + await assetsCommand.parseAsync(['node', 'test', 'push', '--from', DEFAULT_SPACE, '--space', targetSpace]); + + expect(actions.createAsset).toHaveBeenCalledWith(expect.objectContaining({ + size: '2048x1820', + }), expect.anything(), expect.anything()); + }); + + it('should not set `size` when the source filename has no dimensions segment', async () => { + const targetSpace = '54321'; + const asset = makeMockAsset({ filename: `https://a.storyblok.com/f/${DEFAULT_SPACE}/7fb286a4c5/photo.jpg` }); + preconditions.canLoadAssets([asset]); + preconditions.canUpsertRemoteAssets([asset], { space: targetSpace }); + + await assetsCommand.parseAsync(['node', 'test', 'push', '--from', DEFAULT_SPACE, '--space', targetSpace]); + + expect(actions.createAsset).toHaveBeenCalledWith( + expect.not.objectContaining({ size: expect.anything() }), + expect.anything(), + expect.anything(), + ); + }); + it('should correctly resolve parent IDs even when child folders precede parents', async () => { const targetSpace = '54321'; const numPairs = 10; diff --git a/packages/cli/src/commands/assets/streams.ts b/packages/cli/src/commands/assets/streams.ts index 7babbd588..7f0521187 100644 --- a/packages/cli/src/commands/assets/streams.ts +++ b/packages/cli/src/commands/assets/streams.ts @@ -13,7 +13,7 @@ import { getMapiClient } from '../../api'; import { handleAPIError } from '../../utils/error/api-error'; import { FetchError } from '../../utils/fetch'; import { createPipelineBackpressureLock } from '../../utils/backpressure-lock'; -import { getAssetBinaryFilename, getAssetFilename, getFolderFilename, getSidecarFilename, isRemoteSource, loadSidecarAssetData } from './utils'; +import { extractAssetSizeFromFilename, getAssetBinaryFilename, getAssetFilename, getFolderFilename, getSidecarFilename, isRemoteSource, loadSidecarAssetData } from './utils'; let _pipelineSlot: Sema | null = null; const getPipelineSlot = (): Sema => { @@ -650,9 +650,8 @@ export const makeCleanupAssetFSTransport = (): CleanupAssetTransport => const hasId = (a: unknown): a is { id: number } => { return !!a && typeof a === 'object' && 'id' in a && typeof (a as any).id === 'number'; }; -const hasShortFilename = (a: unknown): a is { short_filename: string } => { - return !!a && typeof a === 'object' && 'short_filename' in a && typeof (a as any).short_filename === 'string'; -}; +const hasProp = (a: unknown, key: K): a is Record => + !!a && typeof a === 'object' && key in a && typeof (a as any)[key] === 'string'; const processAsset = async ({ localAsset, @@ -720,7 +719,7 @@ const processAsset = async ({ newRemoteAsset = { ...remoteAsset, ...updatePayload }; status = 'updated'; } - else if (hasShortFilename(localAsset)) { + else if (hasProp(localAsset, 'short_filename')) { // `internal_tags_list` is server-managed (read-only) and must not be sent. // `internal_tag_ids` is rewritten through `maps.assetInternalTagsByName` so // source-space IDs are translated to target-space IDs. When the @@ -730,10 +729,15 @@ const processAsset = async ({ const mappedTagIds = 'internal_tag_ids' in localAsset ? resolveInternalTagIds(localAsset.internal_tag_ids) : undefined; + // Storyblok only keeps the `x` folder in the CDN URL when it + // was supplied at upload time; it is not derived server-side from the file. + // Carry it over from the source asset's filename so pushed assets keep it. + const size = hasProp(rest, 'size') ? rest.size : (hasProp(localAsset, 'filename') ? extractAssetSizeFromFilename(localAsset.filename) : undefined); const createPayload = { ...rest, asset_folder_id: remoteFolderId, ...(mappedTagIds !== undefined ? { internal_tag_ids: mappedTagIds } : {}), + ...(size !== undefined ? { size } : {}), } satisfies AssetUpload; newRemoteAsset = await transports.createAsset(createPayload, fileBuffer); status = 'created'; diff --git a/packages/cli/src/commands/assets/utils.test.ts b/packages/cli/src/commands/assets/utils.test.ts index b1d041ffc..c31defd56 100644 --- a/packages/cli/src/commands/assets/utils.test.ts +++ b/packages/cli/src/commands/assets/utils.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; import { vol } from 'memfs'; -import { collectAssetInternalTagNames, ensureAssetInternalTags, internalTagNamesFromAssets } from './utils'; +import { collectAssetInternalTagNames, ensureAssetInternalTags, extractAssetSizeFromFilename, internalTagNamesFromAssets } from './utils'; + +describe('extractAssetSizeFromFilename', () => { + it('extracts the dimensions segment from a CDN URL', () => { + expect(extractAssetSizeFromFilename('https://a.storyblok.com/f/329189/2048x1820/7fb286a4c5/image.jpg')).toBe('2048x1820'); + }); + + it('returns undefined when the URL has no dimensions segment', () => { + expect(extractAssetSizeFromFilename('https://a.storyblok.com/f/293255674717942/8ae82d3a12/image.jpg')).toBeUndefined(); + }); + + it('returns undefined for an empty or invalid filename', () => { + expect(extractAssetSizeFromFilename(undefined)).toBeUndefined(); + expect(extractAssetSizeFromFilename('not-a-url')).toBeUndefined(); + }); +}); describe('internalTagNamesFromAssets', () => { it('collects unique tag names in first-seen order, ignoring blanks', () => { diff --git a/packages/cli/src/commands/assets/utils.ts b/packages/cli/src/commands/assets/utils.ts index d27133148..674674e53 100644 --- a/packages/cli/src/commands/assets/utils.ts +++ b/packages/cli/src/commands/assets/utils.ts @@ -82,6 +82,25 @@ export const loadAssetFolderMap = async (manifestFile: string) => { return new Map(manifest.map(e => [Number(e.old_id), Number(e.new_id)])) satisfies AssetFolderMap; }; +/** + * Extracts the `x` dimensions segment from an asset CDN URL, + * e.g. `https://a.storyblok.com/f/123/2048x1820/hash/image.jpg` -> `2048x1820`. + * Storyblok only keeps this segment in the path when it was supplied at + * upload time (see `getAssetSizeForUpload`); it is not derived from the file. + */ +export const extractAssetSizeFromFilename = (filename?: string): string | undefined => { + if (!filename) { + return undefined; + } + try { + const segments = new URL(filename).pathname.split('/'); + return segments.find(segment => /^\d+x\d+$/.test(segment)); + } + catch { + return undefined; + } +}; + /** * Extracts the sanitized name and extension from an asset. * Uses short_filename if available, otherwise falls back to the filename basename. diff --git a/packages/mapi-client/src/resources/assets.test.ts b/packages/mapi-client/src/resources/assets.test.ts index af3afb49b..cc5da3afe 100644 --- a/packages/mapi-client/src/resources/assets.test.ts +++ b/packages/mapi-client/src/resources/assets.test.ts @@ -19,6 +19,31 @@ beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); +const preconditions = { + canCreateAssetWithSize({ space = '123' }: { space?: string } = {}) { + let signedBody: { filename?: string; size?: string } | undefined; + server.use( + http.post(`https://mapi.storyblok.com/v1/spaces/${space}/assets`, async ({ request }) => { + signedBody = await request.json() as { filename?: string; size?: string }; + return HttpResponse.json({ + id: 1, + post_url: 'https://s3.amazonaws.com/a.storyblok.com', + fields: { key: `f/${space}/${signedBody.size}/hash/${signedBody.filename}` }, + }); + }), + http.post('https://s3.amazonaws.com/a.storyblok.com', () => HttpResponse.json({})), + http.get(`https://mapi.storyblok.com/v1/spaces/${space}/assets/:asset_id/finish_upload`, () => HttpResponse.json({})), + http.get(`https://mapi.storyblok.com/v1/spaces/${space}/assets/:asset_id`, () => HttpResponse.json({ + id: 1, + filename: `https://a.storyblok.com/f/${space}/2048x1820/hash/hero.png`, + })), + ); + return { + getSignedBody: () => signedBody, + }; + }, +}; + describe('assets.list()', () => { it('should successfully retrieve multiple assets', async () => { const client = createManagementApiClient({ @@ -75,6 +100,25 @@ describe('assets.list()', () => { }); }); +describe('assets.create()', () => { + it('forwards the `size` field to the sign request so the CDN URL keeps its dimensions', async () => { + const { getSignedBody } = preconditions.canCreateAssetWithSize(); + const client = createManagementApiClient({ + personalAccessToken: 'test-token', + spaceId: 123, + region: 'eu', + rateLimit: false, + }); + + await client.assets.create({ + body: { short_filename: 'hero.png', size: '2048x1820' }, + file: new ArrayBuffer(0), + }); + + expect(getSignedBody()?.size).toBe('2048x1820'); + }); +}); + describe('assets.convertToShared()', () => { it('should post to the convert endpoint with target_asset_folder_id and return the converted asset', async () => { let capturedUrl: string | undefined; diff --git a/packages/mapi-client/src/resources/assets.ts b/packages/mapi-client/src/resources/assets.ts index 64a8d3686..83f54e6bc 100644 --- a/packages/mapi-client/src/resources/assets.ts +++ b/packages/mapi-client/src/resources/assets.ts @@ -151,6 +151,8 @@ export function createAssetsResource(deps: MapiResourceDeps) { short_filename: body.short_filename, asset_folder_id: body.asset_folder_id, is_private: body.is_private, + size: body.size, + validate_upload: body.validate_upload, }, file, signal, From 78559032408ee38099c7509dc2e410d7957a0b6c Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:08:27 +0530 Subject: [PATCH 09/48] feat(react): add Suspense support for async components in registry - Add ComponentEntry type for component config with fallback options - Auto-detect React.lazy components and wrap in Suspense - Add per-component fallback option for custom loading states - Add global suspenseFallback config for default loading UI - Add suspense option to force Suspense wrapping for async components --- packages/react/src/v2/component-registry.tsx | 88 ++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/packages/react/src/v2/component-registry.tsx b/packages/react/src/v2/component-registry.tsx index de5671498..223709193 100644 --- a/packages/react/src/v2/component-registry.tsx +++ b/packages/react/src/v2/component-registry.tsx @@ -1,4 +1,4 @@ -import type { ComponentType, ReactNode } from 'react'; +import { type ComponentType, type ReactNode, Suspense } from 'react'; export interface SbBlokData { _uid: string; @@ -9,9 +9,26 @@ export interface SbBlokData { type StoryblokComponentType = ComponentType<{ blok: any }>; +/** + * Component entry that supports async components with Suspense. + * Can be either a plain component or a config object with fallback. + */ +export type ComponentEntry = + | StoryblokComponentType + | { + component: StoryblokComponentType; + /** Custom fallback for this component's Suspense boundary */ + fallback?: ReactNode; + /** Whether to wrap in Suspense (auto-detected for lazy components, can be forced) */ + suspense?: boolean; + }; + export interface RegistryConfig { - components: Record; + components: Record; + /** Fallback component when a blok type is not found */ fallback?: StoryblokComponentType; + /** Default Suspense fallback for async components */ + suspenseFallback?: ReactNode; } export interface RegistryResult { @@ -20,17 +37,78 @@ export interface RegistryResult { resolve: (name: string) => StoryblokComponentType | null; } +/** Default fallback shown while async components load */ +function DefaultSuspenseFallback(): ReactNode { + return null; +} + +/** + * Check if a component is a lazy component (created with React.lazy). + * Lazy components have $$typeof Symbol(react.lazy). + */ +function isLazyComponent(component: unknown): boolean { + if (typeof component !== 'object' || component === null) { + return false; + } + const typedComponent = component as { $$typeof?: symbol }; + return ( + typeof typedComponent.$$typeof === 'symbol' + && typedComponent.$$typeof.toString() === 'Symbol(react.lazy)' + ); +} + +/** + * Normalize a component entry to extract component and config. + */ +function normalizeEntry(entry: ComponentEntry): { + component: StoryblokComponentType; + fallback?: ReactNode; + suspense?: boolean; +} { + if (typeof entry === 'function' || isLazyComponent(entry)) { + return { component: entry as StoryblokComponentType }; + } + return entry; +} + export function createRegistry(config: RegistryConfig): RegistryResult { + const defaultSuspenseFallback = config.suspenseFallback ?? ; + const resolve = (name: string): StoryblokComponentType | null => { - return config.components[name] ?? config.fallback ?? null; + const entry = config.components[name]; + if (!entry) { + return config.fallback ?? null; + } + return normalizeEntry(entry).component; }; function StoryblokComponent({ blok }: { blok: SbBlokData }): ReactNode { - const Component = resolve(blok.component); - if (!Component) { + const entry = config.components[blok.component]; + + if (!entry) { + if (config.fallback) { + const FallbackComponent = config.fallback; + return ; + } console.warn(`[Storyblok] Unknown component: ${blok.component}`); return null; } + + const { component: Component, fallback, suspense } = normalizeEntry(entry); + + // Determine if we should wrap in Suspense: + // - Explicitly set via suspense option + // - Auto-detected for lazy components + const needsSuspense = suspense ?? isLazyComponent(Component); + + if (needsSuspense) { + return ( + + + + ); + } + return ; } From f479038b620f957c9df0c082a0a48be2e982479f Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Mon, 6 Jul 2026 09:33:45 +0200 Subject: [PATCH 10/48] chore(release): publish - project: @storyblok/api-client 0.4.0 - project: @storyblok/management-api-client 0.4.0 - project: @storyblok/migrations 0.1.17 - project: @storyblok/angular 1.0.2 - project: @storyblok/openapi 2.1.0 - project: storyblok 4.18.3 --- packages/angular/package.json | 2 +- packages/capi-client/package.json | 2 +- packages/cli/package.json | 2 +- packages/mapi-client/package.json | 2 +- packages/migrations/package.json | 2 +- packages/openapi/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/angular/package.json b/packages/angular/package.json index 39e8c181b..719b43370 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@storyblok/angular", - "version": "1.0.1", + "version": "1.0.2", "private": false, "description": "Official Angular integration for the Storyblok Headless CMS", "author": "Storyblok", diff --git a/packages/capi-client/package.json b/packages/capi-client/package.json index dcad5cd0e..742f6fef5 100644 --- a/packages/capi-client/package.json +++ b/packages/capi-client/package.json @@ -1,7 +1,7 @@ { "name": "@storyblok/api-client", "type": "module", - "version": "0.3.1", + "version": "0.4.0", "private": false, "description": "Storyblok Content Delivery API Client", "author": "", diff --git a/packages/cli/package.json b/packages/cli/package.json index a2fa8cfac..ea50d9ebf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "storyblok", "type": "module", - "version": "4.18.2", + "version": "4.18.3", "description": "Storyblok CLI", "author": "Alvaro Saburido (https://github.com/alvarosabu/)", "license": "MIT", diff --git a/packages/mapi-client/package.json b/packages/mapi-client/package.json index 6a5d7f763..6dd5c0e20 100644 --- a/packages/mapi-client/package.json +++ b/packages/mapi-client/package.json @@ -1,7 +1,7 @@ { "name": "@storyblok/management-api-client", "type": "module", - "version": "0.3.0", + "version": "0.4.0", "private": false, "description": "Storyblok Management API Client", "author": "", diff --git a/packages/migrations/package.json b/packages/migrations/package.json index 15453f7f8..a9ada1022 100644 --- a/packages/migrations/package.json +++ b/packages/migrations/package.json @@ -1,7 +1,7 @@ { "name": "@storyblok/migrations", "type": "module", - "version": "0.1.16", + "version": "0.1.17", "private": false, "description": "Migration utilities for Storyblok", "author": "Storyblok", diff --git a/packages/openapi/package.json b/packages/openapi/package.json index d55cc6d26..aab12617e 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@storyblok/openapi", - "version": "2.0.1", + "version": "2.1.0", "description": "Storyblok Management API OpenAPI specifications", "private": true, "scripts": { From 881ecb92337bfd55ed2f562bad1cc1f5916c450c Mon Sep 17 00:00:00 2001 From: Dipankar Maikap <45673791+dipankarmaikap@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:34:33 +0530 Subject: [PATCH 11/48] chore: rich text componnet re exported --- packages/react/src/v2/StoryblokRichText.tsx | 16 ++++++++++++++++ packages/react/src/v2/component-registry.tsx | 10 ++-------- packages/react/src/v2/index.ts | 4 +++- 3 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 packages/react/src/v2/StoryblokRichText.tsx diff --git a/packages/react/src/v2/StoryblokRichText.tsx b/packages/react/src/v2/StoryblokRichText.tsx new file mode 100644 index 000000000..6fb8ac9d3 --- /dev/null +++ b/packages/react/src/v2/StoryblokRichText.tsx @@ -0,0 +1,16 @@ +import { createRichTextRenderer } from '.'; +import type { ReactNode } from 'react'; +import type { StoryblokRichTextProps } from '../core/richtext'; + +export function StoryblokRichText({ + document, + optimizeImage, + components, +}: StoryblokRichTextProps): ReactNode { + const render = createRichTextRenderer({ + optimizeImage, + components, + }); + const content = render(document); + return content; +}; diff --git a/packages/react/src/v2/component-registry.tsx b/packages/react/src/v2/component-registry.tsx index 223709193..cd1942c8e 100644 --- a/packages/react/src/v2/component-registry.tsx +++ b/packages/react/src/v2/component-registry.tsx @@ -1,13 +1,7 @@ import { type ComponentType, type ReactNode, Suspense } from 'react'; +import type { SbBlokData } from '.'; -export interface SbBlokData { - _uid: string; - component: string; - _editable?: string; - [key: string]: unknown; -} - -type StoryblokComponentType = ComponentType<{ blok: any }>; +type StoryblokComponentType = ComponentType<{ blok: SbBlokData }>; /** * Component entry that supports async components with Suspense. diff --git a/packages/react/src/v2/index.ts b/packages/react/src/v2/index.ts index 40b76d7a5..1a1564177 100644 --- a/packages/react/src/v2/index.ts +++ b/packages/react/src/v2/index.ts @@ -5,11 +5,13 @@ export interface SbBlokData { [key: string]: unknown; } +export { createRichTextRenderer } from '../core/richtext'; export { createRegistry } from './component-registry'; + +export { StoryblokRichText } from './StoryblokRichText'; export { type ContentApiClientConfig, createApiClient, type Story, } from '@storyblok/api-client'; - export { storyblokEditable } from '@storyblok/live-preview'; From 1076a3a245f7de4e22fc0246de5d4ecfe95e5915 Mon Sep 17 00:00:00 2001 From: Dipankar Maikap Date: Tue, 7 Jul 2026 14:09:33 +0530 Subject: [PATCH 12/48] fix(astro): circular dependency through StoryblokComponent (#661) This PR fixes the TDZ (`ReferenceError`) caused by circular dependencies when Storyblok components are imported directly and also render `` internally. It also adds both unit and Cypress tests to cover this scenario and help prevent future regressions. Fixes #547. --- packages/astro/cypress/e2e/index.cy.js | 21 ++ .../playground/test/src/pages/tdz-test.astro | 31 ++ ...vite-plugin-import-storyblok-components.ts | 158 ++++----- ...plugin-import-storyblok-components.test.ts | 197 ++++++++++++ pnpm-lock.yaml | 300 ++++++++++++++++-- 5 files changed, 582 insertions(+), 125 deletions(-) create mode 100644 packages/astro/playground/test/src/pages/tdz-test.astro create mode 100644 packages/astro/tests/vite-plugin-import-storyblok-components.test.ts diff --git a/packages/astro/cypress/e2e/index.cy.js b/packages/astro/cypress/e2e/index.cy.js index fb523ca56..5ce13df87 100644 --- a/packages/astro/cypress/e2e/index.cy.js +++ b/packages/astro/cypress/e2e/index.cy.js @@ -3,6 +3,7 @@ Tests: - Bridge should be loaded - storyblokEditable attributes are assigned - globally loaded component is rendered correctly +- TDZ fix works for manually registered components */ describe("@storyblok/astro", () => { @@ -31,6 +32,26 @@ describe("@storyblok/astro", () => { cy.visit("http://localhost:4321/"); cy.get("[data-test=custom-fallback-component]").should("exist"); }); + + /** + * TDZ (Temporal Dead Zone) Test + * + * Verifies that manually-registered components don't cause + * "Cannot access 'X' before initialization" errors when: + * 1. A component is registered in astro.config.mjs + * 2. The same component is directly imported in a page + * + * If the page loads and renders correctly, the TDZ fix is working. + */ + it("TDZ fix: directly imported components work alongside registration", () => { + cy.visit("http://localhost:4321/tdz-test"); + // Page should load without TDZ errors + cy.contains("TDZ Test Page").should("exist"); + cy.contains("If you can see this, the TDZ fix is working correctly.").should("exist"); + // Directly imported Teaser component should render + cy.contains("Direct Import Test").should("exist"); + }); + /* it("RichText Renderer renders embedded bloks correctly", () => { cy.visit("http://localhost:4321/"); cy.get("[data-test=embedded-blok]").should("exist"); diff --git a/packages/astro/playground/test/src/pages/tdz-test.astro b/packages/astro/playground/test/src/pages/tdz-test.astro new file mode 100644 index 000000000..e832f9d94 --- /dev/null +++ b/packages/astro/playground/test/src/pages/tdz-test.astro @@ -0,0 +1,31 @@ +--- +/** + * TDZ (Temporal Dead Zone) Test Page + * + * This page verifies that manually-registered components don't cause + * "Cannot access 'X' before initialization" errors during SSR builds. + * + * The TDZ issue occurs when: + * 1. A component is registered in astro.config.mjs: components: { teaser: '...' } + * 2. The same component is directly imported in a page (like below) + * 3. During bundling, the registration code may execute before the component is defined + * + * If this page builds successfully, the TDZ fix is working. + */ +import Teaser from '@shared/storyblok/Teaser.astro'; +import Grid from '@shared/storyblok/Grid.astro'; +--- + + + + TDZ Test + + +

TDZ Test Page

+

If you can see this, the TDZ fix is working correctly.

+ + + + + + diff --git a/packages/astro/src/vite-plugins/vite-plugin-import-storyblok-components.ts b/packages/astro/src/vite-plugins/vite-plugin-import-storyblok-components.ts index 270b31b7f..eed28f38c 100644 --- a/packages/astro/src/vite-plugins/vite-plugin-import-storyblok-components.ts +++ b/packages/astro/src/vite-plugins/vite-plugin-import-storyblok-components.ts @@ -3,14 +3,12 @@ import { normalizePath } from '../utils/normalizePath'; import { toCamelCase } from '../utils/toCamelCase'; import type { Plugin } from 'vite'; -// Virtual module identifiers for Vite's module system const VIRTUAL_MODULE_ID = 'virtual:import-storyblok-components'; const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`; + /** - * Vite plugin that automatically imports Storyblok components from a specified directory - * and merges them with optional user-provided component mappings. - * - * @returns Vite plugin object + * Vite plugin that auto-imports Storyblok components from a directory + * and merges them with user-provided component mappings. */ export function vitePluginImportStoryblokComponents( components: Record, @@ -20,81 +18,75 @@ export function vitePluginImportStoryblokComponents( ): Plugin { return { name: 'vite-plugin-import-storyblok-components', - /** - * Resolves virtual module imports - */ + async resolveId(id: string) { if (id === VIRTUAL_MODULE_ID) { return RESOLVED_VIRTUAL_MODULE_ID; } }, - /** - * Generates the virtual module content with dynamic imports - */ async load(id: string) { if (id !== RESOLVED_VIRTUAL_MODULE_ID) { return; } - // Resolve fallback component import - const fallbackImport = await resolveFallbackComponent( + const fallbackRegistration = await resolveFallbackComponent( this, componentsDir, enableFallbackComponent, customFallbackComponent, ); - const manualImports = await resolveUserComponents( + const manualRegistrations = await resolveUserComponents( this, components, componentsDir, enableFallbackComponent, ); - // Generate the virtual module code - const moduleCode = generateModuleCode( - componentsDir, - fallbackImport, - manualImports, - ); - return { - code: moduleCode, + code: generateModuleCode(componentsDir, fallbackRegistration, manualRegistrations), moduleType: 'js', }; }, }; } + +export interface ComponentRegistrationParts { + importStatement: string; + wrapperDefinition: string; + registrationCall: string; +} + /** - * Generates the complete virtual module code including: - * - Auto-imported components via glob - * - User-provided component imports - * - Optional fallback component - * - * @param componentsDir - Base directory of components - * @param fallbackImport - Import statement for fallback (if any) - * @param manualImports - Explicit imports generated from user-provided components - * @returns Virtual module source code + * Generates the virtual module code with: + * - Static imports at the top for proper hoisting + * - Glob-imported components from storyblok folder + * - Manual and fallback component registrations */ -function generateModuleCode( +export function generateModuleCode( componentsDir: string, - fallbackImport: string, - manualImports: string[], + fallbackRegistration: ComponentRegistrationParts | null, + manualRegistrations: ComponentRegistrationParts[], ): string { - // Normalize components directory path for Vite globbing const normalizedComponentsDir = normalizePath(componentsDir); - - // Only look into the storyblok folder const globPattern = `${normalizedComponentsDir}/storyblok/**/*.astro`; + const allRegistrations = [...manualRegistrations]; + if (fallbackRegistration) { + allRegistrations.push(fallbackRegistration); + } + + const importStatements = allRegistrations.map(r => r.importStatement); + const wrapperDefinitions = allRegistrations.map(r => r.wrapperDefinition); + const registrationCalls = allRegistrations.map(r => r.registrationCall); + return ` - // Import utilities and fallback component import { toCamelCase } from '@storyblok/astro'; + ${importStatements.join('\n ')} - // Dynamically import all Storyblok components using Vite's glob import const modules = import.meta.glob('${globPattern}', { eager: true }); - // Process imported modules into a components object + const storyblokComponents = {}; const createComponentLoader = (module) => { return async () => module?.default ?? module; @@ -106,20 +98,19 @@ function generateModuleCode( get: () => createComponentLoader(component), }); }; + for (const filePath in modules) { - // Extract component name from file path (remove extension) const fileName = filePath.split('/').pop(); - const componentName = toCamelCase(fileName?.replace(/\.[^/.]+$/, '') ?? ''); + const componentName = toCamelCase(fileName?.replace(/\\.[^/.]+$/, '') ?? ''); if (componentName) { registerComponent(componentName, modules[filePath]); } } - - // Manual components - ${manualImports.join('\n\n')} - // Add fallback component if enabled - ${fallbackImport} - // Export the components object for use in Storyblok initialization + + ${wrapperDefinitions.join('\n ')} + + ${registrationCalls.join('\n ')} + export { storyblokComponents }; `.trim(); } @@ -129,21 +120,18 @@ async function resolveFallbackComponent( componentsDir: string, enableFallbackComponent: boolean, customFallbackComponent?: string, -): Promise { +): Promise { if (!enableFallbackComponent) { - return ''; + return null; } if (!customFallbackComponent) { - return createComponentRegistrationCode({ + return createComponentRegistrationParts({ componentName: 'FallbackComponent', importPath: '@storyblok/astro/FallbackComponent.astro', }); } - const componentPath = getComponentFullPath( - componentsDir, - customFallbackComponent, - ); + const componentPath = getComponentFullPath(componentsDir, customFallbackComponent); const resolved = await ctx.resolve(componentPath); if (!resolved) { throw new Error( @@ -151,27 +139,19 @@ async function resolveFallbackComponent( ); } - return createComponentRegistrationCode({ + return createComponentRegistrationParts({ componentName: 'FallbackComponent', importPath: resolved.id, }); } -/** - * Resolves user-provided Storyblok components into import statements. - * - * @param ctx - Vite plugin context (`this` in load hook) - * @param components - User-specified mapping of blok names to component paths - * @param componentsDir - Base directory for components - * @param enableFallback - Whether to silently skip unresolved components - * @returns Object containing import statements - */ + async function resolveUserComponents( ctx: any, components: Record, componentsDir: string, enableFallback: boolean, -): Promise { - const resolvedComponents: string[] = []; +): Promise { + const resolvedComponents: ComponentRegistrationParts[] = []; for (const [blokName, componentPath] of Object.entries(components)) { const fullPath = getComponentFullPath(componentsDir, componentPath); @@ -186,7 +166,7 @@ async function resolveUserComponents( continue; }; const componentName = toCamelCase(blokName); - resolvedComponents.push(createComponentRegistrationCode({ + resolvedComponents.push(createComponentRegistrationParts({ componentName, importPath: resolved.id, })); @@ -194,22 +174,6 @@ async function resolveUserComponents( return resolvedComponents; } -/** - * Builds the full normalized path to an Astro component file. - * - * - Ensures both the components directory and the component path - * are normalized (leading slash, no trailing slash, no duplicates). - * - Concatenates them into a single path. - * - Ensures the `.astro` extension is present. - * - * @param componentsDir - Base directory where Astro components live - * @param componentPath - Relative path (or subpath) to the component - * @returns Full normalized path ending with `.astro` - * - * @example - * getComponentFullPath("components", "ui/Button"); - * // "/components/ui/Button.astro" - */ function getComponentFullPath( componentsDir: string, componentPath: string, @@ -219,17 +183,29 @@ function getComponentFullPath( return normalizeAstroExtension(fullComponentPath); } -interface CreateComponentRegistrationCodeOptions { +interface CreateComponentRegistrationPartsOptions { componentName: string; importPath: string; } -function createComponentRegistrationCode({ +/** + * Generates structured registration parts for a component. + * + * Uses a getter wrapper to avoid TDZ (Temporal Dead Zone) errors. + * When Vite bundles modules, direct references to imported components + * can cause "Cannot access 'X' before initialization" errors. + * The getter defers access until the component is actually needed. + */ +export function createComponentRegistrationParts({ componentName, importPath, -}: CreateComponentRegistrationCodeOptions): string { - return ` - import ${componentName} from '${importPath}'; - registerComponent('${componentName}', ${componentName}); -`.trim(); +}: CreateComponentRegistrationPartsOptions): ComponentRegistrationParts { + const varName = `__${componentName}_component__`; + const wrapperName = `__${componentName}_wrapper__`; + + return { + importStatement: `import ${varName} from '${importPath}';`, + wrapperDefinition: `const ${wrapperName} = { get default() { return ${varName}; } };`, + registrationCall: `registerComponent('${componentName}', ${wrapperName});`, + }; } diff --git a/packages/astro/tests/vite-plugin-import-storyblok-components.test.ts b/packages/astro/tests/vite-plugin-import-storyblok-components.test.ts new file mode 100644 index 000000000..a2dcbf388 --- /dev/null +++ b/packages/astro/tests/vite-plugin-import-storyblok-components.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { createComponentRegistrationParts, generateModuleCode } from '../src/vite-plugins/vite-plugin-import-storyblok-components'; + +describe('vite-plugin-import-storyblok-components', () => { + describe('createComponentRegistrationParts', () => { + it('generates structured parts with wrapper for TDZ avoidance', () => { + const parts = createComponentRegistrationParts({ + componentName: 'Card', + importPath: '/src/components/Card.astro', + }); + + expect(parts.importStatement).toBe(`import __Card_component__ from '/src/components/Card.astro';`); + expect(parts.wrapperDefinition).toBe(`const __Card_wrapper__ = { get default() { return __Card_component__; } };`); + expect(parts.registrationCall).toBe(`registerComponent('Card', __Card_wrapper__);`); + }); + + it('uses unique variable names based on component name', () => { + const cardParts = createComponentRegistrationParts({ + componentName: 'Card', + importPath: '/src/Card.astro', + }); + const heroParts = createComponentRegistrationParts({ + componentName: 'Hero', + importPath: '/src/Hero.astro', + }); + + // Variable names should be unique per component + expect(cardParts.importStatement).toContain('__Card_component__'); + expect(heroParts.importStatement).toContain('__Hero_component__'); + expect(cardParts.wrapperDefinition).toContain('__Card_wrapper__'); + expect(heroParts.wrapperDefinition).toContain('__Hero_wrapper__'); + }); + }); + + describe('generateModuleCode', () => { + it('generates valid module code without manual components', () => { + const code = generateModuleCode('/src/components', null, []); + + expect(code).toContain('import { toCamelCase }'); + expect(code).toContain('import.meta.glob'); + expect(code).toContain('export { storyblokComponents }'); + }); + + it('generates valid module code with manual components', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + createComponentRegistrationParts({ + componentName: 'hero', + importPath: '/src/components/Hero.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + expect(code).toContain(`import __card_component__ from '/src/components/Card.astro'`); + expect(code).toContain(`const __card_wrapper__ = { get default() { return __card_component__; } }`); + expect(code).toContain(`registerComponent('card', __card_wrapper__)`); + expect(code).toContain(`import __hero_component__ from '/src/components/Hero.astro'`); + expect(code).toContain(`const __hero_wrapper__ = { get default() { return __hero_component__; } }`); + expect(code).toContain(`registerComponent('hero', __hero_wrapper__)`); + }); + + it('generates valid module code with fallback component', () => { + const fallbackRegistration = createComponentRegistrationParts({ + componentName: 'FallbackComponent', + importPath: '@storyblok/astro/FallbackComponent.astro', + }); + + const code = generateModuleCode('/src/components', fallbackRegistration, []); + + expect(code).toContain(`import __FallbackComponent_component__ from '@storyblok/astro/FallbackComponent.astro'`); + expect(code).toContain(`const __FallbackComponent_wrapper__ = { get default() { return __FallbackComponent_component__; } }`); + expect(code).toContain(`registerComponent('FallbackComponent', __FallbackComponent_wrapper__)`); + }); + + /** + * This test verifies that static imports are placed at the top of the module + * to ensure proper hoisting behavior and avoid TDZ issues. + */ + it('places static imports at the top of the module', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + // All imports should be at the top, before any other code + const toCamelCaseImportIndex = code.indexOf('import { toCamelCase }'); + const cardImportIndex = code.indexOf('import __card_component__'); + const modulesDefIndex = code.indexOf('const modules = import.meta.glob'); + + expect(toCamelCaseImportIndex).toBeGreaterThan(-1); + expect(cardImportIndex).toBeGreaterThan(-1); + expect(modulesDefIndex).toBeGreaterThan(-1); + + // Both imports should come before the modules definition + expect(toCamelCaseImportIndex).toBeLessThan(modulesDefIndex); + expect(cardImportIndex).toBeLessThan(modulesDefIndex); + }); + + it('places wrapper definitions before registration calls', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + // Wrapper definitions should come before registration calls + const wrapperDefIndex = code.indexOf('const __card_wrapper__'); + const registerCallIndex = code.indexOf(`registerComponent('card', __card_wrapper__)`); + + expect(wrapperDefIndex).toBeGreaterThan(-1); + expect(registerCallIndex).toBeGreaterThan(-1); + expect(wrapperDefIndex).toBeLessThan(registerCallIndex); + }); + + it('places registration calls after registerComponent is defined', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + // registerComponent definition should come before registration calls + const registerComponentDefIndex = code.indexOf('const registerComponent = '); + const registerCallIndex = code.indexOf(`registerComponent('card'`); + + expect(registerComponentDefIndex).toBeGreaterThan(-1); + expect(registerCallIndex).toBeGreaterThan(-1); + expect(registerComponentDefIndex).toBeLessThan(registerCallIndex); + }); + + it('uses glob pattern for auto-discovered components', () => { + const code = generateModuleCode('/src/components', null, []); + + // Should use import.meta.glob for components in storyblok folder + expect(code).toContain(`import.meta.glob('/src/components/storyblok/**/*.astro'`); + expect(code).toContain('eager: true'); + }); + + it('registers glob components before manual components', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + // Glob components registration loop should come before manual registration + const globLoopIndex = code.indexOf('for (const filePath in modules)'); + const manualRegisterIndex = code.indexOf(`registerComponent('card'`); + + expect(globLoopIndex).toBeGreaterThan(-1); + expect(manualRegisterIndex).toBeGreaterThan(-1); + expect(globLoopIndex).toBeLessThan(manualRegisterIndex); + }); + + /** + * This test verifies that the TDZ fix is properly applied: + * components are registered with wrapper objects that use getters, + * not with the raw imported component reference. + */ + it('registers components using wrapper with getter (TDZ fix)', () => { + const manualRegistrations = [ + createComponentRegistrationParts({ + componentName: 'card', + importPath: '/src/components/Card.astro', + }), + ]; + + const code = generateModuleCode('/src/components', null, manualRegistrations); + + // Should NOT register with the raw component + expect(code).not.toContain(`registerComponent('card', __card_component__)`); + + // Should register with the wrapper that has a getter + expect(code).toContain(`registerComponent('card', __card_wrapper__)`); + + // The wrapper should use a getter to defer access + expect(code).toContain(`const __card_wrapper__ = { get default() { return __card_component__; } }`); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0b03fbe..1e4389f24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,7 +143,7 @@ importers: version: 24.11.0 astro: specifier: ^7.0.0 - version: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) cypress: specifier: ^14.3.3 version: 14.5.4 @@ -191,16 +191,16 @@ importers: version: 6.0.0(@types/node@24.11.0)(@types/react-dom@19.2.3(@types/react@19.1.4))(@types/react@19.1.4)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/svelte': specifier: ^9.0.0 - version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) + version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) '@astrojs/vue': specifier: ^7.0.0 - version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) + version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) '@storyblok/astro': specifier: workspace:* version: link:../.. astro: specifier: ^7.0.0 - version: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) react: specifier: ^19.2.4 version: 19.2.4 @@ -225,19 +225,19 @@ importers: version: 6.0.0(@types/node@24.11.0)(@types/react-dom@19.2.3(@types/react@19.1.4))(@types/react@19.1.4)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/svelte': specifier: ^9.0.0 - version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) + version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) '@astrojs/vercel': specifier: ^11.0.0 - version: 11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) + version: 11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) '@astrojs/vue': specifier: ^7.0.0 - version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) + version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) '@storyblok/astro': specifier: workspace:* version: link:../.. astro: specifier: ^7.0.0 - version: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) react: specifier: ^19.2.4 version: 19.2.4 @@ -262,16 +262,16 @@ importers: version: 6.0.0(@types/node@24.11.0)(@types/react-dom@19.2.3(@types/react@19.1.4))(@types/react@19.1.4)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/svelte': specifier: ^9.0.0 - version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) + version: 9.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2) '@astrojs/vue': specifier: ^7.0.0 - version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) + version: 7.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2) '@storyblok/astro': specifier: workspace:* version: link:../.. astro: specifier: ^7.0.0 - version: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) react: specifier: ^19.2.4 version: 19.2.4 @@ -1962,6 +1962,9 @@ packages: '@astrojs/internal-helpers@0.10.0': resolution: {integrity: sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==} + '@astrojs/markdown-remark@7.2.0': + resolution: {integrity: sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==} + '@astrojs/markdown-satteri@0.3.2': resolution: {integrity: sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==} @@ -8242,6 +8245,9 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -10889,12 +10895,24 @@ packages: hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -12159,6 +12177,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -13114,6 +13135,9 @@ packages: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -13964,6 +13988,9 @@ packages: rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -13975,6 +14002,22 @@ packages: engines: {node: ^20.9.0 || >=22.0.0} hasBin: true + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + request-progress@3.0.0: resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} @@ -14031,9 +14074,18 @@ packages: resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==} engines: {node: '>=4'} + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + retext-smartypants@6.2.0: resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -14773,11 +14825,6 @@ packages: resolution: {integrity: sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==} engines: {node: '>=18'} - svgo@4.0.0: - resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==} - engines: {node: '>=16'} - hasBin: true - svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -15276,18 +15323,30 @@ packages: resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} engines: {node: '>=18.12.0'} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-stringify-position@2.0.3: resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} @@ -16795,6 +16854,29 @@ snapshots: smol-toml: 1.6.0 unified: 11.0.5 + '@astrojs/markdown-remark@7.2.0': + dependencies: + '@astrojs/internal-helpers': 0.10.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + '@astrojs/markdown-satteri@0.3.2': dependencies: '@astrojs/internal-helpers': 0.10.0 @@ -16832,10 +16914,10 @@ snapshots: - tsx - yaml - '@astrojs/svelte@9.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2)': + '@astrojs/svelte@9.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(svelte@5.55.0)(terser@5.46.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.2)': dependencies: '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) - astro: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + astro: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) svelte: 5.55.0 svelte2tsx: 0.7.57(svelte@5.55.0)(typescript@6.0.3) typescript: 6.0.3 @@ -16863,14 +16945,14 @@ snapshots: is-wsl: 3.1.1 which-pm-runs: 1.1.0 - '@astrojs/vercel@11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': + '@astrojs/vercel@11.0.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(rollup@4.60.2)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3))': dependencies: '@astrojs/internal-helpers': 0.10.0 '@vercel/analytics': 1.6.1(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.0)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.55.0)(typescript@6.0.3)(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(next@16.1.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.4(vue@3.5.30(typescript@6.0.3)))(vue@3.5.30(typescript@6.0.3)) '@vercel/functions': 3.4.3 '@vercel/nft': 1.3.2(rollup@4.60.2) '@vercel/routing-utils': 5.3.3 - astro: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + astro: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) esbuild: 0.28.1 tinyglobby: 0.2.17 transitivePeerDependencies: @@ -16886,12 +16968,12 @@ snapshots: - vue - vue-router - '@astrojs/vue@7.0.0(@types/node@24.11.0)(astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2)': + '@astrojs/vue@7.0.0(@types/node@24.11.0)(astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(vue@3.5.30(typescript@6.0.3))(yaml@2.8.2)': dependencies: '@vitejs/plugin-vue': 6.0.7(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@6.0.3)) '@vitejs/plugin-vue-jsx': 5.1.6(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@6.0.3)) '@vue/compiler-sfc': 3.5.30 - astro: 7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + astro: 7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite: 8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite-plugin-vue-devtools: 8.1.1(vite@8.1.0(@types/node@24.11.0)(esbuild@0.28.1)(jiti@2.6.1)(less@4.6.4)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@6.0.3)) vue: 3.5.30(typescript@6.0.3) @@ -23959,6 +24041,9 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-iterate@2.0.1: + optional: true + array-union@2.1.0: {} array.prototype.findlast@1.2.5: @@ -24071,7 +24156,7 @@ snapshots: transitivePeerDependencies: - supports-color - astro@7.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + astro@7.0.3(@astrojs/markdown-remark@7.2.0)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.11.0)(@vercel/functions@3.4.3)(db0@0.3.4)(ioredis@5.10.0)(jiti@2.6.1)(less@4.6.4)(rollup@4.60.2)(sass@1.99.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@astrojs/compiler-rs': 0.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@astrojs/internal-helpers': 0.10.0 @@ -24130,6 +24215,7 @@ snapshots: yargs-parser: 22.0.0 zod: 4.3.6 optionalDependencies: + '@astrojs/markdown-remark': 7.2.0 sharp: 0.34.5 transitivePeerDependencies: - '@azure/app-configuration' @@ -27584,10 +27670,32 @@ snapshots: vfile-location: 5.0.3 web-namespaces: 2.0.1 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + optional: true + hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + optional: true + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -27602,6 +27710,25 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + optional: true + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + optional: true + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -28908,6 +29035,13 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + optional: true + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -30799,6 +30933,16 @@ snapshots: index-to-position: 1.2.0 type-fest: 4.41.0 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + optional: true + parse-ms@4.0.0: {} parse-node-version@1.0.1: {} @@ -31126,7 +31270,7 @@ snapshots: dependencies: postcss: 8.5.6 postcss-value-parser: 4.2.0 - svgo: 4.0.0 + svgo: 4.0.1 postcss-unique-selectors@7.0.4(postcss@8.5.6): dependencies: @@ -31712,6 +31856,13 @@ snapshots: hast-util-from-html: 2.0.3 unified: 11.0.5 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + optional: true + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 @@ -31757,6 +31908,52 @@ snapshots: - supports-color - typescript + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + optional: true + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + optional: true + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + optional: true + request-progress@3.0.0: dependencies: throttleit: 1.0.1 @@ -31807,12 +32004,34 @@ snapshots: ret@0.2.2: {} + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + optional: true + retext-smartypants@6.2.0: dependencies: '@types/nlcst': 2.0.3 nlcst-to-string: 4.0.0 unist-util-visit: 5.1.0 + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + optional: true + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + optional: true + retry@0.13.1: {} rettime@0.10.1: {} @@ -32855,16 +33074,6 @@ snapshots: magic-string: 0.30.21 zimmerframe: 1.1.4 - svgo@4.0.0: - dependencies: - commander: 11.1.0 - css-select: 5.2.2 - css-tree: 3.1.0 - css-what: 6.2.2 - csso: 5.0.5 - picocolors: 1.1.1 - sax: 1.5.0 - svgo@4.0.1: dependencies: commander: 11.1.0 @@ -33493,14 +33702,32 @@ snapshots: unplugin: 2.3.11 unplugin-utils: 0.3.1 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + optional: true + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + optional: true + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + optional: true + unist-util-stringify-position@2.0.3: dependencies: '@types/unist': 2.0.11 @@ -33509,6 +33736,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + optional: true + unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 From a3d9612e6f6a80719daec3aaf7b0eb48895b0e86 Mon Sep 17 00:00:00 2001 From: Markus Oberlehner Date: Wed, 8 Jul 2026 08:42:40 +0200 Subject: [PATCH 13/48] feat: support global assets library in `assets push` and `assets pull` (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teaches the `storyblok` CLI to push and pull assets to either a space or the organization's shared libraries, so both kinds round-trip. A library is a top-level shared asset folder with per-space read or write access. ## How **mapi-client (`@storyblok/management-api-client`)** - Adds three hand-written shared resources, mirroring the WDX-409 `convertToShared` pattern (these endpoints are not in the generated SDK, since the WDX-407 overlay will not land): `sharedAssetFolders`, `sharedAssets` (full sign → S3 → finish → get upload flow), and `sharedInternalTags` (every call carries `asset_folder_id` = library root). - Exports the existing `uploadToS3` helper for reuse. - Endpoint request/response shapes were verified against `../storyrails` before wiring. **CLI (`storyblok`)** - `scope.ts`: a `Scope` discriminated union (`space` | `library`), on-disk base-dir resolution, library listing and access helpers, read-only failfast, and a library-root resolver. The existing transport-injected pipelines are reused unchanged. - `assets push`: `--target ` and `--library` flags, a scope loop, read-only-library failfast (no partial state), and library tag remap (`internal_tag_ids` are mapped to the library's shared tags, creating any that are missing). `meta_data` round-trips. Shared-asset creates default the folder to the library root, and the library root folder is skipped on bulk push (it is a shared root folder that cannot be pushed from a space). - `assets pull`: `--target ` (default `with-referenced`), which pulls space assets and then resolves shared-library assets referenced by already-pulled local stories. Classification is id-based, never URL-prefix-based. - `--target` is constrained with Commander `.choices()`, so unknown values fail fast with a non-zero exit. - Shared `shared_*` API error actions; command READMEs updated. ## On-disk layout Library assets live under `.storyblok/assets/shared//`, parallel to the space subtree at `.storyblok/assets//`, each with its own `manifest.jsonl`. Fixes WDX-408 --- .agents/skills/qa-engineer-manual/SKILL.md | 19 +- .../qa-engineer-manual/scripts/_common.sh | 23 + .../scripts/cleanup-local.sh | 7 + .../scripts/cleanup-remote.sh | 158 ++++++ .../scripts/generate-asset.sh | 7 + .../skills/qa-engineer-manual/scripts/list.sh | 63 ++- .../scripts/seed-scenario.sh | 4 +- packages/cli/src/commands/assets/README.md | 4 +- packages/cli/src/commands/assets/actions.ts | 232 ++++++++- packages/cli/src/commands/assets/pipelines.ts | 10 +- .../cli/src/commands/assets/pull/README.md | 1 + .../src/commands/assets/pull/index.test.ts | 126 +++++ .../cli/src/commands/assets/pull/index.ts | 303 +++++++++--- .../cli/src/commands/assets/push/README.md | 2 + .../src/commands/assets/push/index.test.ts | 355 ++++++++++++++ .../cli/src/commands/assets/push/index.ts | 459 ++++++++++++------ .../src/commands/assets/referenced.test.ts | 25 + .../cli/src/commands/assets/referenced.ts | 87 ++++ .../cli/src/commands/assets/scope.test.ts | 19 + packages/cli/src/commands/assets/scope.ts | 107 ++++ packages/cli/src/commands/assets/streams.ts | 205 +++++++- .../src/commands/assets/transfer/README.md | 6 +- .../commands/assets/transfer/index.test.ts | 2 +- .../cli/src/commands/assets/transfer/index.ts | 4 +- packages/cli/src/commands/assets/types.ts | 4 +- .../lib/logger/logger-transport-file.test.ts | 42 +- .../src/lib/logger/logger-transport-file.ts | 17 +- packages/cli/src/types/index.ts | 5 + packages/cli/src/utils/error/api-error.ts | 9 + packages/cli/test/GUIDE.md | 39 ++ packages/mapi-client/src/index.ts | 9 + packages/mapi-client/src/resources/assets.ts | 4 +- .../resources/shared-asset-folders.test.ts | 41 ++ .../src/resources/shared-asset-folders.ts | 82 ++++ .../src/resources/shared-assets.test.ts | 42 ++ .../src/resources/shared-assets.ts | 142 ++++++ .../resources/shared-internal-tags.test.ts | 40 ++ .../src/resources/shared-internal-tags.ts | 72 +++ 38 files changed, 2512 insertions(+), 264 deletions(-) create mode 100644 packages/cli/src/commands/assets/referenced.test.ts create mode 100644 packages/cli/src/commands/assets/referenced.ts create mode 100644 packages/cli/src/commands/assets/scope.test.ts create mode 100644 packages/cli/src/commands/assets/scope.ts create mode 100644 packages/mapi-client/src/resources/shared-asset-folders.test.ts create mode 100644 packages/mapi-client/src/resources/shared-asset-folders.ts create mode 100644 packages/mapi-client/src/resources/shared-assets.test.ts create mode 100644 packages/mapi-client/src/resources/shared-assets.ts create mode 100644 packages/mapi-client/src/resources/shared-internal-tags.test.ts create mode 100644 packages/mapi-client/src/resources/shared-internal-tags.ts diff --git a/.agents/skills/qa-engineer-manual/SKILL.md b/.agents/skills/qa-engineer-manual/SKILL.md index 423c9ae5a..d221ceafd 100644 --- a/.agents/skills/qa-engineer-manual/SKILL.md +++ b/.agents/skills/qa-engineer-manual/SKILL.md @@ -34,6 +34,9 @@ You seed Storyblok QA spaces with predefined test scenarios. Packages might defi - Storyblok CLI built: `pnpm nx build storyblok` - `.env.qa-engineer-manual` file in repo root with `STORYBLOK_TOKEN` and `STORYBLOK_SPACE_ID` +> [!NOTE] +> The seed resolves the CLI and the `.storyblok` staging directory from `git rev-parse --show-toplevel`, i.e. the repo of your current working directory. To exercise changes in a worktree, build the CLI in that worktree and run the seed from inside it; otherwise it falls back to the main checkout's `dist`. + ```bash # .env.qa-engineer-manual STORYBLOK_TOKEN=your_personal_access_token @@ -103,6 +106,18 @@ bash .claude/skills/qa-engineer-manual/scripts/cleanup-remote.sh Deletes all stories, components (except the default `page` component), assets, asset folders, and internal tags in the space. Uses `STORYBLOK_SPACE_ID` from env by default (override with `--space `). This runs automatically before every seed, but can also be used standalone. +### Shared asset libraries + +Shared (org-level) asset libraries are global: they belong to the organization and can be shared across spaces, so a full wipe is destructive. Cleanup is scoped by folder membership, not by name. `--shared --library ` deletes every shared asset in the library's folder tree, every internal tag scoped to the library, and every child folder. It never deletes the library root folder or any resource outside the given library. Folder scoping is deliberate: transferred assets keep their original names, so a `qa-` name prefix would miss them. + +Because this removes all content inside the library regardless of name, only run it against a dedicated QA library, never a shared library that holds real org content. + +```bash +bash .claude/skills/qa-engineer-manual/scripts/cleanup-remote.sh --shared --library +``` + +Inspect a library first with `list.sh --resource shared-assets|shared-folders|shared-tags`. Package guides (for example `packages/cli/test/GUIDE.md`) describe the CLI push/pull workflow against libraries. + ### Scenario structure A scenario is a directory with optional subdirectories for each resource type: @@ -143,8 +158,8 @@ Paths are relative to this `SKILL.md`. | Script | Purpose | | --- | --- | | `./scripts/cleanup-local.sh` | Deletes local QA artifacts in `.storyblok/`. | -| `./scripts/cleanup-remote.sh` | Deletes all stories, components (except `page`), assets, asset folders, and internal tags in the space. Accepts `--space `. | -| `./scripts/list.sh` | Lists resources in the QA space. Pass `--resource stories\|assets\|components\|datasources` and optionally `--space `. | +| `./scripts/cleanup-remote.sh` | Deletes all stories, components (except `page`), assets, asset folders, and internal tags in the space. Accepts `--space `. Add `--shared --library ` to instead delete all shared resources in the given org library's folder tree, never the root (see "Shared asset libraries" below). | +| `./scripts/list.sh` | Lists resources in the QA space. Pass `--resource stories\|assets\|components\|datasources` and optionally `--space `. For org libraries: `--resource shared-assets\|shared-tags --library ` or `--resource shared-folders`. | | `./scripts/generate-story.sh` | Writes a story JSON to stdout. All fields optional — use flags to override `--slug`, `--name`, `--component`, `--parent-id`, `--is-folder`, `--id`, `--uuid`. | | `./scripts/generate-asset.sh` | Writes an asset sidecar JSON to stdout. Use `--filename`, `--alt`, `--title`, `--is-private`, `--folder-id`. Pass `--copy-png ` to also copy the template PNG to a target path. | diff --git a/.agents/skills/qa-engineer-manual/scripts/_common.sh b/.agents/skills/qa-engineer-manual/scripts/_common.sh index 3fd50bcd6..c7ae27b5c 100755 --- a/.agents/skills/qa-engineer-manual/scripts/_common.sh +++ b/.agents/skills/qa-engineer-manual/scripts/_common.sh @@ -5,6 +5,17 @@ # Provides: # load_env — loads .env.qa-engineer-manual and asserts STORYBLOK_TOKEN # require_space_id — ensures space_id is set (from arg or env), exits if not +# require_library_id — ensures library_id is set (from --library), exits if not +# +# Constants: +# QA_SHARED_PREFIX — name prefix every QA-created shared resource must carry +# so org-global libraries can be cleaned up safely +# (only prefix-matched resources are ever deleted). + +# Shared asset libraries are org-global (shared across spaces), so a full wipe +# is unsafe. Every QA-created shared asset, shared folder, and shared tag must +# be named with this prefix; shared cleanup only deletes prefix-matched items. +QA_SHARED_PREFIX="${QA_SHARED_PREFIX:-qa-}" # --------------------------------------------------------------------------- # load_env @@ -53,3 +64,15 @@ require_space_id() { exit 1 fi } + +# --------------------------------------------------------------------------- +# require_library_id +# Call after parsing args. Ensures the global `library_id` variable is set +# (a top-level shared asset folder id). Exits with an error if not. +# --------------------------------------------------------------------------- +require_library_id() { + if [ -z "${library_id:-}" ]; then + printf "Missing --library (a top-level shared asset folder id).\n" >&2 + exit 1 + fi +} diff --git a/.agents/skills/qa-engineer-manual/scripts/cleanup-local.sh b/.agents/skills/qa-engineer-manual/scripts/cleanup-local.sh index de63c813e..e78b22e30 100755 --- a/.agents/skills/qa-engineer-manual/scripts/cleanup-local.sh +++ b/.agents/skills/qa-engineer-manual/scripts/cleanup-local.sh @@ -28,6 +28,13 @@ for sid in "${space_ids[@]}"; do done done +# Shared asset libraries live under .storyblok/assets/shared//, +# keyed by org-global library ID rather than space ID, so clean the whole subtree. +if [ -d ".storyblok/assets/shared" ]; then + rm -rf ".storyblok/assets/shared" + removed=$((removed + 1)) +fi + if [ "${removed}" -eq 0 ]; then printf "clean\n" else diff --git a/.agents/skills/qa-engineer-manual/scripts/cleanup-remote.sh b/.agents/skills/qa-engineer-manual/scripts/cleanup-remote.sh index 2c342b830..cf027fcb4 100644 --- a/.agents/skills/qa-engineer-manual/scripts/cleanup-remote.sh +++ b/.agents/skills/qa-engineer-manual/scripts/cleanup-remote.sh @@ -5,6 +5,16 @@ set -euo pipefail # # Usage: # bash .claude/skills/qa-engineer-manual/scripts/cleanup-remote.sh --space +# +# Shared (org-level) asset libraries are global, so a full wipe is unsafe. +# --shared cleans every shared resource that belongs to ONE library, scoped by +# folder membership (not by name): all assets in the library's folder tree, all +# internal tags scoped to the library, and all child folders. Transferred assets +# keep their original names, so folder scoping catches them where a name prefix +# would not. It never deletes the library root folder (org context only) or any +# resource outside the given library. +# +# bash .claude/skills/qa-engineer-manual/scripts/cleanup-remote.sh --shared --library # shellcheck source=_common.sh source "$(dirname "${BASH_SOURCE[0]}")/_common.sh" @@ -14,6 +24,8 @@ load_env # Parse arguments # --------------------------------------------------------------------------- space_id="" +shared_mode=false +library_id="" while [ "$#" -gt 0 ]; do case "$1" in @@ -21,6 +33,14 @@ while [ "$#" -gt 0 ]; do space_id="$2" shift 2 ;; + --shared) + shared_mode=true + shift 1 + ;; + --library) + library_id="$2" + shift 2 + ;; *) printf "warning: unknown argument '%s'\n" "$1" >&2 shift 1 @@ -37,6 +57,144 @@ per_page=100 found_total=0 deleted_total=0 +# Deletes a list of ids (one per line) against an endpoint. Treats 2xx and 404 +# (already gone) as success. Runs in a command-substitution subshell, so it +# keeps no global state: it prints " " for the caller to total. +# Args: