From 8172258ffff83a00ce7b612b56a457701d94bf62 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 6 Aug 2026 16:41:58 +0200 Subject: [PATCH 01/19] Vue: Synthesize static story snippets from args --- .../story-docs.payload.snapshot | 9 + .../as-cast/story-docs.payload.snapshot | 3 + .../basic/story-docs.payload.snapshot | 3 + .../__testfixtures__/csf4/CsfFourButton.vue | 1 + .../csf4/story-docs.payload.snapshot | 6 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../slot-scoped/story-docs.payload.snapshot | 3 + .../slots/story-docs.payload.snapshot | 5 + .../story-docs.payload.snapshot | 6 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 8 + .../v-model/story-docs.payload.snapshot | 11 + .../vue3/story-docs/build-story-docs.test.ts | 79 ++++-- .../vue3/story-docs/snippet-parity.test.ts | 120 +++++++++ .../vue3/src/story-docs/build-story-docs.ts | 229 ++++++++++++++++-- .../vue3/src/story-docs/classify-args.ts | 164 +++++++++++++ .../vue3/src/story-docs/render-sfc.ts | 184 ++++++++++++++ 20 files changed, 812 insertions(+), 34 deletions(-) create mode 100644 code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts create mode 100644 code/renderers/vue3/src/story-docs/classify-args.ts create mode 100644 code/renderers/vue3/src/story-docs/render-sfc.ts diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot index fe7ba06602fa..b1a96a068f16 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot @@ -8,6 +8,15 @@ "description": undefined, "id": "forms-args-formatting--primary", "name": "Primary", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/as-cast/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/as-cast/story-docs.payload.snapshot index c9e889452b3b..01c4ee02dc98 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/as-cast/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/as-cast/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": "Cast story description.", "id": "forms-as-cast--primary", "name": "Primary", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/basic/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/basic/story-docs.payload.snapshot index cf4da90ebbd4..99905b37a0d5 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/basic/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/basic/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": "Primary button description.", "id": "forms-basic--primary", "name": "Primary", + "snippet": "", "summary": "Primary button summary.", }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf4/CsfFourButton.vue b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf4/CsfFourButton.vue index 872c97e8f958..d5995ff0416f 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf4/CsfFourButton.vue +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf4/CsfFourButton.vue @@ -1,4 +1,5 @@ + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts index 2b9fdb796ca0..4a3de6db90ef 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { IndexEntry } from 'storybook/internal/types'; +import type { DocgenPayload } from 'storybook/open-service'; import { buildStoryDocsPayload } from '../../../../../renderers/vue3/src/story-docs/build-story-docs.ts'; @@ -16,6 +17,7 @@ type FixtureCase = { label: string; name: string; testDir: string; + tree: 'story-docs' | 'docgen'; }; function fixtureCases(fixturesDir: string): string[] { @@ -25,26 +27,58 @@ function fixtureCases(fixturesDir: string): string[] { .sort(); } -function makeFixtureCase(fixturesDir: string, name: string, label = name): FixtureCase { - return { - label, - name, - testDir: resolve(fixturesDir, name), - }; -} - function storyDocsFixtureCases(): FixtureCase[] { - return fixtureCases(FIXTURES_DIR).map((fixtureCase) => - makeFixtureCase(FIXTURES_DIR, fixtureCase) - ); + return fixtureCases(FIXTURES_DIR).map((name) => ({ + label: name, + name, + testDir: resolve(FIXTURES_DIR, name), + tree: 'story-docs' as const, + })); } function docgenFixtureCases(): FixtureCase[] { return fixtureCases(DOCGEN_FIXTURES_DIR) - .filter((fixtureCase) => existsSync(join(DOCGEN_FIXTURES_DIR, fixtureCase, STORIES_FILE))) - .map((fixtureCase) => - makeFixtureCase(DOCGEN_FIXTURES_DIR, fixtureCase, `docgen/${fixtureCase}`) - ); + .filter((name) => existsSync(join(DOCGEN_FIXTURES_DIR, name, STORIES_FILE))) + .map((name) => ({ + label: `docgen/${name}`, + name, + testDir: resolve(DOCGEN_FIXTURES_DIR, name), + tree: 'docgen' as const, + })); +} + +function docgenForFixture( + fixtureCase: string, + id: string, + path: string +): DocgenPayload | undefined { + if (fixtureCase === 'docgen-unavailable' || fixtureCase === 'no-component') { + return undefined; + } + + const details: Record = { + 'function-slot': { slots: ['default'] }, + slots: { slots: ['default', 'header'] }, + 'v-model': { events: ['update:modelValue', 'update:checked'] }, + }; + + return { + id, + name: componentNameFromFixture(fixtureCase), + path, + jsDocTags: {}, + vueComponentMeta: { + events: (details[fixtureCase]?.events ?? []).map((name) => ({ name })), + slots: (details[fixtureCase]?.slots ?? []).map((name) => ({ name })), + }, + }; +} + +function componentNameFromFixture(fixtureCase: string): string { + return fixtureCase + .split('-') + .map((segment) => `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`) + .join(''); } function makeStoryIndexEntry(importPath: string, title: string): IndexEntry { @@ -59,11 +93,18 @@ function makeStoryIndexEntry(importPath: string, title: string): IndexEntry { }; } -async function expectPayloadSnapshot({ name, testDir }: FixtureCase): Promise { +async function expectPayloadSnapshot({ name, testDir, tree }: FixtureCase): Promise { const importPath = resolve(testDir, STORIES_FILE); - const payload = await buildStoryDocsPayload({ - entry: makeStoryIndexEntry(importPath, `Forms/${name}`), - }); + const payload = await buildStoryDocsPayload( + { + entry: makeStoryIndexEntry(importPath, `Forms/${name}`), + }, + { + // Docgen-tree baselines stay metadata-only until real argTypes snapshots are wired in. + readDocgen: async (id) => + tree === 'story-docs' ? docgenForFixture(name, id, importPath) : undefined, + } + ); await expect(payload ? { ...payload, path: '__PATH__' } : payload).toMatchFileSnapshot( join(testDir, 'story-docs.payload.snapshot') diff --git a/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts new file mode 100644 index 000000000000..6d49e02cba1c --- /dev/null +++ b/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts @@ -0,0 +1,120 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { storyNameFromExport } from 'storybook/internal/csf'; +import { loadCsf } from 'storybook/internal/csf-tools'; +import type { IndexEntry } from 'storybook/internal/types'; +import type { DocgenPayload } from 'storybook/open-service'; + +import { buildStoryDocsPayload } from '../../../../../renderers/vue3/src/story-docs/build-story-docs.ts'; +import { expectCurrentOrBetter } from '../../compare/expect-current-or-better.ts'; +import { parseArgTypesSnapshot } from '../../compare/parse-snapshot.ts'; + +const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../__testfixtures__'); +const STORIES_FILE = 'input.stories.ts'; + +function fixtureCases(): string[] { + return readdirSync(FIXTURES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .filter((entry) => + readdirSync(resolve(FIXTURES_DIR, entry.name)).some( + (file) => file.startsWith('snippet-') && file.endsWith('.snapshot') + ) + ) + .map((entry) => entry.name) + .sort(); +} + +function makeStoryIndexEntry(importPath: string, title: string): IndexEntry { + const componentId = title.split('/').at(-1)!.replace(/\s+/g, '').toLowerCase(); + return { + id: `${componentId}--primary`, + name: 'Primary', + title, + type: 'story', + subtype: 'story', + importPath, + }; +} + +function docgenForFixture(fixtureCase: string, id: string, path: string): DocgenPayload { + const argTypesPath = resolve(FIXTURES_DIR, fixtureCase, 'cm-argtypes.snapshot'); + const argTypes = parseArgTypesSnapshot( + readFileSync(argTypesPath, 'utf8'), + `${fixtureCase}/cm-argtypes.snapshot` + ); + + return { + id, + name: componentNameForFixture(fixtureCase), + path, + jsDocTags: {}, + argTypes, + }; +} + +function componentNameForFixture(fixtureCase: string): string { + const sfcFile = readdirSync(resolve(FIXTURES_DIR, fixtureCase)).find((file) => + file.endsWith('.vue') + ); + + return sfcFile ? sfcFile.replace(/\.vue$/, '') : fixtureCase; +} + +function titleForStoryFile(storyFile: string): string { + const storyFileSource = readFileSync(storyFile, 'utf8'); + const csf = loadCsf(storyFileSource, { + makeTitle: (title) => title ?? 'Unknown', + fileName: storyFile, + }).parse(); + + return csf._meta?.title ?? 'Unknown'; +} + +describe('vue3 story-docs static snippet parity', () => { + it.each(fixtureCases())('%s', async (fixtureCase): Promise => { + const testDir = resolve(FIXTURES_DIR, fixtureCase); + const importPath = resolve(testDir, STORIES_FILE); + const payload = await buildStoryDocsPayload( + { + entry: makeStoryIndexEntry(importPath, titleForStoryFile(importPath)), + }, + { + readDocgen: async (id) => docgenForFixture(fixtureCase, id, importPath), + } + ); + + expect(payload, fixtureCase).toBeDefined(); + + for (const snapshotFile of readdirSync(testDir) + .filter((file) => file.startsWith('snippet-') && file.endsWith('.snapshot')) + .sort()) { + const exportName = snapshotFile.replace(/^snippet-/, '').replace(/\.snapshot$/, ''); + const storyName = storyNameFromExport(exportName); + const candidate = Object.values(payload!.stories).find( + (story) => story.name === storyName + )?.snippet; + + if (!candidate) { + continue; + } + + expectCurrentOrBetter({ + kind: 'snippet', + framework: 'vue3', + baseline: readFileSync(resolve(testDir, snapshotFile), 'utf8'), + candidate, + }); + } + + const emittedSnippet = Object.values(payload!.stories).some((story) => story.snippet); + const hasBaseline = readdirSync(testDir).some( + (file) => + file.startsWith('snippet-') && file.endsWith('.snapshot') && existsSync(join(testDir, file)) + ); + expect(emittedSnippet || hasBaseline, fixtureCase).toBe(true); + }); +}); diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index 9fde105ab0b6..bd8ffd322b6b 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -1,31 +1,45 @@ import { readFile } from 'node:fs/promises'; import { isAbsolute, join } from 'node:path'; +import { type NodePath, type types as t } from 'storybook/internal/babel'; import { STORY_FILE_TEST_REGEXP, getComponentIdFromEntry, getStoryImportPathFromEntry, } from 'storybook/internal/common'; +import { getService } from 'storybook/internal/core-server'; import { storyNameFromExport } from 'storybook/internal/csf'; import { + argsRecordFromObjectPath, buildImportStatements, collectImportBindings, extractStoryJSDocInfo, keyOf, loadCsf, + mergeArgsRecords, + metaArgsRecord, metaObjectPath, + normalizeStoryDeclaration, resolveComponentImport, } from 'storybook/internal/csf-tools'; import type { StoryDoc, StoryDocsPayload, StoryDocsProviderInput } from 'storybook/internal/types'; +import type { DocgenPayload, DocgenService } from 'storybook/open-service'; -interface BuildStoryDocsContext { +import { classifyArgs, type VueDocgenArgInfo } from './classify-args.ts'; +import { renderSfcSnippet } from './render-sfc.ts'; + +export interface BuildStoryDocsContext { /** Resolve a CSF import path to an absolute file path. Defaults to `process.cwd()` join. */ resolvePath?: (importPath: string) => string; + /** Reads fresh docgen for the component id. Defaults to the registered core/docgen service. */ + readDocgen?: (id: string) => Promise; } type ParsedCsf = ReturnType['parse']>; +type ArgsObjectPath = NodePath | undefined; const COMPONENT_PROPERTY = 'component'; +const ARGS_PROPERTY = 'args'; /** * Builds Vue story-docs metadata without snippets so runtime source fallback remains authoritative. @@ -59,15 +73,24 @@ export async function buildStoryDocsPayload( return undefined; } - const componentName = csf._meta?.component; + const id = getComponentIdFromEntry(input.entry); + const docgenPayload = await readFreshDocgen(id, context.readDocgen); + const componentName = resolveMetaComponentIdentifier(csf); const importStatement = createImportStatement(csf); + const docgenArgInfo = + docgenPayload && !docgenPayload.error ? vueDocgenArgInfo(docgenPayload) : undefined; return { - id: getComponentIdFromEntry(input.entry), - name: componentName ?? fallbackTitle(input.entry.title), + id, + name: componentName ?? docgenPayload?.name ?? fallbackTitle(input.entry.title), path: storyFilePath, ...(importStatement ? { import: importStatement } : {}), - stories: extractStories(csf), + stories: extractStories(csf, { + componentName, + docgenArgInfo, + metaArgsError: argsContainerError(metaObjectPath(csf)), + metaArgsPath: metaArgsObjectPath(csf), + }), }; } @@ -100,19 +123,195 @@ function createImportStatement(csf: ParsedCsf): string | undefined { return buildImportStatements({ refs: [ref] }).join('\n') || undefined; } -function extractStories(csf: ParsedCsf): Record { +async function readFreshDocgen( + id: string, + readDocgen = readDocgenFromService +): Promise { + try { + return await readDocgen(id); + } catch { + return undefined; + } +} + +async function readDocgenFromService(id: string): Promise { + const docgen = getService('core/docgen', { internal: true }); + return docgen.commands.extractDocgen({ id }); +} + +function vueDocgenArgInfo(payload: DocgenPayload): VueDocgenArgInfo { + const slots = new Set(); + const events = new Set(); + const vueComponentMeta = payload.vueComponentMeta; + + if (isVueComponentMeta(vueComponentMeta)) { + for (const slot of vueComponentMeta.slots) { + if (typeof slot.name === 'string') { + slots.add(slot.name); + } + } + for (const event of vueComponentMeta.events) { + if (typeof event.name === 'string') { + events.add(event.name); + } + } + } + + for (const [name, argType] of Object.entries(payload.argTypes ?? {})) { + const category = argType.table?.category; + if (category === 'slots') { + slots.add(name); + } else if (category === 'events') { + events.add(name); + } + } + + return { slots, events }; +} + +function isVueComponentMeta( + value: unknown +): value is { slots: Array<{ name?: unknown }>; events: Array<{ name?: unknown }> } { + return ( + typeof value === 'object' && + value !== null && + Array.isArray((value as { slots?: unknown }).slots) && + Array.isArray((value as { events?: unknown }).events) + ); +} + +function metaArgsObjectPath(csf: ParsedCsf): ArgsObjectPath { + return argsObjectPathFromObjectPath(metaObjectPath(csf)); +} + +function argsObjectPathFromObjectPath(path?: NodePath): ArgsObjectPath { + const property = path + ?.get('properties') + .find((prop) => prop.isObjectProperty() && keyOf(prop.node) === ARGS_PROPERTY); + + if (!property?.isObjectProperty()) { + return undefined; + } + + const value = property.get('value'); + return value.isObjectExpression() ? value : undefined; +} + +function argsObjectHasSpread(path: ArgsObjectPath): boolean { + return path?.node.properties.some((property) => property.type === 'SpreadElement') ?? false; +} + +function extractStories( + csf: ParsedCsf, + options: { + componentName?: string; + docgenArgInfo?: VueDocgenArgInfo; + metaArgsError?: StoryDoc['error']; + metaArgsPath: ArgsObjectPath; + } +): Record { + const metaArgs = metaArgsRecord(metaObjectPath(csf)?.node); + return Object.fromEntries( Object.entries(csf._stories).map(([storyExport, story]): [string, StoryDoc] => { const { description, summary } = extractStoryJSDocInfo(csf._storyStatements[storyExport]); - return [ - story.id, - { - id: story.id, - name: story.name ?? storyNameFromExport(storyExport), - description, - summary, - }, - ]; + const storyDoc: StoryDoc = { + id: story.id, + name: story.name ?? storyNameFromExport(storyExport), + description, + summary, + }; + return [story.id, enrichStoryDoc(csf, storyExport, storyDoc, metaArgs, options)]; }) ); } + +function enrichStoryDoc( + csf: ParsedCsf, + storyExport: string, + storyDoc: StoryDoc, + metaArgs: Record, + options: { + componentName?: string; + docgenArgInfo?: VueDocgenArgInfo; + metaArgsError?: StoryDoc['error']; + metaArgsPath: ArgsObjectPath; + } +): StoryDoc { + if (!options.componentName || !options.docgenArgInfo) { + return storyDoc; + } + + let normalized; + try { + normalized = normalizeStoryDeclaration(csf._storyDeclarationPath[storyExport]); + } catch { + return storyDoc; + } + + if (normalized.type === 'fn') { + return storyDoc; + } + + const storyArgsError = + normalized.type === 'config' ? argsContainerError(normalized.path) : undefined; + if (options.metaArgsError || storyArgsError) { + return { ...storyDoc, error: options.metaArgsError ?? storyArgsError }; + } + + const storyArgsPath = + normalized.type === 'config' ? argsObjectPathFromObjectPath(normalized.path) : undefined; + if (argsObjectHasSpread(options.metaArgsPath) || argsObjectHasSpread(storyArgsPath)) { + return { + ...storyDoc, + error: { + name: 'Unsupported story args', + message: 'Story args contain a spread value, which cannot be statically inlined yet.', + }, + }; + } + + const storyArgs = normalized.type === 'config' ? argsRecordFromObjectPath(storyArgsPath) : {}; + const classified = classifyArgs(mergeArgsRecords(metaArgs, storyArgs), options.docgenArgInfo); + if (classified.skipSnippet) { + return storyDoc; + } + if (classified.error) { + return { ...storyDoc, error: classified.error }; + } + + return { + ...storyDoc, + snippet: renderSfcSnippet({ + componentName: options.componentName, + args: classified.args, + }), + }; +} + +function argsContainerError(path?: NodePath): StoryDoc['error'] | undefined { + const property = path + ?.get('properties') + .find((prop) => prop.isObjectProperty() && keyOf(prop.node) === ARGS_PROPERTY); + + if (!property?.isObjectProperty()) { + return undefined; + } + + const value = property.get('value'); + if (value.isObjectExpression()) { + return undefined; + } + + if (value.isIdentifier()) { + return { + name: 'Unsupported story args', + message: `Arg "args" references "${value.node.name}", which cannot be statically inlined yet.`, + }; + } + + return { + name: 'Unsupported story args', + message: 'Story args must be an object literal to be statically inlined.', + }; +} diff --git a/code/renderers/vue3/src/story-docs/classify-args.ts b/code/renderers/vue3/src/story-docs/classify-args.ts new file mode 100644 index 000000000000..8ebf97f4bd9e --- /dev/null +++ b/code/renderers/vue3/src/story-docs/classify-args.ts @@ -0,0 +1,164 @@ +import { type types as t } from 'storybook/internal/babel'; +import type { StoryDocsError } from 'storybook/internal/types'; + +/** Docgen-derived names that decide whether args become props, slots, or v-models. */ +export interface VueDocgenArgInfo { + /** Slot names reported by Vue docgen. */ + slots: Set; + /** Event names reported by Vue docgen. */ + events: Set; +} + +/** A prop binding synthesized from a CSF arg. */ +export interface PropArg { + /** Arg name used as the Vue prop name. */ + name: string; + /** CSF arg value expression. */ + value: t.Node; + /** Classification for renderer dispatch. */ + type: 'prop'; +} + +/** A v-model binding synthesized from a CSF arg and matching update event. */ +export interface ModelArg { + /** Arg name used as the v-model model name. */ + name: string; + /** CSF arg value expression. */ + value: t.Node; + /** Classification for renderer dispatch. */ + type: 'model'; +} + +/** Slot content synthesized from a CSF arg and matching slot name. */ +export interface SlotArg { + /** Arg name used as the Vue slot name. */ + name: string; + /** CSF arg value expression. */ + value: t.Node; + /** Classification for renderer dispatch. */ + type: 'slot'; +} + +export type ClassifiedArg = ModelArg | PropArg | SlotArg; + +export interface ClassifyArgsResult { + /** Args that can be rendered into a static Vue snippet. */ + args: ClassifiedArg[]; + /** Story should fall back silently because it needs a deferred renderer. */ + skipSnippet?: boolean; + /** Story-local fallback reason for deferred dynamic arg shapes. */ + error?: StoryDocsError; +} + +const UNSUPPORTED_ARG_ERROR_NAME = 'Unsupported story args'; + +/** Classify merged CSF args by Vue docgen precedence: slot, v-model, function skip, prop. */ +export function classifyArgs( + args: Record, + docgen: VueDocgenArgInfo +): ClassifyArgsResult { + const classified: ClassifiedArg[] = []; + + for (const [name, value] of Object.entries(args)) { + const unsupported = unsupportedArgReason(name, value); + if (unsupported) { + return { + args: [], + error: { + name: UNSUPPORTED_ARG_ERROR_NAME, + message: unsupported, + }, + }; + } + + if (docgen.slots.has(name)) { + if (isFunctionExpression(value)) { + return { args: [], skipSnippet: true }; + } + classified.push({ type: 'slot', name, value }); + continue; + } + + if (docgen.events.has(`update:${name}`)) { + classified.push({ type: 'model', name, value }); + continue; + } + + if (isFunctionExpression(value)) { + continue; + } + + classified.push({ type: 'prop', name, value }); + } + + return { args: classified }; +} + +function unsupportedArgReason(name: string, value: t.Node): string | undefined { + if (isFunctionExpression(value)) { + return undefined; + } + + const unwrapped = unwrapValue(value); + if (unwrapped.type === 'Identifier') { + return `Arg "${name}" references "${unwrapped.name}", which cannot be statically inlined yet.`; + } + + // TODO: check for other non-serializable values like `new Date()`, `new Map()` + // TODO: check how spread values can be supported, e.g. `args: { ...defaultArgs, foo: 'bar' }` or `args: { foo: 'bar', ...extraArgs }` + if (hasSpreadValue(unwrapped)) { + return `Arg "${name}" contains a spread value, which cannot be statically inlined yet.`; + } + + return undefined; +} + +function hasSpreadValue(node: t.Node): boolean { + const unwrapped = unwrapValue(node); + + if (unwrapped.type === 'ObjectExpression') { + return unwrapped.properties.some((property) => { + if (property.type === 'SpreadElement') { + return true; + } + if (property.type === 'ObjectProperty') { + return hasSpreadValue(property.value); + } + return false; + }); + } + + if (unwrapped.type === 'ArrayExpression') { + return unwrapped.elements.some((element) => { + if (!element) { + return false; + } + if (element.type === 'SpreadElement') { + return true; + } + return hasSpreadValue(element); + }); + } + + return false; +} + +function isFunctionExpression( + node: T +): node is T & (t.ArrowFunctionExpression | t.FunctionExpression) { + const unwrapped = unwrapValue(node); + return unwrapped.type === 'ArrowFunctionExpression' || unwrapped.type === 'FunctionExpression'; +} + +function unwrapValue(node: t.Node): t.Node { + if ( + node.type === 'TSAsExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSNonNullExpression' || + node.type === 'TSTypeAssertion' + ) { + return unwrapValue(node.expression); + } + + return node; +} diff --git a/code/renderers/vue3/src/story-docs/render-sfc.ts b/code/renderers/vue3/src/story-docs/render-sfc.ts new file mode 100644 index 000000000000..2a41fb88affe --- /dev/null +++ b/code/renderers/vue3/src/story-docs/render-sfc.ts @@ -0,0 +1,184 @@ +import { recast, type types as t } from 'storybook/internal/babel'; + +import type { ClassifiedArg, ModelArg, PropArg, SlotArg } from './classify-args.ts'; + +export interface RenderSfcInput { + /** Component identifier from CSF meta.component. */ + componentName: string; + /** Classified args to render into the SFC snippet. */ + args: ClassifiedArg[]; +} + +interface RenderContext { + /** Imports hoisted into ``; +} + +function quoteAttributeValue(value: string): string { + if (!value.includes('"')) { + return `"${value}"`; + } + if (!value.includes("'")) { + return `'${value}'`; + } + return `"${value.replaceAll('"', '"')}"`; +} + +function printValue(node: t.Node): string { + return recast.print(node).code; +} + +function slotSortKey(name: string): string { + return name === 'default' ? '' : name; +} + +function unwrapValue(node: t.Node): t.Node { + if ( + node.type === 'TSAsExpression' || + node.type === 'TSSatisfiesExpression' || + node.type === 'TSNonNullExpression' || + node.type === 'TSTypeAssertion' + ) { + return unwrapValue(node.expression); + } + + return node; +} From cbc34b176cbd0011365e79e4ee72f4fc10d256ee Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Fri, 7 Aug 2026 17:10:10 +0200 Subject: [PATCH 02/19] test: add snippet in generated snapshots --- .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../define-expose/story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../events-jsdoc/story-docs.payload.snapshot | 3 + .../jsdoc-tags/story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 12 ++ .../props-generic/story-docs.payload.snapshot | 7 + .../props-ts-enum/story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 10 ++ .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 5 + .../slots/story-docs.payload.snapshot | 3 + .../snippet-parity-summary.md | 37 ++++++ .../story-docs.payload.snapshot | 3 + .../story-docs.payload.snapshot | 7 + .../v-model/story-docs.payload.snapshot | 11 ++ .../vue3/story-docs/build-story-docs.test.ts | 29 ++++- .../vue3/story-docs/snippet-parity.test.ts | 121 ++++++++++++++---- 29 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 code/lib/docgen-harness/src/vue3/__testfixtures__/snippet-parity-summary.md diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-composed-utility/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-composed-utility/story-docs.payload.snapshot index f9158a5087bb..a613220698e1 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-composed-utility/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-composed-utility/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-composed-utility--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-extended-interface/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-extended-interface/story-docs.payload.snapshot index 0ed715ffb705..761105bfbb40 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-extended-interface/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-extended-interface/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-extended-interface--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-imported-interface/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-imported-interface/story-docs.payload.snapshot index ed9b82fc5417..3da343fed4eb 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-imported-interface/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-imported-interface/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-imported-interface--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-props-spread/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-props-spread/story-docs.payload.snapshot index 9d06b75e1c34..92886a4659c2 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-props-spread/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-props-spread/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-props-spread--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-runtime-props/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-runtime-props/story-docs.payload.snapshot index fa97e4be555e..1ab9e542e5f7 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-runtime-props/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-runtime-props/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-runtime-props--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-union-alias/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-union-alias/story-docs.payload.snapshot index 2e5cdf717989..4584f3e71a76 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-union-alias/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/cross-file-union-alias/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-cross-file-union-alias--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-expose/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-expose/story-docs.payload.snapshot index 93edb8a1beb2..45302fbdb9a9 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-expose/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-expose/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-define-expose--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-destructured/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-destructured/story-docs.payload.snapshot index cb3816bc4534..fe6377efebc8 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-destructured/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-destructured/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-define-props-destructured--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-array/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-array/story-docs.payload.snapshot index 86f6cc6034e8..492eb3c4a18e 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-array/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-array/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-define-props-runtime-array--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-object/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-object/story-docs.payload.snapshot index 498b125d1249..1d7a9c9258a3 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-object/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-props-runtime-object/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-define-props-runtime-object--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-slots-with-props/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-slots-with-props/story-docs.payload.snapshot index 26285ed284fb..0069a609f13e 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/define-slots-with-props/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/define-slots-with-props/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-define-slots-with-props--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/events-jsdoc/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/events-jsdoc/story-docs.payload.snapshot index e0381155e33c..6728d665d772 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/events-jsdoc/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/events-jsdoc/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-events-jsdoc--with-handlers", "name": "With Handlers", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/jsdoc-tags/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/jsdoc-tags/story-docs.payload.snapshot index ce2f548ccadd..98c4aecca7a9 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/jsdoc-tags/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/jsdoc-tags/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-jsdoc-tags--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/prop-slot-name-collision/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/prop-slot-name-collision/story-docs.payload.snapshot index 8d0d5e802901..bc2b9cb4f490 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/prop-slot-name-collision/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/prop-slot-name-collision/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-prop-slot-name-collision--icon-prop-as-written", "name": "Icon Prop As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot index 0c7fd7bf74e7..ba01f36a2444 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot @@ -8,12 +8,24 @@ "description": undefined, "id": "forms-props-basic-types--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, "forms-props-basic-types--unrepresentable-args": { "description": undefined, "id": "forms-props-basic-types--unrepresentable-args", "name": "Unrepresentable Args", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-generic/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-generic/story-docs.payload.snapshot index b3310a918fc9..a3cc49f8b641 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-generic/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-generic/story-docs.payload.snapshot @@ -8,6 +8,13 @@ "description": undefined, "id": "forms-props-generic--props-as-written", "name": "Props As Written", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot index 8d18f7d99357..ad69020aa8bd 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-props-ts-enum--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-union-enum/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-union-enum/story-docs.payload.snapshot index d8ef2696cf5c..38ed99241e72 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-union-enum/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-union-enum/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-props-union-enum--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/recursive-type/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/recursive-type/story-docs.payload.snapshot index 5bff9091551a..9fd0ce33a2c1 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/recursive-type/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/recursive-type/story-docs.payload.snapshot @@ -8,6 +8,16 @@ "description": undefined, "id": "forms-recursive-type--props-as-written", "name": "Props As Written", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-multi-constructor/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-multi-constructor/story-docs.payload.snapshot index 59beae2eda0c..496f6d639d1c 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-multi-constructor/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-multi-constructor/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-runtime-multi-constructor--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-proptype-cast/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-proptype-cast/story-docs.payload.snapshot index db2185010d7c..75ef902b4717 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-proptype-cast/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/runtime-proptype-cast/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-runtime-proptype-cast--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/slots-template-only/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/slots-template-only/story-docs.payload.snapshot index 51438f2792dc..8f73dfa3bbd9 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/slots-template-only/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/slots-template-only/story-docs.payload.snapshot @@ -14,6 +14,11 @@ "description": undefined, "id": "forms-slots-template-only--string-child", "name": "String Child", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/slots/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/slots/story-docs.payload.snapshot index b905390c19fc..46b11ba97821 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/slots/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/slots/story-docs.payload.snapshot @@ -14,6 +14,9 @@ "description": undefined, "id": "forms-slots--string-child", "name": "String Child", + "snippet": "", "summary": undefined, }, "forms-slots--v-node-child": { diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/snippet-parity-summary.md b/code/lib/docgen-harness/src/vue3/__testfixtures__/snippet-parity-summary.md new file mode 100644 index 000000000000..0ac70722244d --- /dev/null +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/snippet-parity-summary.md @@ -0,0 +1,37 @@ +# Vue static snippets vs legacy source decorator + +Generated by `snippet-parity.test.ts` — regenerate with `vitest -u`. + +| Fixture | Story | Status | +| --- | --- | --- | +| cross-file-composed-utility | PropsAsWritten | ✅ identical | +| cross-file-extended-interface | PropsAsWritten | ✅ identical | +| cross-file-imported-interface | PropsAsWritten | ✅ identical | +| cross-file-props-spread | PropsAsWritten | ✅ identical | +| cross-file-runtime-props | PropsAsWritten | ✅ identical | +| cross-file-union-alias | PropsAsWritten | ✅ identical | +| define-expose | PropsAsWritten | ✅ identical | +| define-props-destructured | PropsAsWritten | ✅ identical | +| define-props-runtime-array | PropsAsWritten | ✅ identical | +| define-props-runtime-object | PropsAsWritten | ✅ identical | +| define-slots-literal-bindings | ScopedIconBinding | ⏳ no snippet (runtime fallback) | +| define-slots-with-props | PropsAsWritten | ✅ identical | +| events-jsdoc | WithHandlers | ✅ identical | +| jsdoc-tags | PropsAsWritten | ✅ identical | +| prop-slot-name-collision | IconPropAsWritten | ✨ improved | +| props-basic-types | PropsAsWritten | ✅ identical | +| props-basic-types | UnrepresentableArgs | ✨ improved | +| props-generic | PropsAsWritten | ✨ improved | +| props-ts-enum | PropsAsWritten | ✨ improved | +| props-union-enum | PropsAsWritten | ✅ identical | +| recursive-type | PropsAsWritten | ✨ improved | +| runtime-multi-constructor | PropsAsWritten | ✅ identical | +| runtime-proptype-cast | PropsAsWritten | ✅ identical | +| slots | ScopedBindings | ⏳ no snippet (runtime fallback) | +| slots | StringChild | ✅ identical | +| slots | VNodeChild | ⏳ no snippet (runtime fallback) | +| slots-template-only | ScopedBindings | ⏳ no snippet (runtime fallback) | +| slots-template-only | StringChild | ✨ improved | +| type-intersection | PropsAsWritten | ✨ improved | +| type-intersection-whole | PropsAsWritten | ✅ identical | +| v-model | VModelBinding | ✨ improved | diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection-whole/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection-whole/story-docs.payload.snapshot index 3611d8b45342..b7de063e0072 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection-whole/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection-whole/story-docs.payload.snapshot @@ -8,6 +8,9 @@ "description": undefined, "id": "forms-type-intersection-whole--props-as-written", "name": "Props As Written", + "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection/story-docs.payload.snapshot index 0bcd6017410b..b5908790a542 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/type-intersection/story-docs.payload.snapshot @@ -8,6 +8,13 @@ "description": undefined, "id": "forms-type-intersection--props-as-written", "name": "Props As Written", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot index 499715d3ab96..d3b4f1d40633 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot @@ -8,6 +8,17 @@ "description": undefined, "id": "forms-v-model--v-model-binding", "name": "V Model Binding", + "snippet": " + +", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts index 4a3de6db90ef..9ef426dbc21a 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +8,7 @@ import type { IndexEntry } from 'storybook/internal/types'; import type { DocgenPayload } from 'storybook/open-service'; import { buildStoryDocsPayload } from '../../../../../renderers/vue3/src/story-docs/build-story-docs.ts'; +import { parseArgTypesSnapshot } from '../../compare/parse-snapshot.ts'; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), '__testfixtures__'); const DOCGEN_FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '__testfixtures__'); @@ -74,6 +75,27 @@ function docgenForFixture( }; } +/** Feed the real extracted argTypes so docgen-tree baselines exercise snippet synthesis. */ +function docgenFromArgTypesSnapshot( + testDir: string, + id: string, + path: string +): DocgenPayload | undefined { + const argTypesPath = join(testDir, 'cm-argtypes.snapshot'); + if (!existsSync(argTypesPath)) { + return undefined; + } + + const sfcFile = readdirSync(testDir).find((file) => file.endsWith('.vue')); + return { + id, + name: sfcFile ? sfcFile.replace(/\.vue$/, '') : id, + path, + jsDocTags: {}, + argTypes: parseArgTypesSnapshot(readFileSync(argTypesPath, 'utf8'), argTypesPath), + }; +} + function componentNameFromFixture(fixtureCase: string): string { return fixtureCase .split('-') @@ -100,9 +122,10 @@ async function expectPayloadSnapshot({ name, testDir, tree }: FixtureCase): Prom entry: makeStoryIndexEntry(importPath, `Forms/${name}`), }, { - // Docgen-tree baselines stay metadata-only until real argTypes snapshots are wired in. readDocgen: async (id) => - tree === 'story-docs' ? docgenForFixture(name, id, importPath) : undefined, + tree === 'story-docs' + ? docgenForFixture(name, id, importPath) + : docgenFromArgTypesSnapshot(testDir, id, importPath), } ); diff --git a/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts index 6d49e02cba1c..baca74444d00 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/snippet-parity.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { storyNameFromExport } from 'storybook/internal/csf'; import { loadCsf } from 'storybook/internal/csf-tools'; -import type { IndexEntry } from 'storybook/internal/types'; +import type { IndexEntry, StoryDocsPayload } from 'storybook/internal/types'; import type { DocgenPayload } from 'storybook/open-service'; import { buildStoryDocsPayload } from '../../../../../renderers/vue3/src/story-docs/build-story-docs.ts'; @@ -16,6 +16,24 @@ import { parseArgTypesSnapshot } from '../../compare/parse-snapshot.ts'; const FIXTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../__testfixtures__'); const STORIES_FILE = 'input.stories.ts'; +/** + * Stories that intentionally emit no static snippet (runtime source fallback stays authoritative). + * Shrink this set as slot support lands; an entry that starts emitting must be removed. + */ +const EXPECTED_SNIPPETLESS = new Set([ + 'define-slots-literal-bindings/ScopedIconBinding', + 'slots/ScopedBindings', + 'slots/VNodeChild', + 'slots-template-only/ScopedBindings', +]); + +type BaselineComparison = { + fixtureCase: string; + exportName: string; + baseline: string; + candidate: string | undefined; +}; + function fixtureCases(): string[] { return readdirSync(FIXTURES_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -74,47 +92,94 @@ function titleForStoryFile(storyFile: string): string { return csf._meta?.title ?? 'Unknown'; } +async function buildPayloadForFixture(fixtureCase: string): Promise { + const importPath = resolve(FIXTURES_DIR, fixtureCase, STORIES_FILE); + return buildStoryDocsPayload( + { + entry: makeStoryIndexEntry(importPath, titleForStoryFile(importPath)), + }, + { + readDocgen: async (id) => docgenForFixture(fixtureCase, id, importPath), + } + ); +} + +function baselineComparisons(fixtureCase: string, payload: StoryDocsPayload): BaselineComparison[] { + const testDir = resolve(FIXTURES_DIR, fixtureCase); + return readdirSync(testDir) + .filter((file) => file.startsWith('snippet-') && file.endsWith('.snapshot')) + .sort() + .map((snapshotFile) => { + const exportName = snapshotFile.replace(/^snippet-/, '').replace(/\.snapshot$/, ''); + const storyName = storyNameFromExport(exportName); + return { + fixtureCase, + exportName, + baseline: readFileSync(resolve(testDir, snapshotFile), 'utf8'), + candidate: Object.values(payload.stories).find((story) => story.name === storyName) + ?.snippet, + }; + }); +} + describe('vue3 story-docs static snippet parity', () => { it.each(fixtureCases())('%s', async (fixtureCase): Promise => { - const testDir = resolve(FIXTURES_DIR, fixtureCase); - const importPath = resolve(testDir, STORIES_FILE); - const payload = await buildStoryDocsPayload( - { - entry: makeStoryIndexEntry(importPath, titleForStoryFile(importPath)), - }, - { - readDocgen: async (id) => docgenForFixture(fixtureCase, id, importPath), - } - ); - + const payload = await buildPayloadForFixture(fixtureCase); expect(payload, fixtureCase).toBeDefined(); - for (const snapshotFile of readdirSync(testDir) - .filter((file) => file.startsWith('snippet-') && file.endsWith('.snapshot')) - .sort()) { - const exportName = snapshotFile.replace(/^snippet-/, '').replace(/\.snapshot$/, ''); - const storyName = storyNameFromExport(exportName); - const candidate = Object.values(payload!.stories).find( - (story) => story.name === storyName - )?.snippet; + for (const { exportName, baseline, candidate } of baselineComparisons(fixtureCase, payload!)) { + const storyKey = `${fixtureCase}/${exportName}`; if (!candidate) { + expect(EXPECTED_SNIPPETLESS.has(storyKey), `${storyKey} stopped emitting a snippet`).toBe( + true + ); continue; } + expect( + EXPECTED_SNIPPETLESS.has(storyKey), + `${storyKey} now emits a snippet — remove it from EXPECTED_SNIPPETLESS` + ).toBe(false); + expectCurrentOrBetter({ kind: 'snippet', framework: 'vue3', - baseline: readFileSync(resolve(testDir, snapshotFile), 'utf8'), + baseline, candidate, }); } + }); + + it('summarizes parity against the legacy source decorator', async () => { + const rows: string[] = []; + + for (const fixtureCase of fixtureCases()) { + const payload = await buildPayloadForFixture(fixtureCase); + for (const { exportName, baseline, candidate } of baselineComparisons( + fixtureCase, + payload! + )) { + const status = !candidate + ? '⏳ no snippet (runtime fallback)' + : candidate.trim() === baseline.trim() + ? '✅ identical' + : '✨ improved'; + rows.push(`| ${fixtureCase} | ${exportName} | ${status} |`); + } + } - const emittedSnippet = Object.values(payload!.stories).some((story) => story.snippet); - const hasBaseline = readdirSync(testDir).some( - (file) => - file.startsWith('snippet-') && file.endsWith('.snapshot') && existsSync(join(testDir, file)) - ); - expect(emittedSnippet || hasBaseline, fixtureCase).toBe(true); + const report = [ + '# Vue static snippets vs legacy source decorator', + '', + 'Generated by `snippet-parity.test.ts` — regenerate with `vitest -u`.', + '', + '| Fixture | Story | Status |', + '| --- | --- | --- |', + ...rows, + '', + ].join('\n'); + + await expect(report).toMatchFileSnapshot(join(FIXTURES_DIR, 'snippet-parity-summary.md')); }); }); From d3366ea71af706b326d86971f4d7d6252565455e Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Sat, 8 Aug 2026 14:42:31 +0200 Subject: [PATCH 03/19] chore: add comments --- .../vue3/src/story-docs/build-story-docs.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index bd8ffd322b6b..88eb51bba85c 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -99,6 +99,12 @@ function fallbackTitle(title: string): string { return title.split('/').at(-1)!.replace(/\s+/g, ''); } +/** + * Name of the identifier assigned to the meta `component` property, used as payload name, + * import-binding lookup key, and snippet tag so all three stay coherent. + * + * @example `component: MyButton` → `'MyButton'`; `component: UI.Button` → `undefined` + */ function resolveMetaComponentIdentifier(csf: ParsedCsf): string | undefined { const metaPath = metaObjectPath(csf); const componentProperty = metaPath @@ -113,6 +119,9 @@ function resolveMetaComponentIdentifier(csf: ParsedCsf): string | undefined { return value.isIdentifier() ? value.node.name : undefined; } +/** + * Reconstructs the component's import statement from the story file's import bindings. + */ function createImportStatement(csf: ParsedCsf): string | undefined { const componentName = resolveMetaComponentIdentifier(csf); if (!componentName) { @@ -139,6 +148,9 @@ async function readDocgenFromService(id: string): Promise(); const events = new Set(); @@ -184,6 +196,11 @@ function metaArgsObjectPath(csf: ParsedCsf): ArgsObjectPath { return argsObjectPathFromObjectPath(metaObjectPath(csf)); } +/** + * AST path of the `args` property when its value is an object literal. + * + * @example `{ args: { label: 'Hi' } }` → path of `{ label: 'Hi' }`; `{ args: shared }` → undefined + */ function argsObjectPathFromObjectPath(path?: NodePath): ArgsObjectPath { const property = path ?.get('properties') @@ -201,6 +218,9 @@ function argsObjectHasSpread(path: ArgsObjectPath): boolean { return path?.node.properties.some((property) => property.type === 'SpreadElement') ?? false; } +/** + * Maps every CSF story export to its StoryDoc, enriched with a snippet or error where possible. + */ function extractStories( csf: ParsedCsf, options: { @@ -226,6 +246,9 @@ function extractStories( ); } +/** + * Attaches a synthesized snippet (or an "unsupported args" error) to a story doc. + */ function enrichStoryDoc( csf: ParsedCsf, storyExport: string, @@ -289,6 +312,11 @@ function enrichStoryDoc( }; } +/** + * Error for an `args` value that is not an object literal and so cannot be statically inlined. + * + * @example `{ args: sharedArgs }` → "Unsupported story args"; `{ args: { a: 1 } }` → undefined + */ function argsContainerError(path?: NodePath): StoryDoc['error'] | undefined { const property = path ?.get('properties') From 2875084e711010140b207acca9c578d640b2f4b4 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 10 Aug 2026 09:53:02 +0200 Subject: [PATCH 04/19] refactor: hoist quote string into script var --- .../args-formatting/ArgsFormattingButton.vue | 1 + .../args-formatting/input.stories.ts | 1 + .../story-docs.payload.snapshot | 4 +- .../vue3/story-docs/build-story-docs.test.ts | 16 ++++---- .../vue3/src/story-docs/build-story-docs.ts | 37 ++++++------------- .../vue3/src/story-docs/render-sfc.ts | 14 +++++-- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/ArgsFormattingButton.vue b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/ArgsFormattingButton.vue index 5c57c275303d..129e1b998c7d 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/ArgsFormattingButton.vue +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/ArgsFormattingButton.vue @@ -1,6 +1,7 @@ ", "summary": undefined, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts index 9ef426dbc21a..4f2c084271b9 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts @@ -21,10 +21,12 @@ type FixtureCase = { tree: 'story-docs' | 'docgen'; }; +/** A directory is a fixture only if it holds a story file, so stray tooling dirs stay out. */ function fixtureCases(fixturesDir: string): string[] { return readdirSync(fixturesDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) + .filter((name) => existsSync(join(fixturesDir, name, STORIES_FILE))) .sort(); } @@ -38,14 +40,12 @@ function storyDocsFixtureCases(): FixtureCase[] { } function docgenFixtureCases(): FixtureCase[] { - return fixtureCases(DOCGEN_FIXTURES_DIR) - .filter((name) => existsSync(join(DOCGEN_FIXTURES_DIR, name, STORIES_FILE))) - .map((name) => ({ - label: `docgen/${name}`, - name, - testDir: resolve(DOCGEN_FIXTURES_DIR, name), - tree: 'docgen' as const, - })); + return fixtureCases(DOCGEN_FIXTURES_DIR).map((name) => ({ + label: `docgen/${name}`, + name, + testDir: resolve(DOCGEN_FIXTURES_DIR, name), + tree: 'docgen' as const, + })); } function docgenForFixture( diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index 88eb51bba85c..04fff1180cda 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { isAbsolute, join } from 'node:path'; -import { type NodePath, type types as t } from 'storybook/internal/babel'; +import { type NodePath, types as t } from 'storybook/internal/babel'; import { STORY_FILE_TEST_REGEXP, getComponentIdFromEntry, @@ -21,6 +21,7 @@ import { metaObjectPath, normalizeStoryDeclaration, resolveComponentImport, + propertyValue, } from 'storybook/internal/csf-tools'; import type { StoryDoc, StoryDocsPayload, StoryDocsProviderInput } from 'storybook/internal/types'; import type { DocgenPayload, DocgenService } from 'storybook/open-service'; @@ -38,7 +39,6 @@ export interface BuildStoryDocsContext { type ParsedCsf = ReturnType['parse']>; type ArgsObjectPath = NodePath | undefined; -const COMPONENT_PROPERTY = 'component'; const ARGS_PROPERTY = 'args'; /** @@ -106,17 +106,8 @@ function fallbackTitle(title: string): string { * @example `component: MyButton` → `'MyButton'`; `component: UI.Button` → `undefined` */ function resolveMetaComponentIdentifier(csf: ParsedCsf): string | undefined { - const metaPath = metaObjectPath(csf); - const componentProperty = metaPath - ?.get('properties') - .find((property) => property.isObjectProperty() && keyOf(property.node) === COMPONENT_PROPERTY); - - if (!componentProperty?.isObjectProperty()) { - return undefined; - } - - const value = componentProperty.get('value'); - return value.isIdentifier() ? value.node.name : undefined; + const value = propertyValue(metaObjectPath(csf)?.node, 'component'); + return t.isIdentifier(value) ? value.name : undefined; } /** @@ -214,8 +205,8 @@ function argsObjectPathFromObjectPath(path?: NodePath): Args return value.isObjectExpression() ? value : undefined; } -function argsObjectHasSpread(path: ArgsObjectPath): boolean { - return path?.node.properties.some((property) => property.type === 'SpreadElement') ?? false; +function argsObjectHasSpread(object: t.ObjectExpression | undefined): boolean { + return object?.properties.some((property) => property.type === 'SpreadElement') ?? false; } /** @@ -284,7 +275,7 @@ function enrichStoryDoc( const storyArgsPath = normalized.type === 'config' ? argsObjectPathFromObjectPath(normalized.path) : undefined; - if (argsObjectHasSpread(options.metaArgsPath) || argsObjectHasSpread(storyArgsPath)) { + if (argsObjectHasSpread(options.metaArgsPath?.node) || argsObjectHasSpread(storyArgsPath?.node)) { return { ...storyDoc, error: { @@ -318,23 +309,19 @@ function enrichStoryDoc( * @example `{ args: sharedArgs }` → "Unsupported story args"; `{ args: { a: 1 } }` → undefined */ function argsContainerError(path?: NodePath): StoryDoc['error'] | undefined { - const property = path - ?.get('properties') - .find((prop) => prop.isObjectProperty() && keyOf(prop.node) === ARGS_PROPERTY); - - if (!property?.isObjectProperty()) { + const value = propertyValue(path?.node, ARGS_PROPERTY); + if (!value) { return undefined; } - const value = property.get('value'); - if (value.isObjectExpression()) { + if (t.isObjectExpression(value)) { return undefined; } - if (value.isIdentifier()) { + if (t.isIdentifier(value)) { return { name: 'Unsupported story args', - message: `Arg "args" references "${value.node.name}", which cannot be statically inlined yet.`, + message: `Arg "args" references "${value.name}", which cannot be statically inlined yet.`, }; } diff --git a/code/renderers/vue3/src/story-docs/render-sfc.ts b/code/renderers/vue3/src/story-docs/render-sfc.ts index 2a41fb88affe..9fa07458e2b4 100644 --- a/code/renderers/vue3/src/story-docs/render-sfc.ts +++ b/code/renderers/vue3/src/story-docs/render-sfc.ts @@ -62,7 +62,14 @@ function renderPropArg(arg: PropArg, ctx: RenderContext): RenderedProp | undefin if (value.value === '') { return undefined; } - return { name: arg.name, attribute: `${arg.name}=${quoteAttributeValue(value.value)}` }; + { + const quoted = quoteAttributeValue(value.value); + if (quoted === undefined) { + ctx.variables[arg.name] = printValue(value); + return { name: arg.name, attribute: `:${arg.name}="${arg.name}"` }; + } + return { name: arg.name, attribute: `${arg.name}=${quoted}` }; + } case 'BooleanLiteral': return { name: arg.name, @@ -152,14 +159,15 @@ ${importsCode ? `${importsCode}\n\n${variablesCode}` : variablesCode} `; } -function quoteAttributeValue(value: string): string { +/** Quoted attribute value, or `undefined` when both quote styles occur and it must be hoisted. */ +function quoteAttributeValue(value: string): string | undefined { if (!value.includes('"')) { return `"${value}"`; } if (!value.includes("'")) { return `'${value}'`; } - return `"${value.replaceAll('"', '"')}"`; + return undefined; } function printValue(node: t.Node): string { From 5e327fe947ccb3e1b1c3dda78d3f44c0cc30a18a Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 10 Aug 2026 10:21:32 +0200 Subject: [PATCH 05/19] fix: Suppress snippets for render stories and classify args from argTypes --- .../story-docs.payload.snapshot | 3 -- .../slot-scoped/story-docs.payload.snapshot | 3 -- .../story-docs.payload.snapshot | 6 --- .../story-docs.payload.snapshot | 3 -- .../story-docs.payload.snapshot | 3 -- .../vue3/story-docs/build-story-docs.test.ts | 40 +++++++++++++--- .../vue3/src/story-docs/build-story-docs.ts | 48 ++++++++----------- 7 files changed, 54 insertions(+), 52 deletions(-) diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot index 262c2a973dfc..71417abd56fc 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/render-function/story-docs.payload.snapshot @@ -8,9 +8,6 @@ "description": undefined, "id": "forms-render-function--primary", "name": "Primary", - "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/slot-scoped/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/slot-scoped/story-docs.payload.snapshot index 4e3652b1f1c7..5f3cdccdc74c 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/slot-scoped/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/slot-scoped/story-docs.payload.snapshot @@ -8,9 +8,6 @@ "description": undefined, "id": "forms-slot-scoped--primary", "name": "Primary", - "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-meta-render/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-meta-render/story-docs.payload.snapshot index 67887449e0f3..87cff13ed993 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-meta-render/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-meta-render/story-docs.payload.snapshot @@ -8,18 +8,12 @@ "description": undefined, "id": "forms-template-meta-render--primary", "name": "Primary", - "snippet": "", "summary": undefined, }, "forms-template-meta-render--secondary": { "description": undefined, "id": "forms-template-meta-render--secondary", "name": "Secondary", - "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-string/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-string/story-docs.payload.snapshot index 1adf5bff4717..49fb4109644f 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-string/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-string/story-docs.payload.snapshot @@ -8,9 +8,6 @@ "description": undefined, "id": "forms-template-string--primary", "name": "Primary", - "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-unsupported/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-unsupported/story-docs.payload.snapshot index 9f2db7290b6c..ebd35a03e81f 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-unsupported/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/template-unsupported/story-docs.payload.snapshot @@ -8,9 +8,6 @@ "description": undefined, "id": "forms-template-unsupported--primary", "name": "Primary", - "snippet": "", "summary": undefined, }, }, diff --git a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts index 4f2c084271b9..bb6868e3dd22 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/build-story-docs.test.ts @@ -20,6 +20,7 @@ type FixtureCase = { testDir: string; tree: 'story-docs' | 'docgen'; }; +type FixtureArgTypes = NonNullable; /** A directory is a fixture only if it holds a story file, so stray tooling dirs stay out. */ function fixtureCases(fixturesDir: string): string[] { @@ -57,10 +58,25 @@ function docgenForFixture( return undefined; } - const details: Record = { - 'function-slot': { slots: ['default'] }, - slots: { slots: ['default', 'header'] }, - 'v-model': { events: ['update:modelValue', 'update:checked'] }, + const argTypesByFixture: Record = { + 'function-slot': { + default: argType('default', 'slots'), + }, + 'prop-slot-collision': { + default: argType('default', 'props'), + icon: argType('icon', 'props'), + }, + 'slot-scoped': { + item: argType('item', 'slots'), + }, + slots: { + default: argType('default', 'slots'), + header: argType('header', 'slots'), + }, + 'v-model': { + 'update:checked': argType('update:checked', 'events'), + 'update:modelValue': argType('update:modelValue', 'events'), + }, }; return { @@ -68,9 +84,19 @@ function docgenForFixture( name: componentNameFromFixture(fixtureCase), path, jsDocTags: {}, - vueComponentMeta: { - events: (details[fixtureCase]?.events ?? []).map((name) => ({ name })), - slots: (details[fixtureCase]?.slots ?? []).map((name) => ({ name })), + argTypes: argTypesByFixture[fixtureCase] ?? {}, + }; +} + +function argType(name: string, category: string): FixtureArgTypes[string] { + return { + name, + table: { + category, + }, + type: { + name: 'other', + value: 'unknown', }, }; } diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index 04fff1180cda..47df923187a0 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -88,6 +88,7 @@ export async function buildStoryDocsPayload( stories: extractStories(csf, { componentName, docgenArgInfo, + metaPath: metaObjectPath(csf), metaArgsError: argsContainerError(metaObjectPath(csf)), metaArgsPath: metaArgsObjectPath(csf), }), @@ -140,25 +141,11 @@ async function readDocgenFromService(id: string): Promise(); const events = new Set(); - const vueComponentMeta = payload.vueComponentMeta; - - if (isVueComponentMeta(vueComponentMeta)) { - for (const slot of vueComponentMeta.slots) { - if (typeof slot.name === 'string') { - slots.add(slot.name); - } - } - for (const event of vueComponentMeta.events) { - if (typeof event.name === 'string') { - events.add(event.name); - } - } - } for (const [name, argType] of Object.entries(payload.argTypes ?? {})) { const category = argType.table?.category; @@ -172,17 +159,6 @@ function vueDocgenArgInfo(payload: DocgenPayload): VueDocgenArgInfo { return { slots, events }; } -function isVueComponentMeta( - value: unknown -): value is { slots: Array<{ name?: unknown }>; events: Array<{ name?: unknown }> } { - return ( - typeof value === 'object' && - value !== null && - Array.isArray((value as { slots?: unknown }).slots) && - Array.isArray((value as { events?: unknown }).events) - ); -} - function metaArgsObjectPath(csf: ParsedCsf): ArgsObjectPath { return argsObjectPathFromObjectPath(metaObjectPath(csf)); } @@ -217,11 +193,13 @@ function extractStories( options: { componentName?: string; docgenArgInfo?: VueDocgenArgInfo; + metaPath?: NodePath; metaArgsError?: StoryDoc['error']; metaArgsPath: ArgsObjectPath; } ): Record { - const metaArgs = metaArgsRecord(metaObjectPath(csf)?.node); + const metaPath = options.metaPath ?? metaObjectPath(csf); + const metaArgs = metaArgsRecord(metaPath?.node); return Object.fromEntries( Object.entries(csf._stories).map(([storyExport, story]): [string, StoryDoc] => { @@ -248,6 +226,7 @@ function enrichStoryDoc( options: { componentName?: string; docgenArgInfo?: VueDocgenArgInfo; + metaPath?: NodePath; metaArgsError?: StoryDoc['error']; metaArgsPath: ArgsObjectPath; } @@ -267,6 +246,11 @@ function enrichStoryDoc( return storyDoc; } + const storyConfig = normalized.type === 'config' ? normalized.path.node : undefined; + if (hasEffectiveRender(storyConfig, options.metaPath?.node)) { + return storyDoc; + } + const storyArgsError = normalized.type === 'config' ? argsContainerError(normalized.path) : undefined; if (options.metaArgsError || storyArgsError) { @@ -303,6 +287,16 @@ function enrichStoryDoc( }; } +/** + * Effective render presence means runtime source stays authoritative. + */ +function hasEffectiveRender( + storyConfig: t.ObjectExpression | undefined, + metaConfig: t.ObjectExpression | undefined +): boolean { + return Boolean(propertyValue(storyConfig, 'render') || propertyValue(metaConfig, 'render')); +} + /** * Error for an `args` value that is not an object literal and so cannot be statically inlined. * From 6619ccebe5ae8d2972b1017017a02fad9599076a Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 10 Aug 2026 14:37:00 +0200 Subject: [PATCH 06/19] fix: gen safe attributes --- .../vue3/src/story-docs/render-sfc.test.ts | 38 +++++++++++ .../vue3/src/story-docs/render-sfc.ts | 64 +++++++++++++++---- 2 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 code/renderers/vue3/src/story-docs/render-sfc.test.ts diff --git a/code/renderers/vue3/src/story-docs/render-sfc.test.ts b/code/renderers/vue3/src/story-docs/render-sfc.test.ts new file mode 100644 index 000000000000..84afd9f9ff3d --- /dev/null +++ b/code/renderers/vue3/src/story-docs/render-sfc.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { types as t } from 'storybook/internal/babel'; + +import { renderSfcSnippet } from './render-sfc.ts'; + +describe('renderSfcSnippet', () => { + it('allocates valid unique names for hoisted bindings', () => { + const snippet = renderSfcSnippet({ + componentName: 'MyComponent', + args: [ + { type: 'prop', name: 'aria-label', value: t.objectExpression([]) }, + { type: 'prop', name: 'ariaLabel', value: t.arrayExpression([]) }, + { type: 'prop', name: 'default', value: t.objectExpression([]) }, + { type: 'prop', name: 'ref', value: t.objectExpression([]) }, + { type: 'model', name: 'model-value', value: t.stringLiteral('value') }, + ], + }); + + expect(snippet).toBe(` + +`); + }); +}); diff --git a/code/renderers/vue3/src/story-docs/render-sfc.ts b/code/renderers/vue3/src/story-docs/render-sfc.ts index 9fa07458e2b4..d6d5f49f1612 100644 --- a/code/renderers/vue3/src/story-docs/render-sfc.ts +++ b/code/renderers/vue3/src/story-docs/render-sfc.ts @@ -1,4 +1,4 @@ -import { recast, type types as t } from 'storybook/internal/babel'; +import { recast, types as t } from 'storybook/internal/babel'; import type { ClassifiedArg, ModelArg, PropArg, SlotArg } from './classify-args.ts'; @@ -12,8 +12,8 @@ export interface RenderSfcInput { interface RenderContext { /** Imports hoisted into ``; } +function createRenderContext(args: ClassifiedArg[]): RenderContext { + const imports: RenderContext['imports'] = {}; + const bindings = new Set(); + + if ( + args.some((arg) => { + if (arg.type !== 'model') { + return false; + } + const value = unwrapValue(arg.value); + return value.type !== 'ArrowFunctionExpression' && value.type !== 'FunctionExpression'; + }) + ) { + imports[VUE_PACKAGE] = new Set(['ref']); + bindings.add('ref'); + } + + return { imports, bindings, variables: new Map() }; +} + +function allocateBindingName(name: string, ctx: RenderContext): string { + const baseName = t.toIdentifier(name); + let bindingName = baseName; + let suffix = 2; + + while (ctx.bindings.has(bindingName)) { + bindingName = `${baseName}${suffix}`; + suffix += 1; + } + + ctx.bindings.add(bindingName); + return bindingName; +} + /** Quoted attribute value, or `undefined` when both quote styles occur and it must be hoisted. */ function quoteAttributeValue(value: string): string | undefined { if (!value.includes('"')) { From 2c2460e2692d588e3288453a033080508fcf030a Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 10 Aug 2026 15:26:05 +0200 Subject: [PATCH 07/19] fix: reject nested identifier references --- .../vue3/src/story-docs/classify-args.test.ts | 49 +++++++++++++++++++ .../vue3/src/story-docs/classify-args.ts | 40 +++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 code/renderers/vue3/src/story-docs/classify-args.test.ts diff --git a/code/renderers/vue3/src/story-docs/classify-args.test.ts b/code/renderers/vue3/src/story-docs/classify-args.test.ts new file mode 100644 index 000000000000..347be72485e8 --- /dev/null +++ b/code/renderers/vue3/src/story-docs/classify-args.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { types as t } from 'storybook/internal/babel'; + +import { classifyArgs } from './classify-args.ts'; + +describe('classifyArgs', () => { + it.each([ + { + label: 'object', + value: t.objectExpression([ + t.objectProperty( + t.identifier('nested'), + t.objectExpression([ + t.objectProperty(t.identifier('value'), t.identifier('OBJECT_VALUE')), + ]) + ), + ]), + identifier: 'OBJECT_VALUE', + }, + { + label: 'array', + value: t.arrayExpression([ + t.arrayExpression([t.stringLiteral('static'), t.identifier('ARRAY_VALUE')]), + ]), + identifier: 'ARRAY_VALUE', + }, + ])('rejects an identifier nested in an $label value', ({ value, identifier }) => { + expect(classifyArgs({ options: value }, { slots: new Set(), events: new Set() })).toEqual({ + args: [], + error: { + name: 'Unsupported story args', + message: `Arg "options" references "${identifier}", which cannot be statically inlined yet.`, + }, + }); + }); + + it('keeps spread identifiers classified as spread values', () => { + const value = t.objectExpression([t.spreadElement(t.identifier('BASE_OPTIONS'))]); + + expect(classifyArgs({ options: value }, { slots: new Set(), events: new Set() })).toEqual({ + args: [], + error: { + name: 'Unsupported story args', + message: 'Arg "options" contains a spread value, which cannot be statically inlined yet.', + }, + }); + }); +}); diff --git a/code/renderers/vue3/src/story-docs/classify-args.ts b/code/renderers/vue3/src/story-docs/classify-args.ts index 8ebf97f4bd9e..a66ad9a7b1ff 100644 --- a/code/renderers/vue3/src/story-docs/classify-args.ts +++ b/code/renderers/vue3/src/story-docs/classify-args.ts @@ -99,20 +99,52 @@ function unsupportedArgReason(name: string, value: t.Node): string | undefined { return undefined; } - const unwrapped = unwrapValue(value); - if (unwrapped.type === 'Identifier') { - return `Arg "${name}" references "${unwrapped.name}", which cannot be statically inlined yet.`; + const identifier = findIdentifierValue(value); + if (identifier) { + return `Arg "${name}" references "${identifier.name}", which cannot be statically inlined yet.`; } // TODO: check for other non-serializable values like `new Date()`, `new Map()` // TODO: check how spread values can be supported, e.g. `args: { ...defaultArgs, foo: 'bar' }` or `args: { foo: 'bar', ...extraArgs }` - if (hasSpreadValue(unwrapped)) { + if (hasSpreadValue(value)) { return `Arg "${name}" contains a spread value, which cannot be statically inlined yet.`; } return undefined; } +function findIdentifierValue(node: t.Node): t.Identifier | undefined { + const unwrapped = unwrapValue(node); + + if (unwrapped.type === 'Identifier') { + return unwrapped; + } + + if (unwrapped.type === 'ObjectExpression') { + for (const property of unwrapped.properties) { + if (property.type === 'ObjectProperty') { + const identifier = findIdentifierValue(property.value); + if (identifier) { + return identifier; + } + } + } + } + + if (unwrapped.type === 'ArrayExpression') { + for (const element of unwrapped.elements) { + if (element && element.type !== 'SpreadElement') { + const identifier = findIdentifierValue(element); + if (identifier) { + return identifier; + } + } + } + } + + return undefined; +} + function hasSpreadValue(node: t.Node): boolean { const unwrapped = unwrapValue(node); From 5698f9139da86e9c0175eaf1acc75fe830f47348 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Tue, 11 Aug 2026 15:49:16 +0200 Subject: [PATCH 08/19] fix: treat undefined args as unset --- .../args-formatting/input.stories.ts | 7 ++++ .../story-docs.payload.snapshot | 9 +++++ .../vue3/src/story-docs/classify-args.test.ts | 40 +++++++++++++++++++ .../vue3/src/story-docs/classify-args.ts | 13 +++++- 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/input.stories.ts b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/input.stories.ts index afffca803b46..44983c153774 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/input.stories.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/input.stories.ts @@ -24,3 +24,10 @@ export const Primary: Story = { onSubmit: () => undefined, }, }; + +export const UnsetArg: Story = { + args: { + label: undefined, + count: 3, + }, +}; diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot index f62521c75558..252b06e156a5 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/args-formatting/story-docs.payload.snapshot @@ -18,6 +18,15 @@ const items = ['one', 'two']; ", + "summary": undefined, + }, + "forms-args-formatting--unset-arg": { + "description": undefined, + "id": "forms-args-formatting--unset-arg", + "name": "Unset Arg", + "snippet": "", "summary": undefined, }, diff --git a/code/renderers/vue3/src/story-docs/classify-args.test.ts b/code/renderers/vue3/src/story-docs/classify-args.test.ts index 347be72485e8..bc991421f819 100644 --- a/code/renderers/vue3/src/story-docs/classify-args.test.ts +++ b/code/renderers/vue3/src/story-docs/classify-args.test.ts @@ -35,6 +35,46 @@ describe('classifyArgs', () => { }); }); + it('drops an arg set to undefined without failing the story', () => { + const label = t.stringLiteral('ok'); + + expect( + classifyArgs({ a: t.identifier('undefined'), label }, { slots: new Set(), events: new Set() }) + ).toEqual({ args: [{ type: 'prop', name: 'label', value: label }] }); + }); + + it.each([ + { label: 'slot', docgen: { slots: new Set(['content']), events: new Set() } }, + { label: 'v-model', docgen: { slots: new Set(), events: new Set(['update:content']) } }, + ])('drops an undefined $label arg', ({ docgen }) => { + expect(classifyArgs({ content: t.identifier('undefined') }, docgen)).toEqual({ args: [] }); + }); + + it('keeps undefined nested in an object value', () => { + const value = t.objectExpression([ + t.objectProperty(t.identifier('color'), t.identifier('undefined')), + ]); + + expect(classifyArgs({ options: value }, { slots: new Set(), events: new Set() })).toEqual({ + args: [{ type: 'prop', name: 'options', value }], + }); + }); + + it('still rejects an identifier alongside an undefined arg', () => { + expect( + classifyArgs( + { a: t.identifier('undefined'), label: t.identifier('LABEL') }, + { slots: new Set(), events: new Set() } + ) + ).toEqual({ + args: [], + error: { + name: 'Unsupported story args', + message: 'Arg "label" references "LABEL", which cannot be statically inlined yet.', + }, + }); + }); + it('keeps spread identifiers classified as spread values', () => { const value = t.objectExpression([t.spreadElement(t.identifier('BASE_OPTIONS'))]); diff --git a/code/renderers/vue3/src/story-docs/classify-args.ts b/code/renderers/vue3/src/story-docs/classify-args.ts index a66ad9a7b1ff..33a035932e12 100644 --- a/code/renderers/vue3/src/story-docs/classify-args.ts +++ b/code/renderers/vue3/src/story-docs/classify-args.ts @@ -51,6 +51,7 @@ export interface ClassifyArgsResult { } const UNSUPPORTED_ARG_ERROR_NAME = 'Unsupported story args'; +const UNDEFINED_IDENTIFIER = 'undefined'; /** Classify merged CSF args by Vue docgen precedence: slot, v-model, function skip, prop. */ export function classifyArgs( @@ -71,6 +72,11 @@ export function classifyArgs( }; } + // `args: { a: undefined }` unsets an inherited meta arg, so it renders nothing. + if (isUndefinedIdentifier(value)) { + continue; + } + if (docgen.slots.has(name)) { if (isFunctionExpression(value)) { return { args: [], skipSnippet: true }; @@ -117,7 +123,7 @@ function findIdentifierValue(node: t.Node): t.Identifier | undefined { const unwrapped = unwrapValue(node); if (unwrapped.type === 'Identifier') { - return unwrapped; + return unwrapped.name === UNDEFINED_IDENTIFIER ? undefined : unwrapped; } if (unwrapped.type === 'ObjectExpression') { @@ -175,6 +181,11 @@ function hasSpreadValue(node: t.Node): boolean { return false; } +function isUndefinedIdentifier(node: t.Node): boolean { + const unwrapped = unwrapValue(node); + return unwrapped.type === 'Identifier' && unwrapped.name === UNDEFINED_IDENTIFIER; +} + function isFunctionExpression( node: T ): node is T & (t.ArrowFunctionExpression | t.FunctionExpression) { From 75102363c1864415b4e78fb56bc24c150fba36a0 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Tue, 11 Aug 2026 16:04:05 +0200 Subject: [PATCH 09/19] fix: read CSF2 assigned story args --- .../csf1-legacy/input.stories.ts | 14 ++++++- .../csf1-legacy/story-docs.payload.snapshot | 18 +++++++++ .../vue3/src/story-docs/build-story-docs.ts | 37 ++++++++++--------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/input.stories.ts b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/input.stories.ts index 5fc828d41af5..9f022358b407 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/input.stories.ts +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/input.stories.ts @@ -1,4 +1,4 @@ -import type { Meta, StoryFn } from '@storybook/vue3'; +import type { Meta, StoryFn, StoryObj } from '@storybook/vue3'; import CsfOneButton from './CsfOneButton.vue'; @@ -19,3 +19,15 @@ export const Primary = Template.bind({}); Primary.args = { label: 'Legacy', }; + +export const AssignedArgs: StoryObj = {}; +AssignedArgs.args = { + label: 'Assigned', +}; + +export const AssignedArgsOverrideInline: StoryObj = { + args: { label: 'Inline' }, +}; +AssignedArgsOverrideInline.args = { + label: 'Assigned wins', +}; diff --git a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/story-docs.payload.snapshot index 5a4ce55601c8..68a12743f92f 100644 --- a/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/story-docs/__testfixtures__/csf1-legacy/story-docs.payload.snapshot @@ -4,6 +4,24 @@ "name": "CsfOneButton", "path": "__PATH__", "stories": { + "forms-csf1-legacy--assigned-args": { + "description": undefined, + "id": "forms-csf1-legacy--assigned-args", + "name": "Assigned Args", + "snippet": "", + "summary": undefined, + }, + "forms-csf1-legacy--assigned-args-override-inline": { + "description": undefined, + "id": "forms-csf1-legacy--assigned-args-override-inline", + "name": "Assigned Args Override Inline", + "snippet": "", + "summary": undefined, + }, "forms-csf1-legacy--primary": { "description": undefined, "id": "forms-csf1-legacy--primary", diff --git a/code/renderers/vue3/src/story-docs/build-story-docs.ts b/code/renderers/vue3/src/story-docs/build-story-docs.ts index 47df923187a0..21ac20539273 100644 --- a/code/renderers/vue3/src/story-docs/build-story-docs.ts +++ b/code/renderers/vue3/src/story-docs/build-story-docs.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { isAbsolute, join } from 'node:path'; -import { type NodePath, types as t } from 'storybook/internal/babel'; +import { types as t, type NodePath } from 'storybook/internal/babel'; import { STORY_FILE_TEST_REGEXP, getComponentIdFromEntry, @@ -22,6 +22,7 @@ import { normalizeStoryDeclaration, resolveComponentImport, propertyValue, + storyAssignedArgsPath, } from 'storybook/internal/csf-tools'; import type { StoryDoc, StoryDocsPayload, StoryDocsProviderInput } from 'storybook/internal/types'; import type { DocgenPayload, DocgenService } from 'storybook/open-service'; @@ -32,7 +33,7 @@ import { renderSfcSnippet } from './render-sfc.ts'; export interface BuildStoryDocsContext { /** Resolve a CSF import path to an absolute file path. Defaults to `process.cwd()` join. */ resolvePath?: (importPath: string) => string; - /** Reads fresh docgen for the component id. Defaults to the registered core/docgen service. */ + /** Reads docgen for the component id. Defaults to the registered core/docgen service. */ readDocgen?: (id: string) => Promise; } @@ -74,7 +75,12 @@ export async function buildStoryDocsPayload( } const id = getComponentIdFromEntry(input.entry); - const docgenPayload = await readFreshDocgen(id, context.readDocgen); + let docgenPayload: DocgenPayload | undefined; + try { + docgenPayload = await (context.readDocgen ?? readStoredDocgen)(id); + } catch { + // Docgen is optional here: without it the payload is still built, just without snippets. + } const componentName = resolveMetaComponentIdentifier(csf); const importStatement = createImportStatement(csf); const docgenArgInfo = @@ -124,20 +130,12 @@ function createImportStatement(csf: ParsedCsf): string | undefined { return buildImportStatements({ refs: [ref] }).join('\n') || undefined; } -async function readFreshDocgen( - id: string, - readDocgen = readDocgenFromService -): Promise { - try { - return await readDocgen(id); - } catch { - return undefined; - } -} - -async function readDocgenFromService(id: string): Promise { +/** + * Stored docgen for the component, extracted on demand only when nothing is stored yet. + */ +async function readStoredDocgen(id: string): Promise { const docgen = getService('core/docgen', { internal: true }); - return docgen.commands.extractDocgen({ id }); + return docgen.queries.docgen.get({ id }) ?? docgen.queries.docgen.loaded({ id }); } /** @@ -257,8 +255,11 @@ function enrichStoryDoc( return { ...storyDoc, error: options.metaArgsError ?? storyArgsError }; } + // `Primary.args = { … }` runs after the declaration and replaces its args object outright, so an + // assignment wins over inline args rather than merging with them. const storyArgsPath = - normalized.type === 'config' ? argsObjectPathFromObjectPath(normalized.path) : undefined; + storyAssignedArgsPath(csf._file.path, storyExport) ?? + (normalized.type === 'config' ? argsObjectPathFromObjectPath(normalized.path) : undefined); if (argsObjectHasSpread(options.metaArgsPath?.node) || argsObjectHasSpread(storyArgsPath?.node)) { return { ...storyDoc, @@ -269,7 +270,7 @@ function enrichStoryDoc( }; } - const storyArgs = normalized.type === 'config' ? argsRecordFromObjectPath(storyArgsPath) : {}; + const storyArgs = argsRecordFromObjectPath(storyArgsPath); const classified = classifyArgs(mergeArgsRecords(metaArgs, storyArgs), options.docgenArgInfo); if (classified.skipSnippet) { return storyDoc; From 2003598646a4a8dcb6bc7a227ddadc38dfb2e2f3 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Tue, 11 Aug 2026 16:37:45 +0200 Subject: [PATCH 10/19] refactor: classify arg values into a closed union --- .../src/compare/expect-current-or-better.ts | 60 ++++- .../story-docs.payload.snapshot | 8 +- .../props-ts-enum/story-docs.payload.snapshot | 3 +- .../v-model/story-docs.payload.snapshot | 4 +- .../story-docs.payload.snapshot | 4 +- .../story-docs.payload.snapshot | 8 - .../v-model/story-docs.payload.snapshot | 4 +- .../vue3/story-docs/snippet-parity.test.ts | 10 + .../vue3/src/story-docs/build-story-docs.ts | 6 +- .../vue3/src/story-docs/classify-args.test.ts | 142 ++++++----- .../vue3/src/story-docs/classify-args.ts | 223 +++++------------- .../src/story-docs/classify-value.test.ts | 72 ++++++ .../vue3/src/story-docs/classify-value.ts | 184 +++++++++++++++ .../vue3/src/story-docs/render-sfc.test.ts | 98 +++++++- .../vue3/src/story-docs/render-sfc.ts | 169 +++++-------- 15 files changed, 622 insertions(+), 373 deletions(-) create mode 100644 code/renderers/vue3/src/story-docs/classify-value.test.ts create mode 100644 code/renderers/vue3/src/story-docs/classify-value.ts diff --git a/code/lib/docgen-harness/src/compare/expect-current-or-better.ts b/code/lib/docgen-harness/src/compare/expect-current-or-better.ts index 8f36191b9001..8788074e031b 100644 --- a/code/lib/docgen-harness/src/compare/expect-current-or-better.ts +++ b/code/lib/docgen-harness/src/compare/expect-current-or-better.ts @@ -11,22 +11,60 @@ export type ExpectCurrentOrBetterInput = baseline: StrictArgTypes; candidate: StrictArgTypes; } & CompareArgTypesOptions) - | ({ kind: 'snippet' } & CompareSnippetInput); + | ({ kind: 'snippet' } & CompareSnippetInput & { + /** + * Args the candidate is expected to leave out even though the baseline represents them, + * because their source references a binding a static snippet cannot declare. The candidate + * records each one in `StoryDoc.warning`, and the runtime keeps its own snippet for readers + * who need the resolved value. + * + * Checked in both directions: an arg listed here that the candidate does represent fails, so + * the list cannot outlive the gap it documents. + */ + declaredOmissions?: readonly string[]; + }); /** Throws a single error listing every violation, so a failure shows the whole gap at once. */ export function expectCurrentOrBetter(input: ExpectCurrentOrBetterInput): void { - const violations: Violation[] = - input.kind === 'argTypes' - ? compareArgTypes(input.baseline, input.candidate, { - legacyBaseline: input.legacyBaseline, - strictTable: input.strictTable, - }) - : compareSnippet(input); - if (violations.length > 0) { - const lines = violations.map((v) => `- [${v.kind}] ${v.arg}: ${v.message}`); + if (input.kind === 'argTypes') { + throwOnViolations( + compareArgTypes(input.baseline, input.candidate, { + legacyBaseline: input.legacyBaseline, + strictTable: input.strictTable, + }) + ); + return; + } + + const declaredOmissions = input.declaredOmissions ?? []; + const violations = compareSnippet(input); + const omitted = new Set( + violations.filter((v) => v.kind === 'lost-representation').map((v) => v.arg) + ); + + const stale = declaredOmissions.filter((arg) => !omitted.has(arg)); + if (stale.length > 0) { // eslint-disable-next-line local-rules/no-uncategorized-errors throw new Error( - `expectCurrentOrBetter found ${violations.length} violation(s):\n${lines.join('\n')}` + `The candidate snippet now represents ${stale.join(', ')} — remove it from declaredOmissions` ); } + + throwOnViolations( + violations.filter( + (v) => !(v.kind === 'lost-representation' && declaredOmissions.includes(v.arg)) + ) + ); +} + +function throwOnViolations(violations: Violation[]): void { + if (violations.length === 0) { + return; + } + + const lines = violations.map((v) => `- [${v.kind}] ${v.arg}: ${v.message}`); + // eslint-disable-next-line local-rules/no-uncategorized-errors + throw new Error( + `expectCurrentOrBetter found ${violations.length} violation(s):\n${lines.join('\n')}` + ); } diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot index ba01f36a2444..a37a44f8ab4b 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-basic-types/story-docs.payload.snapshot @@ -18,13 +18,17 @@ "id": "forms-props-basic-types--unrepresentable-args", "name": "Unrepresentable Args", "snippet": " ", "summary": undefined, }, diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot index ad69020aa8bd..dd77dafd245c 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/props-ts-enum/story-docs.payload.snapshot @@ -9,9 +9,10 @@ "id": "forms-props-ts-enum--props-as-written", "name": "Props As Written", "snippet": "", "summary": undefined, + "warning": "Omitted args that cannot be resolved statically: severity: Severity.Warning", }, }, } \ No newline at end of file diff --git a/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot b/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot index d3b4f1d40633..dcd50b43ef5c 100644 --- a/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot +++ b/code/lib/docgen-harness/src/vue3/__testfixtures__/v-model/story-docs.payload.snapshot @@ -11,9 +11,9 @@ "snippet": "