diff --git a/lib/blocks/src/blocks/ArgsTable.tsx b/lib/blocks/src/blocks/ArgsTable.tsx index 47da0897c90c..d22d4d366dfd 100644 --- a/lib/blocks/src/blocks/ArgsTable.tsx +++ b/lib/blocks/src/blocks/ArgsTable.tsx @@ -16,7 +16,6 @@ import { import { DocsContext, DocsContextProps } from './DocsContext'; import { Component, CURRENT_SELECTION, PRIMARY_STORY } from './types'; import { getComponentName } from './utils'; -import { lookupStoryId } from './Story'; import { useStory } from './useStory'; interface BaseProps { @@ -173,7 +172,7 @@ export const StoryTable: FC< break; } default: { - storyId = lookupStoryId(storyName, context); + storyId = context.storyIdByName(storyName); } } diff --git a/lib/blocks/src/blocks/Canvas.tsx b/lib/blocks/src/blocks/Canvas.tsx index 13fa8b61ac0e..c7de46a55704 100644 --- a/lib/blocks/src/blocks/Canvas.tsx +++ b/lib/blocks/src/blocks/Canvas.tsx @@ -25,7 +25,7 @@ const getPreviewProps = ( docsContext: DocsContextProps, sourceContext: SourceContextProps ) => { - const { mdxComponentAnnotations, mdxStoryNameToKey } = docsContext; + const { storyIdByName } = docsContext; let sourceState = withSource; let isLoading = false; if (sourceState === SourceState.NONE) { @@ -48,10 +48,7 @@ const getPreviewProps = ( if (id) return id; if (of) return docsContext.storyIdByModuleExport(of); - return toId( - mdxComponentAnnotations.id || mdxComponentAnnotations.title, - storyNameFromExport(mdxStoryNameToKey[name]) - ); + return storyIdByName(name); }); const sourceProps = getSourceProps({ ids: targetIds }, docsContext, sourceContext); diff --git a/lib/blocks/src/blocks/DocsContainer.tsx b/lib/blocks/src/blocks/DocsContainer.tsx index 7e0febccf410..c2f15a9327a9 100644 --- a/lib/blocks/src/blocks/DocsContainer.tsx +++ b/lib/blocks/src/blocks/DocsContainer.tsx @@ -37,13 +37,13 @@ const warnOptionsTheme = deprecate( ); export const DocsContainer: FunctionComponent = ({ context, children }) => { - const { id: storyId, type, storyById } = context; + const { id: storyId, storyById } = context; const allComponents = { ...defaultComponents }; let theme = ensureTheme(null); - if (type === 'legacy') { + try { const { parameters: { options = {}, docs = {} }, - } = storyById(storyId); + } = storyById(); let themeVars = docs.theme; if (!themeVars && options.theme) { warnOptionsTheme(); @@ -51,6 +51,8 @@ export const DocsContainer: FunctionComponent = ({ context, } theme = ensureTheme(themeVars); Object.assign(allComponents, docs.components); + } catch (err) { + // No primary story, ie. standalone docs } useEffect(() => { diff --git a/lib/blocks/src/blocks/ExternalDocsContainer.tsx b/lib/blocks/src/blocks/ExternalDocsContainer.tsx index b260af12af36..38660fe56ab4 100644 --- a/lib/blocks/src/blocks/ExternalDocsContainer.tsx +++ b/lib/blocks/src/blocks/ExternalDocsContainer.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ThemeProvider, themes, ensure } from '@storybook/theming'; import { DocsContextProps } from '@storybook/preview-web'; import { ModuleExport, ModuleExports, Story } from '@storybook/store'; -import { AnyFramework, StoryId } from '@storybook/csf'; +import { AnyFramework, StoryId, StoryName } from '@storybook/csf'; import { DocsContext } from './DocsContext'; import { ExternalPreview } from './ExternalPreview'; @@ -22,8 +22,6 @@ export const ExternalDocsContainer: React.FC<{ projectAnnotations: any }> = ({ }; const docsContext: DocsContextProps = { - type: 'external', - id: 'external-docs', title: 'External', name: 'Docs', @@ -32,6 +30,11 @@ export const ExternalDocsContainer: React.FC<{ projectAnnotations: any }> = ({ return preview.storyIdByModuleExport(storyExport, metaExport || pageMeta); }, + storyIdByName: (name: StoryName) => { + // TODO + throw new Error('not implemented'); + }, + storyById: (id: StoryId) => { return preview.storyById(id); }, diff --git a/lib/blocks/src/blocks/Meta.tsx b/lib/blocks/src/blocks/Meta.tsx index def67c709cc2..eec98ddf2152 100644 --- a/lib/blocks/src/blocks/Meta.tsx +++ b/lib/blocks/src/blocks/Meta.tsx @@ -18,9 +18,6 @@ function getFirstStoryId(docsContext: DocsContextProps): string { function renderAnchor() { const context = useContext(DocsContext); - if (context.type === 'external') { - return null; - } const anchorId = getFirstStoryId(context) || context.id; return ; diff --git a/lib/blocks/src/blocks/Story.tsx b/lib/blocks/src/blocks/Story.tsx index 7d6df5bf3465..397f899663b8 100644 --- a/lib/blocks/src/blocks/Story.tsx +++ b/lib/blocks/src/blocks/Story.tsx @@ -45,15 +45,6 @@ type StoryImportProps = { export type StoryProps = (StoryDefProps | StoryRefProps | StoryImportProps) & CommonProps; -export const lookupStoryId = ( - storyName: string, - { mdxStoryNameToKey, mdxComponentAnnotations }: DocsContextProps -) => - toId( - mdxComponentAnnotations.id || mdxComponentAnnotations.title, - storyNameFromExport(mdxStoryNameToKey[storyName]) - ); - export const getStoryId = (props: StoryProps, context: DocsContextProps): StoryId => { const { id, of, meta } = props as StoryRefProps; @@ -63,7 +54,7 @@ export const getStoryId = (props: StoryProps, context: DocsContextProps): StoryI const { name } = props as StoryDefProps; const inputId = id === CURRENT_SELECTION ? context.id : id; - return inputId || lookupStoryId(name, context); + return inputId || context.storyIdByName(name); }; export const getStoryProps = ( @@ -118,8 +109,7 @@ const Story: FunctionComponent = (props) => { return null; } - const inline = context.type === 'external' || storyProps.inline; - if (inline) { + if (storyProps.inline) { // We do this so React doesn't complain when we replace the span in a secondary render const htmlContents = ``; diff --git a/lib/preview-web/src/Preview.tsx b/lib/preview-web/src/Preview.tsx index bbeef91aa25a..75059c515c16 100644 --- a/lib/preview-web/src/Preview.tsx +++ b/lib/preview-web/src/Preview.tsx @@ -27,7 +27,8 @@ import { } from '@storybook/store'; import { StoryRender } from './render/StoryRender'; -import { AbstractDocsRender } from './render/AbstractDocsRender'; +import { TemplateDocsRender } from './render/TemplateDocsRender'; +import { StandaloneDocsRender } from './render/StandaloneDocsRender'; const { fetch } = global; @@ -324,7 +325,10 @@ export class Preview { } async teardownRender( - render: StoryRender | AbstractDocsRender, + render: + | StoryRender + | TemplateDocsRender + | StandaloneDocsRender, { viewModeChanged }: { viewModeChanged?: boolean } = {} ) { this.storyRenders = this.storyRenders.filter((r) => r !== render); diff --git a/lib/preview-web/src/PreviewWeb.test.ts b/lib/preview-web/src/PreviewWeb.test.ts index e6eafa168dc8..60a518a98b88 100644 --- a/lib/preview-web/src/PreviewWeb.test.ts +++ b/lib/preview-web/src/PreviewWeb.test.ts @@ -621,9 +621,9 @@ describe('PreviewWeb', () => { document.location.search = '?id=component-one--docs&viewMode=docs'; await createAndRenderPreview(); - const { componentStories } = docsRenderer.render.mock.calls[0][0]; + const context = docsRenderer.render.mock.calls[0][0]; - expect(componentStories().map((s) => s.id)).toEqual([ + expect(context.componentStories().map((s) => s.id)).toEqual([ 'component-one--a', 'component-one--b', 'component-one--e', diff --git a/lib/preview-web/src/PreviewWeb.tsx b/lib/preview-web/src/PreviewWeb.tsx index c0fa7320317d..90b1b17c94ff 100644 --- a/lib/preview-web/src/PreviewWeb.tsx +++ b/lib/preview-web/src/PreviewWeb.tsx @@ -389,33 +389,6 @@ export class PreviewWeb extends Preview, element: HTMLElement) { - if (!this.renderToDOM) - throw new Error(`Cannot call renderStoryToElement before initialization`); - - const render = new StoryRender( - this.channel, - this.storyStore, - this.renderToDOM, - this.inlineStoryCallbacks(story.id), - story.id, - 'docs', - story - ); - render.renderToElement(element); - - this.storyRenders.push(render); - - return async () => { - await this.teardownRender(render); - }; - } - async teardownRender( render: PossibleRender, { viewModeChanged = false }: { viewModeChanged?: boolean } = {} diff --git a/lib/preview-web/src/docs-context/DocsContext.ts b/lib/preview-web/src/docs-context/DocsContext.ts new file mode 100644 index 000000000000..7f69f4e5aeb7 --- /dev/null +++ b/lib/preview-web/src/docs-context/DocsContext.ts @@ -0,0 +1,89 @@ +import { + AnyFramework, + ComponentTitle, + StoryContextForLoaders, + StoryId, + StoryName, +} from '@storybook/csf'; +import { CSFFile, ModuleExport, Story, StoryStore } from '@storybook/store'; +import { PreviewWeb } from '../PreviewWeb'; + +import { DocsContextProps } from './DocsContextProps'; + +export class DocsContext implements DocsContextProps { + private componentStoriesValue: Story[]; + + private storyIdToCSFFile: Map>; + + private exportToStoryId: Map; + + private nameToStoryId: Map; + + constructor( + public readonly id: StoryId, + public readonly title: ComponentTitle, + public readonly name: StoryName, + protected store: StoryStore, + /** The CSF files known (via the index) to be refererenced by this docs file */ + public renderStoryToElement: PreviewWeb['renderStoryToElement'], + csfFiles: CSFFile[], + componentStoriesFromAllCsfFiles = true + ) { + this.storyIdToCSFFile = new Map(); + this.exportToStoryId = new Map(); + this.nameToStoryId = new Map(); + this.componentStoriesValue = []; + + csfFiles.forEach((csfFile, index) => { + Object.values(csfFile.stories).forEach((annotation) => { + this.storyIdToCSFFile.set(annotation.id, csfFile); + this.exportToStoryId.set(annotation.moduleExport, annotation.id); + this.nameToStoryId.set(annotation.name, annotation.id); + + if (componentStoriesFromAllCsfFiles || index === 0) + this.componentStoriesValue.push(this.storyById(annotation.id)); + }); + }); + } + + setMeta() { + // Do nothing + } + + storyIdByModuleExport = (storyExport: ModuleExport) => { + const storyId = this.exportToStoryId.get(storyExport); + if (storyId) return storyId; + + throw new Error(`No story found with that export: ${storyExport}`); + }; + + storyIdByName = (storyName: StoryName) => { + const storyId = this.nameToStoryId.get(storyName); + if (storyId) return storyId; + + throw new Error(`No story found with that name: ${storyName}`); + }; + + componentStories = () => { + return this.componentStoriesValue; + }; + + storyById = (inputStoryId?: StoryId) => { + const storyId = inputStoryId || this.id; + const csfFile = this.storyIdToCSFFile.get(storyId); + if (!csfFile) + throw new Error(`Called \`storyById\` for story that was never loaded: ${storyId}`); + return this.store.storyFromCSFFile({ storyId, csfFile }); + }; + + getStoryContext = (story: Story) => { + return { + ...this.store.getStoryContext(story), + viewMode: 'docs', + } as StoryContextForLoaders; + }; + + loadStory = (id: StoryId) => { + return this.store.loadStory({ storyId: id }); + }; +} diff --git a/lib/preview-web/src/docs-context/DocsContextProps.ts b/lib/preview-web/src/docs-context/DocsContextProps.ts new file mode 100644 index 000000000000..b03b959b57db --- /dev/null +++ b/lib/preview-web/src/docs-context/DocsContextProps.ts @@ -0,0 +1,58 @@ +import type { + StoryId, + StoryName, + AnyFramework, + StoryContextForLoaders, + ComponentTitle, +} from '@storybook/csf'; +import type { ModuleExport, ModuleExports, Story } from '@storybook/store'; + +export interface DocsContextProps { + /** + * These fields represent the docs entry that is being rendered to the screen + */ + id: StoryId; + title: ComponentTitle; + name: StoryName; + + /** + * Register the CSF file that this docs entry represents. + * Used by the `` block. + */ + setMeta: (metaExports: ModuleExports) => void; + + /** + * Find a story's id from the direct export from the CSF file. + * This is primarily used by the ` block. + */ + storyIdByModuleExport: (storyExport: ModuleExport, metaExports?: ModuleExports) => StoryId; + /** + * Find a story's id from the name of the story. + * This is primarily used by the ` block. + * Note that the story must be part of the primary CSF file of the docs entry. + */ + storyIdByName: (storyName: StoryName) => StoryId; + /** + * Syncronously find a story by id (if the id is not provided, this will look up the primary + * story in the CSF file, if such a file exists). + */ + storyById: (id?: StoryId) => Story; + /** + * Syncronously find all stories of the component referenced by the CSF file. + */ + componentStories: () => Story[]; + + /** + * Get the story context of the referenced story. + */ + getStoryContext: (story: Story) => StoryContextForLoaders; + /** + * Asyncronously load an arbitrary story by id. + */ + loadStory: (id: StoryId) => Promise>; + + /** + * Render a story to a given HTML element and keep it up to date across context changes + */ + renderStoryToElement: (story: Story, element: HTMLElement) => () => Promise; +} diff --git a/lib/preview-web/src/docs-context/DocsRenderFunction.ts b/lib/preview-web/src/docs-context/DocsRenderFunction.ts new file mode 100644 index 000000000000..b453943311e4 --- /dev/null +++ b/lib/preview-web/src/docs-context/DocsRenderFunction.ts @@ -0,0 +1,9 @@ +import type { AnyFramework, Parameters } from '@storybook/csf'; +import { DocsContextProps } from './DocsContextProps'; + +export type DocsRenderFunction = ( + docsContext: DocsContextProps, + docsParameters: Parameters, + element: HTMLElement, + callback: () => void +) => void; diff --git a/lib/preview-web/src/index.ts b/lib/preview-web/src/index.ts index d3abaf011414..92992bf87195 100644 --- a/lib/preview-web/src/index.ts +++ b/lib/preview-web/src/index.ts @@ -6,4 +6,5 @@ export { PreviewWeb } from './PreviewWeb'; export { simulatePageLoad, simulateDOMContentLoaded } from './simulate-pageload'; -export * from './types'; +export type { DocsContextProps } from './docs-context/DocsContextProps'; +export type { DocsRenderFunction } from './docs-context/DocsRenderFunction'; diff --git a/lib/preview-web/src/render/AbstractDocsRender.ts b/lib/preview-web/src/render/AbstractDocsRender.ts deleted file mode 100644 index 179d4d16971f..000000000000 --- a/lib/preview-web/src/render/AbstractDocsRender.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AnyFramework, StoryId, ViewMode, StoryContextForLoaders } from '@storybook/csf'; -import { Story, StoryStore, IndexEntry, ModuleExports, CSFFile } from '@storybook/store'; -import { Channel } from '@storybook/addons'; -import { Render, RenderType } from './Render'; -import { DocsContextProps } from '../types'; - -export abstract class AbstractDocsRender - implements Render -{ - public type: RenderType = 'docs'; - - public id: StoryId; - - public exports?: ModuleExports; - - protected csfFiles?: CSFFile[]; - - protected preparing = false; - - protected canvasElement?: HTMLElement; - - protected docsContext?: DocsContextProps; - - public disableKeyListeners = false; - - public teardown?: (options: { viewModeChanged?: boolean }) => Promise; - - public torndown = false; - - constructor( - protected channel: Channel, - protected store: StoryStore, - public entry: IndexEntry - ) { - this.id = entry.id; - } - - async prepare() { - this.preparing = true; - const { entryExports, csfFiles = [] } = await this.store.loadEntry(this.id); - this.exports = entryExports; - this.csfFiles = csfFiles; - this.preparing = false; - } - - isPreparing() { - return this.preparing; - } - - loadStory = (storyId: StoryId) => this.store.loadStory({ storyId }); - - getStoryContext = (renderedStory: Story) => - ({ - ...this.store.getStoryContext(renderedStory), - viewMode: 'docs' as ViewMode, - } as StoryContextForLoaders); - - async renderToElement( - canvasElement: HTMLElement, - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ) { - this.canvasElement = canvasElement; - this.docsContext = await this.getDocsContext(renderStoryToElement); - - return this.render(); - } - - abstract isEqual(r: Render): boolean; - - abstract render(): Promise; - - abstract getDocsContext( - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ): Promise>; -} diff --git a/lib/preview-web/src/render/Render.ts b/lib/preview-web/src/render/Render.ts index 2d8549f3058b..64b1458cb885 100644 --- a/lib/preview-web/src/render/Render.ts +++ b/lib/preview-web/src/render/Render.ts @@ -1,6 +1,14 @@ import { StoryId, AnyFramework } from '@storybook/csf'; export type RenderType = 'story' | 'docs'; + +/** + * A "Render" represents the rendering of a single entry to a single location + * + * The implemenations of render are used for two key purposes: + * - Tracking the state of the rendering as it moves between preparing, rendering and tearing down. + * - Tracking what is rendered to know if a change requires re-rendering or teardown + recreation. + */ export interface Render { type: RenderType; id: StoryId; diff --git a/lib/preview-web/src/render/StandaloneDocsRender.ts b/lib/preview-web/src/render/StandaloneDocsRender.ts index 8501b853e258..750291cc2a6c 100644 --- a/lib/preview-web/src/render/StandaloneDocsRender.ts +++ b/lib/preview-web/src/render/StandaloneDocsRender.ts @@ -1,15 +1,59 @@ import { AnyFramework, StoryId } from '@storybook/csf'; -import { CSFFile, ModuleExports, ModuleExport } from '@storybook/store'; +import { CSFFile, ModuleExports, StoryStore } from '@storybook/store'; +import { Channel, IndexEntry } from '@storybook/addons'; import { DOCS_RENDERED } from '@storybook/core-events'; import { Render, RenderType } from './Render'; -import type { DocsContextProps, DocsRenderFunction } from '../types'; -import { AbstractDocsRender } from './AbstractDocsRender'; +import type { DocsContextProps } from '../docs-context/DocsContextProps'; +import type { DocsRenderFunction } from '../docs-context/DocsRenderFunction'; +import { DocsContext } from '../docs-context/DocsContext'; -export class StandaloneDocsRender< - TFramework extends AnyFramework -> extends AbstractDocsRender { - public type: RenderType = 'docs'; +/** + * A StandaloneDocsRender is a render of a docs entry that doesn't directly come from a CSF file. + * + * A standalone render can reference zero or more CSF files that contain stories. + * + * Use cases: + * - *.mdx file that may or may not reference a specific CSF file with `` + */ + +export class StandaloneDocsRender implements Render { + public readonly type: RenderType = 'docs'; + + public readonly id: StoryId; + + private exports?: ModuleExports; + + public teardown?: (options: { viewModeChanged?: boolean }) => Promise; + + public torndown = false; + + public readonly disableKeyListeners = false; + + public preparing = false; + + private csfFiles?: CSFFile[]; + + constructor( + protected channel: Channel, + protected store: StoryStore, + public entry: IndexEntry + ) { + this.id = entry.id; + } + + isPreparing() { + return this.preparing; + } + + async prepare() { + this.preparing = true; + const { entryExports, csfFiles = [] } = await this.store.loadEntry(this.id); + this.csfFiles = csfFiles; + this.exports = entryExports; + + this.preparing = false; + } isEqual(other: Render): boolean { return !!( @@ -19,92 +63,42 @@ export class StandaloneDocsRender< ); } - async getDocsContext( - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ): Promise> { - const { id, title, name } = this.entry; + async renderToElement( + canvasElement: HTMLElement, + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ) { + if (!this.exports || !this.csfFiles || !this.store.projectAnnotations) + throw new Error('Cannot render docs before preparing'); - if (!this.csfFiles) throw new Error('getDocsContext called before prepare'); - - let metaCsfFile: ModuleExports; - const setMeta = (m: ModuleExports) => { - metaCsfFile = m; - }; + const docsContext = new DocsContext( + this.id, + this.entry.title, + this.entry.name, - const exportToStoryId = new Map(); - const storyIdToCSFFile = new Map>(); - // eslint-disable-next-line no-restricted-syntax - for (const csfFile of this.csfFiles) { - // eslint-disable-next-line no-restricted-syntax - for (const annotation of Object.values(csfFile.stories)) { - exportToStoryId.set(annotation.moduleExport, annotation.id); - storyIdToCSFFile.set(annotation.id, csfFile); - } - } - - const storyIdByModuleExport = (moduleExport: ModuleExport) => { - const storyId = exportToStoryId.get(moduleExport); - if (storyId) return storyId; - - throw new Error(`No story found with that export: ${moduleExport}`); - }; - - const storyById = (storyId: StoryId) => { - const csfFile = storyIdToCSFFile.get(storyId); - if (!csfFile) - throw new Error(`Called \`storyById\` for story that was never loaded: ${storyId}`); - return this.store.storyFromCSFFile({ storyId, csfFile }); - }; - - const componentStories = () => { - return ( - Object.entries(metaCsfFile) - .map(([_, moduleExport]) => exportToStoryId.get(moduleExport)) - .filter(Boolean) as StoryId[] - ).map(storyById); - }; - - return { - // TODO - type: 'modern', - id, - title, - name, + this.store, renderStoryToElement, - loadStory: this.loadStory.bind(this), - getStoryContext: this.getStoryContext.bind(this), - storyIdByModuleExport, - storyById, - componentStories, - setMeta, - }; - } - async render() { - if (!this.exports || !this.docsContext || !this.canvasElement || !this.store.projectAnnotations) - throw new Error('DocsRender not ready to render'); + this.csfFiles, + false + ); const { docs } = this.store.projectAnnotations.parameters || {}; - if (!docs) { + if (!docs) throw new Error( `Cannot render a story in viewMode=docs if \`@storybook/addon-docs\` is not installed` ); - } const renderer = await docs.renderer(); (renderer.render as DocsRenderFunction)( - this.docsContext, - { - ...docs, - page: this.exports.default, - }, - this.canvasElement, + docsContext, + { ...docs, page: this.exports.default }, + canvasElement, () => this.channel.emit(DOCS_RENDERED, this.id) ); this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { - if (!viewModeChanged || !this.canvasElement) return; - renderer.unmount(this.canvasElement); + if (!viewModeChanged || !canvasElement) return; + renderer.unmount(canvasElement); this.torndown = true; }; } diff --git a/lib/preview-web/src/render/TemplateDocsRender.ts b/lib/preview-web/src/render/TemplateDocsRender.ts index c558f81193d0..fb2e2d31825c 100644 --- a/lib/preview-web/src/render/TemplateDocsRender.ts +++ b/lib/preview-web/src/render/TemplateDocsRender.ts @@ -1,15 +1,29 @@ import { AnyFramework, StoryId } from '@storybook/csf'; -import { Story } from '@storybook/store'; +import { CSFFile, Story, StoryStore } from '@storybook/store'; +import { Channel, IndexEntry } from '@storybook/addons'; import { DOCS_RENDERED } from '@storybook/core-events'; import { Render, RenderType } from './Render'; -import type { DocsContextProps, DocsRenderFunction } from '../types'; -import { AbstractDocsRender } from './AbstractDocsRender'; - -export class TemplateDocsRender< - TFramework extends AnyFramework -> extends AbstractDocsRender { - public type: RenderType = 'docs'; +import type { DocsContextProps } from '../docs-context/DocsContextProps'; +import type { DocsRenderFunction } from '../docs-context/DocsRenderFunction'; +import { DocsContext } from '../docs-context/DocsContext'; + +/** + * A TemplateDocsRender is a render of a docs entry that is rendered with (an) attached CSF file(s). + * + * The expectation is the primary CSF file which is the `importPath` for the entry will + * define a story which may contain the actual rendered JSX code for the template in the + * `docs.page` parameter. + * + * Use cases: + * - Docs Page, where there is no parameter, and we fall back to the globally defined template. + * - *.stories.mdx files, where the MDX compiler produces a CSF file with a `.parameter.docs.page` + * parameter containing the compiled content of the MDX file. + */ +export class TemplateDocsRender implements Render { + public readonly type: RenderType = 'docs'; + + public readonly id: StoryId; public story?: Story; @@ -17,13 +31,33 @@ export class TemplateDocsRender< public torndown = false; + public readonly disableKeyListeners = false; + + public preparing = false; + + private csfFiles?: CSFFile[]; + + constructor( + protected channel: Channel, + protected store: StoryStore, + public entry: IndexEntry + ) { + this.id = entry.id; + } + + isPreparing() { + return this.preparing; + } + async prepare() { this.preparing = true; - await super.prepare(); + const { entryExports, csfFiles = [] } = await this.store.loadEntry(this.id); const { importPath, title } = this.entry; - this.csfFiles!.unshift( - await this.store.processCSFFileWithCache(this.exports!, importPath, title) + const primaryCsfFile = await this.store.processCSFFileWithCache( + entryExports, + importPath, + title ); // We use the first ("primary") story from the CSF as the "current" story on the context. @@ -31,7 +65,12 @@ export class TemplateDocsRender< // a story to be current (even though now we render a separate docs entry from the stories) // - when rendering a "docs only" (story) id, this will end up being the same story as // this.id, as such "CSF files" have only one story - this.story = this.storyById(Object.keys(this.csfFiles![0].stories)[0]); + const primaryStoryId = Object.keys(primaryCsfFile.stories)[0]; + this.story = this.store.storyFromCSFFile({ storyId: primaryStoryId, csfFile: primaryCsfFile }); + + this.csfFiles = [primaryCsfFile, ...csfFiles]; + + this.preparing = false; } isEqual(other: Render): boolean { @@ -42,52 +81,23 @@ export class TemplateDocsRender< ); } - storyById(storyId: StoryId) { - if (!this.csfFiles) throw new Error(`Cannot call storyById before preparing`); + async renderToElement( + canvasElement: HTMLElement, + renderStoryToElement: DocsContextProps['renderStoryToElement'] + ) { + if (!this.story || !this.csfFiles) throw new Error('Cannot render docs before preparing'); - const csfFile = this.csfFiles.find((f) => !!f.stories[storyId]); - if (!csfFile) - throw new Error(`Didn't find '${storyId}' in a loaded CSF file, this is unexpected`); + const docsContext = new DocsContext( + this.story.id, + this.entry.title, + this.entry.name, - return this.store.storyFromCSFFile({ storyId, csfFile }); - } - - async getDocsContext( - renderStoryToElement: DocsContextProps['renderStoryToElement'] - ): Promise> { - const { title, name } = this.entry; - - const { csfFiles } = this; - if (!csfFiles || !this.story) throw new Error(`Cannot get docs context before preparing`); - - const componentStories = () => - csfFiles.flatMap((csfFile) => this.store.componentStoriesFromCSFFile({ csfFile })); - - const storyIdByModuleExport = () => { - // NOTE: we could implement this easily enough by checking all the component stories - throw new Error('`storyIdByModuleExport` not available for legacy docs files.'); - }; - - return { - // TODO - type: 'legacy', - // NOTE: we use the id of the loaded story for reasons discussed in .prepare() above - id: this.story.id, - title, - name, + this.store, renderStoryToElement, - loadStory: this.loadStory.bind(this), - getStoryContext: this.getStoryContext.bind(this), - componentStories, - storyIdByModuleExport, - storyById: this.storyById.bind(this), - setMeta: () => {}, - }; - } - async render() { - if (!this.story || !this.docsContext || !this.canvasElement) - throw new Error('DocsRender not ready to render'); + this.csfFiles, + true + ); const { docs } = this.story.parameters || {}; @@ -97,15 +107,12 @@ export class TemplateDocsRender< ); const renderer = await docs.renderer(); - (renderer.render as DocsRenderFunction)( - this.docsContext, - docs, - this.canvasElement, - () => this.channel.emit(DOCS_RENDERED, this.id) + (renderer.render as DocsRenderFunction)(docsContext, docs, canvasElement, () => + this.channel.emit(DOCS_RENDERED, this.id) ); this.teardown = async ({ viewModeChanged }: { viewModeChanged?: boolean } = {}) => { - if (!viewModeChanged || !this.canvasElement) return; - renderer.unmount(this.canvasElement); + if (!viewModeChanged || !canvasElement) return; + renderer.unmount(canvasElement); this.torndown = true; }; } diff --git a/lib/preview-web/src/types.ts b/lib/preview-web/src/types.ts deleted file mode 100644 index 2c94ca2d3236..000000000000 --- a/lib/preview-web/src/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { - StoryId, - StoryName, - AnyFramework, - StoryContextForLoaders, - ComponentTitle, - Parameters, -} from '@storybook/csf'; -import type { ModuleExport, ModuleExports, Story } from '@storybook/store'; -import { PreviewWeb } from './PreviewWeb'; - -export interface DocsContextProps { - type: 'legacy' | 'modern' | 'external'; - - id: StoryId; - title: ComponentTitle; - name: StoryName; - - storyIdByModuleExport: (storyExport: ModuleExport, metaExports?: ModuleExports) => StoryId; - storyById: (id: StoryId) => Story; - getStoryContext: (story: Story) => StoryContextForLoaders; - - componentStories: () => Story[]; - - loadStory: (id: StoryId) => Promise>; - renderStoryToElement: PreviewWeb['renderStoryToElement']; - - /** - * mdxStoryNameToKey is an MDX-compiler-generated mapping of an MDX story's - * display name to its story key for ID generation. It's used internally by the `` - * and `Preview` doc blocks. - */ - mdxStoryNameToKey?: Record; - mdxComponentAnnotations?: any; - - /** - * To be used by external docs - */ - setMeta: (metaExport: ModuleExports) => void; -} - -export type DocsRenderFunction = ( - docsContext: DocsContextProps, - docsParameters: Parameters, - element: HTMLElement, - callback: () => void -) => void;