Markdown 转微信公众号, 又何必借助于其他工具??

1,629 阅读2分钟

一、现状

个人目前所有笔记、文章都是在自建站点 昆仑虚 上进行统一管理

image

昆仑虚 编辑器是 Markdown 格式的, 对于 掘金 等大部分平台, 编辑文章、发布文章都是支持 Markdown 格式, 所以平常我都是直接将全文内容直接复制过去, 直接发布即可!!!

但是呢? 如果我想将 昆仑虚 上的文章发布到微信公众号就麻烦了, 鬼知道为什么微信公众号编辑器为啥不支持 Markdown 格式, 目前的做法就是将 Markdown 内容复制到 墨滴(一个 Markdown 转微信公众号平台) 上, 将其转为微信公众号编辑器支持的格式!!!

image

二、目标

那么本次目标或者说是需求, 就是需要编辑器上增加一个按钮, 点击按钮自动将 Markdown 内容转为微信公众号编辑器支持的格式, 并将内容添加到粘贴板...

image

三、解析

3.1 确认格式

首先需要先确认下, 微信公众号编辑器支持的到底是啥格式的内容? 又或者说在 墨滴编辑器 上我们复制出来的内容格式是啥? 这样才能有解决思路...

登录 墨滴编辑器 随便写点内容, 尝试进行复制....

image

将复制内容, 黏贴到编辑器中, 发现原来复制的居然是 html 字符串, 当然这个结果其实也是情理之中...

image

但是, 事情真的这么简单吗? 当然不是啦!!! 如下所示我们如果直接将一段 html 字符串复制到微信公众号编辑器, 它并没有像预期中一样展示, 而是展示了 html 原文

<h1 style="color: #bae637;">标题</h1>
<p style="color: #4096ff;">正文内容</p>

image

揭秘揭秘揭秘揭秘!!! 经过 google 最终确定, 微信公众号 支持的内容是 text/html 格式的数据流, 也就是如果我们只是手动复制一段 html微信公众号 是不行的, 因为它的数据格式并不是 text/html

如下代码, 通过 navigator.clipboard.writehtml 文本, 以 text/plain 以及 text/html 的格式写入剪切板, 同时通过 $0.addEventListener 将方法绑定到当前选中的第一个节点的 click 事件上; 这里之所以通过事件方式触发 navigator.clipboard.write 是因为为了安全 navigator.clipboard 只能够通过 用户手动 触发, 才能生效!!!

const clipboard = async () => {
  const html = `
    <h1 style="color: #bae637;">标题</h1>
    <p style="color: #4096ff;">正文内容</p>
  `;

  await navigator.clipboard.write([
    new window.ClipboardItem({
      // text/html 格式数据, 为了能够将内容复制到微信公众号
      'text/html': new Blob([html], { type: 'text/html' }),
      // text/plain 格式数据, 为了能够将内容作为文本复制到编辑器
      'text/plain': new Blob([html], { type: 'text/plain' }),
    }),
  ]);

  window.alert('已复制到剪贴板');
};

// chrome 调用, $0 表示当前选中的 html 节点
$0.addEventListener('click', clipboard);

测试发现, 微信公众号编辑器确实可以正常解析我们复制的内容咯

ScreenFlow

3.2 获取完整 html

下面我们只剩下一个难点, 那就是如何获取 Markdown 预览后的全部 html 内容

当然这里你也许会觉着, 这不是很简单吗? 当点击复制按钮时, 直接读取预览界面对应 dom 节点的 innerHtml 不就好了!!!

事情哪有这么简单的呢, 我们还是以 墨滴编辑器 来演示, 我们尝试获取下读取预览界面对应 dom 节点的 innerHtml, 会发现和我们复制到的 html 内容还是有很大区别的, 复制到的 html 是带有每个节点的所以样式属性的(style 属性), 但是呢 innerHtml 只是单纯获取节点的 html 不会带有外联的 CSS 样式!!!

image

那么根本问题回到, 我们要如何根据一个指定 DOM 节点, 获取到一个完整的 html, 同时需要将每个节点的所有 CSS 属性, 转为内联的形式!!!

最后经过一顿 google, 终于让我找到一个 npmjuice, 通过它我们就可以将给定的 HTML 中的 CSS 属性全部内联到 style 属性中...

// 有代码如下:
var juice = require('juice');
var result = juice("<style>div{color:red;}</style><div/>");

// 最后结果:
<div style="color: red;"></div>

3.3 另一种方式

在翻资料时, 还找到另一种实现方式, 如下代码直接选中所有 DOM 节点并执行 execCommand 进行拷贝, 这样也能够拿到所有带内联样式的 html, 这里可以参考: Markdown-微信 格式转换器Markdown-Weixin

const clipboardDiv = document.getElementById('#output')
clipboardDiv.focus()
window.getSelection().removeAllRanges()

const range = document.createRange()
range.setStartBefore(clipboardDiv.firstChild)
range.setEndAfter(clipboardDiv.lastChild)
window.getSelection().addRange(range)

if (document.execCommand('copy')) {
  window.alert('已复制到剪贴板')
} else {
  window.alert('未能复制到剪贴板,请全选后右键复制')
}

但是这样拷贝就不太可控, 无法对复制的节点进行一些修改(或者说实现起来比较麻烦)

四、正式开始

4.1 加按钮

首先呢先给 Markdown 预览页面加个小按钮

export default () => {
  const handleCopy = useCallback(() => {}, [])

  return (
    <Icon
      title="复制代码"
      type="icon-copy"
      onClick={handleCopy}
    />
  )
}

image

4.2 获取所有 CSS 规则

下面我们需要获取到所有 CSS 规则, 这样才能借用 juicehtml 转为带有内联样式的 html 字符串

