@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.
Files changed (87) hide show
  1. package/dist/{base-IRZo3zgK.d.ts → base-DT67T5pi.d.ts} +1 -0
  2. package/dist/{chunk-T7NWIO5S.js → chunk-2EABPTOY.js} +5 -5
  3. package/dist/{chunk-PJEDXZVN.js → chunk-7YBE4TTI.js} +2 -1
  4. package/dist/chunk-7YBE4TTI.js.map +1 -0
  5. package/dist/{chunk-POHTFYST.js → chunk-AYYMNFI6.js} +104 -6
  6. package/dist/chunk-AYYMNFI6.js.map +1 -0
  7. package/dist/{chunk-OUEDTRBO.js → chunk-B454DGX6.js} +259 -67
  8. package/dist/chunk-B454DGX6.js.map +1 -0
  9. package/dist/chunk-B5HHF6FC.js +116 -0
  10. package/dist/chunk-B5HHF6FC.js.map +1 -0
  11. package/dist/chunk-CU57AJUW.js +1402 -0
  12. package/dist/chunk-CU57AJUW.js.map +1 -0
  13. package/dist/{chunk-DNGQBPT7.js → chunk-PUTCAN6X.js} +5 -2
  14. package/dist/{chunk-DNGQBPT7.js.map → chunk-PUTCAN6X.js.map} +1 -1
  15. package/dist/{chunk-4SWPVM6R.js → chunk-WUTCMEF5.js} +2 -2
  16. package/dist/index.d.ts +17 -7
  17. package/dist/index.js +6 -5
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/index.d.ts +2 -1
  20. package/dist/integrations/index.js +1 -1
  21. package/dist/middleware.js +18 -131
  22. package/dist/middleware.js.map +1 -1
  23. package/dist/{types-C-jan6Px.d.ts → preset-BvgHg2of.d.ts} +8 -11
  24. package/dist/preset.d.ts +2 -10
  25. package/dist/preset.js +5 -4
  26. package/dist/renderer/renderer-dev.js +62 -0
  27. package/dist/renderer/renderer-dev.js.map +1 -0
  28. package/dist/renderer/renderer-server.js +92 -0
  29. package/dist/renderer/renderer-server.js.map +1 -0
  30. package/dist/renderer/renderer-static.js +54 -0
  31. package/dist/renderer/renderer-static.js.map +1 -0
  32. package/dist/testing.js +12 -11
  33. package/dist/testing.js.map +1 -1
  34. package/dist/{viteStorybookAstroMiddlewarePlugin-2EFKTECT.js → viteStorybookAstroMiddlewarePlugin-UB6ZLJ4B.js} +4 -3
  35. package/dist/vitest/global-setup.js +6 -5
  36. package/dist/vitest/global-setup.js.map +1 -1
  37. package/dist/vitest/index.d.ts +1 -1
  38. package/dist/vitest/index.js +3 -3
  39. package/package.json +14 -43
  40. package/src/astroImageService.ts +57 -0
  41. package/src/astroRenderHandler.ts +205 -0
  42. package/src/importAstroConfig.ts +1 -1
  43. package/src/index.ts +2 -0
  44. package/src/integrations/alpine.ts +1 -0
  45. package/src/integrations/base.ts +6 -0
  46. package/src/lib/revive-dates.test.ts +106 -0
  47. package/src/lib/revive-dates.ts +51 -0
  48. package/src/middleware.ts +29 -200
  49. package/src/module-mocks.ts +153 -5
  50. package/src/preset.ts +38 -8
  51. package/src/productionRenderRuntime.ts +187 -0
  52. package/src/rules.test.ts +52 -4
  53. package/src/rules.ts +54 -7
  54. package/src/server/index.ts +101 -31
  55. package/src/storyRulesRuntime.ts +34 -0
  56. package/src/storySsrVite.ts +240 -0
  57. package/src/types.ts +0 -9
  58. package/src/virtual.d.ts +17 -3
  59. package/src/vite/{astroFilesVirtualModulePlugin.ts → astroFilesPlugin.ts} +4 -4
  60. package/src/vite/sanitizeConfigPlugin.ts +18 -0
  61. package/src/vite/{storybookAstroServerAuthConfigVirtualModulePlugin.test.ts → serverAuthPlugin.test.ts} +7 -10
  62. package/src/vite/{storybookAstroServerAuthConfigVirtualModulePlugin.ts → serverAuthPlugin.ts} +6 -9
  63. package/src/vite/serverRuntimePlugin.ts +109 -0
  64. package/src/vite/{storybookAstroRulesConfigVirtualModulePlugin.ts → storyRulesPlugin.ts} +6 -7
  65. package/src/vite/{createVirtualModulePlugin.test.ts → virtualModulePlugin.test.ts} +5 -5
  66. package/src/vite/{createVirtualModulePlugin.ts → virtualModulePlugin.ts} +2 -2
  67. package/src/viteAstroContainerRenderersPlugin.ts +72 -2
  68. package/src/vitePluginAstroBuildPrerender.ts +75 -646
  69. package/src/vitePluginAstroBuildServer.ts +217 -165
  70. package/src/vitePluginAstroBuildShared.test.ts +87 -0
  71. package/src/vitePluginAstroBuildShared.ts +465 -0
  72. package/src/vitePluginStoryModuleMocks.ts +29 -0
  73. package/src/viteStorybookAstroMiddlewarePlugin.ts +8 -0
  74. package/src/viteStorybookAstroRendererPlugin.ts +13 -6
  75. package/src/viteStorybookRendererFallbackPlugin.ts +2 -2
  76. package/dist/chunk-OUEDTRBO.js.map +0 -1
  77. package/dist/chunk-PBISP7PA.js +0 -1137
  78. package/dist/chunk-PBISP7PA.js.map +0 -1
  79. package/dist/chunk-PJEDXZVN.js.map +0 -1
  80. package/dist/chunk-POHTFYST.js.map +0 -1
  81. package/dist/node/index.d.ts +0 -10
  82. package/dist/node/index.js +0 -10
  83. package/dist/node/index.js.map +0 -1
  84. package/src/vite/storybookAstroSanitizationConfigVirtualModulePlugin.ts +0 -21
  85. /package/dist/{chunk-T7NWIO5S.js.map → chunk-2EABPTOY.js.map} +0 -0
  86. /package/dist/{chunk-4SWPVM6R.js.map → chunk-WUTCMEF5.js.map} +0 -0
  87. /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.moduleMocks.get('~/lib/api')).toBe('~/lib/api.mock');
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.moduleMocks.get('~/service')).toBe('~/service.mock');
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.moduleMocks.get('~/store')).toBe('~/store.mock');
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.moduleMocks.get('~/lib/api')).toBe(
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: (specifier: string, replacement: string) => void;
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, string>;
51
+ moduleMocks: Map<string, StoryModuleMockEntry>;
38
52
  cleanups: StoryRuleCleanup[];
39
53
  };
