dorfteich/packages/shared/src/editor-schema/markdown.test.ts
Claude Fable 5 923532f5f7
All checks were successful
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 3m56s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m53s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Deploy to Test (push) Successful in 12s
Add block plugins: plugin_block node with sandboxed rendering and editing (#76)
The powerful end of the plugin spectrum (ADR 0008 extension point `block`):

- Shared schema: the reserved `plugin_block` node — a block atom carrying
  pluginId, blockType, and the block data as a JSON object. Its DOM shape
  round-trips the full state in data attributes (clipboard-safe), markdown
  maps to a reserved fence (```dorfteich-plugin <plugin>/<type> + data
  JSON body, fence-escalated when the payload contains backticks), and the
  content-cache HTML renders a data-carrying neutral placeholder until the
  export fallbacks land (#79).
- Editor: a React NodeView hosts the #73 sandbox — render lifecycle on
  mount, an edit affordance switching the frame to the plugin's edit mode,
  and the blockData capability persisting through node attrs (a normal
  editor transaction, so Yjs replicates it; writes are refused on read-only
  editors, and the plugin's own attr echo is suppressed so its edit UI
  never resets mid-typing). Collaborator changes re-invoke the current
  lifecycle, keeping frames live. The page surface (ids, openPage) flows
  through a React context like the wikilink pattern; the toolbar gets an
  insert picker fed from the active code plugins' block extension points.
- Fallback: GET /plugins/:id/fallback resolves the manifest fallback from
  the stored snapshot — it survives uninstall as a tombstone, image
  fallbacks degrade to neutral once assets are gone. Signed-in only.
- e2e plugin-blocks.spec.ts covers all four acceptance criteria: insert →
  edit → reload round-trip, live two-user collab, disable → fallback →
  re-enable without document mutation, and copy/paste within and across
  pages (the markdown clipboard carries the reserved fence).

getBlock (cross-page block embedding) stays deferred as in #74: the
schema has no per-block ids yet.

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

164 lines
6.0 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('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' });
});
});