diff --git a/.changeset/silent-dryers-tell.md b/.changeset/silent-dryers-tell.md new file mode 100644 index 00000000..d906d5a0 --- /dev/null +++ b/.changeset/silent-dryers-tell.md @@ -0,0 +1,5 @@ +--- +'@open-wc/demoing-storybook': patch +--- + +inline MDJS plugin diff --git a/packages/demoing-storybook/package.json b/packages/demoing-storybook/package.json index 9a7bc803..dfe7f49d 100644 --- a/packages/demoing-storybook/package.json +++ b/packages/demoing-storybook/package.json @@ -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" } } diff --git a/packages/demoing-storybook/src/build/rollup/transformMdPlugin.js b/packages/demoing-storybook/src/build/rollup/transformMdPlugin.js index 618db89a..7bcd4348 100644 --- a/packages/demoing-storybook/src/build/rollup/transformMdPlugin.js +++ b/packages/demoing-storybook/src/build/rollup/transformMdPlugin.js @@ -1,4 +1,4 @@ -const { mdjsToCsf } = require('storybook-addon-markdown-docs'); +const { mdjsToCsf } = require('../../storybook-addon-markdown-docs'); const { transformMdxToCsf } = require('../../shared/transformMdxToCsf'); /** diff --git a/packages/demoing-storybook/src/start/transformers/createMdjsToCsfTransformer.js b/packages/demoing-storybook/src/start/transformers/createMdjsToCsfTransformer.js index 76fb0771..2c69533d 100644 --- a/packages/demoing-storybook/src/start/transformers/createMdjsToCsfTransformer.js +++ b/packages/demoing-storybook/src/start/transformers/createMdjsToCsfTransformer.js @@ -1,4 +1,4 @@ -const { mdjsToCsf } = require('storybook-addon-markdown-docs'); +const { mdjsToCsf } = require('../../storybook-addon-markdown-docs'); function createMdjsToCsfTransformer(options) { /** diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/index.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/index.js new file mode 100644 index 00000000..faee9ed0 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/index.js @@ -0,0 +1,3 @@ +const { mdjsToCsf } = require('./src/mdjsToCsf'); + +module.exports = { mdjsToCsf }; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/createStoriesCode.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/createStoriesCode.js new file mode 100644 index 00000000..b26d2f71 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/createStoriesCode.js @@ -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, +}; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/global.d.ts b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/global.d.ts new file mode 100644 index 00000000..127f6036 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/global.d.ts @@ -0,0 +1,8 @@ +declare module '@mdx-js/mdx' { + function mdx(markdown: string, options: { compilers: any[]; filepath: string }): Promise; + export = mdx; +} + +declare module '@mdx-js/mdx/mdx-hast-to-jsx' { + export function toJSX(a: any, b: any, c: any): any; +} diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/jsxToJs.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/jsxToJs.js new file mode 100644 index 00000000..522f09b1 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/jsxToJs.js @@ -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} + */ +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 }; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdToJsx.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdToJsx.js new file mode 100644 index 00000000..63b6f4a7 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdToJsx.js @@ -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} + */ +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} */ + 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 = () => ; +${ + !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} + */ +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, +}; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToCsf.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToCsf.js new file mode 100644 index 00000000..fd1c001b --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToCsf.js @@ -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} + */ +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, +}; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToMd.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToMd.js new file mode 100644 index 00000000..b85ce0c4 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/mdjsToMd.js @@ -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 `` 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 = ``; + return h.augment(node, u('raw', raw)); +} + +/** + * Override line break to make it closing `
` 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', '
')), u('text', '\n')]; +} + +/** + * Override hr to make it closing `
` as otherwise the jsx parser throws + * + * @param {*} h + * @param {*} node + */ +function thematicBreak(h, node) { + return h.augment(node, u('raw', `
`)); +} + +function transformPropsHook() { + // @ts-ignore + return tree => { + visit(tree, 'html', (node, ancestors) => { + // @ts-ignore + if (node.value.startsWith('', ' />'); + ancestors[1].children = []; + /* eslint-enable no-param-reassign */ + } + }); + return tree; + }; +} + +/** + * @param {string} name + */ +function storyTag(name) { + return ``; +} + +/** + * @param {string} name + */ +function previewStoryTag(name) { + return ``; +} + +/** @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} + */ +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 }; diff --git a/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/renameDefaultExport.js b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/renameDefaultExport.js new file mode 100644 index 00000000..ff10f6e4 --- /dev/null +++ b/packages/demoing-storybook/src/storybook-addon-markdown-docs/src/renameDefaultExport.js @@ -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, +};