用 Markdown 写公众号:一个本地脚本搞定排版、校验与粘贴发布

中级 12 分钟阅读 微信公众号Markdownmarkdown-it排版内容工作流Node.js

一句话

公众号编辑器吃「带内联样式的富文本粘贴」——所以只要一个本地脚本把 Markdown 渲染成每个标签都带 style 属性的 HTML,再从浏览器一键复制,排版这件事就从每篇半小时变成零成本,而且整个过程不经过任何第三方服务。

它解决什么

用公众号自带编辑器排版,每篇文章都要手动调标题样式、加粗颜色、引用块底色,做一次要二三十分钟,还很难保证十篇文章长得一样。

于是大多数人转向在线排版工具:Markdown 贴进去、选个主题、复制出来。能用,但三个问题会一直跟着你:文章内容要上传给第三方服务器;主题是平台的,想微调品牌色、想十篇文章严格统一,处处受限;工具改版、收费或下线,你的发布流程跟着断。

其实公众号排版的本质很薄:编辑器保存时会剥掉 <style> 标签和 class,但完整保留元素的内联 style 属性。所谓「主题」,不过是给 <p><h2><blockquote> 这些标签各配一段固定的内联样式。这件事一个百行 Node 脚本就能做完,主题固化成代码,文章永远不出本机。

流水线全景

文章.md(frontmatter 存标题/摘要 + Markdown 正文)
   │  node render.mjs 文章.md

校验(标题≤32字、摘要≤128字、正文禁 H1、外链/图片提醒)

内联样式 HTML + 手机宽度预览页  →  浏览器打开

点「复制正文」→ 公众号编辑器粘贴 → 填标题摘要封面 → 发布

依赖只有两个都很常见的包:markdown-it(Markdown 解析)和 gray-matter(frontmatter 解析)。如果你的博客或文档项目已经在用它们,一个新依赖都不用装。

第一步:定文章格式

每篇文章一个 Markdown 文件,元数据放 frontmatter,正文不写 H1(公众号标题在后台单独填,正文再出现一遍会重复):

---
title: 文章标题(≤32 字)
author: 你的公众号名
digest: 公众号摘要,会显示在分享卡片上(≤128 字)
---

正文从第一段直接开始,小节用 ## 二级标题。

> 引用块会渲染成品牌色底的金句卡。

**加粗**会渲染成品牌强调色。

frontmatter 还可以随意加自己的运营字段(发布状态、封面文案、备选标题),脚本只读前三个,多余字段不会进正文——这让「一个文件管一篇文章的所有信息」成为可能。

第二步:写渲染脚本

核心思路:覆盖 markdown-it 的渲染规则,让每种标签输出时自带内联样式。先把主题定义成一张样式表常量——这就是你的「固定主题」,以后改风格只改这里:

// render.mjs —— 最小可运行版
import fs from "node:fs";
import MarkdownIt from "markdown-it";
import matter from "gray-matter";

const ACCENT = "#F97316", ACCENT_DARK = "#D9480F"; // 换成你的品牌色

const S = {
  p: `margin:0 0 20px;font-size:15px;line-height:1.85;letter-spacing:.5px;color:#3f3f3f;text-align:justify;`,
  h2: `margin:40px 0 20px;padding-left:12px;border-left:4px solid ${ACCENT};font-size:17px;font-weight:700;color:#1f2937;`,
  h3: `margin:32px 0 16px;font-size:16px;font-weight:700;color:#1f2937;`,
  quote: `margin:24px 0;padding:14px 18px;background:#FFF7ED;border-left:3px solid ${ACCENT};border-radius:0 8px 8px 0;color:#8a5a34;`,
  li: `margin:0 0 8px;font-size:15px;line-height:1.8;color:#3f3f3f;`,
  hr: `margin:36px auto;border:none;height:1px;background:linear-gradient(to right,transparent,${ACCENT},transparent);`,
};

const md = new MarkdownIt();
const r = md.renderer.rules;
r.paragraph_open  = () => `<p style="${S.p}">`;
r.heading_open    = (t, i) => t[i].tag === "h2" ? `<h2 style="${S.h2}">` : `<h3 style="${S.h3}">`;
r.heading_close   = (t, i) => t[i].tag === "h2" ? "</h2>" : "</h3>";
r.blockquote_open = () => `<section style="${S.quote}">`;
r.blockquote_close= () => "</section>";
r.list_item_open  = () => `<li style="${S.li}">`;
r.hr              = () => `<hr style="${S.hr}">`;
r.strong_open     = () => `<strong style="color:${ACCENT_DARK};">`;

const { data, content } = matter(fs.readFileSync(process.argv[2], "utf8"));
if (!data.title || data.title.length > 32) console.warn("⚠ 标题缺失或超 32 字");
if ((data.digest ?? "").length > 128) console.warn("⚠ 摘要超 128 字");

