@storybook-astro/framework 1.3.0 → 1.4.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 (36) hide show
  1. package/dist/{chunk-N3WTUD2A.js → chunk-A4DQ67HA.js} +53 -2
  2. package/dist/chunk-A4DQ67HA.js.map +1 -0
  3. package/dist/{chunk-2EABPTOY.js → chunk-BV6V2Z4X.js} +2 -2
  4. package/dist/{chunk-AYYMNFI6.js → chunk-VZXGPM6P.js} +218 -34
  5. package/dist/chunk-VZXGPM6P.js.map +1 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/middleware.js +10 -2
  8. package/dist/middleware.js.map +1 -1
  9. package/dist/node/index.d.ts +1 -1
  10. package/dist/preset.d.ts +1 -1
  11. package/dist/preset.js +39 -21
  12. package/dist/preset.js.map +1 -1
  13. package/dist/testing.js +3 -3
  14. package/dist/{types-BCpJLSTo.d.ts → types--SvYP5Ri.d.ts} +55 -0
  15. package/dist/{viteStorybookAstroMiddlewarePlugin-UB6ZLJ4B.js → viteStorybookAstroMiddlewarePlugin-246I5D3Y.js} +2 -2
  16. package/dist/vitest/global-setup.js +2 -2
  17. package/package.json +3 -2
  18. package/src/lib/resolve-aliased-island.test.ts +150 -0
  19. package/src/lib/resolve-aliased-island.ts +97 -0
  20. package/src/loadUserAstroConfig.ts +59 -0
  21. package/src/middleware.ts +15 -0
  22. package/src/productionRenderRuntime.ts +6 -2
  23. package/src/storySsrVite.ts +15 -2
  24. package/src/types.ts +9 -1
  25. package/src/vitePluginAstro.ts +10 -3
  26. package/src/vitePluginAstroBuildPrerender.ts +4 -1
  27. package/src/vitePluginAstroBuildShared.test.ts +25 -6
  28. package/src/vitePluginAstroBuildShared.ts +25 -12
  29. package/src/vitePluginAstroFonts.test.ts +153 -0
  30. package/src/vitePluginAstroFonts.ts +302 -0
  31. package/src/viteStorybookAstroMiddlewarePlugin.ts +20 -8
  32. package/dist/chunk-AYYMNFI6.js.map +0 -1
  33. package/dist/chunk-N3WTUD2A.js.map +0 -1
  34. package/src/vitePluginAstroFontsFallback.ts +0 -69
  35. /package/dist/{chunk-2EABPTOY.js.map → chunk-BV6V2Z4X.js.map} +0 -0
  36. /package/dist/{viteStorybookAstroMiddlewarePlugin-UB6ZLJ4B.js.map → viteStorybookAstroMiddlewarePlugin-246I5D3Y.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, test } from 'vitest';
@@ -22,7 +22,7 @@ describe('collectHydratedComponentPaths', () => {
22
22
  await writeFile(astroFile, `---\nimport Helper from './Helper.tsx';\n---`);
23
23
  await writeFile(namedOnlyTsx, `export const Helper = () => <div />;`);
24
24
 
25
- const result = await collectHydratedComponentPaths(astroFile);
25
+ const result = await collectHydratedComponentPaths(astroFile, tmpDir);
26
26
 
27
27
  expect(result).not.toContain(namedOnlyTsx.replace(/\\/g, '/'));
28
28
  });
@@ -34,7 +34,7 @@ describe('collectHydratedComponentPaths', () => {
34
34
  await writeFile(astroFile, `---\nimport Button from './Button.tsx';\n---`);
35
35
  await writeFile(defaultTsx, `export default function Button() { return <div />; }`);
36
36
 
37
- const result = await collectHydratedComponentPaths(astroFile);
37
+ const result = await collectHydratedComponentPaths(astroFile, tmpDir);
38
38
 
39
39
  expect(result).toContain(defaultTsx.replace(/\\/g, '/'));
40
40
  });
@@ -48,7 +48,7 @@ describe('collectHydratedComponentPaths', () => {
48
48
  await writeFile(astroFile, `---\nimport Counter from './Counter.svelte';\n---`);
49
49
  await writeFile(svelteFile, `<script>\n let count = 0;\n</script>\n<button>{count}</button>`);
50
50
 
51
- const result = await collectHydratedComponentPaths(astroFile);
51
+ const result = await collectHydratedComponentPaths(astroFile, tmpDir);
52
52
 
53
53
  expect(result).toContain(svelteFile.replace(/\\/g, '/'));
54
54
  });
