Files
paseo/packages/app/src/components/markdown/html-ish.ts
T
paseo-bot[bot] b58af43a06 fix(app): render mixed HTML headings without literal tags (#6650)
Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 18:14:08 +02:00

927 lines
26 KiB
TypeScript

import { Parser } from "htmlparser2";
export interface MarkdownTextPart {
kind: "markdown";
text: string;
}
export interface MarkdownDetailsPart {
kind: "details";
summary: string;
body: string;
bodyParts?: MarkdownDisplayPart[];
}
export interface MarkdownInlineImagePart {
kind: "inlineImage";
alt: string;
src: string;
href?: string;
width?: number;
height?: number;
/** The image starts a line and text continues on that same line. */
flowsWithText?: boolean;
}
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const HTML_TAG_RE = new RegExp(
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
"y",
);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
em: ["*", "*"],
i: ["*", "*"],
s: ["~~", "~~"],
strike: ["~~", "~~"],
strong: ["**", "**"],
};
interface ProtectedMarkdownRange {
start: number;
end: number;
}
interface MarkdownImageDimensions {
width?: number;
height?: number;
}
interface HtmlTextToken {
kind: "text";
value: string;
}
interface HtmlCommentToken {
kind: "comment";
}
interface HtmlTagToken {
kind: "tag";
name: string;
closing: boolean;
selfClosing: boolean;
attributes: Record<string, string>;
raw: string;
}
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
interface InlineImageParseResult {
part: MarkdownInlineImagePart;
end: number;
}
interface MarkdownDelimiterMatch {
index: number;
end: number;
}
interface HtmlishTokenParser {
write(chunk: string): void;
skip(length: number): void;
end(): void;
}
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
}
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
const parts: MarkdownDisplayPart[] = [];
let cursor = 0;
while (cursor < tokens.length) {
const token = tokens[cursor];
if (isOpenTag(token, "details")) {
const closeIndex = findMatchingClose(tokens, cursor, "details");
if (closeIndex !== null) {
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
if (details) {
parts.push(details);
cursor = closeIndex + 1;
continue;
}
}
}
const inlineImage = parseInlineImageAt(tokens, cursor);
if (inlineImage) {
parts.push(
flowsWithFollowingText(tokens, cursor, inlineImage.end)
? { ...inlineImage.part, flowsWithText: true }
: inlineImage.part,
);
cursor = inlineImage.end;
continue;
}
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
return parts;
}
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
const token = tokens[start];
if (token?.kind !== "tag" || token.closing) {
return null;
}
if (token.name === "img") {
const image = imageTokenToInlineImage(token, undefined);
return image ? { part: image, end: start + 1 } : null;
}
if (token.name !== "a" && token.name !== "picture" && !isImageBlockWrapper(token)) {
return null;
}
return parseWrappedImageAt(tokens, start);
}
function parseWrappedImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
// Walk an image-only wrapper chain once. Recursing over copied child slices
// makes deeply nested review HTML quadratic and can exhaust the JS stack.
const wrappers: string[] = [];
let image: MarkdownInlineImagePart | null = null;
let href: string | undefined;
let hasLink = false;
for (let index = start; index < tokens.length; index += 1) {
const child = tokens[index];
if (child.kind === "comment" || isWhitespaceText(child)) {
continue;
}
if (child.kind !== "tag") {
return null;
}
if (child.closing) {
if (!image || wrappers.pop() !== child.name) {
return null;
}
if (wrappers.length === 0) {
return { part: image, end: index + 1 };
}
continue;
}
// Pictures use their img fallback, not responsive source selection.
if (child.name === "source" && wrappers.at(-1) === "picture") {
continue;
}
if (image) {
return null;
}
if (child.name === "img") {
image = imageTokenToInlineImage(child, href);
if (!image) {
return null;
}
continue;
}
if (child.name !== "a" && child.name !== "picture" && !isImageBlockWrapper(child)) {
return null;
}
if (child.name === "a" && !hasLink) {
href = safeHref(child.attributes.href);
hasLink = true;
}
wrappers.push(child.name);
}
return null;
}
function isImageBlockWrapper(token: HtmlToken | undefined): boolean {
return isHeadingTag(token) || isOpenTag(token, "p") || isOpenTag(token, "div");
}
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
if (isImageBlockWrapper(tokens[start])) {
return false;
}
const previous = tokens[start - 1];
// At line start if nothing precedes this image, or the preceding token ends with only
// whitespace since the last newline (covers a bare space between two images on the same line).
const atLineStart =
previous === undefined ||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
if (!atLineStart) {
return false;
}
// Scan forward past same-line whitespace-only text tokens and inline images to find
// the first substantive text token, without crossing a newline.
let cursor = end;
while (cursor < tokens.length) {
const token = tokens[cursor];
if (token === undefined) {
break;
}
if (token.kind === "text") {
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
if (sameLine.trim().length > 0) {
return true;
}
// Whitespace-only on this line — keep scanning only if no newline was crossed.
if (token.value.includes("\n") || token.value.includes("\r")) {
return false;
}
cursor += 1;
continue;
}
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
if (token.kind === "tag") {
if (isImageBlockWrapper(token)) {
return false;
}
const imageResult = parseInlineImageAt(tokens, cursor);
if (imageResult) {
cursor = imageResult.end;
continue;
}
}
break;
}
return false;
}
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
}
const previous = parts.at(-1);
if (previous?.kind === "markdown") {
previous.text += text;
return;
}
parts.push({ kind: "markdown", text });
}
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
if (summaryOpenIndex === null) {
return null;
}
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
if (summaryCloseIndex === null) {
return null;
}
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
const summary = renderSummaryTokens(summaryTokens).trim();
if (!summary) {
return null;
}
const bodyParts = splitHtmlishTokens(bodyTokens);
const body = renderBodyText(bodyParts);
return {
kind: "details",
summary,
body: body.trim(),
...(bodyParts.some((part) => part.kind !== "markdown")
? { bodyParts: trimBodyParts(bodyParts) }
: {}),
};
}
function renderBodyText(parts: MarkdownDisplayPart[]): string {
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
}
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
const trimmed = [...parts];
const first = trimmed[0];
if (first?.kind === "markdown") {
first.text = first.text.trimStart();
}
const last = trimmed.at(-1);
if (last?.kind === "markdown") {
last.text = last.text.trimEnd();
}
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
}
function renderSummaryTokens(tokens: HtmlToken[]): string {
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
}
function stripSingleHeadingWrapper(text: string): string {
const tokens = tokenizeHtmlishMarkdown(text.trim());
if (tokens.length < 3) {
return text;
}
const first = tokens[0];
const last = tokens.at(-1);
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
return text;
}
return renderInlineTokens(tokens.slice(1, -1));
}
function renderInlineTokens(tokens: HtmlToken[]): string {
let output = "";
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) {
continue;
}
if (token.kind === "text") {
output += token.value;
continue;
}
if (token.kind === "comment" || token.closing) {
continue;
}
if (token.name === "br") {
output += "\n";
continue;
}
if (token.name === "img") {
output += renderImageToken(token);
continue;
}
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
output += renderUnknownTag(token);
continue;
}
const children = tokens.slice(index + 1, closeIndex);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
if (token.name === "sub" || isHeadingTagName(token.name)) {
return renderInlineTokens(children);
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
return `\`${renderInlineTokens(children)}\``;
}
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
if (wrapper) {
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
}
if (token.name === "table") {
return renderTableTokens(children);
}
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
const rows: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
if (!isOpenTag(tokens[index], "tr")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, "tr");
if (closeIndex === null) {
continue;
}
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
if (cells.length === 1) {
rows.push(`- ${cells[0]}`);
} else if (cells.length > 1) {
const label =
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
}
index = closeIndex;
}
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
}
function renderTableCells(tokens: HtmlToken[]): string[] {
const cells: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
continue;
}
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
if (cell) {
cells.push(cell);
}
index = closeIndex;
}
return cells;
}
function renderImageToken(token: HtmlTagToken): string {
const image = imageTokenToInlineImage(token, undefined);
if (!image) {
return token.raw;
}
return `![${escapeMarkdownImageAlt(image.alt)}](${image.src})`;
}
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
const imageOnly = getSingleImageChild(children);
if (imageOnly) {
return renderImageToken(imageOnly);
}
const label = renderInlineTokens(children);
const href = token.attributes.href ?? "";
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
return label;
}
return `[${label}](${href})`;
}
function imageTokenToInlineImage(
token: HtmlTagToken,
href: string | undefined,
): MarkdownInlineImagePart | null {
const src = token.attributes.src ?? "";
if (!SAFE_IMAGE_SRC_RE.test(src)) {
return null;
}
return {
kind: "inlineImage",
alt: token.attributes.alt ?? "",
src,
...(href ? { href } : {}),
...parseImageDimensions(token.attributes),
};
}
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
return {
...parseImageDimension("width", attributes.width),
...parseImageDimension("height", attributes.height),
};
}
function parseImageDimension(key: "width" | "height", value: string | undefined) {
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
return {};
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
}
function safeHref(href: string | undefined): string | undefined {
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
return undefined;
}
return href;
}
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
}
function renderUnknownTag(token: HtmlTagToken): string {
if (token.name === "script" || token.name === "style") {
return "";
}
return token.raw;
}
function escapeMarkdownImageAlt(value: string): string {
return value.replace(/]/g, "\\]");
}
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (isOpenTag(tokens[index], name)) {
return index;
}
}
return null;
}
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
let depth = 1;
for (let index = openIndex + 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (isOpenTag(token, name) && !token.selfClosing) {
depth += 1;
continue;
}
if (isClosingTag(token, name)) {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
return null;
}
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
return token?.kind === "tag" && token.name === name && !token.closing;
}
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
return token?.kind === "tag" && token.name === name && token.closing;
}
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
}
function isHeadingTagName(name: string): boolean {
return (
name === "h1" ||
name === "h2" ||
name === "h3" ||
name === "h4" ||
name === "h5" ||
name === "h6"
);
}
function isWhitespaceText(token: HtmlToken): boolean {
return token.kind === "text" && token.value.trim() === "";
}
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
const protectedRanges = getProtectedMarkdownRanges(source);
const tokens: HtmlToken[] = [];
const parser = createHtmlishTokenParser(source, tokens);
let cursor = 0;
for (const range of protectedRanges) {
parser.write(source.slice(cursor, range.start));
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
parser.skip(range.end - range.start);
cursor = range.end;
}
parser.write(source.slice(cursor));
parser.end();
return tokens;
}
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
let writtenLength = 0;
let chunkStart = 0;
let parser!: Parser;
// Unknown tags render as text, so keep them as written: the parser lowercases names
// and rewrites attributes. A tag split by a protected range has no single slice.
const readRawTag = (): string | null =>
parser.startIndex < chunkStart
? null
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
if (isImplied) {
return;
}
const selfClosing = VOID_HTML_TAGS.has(name);
tokens.push({
kind: "tag",
name,
closing: false,
selfClosing,
attributes,
raw: readRawTag() ?? renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
if (isImplied || VOID_HTML_TAGS.has(name)) {
return;
}
tokens.push({
kind: "tag",
name,
closing: true,
selfClosing: false,
attributes: {},
raw: readRawTag() ?? `</${name}>`,
});
},
ontext(value) {
if (stripNextLeadingLineBreak) {
stripNextLeadingLineBreak = false;
value = stripLeadingLineBreak(value);
}
if (!value) {
return;
}
tokens.push({ kind: "text", value });
},
oncomment() {
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
tokens.push({ kind: "comment" });
},
},
{
decodeEntities: false,
lowerCaseAttributeNames: true,
lowerCaseTags: true,
recognizeSelfClosing: true,
},
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
skippedLength += length;
},
end() {
parser.end();
},
};
}
function isLineStart(source: string, index: number): boolean {
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
}
function stripLeadingLineBreak(value: string): string {
if (value.startsWith("\r\n")) {
return value.slice(2);
}
if (value.startsWith("\n") || value.startsWith("\r")) {
return value.slice(1);
}
return value;
}
function renderStartTag(name: string, attributes: Record<string, string>): string {
const renderedAttributes = Object.entries(attributes)
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
.join("");
return `<${name}${renderedAttributes}>`;
}
function escapeAttribute(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
const codeRanges = mergeProtectedRanges([
...fencedRanges,
...getInlineCodeRanges(source, fencedRanges),
]);
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
}
/**
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
*/
function getLiteralAngleBracketRanges(
source: string,
codeRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
let codeIndex = 0;
// Reused across comment openers so unclosed comments stay linear.
let commentClose = -1;
let index = source.indexOf("<");
while (index !== -1) {
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
codeIndex += 1;
}
const code = codeRanges[codeIndex];
if (code && code.start <= index) {
index = source.indexOf("<", code.end);
continue;
}
let htmlEnd: number | null;
if (source.startsWith("<!--", index)) {
if (commentClose !== source.length && commentClose < index + 2) {
const close = source.indexOf("-->", index + 2);
commentClose = close === -1 ? source.length : close;
}
htmlEnd = commentClose === source.length ? null : commentClose + 3;
} else {
HTML_TAG_RE.lastIndex = index;
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
}
if (htmlEnd === null) {
ranges.push({ start: index, end: index + 1 });
htmlEnd = index + 1;
}
index = source.indexOf("<", htmlEnd);
}
return ranges;
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
FENCE_LINE_RE.lastIndex = 0;
while (true) {
const open = FENCE_LINE_RE.exec(source);
if (!open) {
return ranges;
}
const marker = open[1];
if (!marker) {
continue;
}
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
if (!close) {
ranges.push({ start: open.index, end: source.length });
return ranges;
}
ranges.push({ start: open.index, end: close.end });
FENCE_LINE_RE.lastIndex = close.end;
}
}
function findClosingFence(
source: string,
start: number,
marker: string,
): MarkdownDelimiterMatch | null {
const closeRe = new RegExp(
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
"gm",
);
closeRe.lastIndex = start;
const close = closeRe.exec(source);
return close ? { index: close.index, end: closeRe.lastIndex } : null;
}
function getInlineCodeRanges(
source: string,
fencedRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
BACKTICK_RUN_RE.lastIndex = 0;
while (true) {
const open = BACKTICK_RUN_RE.exec(source);
if (!open) {
return ranges;
}
if (isProtectedIndex(open.index, fencedRanges)) {
continue;
}
const marker = open[0];
const afterOpen = BACKTICK_RUN_RE.lastIndex;
const close = findClosingBacktickRun(source, afterOpen, marker, fencedRanges);
if (!close) {
// Unmatched backtick run — skip past it so the loop doesn't restart from 0.
// findClosingBacktickRun exhausts the global regex, which resets lastIndex
// to 0 when exec() returns null (ECMAScript §22.2.7.2).
BACKTICK_RUN_RE.lastIndex = afterOpen;
continue;
}
ranges.push({ start: open.index, end: close.end });
BACKTICK_RUN_RE.lastIndex = close.end;
}
}
function findClosingBacktickRun(
source: string,
start: number,
marker: string,
fencedRanges: ProtectedMarkdownRange[],
): MarkdownDelimiterMatch | null {
BACKTICK_RUN_RE.lastIndex = start;
while (true) {
const close = BACKTICK_RUN_RE.exec(source);
if (!close) {
return null;
}
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
}
}
}
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const merged: ProtectedMarkdownRange[] = [];
for (const range of sorted) {
const previous = merged.at(-1);
if (!previous || range.start > previous.end) {
merged.push({ ...range });
continue;
}
previous.end = Math.max(previous.end, range.end);
}
return merged;
}
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
return ranges.some((range) => index >= range.start && index < range.end);
}
export function normalizeHtmlishMarkdown(source: string): string {
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
}