40
54
 
41
55
  type MutableStoryRuleSelection = {
42
- moduleMocks: Map<string, 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, replacement) => {
87
+ mock: ((specifier, replacementOrFactory) => {
72
88
  const normalizedSpecifier = normalizeMockSpecifier(specifier);
73
- const normalizedReplacement = normalizeMockReplacement(replacement, input.configFilePath);
74
89
 
75
- selection.moduleMocks.set(normalizedSpecifier, normalizedReplacement);
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()
@@ -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 '../middleware.ts';
5
- import { handlerFactory } from '../middleware.ts';
6
- import astroFiles from 'virtual:astro-files';
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-server-auth-config';
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 renderHandlerPromise = createRenderHandler();
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 renderHandler = await renderHandlerPromise;
37
- const html = await renderHandler({
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
- return context.text(html);
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
- async function createRenderHandler() {
50
- return handlerFactory([], {
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
- rulesConfigFilePath: storybookAstroStoryRulesConfigFilePath,
53
- resolveRulesConfigModule: () => storyRulesConfigModule,
54
- loadModule: async (componentId) => {
55
- const component = astroFiles[componentId as keyof typeof astroFiles];
56
-
57
- if (!component) {
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 = normalizeHeaderValue(headerValue);
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
- function normalizeHeaderValue(value: string | undefined) {
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
+ }