BulkMD

Right-Click Copy as Markdown in Manifest V3

Build a right-click Copy as Markdown context menu in Manifest V3 — register it in onInstalled, branch on page versus selection, and copy from a service worker.

M. H. Tawfik13 min read

If you have ever wanted a single right-click that turns the page (or just the text you highlighted) into clean Markdown on your clipboard, the mechanics are less obvious than they look. A Manifest V3 service worker is where you register the menu, but it is the worst possible place to actually perform the copy — it has no document, no window, and no clipboard. This post walks through building a working right-click Copy as Markdown in Manifest V3 end to end: registering chrome.contextMenus correctly, distinguishing page context from selection context, and solving the part that trips most people up — writing to the clipboard when the only code holding the result runs in a service worker. It is the same capture path BulkMD uses for its context-menu shortcut.

We will assume you already have HTML-to-Markdown conversion available (Turndown is the usual choice). The focus here is the plumbing around it: the menu lifecycle, the two context modes, and three distinct strategies for getting text onto the clipboard from a worker that cannot touch it.

Where context menus actually live in Manifest V3

The first instinct is to register the menu the same place you register everything else — at the top of the service worker. That is wrong, and the failure mode is loud:

Unchecked runtime.lastError: Cannot create item with duplicate id copy-as-markdown

The reason is the worker lifecycle. A Manifest V3 service worker is a stateless event handler that Chrome starts, runs, and terminates on a roughly thirty-second idle timer. Top-level code runs every single time the worker wakes — which can be dozens of times an hour. chrome.contextMenus.create is not idempotent. The second call with the same ID throws.

Context menus are persisted by the browser, not by your worker. You create them once at install or update, and Chrome remembers them across every worker restart for the life of the install. The correct home is chrome.runtime.onInstalled:

// background.ts
const MENU_ID_PAGE = "copy-page-as-markdown";
const MENU_ID_SELECTION = "copy-selection-as-markdown";

chrome.runtime.onInstalled.addListener(() => {
  // removeAll first makes this safe to re-run on every update
  // without tracking which IDs already exist.
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({
      id: MENU_ID_PAGE,
      title: "Copy page as Markdown",
      contexts: ["page"],
    });

    chrome.contextMenus.create({
      id: MENU_ID_SELECTION,
      title: "Copy selection as Markdown",
      contexts: ["selection"],
    });
  });
});

Two things make this robust. The removeAll call means the handler is safe whether this is a fresh install or a version update — you never have to reason about which IDs survived from the previous version. And the listener is registered at the top level (the addListener call), while the create calls happen inside the callback. Registering listeners synchronously at the top level is itself a hard MV3 requirement: if you register a listener inside a promise or after an await, Chrome may unload the worker before the registration completes and the event is dropped. The companion piece on service-worker architecture for bulk processing goes deeper on why every listener must be attached synchronously.

The manifest itself needs the contextMenus permission, plus scripting and activeTab for the injection we will do shortly:

{
  "manifest_version": 3,
  "permissions": ["contextMenus", "scripting", "activeTab"],
  "background": { "service_worker": "background.js", "type": "module" }
}

Note what is not there: no broad "host_permissions": ["<all_urls>"]. activeTab grants the access we need at the moment the user invokes the menu, scoped to the tab they clicked in, with no install-time permission warning. Requesting host permissions for every site when activeTab covers the interaction is the kind of over-reach that gets extensions flagged in review.

Page context versus selection context

The contexts array on each menu item is the whole behavioral switch. It controls when the item appears and changes what data the click handler receives.

contexts valueWhen the item showsWhat you get in the click event
["page"]Right-click anywhere on the page backgroundtab only — you read the page yourself
["selection"]Right-click while text is highlightedinfo.selectionText (plain text of the selection)
["link"]Right-click on an anchorinfo.linkUrl
["image"]Right-click on an imageinfo.srcUrl
["all"]Every contextWhatever the underlying context provides

The trap is info.selectionText. It gives you the plain text of the selection, with all formatting stripped — no headings, no links, no emphasis. That is fine if you only want a quote, but it is useless for "copy this selected section as Markdown" because the structure you wanted to preserve is already gone by the time the string reaches your handler.

