Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion scripts/convert.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 24 additions & 4 deletions scripts/insert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <thead> (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);
}
45 changes: 45 additions & 0 deletions scripts/obsidian.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions tests/convert.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ test('GFM tables render as table/thead/tbody', () => {
assert.match(html, /<tbody>[\s\S]*<td>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 <td>.
assert.match(html, /<p><em>note text\.<\/em><\/p>/);
assert.doesNotMatch(html, /<td><em>note text\.<\/em><\/td>/);
});

test('links render as a tags', () => {
const html = convert('[foundry](https://foundryvtt.com)', deps);
assert.match(html, /<a href="https:\/\/foundryvtt\.com">foundry<\/a>/);
Expand Down
75 changes: 75 additions & 0 deletions tests/insert.test.js
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><html><body></body></html>');
globalThis.window = jsdom.window;

const { insertHtml } = await import('../scripts/insert.js');

/**
* Build a fake ProseMirror + EditorView pair.
*
* Foundry treats any table containing <thead> (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 = '<table><thead><tr><th>a</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table><p>After.</p>';

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');
});
36 changes: 35 additions & 1 deletion tests/obsidian.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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'));
Expand Down