@storybook-astro/framework 1.6.0 → 1.7.0-canary.2
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.
- package/dist/{chunk-RERWLIIN.js → chunk-5POAXNWB.js} +96 -107
- package/dist/chunk-5POAXNWB.js.map +1 -0
- package/dist/chunk-NOQVUQ7R.js +107 -0
- package/dist/chunk-NOQVUQ7R.js.map +1 -0
- package/dist/{chunk-6RIGYMZP.js → chunk-UIGE5653.js} +1 -1
- package/dist/chunk-UIGE5653.js.map +1 -0
- package/dist/chunk-YRG32BBU.js +15 -0
- package/dist/chunk-YRG32BBU.js.map +1 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.js +4 -2
- package/dist/middleware.js.map +1 -1
- package/dist/preset.js +78 -42
- package/dist/preset.js.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +39 -15
- package/dist/testing.js.map +1 -1
- package/dist/vitest/index.js +1 -1
- package/package.json +17 -13
- package/src/astroRenderHandler.ts +39 -2
- package/src/index.ts +8 -1
- package/src/lib/astro-component-marker.test.ts +48 -0
- package/src/lib/reconstruct-component-args.test.ts +97 -0
- package/src/lib/reconstruct-component-args.ts +155 -0
- package/src/lib/separate-story-slots.ts +20 -0
- package/src/preset.ts +60 -12
- package/src/productionRenderRuntime.test.ts +97 -0
- package/src/productionRenderRuntime.ts +46 -29
- package/src/storySsrVite.ts +23 -6
- package/src/testing/astro-runtime.ts +52 -21
- package/src/vitePluginAstroComponentMarker.ts +6 -1
- package/dist/chunk-6RIGYMZP.js.map +0 -1
- package/dist/chunk-RERWLIIN.js.map +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isAstroComponentFactory, isAstroComponentMarker } from '@storybook-astro/renderer/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves Astro component references that a story passed as props or slot
|
|
5
|
+
* content back into something the Astro Container can render.
|
|
6
|
+
*
|
|
7
|
+
* A component reference arrives either as a serialized marker (the browser /
|
|
8
|
+
* dev / server path, where it crossed a JSON boundary) or as a real component
|
|
9
|
+
* factory (the Vitest / portable-stories path, which imports `.astro` files
|
|
10
|
+
* directly). Both are handled.
|
|
11
|
+
*
|
|
12
|
+
* - **Props**: resolved to the real component factory and passed through, so the
|
|
13
|
+
* parent template renders them with `<Comp />` (the Container supports this).
|
|
14
|
+
* - **Slots**: rendered to an HTML string, because the Container only accepts
|
|
15
|
+
* string slot content — a factory passed as a slot is stringified verbatim.
|
|
16
|
+
*
|
|
17
|
+
* Both callbacks are injected so this is reusable across the dev/server handler
|
|
18
|
+
* (which loads by moduleId and renders via its container) and the testing path
|
|
19
|
+
* (where factories are already in hand).
|
|
20
|
+
*/
|
|
21
|
+
type LoadComponent = (moduleId: string) => Promise<unknown>;
|
|
22
|
+
type RenderToHtml = (component: unknown) => Promise<string>;
|
|
23
|
+
|
|
24
|
+
// Guards against pathological/cyclic arg objects. Component references are leaf
|
|
25
|
+
// replacements, so real nesting is shallow; this is just insurance.
|
|
26
|
+
const MAX_DEPTH = 10;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves component references in **props** back to real component factories,
|
|
30
|
+
* so the parent template renders them with `<Comp />`. Run this before the
|
|
31
|
+
* normal arg processing (image/date/sanitize) — factories pass through those
|
|
32
|
+
* untouched, and a resolved prop must exist before the parent renders.
|
|
33
|
+
*/
|
|
34
|
+
export async function reconstructProps(
|
|
35
|
+
args: Record<string, unknown>,
|
|
36
|
+
callbacks: { loadComponent: LoadComponent }
|
|
37
|
+
): Promise<Record<string, unknown>> {
|
|
38
|
+
return (await resolvePropValue(args, callbacks, 0)) as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves component references in **slots** to HTML strings (the Container only
|
|
43
|
+
* accepts string slots). Run this *after* sanitization so a component's rendered
|
|
44
|
+
* markup — trusted, like a prop-rendered component — isn't stripped by the slot
|
|
45
|
+
* HTML allowlist, while plain-string slots still are.
|
|
46
|
+
*/
|
|
47
|
+
export async function reconstructSlots(
|
|
48
|
+
slots: Record<string, unknown>,
|
|
49
|
+
callbacks: { loadComponent: LoadComponent; renderToHtml: RenderToHtml }
|
|
50
|
+
): Promise<Record<string, unknown>> {
|
|
51
|
+
const out: Record<string, unknown> = {};
|
|
52
|
+
|
|
53
|
+
for (const [name, value] of Object.entries(slots)) {
|
|
54
|
+
out[name] = await resolveSlotValue(name, value, callbacks);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Walks a prop value, replacing component references with real factories.
|
|
62
|
+
* Returns the original reference untouched when nothing changed, so unrelated
|
|
63
|
+
* args keep their identity (e.g. `ImageMetadata` objects the image pipeline
|
|
64
|
+
* later inspects).
|
|
65
|
+
*/
|
|
66
|
+
async function resolvePropValue(
|
|
67
|
+
value: unknown,
|
|
68
|
+
callbacks: { loadComponent: LoadComponent },
|
|
69
|
+
depth: number
|
|
70
|
+
): Promise<unknown> {
|
|
71
|
+
if (isAstroComponentMarker(value)) {
|
|
72
|
+
return callbacks.loadComponent(value.moduleId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Already a real factory (testing path) — pass it straight through as a prop.
|
|
76
|
+
if (isAstroComponentFactory(value)) {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (depth >= MAX_DEPTH) {
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(value)) {
|
|
85
|
+
const resolved = await Promise.all(
|
|
86
|
+
value.map((item) => resolvePropValue(item, callbacks, depth + 1))
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return resolved.some((item, index) => item !== value[index]) ? resolved : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isPlainObject(value)) {
|
|
93
|
+
let changed = false;
|
|
94
|
+
const out: Record<string, unknown> = {};
|
|
95
|
+
|
|
96
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
97
|
+
const resolved = await resolvePropValue(nested, callbacks, depth + 1);
|
|
98
|
+
|
|
99
|
+
if (resolved !== nested) {
|
|
100
|
+
changed = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
out[key] = resolved;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return changed ? out : value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resolveSlotValue(
|
|
113
|
+
name: string,
|
|
114
|
+
value: unknown,
|
|
115
|
+
callbacks: { loadComponent: LoadComponent; renderToHtml: RenderToHtml }
|
|
116
|
+
): Promise<unknown> {
|
|
117
|
+
// An array slot (list of components and/or strings) is concatenated into one
|
|
118
|
+
// HTML string, which is what the Container expects for a single slot.
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
const parts = await Promise.all(value.map((item) => resolveSlotValue(name, item, callbacks)));
|
|
121
|
+
|
|
122
|
+
return parts.join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isAstroComponentMarker(value)) {
|
|
126
|
+
const component = await callbacks.loadComponent(value.moduleId);
|
|
127
|
+
|
|
128
|
+
return renderSlotComponent(name, component, callbacks);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isAstroComponentFactory(value)) {
|
|
132
|
+
return renderSlotComponent(name, value, callbacks);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Plain HTML string (or anything else) passes through unchanged.
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function renderSlotComponent(
|
|
140
|
+
name: string,
|
|
141
|
+
component: unknown,
|
|
142
|
+
callbacks: { renderToHtml: RenderToHtml }
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
try {
|
|
145
|
+
return await callbacks.renderToHtml(component);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
|
|
149
|
+
throw new Error(`Failed to render Astro component passed to slot "${name}": ${message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
154
|
+
return typeof value === 'object' && value !== null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a story's `args` into component props and slot content. Slot content
|
|
3
|
+
* lives under the reserved `slots` key (`args.slots.<name>`); everything else is
|
|
4
|
+
* a prop. Returns `{}` for slots when `args.slots` is absent or not an object.
|
|
5
|
+
*/
|
|
6
|
+
export function separateStorySlots(storyArgs: Record<string, unknown>): {
|
|
7
|
+
componentArgs: Record<string, unknown>;
|
|
8
|
+
storySlots: Record<string, unknown>;
|
|
9
|
+
} {
|
|
10
|
+
const componentArgs = { ...storyArgs };
|
|
11
|
+
const storySlots = componentArgs.slots;
|
|
12
|
+
|
|
13
|
+
delete componentArgs.slots;
|
|
14
|
+
|
|
15
|
+
if (typeof storySlots !== 'object' || storySlots === null) {
|
|
16
|
+
return { componentArgs, storySlots: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { componentArgs, storySlots: storySlots as Record<string, unknown> };
|
|
20
|
+
}
|
package/src/preset.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname } from 'node:path';
|
|
2
|
+
import { version as viteVersion } from 'vite';
|
|
2
3
|
import type { StorybookConfigVite, FrameworkOptions } from './types.ts';
|
|
3
4
|
import { vitePluginStorybookAstroMiddleware } from './viteStorybookAstroMiddlewarePlugin.ts';
|
|
4
5
|
import { viteStorybookRendererFallbackPlugin } from './viteStorybookRendererFallbackPlugin.ts';
|
|
@@ -205,8 +206,8 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
|
|
|
205
206
|
}
|
|
206
207
|
// Mark integration virtual modules as external so the dep bundler doesn't
|
|
207
208
|
// try to resolve them (they are Vite virtual modules with no real package).
|
|
208
|
-
//
|
|
209
|
-
//
|
|
209
|
+
// Vite ≤7 reads these from esbuildOptions; Vite 8+ uses Rolldown and reads
|
|
210
|
+
// them from rolldownOptions. We populate whichever key the running Vite uses.
|
|
210
211
|
const integrationVirtualModules = [
|
|
211
212
|
'virtual:@astrojs/vue/app',
|
|
212
213
|
'virtual:astro:vue-app',
|
|
@@ -215,16 +216,21 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
|
|
|
215
216
|
'astro:toolbar:internal'
|
|
216
217
|
];
|
|
217
218
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
finalConfig.optimizeDeps.esbuildOptions
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (!finalConfig.optimizeDeps.esbuildOptions.external
|
|
227
|
-
finalConfig.optimizeDeps.esbuildOptions.external
|
|
219
|
+
const viteMajor = Number.parseInt(viteVersion, 10);
|
|
220
|
+
|
|
221
|
+
// Vite ≤7 (esbuild-based optimizer). On Vite 8+ setting esbuildOptions logs a
|
|
222
|
+
// deprecation warning, so only touch it on older Vite.
|
|
223
|
+
if (viteMajor < 8) {
|
|
224
|
+
if (!finalConfig.optimizeDeps.esbuildOptions) {
|
|
225
|
+
finalConfig.optimizeDeps.esbuildOptions = {};
|
|
226
|
+
}
|
|
227
|
+
if (!finalConfig.optimizeDeps.esbuildOptions.external) {
|
|
228
|
+
finalConfig.optimizeDeps.esbuildOptions.external = [];
|
|
229
|
+
}
|
|
230
|
+
for (const mod of integrationVirtualModules) {
|
|
231
|
+
if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
|
|
232
|
+
finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
|
|
233
|
+
}
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -237,6 +243,48 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
|
|
|
237
243
|
);
|
|
238
244
|
optimizeDepsMut.rolldownOptions = rolldownOpts;
|
|
239
245
|
|
|
246
|
+
// Vite 8 dev-server compatibility (Astro 7+). Vite ≤7 (Astro 5/6) is unaffected.
|
|
247
|
+
if (configType === 'DEVELOPMENT' && viteMajor >= 8) {
|
|
248
|
+
// 1. Drop @vitejs/plugin-react's Vite 8 native Fast Refresh wrapper. Under
|
|
249
|
+
// Vite 8 the plugin delegates Fast Refresh to a Rolldown builtin
|
|
250
|
+
// (`builtin:vite-react-refresh-wrapper`) that throws
|
|
251
|
+
// "Missing field `moduleType`" while transforming Storybook's iframe.html
|
|
252
|
+
// inline bootstrap script — 500-ing every preview load. There is no
|
|
253
|
+
// config opt-out, so we remove the plugin. React components still render;
|
|
254
|
+
// only Fast Refresh is lost (component edits full-reload instead).
|
|
255
|
+
const stripReactRefreshWrapper = (plugins: unknown[]): unknown[] =>
|
|
256
|
+
plugins
|
|
257
|
+
.map((plugin) => (Array.isArray(plugin) ? stripReactRefreshWrapper(plugin) : plugin))
|
|
258
|
+
.filter(
|
|
259
|
+
(plugin) =>
|
|
260
|
+
!(
|
|
261
|
+
plugin &&
|
|
262
|
+
typeof plugin === 'object' &&
|
|
263
|
+
(plugin as { name?: string }).name === 'vite:react:refresh-wrapper'
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
finalConfig.plugins = stripReactRefreshWrapper(
|
|
268
|
+
finalConfig.plugins ?? []
|
|
269
|
+
) as typeof finalConfig.plugins;
|
|
270
|
+
|
|
271
|
+
// 2. Exclude the Storybook renderer entry-previews from dependency
|
|
272
|
+
// optimization. Some ship non-JS source (e.g. `@storybook/svelte`'s
|
|
273
|
+
// `.svelte` files) that the esbuild dep scanner cannot load
|
|
274
|
+
// ("No loader is configured for .svelte"), which fails optimization and
|
|
275
|
+
// 504s every renderer entry. Serving them as source lets the framework's
|
|
276
|
+
// own Vite plugins transform them.
|
|
277
|
+
const entryPreviews = integrations
|
|
278
|
+
.map((integration) => integration.storybookEntryPreview)
|
|
279
|
+
.filter((specifier): specifier is string => Boolean(specifier));
|
|
280
|
+
|
|
281
|
+
for (const specifier of entryPreviews) {
|
|
282
|
+
if (!finalConfig.optimizeDeps.exclude.includes(specifier)) {
|
|
283
|
+
finalConfig.optimizeDeps.exclude.push(specifier);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
return finalConfig;
|
|
241
289
|
};
|
|
242
290
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
renderProductionStoryToHtml,
|
|
4
|
+
type ProductionRenderRuntime,
|
|
5
|
+
type ProductionStoryEntry
|
|
6
|
+
} from './productionRenderRuntime.ts';
|
|
7
|
+
|
|
8
|
+
const resolveFrom = '/project';
|
|
9
|
+
|
|
10
|
+
const story: ProductionStoryEntry = {
|
|
11
|
+
id: 'components-button--primary',
|
|
12
|
+
importPath: './src/Button.stories.ts',
|
|
13
|
+
componentPath: './src/Button.astro',
|
|
14
|
+
exportName: 'Primary',
|
|
15
|
+
title: 'Components/Button',
|
|
16
|
+
name: 'Primary'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A stand-in Astro component factory; only its identity and `function`-ness matter here. */
|
|
20
|
+
function ButtonAstro() {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createRuntime(storyModule: Record<string, unknown>): {
|
|
25
|
+
runtime: ProductionRenderRuntime;
|
|
26
|
+
renderAstroStory: ReturnType<typeof vi.fn>;
|
|
27
|
+
} {
|
|
28
|
+
const renderAstroStory = vi.fn(async () => '<button>rendered</button>');
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
runtime: {
|
|
32
|
+
loadModule: async () => storyModule,
|
|
33
|
+
renderAstroStory,
|
|
34
|
+
close: async () => {}
|
|
35
|
+
},
|
|
36
|
+
renderAstroStory
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('renderProductionStoryToHtml', () => {
|
|
41
|
+
test('CSF3: reads component from the default export and merges meta + story args', async () => {
|
|
42
|
+
const { runtime, renderAstroStory } = createRuntime({
|
|
43
|
+
default: { component: ButtonAstro, args: { label: 'Meta', size: 'lg' } },
|
|
44
|
+
Primary: { args: { label: 'Primary' } }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
|
|
48
|
+
|
|
49
|
+
expect(html).toBe('<button>rendered</button>');
|
|
50
|
+
expect(renderAstroStory).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({
|
|
52
|
+
component: '/project/src/Button.astro',
|
|
53
|
+
args: { label: 'Primary', size: 'lg' }
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('CSF4 factory: reads component and args from the story export meta with no default export', async () => {
|
|
59
|
+
const { runtime, renderAstroStory } = createRuntime({
|
|
60
|
+
// Shape produced by `meta.story()` in Storybook's CSF factories.
|
|
61
|
+
Primary: {
|
|
62
|
+
_tag: 'Story',
|
|
63
|
+
input: { args: { label: 'Primary' } },
|
|
64
|
+
meta: {
|
|
65
|
+
_tag: 'Meta',
|
|
66
|
+
input: { component: ButtonAstro, args: { label: 'Meta', size: 'lg' } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
|
|
72
|
+
|
|
73
|
+
expect(html).toBe('<button>rendered</button>');
|
|
74
|
+
expect(renderAstroStory).toHaveBeenCalledWith(
|
|
75
|
+
expect.objectContaining({
|
|
76
|
+
component: '/project/src/Button.astro',
|
|
77
|
+
args: { label: 'Primary', size: 'lg' }
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('skips stories that override the component with a non-Astro render', async () => {
|
|
83
|
+
function OtherComponent() {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { runtime, renderAstroStory } = createRuntime({
|
|
88
|
+
default: { component: ButtonAstro },
|
|
89
|
+
Primary: { component: OtherComponent }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
|
|
93
|
+
|
|
94
|
+
expect(html).toBeUndefined();
|
|
95
|
+
expect(renderAstroStory).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { createAstroRenderHandler, type HandlerProps } from './astroRenderHandler.ts';
|
|
3
|
+
import { separateStorySlots } from './lib/separate-story-slots.ts';
|
|
3
4
|
import type { Integration } from './integrations/index.ts';
|
|
4
5
|
import type { SanitizationOptions } from './lib/sanitization.ts';
|
|
5
6
|
import type { FrameworkOptions } from './types.ts';
|
|
@@ -107,25 +108,23 @@ export async function renderProductionStoryToHtml(options: {
|
|
|
107
108
|
const storyModulePath = resolveProjectImportPath(options.story.importPath, options.resolveFrom);
|
|
108
109
|
const componentPath = resolveProjectImportPath(options.story.componentPath, options.resolveFrom);
|
|
109
110
|
const storyModule = await options.runtime.loadModule(storyModulePath);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
const { metaComponent, metaArgs, storyComponent, storyLevelArgs } = resolveStoryAnnotations(
|
|
112
|
+
storyModule,
|
|
113
|
+
options.story.exportName
|
|
114
|
+
);
|
|
113
115
|
|
|
114
|
-
if (typeof
|
|
116
|
+
if (typeof metaComponent !== 'function') {
|
|
115
117
|
throw new Error(
|
|
116
|
-
`Unable to prerender story "${options.story.id}". Missing
|
|
118
|
+
`Unable to prerender story "${options.story.id}". Missing component in ${options.story.importPath}.`
|
|
117
119
|
);
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
// Build-time prerender only supports stories that keep the meta-level Astro component.
|
|
121
|
-
if (
|
|
123
|
+
if (storyComponent && storyComponent !== metaComponent) {
|
|
122
124
|
return undefined;
|
|
123
125
|
}
|
|
124
126
|
|
|
125
|
-
const storyArgs = mergeMetaArgsWithStoryArgs(
|
|
126
|
-
toRecord(defaultStoryMeta.args),
|
|
127
|
-
toRecord(selectedStoryExport.args)
|
|
128
|
-
);
|
|
127
|
+
const storyArgs = mergeMetaArgsWithStoryArgs(metaArgs, storyLevelArgs);
|
|
129
128
|
const { componentArgs, storySlots } = separateStorySlots(storyArgs);
|
|
130
129
|
|
|
131
130
|
return options.runtime.renderAstroStory({
|
|
@@ -140,6 +139,43 @@ export async function renderProductionStoryToHtml(options: {
|
|
|
140
139
|
});
|
|
141
140
|
}
|
|
142
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Reads the meta- and story-level component and args for one story export,
|
|
144
|
+
* supporting both authoring styles:
|
|
145
|
+
*
|
|
146
|
+
* - CSF3: the meta is the module's default export and the named export holds the
|
|
147
|
+
* story's own args/component.
|
|
148
|
+
* - CSF4 factories: there is no default export. The named export is a
|
|
149
|
+
* `{ _tag: 'Story', input, meta }` object produced by `meta.story()`, where the
|
|
150
|
+
* meta-level annotations live on `meta.input`.
|
|
151
|
+
*/
|
|
152
|
+
function resolveStoryAnnotations(storyModule: LoadedStoryModule, exportName: string) {
|
|
153
|
+
const rawStoryExport: unknown = storyModule[exportName];
|
|
154
|
+
|
|
155
|
+
if (isRecord(rawStoryExport) && rawStoryExport._tag === 'Story') {
|
|
156
|
+
const storyInput = toRecord(rawStoryExport.input) ?? {};
|
|
157
|
+
const meta = isRecord(rawStoryExport.meta) ? rawStoryExport.meta : {};
|
|
158
|
+
const metaInput = toRecord(meta.input) ?? {};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
metaComponent: metaInput.component,
|
|
162
|
+
metaArgs: toRecord(metaInput.args),
|
|
163
|
+
storyComponent: storyInput.component,
|
|
164
|
+
storyLevelArgs: toRecord(storyInput.args)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const defaultStoryMeta = isRecord(storyModule.default) ? storyModule.default : {};
|
|
169
|
+
const selectedStoryExport = isRecord(rawStoryExport) ? rawStoryExport : {};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
metaComponent: defaultStoryMeta.component,
|
|
173
|
+
metaArgs: toRecord(defaultStoryMeta.args),
|
|
174
|
+
storyComponent: selectedStoryExport.component,
|
|
175
|
+
storyLevelArgs: toRecord(selectedStoryExport.args)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
function resolveProjectImportPath(importPath: string, resolveFrom: string) {
|
|
144
180
|
if (importPath.startsWith('./') || importPath.startsWith('../')) {
|
|
145
181
|
return resolve(resolveFrom, importPath);
|
|
@@ -158,25 +194,6 @@ function mergeMetaArgsWithStoryArgs(
|
|
|
158
194
|
};
|
|
159
195
|
}
|
|
160
196
|
|
|
161
|
-
function separateStorySlots(storyArgs: Record<string, unknown>) {
|
|
162
|
-
const componentArgs = { ...storyArgs };
|
|
163
|
-
const storySlots = componentArgs.slots;
|
|
164
|
-
|
|
165
|
-
delete componentArgs.slots;
|
|
166
|
-
|
|
167
|
-
if (!isRecord(storySlots)) {
|
|
168
|
-
return {
|
|
169
|
-
componentArgs,
|
|
170
|
-
storySlots: {}
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
componentArgs,
|
|
176
|
-
storySlots: storySlots as Record<string, string>
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
197
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
181
198
|
return typeof value === 'object' && value !== null;
|
|
182
199
|
}
|
package/src/storySsrVite.ts
CHANGED
|
@@ -253,17 +253,34 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
253
253
|
return typeof value === 'object' && value !== null;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
// Stubs Storybook's browser-only packages so
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
256
|
+
// Stubs Storybook's browser-only docs packages so a project's preview config
|
|
257
|
+
// doesn't crash the SSR prerender. Stories import `@storybook/preview`, which
|
|
258
|
+
// loads `.storybook/preview.ts`, which commonly registers the docs addon
|
|
259
|
+
// (`import addonDocs from '@storybook/addon-docs'`). The docs addon pulls in
|
|
260
|
+
// Storybook's UI kit, which reads `document.documentElement` at module load and
|
|
261
|
+
// throws `document is not defined` under Node. The docs UI never runs during
|
|
262
|
+
// prerendering — we only need each story's component and args — so replacing it
|
|
263
|
+
// with a no-op is safe.
|
|
264
|
+
//
|
|
265
|
+
// `@storybook/addon-docs`'s default export is called as a function in preview
|
|
266
|
+
// config (`addonDocs()`), so the stub exports a callable no-op. `blocks` are the
|
|
267
|
+
// docs block components (used only inside MDX, which is not prerendered), and
|
|
268
|
+
// `@storybook/blocks` is the pre-Storybook-10 path for those same blocks.
|
|
260
269
|
function createStorybookBrowserStubPlugin(): Plugin {
|
|
270
|
+
const STUBBED_SPECIFIERS = new Set([
|
|
271
|
+
'@storybook/addon-docs',
|
|
272
|
+
'@storybook/addon-docs/blocks',
|
|
273
|
+
'@storybook/blocks'
|
|
274
|
+
]);
|
|
261
275
|
const STUB_ID = '\0storybook-astro-browser-stub';
|
|
262
276
|
|
|
263
277
|
return {
|
|
264
278
|
name: 'storybook-astro:storybook-browser-stubs',
|
|
279
|
+
// Must run before Astro's resolvers, which would otherwise resolve these
|
|
280
|
+
// bare specifiers to their real (browser-only) files before we can stub them.
|
|
281
|
+
enforce: 'pre',
|
|
265
282
|
resolveId(id: string) {
|
|
266
|
-
if (id
|
|
283
|
+
if (STUBBED_SPECIFIERS.has(id)) {
|
|
267
284
|
return STUB_ID;
|
|
268
285
|
}
|
|
269
286
|
|
|
@@ -271,7 +288,7 @@ function createStorybookBrowserStubPlugin(): Plugin {
|
|
|
271
288
|
},
|
|
272
289
|
load(id: string) {
|
|
273
290
|
if (id === STUB_ID) {
|
|
274
|
-
return 'export {};';
|
|
291
|
+
return 'export default () => ({});';
|
|
275
292
|
}
|
|
276
293
|
|
|
277
294
|
return null;
|
|
@@ -6,6 +6,10 @@ import { resolveTestingProjectRoot } from './project-root.ts';
|
|
|
6
6
|
import { runWithWorkingDirectory } from './working-directory.ts';
|
|
7
7
|
import { getComponentModuleId, isAstroComponentFactory, isStorybookAstroClientStub } from './component-utils.ts';
|
|
8
8
|
import { ssrLoadModuleWithFsFallback } from '../lib/ssr-load-module-with-fs-fallback.ts';
|
|
9
|
+
import { separateStorySlots } from '../lib/separate-story-slots.ts';
|
|
10
|
+
import { reconstructProps, reconstructSlots } from '../lib/reconstruct-component-args.ts';
|
|
11
|
+
import { patchCreateAstroCompat, markRawSlots } from '../astroRenderHandler.ts';
|
|
12
|
+
import { serializeAstroComponentMarkers } from '@storybook-astro/renderer/types';
|
|
9
13
|
import type { ComposedStory } from './types.ts';
|
|
10
14
|
import { renderViaTestingRendererDaemon } from './renderer-daemon.ts';
|
|
11
15
|
|
|
@@ -16,7 +20,13 @@ const astroSsrViteServerPromises = new Map<string, Promise<ViteDevServer>>();
|
|
|
16
20
|
|
|
17
21
|
const astroSsrHandlerPromises = new Map<
|
|
18
22
|
string,
|
|
19
|
-
Promise<
|
|
23
|
+
Promise<
|
|
24
|
+
(data: {
|
|
25
|
+
component: string;
|
|
26
|
+
args?: Record<string, unknown>;
|
|
27
|
+
slots?: Record<string, unknown>;
|
|
28
|
+
}) => Promise<string>
|
|
29
|
+
>
|
|
20
30
|
>();
|
|
21
31
|
|
|
22
32
|
const testingIntegrationsCache = new Map<string, StorybookAstroIntegration[]>();
|
|
@@ -115,6 +125,14 @@ async function resolveAstroComponent(component: unknown, resolveFrom: string) {
|
|
|
115
125
|
return resolvedComponent;
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
function setRenderedHtml(html: string) {
|
|
129
|
+
if (typeof document !== 'undefined') {
|
|
130
|
+
document.body.innerHTML = html;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return html;
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
async function renderAstroComponentToDom(
|
|
119
137
|
component: unknown,
|
|
120
138
|
args: Record<string, unknown>,
|
|
@@ -122,21 +140,26 @@ async function renderAstroComponentToDom(
|
|
|
122
140
|
) {
|
|
123
141
|
const moduleId = getComponentModuleId(component);
|
|
124
142
|
|
|
143
|
+
// Split slot content from props, then serialize any Astro component passed as
|
|
144
|
+
// a prop or slot into a moduleId marker. The handler reconstructs each marker —
|
|
145
|
+
// loading the real server component by moduleId — so a story can nest Astro
|
|
146
|
+
// components without the unrenderable client stub leaking through.
|
|
147
|
+
const { componentArgs, storySlots } = separateStorySlots(args);
|
|
148
|
+
const serializedArgs = serializeAstroComponentMarkers(componentArgs) as Record<string, unknown>;
|
|
149
|
+
const serializedSlots = serializeAstroComponentMarkers(storySlots) as Record<string, unknown>;
|
|
150
|
+
|
|
125
151
|
if (moduleId) {
|
|
126
152
|
try {
|
|
127
153
|
// Fast path: reuse a single shared SSR daemon instead of spinning SSR in each worker.
|
|
128
154
|
const html = await renderViaTestingRendererDaemon({
|
|
129
155
|
resolveFrom,
|
|
130
156
|
component: moduleId,
|
|
131
|
-
args
|
|
157
|
+
args: serializedArgs,
|
|
158
|
+
slots: serializedSlots
|
|
132
159
|
});
|
|
133
160
|
|
|
134
161
|
if (typeof html === 'string') {
|
|
135
|
-
|
|
136
|
-
document.body.innerHTML = html;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return html;
|
|
162
|
+
return setRenderedHtml(html);
|
|
140
163
|
}
|
|
141
164
|
} catch {
|
|
142
165
|
// Fall back to in-worker rendering below when daemon render fails.
|
|
@@ -146,14 +169,11 @@ async function renderAstroComponentToDom(
|
|
|
146
169
|
const handler = await getAstroSsrHandler(resolveFrom);
|
|
147
170
|
const html = await handler({
|
|
148
171
|
component: moduleId,
|
|
149
|
-
args
|
|
172
|
+
args: serializedArgs,
|
|
173
|
+
slots: serializedSlots
|
|
150
174
|
});
|
|
151
175
|
|
|
152
|
-
|
|
153
|
-
document.body.innerHTML = html;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return html;
|
|
176
|
+
return setRenderedHtml(html);
|
|
157
177
|
} catch {
|
|
158
178
|
// Fall back to direct Container rendering below
|
|
159
179
|
}
|
|
@@ -161,20 +181,31 @@ async function renderAstroComponentToDom(
|
|
|
161
181
|
|
|
162
182
|
const resolvedComponent = await resolveAstroComponent(component, resolveFrom);
|
|
163
183
|
const container = await getAstroContainer();
|
|
164
|
-
|
|
184
|
+
|
|
165
185
|
if (!container) {
|
|
166
186
|
throw new Error('Failed to initialize Astro container for rendering');
|
|
167
187
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
188
|
+
|
|
189
|
+
// The direct fallback has no handler, so reconstruct nested components here:
|
|
190
|
+
// load each marker's real server module by id and render slot markers to HTML.
|
|
191
|
+
const loadComponent = async (id: string) => {
|
|
192
|
+
const viteServer = await getAstroSsrViteServer(resolveFrom);
|
|
193
|
+
const mod = (await ssrLoadModuleWithFsFallback(viteServer, id)) as Record<string, unknown>;
|
|
194
|
+
|
|
195
|
+
return patchCreateAstroCompat(mod.default);
|
|
196
|
+
};
|
|
197
|
+
const reconstructedArgs = await reconstructProps(serializedArgs, { loadComponent });
|
|
198
|
+
const reconstructedSlots = await reconstructSlots(serializedSlots, {
|
|
199
|
+
loadComponent,
|
|
200
|
+
renderToHtml: (child) => container.renderToString(child, {})
|
|
171
201
|
});
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
203
|
+
const html = await container.renderToString(resolvedComponent, {
|
|
204
|
+
props: reconstructedArgs,
|
|
205
|
+
slots: markRawSlots(reconstructedSlots)
|
|
206
|
+
});
|
|
176
207
|
|
|
177
|
-
return html;
|
|
208
|
+
return setRenderedHtml(html);
|
|
178
209
|
}
|
|
179
210
|
|
|
180
211
|
async function renderComposedStory(story: ComposedStory) {
|
|
@@ -33,7 +33,12 @@ export function vitePluginAstroComponentMarker(): PluginOption {
|
|
|
33
33
|
// Only process main .astro modules (not sub-modules like ?astro&type=style)
|
|
34
34
|
if (!id.endsWith('.astro')) {return null;}
|
|
35
35
|
|
|
36
|
-
// Detect the Astro
|
|
36
|
+
// Detect the Astro client-side stub pattern. Astro 6's Go compiler and
|
|
37
|
+
// Astro 7's Rust compiler (now the default) both emit this same error
|
|
38
|
+
// string in the browser stub, so the single check covers Astro 5–7. If a
|
|
39
|
+
// future compiler changes this text, components will silently miss the
|
|
40
|
+
// `isAstroComponentFactory = true` marker and render blank — update the
|
|
41
|
+
// string here if that happens.
|
|
37
42
|
if (!code.includes('Astro components cannot be used in the browser')) {return null;}
|
|
38
43
|
|
|
39
44
|
const moduleId = id;
|