diff --git a/addons/docs/src/preset.ts b/addons/docs/src/preset.ts index bac132b16ac8..6e2090febae4 100644 --- a/addons/docs/src/preset.ts +++ b/addons/docs/src/preset.ts @@ -137,7 +137,7 @@ export async function webpack( return result; } -export const storyIndexers = async (indexers?: StoryIndexer[]) => { +export const storyIndexers = async (indexers: StoryIndexer[] | null) => { const mdxIndexer = async (fileName: string, opts: IndexerOptions) => { let code = (await fs.readFile(fileName, 'utf-8')).toString(); // @ts-ignore @@ -151,6 +151,7 @@ export const storyIndexers = async (indexers?: StoryIndexer[]) => { { test: /(stories|story)\.mdx$/, indexer: mdxIndexer, + addDocsTemplate: true, }, ...(indexers || []), ]; diff --git a/examples/react-ts/.storybook/main.ts b/examples/react-ts/.storybook/main.ts index bdf1d107d643..3d73fd524a65 100644 --- a/examples/react-ts/.storybook/main.ts +++ b/examples/react-ts/.storybook/main.ts @@ -23,6 +23,11 @@ const config: StorybookConfig = { '@storybook/addon-storyshots', '@storybook/addon-a11y', ], + docs: { + // enabled: false, + defaultName: 'Info', + // docsPage: false, + }, typescript: { check: true, checkOptions: {}, diff --git a/lib/addons/src/types.ts b/lib/addons/src/types.ts index 98597a91a875..5ce1726388b5 100644 --- a/lib/addons/src/types.ts +++ b/lib/addons/src/types.ts @@ -65,8 +65,14 @@ export type StoryIndexEntry = BaseIndexEntry & { export type DocsIndexEntry = BaseIndexEntry & { storiesImports: Path[]; type: 'docs'; - legacy?: boolean; + standalone: boolean; }; + +/** A StandaloneDocsIndexExtry represents a file who's default export is directly renderable */ +export type StandaloneDocsIndexEntry = DocsIndexEntry & { standalone: true }; +/** A TemplateDocsIndexEntry represents a stories file that gets rendered in "docs" mode */ +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 50150c64c9cf..a5553a50cbc7 100644 --- a/lib/core-common/src/types.ts +++ b/lib/core-common/src/types.ts @@ -224,6 +224,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 544b9741ee90..ca78c5e7db8a 100644 --- a/lib/core-server/package.json +++ b/lib/core-server/package.json @@ -41,7 +41,7 @@ "@storybook/core-events": "7.0.0-alpha.11", "@storybook/csf": "0.0.2--canary.4566f4d.1", "@storybook/csf-tools": "7.0.0-alpha.11", - "@storybook/docs-mdx": "0.0.1-canary.1.4bea5cc.0", + "@storybook/docs-mdx": "0.0.1-canary.12433cf.0", "@storybook/node-logger": "7.0.0-alpha.11", "@storybook/semver": "^7.3.2", "@storybook/store": "7.0.0-alpha.11", diff --git a/lib/core-server/src/dev-server.ts b/lib/core-server/src/dev-server.ts index 197b300ccaae..01819ad63200 100644 --- a/lib/core-server/src/dev-server.ts +++ b/lib/core-server/src/dev-server.ts @@ -1,8 +1,14 @@ import express, { Router } from 'express'; import compression from 'compression'; -import type { CoreConfig, DocsOptions, Options, StorybookConfig } from '@storybook/core-common'; -import { normalizeStories, logConfig } from '@storybook/core-common'; +import { + CoreConfig, + DocsOptions, + Options, + StorybookConfig, + normalizeStories, + logConfig, +} from '@storybook/core-common'; import { telemetry } from '@storybook/telemetry'; import { getMiddleware } from './utils/middleware'; diff --git a/lib/core-server/src/utils/StoryIndexGenerator.test.ts b/lib/core-server/src/utils/StoryIndexGenerator.test.ts index 6929a9573a51..24804894589b 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,206 @@ 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('addDocsTemplate indexer', () => { + const templateIndexer = { ...options.storyIndexers[0], addDocsTemplate: true }; + + it('adds docs entry with docs enabled', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + storyIndexers: [templateIndexer], + }); + 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", + }, + }, + "v": 4, + } + `); + }); + it('does not add docs entry with docs disabled', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/A.stories.js', + options + ); + + const generator = new StoryIndexGenerator([specifier], { + ...options, + storyIndexers: [templateIndexer], + docs: { enabled: false }, + }); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "a--story-one": Object { + "id": "a--story-one", + "importPath": "./src/A.stories.js", + "name": "Story One", + "title": "A", + "type": "story", + }, + }, + "v": 4, + } + `); + }); + }); + + describe('docsPage', () => { + const docsPageOptions = { + ...options, + docs: { ...options.docs, docsPage: true }, + }; + it('generates an entry per CSF file', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/**/*.stories.(ts|js|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], docsPageOptions); + 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], docsPageOptions); await generator.initialize(); expect(await generator.getIndex()).toMatchInlineSnapshot(` @@ -195,6 +400,7 @@ describe('StoryIndexGenerator', () => { "id": "a--docs", "importPath": "./src/docs2/MetaOf.mdx", "name": "docs", + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], @@ -208,35 +414,117 @@ describe('StoryIndexGenerator', () => { "title": "A", "type": "story", }, - "docs2-notitle--docs": Object { - "id": "docs2-notitle--docs", - "importPath": "./src/docs2/NoTitle.mdx", + }, + "v": 4, + } + `); + }); + + it('generates a combined entry if there are two stories files for the same title', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './duplicate/*.stories.(ts|js|jsx)', + options + ); + + const generator = new StoryIndexGenerator([specifier], docsPageOptions); + await generator.initialize(); + + expect(await generator.getIndex()).toMatchInlineSnapshot(` + Object { + "entries": Object { + "duplicate-a--docs": Object { + "id": "duplicate-a--docs", + "importPath": "./duplicate/A.stories.js", "name": "docs", - "storiesImports": Array [], - "title": "docs2/NoTitle", + "standalone": false, + "storiesImports": Array [ + "./duplicate/SecondA.stories.js", + ], + "title": "duplicate/A", + "type": "docs", + }, + "duplicate-a--story-one": Object { + "id": "duplicate-a--story-one", + "importPath": "./duplicate/A.stories.js", + "name": "Story One", + "title": "duplicate/A", + "type": "story", + }, + "duplicate-a--story-two": Object { + "id": "duplicate-a--story-two", + "importPath": "./duplicate/SecondA.stories.js", + "name": "Story Two", + "title": "duplicate/A", + "type": "story", + }, + }, + "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", + "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, @@ -262,6 +550,7 @@ describe('StoryIndexGenerator', () => { } `); }); + it('Allows you to override default name for docs files', async () => { const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], { ...options, @@ -279,6 +568,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 +593,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 +718,7 @@ describe('StoryIndexGenerator', () => { options ); const docsSpecifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( - './src/**/*.mdx', + './src/docs2/*.mdx', options ); @@ -336,14 +731,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 +758,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 +771,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 +815,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 +830,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 +852,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 +900,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 +939,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 +952,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 +974,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 +998,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..d4d231bf71af 100644 --- a/lib/core-server/src/utils/StoryIndexGenerator.ts +++ b/lib/core-server/src/utils/StoryIndexGenerator.ts @@ -9,23 +9,26 @@ 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; +/** A *.stories.* file will produce a list of stories and possibly a docs entry */ +type StoriesCacheEntry = { + entries: (StoryIndexEntry | TemplateDocsIndexEntry)[]; + dependents: Path[]; + type: 'stories'; +}; type CacheEntry = false | StoriesCacheEntry | DocsCacheEntry; type SpecifierStoriesCache = Record; @@ -39,6 +42,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 +117,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]); }) ); }) @@ -131,7 +158,7 @@ export class StoryIndexGenerator { return this.specifiers.flatMap((specifier) => { const cache = this.specifierToCache.get(specifier); - return Object.values(cache).flatMap((entry) => { + return Object.values(cache).flatMap((entry): IndexEntry[] => { if (!entry) return []; if (entry.type === 'docs') return [entry]; return entry.entries; @@ -167,6 +194,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 +264,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 +308,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 +318,7 @@ export class StoryIndexGenerator { importPath, storiesImports: dependencies.map((dep) => dep.entries[0].importPath), type: 'docs', + standalone: true, }; return docsEntry; } catch (err) { @@ -240,50 +327,72 @@ 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}`); + chooseDuplicate(firstEntry: IndexEntry, secondEntry: IndexEntry): IndexEntry { + let firstIsBetter = true; + if (secondEntry.type === 'story') { + firstIsBetter = false; + } else if (secondEntry.standalone && firstEntry.type === 'docs' && !firstEntry.standalone) { + firstIsBetter = false; } - return storyIndexer.indexer(filePath, options); - } + const betterEntry = firstIsBetter ? firstEntry : secondEntry; + const worseEntry = firstIsBetter ? secondEntry : firstEntry; - 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 }; + const changeDocsName = 'Use `` to distinguish them.'; - 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}`); + // This shouldn't be possible, but double check and use for typing + if (worseEntry.type === 'story') throw new Error(`Duplicate stories with id: ${firstEntry.id}`); + + if (betterEntry.type === 'story') { + 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 if (betterEntry.standalone) { + // 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 + } else { + // If both entries are templates (e.g. you have two CSF files with the same title), then + // we need to merge the entries. We'll use the the first one's name and importPath, + // but ensure we include both as storiesImports so they are both loaded before rendering + // the story (for the block & friends) + return { + ...betterEntry, + storiesImports: [ + ...betterEntry.storiesImports, + worseEntry.importPath, + ...worseEntry.storiesImports, + ], + }; } - 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__/duplicate/A.stories.js b/lib/core-server/src/utils/__mockdata__/duplicate/A.stories.js new file mode 100644 index 000000000000..75205409de90 --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/duplicate/A.stories.js @@ -0,0 +1,7 @@ +const component = {}; +export default { + component, + title: 'duplicate/A', +}; + +export const StoryOne = {}; diff --git a/lib/core-server/src/utils/__mockdata__/duplicate/SecondA.stories.js b/lib/core-server/src/utils/__mockdata__/duplicate/SecondA.stories.js new file mode 100644 index 000000000000..5f813ef5073e --- /dev/null +++ b/lib/core-server/src/utils/__mockdata__/duplicate/SecondA.stories.js @@ -0,0 +1,7 @@ +const component = {}; +export default { + component, + title: 'duplicate/A', +}; + +export const StoryTwo = {}; 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..06a8cbf18c98 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", @@ -720,6 +750,7 @@ describe('convertToIndexV3', () => { storiesImports: ['./src/A.stories.js'], title: 'A', type: 'docs', + standalone: true, }, 'a--story-one': { id: 'a--story-one', @@ -751,6 +782,7 @@ describe('convertToIndexV3', () => { "docsOnly": true, "fileName": "./src/docs2/MetaOf.mdx", }, + "standalone": true, "storiesImports": Array [ "./src/A.stories.js", ], diff --git a/lib/core-server/tsconfig.json b/lib/core-server/tsconfig.json index 7fb789ec3eb1..af4fc31458f1 100644 --- a/lib/core-server/tsconfig.json +++ b/lib/core-server/tsconfig.json @@ -6,6 +6,6 @@ "src/**/*" ], "exclude": [ - "src/**.test.ts" + "src/**/**.test.ts" ] } \ No newline at end of file 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 f6f1d582244b..c5e1e40bc03a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8332,7 +8332,7 @@ __metadata: "@storybook/core-events": 7.0.0-alpha.11 "@storybook/csf": 0.0.2--canary.4566f4d.1 "@storybook/csf-tools": 7.0.0-alpha.11 - "@storybook/docs-mdx": 0.0.1-canary.1.4bea5cc.0 + "@storybook/docs-mdx": 0.0.1-canary.12433cf.0 "@storybook/node-logger": 7.0.0-alpha.11 "@storybook/semver": ^7.3.2 "@storybook/store": 7.0.0-alpha.11 @@ -8479,15 +8479,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