在进行中英文混排的文本编写时,常常需要在中文字符和英文字符之间添加空格,以提升可读性和美观度。然而,手动添加这些空格既繁琐又容易出错。尤其是针对历史文章进行一一手动处理,工作量极大,而且容易遗漏。为了解决这个问题,我们可以利用 Astro 的强大功能,自动化地为中英文混排文本添加适当的空格。主要参考了 中文文案排版指北 这篇文章中的建议。

由于我用的 MDX 格式编写文章,有时为了实现更加复杂的样式会使用纯 HTML 代码来替代 Markdown 语法。所以实际使用了两个插件分别对 Markdown 和 HTML 内容进行处理。这时候 Astro 的性能优势就体现出来了,所有的处理都在构建阶段完成,不会影响页面加载速度。

创建 remark 和 rehype 插件文件

首先需要在项目根目录下新建两个文件,分别命名为 remark-cn-spacing.mjsrehype-cn-spacing.mjs,用于处理 Markdown 和 HTML 内容。

mkdir -p scripts/remark  
mkdir -p scripts/rehype
touch scripts/remark/remark-cn-spacing.mjs
touch scripts/rehype/rehype-cn-spacing.mjs

编辑 remark 和 rehype 插件内容

// srcipts/remark/remark-cn-spacing.mjs
import { visit } from "unist-util-visit";

const ZH = /[\u4e00-\u9fff]/;
const EN = /[A-Za-z0-9]/;

// =====================
// text 节点内部
// =====================
function fixTextSpacing(text) {
  return text
    .replace(/([\u4e00-\u9fff])([A-Za-z0-9])/g, "$1 $2")
    .replace(/([A-Za-z0-9])([\u4e00-\u9fff])/g, "$1 $2")
    .replace(/([\u4e00-\u9fff])([()])/g, "$1 $2")
    .replace(/([()])([\u4e00-\u9fff])/g, "$1 $2");
}

// =====================
// 节点边界判断
// =====================
function nodeEndsWithZH(node) {
  if (node.type === "text") {
    const v = node.value.trimEnd();
    return v && ZH.test(v[v.length - 1]);
  }
  return false;
}

function nodeStartsWithENLike(node) {
  return (
    node.type === "link" ||
    node.type === "inlineCode" ||
    node.type === "emphasis" ||
    node.type === "strong"
  );
}

function nodeEndsWithENLike(node) {
  return (
    node.type === "link" ||
    node.type === "inlineCode" ||
    node.type === "emphasis" ||
    node.type === "strong"
  );
}

function nodeStartsWithZH(node) {
  if (node.type === "text") {
    const v = node.value.trimStart();
    return v && ZH.test(v[0]);
  }
  return false;
}

// =====================
// 插件主体
// =====================
export default function remarkCnSpacing() {
  return (tree) => {
    /**
     * 1️⃣ 修 text 节点内部
     */
    visit(tree, "text", (node, index, parent) => {
      if (
        parent?.type === "code" ||
        parent?.type === "inlineCode" ||
        parent?.type?.startsWith("mdxJsx")
      ) {
        return;
      }

      const fixed = fixTextSpacing(node.value);
      if (fixed !== node.value) {
        node.value = fixed;
      }
    });

    /**
     * 2️⃣ 修 paragraph 内相邻节点边界
     */
    visit(tree, "paragraph", (node) => {
      const children = node.children;

      for (let i = 0; i < children.length - 1; i++) {
        const cur = children[i];
        const next = children[i + 1];

        // 中文 + EN-like(link / code / emphasis / strong)
        if (nodeEndsWithZH(cur) && nodeStartsWithENLike(next)) {
          children.splice(i + 1, 0, { type: "text", value: " " });
          i++;
          continue;
        }

        // EN-like + 中文
        if (nodeEndsWithENLike(cur) && nodeStartsWithZH(next)) {
          children.splice(i + 1, 0, { type: "text", value: " " });
          i++;
        }
      }
    });
  };
}
// scripts/rehype/rehype-cn-spacing.mjs
import { visit } from "unist-util-visit";

const ZH = /[\u4e00-\u9fff]/;
const EN = /[A-Za-z0-9]/;

function fixText(text) {
  return text
    .replace(/([\u4e00-\u9fff])([A-Za-z0-9])/g, "$1 $2")
    .replace(/([A-Za-z0-9])([\u4e00-\u9fff])/g, "$1 $2")
    .replace(/([\u4e00-\u9fff])([()])/g, "$1 $2")
    .replace(/([()])([\u4e00-\u9fff])/g, "$1 $2");
}

function isInlineElement(node) {
  return (
    node.tagName === "a" ||
    node.tagName === "span" ||
    node.tagName === "code" ||
    node.tagName === "strong" ||
    node.tagName === "em"
  );
}

export default function rehypeCnSpacing() {
  return (tree) => {
    visit(tree, "text", (node, index, parent) => {
      if (!parent) return;

      // 1️⃣ 修 text 内部
      node.value = fixText(node.value);

      // 2️⃣ 修 inline 元素边界
      const siblings = parent.children;
      if (!siblings) return;

      const prev = siblings[index - 1];
      const next = siblings[index + 1];

      // 前一个是 inline element + 中文
      if (
        prev &&
        isInlineElement(prev) &&
        ZH.test(node.value[0])
      ) {
        node.value = " " + node.value;
      }

      // 后一个是 inline element + 中文
      if (
        next &&
        isInlineElement(next) &&
        ZH.test(node.value[node.value.length - 1])
      ) {
        node.value = node.value + " ";
      }
    });
  };
}

修改 astro.config.mjs 文件

// astro.config.mjs
import mdx from "@astrojs/mdx";
import remarkCnSpacing from "./scripts/remark/remark-cn-spacing.mjs";
import rehypeCnSpacing from "./scripts/rehype/rehype-cn-spacing.mjs";

export default {
  integrations: [
    mdx({
      remarkPlugins: [remarkCnSpacing],
      rehypePlugins: [rehypeCnSpacing],
    }),
  ],
};

然后重新 npm run dev 即可生效了。