diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 3f647a75..e96cbad3 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -483,6 +483,99 @@ export function convertProseMirrorToMarkdown( return `
` INNERMOST first (before the array-order mark loop),
+ // then skip `code` in the loop. Import (`generateJSON`) always yields the
+ // code mark LAST in the array (canonical order `[emphasis, code]`), so an
+ // order-sensitive loop would flip `` to `` on
+ // re-export and break the byte fixpoint. Code-innermost is stable in both
+ // directions and matches the markdown path (case "text" / run factoring).
+ if ((n.marks || []).some((m: any) => m.type === "code")) {
+ t = `${t}`;
+ }
for (const mark of n.marks || []) {
+ if (mark.type === "code") continue; // wrapped innermost above
switch (mark.type) {
case "bold":
t = `${t}`;
diff --git a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts
index 63067427..ae15692a 100644
--- a/packages/prosemirror-markdown/test/generative/text-arbitraries.ts
+++ b/packages/prosemirror-markdown/test/generative/text-arbitraries.ts
@@ -11,9 +11,11 @@
*
* The corpus deliberately spans the CommonMark / canon hostile alphabet
* (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal
- * mark combinations on runs (including the `code` mark, which the schema's
- * `excludes: "_"` makes suppress every co-occurring mark — so it is never
- * combined with another mark in the byte-stable space).
+ * mark combinations on runs. As of #515 the `code` mark no longer excludes other
+ * marks (`excludes: ""`), so the corpus ALSO combines `code` with bold / italic /
+ * strike / highlight — exercising both the HOMOGENEOUS run factoring (adjacent
+ * code+bold spans -> `` **`a` `b`** ``) and the HETEROGENEOUS anti-collision
+ * fallback (`[code,bold]` next to `[italic]` -> schema-HTML, never `` `a`***b* ``).
*/
import fc from 'fast-check';
@@ -106,16 +108,16 @@ export const urlArb: fc.Arbitrary = fc
/**
* A text run with an OPTIONAL single non-code formatting mark (bold/italic/
* strike/underline/superscript/subscript/spoiler), or a SOLE `code` mark, or a
- * link, or an inline comment anchor. `code` is NEVER combined with another mark
- * in the byte-stable space (that combination is a documented converter
- * limitation — the schema's `code` mark declares `excludes: "_"`). Marks wrap
- * `safeTextArb`, which stays stable even when it contains isolated specials.
+ * `code` mark COMBINED with a bare-delimiter emphasis mark (#515), or a link, or
+ * an inline comment anchor. Marks wrap `safeTextArb`, which stays stable even
+ * when it contains isolated specials.
*
- * The mark set here is broadened past the sibling test's {bold,italic,strike}
- * to also cover underline / superscript / subscript / spoiler / textStyle /
- * highlight (all single, non-code marks), so the marks-on-text generator
- * exercises every mark the schema declares except the deliberately-excluded
- * `code`+other combination.
+ * The mark set here is broadened past the sibling test's {bold,italic,strike} to
+ * also cover underline / superscript / subscript / spoiler / textStyle /
+ * highlight (all single, non-code marks). As of #515 it ALSO emits `code`
+ * combined with bold/italic/strike, so the assembled inline content exercises the
+ * converter's code-emphasis run detection (adjacent combos -> homogeneous
+ * factoring or heterogeneous HTML fallback, both lossless).
*/
export const markedTextRunArb: fc.Arbitrary = fc.oneof(
// Plain text.
@@ -138,6 +140,23 @@ export const markedTextRunArb: fc.Arbitrary = fc.oneof(
// Sole code mark (backtick span). safeTextArb is backtick-free, so the span
// content cannot contain an inner backtick.
safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })),
+ // #515: code COMBINED with a bare-delimiter emphasis mark. The converter nests
+ // the backtick span inside the emphasis delimiters (`` **`x`** ``) and, when
+ // such runs sit adjacent, factors a shared mark or falls back to schema-HTML.
+ // Mark order is `[emphasis, code]` — the canonical order the HTML->PM import
+ // yields (code last) — so the P1 semantic round-trip is order-exact.
+ fc
+ .tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
+ .map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }, { type: 'code' }] })),
+ // #515: code combined with an UNCOLORED highlight (also a bare-delimiter mark,
+ // `==…==`), so the highlight+code delimiter interaction is covered too. Import
+ // yields `[code, highlight]` here (the `==` inline extension nests code first),
+ // so the generator matches that order for the order-exact P1 round-trip.
+ safeTextArb.map((t) => ({
+ type: 'text',
+ text: t,
+ marks: [{ type: 'code' }, { type: 'highlight' }],
+ })),
// Link with safe text, a paren/space-free href, optionally a letter-bearing
// title (a purely numeric title is coerced to a number and dropped).
fc
diff --git a/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts b/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts
index 9008c34b..50212819 100644
--- a/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts
+++ b/packages/prosemirror-markdown/test/markdown-converter-gaps.test.ts
@@ -294,10 +294,11 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
);
});
- // 5. code + link co-occur: the schema's `code` mark excludes all other marks
- // (including link), so the link cannot survive import. The lossless,
- // byte-stable behavior is to emit ONLY the backtick code span (code wins).
- it('a code+link run emits the backtick code form (code wins, link dropped)', () => {
+ // 5. code + link co-occur (#515): `code` no longer excludes other marks, so a
+ // link can wrap inline code. The code span is emitted innermost and the link
+ // wraps it — CommonMark allows inline code inside link text, so it survives
+ // the round trip.
+ it('a code+link run nests the backtick span inside the link (#515)', () => {
const out = convertProseMirrorToMarkdown(
doc(
para({
@@ -310,7 +311,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
}),
),
);
- expect(out).toBe('`x`');
+ expect(out).toBe('[`x`](http://a?b&c"d)');
});
// 6. hardBreak inside a heading: prefix applied once, " \n" between a and b.
diff --git a/packages/prosemirror-markdown/test/markdown-converter.test.ts b/packages/prosemirror-markdown/test/markdown-converter.test.ts
index bd4e734b..87daad1c 100644
--- a/packages/prosemirror-markdown/test/markdown-converter.test.ts
+++ b/packages/prosemirror-markdown/test/markdown-converter.test.ts
@@ -59,22 +59,21 @@ describe('convertProseMirrorToMarkdown', () => {
).toBe('`x`');
});
- it('code + another mark emits the backtick code form (code wins)', () => {
- // The schema's `code` mark excludes all other marks, so the editor can
- // never produce code+bold on one run and import always drops the co-mark.
- // The lossless, byte-stable behavior is to emit ONLY the backtick code
- // span and ignore the co-occurring mark.
+ it('code + bold nests the backtick span inside the emphasis (#515)', () => {
+ // #515: the `code` mark no longer excludes other marks (`excludes: ""`), so
+ // a run can carry code+bold. CommonMark nests them (``), so
+ // the code span is emitted innermost and the bold delimiters wrap it.
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'bold' }, { type: 'code' }]))),
);
- expect(out).toBe('`x`');
+ expect(out).toBe('**`x`**');
});
- it('code + strike combo emits the backtick code form (code wins)', () => {
+ it('code + strike nests the backtick span inside the emphasis (#515)', () => {
const out = convertProseMirrorToMarkdown(
doc(para(text('x', [{ type: 'strike' }, { type: 'code' }]))),
);
- expect(out).toBe('`x`');
+ expect(out).toBe('~~`x`~~');
});
});
diff --git a/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts b/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts
index 50f83d73..ee61f59b 100644
--- a/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts
+++ b/packages/prosemirror-markdown/test/markdown-roundtrip.property.test.ts
@@ -80,13 +80,7 @@ import { stripBlockIds } from './roundtrip-helpers.js';
// `it.fails` blocks below (so the suite stays green only because they are marked
// expected-to-fail, never by hiding them):
//
-// 1. The `code` mark COMBINED with any other mark. The converter emits nested
-// HTML (`x`), but the schema's `code` mark
-// declares `excludes: "_"`, so on import every co-occurring mark is dropped
-// and the run comes back as `code` only -> md2 == "`x`". Acknowledged in
-// markdown-converter.ts (the long comment above the marks switch);
-// impossible to round-trip both while `code` excludes them.
-// 2. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
+// 1. A BLOCK-level `image` placed BETWEEN other blocks. The Docmost image node
// is block-level but `` is inline; marked wraps it in a , the
// schema hoists the
out and leaves an empty paragraph sibling, which
// injects an extra blank gap on the second export. An image IS byte-stable
@@ -625,7 +619,7 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
});
// -------------------------------------------------------------------------
- // KNOWN, DOCUMENTED non-roundtrip bug #2 (kept honest as it.fails).
+ // KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
//
// BUG: a block-level `image` placed BETWEEN other blocks is not byte-stable.
// The Docmost image node is BLOCK-level but its markdown form `` is
@@ -655,23 +649,18 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
});
// -------------------------------------------------------------------------
- // KNOWN, DOCUMENTED non-roundtrip bug #1 (kept honest as it.fails).
+ // #515 ROUND-TRIP PIN: `code` combined with another mark.
//
- // BUG: the `code` mark combined with ANY other mark does NOT round-trip.
- // The converter emits nested HTML so the output is well-formed, e.g.
- // marks [code, bold] -> md1 = "x"
- // but the schema's `code` mark declares `excludes: "_"`, so on import the
- // co-occurring mark is dropped and the run comes back as code-only:
- // md2 = "`x`" (=> md2 !== md1).
- // Minimal repro doc:
- // { type:'doc', content:[ { type:'paragraph', content:[
- // { type:'text', text:'x', marks:[{type:'code'},{type:'bold'}] } ] } ] }
- // This is acknowledged in markdown-converter.ts (the long comment above the
- // marks switch): preserving both marks is impossible while `code` excludes
- // them. Documented here, not "fixed", because the source must not change.
+ // Before #515 the `code` mark declared `excludes: "_"`, dropping every co-
+ // occurring mark on import so `` **`x`** `` came back as code-only. Now
+ // `excludes: ""` lets code combine with all marks (CommonMark nests them,
+ // `x`), so the run BOTH round-trips byte-stably
+ // AND preserves the co-occurring mark. This asserts the observable property in
+ // both directions: md2 === md1 (idempotent export) and the imported doc still
+ // carries [code, other].
// -------------------------------------------------------------------------
it(
- 'code mark combined with another mark is byte-stable',
+ 'code combined with another mark round-trips and keeps both marks (#515)',
async () => {
const codeComboArb = fc
.tuple(safeTextArb, fc.constantFrom('bold', 'italic', 'strike'))
@@ -688,11 +677,90 @@ describe('markdown <-> ProseMirror round-trip (property-based)', () => {
}));
await fc.assert(
fc.asyncProperty(codeComboArb, async (doc) => {
- const { md1, md2 } = await roundTrip(doc);
+ const { md1, md2, doc2 } = await roundTrip(doc);
expect(md2).toBe(md1);
+ // The re-imported run carries BOTH code and the co-occurring mark.
+ const run = doc2?.content?.[0]?.content?.[0];
+ const markTypes = (run?.marks || []).map((m: any) => m.type).sort();
+ expect(markTypes).toContain('code');
+ expect(markTypes.length).toBe(2);
}),
{ numRuns: 20, seed: SEED },
);
},
);
+
+ // -------------------------------------------------------------------------
+ // #515 REPRO CASES: the five markdown inputs from the issue must import to a
+ // code+bold node (import correctness) AND re-export byte-stably with no
+ // dangling `**` (export correctness). Import direction is checked against the
+ // real markdown->PM bridge; export direction via the md->pm->md fixpoint.
+ // -------------------------------------------------------------------------
+ it('the five #515 repro cases import to [code,bold] and round-trip clean', async () => {
+ // Collect every inline text run in a doc with its mark type set.
+ const runs = (node: any): { text: string; marks: string[] }[] => {
+ if (node?.type === 'text') {
+ return [{ text: node.text || '', marks: (node.marks || []).map((m: any) => m.type) }];
+ }
+ return (node?.content || []).flatMap(runs);
+ };
+ const findRun = (doc: any, text: string) =>
+ runs(doc).find((r) => r.text === text);
+
+ // Case 1: **`code1`** -> code1 = [code, bold].
+ {
+ const md = '**`code1`**';
+ const pm = await markdownToProseMirror(md);
+ const r = findRun(pm, 'code1');
+ expect(r?.marks.sort()).toEqual(['bold', 'code']);
+ const md2 = convertProseMirrorToMarkdown(pm);
+ expect(md2).toBe('**`code1`**');
+ // md -> pm -> md fixpoint.
+ expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
+ }
+
+ // Case 2: **`aaa` + `bbb`** -> aaa,bbb = [code,bold], "+" carries bold; no
+ // dangling `**` on export.
+ {
+ const md = '**`aaa` + `bbb`**';
+ const pm = await markdownToProseMirror(md);
+ expect(findRun(pm, 'aaa')?.marks.sort()).toEqual(['bold', 'code']);
+ expect(findRun(pm, 'bbb')?.marks.sort()).toEqual(['bold', 'code']);
+ const md2 = convertProseMirrorToMarkdown(pm);
+ expect(md2).toBe('**`aaa` + `bbb`**');
+ // NOT the old broken export with the bold delimiters split onto each span.
+ expect(md2).not.toBe('`aaa`** + **`bbb`');
+ expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
+ }
+
+ // Case 3 (control): **bold3** and `code3` -> bold and code stay SEPARATE.
+ {
+ const md = '**bold3** and `code3`';
+ const pm = await markdownToProseMirror(md);
+ expect(findRun(pm, 'bold3')?.marks).toEqual(['bold']);
+ expect(findRun(pm, 'code3')?.marks).toEqual(['code']);
+ const md2 = convertProseMirrorToMarkdown(pm);
+ expect(md2).toBe('**bold3** and `code3`');
+ }
+
+ // Case 4: **`code4` tail** -> code4 = [code,bold], " tail" = [bold].
+ {
+ const md = '**`code4` tail**';
+ const pm = await markdownToProseMirror(md);
+ expect(findRun(pm, 'code4')?.marks.sort()).toEqual(['bold', 'code']);
+ const md2 = convertProseMirrorToMarkdown(pm);
+ expect(md2).toBe('**`code4` tail**');
+ expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
+ }
+
+ // Case 5: pre **`code5`** post -> code5 = [code,bold], surroundings plain.
+ {
+ const md = 'pre **`code5`** post';
+ const pm = await markdownToProseMirror(md);
+ expect(findRun(pm, 'code5')?.marks.sort()).toEqual(['bold', 'code']);
+ const md2 = convertProseMirrorToMarkdown(pm);
+ expect(md2).toBe('pre **`code5`** post');
+ expect(convertProseMirrorToMarkdown(await markdownToProseMirror(md2))).toBe(md2);
+ }
+ });
});