@storybook-astro/framework 0.1.0-beta.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +38 -0
  2. package/dist/base-IRZo3zgK.d.ts +23 -0
  3. package/dist/chunk-4SWPVM6R.js +96 -0
  4. package/dist/chunk-4SWPVM6R.js.map +1 -0
  5. package/dist/chunk-5EF25G5S.js +69 -0
  6. package/dist/chunk-5EF25G5S.js.map +1 -0
  7. package/dist/chunk-7GHEQUPV.js +439 -0
  8. package/dist/chunk-7GHEQUPV.js.map +1 -0
  9. package/dist/chunk-C5OH4VBR.js +492 -0
  10. package/dist/chunk-C5OH4VBR.js.map +1 -0
  11. package/dist/chunk-DNGQBPT7.js +15 -0
  12. package/dist/chunk-DNGQBPT7.js.map +1 -0
  13. package/dist/chunk-E4LB75JN.js +89 -0
  14. package/dist/chunk-E4LB75JN.js.map +1 -0
  15. package/dist/chunk-PJEDXZVN.js +240 -0
  16. package/dist/chunk-PJEDXZVN.js.map +1 -0
  17. package/dist/chunk-UK43WNEA.js +657 -0
  18. package/dist/chunk-UK43WNEA.js.map +1 -0
  19. package/dist/dist-HJOEPVRQ.js +15574 -0
  20. package/dist/dist-HJOEPVRQ.js.map +1 -0
  21. package/dist/index.d.ts +42 -0
  22. package/dist/index.js +13 -64
  23. package/dist/index.js.map +1 -1
  24. package/dist/integrations/index.d.ts +138 -0
  25. package/dist/integrations/index.js +8 -196
  26. package/dist/integrations/index.js.map +1 -1
  27. package/dist/middleware.d.ts +26 -0
  28. package/dist/middleware.js +179 -0
  29. package/dist/middleware.js.map +1 -0
  30. package/dist/portable-stories-BvdaQigq.d.ts +83 -0
  31. package/dist/preset.d.ts +14 -0
  32. package/dist/preset.js +5 -1
  33. package/dist/testing.d.ts +27 -0
  34. package/dist/testing.js +324 -15539
  35. package/dist/testing.js.map +1 -1
  36. package/dist/types-CHTsRtA7.d.ts +42 -0
  37. package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js +11 -0
  38. package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js.map +1 -0
  39. package/dist/vitest/index.d.ts +19 -0
  40. package/dist/vitest/index.js +229 -0
  41. package/dist/vitest/index.js.map +1 -0
  42. package/package.json +31 -17
  43. package/src/importAstroConfig.ts +11 -0
  44. package/src/index.ts +20 -6
  45. package/src/integrations/alpine.ts +5 -2
  46. package/src/integrations/base.ts +2 -2
  47. package/src/integrations/moduleResolver.ts +43 -0
  48. package/src/integrations/preact.ts +5 -2
  49. package/src/integrations/react.ts +5 -2
  50. package/src/integrations/solid.ts +5 -2
  51. package/src/integrations/svelte.ts +5 -2
  52. package/src/integrations/vue.ts +5 -2
  53. package/src/lib/sanitization.test.ts +232 -0
  54. package/src/lib/sanitization.ts +338 -0
  55. package/src/lib/ssr-load-module-with-fs-fallback.ts +29 -0
  56. package/src/middleware.test.ts +48 -0
  57. package/src/middleware.ts +204 -96
  58. package/src/module-mocks.ts +16 -0
  59. package/src/msw-helpers.ts +1 -0
  60. package/src/msw.ts +58 -0
  61. package/src/preset.ts +38 -3
  62. package/src/rules-options.test.ts +71 -0
  63. package/src/rules-options.ts +87 -0
  64. package/src/rules.test.ts +183 -0
  65. package/src/rules.ts +314 -0
  66. package/src/testing/astro-runtime.ts +219 -0
  67. package/src/testing/component-utils.ts +32 -0
  68. package/src/testing/index.ts +2 -0
  69. package/src/testing/integration-config.ts +121 -0
  70. package/src/testing/project-root.ts +185 -0
  71. package/src/testing/renderer-daemon.ts +269 -0
  72. package/src/testing/story-composition.ts +33 -0
  73. package/src/testing/types.ts +14 -0
  74. package/src/testing/working-directory.ts +28 -0
  75. package/src/testing.ts +1 -254
  76. package/src/types.ts +16 -4
  77. package/src/virtual.d.ts +2 -1
  78. package/src/vite/createVirtualModulePlugin.test.ts +80 -0
  79. package/src/vite/createVirtualModulePlugin.ts +25 -0
  80. package/src/viteAstroContainerRenderersPlugin.ts +60 -26
  81. package/src/vitePluginAstro.ts +12 -5
  82. package/src/vitePluginAstroBuildPrerender.ts +665 -204
  83. package/src/vitePluginAstroRoutesFallback.ts +37 -0
  84. package/src/vitePluginAstroVueFallback.ts +47 -0
  85. package/src/viteStorybookAstroMiddlewarePlugin.ts +88 -12
  86. package/src/viteStorybookRendererFallbackPlugin.ts +13 -23
  87. package/src/vitest/config.ts +95 -0
  88. package/src/vitest/global-setup.ts +16 -0
  89. package/src/vitest/index.ts +2 -0
  90. package/src/vitest/vite-plugins.ts +187 -0
  91. package/dist/chunk-SAOPE6SA.js +0 -557
  92. package/dist/chunk-SAOPE6SA.js.map +0 -1
