Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232// src/plugin/__tests__/markdown-renderer.test.ts/** * Tests for the markdown rendering pipeline: * - parseMarkdownToHtml basic rendering * - MarkdownRenderer.render and .renderMarkdown * - Post-processor invocation, ordering, and error isolation * - MarkdownRenderChild lifecycle (component-managed) * - Plugin reload cleanup * - Security: XSS via raw HTML, javascript: URLs, CSS selector injection */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { parseMarkdownToHtml, renderMarkdownToElement, registerPostProcessor, clearPostProcessorsForPlugin, clearAllPostProcessors, getPostProcessorCount, isDangerousUrl, _resetMarkdownRendererWarnedState,} from '../markdown-renderer-impl'import { MarkdownRenderer, MarkdownRenderChild } from '../obsidian-stubs'import { Component, _resetComponentWarnedState } from '../component'import { App } from '../app'import { Vault } from '../vault'import { Workspace } from '../workspace'import { FileIndex } from '../../services/file-index'import { Plugin, _resetPluginWarnedStubs } from '../plugin'import type { PluginManifest } from '../types'import type { AutomergeUrl } from '@automerge/automerge-repo'
function createMockDocumentManager() { return { createFile: vi.fn(), createFolder: vi.fn(), createFileInFolder: vi.fn().mockImplementation( async () => `automerge:file-${Math.random().toString(36).slice(2)}` as AutomergeUrl ), readText: vi.fn(async (url: AutomergeUrl) => ({ status: 'unavailable' as const, documentId: url })), updateContent: vi.fn(), } as any}
function createTestApp(): App { const dm = createMockDocumentManager() const vault = new Vault('Test', new FileIndex(dm, 'automerge:root' as AutomergeUrl), dm) const workspace = new Workspace(document.createElement('div')) return new App(vault, workspace)}
function createTestPlugin(app: App, id = 'test-plugin'): Plugin { const manifest: PluginManifest = { id, name: 'Test Plugin', version: '1.0.0', minAppVersion: '1.0.0', author: 'Test', description: 'Test plugin', } class TestPlugin extends Plugin { onload() {} } return new TestPlugin(app, manifest)}
// Top-level isolation: ensure no test leaks processor registrations or// warn-once state to the next.afterEach(() => { clearAllPostProcessors() _resetMarkdownRendererWarnedState() _resetComponentWarnedState() _resetPluginWarnedStubs()})
// -----------------------------------------------------------------------------// parseMarkdownToHtml// -----------------------------------------------------------------------------describe('parseMarkdownToHtml', () => { it('renders bold text', () => { const html = parseMarkdownToHtml('**bold**') expect(html).toContain('<strong>bold</strong>') })
it('renders italic text', () => { const html = parseMarkdownToHtml('*italic*') expect(html).toContain('<em>italic</em>') })
it('renders headings', () => { const html = parseMarkdownToHtml('# Heading 1\n## Heading 2') expect(html).toContain('<h1>Heading 1</h1>') expect(html).toContain('<h2>Heading 2</h2>') })
it('renders unordered lists', () => { const html = parseMarkdownToHtml('- item 1\n- item 2') expect(html).toContain('<ul>') expect(html).toContain('<li>item 1</li>') expect(html).toContain('<li>item 2</li>') })
it('renders ordered lists', () => { const html = parseMarkdownToHtml('1. first\n2. second') expect(html).toContain('<ol>') expect(html).toContain('<li>first</li>') expect(html).toContain('<li>second</li>') })
it('renders inline code', () => { const html = parseMarkdownToHtml('use `const x = 1`') expect(html).toContain('<code>const x = 1</code>') })
it('renders code blocks', () => { const html = parseMarkdownToHtml('```js\nconst x = 1\n```') expect(html).toContain('<pre>') expect(html).toContain('<code') expect(html).toContain('const x = 1') })
it('renders links', () => { const html = parseMarkdownToHtml('[example](https://example.com)') expect(html).toContain('<a href="https://example.com"') expect(html).toContain('example</a>') })
it('renders blockquotes', () => { const html = parseMarkdownToHtml('> quoted text') expect(html).toContain('<blockquote>') expect(html).toContain('quoted text') })
it('renders tables', () => { const html = parseMarkdownToHtml('| A | B |\n|---|---|\n| 1 | 2 |') expect(html).toContain('<table>') expect(html).toContain('<th>A</th>') expect(html).toContain('<td>1</td>') })
it('strips frontmatter from output', () => { const html = parseMarkdownToHtml('---\ntitle: Test\n---\n\n# Content') expect(html).not.toContain('title: Test') expect(html).toContain('<h1>Content</h1>') })})
// -----------------------------------------------------------------------------// Security: XSS prevention// -----------------------------------------------------------------------------describe('Security: isDangerousUrl (direct unit tests)', () => { // Direct tests of the URL filter — independent of marked's parsing quirks. const dangerous = [ 'javascript:alert(1)', 'JAVASCRIPT:alert(1)', ' javascript:alert(1)', '\tjavascript:alert(1)', '\njavascript:alert(1)', 'vbscript:msgbox(1)', 'data:text/html,<script>alert(1)</script>', 'data:application/x-shockwave-flash,foo', 'data:image/svg+xml,<svg onload=alert(1)/>', 'data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+', // HTML-entity-encoded "javascript:" with semicolon 'javascript:alert(1)', 'javascript:alert(1)',
// DOUBLE-ENCODED: & -> & on first pass, then j -> j on second '&#x6A;avascript:alert(1)', '&amp;#x6A;avascript:alert(1)', // Percent-encoded '%6Aavascript:alert(1)', // Zero-width Unicode obfuscation 'java\u200bscript:alert(1)', 'java\u200dscript:alert(1)', 'java\ufeffscript:alert(1)', // Mixed encoding + obfuscation '\u200bjavascript:alert(1)', // Deep encoding nesting (should iterate to fixpoint) '&amp;amp;amp;amp;#x6A;avascript:alert(1)', '&amp;amp;amp;amp;amp;amp;amp;#x6A;avascript:alert(1)', // Percent-encoded amp + entity '%26amp%3B%23x6A%3Bavascript%3Aalert(1)', ] for (const url of dangerous) { it(`flags as dangerous: ${JSON.stringify(url)}`, () => { expect(isDangerousUrl(url)).toBe(true) }) }
const safe = [ 'https://example.com', 'http://example.com', 'mailto:user@example.com', 'tel:+15551234567', 'ftp://files.example.com', '/relative/path', './relative.md', '../up.md', '#fragment', '?query=string', 'data:image/png;base64,iVBOR', 'data:image/jpeg;base64,xyz', 'data:image/gif;base64,xyz', '', ] for (const url of safe) { it(`allows: ${JSON.stringify(url)}`, () => { expect(isDangerousUrl(url)).toBe(false) }) }})
describe('Security: XSS prevention (end-to-end via parseMarkdownToHtml)', () => { it('escapes raw <script> HTML', () => { const html = parseMarkdownToHtml('<script>alert("xss")</script>') expect(html).not.toContain('<script>') expect(html).toContain('<script>') })
it('escapes raw <img onerror=...> HTML', () => { const html = parseMarkdownToHtml('<img src=x onerror="alert(1)">') expect(html).not.toMatch(/<img[^>]*onerror/i) })
it('neutralizes javascript: URL in markdown link', () => { const html = parseMarkdownToHtml('[click](javascript:alert(1))') expect(html).not.toContain('javascript:') expect(html).toContain('href="#"') })
it('neutralizes vbscript: URL in markdown link', () => { const html = parseMarkdownToHtml('[click](vbscript:msgbox(1))') expect(html).not.toContain('vbscript:') expect(html).toContain('href="#"') })
it('neutralizes data:text/html URL in markdown link', () => { const html = parseMarkdownToHtml('[click](data:text/html,<script>alert(1)</script>)') expect(html).not.toContain('data:text/html') expect(html).toContain('href="#"') })
it('neutralizes HTML-entity-encoded javascript: URL', () => { const html = parseMarkdownToHtml('[click](javascript:alert(1))') expect(html.toLowerCase()).not.toMatch(/href=["'][^"']*javascript:/) })
it('allows safe http/https URLs', () => { const html = parseMarkdownToHtml('[ok](https://example.com)') expect(html).toContain('href="https://example.com"') })
it('allows mailto URLs (autolink)', () => { const html = parseMarkdownToHtml('[email](mailto:user@example.com)') expect(html).toContain('href="mailto:user@example.com"') })
it('neutralizes javascript: URL in image src', () => { const html = parseMarkdownToHtml(')') expect(html).not.toContain('javascript:') })
it('allows data:image URLs in images', () => { const html = parseMarkdownToHtml('') expect(html).toContain('src="data:image/png;base64,iVBOR"') })
it('renders network images as nonloading alt text, including inside callouts', async () => { const el = document.createElement('div') const processor = vi.fn() registerPostProcessor('trusted', processor) await renderMarkdownToElement( '> [!note] Preview\n> \n\n', el, '/test.md', null, ) expect(el.querySelector('.callout')).not.toBeNull() expect(el.querySelectorAll('img')).toHaveLength(0) expect(el.textContent).toContain('secret') expect(el.textContent).toContain('local') expect(processor).toHaveBeenCalled() })
it('shows a visible placeholder for untitled and oversized images', () => { expect(parseMarkdownToHtml('')).toContain('[image: untitled]') expect(parseMarkdownToHtml(`})`)) .toContain('[image: big]') })
it('neutralizes reference-style link with dangerous URL', () => { const html = parseMarkdownToHtml('[a][b]\n\n[b]: javascript:alert(1)') expect(html.toLowerCase()).not.toMatch(/href=["'][^"']*javascript:/) })})
// -----------------------------------------------------------------------------// MarkdownRenderer surface// -----------------------------------------------------------------------------describe('MarkdownRenderer.renderMarkdown (legacy 4-arg)', () => { it('renders markdown to element', async () => { const el = document.createElement('div') await MarkdownRenderer.renderMarkdown('**bold**', el, '/', null) expect(el.innerHTML).toContain('<strong>bold</strong>') })
it('clears existing content before rendering', async () => { const el = document.createElement('div') el.innerHTML = '<p>old content</p>' await MarkdownRenderer.renderMarkdown('# New', el, '/', null) expect(el.innerHTML).not.toContain('old content') expect(el.innerHTML).toContain('<h1>New</h1>') })
it('wraps content in markdown-rendered div', async () => { const el = document.createElement('div') await MarkdownRenderer.renderMarkdown('text', el, '/', null) const wrapper = el.querySelector('.markdown-rendered') expect(wrapper).not.toBeNull() })
it('invokes registered post-processors (legacy 4-arg invokes them too)', async () => { const processor = vi.fn() registerPostProcessor('test-plugin', processor, 0) const el = document.createElement('div') await MarkdownRenderer.renderMarkdown('# Hello', el, '/test.md', null) expect(processor).toHaveBeenCalled() })})
describe('MarkdownRenderer.render (modern 5-arg)', () => { let app: App beforeEach(() => { app = createTestApp() }) afterEach(() => { app.destroy() })
it('renders markdown to element', async () => { const el = document.createElement('div') await MarkdownRenderer.render(app, '# Hello', el, '/test.md', null) expect(el.innerHTML).toContain('<h1>Hello</h1>') })
it('invokes registered post-processors', async () => { const processor = vi.fn() registerPostProcessor('test-plugin', processor, 0)
const el = document.createElement('div') await MarkdownRenderer.render(app, '# Test', el, '/test.md', null)
expect(processor).toHaveBeenCalled() const [element, ctx] = processor.mock.calls[0] expect(element).toBeInstanceOf(HTMLElement) expect(ctx.sourcePath).toBe('/test.md') })
it('passes correct context to post-processors', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
const el = document.createElement('div') await MarkdownRenderer.render(app, 'test', el, '/path/to/doc.md', null)
expect(capturedCtx).not.toBeNull() // docId is a per-render unique ID, distinct from sourcePath expect(typeof capturedCtx.docId).toBe('string') expect(capturedCtx.docId.length).toBeGreaterThan(0) expect(capturedCtx.docId).not.toBe('/path/to/doc.md') expect(capturedCtx.sourcePath).toBe('/path/to/doc.md') expect(typeof capturedCtx.addChild).toBe('function') expect(typeof capturedCtx.getSectionInfo).toBe('function') expect(capturedCtx.getSectionInfo(el)).toBeNull() })
it('generates a fresh docId per render', async () => { const ids: string[] = [] registerPostProcessor('test-plugin', (_el, ctx) => { ids.push(ctx.docId) }, 0)
const el = document.createElement('div') await MarkdownRenderer.render(app, 'first', el, '/same.md', null) await MarkdownRenderer.render(app, 'second', el, '/same.md', null) await MarkdownRenderer.render(app, 'third', el, '/same.md', null)
expect(ids).toHaveLength(3) expect(new Set(ids).size).toBe(3) // all unique })
it('parses frontmatter into ctx.frontmatter', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
const el = document.createElement('div') await MarkdownRenderer.render( app, '---\ntitle: Test Doc\ncount: 42\n---\n\nBody', el, '/test.md', null ) expect(capturedCtx.frontmatter).toEqual({ title: 'Test Doc', count: 42 }) })
it('ctx.frontmatter is null when no frontmatter present', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
const el = document.createElement('div') await MarkdownRenderer.render(app, 'plain body', el, '/test.md', null) expect(capturedCtx.frontmatter).toBeNull() })
it('ctx.frontmatter is null on malformed YAML', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
const el = document.createElement('div') await MarkdownRenderer.render( app, '---\n: : :\nbroken\n---\n\nbody', el, '/test.md', null ) expect(capturedCtx.frontmatter).toBeNull() })
it('ctx.frontmatter is null when YAML is a top-level array (not an object)', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
const el = document.createElement('div') await MarkdownRenderer.render( app, '---\n[1, 2, 3]\n---\n\nbody', el, '/test.md', null ) expect(capturedCtx.frontmatter).toBeNull() })
it('ctx.frontmatter is null when YAML exceeds size limit (DoS protection)', async () => { let capturedCtx: any = null registerPostProcessor('test-plugin', (_el, ctx) => { capturedCtx = ctx }, 0)
// Build > 64KB of frontmatter const huge = 'key: "' + 'x'.repeat(70 * 1024) + '"\n' const el = document.createElement('div') await MarkdownRenderer.render( app, `---\n${huge}---\n\nbody`, el, '/test.md', null ) expect(capturedCtx.frontmatter).toBeNull() })})
// -----------------------------------------------------------------------------// Post-processor registration & ordering// -----------------------------------------------------------------------------describe('Post-processor registration and ordering', () => { it('registers post-processors to global registry', () => { expect(getPostProcessorCount()).toBe(0) registerPostProcessor('plugin-a', vi.fn(() => {}), 0) expect(getPostProcessorCount()).toBe(1) registerPostProcessor('plugin-b', vi.fn(() => {}), 0) expect(getPostProcessorCount()).toBe(2) })
it('invokes post-processors in sortOrder', async () => { const order: string[] = [] registerPostProcessor('plugin-c', () => { order.push('c') }, 30) registerPostProcessor('plugin-a', () => { order.push('a') }, 10) registerPostProcessor('plugin-b', () => { order.push('b') }, 20)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(order).toEqual(['a', 'b', 'c']) })
it('preserves insertion order for processors with equal sortOrder', async () => { const order: string[] = [] registerPostProcessor('p1', () => { order.push('1') }, 10) registerPostProcessor('p2', () => { order.push('2') }, 10) registerPostProcessor('p3', () => { order.push('3') }, 10)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(order).toEqual(['1', '2', '3']) })
it('clears post-processors for a specific plugin', () => { registerPostProcessor('plugin-a', vi.fn(() => {}), 0) registerPostProcessor('plugin-b', vi.fn(() => {}), 0) registerPostProcessor('plugin-a', vi.fn(() => {}), 0) expect(getPostProcessorCount()).toBe(3)
clearPostProcessorsForPlugin('plugin-a') expect(getPostProcessorCount()).toBe(1) })
it('snapshots processor list at render time (registrations during iteration are deferred)', async () => { const calls: string[] = [] registerPostProcessor('p1', () => { calls.push('1') // Register a new processor mid-iteration; it should NOT run on this render registerPostProcessor('p2', () => { calls.push('2') }, 0) }, 0)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(calls).toEqual(['1']) expect(getPostProcessorCount()).toBe(2) })
it('processor that detaches a block: subsequent processors receive same (now-detached) block reference', async () => { // Documents the chosen semantics: blocks are snapshotted once before // iteration. If processor A removes `block` from its parent, processor B // still receives the same `block` reference, but `block.parentElement` // is now null. This mirrors Obsidian's behavior where each processor // decides independently whether to inspect ancestor state. const observed: Array<{ name: string; sameBlock: boolean; hasParent: boolean }> = [] let firstBlockRef: HTMLElement | null = null
registerPostProcessor('p1-detacher', (block) => { firstBlockRef = block observed.push({ name: 'p1', sameBlock: true, hasParent: block.parentElement !== null }) block.remove() }, 0) registerPostProcessor('p2-observer', (block) => { observed.push({ name: 'p2', sameBlock: block === firstBlockRef, hasParent: block.parentElement !== null, }) }, 10)
const el = document.createElement('div') await renderMarkdownToElement('# only one block', el, '/', null)
expect(observed[0]).toMatchObject({ name: 'p1', hasParent: true }) // p2 receives the SAME block reference, but it's been detached expect(observed[1]).toEqual({ name: 'p2', sameBlock: true, hasParent: false }) })
it('snapshots processor list: clear during iteration does NOT cancel already-snapshotted processors', async () => { // Documents the chosen semantics: a processor that calls // clearPostProcessorsForPlugin('p2') during this render does NOT cancel // p2's invocation on the current render. This keeps iteration semantics // simple and predictable; if a processor needs to abort, it must check a // runtime flag it owns. const calls: string[] = [] registerPostProcessor('p1', () => { calls.push('1') clearPostProcessorsForPlugin('p2') }, 0) registerPostProcessor('p2', () => { calls.push('2') }, 10)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(calls).toEqual(['1', '2']) // Registry state reflects the clear AFTER the render completes expect(getPostProcessorCount()).toBe(1) })})
// -----------------------------------------------------------------------------// Error isolation// -----------------------------------------------------------------------------describe('Post-processor error isolation', () => { it('continues to next processor when one throws', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const processor1 = vi.fn(() => { throw new Error('processor 1 failed') }) const processor2 = vi.fn() const processor3 = vi.fn()
registerPostProcessor('plugin-1', processor1, 10) registerPostProcessor('plugin-2', processor2, 20) registerPostProcessor('plugin-3', processor3, 30)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(processor1).toHaveBeenCalled() expect(processor2).toHaveBeenCalled() expect(processor3).toHaveBeenCalled() expect(consoleError).toHaveBeenCalledWith( expect.stringContaining('plugin-1'), expect.any(Error) )
consoleError.mockRestore() })
it('handles async processor rejection', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const asyncProcessor = vi.fn(async () => { await Promise.resolve() throw new Error('async failure') }) const syncProcessor = vi.fn()
registerPostProcessor('async-plugin', asyncProcessor, 10) registerPostProcessor('sync-plugin', syncProcessor, 20)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(asyncProcessor).toHaveBeenCalled() expect(syncProcessor).toHaveBeenCalled() expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore() })})
// -----------------------------------------------------------------------------// MarkdownRenderChild lifecycle// -----------------------------------------------------------------------------describe('MarkdownRenderChild lifecycle', () => { it('fires onload synchronously when addChild is called (with parent component)', async () => { const onloadSpy = vi.fn()
class TestChild extends MarkdownRenderChild { onload() { onloadSpy() } }
registerPostProcessor('test-plugin', (_el, ctx) => { const child = new TestChild(document.createElement('div')) expect(onloadSpy).not.toHaveBeenCalled() ctx.addChild(child) expect(onloadSpy).toHaveBeenCalledTimes(1) }, 0)
const parent = new Component() parent.load() const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', parent)
expect(onloadSpy).toHaveBeenCalledTimes(1) parent.unload() })
it('fires onload synchronously when addChild is called (with null component)', async () => { _resetMarkdownRendererWarnedState() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const onloadSpy = vi.fn()
class TestChild extends MarkdownRenderChild { onload() { onloadSpy() } }
registerPostProcessor('test-plugin', (_el, ctx) => { ctx.addChild(new TestChild(document.createElement('div'))) }, 0)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
// Even without a parent component, child should load expect(onloadSpy).toHaveBeenCalledTimes(1) // And the user MUST be warned about the leak expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('component=null') )
warnSpy.mockRestore() })
it('warns at most once per session for null component', async () => { _resetMarkdownRendererWarnedState() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null) await renderMarkdownToElement('# Test 2', el, '/', null) await renderMarkdownToElement('# Test 3', el, '/', null)
const nullWarnings = warnSpy.mock.calls.filter((c) => typeof c[0] === 'string' && c[0].includes('component=null') ) expect(nullWarnings).toHaveLength(1)
warnSpy.mockRestore() })
it('does NOT warn when a real component is provided', async () => { _resetMarkdownRendererWarnedState() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const parent = new Component() parent.load() const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', parent)
const nullWarnings = warnSpy.mock.calls.filter((c) => typeof c[0] === 'string' && c[0].includes('component=null') ) expect(nullWarnings).toHaveLength(0) parent.unload() warnSpy.mockRestore() })
it('fires onunload when parent component unloads', async () => { const onunloadSpy = vi.fn()
class TestChild extends MarkdownRenderChild { onunload() { onunloadSpy() } }
registerPostProcessor('test-plugin', (_el, ctx) => { ctx.addChild(new TestChild(document.createElement('div'))) }, 0)
const parent = new Component() parent.load()
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', parent)
expect(onunloadSpy).not.toHaveBeenCalled() parent.unload() expect(onunloadSpy).toHaveBeenCalledTimes(1) })
it('fires onunload for multiple children', async () => { const unloads: string[] = []
class TestChild extends MarkdownRenderChild { constructor(el: HTMLElement, private name: string) { super(el) } onunload() { unloads.push(this.name) } }
registerPostProcessor('test-plugin', (_el, ctx) => { ctx.addChild(new TestChild(document.createElement('div'), 'child-1')) ctx.addChild(new TestChild(document.createElement('div'), 'child-2')) }, 0)
const parent = new Component() parent.load()
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', parent)
parent.unload() expect(unloads).toContain('child-1') expect(unloads).toContain('child-2') })
it('Component.addChild on dead parent warns at most once per session', () => { _resetComponentWarnedState() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// First dead-parent addChild const p1 = new Component() p1.load() p1.unload() p1.addChild(new Component())
// Second dead-parent addChild — should NOT warn again const p2 = new Component() p2.load() p2.unload() p2.addChild(new Component())
// Third for good measure const p3 = new Component() p3.load() p3.unload() p3.addChild(new Component())
const deadParentWarnings = warnSpy.mock.calls.filter((c) => typeof c[0] === 'string' && c[0].includes('dead (unloaded) parent') ) expect(deadParentWarnings).toHaveLength(1)
warnSpy.mockRestore() })
it('nested children: child.addChild registers grandchild and unload cascades', async () => { const unloadOrder: string[] = []
class Named extends MarkdownRenderChild { constructor(el: HTMLElement, private name: string) { super(el) } onunload() { unloadOrder.push(this.name) } }
registerPostProcessor('test-plugin', (_el, ctx) => { const c = new Named(document.createElement('div'), 'child') ctx.addChild(c) const gc = new Named(document.createElement('div'), 'grandchild') c.addChild(gc) }, 0)
const parent = new Component() parent.load() const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', parent)
parent.unload() expect(unloadOrder).toContain('child') expect(unloadOrder).toContain('grandchild') })})
// -----------------------------------------------------------------------------// Plugin reload cleanup// -----------------------------------------------------------------------------describe('Plugin reload cleanup', () => { it('clearPostProcessorsForPlugin removes all processors for that plugin', () => { registerPostProcessor('plugin-a', vi.fn(() => {}), 0) registerPostProcessor('plugin-a', vi.fn(() => {}), 0) registerPostProcessor('plugin-b', vi.fn(() => {}), 0)
expect(getPostProcessorCount()).toBe(3) clearPostProcessorsForPlugin('plugin-a') expect(getPostProcessorCount()).toBe(1) clearPostProcessorsForPlugin('plugin-b') expect(getPostProcessorCount()).toBe(0) })
it('re-registering after clear does not double-invoke', async () => { const calls: string[] = []
registerPostProcessor('test-plugin', () => { calls.push('first') }, 0) clearPostProcessorsForPlugin('test-plugin') registerPostProcessor('test-plugin', () => { calls.push('second') }, 0)
const el = document.createElement('div') await renderMarkdownToElement('# Test', el, '/', null)
expect(calls).toEqual(['second']) })})
// NOTE: The "Warn-once instrumentation" describe block was removed because// SuggestModal.setInstructions is now fully implemented (chainlink #342, F-COMPAT-3).// See src/plugin/__tests__/suggest-modal.test.ts for the new tests.
// -----------------------------------------------------------------------------// Callout handling// -----------------------------------------------------------------------------describe('Callout handling', () => { it('converts Obsidian-style callouts to div with data-callout', () => { const html = parseMarkdownToHtml('> [!note] Important\n> This is a note') expect(html).toContain('data-callout="note"') expect(html).toContain('Important') })
it('normalizes callout type to lowercase', () => { const html = parseMarkdownToHtml('> [!WARNING] Be careful') expect(html).toContain('data-callout="warning"') })
it('handles callout without title', () => { const html = parseMarkdownToHtml('> [!note]\n> Content without title') expect(html).toContain('data-callout="note"') expect(html).toContain('Content without title') // Should not have empty callout-title div expect(html).not.toContain('callout-title') })
it('supports hyphenated custom callout types', () => { const html = parseMarkdownToHtml('> [!my-custom-type] Title') expect(html).toContain('data-callout="my-custom-type"') })
it('supports underscored callout types', () => { const html = parseMarkdownToHtml('> [!my_type] Title') expect(html).toContain('data-callout="my_type"') })
it('captures fold marker + (expand)', () => { const html = parseMarkdownToHtml('> [!note]+ Expanded\n> Content') expect(html).toContain('data-callout="note"') expect(html).toContain('data-callout-fold="+"') })
it('captures fold marker - (collapsed)', () => { const html = parseMarkdownToHtml('> [!note]- Collapsed\n> Content') expect(html).toContain('data-callout="note"') expect(html).toContain('data-callout-fold="-"') })
it('omits data-callout-fold when no fold marker present', () => { const html = parseMarkdownToHtml('> [!note] Title\n> Content') expect(html).not.toContain('data-callout-fold') })
it('preserves multi-paragraph callout content', () => { const html = parseMarkdownToHtml('> [!note] Title\n> Paragraph 1\n>\n> Paragraph 2') expect(html).toContain('data-callout="note"') expect(html).toContain('Paragraph 1') expect(html).toContain('Paragraph 2') })
it('rejects callout types starting with hyphen', () => { // [!-foo] would yield data-callout="-foo" which is confusing. // The regex requires types to begin with a letter. const html = parseMarkdownToHtml('> [!-foo] Title') expect(html).not.toContain('data-callout="-foo"') // The original blockquote should be preserved when no callout match expect(html).toContain('<blockquote>') })
it('handles a single callout containing a (non-callout) nested blockquote', () => { const html = parseMarkdownToHtml('> [!note] outer\n> > inner quote text\n> end of outer') expect(html).toContain('data-callout="note"') expect(html).toContain('inner quote text') // No stray dangling </blockquote> tags const openCount = (html.match(/<blockquote>/g) || []).length const closeCount = (html.match(/<\/blockquote>/g) || []).length expect(openCount).toBe(closeCount) })
it('handles truly nested callouts (callout containing a callout)', () => { const src = '> [!note] outer\n> > [!warning] inner\n> > inner content\n> end of outer' const html = parseMarkdownToHtml(src) expect(html).toContain('data-callout="note"') expect(html).toContain('data-callout="warning"') expect(html).toContain('inner content') // Both blockquotes converted; no leftover blockquote tags expect(html).not.toContain('<blockquote>') expect(html).not.toContain('</blockquote>') })
it('terminates on degenerate input (callout body containing fake nested blockquote text)', () => { // The literal text "<blockquote>" in user content (escaped by marked) // becomes <blockquote> — so it should NOT trigger iteration runaway. const html = parseMarkdownToHtml('> [!note] Title\n> body with <blockquote> as text') expect(html).toContain('data-callout="note"') // The escaped text remains as a string, not a real tag expect(html).toContain('<blockquote>') })
it('preserves inline emphasis in title with continuation line', () => { // Title contains continuation text that becomes part of the same <p> // due to marked's soft-break behavior. The callout converter must split // title from content correctly even when inline elements are involved. const html = parseMarkdownToHtml('> [!note] Plain title\n> *emphasized continuation*') expect(html).toContain('data-callout="note"') expect(html).toContain('Plain title') expect(html).toContain('emphasized continuation') // Title should not include the continuation expect(html).toMatch(/<div class="callout-title">Plain title<\/div>/) })
it('handles title that itself contains inline emphasis', () => { const html = parseMarkdownToHtml('> [!note] Title with *italic* word\n> body') expect(html).toContain('data-callout="note"') expect(html).toContain('Title with') expect(html).toContain('<em>italic</em>') expect(html).toContain('body') })
it('handles bare callout with no title and no body', () => { // Edge case: just the type marker. Result should be a valid callout div // with no title or content children — not malformed HTML. const html = parseMarkdownToHtml('> [!note]') expect(html).toContain('data-callout="note"') expect(html).not.toContain('callout-title') expect(html).not.toContain('callout-content') expect(html).not.toContain('<blockquote>') })})
// -----------------------------------------------------------------------------// registerMarkdownCodeBlockProcessor// -----------------------------------------------------------------------------describe('registerMarkdownCodeBlockProcessor security', () => { let app: App let plugin: Plugin let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => { app = createTestApp() plugin = createTestPlugin(app, 'codeblock-security-test') plugin.load() warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) })
afterEach(() => { plugin.unload() app.destroy() warnSpy.mockRestore() })
it('sanitizes language to prevent CSS selector injection', () => { const handler = vi.fn() plugin.registerMarkdownCodeBlockProcessor('foo, script', handler)
expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('invalid characters') ) expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('sanitized to "fooscript"') ) })
it('refuses registration when language sanitizes to empty string', () => { const handler = vi.fn() const before = getPostProcessorCount() plugin.registerMarkdownCodeBlockProcessor('!!!@@@', handler)
expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('empty after sanitization') ) // Processor was NOT registered into the global registry expect(getPostProcessorCount()).toBe(before) })
it('refuses registration when language is empty string', () => { const handler = vi.fn() const before = getPostProcessorCount() plugin.registerMarkdownCodeBlockProcessor('', handler)
expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('empty after sanitization') ) expect(getPostProcessorCount()).toBe(before) })
it('returned no-op processor is safely callable', () => { const handler = vi.fn() const noop = plugin.registerMarkdownCodeBlockProcessor('', handler) // Calling the returned sentinel must not throw, even with arbitrary args expect(() => (noop as any)(document.createElement('div'), {} as any)).not.toThrow() // And the original handler must NOT be invoked expect(handler).not.toHaveBeenCalled() })
it('warns when called on dead/unloaded plugin', () => { plugin.unload() warnSpy.mockClear() const handler = vi.fn() plugin.registerMarkdownCodeBlockProcessor('mylang', handler) expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('not loaded') ) })
it('allows valid language names without warning', () => { const handler = vi.fn() plugin.registerMarkdownCodeBlockProcessor('mermaid', handler) expect(warnSpy).not.toHaveBeenCalled() })
it('allows hyphens and underscores in language names', () => { const handler = vi.fn() plugin.registerMarkdownCodeBlockProcessor('my-lang_v2', handler) expect(warnSpy).not.toHaveBeenCalled() })})
describe('registerMarkdownCodeBlockProcessor handler invocation', () => { let app: App let plugin: Plugin
beforeEach(() => { app = createTestApp() plugin = createTestPlugin(app, 'codeblock-handler-test') plugin.load() })
afterEach(() => { plugin.unload() app.destroy() })
it('awaits async handler before render returns', async () => { const calls: string[] = [] plugin.registerMarkdownCodeBlockProcessor('mylang', async (source) => { calls.push('start') await new Promise<void>((r) => setTimeout(r, 10)) calls.push(`end:${source.trim()}`) })
const el = document.createElement('div') await renderMarkdownToElement('```mylang\nbody\n```', el, '/', null)
expect(calls).toEqual(['start', 'end:body']) })
it('catches sync errors from handler without breaking other processors', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const otherProcessor = vi.fn()
plugin.registerMarkdownCodeBlockProcessor('mylang', () => { throw new Error('handler explosion') }) registerPostProcessor('other-plugin', otherProcessor, 100)
const el = document.createElement('div') await renderMarkdownToElement('```mylang\nbody\n```\n\n# After', el, '/', null)
expect(consoleError).toHaveBeenCalledWith( expect.stringContaining('threw synchronously'), expect.any(Error) ) expect(otherProcessor).toHaveBeenCalled()
consoleError.mockRestore() })
it('catches async rejections from handler', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) plugin.registerMarkdownCodeBlockProcessor('mylang', async () => { throw new Error('async handler explosion') })
const el = document.createElement('div') await renderMarkdownToElement('```mylang\nbody\n```', el, '/', null)
expect(consoleError).toHaveBeenCalledWith( expect.stringContaining('rejected'), expect.any(Error) )
consoleError.mockRestore() })})
// -----------------------------------------------------------------------------// Plugin unload integration// -----------------------------------------------------------------------------describe('Plugin unload clears post-processors (integration)', () => { let app: App let plugin: Plugin
beforeEach(() => { app = createTestApp() const manifest: PluginManifest = { id: 'unload-test-plugin', name: 'Unload Test Plugin', version: '1.0.0', minAppVersion: '1.0.0', author: 'Test', description: 'Test plugin', } class TestPlugin extends Plugin { onload() { this.registerMarkdownPostProcessor(() => {}) this.registerMarkdownPostProcessor(() => {}) } } plugin = new TestPlugin(app, manifest) })
afterEach(() => { app.destroy() })
it('plugin.unload() clears its registered post-processors', () => { expect(getPostProcessorCount()).toBe(0) plugin.load() expect(getPostProcessorCount()).toBe(2) plugin.unload() expect(getPostProcessorCount()).toBe(0) })
it('plugin unload only clears its own processors, not others', () => { registerPostProcessor('other-plugin', () => {}, 0) expect(getPostProcessorCount()).toBe(1)
plugin.load() expect(getPostProcessorCount()).toBe(3)
plugin.unload() expect(getPostProcessorCount()).toBe(1) })
it('clears N processors at once when plugin registers many', () => { const manifest: PluginManifest = { id: 'many-processors-plugin', name: 'Many', version: '1.0.0', minAppVersion: '1.0.0', author: 'Test', description: '', } class ManyPlugin extends Plugin { onload() { for (let i = 0; i < 10; i++) { this.registerMarkdownPostProcessor(() => {}) } } } const many = new ManyPlugin(app, manifest) many.load() expect(getPostProcessorCount()).toBe(10) many.unload() expect(getPostProcessorCount()).toBe(0) })})