const article = `<section style="font-size:15px;word-break:break-word;">${md.render(content)}</section>`;
fs.writeFileSync("preview.html", buildPreviewPage(data, article)); // 见下一步

三十行渲染逻辑,一套完整主题。想换风格,改 S 里的值即可,所有文章重渲一遍就统一更新——这是在线排版工具给不了的确定性。

两个公众号特有的处理值得单独写规则:

  • 链接:公众号发布时会把外链剥成纯文本,所以把 link_open/link_close 覆盖成「彩色 <span> + 括号内灰色小字网址」,剥掉后地址仍可读。
  • 代码块:覆盖 fence 输出 overflow-x:auto 的深色 <pre>,手机上横向滚动而不是折行成粥。

第三步:预览页与一键复制

关键点:不能让用户复制 HTML 源码文本,要复制「渲染后的富文本」。用 Clipboard API 把 text/html 写进剪贴板:

function buildPreviewPage(meta, article) {
  return `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body
    style="background:#e5e7eb;font-family:-apple-system,'PingFang SC',sans-serif;">
  <div style="max-width:414px;margin:24px auto;background:#fff;border-radius:12px;padding:24px 18px;">
    <button onclick="copyIt(this)">复制正文(粘贴进公众号编辑器)</button>
    <div id="wx">${article}</div>
  </div>
  <script>
  async function copyIt(btn) {
    const el = document.getElementById("wx");
    try {
      await navigator.clipboard.write([new ClipboardItem({
        "text/html":  new Blob([el.innerHTML], { type: "text/html" }),
        "text/plain": new Blob([el.innerText], { type: "text/plain" }),
      })]);
    } catch (e) { // file:// 下部分浏览器限制 Clipboard API,退回选区复制
      const r = document.createRange(); r.selectNodeContents(el);
      const s = getSelection(); s.removeAllRanges(); s.addRange(r);
      document.execCommand("copy"); s.removeAllRanges();
    }
    btn.textContent = "✓ 已复制";
  }
  </script></body></html>`;
}

容器限宽 414px,预览的折行效果和手机上基本一致。到公众号后台「新建图文」,光标放进正文区 Ctrl+V,样式原样落地;标题、摘要、封面在后台对应输入框手工填(值就抄 frontmatter)。

进阶:给主题加固定布局块

纯 Markdown 语义标签之外,公众号文章常用几种「卡片」。用 ::: 围栏语法扩展,渲染前先按行扫描把围栏内容切出来、递归渲染再包上卡片样式:

::: tip 先说结论
适合个人用户的选择是 **方案 A**
:::

::: warn 注意
免费额度每月会重置,不要囤积。
:::

::: cta
:::
  • tip 要点卡:浅品牌色底 + 圆角边框,放核心结论和行动清单;
  • warn 避坑卡:浅红底,放限制与坑点;
  • cta 文末引导卡:居中卡片,留空时自动填入固定的账号介绍 + 关键词回复引导——每篇文章的结尾从此不用复制粘贴。

实现上不需要 markdown-it 插件:逐行匹配 ^::: (tip|warn|cta) 开头、^:::$ 闭合,块外内容照常走 md.render,块内内容渲染后包一层带内联样式的 <section>。五十行以内能写完,且布局块种类被刻意限制在三种——约束就是风格,块越少,十篇文章摆在一起越像一个专栏。

公众号特有的坑

  1. 外链会被剥。发布后非微信域名链接全部变纯文本。应对:链接都渲染成「文字+地址」;唯一重要入口放「阅读原文」,并带上 UTM 参数以便统计导流。
  2. 图片必须走素材库。本地路径和外站图床的图粘贴后不显示。先传公众号素材库拿到 mmbiz.qpic.cn 地址再替换,或粘贴正文后在编辑器里手动插图。让脚本对 ![]() 打警告,避免带着裂图群发。
  3. 别点编辑器的「清除格式」。它会把内联样式一起清掉,等于全篇打回白文。粘贴后个别段落要调整就单独改那一段。
  4. 长度限制要在本地卡住。标题 32 字、摘要 128 字、作者 16 字是硬限制,超了后台会拒绝。放进脚本校验、超限直接报错退出,比群发前才发现体面得多。
  5. 发布前用手机预览。桌面浏览器和微信内置 WebView 的字体渲染有差异,群发前一定发预览到自己手机看一遍。

完整版参考

本文的最小脚本补全校验报错、布局块、图片与外链警告后大约三百行,仍然是单文件零服务依赖。丑橘AI公众号目前就跑在这条流水线上:文章 Markdown 与站点内容同仓库管理,npm run weixin -- 文章.md 一条命令出预览,每篇排版时间约等于零,且所有文章共享同一套代码化主题。

把排版从「每篇的手工活」变成「一次性的工程投入」,你会更愿意把时间花在文章本身——这才是这个脚本真正的收益。