chore(demoing-storybook): inline MDJS plugin

This commit is contained in:
Lars den Bakker
2020-11-07 17:55:45 +01:00
parent 42e5c4f966
commit ffb5f16cad
12 changed files with 449 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
'@open-wc/demoing-storybook': patch
---
inline MDJS plugin

View File

@@ -70,7 +70,16 @@
"rollup": "^2.7.2",
"rollup-plugin-babel": "^5.0.0-alpha.1",
"rollup-plugin-terser": "^7.0.2",
"storybook-addon-markdown-docs": "^0.4.4",
"storybook-prebuilt": "^1.5.0"
"@babel/code-frame": "^7.8.3",
"@mdjs/core": "^0.4.1",
"detab": "^2.0.3",
"mdurl": "^1.0.1",
"remark-html": "^10.0.0",
"remark-parse": "^7.0.2",
"remark-slug": "^5.1.2",
"storybook-prebuilt": "^1.5.0",
"unified": "^8.4.2",
"unist-builder": "^2.0.2",
"unist-util-visit-parents": "^3.0.2"
}
}

View File

@@ -1,4 +1,4 @@
const { mdjsToCsf } = require('storybook-addon-markdown-docs');
const { mdjsToCsf } = require('../../storybook-addon-markdown-docs');
const { transformMdxToCsf } = require('../../shared/transformMdxToCsf');
/**

View File

@@ -1,4 +1,4 @@
const { mdjsToCsf } = require('storybook-addon-markdown-docs');
const { mdjsToCsf } = require('../../storybook-addon-markdown-docs');
function createMdjsToCsfTransformer(options) {
/**

View File

@@ -0,0 +1,3 @@
const { mdjsToCsf } = require('./src/mdjsToCsf');
module.exports = { mdjsToCsf };

View File

@@ -0,0 +1,26 @@
/** @typedef {import('@mdjs/core').Story} Story */
/**
* @param {Story[]} stories
* @returns {string}
*/
function createStoriesCode(stories) {
let allCode = '';
for (const story of stories) {
const { key, name, code } = story;
if (!key) throw new Error(`Missing key in story`);
if (!code) throw new Error(`Missing code in story`);
allCode += `${code}\n`;
allCode += `${key}.story = ${key}.story || {};\n`;
allCode += `${name !== key ? `${key}.story.name = ${JSON.stringify(name)};\n` : ''}`;
allCode += `${key}.story.parameters = ${key}.story.parameters || {};\n`;
allCode += `${key}.story.parameters.mdxSource = ${JSON.stringify(code.trim())};\n`;
allCode += '\n';
}
return allCode;
}
module.exports = {
createStoriesCode,
};

View File

@@ -0,0 +1,8 @@
declare module '@mdx-js/mdx' {
function mdx(markdown: string, options: { compilers: any[]; filepath: string }): Promise<string>;
export = mdx;
}
declare module '@mdx-js/mdx/mdx-hast-to-jsx' {
export function toJSX(a: any, b: any, c: any): any;
}

View File

@@ -0,0 +1,27 @@
const { transformAsync } = require('@babel/core');
/**
* Turns the JSX generated by MDX to JS.
*
* @param {string} docsJsx
* @param {string} filename
* @returns {Promise<string>}
*/
async function jsxToJs(docsJsx, filename) {
const result = await transformAsync(docsJsx, {
filename,
sourceMaps: true,
babelrc: false,
configFile: false,
plugins: [
require.resolve('@babel/plugin-syntax-import-meta'),
[require.resolve('@babel/plugin-transform-react-jsx'), { useSpread: true }],
],
});
if (!result || typeof result.code !== 'string') {
throw new Error(`Something went wrong when compiling ${filename}`);
}
return result.code;
}
module.exports = { jsxToJs };

View File

