Files
kandev/apps/web/lib/markdown/normalize-cache.ts
T
2026-06-30 22:45:25 +01:00

455 lines
14 KiB
TypeScript

/**
* Pure markdown normalization plus a bounded, value-keyed LRU cache.
*
* `normalizeMarkdown` is a pure string transform (moved here from
* markdown-components so the cache has no React dependency). `normalizeCached`
* wraps it in a hard-capped LRU keyed by the raw input string, so repeated
* renders of the same content never re-run the transform. The cache is keyed by
* content only (never by task/session/message id), so it cannot leak per-task
* state: entries are anonymous strings that age out via LRU.
*/
const FENCE_OPEN_RE = /^ {0,3}(`{3,})/;
const MARKDOWN_WRAPPER_OPEN_RE = /^( {0,3})(`{3,})(\s*(?:markdown|mdx?)(?:[ \t].*)?\s*)$/i;
const FENCE_OPENER_LINE_RE = /^ {0,3}(`{3,})(?!`)(?:[ \t]+\S|\S)/;
const PURE_FENCE_LINE_RE = /^( {0,3})(`{3,})\s*$/;
const TRAILING_FENCE_RE = /(`{3,})\s*$/;
function pureCloseLength(line: string, openCount: number): number | null {
const match = PURE_FENCE_LINE_RE.exec(line);
if (!match || match[2].length < openCount) return null;
return match[2].length;
}
function gluedCloseLength(line: string, openCount: number): number | null {
const match = TRAILING_FENCE_RE.exec(line);
if (!match || match[1].length < openCount) return null;
// Reject pure-fence lines (already handled by pureCloseLength) and lines
// where everything before the trailing run is whitespace only.
const head = line.slice(0, line.length - match[0].length).trimEnd();
if (head.length === 0) return null;
return match[1].length;
}
function lastNonBlankLineIndex(lines: string[]): number {
for (let index = lines.length - 1; index >= 0; index--) {
if (lines[index].trim() !== "") return index;
}
return -1;
}
function firstNonBlankLineIndex(lines: string[]): number {
for (let index = 0; index < lines.length; index++) {
if (lines[index].trim() !== "") return index;
}
return -1;
}
function nextNonBlankLineIndex(lines: string[], startIndex: number, endIndex: number): number {
for (let index = startIndex + 1; index < endIndex; index++) {
if (lines[index].trim() !== "") return index;
}
return -1;
}
type InnerFenceScan = {
maxInnerPureFence: number;
nestedFenceCount: number;
};
type NestedFenceStep = {
failed: boolean;
nextIndex: number;
};
type NestedFenceContext = {
closeIndex: number;
lines: string[];
scan: InnerFenceScan;
wrapperOpenCount: number;
};
function matchingCloseLength(line: string, openCount: number): number | null {
return pureCloseLength(line, openCount) ?? gluedCloseLength(line, openCount);
}
function findTaggedNestedClose(
lines: string[],
startIndex: number,
closeIndex: number,
openCount: number,
) {
for (let index = startIndex + 1; index < closeIndex; index++) {
const closeCount = matchingCloseLength(lines[index], openCount);
if (closeCount !== null) return { closeCount, closeIndex: index };
}
return null;
}
function findBareNestedClose(
lines: string[],
startIndex: number,
closeIndex: number,
openCount: number,
wrapperOpenCount: number,
) {
for (let index = startIndex + 1; index < closeIndex; index++) {
const closeCount = matchingCloseLength(lines[index], openCount);
if (closeCount !== null) {
if (
openCount === wrapperOpenCount &&
isSameLengthBareMarkdownSampleClose(lines, startIndex, index, openCount)
) {
return null;
}
return { closeCount, closeIndex: index };
}
}
return null;
}
function nearestNonBlankBetween(lines: string[], startIndex: number, closeIndex: number): string {
for (let index = startIndex + 1; index < closeIndex; index++) {
const line = lines[index].trim();
if (line !== "") return line;
}
return "";
}
function hasProseBeforeFence(lines: string[], startIndex: number, closeIndex: number): boolean {
let sawProse = false;
for (let index = startIndex + 1; index < closeIndex; index++) {
const line = lines[index].trim();
if (line === "") continue;
if (FENCE_OPEN_RE.test(lines[index])) return sawProse;
sawProse = true;
}
return false;
}
function nearestNonBlankBefore(lines: string[], index: number): string {
for (let cursor = index - 1; cursor >= 0; cursor--) {
const line = lines[cursor].trim();
if (line !== "") return line;
}
return "";
}
function hasTaggedLookingBareContent(
lines: string[],
startIndex: number,
closeIndex: number,
openCount: number,
): boolean {
for (let index = startIndex + 1; index < closeIndex; index++) {
const taggedOpener = FENCE_OPENER_LINE_RE.exec(lines[index]);
if ((taggedOpener?.[1]?.length ?? 0) >= openCount) return true;
}
return false;
}
function looksLikeProse(line: string): boolean {
return /\s/.test(line) && !line.startsWith("#") && !FENCE_OPEN_RE.test(line);
}
function hasHeadingBefore(lines: string[], index: number): boolean {
for (let cursor = index - 1; cursor >= 0; cursor--) {
if (lines[cursor].trim().startsWith("#")) return true;
}
return false;
}
function isSameLengthBareMarkdownSampleClose(
lines: string[],
startIndex: number,
closeIndex: number,
openCount: number,
): boolean {
const previousContent = nearestNonBlankBefore(lines, startIndex);
if (previousContent.startsWith("```")) return hasHeadingBefore(lines, startIndex);
if (hasTaggedLookingBareContent(lines, startIndex, closeIndex, openCount)) return false;
const candidateContent = nearestNonBlankBetween(lines, startIndex, closeIndex);
return !previousContent.endsWith(":") && looksLikeProse(candidateContent);
}
function noteNestedFence(state: InnerFenceScan, openCount: number, closeCount: number): void {
state.nestedFenceCount += 1;
state.maxInnerPureFence = Math.max(state.maxInnerPureFence, openCount, closeCount);
}
function taggedNestedFenceClose(
lines: string[],
startIndex: number,
closeIndex: number,
taggedOpenCount: number,
wrapperOpenCount: number,
) {
const taggedClose = findTaggedNestedClose(lines, startIndex, closeIndex, taggedOpenCount);
if (taggedOpenCount < wrapperOpenCount && (taggedClose?.closeCount ?? 0) < wrapperOpenCount) {
return null;
}
return taggedClose;
}
function bareNestedFenceClose(
lines: string[],
startIndex: number,
closeIndex: number,
bareOpenCount: number,
wrapperOpenCount: number,
) {
const bareClose = findBareNestedClose(
lines,
startIndex,
closeIndex,
bareOpenCount,
wrapperOpenCount,
);
if (bareOpenCount < wrapperOpenCount && (bareClose?.closeCount ?? 0) < wrapperOpenCount) {
return null;
}
return bareClose;
}
function scanTaggedNestedFence(
context: NestedFenceContext,
index: number,
taggedOpenCount: number,
): NestedFenceStep {
const taggedClose = taggedNestedFenceClose(
context.lines,
index,
context.closeIndex,
taggedOpenCount,
context.wrapperOpenCount,
);
const nextNonBlank = taggedClose
? nextNonBlankLineIndex(context.lines, taggedClose.closeIndex, context.closeIndex)
: -1;
if (
context.scan.nestedFenceCount === 0 &&
taggedClose &&
pureCloseLength(context.lines[nextNonBlank] ?? "", context.wrapperOpenCount) !== null &&
hasProseBeforeFence(context.lines, nextNonBlank, context.closeIndex)
) {
return { failed: false, nextIndex: taggedClose.closeIndex };
}
if (!taggedClose) {
return {
failed: taggedOpenCount >= context.wrapperOpenCount && context.scan.nestedFenceCount === 0,
nextIndex: index,
};
}
noteNestedFence(context.scan, taggedOpenCount, taggedClose.closeCount);
return { failed: false, nextIndex: taggedClose.closeIndex };
}
function scanBareNestedFence(
context: NestedFenceContext,
index: number,
bareOpenCount: number,
): NestedFenceStep {
const bareClose = bareNestedFenceClose(
context.lines,
index,
context.closeIndex,
bareOpenCount,
context.wrapperOpenCount,
);
if (!bareClose) {
return {
failed: bareOpenCount >= context.wrapperOpenCount && context.scan.nestedFenceCount === 0,
nextIndex: index,
};
}
noteNestedFence(context.scan, bareOpenCount, bareClose.closeCount);
return { failed: false, nextIndex: bareClose.closeIndex };
}
function markdownWrapperInnerFenceInfo(
lines: string[],
openIndex: number,
closeIndex: number,
openCount: number,
) {
const scan: InnerFenceScan = {
maxInnerPureFence: 0,
nestedFenceCount: 0,
};
const context: NestedFenceContext = {
closeIndex,
lines,
scan,
wrapperOpenCount: openCount,
};
for (let index = openIndex + 1; index < closeIndex; index++) {
const taggedOpener = FENCE_OPENER_LINE_RE.exec(lines[index]);
const taggedOpenCount = taggedOpener?.[1]?.length;
if (taggedOpenCount) {
const step = scanTaggedNestedFence(context, index, taggedOpenCount);
if (step.failed) return null;
index = step.nextIndex;
continue;
}
const bareOpener = PURE_FENCE_LINE_RE.exec(lines[index]);
const bareOpenCount = bareOpener?.[2]?.length;
if (bareOpenCount) {
const step = scanBareNestedFence(context, index, bareOpenCount);
if (step.failed) return null;
index = step.nextIndex;
}
}
if (scan.nestedFenceCount === 0 || scan.maxInnerPureFence === 0) return null;
return { maxInnerPureFence: scan.maxInnerPureFence };
}
function wrapperCloseLength(lines: string[], closeIndex: number, openCount: number): number | null {
return (
pureCloseLength(lines[closeIndex], openCount) ?? gluedCloseLength(lines[closeIndex], openCount)
);
}
function writeStrengthenedWrapperClose(
lines: string[],
closeIndex: number,
targetCount: number,
): void {
const closer = PURE_FENCE_LINE_RE.exec(lines[closeIndex]);
if (closer) {
lines[closeIndex] = `${closer[1]}${"`".repeat(targetCount)}`;
return;
}
const closeLine = lines[closeIndex];
const trailingMatch = TRAILING_FENCE_RE.exec(closeLine)!;
const head = closeLine.slice(0, closeLine.length - trailingMatch[0].length);
lines.splice(closeIndex, 1, head, "`".repeat(targetCount));
}
function strengthenMarkdownWrapperFence(lines: string[]): string[] {
const openIndex = firstNonBlankLineIndex(lines);
if (openIndex < 0) return lines;
const opener = MARKDOWN_WRAPPER_OPEN_RE.exec(lines[openIndex] ?? "");
if (!opener || lines.length < 3) return lines;
const closeIndex = lastNonBlankLineIndex(lines);
if (closeIndex <= openIndex + 1) return lines;
const openCount = opener[2].length;
const closeCount = wrapperCloseLength(lines, closeIndex, openCount);
if (closeCount === null) return lines;
if (closeCount < openCount) return lines;
const innerInfo = markdownWrapperInnerFenceInfo(lines, openIndex, closeIndex, openCount);
if (!innerInfo) return lines;
const targetCount = Math.max(openCount + 1, closeCount, innerInfo.maxInnerPureFence + 1);
const strengthened = lines.slice();
strengthened[openIndex] = `${opener[1]}${"`".repeat(targetCount)}${opener[3]}`;
writeStrengthenedWrapperClose(strengthened, closeIndex, targetCount);
return strengthened;
}
/**
* Pre-process a markdown string to repair common malformed LLM fence output:
* - whole-message `markdown` wrappers that contain nested fenced blocks using
* the same backtick run length;
* - closing fences glued to the last code line (`...}\`\`\`\n`prose`).
*
* For glued fences, CommonMark/GFM treats the glued backticks as code content,
* so the fence never closes and following prose gets swallowed into one huge
* code node. We split such lines into `<content>\n<backticks>` only when we're
* inside an open fence whose opener run length is ≤ the trailing run length.
*
* Pure string preprocessing, intentionally not a remark plugin.
*/
export function normalizeMarkdown(input: string): string {
if (!input || input.length === 0) return input;
const hadTrailingNewline = input.endsWith("\n");
const lines = strengthenMarkdownWrapperFence(input.split("\n"));
const out: string[] = [];
let openCount: number | null = null;
for (const line of lines) {
if (openCount === null) {
const opener = FENCE_OPEN_RE.exec(line);
if (opener) openCount = opener[1].length;
out.push(line);
continue;
}
if (pureCloseLength(line, openCount) !== null) {
openCount = null;
out.push(line);
continue;
}
const glued = gluedCloseLength(line, openCount);
if (glued !== null) {
const trailingMatch = TRAILING_FENCE_RE.exec(line)!;
const head = line.slice(0, line.length - trailingMatch[0].length);
out.push(head);
out.push("`".repeat(glued));
openCount = null;
continue;
}
out.push(line);
}
const result = out.join("\n");
return hadTrailingNewline && !result.endsWith("\n") ? result + "\n" : result;
}
// ── Bounded LRU cache ───────────────────────────────────────────────
const MAX_CACHE_ENTRIES = 500;
const normalizeCache = new Map<string, string>();
let parseCount = 0;
/**
* Cached variant of {@link normalizeMarkdown}. Keyed by the raw input string;
* a hit refreshes recency (Map preserves insertion order, so delete+set moves
* the entry to the end). On overflow the oldest entry is evicted.
*/
export function normalizeCached(input: string): string {
const cached = normalizeCache.get(input);
if (cached !== undefined) {
normalizeCache.delete(input);
normalizeCache.set(input, cached);
return cached;
}
parseCount += 1;
const result = normalizeMarkdown(input);
// Evict before insert so the map never exceeds the cap. A miss means the key
// isn't present, so the cache holds at most MAX_CACHE_ENTRIES - 1 entries here.
if (normalizeCache.size >= MAX_CACHE_ENTRIES) {
const oldest = normalizeCache.keys().next().value;
if (oldest !== undefined) normalizeCache.delete(oldest);
}
normalizeCache.set(input, result);
return result;
}
// ── Test-only instrumentation ───────────────────────────────────────
/** Number of real normalize passes (cache misses) since the last reset. */
export function __markdownParseCount(): number {
return parseCount;
}
/** Clears the cache and resets the parse counter. Test-only. */
export function __resetMarkdownCounters(): void {
parseCount = 0;
normalizeCache.clear();
}
/** Current cache size. Test-only (asserts the LRU stays bounded). */
export function __lruSize(): number {
return normalizeCache.size;
}
/** Hard cap on cached entries. Test-only. */
export const __MAX_CACHE_ENTRIES = MAX_CACHE_ENTRIES;