So the two menu items take genuinely different code paths:

  • Page context ignores info.selectionText entirely. It needs the page's DOM, which lives in the tab, so the work happens there.
  • Selection context also needs the DOM, because to produce Markdown from a selection you have to read the selection's HTML (window.getSelection().getRangeAt(0).cloneContents()), not its flattened text.

Both, in other words, end up doing the conversion inside the page. That is not a coincidence — it is the same constraint that forces the clipboard write into the page, which is the next problem. The selection path is also what powers sending just a highlighted selection to an LLM as Markdown, where preserving the selection's structure is what makes the resulting context usable.

Why the service worker cannot touch the clipboard

Here is the architectural fact that shapes everything: a Manifest V3 service worker runs in a worker context with no DOM. There is no document, no window, no document.execCommand, and — critically — navigator.clipboard is unavailable because the Clipboard API requires a document with focus and a transient user-activation signal. The worker has neither.

This is the same DOM-less constraint that makes HTML parsing awkward in MV3, and Chrome's own guidance is explicit: for operations that need DOM APIs, clipboard included, you do the work somewhere that has a document. You have two such places — the page itself (via injected script) and an offscreen document. For a user-initiated copy, the page is the better fit, because the user activation from their right-click can be carried into the injected code.

So the worker's job shrinks to coordination. It receives the click, figures out which menu fired, and injects a function into the active tab that does the reading, converting, and copying — all in one place that actually has a clipboard.

// background.ts
chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (!tab?.id) return;

  const mode = info.menuItemId === MENU_ID_SELECTION ? "selection" : "page";

  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: copyAsMarkdownInPage,
    args: [mode],
  });
});

The function passed to func is serialized and re-evaluated in the page's world, so it cannot close over anything from the worker — mode has to be passed through args. Programmatic injection like this also means the conversion code is only ever loaded when the user actually invokes the feature, not on every page load. The programmatic-injection pattern is the right default for any on-demand feature.

The in-page copy helper, with a fallback

Now the part that runs where a clipboard exists. The injected helper reads the right slice of the page, converts it, and writes it. The conversion is whatever serializer you bundle; below I represent it as htmlToMarkdown so the clipboard logic stays in focus.

// This function is injected into the page; it runs in the page's world.
function copyAsMarkdownInPage(mode: "page" | "selection") {
  // 1. Get the HTML for the chosen scope.
  let html: string;
  if (mode === "selection") {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
    const container = document.createElement("div");
    container.appendChild(sel.getRangeAt(0).cloneContents());
    html = container.innerHTML;
  } else {
    // For full page, prefer the main article element if present.
    const root =
      document.querySelector("article") ??
      document.querySelector("main") ??
      document.body;
    html = root.innerHTML;
  }

  // 2. Convert. Replace with your bundled serializer (e.g. Turndown).
  const markdown = htmlToMarkdown(html);

  // 3. Write to the clipboard, with a fallback for restricted contexts.
  async function writeClipboard(text: string): Promise<boolean> {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(text);
        return true;
      }
    } catch {
      // fall through to the legacy path
    }
    // Legacy fallback: hidden textarea + execCommand.
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    ta.style.pointerEvents = "none";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    let ok = false;
    try {
      ok = document.execCommand("copy");
    } catch {
      ok = false;
    }
    document.body.removeChild(ta);
    return ok;
  }

  writeClipboard(markdown).then((ok) => {
    if (!ok) console.warn("Copy as Markdown: clipboard write failed");
  });
}

Three details are doing real work here.

First, the selection branch reads HTML, not text. cloneContents() returns a DocumentFragment that preserves the structure of what the user highlighted — headings, lists, links — so the serializer has something to work with. Compare that to info.selectionText, which would have handed you a single flattened string.

Second, the navigator.clipboard path is the primary one and works on every modern secure-context page. It is asynchronous, returns a promise, and requires the page to be in a secure context (https: or localhost), which the window.isSecureContext check guards.

Third, the execCommand("copy") fallback exists for the cases where the modern API is blocked or absent. document.execCommand is deprecated, and you should not reach for it first — but it remains widely implemented precisely because so much code depends on it, and it works in a few situations the modern API does not, including some restricted or non-secure contexts. The hidden-textarea-then-execCommand dance is the conventional shape: create an off-screen textarea, select its contents, fire the copy command, and remove the element. Keeping it as a fallback rather than the default gives you the modern API's reliability with the old API's reach.

