Skip to content

Commit f99a09a

Browse files
committed
Normalize streamed Concierge markdown delimiters
1 parent 8a6bfef commit f99a09a

2 files changed

Lines changed: 42 additions & 5 deletions

File tree

src/pages/inbox/conciergeDraftState.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,30 @@ function stripUnpairedLastLineDelimiter(text: string, delimiter: string, ignored
110110
return text;
111111
}
112112

113+
function normalizeDelimiterForExpensiMark(text: string, delimiter: string, replacement: string, ignoredRanges: MarkdownRange[] = []): string {
114+
let result = '';
115+
116+
for (let pos = 0; pos < text.length; pos++) {
117+
const isInIgnoredRange = ignoredRanges.some((range) => pos >= range.start && pos < range.end);
118+
if (text.startsWith(delimiter, pos) && !isEscaped(text, pos) && !isInIgnoredRange) {
119+
result += replacement;
120+
pos += delimiter.length - 1;
121+
continue;
122+
}
123+
124+
result += text[pos];
125+
}
126+
127+
return result;
128+
}
129+
113130
/**
114131
* Strips incomplete markdown constructs from the tail of a streaming markdown
115132
* string so that ExpensiMark doesn't render raw syntax for half-finished
116-
* links, bold, strikethrough, or code blocks.
133+
* links, bold, strikethrough, or code blocks. Completed double-delimiter
134+
* emphasis is normalized to ExpensiMark's single-delimiter syntax so the text
135+
* stays styled without leaking raw delimiters while the server-rendered HTML is
136+
* still pending.
117137
*/
118138
function stripIncompleteMarkdown(markdown: string): string {
119139
if (!markdown) {
@@ -149,7 +169,13 @@ function stripIncompleteMarkdown(markdown: string): string {
149169

150170
codeRanges = getCodeRanges(result).ranges;
151171
result = stripUnpairedLastLineDelimiter(result, '**', codeRanges);
172+
codeRanges = getCodeRanges(result).ranges;
173+
result = normalizeDelimiterForExpensiMark(result, '**', '*', codeRanges);
174+
175+
codeRanges = getCodeRanges(result).ranges;
152176
result = stripUnpairedLastLineDelimiter(result, '~~', codeRanges);
177+
codeRanges = getCodeRanges(result).ranges;
178+
result = normalizeDelimiterForExpensiMark(result, '~~', '~', codeRanges);
153179

154180
return result;
155181
}

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('conciergeDraftState', () => {
4747
expect(draft?.reportAction.actorAccountID).toBe(CONST.ACCOUNT_ID.CONCIERGE);
4848
expect(draft?.reportAction.created).toBe(CREATED);
4949
expect(getFirstMessageHTML(draft)).toContain('<strong>world</strong>');
50-
expect(getFirstMessageText(draft)).toBe('Hello, *world*!');
50+
expect(getFirstMessageText(draft)).toBe('Hello, world!');
5151
});
5252

5353
it('should update the same draft session when a newer sequence arrives', () => {
@@ -79,7 +79,7 @@ describe('conciergeDraftState', () => {
7979
);
8080

8181
expect(staleDraft).toBe(initialDraft);
82-
expect(getFirstMessageText(staleDraft)).toBe('Hello, *world*!');
82+
expect(getFirstMessageText(staleDraft)).toBe('Hello, world!');
8383
});
8484

8585
it('should keep the draft visible through completion and prefer finalRenderedHTML when provided', () => {
@@ -138,10 +138,14 @@ describe('conciergeDraftState', () => {
138138
});
139139

140140
it('does not alter complete markdown', () => {
141-
const complete = 'Hello **bold** and [link](https://example.com) and `code`';
141+
const complete = 'Hello *bold* and [link](https://example.com) and `code`';
142142
expect(stripIncompleteMarkdown(complete)).toBe(complete);
143143
});
144144

145+
it('normalizes complete double-delimiter emphasis for ExpensiMark', () => {
146+
expect(stripIncompleteMarkdown('Hello **bold** and ~~strike~~')).toBe('Hello *bold* and ~strike~');
147+
});
148+
145149
// --- Links / Images ---
146150
it('strips an incomplete link with only opening bracket', () => {
147151
expect(stripIncompleteMarkdown('Check out [')).toBe('Check out ');
@@ -182,7 +186,7 @@ describe('conciergeDraftState', () => {
182186
});
183187

184188
it('preserves complete bold and strips only the unclosed one', () => {
185-
expect(stripIncompleteMarkdown('**done** and **broken')).toBe('**done** and ');
189+
expect(stripIncompleteMarkdown('**done** and **broken')).toBe('*done* and ');
186190
});
187191

188192
// --- Strikethrough (~~) ---
@@ -221,6 +225,13 @@ describe('conciergeDraftState', () => {
221225
});
222226

223227
// --- Streaming integration ---
228+
it('keeps complete double-delimiter bold styled without showing raw delimiters during streaming', () => {
229+
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Hello **bold**!'}), REPORT_ID);
230+
231+
expect(getFirstMessageHTML(draft)).toContain('<strong>bold</strong>');
232+
expect(getFirstMessageHTML(draft)).not.toContain('*');
233+
});
234+
224235
it('strips incomplete markdown during a streaming draft event', () => {
225236
const draft = applyConciergeDraftEvent(null, createDraftEvent({bodyMarkdown: 'Check [this link'}), REPORT_ID);
226237
// The raw '[this link' syntax should NOT appear in the rendered HTML

0 commit comments

Comments
 (0)