import { parseComponents } from './parseComponents'; import { parseLists, renderList } from './parseLists'; import { parseMediaEmbeds } from './parseMediaEmbeds'; import { parseTables, renderTable } from './parseTables'; import { formatText } from './parseTextFormats'; const HEADING_PATTERN = /^(#{2,6})\s+(.+)$/; const TOC_MARKER = /<>/i; const NOTOC_MARKER = /<>/i; interface Section { level: number; rawText: string; processedText: string; id: string; number: string; lineNumber: number; children: Section[]; } export function processBogamark(content: string): string { const lines = content.split('\n'); const sections = parseSections(lines); const tocLineNumber = findTocLine(lines); const hasNoToc = findNoTocLine(lines) !== -1; numberSections(sections); return buildHtml({ lines, sections, tocLineNumber, hasNoToc }); } function parseSections(lines: string[]): Section[] { const sections: Section[] = []; const stack: Section[] = []; lines.forEach((line, i) => { const match = line.match(HEADING_PATTERN); if (!match) return; const level = match[1].length; const rawText = match[2]; const section: Section = { level, rawText, processedText: formatText(rawText), id: generateId(rawText), number: '', lineNumber: i, children: [], }; while (stack.length && stack[stack.length - 1].level >= level) { stack.pop(); } const parent = stack[stack.length - 1]; (parent ? parent.children : sections).push(section); stack.push(section); }); return sections; } function isTocLine(line: string): boolean { return !!line.trim().match(TOC_MARKER); } function findTocLine(lines: string[]): number { for (let i = 0; i < lines.length; i++) { if (isTocLine(lines[i])) return i; } return -1; } function findNoTocLine(lines: string[]): number { return lines.findIndex(line => NOTOC_MARKER.test(line)); } function numberSections(sections: Section[], counters: number[] = []): void { sections.forEach(section => { while (counters.length <= section.level) counters.push(0); counters[section.level]++; counters.length = section.level + 1; section.number = counters.slice(2).join('.'); if (section.children.length) { numberSections(section.children, [...counters]); } }); } function buildHtml({ lines, sections, tocLineNumber, hasNoToc, }: { lines: string[]; sections: Section[]; tocLineNumber: number; hasNoToc: boolean; }): string { const { blocks: tableBlocks, processedLines: tableLines } = parseTables(lines); const { blocks: listBlocks, processedLines: listLines } = parseLists(lines, tableLines); const { blocks: mediaBlocks, processedLines: mediaLines } = parseMediaEmbeds(lines); const { blocks: componentBlocks, processedLines: componentLines } = parseComponents(lines); const allProcessedLines = new Set([ ...tableLines, ...listLines, ...mediaLines, ...componentLines, ]); const sectionMap = createLineMap(sections); const listMap = createBlockMap(listBlocks); const tableMap = createBlockMap(tableBlocks); const mediaMap = createBlockMap(mediaBlocks); const componentMap = createBlockMap(componentBlocks); const tocHtml = sections.length && !hasNoToc ? buildToc(sections) : null; const htmlParts: string[] = []; let paragraph: string[] = []; let currentSection: Section | null = null; let sectionContent: string[] = []; const flushParagraph = () => { if (paragraph.length) { const html = `

${formatText(paragraph.join(' '))}

`; if (currentSection) { sectionContent.push(html); } else { htmlParts.push(html); } paragraph = []; } }; const sectionContentMap = new Map(); const flushSection = () => { if (currentSection && sectionContent.length) { sectionContentMap.set(currentSection, [...sectionContent]); sectionContent = []; } }; lines.forEach((line, i) => { if (allProcessedLines.has(i)) { flushParagraph(); const block = listMap.get(i) || tableMap.get(i) || mediaMap.get(i) || componentMap.get(i); if (block) { let html: string; if ('items' in block) { html = renderList(block); } else if ('rows' in block) { html = renderTable(block); } else { html = block.html; } if (currentSection) { sectionContent.push(html); } else { htmlParts.push(html); } } return; } if (isTocLine(line)) { if (i === tocLineNumber && tocHtml) { flushParagraph(); if (currentSection) { sectionContent.push(tocHtml); } else { htmlParts.push(tocHtml); } } return; } if (NOTOC_MARKER.test(line)) { flushParagraph(); return; } const section = sectionMap.get(i); if (section) { flushParagraph(); flushSection(); currentSection = section; return; } const trimmed = line.trim(); if (!trimmed) { flushParagraph(); } else { paragraph.push(line); } }); flushParagraph(); flushSection(); const buildNestedSections = (sections: Section[]): string => { return sections .map(section => { const content = sectionContentMap.get(section) || []; const childrenHtml = buildNestedSections(section.children); const numberLink = section.number ? `${section.number}. ` : ''; return `
${numberLink}${section.processedText}
${content.join('')} ${childrenHtml}
`; }) .join('\n'); }; htmlParts.push(buildNestedSections(sections)); if (tocLineNumber === -1 && tocHtml) { htmlParts.unshift(tocHtml); } return htmlParts.join('\n'); } function buildToc(sections: Section[]): string { if (!sections.length) return ''; const buildList = (items: Section[]): string => { const listItems = items .map(section => { const children = section.children.length ? buildList(section.children) : ''; return `
  • ${section.number}. ${section.processedText} ${children}
  • `; }) .join('\n'); return `
      \n${listItems}\n
    `; }; return `
    Table of Contents
    ${buildList(sections)}
    `; } function createLineMap(sections: Section[]): Map { const map = new Map(); const addToMap = (items: Section[]) => { items.forEach(section => { map.set(section.lineNumber, section); addToMap(section.children); }); }; addToMap(sections); return map; } function createBlockMap(blocks: T[]): Map { return new Map(blocks.map(block => [block.startLine, block])); } function generateId(text: string): string { return text .replace(/<[^>]*>/g, '') .replace(/[^\w\s가-힣-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); } export { generateId };