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
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
179 lines
6.8 KiB
TypeScript
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: '',
|
|
|
|
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' });
|
|
});
|
|
});
|