@@ -64,7 +64,7 @@ describe('collectHydratedComponentPaths', () => {
64
64
  `<script setup>\nconst count = ref(0);\n</script>\n<template><button>{{ count }}</button></template>`
65
65
  );
66
66
 
67
- const result = await collectHydratedComponentPaths(astroFile);
67
+ const result = await collectHydratedComponentPaths(astroFile, tmpDir);
68
68
 
69
69
  expect(result).toContain(vueFile.replace(/\\/g, '/'));
70
70
  });
@@ -80,8 +80,27 @@ describe('collectHydratedComponentPaths', () => {
80
80
 
81
81
  // Don't write missingTsx — resolveLocalImportPath will not find it,
82
82
  // so it never reaches hasDefaultExport. Confirm the result is just empty.
83
- const result = await collectHydratedComponentPaths(astroFile);
83
+ const result = await collectHydratedComponentPaths(astroFile, tmpDir);
84
84
 
85
85
  expect(result).not.toContain(missingTsx.replace(/\\/g, '/'));
86
86
  });
87
+
88
+ test('includes a tsconfig-aliased island (the static build fix)', async () => {
89
+ // Islands imported via path aliases never matched the staticModuleMap because
90
+ // readLocalImportSpecifiers filtered them out before they could become Rollup
91
+ // inputs. This test confirms the alias is resolved and included.
92
+ await mkdir(join(tmpDir, 'src', 'components'), { recursive: true });
93
+ const counterFile = join(tmpDir, 'src', 'components', 'Counter.tsx');
94
+
95
+ await writeFile(counterFile, `export default function Counter() { return null; }`);
96
+ await writeFile(join(tmpDir, 'Island.astro'), `---\nimport Counter from '@/components/Counter';\n---\n<Counter client:visible />`);
97
+ await writeFile(
98
+ join(tmpDir, 'tsconfig.json'),
99
+ JSON.stringify({ compilerOptions: { paths: { '@/*': ['src/*'] } } }, null, 2)
100
+ );
101
+
102
+ const result = await collectHydratedComponentPaths(join(tmpDir, 'Island.astro'), tmpDir);
103
+
104
+ expect(result).toContain(counterFile.replace(/\\/g, '/'));
105
+ });
87
106
  });
@@ -3,6 +3,7 @@ import { access, copyFile, mkdir, readFile, readdir, stat } from 'node:fs/promis
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import type { Rollup } from 'vite';
5
5
  import type { Integration } from './integrations/index.ts';
6
+ import { resolveAliasedIsland } from './lib/resolve-aliased-island.ts';
6
7
 
7
8
  /** Resolves the shared virtual module ids used by both build pipelines. */
