type Token = | { kind: "text"; value: string; opaque: boolean } | { kind: "delimiter"; char: string; count: number; originalCount: number; canOpen: boolean; canClose: boolean; }; const UNICODE_PUNCTUATION = /[\p{P}\p{S}]/u; function isWhitespaceBoundary(char: string): boolean { return char === "" || /\s/.test(char); } function isPunctuationBoundary(char: string): boolean { return char !== "" && UNICODE_PUNCTUATION.test(char); } function tokenize(text: string, mark: string): Token[] { const tokens: Token[] = []; let i = 0; while (i < text.length) { const char = text[i]; if (char === mark) { const close = text.indexOf(mark, i + 1); const end = close === -1 ? text.length : close + 1; tokens.push({ kind: "text", value: text.slice(i, end), opaque: true }); i = end; continue; } if (char === "*" || char === "_") { let run = i; while (run < text.length && text[run] === char) run++; tokens.push({ kind: "delimiter", char, count: run - i, originalCount: run - i, canOpen: false, canClose: false, }); i = run; continue; } let next = i + 1; while (next < text.length && text[next] !== "*" && text[next] !== "_" && text[next] !== mark) { next++; } tokens.push({ kind: "text", value: text.slice(i, next), opaque: false }); i = next; } return tokens; } function boundaryBefore(tokens: Token[], index: number): string { for (let i = index - 1; i >= 0; i--) { const token = tokens[i]; if (token.kind === "text") { if (token.opaque) return "a"; if (token.value.length > 0) return token.value[token.value.length - 1]; continue; } if (token.count > 0) return token.char; } return ""; } function boundaryAfter(tokens: Token[], index: number): string { for (let i = index + 1; i < tokens.length; i++) { const token = tokens[i]; if (token.kind === "text") { if (token.opaque) return "a"; if (token.value.length > 0) return token.value[0]; continue; } if (token.count > 0) return token.char; } return ""; } function classifyDelimiters(tokens: Token[]): void { for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (token.kind !== "delimiter") continue; const before = boundaryBefore(tokens, i); const after = boundaryAfter(tokens, i); const leftFlanking = !isWhitespaceBoundary(after) && (!isPunctuationBoundary(after) || isWhitespaceBoundary(before) || isPunctuationBoundary(before)); const rightFlanking = !isWhitespaceBoundary(before) && (!isPunctuationBoundary(before) || isWhitespaceBoundary(after) || isPunctuationBoundary(after)); if (token.char === "*") { token.canOpen = leftFlanking; token.canClose = rightFlanking; } else { token.canOpen = leftFlanking && (!rightFlanking || isPunctuationBoundary(before)); token.canClose = rightFlanking && (!leftFlanking || isPunctuationBoundary(after)); } } } function render(tokens: Token[]): string { let out = ""; for (const token of tokens) { out += token.kind === "text" ? token.value : token.char.repeat(token.count); } return out; } function pairingBlockedByRuleOfThree(opener: Token, closer: Token): boolean { if (opener.kind !== "delimiter" || closer.kind !== "delimiter") return false; const eitherIsBoth = (opener.canOpen && opener.canClose) || (closer.canOpen && closer.canClose); if (!eitherIsBoth) return false; if ((opener.originalCount + closer.originalCount) % 3 !== 0) return false; return !(opener.originalCount % 3 === 0 && closer.originalCount % 3 === 0); } /** * Apply CommonMark emphasis to already-escaped text. * * `mark` is the sentinel wrapping parked placeholders; runs of it are treated * as one opaque word character, so a parked link or code span neither opens nor * closes emphasis but still counts as content beside a delimiter. */ export function renderEmphasis(text: string, mark: string): string { if (!text.includes("*") && !text.includes("_")) return text; const tokens = tokenize(text, mark); classifyDelimiters(tokens); const openersBottom = new Map(); let index = 0; while (index < tokens.length) { const closer = tokens[index]; if (closer.kind !== "delimiter" || !closer.canClose || closer.count === 0) { index++; continue; } const bucket = `${closer.char}${closer.originalCount % 3}${closer.canOpen ? 1 : 0}`; const bottom = openersBottom.get(bucket) ?? -1; let openerIndex = -1; for (let back = index - 1; back > bottom; back--) { const candidate = tokens[back]; if ( candidate.kind === "delimiter" && candidate.canOpen && candidate.char === closer.char && candidate.count > 0 && !pairingBlockedByRuleOfThree(candidate, closer) ) { openerIndex = back; break; } } if (openerIndex === -1) { openersBottom.set(bucket, index - 1); index++; continue; } const opener = tokens[openerIndex]; if (opener.kind !== "delimiter") { index++; continue; } const used = opener.count >= 2 && closer.count >= 2 ? 2 : 1; const tag = used === 2 ? "strong" : "em"; const inner = render(tokens.slice(openerIndex + 1, index)); opener.count -= used; closer.count -= used; tokens.splice(openerIndex + 1, index - openerIndex - 1, { kind: "text", value: `<${tag}>${inner}`, opaque: true, }); for (const [key, value] of openersBottom) { if (value > openerIndex) openersBottom.set(key, openerIndex); } index = openerIndex + 2; } return render(tokens); }