@@ -89,6 +89,8 @@ const processor = unified().use(remarkParse)
8989
9090type MarkdownNode = Content | Root
9191
92+ type InlineFallbackNode = Text | Strong | Emphasis
93+
9294interface RenderState {
9395 palette : MarkdownPalette
9496 codeBlockWidth : number
@@ -175,6 +177,177 @@ const splitNodesByNewline = (nodes: ReactNode[]): ReactNode[][] => {
175177 return lines
176178}
177179
180+ const hasUnescapedMarker = ( value : string ) : boolean => {
181+ if ( ! value ) {
182+ return false
183+ }
184+ const markers = [ '**' , '__' , '*' , '_' ]
185+ return markers . some ( ( marker ) => {
186+ let idx = value . indexOf ( marker )
187+ while ( idx !== - 1 ) {
188+ let backslashes = 0
189+ for ( let offset = idx - 1 ; offset >= 0 && value [ offset ] === '\\' ; offset -= 1 ) {
190+ backslashes += 1
191+ }
192+ if ( backslashes % 2 === 0 ) {
193+ return true
194+ }
195+ idx = value . indexOf ( marker , idx + marker . length )
196+ }
197+ return false
198+ } )
199+ }
200+
201+ const findClosingDelimiter = (
202+ value : string ,
203+ start : number ,
204+ marker : string ,
205+ ) : number => {
206+ let idx = start
207+ while ( idx < value . length ) {
208+ idx = value . indexOf ( marker , idx )
209+ if ( idx === - 1 ) {
210+ return - 1
211+ }
212+ let backslashes = 0
213+ for ( let offset = idx - 1 ; offset >= 0 && value [ offset ] === '\\' ; offset -= 1 ) {
214+ backslashes += 1
215+ }
216+ if ( backslashes % 2 === 0 ) {
217+ return idx
218+ }
219+ idx += marker . length
220+ }
221+ return - 1
222+ }
223+
224+ /**
225+ * Remark follows CommonMark's emphasis rules, which ignore some practical
226+ * patterns (e.g., `Other**.github/**`). This fallback splits leftover text
227+ * nodes on emphasis markers so we still render inline styling in those cases.
228+ */
229+ const parseInlineFallback = ( value : string ) : InlineFallbackNode [ ] => {
230+ if ( ! value || ! hasUnescapedMarker ( value ) ) {
231+ return [ { type : 'text' , value } ]
232+ }
233+
234+ const nodes : InlineFallbackNode [ ] = [ ]
235+ let buffer = ''
236+
237+ const flushBuffer = ( ) => {
238+ if ( buffer . length > 0 ) {
239+ nodes . push ( { type : 'text' , value : buffer } )
240+ buffer = ''
241+ }
242+ }
243+
244+ let index = 0
245+ while ( index < value . length ) {
246+ const char = value [ index ]
247+
248+ if ( char === '*' || char === '_' ) {
249+ const markerChar = char
250+ const isDouble =
251+ index + 1 < value . length && value [ index + 1 ] === markerChar
252+ const marker = isDouble ? markerChar . repeat ( 2 ) : markerChar
253+ const markerLength = marker . length
254+
255+ let backslashes = 0
256+ for (
257+ let offset = index - 1 ;
258+ offset >= 0 && value [ offset ] === '\\' ;
259+ offset -= 1
260+ ) {
261+ backslashes += 1
262+ }
263+
264+ if ( backslashes % 2 === 1 ) {
265+ buffer += marker
266+ index += markerLength
267+ continue
268+ }
269+
270+ const closing = findClosingDelimiter ( value , index + markerLength , marker )
271+ if ( closing === - 1 ) {
272+ buffer += marker
273+ index += markerLength
274+ continue
275+ }
276+
277+ const inner = value . slice ( index + markerLength , closing )
278+ flushBuffer ( )
279+ const children = parseInlineFallback ( inner ) . filter (
280+ ( node ) => ! ( node . type === 'text' && node . value . length === 0 ) ,
281+ )
282+
283+ const emphasisNode : InlineFallbackNode =
284+ isDouble && markerChar === '*'
285+ ? { type : 'strong' , children }
286+ : isDouble && markerChar === '_'
287+ ? { type : 'strong' , children }
288+ : { type : 'emphasis' , children }
289+
290+ nodes . push ( emphasisNode )
291+ index = closing + markerLength
292+ continue
293+ }
294+
295+ buffer += char
296+ index += 1
297+ }
298+
299+ flushBuffer ( )
300+
301+ if ( nodes . length === 0 ) {
302+ return [ { type : 'text' , value } ]
303+ }
304+
305+ return nodes
306+ }
307+
308+ const applyInlineFallbackFormatting = ( node : MarkdownNode ) : void => {
309+ if ( ! node || typeof node !== 'object' ) {
310+ return
311+ }
312+
313+ const mutable = node as { children ?: MarkdownNode [ ] }
314+ if ( ! Array . isArray ( mutable . children ) ) {
315+ return
316+ }
317+
318+ const nextChildren : MarkdownNode [ ] = [ ]
319+
320+ mutable . children . forEach ( ( child ) => {
321+ if ( child . type === 'text' ) {
322+ const replacements = parseInlineFallback ( child . value )
323+ const hasChanges =
324+ replacements . length !== 1 ||
325+ replacements [ 0 ] . type !== 'text' ||
326+ replacements [ 0 ] . value !== child . value
327+
328+ if ( hasChanges ) {
329+ replacements . forEach ( ( replacement ) => {
330+ if ( replacement . type === 'text' ) {
331+ nextChildren . push ( replacement )
332+ } else {
333+ applyInlineFallbackFormatting (
334+ replacement as unknown as MarkdownNode ,
335+ )
336+ nextChildren . push ( replacement as unknown as MarkdownNode )
337+ }
338+ } )
339+ return
340+ }
341+ } else {
342+ applyInlineFallbackFormatting ( child as MarkdownNode )
343+ }
344+
345+ nextChildren . push ( child as MarkdownNode )
346+ } )
347+
348+ mutable . children = nextChildren
349+ }
350+
178351const nodeToPlainText = ( node : MarkdownNode ) : string => {
179352 switch ( node . type ) {
180353 case 'root' :
@@ -609,6 +782,7 @@ export function renderMarkdown(
609782 const codeBlockWidth = options . codeBlockWidth ?? 80
610783 const state = createRenderState ( palette , codeBlockWidth )
611784 const ast = processor . parse ( markdown ) as Root
785+ applyInlineFallbackFormatting ( ast )
612786 const nodes = renderNode ( ast , state , ast . type , undefined )
613787 return normalizeOutput ( nodes )
614788 } catch ( error ) {
0 commit comments