8
9
  export function resolveVirtualBuildModuleId(id: string) {
@@ -77,7 +78,10 @@ export async function emitHydratedComponentEntriesFromAstroFile(options: {
77
78
  resolveFrom: string;
78
79
  componentEntrypointRefs: Map<string, string>;
79
80
  }) {
80
- const hydratedComponentPaths = await collectHydratedComponentPaths(options.astroFilePath);
81
+ const hydratedComponentPaths = await collectHydratedComponentPaths(
82
+ options.astroFilePath,
83
+ options.resolveFrom
84
+ );
81
85
 
82
86
  for (const resolvedImportPath of hydratedComponentPaths) {
83
87
 
@@ -96,14 +100,18 @@ export async function emitHydratedComponentEntriesFromAstroFile(options: {
96
100
  }
97
101
 
98
102
  /** Collects the framework component files one Astro component hydrates in the browser. */
99
- export async function collectHydratedComponentPaths(astroFilePath: string) {
103
+ export async function collectHydratedComponentPaths(astroFilePath: string, resolveFrom: string) {
100
104
  // Only Astro components create islands, so only their framework imports
101
105
  // need standalone client chunks in built Storybook output.
102
- const localImportSpecifiers = await readLocalImportSpecifiers(astroFilePath);
106
+ const allImportSpecifiers = await readAllImportSpecifiers(astroFilePath);
103
107
  const hydratedComponentPaths: string[] = [];
104
108
 
105
- for (const specifier of localImportSpecifiers) {
106
- const resolvedImportPath = await resolveLocalImportPath(astroFilePath, specifier);
109
+ for (const specifier of allImportSpecifiers) {
110
+ // Resolve the specifier to an on-disk path — relative imports use the
111
+ // importer's directory; aliased imports go through the tsconfig paths map.
112
+ const resolvedImportPath = specifier.startsWith('.')
113
+ ? await resolveLocalImportPath(astroFilePath, specifier)
114
+ : await resolveAliasedIsland(specifier, resolveFrom);
107
115
 
108
116
  if (!resolvedImportPath) {
109
117
  continue;
@@ -385,22 +393,27 @@ async function copyLocalRuntimeDependencies(
385
393
  }
386
394
  }
387
395
 
388
- /** Reads local import specifiers from source files that can participate in the SSR runtime. */
389
- async function readLocalImportSpecifiers(filePath: string) {
396
+ const IMPORT_RE =
397
+ /(?:import|export)\s+(?:[^'"`]*?\s+from\s+)?['"`]([^'"`]+)['"`]|import\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
398
+
399
+ /** Returns all import specifiers found in the file (relative, aliased, and package). */
400
+ async function readAllImportSpecifiers(filePath: string): Promise<string[]> {
390
401
  if (!/\.(astro|[cm]?[jt]sx?|vue|svelte)$/.test(filePath)) {
391
402
  return [];
392
403
  }
393
404
 
394
405
  const source = await readFile(filePath, 'utf-8');
395
- const matches = source.matchAll(
396
- /(?:import|export)\s+(?:[^'"`]*?\s+from\s+)?['"`]([^'"`]+)['"`]|import\(\s*['"`]([^'"`]+)['"`]\s*\)/g
397
- );
398
406
 
399
- return Array.from(matches, (match) => match[1] ?? match[2]).filter(
400
- (specifier): specifier is string => Boolean(specifier) && specifier.startsWith('.')
407
+ return Array.from(source.matchAll(IMPORT_RE), (match) => match[1] ?? match[2]).filter(
408
+ (specifier): specifier is string => Boolean(specifier)
401
409
  );
402
410
  }
403
411
 
412
+ /** Reads local (relative) import specifiers from source files that can participate in the SSR runtime. */
413
+ async function readLocalImportSpecifiers(filePath: string) {
414
+ return (await readAllImportSpecifiers(filePath)).filter((s) => s.startsWith('.'));
415
+ }
416
+
404
417
  /** Resolves one relative import the same way the project source tree would on disk. */
405
418
  async function resolveLocalImportPath(importerPath: string, specifier: string) {
406
419
  const basePath = resolve(dirname(importerPath), specifier);
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ buildFamilyCss,
4
+ vitePluginAstroFonts,
5
+ type FontFaceData,
6
+ type StorybookFontFamily,
7
+ type StorybookFontProvider
8
+ } from './vitePluginAstroFonts.ts';
9
+
10
+ function makeProvider(faces: FontFaceData[]): StorybookFontProvider {
11
+ return {
12
+ name: 'test',
13
+ resolveFont: () => ({ fonts: faces })
14
+ };
15
+ }
16
+
17
+ describe('buildFamilyCss', () => {
18
+ test('emits @font-face block with src url and font-display: swap by default', () => {
19
+ const family: StorybookFontFamily = {
20
+ name: 'Inter',
21
+ cssVariable: '--font-inter',
22
+ provider: makeProvider([])
23
+ };
24
+ const css = buildFamilyCss(family, [
25
+ { src: [{ url: 'https://example.com/inter-400.woff2', format: 'woff2' }], weight: 400, style: 'normal' }
26
+ ]);
27
+
28
+ expect(css).toContain('@font-face');
29
+ expect(css).toContain('font-family: "Inter"');
30
+ expect(css).toContain('src: url("https://example.com/inter-400.woff2") format("woff2");');
31
+ expect(css).toContain('font-display: swap');
32
+ expect(css).toContain('font-weight: 400');
33
+ expect(css).toContain('font-style: normal');
34
+ });
35
+
36
+ test('emits CSS variable rule binding cssVariable to family name and fallbacks', () => {
37
+ const family: StorybookFontFamily = {
38
+ name: 'Inter',
39
+ cssVariable: '--font-inter',
40
+ provider: makeProvider([]),
41
+ fallbacks: ['system-ui', 'sans-serif']
42
+ };
43
+ const css = buildFamilyCss(family, []);
44
+
45
+ expect(css).toContain(':root { --font-inter: "Inter", system-ui, sans-serif; }');
46
+ });
47
+
48
+ test('handles local() source entries from local font providers', () => {
49
+ const family: StorybookFontFamily = {
50
+ name: 'CustomFont',
51
+ cssVariable: '--font-custom',
52
+ provider: makeProvider([])
53
+ };
54
+ const css = buildFamilyCss(family, [
55
+ { src: [{ name: 'CustomFont Regular' }, { url: '/fonts/custom.woff2', format: 'woff2' }] }
56
+ ]);
57
+
58
+ expect(css).toContain('local("CustomFont Regular")');
59
+ expect(css).toContain('url("/fonts/custom.woff2") format("woff2")');
60
+ });
61
+
62
+ test('emits variable-font weight range when provider returns a tuple', () => {
63
+ const family: StorybookFontFamily = {
64
+ name: 'Inter',
65
+ cssVariable: '--font-inter',
66
+ provider: makeProvider([])
67
+ };
68
+ const css = buildFamilyCss(family, [{ src: [{ url: '/x.woff2' }], weight: [100, 900] }]);
69
+
70
+ expect(css).toContain('font-weight: 100 900');
71
+ });
72
+ });
73
+
74
+ describe('vitePluginAstroFonts virtual modules', () => {
75
+ test('resolveId maps both virtual ids and the bare astro/assets/fonts/runtime import', async () => {
76
+ const plugin = vitePluginAstroFonts();
77
+ const resolveId = plugin.resolveId as (this: unknown, id: string) => string | undefined;
78
+
79
+ expect(resolveId.call({}, 'virtual:astro:assets/fonts/internal')).toBe(
80
+ '\0virtual:astro:assets/fonts/internal'
81
+ );
82
+ expect(resolveId.call({}, 'virtual:astro:assets/fonts/runtime')).toBe(
83
+ '\0virtual:astro:assets/fonts/runtime'
84
+ );
85
+ expect(resolveId.call({}, 'astro/assets/fonts/runtime')).toBe('\0storybook:astro-fonts-runtime');
86
+ expect(resolveId.call({}, 'astro/assets/fonts/runtime.js')).toBe('\0storybook:astro-fonts-runtime');
87
+ expect(resolveId.call({}, 'some-other-id')).toBeUndefined();
88
+ });
89
+
90
+ test('internal virtual module exports a Map populated from the resolved families', async () => {
91
+ const family: StorybookFontFamily = {
92
+ name: 'Inter',
93
+ cssVariable: '--font-inter',
94
+ provider: makeProvider([
95
+ { src: [{ url: 'https://example.com/inter.woff2', format: 'woff2' }], weight: 400 }
96
+ ])
97
+ };
98
+ const plugin = vitePluginAstroFonts({ fonts: [family] });
99
+
100
+ const buildStart = plugin.buildStart as (this: unknown) => Promise<void>;
101
+ const load = plugin.load as (this: unknown, id: string) => Promise<{ code: string } | undefined>;
102
+
103
+ await buildStart.call({});
104
+ const result = await load.call({ load: async () => null }, '\0virtual:astro:assets/fonts/internal');
105
+
106
+ expect(result?.code).toContain('componentDataByCssVariable = new Map(');
107
+ expect(result?.code).toContain('--font-inter');
108
+ expect(result?.code).toContain('@font-face');
109
+ expect(result?.code).toContain('fontDataByCssVariable = ');
110
+ expect(result?.code).toContain('https://example.com/inter.woff2');
111
+ });
112
+
113
+ test('emits empty maps when no families are configured (no-op behavior preserved)', async () => {
114
+ const plugin = vitePluginAstroFonts();
115
+
116
+ const buildStart = plugin.buildStart as (this: unknown) => Promise<void>;
117
+ const load = plugin.load as (this: unknown, id: string) => Promise<{ code: string } | undefined>;
118
+
119
+ await buildStart.call({});
120
+ const result = await load.call({ load: async () => null }, '\0virtual:astro:assets/fonts/internal');
121
+
122
+ expect(result?.code).toContain('componentDataByCssVariable = new Map([])');
123
+ expect(result?.code).toContain('fontDataByCssVariable = {}');
124
+ });
125
+
126
+ test('continues processing remaining families if one provider throws', async () => {
127
+ const failing: StorybookFontFamily = {
128
+ name: 'Broken',
129
+ cssVariable: '--font-broken',
130
+ provider: {
131
+ name: 'broken',
132
+ resolveFont: () => {
133
+ throw new Error('boom');
134
+ }
135
+ }
136
+ };
137
+ const working: StorybookFontFamily = {
138
+ name: 'Inter',
139
+ cssVariable: '--font-inter',
140
+ provider: makeProvider([{ src: [{ url: '/inter.woff2' }], weight: 400 }])
141
+ };
142
+ const plugin = vitePluginAstroFonts({ fonts: [failing, working] });
143
+
144
+ const buildStart = plugin.buildStart as (this: unknown) => Promise<void>;
145
+ const load = plugin.load as (this: unknown, id: string) => Promise<{ code: string } | undefined>;
146
+
147
+ await buildStart.call({});
148
+ const result = await load.call({ load: async () => null }, '\0virtual:astro:assets/fonts/internal');
149
+
150
+ expect(result?.code).toContain('--font-inter');
151
+ expect(result?.code).not.toContain('--font-broken');
152
+ });
153
+ });
@@ -0,0 +1,302 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import type { Plugin } from 'vite';
3
+
4
+ // We avoid a hard import of `astro/assets/fonts/types` here because consumers
5
+ // using older Astro versions without the new fonts API would fail to install.
6
+ // The provider interface we rely on is small and stable enough to type locally.
7
+ export interface StorybookFontProvider {
8
+ name: string;
9
+ init?: (context: { storage: FontStorage; root: URL }) => Promise<void> | void;
10
+ resolveFont: (options: {
11
+ familyName: string;
12
+ weights: string[];
13
+ styles: string[];
14
+ subsets: string[];
15
+ formats: string[];
16
+ }) => Promise<{ fonts: FontFaceData[] } | undefined> | { fonts: FontFaceData[] } | undefined;
17
+ }
18
+
19
+ export interface FontFaceData {
20
+ src: Array<{ url?: string; name?: string; format?: string; tech?: string }>;
21
+ weight?: string | number | [number, number];
22
+ style?: string;
23
+ display?: string;
24
+ unicodeRange?: string[];
25
+ featureSettings?: string;
26
+ variationSettings?: string;
27
+ }
28
+
29
+ export interface StorybookFontFamily {
30
+ name: string;
31
+ cssVariable: string;
32
+ provider: StorybookFontProvider;
33
+ weights?: Array<string | number>;
34
+ styles?: string[];
35
+ subsets?: string[];
36
+ formats?: string[];
37
+ fallbacks?: string[];
38
+ display?: string;
39
+ }
40
+
41
+ interface FontStorage {
42
+ getItem: <T = unknown>(key: string, init?: () => Promise<T> | T) => Promise<T | null>;
43
+ setItem: (key: string, value: unknown) => Promise<void> | void;
44
+ }
45
+
46
+ interface ResolvedFontData {
47
+ componentEntries: Array<[string, { css: string; preloads: never[] }]>;
48
+ fontDataByCssVariable: Record<
49
+ string,
50
+ Array<{
51
+ src: Array<{ url: string; format?: string; tech?: string }>;
52
+ weight?: string;
53
+ style?: string;
54
+ }>
55
+ >;
56
+ }
57
+
58
+ const DEFAULTS = {
59
+ weights: ['400'],
60
+ styles: ['normal', 'italic'],
61
+ subsets: ['latin'],
62
+ formats: ['woff2'],
63
+ fallbacks: ['sans-serif']
64
+ } as const;
65
+
66
+ const VIRTUAL_INTERNAL_ID = 'virtual:astro:assets/fonts/internal';
67
+ const VIRTUAL_RUNTIME_ID = 'virtual:astro:assets/fonts/runtime';
68
+ const VIRTUAL_RUNTIME_RESOLVER_ID = 'virtual:astro:assets/fonts/runtime/font-file-url-resolver';
69
+ const PACKAGE_RUNTIME_IDS = ['astro/assets/fonts/runtime', 'astro/assets/fonts/runtime.js'];
70
+
71
+ const RUNTIME_STUB = `
72
+ export const fontData = {};
73
+ export function createGetFontData(fontsMod) {
74
+ return fontsMod?.fontDataByCssVariable ?? {};
75
+ }
76
+ export const experimental_getFontFileURL = () => undefined;
77
+ `;
78
+
79
+ const RESOLVER_STUB = `
80
+ export const runtimeFontFileUrlResolver = { resolve: () => undefined };
81
+ `;
82
+
83
+ /**
84
+ * Resolves Astro's font Provider API for Storybook by reading the user's
85
+ * configured font families, calling each provider to produce @font-face data,
86
+ * and emitting CSS through Astro's font virtual modules.
87
+ *
88
+ * Lightweight first cut: generates @font-face declarations and a CSS variable
89
+ * binding to the family name plus fallbacks. Does not handle preload links,
90
+ * Capsize-optimized fallback metrics, or build-time font file emission — those
91
+ * paths fall back to remote URLs returned by the provider directly.
92
+ *
93
+ * If no families are provided, the plugin emits no-op stubs so Astro's
94
+ * font virtual modules still resolve in projects that don't configure fonts.
95
+ */
96
+ export function vitePluginAstroFonts(
97
+ options: {
98
+ fonts?: StorybookFontFamily[];
99
+ root?: string;
100
+ } = {}
101
+ ): Plugin {
102
+ const families = options.fonts ?? [];
103
+ const rootDir = options.root ?? process.cwd();
104
+ const root = pathToFileURL(rootDir.endsWith('/') ? rootDir : rootDir + '/');
105
+
106
+ let resolved: ResolvedFontData | null = null;
107
+ let resolvePromise: Promise<ResolvedFontData> | null = null;
108
+
109
+ const ensureResolved = async () => {
110
+ if (!resolvePromise) {
111
+ resolvePromise = resolveAllFamilies(families, root);
112
+ }
113
+
114
+ resolved = await resolvePromise;
115
+
116
+ return resolved;
117
+ };
118
+
119
+ return {
120
+ name: 'storybook-astro-fonts',
121
+ enforce: 'pre',
122
+
123
+ async buildStart() {
124
+ await ensureResolved();
125
+ },
126
+
127
+ resolveId(id) {
128
+ if (id === VIRTUAL_INTERNAL_ID) {
129
+ return '\0' + VIRTUAL_INTERNAL_ID;
130
+ }
131
+ if (id === VIRTUAL_RUNTIME_ID) {
132
+ return '\0' + VIRTUAL_RUNTIME_ID;
133
+ }
134
+ if (id === VIRTUAL_RUNTIME_RESOLVER_ID) {
135
+ return '\0' + VIRTUAL_RUNTIME_RESOLVER_ID;
136
+ }
137
+ if (PACKAGE_RUNTIME_IDS.includes(id)) {
138
+ return '\0storybook:astro-fonts-runtime';
139
+ }
140
+
141
+ return undefined;
142
+ },
143
+
144
+ async load(id) {
145
+ if (id === '\0' + VIRTUAL_INTERNAL_ID) {
146
+ const data = resolved ?? (await ensureResolved());
147
+
148
+ return {
149
+ code:
150
+ `export const componentDataByCssVariable = new Map(${JSON.stringify(data.componentEntries)});\n` +
151
+ `export const fontDataByCssVariable = ${JSON.stringify(data.fontDataByCssVariable)};\n`
152
+ };
153
+ }
154
+ if (id === '\0' + VIRTUAL_RUNTIME_ID || id === '\0storybook:astro-fonts-runtime') {
155
+ return { code: RUNTIME_STUB };
156
+ }
157
+ if (id === '\0' + VIRTUAL_RUNTIME_RESOLVER_ID) {
158
+ return { code: RESOLVER_STUB };
159
+ }
160
+
161
+ return undefined;
162
+ }
163
+ };
164
+ }
165
+
166
+ async function resolveAllFamilies(
167
+ families: StorybookFontFamily[],
168
+ root: URL
169
+ ): Promise<ResolvedFontData> {
170
+ const componentEntries: ResolvedFontData['componentEntries'] = [];
171
+ const fontDataByCssVariable: ResolvedFontData['fontDataByCssVariable'] = {};
172
+ const storage = createMemoryStorage();
173
+
174
+ for (const family of families) {
175
+ try {
176
+ if (family.provider.init) {
177
+ await family.provider.init({ storage, root });
178
+ }
179
+ const result = await family.provider.resolveFont({
180
+ familyName: family.name,
181
+ weights: (family.weights ?? DEFAULTS.weights).map(String),
182
+ styles: family.styles ?? [...DEFAULTS.styles],
183
+ subsets: family.subsets ?? [...DEFAULTS.subsets],
184
+ formats: family.formats ?? [...DEFAULTS.formats]
185
+ });
186
+ const faces = result?.fonts ?? [];
187
+
188
+ if (faces.length === 0) {
189
+ continue;
190
+ }
191
+
192
+ componentEntries.push([
193
+ family.cssVariable,
194
+ { css: buildFamilyCss(family, faces), preloads: [] }
195
+ ]);
196
+ fontDataByCssVariable[family.cssVariable] = faces.map(toFontData);
197
+ } catch (err) {
198
+ // Swallow per-family errors so one bad family doesn't break the rest.
199
+ // Errors surface as the family simply not rendering, matching Astro's
200
+ // behavior when a provider can't resolve a font.
201
+ console.warn(
202
+ `[storybook-astro-fonts] Failed to resolve font family "${family.name}":`,
203
+ err instanceof Error ? err.message : err
204
+ );
205
+ }
206
+ }
207
+
208
+ return { componentEntries, fontDataByCssVariable };
209
+ }
210
+
211
+ export function buildFamilyCss(family: StorybookFontFamily, faces: FontFaceData[]): string {
212
+ const fallbacks = family.fallbacks ?? [...DEFAULTS.fallbacks];
213
+ const familyList = [JSON.stringify(family.name), ...fallbacks].join(', ');
214
+ const faceBlocks = faces.map((face) => buildFontFaceBlock(family, face)).join('\n');
215
+ const rootRule = `:root { ${family.cssVariable}: ${familyList}; }`;
216
+
217
+ return `${faceBlocks}\n${rootRule}`;
218
+ }
219
+
220
+ function buildFontFaceBlock(family: StorybookFontFamily, face: FontFaceData): string {
221
+ const src = face.src
222
+ .map((source) => {
223
+ if (source.url) {
224
+ const format = source.format ? ` format(${JSON.stringify(source.format)})` : '';
225
+ const tech = source.tech ? ` tech(${source.tech})` : '';
226
+
227
+ return `url(${JSON.stringify(source.url)})${format}${tech}`;
228
+ }
229
+ if (source.name) {
230
+ return `local(${JSON.stringify(source.name)})`;
231
+ }
232
+
233
+ return '';
234
+ })
235
+ .filter(Boolean)
236
+ .join(', ');
237
+
238
+ const descriptors: string[] = [
239
+ `font-family: ${JSON.stringify(family.name)};`,
240
+ `src: ${src};`,
241
+ `font-display: ${family.display ?? 'swap'};`
242
+ ];
243
+
244
+ if (face.weight !== undefined) {
245
+ const weight = Array.isArray(face.weight) ? face.weight.join(' ') : String(face.weight);
246
+
247
+ descriptors.push(`font-weight: ${weight};`);
248
+ }
249
+ if (face.style) {
250
+ descriptors.push(`font-style: ${face.style};`);
251
+ }
252
+ if (face.unicodeRange?.length) {
253
+ descriptors.push(`unicode-range: ${face.unicodeRange.join(', ')};`);
254
+ }
255
+ if (face.featureSettings) {
256
+ descriptors.push(`font-feature-settings: ${face.featureSettings};`);
257
+ }
258
+ if (face.variationSettings) {
259
+ descriptors.push(`font-variation-settings: ${face.variationSettings};`);
260
+ }
261
+
262
+ return `@font-face { ${descriptors.join(' ')} }`;
263
+ }
264
+
265
+ function toFontData(face: FontFaceData) {
266
+ return {
267
+ src: face.src
268
+ .filter((source) => source.url)
269
+ .map((source) => ({ url: source.url!, format: source.format, tech: source.tech })),
270
+ weight:
271
+ face.weight !== undefined
272
+ ? Array.isArray(face.weight)
273
+ ? face.weight.join(' ')
274
+ : String(face.weight)
275
+ : undefined,
276
+ style: face.style
277
+ };
278
+ }
279
+
280
+ function createMemoryStorage(): FontStorage {
281
+ const store = new Map<string, unknown>();
282
+
283
+ return {
284
+ async getItem(key, init) {
285
+ if (store.has(key)) {
286
+ return store.get(key) as never;
287
+ }
288
+ if (init) {
289
+ const value = await init();
290
+
291
+ store.set(key, value);
292
+
293
+ return value as never;
294
+ }
295
+
296
+ return null;
297
+ },
298
+ async setItem(key, value) {
299
+ store.set(key, value);
300
+ }
301
+ };
302
+ }
@@ -7,13 +7,14 @@ import type { FrameworkOptions } from './types.ts';
7
7
  import type { Integration } from './integrations/index.ts';
8
8
  import { importAstroConfig } from './importAstroConfig.ts';
9
9
  import { viteAstroContainerRenderersPlugin } from './viteAstroContainerRenderersPlugin.ts';
10
- import { vitePluginAstroFontsFallback } from './vitePluginAstroFontsFallback.ts';
10
+ import { vitePluginAstroFonts } from './vitePluginAstroFonts.ts';
11
11
  import { vitePluginAstroIntegrationOptsFallback } from './vitePluginAstroIntegrationOptsFallback.ts';
12
12
  import { vitePluginAstroVueFallback } from './vitePluginAstroVueFallback.ts';
13
13
  import { vitePluginAstroRoutesFallback } from './vitePluginAstroRoutesFallback.ts';
14
14
  import { vitePluginStoryModuleMocks } from './vitePluginStoryModuleMocks.ts';
15
15
  import { ssrLoadModuleWithFsFallback } from './lib/ssr-load-module-with-fs-fallback.ts';
16
16
  import { resolveRulesConfigFilePath } from './rules-options.ts';
17
+ import { loadUserAstroIntegrations } from './loadUserAstroConfig.ts';
17
18
 
18
19
  export async function vitePluginStorybookAstroMiddleware(options: FrameworkOptions) {
19
20
  // The internal Vite server is created lazily inside configureServer (dev-only).
@@ -25,7 +26,7 @@ export async function vitePluginStorybookAstroMiddleware(options: FrameworkOptio
25
26
  const vitePlugin = {
26
27
  name: 'storybook-astro-middleware-plugin',
27
28
  async configureServer(server) {
28
- viteServer = await createViteServer(options.integrations ?? [], resolveFrom);
29
+ viteServer = await createViteServer(options.integrations ?? [], resolveFrom, options.fonts);
29
30
  const storyRulesConfigFilePath = resolveRulesConfigFilePath(options.storyRules, resolveFrom);
30
31
 
31
32
  const filePath = fileURLToPath(new URL('./middleware', import.meta.url));
@@ -44,7 +45,8 @@ export async function vitePluginStorybookAstroMiddleware(options: FrameworkOptio
44
45
  loadModule: (id: string) =>
45
46
  ssrLoadModuleWithFsFallback(viteServer!, id, {
46
47
  fixStacktrace: true
47
- })
48
+ }),
49
+ resolveFrom
48
50
  });
49
51
 
50
52
  let handlerPromise = createHandler();
@@ -155,18 +157,28 @@ function createSsrServerLogger() {
155
157
  return logger;
156
158
  }
157
159
 
158
- export async function createViteServer(integrations: Integration[], resolveFrom = process.cwd()) {
160
+ export async function createViteServer(
161
+ integrations: Integration[],
162
+ resolveFrom = process.cwd(),
163
+ fonts?: FrameworkOptions['fonts']
164
+ ) {
159
165
  const { getViteConfig, passthroughImageService } = await importAstroConfig(resolveFrom);
160
166
  const safeIntegrations = integrations ?? [];
161
167
  const projectAstroResolutionPlugin = createProjectAstroResolutionPlugin(resolveFrom);
162
168
 
169
+ const frameworkIntegrations = await Promise.all(
170
+ safeIntegrations.map((integration) => integration.loadIntegration(resolveFrom))
171
+ );
172
+
173
+ const userIntegrations = await loadUserAstroIntegrations(resolveFrom);
174
+ const frameworkNames = new Set(frameworkIntegrations.map(i => i.name));
175
+ const extraIntegrations = userIntegrations.filter(i => !frameworkNames.has(i.name));
176
+
163
177
  const config = await getViteConfig(
164
178
  { root: resolveFrom },
165
179
  {
166
180
  configFile: false,
167
- integrations: await Promise.all(
168
- safeIntegrations.map((integration) => integration.loadIntegration(resolveFrom))
169
- ),
181
+ integrations: [...frameworkIntegrations, ...extraIntegrations],
170
182
  // Use the passthrough image service so nested components that use <Image>
171
183
  // from astro:assets render as plain <img> tags without triggering image
172
184
  // optimization (which fails in the Storybook SSR context).
@@ -181,7 +193,7 @@ export async function createViteServer(integrations: Integration[], resolveFrom
181
193
  plugins: [
182
194
  projectAstroResolutionPlugin,
183
195
  // Fallbacks must come first to intercept before Astro's plugins
184
- vitePluginAstroFontsFallback(),
196
+ vitePluginAstroFonts({ fonts, root: resolveFrom }),
185
197
  vitePluginAstroIntegrationOptsFallback(),
186
198
  vitePluginAstroVueFallback(),
187
199
  vitePluginAstroRoutesFallback(),