Markdown 写完,转成公众号排版
用一段不依赖第三方库的 Node 脚本,把 Markdown 转成带行内样式的 HTML。浏览器打开后全选复制,粘贴进公众号编辑器,标题、列表、代码块都能保留。
30 秒速查
- 文章用 Markdown 写,标题、列表、代码都能保留
- 跑
node resources/md2wechat/md2wechat.mjs 示例.md- 生成
示例.html,浏览器打开后全选复制- 粘贴到公众号编辑器,发布前预览一遍
这篇教程解决什么问题
在编辑器里写 Markdown 很顺,粘到公众号就乱了:标题没了层级,列表挤成一段,代码块变成纯文本。
用第三方排版工具当然可以,但通常要登录、绑定账号,样式也在别人手里。这篇换一种做法:本地把 Markdown 转成带行内样式的 HTML,复制粘贴进公众号。
不调用接口,不绑定账号,样式表在你自己手里。
准备一篇示例
新建 示例.md:
# 为什么表格总乱码
很多人以为是 **Excel 坏了**,其实多半是编码没对上。
## 先做三件事
1. 备份原文件
2. 看文件开头
3. 用 `utf8` 重新打开
> 不要在原文件上直接保存。
```bash
node fix-csv.mjs data/
```
参考:[跟我做](https://www.genwozuo.com/)
外层用四个反引号,避免和里面的代码块冲突。
写脚本
保存为 resources/md2wechat/md2wechat.mjs:
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
// 把 Markdown 转成公众号能直接粘贴的 HTML。
// 公众号会过滤 <style>,所以样式全部写成行内 style;不依赖任何第三方库。
const STYLE = {
section:
"font-size:16px;line-height:1.75;color:#333;letter-spacing:.5px;max-width:677px;margin:0 auto;padding:0 16px;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;",
h2: "font-size:22px;font-weight:700;line-height:1.4;margin:28px 0 12px;color:#1a1a1a;",
h3: "font-size:18px;font-weight:700;line-height:1.4;margin:22px 0 10px;color:#1a1a1a;",
h4: "font-size:16px;font-weight:700;line-height:1.4;margin:18px 0 8px;color:#1a1a1a;",
p: "margin:12px 0;font-size:16px;line-height:1.75;color:#333;",
blockquote:
"margin:16px 0;padding:10px 14px;border-left:3px solid #2f6f5e;background:#f7f7f5;color:#555;",
code: "padding:2px 5px;border-radius:4px;background:#f2f2f0;color:#c7254e;font-size:14px;",
pre: "margin:16px 0;padding:12px 14px;border-radius:8px;background:#10201c;color:#e6efe9;overflow-x:auto;font-size:13px;line-height:1.6;",
li: "margin:6px 0;font-size:16px;line-height:1.75;color:#333;",
a: "color:#2f6f5e;text-decoration:none;border-bottom:1px solid #b9d8cd;",
hr: "border:0;border-top:1px solid #e5e5e5;margin:24px 0;",
};
export function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export function inline(text) {
const codes = [];
let value = text.replace(/`([^`]+)`/g, (_match, code) => {
codes.push(code);
return `\u0000${codes.length - 1}\u0000`;
});
value = escapeHtml(value);
value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
value = value.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
`<a href="${escapeHtml("$2")}" style="${STYLE.a}">$1</a>`,
);
return value.replace(/\u0000(\d+)\u0000/g, (_match, index) => {
return `<code style="${STYLE.code}">${escapeHtml(codes[Number(index)])}</code>`;
});
}
function isBlockStart(line) {
return (
/^(#{1,4})\s+/.test(line) ||
/^```/.test(line) ||
/^> /.test(line) ||
/^- /.test(line) ||
/^\d+\. /.test(line) ||
/^---+$/.test(line.trim())
);
}
export function markdownToHtml(markdown) {
const lines = markdown.replaceAll("\r\n", "\n").split("\n");
const blocks = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (line.startsWith("```")) {
index += 1;
const code = [];
while (index < lines.length && !lines[index].startsWith("```")) {
code.push(lines[index]);
index += 1;
}
index += 1;
blocks.push(`<pre style="${STYLE.pre}"><code>${escapeHtml(code.join("\n"))}</code></pre>`);
continue;
}
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
if (heading) {
const level = Math.min(heading[1].length + 1, 4);
blocks.push(`<h${level} style="${STYLE[`h${level}`]}">${inline(heading[2])}</h${level}>`);
index += 1;
continue;
}
if (line.startsWith("> ")) {
const quote = [];
while (index < lines.length && lines[index].startsWith("> ")) {
quote.push(lines[index].slice(2));
index += 1;
}
blocks.push(
`<blockquote style="${STYLE.blockquote}"><p style="${STYLE.p}">${inline(quote.join("<br/>"))}</p></blockquote>`,
);
continue;
}
if (/^- /.test(line)) {
const items = [];
while (index < lines.length && /^- /.test(lines[index])) {
items.push(lines[index].slice(2));
index += 1;
}
blocks.push(
`<ul style="margin:12px 0;padding-left:22px;">${items
.map((item) => `<li style="${STYLE.li}">${inline(item)}</li>`)
.join("")}</ul>`,
);
continue;
}
if (/^\d+\. /.test(line)) {
const items = [];
while (index < lines.length && /^\d+\. /.test(lines[index])) {
items.push(lines[index].replace(/^\d+\.\s+/, ""));
index += 1;
}
blocks.push(
`<ol style="margin:12px 0;padding-left:22px;">${items
.map((item) => `<li style="${STYLE.li}">${inline(item)}</li>`)
.join("")}</ol>`,
);
continue;
}
if (/^---+$/.test(line.trim())) {
blocks.push(`<hr style="${STYLE.hr}"/>`);
index += 1;
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() && !isBlockStart(lines[index])) {
paragraph.push(lines[index]);
index += 1;
}
blocks.push(`<p style="${STYLE.p}">${inline(paragraph.join("<br/>"))}</p>`);
}
return `<section style="${STYLE.section}">\n${blocks.join("\n")}\n</section>\n`;
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const input = process.argv.slice(2).find((arg) => !arg.startsWith("--"));
if (!input) {
console.error("用法:node md2wechat.mjs <文章.md>");
process.exitCode = 1;
} else {
const source = await readFile(input, "utf8");
const html = markdownToHtml(source);
const output = input.replace(/\.md$/i, "") + ".html";
await writeFile(output, html, "utf8");
console.log(`已生成:${output}(${html.length} 字符)`);
}
}
脚本做三件事:
- 按行解析标题、列表、引用、代码块和段落;
- 行内处理加粗、行内代码和链接;
- 所有样式写成
style="...",因为公众号会丢掉<style>。
运行
node resources/md2wechat/md2wechat.mjs 示例.md
真实输出:
已生成:示例.html(1547 字符)
打开 示例.html,页面是一段窄栏排版。全选复制,粘贴到公众号编辑器:
- 标题会变成加粗的大字;
- 列表保留项目符号;
- 代码块是深色底、等宽字体;
- 链接带下划线。
发布前在公众号里预览一次,确认平台没有改写代码块背景。
运行脚本生成 HTML
node resources/md2wechat/md2wechat.mjs 示例.md → 示例.html(1547 字符)。
全选复制,粘进公众号编辑器
标题变粗、列表保留项目符号、代码块深色、链接带下划线。
发布前预览一次
确认平台没有改写代码块背景或链接样式。
常见坑
1. 图片不会自动上传
脚本只转文字。图片要在公众号编辑器里手动插入;Markdown 里的 ![]() 会被当普通文字。想批量处理图片,先看批量压缩图片。
2. 表格不支持
公众号对表格支持有限,复杂表格建议转成图片,或者拆成列表。脚本不解析 | 表格。
3. 代码块里的反引号
Markdown 里嵌代码块时,外层用四个反引号;否则解析会提前结束。
4. 样式被平台改写
公众号可能改写链接、代码块背景和字体。发布前预览,必要时在编辑器里手动补一下。
下一步读什么
排版解决的是「发出去好不好看」,发什么仍然靠选题:选题库和内容日历 会帮你把未来两周排好。配图可以用小红书批量封面一次出 20 张,再回来排版。
这篇也属于从选题到发布的发布环节。
常见问题
样式会被公众号过滤吗?
公众号会过滤 <style> 标签,所以脚本把样式全写成行内 style。大部分能保留,但代码块背景、链接下划线可能被平台改写;发布前一定要在编辑器里预览。
支持图片和表格吗?
不支持。图片要在公众号编辑器里手动上传,表格建议转成图片或简化成列表。这个脚本只解决文字排版,不处理素材上传。
需要绑定公众号或登录吗?
不需要。脚本只把 Markdown 转成 HTML,不调用任何接口。复制粘贴是手动的,账号和密码不经过脚本。
能改颜色和字号吗?
能。样式集中在脚本顶部的 STYLE 对象里,改 h2、p、code 的色值或字号即可;改完重新生成一次 HTML。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。