import type { Element, Nodes } from "hast"; import type { JSX } from "solid-js"; import remarkParse from "remark-parse"; import remarkGfm from "remark-gfm"; import type { Options as RemarkRehypeOptions } from "remark-rehype"; import remarkRehype from "remark-rehype"; import type { PluggableList } from "unified"; import { unified } from "unified"; import { hastToSolid, type Components, type HastNode } from "./hast-to-solid"; export type { Components }; export type MarkdownOptions = { children?: string; components?: Components; rehypePlugins?: PluggableList; remarkPlugins?: PluggableList; remarkRehypeOptions?: Readonly; }; const EMPTY_PLUGINS: PluggableList = []; const DEFAULT_REMARK_REHYPE_OPTIONS = { allowDangerousHtml: false }; // Plugin name cache for faster serialization const pluginNameCache = new WeakMap(); // LRU Cache for unified processors class ProcessorCache { private readonly cache = new Map(); private readonly keyCache = new WeakMap, string>(); private readonly maxSize = 100; generateCacheKey(options: Readonly): string { const cachedKey = this.keyCache.get(options); if (cachedKey) return cachedKey; const rehypePlugins = options.rehypePlugins; const remarkPlugins = options.remarkPlugins; const remarkRehypeOptions = options.remarkRehypeOptions; if (!(rehypePlugins || remarkPlugins || remarkRehypeOptions)) { const key = "default"; this.keyCache.set(options, key); return key; } const serializePlugins = (plugins: PluggableList | undefined): string => { if (!plugins || plugins.length === 0) return ""; let result = ""; for (let i = 0; i < plugins.length; i += 1) { const plugin = plugins[i]; if (i > 0) result += ","; if (Array.isArray(plugin)) { const [pluginFn, pluginOptions] = plugin; if (typeof pluginFn === "function") { let name = pluginNameCache.get(pluginFn); if (!name) { name = pluginFn.name; pluginNameCache.set(pluginFn, name); } result += name; } else { result += String(pluginFn); } result += ":"; result += JSON.stringify(pluginOptions); } else if (typeof plugin === "function") { let name = pluginNameCache.get(plugin); if (!name) { name = plugin.name; pluginNameCache.set(plugin, name); } result += name; } else { result += String(plugin); } } return result; }; const rehypeKey = serializePlugins(rehypePlugins); const remarkKey = serializePlugins(remarkPlugins); const optionsKey = remarkRehypeOptions ? JSON.stringify(remarkRehypeOptions) : ""; const key = `${remarkKey}::${rehypeKey}::${optionsKey}`; this.keyCache.set(options, key); return key; } get(options: Readonly) { const key = this.generateCacheKey(options); const processor = this.cache.get(key); if (processor) { this.cache.delete(key); this.cache.set(key, processor); } return processor; } set(options: Readonly, processor: any): void { const key = this.generateCacheKey(options); if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; if (firstKey) this.cache.delete(firstKey); } this.cache.set(key, processor); } } const processorCache = new ProcessorCache(); const getCachedProcessor = (options: Readonly) => { const cached = processorCache.get(options); if (cached) return cached; const processor = createProcessor(options); processorCache.set(options, processor); return processor; }; const createProcessor = (options: Readonly) => { const rehypePlugins = options.rehypePlugins || EMPTY_PLUGINS; const remarkPlugins = options.remarkPlugins || EMPTY_PLUGINS; const remarkRehypeOptions = options.remarkRehypeOptions ? { ...DEFAULT_REMARK_REHYPE_OPTIONS, ...options.remarkRehypeOptions } : DEFAULT_REMARK_REHYPE_OPTIONS; return unified() .use(remarkParse) .use(remarkGfm) .use(remarkPlugins) .use(remarkRehype, remarkRehypeOptions) .use(rehypePlugins); }; export function Markdown(options: Readonly): JSX.Element { const processor = getCachedProcessor(options); const content = options.children || ""; const tree = processor.runSync(processor.parse(content), content) as HastNode; return hastToSolid(tree, { components: options.components }); } // Convenience function for rendering markdown string directly export function renderMarkdown( markdown: string, options: Omit = {} ): JSX.Element { return Markdown({ ...options, children: markdown }); }