OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-web-ui / lib / render.js

180 lines · javascript ref:
/**
 * Server-side rendering helpers
 *
 * Markdown, syntax highlighting, diff coloring, line numbers, pagination, branch selector.
 * These return HTML strings — usable from both SSR routes and SPA client code.
 */

// ── Markdown ─────────────────────────────────────────────────

// Minimal markdown → HTML (no external dependency)
// Supports: headings, bold, italic, code blocks, inline code, links, images, lists, blockquotes, paragraphs, hr
export function renderMarkdown (text) {
  if (!text) return ''
  let html = esc(text)

  // Code blocks (``` ... ```)
  html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
    return `<pre class="code-block${lang ? ' language-' + lang : ''}"><code>${code.trim()}</code></pre>`
  })

  // Inline code
  html = html.replace(/`([^`]+)`/g, '<code>$1</code>')

  // Headings
  html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
  html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
  html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
  html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
  html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
  html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')

  // Horizontal rule
  html = html.replace(/^---+$/gm, '<hr>')

  // Bold + italic
  html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')

  // Images
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')

  // Links
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')

  // Blockquotes
  html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote>$1</blockquote>')

  // Unordered lists
  html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li>$1</li>')
  html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')

  // Ordered lists
  html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>')

  // Paragraphs (double newline)
  html = html.replace(/\n\n+/g, '</p><p>')
  html = '<p>' + html + '</p>'

  // Clean up empty paragraphs
  html = html.replace(/<p>\s*<\/p>/g, '')
  html = html.replace(/<p>\s*(<h[1-6]>)/g, '$1')
  html = html.replace(/(<\/h[1-6]>)\s*<\/p>/g, '$1')
  html = html.replace(/<p>\s*(<pre)/g, '$1')
  html = html.replace(/(<\/pre>)\s*<\/p>/g, '$1')
  html = html.replace(/<p>\s*(<ul>)/g, '$1')
  html = html.replace(/(<\/ul>)\s*<\/p>/g, '$1')
  html = html.replace(/<p>\s*(<hr>)/g, '$1')
  html = html.replace(/(<hr>)\s*<\/p>/g, '$1')
  html = html.replace(/<p>\s*(<blockquote>)/g, '$1')
  html = html.replace(/(<\/blockquote>)\s*<\/p>/g, '$1')

  return html
}

// ── Diff Coloring ────────────────────────────────────────────

export function renderDiff (patch) {
  if (!patch) return ''
  const lines = patch.split('\n')
  const out = []

  for (const line of lines) {
    const escaped = esc(line)
    if (line.startsWith('+++') || line.startsWith('---')) {
      out.push(`<div class="diff-file">${escaped}</div>`)
    } else if (line.startsWith('@@')) {
      out.push(`<div class="diff-hunk">${escaped}</div>`)
    } else if (line.startsWith('+')) {
      out.push(`<div class="diff-add">${escaped}</div>`)
    } else if (line.startsWith('-')) {
      out.push(`<div class="diff-del">${escaped}</div>`)
    } else {
      out.push(`<div class="diff-ctx">${escaped}</div>`)
    }
  }

  return `<div class="diff-view">${out.join('')}</div>`
}

// ── Line Numbers ─────────────────────────────────────────────

export function renderCode (content, opts = {}) {
  if (!content) return ''
  const lines = content.split('\n')
  const rows = lines.map((line, i) => {
    const num = i + 1
    return `<tr><td class="line-num" data-line="${num}">${num}</td><td class="line-code"><code>${esc(line)}</code></td></tr>`
  }).join('')

  return `<table class="code-table"><tbody>${rows}</tbody></table>`
}

// ── Pagination ───────────────────────────────────────────────

export function renderPagination (total, page, pageSize, baseUrl) {
  const totalPages = Math.ceil(total / pageSize)
  if (totalPages <= 1) return ''

  const links = []
  if (page > 1) {
    links.push(`<a href="${baseUrl}?page=${page - 1}" class="page-link">&laquo; Prev</a>`)
  }

  const start = Math.max(1, page - 2)
  const end = Math.min(totalPages, page + 2)
  for (let i = start; i <= end; i++) {
    if (i === page) {
      links.push(`<span class="page-link page-current">${i}</span>`)
    } else {
      links.push(`<a href="${baseUrl}?page=${i}" class="page-link">${i}</a>`)
    }
  }

  if (page < totalPages) {
    links.push(`<a href="${baseUrl}?page=${page + 1}" class="page-link">Next &raquo;</a>`)
  }

  return `<div class="pagination">${links.join('')}</div>`
}

// ── Branch Selector ──────────────────────────────────────────

export function renderBranchSelector (branches, current, baseUrl) {
  if (!branches || branches.length === 0) return ''
  const options = branches.map(b => {
    const selected = b === current ? ' selected' : ''
    return `<option value="${esc(b)}"${selected}>${esc(b)}</option>`
  }).join('')

  return `<select class="branch-selector" onchange="window.location.hash='${baseUrl}/' + this.value">${options}</select>`
}

// ── Helpers ──────────────────────────────────────────────────

function esc (s) {
  if (!s) return ''
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

// File extension → language hint
export function detectLanguage (filename) {
  if (!filename) return ''
  const ext = filename.split('.').pop().toLowerCase()
  const map = {
    js: 'javascript', mjs: 'javascript', cjs: 'javascript',
    ts: 'typescript', tsx: 'typescript',
    py: 'python', rb: 'ruby', rs: 'rust', go: 'go',
    c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cc: 'cpp',
    java: 'java', kt: 'kotlin', swift: 'swift',
    sh: 'bash', bash: 'bash', zsh: 'bash',
    json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
    md: 'markdown', html: 'html', htm: 'html', xml: 'xml',
    css: 'css', scss: 'scss', less: 'less',
    sql: 'sql', gd: 'gdscript', gdshader: 'glsl', glsl: 'glsl',
    dockerfile: 'docker', makefile: 'makefile'
  }
  return map[ext] || ''
}