md-viewer / index.html
SupaC's picture
Upload folder using huggingface_hub
1bab42d verified
Raw History Blame Contribute Delete
18 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MD Viewer β€” markdown into text + code snippets</title>
<style>
:root { --bg:#0f172a; --panel:#1e293b; --panel2:#020617; --line:#334155; --txt:#e2e8f0; --mut:#94a3b8; --acc:#38bdf8; --grn:#34d399; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:var(--bg); color:var(--txt); }
header { padding:20px 24px 0; max-width:1280px; margin:0 auto; }
h1 { margin:0; font-size:1.5rem; display:flex; align-items:center; gap:.6rem; }
h1 .ico { font-size:1.7rem; }
.sub { color:var(--mut); font-size:.9rem; margin:.35rem 0 1rem; }
.wrap { max-width:1280px; margin:0 auto; padding:0 24px 40px; }
.toolbar { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.search { flex:1; min-width:220px; display:flex; align-items:center; gap:8px; background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:8px 12px; }
.search input { flex:1; background:transparent; border:0; outline:0; color:var(--txt); font-size:.9rem; }
.search input::placeholder { color:var(--mut); }
select, .btn { background:var(--panel2); color:var(--txt); border:1px solid var(--line); border-radius:10px; padding:8px 12px; font-size:.85rem; cursor:pointer; }
.btn:hover { border-color:var(--acc); }
.seg { display:flex; border:1px solid var(--line); border-radius:10px; overflow:hidden; }
.seg button { background:var(--panel2); color:var(--mut); border:0; padding:8px 14px; font-size:.85rem; cursor:pointer; text-transform:capitalize; }
.seg button.on { background:var(--acc); color:#06283a; font-weight:600; }
.chk { display:flex; align-items:center; gap:6px; font-size:.8rem; color:var(--mut); cursor:pointer; user-select:none; }
.chk input { accent-color:var(--acc); }
.stats { display:flex; flex-wrap:wrap; gap:16px; font-size:.78rem; color:var(--mut); margin-top:10px; }
.grid { display:grid; grid-template-columns:280px 1fr; gap:16px; margin-top:16px; }
@media (max-width:900px){ .grid{ grid-template-columns:1fr; } }
.side { display:flex; flex-direction:column; gap:16px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:14px; }
.card h3 { margin:0 0 8px; font-size:.85rem; }
.card input[type=text], .card textarea { width:100%; background:var(--panel2); border:1px solid var(--line); border-radius:10px; color:var(--txt); font-size:.8rem; padding:8px 10px; outline:none; }
.card textarea { font-family:ui-monospace, monospace; font-size:.72rem; resize:vertical; }
.card input:focus, .card textarea:focus { border-color:var(--acc); }
.filelist { max-height:220px; overflow:auto; display:flex; flex-direction:column; gap:2px; margin-top:8px; }
.filelist button { text-align:left; background:transparent; border:0; color:var(--mut); font-family:ui-monospace,monospace; font-size:.72rem; padding:6px 8px; border-radius:8px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.filelist button:hover { background:#33415555; color:var(--txt); }
.filelist button.on { background:#38bdf822; color:var(--acc); }
.cols { display:grid; gap:16px; }
.cols.split { grid-template-columns:1fr 1fr; }
@media (max-width:1100px){ .cols.split{ grid-template-columns:1fr; } }
.sech { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--mut); margin:0 0 8px; }
.tcard { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:14px; margin-bottom:12px; font-size:.88rem; line-height:1.6; }
.tcard .hd { font-size:.68rem; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--acc); margin-bottom:6px; }
.tcard p { margin:.35rem 0; color:#cbd5e1; }
.tcard .mh { font-weight:700; color:var(--txt); margin:.3rem 0; }
.tcard .mh2{font-size:1.05rem}.tcard .mh3{font-size:1rem}.tcard .mh4{font-size:.92rem}
.tcard li { margin:.2rem 0 .2rem 1rem; color:#cbd5e1; }
.tcard blockquote { border-left:3px solid var(--acc); margin:.4rem 0; padding-left:.6rem; opacity:.85; }
.tcard code.ic { font-family:ui-monospace,monospace; font-size:.8em; background:#38bdf822; padding:.05rem .35rem; border-radius:.35rem; }
.scard { background:#000; border:1px solid var(--line); border-radius:12px; overflow:hidden; margin-bottom:12px; }
.scard .bar { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 12px; border-bottom:1px solid #1e293b; }
.lang { font-family:ui-monospace,monospace; font-size:.68rem; font-weight:700; background:#34d39922; color:var(--grn); padding:2px 8px; border-radius:6px; }
.rel { font-size:.68rem; color:var(--mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.copy { background:transparent; border:0; color:var(--mut); font-size:.72rem; cursor:pointer; flex-shrink:0; }
.copy:hover { color:#fff; }
.scard pre { margin:0; padding:12px; font-family:ui-monospace,monospace; font-size:.75rem; line-height:1.55; color:#e2e8f0; overflow:auto; max-height:380px; white-space:pre; }
.empty { font-size:.85rem; color:var(--mut); border:1px dashed var(--line); border-radius:12px; padding:16px; }
.err { background:#7f1d1d55; border:1px solid #ef4444; color:#fca5a5; border-radius:12px; padding:10px 14px; font-size:.85rem; margin-top:12px; display:none; }
footer { text-align:center; color:var(--mut); font-size:.75rem; padding:20px; }
footer a { color:var(--acc); }
#dropov { position:fixed; inset:0; display:none; align-items:center; justify-content:center; background:#38bdf822; border:3px dashed var(--acc); border-radius:18px; margin:12px; z-index:50; font-size:1.1rem; font-weight:600; color:var(--acc); pointer-events:none; }
#dropov.show { display:flex; }
</style>
</head>
<body>
<div id="dropov">Drop the .md file to open it</div>
<header>
<h1><span class="ico">πŸ“„</span> MD Viewer</h1>
<p class="sub">Open any Markdown β€” it is filtered and broken down into <b>text blocks</b> and <b>relevant code snippets</b>.</p>
</header>
<div class="wrap">
<div class="toolbar">
<div class="search">πŸ” <input id="q" type="text" placeholder="Filter text + code (e.g. deploy, docker, api)…" /></div>
<select id="lang" title="Filter snippets by language"><option value="all">All languages</option></select>
<div class="seg" id="viewseg">
<button data-v="split" class="on">Split</button><button data-v="text">Text</button><button data-v="code">Code</button>
</div>
<label class="chk" title="Keep blocks sharing a heading with any match"><input type="checkbox" id="rel" checked /> Relevant only</label>
</div>
<div class="stats" id="stats"></div>
<div class="err" id="err"></div>
<div class="grid">
<div class="side">
<div class="card">
<h3>πŸ“ Sample docs</h3>
<div class="filelist" id="samples"></div>
</div>
<div class="card">
<h3>πŸ”— Load from URL</h3>
<input type="text" id="url" placeholder="https://raw.githubusercontent.com/…/README.md" />
<button class="btn" id="fetchbtn" style="margin-top:8px;width:100%">Fetch</button>
</div>
<div class="card">
<h3>πŸ“€ Paste / upload</h3>
<textarea id="paste" rows="10" spellcheck="false"></textarea>
<div style="display:flex;gap:8px;margin-top:8px">
<label class="btn" style="flex:1;text-align:center">Upload<input type="file" id="up" accept=".md,.markdown,.txt" hidden /></label>
<button class="btn" id="reset" style="flex:1">Sample</button>
</div>
</div>
</div>
<div class="cols split" id="cols">
<section id="textsec"><p class="sech">πŸ“ Text</p><div id="texts"></div></section>
<section id="codesec"><p class="sech">πŸ’» Snippets</p><div id="codes"></div></section>
</div>
</div>
</div>
<footer>MD Viewer Β· Static Space Β· <a href="https://huggingface.co/spaces/SupaC/md-viewer">SupaC/md-viewer</a></footer>
<script>
"use strict";
const $ = id => document.getElementById(id);
const SAMPLE = `# MD Viewer
Paste or open any \`.md\` file. This viewer **filters** the document and breaks it into **text cards** and **code snippets**.
## How it works
- Text blocks keep their heading context
- Code fences become copyable snippets with language badges
- Search keeps only relevant text + its related code
## Example
Inline \`code\` renders inside text. Lists work too:
- First point
- Second point
\`\`\`ts
function greet(name) {
return \`hello \${name}\`;
}
\`\`\`
\`\`\`bash
npm run dev
\`\`\`
`;
const SAMPLE_DEPLOY = `# Deploy Guide
Steps to ship the app to production.
## Prerequisites
You need **docker** and an SSH key on the server.
- Docker 24+
- 2 GB free RAM
## Deploy
Run the deploy script, then check health:
\`\`\`bash
./deploy.sh production
curl -f http://localhost:3000/health
\`\`\`
## Config
Set env vars before deploying:
\`\`\`env
PORT=3000
DATABASE_URL=postgres://db:5432/app
\`\`\`
## API
Health endpoint returns JSON:
\`\`\`ts
const res = await fetch("/health");
const body = await res.json();
\`\`\`
`;
const state = { raw: SAMPLE, name: "sample doc", q: "", lang: "all", view: "split", rel: true };
function esc(s){ return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
function inline(s){
let h = esc(s);
h = h.replace(/`([^`]+)`/g, '<code class="ic">$1</code>');
h = h.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
h = h.replace(/(^|\s)\*([^*\n]+)\*/g, "$1<em>$2</em>");
return h;
}
function renderText(md){
const out = [];
for (const raw of md.split("\n")) {
const line = raw.replace(/\s+$/,"");
if (!line.trim()) continue;
const hm = line.match(/^(#{1,6})\s+(.*)$/);
if (hm) { const l = Math.min(hm[1].length+1,4); out.push(`<div class="mh mh${l}">${inline(hm[2])}</div>`); continue; }
const t = line.trim();
if (/^[-*]\s+/.test(t)) { out.push(`<li>${inline(t.replace(/^[-*]\s+/,""))}</li>`); continue; }
if (/^\d+\.\s+/.test(t)) { out.push(`<li>${inline(t)}</li>`); continue; }
if (/^>\s?/.test(t)) { out.push(`<blockquote>${inline(t.replace(/^>\s?/,""))}</blockquote>`); continue; }
out.push(`<p>${inline(line)}</p>`);
}
return out.join("");
}
function parseMarkdown(src){
const lines = src.replace(/\r\n/g,"\n").split("\n");
const texts = [], codes = [];
let buf = [], curH = null, curL = 0, n = 0;
const flush = () => {
const content = buf.join("\n").trim(); buf = [];
if (!content) return;
let cur = { h: curH, l: curL, lines: [] };
const push = () => { const c = cur.lines.join("\n").trim(); if (c) { n++; texts.push({id:"t"+n, heading:cur.h, level:cur.l, content:c}); } };
for (const line of content.split("\n")) {
const m = line.match(/^(#{1,6})\s+(.*)$/);
if (m) { push(); curH = m[2].trim(); curL = m[1].length; cur = { h: curH, l: curL, lines: [line] }; }
else cur.lines.push(line);
}
push();
};
let inCode = false, lang = "text", cbuf = [], ch = null;
for (const line of lines) {
const f = line.match(/^```\s*([A-Za-z0-9_+#.-]*)\s*$/);
if (f && !inCode) { flush(); inCode = true; lang = (f[1]||"text").toLowerCase()||"text"; cbuf = []; ch = curH; continue; }
if (f && inCode) { n++; codes.push({id:"c"+n, lang, code:cbuf.join("\n"), rel:ch}); inCode = false; cbuf = []; continue; }
if (inCode) cbuf.push(line);
else { const m = line.match(/^(#{1,6})\s+(.*)$/); if (m){ curH = m[2].trim(); curL = m[1].length; } buf.push(line); }
}
if (inCode) { n++; codes.push({id:"c"+n, lang, code:cbuf.join("\n"), rel:ch}); } else flush();
return { texts, codes };
}
function applyFilter(p){
const q = state.q.trim().toLowerCase();
let codes = state.lang === "all" ? p.codes : p.codes.filter(b => b.lang === state.lang);
let texts = p.texts;
if (!q) return { texts, codes };
const th = b => (b.heading||"").toLowerCase().includes(q) || b.content.toLowerCase().includes(q);
const ch2 = b => b.code.toLowerCase().includes(q) || b.lang.includes(q) || (b.rel||"").toLowerCase().includes(q);
const mt = texts.filter(th), mc = codes.filter(ch2);
if (!state.rel) return { texts: mt, codes: mc };
const heads = new Set();
mt.forEach(b => b.heading && heads.add(b.heading));
mc.forEach(b => b.rel && heads.add(b.rel));
return {
texts: texts.filter(b => th(b) || (b.heading && heads.has(b.heading))),
codes: codes.filter(b => ch2(b) || (b.rel && heads.has(b.rel)))
};
}
function render(){
const p = parseMarkdown(state.raw);
const langs = [...new Set(p.codes.map(b=>b.lang))].sort();
const sel = $("lang"), cur = sel.value;
sel.innerHTML = `<option value="all">All languages (${langs.length})</option>` + langs.map(l=>`<option value="${esc(l)}">${esc(l)}</option>`).join("");
sel.value = langs.includes(cur) ? cur : "all";
if (langs.includes(cur)) state.lang = cur; else state.lang = "all";
const f = applyFilter(p);
$("stats").innerHTML = `<span>πŸ“ ${f.texts.length} text blocks</span><span>πŸ’» ${f.codes.length} snippets</span><span>πŸ“„ ${esc(state.name)}</span>` +
(state.q.trim() ? `<span>filter: β€œ${esc(state.q.trim())}”${state.rel ? " (+ related)" : " (exact)"}</span>` : "");
document.querySelectorAll("#viewseg button").forEach(b => b.classList.toggle("on", b.dataset.v === state.view));
const cols = $("cols");
cols.className = "cols" + (state.view === "split" ? " split" : "");
$("textsec").style.display = state.view === "code" ? "none" : "";
$("codesec").style.display = state.view === "text" ? "none" : "";
$("texts").innerHTML = f.texts.length ? f.texts.map(b =>
`<article class="tcard">${b.heading ? `<div class="hd">${esc(b.heading)}</div>` : ""}${renderText(b.content)}</article>`
).join("") : `<div class="empty">No text matches this filter.</div>`;
$("codes").innerHTML = f.codes.length ? f.codes.map(b =>
`<article class="scard"><div class="bar"><div style="display:flex;gap:8px;align-items:center;min-width:0"><span class="lang">${esc(b.lang)}</span>${b.rel ? `<span class="rel">Β§ ${esc(b.rel)}</span>` : ""}</div><button class="copy" data-id="${b.id}">⧉ Copy</button></div><pre>${esc(b.code) || '<span style="color:#475569">(empty)</span>'}</pre></article>`
).join("") : `<div class="empty">No snippets match this filter.</div>`;
document.querySelectorAll(".copy").forEach(btn => btn.addEventListener("click", () => {
const card = btn.closest(".scard");
const code = card.querySelector("pre").innerText;
navigator.clipboard.writeText(code).then(() => {
const o = btn.textContent; btn.textContent = "βœ“ Copied";
setTimeout(() => btn.textContent = o, 1200);
});
}));
}
function setRaw(raw, name){ state.raw = raw; state.name = name; $("paste").value = raw; render(); }
function showErr(m){ const e = $("err"); e.textContent = m; e.style.display = m ? "block" : "none"; }
let qt; $("q").addEventListener("input", e => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value; render(); }, 200); });
$("lang").addEventListener("change", e => { state.lang = e.target.value; render(); });
$("rel").addEventListener("change", e => { state.rel = e.target.checked; render(); });
document.querySelectorAll("#viewseg button").forEach(b => b.addEventListener("click", () => { state.view = b.dataset.v; render(); }));
$("paste").addEventListener("input", e => { state.raw = e.target.value; state.name = "pasted doc"; render(); });
$("up").addEventListener("change", e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => setRaw(String(r.result || ""), f.name);
r.readAsText(f); e.target.value = "";
});
$("reset").addEventListener("click", () => setRaw(SAMPLE, "sample doc"));
async function doFetch(u) {
showErr("");
try {
const r = await fetch(u);
if (!r.ok) throw new Error("HTTP " + r.status);
const text = await r.text();
setRaw(text, (u.split("/").pop() || u).split("?")[0] || u);
try { history.replaceState(null, "", "?url=" + encodeURIComponent(u)); } catch (e) {}
} catch (e) { showErr("Fetch failed (URL may block cross-origin requests): " + e.message); }
}
$("fetchbtn").addEventListener("click", () => {
const u = $("url").value.trim(); if (!u) return;
doFetch(u);
});
// Drag & drop a local .md file anywhere onto the app
window.addEventListener("dragover", e => { e.preventDefault(); $("dropov").classList.add("show"); });
window.addEventListener("dragleave", e => { if (!e.relatedTarget) $("dropov").classList.remove("show"); });
window.addEventListener("drop", e => {
e.preventDefault(); $("dropov").classList.remove("show");
const f = [...(e.dataTransfer.files || [])].find(x => /\.(md|markdown|mdown|txt)$/i.test(x.name));
if (!f) { showErr("Drop a .md file to view it"); return; }
const r = new FileReader();
r.onload = () => { setRaw(String(r.result || ""), f.name); try { history.replaceState(null, "", location.pathname); } catch (e2) {} };
r.readAsText(f);
});
const samples = [["Sample doc", SAMPLE], ["Deploy guide", SAMPLE_DEPLOY]];
const sl = $("samples");
samples.forEach(([name, body], i) => {
const b = document.createElement("button");
b.textContent = name; if (i === 0) b.classList.add("on");
b.addEventListener("click", () => {
sl.querySelectorAll("button").forEach(x => x.classList.remove("on"));
b.classList.add("on"); setRaw(body, name.toLowerCase().replace(/ /g,"-") + ".md");
});
sl.appendChild(b);
});
$("paste").value = SAMPLE;
render();
// Deep-link: ?url=<raw-markdown-url> auto-loads that doc
try {
const deep = new URLSearchParams(location.search).get("url");
if (deep) { $("url").value = deep; doFetch(deep); }
} catch (e) {}
</script>
</body>
</html>