package/src/middleware.ts CHANGED
@@ -1,149 +1,257 @@
1
+ import { pathToFileURL } from 'node:url';
1
2
  import { experimental_AstroContainer as AstroContainer } from 'astro/container';
2
3
  import type { Integration } from './integrations/index.ts';
3
- import { addRenderers } from 'virtual:astro-container-renderers';
4
+ import type { SanitizationOptions } from './lib/sanitization.ts';
5
+ import { resolveSanitizationOptions, sanitizeRenderPayload } from './lib/sanitization.ts';
6
+ import { resolveStoryModuleMock, withStoryModuleMocks } from './module-mocks.ts';
7
+ import { applyMswHandlers } from './msw.ts';
8
+ import { selectStoryRules } from './rules.ts';
9
+ import type { RenderStoryInput } from './types.ts';
10
+ import { addRenderers, resolveClientModules } from 'virtual:astro-container-renderers';
11
+
12
+ type ResolveRulesConfigModule = () => unknown | Promise<unknown>;
13
+
14
+ type AstroCreateResult = {
15
+ createAstro?: (...args: unknown[]) => unknown;
16
+ };
17
+
18
+ type AstroComponentFactory = ((
19
+ result: AstroCreateResult,
20
+ props: unknown,
21
+ slots: unknown
22
+ ) => unknown) & {
23
+ isAstroComponentFactory?: boolean;
24
+ moduleId?: string;
25
+ propagation?: unknown;
26
+ };
4
27
 
5
28
  export type HandlerProps = {
6
29
  component: string;
7
30
  args?: Record<string, unknown>;
8
31
  slots?: Record<string, unknown>;
32
+ story?: RenderStoryInput;
33
+ };
34
+
35
+ type HandlerFactoryOptions = {
36
+ mode?: 'development' | 'production';
37
+ sanitization?: SanitizationOptions;
38
+ rulesConfigFilePath?: string;
39
+ resolveRulesConfigModule?: ResolveRulesConfigModule;
40
+ loadModule?: (id: string) => Promise<{ default: unknown }>;
9
41
  };
10
42
 
