Files
kandev/apps/web/lib/markdown/normalize-cache.ts
T

126 lines
4.6 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 TRAILING_FENCE_RE = /(`{3,})\s*$/;
function pureCloseLength(line: string, openCount: number): number | null {
const match = /^ {0,3}(`{3,})\s*$/.exec(line);
if (!match || match[1].length < openCount) return null;
return match[1].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;
}
/**
* Pre-process a markdown string to repair fenced code blocks that have their
* closing fence glued to the last code line (`...}\`\`\`\n`prose`). Without
* this, 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 = 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;