@storybook-astro/framework 1.2.0 → 1.3.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/{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-OUEDTRBO.js → chunk-B454DGX6.js} +259 -67
- package/dist/chunk-B454DGX6.js.map +1 -0
- package/dist/chunk-B5HHF6FC.js +116 -0
- package/dist/chunk-B5HHF6FC.js.map +1 -0
- package/dist/chunk-CU57AJUW.js +1402 -0
- package/dist/chunk-CU57AJUW.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-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 +205 -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/lib/revive-dates.test.ts +106 -0
- package/src/lib/revive-dates.ts +51 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { withStoryModuleMocks } from './module-mocks.ts';
|
|
2
|
+
import { selectStoryRules, withStoryRuleCleanups, type StoryRuleSelection } from './rules.ts';
|
|
3
|
+
import type { RenderStoryInput } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export type ResolveRulesConfigModule = () => unknown | Promise<unknown>;
|
|
6
|
+
|
|
7
|
+
type RunWithStoryRulesOptions = {
|
|
8
|
+
story?: RenderStoryInput;
|
|
9
|
+
rulesConfigFilePath?: string;
|
|
10
|
+
resolveRulesConfigModule?: ResolveRulesConfigModule;
|
|
11
|
+
invalidateModuleGraph?: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function runWithStoryRules<T>(
|
|
15
|
+
options: RunWithStoryRulesOptions,
|
|
16
|
+
callback: (selection: StoryRuleSelection) => Promise<T>
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const rulesConfigModule = options.resolveRulesConfigModule
|
|
19
|
+
? await options.resolveRulesConfigModule()
|
|
20
|
+
: undefined;
|
|
21
|
+
const selectedRules = await selectStoryRules({
|
|
22
|
+
configModule: rulesConfigModule,
|
|
23
|
+
configFilePath: options.rulesConfigFilePath,
|
|
24
|
+
story: options.story
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (selectedRules.moduleMocks.size > 0) {
|
|
28
|
+
options.invalidateModuleGraph?.();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return withStoryRuleCleanups(selectedRules.cleanups, async () => {
|
|
32
|
+
return withStoryModuleMocks(selectedRules.moduleMocks, async () => callback(selectedRules));
|
|
33
|
+
});
|
|
34
|
+
}
|