clip2md: Paste-to-Markdown Tool (Turndown+GFM, Nginx, Coolify-ready)

- Smart Paste: HTML->Markdown, sonst roh; Cmd/Ctrl+Shift+V erzwingt roh
- Auto-Dateiname aus Titel + Datum, editierbar
- Speichern via Button / Cmd+Ctrl+S, Kopieren, Leeren, Zaehler
- Vendored Turndown 7.2.4 + turndown-plugin-gfm 1.0.2 (kein CDN)
- nginx:alpine Dockerfile + Healthcheck

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 05:21:40 +00:00
commit 6acb87a189
7 changed files with 1524 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.tgz
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
FROM nginx:1.27-alpine
# Static single-page app — no build step.
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
COPY vendor/ /usr/share/nginx/html/vendor/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
+51
View File
@@ -0,0 +1,51 @@
# clip2md — Paste to Markdown
Winziges, self-hosted Tool: langen Text (Reddit-Thread, Website, formatierter Content) reinpasten
→ ein Klick / `⌘/Strg+S` → als `.md` speichern. Dateiname wird automatisch aus Titel + Datum vorgeschlagen.
**Live:** https://clip2md.heidrich-digital.de
## Was es macht
- **Smart Paste:** Enthält die Zwischenablage formatiertes HTML (Copy aus dem Browser, z.B. Reddit
`⌘A``⌘C`), wird es automatisch über [Turndown](https://github.com/mixmark-io/turndown)
(+ GFM-Plugin für Tabellen/Strikethrough/Task-Lists) zu sauberem Markdown konvertiert.
Reiner Text ohne HTML bleibt 1:1 erhalten.
- **Roh erzwingen:** `⌘/Strg+⇧+V` fügt beim nächsten Paste bewusst als Klartext ein (keine Konvertierung).
- **Auto-Dateiname:** aus der ersten Überschrift/Zeile + Datum, z.B. `2026-08-11-mein-thread.md`.
Editierbar — sobald du selbst tippst, wird nicht mehr überschrieben.
- **Speichern:** Button oder `⌘/Strg+S` lädt die `.md` herunter (Browser-Download, kein Server-Roundtrip).
- **Kopieren / Leeren**, Wort- und Zeichenzähler.
Alles läuft **komplett clientseitig** — nichts wird an einen Server geschickt.
## Tech
- Statische `index.html` (inline CSS/JS, OKLCH-Design, Dark).
- Vendored: `vendor/turndown.js`, `vendor/turndown-plugin-gfm.js` (kein CDN, offline-fähig).
- Ausgeliefert via `nginx:alpine` (siehe `Dockerfile` + `nginx.conf`).
## Lokal starten
```bash
docker build -t clip2md .
docker run --rm -p 8080:80 clip2md
# → http://localhost:8080
```
Oder ganz ohne Docker: `index.html` einfach im Browser öffnen.
## Deployment
Deployt über **Coolify** (Dockerfile-Build) aus diesem Gitea-Repo, Domain via Cloudflare
auf `clip2md.heidrich-digital.de`. Details siehe Outline-Wiki (Tech & Stack → clip2md).
## Vendor-Libs aktualisieren
```bash
npm pack turndown turndown-plugin-gfm
# dist/turndown.js → vendor/turndown.js
# dist/turndown-plugin-gfm.js → vendor/turndown-plugin-gfm.js
```
Lizenz: MIT (Turndown ebenfalls MIT).
+456
View File
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Paste to Markdown — reinpasten, ein Klick, .md speichern." />
<title>clip2md — Paste to Markdown</title>
<style>
:root {
color-scheme: dark;
--bg: oklch(0.19 0.012 265);
--bg-2: oklch(0.235 0.014 265);
--bg-3: oklch(0.28 0.016 265);
--line: oklch(0.36 0.018 265);
--text: oklch(0.94 0.008 265);
--muted: oklch(0.66 0.014 265);
--accent: oklch(0.74 0.15 155);
--accent-ink:oklch(0.20 0.05 155);
--danger: oklch(0.68 0.15 25);
--radius: 12px;
--font-ui: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
display: flex;
flex-direction: column;
min-height: 100dvh;
}
header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
header h1 {
font-size: 17px;
font-weight: 650;
letter-spacing: -0.01em;
margin: 0;
}
header h1 .dot { color: var(--accent); }
header .sub {
font-size: 13px;
color: var(--muted);
}
header .spacer { flex: 1; }
header .hint {
font-size: 12px;
color: var(--muted);
font-family: var(--font-mono);
}
header .hint kbd {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 5px;
padding: 1px 6px;
font-size: 11px;
}
.bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: var(--bg-2);
flex-wrap: wrap;
}
.fname {
flex: 1;
min-width: 220px;
display: flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 4px 0 14px;
transition: border-color .15s;
}
.fname:focus-within { border-color: var(--accent); }
.fname .lbl {
font-size: 12px;
color: var(--muted);
margin-right: 10px;
white-space: nowrap;
}
.fname input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 14px;
padding: 11px 0;
}
.fname .ext {
font-family: var(--font-mono);
font-size: 14px;
color: var(--muted);
padding: 0 12px;
}
button {
font-family: var(--font-ui);
font-size: 14px;
font-weight: 550;
border: 1px solid var(--line);
background: var(--bg-3);
color: var(--text);
border-radius: var(--radius);
padding: 11px 16px;
cursor: pointer;
transition: background .15s, border-color .15s, transform .05s;
white-space: nowrap;
}
button:hover { background: var(--line); }
button:active { transform: translateY(1px); }
button.primary {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
font-weight: 650;
}
button.primary:hover { filter: brightness(1.06); background: var(--accent); }
button.ghost { background: transparent; }
button.ghost:hover { background: var(--bg-3); }
main {
flex: 1;
display: flex;
min-height: 0;
padding: 16px 22px;
}
#editor {
flex: 1;
width: 100%;
resize: none;
background: var(--bg-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 20px;
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.65;
outline: none;
tab-size: 2;
transition: border-color .15s;
}
#editor:focus { border-color: var(--accent); }
#editor::placeholder { color: var(--muted); }
footer {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 22px 14px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--muted);
flex-wrap: wrap;
}
footer .spacer { flex: 1; }
footer .count { font-family: var(--font-mono); }
footer .src { color: var(--accent); font-weight: 600; }
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(20px);
background: var(--bg-3);
border: 1px solid var(--line);
color: var(--text);
padding: 11px 18px;
border-radius: 10px;
font-size: 13.5px;
opacity: 0;
pointer-events: none;
transition: opacity .2s, transform .2s;
box-shadow: 0 8px 30px oklch(0 0 0 / 0.4);
z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.err { border-color: var(--danger); }
@media (max-width: 640px) {
header, .bar, main, footer { padding-left: 14px; padding-right: 14px; }
header .hint { display: none; }
.bar { gap: 8px; }
button { padding: 11px 13px; }
}
</style>
</head>
<body>
<header>
<h1>clip<span class="dot">2</span>md</h1>
<span class="sub">Reinpasten → ein Klick → als <code>.md</code> speichern.</span>
<span class="spacer"></span>
<span class="hint">Speichern <kbd>⌘/Strg</kbd> + <kbd>S</kbd> · Roh einfügen <kbd>⌘/Strg</kbd> + <kbd></kbd> + <kbd>V</kbd></span>
</header>
<div class="bar">
<label class="fname">
<span class="lbl">Dateiname</span>
<input id="filename" type="text" spellcheck="false" autocomplete="off" aria-label="Dateiname" />
<span class="ext">.md</span>
</label>
<button id="save" class="primary">Speichern</button>
<button id="copy" class="ghost">Kopieren</button>
<button id="clear" class="ghost">Leeren</button>
</div>
<main>
<textarea id="editor" spellcheck="false" autocapitalize="off" autocorrect="off"
placeholder="Hier reinpasten — ein langer Reddit-Thread, eine Website, formatierter Text…
Formatiertes HTML (Copy aus dem Browser) wird automatisch zu sauberem Markdown. Reiner Text bleibt wie er ist.
Dann ⌘/Strg+S oder „Speichern“."></textarea>
</main>
<footer>
<span class="count"><span id="words">0</span> Wörter · <span id="chars">0</span> Zeichen</span>
<span id="srcInfo" class="src"></span>
<span class="spacer"></span>
<span>clip2md · Heidrich Digital</span>
</footer>
<div id="toast" class="toast"></div>
<script src="vendor/turndown.js"></script>
<script src="vendor/turndown-plugin-gfm.js"></script>
<script>
(function () {
"use strict";
// ---- Turndown setup -------------------------------------------------
var turndown = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "*",
strongDelimiter: "**",
linkStyle: "inlined"
});
try {
if (window.turndownPluginGfm && window.turndownPluginGfm.gfm) {
turndown.use(window.turndownPluginGfm.gfm);
}
} catch (e) { /* gfm optional */ }
// Drop noise that clutters pasted web pages.
turndown.remove(["script", "style", "noscript", "iframe", "svg"]);
var editor = document.getElementById("editor");
var filename = document.getElementById("filename");
var wordsEl = document.getElementById("words");
var charsEl = document.getElementById("chars");
var srcInfo = document.getElementById("srcInfo");
var toastEl = document.getElementById("toast");
var filenameEdited = false; // becomes true once the user types in the field
var toastTimer = null;
// ---- Helpers --------------------------------------------------------
function toast(msg, isErr) {
toastEl.textContent = msg;
toastEl.classList.toggle("err", !!isErr);
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2200);
}
function pad(n) { return n < 10 ? "0" + n : "" + n; }
function today() {
var d = new Date();
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
}
var UMLAUT = { "ä":"ae","ö":"oe","ü":"ue","ß":"ss","Ä":"ae","Ö":"oe","Ü":"ue" };
function slugify(s) {
if (!s) return "";
s = s.replace(/[äöüßÄÖÜ]/g, function (c) { return UMLAUT[c] || c; });
s = s.normalize("NFKD").replace(/[̀-ͯ]/g, ""); // strip accents
s = s.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s.slice(0, 60).replace(/-+$/g, "");
}
function deriveTitle(md) {
var lines = md.split(/\r?\n/);
// Prefer the first ATX heading.
for (var i = 0; i < lines.length; i++) {
var m = lines[i].match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
if (m && m[1].trim()) return m[1].trim();
}
// Otherwise the first non-empty, non-decorative line.
for (var j = 0; j < lines.length; j++) {
var t = lines[j].trim();
if (!t) continue;
if (/^([-*_=#>`|]{1,}|\[.*\])$/.test(t)) continue;
return t.replace(/^[#>*\-\s]+/, "").trim();
}
return "";
}
function computeFilename(md) {
var slug = slugify(deriveTitle(md));
return today() + (slug ? "-" + slug : "-clip");
}
function refreshFilename() {
if (filenameEdited) return;
filename.value = computeFilename(editor.value);
}
function updateCounts() {
var v = editor.value;
charsEl.textContent = v.length.toLocaleString("de-DE");
var w = v.trim() ? v.trim().split(/\s+/).length : 0;
wordsEl.textContent = w.toLocaleString("de-DE");
}
function refreshAll() { updateCounts(); refreshFilename(); }
// ---- Smart paste ----------------------------------------------------
// Cmd/Ctrl+Shift+V forces a raw (plain-text) paste.
var forceRaw = false;
editor.addEventListener("paste", function (e) {
var cd = e.clipboardData || window.clipboardData;
if (!cd) return; // let the browser handle it
var html = cd.getData("text/html");
var text = cd.getData("text/plain");
var out, source;
if (!forceRaw && html && html.trim()) {
try {
out = turndown.turndown(html).replace(/\n{3,}/g, "\n\n").trim();
source = "HTML → Markdown konvertiert";
} catch (err) {
out = text; source = "Roh eingefügt (Konvertierung fehlgeschlagen)";
}
} else {
out = text;
source = forceRaw ? "Roh eingefügt (erzwungen)" : "Roh eingefügt (kein HTML)";
}
forceRaw = false;
if (out == null) return;
e.preventDefault();
insertAtCursor(out);
srcInfo.textContent = source;
refreshAll();
toast(source);
});
function insertAtCursor(str) {
var start = editor.selectionStart, end = editor.selectionEnd;
var before = editor.value.slice(0, start);
var after = editor.value.slice(end);
editor.value = before + str + after;
var pos = start + str.length;
editor.selectionStart = editor.selectionEnd = pos;
}
editor.addEventListener("input", function () {
srcInfo.textContent = "";
refreshAll();
});
// ---- Filename field -------------------------------------------------
filename.addEventListener("input", function () {
filenameEdited = filename.value.trim().length > 0;
});
// ---- Save -----------------------------------------------------------
function save() {
var content = editor.value;
if (!content.trim()) { toast("Nichts zu speichern.", true); return; }
var name = (filename.value.trim() || computeFilename(content));
name = name.replace(/[\/\\:*?"<>|]+/g, "-").replace(/\s+/g, "-");
if (!/\.md$/i.test(name)) name += ".md";
var blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
toast("Gespeichert: " + name);
}
document.getElementById("save").addEventListener("click", save);
// ---- Copy -----------------------------------------------------------
document.getElementById("copy").addEventListener("click", function () {
var v = editor.value;
if (!v.trim()) { toast("Nichts zu kopieren.", true); return; }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(v).then(
function () { toast("Markdown kopiert."); },
function () { legacyCopy(v); }
);
} else { legacyCopy(v); }
});
function legacyCopy(v) {
editor.focus(); editor.select();
try { document.execCommand("copy"); toast("Markdown kopiert."); }
catch (e) { toast("Kopieren nicht möglich.", true); }
editor.selectionStart = editor.selectionEnd = editor.value.length;
}
// ---- Clear ----------------------------------------------------------
document.getElementById("clear").addEventListener("click", function () {
if (editor.value && !confirm("Editor wirklich leeren?")) return;
editor.value = "";
filenameEdited = false;
srcInfo.textContent = "";
refreshAll();
editor.focus();
});
// ---- Keyboard -------------------------------------------------------
document.addEventListener("keydown", function (e) {
var mod = e.metaKey || e.ctrlKey;
if (mod && !e.shiftKey && !e.altKey && (e.key === "s" || e.key === "S")) {
e.preventDefault(); save(); return;
}
// Arm a raw paste for the next paste event.
if (mod && e.shiftKey && (e.key === "v" || e.key === "V")) {
forceRaw = true; // paste handler consumes and resets it
}
});
// ---- Init -----------------------------------------------------------
refreshAll();
editor.focus();
})();
</script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip for the text assets.
gzip on;
gzip_types text/html text/css application/javascript application/json image/svg+xml;
gzip_min_length 512;
# Lightweight health endpoint for Coolify / Docker healthcheck.
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# Vendor libs can be cached hard.
location /vendor/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Never cache the app shell, so updates land immediately.
location = /index.html {
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
}
# Minimal hardening headers.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
}
+165
View File
@@ -0,0 +1,165 @@
var turndownPluginGfm = (function (exports) {
'use strict';
var highlightRegExp = /highlight-(?:text|source)-([a-z0-9]+)/;
function highlightedCodeBlock (turndownService) {
turndownService.addRule('highlightedCodeBlock', {
filter: function (node) {
var firstChild = node.firstChild;
return (
node.nodeName === 'DIV' &&
highlightRegExp.test(node.className) &&
firstChild &&
firstChild.nodeName === 'PRE'
)
},
replacement: function (content, node, options) {
var className = node.className || '';
var language = (className.match(highlightRegExp) || [null, ''])[1];
return (
'\n\n' + options.fence + language + '\n' +
node.firstChild.textContent +
'\n' + options.fence + '\n\n'
)
}
});
}
function strikethrough (turndownService) {
turndownService.addRule('strikethrough', {
filter: ['del', 's', 'strike'],
replacement: function (content) {
return '~' + content + '~'
}
});
}
var indexOf = Array.prototype.indexOf;
var every = Array.prototype.every;
var rules = {};
rules.tableCell = {
filter: ['th', 'td'],
replacement: function (content, node) {
return cell(content, node)
}
};
rules.tableRow = {
filter: 'tr',
replacement: function (content, node) {
var borderCells = '';
var alignMap = { left: ':--', right: '--:', center: ':-:' };
if (isHeadingRow(node)) {
for (var i = 0; i < node.childNodes.length; i++) {
var border = '---';
var align = (
node.childNodes[i].getAttribute('align') || ''
).toLowerCase();
if (align) border = alignMap[align] || border;
borderCells += cell(border, node.childNodes[i]);
}
}
return '\n' + content + (borderCells ? '\n' + borderCells : '')
}
};
rules.table = {
// Only convert tables with a heading row.
// Tables with no heading row are kept using `keep` (see below).
filter: function (node) {
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
},
replacement: function (content) {
// Ensure there are no blank lines
content = content.replace('\n\n', '\n');
return '\n\n' + content + '\n\n'
}
};
rules.tableSection = {
filter: ['thead', 'tbody', 'tfoot'],
replacement: function (content) {
return content
}
};
// A tr is a heading row if:
// - the parent is a THEAD
// - or if its the first child of the TABLE or the first TBODY (possibly
// following a blank THEAD)
// - and every cell is a TH
function isHeadingRow (tr) {
var parentNode = tr.parentNode;
return (
parentNode.nodeName === 'THEAD' ||
(
parentNode.firstChild === tr &&
(parentNode.nodeName === 'TABLE' || isFirstTbody(parentNode)) &&
every.call(tr.childNodes, function (n) { return n.nodeName === 'TH' })
)
)
}
function isFirstTbody (element) {
var previousSibling = element.previousSibling;
return (
element.nodeName === 'TBODY' && (
!previousSibling ||
(
previousSibling.nodeName === 'THEAD' &&
/^\s*$/i.test(previousSibling.textContent)
)
)
)
}
function cell (content, node) {
var index = indexOf.call(node.parentNode.childNodes, node);
var prefix = ' ';
if (index === 0) prefix = '| ';
return prefix + content + ' |'
}
function tables (turndownService) {
turndownService.keep(function (node) {
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
});
for (var key in rules) turndownService.addRule(key, rules[key]);
}
function taskListItems (turndownService) {
turndownService.addRule('taskListItems', {
filter: function (node) {
return node.type === 'checkbox' && node.parentNode.nodeName === 'LI'
},
replacement: function (content, node) {
return (node.checked ? '[x]' : '[ ]') + ' '
}
});
}
function gfm (turndownService) {
turndownService.use([
highlightedCodeBlock,
strikethrough,
tables,
taskListItems
]);
}
exports.gfm = gfm;
exports.highlightedCodeBlock = highlightedCodeBlock;
exports.strikethrough = strikethrough;
exports.tables = tables;
exports.taskListItems = taskListItems;
return exports;
}({}));
+800
View File
@@ -0,0 +1,800 @@
var TurndownService = (function () {
'use strict';
function extend(destination) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) destination[key] = source[key];
}
}
return destination;
}
function repeat(character, count) {
return Array(count + 1).join(character);
}
function trimLeadingNewlines(string) {
return string.replace(/^\n*/, '');
}
function trimTrailingNewlines(string) {
// avoid match-at-end regexp bottleneck, see #370
var indexEnd = string.length;
while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--;
return string.substring(0, indexEnd);
}
function trimNewlines(string) {
return trimTrailingNewlines(trimLeadingNewlines(string));
}
var blockElements = ['ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL'];
function isBlock(node) {
return is(node, blockElements);
}
var voidElements = ['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR'];
function isVoid(node) {
return is(node, voidElements);
}
function hasVoid(node) {
return has(node, voidElements);
}
var meaningfulWhenBlankElements = ['A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', 'AUDIO', 'VIDEO'];
function isMeaningfulWhenBlank(node) {
return is(node, meaningfulWhenBlankElements);
}
function hasMeaningfulWhenBlank(node) {
return has(node, meaningfulWhenBlankElements);
}
function is(node, tagNames) {
return tagNames.indexOf(node.nodeName) >= 0;
}
function has(node, tagNames) {
return node.getElementsByTagName && tagNames.some(function (tagName) {
return node.getElementsByTagName(tagName).length;
});
}
var markdownEscapes = [[/\\/g, '\\\\'], [/\*/g, '\\*'], [/^-/g, '\\-'], [/^\+ /g, '\\+ '], [/^(=+)/g, '\\$1'], [/^(#{1,6}) /g, '\\$1 '], [/`/g, '\\`'], [/^~~~/g, '\\~~~'], [/\[/g, '\\['], [/\]/g, '\\]'], [/^>/g, '\\>'], [/_/g, '\\_'], [/^(\d+)\. /g, '$1\\. ']];
function escapeMarkdown(string) {
return markdownEscapes.reduce(function (accumulator, escape) {
return accumulator.replace(escape[0], escape[1]);
}, string);
}
var rules = {};
rules.paragraph = {
filter: 'p',
replacement: function (content) {
return '\n\n' + content + '\n\n';
}
};
rules.lineBreak = {
filter: 'br',
replacement: function (content, node, options) {
return options.br + '\n';
}
};
rules.heading = {
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
replacement: function (content, node, options) {
var hLevel = Number(node.nodeName.charAt(1));
if (options.headingStyle === 'setext' && hLevel < 3) {
var underline = repeat(hLevel === 1 ? '=' : '-', content.length);
return '\n\n' + content + '\n' + underline + '\n\n';
} else {
return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n';
}
}
};
rules.blockquote = {
filter: 'blockquote',
replacement: function (content) {
content = trimNewlines(content).replace(/^/gm, '> ');
return '\n\n' + content + '\n\n';
}
};
rules.list = {
filter: ['ul', 'ol'],
replacement: function (content, node) {
var parent = node.parentNode;
if (parent.nodeName === 'LI' && parent.lastElementChild === node) {
return '\n' + content;
} else {
return '\n\n' + content + '\n\n';
}
}
};
rules.listItem = {
filter: 'li',
replacement: function (content, node, options) {
var prefix = options.bulletListMarker + ' ';
var parent = node.parentNode;
if (parent.nodeName === 'OL') {
var start = parent.getAttribute('start');
var index = Array.prototype.indexOf.call(parent.children, node);
prefix = (start ? Number(start) + index : index + 1) + '. ';
}
var isParagraph = /\n$/.test(content);
content = trimNewlines(content) + (isParagraph ? '\n' : '');
content = content.replace(/\n/gm, '\n' + ' '.repeat(prefix.length)); // indent
return prefix + content + (node.nextSibling ? '\n' : '');
}
};
rules.indentedCodeBlock = {
filter: function (node, options) {
return options.codeBlockStyle === 'indented' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
},
replacement: function (content, node, options) {
return '\n\n ' + node.firstChild.textContent.replace(/\n/g, '\n ') + '\n\n';
}
};
rules.fencedCodeBlock = {
filter: function (node, options) {
return options.codeBlockStyle === 'fenced' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE';
},
replacement: function (content, node, options) {
var className = node.firstChild.getAttribute('class') || '';
var language = (className.match(/language-(\S+)/) || [null, ''])[1];
var code = node.firstChild.textContent;
var fenceChar = options.fence.charAt(0);
var fenceSize = 3;
var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');
var match;
while (match = fenceInCodeRegex.exec(code)) {
if (match[0].length >= fenceSize) {
fenceSize = match[0].length + 1;
}
}
var fence = repeat(fenceChar, fenceSize);
return '\n\n' + fence + language + '\n' + code.replace(/\n$/, '') + '\n' + fence + '\n\n';
}
};
rules.horizontalRule = {
filter: 'hr',
replacement: function (content, node, options) {
return '\n\n' + options.hr + '\n\n';
}
};
rules.inlineLink = {
filter: function (node, options) {
return options.linkStyle === 'inlined' && node.nodeName === 'A' && node.getAttribute('href');
},
replacement: function (content, node) {
var href = escapeLinkDestination(node.getAttribute('href'));
var title = escapeLinkTitle(cleanAttribute(node.getAttribute('title')));
var titlePart = title ? ' "' + title + '"' : '';
return '[' + content + '](' + href + titlePart + ')';
}
};
rules.referenceLink = {
filter: function (node, options) {
return options.linkStyle === 'referenced' && node.nodeName === 'A' && node.getAttribute('href');
},
replacement: function (content, node, options) {
var href = escapeLinkDestination(node.getAttribute('href'));
var title = cleanAttribute(node.getAttribute('title'));
if (title) title = ' "' + escapeLinkTitle(title) + '"';
var replacement;
var reference;
switch (options.linkReferenceStyle) {
case 'collapsed':
replacement = '[' + content + '][]';
reference = '[' + content + ']: ' + href + title;
break;
case 'shortcut':
replacement = '[' + content + ']';
reference = '[' + content + ']: ' + href + title;
break;
default:
var id = this.references.length + 1;
replacement = '[' + content + '][' + id + ']';
reference = '[' + id + ']: ' + href + title;
}
this.references.push(reference);
return replacement;
},
references: [],
append: function (options) {
var references = '';
if (this.references.length) {
references = '\n\n' + this.references.join('\n') + '\n\n';
this.references = []; // Reset references
}
return references;
}
};
rules.emphasis = {
filter: ['em', 'i'],
replacement: function (content, node, options) {
if (!content.trim()) return '';
return options.emDelimiter + content + options.emDelimiter;
}
};
rules.strong = {
filter: ['strong', 'b'],
replacement: function (content, node, options) {
if (!content.trim()) return '';
return options.strongDelimiter + content + options.strongDelimiter;
}
};
rules.code = {
filter: function (node) {
var hasSiblings = node.previousSibling || node.nextSibling;
var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings;
return node.nodeName === 'CODE' && !isCodeBlock;
},
replacement: function (content) {
if (!content) return '';
content = content.replace(/\r?\n|\r/g, ' ');
var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : '';
var delimiter = '`';
var matches = content.match(/`+/gm) || [];
while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`';
return delimiter + extraSpace + content + extraSpace + delimiter;
}
};
rules.image = {
filter: 'img',
replacement: function (content, node) {
var alt = escapeMarkdown(cleanAttribute(node.getAttribute('alt')));
var src = escapeLinkDestination(node.getAttribute('src') || '');
var title = cleanAttribute(node.getAttribute('title'));
var titlePart = title ? ' "' + escapeLinkTitle(title) + '"' : '';
return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '';
}
};
function cleanAttribute(attribute) {
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
}
function escapeLinkDestination(destination) {
var escaped = destination.replace(/([<>()])/g, '\\$1');
return escaped.indexOf(' ') >= 0 ? '<' + escaped + '>' : escaped;
}
function escapeLinkTitle(title) {
return title.replace(/"/g, '\\"');
}
/**
* Manages a collection of rules used to convert HTML to Markdown
*/
function Rules(options) {
this.options = options;
this._keep = [];
this._remove = [];
this.blankRule = {
replacement: options.blankReplacement
};
this.keepReplacement = options.keepReplacement;
this.defaultRule = {
replacement: options.defaultReplacement
};
this.array = [];
for (var key in options.rules) this.array.push(options.rules[key]);
}
Rules.prototype = {
add: function (key, rule) {
this.array.unshift(rule);
},
keep: function (filter) {
this._keep.unshift({
filter: filter,
replacement: this.keepReplacement
});
},
remove: function (filter) {
this._remove.unshift({
filter: filter,
replacement: function () {
return '';
}
});
},
forNode: function (node) {
if (node.isBlank) return this.blankRule;
var rule;
if (rule = findRule(this.array, node, this.options)) return rule;
if (rule = findRule(this._keep, node, this.options)) return rule;
if (rule = findRule(this._remove, node, this.options)) return rule;
return this.defaultRule;
},
forEach: function (fn) {
for (var i = 0; i < this.array.length; i++) fn(this.array[i], i);
}
};
function findRule(rules, node, options) {
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
if (filterValue(rule, node, options)) return rule;
}
return undefined;
}
function filterValue(rule, node, options) {
var filter = rule.filter;
if (typeof filter === 'string') {
if (filter === node.nodeName.toLowerCase()) return true;
} else if (Array.isArray(filter)) {
if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true;
} else if (typeof filter === 'function') {
if (filter.call(rule, node, options)) return true;
} else {
throw new TypeError('`filter` needs to be a string, array, or function');
}
}
/**
* The collapseWhitespace function is adapted from collapse-whitespace
* by Luc Thevenard.
*
* The MIT License (MIT)
*
* Copyright (c) 2014 Luc Thevenard <lucthevenard@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* collapseWhitespace(options) removes extraneous whitespace from an the given element.
*
* @param {Object} options
*/
function collapseWhitespace(options) {
var element = options.element;
var isBlock = options.isBlock;
var isVoid = options.isVoid;
var isPre = options.isPre || function (node) {
return node.nodeName === 'PRE';
};
if (!element.firstChild || isPre(element)) return;
var prevText = null;
var keepLeadingWs = false;
var prev = null;
var node = next(prev, element, isPre);
while (node !== element) {
if (node.nodeType === 3 || node.nodeType === 4) {
// Node.TEXT_NODE or Node.CDATA_SECTION_NODE
var text = node.data.replace(/[ \r\n\t]+/g, ' ');
if ((!prevText || / $/.test(prevText.data)) && !keepLeadingWs && text[0] === ' ') {
text = text.substr(1);
}
// `text` might be empty at this point.
if (!text) {
node = remove(node);
continue;
}
node.data = text;
prevText = node;
} else if (node.nodeType === 1) {
// Node.ELEMENT_NODE
if (isBlock(node) || node.nodeName === 'BR') {
if (prevText) {
prevText.data = prevText.data.replace(/ $/, '');
}
prevText = null;
keepLeadingWs = false;
} else if (isVoid(node) || isPre(node)) {
// Avoid trimming space around non-block, non-BR void elements and inline PRE.
prevText = null;
keepLeadingWs = true;
} else if (prevText) {
// Drop protection if set previously.
keepLeadingWs = false;
}
} else {
node = remove(node);
continue;
}
var nextNode = next(prev, node, isPre);
prev = node;
node = nextNode;
}
if (prevText) {
prevText.data = prevText.data.replace(/ $/, '');
if (!prevText.data) {
remove(prevText);
}
}
}
/**
* remove(node) removes the given node from the DOM and returns the
* next node in the sequence.
*
* @param {Node} node
* @return {Node} node
*/
function remove(node) {
var next = node.nextSibling || node.parentNode;
node.parentNode.removeChild(node);
return next;
}
/**
* next(prev, current, isPre) returns the next node in the sequence, given the
* current and previous nodes.
*
* @param {Node} prev
* @param {Node} current
* @param {Function} isPre
* @return {Node}
*/
function next(prev, current, isPre) {
if (prev && prev.parentNode === current || isPre(current)) {
return current.nextSibling || current.parentNode;
}
return current.firstChild || current.nextSibling || current.parentNode;
}
/*
* Set up window for Node.js
*/
var root = typeof window !== 'undefined' ? window : {};
/*
* Parsing HTML strings
*/
function canParseHTMLNatively() {
var Parser = root.DOMParser;
var canParse = false;
// Adapted from https://gist.github.com/1129031
// Firefox/Opera/IE throw errors on unsupported types
try {
// WebKit returns null on unsupported types
if (new Parser().parseFromString('', 'text/html')) {
canParse = true;
}
} catch (e) {}
return canParse;
}
function createHTMLParser() {
var Parser = function () {};
{
if (shouldUseActiveX()) {
Parser.prototype.parseFromString = function (string) {
var doc = new window.ActiveXObject('htmlfile');
doc.designMode = 'on'; // disable on-page scripts
doc.open();
doc.write(string);
doc.close();
return doc;
};
} else {
Parser.prototype.parseFromString = function (string) {
var doc = document.implementation.createHTMLDocument('');
doc.open();
doc.write(string);
doc.close();
return doc;
};
}
}
return Parser;
}
function shouldUseActiveX() {
var useActiveX = false;
try {
document.implementation.createHTMLDocument('').open();
} catch (e) {
if (root.ActiveXObject) useActiveX = true;
}
return useActiveX;
}
var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser();
function RootNode(input, options) {
var root;
if (typeof input === 'string') {
var doc = htmlParser().parseFromString(
// DOM parsers arrange elements in the <head> and <body>.
// Wrapping in a custom element ensures elements are reliably arranged in
// a single element.
'<x-turndown id="turndown-root">' + input + '</x-turndown>', 'text/html');
root = doc.getElementById('turndown-root');
} else {
root = input.cloneNode(true);
}
collapseWhitespace({
element: root,
isBlock: isBlock,
isVoid: isVoid,
isPre: options.preformattedCode ? isPreOrCode : null
});
return root;
}
var _htmlParser;
function htmlParser() {
_htmlParser = _htmlParser || new HTMLParser();
return _htmlParser;
}
function isPreOrCode(node) {
return node.nodeName === 'PRE' || node.nodeName === 'CODE';
}
function Node(node, options) {
node.isBlock = isBlock(node);
node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode;
node.isBlank = isBlank(node);
node.flankingWhitespace = flankingWhitespace(node, options);
return node;
}
function isBlank(node) {
return !isVoid(node) && !isMeaningfulWhenBlank(node) && /^\s*$/i.test(node.textContent) && !hasVoid(node) && !hasMeaningfulWhenBlank(node);
}
function flankingWhitespace(node, options) {
if (node.isBlock || options.preformattedCode && node.isCode) {
return {
leading: '',
trailing: ''
};
}
var edges = edgeWhitespace(node.textContent);
// abandon leading ASCII WS if left-flanked by ASCII WS
if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) {
edges.leading = edges.leadingNonAscii;
}
// abandon trailing ASCII WS if right-flanked by ASCII WS
if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) {
edges.trailing = edges.trailingNonAscii;
}
return {
leading: edges.leading,
trailing: edges.trailing
};
}
function edgeWhitespace(string) {
var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/);
return {
leading: m[1],
// whole string for whitespace-only strings
leadingAscii: m[2],
leadingNonAscii: m[3],
trailing: m[4],
// empty for whitespace-only strings
trailingNonAscii: m[5],
trailingAscii: m[6]
};
}
function isFlankedByWhitespace(side, node, options) {
var sibling;
var regExp;
var isFlanked;
if (side === 'left') {
sibling = node.previousSibling;
regExp = / $/;
} else {
sibling = node.nextSibling;
regExp = /^ /;
}
if (sibling) {
if (sibling.nodeType === 3) {
isFlanked = regExp.test(sibling.nodeValue);
} else if (options.preformattedCode && sibling.nodeName === 'CODE') {
isFlanked = false;
} else if (sibling.nodeType === 1 && !isBlock(sibling)) {
isFlanked = regExp.test(sibling.textContent);
}
}
return isFlanked;
}
var reduce = Array.prototype.reduce;
function TurndownService(options) {
if (!(this instanceof TurndownService)) return new TurndownService(options);
var defaults = {
rules: rules,
headingStyle: 'setext',
hr: '* * *',
bulletListMarker: '*',
codeBlockStyle: 'indented',
fence: '```',
emDelimiter: '_',
strongDelimiter: '**',
linkStyle: 'inlined',
linkReferenceStyle: 'full',
br: ' ',
preformattedCode: false,
blankReplacement: function (content, node) {
return node.isBlock ? '\n\n' : '';
},
keepReplacement: function (content, node) {
return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML;
},
defaultReplacement: function (content, node) {
return node.isBlock ? '\n\n' + content + '\n\n' : content;
}
};
this.options = extend({}, defaults, options);
this.rules = new Rules(this.options);
}
TurndownService.prototype = {
/**
* The entry point for converting a string or DOM node to Markdown
* @public
* @param {String|HTMLElement} input The string or DOM node to convert
* @returns A Markdown representation of the input
* @type String
*/
turndown: function (input) {
if (!canConvert(input)) {
throw new TypeError(input + ' is not a string, or an element/document/fragment node.');
}
if (input === '') return '';
var output = process.call(this, new RootNode(input, this.options));
return postProcess.call(this, output);
},
/**
* Add one or more plugins
* @public
* @param {Function|Array} plugin The plugin or array of plugins to add
* @returns The Turndown instance for chaining
* @type Object
*/
use: function (plugin) {
if (Array.isArray(plugin)) {
for (var i = 0; i < plugin.length; i++) this.use(plugin[i]);
} else if (typeof plugin === 'function') {
plugin(this);
} else {
throw new TypeError('plugin must be a Function or an Array of Functions');
}
return this;
},
/**
* Adds a rule
* @public
* @param {String} key The unique key of the rule
* @param {Object} rule The rule
* @returns The Turndown instance for chaining
* @type Object
*/
addRule: function (key, rule) {
this.rules.add(key, rule);
return this;
},
/**
* Keep a node (as HTML) that matches the filter
* @public
* @param {String|Array|Function} filter The unique key of the rule
* @returns The Turndown instance for chaining
* @type Object
*/
keep: function (filter) {
this.rules.keep(filter);
return this;
},
/**
* Remove a node that matches the filter
* @public
* @param {String|Array|Function} filter The unique key of the rule
* @returns The Turndown instance for chaining
* @type Object
*/
remove: function (filter) {
this.rules.remove(filter);
return this;
},
/**
* Escapes Markdown syntax
* @public
* @param {String} string The string to escape
* @returns A string with Markdown syntax escaped
* @type String
*/
escape: function (string) {
return escapeMarkdown(string);
}
};
/**
* Reduces a DOM node down to its Markdown string equivalent
* @private
* @param {HTMLElement} parentNode The node to convert
* @returns A Markdown representation of the node
* @type String
*/
function process(parentNode) {
var self = this;
return reduce.call(parentNode.childNodes, function (output, node) {
node = new Node(node, self.options);
var replacement = '';
if (node.nodeType === 3) {
replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue);
} else if (node.nodeType === 1) {
replacement = replacementForNode.call(self, node);
}
return join(output, replacement);
}, '');
}
/**
* Appends strings as each rule requires and trims the output
* @private
* @param {String} output The conversion output
* @returns A trimmed version of the ouput
* @type String
*/
function postProcess(output) {
var self = this;
this.rules.forEach(function (rule) {
if (typeof rule.append === 'function') {
output = join(output, rule.append(self.options));
}
});
return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '');
}
/**
* Converts an element node to its Markdown equivalent
* @private
* @param {HTMLElement} node The node to convert
* @returns A Markdown representation of the node
* @type String
*/
function replacementForNode(node) {
var rule = this.rules.forNode(node);
var content = process.call(this, node);
var whitespace = node.flankingWhitespace;
if (whitespace.leading || whitespace.trailing) content = content.trim();
return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
}
/**
* Joins replacement to the current output with appropriate number of new lines
* @private
* @param {String} output The current conversion output
* @param {String} replacement The string to append to the output
* @returns Joined output
* @type String
*/
function join(output, replacement) {
var s1 = trimTrailingNewlines(output);
var s2 = trimLeadingNewlines(replacement);
var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
var separator = '\n\n'.substring(0, nls);
return s1 + separator + s2;
}
/**
* Determines whether an input can be converted
* @private
* @param {String|HTMLElement} input Describe this parameter
* @returns Describe what it returns
* @type String|Object|Array|Boolean|Number
*/
function canConvert(input) {
return input != null && (typeof input === 'string' || input.nodeType && (input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11));
}
return TurndownService;
})();