Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Addon-docs: MDX Linking #9051

Merged
merged 14 commits into from
Dec 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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