Skip to content

Commit a283fbb

Browse files
MelvinBotinimaga
andcommitted
Strip incomplete markdown from Concierge streaming drafts
During Concierge streaming, partial markdown (e.g. unclosed bold, links, code blocks) was being rendered as raw syntax by ExpensiMark. Add stripIncompleteMarkdown() to sanitize the tail of bodyMarkdown before parsing, removing unclosed constructs so they don't flash briefly in the UI. Co-authored-by: Issa Nimaga <inimaga@users.noreply.github.com>
1 parent fd64783 commit a283fbb

2 files changed

Lines changed: 158 additions & 3 deletions

File tree

src/pages/inbox/conciergeDraftState.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,82 @@ type BuildConciergeDraftReportActionParams = {
2020
reportID: string;
2121
};
2222

23+
/**
24+
* Count non-overlapping occurrences of `needle` in `haystack`.
25+
*/
26+
function countOccurrences(haystack: string, needle: string): number {
27+
let count = 0;
28+
let pos = 0;
29+
while (true) {
30+
const idx = haystack.indexOf(needle, pos);
31+
if (idx === -1) {
32+
break;
33+
}
34+
count++;
35+
pos = idx + needle.length;
36+
}
37+
return count;
38+
}
39+
40+
/**
41+
* If the last line of `text` contains an odd number of `delimiter` occurrences,
42+
* the final one opened a construct that was never closed. Strip from that
43+
* opening delimiter to the end of the string.
44+
*/
45+
function stripUnpairedLastLineDelimiter(text: string, delimiter: string): string {
46+
const lastNewline = text.lastIndexOf('\n');
47+
const lastLine = text.substring(lastNewline + 1);
48+
const count = countOccurrences(lastLine, delimiter);
49+
50+
if (count > 0 && count % 2 !== 0) {
51+
return text.substring(0, text.lastIndexOf(delimiter));
52+
}
53+
return text;
54+
}
55+
56+
/**
57+
* Strips incomplete markdown constructs from the tail of a streaming markdown
58+
* string so that ExpensiMark doesn't render raw syntax for half-finished
59+
* links, bold, strikethrough, or code blocks.
60+
*/
61+
function stripIncompleteMarkdown(markdown: string): string {
62+
if (!markdown) {
63+
return markdown;
64+
}
65+
66+
let result = markdown;
67+
68+
// 1. Incomplete link/image: find the last '[' and check whether a
69+
// complete [text](url) follows it. If not, strip from '[' (or '![').
70+
const lastOpenBracket = result.lastIndexOf('[');
71+
if (lastOpenBracket !== -1) {
72+
const tail = result.substring(lastOpenBracket);
73+
if (!/^\[[^\]]*\]\([^)]*\)/.test(tail)) {
74+
const stripFrom = lastOpenBracket > 0 && result[lastOpenBracket - 1] === '!' ? lastOpenBracket - 1 : lastOpenBracket;
75+
result = result.substring(0, stripFrom);
76+
}
77+
}
78+
79+
// 2. Unclosed bold (**) on the last line.
80+
result = stripUnpairedLastLineDelimiter(result, '**');
81+
82+
// 3. Unclosed strikethrough (~~) on the last line.
83+
result = stripUnpairedLastLineDelimiter(result, '~~');
84+
85+
// 4. Unclosed code block (``` spans multiple lines).
86+
const codeBlockCount = countOccurrences(result, '```');
87+
if (codeBlockCount % 2 !== 0) {
88+
result = result.substring(0, result.lastIndexOf('```'));
89+
}
90+
91+
// 5. Unclosed inline code (`) on the last line (after code-block handling).
92+
result = stripUnpairedLastLineDelimiter(result, '`');
93+
94+
return result;
95+
}
96+
2397
function buildConciergeDraftReportAction({bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID}: BuildConciergeDraftReportActionParams): ReportAction | null {
24-
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(bodyMarkdown, {reportID}) : '');
98+
const html = finalRenderedHTML ?? (bodyMarkdown ? getParsedComment(stripIncompleteMarkdown(bodyMarkdown), {reportID}) : '');
2599

26100
if (!html) {
27101
return null;
@@ -101,5 +175,5 @@ function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: Co
101175
};
102176
}
103177

