← All projects

Node.js · Vercel Functions · JavaScript

Markdown Notes

A notes app where Markdown is rendered to HTML entirely on the server by a hand-written parser — no client-side library.

Type Markdown on the left, see it rendered on the right — but the rendering happens on the server. The backend ships a small hand-written Markdown-to-HTML converter (headings, bold/italic, lists, links, code blocks, blockquotes) instead of pulling in a client-side Markdown library.

Live demo

embed
function escapeHtml(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function inline(text) {
  let out = escapeHtml(text);
  out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
  out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
  out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
  out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  return out;
}

function renderMarkdown(markdown) {
  const lines = (markdown || '').replace(/\r\n/g, '\n').split('\n');
  const html = [];
  let inCode = false;
  let listOpen = false;
  let quoteOpen = false;

  const closeList = () => { if (listOpen) { html.push('</ul>'); listOpen = false; } };
  const closeQuote = () => { if (quoteOpen) { html.push('</blockquote>'); quoteOpen = false; } };

  for (const raw of lines) {
    if (raw.trim().startsWith('```')) {
      if (!inCode) { closeList(); closeQuote(); html.push('<pre><code>'); inCode = true; }
      else { html.push('</code></pre>'); inCode = false; }
      continue;
    }
    if (inCode) { html.push(escapeHtml(raw)); continue; }

    const heading = raw.match(/^(#{1,6})\s+(.*)$/);
    if (heading) {
      closeList(); closeQuote();
      const level = heading[1].length;
      html.push(`<h${level}>${inline(heading[2])}</h${level}>`);
      continue;
    }

    if (/^>\s?/.test(raw)) {
      closeList();
      if (!quoteOpen) { html.push('<blockquote>'); quoteOpen = true; }
      html.push(`<p>${inline(raw.replace(/^>\s?/, ''))}</p>`);
      continue;
    }
    closeQuote();

    if (/^\s*[-*]\s+/.test(raw)) {
      if (!listOpen) { html.push('<ul>'); listOpen = true; }
      html.push(`<li>${inline(raw.replace(/^\s*[-*]\s+/, ''))}</li>`);
      continue;
    }
    closeList();

    if (raw.trim() === '') continue;
    html.push(`<p>${inline(raw)}</p>`);
  }
  closeList(); closeQuote();
  if (inCode) html.push('</code></pre>');

  return html.join('\n');
}

module.exports = { renderMarkdown };
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const titleInput = document.getElementById('title');
const saveBtn = document.getElementById('save');
const list = document.getElementById('list');

let debounceTimer = null;

async function renderPreview() {
  const res = await fetch('/api/render', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ markdown: editor.value }),
  });
  const data = await res.json();
  preview.innerHTML = data.html;
}

editor.addEventListener('input', () => {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(renderPreview, 250);
});

async function loadNotes() {
  const res = await fetch('/api/notes');
  const notes = await res.json();
  list.innerHTML = notes.map((n) => `
    <div class="card" style="margin-top:10px">
      <strong>${n.title}</strong>
      <div class="preview" style="margin-top:8px">${n.html}</div>
    </div>
  `).join('') || '<p style="color:var(--muted)">No notes saved yet.</p>';
}

saveBtn.addEventListener('click', async () => {
  await fetch('/api/notes', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: titleInput.value, markdown: editor.value }),
  });
  loadNotes();
});

renderPreview();
loadNotes();