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 = ' 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'));
| |