@storybook-astro/framework 0.1.0-beta.9 → 1.0.0

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 (92) hide show
  1. package/README.md +38 -0
  2. package/dist/base-IRZo3zgK.d.ts +23 -0
  3. package/dist/chunk-4SWPVM6R.js +96 -0
  4. package/dist/chunk-4SWPVM6R.js.map +1 -0
  5. package/dist/chunk-5EF25G5S.js +69 -0
  6. package/dist/chunk-5EF25G5S.js.map +1 -0
  7. package/dist/chunk-7GHEQUPV.js +439 -0
  8. package/dist/chunk-7GHEQUPV.js.map +1 -0
  9. package/dist/chunk-C5OH4VBR.js +492 -0
  10. package/dist/chunk-C5OH4VBR.js.map +1 -0
  11. package/dist/chunk-DNGQBPT7.js +15 -0
  12. package/dist/chunk-DNGQBPT7.js.map +1 -0
  13. package/dist/chunk-E4LB75JN.js +89 -0
  14. package/dist/chunk-E4LB75JN.js.map +1 -0
  15. package/dist/chunk-PJEDXZVN.js +240 -0
  16. package/dist/chunk-PJEDXZVN.js.map +1 -0
  17. package/dist/chunk-UK43WNEA.js +657 -0
  18. package/dist/chunk-UK43WNEA.js.map +1 -0
  19. package/dist/dist-HJOEPVRQ.js +15574 -0
  20. package/dist/dist-HJOEPVRQ.js.map +1 -0
  21. package/dist/index.d.ts +42 -0
  22. package/dist/index.js +13 -64
  23. package/dist/index.js.map +1 -1
  24. package/dist/integrations/index.d.ts +138 -0
  25. package/dist/integrations/index.js +8 -196
  26. package/dist/integrations/index.js.map +1 -1
  27. package/dist/middleware.d.ts +26 -0
  28. package/dist/middleware.js +179 -0
  29. package/dist/middleware.js.map +1 -0
  30. package/dist/portable-stories-BvdaQigq.d.ts +83 -0
  31. package/dist/preset.d.ts +14 -0
  32. package/dist/preset.js +5 -1
  33. package/dist/testing.d.ts +27 -0
  34. package/dist/testing.js +324 -15539
  35. package/dist/testing.js.map +1 -1
  36. package/dist/types-CHTsRtA7.d.ts +42 -0
  37. package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js +11 -0
  38. package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js.map +1 -0
  39. package/dist/vitest/index.d.ts +19 -0
  40. package/dist/vitest/index.js +229 -0
  41. package/dist/vitest/index.js.map +1 -0
  42. package/package.json +31 -17
  43. package/src/importAstroConfig.ts +11 -0
  44. package/src/index.ts +20 -6
  45. package/src/integrations/alpine.ts +5 -2
  46. package/src/integrations/base.ts +2 -2
  47. package/src/integrations/moduleResolver.ts +43 -0
  48. package/src/integrations/preact.ts +5 -2
  49. package/src/integrations/react.ts +5 -2
  50. package/src/integrations/solid.ts +5 -2
  51. package/src/integrations/svelte.ts +5 -2
  52. package/src/integrations/vue.ts +5 -2
  53. package/src/lib/sanitization.test.ts +232 -0
  54. package/src/lib/sanitization.ts +338 -0
  55. package/src/lib/ssr-load-module-with-fs-fallback.ts +29 -0
  56. package/src/middleware.test.ts +48 -0
  57. package/src/middleware.ts +204 -96
  58. package/src/module-mocks.ts +16 -0
  59. package/src/msw-helpers.ts +1 -0
  60. package/src/msw.ts +58 -0
  61. package/src/preset.ts +38 -3
  62. package/src/rules-options.test.ts +71 -0
  63. package/src/rules-options.ts +87 -0
  64. package/src/rules.test.ts +183 -0
  65. package/src/rules.ts +314 -0
  66. package/src/testing/astro-runtime.ts +219 -0
  67. package/src/testing/component-utils.ts +32 -0
  68. package/src/testing/index.ts +2 -0
  69. package/src/testing/integration-config.ts +121 -0
  70. package/src/testing/project-root.ts +185 -0
  71. package/src/testing/renderer-daemon.ts +269 -0
  72. package/src/testing/story-composition.ts +33 -0
  73. package/src/testing/types.ts +14 -0
  74. package/src/testing/working-directory.ts +28 -0
  75. package/src/testing.ts +1 -254
  76. package/src/types.ts +16 -4
  77. package/src/virtual.d.ts +2 -1
  78. package/src/vite/createVirtualModulePlugin.test.ts +80 -0
  79. package/src/vite/createVirtualModulePlugin.ts +25 -0
  80. package/src/viteAstroContainerRenderersPlugin.ts +60 -26
  81. package/src/vitePluginAstro.ts +12 -5
  82. package/src/vitePluginAstroBuildPrerender.ts +665 -204
  83. package/src/vitePluginAstroRoutesFallback.ts +37 -0
  84. package/src/vitePluginAstroVueFallback.ts +47 -0
  85. package/src/viteStorybookAstroMiddlewarePlugin.ts +88 -12
  86. package/src/viteStorybookRendererFallbackPlugin.ts +13 -23
  87. package/src/vitest/config.ts +95 -0
  88. package/src/vitest/global-setup.ts +16 -0
  89. package/src/vitest/index.ts +2 -0
  90. package/src/vitest/vite-plugins.ts +187 -0
  91. package/dist/chunk-KTGNRGDJ.js +0 -561
  92. package/dist/chunk-KTGNRGDJ.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { Integration } from './base.ts';
