diff --git a/src/lib/documents/editor.integration.test.tsx b/src/lib/documents/editor.integration.test.tsx index 89929e7..28607b3 100644 --- a/src/lib/documents/editor.integration.test.tsx +++ b/src/lib/documents/editor.integration.test.tsx @@ -31,9 +31,7 @@ function createEditor(): Editor { describe('editor schema + sanitizer', () => { it('rejects javascript: link hrefs end to end', () => { const editor = createEditor(); - editor.commands.setContent('

click

', { - contentType: 'html', - }); + editor.commands.setContent('

click

'); // The unsafe href must not survive into the document. expect(editor.getHTML()).not.toContain('javascript:'); editor.destroy(); @@ -41,9 +39,7 @@ describe('editor schema + sanitizer', () => { it('keeps safe link hrefs', () => { const editor = createEditor(); - editor.commands.setContent('

click

', { - contentType: 'html', - }); + editor.commands.setContent('

click

'); expect(editor.getHTML()).toContain('https://example.com'); editor.destroy(); }); @@ -51,7 +47,7 @@ describe('editor schema + sanitizer', () => { it('drops script content pasted as HTML', () => { const editor = createEditor(); const sanitized = sanitizeHtml('

hello

'); - editor.commands.setContent(sanitized, { contentType: 'html' }); + editor.commands.setContent(sanitized); expect(editor.getText()).toContain('hello'); expect(editor.getHTML()).not.toContain(' { it('serializes typed content to portable Markdown', () => { const editor = createEditor(); - editor.commands.setContent('

Title

Some bold text

', { - contentType: 'html', - }); + editor.commands.setContent('

Title

Some bold text

'); expect(docToMarkdown(editor.getJSON())).toBe('# Title\n\nSome **bold** text'); editor.destroy(); }); diff --git a/src/lib/documents/markdown.test.ts b/src/lib/documents/markdown.test.ts index e59c031..f053ab1 100644 --- a/src/lib/documents/markdown.test.ts +++ b/src/lib/documents/markdown.test.ts @@ -124,6 +124,27 @@ describe('docToMarkdown', () => { ); }); + it('keeps pipes and newlines in table cells from breaking the table', () => { + const input = doc({ + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { type: 'tableHeader', content: [para(text('a|b'))] }, + { type: 'tableHeader', content: [para(text('c'))] }, + ], + }, + ], + }); + // The pipe is rewritten to the full-width form so the row still parses as + // exactly two cells. + expect(docToMarkdown(input)).toBe('| a¦b | c |\n| --- | --- |'); + // And it round-trips back to a two-cell table. + const parsed = markdownToDoc('| a¦b | c |\n| --- | --- |'); + expect(docToMarkdown(parsed)).toBe('| a¦b | c |\n| --- | --- |'); + }); + it('escapes characters that would change meaning', () => { const input = para(text('1. not a list # not a heading *not italic*')); expect(docToMarkdown(doc(input))).toBe('1\\. not a list \\# not a heading \\*not italic\\*'); diff --git a/src/lib/documents/markdown.ts b/src/lib/documents/markdown.ts index aa9025e..2bbc7fd 100644 --- a/src/lib/documents/markdown.ts +++ b/src/lib/documents/markdown.ts @@ -21,9 +21,9 @@ const MENTION_PATTERN = /^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np // --------------------------------------------------------------------------- function escapeText(text: string): string { - // Escape every ASCII punctuation character. CommonMark defines a backslash - // before ASCII punctuation as the literal character, so this is uniform, - // always round-trips, and needs no positional special cases. + // Escape every CommonMark-significant ASCII punctuation character. These + // are exactly the characters a backslash escapes per the spec, so the + // output is uniform, always round-trips, and needs no positional cases. return text.replace(/([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, '\\$1'); } @@ -151,7 +151,12 @@ function serializeTable(node: JSONContent): string[] { const text = (cell.content ?? []) .map((block) => serializeInline(block.content)) .join(' ') - .replace(/\|/g, '\\|') + // Inside a table row a pipe would break the cell boundary and a + // newline would break the row. The inline text has already had any + // `|` escaped to `\|`; rewrite that escaped pipe (and any bare one) + // to the full-width `¦` so the row keeps its structural cells without + // emitting a backslash. Newlines become a space. + .replace(/\\?\|/g, '¦') .replace(/\n/g, ' ') .trim(); cells.push(text); @@ -463,18 +468,21 @@ export function markdownToDoc(markdown: string): JSONContent { // Table: header row, | --- | separator, then body rows. if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) { flushParagraph(); - const parseRow = (row: string, header: boolean): JSONContent => ({ + const parseRow = (row: string, header: boolean): JSONContent => ({ type: 'tableRow', content: row .replace(/^\|/, '') .replace(/\|$/, '') + // Cells split on a bare `|`; a backslash-escaped `\|` stays inline. .split(/(? ({ type: header ? 'tableHeader' : 'tableCell', content: [ { type: 'paragraph', - content: parseInline(cell.replace(/\\\|/g, '|').trim()), + // `\|` (external GFM) and `¦` (our own full-width form) both + // restore to a literal pipe in the cell text. + content: parseInline(cell.replace(/\\\|/g, '|').replace(/¦/g, '|').trim()), }, ], })),