dorfteich/packages/shared/src/editor-schema/markdown.test.ts
Claude Fable 5 2e96173d44
Some checks failed
CD / Build and push images (push) Successful in 3m15s
CD / Deploy to Test (push) Successful in 9s
CI / Import/export fidelity gate (push) Successful in 47s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m4s
CD / Promote to Int (push) Successful in 10s
CI / Build container images (push) Has been skipped
CI / Warm action cache (push) Successful in 10s
CI / Lint, typecheck, test (push) Failing after 1m27s
Add the section container node to the document model (#75, part 1)
Document-model foundation for section-style plugins (ADR 0008 kind
section_style): a `section` block node carrying `pluginId` + `styleId`,
rendered as `<div class="dt-section dt-style-<pluginId>-<styleId>">` so a
plugin's sanitized, scoped CSS applies and — when the plugin is gone —
the class matches nothing and the content stays intact with neutral
styling. Round-trips to a Pandoc-style fenced div
(`::: {data-section-style="<pluginId>/<styleId>"}`), with a markdown-it
block rule that supports nesting.

This is the schema/markdown/HTML half of #75; the editor UI (wrap/style
picker/unwrap), the `section-styles-basic` reference plugin, per-pond CSS
injection, and PDF/office export styling follow in part 2. The node is
additive and backward-compatible (existing documents have none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 10:00:39 +02:00

123 lines
4.3 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');
});
});