If you want to build a Markdown previewer as a vanilla JavaScript project, you’re in the right place. This tutorial walks you through creating a fully functional, real-time editor using nothing but plain HTML, CSS, and JavaScript.
Table of Contents
Why Build a Markdown Previewer from Scratch?
Markdown is everywhere. John Gruber created it (with contributions from Aaron Swartz) in 2004, and it has since become the default lightweight markup language for developer documentation, README files, blogging platforms, and content management systems. GitHub alone hosts hundreds of millions of repositories that rely on Markdown for their primary documentation. The CommonMark specification, currently at version 0.31.2, has standardized parsing rules so Markdown behaves consistently across tools and platforms.
So why build a Markdown editor in JavaScript from scratch when dozens of polished libraries exist?
Because building it yourself forces you to engage with core skills that transfer everywhere: DOM manipulation, event handling, regex-based text parsing, and modern CSS layout. When you understand how a parser tokenizes text and transforms it into HTML, you gain insight that makes you more effective whether you’re debugging a CMS rendering bug, building a chat application with rich text, or contributing to open-source tooling.
When you understand how a parser tokenizes text and transforms it into HTML, you gain insight that makes you more effective whether you’re debugging a CMS rendering bug, building a chat application with rich text, or contributing to open-source tooling.
This project is also genuinely portfolio-worthy. Hiring managers and technical interviewers can immediately see you understand the fundamentals, not just how to install a package.
What you will need to follow along:
- Intermediate HTML and CSS knowledge
- Comfortable working with JavaScript (ES modules, array methods, template literals)
- Basic familiarity with regular expressions
- A code editor and a modern browser
What you will build:
A split-pane Markdown editor with a textarea on the left and a live-rendered preview on the right. It will support headings, bold, italic, links, images, code blocks, blockquotes, lists, and horizontal rules. It will auto-save your content, include a formatting toolbar, offer copy/export functionality, and look good doing it.
One caveat before we start: the parser we build is a learning tool, not a spec-complete CommonMark implementation. The CommonMark specification is remarkably complex, with rules around delimiter runs, nested emphasis, lazy continuation lines, and list tightness that demand a proper state machine or AST-based approach. Our regex-based parser handles the most common Markdown patterns correctly, and where it diverges from the spec, I’ll call that out explicitly. The goal is to understand the principles, not to replace production-grade libraries.
Let’s build it.
Project Setup and Architecture
File Structure and Boilerplate
We’ll keep the project organized into four files:
markdown-previewer/
├── index.html
├── style.css
├── parser.js
└── app.js
Separating the parser logic from the application logic is deliberate. The parser is a pure function module: Markdown string in, HTML string out. The application layer handles DOM interactions, event listeners, storage, and UI concerns. This separation makes the parser independently testable and swappable later if you want to benchmark it against a library.
Since we’re using multiple JavaScript files without a bundler, we’ll rely on native ES modules. This means our script tag needs type="module", and our files can use import/export syntax directly. Module scripts are deferred by default and scoped, so no global variable collisions. Note that ES modules don’t work when opening HTML files directly via file:// URLs due to CORS restrictions. You’ll need a local development server (e.g., npx serve or VS Code’s Live Server extension).
Here’s the complete HTML boilerplate:
Markdown Previewer
The semantic structure is straightforward: a
for the toolbar, a element with two sections for the editor and preview panes, and a single module script entry point. The preview div gets the class markdown-body, which we’ll target with GitHub-flavored styles later.
Designing the Split-Pane Layout with CSS Grid
CSS Grid makes the split-pane editor CSS layout trivial. Two equal columns on desktop, a single stacked column on mobile. Here’s the full stylesheet:
/* style.css */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #1e1e2e;
--bg-secondary: #282840;
--bg-preview: #1e1e2e;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--border-color: #45475a;
--accent: #89b4fa;
--toolbar-bg: #313244;
--font-mono: 'Fira Code', 'Consolas', 'Courier New', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html, body {
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
}
/* Toolbar */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 8px;
}
.toolbar-group {
display: flex;
gap: 4px;
}
.toolbar button {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.85rem;
transition: background 0.15s;
}
.toolbar button:hover {
background: var(--accent);
color: var(--bg-primary);
}
/* Split-pane layout */
.app {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
height: calc(100vh - 52px);
}
@media (max-width: 800px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
/* Editor pane */
.editor-pane {
border-right: 1px solid var(--border-color);
overflow: hidden;
}
#editor {
width: 100%;
height: 100%;
background: var(--bg-secondary);
color: var(--text-primary);
border: none;
outline: none;
resize: none;
padding: 24px;
font-family: var(--font-mono);
font-size: 0.95rem;
line-height: 1.7;
tab-size: 2;
}
#editor::placeholder {
color: var(--text-secondary);
}
/* Preview pane */
.preview-pane {
overflow-y: auto;
padding: 24px;
background: var(--bg-preview);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
The grid approach gives us a responsive split-pane editor that collapses gracefully. The dark theme takes cues from modern code editors, but you could swap the CSS custom properties for a light theme with minimal effort.
Building the Markdown Parser
This is the heart of the project and where the real learning happens. We’re going to build a regex text parsing JavaScript module that converts Markdown strings into HTML. The process breaks into three stages: sanitize the input, parse block-level elements, then process inline elements within each block.
Understanding Markdown Syntax Rules
Based on the CommonMark spec and Gruber’s original syntax definition, we’ll support these elements:
Block-level: headings (h1 through h6), fenced code blocks, blockquotes, unordered lists, ordered lists, horizontal rules, and paragraphs.
Inline: bold, italic, bold-italic, inline code, links, and images.
We’re deliberately skipping raw HTML passthrough, reference-style links, and GFM extensions like tables and task lists. Those are covered in the extension section at the end.
Parsing Block-Level Elements with Regex
The parser works in two passes. First, we split the input into block-level tokens. Then we process inline syntax within the text content of those tokens.
Let’s start with the full parser.js file and walk through it:
// parser.js /** * Sanitize raw text to prevent XSS when inserted into HTML. * This runs on text content BEFORE we wrap it in HTML tags. */ function sanitize(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Validate URLs to prevent javascript: protocol injection. */ function sanitizeURL(url) { const trimmed = url.trim(); if (/^javascript:/i.test(trimmed)) { return '#'; } return sanitize(trimmed); } /** * Parse inline Markdown syntax within a text string. * Operates on already-sanitized text for text nodes, * but handles link/image URLs separately. */ function parseInline(text) { let result = text; // Inline code first: `code` — protect code spans from other transformations result = result.replace( /`([^`]+)`/g, (_, code) => `x00CODE${btoa(code)}CODEx00` ); // Images:  — uses sanitized entities for parens/brackets result = result.replace( /![([^]]*)](([^)]+))/g, (_, alt, src) => `` ); // Links: [text](url) result = result.replace( /[([^]]+)](([^)]+))/g, (_, linkText, url) => `` ); // Bold-italic: ***text*** or ___text___ result = result.replace( /*{3}(.+?)*{3}/g, '$1' ); result = result.replace( /_{3}(.+?)_{3}/g, '$1' ); // Bold: **text** or __text__ result = result.replace( /*{2}(.+?)*{2}/g, '$1' ); result = result.replace( /_{2}(.+?)_{2}/g, '$1' ); // Italic: *text* or _text_ result = result.replace( /*(.+?)*/g, '$1' ); result = result.replace( /(?$1' ); // Restore inline code spans result = result.replace( /x00CODE(.+?)CODEx00/g, (_, encoded) => `
${atob(encoded)}` ); return result; } /** * Parse a Markdown string into block-level tokens. */ function parseBlocks(md) { const tokens = []; const lines = md.split('n'); let i = 0; while (i < lines.length) { const line = lines[i]; // Fenced code block: ``` or ```lang if (/^```(.*)$/.test(line)) { const lang = line.match(/^```(.*)$/)[1].trim(); const codeLines = []; i++; while (i < lines.length && !/^```s*$/.test(lines[i])) { codeLines.push(lines[i]); i++; } tokens.push({ type: 'code_block', lang: sanitize(lang), content: sanitize(codeLines.join('n')) }); i++; // skip closing ``` continue; } // Heading: # through ###### const headingMatch = line.match(/^(#{1,6})s+(.+)$/); if (headingMatch) { tokens.push({ type: 'heading', level: headingMatch[1].length, content: parseInline(sanitize(headingMatch[2])) }); i++; continue; } // Horizontal rule: ---, ***, ___ if (/^(*{3,}|-{3,}|_{3,})s*$/.test(line)) { tokens.push({ type: 'hr' }); i++; continue; } // Blockquote: > text if (/^>s?/.test(line)) { const quoteLines = []; while (i s?/.test(lines[i])) { quoteLines.push(lines[i].replace(/^>s?/, '')); i++; } tokens.push({ type: 'blockquote', content: parseInline(sanitize(quoteLines.join('n'))) }); continue; } // Unordered list: - item, * item, + item if (/^[*-+]s+/.test(line)) { const items = []; while (i < lines.length && /^[*-+]s+/.test(lines[i])) { items.push(parseInline(sanitize(lines[i].replace(/^[*-+]s+/, '')))); i++; } tokens.push({ type: 'ul', items }); continue; } // Ordered list: 1. item, 2. item if (/^d+.s+/.test(line)) { const items = []; while (i < lines.length && /^d+.s+/.test(lines[i])) { items.push(parseInline(sanitize(lines[i].replace(/^d+.s+/, '')))); i++; } tokens.push({ type: 'ol', items }); continue; } // Empty line if (/^s*$/.test(line)) { i++; continue; } // Paragraph: default block const paraLines = []; while ( i < lines.length && !/^s*$/.test(lines[i]) && !/^```/.test(lines[i]) && !/^#{1,6}s/.test(lines[i]) && !/^>s?/.test(lines[i]) && !/^[*-+]s+/.test(lines[i]) && !/^d+.s+/.test(lines[i]) && !/^(*{3,}|-{3,}|_{3,})s*$/.test(lines[i]) ) { paraLines.push(lines[i]); i++; } if (paraLines.length > 0) { tokens.push({ type: 'paragraph', content: parseInline(sanitize(paraLines.join(' '))) }); } } return tokens; } /** * Render parsed tokens into an HTML string. */ function renderToHTML(tokens) { return tokens.map(token => { switch (token.type) { case 'heading': return `${token.content}`; case 'code_block': { const langClass = token.lang ? ` class="language-${token.lang}"` : ''; return `${token.content}`;
}case 'blockquote':
return `${token.content.replace(/n/g, '
')}`;
case 'ul':
return `
- ${token.items.map(item => `
- ${item}
`).join('')}
`;
case 'ol':
return `
- ${token.items.map(item => `
- ${item}
`).join('')}
`;
case 'hr':
return '
';
case 'paragraph':
return `
${token.content}
`;
default:
return '';
}
}).join('n');
}
/**
* Main export: parse Markdown string and return HTML.
*/
export function parseMarkdown(md) {
if (!md || !md.trim()) return '';
const tokens = parseBlocks(md);
return renderToHTML(tokens);
}
Let’s break down what’s happening in each section.
Parsing Inline Elements
The parseInline() function uses chained regex replacements to transform inline Markdown syntax. Order matters here: images before links (since image syntax contains link syntax), bold-italic before bold, bold before italic. Inline code gets processed last to prevent other patterns from matching inside code spans.
A note on correctness: this chained-replacement approach works well for the most common cases, but it will produce wrong output for deeply nested or overlapping emphasis patterns. The CommonMark spec defines a sophisticated delimiter-processing algorithm for emphasis that accounts for edge cases like ***strong em*** and mixed delimiter runs. Our approach handles the 90% case cleanly. If you need full spec compliance, that requires a proper state machine, which is exactly what libraries like markdown-it implement.
The underscore-based italic rule uses (?<!w) and (?!w) lookbehind/lookahead assertions to avoid matching underscores inside words like variable_name_here, which aligns with CommonMark behavior for underscores. Lookbehind assertions work in all modern browsers but aren’t available in older environments like Internet Explorer (which is end-of-life and no longer a concern for most projects).
Converting Tokens to HTML
The renderToHTML() function is a straightforward switch over token types. Each token maps to its corresponding HTML element. Sanitization already happened at the point where text content entered the token, so the rendered output is safe for insertion via innerHTML.
The sanitize() function escapes the five characters that can break out of HTML text and attribute contexts: &, <, >, ", and '. For URLs specifically, sanitizeURL() also blocks javascript: protocol injection, which matters because escaping alone isn’t enough when you’re constructing href and src attributes. A more thorough implementation would also block other dangerous URL schemes like data: and vbscript:.
We deliberately don’t pass through raw HTML. Any angle brackets in the Markdown input get escaped. This is safer for our use case and sidesteps an entire category of XSS vectors.
Wiring Up Real-Time Preview
With the parser built, we need to connect it to the DOM so the preview updates as the user types.
Listening for Input Events
The input event is the right choice for real-time preview JavaScript. Unlike keyup, input fires for every value change including paste operations, cut, drag-and-drop text, autocomplete, dictation, and undo/redo. It covers interaction patterns that keyboard events miss entirely.
One consideration: for users entering text via an IME (Input Method Editor) for languages like Japanese or Chinese, input events fire during composition, which can produce intermediate rendering artifacts. A robust production editor would listen for compositionstart and compositionend events to suppress rendering during composition. For our purposes, the basic input listener is sufficient.
For performance on large documents, we debounce the parsing and rendering. Debouncing delays execution until the user stops typing for a specified interval. 200ms works well as a starting point, though you should measure and adjust based on how complex your documents get and how fast your parser runs.
Rendering HTML to the Preview Pane
Here’s the complete app.js wiring everything together:
// app.js
import { parseMarkdown } from './parser.js';
// DOM elements
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const copyHtmlBtn = document.getElementById('copyHtmlBtn');
const downloadBtn = document.getElementById('downloadBtn');
const clearBtn = document.getElementById('clearBtn');
// Storage key
const STORAGE_KEY = 'md-previewer-content';
// --- Debounce utility ---
function debounce(fn, delay = 200) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// --- Core parse-and-render pipeline ---
function parseAndRender() {
const md = editor.value;
const html = parseMarkdown(md);
preview.innerHTML = html;
highlightCodeBlocks();
saveToStorage(md);
}
const debouncedParseAndRender = debounce(parseAndRender, 200);
// --- Input event listener ---
editor.addEventListener('input', debouncedParseAndRender);
// --- Synchronized scrolling ---
function syncScroll() {
const editorMaxScroll = editor.scrollHeight - editor.clientHeight;
const previewMaxScroll = preview.scrollHeight - preview.clientHeight;
if (editorMaxScroll <= 0) return;
const scrollRatio = editor.scrollTop / editorMaxScroll;
preview.scrollTop = scrollRatio * previewMaxScroll;
}
editor.addEventListener('scroll', syncScroll);
// --- LocalStorage persistence ---
function saveToStorage(md) {
try {
localStorage.setItem(STORAGE_KEY, md);
} catch (e) {
// Quota exceeded or storage unavailable
console.warn('Could not save to localStorage:', e.message);
}
}
function loadFromStorage() {
try {
return localStorage.getItem(STORAGE_KEY) ?? '';
} catch (e) {
console.warn('Could not read from localStorage:', e.message);
return '';
}
}
function clearStorage() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn('Could not clear localStorage:', e.message);
}
}
// --- Toolbar: insert Markdown at cursor ---
function insertAtCursor(before, after = '') {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selectedText = editor.value.substring(start, end);
const replacement = before + (selectedText || 'text') + after;
// setRangeText preserves undo history in most browsers
editor.setRangeText(replacement, start, end, 'select');
editor.focus();
debouncedParseAndRender();
}
const toolbarActions = {
bold: () => insertAtCursor('**', '**'),
italic: () => insertAtCursor('*', '*'),
heading: () => insertAtCursor('## ', ''),
link: () => insertAtCursor('[', '](https://example.com)'),
image: () => insertAtCursor(''),
code: () => insertAtCursor('```n', 'n```')
};
document.querySelectorAll('.toolbar button[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (toolbarActions[action]) {
toolbarActions[action]();
}
});
});
// --- Copy HTML to clipboard ---
async function copyHTML() {
const html = preview.innerHTML;
try {
await navigator.clipboard.writeText(html);
copyHtmlBtn.textContent = 'Copied!';
setTimeout(() => { copyHtmlBtn.textContent = 'Copy HTML'; }, 2000);
} catch (e) {
console.warn('Clipboard write failed:', e.message);
}
}
copyHtmlBtn.addEventListener('click', copyHTML);
// --- Download Markdown as .md file ---
function downloadMarkdown() {
const md = editor.value;
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
downloadBtn.addEventListener('click', downloadMarkdown);
// --- Clear button ---
clearBtn.addEventListener('click', () => {
editor.value = '';
preview.innerHTML = '';
clearStorage();
});
// --- Syntax highlighting for code blocks ---
function highlightCodeBlocks() {
preview.querySelectorAll('pre code').forEach(block => {
let html = block.innerHTML;
// Strings (double and single quoted)
html = html.replace(
/(".*?"|'.*?')/g,
'$1'
);
// Single-line comments
html = html.replace(
/(//.*?)(n|$)/g,
'$2'
);
// Keywords
const keywords = [
'const', 'let', 'var', 'function', 'return', 'if', 'else',
'for', 'while', 'class', 'import', 'export', 'from', 'default',
'new', 'this', 'async', 'await', 'try', 'catch', 'throw'
];
const kwRegex = new RegExp(`b(${keywords.join('|')})b`, 'g');
html = html.replace(kwRegex, '$1');
// Numbers
html = html.replace(
/b(d+.?d*)b/g,
'$1'
);
block.innerHTML = html;
});
}
// --- Initialize on load ---
const saved = loadFromStorage();
if (saved) {
editor.value = saved;
}
parseAndRender();
syncScroll() calculates the proportional scroll position between editor and preview. If the user has scrolled 40% through the textarea, the preview scrolls to 40% of its own content height. This is a simple linear mapping. It’s not pixel-perfect (Markdown content and rendered HTML rarely have a 1:1 height relationship), but it’s good enough for most documents and feels natural.
insertAtCursor() uses setRangeText() instead of manually splicing editor.value. That’s deliberate: setRangeText() integrates with the browser’s undo stack, so users can Ctrl+Z after a toolbar action. Direct value assignment breaks undo history. setRangeText() doesn’t automatically fire an input event, which is why we explicitly call debouncedParseAndRender() after using it.
For the download feature, notice the URL.revokeObjectURL(url) call after the click. Object URLs consume memory until they’re explicitly revoked or the document unloads. Cleaning up immediately after the download trigger prevents leaks.
Adding Essential Features
Persisting Content with LocalStorage
The localStorage JavaScript implementation wraps all operations in try/catch blocks. This matters because localStorage can throw in several scenarios: the user is in private browsing mode (some older browsers restricted storage in this mode), the quota is exceeded, or storage is disabled entirely. The default quota is typically around 5 MB per origin in most browsers, but no specification guarantees it and it can vary. Treat storage as a convenience feature that degrades gracefully, not a guarantee.
Content saves on every change through the debounced render pipeline and loads when the page initializes. The clear button removes stored content and resets both panes.
Adding a Toolbar with Common Markdown Actions
The toolbar buttons map to data-action attributes, keeping the HTML declarative and the JavaScript clean. Each action calls insertAtCursor() with the appropriate Markdown delimiters. If text is selected, the delimiters wrap the selection. If nothing is selected, a placeholder word “text” gets inserted between the delimiters so the user can immediately start typing.
Implementing Syntax Highlighting for Code Blocks
highlightCodeBlocks() runs after every render and applies lightweight syntax highlighting by wrapping recognized patterns in elements with CSS classes. It handles four token categories: strings, comments, keywords, and numbers. This is intentionally simple. Real syntax highlighting (like what Prism.js or highlight.js provides) requires language-specific grammars and much more sophisticated tokenization. Our approach gives code blocks visual distinction without adding any dependencies.
Because the code block content was already HTML-escaped by the parser’s sanitize() function, the highlighting regex matches escaped entities like " for strings rather than raw quote characters. We highlight after sanitization, so we’re operating on safe HTML.
Export and Copy Functionality
The Clipboard API (navigator.clipboard.writeText()) requires a secure context: HTTPS or localhost. If you’re testing from a file:// URL, the copy button will fail. Some browsers (notably Safari) also require the Clipboard API call to originate directly from a user-initiated event handler. Wrapping it in an async function as we do here generally satisfies this requirement, but keep this constraint in mind if you refactor the code.
Styling the Preview Output
GitHub-Flavored CSS for Rendered Markdown
To make the rendered Markdown look polished, we need styles targeting the HTML elements our parser generates. This CSS goes into the same style.css file, targeting elements inside the .markdown-body container:
/* Preview pane: GitHub-flavored Markdown styles */
.markdown-body {
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.7;
color: var(--text-primary);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.3em;
}
.markdown-body h3 { font-size: 1.25em; }
.markdown-body h4 { font-size: 1em; }
.markdown-body h5 { font-size: 0.875em; }
.markdown-body h6 {
font-size: 0.85em;
color: var(--text-secondary);
}
.markdown-body p {
margin-bottom: 1em;
}
.markdown-body a {
color: var(--accent);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body em {
font-style: italic;
}
.markdown-body blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid var(--accent);
background: var(--bg-secondary);
border-radius: 0 4px 4px 0;
}
.markdown-body blockquote p {
margin: 0;
}
.markdown-body pre {
margin: 1em 0;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow-x: auto;
}
.markdown-body pre code {
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.6;
background: none;
padding: 0;
border: none;
}
.markdown-body code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border-color);
}
.markdown-body ul,
.markdown-body ol {
margin: 1em 0;
padding-left: 2em;
}
.markdown-body li {
margin-bottom: 0.25em;
}
.markdown-body hr {
margin: 2em 0;
border: none;
border-top: 2px solid var(--border-color);
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1em 0;
}
/* Syntax highlighting classes */
.hl-keyword {
color: #cba6f7;
}
.hl-string {
color: #a6e3a1;
}
.hl-comment {
color: #6c7086;
font-style: italic;
}
.hl-number {
color: #fab387;
}
This stylesheet borrows the structure of popular open-source Markdown CSS packages like github-markdown-css by Sindre Sorhus, adapted for our dark theme. The typography scale uses a clear hierarchy from h1 at 2em down to h6 at 0.85em. Code blocks get distinct background colors and monospace fonts. Blockquotes have a colored left border that visually separates quoted content.
Testing and Edge Cases
Handling Tricky Markdown Scenarios
A regex-based parser has known limitations. Here’s where ours works correctly, and where it falls apart:
Works correctly:
- Standard headings with text content
- Single-level unordered and ordered lists
- Fenced code blocks with or without language tags
- Basic bold, italic, and bold-italic emphasis
- Links and images with simple URLs
- Horizontal rules using three or more dashes, asterisks, or underscores
- Paragraphs separated by blank lines
Known limitations:
- Nested lists (a list item containing a sub-list) won’t parse correctly. CommonMark determines nesting by indentation level, requiring a state machine that tracks indentation context. Our line-by-line parser treats each line independently.
- Mixed list types (switching from ordered to unordered within a single list) get split into separate list tokens.
- Escaped characters like
*not bold*aren’t handled. A proper parser would process backslash escapes before applying emphasis rules. - Emphasis spanning multiple lines or containing other block elements will fail.
- Setext-style headings (underlined with
===or---) aren’t supported; only ATX-style (#) headings are recognized. - Indented code blocks (four-space indent) aren’t supported; only fenced code blocks (triple backtick) are recognized.
- Extremely long documents (10,000+ lines) may show perceptible latency even with debouncing, though this is more of a rendering concern than a parsing concern.
These limitations are exactly why production tools use dedicated parsing libraries. Our parser is a learning exercise that handles the common subset of Markdown you run into in everyday use.
Cross-Browser Verification
The technologies we’re using (ES modules, CSS Grid, the Clipboard API, localStorage, input events) are well-supported across Chrome, Firefox, Safari, and Edge. The one area to verify manually is the Clipboard API: Safari has historically required a user gesture and secure context, and behavior around navigator.clipboard.writeText() has shifted across versions.
For mobile responsiveness, the CSS Grid layout collapses to a single column below 800px viewport width. Test that the textarea remains usable on touch devices and that the preview pane scrolls independently.
Here’s a simple test harness you can run in the browser console or as a separate test script:
// test.js - simple parser validation
// To run: serve the project directory and open test.html, or use a JS runtime
// that supports ES modules (e.g., node --experimental-vm-modules).
// For browser: create a test.html that includes
//
import { parseMarkdown } from './parser.js';
const tests = [
{
name: 'Heading 1',
input: '# Hello World',
expected: 'Hello World
'
},
{
name: 'Heading 3',
input: '### Third Level',
expected: 'Third Level
'
},
{
name: 'Bold text',
input: 'This is **bold** text',
expected: 'This is bold text
'
},
{
name: 'Italic text',
input: 'This is *italic* text',
expected: 'This is italic text
'
},
{
name: 'Inline code',
input: 'Use `console.log()` here',
expected: 'Use console.log() here
'
},
{
name: 'Link',
input: '[Example](https://example.com)',
expected: ''
},
{
name: 'Unordered list',
input: '- Item onen- Item twon- Item three',
expected: '- Item one
- Item two
- Item three
'
},
{
name: 'Horizontal rule',
input: '---',
expected: '
'
},
{
name: 'Empty input',
input: '',
expected: ''
},
{
name: 'Whitespace only',
input: ' n n ',
expected: ''
},
{
name: 'XSS attempt in text',
input: '',
expected: '<script>alert("xss")</script>
'
},
{
name: 'JavaScript URL injection',
input: '[click](javascript:alert(1))',
expected: ''
}
];
let passed = 0;
let failed = 0;
tests.forEach(test => {
const result = parseMarkdown(test.input);
if (result.trim() === test.expected.trim()) {
console.log(`✅ PASS: ${test.name}`);
passed++;
} else {
console.log(`❌ FAIL: ${test.name}`);
console.log(` Expected: ${test.expected}`);
console.log(` Got: ${result}`);
failed++;
}
});
console.log(`nResults: ${passed} passed, ${failed} failed out of ${tests.length} tests`);
This test suite validates the parser in isolation, separate from any DOM interaction. The XSS test cases matter most: they confirm that script injection and javascript: URLs get neutralized.
Performance Optimization
Debouncing and Throttling Input Processing
Our debounce implementation delays parsing until 200ms after the user stops typing. Rapid keystrokes produce only a single parse-and-render cycle rather than one per keystroke.
How does debounce compare to throttle here? Throttle would ensure rendering happens at most once every N milliseconds, regardless of whether the user has stopped typing. For a Markdown previewer, debounce feels more natural: you see the result after a brief pause, not during continuous typing. Throttle fits better for scroll handlers or resize events where you want periodic updates during continuous activity.
The 200ms delay is a reasonable starting point, not a universal best practice. If your parser handles a 500-line document in under 5ms (likely for regex-based parsing), you could drop the delay to 100ms or even 50ms for a snappier feel. Measure with performance.now() around your parseAndRender() call to find the right balance for your content size.
Incremental Parsing Considerations
Our current approach re-parses the entire document on every change. For typical Markdown documents (under a few hundred lines), this is fast enough that users will never notice. For documents with thousands of lines, full re-parsing starts to get expensive, not because of the regex matching itself, but because innerHTML assignment forces the browser to destroy and rebuild the entire preview DOM tree.
Production editors like CodeMirror and ProseMirror address this with diffing strategies: they parse only the changed portion of the document, generate a minimal set of DOM mutations, and apply those incrementally.
Production editors like CodeMirror and ProseMirror address this with diffing strategies: they parse only the changed portion of the document, generate a minimal set of DOM mutations, and apply those incrementally. Implementing this is a significant engineering effort well beyond our scope, but understanding that the bottleneck is DOM manipulation (not parsing) is valuable insight. If you hit performance limits, the first optimization should be a virtual scrolling approach for the preview pane, not a faster parser.
Using requestAnimationFrame for scroll synchronization (rather than handling it synchronously in the scroll event) can also reduce layout thrash, since it batches the position update with the browser’s next paint cycle.
Deploying the Project
Building for Production
Since this is a no-framework JavaScript app with no build step, deployment is remarkably simple. Your four files are production-ready as-is. If you want to optimize further, you can minify the JavaScript and CSS using tools like terser and cssnano, but for a project this size, the unminified files are already small enough that the gains are marginal.
Deploying to GitHub Pages:
Push your project to a GitHub repository, then enable GitHub Pages in the repository settings (Settings > Pages > Source: main branch, root folder). Your site will be live at https://yourusername.github.io/repo-name/ within a few minutes.
For an automated approach with GitHub Actions:
# .github/workflows/deploy.yml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v4
- uses: actions/upload-pages-artifact@v3
with:
path: '.'
- id: deployment
uses: actions/deploy-pages@v4
Deploying to Netlify:
Create a netlify.toml in your project root:
[build]
publish = "."
Then connect your Git repository to Netlify through their dashboard, or use the Netlify CLI: npx netlify-cli deploy --prod. Since there’s no build step, the publish directory is just the project root.
Both platforms serve your site over HTTPS, which the Clipboard API requires to function. If you were testing locally with file:// URLs and the copy button wasn’t working, now you know why.
Extending the Project: Next Steps
Building the base version is just the start. Here are concrete directions to take it further, roughly ordered by complexity:
Add keyboard shortcuts. Map Ctrl+B to bold, Ctrl+I to italic, Ctrl+K to link insertion. Listen for keydown events on the textarea and check event.ctrlKey or event.metaKey (for macOS) combined with the relevant key code. Small feature, big usability win.
Implement GFM tables and task lists. GitHub Flavored Markdown extends CommonMark with pipe-delimited tables and - [ ] / - [x] checkboxes. Adding table parsing requires recognizing the header-separator pattern (|---|---|) and splitting cell content. Task lists extend the unordered list parser with checkbox detection.
Add split-pane drag-to-resize. Insert a draggable handle between the editor and preview panes. On mousedown, track horizontal movement and update the grid-template-columns value dynamically. Good exercise in pointer event handling and CSS manipulation from JavaScript.
Support multiple document tabs. Store an array of documents in localStorage, each with a title and content. Render a tab bar above the editor and switch between documents. This introduces state management patterns without requiring a framework.
Benchmark against production parsers. Install marked.js or markdown-it and render the same document with both your custom parser and the library. Compare output correctness (especially on CommonMark edge cases) and parse time using performance.now(). This gives you concrete data on where your parser diverges and where production parsers invest their complexity.
// Quick benchmark comparison
// Requires a bundler or Node.js environment with these packages installed:
// npm install marked markdown-it
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import { parseMarkdown } from './parser.js';
const mdIt = new MarkdownIt();
const testDoc = '# HellonnSome **bold** and *italic* text.nn- Item 1n- Item 2';
console.time('custom');
const customResult = parseMarkdown(testDoc);
console.timeEnd('custom');
console.time('marked');
const markedResult = marked.parse(testDoc);
console.timeEnd('marked');
console.time('markdown-it');
const mdItResult = mdIt.render(testDoc);
console.timeEnd('markdown-it');
Be fair when reading these results: if your custom parser is faster, it’s almost certainly because it handles fewer features and edge cases, not because it’s better engineered. Feature parity matters in benchmarks.
What You Built and Where It Leads
Over the course of this tutorial, you built a real-time Markdown previewer from scratch using vanilla JavaScript, and along the way you practiced a set of skills that matter far beyond this single project.
Regex-based text parsing taught you how input text can be systematically transformed through pattern matching, and where that approach hits its limits. DOM manipulation and event handling showed you that modern browsers give you everything you need for interactive applications without framework overhead. Security-conscious HTML generation forced you to think about XSS at the point where user input becomes rendered output. CSS Grid layout gave you a responsive two-pane interface with minimal code. And localStorage persistence introduced client-side state management with proper error handling.
Every one of these skills transfers directly to framework-based development. When you write a React component that renders user-generated content, you’ll think about sanitization. When you debug a Vue computed property that re-renders too often, you’ll think about debouncing. When you optimize a Next.js page that feels sluggish, you’ll think about DOM cost.
Every one of these skills transfers directly to framework-based development. When you write a React component that renders user-generated content, you’ll think about sanitization. When you debug a Vue computed property that re-renders too often, you’ll think about debouncing. When you optimize a Next.js page that feels sluggish, you’ll think about DOM cost.
The parser you built is deliberately simplified. Real parsers like those in marked.js and markdown-it use tokenizers, state machines, and abstract syntax trees rather than chained regex. But you now understand the problem space those tools operate in, which makes you a better consumer and potential contributor to them.
Fork the code, extend it, break it, rebuild it. Share your version. And if you find a bug in the parser that teaches you something about CommonMark’s edge cases, that’s the whole point.
Fork the code, extend it, break it, rebuild it. Share your version. And if you find a bug in the parser that teaches you something about CommonMark’s edge cases, that’s the whole point.