diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts index bcd31451..1f5feef8 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.test.ts @@ -103,6 +103,9 @@ describe("gitmostInsertTranscriptIntoEditor", () => { "1. one", "> [!info] note", "```js", + "---", // solid thematic break -> horizontalRule (text-losing) if unneutralized + "***", + "___", "You: normal line", ].join("\n"), ); @@ -122,6 +125,9 @@ describe("gitmostInsertTranscriptIntoEditor", () => { ZWSP + "1. one", ZWSP + "> [!info] note", ZWSP + "```js", + ZWSP + "---", + ZWSP + "***", + ZWSP + "___", "You: normal line", ]); diff --git a/apps/client/src/features/editor/gitmost/gitmost-recording.ts b/apps/client/src/features/editor/gitmost/gitmost-recording.ts index bb37297b..2856ac08 100644 --- a/apps/client/src/features/editor/gitmost/gitmost-recording.ts +++ b/apps/client/src/features/editor/gitmost/gitmost-recording.ts @@ -253,11 +253,15 @@ const GITMOST_ZWSP = "​"; // verbatim with NO block-escape (the pre-existing root cause), so a leading // `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table // `|`, or a `> [!info]` callout opener would silently become a heading / list / -// quote / code block / table / callout. This matches a TRIMMED line's start; -// the transcript's own `You:` / `Speaker N:` prefix begins with a letter and -// never matches, so prefixed lines are left byte-exact. +// quote / code block / table / callout. The final alternative matches a WHOLE- +// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of +// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because +// that node carries NO text, an un-neutralized separator line would LOSE its +// text entirely (worse than the list/quote case). This matches a TRIMMED line's +// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter +// and never matches, so prefixed lines are left byte-exact. const GITMOST_MD_BLOCK_TRIGGER_RE = - /^(#{1,6}(\s|$)|[-*+](\s|$)|>|\d+[.)](\s|$)|```|~~~|\|)/; + /^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/; // Append a transcript block BELOW the recording's audio node in a live editor: // a "Transcript" heading followed by one paragraph per non-empty transcript diff --git a/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts b/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts index ad60b8c8..6fb35f1f 100644 --- a/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts +++ b/packages/prosemirror-markdown/test/gitmost-transcript-neutralization.test.ts @@ -29,7 +29,7 @@ const ZWSP = "​"; // U+200B // MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge. const MD_BLOCK_TRIGGER_RE = - /^(#{1,6}(\s|$)|[-*+](\s|$)|>|\d+[.)](\s|$)|```|~~~|\|)/; + /^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/; const doc = (...nodes: any[]) => ({ type: "doc", content: nodes }); const para = (t: string) => ({ @@ -57,6 +57,14 @@ describe("gitmost transcript neutralization (git-sync round-trip)", () => { "> [!info] note", "```js", "~~~", + // Solid + spaced thematic breaks — these re-parse into a `horizontalRule`, + // which carries NO text, so a bare separator line LOSES its text entirely + // (round-2 finding). `_` also only forms a block via this construct. + "---", + "***", + "___", + "- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break) + "_ _ _", ]; it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => { @@ -71,6 +79,18 @@ describe("gitmost transcript neutralization (git-sync round-trip)", () => { } }); + it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => { + // The severe case: no text node survives. Documents why neutralization + // matters more here than for list/quote (where the text survived). + for (const line of ["---", "***", "___"]) { + const blocks = await roundtrip(line); + expect(blocks.map((b) => b.type)).toContain("horizontalRule"); + // No block carries the original text anywhere. + const flat = JSON.stringify(blocks); + expect(flat).not.toContain(line); + } + }); + it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => { for (const line of triggerLines) { // The regex must actually classify each as a trigger.