/** * Lightweight AsciiDoc-to-HTML converter for news posts. * Handles: paragraphs, headings, bold, italic, monospace, links, lists, source blocks. */ import { escapeHtml, escapeAttr } from './escape'; import { sanitizeUrl } from './url-safety'; export function renderAsciiDocLite(text: string): string { if (!text) return ''; const output: string[] = []; let paragraphBuf: string[] = []; function flushParagraph() { if (paragraphBuf.length > 0) { output.push(`

${paragraphBuf.join(' ')}

`); paragraphBuf = []; } } const lines = text.split('\n'); let i = 0; let inSourceBlock = false; let sourceLines: string[] = []; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); // Source block delimiter if (trimmed.match(/^-{4,}\s*$/) || trimmed.match(/^\.{4,}\s*$/)) { if (inSourceBlock) { output.push(`
${sourceLines.map(escapeHtml).join('\n')}
`); sourceLines = []; inSourceBlock = false; } else { flushParagraph(); inSourceBlock = true; } i++; continue; } if (inSourceBlock) { sourceLines.push(line); i++; continue; } // Empty line — paragraph break if (!trimmed) { flushParagraph(); i++; continue; } // Headings const headingMatch = trimmed.match(/^(={1,5})\s+(.+)$/); if (headingMatch) { flushParagraph(); const level = headingMatch[1].length + 1; output.push(`${inlineFormat(headingMatch[2])}`); i++; continue; } // Unordered list item if (trimmed.match(/^\*+\s+/)) { flushParagraph(); const items: string[] = []; while (i < lines.length && lines[i].trim().match(/^\*+\s+/)) { const itemLine = lines[i].trim(); const stars = itemLine.match(/^(\*+)\s+/)?.[1].length ?? 1; const text = itemLine.replace(/^\*+\s+/, ''); items.push(`
  • ${inlineFormat(text)}
  • `); i++; } output.push(``); continue; } // Ordered list item if (trimmed.match(/^\.\s+/)) { flushParagraph(); const items: string[] = []; while (i < lines.length && lines[i].trim().match(/^\.\s+/)) { items.push(`
  • ${inlineFormat(lines[i].trim().replace(/^\.\s+/, ''))}
  • `); i++; } output.push(`
      ${items.join('')}
    `); continue; } // Regular text — accumulate into paragraph buffer paragraphBuf.push(inlineFormat(trimmed)); i++; } flushParagraph(); return output.join('\n'); } function inlineFormat(text: string): string { // AsciiDoc link: https://example.com[text] text = text.replace(/(https?:\/\/[^\s\[]+)\[([^\]]*)\]/g, (_, url, label) => { const href = sanitizeUrl(url); if (!href) return escapeHtml(label || url); return `${escapeHtml(label || url)}`; }); // Bare URLs text = text.replace(/(?)(https?:\/\/[^\s<]+)/g, url => { const href = sanitizeUrl(url); if (!href) return escapeHtml(url); return `${escapeHtml(url)}`; }); // Monospace: `text` text = text.replace(/`([^`]+)`/g, '$1'); // Bold: *text* text = text.replace(/\*([^*]+)\*/g, '$1'); // Italic: _text_ text = text.replace(/_([^_]+)_/g, '$1'); return text; }