Bonus
Markdown live preview
Split editor rendering as you type
Split editor rendering as you type
Build a split markdown editor with Tailwind CSS. Use a bordered rounded-2xl card with a toolbar holding a document title on the left, a word and character count in the middle, and on the right a two-option segmented control for Split and Preview plus a small "Copy HTML" button. Below, a two-column grid: the left pane is a monospaced textarea on a faint background with no border and a comfortable line height, pre-filled with a short sample document; the right pane is the rendered output on white with sensible typographic spacing. Then add JavaScript implementing a small markdown renderer covering headings up to level three, bold, italic, inline code, links, unordered lists, blockquotes and fenced code blocks, escaping HTML first so pasted markup cannot inject; re-render on input with a short debounce, keep the counts current, switch layout when Preview is selected, and copy the generated HTML. <section class="mx-auto max-w-5xl px-6 py-12">
<div class="overflow-hidden rounded-2xl border border-neutral-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-neutral-200 px-5 py-3">
<p class="text-sm font-medium text-neutral-900">release-notes.md</p>
<p id="mdCount" class="font-mono text-xs text-neutral-400">0 words · 0 characters</p>
<div class="flex items-center gap-2">
<div class="flex rounded-lg border border-neutral-200 p-0.5">
<button id="mdSplit" class="rounded-md bg-neutral-900 px-3 py-1 text-xs font-medium text-white">Split</button>
<button id="mdOnly" class="rounded-md px-3 py-1 text-xs font-medium text-neutral-600">Preview</button>
</div>
<button id="mdCopy" class="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 transition hover:bg-neutral-50">Copy HTML</button>
</div>
</div>
<div id="mdGrid" class="grid md:grid-cols-2">
<textarea id="mdInput" spellcheck="false"
class="h-[420px] resize-none border-neutral-200 bg-neutral-50 p-6 font-mono text-[13px] leading-relaxed text-neutral-800 outline-none md:border-r"># Release 4.2
Query caching is now **on by default** for every plan, including free.
## What changed
- Dashboards hit a warm cache for identical queries
- Cache keys include the *row-level* access rules
- Manual invalidation via `POST /v1/cache/purge`
> Median dashboard load went from 1.4s to 380ms across the fleet.
### Upgrading
No action needed. To opt out per workspace, see [the docs](https://example.com).</textarea>
<div id="mdPreview" class="h-[420px] overflow-y-auto p-6"></div>
</div>
</div>
</section> var input = document.getElementById("mdInput");
var preview = document.getElementById("mdPreview");
var count = document.getElementById("mdCount");
var grid = document.getElementById("mdGrid");
var splitBtn = document.getElementById("mdSplit");
var onlyBtn = document.getElementById("mdOnly");
var copyBtn = document.getElementById("mdCopy");
var timer;
function escapeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function inline(text) {
return text
.replace(/`([^`]+)`/g, '<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[0.85em] text-neutral-800">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-neutral-900">$1</strong>')
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-neutral-900 underline underline-offset-2">$1</a>');
}
function render(source) {
// Escape before anything else, so pasted markup renders as text.
var lines = escapeHtml(source).split("\n");
var html = "";
var inList = false;
var inCode = false;
lines.forEach(function (line) {
if (line.indexOf("```") === 0) {
if (inCode) {
html += "</pre>";
inCode = false;
} else {
html += '<pre class="my-4 overflow-x-auto rounded-lg bg-neutral-900 p-4 font-mono text-xs text-neutral-200">';
inCode = true;
}
return;
}
if (inCode) {
html += line + "\n";
return;
}
if (line.indexOf("- ") === 0) {
if (!inList) {
html += '<ul class="my-3 list-disc space-y-1 pl-5 text-neutral-600">';
inList = true;
}
html += "<li>" + inline(line.slice(2)) + "</li>";
return;
}
if (inList) {
html += "</ul>";
inList = false;
}
if (line.indexOf("### ") === 0) html += '<h3 class="mt-5 text-sm font-semibold text-neutral-900">' + inline(line.slice(4)) + "</h3>";
else if (line.indexOf("## ") === 0) html += '<h2 class="mt-6 text-lg font-semibold text-neutral-900">' + inline(line.slice(3)) + "</h2>";
else if (line.indexOf("# ") === 0) html += '<h1 class="text-2xl font-semibold tracking-tight text-neutral-900">' + inline(line.slice(2)) + "</h1>";
else if (line.indexOf("> ") === 0) html += '<blockquote class="my-4 border-l-2 border-neutral-300 pl-4 italic text-neutral-500">' + inline(line.slice(5)) + "</blockquote>";
else if (line.trim() === "") html += "";
else html += '<p class="my-3 leading-relaxed text-neutral-600">' + inline(line) + "</p>";
});
if (inList) html += "</ul>";
if (inCode) html += "</pre>";
return html;
}
function update() {
var text = input.value;
preview.innerHTML = render(text);
var words = text.trim() ? text.trim().split(/\s+/).length : 0;
count.textContent = words + " words · " + text.length + " characters";
}
input.addEventListener("input", function () {
clearTimeout(timer);
timer = setTimeout(update, 120);
});
splitBtn.addEventListener("click", function () {
grid.classList.add("md:grid-cols-2");
input.classList.remove("hidden");
splitBtn.classList.add("bg-neutral-900", "text-white");
splitBtn.classList.remove("text-neutral-600");
onlyBtn.classList.remove("bg-neutral-900", "text-white");
onlyBtn.classList.add("text-neutral-600");
});
onlyBtn.addEventListener("click", function () {
grid.classList.remove("md:grid-cols-2");
input.classList.add("hidden");
onlyBtn.classList.add("bg-neutral-900", "text-white");
onlyBtn.classList.remove("text-neutral-600");
splitBtn.classList.remove("bg-neutral-900", "text-white");
splitBtn.classList.add("text-neutral-600");
});
copyBtn.addEventListener("click", function () {
navigator.clipboard.writeText(preview.innerHTML).then(function () {
copyBtn.textContent = "Copied";
setTimeout(function () {
copyBtn.textContent = "Copy HTML";
}, 1500);
});
});
update();