diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c47c4c..d5da396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.3.] - 2026-06-05 + +### Fixed +- Pasting a table followed by a paragraph no longer throws a parse error. (#16) +- A note or sentence placed directly under a table (no blank line) is no longer swallowed as a stray table row when "Process Obsidian syntax" is on. (#16) + ## [0.3.0] - 2026-05-29 ### Added diff --git a/scripts/convert.js b/scripts/convert.js index 51f4be1..413d9a7 100644 --- a/scripts/convert.js +++ b/scripts/convert.js @@ -1,4 +1,4 @@ -import { extractFrontmatter, frontmatterToHtml, stripWikiLinks, transformCallouts, transformHighlights } from './obsidian.js'; +import { extractFrontmatter, frontmatterToHtml, isolateTables, stripWikiLinks, transformCallouts, transformHighlights } from './obsidian.js'; /** * Force rel="noopener noreferrer" on every target="_blank" link to block @@ -42,6 +42,7 @@ export function convert(md, deps, options = {}) { if (obsidian) { const { frontmatter, body } = extractFrontmatter(source); let processed = stripWikiLinks(body); + processed = isolateTables(processed); processed = transformHighlights(processed); processed = transformCallouts(processed, marked, labels); source = (frontmatter ? `${frontmatterToHtml(frontmatter, labels)}\n\n` : '') + processed; diff --git a/scripts/insert.js b/scripts/insert.js index bc2cd87..9148c1c 100644 --- a/scripts/insert.js +++ b/scripts/insert.js @@ -18,9 +18,29 @@ export function insertHtml(view, safeHtml) { .body; // ProseMirror.DOMParser is exposed by Foundry as part of the ProseMirror global. - const slice = ProseMirror.DOMParser - .fromSchema(view.state.schema) - .parseSlice(dom); + const parser = ProseMirror.DOMParser.fromSchema(view.state.schema); - view.dispatch(view.state.tr.replaceSelection(slice)); + // Preferred path: parse an open slice and replace the selection. Open ends let + // pasted inline content merge with surrounding text, which is the nicer result + // for most pastes. + // + // Foundry treats any table carrying a (which every GFM table from + // `marked` does) as an *isolating* "complex" table. When the parsed slice mixes + // such a table with a sibling block — e.g. a table followed by a paragraph — + // ProseMirror's fitter throws (TypeError: Cannot read properties of null) while + // trying to fit the open slice. See issue #16. Building the transform does not + // mutate state — only `dispatch` does — so catching here is safe: nothing has + // been applied when we reach the fallback. + let tr; + try { + tr = view.state.tr.replaceSelection(parser.parseSlice(dom)); + } catch { + // Fallback: parse a full document and insert its content as a fully-closed + // fragment via replaceWith. Closed content sidesteps the open-end fitting + // that crashes on isolating tables, at the cost of inline merging. + const { from, to } = view.state.selection; + tr = view.state.tr.replaceWith(from, to, parser.parse(dom).content); + } + + view.dispatch(tr); } diff --git a/scripts/obsidian.js b/scripts/obsidian.js index 725d8ab..1131fdf 100644 --- a/scripts/obsidian.js +++ b/scripts/obsidian.js @@ -167,6 +167,51 @@ export function stripWikiLinks(md) { }); } +// A GFM table delimiter row: pipe-separated cells of hyphens with optional +// alignment colons (e.g. `| --- | :-: |`, `|---|`, `:--`). +const TABLE_DELIMITER = /^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?\s*$/; + +/** + * Terminate a Markdown table that is glued directly to following prose. + * + * GFM (and `marked`) absorb a pipe-less line immediately after table rows as a + * trailing single-cell row, whereas Obsidian and Typora end the table there. + * To match the source app, insert a blank line between a table's last row and a + * following non-blank, pipe-less line so it parses as its own block. See issue #16. + * + * @param {string} md + * @returns {string} + */ +export function isolateTables(md) { + const lines = md.replace(/\r\n/g, '\n').split('\n'); + const out = []; + let i = 0; + while (i < lines.length) { + const header = lines[i]; + const delimiter = lines[i + 1]; + const isTableStart = header.includes('|') + && delimiter !== undefined + && delimiter.includes('-') + && TABLE_DELIMITER.test(delimiter); + + if (!isTableStart) { out.push(header); i++; continue; } + + out.push(header, delimiter); + i += 2; + // Body rows: contiguous non-blank lines that still carry a pipe. + while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) { + out.push(lines[i]); + i++; + } + // A non-blank, pipe-less line here would be swallowed as a single-cell row; + // a blank line keeps it a separate block. + if (i < lines.length && lines[i].trim() !== '' && !lines[i].includes('|')) { + out.push(''); + } + } + return out.join('\n'); +} + function renderCallout(block, head, marked, labels) { const rawType = head[1].toLowerCase(); const canon = canonicalType(rawType); diff --git a/tests/convert.test.js b/tests/convert.test.js index cddf3c3..a2955b1 100644 --- a/tests/convert.test.js +++ b/tests/convert.test.js @@ -76,6 +76,14 @@ test('GFM tables render as table/thead/tbody', () => { assert.match(html, /[\s\S]*1<\/td>[\s\S]*<\/tbody>/); }); +test('text glued directly after a table is not absorbed as a table row (issue #16)', () => { + const md = '| a | b |\n| - | - |\n| 1 | 2 |\n*note text.*'; + const html = convert(md, deps); + // The note must be its own paragraph, not a trailing single-cell . + assert.match(html, /

