scrubdata / frontend /variant_b /index.html
OpenAI Codex
deploy: add sponsor:openai tag (Best Use of Codex) + Codex-hardened build
16dc556
Raw
History Blame Contribute Delete
24.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ScrubData · Trailhead</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bark: #2f2a22;
--moss: #3f6f4f;
--moss-deep: #2c5239;
--fern: #5a9367;
--amber: #d98c2b;
--amber-soft: #f0b95c;
--cream: #f7f1e3;
--cream-2: #efe6d2;
--paper: #fdf9ef;
--clay: #c2693b;
--ink-soft: #6b6354;
--line: #d8cbb0;
--rust: #b04a3a;
--shadow: rgba(47,42,34,.16);
--radius: 16px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: "DM Sans", system-ui, sans-serif;
color: var(--bark);
background-color: var(--cream);
background-image:
radial-gradient(circle at 12% 18%, rgba(90,147,103,.10), transparent 38%),
radial-gradient(circle at 88% 8%, rgba(217,140,43,.12), transparent 42%),
radial-gradient(circle at 50% 120%, rgba(63,111,79,.10), transparent 55%);
min-height: 100vh;
}
/* faint topo-map contour texture */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: .5;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220' viewBox='0 0 220 220'><g fill='none' stroke='%233f6f4f' stroke-opacity='0.07' stroke-width='1.4'><path d='M10 120 Q60 60 110 110 T210 100'/><path d='M0 160 Q60 110 120 150 T220 140'/><path d='M-10 70 Q50 20 110 60 T220 50'/></g></svg>");
}
.wrap {
position: relative;
z-index: 1;
max-width: 1180px;
margin: 0 auto;
padding: 38px 26px 80px;
}
/* ---------- HEADER ---------- */
header.hero {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 8px;
}
.badge-trail {
flex: none;
width: 64px; height: 64px;
border-radius: 18px;
background: linear-gradient(150deg, var(--moss), var(--moss-deep));
display: grid; place-items: center;
box-shadow: 0 8px 20px var(--shadow), inset 0 0 0 3px rgba(255,255,255,.12);
transform: rotate(-4deg);
}
.badge-trail svg { width: 40px; height: 40px; }
.hero h1 {
font-family: "Fraunces", serif;
font-weight: 700;
font-size: clamp(2rem, 4.5vw, 3.1rem);
line-height: .98;
margin: 0;
color: var(--bark);
letter-spacing: -.5px;
}
.hero h1 .dot { color: var(--amber); }
.hero .kicker {
font-family: "Caveat", cursive;
font-size: 1.55rem;
color: var(--moss-deep);
transform: rotate(-2deg);
margin-top: 2px;
display: inline-block;
}
.tagline {
font-size: 1.05rem;
color: var(--ink-soft);
max-width: 640px;
margin: 14px 0 30px;
line-height: 1.5;
}
.tagline b { color: var(--moss-deep); }
/* ---------- CARD ---------- */
.card {
background: var(--paper);
border: 1.5px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 10px 30px var(--shadow);
padding: 26px;
position: relative;
}
/* little "stitched" dashed inner border for the trail-map feel */
.card.dashed::after {
content: "";
position: absolute;
inset: 9px;
border: 2px dashed var(--line);
border-radius: 11px;
pointer-events: none;
}
/* ---------- DROPZONE ---------- */
.stage { display: grid; grid-template-columns: 1.3fr .9fr; gap: 22px; align-items: stretch; }
@media (max-width: 820px){ .stage { grid-template-columns: 1fr; } }
.dropzone {
border: 2.5px dashed var(--fern);
border-radius: 14px;
background:
repeating-linear-gradient(135deg, rgba(90,147,103,.05) 0 14px, transparent 14px 28px),
var(--cream-2);
padding: 34px 24px;
text-align: center;
cursor: pointer;
transition: border-color .18s, background .18s, transform .18s;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 220px;
}
.dropzone:hover { border-color: var(--moss); transform: translateY(-2px); }
.dropzone.drag {
border-color: var(--amber);
background: var(--cream);
box-shadow: inset 0 0 0 3px rgba(217,140,43,.25);
}
.dropzone .foot {
font-size: 3.2rem; line-height: 1; margin-bottom: 8px;
filter: drop-shadow(0 4px 6px rgba(47,42,34,.18));
}
.dropzone h3 { margin: 4px 0 4px; font-family: "Fraunces", serif; font-size: 1.3rem; font-weight: 600; }
.dropzone p { margin: 0; color: var(--ink-soft); font-size: .95rem; }
.filechip {
margin-top: 14px;
display: none;
align-items: center; gap: 8px;
background: var(--moss);
color: var(--paper);
font-weight: 600; font-size: .9rem;
padding: 7px 14px; border-radius: 999px;
box-shadow: 0 4px 10px var(--shadow);
}
.filechip.show { display: inline-flex; }
/* ---------- ACTION PANEL ---------- */
.panel { display: flex; flex-direction: column; justify-content: center; gap: 16px; }
.btn {
font-family: "DM Sans", sans-serif;
font-weight: 700;
font-size: 1.05rem;
border: none;
border-radius: 13px;
padding: 16px 22px;
cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
transition: transform .12s, box-shadow .12s, filter .12s;
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: linear-gradient(150deg, var(--amber-soft), var(--amber));
color: #3a2a10;
box-shadow: 0 8px 18px rgba(217,140,43,.4), inset 0 1px 0 rgba(255,255,255,.5);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.04); box-shadow: 0 10px 24px rgba(217,140,43,.5); }
.btn-primary:disabled { filter: grayscale(.5) brightness(.95); cursor: not-allowed; opacity: .75; }
.btn-ghost {
background: transparent;
color: var(--moss-deep);
border: 1.5px solid var(--line);
font-weight: 600; font-size: .95rem;
padding: 12px 16px;
}
.btn-ghost:hover { background: var(--cream-2); border-color: var(--fern); }
.hint {
font-family: "Caveat", cursive;
font-size: 1.15rem;
color: var(--clay);
text-align: center;
transform: rotate(-1deg);
}
.hint code {
font-family: "DM Sans", sans-serif;
font-size: .8rem;
background: var(--cream-2);
padding: 1px 6px; border-radius: 5px;
color: var(--bark);
}
/* ---------- STATUS / ERROR ---------- */
.status {
margin-top: 22px;
display: none;
align-items: center; gap: 14px;
padding: 16px 20px;
border-radius: 12px;
background: var(--cream-2);
border: 1.5px solid var(--line);
font-weight: 600;
}
.status.show { display: flex; }
.status.err { background: #fbeae6; border-color: var(--rust); color: var(--rust); }
/* scrubbing animation */
.scrubber { position: relative; width: 52px; height: 30px; flex: none; }
.scrubber .suds {
position: absolute; inset: 0;
background:
radial-gradient(circle at 30% 60%, rgba(90,147,103,.5) 4px, transparent 5px),
radial-gradient(circle at 65% 35%, rgba(217,140,43,.5) 5px, transparent 6px),
radial-gradient(circle at 50% 70%, rgba(90,147,103,.4) 3px, transparent 4px);
}
.scrubber .brush {
position: absolute; top: 4px; left: 0;
font-size: 1.5rem;
animation: scrub 0.7s ease-in-out infinite;
}
@keyframes scrub {
0%,100% { transform: translateX(0) rotate(-8deg); }
50% { transform: translateX(22px) rotate(8deg); }
}
/* ---------- RESULTS ---------- */
.results { margin-top: 30px; display: none; }
.results.show { display: block; animation: rise .4s ease both; }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
.section-tab {
display: inline-flex; align-items: center; gap: 8px;
font-family: "Fraunces", serif; font-weight: 600;
font-size: 1.25rem;
background: var(--moss);
color: var(--paper);
padding: 7px 18px 7px 14px;
border-radius: 10px 10px 0 0;
transform: translateY(1px);
box-shadow: 0 -2px 6px var(--shadow);
}
.summary-card {
background: linear-gradient(150deg, var(--moss-deep), var(--moss));
color: var(--paper);
border-radius: 14px;
padding: 22px 26px;
margin-bottom: 26px;
box-shadow: 0 10px 26px var(--shadow);
position: relative;
overflow: hidden;
}
.summary-card::after {
content: "🏕️";
position: absolute; right: 18px; bottom: -6px;
font-size: 4.5rem; opacity: .16;
}
.summary-card .lbl {
font-family: "Caveat", cursive; font-size: 1.3rem;
color: var(--amber-soft); margin-bottom: 2px;
}
.summary-card p { margin: 0; font-size: 1.15rem; line-height: 1.45; max-width: 80%; }
.stats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 28px; }
.stat {
flex: 1 1 130px;
background: var(--paper);
border: 1.5px solid var(--line);
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 4px 12px var(--shadow);
}
.stat .num { font-family: "Fraunces", serif; font-size: 1.8rem; font-weight: 700; color: var(--moss-deep); line-height: 1; }
.stat .cap { font-size: .82rem; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .04em; margin-top: 5px; }
.tables { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
@media (max-width: 900px){ .tables { grid-template-columns: 1fr; } }
.tablecard { display: flex; flex-direction: column; }
.tablecard .head {
display: flex; align-items: baseline; gap: 10px;
margin-bottom: 6px;
}
.tablecard .head h4 { font-family: "Fraunces", serif; margin: 0; font-size: 1.15rem; }
.tag { font-size: .72rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: .05em; }
.tag.before { background: #f5e0d6; color: var(--rust); }
.tag.after { background: #d9ecdc; color: var(--moss-deep); }
.scroll {
overflow: auto;
max-height: 360px;
border: 1.5px solid var(--line);
border-radius: 12px;
background: var(--paper);
}
table.grid { border-collapse: collapse; width: 100%; font-size: .82rem; white-space: nowrap; }
table.grid th {
position: sticky; top: 0;
background: var(--cream-2);
color: var(--bark);
text-align: left;
font-weight: 700;
padding: 9px 12px;
border-bottom: 2px solid var(--line);
z-index: 1;
}
table.grid td {
padding: 8px 12px;
border-bottom: 1px solid var(--line);
color: var(--bark);
}
table.grid tr:nth-child(even) td { background: rgba(239,230,210,.4); }
table.grid td.empty { color: #b9ac90; font-style: italic; }
/* ---------- REPORT ---------- */
.report-card { background: var(--paper); border: 1.5px solid var(--line); border-radius: 0 14px 14px 14px; padding: 24px 28px; box-shadow: 0 8px 22px var(--shadow); margin-bottom: 26px; }
.report-card h1, .report-card h2, .report-card h3 { font-family: "Fraunces", serif; color: var(--moss-deep); margin: 18px 0 8px; }
.report-card h1 { font-size: 1.5rem; margin-top: 0; }
.report-card h2 { font-size: 1.25rem; }
.report-card h3 { font-size: 1.08rem; }
.report-card p, .report-card li { line-height: 1.6; color: var(--bark); }
.report-card ul { padding-left: 22px; }
.report-card li { margin: 4px 0; }
.report-card code { background: var(--cream-2); padding: 1px 6px; border-radius: 5px; font-size: .88em; }
.report-card strong { color: var(--moss-deep); }
.report-card hr { border: none; border-top: 1.5px dashed var(--line); margin: 18px 0; }
.report-card table { border-collapse: collapse; margin: 10px 0; }
.report-card th, .report-card td { border: 1px solid var(--line); padding: 6px 12px; text-align: left; font-size: .9rem; }
.report-card th { background: var(--cream-2); }
.download-row { display: flex; justify-content: center; padding: 8px 0 0; }
.btn-download {
background: linear-gradient(150deg, var(--fern), var(--moss-deep));
color: var(--paper);
box-shadow: 0 8px 18px rgba(44,82,57,.4);
font-size: 1.1rem; padding: 16px 32px;
}
.btn-download:hover { filter: brightness(1.05); }
footer { text-align: center; margin-top: 50px; color: var(--ink-soft); font-size: .85rem; }
footer .star { color: var(--amber); }
.spin { display:inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="wrap">
<header class="hero">
<div class="badge-trail" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="#f7f1e3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 20h18"/><path d="M7 20l4-13 3 6 2-4 3 11"/><circle cx="11" cy="7" r="1.4" fill="#f0b95c" stroke="none"/>
</svg>
</div>
<div>
<h1>ScrubData<span class="dot">.</span></h1>
<span class="kicker">your data, back on the trail</span>
</div>
</header>
<p class="tagline">
Drop in a messy spreadsheet and we'll <b>tidy the whole trail</b> — trimming stray
whitespace, fixing $ amounts stored as text, unifying spellings, and de-duping rows.
Every change is <b>shown and explained</b>, so you can vouch for the result.
</p>
<!-- INPUT CARD -->
<div class="card dashed">
<div class="stage">
<label class="dropzone" id="dropzone">
<input type="file" id="fileInput" accept=".csv,.xlsx,.xls" hidden />
<div class="foot">🥾</div>
<h3>Drop your CSV / Excel here</h3>
<p>or click to pick a file from your pack</p>
<span class="filechip" id="fileChip">📄 <span id="fileName"></span></span>
</label>
<div class="panel">
<button class="btn btn-primary" id="cleanBtn" disabled>🧽 Clean it</button>
<button class="btn btn-ghost" id="sampleBtn">🍂 Load the messy sample</button>
<div class="hint">no file? grab <code>samples/dirty_contacts.csv</code></div>
</div>
</div>
<div class="status" id="status" role="status" aria-live="polite">
<div class="scrubber"><div class="suds"></div><div class="brush">🧽</div></div>
<span id="statusText">Scrubbing your data clean…</span>
</div>
</div>
<!-- RESULTS -->
<div class="results" id="results">
<div class="summary-card">
<div class="lbl">trail report</div>
<p id="summaryText"></p>
</div>
<div class="stats" id="stats"></div>
<div class="tables">
<div class="tablecard">
<div class="head"><h4>Before</h4><span class="tag before">as uploaded</span></div>
<div class="scroll"><div id="beforeTable"></div></div>
</div>
<div class="tablecard">
<div class="head"><h4>After</h4><span class="tag after">scrubbed</span></div>
<div class="scroll"><div id="afterTable"></div></div>
</div>
</div>
<span class="section-tab">📜 What we changed</span>
<div class="report-card" id="report"></div>
<div class="download-row">
<button class="btn btn-download" id="downloadBtn">⬇️ Download scrubbed.csv</button>
</div>
</div>
<footer>
Built small for the trail <span class="star"></span> ScrubData keeps your original file untouched.
</footer>
</div>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client";
// ---- element refs ----
const fileInput = document.getElementById("fileInput");
const dropzone = document.getElementById("dropzone");
const fileChip = document.getElementById("fileChip");
const fileName = document.getElementById("fileName");
const cleanBtn = document.getElementById("cleanBtn");
const sampleBtn = document.getElementById("sampleBtn");
const statusEl = document.getElementById("status");
const statusText= document.getElementById("statusText");
const results = document.getElementById("results");
const summaryEl = document.getElementById("summaryText");
const statsEl = document.getElementById("stats");
const reportEl = document.getElementById("report");
const downloadBtn = document.getElementById("downloadBtn");
const SAMPLE_PATH = "samples/dirty_contacts.csv";
let selectedFile = null; // a File object OR a {url} handle for the sample
let csvText = "";
// ---------- file selection ----------
function setFile(file, displayName) {
selectedFile = file;
fileName.textContent = displayName;
fileChip.classList.add("show");
cleanBtn.disabled = false;
}
fileInput.addEventListener("change", e => {
const f = e.target.files[0];
if (f) setFile(f, f.name);
});
// drag & drop
["dragenter","dragover"].forEach(ev =>
dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.add("drag"); }));
["dragleave","drop"].forEach(ev =>
dropzone.addEventListener(ev, e => { e.preventDefault(); dropzone.classList.remove("drag"); }));
dropzone.addEventListener("drop", e => {
const f = e.dataTransfer.files[0];
if (f) setFile(f, f.name);
});
// ---------- sample loader ----------
sampleBtn.addEventListener("click", async () => {
sampleBtn.disabled = true;
const original = sampleBtn.innerHTML;
sampleBtn.innerHTML = '<span class="spin">🍂</span> fetching sample…';
try {
const res = await fetch(SAMPLE_PATH);
if (!res.ok) throw new Error("sample not reachable (HTTP " + res.status + ")");
const blob = await res.blob();
const file = new File([blob], "dirty_contacts.csv", { type: "text/csv" });
setFile(file, "dirty_contacts.csv (sample)");
} catch (err) {
// Fall back to passing the server-relative path directly to handle_file.
setFile({ __samplePath: SAMPLE_PATH }, "dirty_contacts.csv (sample)");
} finally {
sampleBtn.disabled = false;
sampleBtn.innerHTML = original;
}
});
// ---------- clean action ----------
cleanBtn.addEventListener("click", runClean);
async function runClean() {
if (!selectedFile) return;
showStatus("Scrubbing your data clean…", false);
cleanBtn.disabled = true;
results.classList.remove("show");
try {
const client = await Client.connect(window.location.origin);
const fileArg = selectedFile.__samplePath
? handle_file(selectedFile.__samplePath)
: handle_file(selectedFile);
const result = await client.predict("/clean_data", { file_path: fileArg });
const data = result?.data?.[0];
if (!data) throw new Error("Backend returned no data.");
render(data);
hideStatus();
results.classList.add("show");
results.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (err) {
console.error(err);
showStatus("😖 Couldn't clean that file: " + (err?.message || err), true);
} finally {
cleanBtn.disabled = false;
}
}
// ---------- status helpers ----------
function showStatus(msg, isError) {
statusText.textContent = msg;
statusEl.classList.toggle("err", !!isError);
statusEl.classList.add("show");
// hide the scrubber animation in error state
statusEl.querySelector(".scrubber").style.display = isError ? "none" : "block";
}
function hideStatus() { statusEl.classList.remove("show"); }
// ---------- rendering ----------
function render(data) {
csvText = data.csv_text || "";
// summary
summaryEl.textContent = data.summary || "Cleaning complete.";
// tables
const before = normalizeRecords(data.before);
const after = normalizeRecords(data.after);
document.getElementById("beforeTable").innerHTML = buildTable(before);
document.getElementById("afterTable").innerHTML = buildTable(after);
// quick stats
const bRows = before.length, aRows = after.length;
const bCols = before.length ? Object.keys(before[0]).length : 0;
const aCols = after.length ? Object.keys(after[0]).length : 0;
statsEl.innerHTML = [
stat(bRows, "rows in"),
stat(aRows, "rows out"),
stat(Math.max(0, bRows - aRows), "rows removed"),
stat(aCols, "clean columns"),
].join("");
// report markdown
reportEl.innerHTML = renderMarkdown(data.report_md || "_No report provided._");
}
function stat(num, cap) {
return `<div class="stat"><div class="num">${num}</div><div class="cap">${cap}</div></div>`;
}
// Accept either array-of-records or {columns, data} shapes.
function normalizeRecords(payload) {
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (payload.columns && Array.isArray(payload.data)) {
return payload.data.map(row => {
const o = {};
payload.columns.forEach((c, i) => (o[c] = row[i]));
return o;
});
}
return [];
}
function buildTable(records) {
if (!records.length) return '<p style="padding:18px;color:#b9ac90;">No rows.</p>';
const cols = Object.keys(records[0]);
const MAX = 60; // cap rows rendered for performance
const shown = records.slice(0, MAX);
let html = "<table class='grid'><thead><tr>";
cols.forEach(c => (html += `<th>${esc(c)}</th>`));
html += "</tr></thead><tbody>";
shown.forEach(r => {
html += "<tr>";
cols.forEach(c => {
const v = r[c];
const empty = v === null || v === undefined || v === "" || (typeof v === "number" && isNaN(v));
html += `<td class="${empty ? "empty" : ""}">${empty ? "—" : esc(String(v))}</td>`;
});
html += "</tr>";
});
html += "</tbody></table>";
if (records.length > MAX) {
html += `<p style="padding:8px 12px;color:#8a8064;font-size:.78rem;">…showing first ${MAX} of ${records.length} rows.</p>`;
}
return html;
}
function esc(s) {
return s.replace(/[&<>"']/g, m => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[m]));
}
// ---------- download ----------
downloadBtn.addEventListener("click", () => {
if (!csvText) return;
const blob = new Blob([csvText], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "scrubbed.csv";
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
});
// ---------- tiny markdown renderer ----------
// Supports headings, bold, inline code, bullet lists, simple GFM tables, hr.
function renderMarkdown(md) {
const lines = md.replace(/\r\n/g, "\n").split("\n");
let html = "", inList = false, i = 0;
const inline = t => esc(t)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
const closeList = () => { if (inList) { html += "</ul>"; inList = false; } };
while (i < lines.length) {
let line = lines[i];
// table block
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
closeList();
const header = splitRow(line);
i += 2; // skip separator
let rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(splitRow(lines[i])); i++; }
html += "<table><thead><tr>" + header.map(h => `<th>${inline(h)}</th>`).join("") + "</tr></thead><tbody>";
rows.forEach(r => { html += "<tr>" + r.map(c => `<td>${inline(c)}</td>`).join("") + "</tr>"; });
html += "</tbody></table>";
continue;
}
if (/^\s*$/.test(line)) { closeList(); i++; continue; }
if (/^---+$/.test(line.trim())) { closeList(); html += "<hr/>"; i++; continue; }
let m;
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
closeList();
const lvl = Math.min(m[1].length, 3);
html += `<h${lvl}>${inline(m[2])}</h${lvl}>`;
} else if ((m = line.match(/^\s*[-*+]\s+(.*)$/))) {
if (!inList) { html += "<ul>"; inList = true; }
html += `<li>${inline(m[1])}</li>`;
} else {
closeList();
html += `<p>${inline(line)}</p>`;
}
i++;
}
closeList();
return html;
}
function splitRow(line) {
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(s => s.trim());
}
</script>
</body>
</html>