Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint:spell
- run: npm run lint:html
- run: npm test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.DS_Store
coverage/
.vitest-cache/
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ Most rich-text editors export Markdown full of artefacts: Notion's wrapping divs

- No install, no build, one HTML file
- Auto-copies output to your clipboard
- Headings, bold, italic, strikethrough, inline code, code blocks, blockquotes, links
- Ordered, unordered, and nested lists
- Checkboxes (Notion to-dos, Google Docs checklists)
- Headings (h1–h6), bold, italic, strikethrough, highlight, sub/superscript, inline code, code blocks (with language hints), blockquotes, links, images
- Ordered, unordered, and nested lists; checkboxes (Notion to-dos, Google Docs checklists)
- Tables (GitHub-flavoured Markdown)
- Horizontal rules
- Escapes Markdown-significant characters in your text so "5 \* 3" stays as "5 \\* 3" instead of becoming italic

## Privacy

100% client-side. The page is static HTML and JavaScript — your pasted content never leaves your browser.

## Run locally

Expand All @@ -21,7 +27,7 @@ git clone https://github.com/dewierwan/paste-to-markdown
open paste-to-markdown/index.html
```

That is the whole setup. No `npm install`.
That is the whole setup for using the site. No `npm install` needed.

## Contributing

Expand All @@ -30,14 +36,19 @@ All conversion logic lives in `index.html`, inside `convertNodeToMarkdown`. PRs
- New element types
- Better support for a specific source (Slack, Confluence, Linear, Quip)
- Whitespace and nesting edge cases
- Real-world HTML fixtures under `tests/fixtures/<source>/<scenario>.html` paired with `<scenario>.expected.md`

Before opening a PR:
Run the contributor checks before opening a PR:

```sh
npx cspell "**/*.{html,js,md,json}"
npx html-validate index.html
npm install
npm test
npm run lint:spell
npm run lint:html
```

`npm install` is contributor-only — end users still just open `index.html`.

## License

[MIT](LICENSE)
24 changes: 21 additions & 3 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,25 @@
"version": "0.2",
"language": "en",
"words": [
"Airtable"
"Airtable",
"artefacts",
"blockquotes",
"Confluence",
"cspell",
"fixturesDir",
"indentLevel",
"jsdom",
"markdownify",
"Pandoc",
"Prism",
"Quill",
"rowspan",
"strikethrough",
"Turndown",
"vitest"
],
"ignorePaths": []
}
"ignorePaths": [
"node_modules/**",
"package-lock.json"
]
}
72 changes: 68 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -323,14 +323,20 @@ <h3 class="section-label">Markdown output</h3>
function convertToMarkdown(htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
return convertNodeToMarkdown(tempDiv).trim();
return convertNodeToMarkdown(tempDiv).replace(/\n{3,}/g, '\n\n').trim();
}

// Escape characters that would otherwise be parsed as Markdown formatting.
// Conservative set: chars that are inline-meaningful in any position.
function escapeMarkdown(text) {
return text.replace(/([\\`*_\[\]~])/g, '\\$1');
}

// Recursively convert a node to Markdown.
function convertNodeToMarkdown(node, indentLevel = 0, listIndex = null) {
let markdown = "";
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
return escapeMarkdown(node.textContent);
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
switch (tag) {
Expand All @@ -340,6 +346,12 @@ <h3 class="section-label">Markdown output</h3>
return "## " + getInnerMarkdown(node, indentLevel) + "\n\n";
case 'h3':
return "### " + getInnerMarkdown(node, indentLevel) + "\n\n";
case 'h4':
return "#### " + getInnerMarkdown(node, indentLevel) + "\n\n";
case 'h5':
return "##### " + getInnerMarkdown(node, indentLevel) + "\n\n";
case 'h6':
return "###### " + getInnerMarkdown(node, indentLevel) + "\n\n";
case 'strong':
case 'b':
return "**" + getInnerMarkdown(node, indentLevel) + "**";
Expand All @@ -357,11 +369,27 @@ <h3 class="section-label">Markdown output</h3>
}
case 's':
case 'strike':
case 'del':
return "~~" + getInnerMarkdown(node, indentLevel) + "~~";
case 'mark':
return "<mark>" + getInnerMarkdown(node, indentLevel) + "</mark>";
case 'sub':
return "<sub>" + getInnerMarkdown(node, indentLevel) + "</sub>";
case 'sup':
return "<sup>" + getInnerMarkdown(node, indentLevel) + "</sup>";
case 'code':
return "`" + node.textContent + "`";
case 'pre':
return "```\n" + node.textContent + "\n```\n\n";
case 'pre': {
// Language hint: data-language (Notion) or class="language-xyz" (Prism)
let lang = node.getAttribute('data-language') || '';
if (!lang) {
const codeChild = node.querySelector('code');
const target = codeChild || node;
const langClass = Array.from(target.classList).find(c => c.startsWith('language-'));
if (langClass) lang = langClass.slice(9);
}
return "```" + lang + "\n" + node.textContent + "\n```\n\n";
}
case 'blockquote': {
const blockquoteContent = getInnerMarkdown(node, indentLevel);
return blockquoteContent.split('\n')
Expand Down Expand Up @@ -421,10 +449,46 @@ <h3 class="section-label">Markdown output</h3>
const href = node.getAttribute("href") || "";
return "[" + getInnerMarkdown(node, indentLevel) + "](" + href + ")";
}
case 'img': {
const src = node.getAttribute("src") || "";
const alt = node.getAttribute("alt") || "";
const title = node.getAttribute("title");
if (!src) return "";
return title ? "![" + alt + "](" + src + " \"" + title + "\")" : "![" + alt + "](" + src + ")";
}
case 'hr':
return "\n---\n\n";
case 'br':
return "\n";
case 'p':
return getInnerMarkdown(node, indentLevel) + "\n\n";
case 'table': {
const allRows = Array.from(node.querySelectorAll('tr'));
if (allRows.length === 0) return "";
const theadRows = Array.from(node.querySelectorAll('thead tr'));
let headerRow, bodyRows;
if (theadRows.length > 0) {
headerRow = theadRows[0];
bodyRows = allRows.filter(r => !theadRows.includes(r));
} else {
headerRow = allRows[0];
bodyRows = allRows.slice(1);
}
const cellsOf = (tr) => Array.from(tr.children).filter(c => /^t[hd]$/i.test(c.tagName));
const renderRow = (tr) => {
const cells = cellsOf(tr);
return "| " + cells.map(cell => {
const inner = getInnerMarkdown(cell, 0).trim().replace(/\|/g, '\\|').replace(/\n+/g, ' ');
return inner || ' ';
}).join(" | ") + " |";
};
const colCount = cellsOf(headerRow).length;
if (colCount === 0) return "";
let table = renderRow(headerRow) + "\n";
table += "|" + " --- |".repeat(colCount) + "\n";
bodyRows.forEach(tr => { table += renderRow(tr) + "\n"; });
return "\n" + table + "\n";
}
case 'span': {
let text = getInnerMarkdown(node, indentLevel);
// Check classes for formatting
Expand Down
Loading
Loading