OverRainbow

MDX写作体验改进

☕️ 7 min read

背景

本博客在日常写作中遇到一些可读性和视觉传达方面的问题,比如

  • 没有目录 Toc

  • 缺乏代码行高亮 LineNumbersHighlight。代码缺乏表现力、重点不突出。

本文就对此进行学习,有功能就用,没有就造轮子。

Toc

此前,我在每次落笔前会先写一个所谓的“你将从本文了解到”的一节来作为提纲,避免离题。(事实证明确实有帮助) 但是这个手工的目录和具备跳转能力的目录还是差很多。

本博客工程中并没有组件实现这个功能。但好在 mdx 相关插件(mdxTableOfContents)提供了 toc 的数据。我们可以自己渲染一个 toc,问题就变成了写一个目录组件。

Toc 数据来源及其数据结构

可以从src/components/BlogPostLayout.tsx中发现 react-navi 路由提供了视图数据,其中 tableOfContents 就是 mdx 插件提供的获取 Toc 数据体的 function。

function BlogPostLayout({blogRoot}: BlogPostLayoutProps) {
    let {title, data, url} = useCurrentRoute();
    let {connect, content, head} = useView()!;
    // let {MDXComponent, readingTime} = content;
    let {MDXComponent, readingTime, tableOfContents} = content;    const disqusShortname = 'miaocode';
    const disqusConfig: any = {
        url: 'https://mzvast.github.io' + url.pathname,
        identifier: data.slug,
        title: title
    };

其返还的数据结构TableOfContentsData如下。

type TocItem = {
    id: string;
    level: number;
    title: string;
    children: TocItem[];
};

type TableOfContentsData:TocItem[]

插件的部分代码我们可以看一下,值得到这个插件只用到 level2 和 3,再深的层次就不解析了。如果有需要可以通过传参的方式修改层级(本博客并不需要)。

function getInfo(    root,    {minTableOfContentsLevel = 2, maxTableOfContentsLevel = 3} = {}) {    let info = {
        hasFrontMatterExport: false,
        hasTableOfContentsExport: false,
        tableOfContents: []
    };

    // ...

    return info;
}

最后有了数据之后的组件编写问题就简单了。样式上复用 blockquote 的样式,并稍作调整,完成之后就是现在文章头部的 Toc 惹。

代码块高亮

工程中@mdx-js/react 是默认支持该功能的。具体看下面分析。

code block highlight 语法

  • 代码块头部声明

    ```js{1,2,3–6}

  • 文中声明

    highlight-line:高亮当前行
    
    highlight-next-line:高亮下一行
    
    highlight-start:连续高亮开始,和 hightlight-end 成对使用。
    
    highlight-range{1, 4-6}:指定行号高亮。
    

mdx 中的具体实现

本 blog 工程基于react-scripts-mdx,它依赖了mdx-loader,这个 loader 的入口代码如下

const {getOptions} = require('loader-utils');
const readingTime = require('reading-time');
const emoji = require('remark-emoji');
const images = require('remark-images');
const textr = require('remark-textr');
const slug = require('remark-slug');
const mdx = require('@mdx-js/mdx');
const mdxTableOfContents = require('mdx-table-of-contents');
const mdxExportJSONByDefault = require('mdx-constant');
const grayMatter = require('gray-matter');
const typography = require('./typography');
const rehypePrism = require('./prism');
module.exports = async function (source) {
    let result;
    const {data, content: mdxContent} = grayMatter(source);
    const callback = this.async();
    const options = Object.assign(
        {
            remarkPlugins: [
                slug,
                images,
                emoji,
                [textr, {plugins: [typography]}]
            ],
            rehypePlugins: [rehypePrism],            compilers: [
                mdxTableOfContents,
                mdxExportJSONByDefault('frontMatter', data)
            ]
        },
        getOptions(this),
        {filepath: this.resourcePath}
    );

    try {
        result = await mdx(mdxContent, options);
    } catch (err) {
        return callback(err);
    }

    const estimatedReadingTime = readingTime(source);

    let code = `
import React from 'react'
import { mdx } from '@mdx-js/react'
export const readingTime = ${JSON.stringify(estimatedReadingTime)}
${result}
`;

    return callback(null, code);
};

其中有个 rehypePrism,我们对此比较感兴趣,

其目录结构如下

.
├── getCodeBlockOptions.js
├── highlightCode.js
├── highlightLines.js
├── index.js
└── loadPrismLanguage.js

让我们看看代码

index.js

顶部有一段注释,表明这段代码跟gatsby-remark-prismjs有某种关联。

这段代码做的事情是结算代码块头部的高亮信息

.
├── getCodeBlockOptions.js
├── highlightCode.js
├── highlightLines.js
├── index.js└── loadPrismLanguage.js
/*
Code used under license from mapbox and Gatsby
https://github.com/mapbox/rehype-prism
https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-prismjs/src/*/

const visit = require('unist-util-visit');
const nodeToString = require('hast-util-to-string');
const getCodeBlockOptions = require('./getCodeBlockOptions');
const highlightCode = require(`./highlightCode`);

const defaultAliases = {
    js: 'jsx',
    html: 'markup'
};

module.exports = (options) => {
    options = options || {};

    return (tree) => {
        visit(tree, 'element', visitor);    };

    function visitor(node, index, parent) {
        if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') {            return;
        }
        // 只处理pre/code标签的直接孩子
        let fenceString;
        const className = node.properties.className || [];
        for (const classListItem of className) {
            if (classListItem.slice(0, 9) === 'language-') {
                fenceString = classListItem.slice(9);
            }
        }
        const {            language,            normalizedLanguage,            highlightedLineNumbers = []        } = getCodeBlockOptions(fenceString, options.aliases || defaultAliases);        // 解析代码块头部的{1-10,5,6},变成行号数组
        if (language === null) {
            return;
        }

        let code = nodeToString(node);
        try {
            node.properties.className = (
                parent.properties.className || []
            ).concat('language-' + normalizedLanguage);

            node.properties['data-language'] = normalizedLanguage;
            node.properties[
                'data-highlighted-line-numbers'
            ] = highlightedLineNumbers.join(',');

            node.children = [];
            node.properties.dangerouslySetInnerHTML = {                __html: highlightCode(language, code, highlightedLineNumbers)            };            // 将代码块头部的行号传入高亮处理函数,生产出需要的html(它还会处理code中的注释形式的高亮)        } catch (err) {
            if (/Unknown language/.test(err.message)) {
                return;
            }
            throw err;
        }
    }
};

highlightCode.js

这个文件的作用主要就是针对语言进行grammer高亮然后将处理过的 code 用highlightLines函数标记 lineNumber 高亮

.
├── getCodeBlockOptions.js
├── highlightCode.js├── highlightLines.js
├── index.js
└── loadPrismLanguage.js
/*
Code used under license from Gatsby
https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-prismjs/src/
*/

const Prism = require(`prismjs`);
const _ = require(`lodash`);

const loadPrismLanguage = require(`./loadPrismLanguage`);
const highlightLines = require(`./highlightLines`);

module.exports = (language, code, lineNumbersHighlight = []) => {
    // (Try to) load languages on demand.
    if (!Prism.languages[language]) {
        try {
            loadPrismLanguage(language);
        } catch (e) {
            // Language wasn't loaded so let's bail.
            if (language === `none`) {
                return code; // Don't escape if set to none.
            } else {
                return _.escape(code);
            }
        }
    }

    const grammar = Prism.languages[language];

    const highlightedCode = Prism.highlight(code, grammar, language);
    const codeSplits = highlightLines(highlightedCode, lineNumbersHighlight);
    let finalCode = ``;

    const lastIdx = codeSplits.length - 1;
    // Don't add back the new line character after highlighted lines
    // as they need to be display: block and full-width.
    codeSplits.forEach((split, idx) => {
        split.highlight
            ? (finalCode += split.code)
            : (finalCode += `${split.code}${idx == lastIdx ? `` : `\n`}`);
    });

    return finalCode;
};

highlightLines.js

这段代码只做一件事就是高亮行

.
├── getCodeBlockOptions.js
├── highlightCode.js
├── highlightLines.js├── index.js
└── loadPrismLanguage.js
/*
Code used under license from Gatsby
https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-prismjs/src/
*/

const rangeParser = require(`parse-numeric-range`);

/**
 * As code has already been prism-highlighted at this point,
 * a JSX opening comment:
 *     {/*
 * would look like this:
 *     <span class="token punctuation">{</span><span class="token comment">/*
 * And a HTML opening comment:
 *     <!--
 * would look like this:
 *     &lt;!--
 */
const HIGHLIGHTED_JSX_COMMENT_START = `<span class="token punctuation">\\{<\\/span><span class="token comment">\\/\\*`;
const HIGHLIGHTED_JSX_COMMENT_END = `\\*\\/<\\/span><span class="token punctuation">\\}</span>`;
const HIGHLIGHTED_HTML_COMMENT_START = `&lt;!--`;

const PRISMJS_COMMENT_OPENING_SPAN_TAG = `(<span\\sclass="token\\scomment">)?`;
const PRISMJS_COMMENT_CLOSING_SPAN_TAG = `(<\\/span>)?`;

const COMMENT_START = new RegExp(
    `(#|\\/\\/|\\{\\/\\*|\\/\\*+|${HIGHLIGHTED_HTML_COMMENT_START})`
);

const createDirectiveRegExp = (featureSelector) =>
    new RegExp(
        `${featureSelector}-(next-line|line|start|end|range)({([^}]+)})?`
    );

const COMMENT_END = new RegExp(`(-->|\\*\\/\\}|\\*\\/)?`);
const DIRECTIVE = createDirectiveRegExp(`(highlight|hide)`);
const HIGHLIGHT_DIRECTIVE = createDirectiveRegExp(`highlight`);

const END_DIRECTIVE = {
    highlight: /highlight-end/,
    hide: /hide-end/
};

const PLAIN_TEXT_WITH_LF_TEST = /<span class="token plain-text">[^<]*\n[^<]*<\/span>/g;

const stripComment = (line) =>
    /**
     * This regexp does the following:
     * 1. Match a comment start, along with the accompanying PrismJS opening comment span tag;
     * 2. Match one of the directives;
     * 3. Match a comment end, along with the accompanying PrismJS closing span tag.
     */
    line.replace(
        new RegExp(
            `\\s*(${HIGHLIGHTED_JSX_COMMENT_START}|${PRISMJS_COMMENT_OPENING_SPAN_TAG}${COMMENT_START.source})\\s*${DIRECTIVE.source}\\s*(${HIGHLIGHTED_JSX_COMMENT_END}|${COMMENT_END.source}${PRISMJS_COMMENT_CLOSING_SPAN_TAG})`
        ),
        ``
    );

const highlightWrap = (line) =>
    [`<span class="highlighted-line">`, line, `</span>`].join(``);


const parseLine = (line, code, index, actions) => {
    const [, feature, directive, directiveRange] = line.match(DIRECTIVE);
    const flagSource = {
        feature,
        index,
        directive: `${feature}-${directive}${directiveRange}`
    };
    switch (directive) {
        case `next-line`:
            actions.flag(feature, index + 1, flagSource);
            actions.hide(index);
            break;
            // 跨行高亮的逻辑        case `start`: {            // find the next `${feature}-end` directive, starting from next line            const endIndex = code.findIndex(                (line, idx) => idx > index && END_DIRECTIVE[feature].test(line)            );            const end = endIndex === -1 ? code.length : endIndex;            actions.hide(index);            actions.hide(end);            for (let i = index + 1; i < end; i++) {                actions.flag(feature, i, flagSource);            }            break;        }        case `line`:
            actions.flag(feature, index, flagSource);
            actions.stripComment(index);
            break;
        case `range`:
            actions.hide(index);

            if (directiveRange) {
                const strippedDirectiveRange = directiveRange.slice(1, -1);
                const range = rangeParser.parse(strippedDirectiveRange);
                if (range.length > 0) {
                    range.forEach((relativeIndex) => {
                        actions.flag(
                            feature,
                            index + relativeIndex,
                            flagSource
                        );
                    });
                    break;
                }
            }

            console.warn(`Invalid match specified: "${line.trim()}"`);
            break;
    }
};

module.exports = function highlightLineRange(code, highlights = []) {
    if (highlights.length > 0 || HIGHLIGHT_DIRECTIVE.test(code)) {        // HACK split plain-text spans with line separators inside into multiple plain-text spans
        // separatered by line separator - this fixes line highlighting behaviour for jsx
        code = code.replace(PLAIN_TEXT_WITH_LF_TEST, (match) =>
            match.replace(/\n/g, `</span>\n<span class="token plain-text">`)
        );
    }

    const split = code.split(`\n`);
    const lines = split.map((code) => {
        return {code, highlight: false, hide: false, flagSources: []};
    });

    const actions = {
        flag: (feature, line, flagSource) => {
            if (line >= 0 && line < lines.length) {
                const lineMeta = lines[line];
                lineMeta[feature] = true;
                lineMeta.flagSources.push(flagSource);
            }
        },
        hide: (line) => actions.flag(`hide`, line),
        highlight: (line) => actions.flag(`highlight`, line),
        stripComment: (line) => {
            lines[line].code = stripComment(lines[line].code);
        }
    };

    const transform = (lines) =>
        lines
            .filter(({hide, highlight, flagSources}, index) => {
                if (hide && highlight) {
                    const formattedSources = flagSources
                        .map(
                            ({feature, index, directive}) =>
                                `  - Line ${
                                    index + 1
                                }: ${feature} ("${directive}")`
                        )
                        .join(`\n`);
                    throw Error(
                        `Line ${
                            index + 1
                        } has been marked as both hidden and highlighted.\n${formattedSources}`
                    );
                }

                return !hide;
            })
            .map((line) => {
                if (line.highlight) {
                    line.code = highlightWrap(line.code);
                }
                return line;
            });

    // If a highlight range is passed with the language declaration, e.g.
    // ``jsx{1, 3-4}
    // we only use that and do not try to parse highlight directives
    if (highlights.length > 0) {
        highlights.forEach((lineNumber) => {
            actions.highlight(lineNumber - 1);// 高亮块声明        });
        return transform(lines);
    }

    for (let i = 0; i < split.length; i++) {
        const line = split[i];
        if (DIRECTIVE.test(line)) {
            parseLine(line, split, i, actions);// 高亮指令        }
    }

    return transform(lines);
};