markdown转react可运行示例

团队最近在做UI体系的沉淀,会涉及到业务组件的demo呈现,demo的文档以markdown承载,所以需要将md文件转成可运行的示例代码,转换的时机是在webpack构建时以loader的形式去解析,而在代码中可直接以类React组件的形式使用,如:

// doc.js
import ButtonDemo from './button.md';
class Demo extends React.Component {
    render() {
        return <ButtonDemo />
    }
}

 

下面简单介绍一下解析的过程,需要用到的依赖包有:

npm install front-matter highlight.js markdown-it --save-dev

 

在此之前我们来看看组件的md文档的写法,以button来说,其说明文档可能如下:

---
name: NruiButton
imports:
    import NruiButton from '@alife/nrui-button';
---
### Demo示例
这是示例的文案描述
````
class NruiButtonDemo extends React.Component {
  render() {
    return <NruiButton errMsg='错误信息' errCode='错误码'/>;
  }
}
````
### API
参数 | 说明 | 类型 | 默认值
------- | ------- | ------- | -------
errMsg | 错误信息 | string | -
errCode | 错误吗 | string | -


 

最顶部---之间的是一些元数据,包括name名称和一些import依赖,而之间的就是示例代码,我们的目的就是要解析这里的代码,并变成可执行的示例。而元数据可以通过前面安装的front-matter包提取出来,至于示例代码可通过正则匹配出来

// loader导出
module.exports = function (source) {
    this.cacheable && this.cacheable();
    // 获取到头部元数据信息
    const { body, attributes: { name, imports: importMap } } = frontMatter(source);
    
    // 默认import react
    let defaultImports = "import React from 'react';\n";
    // 拼接其他import依赖
    if (importMap) {
        defaultImports += `${importMap}\n`;
    }
    // 获取代码片段,并替换为占位符
    const codes = {};
    let html = body.replace(/````([^]+?)````/g, (match, $1, offset) => {
        const id = name + offset;
        codes[id] = $1;
        return `mark-placeholder-${id}`;
    });
    // 将markdown转成html,md对象后面会提到
    html = md.render(html);
    // 将demo再包装一下
    Object.keys(codes).forEach((key) => {
        let code = codes[key];
        html = html.replace(new RegExp(`(<p>)?mark-placeholder-${key}(<\\/p>)?`, 'g'), () => {
            // wrapperDemo、matchComponentName后面会提到
            return wrapperDemo(matchComponentName(code), code, key);
        });
    });
    // 替换未闭合的标签和class
    html = html.replace(/<hr>/g, '<hr />')
        .replace(/<br>/g, '<br />')
        .replace(/class=/g, 'className=');
    // 最终将md文档包装成react组件形式的字符串,wrapModule后面会提到
    return wrapperModule(defaultImports, Object.values(codes).join('\n'), html);
};

 

上面大概给出了简单的代码示例,里面涉及了几个函数在这里说明一下

首先是md对象,这里用到了markdown-it,如下

const hljs = require('highlight.js');
const mdIt = require('markdown-it');
const md = mdIt({
    linkify: true,
    typographer: true,
    xhtmlOut: true,
}).enable([
    'smartquotes', 'image', 'link',
]).set({
    highlight(content, languageHint) {
        let highlightedContent;
        hljs.configure({
            useBR: true,
            tabReplace: '    ',
        });
        if (languageHint && hljs.getLanguage(languageHint)) {
            highlightedContent = hljs.highlight(languageHint, content).value;
        }
        if (!highlightedContent) {
            highlightedContent = hljs.highlightAuto(content).value;
        }
        // 把代码中的{}转{'{'}、{'}'}
        highlightedContent = highlightedContent.replace(/[{}]/g, match => `{'${match}'}`);
        return hljs.fixMarkup(highlightedContent);
    },
});

 

然后是wrapperDemomatchComponentName方法,如下

// 将代码片段再包装一下,效果可看下图
const wrapperDemo = (component, code, id) => {
    code = md.render('```jsx\n' + code + '```\n');
    return (
        `<div className={"demo-content " + (this.state['show' + '${id}'] ? "expland" : "")}>
            <div className="demo-example">
                <h4>示例</h4>
                <${component} />
            </div>
            <div className="demo-code-wrap">
                <div className="demo-code">
                    ${code}
                </div>
                <div className="demo-code-btn" onClick={this.onToggleCode.bind(this, '${id}')}>
                    <i/>
                </div>
            </div>
        </div>`
    );
};
// 匹配到组件名称
const matchComponentName = (code) => {
    const reg = /\s*class\s*(\w+)\s*extends/g;
    const arr = reg.exec(code);
    return arr[1];
};

test111.png

 

最后我们来看看wrapperModule,其实就是拼接成react组件字符串

const wrapperModule = (imports, js, html) => {
    const moduleText = `
    ${imports}
    ${js}
    export default class MarkdownReactComponent extends React.Component {
        constructor(props){
            super(props);
            this.state = {};
        }
        onToggleCode(id){
            const key = 'show' + id;
            this.setState({
                [key]: !this.state[key]
            });
        }
        render(){
            return (
                <div className="doc">
                    ${html}
                </div>
            );
        }
    };`;
    return moduleText;
};

 

ok,到此为止,一个简单的md转react可运行的demo就算完成了,不过还有很多地方需要完善和考虑的。最后,因为是以webpack-loader的形式使用的,所以在webpack配置里需要加上

{
    test: /\.md$/,
    use: [
        {
            loader: 'babel-loader',
            options: {
                presets: ['env', 'stage-2', 'react'],
            },
        },
        {
            loader: path.resolve('./md-loader.js'),
        },
    ],
}