2
2
  import type { Options as VueOptions } from '@vitejs/plugin-vue';
3
3
  import type { Options as VueJsxOptions } from '@vitejs/plugin-vue-jsx';
4
+ import { importModule } from './moduleResolver.ts';
4
5
 
5
6
  export type Options = Pick<VueOptions, 'include' | 'exclude'> & {
6
7
  jsx?: boolean | VueJsxOptions;
@@ -37,8 +38,10 @@ export class VueIntegration implements Integration {
37
38
  }
38
39
  }
39
40
 
40
- async loadIntegration() {
41
- const framework = await import('@astrojs/vue');
41
+ async loadIntegration(resolveFrom = process.cwd()) {
42
+ const framework = await importModule<{
43
+ default: (options: Options) => Awaited<ReturnType<Integration['loadIntegration']>>;
44
+ }>('@astrojs/vue', resolveFrom);
42
45
 
43
46
  return framework.default(this.options);
44
47
  }
@@ -0,0 +1,232 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { resolveSanitizationOptions, sanitizeRenderPayload } from './sanitization.ts';
3
+
4
+ describe('sanitization', () => {
5
+ test('enables sanitization and sanitizes all slots by default', () => {
6
+ const options = resolveSanitizationOptions();
7
+
8
+ expect(options.enabled).toBe(true);
9
+ expect(options.args).toEqual([]);
10
+ expect(options.slots).toEqual(['**']);
11
+
12
+ const payload = sanitizeRenderPayload(
13
+ {
14
+ args: {
15
+ title: '<b>Leave args as-is</b><script>alert(1)</script>'
16
+ },
17
+ slots: {
18
+ default: '<p>Hello<script>alert(1)</script></p>'
19
+ }
20
+ },
21
+ options
22
+ );
23
+
24
+ expect(payload.args.title).toBe('<b>Leave args as-is</b><script>alert(1)</script>');
25
+ expect(payload.slots.default).toBe('<p>Hello</p>');
26
+ });
27
+
28
+ test('sanitizes only configured arg paths', () => {
29
+ const options = resolveSanitizationOptions({
30
+ args: ['content'],
31
+ slots: []
32
+ });
33
+
34
+ const payload = sanitizeRenderPayload(
35
+ {
36
+ args: {
37
+ content: '<p>Hello</p><script>alert(1)</script>',
38
+ title: '<b>Keep me</b><script>alert(1)</script>'
39
+ },
40
+ slots: {}
41
+ },
42
+ options
43
+ );
44
+
45
+ expect(payload.args.content).toBe('<p>Hello</p>');
46
+ expect(payload.args.title).toBe('<b>Keep me</b><script>alert(1)</script>');
47
+ });
48
+
49
+ test('supports wildcard patterns for nested values', () => {
50
+ const options = resolveSanitizationOptions({
51
+ args: ['items.*.html'],
52
+ slots: []
53
+ });
54
+
55
+ const payload = sanitizeRenderPayload(
56
+ {
57
+ args: {
58
+ items: [{ html: '<img class="hero" src="x" onerror="alert(1)"><p>Safe</p>' }]
59
+ },
60
+ slots: {}
61
+ },
62
+ options
63
+ );
64
+
65
+ expect(payload.args.items).toEqual([{ html: '<img class="hero" src="x" /><p>Safe</p>' }]);
66
+ });
67
+
68
+ test('returns payload untouched when sanitization is disabled', () => {
69
+ const options = resolveSanitizationOptions({
70
+ enabled: false,
71
+ args: ['content']
72
+ });
73
+
74
+ const payload = {
75
+ args: {
76
+ content: '<p>Hello</p><script>alert(1)</script>'
77
+ },
78
+ slots: {
79
+ default: '<p>Body<script>alert(1)</script></p>'
80
+ }
81
+ };
82
+
83
+ const sanitizedPayload = sanitizeRenderPayload(payload, options);
84
+
85
+ expect(sanitizedPayload).toBe(payload);
86
+ });
87
+
88
+ test('merges sanitize-html object options with defaults', () => {
89
+ const options = resolveSanitizationOptions({
90
+ sanitizeHtml: {
91
+ allowedAttributes: {
92
+ section: ['data-foo']
93
+ }
94
+ }
95
+ });
96
+
97
+ expect(options.sanitizeHtml.allowedAttributes).toMatchObject({
98
+ a: ['href', 'name', 'target', 'rel'],
99
+ section: ['data-foo']
100
+ });
101
+ });
102
+
103
+ test('merges sanitize-html classes and styles options', () => {
104
+ const options = resolveSanitizationOptions({
105
+ sanitizeHtml: {
106
+ allowedClasses: {
107
+ p: ['prose']
108
+ },
109
+ allowedStyles: {
110
+ '*': {
111
+ color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/]
112
+ }
113
+ }
114
+ }
115
+ });
116
+
117
+ expect(options.sanitizeHtml.allowedClasses).toMatchObject({
118
+ p: ['prose']
119
+ });
120
+
121
+ expect(options.sanitizeHtml.allowedStyles).toMatchObject({
122
+ '*': {
123
+ color: [expect.any(RegExp)]
124
+ }
125
+ });
126
+ });
127
+
128
+ test('rejects invalid path lists', () => {
129
+ expect(() =>
130
+ resolveSanitizationOptions({
131
+ args: ['ok', ' ']
132
+ })
133
+ ).toThrow('framework.options.sanitization.args[1] cannot be an empty string.');
134
+ });
135
+
136
+ test('rejects non-array path list values', () => {
137
+ expect(() =>
138
+ resolveSanitizationOptions({
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
+ args: 'content' as any
141
+ })
142
+ ).toThrow('framework.options.sanitization.args must be an array of dot-path patterns.');
143
+ });
144
+
145
+ test('rejects non-string entries in path lists', () => {
146
+ expect(() =>
147
+ resolveSanitizationOptions({
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ args: ['ok', 1 as any]
150
+ })
151
+ ).toThrow('framework.options.sanitization.args[1] must be a string.');
152
+ });
153
+
154
+ test('supports ** suffix matching and leaves non-matching paths untouched', () => {
155
+ const options = resolveSanitizationOptions({
156
+ args: ['**.html'],
157
+ slots: []
158
+ });
159
+
160
+ const payload = sanitizeRenderPayload(
161
+ {
162
+ args: {
163
+ items: [
164
+ {
165
+ nested: {
166
+ html: '<p>Safe<script>alert(1)</script></p>'
167
+ },
168
+ content: '<p>Leave<script>alert(1)</script></p>'
169
+ }
170
+ ]
171
+ },
172
+ slots: {}
173
+ },
174
+ options
175
+ );
176
+
177
+ const items = payload.args.items as Array<{ nested: { html: string }; content: string }>;
178
+
179
+ expect(items[0].nested.html).toBe('<p>Safe</p>');
180
+ expect(items[0].content).toBe('<p>Leave<script>alert(1)</script></p>');
181
+ });
182
+
183
+ test('does not sanitize when path is shorter than pattern and keeps non-string values', () => {
184
+ const options = resolveSanitizationOptions({
185
+ args: ['title.html'],
186
+ slots: []
187
+ });
188
+
189
+ const regexValue = /hello/i;
190
+
191
+ const payload = sanitizeRenderPayload(
192
+ {
193
+ args: {
194
+ title: '<p>Keep<script>alert(1)</script></p>',
195
+ count: 1,
196
+ truthy: true,
197
+ pattern: regexValue
198
+ },
199
+ slots: {}
200
+ },
201
+ options
202
+ );
203
+
204
+ expect(payload.args.title).toBe('<p>Keep<script>alert(1)</script></p>');
205
+ expect(payload.args.count).toBe(1);
206
+ expect(payload.args.truthy).toBe(true);
207
+ expect(payload.args.pattern).toBe(regexValue);
208
+ });
209
+
210
+ test('supports null-prototype records during traversal', () => {
211
+ const options = resolveSanitizationOptions({
212
+ args: ['meta.html'],
213
+ slots: []
214
+ });
215
+
216
+ const meta = Object.create(null) as Record<string, unknown>;
217
+
218
+ meta.html = '<p>Safe<script>alert(1)</script></p>';
219
+
220
+ const payload = sanitizeRenderPayload(
221
+ {
222
+ args: {
223
+ meta
224
+ },
225
+ slots: {}
226
+ },
227
+ options
228
+ );
229
+
230
+ expect((payload.args.meta as Record<string, unknown>).html).toBe('<p>Safe</p>');
231
+ });
232
+ });
@@ -0,0 +1,338 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import type { IOptions } from 'sanitize-html';
3
+
4
+ type SanitizationPayload = {
5
+ args: Record<string, unknown>;
6
+ slots: Record<string, unknown>;
7
+ };
8
+
9
+ export type SanitizationOptions = {
10
+ enabled?: boolean;
11
+ args?: string[];
12
+ slots?: string[];
13
+ sanitizeHtml?: IOptions;
14
+ };
15
+
16
+ export type ResolvedSanitizationOptions = {
17
+ enabled: boolean;
18
+ args: string[];
19
+ slots: string[];
20
+ sanitizeHtml: IOptions;
21
+ };
22
+
23
+ const DEFAULT_SANITIZE_HTML_OPTIONS: IOptions = {
24
+ allowedTags: [
25
+ 'a',
26
+ 'abbr',
27
+ 'b',
28
+ 'blockquote',
29
+ 'br',
30
+ 'caption',
31
+ 'cite',
32
+ 'code',
33
+ 'col',
34
+ 'colgroup',
35
+ 'dd',
36
+ 'details',
37
+ 'dfn',
38
+ 'div',
39
+ 'dl',
40
+ 'dt',
41
+ 'em',
42
+ 'figcaption',
43
+ 'figure',
44
+ 'h1',
45
+ 'h2',
46
+ 'h3',
47
+ 'h4',
48
+ 'h5',
49
+ 'h6',
50
+ 'hr',
51
+ 'i',
52
+ 'img',
53
+ 'kbd',
54
+ 'li',
55
+ 'mark',
56
+ 'ol',
57
+ 'p',
58
+ 'pre',
59
+ 'q',
60
+ 'rp',
61
+ 'rt',
62
+ 'ruby',
63
+ 's',
64
+ 'samp',
65
+ 'small',
66
+ 'span',
67
+ 'strong',
68
+ 'sub',
69
+ 'summary',
70
+ 'sup',
71
+ 'table',
72
+ 'tbody',
73
+ 'td',
74
+ 'tfoot',
75
+ 'th',
76
+ 'thead',
77
+ 'time',
78
+ 'tr',
79
+ 'u',
80
+ 'ul',
81
+ 'var',
82
+ 'wbr'
83
+ ],
84
+ allowedAttributes: {
85
+ '*': [
86
+ 'aria-describedby',
87
+ 'aria-hidden',
88
+ 'aria-label',
89
+ 'aria-labelledby',
90
+ 'class',
91
+ 'id',
92
+ 'lang',
93
+ 'role',
94
+ 'title'
95
+ ],
96
+ a: ['href', 'name', 'target', 'rel'],
97
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
98
+ td: ['colspan', 'rowspan'],
99
+ th: ['colspan', 'rowspan', 'scope'],
100
+ time: ['datetime']
101
+ },
102
+ allowedSchemes: ['http', 'https', 'mailto', 'tel', 'data'],
103
+ allowedSchemesByTag: {
104
+ a: ['http', 'https', 'mailto', 'tel'],
105
+ img: ['http', 'https', 'data']
106
+ },
107
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'cite', 'srcset'],
108
+ allowProtocolRelative: false,
109
+ disallowedTagsMode: 'discard',
110
+ enforceHtmlBoundary: true,
111
+ parseStyleAttributes: false
112
+ };
113
+
114
+ export function resolveSanitizationOptions(options?: SanitizationOptions): ResolvedSanitizationOptions {
115
+ if (!options) {
116
+ return {
117
+ enabled: true,
118
+ args: [],
119
+ slots: ['**'],
120
+ sanitizeHtml: mergeSanitizeHtmlOptions()
121
+ };
122
+ }
123
+
124
+ const enabled = options.enabled ?? true;
125
+ const args = normalizePathList(options.args, 'framework.options.sanitization.args');
126
+ const slots =
127
+ options.slots === undefined
128
+ ? ['**']
129
+ : normalizePathList(options.slots, 'framework.options.sanitization.slots');
130
+
131
+ return {
132
+ enabled,
133
+ args,
134
+ slots,
135
+ sanitizeHtml: mergeSanitizeHtmlOptions(options.sanitizeHtml)
136
+ };
137
+ }
138
+
139
+ export function sanitizeRenderPayload(
140
+ payload: SanitizationPayload,
141
+ options: ResolvedSanitizationOptions
142
+ ): SanitizationPayload {
143
+ if (!options.enabled) {
144
+ return payload;
145
+ }
146
+
147
+ const sanitizedArgs =
148
+ options.args.length > 0
149
+ ? sanitizeRecord(payload.args, options.args, options.sanitizeHtml)
150
+ : payload.args;
151
+
152
+ const sanitizedSlots =
153
+ options.slots.length > 0
154
+ ? sanitizeRecord(payload.slots, options.slots, options.sanitizeHtml)
155
+ : payload.slots;
156
+
157
+ return {
158
+ args: sanitizedArgs,
159
+ slots: sanitizedSlots
160
+ };
161
+ }
162
+
163
+ function mergeSanitizeHtmlOptions(userOptions?: IOptions): IOptions {
164
+ const merged: IOptions = {
165
+ ...DEFAULT_SANITIZE_HTML_OPTIONS,
166
+ ...userOptions
167
+ };
168
+
169
+ if (
170
+ isRecord(DEFAULT_SANITIZE_HTML_OPTIONS.allowedAttributes) &&
171
+ isRecord(userOptions?.allowedAttributes)
172
+ ) {
173
+ merged.allowedAttributes = {
174
+ ...DEFAULT_SANITIZE_HTML_OPTIONS.allowedAttributes,
175
+ ...userOptions.allowedAttributes
176
+ };
177
+ }
178
+
179
+ if (isRecord(userOptions?.allowedClasses)) {
180
+ merged.allowedClasses = {
181
+ ...(isRecord(DEFAULT_SANITIZE_HTML_OPTIONS.allowedClasses)
182
+ ? DEFAULT_SANITIZE_HTML_OPTIONS.allowedClasses
183
+ : {}),
184
+ ...userOptions.allowedClasses
185
+ };
186
+ }
187
+
188
+ if (isRecord(userOptions?.allowedStyles)) {
189
+ merged.allowedStyles = {
190
+ ...(isRecord(DEFAULT_SANITIZE_HTML_OPTIONS.allowedStyles)
191
+ ? DEFAULT_SANITIZE_HTML_OPTIONS.allowedStyles
192
+ : {}),
193
+ ...userOptions.allowedStyles
194
+ };
195
+ }
196
+
197
+ return merged;
198
+ }
199
+
200
+ function normalizePathList(value: unknown, path: string): string[] {
201
+ if (value === undefined) {
202
+ return [];
203
+ }
204
+
205
+ if (!Array.isArray(value)) {
206
+ throw new Error(`${path} must be an array of dot-path patterns.`);
207
+ }
208
+
209
+ const unique = new Set<string>();
210
+
211
+ value.forEach((entry, index) => {
212
+ if (typeof entry !== 'string') {
213
+ throw new Error(`${path}[${index}] must be a string.`);
214
+ }
215
+
216
+ const normalized = entry.trim();
217
+
218
+ if (!normalized) {
219
+ throw new Error(`${path}[${index}] cannot be an empty string.`);
220
+ }
221
+
222
+ unique.add(normalized);
223
+ });
224
+
225
+ return Array.from(unique);
226
+ }
227
+
228
+ function sanitizeRecord(
229
+ record: Record<string, unknown>,
230
+ patterns: string[],
231
+ options: IOptions
232
+ ): Record<string, unknown> {
233
+ const sanitized: Record<string, unknown> = {};
234
+
235
+ Object.entries(record).forEach(([key, value]) => {
236
+ sanitized[key] = sanitizeValue(value, key, patterns, options);
237
+ });
238
+
239
+ return sanitized;
240
+ }
241
+
242
+ function sanitizeValue(
243
+ value: unknown,
244
+ currentPath: string,
245
+ patterns: string[],
246
+ options: IOptions
247
+ ): unknown {
248
+ if (typeof value === 'string') {
249
+ if (shouldSanitizePath(currentPath, patterns)) {
250
+ return sanitizeHtml(value, options);
251
+ }
252
+
253
+ return value;
254
+ }
255
+
256
+ if (Array.isArray(value)) {
257
+ return value.map((item, index) => {
258
+ const nextPath = `${currentPath}.${index}`;
259
+
260
+ return sanitizeValue(item, nextPath, patterns, options);
261
+ });
262
+ }
263
+
264
+ if (isRecord(value)) {
265
+ const sanitized: Record<string, unknown> = {};
266
+
267
+ Object.entries(value).forEach(([key, nestedValue]) => {
268
+ const nextPath = `${currentPath}.${key}`;
269
+
270
+ sanitized[key] = sanitizeValue(nestedValue, nextPath, patterns, options);
271
+ });
272
+
273
+ return sanitized;
274
+ }
275
+
276
+ return value;
277
+ }
278
+
279
+ function shouldSanitizePath(path: string, patterns: string[]): boolean {
280
+ return patterns.some((pattern) => matchesPathPattern(path, pattern));
281
+ }
282
+
283
+ function matchesPathPattern(path: string, pattern: string): boolean {
284
+ const pathSegments = path.split('.');
285
+ const patternSegments = pattern.split('.');
286
+
287
+ return matchSegments(pathSegments, patternSegments);
288
+ }
289
+
290
+ function matchSegments(pathSegments: string[], patternSegments: string[]): boolean {
291
+ if (patternSegments.length === 0) {
292
+ return pathSegments.length === 0;
293
+ }
294
+
295
+ const [patternHead, ...patternTail] = patternSegments;
296
+
297
+ if (patternHead === '**') {
298
+ if (patternTail.length === 0) {
299
+ return true;
300
+ }
301
+
302
+ for (let index = 0; index <= pathSegments.length; index += 1) {
303
+ const remainingPath = pathSegments.slice(index);
304
+
305
+ if (matchSegments(remainingPath, patternTail)) {
306
+ return true;
307
+ }
308
+ }
309
+
310
+ return false;
311
+ }
312
+
313
+ if (pathSegments.length === 0) {
314
+ return false;
315
+ }
316
+
317
+ const [pathHead, ...pathTail] = pathSegments;
318
+
319
+ if (patternHead === '*' || patternHead === pathHead) {
320
+ return matchSegments(pathTail, patternTail);
321
+ }
322
+
323
+ return false;
324
+ }
325
+
326
+ function isRecord(value: unknown): value is Record<string, unknown> {
327
+ if (typeof value !== 'object' || value === null) {
328
+ return false;
329
+ }
330
+
331
+ if (Array.isArray(value) || value instanceof RegExp) {
332
+ return false;
333
+ }
334
+
335
+ const prototype = Object.getPrototypeOf(value);
336
+
337
+ return prototype === Object.prototype || prototype === null;
338
+ }
@@ -0,0 +1,29 @@
1
+ import type { ViteDevServer } from 'vite';
2
+
3
+ type SsrLoadModuleOptions = {
4
+ fixStacktrace?: boolean;
5
+ };
6
+
7
+ export async function ssrLoadModuleWithFsFallback<TModule = unknown>(
8
+ viteServer: Pick<ViteDevServer, 'ssrLoadModule'>,
9
+ id: string,
10
+ options?: SsrLoadModuleOptions
11
+ ) {
12
+ const ids = [id];
13
+
14
+ if (id.startsWith('/') && !id.startsWith('/@fs/')) {
15
+ ids.push(`/@fs${id}`);
16
+ }
17
+
18
+ let lastError: unknown;
19
+
20
+ for (const candidate of ids) {
21
+ try {
22
+ return await viteServer.ssrLoadModule(candidate, options) as TModule;
23
+ } catch (error) {
24
+ lastError = error;
25
+ }
26
+ }
27
+
28
+ throw lastError;
29
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ describe('middleware', () => {
4
+ describe('Windows absolute path normalization', () => {
5
+ /**
6
+ * Regex pattern used in middleware.ts to detect Windows absolute paths.
7
+ * This pattern matches paths starting with a drive letter (e.g., C:) followed by
8
+ * a forward slash or backslash, indicating a Windows absolute path.
9
+ */
10
+ const windowsPathRegex = /^[a-zA-Z]:[/\\]/;
11
+
12
+ test('detects Windows absolute paths with forward slashes', () => {
13
+ const pathToTest = 'C:/Users/project/Component.astro';
14
+
15
+ expect(windowsPathRegex.test(pathToTest)).toBe(true);
16
+ });
17
+
18
+ test('detects Windows absolute paths with backslashes', () => {
19
+ const pathToTest = 'C:\\Users\\project\\Component.astro';
20
+
21
+ expect(windowsPathRegex.test(pathToTest)).toBe(true);
22
+ });
23
+
24
+ test('ignores Unix absolute paths', () => {
25
+ const unixPath = '/Users/project/Component.astro';
26
+
27
+ expect(windowsPathRegex.test(unixPath)).toBe(false);
28
+ });
29
+
30
+ test('ignores relative paths', () => {
31
+ const relativePath = './Component.astro';
32
+
33
+ expect(windowsPathRegex.test(relativePath)).toBe(false);
34
+ });
35
+
36
+ test('ignores module specifiers', () => {
37
+ const specifier = '@storybook-astro/renderer';
38
+
39
+ expect(windowsPathRegex.test(specifier)).toBe(false);
40
+ });
41
+
42
+ test('ignores file URLs', () => {
43
+ const fileUrl = 'file:///C:/Users/project/Component.astro';
44
+
45
+ expect(windowsPathRegex.test(fileUrl)).toBe(false);
46
+ });
47
+ });
48
+ });