@@ -34,6 +34,157 @@ const isWordChar = (char: string): boolean => {
3434 return letterNumberUnderscorePattern . test ( char )
3535}
3636
37+ // Detect custom inline reference tags
38+ const mentionTagStartPattern = / < \s * m e n t i o n - (?: e n t r y | f e e d ) \b / gi
39+ const mentionTagCompletePattern = / ^ < \s * ( m e n t i o n - (?: e n t r y | f e e d ) ) / i
40+
41+ // Finds the end index of a mention tag (self-closing or paired) starting at `startIndex`.
42+ // Returns the index of the closing `>` when found outside of quotes; otherwise -1.
43+ const findMentionTagEnd = ( text : string , startIndex : number ) : number => {
44+ // Don't process if inside a complete code block
45+ if ( hasCompleteCodeBlock ( text ) ) return - 1
46+
47+ let inQuote : '"' | "'" | null = null
48+ let openingTagEnd = - 1
49+ for ( let i = startIndex ; i < text . length ; i ++ ) {
50+ const char = text [ i ]
51+ if ( inQuote ) {
52+ if ( char === inQuote && text [ i - 1 ] !== "\\" ) {
53+ inQuote = null
54+ }
55+ continue
56+ }
57+ if ( char === '"' || char === "'" ) {
58+ inQuote = char
59+ continue
60+ }
61+ if ( char === "/" && text [ i + 1 ] === ">" ) {
62+ return i + 1 // index of '>' in `/>`
63+ }
64+ if ( char === ">" ) {
65+ openingTagEnd = i
66+ break
67+ }
68+ }
69+
70+ if ( openingTagEnd === - 1 ) {
71+ return - 1
72+ }
73+
74+ const openingTag = text . substring ( startIndex , openingTagEnd + 1 )
75+ const tagNameMatch = openingTag . match ( mentionTagCompletePattern )
76+ if ( ! tagNameMatch ) {
77+ return - 1
78+ }
79+
80+ // If the tag is already self-closing (allow whitespace before `/`)
81+ if ( / \/ \s * > $ / . test ( openingTag ) ) {
82+ return openingTagEnd
83+ }
84+
85+ const tagName = ( tagNameMatch [ 1 ] ?? "" ) . toLowerCase ( )
86+ if ( ! tagName ) {
87+ return - 1
88+ }
89+ const afterOpening = text . substring ( openingTagEnd + 1 )
90+ const closingTagPattern = new RegExp ( `<\\s*/\\s*${ tagName } \\s*>` , "i" )
91+ const closingMatch = closingTagPattern . exec ( afterOpening )
92+
93+ if ( ! closingMatch ) {
94+ return - 1
95+ }
96+
97+ return openingTagEnd + 1 + closingMatch . index + closingMatch [ 0 ] . length - 1
98+ }
99+
100+ // Trims trailing, incomplete `<mention-entry ...>` or `<mention-feed ...>` tags to avoid
101+ // injecting broken raw HTML into markdown while streaming.
102+ const handleIncompleteMentionTags = ( text : string ) : string => {
103+ // Don't process if inside a complete code block
104+ if ( hasCompleteCodeBlock ( text ) ) {
105+ return text
106+ }
107+
108+ let cutIndex : number | null = null
109+ let match : RegExpExecArray | null
110+ mentionTagStartPattern . lastIndex = 0
111+ while ( ( match = mentionTagStartPattern . exec ( text ) ) ) {
112+ const start = match . index
113+ const end = findMentionTagEnd ( text , start )
114+ if ( end === - 1 ) {
115+ cutIndex = start
116+ break
117+ } else {
118+ // continue scanning after this complete tag
119+ mentionTagStartPattern . lastIndex = end + 1
120+ }
121+ }
122+
123+ if ( cutIndex !== null ) {
124+ const nextNewlineIndex = text . indexOf ( "\n" , cutIndex )
125+ if ( nextNewlineIndex !== - 1 ) {
126+ // Remove only the incomplete tag segment and preserve following lines
127+ return text . substring ( 0 , cutIndex ) + text . substring ( nextNewlineIndex )
128+ }
129+ // No newline after the incomplete tag; drop the trailing incomplete segment
130+ return text . substring ( 0 , cutIndex )
131+ }
132+ return text
133+ }
134+
135+ // Handles `<Use: ...>` wrappers that contain mention tags (self-closing or paired) by:
136+ // - Replacing the whole wrapper with only the inner `<mention-...>` when complete
137+ // - Trimming from `<Use:` if the inner mention tag is incomplete while streaming
138+ const handleUseWrapper = ( text : string ) : string => {
139+ // Don't process if inside a complete code block
140+ if ( hasCompleteCodeBlock ( text ) ) return text
141+
142+ const usePattern = / < \s * U s e : \s * / gi
143+ let result = text
144+ let match : RegExpExecArray | null
145+ usePattern . lastIndex = 0
146+
147+ // We rebuild iteratively in case of multiple occurrences
148+ while ( ( match = usePattern . exec ( result ) ) ) {
149+ const useStart = match . index
150+ const mentionStart = result . indexOf ( "<mention-" , useStart )
151+ if ( mentionStart === - 1 ) {
152+ // Incomplete `<Use:` without a mention yet → remove only the incomplete segment
153+ const nextNewlineIndex = result . indexOf ( "\n" , useStart )
154+ return nextNewlineIndex !== - 1
155+ ? result . substring ( 0 , useStart ) + result . substring ( nextNewlineIndex )
156+ : result . substring ( 0 , useStart )
157+ }
158+
159+ // Ensure mention is the immediate content of the Use wrapper (allow whitespace)
160+ const between = result . substring ( useStart + match [ 0 ] . length , mentionStart )
161+ if ( ! / ^ \s * $ / . test ( between ) ) {
162+ // Unexpected content between Use and mention → treat as plain text, continue
163+ continue
164+ }
165+
166+ const mentionEnd = findMentionTagEnd ( result , mentionStart )
167+ if ( mentionEnd === - 1 ) {
168+ // Mention not finished yet → remove only the incomplete wrapper segment
169+ const nextNewlineIndex = result . indexOf ( "\n" , useStart )
170+ return nextNewlineIndex !== - 1
171+ ? result . substring ( 0 , useStart ) + result . substring ( nextNewlineIndex )
172+ : result . substring ( 0 , useStart )
173+ }
174+
175+ // Replace `<Use: <mention-...>` with `<mention-...>`
176+ const before = result . substring ( 0 , useStart )
177+ const mentionTag = result . substring ( mentionStart , mentionEnd + 1 )
178+ const after = result . substring ( mentionEnd + 1 )
179+ result = before + mentionTag + after
180+
181+ // Reset the regex lastIndex to continue scanning after the replaced tag
182+ usePattern . lastIndex = before . length + mentionTag . length
183+ }
184+
185+ return result
186+ }
187+
37188// Helper function to check if we have a complete code block
38189const hasCompleteCodeBlock = ( text : string ) : boolean => {
39190 const tripleBackticks = ( text . match ( / ` ` ` / g) || [ ] ) . length
@@ -611,6 +762,24 @@ const handleIncompleteStrikethrough = (text: string): string => {
611762 return text
612763}
613764
765+ // Counts single dollar signs that are not part of double dollar signs and not escaped
766+ const _countSingleDollarSigns = ( text : string ) : number => {
767+ return text . split ( "" ) . reduce ( ( acc , char , index ) => {
768+ if ( char === "$" ) {
769+ const prevChar = text [ index - 1 ]
770+ const nextChar = text [ index + 1 ]
771+ // Skip if escaped with backslash
772+ if ( prevChar === "\\" ) {
773+ return acc
774+ }
775+ if ( prevChar !== "$" && nextChar !== "$" ) {
776+ return acc + 1
777+ }
778+ }
779+ return acc
780+ } , 0 )
781+ }
782+
614783// Completes incomplete block KaTeX formatting ($$)
615784const handleIncompleteBlockKatex = ( text : string ) : string => {
616785 // Count all $$ pairs in the text
@@ -717,6 +886,10 @@ export const parseIncompleteMarkdown = (text: string): string => {
717886 // Handle various formatting completions
718887 // Handle triple asterisks first (most specific)
719888 result = handleIncompleteBoldItalic ( result )
889+ // Normalize and guard the `<Use:` wrapper first so inner tags are handled correctly
890+ result = handleUseWrapper ( result )
891+ // Handle custom mention tags trimming before other single-character completions
892+ result = handleIncompleteMentionTags ( result )
720893 result = handleIncompleteBold ( result )
721894 result = handleIncompleteDoubleUnderscoreItalic ( result )
722895 result = handleIncompleteSingleAsteriskItalic ( result )
0 commit comments