@@ -0,0 +1,97 @@
/** @typedef {import('@mdjs/core').Story} Story */
const mdx = require('@mdx-js/mdx');
const mdxToJsx = require('@mdx-js/mdx/mdx-hast-to-jsx');
/**
* @param {string} markdown
* @param {string} filepath
* @returns {Promise<string>}
*/
function compileMdToJsx(markdown, filepath) {
return mdx(
`import { Story, Preview, Props } from 'storybook-prebuilt/addon-docs/blocks.js';\n\n${markdown}`,
{
compilers: [
// custom mdx compiler which ensures mdx doesn't add a default export,
// we don't need it because we are adding our own
function mdxCompiler() {
// @ts-ignore
this.Compiler = tree => mdxToJsx.toJSX(tree, {}, { skipExport: true });
},
],
filepath,
},
);
}
/**
* @param {Story[]} [stories]
*/
function createDocsPage(stories) {
/** @type {Record<string, string>} */
const storyNameToKey = {};
if (stories) {
for (const { key, name } of stories) {
storyNameToKey[name || key] = key;
}
}
return `import * as React from 'storybook-prebuilt/react.js';
import { mdx, AddContext } from 'storybook-prebuilt/addon-docs/blocks.js';
// Setup docs page
const mdxStoryNameToKey = ${JSON.stringify(storyNameToKey)};
__export_default__.parameters = __export_default__.parameters || {};
__export_default__.parameters.docs = __export_default__.parameters.docs || {};
__export_default__.parameters.docs.page = () => <AddContext
mdxStoryNameToKey={mdxStoryNameToKey}
mdxComponentMeta={__export_default__}><MDXContent
/></AddContext>;
${
!stories || stories.length === 0
? `
export const __page = () => {
throw new Error("Docs-only story");
};
__page.story = {
parameters: {
docsOnly: true
}
};
`
: ''
}
export default __export_default__;`;
}
/**
* Turns MD into JSX using the MDX compiler. This is necessary because most of the
* regular storybook docs functionality relies on JSX and MDX specifics
*
* @param {string} markdown
* @param {string} filepath
* @param {Story[]} [stories]
* @returns {Promise<string>}
*/
async function mdToJsx(markdown, filepath, stories) {
return `/**
*
* The code below is generated by storybook docs.
*
*/
${createDocsPage(stories)}
// The docs page, markdown turned into using jsx for storybook
${await compileMdToJsx(markdown, filepath)}`;
}
module.exports = {
mdToJsx,
// export for testing
createDocsPage,
compileMdToJsx,
};

View File

@@ -0,0 +1,26 @@
const { mdjsToMd } = require('./mdjsToMd');
const { renameDefaultExport } = require('./renameDefaultExport');
const { createStoriesCode } = require('./createStoriesCode');
const { mdToJsx } = require('./mdToJsx');
const { jsxToJs } = require('./jsxToJs');
/**
* @param {string} markdown
* @param {string} filePath
* @param {object} options
* @returns {Promise<string>}
*/
async function mdjsToCsf(markdown, filePath, options = {}) {
const markdownResult = await mdjsToMd(markdown, { ...options, filePath });
const jsCode = renameDefaultExport(markdownResult.jsCode, filePath);
const storiesCode = createStoriesCode(markdownResult.stories);
const docsJsx = await mdToJsx(markdownResult.html, filePath, markdownResult.stories);
const docs = await jsxToJs(docsJsx, filePath);
return `${jsCode}\n${storiesCode}\n${docs}`;
}
module.exports = {
mdjsToCsf,
};

View File

