← All posts

Action types

A one-click action that copies any page as clean Markdown

You read something worth keeping and you want it in your notes. Copy-pasting from the browser drags in the navigation menu, three ads, a cookie bar and a tangle of inline styles. What you actually want is clean Markdown: the heading, the paragraphs, the links, the code blocks — nothing else. This article builds a JustZix action button that does exactly that, in one click.

Why Markdown, and why an action button

Markdown is the universal note format — it pastes cleanly into Obsidian, Notion, GitHub issues, your editor, a plain text file. The goal here is a button in the JustZix action bar that converts either the current text selection or the whole article to Markdown and drops it on your clipboard. No DevTools, no extra app, no manual cleanup.

An action of type BUTTON is perfect for this: it shows a labeled button in the action bar, and clicking it runs your JavaScript. We will write that JavaScript now.

The conversion strategy

We walk the DOM recursively. For each node we decide: is it text (emit it), or an element we know how to convert (emit Markdown for it), or something to skip (nav, script, ads). Anything we do not recognize, we recurse into — so unknown wrappers do not lose their content. That recurse-by-default behavior is the fallback to plain text.

// Elements we never want in the output
const SKIP = new Set([
  'SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'ASIDE',
  'NOSCRIPT', 'IFRAME', 'FORM', 'BUTTON', 'SVG'
]);

function isHidden(el) {
  const s = getComputedStyle(el);
  return s.display === 'none' || s.visibility === 'hidden';
}

Converting inline elements

Inline conversion handles the run-of-text formatting: bold, italic, code, links. It returns a string, recursing into children so nested inline tags work.

function inline(node) {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent.replace(/\s+/g, ' ');
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return '';
  if (SKIP.has(node.tagName) || isHidden(node)) return '';

  const inner = [...node.childNodes].map(inline).join('');
  switch (node.tagName) {
    case 'STRONG': case 'B':   return '**' + inner.trim() + '**';
    case 'EM': case 'I':       return '*' + inner.trim() + '*';
    case 'CODE':               return '`' + inner.trim() + '`';
    case 'BR':                 return '  \n';
    case 'A': {
      const href = node.getAttribute('href') || '';
      const abs = href ? new URL(href, location.href).href : '';
      return abs ? '[' + inner.trim() + '](' + abs + ')' : inner;
    }
    default:                   return inner;
  }
}

Note the link handling: we resolve relative hrefs against location.href with the URL constructor, so a copied link still works when you paste it elsewhere.

Converting block elements

Block conversion handles the structural pieces — headings, paragraphs, lists, code blocks, blockquotes — and joins them with blank lines.

function block(node) {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent.trim();
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return '';
  if (SKIP.has(node.tagName) || isHidden(node)) return '';

  const t = node.tagName;

  if (/^H[1-6]$/.test(t)) {
    return '#'.repeat(+t[1]) + ' ' + inline(node).trim();
  }
  if (t === 'P') return inline(node).trim();
  if (t === 'BLOCKQUOTE') {
    return inline(node).trim()
      .split('\n').map(l => '> ' + l).join('\n');
  }
  if (t === 'PRE') {
    return '```\n' + node.textContent.replace(/\n$/, '') + '\n```';
  }
  if (t === 'UL' || t === 'OL') {
    const ordered = t === 'OL';
    return [...node.children]
      .filter(li => li.tagName === 'LI')
      .map((li, i) => (ordered ? (i + 1) + '. ' : '- ')
        + inline(li).trim())
      .join('\n');
  }
  if (t === 'HR') return '---';
  if (t === 'IMG') {
    const alt = node.getAttribute('alt') || '';
    const src = node.src || '';
    return src ? '![' + alt + '](' + src + ')' : '';
  }

  // Unknown wrapper: recurse, keep the children
  return [...node.childNodes].map(block)
    .filter(Boolean).join('\n\n');
}

The last line is the fallback: a <div>, <section> or <article> we have no special rule for just recurses into its children. Worst case, an exotic element degrades to its plain text — never to nothing.

Selection first, article second

The button should be smart: if you have text selected, convert just that; otherwise convert the main article. The Selection API gives us a range we can clone into a fragment.

function getRoot() {
  const sel = window.getSelection();
  if (sel && sel.rangeCount && !sel.isCollapsed) {
    const frag = sel.getRangeAt(0).cloneContents();
    const wrap = document.createElement('div');
    wrap.appendChild(frag);
    return wrap;
  }
  // No selection: best guess at the main content
  return document.querySelector(
    'article, main, [role="main"], .post, .content'
  ) || document.body;
}

A cloned selection fragment can start mid-element, so block conversion may see partial nodes — that is fine, our recurse-by-default rule handles it gracefully.

Wiring it to a BUTTON action and copying

Now assemble the pieces, build the final string, and write it to the clipboard. This is the body of your BUTTON action.

const root = getRoot();
const md = [...root.childNodes]
  .map(block)
  .filter(Boolean)
  .join('\n\n')
  .replace(/\n{3,}/g, '\n\n')   // collapse extra blank lines
  .trim();

navigator.clipboard.writeText(md)
  .then(() => JZ.toast('Copied ' + md.length + ' chars of Markdown'))
  .catch(() => {
    // Fallback for older clipboard restrictions
    const ta = document.createElement('textarea');
    ta.value = md;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    ta.remove();
    JZ.toast('Copied (fallback)');
  });

To turn this into a button: add an action of type BUTTON to the rule, label it "Copy as Markdown", and paste the code above as its handler. JZ.toast() is the JustZix helper for a quick on-page confirmation — handy because clipboard writes are otherwise silent.

Refinements worth adding

Why the clipboard, not a download

You could trigger a .md file download instead, but the clipboard wins for note-taking: you click the button, switch to your notes app, paste. No file to find, no rename, no cleanup. The navigator.clipboard.writeText call needs a user gesture — which a button click is, so it just works.

See also

One button, clean Markdown, zero cleanup. Install JustZix, add a BUTTON action with the code above, and start collecting the web the way your notes app actually wants it.

Rate this post

No ratings yet — be the first.

Try it yourself

Install JustZix and paste any snippet from this article. Two minutes from zero to a working rule across all your devices.

Get JustZix

Features · How it works · Examples · Use cases