export interface MarkdownSection { heading: string; level: number; content: string; startOffset: number; endOffset: number; } const HEADING_RE = /^(#{1,6})\s+(.*)$/gm; function parseFallbackSections(body: string): MarkdownSection[] { if (!body.trim()) return []; const isPseudoHeading = (text: string): boolean => { const t = text.trim(); return ( t.length > 0 && t.length <= 120 && !t.includes('\n') && !/^[-*+>]/.test(t) && !/^\d+\./.test(t) && !/^`{3,}/.test(t) ); }; // Build paragraph list with exact start/end offsets const separatorRe = /\n{2,}/g; const paragraphs: Array<{ text: string; start: number; end: number }> = []; let lastEnd = 0; let sep: RegExpExecArray | null; while ((sep = separatorRe.exec(body)) !== null) { if (sep.index > lastEnd) paragraphs.push({ text: body.slice(lastEnd, sep.index), start: lastEnd, end: sep.index }); lastEnd = sep.index + sep[0].length; } if (lastEnd < body.length) paragraphs.push({ text: body.slice(lastEnd), start: lastEnd, end: body.length }); if (paragraphs.length === 0) return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }]; const headingIndices = new Set( paragraphs.map((p, i) => isPseudoHeading(p.text) ? i : -1).filter(i => i >= 0) ); if (headingIndices.size === 0) { // No pseudo-headings — check if body is a bullet/numbered list. // If so, treat each top-level item (no leading spaces) as a section. const TOP_BULLET_RE = /^(?:[-*+]|\d+\.) /gm; const bulletStarts: number[] = []; let bm: RegExpExecArray | null; while ((bm = TOP_BULLET_RE.exec(body)) !== null) { bulletStarts.push(bm.index); } if (bulletStarts.length > 1) { return bulletStarts.map((startIdx, i) => { const endIdx = i + 1 < bulletStarts.length ? bulletStarts[i + 1] : body.length; const content = body.slice(startIdx, endIdx); const firstLine = content.split('\n')[0].replace(/^(?:[-*+]|\d+\.) /, '').trim(); return { heading: firstLine.length > 50 ? firstLine.slice(0, 50) + '\u2026' : firstLine, level: 0, content, startOffset: startIdx, endOffset: endIdx, }; }); } return [{ heading: '', level: 0, content: body, startOffset: 0, endOffset: body.length }]; } const sections: MarkdownSection[] = []; const headingIdxArray = [...headingIndices].sort((a, b) => a - b); const firstHi = headingIdxArray[0]; // Preamble (content before first pseudo-heading) if (firstHi > 0) { sections.push({ heading: '', level: 0, content: body.slice(paragraphs[0].start, paragraphs[firstHi].start), startOffset: paragraphs[0].start, endOffset: paragraphs[firstHi].start, }); } // One section per pseudo-heading for (let h = 0; h < headingIdxArray.length; h++) { const hi = headingIdxArray[h]; const nextHi = h + 1 < headingIdxArray.length ? headingIdxArray[h + 1] : null; const sectionEnd = nextHi !== null ? paragraphs[nextHi].start : body.length; sections.push({ heading: paragraphs[hi].text.trim(), level: 0, content: body.slice(paragraphs[hi].start, sectionEnd), startOffset: paragraphs[hi].start, endOffset: sectionEnd, }); } return sections; } export function parseMarkdownSections(body: string): MarkdownSection[] { const sections: MarkdownSection[] = []; const matches: { index: number; level: number; heading: string }[] = []; let match: RegExpExecArray | null; while ((match = HEADING_RE.exec(body)) !== null) { matches.push({ index: match.index, level: match[1].length, heading: match[0], }); } // Preamble: text before the first heading if (matches.length === 0) { return parseFallbackSections(body); } if (matches[0].index > 0) { sections.push({ heading: "", level: 0, content: body.slice(0, matches[0].index), startOffset: 0, endOffset: matches[0].index, }); } for (let i = 0; i < matches.length; i++) { const start = matches[i].index; const end = i + 1 < matches.length ? matches[i + 1].index : body.length; sections.push({ heading: matches[i].heading, level: matches[i].level, content: body.slice(start, end), startOffset: start, endOffset: end, }); } return sections; }