/** * Memoizing wrapper around `rehype-katex`. * * Why: the default `@streamdown/math` plugin runs `rehype-katex` on every * markdown commit. During streaming, that means each new token re-runs * KaTeX on EVERY math node in the message — including equations that * haven't changed since the last token. For math-heavy responses (a * model deriving an equation step-by-step) this becomes a major source * of jank: 20 unchanged equations each pay ~5–20ms of katex.renderToString * work per token, adding up to hundreds of ms of CPU bound work that * delays the next streaming update. * * What this plugin does: walk the hast tree looking for the math nodes * that `remark-math` emits (`…` for * inline and `
…
` for * display), key them by `(displayMode, value)`, and serve them from an * in-memory LRU cache when we've rendered the same equation before. * Cache misses still go through `katex.renderToString`; cache hits * return the previously generated hast subtree. * * Result: each unique equation only pays the katex cost once. Adding * one new equation to a paragraph re-renders just that one equation * instead of all of them. The cache is process-global so it survives * moves between messages (e.g., re-rendering a session). * * Compatibility: the produced hast structure matches what `rehype-katex` * itself produces — we use the same `hast-util-from-html-isomorphic` * fragment parsing and the same parent-splice semantics, including the * `
`-walk-up for display mode. Drop-in replacement for the math
 * slot in streamdown's PluginConfig.
 *
 * Wire it in via `createMemoizedMathPlugin`:
 *
 *   import { createMemoizedMathPlugin } from '@/lib/katex-memo'
 *   const math = createMemoizedMathPlugin({ singleDollarTextMath: true })
 *   
 */

import type { Element, ElementContent, Parent, Root } from 'hast'
import { fromHtmlIsomorphic } from 'hast-util-from-html-isomorphic'
import { toText } from 'hast-util-to-text'
import katex from 'katex'
import remarkMath from 'remark-math'
import type { Pluggable } from 'unified'
import { SKIP, visitParents } from 'unist-util-visit-parents'
import type { VFile } from 'vfile'

import { LruCache } from '@/lib/lru-cache'

interface KatexMemoOptions {
  /**
   * Color used for KaTeX errors when we fall back to the lenient parser.
   * Mirrors `@streamdown/math`'s default so the visual output is identical.
   */
  errorColor?: string
}

interface MathPluginConfig {
  /**
   * Match `singleDollarTextMath` from `@streamdown/math`. When true the
   * remark-math parser treats `$x$` as inline math; when false it requires
   * `$$x$$`. Models almost always emit the single-dollar form, so we
   * default it to true at the createMemoizedMathPlugin call site.
   */
  singleDollarTextMath?: boolean
  errorColor?: string
}

/** Cached rendered hast — children to splice into the math node's parent. */
type CachedRender = ElementContent[]

const CACHE_LIMIT = 512

const cache = new LruCache(CACHE_LIMIT)

function cacheKey(displayMode: boolean, value: string): string {
  // `\u0001` is a control character that (a) won't appear in normal
  // markdown and (b) is a single byte so the join is cheap.
  return `${displayMode ? 'd' : 'i'}\u0001${value}`
}

/**
 * Render one math expression with the same two-pass strategy `rehype-katex`
 * uses internally: try strict first (so genuine TeX errors get reported in
 * the VFile message stream), and on failure fall back to lenient mode so
 * the document still renders without a thrown exception. The lenient
 * fallback paints the equation in `errorColor` instead of erroring out.
 */
