@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,57 @@
|
|
|
1
|
+
export function ensureAstroPassthroughImageService() {
|
|
2
|
+
if (!globalThis.astroAsset) {
|
|
3
|
+
(globalThis as Record<string, unknown>).astroAsset = {};
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
(globalThis.astroAsset as Record<string, unknown>).imageService = {
|
|
7
|
+
propertiesToHash: ['src'],
|
|
8
|
+
validateOptions(options: Record<string, unknown>) {
|
|
9
|
+
return options;
|
|
10
|
+
},
|
|
11
|
+
getURL(options: { src: unknown }) {
|
|
12
|
+
const src = options.src;
|
|
13
|
+
|
|
14
|
+
if (
|
|
15
|
+
src != null &&
|
|
16
|
+
typeof src === 'object' &&
|
|
17
|
+
'src' in src &&
|
|
18
|
+
typeof (src as Record<string, unknown>).src === 'string'
|
|
19
|
+
) {
|
|
20
|
+
return (src as Record<string, unknown>).src as string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return typeof src === 'string' ? src : '';
|
|
24
|
+
},
|
|
25
|
+
getHTMLAttributes(options: Record<string, unknown>) {
|
|
26
|
+
const {
|
|
27
|
+
src,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
format,
|
|
31
|
+
quality,
|
|
32
|
+
densities,
|
|
33
|
+
widths,
|
|
34
|
+
formats,
|
|
35
|
+
layout,
|
|
36
|
+
priority,
|
|
37
|
+
fit,
|
|
38
|
+
position,
|
|
39
|
+
background,
|
|
40
|
+
...attrs
|
|
41
|
+
} = options;
|
|
42
|
+
const srcObject =
|
|
43
|
+
src != null && typeof src === 'object' ? (src as Record<string, unknown>) : null;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...attrs,
|
|
47
|
+
width: width ?? srcObject?.width,
|
|
48
|
+
height: height ?? srcObject?.height,
|
|
49
|
+
loading: (attrs.loading as string | undefined) ?? 'lazy',
|
|
50
|
+
decoding: (attrs.decoding as string | undefined) ?? 'async'
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
getSrcSet() {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
|
2
|
+
import type { SanitizationOptions } from './lib/sanitization.ts';
|
|
3
|
+
import { resolveSanitizationOptions, sanitizeRenderPayload } from './lib/sanitization.ts';
|
|
4
|
+
import { reviveDateStrings } from './lib/revive-dates.ts';
|
|
5
|
+
import { runWithStoryRules, type ResolveRulesConfigModule } from './storyRulesRuntime.ts';
|
|
6
|
+
import type { RenderStoryInput } from './types.ts';
|
|
7
|
+
|
|
8
|
+
type AstroCreateResult = {
|
|
9
|
+
createAstro?: (...args: unknown[]) => unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type AstroComponentFactory = ((
|
|
13
|
+
result: AstroCreateResult,
|
|
14
|
+
props: unknown,
|
|
15
|
+
slots: unknown
|
|
16
|
+
) => unknown) & {
|
|
17
|
+
isAstroComponentFactory?: boolean;
|
|
18
|
+
moduleId?: string;
|
|
19
|
+
propagation?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type HandlerProps = {
|
|
23
|
+
component: string;
|
|
24
|
+
args?: Record<string, unknown>;
|
|
25
|
+
slots?: Record<string, unknown>;
|
|
26
|
+
story?: RenderStoryInput;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type CreateAstroRenderHandlerOptions = {
|
|
30
|
+
container: Awaited<ReturnType<typeof AstroContainer.create>>;
|
|
31
|
+
sanitization?: SanitizationOptions;
|
|
32
|
+
rulesConfigFilePath?: string;
|
|
33
|
+
resolveRulesConfigModule?: ResolveRulesConfigModule;
|
|
34
|
+
loadModule: (id: string) => Promise<{ default: unknown }>;
|
|
35
|
+
invalidateModuleGraph?: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function createAstroRenderHandler(options: CreateAstroRenderHandlerOptions) {
|
|
39
|
+
const sanitizationOptions = resolveSanitizationOptions(options.sanitization);
|
|
40
|
+
const componentCache = new Map<string, Promise<AstroComponentFactory>>();
|
|
41
|
+
let renderQueue = Promise.resolve<void>(undefined);
|
|
42
|
+
|
|
43
|
+
async function loadPatchedComponent(componentId: string, useCache = true) {
|
|
44
|
+
if (!useCache) {
|
|
45
|
+
const { default: component } = await options.loadModule(componentId);
|
|
46
|
+
|
|
47
|
+
return patchCreateAstroCompat(component);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!componentCache.has(componentId)) {
|
|
51
|
+
componentCache.set(
|
|
52
|
+
componentId,
|
|
53
|
+
(async () => {
|
|
54
|
+
const { default: component } = await options.loadModule(componentId);
|
|
55
|
+
|
|
56
|
+
return patchCreateAstroCompat(component);
|
|
57
|
+
})()
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cachedComponent = componentCache.get(componentId);
|
|
62
|
+
|
|
63
|
+
if (!cachedComponent) {
|
|
64
|
+
throw new Error(`Failed to load Astro component: ${componentId}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return await cachedComponent;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
componentCache.delete(componentId);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return async function handler(data: HandlerProps) {
|
|
76
|
+
const executeRender = async () => {
|
|
77
|
+
return runWithStoryRules(
|
|
78
|
+
{
|
|
79
|
+
story: data.story,
|
|
80
|
+
rulesConfigFilePath: options.rulesConfigFilePath,
|
|
81
|
+
resolveRulesConfigModule: options.resolveRulesConfigModule,
|
|
82
|
+
invalidateModuleGraph: options.invalidateModuleGraph
|
|
83
|
+
},
|
|
84
|
+
async (selectedRules) => {
|
|
85
|
+
const patchedComponent = await loadPatchedComponent(
|
|
86
|
+
data.component,
|
|
87
|
+
selectedRules.moduleMocks.size === 0
|
|
88
|
+
);
|
|
89
|
+
const processedArgs = await processImageMetadata(data.args ?? {});
|
|
90
|
+
const revivedArgs = reviveDateStrings(processedArgs);
|
|
91
|
+
const sanitizedPayload = sanitizeRenderPayload(
|
|
92
|
+
{
|
|
93
|
+
args: revivedArgs,
|
|
94
|
+
slots: data.slots ?? {}
|
|
95
|
+
},
|
|
96
|
+
sanitizationOptions
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return options.container.renderToString(
|
|
100
|
+
patchedComponent as Parameters<typeof options.container.renderToString>[0],
|
|
101
|
+
{
|
|
102
|
+
props: sanitizedPayload.args,
|
|
103
|
+
slots: sanitizedPayload.slots
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const resultPromise = renderQueue.then(executeRender, executeRender);
|
|
111
|
+
|
|
112
|
+
renderQueue = resultPromise.then(
|
|
113
|
+
() => undefined,
|
|
114
|
+
() => undefined
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return resultPromise;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function patchCreateAstroCompat(component: unknown): AstroComponentFactory {
|
|
122
|
+
if (typeof component !== 'function') {
|
|
123
|
+
throw new Error('Expected Astro component factory to be a function.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const originalComponent = component as AstroComponentFactory;
|
|
127
|
+
const wrapped = ((result: AstroCreateResult, props: unknown, slots: unknown) => {
|
|
128
|
+
if (result && typeof result.createAstro === 'function') {
|
|
129
|
+
const originalCreateAstro = result.createAstro;
|
|
130
|
+
const runtimeExpectsAstroGlobal = originalCreateAstro.length >= 3;
|
|
131
|
+
|
|
132
|
+
result.createAstro = (...args: unknown[]) => {
|
|
133
|
+
if (args.length === 3 && !runtimeExpectsAstroGlobal) {
|
|
134
|
+
return originalCreateAstro(args[1], args[2]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return originalCreateAstro(...args);
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return originalComponent(result, props, slots);
|
|
142
|
+
}) as AstroComponentFactory;
|
|
143
|
+
|
|
144
|
+
wrapped.isAstroComponentFactory = originalComponent.isAstroComponentFactory;
|
|
145
|
+
wrapped.moduleId = originalComponent.moduleId;
|
|
146
|
+
wrapped.propagation = originalComponent.propagation;
|
|
147
|
+
|
|
148
|
+
return wrapped;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function processImageMetadata(
|
|
152
|
+
args: Record<string, unknown>
|
|
153
|
+
): Promise<Record<string, unknown>> {
|
|
154
|
+
const processed: Record<string, unknown> = {};
|
|
155
|
+
|
|
156
|
+
for (const [key, value] of Object.entries(args)) {
|
|
157
|
+
if (isImageMetadata(value)) {
|
|
158
|
+
// Keep ImageMetadata as an object so Astro's image pipeline still
|
|
159
|
+
// recognizes it as an imported image and skips local path validation.
|
|
160
|
+
processed[key] = value;
|
|
161
|
+
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
processed[key] = await Promise.all(
|
|
167
|
+
value.map(async (item) => {
|
|
168
|
+
if (isImageMetadata(item)) {
|
|
169
|
+
return item;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (isRecord(item)) {
|
|
173
|
+
return processImageMetadata(item);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return item;
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (isRecord(value)) {
|
|
184
|
+
processed[key] = await processImageMetadata(value);
|
|
185
|
+
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
processed[key] = value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return processed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isImageMetadata(value: unknown): value is Record<string, unknown> {
|
|
196
|
+
return (
|
|
197
|
+
isRecord(value) &&
|
|
198
|
+
typeof value.src === 'string' &&
|
|
199
|
+
('width' in value || 'height' in value || 'format' in value)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
204
|
+
return typeof value === 'object' && value !== null;
|
|
205
|
+
}
|
package/src/importAstroConfig.ts
CHANGED
package/src/index.ts
CHANGED
package/src/integrations/base.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type RendererDeclaration = {
|
|
|
13
13
|
|
|
14
14
|
export abstract class Integration {
|
|
15
15
|
abstract readonly name: string;
|
|
16
|
+
// Identifier used to import this integration's factory from
|
|
17
|
+
// `@storybook-astro/framework/integrations` when generating the server
|
|
18
|
+
// runtime module. Defaults to `name` for integrations whose public name
|
|
19
|
+
// matches their factory export. Override when they diverge (e.g. Alpine's
|
|
20
|
+
// `name` is "alpine" but its factory export is `alpinejs`).
|
|
21
|
+
readonly factoryName?: string;
|
|
16
22
|
abstract readonly dependencies: string[];
|
|
17
23
|
abstract readonly options: Record<string | number | symbol, unknown>;
|
|
18
24
|
abstract readonly renderer: RendererDeclaration;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { reviveDateStrings } from './revive-dates.ts';
|
|
3
|
+
|
|
4
|
+
describe('reviveDateStrings', () => {
|
|
5
|
+
test('converts an ISO 8601 date string to a Date object', () => {
|
|
6
|
+
const args = { pubDate: '2025-04-12T00:00:00.000Z' };
|
|
7
|
+
const result = reviveDateStrings(args);
|
|
8
|
+
|
|
9
|
+
expect(result.pubDate).toBeInstanceOf(Date);
|
|
10
|
+
expect((result.pubDate as Date).toISOString()).toBe('2025-04-12T00:00:00.000Z');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('converts dates nested inside objects', () => {
|
|
14
|
+
const args = {
|
|
15
|
+
post: {
|
|
16
|
+
data: {
|
|
17
|
+
pubDate: '2025-04-12T00:00:00.000Z'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const result = reviveDateStrings(args);
|
|
22
|
+
|
|
23
|
+
expect((result.post as Record<string, unknown>)).toMatchObject({
|
|
24
|
+
data: {
|
|
25
|
+
pubDate: expect.any(Date)
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('converts dates inside arrays', () => {
|
|
31
|
+
const args = {
|
|
32
|
+
posts: [
|
|
33
|
+
{ pubDate: '2025-04-12T00:00:00.000Z' },
|
|
34
|
+
{ pubDate: '2025-03-08T00:00:00.000Z' }
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
const result = reviveDateStrings(args);
|
|
38
|
+
const posts = result.posts as Array<{ pubDate: Date }>;
|
|
39
|
+
|
|
40
|
+
expect(posts[0].pubDate).toBeInstanceOf(Date);
|
|
41
|
+
expect(posts[1].pubDate).toBeInstanceOf(Date);
|
|
42
|
+
expect(posts[0].pubDate.toISOString()).toBe('2025-04-12T00:00:00.000Z');
|
|
43
|
+
expect(posts[1].pubDate.toISOString()).toBe('2025-03-08T00:00:00.000Z');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves non-date strings untouched', () => {
|
|
47
|
+
const args = {
|
|
48
|
+
title: 'Hello World',
|
|
49
|
+
description: 'A post about dates',
|
|
50
|
+
empty: ''
|
|
51
|
+
};
|
|
52
|
+
const result = reviveDateStrings(args);
|
|
53
|
+
|
|
54
|
+
expect(result.title).toBe('Hello World');
|
|
55
|
+
expect(result.description).toBe('A post about dates');
|
|
56
|
+
expect(result.empty).toBe('');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('leaves date-only strings untouched (no time component)', () => {
|
|
60
|
+
const args = { date: '2025-04-12' };
|
|
61
|
+
const result = reviveDateStrings(args);
|
|
62
|
+
|
|
63
|
+
expect(result.date).toBe('2025-04-12');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('leaves partial ISO strings untouched', () => {
|
|
67
|
+
const args = {
|
|
68
|
+
noMillis: '2025-04-12T00:00:00Z',
|
|
69
|
+
withOffset: '2025-04-12T00:00:00.000+00:00',
|
|
70
|
+
dateOnly: '2025-04-12'
|
|
71
|
+
};
|
|
72
|
+
const result = reviveDateStrings(args);
|
|
73
|
+
|
|
74
|
+
expect(result.noMillis).toBe('2025-04-12T00:00:00Z');
|
|
75
|
+
expect(result.withOffset).toBe('2025-04-12T00:00:00.000+00:00');
|
|
76
|
+
expect(result.dateOnly).toBe('2025-04-12');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('preserves non-string values', () => {
|
|
80
|
+
const args = {
|
|
81
|
+
count: 42,
|
|
82
|
+
active: true,
|
|
83
|
+
missing: null,
|
|
84
|
+
tags: ['a', 'b']
|
|
85
|
+
};
|
|
86
|
+
const result = reviveDateStrings(args);
|
|
87
|
+
|
|
88
|
+
expect(result.count).toBe(42);
|
|
89
|
+
expect(result.active).toBe(true);
|
|
90
|
+
expect(result.missing).toBe(null);
|
|
91
|
+
expect(result.tags).toEqual(['a', 'b']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('handles an empty args object', () => {
|
|
95
|
+
expect(reviveDateStrings({})).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('round-trips a Date through JSON serialization', () => {
|
|
99
|
+
const original = new Date('2025-06-04T14:30:00.000Z');
|
|
100
|
+
const serialized = JSON.parse(JSON.stringify({ date: original })) as Record<string, unknown>;
|
|
101
|
+
const result = reviveDateStrings(serialized);
|
|
102
|
+
|
|
103
|
+
expect(result.date).toBeInstanceOf(Date);
|
|
104
|
+
expect((result.date as Date).getTime()).toBe(original.getTime());
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revives Date objects that were lost during JSON serialization.
|
|
3
|
+
*
|
|
4
|
+
* When story args travel over Vite HMR (dev) or HTTP (server mode), Date
|
|
5
|
+
* values are serialized by JSON.stringify into ISO 8601 strings like
|
|
6
|
+
* "2025-04-12T00:00:00.000Z". This function walks the args tree and
|
|
7
|
+
* converts those strings back into Date objects so Astro components
|
|
8
|
+
* receive the types they expect.
|
|
9
|
+
*
|
|
10
|
+
* Only the exact format produced by Date.toJSON() is matched
|
|
11
|
+
* (YYYY-MM-DDTHH:mm:ss.sssZ) to minimize false positives.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Matches the exact output of Date.toJSON() / JSON.stringify(date).
|
|
15
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
16
|
+
|
|
17
|
+
export function reviveDateStrings(args: Record<string, unknown>): Record<string, unknown> {
|
|
18
|
+
const revived: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
for (const [key, value] of Object.entries(args)) {
|
|
21
|
+
revived[key] = reviveValue(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return revived;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function reviveValue(value: unknown): unknown {
|
|
28
|
+
if (typeof value === 'string' && ISO_DATE_PATTERN.test(value)) {
|
|
29
|
+
const date = new Date(value);
|
|
30
|
+
|
|
31
|
+
if (!Number.isNaN(date.getTime())) {
|
|
32
|
+
return date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return value.map(reviveValue);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isRecord(value)) {
|
|
43
|
+
return reviveDateStrings(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|