test: add milkdown markdown roundtrip integration coverage
Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com>
This commit is contained in:
@@ -22,7 +22,7 @@ import { imageResolverPlugin } from '../../plugins/imageResolverPlugin';
|
|||||||
import '../../macros';
|
import '../../macros';
|
||||||
import './MilkdownEditor.css';
|
import './MilkdownEditor.css';
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { unescapeMacroSyntax } from '../../utils/markdownEscape';
|
import { normalizeMilkdownMarkdown } from '../../utils/markdownEscape';
|
||||||
|
|
||||||
// Remark plugin to force tight lists (no blank lines between list items)
|
// Remark plugin to force tight lists (no blank lines between list items)
|
||||||
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
||||||
@@ -68,11 +68,11 @@ export const shouldPropagateMilkdownChange = ({
|
|||||||
externalContent,
|
externalContent,
|
||||||
hasUserInteracted,
|
hasUserInteracted,
|
||||||
}: MilkdownChangePropagationInput): boolean => {
|
}: MilkdownChangePropagationInput): boolean => {
|
||||||
const unescaped = unescapeMacroSyntax(markdown);
|
const normalized = normalizeMilkdownMarkdown(markdown);
|
||||||
const prevUnescaped = unescapeMacroSyntax(prevMarkdown);
|
const prevNormalized = normalizeMilkdownMarkdown(prevMarkdown);
|
||||||
const externalUnescaped = unescapeMacroSyntax(externalContent);
|
const externalNormalized = normalizeMilkdownMarkdown(externalContent);
|
||||||
|
|
||||||
if (unescaped === prevUnescaped) {
|
if (normalized === prevNormalized) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export const shouldPropagateMilkdownChange = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return unescaped !== externalUnescaped;
|
return normalized !== externalNormalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EditorToolbarProps {
|
interface EditorToolbarProps {
|
||||||
@@ -329,7 +329,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
|
|||||||
|
|
||||||
if (shouldEmit) {
|
if (shouldEmit) {
|
||||||
isInternalChange.current = true;
|
isInternalChange.current = true;
|
||||||
onChangeRef.current(unescapeMacroSyntax(markdown));
|
onChangeRef.current(normalizeMilkdownMarkdown(markdown));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,3 +37,37 @@ export function unescapeMacroSyntax(markdown: string): string {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unorderedListItemPattern = /^\s{0,3}[-+*]\s/;
|
||||||
|
const orderedListItemPattern = /^\s{0,3}\d+\.\s/;
|
||||||
|
|
||||||
|
function getListLineType(line: string): 'ordered' | 'unordered' | null {
|
||||||
|
if (unorderedListItemPattern.test(line)) return 'unordered';
|
||||||
|
if (orderedListItemPattern.test(line)) return 'ordered';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMilkdownMarkdown(markdown: string): string {
|
||||||
|
const unescaped = unescapeMacroSyntax(markdown);
|
||||||
|
const lines = unescaped.split('\n');
|
||||||
|
const normalized: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
const previousListType = i > 0 ? getListLineType(lines[i - 1]) : null;
|
||||||
|
const nextListType = i < lines.length - 1 ? getListLineType(lines[i + 1]) : null;
|
||||||
|
if (line === '' && previousListType !== null && previousListType === nextListType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line === '>') {
|
||||||
|
normalized.push('> ');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,4 +45,15 @@ describe('shouldPropagateMilkdownChange', () => {
|
|||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not propagate list-spacing-only normalization differences', () => {
|
||||||
|
const result = shouldPropagateMilkdownChange({
|
||||||
|
markdown: '- one\n\n- two',
|
||||||
|
prevMarkdown: '',
|
||||||
|
externalContent: '- one\n- two',
|
||||||
|
hasUserInteracted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
74
tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts
Normal file
74
tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import matter from 'gray-matter';
|
||||||
|
import { Editor, defaultValueCtx, parserCtx, remarkPluginsCtx, remarkStringifyOptionsCtx, rootCtx, serializerCtx } from '@milkdown/kit/core';
|
||||||
|
import { commonmark } from '@milkdown/kit/preset/commonmark';
|
||||||
|
import { gfm } from '@milkdown/kit/preset/gfm';
|
||||||
|
import type { Plugin } from 'unified';
|
||||||
|
import type { Root, List, ListItem } from 'mdast';
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
import { normalizeMilkdownMarkdown } from '../../../src/renderer/utils/markdownEscape';
|
||||||
|
|
||||||
|
const wxrRefDir = '/home/runner/work/bDS/bDS/tests/assets/wxr-ref';
|
||||||
|
|
||||||
|
const remarkTightListsPlugin: Plugin<[Record<string, unknown>], Root> = () => {
|
||||||
|
return (tree: Root) => {
|
||||||
|
visit(tree, 'list', (node) => {
|
||||||
|
const listNode = node as List;
|
||||||
|
listNode.spread = false;
|
||||||
|
for (const child of listNode.children) {
|
||||||
|
if (child.type === 'listItem') {
|
||||||
|
(child as ListItem).spread = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Milkdown markdown round trip', () => {
|
||||||
|
let root: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = document.createElement('div');
|
||||||
|
document.body.appendChild(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
root.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves reference markdown bodies across load -> internal -> markdown cycle', async () => {
|
||||||
|
const files = fs.readdirSync(wxrRefDir).filter((file) => file.endsWith('.md'));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const raw = fs.readFileSync(path.join(wxrRefDir, file), 'utf-8');
|
||||||
|
const { content } = matter(raw);
|
||||||
|
|
||||||
|
const editor = await Editor.make()
|
||||||
|
.config((ctx) => {
|
||||||
|
ctx.set(rootCtx, root);
|
||||||
|
ctx.set(defaultValueCtx, content);
|
||||||
|
ctx.set(remarkStringifyOptionsCtx, {
|
||||||
|
bullet: '-',
|
||||||
|
listItemIndent: 'one',
|
||||||
|
});
|
||||||
|
ctx.set(remarkPluginsCtx, [{ plugin: remarkTightListsPlugin, options: {} }]);
|
||||||
|
})
|
||||||
|
.use(commonmark)
|
||||||
|
.use(gfm)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
const serialized = editor.action((ctx) => {
|
||||||
|
const parser = ctx.get(parserCtx);
|
||||||
|
const serializer = ctx.get(serializerCtx);
|
||||||
|
const doc = parser(content);
|
||||||
|
return normalizeMilkdownMarkdown(serializer(doc));
|
||||||
|
});
|
||||||
|
|
||||||
|
await editor.destroy();
|
||||||
|
|
||||||
|
expect(serialized, `round trip mismatch for ${file}`).toBe(content);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user