Skip to main content

将Asciinema集成到Docusaurus项目中

· 7 min read
orange
programmer on jvm platform

Markdown文档中, 涉及到一些终端操作时, 需要一种方式来展示这些操作.

常见的解决方案

虽然Markdown内置的codeblock语法方便, 但在某些情况下, 它也有不足之处.
比如, 当终端操作步骤过长, 包含大量输入输出内容时, 会导致文档占用过多空间, 影响阅读体验.
另一种方法是将终端操作录制成视频并通过链接引用, 这样视频可以嵌入页面中展示操作过程.
然而, 视频文件通常较大, 占用较多空间, 而终端操作本质上只是字符串.

工具介绍

Asciinema

Asciinema是一个开源的终端录制工具, 可以记录终端操作并生成可播放的文件.
这个文件仅包含文本信息, 不占用太多空间, 非常适合展示终端操作演示.

要生成终端操作演示, 需要安装Asciinema的命令行工具, 并使用该工具进行录制.
详细的安装和使用教程可以参考getting-started页面.

Docusaurus

Docusaurus是一个开源的静态网站生成器, 基于 React 实现, 允许使用Markdown编写文档, 并用 React渲染.
Docusaurus中, 支持使用MDX扩展.

MDX

MDXMarkdown的扩展, 允许我们在Markdown文档中使用JavaScript 代码来实现更丰富的文档内容渲染.

功能设计

Docusaurus支持MDX语法, 因此我们可以在文档中引入JavaScript代码来渲染页面.
虽然Asciinema提供了用于页面渲染的JavaScript库, 但遗憾的是目前只有基本的JavaScript实现.
由于Docusaurus是基于React实现的, 因此我们需要将Asciinema提供的JavaScript库封装为Docusaurus支持的React 组件.

封装React组件

如果我们需要在Docusaurus中集成Asciinema, 那么需要完成以下步骤:

  • Asciinema提供的JavaScript库封装为Docusaurus支持的React组件.

完成上述实现后, 我们就可以在Markdown文档中引入Asciinema动画文件, 并将其渲染到页面中.

例如:

import AsciinemaPlayer from '@site/src/components/asciinema/react';

<AsciinemaPlayer src="/blog/2024-06-28-demo.cast" />

这样, 最终的渲染效果应该是:

扩展Markdown解析器并实现link语法的渲染

在上一个步骤中, 最终的Markdown文件中需要编写JavaScript代码来完成渲染, 但对于文档编写者来说会面临以下问题:

  • 如果文档项目由多人编写, 语法问题的概率会增加.
  • Markdown迁移难度加大, 因为JS代码只是实现某个功能的一种方式, 而不应该在Markdown文件中显式依赖解决方案空间的细节.

为了解决以上问题, 我们需要优化使用方式, 降低使用者的难度, 同时屏蔽底层的细节.

最终的使用者可能更希望在Markdown中使用link语法来引入Asciinema动画文件, 而在底层我们需要通过Docusaurus 提供的功能进行扩展渲染.

例如, 用户在文档中增加如下内容:

[x](/blog/2024-06-28-demo.cast)

那么, 最终的渲染效果应该是:

上述实现思路主要是扩展DocusaurusMarkdown解析器, 并对其进行扩展, 最终转换为上一个步骤中的React组件.
Docusaurus中, 可以通过扩展remarkrehype来实现对Markdown语法解析的扩展.
这种方法允许我们在Markdown解析为AST后进行修改, 从而实现对Markdown的扩展功能.

详细的文档和插件开发信息, 请参考MDX Plugins.

功能实现

封装Asciinema库为React组件

首先, 将Asciinema添加到项目中:

yarn add asciinema-player

接下来, 我们需要封装成React组件.

src/components/asciinema/react/index.js

// import 'asciinema-player/dist/bundle/asciinema-player.css';
import './asciinema-player.css'; // We hacked the CSS of the asciinema-player located at 'asciinema-player/dist/bundle/asciinema-player.css'.

import {FC, useEffect, useRef, useState} from 'react';
import {useColorMode} from '@docusaurus/theme-common';

type Props = {
src: string;
cols: string;
rows: string;
autoPlay: boolean
preload: boolean;
loop: boolean | number;
startAt: number | string;
speed: number;
idleTimeLimit: number;
theme: string;
poster: string;
fit: string;
fontSize: string;
};