function renderMath(
  value: string,
  displayMode: boolean,
  errorColor: string,
  file: VFile,
  element: Element
): ElementContent[] {
  let html: string

  try {
    html = katex.renderToString(value, { displayMode, throwOnError: true })
  } catch (error) {
    const cause = error as Error

    file.message('Could not render math with KaTeX', {
      cause,
      place: element.position,
      ruleId: cause.name?.toLowerCase() ?? 'katex',
      source: 'rehype-katex-memo'
    })

    try {
      html = katex.renderToString(value, {
        displayMode,
        errorColor,
        strict: 'ignore',
        throwOnError: false
      })
    } catch {
      // Last-resort fallback — render the source text inside a styled span
      // so the user at least sees what was supposed to be there. Mirrors
      // rehype-katex's own escape hatch.
      return [
        {
          type: 'element',
          tagName: 'span',
          properties: {
            className: ['katex-error'],
            style: `color:${errorColor}`,
            title: String(error)
          },
          children: [{ type: 'text', value }]
        }
      ]
    }
  }

  const fragment = fromHtmlIsomorphic(html, { fragment: true })

  return fragment.children as ElementContent[]
}

/**
 * The actual rehype plugin. Wraps `rehype-katex`'s logic with our LRU
 * cache. Mirrors the upstream visitor exactly except for the cache lookup
 * and an LRU.set on miss.
 */
function createMemoizedRehypeKatex(options: KatexMemoOptions = {}): Pluggable {
  const errorColor = options.errorColor ?? 'var(--color-muted-foreground)'

  return () =>
    function transform(tree: Root, file: VFile): undefined {
      visitParents(tree, 'element', (element, parents) => {
        const classes = Array.isArray(element.properties?.className) ? (element.properties.className as string[]) : []

        // Match the same class set rehype-katex looks for. `language-math`
        // is the markdown ` ```math ` form, `math-inline` is what
        // remark-math emits for `$x$`, `math-display` for `$$x$$`.
        const languageMath = classes.includes('language-math')
        const mathDisplay = classes.includes('math-display')
        const mathInline = classes.includes('math-inline')

        if (!(languageMath || mathDisplay || mathInline)) {
          return
        }

        let displayMode = mathDisplay
        let scope: Element = element
        let parent: Parent | undefined = parents[parents.length - 1]

        // For ` ```math ` the scope walks up to the wrapping 
 and
        // we treat it as display math. Same logic rehype-katex uses.
        if (languageMath && parent && parent.type === 'element' && (parent as Element).tagName === 'pre') {
          scope = parent as Element
          parent = parents[parents.length - 2]
          displayMode = true
        }

        // No parent means the math node is at the root — there's nothing
        // to splice into, so bail. This shouldn't happen for properly
        // nested markdown but is the same defensive guard rehype-katex has.
        if (!parent) {
          return
        }

        const value = toText(scope, { whitespace: 'pre' })
        const key = cacheKey(displayMode, value)
        let cached = cache.get(key)

        if (!cached) {
          cached = renderMath(value, displayMode, errorColor, file, scope)
          cache.set(key, cached)
        }

        // Splice CLONES of the cached children into the parent. Reusing
        // the same node instances across renders would let downstream
        // rehype plugins or toJsxRuntime mutate the cached subtree —
        // breaking the next cache hit. structuredClone is ~100µs per
        // equation, well below the ~5–20ms katex.renderToString cost
        // we're avoiding.
        const clonedChildren = cached.map(child => structuredClone(child))
        const index = parent.children.indexOf(scope as ElementContent)

        if (index === -1) {
          return
        }

        parent.children.splice(index, 1, ...clonedChildren)

        return SKIP
      })
    }
}

/**
 * Build a streamdown MathPlugin object that uses the memoized rehype-katex
 * wrapper. Drop-in for `@streamdown/math`'s `createMathPlugin`.
 */
export function createMemoizedMathPlugin(config: MathPluginConfig = {}) {
  const remarkPlugin: Pluggable = [remarkMath, { singleDollarTextMath: config.singleDollarTextMath ?? false }]

  const rehypePlugin = createMemoizedRehypeKatex({ errorColor: config.errorColor })

  return {
    name: 'katex' as const,
    type: 'math' as const,
    remarkPlugin,
    rehypePlugin,
    getStyles: () => 'katex/dist/katex.min.css'
  }
}