image

这里我们可通过原生 API document.styleSheets, 获取到当前页面中所有 CSS 规则; 如下代码所示: 通过 document.styleSheets 获取到所有 CSS 规则, 并合并为字符串

// 返回当前页面所以样式规则: .body{...} .main{...}
export default () => [...document.styleSheets].reduce((total, styleSheet) => {
  const list = [...styleSheet.cssRules].map((rule) => {
    // 过滤掉以 .ant 开头的规则: 因为预览模块没用到 antd 的组件, 所以干脆过滤掉 antd 的样式
    // 过滤掉以 :root 开头的规则: 因为 juice 暂不支持 CSS 变量, 所以就把 :root 上的规则过滤掉(大部分都是 CSS 变量定义)
    if (/^(\.ant|:root)/.test(rule.cssText)) {
      return '';
    }

    return rule.cssText;
  });

  return [...total, ...list];
}, []).join('\n');

image

4.3 转换并复制

拿到 CSS 后接下来就可以尝试将要复制的 Dom 节点的 html 进行一个转换

const handleCopy = useCallback(async () => {
  const sourceHtml = previewRef.current.outerHTML;
  const css = getAllCSS();

  const options = {
    removeStyleTags: true,     // 移除 Style 标签(暂时无法生效, 已有人提交 issues: https://github.com/Automattic/juice/issues/470)
    inlinePseudoElements: true, // 是否将伪元素转为 span 标签
  };
  const transform = juice(`<style>${css}</style>${sourceHtml}`, options);
  const resHtml = transform.replace(/<style>[\s\S]*?<\/style>/, ''); // 先手动移除 Style

  // 复制: 将文本内容以 text/html、text/plain 格式写入剪切板
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'text/html': new Blob([resHtml], { type: 'text/html' }),
      'text/plain': new Blob([resHtml], { type: 'text/plain' }),
    }),
  ]);

  window.alert('复制成功!!!');
}, []);

下面是测试结果, 基本上能够完成正常的复制了, 但是对于代码块部分, 还是出现样式丢失问题!!!

ScreenFlow

对比原页面 htmljuice 转换出来的 html 以及复制到微信公众编辑器的 html, 发现 juice 转换出来的并没有问题, 主要问题出在将 html 复制到微信公众号编辑器时, 被转换了!!!

image

经测试, 姑且认为微信公众号编辑器不支持 div 标签, 会默认转为 p 标签, 同时经测试对于嵌套的 p 标签也是不支持的!! 最后发现对于 section 微信公众号编辑器是支持的, 并且还能够被嵌套使用! 那就很简单咯, 只要将所有 div 标签换成 section 即可, 修改后代码如下:

const handleCopy = useCallback(async () => {
  const sourceHtml = previewRef.current.outerHTML;
  const css = getAllCSS();

  const options = {
    removeStyleTags: true,     // 移除 Style 标签(暂时无法生效, 已有人提交 issues: https://github.com/Automattic/juice/issues/470)
    inlinePseudoElements: true, // 是否将伪元素转为 span 标签
  };
  const transform = juice(`<style>${css}</style>${sourceHtml}`, options);
+ const resHtml = transform
+   .replace(/<style>[\s\S]*?<\/style>/, '') // 先手动移除 Style
+   .replace(/(?<=\/?)div(?=[\s>])/ig, 'section'); // div 标签转为 section

  // 复制: 将文本内容以 text/html、text/plain 格式写入剪切板
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'text/html': new Blob([resHtml], { type: 'text/html' }),
      'text/plain': new Blob([resHtml], { type: 'text/plain' }),
    }),
  ]);

  window.alert('复制成功!!!');
}, []);

最下来看下修改后的效果吧

ScreenFlow

五、样式优化

到这一步, 想要的需求基本就已经完成, 下面剩余一些收尾工作可能就是:

  1. 交互优化: 目前复制成功的提示只是简单 alert 出提示语, 这里需要改掉

image

  1. 样式优化: 需要针对微信工作号, 定制一些特定的样式, 比如暗色模式下样式的兼容
const COPY_CUSTOM_STYLE = `
  <style>
    @media (prefers-color-scheme: light) {

    }
    @media (prefers-color-scheme: dark) {
      .brick-markdown-preview-light * {
        color: rgba(255, 255, 255, 0.85);
      }

      .brick-markdown-preview-light blockquote {
        background: #3b3456;
        border-color: #241f3a;
      }
    }
  </style>
`;

const handleCopy = useCallback(async () => {
  const sourceHtml = previewRef.current.outerHTML;
  const css = getAllCSS();

  const options = {
    removeStyleTags: true,     // 移除 Style 标签(暂时无法生效, 已有人提交 issues: https://github.com/Automattic/juice/issues/470)
    inlinePseudoElements: true, // 是否将伪元素转为 span 标签
  };
  const transform = juice(`<style>${css}</style>${sourceHtml}`, options);
  const replaceHtml = transform
    .replace(/<style>[\s\S]*?<\/style>/, '') // 先手动移除 Style
    .replace(/(?<=\/?)div(?=[\s>])/ig, 'section'); // div 标签转为 section

  const copyContent = [`${COPY_CUSTOM_STYLE}${replaceHtml}`];

  // 复制: 将文本内容以 text/html、text/plain 格式写入剪切板
  await navigator.clipboard.write([
    new window.ClipboardItem({
      'text/html': new Blob([copyContent], { type: 'text/html' }),
      'text/plain': new Blob([copyContent], { type: 'text/plain' }),
    }),
  ]);

  window.alert('复制成功!!!');
}, []);

七、参考

大家好, 我是墨渊君, 如果您喜欢我的文章可以: