From e35167ec36e7dfe42f42a45e1a6afc3e915f52b9 Mon Sep 17 00:00:00 2001 From: Tom Coleman Date: Wed, 29 Jun 2022 13:50:47 +1000 Subject: [PATCH] WIP --- addons/docs/src/preset.ts | 1 + lib/addons/src/types.ts | 6 +- lib/core-common/src/types.ts | 1 + lib/core-server/package.json | 2 +- .../src/utils/StoryIndexGenerator.test.ts | 399 +++++++++++++++--- .../src/utils/StoryIndexGenerator.ts | 192 ++++++--- .../__mockdata__/errors/DuplicateMetaOf.mdx | 7 + .../errors/MetaOfClashingName.mdx | 9 + .../{src => errors}/NoMeta.stories.ts | 0 .../utils/__mockdata__/src/docs2/MetaOf.mdx | 4 +- .../__mockdata__/src/docs2/SecondMetaOf.mdx | 7 + .../utils/__mockdata__/src/docs2/Template.mdx | 7 + .../src/utils/stories-json.test.ts | 32 +- lib/preview-web/src/DocsRender.ts | 2 +- lib/store/src/StoryStore.ts | 2 +- lib/store/src/types.ts | 18 +- yarn.lock | 10 +- 17 files changed, 578 insertions(+), 121 deletions(-) create mode 100644 lib/core-server/src/utils/__mockdata__/errors/DuplicateMetaOf.mdx create mode 100644 lib/core-server/src/utils/__mockdata__/errors/MetaOfClashingName.mdx rename lib/core-server/src/utils/__mockdata__/{src => errors}/NoMeta.stories.ts (100%) create mode 100644 lib/core-server/src/utils/__mockdata__/src/docs2/SecondMetaOf.mdx create mode 100644 lib/core-server/src/utils/__mockdata__/src/docs2/Template.mdx diff --git a/addons/docs/src/preset.ts b/addons/docs/src/preset.ts index 583431ebf3ef..6e2090febae4 100644 --- a/addons/docs/src/preset.ts +++ b/addons/docs/src/preset.ts @@ -151,6 +151,7 @@ export const storyIndexers = async (indexers: StoryIndexer[] | null) => { { test: /(stories|story)\.mdx$/, indexer: mdxIndexer, + addDocsTemplate: true, }, ...(indexers || []), ]; diff --git a/lib/addons/src/types.ts b/lib/addons/src/types.ts index 0b518cff7310..fc7aff22035a 100644 --- a/lib/addons/src/types.ts +++ b/lib/addons/src/types.ts @@ -65,8 +65,12 @@ export type StoryIndexEntry = BaseIndexEntry & { export type DocsIndexEntry = BaseIndexEntry & { storiesImports: Path[]; type: 'docs'; - legacy?: boolean; + standalone: boolean; }; + +export type StandaloneDocsIndexEntry = DocsIndexEntry & { standalone: true }; +export type TemplateDocsIndexEntry = DocsIndexEntry & { standalone: false }; + export type IndexEntry = StoryIndexEntry | DocsIndexEntry; // The `any` here is the story store's `StoreItem` record. Ideally we should probably only diff --git a/lib/core-common/src/types.ts b/lib/core-common/src/types.ts index 480ad7d4e9ae..6239b79b2458 100644 --- a/lib/core-common/src/types.ts +++ b/lib/core-common/src/types.ts @@ -245,6 +245,7 @@ export interface StoryIndex { export interface StoryIndexer { test: RegExp; indexer: (fileName: string, options: IndexerOptions) => Promise; + addDocsTemplate?: boolean; } /** diff --git a/lib/core-server/package.json b/lib/core-server/package.json index 3afac00ae3d0..29122a51a02f 100644 --- a/lib/core-server/package.json +++ b/lib/core-server/package.json @@ -38,7 +38,7 @@ "@storybook/core-events": "7.0.0-alpha.6", "@storybook/csf": "0.0.2--canary.4566f4d.1", "@storybook/csf-tools": "7.0.0-alpha.6", - "@storybook/docs-mdx": "0.0.1-canary.1.4bea5cc.0", + "@storybook/docs-mdx": "0.0.1-canary.12433cf.0", "@storybook/manager-webpack5": "7.0.0-alpha.6", "@storybook/node-logger": "7.0.0-alpha.6", "@storybook/semver": "^7.3.2", diff --git a/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 6929a9573a51..59085c9ebfda 100644 --- a/lib/core-server/src/utils/StoryIndexGenerator.test.ts +++ b/lib/core-server/src/utils/StoryIndexGenerator.test.ts @@ -4,6 +4,8 @@ import { normalizeStoriesEntry } from '@storybook/core-common'; import type { NormalizedStoriesSpecifier } from '@storybook/core-common'; import { loadCsf, getStorySortParameter } from '@storybook/csf-tools'; import { toId } from '@storybook/csf'; +import { logger } from '@storybook/node-logger'; +import { mocked } from 'ts-jest/utils'; import { StoryIndexGenerator } from './StoryIndexGenerator'; @@ -22,11 +24,15 @@ jest.mock('@storybook/docs-mdx', async () => ({ const importMatches = content.matchAll(/'(.[^']*\.stories)'/g); const imports = Array.from(importMatches).map((match) => match[1]); const title = content.match(/title=['"](.*)['"]/)?.[1]; + const name = content.match(/name=['"](.*)['"]/)?.[1]; const ofMatch = content.match(/of=\{(.*)\}/)?.[1]; - return { title, imports, of: ofMatch && imports.length && imports[0] }; + const isTemplate = content.match(/isTemplate/); + return { title, name, imports, of: ofMatch && imports.length && imports[0], isTemplate }; }, })); +jest.mock('@storybook/node-logger'); + const toIdMock = toId as jest.Mock>; const loadCsfMock = loadCsf as jest.Mock>; const getStorySortParameterMock = getStorySortParameter as jest.Mock< @@ -44,15 +50,25 @@ const options = { storyIndexers: [{ test: /\.stories\..*$/, indexer: csfIndexer }], storiesV2Compatibility: false, storyStoreV7: true, - docs: { enabled: true, defaultName: 'docs', docsPage: true }, + docs: { enabled: true, defaultName: 'docs', docsPage: false }, }; describe('StoryIndexGenerator', () => { beforeEach(() => { const actual = jest.requireActual('@storybook/csf-tools'); loadCsfMock.mockImplementation(actual.loadCsf); + mocked(logger.warn).mockClear(); }); describe('extraction', () => { + const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.(ts|js|jsx)', + options + ); + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/*.mdx', + options + ); + describe('single file specifier', () => { it('extracts stories from the right files', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( @@ -175,17 +191,139 @@ describe('StoryIndexGenerator', () => { }); }); - describe('docs specifier', () => { - const storiesSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/A.stories.(ts|js|jsx)', - options - ); - const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', - options - ); - it('extracts stories from the right files', async () => { - const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + describe('docsPage', () => { + it('generates an entry per CSF file', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + docs: { ...options.docs, docsPage: true }, + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--docs": Object { + "id": "a--docs", + "importPath": "./src/A.stories.js", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "A", + "type": "docs", + }, + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "title": "A", + "type": "story", + }, + "b--docs": Object { + "id": "b--docs", + "importPath": "./src/B.stories.ts", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "B", + "type": "docs", + }, + "b--story-one": Object { + "id": "b--story-one", + "importPath": "./src/B.stories.ts", + "name": "Story One", + "title": "B", + "type": "story", + }, + "d--docs": Object { + "id": "d--docs", + "importPath": "./src/D.stories.jsx", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "D", + "type": "docs", + }, + "d--story-one": Object { + "id": "d--story-one", + "importPath": "./src/D.stories.jsx", + "name": "Story One", + "title": "D", + "type": "story", + }, + "first-nested-deeply-f--docs": Object { + "id": "first-nested-deeply-f--docs", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "first-nested/deeply/F", + "type": "docs", + }, + "first-nested-deeply-f--story-one": Object { + "id": "first-nested-deeply-f--story-one", + "importPath": "./src/first-nested/deeply/F.stories.js", + "name": "Story One", + "title": "first-nested/deeply/F", + "type": "story", + }, + "nested-button--docs": Object { + "id": "nested-button--docs", + "importPath": "./src/nested/Button.stories.ts", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "nested/Button", + "type": "docs", + }, + "nested-button--story-one": Object { + "id": "nested-button--story-one", + "importPath": "./src/nested/Button.stories.ts", + "name": "Story One", + "title": "nested/Button", + "type": "story", + }, + "second-nested-g--docs": Object { + "id": "second-nested-g--docs", + "importPath": "./src/second-nested/G.stories.ts", + "name": "docs", + "standalone": false, + "storiesImports": Array [], + "title": "second-nested/G", + "type": "docs", + }, + "second-nested-g--story-one": Object { + "id": "second-nested-g--story-one", + "importPath": "./src/second-nested/G.stories.ts", + "name": "Story One", + "title": "second-nested/G", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + + it('does not generate a docs page entry if there is a standalone entry with the same name', async () => { + const csfSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/docs2/MetaOf.mdx', + options + ); + + const generator = new StoryIndexGenerator([csfSpecifier, docsSpecifier], { + ...options, + docs: { ...options.docs, docsPage: true }, + }); await generator.initialize(); expect(await generator.getIndex()).toMatchInlineSnapshot(` @@ -195,6 +333,7 @@ describe('StoryIndexGenerator', () => { "id": "a--docs", "importPath": "./src/docs2/MetaOf.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], @@ -208,35 +347,74 @@ describe('StoryIndexGenerator', () => { "title": "A", "type": "story", }, - "docs2-notitle--docs": Object { - "id": "docs2-notitle--docs", - "importPath": "./src/docs2/NoTitle.mdx", + }, + "v": 4, + } + `); + }); + }); + + describe('docs specifier', () => { + it('creates correct docs entries', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--docs": Object { + "id": "a--docs", + "importPath": "./src/docs2/MetaOf.mdx", "name": "docs", - "storiesImports": Array [], - "title": "docs2/NoTitle", + "standalone": true, + "storiesImports": Array [ + "./src/A.stories.js", + ], + "title": "A", + "type": "docs", + }, + "a--second-docs": Object { + "id": "a--second-docs", + "importPath": "./src/docs2/SecondMetaOf.mdx", + "name": "Second Docs", + "standalone": true, + "storiesImports": Array [ + "./src/A.stories.js", + ], + "title": "A", "type": "docs", }, + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "title": "A", + "type": "story", + }, "docs2-yabbadabbadooo--docs": Object { "id": "docs2-yabbadabbadooo--docs", "importPath": "./src/docs2/Title.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [], "title": "docs2/Yabbadabbadooo", "type": "docs", }, + "notitle--docs": Object { + "id": "notitle--docs", + "importPath": "./src/docs2/NoTitle.mdx", + "name": "docs", + "standalone": true, + "storiesImports": Array [], + "title": "NoTitle", + "type": "docs", + }, }, "v": 4, } `); }); - it('errors when docs dependencies are missing', async () => { - const generator = new StoryIndexGenerator([docsSpecifier], options); - await expect(() => generator.initialize()).rejects.toThrowErrorMatchingInlineSnapshot( - `"Could not find \\"../A.stories\\" for docs file \\"src/docs2/MetaOf.mdx\\"."` - ); - }); - it('generates no docs entries when docs are disabled', async () => { const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], { ...options, @@ -279,6 +457,18 @@ describe('StoryIndexGenerator', () => { "id": "a--info", "importPath": "./src/docs2/MetaOf.mdx", "name": "Info", + "standalone": true, + "storiesImports": Array [ + "./src/A.stories.js", + ], + "title": "A", + "type": "docs", + }, + "a--second-docs": Object { + "id": "a--second-docs", + "importPath": "./src/docs2/SecondMetaOf.mdx", + "name": "Second Docs", + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], @@ -292,28 +482,122 @@ describe('StoryIndexGenerator', () => { "title": "A", "type": "story", }, - "docs2-notitle--info": Object { - "id": "docs2-notitle--info", - "importPath": "./src/docs2/NoTitle.mdx", - "name": "Info", - "storiesImports": Array [], - "title": "docs2/NoTitle", - "type": "docs", - }, "docs2-yabbadabbadooo--info": Object { "id": "docs2-yabbadabbadooo--info", "importPath": "./src/docs2/Title.mdx", "name": "Info", + "standalone": true, "storiesImports": Array [], "title": "docs2/Yabbadabbadooo", "type": "docs", }, + "notitle--info": Object { + "id": "notitle--info", + "importPath": "./src/docs2/NoTitle.mdx", + "name": "Info", + "standalone": true, + "storiesImports": Array [], + "title": "NoTitle", + "type": "docs", + }, }, "v": 4, } `); }); }); + + describe('errors', () => { + it('when docs dependencies are missing', async () => { + const generator = new StoryIndexGenerator( + [normalizeStoriesEntry('./src/docs2/MetaOf.mdx', options)], + options + ); + await expect(() => generator.initialize()).rejects.toThrowError( + /Could not find "..\/A.stories" for docs file/ + ); + }); + }); + + describe('duplicates', () => { + it('warns when two standalone entries reference the same CSF file without a name', async () => { + const docsErrorSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/DuplicateMetaOf.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsErrorSpecifier], + options + ); + await generator.initialize(); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + "a--docs", + "notitle--docs", + "a--second-docs", + "docs2-yabbadabbadooo--docs", + ] + `); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(mocked(logger.warn).mock.calls[0][0]).toMatchInlineSnapshot( + `"🚨 You have two component docs pages with the same name A:docs. Use \`\` to distinguish them."` + ); + }); + + it('warns when a standalone entry has the same name as a story', async () => { + const docsErrorSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './errors/MetaOfClashingName.mdx', + options + ); + + const generator = new StoryIndexGenerator( + [storiesSpecifier, docsSpecifier, docsErrorSpecifier], + options + ); + await generator.initialize(); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + "a--docs", + "notitle--docs", + "a--second-docs", + "docs2-yabbadabbadooo--docs", + ] + `); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(mocked(logger.warn).mock.calls[0][0]).toMatchInlineSnapshot( + `"🚨 You have a story for A with the same name as your component docs page (Story One), so the docs page is being dropped. Use \`\` to distinguish them."` + ); + }); + + it('warns when a story has the default docs name', async () => { + const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], { + ...options, + docs: { ...options.docs, defaultName: 'Story One' }, + }); + await generator.initialize(); + + expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` + Array [ + "a--story-one", + "notitle--story-one", + "a--second-docs", + "docs2-yabbadabbadooo--story-one", + ] + `); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(mocked(logger.warn).mock.calls[0][0]).toMatchInlineSnapshot( + `"🚨 You have a story for A with the same name as your default docs entry name (Story One), so the docs page is being dropped. Consider changing the story name."` + ); + }); + }); }); describe('sorting', () => { @@ -323,7 +607,7 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); @@ -336,14 +620,15 @@ describe('StoryIndexGenerator', () => { expect(Object.keys((await generator.getIndex()).entries)).toMatchInlineSnapshot(` Array [ - "docs2-notitle--docs", "docs2-yabbadabbadooo--docs", "d--story-one", "b--story-one", "nested-button--story-one", "a--docs", + "a--second-docs", "a--story-one", "second-nested-g--story-one", + "notitle--docs", "first-nested-deeply-f--story-one", ] `); @@ -362,7 +647,7 @@ describe('StoryIndexGenerator', () => { const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(loadCsfMock).toHaveBeenCalledTimes(7); + expect(loadCsfMock).toHaveBeenCalledTimes(6); loadCsfMock.mockClear(); await generator.getIndex(); @@ -375,14 +660,14 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); toIdMock.mockClear(); await generator.getIndex(); @@ -419,7 +704,7 @@ describe('StoryIndexGenerator', () => { const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(loadCsfMock).toHaveBeenCalledTimes(7); + expect(loadCsfMock).toHaveBeenCalledTimes(6); generator.invalidate(specifier, './src/B.stories.ts', false); @@ -434,14 +719,14 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); generator.invalidate(docsSpecifier, './src/docs2/Title.mdx', false); @@ -456,20 +741,20 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); generator.invalidate(storiesSpecifier, './src/A.stories.js', false); toIdMock.mockClear(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(2); + expect(toId).toHaveBeenCalledTimes(3); }); it('does call the sort function a second time', async () => { @@ -504,7 +789,7 @@ describe('StoryIndexGenerator', () => { const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(loadCsfMock).toHaveBeenCalledTimes(7); + expect(loadCsfMock).toHaveBeenCalledTimes(6); generator.invalidate(specifier, './src/B.stories.ts', true); @@ -543,7 +828,7 @@ describe('StoryIndexGenerator', () => { const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(loadCsfMock).toHaveBeenCalledTimes(7); + expect(loadCsfMock).toHaveBeenCalledTimes(6); generator.invalidate(specifier, './src/B.stories.ts', true); @@ -556,22 +841,20 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); - expect(Object.keys((await generator.getIndex()).entries)).toContain('docs2-notitle--docs'); + expect(Object.keys((await generator.getIndex()).entries)).toContain('notitle--docs'); generator.invalidate(docsSpecifier, './src/docs2/NoTitle.mdx', true); - expect(Object.keys((await generator.getIndex()).entries)).not.toContain( - 'docs2-notitle--docs' - ); + expect(Object.keys((await generator.getIndex()).entries)).not.toContain('notitle--docs'); }); it('errors on dependency deletion', async () => { @@ -580,21 +863,21 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); expect(Object.keys((await generator.getIndex()).entries)).toContain('a--story-one'); generator.invalidate(storiesSpecifier, './src/A.stories.js', true); - await expect(() => generator.getIndex()).rejects.toThrowErrorMatchingInlineSnapshot( - `"Could not find \\"../A.stories\\" for docs file \\"src/docs2/MetaOf.mdx\\"."` + await expect(() => generator.getIndex()).rejects.toThrowError( + /Could not find "..\/A.stories" for docs file/ ); }); @@ -604,14 +887,14 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); const generator = new StoryIndexGenerator([docsSpecifier, storiesSpecifier], options); await generator.initialize(); await generator.getIndex(); - expect(toId).toHaveBeenCalledTimes(4); + expect(toId).toHaveBeenCalledTimes(5); expect(Object.keys((await generator.getIndex()).entries)).toContain('a--docs'); diff --git a/lib/core-server/src/utils/StoryIndexGenerator.ts b/lib/core-server/src/utils/StoryIndexGenerator.ts index b845adc4fb24..2b74bebae55b 100644 --- a/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -9,23 +9,25 @@ import type { V2CompatIndexEntry, StoryId, IndexEntry, - DocsIndexEntry, + StoryIndexEntry, + StandaloneDocsIndexEntry, + TemplateDocsIndexEntry, } from '@storybook/store'; import { userOrAutoTitleFromSpecifier, sortStoriesV7 } from '@storybook/store'; -import type { - StoryIndexer, - IndexerOptions, - NormalizedStoriesSpecifier, - DocsOptions, -} from '@storybook/core-common'; +import type { StoryIndexer, NormalizedStoriesSpecifier, DocsOptions } from '@storybook/core-common'; import { normalizeStoryPath } from '@storybook/core-common'; import { logger } from '@storybook/node-logger'; import { getStorySortParameter } from '@storybook/csf-tools'; -import type { ComponentTitle } from '@storybook/csf'; +import type { ComponentTitle, StoryName } from '@storybook/csf'; import { toId } from '@storybook/csf'; -type DocsCacheEntry = DocsIndexEntry; -type StoriesCacheEntry = { entries: IndexEntry[]; dependents: Path[]; type: 'stories' }; +/** A .mdx file will produce a "standalone" docs entry */ +type DocsCacheEntry = StandaloneDocsIndexEntry; +type StoriesCacheEntry = { + entries: (StoryIndexEntry | TemplateDocsIndexEntry)[]; + dependents: Path[]; + type: 'stories'; +}; type CacheEntry = false | StoriesCacheEntry | DocsCacheEntry; type SpecifierStoriesCache = Record; @@ -39,6 +41,24 @@ const makeAbsolute = (otherImport: Path, normalizedPath: Path, workingDir: Path) ) : otherImport; +/** + * The StoryIndexGenerator extracts stories and docs entries for each file matching + * (one or more) stories "specifiers", as defined in main.js. + * + * The output is a set of entries (see above for the types). + * + * Each file is treated as a stories or a (modern) docs file. + * + * A stories file is indexed by an indexer (passed in), which produces a list of stories. + * - If the stories have the `parameters.docsOnly` setting, they are disregarded. + * - If the indexer is a "docs template" indexer, OR docsPage is enabled, + * a templated docs entry is added pointing to the story file. + * + * A (modern) docs file is indexed, a standalone docs entry is added. + * + * The entries are "uniq"-ed and sorted. Stories entries are preferred to docs entries and + * standalone docs entries are preferred to templates (with warnings). + */ export class StoryIndexGenerator { // An internal cache mapping specifiers to a set of path=> // Later, we'll combine each of these subsets together to form the full index @@ -96,14 +116,20 @@ export class StoryIndexGenerator { * Run the updater function over all the empty cache entries */ async updateExtracted( - updater: (specifier: NormalizedStoriesSpecifier, absolutePath: Path) => Promise + updater: ( + specifier: NormalizedStoriesSpecifier, + absolutePath: Path, + existingEntry: CacheEntry + ) => Promise, + overwrite = false ) { await Promise.all( this.specifiers.map(async (specifier) => { const entry = this.specifierToCache.get(specifier); return Promise.all( Object.keys(entry).map(async (absolutePath) => { - entry[absolutePath] = entry[absolutePath] || (await updater(specifier, absolutePath)); + if (entry[absolutePath] && !overwrite) return; + entry[absolutePath] = await updater(specifier, absolutePath, entry[absolutePath]); }) ); }) @@ -167,6 +193,57 @@ export class StoryIndexGenerator { return dependencies; } + async extractStories(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { + const relativePath = path.relative(this.options.workingDir, absolutePath); + const entries = [] as IndexEntry[]; + try { + const importPath = slash(normalizeStoryPath(relativePath)); + const makeTitle = (userTitle?: string) => { + return userOrAutoTitleFromSpecifier(importPath, specifier, userTitle); + }; + + const storyIndexer = this.options.storyIndexers.find((indexer) => + indexer.test.exec(absolutePath) + ); + if (!storyIndexer) { + throw new Error(`No matching story indexer found for ${absolutePath}`); + } + const csf = await storyIndexer.indexer(absolutePath, { makeTitle }); + + csf.stories.forEach(({ id, name, parameters }) => { + if (!parameters?.docsOnly) { + entries.push({ id, title: csf.meta.title, name, importPath, type: 'story' }); + } + }); + + if (this.options.docs.enabled) { + // We always add a template for *.stories.mdx, but only if docs page is enabled for + // regular CSF files + if (storyIndexer.addDocsTemplate || this.options.docs.docsPage) { + const name = this.options.docs.defaultName; + const id = toId(csf.meta.title, name); + entries.unshift({ + id, + title: csf.meta.title, + name, + importPath, + type: 'docs', + storiesImports: [], + standalone: false, + }); + } + } + } catch (err) { + if (err.name === 'NoMetaError') { + logger.info(`💡 Skipping ${relativePath}: ${err}`); + } else { + logger.warn(`🚨 Extraction error on ${relativePath}: ${err}`); + throw err; + } + } + return { entries, type: 'stories', dependents: [] } as StoriesCacheEntry; + } + async extractDocs(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { const relativePath = path.relative(this.options.workingDir, absolutePath); try { @@ -186,8 +263,16 @@ export class StoryIndexGenerator { // eslint-disable-next-line global-require const { analyze } = await require('@storybook/docs-mdx'); const content = await fs.readFile(absolutePath, 'utf8'); - // { title?, of?, imports? } - const result = analyze(content); + const result: { + title?: ComponentTitle; + of?: Path; + name?: StoryName; + isTemplate?: boolean; + imports?: Path[]; + } = analyze(content); + + // Templates are not indexed + if (result.isTemplate) return false; const absoluteImports = (result.imports as string[]).map((p) => makeAbsolute(p, normalizedPath, this.options.workingDir) @@ -222,7 +307,7 @@ export class StoryIndexGenerator { }); const title = userOrAutoTitleFromSpecifier(importPath, specifier, result.title || ofTitle); - const name = this.options.docs.defaultName; + const name = result.name || this.options.docs.defaultName; const id = toId(title, name); const docsEntry: DocsCacheEntry = { @@ -232,6 +317,7 @@ export class StoryIndexGenerator { importPath, storiesImports: dependencies.map((dep) => dep.entries[0].importPath), type: 'docs', + standalone: true, }; return docsEntry; } catch (err) { @@ -240,50 +326,60 @@ export class StoryIndexGenerator { } } - async index(filePath: string, options: IndexerOptions) { - const storyIndexer = this.options.storyIndexers.find((indexer) => indexer.test.exec(filePath)); - if (!storyIndexer) { - throw new Error(`No matching story indexer found for ${filePath}`); - } - return storyIndexer.indexer(filePath, options); - } + chooseDuplicate(betterEntry: IndexEntry, worseEntry: IndexEntry): IndexEntry { + const changeDocsName = 'Use `` to distinguish them.'; - async extractStories(specifier: NormalizedStoriesSpecifier, absolutePath: Path) { - const relativePath = path.relative(this.options.workingDir, absolutePath); - const entries = [] as IndexEntry[]; - try { - const importPath = slash(normalizeStoryPath(relativePath)); - const makeTitle = (userTitle?: string) => { - return userOrAutoTitleFromSpecifier(importPath, specifier, userTitle); - }; - const csf = await this.index(absolutePath, { makeTitle }); - csf.stories.forEach(({ id, name, parameters }) => { - const base = { id, title: csf.meta.title, name, importPath }; + if (betterEntry.type === 'story') { + // This shouldn't be possible + if (worseEntry.type === 'story') + throw new Error(`Duplicate stories with id: ${betterEntry.id}`); - if (parameters?.docsOnly) { - if (this.options.docs.enabled) { - entries.push({ ...base, type: 'docs', storiesImports: [], legacy: true }); - } - } else { - entries.push({ ...base, type: 'story' }); - } - }); - } catch (err) { - if (err.name === 'NoMetaError') { - logger.info(`💡 Skipping ${relativePath}: ${err}`); + const worseDescriptor = worseEntry.standalone + ? `component docs page` + : `automatically generated docs page`; + if (betterEntry.name === this.options.docs.defaultName) { + logger.warn( + `🚨 You have a story for ${betterEntry.title} with the same name as your default docs entry name (${betterEntry.name}), so the docs page is being dropped. Consider changing the story name.` + ); } else { - logger.warn(`🚨 Extraction error on ${relativePath}: ${err}`); - throw err; + logger.warn( + `🚨 You have a story for ${betterEntry.title} with the same name as your ${worseDescriptor} (${worseEntry.name}), so the docs page is being dropped. ${changeDocsName}` + ); } + } else { + // Check if the other is truly better -- story is better than docs + if (worseEntry.type === 'story') return this.chooseDuplicate(worseEntry, betterEntry); + + // Check if the other is truly better -- manual docs better than automatic + if (!betterEntry.standalone) return this.chooseDuplicate(worseEntry, betterEntry); + + // Both entries are standalone but pointing at the same place + if (worseEntry.standalone) { + logger.warn( + `🚨 You have two component docs pages with the same name ${betterEntry.title}:${betterEntry.name}. ${changeDocsName}` + ); + } + + // If one entry is standalone (i.e. .mdx of={}) we are OK with it overriding a template + // - docs page templates, this is totally fine and expected + // - not sure if it is even possible to have a .mdx of={} pointing at a stories.mdx file + // If both entries are templates (e.g. you have two CSF files with the same title), then + // they are effectively equal. } - return { entries, type: 'stories', dependents: [] } as StoriesCacheEntry; + + return betterEntry; } async sortStories(storiesList: IndexEntry[]) { const entries: StoryIndex['entries'] = {}; storiesList.forEach((entry) => { - entries[entry.id] = entry; + const existing = entries[entry.id]; + if (existing) { + entries[entry.id] = this.chooseDuplicate(existing, entry); + } else { + entries[entry.id] = entry; + } }); const sortableStories = Object.values(entries); diff --git a/lib/core-server/src/utils/__mockdata__/errors/DuplicateMetaOf.mdx b/lib/core-server/src/utils/__mockdata__/errors/DuplicateMetaOf.mdx new file mode 100644 index 000000000000..261b3ee054ab --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/errors/DuplicateMetaOf.mdx @@ -0,0 +1,7 @@ +import * as AStories from '../src/A.stories'; + + + +# Docs with of + +hello docs diff --git a/lib/core-server/src/utils/__mockdata__/errors/MetaOfClashingName.mdx b/lib/core-server/src/utils/__mockdata__/errors/MetaOfClashingName.mdx new file mode 100644 index 000000000000..f5cf2561dcc2 --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/errors/MetaOfClashingName.mdx @@ -0,0 +1,9 @@ +import * as AStories from '../src/A.stories'; + + + + + +# Docs with of + +hello docs diff --git a/lib/core-server/src/utils/__mockdata__/src/NoMeta.stories.ts b/lib/core-server/src/utils/__mockdata__/errors/NoMeta.stories.ts similarity index 100% rename from lib/core-server/src/utils/__mockdata__/src/NoMeta.stories.ts rename to lib/core-server/src/utils/__mockdata__/errors/NoMeta.stories.ts diff --git a/lib/core-server/src/utils/__mockdata__/src/docs2/MetaOf.mdx b/lib/core-server/src/utils/__mockdata__/src/docs2/MetaOf.mdx index 9a710755c0a1..acd170dd55ba 100644 --- a/lib/core-server/src/utils/__mockdata__/src/docs2/MetaOf.mdx +++ b/lib/core-server/src/utils/__mockdata__/src/docs2/MetaOf.mdx @@ -1,6 +1,6 @@ -import meta from '../A.stories'; +import * as AStories from '../A.stories'; - + # Docs with of diff --git a/lib/core-server/src/utils/__mockdata__/src/docs2/SecondMetaOf.mdx b/lib/core-server/src/utils/__mockdata__/src/docs2/SecondMetaOf.mdx new file mode 100644 index 000000000000..f2c43cfa83a9 --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/src/docs2/SecondMetaOf.mdx @@ -0,0 +1,7 @@ +import * as AStories from '../A.stories'; + + + +# Second Docs + +hello docs diff --git a/lib/core-server/src/utils/__mockdata__/src/docs2/Template.mdx b/lib/core-server/src/utils/__mockdata__/src/docs2/Template.mdx new file mode 100644 index 000000000000..420fac1c277b --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/src/docs2/Template.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# Docs with title + +hello docs diff --git a/lib/core-server/src/utils/stories-json.test.ts b/lib/core-server/src/utils/stories-json.test.ts index a1af8223a897..a45b75d3ba4e 100644 --- a/lib/core-server/src/utils/stories-json.test.ts +++ b/lib/core-server/src/utils/stories-json.test.ts @@ -61,7 +61,7 @@ const getInitializedStoryIndexGenerator = async ( workingDir, storiesV2Compatibility: false, storyStoreV7: true, - docs: { enabled: true, defaultName: 'docs', docsPage: true }, + docs: { enabled: true, defaultName: 'docs', docsPage: false }, ...overrides, }); await generator.initialize(); @@ -120,6 +120,7 @@ describe('useStoriesJson', () => { "id": "a--docs", "importPath": "./src/docs2/MetaOf.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], @@ -151,14 +152,25 @@ describe('useStoriesJson', () => { "id": "docs2-notitle--docs", "importPath": "./src/docs2/NoTitle.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [], "title": "docs2/NoTitle", "type": "docs", }, + "docs2-template--docs": Object { + "id": "docs2-template--docs", + "importPath": "./src/docs2/Template.mdx", + "name": "docs", + "standalone": true, + "storiesImports": Array [], + "title": "docs2/Template", + "type": "docs", + }, "docs2-yabbadabbadooo--docs": Object { "id": "docs2-yabbadabbadooo--docs", "importPath": "./src/docs2/Title.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [], "title": "docs2/Yabbadabbadooo", "type": "docs", @@ -219,6 +231,7 @@ describe('useStoriesJson', () => { "docsOnly": true, "fileName": "./src/docs2/MetaOf.mdx", }, + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], @@ -274,10 +287,26 @@ describe('useStoriesJson', () => { "docsOnly": true, "fileName": "./src/docs2/NoTitle.mdx", }, + "standalone": true, "storiesImports": Array [], "story": "docs", "title": "docs2/NoTitle", }, + "docs2-template--docs": Object { + "id": "docs2-template--docs", + "importPath": "./src/docs2/Template.mdx", + "kind": "docs2/Template", + "name": "docs", + "parameters": Object { + "__id": "docs2-template--docs", + "docsOnly": true, + "fileName": "./src/docs2/Template.mdx", + }, + "standalone": true, + "storiesImports": Array [], + "story": "docs", + "title": "docs2/Template", + }, "docs2-yabbadabbadooo--docs": Object { "id": "docs2-yabbadabbadooo--docs", "importPath": "./src/docs2/Title.mdx", @@ -288,6 +317,7 @@ describe('useStoriesJson', () => { "docsOnly": true, "fileName": "./src/docs2/Title.mdx", }, + "standalone": true, "storiesImports": Array [], "story": "docs", "title": "docs2/Yabbadabbadooo", diff --git a/lib/preview-web/src/DocsRender.ts b/lib/preview-web/src/DocsRender.ts index 7ff7d5f690a7..1c0e98f6edfa 100644 --- a/lib/preview-web/src/DocsRender.ts +++ b/lib/preview-web/src/DocsRender.ts @@ -44,7 +44,7 @@ export class DocsRender implements Render { async loadEntry(id: StoryId) { const entry = await this.storyIdToEntry(id); - if (entry.type === 'docs' && !entry.legacy) { + if (entry.type === 'docs' && entry.standalone) { return this.loadDocsFileById(id); } return this.loadCSFFileByStoryId(id); diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index c84bdd451807..d816fab56a2f 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -22,9 +22,21 @@ import type { PartialStoryFn, Parameters, } from '@storybook/csf'; -import type { StoryIndexEntry, DocsIndexEntry, IndexEntry } from '@storybook/addons'; - -export type { StoryIndexEntry, DocsIndexEntry, IndexEntry }; +import type { + StoryIndexEntry, + DocsIndexEntry, + TemplateDocsIndexEntry, + StandaloneDocsIndexEntry, + IndexEntry, +} from '@storybook/addons'; + +export type { + StoryIndexEntry, + DocsIndexEntry, + IndexEntry, + TemplateDocsIndexEntry, + StandaloneDocsIndexEntry, +}; export type { StoryId, Parameters }; export type Path = string; export type ModuleExport = any; diff --git a/yarn.lock b/yarn.lock index 636264e47c20..c8493e85657f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8140,7 +8140,7 @@ __metadata: "@storybook/core-events": 7.0.0-alpha.6 "@storybook/csf": 0.0.2--canary.4566f4d.1 "@storybook/csf-tools": 7.0.0-alpha.6 - "@storybook/docs-mdx": 0.0.1-canary.1.4bea5cc.0 + "@storybook/docs-mdx": 0.0.1-canary.12433cf.0 "@storybook/manager-webpack5": 7.0.0-alpha.6 "@storybook/node-logger": 7.0.0-alpha.6 "@storybook/semver": ^7.3.2 @@ -8280,15 +8280,15 @@ __metadata: languageName: node linkType: hard -"@storybook/docs-mdx@npm:0.0.1-canary.1.4bea5cc.0": - version: 0.0.1-canary.1.4bea5cc.0 - resolution: "@storybook/docs-mdx@npm:0.0.1-canary.1.4bea5cc.0" +"@storybook/docs-mdx@npm:0.0.1-canary.12433cf.0": + version: 0.0.1-canary.12433cf.0 + resolution: "@storybook/docs-mdx@npm:0.0.1-canary.12433cf.0" dependencies: "@babel/traverse": ^7.12.11 "@mdx-js/mdx": ^2.0.0 estree-to-babel: ^4.9.0 hast-util-to-estree: ^2.0.2 - checksum: e124e704f31908775f42caf61ed88daed1fc21d582a0884bc38af603f1dcc32647eb0472b2ade72b361d3b5c2d467b9d6ea41def56b62ad1b3169e0229be3a7f + checksum: 41ad8df3c898d0eea257ffee4c7a127625ebd95d7af01899b342562254e90c70e4c8f56ae32ad8171c72f1cc0503c8c5967c43fc9ef2b90eaee2506060db898c languageName: node linkType: hard