diff --git a/addons/docs/src/frameworks/html/config.ts b/addons/docs/src/frameworks/html/config.ts new file mode 100644 index 000000000000..007800d5da93 --- /dev/null +++ b/addons/docs/src/frameworks/html/config.ts @@ -0,0 +1,16 @@ +import { sourceDecorator } from './sourceDecorator'; +import { prepareForInline } from './prepareForInline'; +import { SourceType } from '../../shared'; + +export const decorators = [sourceDecorator]; + +export const parameters = { + docs: { + inlineStories: true, + prepareForInline, + source: { + type: SourceType.DYNAMIC, + language: 'html', + }, + }, +}; diff --git a/addons/docs/src/frameworks/html/config.tsx b/addons/docs/src/frameworks/html/config.tsx deleted file mode 100644 index 5f07f0d90547..000000000000 --- a/addons/docs/src/frameworks/html/config.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { StoryFn } from '@storybook/addons'; - -export const parameters = { - docs: { - inlineStories: true, - prepareForInline: (storyFn: StoryFn) => { - const html = storyFn(); - if (typeof html === 'string') { - // eslint-disable-next-line react/no-danger - return
; - } - return ( -
(node ? node.appendChild(html) : null)} - /> - ); - }, - }, -}; diff --git a/addons/docs/src/frameworks/html/prepareForInline.tsx b/addons/docs/src/frameworks/html/prepareForInline.tsx new file mode 100644 index 000000000000..578f42c0bb34 --- /dev/null +++ b/addons/docs/src/frameworks/html/prepareForInline.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { StoryFn } from '@storybook/addons'; + +export function prepareForInline(storyFn: StoryFn) { + const html = storyFn(); + if (typeof html === 'string') { + // eslint-disable-next-line react/no-danger + return
; + } + return ( +
(node ? node.appendChild(html) : null)} /> + ); +} diff --git a/addons/docs/src/frameworks/html/sourceDecorator.test.ts b/addons/docs/src/frameworks/html/sourceDecorator.test.ts new file mode 100644 index 000000000000..6efefd6cfac4 --- /dev/null +++ b/addons/docs/src/frameworks/html/sourceDecorator.test.ts @@ -0,0 +1,113 @@ +import { addons, StoryContext } from '@storybook/addons'; +import { sourceDecorator } from './sourceDecorator'; +import { SNIPPET_RENDERED } from '../../shared'; + +jest.mock('@storybook/addons'); +const mockedAddons = addons as jest.Mocked; + +expect.addSnapshotSerializer({ + print: (val: any) => val, + test: (val) => typeof val === 'string', +}); + +const makeContext = (name: string, parameters: any, args: any, extra?: object): StoryContext => ({ + id: `html-test--${name}`, + kind: 'js-text', + name, + parameters, + args, + argTypes: {}, + globals: {}, + ...extra, +}); + +describe('sourceDecorator', () => { + let mockChannel: { on: jest.Mock; emit?: jest.Mock }; + beforeEach(() => { + mockedAddons.getChannel.mockReset(); + + mockChannel = { on: jest.fn(), emit: jest.fn() }; + mockedAddons.getChannel.mockReturnValue(mockChannel as any); + }); + + it('should render dynamically for args stories', () => { + const storyFn = (args: any) => `
args story
`; + const context = makeContext('args', { __isArgsStory: true }, {}); + sourceDecorator(storyFn, context); + expect(mockChannel.emit).toHaveBeenCalledWith( + SNIPPET_RENDERED, + 'html-test--args', + '
args story
' + ); + }); + + it('should dedent source by default', () => { + const storyFn = (args: any) => ` +
+ args story +
+ `; + const context = makeContext('args', { __isArgsStory: true }, {}); + sourceDecorator(storyFn, context); + expect(mockChannel.emit).toHaveBeenCalledWith( + SNIPPET_RENDERED, + 'html-test--args', + ['
', ' args story', '
'].join('\n') + ); + }); + + it('should skip dynamic rendering for no-args stories', () => { + const storyFn = () => `
classic story
`; + const context = makeContext('classic', {}, {}); + sourceDecorator(storyFn, context); + expect(mockChannel.emit).not.toHaveBeenCalled(); + }); + + it('should use the originalStoryFn if excludeDecorators is set', () => { + const storyFn = (args: any) => `
args story
`; + const decoratedStoryFn = (args: any) => ` +
${storyFn(args)}
+ `; + const context = makeContext( + 'args', + { + __isArgsStory: true, + docs: { + source: { + excludeDecorators: true, + }, + }, + }, + {}, + { originalStoryFn: storyFn } + ); + sourceDecorator(decoratedStoryFn, context); + expect(mockChannel.emit).toHaveBeenCalledWith( + SNIPPET_RENDERED, + 'html-test--args', + '
args story
' + ); + }); + + it('allows the snippet output to be modified by transformSource', () => { + const storyFn = (args: any) => `
args story
`; + const transformSource = (dom: string) => `

${dom}

`; + const docs = { transformSource }; + const context = makeContext('args', { __isArgsStory: true, docs }, {}); + sourceDecorator(storyFn, context); + expect(mockChannel.emit).toHaveBeenCalledWith( + SNIPPET_RENDERED, + 'html-test--args', + '

args story

' + ); + }); + + it('provides the story context to transformSource', () => { + const storyFn = (args: any) => `
args story
`; + const transformSource = jest.fn((x) => x); + const docs = { transformSource }; + const context = makeContext('args', { __isArgsStory: true, docs }, {}); + sourceDecorator(storyFn, context); + expect(transformSource).toHaveBeenCalledWith('
args story
', context); + }); +}); diff --git a/addons/docs/src/frameworks/html/sourceDecorator.ts b/addons/docs/src/frameworks/html/sourceDecorator.ts new file mode 100644 index 000000000000..fa45e820ed4c --- /dev/null +++ b/addons/docs/src/frameworks/html/sourceDecorator.ts @@ -0,0 +1,44 @@ +/* global window */ +import { addons, StoryContext, StoryFn } from '@storybook/addons'; +import dedent from 'ts-dedent'; +import { SNIPPET_RENDERED, SourceType } from '../../shared'; + +function skipSourceRender(context: StoryContext) { + const sourceParams = context?.parameters.docs?.source; + const isArgsStory = context?.parameters.__isArgsStory; + + // always render if the user forces it + if (sourceParams?.type === SourceType.DYNAMIC) { + return false; + } + + // never render if the user is forcing the block to render code, or + // if the user provides code, or if it's not an args story. + return !isArgsStory || sourceParams?.code || sourceParams?.type === SourceType.CODE; +} + +// By default, just remove indentation +function defaultTransformSource(source: string) { + // Have to wrap dedent so it doesn't serialize the context + return dedent(source); +} + +function applyTransformSource(source: string, context: StoryContext): string { + const docs = context.parameters.docs ?? {}; + const transformSource = docs.transformSource ?? defaultTransformSource; + return transformSource(source, context); +} + +export function sourceDecorator(storyFn: StoryFn, context: StoryContext) { + const story = context?.parameters.docs?.source?.excludeDecorators + ? context.originalStoryFn(context.args) + : storyFn(); + + if (typeof story === 'string' && !skipSourceRender(context)) { + const source = applyTransformSource(story, context); + + addons.getChannel().emit(SNIPPET_RENDERED, context.id, source); + } + + return story; +} diff --git a/docs/frameworks.js b/docs/frameworks.js index 4848ca94a20b..b777951f33b0 100644 --- a/docs/frameworks.js +++ b/docs/frameworks.js @@ -121,7 +121,7 @@ module.exports = { }, { name: 'Dynamic source', - supported: ['react', 'vue', 'angular', 'svelte', 'web-components'], + supported: ['react', 'vue', 'angular', 'svelte', 'web-components', 'html'], path: 'writing-docs/doc-blocks#source', }, { diff --git a/examples/html-kitchen-sink/.storybook/preview.js b/examples/html-kitchen-sink/.storybook/preview.js index 4d8de85177bc..00412fe3b266 100644 --- a/examples/html-kitchen-sink/.storybook/preview.js +++ b/examples/html-kitchen-sink/.storybook/preview.js @@ -1,7 +1,5 @@ import { addParameters } from '@storybook/html'; -const SOURCE_REGEX = /^\(\) => [`'"](.*)['`"]$/; - addParameters({ a11y: { config: {}, @@ -12,9 +10,5 @@ addParameters({ }, docs: { iframeHeight: '200px', - transformSource: (src) => { - const match = SOURCE_REGEX.exec(src); - return match ? match[1] : src; - }, }, }); diff --git a/examples/html-kitchen-sink/stories/__snapshots__/addon-docs.stories.storyshot b/examples/html-kitchen-sink/stories/__snapshots__/addon-docs.stories.storyshot index 63efe8baf032..e4acdbf09b6b 100644 --- a/examples/html-kitchen-sink/stories/__snapshots__/addon-docs.stories.storyshot +++ b/examples/html-kitchen-sink/stories/__snapshots__/addon-docs.stories.storyshot @@ -18,6 +18,12 @@ exports[`Storyshots Addons/Docs heading 1`] = ` `; +exports[`Storyshots Addons/Docs standard source 1`] = ` +

+ Standard source +

+`; + exports[`Storyshots Addons/Docs transformed source 1`] = `

Some source diff --git a/examples/html-kitchen-sink/stories/addon-docs.stories.mdx b/examples/html-kitchen-sink/stories/addon-docs.stories.mdx index 81a113956ebb..b844dc5e346d 100644 --- a/examples/html-kitchen-sink/stories/addon-docs.stories.mdx +++ b/examples/html-kitchen-sink/stories/addon-docs.stories.mdx @@ -25,6 +25,14 @@ How you like them apples?! }} +## Standard source + + + + {'

Standard source

'} +
+
+ ## Custom source