From 5bb5f44b2761c998993640e5a7b3c867a966274b Mon Sep 17 00:00:00 2001 From: webdevsamran Date: Thu, 27 Aug 2026 14:37:35 +0500 Subject: [PATCH 001/326] fix(docs): prevent source transform from being applied twice on docs pages When dynamic story snippets are rendered, emitTransformCode applies parameters.docs.source.transform before emitting SNIPPET_RENDERED to the source context. When the docs block rendered the snippet, useCode re-applied sourceParameters.transform unconditionally, causing the transform to run twice on docs pages while running only once on the individual story Code tab. Fix: - In Source.tsx: only apply sourceParameters.transform when not already using an emitted snippet, or when explicitly overridden via transformFromProps. - In Source.test.tsx: add unit tests verifying single execution on snippets, prop transform override, and originalSource transform fallback. Fixes #35889 --- .../docs/src/blocks/blocks/Source.test.tsx | 78 +++++++++++++++++++ code/addons/docs/src/blocks/blocks/Source.tsx | 5 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 code/addons/docs/src/blocks/blocks/Source.test.tsx diff --git a/code/addons/docs/src/blocks/blocks/Source.test.tsx b/code/addons/docs/src/blocks/blocks/Source.test.tsx new file mode 100644 index 000000000000..d2a720261d90 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/Source.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SourceType } from 'storybook/internal/docs-tools'; +import { useCode } from './Source.tsx'; + +describe('useCode', () => { + it('does not apply sourceParameters.transform twice when snippet is already provided (issue #35889)', () => { + const transform = vi.fn((code: string) => /*transformed*/ ); + + const result = useCode({ + snippet: '/*transformed*/ ', + 'panel-part': 'color: rebeccapurple;', + '--slot-panel-color': 'teal', + }, +}; +``` + +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" +import type { Meta, StoryObj } from '@storybook/web-components-vite'; + +const meta = { + component: 'lit-slots-and-css', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Args: Story = { + args: { + heading: 'Default render', + 'default-slot': 'Body text', + 'actions-slot': '', + 'panel-part': 'color: rebeccapurple;', + '--slot-panel-color': 'teal', + }, +}; +``` diff --git a/docs/_snippets/web-components-story-snippet-listeners.md b/docs/_snippets/web-components-story-snippet-listeners.md new file mode 100644 index 000000000000..102ad3533cdc --- /dev/null +++ b/docs/_snippets/web-components-story-snippet-listeners.md @@ -0,0 +1,30 @@ +```js filename="lit-events.stories.js" renderer="web-components" language="js" +export default { + component: 'lit-events', +}; + +export const Listeners = { + args: { + value: 'changed', + 'my-change-event': () => {}, + }, +}; +``` + +```ts filename="lit-events.stories.ts" renderer="web-components" language="ts" +import type { Meta, StoryObj } from '@storybook/web-components-vite'; + +const meta = { + component: 'lit-events', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Listeners: Story = { + args: { + value: 'changed', + 'my-change-event': () => {}, + }, +}; +``` diff --git a/docs/_snippets/web-components-story-snippet-template.md b/docs/_snippets/web-components-story-snippet-template.md new file mode 100644 index 000000000000..16ed03fd569a --- /dev/null +++ b/docs/_snippets/web-components-story-snippet-template.md @@ -0,0 +1,41 @@ +```js filename="lit-slots-and-css.stories.js" renderer="web-components" language="js" +import { html } from 'lit'; + +export default { + component: 'lit-slots-and-css', +}; + +export const Template = { + render: (args) => + html`

Default slot content

+
`, + args: { + heading: 'Projected', + }, +}; +``` + +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" +import { html } from 'lit'; +import type { Meta, StoryObj } from '@storybook/web-components-vite'; + +const meta = { + component: 'lit-slots-and-css', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Template: Story = { + render: (args) => + html`

Default slot content

+
`, + args: { + heading: 'Projected', + }, +}; +``` diff --git a/docs/_snippets/web-components-vite-framework-options.md b/docs/_snippets/web-components-vite-framework-options.md index ea3cf5e1be42..804f2713ea3d 100644 --- a/docs/_snippets/web-components-vite-framework-options.md +++ b/docs/_snippets/web-components-vite-framework-options.md @@ -3,7 +3,10 @@ export default { framework: { name: '@storybook/web-components-vite', options: { - // ... + customElementsManifest: '../custom-elements.json', + docgen: { + typeProperty: 'parsedType', + }, }, }, }; @@ -16,7 +19,10 @@ const config: StorybookConfig = { framework: { name: '@storybook/web-components-vite', options: { - // ... + customElementsManifest: '../custom-elements.json', + docgen: { + typeProperty: 'parsedType', + }, }, }, }; @@ -31,7 +37,10 @@ export default defineMain({ framework: { name: '@storybook/web-components-vite', options: { - // ... + customElementsManifest: '../custom-elements.json', + docgen: { + typeProperty: 'parsedType', + }, }, }, }); @@ -46,7 +55,10 @@ export default defineMain({ framework: { name: '@storybook/web-components-vite', options: { - // ... + customElementsManifest: '../custom-elements.json', + docgen: { + typeProperty: 'parsedType', + }, }, }, }); diff --git a/docs/get-started/frameworks/web-components-vite.mdx b/docs/get-started/frameworks/web-components-vite.mdx index b0a75dc07dc5..07446246b26d 100644 --- a/docs/get-started/frameworks/web-components-vite.mdx +++ b/docs/get-started/frameworks/web-components-vite.mdx @@ -40,6 +40,134 @@ To build Storybook, run: You will find the output in the configured `outputDir` (default is `storybook-static`). +## Configure + +Storybook for Web components with Vite works without extra configuration, but component documentation depends on where Storybook reads your Custom Elements Manifest. The sections below cover the available setup paths and framework options. + +### Component documentation + +JSDoc comments on your elements, attributes, properties, events, slots and CSS API reach [automatic documentation](../../writing-docs/autodocs.mdx) and the [controls](../../essentials/controls.mdx) table through a Custom Elements Manifest. + +Two paths can read the manifest, and which one you get depends on a single feature flag: + +| | [`docgenServer`](../../api/main-config/main-config-features.mdx) on, the default | flag off | +| --------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Reads the manifest | on the Storybook server, from the manifest file | in the browser, from a manifest you import in `.storybook/preview.ts` | +| Configure with | `framework.options.customElementsManifest` or `package.json#customElements` | `setCustomElementsManifest()` in the preview | +| Powers | controls, autodocs, code snippets, and the [components manifest](../../ai/manifests.mdx) | controls and autodocs | +| Updates while running | yes, the file is watched | no, restart or reload after regenerating the manifest | + +Server-side extraction is what you get unless you turn the flag off with `features: { docgenServer: false }`. The flag-off column is the path projects created before Storybook 11 use. + +Attributes, properties, events, slots, CSS custom properties, CSS parts and CSS states from the manifest become argTypes. Event, slot, part and state arg keys are suffixed, such as `my-change-event`, `default-slot`, `panel-part` and `active-state`, so they never collide with attributes. CSS custom properties keep their `--name` key. + +#### Generating a manifest + +Install and run `@custom-elements-manifest/analyzer` to generate a Custom Elements Manifest: + + + +The manifest must use Custom Elements Manifest `schemaVersion` 1.x or 2.x, generated by `@custom-elements-manifest/analyzer`. Drop `--litelement` for elements that do not extend `LitElement`. Add the command to your `package.json` scripts so it runs before `storybook dev`, or run the analyzer in `--watch` mode next to Storybook. + +The analyzer writes `custom-elements.json` in the current directory. If your `package.json` has a `customElements` field that points at that file, server-side docgen uses it without more configuration. + +#### Server-side extraction + +Point Storybook at the manifest file: + + + +Storybook resolves manifests in this order: + +- `framework.options.customElementsManifest`, relative to the Storybook config directory +- The `customElements` field in the nearest `package.json`, relative to that `package.json` +- Nothing, so server-side docgen is skipped + +When the manifest cannot be resolved and the flag is on, Storybook logs that docgen is skipped and the runtime path still works. + +If Storybook cannot read complete metadata, it reports the problem in the Controls table for the affected component and in the tools CLI (`npx storybook tools docs show`). These messages do not fail the build. + +- Missing manifest file: `Custom Elements Manifest not found yet; docs load once the analyzer writes it` +- Tag missing from the manifest: `No declaration for "" was found in . If the element is new, rerun the custom elements manifest analyzer.` +- Older web-component-analyzer manifest shape: ` uses the web-component-analyzer manifest shape. The Storybook docgen server reads Custom Elements Manifests only; generate one with @custom-elements-manifest/analyzer.` + +If a watched manifest becomes invalid after Storybook has loaded a valid version, Storybook keeps the last valid version and reports a warning that ends with `; using the last valid version`. + +For docgen to attach to a story, `meta.component` must be the tag name string, such as `component: 'demo-card'`. Metas without `component` get no docgen, and their stories show the story source in the Code panel. + +#### Story snippets + +With server-side docgen, Storybook generates snippets for the Code panel and the `docs-show` MCP tool from the story file. Args stories become one element with attributes from args: booleans print bare, objects print as JSON, CSS custom properties go into `style`, parts and states go into a leading ` + + Body text + + +``` + +Template stories read the returned template: + + + +```html + +

Default slot content

+ +
+``` + +Listener args add a script after the element: + + + +```html + + +``` + +`parameters.docs.source.code` always wins, and `parameters.docs.source.code: null` disables the snippet. See the [Source block](../../api/doc-blocks/doc-block-source.mdx) page for the full `parameters.docs.source` API. + +Storybook reports a warning above the snippet instead of printing values it cannot represent. Warnings cover directives such as `classMap` or `repeat`, properties with no paired attribute, args the template never uses, imported arg values, and render functions that build DOM in code. + +## Known limitations + +These apply to the default server-side extraction path. + +### Code snippets do not follow your controls + +The snippets shown in autodocs and the Code panel are generated from your story's source, so they show the story as you wrote it. Changing a value in the controls table updates the rendered component but not the snippet next to it. + +### Elements must be declared in the manifest + +A `meta.component` whose tag is not in the manifest gets the `No declaration` message listed above and no controls. + +Template-function components, like the `Button`, `Header` and `Page` the starter generates, have no custom element and therefore no docgen. Their Code panel shows the story source. + +### Properties without an attribute cannot be shown in HTML + +Properties with no paired attribute are listed in the snippet warning. Bind them through an attribute, or write the snippet yourself with `parameters.docs.source.code`. + ## FAQ ### How do I manually install the Web Components framework? @@ -62,6 +190,28 @@ You can pass an options object for additional configuration if needed: The available options are: +#### `customElementsManifest` + +Type: `string | string[]` + +Default: the `customElements` field of your `package.json` + +Path or paths to Custom Elements Manifest files, relative to the Storybook config directory. This option is ignored when `docgenServer` is off. + +#### `docgen` + +Type: `{ typeProperty?: string }` + +Configure how Storybook reads Custom Elements Manifest metadata. + +##### `docgen.typeProperty` + +Type: `string` + +Default: `'parsedType'` + +The manifest key holding analyzer-resolved type text. Use this when an analyzer plugin writes resolved type text under another key. + #### `builder` Type: `Record` From 33aa688b6a15c56be65a8df8e5924dfd41e0ea70 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 7 Oct 2026 15:53:02 +0200 Subject: [PATCH 006/326] Manager: Type index titles by their contents The manager index typed every title by its position, so an unattached MDX page got a synthetic component wrapper that the sidebar hid at render time and search, keyboard navigation and addons did not. Build untyped title nodes from the full index first, then type each node from what sits under it: other titles make a group, any story makes a component, a single docs page takes the title's place, and several docs pages make a group. Filters only prune entries, so index and filteredIndex always agree on the shape. The sidebar's render-time docs hoist and the search-layer workaround from #35495 are no longer needed and are removed. Closes #35513 Co-authored-by: Prajakta Ukirde <165979795+prajaktaukirde@users.noreply.github.com> --- MIGRATION.md | 22 ++ code/core/src/manager-api/lib/stories.test.ts | 90 +++++++ code/core/src/manager-api/lib/stories.ts | 255 +++++++++--------- code/core/src/manager-api/modules/stories.ts | 9 +- code/core/src/manager-api/tests/refs.test.ts | 3 +- .../src/manager-api/tests/stories.test.ts | 82 +++++- .../src/manager/components/sidebar/Search.tsx | 52 +--- .../components/sidebar/Tree.stories.tsx | 30 +-- .../src/manager/components/sidebar/Tree.tsx | 5 - code/core/src/types/modules/api-stories.ts | 2 +- code/e2e-internal/navigation.spec.ts | 6 +- 11 files changed, 341 insertions(+), 215 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index cf890c379a6c..55c5caf12f23 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,6 +19,7 @@ - [Yarn PnP support removed](#yarn-pnp-support-removed) - [Top-level `setConfig` layout and UI options removed](#top-level-setconfig-layout-and-ui-options-removed) - [Sidebar label rendering: renderAriaLabel and a context argument](#sidebar-label-rendering-renderarialabel-and-a-context-argument) + - [Manager index: title entries are typed by their contents](#manager-index-title-entries-are-typed-by-their-contents) - [Test runner support ended](#test-runner-support-ended) - [Vitest Addon: requires Vitest 4.0 or higher](#vitest-addon-requires-vitest-40-or-higher) - [Vitest Addon: `setProjectAnnotations` must not be called in setup files](#vitest-addon-setprojectannotations-must-not-be-called-in-setup-files) @@ -917,6 +918,27 @@ option exists in both places, keep the nested value because it was authoritative `sidebar.renderAriaLabel` was added alongside it and must return a plain string; it feeds accessible names for tree entries and the mobile bottom bar's current-page announcement. When `renderLabel` returns a React element, the bottom bar now falls back to the entry name for its concatenated announcement instead of stringifying the element. +### Manager index: title entries are typed by their contents + +The manager index (`useStorybookState().index` and `filteredIndex`) used to type every title by its position: the last segment of a title was always a `component`. +An unattached docs page (for example an MDX file with ``) was therefore wrapped in a synthetic `component` with a single `docs` child, which the sidebar hid when rendering. + +Each title is now typed by everything the story index puts under it: + +| Under the title | Entry in the manager index | +| --- | --- | +| Other titles | `group` (or `root`) | +| At least one story | `component` | +| Exactly one docs page and no stories | the `docs` entry itself, with the title's `name`, `depth` and `parent` (`undefined` at the top level) | +| Several docs pages and no stories | `group` | + +The type comes from the full index, so `index` and `filteredIndex` always agree on it. +Filters only remove entries: a component whose stories are all filtered out stays a `component` with its docs child, and the sidebar shows it as such instead of collapsing it. + +Functions passed to `sidebar.renderLabel` and sidebar filters receive the `docs` entry instead of the old wrapper and its `Docs` child. +Sidebar search lists these pages under their own name, for example `Introduction` instead of `Docs` under `Introduction`. +`api.selectStory('Introduction')` and `linkTo('Introduction')` still resolve to the docs page. + ### Test runner support ended Official support for [`@storybook/test-runner`](https://github.com/storybookjs/test-runner) has ended. The package stays published and accepts Storybook 11 and later as a peer dependency, so existing setups can keep running it at their own risk, but it no longer receives fixes or compatibility updates and prints a warning on every run. diff --git a/code/core/src/manager-api/lib/stories.test.ts b/code/core/src/manager-api/lib/stories.test.ts index a4a2916f368b..2effd6f51fcc 100644 --- a/code/core/src/manager-api/lib/stories.test.ts +++ b/code/core/src/manager-api/lib/stories.test.ts @@ -300,4 +300,94 @@ describe('transformStoryIndexToStoriesHash', () => { expect(result['story-2']).toBeUndefined(); expect(result['2']).toBeUndefined(); }); + + describe('title types', () => { + const options = { + provider: { getConfig: () => ({ sidebar: {} }) } as any, + docsOptions: { docsMode: false }, + filters: {}, + allStatuses: {}, + }; + const docs = (id: string, title: string, name = 'Docs') => ({ + id, + type: 'docs' as const, + title, + name, + importPath: `./${id}.mdx`, + storiesImports: [], + tags: [], + }); + const index: API_PreparedStoryIndex = { + v: 5, + entries: { + 'welcome--docs': docs('welcome--docs', 'Welcome'), + 'guides-intro--docs': docs('guides-intro--docs', 'Guides/Intro'), + 'guides-faq--setup': docs('guides-faq--setup', 'Guides/FAQ', 'Setup'), + 'guides-faq--usage': docs('guides-faq--usage', 'Guides/FAQ', 'Usage'), + 'guides-button--docs': docs('guides-button--docs', 'Guides/Button'), + 'guides-button--primary': { + id: 'guides-button--primary', + type: 'story', + subtype: 'story', + title: 'Guides/Button', + name: 'Primary', + importPath: './button.stories.ts', + tags: [], + }, + }, + }; + const result = transformStoryIndexToStoriesHash(index, options); + + it('puts a single unattached docs page in place of its title', () => { + expect(result['guides-intro']).toBeUndefined(); + expect(result['guides-intro--docs']).toMatchObject({ + name: 'Intro', + parent: 'guides', + depth: 1, + }); + expect(result['guides']).toMatchObject({ + children: ['guides-intro--docs', 'guides-faq', 'guides-button'], + }); + }); + + it('puts a root-level unattached docs page at the root', () => { + expect(result['welcome']).toBeUndefined(); + expect(result['welcome--docs']).toMatchObject({ + name: 'Welcome', + parent: undefined, + depth: 0, + }); + }); + + it('makes a title with several docs pages and no stories a group', () => { + expect(result['guides-faq']).toMatchObject({ + type: 'group', + children: ['guides-faq--setup', 'guides-faq--usage'], + }); + expect(result['guides-faq--setup']).toMatchObject({ name: 'Setup', parent: 'guides-faq' }); + }); + + it('keeps the component of a docs page that has stories', () => { + expect(result['guides-button']).toMatchObject({ + type: 'component', + children: ['guides-button--docs', 'guides-button--primary'], + }); + expect(result['guides-button--docs']).toMatchObject({ + name: 'Docs', + parent: 'guides-button', + }); + }); + + it('keeps the component when filters hide all of its stories', () => { + const filtered = transformStoryIndexToStoriesHash(index, { + ...options, + filters: { hideStories: (entry) => entry.type !== 'story' }, + }); + expect(filtered['guides-button']).toMatchObject({ + type: 'component', + children: ['guides-button--docs'], + }); + expect(filtered['guides-button--primary']).toBeUndefined(); + }); + }); }); diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts index 0c3cca7d2406..8d2b7a47752f 100644 --- a/code/core/src/manager-api/lib/stories.ts +++ b/code/core/src/manager-api/lib/stories.ts @@ -3,8 +3,8 @@ import type { API_ComponentEntry, API_DocsEntry, API_GroupEntry, - API_HashEntry, API_IndexHash, + API_LeafEntry, API_PreparedIndexEntry, API_PreparedStoryIndex, API_Provider, @@ -29,7 +29,6 @@ import { dedent } from 'ts-dedent'; import { Tag } from '../../shared/constants/tags.ts'; import { type API, type State, combineParameters } from '../root.tsx'; import intersect from './intersect.ts'; -import merge from './merge.ts'; const TITLE_PATH_SEPARATOR = /\s*\/\s*/; @@ -165,6 +164,17 @@ export const transformStoryIndexV4toV5 = ( }; }; +type TitleNodeType = 'root' | 'group' | 'component' | 'docs'; + +type TitleNode = { + id: StoryId; + name: string; + parent: StoryId | undefined; + depth: number; + isRoot: boolean; + children: StoryId[]; +}; + type ToStoriesHashOptions = { provider: API_Provider; docsOptions: DocsOptions; @@ -226,9 +236,20 @@ export const transformStoryIndexToStoriesHash = ( const setShowRoots = typeof showRoots !== 'undefined'; - const storiesHashOutOfOrder = entryValues.reduce((acc, item) => { + const includedIds = new Set(entryValues.map((entry) => entry.id)); + const entriesById = new Map(); + const titleNodes = new Map(); + const testIdsByStoryId = new Map(); + + indexEntries.forEach((item) => { if (docsOptions.docsMode && item.type !== 'docs') { - return acc; + return; + } + entriesById.set(item.id, item); + + if ('parent' in item && item.parent) { + testIdsByStoryId.set(item.parent, [...(testIdsByStoryId.get(item.parent) ?? []), item.id]); + return; } // First, split the title into a set of names, separated by '/' and trimmed. @@ -259,141 +280,124 @@ export const transformStoryIndexToStoriesHash = ( return list; }, [] as string[]); - // Now, let's add an entry to the hash for each path/name pair paths.forEach((id, idx) => { - // The child is the next path, OR the story/docs entry itself - const childId = paths[idx + 1] || item.id; - - if (root.length && idx === 0) { - acc[id] = merge((acc[id] || {}) as API_RootEntry, { - type: 'root', - id, - name: names[idx], - tags: [], - depth: idx, - renderAriaLabel, - renderLabel, - startCollapsed: collapsedRoots.includes(id), - // Note that this will later get appended to the previous list of children (see below) - children: [childId], - }); - // Usually the last path/name pair will be displayed as a component, - // *unless* there are other stories that are more deeply nested under it - // - // For example, if we had stories for both - // - Atoms / Button - // - Atoms / Button / LabelledButton - // - // In this example the entry for 'atoms-button' would *not* be a component. - } else if ((!acc[id] || acc[id].type === 'component') && idx === paths.length - 1) { - acc[id] = merge((acc[id] || {}) as API_ComponentEntry, { - type: 'component', - id, - name: names[idx], - tags: [], - parent: paths[idx - 1], - depth: idx, - renderAriaLabel, - renderLabel, - ...(childId && { - children: [childId], - }), - }); - } else { - acc[id] = merge((acc[id] || {}) as API_GroupEntry, { - type: 'group', - id, - name: names[idx], - tags: [], - parent: paths[idx - 1], - depth: idx, - renderAriaLabel, - renderLabel, - ...(childId && { - children: [childId], - }), - }); + const node = titleNodes.get(id) ?? { + id, + name: '', + parent: paths[idx - 1], + depth: idx, + isRoot: false, + children: [], + }; + titleNodes.set(id, node); + node.name = names[idx]!; + node.isRoot ||= root.length > 0 && idx === 0; + + const childId = paths[idx + 1] ?? item.id; + if (!node.children.includes(childId)) { + node.children.push(childId); } }); + }); + + // A title's type follows from everything indexed under it, never from the active filters + const typeOf = (node: TitleNode): TitleNodeType => { + if (node.isRoot) { + return 'root'; + } + if (node.children.some((id) => titleNodes.has(id))) { + return 'group'; + } + if (node.children.some((id) => entriesById.get(id)!.type === 'story')) { + return 'component'; + } + return node.children.length === 1 ? 'docs' : 'group'; + }; + + const isIncluded = (id: StoryId): boolean => { + const node = titleNodes.get(id); + return node ? node.children.some(isIncluded) : includedIds.has(id); + }; + + const hashIdOf = (id: StoryId) => { + const node = titleNodes.get(id); + return node && typeOf(node) === 'docs' ? node.children[0] : id; + }; - // Finally add an entry for the docs/story/test itself - acc[item.id] = { + const storiesHash: API_IndexHash = {}; + + const addLeaf = (id: StoryId, parent: StoryId | undefined, depth: number) => { + const item = entriesById.get(id)!; + storiesHash[id] = { tags: [], ...item, - depth: paths.length, - parent: 'parent' in item ? item.parent : paths[paths.length - 1], + depth, + parent, renderAriaLabel, renderLabel, prepared: !!item.parameters, } as API_DocsEntry | API_StoryEntry; - return acc; - }, {} as API_IndexHash); - - // This function adds a "root" or "orphan" and all of its descendents to the hash. - function addItem(acc: API_IndexHash, item: API_HashEntry) { - // If we were already inserted as part of a group, that's great. - if (!acc[item.id]) { - acc[item.id] = item; - - // Ensure we add the children depth-first *before* inserting any other entries. - if ('children' in item && item.children) { - item.children.forEach((childId) => addItem(acc, storiesHashOutOfOrder[childId])); - - item.tags = - item.children.reduce((currentTags: Tag[] | null, childId): Tag[] => { - // On the first child, we have nothing to intersect against so we use it as a source of data. - return currentTags === null - ? acc[childId].tags - : intersect(currentTags, acc[childId].tags); - }, null) || []; - } + const testIds = (testIdsByStoryId.get(id) ?? []).filter((testId) => includedIds.has(testId)); + if (testIds.length) { + (storiesHash[id] as API_StoryEntry).children = testIds; + testIds.forEach((testId) => addLeaf(testId, id, depth + 1)); } + }; - if (item.type === 'component') { - const firstChild = acc[item.children[0]]; - if (firstChild && 'importPath' in firstChild) { - // attach importPath to the component node which should be the same for all children - // this way we can add "open in editor" to the component node - item.importPath = firstChild.importPath; - } + const addNode = (node: TitleNode) => { + const type = typeOf(node); + if (type === 'docs') { + addLeaf(node.children[0], node.parent, node.depth); + storiesHash[node.children[0]].name = node.name; + return; } - return acc; - } - // We'll do two passes over the data, adding all the orphans, then all the roots - let storiesHash = Object.values(storiesHashOutOfOrder) - .filter((i) => i.type !== 'root' && !i.parent) - .reduce((acc, item) => addItem(acc, item), {} as API_IndexHash); - - storiesHash = Object.values(storiesHashOutOfOrder) - .filter((i) => i.type === 'root') - .reduce(addItem, storiesHash); - - // Update stories to include tests as children, and increase depth for those tests - storiesHash = Object.values(storiesHash).reduce((acc, item) => { - if (item.type === 'story' && item.subtype === 'test') { - const story = acc[item.parent] as API_StoryEntry; - const component = acc[story.parent] as API_ComponentEntry; - acc[component.id] = { - ...component, - // Remove test from the component node as it will be attached to the story node instead - children: component.children && component.children.filter((id) => id !== item.id), - }; - acc[story.id] = { - ...story, - // Add test to the story node - children: (story.children || []).concat(item.id), - }; - acc[item.id] = { - ...item, - depth: item.depth + 1, - }; - } else { - acc[item.id] = item; + const includedChildren = node.children.filter(isIncluded); + const children = includedChildren.map(hashIdOf); + const entry = { + type, + id: node.id, + name: node.name, + depth: node.depth, + tags: [] as string[], + renderAriaLabel, + renderLabel, + children, + ...(type === 'root' + ? { startCollapsed: collapsedRoots.includes(node.id) } + : { parent: node.parent }), + } as API_RootEntry | API_GroupEntry | API_ComponentEntry; + storiesHash[node.id] = entry; + + includedChildren.forEach((childId) => { + const child = titleNodes.get(childId); + if (child) { + addNode(child); + } else { + addLeaf(childId, node.id, node.depth + 1); + } + }); + + entry.tags = children + .flatMap((id) => { + const child = storiesHash[id]; + return child.type === 'story' && 'children' in child && child.children + ? [id, ...child.children] + : [id]; + }) + .map((id) => storiesHash[id].tags) + .reduce(intersect); + if (entry.type === 'component') { + entry.importPath = (storiesHash[children[0]] as API_LeafEntry).importPath; } - return acc; - }, {} as API_IndexHash); + }; + + const topLevelNodes = [...titleNodes.values()].filter( + (node) => node.depth === 0 && isIncluded(node.id) + ); + topLevelNodes.filter((node) => !node.isRoot).forEach(addNode); + topLevelNodes.filter((node) => node.isRoot).forEach(addNode); return storiesHash; }; @@ -425,6 +429,11 @@ export const getComponentLookupList = memoize(1)((hash: API_IndexHash) => { const value = i[1]; if (value.type === 'component') { acc.push([...value.children]); + } else if ( + value.type === 'docs' && + (!value.parent || hash[value.parent].type !== 'component') + ) { + acc.push([value.id]); } return acc; }, [] as StoryId[][]); diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 6de34163519b..cd0724f71220 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -651,7 +651,14 @@ export const init: ModuleFn = ({ if (!name) { // Find the entry (group, component, story or docs) that is referred to - const entry = titleOrId ? hash[titleOrId] || hash[sanitize(titleOrId)] : hash[kindSlug]; + const entry = titleOrId + ? hash[titleOrId] || + hash[sanitize(titleOrId)] || + // Unattached docs pages have no entry keyed by their title + Object.values(hash).find( + (e) => e.type === 'docs' && sanitize(e.title) === sanitize(titleOrId) + ) + : hash[kindSlug]; if (!entry) { throw new Error(`Unknown id or title: '${titleOrId}'`); diff --git a/code/core/src/manager-api/tests/refs.test.ts b/code/core/src/manager-api/tests/refs.test.ts index 872b41c7a01b..f85076b60672 100644 --- a/code/core/src/manager-api/tests/refs.test.ts +++ b/code/core/src/manager-api/tests/refs.test.ts @@ -1206,7 +1206,6 @@ describe('Refs API', () => { 'component-a', 'component-a--page', 'component-a--story-2', - 'component-b', 'component-b--page', 'component-c', 'component-c--story-4', @@ -1258,7 +1257,7 @@ describe('Refs API', () => { const hash = refs.fake.index; // We need exact key ordering, even if in theory JS doesn't guarantee it - expect(Object.keys(hash)).toEqual(['component-a', 'component-a--docs']); + expect(Object.keys(hash)).toEqual(['component-a--docs']); expect(hash['component-a--docs'].type).toBe('docs'); }); }); diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index affa9c57098e..7b656df1a13a 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -18,7 +18,11 @@ import { UPDATE_STORY_ARGS, } from 'storybook/internal/core-events'; import { logger } from 'storybook/internal/client-logger'; -import { type API_StoryEntry, type StoryIndex } from 'storybook/internal/types'; +import { + type API_PreparedIndexEntry, + type API_StoryEntry, + type StoryIndex, +} from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -371,36 +375,36 @@ describe('stories API', () => { api.setIndex({ v: 5, entries: { - 'a-sampleone': { + 'a-sampleone--1': { type: 'story', subtype: 'story', - id: 'a-sampleone', + id: 'a-sampleone--1', title: 'A/SampleOne', name: '1', tags: ['shared', 'one-specific'], importPath: './a.ts', }, - 'a-sampletwo': { + 'a-sampletwo--2': { type: 'story', subtype: 'story', - id: 'a-sampletwo', + id: 'a-sampletwo--2', title: 'A/SampleTwo', name: '2', tags: ['shared', 'two-specific'], importPath: './a.ts', }, - 'a-embedded-othertopic': { + 'a-embedded-othertopic--docs': { type: 'docs', - id: 'a-embedded-othertopic', + id: 'a-embedded-othertopic--docs', title: 'A/Embedded/OtherTopic', name: '3', tags: ['shared', 'embedded-docs-specific', 'other'], storiesImports: [], importPath: './embedded/other.mdx', }, - 'a-embedded-extras': { + 'a-embedded-extras--docs': { type: 'docs', - id: 'a-embedded-extras', + id: 'a-embedded-extras--docs', title: 'A/Embedded/Extras', name: '3', tags: ['shared', 'embedded-docs-specific', 'extras'], @@ -414,10 +418,12 @@ describe('stories API', () => { expect(Object.keys(index!)).toEqual([ 'a', 'a-sampleone', + 'a-sampleone--1', 'a-sampletwo', + 'a-sampletwo--2', 'a-embedded', - 'a-embedded-othertopic', - 'a-embedded-extras', + 'a-embedded-othertopic--docs', + 'a-embedded-extras--docs', ]); // Acts as the root, so that the next level is a group we're testing. expect(index!.a).toMatchObject({ @@ -559,7 +565,6 @@ describe('stories API', () => { 'component-a', 'component-a--page', 'component-a--story-2', - 'component-b', 'component-b--docs', 'component-c', 'component-c--story-4', @@ -579,7 +584,7 @@ describe('stories API', () => { const { store } = moduleArgs; api.setIndex({ v: 5, entries: docsEntries }); const { index } = store.getState(); - expect(Object.keys(index!)).toEqual(['component-b', 'component-b--docs']); + expect(Object.keys(index!)).toEqual(['component-b--docs']); }); }); }); @@ -1066,6 +1071,29 @@ describe('stories API', () => { api.jumpToComponent(1); expect(navigate).not.toHaveBeenCalled(); }); + it('lands on an unattached docs page', () => { + const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ + v: 5, + entries: { + ...navigationEntries, + 'intro--docs': { + type: 'docs', + id: 'intro--docs', + title: 'Intro', + name: 'Page', + importPath: './intro.mdx', + storiesImports: [], + }, + }, + }); + api.jumpToComponent(1); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); + }); it('does nothing if you are at the first component and go backward', () => { const initialState = { path: '/story/a--2', storyId: 'a--2', viewMode: 'story' }; const moduleArgs = createMockModuleArgs({ initialState }); @@ -1121,6 +1149,34 @@ describe('stories API', () => { api.selectStory('a--1'); expect(store.getState().settings.lastTrackedStoryId).toBe('a--1'); }); + it('selects the docs page of a component whose stories are all filtered out', () => { + const initialState = { + path: '/story/a--1', + storyId: 'a--1', + viewMode: 'story', + filters: { hideStories: (entry: API_PreparedIndexEntry) => entry.type !== 'story' }, + }; + const moduleArgs = createMockModuleArgs({ initialState }); + const { api } = initStories(moduleArgs as unknown as ModuleArgs); + const { navigate } = moduleArgs; + + api.setIndex({ + v: 5, + entries: { + 'a--docs': { + type: 'docs', + id: 'a--docs', + title: 'a', + name: 'Docs', + importPath: './a.ts', + storiesImports: [], + }, + 'a--1': navigationEntries['a--1'], + }, + }); + api.selectStory('a'); + expect(navigate).toHaveBeenCalledWith('/docs/a--docs', undefined); + }); it('selects first visible child when component is clicked with filtered index', () => { const initialState = { path: '/story/a--1', storyId: 'a--1', viewMode: 'story' }; const moduleArgs = createMockModuleArgs({ initialState }); diff --git a/code/core/src/manager/components/sidebar/Search.tsx b/code/core/src/manager/components/sidebar/Search.tsx index 17254ca94814..b60b04c4a2e3 100644 --- a/code/core/src/manager/components/sidebar/Search.tsx +++ b/code/core/src/manager/components/sidebar/Search.tsx @@ -252,55 +252,15 @@ export const Search = React.memo(function Search({ let results: DownshiftItem[] = []; const resultIds: Set = new Set(); - const allMatches = (fuse.search(input) as SearchResult[]).filter(({ item }) => { - return item.type === 'component' || item.type === 'docs' || item.type === 'story'; - }); - - // When the index is being created, we have a legacy piece of logic that - // wraps every docs page inside a component entry. This originates from - // Storybook 6 and has never been removed. Because of it, we must dedupe - // docs entries that are hidden under a fake component entry. - // See https://github.com/storybookjs/storybook/issues/35513 for details. - const docsParentIds = new Set(); - allMatches.forEach(({ item }) => { - if (item.type === 'docs' && item.parent) { - docsParentIds.add(item.parent); - } - }); - - // Components suppressed in favor of a matching docs child that hasn't been rendered yet. - // The suppressed component still occupies its slot in `resultIds` so that sibling stories - // ranked between the component and its docs entry are deduplicated, as they were before. - const pendingDocsReplacements = new Set(); - - const distinctResults = allMatches.filter(({ item }) => { - // This always gets called before the corresponding docs item - // because of the sorting performed by the search index. So it's - // safe to use `pendingDocsReplacements` in a single-pass lookup. - if (item.type === 'component' && docsParentIds.has(item.id)) { - if (!resultIds.has(item.id)) { - resultIds.add(item.id); - pendingDocsReplacements.add(item.id); - } - return false; - } - - // When we reach this, we know we found an unattached MDX page with - // a synthetic docs wrapper. Like in Tree.tsx, remove the wrapper - // and present the docs item to end users. - if (item.type === 'docs' && item.parent && pendingDocsReplacements.has(item.parent)) { - pendingDocsReplacements.delete(item.parent); - resultIds.add(item.id); - return true; - } - // @ts-expect-error (non strict) - if (resultIds.has(item.parent)) { + const distinctResults = (fuse.search(input) as SearchResult[]).filter(({ item }) => { + if ( + !(item.type === 'component' || item.type === 'docs' || item.type === 'story') || + // @ts-expect-error (non strict) + resultIds.has(item.parent) + ) { return false; } resultIds.add(item.id); - if (item.type === 'docs' && item.parent) { - resultIds.add(item.parent); - } return true; }); diff --git a/code/core/src/manager/components/sidebar/Tree.stories.tsx b/code/core/src/manager/components/sidebar/Tree.stories.tsx index 220144221dbd..4d83ce127a24 100644 --- a/code/core/src/manager/components/sidebar/Tree.stories.tsx +++ b/code/core/src/manager/components/sidebar/Tree.stories.tsx @@ -202,28 +202,16 @@ export const DocsOnlySingleStoryComponents = { isBrowsing isMain refId={DEFAULT_REF_ID} - // @ts-expect-error (non strict) data={{ - ...{ - single: { - type: 'component', - name: 'Single', - id: 'single', - parent: null, - depth: 0, - children: ['single--docs'], - }, - 'single--docs': { - type: 'docs', - id: 'single--docs', - title: 'Single', - name: 'Single', - tags: [], - prepared: true, - depth: 1, - parent: 'single', - importPath: './single.stories.js', - }, + 'single--docs': { + type: 'docs', + id: 'single--docs', + title: 'Single', + name: 'Single', + tags: [], + prepared: true, + depth: 0, + importPath: './single.stories.js', }, ...Object.keys(index).reduce((acc, key) => { if (key === 'tooltip-tooltipselect--default') { diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index 7dc7fd540cb3..2dfe0eab7481 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -669,7 +669,6 @@ export const Tree = React.memo<{ // Create a list of component IDs which should be collapsed into their (only) child. // That is: // - components with a single story child with the same name - // - components with only a single docs child const singleStoryComponentIds = useMemo(() => { return Object.keys(data).filter((id) => { const entry = data[id]; @@ -686,10 +685,6 @@ export const Tree = React.memo<{ const onlyChild = data[children[0]]; - if (onlyChild.type === 'docs') { - return true; - } - if (onlyChild.type === 'story' && onlyChild.subtype === 'story') { return isStoryHoistable(onlyChild.name, name); } diff --git a/code/core/src/types/modules/api-stories.ts b/code/core/src/types/modules/api-stories.ts index b06bc817bbc6..70e9fede573a 100644 --- a/code/core/src/types/modules/api-stories.ts +++ b/code/core/src/types/modules/api-stories.ts @@ -34,7 +34,7 @@ export interface API_ComponentEntry extends API_BaseEntry { export interface API_DocsEntry extends API_BaseEntry { type: 'docs'; - parent: StoryId; + parent?: StoryId; title: ComponentTitle; importPath: Path; prepared: boolean; diff --git a/code/e2e-internal/navigation.spec.ts b/code/e2e-internal/navigation.spec.ts index be71894f5b95..9e2e9bf6945f 100644 --- a/code/e2e-internal/navigation.spec.ts +++ b/code/e2e-internal/navigation.spec.ts @@ -40,13 +40,13 @@ test.describe('navigating', () => { await page.getByRole('searchbox').fill('Monospace'); const searchItem = page.getByRole('option', { - name: 'Docs / Monospace brand / Typography', + name: 'Typography / Monospace brand', exact: true, }); await expect(searchItem).toBeVisible(); await expect( page.getByRole('option', { - name: 'Docs / Sans-serif brand / Typography', + name: 'Typography / Sans-serif brand', exact: true, }) ).toBeHidden(); @@ -64,7 +64,7 @@ test.describe('navigating', () => { await page .getByRole('option', { - name: 'Docs / Monospace brand / Typography', + name: 'Typography / Monospace brand', exact: true, }) .click(); From 3e44dd3107d5c9c948d31d24961d57da5807d3a8 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 7 Oct 2026 16:53:34 +0200 Subject: [PATCH 007/326] Manager: Redirect title-id deep links of unattached docs pages A URL such as ?path=/docs/brand-typography used to resolve through the component wrapper. Without the wrapper the id is not in the index, so the STORY_SPECIFIED handler treated it as a known leaf, pushed it back to the preview and showed "Couldn't find story matching id". Only a story or docs entry counts as the current selection now, so the manager follows the entry the preview resolved from the partial id. --- code/core/src/manager-api/modules/stories.ts | 2 +- code/core/src/manager-api/tests/stories.test.ts | 15 +++++++++++++++ code/e2e-internal/navigation.spec.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index cd0724f71220..8b360b8cbc04 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -1128,7 +1128,7 @@ export const init: ModuleFn = ({ const stateHasSelection = state.viewMode && state.storyId; const stateSelectionDifferent = state.viewMode !== viewMode || state.storyId !== storyId; const { type } = state.index?.[state.storyId] || {}; - const isStory = !(type === 'root' || type === 'component' || type === 'group'); + const isStory = type === 'story' || type === 'docs'; /** * When storybook starts, we want to navigate to the first story. But there are a few diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index 7b656df1a13a..dd04aa3d7fe1 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -741,6 +741,21 @@ describe('stories API', () => { provider.channel.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' }); expect(navigate).not.toHaveBeenCalled(); }); + it('navigates to the entry the preview resolved from a partial id', async () => { + const moduleArgs = createMockModuleArgs({ + initialState: { + path: '/docs/intro', + storyId: 'intro', + viewMode: 'docs', + index: { 'intro--docs': { type: 'docs', id: 'intro--docs' } as any }, + }, + }); + initStories(moduleArgs as unknown as ModuleArgs); + const { navigate, provider } = moduleArgs; + + provider.channel.emit(STORY_SPECIFIED, { storyId: 'intro--docs', viewMode: 'docs' }); + expect(navigate).toHaveBeenCalledWith('/docs/intro--docs', undefined); + }); it('DOES not navigate if a settings page was selected', async () => { const moduleArgs = createMockModuleArgs({ initialState: { path: '/settings/about', index: {} }, diff --git a/code/e2e-internal/navigation.spec.ts b/code/e2e-internal/navigation.spec.ts index 9e2e9bf6945f..d4182aad69c4 100644 --- a/code/e2e-internal/navigation.spec.ts +++ b/code/e2e-internal/navigation.spec.ts @@ -21,6 +21,20 @@ test.describe('navigating', () => { expect(sbPage.page.url()).toContain('/docs/example-button--docs'); }); + test('a URL with the title id of an unattached docs page redirects to the page', async ({ + page, + }) => { + await page.goto(`${storybookUrl}?path=/docs/brand-typography`); + + const sbPage = new SbPage(page, expect); + await sbPage.waitUntilLoaded(); + + await page.waitForFunction(() => + window.document.location.href.match('/docs/brand-typography--docs') + ); + await expect(sbPage.previewIframe().getByRole('heading', { name: 'Monospace' })).toBeVisible(); + }); + test('searching for "typography" surfaces the brand typography docs entry', async ({ page }) => { await page.goto(storybookUrl); From 4bd81fdd274f3437b5ba833b4bfa2611dccd2680 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 7 Oct 2026 22:21:23 +0200 Subject: [PATCH 008/326] feat(svelte): add definePreview for CSF factories preview.meta() infers args from the props of a Svelte component, like the React and Vue renderers. definePreview adds the renderer's preview annotations itself, because CSF factories mode ignores preset previewAnnotations. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../svelte/src/__test__/Divider.svelte | 1 + .../svelte/src/__test__/Input.svelte | 9 + .../svelte/src/__test__/Layout.svelte | 14 + .../renderers/svelte/src/__test__/List.svelte | 13 + .../svelte/src/csf-factories.test.ts | 572 ++++++++++++++++++ code/renderers/svelte/src/index.ts | 2 + code/renderers/svelte/src/preview.ts | 149 +++++ code/renderers/svelte/src/types.ts | 2 + 8 files changed, 762 insertions(+) create mode 100644 code/renderers/svelte/src/__test__/Divider.svelte create mode 100644 code/renderers/svelte/src/__test__/Input.svelte create mode 100644 code/renderers/svelte/src/__test__/Layout.svelte create mode 100644 code/renderers/svelte/src/__test__/List.svelte create mode 100644 code/renderers/svelte/src/csf-factories.test.ts create mode 100644 code/renderers/svelte/src/preview.ts diff --git a/code/renderers/svelte/src/__test__/Divider.svelte b/code/renderers/svelte/src/__test__/Divider.svelte new file mode 100644 index 000000000000..09d5649d1328 --- /dev/null +++ b/code/renderers/svelte/src/__test__/Divider.svelte @@ -0,0 +1 @@ +
diff --git a/code/renderers/svelte/src/__test__/Input.svelte b/code/renderers/svelte/src/__test__/Input.svelte new file mode 100644 index 000000000000..6006de697b94 --- /dev/null +++ b/code/renderers/svelte/src/__test__/Input.svelte @@ -0,0 +1,9 @@ + + + oninput?.(value)} /> diff --git a/code/renderers/svelte/src/__test__/Layout.svelte b/code/renderers/svelte/src/__test__/Layout.svelte new file mode 100644 index 000000000000..300db65da909 --- /dev/null +++ b/code/renderers/svelte/src/__test__/Layout.svelte @@ -0,0 +1,14 @@ + + +

{title}

+{@render children()} +{@render footer?.(2026)} diff --git a/code/renderers/svelte/src/__test__/List.svelte b/code/renderers/svelte/src/__test__/List.svelte new file mode 100644 index 000000000000..81ebf15cb64f --- /dev/null +++ b/code/renderers/svelte/src/__test__/List.svelte @@ -0,0 +1,13 @@ + + +
    + {#each items as item} +
  • {getLabel(item)}
  • + {/each} +
diff --git a/code/renderers/svelte/src/csf-factories.test.ts b/code/renderers/svelte/src/csf-factories.test.ts new file mode 100644 index 000000000000..98a75a384ba7 --- /dev/null +++ b/code/renderers/svelte/src/csf-factories.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, expectTypeOf, it, test } from 'vitest'; + +import type { Canvas } from 'storybook/internal/types'; + +import type { Component, ComponentProps, Snippet } from 'svelte'; + +import { fn, mocked } from 'storybook/test'; + +import Button from './__test__/Button.svelte'; +import Decorator2 from './__test__/Decorator2.svelte'; +import Decorator1 from './__test__/Decorator.svelte'; +import Divider from './__test__/Divider.svelte'; +import Input from './__test__/Input.svelte'; +import Layout from './__test__/Layout.svelte'; +import List from './__test__/List.svelte'; +import { __definePreview } from './preview.ts'; +import type { Decorator, StoryObj } from './public-types.ts'; + +type ButtonProps = ComponentProps; + +const preview = __definePreview({ addons: [] }); + +test('csf factories', () => { + const config = __definePreview({ addons: [{ decorators: [] }] }); + + const meta = config.meta({ component: Button, args: { disabled: false } }); + const MyStory = meta.story({ args: { label: 'Hello world' } }); + + expect(MyStory.input.args?.label).toBe('Hello world'); +}); + +describe('Meta', () => { + it('Generic parameter of Meta can be a component', () => { + preview.meta({ component: Button, args: { label: 'good', disabled: false } }); + }); + + it('Callback props are inferred from the component', () => { + preview.meta({ + component: Button, + args: { + label: 'good', + disabled: false, + clicked: (event) => expectTypeOf(event).toEqualTypeOf(), + }, + render: (args) => { + expectTypeOf(args.clicked).toEqualTypeOf<((e: MouseEvent) => void) | undefined>(); + return { Component: Button, props: args }; + }, + }); + }); +}); + +describe('StoryObj', () => { + it('✅ Required args may be provided partial in meta and the story', () => { + const meta = preview.meta({ component: Button, args: { label: 'good' } }); + const Story = meta.story({ args: { disabled: true } }); + }); + + it('❌ The combined shape of meta args and story args must match the required args.', () => { + { + const meta = preview.meta({ component: Button, args: { label: 'good' } }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story(); + } + { + const meta = preview.meta({ component: Button }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ args: { label: 'good' } }); + } + }); + + it('a story with a render that takes no args needs no args', () => { + const meta = preview.meta({ component: Button }); + const RenderOnly = meta.story(() => ({ Component: Divider })); + const RenderInObject = meta.story({ render: () => ({ Component: Divider }) }); + }); + + it('a story with a render that takes args needs the required args', () => { + const meta = preview.meta({ component: Button }); + const WithArgs = meta.story({ + args: { label: 'good', disabled: true }, + render: (args) => ({ Component: Button, props: args }), + }); + // @ts-expect-error disabled not provided ❌ + const Missing = meta.story({ + args: { label: 'good' }, + render: (args) => ({ Component: Button, props: args }), + }); + }); +}); + +type ThemeData = 'light' | 'dark'; + +describe('Story args can be inferred', () => { + it('Correct args are inferred when type is widened for render function', () => { + const meta = preview.type<{ args: { theme: ThemeData } }>().meta({ + component: Button, + render: (args) => { + expectTypeOf(args.theme).toEqualTypeOf(); + return { Component: Button, props: args }; + }, + args: { disabled: false }, + }); + + const Basic = meta.story({ args: { theme: 'light', label: 'good' } }); + }); + + it('Args of a typed render can be set in meta', () => { + const meta = preview.meta({ + component: Button, + render: (args: ButtonProps & { theme: ThemeData }) => ({ Component: Button, props: args }), + args: { theme: 'light', disabled: false }, + }); + + const Basic = meta.story({ args: { label: 'good' } }); + }); + + const withDecorator: Decorator<{ decoratorArg: string }> = (_, { args: { decoratorArg } }) => ({ + Component: Decorator1, + props: { decoratorArg }, + }); + + it('Correct args are inferred when type is widened for decorators', () => { + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: 'title', label: 'good' } }); + // @ts-expect-error decoratorArg not provided ❌ + const Missing = meta.story({ args: { label: 'good' } }); + }); + + it('Correct args are inferred when type is widened for multiple decorators', () => { + const secondDecorator: Decorator<{ decoratorArg2: string }> = ( + _, + { args: { decoratorArg2 } } + ) => ({ Component: Decorator2, props: { decoratorArg2 } }); + + const meta = preview.meta({ + component: Button, + args: { disabled: false }, + decorators: [withDecorator, secondDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: '', decoratorArg2: '', label: 'good' } }); + }); +}); + +describe('Custom args types written by the csf-factories codemod', () => { + type Icon = { name: string }; + type StoryArgs = { pageIcon: Icon }; + + it('✅ A custom args type can include the props of the component', () => { + const meta = preview + .type<{ args: ButtonProps & { footer?: string } }>() + .meta({ component: Button }); + + const Default = meta.story({ args: { label: 'good', disabled: false, footer: 'footer' } }); + // @ts-expect-error disabled not provided ❌ + const Missing = meta.story({ args: { label: 'good' } }); + }); + + it('✅ A custom arg can be used when meta has no component', () => { + const PageIcon = {} as Component; + const meta = preview.type<{ args: StoryArgs }>().meta({ + render: (args) => { + expectTypeOf(args.pageIcon).toEqualTypeOf(); + return { Component: PageIcon, props: args }; + }, + args: { pageIcon: { name: 'organization' } }, + }); + + const Default = meta.story(); + const Overridden = meta.story({ args: { pageIcon: { name: 'user' } } }); + }); +}); + +describe('Svelte components', () => { + it('snippet props are args', () => { + const meta = preview.meta({ component: Layout, args: { title: 'Home' } }); + + const Default = meta.story({ + args: { + children: (() => {}) as unknown as Snippet, + footer: (() => {}) as unknown as Snippet<[year: number]>, + }, + }); + // @ts-expect-error children not provided ❌ + const Missing = meta.story(); + }); + + it('bindable props are args', () => { + const meta = preview.meta({ + component: Input, + args: { oninput: (value) => expectTypeOf(value).toEqualTypeOf() }, + }); + + const Default = meta.story({ args: { value: 'Hi' } }); + // @ts-expect-error value must be a string + const Invalid = meta.story({ args: { value: 1 } }); + }); + + it('generic components take their type argument directly', () => { + const meta = preview.meta({ component: List<{ id: number; name: string }> }); + + const Default = meta.story({ + args: { + items: [{ id: 1, name: 'Ada' }], + getLabel: (item) => { + expectTypeOf(item).toEqualTypeOf<{ id: number; name: string }>(); + return item.name; + }, + }, + }); + }); + + it('a component without props needs no args', () => { + const meta = preview.meta({ component: Divider }); + const Default = meta.story(); + }); + + it('mount accepts a component and its props', () => { + const Basic: StoryObj = { + async play({ mount }) { + const canvas = await mount(Button, { props: { label: 'label', disabled: true } }); + expectTypeOf(canvas).toEqualTypeOf(); + }, + }; + }); +}); + +it('allow types to be inferred from render as well', () => { + const meta = preview.meta({ + render: (args: ButtonProps) => ({ Component: Button, props: args }), + args: { label: 'hello' }, + }); + + const Story = meta.story({ args: { disabled: true } }); +}); + +it('Literal props need no `as const` in meta args, issue #36125', () => { + const Badge = {} as Component<{ label: string; variant: 'primary' | 'secondary' }>; + + const meta = preview.meta({ component: Badge, args: { variant: 'primary' } }); + const Default = meta.story({ args: { label: 'Hi' } }); + expectTypeOf(meta.input.args.variant).toEqualTypeOf<'primary' | 'secondary'>(); + + const typedMeta = preview + .type<{ args: { extra?: boolean } }>() + .meta({ component: Badge, args: { variant: 'primary', label: 'Hi' } }); + const NoArgs = typedMeta.story(); +}); + +describe('Meta args are typed by the keys you provide', () => { + enum Size { + Small = 'small', + Large = 'large', + } + type UserId = string & { readonly brand: unique symbol }; + const userId = (id: string) => id as UserId; + class Store { + #count = 0; + increment() { + this.#count++; + } + } + type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; + type CardProps = { + label: string; + variant: 'primary' | 'secondary'; + size: Size; + icon: `icon-${string}`; + userId: UserId; + range: [min: number, max: number]; + items: string[]; + config: { theme: { mode: 'light' | 'dark'; accents: { tone: 'warm' | 'cool' }[] } }; + shape: Shape; + store: Store; + onclick: () => void; + onchange: (value: number) => void; + handlers: { onselect: (item: string) => void; onreset: () => void }; + getUsers: () => Promise; + }; + const Card = {} as Component; + + const meta = preview.meta({ + component: Card, + args: { + variant: 'primary', + size: Size.Small, + icon: 'icon-star', + userId: userId('1'), + range: [0, 10], + items: ['a'], + config: { theme: { mode: 'dark', accents: [{ tone: 'warm' }] } }, + shape: { kind: 'circle', radius: 1 }, + store: new Store(), + onclick: () => {}, + onchange: (value) => expectTypeOf(value).toEqualTypeOf(), + handlers: { + onselect: (item) => expectTypeOf(item).toEqualTypeOf(), + onreset: function () {}, + }, + getUsers: fn(), + }, + }); + + it('literal, enum, template literal and branded props need no `as const`', () => { + expectTypeOf(meta.input.args.variant).toEqualTypeOf<'primary' | 'secondary'>(); + expectTypeOf(meta.input.args.size).toEqualTypeOf(); + expectTypeOf(meta.input.args.icon).toEqualTypeOf<`icon-${string}`>(); + expectTypeOf(meta.input.args.userId).toEqualTypeOf(); + + const Default = meta.story({ args: { label: 'Hi' } }); + // @ts-expect-error label is required + const Missing = meta.story(); + }); + + it('meta.input.args keeps the declared prop types', () => { + const items: string[] = meta.input.args.items; + expectTypeOf(meta.input.args.range).toEqualTypeOf<[min: number, max: number]>(); + expectTypeOf(meta.input.args.config.theme.accents[0].tone).toEqualTypeOf<'warm' | 'cool'>(); + expectTypeOf(meta.input.args.store).toEqualTypeOf(); + + const { shape } = meta.input.args; + if (shape.kind === 'circle') { + expectTypeOf(shape.radius).toEqualTypeOf(); + } + }); + + it('fn() args are typed as declared, mocked() gives the mock API', () => { + const Default = meta.story({ + args: { label: 'Hi' }, + play: async ({ args }) => { + expectTypeOf(args.getUsers).toEqualTypeOf<() => Promise>(); + mocked(args.getUsers).mockResolvedValue(['Ada']); + }, + }); + }); + + it('invalid meta args are rejected', () => { + // @ts-expect-error not a variant + preview.meta({ component: Card, args: { variant: 'tertiary' } }); + // @ts-expect-error not a mode + preview.meta({ component: Card, args: { config: { theme: { mode: 'dim', accents: [] } } } }); + // @ts-expect-error max must be a number + preview.meta({ component: Card, args: { range: [0, 'ten'] } }); + // @ts-expect-error not a prop of Card + preview.meta({ component: Card, args: { variant: 'primary', unknown: true } }); + }); + + it('stories override meta args and infer callback parameters', () => { + const Default = meta.story({ + args: { + label: 'Hi', + variant: 'secondary', + onclick: () => {}, + handlers: { + onselect: (item) => expectTypeOf(item).toEqualTypeOf(), + onreset: () => undefined, + }, + }, + }); + const Extended = Default.extend({ args: { variant: 'primary', onclick: function () {} } }); + // @ts-expect-error not a variant + Default.extend({ args: { variant: 'tertiary' } }); + }); + + it('meta.story() needs no args when meta provides all required args', () => { + const complete = preview.meta({ component: Button, args: { label: 'Hi', disabled: false } }); + const Default = complete.story(); + }); + + it('union props accept keys shared by every member', () => { + type Props = { label: string } & ( + | { kind: 'link'; href: string } + | { kind: 'button'; onpress: () => void } + ); + const Action = {} as Component; + + const actionMeta = preview.meta({ component: Action, args: { label: 'Go', kind: 'link' } }); + const Link = actionMeta.story({ args: { href: '/' } }); + + // @ts-expect-error href is not a prop of every member, set it per story + preview.meta({ component: Action, args: { kind: 'link', href: '/' } }); + }); + + it('args declared with preview.type<>() and decorators', () => { + const withTheme: Decorator<{ theme: 'light' | 'dark' }> = (Story) => Story(); + + const typedMeta = preview.type<{ args: { locale: 'en' | 'nl' } }>().meta({ + component: Button, + decorators: [withTheme], + args: { locale: 'nl', theme: 'dark', label: 'Hi' }, + }); + const Default = typedMeta.story({ args: { disabled: false } }); + expectTypeOf(typedMeta.input.args.locale).toEqualTypeOf<'en' | 'nl'>(); + }); + + it('render-only meta', () => { + type CounterProps = { mode: 'compact' | 'wide'; count: number }; + const Counter = {} as Component; + const renderMeta = preview.meta({ + render: (args: CounterProps) => ({ Component: Counter, props: args }), + args: { mode: 'wide' }, + }); + const Default = renderMeta.story({ args: { count: 1 } }); + // @ts-expect-error count is required + const Missing = renderMeta.story(); + }); +}); + +it('a meta like the Button stories of the sandboxes', () => { + const ExampleButton = {} as Component<{ + primary?: boolean; + backgroundColor?: string; + size?: 'small' | 'medium' | 'large'; + label: string; + onclick?: (event: MouseEvent) => void; + }>; + + const meta = preview.meta({ + title: 'Example/Button', + component: ExampleButton, + tags: ['autodocs'], + argTypes: { + backgroundColor: { control: 'color' }, + size: { control: { type: 'select' }, options: ['small', 'medium', 'large'] }, + }, + args: { onclick: fn() }, + }); + + const Primary = meta.story({ + args: { primary: true, label: 'Button' }, + play: async ({ args }) => { + mocked(args.onclick).mockClear(); + }, + }); + const Large = meta.story({ args: { size: 'large', label: 'Button' } }); + // @ts-expect-error not a size + const Huge = meta.story({ args: { size: 'huge', label: 'Button' } }); +}); + +it('argTypes of a meta without component do not type its args', () => { + const Text = {} as Component>; + const meta = preview.meta({ + render: (args) => ({ Component: Text, props: args }), + argTypes: { size: { control: 'select', options: ['small', 'large'] } }, + }); + + const Default = meta.story({ args: { label: 'Hi' } }); +}); + +it('components without known props accept any args', () => { + const shim = {} as Component; + const component: any = Button; + + preview.meta({ component: shim, args: { label: 'Hi' } }); + preview.meta({ component, args: { label: 'Hi' } }); +}); + +it('a render typed as any keeps the component args', () => { + const meta = preview.meta({ + component: Button, + render: (args: any) => ({ Component: Button, props: args }), + }); + // @ts-expect-error not a boolean + const Invalid = meta.story({ args: { disabled: 'yes', label: 'Hi' } }); + + preview.meta({ + component: Button, + render: (args: any) => ({ Component: Button, props: args }), + // @ts-expect-error bogus is not an arg + args: { bogus: 1 }, + }); +}); + +describe('meta.type<>() types the stories created from it', () => { + const meta = preview.meta({ component: Button, args: { disabled: false } }); + + it('adds an arg to the args and the render of that story only', () => { + meta.type<{ args: { icon: 'star' | 'heart' } }>().story({ + args: { label: 'Hi', icon: 'star' }, + render: (args) => { + expectTypeOf(args.icon).toEqualTypeOf<'star' | 'heart'>(); + expectTypeOf(args.label).toEqualTypeOf(); + return { Component: Button, props: args }; + }, + play: async ({ args }) => { + expectTypeOf(args.icon).toEqualTypeOf<'star' | 'heart'>(); + }, + }); + + meta.story({ + args: { label: 'Hi' }, + // @ts-expect-error icon is not an arg of the other stories + render: ({ icon, ...args }) => ({ Component: Button, props: args }), + }); + // @ts-expect-error icon must be 'star' | 'heart' + meta.type<{ args: { icon: 'star' | 'heart' } }>().story({ args: { label: 'Hi', icon: 'x' } }); + }); + + it('a required key is required in that story only', () => { + // @ts-expect-error icon is required + meta.type<{ args: { icon: string } }>().story({ args: { label: 'Hi' } }); + meta.type<{ args: { icon?: string } }>().story({ args: { label: 'Hi' } }); + meta.story({ args: { label: 'Hi' } }); + }); + + it('args set in meta stay optional and the others stay required', () => { + const typed = meta.type<{ args: { icon: string } }>(); + typed.story({ args: { label: 'Hi', icon: 'star' } }); + typed.story({ args: { label: 'Hi', icon: 'star', disabled: true } }); + // @ts-expect-error label is required + typed.story({ args: { icon: 'star' } }); + }); + + it('an arg of the meta that is redeclared must be set again', () => { + // @ts-expect-error disabled is required, the meta sets it to false + meta.type<{ args: { disabled: true } }>().story({ args: { label: 'Hi' } }); + meta.type<{ args: { disabled: true } }>().story({ args: { label: 'Hi', disabled: true } }); + }); + + it('story() needs no args when no required arg is left or render takes none', () => { + const complete = preview.meta({ component: Button, args: { label: 'Hi', disabled: false } }); + complete.type<{ args: { icon?: string } }>().story(); + complete.type<{ args: { icon?: string } }>().story({}); + // @ts-expect-error icon is required + complete.type<{ args: { icon: string } }>().story(); + // @ts-expect-error icon is required + complete.type<{ args: { icon: string } }>().story({}); + complete.type<{ args: { icon: string } }>().story(() => ({ Component: Divider })); + complete.type<{ args: { icon: string } }>().story({ render: () => ({ Component: Divider }) }); + }); + + it('composes with preview.type<>(), decorators and itself', () => { + const withTheme: Decorator<{ theme: 'light' | 'dark' }> = (Story) => Story(); + const typedMeta = preview.type<{ args: { locale: 'en' | 'nl' } }>().meta({ + component: Button, + decorators: [withTheme], + args: { locale: 'nl', disabled: false }, + }); + const typed = typedMeta.type<{ args: { icon: string } }>().type<{ args: { size: number } }>(); + + typed.story({ + args: { label: 'Hi', theme: 'dark', icon: 'star', size: 1 }, + play: async ({ args }) => { + expectTypeOf(args.locale).toEqualTypeOf<'en' | 'nl'>(); + expectTypeOf(args.theme).toEqualTypeOf<'light' | 'dark'>(); + expectTypeOf(args.icon).toEqualTypeOf(); + expectTypeOf(args.size).toEqualTypeOf(); + }, + }); + // @ts-expect-error size is required + typed.story({ args: { label: 'Hi', theme: 'dark', icon: 'star' } }); + }); + + it('the story can be extended and composed', () => { + const WithIcon = meta.type<{ args: { icon: string } }>().story({ + args: { label: 'Hi', icon: 'star' }, + }); + const WithHeart = WithIcon.extend({ args: { icon: 'heart' } }); + // @ts-expect-error icon is a string + WithIcon.extend({ args: { icon: 1 } }); + + expectTypeOf(WithIcon.composed.args.icon).toEqualTypeOf(); + expect(WithIcon.composed.args).toEqual({ label: 'Hi', icon: 'star', disabled: false }); + expect(WithHeart.composed.args).toEqual({ label: 'Hi', icon: 'heart', disabled: false }); + }); +}); diff --git a/code/renderers/svelte/src/index.ts b/code/renderers/svelte/src/index.ts index 9b9a0c4d4c56..af5589e45e19 100644 --- a/code/renderers/svelte/src/index.ts +++ b/code/renderers/svelte/src/index.ts @@ -2,4 +2,6 @@ import './globals.ts'; export * from './public-types.ts'; export * from './portable-stories.ts'; +export * from './preview.ts'; export { defineMeta } from './svelte-csf/index.ts'; +export type { SvelteTypes } from './types.ts'; diff --git a/code/renderers/svelte/src/preview.ts b/code/renderers/svelte/src/preview.ts new file mode 100644 index 000000000000..32a90195d489 --- /dev/null +++ b/code/renderers/svelte/src/preview.ts @@ -0,0 +1,149 @@ +import type { + AddonTypes, + InferTypes, + Meta, + MetaInput, + MetaTypes, + Preview, + PreviewAddonEntry, + Story, + StoryArgs, + TypedMetaArgKeys, + WithRenderArgs, +} from 'storybook/internal/csf'; +import { definePreview as definePreviewBase } from 'storybook/internal/csf'; +import type { + Args, + ArgsStoryFn, + DecoratorFunction, + ProjectAnnotations, + StoryAnnotations, +} from 'storybook/internal/types'; + +import type { Component, ComponentProps } from 'svelte'; +import type { Simplify } from 'type-fest'; + +import * as svelteAnnotations from './entry-preview.ts'; +import * as svelteDocsAnnotations from './entry-preview-docs.ts'; +import type { SvelteTypes } from './types.ts'; + +/** + * Define the preview of a Svelte project, with the `preview.meta()` and `meta.story()` factories. + * + * @example + * + * ```ts + * // .storybook/preview.ts + * import { definePreview } from '@storybook/svelte-vite'; + * + * export default definePreview({ + * addons: [], + * parameters: { layout: 'centered' }, + * }); + * ``` + */ +export function __definePreview( + input: { addons: Addons } & ProjectAnnotations> +): SveltePreview> { + return definePreviewBase({ + ...input, + addons: [svelteAnnotations, svelteDocsAnnotations, ...(input.addons ?? [])], + }) as SveltePreview>; +} + +// A component typed as `any` or by the `*.svelte` shim has no known props, so it takes any args. +type InferComponentArgs = + C extends Component + ? [keyof ComponentProps] extends [never] + ? Args + : ComponentProps + : Args; + +/** + * The preview returned by `definePreview`. `preview.meta()` infers the args from the props of the + * component, the decorators, and the addons. + */ +export interface SveltePreview extends Preview { + /** + * Add types to the metas created from the returned preview, such as an arg that the component + * doesn't declare: `preview.type<{ args: { theme: 'light' | 'dark' } }>().meta({ component })`. + */ + type(): SveltePreview; + + meta< + C, + Decorators extends DecoratorFunction, + TRenderArgs = unknown, + TMetaArgKeys extends PropertyKey = never, + >( + meta: { + component: C; + render?: ArgsStoryFn & TRenderArgs & T['args']>; + } & MetaInput< + SvelteTypes & T, + WithRenderArgs, TRenderArgs>, + Decorators, + TMetaArgKeys + > + ): SvelteMeta< + MetaTypes< + SvelteTypes & T, + WithRenderArgs, TRenderArgs>, + Decorators, + TMetaArgKeys + >, + TMetaArgKeys + >; + + meta< + TArgs extends Args, + Decorators extends DecoratorFunction, + TMetaArgKeys extends PropertyKey = never, + >( + meta: { + render?: ArgsStoryFn; + } & MetaInput + ): SvelteMeta, TMetaArgKeys>; +} + +/** + * The meta returned by `preview.meta()`. Args set in meta are optional in its stories; the other + * required args must be set in each story. + */ +export interface SvelteMeta< + T extends SvelteTypes, + TMetaArgKeys extends PropertyKey = never, +> extends Meta { + /** Create a story with a render that takes no args, so the story needs no args. */ + story< + TInput extends + | (() => SvelteTypes['storyResult']) + | (StoryAnnotations & { render: () => SvelteTypes['storyResult'] }), + >( + story: TInput + ): SvelteStory SvelteTypes['storyResult'] ? { render: TInput } : TInput>; + + /** Create a story. It must set the required args that meta didn't set. */ + story< + TInput extends Simplify>>, + >( + story: TInput + ): SvelteStory; + + /** Create a story without annotations. Only allowed when meta sets every required arg. */ + story( + ..._args: Partial extends StoryArgs ? [] : [never] + ): SvelteStory; + + /** + * Add types to the stories created from the returned meta, such as an arg that only one story + * has: `meta.type<{ args: { icon: string } }>().story({ args: { icon: 'star' } })`. + */ + type(): SvelteMeta>; +} + +/** The story returned by `meta.story()`. */ +export interface SvelteStory< + T extends SvelteTypes, + TInput extends StoryAnnotations, +> extends Story {} diff --git a/code/renderers/svelte/src/types.ts b/code/renderers/svelte/src/types.ts index ddceb7c958a7..03ae5ca5d3f1 100644 --- a/code/renderers/svelte/src/types.ts +++ b/code/renderers/svelte/src/types.ts @@ -23,6 +23,8 @@ export interface SvelteRenderer< ) => Promise; } +export interface SvelteTypes extends SvelteRenderer {} + export interface SvelteStoryResult< Props extends Record = any, Exports extends Record = any, From 76a451e0f5dc60c271a186bd2433f1e80c5471bb Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 7 Oct 2026 22:21:28 +0200 Subject: [PATCH 009/326] test(svelte): run factory stories with portable stories Co-Authored-By: Claude Opus 5.5 (1M context) --- .../composeStories/Button.csf4.stories.ts | 71 +++++++ .../InputFilledStoryComponent.svelte | 2 +- .../LoaderStoryComponent.svelte | 4 +- .../portable-stories-factory.test.ts.snap | 190 ++++++++++++++++++ .../portable-stories-factory.test.ts | 30 +++ 5 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 code/renderers/svelte/src/__test__/composeStories/Button.csf4.stories.ts create mode 100644 code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories-factory.test.ts.snap create mode 100644 code/renderers/svelte/src/__test__/composeStories/portable-stories-factory.test.ts diff --git a/code/renderers/svelte/src/__test__/composeStories/Button.csf4.stories.ts b/code/renderers/svelte/src/__test__/composeStories/Button.csf4.stories.ts new file mode 100644 index 000000000000..44cc9d79df04 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/Button.csf4.stories.ts @@ -0,0 +1,71 @@ +import type { ComponentProps } from 'svelte'; + +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { __definePreview } from '../../preview.ts'; +import AddWrapperDecorator from './AddWrapperDecorator.svelte'; +import Button from './Button.svelte'; +import CustomRenderComponent from './CustomRenderComponent.svelte'; +import InputFilledStoryComponent from './InputFilledStoryComponent.svelte'; +import LoaderStoryComponent from './LoaderStoryComponent.svelte'; + +const preview = __definePreview({ addons: [] }); + +const meta = preview.meta({ + title: 'Example/Button', + component: Button, + argTypes: { + backgroundColor: { control: 'color' }, + size: { control: { type: 'select' }, options: ['small', 'medium', 'large'] }, + }, +}); + +export const CSF3Primary = meta.story({ + args: { label: 'foo', size: 'large', primary: true }, +}); + +export const CSF3Button = meta.story({ args: { label: 'foo' } }); + +export const WithDecorator = meta.story({ + args: { label: 'foo' }, + decorators: [() => ({ Component: AddWrapperDecorator })], +}); + +export const CSF3ButtonWithRender = meta + .type<{ args: ComponentProps }>() + .story({ + args: { buttonProps: { label: 'foo' } }, + render: (args) => ({ Component: CustomRenderComponent, props: args }), + }); + +export const CSF3InputFieldFilled = meta.story({ + render: () => ({ Component: InputFilledStoryComponent }), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Step label', async () => { + const inputEl = canvas.getByTestId('input'); + await userEvent.type(inputEl, 'Hello world!'); + await expect(inputEl).toHaveValue('Hello world!'); + }); + }, +}); + +const mockFn = fn(); +export const LoaderStory = meta.type<{ args: { mockFn: (arg: string) => string } }>().story({ + args: { mockFn }, + loaders: [ + async () => { + mockFn.mockReturnValueOnce('mockFn return value'); + return { value: 'loaded data' }; + }, + ], + render: (args, { loaded }) => ({ + Component: LoaderStoryComponent, + props: { ...args, loaded: loaded as { value: string } }, + }), + play: async () => { + expect(mockFn).toHaveBeenCalledWith('render'); + }, +}); + +export const Extended = CSF3Primary.extend({ args: { label: 'extended' } }); diff --git a/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte index 8ad32f10e39f..59ca0793a7de 100644 --- a/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte +++ b/code/renderers/svelte/src/__test__/composeStories/InputFilledStoryComponent.svelte @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte b/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte index 8e30fc3c95d7..ed9e6b4c8db8 100644 --- a/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte +++ b/code/renderers/svelte/src/__test__/composeStories/LoaderStoryComponent.svelte @@ -4,13 +4,13 @@ loaded, }: { mockFn: (arg: string) => string; - loaded: { value: string }; + loaded?: { value: string }; } = $props(); let data = $derived(mockFn && loaded && mockFn('render'));
-
{loaded.value}
+
{loaded?.value}
{String(data)}
diff --git a/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories-factory.test.ts.snap b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories-factory.test.ts.snap new file mode 100644 index 000000000000..f32d82f57021 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/__snapshots__/portable-stories-factory.test.ts.snap @@ -0,0 +1,190 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Renders CSF3Button story 1`] = ` + +
+ + + + + + + + + + +
+ +`; + +exports[`Renders CSF3ButtonWithRender story 1`] = ` + +
+ + + +
+

+ I am a custom render function +

+ + + +
+ + + + + + +
+ +`; + +exports[`Renders CSF3InputFieldFilled story 1`] = ` + +
+ + + + + + + + + + +
+ +`; + +exports[`Renders CSF3Primary story 1`] = ` + +
+ + + + + + + + + + +
+ +`; + +exports[`Renders Extended story 1`] = ` + +
+ + + + + + + + + + +
+ +`; + +exports[`Renders LoaderStory story 1`] = ` + +
+ + + +
+
+ loaded data +
+ +
+ mockFn return value +
+
+ + + + + + +
+ +`; + +exports[`Renders WithDecorator story 1`] = ` + +
+ + + + + +
+ + + + +
+ + + + + + + + +
+ +`; diff --git a/code/renderers/svelte/src/__test__/composeStories/portable-stories-factory.test.ts b/code/renderers/svelte/src/__test__/composeStories/portable-stories-factory.test.ts new file mode 100644 index 000000000000..826e69a1d685 --- /dev/null +++ b/code/renderers/svelte/src/__test__/composeStories/portable-stories-factory.test.ts @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { cleanup, screen } from '@testing-library/svelte'; +import { afterEach, expect, it } from 'vitest'; + +import * as stories from './Button.csf4.stories.ts'; + +afterEach(() => { + cleanup(); +}); + +it('runs the play function', async () => { + await stories.CSF3InputFieldFilled.run(); + expect((screen.getByTestId('input') as HTMLInputElement).value).toEqual('Hello world!'); +}); + +it('merges the args of an extended story', () => { + expect(stories.Extended.composed.args).toEqual({ + label: 'extended', + size: 'large', + primary: true, + }); +}); + +const testCases = Object.entries(stories).map( + ([name, Story]) => [name, Story] as [string, typeof Story] +); +it.each(testCases)('Renders %s story', async (_storyName, Story) => { + await Story.run(); + expect(document.body).toMatchSnapshot(); +}); From b7944cc90889505d4c6a5413576c0388adeca37c Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Wed, 7 Oct 2026 22:21:31 +0200 Subject: [PATCH 010/326] feat(svelte-vite, sveltekit): export definePreview The SvelteKit definePreview adds the SvelteKit mocks and types parameters.sveltekit_experimental. The types now accept what the mocks accept at runtime: each nested mock is optional, callbacks may return anything, and page.error is SvelteKit's error object. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/frameworks/svelte-vite/src/index.test.ts | 4 + code/frameworks/svelte-vite/src/index.ts | 2 + .../sveltekit/src/csf-factories.test.ts | 98 +++++++++++++++++++ code/frameworks/sveltekit/src/index.ts | 33 +++++++ code/frameworks/sveltekit/src/types.ts | 39 +++++--- code/frameworks/sveltekit/vitest.config.ts | 2 +- 6 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 code/frameworks/sveltekit/src/csf-factories.test.ts diff --git a/code/frameworks/svelte-vite/src/index.test.ts b/code/frameworks/svelte-vite/src/index.test.ts index 31b0a92a8d13..d0955acb4289 100644 --- a/code/frameworks/svelte-vite/src/index.test.ts +++ b/code/frameworks/svelte-vite/src/index.test.ts @@ -9,3 +9,7 @@ it('exports Svelte CSF, so stories files can import it from the framework', () = expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + +it('exports definePreview, so the preview file can import it from the framework', () => { + expectTypeOf().toEqualTypeOf(); +}); diff --git a/code/frameworks/svelte-vite/src/index.ts b/code/frameworks/svelte-vite/src/index.ts index 6bc315a6a6f6..20d0c161d7f8 100644 --- a/code/frameworks/svelte-vite/src/index.ts +++ b/code/frameworks/svelte-vite/src/index.ts @@ -1,2 +1,4 @@ export * from '@storybook/svelte'; export * from './types.ts'; + +export { __definePreview as definePreview } from '@storybook/svelte'; diff --git a/code/frameworks/sveltekit/src/csf-factories.test.ts b/code/frameworks/sveltekit/src/csf-factories.test.ts new file mode 100644 index 000000000000..bb3e0a926bd7 --- /dev/null +++ b/code/frameworks/sveltekit/src/csf-factories.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment happy-dom +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { Component } from 'svelte'; + +import { fn } from 'storybook/test'; + +import { definePreview } from './index.ts'; +import * as svelteKitPreview from './preview.ts'; + +type BadgeProps = { variant: 'primary' | 'secondary'; label: string }; +const Badge = {} as Component; + +const preview = definePreview({ addons: [] }); + +it('includes the SvelteKit mocks', () => { + expect(preview.composed.decorators).toEqual(expect.arrayContaining(svelteKitPreview.decorators)); + expect(preview.composed.beforeEach).toContain(svelteKitPreview.beforeEach); +}); + +it('infers the args from the component through the wrapper', () => { + const meta = preview.meta({ component: Badge, args: { variant: 'primary' } }); + expectTypeOf(meta.input.args.variant).toEqualTypeOf<'primary' | 'secondary'>(); + + // @ts-expect-error label is required + const Missing = meta.story(); +}); + +it('infers the args when the preview has no addons', () => { + const noAddons = definePreview({ + parameters: { sveltekit_experimental: { state: { page: { status: 404 } } } }, + }); + const meta = noAddons.meta({ component: Badge, args: { variant: 'primary' } }); + const Default = meta.story({ args: { label: 'Hi' } }); + // @ts-expect-error label is required + const Missing = meta.story(); +}); + +describe('parameters.sveltekit_experimental', () => { + it('accepts mocks that return nothing, as the docs show', () => { + const meta = preview.meta({ component: Badge, args: { variant: 'primary', label: 'Hi' } }); + const Callbacks = meta.story({ + parameters: { + sveltekit_experimental: { + forms: { enhance: () => {} }, + navigation: { goto: (url: string) => {}, invalidateAll: () => {} }, + }, + }, + }); + const Mocks = meta.story({ + parameters: { + sveltekit_experimental: { forms: { enhance: fn() }, navigation: { goto: fn() } }, + }, + }); + }); + + it('accepts the page error of SvelteKit, an object with a message', () => { + const meta = preview.meta({ component: Badge, args: { variant: 'primary', label: 'Hi' } }); + const NotFound = meta.story({ + parameters: { + sveltekit_experimental: { + state: { page: { status: 404, error: { message: 'Not found', code: 'E404' } } }, + }, + }, + }); + const Thrown = meta.story({ + parameters: { sveltekit_experimental: { state: { page: { error: new Error('Boom') } } } }, + }); + }); + + it('is typed in the preview, the meta and the story', () => { + definePreview({ + addons: [], + parameters: { sveltekit_experimental: { state: { updated: { current: true } } } }, + }); + + const meta = preview.meta({ + component: Badge, + args: { variant: 'primary', label: 'Hi' }, + parameters: { sveltekit_experimental: { state: { page: { status: 404 } } } }, + }); + const Default = meta.story({ + parameters: { sveltekit_experimental: { hrefs: { '/home': () => {} } } }, + }); + }); + + it('rejects invalid mocks', () => { + const meta = preview.meta({ component: Badge, args: { variant: 'primary', label: 'Hi' } }); + meta.story({ + parameters: { + sveltekit_experimental: { + // @ts-expect-error status must be a number + state: { page: { status: '404' } }, + }, + }, + }); + }); +}); diff --git a/code/frameworks/sveltekit/src/index.ts b/code/frameworks/sveltekit/src/index.ts index 96cafd282a3f..034fcc50b950 100644 --- a/code/frameworks/sveltekit/src/index.ts +++ b/code/frameworks/sveltekit/src/index.ts @@ -1,4 +1,37 @@ +import type { + AddonTypes, + InferTypes, + PreviewAddon, + PreviewAddonEntry, +} from 'storybook/internal/csf'; +import type { ProjectAnnotations } from 'storybook/internal/types'; + +import type { SveltePreview, SvelteTypes } from '@storybook/svelte'; +import { __definePreview } from '@storybook/svelte'; + +import * as svelteKitPreview from './preview.ts'; +import type { SvelteKitTypes } from './types.ts'; + export * from '@storybook/svelte'; export * from './types.ts'; // @ts-expect-error (double exports) export * from './portable-stories.ts'; + +/** + * Define the preview of a SvelteKit project, with the `preview.meta()` and `meta.story()` + * factories. It includes the SvelteKit mocks, configured with `parameters.sveltekit_experimental`. + */ +export function definePreview( + preview: { addons?: Addons } & ProjectAnnotations< + SvelteTypes & SvelteKitTypes & InferTypes + > +): SvelteKitPreview> { + return __definePreview({ + ...preview, + addons: [svelteKitPreview, ...(preview.addons ?? [])] as PreviewAddon< + SvelteKitTypes & InferTypes + >[], + }); +} + +export interface SvelteKitPreview extends SveltePreview {} diff --git a/code/frameworks/sveltekit/src/types.ts b/code/frameworks/sveltekit/src/types.ts index e92699b9ca6e..3c3fc797464d 100644 --- a/code/frameworks/sveltekit/src/types.ts +++ b/code/frameworks/sveltekit/src/types.ts @@ -5,7 +5,6 @@ import type { import type { BuilderOptions, StorybookConfigVite } from '@storybook/builder-vite'; -import type { enhance } from './mocks/app/forms.ts'; import type { goto, invalidate, @@ -75,7 +74,7 @@ interface Page< id: RouteId; }; status: number; - error: Error | null; + error: ({ message: string } & Record) | null; data: Record; state: Record; form: any; @@ -112,23 +111,33 @@ interface Navigation { complete: Promise; } +// The mocks ignore what a callback returns. +type MockOf unknown> = (...args: Parameters) => void; + export type SvelteKitParameters = Partial<{ hrefs: Record; - state: { + state: Partial<{ page: Partial; navigating: Partial; updated: { current: boolean }; - }; - navigation: { - goto: typeof goto; - invalidate: typeof invalidate; - invalidateAll: typeof invalidateAll; - refreshAll: typeof refreshAll; - pushState: typeof pushState; - replaceState: typeof replaceState; + }>; + navigation: Partial<{ + goto: MockOf; + invalidate: MockOf; + invalidateAll: MockOf; + refreshAll: MockOf; + pushState: MockOf; + replaceState: MockOf; afterNavigate: Record; - }; - forms: { - enhance: typeof enhance; - }; + }>; + forms: Partial<{ + enhance: (event: SubmitEvent) => void; + }>; }>; + +export interface SvelteKitTypes { + parameters: { + /** Mocks for the SvelteKit modules, such as `$app/state` and `$app/navigation`. */ + sveltekit_experimental?: SvelteKitParameters; + }; +} diff --git a/code/frameworks/sveltekit/vitest.config.ts b/code/frameworks/sveltekit/vitest.config.ts index 25fae90d1f77..e79dbf04e28e 100644 --- a/code/frameworks/sveltekit/vitest.config.ts +++ b/code/frameworks/sveltekit/vitest.config.ts @@ -5,6 +5,6 @@ import { vitestCommonConfig } from '../../vitest.shared.ts'; export default mergeConfig( vitestCommonConfig, defineConfig({ - // Add custom config here + plugins: [import('@sveltejs/vite-plugin-svelte').then(({ svelte }) => svelte())], }) ); From c68ff4914969ffed3d2b0b29f61edf2e47be5199 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 9 Oct 2026 09:39:11 +0200 Subject: [PATCH 011/326] test(svelte): show placeholders for the version and paths in error snapshots The error snapshots interpolated the package version and the file URLs with template literals. Updating them with `-u` replaced those with the current version and the absolute paths of the machine, which break on the next release or on another machine. A snapshot serializer now prints `v` and `` in their place. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../remove-export-default.test.ts | 3 +-- .../parser/analyse/story/attributes.test.ts | 7 +++--- .../story/attributes/identifiers.test.ts | 19 +++++++-------- .../parser/extract/compiled/nodes.test.ts | 4 +--- .../extract/svelte/module-nodes.test.ts | 11 ++++----- code/renderers/svelte/vitest-setup.ts | 23 +++++++++++++++++++ code/renderers/svelte/vitest.config.ts | 1 + 7 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 code/renderers/svelte/vitest-setup.ts diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/remove-export-default.test.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/remove-export-default.test.ts index 2ba78ef0c3b3..5b77fb949762 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/remove-export-default.test.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/remove-export-default.test.ts @@ -9,7 +9,6 @@ import { describe, it } from 'vitest'; import { removeExportDefault } from './remove-export-default.ts'; import { extractCompiledASTNodes } from '../../parser/extract/compiled/nodes.ts'; -import { StorybookSvelteCSFError } from '../../utils/error.ts'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); @@ -35,7 +34,7 @@ describe(removeExportDefault.name, () => { ` [SB_SVELTE_CSF_PARSER_EXTRACT_COMPILED_0003 (NoExportDefaultError): Could not find 'export default' in the compiled output of the stories file: - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_EXTRACT_COMPILED_0003 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_EXTRACT_COMPILED_0003 ] ` ); diff --git a/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes.test.ts b/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes.test.ts index 659eff996300..795bfa34d24d 100644 --- a/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes.test.ts +++ b/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes.test.ts @@ -5,7 +5,6 @@ import { getArrayOfStringsValueFromAttribute, getStringValueFromAttribute } from import { getSvelteAST } from '../../ast.ts'; import { extractSvelteASTNodes } from '../../extract/svelte/nodes.ts'; import { extractStoryAttributesNodes } from '../../extract/svelte/story/attributes.ts'; -import { StorybookSvelteCSFError } from '../../../utils/error.ts'; describe(getStringValueFromAttribute.name, () => { it("throws error when a `` 'name' attribute value is not a string", async ({ @@ -39,7 +38,7 @@ describe(getStringValueFromAttribute.name, () => { A '' has a prop 'name' whose value must be a static literal string. - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0001 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0001 ] `); }); @@ -110,7 +109,7 @@ describe(getArrayOfStringsValueFromAttribute.name, () => { A '' has a prop'tags' whose value was expected to be a static array. Instead the value type is '0'. - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0002 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0002 ] ` ); @@ -150,7 +149,7 @@ describe(getArrayOfStringsValueFromAttribute.name, () => { A '' has attribute 'tags' whose value was expected to be an array expression. All elements in the array must be static literal strings only, but one of the elements is of type 'undefined'. - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0003 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0003 ] ` ); diff --git a/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes/identifiers.test.ts b/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes/identifiers.test.ts index afc3bad3bd36..4705059345f3 100644 --- a/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes/identifiers.test.ts +++ b/code/renderers/svelte/src/svelte-csf/parser/analyse/story/attributes/identifiers.test.ts @@ -1,5 +1,3 @@ -import url from 'node:url'; - import { describe, expect, it } from 'vitest'; import { getStoryIdentifiers, getStoriesIdentifiers } from './identifiers.ts'; @@ -7,7 +5,6 @@ import { getStoryIdentifiers, getStoriesIdentifiers } from './identifiers.ts'; import { getSvelteAST } from '../../../ast.ts'; import { extractSvelteASTNodes } from '../../../extract/svelte/nodes.ts'; import { extractStoryAttributesNodes } from '../../../extract/svelte/story/attributes.ts'; -import { StorybookSvelteCSFError } from '../../../../utils/error.ts'; describe(getStoryIdentifiers.name, () => { it("extracts 'exportName' attribute when is a Text string", async () => { @@ -94,10 +91,10 @@ describe(getStoryIdentifiers.name, () => { }) ).toThrowErrorMatchingInlineSnapshot( ` - [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0004 (NoStoryIdentifierError): Missing 'name' or 'exportName' attribute (prop) in a '' definition in the stories file: '${url.pathToFileURL('invalid.stories.svelte')}'. + [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0004 (NoStoryIdentifierError): Missing 'name' or 'exportName' attribute (prop) in a '' definition in the stories file: '/invalid.stories.svelte'. All stories must either have a 'name' or an 'exportName' prop, or both. - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0004 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0004 ] ` ); @@ -218,13 +215,13 @@ describe(getStoryIdentifiers.name, () => { }) ).toThrowErrorMatchingInlineSnapshot( ` - [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0005 (InvalidStoryExportNameError): Invalid attribute 'exportName' value 'default' found in '' component inside stories file: ${url.pathToFileURL('invalid.stories.svelte')} + [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0005 (InvalidStoryExportNameError): Invalid attribute 'exportName' value 'default' found in '' component inside stories file: /invalid.stories.svelte 'exportName' value must be a valid JavaScript variable name. It must start with a letter, $ or _, followed by letters, numbers, $ or _. Reserved words like 'default' are also not allowed (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#reserved_words) - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0005 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0005 ] ` ); @@ -290,7 +287,7 @@ describe(getStoriesIdentifiers.name, () => { }) ).toThrowErrorMatchingInlineSnapshot( ` - [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 (DuplicateStoryIdentifiersError): Duplicate exportNames found between two '' definitions in stories file: ${url.pathToFileURL('duplicate-identifiers.stories.svelte')} + [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 (DuplicateStoryIdentifiersError): Duplicate exportNames found between two '' definitions in stories file: /duplicate-identifiers.stories.svelte First instance: Second instance: @@ -300,7 +297,7 @@ describe(getStoriesIdentifiers.name, () => { eg. 'Some story name!!' will be converted to 'SomeStoryName'. You can fix this collision by providing a unique 'exportName' prop with . - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 ] ` ); @@ -326,7 +323,7 @@ describe(getStoriesIdentifiers.name, () => { }) ).toThrowErrorMatchingInlineSnapshot( ` - [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 (DuplicateStoryIdentifiersError): Duplicate exportNames found between two '' definitions in stories file: ${url.pathToFileURL('duplicate-identifiers.stories.svelte')} + [SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 (DuplicateStoryIdentifiersError): Duplicate exportNames found between two '' definitions in stories file: /duplicate-identifiers.stories.svelte First instance: Second instance: @@ -336,7 +333,7 @@ describe(getStoriesIdentifiers.name, () => { eg. 'Some story name!!' will be converted to 'SomeStoryName'. You can fix this collision by providing a unique 'exportName' prop with . - More info: https://github.com/storybookjs/storybook/blob/v${StorybookSvelteCSFError.packageVersion}/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 + More info: https://github.com/storybookjs/storybook/blob/v/code/renderers/svelte/src/svelte-csf/ERRORS.md#SB_SVELTE_CSF_PARSER_ANALYSE_STORY_0006 ] ` ); diff --git a/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.test.ts b/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.test.ts index 28c551c1cb84..a1f77ba362a1 100644 --- a/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.test.ts +++ b/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.test.ts @@ -6,8 +6,6 @@ import { extractCompiledASTNodes } from './nodes.ts'; import { SVELTE_CSF_IMPORT_SOURCES } from '../../../constants.ts'; -import { StorybookSvelteCSFError } from '../../../utils/error.ts'; - function getCompiledAST(moduleScript: string) { const { js } = compile( ` +``` + +### `SB_SVELTE_CSF_PARSER_EXTRACT_SVELTE_0011` + +The stories file calls `preview.meta()`, but `preview` isn't imported from the preview file of Storybook. Storybook finds a CSF factories meta through this import. The import path must end with `/preview`, with or without a file extension: + +```diff + +``` + +### `SB_SVELTE_CSF_PARSER_EXTRACT_SVELTE_0012` + +The stories file calls `preview.meta()` more than once. A stories file has one meta. Move the stories of each meta to their own stories file: + +```diff + +``` + ## `PARSER_EXTRACT_COMPILED` > [!NOTE] diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-runtime-story-variable-declaration.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-runtime-story-variable-declaration.ts index 996f9a69ea87..2e7c91e91390 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-runtime-story-variable-declaration.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-runtime-story-variable-declaration.ts @@ -15,6 +15,8 @@ interface RuntimeStoryVariableDeclarationParams { nodes: { variable: ReturnType; tags?: ESTreeAST.ArrayExpression; + // The meta of a CSF factories file, which creates the story with `meta.story()` + factoryMeta?: ESTreeAST.Identifier; }; } @@ -26,6 +28,21 @@ export function createRuntimeStoryVariableDeclaration( { type: 'Literal', value: SVELTE_CSF_V5_TAG }, ]); + const annotations = createASTObjectExpression([ + { + type: 'SpreadElement', + argument: { + type: 'MemberExpression', + computed: true, + optional: false, + object: params.nodes.variable.declarations[0].id as ESTreeAST.Identifier, + property: { type: 'Literal', value: params.exportName }, + }, + }, + createASTProperty('tags', tags), + ]); + const { factoryMeta } = params.nodes; + return { type: 'VariableDeclaration', kind: 'const', @@ -33,19 +50,20 @@ export function createRuntimeStoryVariableDeclaration( { type: 'VariableDeclarator', id: createASTIdentifier(`${STORYBOOK_INTERNAL_PREFIX}${params.exportName}`), - init: createASTObjectExpression([ - { - type: 'SpreadElement', - argument: { - type: 'MemberExpression', - computed: true, + init: factoryMeta + ? { + type: 'CallExpression', optional: false, - object: params.nodes.variable.declarations[0].id as ESTreeAST.Identifier, - property: { type: 'Literal', value: params.exportName }, - }, - }, - createASTProperty('tags', tags), - ]), + callee: { + type: 'MemberExpression', + computed: false, + optional: false, + object: factoryMeta, + property: createASTIdentifier('story'), + }, + arguments: [annotations], + } + : annotations, }, ], }; diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-variable-from-runtime-stories-call.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-variable-from-runtime-stories-call.ts index b0ebfcf404f1..a0da100daff1 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-variable-from-runtime-stories-call.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/appendix/create-variable-from-runtime-stories-call.ts @@ -3,13 +3,15 @@ import { createASTIdentifier, type ESTreeAST } from '../../../parser/ast.ts'; interface Params { storiesFunctionDeclaration: ESTreeAST.FunctionDeclaration; + // The meta of a CSF factories file. Its annotations are in `meta.input`. + factoryMeta?: ESTreeAST.Identifier; filename?: string; } export function createVariableFromRuntimeStoriesCall( params: Params ): ESTreeAST.VariableDeclaration { - const { storiesFunctionDeclaration } = params; + const { storiesFunctionDeclaration, factoryMeta } = params; return { type: 'VariableDeclaration', @@ -26,7 +28,15 @@ export function createVariableFromRuntimeStoriesCall( callee: createASTIdentifier('createRuntimeStories'), arguments: [ createASTIdentifier(storiesFunctionDeclaration.id.name), - createASTIdentifier(STORYBOOK_META_IDENTIFIER), + factoryMeta + ? { + type: 'MemberExpression', + computed: false, + optional: false, + object: factoryMeta, + property: createASTIdentifier('input'), + } + : createASTIdentifier(STORYBOOK_META_IDENTIFIER), ], }, }, diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/create-appendix.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/create-appendix.ts index 7775dee56a8d..c1bf615b3c6d 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/create-appendix.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/create-appendix.ts @@ -26,6 +26,9 @@ export async function createAppendix(params: Params) { const { code, nodes, filename } = params; const { compiled, svelte } = nodes; const { storiesFunctionDeclaration } = compiled; + const factoryMeta = compiled.isFactory + ? (compiled.metaIdentifier ?? createASTIdentifier(STORYBOOK_META_IDENTIFIER)) + : undefined; const storiesIdentifiers = getStoriesIdentifiers({ nodes: svelte, @@ -33,6 +36,7 @@ export async function createAppendix(params: Params) { }); const variableFromRuntimeStoriesCall = createVariableFromRuntimeStoriesCall({ storiesFunctionDeclaration, + factoryMeta, filename, }); const storiesVariableDeclarations = storiesIdentifiers.map(({ exportName }, idx) => @@ -45,6 +49,7 @@ export async function createAppendix(params: Params) { storyComponents: params.nodes.svelte.storyComponents, idx, }), + factoryMeta, }, }) ); @@ -55,7 +60,8 @@ export async function createAppendix(params: Params) { body: [ createRuntimeStoriesImport(), variableFromRuntimeStoriesCall, - createExportDefaultMeta(), + // A CSF factories file has no default export: core finds the meta through the stories + ...(factoryMeta ? [] : [createExportDefaultMeta()]), createExportOrderVariableDeclaration({ storiesIdentifiers, filename }), ...storiesVariableDeclarations, createNamedExportStories({ storiesIdentifiers }), diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/index.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/index.ts index 2ec62a028b2f..e35988f7e614 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/index.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/index.ts @@ -26,6 +26,12 @@ export function transformDefineMeta(params: Params): void { const { code, nodes, filename } = params; insertDefineMetaParameters({ nodes, filename }); + + if (nodes.compiled.isFactory) { + transformFactoryMeta(params); + return; + } + const metaObjectExpression = replaceDefineMetaArgument({ nodes, filename }); const metaVariableDeclaration = createMetaVariableDeclaration({ init: metaObjectExpression }); @@ -37,10 +43,26 @@ export function transformDefineMeta(params: Params): void { code.appendLeft(start as number, print(metaVariableDeclaration).code + '\n'); } +// Names the meta `$__meta`, unless the file already does, so that the stories can call `meta.story()` +function transformFactoryMeta(params: Params): void { + const { code, nodes } = params; + const { defineMetaVariableDeclaration, metaIdentifier } = nodes.compiled; + const { start, end } = defineMetaVariableDeclaration; + const [declarator] = defineMetaVariableDeclaration.declarations; + let printed = ''; + + if (!metaIdentifier && declarator.init) { + printed += print(createMetaVariableDeclaration({ init: declarator.init })).code + '\n'; + declarator.init = createASTIdentifier(STORYBOOK_META_IDENTIFIER); + } + + code.update(start as number, end as number, printed + print(defineMetaVariableDeclaration).code); +} + export function createMetaVariableDeclaration({ init, }: { - init: ESTreeAST.ObjectExpression; + init: ESTreeAST.Expression; }): ESTreeAST.VariableDeclaration { return { type: 'VariableDeclaration', diff --git a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/replace-argument.ts b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/replace-argument.ts index c21b4ba88bcd..ec4a47d20164 100644 --- a/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/replace-argument.ts +++ b/code/renderers/svelte/src/svelte-csf/compiler/post-transform/define-meta/replace-argument.ts @@ -26,12 +26,7 @@ export function replaceDefineMetaArgument(params: Params): ESTreeAST.ObjectExpre const declaration = params.nodes.compiled.defineMetaVariableDeclaration.declarations[0]; - if ( - !declaration || - declaration.init?.type !== 'CallExpression' || - declaration?.init?.callee.type !== 'Identifier' || - declaration?.init?.callee.name !== params.nodes.compiled.defineMetaImport.local.name - ) { + if (declaration?.init?.type !== 'CallExpression') { throw new NoDestructuredDefineMetaCallError({ defineMetaVariableDeclarator: declaration, filename: params.filename, diff --git a/code/renderers/svelte/src/svelte-csf/indexer/parser.ts b/code/renderers/svelte/src/svelte-csf/indexer/parser.ts index c550dc1d3d41..f465397d3a73 100644 --- a/code/renderers/svelte/src/svelte-csf/indexer/parser.ts +++ b/code/renderers/svelte/src/svelte-csf/indexer/parser.ts @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; -import { findDefineMetaImport } from '../utils/import-source.ts'; +import { findMeta, findMetaImports, hasMetaImport } from '../utils/import-source.ts'; import type { IndexInput } from 'storybook/internal/types'; import { getSvelteAST, type ESTreeAST, type SvelteAST } from '../parser/ast.ts'; @@ -56,7 +56,7 @@ export async function parseForIndexer(filename: string): Promise { const svelteAST = getIndexableAST(code, filename); const results: Results & { - defineMetaImport?: ESTreeAST.ImportSpecifier; + hasMetaImport?: boolean; defineMetaStory?: ESTreeAST.Identifier; } = { meta: {}, @@ -93,64 +93,45 @@ export async function parseForIndexer(filename: string): Promise { Program(node, context) { const { body } = node; const { state, visit } = context; - const imports = findDefineMetaImport(body); + const imports = findMetaImports(body); + const meta = findMeta(body, imports, filename); - state.defineMetaImport = imports.defineMetaImport; + state.hasMetaImport = hasMetaImport(imports); hasDefaultOrNamespaceImport = imports.hasDefaultOrNamespaceImport; - for (const statement of body) { - if (statement.type === 'VariableDeclaration') { - visit(statement, state); - } + if (!meta) { + return; } - }, - VariableDeclaration(node, context) { - const { declarations } = node; - const { state, visit } = context; - const { id, init } = declarations[0]; - - if (init?.type === 'CallExpression') { - const { arguments: arguments_, callee } = init; - - if (callee.type === 'Identifier' && callee.name === state.defineMetaImport?.local.name) { - if (id?.type !== 'ObjectPattern') { - throw new NoDestructuredDefineMetaCallError({ - defineMetaVariableDeclarator: declarations[0], - filename, - }); - } - - const { properties } = id; - const destructuredStoryIdentifier = properties.find( - (property) => - property.type === 'Property' && - property.key.type === 'Identifier' && - property.key.name === 'Story' - ) as ESTreeAST.Property | undefined; - - if (!destructuredStoryIdentifier) { - throw new NoStoryComponentDestructuredError({ - filename, - defineMetaImport: state.defineMetaImport, - }); - } - - state.defineMetaStory = destructuredStoryIdentifier.value as ESTreeAST.Identifier; - - if (arguments_[0].type !== 'ObjectExpression') { - throw new GetDefineMetaFirstArgumentError({ - filename, - defineMetaVariableDeclaration: node, - }); - } - - visit(arguments_[0], state); - } + const [declarator] = meta.declaration.declarations; + + if (!meta.isFactory && declarator.id.type !== 'ObjectPattern') { + throw new NoDestructuredDefineMetaCallError({ + defineMetaVariableDeclarator: declarator, + filename, + }); } + + if (!meta.storyIdentifier) { + throw new NoStoryComponentDestructuredError({ + filename, + metaFunctionName: meta.functionName, + }); + } + + state.defineMetaStory = meta.storyIdentifier; + + if (meta.call.arguments[0]?.type !== 'ObjectExpression') { + throw new GetDefineMetaFirstArgumentError({ + filename, + defineMetaVariableDeclaration: meta.declaration, + }); + } + + visit(meta.call.arguments[0], state); }, - // NOTE: We assume this one is value of first argument passed to `defineMeta({ ... })` call + // NOTE: We assume this one is value of first argument passed to `defineMeta({ ... })` or `preview.meta({ ... })` call ObjectExpression(node, context) { const { properties } = node; const { state, visit } = context; @@ -249,7 +230,7 @@ export async function parseForIndexer(filename: string): Promise { }, }); - if (!results.defineMetaImport) { + if (!results.hasMetaImport) { if (hasDefaultOrNamespaceImport) { throw new DefaultOrNamespaceImportUsedError(filename); } diff --git a/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.ts b/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.ts index 351c4b6f4d1f..8af92d12f8ea 100644 --- a/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.ts +++ b/code/renderers/svelte/src/svelte-csf/parser/extract/compiled/nodes.ts @@ -1,4 +1,4 @@ -import { findDefineMetaImport } from '../../../utils/import-source.ts'; +import { findMeta, findMetaImports, hasMetaImport } from '../../../utils/import-source.ts'; import type { ParseAst } from 'rollup'; import type { Visitors } from 'zimmerframe'; @@ -18,16 +18,16 @@ import { DefaultOrNamespaceImportUsedError } from '../../../utils/error/parser/e * Powered by `rollup`'s internal [`this.parse()`](https://rollupjs.org/plugin-development/#this-parse) */ export interface CompiledASTNodes { + /** `true` when the meta comes from `preview.meta()`, which creates a CSF factories meta. */ + isFactory: boolean; /** - * Import specifier for `defineMeta`, imported from one of `SVELTE_CSF_IMPORT_SOURCES`. - * Could be renamed - e.g. `import { defineMeta } from "@storybook/svelte"` - */ - defineMetaImport: ESTreeAST.ImportSpecifier; - /** - * Variable declaration: `const { Story } = defineMeta({ })` - * Could be destructured with rename - e.g. `const { Story: S } = defineMeta({ ... })` + * Variable declaration with the meta call: `const { Story } = defineMeta({ })`, or + * `preview.meta({ })` in place of `defineMeta({ })`. It can also be `const meta = + * preview.meta({ })`, followed by `const { Story } = meta`. */ defineMetaVariableDeclaration: ESTreeAST.VariableDeclaration; + /** `meta` in `const meta = preview.meta({ })`. */ + metaIdentifier?: ESTreeAST.Identifier; /** * Store the `export default declaration`, we will need to remove it later. * Why? Storybook expects `export default meta`, instead of what `@sveltejs/vite-plugin-svelte` will produce. @@ -44,10 +44,6 @@ export interface CompiledASTNodes { storiesFunctionDeclaration: ESTreeAST.FunctionDeclaration; } -const AST_NODES_NAMES = { - Story: 'Story', -} as const; - interface Params { // Rollup's AST has its own copy of the ESTree types ast: ESTreeAST.Program | ReturnType; @@ -66,35 +62,11 @@ export async function extractCompiledASTNodes(params: Params): Promise & { potentialStoriesFunctionDeclaration: ESTreeAST.FunctionDeclaration[]; } = { potentialStoriesFunctionDeclaration: [] }; - const imports = findDefineMetaImport((ast as ESTreeAST.Program).body); - state.defineMetaImport = imports.defineMetaImport; - const visitors: Visitors = { - VariableDeclaration(node, { state }) { - const { declarations } = node; - const declaration = declarations[0]; - const { id, init } = declaration; - - if ( - id.type === 'ObjectPattern' && - init?.type === 'CallExpression' && - init.callee.type === 'Identifier' && - init.callee.name === state.defineMetaImport?.local.name - ) { - state.defineMetaVariableDeclaration = node; - - for (const property of id.properties) { - if ( - property.type === 'Property' && - property.key.type === 'Identifier' && - property.key.name === AST_NODES_NAMES.Story && - property.value.type === 'Identifier' - ) { - state.storyIdentifier = property.value; - } - } - } - }, + const { body } = ast as ESTreeAST.Program; + const imports = findMetaImports(body); + const meta = findMeta(body, imports, filename); + const visitors: Visitors = { FunctionDeclaration(node, { state }) { state.potentialStoriesFunctionDeclaration.push(node); }, @@ -123,15 +95,9 @@ export async function extractCompiledASTNodes(params: Params): Promise>>, 'filename' | 'nodes'> ): ESTreeAST.ObjectExpression { const { nodes, filename } = options; - const { defineMetaVariableDeclaration, defineMetaImport } = nodes; + const { defineMetaVariableDeclaration } = nodes; const { declarations } = defineMetaVariableDeclaration; const declaration = declarations[0]; const { init } = declaration; if ( init?.type === 'CallExpression' && - init.callee.type === 'Identifier' && - init.callee.name === defineMetaImport.local.name && // NOTE: the callee.name could be renamed by user init.arguments.length === 1 && init.arguments[0].type === 'ObjectExpression' ) { diff --git a/code/renderers/svelte/src/svelte-csf/parser/extract/svelte/module-nodes.ts b/code/renderers/svelte/src/svelte-csf/parser/extract/svelte/module-nodes.ts index e135e1d2e3cb..997f0afb8ee9 100644 --- a/code/renderers/svelte/src/svelte-csf/parser/extract/svelte/module-nodes.ts +++ b/code/renderers/svelte/src/svelte-csf/parser/extract/svelte/module-nodes.ts @@ -1,5 +1,4 @@ -import { findDefineMetaImport } from '../../../utils/import-source.ts'; -import type { Visitors } from 'zimmerframe'; +import { findMeta, findMetaImports, hasMetaImport } from '../../../utils/import-source.ts'; import type { ESTreeAST, SvelteAST } from '../../ast.ts'; import { @@ -9,23 +8,18 @@ import { MissingModuleTagError, NoStoryComponentDestructuredError, } from '../../../utils/error/parser/extract/svelte.ts'; -import type { Identifier } from 'estree'; - -const AST_NODES_NAMES = { - Story: 'Story', -} as const; interface Result { + /** `true` when the meta comes from `preview.meta()`, which creates a CSF factories meta. */ + isFactory: boolean; /** - * Import specifier for `defineMeta`, imported from one of `SVELTE_CSF_IMPORT_SOURCES`. - * Could be renamed - e.g. `import { defineMeta as df } from "@storybook/svelte"` - */ - defineMetaImport: ESTreeAST.ImportSpecifier; - /** - * Variable declaration: `const { Story } = defineMeta({ })` - * Could be destructured with rename - e.g. `const { Story: S } = defineMeta({ ... })` + * Variable declaration with the meta call: `const { Story } = defineMeta({ })`, or + * `preview.meta({ })` in place of `defineMeta({ })`. It can also be `const meta = + * preview.meta({ })`, followed by `const { Story } = meta`. */ defineMetaVariableDeclaration: ESTreeAST.VariableDeclaration; + /** `meta` in `const meta = preview.meta({ })`. */ + metaIdentifier?: ESTreeAST.Identifier; /** * An identifier for the `` component. * It could be destructured with rename - e.g. `const { Story: S } = defineMeta({ ... })` @@ -50,43 +44,10 @@ export async function extractModuleNodes(options: Params): Promise { throw new MissingModuleTagError(filename); } - const { walk } = await import('zimmerframe'); - - const imports = findDefineMetaImport(module.content.body); - const state: Partial = { defineMetaImport: imports.defineMetaImport }; - const visitors: Visitors = { - VariableDeclaration(node, { state }) { - const { declarations } = node; - const declaration = declarations[0]; - const { id, init } = declaration; - - if ( - id.type === 'ObjectPattern' && - init?.type === 'CallExpression' && - init.callee.type === 'Identifier' && - init.callee.name === state.defineMetaImport?.local.name - ) { - state.defineMetaVariableDeclaration = node; - - for (const property of id.properties) { - if ( - property.type === 'Property' && - property.key.type === 'Identifier' && - property.key.name === AST_NODES_NAMES.Story && - property.value.type === 'Identifier' - ) { - state.storyIdentifier = property.value; - } - } - } - }, - }; - - walk(module.content, state, visitors); - - const { defineMetaImport, defineMetaVariableDeclaration, storyIdentifier } = state; + const imports = findMetaImports(module.content.body); + const meta = findMeta(module.content.body, imports, filename); - if (!defineMetaImport) { + if (!hasMetaImport(imports)) { if (imports.hasDefaultOrNamespaceImport) { throw new DefaultOrNamespaceImportUsedError(filename); } @@ -94,17 +55,21 @@ export async function extractModuleNodes(options: Params): Promise { throw new MissingDefineMetaImportError(filename); } - if (!defineMetaVariableDeclaration) { + if (!meta) { throw new MissingDefineMetaVariableDeclarationError(filename); } - if (!storyIdentifier) { - throw new NoStoryComponentDestructuredError({ filename, defineMetaImport }); + if (!meta.storyIdentifier) { + throw new NoStoryComponentDestructuredError({ + filename, + metaFunctionName: meta.functionName, + }); } return { - defineMetaImport, - defineMetaVariableDeclaration, - storyIdentifier, + isFactory: meta.isFactory, + defineMetaVariableDeclaration: meta.declaration, + metaIdentifier: meta.metaIdentifier, + storyIdentifier: meta.storyIdentifier, }; } diff --git a/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/compiled.ts b/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/compiled.ts index f4962e6c9aed..ee7577584f6e 100644 --- a/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/compiled.ts +++ b/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/compiled.ts @@ -14,7 +14,7 @@ export class MissingImportedDefineMetaError extends StorybookSvelteCSFError { template() { return dedent` - Could not find the import statement of 'defineMeta' from ${StorybookSvelteCSFError.importSourcesText} in the compiled output of: ${this.filepathURL} + Could not find the import statement of 'defineMeta' from ${StorybookSvelteCSFError.importSourcesText}, or of the preview from .storybook/preview, in the compiled output of: ${this.filepathURL} `; } } @@ -30,7 +30,7 @@ export class MissingDefineMetaVariableDeclarationError extends StorybookSvelteCS template() { return dedent` - Could not find variable declaration from 'defineMeta' call in the compiled output of the stories file: ${this.filepathURL} + Could not find variable declaration from 'defineMeta' or 'preview.meta' call in the compiled output of the stories file: ${this.filepathURL} `; } } diff --git a/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/svelte.ts b/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/svelte.ts index db99d8817aaf..0c66bc250257 100644 --- a/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/svelte.ts +++ b/code/renderers/svelte/src/svelte-csf/utils/error/parser/extract/svelte.ts @@ -16,6 +16,7 @@ const BASE_INITIAL_SNIPPET = dedent` In a SvelteKit project, import defineMeta from "${StorybookSvelteCSFError.sveltekitImportSource}". +With CSF factories, call preview.meta() in place of defineMeta(), with preview imported from '#.storybook/preview'. `; export class MissingModuleTagError extends StorybookSvelteCSFError { @@ -32,7 +33,7 @@ export class MissingModuleTagError extends StorybookSvelteCSFError { The file '${this.filepathURL}' does not have a module context (). - defineMeta(...) should be called inside a module script tag, like so: + defineMeta(...) or preview.meta(...) should be called inside a module script tag, like so: ${BASE_INITIAL_SNIPPET} `; @@ -73,7 +74,7 @@ export class MissingDefineMetaImportError extends StorybookSvelteCSFError { template() { return dedent` The file '${this.filepathURL}' - does not import defineMeta from ${StorybookSvelteCSFError.importSourcesText} inside the module context. + does not import defineMeta from ${StorybookSvelteCSFError.importSourcesText}, or the preview from .storybook/preview, inside the module context. Make sure to import defineMeta from the package and use it inside the module context like so: @@ -94,7 +95,7 @@ export class MissingDefineMetaVariableDeclarationError extends StorybookSvelteCS template() { return dedent` The file '${this.filepathURL}' - does not store the result of calling defineMeta(). While defineMeta() might have been called, + does not store the result of calling defineMeta() or preview.meta(). While it might have been called, it's return value needs to be stored and destructured for the parsing to succeed, eg.: ${BASE_INITIAL_SNIPPET} @@ -107,23 +108,24 @@ export class NoStoryComponentDestructuredError extends StorybookSvelteCSFError { readonly code = 5; public documentation = true; - public defineMetaImport: SvelteASTNodes['defineMetaImport']; + // The meta function, such as `defineMeta` or `preview.meta` + public metaFunctionName: string; constructor({ filename, - defineMetaImport, + metaFunctionName, }: { filename?: StorybookSvelteCSFError['filename']; - defineMetaImport: NoStoryComponentDestructuredError['defineMetaImport']; + metaFunctionName: NoStoryComponentDestructuredError['metaFunctionName']; }) { super({ filename }); - this.defineMetaImport = defineMetaImport; + this.metaFunctionName = metaFunctionName; } template() { return dedent` The file '${this.filepathURL}' - does not destructure the Story component from the '${this.defineMetaImport.local.name}({ ... })' function call. + does not destructure the Story component from the '${this.metaFunctionName}({ ... })' function call. eg.: ${BASE_INITIAL_SNIPPET} @@ -152,7 +154,7 @@ export class GetDefineMetaFirstArgumentError extends StorybookSvelteCSFError { template() { return dedent` The file '${this.filepathURL}' - passes an invalid first argument to the 'defineMeta' call. + passes an invalid first argument to the 'defineMeta' or 'preview.meta' call. The first argument must be an object expression with the meta properties set. `; @@ -235,3 +237,64 @@ export class IndexerParseError extends StorybookSvelteCSFError { `; } } + +export class MixedMetaError extends StorybookSvelteCSFError { + readonly category = StorybookSvelteCSFError.CATEGORY.parserExtractSvelte; + readonly code = 10; + public documentation = true; + + constructor(filename?: StorybookSvelteCSFError['filename']) { + super({ filename }); + } + + template() { + return dedent` + The file '${this.filepathURL}' + calls both defineMeta() and preview.meta(). A stories file can only have one meta. + Use preview.meta() for CSF factories, or defineMeta() otherwise. + `; + } +} + +export class MultipleMetaError extends StorybookSvelteCSFError { + readonly category = StorybookSvelteCSFError.CATEGORY.parserExtractSvelte; + readonly code = 12; + public documentation = true; + + constructor(filename?: StorybookSvelteCSFError['filename']) { + super({ filename }); + } + + template() { + return dedent` + The file '${this.filepathURL}' + calls preview.meta() more than once. A stories file can only have one meta. + Move the stories of each meta to their own stories file. + `; + } +} + +export class PreviewNotImportedError extends StorybookSvelteCSFError { + readonly category = StorybookSvelteCSFError.CATEGORY.parserExtractSvelte; + readonly code = 11; + public documentation = true; + + constructor(filename?: StorybookSvelteCSFError['filename']) { + super({ filename }); + } + + template() { + return dedent` + The file '${this.filepathURL}' + calls preview.meta(), but it doesn't import preview from the preview file of Storybook. Import it like so: + + + + The import path must end with '/preview', with or without a file extension. + `; + } +} diff --git a/code/renderers/svelte/src/svelte-csf/utils/import-source.ts b/code/renderers/svelte/src/svelte-csf/utils/import-source.ts index 2e0673d1847c..d9819b3db315 100644 --- a/code/renderers/svelte/src/svelte-csf/utils/import-source.ts +++ b/code/renderers/svelte/src/svelte-csf/utils/import-source.ts @@ -1,30 +1,47 @@ +import { isValidPreviewPath } from 'storybook/internal/csf-tools'; + import type * as ESTreeAST from 'estree'; import { SVELTE_CSF_IMPORT_SOURCES } from '../constants.ts'; import { isOneOf } from './is-one-of.ts'; +import { + MixedMetaError, + MultipleMetaError, + PreviewNotImportedError, +} from './error/parser/extract/svelte.ts'; export function isSvelteCsfImportSource(source: unknown): boolean { return typeof source === 'string' && isOneOf(SVELTE_CSF_IMPORT_SOURCES, source); } -interface DefineMetaImport { +export interface MetaImports { defineMetaImport?: ESTreeAST.ImportSpecifier; hasDefaultOrNamespaceImport: boolean; + // The local names of the imports from a preview file, such as `preview` + previewNames: string[]; } // Imports are only allowed at the top level, so there's no need to walk the whole AST -export function findDefineMetaImport(body: ESTreeAST.Program['body']): DefineMetaImport { - const result: DefineMetaImport = { hasDefaultOrNamespaceImport: false }; +export function findMetaImports(body: ESTreeAST.Program['body']): MetaImports { + const result: MetaImports = { hasDefaultOrNamespaceImport: false, previewNames: [] }; for (const statement of body) { - if ( - statement.type !== 'ImportDeclaration' || - !isSvelteCsfImportSource(statement.source.value) - ) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + + const { source, specifiers } = statement; + + if (typeof source.value === 'string' && isValidPreviewPath(source.value)) { + result.previewNames.push(...specifiers.map((specifier) => specifier.local.name)); continue; } - for (const specifier of statement.specifiers) { + if (!isSvelteCsfImportSource(source.value)) { + continue; + } + + for (const specifier of specifiers) { if (specifier.type !== 'ImportSpecifier') { result.hasDefaultOrNamespaceImport = true; } else if ('name' in specifier.imported && specifier.imported.name === 'defineMeta') { @@ -35,3 +52,124 @@ export function findDefineMetaImport(body: ESTreeAST.Program['body']): DefineMet return result; } + +export function hasMetaImport(imports: MetaImports): boolean { + return !!imports.defineMetaImport || imports.previewNames.length > 0; +} + +// `const { Story } = defineMeta({ … })`, `const { Story } = preview.meta({ … })`, or +// `const meta = preview.meta({ … })` followed by `const { Story } = meta` +export interface MetaNodes { + // `preview.meta()` creates a CSF factories meta + isFactory: boolean; + declaration: ESTreeAST.VariableDeclaration; + call: ESTreeAST.CallExpression; + // For error messages, such as `defineMeta` or `preview.meta` + functionName: string; + // `meta` in `const meta = preview.meta({ … })` + metaIdentifier?: ESTreeAST.Identifier; + // It can be renamed: `const { Story: S } = …` + storyIdentifier?: ESTreeAST.Identifier; +} + +export function findMeta( + body: ESTreeAST.Program['body'], + imports: MetaImports, + filename?: string +): MetaNodes | undefined { + let meta: MetaNodes | undefined; + + for (const statement of body) { + if (statement.type !== 'VariableDeclaration') { + continue; + } + + const { id, init } = statement.declarations[0]; + const functionName = + init?.type === 'CallExpression' ? getMetaFunctionName(init, imports, filename) : undefined; + + if (init?.type === 'CallExpression' && functionName) { + const isFactory = init.callee.type === 'MemberExpression'; + + if (meta && meta.isFactory !== isFactory) { + throw new MixedMetaError(filename); + } + + // Two defineMeta() calls are allowed: the last one wins + if (meta?.isFactory) { + throw new MultipleMetaError(filename); + } + + meta = { + isFactory, + declaration: statement, + call: init, + functionName, + metaIdentifier: isFactory && id.type === 'Identifier' ? id : undefined, + storyIdentifier: findStoryIdentifier(id), + }; + } else if ( + meta?.metaIdentifier && + init?.type === 'Identifier' && + init.name === meta.metaIdentifier.name + ) { + meta.storyIdentifier ??= findStoryIdentifier(id); + } + } + + return meta; +} + +// The name of the function when the call is `defineMeta()` or `preview.meta()` +function getMetaFunctionName( + call: ESTreeAST.CallExpression, + imports: MetaImports, + filename?: string +): string | undefined { + const { callee } = call; + + if (callee.type === 'Identifier') { + return callee.name === imports.defineMetaImport?.local.name ? callee.name : undefined; + } + + if ( + callee.type !== 'MemberExpression' || + callee.computed || + callee.object.type !== 'Identifier' || + callee.property.type !== 'Identifier' || + callee.property.name !== 'meta' + ) { + return undefined; + } + + if (imports.previewNames.includes(callee.object.name)) { + return `${callee.object.name}.meta`; + } + + // Like `CsfFile`, only a variable named `preview` shows that the file tries to use CSF factories. + // Other `.meta()` calls, such as Zod's, are not a meta. + if (callee.object.name === 'preview') { + throw new PreviewNotImportedError(filename); + } + + return undefined; +} + +function findStoryIdentifier(id: ESTreeAST.Pattern): ESTreeAST.Identifier | undefined { + if (id.type !== 'ObjectPattern') { + return undefined; + } + + for (const property of id.properties) { + if ( + property.type === 'Property' && + property.key.type === 'Identifier' && + property.key.name === 'Story' && + property.value.type === 'Identifier' + ) { + return property.value; + } + } + + return undefined; +} From 6dec302477e745c151fa537b3b5e99ab8f15da36 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Fri, 9 Oct 2026 09:39:30 +0200 Subject: [PATCH 014/326] test: the test cases for preview.meta() in Svelte CSF files The cases that show what the previous commits do, from source in to output out: - Compiler: the full compiled module for both meta shapes (`compiler/post-transform/index.test.ts`). - Vitest transform: core's `vitestTransform` on the compiled module, as in addon-vitest (`compiler/vitest-transform.test.ts`). - Indexer and parsers: index entries for each import shape, and the new errors. - Runtime: factory `.stories.svelte` files rendered with `Story.run()` and `composeStories` (`__test__/svelte-csf-factories/`). The renderer's Vitest config loads the `svelteCsf()` plugin for this. - Types: `type-checks.svelte`, which `svelte-check` checks, and `Args`. - Core: `CsfFile` reads factory stories behind export specifiers. Co-Authored-By: Claude Opus 5.5 (1M context) --- code/core/src/csf-tools/CsfFile.test.ts | 27 +++ .../button.stories.svelte | 34 +++ .../interactions.stories.svelte | 49 ++++ .../__test__/svelte-csf-factories/preview.ts | 3 + .../svelte-csf-factories.test.ts | 35 +++ .../templating.stories.svelte | 67 ++++++ .../svelte-csf-factories/type-checks.svelte | 101 +++++++++ .../renderers/svelte/src/public-types.test.ts | 15 ++ .../compiler/post-transform/index.test.ts | 209 ++++++++++++++++++ .../compiler/vitest-transform.test.ts | 96 ++++++++ .../src/svelte-csf/indexer/parser.test.ts | 100 +++++++++ .../parser/extract/compiled/nodes.test.ts | 34 ++- .../extract/svelte/module-nodes.test.ts | 186 +++++++++++++++- code/renderers/svelte/vitest.config.ts | 2 + 14 files changed, 944 insertions(+), 14 deletions(-) create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/button.stories.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/interactions.stories.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/preview.ts create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/svelte-csf-factories.test.ts create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/templating.stories.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/type-checks.svelte create mode 100644 code/renderers/svelte/src/svelte-csf/compiler/vitest-transform.test.ts diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index 00bb0fd35d4f..144af124b5a9 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -3093,6 +3093,33 @@ describe('CsfFile', () => { expect(Object.keys(parsed._stories)).toEqual(['A']); }); + it('reads factory stories exported with export specifiers', () => { + const parsed = loadCsf( + dedent` + import { config } from '#.storybook/preview' + const meta = config.meta({ component: 'foo' }); + const $__Button = meta.story({ tags: ['a'] }); + const $__Typed = meta.story({ tags: ['b'] }) satisfies ReturnType; + const someHelper = () => {}; + export { $__Button as Button, $__Typed as Typed, someHelper }; + export { Other } from './Other.stories'; + `, + { makeTitle } + ).parse(); + + expect( + parsed.stories.map(({ name, tags, __stats }) => ({ + name, + tags, + factory: __stats.factory, + })) + ).toEqual([ + { name: 'Button', tags: ['a'], factory: true }, + { name: 'Typed', tags: ['b'], factory: true }, + { name: 'Other', tags: undefined, factory: undefined }, + ]); + }); + it('excludeStories still works with factories', () => { const parsed = loadCsf( dedent` diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/button.stories.svelte b/code/renderers/svelte/src/__test__/svelte-csf-factories/button.stories.svelte new file mode 100644 index 000000000000..a4339f7f7be6 --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/button.stories.svelte @@ -0,0 +1,34 @@ + + + + + { + await expect(canvas.getByRole('button')).toHaveClass('primary', 'large'); + }} +> + Primary + + + + { + await expect(canvas.getByRole('button')).toHaveTextContent('local'); + }} +> + {local.meta} + diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/interactions.stories.svelte b/code/renderers/svelte/src/__test__/svelte-csf-factories/interactions.stories.svelte new file mode 100644 index 000000000000..8cb0af3659b5 --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/interactions.stories.svelte @@ -0,0 +1,49 @@ + + + + + { + const count = await canvas.findByTestId('count'); + + await userEvent.click(await canvas.findByText('Increment')); + await expect(count).toHaveTextContent('You clicked 1 times'); + }} +/> + + { + const p = canvas.getByTestId('count'); + await expect(p).toHaveTextContent('0'); + + i++; + await tick(); + await expect(p).toHaveTextContent('1'); + }} +> +

{i}

+
+ + { + await expect(canvas.getByTestId('name')).toHaveTextContent(name); + }} +> + {#snippet template(_args, context)} +

{context.name}

+ {/snippet} +
diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/preview.ts b/code/renderers/svelte/src/__test__/svelte-csf-factories/preview.ts new file mode 100644 index 000000000000..70c66dacd275 --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/preview.ts @@ -0,0 +1,3 @@ +import { __definePreview } from '../../preview.ts'; + +export default __definePreview({ addons: [] }); diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/svelte-csf-factories.test.ts b/code/renderers/svelte/src/__test__/svelte-csf-factories/svelte-csf-factories.test.ts new file mode 100644 index 000000000000..299c48aaccb2 --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/svelte-csf-factories.test.ts @@ -0,0 +1,35 @@ +import { cleanup } from '@testing-library/svelte'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { isStory } from 'storybook/internal/csf'; + +import { composeStories } from '../../portable-stories.ts'; +import * as ButtonStories from './button.stories.svelte'; +import * as InteractionsStories from './interactions.stories.svelte'; +import * as TemplatingStories from './templating.stories.svelte'; + +afterEach(() => { + cleanup(); +}); + +const files = { ButtonStories, InteractionsStories, TemplatingStories }; + +describe.each(Object.entries(files))('%s', (_name, file) => { + const stories = Object.entries(file).filter(([, story]) => isStory(story)); + + it('has no default export', () => { + expect(file).not.toHaveProperty('default'); + }); + + it.each(stories)('renders %s and passes its play function', async (_exportName, Story) => { + await Story.run(); + }); +}); + +it('composes the stories with composeStories', async () => { + // The types of composeStories need a default export, which a CSF factories file doesn't have. + const { Primary } = composeStories(ButtonStories as never) as Record; + + expect(Primary.args).toEqual({ size: 'large', primary: true }); + await Primary.run(); +}); diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/templating.stories.svelte b/code/renderers/svelte/src/__test__/svelte-csf-factories/templating.stories.svelte new file mode 100644 index 000000000000..8a19bc21019f --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/templating.stories.svelte @@ -0,0 +1,67 @@ + + +{#snippet defaultTemplate(args: { text: string })} +

Default template

+

{args?.text}

+{/snippet} + + { + await expect(await canvas.findByTestId('heading')).toHaveTextContent('Static template'); + }} +> +

Static template

+
+ + { + await expect(await canvas.findByTestId('heading')).toHaveTextContent('Template snippet'); + await expect(await canvas.findByText(args.text)).toBeInTheDocument(); + }} +> + {#snippet template(args)} +

Template snippet

+

{args?.text}

+ {/snippet} +
+ +{#snippet sharedTemplate(args: { text: string })} +

Shared template

+

{args?.text}

+{/snippet} + + { + await expect(await canvas.findByTestId('heading')).toHaveTextContent('Shared template'); + await expect(await canvas.findByText(args.text)).toBeInTheDocument(); + }} +/> + + { + await expect(await canvas.findByTestId('heading')).toHaveTextContent('Default template'); + await expect(await canvas.findByText(args.text)).toBeInTheDocument(); + }} +/> diff --git a/code/renderers/svelte/src/__test__/svelte-csf-factories/type-checks.svelte b/code/renderers/svelte/src/__test__/svelte-csf-factories/type-checks.svelte new file mode 100644 index 000000000000..d71528e37960 --- /dev/null +++ b/code/renderers/svelte/src/__test__/svelte-csf-factories/type-checks.svelte @@ -0,0 +1,101 @@ + + +{#snippet textTemplate(args: { text: string })} +

{args.text}

+{/snippet} + + + +{/* @ts-expect-error label is missing */ null} + + + + + +

Static content

+
+ + + {#snippet template()} +

Static content

+ {/snippet} +
+ + + {#snippet template(args, context)} + {expectTypeOf(args.label).toEqualTypeOf()} + {expectTypeOf(context.args.disabled).toEqualTypeOf()} + ; + }, + args: { foo: 'bar' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const AsyncRender = async () => { + const data = await load(); + return ; + };" + `); +}); + +test('template literal inside a render body keeps its lines', async () => { + const input = withCSF3( + `export const Template = { + render: (args) => { + const code = \`line1 +line2\`; + return ; + }, + args: { foo: 'bar' } + };` + ); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const Template = () => { + const code = \`line1 + line2\`; + return ; + };" + `); +}); + test('ObjectFalsyArgs only', async () => { const input = withCSF3( `export const ObjectFalsyArgs = { diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 68c15e6847bc..208326d75c7e 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -14,6 +14,7 @@ import { metaObject, normalizeStoryDeclaration, parseModule, + reindent, resolveRenderFunction, storyShapeError, walk, @@ -184,7 +185,7 @@ function functionSnippet( if (fn.type === 'ArrowFunctionExpression' && isJsx(fn.body)) { if (rewriteJsx(code, fn.body, merged, edits)) { const body = printRange(code, fn.body, edits); - const kept = readsArgs(`(${body});`) ? params : ''; + const kept = readsArgs(`${asyncPrefix}() => (${body});`) ? params : ''; return `const ${storyName} = ${asyncPrefix}(${kept}) => ${body};`; } } else if (fn.body?.type === 'BlockStatement') { @@ -196,7 +197,8 @@ function functionSnippet( } if (changed) { const body = printRange(code, fn.body, edits); - const kept = readsArgs(`function f() ${body}`) ? params : ''; + const head = `${asyncPrefix}function${fn.generator ? '*' : ''}`; + const kept = readsArgs(`${head} f() ${body}`) ? params : ''; return fn.type === 'FunctionDeclaration' ? `${asyncPrefix}function${fn.generator ? '*' : ''} ${storyName}(${kept}) ${body}` : `const ${storyName} = ${asyncPrefix}(${kept}) => ${body};`; @@ -227,14 +229,7 @@ function printRange(code: string, range: { start: number; end: number }, edits: const lineStart = code.lastIndexOf('\n', range.start - 1) + 1; const indent = /^[ \t]*/.exec(code.slice(lineStart))?.[0] ?? ''; - return indent - ? text - .split('\n') - .map((line, index) => - index > 0 && line.startsWith(indent) ? line.slice(indent.length) : line - ) - .join('\n') - : text; + return reindent(text, indent, ''); } // Whether code still reads the `args` parameter, so the snippet has to keep declaring it. From 33392441389f9e385d20437a1ebf76acd09772ab Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 10:15:27 +0900 Subject: [PATCH 066/326] fix(web-components): keep listener handlers as written in story snippets Co-Authored-By: Claude Fable 5.1 --- .../story-docs/build-story-docs.test.ts | 28 +++++++++++++++++++ .../src/docgen/story-docs/build-story-docs.ts | 14 ++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/code/renderers/web-components/src/docgen/story-docs/build-story-docs.test.ts b/code/renderers/web-components/src/docgen/story-docs/build-story-docs.test.ts index 991333980f76..99bc0c17de57 100644 --- a/code/renderers/web-components/src/docgen/story-docs/build-story-docs.test.ts +++ b/code/renderers/web-components/src/docgen/story-docs/build-story-docs.test.ts @@ -329,6 +329,34 @@ describe('buildStoryDocsPayload', () => { const host = document.currentScript.previousElementSibling; host.addEventListener('shape-change', (event) => event.preventDefault()); } +`, + }, + }, + { + name: 'keeps the lines and comments of a multi-line listener', + source: ` + export default { title: 'Example/TestElement', component: 'test-element' }; + export const Primary = { + args: { + onShapeChange: (e) => { + // log the event + console.log(e); + }, + }, + }; + `, + expected: { + id: 'example-testelement--primary', + name: 'Primary', + snippet: ` +`, }, }, diff --git a/code/renderers/web-components/src/docgen/story-docs/build-story-docs.ts b/code/renderers/web-components/src/docgen/story-docs/build-story-docs.ts index 1a28992b3f76..19927dc82e3e 100644 --- a/code/renderers/web-components/src/docgen/story-docs/build-story-docs.ts +++ b/code/renderers/web-components/src/docgen/story-docs/build-story-docs.ts @@ -8,10 +8,12 @@ import type { } from 'storybook/internal/csf-tools'; import { authoredSource, + codeOf, createStoryArgsResolver, createStoryReferenceResolver, extractStoryJSDocInfo, noSnippetWarning, + reindent, resolveEffectiveRender, sourceOf, unwrapExpression, @@ -434,8 +436,16 @@ const hasSnippetContent = (snippet: ElementSnippet): boolean => snippet.slots.length > 0 || snippet.styleRules.length > 0; -const listenerHandler = (node: Node): string => - isFunctionNode(unwrapExpression(node)) ? sourceOf(node) : '() => {}'; +// The handler runs inside the snippet's ` + + " + `); + }); + it('bails when a bound arg is missing from the story args altogether', async () => { const payload = await buildPayload(` export const Primary = { diff --git a/code/renderers/vue3/src/docgen/story-docs/transform-template/transform-template.ts b/code/renderers/vue3/src/docgen/story-docs/transform-template/transform-template.ts index 63eb60179cf8..ca2c89f8ffca 100644 --- a/code/renderers/vue3/src/docgen/story-docs/transform-template/transform-template.ts +++ b/code/renderers/vue3/src/docgen/story-docs/transform-template/transform-template.ts @@ -768,11 +768,12 @@ function parseTemplateExpression(code: string): E.Expression | undefined { return undefined; } const [statement, ...rest] = program.body; - // Starting anywhere but right after the opening paren means `code` closed it, as in `a) + (b`. + // An expression that closed the wrapping paren, as in `a) + (b`, starts at the paren itself. + // Whitespace and outer parentheses (which the parser drops) make a valid expression start later. if ( rest.length > 0 || statement?.type !== 'ExpressionStatement' || - (statement.expression as E.Expression & { start: number }).start !== 1 + (statement.expression as E.Expression & { start: number }).start === 0 ) { return undefined; } From 4b2f24bc217d7d520f322ef591e70cdb4ef0bfee Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 10:25:23 +0900 Subject: [PATCH 068/326] fix(angular-vite): strip comments from template bindings and keep template literal lines Co-Authored-By: Claude Fable 5.1 --- .../src/docgen/story-docs-args.test.ts | 30 ++++++++++ .../src/docgen/story-docs-args.ts | 60 +++++++++++++------ 2 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 code/frameworks/angular-vite/src/docgen/story-docs-args.test.ts diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-args.test.ts b/code/frameworks/angular-vite/src/docgen/story-docs-args.test.ts new file mode 100644 index 000000000000..ee61a7736da2 --- /dev/null +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { expressionFromSource } from 'storybook/internal/csf-tools'; + +import { argFieldValue, evaluateArgExpression } from './story-docs-args.ts'; + +describe('evaluateArgExpression', () => { + it('strips comments from an unevaluable arg, which Angular templates cannot parse', () => { + expect(evaluateArgExpression(expressionFromSource(`helper(/* note */ 'x')`), [])).toBe( + `helper('x')` + ); + expect( + evaluateArgExpression( + expressionFromSource(`helper(\n 'x', // trailing\n 2,\n )`), + [] + ) + ).toBe(`helper(\n 'x',\n 2,\n)`); + }); +}); + +describe('argFieldValue', () => { + it('keeps comments and dedents continuation lines, but not template literal values', () => { + expect(argFieldValue(expressionFromSource(`(value) => /* why */ value.trim()`))).toBe( + `(value) => /* why */ value.trim()` + ); + expect(argFieldValue(expressionFromSource('helper({\n code: `a\n b`,\n })'))).toBe( + 'helper({\n code: `a\n b`,\n})' + ); + }); +}); diff --git a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts index 9dc66be6286f..3084e8c16da4 100644 --- a/code/frameworks/angular-vite/src/docgen/story-docs-args.ts +++ b/code/frameworks/angular-vite/src/docgen/story-docs-args.ts @@ -5,6 +5,8 @@ import { codeOf, expressionFromSource, keyOf, + parseModule, + reindent, unwrapExpression, } from 'storybook/internal/csf-tools'; @@ -28,14 +30,6 @@ export const createArgExternalizer = : expressionFromSource(printExpressionValue(value, new Set())); }; -// An arg no static evaluation could reduce to a value falls back to its source text. Every -// expression is escaped for the attribute position it lands in: the double-quote delimiter and -// text Angular's lexer would decode as a character reference survive the round-trip unchanged. -export const evaluateArgExpression = (node: Node, enums: SnippetEnum[]): string => { - const literal = evaluateArgLiteral(node, enums); - return escapeAttributeExpression(literal ?? printArgSource(unwrapExpression(node))); -}; - /** * The arg's value as a standalone expression, or `undefined` when it needs the story to run. * @@ -48,18 +42,48 @@ export const evaluateArgLiteral = (node: Node, enums: SnippetEnum[]): string | u return value === EVAL_FAILED ? undefined : printExpressionValue(value, new Set()); }; -export const argFieldValue = (node: Node): string => printArgSource(unwrapExpression(node)); +export const argFieldValue = (node: Node): string => printArgSource(codeOf(unwrapExpression(node))); -// Continuation lines keep the story file's indentation, which the snippet replaces with its own. -const printArgSource = (node: Node): string => { - const [first, ...rest] = codeOf(node).split(/\r?\n/); - const indent = Math.min( - ...rest.filter((line) => line.trim()).map((line) => /^[ \t]*/.exec(line)![0].length) - ); - const dedented = rest.map((line) => - line.slice(Math.min(indent, line.length)).replace(/^\t+/, (tabs) => ' '.repeat(tabs.length)) +// An arg no static evaluation could reduce to a value falls back to its source text, without the +// comments a template expression cannot take (a class field keeps them). Every expression is +// escaped for the attribute position it lands in: the double-quote delimiter and text Angular's +// lexer would decode as a character reference survive the round-trip unchanged. +export const evaluateArgExpression = (node: Node, enums: SnippetEnum[]): string => { + const literal = evaluateArgLiteral(node, enums); + return escapeAttributeExpression( + literal ?? printArgSource(withoutComments(codeOf(unwrapExpression(node)))) ); - return [first, ...dedented].join('\n'); +}; + +// Continuation lines keep the story file's indentation, which the snippet replaces with its own. +// Lines inside template literals are values and keep theirs. +const printArgSource = (source: string): string => { + const code = source.replace(/\r\n/g, '\n'); + const lines = code.split('\n').slice(1); + const last = lines.at(-1); + const indents = lines.filter((line) => line.trim()).map((line) => /^[ \t]*/.exec(line)![0]); + // A closing bracket sits at the expression's own indentation; otherwise the shallowest line does. + const indent = + last && /^[ \t]*[)\]}]/.test(last) + ? /^[ \t]*/.exec(last)![0] + : indents.reduce( + (shortest, current) => (current.length < shortest.length ? current : shortest), + indents[0] ?? '' + ); + return reindent(code, indent, '').replace(/^\t+/gm, (tabs) => ' '.repeat(tabs.length)); +}; + +const withoutComments = (code: string): string => { + const wrapped = `(${code}\n)`; + const { comments } = parseModule(wrapped); + let result = ''; + let cursor = 0; + for (const comment of comments) { + result += wrapped.slice(cursor, comment.start); + cursor = comment.end + /^[ \t]*/.exec(wrapped.slice(comment.end))![0].length; + } + result += wrapped.slice(cursor); + return result.slice(1, -2).replace(/[ \t]+\n/g, '\n'); }; // Angular expression strings support backslash escapes, so quoting stays lossless. From 9648cae18be5fa5dd50a9e6d6572f38e5b80a5eb Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 10:28:43 +0900 Subject: [PATCH 069/326] fix(addon-vitest): insert template statements on their own lines and skip imported names Co-Authored-By: Claude Fable 5.1 --- .../src/updateVitestFile.statements.test.ts | 47 +++++++++++++++++++ code/addons/vitest/src/updateVitestFile.ts | 13 ++++- 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 code/addons/vitest/src/updateVitestFile.statements.test.ts diff --git a/code/addons/vitest/src/updateVitestFile.statements.test.ts b/code/addons/vitest/src/updateVitestFile.statements.test.ts new file mode 100644 index 000000000000..cea521cc64f8 --- /dev/null +++ b/code/addons/vitest/src/updateVitestFile.statements.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { SourceEditor } from 'storybook/internal/csf-tools'; + +import { dedent } from 'ts-dedent'; + +import { updateConfigFile } from './updateVitestFile.ts'; + +const template = dedent` + import { fileURLToPath } from 'node:url'; + import { dirname } from 'node:path'; + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + const dirname = fileURLToPath(new URL('.', import.meta.url)); + + export default defineConfig({ + test: { + projects: [{ extends: true, test: { name: 'storybook' } }], + }, + }); +`; + +describe('updateConfigFile statements', () => { + it('inserts the template statements on their own lines after the last import', () => { + const target = new SourceEditor( + `import react from '@vitejs/plugin-react'; const x = 1\nexport default defineConfig({ test: {} })\n` + ); + + expect(updateConfigFile(template, target)).toBe(true); + + const code = target.toString(); + expect(code).toMatch(/^import react from '@vitejs\/plugin-react'; const x = 1$/m); + expect(code).toMatch(/^\/\/ More info at: .*\nconst dirname = /m); + expect(() => new SourceEditor(code)).not.toThrow(); + }); + + it('does not declare a name the config already imports', () => { + const target = new SourceEditor(dedent` + import { dirname } from 'node:path'; + import { fileURLToPath } from 'node:url'; + export default defineConfig({ test: {} }); + `); + + expect(updateConfigFile(template, target)).toBe(true); + expect(target.toString()).not.toContain('const dirname'); + expect(() => new SourceEditor(target.toString())).not.toThrow(); + }); +}); diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 3c71e38a1b47..1e327f1957de 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -11,6 +11,7 @@ import { appendToList, arrayList, itemText, + reindent, objectList, prependToList, } from 'storybook/internal/csf-tools'; @@ -68,7 +69,7 @@ const elementTexts = (editor: SourceEditor, array: E.ArrayExpression) => itemText(editor, arrayList(editor, array), element) ); -const indent = (text: string, prefix: string) => prefix + text.replaceAll('\n', `\n${prefix}`); +const indent = (text: string, prefix: string) => prefix + reindent(text, '', prefix); const lineIndentAt = (code: string, index: number) => /^[ \t]*/.exec(code.slice(code.lastIndexOf('\n', index - 1) + 1))![0]; @@ -307,7 +308,9 @@ const missingStatements = (source: SourceEditor, target: E.Program) => { node.type === 'ImportDeclaration' && node.specifiers.some((specifier) => specifier.local.name === name) ); + // Any top-level binding counts, so a name the config imports is not declared a second time. const declaresVariable = (name: string) => + declaresImport(name) || target.body.some( (node) => node.type === 'VariableDeclaration' && @@ -347,7 +350,13 @@ export const updateConfigFile = (sourceCode: string, target: SourceEditor): bool if (statements.length > 0) { const lastImport = target.program.body.findLast((node) => node.type === 'ImportDeclaration'); if (lastImport) { - target.edits.appendLeft(lastImport.end, `\n${statements.join('\n')}`); + // On the line after the import, so code and comments sharing its line stay where they are. + const lineEnd = target.code.indexOf('\n', lastImport.end); + if (lineEnd === -1) { + target.edits.append(`\n${statements.join('\n')}`); + } else { + target.edits.appendLeft(lineEnd + 1, `${statements.join('\n')}\n`); + } } else { target.edits.appendLeft(target.program.body[0].start, `${statements.join('\n')}\n\n`); } From fa393abafcb8b3e877268fe4c560b5c989ab0757 Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 10:31:23 +0900 Subject: [PATCH 070/326] fix(csf-tools): hoist parenthesized default exports without dropping their parentheses Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/CsfFile.ts | 11 +++++++++-- code/core/src/csf-tools/estree/editor.test.ts | 12 ++++++++++++ code/core/src/csf-tools/estree/editor.ts | 4 ++++ .../vitest-plugin/component-transformer.test.ts | 7 +++++++ .../csf-tools/vitest-plugin/component-transformer.ts | 5 +++-- 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 5624e051cac0..caadc519943a 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -33,7 +33,12 @@ import { locationOf, unwrapExpression, } from './estree/ast.ts'; -import { type InputSourceMap, SourceEditor, appendStatement } from './estree/editor.ts'; +import { + type InputSourceMap, + SourceEditor, + appendStatement, + exportDefaultKeywords, +} from './estree/editor.ts'; import { generateUid } from './estree/scope.ts'; import { findVarInitialization } from './findVarInitialization.ts'; @@ -607,7 +612,9 @@ export class CsfFile { const metaName = generateUid(this._editor.scopes, 'meta'); this._metaVariableName = metaName; const declaration = node.declaration as Node & { start: number; end: number }; - this._editor.edits.overwrite(node.start, declaration.start, `const ${metaName} = `); + // Only the keywords: the declaration's span may sit inside parentheses the parser dropped. + const keywords = exportDefaultKeywords(this._editor.code, node.start); + this._editor.edits.overwrite(node.start, node.start + keywords, `const ${metaName} = `); this._editor.edits.appendLeft(node.end, `\nexport default ${metaName};`); this._metaStatement = node; decl = declaration; diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index db2d1a8bf32b..9900ee5b6311 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -115,6 +115,18 @@ describe('removeStatement', () => { }); }); +describe('parenthesized default exports', () => { + it('hoists an inline meta without breaking its parentheses', () => { + const csf = loadCsf(`export default ({ title: 'X' } satisfies Meta);\nexport const A = {};`, { + transformInlineMeta: true, + makeTitle: (title) => title ?? 'title', + }).parse(); + expect(printCsf(csf).code).toBe( + `const _meta = ({ title: 'X' } satisfies Meta);\nexport default _meta;\nexport const A = {};` + ); + }); +}); + describe('line endings', () => { it('writes inserted text with the line ending of the file', () => { const csf = parse('export default {\r\n title: "A",\r\n};\r\n'); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 6c1dbcc7c1f2..891b3a7cce8d 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -711,6 +711,10 @@ const methodFromFunction = (text: string, key: string) => { : undefined; }; +// Length of `export default` and the whitespace after it at `start`. +export const exportDefaultKeywords = (code: string, start: number) => + /^export\s+default\s*/.exec(code.slice(start))![0].length; + export const isSpan = (value: unknown): value is Span => isNode(value) && typeof (value as unknown as Span).start === 'number'; diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts index d7468cfdd1e6..6e20a8891e5a 100644 --- a/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.test.ts @@ -114,6 +114,13 @@ describe('component transformer', () => { expect(result.code).toContain('const _Spinner = someWrapper(Component);'); expect(result.code).toContain('export default _Spinner;'); + + const parenthesized = await transform({ + code: `import { memo } from 'react';\nfunction Inner() {}\nexport default (memo(Inner));`, + fileName: 'src/components/Button.tsx', + }); + expect(parenthesized.code).toContain('const _Button = (memo(Inner));'); + expect(result.code).toContain('export default _Spinner;'); expect(result.code).toContain('_test("Spinner", _testStory({'); expect(result.code).toMatchInlineSnapshot(` diff --git a/code/core/src/csf-tools/vitest-plugin/component-transformer.ts b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts index 290375e310d6..36f7707a335e 100644 --- a/code/core/src/csf-tools/vitest-plugin/component-transformer.ts +++ b/code/core/src/csf-tools/vitest-plugin/component-transformer.ts @@ -13,6 +13,7 @@ import { prependStatement, printKey, printValue, + exportDefaultKeywords, } from '../estree/editor.ts'; import { generateUid } from '../estree/scope.ts'; import { GeneratedTail, createTestGuardDeclaration, printWithTail } from './transformer.ts'; @@ -163,8 +164,8 @@ const collectComponentExports = (editor: SourceEditor, fileName: string) => { ) { const identifierName = createComponentNameFromFileName(fileName); const identifier = generateUid(scopes, identifierName); - const span = declaration as Node & { start: number }; - editor.edits.overwrite(node.start, span.start, `const ${identifier} = `); + const keywords = exportDefaultKeywords(editor.code, node.start); + editor.edits.overwrite(node.start, node.start + keywords, `const ${identifier} = `); editor.edits.appendLeft(node.end, `\nexport default ${identifier};`); if (declaration.type === 'CallExpression') { From 55518486cd9cd6a79e254ad0e8366279ab6c28fa Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 10:33:12 +0900 Subject: [PATCH 071/326] fix(cli): keep parentheses when the CSF factory codemod drops a type cast Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/index.ts | 1 + .../helpers/story-to-csf-factory.test.ts | 25 +++++++++++++++++++ .../codemod/helpers/story-to-csf-factory.ts | 13 ++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index c0c926f40127..928b7c806c4e 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -46,6 +46,7 @@ export { printValue, reindent, removeFromList, + withParentheses, removeMembers, removeStatement, } from './estree/editor.ts'; diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index d6772773ff4f..2aa7de49e83d 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -923,6 +923,31 @@ describe('stories codemod', () => { `); }); + it('migrates parenthesized expressions before satisfies and as', async () => { + await expect( + transform(dedent` + import type { StoryFn, StoryObj } from '@storybook/react'; + import { Button } from './Button'; + + export default { component: Button }; + + export const P = ((args) => \n
\n \n \n `,\n };\n};\n\nconst localeDecorator: DecoratorFunction = (story, { globals }) => {\n return {\n components: { story },\n setup() {\n const ctxGreeting = computed(() => getCaptionForLocale(globals?.locale) || 'Hello!');\n\n return {\n ctxGreeting,\n globals,\n };\n },\n template: `\n
\n

Greeting: {{ctxGreeting}}

\n

Locale: {{globals?.locale}}

\n \n
\n `,\n };\n};\n\nexport const ComponentTemplate: Story = {\n args: { label: 'With component' },\n decorators: [ComponentTemplateWrapper],\n};\n\nexport const SimpleTemplate: Story = {\n args: { label: 'With border' },\n decorators: [SimpleTemplateWrapper],\n};\n\nexport const VueWrapper: Story = {\n args: { label: 'With Vue wrapper' },\n decorators: [VueWrapperWrapper],\n};\n\nexport const DynamicWrapper: Story = {\n args: { label: 'With dynamic wrapper', primary: true },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [DynamicWrapperWrapper],\n};\n\nexport const MultipleWrappers = {\n args: { label: 'With multiple wrappers' },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [\n ComponentTemplateWrapper,\n SimpleTemplateWrapper,\n VueWrapperWrapper,\n DynamicWrapperWrapper,\n ],\n};\n\nexport const UpdateArgs = {\n args: { label: '0' },\n decorators: [updateArgsDecorator],\n};\n\nexport const ReactiveGlobalDecorator = {\n args: { label: 'With reactive global decorator' },\n decorators: [localeDecorator],\n};\n", + "message": "No component file found for the component declared in meta.component.\n(12:0)\n> 12 | const meta = {\n | ^\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/decorators.stories.ts:\nimport type { DecoratorFunction } from 'storybook/internal/types';\n\nimport { global as globalThis } from '@storybook/global';\nimport type { Meta, StoryObj, VueRenderer } from '@storybook/vue3';\n\nimport { useArgs } from 'storybook/preview-api';\nimport { h } from 'vue';\nimport { computed } from 'vue';\n\nconst { Button, Pre } = (globalThis as any).__TEMPLATE_COMPONENTS__;\n\nconst meta = {\n component: Button,\n} satisfies Meta;\n\nexport default meta;\n\ntype Story = StoryObj;\n\nconst ComponentTemplateWrapper = () => ({\n components: {\n Pre,\n },\n template: `\n
\n    \n  `,\n});\n\nconst SimpleTemplateWrapper = () => ({\n  template: `\n    
\n \n
\n `,\n});\n\nconst VueWrapperWrapper: DecoratorFunction = (storyFn, context) => {\n // Call the `storyFn` to receive a component that Vue can render\n const story = storyFn();\n // Vue 3 \"Functional\" component as decorator\n return () => {\n return h('div', { style: 'border: 5px solid blue' }, h(story, context.args));\n };\n};\n\nconst DynamicWrapperWrapper: DecoratorFunction = (storyFn, { args }) => ({\n template: `
`,\n computed: { level: () => `${args.level}px` },\n});\n\nconst getCaptionForLocale = (locale: string) => {\n switch (locale) {\n case 'es':\n return 'Hola!';\n case 'kr':\n return '안녕하세요!';\n case 'zh':\n return '你好!';\n case 'en':\n return 'Hello!';\n default:\n return undefined;\n }\n};\n\nconst updateArgsDecorator: DecoratorFunction = (story, { args }) => {\n const [, updateArgs] = useArgs();\n return {\n components: { story },\n setup() {\n return {\n args,\n updateArgs,\n };\n },\n template: `\n
\n \n
\n \n
\n `,\n };\n};\n\nconst localeDecorator: DecoratorFunction = (story, { globals }) => {\n return {\n components: { story },\n setup() {\n const ctxGreeting = computed(() => getCaptionForLocale(globals?.locale) || 'Hello!');\n\n return {\n ctxGreeting,\n globals,\n };\n },\n template: `\n
\n

Greeting: {{ctxGreeting}}

\n

Locale: {{globals?.locale}}

\n \n
\n `,\n };\n};\n\nexport const ComponentTemplate: Story = {\n args: { label: 'With component' },\n decorators: [ComponentTemplateWrapper],\n};\n\nexport const SimpleTemplate: Story = {\n args: { label: 'With border' },\n decorators: [SimpleTemplateWrapper],\n};\n\nexport const VueWrapper: Story = {\n args: { label: 'With Vue wrapper' },\n decorators: [VueWrapperWrapper],\n};\n\nexport const DynamicWrapper: Story = {\n args: { label: 'With dynamic wrapper', primary: true },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [DynamicWrapperWrapper],\n};\n\nexport const MultipleWrappers = {\n args: { label: 'With multiple wrappers' },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [\n ComponentTemplateWrapper,\n SimpleTemplateWrapper,\n VueWrapperWrapper,\n DynamicWrapperWrapper,\n ],\n};\n\nexport const UpdateArgs = {\n args: { label: '0' },\n decorators: [updateArgsDecorator],\n};\n\nexport const ReactiveGlobalDecorator = {\n args: { label: 'With reactive global decorator' },\n decorators: [localeDecorator],\n};\n", "name": "No component import found" }, "id": "stories-renderers-vue3-vue3-vite-default-ts-decorators", diff --git a/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json b/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json index 92c8ee94306f..8b124278c677 100644 --- a/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json +++ b/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json @@ -1,6 +1,6 @@ { "error": { - "message": "No component file found for the component declared in meta.component. (10:0)\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/mount-in-play.stories.ts:\nimport type { StoryObj } from '@storybook/vue3';\n\nimport { defineComponent } from 'vue';\n\nconst Button = defineComponent({\n template: '',\n props: ['disabled', 'label'],\n});\n\nexport default {\n component: Button,\n};\n\nexport const Basic: StoryObj = {\n args: {\n disabled: true,\n },\n async play({ mount, args }) {\n await mount(Button, { props: { ...args, label: 'set in play' } });\n },\n};\n", + "message": "No component file found for the component declared in meta.component.\n(10:0)\n> 10 | export default {\n | ^\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/mount-in-play.stories.ts:\nimport type { StoryObj } from '@storybook/vue3';\n\nimport { defineComponent } from 'vue';\n\nconst Button = defineComponent({\n template: '',\n props: ['disabled', 'label'],\n});\n\nexport default {\n component: Button,\n};\n\nexport const Basic: StoryObj = {\n args: {\n disabled: true,\n },\n async play({ mount, args }) {\n await mount(Button, { props: { ...args, label: 'set in play' } });\n },\n};\n", "name": "No component import found" }, "id": "stories-renderers-vue3-vue3-vite-default-ts-mount-in-play", diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index 22eb5554343d..6906abe9a2d5 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -105,7 +105,12 @@ test('Edge case identifier we can not find', () => { export const Default = someImportOrWhatever; `); expect(() => generateExample(input)).toThrowErrorMatchingInlineSnapshot( - `[SyntaxError: Expected story to be csf factory, function or an object expression (13:27)]` + ` + [SyntaxError: Expected story to be csf factory, function or an object expression + (13:27) + > 13 | export const Default = someImportOrWhatever; + | ^] + ` ); }); diff --git a/code/renderers/react/src/componentManifest/generator.test.ts b/code/renderers/react/src/componentManifest/generator.test.ts index 8e3d02d6d85d..67b4a1502021 100644 --- a/code/renderers/react/src/componentManifest/generator.test.ts +++ b/code/renderers/react/src/componentManifest/generator.test.ts @@ -491,7 +491,10 @@ test('component exported from other file', async () => { "stories": [ { "error": { - "message": "Expected story to be a function or variable declaration (11:0)", + "message": "Expected story to be a function or variable declaration + (11:0) + > 11 | export { Primary } from './other-file'; + | ^", "name": "SyntaxError", }, "id": "example-button--primary", @@ -542,7 +545,10 @@ test('unknown expressions', async () => { "stories": [ { "error": { - "message": "Expected story to be csf factory, function or an object expression (11:23)", + "message": "Expected story to be csf factory, function or an object expression + (11:23) + > 11 | export const Primary = someWeirdExpression; + | ^", "name": "SyntaxError", }, "id": "example-button--primary", From c618c1af25e4110a801283e778eccd8365419ed0 Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 11:11:06 +0900 Subject: [PATCH 078/326] refactor(cli): reuse the core editor for statement removal and import rewrites Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/editor.test.ts | 2 +- code/core/src/csf-tools/estree/editor.ts | 96 +++++++++++++++++-- code/core/src/csf-tools/index.ts | 4 +- .../fixes/vitest-setup-file.test.ts | 3 +- .../automigrate/fixes/vitest-setup-file.ts | 3 +- .../web-components-runtime-manifest.test.ts | 6 +- .../fixes/web-components-runtime-manifest.ts | 3 +- .../src/automigrate/helpers/preview-call.ts | 4 +- .../src/automigrate/helpers/source-edits.ts | 55 ----------- .../helpers/config-to-csf-factory.test.ts | 1 + .../codemod/helpers/config-to-csf-factory.ts | 7 +- .../codemod/helpers/csf-factories-utils.ts | 15 +-- .../codemod/helpers/remove-unused-types.ts | 2 +- .../helpers/story-to-csf-factory.test.ts | 1 + .../codemod/helpers/story-to-csf-factory.ts | 7 +- .../src/codemod/helpers/wrap-args-mocks.ts | 3 +- .../upgrade-deprecated-types.test.ts.snap | 5 +- .../__tests__/find-implicit-spies.test.ts | 10 +- 18 files changed, 130 insertions(+), 97 deletions(-) delete mode 100644 code/lib/cli-storybook/src/automigrate/helpers/source-edits.ts diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index 682445b17538..a078a4e15515 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -113,7 +113,7 @@ describe('removeStatement', () => { `); removeStatement(editor, editor.program.body[1]); removeStatement(editor, editor.program.body[2]); - expect(editor.toString()).toBe(`import a from 'a';\n\n`); + expect(editor.toString()).toBe(`import a from 'a';\n`); }); it('keeps the file header when the first statement is removed', () => { diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index eaf9e0f1df0b..bf840e19ed0f 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -627,19 +627,99 @@ export const leadingComments = (editor: SourceEditor, statement: Span): E.Commen ); }; -/** Remove a statement with its leading comments, and its line when nothing else shares it. */ -export const removeStatement = (editor: SourceEditor, statement: Span) => { +/** + * Remove a statement with the comments that lead it and the comment that trails it on its line, + * and the line itself when nothing else shares it. + */ +export const removeStatement = (editor: SourceEditor, statement: Span, atTop?: boolean) => { const { code } = editor; const first = leadingComments(editor, statement)[0]; - const lineStart = code.lastIndexOf('\n', (first ?? statement).start - 1) + 1; + const start = (first ?? statement).start; + const lineStart = code.lastIndexOf('\n', start - 1) + 1; const lineEnd = code.indexOf('\n', statement.end); - const before = code.slice(lineStart, (first ?? statement).start); - const after = code.slice(statement.end, lineEnd === -1 ? code.length : lineEnd); - if (!before.trim() && !after.trim()) { - editor.edits.remove(lineStart, lineEnd === -1 ? code.length : lineEnd + 1); + const trailing = editor.comments.find( + (comment) => + comment.start >= statement.end && + (lineEnd === -1 || comment.start < lineEnd) && + !code.slice(statement.end, comment.start).trim() + ); + const end = + trailing && (lineEnd === -1 || trailing.end <= lineEnd) ? trailing.end : statement.end; + const before = code.slice(lineStart, start); + const after = code.slice(end, lineEnd === -1 ? code.length : lineEnd); + if (before.trim() || after.trim()) { + editor.edits.remove(start, end); + return; + } + // The statement takes the blank lines above it, so none are left behind; at the top of the + // file it takes the ones below instead. + const top = atTop ?? lineStart === 0; + let from = lineStart; + let to = lineEnd === -1 ? code.length : lineEnd + 1; + if (top) { + to += /^\s*/.exec(code.slice(to))![0].length; } else { - editor.edits.remove((first ?? statement).start, statement.end); + while (from > 0 && /^[ \t]*\n$/.test(code.slice(code.lastIndexOf('\n', from - 2) + 1, from))) { + from = code.lastIndexOf('\n', from - 2) + 1; + } } + editor.edits.remove(from, to); +}; + +/** Remove top-level statements, each with a `;` that directly follows it. */ +export const removeStatements = (editor: SourceEditor, statements: ReadonlySet) => { + const { body } = editor.program; + let keptBefore = false; + body.forEach((statement, index) => { + const next = body[index + 1]; + if (!statements.has(statement)) { + keptBefore = keptBefore || statement.type !== 'EmptyStatement'; + return; + } + const empty = next?.type === 'EmptyStatement' && next.start === statement.end; + const span = empty ? { start: statement.start, end: next.end } : statement; + // Nothing kept above: the statement takes the whitespace below it instead. + removeStatement(editor, span, !keptBefore); + }); +}; + +/** + * Rewrite an import's specifiers to `kept` plus `added`, keeping each kept specifier as written, + * comments included. + */ +export const setImportSpecifiers = ( + editor: SourceEditor, + declaration: E.ImportDeclaration, + kept: readonly E.ImportDeclaration['specifiers'][number][], + added: { default?: string; named?: string[] } = {} +) => { + const { code } = editor; + const { specifiers } = declaration; + const named = specifiers.filter((specifier) => specifier.type === 'ImportSpecifier'); + const list: List | undefined = named.length + ? { + open: code.lastIndexOf('{', named[0].start) + 1, + close: code.indexOf('}', named.at(-1)!.end), + items: named, + } + : undefined; + const namedTexts = [ + ...named.filter((specifier) => kept.includes(specifier)).map((s) => itemText(editor, list!, s)), + ...(added.named ?? []), + ]; + const parts = [ + ...(added.default ? [added.default] : []), + ...specifiers + .filter((specifier) => specifier.type !== 'ImportSpecifier' && kept.includes(specifier)) + .map((specifier) => editor.source(specifier)), + ...(namedTexts.length > 0 ? [`{ ${namedTexts.join(', ')} }`] : []), + ]; + const kind = declaration.importKind === 'type' ? 'import type' : 'import'; + editor.edits.overwrite( + declaration.start, + declaration.source.start, + `${kind} ${parts.join(', ')} from ` + ); }; export const replaceValue = (editor: SourceEditor, member: Property, text: string) => { diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index 928b7c806c4e..ef3ffc9dfed1 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -46,7 +46,9 @@ export { printValue, reindent, removeFromList, - withParentheses, removeMembers, removeStatement, + removeStatements, + setImportSpecifiers, + withParentheses, } from './estree/editor.ts'; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.test.ts index 0ae516bce8a3..da7d7f533b0e 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.test.ts @@ -413,7 +413,8 @@ describe('vitestSetupFile', () => { "import { MotionGlobalConfig } from 'framer-motion'; // Skip animations - MotionGlobalConfig.skipAnimations = true;" + MotionGlobalConfig.skipAnimations = true; + " `); expect(readFixture('vitest.config.ts')).toBe(STANDARD_VITEST_CONFIG); }); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.ts b/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.ts index 8bb08ac52b3b..6b6acf7968cc 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/vitest-setup-file.ts @@ -12,6 +12,8 @@ import { SourceEditor, headerComments, walk, + removeStatements, + setImportSpecifiers, } from 'storybook/internal/csf-tools'; import jscodeshift from 'jscodeshift'; @@ -21,7 +23,6 @@ import semver from 'semver'; import { dedent } from 'ts-dedent'; import { findFilesUp } from '../../util.ts'; -import { removeStatements, setImportSpecifiers } from '../helpers/source-edits.ts'; import type { Fix } from '../types.ts'; const VITEST_ADDON_NAME = '@storybook/addon-vitest'; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.test.ts index 3396ffd5e46d..e734d7853ecb 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.test.ts @@ -816,7 +816,8 @@ describe('run', (): void => { expect(vol.readFileSync(PREVIEW, 'utf8')).toMatchInlineSnapshot(` "import customElements from "../custom-elements.json"; - const m = format(customElements);" + const m = format(customElements); + " `); expect(vi.mocked(logger.warn).mock.calls).toMatchInlineSnapshot(` [ @@ -842,7 +843,8 @@ describe('run', (): void => { await runWith({ manifestPath: null }); expect(vol.readFileSync(PREVIEW, 'utf8')).toMatchInlineSnapshot(` - "import tokens from "../tokens.json";" + "import tokens from "../tokens.json"; + " `); expect(logger.warn).not.toHaveBeenCalled(); }); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.ts b/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.ts index 819a56daf19c..25e661b4de73 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/web-components-runtime-manifest.ts @@ -4,6 +4,8 @@ import { type ESTree as E, type ESTreeNode as Node, SourceEditor, + removeStatements, + setImportSpecifiers, } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { SupportedRenderer } from 'storybook/internal/types'; @@ -20,7 +22,6 @@ import { topLevelCalls, topLevelReaders, } from '../helpers/preview-call.ts'; -import { removeStatements, setImportSpecifiers } from '../helpers/source-edits.ts'; import { findWorkspaceFiles, readJsonFile } from '../helpers/workspace-files.ts'; import type { FixTransform } from '../pipeline.ts'; import type { Fix } from '../types.ts'; diff --git a/code/lib/cli-storybook/src/automigrate/helpers/preview-call.ts b/code/lib/cli-storybook/src/automigrate/helpers/preview-call.ts index 81b59532c867..ceb2bdf53965 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/preview-call.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/preview-call.ts @@ -4,10 +4,10 @@ import { type ESTreeNode as Node, type SourceEditor, walk, + removeStatements, + setImportSpecifiers, } from 'storybook/internal/csf-tools'; -import { removeStatements, setImportSpecifiers } from './source-edits.ts'; - export type RemoveTopLevelCallResult = | { removed: false; reason: string } | { removed: true; keptArguments: string[] }; diff --git a/code/lib/cli-storybook/src/automigrate/helpers/source-edits.ts b/code/lib/cli-storybook/src/automigrate/helpers/source-edits.ts deleted file mode 100644 index 0f6904a8773e..000000000000 --- a/code/lib/cli-storybook/src/automigrate/helpers/source-edits.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ESTree as E, ESTreeNode as Node, SourceEditor } from 'storybook/internal/csf-tools'; - -// Each statement takes the whitespace before it, or after it when nothing before it is kept, so no -// blank lines are left behind. A `;` right after a removed statement goes with it. -export function removeStatements(editor: SourceEditor, statements: ReadonlySet) { - const { code, program } = editor; - const removed = new Set(statements); - program.body.forEach((statement, index) => { - const next = program.body[index + 1]; - if (removed.has(statement) && next?.type === 'EmptyStatement' && next.start === statement.end) { - removed.add(next); - } - }); - program.body.forEach((statement, index) => { - if (!removed.has(statement)) { - return; - } - if (program.body.slice(0, index).every((previous) => removed.has(previous))) { - const next = /\S/.exec(code.slice(statement.end)); - editor.edits.remove(statement.start, next ? statement.end + next.index : code.length); - return; - } - const previousEnd = program.body[index - 1].end; - const lineEnd = code.indexOf('\n', previousEnd); - const hasTrailingComment = - lineEnd !== -1 && - lineEnd < statement.start && - /^[ \t]*\/[/*]/.test(code.slice(previousEnd, lineEnd)); - editor.edits.remove(hasTrailingComment ? lineEnd : previousEnd, statement.end); - }); -} - -export function setImportSpecifiers( - editor: SourceEditor, - declaration: E.ImportDeclaration, - specifiers: readonly E.ImportDeclaration['specifiers'][number][], - added: { default?: string; named?: string[] } = {} -) { - const source = (specifier: Node) => editor.source(specifier); - const named = [ - ...specifiers.filter((specifier) => specifier.type === 'ImportSpecifier').map(source), - ...(added.named ?? []), - ]; - const parts = [ - ...(added.default ? [added.default] : []), - ...specifiers.filter((specifier) => specifier.type !== 'ImportSpecifier').map(source), - ...(named.length > 0 ? [`{ ${named.join(', ')} }`] : []), - ]; - const kind = declaration.importKind === 'type' ? 'import type' : 'import'; - editor.edits.overwrite( - declaration.start, - declaration.source.start, - `${kind} ${parts.join(', ')} from ` - ); -} diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts index 8d8d27c81448..2ce762c3fd65 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts @@ -75,6 +75,7 @@ describe('main/preview codemod: general parsing functionality', () => { // @ts-check /** @license MIT */ import { defineMain } from '@storybook/react-vite/node'; + export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], framework: '@storybook/react-vite', diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts index 453378ac1b39..00da57a4a1f6 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts @@ -4,15 +4,16 @@ import { type ESTreeNode as Node, loadConfig, printConfig, + removeStatements, + setImportSpecifiers, + prependStatement, } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import picocolors from 'picocolors'; import type { FileInfo } from '../../automigrate/codemod.ts'; -import { removeStatements, setImportSpecifiers } from '../../automigrate/helpers/source-edits.ts'; import { - addImportToTop, cleanupTypeImports, getConfigProperties, removeExportDeclarations, @@ -205,7 +206,7 @@ export async function configToCsfFactory( } } else { // if not, add import { defineMain } from '@storybook/framework' - addImportToTop( + prependStatement( editor, `import { ${methodName} } from ${quote}${expectedImportSource}${quote};` ); diff --git a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts index e04839f1e51f..b6d588689850 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts @@ -3,10 +3,10 @@ import { type ESTreeNode as Node, type SourceEditor, walk, + removeStatements, + setImportSpecifiers, } from 'storybook/internal/csf-tools'; -import { removeStatements, setImportSpecifiers } from '../../automigrate/helpers/source-edits.ts'; - const projectAnnotationNames = [ 'decorators', 'parameters', @@ -118,14 +118,3 @@ export function getConfigProperties( } // Leading file comments, such as license headers or `@ts-check`, stay above the import. -export function addImportToTop(editor: SourceEditor, importDeclaration: string): void { - const first = editor.program.body.find( - (node) => !(node.type === 'ExpressionStatement' && node.directive) - ); - if (first) { - editor.edits.appendLeft(first.start, `${importDeclaration}\n`); - } else { - const { code } = editor; - editor.edits.append(`${code && !code.endsWith('\n') ? '\n' : ''}${importDeclaration}\n`); - } -} diff --git a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts index a5202bc308bb..d396c532fdb1 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/remove-unused-types.ts @@ -3,9 +3,9 @@ import { type ESTreeNode as Node, type SourceEditor, walk, + removeStatements, } from 'storybook/internal/csf-tools'; -import { removeStatements } from '../../automigrate/helpers/source-edits.ts'; import { cleanupTypeImports } from './csf-factories-utils.ts'; // Name of types that should be removed from the import list diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index 2aa7de49e83d..f986d8c2b756 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -63,6 +63,7 @@ describe('stories codemod', () => { * Copyright 2024 */ import preview from "#.storybook/preview"; + const meta = preview.meta({ title: "Component" }); export const A = meta.story(); `); diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index 76e14578ad4d..4fd082b94ce7 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -6,6 +6,9 @@ import { printCsf, walk, withParentheses, + removeStatements, + setImportSpecifiers, + prependStatement, } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; @@ -13,8 +16,6 @@ import path from 'path'; import { DOCUMENTED_PREVIEW_IMPORT } from '../../../../../core/src/shared/constants/config-folder.ts'; import type { FileInfo } from '../../automigrate/codemod.ts'; -import { removeStatements, setImportSpecifiers } from '../../automigrate/helpers/source-edits.ts'; -import { addImportToTop } from './csf-factories-utils.ts'; import { customArgsTypes } from './custom-args-type.ts'; import { removeUnusedTypes } from './remove-unused-types.ts'; import { wrapArgsMocks } from './wrap-args-mocks.ts'; @@ -457,7 +458,7 @@ export async function storyToCsfFactory( ); } } else if (hasMeta) { - addImportToTop(editor, `import ${sbConfigImportName} from ${quote}${previewPath}${quote};`); + prependStatement(editor, `import ${sbConfigImportName} from ${quote}${previewPath}${quote};`); } editor.commit(); diff --git a/code/lib/cli-storybook/src/codemod/helpers/wrap-args-mocks.ts b/code/lib/cli-storybook/src/codemod/helpers/wrap-args-mocks.ts index 63ac30e0f016..5c7ee5a195a1 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/wrap-args-mocks.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/wrap-args-mocks.ts @@ -5,10 +5,9 @@ import { type SourceEditor, generateUid, walk, + setImportSpecifiers, } from 'storybook/internal/csf-tools'; -import { setImportSpecifiers } from '../../automigrate/helpers/source-edits.ts'; - const mockMembers = [ 'mock', 'mockClear', diff --git a/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap b/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap index fe07af36e948..a4fce765b917 100644 --- a/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap +++ b/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap @@ -2,7 +2,10 @@ exports[`upgrade-deprecated-types > typescript > upgrade imports with conflicting local names 1`] = ` SyntaxError: This codemod does not support local imports that are called the same as a storybook import. -Rename this local import and try again. (1:10) +Rename this local import and try again. +(1:10) +> 1 | import { ComponentMeta as Meta, ComponentStory as StoryFn } from '@storybook/react'; + | ^ `; exports[`upgrade-deprecated-types > typescript > upgrade imports with local names 1`] = ` diff --git a/code/lib/codemod/src/transforms/__tests__/find-implicit-spies.test.ts b/code/lib/codemod/src/transforms/__tests__/find-implicit-spies.test.ts index d9a5203aa3f4..ac119aad1637 100644 --- a/code/lib/codemod/src/transforms/__tests__/find-implicit-spies.test.ts +++ b/code/lib/codemod/src/transforms/__tests__/find-implicit-spies.test.ts @@ -49,10 +49,16 @@ test('Warn for possible implicit actions', async () => { expect(warn.mock.calls).toMatchInlineSnapshot(` [ [ - "Component.stories.tsx Possible implicit spy found (7:20)", + "Component.stories.tsx Possible implicit spy found + (7:20) + > 7 | await expect(args.onImplicit).toHaveBeenCalled(); + | ^", ], [ - "Component.stories.tsx Possible implicit spy found (17:22)", + "Component.stories.tsx Possible implicit spy found + (17:22) + > 17 | await expect(args.onImplicit).toHaveBeenCalled(); + | ^", ], ] `); From d10498d45971bd7b37a8dd9c604bf6828661a254 Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 11:18:26 +0900 Subject: [PATCH 079/326] fix(csf-tools): close the scope analysis gaps that could delete live code Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/scope.test.ts | 54 +++++++- code/core/src/csf-tools/estree/scope.ts | 118 ++++++++++++++++-- .../plugins/server-code-elimination.test.ts | 15 +++ 3 files changed, 177 insertions(+), 10 deletions(-) diff --git a/code/core/src/csf-tools/estree/scope.test.ts b/code/core/src/csf-tools/estree/scope.test.ts index e3a17b495fc9..3017e1a41f3f 100644 --- a/code/core/src/csf-tools/estree/scope.test.ts +++ b/code/core/src/csf-tools/estree/scope.test.ts @@ -16,7 +16,11 @@ const programBindings = (code: string) => { describe('analyzeScopes', () => { it.each([ ['a const read once', 'const a = 1; a;', { a: { constant: true, references: 1 } }], - ['a reassigned let', 'let a = 1; a = 2;', { a: { constant: false, references: 0 } }], + [ + 'a reassigned let, whose write is a use', + 'let a = 1; a = 2;', + { a: { constant: false, references: 1 } }, + ], [ 'an update, which reads and writes', 'let a = 1; a++;', @@ -69,6 +73,54 @@ describe('analyzeScopes', () => { expect(first.references).toBe(references); }); + it('resolves parameter defaults outside the function body', () => { + expect( + programBindings('const q = 1; function f(a = q) { const q = 2; return a; }') + ).toMatchObject({ q: { references: 1 } }); + }); + + it('resolves the switch discriminant outside the switch scope', () => { + expect( + programBindings('const x = 1; switch (x) { case 1: { const x = 2; x; } }') + ).toMatchObject({ x: { references: 1 } }); + }); + + it('visits decorators on parameters and abstract members', () => { + expect(programBindings('const dec = () => {}; class A { m(@dec p: string) {} }')).toMatchObject( + { dec: { references: 1 } } + ); + expect( + programBindings('const dec = () => {}; abstract class A { @dec abstract p: string; }') + ).toMatchObject({ dec: { references: 1 } }); + }); + + it('counts the namespace an import-equals declaration reads', () => { + expect(programBindings("import NS from 'x'; import Y = NS.Y; Y;")).toMatchObject({ + NS: { references: 1 }, + }); + }); + + it('binds a var declared in a class static block to the block', () => { + const { program } = parseModule('class A { static { var v = 1; v; } }', 'file.ts'); + const info = analyzeScopes(program); + expect(info.program.bindings.has('v')).toBe(false); + expect(info.unbound.has('v')).toBe(false); + }); + + it('keeps a binding alive through a write inside a function', () => { + expect( + programBindings('let token; export function setSession(s) { token = s.token; }') + ).toMatchObject({ token: { constant: false, references: 1 } }); + }); + + it('refuses TS node types it does not know instead of treating them as types', () => { + const program = { + type: 'Program', + body: [{ type: 'ExpressionStatement', expression: { type: 'TSNewThing' } }], + } as unknown as E.Program; + expect(() => analyzeScopes(program)).toThrow('TSNewThing'); + }); + it('counts overload signatures as references to the implementation', () => { expect(programBindings('function f(): void; function f(a?: unknown) {} f();')).toEqual({ f: { constant: true, references: 2 }, diff --git a/code/core/src/csf-tools/estree/scope.ts b/code/core/src/csf-tools/estree/scope.ts index 78719fa8f55f..295350e5254e 100644 --- a/code/core/src/csf-tools/estree/scope.ts +++ b/code/core/src/csf-tools/estree/scope.ts @@ -85,7 +85,7 @@ const BLOCK_SCOPE_TYPES = new Set([ 'ClassDeclaration', ]); -// TS wrappers that hold a runtime expression; every other `TS*` node is type-only. +// TS nodes that hold runtime code and are visited like any other expression or declaration. const TS_EXPRESSIONS = new Set([ 'TSModuleDeclaration', 'TSModuleBlock', @@ -99,6 +99,73 @@ const TS_EXPRESSIONS = new Set([ 'TSEnumDeclaration', 'TSEnumBody', 'TSEnumMember', + 'TSImportEqualsDeclaration', + 'TSAbstractMethodDefinition', + 'TSAbstractPropertyDefinition', + 'TSAbstractAccessorProperty', +]); + +// TS nodes that only describe types. A `TS*` node in neither set is an error rather than a guess: +// treating a renamed runtime node as a type would silently drop references. +const TS_TYPES = new Set([ + 'TSAnyKeyword', + 'TSArrayType', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSCallSignatureDeclaration', + 'TSClassImplements', + 'TSConditionalType', + 'TSConstructSignatureDeclaration', + 'TSConstructorType', + 'TSDeclareFunction', + 'TSEmptyBodyFunctionExpression', + 'TSExternalModuleReference', + 'TSFunctionType', + 'TSImportType', + 'TSIndexSignature', + 'TSIndexedAccessType', + 'TSInferType', + 'TSInterfaceBody', + 'TSInterfaceDeclaration', + 'TSInterfaceHeritage', + 'TSIntersectionType', + 'TSIntrinsicKeyword', + 'TSJSDocNonNullableType', + 'TSJSDocNullableType', + 'TSJSDocUnknownType', + 'TSLiteralType', + 'TSMappedType', + 'TSMethodSignature', + 'TSNamedTupleMember', + 'TSNamespaceExportDeclaration', + 'TSNeverKeyword', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSOptionalType', + 'TSParenthesizedType', + 'TSPropertySignature', + 'TSQualifiedName', + 'TSRestType', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSTemplateLiteralType', + 'TSThisType', + 'TSTupleType', + 'TSTypeAliasDeclaration', + 'TSTypeAnnotation', + 'TSTypeLiteral', + 'TSTypeOperator', + 'TSTypeParameter', + 'TSTypeParameterDeclaration', + 'TSTypeParameterInstantiation', + 'TSTypePredicate', + 'TSTypeQuery', + 'TSTypeReference', + 'TSUndefinedKeyword', + 'TSUnionType', + 'TSUnknownKeyword', + 'TSVoidKeyword', ]); const TYPE_KEYS = new Set([ @@ -112,12 +179,16 @@ const TYPE_KEYS = new Set([ const isCompatTag = (name: string) => /^[a-z]/.test(name) || name.includes('-'); -// Identifiers a binding pattern declares, plus the expressions nested in it (defaults, keys). +// Identifiers a binding pattern declares, plus the expressions nested in it (defaults, keys, +// decorators). const patternParts = (pattern: Node, ids: E.BindingIdentifier[], expressions: Node[]) => { const annotation = (pattern as { typeAnnotation?: Node | null }).typeAnnotation; if (annotation) { expressions.push(annotation); } + for (const decorator of (pattern as { decorators?: Node[] }).decorators ?? []) { + expressions.push(decorator); + } switch (pattern.type) { case 'Identifier': ids.push(pattern as E.BindingIdentifier); @@ -282,9 +353,12 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { declare(scope, id, 'param', param); } } + // The body declares into its own scope, so a parameter default cannot see a body binding. if (fn.body?.type === 'BlockStatement') { - hoistVars(scope, fn.body); - declareLexical(scope, fn.body); + const body = new Scope(fn.body, scope, false); + nodeScopes.set(fn.body, body); + hoistVars(body, fn.body); + declareLexical(body, fn.body); } } else if (node.type === 'ClassExpression' && node.id) { declare(scope, node.id, 'class', node); @@ -306,7 +380,7 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { declareVariable(scope, declaration); } } else { - if (node.type === 'TSModuleBlock') { + if (node.type === 'TSModuleBlock' || node.type === 'StaticBlock') { hoistVars(scope, node); } declareLexical(scope, node); @@ -328,6 +402,8 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { patternParts(target, ids, expressions); for (const id of ids) { names.add(id.name); + // A write keeps the binding alive as much as a read does. + references.push({ scope, identifier: id, name: id.name }); violations.push({ scope, identifier: id as unknown as E.IdentifierReference }); } for (const expression of expressions) { @@ -422,6 +498,9 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { const visitNode = (node: Node, scope: Scope): void => { if (node.type.startsWith('TS') && !TS_EXPRESSIONS.has(node.type)) { + if (!TS_TYPES.has(node.type)) { + throw new Error(`Scope analysis does not know the ${node.type} node type`); + } visitType(node, scope); return; } @@ -534,9 +613,9 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { } if (node.body) { if (node.body.type === 'BlockStatement') { - nodeScopes.set(node.body, current); + const body = nodeScopes.get(node.body) ?? current; for (const statement of node.body.body) { - visit(statement, current); + visit(statement, body); } } else { visit(node.body, current); @@ -569,8 +648,6 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { return; case 'UpdateExpression': if (node.argument.type === 'Identifier') { - // `x++` both reads and writes `x`. - references.push({ scope: current, identifier: node.argument, name: node.argument.name }); markWrites(node.argument, current); } else { visit(node.argument, current); @@ -589,6 +666,29 @@ export const analyzeScopes = (program: E.Program): ScopeInfo => { case 'TSParameterProperty': visitPattern(node.parameter, current); return; + case 'SwitchStatement': + // The discriminant is evaluated before the switch's own scope exists. + visit(node.discriminant, scope); + for (const switchCase of node.cases) { + visit(switchCase, current); + } + return; + case 'TSAbstractMethodDefinition': + case 'TSAbstractPropertyDefinition': + case 'TSAbstractAccessorProperty': + for (const decorator of node.decorators ?? []) { + visit(decorator, current); + } + if (node.computed) { + visit(node.key, current); + } + return; + case 'TSImportEqualsDeclaration': + // `import X = NS.Y` reads `NS`; `import X = require('y')` reads nothing. + if (node.moduleReference.type !== 'TSExternalModuleReference') { + reference(node.moduleReference, current); + } + return; case 'TSEnumDeclaration': visit(node.body, current); return; diff --git a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts index 33218c1738b7..ee64dca44828 100644 --- a/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts +++ b/code/frameworks/tanstack-react/src/plugins/server-code-elimination.test.ts @@ -112,6 +112,21 @@ describe('serverCodeEliminationPlugin', () => { expect(result!.code).toContain('__sb_fn()'); }); + it('keeps declarations that live code writes or reads through tricky scopes', async () => { + const code = [ + `import { createServerFn } from '@tanstack/react-start';`, + `import { config } from './config';`, + `let token;`, + `export function setSession(s) { token = s.token; }`, + `export function useThing(c = config) { const config = 1; return c + config; }`, + `export const getData = createServerFn().handler(async () => token + config);`, + ].join('\n'); + const result = await transform(code); + expect(result!.code).toContain('let token;'); + expect(result!.code).toContain(`import { config } from './config';`); + expect(result!.code).toContain('__sb_fn()'); + }); + it('handles chained .middleware().handler()', async () => { const code = [ `import { createServerFn } from '@tanstack/react-start';`, From cad8a194fe6301d9bc628cfba836019ac46e7b7a Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 11:19:31 +0900 Subject: [PATCH 080/326] docs: list the breaking changes of the OXC-based csf-tools Co-Authored-By: Claude Fable 5.1 --- MIGRATION.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/MIGRATION.md b/MIGRATION.md index 7cb7aa7f4843..8f22923f9778 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1324,6 +1324,23 @@ This only affects tooling that imports Storybook's internal entry points: - `CsfObject#get` returns an ESTree expression, and `set`/`transform` accept expressions from `get()` or `parseExpression(code)`. Use `printExpression(node)` to read an expression's source. - An `experimental_enrichCsf` function receives the new `CsfFile`. Add code with `csf._appendStatement(code)` instead of pushing Babel nodes onto `csf._ast`. +Behavior that changes for every project: + +- **Flow syntax is no longer supported in story files.** Babel parsed `// @flow` files with its Flow plugin; OXC does not parse Flow, so such files fail to index with an error that names Flow. Convert them to TypeScript or plain JavaScript. +- `parameters.docs.source.originalSource` is now the story's source text exactly as written, where Babel reprinted it with its own formatting. "Show code" and `docs.source.transform` functions receive the authored text, with line endings normalized to `\n`. +- Parse errors use OXC's wording, and end with ` in ` when the file name is known. Duplicate declarations and legacy octal literals are still rejected. + +Internal exports that are removed or changed, for tooling built on `storybook/internal/csf-tools`: + +- `babelParse` and `babelParseFile` are removed; use `loadCsf(code, options)` and `readCsf(fileName, options)`. +- `getHeaderComments(statement)` is replaced by `headerComments(editor)`. +- The story-shape helpers no longer take Babel paths: `jsDocTagsForPath` is `jsDocTagsForNode`, `metaObjectPath` is `metaObject`, and `pathForNode` and `returnedExpressionPath` are removed in favor of `returnedExpression` and plain nodes. +- The `getAbsolutePath` wrapper helpers in `storybook/internal/common` work on nodes: `getAbsolutePathWrapperAsCallExpression` is replaced by `getAbsolutePathCall`, and `wrapValueWithGetAbsolutePathWrapper` by `wrapValuesWithGetAbsolutePathWrapper`. +- `new CsfFile(code, options)` and `new ConfigFile(code, fileName)` take source code instead of a Babel AST. +- `formatCsf(csf, { sourceMaps, sourceFileName, inputSourceMap })` replaces the Babel generator options; `printCsf` and `printConfig` take no options and always print with `\n` line endings. +- `ConfigFile#setBodyDeclaration(code)` takes the declaration's source text instead of a Babel node. +- The Vitest config helpers (`canUpdateVitestConfigFile`, `getTargetConfigObject`, ...) moved from `storybook/internal/babel` to `storybook/internal/cli`. + Babel itself is still used where it compiles your code, for example by `@storybook/nextjs` and the webpack Babel compiler addon. ### Create React App support removed From d1ea90bb79c05360990b0958a1dcf95ac824dd5b Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 11:23:25 +0900 Subject: [PATCH 081/326] refactor(csf-tools): register node sources when parsing instead of on first parent lookup Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/ConfigFile.test.ts | 7 +++---- code/core/src/csf-tools/CsfObject.ts | 2 -- code/core/src/csf-tools/estree/editor.ts | 6 ++++-- .../src/csf-tools/story-shape/utils.test.ts | 18 +++++++++++------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index aae4a02711b2..1cfe06a6d777 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -454,12 +454,11 @@ describe('ConfigFile', () => { ); }); - it('rejects a node whose source is unknown', () => { + it('copies a node from another file by its source', () => { const other = loadConfig(`export const a = 1;`).parse(); const config = loadConfig(`export const c = {};`).parse(); - expect(() => config.set(['c', 'd'], other.getFieldNode(['a']) as CsfExpression)).toThrow( - /expressions must come from/ - ); + config.set(['c', 'd'], other.getFieldNode(['a']) as CsfExpression); + expect(formatConfig(config)).toBe(`export const c = { d: 1 };`); }); describe('named exports', () => { diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 0364963f379c..b00d49441443 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -528,9 +528,7 @@ class CsfObjectEditor implements CsfObject { private sourceFor(value: CsfValue | CsfExpression) { if (isSpan(value)) { - // Registers the source of this file's nodes, so a node read from the file prints as written. const node = value as Node; - this.editor.parentOf(node); return placeableSource(printExpression(node), node.type); } if (isAstLike(value)) { diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index bf840e19ed0f..00e3d14a1080 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -55,6 +55,8 @@ export class SourceEditor { constructor(code: string, fileName?: string) { this.fileName = fileName; this._module = parseModule(code, fileName); + // Every node prints as the text it was parsed from, wherever it is used. + registerSource(this._module.program, code); this._edits = new MagicString(code, { filename: fileName }); } @@ -75,14 +77,13 @@ export class SourceEditor { return this._scopes; } - /** Parent of a node in this source; building the map also registers every node's source text. */ + /** Parent of a node in this source. */ parentOf(node: Node): Node | null { if (!this._parents) { const parents = new Map(); walk(this._module.program, (child, parent) => { parents.set(child, parent); }); - registerSource(this._module.program, this._module.code); this._parents = parents; } return this._parents.get(node) ?? null; @@ -124,6 +125,7 @@ export class SourceEditor { replaceSource(code: string) { this._module = parseModule(code, this.fileName); + registerSource(this._module.program, code); this._scopes = undefined; this._parents = undefined; this._edits = new MagicString(code, { filename: this.fileName }); diff --git a/code/core/src/csf-tools/story-shape/utils.test.ts b/code/core/src/csf-tools/story-shape/utils.test.ts index fb9c8ffef545..e6e2a813811b 100644 --- a/code/core/src/csf-tools/story-shape/utils.test.ts +++ b/code/core/src/csf-tools/story-shape/utils.test.ts @@ -136,13 +136,17 @@ describe('keyOf', () => { }); describe('unwrapExpression', () => { - it.each(['{} as Story', '{} satisfies Story', '{}!', '({})', '(({} as Story)!) satisfies Story'])( - 'unwraps %s', - (code) => { - const wrapped = expressionFromSource(code); - expect(unwrapExpression(wrapped).type).toBe('ObjectExpression'); - } - ); + it.each([ + '{} as Story', + '{} satisfies Story', + '{}!', + '({})', + '{}', + '(({} as Story)!) satisfies Story', + ])('unwraps %s', (code) => { + const wrapped = expressionFromSource(code); + expect(unwrapExpression(wrapped).type).toBe('ObjectExpression'); + }); it('returns other nodes untouched', () => { const value = expressionFromSource("'Save'"); From 79403c70061c7ad75ffc047d6fc117f4744868a9 Mon Sep 17 00:00:00 2001 From: EastonShin Date: Fri, 16 Oct 2026 11:30:20 +0900 Subject: [PATCH 082/326] test(csf-tools): compare indexing against outputs recorded from the Babel implementation Co-Authored-By: Claude Fable 5.1 --- code/.oxlintrc.json | 1 + code/core/src/csf-tools/__corpus__/README.md | 11 + .../core/src/csf-tools/__corpus__/golden.json | 4393 +++++++++++++++++ .../inputs/core-argMapping.stories.ts | 72 + .../__corpus__/inputs/core-args.stories.ts | 98 + .../inputs/core-autotitle.stories.ts | 16 + .../__corpus__/inputs/core-basics.stories.ts | 99 + .../inputs/core-before-each.stories.ts | 43 + .../core-component-test.basics.stories.ts | 132 + .../inputs/core-exportOrder.stories.ts | 11 + .../__corpus__/inputs/core-globals.stories.ts | 89 + .../__corpus__/inputs/core-indexer.stories.ts | 26 + .../inputs/core-interleavedExports.stories.ts | 12 + .../inputs/core-moduleMocking.stories.ts | 28 + .../inputs/core-mount-in-play.stories.ts | 25 + .../__corpus__/inputs/core-names.stories.ts | 25 + .../inputs/core-parameters.stories.ts | 48 + .../inputs/core-tags-add.stories.ts | 41 + .../inputs/core-tags-config.stories.ts | 45 + .../__corpus__/inputs/edge-legacy.stories.tsx | 38 + .../inputs/edge-typescript.stories.ts | 30 + .../inputs/errors-NoStories.stories.ts | 5 + .../__corpus__/inputs/react-csf1.stories.tsx | 11 + .../__corpus__/inputs/react-csf2.stories.tsx | 17 + .../__corpus__/inputs/react-csf4.stories.tsx | 13 + .../inputs/react-decorators.stories.tsx | 80 + .../inputs/react-js-argtypes.stories.jsx | 83 + .../inputs/react-mount-in-play.stories.tsx | 21 + .../inputs/react-test-fn.stories.tsx | 92 + .../inputs/shapes-angular-button.stories.ts | 142 + code/core/src/csf-tools/__corpus__/record.mjs | 68 + code/core/src/csf-tools/corpus.test.ts | 79 + code/core/tsconfig.json | 3 +- 33 files changed, 5896 insertions(+), 1 deletion(-) create mode 100644 code/core/src/csf-tools/__corpus__/README.md create mode 100644 code/core/src/csf-tools/__corpus__/golden.json create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-argMapping.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-args.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-autotitle.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-basics.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-before-each.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-component-test.basics.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-exportOrder.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-globals.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-indexer.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-interleavedExports.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-moduleMocking.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-mount-in-play.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-names.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-parameters.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-tags-add.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/core-tags-config.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/edge-legacy.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/edge-typescript.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/errors-NoStories.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-csf1.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-csf2.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-csf4.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-decorators.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-js-argtypes.stories.jsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-mount-in-play.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/react-test-fn.stories.tsx create mode 100644 code/core/src/csf-tools/__corpus__/inputs/shapes-angular-button.stories.ts create mode 100644 code/core/src/csf-tools/__corpus__/record.mjs create mode 100644 code/core/src/csf-tools/corpus.test.ts diff --git a/code/.oxlintrc.json b/code/.oxlintrc.json index b465c093f2d9..b37a9751ad60 100644 --- a/code/.oxlintrc.json +++ b/code/.oxlintrc.json @@ -20,6 +20,7 @@ "ember-output", "**/__testfixtures__", "**/__mockdata__", + "**/__corpus__/inputs", "renderers/svelte/src/svelte-csf/__tests__/__compiled__", "core/assets", "core/report", diff --git a/code/core/src/csf-tools/__corpus__/README.md b/code/core/src/csf-tools/__corpus__/README.md new file mode 100644 index 000000000000..10cb80a8a68f --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/README.md @@ -0,0 +1,11 @@ +# CSF corpus + +Frozen story files and the outputs the Babel-based `CsfFile` produced for them, recorded once from +the last `next` commit that still indexed with Babel. `corpus.test.ts` runs the OXC implementation +over the same inputs and compares with `toEqual`, so `vitest -u` cannot re-baseline the outputs. + +Rerun on an OXC upgrade. To extend the corpus, add inputs and record again from a Babel checkout: + +```sh +node code/core/src/csf-tools/__corpus__/record.mjs /code +``` diff --git a/code/core/src/csf-tools/__corpus__/golden.json b/code/core/src/csf-tools/__corpus__/golden.json new file mode 100644 index 000000000000..891227215aa9 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/golden.json @@ -0,0 +1,4393 @@ +{ + "core-argMapping.stories.ts": { + "indexInputs": [ + { + "exportName": "Single", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--single", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Single" + }, + { + "exportName": "Multiple", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--multiple", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Multiple" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Single": { + "id": "untitled--single", + "name": "Single", + "tags": ["play-fn"], + "localName": null + }, + "Multiple": { + "id": "untitled--multiple", + "name": "Multiple", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 2 + }, + "within": { + "constant": true, + "uses": 2 + }, + "arrows": { + "constant": true, + "uses": 4 + }, + "labels": { + "constant": true, + "uses": 2 + }, + "Single": { + "constant": true, + "uses": 1 + }, + "Multiple": { + "constant": true, + "uses": 1 + } + } + }, + "core-args.stories.ts": { + "indexInputs": [ + { + "exportName": "Inheritance", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--inheritance", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Inheritance" + }, + { + "exportName": "Targets", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--targets", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Targets" + }, + { + "exportName": "Events", + "title": "Untitled", + "tags": ["!vitest", "!test", "play-fn"], + "__id": "untitled--events", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Events" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Inheritance": { + "id": "untitled--inheritance", + "name": "Inheritance", + "tags": ["play-fn"], + "localName": null + }, + "Targets": { + "id": "untitled--targets", + "name": "Targets", + "tags": ["!vitest", "play-fn"], + "localName": null + }, + "Events": { + "id": "untitled--events", + "name": "Events", + "tags": ["!vitest", "!test", "play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "RESET_STORY_ARGS": { + "constant": true, + "uses": 1 + }, + "STORY_ARGS_UPDATED": { + "constant": true, + "uses": 2 + }, + "UPDATE_STORY_ARGS": { + "constant": true, + "uses": 1 + }, + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 2 + }, + "expect": { + "constant": true, + "uses": 2 + }, + "within": { + "constant": true, + "uses": 4 + }, + "pick": { + "constant": true, + "uses": 1 + }, + "Inheritance": { + "constant": true, + "uses": 1 + }, + "Targets": { + "constant": true, + "uses": 1 + }, + "Events": { + "constant": true, + "uses": 1 + } + } + }, + "core-autotitle.stories.ts": { + "indexInputs": [ + { + "exportName": "Default", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--default", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Default" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Default": { + "id": "untitled--default", + "name": "Default", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 1 + }, + "Default": { + "constant": true, + "uses": 1 + } + } + }, + "core-basics.stories.ts": { + "indexInputs": [ + { + "exportName": "Basic", + "title": "Untitled", + "tags": [], + "__id": "untitled--basic", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Basic" + }, + { + "exportName": "TypeString", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-string", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type String" + }, + { + "exportName": "TypeBoolean", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-boolean", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Boolean" + }, + { + "exportName": "TypeObject", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-object", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Object" + }, + { + "exportName": "TypeNull", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-null", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Null" + }, + { + "exportName": "TypeUndefined", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-undefined", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Undefined" + }, + { + "exportName": "TypeNaN", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-na-n", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Na N" + }, + { + "exportName": "TypeInfinity", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-infinity", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Infinity" + }, + { + "exportName": "TypeMinusInfinity", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-minus-infinity", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Minus Infinity" + }, + { + "exportName": "TypeNumber", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-number", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Number" + }, + { + "exportName": "TypeGlobal", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-global", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Global" + }, + { + "exportName": "TypeSymbol", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-symbol", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Symbol" + }, + { + "exportName": "TypeRegExp", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-reg-exp", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Reg Exp" + }, + { + "exportName": "TypeArray", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-array", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Array" + }, + { + "exportName": "TypeClass", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-class", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Class" + }, + { + "exportName": "TypeFunction", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-function", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Function" + }, + { + "exportName": "TypeMultiple", + "title": "Untitled", + "tags": [], + "__id": "untitled--type-multiple", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type Multiple" + }, + { + "exportName": "Cyclical", + "title": "Untitled", + "tags": [], + "__id": "untitled--cyclical", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Cyclical" + }, + { + "exportName": "Reserved", + "title": "Untitled", + "tags": [], + "__id": "untitled--reserved", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Reserved" + }, + { + "exportName": "OptionPersist", + "title": "Untitled", + "tags": [], + "__id": "untitled--option-persist", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Option Persist" + }, + { + "exportName": "OptionDepth", + "title": "Untitled", + "tags": [], + "__id": "untitled--option-depth", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Option Depth" + }, + { + "exportName": "Disabled", + "title": "Untitled", + "tags": [], + "__id": "untitled--disabled", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Disabled" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Button", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Basic": { + "id": "untitled--basic", + "name": "Basic", + "localName": null + }, + "TypeString": { + "id": "untitled--type-string", + "name": "Type String", + "localName": null + }, + "TypeBoolean": { + "id": "untitled--type-boolean", + "name": "Type Boolean", + "localName": null + }, + "TypeObject": { + "id": "untitled--type-object", + "name": "Type Object", + "localName": null + }, + "TypeNull": { + "id": "untitled--type-null", + "name": "Type Null", + "localName": null + }, + "TypeUndefined": { + "id": "untitled--type-undefined", + "name": "Type Undefined", + "localName": null + }, + "TypeNaN": { + "id": "untitled--type-na-n", + "name": "Type Na N", + "localName": null + }, + "TypeInfinity": { + "id": "untitled--type-infinity", + "name": "Type Infinity", + "localName": null + }, + "TypeMinusInfinity": { + "id": "untitled--type-minus-infinity", + "name": "Type Minus Infinity", + "localName": null + }, + "TypeNumber": { + "id": "untitled--type-number", + "name": "Type Number", + "localName": null + }, + "TypeGlobal": { + "id": "untitled--type-global", + "name": "Type Global", + "localName": null + }, + "TypeSymbol": { + "id": "untitled--type-symbol", + "name": "Type Symbol", + "localName": null + }, + "TypeRegExp": { + "id": "untitled--type-reg-exp", + "name": "Type Reg Exp", + "localName": null + }, + "TypeArray": { + "id": "untitled--type-array", + "name": "Type Array", + "localName": null + }, + "TypeClass": { + "id": "untitled--type-class", + "name": "Type Class", + "localName": null + }, + "TypeFunction": { + "id": "untitled--type-function", + "name": "Type Function", + "localName": null + }, + "TypeMultiple": { + "id": "untitled--type-multiple", + "name": "Type Multiple", + "localName": null + }, + "Cyclical": { + "id": "untitled--cyclical", + "name": "Cyclical", + "localName": null + }, + "Reserved": { + "id": "untitled--reserved", + "name": "Reserved", + "localName": null + }, + "OptionPersist": { + "id": "untitled--option-persist", + "name": "Option Persist", + "localName": null + }, + "OptionDepth": { + "id": "untitled--option-depth", + "name": "Option Depth", + "localName": null + }, + "Disabled": { + "id": "untitled--disabled", + "name": "Disabled", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "globalThis": { + "constant": true, + "uses": 2 + }, + "action": { + "constant": true, + "uses": 22 + }, + "Basic": { + "constant": true, + "uses": 1 + }, + "TypeString": { + "constant": true, + "uses": 1 + }, + "TypeBoolean": { + "constant": true, + "uses": 1 + }, + "TypeObject": { + "constant": true, + "uses": 1 + }, + "TypeNull": { + "constant": true, + "uses": 1 + }, + "TypeUndefined": { + "constant": true, + "uses": 1 + }, + "TypeNaN": { + "constant": true, + "uses": 1 + }, + "TypeInfinity": { + "constant": true, + "uses": 1 + }, + "TypeMinusInfinity": { + "constant": true, + "uses": 1 + }, + "TypeNumber": { + "constant": true, + "uses": 1 + }, + "TypeGlobal": { + "constant": true, + "uses": 1 + }, + "TypeSymbol": { + "constant": true, + "uses": 1 + }, + "TypeRegExp": { + "constant": true, + "uses": 1 + }, + "TypeArray": { + "constant": true, + "uses": 1 + }, + "TypeClass": { + "constant": true, + "uses": 1 + }, + "TypeFunction": { + "constant": true, + "uses": 1 + }, + "TypeMultiple": { + "constant": true, + "uses": 1 + }, + "Cyclical": { + "constant": true, + "uses": 1 + }, + "Reserved": { + "constant": true, + "uses": 1 + }, + "OptionPersist": { + "constant": true, + "uses": 1 + }, + "OptionDepth": { + "constant": true, + "uses": 1 + }, + "Disabled": { + "constant": true, + "uses": 1 + } + } + }, + "core-before-each.stories.ts": { + "indexInputs": [ + { + "exportName": "before_each_and_loaders_can_extend_context", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--before-each-and-loaders-can-extend-context", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": true, + "beforeEach": true, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Before Each And Loaders Can Extend Context" + }, + { + "exportName": "context_prop_is_available", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--context-prop-is-available", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Context Prop Is Available" + }, + { + "exportName": "step_and_canvas_element_can_be_used_in_loaders_and_before_each", + "title": "Untitled", + "tags": [], + "__id": "untitled--step-and-canvas-element-can-be-used-in-loaders-and-before-each", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": true, + "beforeEach": true, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Step And Canvas Element Can Be Used In Loaders And Before Each" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Button", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "before_each_and_loaders_can_extend_context": { + "id": "untitled--before-each-and-loaders-can-extend-context", + "name": "Before Each And Loaders Can Extend Context", + "tags": ["play-fn"], + "localName": null + }, + "context_prop_is_available": { + "id": "untitled--context-prop-is-available", + "name": "Context Prop Is Available", + "tags": ["play-fn"], + "localName": null + }, + "step_and_canvas_element_can_be_used_in_loaders_and_before_each": { + "id": "untitled--step-and-canvas-element-can-be-used-in-loaders-and-before-each", + "name": "Step And Canvas Element Can Be Used In Loaders And Before Each", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "expect": { + "constant": true, + "uses": 4 + }, + "meta": { + "constant": true, + "uses": 1 + }, + "before_each_and_loaders_can_extend_context": { + "constant": true, + "uses": 1 + }, + "context_prop_is_available": { + "constant": true, + "uses": 1 + }, + "step_and_canvas_element_can_be_used_in_loaders_and_before_each": { + "constant": true, + "uses": 1 + } + } + }, + "core-component-test.basics.stories.ts": { + "indexInputs": [ + { + "exportName": "Validation", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--validation", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Validation" + }, + { + "exportName": "Type", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--type", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type" + }, + { + "exportName": "Step", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--step", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Step" + }, + { + "exportName": "TypeAndClear", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--type-and-clear", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Type And Clear" + }, + { + "exportName": "Callback", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--callback", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Callback" + }, + { + "exportName": "SyncWaitFor", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--sync-wait-for", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Sync Wait For" + }, + { + "exportName": "AsyncWaitFor", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--async-wait-for", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Async Wait For" + }, + { + "exportName": "WithLoaders", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--with-loaders", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": true, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "With Loaders" + }, + { + "exportName": "UserEventSetup", + "title": "Untitled", + "tags": ["!vitest", "play-fn"], + "__id": "untitled--user-event-setup", + "__stats": { + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "UserEventSetup" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["!vitest"], + "component": "globalThis.__TEMPLATE_COMPONENTS__.Form", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Validation": { + "id": "untitled--validation", + "name": "Validation", + "tags": ["play-fn"], + "localName": null + }, + "Type": { + "id": "untitled--type", + "name": "Type", + "tags": ["play-fn"], + "localName": null + }, + "Step": { + "id": "untitled--step", + "name": "Step", + "tags": ["play-fn"], + "localName": null + }, + "TypeAndClear": { + "id": "untitled--type-and-clear", + "name": "Type And Clear", + "tags": ["play-fn"], + "localName": null + }, + "Callback": { + "id": "untitled--callback", + "name": "Callback", + "tags": ["play-fn"], + "localName": null + }, + "SyncWaitFor": { + "id": "untitled--sync-wait-for", + "name": "Sync Wait For", + "tags": ["play-fn"], + "localName": null + }, + "AsyncWaitFor": { + "id": "untitled--async-wait-for", + "name": "Async Wait For", + "tags": ["play-fn"], + "localName": null + }, + "WithLoaders": { + "id": "untitled--with-loaders", + "name": "With Loaders", + "tags": ["play-fn"], + "localName": null + }, + "UserEventSetup": { + "id": "untitled--user-event-setup", + "name": "UserEventSetup", + "tags": ["play-fn"], + "localName": "UserEventSetup" + } + }, + "namedExportsOrder": null, + "bindings": { + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 4 + }, + "fireEvent": { + "constant": true, + "uses": 3 + }, + "fn": { + "constant": true, + "uses": 1 + }, + "testUserEvent": { + "constant": true, + "uses": 1 + }, + "waitFor": { + "constant": true, + "uses": 2 + }, + "waitForElementToBeRemoved": { + "constant": true, + "uses": 2 + }, + "within": { + "constant": true, + "uses": 7 + }, + "Validation": { + "constant": true, + "uses": 1 + }, + "Type": { + "constant": true, + "uses": 3 + }, + "Step": { + "constant": true, + "uses": 1 + }, + "TypeAndClear": { + "constant": true, + "uses": 1 + }, + "Callback": { + "constant": true, + "uses": 4 + }, + "SyncWaitFor": { + "constant": true, + "uses": 1 + }, + "AsyncWaitFor": { + "constant": true, + "uses": 1 + }, + "WithLoaders": { + "constant": true, + "uses": 1 + }, + "UserEventSetup": { + "constant": true, + "uses": 1 + } + } + }, + "core-exportOrder.stories.ts": { + "indexInputs": [ + { + "exportName": "Story2", + "title": "Untitled", + "tags": [], + "__id": "untitled--story-2", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Story 2" + }, + { + "exportName": "Story1", + "title": "Untitled", + "tags": [], + "__id": "untitled--story-1", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Story 1" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Story2": { + "id": "untitled--story-2", + "name": "Story 2", + "localName": null + }, + "Story1": { + "id": "untitled--story-1", + "name": "Story 1", + "localName": null + } + }, + "namedExportsOrder": ["Story2", "Story1"], + "bindings": { + "globalThis": { + "constant": true, + "uses": 1 + }, + "Story1": { + "constant": true, + "uses": 1 + }, + "Story2": { + "constant": true, + "uses": 1 + }, + "__namedExportsOrder": { + "constant": true, + "uses": 1 + } + } + }, + "core-globals.stories.ts": { + "indexInputs": [ + { + "exportName": "Inheritance", + "title": "Untitled", + "tags": ["autodocs", "play-fn"], + "__id": "untitled--inheritance", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Inheritance" + }, + { + "exportName": "Events", + "title": "Untitled", + "tags": ["autodocs", "!vitest", "!test", "play-fn"], + "__id": "untitled--events", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Events" + }, + { + "exportName": "Overrides1", + "title": "Untitled", + "tags": ["autodocs", "play-fn"], + "__id": "untitled--overrides-1", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Overrides 1" + }, + { + "exportName": "Overrides2", + "title": "Untitled", + "tags": ["autodocs", "play-fn"], + "__id": "untitled--overrides-2", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": true, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Overrides 2" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["autodocs"], + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Inheritance": { + "id": "untitled--inheritance", + "name": "Inheritance", + "tags": ["play-fn"], + "localName": null + }, + "Events": { + "id": "untitled--events", + "name": "Events", + "tags": ["!vitest", "!test", "play-fn"], + "localName": null + }, + "Overrides1": { + "id": "untitled--overrides-1", + "name": "Overrides 1", + "tags": ["play-fn"], + "localName": null + }, + "Overrides2": { + "id": "untitled--overrides-2", + "name": "Overrides 2", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 2 + }, + "expect": { + "constant": true, + "uses": 3 + }, + "within": { + "constant": true, + "uses": 6 + }, + "Inheritance": { + "constant": true, + "uses": 1 + }, + "Events": { + "constant": true, + "uses": 1 + }, + "Overrides1": { + "constant": true, + "uses": 1 + }, + "Overrides2": { + "constant": true, + "uses": 1 + } + } + }, + "core-indexer.stories.ts": { + "indexInputs": [ + { + "exportName": "Default", + "title": "Untitled", + "metaId": "indexer-custom-meta-id", + "tags": ["play-fn"], + "__id": "indexer-custom-meta-id--default", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Default" + }, + { + "exportName": "CustomParametersId", + "title": "Untitled", + "metaId": "indexer-custom-meta-id", + "tags": ["play-fn"], + "__id": "custom-id", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Custom Parameters Id" + } + ], + "meta": { + "title": "Untitled", + "id": "indexer-custom-meta-id", + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Default": { + "id": "indexer-custom-meta-id--default", + "name": "Default", + "tags": ["play-fn"], + "localName": null + }, + "CustomParametersId": { + "id": "custom-id", + "name": "Custom Parameters Id", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 2 + }, + "Default": { + "constant": true, + "uses": 1 + }, + "CustomParametersId": { + "constant": true, + "uses": 1 + } + } + }, + "core-interleavedExports.stories.ts": { + "indexInputs": [ + { + "exportName": "Story1", + "title": "Untitled", + "tags": [], + "__id": "untitled--story-1", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Story 1" + }, + { + "exportName": "Story2", + "title": "Untitled", + "tags": [], + "__id": "untitled--story-2", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Story 2" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Story1": { + "id": "untitled--story-1", + "name": "Story 1", + "localName": null + }, + "Story2": { + "id": "untitled--story-2", + "name": "Story 2", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "globalThis": { + "constant": true, + "uses": 1 + }, + "Story1": { + "constant": true, + "uses": 1 + }, + "Story2": { + "constant": true, + "uses": 1 + } + } + }, + "core-moduleMocking.stories.ts": { + "indexInputs": [ + { + "exportName": "Basic", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--basic", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": true, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Basic" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["play-fn"], + "component": "globalThis.__TEMPLATE_COMPONENTS__.Button", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Basic": { + "id": "untitled--basic", + "name": "Basic", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "globalThis": { + "constant": true, + "uses": 1 + }, + "foo": { + "constant": true, + "uses": 3 + }, + "expect": { + "constant": true, + "uses": 2 + }, + "fn": { + "constant": true, + "uses": 1 + }, + "isMockFunction": { + "constant": true, + "uses": 1 + }, + "mocked": { + "constant": true, + "uses": 1 + }, + "Basic": { + "constant": true, + "uses": 1 + } + } + }, + "core-mount-in-play.stories.ts": { + "indexInputs": [ + { + "exportName": "MountShouldBeDestructured", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--mount-should-be-destructured", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Mount Should Be Destructured" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Button", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "MountShouldBeDestructured": { + "id": "untitled--mount-should-be-destructured", + "name": "Mount Should Be Destructured", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "expect": { + "constant": true, + "uses": 1 + }, + "fn": { + "constant": true, + "uses": 1 + }, + "meta": { + "constant": true, + "uses": 1 + }, + "MountShouldBeDestructured": { + "constant": true, + "uses": 1 + } + } + }, + "core-names.stories.ts": { + "indexInputs": [ + { + "exportName": "PrefixAndName", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--prefix-and-name", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Prefix And Name" + }, + { + "exportName": "Prefix", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--prefix", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Prefix" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "PrefixAndName": { + "id": "untitled--prefix-and-name", + "name": "Prefix And Name", + "tags": ["play-fn"], + "localName": null + }, + "Prefix": { + "id": "untitled--prefix", + "name": "Prefix", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 2 + }, + "PrefixAndName": { + "constant": true, + "uses": 1 + }, + "Prefix": { + "constant": true, + "uses": 1 + } + } + }, + "core-parameters.stories.ts": { + "indexInputs": [ + { + "exportName": "Inheritance", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--inheritance", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Inheritance" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Inheritance": { + "id": "untitled--inheritance", + "name": "Inheritance", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 1 + }, + "within": { + "constant": true, + "uses": 1 + }, + "Inheritance": { + "constant": true, + "uses": 1 + } + } + }, + "core-tags-add.stories.ts": { + "indexInputs": [ + { + "exportName": "Inheritance", + "title": "Untitled", + "tags": ["!dev", "!autodocs", "!test", "story-one", "!vitest", "play-fn"], + "__id": "untitled--inheritance", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Inheritance" + }, + { + "exportName": "Dev", + "title": "Untitled", + "tags": ["!dev", "!autodocs", "!test", "dev"], + "__id": "untitled--dev", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Dev" + }, + { + "exportName": "Autodocs", + "title": "Untitled", + "tags": ["!dev", "!autodocs", "!test", "autodocs"], + "__id": "untitled--autodocs", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Autodocs" + }, + { + "exportName": "Test", + "title": "Untitled", + "tags": ["!dev", "!autodocs", "!test", "test"], + "__id": "untitled--test", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Test" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["!dev", "!autodocs", "!test"], + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Inheritance": { + "id": "untitled--inheritance", + "name": "Inheritance", + "tags": ["story-one", "!vitest", "play-fn"], + "localName": null + }, + "Dev": { + "id": "untitled--dev", + "name": "Dev", + "tags": ["dev"], + "localName": null + }, + "Autodocs": { + "id": "untitled--autodocs", + "name": "Autodocs", + "tags": ["autodocs"], + "localName": null + }, + "Test": { + "id": "untitled--test", + "name": "Test", + "tags": ["test"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 1 + }, + "within": { + "constant": true, + "uses": 1 + }, + "Inheritance": { + "constant": true, + "uses": 1 + }, + "Dev": { + "constant": true, + "uses": 1 + }, + "Autodocs": { + "constant": true, + "uses": 1 + }, + "Test": { + "constant": true, + "uses": 1 + } + } + }, + "core-tags-config.stories.ts": { + "indexInputs": [ + { + "exportName": "Inheritance", + "title": "Untitled", + "tags": ["component-one", "component-two", "autodocs", "story-one", "!vitest", "play-fn"], + "__id": "untitled--inheritance", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Inheritance" + }, + { + "exportName": "DocsOnly", + "title": "Untitled", + "tags": ["component-one", "component-two", "autodocs", "docs-only"], + "__id": "untitled--docs-only", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Docs Only" + }, + { + "exportName": "TestOnly", + "title": "Untitled", + "tags": ["component-one", "component-two", "autodocs", "test-only"], + "__id": "untitled--test-only", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Test Only" + }, + { + "exportName": "DevOnly", + "title": "Untitled", + "tags": ["component-one", "component-two", "autodocs", "dev-only"], + "__id": "untitled--dev-only", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Dev Only" + }, + { + "exportName": "TagRemoval", + "title": "Untitled", + "tags": ["component-one", "component-two", "autodocs", "!component-two"], + "__id": "untitled--tag-removal", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Tag Removal" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["component-one", "component-two", "autodocs"], + "component": "globalThis.__TEMPLATE_COMPONENTS__.Pre", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Inheritance": { + "id": "untitled--inheritance", + "name": "Inheritance", + "tags": ["story-one", "!vitest", "play-fn"], + "localName": null + }, + "DocsOnly": { + "id": "untitled--docs-only", + "name": "Docs Only", + "tags": ["docs-only"], + "localName": null + }, + "TestOnly": { + "id": "untitled--test-only", + "name": "Test Only", + "tags": ["test-only"], + "localName": null + }, + "DevOnly": { + "id": "untitled--dev-only", + "name": "Dev Only", + "tags": ["dev-only"], + "localName": null + }, + "TagRemoval": { + "id": "untitled--tag-removal", + "name": "Tag Removal", + "tags": ["!component-two"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "PartialStoryFn": { + "constant": true, + "uses": 0 + }, + "PlayFunctionContext": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "globalThis": { + "constant": true, + "uses": 1 + }, + "expect": { + "constant": true, + "uses": 1 + }, + "within": { + "constant": true, + "uses": 1 + }, + "Inheritance": { + "constant": true, + "uses": 1 + }, + "DocsOnly": { + "constant": true, + "uses": 1 + }, + "TestOnly": { + "constant": true, + "uses": 1 + }, + "DevOnly": { + "constant": true, + "uses": 1 + }, + "TagRemoval": { + "constant": true, + "uses": 1 + } + } + }, + "edge-legacy.stories.tsx": { + "indexInputs": [ + { + "rawComponentPath": "./Button", + "exportName": "Bound", + "title": "Edge/Legacy", + "tags": ["autodocs"], + "__id": "edge-legacy--bound", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Bound story" + }, + { + "rawComponentPath": "./Button", + "exportName": "Cast", + "title": "Edge/Legacy", + "tags": ["autodocs"], + "__id": "edge-legacy--cast", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Cast" + }, + { + "rawComponentPath": "./Button", + "exportName": "WithPlay", + "title": "Edge/Legacy", + "tags": ["autodocs", "play-fn"], + "__id": "edge-legacy--with-play", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": true, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "With Play" + }, + { + "rawComponentPath": "./Button", + "exportName": "Declared", + "title": "Edge/Legacy", + "tags": ["autodocs"], + "__id": "edge-legacy--declared", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Declared" + }, + { + "rawComponentPath": "./Button", + "exportName": "Alias", + "title": "Edge/Legacy", + "tags": ["autodocs"], + "__id": "edge-legacy--alias", + "__stats": { + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Alias" + } + ], + "meta": { + "title": "Edge/Legacy", + "id": null, + "tags": ["autodocs"], + "component": "Button", + "includeStories": { + "regex": "^[A-Z]", + "flags": "" + }, + "excludeStories": ["Helper"], + "isFactory": false + }, + "stories": { + "Bound": { + "id": "edge-legacy--bound", + "name": "Bound story", + "localName": null + }, + "Cast": { + "id": "edge-legacy--cast", + "name": "Cast", + "localName": null + }, + "WithPlay": { + "id": "edge-legacy--with-play", + "name": "With Play", + "tags": ["play-fn"], + "localName": null + }, + "Declared": { + "id": "edge-legacy--declared", + "name": "Declared", + "localName": null + }, + "Alias": { + "id": "edge-legacy--alias", + "name": "Alias", + "localName": "Local" + } + }, + "namedExportsOrder": null, + "bindings": { + "Meta": { + "constant": true, + "uses": 1 + }, + "StoryFn": { + "constant": true, + "uses": 0 + }, + "StoryObj": { + "constant": true, + "uses": 1 + }, + "Button": { + "constant": true, + "uses": 4 + }, + "meta": { + "constant": true, + "uses": 1 + }, + "Helper": { + "constant": true, + "uses": 1 + }, + "Template": { + "constant": true, + "uses": 3 + }, + "Bound": { + "constant": true, + "uses": 4 + }, + "Cast": { + "constant": true, + "uses": 2 + }, + "WithPlay": { + "constant": true, + "uses": 1 + }, + "Declared": { + "constant": true, + "uses": 1 + }, + "Local": { + "constant": true, + "uses": 1 + } + } + }, + "edge-typescript.stories.ts": { + "error": true + }, + "errors-NoStories.stories.ts": { + "indexInputs": [], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "component", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": {}, + "namedExportsOrder": null, + "bindings": { + "component": { + "constant": true, + "uses": 1 + } + } + }, + "react-csf1.stories.tsx": { + "indexInputs": [ + { + "exportName": "Hello1", + "title": "Untitled", + "tags": [], + "__id": "untitled--hello-1", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hello 1" + }, + { + "exportName": "Hello2", + "title": "Untitled", + "tags": [], + "__id": "untitled--hello-2", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hello 2" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "{}", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Hello1": { + "id": "untitled--hello-1", + "name": "Hello 1", + "localName": null + }, + "Hello2": { + "id": "untitled--hello-2", + "name": "Hello 2", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "React": { + "constant": true, + "uses": 0 + }, + "Hello1": { + "constant": true, + "uses": 1 + }, + "Hello2": { + "constant": true, + "uses": 1 + } + } + }, + "react-csf2.stories.tsx": { + "indexInputs": [ + { + "exportName": "Hello1", + "title": "Untitled", + "tags": [], + "__id": "untitled--hello-1", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hello 1" + }, + { + "exportName": "Hello2", + "title": "Untitled", + "tags": [], + "__id": "untitled--hello-2", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hello 2" + }, + { + "exportName": "Hello3", + "title": "Untitled", + "tags": [], + "__id": "untitled--hello-3", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": true, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hello 3" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "{}", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Hello1": { + "id": "untitled--hello-1", + "name": "Hello 1", + "localName": null + }, + "Hello2": { + "id": "untitled--hello-2", + "name": "Hello 2", + "localName": null + }, + "Hello3": { + "id": "untitled--hello-3", + "name": "Hello 3", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "React": { + "constant": true, + "uses": 0 + }, + "Template": { + "constant": true, + "uses": 4 + }, + "Hello1": { + "constant": true, + "uses": 1 + }, + "Hello2": { + "constant": true, + "uses": 1 + }, + "Hello3": { + "constant": true, + "uses": 1 + } + } + }, + "react-csf4.stories.tsx": { + "indexInputs": [ + { + "exportName": "Story", + "title": "Untitled", + "tags": [], + "__id": "untitled--story", + "__stats": { + "factory": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Story" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "globalThis.__TEMPLATE_COMPONENTS__.Button", + "includeStories": null, + "excludeStories": null, + "isFactory": true + }, + "stories": { + "Story": { + "id": "untitled--story", + "name": "Story", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "preview": { + "constant": true, + "uses": 1 + }, + "meta": { + "constant": true, + "uses": 1 + }, + "Story": { + "constant": true, + "uses": 1 + } + } + }, + "react-decorators.stories.tsx": { + "indexInputs": [ + { + "exportName": "All", + "title": "Untitled", + "tags": ["autodocs"], + "__id": "untitled--all", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "All" + }, + { + "exportName": "Context", + "title": "Untitled", + "tags": ["autodocs"], + "__id": "untitled--context", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Context" + }, + { + "exportName": "AllowUseStateInRender", + "title": "Untitled", + "tags": ["autodocs", "!vitest", "play-fn"], + "__id": "untitled--allow-use-state-in-render", + "__stats": { + "factory": false, + "play": true, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Allow Use State In Render" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["autodocs"], + "component": "Component", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "All": { + "id": "untitled--all", + "name": "All", + "localName": null + }, + "Context": { + "id": "untitled--context", + "name": "Context", + "localName": null + }, + "AllowUseStateInRender": { + "id": "untitled--allow-use-state-in-render", + "name": "Allow Use State In Render", + "tags": ["!vitest", "play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "FC": { + "constant": true, + "uses": 0 + }, + "React": { + "constant": true, + "uses": 0 + }, + "createContext": { + "constant": true, + "uses": 1 + }, + "useContext": { + "constant": true, + "uses": 1 + }, + "useState": { + "constant": true, + "uses": 1 + }, + "Meta": { + "constant": true, + "uses": 1 + }, + "StoryObj": { + "constant": true, + "uses": 0 + }, + "useParameter": { + "constant": true, + "uses": 1 + }, + "Component": { + "constant": true, + "uses": 2 + }, + "All": { + "constant": true, + "uses": 1 + }, + "TestContext": { + "constant": true, + "uses": 3 + }, + "Context": { + "constant": true, + "uses": 1 + }, + "AllowUseStateInRender": { + "constant": true, + "uses": 1 + } + } + }, + "react-js-argtypes.stories.jsx": { + "indexInputs": [ + { + "rawComponentPath": "./docgen-components/js-function-component-inline-defaults/input.jsx", + "exportName": "JsFunctionComponentInlineDefaults", + "title": "Untitled", + "tags": ["!vitest"], + "__id": "untitled--js-function-component-inline-defaults", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": true, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Js Function Component Inline Defaults" + }, + { + "rawComponentPath": "./docgen-components/js-function-component-inline-defaults/input.jsx", + "exportName": "JsFunctionComponentInlineDefaultsNoPropTypes", + "title": "Untitled", + "tags": ["!vitest"], + "__id": "untitled--js-function-component-inline-defaults-no-prop-types", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": true, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Js Function Component Inline Defaults No Prop Types" + }, + { + "rawComponentPath": "./docgen-components/js-function-component-inline-defaults/input.jsx", + "exportName": "JsDefaultValues", + "title": "Untitled", + "tags": ["!vitest"], + "__id": "untitled--js-default-values", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": true, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Js Default Values" + }, + { + "rawComponentPath": "./docgen-components/js-function-component-inline-defaults/input.jsx", + "exportName": "JsReactMemo", + "title": "Untitled", + "tags": ["!vitest"], + "__id": "untitled--js-react-memo", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": true, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Js React Memo" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["!vitest"], + "component": "JsFunctionComponentInlineDefaultsComponent", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "JsFunctionComponentInlineDefaults": { + "id": "untitled--js-function-component-inline-defaults", + "name": "Js Function Component Inline Defaults", + "localName": null + }, + "JsFunctionComponentInlineDefaultsNoPropTypes": { + "id": "untitled--js-function-component-inline-defaults-no-prop-types", + "name": "Js Function Component Inline Defaults No Prop Types", + "localName": null + }, + "JsDefaultValues": { + "id": "untitled--js-default-values", + "name": "Js Default Values", + "localName": null + }, + "JsReactMemo": { + "id": "untitled--js-react-memo", + "name": "Js React Memo", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "React": { + "constant": true, + "uses": 0 + }, + "useState": { + "constant": true, + "uses": 1 + }, + "ArgsTable": { + "constant": true, + "uses": 1 + }, + "PropTypes": { + "constant": true, + "uses": 1 + }, + "storyNameFromExport": { + "constant": true, + "uses": 1 + }, + "getService": { + "constant": true, + "uses": 1 + }, + "inferControls": { + "constant": true, + "uses": 1 + }, + "ThemeProvider": { + "constant": true, + "uses": 2 + }, + "convert": { + "constant": true, + "uses": 1 + }, + "themes": { + "constant": true, + "uses": 1 + }, + "JsReactMemoComponent": { + "constant": true, + "uses": 2 + }, + "JsDefaultValuesComponent": { + "constant": true, + "uses": 2 + }, + "JsFunctionComponentInlineDefaultsNoPropTypesComponent": { + "constant": true, + "uses": 2 + }, + "JsFunctionComponentInlineDefaultsComponent": { + "constant": true, + "uses": 3 + }, + "isVite": { + "constant": true, + "uses": 1 + }, + "ArgsStory": { + "constant": true, + "uses": 2 + }, + "JsFunctionComponentInlineDefaults": { + "constant": true, + "uses": 1 + }, + "JsFunctionComponentInlineDefaultsNoPropTypes": { + "constant": true, + "uses": 1 + }, + "JsDefaultValues": { + "constant": true, + "uses": 1 + }, + "JsReactMemo": { + "constant": true, + "uses": 1 + } + } + }, + "react-mount-in-play.stories.tsx": { + "indexInputs": [ + { + "exportName": "Basic", + "title": "Untitled", + "tags": ["play-fn"], + "__id": "untitled--basic", + "__stats": { + "factory": false, + "play": true, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": true, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Basic" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": null, + "component": "Button", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "Basic": { + "id": "untitled--basic", + "name": "Basic", + "tags": ["play-fn"], + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "FC": { + "constant": true, + "uses": 0 + }, + "React": { + "constant": true, + "uses": 0 + }, + "StoryObj": { + "constant": true, + "uses": 0 + }, + "Button": { + "constant": true, + "uses": 2 + }, + "Basic": { + "constant": true, + "uses": 1 + } + } + }, + "react-test-fn.stories.tsx": { + "indexInputs": [ + { + "exportName": "Default", + "title": "Untitled", + "tags": ["some-tag"], + "__id": "untitled--default", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Default" + }, + { + "exportName": "Default", + "title": "Untitled", + "tags": ["some-tag", "!autodocs", "test-fn"], + "__id": "untitled--default:simple", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "test", + "name": "simple", + "parent": "untitled--default", + "parentName": "Default" + }, + { + "exportName": "Default", + "title": "Untitled", + "tags": ["some-tag", "!autodocs", "test-fn"], + "__id": "untitled--default:referring-to-function-in-file", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "test", + "name": "referring to function in file", + "parent": "untitled--default", + "parentName": "Default" + }, + { + "exportName": "Default", + "title": "Untitled", + "tags": ["some-tag", "!autodocs", "!test", "test-fn"], + "__id": "untitled--default:with-overrides", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "test", + "name": "with overrides", + "parent": "untitled--default", + "parentName": "Default" + }, + { + "exportName": "Default", + "title": "Untitled", + "tags": ["some-tag", "!autodocs", "test-fn"], + "__id": "untitled--default:with-play-function", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "test", + "name": "with play function", + "parent": "untitled--default", + "parentName": "Default" + }, + { + "exportName": "Extended", + "title": "Untitled", + "tags": ["some-tag"], + "__id": "untitled--extended", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Extended" + }, + { + "exportName": "Extended", + "title": "Untitled", + "tags": ["some-tag", "!autodocs", "test-fn"], + "__id": "untitled--extended:should-have-extended-args", + "__stats": { + "factory": true, + "tests": true, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": true, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "test", + "name": "should have extended args", + "parent": "untitled--extended", + "parentName": "Extended" + } + ], + "meta": { + "title": "Untitled", + "id": null, + "tags": ["some-tag"], + "component": "Button", + "includeStories": null, + "excludeStories": null, + "isFactory": true + }, + "stories": { + "Default": { + "id": "untitled--default", + "name": "Default", + "localName": null + }, + "Extended": { + "id": "untitled--extended", + "name": "Extended", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "React": { + "constant": true, + "uses": 0 + }, + "StoryContext": { + "constant": true, + "uses": 0 + }, + "expect": { + "constant": true, + "uses": 7 + }, + "fn": { + "constant": true, + "uses": 1 + }, + "preview": { + "constant": true, + "uses": 1 + }, + "Button": { + "constant": true, + "uses": 1 + }, + "meta": { + "constant": true, + "uses": 1 + }, + "Default": { + "constant": true, + "uses": 6 + }, + "doTest": { + "constant": true, + "uses": 1 + }, + "Extended": { + "constant": true, + "uses": 2 + } + } + }, + "shapes-angular-button.stories.ts": { + "indexInputs": [ + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "OwnTemplate", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--own-template", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Own Template" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "RenderTemplate", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--render-template", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Render Template" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "MethodRender", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--method-render", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Method Render" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "ArgsToTemplateWrapper", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--args-to-template-wrapper", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Args To Template Wrapper" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "ArgsToTemplateInclude", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--args-to-template-include", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Args To Template Include" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "SlotInterpolation", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--slot-interpolation", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Slot Interpolation" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "ModuleConstant", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--module-constant", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Module Constant" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "HoistedTemplate", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--hoisted-template", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hoisted Template" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "QuotedArgs", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--quoted-args", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Quoted Args" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "ImportedTemplate", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--imported-template", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Imported Template" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "SpreadShadowedRender", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--spread-shadowed-render", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Spread Shadowed Render" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "SpreadArgs", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--spread-args", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Spread Args" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "AccessorRender", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--accessor-render", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Accessor Render" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "MultiExitRender", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--multi-exit-render", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Multi Exit Render" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "DynamicOptions", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--dynamic-options", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Dynamic Options" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "TemplateBind", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--template-bind", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Template Bind" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "MemberAssignedRender", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--member-assigned-render", + "__stats": { + "factory": false, + "play": false, + "render": true, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Member Assigned Render" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "ReassignedTemplate", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--reassigned-template", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Reassigned Template" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "SpreadStoryArgs", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--spread-story-args", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Spread Story Args" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "HoistedArgs", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--hoisted-args", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Hoisted Args" + }, + { + "rawComponentPath": "./shape-button.component.ts", + "exportName": "SpreadCrossFileArgs", + "title": "AngularShapes/button-shapes", + "tags": [], + "__id": "angularshapes-button-shapes--spread-cross-file-args", + "__stats": { + "factory": false, + "play": false, + "render": false, + "loaders": false, + "beforeEach": false, + "globals": false, + "tags": false, + "storyFn": false, + "mount": false, + "moduleMock": false + }, + "type": "story", + "subtype": "story", + "name": "Spread Cross File Args" + } + ], + "meta": { + "title": "AngularShapes/button-shapes", + "id": null, + "tags": null, + "component": "ShapeButtonComponent", + "includeStories": null, + "excludeStories": null, + "isFactory": false + }, + "stories": { + "OwnTemplate": { + "id": "angularshapes-button-shapes--own-template", + "name": "Own Template", + "localName": null + }, + "RenderTemplate": { + "id": "angularshapes-button-shapes--render-template", + "name": "Render Template", + "localName": null + }, + "MethodRender": { + "id": "angularshapes-button-shapes--method-render", + "name": "Method Render", + "localName": null + }, + "ArgsToTemplateWrapper": { + "id": "angularshapes-button-shapes--args-to-template-wrapper", + "name": "Args To Template Wrapper", + "localName": null + }, + "ArgsToTemplateInclude": { + "id": "angularshapes-button-shapes--args-to-template-include", + "name": "Args To Template Include", + "localName": null + }, + "SlotInterpolation": { + "id": "angularshapes-button-shapes--slot-interpolation", + "name": "Slot Interpolation", + "localName": null + }, + "ModuleConstant": { + "id": "angularshapes-button-shapes--module-constant", + "name": "Module Constant", + "localName": null + }, + "HoistedTemplate": { + "id": "angularshapes-button-shapes--hoisted-template", + "name": "Hoisted Template", + "localName": null + }, + "QuotedArgs": { + "id": "angularshapes-button-shapes--quoted-args", + "name": "Quoted Args", + "localName": null + }, + "ImportedTemplate": { + "id": "angularshapes-button-shapes--imported-template", + "name": "Imported Template", + "localName": null + }, + "SpreadShadowedRender": { + "id": "angularshapes-button-shapes--spread-shadowed-render", + "name": "Spread Shadowed Render", + "localName": null + }, + "SpreadArgs": { + "id": "angularshapes-button-shapes--spread-args", + "name": "Spread Args", + "localName": null + }, + "AccessorRender": { + "id": "angularshapes-button-shapes--accessor-render", + "name": "Accessor Render", + "localName": null + }, + "MultiExitRender": { + "id": "angularshapes-button-shapes--multi-exit-render", + "name": "Multi Exit Render", + "localName": null + }, + "DynamicOptions": { + "id": "angularshapes-button-shapes--dynamic-options", + "name": "Dynamic Options", + "localName": null + }, + "TemplateBind": { + "id": "angularshapes-button-shapes--template-bind", + "name": "Template Bind", + "localName": null + }, + "MemberAssignedRender": { + "id": "angularshapes-button-shapes--member-assigned-render", + "name": "Member Assigned Render", + "localName": null + }, + "ReassignedTemplate": { + "id": "angularshapes-button-shapes--reassigned-template", + "name": "Reassigned Template", + "localName": null + }, + "SpreadStoryArgs": { + "id": "angularshapes-button-shapes--spread-story-args", + "name": "Spread Story Args", + "localName": null + }, + "HoistedArgs": { + "id": "angularshapes-button-shapes--hoisted-args", + "name": "Hoisted Args", + "localName": null + }, + "SpreadCrossFileArgs": { + "id": "angularshapes-button-shapes--spread-cross-file-args", + "name": "Spread Cross File Args", + "localName": null + } + }, + "namedExportsOrder": null, + "bindings": { + "ShapeButtonComponent": { + "constant": true, + "uses": 1 + }, + "IMPORTED_TEMPLATE": { + "constant": true, + "uses": 1 + }, + "BaseStories": { + "constant": true, + "uses": 1 + }, + "argsToTemplate": { + "constant": true, + "uses": 3 + }, + "HOISTED_TEMPLATE": { + "constant": true, + "uses": 1 + }, + "HEADER": { + "constant": true, + "uses": 1 + }, + "INCLUDES": { + "constant": true, + "uses": 1 + }, + "base": { + "constant": true, + "uses": 1 + }, + "Template": { + "constant": true, + "uses": 2 + }, + "MUTABLE_TEMPLATE": { + "constant": false, + "uses": 2 + }, + "extraArgs": { + "constant": true, + "uses": 1 + }, + "OwnTemplate": { + "constant": true, + "uses": 1 + }, + "RenderTemplate": { + "constant": true, + "uses": 1 + }, + "MethodRender": { + "constant": true, + "uses": 1 + }, + "ArgsToTemplateWrapper": { + "constant": true, + "uses": 1 + }, + "ArgsToTemplateInclude": { + "constant": true, + "uses": 1 + }, + "SlotInterpolation": { + "constant": true, + "uses": 1 + }, + "ModuleConstant": { + "constant": true, + "uses": 1 + }, + "HoistedTemplate": { + "constant": true, + "uses": 1 + }, + "QuotedArgs": { + "constant": true, + "uses": 2 + }, + "ImportedTemplate": { + "constant": true, + "uses": 1 + }, + "SpreadShadowedRender": { + "constant": true, + "uses": 1 + }, + "SpreadArgs": { + "constant": true, + "uses": 1 + }, + "AccessorRender": { + "constant": true, + "uses": 1 + }, + "MultiExitRender": { + "constant": true, + "uses": 1 + }, + "DynamicOptions": { + "constant": true, + "uses": 1 + }, + "TemplateBind": { + "constant": true, + "uses": 2 + }, + "MemberAssignedRender": { + "constant": true, + "uses": 2 + }, + "ReassignedTemplate": { + "constant": true, + "uses": 1 + }, + "SpreadStoryArgs": { + "constant": true, + "uses": 1 + }, + "HoistedArgs": { + "constant": true, + "uses": 1 + }, + "SpreadCrossFileArgs": { + "constant": true, + "uses": 1 + } + } + } +} diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-argMapping.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-argMapping.stories.ts new file mode 100644 index 000000000000..ea77d3edfddc --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-argMapping.stories.ts @@ -0,0 +1,72 @@ +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +const arrows = { + ArrowUp: { name: 'ArrowUp' }, + ArrowDown: { name: 'ArrowDown' }, + ArrowLeft: { name: 'ArrowLeft' }, + ArrowRight: { name: 'ArrowRight' }, +}; + +const labels = { + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', +}; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => { + return storyFn({ + args: { object: { ...context.args } }, + }); + }, + ], +}; + +export const Single = { + args: { + mappingArg: 'ArrowRight', + }, + argTypes: { + mappingArg: { + options: Object.keys(arrows), + mapping: arrows, + control: { + type: 'select', + labels, + }, + }, + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toMatchObject({ + mappingArg: { name: 'ArrowRight' }, + }); + }, +}; + +export const Multiple = { + args: { + mappingArg: ['ArrowRight', 'ArrowLeft'], + }, + argTypes: { + mappingArg: { + options: Object.keys(arrows), + mapping: arrows, + control: { + type: 'multi-select', + labels, + }, + }, + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toMatchObject({ + mappingArg: [{ name: 'ArrowRight' }, { name: 'ArrowLeft' }], + }); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-args.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-args.stories.ts new file mode 100644 index 000000000000..e1760c784a3b --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-args.stories.ts @@ -0,0 +1,98 @@ +import { + RESET_STORY_ARGS, + STORY_ARGS_UPDATED, + UPDATE_STORY_ARGS, +} from 'storybook/internal/core-events'; +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +function pick(obj, keys) { + const result = {}; + for (const key of keys) { + result[key] = obj[key]; + } + return result; +} + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { + componentArg: 'componentArg', + storyArg: 'componentStoryArg', + object: { + a: 'component', + b: 'component', + }, + }, + // Compose the set of args into `object`, so the pre component only needs a single prop + // (selecting only the args specified on parameters.argNames if set) + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => { + const { argNames } = context.parameters; + const args = { ...context.args }; + const object = argNames ? pick(args, argNames) : args; + return storyFn({ args: { object } }); + }, + ], +}; + +export const Inheritance = { + args: { + storyArg: 'storyArg', + object: { + a: 'story', + }, + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + // NOTE: these stories don't test project-level args inheritance as it is too problematic + // to have an arg floating around that will apply too *all* other stories in our sandboxes. + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toEqual({ + componentArg: 'componentArg', + storyArg: 'storyArg', + object: { + a: 'story', + }, + }); + }, +}; + +export const Targets = { + args: { + a: 'a', + b: 'b', + }, + argTypes: { + a: { target: 'elsewhere' }, + }, + parameters: { argNames: ['a', 'b'] }, + play: async ({ canvasElement }: PlayFunctionContext) => { + // Check that `a` doesn't end up set + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toEqual({ + b: 'b', + }); + }, + tags: ['!vitest'], +}; + +export const Events = { + args: { + test: 'initial', + }, + parameters: { argNames: ['test'] }, + play: async ({ canvasElement, id }: PlayFunctionContext) => { + const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; + await channel.emit(RESET_STORY_ARGS, { storyId: id }); + await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve)); + + await within(canvasElement).findByText(/initial/); + + await channel.emit(UPDATE_STORY_ARGS, { storyId: id, updatedArgs: { test: 'updated' } }); + await new Promise((resolve) => channel.once(STORY_ARGS_UPDATED, resolve)); + await within(canvasElement).findByText(/updated/); + }, + // this story can't be reliably tested because the args changes results in renderPhases disrupting test runs + tags: ['!vitest', '!test'], +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-autotitle.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-autotitle.stories.ts new file mode 100644 index 000000000000..5fa47cbe32ab --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-autotitle.stories.ts @@ -0,0 +1,16 @@ +import type { PlayFunctionContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { text: 'No content' }, +}; + +export const Default = { + play: async ({ title }: PlayFunctionContext) => { + await expect(title).toBe('core/autotitle'); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-basics.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-basics.stories.ts new file mode 100644 index 000000000000..354bacc466c9 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-basics.stories.ts @@ -0,0 +1,99 @@ +import { global as globalThis } from '@storybook/global'; + +import { action } from 'storybook/actions'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { + label: 'Click Me!', + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const Basic = { + args: { onClick: action('onClick') }, +}; + +export const TypeString = { + args: { onClick: () => action('onClick')('string') }, +}; +export const TypeBoolean = { + args: { onClick: () => action('onClick')(false) }, +}; +export const TypeObject = { + args: { onClick: () => action('onClick')({}) }, +}; +export const TypeNull = { + args: { onClick: () => action('onClick')(null) }, +}; +export const TypeUndefined = { + args: { onClick: () => action('onClick')(undefined) }, +}; +export const TypeNaN = { + args: { onClick: () => action('onClick')(NaN) }, +}; +export const TypeInfinity = { + args: { onClick: () => action('onClick')(Infinity) }, +}; +export const TypeMinusInfinity = { + args: { onClick: () => action('onClick')(-Infinity) }, +}; +export const TypeNumber = { + args: { onClick: () => action('onClick')(10000) }, +}; +export const TypeGlobal = { + args: { onClick: () => action('onClick')(globalThis) }, +}; +export const TypeSymbol = { + args: { onClick: () => action('onClick')(Symbol('MySymbol')) }, +}; +export const TypeRegExp = { + args: { onClick: () => action('onClick')(new RegExp('MyRegExp')) }, +}; +export const TypeArray = { + args: { onClick: () => action('onClick')(['a', 'b', 'c']) }, +}; +export const TypeClass = { + args: { onClick: () => action('onClick')(class MyClass {}) }, +}; +export const TypeFunction = { + args: { onClick: () => action('onClick')(function MyFunction() {}) }, +}; +export const TypeMultiple = { + args: { onClick: () => action('onClick')('string', true, false, null, undefined, [], {}) }, +}; + +export const Cyclical = { + args: { + onClick: () => + action('onClick')( + (() => { + const cyclical: Record = {}; + cyclical.cyclical = cyclical; + return cyclical; + })() + ), + }, +}; + +export const Reserved = { + args: { onClick: action('delete') }, +}; + +export const OptionPersist = { + args: { onClick: action('onClick', { clearOnStoryChange: false }) }, +}; +export const OptionDepth = { + args: { onClick: action('onClick', { depth: 2 }) }, +}; + +export const Disabled = { + args: { onClick: action('onCLick') }, + parameters: { + actions: { + disabled: true, + }, + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-before-each.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-before-each.stories.ts new file mode 100644 index 000000000000..4dc557af3c87 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-before-each.stories.ts @@ -0,0 +1,43 @@ +/* eslint-disable storybook/prefer-pascal-case */ +import { expect } from 'storybook/test'; + +const meta = { + component: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { label: 'Button' }, +}; + +export default meta; + +export const before_each_and_loaders_can_extend_context = { + parameters: { chromatic: { disableSnapshot: true } }, + loaders(context) { + context.foo = ['bar']; + }, + beforeEach(context) { + context.foo = [...context.foo, 'baz']; + }, + async play({ foo }) { + await expect(foo).toEqual(['bar', 'baz']); + }, +}; + +export const context_prop_is_available = { + parameters: { chromatic: { disableSnapshot: true } }, + async play({ context, canvasElement }) { + await expect(context.canvasElement).toEqual(canvasElement); + }, +}; + +export const step_and_canvas_element_can_be_used_in_loaders_and_before_each = { + parameters: { chromatic: { disableSnapshot: true } }, + loaders({ step, canvasElement }) { + step('loaders', async () => { + await expect(canvasElement).toBeInTheDocument(); + }); + }, + beforeEach({ step, canvasElement }) { + step('before each', async () => { + await expect(canvasElement).toBeInTheDocument(); + }); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-component-test.basics.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-component-test.basics.stories.ts new file mode 100644 index 000000000000..48f4e494ed07 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-component-test.basics.stories.ts @@ -0,0 +1,132 @@ +import { global as globalThis } from '@storybook/global'; + +import { + expect, + fireEvent, + fn, + userEvent as testUserEvent, + waitFor, + waitForElementToBeRemoved, + within, +} from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Form, + args: { + onSuccess: fn(), + }, + globals: { + sb_theme: 'light', + }, + tags: ['!vitest'], +}; + +export const Validation = { + play: async (context) => { + const { args, canvasElement, step } = context; + const canvas = within(canvasElement); + + await step('Submit', async () => fireEvent.click(canvas.getByRole('button'))); + + await expect(args.onSuccess).not.toHaveBeenCalled(); + }, +}; + +export const Type = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByTestId('value'), 'foobar'); + }, +}; + +export const Step = { + play: async ({ step }) => { + await step('Enter value', Type.play); + }, +}; + +export const TypeAndClear = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await userEvent.type(canvas.getByTestId('value'), 'initial value'); + await userEvent.clear(canvas.getByTestId('value')); + await userEvent.type(canvas.getByTestId('value'), 'final value'); + }, +}; + +export const Callback = { + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Enter value', Type.play); + + await step('Submit', async () => { + await fireEvent.click(canvas.getByRole('button')); + }); + + await expect(args.onSuccess).toHaveBeenCalled(); + }, +}; + +// NOTE: of course you can use `findByText()` to implicitly waitFor, but we want +// an explicit test here +export const SyncWaitFor = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Submit form', Callback.play); + await waitFor(() => canvas.getByText('Completed!!')); + await waitForElementToBeRemoved(() => canvas.queryByText('Completed!!'), { + timeout: 2000, + }); + }, +}; + +export const AsyncWaitFor = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + await step('Submit form', Callback.play); + await waitFor(async () => canvas.getByText('Completed!!')); + await waitForElementToBeRemoved(() => canvas.queryByText('Completed!!'), { + timeout: 2000, + }); + }, +}; + +export const WithLoaders = { + loaders: [async () => new Promise((resolve) => setTimeout(resolve, 2000))], + play: async ({ step }) => { + await step('Submit form', Callback.play); + }, +}; + +const UserEventSetup = { + play: async (context) => { + const { args, canvasElement, step, userEvent } = context; + const canvas = within(canvasElement); + await step('Select and type on input using user-event v14 setup', async () => { + const input = canvas.getByRole('textbox'); + await userEvent.click(input); + await userEvent.type(input, 'Typing ...'); + }); + await step('Tab and press enter on submit button', async () => { + // Vitest's userEvent does not support pointer events, so we use storybook's + await testUserEvent.pointer([ + { keys: '[TouchA>]', target: canvas.getByRole('textbox') }, + { keys: '[/TouchA]' }, + ]); + const submitButton = await canvas.findByRole('button'); + + if (navigator.userAgent.toLowerCase().includes('firefox')) { + // user event has a few issues on firefox, therefore we do it differently + await fireEvent.click(submitButton); + } else { + await userEvent.tab(); + await userEvent.keyboard('{enter}'); + await expect(submitButton).toHaveFocus(); + } + + await expect(args.onSuccess).toHaveBeenCalled(); + }); + }, +}; + +export { UserEventSetup }; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-exportOrder.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-exportOrder.stories.ts new file mode 100644 index 000000000000..4d4ccbd05ffa --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-exportOrder.stories.ts @@ -0,0 +1,11 @@ +import { global as globalThis } from '@storybook/global'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { text: 'Check that Story2 is listed before Story1' }, +}; + +export const Story1 = {}; +export const Story2 = {}; + +export const __namedExportsOrder = ['Story2', 'Story1']; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-globals.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-globals.stories.ts new file mode 100644 index 000000000000..15bbf6acd55e --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-globals.stories.ts @@ -0,0 +1,89 @@ +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + tags: ['autodocs'], + globals: { + baz: 'bazComponentValue', + }, +}; + +export const Inheritance = { + // Compose all the globals into `object`, so the pre component only needs a single prop + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => + storyFn({ args: { object: context.globals } }), + ], + play: async ({ canvasElement }: PlayFunctionContext) => { + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toMatchObject({ + foo: 'fooValue', + bar: 'barValue', + baz: 'bazComponentValue', + }); + }, +}; + +export const Events = { + parameters: { chromatic: { disableSnapshot: true } }, + // Just pass the "foo" global to the pre + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => + storyFn({ args: { text: context.globals.foo } }), + ], + play: async ({ canvasElement }: PlayFunctionContext) => { + const channel = globalThis.__STORYBOOK_ADDONS_CHANNEL__; + await channel.emit('updateGlobals', { globals: { foo: 'fooValue' } }); + await within(canvasElement).findByText('fooValue'); + + await channel.emit('updateGlobals', { globals: { foo: 'updated' } }); + await within(canvasElement).findByText('updated'); + + // Reset it back to the original value just to avoid polluting the URL + await channel.emit('updateGlobals', { globals: { foo: 'fooValue' } }); + await within(canvasElement).findByText('fooValue'); + }, + // this story can't be reliably tested because the globals changes results in renderPhases disrupting test runs + tags: ['!vitest', '!test'], +}; + +export const Overrides1 = { + // Compose all the globals into `object`, so the pre component only needs a single prop + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => + storyFn({ args: { object: context.globals } }), + ], + globals: { + foo: 'fooOverridden1', + baz: 'bazOverridden1', + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toMatchObject({ + foo: 'fooOverridden1', + bar: 'barValue', + baz: 'bazOverridden1', + }); + }, +}; + +export const Overrides2 = { + // Compose all the globals into `object`, so the pre component only needs a single prop + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => + storyFn({ args: { object: context.globals } }), + ], + globals: { + foo: 'fooOverridden2', + baz: 'bazOverridden2', + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + await expect(JSON.parse(within(canvasElement).getByTestId('pre').innerText)).toMatchObject({ + foo: 'fooOverridden2', + bar: 'barValue', + baz: 'bazOverridden2', + }); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-indexer.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-indexer.stories.ts new file mode 100644 index 000000000000..1b03c38bd029 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-indexer.stories.ts @@ -0,0 +1,26 @@ +import type { PlayFunctionContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { text: 'Check that id assertions in interaction tests are passing' }, + id: 'indexer-custom-meta-id', +}; + +export const Default = { + play: async ({ id }: PlayFunctionContext) => { + await expect(id).toBe('indexer-custom-meta-id--default'); + }, +}; + +export const CustomParametersId = { + parameters: { + __id: 'custom-id', + }, + play: async ({ id }: PlayFunctionContext) => { + await expect(id).toBe('custom-id'); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-interleavedExports.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-interleavedExports.stories.ts new file mode 100644 index 000000000000..1307fb1faf58 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-interleavedExports.stories.ts @@ -0,0 +1,12 @@ +import { global as globalThis } from '@storybook/global'; + +import './import'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { text: 'Check that stories are processed OK' }, +}; + +export const Story1 = {}; + +export const Story2 = {}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-moduleMocking.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-moduleMocking.stories.ts new file mode 100644 index 000000000000..4fdb186a3165 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-moduleMocking.stories.ts @@ -0,0 +1,28 @@ +import { global as globalThis } from '@storybook/global'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore This alias is set in the sandbox. Using ts-ignore instead of ts-expect-error to avoid build errors in the sandbox. +import { foo } from '#utils'; +import { expect, fn, isMockFunction, mocked } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { + onClick: fn(), + label: 'Mock story', + }, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, + beforeEach: () => { + mocked(foo).mockReturnValue('mocked'); + }, + async play() { + await expect(isMockFunction(foo)).toBe(true); + await expect(foo()).toBe('mocked'); + }, +}; + +export const Basic = {}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-mount-in-play.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-mount-in-play.stories.ts new file mode 100644 index 000000000000..5a3564175ee8 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-mount-in-play.stories.ts @@ -0,0 +1,25 @@ +import { expect, fn } from 'storybook/test'; + +const meta = { component: globalThis.__TEMPLATE_COMPONENTS__.Button }; + +export default meta; + +export const MountShouldBeDestructured = { + parameters: { chromatic: { disableSnapshot: true } }, + args: { + label: 'Button', + onClick: fn(), + }, + async play(context) { + let error; + + // TODO use expect.toThrow once this issue is fixed + // https://github.com/storybookjs/storybook/issues/28406 + try { + await context.mount(); + } catch (e) { + error = e; + } + await expect(error?.name).toContain('MountMustBeDestructuredError'); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-names.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-names.stories.ts new file mode 100644 index 000000000000..f87b1f8aafa3 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-names.stories.ts @@ -0,0 +1,25 @@ +import type { PlayFunctionContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + args: { text: 'No content' }, +}; + +// Very simple stories to show what happens when one story's id is a prefix of another's +// Repro for https://github.com/storybookjs/storybook/issues/11571 + +export const PrefixAndName = { + play: async ({ name }: PlayFunctionContext) => { + await expect(name).toBe('Prefix And Name'); + }, +}; + +export const Prefix = { + play: async ({ name }: PlayFunctionContext) => { + await expect(name).toBe('Prefix'); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-parameters.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-parameters.stories.ts new file mode 100644 index 000000000000..7c8c1e00c29b --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-parameters.stories.ts @@ -0,0 +1,48 @@ +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + parameters: { + componentParameter: 'componentParameter', + storyParameter: 'componentStoryParameter', // Checking this gets overridden + storyObject: { + a: 'component', + b: 'component', + }, + }, + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => { + const { projectParameter, componentParameter, storyParameter, storyObject } = + context.parameters; + return storyFn({ + args: { object: { projectParameter, componentParameter, storyParameter, storyObject } }, + }); + }, + ], +}; + +export const Inheritance = { + parameters: { + storyParameter: 'storyParameter', + storyObject: { + a: 'story', + }, + }, + play: async ({ canvasElement }: PlayFunctionContext) => { + const canvas = within(canvasElement); + await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ + projectParameter: 'projectParameter', + componentParameter: 'componentParameter', + storyParameter: 'storyParameter', + storyObject: { + a: 'story', + b: 'component', + c: 'project', + }, + }); + }, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-tags-add.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-tags-add.stories.ts new file mode 100644 index 000000000000..8f330a5eca0e --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-tags-add.stories.ts @@ -0,0 +1,41 @@ +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + tags: ['!dev', '!autodocs', '!test'], + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => { + return storyFn({ + args: { object: { tags: context.tags } }, + }); + }, + ], + parameters: { chromatic: { disableSnapshot: true } }, +}; + +export const Inheritance = { + tags: ['story-one', '!vitest'], + play: async ({ canvasElement }: PlayFunctionContext) => { + const canvas = within(canvasElement); + await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ + tags: ['story-one'], + }); + }, + parameters: { chromatic: { disableSnapshot: false } }, +}; + +export const Dev = { + tags: ['dev'], +}; + +export const Autodocs = { + tags: ['autodocs'], +}; + +export const Test = { + tags: ['test'], +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/core-tags-config.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/core-tags-config.stories.ts new file mode 100644 index 000000000000..73241353cb7f --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/core-tags-config.stories.ts @@ -0,0 +1,45 @@ +import type { PartialStoryFn, PlayFunctionContext, StoryContext } from 'storybook/internal/types'; + +import { global as globalThis } from '@storybook/global'; + +import { expect, within } from 'storybook/test'; + +export default { + component: globalThis.__TEMPLATE_COMPONENTS__.Pre, + tags: ['component-one', 'component-two', 'autodocs'], + decorators: [ + (storyFn: PartialStoryFn, context: StoryContext) => { + return storyFn({ + args: { object: { tags: context.tags } }, + }); + }, + ], + parameters: { chromatic: { disableSnapshot: true } }, +}; + +export const Inheritance = { + tags: ['story-one', '!vitest'], + play: async ({ canvasElement }: PlayFunctionContext) => { + const canvas = within(canvasElement); + await expect(JSON.parse(canvas.getByTestId('pre').innerText)).toEqual({ + tags: ['dev', 'test', 'component-one', 'component-two', 'autodocs', 'story-one'], + }); + }, + parameters: { chromatic: { disableSnapshot: false } }, +}; + +export const DocsOnly = { + tags: ['docs-only'], +}; + +export const TestOnly = { + tags: ['test-only'], +}; + +export const DevOnly = { + tags: ['dev-only'], +}; + +export const TagRemoval = { + tags: ['!component-two'], +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/edge-legacy.stories.tsx b/code/core/src/csf-tools/__corpus__/inputs/edge-legacy.stories.tsx new file mode 100644 index 000000000000..4195503fe8d4 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/edge-legacy.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +import { Button } from './Button'; + +const meta = { + title: 'Edge/Legacy', + component: Button, + includeStories: /^[A-Z]/, + excludeStories: ['Helper'], + tags: ['autodocs'], +} satisfies Meta; +export default meta; + +export const Helper = () => null; + +const Template: StoryFn = (args) => `), + args: { label: identity('Hi') }, + parameters: { + layout: 'centered', + docs: { + source: { + code: `line1 +line2`, + }, + }, + }, +} satisfies Meta as Meta; + +export const Primary: StoryObj = { args: { label: 'Primary' } }; + +export const Sized = { + args: { label: `size ${size}` }, + tags: ['!dev'], +} satisfies StoryObj; + +export const Composed: StoryObj = { ...Primary, name: 'Composed name' }; diff --git a/code/core/src/csf-tools/__corpus__/inputs/errors-NoStories.stories.ts b/code/core/src/csf-tools/__corpus__/inputs/errors-NoStories.stories.ts new file mode 100644 index 000000000000..bb9598181ded --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/errors-NoStories.stories.ts @@ -0,0 +1,5 @@ +/* eslint-disable storybook/story-exports */ +const component = {}; +export default { + component, +}; diff --git a/code/core/src/csf-tools/__corpus__/inputs/react-csf1.stories.tsx b/code/core/src/csf-tools/__corpus__/inputs/react-csf1.stories.tsx new file mode 100644 index 000000000000..eff0dfceb66b --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/react-csf1.stories.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export default { + component: {}, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const Hello1 = () =>
Hello1
; +export const Hello2 = () =>
Hello2
; diff --git a/code/core/src/csf-tools/__corpus__/inputs/react-csf2.stories.tsx b/code/core/src/csf-tools/__corpus__/inputs/react-csf2.stories.tsx new file mode 100644 index 000000000000..f2b7ff0d63bb --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/react-csf2.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export default { + component: {}, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +const Template = ({ label }: { label: string }) =>
{label}
; +Template.args = { label: 'Hello' }; + +export const Hello1 = Template.bind({}); + +export const Hello2 = Template.bind({}); + +export const Hello3 = Template.bind({}); diff --git a/code/core/src/csf-tools/__corpus__/inputs/react-csf4.stories.tsx b/code/core/src/csf-tools/__corpus__/inputs/react-csf4.stories.tsx new file mode 100644 index 000000000000..2a68cfef500b --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/react-csf4.stories.tsx @@ -0,0 +1,13 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- we can't expect error as it is an error in development but it isn't sandbox +// @ts-ignore only present in sandbox +import preview from '#.storybook/preview'; + +const meta = preview.meta({ + // @ts-expect-error fix globalThis.__TEMPLATE_COMPONENTS__ type not existing later + component: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { + label: 'Hello world!', + }, +}); + +export const Story = meta.story({}); diff --git a/code/core/src/csf-tools/__corpus__/inputs/react-decorators.stories.tsx b/code/core/src/csf-tools/__corpus__/inputs/react-decorators.stories.tsx new file mode 100644 index 000000000000..340ffaeb6606 --- /dev/null +++ b/code/core/src/csf-tools/__corpus__/inputs/react-decorators.stories.tsx @@ -0,0 +1,80 @@ +import type { FC } from 'react'; +import React, { createContext, useContext, useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { useParameter } from 'storybook/preview-api'; + +const Component: FC = () =>

Story

; + +export default { + component: Component, + tags: ['autodocs'], + decorators: [ + (Story) => ( + <> +

Component Decorator

+ + + ), + ], +} as Meta; + +export const All: StoryObj = { + decorators: [ + (Story) => ( + <> +

Local Decorator

+ + + ), + ], +}; + +// This story should not error +// See https://github.com/storybookjs/storybook/issues/21900 +const TestContext = createContext(false); +export const Context: StoryObj = { + parameters: { docs: { source: { excludeDecorators: true } } }, + decorators: [ + (Story) => ( + + + + ), + ], + render: function Render() { + const value = useContext(TestContext); + + if (!value) { + throw new Error('TestContext not set, decorator did not run!'); + } + return

Story

; + }, +}; + +/** + * This story demonstrates is a regression test for this issue with React hooks in Storybook + * (https://github.com/storybookjs/storybook/issues/29189) + * + * Which happened when a decorator was using storybook hooks, and the render react hooks. + */ +export const AllowUseStateInRender: StoryObj = { + render: () => { + const [count, setCount] = useState(0); + const Button = (globalThis as any).__TEMPLATE_COMPONENTS__.Button; + return ; +}; + +export default { + component: Button, +}; + +export const Basic: StoryObj = { + args: { + disabled: true, + }, + async play({ mount, args }) { + await mount('` | children with `slot="actions"` | +| CSS part | `-part` | `'panel-part': 'color: rebeccapurple;'` | a `::part(panel)` rule | +| CSS state | `-state` | `'active-state': 'opacity: 0.5;'` | a `:state(active)` rule | +| CSS custom property | `--name` | `'--panel-color': 'teal'` | `style="--panel-color: teal"` | -#### Story snippets +To control the markup yourself, write a `render` function with Lit's `html`: -With server-side docgen, Storybook generates snippets for the Code panel and the `docs-show` MCP tool from the story file. Args stories become one element with attributes from args: booleans print bare, objects print as JSON, CSS custom properties go into `style`, parts and states go into a leading ` - + Body text @@ -106,9 +103,9 @@ Some things cannot be shown as HTML, such as a Lit directive or a property that A [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest) is optional. If your project already has one, you can tell Storybook where to find it. Storybook then reads the elements in the manifest from the manifest instead of from your source code: - + -If you do not set this option, Storybook uses the `customElements` field in your `package.json`, if it exists. +If you do not set this option, Storybook uses the `customElements` field in your `package.json`, if it exists. This option replaces the runtime `setCustomElementsManifest()` call, which is deprecated and will be removed in Storybook 12. If you generate the manifest with `@custom-elements-manifest/analyzer`, enable the plugin for your library, such as `--litelement` for Lit. Without it, the manifest does not include attributes that you declare with decorators. @@ -121,7 +118,7 @@ If you register Stencil components with `defineCustomElements()` from the genera For Stencil 4.26 and later: ```ts title="stencil.config.ts" -import type { Config } from "@stencil/core"; +import type { Config } from '@stencil/core'; export const config: Config = { extras: { From a29f0f47c4fbdd447bf93099ca76cda0a3c195e2 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 10:29:59 +0200 Subject: [PATCH 252/326] Apply suggestion from @Sidnioulz --- code/core/src/common/js-package-manager/PNPMProxy.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 43c686d4fb44..a863ed5b6ab7 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -68,7 +68,10 @@ const STORYBOOK_BUILD_SCRIPT_PACKAGES = ['esbuild']; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// A matcher is a bare name, or a name pinned to versions or a source: `esbuild@0.25.0 || 0.25.1`. +/** + * Matches a package name, including pinned versions of that package. + * For instance, if `matcher` is `esbuild@0.25.0 || 0.25.1`, it matches `pkg` `esbuild`. + */ const matchesPackage = (matcher: unknown, pkg: string): boolean => typeof matcher === 'string' && (matcher === pkg || matcher.startsWith(`${pkg}@`)); From 67f593c8d2dd3536fdb62688584ca2c63b1c57f0 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 10:53:45 +0200 Subject: [PATCH 253/326] Improve docs and simplify code --- .../common/js-package-manager/PNPMProxy.ts | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index a863ed5b6ab7..fdcb53f43e64 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -70,28 +70,51 @@ const isRecord = (value: unknown): value is Record => /** * Matches a package name, including pinned versions of that package. - * For instance, if `matcher` is `esbuild@0.25.0 || 0.25.1`, it matches `pkg` `esbuild`. + * For instance, if `value` is `esbuild@0.25.0 || 0.25.1`, it matches `pkg` `esbuild`. + * @param value The package name or pinned version to match against. + * @param pkg The package name to check against the value. + * @returns whether the package matches the given value. */ -const matchesPackage = (matcher: unknown, pkg: string): boolean => - typeof matcher === 'string' && (matcher === pkg || matcher.startsWith(`${pkg}@`)); +const matchesPackage = (value: unknown, pkg: string): boolean => + typeof value === 'string' && (value === pkg || value.startsWith(`${pkg}@`)); +/** + * Checks if a list of packages matches a given package name, including if the + * list contains pinned versions of that package. + * @param list List of package names to check. + * @param pkg The package name to check against the list of matchers. + * @returns whether the package matches any of the entries in the list. + */ const listsPackage = (list: unknown, pkg: string): boolean => - Array.isArray(list) && list.some((matcher) => matchesPackage(matcher, pkg)); + Array.isArray(list) && list.some((value) => matchesPackage(value, pkg)); -// The bare key only counts once it holds a boolean, so the placeholder pnpm 12 writes still gets -// resolved; a pinned matcher counts whatever its value, as a bare entry would widen it. +/** + * Checks if PNPM settings contain an `allowBuilds` decision for the given package. + * Does not check whether it's allowed or refused, we only care to not modify `allowBuilds` + * when a pre-existing user decision exists. + * + * Note however that PNPM 12 inserts placeholder keys in `allowBuilds` for package names + * where the user has not made a decision. This does not happen for pinned versions but + * only for exact matches. So we verify we do have a boolean value for exact matches. + * + * @param settings The PNPM settings object to check. + * @param pkg The package name to check for an `allowBuilds` decision. + * @returns `true` if there is an existing `allowBuilds` decision for the package, `false` otherwise. + */ function hasAllowBuildsDecision(settings: unknown, pkg: string): boolean { if (!isRecord(settings)) { return false; } + if (settings.dangerouslyAllowAllBuilds === true) { return true; } + const { allowBuilds } = settings; return ( isRecord(allowBuilds) && - Object.entries(allowBuilds).some(([matcher, value]) => - matcher === pkg ? typeof value === 'boolean' : matchesPackage(matcher, pkg) + Object.keys(allowBuilds).some( + (matcher) => matchesPackage(matcher, pkg) && typeof allowBuilds[matcher] === 'boolean' ) ); } From 5fa76cfc944e88e7bdb23cbac1c2e22e6598b22b Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:12:23 +0200 Subject: [PATCH 254/326] fix(csf-tools): let the import helpers see every import, keep types and follow the file's style setImport, setRequireImport and removeImport looked only at the first declaration of a module, so a second import of it was invisible: names it already bound were added again, and names it bound were not removed. They also edited an `import type` declaration as if it were a value import, which turned an added value binding into a type, and a `const a = require('x')` made setRequireImport(['b'], 'x') a silent no-op. They now look at every import and top-level require of the module and add value bindings only to value imports. A binding that only a type-only import provides moves out of it into a value import, since the two would clash; other type-only imports are left alone. A named binding can be given as `'name as alias'`, the form setImportSpecifiers already takes, and counts as present only under its own local name, `{ a = 1 }` in a require included. A malformed name throws instead of printing invalid code. removeImport keeps its contract that a plain name removes every import of that export. The statements these helpers add now follow the file's semicolon style, inferred like its quotes. ensureNamedImport uses setImport for its aliased import, so it gets the same style and merges into an existing import. Co-Authored-By: Claude Fable 5.1 --- .../utils/wrap-getAbsolutePath-utils.ts | 8 +- code/core/src/csf-tools/ConfigFile.test.ts | 179 +++++++++ code/core/src/csf-tools/ConfigFile.ts | 374 ++++++++++-------- code/core/src/csf-tools/README.md | 2 + code/core/src/csf-tools/estree/editor.test.ts | 27 ++ code/core/src/csf-tools/estree/editor.ts | 47 ++- 6 files changed, 473 insertions(+), 164 deletions(-) diff --git a/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts b/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts index 7ad0a6cdcca0..44698ce83194 100644 --- a/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts +++ b/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts @@ -1,7 +1,7 @@ import type { ConfigFile } from 'storybook/internal/csf-tools'; import { type Node, isStringLiteral } from '../../csf-tools/estree/ast.ts'; -import { prependStatement, printString } from '../../csf-tools/estree/editor.ts'; +import { printString } from '../../csf-tools/estree/editor.ts'; const PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'getAbsolutePath'; const ALTERNATIVE_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'wrapForPnp'; @@ -203,10 +203,6 @@ export function ensureNamedImport(config: ConfigFile, imported: string, source: while (taken.has(local)) { local = `_${local}`; } - prependStatement( - config._editor, - `import { ${imported} as ${local} } from ${printString(source, config._editor.quote)};` - ); - config._editor.commit(); + config.setImport([`${imported} as ${local}`], source); return local; } diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index ebe32a17a5d5..37264b512be5 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -1619,6 +1619,104 @@ describe('ConfigFile', () => { expect(printConfig(config).code).toBe(output); }); + it.each([ + { + source: `import type { A } from 'x';\nexport default {};`, + specifier: ['b'], + output: `import type { A } from 'x';\nimport { b } from 'x';\nexport default {};`, + }, + { + source: `import type A from 'x';`, + specifier: 'b', + output: `import type A from 'x';\nimport b from 'x';`, + }, + { + source: `import type { A } from 'x';\nimport { b } from 'x';`, + specifier: ['c'], + output: `import type { A } from 'x';\nimport { b, c } from 'x';`, + }, + { + source: `import * as ns from 'x';\nimport { a } from 'x';`, + specifier: ['a', 'b'], + output: `import * as ns from 'x';\nimport { a, b } from 'x';`, + }, + { + source: `import { a } from 'x';\nimport d from 'x';`, + specifier: 'd', + output: `import { a } from 'x';\nimport d from 'x';`, + }, + ])('adds value specifiers $specifier to $source', ({ source, specifier, output }) => { + const config = loadConfig(source).parse(); + config.setImport(specifier, 'x'); + + expect(printConfig(config).code).toBe(output); + }); + + it.each([ + { source: ``, output: `import { a as b } from "x";\n` }, + { source: `import { a } from 'x';`, output: `import { a, a as b } from 'x';` }, + { source: `import { a as b } from 'x';`, output: `import { a as b } from 'x';` }, + ])('adds the alias `a as b` to $source', ({ source, output }) => { + const config = loadConfig(source).parse(); + config.setImport(['a as b'], 'x'); + + expect(printConfig(config).code).toBe(output); + }); + + it.each([ + { + source: `import type { A } from 'x';\nexport default {};`, + specifier: ['A'], + output: `import { A } from 'x';\nexport default {};`, + }, + { + source: `import { type A, b } from 'x';`, + specifier: ['A'], + output: `import { b, A } from 'x';`, + }, + { + source: `import type { A as B, C } from 'x';`, + specifier: ['A as B'], + output: `import type { C } from 'x';\nimport { A as B } from 'x';`, + }, + { + source: `import type A from 'x';\nexport default {};`, + specifier: 'A', + output: `import A from 'x';\nexport default {};`, + }, + ])( + 'turns the type-only binding of $specifier in $source into a value import', + ({ source, specifier, output }) => { + const config = loadConfig(source).parse(); + config.setImport(specifier, 'x'); + + expect(printConfig(config).code).toBe(output); + } + ); + + it.each(['a-b', 'a as b as c', ''])('rejects the import name %j', (name) => { + const config = loadConfig('').parse(); + + expect(() => config.setImport([name], 'x')).toThrow('import name'); + }); + + it('adds a name that the file only imports under an alias', () => { + const config = loadConfig(`import { a as b } from 'x';`).parse(); + config.setImport(['a'], 'x'); + + expect(printConfig(config).code).toBe(`import { a as b, a } from 'x';`); + }); + + it('ends the statements it adds like the file does', () => { + const config = loadConfig(`import a from 'a'\nexport default {}\n`).parse(); + config.setImport(['b'], 'b'); + config.setImport(null, 'c'); + + expect(printConfig(config).code).toBe( + `import a from 'a'\nimport { b } from 'b'\nimport 'c'\nexport default {}\n` + ); + }); + it(`supports setting a namespaced import`, () => { const config = loadConfig('').parse(); config.setImport({ namespace: 'path' }, 'path'); @@ -1731,6 +1829,49 @@ describe('ConfigFile', () => { expect(printConfig(config).code).toBe(output); }); + it.each([ + { + source: `const a = require('x');`, + specifier: ['b'], + output: `const { b } = require('x');\nconst a = require('x');`, + }, + { + source: `const a = require('x');\nconst { b } = require('x');`, + specifier: ['c'], + output: `const a = require('x');\nconst { b, c } = require('x');`, + }, + { + source: `const { b } = require('x');\nconst a = require('x');`, + specifier: 'a', + output: `const { b } = require('x');\nconst a = require('x');`, + }, + { + source: `const { b } = require('x');`, + specifier: ['a as c', 'b'], + output: `const { b, a: c } = require('x');`, + }, + { + source: `const { a: c } = require('x');`, + specifier: ['a as c', 'a'], + output: `const { a: c, a } = require('x');`, + }, + { + source: `const { a = 1, b: c = 2 } = require('x');`, + specifier: ['a', 'b as c'], + output: `const { a = 1, b: c = 2 } = require('x');`, + }, + { + source: `module.exports = {}`, + specifier: 'a', + output: `const a = require("x")\nmodule.exports = {}`, + }, + ])('adds $specifier to $source', ({ source, specifier, output }) => { + const config = loadConfig(source).parse(); + config.setRequireImport(specifier, 'x'); + + expect(printConfig(config).code).toBe(output); + }); + it(`supports setting a named import for a field where the source already exists without "node:" prefix`, () => { const source = dedent` const { dirname } = require('path'); @@ -2024,6 +2165,44 @@ describe('ConfigFile', () => { expect(parsed).toMatchInlineSnapshot(source); }); + it.each([ + { + source: `import type { A } from 'x';\nimport { b } from 'x';\nimport { c, d } from 'x';`, + specifier: ['A', 'c'], + output: `import type { A } from 'x';\nimport { b } from 'x';\nimport { d } from 'x';`, + }, + { + source: `import { a as b, a } from 'x';`, + specifier: ['a as b'], + output: `import { a } from 'x';`, + }, + { + source: `import { a as b } from 'x';\nimport { a } from 'x';`, + specifier: ['a'], + output: ``, + }, + { + source: `import 'x';\nimport { a } from 'x';\nimport 'x';`, + specifier: null, + output: `import { a } from 'x';\n`, + }, + { + source: `const a = require('x');\nconst { b, c } = require('x');`, + specifier: ['b'], + output: `const a = require('x');\nconst { c } = require('x');`, + }, + { + source: `const { a: b = 1, c } = require('x');`, + specifier: ['a as b'], + output: `const { c } = require('x');`, + }, + ])('removes $specifier from $source', ({ source, specifier, output }) => { + const config = loadConfig(source).parse(); + config.removeImport(specifier, 'x'); + + expect(printConfig(config).code).toBe(output); + }); + it(`removes ES6 import while preserving require import`, () => { const source = dedent` import { readFile } from 'fs'; diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index b0c5d67444a3..b39bc524d216 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -129,15 +129,56 @@ const _findVarDeclarator = ( return undefined; }; -const namesToAdd = (names: readonly string[], has: (name: string) => boolean) => - [...new Set(names)].filter((name) => !has(name)); - const isRequireOf = (node: Node | null | undefined, fromImport: string) => node?.type === 'CallExpression' && isIdentifier(node.callee, 'require') && isStringLiteral(node.arguments[0]) && - (node.arguments[0].value === fromImport || - node.arguments[0].value === fromImport.split('node:')[1]); + isSource(node.arguments[0].value, fromImport); + +// `node:path` also finds an import of `path`. +const isSource = (value: string, fromImport: string) => + value === fromImport || value === fromImport.split('node:')[1]; + +interface ImportName { + imported: string; + local: string; +} + +// `'a as b'` imports `a` under the local name `b`. +const IMPORT_NAME = /^\s*([\p{L}\p{N}_$]+)(?:\s+as\s+([\p{L}\p{N}_$]+))?\s*$/u; + +const importNames = (specifiers: readonly string[]) => { + const names = new Map(); + for (const specifier of specifiers) { + const match = IMPORT_NAME.exec(specifier); + if (!match) { + throw new Error(`Expected an import name like 'a' or 'a as b', got '${specifier}'`); + } + const [, imported, local = imported] = match; + names.set(`${imported} as ${local}`, { imported, local }); + } + return [...names.values()]; +}; + +const printImportName = ({ imported, local }: ImportName, separator: string) => + imported === local ? imported : `${imported}${separator}${local}`; + +const bindsImport = (name: ImportName, imported: Node, local: Node) => + isIdentifier(imported, name.imported) && + // `{ a = 1 }` binds `a` too. + isIdentifier(local.type === 'AssignmentPattern' ? local.left : local, name.local); + +// A plain name removes every import of that export, an alias only the import under that name. +const removesImport = (name: ImportName, imported: Node, local: Node) => + name.imported === name.local + ? isIdentifier(imported, name.imported) + : bindsImport(name, imported, local); + +type ImportSpecifierNode = E.ImportDeclaration['specifiers'][number]; + +const isTypeOnly = (declaration: E.ImportDeclaration, specifier: ImportSpecifierNode) => + declaration.importKind === 'type' || + (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'); export class ConfigFile implements CsfObject { /** @@ -827,62 +868,65 @@ export class ConfigFile implements CsfObject { } /** - * Import specifiers for a specific require import + * Add `require` bindings of a module: a string as `const name = require(…)`, an array of names or + * `'name as alias'` as destructured properties. A binding a top-level `require` of the module has + * already is skipped. Statements end with `;` unless most statements of the file omit it. * * @example * * ```ts - * // const { foo } = require('bar'); - * setRequireImport(['foo'], 'bar'); + * // const { foo, bar: baz } = require('bar'); + * setRequireImport(['foo', 'bar as baz'], 'bar'); * * // const foo = require('bar'); * setRequireImport('foo', 'bar'); * ``` * - * @param importSpecifiers - The import specifiers to set. If a string is passed in, a default - * import will be set. Otherwise, an array of named imports will be set - * @param fromImport - The module to import from + * @throws When a name is neither an identifier nor `'name as alias'`. */ setRequireImport(importSpecifier: string[] | string, fromImport: string) { - const requireDeclaration = this._findRequire(fromImport); - if (requireDeclaration) { - fromImport = ( - (requireDeclaration.declarations[0].init as E.CallExpression) - .arguments[0] as E.StringLiteral - ).value; - } - const source = printString(fromImport, this._editor.quote); + const requires = this._findRequires(fromImport); + const call = requires[0]?.declarations[0].init as E.CallExpression | undefined; + const source = printString( + call ? (call.arguments[0] as E.StringLiteral).value : fromImport, + this._editor.quote + ); + const end = this._editor.semicolons ? ';' : ''; const editor = this._editor; + const ids = requires.map((declaration) => declaration.declarations[0].id); - // if the import specifier is a string, we're dealing with default imports if (typeof importSpecifier === 'string') { - const declarator = requireDeclaration?.declarations[0]; - if (!declarator || !isIdentifier(declarator.id, importSpecifier)) { - // If the import declaration hasn't the specified default identifier, we add a new variable declaration - prependStatement(editor, `const ${importSpecifier} = require(${source});`); + if (!ids.some((id) => isIdentifier(id, importSpecifier))) { + prependStatement(editor, `const ${importSpecifier} = require(${source})${end}`); } - // if the import specifier is an array, we're dealing with named imports } else { - const pattern = requireDeclaration?.declarations[0].id; - const names = namesToAdd( - importSpecifier, - (name) => - pattern?.type === 'ObjectPattern' && - pattern.properties.some( - (property) => property.type === 'Property' && isIdentifier(property.key, name) - ) + const patterns = ids.filter((id): id is E.ObjectPattern => id.type === 'ObjectPattern'); + const texts = importNames(importSpecifier) + .filter( + (name) => + !patterns.some((pattern) => + pattern.properties.some( + (property) => + property.type === 'Property' && bindsImport(name, property.key, property.value) + ) + ) + ) + .map((name) => printImportName(name, ': ')); + // Nothing may follow a rest element. + const pattern = patterns.find((id) => + id.properties.every((property) => property.type === 'Property') ); - if (!pattern) { - prependStatement(editor, `const { ${names.join(', ')} } = require(${source});`); - } else if (pattern.type === 'ObjectPattern' && names.length > 0) { - appendToList(editor, objectList(editor, pattern), names); + if (texts.length > 0 && pattern) { + appendToList(editor, objectList(editor, pattern), texts); + } else if (texts.length > 0) { + prependStatement(editor, `const { ${texts.join(', ')} } = require(${source})${end}`); } } this._editor.commit(); } - _findRequire(fromImport: string) { - return this._program.body.find( + _findRequires(fromImport: string) { + return this._program.body.filter( (node): node is E.VariableDeclaration => node.type === 'VariableDeclaration' && node.declarations.length === 1 && @@ -890,24 +934,24 @@ export class ConfigFile implements CsfObject { ); } - _findImport(fromImport: string) { - return this._program.body.find( + _findImports(fromImport: string) { + return this._program.body.filter( (node): node is E.ImportDeclaration => - node.type === 'ImportDeclaration' && - (node.source.value === fromImport || node.source.value === fromImport.split('node:')[1]) + node.type === 'ImportDeclaration' && isSource(node.source.value, fromImport) ); } /** - * Set import specifiers for a given import statement. - * - * Does not support setting type imports (yet) + * Add import bindings of a module: a string as the default import, an array of names or + * `'name as alias'` as named imports. A binding a value import of the module has already is + * skipped; one only a type-only import has becomes a value import. Statements end with `;` unless + * most statements of the file omit it. * * @example * * ```ts - * // import { foo } from 'bar'; - * setImport(['foo'], 'bar'); + * // import { foo, bar as baz } from 'bar'; + * setImport(['foo', 'bar as baz'], 'bar'); * * // import foo from 'bar'; * setImport('foo', 'bar'); @@ -919,104 +963,144 @@ export class ConfigFile implements CsfObject { * setImport(null, 'bar'); * ``` * - * @param importSpecifiers - The import specifiers to set. If a string is passed in, a default - * import will be set. Otherwise, an array of named imports will be set - * @param fromImport - The module to import from + * @throws When a name is neither an identifier nor `'name as alias'`. */ setImport(importSpecifier: string[] | string | { namespace: string } | null, fromImport: string) { - const importDeclaration = this._findImport(fromImport); - if (importDeclaration) { - fromImport = importDeclaration.source.value; - } + const names = Array.isArray(importSpecifier) ? importNames(importSpecifier) : []; + const spelled = this._findImports(fromImport)[0]?.source.value ?? fromImport; + // A type-only binding of a name to import as a value would clash with it, so it goes. + this._removeImportSpecifiers( + fromImport, + (declaration, specifier) => + isTypeOnly(declaration, specifier) && + (specifier.type === 'ImportSpecifier' + ? names.some((name) => bindsImport(name, specifier.imported, specifier.local)) + : specifier.type === 'ImportDefaultSpecifier' && specifier.local.name === importSpecifier) + ); + this._editor.commit(); + + const values = this._findImports(fromImport).filter( + (declaration) => declaration.importKind !== 'type' + ); const editor = this._editor; - const source = printString(fromImport, this._editor.quote); - const has = (type: string, name?: string) => - !!importDeclaration?.specifiers.some( - (specifier) => - specifier.type === type && - (name === undefined || - (type === 'ImportSpecifier' - ? isIdentifier((specifier as E.ImportSpecifier).imported, name) - : specifier.local.name === name)) - ); - // A new import goes after the existing ones, and a clause the declaration cannot take gets a - // declaration of its own right after it, which keeps the module evaluation order. - const addImport = (clause: string, merged?: { default?: string; named?: string[] }) => { - if (!importDeclaration) { - insertAfterImports(editor, `import ${clause} from ${source};`); - } else if (merged) { - setImportSpecifiers(editor, importDeclaration, importDeclaration.specifiers, merged); + const source = printString(spelled, this._editor.quote); + const end = this._editor.semicolons ? ';' : ''; + const specifiers = values.flatMap((declaration) => + declaration.specifiers.filter((specifier) => !isTypeOnly(declaration, specifier)) + ); + // A clause goes into the first value import without a specifier that blocks it. Otherwise it + // gets a declaration of its own after the module's value imports, or after all imports, which + // keeps the module evaluation order. + const addImport = ( + clause: string, + merged?: { blockedBy: string; specifiers: { default?: string; named?: string[] } } + ) => { + const target = + merged && + values.find( + (declaration) => + !declaration.specifiers.some((specifier) => specifier.type === merged.blockedBy) + ); + if (target) { + setImportSpecifiers(editor, target, target.specifiers, merged.specifiers); + } else if (values.length > 0) { + insertStatementAfter(editor, values.at(-1)!, `import ${clause} from ${source}${end}`); } else { - insertStatementAfter(editor, importDeclaration, `import ${clause} from ${source};`); + insertAfterImports(editor, `import ${clause} from ${source}${end}`); } }; // Handle side-effect imports (e.g., import 'foo') if (importSpecifier === null) { - if (!importDeclaration) { - insertAfterImports(editor, `import ${source};`); + if (values.length === 0) { + insertAfterImports(editor, `import ${source}${end}`); } // Handle default imports e.g. import foo from 'bar' } else if (typeof importSpecifier === 'string') { - if (!has('ImportDefaultSpecifier', importSpecifier)) { - addImport( - importSpecifier, - has('ImportDefaultSpecifier') ? undefined : { default: importSpecifier } - ); + if ( + !specifiers.some( + (specifier) => + specifier.type === 'ImportDefaultSpecifier' && specifier.local.name === importSpecifier + ) + ) { + addImport(importSpecifier, { + blockedBy: 'ImportDefaultSpecifier', + specifiers: { default: importSpecifier }, + }); } // Handle named imports e.g. import { foo } from 'bar' } else if (Array.isArray(importSpecifier)) { - const names = namesToAdd(importSpecifier, (name) => has('ImportSpecifier', name)); - if (names.length > 0) { - addImport( - `{ ${names.join(', ')} }`, - has('ImportNamespaceSpecifier') ? undefined : { named: names } - ); + const texts = names + .filter( + (name) => + !specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + bindsImport(name, specifier.imported, specifier.local) + ) + ) + .map((name) => printImportName(name, ' as ')); + if (texts.length > 0) { + addImport(`{ ${texts.join(', ')} }`, { + blockedBy: 'ImportNamespaceSpecifier', + specifiers: { named: texts }, + }); } // Handle namespace imports e.g. import * as foo from 'bar' } else if ( importSpecifier.namespace && - !has('ImportNamespaceSpecifier', importSpecifier.namespace) + !specifiers.some( + (specifier) => + specifier.type === 'ImportNamespaceSpecifier' && + specifier.local.name === importSpecifier.namespace + ) ) { addImport(`* as ${importSpecifier.namespace}`); } this._editor.commit(); } + // Removes the matching specifiers of a module's imports, and each import left without any. + _removeImportSpecifiers( + fromImport: string, + matches: (declaration: E.ImportDeclaration, specifier: ImportSpecifierNode) => boolean + ) { + for (const declaration of this._findImports(fromImport)) { + const kept = declaration.specifiers.filter((specifier) => !matches(declaration, specifier)); + if (kept.length === 0 && declaration.specifiers.length > 0) { + removeStatement(this._editor, declaration); + } else if (kept.length < declaration.specifiers.length) { + setImportSpecifiers(this._editor, declaration, kept); + } + } + } + _removeRequireImport( importSpecifier: string[] | string | { namespace: string } | null, fromImport: string ) { - const requireDeclaration = this._findRequire(fromImport); - // require() has no side-effect only or namespace forms, so those are skipped. - if (!requireDeclaration || importSpecifier === null) { - return; - } - const declarator = requireDeclaration.declarations[0]; const editor = this._editor; - - // Handle default requires e.g. const foo = require('bar') - if (typeof importSpecifier === 'string') { - // For default requires, if the identifier matches, remove the entire declaration - if (isIdentifier(declarator.id, importSpecifier)) { - removeStatement(editor, requireDeclaration); + // require() has no side-effect only or namespace forms, so those are skipped. + const names = Array.isArray(importSpecifier) ? importNames(importSpecifier) : undefined; + for (const declaration of this._findRequires(fromImport)) { + const { id } = declaration.declarations[0]; + // Handle default requires e.g. const foo = require('bar') + if (typeof importSpecifier === 'string' && isIdentifier(id, importSpecifier)) { + removeStatement(editor, declaration); } - return; - } - - // Handle named requires e.g. const { foo, bar } = require('baz') - if (Array.isArray(importSpecifier) && declarator.id.type === 'ObjectPattern') { - const pattern = declarator.id; - const removed = pattern.properties.filter( + // Handle named requires e.g. const { foo, bar } = require('baz') + if (!names || id.type !== 'ObjectPattern') { + continue; + } + const removed = id.properties.filter( (property) => property.type === 'Property' && - importSpecifier.some((specifier) => isIdentifier(property.key, specifier)) + names.some((name) => removesImport(name, property.key, property.value)) ); - // If no properties left in the destructuring, remove the entire declaration - if (removed.length === pattern.properties.length) { - removeStatement(editor, requireDeclaration); + if (removed.length === id.properties.length) { + removeStatement(editor, declaration); } else if (removed.length > 0) { - removeFromList(editor, objectList(editor, pattern), removed); + removeFromList(editor, objectList(editor, id), removed); } } } @@ -1025,27 +1109,19 @@ export class ConfigFile implements CsfObject { importSpecifier: string[] | string | { namespace: string } | null, fromImport: string ) { - const importDeclaration = this._findImport(fromImport); - if (!importDeclaration) { - return; - } - const editor = this._editor; - // Remove side-effect imports (e.g., import 'foo') if exact match, else do nothing. if (importSpecifier === null) { - if (importDeclaration.specifiers.length === 0) { - removeStatement(editor, importDeclaration); + for (const declaration of this._findImports(fromImport)) { + if (declaration.specifiers.length === 0) { + removeStatement(this._editor, declaration); + } } return; } - - const removed = importDeclaration.specifiers.filter((specifier) => { - // Handle namespace imports e.g. import * as foo from 'bar' - if (typeof importSpecifier === 'object' && 'namespace' in importSpecifier) { - return ( - specifier.type === 'ImportNamespaceSpecifier' && - specifier.local.name === importSpecifier.namespace - ); + const names = Array.isArray(importSpecifier) ? importNames(importSpecifier) : []; + this._removeImportSpecifiers(fromImport, (declaration, specifier) => { + if (isTypeOnly(declaration, specifier)) { + return false; } // Handle default imports e.g. import foo from 'bar' if (typeof importSpecifier === 'string') { @@ -1054,51 +1130,41 @@ export class ConfigFile implements CsfObject { ); } // Handle named imports e.g. import { foo } from 'bar' + if (Array.isArray(importSpecifier)) { + return ( + specifier.type === 'ImportSpecifier' && + names.some((name) => removesImport(name, specifier.imported, specifier.local)) + ); + } + // Handle namespace imports e.g. import * as foo from 'bar' return ( - specifier.type === 'ImportSpecifier' && - importSpecifier.some((name) => isIdentifier(specifier.imported, name)) + specifier.type === 'ImportNamespaceSpecifier' && + specifier.local.name === importSpecifier.namespace ); }); - if (removed.length === 0) { - return; - } - - // If the import declaration has no specifiers left, remove it. - if (removed.length === importDeclaration.specifiers.length) { - removeStatement(editor, importDeclaration); - return; - } - setImportSpecifiers( - editor, - importDeclaration, - importDeclaration.specifiers.filter((specifier) => !removed.includes(specifier)) - ); } /** - * Remove import specifiers for a given import statement. - * - * Does not support removing type imports (yet) + * Remove import and `require` bindings of a module: a string removes the default import, an + * array named imports. A name removes every import of that export, whatever its local name; + * `'name as alias'` removes only the import under that alias. Type-only imports are left as they + * are. * * @example * * ```ts * // import { foo } from 'bar'; - * setImport(['foo'], 'bar'); + * removeImport(['foo'], 'bar'); * * // import foo from 'bar'; - * setImport('foo', 'bar'); + * removeImport('foo', 'bar'); * * // import * as foo from 'bar'; - * setImport({ namespace: 'foo' }, 'bar'); + * removeImport({ namespace: 'foo' }, 'bar'); * * // import 'bar'; - * setImport(null, 'bar'); + * removeImport(null, 'bar'); * ``` - * - * @param importSpecifiers - The import specifiers to remove. If a string is passed in, will only - * remove the default import. Otherwise, named imports matching the array will be removed. - * @param fromImport - The module to import from */ removeImport( importSpecifier: string[] | string | { namespace: string } | null, diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index bde00c2e77be..2f44ea8ba956 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -48,6 +48,8 @@ if (config.changed && config.mutationDiagnostics.length === 0) { `ConfigFile.changed` and `ConfigFile.mutationDiagnostics` track these editor operations. Existing `getFieldNode` reads reflect editor changes. Array and import helpers remain available; their edits are not tracked by `changed`. `writeConfig` rejects files with mutation diagnostics to avoid writing partial edits. +`setImport`, `setRequireImport`, and `removeImport` look at every import or top-level `require` of the module, and take a named binding as `'name'` or `'name as alias'`. `setImport` and `setRequireImport` skip a binding the module already provides under that local name. A binding that only an `import type` provides becomes a value import; other type-only imports are left alone. `removeImport` removes every import of a plain name, whatever its local name, but only the matching import of an alias. Added statements follow the file's quotes and semicolons. + Discovery rejects shared or reassigned bindings, unresolved re-exports, and conditional or repeated CommonJS assignments. Inline object methods and exported function declarations can be transformed as function expressions while retaining their declaration form. Path edits use the same spread, duplicate-key, occupied-destination, and dynamic-value checks as story edits. Unsupported input produces diagnostics without rewriting that input. ## Transforming method call arguments diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index a1617f0e551d..a72da53188b4 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -175,6 +175,22 @@ describe('style', () => { expect(new SourceEditor('const a = 1;').quote).toBe('"'); }); + it('ends statements with a semicolon unless most statements of the file omit it', () => { + expect(new SourceEditor("import a from 'a'\nconst b = 1\nexport default b;").semicolons).toBe( + false + ); + expect( + new SourceEditor("import a from 'a';\nfor (let i = 0; i < 1; i++) a(i)").semicolons + ).toBe(true); + expect(new SourceEditor('export function f() {}').semicolons).toBe(true); + expect( + new SourceEditor( + 'export type A = 1\ntype B = 2\nclass C { d = 1 }\nexport const e = 1;', + 'a.ts' + ).semicolons + ).toBe(false); + }); + it('keeps the style of the code it was created with after a commit', () => { const editor = new SourceEditor("const a = 'a';\n"); editor.edits.overwrite(10, 13, '1'); @@ -624,6 +640,12 @@ describe('prependStatement', () => { expect(editor.toString()).toBe(`\uFEFF// @ts-nocheck\nimport a from 'a';\n`); }); + it('ends the statement when the first statement would continue it', () => { + const editor = new SourceEditor(`[1].map(f)\n`); + prependStatement(editor, `const a = require('a')`); + expect(editor.toString()).toBe(`const a = require('a');\n[1].map(f)\n`); + }); + it('inserts right after the BOM of a file with nothing else', () => { const editor = new SourceEditor('\uFEFF'); prependStatement(editor, `import a from 'a';`); @@ -792,6 +814,11 @@ describe('setImportSpecifiers', () => { setImportSpecifiers(editor, second, second.specifiers, { default: 'D', named: ['e'] }); expect(editor.toString()).toBe(`import A, { c } from 'x';\nimport D, { b, e } from 'y';`); + const aliased = new SourceEditor(`import { a } from 'x';`); + const imported = aliased.program.body[0] as E.ImportDeclaration; + setImportSpecifiers(aliased, imported, imported.specifiers, { named: ['b as c'] }); + expect(aliased.toString()).toBe(`import { a, b as c } from 'x';`); + const replaced = new SourceEditor(`import { a, b } from 'x';`); const declaration = replaced.program.body[0] as E.ImportDeclaration; setImportSpecifiers(replaced, declaration, [declaration.specifiers[0]], { named: ['c'] }); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 80634e8d1ed1..2b79fef04ff9 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -271,17 +271,51 @@ export class SourceEditor { this._style ??= inferStyle(this._module); return this._style.indent; } + + /** Whether statements to generate end with `;`: false only when most statements of the file omit it. */ + get semicolons() { + this._style ??= inferStyle(this._module); + return this._style.semicolons; + } } -type Style = { quote: "'" | '"'; indent: string }; +type Style = { quote: "'" | '"'; indent: string; semicolons: boolean }; + +// Statements and class fields that end with an optional `;`. A declaration in a `for` head has none; +// an exported declaration ends with the `;` of its `export`. +const endsWithOptionalSemicolon = (node: Node, parent: Node | null) => { + switch (node.type) { + case 'ImportDeclaration': + case 'ExportAllDeclaration': + case 'ExpressionStatement': + case 'ReturnStatement': + case 'ThrowStatement': + return true; + case 'TSTypeAliasDeclaration': + case 'PropertyDefinition': + return true; + case 'ExportNamedDeclaration': + return !node.declaration; + case 'ExportDefaultDeclaration': + return !/Declaration$/.test(node.declaration.type); + case 'VariableDeclaration': + return !parent?.type.startsWith('For'); + default: + return false; + } +}; // The style of the code the editor was created with, so edits do not change it. A tie between // quotes goes to the first literal; JSX attributes are left out, as they keep double quotes. const inferStyle = ({ code, program }: ParsedModule): Style => { const counts = { "'": 0, '"': 0 }; + const endings = { with: 0, without: 0 }; let first: "'" | '"' | undefined; let indent: string | undefined; walk(program, (node, parent) => { + if (endsWithOptionalSemicolon(node, parent)) { + endings[code[node.end - 1] === ';' ? 'with' : 'without']++; + } if (isStringLiteral(node) && parent?.type !== 'JSXAttribute') { const quote = node.raw?.[0]; if (quote === "'" || quote === '"') { @@ -293,7 +327,7 @@ const inferStyle = ({ code, program }: ParsedModule): Style => { }); const quote = counts["'"] === counts['"'] ? (first ?? '"') : counts["'"] > counts['"'] ? "'" : '"'; - return { quote, indent: indent ?? ' ' }; + return { quote, indent: indent ?? ' ', semicolons: endings.without <= endings.with }; }; // The indent step from a block's line to the line of its first item, if that is a deeper one. @@ -1098,7 +1132,12 @@ export const prependStatement = (editor: SourceEditor, text: string) => { appendStatement(editor, text); return; } - editor.edits.appendRight((leadingComments(editor, first)[0] ?? first).start, `${text}\n`); + const separator = + !text.endsWith(';') && CONTINUES_STATEMENT.test(editor.code.slice(first.start)) ? ';' : ''; + editor.edits.appendRight( + (leadingComments(editor, first)[0] ?? first).start, + `${text}${separator}\n` + ); }; // Whether pending edits removed a span; one cutting through either end of it counts as that. @@ -1187,7 +1226,7 @@ export const removeStatements = (editor: SourceEditor, statements: ReadonlySet Date: Tue, 20 Oct 2026 10:18:21 +0200 Subject: [PATCH 255/326] fix(csf-tools): return a mutation result from the array helpers instead of throwing appendValueToArray, appendNodeToArray and removeEntryFromArray threw on a field that is not an array literal, while every other ConfigFile mutation returns a CsfMutationResult and records a diagnostic. A caller had to wrap them in try/catch and could not tell a bad config from a bug. They now return a CsfMutationResult: a field that is not an array literal leaves the file as written and adds an `unsupported-value` diagnostic to mutationDiagnostics, and a successful edit sets `changed`. A missing entry in removeEntryFromArray is a no-op, like `remove` of a missing field. setupAddonInConfig, which relied on the throw to stop before writing, now throws the diagnostic itself. This changes behaviour for callers that caught the throw and wrote the file anyway: the diagnostic now makes writeConfig refuse it, and a missing entry no longer throws. Co-Authored-By: Claude Fable 5.1 --- .../utils/setup-addon-in-config.test.ts | 23 ++++- .../src/common/utils/setup-addon-in-config.ts | 16 ++-- code/core/src/csf-tools/ConfigFile.test.ts | 69 ++++++++------- code/core/src/csf-tools/ConfigFile.ts | 85 +++++++++++-------- code/core/src/csf-tools/README.md | 6 +- 5 files changed, 121 insertions(+), 78 deletions(-) diff --git a/code/core/src/common/utils/setup-addon-in-config.test.ts b/code/core/src/common/utils/setup-addon-in-config.test.ts index 6938324b06cc..0939353409eb 100644 --- a/code/core/src/common/utils/setup-addon-in-config.test.ts +++ b/code/core/src/common/utils/setup-addon-in-config.test.ts @@ -25,8 +25,8 @@ describe('setupAddonInConfig', () => { _editor: { quote: "'" }, getBodyDeclarations: () => [], getFieldNode: vi.fn(), - appendNodeToArray: vi.fn(), - appendValueToArray: vi.fn(), + appendNodeToArray: vi.fn(() => ({ ok: true, changed: true })), + appendValueToArray: vi.fn(() => ({ ok: true, changed: true })), } as any; mockMainConfig = { @@ -104,6 +104,25 @@ describe('setupAddonInConfig', () => { expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); }); + it('throws without writing when the addons field is not an array', async () => { + const diagnostic = { message: "Expected an array literal at 'addons', got 'Identifier'" }; + vi.mocked(mockMain.appendValueToArray).mockReturnValue({ + ok: false, + changed: false, + diagnostic, + } as any); + + await expect( + setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: undefined, + configDir: '.storybook', + }) + ).rejects.toThrow(diagnostic.message); + expect(csfTools.writeConfig).not.toHaveBeenCalled(); + }); + it('should handle sync errors gracefully', async () => { vi.mocked(mockMain.getFieldNode).mockReturnValue(undefined); vi.mocked(syncModule.syncStorybookAddons).mockRejectedValue(new Error('Sync failed')); diff --git a/code/core/src/common/utils/setup-addon-in-config.ts b/code/core/src/common/utils/setup-addon-in-config.ts index a1e3dd391e5f..fc123e246170 100644 --- a/code/core/src/common/utils/setup-addon-in-config.ts +++ b/code/core/src/common/utils/setup-addon-in-config.ts @@ -25,13 +25,15 @@ export async function setupAddonInConfig({ mainConfigCSFFile, }: SetupAddonInConfigOptions): Promise { const mainConfigAddons = mainConfigCSFFile.getFieldNode(['addons']); - if (mainConfigAddons && getAbsolutePathWrapperName(mainConfigCSFFile) !== null) { - mainConfigCSFFile.appendNodeToArray( - ['addons'], - parseExpression(getAbsolutePathCall(mainConfigCSFFile, addonName)) - ); - } else { - mainConfigCSFFile.appendValueToArray(['addons'], addonName); + const result = + mainConfigAddons && getAbsolutePathWrapperName(mainConfigCSFFile) !== null + ? mainConfigCSFFile.appendNodeToArray( + ['addons'], + parseExpression(getAbsolutePathCall(mainConfigCSFFile, addonName)) + ) + : mainConfigCSFFile.appendValueToArray(['addons'], addonName); + if (!result.ok) { + throw new Error(result.diagnostic.message); } await writeConfig(mainConfigCSFFile); diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index 37264b512be5..3e4bc9057d23 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -781,16 +781,28 @@ describe('ConfigFile', () => { `export default { core: { builder: 'webpack5' }, addons: ['docs'] };` ); }); - it('found scalar', () => { - expect(() => - appendToArray( - ['addons'], - 'docs', - dedent` - export default { addons: 5 }; - ` - ) - ).toThrowErrorMatchingInlineSnapshot(`Error: Expected array at 'addons', got 'Literal'`); + it('reports a field that is not an array literal and leaves it as written', () => { + const config = loadConfig(`export default { addons: 5 };`).parse(); + const result = config.appendValueToArray(['addons'], 'docs'); + + expect(result).toEqual({ + ok: false, + changed: false, + diagnostic: expect.objectContaining({ code: 'unsupported-value', path: ['addons'] }), + }); + expect(config.mutationDiagnostics).toHaveLength(1); + expect(config.appendNodeToArray(['addons'], parseExpression('docs'))).toMatchObject({ + ok: false, + }); + expect(config.changed).toBe(false); + expect(printConfig(config).code).toBe(`export default { addons: 5 };`); + }); + + it('reports a change like the other mutations', () => { + const config = loadConfig(`export default { addons: [] };`).parse(); + + expect(config.appendValueToArray(['addons'], 'docs')).toEqual({ ok: true, changed: true }); + expect(config.changed).toBe(true); }); it('array of simple values', () => { expect( @@ -2299,28 +2311,25 @@ describe('ConfigFile', () => { ); }); - it('throws when entry is missing', () => { - const source = dedent` - export default { - addons: ['a', { name: 'b', options: {} }, 'c'], - } - `; - const config = loadConfig(source).parse(); - expect(() => config.removeEntryFromArray(['addons'], 'x')).toThrowErrorMatchingInlineSnapshot( - `Error: Could not find 'x' in array at 'addons'` - ); + it('leaves an array without the entry unchanged, like a missing field', () => { + const config = loadConfig(`export default { addons: ['a'] };`).parse(); + + expect(config.removeEntryFromArray(['addons'], 'x')).toEqual({ ok: true, changed: false }); + expect(config.removeEntryFromArray(['stories'], 'x')).toEqual({ ok: true, changed: false }); + expect(config.removeEntryFromArray(['addons'], 'a')).toEqual({ ok: true, changed: true }); + expect(config.changed).toBe(true); }); - it('throws when target array is not an arral', () => { - const source = dedent` - export default { - addons: {}, - } - `; - const config = loadConfig(source).parse(); - expect(() => config.removeEntryFromArray(['addons'], 'x')).toThrowErrorMatchingInlineSnapshot( - `Error: Expected array at 'addons', got 'ObjectExpression'` - ); + it('reports a field that is not an array literal', () => { + const config = loadConfig(`export default { addons: {} };`).parse(); + const result = config.removeEntryFromArray(['addons'], 'x'); + + expect(result).toEqual({ + ok: false, + changed: false, + diagnostic: expect.objectContaining({ code: 'unsupported-value', path: ['addons'] }), + }); + expect(config.mutationDiagnostics).toHaveLength(1); }); }); diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index b39bc524d216..1eba1ec80ecb 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -756,60 +756,71 @@ export class ConfigFile implements CsfObject { } } - appendValueToArray(path: string[], value: any) { - const node = this.valueToNode(value); - - if (node) { - this.appendNodeToArray(path, node); - } + /** Append a plain value to the array literal at a path, creating the field when it is missing. */ + appendValueToArray(path: string[], value: any): CsfMutationResult { + return this.appendNodeToArray(path, this.valueToNode(value)); } - appendNodeToArray(path: string[], node: CsfExpression) { + /** Append an expression to the array literal at a path, creating the field when it is missing. */ + appendNodeToArray(path: string[], node: CsfExpression): CsfMutationResult { const current = this.getFieldNode(path); const source = placeableTextOf(node); if (!current) { - this.set(path, parseExpression(`[${source}]`)); - } else if (current.type === 'ArrayExpression') { - appendToList(this._editor, arrayList(this._editor, current), [source]); - this._editor.commit(); - } else { - throw new Error(`Expected array at '${path.join('.')}', got '${current.type}'`); + return this.set(path, parseExpression(`[${source}]`)); + } + if (current.type !== 'ArrayExpression') { + return this._notAnArray(path, current); } + appendToList(this._editor, arrayList(this._editor, current), [source]); + this._editor.commit(); + this._changed = true; + return { ok: true, changed: true }; } /** - * Specialized helper to remove addons or other array entries that can either be strings or - * objects with a name property. + * Remove the first entry of the array literal at a path that names `value`: a string, a + * `{ name }` object, or a call like `getAbsolutePath(value)`. A missing field or entry is a no-op. */ - removeEntryFromArray(path: string[], value: string) { + removeEntryFromArray(path: string[], value: string): CsfMutationResult { const current = this.getFieldNode(path); - if (!current) { - return; + return { ok: true, changed: false }; } - if (current.type === 'ArrayExpression') { - const element = current.elements.find((element) => { - if (isStringLiteral(element)) { - return element.value === value; - } - if (element?.type === 'ObjectExpression') { - const name = this._getPresetValue(element, 'name'); - return name === value; - } - return element ? this._getWrappedValue(element) === value : false; - }); - if (element) { - removeFromList(this._editor, arrayList(this._editor, current), [element]); - this._editor.commit(); - } else { - throw new Error(`Could not find '${value}' in array at '${path.join('.')}'`); + if (current.type !== 'ArrayExpression') { + return this._notAnArray(path, current); + } + const element = current.elements.find((element) => { + if (isStringLiteral(element)) { + return element.value === value; } - } else { - throw new Error(`Expected array at '${path.join('.')}', got '${current.type}'`); + if (element?.type === 'ObjectExpression') { + const name = this._getPresetValue(element, 'name'); + return name === value; + } + return element ? this._getWrappedValue(element) === value : false; + }); + if (!element) { + return { ok: true, changed: false }; } + removeFromList(this._editor, arrayList(this._editor, current), [element]); + this._editor.commit(); + this._changed = true; + return { ok: true, changed: true }; + } + + _notAnArray(path: string[], node: Node): CsfMutationResult { + const diagnostic: CsfMutationDiagnostic = { + code: 'unsupported-value', + target: this.target, + path, + message: `Expected an array literal at '${path.join('.')}', got '${node.type}'`, + loc: locationOf(this._code, node.start, node.end), + }; + this._mutationDiagnostics.push(diagnostic); + return { ok: false, changed: false, diagnostic }; } - valueToNode(value: any): CsfExpression | undefined { + valueToNode(value: any): CsfExpression { return parseExpression(printValue(value, this._editor.quote)); } diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index 2f44ea8ba956..edb1f5c8c2da 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -46,9 +46,11 @@ if (config.changed && config.mutationDiagnostics.length === 0) { } ``` -`ConfigFile.changed` and `ConfigFile.mutationDiagnostics` track these editor operations. Existing `getFieldNode` reads reflect editor changes. Array and import helpers remain available; their edits are not tracked by `changed`. `writeConfig` rejects files with mutation diagnostics to avoid writing partial edits. +`ConfigFile.changed` and `ConfigFile.mutationDiagnostics` track these editor operations. Existing `getFieldNode` reads reflect editor changes. `writeConfig` rejects files with mutation diagnostics to avoid writing partial edits. -`setImport`, `setRequireImport`, and `removeImport` look at every import or top-level `require` of the module, and take a named binding as `'name'` or `'name as alias'`. `setImport` and `setRequireImport` skip a binding the module already provides under that local name. A binding that only an `import type` provides becomes a value import; other type-only imports are left alone. `removeImport` removes every import of a plain name, whatever its local name, but only the matching import of an alias. Added statements follow the file's quotes and semicolons. +`appendValueToArray`, `appendNodeToArray`, and `removeEntryFromArray` return a `CsfMutationResult` like the editor operations. A field that is not an array literal leaves the file as it is and adds an `unsupported-value` diagnostic; a missing field or entry is a no-op for `removeEntryFromArray`. + +`setImport`, `setRequireImport`, and `removeImport` look at every import or top-level `require` of the module, and take a named binding as `'name'` or `'name as alias'`. `setImport` and `setRequireImport` skip a binding the module already provides under that local name. A binding that only an `import type` provides becomes a value import; other type-only imports are left alone. `removeImport` removes every import of a plain name, whatever its local name, but only the matching import of an alias. Added statements follow the file's quotes and semicolons. Import helper edits are not tracked by `changed`. Discovery rejects shared or reassigned bindings, unresolved re-exports, and conditional or repeated CommonJS assignments. Inline object methods and exported function declarations can be transformed as function expressions while retaining their declaration form. Path edits use the same spread, duplicate-key, occupied-destination, and dynamic-value checks as story edits. Unsupported input produces diagnostics without rewriting that input. From 58b8356e081180514d84b73260f74e1eed685e7c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:21:55 +0200 Subject: [PATCH 256/326] feat(csf-tools): expose the edited object literal as CsfObject.node A consumer that holds a CsfObject and needs the object itself, to inspect members the path API does not cover or to anchor its own edit, had to run discovery again and match the target by hand. The editor already finds its object in the current parse after every commit, so it now exposes it. `node` returns the meta or story object, the object assigned to a CSF2 annotation, or the config object, and `undefined` for a config made of named exports. It is the file's own node rather than a detached copy, read again on every access, and reading it adds no diagnostic. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/ConfigFile.ts | 18 +++++++++++- code/core/src/csf-tools/CsfObject.test.ts | 35 +++++++++++++++++++++++ code/core/src/csf-tools/CsfObject.ts | 20 +++++++++++++ code/core/src/csf-tools/README.md | 1 + 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 1eba1ec80ecb..99a6943b7a66 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -6,7 +6,7 @@ import { dedent } from 'ts-dedent'; import invariant from 'tiny-invariant'; import type { PrintResultType } from './PrintResultType.ts'; -import { createConfigObject } from './ConfigObject.ts'; +import { createConfigObject, locateConfigRoot } from './ConfigObject.ts'; import { type CsfExpression, type CsfMutationDiagnostic, @@ -211,6 +211,22 @@ export class ConfigFile implements CsfObject { return this._changed; } + /** + * Find the config object literal in the current parse, or `undefined` for a config made of named + * exports or one whose config object cannot be located. It is the file's own node, not a copy: do + * not change it, and read it again after a mutation, which parses the file again. + * + * @example + * ```ts + * const config = loadConfig('export default definePreview({ tags: [] });').parse(); + * textOf(config.node!); // '{ tags: [] }' + * ``` + */ + get node(): E.ObjectExpression | undefined { + const located = locateConfigRoot(this, 'read'); + return located.ok ? located.root.node : undefined; + } + /** * Read diagnostics from unsupported discovery, reads, or mutations. `writeConfig` refuses to * write a config with diagnostics, even if another edit succeeded. diff --git a/code/core/src/csf-tools/CsfObject.test.ts b/code/core/src/csf-tools/CsfObject.test.ts index 47b229b406d6..c0528b0b45d6 100644 --- a/code/core/src/csf-tools/CsfObject.test.ts +++ b/code/core/src/csf-tools/CsfObject.test.ts @@ -1187,3 +1187,38 @@ describe('CsfObject', () => { }); }); }); + +describe('CsfObject.node', () => { + it('returns the object literal of the current parse, found again after an edit', () => { + const csf = parse(`export default { title: 'A' };\nexport const Primary = { args: {} };`); + const [meta, story] = csf.objects(); + const before = story.node!; + + expect(textOf(before)).toBe('{ args: {} }'); + expect(meta.node).toBe(csf._metaNode); + + story.set(['args', 'disabled'], true); + + expect(story.node).not.toBe(before); + expect(textOf(story.node!)).toBe('{ args: { disabled: true } }'); + }); + + it('returns the assigned object of a CSF2 annotation and the argument of a factory', () => { + const csf = parse( + `export default {};\nexport const Primary = () => null;\nPrimary.parameters = { a: 1 };` + ); + const [annotation] = csf.objects({ meta: false }); + + expect(textOf(annotation.node!)).toBe('{ a: 1 }'); + + const config = loadConfig(`export default definePreview({ tags: [] });`).parse(); + expect(textOf(config.node!)).toBe('{ tags: [] }'); + }); + + it('returns undefined for a config made of named exports, without a diagnostic', () => { + const config = loadConfig(`export const tags = [];`).parse(); + + expect(config.node).toBeUndefined(); + expect(config.mutationDiagnostics).toEqual([]); + }); +}); diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index e7368a8d2af7..6d6463df3df4 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -108,6 +108,22 @@ export interface CsfObject { * ``` */ readonly changed: boolean; + /** + * Find the object literal this editor edits in the current parse; for a CSF2 annotation editor, + * the object its annotation path names. It is `undefined` for a config made of named exports and + * once the object is gone. It is the file's own node, not a copy: do not change it, and read it + * again after a mutation, which parses the file again. + * + * @example + * ```ts + * const csf = loadCsf('export default {}; export const Primary = { args: {} };', { + * makeTitle: () => 'Example', + * }).parse(); + * const [story] = csf.objects({ meta: false }); + * textOf(story.node!); // '{ args: {} }' + * ``` + */ + readonly node: E.ObjectExpression | undefined; /** * Read a detached copy of the expression at a property path. For a CSF2 annotation, its * annotation path returns the whole object. Missing fields return `undefined`. @@ -370,6 +386,10 @@ class CsfObjectEditor implements CsfObject { return this.#changed; } + get node() { + return this.host.root()?.node; + } + private get editor() { return this.host.editor; } diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index edb1f5c8c2da..c88e14315ce4 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -22,6 +22,7 @@ Successful removals and moves recursively remove empty source parents, stopping Nested object paths can follow local constants used only by that object; shared or reassigned references remain untouched. Nodes reused by `transform` keep their original source, so relocating a value prints it as written instead of pretty-printing it. `group(path, names)` nests named siblings under a destination object, retaining their source order. A new group replaces a contiguous run of source fields at their original position. Grouping into an existing object requires values whose relocation cannot change expression evaluation order. Conflicts, ambiguous keys, and unsafe relocations produce diagnostics before the operation changes the source; absent source fields are ignored. +`node` is the editor's object literal in the current parse: the meta or story object, a call argument, a config object, or for a CSF2 annotation editor the assigned object that its annotation path (such as `['parameters']`) names. It is the file's own node, found again after every mutation: read it after editing instead of keeping it, and do not change it, since edits go through the source. It is `undefined` for a config made of named exports and once the object is gone. Annotation editors take the same paths as their story counterparts, so `['parameters', 'a11y']` addresses `Story.parameters.a11y` and an inline `parameters.a11y` alike. Discovery and mutation are conservative: an object or a path whose shape cannot be proven is left untouched, and the reason is reported on `CsfFile.mutationDiagnostics` as a `CsfMutationDiagnostic`. From 08b60d1b3a9a4594358cba35f079ec4a044d4182 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:25:05 +0200 Subject: [PATCH 257/326] feat(csf-tools): expose the meta component's import as CsfFile.componentImport `meta.component` is the component as written and `imports` lists module specifiers, but which export a component was imported as, and under which local name, was only on the private `_componentImportSpecifier` node. An addon that rewrites or resolves the component had to reach into it and handle default and string-named specifiers itself. `componentImport` returns `{ source, imported, local }`, with `imported` set to `'default'` for a default import, or `undefined` when `component` is not an identifier bound by a default or named import. `_componentImportSpecifier` stays as it is for the vitest transformer. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/CsfFile.test.ts | 34 +++++++++++++++++++++++ code/core/src/csf-tools/CsfFile.ts | 36 +++++++++++++++++++++++++ code/core/src/csf-tools/README.md | 2 ++ 3 files changed, 72 insertions(+) diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index 1f56c75db6d1..34199647bd79 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -2488,6 +2488,40 @@ describe('CsfFile', () => { }); }); + describe('component import', () => { + const componentImport = (code: string) => + loadCsf(code, { makeTitle, fileName: 'Button.stories.ts' }).parse().componentImport; + + it.each([ + { + code: `import { Button as B } from './Button';\nexport default { component: B };`, + expected: { source: './Button', imported: 'Button', local: 'B' }, + }, + { + code: `import Button from './Button';\nexport default { component: Button };`, + expected: { source: './Button', imported: 'default', local: 'Button' }, + }, + { + code: `import { 'my-button' as B } from 'lib';\nexport default { component: B };`, + expected: { source: 'lib', imported: 'my-button', local: 'B' }, + }, + { + code: `import preview from '#.storybook/preview';\nimport { Button } from './Button';\nconst meta = preview.meta({ component: Button });\nexport const A = meta.story();`, + expected: { source: './Button', imported: 'Button', local: 'Button' }, + }, + ])('reads $expected.local from $expected.source', ({ code, expected }) => { + expect(componentImport(code)).toEqual(expected); + }); + + it.each([ + `export default {};`, + `const Button = () => null;\nexport default { component: Button };`, + `import * as UI from './ui';\nexport default { component: UI.Button };`, + ])('is undefined for %s', (code) => { + expect(componentImport(code)).toBeUndefined(); + }); + }); + describe('beforeEach', () => { it('basic', () => { expect( diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index ec5849435aec..47cee9f5b38f 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -313,6 +313,13 @@ export interface StoryTest { parent: { node: Node | undefined }; } +/** The import a meta's `component` refers to: `imported` is `'default'` for a default import. */ +export interface CsfComponentImport { + source: string; + imported: string; + local: string; +} + export class CsfFile { _editor: SourceEditor; @@ -1104,6 +1111,35 @@ export class CsfFile { return this._meta; } + /** + * Read the import the meta's `component` refers to, or `undefined` when `component` is not an + * identifier bound by a default or named import, such as a local component, a namespace import or + * member, or `Button as any`. + * + * @example + * ```ts + * const csf = loadCsf( + * "import { Button as B } from './Button';\nexport default { component: B };", + * { makeTitle: () => 'Example' } + * ).parse(); + * csf.componentImport; // { source: './Button', imported: 'Button', local: 'B' } + * ``` + */ + public get componentImport(): CsfComponentImport | undefined { + this._assertIndexed(); + const specifier = this._componentImportSpecifier; + if (!specifier || this._rawComponentPath === undefined) { + return undefined; + } + const imported = + specifier.type === 'ImportDefaultSpecifier' + ? 'default' + : specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + return { source: this._rawComponentPath, imported, local: specifier.local.name }; + } + public get stories() { this._assertIndexed(); return Object.values(this._stories); diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index c88e14315ce4..df4daac98f3f 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -9,6 +9,8 @@ An experimental library to read, analyze, transform, and write CSF programmatica It can parse MDX into CSF. +`CsfFile.meta.component` is the meta's `component` as written. `CsfFile.componentImport` is the import it refers to, as `{ source, imported, local }`, so `import { Button as B } from './Button'` gives `{ source: './Button', imported: 'Button', local: 'B' }`; `imported` is `'default'` for a default import. It is `undefined` when `component` is not an identifier bound by a default or named import, such as a local component, a namespace import or a member like `UI.Button`, or `Button as any`. + ## Transforming stories `CsfFile.objects()` returns one `CsfObject` editor per mutable object it can prove safe to edit: the meta, each story export, and each CSF2 `Story.parameters` / `Story.argTypes` annotation assignment. From 89aee3c440f58fd49437685f6435c3836727639c Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:28:28 +0200 Subject: [PATCH 258/326] docs(csf-tools): explain how to tell a missing field from an unreadable one `get` and `getValue` return `undefined` for a missing field and for one they cannot read statically, and the README only said so for `getValue`. A port that treats every `undefined` as missing overwrites values it could not see. The README now spells out both cases, that only the second adds a CsfMutationDiagnostic, that a field written as `undefined` is a third case `get` can tell apart, and shows the before/after diagnostics check. It also notes that a read diagnostic makes writeConfig refuse the file. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/ConfigFile.ts | 4 +++- code/core/src/csf-tools/CsfObject.ts | 4 +++- code/core/src/csf-tools/README.md | 18 +++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 99a6943b7a66..12174059305f 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -244,7 +244,9 @@ export class ConfigFile implements CsfObject { } /** - * Read a detached copy of the expression at a property path. Missing fields return `undefined`. + * Read a detached copy of the expression at a property path. Missing fields return `undefined`; + * so does a path through a value that is not an object literal, or one a spread, computed key, or + * duplicate field leaves unproven, which also adds a mutation diagnostic. * * @example * ```ts diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 6d6463df3df4..1c663eaeb4ea 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -126,7 +126,9 @@ export interface CsfObject { readonly node: E.ObjectExpression | undefined; /** * Read a detached copy of the expression at a property path. For a CSF2 annotation, its - * annotation path returns the whole object. Missing fields return `undefined`. + * annotation path returns the whole object. Missing fields return `undefined`; so does a path + * through a value that is not an object literal, or one a spread, computed key, or duplicate field + * leaves unproven, which also adds a mutation diagnostic. * * @example * ```ts diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index df4daac98f3f..ba44cb768bb2 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -17,7 +17,23 @@ It can parse MDX into CSF. `{ meta, stories }` narrows what is discovered; both are included by default. Story discovery also includes separate CSF2 parameter assignments, so callers use the same paths regardless of the source format. Each editor reads and writes static property paths with `get`, `getValue`, `set`, `transform`, `remove`, `rename`, `move`, and `group`. -`get` returns an AST expression; `getValue` reads plain values from literals, nested arrays and objects, and local constants without executing code. Missing fields return `undefined`. Values that cannot be resolved statically return `undefined` and add a mutation diagnostic; reads never return a partially decoded object. +`get` returns an AST expression; `getValue` reads plain values from literals, nested arrays and objects, and local constants without executing code. Reads never return a partially decoded object. + +`get` and `getValue` return `undefined` both for a field that is missing and for one that cannot be read statically. Only the second adds a `CsfMutationDiagnostic` to `mutationDiagnostics`. For `get` that is a path whose parent is not an object literal, or that a spread, a computed key, or a duplicate field leaves unproven; `getValue` also reports a value it cannot evaluate, such as a call or a function. A field written as `undefined` is `undefined` without a diagnostic too, but `get` returns its expression. Compare the diagnostics before and after a read to tell the cases apart: + +```ts +const before = csf.mutationDiagnostics.length; +const size = story.getValue(['args', 'size']); +const [unreadable] = csf.mutationDiagnostics.slice(before); +if (size === undefined && unreadable) { + // `args.size` may be set to a value that cannot be read statically: see `unreadable.code` +} else if (size === undefined) { + // `args.size` is missing, or written as `undefined` +} +``` + +Read diagnostics count like mutation ones, so `writeConfig` refuses a config after a read that added one. + `set` accepts an expression from `get` or `parseExpression`, or plain strings, numbers, booleans, `null`, `undefined`, and nested arrays or objects of those values and expressions. An object whose `type` names an AST node is rejected with a diagnostic unless it came from `get` or `parseExpression`. Plain values are printed into the source, so later changes to the input do not affect the file. An expression from `get` is indented as if its line had no indent, and `set` and `transform` indent a value's lines like the line of the field it is written to, so a value copied between fields keeps its layout. Successful removals and moves recursively remove empty source parents, stopping at the editor's root object or an empty parent that shadows an earlier spread or computed key. Removing a field that could reveal an earlier spread value produces a diagnostic. Moves clean up after inserting the destination, so shared ancestors remain intact. Unrelated empty objects are preserved. From f3969663130f89135665d8ac7c0557f424a9e052 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 11:23:18 +0200 Subject: [PATCH 259/326] JSDoc improvements --- .../common/js-package-manager/PNPMProxy.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index fdcb53f43e64..72572b8083df 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -119,6 +119,12 @@ function hasAllowBuildsDecision(settings: unknown, pkg: string): boolean { ); } +/** + * Reads an existing `onlyBuiltDependenciesFile` and returns its contents as an array of package names or pins. + * @param file The path to the `onlyBuiltDependenciesFile`. + * @param dir The directory containing the `onlyBuiltDependenciesFile`. + * @returns An array of package names listed in the file, or an empty array if the file does not exist or is invalid. + */ function readOnlyBuiltDependenciesFile(file: unknown, dir: string): unknown[] { if (typeof file !== 'string') { return []; @@ -131,13 +137,23 @@ function readOnlyBuiltDependenciesFile(file: unknown, dir: string): unknown[] { } } -// pnpm 10 refuses to combine `neverBuiltDependencies` with any allow list, so its presence alone -// is a decision. +/** + * Checks whether there is any user setting that indicates we cannot decide for them to + * preallow a package. This can be because they made an explicit decision, or because us + * setting `allowBuilds` in a `pnpm-workspace.yaml` file would lead to invalid config + * or unwanted side effects. + * @param settings The PNPM settings object to check. + * @param pkg The package name to check for an existing decision. + * @param dir The directory containing the `onlyBuiltDependenciesFile`. + * @returns `true` if there is an existing decision that prevents preallowing the package, `false` otherwise. + */ function hasPnpm10ListDecision(settings: unknown, pkg: string, dir: string): boolean { if (!isRecord(settings)) { return false; } return ( + // pnpm 10 refuses to combine `neverBuiltDependencies` with any allow list, + // so its presence alone is a decision. Array.isArray(settings.neverBuiltDependencies) || listsPackage(settings.onlyBuiltDependencies, pkg) || listsPackage(settings.ignoredBuiltDependencies, pkg) || From 266bce906a19af035a57cf641abf50a87ac2d8dd Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 11:27:23 +0200 Subject: [PATCH 260/326] Core: Write pnpm-workspace.yaml directly and unit-test the build-script helpers The workspace file is already written with a plain writeFileSync by the catalog and minimum-release-age paths, so the temp-file rename was an exception without a reason. The decision helpers move to their own module so they can be tested on their own. Co-Authored-By: Claude Fable 5.1 --- .../PNPMProxy.catalog.test.ts | 6 +- .../js-package-manager/PNPMProxy.test.ts | 20 +-- .../common/js-package-manager/PNPMProxy.ts | 120 +-------------- .../pnpm-build-scripts.test.ts | 141 ++++++++++++++++++ .../js-package-manager/pnpm-build-scripts.ts | 98 ++++++++++++ 5 files changed, 245 insertions(+), 140 deletions(-) create mode 100644 code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts create mode 100644 code/core/src/common/js-package-manager/pnpm-build-scripts.ts diff --git a/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts index f7b1f91dc5c6..34deddbafd01 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts @@ -1,4 +1,4 @@ -import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'pathe'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -32,14 +32,10 @@ describe('PNPMProxy catalogs', () => { // Restore the real fs so the proxy can be constructed, then redirect fs to memfs. vi.mocked(readFileSync).mockReset(); vi.mocked(writeFileSync).mockReset(); - vi.mocked(renameSync).mockReset(); - vi.mocked(unlinkSync).mockReset(); pnpmProxy = new PNPMProxy(); vol.reset(); vi.mocked(readFileSync).mockImplementation(memfs.readFileSync as typeof readFileSync); vi.mocked(writeFileSync).mockImplementation(memfs.writeFileSync as typeof writeFileSync); - vi.mocked(renameSync).mockImplementation(memfs.renameSync as typeof renameSync); - vi.mocked(unlinkSync).mockImplementation(memfs.unlinkSync as typeof unlinkSync); vi.mocked(getProjectRoot).mockReturnValue('/root'); vi.mocked(find.up).mockReturnValue(WORKSPACE_YAML); // Drive the version resolution through controllable inputs. diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index 18ff007be8d9..d3447580cc0a 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -1,4 +1,4 @@ -import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -67,16 +67,12 @@ describe('PNPM Proxy', () => { beforeEach(() => { vi.mocked(readFileSync).mockReset(); vi.mocked(writeFileSync).mockReset(); - vi.mocked(renameSync).mockReset(); - vi.mocked(unlinkSync).mockReset(); mockPnpmVersion('11.18.0'); pnpmProxy = new PNPMProxy(); vol.reset(); vol.mkdirSync(PROJECT_DIR, { recursive: true }); vi.mocked(readFileSync).mockImplementation(memfs.readFileSync as typeof readFileSync); vi.mocked(writeFileSync).mockImplementation(memfs.writeFileSync as typeof writeFileSync); - vi.mocked(renameSync).mockImplementation(memfs.renameSync as typeof renameSync); - vi.mocked(unlinkSync).mockImplementation(memfs.unlinkSync as typeof unlinkSync); useMemfsProject(pnpmProxy); JsPackageManager.clearLatestVersionCache(); vi.spyOn(pnpmProxy, 'writePackageJson').mockImplementation(vi.fn()); @@ -169,16 +165,6 @@ describe('PNPM Proxy', () => { await install(); expect(workspaceYaml()).toBe('allowBuilds:\n esbuild: true\n'); - expect(writeFileSync).toHaveBeenCalledWith( - expect.stringMatching(/\/project\/pnpm-workspace\.yaml\.[0-9a-f]+\.tmp$/), - 'allowBuilds:\n esbuild: true\n', - 'utf8' - ); - expect(renameSync).toHaveBeenCalledWith( - expect.stringMatching(/\/project\/pnpm-workspace\.yaml\.[0-9a-f]+\.tmp$/), - WORKSPACE_YAML - ); - expect(Object.keys(vol.toJSON()).some((filePath) => filePath.endsWith('.tmp'))).toBe(false); expect(mockedExecuteCommand).toHaveBeenCalledWith( expect.objectContaining({ command: 'pnpm', args: ['install'] }) ); @@ -228,10 +214,6 @@ describe('PNPM Proxy', () => { 'a git-pinned allowBuilds matcher', "allowBuilds:\n 'esbuild@git+ssh://git@example.com/esbuild.git': true\n", ], - [ - 'a pinned allowBuilds matcher pnpm has not resolved yet', - 'allowBuilds:\n esbuild@0.28.2: set this to true or false\n', - ], ['dangerouslyAllowAllBuilds', 'dangerouslyAllowAllBuilds: true\n'], ])('does not add a bare esbuild key when %s already covers it', async (_, yaml) => { vol.fromJSON({ [WORKSPACE_YAML]: yaml }); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 72572b8083df..6153fd707ab3 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -1,6 +1,5 @@ -import { randomBytes } from 'node:crypto'; -import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'pathe'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'pathe'; import { logger, prompt } from 'storybook/internal/node-logger'; import { @@ -20,6 +19,7 @@ import { executeCommand, executeCommandSync } from '../utils/command.ts'; import { getProjectRoot } from '../utils/paths.ts'; import { JsPackageManager, PackageManagerName } from './JsPackageManager.ts'; import type { PackageJson } from './PackageJson.ts'; +import { hasAllowBuildsDecision, hasPnpm10ListDecision } from './pnpm-build-scripts.ts'; import type { InstallationMetadata, PackageMetadata } from './types.ts'; import { getAgeInMinutes, @@ -65,118 +65,6 @@ const PNPM_LEGACY_BUILD_LISTS_REMOVED = '11.0.0'; // Storybook dependencies with a build script that pnpm 10+ refuses to run until it is approved. const STORYBOOK_BUILD_SCRIPT_PACKAGES = ['esbuild']; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -/** - * Matches a package name, including pinned versions of that package. - * For instance, if `value` is `esbuild@0.25.0 || 0.25.1`, it matches `pkg` `esbuild`. - * @param value The package name or pinned version to match against. - * @param pkg The package name to check against the value. - * @returns whether the package matches the given value. - */ -const matchesPackage = (value: unknown, pkg: string): boolean => - typeof value === 'string' && (value === pkg || value.startsWith(`${pkg}@`)); - -/** - * Checks if a list of packages matches a given package name, including if the - * list contains pinned versions of that package. - * @param list List of package names to check. - * @param pkg The package name to check against the list of matchers. - * @returns whether the package matches any of the entries in the list. - */ -const listsPackage = (list: unknown, pkg: string): boolean => - Array.isArray(list) && list.some((value) => matchesPackage(value, pkg)); - -/** - * Checks if PNPM settings contain an `allowBuilds` decision for the given package. - * Does not check whether it's allowed or refused, we only care to not modify `allowBuilds` - * when a pre-existing user decision exists. - * - * Note however that PNPM 12 inserts placeholder keys in `allowBuilds` for package names - * where the user has not made a decision. This does not happen for pinned versions but - * only for exact matches. So we verify we do have a boolean value for exact matches. - * - * @param settings The PNPM settings object to check. - * @param pkg The package name to check for an `allowBuilds` decision. - * @returns `true` if there is an existing `allowBuilds` decision for the package, `false` otherwise. - */ -function hasAllowBuildsDecision(settings: unknown, pkg: string): boolean { - if (!isRecord(settings)) { - return false; - } - - if (settings.dangerouslyAllowAllBuilds === true) { - return true; - } - - const { allowBuilds } = settings; - return ( - isRecord(allowBuilds) && - Object.keys(allowBuilds).some( - (matcher) => matchesPackage(matcher, pkg) && typeof allowBuilds[matcher] === 'boolean' - ) - ); -} - -/** - * Reads an existing `onlyBuiltDependenciesFile` and returns its contents as an array of package names or pins. - * @param file The path to the `onlyBuiltDependenciesFile`. - * @param dir The directory containing the `onlyBuiltDependenciesFile`. - * @returns An array of package names listed in the file, or an empty array if the file does not exist or is invalid. - */ -function readOnlyBuiltDependenciesFile(file: unknown, dir: string): unknown[] { - if (typeof file !== 'string') { - return []; - } - try { - const list = JSON.parse(readFileSync(resolve(dir, file), 'utf8')); - return Array.isArray(list) ? list : []; - } catch { - return []; - } -} - -/** - * Checks whether there is any user setting that indicates we cannot decide for them to - * preallow a package. This can be because they made an explicit decision, or because us - * setting `allowBuilds` in a `pnpm-workspace.yaml` file would lead to invalid config - * or unwanted side effects. - * @param settings The PNPM settings object to check. - * @param pkg The package name to check for an existing decision. - * @param dir The directory containing the `onlyBuiltDependenciesFile`. - * @returns `true` if there is an existing decision that prevents preallowing the package, `false` otherwise. - */ -function hasPnpm10ListDecision(settings: unknown, pkg: string, dir: string): boolean { - if (!isRecord(settings)) { - return false; - } - return ( - // pnpm 10 refuses to combine `neverBuiltDependencies` with any allow list, - // so its presence alone is a decision. - Array.isArray(settings.neverBuiltDependencies) || - listsPackage(settings.onlyBuiltDependencies, pkg) || - listsPackage(settings.ignoredBuiltDependencies, pkg) || - listsPackage(readOnlyBuiltDependenciesFile(settings.onlyBuiltDependenciesFile, dir), pkg) - ); -} - -// Same-directory rename so a crash cannot truncate the user's workspace file. -function writeFileAtomically(filePath: string, contents: string): void { - const tempPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp`; - try { - writeFileSync(tempPath, contents, 'utf8'); - renameSync(tempPath, filePath); - } catch (error) { - try { - unlinkSync(tempPath); - } catch { - // The temp file was never created, or rename already consumed it. - } - throw error; - } -} - export class PNPMProxy extends JsPackageManager { readonly type = PackageManagerName.PNPM; @@ -480,7 +368,7 @@ export class PNPMProxy extends JsPackageManager { for (const pkg of pending) { doc.setIn(['allowBuilds', pkg], true); } - writeFileAtomically(path, doc.toString()); + writeFileSync(path, doc.toString(), 'utf8'); } catch (e) { logger.warn( `Could not allow build scripts of ${STORYBOOK_BUILD_SCRIPT_PACKAGES.join(', ')} in ${path}: ${String(e)}` diff --git a/code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts b/code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts new file mode 100644 index 000000000000..e0e25cfa8940 --- /dev/null +++ b/code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fs as memfs, vol } from 'memfs'; + +import { + hasAllowBuildsDecision, + hasPnpm10ListDecision, + listsPackage, + matchesPackage, + readOnlyBuiltDependenciesFile, +} from './pnpm-build-scripts.ts'; + +vi.mock('node:fs', { spy: true }); + +describe('matchesPackage', () => { + it.each([ + ['esbuild', true], + ['esbuild@0.25.0', true], + ['esbuild@0.25.0 || 0.25.1', true], + ['esbuild@git+ssh://git@example.com/esbuild.git', true], + ['esbuild-wasm', false], + ['esbuild-wasm@1.0.0', false], + ['@scope/esbuild', false], + [42, false], + [null, false], + ])('%j matches esbuild: %s', (value, expected) => { + expect(matchesPackage(value, 'esbuild')).toBe(expected); + }); +}); + +describe('listsPackage', () => { + it('finds a bare or pinned entry', () => { + expect(listsPackage(['sharp', 'esbuild@0.25.0'], 'esbuild')).toBe(true); + }); + + it('is false for a list of other packages, or anything that is not a list', () => { + expect(listsPackage(['sharp', 'esbuild-wasm'], 'esbuild')).toBe(false); + expect(listsPackage('esbuild', 'esbuild')).toBe(false); + expect(listsPackage(undefined, 'esbuild')).toBe(false); + }); +}); + +describe('hasAllowBuildsDecision', () => { + it.each([ + ['an approval', { allowBuilds: { esbuild: true } }], + ['a denial', { allowBuilds: { esbuild: false } }], + ['a version-pinned entry', { allowBuilds: { 'esbuild@0.25.0 || 0.25.1': false } }], + ['dangerouslyAllowAllBuilds', { dangerouslyAllowAllBuilds: true }], + ])('is true for %s', (_, settings) => { + expect(hasAllowBuildsDecision(settings, 'esbuild')).toBe(true); + }); + + it.each([ + [ + 'the placeholder pnpm writes for an unreviewed package', + { allowBuilds: { esbuild: 'set this to true or false' } }, + ], + ['entries for other packages', { allowBuilds: { sharp: true, 'esbuild-wasm@1.0.0': true } }], + ['an empty allowBuilds key', { allowBuilds: null }], + ['allowBuilds that is not a map', { allowBuilds: ['esbuild'] }], + ['dangerouslyAllowAllBuilds set to false', { dangerouslyAllowAllBuilds: false }], + ['no settings', undefined], + ])('is false for %s', (_, settings) => { + expect(hasAllowBuildsDecision(settings, 'esbuild')).toBe(false); + }); +}); + +describe('readOnlyBuiltDependenciesFile', () => { + beforeEach(() => { + vol.reset(); + vi.mocked(readFileSync).mockImplementation(memfs.readFileSync as typeof readFileSync); + }); + + it('reads the list relative to the workspace directory', () => { + vol.fromJSON({ '/root/trusted/allow.json': '["@swc/core", "esbuild@0.25.0"]' }); + + expect(readOnlyBuiltDependenciesFile('trusted/allow.json', '/root')).toEqual([ + '@swc/core', + 'esbuild@0.25.0', + ]); + }); + + it.each([ + ['the setting is absent', undefined, {}], + ['the file is missing', 'trusted/allow.json', {}], + ['the file is not JSON', 'trusted/allow.json', { '/root/trusted/allow.json': '[' }], + [ + 'the file holds no list', + 'trusted/allow.json', + { '/root/trusted/allow.json': '{"esbuild": true}' }, + ], + ])('lists nothing when %s', (_, file, files) => { + vol.fromJSON(files); + + expect(readOnlyBuiltDependenciesFile(file, '/root')).toEqual([]); + }); +}); + +describe('hasPnpm10ListDecision', () => { + beforeEach(() => { + vol.reset(); + vi.mocked(readFileSync).mockImplementation(memfs.readFileSync as typeof readFileSync); + }); + + it.each([ + ['onlyBuiltDependencies names the package', { onlyBuiltDependencies: ['esbuild'] }], + ['ignoredBuiltDependencies pins the package', { ignoredBuiltDependencies: ['esbuild@0.25.0'] }], + ['neverBuiltDependencies names the package', { neverBuiltDependencies: ['esbuild'] }], + ['neverBuiltDependencies names only other packages', { neverBuiltDependencies: ['fsevents'] }], + ['neverBuiltDependencies is empty', { neverBuiltDependencies: [] }], + ])('is true when %s', (_, settings) => { + expect(hasPnpm10ListDecision(settings, 'esbuild', '/root')).toBe(true); + }); + + it('is true when the trusted-dependencies file names the package', () => { + vol.fromJSON({ '/root/trusted/allow.json': '["esbuild"]' }); + + expect( + hasPnpm10ListDecision({ onlyBuiltDependenciesFile: 'trusted/allow.json' }, 'esbuild', '/root') + ).toBe(true); + }); + + it.each([ + [ + 'the lists name only other packages', + { onlyBuiltDependencies: ['sharp'], ignoredBuiltDependencies: ['fsevents'] }, + ], + [ + 'the trusted-dependencies file names only other packages', + { onlyBuiltDependenciesFile: 'trusted/allow.json' }, + ], + ['only allowBuilds is set', { allowBuilds: { esbuild: true } }], + ['no settings', undefined], + ])('is false when %s', (_, settings) => { + vol.fromJSON({ '/root/trusted/allow.json': '["sharp"]' }); + + expect(hasPnpm10ListDecision(settings, 'esbuild', '/root')).toBe(false); + }); +}); diff --git a/code/core/src/common/js-package-manager/pnpm-build-scripts.ts b/code/core/src/common/js-package-manager/pnpm-build-scripts.ts new file mode 100644 index 000000000000..ad9b1b5939c0 --- /dev/null +++ b/code/core/src/common/js-package-manager/pnpm-build-scripts.ts @@ -0,0 +1,98 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'pathe'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Matches a package name, including pinned versions of that package. + * For instance, if `value` is `esbuild@0.25.0 || 0.25.1`, it matches `pkg` `esbuild`. + * @param value The package name or pinned version to match against. + * @param pkg The package name to check against the value. + * @returns whether the package matches the given value. + */ +export const matchesPackage = (value: unknown, pkg: string): boolean => + typeof value === 'string' && (value === pkg || value.startsWith(`${pkg}@`)); + +/** + * Checks if a list of packages matches a given package name, including if the + * list contains pinned versions of that package. + * @param list List of package names to check. + * @param pkg The package name to check against the list of matchers. + * @returns whether the package matches any of the entries in the list. + */ +export const listsPackage = (list: unknown, pkg: string): boolean => + Array.isArray(list) && list.some((value) => matchesPackage(value, pkg)); + +/** + * Checks if PNPM settings contain an `allowBuilds` decision for the given package. + * Does not check whether it's allowed or refused, we only care to not modify `allowBuilds` + * when a pre-existing user decision exists. + * + * Note however that PNPM 12 inserts placeholder keys in `allowBuilds` for package names + * where the user has not made a decision. This does not happen for pinned versions but + * only for exact matches. So we verify we do have a boolean value for exact matches. + * + * @param settings The PNPM settings object to check. + * @param pkg The package name to check for an `allowBuilds` decision. + * @returns `true` if there is an existing `allowBuilds` decision for the package, `false` otherwise. + */ +export function hasAllowBuildsDecision(settings: unknown, pkg: string): boolean { + if (!isRecord(settings)) { + return false; + } + + if (settings.dangerouslyAllowAllBuilds === true) { + return true; + } + + const { allowBuilds } = settings; + return ( + isRecord(allowBuilds) && + Object.keys(allowBuilds).some( + (matcher) => matchesPackage(matcher, pkg) && typeof allowBuilds[matcher] === 'boolean' + ) + ); +} + +/** + * Reads an existing `onlyBuiltDependenciesFile` and returns its contents as an array of package names or pins. + * @param file The path to the `onlyBuiltDependenciesFile`. + * @param dir The directory containing the `onlyBuiltDependenciesFile`. + * @returns An array of package names listed in the file, or an empty array if the file does not exist or is invalid. + */ +export function readOnlyBuiltDependenciesFile(file: unknown, dir: string): unknown[] { + if (typeof file !== 'string') { + return []; + } + try { + const list = JSON.parse(readFileSync(resolve(dir, file), 'utf8')); + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +/** + * Checks whether there is any user setting that indicates we cannot decide for them to + * preallow a package. This can be because they made an explicit decision, or because us + * setting `allowBuilds` in a `pnpm-workspace.yaml` file would lead to invalid config + * or unwanted side effects. + * @param settings The PNPM settings object to check. + * @param pkg The package name to check for an existing decision. + * @param dir The directory containing the `onlyBuiltDependenciesFile`. + * @returns `true` if there is an existing decision that prevents preallowing the package, `false` otherwise. + */ +export function hasPnpm10ListDecision(settings: unknown, pkg: string, dir: string): boolean { + if (!isRecord(settings)) { + return false; + } + return ( + // pnpm 10 refuses to combine `neverBuiltDependencies` with any allow list, + // so its presence alone is a decision. + Array.isArray(settings.neverBuiltDependencies) || + listsPackage(settings.onlyBuiltDependencies, pkg) || + listsPackage(settings.ignoredBuiltDependencies, pkg) || + listsPackage(readOnlyBuiltDependenciesFile(settings.onlyBuiltDependenciesFile, dir), pkg) + ); +} From 2b30d6cd6c075af4dc447e71066d35d7eb16557f Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:13:14 +0200 Subject: [PATCH 261/326] fix(csf-tools): append after the items an earlier append of the batch added `appendToList` read only the code the editor was created with, so a second append to the same list before a commit did not see the first. A multi-line list whose last item has a trailing comment and no comma got `Button,, // c` and items without a comma between them; an empty `{}` got `{ a: 1 b: 2 }`, and `{ }` lost the first item to the second overwrite. `enrichCsf` and a bundler transformer both appending to a meta with a JSDoc description and no `parameters` hit this. Each batch of edits now remembers per list what its appends wrote, and a later append goes after it with the comma it needs. The text that closes an empty list is written to the right of its end, so later items land before it. Two appends now write what one append of both items writes, except after an item ending in a line comment, where the comma goes on the next line. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/editor.test.ts | 48 ++++++ code/core/src/csf-tools/estree/editor.ts | 140 ++++++++++++++---- 2 files changed, 158 insertions(+), 30 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index a72da53188b4..d79476700384 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -286,6 +286,54 @@ describe('lists', () => { }); }); +describe('appendToList', () => { + const exported = (editor: SourceEditor) => + (editor.program.body[0] as E.ExportDefaultDeclaration).declaration as + | E.ObjectExpression + | E.ArrayExpression; + const listOf = (editor: SourceEditor) => { + const node = exported(editor); + return node.type === 'ObjectExpression' ? objectList(editor, node) : arrayList(editor, node); + }; + const append = (code: string, ...batches: string[][]) => { + const editor = new SourceEditor(code); + for (const texts of batches) { + appendToList(editor, listOf(editor), texts); + } + return editor.toString(); + }; + + it.each([ + "export default {\n title: 'X',\n component: Button // c\n};", + "export default {\n title: 'X',\n component: Button, // c\n};", + "export default {\n title: 'X',\n component: Button\n};", + "export default { title: 'X' };", + "export default { title: 'X', };", + "export default { title: 'X' /* c */ };", + 'export default {};', + 'export default { };', + 'export default {\n};', + 'export default { /* c */ };', + 'export default {/* c */};', + 'export default {\n // c\n};', + 'export default [];', + 'export default [a, ,];', + ])('appends in two calls of one batch what one call appends, to %j', (code) => { + const batch = code.includes('[') ? [['x'], ['y']] : [['a: 1'], ['b: 2']]; + const once = append(code, batch.flat()); + + expect(append(code, ...batch)).toBe(once); + expect(() => parseModule(once)).not.toThrow(); + }); + + it('separates an item from an earlier one of the batch that ends in a line comment', () => { + const output = append("export default {\n title: 'X'\n};", ['a: x // c\n'], ['b: 2']); + + expect(output).toBe("export default {\n title: 'X',\n a: x // c\n ,\n b: 2\n};"); + expect(() => parseModule(output)).not.toThrow(); + }); +}); + describe('parenthesized array elements', () => { it('appends after a comma expression and removes a doubly parenthesized one whole', () => { const editor = new SourceEditor(`export default { addons: [(a, b)] };`); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 2b79fef04ff9..319ca3cbf032 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -818,10 +818,61 @@ const inline = ( return joined; }; -export const appendToList = (editor: SourceEditor, list: List, texts: string[]) => { - editor.assertCurrent(list); +// What the appends of a batch of edits wrote to a list: where later items go, one per line at +// `indent` or on the list's line without one, the list's trailing comma, whether a line break +// follows, and what has to come before the next item. +interface Appended { + at: number; + indent: string | undefined; + trailingComma: string; + lineBreakFollows: boolean; + separator: string; +} + +const appendedTo = new WeakMap>(); + +// Items one per line at `indent`, each after a line break. +const itemLines = ( + texts: string[], + indent: string, + trailingComma: string, + lineBreakFollows: boolean +) => + texts + .map((text, index) => { + const isLast = index === texts.length - 1; + return `\n${indent}${withComma(reindent(text, '', indent), isLast ? trailingComma : ',', !isLast || lineBreakFollows)}`; + }) + .join(''); + +// A line comment ending the last item puts the comma for the next one on the next line. +const appended = ( + layout: Omit, + written: string, + lastItem: string +): Appended => ({ + ...layout, + separator: + layout.trailingComma === ',' + ? '' + : endsInLineComment(lastItem) + ? `${written.endsWith('\n') ? '' : '\n'}${layout.indent ?? ''},` + : ',', +}); + +const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appended => { const { code, edits } = editor; const { items, padding } = list; + const write = (layout: Omit, text: string) => { + edits.appendLeft(layout.at, text); + return appended(layout, text, texts.at(-1)!); + }; + // The text after the items goes to the right of the list's end, so later items go before it. + const close = (text: string) => { + if (text !== '') { + edits.appendRight(list.close, text); + } + }; if (items.length === 0) { // Only comments can be inside an empty list, and the items go after them. const inner = code.slice(list.open, list.close); @@ -838,18 +889,22 @@ export const appendToList = (editor: SourceEditor, list: List, texts: string[]) if (list.close > end) { edits.remove(end, list.close); } - edits.appendLeft( - end, - `\n${texts.map((text) => `${indent}${withComma(reindent(text, '', indent), ',', true)}`).join('\n')}\n${outer}` - ); - } else if (end > list.open) { - edits.appendLeft(end, ` ${texts.join(', ')}${end === list.close ? padding : ''}`); - } else if (list.close > list.open) { - edits.overwrite(list.open, list.close, `${padding}${texts.join(', ')}${padding}`); - } else { - edits.appendLeft(list.open, `${padding}${texts.join(', ')}${padding}`); + close(`\n${outer}`); + const layout = { at: end, indent, trailingComma: ',', lineBreakFollows: true }; + return write(layout, itemLines(texts, indent, ',', true)); } - return; + const inline = { indent: undefined, trailingComma: '', lineBreakFollows: false }; + if (end > list.open) { + if (end === list.close) { + close(padding); + } + return write({ ...inline, at: end }, ` ${texts.join(', ')}`); + } + if (list.close > list.open) { + edits.remove(list.open, list.close); + } + close(padding); + return write({ ...inline, at: list.open }, `${padding}${texts.join(', ')}`); } const last = items.at(-1)!; const comma = separatorAfter(editor, last); @@ -858,28 +913,53 @@ export const appendToList = (editor: SourceEditor, list: List, texts: string[]) if (isMultiline(code, list)) { const indent = lineIndent(code, last.start); const at = itemEnd(editor, list, items.length - 1); - const block = texts - .map((text, index) => { - const isLast = index === texts.length - 1; - const separator = isLast ? trailingComma : ','; - const lineBreakFollows = !isLast || lineBreakAt(code, at); - return `${indent}${withComma(reindent(text, '', indent), separator, lineBreakFollows)}`; - }) - .join('\n'); if (comma === undefined) { edits.appendLeft(last.end, ','); } - edits.appendLeft(at, `\n${block}`); - return; - } - if (comma !== undefined) { - edits.appendLeft( - comma, - ` ${inline(editor, list, texts, trailingComma, lineBreakAt(code, comma))}` + const lineBreakFollows = lineBreakAt(code, at); + return write( + { at, indent, trailingComma, lineBreakFollows }, + itemLines(texts, indent, trailingComma, lineBreakFollows) ); - } else { - edits.appendLeft(last.end, `, ${inline(editor, list, texts, '', lineBreakAt(code, last.end))}`); } + const at = comma ?? last.end; + const lineBreakFollows = lineBreakAt(code, at); + const text = inline(editor, list, texts, trailingComma, lineBreakFollows); + return write( + { at, indent: undefined, trailingComma, lineBreakFollows }, + comma === undefined ? `, ${text}` : ` ${text}` + ); +}; + +const appendAfter = (editor: SourceEditor, list: List, previous: Appended, texts: string[]) => { + const { at, indent, trailingComma, lineBreakFollows, separator } = previous; + const text = + indent === undefined + ? ` ${inline(editor, list, texts, trailingComma, lineBreakFollows)}` + : itemLines(texts, indent, trailingComma, lineBreakFollows); + editor.edits.appendLeft(at, `${separator}${text}`); + return appended(previous, text, texts.at(-1)!); +}; + +/** + * Append items to a list, after the items earlier appends of the same batch of edits added to it. + * That writes what one append of all of them writes, except that a comma after an item ending in + * a line comment goes on the next line, and items stay on the line of a list an earlier append + * laid out so. + */ +export const appendToList = (editor: SourceEditor, list: List, texts: string[]) => { + editor.assertCurrent(list); + let lists = appendedTo.get(editor.edits); + if (!lists) { + lists = new Map(); + appendedTo.set(editor.edits, lists); + } + const key = `${list.open}:${list.close}`; + const previous = lists.get(key); + lists.set( + key, + previous ? appendAfter(editor, list, previous, texts) : appendFirst(editor, list, texts) + ); }; export const prependToList = (editor: SourceEditor, list: List, texts: string[]) => { From fa5d69cc75ce3a1a49f79b6ffefecfe1c4c8f925 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:15:58 +0200 Subject: [PATCH 262/326] fix(csf-tools): keep a wrapper around its expression alone when list edits write next to it `wrapExpression` wrote its suffix with `appendLeft`, the same insertion `appendToList` uses for an item after the last one. When the item was appended first, `render: (args) => null` became `render: (wrapped((args) => null, parameters: {}))`. Wrapping the same expression twice was broken the same way: `[(x])`. Edits at the same offset now follow one rule: text an edit writes at an expression's end, such as the codemod's `.input`, and an earlier wrapper belong to the expression, so a later wrapper goes around them. `wrapExpression` writes its prefix with `prependRight` and its suffix with `appendLeft`. Items list edits add next to an item belong to the list, so `appendToList` writes after a last item to the right of its end and `prependToList` before a first item to the left of its start; a wrapper of that item, before or after, stays around the item alone. `SourceEdits` gains `prependRight`, the one insertion this needs. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/editor.test.ts | 51 +++++++++++++++++++ code/core/src/csf-tools/estree/editor.ts | 37 +++++++++----- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index d79476700384..eda1c5b8675f 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -29,6 +29,7 @@ import { removeStatements, replaceValue, setImportSpecifiers, + wrapExpression, } from './editor.ts'; const parse = (source: string, fileName?: string) => @@ -334,6 +335,56 @@ describe('appendToList', () => { }); }); +describe('wrapExpression', () => { + const render = (editor: SourceEditor) => { + const meta = (editor.program.body[0] as E.ExportDefaultDeclaration) + .declaration as E.ObjectExpression; + return { meta, value: (meta.properties[1] as E.ObjectProperty).value }; + }; + const elements = (editor: SourceEditor) => { + const array = (editor.program.body[0] as E.ExportDefaultDeclaration) + .declaration as E.ArrayExpression; + return { array, first: array.elements[0]! }; + }; + + it.each([true, false])( + 'writes the wrapper around the expression alone, the list edits next to it first: %s', + (listEditsFirst) => { + const editor = new SourceEditor("export default { title: 'X', render: (args) => null };"); + const list = new SourceEditor('export default [a];'); + const listEdits = () => { + appendToList(editor, objectList(editor, render(editor).meta), ['a: {}']); + prependToList(list, arrayList(list, elements(list).array), ['b']); + appendToList(list, arrayList(list, elements(list).array), ['c']); + }; + if (listEditsFirst) { + listEdits(); + } + wrapExpression(editor, render(editor).value, 'wrapped(', ')'); + wrapExpression(list, elements(list).first, 'f(', ')'); + if (!listEditsFirst) { + listEdits(); + } + + expect(editor.toString()).toBe( + "export default { title: 'X', render: wrapped((args) => null), a: {} };" + ); + expect(list.toString()).toBe('export default [b, f(a), c];'); + } + ); + + it('wraps text an earlier edit wrote at its end, and an earlier wrapper', () => { + const editor = new SourceEditor("export default { title: 'X', render: (args) => null };"); + const { value } = render(editor); + editor.edits.appendLeft(value.end, '.input'); + wrapExpression(editor, value, '[', ']'); + wrapExpression(editor, value, '(', ')'); + expect(editor.toString()).toBe( + "export default { title: 'X', render: ([(args) => null.input]) };" + ); + }); +}); + describe('parenthesized array elements', () => { it('appends after a comma expression and removes a doubly parenthesized one whole', () => { const editor = new SourceEditor(`export default { addons: [(a, b)] };`); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 319ca3cbf032..9207006856f7 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -47,6 +47,7 @@ export interface SourceEdits { append(content: string): this; appendLeft(index: number, content: string): this; appendRight(index: number, content: string): this; + prependRight(index: number, content: string): this; overwrite(start: number, end: number, content: string): this; remove(start: number, end: number): this; /** The content of a span as edited so far; throws when an edit cuts through its ends. */ @@ -820,9 +821,11 @@ const inline = ( // What the appends of a batch of edits wrote to a list: where later items go, one per line at // `indent` or on the list's line without one, the list's trailing comma, whether a line break -// follows, and what has to come before the next item. +// follows, and what has to come before the next item. Items after an item are written to the right +// of its end, so a wrapper of that item stays around the item alone. interface Appended { at: number; + right: boolean; indent: string | undefined; trailingComma: string; lineBreakFollows: boolean; @@ -831,6 +834,9 @@ interface Appended { const appendedTo = new WeakMap>(); +const insert = (edits: SourceEdits, { at, right }: Pick, text: string) => + right ? edits.appendRight(at, text) : edits.appendLeft(at, text); + // Items one per line at `indent`, each after a line break. const itemLines = ( texts: string[], @@ -864,7 +870,7 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const { code, edits } = editor; const { items, padding } = list; const write = (layout: Omit, text: string) => { - edits.appendLeft(layout.at, text); + insert(edits, layout, text); return appended(layout, text, texts.at(-1)!); }; // The text after the items goes to the right of the list's end, so later items go before it. @@ -890,10 +896,10 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende edits.remove(end, list.close); } close(`\n${outer}`); - const layout = { at: end, indent, trailingComma: ',', lineBreakFollows: true }; + const layout = { at: end, right: false, indent, trailingComma: ',', lineBreakFollows: true }; return write(layout, itemLines(texts, indent, ',', true)); } - const inline = { indent: undefined, trailingComma: '', lineBreakFollows: false }; + const inline = { right: false, indent: undefined, trailingComma: '', lineBreakFollows: false }; if (end > list.open) { if (end === list.close) { close(padding); @@ -914,11 +920,11 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const indent = lineIndent(code, last.start); const at = itemEnd(editor, list, items.length - 1); if (comma === undefined) { - edits.appendLeft(last.end, ','); + edits.appendRight(last.end, ','); } const lineBreakFollows = lineBreakAt(code, at); return write( - { at, indent, trailingComma, lineBreakFollows }, + { at, right: true, indent, trailingComma, lineBreakFollows }, itemLines(texts, indent, trailingComma, lineBreakFollows) ); } @@ -926,18 +932,18 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const lineBreakFollows = lineBreakAt(code, at); const text = inline(editor, list, texts, trailingComma, lineBreakFollows); return write( - { at, indent: undefined, trailingComma, lineBreakFollows }, + { at, right: true, indent: undefined, trailingComma, lineBreakFollows }, comma === undefined ? `, ${text}` : ` ${text}` ); }; const appendAfter = (editor: SourceEditor, list: List, previous: Appended, texts: string[]) => { - const { at, indent, trailingComma, lineBreakFollows, separator } = previous; + const { indent, trailingComma, lineBreakFollows, separator } = previous; const text = indent === undefined ? ` ${inline(editor, list, texts, trailingComma, lineBreakFollows)}` : itemLines(texts, indent, trailingComma, lineBreakFollows); - editor.edits.appendLeft(at, `${separator}${text}`); + insert(editor.edits, previous, `${separator}${text}`); return appended(previous, text, texts.at(-1)!); }; @@ -982,11 +988,12 @@ export const prependToList = (editor: SourceEditor, list: List, texts: string[]) return `${indent}${withComma(reindent(text, '', indent), ',', lineBreakFollows)}`; }) .join('\n'); - edits.appendRight(list.open, `\n${block}`); + edits.appendLeft(list.open, `\n${block}`); return; } + // To the left of the first item, so a wrapper of it stays around it alone. const items = inline(editor, list, texts, ',', false); - edits.appendRight(first.start, items.endsWith('\n') ? items : `${items} `); + edits.appendLeft(first.start, items.endsWith('\n') ? items : `${items} `); }; /** Remove items with their separators and the comments that lead and trail them from a list. */ @@ -1383,11 +1390,15 @@ export const replaceValue = (editor: SourceEditor, member: Property, text: strin } }; -/** Write `before` and `after` around an expression and the parentheses written around it. */ +/** + * Write `before` and `after` around an expression and the parentheses written around it. Text an + * earlier edit wrote at its ends, such as `.input` after it, ends up inside, as does an earlier + * wrapper; items list edits add next to it stay outside in either order. + */ export const wrapExpression = (editor: SourceEditor, node: Node, before: string, after: string) => { editor.assertCurrent(node); const { start, end } = outerSpan(node); - editor.edits.appendRight(start, before); + editor.edits.prependRight(start, before); editor.edits.appendLeft(end, after); }; From 1be8bc8524b5d9c14aefef00d9d147164c68ddc4 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:18:19 +0200 Subject: [PATCH 263/326] fix(csf-tools): separate an appended statement that starts with ( after its blank line `appendStatement` sets a statement apart from a multi-line one with a blank line, which `insertStatementAfter` received as the text's leading `\n`. The check for a statement that would continue the one before it, such as `(a.b).c = 1`, tested that text and so missed the `(`: after `export default {\n title: 'X'\n}` the two statements became one call. The check now skips the text's leading line breaks and the `;` goes after them, as `;(a.b).c = 1`. It also looks at the text inserted after the same statement before, which is what the new text follows, so `f()` then `(a.b).c = 1` get the separator too. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/editor.test.ts | 31 +++++++++++++++++++ code/core/src/csf-tools/estree/editor.ts | 18 +++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index eda1c5b8675f..8cfe40d32acf 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -845,6 +845,37 @@ describe('appendStatement', () => { ); }); + it.each([ + [ + 'a multi-line statement', + 'export default {\n title: "X"\n}\n', + 'export default {\n title: "X"\n}\n\n;(a.b).c = 1;\n', + ], + [ + 'a one-line statement', + 'export default { title: "X" }\n', + 'export default { title: "X" }\n;(a.b).c = 1;\n', + ], + ])('separates a statement that would continue %s without a semicolon', (_case, code, output) => { + const editor = new SourceEditor(code); + appendStatement(editor, '(a.b).c = 1;'); + expect(editor.toString()).toBe(output); + }); + + it('separates a statement that would continue the one appended before it', () => { + const editor = new SourceEditor('const a = 1;\n'); + appendStatement(editor, 'f()'); + appendStatement(editor, '(a.b).c = 1;'); + expect(editor.toString()).toBe('const a = 1;\nf()\n;(a.b).c = 1;\n'); + }); + + it('separates a statement from one whose line comment ends in a semicolon', () => { + const editor = new SourceEditor('const a = 1;\n'); + appendStatement(editor, 'f() // done;'); + appendStatement(editor, '(a.b).c = 1;'); + expect(editor.toString()).toBe('const a = 1;\nf() // done;\n;(a.b).c = 1;\n'); + }); + it('appends to the end of a file whose statements are all removed', () => { const editor = new SourceEditor(`const a = 1;\nconst b = 2;\n`); removeStatements(editor, new Set(editor.program.body)); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 9207006856f7..a81492a3c3ba 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -1159,6 +1159,12 @@ export const statementEnd = (editor: SourceEditor, statement: Span) => { // Text that would continue a statement not ended by `;` on the line before, such as `[1].map(f)`. const CONTINUES_STATEMENT = /^[([`+\-/]/; +// Whether a text ends its statement with `;`, before a line comment that ends it. +const endsStatement = (text: string) => { + const { body, at } = trailingLineComment(`${text}\n`); + return body.slice(0, at).trimEnd().endsWith(';'); +}; + /** * Insert a statement on its own line right after a statement and the comments it owns. What else * shares that line moves below the inserted statements. @@ -1170,15 +1176,21 @@ export const insertStatementAfter = (editor: SourceEditor, statement: Span, text inserted = new Map(); lastInserted.set(edits, inserted); } + // The new text follows the text inserted after the statement earlier in the batch, if any. + const before = inserted.get(statement) ?? editor.source(statement); inserted.set(statement, text); const end = statementEnd(editor, statement); const next = end + /^[^\S\n]*/.exec(code.slice(end))![0].length; - const separator = code[statement.end - 1] !== ';' && CONTINUES_STATEMENT.test(text) ? ';' : ''; + // The separator goes after the line breaks that lead the text. + const lead = /^\s*/.exec(text)![0]; + const body = text.slice(lead.length); + const separator = !endsStatement(before) && CONTINUES_STATEMENT.test(body) ? ';' : ''; + const written = `\n${lead}${separator}${body}`; if (next === code.length || code[next] === '\n') { - edits.appendLeft(next, `\n${separator}${text}`); + edits.appendLeft(next, written); return; } - edits.appendLeft(end, `\n${separator}${text}`); + edits.appendLeft(end, written); // The same line break for every statement inserted here. if (next > end) { edits.overwrite(end, next, '\n'); From 296cfcd8ecdee9d4a280202894f26285100d17f1 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:21:31 +0200 Subject: [PATCH 264/326] feat(csf-tools): add CsfObject#inspect to read a path without recording a diagnostic A read that cannot be proven adds a diagnostic to `mutationDiagnostics`, which makes `writeConfig` refuse the file: an automigration must not write after an unreadable read. An addon that only reads, such as one showing a story's args, had no way to ask, and the README told it to count the diagnostics before and after a read. `inspect(path)` returns `{ kind: 'value', node }`, `{ kind: 'missing' }` or `{ kind: 'unreadable', diagnostic }` and records nothing. `get` is now `inspect` plus reporting the diagnostic, and `getValue` finds its field through the same lookup, so the three agree on what is unreadable. `ConfigFile#inspect` also returns a root it cannot locate as unreadable instead of recording it. The README's example uses `inspect` and says when to use which. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/ConfigFile.ts | 27 +++++ code/core/src/csf-tools/ConfigObject.test.ts | 13 +++ code/core/src/csf-tools/CsfObject.test.ts | 37 ++++++ code/core/src/csf-tools/CsfObject.ts | 115 +++++++++++++++---- code/core/src/csf-tools/README.md | 20 ++-- code/core/src/csf-tools/index.ts | 1 + 6 files changed, 181 insertions(+), 32 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 12174059305f..0158364a8a3f 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -9,6 +9,7 @@ import type { PrintResultType } from './PrintResultType.ts'; import { createConfigObject, locateConfigRoot } from './ConfigObject.ts'; import { type CsfExpression, + type CsfInspection, type CsfMutationDiagnostic, type CsfMutationResult, type CsfObject, @@ -260,6 +261,32 @@ export class ConfigFile implements CsfObject { return editor.ok ? editor.object.get(path) : undefined; } + /** + * Read the expression at a property path like `get`, telling a missing field from one that cannot + * be read statically, without adding a mutation diagnostic. Use it for reads that only inform; + * `get` and `getValue` record why a read failed, which stops `writeConfig`. + * + * @example + * ```ts + * const config = loadConfig('export default { tags: [], args: { ...base } };').parse(); + * config.inspect(['tags']).kind; // 'value' + * config.inspect(['missing']); // { kind: 'missing' } + * config.inspect(['args', 'size']).kind; // 'unreadable' + * config.mutationDiagnostics; // [] + * ``` + */ + inspect(path: readonly string[]): CsfInspection { + const editor = createConfigObject( + this, + 'read', + () => {}, + () => {} + ); + return editor.ok + ? editor.object.inspect(path) + : { kind: 'unreadable', diagnostic: { ...editor.diagnostic, path: [...path] } }; + } + /** * Read plain values without executing code. Missing fields return `undefined`; unresolved * expressions also return `undefined` and add a mutation diagnostic. diff --git a/code/core/src/csf-tools/ConfigObject.test.ts b/code/core/src/csf-tools/ConfigObject.test.ts index 4102daa1ca1a..cc5a3614b71a 100644 --- a/code/core/src/csf-tools/ConfigObject.test.ts +++ b/code/core/src/csf-tools/ConfigObject.test.ts @@ -97,6 +97,19 @@ describe('ConfigFile mutations', () => { ); }); + it('inspects a config without recording a diagnostic, also when its root is unreadable', () => { + const config = loadConfig("export default { tags: ['docs'] };").parse(); + const tags = config.inspect(['tags']); + expect(tags.kind === 'value' && textOf(tags.node)).toBe("['docs']"); + + const unreadable = loadConfig('export default createConfig();').parse(); + expect(unreadable.inspect(['tags'])).toMatchObject({ + kind: 'unreadable', + diagnostic: { code: 'unsupported-initializer', path: ['tags'] }, + }); + expect(unreadable.mutationDiagnostics).toEqual([]); + }); + it('infers quotes and keeps the line endings of CRLF sources', () => { const source = [ "import { definePreview } from '@storybook/react';", diff --git a/code/core/src/csf-tools/CsfObject.test.ts b/code/core/src/csf-tools/CsfObject.test.ts index c0528b0b45d6..979f2058392b 100644 --- a/code/core/src/csf-tools/CsfObject.test.ts +++ b/code/core/src/csf-tools/CsfObject.test.ts @@ -61,6 +61,43 @@ describe('CsfObject', () => { expect(csf.mutationDiagnostics).toEqual([]); }); + it('tells a field from a missing and an unreadable one without recording a diagnostic', () => { + const csf = parse( + 'export default {};\nexport const A = { args: { ...base }, parameters: { a: 1 } };' + ); + const [story] = csf.objects({ meta: false }); + + const value = story.inspect(['parameters', 'a']); + expect(value.kind === 'value' && textOf(value.node)).toBe('1'); + expect(story.inspect(['missing'])).toEqual({ kind: 'missing' }); + expect(story.inspect(['args', 'size'])).toMatchObject({ + kind: 'unreadable', + diagnostic: { code: 'spread-field', path: ['args', 'size'] }, + }); + expect(csf.mutationDiagnostics).toEqual([]); + + expect(story.get(['args', 'size'])).toBeUndefined(); + expect(csf.mutationDiagnostics).toMatchObject([{ code: 'spread-field' }]); + }); + + it('inspects a path a CSF2 annotation editor does not hold, and the object itself, as unreadable', () => { + const csf = parse( + 'export default {};\nexport const A = Template.bind({});\nA.args = { size: "l" };\nA.parameters = { a: 1 };' + ); + const [parameters] = csf.objects({ meta: false }); + const [meta] = csf.objects({ stories: false }); + + expect(parameters.target).toMatchObject({ kind: 'story-annotation', annotation: 'parameters' }); + expect(parameters.inspect(['parameters']).kind).toBe('value'); + expect(parameters.inspect(['args', 'size'])).toMatchObject({ + kind: 'unreadable', + diagnostic: { code: 'unsupported-member', path: ['args', 'size'] }, + }); + expect(meta.inspect([])).toMatchObject({ kind: 'unreadable' }); + expect(parameters.get(['args', 'size'])).toBeUndefined(); + expect(csf.mutationDiagnostics).toEqual([]); + }); + it('keeps local bindings readable after replacing a story expression', () => { const csf = parse(` const params = { a11y: { element: '#app' } }; diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 1c663eaeb4ea..6bfc646ac2c3 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -70,6 +70,15 @@ export interface CsfMutationDiagnostic { loc?: SourceLocation; } +/** + * What `inspect` found at a path. A path that is not a field of the object, such as one outside a + * CSF2 annotation editor's annotation, is unreadable. + */ +export type CsfInspection = + | { kind: 'value'; node: CsfExpression } + | { kind: 'missing' } + | { kind: 'unreadable'; diagnostic: CsfMutationDiagnostic }; + /** * Outcome of a mutation. `ok: false` with `changed: true` means the edit was applied but left a file * the CSF indexer rejects, such as a meta `tags` that is no longer an array of string literals. @@ -138,6 +147,21 @@ export interface CsfObject { * ``` */ get(path: readonly string[]): CsfExpression | undefined; + /** + * Read the expression at a property path like `get`, telling a missing field from one that cannot + * be read statically, without adding a mutation diagnostic. Use it for reads that only inform; + * `get` and `getValue` record why a read failed, which stops `writeConfig`. + * + * @example + * ```ts + * const object = loadConfig('export default { tags: [], args: { ...base } };').parse(); + * object.inspect(['tags']).kind; // 'value' + * object.inspect(['missing']); // { kind: 'missing' } + * object.inspect(['args', 'size']).kind; // 'unreadable' + * object.mutationDiagnostics; // [] + * ``` + */ + inspect(path: readonly string[]): CsfInspection; /** * Read plain values without executing code. Missing fields return `undefined`; unresolved * expressions also return `undefined` and add a mutation diagnostic. @@ -405,12 +429,37 @@ class CsfObjectEditor implements CsfObject { } get(path: readonly string[]): CsfExpression | undefined { + const read = this.read(path); + if (read.kind === 'unreadable') { + this.reportDiagnostic(read.diagnostic); + } + return read.kind === 'value' ? read.node : undefined; + } + + inspect(path: readonly string[]): CsfInspection { + const read = this.read(path); + if (read.kind !== 'outside') { + return read; + } + const name = path.length > 0 ? path.join('.') : 'the object itself'; + const message = `Cannot read ${name}, which is not a field of this ${this.target.kind} object`; + return { + kind: 'unreadable', + diagnostic: this.diagnostic('unsupported-member', path, this.anchor(), message), + }; + } + + // `get` reads a path outside the object as missing, without a diagnostic. + private read(path: readonly string[]): CsfInspection | { kind: 'outside' } { const root = this.rootNodeFor(path); if (root) { - return parseExpression(detachedText(this.editor.code, root.start, this.editor.source(root))); + const source = detachedText(this.editor.code, root.start, this.editor.source(root)); + return { kind: 'value', node: parseExpression(source) }; } - const found = this.getProperty(path); - return found ? parseExpression(valueSource(this.editor, found)) : undefined; + const found = this.find(path); + return found.kind === 'property' + ? { kind: 'value', node: parseExpression(valueSource(this.editor, found.property)) } + : found; } getValue(path: readonly string[]): CsfValue { @@ -446,17 +495,30 @@ class CsfObjectEditor implements CsfObject { : undefined; } - private getProperty(path: readonly string[]): Property | undefined { + private find( + path: readonly string[] + ): + | { kind: 'property'; property: Property } + | { kind: 'missing' } + | { kind: 'outside' } + | { kind: 'unreadable'; diagnostic: CsfMutationDiagnostic } { const logicalPath = this.normalizePath(path); - if (!logicalPath) { - return undefined; + if (!logicalPath || logicalPath.length === 0) { + return { kind: 'outside' }; } - const found = this.inspect(logicalPath); + const found = this.locate(logicalPath); if (found.ok === false) { - this.failure(found.code, path, found.node); - return undefined; + return { kind: 'unreadable', diagnostic: this.diagnostic(found.code, path, found.node) }; + } + return found.property ? { kind: 'property', property: found.property } : { kind: 'missing' }; + } + + private getProperty(path: readonly string[]): Property | undefined { + const found = this.find(path); + if (found.kind === 'unreadable') { + this.reportDiagnostic(found.diagnostic); } - return found.property; + return found.kind === 'property' ? found.property : undefined; } set(path: readonly string[], value: CsfValue): CsfMutationResult { @@ -473,7 +535,7 @@ class CsfObjectEditor implements CsfObject { `Cannot set ${path.join('.')} to a ${unprintable.type} node that did not come from \`get\`, \`transform\` or \`parseExpression\`` ); } - const inspected = this.inspect(logicalPath, 'set'); + const inspected = this.locate(logicalPath, 'set'); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -501,7 +563,7 @@ class CsfObjectEditor implements CsfObject { if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath)) { return this.failure('unsupported-member', path, this.anchor()); } - const inspected = this.inspect(logicalPath); + const inspected = this.locate(logicalPath); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -523,7 +585,7 @@ class CsfObjectEditor implements CsfObject { if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath)) { return this.failure('unsupported-member', path, this.anchor()); } - const inspected = this.inspect(logicalPath); + const inspected = this.locate(logicalPath); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -573,7 +635,7 @@ class CsfObjectEditor implements CsfObject { return this.failure('cyclic-move', to, this.anchor()); } - const source = this.inspect(sourcePath); + const source = this.locate(sourcePath); if (source.ok === false) { return this.failure(source.code, from, source.node); } @@ -584,7 +646,7 @@ class CsfObjectEditor implements CsfObject { if (!removal.ok) { return this.failure(removal.code, from, removal.node); } - const destination = this.inspect(destinationPath); + const destination = this.locate(destinationPath); if (destination.ok === false) { return this.failure(destination.code, to, destination.node); } @@ -611,7 +673,7 @@ class CsfObjectEditor implements CsfObject { if (!inserted.ok) { return inserted; } - const remaining = this.inspect(sourcePath); + const remaining = this.locate(sourcePath); if (remaining.ok === false || !remaining.property || !remaining.parent) { throw new Error(`CsfObject: ${sourcePath.join('.')} was not found again after moving it`); } @@ -634,7 +696,7 @@ class CsfObjectEditor implements CsfObject { } let parent: ObjectRoot = this.root(); if (logicalPath.length > 1) { - const inspected = this.inspect(logicalPath.slice(0, -1)); + const inspected = this.locate(logicalPath.slice(0, -1)); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -789,7 +851,7 @@ class CsfObjectEditor implements CsfObject { reported: readonly string[] ): CsfMutationResult | undefined { for (let depth = path.length - 1; depth > 0; depth--) { - const ancestor = this.inspect(path.slice(0, depth)); + const ancestor = this.locate(path.slice(0, depth)); if ( ancestor.ok === false || !ancestor.property || @@ -828,7 +890,7 @@ class CsfObjectEditor implements CsfObject { } // `access` applies to the last field of the path; its parents are read. - private inspect( + private locate( path: readonly string[], access: Access = 'read' ): @@ -903,13 +965,13 @@ class CsfObjectEditor implements CsfObject { return this.editor.program; } - private failure( + private diagnostic( code: CsfMutationDiagnosticCode, path: readonly string[], node: Node, message = `Cannot mutate ${path.join('.')} because the target contains ${code.replaceAll('-', ' ')}` - ) { - const diagnostic: CsfMutationDiagnostic = { + ): CsfMutationDiagnostic { + return { code, target: this.target, path: [...path], @@ -918,6 +980,15 @@ class CsfObjectEditor implements CsfObject { ? { loc: locationOf(this.editor.code, node.start, node.end) } : {}), }; + } + + private failure( + code: CsfMutationDiagnosticCode, + path: readonly string[], + node: Node, + message?: string + ) { + const diagnostic = this.diagnostic(code, path, node, message); this.reportDiagnostic(diagnostic); return { ok: false as const, changed: false as const, diagnostic }; } diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index ba44cb768bb2..b8fc4c4049e3 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -16,23 +16,23 @@ It can parse MDX into CSF. `CsfFile.objects()` returns one `CsfObject` editor per mutable object it can prove safe to edit: the meta, each story export, and each CSF2 `Story.parameters` / `Story.argTypes` annotation assignment. `{ meta, stories }` narrows what is discovered; both are included by default. Story discovery also includes separate CSF2 parameter assignments, so callers use the same paths regardless of the source format. -Each editor reads and writes static property paths with `get`, `getValue`, `set`, `transform`, `remove`, `rename`, `move`, and `group`. +Each editor reads and writes static property paths with `get`, `getValue`, `inspect`, `set`, `transform`, `remove`, `rename`, `move`, and `group`. `get` returns an AST expression; `getValue` reads plain values from literals, nested arrays and objects, and local constants without executing code. Reads never return a partially decoded object. -`get` and `getValue` return `undefined` both for a field that is missing and for one that cannot be read statically. Only the second adds a `CsfMutationDiagnostic` to `mutationDiagnostics`. For `get` that is a path whose parent is not an object literal, or that a spread, a computed key, or a duplicate field leaves unproven; `getValue` also reports a value it cannot evaluate, such as a call or a function. A field written as `undefined` is `undefined` without a diagnostic too, but `get` returns its expression. Compare the diagnostics before and after a read to tell the cases apart: +`get` and `getValue` return `undefined` both for a field that is missing and for one that cannot be read statically. Only the second adds a `CsfMutationDiagnostic` to `mutationDiagnostics`. For `get` that is a path whose parent is not an object literal, or that a spread, a computed key, or a duplicate field leaves unproven; `getValue` also reports a value it cannot evaluate, such as a call or a function. A field written as `undefined` is `undefined` without a diagnostic too, but `get` returns its expression. `inspect` tells the cases apart without recording a diagnostic. It reads the expression like `get`, without evaluating it: ```ts -const before = csf.mutationDiagnostics.length; -const size = story.getValue(['args', 'size']); -const [unreadable] = csf.mutationDiagnostics.slice(before); -if (size === undefined && unreadable) { - // `args.size` may be set to a value that cannot be read statically: see `unreadable.code` -} else if (size === undefined) { - // `args.size` is missing, or written as `undefined` +const size = story.inspect(['args', 'size']); +if (size.kind === 'value') { + // `size.node` is the expression, `undefined` included +} else if (size.kind === 'unreadable') { + // `args.size` may be set where it cannot be read statically: see `size.diagnostic.code` +} else { + // `args.size` is missing } ``` -Read diagnostics count like mutation ones, so `writeConfig` refuses a config after a read that added one. +Read diagnostics count like mutation ones, so `writeConfig` refuses a config after a read that added one. Use `get` and `getValue` for the reads an edit depends on, so a file whose shape was not proven is not written, and `inspect` for reads that only inform, such as an addon showing a field. `set` accepts an expression from `get` or `parseExpression`, or plain strings, numbers, booleans, `null`, `undefined`, and nested arrays or objects of those values and expressions. An object whose `type` names an AST node is rejected with a diagnostic unless it came from `get` or `parseExpression`. Plain values are printed into the source, so later changes to the input do not affect the file. An expression from `get` is indented as if its line had no indent, and `set` and `transform` indent a value's lines like the line of the field it is written to, so a value copied between fields keeps its layout. diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index 529195f6783f..6e8e3d6cc03a 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -1,5 +1,6 @@ export * from './CsfFile.ts'; export type { + CsfInspection, CsfMutationDiagnostic, CsfMutationDiagnosticCode, CsfMutationResult, From 18e6038772bffe261a745727f14ebfe9aaa42ef1 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:36:09 +0200 Subject: [PATCH 265/326] test(cli-storybook): expect the dirname import to join the existing node:path import `setImport` now merges a value specifier into the declaration of the same module instead of adding a second one, so the wrap-getAbsolutePath automigration writes one `node:path` import. Co-Authored-By: Claude Fable 5.1 --- .../src/automigrate/fixes/wrap-getAbsolutePath.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.test.ts index b2b7ce3e7b25..a1b8c43e8af1 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.test.ts @@ -118,8 +118,7 @@ describe('wrapGetAbsolutePath', () => { expect(() => parseModule(output, 'main.js')).not.toThrow(); expect(output).toMatchInlineSnapshot(` - "import { dirname as pathDirname } from 'node:path'; - import path from 'node:path'; + "import path, { dirname as pathDirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -187,8 +186,7 @@ describe('wrapGetAbsolutePath', () => { expect(() => parseModule(output, 'main.js')).not.toThrow(); expect(output).toMatchInlineSnapshot(` - "import { dirname as pathDirname } from 'node:path'; - import path from 'node:path'; + "import path, { dirname as pathDirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const { dirname } = path; From a0d4d70a8f68779db8f87196444c60526746b0ee Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 11:42:46 +0200 Subject: [PATCH 266/326] Core: Share one pnpm-workspace.yaml writer and inline the path lookup Co-Authored-By: Claude Fable 5.1 --- .../js-package-manager/PNPMProxy.test.ts | 21 ++++++++++++---- .../common/js-package-manager/PNPMProxy.ts | 24 +++++++++---------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index d3447580cc0a..5f0f78691f5d 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { logger, prompt } from 'storybook/internal/node-logger'; import { MinimumReleaseAgeHandledError } from 'storybook/internal/server-errors'; +import * as find from 'empathic/find'; import { fs as memfs, vol } from 'memfs'; import { executeCommand, executeCommandSync } from '../utils/command.ts'; +import { getProjectRoot } from '../utils/paths.ts'; import { JsPackageManager } from './JsPackageManager.ts'; import { PNPMProxy } from './PNPMProxy.ts'; @@ -27,6 +29,8 @@ vi.mock('storybook/internal/node-logger', () => ({ vi.mock(import('../utils/command.ts'), { spy: true }); vi.mock('node:fs', { spy: true }); +vi.mock('empathic/find', { spy: true }); +vi.mock('../utils/paths.ts', { spy: true }); const mockedExecuteCommand = vi.mocked(executeCommand); const mockedExecuteCommandSync = vi.mocked(executeCommandSync); @@ -55,8 +59,9 @@ describe('PNPM Proxy', () => { const workspaceYaml = () => vol.toJSON()[WORKSPACE_YAML]; const useMemfsProject = (proxy: PNPMProxy, packageJson: Record = {}) => { - vi.spyOn(proxy as any, 'findWorkspaceYamlPath').mockImplementation(() => - vol.existsSync(WORKSPACE_YAML) ? WORKSPACE_YAML : undefined + vi.mocked(getProjectRoot).mockReturnValue(PROJECT_DIR); + vi.mocked(find.up).mockImplementation((name) => + name === 'pnpm-workspace.yaml' && vol.existsSync(WORKSPACE_YAML) ? WORKSPACE_YAML : undefined ); vi.spyOn(proxy, 'primaryPackageJson', 'get').mockReturnValue({ operationDir: PROJECT_DIR, @@ -379,14 +384,20 @@ describe('PNPM Proxy', () => { ); }); - it('does nothing on pnpm < 10.26, which has no allowBuilds setting', async () => { - mockPnpmVersion('10.25.0'); + it.each([ + ['pnpm 9, which runs build scripts without approval', '9.15.9'], + ['pnpm < 10.26, which has no allowBuilds setting', '10.25.0'], + ])('neither reads nor writes the workspace file on %s', async (_, version) => { + mockPnpmVersion(version); pnpmProxy = new PNPMProxy(); useMemfsProject(pnpmProxy); + vol.fromJSON({ [WORKSPACE_YAML]: 'packages:\n - "packages/*"\n' }); await install(); - expect(workspaceYaml()).toBeUndefined(); + expect(readFileSync).not.toHaveBeenCalledWith(WORKSPACE_YAML, expect.anything()); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(workspaceYaml()).toBe('packages:\n - "packages/*"\n'); }); }); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 6153fd707ab3..af218f1f00a3 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -287,7 +287,7 @@ export class PNPMProxy extends JsPackageManager { /^[~^]/.test(version) || prerelease(version) ? '' : (current.match(/^[~^]/)?.[0] ?? ''); workspace.doc.setIn(keyPath, `${modifier}${version}`); } - writeFileSync(workspace.path, workspace.doc.toString(), 'utf8'); + this.#writeWorkspaceYaml(workspace); return upgrades.filter(({ catalog }) => catalog === null).map(({ specifier }) => specifier); } @@ -319,14 +319,6 @@ export class PNPMProxy extends JsPackageManager { return match ? match[1].trim() : null; } - /** Looks for a `pnpm-workspace.yaml` all the way down to the project root. */ - protected findWorkspaceYamlPath(): string | undefined { - return find.up('pnpm-workspace.yaml', { - cwd: this.primaryPackageJson.operationDir, - last: getProjectRoot(), - }); - } - // From pnpm 11, an unreviewed build script fails a non-interactive install outright, and // `pnpm install` has no flag to approve one, so record the approval like `pnpm approve-builds`. protected allowStorybookBuildScripts(): void { @@ -368,7 +360,7 @@ export class PNPMProxy extends JsPackageManager { for (const pkg of pending) { doc.setIn(['allowBuilds', pkg], true); } - writeFileSync(path, doc.toString(), 'utf8'); + this.#writeWorkspaceYaml({ path, doc }); } catch (e) { logger.warn( `Could not allow build scripts of ${STORYBOOK_BUILD_SCRIPT_PACKAGES.join(', ')} in ${path}: ${String(e)}` @@ -376,12 +368,20 @@ export class PNPMProxy extends JsPackageManager { } } + #writeWorkspaceYaml({ path, doc }: { path: string; doc: Document }): void { + logger.debug(`Writing pnpm workspace file ${path}`); + writeFileSync(path, doc.toString(), 'utf8'); + } + // Null when the file is malformed, or missing without `createIfMissing`; with it, a missing file // yields an empty document addressed next to the package.json we operate on. #readWorkspaceYaml(options?: { createIfMissing: boolean; }): { path: string; doc: Document } | null { - const path = this.findWorkspaceYamlPath(); + const path = find.up('pnpm-workspace.yaml', { + cwd: this.primaryPackageJson.operationDir, + last: getProjectRoot(), + }); if (!path) { return options?.createIfMissing ? { @@ -456,7 +456,7 @@ export class PNPMProxy extends JsPackageManager { } if (changed) { - writeFileSync(workspace.path, workspace.doc.toString(), 'utf8'); + this.#writeWorkspaceYaml(workspace); } return true; } catch (e) { From 7dbdb6badddd55bed3a8e733775a2c66c7538f13 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:14:05 +0200 Subject: [PATCH 267/326] feat(csf-tools): apply several mutations as one with transaction Every CsfObject mutation commits on its own. A codemod that removes parameters.msw and then has its beforeEach set refused leaves the file half edited, with nothing to undo the removal. CsfFile.transaction(fn) and ConfigFile.transaction(fn) snapshot the editor (tree, pending edits, committed edits), run fn, and put the snapshot back and index it again through the commit hook when fn throws, returns false, or any mutation in it returned ok: false. Per-object and per-file changed flags are restored through rollbacks registered while the transaction runs. Reads that cannot resolve a value still report a diagnostic but do not count as refusals. Diagnostics stay, so a codemod can report why a story was skipped. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/ConfigFile.ts | 30 ++++- code/core/src/csf-tools/CsfFile.ts | 23 ++++ code/core/src/csf-tools/CsfObject.test.ts | 140 ++++++++++++++++++++++ code/core/src/csf-tools/CsfObject.ts | 15 ++- code/core/src/csf-tools/README.md | 14 +++ code/core/src/csf-tools/estree/editor.ts | 55 +++++++++ 6 files changed, 270 insertions(+), 7 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 0158364a8a3f..8bc33eabae61 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -402,11 +402,34 @@ export class ConfigFile implements CsfObject { return this._mutate((object) => object.group(path, names)); } + /** + * Apply mutations together: when `fn` throws, returns `false`, or any mutation in it returns + * `{ ok: false }`, every edit made in it is rolled back, those of array and import helpers too. + * Its diagnostics stay, to report why. Returns whether the edits were kept. + * + * @example + * ```ts + * const config = loadConfig("export default { layout: 'centered', tags: ['a'] };").parse(); + * config.transaction(() => { + * config.remove(['layout']); + * config.set(['tags', 'value'], 'b'); + * }); // false: `tags` is an array, so the removal is rolled back too + * ``` + */ + transaction(fn: () => boolean | void): boolean { + const changed = this._changed; + return this._editor.transaction(fn, () => { + this._changed = changed; + }); + } + _mutate(operation: (object: CsfObject) => CsfMutationResult): CsfMutationResult { const editor = this._objectEditor('mutate'); - return editor.ok === true - ? operation(editor.object) - : { ok: false, changed: false, diagnostic: editor.diagnostic }; + if (editor.ok === true) { + return operation(editor.object); + } + this._editor._refuse(); + return { ok: false, changed: false, diagnostic: editor.diagnostic }; } _objectEditor(mode: 'read' | 'mutate') { @@ -862,6 +885,7 @@ export class ConfigFile implements CsfObject { loc: locationOf(this._code, node.start, node.end), }; this._mutationDiagnostics.push(diagnostic); + this._editor._refuse(); return { ok: false, changed: false, diagnostic }; } diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 47cee9f5b38f..198323fecd62 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -447,6 +447,29 @@ export class CsfFile { ); } + /** + * Apply mutations together: when `fn` throws, returns `false`, or any mutation in it returns + * `{ ok: false }`, every edit made in it is rolled back. Its diagnostics stay, to report why. + * Returns whether the edits were kept. + * + * @example + * ```ts + * const [story] = csf.objects({ meta: false }); + * const migrated = csf.transaction(() => { + * story.remove(['parameters', 'msw']); + * story.set(['beforeEach'], parseExpression('mockHandlers')); + * }); + * // false, and the file as it was, when `beforeEach` cannot be set + * ``` + */ + transaction(fn: () => boolean | void): boolean { + const state = mutationStates.get(this)!; + const { changed } = state; + return this._editor.transaction(fn, () => { + state.changed = changed; + }); + } + // Not committed: `formatCsf` prints it with the other pending edits. _appendStatement(code: string) { appendStatement(this._editor, code); diff --git a/code/core/src/csf-tools/CsfObject.test.ts b/code/core/src/csf-tools/CsfObject.test.ts index 979f2058392b..bbcf40400c38 100644 --- a/code/core/src/csf-tools/CsfObject.test.ts +++ b/code/core/src/csf-tools/CsfObject.test.ts @@ -1223,6 +1223,146 @@ describe('CsfObject', () => { expect(() => csf.objects()).toThrow(error); }); }); + + describe('transaction', () => { + const source = `export default { title: 'B' }; +export const A = { + beforeEach: setup, + ...shared, + parameters: { msw: { handlers: [] }, layout: 'centered' }, +}; +`; + + it('rolls every edit back when a mutation in it is refused', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + const kept = csf.transaction(() => { + expect(story.remove(['parameters', 'msw'])).toEqual({ ok: true, changed: true }); + expect(story.set(['beforeEach'], parseExpression('mockHandlers')).ok).toBe(false); + }); + + expect(kept).toBe(false); + expect(printCsf(csf).code).toBe(source); + expect(story.getValue(['parameters', 'layout'])).toBe('centered'); + expect(story.get(['parameters', 'msw'])).toBeDefined(); + expect(csf.changed).toBe(false); + expect(story.changed).toBe(false); + expect(csf.mutationDiagnostics).toEqual([ + expect.objectContaining({ code: 'spread-field', path: ['beforeEach'] }), + ]); + }); + + it('keeps the edits when every mutation in it succeeds', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + const kept = csf.transaction(() => { + story.remove(['parameters', 'msw']); + story.set(['parameters', 'layout'], 'fullscreen'); + }); + + expect(kept).toBe(true); + expect(printCsf(csf).code).toContain("parameters: { layout: 'fullscreen' },"); + expect(csf.changed).toBe(true); + expect(story.changed).toBe(true); + }); + + it('rolls back when the function returns false or throws, and rethrows', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + expect(csf.transaction(() => (story.remove(['parameters']), false))).toBe(false); + expect(() => + csf.transaction(() => { + story.remove(['parameters']); + throw new Error('codemod bug'); + }) + ).toThrow('codemod bug'); + + expect(printCsf(csf).code).toBe(source); + expect(csf.stories.map(({ name }) => name)).toEqual(['A']); + }); + + it('rolls back an edit the CSF indexer rejects, so the file indexes again', () => { + const csf = parse("export default { title: 'B', tags: ['docs'] };\nexport const A = {};\n"); + const [meta] = csf.objects({ stories: false }); + + const kept = csf.transaction(() => { + meta.set(['tags'], parseExpression("[...['docs'], 'autodocs']")); + }); + + expect(kept).toBe(false); + expect(csf.meta?.tags).toEqual(['docs']); + expect(meta.set(['tags'], ['docs', 'autodocs'])).toEqual({ ok: true, changed: true }); + }); + + it('rolls back an inner transaction on its own', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + const kept = csf.transaction(() => { + story.remove(['parameters', 'msw']); + expect(csf.transaction(() => story.set(['beforeEach'], 'x').ok)).toBe(false); + }); + + expect(kept).toBe(true); + expect(printCsf(csf).code).toContain("parameters: { layout: 'centered' },"); + expect(printCsf(csf).code).toContain('beforeEach: setup,'); + }); + + it('rolls back a kept inner transaction with the outer one', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + const kept = csf.transaction(() => { + expect(csf.transaction(() => story.remove(['parameters', 'msw']).ok)).toBe(true); + story.set(['beforeEach'], 'x'); + }); + + expect(kept).toBe(false); + expect(printCsf(csf).code).toBe(source); + expect(story.changed).toBe(false); + }); + + it('rolls back what a function returning a promise did before throwing', () => { + const csf = parse(source); + const [story] = csf.objects({ meta: false }); + + // @ts-expect-error A transaction runs synchronously. + expect(() => csf.transaction(async () => story.remove(['parameters']))).toThrow('promise'); + expect(printCsf(csf).code).toBe(source); + }); + + it('rolls back when an array helper is refused', () => { + const code = "export default { layout: 'centered', tags: 'a' };"; + const config = loadConfig(code).parse(); + + const kept = config.transaction(() => { + config.remove(['layout']); + config.appendValueToArray(['tags'], 'b'); + }); + + expect(kept).toBe(false); + expect(printConfig(config).code).toBe(code); + }); + + it('rolls back config edits, named exports included', () => { + const code = + "export const parameters = { layout: 'centered' };\nexport const tags = ['a'];\n"; + const config = loadConfig(code).parse(); + + const kept = config.transaction(() => { + config.remove(['parameters', 'layout']); + config.set(['tags', 'nested'], 1); + }); + + expect(kept).toBe(false); + expect(printConfig(config).code).toBe(code); + expect(config.changed).toBe(false); + expect(config.getValue(['parameters'])).toEqual({ layout: 'centered' }); + }); + }); }); describe('CsfObject.node', () => { diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 6bfc646ac2c3..7dcb7d3625be 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -467,7 +467,7 @@ class CsfObjectEditor implements CsfObject { if (root) { const value = readValue(this.editor.scopes, root); if (value === UNRESOLVED) { - this.failure('unsupported-value', path, root); + this.reportDiagnostic(this.diagnostic('unsupported-value', path, root)); return undefined; } return value; @@ -477,12 +477,12 @@ class CsfObjectEditor implements CsfObject { return undefined; } if (property.method) { - this.failure('unsupported-value', path, property); + this.reportDiagnostic(this.diagnostic('unsupported-value', path, property)); return undefined; } const value = readValue(this.editor.scopes, property.value); if (value === UNRESOLVED) { - this.failure('unsupported-value', path, property.value); + this.reportDiagnostic(this.diagnostic('unsupported-value', path, property.value)); return undefined; } return value; @@ -990,6 +990,8 @@ class CsfObjectEditor implements CsfObject { ) { const diagnostic = this.diagnostic(code, path, node, message); this.reportDiagnostic(diagnostic); + // Unlike a read, a refused mutation rolls back the transaction it runs in. + this.editor._refuse(); return { ok: false as const, changed: false as const, diagnostic }; } @@ -997,7 +999,12 @@ class CsfObjectEditor implements CsfObject { // JS, so that is a diagnostic rather than an error. Code that does not parse is left pending by // the editor, and the error is the caller's. private commit(path: readonly string[]): CsfMutationResult { - this.#changed = true; + if (!this.#changed) { + this.#changed = true; + this.editor._transaction?.rollbacks.push(() => { + this.#changed = false; + }); + } this.markChanged(); try { this.editor.commit(); diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index b8fc4c4049e3..613b3e6f1d39 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -49,6 +49,20 @@ Unproven shapes include story or meta bindings that are reassigned or aliased, a `CsfFile.changed` is true once any mutation has been applied, which is the signal for whether the file needs to be written back. +Each mutation is applied on its own. To apply several as one, run them in `transaction(fn)` on `CsfFile` or `ConfigFile`: when `fn` throws, returns `false`, or any mutation in it returns `{ ok: false }`, every edit made in it is rolled back, and `changed` with it. Mutations inside return their results as usual, and the diagnostics of refused ones stay on `mutationDiagnostics`. `transaction` returns whether the edits were kept. `fn` runs synchronously; one that returns a promise throws. A transaction inside another rolls back on its own, and with the outer one. + +```ts +for (const story of csf.objects()) { + const handlers = story.get(['parameters', 'msw', 'handlers']); + if (handlers) { + csf.transaction(() => { + story.remove(['parameters', 'msw']); + story.set(['beforeEach'], parseExpression(`() => worker.use(...${placeableTextOf(handlers)})`)); + }); + } +} +``` + ## Transforming preview and main configuration `ConfigFile` implements `CsfObject` directly, exposing the same methods and mutation results as the editors returned by `CsfFile.objects()`. Config files use `{ kind: 'config' }` as their target. Default-exported objects, identifier-backed objects, `definePreview` configurations, and CommonJS `module.exports` use their config object as the root. diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index a81492a3c3ba..435857de6e38 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -50,6 +50,7 @@ export interface SourceEdits { prependRight(index: number, content: string): this; overwrite(start: number, end: number, content: string): this; remove(start: number, end: number): this; + clone(): SourceEdits; /** The content of a span as edited so far; throws when an edit cuts through its ends. */ slice(start: number, end: number): string; hasChanged(): boolean; @@ -105,6 +106,17 @@ export class SourceEditor { _style: Style | undefined; + // The transaction running, if any: whether a mutation in it was refused, so it rolls back, and + // how to undo state kept outside the editor, such as a `changed` flag. + _transaction: { refused: boolean; rollbacks: (() => void)[] } | undefined; + + /** Mark a mutation as refused, so the transaction it runs in rolls back. */ + _refuse() { + if (this._transaction) { + this._transaction.refused = true; + } + } + constructor(code: string, fileName?: string, onCommit?: () => void) { this.fileName = fileName; this._onCommit = onCommit; @@ -206,6 +218,49 @@ export class SourceEditor { return true; } + /** + * Run `fn`, keeping its edits only when it does not throw, does not return `false` and no + * mutation in it was refused. Otherwise the code, the tree and the pending and committed edits + * are put back as they were and indexed again, as a commit would, and `onRollback` runs. Returns + * whether they were kept. + */ + transaction(fn: () => boolean | void, onRollback?: () => void): boolean { + const { _module, _style, _transaction: outer } = this; + const committed = this._committed.length; + const edits = this._edits.clone(); + const transaction = { refused: false, rollbacks: onRollback ? [onRollback] : [] }; + this._transaction = transaction; + let kept = false; + try { + const result = fn(); + if (typeof (result as unknown as PromiseLike | undefined)?.then === 'function') { + throw new Error( + 'A transaction cannot wait for a promise: its edits would be made after it' + ); + } + kept = result !== false && !transaction.refused; + } finally { + this._transaction = outer; + if (kept) { + outer?.rollbacks.push(...transaction.rollbacks); + } else { + transaction.rollbacks.reverse().forEach((rollback) => rollback()); + this._module = _module; + this._style = _style; + this._committed.length = committed; + this._edits = edits; + this._scopes = undefined; + this._parents = undefined; + try { + this._onCommit?.(); + } catch { + // The code before `fn` indexed, or failed to, the same way. + } + } + } + return kept; + } + /** * Hi-res map from the code the editor was created with to `toString()`, through every commit, * and through `inputSourceMap` when the code came from an earlier transform. Without an input From 8da8b518a79e5e7be271ce5b74696efed8643f17 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 11:49:41 +0200 Subject: [PATCH 268/326] fix(csf-tools): put a one-line object on several lines for a multi-line member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `play: async () => {…}` to `{ args: { a: 1 } }` wrote the function inside the one-line object, a layout no formatter produces and that reads badly in a diff. When a multi-line item, or one ending in a line comment, is appended to an object or array written on one line, appendToList now puts every item on a line of its own at the list's indent plus the file's step, and the closing brace or bracket on the line after them. The items' text stays as written, and so does the list's trailing comma convention. A later append of the same batch continues the broken layout. Call arguments keep hugging their call, as `f(a, () => {…})` does in formatted code, other lists (declarators, specifiers) keep their line, and a one-line item leaves the list on its line. Lists around the broken one, and prepends, keep their layout. The line breaks replace the spaces between items with `update`, which keeps what was written next to them, so appended items stay to the right of the last item and a wrapper written around it before or after the break stays around it alone. Without spaces before the closing token, the token itself takes the line break, so it follows the items. `SourceEdits` gains `update`. No automigration or create-storybook snapshot changed. Two editor and config tests that appended an item ending in a line comment now expect the broken layout, which is closer to what a formatter makes of them. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/ConfigFile.test.ts | 2 +- code/core/src/csf-tools/README.md | 2 +- code/core/src/csf-tools/estree/editor.test.ts | 101 ++++++++++++++++-- code/core/src/csf-tools/estree/editor.ts | 42 +++++++- 4 files changed, 132 insertions(+), 15 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.test.ts b/code/core/src/csf-tools/ConfigFile.test.ts index 3e4bc9057d23..8451fadfab60 100644 --- a/code/core/src/csf-tools/ConfigFile.test.ts +++ b/code/core/src/csf-tools/ConfigFile.test.ts @@ -845,7 +845,7 @@ describe('ConfigFile', () => { { source: `export default { addons: ['b'] };`, expression: `'x' //c`, - output: `export default { addons: ['b', 'x' //c\n] };`, + output: `export default { addons: [\n 'b',\n 'x' //c\n] };`, }, { source: `export default {};`, diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index 613b3e6f1d39..98350476f6f8 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -35,7 +35,7 @@ if (size.kind === 'value') { Read diagnostics count like mutation ones, so `writeConfig` refuses a config after a read that added one. Use `get` and `getValue` for the reads an edit depends on, so a file whose shape was not proven is not written, and `inspect` for reads that only inform, such as an addon showing a field. `set` accepts an expression from `get` or `parseExpression`, or plain strings, numbers, booleans, `null`, `undefined`, and nested arrays or objects of those values and expressions. An object whose `type` names an AST node is rejected with a diagnostic unless it came from `get` or `parseExpression`. Plain values are printed into the source, so later changes to the input do not affect the file. -An expression from `get` is indented as if its line had no indent, and `set` and `transform` indent a value's lines like the line of the field it is written to, so a value copied between fields keeps its layout. +An expression from `get` is indented as if its line had no indent, and `set` and `transform` indent a value's lines like the line of the field it is written to, so a value copied between fields keeps its layout. A field that spans lines, added to an object written on one line, puts every field of that object on a line of its own; a one-line field leaves the object on its line. Objects around it, and an object a field is grouped into, keep their layout. Successful removals and moves recursively remove empty source parents, stopping at the editor's root object or an empty parent that shadows an earlier spread or computed key. Removing a field that could reveal an earlier spread value produces a diagnostic. Moves clean up after inserting the destination, so shared ancestors remain intact. Unrelated empty objects are preserved. Nested object paths can follow local constants used only by that object; shared or reassigned references remain untouched. Nodes reused by `transform` keep their original source, so relocating a value prints it as written instead of pretty-printing it. diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index 8cfe40d32acf..77b0aa1db03c 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -249,13 +249,94 @@ describe('lists', () => { expect(editor.toString()).toBe('f?.(/* ( */ a, b);'); }); - it('indents the continuation lines of an item added to a one-line list like its line', () => { - const { editor } = firstStatement('const a = {\n b: [1],\n};'); - const array = editor.program.body[0] as E.VariableDeclaration; - const object = array.declarations[0].init as E.ObjectExpression; - const value = (object.properties[0] as E.ObjectProperty).value as E.ArrayExpression; - appendToList(editor, arrayList(editor, value), ['{\n c: 2,\n}']); - expect(editor.toString()).toBe('const a = {\n b: [1, {\n c: 2,\n }],\n};'); + it('indents the continuation lines of an argument added to a one-line call like its line', () => { + const { editor } = firstStatement('const a = {\n b: f(1),\n};'); + const declaration = editor.program.body[0] as E.VariableDeclaration; + const object = declaration.declarations[0].init as E.ObjectExpression; + const value = (object.properties[0] as E.ObjectProperty).value as E.CallExpression; + appendToList(editor, argumentList(editor, value), ['{\n c: 2,\n}']); + expect(editor.toString()).toBe('const a = {\n b: f(1, {\n c: 2,\n }),\n};'); + }); + + it('puts the items of a one-line object or array on lines of their own for a multi-line item', () => { + const csf = parse('export default {};\nexport const A = { args: { a: 1 } };\n'); + const [story] = csf.objects({ meta: false }); + story.set(['play'], parseExpression('async () => {\n await x();\n}')); + expect(printCsf(csf).code).toBe(dedent` + export default {}; + export const A = { + args: { a: 1 }, + play: async () => { + await x(); + } + }; + + `); + + const { editor, statement } = firstStatement('call([1, /* two */ 2]);'); + const call = expressionOf(statement) as E.CallExpression; + appendToList(editor, arrayList(editor, call.arguments[0] as E.ArrayExpression), [ + '() => {\n y();\n}', + ]); + expect(editor.toString()).toBe('call([\n 1,\n /* two */ 2,\n () => {\n y();\n }\n]);'); + }); + + it('keeps a one-line object on its line for a one-line item', () => { + const csf = parse('export default {};\nexport const A = { args: { a: 1 } };\n'); + const [story] = csf.objects({ meta: false }); + story.set(['args', 'b'], 2); + story.set(['tags'], ['x']); + expect(printCsf(csf).code).toBe( + 'export default {};\nexport const A = { args: { a: 1, b: 2 }, tags: ["x"] };\n' + ); + }); + + it('lays out a multi-line item added to an object without spaces inside its braces', () => { + const { editor, statement } = firstStatement('({a: 1});'); + appendToList(editor, objectList(editor, expressionOf(statement) as E.ObjectExpression), [ + 'b: {\n c: 2,\n}', + ]); + expect(editor.toString()).toBe('({\n a: 1,\n b: {\n c: 2,\n }\n});'); + }); + + it('keeps the comma of each item when appending to a one-line list twice before a commit', () => { + const { editor, statement } = firstStatement('f([1], { a: 1 });'); + const call = expressionOf(statement) as E.CallExpression; + const [array, object] = call.arguments as [E.ArrayExpression, E.ObjectExpression]; + appendToList(editor, arrayList(editor, array), ['() => {\n x();\n}']); + appendToList(editor, arrayList(editor, array), ['3']); + appendToList(editor, objectList(editor, object), ['b: {\n c: 2,\n}']); + appendToList(editor, objectList(editor, object), ['d: {\n e: 3,\n}']); + expect(editor.toString()).toBe( + 'f([\n 1,\n () => {\n x();\n },\n 3\n], {\n a: 1,\n b: {\n c: 2,\n },\n d: {\n e: 3,\n }\n});' + ); + expect(() => parseModule(editor.toString())).not.toThrow(); + }); + + it.each([ + ['before', '({a: x});'], + ['after', '({ a: x });'], + ])('keeps a wrapper written %s the break around its member alone', (order, code) => { + const { editor, statement } = firstStatement(code); + const object = expressionOf(statement) as E.ObjectExpression; + const wrap = () => + wrapExpression(editor, (object.properties[0] as E.ObjectProperty).value, 'w(', ')'); + if (order === 'before') { + wrap(); + } + appendToList(editor, objectList(editor, object), ['b: {\n c: 2,\n}']); + if (order === 'after') { + wrap(); + } + expect(editor.toString()).toBe('({\n a: w(x),\n b: {\n c: 2,\n }\n});'); + }); + + it('keeps a declaration on its line for a multi-line declarator', () => { + const { editor, statement } = firstStatement('const a = 1;'); + appendToList(editor, declaratorList(editor, statement as E.VariableDeclaration), [ + 'b = {\n c: 2,\n}', + ]); + expect(editor.toString()).toBe('const a = 1, b = {\n c: 2,\n};'); }); it('keeps the line break of a prepended item ending in a line comment when code follows it', () => { @@ -555,19 +636,19 @@ describe('replaceValue', () => { expect(editor.toString()).toBe(`export default { a: x, // c\n };`); }); - it('ends a line comment before the member it is appended after on one line', () => { + it('puts the members of a one-line object on lines of their own for one ending in a line comment', () => { const trailingComma = new SourceEditor(`export default { a: 1, };`); appendToList(trailingComma, objectList(trailingComma, firstMember(trailingComma).object), [ 'b: x // c\n', ]); - expect(trailingComma.toString()).toBe(`export default { a: 1, b: x, // c\n };`); + expect(trailingComma.toString()).toBe(`export default {\n a: 1,\n b: x, // c\n};`); const noComma = new SourceEditor(`export default { a: 1 };`); appendToList(noComma, objectList(noComma, firstMember(noComma).object), [ 'b: x // c\n', 'd: 2', ]); - expect(noComma.toString()).toBe(`export default { a: 1, b: x, // c\nd: 2 };`); + expect(noComma.toString()).toBe(`export default {\n a: 1,\n b: x, // c\n d: 2\n};`); }); }); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 435857de6e38..cd6d6e775034 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -49,6 +49,8 @@ export interface SourceEdits { appendRight(index: number, content: string): this; prependRight(index: number, content: string): this; overwrite(start: number, end: number, content: string): this; + /** Like `overwrite`, but keeps what was written next to the span. */ + update(start: number, end: number, content: string): this; remove(start: number, end: number): this; clone(): SourceEdits; /** The content of a span as edited so far; throws when an edit cuts through its ends. */ @@ -921,6 +923,29 @@ const appended = ( : ',', }); +// Each item of a one-line list on a line of its own at `indent`, and its closing token on the next. +// The line breaks replace the spaces in place, so items written next to them keep their side. +const breakLines = (editor: SourceEditor, list: List, indent: string) => { + const { code, edits } = editor; + list.items.forEach((_, index) => { + const from = itemStart(editor, list, index); + const to = from + /^\s*/.exec(code.slice(from))![0].length; + if (to > from) { + edits.update(from, to, `\n${indent}`); + } else { + edits.appendRight(from, `\n${indent}`); + } + }); + const outer = `\n${lineIndent(code, list.open)}`; + const end = list.close - /\s*$/.exec(code.slice(list.open, list.close))![0].length; + if (end < list.close) { + edits.update(end, list.close, outer); + } else { + // Without spaces before it, the closing token takes the line break, so it follows the items. + edits.update(list.close, list.close + 1, `${outer}${code[list.close]}`); + } +}; + const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appended => { const { code, edits } = editor; const { items, padding } = list; @@ -971,13 +996,24 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const comma = separatorAfter(editor, last); // The comma of a hole writes the hole, so it is no trailing comma to keep. const trailingComma = comma === undefined || isHole(last) ? '' : ','; - if (isMultiline(code, list)) { - const indent = lineIndent(code, last.start); + // A formatter puts the items of an object or array on lines of their own once one spans lines, + // but keeps a last argument that does on the line of the call: `f(a, () => {…})`. + const breaks = + (code[list.open - 1] === '{' || code[list.open - 1] === '[') && + !isMultiline(code, list) && + texts.some((text) => text.includes('\n')); + if (breaks || isMultiline(code, list)) { + const indent = breaks + ? `${lineIndent(code, list.open)}${editor.indent}` + : lineIndent(code, last.start); + if (breaks) { + breakLines(editor, list, indent); + } const at = itemEnd(editor, list, items.length - 1); if (comma === undefined) { edits.appendRight(last.end, ','); } - const lineBreakFollows = lineBreakAt(code, at); + const lineBreakFollows = breaks || lineBreakAt(code, at); return write( { at, right: true, indent, trailingComma, lineBreakFollows }, itemLines(texts, indent, trailingComma, lineBreakFollows) From 629316ae5a37449871387135f459a93566fc166f Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:50:21 +0000 Subject: [PATCH 269/326] Update CHANGELOG.md for v10.6.3 [skip ci] --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 158e7c1b6bff..5cd0b049b2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 10.6.3 + +- Addon Vitest: Support Vitest 5 - [#36671](https://github.com/storybookjs/storybook/pull/36671), thanks @PaulMest! +- Next.js: Fix app-router mock crash on Next.js 16.4 - [#36588](https://github.com/storybookjs/storybook/pull/36588), thanks @obvious-autobuild! +- Nextjs-Vite: Provide parentRenderTree.bfcacheId in app router mock - [#36732](https://github.com/storybookjs/storybook/pull/36732), thanks @bhushan4work! +- TypeScript: Explicitly type returntype of ArgsStore#get - [#36583](https://github.com/storybookjs/storybook/pull/36583), thanks @mrginglymus! + ## 10.6.2 - Angular-Vite: Require @analogjs/vite-plugin-angular 2.7.3 or later - [#36525](https://github.com/storybookjs/storybook/pull/36525), thanks @valentinpalkovic! From 78f0c0c0e99c526d2288154be49d957681752808 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:30:41 +0200 Subject: [PATCH 270/326] fix(csf-tools): set an appended statement apart when a later commit makes it span lines appendStatement chose the blank line before a statement from the statement and the one before it as they were when it was appended. A story copied as `export const Copy = {};` and then given args or a play function through the next edits stayed glued to the story above it. The editor now remembers what it appended without a blank line, as text until the commit that parses it and as a statement after that. Each commit finds those statements in the code it parses: a text by its source, a statement by where its start lands in the edited code. When one of them, or the statement before it, spans lines with no blank line between them, the commit adds the blank line as a second batch of edits over the code it parsed, pushed onto the committed edits so source maps still chain, and parses once more. A statement a commit removes, or that no statement starts at any more, is forgotten, which leaves the layout as it was before this change. A transaction that rolls back restores the list too. Deciding at commit time keeps the existing rule within one batch of edits, so the existing editor.test.ts cases stay as they were, and costs a second parse only in a commit that adds a blank line. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/estree/editor.test.ts | 85 +++++++++++++++++++ code/core/src/csf-tools/estree/editor.ts | 80 ++++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index 77b0aa1db03c..6400ef8c8973 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -957,6 +957,91 @@ describe('appendStatement', () => { expect(editor.toString()).toBe('const a = 1;\nf() // done;\n;(a.b).c = 1;\n'); }); + it('sets an appended statement apart once a later commit makes it span lines', () => { + const csf = parse('export default {};\nexport const A = {};\n'); + appendStatement(csf._editor, 'export const B = {};'); + csf._editor.commit(); + const story = csf + .objects({ meta: false }) + .find(({ target }) => 'exportName' in target && target.exportName === 'B')!; + story.set(['args', 'a'], 1); + expect(printCsf(csf).code).toBe( + 'export default {};\nexport const A = {};\nexport const B = { args: { a: 1 } };\n' + ); + + story.set(['play'], parseExpression('async () => {\n await x();\n}')); + expect(printCsf(csf).code).toBe(dedent` + export default {}; + export const A = {}; + + export const B = { + args: { a: 1 }, + play: async () => { + await x(); + } + }; + + `); + }); + + it('sets an appended statement apart once a later commit makes the one before it span lines', () => { + const csf = parse('export default {};\nexport const A = {};\n'); + appendStatement(csf._editor, 'export const B = {};'); + csf._editor.commit(); + const [story] = csf.objects({ meta: false }); + story.set(['play'], parseExpression('async () => {\n await x();\n}')); + expect(printCsf(csf).code).toBe(dedent` + export default {}; + export const A = { + play: async () => { + await x(); + }, + }; + + export const B = {}; + + `); + }); + + it('sets a pending appended statement apart in the commit that makes the one before it span lines', () => { + const csf = parse('export default {};\nexport const A = {}; // A\n'); + csf._appendStatement('export const B = {};'); + const [story] = csf.objects({ meta: false }); + story.set(['play'], parseExpression('async () => {\n await x();\n}')); + expect(printCsf(csf).code).toBe(dedent` + export default {}; + export const A = { + play: async () => { + await x(); + }, + }; // A + + export const B = {}; + + `); + }); + + it('forgets a statement appended in a transaction that rolls back', () => { + const csf = parse('export default {};\nexport const A = {};\nexport const B = {};\n'); + csf.transaction(() => { + csf._appendStatement('export const B = {};'); + return false; + }); + const [first, story] = csf.objects({ meta: false }); + first.set(['args'], {}); + story.set(['play'], parseExpression('async () => {\n await x();\n}')); + expect(printCsf(csf).code).toBe(dedent` + export default {}; + export const A = { args: {} }; + export const B = { + play: async () => { + await x(); + }, + }; + + `); + }); + it('appends to the end of a file whose statements are all removed', () => { const editor = new SourceEditor(`const a = 1;\nconst b = 2;\n`); removeStatements(editor, new Set(editor.program.body)); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index cd6d6e775034..4d25c838c0ff 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -119,6 +119,11 @@ export class SourceEditor { } } + // What `appendStatement` wrote without a blank line before it: statements of this parse, and + // texts of pending edits. Each commit finds them in the code it parses, and sets one apart once it + // or the statement before it spans lines. + _appendedWithoutBlankLine: (Node | string)[] = []; + constructor(code: string, fileName?: string, onCommit?: () => void) { this.fileName = fileName; this._onCommit = onCommit; @@ -209,9 +214,27 @@ export class SourceEditor { if (!this.pending) { return false; } - const module = parsePrintable(this._edits.toString(), this.fileName); + let module = parsePrintable(this._edits.toString(), this.fileName); this._style ??= inferStyle(this._module); this._committed.push(this._edits); + let appended = this._appendedWithoutBlankLine + .map((statement) => committedStatement(this._edits, module, statement)) + .filter((statement) => statement !== undefined); + // The blank lines go in as edits of their own, over the code just parsed. + const apart = appended.filter((statement) => setsApart(module, statement)); + if (apart.length > 0) { + const spacing = new MagicString(module.code, { filename: this.fileName }); + for (const statement of apart) { + spacing.appendLeft(ownLineStart(module, statement), '\n'); + } + module = parsePrintable(spacing.toString(), this.fileName); + this._committed.push(spacing); + appended = appended + .filter((statement) => !apart.includes(statement)) + .map((statement) => committedStatement(spacing, module, statement)) + .filter((statement) => statement !== undefined); + } + this._appendedWithoutBlankLine = appended; this._module = module; this._scopes = undefined; this._parents = undefined; @@ -230,6 +253,8 @@ export class SourceEditor { const { _module, _style, _transaction: outer } = this; const committed = this._committed.length; const edits = this._edits.clone(); + lastInserted.set(edits, new Map(lastInserted.get(this._edits))); + const appended = [...this._appendedWithoutBlankLine]; const transaction = { refused: false, rollbacks: onRollback ? [onRollback] : [] }; this._transaction = transaction; let kept = false; @@ -251,6 +276,7 @@ export class SourceEditor { this._style = _style; this._committed.length = committed; this._edits = edits; + this._appendedWithoutBlankLine = appended; this._scopes = undefined; this._parents = undefined; try { @@ -1358,9 +1384,61 @@ export const appendStatement = (editor: SourceEditor, text: string) => { // The text inserted last may lead with the blank line that set it apart. const before = lastInserted.get(editor.edits)?.get(last)?.trim() ?? editor.source(last); const multiline = text.includes('\n') || before.includes('\n'); + if (!multiline) { + editor._appendedWithoutBlankLine.push(text); + } insertStatementAfter(editor, last, `${multiline ? '\n' : ''}${text}`); }; +// The statement of the parse of `edits` that a statement of the code they edit became, or that a +// text appended in them is. +const committedStatement = ( + edits: SourceEdits, + { code, program }: ParsedModule, + statement: Node | string +) => { + if (typeof statement === 'string') { + return program.body.findLast((node) => sourceOf(code, node) === statement); + } + let start: number; + try { + if (edits.slice(statement.start, statement.end) === '') { + return undefined; + } + start = normalizeLineEndings(edits.slice(0, statement.start)).length; + } catch { + // An edit cut through the statement. + return undefined; + } + return program.body.find((node) => node.start === start); +}; + +const statementBefore = (program: E.Program, statement: Node): Node | undefined => + (program.body as readonly Node[])[program.body.indexOf(statement as E.Statement) - 1]; + +// Whether a statement, or the statement before it, spans lines with no blank line between them. +const setsApart = ({ code, program }: ParsedModule, statement: Node) => { + const before = statementBefore(program, statement); + return ( + !!before && + [before, statement].some((node) => sourceOf(code, node).includes('\n')) && + !/\n[^\S\n]*\n/.test(code.slice(before.end, statement.start)) + ); +}; + +// The start of the first line of a statement with the comments above it, below the statement +// before it and the comments that share that statement's line. +const ownLineStart = ({ code, program, comments }: ParsedModule, statement: Node) => { + const before = statementBefore(program, statement)!; + const above = comments.find( + (comment) => + comment.start >= before.end && + comment.end <= statement.start && + code.slice(before.end, comment.start).includes('\n') + ); + return lineStart(code, (above ?? statement).start); +}; + /** The span of a statement with the comments it owns. */ export const statementSpan = (editor: SourceEditor, statement: Span): Span => ({ start: (leadingComments(editor, statement)[0] ?? statement).start, From 93fc3eee5c5b2ad6db172e3efd912b0e73a71dd2 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 10:44:09 +0200 Subject: [PATCH 271/326] feat(csf-tools): edit a CSF2 story's annotation assignments as fields of the story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objects() found `Story.parameters = {…}` and `Story.argTypes = {…}` as separate editors that each refused every path outside their own annotation. A codemod porting `parameters.msw` to `beforeEach` could remove the msw handlers but not add `beforeEach`, and `args`, `play`, `tags` and the other annotations had no editor at all. A story is now one editor whose fields are the members of its object literal, if it has one, followed by its top-level `Story.name = value` assignments, which run after it. Every CSF annotation written that way is a field, read and written with the same paths as an inline one. A story without an object literal (a function or a bound template) adds a missing field as `Story.name = value;` after its last assignment, or after its declaration when it has none; removing a field, or emptying it through cleanup, removes its statement. A field written inline and assigned is a duplicate field, and fields from both places cannot be grouped together. The assignments become members of an in-memory root, as named config exports already are (both now build them with `detachedMember`), so the mutation code has no second path and the annotation prefix and its path rewriting are gone. `node` is the story's object literal, or `undefined` for a story without one. The conservative rules stay and are now checked through the references to the story's binding. A reassigned binding, or any write other than a top-level `Story.name = value` (a nested write such as `Story.args.x = 1`, a compound or computed assignment), leaves the story uneditable with a diagnostic; that now includes story objects with a nested write, which were editable before although their fields did not show it. For a story whose assignments are edited, an alias, a spread, or a call or array that receives the story does the same; calling or rendering it does not. A story holding another value than an object, a function or a bound template cannot be annotated, since that could change other stories. The `story-annotation` target is gone; these editors are `story` targets. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/ConfigFile.ts | 2 +- code/core/src/csf-tools/ConfigObject.ts | 15 +- code/core/src/csf-tools/CsfFile.ts | 4 +- code/core/src/csf-tools/CsfObject.test.ts | 53 ++- code/core/src/csf-tools/CsfObject.ts | 183 ++++---- .../src/csf-tools/CsfObjectDiscovery.test.ts | 249 ++++++++++- code/core/src/csf-tools/CsfObjectDiscovery.ts | 393 ++++++++++++------ code/core/src/csf-tools/README.md | 11 +- 8 files changed, 617 insertions(+), 293 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 8bc33eabae61..db752903040c 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -935,7 +935,7 @@ export class ConfigFile implements CsfObject { return node && literalRoot(this._editor, node); }, }; - return createCsfObject(target, host, [], report, () => { + return createCsfObject(target, host, report, () => { this._changed = true; }); }); diff --git a/code/core/src/csf-tools/ConfigObject.ts b/code/core/src/csf-tools/ConfigObject.ts index fc067f070f9f..10bde64dce25 100644 --- a/code/core/src/csf-tools/ConfigObject.ts +++ b/code/core/src/csf-tools/ConfigObject.ts @@ -6,6 +6,7 @@ import { type MemberInsert, type ObjectRoot, createCsfObject, + detachedMember, literalRoot, } from './CsfObject.ts'; import { @@ -337,17 +338,7 @@ const namedExportsRoot = (editor: SourceEditor, entries: NamedEntry[]): ObjectRo entry.declaration.type === 'VariableDeclarator' ? entry.declaration.init : entry.declaration ) as E.Expression; const key = entry.specifier ? entry.specifier.specifier.exported : entry.declaration.id!; - const property = { - type: 'Property', - kind: 'init', - method: false, - shorthand: false, - computed: false, - key, - value, - start: entry.declaration.start, - end: entry.declaration.end, - } as unknown as Property; + const property = detachedMember(key, value, false, entry.declaration); byProperty.set(property, entry); return property; }); @@ -496,5 +487,5 @@ export function createConfigObject( return current.ok ? current.root : undefined; }, }; - return { ok: true, object: createCsfObject({ kind: 'config' }, host, [], report, markChanged) }; + return { ok: true, object: createCsfObject({ kind: 'config' }, host, report, markChanged) }; } diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 198323fecd62..83bc9b49c598 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -421,8 +421,8 @@ export class CsfFile { } /** - * Discover editors for the meta and stories, including CSF2 parameter and argTypes assignments. Unsupported - * targets are skipped and reported in `mutationDiagnostics`. + * Discover editors for the meta and stories; a story's CSF2 annotation assignments are its fields. + * Unsupported targets are skipped and reported in `mutationDiagnostics`. * * @example * ```ts diff --git a/code/core/src/csf-tools/CsfObject.test.ts b/code/core/src/csf-tools/CsfObject.test.ts index bbcf40400c38..91cbbae3999b 100644 --- a/code/core/src/csf-tools/CsfObject.test.ts +++ b/code/core/src/csf-tools/CsfObject.test.ts @@ -80,21 +80,22 @@ describe('CsfObject', () => { expect(csf.mutationDiagnostics).toMatchObject([{ code: 'spread-field' }]); }); - it('inspects a path a CSF2 annotation editor does not hold, and the object itself, as unreadable', () => { + it('inspects every annotation of a CSF2 story, and the object itself as unreadable', () => { const csf = parse( 'export default {};\nexport const A = Template.bind({});\nA.args = { size: "l" };\nA.parameters = { a: 1 };' ); - const [parameters] = csf.objects({ meta: false }); + const [story] = csf.objects({ meta: false }); const [meta] = csf.objects({ stories: false }); - expect(parameters.target).toMatchObject({ kind: 'story-annotation', annotation: 'parameters' }); - expect(parameters.inspect(['parameters']).kind).toBe('value'); - expect(parameters.inspect(['args', 'size'])).toMatchObject({ + expect(story.target).toEqual({ kind: 'story', exportName: 'A', localName: 'A' }); + expect(story.inspect(['parameters']).kind).toBe('value'); + expect(story.inspect(['args', 'size']).kind).toBe('value'); + expect(story.inspect([])).toMatchObject({ kind: 'unreadable', - diagnostic: { code: 'unsupported-member', path: ['args', 'size'] }, + diagnostic: { code: 'unsupported-member', path: [] }, }); expect(meta.inspect([])).toMatchObject({ kind: 'unreadable' }); - expect(parameters.get(['args', 'size'])).toBeUndefined(); + expect(story.get([])).toBeUndefined(); expect(csf.mutationDiagnostics).toEqual([]); }); @@ -427,19 +428,24 @@ describe('CsfObject', () => { expect(csf.changed).toBe(false); }); - it('stops cleanup at a CSF2 annotation root', () => { + it('removes a CSF2 annotation that cleanup empties', () => { const csf = parse(` export default { title: 'Example' }; export const Basic = () => null; Basic.parameters = { viewport: { disable: true } }; + Basic.args = {}; `); - const [parameters] = csf.objects({ meta: false }); + const [story] = csf.objects({ meta: false }); - expect(parameters.remove(['parameters', 'viewport', 'disable'])).toEqual({ + expect(story.remove(['parameters', 'viewport', 'disable'])).toEqual({ ok: true, changed: true, }); - expect(printCsf(csf).code).toContain('Basic.parameters = {};'); + expect(story.remove(['args'])).toEqual({ ok: true, changed: true }); + expect(printCsf(csf).code).toBe(` + export default { title: 'Example' }; + export const Basic = () => null; + `); expect(csf.mutationDiagnostics).toEqual([]); }); @@ -595,15 +601,17 @@ describe('CsfObject', () => { export const Basic = () => null; Basic.parameters = { first: true }; Basic.parameters = { second: true }; + Basic.args = {}; `); + const [story] = csf.objects({ meta: false }); - expect(csf.objects({ meta: false })).toEqual([]); - expect(csf.mutationDiagnostics).toContainEqual( - expect.objectContaining({ - code: 'ambiguous-binding', - target: expect.objectContaining({ kind: 'story-annotation', exportName: 'Basic' }), - }) - ); + expect(story.get(['parameters'])).toBeUndefined(); + expect(story.remove(['parameters', 'first']).ok).toBe(false); + expect(story.set(['args', 'label'], 'Label').ok).toBe(true); + expect(csf.mutationDiagnostics).toEqual([ + expect.objectContaining({ code: 'duplicate-field', path: ['parameters'] }), + expect.objectContaining({ code: 'duplicate-field', path: ['parameters', 'first'] }), + ]); }); it.each([ @@ -1380,13 +1388,14 @@ describe('CsfObject.node', () => { expect(textOf(story.node!)).toBe('{ args: { disabled: true } }'); }); - it('returns the assigned object of a CSF2 annotation and the argument of a factory', () => { + it('returns the object of a story with assignments, nothing without one, and the argument of a factory', () => { const csf = parse( - `export default {};\nexport const Primary = () => null;\nPrimary.parameters = { a: 1 };` + `export default {};\nexport const Primary = () => null;\nPrimary.parameters = { a: 1 };\nexport const Secondary = { args: {} };\nSecondary.parameters = { a: 1 };` ); - const [annotation] = csf.objects({ meta: false }); + const [primary, secondary] = csf.objects({ meta: false }); - expect(textOf(annotation.node!)).toBe('{ a: 1 }'); + expect(primary.node).toBeUndefined(); + expect(textOf(secondary.node!)).toBe('{ args: {} }'); const config = loadConfig(`export default definePreview({ tags: [] });`).parse(); expect(textOf(config.node!)).toBe('{ tags: [] }'); diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 7dcb7d3625be..f906e87bc806 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -3,6 +3,7 @@ import { type Node, type Property, type SourceLocation, + type Span, locationOf, parseExpression, staticKey, @@ -11,7 +12,6 @@ import { import { type SourceEditor, appendMembers, - detachedText, detachedTextOf, groupMembers, keySource, @@ -41,13 +41,7 @@ export type CsfObjectTarget = | { kind: 'config' } | { kind: 'call-argument'; importedName: string; methodName: string } | { kind: 'meta' } - | { kind: 'story'; exportName: string; localName: string } - | { - kind: 'story-annotation'; - exportName: string; - localName: string; - annotation: 'parameters' | 'argTypes'; - }; + | { kind: 'story'; exportName: string; localName: string }; export type CsfMutationDiagnosticCode = | 'unsupported-initializer' @@ -71,8 +65,8 @@ export interface CsfMutationDiagnostic { } /** - * What `inspect` found at a path. A path that is not a field of the object, such as one outside a - * CSF2 annotation editor's annotation, is unreadable. + * What `inspect` found at a path. A path that is not a field of the object, the empty path, is + * unreadable. */ export type CsfInspection = | { kind: 'value'; node: CsfExpression } @@ -96,7 +90,7 @@ export type CsfExpression = E.Expression; export interface CsfObject { /** - * Identify the config, meta, story, annotation, or call argument this editor represents. + * Identify the config, meta, story, or call argument this editor represents. * * @example * ```ts @@ -118,9 +112,9 @@ export interface CsfObject { */ readonly changed: boolean; /** - * Find the object literal this editor edits in the current parse; for a CSF2 annotation editor, - * the object its annotation path names. It is `undefined` for a config made of named exports and - * once the object is gone. It is the file's own node, not a copy: do not change it, and read it + * Find the object literal this editor edits in the current parse. It is `undefined` for a config + * made of named exports, for a story without an object literal, whose fields are all + * `Story.name = value` assignments, and once the object is gone. It is the file's own node, not a copy: do not change it, and read it * again after a mutation, which parses the file again. * * @example @@ -134,8 +128,7 @@ export interface CsfObject { */ readonly node: E.ObjectExpression | undefined; /** - * Read a detached copy of the expression at a property path. For a CSF2 annotation, its - * annotation path returns the whole object. Missing fields return `undefined`; so does a path + * Read a detached copy of the expression at a property path. Missing fields return `undefined`; so does a path * through a value that is not an object literal, or one a spread, computed key, or duplicate field * leaves unproven, which also adds a mutation diagnostic. * @@ -288,6 +281,24 @@ export interface ObjectRoot { take(member: Property, key: string): MemberInsert; } +/** + * A member of an `ObjectRoot` that is not written as one, such as a named export or a + * `Story.name = value` assignment. Its key and value are real nodes, so every span a shared helper + * reads is a real one; the member itself only exists in memory, spanning `statement`. + */ +export const detachedMember = (key: Node, value: Node, computed: boolean, statement: Span) => + ({ + type: 'Property', + kind: 'init', + method: false, + shorthand: false, + computed, + key, + value, + start: statement.start, + end: statement.end, + }) as unknown as Property; + export interface CsfObjectHost { readonly editor: SourceEditor; // Located again after every commit. @@ -388,8 +399,6 @@ class CsfObjectEditor implements CsfObject { private readonly host: CsfObjectHost; - private readonly prefix: readonly string[]; - private readonly reportDiagnostic: ReportDiagnostic; private readonly markChanged: MarkChanged; @@ -397,13 +406,11 @@ class CsfObjectEditor implements CsfObject { constructor( target: CsfObjectTarget, host: CsfObjectHost, - prefix: readonly string[], reportDiagnostic: ReportDiagnostic, markChanged: MarkChanged ) { this.target = target; this.host = host; - this.prefix = prefix; this.reportDiagnostic = reportDiagnostic; this.markChanged = markChanged; } @@ -451,11 +458,6 @@ class CsfObjectEditor implements CsfObject { // `get` reads a path outside the object as missing, without a diagnostic. private read(path: readonly string[]): CsfInspection | { kind: 'outside' } { - const root = this.rootNodeFor(path); - if (root) { - const source = detachedText(this.editor.code, root.start, this.editor.source(root)); - return { kind: 'value', node: parseExpression(source) }; - } const found = this.find(path); return found.kind === 'property' ? { kind: 'value', node: parseExpression(valueSource(this.editor, found.property)) } @@ -463,15 +465,6 @@ class CsfObjectEditor implements CsfObject { } getValue(path: readonly string[]): CsfValue { - const root = this.rootNodeFor(path); - if (root) { - const value = readValue(this.editor.scopes, root); - if (value === UNRESOLVED) { - this.reportDiagnostic(this.diagnostic('unsupported-value', path, root)); - return undefined; - } - return value; - } const property = this.getProperty(path); if (!property) { return undefined; @@ -488,13 +481,6 @@ class CsfObjectEditor implements CsfObject { return value; } - // A CSF2 annotation editor answers its own annotation path with the whole assigned object. - private rootNodeFor(path: readonly string[]): E.ObjectExpression | undefined { - return this.prefix.length > 0 && this.normalizePath(path)?.length === 0 - ? this.root().node - : undefined; - } - private find( path: readonly string[] ): @@ -502,11 +488,10 @@ class CsfObjectEditor implements CsfObject { | { kind: 'missing' } | { kind: 'outside' } | { kind: 'unreadable'; diagnostic: CsfMutationDiagnostic } { - const logicalPath = this.normalizePath(path); - if (!logicalPath || logicalPath.length === 0) { + if (path.length === 0) { return { kind: 'outside' }; } - const found = this.locate(logicalPath); + const found = this.locate(path); if (found.ok === false) { return { kind: 'unreadable', diagnostic: this.diagnostic(found.code, path, found.node) }; } @@ -522,8 +507,7 @@ class CsfObjectEditor implements CsfObject { } set(path: readonly string[], value: CsfValue): CsfMutationResult { - const logicalPath = this.normalizePath(path); - if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath)) { + if (path.length === 0 || unsafePath(path)) { return this.failure('unsupported-member', path, this.anchor()); } const unprintable = nodeWithoutParse(value); @@ -535,7 +519,7 @@ class CsfObjectEditor implements CsfObject { `Cannot set ${path.join('.')} to a ${unprintable.type} node that did not come from \`get\`, \`transform\` or \`parseExpression\`` ); } - const inspected = this.locate(logicalPath, 'set'); + const inspected = this.locate(path, 'set'); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -544,11 +528,11 @@ class CsfObjectEditor implements CsfObject { if (inspected.property && inspected.parent) { inspected.parent.root.replaceValue(inspected.property, text); } else { - this.insert(logicalPath, [ + this.insert(path, [ { - key: logicalPath.at(-1)!, + key: path.at(-1)!, value: text, - text: `${printKey(logicalPath.at(-1)!, this.editor.quote)}: ${text}`, + text: `${printKey(path.at(-1)!, this.editor.quote)}: ${text}`, }, ]); } @@ -559,11 +543,10 @@ class CsfObjectEditor implements CsfObject { path: readonly string[], derive: (value: CsfExpression) => CsfExpression | undefined ): CsfMutationResult { - const logicalPath = this.normalizePath(path); - if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath)) { + if (path.length === 0 || unsafePath(path)) { return this.failure('unsupported-member', path, this.anchor()); } - const inspected = this.locate(logicalPath); + const inspected = this.locate(path); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -581,18 +564,17 @@ class CsfObjectEditor implements CsfObject { } remove(path: readonly string[]): CsfMutationResult { - const logicalPath = this.normalizePath(path); - if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath)) { + if (path.length === 0 || unsafePath(path)) { return this.failure('unsupported-member', path, this.anchor()); } - const inspected = this.locate(logicalPath); + const inspected = this.locate(path); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } if (!inspected.property || !inspected.parent) { return { ok: true, changed: false }; } - const removal = lookupProperty(inspected.parent.root.properties, logicalPath.at(-1)!, 'remove'); + const removal = lookupProperty(inspected.parent.root.properties, path.at(-1)!, 'remove'); if (!removal.ok) { return this.failure(removal.code, path, removal.node); } @@ -601,7 +583,7 @@ class CsfObjectEditor implements CsfObject { if (!removed.ok) { return removed; } - return this.removeEmptyParents(logicalPath, path) ?? removed; + return this.removeEmptyParents(path) ?? removed; } rename(path: readonly string[], name: string): CsfMutationResult { @@ -610,43 +592,28 @@ class CsfObjectEditor implements CsfObject { } move(from: readonly string[], to: readonly string[]): CsfMutationResult { - const sourcePath = this.normalizePath(from); - const destinationPath = this.normalizePath(to); - if ( - !sourcePath || - !destinationPath || - sourcePath.length === 0 || - destinationPath.length === 0 || - unsafePath(sourcePath) || - unsafePath(destinationPath) - ) { - return this.failure('unsupported-member', !sourcePath ? from : to, this.anchor()); + if (from.length === 0 || to.length === 0 || unsafePath(from) || unsafePath(to)) { + return this.failure('unsupported-member', from.length === 0 ? from : to, this.anchor()); } - if ( - sourcePath.length === destinationPath.length && - sourcePath.every((part, index) => destinationPath[index] === part) - ) { + if (from.length === to.length && from.every((part, index) => to[index] === part)) { return { ok: true, changed: false }; } - if ( - destinationPath.length > sourcePath.length && - sourcePath.every((part, index) => destinationPath[index] === part) - ) { + if (to.length > from.length && from.every((part, index) => to[index] === part)) { return this.failure('cyclic-move', to, this.anchor()); } - const source = this.locate(sourcePath); + const source = this.locate(from); if (source.ok === false) { return this.failure(source.code, from, source.node); } if (!source.property || !source.parent) { return { ok: true, changed: false }; } - const removal = lookupProperty(source.parent.root.properties, sourcePath.at(-1)!, 'remove'); + const removal = lookupProperty(source.parent.root.properties, from.at(-1)!, 'remove'); if (!removal.ok) { return this.failure(removal.code, from, removal.node); } - const destination = this.locate(destinationPath); + const destination = this.locate(to); if (destination.ok === false) { return this.failure(destination.code, to, destination.node); } @@ -654,9 +621,9 @@ class CsfObjectEditor implements CsfObject { return this.failure('occupied-destination', to, destination.property); } - const key = destinationPath.at(-1)!; - const sourceParent = sourcePath.slice(0, -1); - const destinationParent = destinationPath.slice(0, -1); + const key = to.at(-1)!; + const sourceParent = from.slice(0, -1); + const destinationParent = to.slice(0, -1); if ( sourceParent.length === destinationParent.length && sourceParent.every((part, index) => destinationParent[index] === part) @@ -668,35 +635,34 @@ class CsfObjectEditor implements CsfObject { const moved = source.parent.root.take(source.property, key); // Insert first: if that throws, nothing has been committed. The source is removed afterwards, // looked up again since the insertion moved its spans. - this.insert(destinationPath, [{ ...moved, key }]); + this.insert(to, [{ ...moved, key }]); const inserted = this.commit(from); if (!inserted.ok) { return inserted; } - const remaining = this.locate(sourcePath); + const remaining = this.locate(from); if (remaining.ok === false || !remaining.property || !remaining.parent) { - throw new Error(`CsfObject: ${sourcePath.join('.')} was not found again after moving it`); + throw new Error(`CsfObject: ${from.join('.')} was not found again after moving it`); } remaining.parent.root.remove([remaining.property]); const removed = this.commit(from); if (!removed.ok) { return removed; } - return this.removeEmptyParents(sourcePath, from) ?? removed; + return this.removeEmptyParents(from) ?? removed; } group(path: readonly string[], names: readonly string[]): CsfMutationResult { - const logicalPath = this.normalizePath(path); - if (!logicalPath || logicalPath.length === 0 || unsafePath(logicalPath) || unsafePath(names)) { + if (path.length === 0 || unsafePath(path) || unsafePath(names)) { return this.failure('unsupported-member', path, this.anchor()); } - const group = logicalPath.at(-1)!; + const group = path.at(-1)!; if (names.includes(group)) { return this.failure('cyclic-move', path, this.anchor()); } let parent: ObjectRoot = this.root(); - if (logicalPath.length > 1) { - const inspected = this.locate(logicalPath.slice(0, -1)); + if (path.length > 1) { + const inspected = this.locate(path.slice(0, -1)); if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } @@ -830,6 +796,16 @@ class CsfObjectEditor implements CsfObject { ); } } + // The members a root adds after its object literal, like a story's assignments, run later. + const inLiteral = (property: Property) => parent.node?.properties.includes(property); + if (moved.some(inLiteral) && !moved.every(inLiteral)) { + return this.failure( + 'evaluation-order', + path, + moved.find((property) => !inLiteral(property))!, + `the ${group} options are written both in the object and after it, so grouping them could change expression evaluation order` + ); + } const first = parent.properties.indexOf(moved[0]); const last = parent.properties.indexOf(moved.at(-1)!); if (last - first + 1 !== moved.length) { @@ -846,10 +822,7 @@ class CsfObjectEditor implements CsfObject { } // Returns the result of a rejected commit, which ends the clean-up. - private removeEmptyParents( - path: readonly string[], - reported: readonly string[] - ): CsfMutationResult | undefined { + private removeEmptyParents(path: readonly string[]): CsfMutationResult | undefined { for (let depth = path.length - 1; depth > 0; depth--) { const ancestor = this.locate(path.slice(0, depth)); if ( @@ -868,7 +841,7 @@ class CsfObjectEditor implements CsfObject { return; } ancestor.parent.root.remove([ancestor.property]); - const committed = this.commit(reported); + const committed = this.commit(path); if (!committed.ok) { return committed; } @@ -876,19 +849,6 @@ class CsfObjectEditor implements CsfObject { return undefined; } - private normalizePath(path: readonly string[]) { - if (this.prefix.length === 0) { - return [...path]; - } - if ( - path.length < this.prefix.length || - this.prefix.some((part, index) => path[index] !== part) - ) { - return undefined; - } - return path.slice(this.prefix.length); - } - // `access` applies to the last field of the path; its parents are read. private locate( path: readonly string[], @@ -1028,9 +988,8 @@ class CsfObjectEditor implements CsfObject { export const createCsfObject = ( target: CsfObjectTarget, host: CsfObjectHost, - prefix: readonly string[], report: ReportDiagnostic, markChanged: MarkChanged -): CsfObject => new CsfObjectEditor(target, host, prefix, report, markChanged); +): CsfObject => new CsfObjectEditor(target, host, report, markChanged); export { keySource }; diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts index e24a723c8f68..b28641bd7e8f 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { loadCsf, printCsf } from './CsfFile.ts'; +import { parseExpression } from './estree/ast.ts'; const parse = (source: string) => loadCsf(source, { makeTitle: (title) => title ?? 'title' }).parse(); @@ -29,13 +30,8 @@ describe('CsfObject discovery', () => { expect(csf.objects({ meta: false })).toEqual([]); expect(csf.mutationDiagnostics).toContainEqual( expect.objectContaining({ - code: 'unsupported-initializer', - target: { - kind: 'story-annotation', - exportName: 'Basic', - localName: 'Basic', - annotation: 'parameters', - }, + code: 'dynamic-key', + target: { kind: 'story', exportName: 'Basic', localName: 'Basic' }, }) ); }); @@ -50,14 +46,12 @@ describe('CsfObject discovery', () => { `); expect(csf.objects({ meta: false })).toEqual([]); - expect(csf.mutationDiagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'ambiguous-binding', - target: expect.objectContaining({ annotation: 'parameters' }), - }), - ]) - ); + expect(csf.mutationDiagnostics).toEqual([ + expect.objectContaining({ + code: 'dynamic-key', + target: { kind: 'story', exportName: 'Basic', localName: 'Basic' }, + }), + ]); }); it('does not discover excluded CSF4 factory exports', () => { @@ -222,4 +216,229 @@ describe('CsfObject discovery', () => { }) ); }); + + describe('CSF2 annotation assignments', () => { + const legacy = `export default { title: 'Example' }; +const Template = (args) => args; +export const Primary = Template.bind({}); +Primary.args = { label: 'Primary' }; +Primary.argTypes = { label: { control: 'text' } }; +Primary.parameters = { msw: { handlers: [] } }; +Primary.decorators = [withTheme]; +Primary.loaders = [async () => ({})]; +Primary.beforeEach = setup; +Primary.play = async () => {}; +Primary.render = (args) => args; +Primary.tags = ['autodocs']; +`; + + it('edits every annotation of a story through one editor with the paths of a story object', () => { + const csf = parse(legacy); + const objects = csf.objects({ meta: false }); + + expect(objects.map(({ target }) => target)).toEqual([ + { kind: 'story', exportName: 'Primary', localName: 'Primary' }, + ]); + const [story] = objects; + expect(story.getValue(['args'])).toEqual({ label: 'Primary' }); + expect(story.getValue(['argTypes', 'label', 'control'])).toBe('text'); + expect(story.getValue(['tags'])).toEqual(['autodocs']); + for (const annotation of ['decorators', 'loaders', 'beforeEach', 'play', 'render']) { + expect(story.get([annotation])).toBeDefined(); + } + + expect(story.set(['args', 'label'], 'Changed').ok).toBe(true); + expect(story.set(['play'], parseExpression('async () => run()')).ok).toBe(true); + expect(story.remove(['tags']).ok).toBe(true); + expect(printCsf(csf).code).toContain("Primary.args = { label: 'Changed' };"); + expect(printCsf(csf).code).toContain('Primary.play = async () => run();'); + expect(printCsf(csf).code).not.toContain('Primary.tags'); + expect(csf.mutationDiagnostics).toEqual([]); + }); + + it('adds a missing annotation after the last one and removes an annotation that is emptied', () => { + const csf = parse( + legacy.replace(/^Primary\.(decorators|loaders|beforeEach|play|render|tags) = .*\n/gm, '') + ); + const [story] = csf.objects({ meta: false }); + + const kept = csf.transaction(() => { + story.remove(['parameters', 'msw']); + story.set(['beforeEach'], parseExpression('mockHandlers')); + }); + + expect(kept).toBe(true); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +const Template = (args) => args; +export const Primary = Template.bind({}); +Primary.args = { label: 'Primary' }; +Primary.argTypes = { label: { control: 'text' } }; +Primary.beforeEach = mockHandlers; +`); + }); + + it('adds the first annotation of a story after its declaration', () => { + const csf = parse(`export default { title: 'Example' }; +const Local = () => null; +export { Local as Primary }; +const after = 1; +`); + const [story] = csf.objects({ meta: false }); + + expect(story.set(['parameters', 'layout'], 'centered')).toEqual({ ok: true, changed: true }); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +const Local = () => null; +Local.parameters = { layout: 'centered' }; +export { Local as Primary }; +const after = 1; +`); + }); + + it('adds an annotation to a story declared as a function', () => { + const csf = parse(`export default { title: 'Example' }; +export function Primary() {} +`); + const [story] = csf.objects({ meta: false }); + + expect(story.set(['args'], { label: 'Primary' })).toEqual({ ok: true, changed: true }); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +export function Primary() {} +Primary.args = { label: 'Primary' }; +`); + }); + + it('edits the object of a story and its assignments as one object', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = { args: { label: 'Primary' } }; +Primary.parameters = { layout: 'centered' }; +`); + const objects = csf.objects({ meta: false }); + expect(objects).toHaveLength(1); + const [story] = objects; + + expect(story.set(['tags'], ['autodocs']).ok).toBe(true); + expect(story.remove(['parameters', 'layout']).ok).toBe(true); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +export const Primary = { args: { label: 'Primary' }, tags: ['autodocs'] }; +`); + }); + + it('rejects an annotation written both in the story object and as an assignment', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = { args: { label: 'Primary' } }; +Primary.args = { label: 'Assigned' }; +`); + const [story] = csf.objects({ meta: false }); + + expect(story.set(['args', 'label'], 'Changed').ok).toBe(false); + expect(story.getValue(['args'])).toBeUndefined(); + expect(csf.mutationDiagnostics.map(({ code }) => code)).toEqual([ + 'duplicate-field', + 'duplicate-field', + ]); + }); + + it.each([ + ['an alias', 'export const Secondary = Primary;\nSecondary.args = {};'], + ['a nested write', "Primary.args.label = 'Changed';"], + ['an Object.assign call', 'Object.assign(Primary, { args: {} });'], + ])('rejects a story whose binding has %s', (_kind, code) => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = () => null; +Primary.args = { label: 'Primary' }; +${code} +`); + + expect( + csf + .objects({ meta: false }) + .filter(({ target }) => 'exportName' in target && target.exportName === 'Primary') + ).toEqual([]); + expect(csf.mutationDiagnostics).toContainEqual( + expect.objectContaining({ + code: 'ambiguous-binding', + target: { kind: 'story', exportName: 'Primary', localName: 'Primary' }, + }) + ); + }); + + it('groups assignments into a new assignment and refuses to group them with object fields', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = () => null; +Primary.width = 1; +Primary.height = 2; +export const Mixed = { width: 1 }; +Mixed.height = 2; +`); + const [primary, mixed] = csf.objects({ meta: false }); + + expect(primary.group(['size'], ['width', 'height'])).toEqual({ ok: true, changed: true }); + expect(mixed.group(['size'], ['width', 'height'])).toMatchObject({ + ok: false, + diagnostic: { code: 'evaluation-order' }, + }); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +export const Primary = () => null; +Primary.size = { width: 1, height: 2 }; +export const Mixed = { width: 1 }; +Mixed.height = 2; +`); + }); + + it('edits a story that another story calls', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = (args) => null; +export const Secondary = (args) => Primary(args); +`); + + expect(csf.objects({ meta: false })).toHaveLength(2); + expect(csf.mutationDiagnostics).toEqual([]); + }); + + it.each([ + ['a story without annotations in an array', 'export const all = [Primary];'], + ['a spread of the story', 'export const Copy = { ...Primary };'], + ])('rejects %s, which could see the annotations it adds', (_kind, code) => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = Template.bind({}); +${code} +`); + + expect( + csf + .objects({ meta: false }) + .filter(({ target }) => 'exportName' in target && target.exportName === 'Primary') + ).toEqual([]); + expect(csf.mutationDiagnostics).toContainEqual( + expect.objectContaining({ + code: 'ambiguous-binding', + target: { kind: 'story', exportName: 'Primary', localName: 'Primary' }, + }) + ); + }); + + it('rejects a story object with a nested write, which its fields would not show', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = { args: { label: 'Primary' } }; +Primary.args.label = 'Changed'; +`); + + expect(csf.objects({ meta: false })).toEqual([]); + expect(csf.mutationDiagnostics).toEqual([ + expect.objectContaining({ code: 'ambiguous-binding', path: [] }), + ]); + }); + + it('does not add annotations to a story that holds another value', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = shared; +Primary.args = {}; +`); + + expect(csf.objects({ meta: false })).toEqual([]); + expect(csf.mutationDiagnostics).toContainEqual( + expect.objectContaining({ code: 'unsupported-initializer', path: ['args'] }) + ); + }); + }); }); diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.ts b/code/core/src/csf-tools/CsfObjectDiscovery.ts index 3de32de71339..35c746c87204 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.ts @@ -2,14 +2,38 @@ import type { CsfFile } from './CsfFile.ts'; import { csfFactoryReceiver, isCsfFactoryCall, withoutTypeCalls } from './CsfFile.ts'; import { type CsfMutationDiagnostic, + type CsfMutationDiagnosticCode, type CsfObject, type CsfObjectHost, type CsfObjectOptions, type CsfObjectTarget, + type MemberInsert, + type ObjectRoot, createCsfObject, + detachedMember, literalRoot, } from './CsfObject.ts'; -import { type E, type Node, isStringLiteral, locationOf, unwrapExpression } from './estree/ast.ts'; +import { + type E, + type Node, + type Property, + isFunction, + isIdentifier, + isStringLiteral, + isValidIdentifier, + locationOf, + outerSpan, + staticKey, + unwrapExpression, +} from './estree/ast.ts'; +import { + insertStatementAfter, + printKey, + printMembers, + printString, + removeStatements, +} from './estree/editor.ts'; +import { placeableValueSource } from './estree/values.ts'; import type { Binding } from './estree/scope.ts'; type ReportDiagnostic = (diagnostic: CsfMutationDiagnostic) => void; @@ -21,16 +45,17 @@ type StoryBinding = { declaration: E.VariableDeclarator | E.Function; }; -type AnnotationCandidate = { - target: Extract; - annotation: 'parameters' | 'argTypes'; - root?: E.ObjectExpression; - node: Node; - message?: string; +// The object an editor works on, before it is wrapped into a `CsfObject`. +type Discovered = { target: CsfObjectTarget; root: ObjectRoot }; + +// A top-level `Story.name = value` statement, as a member of the story. +type Assignment = { + statement: E.ExpressionStatement; + expression: E.AssignmentExpression & { left: E.MemberExpression }; + property: Property; }; -// An object literal an editor works on, before it is wrapped into a `CsfObject`. -type Discovered = { target: CsfObjectTarget; prefix: readonly string[]; node: E.ObjectExpression }; +type Rejection = { code: CsfMutationDiagnosticCode; node: Node; path: string[]; message: string }; const loc = (csf: CsfFile, node: Node) => ({ loc: locationOf(csf._code, node.start, node.end) }); @@ -232,7 +257,7 @@ const discoverMeta = (csf: CsfFile, report: ReportDiagnostic): Discovered[] => { }); return []; } - return [{ target: { kind: 'meta' }, prefix: [], node: csf._metaNode }]; + return [{ target: { kind: 'meta' }, root: literalRoot(csf._editor, csf._metaNode) }]; } if (csf._metaIsFactory && csf._metaFactoryCall) { report({ @@ -246,6 +271,212 @@ const discoverMeta = (csf: CsfFile, report: ReportDiagnostic): Discovered[] => { return []; }; +const annotationName = (left: E.MemberExpression) => + !left.computed && left.property.type === 'Identifier' + ? left.property.name + : left.computed && isStringLiteral(left.property) + ? left.property.value + : undefined; + +// Where a reference to a story neither writes to it nor lets other code see its annotations. +const NON_SHARING_PARENTS = new Set([ + 'ExportSpecifier', + 'TSTypeQuery', + 'JSXOpeningElement', + 'JSXClosingElement', +]); + +// The `Story.name = value` statements of a story, found through the references to its binding. +// Any other write to it, such as a nested or a compound one, is a `write` rejection. A reference +// that could share the story, such as an alias or a call that receives it, is a `shared` one: it +// only matters for a story whose assignments are edited, as it could add them to another story. +const assignmentsOf = (csf: CsfFile, binding: StoryBinding) => { + const editor = csf._editor; + const parentOf = (node: Node) => editor.parentOf(node); + const assignments: Assignment[] = []; + let shared: Rejection | undefined; + const references = editor.scopes.program.bindings.get(binding.localName)?.references ?? []; + for (const reference of references) { + // An `export` of the declaration counts as a reference. + if (reference.type !== 'Identifier' && reference.type !== 'JSXIdentifier') { + continue; + } + const parent = parentOf(reference); + if ( + parent && + (NON_SHARING_PARENTS.has(parent.type) || + (parent.type === 'CallExpression' && parent.callee === reference)) + ) { + continue; + } + if (parent?.type !== 'MemberExpression' || parent.object !== reference) { + shared ??= { + code: 'ambiguous-binding', + node: reference, + path: [], + message: `Cannot mutate ${binding.exportName} because ${binding.localName} is shared with other code`, + }; + continue; + } + let outer: Node = parent; + for ( + let next = parentOf(outer); + next?.type === 'MemberExpression' && next.object === outer; + next = parentOf(outer) + ) { + outer = next; + } + const user = parentOf(outer); + const writes = + (user?.type === 'AssignmentExpression' && user.left === outer) || + user?.type === 'UpdateExpression' || + (user?.type === 'UnaryExpression' && user.operator === 'delete'); + if (!writes) { + continue; + } + const statement = parentOf(user); + if ( + outer !== parent || + user.type !== 'AssignmentExpression' || + statement?.type !== 'ExpressionStatement' || + parentOf(statement)?.type !== 'Program' + ) { + const write: Rejection = { + code: 'ambiguous-binding', + node: user, + path: [], + message: `Cannot mutate ${binding.exportName} because ${binding.localName} is written to outside its top-level annotations`, + }; + return { assignments, write }; + } + const name = annotationName(parent); + if (name === undefined) { + const write: Rejection = { + code: 'dynamic-key', + node: parent.property, + path: [], + message: `Cannot mutate ${binding.exportName} because an annotation name is computed at runtime`, + }; + return { assignments, write }; + } + if (user.operator !== '=') { + const write: Rejection = { + code: 'unsupported-initializer', + node: user, + path: [name], + message: `Cannot mutate ${binding.localName}.${name} because it uses ${user.operator} assignment`, + }; + return { assignments, write }; + } + const property = detachedMember(parent.property, user.right, parent.computed, statement); + assignments.push({ statement, expression: user as Assignment['expression'], property }); + } + assignments.sort((a, b) => a.statement.start - b.statement.start); + return { assignments, shared }; +}; + +// A story edited as one object: the members of its object literal, if it has one, then its +// `Story.name = value` assignments, which run after it. A missing member goes into the object +// literal, or else into a new assignment after the story's last one. +const storyRoot = ( + csf: CsfFile, + binding: StoryBinding, + object: E.ObjectExpression | undefined, + assignments: Assignment[] +): ObjectRoot => { + const editor = csf._editor; + const literal = object && literalRoot(editor, object); + const byProperty = new Map(assignments.map((assignment) => [assignment.property, assignment])); + const own = (members: readonly Property[]) => members.filter((member) => !byProperty.has(member)); + const declaration = csf._program.body.find( + (statement) => + statement.start <= binding.declaration.start && binding.declaration.end <= statement.end + )!; + const accessor = (key: string) => + isValidIdentifier(key) ? `.${key}` : `[${printString(key, editor.quote)}]`; + // `removed` are statements the same edits remove, which the new ones cannot follow. + const assign = (members: MemberInsert[], removed: ReadonlySet = new Set()) => { + const after = + assignments.findLast(({ statement }) => !removed.has(statement))?.statement ?? declaration; + for (const { key, value } of members) { + insertStatementAfter(editor, after, `${binding.localName}${accessor(key)} = ${value};`); + } + }; + const statementsOf = (members: readonly Property[]) => + new Set(members.flatMap((member) => byProperty.get(member)?.statement ?? [])); + const remove = (members: Property[]) => { + const inObject = own(members); + if (inObject.length > 0) { + literal!.remove(inObject); + } + const statements = statementsOf(members); + if (statements.size > 0) { + removeStatements(editor, statements); + } + }; + const take = (member: Property, key: string): MemberInsert => { + const assignment = byProperty.get(member); + if (!assignment) { + return literal!.take(member, key); + } + const value = placeableValueSource(editor, member); + return { key, value, text: `${printKey(key, editor.quote)}: ${value}` }; + }; + const append = (members: MemberInsert[]) => (literal ? literal.append(members) : assign(members)); + return { + properties: [...(object?.properties ?? []), ...assignments.map(({ property }) => property)], + // Moving an assignment's value moves when it is evaluated. + detached: assignments.length > 0, + node: object, + append, + prepend: (members) => (literal ? literal.prepend(members) : assign(members)), + remove, + replaceValue: (member, text) => { + const assignment = byProperty.get(member); + if (!assignment) { + literal!.replaceValue(member, text); + return; + } + const { start, end } = outerSpan(assignment.expression.right); + editor.edits.overwrite(start, end, text); + }, + rename: (member, key) => { + const assignment = byProperty.get(member); + if (!assignment) { + literal!.rename(member, key); + return; + } + const { left } = assignment.expression; + editor.edits.overwrite(left.object.end, left.end, accessor(key)); + }, + // `CsfObject` refuses a group of members from both the object and the assignments. + group: (members, key) => { + if (literal && own(members).length === members.length) { + literal.group(members, key); + return; + } + const texts = members.map((member) => take(member, staticKey(member)!).text); + const value = texts.some((text) => text.includes('\n')) + ? printMembers(texts, editor.indent) + : `{ ${texts.join(', ')} }`; + const statements = statementsOf(members); + removeStatements(editor, statements); + assign([{ key, value, text: '' }], statements); + }, + take, + }; +}; + +// A story without an object literal is a function or a bound template, which its assignments +// annotate. Any other value may be shared, so annotating it could change other stories. +const isAnnotatable = (node: Node | undefined) => + !!node && + (isFunction(node) || + (node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + !node.callee.computed && + isIdentifier(node.callee.property, 'bind'))); + const discoverStories = ( csf: CsfFile, bindings: StoryBinding[], @@ -265,7 +496,7 @@ const discoverStories = ( node.arguments.length === 1 && argumentNode?.type === 'ObjectExpression' ) { - return [{ target: storyTarget(binding), prefix: [], node: argumentNode }]; + return [{ target: storyTarget(binding), root: literalRoot(csf._editor, argumentNode) }]; } report({ code: 'unsupported-initializer', @@ -276,116 +507,34 @@ const discoverStories = ( }); return []; } - return node?.type === 'ObjectExpression' - ? [{ target: storyTarget(binding), prefix: [], node }] - : []; - }); - -const annotationCandidates = ( - statement: Node, - bindings: Map -): AnnotationCandidate[] => { - if (statement.type !== 'ExpressionStatement') { - return []; - } - const { expression } = statement; - if (expression.type !== 'AssignmentExpression') { - return []; - } - const { left, right } = expression; - if (left.type !== 'MemberExpression' || left.object.type !== 'Identifier') { - return []; - } - const property = left.property; - const propertyName = - !left.computed && property.type === 'Identifier' - ? property.name - : left.computed && isStringLiteral(property) - ? property.value - : undefined; - const binding = bindings.get(left.object.name); - if (!propertyName && left.computed && binding) { - return [ - { - target: { - kind: 'story-annotation', - exportName: binding.exportName, - localName: binding.localName, - annotation: 'parameters', - }, - annotation: 'parameters', - node: property, - message: `Cannot mutate ${binding.localName} annotation because its computed name is not a static string literal`, - }, - ]; - } - const annotation = - propertyName === 'parameters' || propertyName === 'argTypes' ? propertyName : undefined; - if (!annotation || !binding) { - return []; - } - const rootNode = unwrapExpression(right); - return [ - { - target: { - kind: 'story-annotation', - exportName: binding.exportName, - localName: binding.localName, - annotation, - }, - annotation, - root: - expression.operator === '=' && rootNode.type === 'ObjectExpression' ? rootNode : undefined, - node: right, - ...(expression.operator === '=' - ? {} - : { - message: `Cannot mutate ${binding.localName}.${annotation} because it uses ${expression.operator} assignment`, - }), - }, - ]; -}; - -const discoverAnnotations = ( - csf: CsfFile, - storyBindings: StoryBinding[], - report: ReportDiagnostic -): Discovered[] => { - const bindings = new Map(storyBindings.map((binding) => [binding.localName, binding])); - const candidates = new Map(); - for (const statement of csf._program.body) { - for (const candidate of annotationCandidates(statement, bindings)) { - const identity = `${candidate.target.localName}:${candidate.annotation}`; - candidates.set(identity, [...(candidates.get(identity) ?? []), candidate]); - } - } - return [...candidates.values()].flatMap((matches): Discovered[] => { - const [candidate] = matches; - if (matches.length > 1) { - report({ - code: 'ambiguous-binding', - target: candidate.target, - path: [candidate.annotation], - message: `Cannot mutate repeated ${candidate.target.localName}.${candidate.annotation} assignments`, - ...loc(csf, candidate.node), - }); + const object = node?.type === 'ObjectExpression' ? node : undefined; + const { assignments, write, shared } = assignmentsOf(csf, binding); + const reject = ({ code, node: at, path, message }: Rejection): Discovered[] => { + report({ code, target: storyTarget(binding), path, message, ...loc(csf, at) }); return []; + }; + if (write) { + return reject(write); } - if (!candidate.root) { - report({ + if (assignments.length === 0 && (object || !isAnnotatable(node))) { + return object + ? [{ target: storyTarget(binding), root: literalRoot(csf._editor, object) }] + : []; + } + if (shared) { + return reject(shared); + } + if (!object && !isAnnotatable(node)) { + const name = annotationName(assignments[0].expression.left)!; + return reject({ code: 'unsupported-initializer', - target: candidate.target, - path: [candidate.annotation], - message: - candidate.message ?? - `Cannot mutate ${candidate.target.localName}.${candidate.annotation} because its value is not an object literal`, - ...loc(csf, candidate.node), + node: assignments[0].property.value, + path: [name], + message: `Cannot mutate ${binding.localName}.${name} because ${binding.localName} is not a story object, function or bound template`, }); - return []; } - return [{ target: candidate.target, prefix: [candidate.annotation], node: candidate.root }]; + return [{ target: storyTarget(binding), root: storyRoot(csf, binding, object, assignments) }]; }); -}; const discover = ( csf: CsfFile, @@ -398,7 +547,6 @@ const discover = ( return [ ...((options.meta ?? true) ? discoverMeta(csf, report) : []), ...(includeStories ? discoverStories(csf, bindings, report) : []), - ...(includeStories ? discoverAnnotations(csf, bindings, report) : []), ]; }; @@ -411,16 +559,13 @@ export const discoverCsfObjects = ( report: ReportDiagnostic, markChanged: MarkChanged ): readonly CsfObject[] => - discover(csf, options, report).map(({ target, prefix }) => { + discover(csf, options, report).map(({ target }) => { const host: CsfObjectHost = { editor: csf._editor, - root: () => { + root: () => // Edits re-parse the file, so the object is found again by its target. - const current = discover(csf, options, () => {}).find((candidate) => - sameTarget(candidate.target, target) - ); - return current && literalRoot(csf._editor, current.node); - }, + discover(csf, options, () => {}).find((candidate) => sameTarget(candidate.target, target)) + ?.root, }; - return createCsfObject(target, host, prefix, report, markChanged); + return createCsfObject(target, host, report, markChanged); }); diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index 98350476f6f8..a9de60d0c49b 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -13,8 +13,8 @@ It can parse MDX into CSF. ## Transforming stories -`CsfFile.objects()` returns one `CsfObject` editor per mutable object it can prove safe to edit: the meta, each story export, and each CSF2 `Story.parameters` / `Story.argTypes` annotation assignment. -`{ meta, stories }` narrows what is discovered; both are included by default. Story discovery also includes separate CSF2 parameter assignments, so callers use the same paths regardless of the source format. +`CsfFile.objects()` returns one `CsfObject` editor per mutable object it can prove safe to edit: the meta and each story export. +`{ meta, stories }` narrows what is discovered; both are included by default. Each editor reads and writes static property paths with `get`, `getValue`, `inspect`, `set`, `transform`, `remove`, `rename`, `move`, and `group`. `get` returns an AST expression; `getValue` reads plain values from literals, nested arrays and objects, and local constants without executing code. Reads never return a partially decoded object. @@ -40,11 +40,12 @@ Successful removals and moves recursively remove empty source parents, stopping Nested object paths can follow local constants used only by that object; shared or reassigned references remain untouched. Nodes reused by `transform` keep their original source, so relocating a value prints it as written instead of pretty-printing it. `group(path, names)` nests named siblings under a destination object, retaining their source order. A new group replaces a contiguous run of source fields at their original position. Grouping into an existing object requires values whose relocation cannot change expression evaluation order. Conflicts, ambiguous keys, and unsafe relocations produce diagnostics before the operation changes the source; absent source fields are ignored. -`node` is the editor's object literal in the current parse: the meta or story object, a call argument, a config object, or for a CSF2 annotation editor the assigned object that its annotation path (such as `['parameters']`) names. It is the file's own node, found again after every mutation: read it after editing instead of keeping it, and do not change it, since edits go through the source. It is `undefined` for a config made of named exports and once the object is gone. -Annotation editors take the same paths as their story counterparts, so `['parameters', 'a11y']` addresses `Story.parameters.a11y` and an inline `parameters.a11y` alike. +`node` is the editor's object literal in the current parse: the meta or story object, a call argument, or a config object. It is the file's own node, found again after every mutation: read it after editing instead of keeping it, and do not change it, since edits go through the source. It is `undefined` for a config made of named exports, for a story without an object literal, and once the object is gone. +A story's top-level `Story.name = value` assignments (CSF2 annotations such as `args`, `argTypes`, `parameters`, `decorators`, `loaders`, `beforeEach`, `play`, `render` and `tags`) are fields of the story, after the fields of its object literal if it has one. The same paths address both forms, so `['parameters', 'a11y']` is `Story.parameters.a11y` and an inline `parameters.a11y` alike. A story without an object literal, a function or a bound template, adds a missing field as a new `Story.name = value;` statement after its last annotation, or after its declaration; removing a field, or emptying it through cleanup, removes its statement. A field written both inline and as an assignment is a duplicate field. Discovery and mutation are conservative: an object or a path whose shape cannot be proven is left untouched, and the reason is reported on `CsfFile.mutationDiagnostics` as a `CsfMutationDiagnostic`. -Unproven shapes include story or meta bindings that are reassigned or aliased, a CSF factory configuration that is not an object literal owned by that single factory call, dynamically computed annotation names, and target paths shadowed by a spread, a duplicate field, or a computed key. +Unproven shapes include story or meta bindings that are reassigned or aliased, a CSF factory configuration that is not an object literal owned by that single factory call, and target paths shadowed by a spread, a duplicate field, or a computed key. +A story is also left untouched when it is written to other than by a top-level `Story.name = value`, such as `Story.args.label = 'x'`, `Story.parameters ||= {}` or `Story[name] = {}`. A story whose assignments are edited is left untouched when other code could see them: an alias, a spread, or a call or array that receives the story; calling or rendering the story is fine. Such a story without an object literal holds a function or a bound template; any other value could be shared with other stories. `set` can still add a field missing from an object with a spread or a computed key: the field is appended after them, so it overrides any value they provide. It cannot create a missing parent there, nor change a field that a later spread or computed key could override. `CsfFile.changed` is true once any mutation has been applied, which is the signal for whether the file needs to be written back. From 1351cbd757f5a633d945a6f471ab2bafd1f543a3 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Tue, 20 Oct 2026 12:04:30 +0200 Subject: [PATCH 272/326] Core: Announce pnpm build approvals and keep the workspace reader plain Log an info line whenever Storybook records an approval in pnpm-workspace.yaml, drop the createIfMissing mode from the reader in favor of a private path lookup, and rename the helper module to pnpm-build-approvals. Co-Authored-By: Claude Fable 5.1 --- .../PNPMProxy.catalog.test.ts | 2 +- .../js-package-manager/PNPMProxy.test.ts | 4 +++ .../common/js-package-manager/PNPMProxy.ts | 30 ++++++++++--------- ...s.test.ts => pnpm-build-approvals.test.ts} | 2 +- ...ild-scripts.ts => pnpm-build-approvals.ts} | 0 5 files changed, 22 insertions(+), 16 deletions(-) rename code/core/src/common/js-package-manager/{pnpm-build-scripts.test.ts => pnpm-build-approvals.test.ts} (99%) rename code/core/src/common/js-package-manager/{pnpm-build-scripts.ts => pnpm-build-approvals.ts} (100%) diff --git a/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts index 34deddbafd01..0e4e42bc42a3 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.catalog.test.ts @@ -285,7 +285,7 @@ describe('PNPMProxy catalogs', () => { }); }); - describe('allowStorybookBuildScripts (workspace reader with createIfMissing)', () => { + describe('allowStorybookBuildScripts', () => { const allowBuildScripts = () => (pnpmProxy as any).allowStorybookBuildScripts(); // The package.json being installed lives below the workspace root, on memfs. const PROJECT_DIR = '/root/packages/app'; diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index 5f0f78691f5d..b006f87c8d96 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -170,6 +170,9 @@ describe('PNPM Proxy', () => { await install(); expect(workspaceYaml()).toBe('allowBuilds:\n esbuild: true\n'); + expect(logger.info).toHaveBeenCalledWith( + 'Approved build scripts for esbuild in /project/pnpm-workspace.yaml' + ); expect(mockedExecuteCommand).toHaveBeenCalledWith( expect.objectContaining({ command: 'pnpm', args: ['install'] }) ); @@ -203,6 +206,7 @@ describe('PNPM Proxy', () => { expect(workspaceYaml()).toBe('allowBuilds:\n esbuild: false\n'); expect(writeFileSync).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); }); it('resolves the placeholder pnpm 12 writes for unreviewed packages', async () => { diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index af218f1f00a3..1e10caa98569 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -19,7 +19,7 @@ import { executeCommand, executeCommandSync } from '../utils/command.ts'; import { getProjectRoot } from '../utils/paths.ts'; import { JsPackageManager, PackageManagerName } from './JsPackageManager.ts'; import type { PackageJson } from './PackageJson.ts'; -import { hasAllowBuildsDecision, hasPnpm10ListDecision } from './pnpm-build-scripts.ts'; +import { hasAllowBuildsDecision, hasPnpm10ListDecision } from './pnpm-build-approvals.ts'; import type { InstallationMetadata, PackageMetadata } from './types.ts'; import { getAgeInMinutes, @@ -325,7 +325,12 @@ export class PNPMProxy extends JsPackageManager { if (!this.#pnpmGte(PNPM_ALLOW_BUILDS_MIN)) { return; } - const workspace = this.#readWorkspaceYaml({ createIfMissing: true }); + const workspace = this.#findWorkspaceYamlPath() + ? this.#readWorkspaceYaml() + : { + path: join(this.primaryPackageJson.operationDir, 'pnpm-workspace.yaml'), + doc: new Document(), + }; if (!workspace) { logger.warn( `Could not allow build scripts of ${STORYBOOK_BUILD_SCRIPT_PACKAGES.join(', ')}: pnpm-workspace.yaml is unreadable` @@ -361,6 +366,7 @@ export class PNPMProxy extends JsPackageManager { doc.setIn(['allowBuilds', pkg], true); } this.#writeWorkspaceYaml({ path, doc }); + logger.info(`Approved build scripts for ${pending.join(', ')} in ${path}`); } catch (e) { logger.warn( `Could not allow build scripts of ${STORYBOOK_BUILD_SCRIPT_PACKAGES.join(', ')} in ${path}: ${String(e)}` @@ -373,22 +379,18 @@ export class PNPMProxy extends JsPackageManager { writeFileSync(path, doc.toString(), 'utf8'); } - // Null when the file is malformed, or missing without `createIfMissing`; with it, a missing file - // yields an empty document addressed next to the package.json we operate on. - #readWorkspaceYaml(options?: { - createIfMissing: boolean; - }): { path: string; doc: Document } | null { - const path = find.up('pnpm-workspace.yaml', { + #findWorkspaceYamlPath(): string | undefined { + return find.up('pnpm-workspace.yaml', { cwd: this.primaryPackageJson.operationDir, last: getProjectRoot(), }); + } + + // Null when the file is missing or malformed. + #readWorkspaceYaml(): { path: string; doc: Document } | null { + const path = this.#findWorkspaceYamlPath(); if (!path) { - return options?.createIfMissing - ? { - path: join(this.primaryPackageJson.operationDir, 'pnpm-workspace.yaml'), - doc: new Document(), - } - : null; + return null; } try { const doc = parseDocument(readFileSync(path, 'utf8')); diff --git a/code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts b/code/core/src/common/js-package-manager/pnpm-build-approvals.test.ts similarity index 99% rename from code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts rename to code/core/src/common/js-package-manager/pnpm-build-approvals.test.ts index e0e25cfa8940..4a721abe7cc1 100644 --- a/code/core/src/common/js-package-manager/pnpm-build-scripts.test.ts +++ b/code/core/src/common/js-package-manager/pnpm-build-approvals.test.ts @@ -10,7 +10,7 @@ import { listsPackage, matchesPackage, readOnlyBuiltDependenciesFile, -} from './pnpm-build-scripts.ts'; +} from './pnpm-build-approvals.ts'; vi.mock('node:fs', { spy: true }); diff --git a/code/core/src/common/js-package-manager/pnpm-build-scripts.ts b/code/core/src/common/js-package-manager/pnpm-build-approvals.ts similarity index 100% rename from code/core/src/common/js-package-manager/pnpm-build-scripts.ts rename to code/core/src/common/js-package-manager/pnpm-build-approvals.ts From e65ab90fcb0309b8379c603df9e70df0e530afab Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Tue, 20 Oct 2026 12:37:39 +0200 Subject: [PATCH 273/326] test: the test cases for Svelte CSF factories in the codemod and the sandboxes Co-Authored-By: Claude Opus 5.5 --- .../src/plugins/svelte-csf.test.ts | 16 +- .../helpers/story-to-csf-factory.test.ts | 96 ++++- .../helpers/svelte-csf-to-csf-factory.test.ts | 347 ++++++++++++++++++ .../codemod/component.svelte | 19 + .../codemod/renamed.svelte | 9 + .../codemod/render.svelte | 11 + .../generic-component.stories.svelte | 16 + .../svelte-csf-factories.test.ts | 9 +- .../svelte-csf-factories/type-checks.svelte | 22 ++ .../template/stories/csf-factories/csf4.mdx | 20 + .../stories/csf-factories/csf4.stories.ts | 17 + .../csf-factories/svelte-csf.stories.svelte | 33 ++ 12 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 code/lib/cli-storybook/src/codemod/helpers/svelte-csf-to-csf-factory.test.ts create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/codemod/component.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/codemod/renamed.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/codemod/render.svelte create mode 100644 code/renderers/svelte/src/__test__/svelte-csf-factories/generic-component.stories.svelte create mode 100644 code/renderers/svelte/template/stories/csf-factories/csf4.mdx create mode 100644 code/renderers/svelte/template/stories/csf-factories/csf4.stories.ts create mode 100644 code/renderers/svelte/template/stories/csf-factories/svelte-csf.stories.svelte diff --git a/code/frameworks/svelte-vite/src/plugins/svelte-csf.test.ts b/code/frameworks/svelte-vite/src/plugins/svelte-csf.test.ts index 25f94aa79136..30cfd40dca28 100644 --- a/code/frameworks/svelte-vite/src/plugins/svelte-csf.test.ts +++ b/code/frameworks/svelte-vite/src/plugins/svelte-csf.test.ts @@ -27,14 +27,24 @@ describe(svelteCsf.name, () => { expect(handler()).toBe(svelteCsfRuntimeStoriesPath); }); - it('only transforms stories files', () => { + it('only transforms stories files and their styles', () => { const { filter } = svelteCsf().transform as Handler; expect(filter.id.test('/project/src/Button.stories.svelte')).toBe(true); - expect(filter.id.test('/project/src/Button.svelte')).toBe(false); expect(filter.id.test('/project/src/Button.stories.svelte?svelte&type=style&lang.css')).toBe( - false + true ); + expect(filter.id.test('/project/src/Button.svelte')).toBe(false); + expect(filter.id.test('/project/src/Button.svelte?svelte&type=style&lang.css')).toBe(false); + }); + + it('keeps the styles of a stories file while the stories file is used, not only its default export', () => { + const { handler } = svelteCsf().transform as Handler<(code: string, id: string) => unknown>; + + expect(handler('', '/project/src/Button.stories.svelte?svelte&type=style&lang.css')).toEqual({ + code: '', + meta: { vite: { cssScopeTo: ['/project/src/Button.stories.svelte', undefined] } }, + }); }); it('passes the preprocessors that vite-plugin-svelte resolved, including its inline options', async () => { diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index e94487e6226f..e952d2418db5 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from 'vitest'; import { formatFileContent } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; -import path from 'path'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path, { join } from 'path'; import { dedent } from 'ts-dedent'; import { storyToCsfFactory } from './story-to-csf-factory.ts'; @@ -293,6 +295,38 @@ describe('stories codemod', () => { `); }); + it('keeps the exports of an imported Svelte CSF file that are not stories', async () => { + const dir = mkdtempSync(join(tmpdir(), 'svelte-csf-')); + writeFileSync( + join(dir, 'Task.stories.svelte'), + dedent` + + ` + ); + + await expect( + storyToCsfFactory( + { + path: join(dir, 'TaskList.stories.ts'), + source: dedent` + import * as TaskStories from './Task.stories.svelte'; + + export default { title: 'TaskList' }; + + export const Default = { + args: { ...TaskStories.Default.args, task: TaskStories.TaskData.task }, + }; + `, + }, + { useSubPathImports: true } + ) + ).resolves.toContain( + 'args: { ...TaskStories.Default.input.args, task: TaskStories.TaskData.task }' + ); + }); + it('migrate cross-file story imports from `ImportedStories.Story.xyz` to `ImportedStories.Story.input.xyz`', async () => { await expect( transform(dedent` @@ -1157,6 +1191,66 @@ describe('stories codemod', () => { }); }); + it('migrates story imports from Svelte CSF files', async () => { + await expect( + transform(dedent` + import * as HeaderStories from './Header.stories.svelte'; + + export default { title: 'Page' }; + + export const LoggedIn = { args: HeaderStories.LoggedIn.args }; + `) + ).resolves.toMatchInlineSnapshot(` + import preview from "#.storybook/preview"; + import * as HeaderStories from "./Header.stories.svelte"; + + const meta = preview.meta({ + title: "Page", + }); + + export const LoggedIn = meta.story({ args: HeaderStories.LoggedIn.input.args }); + `); + }); + + it('migrates a Svelte stories file', async () => { + await expect( + transform(dedent` + import type { Meta, StoryObj } from '@storybook/svelte'; + import { fn } from 'storybook/test'; + + import Button from './Button.svelte'; + + const meta = { + title: 'Example/Button', + component: Button, + args: { onclick: fn() }, + } satisfies Meta; + + export default meta; + type Story = StoryObj; + + export const Primary: Story = { + args: { primary: true, label: 'Button' }, + }; + `) + ).resolves.toMatchInlineSnapshot(` + import preview from "#.storybook/preview"; + import { fn } from "storybook/test"; + + import Button from "./Button.svelte"; + + const meta = preview.meta({ + title: "Example/Button", + component: Button, + args: { onclick: fn() }, + }); + + export const Primary = meta.story({ + args: { primary: true, label: "Button" }, + }); + `); + }); + it('should remove unused Story types', async () => { await expect( transform( diff --git a/code/lib/cli-storybook/src/codemod/helpers/svelte-csf-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/svelte-csf-to-csf-factory.test.ts new file mode 100644 index 000000000000..6cb4ef710536 --- /dev/null +++ b/code/lib/cli-storybook/src/codemod/helpers/svelte-csf-to-csf-factory.test.ts @@ -0,0 +1,347 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { dedent } from 'ts-dedent'; + +import { svelteCsfToCsfFactory } from './svelte-csf-to-csf-factory.ts'; + +// The Svelte renderer type-checks the files written there with `svelte-check` +const TYPE_FIXTURES_DIR = join( + import.meta.dirname, + '../../../../../renderers/svelte/src/__test__/svelte-csf-factories/codemod' +); + +const transform = (source: string, path = '/project/src/stories/Button.stories.svelte') => + svelteCsfToCsfFactory({ source, path }, { useSubPathImports: true }); + +const transformRelative = (source: string, path = '/project/src/stories/Button.stories.svelte') => + svelteCsfToCsfFactory( + { source, path }, + { useSubPathImports: false, previewConfigPath: '/project/.storybook/preview.ts' } + ); + +const transformTypeFixture = (source: string, name: string) => + svelteCsfToCsfFactory( + { source: `${source}\n`, path: join(TYPE_FIXTURES_DIR, name) }, + { useSubPathImports: false, previewConfigPath: join(TYPE_FIXTURES_DIR, '../preview.ts') } + ); + +describe('svelteCsfToCsfFactory', () => { + it('changes defineMeta() to preview.meta() with a subpath import', () => { + expect( + transform(dedent` + + + + `) + ).toMatchInlineSnapshot(` + " + + " + `); + }); + + it('imports the preview with a relative path', () => { + expect( + transformRelative(dedent` + + `) + ).toMatchInlineSnapshot(` + "" + `); + }); + + it('keeps the other imports from the Svelte CSF module', () => { + expect( + transform(dedent` + + `) + ).toMatchInlineSnapshot(` + "" + `); + }); + + it('finds defineMeta after a type import from the same module', () => { + expect( + transform(dedent` + + `) + ).toMatchInlineSnapshot(` + "" + `); + }); + + it('reads the annotations of stories from other stories files from their input', () => { + expect( + transform(dedent` + + + + + + `) + ).toMatchInlineSnapshot(` + " + + + + " + `); + }); + + it('keeps the exports of an imported Svelte CSF file that are not stories', () => { + const dir = mkdtempSync(join(tmpdir(), 'svelte-csf-')); + writeFileSync( + join(dir, 'Task.stories.svelte'), + dedent` + + + + ` + ); + + expect( + transform( + dedent` + + + + `, + join(dir, 'TaskList.stories.svelte') + ) + ).toMatchInlineSnapshot(` + " + + " + `); + }); + + it('reads the story imports of every module script', () => { + expect( + transform(dedent` + + + `) + ).toMatchInlineSnapshot(` + " + " + `); + }); + + it('names the import storybookPreview when the file declares preview', () => { + expect( + transform(dedent` + + `) + ).toMatchInlineSnapshot(` + "" + `); + }); + + it('changes a + `) + ).toMatchInlineSnapshot(` + "" + `); + }); + + it.for([ + [ + 'a file that already calls preview.meta()', + dedent` + + `, + ], + [ + 'a defineMeta() call in the instance script', + dedent` + + `, + ], + [ + 'a defineMeta() import from another module', + dedent` + + `, + ], + ])('does not change %s', ([, source]) => { + expect(transform(source)).toBe(source); + }); + + describe('writes code that type-checks', () => { + it('for a meta with a component', async () => { + await expect( + transformTypeFixture( + dedent` + + + + + + {#snippet template(args)} + \n
\n \n \n `,\n };\n};\n\nconst localeDecorator: DecoratorFunction = (story, { globals }) => {\n return {\n components: { story },\n setup() {\n const ctxGreeting = computed(() => getCaptionForLocale(globals?.locale) || 'Hello!');\n\n return {\n ctxGreeting,\n globals,\n };\n },\n template: `\n
\n

Greeting: {{ctxGreeting}}

\n

Locale: {{globals?.locale}}

\n \n
\n `,\n };\n};\n\nexport const ComponentTemplate: Story = {\n args: { label: 'With component' },\n decorators: [ComponentTemplateWrapper],\n};\n\nexport const SimpleTemplate: Story = {\n args: { label: 'With border' },\n decorators: [SimpleTemplateWrapper],\n};\n\nexport const VueWrapper: Story = {\n args: { label: 'With Vue wrapper' },\n decorators: [VueWrapperWrapper],\n};\n\nexport const DynamicWrapper: Story = {\n args: { label: 'With dynamic wrapper', primary: true },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [DynamicWrapperWrapper],\n};\n\nexport const MultipleWrappers = {\n args: { label: 'With multiple wrappers' },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [\n ComponentTemplateWrapper,\n SimpleTemplateWrapper,\n VueWrapperWrapper,\n DynamicWrapperWrapper,\n ],\n};\n\nexport const UpdateArgs = {\n args: { label: '0' },\n decorators: [updateArgsDecorator],\n};\n\nexport const ReactiveGlobalDecorator = {\n args: { label: 'With reactive global decorator' },\n decorators: [localeDecorator],\n};\n", + "message": "No component file found for the component declared in meta.component.\n/src/stories/renderers/vue3_vue3-vite-default-ts/decorators.stories.ts: (12:0)\n> 12 | const meta = {\n | ^\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/decorators.stories.ts:\nimport type { DecoratorFunction } from 'storybook/internal/types';\n\nimport { global as globalThis } from '@storybook/global';\nimport type { Meta, StoryObj, VueRenderer } from '@storybook/vue3';\n\nimport { useArgs } from 'storybook/preview-api';\nimport { h } from 'vue';\nimport { computed } from 'vue';\n\nconst { Button, Pre } = (globalThis as any).__TEMPLATE_COMPONENTS__;\n\nconst meta = {\n component: Button,\n} satisfies Meta;\n\nexport default meta;\n\ntype Story = StoryObj;\n\nconst ComponentTemplateWrapper = () => ({\n components: {\n Pre,\n },\n template: `\n
\n    \n  `,\n});\n\nconst SimpleTemplateWrapper = () => ({\n  template: `\n    
\n \n
\n `,\n});\n\nconst VueWrapperWrapper: DecoratorFunction = (storyFn, context) => {\n // Call the `storyFn` to receive a component that Vue can render\n const story = storyFn();\n // Vue 3 \"Functional\" component as decorator\n return () => {\n return h('div', { style: 'border: 5px solid blue' }, h(story, context.args));\n };\n};\n\nconst DynamicWrapperWrapper: DecoratorFunction = (storyFn, { args }) => ({\n template: `
`,\n computed: { level: () => `${args.level}px` },\n});\n\nconst getCaptionForLocale = (locale: string) => {\n switch (locale) {\n case 'es':\n return 'Hola!';\n case 'kr':\n return '안녕하세요!';\n case 'zh':\n return '你好!';\n case 'en':\n return 'Hello!';\n default:\n return undefined;\n }\n};\n\nconst updateArgsDecorator: DecoratorFunction = (story, { args }) => {\n const [, updateArgs] = useArgs();\n return {\n components: { story },\n setup() {\n return {\n args,\n updateArgs,\n };\n },\n template: `\n
\n \n
\n \n
\n `,\n };\n};\n\nconst localeDecorator: DecoratorFunction = (story, { globals }) => {\n return {\n components: { story },\n setup() {\n const ctxGreeting = computed(() => getCaptionForLocale(globals?.locale) || 'Hello!');\n\n return {\n ctxGreeting,\n globals,\n };\n },\n template: `\n
\n

Greeting: {{ctxGreeting}}

\n

Locale: {{globals?.locale}}

\n \n
\n `,\n };\n};\n\nexport const ComponentTemplate: Story = {\n args: { label: 'With component' },\n decorators: [ComponentTemplateWrapper],\n};\n\nexport const SimpleTemplate: Story = {\n args: { label: 'With border' },\n decorators: [SimpleTemplateWrapper],\n};\n\nexport const VueWrapper: Story = {\n args: { label: 'With Vue wrapper' },\n decorators: [VueWrapperWrapper],\n};\n\nexport const DynamicWrapper: Story = {\n args: { label: 'With dynamic wrapper', primary: true },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [DynamicWrapperWrapper],\n};\n\nexport const MultipleWrappers = {\n args: { label: 'With multiple wrappers' },\n argTypes: {\n // Number type is detected, but we still want to constrain the range from 1-6\n level: { control: { type: 'range', min: 1, max: 6 } },\n },\n decorators: [\n ComponentTemplateWrapper,\n SimpleTemplateWrapper,\n VueWrapperWrapper,\n DynamicWrapperWrapper,\n ],\n};\n\nexport const UpdateArgs = {\n args: { label: '0' },\n decorators: [updateArgsDecorator],\n};\n\nexport const ReactiveGlobalDecorator = {\n args: { label: 'With reactive global decorator' },\n decorators: [localeDecorator],\n};\n", "name": "No component import found" }, "id": "stories-renderers-vue3-vue3-vite-default-ts-decorators", diff --git a/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json b/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json index 8b124278c677..8082b740c60a 100644 --- a/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json +++ b/code/lib/docgen-harness/src/sandbox-baselines/__baselines__/vue3-vite-default-ts/stories-renderers-vue3-vue3-vite-default-ts-mount-in-play.json @@ -1,6 +1,6 @@ { "error": { - "message": "No component file found for the component declared in meta.component.\n(10:0)\n> 10 | export default {\n | ^\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/mount-in-play.stories.ts:\nimport type { StoryObj } from '@storybook/vue3';\n\nimport { defineComponent } from 'vue';\n\nconst Button = defineComponent({\n template: '',\n props: ['disabled', 'label'],\n});\n\nexport default {\n component: Button,\n};\n\nexport const Basic: StoryObj = {\n args: {\n disabled: true,\n },\n async play({ mount, args }) {\n await mount(Button, { props: { ...args, label: 'set in play' } });\n },\n};\n", + "message": "No component file found for the component declared in meta.component.\n/src/stories/renderers/vue3_vue3-vite-default-ts/mount-in-play.stories.ts: (10:0)\n> 10 | export default {\n | ^\n\n./src/stories/renderers/vue3_vue3-vite-default-ts/mount-in-play.stories.ts:\nimport type { StoryObj } from '@storybook/vue3';\n\nimport { defineComponent } from 'vue';\n\nconst Button = defineComponent({\n template: '',\n props: ['disabled', 'label'],\n});\n\nexport default {\n component: Button,\n};\n\nexport const Basic: StoryObj = {\n args: {\n disabled: true,\n },\n async play({ mount, args }) {\n await mount(Button, { props: { ...args, label: 'set in play' } });\n },\n};\n", "name": "No component import found" }, "id": "stories-renderers-vue3-vue3-vite-default-ts-mount-in-play", From 60fb08d2e4610ff287ad7663a551ee698d0b2787 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:05:55 +0200 Subject: [PATCH 280/326] ci: skip the rsbuild and solid sandboxes until their Storybook 11 releases The published storybook-builder-rsbuild imports `babelParser` from `storybook/internal/mocking-utils`, which Storybook 11 removed, so the four rsbuild sandboxes cannot be created. They leave the CI cadences until https://github.com/rstackjs/storybook-rsbuild/pull/603 is released. The published storybook-solidjs-vite imports `storybook/internal/babel`, which Storybook 11 removed, so its built Storybook renders no stories. Creating and building the sandbox still pass, so only its chromatic job is skipped until https://github.com/solidjs-community/storybook/pull/76 is released. The generated NX sandbox projects are regenerated to drop the rsbuild CI tags. Co-Authored-By: Claude Opus 5.5 --- .../cli-storybook/src/sandbox-templates.ts | 28 +++++++++++++++---- .../html-rsbuild-default-ts/project.json | 2 +- .../lit-rsbuild-default-ts/project.json | 2 +- .../react-rsbuild-default-ts/project.json | 2 +- .../vue3-rsbuild-default-ts/project.json | 2 +- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 3cfb6385615f..dd28a803cf96 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -428,7 +428,13 @@ export const baseTemplates = { renderer: 'storybook-solidjs-vite', builder: '@storybook/builder-vite', }, - skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench', 'vitest-integration'], + // `chromatic` is skipped: the published storybook-solidjs-vite imports + // `storybook/internal/babel`, which Storybook 11 removed, so its preset fails to load and + // the built Storybook renders no stories ("Expected your framework's preset to export a + // `renderToCanvas` field"). Creating and building the sandbox still pass. + // Fix: https://github.com/solidjs-community/storybook/pull/76 + // Re-enable once storybook-solidjs-vite releases a version that works with Storybook 11. + skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench', 'vitest-integration', 'chromatic'], }, 'tanstack-react-router/default-ts': { name: 'TanStack React Router Latest (Vite | TypeScript)', @@ -1065,7 +1071,11 @@ export const normal: TemplateKey[] = [ 'bench/react-vite-default-ts-test-build', 'bench/react-webpack-18-ts-test-build', // 'ember/default-js', - 'react-rsbuild/default-ts', + // Disabled in CI: the published storybook-builder-rsbuild imports `babelParser` from + // `storybook/internal/mocking-utils`, which Storybook 11 removed, so the sandbox cannot be + // created. Fix: https://github.com/rstackjs/storybook-rsbuild/pull/603 + // Re-enable once storybook-builder-rsbuild releases a version that works with Storybook 11. + // 'react-rsbuild/default-ts', 'tanstack-react-router/default-ts', 'tanstack-react-start/default-ts', ]; @@ -1078,7 +1088,11 @@ export const merged: TemplateKey[] = [ 'preact-vite/default-ts', 'html-vite/default-ts', 'solid-vite/default-ts', - 'vue3-rsbuild/default-ts', + // Disabled in CI: the published storybook-builder-rsbuild imports `babelParser` from + // `storybook/internal/mocking-utils`, which Storybook 11 removed, so the sandbox cannot be + // created. Fix: https://github.com/rstackjs/storybook-rsbuild/pull/603 + // Re-enable once storybook-builder-rsbuild releases a version that works with Storybook 11. + // 'vue3-rsbuild/default-ts', ]; export const daily: TemplateKey[] = [ @@ -1097,8 +1111,12 @@ export const daily: TemplateKey[] = [ 'html-vite/default-js', 'internal/react18-webpack-babel', 'react-native-web-vite/expo-ts', - 'lit-rsbuild/default-ts', - 'html-rsbuild/default-ts', + // Disabled in CI: the published storybook-builder-rsbuild imports `babelParser` from + // `storybook/internal/mocking-utils`, which Storybook 11 removed, so the sandbox cannot be + // created. Fix: https://github.com/rstackjs/storybook-rsbuild/pull/603 + // Re-enable once storybook-builder-rsbuild releases a version that works with Storybook 11. + // 'lit-rsbuild/default-ts', + // 'html-rsbuild/default-ts', // 'react-native-web-vite/rn-cli-ts', ]; diff --git a/code/sandbox/html-rsbuild-default-ts/project.json b/code/sandbox/html-rsbuild-default-ts/project.json index 1bfb15b53405..da752a3dc657 100644 --- a/code/sandbox/html-rsbuild-default-ts/project.json +++ b/code/sandbox/html-rsbuild-default-ts/project.json @@ -25,5 +25,5 @@ "chromatic": {}, "serve": {} }, - "tags": ["ci:daily"] + "tags": [] } diff --git a/code/sandbox/lit-rsbuild-default-ts/project.json b/code/sandbox/lit-rsbuild-default-ts/project.json index 938f59915da5..a82177865a87 100644 --- a/code/sandbox/lit-rsbuild-default-ts/project.json +++ b/code/sandbox/lit-rsbuild-default-ts/project.json @@ -25,5 +25,5 @@ "chromatic": {}, "serve": {} }, - "tags": ["ci:daily"] + "tags": [] } diff --git a/code/sandbox/react-rsbuild-default-ts/project.json b/code/sandbox/react-rsbuild-default-ts/project.json index 7416fea10b04..57cfc17758bf 100644 --- a/code/sandbox/react-rsbuild-default-ts/project.json +++ b/code/sandbox/react-rsbuild-default-ts/project.json @@ -25,5 +25,5 @@ "chromatic": {}, "serve": {} }, - "tags": ["ci:normal", "ci:merged", "ci:daily"] + "tags": [] } diff --git a/code/sandbox/vue3-rsbuild-default-ts/project.json b/code/sandbox/vue3-rsbuild-default-ts/project.json index 980f4e6bf4ca..1a4f0c69dc22 100644 --- a/code/sandbox/vue3-rsbuild-default-ts/project.json +++ b/code/sandbox/vue3-rsbuild-default-ts/project.json @@ -25,5 +25,5 @@ "chromatic": {}, "serve": {} }, - "tags": ["ci:merged", "ci:daily"] + "tags": [] } From 7d8cb61338f58108a9a76647cfe36fde1c00b3a0 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 20 Oct 2026 13:39:22 +0200 Subject: [PATCH 281/326] fix(core): preapprove chromatic alongside @chromatic-com/storybook @chromatic-com/storybook is exempt from the release-age gate, so Yarn picks its newest release, but its chromatic dependency is not. When that release raises the chromatic range past every version older than the gate (5.6.0 requires ^18.12.1), the install fails with YN0016. --- code/core/src/common/js-package-manager/BUNProxy.test.ts | 3 ++- code/core/src/common/js-package-manager/BUNProxy.ts | 2 +- code/core/src/common/js-package-manager/PNPMProxy.test.ts | 2 ++ code/core/src/common/js-package-manager/PNPMProxy.ts | 2 +- code/core/src/common/js-package-manager/Yarn2Proxy.test.ts | 4 +++- code/core/src/common/js-package-manager/Yarn2Proxy.ts | 2 +- code/core/src/common/js-package-manager/util.test.ts | 4 +++- code/core/src/common/js-package-manager/util.ts | 1 + 8 files changed, 14 insertions(+), 6 deletions(-) diff --git a/code/core/src/common/js-package-manager/BUNProxy.test.ts b/code/core/src/common/js-package-manager/BUNProxy.test.ts index a5c716b9c984..f26c84587277 100644 --- a/code/core/src/common/js-package-manager/BUNProxy.test.ts +++ b/code/core/src/common/js-package-manager/BUNProxy.test.ts @@ -178,7 +178,7 @@ describe('BUN Proxy', () => { [ '[install]', 'minimumReleaseAge = 3600', - 'minimumReleaseAgeExcludes = ["storybook", "@storybook/*", "eslint-plugin-storybook", "@chromatic-com/storybook"]', + 'minimumReleaseAgeExcludes = ["storybook", "@storybook/*", "eslint-plugin-storybook", "@chromatic-com/storybook", "chromatic"]', '', ].join('\n') ); @@ -225,6 +225,7 @@ describe('BUN Proxy', () => { ' "@storybook/*",', ' "eslint-plugin-storybook",', ' "@chromatic-com/storybook",', + ' "chromatic",', ']', '[test]', 'coverageThreshold = 0.9', diff --git a/code/core/src/common/js-package-manager/BUNProxy.ts b/code/core/src/common/js-package-manager/BUNProxy.ts index a5a86233689c..8311082103b9 100644 --- a/code/core/src/common/js-package-manager/BUNProxy.ts +++ b/code/core/src/common/js-package-manager/BUNProxy.ts @@ -302,7 +302,7 @@ export class BUNProxy extends JsPackageManager { dedent` bun minimumReleaseAge would block storybook@${storybookVersion} from being installed because it was released within the configured minimumReleaseAge window, so Storybook updated minimumReleaseAgeExcludes for this project automatically. - Added patterns: storybook, @storybook/*, eslint-plugin-storybook, @chromatic-com/storybook + Added patterns: ${STORYBOOK_PACKAGE_PATTERNS.join(', ')} Read more: - https://bun.com/docs/pm/cli/install#minimum-release-age diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index 1902b7b424b0..d780fc48ec24 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -50,6 +50,7 @@ const expectedMinimumReleaseAgeExcludePackages = [ '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ]; describe('PNPM Proxy', () => { @@ -1015,6 +1016,7 @@ describe('PNPM Proxy', () => { '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ]), } as any); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index b50b20906aa0..12ac40792e8d 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -544,7 +544,7 @@ export class PNPMProxy extends JsPackageManager { dedent` pnpm minimumReleaseAge would block storybook@${storybookVersion} from being installed because it was released within the configured minimumReleaseAge window, so Storybook updated minimumReleaseAgeExclude for this project automatically. - Added patterns: storybook, @storybook/*, eslint-plugin-storybook, @chromatic-com/storybook + Added patterns: ${STORYBOOK_PACKAGE_PATTERNS.join(', ')} Read more: - https://pnpm.io/settings#minimumreleaseage diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts index 271d8410b868..ec80a6d0b57a 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts @@ -306,6 +306,7 @@ describe('Yarn 2 Proxy', () => { '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ]), ], }) @@ -384,6 +385,7 @@ describe('Yarn 2 Proxy', () => { '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ]), ], }) @@ -469,7 +471,7 @@ describe('Yarn 2 Proxy', () => { .mockResolvedValueOnce({ stdout: '1440\n' } as any) .mockResolvedValueOnce({ stdout: - "[\n 'storybook',\n '@storybook/*',\n 'eslint-plugin-storybook',\n '@chromatic-com/storybook',\n]\n", + "[\n 'storybook',\n '@storybook/*',\n 'eslint-plugin-storybook',\n '@chromatic-com/storybook',\n 'chromatic',\n]\n", } as any); await expect( diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index 23d769bd0dd4..8f5d2f8fd493 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -288,7 +288,7 @@ export class Yarn2Proxy extends JsPackageManager { dedent` yarn npmMinimalAgeGate would block storybook@${storybookVersion} from being installed because it was released within the configured npmMinimalAgeGate window, so Storybook updated npmPreapprovedPackages for this project automatically. - Added patterns: storybook, @storybook/*, eslint-plugin-storybook, @chromatic-com/storybook + Added patterns: ${STORYBOOK_PACKAGE_PATTERNS.join(', ')} Read more: - https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate diff --git a/code/core/src/common/js-package-manager/util.test.ts b/code/core/src/common/js-package-manager/util.test.ts index 9cdbf380222d..6bc5fade7a2b 100644 --- a/code/core/src/common/js-package-manager/util.test.ts +++ b/code/core/src/common/js-package-manager/util.test.ts @@ -105,10 +105,12 @@ describe('js package manager util', () => { '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ]) ).toBe(true); expect(hasStorybookMinimumAgeExclusions(['storybook', '@storybook/react-vite'])).toBe(false); - expect(hasStorybookMinimumAgeExclusions(['*storybook*'])).toBe(true); + expect(hasStorybookMinimumAgeExclusions(['*storybook*', 'chromatic'])).toBe(true); + expect(hasStorybookMinimumAgeExclusions(['*storybook*'])).toBe(false); }); it('builds rerun guidance', () => { diff --git a/code/core/src/common/js-package-manager/util.ts b/code/core/src/common/js-package-manager/util.ts index 930245d14bb9..354990d3810e 100644 --- a/code/core/src/common/js-package-manager/util.ts +++ b/code/core/src/common/js-package-manager/util.ts @@ -10,6 +10,7 @@ export const STORYBOOK_PACKAGE_PATTERNS = [ '@storybook/*', 'eslint-plugin-storybook', '@chromatic-com/storybook', + 'chromatic', ] as const; const escapePatternForRegex = (pattern: string) => pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); From 60083c343d7601207e88adb8cec3a3815651f2f6 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:32:40 +0200 Subject: [PATCH 282/326] fix(csf-tools): keep a batch's list and statement bookkeeping across a rollback A transaction snapshots the pending edits and puts a clone of them back when it rolls back. Only the texts inserted after each statement were copied to the clone; what earlier appends wrote to each list and where statements were inserted at a line start were lost. An append to a list that was appended to before the transaction then wrote over the first one (`{ a: 1 b: 2 }`), and removing the statement an insertion followed dropped the inserted statement. The clone now gets copies of all three. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/estree/editor.test.ts | 21 +++++++++++++++++++ code/core/src/csf-tools/estree/editor.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index f6288876006d..8b8f152548f8 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -166,6 +166,27 @@ describe('commit', () => { }); }); +describe('transaction', () => { + it('appends after the items an append before a rolled back transaction added', () => { + const editor = new SourceEditor('export default {};\n'); + const object = () => + (editor.program.body[0] as E.ExportDefaultDeclaration).declaration as E.ObjectExpression; + appendToList(editor, objectList(editor, object()), ['a: 1']); + editor.transaction(() => false); + appendToList(editor, objectList(editor, object()), ['b: 2']); + expect(editor.toString()).toBe('export default { a: 1, b: 2 };\n'); + }); + + it('keeps a statement inserted before a rolled back transaction when its anchor is removed', () => { + const editor = new SourceEditor("import a from 'a';\n\nfoo();\n"); + const [anchor] = editor.program.body; + insertStatementAfter(editor, anchor, 'const b = 1;'); + editor.transaction(() => false); + removeStatement(editor, anchor); + expect(editor.toString()).toBe('const b = 1;\nfoo();\n'); + }); +}); + describe('style', () => { it('quotes like most string literals, leaving JSX attributes out', () => { expect(new SourceEditor('import a from \'a\';\nconst x =
;').quote).toBe("'"); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 8fc883682eb8..4aecc15a3d01 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -254,6 +254,8 @@ export class SourceEditor { const committed = this._committed.length; const edits = this._edits.clone(); lastInserted.set(edits, new Map(lastInserted.get(this._edits))); + appendedTo.set(edits, new Map(appendedTo.get(this._edits))); + insertedAt.set(edits, new Set(insertedAt.get(this._edits))); const appended = [...this._appendedWithoutBlankLine]; const transaction = { refused: false, rollbacks: onRollback ? [onRollback] : [] }; this._transaction = transaction; From c8380f3cc4b6780103e0a6d69cdee59d5a0eaca1 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:33:38 +0200 Subject: [PATCH 283/326] refactor(csf-tools): keep the pending edits and what they wrote in one record The list appends, the texts inserted after statements and the line starts statements were inserted at lived in three WeakMaps keyed by the pending edits, each created lazily, and a transaction had to remember to copy every one of them next to the edits. They are now one `Batch` on the editor that holds the edits too, which `commit` replaces and `transaction` copies and restores as a whole. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/estree/editor.ts | 89 ++++++++++++------------ 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 4aecc15a3d01..8fe92045653f 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -93,7 +93,8 @@ export class SourceEditor { _parents: Map | undefined; - _edits: SourceEdits; + // The pending edits, with what they wrote that later edits of the same batch build on. + _batch: Batch; // The edits each earlier commit applied, oldest first. Their maps are only generated when one // is requested, so the output still maps to the code the editor was created with. @@ -129,7 +130,7 @@ export class SourceEditor { this._onCommit = onCommit; this._originalCode = code; this._module = parsePrintable(code, fileName); - this._edits = new MagicString(this.code, { filename: fileName }); + this._batch = newBatch(this.code, fileName); } get code() { @@ -172,11 +173,11 @@ export class SourceEditor { } get edits() { - return this._edits; + return this._batch.edits; } get pending() { - return this._edits.hasChanged(); + return this.edits.hasChanged(); } source(node: Span) { @@ -214,11 +215,11 @@ export class SourceEditor { if (!this.pending) { return false; } - let module = parsePrintable(this._edits.toString(), this.fileName); + let module = parsePrintable(this.edits.toString(), this.fileName); this._style ??= inferStyle(this._module); - this._committed.push(this._edits); + this._committed.push(this.edits); let appended = this._appendedWithoutBlankLine - .map((statement) => committedStatement(this._edits, module, statement)) + .map((statement) => committedStatement(this.edits, module, statement)) .filter((statement) => statement !== undefined); // The blank lines go in as edits of their own, over the code just parsed. const apart = appended.filter((statement) => setsApart(module, statement)); @@ -238,7 +239,7 @@ export class SourceEditor { this._module = module; this._scopes = undefined; this._parents = undefined; - this._edits = new MagicString(this.code, { filename: this.fileName }); + this._batch = newBatch(this.code, this.fileName); this._onCommit?.(); return true; } @@ -252,10 +253,7 @@ export class SourceEditor { transaction(fn: () => boolean | void, onRollback?: () => void): boolean { const { _module, _style, _transaction: outer } = this; const committed = this._committed.length; - const edits = this._edits.clone(); - lastInserted.set(edits, new Map(lastInserted.get(this._edits))); - appendedTo.set(edits, new Map(appendedTo.get(this._edits))); - insertedAt.set(edits, new Set(insertedAt.get(this._edits))); + const batch = copyBatch(this._batch); const appended = [...this._appendedWithoutBlankLine]; const transaction = { refused: false, rollbacks: onRollback ? [onRollback] : [] }; this._transaction = transaction; @@ -277,7 +275,7 @@ export class SourceEditor { this._module = _module; this._style = _style; this._committed.length = committed; - this._edits = edits; + this._batch = batch; this._appendedWithoutBlankLine = appended; this._scopes = undefined; this._parents = undefined; @@ -298,7 +296,7 @@ export class SourceEditor { */ generateMap(options: { source?: string; inputSourceMap?: InputSourceMap } = {}): GeneratedMap { const source = options.source ?? this.fileName ?? ''; - const chain = [this._edits, ...this._committed.slice().reverse()].map((edits) => + const chain = [this.edits, ...this._committed.slice().reverse()].map((edits) => edits.generateMap({ hires: true, includeContent: true, source: mapSource(this) }) ); const [current] = chain; @@ -343,7 +341,7 @@ export class SourceEditor { } toString() { - return restoreLineEndings(this._edits.toString(), this._originalCode); + return restoreLineEndings(this.edits.toString(), this._originalCode); } /** Quote for generated strings: the one most string literals of the file use. */ @@ -367,6 +365,30 @@ export class SourceEditor { type Style = { quote: "'" | '"'; indent: string; semicolons: boolean }; +interface Batch { + edits: SourceEdits; + // What the appends to each list wrote, by the list's span. + lists: Map; + // The text inserted last after each statement. + lastInsertedAfter: Map; + // The line starts statements were inserted at, which a removal around them keeps. + insertedAt: Set; +} + +const newBatch = (code: string, fileName: string | undefined): Batch => ({ + edits: new MagicString(code, { filename: fileName }), + lists: new Map(), + lastInsertedAfter: new Map(), + insertedAt: new Set(), +}); + +const copyBatch = ({ edits, lists, lastInsertedAfter, insertedAt }: Batch): Batch => ({ + edits: edits.clone(), + lists: new Map(lists), + lastInsertedAfter: new Map(lastInsertedAfter), + insertedAt: new Set(insertedAt), +}); + // Statements and class fields that end with an optional `;`. A declaration in a `for` head has none; // an exported declaration ends with the `;` of its `export`. const endsWithOptionalSemicolon = (node: Node, parent: Node | null) => { @@ -917,8 +939,6 @@ interface Appended { separator: string; } -const appendedTo = new WeakMap>(); - const insert = (edits: SourceEdits, { at, right }: Pick, text: string) => right ? edits.appendRight(at, text) : edits.appendLeft(at, text); @@ -1074,11 +1094,7 @@ const appendAfter = (editor: SourceEditor, list: List, previous: Appended, texts */ export const appendToList = (editor: SourceEditor, list: List, texts: string[]) => { editor.assertCurrent(list); - let lists = appendedTo.get(editor.edits); - if (!lists) { - lists = new Map(); - appendedTo.set(editor.edits, lists); - } + const { lists } = editor._batch; const key = `${list.open}:${list.close}`; const previous = lists.get(key); lists.set( @@ -1289,15 +1305,10 @@ const endsStatement = (text: string) => { * shares that line moves below the inserted statements. */ export const insertStatementAfter = (editor: SourceEditor, statement: Span, text: string) => { - const { code, edits } = editor; - let inserted = lastInserted.get(edits); - if (!inserted) { - inserted = new Map(); - lastInserted.set(edits, inserted); - } + const { code, edits, _batch: batch } = editor; // The new text follows the text inserted after the statement earlier in the batch, if any. - const before = inserted.get(statement) ?? editor.source(statement); - inserted.set(statement, text); + const before = batch.lastInsertedAfter.get(statement) ?? editor.source(statement); + batch.lastInsertedAfter.set(statement, text); const end = statementEnd(editor, statement); const next = end + /^[^\S\n]*/.exec(code.slice(end))![0].length; // The separator goes after the line breaks that lead the text. @@ -1318,12 +1329,7 @@ export const insertStatementAfter = (editor: SourceEditor, statement: Span, text if (code[next] === '\n') { const at = next + 1; edits.appendLeft(at, `${written.slice(1)}\n`); - let points = insertedAt.get(edits); - if (!points) { - points = new Set(); - insertedAt.set(edits, points); - } - points.add(at); + batch.insertedAt.add(at); return; } edits.appendLeft(end, written); @@ -1384,14 +1390,10 @@ const isRemoved = (editor: SourceEditor, span: Span) => { } }; -// Where `insertStatementAfter` wrote statements at the start of a line, per batch of edits, which -// a removal around them keeps. -const insertedAt = new WeakMap>(); - // `remove`, split around the statements inserted inside the range, as removing a range clears what // other edits wrote inside it. const removeKeeping = (editor: SourceEditor, start: number, end: number) => { - const inside = [...(insertedAt.get(editor.edits) ?? [])] + const inside = [...editor._batch.insertedAt] .filter((at) => start < at && at < end) .sort((a, b) => a - b); let from = start; @@ -1401,9 +1403,6 @@ const removeKeeping = (editor: SourceEditor, start: number, end: number) => { } }; -// The text `insertStatementAfter` added last after each statement, per batch of edits. -const lastInserted = new WeakMap>(); - /** * Insert a statement after the last statement that pending edits did not remove. A blank line * sets it apart when it or what it follows spans lines: that statement, or the text appended after @@ -1418,7 +1417,7 @@ export const appendStatement = (editor: SourceEditor, text: string) => { return; } // The text inserted last may lead with the blank line that set it apart. - const before = lastInserted.get(editor.edits)?.get(last)?.trim() ?? editor.source(last); + const before = editor._batch.lastInsertedAfter.get(last)?.trim() ?? editor.source(last); const multiline = text.includes('\n') || before.includes('\n'); if (!multiline) { editor._appendedWithoutBlankLine.push(text); From 9c9213603cc14b3fdb19000db6e24e7deb5e4595 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:34:38 +0200 Subject: [PATCH 284/326] refactor(csf-tools): dedent a value's source with the editor's parse, like item texts A value read from the file was dedented by `detachedText`, which parses the text again to find its literals, while item texts were dedented by `dedentSource` with the literals of the editor's parse. A value is code of the same parse, so `valueSource` now uses that one too, through `detachedSource`, and `functionFromMethod` returns the dedented function itself. `detachedTextOf` stays for a node placed as a value, from this parse or another, as `placeableTextOf` can wrap it in parentheses that are not in the code. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/estree/editor.ts | 37 ++++++++++++------------ code/core/src/csf-tools/estree/values.ts | 10 +++---- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 8fe92045653f..9bb6e0642395 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -830,23 +830,24 @@ export const reindent = (source: string, from: string, to: string) => { return literals ? reindentLines(text, from, to, literals) : text; }; -// `text`, the source of a node at `start` in `code`, indented as if the node's line had no indent. -export const detachedText = (code: string, start: number, text: string) => - reindent(text, lineIndent(code, start), ''); - -// `placeableTextOf` as `detachedText`, for a node of `get`, `parseExpression` or a file. -export const detachedTextOf = (node: Node) => { - const text = placeableTextOf(node); - return detachedText(moduleOf(node)!.code, node.start, text); -}; - -const dedentSource = (editor: SourceEditor, start: number, end: number, from: string) => { +// The editor's code from `start` to `end`, its continuation lines without `indent`. +const dedentSource = (editor: SourceEditor, start: number, end: number, indent: string) => { const text = editor.code.slice(start, end); - return from === '' || !text.includes('\n') + return indent === '' || !text.includes('\n') ? text - : reindentLines(text, from, '', literalSpans(editor.program, { start, end })); + : reindentLines(text, indent, '', literalSpans(editor.program, { start, end })); +}; + +// The source of a span of the editor's code as if the line it starts on had no indent. +export const detachedSource = (editor: SourceEditor, span: Span) => { + editor.assertCurrent(span); + return dedentSource(editor, span.start, span.end, lineIndent(editor.code, span.start)); }; +// `placeableTextOf` as `detachedSource`, for a node of `get`, `parseExpression` or any file. +export const detachedTextOf = (node: Node) => + reindent(placeableTextOf(node), lineIndent(moduleOf(node)!.code, node.start), ''); + // An item owns the comments after its separator when they end the line. Followed by code on that // line, they lead the next item instead. const trailingComments = (editor: SourceEditor, separatorEnd: number) => { @@ -892,9 +893,7 @@ const itemTextWith = (editor: SourceEditor, list: List, index: number, print: () export const itemText = (editor: SourceEditor, list: List, item: Span) => { editor.assertCurrent(list); editor.assertCurrent(item); - return itemTextWith(editor, list, list.items.indexOf(item), () => - dedentSource(editor, item.start, item.end, lineIndent(editor.code, item.start)) - ); + return itemTextWith(editor, list, list.items.indexOf(item), () => detachedSource(editor, item)); }; export const memberText = ( @@ -1663,12 +1662,12 @@ export const renamedMemberText = ( }); }; -// Function expression source for a method, so reads and transforms of `play() {}` see a value. -// `async *play(a) {}` becomes `async function* (a) {}`. +// A method as the source of a function expression, as `detachedSource`, so reads and transforms +// of `play() {}` see a value. `async *play(a) {}` becomes `async function* (a) {}`. export const functionFromMethod = (editor: SourceEditor, member: Property) => { const fn = member.value as E.Function; const range = keyRange(editor, member); - const rest = editor.code.slice(range.end, member.end); + const rest = dedentSource(editor, range.end, member.end, lineIndent(editor.code, member.start)); return `${fn.async ? 'async ' : ''}function${fn.generator ? '*' : ''} ${rest.replace(/^\s*/, '')}`; }; diff --git a/code/core/src/csf-tools/estree/values.ts b/code/core/src/csf-tools/estree/values.ts index 3668b1a379ab..68ac14c4c1d7 100644 --- a/code/core/src/csf-tools/estree/values.ts +++ b/code/core/src/csf-tools/estree/values.ts @@ -11,7 +11,7 @@ import { staticKey, unwrapExpression, } from './ast.ts'; -import { type SourceEditor, detachedText, detachedTextOf, functionFromMethod } from './editor.ts'; +import { type SourceEditor, detachedSource, detachedTextOf, functionFromMethod } from './editor.ts'; import type { ScopeInfo } from './scope.ts'; // Node types Babel produces that OXC's ESTree output has no equivalent for (`@babel/types` @@ -49,16 +49,16 @@ const BABEL_ONLY_TYPES = new Set([ export const isAstLike = (value: unknown): value is Node => isNode(value) && (Object.hasOwn(visitorKeys, value.type) || BABEL_ONLY_TYPES.has(value.type)); -// A member's value as `detachedText`; methods become function expressions. +// A member's value as `detachedSource`; methods become function expressions. export const valueSource = (editor: SourceEditor, member: Property) => member.method || member.kind !== 'init' - ? detachedText(editor.code, member.start, functionFromMethod(editor, member)) - : detachedText(editor.code, member.value.start, editor.source(member.value)); + ? functionFromMethod(editor, member) + : detachedSource(editor, member.value); // `valueSource` ready to be placed as another value, as `placeableTextOf` prints it. export const placeableValueSource = (editor: SourceEditor, member: Property) => member.method || member.kind !== 'init' - ? detachedText(editor.code, member.start, functionFromMethod(editor, member)) + ? functionFromMethod(editor, member) : detachedTextOf(member.value); /** From f14875e1fd03efedd72d2fb9cebc73ed382d31f4 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:35:33 +0200 Subject: [PATCH 285/326] refactor(csf-tools): print a member access with one helper Carrying a spread forward and writing a `Story.name = value` annotation both print `.key` or `['key']`, with two helpers that disagreed on reserved words. `printMemberAccess` sits next to `printKey` and writes `.default` like any other identifier name, which member access allows. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/CsfObject.ts | 14 ++++---------- code/core/src/csf-tools/CsfObjectDiscovery.test.ts | 13 +++++++++++++ code/core/src/csf-tools/CsfObjectDiscovery.ts | 13 +++++++------ code/core/src/csf-tools/estree/editor.ts | 6 ++++++ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index b4401af1aaab..4f6bd521e04f 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -4,7 +4,6 @@ import { type Property, type SourceLocation, type Span, - isValidIdentifier, locationOf, parseExpression, staticKey, @@ -18,8 +17,8 @@ import { keySource, prependMembers, printKey, + printMemberAccess, printMembers, - printString, printValue, removeMembers, renameKey, @@ -441,11 +440,6 @@ const carriedSpreads = ( return { ok: true, spreads }; }; -const memberAccess = (key: string, optional: boolean, quote: string) => - isValidIdentifier(key, false) - ? `${optional ? '?.' : '.'}${key}` - : `${optional ? '?.' : ''}[${printString(key, quote)}]`; - // What an object literal is known to hold at `key`: no member, the value of one, or unknown. const literalMember = (literal: E.ObjectExpression, key: string) => { const lookup = lookupProperty(literal.properties, key); @@ -476,7 +470,7 @@ const carriedValue = ( continue; } candidates.push({ - expression: `${spread.source}${memberAccess(first, false, quote)}`, + expression: `${spread.source}${printMemberAccess(first, quote)}`, literal: member?.kind === 'value' ? member.literal : undefined, }); if (member?.kind === 'value') { @@ -491,7 +485,7 @@ const carriedValue = ( return rest.length === 0 ? { expression: either, alternatives: true } : { - expression: `(${either})${rest.map((key) => memberAccess(key, true, quote)).join('')}`, + expression: `(${either})${rest.map((key) => printMemberAccess(key, quote, true)).join('')}`, alternatives: false, }; } @@ -501,7 +495,7 @@ const carriedValue = ( if (member?.kind === 'missing') { return undefined; } - expression += memberAccess(key, !literal, quote); + expression += printMemberAccess(key, quote, !literal); literal = member?.kind === 'value' ? member.literal : undefined; } return { expression, alternatives: false }; diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts index 7806acf2b353..cc58bb37d820 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts @@ -299,6 +299,19 @@ const after = 1 `); }); + it('writes an annotation named by a reserved word as a member access', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = () => null; +`); + const [story] = csf.objects({ meta: false }); + + expect(story.set(['default'], true)).toEqual({ ok: true, changed: true }); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +export const Primary = () => null; +Primary.default = true; +`); + }); + it('ends an annotation with a semicolon before a statement that would continue it', () => { const csf = parse(`export default { title: 'Example' } export function Primary() {} diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.ts b/code/core/src/csf-tools/CsfObjectDiscovery.ts index 2a9ea351d151..a24ee71347ff 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.ts @@ -20,7 +20,6 @@ import { isFunction, isIdentifier, isStringLiteral, - isValidIdentifier, locationOf, outerSpan, staticKey, @@ -29,8 +28,8 @@ import { import { insertStatementAfter, printKey, + printMemberAccess, printMembers, - printString, removeStatements, } from './estree/editor.ts'; import { placeableValueSource } from './estree/values.ts'; @@ -392,15 +391,17 @@ const storyRoot = ( (statement) => statement.start <= binding.declaration.start && binding.declaration.end <= statement.end )!; - const accessor = (key: string) => - isValidIdentifier(key) ? `.${key}` : `[${printString(key, editor.quote)}]`; // `removed` are statements the same edits remove, which the new ones cannot follow. const assign = (members: MemberInsert[], removed: ReadonlySet = new Set()) => { const after = assignments.findLast(({ statement }) => !removed.has(statement))?.statement ?? declaration; const end = editor.semicolons ? ';' : ''; for (const { key, value } of members) { - insertStatementAfter(editor, after, `${binding.localName}${accessor(key)} = ${value}${end}`); + insertStatementAfter( + editor, + after, + `${binding.localName}${printMemberAccess(key, editor.quote)} = ${value}${end}` + ); } }; const statementsOf = (members: readonly Property[]) => @@ -448,7 +449,7 @@ const storyRoot = ( return; } const { left } = assignment.expression; - editor.edits.overwrite(left.object.end, left.end, accessor(key)); + editor.edits.overwrite(left.object.end, left.end, printMemberAccess(key, editor.quote)); }, // `CsfObject` refuses a group of members from both the object and the assignments. group: (members, key) => { diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 9bb6e0642395..ef92d2d411d9 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -461,6 +461,12 @@ const blockIndent = (code: string, node: Node) => { export const printKey = (name: string, quote: string) => isValidIdentifier(name, false) ? name : printString(name, quote); +// `.key`, or `['key']` for a key that is no identifier; with `?.` when `optional`. +export const printMemberAccess = (key: string, quote: string, optional = false) => + isValidIdentifier(key, false) + ? `${optional ? '?.' : '.'}${key}` + : `${optional ? '?.' : ''}[${printString(key, quote)}]`; + export const printString = (value: string, quote: string) => { const json = JSON.stringify(value); return quote === '"' From 8f6d6f35948b089e20b6dc9266ea6f6ecc32b478 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:40:38 +0200 Subject: [PATCH 286/326] refactor(csf-tools): let types carry what CsfObject checked or cast - `get` finds its field like `getValue`, and only `inspect` treats the empty path apart, so the `outside` read kind and `read` are gone. - `locate` always returns the parent object it looked in, so callers stop checking for one, and that parent is the `ObjectRoot` itself rather than a wrapper with an unread copy of its node. - `createCsfObject` returns the editor's own type, so `ConfigFile` calls `createArray` on it directly instead of through a function that checks `instanceof` and throws. - `carriedSpread` prints the spread it carries, so it no longer returns an `alternatives` flag for the caller to print it with. - `detachedMember` builds a typed `Property` instead of casting the whole member; the named-export root narrows its key where it knows it is an identifier. A story assignment keeps its typed `left` instead of a cast assignment expression, and `statementBefore` takes a top-level statement. The `node` and `get` docs of `CsfObject` are wrapped at 100 columns. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/ConfigFile.ts | 6 +- code/core/src/csf-tools/ConfigObject.ts | 8 +- code/core/src/csf-tools/CsfObject.ts | 184 ++++++++---------- code/core/src/csf-tools/CsfObjectDiscovery.ts | 10 +- code/core/src/csf-tools/estree/editor.ts | 12 +- 5 files changed, 97 insertions(+), 123 deletions(-) diff --git a/code/core/src/csf-tools/ConfigFile.ts b/code/core/src/csf-tools/ConfigFile.ts index 5235eb945f53..c8fa1451a71d 100644 --- a/code/core/src/csf-tools/ConfigFile.ts +++ b/code/core/src/csf-tools/ConfigFile.ts @@ -13,9 +13,9 @@ import { type CsfMutationDiagnostic, type CsfMutationResult, type CsfObject, + type CsfObjectEditor, type CsfObjectHost, type CsfValue, - createArray, createCsfObject, literalRoot, } from './CsfObject.ts'; @@ -424,7 +424,7 @@ export class ConfigFile implements CsfObject { }); } - _mutate(operation: (object: CsfObject) => CsfMutationResult): CsfMutationResult { + _mutate(operation: (object: CsfObjectEditor) => CsfMutationResult): CsfMutationResult { const editor = this._objectEditor('mutate'); if (editor.ok === true) { return operation(editor.object); @@ -835,7 +835,7 @@ export class ConfigFile implements CsfObject { const current = this.getFieldNode(path); const source = placeableTextOf(node); if (!current) { - return this._mutate((object) => createArray(object, path, [source])); + return this._mutate((object) => object.createArray(path, [source])); } if (current.type !== 'ArrayExpression') { return this._notAnArray(path, current); diff --git a/code/core/src/csf-tools/ConfigObject.ts b/code/core/src/csf-tools/ConfigObject.ts index 10bde64dce25..8f5a4399ca89 100644 --- a/code/core/src/csf-tools/ConfigObject.ts +++ b/code/core/src/csf-tools/ConfigObject.ts @@ -1,7 +1,7 @@ import type { ConfigFile } from './ConfigFile.ts'; import { type CsfMutationDiagnostic, - type CsfObject, + type CsfObjectEditor, type CsfObjectHost, type MemberInsert, type ObjectRoot, @@ -337,7 +337,9 @@ const namedExportsRoot = (editor: SourceEditor, entries: NamedEntry[]): ObjectRo const value = ( entry.declaration.type === 'VariableDeclarator' ? entry.declaration.init : entry.declaration ) as E.Expression; - const key = entry.specifier ? entry.specifier.specifier.exported : entry.declaration.id!; + const key = entry.specifier + ? entry.specifier.specifier.exported + : (entry.declaration.id as E.IdentifierName); const property = detachedMember(key, value, false, entry.declaration); byProperty.set(property, entry); return property; @@ -475,7 +477,7 @@ export function createConfigObject( mode: 'read' | 'mutate', report: (diagnostic: CsfMutationDiagnostic) => void, markChanged: () => void -): { ok: true; object: CsfObject } | { ok: false; diagnostic: CsfMutationDiagnostic } { +): { ok: true; object: CsfObjectEditor } | { ok: false; diagnostic: CsfMutationDiagnostic } { const located = locateConfigRoot(config, mode); if (located.ok === false) { return located; diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 4f6bd521e04f..9f99eab4ceba 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -115,8 +115,8 @@ export interface CsfObject { /** * Find the object literal this editor edits in the current parse. It is `undefined` for a config * made of named exports, for a story without an object literal, whose fields are all - * `Story.name = value` assignments, and once the object is gone. It is the file's own node, not a copy: do not change it, and read it - * again after a mutation, which parses the file again. + * `Story.name = value` assignments, and once the object is gone. It is the file's own node, not + * a copy: do not change it, and read it again after a mutation, which parses the file again. * * @example * ```ts @@ -129,9 +129,9 @@ export interface CsfObject { */ readonly node: E.ObjectExpression | undefined; /** - * Read a detached copy of the expression at a property path. Missing fields return `undefined`; so does a path - * through a value that is not an object literal, or one a spread, computed key, or duplicate field - * leaves unproven, which also adds a mutation diagnostic. + * Read a detached copy of the expression at a property path. Missing fields return `undefined`; + * so does a path through a value that is not an object literal, or one a spread, computed key, or + * duplicate field leaves unproven, which also adds a mutation diagnostic. * * @example * ```ts @@ -282,23 +282,25 @@ export interface ObjectRoot { take(member: Property, key: string): MemberInsert; } -/** - * A member of an `ObjectRoot` that is not written as one, such as a named export or a - * `Story.name = value` assignment. Its key and value are real nodes, so every span a shared helper - * reads is a real one; the member itself only exists in memory, spanning `statement`. - */ -export const detachedMember = (key: Node, value: Node, computed: boolean, statement: Span) => - ({ - type: 'Property', - kind: 'init', - method: false, - shorthand: false, - computed, - key, - value, - start: statement.start, - end: statement.end, - }) as unknown as Property; +// A member of an `ObjectRoot` that is not written as one, such as a named export or a +// `Story.name = value` assignment. Its key and value are nodes of the file, so every span read from +// it is real; the member itself only exists in memory, spanning `statement`. +export const detachedMember = ( + key: Property['key'], + value: Property['value'], + computed: boolean, + statement: Span +): Property => ({ + type: 'Property', + kind: 'init', + method: false, + shorthand: false, + computed, + key, + value, + start: statement.start, + end: statement.end, +}); export interface CsfObjectHost { readonly editor: SourceEditor; @@ -336,8 +338,6 @@ export const literalRoot = (editor: SourceEditor, object: E.ObjectExpression): O type ReportDiagnostic = (diagnostic: CsfMutationDiagnostic) => void; type MarkChanged = () => void; -type ParentObject = { root: ObjectRoot; node?: E.ObjectExpression }; - type PropertyLookup = | { ok: true; property?: Property } | { ok: false; code: CsfMutationDiagnosticCode; node: Node }; @@ -453,15 +453,18 @@ const literalMember = (literal: E.ObjectExpression, key: string) => { return { kind: 'value' as const, literal: value.type === 'ObjectExpression' ? value : undefined }; }; -// The value the spreads give the member at `keys`, or `undefined` when none gives it one. The last -// spread with the first key replaces what earlier ones give it, which are only read when it may -// lack it, after `??`. A member below the spread's own is read with `?.`, as it may be missing; one -// a local literal lacks is left out. -const carriedValue = ( +// The spread that carries the member the spreads give at `keys` into an object, or an `array`, +// created there; `undefined` when none gives it one. The last spread with the first key replaces +// what earlier ones give it, which are only read when it may lack it, after `??`. A member below +// the spread's own is read with `?.`, as it may be missing; one a local literal lacks is left out. +const carriedSpread = ( spreads: readonly CarriedSpread[], keys: readonly string[], - quote: string + quote: string, + array: boolean ) => { + const spreadOf = (expression: string, alternatives = false) => + array ? `...(${expression} ?? [])` : `...${alternatives ? `(${expression})` : expression}`; const [first, ...rest] = keys; const candidates: { expression: string; literal: E.ObjectExpression | undefined }[] = []; for (const spread of [...spreads].reverse()) { @@ -483,11 +486,8 @@ const carriedValue = ( if (candidates.length > 1) { const either = candidates.map(({ expression }) => expression).join(' ?? '); return rest.length === 0 - ? { expression: either, alternatives: true } - : { - expression: `(${either})${rest.map((key) => printMemberAccess(key, quote, true)).join('')}`, - alternatives: false, - }; + ? spreadOf(either, true) + : spreadOf(`(${either})${rest.map((key) => printMemberAccess(key, quote, true)).join('')}`); } let [{ expression, literal }] = candidates; for (const key of rest) { @@ -498,7 +498,7 @@ const carriedValue = ( expression += printMemberAccess(key, quote, !literal); literal = member?.kind === 'value' ? member.literal : undefined; } - return { expression, alternatives: false }; + return spreadOf(expression); }; class CsfObjectEditor implements CsfObject { @@ -545,28 +545,19 @@ class CsfObjectEditor implements CsfObject { } get(path: readonly string[]): CsfExpression | undefined { - const read = this.read(path); - if (read.kind === 'unreadable') { - this.reportDiagnostic(read.diagnostic); - } - return read.kind === 'value' ? read.node : undefined; + const property = this.getProperty(path); + return property && parseExpression(valueSource(this.editor, property)); } + // Unlike `get`, which reads the object itself as missing, `inspect` reports it. inspect(path: readonly string[]): CsfInspection { - const read = this.read(path); - if (read.kind !== 'outside') { - return read; + if (path.length === 0) { + const message = `Cannot read the object itself, which is not a field of this ${this.target.kind} object`; + return { + kind: 'unreadable', + diagnostic: this.diagnostic('unsupported-member', path, this.anchor(), message), + }; } - const name = path.length > 0 ? path.join('.') : 'the object itself'; - const message = `Cannot read ${name}, which is not a field of this ${this.target.kind} object`; - return { - kind: 'unreadable', - diagnostic: this.diagnostic('unsupported-member', path, this.anchor(), message), - }; - } - - // `get` reads a path outside the object as missing, without a diagnostic. - private read(path: readonly string[]): CsfInspection | { kind: 'outside' } { const found = this.find(path); return found.kind === 'property' ? { kind: 'value', node: parseExpression(valueSource(this.editor, found.property)) } @@ -595,10 +586,9 @@ class CsfObjectEditor implements CsfObject { ): | { kind: 'property'; property: Property } | { kind: 'missing' } - | { kind: 'outside' } | { kind: 'unreadable'; diagnostic: CsfMutationDiagnostic } { if (path.length === 0) { - return { kind: 'outside' }; + return { kind: 'missing' }; } const found = this.locate(path); if (found.ok === false) { @@ -633,15 +623,15 @@ class CsfObjectEditor implements CsfObject { return this.failure(inspected.code, path, inspected.node); } if (inspected.missing !== undefined && inspected.missing < path.length - 1) { - const carried = carriedSpreads(this.editor, inspected.parent!.root.properties); + const carried = carriedSpreads(this.editor, inspected.parent.properties); if (carried.ok === false) { return this.failure(carried.code, path, carried.node); } } const { quote, indent } = this.editor; const text = printValue(value, quote, { indent, expand: 'multiline' }); - if (inspected.property && inspected.parent) { - inspected.parent.root.replaceValue(inspected.property, text); + if (inspected.property) { + inspected.parent.replaceValue(inspected.property, text); } else { const key = path.at(-1)!; this.insert(path.slice(0, -1), { @@ -664,7 +654,7 @@ class CsfObjectEditor implements CsfObject { if (inspected.property) { return this.failure('occupied-destination', path, inspected.property); } - const carried = carriedSpreads(this.editor, inspected.parent!.root.properties); + const carried = carriedSpreads(this.editor, inspected.parent.properties); if (carried.ok === false) { return this.failure(carried.code, path, carried.node); } @@ -684,7 +674,7 @@ class CsfObjectEditor implements CsfObject { return this.failure(inspected.code, path, inspected.node); } const { property, parent } = inspected; - if (!property || !parent) { + if (!property) { return { ok: true, changed: false }; } const value = parseExpression(valueSource(this.editor, property)); @@ -692,7 +682,7 @@ class CsfObjectEditor implements CsfObject { if (!derived || derived === value) { return { ok: true, changed: false }; } - parent.root.replaceValue(property, detachedTextOf(derived)); + parent.replaceValue(property, detachedTextOf(derived)); return this.commit(path); } @@ -704,14 +694,14 @@ class CsfObjectEditor implements CsfObject { if (inspected.ok === false) { return this.failure(inspected.code, path, inspected.node); } - if (!inspected.property || !inspected.parent) { + if (!inspected.property) { return { ok: true, changed: false }; } - const removal = lookupProperty(inspected.parent.root.properties, path.at(-1)!, 'remove'); + const removal = lookupProperty(inspected.parent.properties, path.at(-1)!, 'remove'); if (!removal.ok) { return this.failure(removal.code, path, removal.node); } - inspected.parent.root.remove([inspected.property]); + inspected.parent.remove([inspected.property]); const removed = this.commit(path); if (!removed.ok) { return removed; @@ -739,10 +729,10 @@ class CsfObjectEditor implements CsfObject { if (source.ok === false) { return this.failure(source.code, from, source.node); } - if (!source.property || !source.parent) { + if (!source.property) { return { ok: true, changed: false }; } - const removal = lookupProperty(source.parent.root.properties, from.at(-1)!, 'remove'); + const removal = lookupProperty(source.parent.properties, from.at(-1)!, 'remove'); if (!removal.ok) { return this.failure(removal.code, from, removal.node); } @@ -761,11 +751,11 @@ class CsfObjectEditor implements CsfObject { sourceParent.length === destinationParent.length && sourceParent.every((part, index) => destinationParent[index] === part) ) { - source.parent.root.rename(source.property, key); + source.parent.rename(source.property, key); return this.commit(from); } - const moved = source.parent.root.take(source.property, key); + const moved = source.parent.take(source.property, key); // Insert first: if that throws, nothing has been committed. The source is removed afterwards, // looked up again since the insertion moved its spans. this.insert(to.slice(0, -1), { members: [{ ...moved, key }] }); @@ -774,10 +764,10 @@ class CsfObjectEditor implements CsfObject { return inserted; } const remaining = this.locate(from); - if (remaining.ok === false || !remaining.property || !remaining.parent) { + if (remaining.ok === false || !remaining.property) { throw new Error(`CsfObject: ${from.join('.')} was not found again after moving it`); } - remaining.parent.root.remove([remaining.property]); + remaining.parent.remove([remaining.property]); const removed = this.commit(from); if (!removed.ok) { return removed; @@ -958,22 +948,17 @@ class CsfObjectEditor implements CsfObject { private removeEmptyParents(path: readonly string[]): CsfMutationResult | undefined { for (let depth = path.length - 1; depth > 0; depth--) { const ancestor = this.locate(path.slice(0, depth)); - if ( - ancestor.ok === false || - !ancestor.property || - ancestor.property.method || - !ancestor.parent - ) { + if (ancestor.ok === false || !ancestor.property || ancestor.property.method) { return; } const value = resolveExpression(this.editor.scopes, ancestor.property.value); if (value?.type !== 'ObjectExpression' || value.properties.length > 0) { return; } - if (!lookupProperty(ancestor.parent.root.properties, path[depth - 1], 'remove').ok) { + if (!lookupProperty(ancestor.parent.properties, path[depth - 1], 'remove').ok) { return; } - ancestor.parent.root.remove([ancestor.property]); + ancestor.parent.remove([ancestor.property]); const committed = this.commit(path); if (!committed.ok) { return committed; @@ -988,14 +973,14 @@ class CsfObjectEditor implements CsfObject { path: readonly string[], access: Access = 'read' ): - | { ok: true; parent?: ParentObject; property?: Property; missing?: number } + | { ok: true; parent: ObjectRoot; property?: Property; missing?: number } | { ok: false; code: CsfMutationDiagnosticCode; node: Node } { - let parent: ParentObject = { root: this.root() }; + let parent = this.root(); let property: Property | undefined; for (const [index, name] of path.entries()) { const lookup = lookupProperty( - parent.root.properties, + parent.properties, name, index === path.length - 1 || access === 'set' ? access : 'read' ); @@ -1016,7 +1001,7 @@ class CsfObjectEditor implements CsfObject { if (value?.type !== 'ObjectExpression') { return { ok: false, code: 'unsupported-member', node: property.value }; } - parent = { root: literalRoot(this.editor, value), node: value }; + parent = literalRoot(this.editor, value); } return { ok: true, parent, property }; @@ -1048,28 +1033,17 @@ class CsfObjectEditor implements CsfObject { const multiline = texts.some((text) => text.includes('\n')); const block = (members: string[]) => multiline ? printMembers(members, indent) : `{ ${members.join(', ')} }`; - const forward = (depth: number, array: boolean) => { - const value = carriedValue(carried.spreads, missing.slice(0, depth + 1), quote); - if (!value) { - return []; - } - const { expression, alternatives } = value; - return [ - array - ? `...(${expression} ?? [])` - : `...${alternatives ? `(${expression})` : expression}`, - ]; + const forward = (depth: number, array = false) => { + const spread = carriedSpread(carried.spreads, missing.slice(0, depth + 1), quote, array); + return spread ? [spread] : []; }; const last = missing.length - 1; let nested = 'members' in content - ? block([...forward(last, false), ...texts]) + ? block([...forward(last), ...texts]) : `[${[...forward(last, true), ...texts].join(', ')}]`; for (let depth = last - 1; depth >= 0; depth--) { - nested = block([ - ...forward(depth, false), - `${printKey(missing[depth + 1], quote)}: ${nested}`, - ]); + nested = block([...forward(depth), `${printKey(missing[depth + 1], quote)}: ${nested}`]); } parent.append([{ key: name, value: nested, text: `${printKey(name, quote)}: ${nested}` }]); return; @@ -1152,19 +1126,13 @@ class CsfObjectEditor implements CsfObject { } } -// Append to an array at a missing field, as `ConfigFile#appendNodeToArray` does. -export const createArray = (object: CsfObject, path: readonly string[], items: string[]) => { - if (!(object instanceof CsfObjectEditor)) { - throw new Error('CsfObject: createArray needs an editor of createCsfObject'); - } - return object.createArray(path, items); -}; - export const createCsfObject = ( target: CsfObjectTarget, host: CsfObjectHost, report: ReportDiagnostic, markChanged: MarkChanged -): CsfObject => new CsfObjectEditor(target, host, report, markChanged); +) => new CsfObjectEditor(target, host, report, markChanged); + +export type { CsfObjectEditor }; export { keySource }; diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.ts b/code/core/src/csf-tools/CsfObjectDiscovery.ts index a24ee71347ff..30635e53e444 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.ts @@ -50,7 +50,7 @@ type Discovered = { target: CsfObjectTarget; root: ObjectRoot }; // A top-level `Story.name = value` statement, as a member of the story. type Assignment = { statement: E.ExpressionStatement; - expression: E.AssignmentExpression & { left: E.MemberExpression }; + left: E.MemberExpression; property: Property; }; @@ -368,7 +368,7 @@ const assignmentsOf = (csf: CsfFile, binding: StoryBinding) => { return { assignments, write }; } const property = detachedMember(parent.property, user.right, parent.computed, statement); - assignments.push({ statement, expression: user as Assignment['expression'], property }); + assignments.push({ statement, left: parent, property }); } assignments.sort((a, b) => a.statement.start - b.statement.start); return { assignments, shared }; @@ -439,7 +439,7 @@ const storyRoot = ( literal!.replaceValue(member, text); return; } - const { start, end } = outerSpan(assignment.expression.right); + const { start, end } = outerSpan(assignment.property.value); editor.edits.overwrite(start, end, text); }, rename: (member, key) => { @@ -448,7 +448,7 @@ const storyRoot = ( literal!.rename(member, key); return; } - const { left } = assignment.expression; + const { left } = assignment; editor.edits.overwrite(left.object.end, left.end, printMemberAccess(key, editor.quote)); }, // `CsfObject` refuses a group of members from both the object and the assignments. @@ -527,7 +527,7 @@ const discoverStories = ( return reject(shared); } if (!object && !isAnnotatable(node)) { - const name = annotationName(assignments[0].expression.left)!; + const name = annotationName(assignments[0].left)!; return reject({ code: 'unsupported-initializer', node: assignments[0].property.value, diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index ef92d2d411d9..c8e6ed655c42 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -1453,11 +1453,15 @@ const committedStatement = ( return program.body.find((node) => node.start === start); }; -const statementBefore = (program: E.Program, statement: Node): Node | undefined => - (program.body as readonly Node[])[program.body.indexOf(statement as E.Statement) - 1]; +type TopLevel = E.Program['body'][number]; + +const statementBefore = (program: E.Program, statement: TopLevel) => { + const index = program.body.indexOf(statement); + return index > 0 ? program.body[index - 1] : undefined; +}; // Whether a statement, or the statement before it, spans lines with no blank line between them. -const setsApart = ({ code, program }: ParsedModule, statement: Node) => { +const setsApart = ({ code, program }: ParsedModule, statement: TopLevel) => { const before = statementBefore(program, statement); return ( !!before && @@ -1468,7 +1472,7 @@ const setsApart = ({ code, program }: ParsedModule, statement: Node) => { // The start of the first line of a statement with the comments above it, below the statement // before it and the comments that share that statement's line. -const ownLineStart = ({ code, program, comments }: ParsedModule, statement: Node) => { +const ownLineStart = ({ code, program, comments }: ParsedModule, statement: TopLevel) => { const before = statementBefore(program, statement)!; const above = comments.find( (comment) => From 823d040b0de55d52413619b8034f077f13aacbb5 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:01:00 +0200 Subject: [PATCH 287/326] refactor(csf-tools): wrap long comments and list each semicolon case once `_refuse` is internal, so its comment is a line comment, the comment lines past 100 columns are wrapped, and `endsWithOptionalSemicolon` lists the statements that end with an optional `;` in one case group. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/estree/editor.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index c8e6ed655c42..59ec29535bf6 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -113,7 +113,7 @@ export class SourceEditor { // how to undo state kept outside the editor, such as a `changed` flag. _transaction: { refused: boolean; rollbacks: (() => void)[] } | undefined; - /** Mark a mutation as refused, so the transaction it runs in rolls back. */ + // Marks a mutation as refused, so the transaction it runs in rolls back. _refuse() { if (this._transaction) { this._transaction.refused = true; @@ -121,8 +121,8 @@ export class SourceEditor { } // What `appendStatement` wrote without a blank line before it: statements of this parse, and - // texts of pending edits. Each commit finds them in the code it parses, and sets one apart once it - // or the statement before it spans lines. + // texts of pending edits. Each commit finds them in the code it parses, and sets one apart once + // it or the statement before it spans lines. _appendedWithoutBlankLine: (Node | string)[] = []; constructor(code: string, fileName?: string, onCommit?: () => void) { @@ -356,7 +356,7 @@ export class SourceEditor { return this._style.indent; } - /** Whether statements to generate end with `;`: false only when most statements of the file omit it. */ + /** Whether generated statements end with `;`: unless most statements of the file omit it. */ get semicolons() { this._style ??= inferStyle(this._module); return this._style.semicolons; @@ -389,8 +389,8 @@ const copyBatch = ({ edits, lists, lastInsertedAfter, insertedAt }: Batch): Batc insertedAt: new Set(insertedAt), }); -// Statements and class fields that end with an optional `;`. A declaration in a `for` head has none; -// an exported declaration ends with the `;` of its `export`. +// Statements and class fields that end with an optional `;`. A declaration in a `for` head has +// none; an exported declaration ends with the `;` of its `export`. const endsWithOptionalSemicolon = (node: Node, parent: Node | null) => { switch (node.type) { case 'ImportDeclaration': @@ -398,7 +398,6 @@ const endsWithOptionalSemicolon = (node: Node, parent: Node | null) => { case 'ExpressionStatement': case 'ReturnStatement': case 'ThrowStatement': - return true; case 'TSTypeAliasDeclaration': case 'PropertyDefinition': return true; @@ -1430,8 +1429,8 @@ export const appendStatement = (editor: SourceEditor, text: string) => { insertStatementAfter(editor, last, `${multiline ? '\n' : ''}${text}`); }; -// The statement of the parse of `edits` that a statement of the code they edit became, or that a -// text appended in them is. +// The statement of `module`, the parse of `edits`, that a statement of the code they edit became, +// or whose source is a text they appended. const committedStatement = ( edits: SourceEdits, { code, program }: ParsedModule, From 3ca63d168c0b56853b6602a78574e35c1400db8b Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:01:00 +0200 Subject: [PATCH 288/326] docs(csf-tools): say what componentImport is in one sentence The two sentences on `meta.component` and `componentImport` become one, and the examples of a `component` without an import keep the local component, the member and the type assertion. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index ee1212a1d7a6..f2e6cb1013bf 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -9,7 +9,7 @@ An experimental library to read, analyze, transform, and write CSF programmatica It can parse MDX into CSF. -`CsfFile.meta.component` is the meta's `component` as written. `CsfFile.componentImport` is the import it refers to, as `{ source, imported, local }`, so `import { Button as B } from './Button'` gives `{ source: './Button', imported: 'Button', local: 'B' }`; `imported` is `'default'` for a default import. It is `undefined` when `component` is not an identifier bound by a default or named import, such as a local component, a namespace import or a member like `UI.Button`, or `Button as any`. +`CsfFile.meta.component` is the meta's `component` as written, and `CsfFile.componentImport` the import it refers to, as `{ source, imported, local }`: `import { Button as B } from './Button'` gives `{ source: './Button', imported: 'Button', local: 'B' }`, and a default import has `imported: 'default'`. It is `undefined` when `component` is not an identifier bound by a default or named import, such as a local component, a member like `UI.Button`, or `Button as any`. ## Transforming stories From 17681813deb728742735607141c49d896b7d94ba Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:58:11 +0200 Subject: [PATCH 289/326] fix(csf-tools): rename an annotation of a parenthesized story Renaming a `Story.name = value` field overwrote from the end of the story's identifier to the end of the member, so `(Primary).argTypes` lost its closing parenthesis and the commit threw a parse error. The overwrite now starts after the parentheses around the story. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/CsfObjectDiscovery.test.ts | 14 ++++++++++++++ code/core/src/csf-tools/CsfObjectDiscovery.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts index cc58bb37d820..3f96027d8dec 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.test.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.test.ts @@ -479,6 +479,20 @@ Primary.args.label = 'Changed'; ]); }); + it('renames an annotation of a parenthesized story', () => { + const csf = parse(`export default { title: 'Example' }; +export const Primary = () => null; +(Primary).argTypes = { label: { control: 'text' } }; +`); + const [story] = csf.objects({ meta: false }); + + expect(story.rename(['argTypes'], 'args')).toEqual({ ok: true, changed: true }); + expect(printCsf(csf).code).toBe(`export default { title: 'Example' }; +export const Primary = () => null; +(Primary).args = { label: { control: 'text' } }; +`); + }); + it('does not add annotations to a story that holds another value', () => { const csf = parse(`export default { title: 'Example' }; export const Primary = shared; diff --git a/code/core/src/csf-tools/CsfObjectDiscovery.ts b/code/core/src/csf-tools/CsfObjectDiscovery.ts index 30635e53e444..8175c843c34c 100644 --- a/code/core/src/csf-tools/CsfObjectDiscovery.ts +++ b/code/core/src/csf-tools/CsfObjectDiscovery.ts @@ -449,7 +449,11 @@ const storyRoot = ( return; } const { left } = assignment; - editor.edits.overwrite(left.object.end, left.end, printMemberAccess(key, editor.quote)); + editor.edits.overwrite( + outerSpan(left.object).end, + left.end, + printMemberAccess(key, editor.quote) + ); }, // `CsfObject` refuses a group of members from both the object and the assignments. group: (members, key) => { From 75c5d611bc0d62a171cc35e0db0864c5e166a949 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 13:59:13 +0200 Subject: [PATCH 290/326] refactor(csf-tools): give the list helpers and spread carrying names of their own - `withSeparator` and `oneLine` no longer share their names with the `appended` locals of `commit` and `transaction` and with `inline`. - `carriedSpread` takes `into: 'object' | 'array'` instead of a boolean. - The dead `keySource` re-export of CsfObject.ts goes. - `transaction` reads its result as `unknown` before looking for `then`. - The comments on `semicolons`, `committedStatement`, `carriedSpread` and the named-export root say what they hold in fewer words, and the one restating `printMemberAccess` goes. Co-Authored-By: Claude Opus 5.5 --- code/core/src/csf-tools/ConfigObject.ts | 5 ++--- code/core/src/csf-tools/CsfObject.ts | 23 +++++++++++------------ code/core/src/csf-tools/estree/editor.ts | 22 ++++++++++------------ 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/code/core/src/csf-tools/ConfigObject.ts b/code/core/src/csf-tools/ConfigObject.ts index 8f5a4399ca89..a285268e31bf 100644 --- a/code/core/src/csf-tools/ConfigObject.ts +++ b/code/core/src/csf-tools/ConfigObject.ts @@ -327,9 +327,8 @@ export const locateConfigRoot = ( const exportedName = (node: E.ModuleExportName) => node.type === 'Identifier' ? node.name : (node as E.StringLiteral).value; -// A config made of named exports, edited as if it were one object literal. Each member's key is the -// node that carries the exported name and its value the declaration's initializer, so every span a -// shared helper could read is a real one; the member itself only exists in memory. +// A config made of named exports, edited as if it were one object literal of `detachedMember`s: the +// exported name is the key and the declaration's initializer the value. const namedExportsRoot = (editor: SourceEditor, entries: NamedEntry[]): ObjectRoot => { const quote = editor.quote; const byProperty = new Map(); diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 9f99eab4ceba..08eaca6f316a 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -14,7 +14,6 @@ import { appendMembers, detachedTextOf, groupMembers, - keySource, prependMembers, printKey, printMemberAccess, @@ -453,18 +452,20 @@ const literalMember = (literal: E.ObjectExpression, key: string) => { return { kind: 'value' as const, literal: value.type === 'ObjectExpression' ? value : undefined }; }; -// The spread that carries the member the spreads give at `keys` into an object, or an `array`, -// created there; `undefined` when none gives it one. The last spread with the first key replaces -// what earlier ones give it, which are only read when it may lack it, after `??`. A member below -// the spread's own is read with `?.`, as it may be missing; one a local literal lacks is left out. +// The spread that writes the member at `keys` of `spreads` into a new object or array, or +// `undefined` when none can have it. The last spread that may have the first key wins, earlier ones +// follow after `??` in case it lacks it; a local literal known to have it ends that list, and one +// known to lack it is skipped. A member below a spread's own is read with `?.`. const carriedSpread = ( spreads: readonly CarriedSpread[], keys: readonly string[], quote: string, - array: boolean + into: 'object' | 'array' ) => { const spreadOf = (expression: string, alternatives = false) => - array ? `...(${expression} ?? [])` : `...${alternatives ? `(${expression})` : expression}`; + into === 'array' + ? `...(${expression} ?? [])` + : `...${alternatives ? `(${expression})` : expression}`; const [first, ...rest] = keys; const candidates: { expression: string; literal: E.ObjectExpression | undefined }[] = []; for (const spread of [...spreads].reverse()) { @@ -1033,15 +1034,15 @@ class CsfObjectEditor implements CsfObject { const multiline = texts.some((text) => text.includes('\n')); const block = (members: string[]) => multiline ? printMembers(members, indent) : `{ ${members.join(', ')} }`; - const forward = (depth: number, array = false) => { - const spread = carriedSpread(carried.spreads, missing.slice(0, depth + 1), quote, array); + const forward = (depth: number, into: 'object' | 'array' = 'object') => { + const spread = carriedSpread(carried.spreads, missing.slice(0, depth + 1), quote, into); return spread ? [spread] : []; }; const last = missing.length - 1; let nested = 'members' in content ? block([...forward(last), ...texts]) - : `[${[...forward(last, true), ...texts].join(', ')}]`; + : `[${[...forward(last, 'array'), ...texts].join(', ')}]`; for (let depth = last - 1; depth >= 0; depth--) { nested = block([...forward(depth), `${printKey(missing[depth + 1], quote)}: ${nested}`]); } @@ -1134,5 +1135,3 @@ export const createCsfObject = ( ) => new CsfObjectEditor(target, host, report, markChanged); export type { CsfObjectEditor }; - -export { keySource }; diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 59ec29535bf6..69ae0a865d6d 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -259,8 +259,8 @@ export class SourceEditor { this._transaction = transaction; let kept = false; try { - const result = fn(); - if (typeof (result as unknown as PromiseLike | undefined)?.then === 'function') { + const result: unknown = fn(); + if (typeof (result as PromiseLike | undefined)?.then === 'function') { throw new Error( 'A transaction cannot wait for a promise: its edits would be made after it' ); @@ -356,7 +356,7 @@ export class SourceEditor { return this._style.indent; } - /** Whether generated statements end with `;`: unless most statements of the file omit it. */ + /** Whether generated statements end with `;`, as they do unless most of the file's omit it. */ get semicolons() { this._style ??= inferStyle(this._module); return this._style.semicolons; @@ -460,7 +460,6 @@ const blockIndent = (code: string, node: Node) => { export const printKey = (name: string, quote: string) => isValidIdentifier(name, false) ? name : printString(name, quote); -// `.key`, or `['key']` for a key that is no identifier; with `?.` when `optional`. export const printMemberAccess = (key: string, quote: string, optional = false) => isValidIdentifier(key, false) ? `${optional ? '?.' : '.'}${key}` @@ -961,7 +960,7 @@ const itemLines = ( .join(''); // A line comment ending the last item puts the comma for the next one on the next line. -const appended = ( +const withSeparator = ( layout: Omit, written: string, lastItem: string @@ -1003,7 +1002,7 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const { items, padding } = list; const write = (layout: Omit, text: string) => { insert(edits, layout, text); - return appended(layout, text, texts.at(-1)!); + return withSeparator(layout, text, texts.at(-1)!); }; // The text after the items goes to the right of the list's end, so later items go before it. const close = (text: string) => { @@ -1031,18 +1030,18 @@ const appendFirst = (editor: SourceEditor, list: List, texts: string[]): Appende const layout = { at: end, right: false, indent, trailingComma: ',', lineBreakFollows: true }; return write(layout, itemLines(texts, indent, ',', true)); } - const inline = { right: false, indent: undefined, trailingComma: '', lineBreakFollows: false }; + const oneLine = { right: false, indent: undefined, trailingComma: '', lineBreakFollows: false }; if (end > list.open) { if (end === list.close) { close(padding); } - return write({ ...inline, at: end }, ` ${texts.join(', ')}`); + return write({ ...oneLine, at: end }, ` ${texts.join(', ')}`); } if (list.close > list.open) { edits.remove(list.open, list.close); } close(padding); - return write({ ...inline, at: list.open }, `${padding}${texts.join(', ')}`); + return write({ ...oneLine, at: list.open }, `${padding}${texts.join(', ')}`); } const last = items.at(-1)!; const comma = separatorAfter(editor, last); @@ -1087,7 +1086,7 @@ const appendAfter = (editor: SourceEditor, list: List, previous: Appended, texts ? ` ${inline(editor, list, texts, trailingComma, lineBreakFollows)}` : itemLines(texts, indent, trailingComma, lineBreakFollows); insert(editor.edits, previous, `${separator}${text}`); - return appended(previous, text, texts.at(-1)!); + return withSeparator(previous, text, texts.at(-1)!); }; /** @@ -1429,8 +1428,7 @@ export const appendStatement = (editor: SourceEditor, text: string) => { insertStatementAfter(editor, last, `${multiline ? '\n' : ''}${text}`); }; -// The statement of `module`, the parse of `edits`, that a statement of the code they edit became, -// or whose source is a text they appended. +// Where a statement, or a text appended as one, is in `module`, the code `edits` produced. const committedStatement = ( edits: SourceEdits, { code, program }: ParsedModule, From 9f6f5a33714e668f985db06f200d51920792b7b7 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:39:52 +0200 Subject: [PATCH 291/326] fix(csf-tools): type the editor ConfigFile mutates as an interface ConfigFile._mutate took the CsfObject editor class as its type. The class has private members, so the type was nominal and the ConfigFile of src and of dist were no longer assignable to each other, which the scripts typecheck mixes. CsfObjectEditor is now an interface: CsfObject with createArray. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/CsfObject.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/code/core/src/csf-tools/CsfObject.ts b/code/core/src/csf-tools/CsfObject.ts index 08eaca6f316a..e6e473bc1b75 100644 --- a/code/core/src/csf-tools/CsfObject.ts +++ b/code/core/src/csf-tools/CsfObject.ts @@ -251,6 +251,11 @@ export interface CsfObject { group(path: readonly string[], names: readonly string[]): CsfMutationResult; } +// `CsfObject` with the mutation `ConfigFile` adds: a new array at a missing field. +export interface CsfObjectEditor extends CsfObject { + createArray(path: readonly string[], items: string[]): CsfMutationResult; +} + export interface CsfObjectOptions { meta?: boolean; stories?: boolean; @@ -502,7 +507,7 @@ const carriedSpread = ( return spreadOf(expression); }; -class CsfObjectEditor implements CsfObject { +class Editor implements CsfObjectEditor { #changed = false; readonly target: CsfObjectTarget; @@ -1132,6 +1137,4 @@ export const createCsfObject = ( host: CsfObjectHost, report: ReportDiagnostic, markChanged: MarkChanged -) => new CsfObjectEditor(target, host, report, markChanged); - -export type { CsfObjectEditor }; +): CsfObjectEditor => new Editor(target, host, report, markChanged); From 99039cbfcb7a3806466ceebe90428994a6f7fafc Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:08:27 +0200 Subject: [PATCH 292/326] fix(csf-tools): put what follows a removed item after a line comment at its indent Removing the last member after one that ends in a line comment kept the space before the closing token, so it landed at the start of the next line one space in. What follows the removed run now starts that line at the list's indent when it is the closing token, and at the removed item's indent when it is the next item. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/estree/editor.test.ts | 33 +++++++++++++++++-- code/core/src/csf-tools/estree/editor.ts | 15 ++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index 8b8f152548f8..d205e20e9f60 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -589,7 +589,7 @@ describe('removeFromList', () => { it('keeps what follows a removed item off the line of the comment before it', () => { expect(removeElement(`export default { addons: ['a', /* x */ // l\n 'b', 'c'] };`, 1)).toBe( - `export default { addons: ['a', /* x */ // l\n 'c'] };` + `export default { addons: ['a', /* x */ // l\n 'c'] };` ); expect(removeElement(`export default { addons: ['a', // l\n 'b'] };`, 1)).toBe( `export default { addons: ['a' // l\n] };` @@ -602,6 +602,35 @@ describe('removeFromList', () => { ); }); + it.each([ + [ + 'shares the line of the removed item after a line comment', + `({\n render: () => null, // custom render\n name() { return 1; } })`, + `({\n render: () => null // custom render\n})`, + ], + [ + 'is on a line of its own after a line comment', + `({\n render: () => null, // custom render\n name() { return 1; }\n})`, + `({\n render: () => null // custom render\n})`, + ], + [ + 'follows a trailing comma after a line comment', + `({\n render: () => null, // custom render\n name() { return 1; }, })`, + `({\n render: () => null, // custom render\n})`, + ], + [ + 'is on the line of a one-line list', + `({ render: () => null, name() { return 1; } })`, + `({ render: () => null })`, + ], + ])('puts the closing token at the indent of the list when it %s', (_, code, result) => { + const editor = new SourceEditor(code); + const object = (editor.program.body[0] as E.ExpressionStatement) + .expression as E.ObjectExpression; + removeFromList(editor, objectList(editor, object), [object.properties[1]]); + expect(editor.toString()).toBe(result); + }); + it.each(['\n', '\r\n'])( 'keeps what follows adjacent removed items off the line of the comment before them', (eol) => { @@ -609,7 +638,7 @@ describe('removeFromList', () => { const array = (editor.program.body[0] as E.ExportDefaultDeclaration) .declaration as E.ArrayExpression; removeFromList(editor, arrayList(editor, array), array.elements.slice(1, 3) as E.Node[]); - expect(editor.toString()).toBe(`export default [1, /* k */ // k${eol} 4,${eol}];`); + expect(editor.toString()).toBe(`export default [1, /* k */ // k${eol}4,${eol}];`); } ); }); diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 69ae0a865d6d..6abe984fa84a 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -1179,12 +1179,17 @@ export const removeFromList = (editor: SourceEditor, list: List, removedItems: r const item = items[0].start; edits.remove(start, item - /\s*$/.exec(code.slice(start, item))![0].length); edits.remove(item, end + /^\s*/.exec(code.slice(end))![0].length); + } else if ( + commentAt(editor, start, 'end')?.type === 'Line' && + code.slice(end, lineEnd(code, end)).trim() + ) { + // Code after the run on its line must not join the line of a line comment before it, so it + // starts the next line, where the run's first item started or at the list's indent. + const next = end + /^\s*/.exec(code.slice(end))![0].length; + const indent = lineIndent(code, last === items.length - 1 ? list.open : items[first].start); + edits.update(lineEnd(code, start) + 1, next, indent); } else { - // Code after the run on its line must not join the line of a line comment before it. - const joins = - commentAt(editor, start, 'end')?.type === 'Line' && - code.slice(end, lineEnd(code, end)).trim(); - edits.remove(joins ? lineEnd(code, start) + 1 : start, end); + edits.remove(start, end); } first = last; } From 0ffb2d3019c80243c9de8b81c77066ad8e34bb54 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:09:48 +0200 Subject: [PATCH 293/326] feat(csf-tools): export detachedTextOf A snippet generator that places a value from a story file at another indent needs the value's text with its lines dedented, which detachedTextOf gives and the entry point did not export. Co-Authored-By: Claude Fable 5.1 --- code/core/src/csf-tools/README.md | 2 ++ code/core/src/csf-tools/estree/editor.test.ts | 13 +++++++++++++ code/core/src/csf-tools/estree/editor.ts | 5 ++++- code/core/src/csf-tools/index.ts | 1 + 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/code/core/src/csf-tools/README.md b/code/core/src/csf-tools/README.md index f2e6cb1013bf..5f8b52d4430c 100644 --- a/code/core/src/csf-tools/README.md +++ b/code/core/src/csf-tools/README.md @@ -64,6 +64,8 @@ for (const story of csf.objects()) { } ``` +`textOf` gives a node's source as written, and `placeableTextOf` that source ready to place as a value. `detachedTextOf` also dedents it as if the node's line had no indent, to place a value from a file at another indent, such as in a generated snippet. + ## Transforming preview and main configuration `ConfigFile` implements `CsfObject` directly, exposing the same methods and mutation results as the editors returned by `CsfFile.objects()`. Config files use `{ kind: 'config' }` as their target. Default-exported objects, identifier-backed objects, `definePreview` configurations, and CommonJS `module.exports` use their config object as the root. diff --git a/code/core/src/csf-tools/estree/editor.test.ts b/code/core/src/csf-tools/estree/editor.test.ts index d205e20e9f60..ac756bae805c 100644 --- a/code/core/src/csf-tools/estree/editor.test.ts +++ b/code/core/src/csf-tools/estree/editor.test.ts @@ -8,6 +8,7 @@ import { dedent } from 'ts-dedent'; import { loadConfig, printConfig } from '../ConfigFile.ts'; import { loadCsf, printCsf } from '../CsfFile.ts'; +import { detachedTextOf } from '../index.ts'; import type { E } from './ast.ts'; import { parseExpression, parseModule } from './ast.ts'; import { @@ -643,6 +644,18 @@ describe('removeFromList', () => { ); }); +describe('detachedTextOf', () => { + it('gives the text of a value of the file as if its line had no indent', () => { + const editor = new SourceEditor( + `export default {\n args: {\n a: [\n 1, // one\n ],\n },\n};` + ); + const object = (editor.program.body[0] as E.ExportDefaultDeclaration) + .declaration as E.ObjectExpression; + const args = (object.properties[0] as E.ObjectProperty).value as E.ObjectExpression; + expect(detachedTextOf(args)).toBe(`{\n a: [\n 1, // one\n ],\n}`); + }); +}); + describe('replaceValue', () => { const firstMember = (editor: SourceEditor) => { const statement = editor.program.body[0] as E.ExportDefaultDeclaration; diff --git a/code/core/src/csf-tools/estree/editor.ts b/code/core/src/csf-tools/estree/editor.ts index 6abe984fa84a..de943a2d2908 100644 --- a/code/core/src/csf-tools/estree/editor.ts +++ b/code/core/src/csf-tools/estree/editor.ts @@ -848,7 +848,10 @@ export const detachedSource = (editor: SourceEditor, span: Span) => { return dedentSource(editor, span.start, span.end, lineIndent(editor.code, span.start)); }; -// `placeableTextOf` as `detachedSource`, for a node of `get`, `parseExpression` or any file. +/** + * `placeableTextOf` dedented as if the node's line had no indent, to place a node of `get`, + * `parseExpression` or a `SourceEditor` at another indent. + */ export const detachedTextOf = (node: Node) => reindent(placeableTextOf(node), lineIndent(moduleOf(node)!.code, node.start), ''); diff --git a/code/core/src/csf-tools/index.ts b/code/core/src/csf-tools/index.ts index 6e8e3d6cc03a..693d073343ed 100644 --- a/code/core/src/csf-tools/index.ts +++ b/code/core/src/csf-tools/index.ts @@ -38,6 +38,7 @@ export { argumentList, arrayList, declaratorList, + detachedTextOf, insertAfterImports, itemText, lineIndent, From b3835d2c75739503cbe8bc6ebb7da5c2e01e5160 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:37:31 +0200 Subject: [PATCH 294/326] fix(csf-tools): indent a value written out from a spread one level below its member objectFrom indented every line of a member value, but a value read from the story file still had the indent of its line there, so a nested object came out one level too deep, and a template literal's lines moved with it. The value is now dedented as if its line had no indent, and its lines are indented with the file's indent, except inside literals. The elements of a rebuilt array are dedented the same way. Co-Authored-By: Claude Fable 5.1 --- .../story-shape/resolve-arg-value.test.ts | 50 +++++++++++++++++++ .../story-shape/resolve-arg-value.ts | 12 +++-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/code/core/src/csf-tools/story-shape/resolve-arg-value.test.ts b/code/core/src/csf-tools/story-shape/resolve-arg-value.test.ts index aff5d0c90917..98875330216f 100644 --- a/code/core/src/csf-tools/story-shape/resolve-arg-value.test.ts +++ b/code/core/src/csf-tools/story-shape/resolve-arg-value.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { dedent } from 'ts-dedent'; + +import { textOf } from '../estree/ast.ts'; import { SourceEditor } from '../estree/editor.ts'; import { resolveArgValue } from './resolve-arg-value.ts'; import { @@ -130,6 +133,53 @@ describe('resolveArgValue', () => { }); }); + const textOfValue = (code: string) => { + const ctx = contextOf(code); + const record = resolveArgsRecord(resolveBindingMembers(ctx, 'Story')?.properties.args, ctx); + return textOf(resolveArgValue(record.properties.v, ctx).node); + }; + + it.each([ + [ + 'two levels deep', + `const base = {\n nested: {\n deep: 1,\n },\n};`, + `{\n nested: {\n deep: 1,\n },\n tone: 'x'\n}`, + ], + [ + 'three levels deep', + `const base = {\n nested: {\n deeper: {\n deepest: \`a\n b\`,\n },\n },\n};`, + `{\n nested: {\n deeper: {\n deepest: \`a\n b\`,\n },\n },\n tone: 'x'\n}`, + ], + [ + 'indented with tabs', + `const base = {\n\tnested: {\n\t\tdeep: \`a\n\tb\`,\n\t},\n};`, + `{\n\tnested: {\n\t\tdeep: \`a\n\tb\`,\n\t},\n\ttone: 'x'\n}`, + ], + ])( + "writes out a spread's members %s at the file's indent, leaving literal lines alone", + (_, code, text) => { + const story = `export const Story = { args: { v: { ...base, tone: 'x' } } };`; + expect(textOfValue(`${code}\n${story}`)).toBe(text); + } + ); + + it('writes out the elements of an array holding a spread as if their lines had no indent', () => { + const code = dedent` + const base = { a: 1 }; + export const Story = { + args: { + v: [ + { ...base }, + { + z: 2, + }, + ], + }, + }; + `; + expect(textOfValue(code)).toBe(`[{\n a: 1\n}, {\n z: 2,\n}]`); + }); + it('names nothing for a global or a parameter the expression declares itself', () => { expect(valueOf('', '(event) => Math.max(event.x, 0)')).toEqual({ node: '(event) => Math.max(event.x, 0)', diff --git a/code/core/src/csf-tools/story-shape/resolve-arg-value.ts b/code/core/src/csf-tools/story-shape/resolve-arg-value.ts index ba7e2af3951f..63a5d476f75c 100644 --- a/code/core/src/csf-tools/story-shape/resolve-arg-value.ts +++ b/code/core/src/csf-tools/story-shape/resolve-arg-value.ts @@ -11,6 +11,7 @@ import { textOf, unwrapExpression, } from '../estree/ast.ts'; +import { detachedTextOf, reindent } from '../estree/editor.ts'; import { analyzeScopes } from '../estree/scope.ts'; import { type ImportRef } from './import-statements.ts'; import { type ImportBinding, collectImportBindings } from './imports.ts'; @@ -101,9 +102,9 @@ const inlineSpreads = (node: Node, ctx: ReferenceContext, unresolved: string[]): .map((element) => element === null ? '' - : element.type === 'SpreadElement' - ? textOf(element) - : placeableTextOf(inlineSpreads(element, ctx, unresolved)) + : detachedTextOf( + element.type === 'SpreadElement' ? element : inlineSpreads(element, ctx, unresolved) + ) ) .join(', ')}]` ); @@ -187,13 +188,14 @@ const objectFrom = ( // A shorthand prints its key and nothing else, so it may only stay shorthand while its value is // still the one the key stands for. const shorthand = 'type' in property && property.shorthand && value === property.value; - members.push(shorthand ? key : `${key}: ${placeableTextOf(value)}`); + members.push(shorthand ? key : `${key}: ${detachedTextOf(value)}`); } // One member per line, so formatters keep the object expanded. + const { indent } = ctx.editor; return rebuilt( members.length === 0 ? '{}' - : `{\n${members.map((member) => ` ${member.replaceAll('\n', '\n ')}`).join(',\n')}\n}` + : `{\n${members.map((member) => `${indent}${reindent(member, '', indent)}`).join(',\n')}\n}` ); }; From 209b3fb450963574368557d05c9c5909d13d9776 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:42:03 +0200 Subject: [PATCH 295/326] fix(react): print a string arg JSX cannot hold as an expression A JSX attribute string has no escapes, decodes entities and folds line breaks, and JSX text also cannot hold braces or tags. A string arg with an ampersand or a line break became a different attribute value, and children with braces, angle brackets or an ampersand became different JSX. Those strings now print as an expression, as one with a double quote already did. Co-Authored-By: Claude Fable 5.1 --- .../generateCodeSnippet.test.tsx | 24 +++++++++++++++++++ .../componentManifest/generateCodeSnippet.ts | 7 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index c17cca09a31c..c5df1c09657f 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -975,3 +975,27 @@ test('an arg value keeps a shorthand property shorthand', () => { ` ); }); + +test('a string arg prints as an attribute only when JSX reads it back unchanged', () => { + const input = withCSF3(dedent` + export const Strings: Story = { + args: { quote: 'say "hi"', entity: 'a & b', lines: 'a\\nb', path: 'C:\\\\temp' }, + }; + `); + expect(generateExample(input)).toMatchInlineSnapshot( + `"const Strings = () => ;"` + ); +}); + +test('children that JSX text cannot hold stay an expression', () => { + const input = withCSF3(dedent` + export const Braces: Story = { args: { children: 'a {b} ' } }; + export const Entity: Story = { args: { children: 'a & b' } }; + export const Lines: Story = { args: { children: 'a\\nb' } }; + `); + expect(generateExample(input)).toMatchInlineSnapshot(` + "const Braces = () => ; + const Entity = () => ; + const Lines = () => ;" + `); +}); diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts index 013f31335d50..30b6318c8394 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.ts +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.ts @@ -259,7 +259,9 @@ const toAttr = (key: string, value: Node) => { if (value.type === 'Literal' && typeof value.value === 'boolean') { return value.value ? key : `${key}={false}`; } - if (isStringLiteral(value) && !value.value.includes('"')) { + // A JSX attribute string has no escapes, decodes entities and folds line breaks, so a value it + // cannot hold as written stays an expression. + if (isStringLiteral(value) && !/["&\r\n]/.test(value.value)) { return `${key}="${value.value}"`; } return `${key}={${placeableTextOf(value)}}`; @@ -279,10 +281,11 @@ function injectedAttributes(merged: Args, existing: ReadonlySet): string return attrs; } +// JSX text cannot hold braces or tags, decodes entities and folds line breaks. const toJsxChildren = (node: Node | null | undefined) => !node ? '' - : isStringLiteral(node) + : isStringLiteral(node) && !/[{}<>&\r\n]/.test(node.value) ? node.value : isJsx(node) ? textOf(node) From 633887306f7a85df17620cb0776e9914413b81e6 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Tue, 20 Oct 2026 14:42:06 +0200 Subject: [PATCH 296/326] fix(react): read only the story's own args into a snippet `key in merged` also holds for a name every object inherits, so a render reading `args.constructor` took Object as an arg and threw. Only an own property of the args is inlined now, and an own arg with an empty name, read as `args['']`, is inlined too. Co-Authored-By: Claude Fable 5.1 --- .../componentManifest/generateCodeSnippet.test.tsx | 11 +++++++++++ .../src/componentManifest/generateCodeSnippet.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx index c5df1c09657f..6d7667bf34fc 100644 --- a/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx +++ b/code/renderers/react/src/componentManifest/generateCodeSnippet.test.tsx @@ -999,3 +999,14 @@ test('children that JSX text cannot hold stay an expression', () => { const Lines = () => ;" `); }); + +test('a read of a name every object inherits is not an arg', () => { + const input = withCSF3(dedent` + export const Inherited: Story = { + render: (args) => ', + 'panel-part': 'color: rebeccapurple;', + '--slot-panel-color': 'teal', + }, +}); +``` + +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" tabTitle="CSF Next 🧪" +import preview from '../.storybook/preview'; + +import './lit-slots-and-css'; + +const meta = preview.meta({ + component: 'lit-slots-and-css', +}); + +export const Basic = meta.story({ + args: { + heading: 'Hello', + 'default-slot': 'Body text', + 'actions-slot': '', + 'panel-part': 'color: rebeccapurple;', + '--slot-panel-color': 'teal', + }, +}); +``` + +```js filename="lit-slots-and-css.stories.js" renderer="web-components" language="js" tabTitle="CSF 3" import './lit-slots-and-css'; export default { @@ -16,7 +56,7 @@ export const Basic = { }; ``` -```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" tabTitle="CSF 3" import type { Meta, StoryObj } from '@storybook/web-components-vite'; import './lit-slots-and-css'; diff --git a/docs/_snippets/web-components-story-snippet-listeners.md b/docs/_snippets/web-components-story-snippet-listeners.md index 84db8e0ad49c..de170a5716cc 100644 --- a/docs/_snippets/web-components-story-snippet-listeners.md +++ b/docs/_snippets/web-components-story-snippet-listeners.md @@ -1,4 +1,38 @@ -```js filename="lit-events.stories.js" renderer="web-components" language="js" +```js filename="lit-events.stories.js" renderer="web-components" language="js" tabTitle="CSF Next 🧪" +import preview from '../.storybook/preview'; + +import './lit-events'; + +const meta = preview.meta({ + component: 'lit-events', +}); + +export const WithListener = meta.story({ + args: { + value: 'changed', + 'my-change-event': () => {}, + }, +}); +``` + +```ts filename="lit-events.stories.ts" renderer="web-components" language="ts" tabTitle="CSF Next 🧪" +import preview from '../.storybook/preview'; + +import './lit-events'; + +const meta = preview.meta({ + component: 'lit-events', +}); + +export const WithListener = meta.story({ + args: { + value: 'changed', + 'my-change-event': () => {}, + }, +}); +``` + +```js filename="lit-events.stories.js" renderer="web-components" language="js" tabTitle="CSF 3" import './lit-events'; export default { @@ -13,7 +47,7 @@ export const WithListener = { }; ``` -```ts filename="lit-events.stories.ts" renderer="web-components" language="ts" +```ts filename="lit-events.stories.ts" renderer="web-components" language="ts" tabTitle="CSF 3" import type { Meta, StoryObj } from '@storybook/web-components-vite'; import './lit-events'; diff --git a/docs/_snippets/web-components-story-snippet-template.md b/docs/_snippets/web-components-story-snippet-template.md index 2fd6e798c18f..3dcaae3092ef 100644 --- a/docs/_snippets/web-components-story-snippet-template.md +++ b/docs/_snippets/web-components-story-snippet-template.md @@ -1,4 +1,48 @@ -```js filename="lit-slots-and-css.stories.js" renderer="web-components" language="js" +```js filename="lit-slots-and-css.stories.js" renderer="web-components" language="js" tabTitle="CSF Next 🧪" +import preview from '../.storybook/preview'; +import { html } from 'lit'; + +import './lit-slots-and-css'; + +const meta = preview.meta({ + component: 'lit-slots-and-css', +}); + +export const CustomMarkup = meta.story({ + render: (args) => + html` +

Default slot content

+ +
`, + args: { + heading: 'Hello', + }, +}); +``` + +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" tabTitle="CSF Next 🧪" +import preview from '../.storybook/preview'; +import { html } from 'lit'; + +import './lit-slots-and-css'; + +const meta = preview.meta({ + component: 'lit-slots-and-css', +}); + +export const CustomMarkup = meta.story({ + render: (args) => + html` +

Default slot content

+ +
`, + args: { + heading: 'Hello', + }, +}); +``` + +```js filename="lit-slots-and-css.stories.js" renderer="web-components" language="js" tabTitle="CSF 3" import { html } from 'lit'; import './lit-slots-and-css'; @@ -9,17 +53,17 @@ export default { export const CustomMarkup = { render: (args) => - html`

Default slot content

-
`, + html` +

Default slot content

+ +
`, args: { heading: 'Hello', }, }; ``` -```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" +```ts filename="lit-slots-and-css.stories.ts" renderer="web-components" language="ts" tabTitle="CSF 3" import { html } from 'lit'; import type { Meta, StoryObj } from '@storybook/web-components-vite'; @@ -34,10 +78,10 @@ type Story = StoryObj; export const CustomMarkup: Story = { render: (args) => - html`

Default slot content

-
`, + html` +

Default slot content

+ +
`, args: { heading: 'Hello', }, diff --git a/docs/get-started/frameworks/web-components-vite.mdx b/docs/get-started/frameworks/web-components-vite.mdx index 5975dbc5ddc2..fbe5a5d2c782 100644 --- a/docs/get-started/frameworks/web-components-vite.mdx +++ b/docs/get-started/frameworks/web-components-vite.mdx @@ -46,12 +46,16 @@ Storybook for Web components with Vite needs no extra configuration. It reads yo ### Writing stories -In your story file, set `component` to the element's tag name and import the module that defines the element. Storybook then finds the element's attributes, properties, events, slots, CSS custom properties, CSS parts, and CSS states. It uses them to create controls and [automatic documentation](../../writing-docs/autodocs.mdx), and it shows your JSDoc comments as descriptions. +In your story file, set the `component` property to the element's tag name and import the module that defines the element. Storybook then finds the element's attributes, properties, events, slots, CSS custom properties, CSS parts, and CSS states. It uses them to create [controls](../../essentials/controls.mdx) and automatic documentation, and it shows your JSDoc comments as descriptions. By default, Storybook renders the element from the story's args: +To listen to an event, set its arg to a function: + + + Each arg key tells Storybook which part of the element to set. Keys for events, slots, CSS parts, and CSS states end in a suffix, so they do not conflict with attribute names: | Element API | Arg key | Example | Result | @@ -69,17 +73,13 @@ To write the markup yourself, add a `render` function that returns a Lit `html` -To listen to an event, set its arg to a function: - - - -Storybook finds your elements by following the imports in your story files and preview file. If it cannot find or read an element, the Controls table shows a message, and so does `npx storybook tools docs show`. The build does not fail. +Storybook finds your elements by following the imports in your story files and preview file. If it cannot find or read an element, the Controls table shows a message, as will agents using the Storybook's [AI tools](../../ai/index.mdx). The build does not fail. -A story file without `component` gets no automatic controls. The `Button`, `Header`, and `Page` example stories that Storybook adds to a new project work this way: they render through a template function, and the Code panel shows the story's source code. +A story file with no `component` defined gets no automatic controls. The `Button`, `Header`, and `Page` example stories that Storybook adds to a new project work this way: they render through a template function, and the Code panel shows the story's source code. ### Code snippets -Autodocs and the Code panel show HTML that Storybook generates from your story. For a story that uses args, the snippet is one element with its attributes and slot content, plus a `