0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Manifest V3 で『選択範囲を Markdown コピー』Chrome 拡張を 230 行で書く — host_permissions ゼロで活躍する activeTab + scripting パターン

0
Posted at

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 コンバータの実装ポイントが主題。

copy-as-md のホスト版 playground。左に HTML を貼ると右に GFM が即時生成される。下半分には対応する HTML タグの一覧。

🧩 Demo: https://sen.ltd/portfolio/copy-as-md/
📦 GitHub: https://github.com/sen-ltd/copy-as-md

なぜブラウザのコピー機能では足りないのか

Chrome の「コピー」は選択範囲を text/plaintext/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_permissionsweb_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("![", n.getAttribute("alt") || "", "](", n.getAttribute("src") || "", ")"),
  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.executeScript2 回呼ぶパターン(コンバータを 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 は拡張をインストールしなくてもブラウザで挙動を確認できるようにしてあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?