note text\.<\/em><\/p>/); + assert.doesNotMatch(html, /note text\.<\/em><\/td>/); +}); + test('links render as a tags', () => { const html = convert('[foundry](https://foundryvtt.com)', deps); assert.match(html, /foundry<\/a>/); diff --git a/tests/insert.test.js b/tests/insert.test.js new file mode 100644 index 0000000..ed0eb3d --- /dev/null +++ b/tests/insert.test.js @@ -0,0 +1,75 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// insert.js touches two runtime globals: `window` (for the standard DOMParser) +// and `ProseMirror` (Foundry's bundled ProseMirror). Both only inside the +// function body, so defining them before importing the module is enough. +const jsdom = new JSDOM(''); +globalThis.window = jsdom.window; + +const { insertHtml } = await import('../scripts/insert.js'); + +/** + * Build a fake ProseMirror + EditorView pair. + * + * Foundry treats any table containing (which every GFM table from + * `marked` carries) as an *isolating* "complex" table. When the parsed slice is + * `[isolating table, paragraph]` — e.g. a table followed by a paragraph — the + * ProseMirror fitter throws `TypeError: Cannot read properties of null` from + * `replaceSelection`. This was confirmed against the real prosemirror-tables + * fitter using Foundry's own schema (see issue #16). `sliceThrows` simulates + * that crash so the test stays free of the heavy ProseMirror dependency. + */ +function makeHarness({ sliceThrows }) { + const calls = { parseSlice: 0, parse: 0, replaceSelection: 0, replaceWith: 0, dispatch: 0 }; + const fragment = { __fragment: true }; + + const parser = { + parseSlice: () => { calls.parseSlice++; return { __slice: true }; }, + parse: () => { calls.parse++; return { content: fragment }; }, + }; + + globalThis.ProseMirror = { DOMParser: { fromSchema: () => parser } }; + + const tr = { + replaceSelection() { + calls.replaceSelection++; + if (sliceThrows) throw new TypeError("Cannot read properties of null (reading 'type')"); + return this; + }, + replaceWith(from, to, content) { + calls.replaceWith++; + assert.equal(content, fragment, 'fallback inserts the parsed document content'); + return this; + }, + }; + + const view = { + dom: jsdom.window.document.body, + state: { schema: {}, selection: { from: 3, to: 3 }, tr }, + dispatch() { calls.dispatch++; }, + }; + + return { view, calls }; +} + +const HTML = '
a
1

After.

'; + +test('happy path: a fittable slice is inserted via replaceSelection', () => { + const { view, calls } = makeHarness({ sliceThrows: false }); + insertHtml(view, HTML); + assert.equal(calls.replaceSelection, 1); + assert.equal(calls.replaceWith, 0, 'no fallback needed when the slice fits'); + assert.equal(calls.dispatch, 1); +}); + +test('regression (issue #16): a fitter crash falls back to replaceWith instead of throwing', () => { + const { view, calls } = makeHarness({ sliceThrows: true }); + // Before the fix this threw, bubbling up to dialog.js as "conversion failed". + assert.doesNotThrow(() => insertHtml(view, HTML)); + assert.equal(calls.replaceSelection, 1, 'still attempts the slice path first'); + assert.equal(calls.parse, 1, 'falls back to a full parse()'); + assert.equal(calls.replaceWith, 1, 'inserts fully-closed content on fallback'); + assert.equal(calls.dispatch, 1, 'the document is still updated'); +}); diff --git a/tests/obsidian.test.js b/tests/obsidian.test.js index ca819c8..b3ae429 100644 --- a/tests/obsidian.test.js +++ b/tests/obsidian.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { marked } from '../vendor/marked.esm.js'; -import { canonicalType, CALLOUT_TYPES, extractFrontmatter, frontmatterToHtml, stripWikiLinks, transformCallouts, transformHighlights } from '../scripts/obsidian.js'; +import { canonicalType, CALLOUT_TYPES, extractFrontmatter, frontmatterToHtml, isolateTables, stripWikiLinks, transformCallouts, transformHighlights } from '../scripts/obsidian.js'; test('canonicalType returns canonical types unchanged (case-insensitive)', () => { assert.equal(canonicalType('tip'), 'tip'); @@ -19,6 +19,40 @@ test('canonicalType returns null for unknown types', () => { assert.equal(canonicalType('frobnicate'), null); }); +// --- isolateTables: terminate a table that is glued to following text ------- +// GFM (and marked) absorb a pipe-less line directly after table rows as a +// single-cell row; Obsidian/Typora end the table there. issue #16. + +test('isolateTables inserts a blank line between a table and a glued text line', () => { + const md = '| a | b |\n| - | - |\n| 1 | 2 |\n*note text.*'; + assert.equal(isolateTables(md), '| a | b |\n| - | - |\n| 1 | 2 |\n\n*note text.*'); +}); + +test('isolateTables leaves a table already separated by a blank line unchanged', () => { + const md = '| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter.'; + assert.equal(isolateTables(md), md); +}); + +test('isolateTables keeps following pipe rows as part of the table', () => { + const md = '| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |'; + assert.equal(isolateTables(md), md); +}); + +test('isolateTables leaves prose without a table unchanged', () => { + const md = 'Just a paragraph.\nAnother line.'; + assert.equal(isolateTables(md), md); +}); + +test('isolateTables handles a table at end of input without a trailing line', () => { + const md = '| a | b |\n| - | - |\n| 1 | 2 |'; + assert.equal(isolateTables(md), md); +}); + +test('isolateTables does not treat a pipe-less header candidate as a table', () => { + const md = 'no pipes here\n- - -\nstill prose'; + assert.equal(isolateTables(md), md); +}); + test('CALLOUT_TYPES lists the 13 canonical types', () => { assert.equal(CALLOUT_TYPES.length, 13); assert.ok(CALLOUT_TYPES.includes('tip'));