ESC

Type to search the knowledge base.

Split Markdown Editor

Machine-coding brief for split markdown edit/preview — sync scroll optional, sanitize HTML, debounce render.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • markdown

Problem statement

Build a split markdown editor: left pane raw markdown textarea, right pane live preview. Interviewers score controlled state, safe HTML rendering, and performance (don’t re-parse on every keystroke without debounce). Full CommonMark compliance is not required — a minimal subset or a library is fine if allowed.

Requirements

Must have

  • Textarea bound to markdown string
  • Preview updates from markdown
  • Support at least: headings, bold/italic, links, lists, code (via library or tiny parser)
  • Layout: 50/50 split on desktop; stack on small screens
  • Accessible labels for editor and preview

Should have

  • Debounced preview (100–200ms)
  • Sanitize HTML if using dangerouslySetInnerHTML
  • Word/char count
  • Toolbar insert helpers (**bold**)

Nice to have

  • Sync scroll between panes
  • LocalStorage draft
  • Diff view

Planning (5 minutes out loud)

  1. Library policy — ask if marked + DOMPurify allowed; else subset regex parser for demo
  2. Single source of truth — markdown string
  3. Security — never unsanitized HTML
  4. MVP — textarea + preview with bold/heading; then debounce + sanitize
  5. contentEditable is the wrong tree for this problem

Architecture

SplitMarkdownEditor
├── EditorPane (textarea)
├── PreviewPane (article)
└── useDebouncedValue + renderMarkdown

API

type SplitMarkdownEditorProps = {
  value: string;
  onChange: (value: string) => void;
  debounceMs?: number;
};

Implementation sketch

Debounce

function useDebouncedValue<T>(value: T, ms: number): T {
  const [v, setV] = useState(value);
  useEffect(() => {
    const id = window.setTimeout(() => setV(value), ms);
    return () => window.clearTimeout(id);
  }, [value, ms]);
  return v;
}

Tiny safe subset (if no libs)

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

/** Demo-only subset — not CommonMark */
function renderMarkdownLite(md: string): string {
  const escaped = escapeHtml(md);
  return escaped
    .replace(/^### (.*)$/gm, "<h3>$1</h3>")
    .replace(/^## (.*)$/gm, "<h2>$1</h2>")
    .replace(/^# (.*)$/gm, "<h1>$1</h1>")
    .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
    .replace(/\*(.+?)\*/g, "<em>$1</em>")
    .replace(/`([^`]+)`/g, "<code>$1</code>")
    .replace(
      /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
      '<a href="$2" rel="noopener noreferrer">$1</a>'
    )
    .replace(/\n/g, "<br />");
}

With libraries:

import { marked } from "marked";
import DOMPurify from "dompurify";

function renderMarkdown(md: string) {
  const html = marked.parse(md, { async: false }) as string;
  return DOMPurify.sanitize(html);
}

Component

function SplitMarkdownEditor({
  value,
  onChange,
  debounceMs = 150,
}: SplitMarkdownEditorProps) {
  const debounced = useDebouncedValue(value, debounceMs);
  const html = useMemo(() => renderMarkdownLite(debounced), [debounced]);

  return (
    <div className="split-md">
      <label className="pane">
        <span>Markdown</span>
        <textarea
          value={value}
          onChange={(e) => onChange(e.target.value)}
          spellCheck={false}
        />
      </label>
      <div className="pane">
        <span id="preview-label">Preview</span>
        <article
          className="preview"
          aria-labelledby="preview-label"
          dangerouslySetInnerHTML={{ __html: html }}
        />
      </div>
    </div>
  );
}

Toolbar insert

function wrapSelection(
  value: string,
  start: number,
  end: number,
  before: string,
  after: string
) {
  return {
    next:
      value.slice(0, start) +
      before +
      value.slice(start, end) +
      after +
      value.slice(end),
    caret: end + before.length + after.length,
  };
}

Accessibility essentials

  • Labeled textarea
  • Preview is an article with heading; not a live region on every keystroke (too noisy) — optional polite update on debounce settle is debatable
  • Links in preview open safely (rel on user links)
  • Keyboard usable toolbar buttons

Performance notes

  • Debounce parse; memo HTML
  • Large docs: Web Worker for parse
  • Avoid syncing scroll every pixel without rAF

Sync scroll (optional)

function syncScroll(source: HTMLElement, target: HTMLElement) {
  const ratio =
    source.scrollTop / (source.scrollHeight - source.clientHeight || 1);
  target.scrollTop = ratio * (target.scrollHeight - target.clientHeight);
}

Footguns

  1. XSS via raw HTML in markdown (<script>) — escape/sanitize
  2. javascript: URLs in links
  3. Re-parsing on every key without debounce → jank
  4. Controlled textarea caret jumps if you reformat value while typing
  5. Claiming full markdown while shipping regex toys — be honest

Interview out-loud answer

Split editor keeps one markdown string. The textarea is controlled; preview renders a debounced, sanitized HTML transformation. I’d use marked+DOMPurify if allowed, otherwise a tiny escaped subset. Security of links and raw HTML is the first footgun I mention. Sync scroll and drafts are polish.

Further reading