@storybook-astro/framework 0.1.0-beta.9 → 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-KTGNRGDJ.js +0 -561
  92. package/dist/chunk-KTGNRGDJ.js.map +0 -1
@@ -0,0 +1,183 @@
1
+ import { resolve } from 'node:path';
2
+ import type { RequestHandler } from 'msw';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { defineStoryRules, selectStoryRules, type StoryRulesConfig } from './rules.ts';
5
+
6
+ function createRulesConfig(config: StoryRulesConfig) {
7
+ return {
8
+ default: defineStoryRules(config)
9
+ };
10
+ }
11
+
12
+ describe('story rules', () => {
13
+ test('returns an empty selection when no rules are configured', async () => {
14
+ const selection = await selectStoryRules({
15
+ configModule: undefined,
16
+ mode: 'development',
17
+ story: {
18
+ id: 'components-card--default'
19
+ }
20
+ });
21
+
22
+ expect(selection.moduleMocks.size).toBe(0);
23
+ expect(selection.mswHandlers).toEqual([]);
24
+ });
25
+
26
+ test('matches rules against story id and applies module mocks', async () => {
27
+ const selection = await selectStoryRules({
28
+ configModule: createRulesConfig({
29
+ rules: [
30
+ {
31
+ match: 'components-card--*',
32
+ use: ({ mock }) => {
33
+ mock('~/lib/api', '~/lib/api.mock');
34
+ }
35
+ }
36
+ ]
37
+ }),
38
+ mode: 'development',
39
+ story: {
40
+ id: 'components-card--default'
41
+ }
42
+ });
43
+
44
+ expect(selection.moduleMocks.get('~/lib/api')).toBe('~/lib/api.mock');
45
+ expect(selection.mswHandlers).toHaveLength(0);
46
+ });
47
+
48
+ test('matches rules against title and story name paths', async () => {
49
+ const selection = await selectStoryRules({
50
+ configModule: createRulesConfig({
51
+ rules: [
52
+ {
53
+ match: 'guides/getting-started/default-state',
54
+ use: ({ mock }) => {
55
+ mock('~/service', '~/service.mock');
56
+ }
57
+ }
58
+ ]
59
+ }),
60
+ mode: 'development',
61
+ story: {
62
+ id: 'guides-getting-started--default-state',
63
+ title: 'Guides/Getting Started',
64
+ name: 'Default State'
65
+ }
66
+ });
67
+
68
+ expect(selection.moduleMocks.get('~/service')).toBe('~/service.mock');
69
+ });
70
+
71
+ test('matches rules against /story/<id> style story identifiers', async () => {
72
+ const selection = await selectStoryRules({
73
+ configModule: createRulesConfig({
74
+ rules: [
75
+ {
76
+ match: '/story/components-card--default',
77
+ use: ({ mock }) => {
78
+ mock('~/store', '~/store.mock');
79
+ }
80
+ }
81
+ ]
82
+ }),
83
+ mode: 'development',
84
+ story: {
85
+ id: '/story/components-card--default'
86
+ }
87
+ });
88
+
89
+ expect(selection.moduleMocks.get('~/store')).toBe('~/store.mock');
90
+ });
91
+
92
+ test('collects MSW handlers from matching rules', async () => {
93
+ const firstHandler = {} as RequestHandler;
94
+ const secondHandler = {} as RequestHandler;
95
+
96
+ const selection = await selectStoryRules({
97
+ configModule: createRulesConfig({
98
+ rules: [
99
+ {
100
+ match: '*',
101
+ use: ({ msw }) => {
102
+ msw.use(firstHandler, secondHandler);
103
+ }
104
+ }
105
+ ]
106
+ }),
107
+ mode: 'production',
108
+ story: {
109
+ id: 'components-card--default'
110
+ }
111
+ });
112
+
113
+ expect(selection.mswHandlers).toEqual([firstHandler, secondHandler]);
114
+ });
115
+
116
+ test('resolves relative mock replacements from config file location', async () => {
117
+ const configFilePath = '/repo/.storybook/story-rules.ts';
118
+
119
+ const selection = await selectStoryRules({
120
+ configModule: createRulesConfig({
121
+ rules: [
122
+ {
123
+ match: '*',
124
+ use: ({ mock }) => {
125
+ mock('~/lib/api', './mocks/api.ts');
126
+ }
127
+ }
128
+ ]
129
+ }),
130
+ configFilePath,
131
+ mode: 'development',
132
+ story: {
133
+ id: 'components-card--default'
134
+ }
135
+ });
136
+
137
+ expect(selection.moduleMocks.get('~/lib/api')).toBe(
138
+ resolve('/repo/.storybook', './mocks/api.ts').replaceAll('\\', '/')
139
+ );
140
+ });
141
+
142
+ test('throws when a rule match pattern is empty', async () => {
143
+ await expect(
144
+ selectStoryRules({
145
+ configModule: createRulesConfig({
146
+ rules: [
147
+ {
148
+ match: ' ',
149
+ use: () => undefined
150
+ }
151
+ ]
152
+ }),
153
+ mode: 'development',
154
+ story: {
155
+ id: 'components-card--default'
156
+ }
157
+ })
158
+ ).rejects.toThrow('Story rule "match" cannot be empty.');
159
+ });
160
+
161
+ test('throws when a mock replacement is relative but config path is missing', async () => {
162
+ await expect(
163
+ selectStoryRules({
164
+ configModule: createRulesConfig({
165
+ rules: [
166
+ {
167
+ match: '*',
168
+ use: ({ mock }) => {
169
+ mock('~/lib/api', './mocks/api.ts');
170
+ }
171
+ }
172
+ ]
173
+ }),
174
+ mode: 'development',
175
+ story: {
176
+ id: 'components-card--default'
177
+ }
178
+ })
179
+ ).rejects.toThrow(
180
+ 'Story rule mock replacement uses a relative path, but rules config path is unavailable.'
181
+ );
182
+ });
183
+ });
package/src/rules.ts ADDED
@@ -0,0 +1,314 @@
1
+ import { dirname, isAbsolute, resolve } from 'node:path';
2
+ import type { RequestHandler } from 'msw';
3
+ import type { RenderStoryInput } from './types.ts';
4
+
5
+ type StoryMode = 'development' | 'production';
6
+ type StoryRuleUseResult = void | Promise<void>;
7
+
8
+ export type StoryRuleUseContext = {
9
+ mode: StoryMode;
10
+ story: StoryRuleStory;
11
+ msw: {
12
+ use: (...handlers: RequestHandler[]) => void;
13
+ };
14
+ mock: (specifier: string, replacement: string) => void;
15
+ };
16
+
17
+ export type StoryRuleUse = (context: StoryRuleUseContext) => StoryRuleUseResult;
18
+
19
+ export type StoryRule = {
20
+ match: string | string[];
21
+ use: StoryRuleUse | StoryRuleUse[];
22
+ };
23
+
24
+ export type StoryRulesConfig = {
25
+ rules: StoryRule[];
26
+ };
27
+
28
+ export type StoryRuleStory = {
29
+ id: string;
30
+ title?: string;
31
+ name?: string;
32
+ keys: string[];
33
+ };
34
+
35
+ export type StoryRuleSelectionInput = {
36
+ configModule: unknown;
37
+ configFilePath?: string;
38
+ mode: StoryMode;
39
+ story?: RenderStoryInput;
40
+ };
41
+
42
+ export type StoryRuleSelection = {
43
+ moduleMocks: Map<string, string>;
44
+ mswHandlers: RequestHandler[];
45
+ };
46
+
47
+ type MutableStoryRuleSelection = {
48
+ moduleMocks: Map<string, string>;
49
+ mswHandlers: RequestHandler[];
50
+ };
51
+
52
+ export function defineStoryRules(config: StoryRulesConfig): StoryRulesConfig {
53
+ return config;
54
+ }
55
+
56
+ export async function selectStoryRules(
57
+ input: StoryRuleSelectionInput
58
+ ): Promise<StoryRuleSelection> {
59
+ const config = normalizeRulesConfig(input.configModule);
60
+ const story = normalizeStory(input.story);
61
+ const selection = createEmptySelection();
62
+
63
+ for (const rule of config.rules) {
64
+ if (!isStoryRuleMatch(rule.match, story)) {
65
+ continue;
66
+ }
67
+
68
+ const uses = Array.isArray(rule.use) ? rule.use : [rule.use];
69
+
70
+ for (const use of uses) {
71
+ if (typeof use !== 'function') {
72
+ throw new Error('Each story rule "use" entry must be a function.');
73
+ }
74
+
75
+ await use({
76
+ mode: input.mode,
77
+ story,
78
+ msw: {
79
+ use: (...handlers) => {
80
+ selection.mswHandlers.push(...handlers);
81
+ }
82
+ },
83
+ mock: (specifier, replacement) => {
84
+ const normalizedSpecifier = normalizeMockSpecifier(specifier);
85
+ const normalizedReplacement = normalizeMockReplacement(replacement, input.configFilePath);
86
+
87
+ selection.moduleMocks.set(normalizedSpecifier, normalizedReplacement);
88
+ }
89
+ });
90
+ }
91
+ }
92
+
93
+ return selection;
94
+ }
95
+
96
+ function normalizeRulesConfig(configModule: unknown): StoryRulesConfig {
97
+ const configExport = getRulesConfigExport(configModule);
98
+
99
+ if (configExport === undefined || configExport === null) {
100
+ return {
101
+ rules: []
102
+ };
103
+ }
104
+
105
+ if (!isRecord(configExport)) {
106
+ throw new Error(
107
+ 'Story rules config must export an object with a "rules" array via a default export or named export.'
108
+ );
109
+ }
110
+
111
+ const rules = configExport.rules;
112
+
113
+ if (rules === undefined) {
114
+ return {
115
+ rules: []
116
+ };
117
+ }
118
+
119
+ if (!Array.isArray(rules)) {
120
+ throw new Error('Story rules config "rules" must be an array.');
121
+ }
122
+
123
+ return {
124
+ rules: rules as StoryRule[]
125
+ };
126
+ }
127
+
128
+ function getRulesConfigExport(configModule: unknown): unknown {
129
+ if (!isRecord(configModule)) {
130
+ return configModule;
131
+ }
132
+
133
+ if ('default' in configModule && configModule.default !== undefined) {
134
+ return configModule.default;
135
+ }
136
+
137
+ if ('rules' in configModule) {
138
+ return {
139
+ rules: configModule.rules
140
+ };
141
+ }
142
+
143
+ return undefined;
144
+ }
145
+
146
+ function normalizeStory(story?: RenderStoryInput): StoryRuleStory {
147
+ const id = normalizeStoryId(story?.id);
148
+ const title = normalizeOptionalString(story?.title);
149
+ const name = normalizeOptionalString(story?.name);
150
+ const keys = Array.from(resolveStoryKeys({ id, title, name }));
151
+
152
+ return {
153
+ id,
154
+ title,
155
+ name,
156
+ keys
157
+ };
158
+ }
159
+
160
+ function resolveStoryKeys(story: { id: string; title?: string; name?: string }) {
161
+ const keys = new Set<string>();
162
+
163
+ keys.add('');
164
+
165
+ const storyId = story.id;
166
+ const idPath = storyId ? storyId.replaceAll('--', '/') : '';
167
+
168
+ if (storyId) {
169
+ keys.add(storyId);
170
+ keys.add(`/story/${storyId}`);
171
+ }
172
+
173
+ if (idPath) {
174
+ keys.add(idPath);
175
+ keys.add(`/story/${idPath}`);
176
+ }
177
+
178
+ const titlePath = story.title
179
+ ? story.title
180
+ .split('/')
181
+ .map((segment) => slugify(segment))
182
+ .filter(Boolean)
183
+ .join('/')
184
+ : '';
185
+
186
+ const storyNamePath = story.name ? slugify(story.name) : '';
187
+
188
+ if (titlePath && storyNamePath) {
189
+ const composedPath = `${titlePath}/${storyNamePath}`;
190
+
191
+ keys.add(composedPath);
192
+ keys.add(`/story/${composedPath}`);
193
+ }
194
+
195
+ return keys;
196
+ }
197
+
198
+ function isStoryRuleMatch(match: string | string[], story: StoryRuleStory): boolean {
199
+ const patterns = Array.isArray(match) ? match : [match];
200
+
201
+ return patterns.some((pattern) => {
202
+ if (typeof pattern !== 'string') {
203
+ throw new Error('Story rule "match" must be a string or an array of strings.');
204
+ }
205
+
206
+ const normalizedPattern = pattern.trim();
207
+
208
+ if (!normalizedPattern) {
209
+ throw new Error('Story rule "match" cannot be empty.');
210
+ }
211
+
212
+ return story.keys.some((key) => isWildcardMatch(normalizedPattern, key));
213
+ });
214
+ }
215
+
216
+ function isWildcardMatch(pattern: string, candidate: string): boolean {
217
+ const escapedPattern = escapeRegExp(pattern).replaceAll('*', '.*');
218
+ const regex = new RegExp(`^${escapedPattern}$`);
219
+
220
+ return regex.test(candidate);
221
+ }
222
+
223
+ function normalizeStoryId(id?: string): string {
224
+ const value = normalizeOptionalString(id) ?? '';
225
+
226
+ if (!value) {
227
+ return '';
228
+ }
229
+
230
+ return value.startsWith('/story/') ? value.slice('/story/'.length) : value;
231
+ }
232
+
233
+ function normalizeOptionalString(value: unknown): string | undefined {
234
+ if (typeof value !== 'string') {
235
+ return undefined;
236
+ }
237
+
238
+ const normalizedValue = value.trim();
239
+
240
+ return normalizedValue || undefined;
241
+ }
242
+
243
+ function normalizeMockSpecifier(value: unknown): string {
244
+ if (typeof value !== 'string') {
245
+ throw new Error('Story rule mock specifier must be a string.');
246
+ }
247
+
248
+ const normalizedValue = value.trim();
249
+
250
+ if (!normalizedValue) {
251
+ throw new Error('Story rule mock specifier cannot be empty.');
252
+ }
253
+
254
+ return normalizedValue;
255
+ }
256
+
257
+ function normalizeMockReplacement(value: unknown, configFilePath?: string): string {
258
+ if (typeof value !== 'string') {
259
+ throw new Error('Story rule mock replacement must be a string.');
260
+ }
261
+
262
+ const normalizedValue = value.trim();
263
+
264
+ if (!normalizedValue) {
265
+ throw new Error('Story rule mock replacement cannot be empty.');
266
+ }
267
+
268
+ if (isAbsolute(normalizedValue)) {
269
+ return toPosixPath(normalizedValue);
270
+ }
271
+
272
+ if (normalizedValue.startsWith('.')) {
273
+ if (!configFilePath) {
274
+ throw new Error(
275
+ 'Story rule mock replacement uses a relative path, but rules config path is unavailable.'
276
+ );
277
+ }
278
+
279
+ return toPosixPath(resolve(dirname(configFilePath), normalizedValue));
280
+ }
281
+
282
+ return normalizedValue;
283
+ }
284
+
285
+ function slugify(input: string): string {
286
+ return input
287
+ .trim()
288
+ .toLowerCase()
289
+ .replace(/[^a-z0-9]+/g, '-')
290
+ .replace(/^-+|-+$/g, '');
291
+ }
292
+
293
+ function createEmptySelection(): MutableStoryRuleSelection {
294
+ return {
295
+ moduleMocks: new Map(),
296
+ mswHandlers: []
297
+ };
298
+ }
299
+
300
+ function toPosixPath(input: string): string {
301
+ return input.replaceAll('\\', '/');
302
+ }
303
+
304
+ function escapeRegExp(input: string) {
305
+ return input.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
306
+ }
307
+
308
+ function isRecord(value: unknown): value is Record<string, unknown> {
309
+ if (typeof value !== 'object' || value === null) {
310
+ return false;
311
+ }
312
+
313
+ return !Array.isArray(value);
314
+ }
@@ -0,0 +1,219 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { ViteDevServer } from 'vite';
3
+ import type { Integration as StorybookAstroIntegration } from '../integrations/base.ts';
4
+ import { resolveTestingIntegrationsForRoot } from './integration-config.ts';
5
+ import { resolveTestingProjectRoot } from './project-root.ts';
6
+ import { runWithWorkingDirectory } from './working-directory.ts';
7
+ import { getComponentModuleId, isAstroComponentFactory, isStorybookAstroClientStub } from './component-utils.ts';
8
+ import { ssrLoadModuleWithFsFallback } from '../lib/ssr-load-module-with-fs-fallback.ts';
9
+ import type { ComposedStory } from './types.ts';
10
+ import { renderViaTestingRendererDaemon } from './renderer-daemon.ts';
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ let astroContainerPromise: Promise<any> | null = null;
14
+
15
+ const astroSsrViteServerPromises = new Map<string, Promise<ViteDevServer>>();
16
+
17
+ const astroSsrHandlerPromises = new Map<
18
+ string,
19
+ Promise<(data: { component: string; args?: Record<string, unknown> }) => Promise<string>>
20
+ >();
21
+
22
+ const testingIntegrationsCache = new Map<string, StorybookAstroIntegration[]>();
23
+
24
+ function getTestingIntegrations(resolveFrom: string) {
25
+ if (!testingIntegrationsCache.has(resolveFrom)) {
26
+ testingIntegrationsCache.set(resolveFrom, resolveTestingIntegrationsForRoot(resolveFrom));
27
+ }
28
+
29
+ return testingIntegrationsCache.get(resolveFrom)!;
30
+ }
31
+
32
+ async function getAstroContainer() {
33
+ if (!astroContainerPromise) {
34
+ const { experimental_AstroContainer: AstroContainer } = await import('astro/container');
35
+
36
+ astroContainerPromise = AstroContainer.create();
37
+ }
38
+
39
+ return astroContainerPromise;
40
+ }
41
+
42
+ async function getAstroSsrViteServer(resolveFrom: string) {
43
+ if (!astroSsrViteServerPromises.has(resolveFrom)) {
44
+ const { createViteServer } = await import('../viteStorybookAstroMiddlewarePlugin.ts');
45
+ const integrations = getTestingIntegrations(resolveFrom);
46
+
47
+ astroSsrViteServerPromises.set(
48
+ resolveFrom,
49
+ runWithWorkingDirectory(resolveFrom, () => createViteServer(integrations, resolveFrom))
50
+ );
51
+ }
52
+
53
+ return astroSsrViteServerPromises.get(resolveFrom)!;
54
+ }
55
+
56
+ async function getAstroSsrHandler(resolveFrom: string) {
57
+ if (!astroSsrHandlerPromises.has(resolveFrom)) {
58
+ astroSsrHandlerPromises.set(resolveFrom, (async () => {
59
+ const integrations = getTestingIntegrations(resolveFrom);
60
+ const viteServer = await getAstroSsrViteServer(resolveFrom);
61
+ const middlewareModulePath = fileURLToPath(new URL('../middleware', import.meta.url));
62
+ const middleware = await runWithWorkingDirectory(resolveFrom, () =>
63
+ viteServer.ssrLoadModule(middlewareModulePath, {
64
+ fixStacktrace: true
65
+ })
66
+ );
67
+
68
+ return middleware.handlerFactory(integrations, {
69
+ loadModule: (id: string) =>
70
+ ssrLoadModuleWithFsFallback(viteServer, id, {
71
+ fixStacktrace: true
72
+ })
73
+ });
74
+ })());
75
+ }
76
+
77
+ return astroSsrHandlerPromises.get(resolveFrom)!;
78
+ }
79
+
80
+ async function resolveAstroComponent(component: unknown, resolveFrom: string) {
81
+ let resolvedComponent = component;
82
+
83
+ if (!isAstroComponentFactory(resolvedComponent)) {
84
+ throw new Error('Story meta.component must be an Astro component factory.');
85
+ }
86
+
87
+ if ('moduleId' in resolvedComponent && typeof resolvedComponent.moduleId === 'string') {
88
+ const moduleId = resolvedComponent.moduleId;
89
+ const normalizedModuleId = moduleId.split('?')[0].split('#')[0];
90
+
91
+ try {
92
+ const mod = await import(/* @vite-ignore */ normalizedModuleId) as Record<string, unknown>;
93
+
94
+ if (isAstroComponentFactory(mod.default)) {
95
+ resolvedComponent = mod.default;
96
+ }
97
+ } catch {
98
+ // keep current component when direct module import is unavailable
99
+ }
100
+
101
+ if (isStorybookAstroClientStub(resolvedComponent)) {
102
+ try {
103
+ const viteServer = await getAstroSsrViteServer(resolveFrom);
104
+ const mod = (await ssrLoadModuleWithFsFallback(viteServer, normalizedModuleId)) as Record<string, unknown>;
105
+
106
+ if (isAstroComponentFactory(mod.default)) {
107
+ resolvedComponent = mod.default;
108
+ }
109
+ } catch {
110
+ // keep current component when SSR module loading is unavailable
111
+ }
112
+ }
113
+ }
114
+
115
+ return resolvedComponent;
116
+ }
117
+
118
+ async function renderAstroComponentToDom(
119
+ component: unknown,
120
+ args: Record<string, unknown>,
121
+ resolveFrom: string
122
+ ) {
123
+ const moduleId = getComponentModuleId(component);
124
+
125
+ if (moduleId) {
126
+ try {
127
+ // Fast path: reuse a single shared SSR daemon instead of spinning SSR in each worker.
128
+ const html = await renderViaTestingRendererDaemon({
129
+ resolveFrom,
130
+ component: moduleId,
131
+ args
132
+ });
133
+
134
+ if (typeof html === 'string') {
135
+ if (typeof document !== 'undefined') {
136
+ document.body.innerHTML = html;
137
+ }
138
+
139
+ return html;
140
+ }
141
+ } catch {
142
+ // Fall back to in-worker rendering below when daemon render fails.
143
+ }
144
+
145
+ try {
146
+ const handler = await getAstroSsrHandler(resolveFrom);
147
+ const html = await handler({
148
+ component: moduleId,
149
+ args
150
+ });
151
+
152
+ if (typeof document !== 'undefined') {
153
+ document.body.innerHTML = html;
154
+ }
155
+
156
+ return html;
157
+ } catch {
158
+ // Fall back to direct Container rendering below
159
+ }
160
+ }
161
+
162
+ const resolvedComponent = await resolveAstroComponent(component, resolveFrom);
163
+ const container = await getAstroContainer();
164
+
165
+ if (!container) {
166
+ throw new Error('Failed to initialize Astro container for rendering');
167
+ }
168
+
169
+ const html = await container.renderToString(resolvedComponent, {
170
+ props: args
171
+ });
172
+
173
+ if (typeof document !== 'undefined') {
174
+ document.body.innerHTML = html;
175
+ }
176
+
177
+ return html;
178
+ }
179
+
180
+ async function renderComposedStory(story: ComposedStory) {
181
+ const meta = story.__storybookAstroMeta;
182
+ const storyExport = story.__storybookAstroStoryExport;
183
+ let component = meta?.component ?? story.component;
184
+
185
+ if (!isAstroComponentFactory(component)) {
186
+ const maybeRendered = await story();
187
+
188
+ if (isAstroComponentFactory(maybeRendered)) {
189
+ component = maybeRendered;
190
+ } else if (
191
+ typeof maybeRendered === 'object' &&
192
+ maybeRendered !== null &&
193
+ 'component' in maybeRendered &&
194
+ isAstroComponentFactory((maybeRendered as { component: unknown }).component)
195
+ ) {
196
+ component = (maybeRendered as { component: unknown }).component;
197
+ }
198
+ }
199
+
200
+ if (!component) {
201
+ throw new Error('Unable to resolve Astro component from composed story.');
202
+ }
203
+
204
+ const args = {
205
+ ...(meta?.args ?? {}),
206
+ ...(storyExport?.args ?? {}),
207
+ ...(story.args ?? {})
208
+ };
209
+
210
+ const resolveFrom = await resolveTestingProjectRoot(component);
211
+
212
+ return renderAstroComponentToDom(component, args, resolveFrom);
213
+ }
214
+
215
+ export async function renderStory(story: ComposedStory) {
216
+ return renderComposedStory(story);
217
+ }
218
+
219
+ export const renderAstroStory = renderStory;