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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
+11
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Vendored
+165
@@ -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;
|
||||
|
||||
}({}));
|
||||
Vendored
+800
@@ -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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user