dorfteich/packages/shared/src/editor-schema/markdown.test.ts
Claude Fable 5 3bf9363c34
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m18s
CI / Build container images (pull_request) Successful in 4m41s
CI / Auth e2e pack (pull_request) Successful in 10m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Successful in 33s
CI / Lint, typecheck, test (push) Has been cancelled
Merge and split table cells (#337)
prosemirror-tables already ships mergeCells/splitCell and the schema
(tableNodes) already carries colspan/rowspan -- only the controls were
missing. Adds the two commands, toolbar buttons whose enabled state
follows the selection (merge needs a multi-cell selection, split a
merged cell), and de+en labels.

Both render paths now carry the spans: docToHtml emits colspan/rowspan
(read mode, exports via the HTML path), and the markdown serializer pads
a colspan with empty cells so every row keeps the table's column count
-- rowspan stays lossy there, GFM cannot express it.

e2e drives merge and split through the toolbar; the cell selection is
made per Shift+Click because a keypress in the same tick as the
preceding click races the editor's post-click rendering (keyboard cell
selection itself works, verified interactively with a settled editor).

Closes #337

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
2026-08-15 21:32:14 +02:00

179 lines
6.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { docToMarkdown, markdownToDoc } from './markdown';
/**
* Markdown → doc → Markdown must be stable for the node/mark set the
* schema supports (issue #24 acceptance criterion). Each fixture is
* expected to serialize back to itself byte-for-byte.
*/
const FIXTURES: Record<string, string> = {
headings: '# Level one\n\n## Level two\n\n### Level three\n\n#### Level four',
paragraphInlineMarks:
'Plain, **bold**, *italic*, ~~strikethrough~~, `code`, and [a link](https://example.org/page).',
bulletList: '- First item\n- Second item\n- Third item',
orderedList: '1. First\n2. Second\n3. Third',
taskList: '- [ ] Buy milk\n- [x] Walk the dog',
blockquote: '> Quoted paragraph one.\n>\n> Quoted paragraph two.',
codeBlock: '```\nconst x = 1;\nconsole.log(x);\n```',
horizontalRule: 'Above\n\n---\n\nBelow',
table: '| Name | Role |\n| --- | --- |\n| Uma | Editor |\n| Otto | Reader |',
image: '![A pond](file-123)',
nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item',
wikilinkBare: 'See [[architecture]] for details.',
wikilinkWithDisplay: 'See [[architecture|the design docs]] for details.',
};
describe('markdown round-trip (issue #24)', () => {
for (const [name, markdown] of Object.entries(FIXTURES)) {
it(`stabilizes: ${name}`, () => {
const doc = markdownToDoc(markdown);
expect(docToMarkdown(doc)).toBe(markdown);
});
}
it('produces the expected document shape for a mixed fixture', () => {
const doc = markdownToDoc(FIXTURES.paragraphInlineMarks ?? '');
expect(doc.toJSON()).toMatchObject({
type: 'doc',
content: [{ type: 'paragraph' }],
});
});
it('clamps markdown heading levels beyond 4 down to level 4', () => {
const doc = markdownToDoc('###### Deep heading');
const heading = doc.firstChild;
expect(heading?.type.name).toBe('heading');
expect(heading?.attrs.level).toBe(4);
});
it('does not turn a mixed checkbox/plain list into a task list', () => {
const doc = markdownToDoc('- [ ] Task\n- Plain item');
expect(doc.firstChild?.type.name).toBe('bullet_list');
});
it('parses a wikilink into a node with slug and optional display text (issue #46)', () => {
const withDisplay = markdownToDoc('[[my-page|My Page]]').firstChild?.firstChild;
expect(withDisplay?.type.name).toBe('wikilink');
expect(withDisplay?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: 'My Page' });
const bare = markdownToDoc('[[my-page]]').firstChild?.firstChild;
expect(bare?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: null });
});
it('leaves malformed brackets as plain text', () => {
// A single bracket pair is an ordinary (broken) markdown link, not a wikilink.
const doc = markdownToDoc('[[unclosed and [not a link]');
expect(doc.textContent).toContain('[[unclosed');
let hasWikilink = false;
doc.descendants((n) => {
if (n.type.name === 'wikilink') hasWikilink = true;
});
expect(hasWikilink).toBe(false);
});
it('round-trips a section-style container as a fenced div (issue #75)', () => {
const md = [
'::: {data-section-style="section-styles-basic/callout"}',
'Inside the callout.',
'',
'Second paragraph.',
':::',
].join('\n');
const doc = markdownToDoc(md);
const section = doc.firstChild;
expect(section?.type.name).toBe('section');
expect(section?.attrs).toMatchObject({
pluginId: 'section-styles-basic',
styleId: 'callout',
});
expect(section?.childCount).toBe(2); // two paragraphs preserved
// Serialization is idempotent: a second pass reproduces the first exactly.
const once = docToMarkdown(doc);
expect(docToMarkdown(markdownToDoc(once))).toBe(once);
expect(once).toContain('data-section-style="section-styles-basic/callout"');
});
it('handles nested section containers (issue #75)', () => {
const md = [
'::: {data-section-style="p/outer"}',
'::: {data-section-style="p/inner"}',
'Deep.',
':::',
':::',
].join('\n');
const outer = markdownToDoc(md).firstChild;
expect(outer?.attrs.styleId).toBe('outer');
expect(outer?.firstChild?.type.name).toBe('section');
expect(outer?.firstChild?.attrs.styleId).toBe('inner');
});
it('round-trips a plugin block as a reserved fence (issue #76)', () => {
const md = ['```dorfteich-plugin mermaid/diagram', '{"source":"graph TD; A-->B"}', '```'].join(
'\n',
);
const doc = markdownToDoc(md);
const block = doc.firstChild;
expect(block?.type.name).toBe('plugin_block');
expect(block?.attrs).toMatchObject({
pluginId: 'mermaid',
blockType: 'diagram',
data: { source: 'graph TD; A-->B' },
});
const once = docToMarkdown(doc);
expect(docToMarkdown(markdownToDoc(once))).toBe(once);
expect(once).toContain('```dorfteich-plugin mermaid/diagram');
});
it('keeps an ordinary fenced code block untouched by the plugin rule', () => {
const doc = markdownToDoc('```js\nconst x = 1;\n```');
expect(doc.firstChild?.type.name).toBe('code_block');
});
it('degrades an unparsable plugin-block body to empty data (issue #76)', () => {
const doc = markdownToDoc('```dorfteich-plugin p/t\nnot json at all\n```');
expect(doc.firstChild?.type.name).toBe('plugin_block');
expect(doc.firstChild?.attrs.data).toEqual({});
});
it('pads a colspan cell so every row keeps the column count (issue #337)', () => {
// Built directly: markdown cannot express merged cells, so the export
// is lossy by format — but it must stay a well-formed GFM table.
const schema = markdownToDoc('x').type.schema;
const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) =>
schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]);
const doc = schema.node('doc', null, [
schema.node('table', null, [
schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]),
schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]),
]),
]);
expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |');
});
it('escalates the fence when the data contains backticks (issue #76)', () => {
const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```');
const withTicks = doc.type.schema.nodes.plugin_block!.create({
pluginId: 'p',
blockType: 't',
data: { code: 'a ``` fence inside' },
});
const md = docToMarkdown(doc.type.schema.nodes.doc!.create(null, [withTicks]));
const reparsed = markdownToDoc(md);
expect(reparsed.firstChild?.type.name).toBe('plugin_block');
expect(reparsed.firstChild?.attrs.data).toEqual({ code: 'a ``` fence inside' });
});
});