Skip to content

Commit

Permalink
HTML: Dynamic source snippets
Browse files Browse the repository at this point in the history
Issue: #12755

Adds dynamic source to the HTML 'framework' package.
  • Loading branch information
Chris Garrett committed Aug 3, 2021
1 parent d29ac01 commit b8b55e9
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 27 deletions.
16 changes: 16 additions & 0 deletions 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',
},
},
};
20 changes: 0 additions & 20 deletions addons/docs/src/frameworks/html/config.tsx

This file was deleted.

13 changes: 13 additions & 0 deletions 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<string>) {
const html = storyFn();
if (typeof html === 'string') {
// eslint-disable-next-line react/no-danger
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<div ref={(node?: HTMLDivElement): never | null => (node ? node.appendChild(html) : null)} />
);
}
113 changes: 113 additions & 0 deletions 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<typeof addons>;

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) => `<div>args story</div>`;
const context = makeContext('args', { __isArgsStory: true }, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'html-test--args',
'<div>args story</div>'
);
});

it('should dedent source by default', () => {
const storyFn = (args: any) => `
<div>
args story
</div>
`;
const context = makeContext('args', { __isArgsStory: true }, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'html-test--args',
['<div>', ' args story', '</div>'].join('\n')
);
});

it('should skip dynamic rendering for no-args stories', () => {
const storyFn = () => `<div>classic story</div>`;
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) => `<div>args story</div>`;
const decoratedStoryFn = (args: any) => `
<div style="padding: 25px; border: 3px solid red;">${storyFn(args)}</div>
`;
const context = makeContext(
'args',
{
__isArgsStory: true,
docs: {
source: {
excludeDecorators: true,
},
},
},
{},
{ originalStoryFn: storyFn }
);
sourceDecorator(decoratedStoryFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'html-test--args',
'<div>args story</div>'
);
});

it('allows the snippet output to be modified by transformSource', () => {
const storyFn = (args: any) => `<div>args story</div>`;
const transformSource = (dom: string) => `<p>${dom}</p>`;
const docs = { transformSource };
const context = makeContext('args', { __isArgsStory: true, docs }, {});
sourceDecorator(storyFn, context);
expect(mockChannel.emit).toHaveBeenCalledWith(
SNIPPET_RENDERED,
'html-test--args',
'<p><div>args story</div></p>'
);
});

it('provides the story context to transformSource', () => {
const storyFn = (args: any) => `<div>args story</div>`;
const transformSource = jest.fn((x) => x);
const docs = { transformSource };
const context = makeContext('args', { __isArgsStory: true, docs }, {});
sourceDecorator(storyFn, context);
expect(transformSource).toHaveBeenCalledWith('<div>args story</div>', context);
});
});
44 changes: 44 additions & 0 deletions 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;
}
2 changes: 1 addition & 1 deletion docs/frameworks.js
Expand Up @@ -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',
},
{
Expand Down
6 changes: 0 additions & 6 deletions examples/html-kitchen-sink/.storybook/preview.js
@@ -1,7 +1,5 @@
import { addParameters } from '@storybook/html';

const SOURCE_REGEX = /^\(\) => [`'"](.*)['`"]$/;

addParameters({
a11y: {
config: {},
Expand All @@ -12,9 +10,5 @@ addParameters({
},
docs: {
iframeHeight: '200px',
transformSource: (src) => {
const match = SOURCE_REGEX.exec(src);
return match ? match[1] : src;
},
},
});
8 changes: 8 additions & 0 deletions examples/html-kitchen-sink/stories/addon-docs.stories.mdx
Expand Up @@ -25,6 +25,14 @@ How you like them apples?!
}}
</Story>

## Standard source

<Canvas>
<Story name="standard source" height="100px">
{'<h1>Standard source</h1>'}
</Story>
</Canvas>

## Custom source

<Canvas>
Expand Down

0 comments on commit b8b55e9

Please sign in to comment.