104-
export {applyConciergeDraftEvent, buildConciergeDraftReportAction, getCachedDraft, setCachedDraft};
178+
export {applyConciergeDraftEvent, buildConciergeDraftReportAction, getCachedDraft, setCachedDraft, stripIncompleteMarkdown};
105179
export type {ConciergeDraft};

tests/unit/pages/inbox/conciergeDraftState.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft} from '@pages/inbox/conciergeDraftState';
1+
import {applyConciergeDraftEvent, getCachedDraft, setCachedDraft, stripIncompleteMarkdown} from '@pages/inbox/conciergeDraftState';
22
import CONST from '@src/CONST';
33

44
const REPORT_ID = '123';
@@ -132,6 +132,87 @@ describe('conciergeDraftState', () => {
132132
expect(otherReportDraft).toBe(initialDraft);
133133
});
134134

135+
describe('stripIncompleteMarkdown', () => {
136+
it('returns empty/falsy values unchanged', () => {
137+
expect(stripIncompleteMarkdown('')).toBe('');
138+
});
139+
140+
it('does not alter complete markdown', () => {
141+
const complete = 'Hello **bold** and [link](https://example.com) and `code`';
142+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
143+
});
144+
145+
// --- Links / Images ---
146+
it('strips an incomplete link with only opening bracket', () => {
147+
expect(stripIncompleteMarkdown('Check out [')).toBe('Check out ');
148+
});
149+
150+
it('strips an incomplete link with text but no closing bracket', () => {
151+
expect(stripIncompleteMarkdown('Check out [this page')).toBe('Check out ');
152+
});
153+
154+
it('strips an incomplete link with bracket closed but no URL', () => {
155+
expect(stripIncompleteMarkdown('Check out [link](')).toBe('Check out ');
156+
});
157+
158+
it('strips an incomplete link with partial URL', () => {
159+
expect(stripIncompleteMarkdown('Check out [link](https://example')).toBe('Check out ');
160+
});
161+
162+
it('preserves a complete link followed by an incomplete one', () => {
163+
expect(stripIncompleteMarkdown('[done](https://a.com) and [broken')).toBe('[done](https://a.com) and ');
164+
});
165+
166+
it('strips an incomplete image syntax', () => {
167+
expect(stripIncompleteMarkdown('Here is ![alt')).toBe('Here is ');
168+
});
169+
170+
// --- Bold (**) ---
171+
it('strips trailing unclosed bold', () => {
172+
expect(stripIncompleteMarkdown('Hello **world')).toBe('Hello ');
173+
});
174+
175+
it('strips bare trailing ** delimiter', () => {
176+
expect(stripIncompleteMarkdown('Hello **')).toBe('Hello ');
177+
});
178+
179+
it('preserves complete bold and strips only the unclosed one', () => {
180+
expect(stripIncompleteMarkdown('**done** and **broken')).toBe('**done** and ');
181+
});
182+
183+
// --- Strikethrough (~~) ---
184+
it('strips trailing unclosed strikethrough', () => {
185+
expect(stripIncompleteMarkdown('Hello ~~strike')).toBe('Hello ');
186+
});
187+
188+
// --- Code blocks (```) ---
189+
it('strips an unclosed code block', () => {
190+
expect(stripIncompleteMarkdown('Here:\n```\ncode')).toBe('Here:\n');
191+
});
192+
193+
it('preserves a complete code block', () => {
194+
const complete = 'Before\n```\ncode\n```\nAfter';
195+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
196+
});
197+
198+
// --- Inline code (`) ---
199+
it('strips trailing unclosed inline code', () => {
200+
expect(stripIncompleteMarkdown('Run `command')).toBe('Run ');
201+
});
202+
203+
it('preserves complete inline code', () => {
204+
const complete = 'Run `command` now';
205+
expect(stripIncompleteMarkdown(complete)).toBe(complete);
206+
});
207+
208+
// --- Streaming integration ---
209+
it('strips incomplete markdown during a streaming draft event', () => {
210+
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Check [this link'}), REPORT_ID);
211+
// The raw '[this link' syntax should NOT appear in the rendered HTML
212+
expect(getFirstMessageHTML(draft)).not.toContain('[this link');
213+
});
214+
});
215+
135216
describe('draftCache', () => {
136217
// Always start clean so tests don't leak state into each other.
137218
beforeEach(() => {

0 commit comments

Comments
 (0)