@storybook-astro/framework 1.2.0 → 1.3.0-canary.1
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/{base-IRZo3zgK.d.ts → base-DT67T5pi.d.ts} +1 -0
- package/dist/{chunk-T7NWIO5S.js → chunk-2EABPTOY.js} +5 -5
- package/dist/{chunk-PJEDXZVN.js → chunk-7YBE4TTI.js} +2 -1
- package/dist/chunk-7YBE4TTI.js.map +1 -0
- package/dist/{chunk-POHTFYST.js → chunk-AYYMNFI6.js} +104 -6
- package/dist/chunk-AYYMNFI6.js.map +1 -0
- package/dist/chunk-B5HHF6FC.js +116 -0
- package/dist/chunk-B5HHF6FC.js.map +1 -0
- package/dist/chunk-H3XZHW6Z.js +1402 -0
- package/dist/chunk-H3XZHW6Z.js.map +1 -0
- package/dist/{chunk-DNGQBPT7.js → chunk-PUTCAN6X.js} +5 -2
- package/dist/{chunk-DNGQBPT7.js.map → chunk-PUTCAN6X.js.map} +1 -1
- package/dist/{chunk-OUEDTRBO.js → chunk-TWAO2IQW.js} +229 -67
- package/dist/chunk-TWAO2IQW.js.map +1 -0
- package/dist/{chunk-4SWPVM6R.js → chunk-WUTCMEF5.js} +2 -2
- package/dist/index.d.ts +17 -7
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/integrations/index.d.ts +2 -1
- package/dist/integrations/index.js +1 -1
- package/dist/middleware.js +18 -131
- package/dist/middleware.js.map +1 -1
- package/dist/{types-C-jan6Px.d.ts → preset-BvgHg2of.d.ts} +8 -11
- package/dist/preset.d.ts +2 -10
- package/dist/preset.js +5 -4
- package/dist/renderer/renderer-dev.js +62 -0
- package/dist/renderer/renderer-dev.js.map +1 -0
- package/dist/renderer/renderer-server.js +92 -0
- package/dist/renderer/renderer-server.js.map +1 -0
- package/dist/renderer/renderer-static.js +54 -0
- package/dist/renderer/renderer-static.js.map +1 -0
- package/dist/testing.js +12 -11
- package/dist/testing.js.map +1 -1
- package/dist/{viteStorybookAstroMiddlewarePlugin-2EFKTECT.js → viteStorybookAstroMiddlewarePlugin-UB6ZLJ4B.js} +4 -3
- package/dist/vitest/global-setup.js +6 -5
- package/dist/vitest/global-setup.js.map +1 -1
- package/dist/vitest/index.d.ts +1 -1
- package/dist/vitest/index.js +3 -3
- package/package.json +14 -43
- package/src/astroImageService.ts +57 -0
- package/src/astroRenderHandler.ts +203 -0
- package/src/importAstroConfig.ts +1 -1
- package/src/index.ts +2 -0
- package/src/integrations/alpine.ts +1 -0
- package/src/integrations/base.ts +6 -0
- package/src/middleware.ts +29 -200
- package/src/module-mocks.ts +153 -5
- package/src/preset.ts +38 -8
- package/src/productionRenderRuntime.ts +187 -0
- package/src/rules.test.ts +52 -4
- package/src/rules.ts +54 -7
- package/src/server/index.ts +101 -31
- package/src/storyRulesRuntime.ts +34 -0
- package/src/storySsrVite.ts +240 -0
- package/src/types.ts +0 -9
- package/src/virtual.d.ts +17 -3
- package/src/vite/{astroFilesVirtualModulePlugin.ts → astroFilesPlugin.ts} +4 -4
- package/src/vite/sanitizeConfigPlugin.ts +18 -0
- package/src/vite/{storybookAstroServerAuthConfigVirtualModulePlugin.test.ts → serverAuthPlugin.test.ts} +7 -10
- package/src/vite/{storybookAstroServerAuthConfigVirtualModulePlugin.ts → serverAuthPlugin.ts} +6 -9
- package/src/vite/serverRuntimePlugin.ts +109 -0
- package/src/vite/{storybookAstroRulesConfigVirtualModulePlugin.ts → storyRulesPlugin.ts} +6 -7
- package/src/vite/{createVirtualModulePlugin.test.ts → virtualModulePlugin.test.ts} +5 -5
- package/src/vite/{createVirtualModulePlugin.ts → virtualModulePlugin.ts} +2 -2
- package/src/viteAstroContainerRenderersPlugin.ts +72 -2
- package/src/vitePluginAstroBuildPrerender.ts +75 -646
- package/src/vitePluginAstroBuildServer.ts +217 -165
- package/src/vitePluginAstroBuildShared.test.ts +87 -0
- package/src/vitePluginAstroBuildShared.ts +465 -0
- package/src/vitePluginStoryModuleMocks.ts +29 -0
- package/src/viteStorybookAstroMiddlewarePlugin.ts +8 -0
- package/src/viteStorybookAstroRendererPlugin.ts +13 -6
- package/src/viteStorybookRendererFallbackPlugin.ts +2 -2
- package/dist/chunk-OUEDTRBO.js.map +0 -1
- package/dist/chunk-PBISP7PA.js +0 -1137
- package/dist/chunk-PBISP7PA.js.map +0 -1
- package/dist/chunk-PJEDXZVN.js.map +0 -1
- package/dist/chunk-POHTFYST.js.map +0 -1
- package/dist/node/index.d.ts +0 -10
- package/dist/node/index.js +0 -10
- package/dist/node/index.js.map +0 -1
- package/src/vite/storybookAstroSanitizationConfigVirtualModulePlugin.ts +0 -21
- /package/dist/{chunk-T7NWIO5S.js.map → chunk-2EABPTOY.js.map} +0 -0
- /package/dist/{chunk-4SWPVM6R.js.map → chunk-WUTCMEF5.js.map} +0 -0
- /package/dist/{viteStorybookAstroMiddlewarePlugin-2EFKTECT.js.map → viteStorybookAstroMiddlewarePlugin-UB6ZLJ4B.js.map} +0 -0
package/src/preset.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
1
2
|
import type { StorybookConfigVite, FrameworkOptions } from './types.ts';
|
|
2
3
|
import { vitePluginStorybookAstroMiddleware } from './viteStorybookAstroMiddlewarePlugin.ts';
|
|
3
4
|
import { viteStorybookRendererFallbackPlugin } from './viteStorybookRendererFallbackPlugin.ts';
|
|
@@ -23,15 +24,19 @@ export const core = {
|
|
|
23
24
|
renderer: import.meta.resolve('@storybook-astro/renderer')
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
export const viteFinal: StorybookConfigVite['viteFinal'] = async (config,
|
|
27
|
-
const
|
|
27
|
+
export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storybookOptions) => {
|
|
28
|
+
const { configType, presets, configDir } = storybookOptions;
|
|
29
|
+
const frameworkOptions = await presets.apply<FrameworkOptions>('frameworkOptions');
|
|
30
|
+
const options = {
|
|
31
|
+
...frameworkOptions,
|
|
32
|
+
resolveFrom: frameworkOptions.resolveFrom ?? dirname(configDir)
|
|
33
|
+
} satisfies FrameworkOptions;
|
|
28
34
|
|
|
29
35
|
if (!config.plugins) {
|
|
30
36
|
config.plugins = [];
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
const integrations = options.integrations ?? [];
|
|
34
|
-
const resolveFrom = options.resolveFrom ?? process.cwd();
|
|
35
40
|
const renderMode = options.renderMode ?? 'server';
|
|
36
41
|
const mode = configType === 'DEVELOPMENT' ? 'development' : 'production';
|
|
37
42
|
const command = configType === 'DEVELOPMENT' ? 'serve' : 'build';
|
|
@@ -54,7 +59,7 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
|
|
|
54
59
|
vitePluginAstroComponentMarker() as any,
|
|
55
60
|
vitePluginAstroIntegrationOptsFallback(),
|
|
56
61
|
vitePluginAstroToolbarFallback(),
|
|
57
|
-
vitePluginAstroVueFallback()
|
|
62
|
+
vitePluginAstroVueFallback()
|
|
58
63
|
);
|
|
59
64
|
|
|
60
65
|
if (configType === 'DEVELOPMENT') {
|
|
@@ -97,7 +102,13 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
|
|
|
97
102
|
aliases['react-dom'] = 'react-dom';
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
const finalConfig = await mergeWithAstroConfig(
|
|
105
|
+
const finalConfig = await mergeWithAstroConfig(
|
|
106
|
+
config,
|
|
107
|
+
integrations,
|
|
108
|
+
options.resolveFrom,
|
|
109
|
+
mode,
|
|
110
|
+
command
|
|
111
|
+
);
|
|
101
112
|
|
|
102
113
|
// Exclude Astro integration packages from dependency optimization because
|
|
103
114
|
// they import virtual modules that esbuild cannot resolve.
|
|
@@ -107,7 +118,17 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
|
|
|
107
118
|
if (!finalConfig.optimizeDeps.exclude) {
|
|
108
119
|
finalConfig.optimizeDeps.exclude = [];
|
|
109
120
|
}
|
|
110
|
-
for (const pkg of [
|
|
121
|
+
for (const pkg of [
|
|
122
|
+
'@astrojs/vue',
|
|
123
|
+
'@astrojs/vue/client.js',
|
|
124
|
+
'@astrojs/vue/server.js',
|
|
125
|
+
'@astrojs/react',
|
|
126
|
+
'@astrojs/react/client.js',
|
|
127
|
+
'@astrojs/react/server.js',
|
|
128
|
+
'@astrojs/preact',
|
|
129
|
+
'@astrojs/preact/client.js',
|
|
130
|
+
'@astrojs/preact/server.js'
|
|
131
|
+
]) {
|
|
111
132
|
if (!finalConfig.optimizeDeps.exclude.includes(pkg)) {
|
|
112
133
|
finalConfig.optimizeDeps.exclude.push(pkg);
|
|
113
134
|
}
|
|
@@ -121,6 +142,16 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
|
|
|
121
142
|
if (!finalConfig.optimizeDeps.exclude.includes('@storybook-astro/renderer')) {
|
|
122
143
|
finalConfig.optimizeDeps.exclude.push('@storybook-astro/renderer');
|
|
123
144
|
}
|
|
145
|
+
// fsevents is a macOS-only native chokidar dep with a .node binary that
|
|
146
|
+
// esbuild's prebundler can't load. storybook/internal/preview-api can pass
|
|
147
|
+
// through the transform pipeline twice when used by CSF Next portable
|
|
148
|
+
// stories, producing a duplicate __vite__injectQuery import in the
|
|
149
|
+
// generated chunk; excluding it from prebundling collapses the duplicate.
|
|
150
|
+
for (const pkg of ['fsevents', 'storybook/internal/preview-api']) {
|
|
151
|
+
if (!finalConfig.optimizeDeps.exclude.includes(pkg)) {
|
|
152
|
+
finalConfig.optimizeDeps.exclude.push(pkg);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
124
155
|
// Mark integration virtual modules as external so the dep bundler doesn't
|
|
125
156
|
// try to resolve them (they are Vite virtual modules with no real package).
|
|
126
157
|
// Set both esbuildOptions (Vite ≤7) and rolldownOptions (Vite 8+, Rolldown)
|
|
@@ -146,8 +177,7 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, { conf
|
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
179
|
|
|
149
|
-
// Vite 8+
|
|
150
|
-
// Use a loose cast because rolldownOptions is absent from Vite <8 types.
|
|
180
|
+
// Vite 8+ uses Rolldown for dependency optimization.
|
|
151
181
|
const optimizeDepsMut = finalConfig.optimizeDeps as Record<string, unknown>;
|
|
152
182
|
const rolldownOpts = (optimizeDepsMut.rolldownOptions ?? {}) as { external?: string[] };
|
|
153
183
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createAstroRenderHandler, type HandlerProps } from './astroRenderHandler.ts';
|
|
3
|
+
import type { Integration } from './integrations/index.ts';
|
|
4
|
+
import type { SanitizationOptions } from './lib/sanitization.ts';
|
|
5
|
+
import {
|
|
6
|
+
createClientModuleResolver,
|
|
7
|
+
createProductionAstroContainer,
|
|
8
|
+
createStorySsrViteServer,
|
|
9
|
+
loadRulesConfigModule
|
|
10
|
+
} from './storySsrVite.ts';
|
|
11
|
+
|
|
12
|
+
type LoadedStoryModule = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export type ProductionRenderRuntime = {
|
|
15
|
+
loadModule: (moduleId: string) => Promise<LoadedStoryModule>;
|
|
16
|
+
renderAstroStory: (data: HandlerProps) => Promise<string>;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type ProductionStoryEntry = {
|
|
21
|
+
id: string;
|
|
22
|
+
importPath: string;
|
|
23
|
+
componentPath: string;
|
|
24
|
+
exportName: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ProductionRenderRuntimeOptions = {
|
|
30
|
+
integrations: Integration[];
|
|
31
|
+
sanitization?: SanitizationOptions;
|
|
32
|
+
storyRulesConfigFilePath?: string;
|
|
33
|
+
staticModuleMap: Record<string, string>;
|
|
34
|
+
trackedSpecifiers: Set<string>;
|
|
35
|
+
resolveFrom: string;
|
|
36
|
+
resolveComponentId?: (id: string) => string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Creates the shared SSR runtime used by both build-time prerendering and the standalone render server. */
|
|
40
|
+
export async function createProductionRenderRuntime(
|
|
41
|
+
options: ProductionRenderRuntimeOptions
|
|
42
|
+
): Promise<ProductionRenderRuntime> {
|
|
43
|
+
const viteServer = await createStorySsrViteServer({
|
|
44
|
+
integrations: options.integrations,
|
|
45
|
+
trackedSpecifiers: options.trackedSpecifiers,
|
|
46
|
+
resolveFrom: options.resolveFrom
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const rulesConfigModule = await loadRulesConfigModule(
|
|
51
|
+
viteServer,
|
|
52
|
+
options.storyRulesConfigFilePath
|
|
53
|
+
);
|
|
54
|
+
const resolveClientModule = createClientModuleResolver(
|
|
55
|
+
options.integrations,
|
|
56
|
+
options.staticModuleMap
|
|
57
|
+
);
|
|
58
|
+
const astroContainer = await createProductionAstroContainer({
|
|
59
|
+
integrations: options.integrations,
|
|
60
|
+
resolveClientModule,
|
|
61
|
+
viteServer
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const loadModule = async (moduleId: string) => {
|
|
65
|
+
return (await viteServer.ssrLoadModule(
|
|
66
|
+
options.resolveComponentId?.(moduleId) ?? moduleId
|
|
67
|
+
)) as LoadedStoryModule;
|
|
68
|
+
};
|
|
69
|
+
const renderAstroStory = createAstroRenderHandler({
|
|
70
|
+
container: astroContainer,
|
|
71
|
+
sanitization: options.sanitization,
|
|
72
|
+
rulesConfigFilePath: options.storyRulesConfigFilePath,
|
|
73
|
+
resolveRulesConfigModule: () => rulesConfigModule,
|
|
74
|
+
loadModule: async (moduleId: string) => {
|
|
75
|
+
const loadedModule = await loadModule(moduleId);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
default: loadedModule.default
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
invalidateModuleGraph: () => {
|
|
82
|
+
viteServer.moduleGraph.invalidateAll();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
loadModule,
|
|
88
|
+
renderAstroStory,
|
|
89
|
+
close: () => viteServer.close()
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await viteServer.close();
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Loads one Astro story module, merges its args, and renders the Astro component to HTML. */
|
|
98
|
+
export async function renderProductionStoryToHtml(options: {
|
|
99
|
+
story: ProductionStoryEntry;
|
|
100
|
+
runtime: ProductionRenderRuntime;
|
|
101
|
+
resolveFrom: string;
|
|
102
|
+
}) {
|
|
103
|
+
const storyModulePath = resolveProjectImportPath(options.story.importPath, options.resolveFrom);
|
|
104
|
+
const componentPath = resolveProjectImportPath(options.story.componentPath, options.resolveFrom);
|
|
105
|
+
const storyModule = await options.runtime.loadModule(storyModulePath);
|
|
106
|
+
const defaultStoryMeta = isRecord(storyModule.default) ? storyModule.default : {};
|
|
107
|
+
const selectedStoryExport = isRecord(storyModule[options.story.exportName])
|
|
108
|
+
? storyModule[options.story.exportName]
|
|
109
|
+
: {};
|
|
110
|
+
|
|
111
|
+
if (typeof defaultStoryMeta.component !== 'function') {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Unable to prerender story "${options.story.id}". Missing default export component in ${options.story.importPath}.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build-time prerender only supports stories that keep the meta-level Astro component.
|
|
118
|
+
if (selectedStoryExport.component && selectedStoryExport.component !== defaultStoryMeta.component) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const storyArgs = mergeMetaArgsWithStoryArgs(
|
|
123
|
+
toRecord(defaultStoryMeta.args),
|
|
124
|
+
toRecord(selectedStoryExport.args)
|
|
125
|
+
);
|
|
126
|
+
const { componentArgs, storySlots } = separateStorySlots(storyArgs);
|
|
127
|
+
|
|
128
|
+
return options.runtime.renderAstroStory({
|
|
129
|
+
component: componentPath,
|
|
130
|
+
args: componentArgs,
|
|
131
|
+
slots: storySlots,
|
|
132
|
+
story: {
|
|
133
|
+
id: options.story.id,
|
|
134
|
+
title: options.story.title,
|
|
135
|
+
name: options.story.name
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resolveProjectImportPath(importPath: string, resolveFrom: string) {
|
|
141
|
+
if (importPath.startsWith('./') || importPath.startsWith('../')) {
|
|
142
|
+
return resolve(resolveFrom, importPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return importPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mergeMetaArgsWithStoryArgs(
|
|
149
|
+
metaArgs: Record<string, unknown> | undefined,
|
|
150
|
+
storyArgs: Record<string, unknown> | undefined
|
|
151
|
+
) {
|
|
152
|
+
return {
|
|
153
|
+
...(metaArgs ?? {}),
|
|
154
|
+
...(storyArgs ?? {})
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function separateStorySlots(storyArgs: Record<string, unknown>) {
|
|
159
|
+
const componentArgs = { ...storyArgs };
|
|
160
|
+
const storySlots = componentArgs.slots;
|
|
161
|
+
|
|
162
|
+
delete componentArgs.slots;
|
|
163
|
+
|
|
164
|
+
if (!isRecord(storySlots)) {
|
|
165
|
+
return {
|
|
166
|
+
componentArgs,
|
|
167
|
+
storySlots: {}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
componentArgs,
|
|
173
|
+
storySlots: storySlots as Record<string, string>
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === 'object' && value !== null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
182
|
+
if (!isRecord(value)) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return value;
|
|
187
|
+
}
|
package/src/rules.test.ts
CHANGED
|
@@ -13,6 +13,10 @@ function createRulesConfig(config: StoryRulesConfig) {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function getMockReplacement(selection: Awaited<ReturnType<typeof selectStoryRules>>, specifier: string) {
|
|
17
|
+
return selection.moduleMocks.get(specifier)?.replacement;
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
describe('story rules', () => {
|
|
17
21
|
test('returns an empty selection when no rules are configured', async () => {
|
|
18
22
|
const selection = await selectStoryRules({
|
|
@@ -43,7 +47,7 @@ describe('story rules', () => {
|
|
|
43
47
|
}
|
|
44
48
|
});
|
|
45
49
|
|
|
46
|
-
expect(selection
|
|
50
|
+
expect(getMockReplacement(selection, '~/lib/api')).toBe('~/lib/api.mock');
|
|
47
51
|
expect(selection.cleanups).toHaveLength(0);
|
|
48
52
|
});
|
|
49
53
|
|
|
@@ -66,7 +70,7 @@ describe('story rules', () => {
|
|
|
66
70
|
}
|
|
67
71
|
});
|
|
68
72
|
|
|
69
|
-
expect(selection
|
|
73
|
+
expect(getMockReplacement(selection, '~/service')).toBe('~/service.mock');
|
|
70
74
|
});
|
|
71
75
|
|
|
72
76
|
test('matches rules against /story/<id> style story identifiers', async () => {
|
|
@@ -86,7 +90,31 @@ describe('story rules', () => {
|
|
|
86
90
|
}
|
|
87
91
|
});
|
|
88
92
|
|
|
89
|
-
expect(selection
|
|
93
|
+
expect(getMockReplacement(selection, '~/store')).toBe('~/store.mock');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('supports inline factory module mocks', async () => {
|
|
97
|
+
const selection = await selectStoryRules({
|
|
98
|
+
configModule: createRulesConfig({
|
|
99
|
+
rules: [
|
|
100
|
+
{
|
|
101
|
+
match: '*',
|
|
102
|
+
use: ({ mock }) => {
|
|
103
|
+
mock('~/lib/api', () => ({
|
|
104
|
+
fetchUser: async () => ({ id: 1, name: 'Storybook User' })
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}),
|
|
110
|
+
story: {
|
|
111
|
+
id: 'components-card--default'
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(getMockReplacement(selection, '~/lib/api')).toMatch(
|
|
116
|
+
/^virtual:storybook-astro-inline-module:/
|
|
117
|
+
);
|
|
90
118
|
});
|
|
91
119
|
|
|
92
120
|
test('collects cleanup functions from matching rules', async () => {
|
|
@@ -207,7 +235,7 @@ describe('story rules', () => {
|
|
|
207
235
|
}
|
|
208
236
|
});
|
|
209
237
|
|
|
210
|
-
expect(selection
|
|
238
|
+
expect(getMockReplacement(selection, '~/lib/api')).toBe(
|
|
211
239
|
resolve('/repo/.storybook', './mocks/api.ts').replaceAll('\\', '/')
|
|
212
240
|
);
|
|
213
241
|
});
|
|
@@ -251,4 +279,24 @@ describe('story rules', () => {
|
|
|
251
279
|
'Story rule mock replacement uses a relative path, but rules config path is unavailable.'
|
|
252
280
|
);
|
|
253
281
|
});
|
|
282
|
+
|
|
283
|
+
test('throws when a mock factory returns a non-object value', async () => {
|
|
284
|
+
await expect(
|
|
285
|
+
selectStoryRules({
|
|
286
|
+
configModule: createRulesConfig({
|
|
287
|
+
rules: [
|
|
288
|
+
{
|
|
289
|
+
match: '*',
|
|
290
|
+
use: ({ mock }) => {
|
|
291
|
+
mock('~/lib/api', () => 'bad' as never);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
]
|
|
295
|
+
}),
|
|
296
|
+
story: {
|
|
297
|
+
id: 'components-card--default'
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
).rejects.toThrow('Story rule mock factory must return an object of module exports.');
|
|
301
|
+
});
|
|
254
302
|
});
|
package/src/rules.ts
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
createInlineStoryModuleMock,
|
|
4
|
+
createPathStoryModuleMock,
|
|
5
|
+
type StoryModuleMockEntry,
|
|
6
|
+
type StoryModuleMockFactoryResult
|
|
7
|
+
} from './module-mocks.ts';
|
|
2
8
|
import type { RenderStoryInput } from './types.ts';
|
|
3
9
|
|
|
4
10
|
export type StoryRuleCleanup = () => void | Promise<void>;
|
|
5
11
|
type StoryRuleUseResult = void | StoryRuleCleanup | Promise<void | StoryRuleCleanup>;
|
|
6
12
|
|
|
13
|
+
export type StoryRuleMockFactory =
|
|
14
|
+
() => StoryModuleMockFactoryResult | Promise<StoryModuleMockFactoryResult>;
|
|
15
|
+
|
|
16
|
+
export type StoryRuleMock = {
|
|
17
|
+
(specifier: string, replacement: string): void;
|
|
18
|
+
(specifier: string, factory: StoryRuleMockFactory): void;
|
|
19
|
+
};
|
|
20
|
+
|
|
7
21
|
export type StoryRuleUseContext = {
|
|
8
22
|
story: StoryRuleStory;
|
|
9
|
-
mock:
|
|
23
|
+
mock: StoryRuleMock;
|
|
10
24
|
};
|
|
11
25
|
|
|
12
26
|
export type StoryRuleUse = (context: StoryRuleUseContext) => StoryRuleUseResult;
|
|
@@ -34,12 +48,12 @@ export type StoryRuleSelectionInput = {
|
|
|
34
48
|
};
|
|
35
49
|
|
|
36
50
|
export type StoryRuleSelection = {
|
|
37
|
-
moduleMocks: Map<string,
|
|
51
|
+
moduleMocks: Map<string, StoryModuleMockEntry>;
|
|
38
52
|
cleanups: StoryRuleCleanup[];
|
|
39
53
|
};
|
|
40
54
|
|
|
41
55
|
type MutableStoryRuleSelection = {
|
|
42
|
-
moduleMocks: Map<string,
|
|
56
|
+
moduleMocks: Map<string, StoryModuleMockEntry>;
|
|
43
57
|
cleanups: StoryRuleCleanup[];
|
|
44
58
|
};
|
|
45
59
|
|
|
@@ -62,20 +76,45 @@ export async function selectStoryRules(
|
|
|
62
76
|
const uses = Array.isArray(rule.use) ? rule.use : [rule.use];
|
|
63
77
|
|
|
64
78
|
for (const use of uses) {
|
|
79
|
+
const pendingModuleMocks: Promise<void>[] = [];
|
|
80
|
+
|
|
65
81
|
if (typeof use !== 'function') {
|
|
66
82
|
throw new Error('Each story rule "use" entry must be a function.');
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
const cleanup = await use({
|
|
70
86
|
story,
|
|
71
|
-
mock: (specifier,
|
|
87
|
+
mock: ((specifier, replacementOrFactory) => {
|
|
72
88
|
const normalizedSpecifier = normalizeMockSpecifier(specifier);
|
|
73
|
-
const normalizedReplacement = normalizeMockReplacement(replacement, input.configFilePath);
|
|
74
89
|
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
if (typeof replacementOrFactory === 'function') {
|
|
91
|
+
pendingModuleMocks.push(
|
|
92
|
+
Promise.resolve(replacementOrFactory()).then((exportsObject) => {
|
|
93
|
+
selection.moduleMocks.set(
|
|
94
|
+
normalizedSpecifier,
|
|
95
|
+
createInlineStoryModuleMock(normalizeMockFactoryResult(exportsObject))
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return undefined;
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const normalizedReplacement = normalizeMockReplacement(
|
|
106
|
+
replacementOrFactory,
|
|
107
|
+
input.configFilePath
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
selection.moduleMocks.set(normalizedSpecifier, createPathStoryModuleMock(normalizedReplacement));
|
|
111
|
+
}) as StoryRuleMock
|
|
77
112
|
});
|
|
78
113
|
|
|
114
|
+
if (pendingModuleMocks.length > 0) {
|
|
115
|
+
await Promise.all(pendingModuleMocks);
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
if (cleanup !== undefined) {
|
|
80
119
|
if (typeof cleanup !== 'function') {
|
|
81
120
|
throw new Error('Story rule "use" must return either nothing or a cleanup function.');
|
|
@@ -331,6 +370,14 @@ function normalizeMockReplacement(value: unknown, configFilePath?: string): stri
|
|
|
331
370
|
return normalizedValue;
|
|
332
371
|
}
|
|
333
372
|
|
|
373
|
+
function normalizeMockFactoryResult(value: unknown): StoryModuleMockFactoryResult {
|
|
374
|
+
if (!isRecord(value)) {
|
|
375
|
+
throw new Error('Story rule mock factory must return an object of module exports.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
|
|
334
381
|
function slugify(input: string): string {
|
|
335
382
|
return input
|
|
336
383
|
.trim()
|
package/src/server/index.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
|
+
/// <reference path="../virtual.d.ts" />
|
|
2
|
+
|
|
1
3
|
import { timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { resolve, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
2
6
|
import { Hono } from 'hono';
|
|
3
7
|
import { cors } from 'hono/cors';
|
|
4
|
-
import type { HandlerProps } from '../
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import sanitization from 'virtual:storybook-astro-sanitization-config';
|
|
8
|
-
import storyRulesConfigModule, {
|
|
9
|
-
storybookAstroStoryRulesConfigFilePath
|
|
10
|
-
} from 'virtual:storybook-astro-story-rules-config';
|
|
8
|
+
import type { HandlerProps } from '../astroRenderHandler.ts';
|
|
9
|
+
import { createProductionRenderRuntime } from '../productionRenderRuntime.ts';
|
|
10
|
+
import sanitization from 'virtual:storybook-astro/sanitize-config';
|
|
11
11
|
import {
|
|
12
12
|
storybookAstroServerAuthHeader,
|
|
13
13
|
storybookAstroServerAuthToken
|
|
14
|
-
} from 'virtual:storybook-astro
|
|
14
|
+
} from 'virtual:storybook-astro/server-auth';
|
|
15
|
+
import {
|
|
16
|
+
integrations,
|
|
17
|
+
runtimeConfig
|
|
18
|
+
} from 'virtual:storybook-astro/server-runtime';
|
|
15
19
|
|
|
16
20
|
const app = new Hono();
|
|
17
|
-
const
|
|
21
|
+
const renderAstroStoryPromise = createAstroStoryRenderer();
|
|
18
22
|
|
|
19
23
|
app.use(
|
|
20
24
|
'*',
|
|
@@ -33,46 +37,62 @@ app.post('/render', async (context) => {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
const input = (await context.req.json()) as Partial<HandlerProps>;
|
|
36
|
-
const
|
|
37
|
-
const html = await
|
|
40
|
+
const renderAstroStory = await renderAstroStoryPromise;
|
|
41
|
+
const html = await renderAstroStory({
|
|
38
42
|
component: input.component ?? '',
|
|
39
43
|
args: input.args ?? {},
|
|
40
44
|
slots: input.slots ?? {},
|
|
41
45
|
story: input.story
|
|
42
46
|
});
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
// The server runtime renders against source modules, then rewrites the HTML
|
|
49
|
+
// so the browser only sees built asset URLs and matching stylesheets.
|
|
50
|
+
return context.text(addStaticStylesheets(rewriteBuiltModulePaths(html)));
|
|
45
51
|
});
|
|
46
52
|
|
|
47
53
|
export default app;
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
/** Creates the server-mode Astro story renderer from the shared production runtime. */
|
|
56
|
+
async function createAstroStoryRenderer() {
|
|
57
|
+
const snapshotRoot = resolve(
|
|
58
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
59
|
+
runtimeConfig.snapshotDirName
|
|
60
|
+
);
|
|
61
|
+
const storyRulesConfigFilePath = runtimeConfig.storyRulesConfigRelativePath
|
|
62
|
+
? resolve(snapshotRoot, runtimeConfig.storyRulesConfigRelativePath)
|
|
63
|
+
: undefined;
|
|
64
|
+
const runtime = await createProductionRenderRuntime({
|
|
65
|
+
integrations,
|
|
51
66
|
sanitization: sanitization ?? undefined,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
throw new Error(
|
|
59
|
-
`Unable to resolve Astro component "${componentId}" in the server build output.`
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
default: component
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
+
storyRulesConfigFilePath,
|
|
68
|
+
staticModuleMap: runtimeConfig.staticModuleMap,
|
|
69
|
+
trackedSpecifiers: new Set(runtimeConfig.trackedSpecifiers),
|
|
70
|
+
resolveFrom: snapshotRoot,
|
|
71
|
+
resolveComponentId: (componentId: string) =>
|
|
72
|
+
resolveSnapshotComponentPath(snapshotRoot, componentId)
|
|
67
73
|
});
|
|
74
|
+
|
|
75
|
+
return runtime.renderAstroStory;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolves one original component id to the copied file inside the runtime snapshot. */
|
|
79
|
+
function resolveSnapshotComponentPath(snapshotRoot: string, componentId: string) {
|
|
80
|
+
const snapshotComponentPath = runtimeConfig.componentPathMap[componentId];
|
|
81
|
+
|
|
82
|
+
if (snapshotComponentPath) {
|
|
83
|
+
return resolve(snapshotRoot, snapshotComponentPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return componentId;
|
|
68
87
|
}
|
|
69
88
|
|
|
89
|
+
/** Checks the incoming auth header against the configured render-server token. */
|
|
70
90
|
function isRequestAuthorized(headerValue: string | undefined) {
|
|
71
91
|
if (!storybookAstroServerAuthToken) {
|
|
72
92
|
return true;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
const normalizedHeaderValue =
|
|
95
|
+
const normalizedHeaderValue = normalizeAuthHeaderValue(headerValue);
|
|
76
96
|
|
|
77
97
|
if (!normalizedHeaderValue) {
|
|
78
98
|
return false;
|
|
@@ -81,7 +101,8 @@ function isRequestAuthorized(headerValue: string | undefined) {
|
|
|
81
101
|
return isSecureEqual(normalizedHeaderValue, storybookAstroServerAuthToken);
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
|
|
104
|
+
/** Normalizes auth header values so bearer and raw token formats compare the same way. */
|
|
105
|
+
function normalizeAuthHeaderValue(value: string | undefined) {
|
|
85
106
|
if (!value) {
|
|
86
107
|
return undefined;
|
|
87
108
|
}
|
|
@@ -99,6 +120,7 @@ function normalizeHeaderValue(value: string | undefined) {
|
|
|
99
120
|
return trimmedValue;
|
|
100
121
|
}
|
|
101
122
|
|
|
123
|
+
/** Compares auth tokens without leaking length-matched timing differences. */
|
|
102
124
|
function isSecureEqual(actual: string, expected: string) {
|
|
103
125
|
const actualBuffer = Buffer.from(actual);
|
|
104
126
|
const expectedBuffer = Buffer.from(expected);
|
|
@@ -109,3 +131,51 @@ function isSecureEqual(actual: string, expected: string) {
|
|
|
109
131
|
|
|
110
132
|
return timingSafeEqual(actualBuffer, expectedBuffer);
|
|
111
133
|
}
|
|
134
|
+
|
|
135
|
+
/** Rewrites source module paths in rendered HTML to the built asset paths emitted by Storybook. */
|
|
136
|
+
function rewriteBuiltModulePaths(html: string) {
|
|
137
|
+
let output = html;
|
|
138
|
+
const entries = Object.entries(runtimeConfig.staticModuleMap).sort(
|
|
139
|
+
([left], [right]) => right.length - left.length
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
for (const [sourcePath, builtPath] of entries) {
|
|
143
|
+
output = output.split(sourcePath).join(builtPath);
|
|
144
|
+
output = output.split(toFsPath(sourcePath)).join(builtPath);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return output;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Prepends stylesheet links for any built framework chunks referenced by the rendered HTML. */
|
|
151
|
+
function addStaticStylesheets(html: string) {
|
|
152
|
+
const stylesheets = new Set<string>();
|
|
153
|
+
|
|
154
|
+
for (const [sourcePath, cssPaths] of Object.entries(runtimeConfig.staticCssMap)) {
|
|
155
|
+
const builtModulePath = runtimeConfig.staticModuleMap[sourcePath];
|
|
156
|
+
|
|
157
|
+
// Match either the original source path or the rewritten built module URL.
|
|
158
|
+
if (!html.includes(sourcePath) && (!builtModulePath || !html.includes(builtModulePath))) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
cssPaths.forEach((cssPath) => stylesheets.add(cssPath));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (stylesheets.size === 0) {
|
|
166
|
+
return html;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const stylesheetTags = Array.from(stylesheets)
|
|
170
|
+
.map((href) => `<link rel="stylesheet" href="${href}">`)
|
|
171
|
+
.join('');
|
|
172
|
+
|
|
173
|
+
return `${stylesheetTags}${html}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Converts one source file path into the Vite /@fs/ URL form used during SSR. */
|
|
177
|
+
function toFsPath(sourcePath: string) {
|
|
178
|
+
const normalizedPath = sourcePath.replace(/\\/g, '/');
|
|
179
|
+
|
|
180
|
+
return normalizedPath.startsWith('/') ? `/@fs${normalizedPath}` : `/@fs/${normalizedPath}`;
|
|
181
|
+
}
|