Skip to content

Commit

Permalink
Addon-docs: MDX Linking (#9051)
Browse files Browse the repository at this point in the history
Addon-docs: MDX Linking
  • Loading branch information
shilman authored Dec 4, 2019
2 parents edb29bc + 421f61e commit 21b8a7f
Show file tree
Hide file tree
Showing 13 changed files with 365 additions and 56 deletions.
2 changes: 2 additions & 0 deletions addons/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
"lodash": "^4.17.15",
"prop-types": "^15.7.2",
"react-element-to-jsx-string": "^14.1.0",
"remark-external-links": "^5.0.0",
"remark-slug": "^5.1.2",
"ts-dedent": "^1.1.0",
"util-deprecate": "^1.0.2",
"vue-docgen-api": "^3.26.0",
Expand Down
4 changes: 2 additions & 2 deletions addons/docs/src/blocks/Anchor.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { FunctionComponent } from 'react';
import React, { FC } from 'react';

export const anchorBlockIdFromId = (storyId: string) => `anchor--${storyId}`;

export interface AnchorProps {
storyId: string;
}

export const Anchor: FunctionComponent<AnchorProps> = ({ storyId, children }) => (
export const Anchor: FC<AnchorProps> = ({ storyId, children }) => (
<div id={anchorBlockIdFromId(storyId)}>{children}</div>
);
78 changes: 34 additions & 44 deletions addons/docs/src/blocks/DocsContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,65 @@
import React, { FunctionComponent, useEffect } from 'react';
import { document } from 'global';
import { document, window } from 'global';
import { MDXProvider } from '@mdx-js/react';
import { ThemeProvider, ensure as ensureTheme } from '@storybook/theming';
import { DocsWrapper, DocsContent, Source } from '@storybook/components';
import { components as htmlComponents, Code } from '@storybook/components/html';
import { DocsWrapper, DocsContent } from '@storybook/components';
import { components as htmlComponents } from '@storybook/components/html';
import { DocsContextProps, DocsContext } from './DocsContext';
import { anchorBlockIdFromId } from './Anchor';
import { storyBlockIdFromId } from './Story';
import { CodeOrSourceMdx, AnchorMdx, HeadersMdx } from './mdx';
import { scrollToElement } from './utils';

interface DocsContainerProps {
context: DocsContextProps;
}

interface CodeOrSourceProps {
className?: string;
}
export const CodeOrSource: FunctionComponent<CodeOrSourceProps> = props => {
const { className, children, ...rest } = props;
// markdown-to-jsx does not add className to inline code
if (
typeof className !== 'string' &&
(typeof children !== 'string' || !(children as string).match(/[\n\r]/g))
) {
return <Code>{children}</Code>;
}
// className: "lang-jsx"
const language = className && className.split('-');
return (
<Source
language={(language && language[1]) || 'plaintext'}
format={false}
code={children as string}
{...rest}
/>
);
};

const defaultComponents = {
...htmlComponents,
code: CodeOrSource,
code: CodeOrSourceMdx,
a: AnchorMdx,
...HeadersMdx,
};

export const DocsContainer: FunctionComponent<DocsContainerProps> = ({ context, children }) => {
const { id: storyId = null, parameters = {} } = context || {};
const options = parameters.options || {};
const theme = ensureTheme(options.theme);
const { components: userComponents = null } = parameters.docs || {};
const components = { ...defaultComponents, ...userComponents };
const allComponents = { ...defaultComponents, ...userComponents };

useEffect(() => {
let element = document.getElementById(anchorBlockIdFromId(storyId));
if (!element) {
element = document.getElementById(storyBlockIdFromId(storyId));
}
if (element) {
const allStories = element.parentElement.querySelectorAll('[id|="anchor-"]');
let block = 'start';
if (allStories && allStories[0] === element) {
block = 'end'; // first story should be shown with the intro content above
const url = new URL(window.parent.location);
if (url.hash) {
const element = document.getElementById(url.hash.substring(1));
if (element) {
// Introducing a delay to ensure scrolling works when it's a full refresh.
setTimeout(() => {
scrollToElement(element);
}, 200);
}
} else {
const element =
document.getElementById(anchorBlockIdFromId(storyId)) ||
document.getElementById(storyBlockIdFromId(storyId));
if (element) {
const allStories = element.parentElement.querySelectorAll('[id|="anchor-"]');
let block = 'start';
if (allStories && allStories[0] === element) {
block = 'end'; // first story should be shown with the intro content above
}
// Introducing a delay to ensure scrolling works when it's a full refresh.
setTimeout(() => {
scrollToElement(element, block);
}, 200);
}
element.scrollIntoView({
behavior: 'smooth',
block,
inline: 'nearest',
});
}
}, [storyId]);

return (
<DocsContext.Provider value={context}>
<ThemeProvider theme={theme}>
<MDXProvider components={components}>
<MDXProvider components={allComponents}>
<DocsWrapper className="sbdocs sbdocs-wrapper">
<DocsContent className="sbdocs sbdocs-content">{children}</DocsContent>
</DocsWrapper>
Expand Down
193 changes: 191 additions & 2 deletions addons/docs/src/blocks/mdx.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,203 @@
import * as React from 'react';
import React, { FC, SyntheticEvent } from 'react';
import { Source } from '@storybook/components';
import { Code, components } from '@storybook/components/html';
import { document, window } from 'global';
import { isNil } from 'lodash';
import { styled } from '@storybook/theming';
import { DocsContext, DocsContextProps } from './DocsContext';
import { scrollToElement } from './utils';

// Hacky utility for dealing with functions or values in MDX Story elements
export const makeStoryFn = (val: any) => (typeof val === 'function' ? val : () => val);

// Hacky utilty for adding mdxStoryToId to the default context
export const AddContext: React.FC<DocsContextProps> = props => {
export const AddContext: FC<DocsContextProps> = props => {
const { children, ...rest } = props;
const parentContext = React.useContext(DocsContext);
return (
<DocsContext.Provider value={{ ...parentContext, ...rest }}>{children}</DocsContext.Provider>
);
};

interface CodeOrSourceMdxProps {
className?: string;
}

export const CodeOrSourceMdx: FC<CodeOrSourceMdxProps> = ({ className, children, ...rest }) => {
// markdown-to-jsx does not add className to inline code
if (
typeof className !== 'string' &&
(typeof children !== 'string' || !(children as string).match(/[\n\r]/g))
) {
return <Code>{children}</Code>;
}
// className: "lang-jsx"
const language = className && className.split('-');
return (
<Source
language={(language && language[1]) || 'plaintext'}
format={false}
code={children as string}
{...rest}
/>
);
};

function generateHrefWithHash(hash: string): string {
const url = new URL(window.parent.location);
const href = `${url.origin}/${url.search}#${hash}`;

return href;
}

// @ts-ignore
const A = components.a;

interface AnchorInPageProps {
hash: string;
}

const AnchorInPage: FC<AnchorInPageProps> = ({ hash, children }) => (
<A
href={hash}
onClick={(event: SyntheticEvent) => {
event.preventDefault();

const hashValue = hash.substring(1);
const element = document.getElementById(hashValue);
if (!isNil(element)) {
window.parent.history.replaceState(null, '', generateHrefWithHash(hashValue));
scrollToElement(element);
}
}}
>
{children}
</A>
);

interface AnchorMdxProps {
href: string;
target: string;
}

export const AnchorMdx: FC<AnchorMdxProps> = props => {
const { href, target, children, ...rest } = props;

if (!isNil(href)) {
// Enable scrolling for in-page anchors.
if (href.startsWith('#')) {
return <AnchorInPage hash={href}>{children}</AnchorInPage>;
}

// Links to other pages of SB should use the base URL of the top level iframe instead of the base URL of the preview iframe.
if (target !== '_blank') {
const parentUrl = new URL(window.parent.location.href);
const newHref = `${parentUrl.origin}${href}`;

return (
<A href={newHref} target={target} {...rest}>
{children}
</A>
);
}
}

// External URL dont need any modification.
return <A {...props} />;
};

const SUPPORTED_MDX_HEADERS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];

const OcticonHeaders = SUPPORTED_MDX_HEADERS.reduce(
(acc, headerType) => ({
...acc,
// @ts-ignore
[headerType]: styled(components[headerType])({
'& svg': {
visibility: 'hidden',
},
'&:hover svg': {
visibility: 'visible',
},
}),
}),
{}
);

const OcticonAnchor = styled.a(() => ({
float: 'left',
paddingRight: '4px',
marginLeft: '-20px',
}));

interface HeaderWithOcticonAnchorProps {
as: string;
id: string;
children: any;
}

const HeaderWithOcticonAnchor: FC<HeaderWithOcticonAnchorProps> = ({
as,
id,
children,
...rest
}) => {
// @ts-ignore
const OcticonHeader = OcticonHeaders[as];

return (
<OcticonHeader id={id} {...rest}>
<OcticonAnchor
aria-hidden="true"
href={generateHrefWithHash(id)}
onClick={() => {
const element = document.getElementById(id);
if (!isNil(element)) {
scrollToElement(element);
}
}}
>
<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
<path
fillRule="evenodd"
d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"
/>
</svg>
</OcticonAnchor>
{children}
</OcticonHeader>
);
};

interface HeaderMdxProps {
as: string;
id: string;
}

const HeaderMdx: FC<HeaderMdxProps> = props => {
const { as, id, children, ...rest } = props;

// An id should have been added on every header by the "remark-slug" plugin.
if (!isNil(id)) {
return (
<HeaderWithOcticonAnchor as={as} id={id} {...rest}>
{children}
</HeaderWithOcticonAnchor>
);
}

// @ts-ignore
const Header = components[as];

// Make sure it still work if "remark-slug" plugin is not present.
return <Header {...props} />;
};

export const HeadersMdx = SUPPORTED_MDX_HEADERS.reduce(
(acc, headerType) => ({
...acc,
// @ts-ignore
[headerType]: (props: object) => <HeaderMdx as={headerType} {...props} />,
}),
{}
);
8 changes: 8 additions & 0 deletions addons/docs/src/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ export const getComponentName = (component: Component): string => {

return component.name;
};

export function scrollToElement(element: any, block = 'start') {
element.scrollIntoView({
behavior: 'smooth',
block,
inline: 'nearest',
});
}
8 changes: 8 additions & 0 deletions addons/docs/src/frameworks/common/preset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import createCompiler from '@storybook/addon-docs/mdx-compiler-plugin';
import remarkSlug from 'remark-slug';
import remarkExternalLinks from 'remark-external-links';

function createBabelOptions(babelOptions?: any, configureJSX?: boolean) {
if (!configureJSX) {
Expand All @@ -26,6 +28,10 @@ export function webpack(webpackConfig: any = {}, options: any = {}) {
sourceLoaderOptions = {},
} = options;

const mdxLoaderOptions = {
remarkPlugins: [remarkSlug, remarkExternalLinks],
};

// set `sourceLoaderOptions` to `null` to disable for manual configuration
const sourceLoader = sourceLoaderOptions
? [
Expand Down Expand Up @@ -67,6 +73,7 @@ export function webpack(webpackConfig: any = {}, options: any = {}) {
loader: '@mdx-js/loader',
options: {
compilers: [createCompiler(options)],
...mdxLoaderOptions,
},
},
],
Expand All @@ -81,6 +88,7 @@ export function webpack(webpackConfig: any = {}, options: any = {}) {
},
{
loader: '@mdx-js/loader',
options: mdxLoaderOptions,
},
],
},
Expand Down
2 changes: 2 additions & 0 deletions addons/docs/src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ declare module '@storybook/addon-docs/blocks';
declare module 'global';
declare module 'react-is';
declare module '@egoist/vue-to-react';
declare module "remark-slug";
declare module "remark-external-links";
Loading

0 comments on commit 21b8a7f

Please sign in to comment.