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:
* <!--
*/
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 = `<!--`;
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);
};