diff --git a/.changeset/eighty-apes-float.md b/.changeset/eighty-apes-float.md new file mode 100644 index 00000000..dd1dfe78 --- /dev/null +++ b/.changeset/eighty-apes-float.md @@ -0,0 +1,5 @@ +--- +'@jpmorganchase/mosaic-source-storybook': patch +--- + +Fix storybook source not working with index.json diff --git a/docs/configure/sources/source-storybook.mdx b/docs/configure/sources/source-storybook.mdx index def271ec..a190f602 100644 --- a/docs/configure/sources/source-storybook.mdx +++ b/docs/configure/sources/source-storybook.mdx @@ -10,7 +10,7 @@ sidebar: The Storybook source is used to pull individual stories from Storybook, based on tags. -The Mosaic source will filter your Storybook's stories, based on `kind` or `tags`. +The Mosaic source will filter your Storybook's stories, based on `title` or `tags`. For each matching story, a page is created in the Mosaic file-system. @@ -29,7 +29,7 @@ The Storybook source is an [`HttpSource`](./https-source) and shares the same ba The `transformResponseToPagesModulePath` prop has a default transformer which creates a page for each matching Story. The `stories` option is an array of Storybook urls that are used as Sources. -Each story is matched on `kind` using the `filter` Regexp (or by `filterTags`). +Each story is matched on `title` using the `filter` Regexp (or by `filterTags`). If specified, `additionalTags` and `additionalData` can be added to any matching pages. @@ -69,10 +69,26 @@ sources: [ source: 'STORYBOOK' } }, - filter: /Lab\/Tabs/ // this is a Regexp that matches on Storybook kind + filter: /Lab\/Tabs/ // this is a Regexp that matches on Storybook title } ] } } ] ``` + +### Example story metadata format + +``` +{ + "type": "story", + "id": "folder--story-1", + "name": "Story 1", + "title": "Folder/Story 1", + "importPath": "./folder/story-1.stories.tsx", + "tags": [ + "dev", + "test" + ] +} +``` diff --git a/packages/source-storybook/package.json b/packages/source-storybook/package.json index c28581f4..8ba9c633 100644 --- a/packages/source-storybook/package.json +++ b/packages/source-storybook/package.json @@ -34,6 +34,8 @@ "@jpmorganchase/mosaic-source-http": "^0.1.0-beta.80", "@jpmorganchase/mosaic-schemas": "^0.1.0-beta.80", "@jpmorganchase/mosaic-types": "^0.1.0-beta.80", + "deepmerge": "^4.2.2", + "lodash-es": "^4.17.21", "rxjs": "^7.5.5", "zod": "^3.22.3" }, diff --git a/packages/source-storybook/src/__tests__/index.test.ts b/packages/source-storybook/src/__tests__/index.test.ts index 7c83a45d..2e7ce281 100644 --- a/packages/source-storybook/src/__tests__/index.test.ts +++ b/packages/source-storybook/src/__tests__/index.test.ts @@ -60,17 +60,32 @@ const options: StorybookSourceOptions = { ] }; -const createResponse = (index: number) => ({ +const createStoriesResponse = (index: number) => ({ + v: 3, stories: { [`component${index}`]: { id: `component${index}Id`, - title: `Component ${index} Title`, name: `Component ${index} Name`, + title: `TestComponent/SomePath/Component-${index}`, importPath: `./some-path/to/component-${index}`, tags: ['tag-1', 'tag-2'], - storiesImports: [], kind: `TestComponent/SomePath/Component-${index}`, - story: `Docs ${index}` + story: `Docs ${index}`, + parameters: {} + } + } +}); + +const createIndexResponse = (index: number) => ({ + v: 5, + entries: { + [`component${index}`]: { + type: `story`, + id: `component${index}Id`, + name: `Component ${index} Name`, + title: `TestComponent/SomePath/Component-${index}`, + importPath: `./some-path/to/component-${index}`, + tags: ['tag-1', 'tag-2'] } } }); @@ -81,32 +96,32 @@ const createExpectedResult = (index: number) => ({ fullPath: `prefixDir/component${index}Id.json`, tags: [`some-additional-tag-${index}`], data: { + type: 'story', id: `component${index}Id`, contentUrl: `https://storybook.endpoint.com/${index}/iframe.html?id=component${index}Id&viewMode=story&shortcuts=false&singleStory=true`, description: `some description ${index}`, - kind: `TestComponent/SomePath/Component-${index}`, + title: `TestComponent/SomePath/Component-${index}`, link: `https://storybook.endpoint.com/${index}?id=component${index}Id`, name: `Component ${index} Name`, - owner: `some owner ${index}`, - story: `Docs ${index}` + owner: `some owner ${index}` } }); const successHandlers = [ rest.get(options.stories[0].storiesUrl, (_req, res, ctx) => { - return res(ctx.status(200), ctx.json(createResponse(1))); + return res(ctx.status(200), ctx.json(createStoriesResponse(1))); }), rest.get(options.stories[1].storiesUrl, (_req, res, ctx) => { - return res(ctx.status(200), ctx.json(createResponse(2))); + return res(ctx.status(200), ctx.json(createIndexResponse(2))); }), rest.get(options.stories[2].storiesUrl, (_req, res, ctx) => { - return res(ctx.status(200), ctx.json(createResponse(3))); + return res(ctx.status(200), ctx.json(createStoriesResponse(3))); }), rest.get(options.stories[3].storiesUrl, (_req, res, ctx) => { - return res(ctx.status(200), ctx.json(createResponse(4))); + return res(ctx.status(200), ctx.json(createIndexResponse(4))); }), rest.get(options.stories[4].storiesUrl, (_req, res, ctx) => { - return res(ctx.status(200), ctx.json(createResponse(5))); + return res(ctx.status(200), ctx.json(createStoriesResponse(5))); }) ]; diff --git a/packages/source-storybook/src/index.ts b/packages/source-storybook/src/index.ts index 4b15f124..425875a1 100644 --- a/packages/source-storybook/src/index.ts +++ b/packages/source-storybook/src/index.ts @@ -7,9 +7,10 @@ import { httpSourceCreatorSchema, createProxyAgent } from '@jpmorganchase/mosaic-source-http'; +import deepmerge from 'deepmerge'; import { StoriesResponseJSON, StorybookPage, StoryConfig } from './types/index.js'; -import deepmerge from 'deepmerge'; +import { normalizeStorybookJson } from './stories.js'; const baseSchema = httpSourceCreatorSchema.omit({ endpoints: true, // will be generated from the url in the stories object, @@ -49,30 +50,35 @@ const transformStorybookPages = ( storyConfig: StoryConfig[] ): StorybookPage[] => { const { meta = {}, description, filter, filterTags, storyUrlPrefix } = storyConfig[index]; - const storyIds = Object.keys(storyJSON.stories); + const normalizedJson = normalizeStorybookJson(storyJSON); + const storyIds = Object.keys(normalizedJson.entries); return storyIds.reduce((result, storyId) => { - const story = storyJSON.stories[storyId]; - if (filter && !filter.test(story.kind)) { + const story = normalizedJson.entries[storyId]; + const { id, type, name, title: storyTitle, tags } = story; + if (filter && !filter.test(storyTitle)) { return result; } - if (filterTags && filterTags.some(filterTag => story.tags.indexOf(filterTag) >= 0)) { + if ( + filterTags && + filterTags.some(filterTag => Array.isArray(tags) && tags.indexOf(filterTag) >= 0) + ) { return result; } - const { id, kind, name, story: storyName } = story; - const title = `${kind} - ${name}`; + + const title = `${storyTitle} - ${name}`; const route = `${prefixDir}/${id}`; let storyPageMeta: StorybookPage = { title, route, fullPath: `${route}.json`, data: { + type, id, + name, + title: storyTitle, description, - kind, contentUrl: `${storyUrlPrefix}/iframe.html?id=${id}&viewMode=story&shortcuts=false&singleStory=true`, - link: `${storyUrlPrefix}?id=${id}`, - name, - story: storyName + link: `${storyUrlPrefix}?id=${id}` } }; if (meta) { @@ -102,7 +108,7 @@ const StorybookSource: Source = { console.log(`[Mosaic] Storybook source using ${proxyEndpoint} proxy for ${storiesUrl}`); agent = createProxyAgent(proxyEndpoint); } - const url = storiesUrl || `${storyUrlPrefix}/stories.json`; + const url = storiesUrl || `${storyUrlPrefix}/index.json`; return new Request(url, { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/source-storybook/src/stories.ts b/packages/source-storybook/src/stories.ts new file mode 100644 index 00000000..b9dc7a8f --- /dev/null +++ b/packages/source-storybook/src/stories.ts @@ -0,0 +1,82 @@ +// Transformers taken from https://github.com/storybookjs/storybook/blob/34ea7ba592ff06f6fbd31e3af95aa2efdfab2397/code/core/src/manager-api/lib/stories.ts#L52 +import { countBy } from 'lodash-es'; +import type { + StoriesResponseJSON, + StoryIndexV2, + StoryIndexV3, + API_PreparedStoryIndex, + IndexEntry +} from './types/index.js'; + +export const transformStoryIndexV2toV3 = (index: StoryIndexV2): StoryIndexV3 => ({ + v: 3, + stories: Object.values(index.stories).reduce((acc, entry) => { + acc[entry.id] = { + ...entry, + title: entry.kind, + name: entry.name || entry.story, + importPath: entry.parameters.fileName || '' + }; + + return acc; + }, {} as StoryIndexV3['stories']) +}); + +export const transformStoryIndexV3toV4 = (index: StoryIndexV3): API_PreparedStoryIndex => { + const countByTitle = countBy(Object.values(index.stories), 'title'); + return { + v: 4, + entries: Object.values(index.stories).reduce((acc, entry: any) => { + let type: IndexEntry['type'] = 'story'; + if ( + entry.parameters?.docsOnly || + (entry.name === 'Page' && countByTitle[entry.title] === 1) + ) { + type = 'docs'; + } + acc[entry.id] = { + type, + ...(type === 'docs' && { tags: ['stories-mdx'], storiesImports: [] }), + ...entry + }; + + // @ts-expect-error (we're removing something that should not be there) + delete acc[entry.id].story; + // @ts-expect-error (we're removing something that should not be there) + delete acc[entry.id].kind; + + return acc; + }, {} as API_PreparedStoryIndex['entries']) + }; +}; + +/** + * Storybook 8.0 and below did not automatically tag stories with 'dev'. + * Therefore Storybook 8.1 and above would not show composed 8.0 stories by default. + * This function adds the 'dev' tag to all stories in the index to workaround this issue. + */ +export const transformStoryIndexV4toV5 = ( + index: API_PreparedStoryIndex +): API_PreparedStoryIndex => ({ + v: 5, + entries: Object.values(index.entries).reduce((acc, entry) => { + acc[entry.id] = { + ...entry, + tags: entry.tags ? ['dev', 'test', ...entry.tags] : ['dev'] + }; + + return acc; + }, {} as API_PreparedStoryIndex['entries']) +}); + +export const normalizeStorybookJson = (input: StoriesResponseJSON): API_PreparedStoryIndex => { + if (!input.v) { + return {} as API_PreparedStoryIndex; + } + + let index = input; + index = index.v === 2 ? transformStoryIndexV2toV3(index as never) : index; + index = index.v === 3 ? transformStoryIndexV3toV4(index as never) : index; + index = index.v === 4 ? transformStoryIndexV4toV5(index as never) : index; + return index as API_PreparedStoryIndex; +}; diff --git a/packages/source-storybook/src/types/index.ts b/packages/source-storybook/src/types/index.ts index df4415e0..ac293765 100644 --- a/packages/source-storybook/src/types/index.ts +++ b/packages/source-storybook/src/types/index.ts @@ -1,6 +1,6 @@ import type { Page } from '@jpmorganchase/mosaic-types'; -/** Storybook Source config - added to mosaic.config.mjs **/ +/* Storybook Source config - added to mosaic.config.mjs */ export type StoryConfig = { storiesUrl?: string; storyUrlPrefix: string; @@ -10,28 +10,63 @@ export type StoryConfig = { meta?: Partial; }; -/** Storybook API response */ -export type StoryResponseJSON = { +/* Storybook types inlined and simplified from @storybook/core/types */ +interface Parameters { + [name: string]: any; +} + +interface BaseIndexEntry { id: string; - kind: string; name: string; - story: string; title: string; - tags: string[]; + tags?: string[]; + importPath: string; +} +type StoryIndexEntry = BaseIndexEntry & { + type: 'story'; }; -export type StoriesResponseJSON = { - stories: Record; +type DocsIndexEntry = BaseIndexEntry & { + storiesImports: string[]; + type: 'docs'; }; +export type IndexEntry = StoryIndexEntry | DocsIndexEntry; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface API_PreparedStoryIndex { + v: number; + entries: Record; +} + +interface V3CompatIndexEntry extends Omit { + kind: string; + story: string; + parameters: Parameters; +} +export interface StoryIndexV2 { + v: number; + stories: Record< + string, + Omit & { + name?: string; + } + >; +} +export interface StoryIndexV3 { + v: number; + stories: Record; +} + +export type StoriesResponseJSON = StoryIndexV2 | StoryIndexV3 | API_PreparedStoryIndex; /** Storybook page data */ export type StorybookPageData = { + type: 'story' | 'docs'; id: string; - contentUrl: string; description: string; + contentUrl: string; link: string; - kind: string; + title: string; name: string; - story: string; }; /** Page created by the Source for each Storybook story */