@@ -20,8 +20,170 @@ type BuildConciergeDraftReportActionParams = {
2020 reportID : string ;
2121} ;
2222
23+ type TextRange = {
24+ start : number ;
25+ end : number ;
26+ } ;
27+
28+ const CODE_BLOCK_DELIMITER = '```' ;
29+ const INLINE_CODE_DELIMITER = '`' ;
30+ const BOLD_DELIMITER = '**' ;
31+ const STRIKETHROUGH_DELIMITER = '~~' ;
32+
33+ function isInAnyRange ( position : number , ranges : TextRange [ ] ) : boolean {
34+ return ranges . some ( ( range ) => position >= range . start && position < range . end ) ;
35+ }
36+
37+ function isEscaped ( text : string , index : number ) : boolean {
38+ let slashCount = 0 ;
39+ let pos = index - 1 ;
40+
41+ while ( pos >= 0 && text [ pos ] === '\\' ) {
42+ slashCount ++ ;
43+ pos -- ;
44+ }
45+
46+ return slashCount % 2 !== 0 ;
47+ }
48+
49+ function getCodeRanges ( text : string ) : { ranges : TextRange [ ] ; unclosedCodeBlockStart : number | null } {
50+ const ranges : TextRange [ ] = [ ] ;
51+ let unclosedCodeBlockStart : number | null = null ;
52+
53+ for ( let pos = 0 ; pos <= text . length - CODE_BLOCK_DELIMITER . length ; pos ++ ) {
54+ if ( ! text . startsWith ( CODE_BLOCK_DELIMITER , pos ) || isEscaped ( text , pos ) ) {
55+ continue ;
56+ }
57+
58+ if ( unclosedCodeBlockStart === null ) {
59+ unclosedCodeBlockStart = pos ;
60+ } else {
61+ ranges . push ( { start : unclosedCodeBlockStart , end : pos + CODE_BLOCK_DELIMITER . length } ) ;
62+ unclosedCodeBlockStart = null ;
63+ }
64+ pos += CODE_BLOCK_DELIMITER . length - 1 ;
65+ }
66+
67+ let lineStart = 0 ;
68+
69+ while ( lineStart <= text . length ) {
70+ const nextNewline = text . indexOf ( '\n' , lineStart ) ;
71+ const lineEnd = nextNewline === - 1 ? text . length : nextNewline ;
72+ let openingDelimiterIndex : number | null = null ;
73+
74+ for ( let pos = lineStart ; pos < lineEnd ; pos ++ ) {
75+ if ( text [ pos ] !== INLINE_CODE_DELIMITER || isEscaped ( text , pos ) || isInAnyRange ( pos , ranges ) ) {
76+ continue ;
77+ }
78+
79+ if ( openingDelimiterIndex === null ) {
80+ openingDelimiterIndex = pos ;
81+ } else {
82+ ranges . push ( { start : openingDelimiterIndex , end : pos + INLINE_CODE_DELIMITER . length } ) ;
83+ openingDelimiterIndex = null ;
84+ }
85+ }
86+
87+ if ( nextNewline === - 1 ) {
88+ break ;
89+ }
90+ lineStart = nextNewline + 1 ;
91+ }
92+
93+ return { ranges, unclosedCodeBlockStart} ;
94+ }
95+
96+ function stripUnpairedLastLineDelimiter ( text : string , delimiter : string , ignoredRanges : TextRange [ ] = [ ] ) : string {
97+ const lastNewline = text . lastIndexOf ( '\n' ) ;
98+ const lastLineStart = lastNewline + 1 ;
99+ const delimiterIndexes : number [ ] = [ ] ;
100+
101+ for ( let pos = lastLineStart ; pos <= text . length - delimiter . length ; pos ++ ) {
102+ if ( ! text . startsWith ( delimiter , pos ) || isEscaped ( text , pos ) || isInAnyRange ( pos , ignoredRanges ) ) {
103+ continue ;
104+ }
105+
106+ delimiterIndexes . push ( pos ) ;
107+ pos += delimiter . length - 1 ;
108+ }
109+
110+ if ( delimiterIndexes . length > 0 && delimiterIndexes . length % 2 !== 0 ) {
111+ return text . substring ( 0 , delimiterIndexes . at ( - 1 ) ) ;
112+ }
113+
114+ return text ;
115+ }
116+
117+ function normalizeDelimiterForExpensiMark ( text : string , delimiter : string , replacement : string , ignoredRanges : TextRange [ ] = [ ] ) : string {
118+ let result = '' ;
119+
120+ for ( let pos = 0 ; pos < text . length ; pos ++ ) {
121+ if ( text . startsWith ( delimiter , pos ) && ! isEscaped ( text , pos ) && ! isInAnyRange ( pos , ignoredRanges ) ) {
122+ result += replacement ;
123+ pos += delimiter . length - 1 ;
124+ continue ;
125+ }
126+
127+ result += text [ pos ] ;
128+ }
129+
130+ return result ;
131+ }
132+
133+ /**
134+ * Strips incomplete markdown constructs from the tail of a streaming markdown
135+ * string so that ExpensiMark doesn't render raw syntax for half-finished
136+ * links, bold, strikethrough, or code blocks. Completed double-delimiter
137+ * emphasis is normalized to ExpensiMark's single-delimiter syntax so the text
138+ * stays styled without leaking raw delimiters while the server-rendered HTML is
139+ * still pending.
140+ */
141+ function stripIncompleteMarkdown ( markdown : string ) : string {
142+ if ( ! markdown ) {
143+ return markdown ;
144+ }
145+
146+ const initialCodeState = getCodeRanges ( markdown ) ;
147+ let codeRanges = initialCodeState . ranges ;
148+ let result = initialCodeState . unclosedCodeBlockStart === null ? markdown : markdown . substring ( 0 , initialCodeState . unclosedCodeBlockStart ) ;
149+
150+ // Strip incomplete inline code before looking for other markdown so code
151+ // contents don't look like unfinished links or emphasis.
152+ codeRanges = codeRanges . filter ( ( range ) => range . end <= result . length ) ;
153+ result = stripUnpairedLastLineDelimiter ( result , INLINE_CODE_DELIMITER , codeRanges ) ;
154+
155+ codeRanges = getCodeRanges ( result ) . ranges ;
156+ for ( let openBracketIndex = result . length - 1 ; openBracketIndex >= 0 ; openBracketIndex -- ) {
157+ if ( result [ openBracketIndex ] !== '[' || isEscaped ( result , openBracketIndex ) || isInAnyRange ( openBracketIndex , codeRanges ) ) {
158+ continue ;
159+ }
160+
161+ const closeBracketIndex = result . indexOf ( ']' , openBracketIndex + 1 ) ;
162+ const stripFrom = openBracketIndex > 0 && result [ openBracketIndex - 1 ] === '!' && ! isEscaped ( result , openBracketIndex - 1 ) ? openBracketIndex - 1 : openBracketIndex ;
163+
164+ if ( closeBracketIndex === - 1 ) {
165+ result = result . substring ( 0 , stripFrom ) ;
166+ } else if ( result [ closeBracketIndex + 1 ] === '(' && result . indexOf ( ')' , closeBracketIndex + 2 ) === - 1 ) {
167+ result = result . substring ( 0 , stripFrom ) ;
168+ }
169+ break ;
170+ }
171+
172+ codeRanges = getCodeRanges ( result ) . ranges ;
173+ result = stripUnpairedLastLineDelimiter ( result , BOLD_DELIMITER , codeRanges ) ;
174+ codeRanges = getCodeRanges ( result ) . ranges ;
175+ result = normalizeDelimiterForExpensiMark ( result , BOLD_DELIMITER , '*' , codeRanges ) ;
176+
177+ codeRanges = getCodeRanges ( result ) . ranges ;
178+ result = stripUnpairedLastLineDelimiter ( result , STRIKETHROUGH_DELIMITER , codeRanges ) ;
179+ codeRanges = getCodeRanges ( result ) . ranges ;
180+ result = normalizeDelimiterForExpensiMark ( result , STRIKETHROUGH_DELIMITER , '~' , codeRanges ) ;
181+
182+ return result ;
183+ }
184+
23185function buildConciergeDraftReportAction ( { bodyMarkdown, created, finalRenderedHTML, reportActionID, reportID} : BuildConciergeDraftReportActionParams ) : ReportAction | null {
24- const html = finalRenderedHTML ?? ( bodyMarkdown ? getParsedComment ( bodyMarkdown , { reportID} ) : '' ) ;
186+ const html = finalRenderedHTML ?? ( bodyMarkdown ? getParsedComment ( stripIncompleteMarkdown ( bodyMarkdown ) , { reportID} ) : '' ) ;
25187
26188 if ( ! html ) {
27189 return null ;
@@ -101,5 +263,5 @@ function applyConciergeDraftEvent(currentDraft: ConciergeDraft | null, event: Co
101263 } ;
102264}
103265
104- export { applyConciergeDraftEvent , getCachedDraft , setCachedDraft } ;
266+ export { applyConciergeDraftEvent , getCachedDraft , setCachedDraft , stripIncompleteMarkdown } ;
105267export type { ConciergeDraft } ;
0 commit comments