diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 406d4c1f2dc9..226ee2b8ad91 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -4,6 +4,7 @@ on: push: branches: - next + # TODO use pull_request_target in the future to also run on forks pull_request: types: [opened, synchronize, labeled, reopened] schedule: @@ -21,6 +22,7 @@ jobs: nx: if: > (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.labels.*.name, 'ci:normal') || contains(github.event.pull_request.labels.*.name, 'ci:merged') || contains(github.event.pull_request.labels.*.name, 'ci:daily')) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index ada2157213bd..13aba8e45642 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,9 @@ +## 10.2.0-alpha.12 + +- Addon-docs: Add MDX manifest generation - [#33408](https://github.com/storybookjs/storybook/pull/33408), thanks @copilot-swe-agent! +- AddonVitest: Improve perf & fix loading incorrect `.env` file - [#33469](https://github.com/storybookjs/storybook/pull/33469), thanks @ndelangen! +- Core: Add `getStoryHrefs` manager API and add hotkey for "open in isolation" - [#33416](https://github.com/storybookjs/storybook/pull/33416), thanks @ghengeveld! + ## 10.2.0-alpha.11 - Core: Add try-catch for cross-origin access in Storybook hooks - [#33448](https://github.com/storybookjs/storybook/pull/33448), thanks @ndelangen! diff --git a/code/addons/docs/docs/frameworks/COMMON.md b/code/addons/docs/docs/frameworks/COMMON.md index 4a43dfe641de..016a853c2eaa 100644 --- a/code/addons/docs/docs/frameworks/COMMON.md +++ b/code/addons/docs/docs/frameworks/COMMON.md @@ -28,7 +28,7 @@ export default { ## DocsPage -When you [install docs](#installation) you should get basic [DocsPage](../docs/docspage.md) documentation automagically for all your stories, available in the `Docs` tab of the Storybook UI. +When you [install docs](#installation) you should get basic [DocsPage](../docs/docspage.md) documentation automatically for all your stories, available in the `Docs` tab of the Storybook UI. ## MDX diff --git a/code/addons/docs/docs/frameworks/REACT.md b/code/addons/docs/docs/frameworks/REACT.md index 93bb00f80513..97bae519db7b 100644 --- a/code/addons/docs/docs/frameworks/REACT.md +++ b/code/addons/docs/docs/frameworks/REACT.md @@ -37,7 +37,7 @@ export default { ## DocsPage -When you [install docs](#installation) you should get basic [DocsPage](../docs/docspage.md) documentation automagically for all your stories, available in the `Docs` tab of the Storybook UI. +When you [install docs](#installation) you should get basic [DocsPage](../docs/docspage.md) documentation automatically for all your stories, available in the `Docs` tab of the Storybook UI. ## Props tables diff --git a/code/addons/docs/src/blocks/blocks/Stories.tsx b/code/addons/docs/src/blocks/blocks/Stories.tsx index 495183304a08..e6760638d5f5 100644 --- a/code/addons/docs/src/blocks/blocks/Stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Stories.tsx @@ -1,6 +1,8 @@ import type { FC, ReactElement } from 'react'; import React, { useContext } from 'react'; +import { Tag } from 'storybook/internal/preview-api'; + import { styled } from 'storybook/theming'; import { DocsContext } from './DocsContext'; @@ -43,11 +45,11 @@ export const Stories: FC = ({ title = 'Stories', includePrimary = // The new behavior here is that if NONE of the stories in the autodocs page are tagged // with 'autodocs', we show all stories. If ANY of the stories have autodocs then we use // the new behavior. - const hasAutodocsTaggedStory = stories.some((story) => story.tags?.includes('autodocs')); + const hasAutodocsTaggedStory = stories.some((story) => story.tags?.includes(Tag.AUTODOCS)); if (hasAutodocsTaggedStory) { // Don't show stories where mount is used in docs. // As the play function is not running in docs, and when mount is used, the mounting is happening in play itself. - stories = stories.filter((story) => story.tags?.includes('autodocs') && !story.usesMount); + stories = stories.filter((story) => story.tags?.includes(Tag.AUTODOCS) && !story.usesMount); } if (!includePrimary) { diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx index aa92719b802a..f657c2f3dc63 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import React from 'react'; import type { FC, PropsWithChildren } from 'react'; +import { Tag } from 'storybook/internal/core-server'; import type { PreparedStory } from 'storybook/internal/types'; import type { DocsContextProps } from './DocsContext'; @@ -12,9 +13,9 @@ import { DocsContext } from './DocsContext'; import { usePrimaryStory } from './usePrimaryStory'; const stories: Record> = { - story1: { name: 'Story One', tags: ['!autodocs'] }, - story2: { name: 'Story Two', tags: ['autodocs'] }, - story3: { name: 'Story Three', tags: ['autodocs'] }, + story1: { name: 'Story One', tags: [`!${Tag.AUTODOCS}`] }, + story2: { name: 'Story Two', tags: [Tag.AUTODOCS] }, + story3: { name: 'Story Three', tags: [Tag.AUTODOCS] }, story4: { name: 'Story Four', tags: [] }, }; diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts index fd177287023e..75d085a69b17 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts @@ -1,5 +1,6 @@ import { useContext } from 'react'; +import { Tag } from 'storybook/internal/preview-api'; import type { PreparedStory } from 'storybook/internal/types'; import { DocsContext } from './DocsContext'; @@ -11,5 +12,5 @@ import { DocsContext } from './DocsContext'; export const usePrimaryStory = (): PreparedStory | undefined => { const context = useContext(DocsContext); const stories = context.componentStories(); - return stories.find((story) => story.tags.includes('autodocs')); + return stories.find((story) => story.tags.includes(Tag.AUTODOCS)); }; diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index b43dd30519a3..edaeb88a9876 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -260,7 +260,6 @@ export const Preview: FC = ({ zoom={(z: number) => setScale(scale * z)} resetZoom={() => setScale(1)} storyId={!isLoading && childProps ? getStoryId(childProps, context) : undefined} - baseUrl="./iframe.html" /> )} diff --git a/code/addons/docs/src/blocks/components/Story.tsx b/code/addons/docs/src/blocks/components/Story.tsx index 5af081fce655..144d14445fba 100644 --- a/code/addons/docs/src/blocks/components/Story.tsx +++ b/code/addons/docs/src/blocks/components/Story.tsx @@ -2,17 +2,15 @@ import type { FunctionComponent } from 'react'; import React, { useEffect, useRef, useState } from 'react'; -import { ErrorFormatter, Loader, getStoryHref } from 'storybook/internal/components'; +import { ErrorFormatter, Loader } from 'storybook/internal/components'; import type { DocsContextProps, PreparedStory } from 'storybook/internal/types'; import { styled } from 'storybook/theming'; +import { getStoryHref } from '../getStoryHref'; import { IFrame } from './IFrame'; import { ZoomContext } from './ZoomContext'; -const { PREVIEW_URL } = globalThis; -const BASE_URL = PREVIEW_URL || 'iframe.html'; - interface CommonProps { story: PreparedStory; inline: boolean; @@ -98,7 +96,7 @@ const IFrameStory: FunctionComponent = ({ story, height = '500 key="iframe" id={`iframe--${story.id}`} title={story.name} - src={getStoryHref(BASE_URL, story.id, { viewMode: 'story' })} + src={getStoryHref(story.id, { viewMode: 'story' })} allowFullScreen scale={scale} style={{ diff --git a/code/addons/docs/src/blocks/components/Toolbar.tsx b/code/addons/docs/src/blocks/components/Toolbar.tsx index 18713fc474fc..afebccb47b68 100644 --- a/code/addons/docs/src/blocks/components/Toolbar.tsx +++ b/code/addons/docs/src/blocks/components/Toolbar.tsx @@ -1,12 +1,14 @@ import type { FC, SyntheticEvent } from 'react'; import React from 'react'; -import { Button, Toolbar as SharedToolbar, getStoryHref } from 'storybook/internal/components'; +import { Button, Toolbar as SharedToolbar } from 'storybook/internal/components'; import { ShareAltIcon, ZoomIcon, ZoomOutIcon, ZoomResetIcon } from '@storybook/icons'; import { styled } from 'storybook/theming'; +import { getStoryHref } from '../getStoryHref'; + interface ZoomProps { zoom: (val: number) => void; resetZoom: () => void; @@ -14,7 +16,6 @@ interface ZoomProps { interface EjectProps { storyId?: string; - baseUrl?: string; } interface BarProps { @@ -52,14 +53,7 @@ const IconPlaceholder = styled.div(({ theme }) => ({ animation: `${theme.animation.glow} 1.5s ease-in-out infinite`, })); -export const Toolbar: FC = ({ - isLoading, - storyId, - baseUrl, - zoom, - resetZoom, - ...rest -}) => ( +export const Toolbar: FC = ({ isLoading, storyId, zoom, resetZoom, ...rest }) => ( {isLoading ? ( @@ -111,7 +105,6 @@ export const Toolbar: FC = ({ ) : ( - baseUrl && storyId && ( diff --git a/code/addons/docs/src/blocks/getStoryHref.ts b/code/addons/docs/src/blocks/getStoryHref.ts new file mode 100644 index 000000000000..524fbc43f613 --- /dev/null +++ b/code/addons/docs/src/blocks/getStoryHref.ts @@ -0,0 +1,17 @@ +/** + * Only for internal use in addon-docs code, because the parent util in `core` cannot be imported. + * Unlike the parent util, this one only returns the preview URL. + */ +export const getStoryHref = (storyId: string, additionalParams: Record = {}) => { + const baseUrl = globalThis.PREVIEW_URL || 'iframe.html'; + const [url, paramsStr] = baseUrl.split('?'); + const params = new URLSearchParams(paramsStr || ''); + + Object.entries(additionalParams).forEach(([key, value]) => { + params.set(key, value); + }); + + params.set('id', storyId); + + return `${url}?${params.toString()}`; +}; diff --git a/code/addons/docs/src/manifest.test.ts b/code/addons/docs/src/manifest.test.ts new file mode 100644 index 000000000000..2f020dc64382 --- /dev/null +++ b/code/addons/docs/src/manifest.test.ts @@ -0,0 +1,362 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Tag } from 'storybook/internal/core-server'; +import type { DocsIndexEntry, IndexEntry } from 'storybook/internal/types'; + +import { vol } from 'memfs'; + +import type { DocsManifestEntry } from './manifest'; +import { manifests } from './manifest'; + +vi.mock('node:fs/promises', async () => { + const memfs = await vi.importActual('memfs'); + return memfs.fs.promises; +}); + +beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue('/app'); + vol.fromJSON( + { + './Example.mdx': '# Example\n\nThis is example documentation.', + './Standalone.mdx': '# Standalone\n\nThis is standalone documentation.', + }, + '/app' + ); +}); + +interface DocsManifest { + v: number; + docs: Record; +} + +interface ComponentManifestWithDocs { + id: string; + path: string; + name: string; + stories: unknown[]; + jsDocTags: Record; + docs?: Record; +} + +interface ComponentsManifestWithDocs { + v: number; + components: Record; +} + +interface ManifestResult { + docs?: DocsManifest; + components?: ComponentsManifestWithDocs; +} + +describe('experimental_manifests', () => { + it('should return existing manifests when no docs entries are found', async () => { + const existingManifests = { + components: { + v: 0, + components: { + 'example-component': { + id: 'example-component', + path: './Example.stories.tsx', + name: 'Example', + stories: [], + jsDocTags: {}, + }, + }, + }, + }; + const manifestEntries: IndexEntry[] = [ + { + id: 'example--story', + name: 'Story', + title: 'Example', + type: 'story', + subtype: 'story', + importPath: './Example.stories.tsx', + tags: [Tag.MANIFEST], + }, + ]; + + const result = (await manifests(existingManifests, { + manifestEntries, + } as any)) as ManifestResult; + + expect(result).toEqual(existingManifests); + }); + + it('should drop docs entries without attached-mdx or unattached-mdx tags', async () => { + const existingManifests = { + components: { + v: 0, + components: { + example: { + id: 'example', + path: './Example.stories.tsx', + name: 'Example', + stories: [], + jsDocTags: {}, + }, + }, + }, + }; + const manifestEntries: IndexEntry[] = [ + { + id: 'example--docs', + name: 'docs', + title: 'Example', + type: 'docs', + importPath: './Example.mdx', + tags: [Tag.MANIFEST, Tag.AUTODOCS], // No attached-mdx or unattached-mdx tag + storiesImports: ['./Example.stories.tsx'], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(existingManifests, { + manifestEntries, + } as any)) as ManifestResult; + + // Should return existing manifests unchanged since docs entry was dropped + expect(result).toEqual(existingManifests); + expect(result.components?.components.example.docs).toBeUndefined(); + }); + + it('should add attached docs entries to component manifests', async () => { + const existingManifests = { + components: { + v: 0, + components: { + example: { + id: 'example', + path: './Example.stories.tsx', + name: 'Example', + stories: [], + jsDocTags: {}, + }, + }, + }, + }; + const manifestEntries: IndexEntry[] = [ + { + id: 'example--docs', + name: 'docs', + title: 'Example', + type: 'docs', + importPath: './Example.mdx', + tags: [Tag.MANIFEST, Tag.ATTACHED_MDX], + storiesImports: ['./Example.stories.tsx'], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(existingManifests, { + manifestEntries, + } as any)) as ManifestResult; + + expect(result).toHaveProperty('components'); + expect(result).not.toHaveProperty('docs'); + expect(result.components?.components.example.docs).toEqual({ + 'example--docs': { + id: 'example--docs', + name: 'docs', + path: './Example.mdx', + title: 'Example', + content: '# Example\n\nThis is example documentation.', + }, + }); + }); + + it('should generate docs manifest for unattached-mdx entries', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(undefined, { manifestEntries } as any)) as ManifestResult; + + expect(result).toHaveProperty('docs'); + expect(result.docs).toEqual({ + v: 0, + docs: { + 'standalone--docs': { + id: 'standalone--docs', + name: 'docs', + path: './Standalone.mdx', + title: 'Standalone', + content: '# Standalone\n\nThis is standalone documentation.', + }, + }, + }); + }); + + it('should handle both attached and unattached docs entries separately', async () => { + const existingManifests = { + components: { + v: 0, + components: { + example: { + id: 'example', + path: './Example.stories.tsx', + name: 'Example', + stories: [], + jsDocTags: {}, + }, + }, + }, + }; + const manifestEntries: IndexEntry[] = [ + { + id: 'example--docs', + name: 'docs', + title: 'Example', + type: 'docs', + importPath: './Example.mdx', + tags: [Tag.MANIFEST, Tag.ATTACHED_MDX], + storiesImports: ['./Example.stories.tsx'], + } satisfies DocsIndexEntry, + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(existingManifests, { + manifestEntries, + } as any)) as ManifestResult; + + // Unattached docs should be in the docs manifest + expect(result).toHaveProperty('docs'); + expect(result.docs?.docs).toHaveProperty('standalone--docs'); + expect(Object.keys(result.docs?.docs ?? {})).toHaveLength(1); + expect(result.docs?.docs['standalone--docs'].content).toBe( + '# Standalone\n\nThis is standalone documentation.' + ); + + // Attached docs should be in the component manifest + expect(result.components?.components.example.docs).toEqual({ + 'example--docs': { + id: 'example--docs', + name: 'docs', + path: './Example.mdx', + title: 'Example', + content: '# Example\n\nThis is example documentation.', + }, + }); + }); + + it('should preserve existing manifests and add unattached docs', async () => { + const existingManifests = { + components: { + v: 0, + components: { + example: { + id: 'example', + path: './Example.stories.tsx', + name: 'Example', + stories: [], + jsDocTags: {}, + }, + }, + }, + }; + const manifestEntries: IndexEntry[] = [ + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(existingManifests, { + manifestEntries, + } as any)) as ManifestResult; + + expect(result).toHaveProperty('components'); + expect(result).toHaveProperty('docs'); + // Components should be preserved (no docs added since no attached docs entries) + expect(result.components?.components.example.docs).toBeUndefined(); + }); + + it('should include error when file cannot be read for unattached docs', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'missing--docs', + name: 'docs', + title: 'Missing', + type: 'docs', + importPath: './NonExistent.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(undefined, { manifestEntries } as any)) as ManifestResult; + + expect(result).toHaveProperty('docs'); + expect(result.docs?.docs['missing--docs']).toEqual({ + id: 'missing--docs', + name: 'docs', + path: './NonExistent.mdx', + title: 'Missing', + error: { + name: 'Error', + message: expect.stringContaining('ENOENT'), + }, + }); + expect(result.docs?.docs['missing--docs'].content).toBeUndefined(); + }); + + it('should handle mixed success and error entries for unattached docs', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + { + id: 'missing--docs', + name: 'docs', + title: 'Missing', + type: 'docs', + importPath: './NonExistent.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(undefined, { manifestEntries } as any)) as ManifestResult; + + expect(result).toHaveProperty('docs'); + expect(Object.keys(result.docs?.docs ?? {})).toHaveLength(2); + + // Successful entry + expect(result.docs?.docs['standalone--docs'].content).toBe( + '# Standalone\n\nThis is standalone documentation.' + ); + expect(result.docs?.docs['standalone--docs'].error).toBeUndefined(); + + // Failed entry + expect(result.docs?.docs['missing--docs'].content).toBeUndefined(); + expect(result.docs?.docs['missing--docs'].error).toEqual({ + name: 'Error', + message: expect.stringContaining('ENOENT'), + }); + }); +}); diff --git a/code/addons/docs/src/manifest.ts b/code/addons/docs/src/manifest.ts new file mode 100644 index 000000000000..59e4efa4af5b --- /dev/null +++ b/code/addons/docs/src/manifest.ts @@ -0,0 +1,183 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { groupBy } from 'storybook/internal/common'; +import { Tag } from 'storybook/internal/core-server'; +import { logger } from 'storybook/internal/node-logger'; +import type { + ComponentManifest, + DocsIndexEntry, + IndexEntry, + Manifests, + Path, + PresetPropertyFn, + StorybookConfigRaw, +} from 'storybook/internal/types'; + +export interface DocsManifestEntry { + id: string; + name: string; + path: Path; + title: string; + content?: string; + error?: { name: string; message: string }; +} + +interface DocsManifest { + v: number; + docs: Record; +} + +interface ComponentManifestWithDocs extends ComponentManifest { + docs?: Record; +} + +interface ComponentsManifestWithDocs { + v: number; + components: Record; +} + +interface ManifestsWithDocs extends Manifests { + docs?: DocsManifest; + components?: ComponentsManifestWithDocs; +} + +/** Converts a DocsIndexEntry to a DocsManifestEntry by reading its file content. */ +export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise { + const absolutePath = path.join(process.cwd(), entry.importPath); + try { + const content = await fs.readFile(absolutePath, 'utf-8'); + return { + id: entry.id, + name: entry.name, + path: entry.importPath, + title: entry.title, + content, + }; + } catch (err) { + return { + id: entry.id, + name: entry.name, + path: entry.importPath, + title: entry.title, + error: { + name: err instanceof Error ? err.name : 'Error', + message: err instanceof Error ? err.message : String(err), + }, + }; + } +} + +/** + * Extracts unattached MDX entries (standalone docs not attached to any component). These are added + * to a separate `docs` manifest. + */ +export async function extractUnattachedDocsEntries( + entries: DocsIndexEntry[] +): Promise> { + if (entries.length === 0) { + return {}; + } + const entriesWithContent = await Promise.all(entries.map(createDocsManifestEntry)); + return Object.fromEntries(entriesWithContent.map((entry) => [entry.id, entry])); +} + +/** + * Extracts attached docs entries by adding them to their corresponding component manifests. + * + * Returns the updated components manifest with docs added to each component. + */ +export async function extractAttachedDocsEntries( + entries: DocsIndexEntry[], + existingComponents: ComponentsManifestWithDocs | undefined +): Promise { + if (!existingComponents || entries.length === 0) { + return existingComponents; + } + + const entriesWithContent = await Promise.all(entries.map(createDocsManifestEntry)); + + // Add docs to their corresponding components based on the entry id prefix + for (const docsEntry of entriesWithContent) { + const componentId = docsEntry.id.split('--')[0]; + + const component = existingComponents.components[componentId]; + if (component) { + if (!component.docs) { + component.docs = {}; + } + component.docs[docsEntry.id] = docsEntry; + } + } + + return existingComponents; +} + +/** + * Generates a manifest of docs entries. This extends the existing manifest system to include docs + * entries. + * + * - Unattached MDX entries (with 'unattached-mdx' tag) are added to a separate `docs` manifest. + * - Attached MDX entries (with 'attached-mdx' tag) are added to their corresponding component + * manifests under a `docs` property. + * - Docs entries without either tag are ignored. + */ +export const manifests: PresetPropertyFn< + 'experimental_manifests', + StorybookConfigRaw, + { manifestEntries: IndexEntry[] } +> = async (existingManifests = {}, { manifestEntries }) => { + const startPerformance = performance.now(); + + const docsEntries = manifestEntries.filter( + (entry): entry is DocsIndexEntry => entry.type === 'docs' + ); + + if (docsEntries.length === 0) { + return existingManifests; + } + + const { attachedEntries = [], unattachedEntries = [] } = groupBy(docsEntries, (entry) => { + switch (true) { + case entry.tags?.includes(Tag.UNATTACHED_MDX): + return 'unattachedEntries'; + case entry.tags?.includes(Tag.ATTACHED_MDX): + return 'attachedEntries'; + default: + return 'ignored'; + } + }); + + if (unattachedEntries.length === 0 && attachedEntries.length === 0) { + return existingManifests; + } + + const existingManifestsWithDocs = existingManifests as ManifestsWithDocs; + + const [unattachedDocs, updatedComponents] = await Promise.all([ + extractUnattachedDocsEntries(unattachedEntries), + extractAttachedDocsEntries(attachedEntries, existingManifestsWithDocs.components), + ]); + + const processedCount = unattachedEntries.length + attachedEntries.length; + logger.verbose( + `Docs manifest generation took ${performance.now() - startPerformance}ms for ${processedCount} entries (${unattachedEntries.length} unattached, ${attachedEntries.length} attached)` + ); + + const result = { ...existingManifestsWithDocs }; + + // Add unattached docs to the docs manifest + if (Object.keys(unattachedDocs).length > 0) { + result.docs = { + v: 0, + docs: unattachedDocs, + }; + } + + // Update the components manifest with attached docs + if (updatedComponents) { + result.components = updatedComponents; + } + + return result; +}; diff --git a/code/addons/docs/src/preset.ts b/code/addons/docs/src/preset.ts index ce4cab9a4ffe..5aaeca9b66ed 100644 --- a/code/addons/docs/src/preset.ts +++ b/code/addons/docs/src/preset.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from 'node:url'; import { logger } from 'storybook/internal/node-logger'; import type { Options, PresetProperty, StorybookConfigRaw } from 'storybook/internal/types'; -import { type CsfEnricher } from 'storybook/internal/types'; import type { CsfPluginOptions } from '@storybook/csf-plugin'; @@ -219,3 +218,4 @@ const optimizeViteDeps = [ ]; export { webpackX as webpack, docsX as docs, optimizeViteDeps }; +export { manifests as experimental_manifests } from './manifest'; diff --git a/code/addons/vitest/src/manager.tsx b/code/addons/vitest/src/manager.tsx index f12c41fe95d7..14e4e0815f2d 100644 --- a/code/addons/vitest/src/manager.tsx +++ b/code/addons/vitest/src/manager.tsx @@ -8,7 +8,7 @@ import { store, testProviderStore, } from '#manager-store'; -import { addons } from 'storybook/manager-api'; +import { Tag, addons } from 'storybook/manager-api'; import { GlobalErrorContext, GlobalErrorModal } from './components/GlobalErrorModal'; import { SidebarContextMenu } from './components/SidebarContextMenu'; @@ -95,7 +95,7 @@ addons.register(ADDON_ID, (api) => { if (context.type === 'docs') { return null; } - if (context.type === 'story' && !context.tags.includes('test')) { + if (context.type === 'story' && !context.tags.includes(Tag.TEST)) { return null; } return ; diff --git a/code/addons/vitest/src/node/test-manager.test.ts b/code/addons/vitest/src/node/test-manager.test.ts index 0845f8dc8bd2..3a5668e645f3 100644 --- a/code/addons/vitest/src/node/test-manager.test.ts +++ b/code/addons/vitest/src/node/test-manager.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel, type ChannelTransport } from 'storybook/internal/channels'; -import { experimental_MockUniversalStore } from 'storybook/internal/core-server'; +import { Tag, experimental_MockUniversalStore } from 'storybook/internal/core-server'; import type { Options, StatusStoreByTypeId, @@ -115,7 +115,7 @@ global.fetch = vi.fn().mockResolvedValue({ name: 'One', title: 'story/one', importPath: 'path/to/file', - tags: ['test'], + tags: [Tag.TEST], }, 'another--one': { type: 'story', @@ -124,7 +124,7 @@ global.fetch = vi.fn().mockResolvedValue({ name: 'One', title: 'another/one', importPath: 'path/to/another/file', - tags: ['test'], + tags: [Tag.TEST], }, 'parent--story': { type: 'story', @@ -133,17 +133,17 @@ global.fetch = vi.fn().mockResolvedValue({ name: 'Parent story', title: 'parent/story', importPath: 'path/to/parent/file', - tags: ['test'], + tags: [Tag.TEST], }, 'parent--story:test': { type: 'story', - subtype: 'test', + subtype: Tag.TEST, id: 'parent--story:test', name: 'Test name', title: 'parent/story', parent: 'parent--story', importPath: 'path/to/parent/file', - tags: ['test', 'test-fn'], + tags: [Tag.TEST, Tag.TEST_FN], }, }, } as StoryIndex) @@ -278,7 +278,7 @@ describe('TestManager', () => { expect(createVitest).toHaveBeenCalledTimes(1); expect(createVitest).toHaveBeenCalledWith( - 'test', + Tag.TEST, expect.objectContaining({ coverage: expect.objectContaining({ enabled: true }), }) diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index c6c53908b01a..8cf3e0f15e7e 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -9,6 +9,7 @@ import type { } from 'vitest/node'; import { getProjectRoot, resolvePathInStorybookCache } from 'storybook/internal/common'; +import { Tag } from 'storybook/internal/core-server'; import type { StoryId, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; import * as find from 'empathic/find'; @@ -221,7 +222,7 @@ export class VitestManager { for (const testSpecification of testSpecifications) { const { env = {} } = testSpecification.project.config; - const include = env.__VITEST_INCLUDE_TAGS__?.split(',').filter(Boolean) ?? ['test']; + const include = env.__VITEST_INCLUDE_TAGS__?.split(',').filter(Boolean) ?? [Tag.TEST]; const exclude = env.__VITEST_EXCLUDE_TAGS__?.split(',').filter(Boolean) ?? []; const skip = env.__VITEST_SKIP_TAGS__?.split(',').filter(Boolean) ?? []; diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 973eeb0d8cac..9e65cd3b747e 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -14,6 +14,7 @@ import { } from 'storybook/internal/common'; import { StoryIndexGenerator, + Tag, experimental_loadStorybook, mapStaticDir, } from 'storybook/internal/core-server'; @@ -98,6 +99,10 @@ const mdxStubPlugin: Plugin = { }; export const storybookTest = async (options?: UserOptions): Promise => { + if (!optionalEnvToBoolean(process.env.VITEST)) { + return []; + } + const finalOptions = { ...defaultOptions, ...options, @@ -105,7 +110,7 @@ export const storybookTest = async (options?: UserOptions): Promise => ? resolve(WORKING_DIR, options.configDir) : defaultOptions.configDir, tags: { - include: options?.tags?.include ?? ['test'], + include: options?.tags?.include ?? [Tag.TEST], exclude: options?.tags?.exclude ?? [], skip: options?.tags?.skip ?? [], }, @@ -142,7 +147,6 @@ export const storybookTest = async (options?: UserOptions): Promise => const [ { storiesGlobs }, framework, - storybookEnv, viteConfigFromStorybook, staticDirs, previewLevelTags, @@ -152,7 +156,6 @@ export const storybookTest = async (options?: UserOptions): Promise => ] = await Promise.all([ getStoryGlobsAndFiles(presets, directories), presets.apply('framework', undefined), - presets.apply('env', {}), presets.apply<{ plugins?: Plugin[]; root: string }>('viteFinal', commonConfig), presets.apply('staticDirs', []), extractTagsFromPreview(finalOptions.configDir), @@ -191,7 +194,11 @@ export const storybookTest = async (options?: UserOptions): Promise => .replace('', `${headHtmlSnippet ?? ''}`) .replace('', `${bodyHtmlSnippet ?? ''}`); }, - async config(nonMutableInputConfig) { + async config(nonMutableInputConfig, { mode }) { + if (mode) { + // Needed for `preset.apply('env')` to work correctly + process.env.BUILD_TARGET = mode; + } // ! We're not mutating the input config, instead we're returning a new partial config // ! see https://vite.dev/guide/api-plugin.html#config try { @@ -254,7 +261,15 @@ export const storybookTest = async (options?: UserOptions): Promise => : {}), env: { - ...storybookEnv, + /** + * We do this late, because we need vitest's --mode to be available and set to + * BUILD_MODE. Unfortunately, the dependencies we use to load .env files can only be + * configured using that environment variable. We need it to be synced up with the mode + * that vitest is running in, or risk leaking envs from the wrong file. + * + * @see https://github.com/storybookjs/storybook/issues/33101 + */ + ...(await presets.apply('env', {})), // To be accessed by the setup file __STORYBOOK_URL__: finalOptions.storybookUrl, @@ -408,10 +423,6 @@ export const storybookTest = async (options?: UserOptions): Promise => } }, async transform(code, id) { - if (!optionalEnvToBoolean(process.env.VITEST)) { - return code; - } - const relativeId = relative(finalOptions.vitestRoot, id); if (match([relativeId], finalOptions.includeStories).length > 0) { diff --git a/code/core/src/components/components/utils/getStoryHref.ts b/code/core/src/components/components/utils/getStoryHref.ts index 335bead96aa3..f2eabf080514 100644 --- a/code/core/src/components/components/utils/getStoryHref.ts +++ b/code/core/src/components/components/utils/getStoryHref.ts @@ -1,3 +1,5 @@ +import { deprecate } from 'storybook/internal/client-logger'; + function parseQuery(queryString: string) { const query: Record = {}; const pairs = queryString.split('&'); @@ -9,11 +11,15 @@ function parseQuery(queryString: string) { return query; } +/** @deprecated Use the api.getStoryHrefs method instead (from `storybook/manager-api`) */ export const getStoryHref = ( baseUrl: string, storyId: string, additionalParams: Record = {} ) => { + deprecate( + 'getStoryHref is deprecated and will be removed in Storybook 11, use the api.getStoryHrefs method instead' + ); const [url, paramsStr] = baseUrl.split('?'); const params = paramsStr ? { diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index a36bf54ceca0..9e4e76ea230a 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -12,6 +12,8 @@ export { StoryIndexGenerator } from './utils/StoryIndexGenerator'; export { loadStorybook as experimental_loadStorybook } from './load'; +export { Tag } from '../shared/constants/tags'; + export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store'; export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock'; export { diff --git a/code/core/src/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index 563443222ac3..bd4c358e837b 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,7 +1,7 @@ /* these imports are in the exact order in which the panels need to be registered */ import { global } from '@storybook/global'; -import { addons } from 'storybook/manager-api'; +import { addons, Tag } from 'storybook/manager-api'; // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! import controlsManager from '../../controls/manager'; @@ -33,7 +33,7 @@ const tagFiltersManager = addons.register(TAG_FILTERS, (api) => { const tags = item.tags ?? []; return ( // we can filter out the primary story, but we still want to show autodocs - (tags.includes('dev') || item.type === 'docs') && + (tags.includes(Tag.DEV) || item.type === 'docs') && tags.filter((tag) => staticExcludeTags[tag]).length === 0 ); }); diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts index f9359d21715c..857f06b12542 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts @@ -8,6 +8,7 @@ import { getStorySortParameter, readCsf } from 'storybook/internal/csf-tools'; import { logger, once } from 'storybook/internal/node-logger'; import type { NormalizedStoriesSpecifier, StoryIndexEntry } from 'storybook/internal/types'; +import { Tag } from '../../shared/constants/tags'; import { csfIndexer } from '../presets/common-preset'; import type { StoryIndexGeneratorOptions } from './StoryIndexGenerator'; import { StoryIndexGenerator } from './StoryIndexGenerator'; @@ -1021,7 +1022,7 @@ describe('StoryIndexGenerator', () => { ); const generator = new StoryIndexGenerator([specifier], autodocsOptions); - generator.getProjectTags = () => ['dev', 'test', 'autodocs']; + generator.getProjectTags = () => [Tag.DEV, Tag.TEST, Tag.AUTODOCS]; await generator.initialize(); expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` @@ -1065,12 +1066,12 @@ describe('StoryIndexGenerator', () => { ); const generator = new StoryIndexGenerator([specifier], autodocsOptions); - generator.getProjectTags = () => ['dev', 'test', 'autodocs']; + generator.getProjectTags = () => [Tag.DEV, Tag.TEST, Tag.AUTODOCS]; await generator.initialize(); const index = await generator.getIndex(); expect(index.entries['first-nested-deeply-f--docs'].tags).toEqual( - expect.arrayContaining(['autodocs']) + expect.arrayContaining([Tag.AUTODOCS]) ); }); @@ -1327,7 +1328,7 @@ describe('StoryIndexGenerator', () => { ); const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], autodocsOptions); - generator.getProjectTags = () => ['dev', 'test', 'autodocs']; + generator.getProjectTags = () => [Tag.DEV, Tag.TEST, Tag.AUTODOCS]; await generator.initialize(); const { storyIndex } = await generator.getIndexAndStats(); diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.ts b/code/core/src/core-server/utils/StoryIndexGenerator.ts index b71367d03742..f1578cdbfa44 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.ts @@ -19,7 +19,6 @@ import type { StoryIndexEntry, StoryIndexInput, StorybookConfigRaw, - Tag, } from 'storybook/internal/types'; import * as find from 'empathic/find'; @@ -33,6 +32,7 @@ import * as TsconfigPaths from 'tsconfig-paths'; import { resolveImport, supportedExtensions } from '../../common'; import { userOrAutoTitleFromSpecifier } from '../../preview-api/modules/store/autoTitle'; import { sortStoriesV7 } from '../../preview-api/modules/store/sortStories'; +import { Tag } from '../../shared/constants/tags'; import { IndexingError, MultipleIndexingError } from './IndexingError'; import { autoName } from './autoName'; import { type IndexStatsSummary, addStats } from './summarizeStats'; @@ -64,15 +64,9 @@ export type StoryIndexGeneratorOptions = { build?: StorybookConfigRaw['build']; }; -export const AUTODOCS_TAG = 'autodocs'; -export const ATTACHED_MDX_TAG = 'attached-mdx'; -export const UNATTACHED_MDX_TAG = 'unattached-mdx'; -export const PLAY_FN_TAG = 'play-fn'; -export const TEST_FN_TAG = 'test-fn'; - /** Was this docs entry generated by a .mdx file? (see discussion below) */ export function isMdxEntry({ tags }: DocsIndexEntry) { - return tags?.includes(UNATTACHED_MDX_TAG) || tags?.includes(ATTACHED_MDX_TAG); + return tags?.includes(Tag.UNATTACHED_MDX) || tags?.includes(Tag.ATTACHED_MDX); } const makeAbsolute = (otherImport: Path, normalizedPath: Path, workingDir: Path) => @@ -486,7 +480,7 @@ export class StoryIndexGenerator { // We need a docs entry attached to the CSF file if either: // a) autodocs is globally enabled // b) we have autodocs enabled for this file - const hasAutodocsTag = storyEntries.some((entry) => entry.tags.includes(AUTODOCS_TAG)); + const hasAutodocsTag = storyEntries.some((entry) => entry.tags.includes(Tag.AUTODOCS)); const createDocEntry = hasAutodocsTag && !!this.options.docs; if (createDocEntry && this.options.build?.test?.disableAutoDocs !== true) { @@ -611,7 +605,7 @@ export class StoryIndexGenerator { ...projectTags, ...(csfEntry?.tags ?? []), ...(result.metaTags ?? []), - csfEntry ? 'attached-mdx' : 'unattached-mdx' + csfEntry ? Tag.ATTACHED_MDX : Tag.UNATTACHED_MDX ); const docsEntry: DocsCacheEntry = { @@ -687,9 +681,9 @@ export class StoryIndexGenerator { } // If you link a file to a tagged CSF file, you have probably made a mistake - if (worseEntry.tags?.includes(AUTODOCS_TAG) && !projectTags?.includes(AUTODOCS_TAG)) { + if (worseEntry.tags?.includes(Tag.AUTODOCS) && !projectTags?.includes(Tag.AUTODOCS)) { throw new IndexingError( - `You created a component docs page for '${worseEntry.title}', but also tagged the CSF file with '${AUTODOCS_TAG}'. This is probably a mistake.`, + `You created a component docs page for '${worseEntry.title}', but also tagged the CSF file with '${Tag.AUTODOCS}'. This is probably a mistake.`, [betterEntry.importPath, worseEntry.importPath] ); } @@ -875,7 +869,7 @@ export class StoryIndexGenerator { getProjectTags(previewCode?: string) { let projectTags = [] as Tag[]; - const defaultTags = ['dev', 'test', 'manifest']; + const defaultTags = [Tag.DEV, Tag.TEST, Tag.MANIFEST]; if (previewCode) { try { const projectAnnotations = loadConfig(previewCode).parse(); diff --git a/code/core/src/core-server/utils/__tests__/index-extraction.test.ts b/code/core/src/core-server/utils/__tests__/index-extraction.test.ts index ab582f0bb3ab..341de83a4ae5 100644 --- a/code/core/src/core-server/utils/__tests__/index-extraction.test.ts +++ b/code/core/src/core-server/utils/__tests__/index-extraction.test.ts @@ -5,8 +5,9 @@ import { describe, expect, it, vi } from 'vitest'; import { normalizeStoriesEntry } from 'storybook/internal/common'; import type { NormalizedStoriesSpecifier } from 'storybook/internal/types'; +import { Tag } from '../../../shared/constants/tags'; import type { StoryIndexGeneratorOptions } from '../StoryIndexGenerator'; -import { AUTODOCS_TAG, StoryIndexGenerator } from '../StoryIndexGenerator'; +import { StoryIndexGenerator } from '../StoryIndexGenerator'; vi.mock('storybook/internal/node-logger'); @@ -419,7 +420,7 @@ describe('story extraction', () => { }); }); describe('docs entries from story extraction', () => { - it(`adds docs entry when autodocs is "tag" and an entry has the "${AUTODOCS_TAG}" tag`, async () => { + it(`adds docs entry when autodocs is "tag" and an entry has the "${Tag.AUTODOCS}" tag`, async () => { const relativePath = './src/A.stories.js'; const absolutePath = join(options.workingDir, relativePath); const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry(relativePath, options); @@ -436,7 +437,7 @@ describe('docs entries from story extraction', () => { __id: 'a--story-one', name: 'Story One', title: 'A', - tags: [AUTODOCS_TAG, 'story-tag-from-indexer'], + tags: [Tag.AUTODOCS, 'story-tag-from-indexer'], importPath: fileName, type: 'story', subtype: 'story', diff --git a/code/core/src/core-server/utils/manifests/manifests.test.ts b/code/core/src/core-server/utils/manifests/manifests.test.ts index 41ceddf8a452..8111e48b7802 100644 --- a/code/core/src/core-server/utils/manifests/manifests.test.ts +++ b/code/core/src/core-server/utils/manifests/manifests.test.ts @@ -6,6 +6,7 @@ import type { ComponentsManifest, Manifests, Presets, StoryIndex } from 'storybo import { vol } from 'memfs'; import type { Polka, Request, Response } from 'polka'; +import { Tag } from '../../../shared/constants/tags'; import { registerManifests, writeManifests } from './manifests'; // Mock dependencies @@ -130,7 +131,7 @@ describe('manifests', () => { name: 'Story', title: 'Example', importPath: './Example.stories.tsx', - tags: ['manifest', 'other'], + tags: [Tag.MANIFEST, 'other'], }, 'story-without-manifest': { type: 'story', @@ -147,7 +148,7 @@ describe('manifests', () => { name: 'Docs', title: 'Docs', importPath: './Docs.mdx', - tags: ['manifest'], + tags: [Tag.MANIFEST], storiesImports: [], }, }, diff --git a/code/core/src/core-server/utils/manifests/manifests.ts b/code/core/src/core-server/utils/manifests/manifests.ts index a77907df7a59..386c920270ac 100644 --- a/code/core/src/core-server/utils/manifests/manifests.ts +++ b/code/core/src/core-server/utils/manifests/manifests.ts @@ -7,6 +7,7 @@ import { join } from 'pathe'; import type { Polka } from 'polka'; import invariant from 'tiny-invariant'; +import { Tag } from '../../../shared/constants/tags'; import { renderComponentsManifest } from './render-components-manifest'; async function getManifests(presets: Presets) { @@ -14,7 +15,7 @@ async function getManifests(presets: Presets) { invariant(generator, 'storyIndexGenerator must be configured'); const index = await generator.getIndex(); const manifestEntries = Object.values(index.entries).filter( - (entry) => entry.tags?.includes('manifest') ?? false + (entry) => entry.tags?.includes(Tag.MANIFEST) ?? false ); return ( diff --git a/code/core/src/core-server/utils/summarizeIndex.test.ts b/code/core/src/core-server/utils/summarizeIndex.test.ts index a90e0b27f703..7e86aafb7c27 100644 --- a/code/core/src/core-server/utils/summarizeIndex.test.ts +++ b/code/core/src/core-server/utils/summarizeIndex.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { Tag } from '../../shared/constants/tags'; import { isPageStory, summarizeIndex } from './summarizeIndex'; describe('isPageStory', () => { @@ -56,7 +57,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './src/stories/Button.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, 'example-button--primary': { @@ -64,7 +65,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Primary', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -73,7 +74,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Secondary', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -82,7 +83,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Large', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -91,7 +92,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Small', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -101,7 +102,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './src/stories/Header.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, 'example-header--logged-in': { @@ -109,7 +110,7 @@ describe('summarizeIndex', () => { title: 'Example/Header', name: 'Logged In', importPath: './src/stories/Header.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -118,7 +119,7 @@ describe('summarizeIndex', () => { title: 'Example/Header', name: 'Logged Out', importPath: './src/stories/Header.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -136,7 +137,7 @@ describe('summarizeIndex', () => { title: 'Example/Page', name: 'Logged In', importPath: './src/stories/Page.stories.ts', - tags: ['play-fn', 'story'], + tags: [Tag.PLAY_FN, 'story'], type: 'story', subtype: 'story', }, @@ -183,7 +184,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './src/stories/Button.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, 'example-button--primary': { @@ -191,7 +192,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Primary', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -200,7 +201,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Warning', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story', 'svelte-csf-v4'], + tags: [Tag.AUTODOCS, 'story', 'svelte-csf-v4'], type: 'story', subtype: 'story', }, @@ -303,7 +304,7 @@ describe('summarizeIndex', () => { name: 'Default', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag'], }, 'component-testing-test-fn--default:simple': { type: 'story', @@ -312,7 +313,7 @@ describe('summarizeIndex', () => { name: 'simple', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag', Tag.TEST_FN], parent: 'component-testing-test-fn--default', }, 'component-testing-test-fn--default:referring-to-function-in-file': { @@ -322,7 +323,7 @@ describe('summarizeIndex', () => { name: 'referring to function in file', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag', Tag.TEST_FN], parent: 'component-testing-test-fn--default', }, 'component-testing-test-fn--default:with-overrides': { @@ -332,7 +333,7 @@ describe('summarizeIndex', () => { name: 'with overrides', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag', Tag.TEST_FN], parent: 'component-testing-test-fn--default', }, 'component-testing-test-fn--default:with-play-function': { @@ -342,7 +343,7 @@ describe('summarizeIndex', () => { name: 'with play function', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag', Tag.TEST_FN], parent: 'component-testing-test-fn--default', }, 'component-testing-test-fn--default-extended': { @@ -352,7 +353,7 @@ describe('summarizeIndex', () => { name: 'Default Extended', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag'], }, 'component-testing-test-fn--default-extended:should-have-extended-args': { type: 'story', @@ -361,7 +362,7 @@ describe('summarizeIndex', () => { name: 'should have extended args', title: 'component-testing/test-fn', importPath: './core/src/component-testing/components/test-fn.stories.tsx', - tags: ['dev', 'test', 'vitest', 'some-tag', 'test-fn'], + tags: [Tag.DEV, Tag.TEST, 'vitest', 'some-tag', Tag.TEST_FN], parent: 'component-testing-test-fn--default-extended', }, }, @@ -406,7 +407,7 @@ describe('summarizeIndex', () => { title: 'Example/Page', name: 'Logged In', importPath: './src/stories/Page.stories.ts', - tags: ['play-fn', 'story'], + tags: [Tag.PLAY_FN, 'story'], type: 'story', subtype: 'story', }, @@ -416,7 +417,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './template-stories/addons/docs/docspage/autoplay.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, 'addons-docs-docspage-autoplay--no-autoplay': { @@ -424,7 +425,7 @@ describe('summarizeIndex', () => { title: 'addons/docs/docspage/autoplay', name: 'No Autoplay', importPath: './template-stories/addons/docs/docspage/autoplay.stories.ts', - tags: ['play-fn', 'story'], + tags: [Tag.PLAY_FN, 'story'], type: 'story', subtype: 'story', }, @@ -462,7 +463,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './src/stories/Button.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, 'example-button--large': { @@ -470,7 +471,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Large', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -479,7 +480,7 @@ describe('summarizeIndex', () => { title: 'Example/Button', name: 'Small', importPath: './src/stories/Button.stories.ts', - tags: ['autodocs', 'story'], + tags: [Tag.AUTODOCS, 'story'], type: 'story', subtype: 'story', }, @@ -489,7 +490,7 @@ describe('summarizeIndex', () => { name: 'Docs', importPath: './template-stories/lib/preview-api/shortcuts.stories.ts', type: 'docs', - tags: ['autodocs', 'docs'], + tags: [Tag.AUTODOCS, 'docs'], storiesImports: [], }, }, @@ -536,7 +537,7 @@ describe('summarizeIndex', () => { importPath: './template-stories/addons/docs/docs2/NoTitle.mdx', storiesImports: [], type: 'docs', - tags: ['docs', 'attached-mdx'], + tags: ['docs', Tag.ATTACHED_MDX], }, 'addons-docs-yabbadabbadooo--docs': { id: 'addons-docs-yabbadabbadooo--docs', @@ -545,7 +546,7 @@ describe('summarizeIndex', () => { importPath: './template-stories/addons/docs/docs2/Title.mdx', storiesImports: [], type: 'docs', - tags: ['docs', 'attached-mdx'], + tags: ['docs', Tag.ATTACHED_MDX], }, }, }) diff --git a/code/core/src/core-server/utils/summarizeIndex.ts b/code/core/src/core-server/utils/summarizeIndex.ts index 5e6520323c78..b55d4e6b24e0 100644 --- a/code/core/src/core-server/utils/summarizeIndex.ts +++ b/code/core/src/core-server/utils/summarizeIndex.ts @@ -1,10 +1,10 @@ import { isExampleStoryId } from 'storybook/internal/telemetry'; import type { IndexEntry, StoryIndex } from 'storybook/internal/types'; -import { AUTODOCS_TAG, PLAY_FN_TAG, TEST_FN_TAG, isMdxEntry } from './StoryIndexGenerator'; +import { Tag } from '../../shared/constants/tags'; +import { isMdxEntry } from './StoryIndexGenerator'; const PAGE_REGEX = /(page|screen)/i; -const SVELTE_CSF_TAG = 'svelte-csf'; export const isPageStory = (storyId: string) => PAGE_REGEX.test(storyId); @@ -63,10 +63,10 @@ export function summarizeIndex(storyIndex: StoryIndex) { if (isPageStory(entry.title)) { pageStoryCount += 1; } - if (entry.tags?.includes(PLAY_FN_TAG)) { + if (entry.tags?.includes(Tag.PLAY_FN)) { playStoryCount += 1; } - if (entry.tags?.includes(TEST_FN_TAG) && entry.parent) { + if (entry.tags?.includes(Tag.TEST_FN) && entry.parent) { testStoryCount += 1; testsPerParentStory.set(entry.parent, (testsPerParentStory.get(entry.parent) ?? 0) + 1); } @@ -78,7 +78,7 @@ export function summarizeIndex(storyIndex: StoryIndex) { } else if (entry.type === 'docs') { if (isMdxEntry(entry)) { mdxCount += 1; - } else if (entry.tags?.includes(AUTODOCS_TAG)) { + } else if (entry.tags?.includes(Tag.AUTODOCS)) { autodocsCount += 1; } } diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index 44088c6aff2d..5663d8140537 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -19,11 +19,11 @@ import type { IndexInputStats, IndexedCSFFile, StoryAnnotations, - Tag, } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; +import { Tag } from '../shared/constants/tags'; import type { PrintResultType } from './PrintResultType'; import { findVarInitialization } from './findVarInitialization'; @@ -874,7 +874,7 @@ export class CsfFile { const entries = Object.entries(self._stories); self._meta.title = this._options.makeTitle(self._meta?.title as string); if (self._metaAnnotations.play) { - self._meta.tags = [...(self._meta.tags || []), 'play-fn']; + self._meta.tags = [...(self._meta.tags || []), Tag.PLAY_FN]; } self._stories = entries.reduce( (acc, [key, story]) => { @@ -903,7 +903,7 @@ export class CsfFile { acc[key].tags = parseTags(node); } if (play) { - acc[key].tags = [...(acc[key].tags || []), 'play-fn']; + acc[key].tags = [...(acc[key].tags || []), Tag.PLAY_FN]; } const stats = acc[key].__stats; ['play', 'render', 'loaders', 'beforeEach', 'globals', 'tags'].forEach((annotation) => { @@ -1024,10 +1024,10 @@ export class CsfFile { tags: [ ...storyInput.tags, // this tag comes before test tags so users can invert if they like - '!autodocs', + `!${Tag.AUTODOCS}`, ...test.tags, // this tag comes after test tags so users can't change it - 'test-fn', + Tag.TEST_FN, ], __id: test.id, }); diff --git a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts index 840d16f57499..ba39bc10aa96 100644 --- a/code/core/src/csf-tools/vitest-plugin/transformer.test.ts +++ b/code/core/src/csf-tools/vitest-plugin/transformer.test.ts @@ -5,6 +5,7 @@ import { logger } from 'storybook/internal/node-logger'; import { type RawSourceMap, SourceMapConsumer } from 'source-map'; +import { Tag } from '../../shared/constants/tags'; import { vitestTransform as originalTransform } from './transformer'; vi.mock('storybook/internal/common', async (importOriginal) => { @@ -24,7 +25,7 @@ const transform = async ({ code = '', fileName = 'src/components/Button.stories.js', tagsFilter = { - include: ['test'], + include: [Tag.TEST] as string[], exclude: [] as string[], skip: [] as string[], }, @@ -456,7 +457,7 @@ describe('transformer', () => { const result = await transform({ code, - tagsFilter: { include: ['test'], exclude: ['exclude-me'], skip: [] }, + tagsFilter: { include: [Tag.TEST], exclude: ['exclude-me'], skip: [] }, }); expect(result.code).toMatchInlineSnapshot(` @@ -485,7 +486,7 @@ describe('transformer', () => { const result = await transform({ code, - tagsFilter: { include: ['test'], exclude: [], skip: ['skip-me'] }, + tagsFilter: { include: [Tag.TEST], exclude: [], skip: ['skip-me'] }, }); expect(result.code).toMatchInlineSnapshot(` @@ -896,7 +897,7 @@ describe('transformer', () => { const result = await transform({ code, - tagsFilter: { include: ['test'], exclude: ['exclude-me'], skip: [] }, + tagsFilter: { include: [Tag.TEST], exclude: ['exclude-me'], skip: [] }, }); expect(result.code).toMatchInlineSnapshot(` @@ -926,7 +927,7 @@ describe('transformer', () => { const result = await transform({ code, - tagsFilter: { include: ['test'], exclude: [], skip: ['skip-me'] }, + tagsFilter: { include: [Tag.TEST], exclude: [], skip: ['skip-me'] }, }); expect(result.code).toMatchInlineSnapshot(` diff --git a/code/core/src/csf/csf-factories.ts b/code/core/src/csf/csf-factories.ts index d05495db58f7..c7de8a392273 100644 --- a/code/core/src/csf/csf-factories.ts +++ b/code/core/src/csf/csf-factories.ts @@ -19,6 +19,7 @@ import { normalizeProjectAnnotations, } from '../preview-api/index'; import { mountDestructured } from '../preview-api/modules/preview-web/render/mount-utils'; +import { Tag } from '../shared/constants/tags'; import { getCoreAnnotations } from './core-annotations'; export interface Preview { @@ -219,7 +220,7 @@ function defineStory< const test = this.extend({ ...annotations, name, - tags: ['test-fn', '!autodocs', ...(annotations.tags ?? [])], + tags: [Tag.TEST_FN, `!${Tag.AUTODOCS}`, ...(annotations.tags ?? [])], play, }); __children.push(test); diff --git a/code/core/src/manager-api/index.mock.ts b/code/core/src/manager-api/index.mock.ts index 231a30b526ea..2ba8bb7e0212 100644 --- a/code/core/src/manager-api/index.mock.ts +++ b/code/core/src/manager-api/index.mock.ts @@ -1,6 +1,7 @@ import { fn } from 'storybook/test'; export * from './root'; +export { Tag } from '../shared/constants/tags'; export const openInEditor = fn(); diff --git a/code/core/src/manager-api/index.ts b/code/core/src/manager-api/index.ts index a977403fc49a..8b24405ca9af 100644 --- a/code/core/src/manager-api/index.ts +++ b/code/core/src/manager-api/index.ts @@ -22,3 +22,5 @@ export { checklistStore as internal_checklistStore, universalChecklistStore as internal_universalChecklistStore, } from './stores/checklist'; + +export { Tag } from '../shared/constants/tags'; diff --git a/code/core/src/manager-api/lib/stories.ts b/code/core/src/manager-api/lib/stories.ts index a9413f8119c8..b30b77aa67db 100644 --- a/code/core/src/manager-api/lib/stories.ts +++ b/code/core/src/manager-api/lib/stories.ts @@ -19,7 +19,6 @@ import type { StoryId, StoryIndexV2, StoryIndexV3, - Tag, } from 'storybook/internal/types'; import { countBy } from 'es-toolkit/array'; @@ -27,6 +26,7 @@ import { mapValues } from 'es-toolkit/object'; import memoize from 'memoizerific'; import { dedent } from 'ts-dedent'; +import { Tag } from '../../shared/constants/tags'; import { type API, type State, combineParameters } from '../root'; import intersect from './intersect'; import merge from './merge'; @@ -155,7 +155,7 @@ export const transformStoryIndexV4toV5 = ( (acc, entry) => { acc[entry.id] = { ...entry, - tags: entry.tags ? ['dev', 'test', ...entry.tags] : ['dev'], + tags: entry.tags ? [Tag.DEV, Tag.TEST, ...entry.tags] : [Tag.DEV], }; return acc; diff --git a/code/core/src/manager-api/modules/shortcuts.ts b/code/core/src/manager-api/modules/shortcuts.ts index 57233fca2eaa..0c16b169686d 100644 --- a/code/core/src/manager-api/modules/shortcuts.ts +++ b/code/core/src/manager-api/modules/shortcuts.ts @@ -113,6 +113,7 @@ export interface API_Shortcuts { expandAll: API_KeyCollection; remount: API_KeyCollection; openInEditor: API_KeyCollection; + openInIsolation: API_KeyCollection; copyStoryLink: API_KeyCollection; // TODO: bring this back once we want to add shortcuts for this // copyStoryName: API_KeyCollection; @@ -152,6 +153,7 @@ export const defaultShortcuts: API_Shortcuts = Object.freeze({ expandAll: [controlOrMetaKey(), 'shift', 'ArrowDown'], remount: ['alt', 'R'], openInEditor: ['alt', 'shift', 'E'], + openInIsolation: ['alt', 'shift', 'I'], copyStoryLink: ['alt', 'shift', 'L'], // TODO: bring this back once we want to add shortcuts for this // copyStoryName: ['alt', 'shift', 'C'], @@ -247,6 +249,8 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { const { ui: { enableShortcuts }, storyId, + refId, + viewMode, } = store.getState(); if (!enableShortcuts) { return; @@ -397,6 +401,13 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { } break; } + case 'openInIsolation': { + if (storyId && viewMode === 'story') { + const { previewHref } = fullAPI.getStoryHrefs(storyId, { refId }); + window.open(previewHref, '_blank', 'noopener,noreferrer'); + } + break; + } // TODO: bring this back once we want to add shortcuts for this // case 'copyStoryName': { // const storyData = fullAPI.getCurrentStoryData(); @@ -406,7 +417,10 @@ export const init: ModuleFn = ({ store, fullAPI, provider }) => { // break; // } case 'copyStoryLink': { - copy(window.location.href); + if (storyId) { + const { managerHref } = fullAPI.getStoryHrefs(storyId, { refId }); + copy(managerHref); + } break; } default: diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 2eda0d3c4752..c37e95537bf3 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -7,17 +7,16 @@ import { } from 'storybook/internal/core-events'; import { buildArgsParam, queryFromLocation } from 'storybook/internal/router'; import type { NavigateOptions } from 'storybook/internal/router'; -import type { API_Layout, API_UI, Args } from 'storybook/internal/types'; +import type { API_Layout, API_UI, API_ViewMode, Args } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { dequal as deepEqual } from 'dequal'; +import { stringify } from 'picoquery'; import type { ModuleArgs, ModuleFn } from '../lib/types'; import { defaultLayoutState } from './layout'; -const { window: globalWindow } = global; - export interface SubState { customQueryParams: QueryParams; } @@ -33,6 +32,24 @@ const parseBoolean = (value: string) => { return undefined; }; +const parseSerializedParam = (param: string) => + Object.fromEntries( + param + .split(';') + .map((pair) => pair.split(':')) + // Encoding values ensures we don't break already encoded args/globals but also don't encode our own special characters like ; and :. + .map(([key, value]) => [key, encodeURIComponent(value)]) + .filter(([key, value]) => key && value) + ); + +const mergeSerializedParams = (params: string, extraParams: string) => { + const pairs = parseSerializedParam(params); + const extra = parseSerializedParam(extraParams); + return Object.entries({ ...pairs, ...extra }) + .map(([key, value]) => `${key}:${value}`) + .join(';'); +}; + // Initialize the state based on the URL. // NOTE: // Although we don't change the URL when you change the state, we do support setting initial state @@ -121,6 +138,33 @@ export interface SubAPI { * @returns {void} */ navigateUrl: (url: string, options: NavigateOptions) => void; + /** + * Get the manager and preview hrefs for a story. + * + * @param {string} storyId - The ID of the story to get the URL for. + * @param {Object} options - Options for the URL. + * @param {string} [options.base] - Return an absolute href based on the current origin or network + * address. + * @param {boolean} [options.inheritArgs] - Inherit args from the current URL. If storyId matches + * current story, inheritArgs defaults to true. + * @param {boolean} [options.inheritGlobals] - Inherit globals from the current URL. Defaults to + * true. + * @param {QueryParams} [options.queryParams] - Query params to add to the URL. + * @param {string} [options.refId] - ID of the ref to get the URL for (for composed Storybooks) + * @param {string} [options.viewMode] - The view mode to use, defaults to 'story'. + * @returns {Object} Manager and preview hrefs for the story. + */ + getStoryHrefs( + storyId: string, + options?: { + base?: 'origin' | 'network'; + inheritArgs?: boolean; + inheritGlobals?: boolean; + queryParams?: QueryParams; + refId?: string; + viewMode?: API_ViewMode; + } + ): { managerHref: string; previewHref: string }; /** * Get the value of a query parameter from the current URL. * @@ -183,6 +227,54 @@ export const init: ModuleFn = (moduleArgs) => { }; const api: SubAPI = { + getStoryHrefs(storyId, options = {}) { + const { id: currentStoryId, refId: currentRefId } = fullAPI.getCurrentStoryData() ?? {}; + const isCurrentStory = storyId === currentStoryId && options.refId === currentRefId; + + const { customQueryParams, location, refs } = store.getState(); + const { + base, + inheritArgs = isCurrentStory, + inheritGlobals = true, + queryParams = {}, + refId, + viewMode = 'story', + } = options; + + if (refId && !refs[refId]) { + throw new Error(`Invalid refId: ${refId}`); + } + + const originAddress = global.window.location.origin + location.pathname; + const networkAddress = global.STORYBOOK_NETWORK_ADDRESS ?? originAddress; + const managerBase = + base === 'origin' ? originAddress : base === 'network' ? networkAddress : location.pathname; + const previewBase = refId + ? refs[refId].url + '/iframe.html' + : global.PREVIEW_URL || `${managerBase}iframe.html`; + + const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; + const { args = '', globals = '', ...otherParams } = queryParams; + let argsParam = inheritArgs + ? mergeSerializedParams(customQueryParams?.args ?? '', args) + : args; + let globalsParam = inheritGlobals + ? mergeSerializedParams(customQueryParams?.globals ?? '', globals) + : globals; + let customParams = stringify(otherParams, { + nesting: true, + nestingSyntax: 'js', + }); + + argsParam = argsParam && `&args=${argsParam}`; + globalsParam = globalsParam && `&globals=${globalsParam}`; + customParams = customParams && `&${customParams}`; + + return { + managerHref: `${managerBase}?path=/${viewMode}/${refId ? `${refId}_` : ''}${storyId}${argsParam}${globalsParam}${customParams}`, + previewHref: `${previewBase}?id=${storyId}&viewMode=${viewMode}${refParam}${argsParam}${refId ? '' : globalsParam}${customParams}`, + }; + }, getQueryParam(key) { const { customQueryParams } = store.getState(); return customQueryParams ? customQueryParams[key] : undefined; @@ -253,11 +345,11 @@ export const init: ModuleFn = (moduleArgs) => { let handleOrId: any; provider.channel?.on(STORY_ARGS_UPDATED, () => { - if ('requestIdleCallback' in globalWindow) { + if ('requestIdleCallback' in global.window) { if (handleOrId) { - globalWindow.cancelIdleCallback(handleOrId); + global.window.cancelIdleCallback(handleOrId); } - handleOrId = globalWindow.requestIdleCallback(updateArgsParam, { timeout: 1000 }); + handleOrId = global.window.requestIdleCallback(updateArgsParam, { timeout: 1000 }); } else { if (handleOrId) { clearTimeout(handleOrId); diff --git a/code/core/src/manager-api/root.tsx b/code/core/src/manager-api/root.tsx index 7388657698a9..f6526c27d1ae 100644 --- a/code/core/src/manager-api/root.tsx +++ b/code/core/src/manager-api/root.tsx @@ -105,6 +105,7 @@ export type API = addons.SubAPI & version.SubAPI & url.SubAPI & whatsnew.SubAPI & + openInEditor.SubAPI & Other; interface Other { diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index 349783223291..1b095ff0de11 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -6,11 +6,25 @@ import { UPDATE_QUERY_PARAMS, } from 'storybook/internal/core-events'; +import { global } from '@storybook/global'; + import EventEmitter from 'events'; import { init as initURL } from '../modules/url'; vi.mock('storybook/internal/client-logger'); +vi.mock('@storybook/global', () => ({ + global: { + window: { + location: { + hash: '', + href: 'http://localhost:6006', + origin: 'http://localhost:6006', + }, + }, + STORYBOOK_NETWORK_ADDRESS: 'http://192.168.1.1:6006/', + }, +})); const storyState = (storyId) => ({ path: `/story/${storyId}`, @@ -19,8 +33,6 @@ const storyState = (storyId) => ({ }); describe('initial state', () => { - const viewMode = 'story'; - describe('config query parameters', () => { it('handles full parameter', () => { const navigate = vi.fn(); @@ -236,3 +248,210 @@ describe('initModule', () => { ); }); }); + +describe('getStoryHrefs', () => { + let state = {}; + const store = { + setState: (change) => { + state = { ...state, ...change }; + }, + getState: () => state, + }; + + it('returns manager and preview URLs for a story', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); + }); + + it('retains args and globals from the URL', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=a:1&globals=b:2'); + expect(previewHref).toContain('&args=a:1&globals=b:2'); + }); + + it('retains args with special values', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:!null;b:!hex(f00);c:!undefined' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=a:!null;b:!hex(f00);c:!undefined'); + expect(previewHref).toContain('&args=a:!null;b:!hex(f00);c:!undefined'); + }); + + it('drops args but retains globals when changing stories', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--another-story'); + expect(managerHref).toEqual('/?path=/story/test--another-story&globals=b:2'); + expect(previewHref).toEqual('/iframe.html?id=test--another-story&viewMode=story&globals=b:2'); + }); + + it('supports disabling inheritance of args and globals', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + inheritArgs: false, + inheritGlobals: false, + }); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); + }); + + it('supports extra args and globals with merging', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1;b:2&globals=c:3;d:4' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { args: 'a:2;c:3', globals: 'd:5' }, + }); + expect(managerHref).toContain('&args=a:2;b:2;c:3&globals=c:3;d:5'); + expect(previewHref).toContain('&args=a:2;b:2;c:3&globals=c:3;d:5'); + }); + + it('supports additional query params, including nested objects', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { one: 1, foo: { bar: 'baz' } }, + }); + expect(managerHref).toContain('&args=a:1&globals=b:2&one=1&foo.bar=baz'); + expect(previewHref).toContain('&args=a:1&globals=b:2&one=1&foo.bar=baz'); + }); + + it('correctly preserves args and globals encoding', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=equal:g%3Dh&globals=ampersand:c%26d' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toContain('&args=equal:g%3Dh&globals=ampersand:c%26d'); + expect(previewHref).toContain('&args=equal:g%3Dh&globals=ampersand:c%26d'); + }); + + it('correctly encodes query params', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { + queryParams: { equal: 'a=b', ampersand: 'c&d' }, + }); + expect(managerHref).toContain('&equal=a%3Db&ersand=c%26d'); + expect(previewHref).toContain('&equal=a%3Db&ersand=c%26d'); + }); + + it('supports returning absolute URLs using the base option', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const origin = api.getStoryHrefs('test--story', { base: 'origin' }); + expect(origin.managerHref).toContain('http://localhost:6006/?path='); + expect(origin.previewHref).toContain('http://localhost:6006/iframe.html'); + + const network = api.getStoryHrefs('test--story', { base: 'network' }); + expect(network.managerHref).toContain('http://192.168.1.1:6006/?path='); + expect(network.previewHref).toContain('http://192.168.1.1:6006/iframe.html'); + }); + + it('supports linking to a ref, dropping globals in preview', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '?args=a:1&globals=b:2' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + store.setState({ refs: { external: { url: 'https://sb.example.com' } } }); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story', { refId: 'external' }); + expect(managerHref).toEqual('/?path=/story/external_test--story&globals=b:2'); + expect(previewHref).toEqual( + 'https://sb.example.com/iframe.html?id=test--story&viewMode=story&refId=external' + ); + }); + + it('supports PREVIEW_URL override', () => { + global.PREVIEW_URL = 'https://custom.preview.url/'; + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/?path=/story/test--story'); + expect(previewHref).toEqual('https://custom.preview.url/?id=test--story&viewMode=story'); + }); +}); diff --git a/code/core/src/manager/components/preview/FramesRenderer.tsx b/code/core/src/manager/components/preview/FramesRenderer.tsx index fd15d6e19a95..03a47e247676 100644 --- a/code/core/src/manager/components/preview/FramesRenderer.tsx +++ b/code/core/src/manager/components/preview/FramesRenderer.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import React, { Fragment, useRef } from 'react'; -import { Button, getStoryHref } from 'storybook/internal/components'; +import { Button } from 'storybook/internal/components'; import type { Combo } from 'storybook/manager-api'; import { Consumer } from 'storybook/manager-api'; @@ -9,7 +9,6 @@ import { Global, styled } from 'storybook/theming'; import type { CSSObject } from 'storybook/theming'; import { IFrame } from './Iframe'; -import { stringifyQueryParams } from './utils/stringifyQueryParams'; import type { FramesRendererProps } from './utils/types'; const getActive = (refId: FramesRendererProps['refId'], refs: FramesRendererProps['refs']) => { @@ -53,19 +52,15 @@ const styles: CSSObject = { }; export const FramesRenderer: FC = ({ + api, refs, scale, viewMode = 'story', refId, queryParams = {}, - baseUrl, storyId = '*', }) => { const version = refs[refId]?.version; - const stringifiedQueryParams = stringifyQueryParams({ - ...queryParams, - ...(version && { version }), - }); const active = getActive(refId, refs); const { current: frames } = useRef>({}); @@ -74,19 +69,21 @@ export const FramesRenderer: FC = ({ }, {}); if (!frames['storybook-preview-iframe']) { - frames['storybook-preview-iframe'] = getStoryHref(baseUrl, storyId, { - ...queryParams, - ...(version && { version }), + frames['storybook-preview-iframe'] = api.getStoryHrefs(storyId, { + queryParams: { ...queryParams, ...(version && { version }) }, + refId, viewMode, - }); + }).previewHref; } refsToLoad.forEach((ref) => { const id = `storybook-ref-${ref.id}`; - const existingUrl = frames[id]?.split('/iframe.html')[0]; - if (!existingUrl || ref.url !== existingUrl) { - const newUrl = `${ref.url}/iframe.html?id=${storyId}&viewMode=${viewMode}&refId=${ref.id}${stringifiedQueryParams}`; - frames[id] = newUrl; + if (!frames[id]?.startsWith(ref.url)) { + frames[id] = api.getStoryHrefs(storyId, { + queryParams: { ...queryParams, ...(version && { version }) }, + refId: ref.id, + viewMode, + }).previewHref; } }); diff --git a/code/core/src/manager/components/preview/Iframe.stories.tsx b/code/core/src/manager/components/preview/Iframe.stories.tsx index 2390ca501774..319fde0986bb 100644 --- a/code/core/src/manager/components/preview/Iframe.stories.tsx +++ b/code/core/src/manager/components/preview/Iframe.stories.tsx @@ -30,34 +30,38 @@ const style: CSSProperties = { height: '700px', }; -export const WorkingStory = () => ( -