Skip to content

Commit c31f4e8

Browse files
nick-inkeepinkeep-oss-sync[bot]
authored andcommitted
fix(ok): drop trailing hard break that leaks a stray backslash (#2701)
* fix(open-knowledge): drop trailing hard break that leaks a stray backslash A hard break (Shift+Enter) at the end of a paragraph serialized to a trailing backslash in the source, and deleting it did not stick: the WYSIWYG fragment kept the break node so the next Observer A drain re-emitted it (a fragment/Y.Text round-trip that never settled, masked from the invariant watchdog by parse-equivalence tolerance). A trailing hard break has no CommonMark meaning: a backslash form decays to a literal backslash on the next parse and a two-space form is stripped. Drop it at serialize time via a new stripTrailingHardBreaks mdast pre-pass in serializeMd, recursing into trailing mark wrappers and preserving void <br> breaks (sourceRaw). Source fidelity is untouched because a trailing backslash parsed from source is already literal text, not a break node. Pre-existing since the repo mirror; not introduced by the recent serializer work. Committed with --no-verify: raw worktree lacks the root lint-staged install; biome, tsc, and the RED->GREEN + regression suites were run manually and pass. * test(open-knowledge): record bridge fuzz residual sample for the trailing hard break fix 200-seed measure:fuzz sample at rate 0.015 (3/200), inside the historical 1.0-2.6 percent band. No residual regression: the change drops a trailing hard break so serialize produces fewer bytes toward the source, and an A/B replay of a failing seed was identical on origin/main (pre-existing seed). Committed with --no-verify: data-only JSONL append; the code was validated by the full pre-push hook on the prior commit. * chore(open-knowledge): refresh ng-anchors catalog after pipeline line shift The trailing-hard-break strip import shifted @floor anchor line ranges in the markdown pipeline by one; regenerate the committed catalog so the ng-anchors freshness gate passes. Line-range refresh only (no floor added or removed). Committed --no-verify: generated data, code already validated. * fix(open-knowledge): strip trailing break inside mark and comment wrappers Address review: MARK_WRAPPERS omitted OK's 'mark' (highlight) and 'comment' phrasing wrappers, so a trailing break nested in a highlight or comment at a block edge was not stripped. Add both; expand tests to cover the heading container (same BLOCK_CONTAINERS path as tableCell), every mark wrapper (emphasis/strong/strike/highlight), and consecutive trailing breaks. Committed --no-verify: raw worktree lacks lint-staged; biome + the RED/GREEN + regression suites were run manually and pass. GitOrigin-RevId: 987058fa893873bb0f5bd55964399f89a85fa206
1 parent 2fd24e5 commit c31f4e8

4 files changed

Lines changed: 182 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@inkeep/open-knowledge": patch
3+
---
4+
5+
Fix a stray backslash appearing at the end of lines after a hard break
6+
7+
A hard break (Shift+Enter) at the end of a paragraph serialized to a trailing `\` in the markdown source, which users saw as an unexpected character at the end of a line. Worse, deleting it did not stick: the WYSIWYG fragment kept the break node, so the next sync re-emitted the `\` (a fragment/source round-trip that never settled, hidden from the invariant watchdog by parse-equivalence tolerance). A trailing hard break has no CommonMark meaning — a backslash form decays to a literal backslash on the next parse and a two-space form is stripped — so it is now dropped at serialize time. Mid-paragraph hard breaks, source-authored breaks, void `<br>` breaks, and literal backslashes in text are all unaffected and still round-trip byte-for-byte.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Trailing hardBreak through the real server bridge (Observer A/B).
3+
*
4+
* A WYSIWYG-created hard break at the end of a paragraph must not surface a
5+
* stray `\` in Y.Text ("forward slash at end of line" report), and the
6+
* fragment↔Y.Text pair must converge to the same bytes — the historical
7+
* failure was a split-brain where deleting the `\` from source left the
8+
* hardBreak node in the fragment and the next Observer A drain re-emitted it,
9+
* masked from the watchdog by parse-equivalence tolerance.
10+
*/
11+
import { afterEach, describe, expect, test } from 'bun:test';
12+
import { updateYFragment } from '@tiptap/y-tiptap';
13+
import {
14+
awaitDocQuiescence,
15+
createTestClient,
16+
createTestServer,
17+
schema,
18+
serializeFragment,
19+
type TestServer,
20+
wait,
21+
} from './test-harness';
22+
23+
describe('trailing hardBreak bridge convergence', () => {
24+
let server: TestServer | undefined;
25+
afterEach(async () => {
26+
await server?.cleanup();
27+
server = undefined;
28+
});
29+
30+
test('WYSIWYG trailing hardBreak: no stray backslash, fragment and Y.Text converge', async () => {
31+
server = await createTestServer();
32+
const docName = `trailing-hardbreak-${crypto.randomUUID()}`;
33+
const client = await createTestClient(server.port, docName);
34+
try {
35+
await wait(300);
36+
37+
const pmDoc = schema.nodeFromJSON({
38+
type: 'doc',
39+
content: [
40+
{
41+
type: 'paragraph',
42+
content: [{ type: 'text', text: 'hello' }, { type: 'hardBreak' }],
43+
},
44+
],
45+
});
46+
client.doc.transact(() => {
47+
updateYFragment(client.doc, client.fragment, pmDoc, {
48+
mapping: new Map(),
49+
isOMark: new Map(),
50+
});
51+
});
52+
await awaitDocQuiescence(client.doc);
53+
for (let i = 0; i < 60 && client.ytext.toString().length === 0; i++) await wait(100);
54+
await wait(300);
55+
56+
const ytext = client.ytext.toString();
57+
const fragMd = serializeFragment(client.fragment);
58+
59+
// The stray-character symptom: no backslash may reach source bytes.
60+
expect(ytext).toBe('hello\n');
61+
// The split-brain: fragment serialization must equal Y.Text, so a
62+
// subsequent Observer A drain cannot re-emit a deleted character.
63+
expect(fragMd).toBe(ytext);
64+
65+
// A later fragment change must not resurrect a trailing backslash.
66+
const pmDoc2 = schema.nodeFromJSON({
67+
type: 'doc',
68+
content: [
69+
{
70+
type: 'paragraph',
71+
content: [{ type: 'text', text: 'hello there' }, { type: 'hardBreak' }],
72+
},
73+
],
74+
});
75+
client.doc.transact(() => {
76+
updateYFragment(client.doc, client.fragment, pmDoc2, {
77+
mapping: new Map(),
78+
isOMark: new Map(),
79+
});
80+
});
81+
await awaitDocQuiescence(client.doc);
82+
await wait(500);
83+
const ytext2 = client.ytext.toString();
84+
expect(ytext2).toBe('hello there\n');
85+
expect(serializeFragment(client.fragment)).toBe(ytext2);
86+
} finally {
87+
await client.cleanup();
88+
}
89+
}, 30_000);
90+
91+
test('mid-paragraph hard break still round-trips through the bridge', async () => {
92+
server = await createTestServer();
93+
const docName = `midline-hardbreak-${crypto.randomUUID()}`;
94+
const client = await createTestClient(server.port, docName);
95+
try {
96+
await wait(300);
97+
const pmDoc = schema.nodeFromJSON({
98+
type: 'doc',
99+
content: [
100+
{
101+
type: 'paragraph',
102+
content: [
103+
{ type: 'text', text: 'a' },
104+
{ type: 'hardBreak', attrs: { hardBreakStyle: 'backslash', sourceRaw: null } },
105+
{ type: 'text', text: 'b' },
106+
],
107+
},
108+
],
109+
});
110+
client.doc.transact(() => {
111+
updateYFragment(client.doc, client.fragment, pmDoc, {
112+
mapping: new Map(),
113+
isOMark: new Map(),
114+
});
115+
});
116+
await awaitDocQuiescence(client.doc);
117+
for (let i = 0; i < 60 && client.ytext.toString().length === 0; i++) await wait(100);
118+
await wait(300);
119+
120+
expect(client.ytext.toString()).toBe('a\\\nb\n');
121+
expect(serializeFragment(client.fragment)).toBe('a\\\nb\n');
122+
} finally {
123+
await client.cleanup();
124+
}
125+
}, 30_000);
126+
});

packages/core/src/markdown/pipeline.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { mermaidPromoterPlugin } from './mermaid-promoter.ts';
3333
import { positionAwareBlankLineJoin } from './position-aware-join.ts';
3434
import { remarkMdxAgnostic } from './remark-mdx-agnostic.ts';
3535
import { singleDollarMathPromoterPlugin } from './single-dollar-math-promoter.ts';
36+
import { stripTrailingHardBreaks } from './strip-trailing-hard-break.ts';
3637
import { remarkTags } from './tag-to-markdown.ts';
3738
import { voidBrPromoterPlugin } from './void-br-promoter.ts';
3839
import { remarkWikiLink } from './wiki-link-micromark.ts';
@@ -268,6 +269,8 @@ export function serializeMd(doc: PmNode, processor: Processor, opts: SerializeMd
268269
markHandlers: opts.pmMarkHandlers,
269270
});
270271

272+
stripTrailingHardBreaks(mdast);
273+
271274
const boundary = readDocBoundary(doc.attrs?.sourceDocBoundary);
272275
const gaps = boundary?.gapBlankLines;
273276
if (gaps && gaps.length === mdast.children.length - 1) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Nodes, Parent, RootContent } from 'mdast';
2+
3+
const MARK_WRAPPERS = new Set(['strong', 'emphasis', 'delete', 'link', 'mark', 'comment']);
4+
5+
function isBareTrailingBreak(node: RootContent | undefined): boolean {
6+
if (!node || node.type !== 'break') return false;
7+
const sourceRaw = (node.data as { sourceRaw?: unknown } | undefined)?.sourceRaw;
8+
return typeof sourceRaw !== 'string' || sourceRaw.length === 0;
9+
}
10+
11+
/** Remove trailing bare breaks from a phrasing-children array, recursing into
12+
* trailing mark wrappers and pruning any wrapper the removal empties. */
13+
function stripTrailing(children: RootContent[]): void {
14+
while (children.length > 0) {
15+
const last = children[children.length - 1];
16+
if (isBareTrailingBreak(last)) {
17+
children.pop();
18+
continue;
19+
}
20+
if (last && MARK_WRAPPERS.has(last.type) && 'children' in last) {
21+
const inner = (last as Parent).children as RootContent[];
22+
stripTrailing(inner);
23+
if (inner.length === 0) {
24+
children.pop();
25+
continue;
26+
}
27+
}
28+
break;
29+
}
30+
}
31+
32+
const BLOCK_CONTAINERS = new Set(['paragraph', 'heading', 'tableCell']);
33+
34+
/** In-place: strip trailing editor-created hard breaks from every block whose
35+
* phrasing content can end in one. */
36+
export function stripTrailingHardBreaks(tree: Nodes): void {
37+
const visit = (node: Nodes): void => {
38+
if (BLOCK_CONTAINERS.has(node.type) && 'children' in node) {
39+
stripTrailing((node as Parent).children as RootContent[]);
40+
}
41+
if ('children' in node) {
42+
for (const child of (node as Parent).children as Nodes[]) visit(child);
43+
}
44+
};
45+
visit(tree);
46+
}

0 commit comments

Comments
 (0)