const AsciinemaPlayer: FC<Props> = ({src, ...rest}) => {
const [player, setPlayer] = useState<typeof import ('asciinema-player')>()
useEffect(() => {
import("asciinema-player").then(p => {setPlayer(p)})
}, []) // executed once

const { colorMode } = useColorMode();
const ref = useRef<HTMLDivElement>(null);

useEffect(
() => {
const currentRef = ref.current

const instance = player?.create(src, currentRef, {...rest, theme: colorMode === 'dark' ? 'docusaurus-classic-dark' : 'docusaurus-classic-light'});

return () => {
instance?.dispose()
}
}, [src, rest, colorMode, player] // executed every time the array items change
);

return <div ref={ref}/>;
};

export default AsciinemaPlayer;

src/components/asciinema/react/asciinema-player.css

上面的代码中, 我们将AsciinemaJavaScript库封装为React组件.
利用React提供的Ref功能, 将Asciinema操作的DOM组件与React组件关联起来, 以确保React能够集成该组件.
为了避免重复渲染, 需要确保组件在适当的时候被dispose.
为了适配Docusaurus的主题, 我们需要引入自定义的CSS样式. 在Asciinema提供的基础样式上, 添加对Docusaurus主题的支持.

自定义Markdown语法树解析

首先, 我们需要将下面的依赖安装到项目中:

yarn add rehype-katex remark-math

这两个库用于解析Markdown语法树, 并对语法树内容进行修改, 以实现我们的目的.

现在可以开始编写下面的代码来实现

src/components/asciinema/Markdown/Markdown.ts
import {visit} from 'unist-util-visit';

const plugin = (options) => {
const transformer = async (tree) => {
let importInserted = false;

visit(tree, 'link', (node, index, parent) => {
if (!node.url.endsWith(".cast")) {
return
}

if (!importInserted) {
const importNode = {
type: 'mdxjsEsm',
value: `import AsciinemaPlayer from '@site/src/components/asciinema/react';`,
data: {
estree: {
type: 'Program',
body: [
{
type: 'ImportDeclaration',
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: {type: 'Identifier', name: 'AsciinemaPlayer'}
}
],
source: {type: 'Literal', value: '@site/src/components/asciinema/react'}
}
]
}
}
};

tree.children.unshift(importNode);
importInserted = true;
}

const jsxNode = {
type: 'mdxJsxFlowElement',
name: 'AsciinemaPlayer',
attributes: [
{type: 'mdxJsxAttribute', name: 'src', value: node.url},
{type: 'mdxJsxAttribute', name: 'theme', value: 'docusaurus-classic-light'},
{type: 'mdxJsxAttribute', name: 'rows', value: 30},
{type: 'mdxJsxAttribute', name: 'idleTimeLimit', value: 3},
{type: 'mdxJsxAttribute', name: 'preload', value: true}
],
children: []
};

parent.children.splice(index, 1, jsxNode);
});
};
return transformer;
};

export default plugin;

在上述代码中, 我们对link进行了修改, 并将其转换为JSX语法, 这样可以在Markdown中直接使用Asciinema组件. 除了上述代码, 我们还需要在Docusaurus中进行配置以进行功能集成.

参考以下配置来配置Docusaurus:

docusaurus.config.js
import rehypeKatex from 'rehype-katex';
import asciinema from './src/components/asciinema/Markdown/Markdown';

export default {
presets: [
[
'@docusaurus/preset-classic',
{
docs: {
path: 'docs',
beforeDefaultRemarkPlugins: [asciinema],
rehypePlugins: [rehypeKatex],
},
blog: {
beforeDefaultRemarkPlugins: [asciinema],
rehypePlugins: [rehypeKatex],
}
},
],
],
};

上述配置对Docusaurus中的DocsBlog进行了配置, 并实现了Markdown中的Asciinema组件的解析.

需要注意的一点是, 我们使用beforeDefaultRemarkPlugins而不是remarkPlugins进行配置的主要原因是,
Docusaurus会对Markdown语法树进行修改, 因此我们需要在其修改之前进行配置, 以确保最终的结果正确.

参考