GitHub の Issue、dev.to や Qiita の下書き、Notion、Obsidian — エンジニアがコピペする先はだいたい Markdown を期待しています。でもブラウザの「コピー」が渡すのは HTML(
text/htmlクリップボード形式)で、貼った先で表示が崩れたりリンクが消えたりする。Chrome MV3 で「選択範囲 → Markdown 変換 → クリップボードに書き込み」を 230 行のバニラ JS で組んだ話です。
<all_urls>を要求しないactiveTab + chrome.scripting.executeScriptパターン、そして DOM ツリーを歩く HTML→MD コンバータの実装ポイントが主題。
🧩 Demo: https://sen.ltd/portfolio/copy-as-md/
📦 GitHub: https://github.com/sen-ltd/copy-as-md
なぜブラウザのコピー機能では足りないのか
Chrome の「コピー」は選択範囲を text/plain と text/html の 2 形式 でクリップボードに置きます。貼り付け先がリッチテキストを受け付ければ HTML、そうでなければ plain text になる。
問題は、エンジニアの貼り付け先のほとんどが「3 番目のフォーマット」を期待することです:
| 貼り付け先 | 期待形式 |
|---|---|
| GitHub Issue / PR | Markdown |
| dev.to / Zenn / Qiita | Markdown |
| Notion | Markdown は読まない(独自) |
| Obsidian / Bear / Joplin | Markdown |
| Slack | Markdown サブセット |
GitHub に貼ると、<a href="...">link</a> がそのまま表示されることはなく、HTML タグはエスケープされて見える。Notion は「貼り付け時に Markdown として解釈」もできるが、ペースト → そのページ全体が plain text になる。
「だったら最初から Markdown でクリップボードに置けばいい」が今回のツールです。
アーキテクチャ — MV3 service worker + executeScript パターン
Manifest V3 の制約 (background script ではなく service worker) を踏まえて、最小構成にした:
[ユーザー操作]
│
├─ 右クリック「Copy selection as Markdown」(contextMenus)
├─ Cmd/Ctrl+Shift+M (commands)
└─ ツールバーアイコン→ポップアップの「Copy」(runtime.sendMessage)
│
▼
[service worker (background.js)]
│
▼
chrome.scripting.executeScript を 2 回:
① files: ["html-to-md.js"] ← コンバータを注入
② func: runner ← selection 取得 → 変換 → clipboard.writeText
3 系統のトリガーすべてが runOnTab(tabId) に集約されます:
async function runOnTab(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
files: ["html-to-md.js"], // globalThis.htmlToMarkdown を定義
});
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId },
func: runner,
});
return result;
}
function runner() {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
const md = globalThis.htmlToMarkdown(document.body);
navigator.clipboard.writeText(md).catch(() => {});
return { source: "page", markdown: md };
}
const fragment = sel.getRangeAt(0).cloneContents();
const md = globalThis.htmlToMarkdown(fragment);
navigator.clipboard.writeText(md).catch(() => {});
return { source: "selection", markdown: md };
}
重要: <all_urls> を要求しない
MV3 拡張で「全サイトで動く」を求めると host_permissions: ["<all_urls>"] を書きがちです。これは強い権限で、Chrome Web Store の審査でも目立つし、ユーザーがインストール時に「このサイトのデータを読み取り、変更できます」警告を見ます。
代わりに activeTab + scripting だけで済ませると:
-
host_permissions不要 - インストール時の警告が穏やか
- 「ユーザーが拡張アイコン or ショートカット or context menu で能動操作した瞬間だけ、そのアクティブなタブにアクセスできる」というセマンティクスになる
ユーザーが意図的にトリガーした瞬間だけスクリプトが入る、という MV3 の activeTab の意図にも沿います。実際の manifest.json:
{
"manifest_version": 3,
"permissions": ["activeTab", "scripting", "contextMenus"],
"background": { "service_worker": "background.js" },
"action": { "default_popup": "popup.html" },
"commands": {
"copy-selection-as-markdown": {
"suggested_key": { "default": "Ctrl+Shift+M", "mac": "Command+Shift+M" }
}
}
}
host_permissions も web_accessible_resources も書かない。これがこのカテゴリの拡張の現代的な「最小権限」スタイル。
ポップアップから service worker へ
ポップアップは Chrome が UI を出した瞬間に「現在のタブ」が popup window になってしまうので、chrome.tabs.query({active: true, currentWindow: true}) をポップアップから呼ぶと ポップアップ自身を選ぶ ような挙動になりがち。安全に「ユーザーが見ていた直前のタブ」に到達するには、ポップアップ → service worker → chrome.tabs.query の経路を取ります:
// popup.js
chrome.runtime.sendMessage({ type: "copy-as-md/run" }, (resp) => { ... });
// background.js
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== "copy-as-md/run") return;
(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const md = await runOnTab(tab.id);
sendResponse({ ok: true, markdown: md });
})();
return true; // sendResponse を非同期で呼ぶための魔法の return 値
});
return true を忘れると sendResponse が無効になって popup 側のコールバックが走らない。MV3 の onMessage で 1 度は踏む罠です。
HTML→Markdown コンバータ — タグディスパッチで 230 行
コンバータ本体は純粋なロジックで、DOM ツリー(Element / DocumentFragment / Document.body 何でも可)を受け取って Markdown 文字列を返します。
function htmlToMarkdown(node) {
const out = [];
walk(node, { listDepth: 0 }, out);
return collapseBlankLines(out.join("")).trim() + "\n";
}
const HANDLERS = {
H1: (n, c, o) => heading(n, 1, c, o),
H2: (n, c, o) => heading(n, 2, c, o),
// ... H3-H6
P: (n, c, o) => o.push("\n\n", innerMarkdown(n, c).trim(), "\n\n"),
A: (n, c, o) => {
const text = innerMarkdown(n, c).trim();
const href = n.getAttribute("href");
if (!href) o.push(text);
else if (text === href) o.push("<", href, ">");
else o.push("[", text, "](", href, ")");
},
CODE: ..., PRE: ..., BLOCKQUOTE: ...,
UL: (n, c, o) => list(n, "ul", c, o),
OL: (n, c, o) => list(n, "ol", c, o),
TABLE: ..., STRONG: ..., EM: ..., DEL: ...,
IMG: (n, _c, o) => o.push(" || "", ")"),
SCRIPT: () => {}, STYLE: () => {}, NOSCRIPT: () => {},
};
walk がノードを訪問して、HANDLERS[tagName] が定義されていればそれに任せる、なければ children を再帰、というディスパッチ表。エッジケースを 1 タグずつ閉じ込められるので追加修正がしやすい。
罠 1: prettify されたソース HTML の余白
入力 HTML が改行とインデント付きで整形されていると、ブロック要素同士の間に 空白だけのテキストノード ができます:
<h1>Title</h1>
<p>...</p>
そのまま walk すると # Title\n\n \n\n... のように空白行に「 」が紛れ込み、Markdown パーサが「インデント付きのコードブロック」と誤解する場合がある。
対策: テキストノード処理で 改行を含む空白だけのテキストはドロップ:
if (/^\s+$/.test(text) && /\n/.test(text)) return;
「改行を含む」という追加条件は重要で、これがないと <span>x</span> <span>y</span> の間の意図的なスペースまで消えてしまう。Pretty-print 由来の余白だけが改行を含む、という経験則です。
罠 2: ネストしたリストの indent
<ul><li>a<ul><li>b</li></ul></li></ul>
を CommonMark で書くと:
- a
- b
つまり内側の - b は 2 スペースインデント。ところが「外側 LI の continuation 行も自動でインデントする」「内側 UL は depth-1 から indent を始める」を両方やると 二重インデント になる。
このコンバータでは:
- 内側 UL は自分の depth に応じた
" ".repeat(depth)を自分でつける - 外側 LI の continuation 行には 追加インデントしない
たいていの「複数段落の LI」より「ネストしたリスト」の方が現実の Web で出現するので、後者を優先しました(CommonMark でも複数段落の LI は indent なしでもパースされる)。
罠 3: <thead> のないテーブルを GFM に変換する
GFM テーブルはヘッダ行が必須:
| h1 | h2 |
| --- | --- |
| a | b |
でも世の中の HTML テーブルの多くは <thead> なしで <tr><th> が直に書かれていたり、それすらなくて全部 <td> だったり。
対策: <th> を含む TR を見つけたらそれをヘッダ、なければ最初の TR をヘッダ扱いに昇格:
let headerRow = null;
const rows = [];
for (const tr of allTrs) {
const cells = ...;
const isHeader = Array.from(tr.children).some((c) => c.tagName === "TH");
if (isHeader && !headerRow) headerRow = cells;
else rows.push(cells);
}
if (!headerRow && rows.length > 0) headerRow = rows.shift();
これで「ヘッダなしで data だけのテーブル」も GFM になる(ただし最初の行がヘッダに昇格してしまうという trade-off は残る)。
同じコードを Node でテストする
コンバータが純粋関数なので、ブラウザを起動せずに Node で直接テストできます。DOM だけ供給すればいい:
import { test } from "node:test";
import { JSDOM } from "jsdom";
import "../html-to-md.js"; // side effect: globalThis.htmlToMarkdown を設定
const { document } = new JSDOM().window;
const md = (html) => {
document.body.innerHTML = html;
return globalThis.htmlToMarkdown(document.body);
};
test("nested ul indents inner list", () => {
assert.equal(md("<ul><li>a<ul><li>b</li></ul></li></ul>"), "- a\n - b\n");
});
35 ケースすべてを node --test で 0.3 秒で回せる。MV3 の lifecycle に絡む部分(service worker、popup、context menu)は手動 smoke test ですが、ロジックの 90% を占めるコンバータが Node で全網羅できるのは大きい。
まとめ
- MV3 で「全サイト対応」のために
<all_urls>は要らない。activeTab + scripting + contextMenusで十分。Chrome Web Store の審査もインストール警告も穏やかになる - service worker からの
chrome.scripting.executeScriptは 2 回呼ぶパターン(コンバータを files で注入 → 実行スクリプトを func で注入)が再利用しやすい - ポップアップ → service worker → tabs.query が「直前のアクティブタブ」を正しく取る経路。
onMessageの async はreturn true必須 - HTML→Markdown コンバータは タグディスパッチ表で書くと 230 行に収まる。罠は (1) prettify 余白、(2) ネストリストの二重 indent、(3) thead なしテーブルの header 昇格
- 純粋ロジックは jsdom +
node --testで 100% 網羅できる。ブラウザ起動なしで開発が回る
コード全文 — html-to-md.js がコンバータ、background.js が SW、tests/ に 35 ケース。MIT。
ホスト版 playground は拡張をインストールしなくてもブラウザで挙動を確認できるようにしてあります。
