Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872// src/plugin/markdown-renderer-impl.ts/** * Real markdown rendering pipeline for MarkdownRenderer.render() and * MarkdownPostProcessor invocation. * * Uses `marked` as the rendering engine. This is NOT for the CM6 editor's * live preview -- this is for popovers, embeds, and any context where * markdown source needs to be rendered to static HTML. * * Security: * - marked's default `html` renderer is overridden so raw HTML in source * becomes escaped `<pre><code>` blocks (no XSS via inline `<script>`). * - A `walkTokens` hook neutralizes link/image tokens whose `href` uses a * dangerous URL scheme (javascript:, vbscript:, data:text/html). We * percent-decode AND HTML-entity-decode the href before scheme detection * so encoded variants (`javascript:...`) cannot bypass. * - Markdown images with network URLs render as nonloading alt text. A note * must not issue a request simply because it was opened or rendered. * Installed external plugins and their postprocessors are trusted same-origin * code; their DOM writes are outside this source-rendering boundary. */
import { Marked, Renderer, type Tokens } from 'marked'import yaml from 'js-yaml'import type { MarkdownPostProcessor, MarkdownPostProcessorContext } from './types'import { Component } from './component'import type { MarkdownRenderChild } from './markdown-render-child'
/** Global registry of MarkdownPostProcessors from all plugins */const globalPostProcessors: Array<{ processor: MarkdownPostProcessor pluginId: string sortOrder: number codeBlock?: { language: string handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => Promise<unknown> | void }}> = []let postProcessorRevision = 0const postProcessorListeners = new Set<() => void>()
function notifyPostProcessorChanges(): void { postProcessorRevision++ for (const listener of [...postProcessorListeners]) { try { listener() } catch (error) { console.error('[Reams] Post-processor change listener failed:', error) } }}
/** Obsidian's renderer adds these DOM helpers to render containers. */export function enhanceRenderElement(el: HTMLElement): void { const target = el as HTMLElement & { findAllSelf?: (selector: string) => HTMLElement[] onNodeInserted?: (callback: () => void) => () => void isShown?: () => boolean } target.findAllSelf ??= (selector: string) => [ ...(el.matches(selector) ? [el] : []), ...Array.from(el.querySelectorAll<HTMLElement>(selector)), ] target.isShown ??= () => el.isConnected && el.getClientRects().length > 0 target.onNodeInserted ??= (callback: () => void) => { let active = true const invoke = () => { if (!active || !el.isConnected) return active = false observer.disconnect() callback() } const observer = new MutationObserver(invoke) if (el.isConnected) queueMicrotask(invoke) else if (document.body) observer.observe(document.body, { childList: true, subtree: true }) return () => { active = false; observer.disconnect() } }}
/** * Register a post-processor in the global registry. * Called by Plugin.registerMarkdownPostProcessor. * * Insertion-sorted O(N) per call so the registry remains in ascending * sortOrder; we choose this over re-sorting because most call sites register * a small number of processors at startup. JS array `splice` itself is O(N), * so a binary-search find wouldn't reduce the asymptotic cost. */export function registerPostProcessor( pluginId: string, processor: MarkdownPostProcessor, sortOrder = 0, codeBlock?: (typeof globalPostProcessors)[number]['codeBlock'],): void { let currentSortOrder = sortOrder const entry: (typeof globalPostProcessors)[number] = { processor, pluginId, codeBlock, get sortOrder() { return currentSortOrder }, set sortOrder(value: number) { currentSortOrder = value globalPostProcessors.sort((a, b) => a.sortOrder - b.sortOrder) notifyPostProcessorChanges() }, } Object.defineProperty(processor, 'sortOrder', { configurable: true, get: () => currentSortOrder, set: (value: number) => { entry.sortOrder = value }, }) // Find insertion point that preserves ascending sortOrder. Equal sortOrder // entries keep insertion order (this scan condition uses `<=`). let i = 0 while (i < globalPostProcessors.length && globalPostProcessors[i].sortOrder <= sortOrder) { i++ } globalPostProcessors.splice(i, 0, entry) notifyPostProcessorChanges()}
export function registerCodeBlockProcessor( pluginId: string, language: string, processor: MarkdownPostProcessor, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => Promise<unknown> | void, sortOrder = 0,): void { registerPostProcessor(pluginId, processor, sortOrder, { language, handler })}
/** * Clear all post-processors for a specific plugin. * Called by cleanupPluginResources on plugin unload. */export function clearPostProcessorsForPlugin(pluginId: string): void { let removed = false for (let i = globalPostProcessors.length - 1; i >= 0; i--) { if (globalPostProcessors[i].pluginId === pluginId) { globalPostProcessors.splice(i, 1) removed = true } } if (removed) notifyPostProcessorChanges()}
/** * Unregister a single post-processor by reference. Used by * `MarkdownPreviewRenderer.unregisterPostProcessor` (Obsidian-compat * surface) which doesn't have a pluginId association. * * Removes ALL entries whose `processor` field matches the given * function (in case the same processor was registered multiple times). * Returns the number of entries removed. */export function unregisterPostProcessor( processor: MarkdownPostProcessor,): number { let removed = 0 for (let i = globalPostProcessors.length - 1; i >= 0; i--) { if (globalPostProcessors[i].processor === processor) { globalPostProcessors.splice(i, 1) removed++ } } if (removed > 0) notifyPostProcessorChanges() return removed}
/** * Decode HTML entities used in URL contexts. * * Decodes: * - Numeric character refs `&#NN;` and `&#NN` (browsers tolerate missing `;`) * - Hex character refs `&#xNN;` and `&#xNN` (same) * - The structural named entity `&` -> `&` (so double-encoding like * `&#x6A;avascript:` collapses on the next iteration) * * Other named entities (`<`, `>`, etc.) are intentionally NOT decoded * here because they don't reintroduce URL-scheme attack surface. * * Examples: * decodeEntities('javascript:') === 'javascript:' * decodeEntities('javascript:') === 'javascript:' * decodeEntities('&#x6a;avascript:') === 'javascript:' (one pass) */function decodeEntities(text: string): string { // First, decode & -> & so double-encoded forms collapse on iteration. let out = text.replace(/&/gi, '&') // Then numeric char refs with optional trailing semicolon. // Order matters: hex form must be tried before decimal form because the // shared prefix `&#x` is ambiguous otherwise. out = out.replace(/&#x([0-9a-f]+);?/gi, (match, hex: string) => { const n = parseInt(hex, 16) if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return match try { return String.fromCodePoint(n) } catch { return match } }) out = out.replace(/&#([0-9]+);?/g, (match, dec: string) => { const n = parseInt(dec, 10) if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return match try { return String.fromCodePoint(n) } catch { return match } }) return out}
/** * Set of safe data: subtypes that are allowed (raster images only). * SVG is NOT in this list because `<svg onload=...>` is script-capable. */const SAFE_DATA_SUBTYPES = ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'bmp', 'ico']
/** Maximum decode-iteration depth. Each iteration collapses one level of * nested `&` or percent-encoding. 16 is well above realistic attack * depth (browsers typically decode at most 2-3 levels) but bounded enough * to prevent algorithmic DoS via pathological input. */const MAX_DECODE_ITERATIONS = 16
/** * Determine whether a URL uses a dangerous scheme that could result in XSS. * * The check is conservative and iterates decode passes to fixpoint to catch * double-encoded inputs (`&...&#x6A;avascript:`): * 1. HTML-entity decode (numeric, hex, and `&`) * 2. Percent-decode once (best effort) * 3. Strip ASCII whitespace, control chars, AND zero-width / format Unicode * that browsers tolerate in URL parsing * 4. Lowercase, then prefix-match dangerous schemes * * Steps 1-2 repeat up to MAX_DECODE_ITERATIONS times until the string stops * changing — this catches arbitrarily-nested encodings without unbounded work. * * Allowed: http(s), mailto, tel, ftp, relative, data: with subtype in * SAFE_DATA_SUBTYPES (raster image formats only). * Blocked: javascript:, vbscript:, ANY data: subtype not in allowlist * (so SVG, text/html, application/x-shockwave-flash etc. are all rejected). * * @internal exported for direct security testing */export function isDangerousUrl(url: string): boolean { if (!url) return false let decoded = url let prev = '' let iter = 0 while (decoded !== prev && iter < MAX_DECODE_ITERATIONS) { prev = decoded decoded = decodeEntities(decoded) try { decoded = decodeURIComponent(decoded) } catch { // Leave decoded as-is if it's malformed } iter++ } const stripped = decoded .replace(/[\u0000-\u001f\s]+/g, '') .replace(/[\u200b-\u200f\u2028-\u202f\ufeff]+/g, '') .toLowerCase() if (stripped.startsWith('javascript:')) return true if (stripped.startsWith('vbscript:')) return true if (stripped.startsWith('data:')) { // Allowlist (not denylist): only known-safe raster image subtypes pass. // Match `data:image/<subtype>` where <subtype> is in SAFE_DATA_SUBTYPES. const m = stripped.match(/^data:image\/([a-z0-9.+-]+)/) if (!m) return true const subtype = m[1] // Reject if subtype contains a `+` suffix (e.g., svg+xml) by checking // the bare type before any `+`. const bareType = subtype.split('+')[0] return !SAFE_DATA_SUBTYPES.includes(bareType) } return false}
/** * Create a safe marked renderer. * * Raw HTML is escaped and remote image URLs become nonloading placeholders. * * URL filtering is done via the marked instance's `walkTokens` hook (which * mutates `token.href` before render). Link rendering retains its original * parser binding; the image override does not parse inline tokens. */function createSafeRenderer(): Renderer { const renderer = new Renderer() renderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string => { return `<pre><code>${escapeHtml(text)}</code></pre>` } renderer.image = function (token: Tokens.Image): string { // Keep self-contained raster images; any other src can initiate a request // during the callout DOM parse, before a later sanitizer can intervene. if (token.href.length <= 1024 * 1024 && /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|ico)(?:;|,)/i.test(token.href) && !isDangerousUrl(token.href)) { return Renderer.prototype.image.call(this, token) } return `<span class="markdown-image-placeholder">[image: ${escapeHtml(token.text || 'untitled')}]</span>` } return renderer}
function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''')}
// Single marked instance with safe renderer + URL-scheme guard.const marked = new Marked({ renderer: createSafeRenderer(), gfm: true, breaks: false, walkTokens(token: Tokens.Generic) { if ((token.type === 'link' || token.type === 'image') && typeof token.href === 'string') { if (isDangerousUrl(token.href)) { token.href = '#' } } },})
/** * Maximum YAML frontmatter size in UTF-16 code units (NOT bytes — JS strings * are UTF-16). Frontmatter larger than this is rejected without parsing to * defend against billion-laughs / anchor-bomb style DoS inputs that js-yaml * does not bound by default. */const MAX_FRONTMATTER_CHARS = 64 * 1024
/** * Parse YAML frontmatter from the start of a markdown source string. * * Returns the parsed object (or null if no frontmatter / parse error / * exceeds size limit) and the byte offset where the frontmatter block ends * (0 if absent). * * Uses js-yaml's CORE_SCHEMA explicitly to: * - Pin behavior so future js-yaml upgrades don't silently change types * - Disallow any custom tags (DEFAULT_SCHEMA in v4+ is already safe but * explicit > implicit for security primitives) */function extractFrontmatter(source: string): { frontmatter: Record<string, unknown> | null bodyOffset: number} { const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/) if (!m) return { frontmatter: null, bodyOffset: 0 } if (m[1].length > MAX_FRONTMATTER_CHARS) { return { frontmatter: null, bodyOffset: m[0].length } } let parsed: Record<string, unknown> | null = null try { const raw = yaml.load(m[1], { schema: yaml.CORE_SCHEMA }) if (raw && typeof raw === 'object' && !Array.isArray(raw)) { parsed = raw as Record<string, unknown> } } catch { parsed = null } return { frontmatter: parsed, bodyOffset: m[0].length }}
/** * Parse markdown source to HTML string using marked. * * Strips YAML frontmatter and converts Obsidian-style callouts * (`> [!type][+/-] Title\n> Content`) into div.callout elements. * * @param source - Markdown source text * @param precomputed - Optional pre-extracted frontmatter result to avoid * re-parsing YAML when the caller (renderMarkdownToElement) already did so. */export function parseMarkdownToHtml( source: string, precomputed?: { bodyOffset: number }): string { const bodyOffset = precomputed ? precomputed.bodyOffset : extractFrontmatter(source).bodyOffset const contentSource = bodyOffset > 0 ? source.slice(bodyOffset) : source const html = marked.parse(contentSource, { async: false }) as string return convertCalloutsInHtml(html)}
/** * Convert Obsidian-style callouts in rendered HTML. * * Uses the DOM (not regex) to walk every `<blockquote>` and convert those * whose first paragraph begins with `[!type]`. This handles nested * blockquotes correctly because the DOM walker already understands tag * pairing — something regex can't do without unbounded backtracking. * * Input pattern recognized: * <blockquote>\n<p>[!type][+/-/(empty)] Title\nContent...</p>...</blockquote> * * Output: * <div class="callout" data-callout="type" data-callout-fold="+|-"> * <div class="callout-title">Title</div> * <div class="callout-content">Content</div> * </div> * * Type must start with a letter (rejects `[!-foo]`); then word chars / hyphen. * Title may contain inline marked-rendered HTML (em/strong/etc). */function convertCalloutsInHtml(html: string): string { // Use a detached container so the parsed DOM doesn't interact with the page. const container = document.createElement('div') container.innerHTML = html
// Match `[!type][+/-]?<rest of first line>` const headerRe = /^\[!([a-zA-Z][\w-]*)\]([+-]?)([^\n]*)/
// Walk all blockquotes; for each, inspect its first <p>. // We snapshot the list first because we'll be replacing nodes in place. // Process INNERMOST first by reversing document order — querySelectorAll // returns ancestors before descendants for nested elements (in tree order), // and within siblings, earlier ones first. Reversing lets us convert inner // callouts before their outer wrappers, so the outer's content captures the // already-converted inner divs (not raw <blockquote> markup). const blockquotes = Array.from(container.querySelectorAll('blockquote')).reverse()
for (const bq of blockquotes) { // Find first <p> child (skipping text whitespace) const firstP = bq.firstElementChild as HTMLElement | null if (!firstP || firstP.tagName !== 'P') continue
// The first text token of the <p> must start with [!type]. We read // textContent to see the literal pattern even if marked wrapped part of // the title in inline tags (e.g., <strong>). const headText = firstP.textContent ?? '' const m = headText.match(headerRe) if (!m) continue
const calloutType = m[1].toLowerCase() const fold = m[2] // '', '+', or '-' // m[3] contains the title line; it's extracted via DOM traversal below instead
// Capture content of the first <p> after the title line: any HTML that // followed the first `\n` inside the <p>. Use innerHTML and split on the // first newline that exists in textContent, then map back. // Simpler: take the original innerHTML and remove the leading // `[!type][+-]?` prefix using textContent length math. const prefixLen = `[!${m[1]}]${fold}`.length // Walk the first <p>'s child nodes to slice off the prefix while // preserving inline HTML. const titleAndRest = sliceFromTextOffset(firstP, prefixLen) // Split titleAndRest at the first newline to get titleHtml vs rest const newlineIdx = findFirstNewlineInChildren(titleAndRest) let titleHtml = '' let restOfP = '' if (newlineIdx === -1) { titleHtml = stringifyNodes(titleAndRest).trim() } else { const { before, after } = splitChildrenAtTextOffset(titleAndRest, newlineIdx) titleHtml = stringifyNodes(before).trim() restOfP = stringifyNodes(after).trim() }
// The remaining siblings of the first <p> in the blockquote are the // "trailing content" (paragraphs, nested blockquotes, etc.) const trailingNodes = Array.from(bq.children).slice(1) const trailingHtml = trailingNodes.map((n) => n.outerHTML).join('').trim()
// Combine inline rest-of-p and trailing siblings. const contentHtml = [restOfP, trailingHtml] .filter((s) => s.length > 0) .join('')
// Build the callout node const callout = document.createElement('div') callout.className = 'callout' callout.setAttribute('data-callout', calloutType) if (fold) { // `fold` is constrained by the regex to be exactly '+' or '-' callout.setAttribute('data-callout-fold', fold) } if (titleHtml.length > 0) { const titleDiv = document.createElement('div') titleDiv.className = 'callout-title' titleDiv.innerHTML = titleHtml callout.appendChild(titleDiv) } if (contentHtml.length > 0) { const contentDiv = document.createElement('div') contentDiv.className = 'callout-content' contentDiv.innerHTML = contentHtml callout.appendChild(contentDiv) }
bq.replaceWith(callout) }
return container.innerHTML}
/** * Helpers for slicing the first <p>'s children at a textContent offset, * preserving inline HTML structure. */
/** Return the children of `parent` after skipping the first `offset` characters of textContent. */function sliceFromTextOffset(parent: HTMLElement, offset: number): Node[] { const result: Node[] = [] let remaining = offset for (const child of Array.from(parent.childNodes)) { if (remaining <= 0) { result.push(child) continue } const text = child.textContent ?? '' if (text.length <= remaining) { remaining -= text.length continue // skip entirely } // Partial: keep the tail if (child.nodeType === Node.TEXT_NODE) { const sliced = document.createTextNode(text.slice(remaining)) result.push(sliced) remaining = 0 } else { // Non-text node where the prefix to skip ends inside it. // Approximate by keeping the whole node — this happens only when the // [!type] prefix straddles into an inline element, which marked never // does for the literal pattern recognized by headerRe. result.push(child) remaining = 0 } } return result}
/** Find the textContent offset of the first newline across a list of nodes. Returns -1 if none. */function findFirstNewlineInChildren(nodes: Node[]): number { let acc = 0 for (const n of nodes) { const text = n.textContent ?? '' const idx = text.indexOf('\n') if (idx !== -1) return acc + idx acc += text.length } return -1}
/** * Split `nodes` at the given textContent offset, returning before/after lists. * * If the character at the split offset is `\n`, it's consumed (treated as the * separator between title and content). Otherwise the offset character stays * on the `after` side. * * Edge cases handled: * - offset lands exactly at a node boundary (text.length === remaining): * subsequent nodes go to `after`, including any newline at their start * - newline is inside a non-text inline element: we descend into the element * to perform the split, preserving the element wrapper on the appropriate * side(s) */function splitChildrenAtTextOffset(nodes: Node[], offset: number): { before: Node[]; after: Node[] } { const before: Node[] = [] const after: Node[] = [] let remaining = offset let split = false for (const n of nodes) { if (split) { after.push(n) continue } const text = n.textContent ?? '' // Strict less-than-or-equal: when offset lands exactly at boundary, this // node goes entirely on `before`, and the newline (which must start the // next node) is handled in the next iteration via the splitting branch // (where text.length(>0) > remaining(0) holds). if (text.length <= remaining) { before.push(n) remaining -= text.length continue } // Split point inside this node if (n.nodeType === Node.TEXT_NODE) { const ch = text.charAt(remaining) // Skip the newline if that's the separator; otherwise preserve all chars. const skip = ch === '\n' ? 1 : 0 const headText = text.slice(0, remaining) const tailText = text.slice(remaining + skip) if (headText.length > 0) before.push(document.createTextNode(headText)) if (tailText.length > 0) after.push(document.createTextNode(tailText)) } else if (n.nodeType === Node.ELEMENT_NODE) { // Newline inside an inline element — recursively split its children. const el = n as HTMLElement const inner = splitChildrenAtTextOffset(Array.from(el.childNodes), remaining) // Build cloned wrappers for each side that has content if (inner.before.length > 0) { const wrapBefore = el.cloneNode(false) as HTMLElement for (const c of inner.before) wrapBefore.appendChild(c) before.push(wrapBefore) } if (inner.after.length > 0) { const wrapAfter = el.cloneNode(false) as HTMLElement for (const c of inner.after) wrapAfter.appendChild(c) after.push(wrapAfter) } } else { // Other node types (comments, etc.): keep on `before` side before.push(n) } split = true } return { before, after }}
/** Render a list of nodes back to HTML string. */function stringifyNodes(nodes: Node[]): string { const wrapper = document.createElement('div') for (const n of nodes) { wrapper.appendChild(n.cloneNode(true)) } return wrapper.innerHTML}
/** * Generate a fresh document render ID. Obsidian's `docId` is a per-render * unique identifier (used by plugins like Dataview to dedupe caches across * multiple views of the same source path). We honor that contract. */function newRenderDocId(): string { // crypto.randomUUID is widely available in modern browsers and Node 19+. // Fallback to a Math.random-based ID for environments without it. if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `render-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}
function createPostProcessorContext( source: string, sourcePath: string, component: Component, onChildAdded?: () => void,): MarkdownPostProcessorContext { const fmResult = extractFrontmatter(source) return { docId: newRenderDocId(), sourcePath, frontmatter: fmResult.frontmatter, addChild(child: MarkdownRenderChild): void { onChildAdded?.() component.addChild(child) }, getSectionInfo(_el: HTMLElement): null { return null }, }}
export function hasRegisteredCodeBlockProcessor(language: string): boolean { return globalPostProcessors.some((entry) => entry.codeBlock?.language === language)}
export function getPostProcessorRevision(): number { return postProcessorRevision}
export function subscribeToPostProcessorChanges(listener: () => void): () => void { postProcessorListeners.add(listener) return () => postProcessorListeners.delete(listener)}
export async function invokeCodeBlockProcessor(options: { pluginId: string language: string handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => Promise<unknown> | void source: string el: HTMLElement ctx: MarkdownPostProcessorContext}): Promise<boolean> { let result: Promise<unknown> | void try { result = options.handler(options.source, options.el, options.ctx) } catch (error) { console.error( `[Reams] CodeBlockProcessor (${options.pluginId}, ${options.language}) threw synchronously:`, error, ) return false } try { await result return true } catch (error) { console.error(`[Reams] CodeBlockProcessor (${options.pluginId}, ${options.language}) rejected:`, error) return false }}
export async function renderMarkdownCodeBlockToElement(options: { language: string source: string documentSource: string el: HTMLElement sourcePath: string component: Component}): Promise<'rendered' | 'empty' | 'error'> { const entry = globalPostProcessors .slice() .filter((candidate) => candidate.codeBlock?.language === options.language)[0] if (!entry?.codeBlock) return 'empty'
const container = options.el enhanceRenderElement(container) container.innerHTML = '' let childCount = 0 const ctx = createPostProcessorContext(options.documentSource, options.sourcePath, options.component, () => { childCount++ }) const succeeded = await invokeCodeBlockProcessor({ pluginId: entry.pluginId, language: options.language, handler: entry.codeBlock.handler, source: options.source, el: container, ctx, }) if (!succeeded) return 'error' const hasMeaningfulOutput = (element: HTMLElement): boolean => { for (const child of Array.from(element.childNodes)) { if (child.nodeType === Node.TEXT_NODE && (child.textContent ?? '').trim() !== '') return true if (child.nodeType !== Node.ELEMENT_NODE) continue const childElement = child as HTMLElement if (/^(IMG|SVG|CANVAS|VIDEO|AUDIO|IFRAME|INPUT|TABLE)$/.test(childElement.tagName)) return true if (hasMeaningfulOutput(childElement)) return true } return false } return hasMeaningfulOutput(container) || childCount > 0 ? 'rendered' : 'empty'}
/** Tracks whether we've warned about null-component leak risk. */let _warnedNullComponent = false
/** * Reset internal warn-once state for tests. * @internal */export function _resetMarkdownRendererWarnedState(): void { _warnedNullComponent = false}
/** * Render markdown source into an HTML element and invoke post-processors. * * Children added via `ctx.addChild(child)` are attached to the parent * `component` via `component.addChild(child)` so their lifecycle (onload at * add time, onunload when parent unloads, recursive children) is properly * bound. * * If `component` is null: * - The first such call in a session emits a console.warn explaining that * children added during this render WILL LEAK (no automatic unload). * - An internal anonymous Component is created and loaded so children at * least invoke onload synchronously. * - Callers should pass a Component (e.g. their plugin instance, a view, or * a fresh `new Component()` they unload manually) to get proper cleanup. * * @param source - Markdown source text * @param el - Target HTML element to render into * @param sourcePath - Path to the source file (for resolving links) * @param component - Parent component for lifecycle management (or null) */export async function renderMarkdownToElement( source: string, el: HTMLElement, sourcePath: string, component: Component | null): Promise<void> { // Extract frontmatter once and reuse for HTML parse const fmResult = extractFrontmatter(source) const html = parseMarkdownToHtml(source, fmResult)
// Wrap rendered HTML const wrapper = document.createElement('div') wrapper.className = 'markdown-rendered' enhanceRenderElement(wrapper) wrapper.innerHTML = html el.innerHTML = '' el.appendChild(wrapper)
// Lifecycle owner for children added via ctx.addChild. // null component → internal owner (children leak; warn once). let childOwner: Component if (component === null) { if (!_warnedNullComponent) { _warnedNullComponent = true console.warn( '[Reams] renderMarkdownToElement called with component=null. ' + 'MarkdownRenderChild instances added via ctx.addChild() will load ' + 'but never auto-unload. Pass a parent Component to bind their lifecycle.' ) } childOwner = new Component() childOwner.load() } else { childOwner = component }
const ctx = createPostProcessorContext(source, sourcePath, childOwner)
// Snapshot the processor list at render-start. Concurrent register/clear // calls from inside a processor are deferred to the next render — this // gives stable, predictable iteration semantics. // // Trade-off: a processor that calls `clearPostProcessorsForPlugin('p2')` // does NOT cancel p2's invocation on the current render. To cancel mid- // render, processors must check a runtime flag they own. const processors = globalPostProcessors.slice()
// Snapshot blocks similarly — processors may detach/replace nodes. const blocks = Array.from(wrapper.children) for (const block of blocks) { if (!(block instanceof HTMLElement)) continue enhanceRenderElement(block)
for (const { processor, pluginId } of processors) { try { const result = processor(block, ctx) if (result instanceof Promise) { // Convert any rejection into a console.error so unhandled-rejection // tracking doesn't fire spuriously. await result.catch((err) => { console.error( `[Reams] MarkdownPostProcessor from ${pluginId} rejected:`, err ) }) } } catch (err) { console.error( `[Reams] MarkdownPostProcessor from ${pluginId} threw:`, err ) } } }}
/** * Get the count of registered post-processors (for testing). * @internal */export function getPostProcessorCount(): number { return globalPostProcessors.length}
/** * Clear all post-processors (for testing). * @internal */export function clearAllPostProcessors(): void { const hadEntries = globalPostProcessors.length > 0 globalPostProcessors.length = 0 if (hadEntries) notifyPostProcessorChanges()}
// MarkdownRenderChild is exported from `./markdown-render-child` and re-exported// from `./obsidian-stubs`. We do NOT re-export it here to avoid a duplicate// export site that confuses tooling like knip.