What this design refuses to do is route the text back to the worker to copy. There is no point: the worker still cannot write the clipboard, and shipping a potentially large Markdown string across the message boundary just to send it nowhere useful is wasted work. The copy completes entirely inside the tab.

When the page is off-limits

Injection fails on pages the extension is not allowed to touch — chrome:// pages, the Chrome Web Store, PDF viewers, and view-source:. chrome.scripting.executeScript rejects there, and there is no in-page clipboard to fall back to. If you need clipboard access in a context with no usable page, that is the one legitimate use for an offscreen document with the CLIPBOARD reason — a hidden extension-owned page that does have a document. For a right-click on a normal web page, though, injection is the simpler and more direct path, and it carries the user activation the clipboard write wants.

How BulkMD wires this into capture

BulkMD's context-menu shortcut is this exact pipeline, with two production hardenings. The menu is registered once in onInstalled and never re-touched. The injected helper uses the same Readability-plus-Turndown conversion the rest of the extension uses, so a right-click "Copy page as Markdown" produces output identical to what the popup would — the article body, no navigation chrome, no cookie banners, no footer link farms.

That equivalence is the point of doing the conversion in the page rather than scraping plain text. A right-click on a typical long-form article yields clean Markdown that runs roughly 60 to 80 percent fewer tokens than the raw page, because the boilerplate — sidebars, related-posts grids, share widgets — never makes it into the selection in the first place. On a heavily templated page, the reduction can reach the low nineties. BulkMD does all of this locally: the conversion and the clipboard write happen on your machine, no account, no server round-trip, no telemetry. The only feature that ever makes a network call is the optional AI summarize step, which is off by default and uses your own API key.

The result is that "I want this page as Markdown context for Claude" collapses into one right-click, and the text is on your clipboard before you have moved the mouse.

TL;DR

To build a right-click Copy as Markdown in Manifest V3: register the menu items inside chrome.runtime.onInstalled (call removeAll first so updates are safe), give each item the right contexts array, and in the click handler inject a function into the active tab with chrome.scripting.executeScript. Do the HTML read and the clipboard write inside the page — read selection HTML via cloneContents() for the selection item, prefer navigator.clipboard.writeText, and keep a hidden-textarea execCommand fallback for restricted contexts. The next step: wire your existing Turndown conversion into the injected helper, or install BulkMD from the Chrome Web Store and use the context-menu capture that already implements all of it.

Frequently asked questions

Why does my context menu throw a duplicate ID error?

Almost always because chrome.contextMenus.create is being called at the top level of the service worker. Top-level code runs every time the worker wakes, so the second wake re-creates a menu that already exists. Move creation into chrome.runtime.onInstalled, and call chrome.contextMenus.removeAll() first so it is safe across updates.

Can I call navigator.clipboard directly from the service worker?

No. A Manifest V3 service worker has no document and no window, and the Clipboard API requires a focused document and user activation. You must perform the write somewhere with a DOM — either inject a helper into the active tab (best for user-initiated copies) or use an offscreen document with the CLIPBOARD reason.

What is the difference between info.selectionText and reading the selection HTML?

info.selectionText is the plain text of the selection with all formatting removed, so headings, links, and lists are lost. To produce Markdown that preserves structure, read the selection's HTML inside the page using window.getSelection().getRangeAt(0).cloneContents(), then convert that HTML.

Do I still need the execCommand fallback in 2026?

It is no longer the primary path — navigator.clipboard.writeText works on every modern secure-context page. But execCommand("copy") still succeeds in a few situations the modern API does not, including some non-secure or restricted contexts, so keeping it as a fallback costs little and widens reach. Try the modern API first and fall back only on failure.

Why not send the converted Markdown back to the service worker to copy it?

Because the worker still cannot write the clipboard, so the round-trip accomplishes nothing and just ships a potentially large string across the message boundary. Convert and copy entirely inside the tab where a clipboard and the user-activation signal both exist.

About the author

M. H. Tawfik

Lead Developer & Owner

Working from Kushtia, Bangladesh.

Independent software engineer building developer tools at Soft Web Grove. Creator and maintainer of BulkMD.

Reach the team at [email protected] — typically within 24 hours, any day of the year. Soft Web Grove also takes a small number of outside engagements; details on the about page.

ShareXinHN
TaggedManifest V3Chrome extensionContent scriptMarkdown