11
- export async function handlerFactory(integrations: Integration[]) {
12
- const safeIntegrations = integrations ?? [];
43
+ export async function handlerFactory(_integrations: Integration[], options?: HandlerFactoryOptions) {
44
+ const mode = options?.mode ?? 'development';
13
45
  const container = await AstroContainer.create({
14
46
  // Somewhat hacky way to force client-side Storybook's Vite to resolve modules properly
15
- resolve: async (s) => {
16
- if (s.startsWith('astro:scripts')) {
17
- return `/@id/${s}`;
47
+ resolve: async (specifier) => {
48
+ const mockedModule = resolveStoryModuleMock(specifier);
49
+
50
+ if (mockedModule) {
51
+ return mockedModule;
52
+ }
53
+
54
+ if (specifier.startsWith('astro:scripts')) {
55
+ return `/@id/${specifier}`;
18
56
  }
19
57
 
20
- for (const integration of safeIntegrations) {
21
- const resolution = integration.resolveClient(s);
58
+ const resolution = resolveClientModules(specifier);
22
59
 
23
- if (resolution) {
24
- return resolution;
25
- }
60
+ if (resolution) {
61
+ return resolution;
26
62
  }
27
63
 
28
- return s;
64
+ return specifier;
29
65
  }
30
66
  });
31
67
 
32
68
  addRenderers(container);
69
+ const sanitizationOptions = resolveSanitizationOptions(options?.sanitization);
70
+ const loadModule =
71
+ options?.loadModule ??
72
+ ((id: string) => {
73
+ const normalizedId = /^[a-zA-Z]:[/\\]/.test(id) ? pathToFileURL(id).href : id;
33
74
 
34
- return async function handler(data: HandlerProps) {
35
- const { default: Component } = await import(/* @vite-ignore */ data.component);
36
-
37
- // Process args to convert ImageMetadata objects to usable URLs
38
- const processedArgs = await processImageMetadata(data.args || {});
39
-
40
- // Wrap the component factory to fix the createAstro calling convention mismatch.
41
- // Astro compiler v2 produces: result.createAstro($$Astro, $$props, $$slots) [3 args]
42
- // Astro 6 runtime expects: result.createAstro($$props, $$slots) [2 args]
43
- // When v2-compiled components run against the v6 runtime, $$Astro gets captured as
44
- // "props" and actual props end up as "slots". This wrapper detects the 3-arg call
45
- // and strips the leading $$Astro argument.
46
- const patchedComponent = patchCreateAstroCompat(Component);
47
-
48
- const result = await container.renderToString(patchedComponent, {
49
- props: processedArgs,
50
- slots: data.slots ?? {}
75
+ return import(/* @vite-ignore */ normalizedId);
51
76
  });
77
+ const componentCache = new Map<string, Promise<AstroComponentFactory>>();
78
+ let renderQueue = Promise.resolve<void>(undefined);
79
+
80
+ async function loadPatchedComponent(componentId: string, useCache = true) {
81
+ if (!useCache) {
82
+ const { default: component } = await loadModule(componentId);
83
+
84
+ return patchCreateAstroCompat(component);
85
+ }
86
+
87
+ if (!componentCache.has(componentId)) {
88
+ componentCache.set(componentId, (async () => {
89
+ const { default: component } = await loadModule(componentId);
90
+
91
+ return patchCreateAstroCompat(component);
92
+ })());
93
+ }
94
+
95
+ const cachedComponent = componentCache.get(componentId);
96
+
97
+ if (!cachedComponent) {
98
+ throw new Error(`Failed to load Astro component: ${componentId}`);
99
+ }
100
+
101
+ try {
102
+ return await cachedComponent;
103
+ } catch (error) {
104
+ // Drop failed entries so transient/module errors can recover on the next request.
105
+ componentCache.delete(componentId);
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ return async function handler(data: HandlerProps) {
111
+ const executeRender = async () => {
112
+ const rulesConfigModule = options?.resolveRulesConfigModule
113
+ ? await options.resolveRulesConfigModule()
114
+ : undefined;
115
+
116
+ const selectedRules = await selectStoryRules({
117
+ configModule: rulesConfigModule,
118
+ configFilePath: options?.rulesConfigFilePath,
119
+ mode,
120
+ story: data.story
121
+ });
122
+
123
+ await applyMswHandlers(selectedRules.mswHandlers);
52
124
 
53
- return result;
125
+ return withStoryModuleMocks(selectedRules.moduleMocks, async () => {
126
+ const patchedComponent = await loadPatchedComponent(
127
+ data.component,
128
+ selectedRules.moduleMocks.size === 0
129
+ );
130
+ const processedArgs = await processImageMetadata(data.args ?? {});
131
+ const sanitizedPayload = sanitizeRenderPayload(
132
+ {
133
+ args: processedArgs,
134
+ slots: data.slots ?? {}
135
+ },
136
+ sanitizationOptions
137
+ );
138
+
139
+ return container.renderToString(
140
+ patchedComponent as Parameters<typeof container.renderToString>[0],
141
+ {
142
+ props: sanitizedPayload.args,
143
+ slots: sanitizedPayload.slots
144
+ }
145
+ );
146
+ });
147
+ };
148
+
149
+ const resultPromise = renderQueue.then(executeRender, executeRender);
150
+
151
+ renderQueue = resultPromise.then(
152
+ () => undefined,
153
+ () => undefined
154
+ );
155
+
156
+ return resultPromise;
54
157
  };
55
158
  }
56
159
 
57
- /**
58
- * Wraps an Astro component factory to fix the createAstro calling convention mismatch
59
- * between Astro compiler v2 and the Astro 6 runtime.
60
- *
61
- * The compiled component calls result.createAstro($$Astro, $$props, $$slots) [3 args],
62
- * but the Astro 6 runtime's createResult defines createAstro(props, slots) [2 params].
63
- * This causes $$Astro to be captured as "props" and actual props to be lost.
64
- *
65
- * The wrapper intercepts the result object and patches its createAstro method to
66
- * handle both calling conventions.
67
- */
68
- function patchCreateAstroCompat(Component: any): any {
69
- const wrapped = (result: any, props: any, slots: any) => {
70
- if (result && result.createAstro) {
71
- const origCreateAstro = result.createAstro;
72
-
73
- result.createAstro = (...args: any[]) => {
74
- if (args.length === 3) {
75
- // Compiler v2 convention: ($$Astro, $$props, $$slots) → skip $$Astro
76
- return origCreateAstro(args[1], args[2]);
160
+ function patchCreateAstroCompat(component: unknown): AstroComponentFactory {
161
+ if (typeof component !== 'function') {
162
+ throw new Error('Expected Astro component factory to be a function.');
163
+ }
164
+
165
+ const originalComponent = component as AstroComponentFactory;
166
+ const wrapped = ((result: AstroCreateResult, props: unknown, slots: unknown) => {
167
+ if (result && typeof result.createAstro === 'function') {
168
+ const originalCreateAstro = result.createAstro;
169
+ const runtimeExpectsAstroGlobal = originalCreateAstro.length >= 3;
170
+
171
+ result.createAstro = (...args: unknown[]) => {
172
+ if (args.length === 3 && !runtimeExpectsAstroGlobal) {
173
+ return originalCreateAstro(args[1], args[2]);
77
174
  }
78
175
 
79
- // Compiler v3 convention: ($$props, $$slots) → pass through
80
- return origCreateAstro(...args);
176
+ return originalCreateAstro(...args);
81
177
  };
82
178
  }
83
179
 
84
- return Component(result, props, slots);
85
- };
180
+ return originalComponent(result, props, slots);
181
+ }) as AstroComponentFactory;
86
182
 
87
- // Copy component factory metadata so the Container treats it as a valid Astro component
88
- wrapped.isAstroComponentFactory = Component.isAstroComponentFactory;
89
- wrapped.moduleId = Component.moduleId;
90
- wrapped.propagation = Component.propagation;
183
+ wrapped.isAstroComponentFactory = originalComponent.isAstroComponentFactory;
184
+ wrapped.moduleId = originalComponent.moduleId;
185
+ wrapped.propagation = originalComponent.propagation;
91
186
 
92
187
  return wrapped;
93
188
  }
94
189
 
95
- /**
96
- * Recursively processes arguments to convert ImageMetadata objects to usable image URLs.
97
- * This allows Astro's Image component to work properly in Storybook by converting
98
- * optimized asset references to direct file paths.
99
- */
100
- async function processImageMetadata(args: Record<string, unknown>): Promise<Record<string, unknown>> {
190
+ async function processImageMetadata(
191
+ args: Record<string, unknown>
192
+ ): Promise<Record<string, unknown>> {
101
193
  const processed: Record<string, unknown> = {};
102
-
194
+
103
195
  for (const [key, value] of Object.entries(args)) {
104
196
  if (isImageMetadata(value)) {
105
- // Convert ImageMetadata to a usable URL
106
197
  processed[key] = convertImageMetadataToUrl(value);
107
- } else if (Array.isArray(value)) {
108
- // Process arrays recursively
198
+
199
+ continue;
200
+ }
201
+
202
+ if (Array.isArray(value)) {
109
203
  processed[key] = await Promise.all(
110
- value.map(async (item) =>
111
- typeof item === 'object' && item !== null
112
- ? await processImageMetadata(item as Record<string, unknown>)
113
- : item
114
- )
204
+ value.map(async (item) => {
205
+ if (isImageMetadata(item)) {
206
+ return convertImageMetadataToUrl(item);
207
+ }
208
+
209
+ if (isRecord(item)) {
210
+ return processImageMetadata(item);
211
+ }
212
+
213
+ return item;
214
+ })
115
215
  );
116
- } else if (typeof value === 'object' && value !== null) {
117
- // Process nested objects recursively
118
- processed[key] = await processImageMetadata(value as Record<string, unknown>);
119
- } else {
120
- processed[key] = value;
216
+
217
+ continue;
218
+ }
219
+
220
+ if (isRecord(value)) {
221
+ processed[key] = await processImageMetadata(value);
222
+
223
+ continue;
121
224
  }
225
+
226
+ processed[key] = value;
122
227
  }
123
-
228
+
124
229
  return processed;
125
230
  }
126
231
 
127
- /**
128
- * Type guard to check if a value is an ImageMetadata object.
129
- * ImageMetadata objects typically have properties like src, width, height, format.
130
- */
131
- function isImageMetadata(value: unknown): value is Record<string, any> {
232
+ function isImageMetadata(value: unknown): value is Record<string, unknown> {
132
233
  return (
133
- typeof value === 'object' &&
134
- value !== null &&
135
- 'src' in value &&
136
- typeof (value as any).src === 'string' &&
234
+ isRecord(value) &&
235
+ typeof value.src === 'string' &&
137
236
  ('width' in value || 'height' in value || 'format' in value)
138
237
  );
139
238
  }
140
239
 
141
- /**
142
- * Converts an ImageMetadata object to a usable URL for Storybook.
143
- * In a Storybook environment, we use the raw file path instead of optimized URLs.
144
- */
145
- function convertImageMetadataToUrl(imageMetadata: Record<string, any>): string {
146
- // For Storybook, use the raw src path which should be the file path
147
- // This bypasses Astro's image optimization which doesn't work in Storybook
148
- return imageMetadata.src || imageMetadata.fsPath || String(imageMetadata);
240
+ function convertImageMetadataToUrl(imageMetadata: Record<string, unknown>): string {
241
+ const src = imageMetadata.src;
242
+ const fsPath = imageMetadata.fsPath;
243
+
244
+ if (typeof src === 'string') {
245
+ return src;
246
+ }
247
+
248
+ if (typeof fsPath === 'string') {
249
+ return fsPath;
250
+ }
251
+
252
+ return String(imageMetadata);
253
+ }
254
+
255
+ function isRecord(value: unknown): value is Record<string, unknown> {
256
+ return typeof value === 'object' && value !== null;
149
257
  }
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export type StoryModuleMocks = Map<string, string>;
4
+
5
+ const moduleMockStorage = new AsyncLocalStorage<StoryModuleMocks>();
6
+
7
+ export async function withStoryModuleMocks<T>(
8
+ moduleMocks: StoryModuleMocks,
9
+ callback: () => Promise<T>
10
+ ): Promise<T> {
11
+ return moduleMockStorage.run(moduleMocks, callback);
12
+ }
13
+
14
+ export function resolveStoryModuleMock(specifier: string): string | undefined {
15
+ return moduleMockStorage.getStore()?.get(specifier);
16
+ }
@@ -0,0 +1 @@
1
+ export { http, HttpResponse } from 'msw';
package/src/msw.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { RequestHandler } from 'msw';
2
+ import { setupServer } from 'msw/node';
3
+
4
+ type MswServer = ReturnType<typeof setupServer>;
5
+ type MswServerListenOptions = Parameters<MswServer['listen']>[0];
6
+
7
+ type MswState = {
8
+ server?: MswServer;
9
+ pendingUpdate?: Promise<void>;
10
+ };
11
+
12
+ type MswGlobalState = typeof globalThis & {
13
+ __storybookAstroMswState__?: MswState;
14
+ };
15
+
16
+ const defaultListenOptions: MswServerListenOptions = {
17
+ onUnhandledRequest: 'bypass'
18
+ };
19
+
20
+ export async function applyMswHandlers(handlers: RequestHandler[]): Promise<void> {
21
+ const state = getMswState();
22
+
23
+ if (state.pendingUpdate) {
24
+ await state.pendingUpdate;
25
+ }
26
+
27
+ const updatePromise = syncMswHandlers(handlers, state);
28
+
29
+ state.pendingUpdate = updatePromise;
30
+
31
+ try {
32
+ await updatePromise;
33
+ } finally {
34
+ state.pendingUpdate = undefined;
35
+ }
36
+ }
37
+
38
+ async function syncMswHandlers(handlers: RequestHandler[], state: MswState): Promise<void> {
39
+ if (!state.server) {
40
+ state.server = setupServer();
41
+ state.server.listen(defaultListenOptions);
42
+ }
43
+
44
+ state.server.resetHandlers(...handlers);
45
+ }
46
+
47
+ function getMswState(): MswState {
48
+ const globalState = globalThis as MswGlobalState;
49
+
50
+ if (!globalState.__storybookAstroMswState__) {
51
+ globalState.__storybookAstroMswState__ = {
52
+ server: undefined,
53
+ pendingUpdate: undefined
54
+ };
55
+ }
56
+
57
+ return globalState.__storybookAstroMswState__;
58
+ }
package/src/preset.ts CHANGED
@@ -3,6 +3,8 @@ import { vitePluginStorybookAstroMiddleware } from './viteStorybookAstroMiddlewa
3
3
  import { viteStorybookRendererFallbackPlugin } from './viteStorybookRendererFallbackPlugin.ts';
4
4
  import { vitePluginAstroComponentMarker } from './vitePluginAstroComponentMarker.ts';
5
5
  import { vitePluginAstroBuildPrerender } from './vitePluginAstroBuildPrerender.ts';
6
+ import { vitePluginAstroVueFallback } from './vitePluginAstroVueFallback.ts';
7
+ import { resolveSanitizationOptions } from './lib/sanitization.ts';
6
8
  import { mergeWithAstroConfig } from './vitePluginAstro.ts';
7
9
 
8
10
  export const core = {
@@ -10,7 +12,7 @@ export const core = {
10
12
  renderer: '@storybook-astro/renderer'
11
13
  };
12
14
 
13
- export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { presets }) => {
15
+ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { configType, presets }) => {
14
16
  const options = await presets.apply<FrameworkOptions>('frameworkOptions');
15
17
  const { vitePlugin: storybookAstroMiddlewarePlugin, viteConfig } =
16
18
  await vitePluginStorybookAstroMiddleware(options);
@@ -20,6 +22,11 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { pres
20
22
  }
21
23
 
22
24
  const integrations = options.integrations ?? [];
25
+ const resolveFrom = options.resolveFrom ?? process.cwd();
26
+ const mode = configType === 'DEVELOPMENT' ? 'development' : 'production';
27
+ const command = configType === 'DEVELOPMENT' ? 'serve' : 'build';
28
+
29
+ resolveSanitizationOptions(options.sanitization);
23
30
 
24
31
  config.plugins.push(
25
32
  storybookAstroMiddlewarePlugin,
@@ -27,7 +34,8 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { pres
27
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
35
  vitePluginAstroComponentMarker() as any,
29
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
- vitePluginAstroBuildPrerender(integrations) as any,
37
+ vitePluginAstroBuildPrerender(options) as any,
38
+ vitePluginAstroVueFallback(),
31
39
  ...viteConfig.plugins
32
40
  );
33
41
 
@@ -49,7 +57,34 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { pres
49
57
  aliases['react-dom'] = 'react-dom';
50
58
  }
51
59
 
52
- const finalConfig = await mergeWithAstroConfig(config, integrations);
60
+ const finalConfig = await mergeWithAstroConfig(config, integrations, resolveFrom, mode, command);
61
+
62
+ // Exclude @astrojs/vue from dependency optimization because it imports
63
+ // virtual modules that esbuild cannot resolve (virtual:@astrojs/vue/app).
64
+ // This must be done after mergeWithAstroConfig to avoid being overwritten.
65
+ if (!finalConfig.optimizeDeps) {
66
+ finalConfig.optimizeDeps = {};
67
+ }
68
+ if (!finalConfig.optimizeDeps.exclude) {
69
+ finalConfig.optimizeDeps.exclude = [];
70
+ }
71
+ if (!finalConfig.optimizeDeps.exclude.includes('@astrojs/vue')) {
72
+ finalConfig.optimizeDeps.exclude.push('@astrojs/vue');
73
+ }
74
+ // Mark Vue virtual modules as external so esbuild doesn't try to resolve them
75
+ if (!finalConfig.optimizeDeps.esbuildOptions) {
76
+ finalConfig.optimizeDeps.esbuildOptions = {};
77
+ }
78
+ if (!finalConfig.optimizeDeps.esbuildOptions.external) {
79
+ finalConfig.optimizeDeps.esbuildOptions.external = [];
80
+ }
81
+ const vueVirtualModules = ['virtual:@astrojs/vue/app', 'virtual:astro:vue-app'];
82
+
83
+ for (const mod of vueVirtualModules) {
84
+ if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
85
+ finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
86
+ }
87
+ }
53
88
 
54
89
  return finalConfig;
55
90
  };
@@ -0,0 +1,71 @@
1
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { afterEach, describe, expect, test } from 'vitest';
5
+ import { resolveRulesConfigFilePath } from './rules-options.ts';
6
+
7
+ const createdDirs = new Set<string>();
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(
11
+ Array.from(createdDirs).map(async (directory) => {
12
+ await rm(directory, { recursive: true, force: true });
13
+ createdDirs.delete(directory);
14
+ })
15
+ );
16
+ });
17
+
18
+ async function createTempDirectory() {
19
+ const directory = await mkdtemp(join(tmpdir(), 'storybook-astro-story-rules-'));
20
+
21
+ createdDirs.add(directory);
22
+
23
+ return directory;
24
+ }
25
+
26
+ describe('resolveRulesConfigFilePath', () => {
27
+ test('returns undefined when story rules are not configured', () => {
28
+ expect(resolveRulesConfigFilePath(undefined)).toBeUndefined();
29
+ });
30
+
31
+ test('resolves a direct file path', async () => {
32
+ const directory = await createTempDirectory();
33
+ const configPath = join(directory, 'story-rules.ts');
34
+
35
+ await writeFile(configPath, 'export default { rules: [] };', 'utf-8');
36
+
37
+ expect(resolveRulesConfigFilePath('./story-rules.ts', directory)).toBe(configPath);
38
+ });
39
+
40
+ test('resolves extensionless config path', async () => {
41
+ const directory = await createTempDirectory();
42
+ const configPath = join(directory, 'story-rules.ts');
43
+
44
+ await writeFile(configPath, 'export default { rules: [] };', 'utf-8');
45
+
46
+ expect(resolveRulesConfigFilePath('./story-rules', directory)).toBe(configPath);
47
+ });
48
+
49
+ test('resolves directory config to index file', async () => {
50
+ const directory = await createTempDirectory();
51
+ const configDirectory = join(directory, 'story-rules');
52
+ const configPath = join(configDirectory, 'index.ts');
53
+
54
+ await mkdir(configDirectory, { recursive: true });
55
+ await writeFile(configPath, 'export default { rules: [] };', 'utf-8');
56
+
57
+ expect(resolveRulesConfigFilePath('./story-rules', directory)).toBe(configPath);
58
+ });
59
+
60
+ test('throws when the configured file does not exist', () => {
61
+ expect(() => resolveRulesConfigFilePath('./missing-rules', '/tmp')).toThrow(
62
+ 'framework.options.storyRules config file was not found:'
63
+ );
64
+ });
65
+
66
+ test('throws when config path is empty', () => {
67
+ expect(() => resolveRulesConfigFilePath(' ')).toThrow(
68
+ 'framework.options.storyRules config file path cannot be empty.'
69
+ );
70
+ });
71
+ });
@@ -0,0 +1,87 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { extname, resolve } from 'node:path';
3
+
4
+ export type StoryRulesOptions =
5
+ | string
6
+ | {
7
+ configFile: string;
8
+ };
9
+
10
+ export function resolveRulesConfigFilePath(
11
+ options?: StoryRulesOptions,
12
+ resolveFrom = process.cwd()
13
+ ): string | undefined {
14
+ if (options === undefined) {
15
+ return undefined;
16
+ }
17
+
18
+ const configFile = normalizeConfigFileOption(options);
19
+ const resolvedConfigFilePath = resolve(resolveFrom, configFile);
20
+ const normalizedConfigFilePath = resolveConfigFilePath(resolvedConfigFilePath);
21
+
22
+ if (!normalizedConfigFilePath) {
23
+ throw new Error(
24
+ `framework.options.storyRules config file was not found: ${resolvedConfigFilePath}. ` +
25
+ 'Provide an existing path in framework.options.storyRules.'
26
+ );
27
+ }
28
+
29
+ return normalizedConfigFilePath;
30
+ }
31
+
32
+ function normalizeConfigFileOption(options: StoryRulesOptions): string {
33
+ const configFile =
34
+ typeof options === 'string'
35
+ ? options
36
+ : typeof options === 'object' && options !== null
37
+ ? options.configFile
38
+ : undefined;
39
+
40
+ if (typeof configFile !== 'string') {
41
+ throw new Error(
42
+ 'framework.options.storyRules must be either a string path or an object with a string configFile.'
43
+ );
44
+ }
45
+
46
+ const normalizedConfigFile = configFile.trim();
47
+
48
+ if (!normalizedConfigFile) {
49
+ throw new Error('framework.options.storyRules config file path cannot be empty.');
50
+ }
51
+
52
+ return normalizedConfigFile;
53
+ }
54
+
55
+ function resolveConfigFilePath(filePath: string): string | undefined {
56
+ if (existsSync(filePath)) {
57
+ const fileStats = statSync(filePath);
58
+
59
+ if (fileStats.isFile()) {
60
+ return filePath;
61
+ }
62
+ }
63
+
64
+ if (extname(filePath)) {
65
+ return undefined;
66
+ }
67
+
68
+ const extensions = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'];
69
+
70
+ for (const extension of extensions) {
71
+ const candidateFilePath = `${filePath}${extension}`;
72
+
73
+ if (existsSync(candidateFilePath)) {
74
+ return candidateFilePath;
75
+ }
76
+ }
77
+
78
+ for (const extension of extensions) {
79
+ const candidateFilePath = resolve(filePath, `index${extension}`);
80
+
81
+ if (existsSync(candidateFilePath)) {
82
+ return candidateFilePath;
83
+ }
84
+ }
85
+
86
+ return undefined;
87
+ }