Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051// src/editor/__tests__/table-grid.test.ts/** * Tests for the table-grid extension. * * Test groups: * 1. Alignment helpers (pure functions) * 2. Lezer node detection (decoration counts/shapes) * 3. Per-cell alignment classes * 4. Pipe hiding + separator replace * 5. Empty cells * 6. Edge cases (single col, header-only, malformed, long, header-with-fewer-cols, adjacent tables, mid-cell pipe) * 7. Grid stability across cursor movement (incl. click-to-position offset) * 8. Mode scoping (present in livePreview, absent without; decoration count > 0) * 9. Code/frontmatter exclusion (real frontmatter boundary case) * 10. Editing rebuild (alignment re-parse on doc change) */import { describe, it, expect, afterEach } from 'vitest'import { EditorState } from '@codemirror/state'import { EditorView, type DecorationSet } from '@codemirror/view'import { markdown, markdownLanguage } from '@codemirror/lang-markdown'import { GFM } from '@lezer/markdown'import { tableGrid, parseSeparatorAlignment, normalizeAlignment, type TableAlignment,} from '../extensions/table-grid'import { markdownFrontmatterField } from '../extensions/markdown-context'
function createView(doc: string, cursorPos?: number): EditorView { const pos = Math.min(cursorPos ?? doc.length, doc.length) return new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, tableGrid, ], selection: { anchor: pos }, }), })}
interface DecoEntry { from: number to: number classes: string[] isLine: boolean isReplace: boolean isWidget: boolean widget: WidgetWithAlign | null widgetAlign: TableAlignment | null style: string | null}
/** * Iterate the tableGrid plugin's decoration set and shape every entry. * Distinguishes line / mark / replace / widget by typed inspection of * the public Decoration.spec API. */function getDecos(view: EditorView): DecoEntry[] { const out: DecoEntry[] = [] const plugin = view.plugin(tableGrid) if (!plugin) return out const decoSet = plugin.decorations as DecorationSet const iter = decoSet.iter() while (iter.value) { const value = iter.value as unknown as { spec?: { class?: string attributes?: { style?: string } widget?: WidgetWithAlign } } const spec = value.spec ?? {} const cls = spec.class ?? '' const classes = cls ? cls.split(/\s+/).filter(Boolean) : [] const widget = spec.widget const isWidget = widget !== undefined && iter.from === iter.to const isLine = iter.from === iter.to && !isWidget // Decoration.replace: nonzero range with no class and no widget. const isReplace = !isWidget && !isLine && classes.length === 0 out.push({ from: iter.from, to: iter.to, classes, isLine, isReplace, isWidget, widget: isWidget && widget ? (widget as WidgetWithAlign) : null, widgetAlign: isWidget && widget && 'align' in widget ? widget.align : null, style: spec.attributes?.style ?? null, }) iter.next() } return out}
interface WidgetWithAlign { align: TableAlignment }
afterEach(() => { document.body.innerHTML = ''})
// --------------------------------------------------------------------------// Group 1: alignment helpers// --------------------------------------------------------------------------
describe('parseSeparatorAlignment', () => { it('plain dashes default to left', () => { expect(parseSeparatorAlignment('|---|---|')).toEqual(['left', 'left']) }) it('parses :--- as left, ---: as right, :---: as center', () => { expect(parseSeparatorAlignment('|:---|:---:|---:|')).toEqual([ 'left', 'center', 'right', ]) }) it('tolerates inner whitespace', () => { expect(parseSeparatorAlignment('| :--- | :---: | ---: |')).toEqual([ 'left', 'center', 'right', ]) }) it('handles single-column separator', () => { expect(parseSeparatorAlignment('|---:|')).toEqual(['right']) }) it('returns empty array for malformed (no pipes) input', () => { expect(parseSeparatorAlignment('---')).toEqual([]) })})
describe('countHeaderColumns + normalizeAlignment', () => { it('normalizeAlignment pads short array with left', () => { expect(normalizeAlignment(['right'], 3)).toEqual(['right', 'left', 'left']) }) it('normalizeAlignment truncates long array', () => { expect(normalizeAlignment(['left', 'center', 'right'], 2)).toEqual([ 'left', 'center', ]) })})
// --------------------------------------------------------------------------// Group 2: Lezer node detection// --------------------------------------------------------------------------
describe('tableGrid — node detection', () => { it('basic 2-col 1-body table → 3 row lines, 1 separator-anchor mark, 4 cell marks, 6 pipe-hides (#269)', () => { const doc = '| H1 | H2 |\n|---|---|\n| a | b |\n' const view = createView(doc) const decos = getDecos(view) const lineDecos = decos.filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) // Per #269, the separator's `|---|---|` is wrapped by a // `Decoration.mark` carrying `cm-table-separator-anchor` rather // than a `Decoration.replace` — the source text stays in the DOM // as editable text so `caretRangeFromPoint` can find it. const replaces = decos.filter(d => d.isReplace) expect(replaces).toHaveLength(0) const sepAnchorMarks = decos.filter(d => d.classes.includes('cm-table-separator-anchor')) expect(sepAnchorMarks).toHaveLength(1) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(4) const pipeHides = decos.filter(d => d.classes.includes('cm-table-pipe-hidden')) // 3 pipes/row × 2 non-separator rows = 6. expect(pipeHides).toHaveLength(6) view.destroy() })
it('header line carries cm-table-header-row + cm-table-row-first', () => { const doc = '| H1 | H2 |\n|---|---|\n| a | b |\n' const view = createView(doc) const headerLine = getDecos(view).find(d => d.isLine && d.classes.includes('cm-table-header-row')) expect(headerLine).toBeDefined() expect(headerLine!.from).toBe(0) expect(headerLine!.classes).toContain('cm-table-row-first') view.destroy() })
it('final body row carries cm-table-row-last', () => { const doc = '| H |\n|---|\n| a |\n| b |\n' const view = createView(doc) const lasts = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row-last')) expect(lasts).toHaveLength(1) view.destroy() })
it('encodes column count via --cm-table-cols on every row line', () => { const doc = '| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n' const view = createView(doc) const styles = getDecos(view).filter(d => d.style).map(d => d.style!) expect(styles.length).toBeGreaterThan(0) expect(styles.every(s => s.includes('--cm-table-cols: 3'))).toBe(true) view.destroy() })})
// --------------------------------------------------------------------------// Group 3: per-cell alignment// --------------------------------------------------------------------------
describe('tableGrid — per-cell alignment', () => { function getCellAligns(view: EditorView): string[] { return getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) .map(d => d.classes.find(c => c.startsWith('cm-table-cell-align-')) ?? '') }
it('all left when separator is plain dashes', () => { const doc = '| A | B | C |\n|---|---|---|\n| 1 | 2 | 3 |\n' const view = createView(doc) const aligns = getCellAligns(view) expect(aligns).toHaveLength(6) expect(aligns.every(c => c === 'cm-table-cell-align-left')).toBe(true) view.destroy() })
it('center applies to that column in every row', () => { const doc = '| A | B |\n|---|:---:|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-left', 'cm-table-cell-align-center', ]) view.destroy() })
it('right applies to that column in every row', () => { const doc = '| A | B |\n|---|---:|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-right', ]) view.destroy() })
it('mixed alignment across all three flavors', () => { const doc = '| A | B | C |\n|:---|:---:|---:|\n| 1 | 2 | 3 |\n' const view = createView(doc) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-center', 'cm-table-cell-align-right', ]) view.destroy() })})
// --------------------------------------------------------------------------// Group 4: pipe hiding + separator replace// --------------------------------------------------------------------------
describe('tableGrid — chrome hiding', () => { it('every intra-row pipe is exactly 1 char wide', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const pipes = getDecos(view).filter(d => d.classes.includes('cm-table-pipe-hidden')) expect(pipes).toHaveLength(6) pipes.forEach(p => { expect(p.to - p.from).toBe(1) expect(view.state.doc.sliceString(p.from, p.to)).toBe('|') }) view.destroy() })
it('separator anchor mark covers exactly the separator row content (#269)', () => { // Per #269 the separator's `|---|---|` is wrapped by a // `Decoration.mark` carrying `cm-table-separator-anchor` (was // previously a `Decoration.replace`). Keeping the source text in // the DOM is what makes the line caret-targetable. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const replaces = getDecos(view).filter(d => d.isReplace) expect(replaces).toHaveLength(0) const sepMarks = getDecos(view).filter(d => d.classes.includes('cm-table-separator-anchor')) expect(sepMarks).toHaveLength(1) expect(view.state.doc.sliceString(sepMarks[0].from, sepMarks[0].to)) .toBe('|---|---|') view.destroy() })
it('separator pipes are NOT individually pipe-hidden (they are inside the separator-anchor range)', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const sepFrom = '| A | B |\n'.length const sepTo = sepFrom + '|---|---|'.length const pipes = getDecos(view).filter(d => d.classes.includes('cm-table-pipe-hidden')) expect(pipes.every(p => p.to <= sepFrom || p.from >= sepTo)).toBe(true) view.destroy() })})
// --------------------------------------------------------------------------// Group 5: empty cells// --------------------------------------------------------------------------
describe('tableGrid — empty cells', () => { it('empty leading cell with whitespace gap → cell-empty wrapping mark, NO widget', () => { // For "| | b |", the empty leading cell has a 3-char whitespace gap. // The wrapping cm-table-cell-empty mark provides the flex item; CSS // ::before adds nbsp for line height. No widget needed in this case. const doc = '| A | B |\n|---|---|\n| | b |\n' const view = createView(doc) const decos = getDecos(view) const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(0) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) // Header has 2 inter-pipe cell marks; body has 1 empty-wrapping mark // (covers ' ') + 1 normal cell mark (covers ' b '). Total 4. expect(cellMarks).toHaveLength(4) const emptyMarks = cellMarks.filter(d => d.classes.includes('cm-table-cell-empty')) expect(emptyMarks).toHaveLength(1) expect(emptyMarks[0].classes).toContain('cm-table-cell-align-left') view.destroy() })
it('empty cell wrapping marks carry the column alignment', () => { const doc = '| A | B |\n|---|---:|\n| | |\n' const view = createView(doc) const decos = getDecos(view) // Both cells have a whitespace gap → 2 wrapping marks, no widgets. const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(0) const emptyMarks = decos.filter(d => d.classes.includes('cm-table-cell') && d.classes.includes('cm-table-cell-empty'), ) expect(emptyMarks).toHaveLength(2) expect(emptyMarks[0].classes).toContain('cm-table-cell-align-left') expect(emptyMarks[1].classes).toContain('cm-table-cell-align-right') view.destroy() })
it('no-gap empty cell (consecutive pipes with no whitespace) emits exactly one EmptyCellWidget', () => { // For "|| b |" the leading cell has no inter-pipe content at all // (innerFrom === innerTo). A zero-length mark can't be emitted, so the // widget anchor IS the flex item. // // Empirically validated lezer AST for the body row "|| b |": // children = [TableDelim@20-21, TableDelim@21-22, TableCell@23-24, // TableDelim@25-26] // The walker produces: // - 1 EmptyCellWidget for the empty leading cell (gap between the // two consecutive TableDelimiters at offsets 20-21 and 21-22). // - 1 cm-table-cell mark for the second cell (covers ' b ', the // full inter-pipe range between TableDelim@21-22 and // TableDelim@25-26). // Plus the 2 cm-table-cell marks for the header row "| A | B |". // Plus the cm-table-cell-empty class is NOT applied to the widget; // it's a Decoration.widget, not a mark with a class. // (Review #22 strengthened this from "<= 1 widget" to exact-equality.) const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) const decos = getDecos(view) const widgets = decos.filter(d => d.isWidget) expect(widgets).toHaveLength(1) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) // 2 from header + 1 from the non-empty body cell = 3. expect(cellMarks).toHaveLength(3) view.destroy() })
it('EmptyCellWidget eq compares alignment across a rebuild', () => { const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) // A document change forces a decoration rebuild, so CM6 compares the // old and new EmptyCellWidget via eq (same alignment → DOM reused). view.dispatch({ changes: { from: 2, insert: 'x' } }) const anchor = view.dom.querySelector<HTMLElement>('.cm-table-cell-empty-anchor') expect(anchor).toBeTruthy() view.destroy() })
it('EmptyCellWidget ignoreEvent returns false (CM6 must handle events on the anchor)', () => { // The widget is not exported, so reach it through the decoration set — // the same pattern wikilink-widget.test.ts uses for its chip widget. const doc = '| A | B |\n|---|---|\n|| b |\n' const view = createView(doc) const widget = getDecos(view).find(d => d.isWidget)?.widget expect(widget).toBeDefined() // ignoreEvent() === false means the editor's own event handling is // allowed to run for events landing on the empty-cell anchor (the // anchor is caret-targetable text, not chrome). expect((widget as WidgetWithAlign & { ignoreEvent(): boolean }).ignoreEvent()).toBe(false) view.destroy() })
it('no-gap empty cell in the HEADER row emits a widget via the header push path', () => { // The header row "|| B |" has a zero-length leading cell, so the // header's pushWidget callback (not just the body rows') must fire. const doc = '|| B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const widgets = getDecos(view).filter(d => d.isWidget) expect(widgets).toHaveLength(1) expect(widgets[0].widgetAlign).toBe('left') view.destroy() })
it('falls back to the full document when visibleRanges is empty', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) Object.defineProperty(view, 'visibleRanges', { value: [], configurable: true, }) view.dispatch({ changes: { from: 2, insert: 'x' } }) // The fallback range covers the whole doc, so the table still renders. const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) view.destroy() })
it('skips empty visible ranges without double-processing the table', () => { // A zero-width visible range at offset 5 falls INSIDE the table, and // lezer's iterate({from: 5, to: 5}) still enters the Table node — so // without the `rangeFrom >= rangeTo` guard the table would be // decorated twice (once per overlapping range). With the guard, the // empty range contributes nothing. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) Object.defineProperty(view, 'visibleRanges', { value: [{ from: 5, to: 5 }], configurable: true, }) view.dispatch({ changes: { from: 2, insert: 'x' } }) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(0) view.destroy() })})
// --------------------------------------------------------------------------// Group 6: edge cases// --------------------------------------------------------------------------
describe('tableGrid — edge cases', () => { it('single-column table renders with --cm-table-cols: 1 on every row', () => { const doc = '| H |\n|---|\n| a |\n' const view = createView(doc) const styles = getDecos(view).filter(d => d.style).map(d => d.style!) expect(styles.length).toBeGreaterThan(0) expect(styles.every(s => s.includes('--cm-table-cols: 1'))).toBe(true) view.destroy() })
it('header-only table: header line carries BOTH row-first AND row-last', () => { const doc = '| H1 | H2 |\n|---|---|\n' const view = createView(doc) const decos = getDecos(view) const lineDecos = decos.filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(2) // header + separator const headerLine = decos.find(d => d.isLine && d.classes.includes('cm-table-header-row')) expect(headerLine!.classes).toContain('cm-table-row-first') expect(headerLine!.classes).toContain('cm-table-row-last') view.destroy() })
it('long table: 12 body rows produce 12 body-row line decorations', () => { const rows = Array.from({ length: 12 }, (_, i) => `| r${i} | v${i} |`).join('\n') const doc = `| H1 | H2 |\n|---|---|\n${rows}\n` const view = createView(doc) const bodyLines = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')) expect(bodyLines).toHaveLength(12) view.destroy() })
it('header has fewer columns than separator: lezer-markdown rejects the table → no decorations', () => { // 2-col header, 3-col separator. Lezer GFM is strict: when header // column count != separator column count, the entire construct is // NOT parsed as a Table (it stays a Paragraph). The plan's original // expectation was that the renderer would truncate alignment to // header count — but that path only runs if a Table is parsed, and // here it isn't. The test asserts the actual observable behavior: // zero cell-mark decorations because tableGrid never sees a Table. const doc = '| A | B |\n|---|:--:|---:|\n| 1 | 2 |\n' const view = createView(doc) const cellMarks = getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(0) view.destroy() })
it('header has fewer columns than separator declares: lezer rejects the table → no row decorations', () => { // Same scenario with a different separator width. Same Lezer // behavior: not parsed as Table. Confirmed by inspecting the syntax // tree: only Document/Paragraph nodes, no Table. const doc = '| A | B |\n|---|:---:|---:|\n| 1 | 2 |\n' const view = createView(doc) const lineDecos = getDecos(view) .filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(0) view.destroy() })
it('two adjacent tables: row-last on first table does NOT appear on second table header', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' + '\n' + '| C | D |\n|---|---|\n| 3 | 4 |\n' const view = createView(doc) const decos = getDecos(view) const firsts = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-first')) const lasts = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-last')) // One first per table, one last per table = 2 each. expect(firsts).toHaveLength(2) expect(lasts).toHaveLength(2) // Each first sits at the header line of its respective table; each last // at the body row line. No row carries both first AND last (the tables // each have body rows, so the header is not also the last). const both = decos.filter(d => d.isLine && d.classes.includes('cm-table-row-first') && d.classes.includes('cm-table-row-last'), ) expect(both).toHaveLength(0) view.destroy() })
it('typing `|` mid-cell: doc grows; plugin rebuilds decorations without crashing', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) // Insert `|` inside the body cell containing "1" (offset right after "1"). const insertAt = doc.indexOf('| 1') + 3 expect(() => { view.dispatch({ changes: { from: insertAt, insert: '|' } }) }).not.toThrow() // Plugin should still expose decorations. const plugin = view.plugin(tableGrid) expect(plugin).not.toBeNull() // Stronger assertion (review #7): after the edit, the doc has 3 pipes // in the body row instead of 2. lezer-markdown's GFM parser may // either re-parse the row at a new column count (and tableGrid // re-emits with the new structure) or reject the row. Either way, // the decoration set must still be a valid RangeSet (iterable, no // throw on access) and the cell-mark wrapper invariant must hold: // for any cells that ARE emitted, each must cover its full // inter-pipe range (not split by the newly-inserted pipe). const decos = getDecos(view) // Smoke: iterating the decoration set must not throw and must // produce a non-error result. expect(Array.isArray(decos)).toBe(true) // For any cell marks that ARE emitted, their text must start and // end with whitespace (the inter-pipe-range invariant from D5). const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) cellMarks.forEach(m => { const text = view.state.doc.sliceString(m.from, m.to) // Cell marks always span at least the surrounding whitespace; any // mark that is just '|' or contains '|' as boundary chars would // indicate the wrapper got split. expect(text.includes('|')).toBe(false) }) view.destroy() })})
// --------------------------------------------------------------------------// Group 7: row-layout stability across cursor movement// --------------------------------------------------------------------------
describe('tableGrid — row-layout stability', () => { function decoCount(view: EditorView): number { const plugin = view.plugin(tableGrid) if (!plugin) return 0 let n = 0 const iter = (plugin.decorations as DecorationSet).iter() while (iter.value) { n++; iter.next() } return n }
/** * Serialize the decoration set to a stable string of * `from:to:classes|from:to:classes|...` for content-equality comparison * (review #8 strengthened this from a count-only comparison). */ function decoSignature(view: EditorView): string { return getDecos(view) .map(d => `${d.from}:${d.to}:${d.classes.sort().join(',')}`) .sort() .join('|') }
it('decoration set is identical (positions + classes) regardless of initial cursor position', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n' const v1 = createView(doc, 0) const v2 = createView(doc, 12) const v3 = createView(doc, doc.length) const sigs = [decoSignature(v1), decoSignature(v2), decoSignature(v3)] // Stronger than count-equality: same positions + classes too. expect(sigs[0]).toBe(sigs[1]) expect(sigs[1]).toBe(sigs[2]) expect(sigs[0].length).toBeGreaterThan(0) // And count, for legacy parity with the original assertion. expect(decoCount(v1)).toBe(decoCount(v2)) expect(decoCount(v2)).toBe(decoCount(v3)) v1.destroy(); v2.destroy(); v3.destroy() })
it('moving the cursor inside the table does NOT change the decoration content', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc, doc.length) const before = decoSignature(view) view.dispatch({ selection: { anchor: 3 } }) expect(decoSignature(view)).toBe(before) view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 2 } }) expect(decoSignature(view)).toBe(before) view.dispatch({ selection: { anchor: view.state.doc.line(2).from + 1 } }) expect(decoSignature(view)).toBe(before) view.destroy() })
it('typing a character inside a body cell inserts at the cursor offset', () => { // Asserts cursor geometry: typing `X` at offset N produces text with // `X` at offset N. Mirrors the spike R4 assertion but on the production // extension. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const cursor = doc.indexOf('| 1') + 2 // immediately before "1" const view = createView(doc, cursor) view.dispatch({ changes: { from: cursor, insert: 'X' } }) expect(view.state.doc.toString()).toContain('| X1 |') view.destroy() })
it('cell mark ranges cover surrounding whitespace (no anonymous flex items)', () => { // Production-level regression check that mirrors Spike R5: cell marks // span [prevDelim.to, nextDelim.from), so for a row "| a | b |" each // cell mark covers " a " and " b " (3 chars each), not just "a" / "b" // (1 char each). This is the explicit fix for the bare-whitespace // anonymous-flex-item bug under display: flex (and would equally apply // under display: grid). If a future refactor reverts to TableCell // ranges, this assertion fails. const doc = '| a | b |\n|---|---|\n' const view = createView(doc) const decos = getDecos(view) const cellMarks = decos.filter(d => d.classes.includes('cm-table-cell')) expect(cellMarks).toHaveLength(2) // Each cell mark spans 3 chars: ' a ' and ' b '. cellMarks.forEach(m => { expect(m.to - m.from).toBe(3) const text = view.state.doc.sliceString(m.from, m.to) expect(text.startsWith(' ')).toBe(true) expect(text.endsWith(' ')).toBe(true) }) view.destroy() })})
// --------------------------------------------------------------------------// Group 8: mode scoping// --------------------------------------------------------------------------
describe('tableGrid — mode scoping', () => { const SAMPLE = '| A | B |\n|---|---|\n| 1 | 2 |\n'
function build(extensions: unknown[]): EditorView { return new EditorView({ state: EditorState.create({ doc: SAMPLE, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, ...(extensions as never[]), ], selection: { anchor: SAMPLE.length }, }), }) }
it('tableGrid is present AND emits decorations when livePreview() is mounted', async () => { const { livePreview } = await import('../extensions/live-preview') const view = build([livePreview({})]) const plugin = view.plugin(tableGrid) expect(plugin).not.toBeNull() // Strong assertion (per adversary feedback #8): not just presence — // the plugin must actually be producing decorations. A future // regression that loads the plugin but produces zero decorations // (e.g. silent error swallowed in buildDecorations) fails here. let count = 0 const iter = (plugin!.decorations as DecorationSet).iter() while (iter.value) { count++; iter.next() } expect(count).toBeGreaterThan(0) view.destroy() })
it('tableGrid is ABSENT when livePreview() is NOT mounted (source mode shape)', () => { const view = build([]) expect(view.plugin(tableGrid)).toBeNull() view.destroy() })})
// --------------------------------------------------------------------------// Group 9: code/frontmatter exclusion// --------------------------------------------------------------------------
describe('tableGrid — exclusions', () => { it('does not produce row decorations for table syntax inside fenced code', () => { const doc = '```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n\n' + '| X | Y |\n|---|---|\n| a | b |\n' const view = createView(doc) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) // Only the real outside-fence table: header + separator + 1 body = 3. expect(lineDecos).toHaveLength(3) view.destroy() })
it('does not produce decorations on a doc with only a code-fenced table', () => { const doc = '```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n' const view = createView(doc) expect(getDecos(view)).toHaveLength(0) view.destroy() })
it('frontmatter belt-and-suspenders: real boundary case', () => { // The adversary correctly noted that with a normal `---\n…\n---\n` block // Lezer doesn't parse a Table inside the YAML, so a naive "table inside // frontmatter" test is a tautology. This test exercises the boundary // case the exclusion exists for: a MALFORMED frontmatter (no closing // `---`) where Lezer might otherwise parse a Table inside what we // consider frontmatter range. parseFrontmatter requires a closing fence // so it returns null in this case; the test asserts the table OUTSIDE // the would-be frontmatter still renders normally — a regression check // that isRangeInFrontmatter(null) doesn't accidentally return true. const doc = '---\nkey: value\n\n| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const lineDecos = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(lineDecos).toHaveLength(3) view.destroy() })
it('code-context belt-and-suspenders: isRangeInCodeContext detects code regions for the table walker', async () => { // Note: lezer-markdown does NOT currently emit a Table inside FencedCode // (the first Group-9 test verifies this end-to-end: a code-fenced table // produces zero decorations). That test is a tautology w.r.t. the // production isRangeInCodeContext check because the Table never enters // the walker. This test documents the defense-in-depth: the helper used // by buildDecorations is real and correctly classifies code ranges. // If a future lezer regression starts emitting Table inside FencedCode, // the production code path would catch it via this same helper. const { isRangeInCodeContext } = await import('../extensions/markdown-context') const doc = 'plain text\n\n```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n' const view = createView(doc) // The code-fenced table content lives inside the fenced range. Pick a // position inside the inner table-syntax line. const innerFrom = doc.indexOf('| A | B |') const innerTo = doc.indexOf('```\n', innerFrom) expect(innerFrom).toBeGreaterThan(0) expect(isRangeInCodeContext(view.state, innerFrom, innerTo)).toBe(true) // Sanity: a position inside the leading "plain text\n" prefix is NOT // in code context. expect(isRangeInCodeContext(view.state, 0, 5)).toBe(false) view.destroy() })})
// --------------------------------------------------------------------------// Group 10: rebuild on doc change// --------------------------------------------------------------------------
describe('tableGrid — rebuild on doc change', () => { function getCellAligns(view: EditorView): string[] { return getDecos(view) .filter(d => d.classes.includes('cm-table-cell')) .map(d => d.classes.find(c => c.startsWith('cm-table-cell-align-')) ?? '') }
it('changing the separator row re-parses alignment', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) expect(getCellAligns(view).every(c => c === 'cm-table-cell-align-left')) .toBe(true) const sepFrom = '| A | B |\n'.length view.dispatch({ changes: { from: sepFrom, to: sepFrom + '|---|---|'.length, insert: '|---|---:|', }, }) expect(getCellAligns(view)).toEqual([ 'cm-table-cell-align-left', 'cm-table-cell-align-right', 'cm-table-cell-align-left', 'cm-table-cell-align-right', ]) view.destroy() })
it('inserting a body row produces a new body-row decoration', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) const before = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')).length expect(before).toBe(1) view.dispatch({ changes: { from: view.state.doc.length, insert: '| 3 | 4 |\n' }, }) const after = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-body-row')).length expect(after).toBe(2) view.destroy() })
it('removing the separator row removes all table decorations', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const view = createView(doc) expect(getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')).length).toBeGreaterThan(0) const sepFrom = '| A | B |\n'.length view.dispatch({ changes: { from: sepFrom, to: sepFrom + '|---|---|\n'.length, insert: '', }, }) const after = getDecos(view).filter(d => d.isLine && d.classes.includes('cm-table-row')) expect(after).toHaveLength(0) view.destroy() })})
// --------------------------------------------------------------------------// Group 11: full-stack integration — Fallback A (extended) under livePreview//// Issue #13 from the round-1 review: Fallback A initially patched only// inline-format and code-marker decorations. The audit added cell-aware// suppression to wikilink + image-embed extensions (regex-based) and// defensive guards to heading + checkbox (syntax-tree-based, where Lezer// does not currently emit the relevant nodes inside TableCell but a// future parser change could). These tests mount the FULL livePreview()// stack and verify that wikilinks / images / inline marks inside cells// do not split the .cm-table-cell wrapper.// --------------------------------------------------------------------------
describe('tableGrid — full-stack Fallback A integration', () => { it('wikilink + table cell: suppression + DOM cell-wrapper integrity (livePreview with wikilink)', async () => { // ARCHITECTURAL PROPERTY (Spike R2 / Fallback A): // When a Decoration.replace from another extension overlaps a // cm-table-cell mark range, CM6 splits the wrapper into N sibling // spans, breaking the one-cell-equals-one-flex-item invariant. // This test verifies BOTH layers of the architecture: // (1) suppression — wikilink emits zero decorations inside cells. // (2) DOM integrity — exactly N rendered .cm-table-cell elements, // one per logical cell, regardless of whether other plugins // were tempted to overlap. // // Layer (2) is the load-bearing assertion. RangeSetBuilder always // produces one cm-table-cell SPEC per logical cell — that's not the // property at risk. The property is whether the rendered DOM still // shows exactly N direct .cm-table-cell elements after CM6's // mark-merging pass. If a future regression removes the wikilink // guard (or a new extension is added without a guard), the spec // count stays at N (unchanged) but the DOM count drops below N // (wrapper got split into siblings). DOM-level assertion catches that. const { livePreview } = await import('../extensions/live-preview') const doc = '| [[target]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ wikilink: { resolveLink: () => true } }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() // Layer 1: the wikilink chip is not rendered (suppression works). const chips = view.dom.querySelectorAll('.cm-wikilink-chip') expect(chips).toHaveLength(0) // Layer 2: rendered DOM still has exactly N cell wrappers. // 2-col table × 2 non-separator rows = 4 cells. const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })
it('image embed + table cell: suppression + DOM cell-wrapper integrity (livePreview with imageEmbed)', async () => { // See the wikilink test above for the architectural rationale. // Layer 1 (suppression): no .cm-image-embed[data-target] inside cell. // Layer 2 (DOM integrity): exactly 4 rendered .cm-table-cell elements. const { livePreview } = await import('../extensions/live-preview') const doc = '| ![[a.png]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ imageEmbed: { resolver: { resolveImageUrl: async () => 'blob:test' } }, }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const wrappers = view.dom.querySelectorAll('.cm-image-embed[data-target]') expect(wrappers).toHaveLength(0) const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })
it('bold inside table cell: suppression + DOM cell-wrapper integrity (livePreview default config)', async () => { // Smoke test under the basic livePreview stack (no wikilink/image // config). Inline backticks inside table cells interact with // lezer-markdown's GFM table parser in ways that may suppress the // table parse entirely; coverage for Fallback A on `code` is in the // dedicated code-marker-decoration suppression tests. // // Layer 1 (decoration spec count): tableGrid emits 4 cm-table-cell // marks (1 per logical cell × 2 rows × 2 cols). // Layer 2 (DOM integrity): the rendered DOM also has exactly 4 // .cm-table-cell elements — i.e. the bold mark from // inlineFormatDecoration is suppressed (Fallback A) and therefore // does NOT split the wrapper into siblings. const { livePreview } = await import('../extensions/live-preview') const doc = '| **bold** | normal |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({}), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() // Layer 1: spec count = 4 (verified via the tableGrid plugin). const tg = view.plugin(tableGrid) expect(tg).not.toBeNull() let cellSpecs = 0 const iter = (tg!.decorations as DecorationSet).iter() while (iter.value) { const cls = ((iter.value as unknown as { spec?: { class?: string } }).spec?.class ?? '') if (cls.includes('cm-table-cell ') || cls.endsWith('cm-table-cell') || /cm-table-cell\b/.test(cls)) { cellSpecs++ } iter.next() } expect(cellSpecs).toBe(4) // Layer 2: DOM has exactly 4 cm-table-cell elements. const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })
// -------------------------------------------------------------------- // Round-2 review #1: explicit DOM-level integrity tests // // The tests above verify decoration suppression (correctly necessary, // but if a NEW decoration-emitting extension is added to livePreview() // without a TableCell guard, only a real DOM-level test would catch // the cell-wrapper split). These three tests are dedicated to the // DOM property — N rendered .cm-table-cell elements, one per logical // cell, under the full livePreview() stack. // // We use livePreview() with explicit configs to exercise the // production wiring path (wikilink, image-embed) as a future-proof // regression check against any new extension being added without a // TableCell guard. // --------------------------------------------------------------------
it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table under default livePreview', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| **bold** | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({}), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })
it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table with wikilinkExtension loaded', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| [[link]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ wikilink: { resolveLink: () => true } }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })
it('DOM integrity: 4 cm-table-cell elements rendered for a 2x2 table with imageEmbedWidget loaded', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '| ![[img.png]] | b |\n|---|---|\n| 1 | 2 |\n' const view = new EditorView({ state: EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, livePreview({ imageEmbed: { resolver: { resolveImageUrl: async () => 'blob:test' } }, }), ], selection: { anchor: doc.length }, }), parent: document.body, }) view.requestMeasure() const cellEls = view.dom.querySelectorAll('.cm-table-cell') expect(cellEls).toHaveLength(4) view.destroy() })})