😎处理FrontMatter信息--同时渲染组件及其代码上篇文章分享了如何将 makrdown 中的代码块变成 Rea
前言
上篇文章分享了如何将 makrdown 中的代码块变成 React 组件,并且嵌入 markdown 中。随之而来的就有一个问题,如果我有时候想展示代码,有时候只想显示组件,有时候即想显示代码又想显示组件,怎么办?
我们捋一下需求:
- 第一个需求,对应的场景:不是每个代码都能变成组件,或者都想变成组件
- 第二个需求,对应的场景:我想在页面展示 markdown 做不到效果,但又和例子展示无关
- 第三个需求,对应的场景:想让用户同时看见代码和组件,就像一般的组件库文档一样
其实,无论对于代码块的展示,还是组件的渲染,我们在前面的文章,都已经实现,技术上都可以满足。无非就是将 rehype ast 节点,替换来替换去罢了。
所以这篇的主要目的是将代码整理下,在添加新功能的基础之上依旧有序,支持高拓展,低耦合
思路分析
想要同时满足上面的需求,就需要用户在 markdown 代码块中传递信息来告诉我们,一个代码块是想变成组件,还是依旧保持代码块进行显示。传递信息,可以借助 frontmatter 来做到,frontmatter 之于代码块就相当于 properties 之于html 标签。所以我们可以将代码块统一转成 code 标签,代码块的 frontmatter 转位 code 的 properties,以及代码块的代码可以作为 code 标签的 value 属性(<code value="import Rea..."/>
)
像这样:
```js
---
isEdit: false
---
import React from 'react';
export default function App(){
return <div>app</div>
}
``
转成:
<code
isEdit="false"
code="import React from 'react';
export default function App(){
return <div>app</div>
}"
></code>
整理代码
先整理代码,markdown 转 html 代码放在 vite 插件的代码里有些臃肿创建script/vite/m2hByVite.ts
专门放这块的代码:
import path from "path";
import markdown2html from "../markdown/markdown2html";
import { handleTagName } from "./util";
import { TempPath } from "../config";
import fs from "fs";
const m2hByVite = async (code: string, id: string) => {
const dynamicComponents: Record<string, string> = {
showcode: "@src/components/ShowCode",
};
const htmlFile = await markdown2html(code, {
codeBlock: {
handleCode(index, node) {
if (node.children.length > 0) {
const mdName = path.basename(id);
const filename = handleTagName(
`${mdName.replace(".md", "")}_demo_${index}`
);
const filePath = path.resolve(TempPath, "./" + filename + ".tsx");
fs.writeFileSync(filePath, node.children[0].value, "utf-8");
dynamicComponents[filename] = filePath;
return {
tagName: filename,
children: [],
};
}
return null;
},
},
codeTag: {
handleCode(index, node) {
if (
node.tagName == "code" &&
node.properties.src &&
node.properties.src.startsWith(".")
) {
console.log(node);
const mdName = path.basename(id);
const filename = handleTagName(
`${mdName.replace(".md", "")}_demo_tag_${index}`
);
const compoPath = path.resolve(path.dirname(id), node.properties.src);
if (!fs.existsSync(compoPath)) return null;
dynamicComponents[filename] = compoPath;
return {
tagName: filename,
};
}
return null;
},
},
});
return {
html: htmlFile.value,
dynamicComponents,
};
};
export default m2hByVite;
创建 script/vite/util.ts
存放工具代码:
const firstCharUpperCase = (str: string) => {
return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase();
};
const handleTagName = (tagName: string) => {
return tagName.toLowerCase();
};
const switchTagName = (tagNames: string[], code: string) => {
code = code.replace(
new RegExp(`${tagNames.map((item) => item.toLowerCase()).join("|")}`, "g"),
(match: string) => {
return firstCharUpperCase(match);
}
);
return code;
};
export { firstCharUpperCase, handleTagName, switchTagName };
整理后的 vite 插件script/vite/VitePluginMarkdown.ts
代码:
async transform(code, id) {
if (id.endsWith(".md")) {
// 调用markdown转html
const { html, dynamicComponents } = await m2hByVite(code, id);
//其他内容保持不变
...
return {
code: esbuildRes.code,
};
}
},
开始
先看看 gray-matter 的用法。安装依赖:
npm i gray-matter
gray-matter
基本用法:
import grayMatter from "gray-matter";
const code = `
---
isEdit: false
---
import React from 'react';
export default ()=><div>App</div>;
`
const {content, data} = grayMatter(code);
/**
data: {isEdit: false}
*/
/**
content: import React from 'react';
export default ()=><div>App</div>;
*/
可以看到 gray-matter
的作用是将 frontmatter
提取出来,转成对象,并且返回剥离 frontmatter
的代码content
下面用 gray-matter
处理代码块:
// 将对象中,值为布尔类型的,转成字符串类型
const handleFrontMatterData = (
data: Record<string, string | number | boolean>
) => {
return Object.entries(data).reduce((res, [key, value]) => {
let _value = value;
if (typeof _value === "boolean") {
_value = Boolean(_value) + "";
}
res[key] = _value;
return res;
}, {} as Record<string, number | string>);
};
...
codeBlock: {
handleCode(index, node) {
if (node.children.length > 0) {
const { content, data } = grayMatter(node.children[0].value);
const _data = handleFrontMatterData(data);
return {
tagName: "code",
properties: {
_value: content,
..._data,
},
children: [],
};
}
return null;
},
}
这是传给 rehype 插件的配置项,handleCode 中的逻辑:
- 先检测出代码块,然后将代码交给
gray-matter
处理 - 然后返回
{ properties: { _value: content, ..._data } }
,目的是将代码作为 code 标签的参数往下传递,frontmatter
数据也作为 code 的参数。 children:[]
:表示清空 code 的 children- handleFrontMatterData 将 boolean 类型的值变成字符串,为了之后之后的 rehype-stringify 处理
上面完成了代码块到 code 标签的转化,下面就要处理 code 标签了。
处理 `code` 标签
有两种 code 标签:
- 有 src 属性的
code
标签,表示引用其他 React组件的代码 - 有 _value 属性的 code 标签,表示是由代码转化过来的
...
codeTag: {
handleCode(index, node) {
if (
node.tagName == "code" &&
node.properties.src &&
node.properties.src.startsWith(".")
) {
const mdName = path.basename(id).replace(".md", "");
const filename = handleTagName(`${mdName}_demo_tag_${index}`);
const compoPath = path.resolve(path.dirname(id), node.properties.src);
if (!fs.existsSync(compoPath)) return null;
dynamicComponents[filename] = compoPath;
return {
tagName: filename,
};
} else if (
node.tagName === "code" &&
!node.properties.src &&
node.properties._value
) {
const { _value, ...other } = node.properties;
const mdName = path.basename(id).replace(".md", "");
let tagName = handleTagName(`${mdName}_demo_${index}`);
const properties = { ...other } as Record<string, string>;
const filePath = path.resolve(TempPath, "./" + tagName + ".tsx");
fs.writeFileSync(filePath, _value, "utf-8");
dynamicComponents[tagName] = filePath;
return {
tagName,
properties,
};
}
return null;
},
...
handleCode 中主要有两个逻辑,第一个逻辑处理有 src 的 code标签,第二个逻辑是处理有 _value 的 code 标签。
其实第一个逻辑也可以转成第二个逻辑。将 code 中 src 对应的组件代码,读取出来,放到 code 的 _value 属性中:
const handleCodeProperties = (properties: Record<string, string>) => {
return Object.entries(properties).reduce((res, [key, value]) => {
key = key
.split("-")
.map((item, index) => (index !== 0 ? firstCharUpperCase(item) : item))
.join("");
res[key] = value;
return res;
}, {} as Record<string, string>);
};
......
handleCode(index, node) {
if (node.tagName == "code" && node.properties.src && node.properties.src.startsWith(".")) {
const { src, ...otherProperties } = node.properties;
const compoPath = path.resolve(path.dirname(id), src);
if (!fs.existsSync(compoPath)) return null;
const _value = fs.readFileSync(compoPath, "utf-8");
return {
tagName: "code",
properties: { _value, ...handleCodeProperties(otherProperties) },
};
}
return null;
},
代码很简单😉
有个点需要注意,这里虽然支持通过 code 的 properties 模拟代码块的 frontmatter 传递信息,但是直接在 code 里写入的 field 会全部变成小写,例如isRender
-> isrender
,所以需要专门做处理。
限制传入 code 的 field 必须是连字符格式,如is-render
,在上面的代码就可以通过handleCodeProperties
函数,将连字格式转成驼峰格式了,如is-render
-> isRender。这样就可以和代码块的 frontmatter 完全统一了。
代码再整理
到现在有两段固定的逻辑,一个是将代码块 block 转成 code 标签,第二个是将有 src 属性的 code 转成有 _value 属性的 code。这两个逻辑可以固定在 markdown 的处理流程中,而不是在 vite 插件中传进去。创建 script/markdown/plugin/Block2Code.ts
:
import switchCode from "./switchCode";
import grayMatter from "gray-matter";
const handleFrontMatterData = (data: Record<string, string | number | boolean>) => {
return Object.entries(data).reduce((res, [key, value]) => {
let _value = value;
if (typeof _value === "boolean") {
_value = Boolean(_value) + "";
}
res[key] = _value;
return res;
}, {} as Record<string, number | string>);
};
const Block2Code = () => {
return switchCode({
handleCode(index, node) {
if (node.children.length > 0) {
const { content, data } = grayMatter(node.children[0].value);
const _data = handleFrontMatterData(data);
return {
tagName: "code",
properties: {
_value: content,
..._data,
},
children: [],
};
}
return null;
},
});
};
export default Block2Code;
创建script/markdown/plugin/SrcCode2ValueCode.ts
:
import path from "path";
import switchCode from "./switchCode";
import fs from "fs";
const SrcCode2ValueCode = (id: string) => {
return switchCode({
handleCode(index, node) {
if (node.tagName == "code" && node.properties.src && node.properties.src.startsWith(".")) {
const { src, ...otherProperties } = node.properties;
const compoPath = path.resolve(path.dirname(id), src);
if (!fs.existsSync(compoPath)) return null;
const _value = fs.readFileSync(compoPath, "utf-8");
return {
tagName: "code",
properties: { _value, ...otherProperties },
};
}
return null;
},
});
};
export default SrcCode2ValueCode;
上面两个处理过程,都借用了之前创建的switchCode
函数,这个函数作用是用配置项的 handleCode 来处理遍历到的 code,这是代码:
import { visit } from "unist-util-visit";
export type ElementType = {
tagName: string;
properties: Record<string, string>;
children?: (ElementType & { value: string })[];
};
export type SwitchCodeOptionsType = Partial<ElementType> & {
handleCode?: (index: number, node: ElementType & { children: ElementType[] }) => Partial<ElementType> | null;
};
const switchCode = (options: SwitchCodeOptionsType) => {
let index = 0;
return () => {
const {
tagName = "code",
properties = {},
children = [],
handleCode = () => ({ tagName: "code", properties: {} }),
} = options;
return (tree: import("unist").Node) => {
visit(tree, "element", (node: any) => {
if (node.tagName == "code") {
const res = handleCode(index++, node);
const assignedOptions = {
tagName,
properties,
children,
...(res || {}),
};
node.tagName = assignedOptions.tagName;
node.properties = {
...node.properties,
...assignedOptions.properties,
};
node.children = assignedOptions.children || [];
}
});
};
};
};
export default switchCode;
两个插件创建好了,修改下 script/markdown/plugin/markdown2html.ts
:
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import switchCode, { SwitchCodeOptionsType } from "./plugin/switchCode";
import Block2Code from "./plugin/Block2Code";
import SrcCode2ValueCode from "./plugin/SrcCode2ValueCode";
const markdown2html = (
id: string,
code: string,
options: {
// 处理code Block配置不再需要了
// codeBlock?: SwitchCodeOptionsType;
codeTag?: SwitchCodeOptionsType;
}
) => {
let tempProcess = remark()
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(Block2Code()) // 处理code block
.use(SrcCode2ValueCode(id)); // 将src code转成_value code
Object.entries(options).forEach(([key, value]) => {
value && (tempProcess = tempProcess.use(switchCode(value)));
});
return tempProcess
.use(rehypeStringify, {
allowDangerousHtml: true,
})
.process(code);
};
export default markdown2html;
vite 插件的代码可以删掉一些了,这是整理之后的代码:
import fs from "fs";
import path from "path";
import { TempPath } from "../config";
import markdown2html from "../markdown/markdown2html";
import { ElementType } from "../markdown/plugin/switchCode";
import { handleTagName } from "./util";
type handleCodeParamsType = {
id: string;
index: number;
node: ElementType & {
children: ElementType[];
};
dynamicComponents: Record<string, string>;
};
// 处理带有_value的code标签
const handleValueCode = (data: handleCodeParamsType) => {
const { id, index, node, dynamicComponents } = data;
const { _value, ...other } = node.properties;
const mdName = path.basename(id).replace(".md", "");
let tagName = handleTagName(`${mdName}_demo_${index}`);
const properties = { ...other } as Record<string, string>;
const filePath = path.resolve(TempPath, "./" + tagName + ".tsx");
fs.writeFileSync(filePath, _value, "utf-8");
dynamicComponents[tagName] = filePath;
return {
tagName,
properties,
};
};
const m2hByVite = async (code: string, id: string) => {
const dynamicComponents: Record<string, string> = {
showcode: "@src/components/ShowCode",
};
const htmlFile = await markdown2html(id, code, {
codeTag: {
handleCode(index, node) {
// 过滤带有_value的code标签
if (node.tagName === "code" && !node.properties.src && node.properties._value) {
return handleValueCode({
id,
index,
node,
dynamicComponents,
});
}
return null;
},
},
});
return {
html: htmlFile.value,
dynamicComponents,
};
};
export default m2hByVite;
这是现在的目录结构:
是不是很简洁了
这是完整项目代码:GitHub - zenoskongfu/markdown-doc
好了准备工作做好了,下面开始处理代码块中传来的 fronmatter
数据了。
处理 Frontmatter 数据
假设在 fronmatter 中传入`isRender: false | true`,来表示显示 React 组件还是 React 代码,像这样:```ts
---
isRender: false
---
import React from 'react';
const Demo1 = ()=>{
return <div>Demo1</div>
}
export default Demo1
``
上面提到了,fronmatter
中的数据会被放在 code 的 properties
对象中,我们直接从里面拿就好了。处理 _value code 的代码:
const handleValueCode = (data: handleCodeParamsType) => {
const { id, index, node, dynamicComponents } = data;
const { isRender = "true", _value, ...other } = node.properties;
const mdName = path.basename(id).replace(".md", "");
let tagName = handleTagName(`${mdName}_demo_${index}`);
const properties = { ...other } as Record<string, string>;
if (isRender == "true") {
const filePath = path.resolve(TempPath, "./" + tagName + ".tsx");
fs.writeFileSync(filePath, _value, "utf-8");
dynamicComponents[tagName] = filePath;
} else {
tagName = "showcode";
properties.code = _value.replace('"', "'");
}
return {
tagName,
properties,
};
};
如果 isRender 为 true,就生成临时文件然后导入进来,为了渲染 React 组件。如果 isRender 为 false,就直接将 tagName 设为showcode
,并且设置 showcode 组件的 props.code
。
ShowCode 是一个自定义的,可以显示高亮代码的组件,前两篇文章提到过
我们看看效果,这是完整的 md 文件:
# Foo
这是一个测试的markdown文件
## 这是第一个代码块
```ts
---
isRender: false
---
import React from 'react';
const Demo1 = ()=>{
return <div>Demo1</div>
}
export default Demo1
``
## 这是第二块代码
```ts
import React from 'react';
const Demo2 = ()=>{
return <div>Demo2</div>
}
export default Demo2
``
## 这是第三个代码块
<code src="./FooCompo.tsx" ></code>
## 这是第四个代码块
<code src="./FooCompo.tsx" is-render="false" ></code>
浏览器效果:
完美。
要是用户想同时显示代码块和组件呢,就像一般的组件库文档那样
同时显示组件和代码
我们将参数改改,将 `isRender` 改成 `type`,`type` 有三种值:`'componnet'|'code'|'both'`,分别表示只渲染组件、只显示 code、两个都显示首先,这里需要新增一个组件,处理这种需求。
components/ComponentAndCode.ts
:
import ShowCode from "../ShowCode";
import { useState } from "react";
import "./index.scss";
type Props = {
children: React.ReactElement;
code: string;
};
const ComponentAndCode = (props: Props) => {
const [isShow, setShow] = useState(false);
return (
<div className="compo-and-code">
<div className="compo-container">{props.children}</div>
<div className="tool-bar" style={{ borderBottomWidth: isShow ? 1 : 0 }}>
<div onClick={() => setShow(!isShow)}>
<img src="https://gw.alipayobjects.com/zos/antfincdn/Z5c7kzvi30/expand.svg" />
</div>
</div>
<div className="code-container" style={{ maxHeight: isShow ? "200px" : 0 }}>
<ShowCode code={props.code} />
</div>
</div>
);
};
export default ComponentAndCode;
代码很简单,同时接受两个参数,一个 children,表示需要显示的 React 组件;另一个是 code,表示需要显示的组件代码。然后样式设计参考了 antd design
下面修改 vite 插件代码:
const handleValueCode = (data: handleCodeParamsType) => {
const { id, index, node, dynamicComponents } = data;
const { type = "both", _value, ...other } = node.properties;
const mdName = path.basename(id).replace(".md", "");
const fileName = handleTagName(`${mdName}_demo_${index}`);
// initial value
let tagName = fileName;
const properties = { ...other } as Record<string, string>;
let children = [];
switch (type) {
case "component":
return handleCompoRender();
case "code":
return handleCodeRender();
case "both":
default:
return handleBothRender();
}
// 渲染组件和代码
function handleBothRender() {
const filePath = path.resolve(TempPath, "./" + fileName + ".tsx");
fs.writeFileSync(filePath, _value, "utf-8");
dynamicComponents[fileName] = filePath;
tagName = "component_and_code";
dynamicComponents[tagName] = "@src/components/ComponentAndCode";
properties.code = _value.replace(/"/g, "'");
children = [
{
type: "element",
tagName: fileName,
},
];
return {
tagName,
properties,
children,
};
}
// 渲染组件
function handleCompoRender() {
const filePath = path.resolve(TempPath, "./" + fileName + ".tsx");
fs.writeFileSync(filePath, _value, "utf-8");
dynamicComponents[fileName] = filePath;
return {
tagName: fileName,
properties,
};
}
// 渲染代码
function handleCodeRender() {
tagName = "showcode";
properties.code = _value.replace(/"/g, "'");
return {
tagName,
properties,
};
}
};
代码很简单,就不解释了
修改 md 文件测试一下:
# Foo
这是一个测试的markdown文件
### 这是第一个代码块
```ts
---
type: component
---
import React from 'react';
const Demo1 = ()=>{
return <div>Demo1</div>
}
export default Demo1
``
### 这是第二块代码
```ts
import React from 'react';
const Demo2 = ()=>{
return <div>Demo2</div>
}
export default Demo2
``
### 这是第三个代码块
<code src="./FooCompo.tsx" type="code"></code>
### 这是第四个代码块
<code src="./FooCompo.tsx" ></code>
如果提供 type
,就按照 type
的值来显示,如果没有提供 type
,就默认值为 both
,即既能显示组件也能显示代码
效果:
效果很完美,都按照 md 文件中的设置显示了
总结
这篇文章分享如何通过 frontmatter 数据来传递信息,以及实现了文档中代码块展现的三种形式:React 组件,代码,React 组件和代码。并在这过程中整理了插件的代码,依旧保持代码的可读性,高内聚,低耦合。下篇文章分享:实现示例组件的在线可编辑
转载自:https://juejin.cn/post/7419876378038960167