backdrop
background

给你的Markdown扩展新语法吧:下篇

2023年8月7日
笑颜如旧

本文最后一次更新于 大约 1 年前,文中内容可能已经过时,请注意甄别。

注意

这篇文章仅适用于基于 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;

给你的Markdown扩展新语法吧:下篇

https://www.smilen.cn/posts/extend-markdown-syntax-practical/

作者

笑颜如旧

发布于

2023年8月7日

编辑于

2023年12月28日

许可协议

转载或引用本文时请注明作者及出处,不得用于商业用途。