@@ -0,0 +1,169 @@
/** @typedef {import('@mdjs/core').Story} Story */
/** @typedef {import('@mdjs/core').MarkdownResult} MarkdownResult */
/** @typedef {import('@mdjs/core').ParseResult} ParseResult */
/** @typedef {import('@mdjs/core').MdjsProcessPlugin} MdjsProcessPlugin */
const unified = require('unified');
const markdown = require('remark-parse');
// @ts-ignore
const mdSlug = require('remark-slug');
// @ts-ignore
const mdStringify = require('remark-html');
// @ts-ignore
const detab = require('detab');
const u = require('unist-builder');
const visit = require('unist-util-visit-parents');
// @ts-ignore
const normalize = require('mdurl/encode');
const { mdjsParse, mdjsStoryParse } = require('@mdjs/core');
/**
* Keep the code blocks as md source code so storybook will use it's special code block
*
* @param {*} h
* @param {*} node
*/
function code(h, node) {
const value = node.value ? detab(node.value) : '';
const raw = ['', `\`\`\`${node.lang}`, value, '```'].join('\n');
return h.augment(node, u('raw', raw));
}
/**
* Override image to make it closing `<img src="" />` as otherwise the jsx parser throws
*
* @param {*} h
* @param {*} node
*/
function image(h, node) {
const attributes = [];
if (node.title !== null && node.title !== undefined) {
attributes.push(`title="${node.title}"`);
}
if (node.alt !== null && node.alt !== undefined) {
attributes.push(`alt="${node.alt}"`);
}
if (node.url !== null && node.url !== undefined) {
attributes.push(`src="${normalize(node.url)}"`);
}
const raw = `<img ${attributes.join(' ')} />`;
return h.augment(node, u('raw', raw));
}
/**
* Override line break to make it closing `<br />` as otherwise the jsx parser throws
* Can't use 'break' as a function name as it is a reserved javascript language name
*
* @param {*} h
* @param {*} node
*/
function hardBreak(h, node) {
return [h.augment(node, u('raw', '<br />')), u('text', '\n')];
}
/**
* Override hr to make it closing `<hr />` as otherwise the jsx parser throws
*
* @param {*} h
* @param {*} node
*/
function thematicBreak(h, node) {
return h.augment(node, u('raw', `<hr />`));
}
function transformPropsHook() {
// @ts-ignore
return tree => {
visit(tree, 'html', (node, ancestors) => {
// @ts-ignore
if (node.value.startsWith('<sb-props')) {
/* eslint-disable no-param-reassign */
ancestors[1].type = 'html';
// @ts-ignore
ancestors[1].value = node.value.replace('<sb-props', '<Props').replace('>', ' />');
ancestors[1].children = [];
/* eslint-enable no-param-reassign */
}
});
return tree;
};
}
/**
* @param {string} name
*/
function storyTag(name) {
return `<Story name="${name}"></Story>`;
}
/**
* @param {string} name
*/
function previewStoryTag(name) {
return `<Preview><Story name="${name}"></Story></Preview>`;
}
/** @type {MdjsProcessPlugin[]} */
const mdjsToMdPlugins = [
{ name: 'markdown', plugin: markdown },
{ name: 'mdjsParse', plugin: mdjsParse },
{
name: 'mdjsStoryParse',
plugin: mdjsStoryParse,
options: {
storyTag,
previewStoryTag,
},
},
{ name: 'transformPropsHook', plugin: transformPropsHook },
{ name: 'mdSlug', plugin: mdSlug },
{
name: 'mdStringify',
plugin: mdStringify,
options: {
handlers: {
code,
image,
break: hardBreak,
thematicBreak,
},
},
},
];
/**
* @param {MdjsProcessPlugin[]} plugins
* @param {string=} filePath
*/
// eslint-disable-next-line no-unused-vars
function defaultSetupMdjsPlugins(plugins, filePath = '') {
return plugins;
}
/**
* @param {string} markdownText
* @returns {Promise<MarkdownResult>}
*/
async function mdjsToMd(
markdownText,
{ filePath = '', setupMdjsPlugins = defaultSetupMdjsPlugins } = {},
) {
const plugins = setupMdjsPlugins(mdjsToMdPlugins, filePath);
const parser = unified();
for (const pluginObj of plugins) {
parser.use(pluginObj.plugin, pluginObj.options);
}
/** @type {unknown} */
const parseResult = await parser.process(markdownText);
const result = /** @type {ParseResult} */ (parseResult);
return {
html: result.contents,
jsCode: result.data.jsCode,
stories: result.data.stories,
};
}
module.exports = { mdjsToMd };

View File

@@ -0,0 +1,75 @@
/** @typedef {import('@babel/core').types.File} File */
const { parse } = require('@babel/parser');
// @ts-ignore
const { codeFrameColumns } = require('@babel/code-frame');
const {
isExportDefaultDeclaration,
isExpression,
isIdentifier,
variableDeclaration,
variableDeclarator,
identifier,
} = require('@babel/core').types;
const { default: generate } = require('@babel/generator');
/**
* @param {string} path
*/
function createMissingExportError(path) {
return new Error(
`${path} does not contain a default export with the page title. This is required for Storybook.`,
);
}
/**
* @param {string} code
* @param {string} path
* @param {boolean} [highlightError]
* @returns {string}
*/
function renameDefaultExport(code, path, highlightError = true) {
if (!code) {
throw createMissingExportError(path);
}
/** @type {File} */
let file;
try {
// @ts-ignore
file = parse(code, { sourceType: 'module', sourceFilename: path });
} catch (error) {
const codeFrame = codeFrameColumns(
code,
{ start: error.loc },
{ highlightCode: highlightError },
);
throw new Error(`${error.message}\n\n${codeFrame}`);
}
if (!file) {
throw createMissingExportError(path);
}
const { body } = file.program;
const [defaultExport] = body.filter(n => isExportDefaultDeclaration(n));
if (!defaultExport || !isExportDefaultDeclaration(defaultExport)) {
throw createMissingExportError(path);
}
if (!isExpression(defaultExport.declaration) && !isIdentifier(defaultExport.declaration)) {
throw createMissingExportError(path);
}
// replace the user's default export with a variable, so that we can add it to the storybook
// default export later
const defaultExportReplacement = variableDeclaration('const', [
variableDeclarator(identifier('__export_default__'), defaultExport.declaration),
]);
body.splice(body.indexOf(defaultExport), 1, defaultExportReplacement);
return generate(file).code;
}
module.exports = {
renameDefaultExport,
};