type FenceState = { character: "`" | "~"; length: number; owner: ContainerKind | null; }; type ContainerKind = "blockquote" | "list"; type ContainerState = { kind: ContainerKind; afterBlank: boolean; }; export type MarkdownLine = { text: string; ending: string; }; type HtmlBlockState = { kind: "basic" | "comment" | "cdata" | "raw-tag" | "processing-instruction" | "declaration"; tag?: string; }; const SEPARATOR_LINE_RE = /^ {0,3}-{3,}[ \t]*$/; const FENCE_OPEN_LINE_RE = /^ {0,3}((?:`{3,}|~{3,}))/; const FENCE_CLOSE_LINE_RE = /^ {0,3}((?:`{3,}|~{3,}))[ \t]*$/; const ATX_HEADING_RE = /^ {0,3}#{1,6}(?:[ \t]|$)/; const BLOCKQUOTE_RE = /^ {0,3}>/; const LIST_ITEM_RE = /^ {0,3}(?:[-+*](?:[ \t]+|$)|\d{1,9}[.)](?:[ \t]+|$))/; const LIST_FENCE_PREFIX_RE = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+(.+)$/; const BLOCKQUOTE_FENCE_PREFIX_RE = /^ {0,3}>[ \t]?(.*)$/; const HTML_TAG_RE = /^ {0,3}<\/?([A-Za-z][\w:-]*)(?:[ \t]+[^<>]*)?[ \t]*\/?>/; const HTML_PROCESSING_INSTRUCTION_RE = /^ {0,3}<\?/; const HTML_DECLARATION_RE = /^ {0,3}= fence.length); } function isRuleLine(line: string): boolean { const text = withoutLineEnding(line).trim(); if (SEPARATOR_LINE_RE.test(withoutLineEnding(line))) return true; for (const character of ["*", "_", "-"]) { const compact = text.replaceAll(" ", "").replaceAll("\t", ""); if (compact.length >= 3 && compact.split("").every((item) => item === character)) return true; } return false; } function hasUnescapedPipe(line: string): boolean { const text = withoutLineEnding(line); for (let index = 0; index < text.length; index++) { if (text[index] === "|" && text[index - 1] !== "\\") return true; } return false; } function isBlockquoteOrListMarker(line: string): boolean { const text = withoutLineEnding(line); return BLOCKQUOTE_RE.test(text) || LIST_ITEM_RE.test(text); } function isStructuralLine(line: string): boolean { const text = withoutLineEnding(line); const trimmed = text.trim(); return ( /^\t/.test(text) || /^ {4}/.test(text) || ATX_HEADING_RE.test(text) || isBlockquoteOrListMarker(text) || FENCE_OPEN_LINE_RE.test(text) || isRuleLine(text) || hasUnescapedPipe(text) || /^ {0,3}<\/?[A-Za-z!][^>]*>/.test(text) || trimmed === "" ); } function sentenceTerminator(line: string): boolean { const text = withoutLineEnding(line).trim(); const withoutClosingMarkers = text.replace(/[\\`*_)\]}>'"]+$/u, ""); return /[.!?]$/u.test(withoutClosingMarkers); } function isEligibleProse(line: string, container: ContainerState | null): boolean { const text = withoutLineEnding(line); const trimmed = text.trim(); if (container || trimmed === "" || isStructuralLine(text)) return false; if (!/\s/u.test(trimmed)) return false; const codePointLength = Array.from(trimmed).length; return codePointLength >= 70 || (codePointLength >= 40 && sentenceTerminator(text)); } function containerMarker(line: string): ContainerKind | null { const text = withoutLineEnding(line); if (BLOCKQUOTE_RE.test(text)) return "blockquote"; if (LIST_ITEM_RE.test(text)) return "list"; return null; } function updateContainer( current: ContainerState | null, line: string, eligible: boolean, ): ContainerState | null { if (isBlank(line)) { return current?.kind === "list" ? { ...current, afterBlank: true } : null; } if (current?.kind === "list" && isIndentedListContinuation(line)) { return { ...current, afterBlank: false }; } if (current?.afterBlank) return null; if (isRuleLine(line)) return null; const marker = containerMarker(line); if (marker) return { kind: marker, afterBlank: false }; if (eligible) return null; return current; } function isContainerBoundary( line: string, fenceStart: FenceState | null, htmlStart: HtmlBlockState | null, container: ContainerState | null, ): boolean { const text = withoutLineEnding(line); const isIndentedContinuation = container?.kind === "list" && isIndentedListContinuation(text); const listEndedAfterBlank = container?.kind === "list" && container.afterBlank && !isIndentedContinuation; return ( listEndedAfterBlank || (!isIndentedContinuation && (ATX_HEADING_RE.test(text) || isRuleLine(text) || Boolean(htmlStart) || (Boolean(fenceStart) && fenceStart?.owner === null))) ); } function isIndentedListContinuation(line: string): boolean { return /^(?: {2,}|\t)\S/u.test(withoutLineEnding(line)); } function htmlBlockStart(line: string): HtmlBlockState | null { const source = withoutLineEnding(line); const text = source.replace(/^ {0,3}/u, ""); if (text.startsWith("")) return { kind: "comment" }; if (text.startsWith("")) { return { kind: "cdata" }; } if (HTML_PROCESSING_INSTRUCTION_RE.test(source) && !text.includes("?>")) { return { kind: "processing-instruction" }; } if (HTML_DECLARATION_RE.test(source) && !text.includes(">")) { return { kind: "declaration" }; } const match = HTML_TAG_RE.exec(source); if (!match) return null; const tag = match[1].toLowerCase(); const isClosing = /^ {0,3}<\//u.test(source); if (!isClosing && HTML_RAW_TAGS.has(tag)) return { kind: "raw-tag", tag }; return { kind: "basic" }; } function htmlBlockEnd(state: HtmlBlockState, line: string): boolean { const text = withoutLineEnding(line); if (state.kind === "comment") return text.includes("-->"); if (state.kind === "cdata") return text.includes("]]>"); if (state.kind === "processing-instruction") return text.includes("?>"); if (state.kind === "declaration") return text.includes(">"); if (state.kind === "raw-tag") { return Boolean(state.tag && new RegExp(``, "i").test(text)); } return isBlank(line); } function isFrontMatterBoundary(line: string): boolean { const text = withoutLineEnding(line).trim(); return text === "---" || text === "..."; } function isFrontMatterStart(line: string): boolean { const text = withoutLineEnding(line); return text === "---"; } function firstNonBlankIndex(lines: MarkdownLine[]): number { return lines.findIndex((line) => !isBlank(line.text)); } type SeparatorScanState = { container: ContainerState | null; fence: FenceState | null; frontMatter: boolean; frontMatterStart: number; htmlBlock: HtmlBlockState | null; output: MarkdownLine[]; previousEligible: boolean; }; function handleFrontMatterLine( state: SeparatorScanState, line: MarkdownLine, index: number, ): boolean { if (!state.frontMatter) return false; state.output.push(line); state.previousEligible = false; if (index > state.frontMatterStart && isFrontMatterBoundary(line.text)) state.frontMatter = false; return true; } function fenceCloseLine(line: string, fence: FenceState): string { if (fence.owner !== "blockquote") return line; return BLOCKQUOTE_FENCE_PREFIX_RE.exec(withoutLineEnding(line))?.[1] ?? line; } function handleFenceLine(state: SeparatorScanState, line: MarkdownLine): boolean { if (!state.fence) return false; state.output.push(line); state.previousEligible = false; if (isFenceClose(fenceCloseLine(line.text, state.fence), state.fence)) { const owner = state.fence.owner; state.fence = null; state.container = owner === "list" ? { kind: "list", afterBlank: false } : null; } return true; } function handleHtmlLine(state: SeparatorScanState, line: MarkdownLine): boolean { if (!state.htmlBlock) return false; state.output.push(line); state.previousEligible = false; if (htmlBlockEnd(state.htmlBlock, line.text)) state.htmlBlock = null; return true; } function handleSeparatorLine( state: SeparatorScanState, lines: MarkdownLine[], index: number, ): boolean { const line = lines[index]; if (!line || !isSeparatorLine(line.text) || !state.previousEligible) return false; const previous = lines[index - 1]; state.output.push({ text: "", ending: previous?.ending ?? line.ending }); state.output.push(line); state.previousEligible = false; return true; } function handleOrdinaryLine(state: SeparatorScanState, line: MarkdownLine): void { state.output.push(line); const containerFence = isContainerFenceOpen(line.text) ?? (state.container?.kind === "list" && isIndentedListContinuation(line.text) ? isFenceOpen(line.text.trimStart()) : null); const fenceStart = containerFence ? { ...containerFence, owner: containerFence.owner ?? state.container?.kind ?? null } : isFenceOpen(line.text); const htmlStart = htmlBlockStart(line.text); if (isContainerBoundary(line.text, fenceStart, htmlStart, state.container)) { state.container = null; } if (fenceStart) { state.fence = fenceStart; state.container = fenceStart.owner ? { kind: fenceStart.owner, afterBlank: false } : null; state.previousEligible = false; return; } if (htmlStart) { state.htmlBlock = htmlBlockEnd(htmlStart, line.text) ? null : htmlStart; state.previousEligible = false; return; } const eligible = isEligibleProse(line.text, state.container); state.previousEligible = eligible; state.container = updateContainer(state.container, line.text, eligible); } /** * Inserts one blank line before a long prose line's hyphen separator. The * caller supplies source lines so existing line endings and fence repairs stay * byte-for-byte unchanged except for the inserted separator line. */ export function normalizeProseSeparators(lines: MarkdownLine[]): MarkdownLine[] { const frontMatterStart = firstNonBlankIndex(lines); const state: SeparatorScanState = { container: null, fence: null, frontMatter: frontMatterStart >= 0 && isFrontMatterStart(lines[frontMatterStart].text), frontMatterStart, htmlBlock: null, output: [], previousEligible: false, }; for (let index = 0; index < lines.length; index++) { const line = lines[index]; if (!line) continue; if (handleFrontMatterLine(state, line, index)) continue; if (handleFenceLine(state, line)) continue; if (handleHtmlLine(state, line)) continue; if (handleSeparatorLine(state, lines, index)) continue; handleOrdinaryLine(state, line); } return state.output; }