diff --git a/.storybook/main.ts b/.storybook/main.ts index 31dc739ca..8e14495eb 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,6 @@ import type { StorybookConfig } from '@storybook-vue/nuxt' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' const config = { stories: [ @@ -21,29 +23,95 @@ const config = { async viteFinal(newConfig) { newConfig.plugins ??= [] + // Fix: nuxt:components:imports-alias relies on internal Nuxt state that is + // cleaned up after nuxt.close() in @storybook-vue/nuxt's loadNuxtViteConfig. + // When that state is gone, `import X from '#components'` is left unresolved + // and Vite 8 falls through to package-subpath resolution, which fails with + // "Missing '#components' specifier in 'nuxt' package". + // This plugin intercepts #components first and serves a virtual module built + // from the components.d.ts written during the same Nuxt boot. + // Resolve the Nuxt build dir from Vite's alias map, which can be either a + // plain-object (Record) or Vite's resolved array form + // (readonly Alias[] where find is string | RegExp). We must handle both + // without casting to Record, which would be unsound for the + // array form. + const aliases = newConfig.resolve?.alias + const buildDir = (() => { + if (!aliases) return undefined + if (Array.isArray(aliases)) { + const entry = aliases.find(a => a.find === '#build') + return typeof entry?.replacement === 'string' ? entry.replacement : undefined + } + const value = (aliases as Record)['#build'] + return typeof value === 'string' ? value : undefined + })() + newConfig.plugins.unshift({ + name: 'storybook-nuxt-components', + enforce: 'pre', + resolveId(id) { + if (id === '#components') return '\0virtual:#components' + return null + }, + load(id) { + if (id !== '\0virtual:#components') return + if (!buildDir) { + throw new Error('[storybook-nuxt-components] Could not resolve the `#build` alias.') + } + const dtsPath = resolve(buildDir, 'components.d.ts') + // Wire the generated declaration file into Vite's file-watch graph so + // that the virtual module is invalidated when Nuxt regenerates it. + this.addWatchFile(dtsPath) + const dts = readFileSync(dtsPath, 'utf-8') + const lines: string[] = [] + // Match only the direct `typeof import("…").default` form. + // Lazy/island wrappers (LazyComponent, IslandComponent) are + // excluded intentionally — Storybook only needs the concrete type. + // The format has been stable across all Nuxt 3 releases. + const re = + /^export const (\w+): typeof import\("([^"]+)"\)(?:\.default|\[['"]default['"]\])\s*;?$/gm + let match: RegExpExecArray | null + while ((match = re.exec(dts)) !== null) { + const [, name, rel] = match + if (!name || !rel) continue + const abs = resolve(buildDir, rel).replaceAll('\\', '/') + lines.push(`export { default as ${name} } from ${JSON.stringify(abs)}`) + } + if (lines.length === 0) { + throw new Error( + `[storybook-nuxt-components] No component exports were found in ${dtsPath}.`, + ) + } + return lines.join('\n') + }, + }) + // Bridge compatibility between Storybook v10 core and v9 @storybook-vue/nuxt // v10 expects module federation globals that v9 doesn't provide newConfig.plugins.push({ name: 'storybook-v10-compat', transformIndexHtml: { order: 'pre', - handler(html) { - const script = ` -` - return html.replace(/