这篇文章仅适用于基于 Remark.js 开发的 Markdown 应用,如 Astro.js、MDX 等等。
从 mdast-util-from-markdown 2.0.0 开始,this.exit() 方法删除了返回值,不再返回 AST 节点,因此在该版本里,我们需要自己使用其他方法获取节点。例如:
ts function exitMyNodeType(token) { const node = this.stack[this.stack.length - 1] as MyNodeType; this.exit(token); // previous code will case error // const node = this.exit(token) as MyNodeType; }
该变更影响 remark 15.0.0 及以上版本,请注意你所使用的框架的依赖版本。
上一篇文章 讲了 Remark 扩展新语法的方法,现在我们就来实践吧。
在这篇文章里,我们将会实现两个有趣的新语法,分别是:
- Spoiler(黑幕):形成对文字的遮挡效果,只有在鼠标悬停时才会显示。
- Admonition(警告框):使用块级元素来强调一段文字,可以有不同的类型、颜色和图标。
Spoiler
语法定义
先来介绍一下 Spoiler 的语法。使用 !!
双叹号包裹行内文本,文本将会具有 Spolier 效果。
!!这是一条 Spoiler 测试文本。这是一条 Spoiler 测试文本。!!
效果如下(鼠标悬停在文本上,以显示文字):这是一条 Spoiler 测试文本。这是一条 Spoiler 测试文本。
文件结构
- remarkSpoiler
- syntax.ts // micromark 扩展
- fromMarkdown.ts // mdast-util-from-markdown 扩展
- index.ts // 插件入口
micromark 扩展
import { markdownLineEnding } from "micromark-util-character";
import { factorySpace } from "micromark-factory-space";
import { codes, types, constants } from "micromark-util-symbol";
import type {
Construct,
Tokenizer,
State,
Extension as MicromarkExtension,
} from "micromark-util-types";
// 定义 spoiler Tokenizer
const tokenizeSpoiler: Tokenizer = function (effects, ok, nok) {
const self = this;
const start: State = function (code) {
effects.enter("spoiler");
return effects.attempt(
marker,
factorySpace(effects, contentStart, types.whitespace),
nok
);
};
const contentStart: State = function (code) {
effects.enter(types.chunkText, {
contentType: constants.contentTypeText,
});
return content;
};
const content: State = function (code) {
return effects.check(
marker,
factorySpace(effects, contentAfter, types.whitespace),
comsumeData
);
};
const comsumeData: State = function (code) {
if (markdownLineEnding(code) || code === codes.eof) {
return nok;
}
effects.consume(code);
return content;
};
const contentAfter: State = function (code) {
effects.exit(types.chunkText);
return effects.attempt(marker, after, nok);
};
const after: State = function (code) {
effects.exit("spoiler");
return ok;
};
return start;
};
// 定义分界符 (!!) 的 Tokenizer
const tokenizeMarker: Tokenizer = function (effects, ok, nok) {
let markerSize = 0;
if (this.previous === codes.exclamationMark) {
return nok;
}
const start: State = function (code) {
effects.enter("spoilerMarker");
return marker;
};
const marker: State = function (code) {
if (code === codes.exclamationMark) {
effects.consume(code);
markerSize++;
return marker;
}
if (markerSize == 2) {
effects.exit("spoilerMarker");
markerSize = 0;
return ok;
}
return nok;
};
return factorySpace(effects, start, types.whitespace);
};
const marker: Construct = {
tokenize: tokenizeMarker,
partial: true,
};
const spoiler: Construct = {
name: "spoiler",
tokenize: tokenizeSpoiler,
};
export const syntax = (): MicromarkExtension => {
return {
text: {
[codes.exclamationMark]: spoiler,
},
};
};
mdast-util-from-markdown 扩展
在 html 上实现 spoiler 效果比较简易,只需要添加一些 css 即可。
import type { Parent } from "mdast";
import type {
Handle,
Extension as FromMarkdownExtension,
} from "mdast-util-from-markdown";
// 定义 spoiler 节点类型
export interface Spoiler extends Parent {
type: "spoiler";
}
// 声明自定义 mdast 类型
declare module "mdast" {
interface StaticPhrasingContentMap {
spoiler: Spoiler;
}
}
export const fromMarkdown = (): FromMarkdownExtension => {
const enterSpoiler: Handle = function (token) {
this.enter<Spoiler>(
{
type: "spoiler",
children: [],
},
token
);
};
const exitSpoiler: Handle = function (token) {
const node = this.exit(token) as Spoiler;
node.data = {
...node.data,
hName: "span",
hProperties: {
className: "bg-current hover:bg-transparent",
},
};
};
return {
enter: {
spoiler: enterSpoiler,
},
exit: {
spoiler: exitSpoiler,
},
};
};
完善插件
这里编写了一个 add
工具函数,用来向对象中某列表属性添加元素,如果属性不存在则创建一个新的列表。
import type { Plugin } from "unified";
import type { Root } from "mdast";
import { syntax } from "./syntax";
import { fromMarkdown } from "./fromMarkdown";
const remarkSpoiler: Plugin<[], Root> = function () {
const data = this.data();
function add(key: string, value: unknown) {
if (Array.isArray(data[key])) {
(data[key] as unknown[]).push(value);
} else {
data[key] = [value];
}
}
add("micromarkExtensions", syntax());
add("fromMarkdownExtensions", fromMarkdown());
};
export default remarkSpoiler;
Admonition
语法定义
使用三个叹号作为前缀,后面紧随 admonition 的类型,类型之后可以跟一个可选的标题,标题和类型之间用空格分隔。
从第二行开始为 admonition 的内容,需要有 4 个空格的缩进。如果下一行的内容仍然以 4 个空格缩进,则该内容块仍然属于 admonition,直到遇到非法缩进。
!!! info 注意
这是一条注意事项。
!!! tip 提示:_标题可以带有格式_
Admonition 可以嵌套使用。
这是一条注意事项。
Admonition 可以嵌套使用。
文件结构
- remarkAdmonition
- syntax.ts // micromark 扩展
- fromMarkdown.ts // mdast-util-from-markdown 扩展
- index.ts // 插件入口
- styles.css // 附加样式
micromark 扩展
import {
asciiAlpha,
asciiAlphanumeric,
markdownLineEnding,
markdownSpace,
} from "micromark-util-character";
import { factorySpace } from "micromark-factory-space";
import { codes, types, constants } from "micromark-util-symbol";
import type {
TokenizeContext,
Construct,
Tokenizer,
Effects,
Exiter,
State,
Token,
Extension as MicromarkExtension,
} from "micromark-util-types";
// characters in name can be: a-z, A-Z, 0-9, -, _
// but the first character must be a-z or A-Z
function factoryName(
this: TokenizeContext,
effects: Effects,
ok: State,
nok: State,
type: string
) {
const self = this;
const start: State = function (code) {
if (asciiAlpha(code)) {
effects.enter(type);
effects.consume(code);
return name;
}
return nok;
};
const name: State = function (code) {
if (
code === codes.dash ||
code === codes.underscore ||
asciiAlphanumeric(code)
) {
effects.consume(code);
return name;
}
effects.exit(type);
return self.previous === codes.dash || self.previous === codes.underscore
? nok
: ok;
};
return start;
}
// tokenizing title, the title can be any character except line ending
function factoryTitle(effects: Effects, ok: State, nok: State, type: string) {
let previous: Token;
const start: State = function (code) {
effects.enter(type);
return titleStart;
};
const titleStart: State = function (code) {
if (markdownLineEnding(code) || code === codes.eof) {
effects.exit(type);
return ok;
}
const token = effects.enter(types.chunkText, {
contentType: constants.contentTypeText,
previous,
});
if (previous) previous.next = token;
previous = token;
effects.consume(code);
return title;
};
const title: State = function (code) {
if (markdownLineEnding(code) || code === codes.eof) {
effects.exit(types.chunkText);
effects.exit(type);
return ok;
}
effects.consume(code);
return title;
};
return start;
}
const tokenizeIndent: Tokenizer = function (effects, ok, nok) {
const self = this;
const prefix: State = function (code) {
if (!self.containerState) {
throw new Error("expected state");
}
if (typeof self.containerState.indent !== "number") {
throw new Error("expected indent");
}
return factorySpace(
effects,
afterPrefix,
"admonitionIndent",
constants.tabSize + 1
);
};
const afterPrefix: State = function (code) {
if (!self.containerState) {
throw new Error("expected state");
}
const tail = self.events[self.events.length - 1];
if (tail) {
const [type, token, context] = tail;
if (
token.type === "admonitionIndent" &&
token.end.column - 1 === self.containerState.indent
) {
return ok;
}
}
return nok;
};
return prefix;
};
const tokenizeBlankLine: Tokenizer = function (effects, ok, nok) {
const self = this;
const start: State = function (code) {
return markdownSpace(code)
? factorySpace(effects, after, types.whitespace)
: after;
};
const after: State = function (code) {
return code === codes.eof || markdownLineEnding(code) ? ok : nok;
};
return start;
};
const tokenizeAdmonitionStart: Tokenizer = function (effects, ok, nok) {
// used for factoryName
const self = this;
// define data
let markerSize = 0;
const tail = self.events[self.events.length - 1];
let initialIndent = 0;
if (tail) {
const [tailType, tailToken, tailContext] = tail;
initialIndent =
tailToken.type === "admonitionIndent"
? tailContext.sliceSerialize(tailToken, true).length
: 0;
}
if (!self.containerState) {
throw new Error("expected state");
}
self.containerState.indent = initialIndent + constants.tabSize;
// define states
const start: State = function (code) {
effects.enter("admonition");
effects.enter("admonitionPrefix");
if (code === codes.exclamationMark) {
effects.enter("admonitionMarker");
return marker;
}
return nok;
};
const marker: State = function (code) {
if (code === codes.exclamationMark) {
effects.consume(code);
markerSize++;
return marker;
}
if (markerSize !== 3) {
return nok;
}
effects.exit("admonitionMarker");
return afterMarker;
};
const afterMarker: State = function (code) {
if (markdownSpace(code)) {
return factorySpace(effects, afterMarker, types.whitespace);
}
return name;
};
const name: State = function (code) {
return factoryName.call(self, effects, afterName, nok, "admonitionName");
};
const afterName: State = function (code) {
if (markdownSpace(code)) {
return factorySpace(effects, afterName, types.whitespace);
}
if (markdownLineEnding(code) || code === codes.eof) {
return after;
}
return title;
};
const title: State = function (code) {
return factoryTitle(effects, afterTitle, nok, "admonitionTitle");
};
const afterTitle: State = function (code) {
if (!markdownLineEnding(code)) {
effects.consume(code);
// self.parser.lazy[t.start.line] = false
return afterTitle;
}
return after;
};
const after: State = function (code) {
effects.exit("admonitionPrefix");
return ok;
};
return start;
};
const tokenizeAdmonitionContinuation: Tokenizer = function (effects, ok, nok) {
return effects.check(blankLine, ok, effects.attempt(indent, ok, nok));
};
const exit: Exiter = function (effects) {
effects.exit("admonition");
};
const indent: Construct = { tokenize: tokenizeIndent, partial: true };
const blankLine: Construct = { tokenize: tokenizeBlankLine, partial: true };
const admonition: Construct = {
name: "admonition",
tokenize: tokenizeAdmonitionStart,
continuation: {
tokenize: tokenizeAdmonitionContinuation,
},
exit: exit,
};
export const syntax = (): MicromarkExtension => {
return {
document: {
[codes.exclamationMark]: admonition,
},
};
};
mdast-util-from-markdown 扩展
import type { Paragraph, Parent, Text, Content } from "mdast";
import type { Content as HastContent } from "hast";
import type {
Handle,
Extension as FromMarkdownExtension,
} from "mdast-util-from-markdown";
import { fromHtml } from "hast-util-from-html";
export interface Admonition extends Parent {
type: "admonition";
name: string;
}
export interface admonitionHTML extends Parent {
type: "admonitionHTML";
}
declare module "mdast" {
interface BlockContentMap {
admonitionHTML: admonitionHTML;
admonition: Admonition;
}
interface ParagraphData {
admonitionTitle?: boolean;
}
}
const admonitionConfig: Record<
string,
{
title: string;
icon: string;
class: string;
}
> = {
note: {
title: "备注",
icon: "<svg>...</svg>",
class: "admonition-note",
},
info: {
title: "信息",
icon: "...",
class: "admonition-info",
},
tip: {
title: "提示",
icon: "...",
class: "admonition-tip",
},
caution: {
title: "注意",
icon: "...",
class: "admonition-caution",
},
danger: {
title: "危险",
icon: "...",
class: "admonition-danger",
},
};
function h(
tagName: string,
properties: Record<string, any>,
children?: (Content | undefined | null | false)[]
): admonitionHTML {
const filteredChildren =
children?.filter<Content>((child): child is Content => {
return child !== undefined && child !== null && child !== false;
}) ?? [];
return {
type: "admonitionHTML",
data: {
hName: tagName,
hProperties: properties,
},
children: filteredChildren,
};
}
function toMdast(node: HastContent) {
if (node.type === "text") {
return {
type: "text",
value: node.value,
} as Text;
}
if (node.type === "element") {
const mdastNode = {
type: "admonitionHTML",
data: {
hName: node.tagName,
hProperties: node.properties,
},
children: [],
} as admonitionHTML;
node.children.map(toMdast).forEach((child) => {
if (child) {
mdastNode.children.push(child);
}
});
return mdastNode;
}
return null;
}
function htmlTemplate(
type: string,
title?: string | Content,
children?: (Content | undefined | null | false)[]
) {
const key = type in admonitionConfig ? type : "note";
const config = admonitionConfig[key];
let titleNode = null;
if (typeof title === "string") {
titleNode = {
type: "text",
value: title,
} as Text;
} else if (typeof title === "undefined") {
titleNode = {
type: "text",
value: config.title,
} as Text;
} else {
titleNode = title;
}
const iconNode = config.icon
? toMdast(
fromHtml(config.icon, {
space: "svg",
fragment: true,
}).children[0]
)
: null;
return h(
"div",
{
class: "my-4 rounded-lg px-4 py-2 " + config.class,
},
[
h(
"div",
{
class: "flex items-center text-base font-bold",
},
[iconNode, titleNode]
),
children &&
children.length > 0 &&
h(
"div",
{
class: "mt-2 prose-compact text-sm",
},
children
),
]
);
}
export const fromMarkdown = (): FromMarkdownExtension => {
const enterAdmonition: Handle = function (token) {
this.enter<Admonition>(
{
type: "admonition",
name: "",
children: [],
},
token
);
};
const enterAdmonitionTitle: Handle = function (token) {
this.enter<Paragraph>(
{
type: "paragraph",
data: {
admonitionTitle: true,
},
children: [],
},
token
);
};
const exitAdmonition: Handle = function (token) {
const node = this.exit(token) as Admonition;
const type = node.name;
const titleNodeIndex = node.children.findIndex((child) => {
return child.type === "paragraph" && child.data?.admonitionTitle;
});
const titleNode =
titleNodeIndex >= 0
? (node.children.splice(titleNodeIndex, 1)[0] as Paragraph)
: undefined;
if (titleNode) {
titleNode.data = {
...titleNode.data,
hName: "span",
};
}
const subtree = htmlTemplate(type, titleNode, node.children);
if (subtree) {
Object.assign(node, subtree);
}
};
const exitAdmonitionName: Handle = function (token) {
const node = this.stack[this.stack.length - 1] as Admonition;
if (node.type === "admonition") {
node.name = this.sliceSerialize(token);
}
};
const exitAdmonitionTitle: Handle = function (token) {
this.exit(token);
};
return {
enter: {
admonition: enterAdmonition,
admonitionTitle: enterAdmonitionTitle,
},
exit: {
admonition: exitAdmonition,
admonitionName: exitAdmonitionName,
admonitionTitle: exitAdmonitionTitle,
},
};
};
完善插件
import type { RemarkPlugin } from "@astrojs/markdown-remark";
import { syntax } from "./syntax";
import { fromMarkdown } from "./fromMarkdown";
const remarkAdmonition: RemarkPlugin<[]> = function () {
const data = this.data();
function add(key: string, value: unknown) {
if (Array.isArray(data[key])) {
(data[key] as unknown[]).push(value);
} else {
data[key] = [value];
}
}
add("micromarkExtensions", syntax());
add("fromMarkdownExtensions", fromMarkdown());
};
export default remarkAdmonition;