Skip to content

Commit f9e89b9

Browse files
committed
Fix inline emphasis in markdown headings
1 parent 0ce4097 commit f9e89b9

File tree

2 files changed

+192
-0
lines changed

2 files changed

+192
-0
lines changed

cli/src/utils/__tests__/markdown-renderer.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,24 @@ describe('markdown renderer', () => {
8383
expect(flattenChildren(heading.props.children)).toEqual(['Heading One'])
8484
})
8585

86+
test('renders inline emphasis inside headings without extra spacing', () => {
87+
const output = renderMarkdown(
88+
'# Other**.github/** - GitHub workflows and config',
89+
)
90+
const nodes = flattenNodes(output)
91+
92+
const heading = nodes[0] as React.ReactElement
93+
const contents = flattenChildren(heading.props.children)
94+
95+
expect(contents[0]).toBe('Other')
96+
97+
const strong = contents[1] as React.ReactElement
98+
expect(strong.props.attributes).toBe(TextAttributes.BOLD)
99+
expect(flattenChildren(strong.props.children)).toEqual(['.github/'])
100+
101+
expect(contents[2]).toBe(' - GitHub workflows and config')
102+
})
103+
86104
test('renders blockquotes with prefix', () => {
87105
const output = renderMarkdown('> note')
88106
const nodes = flattenNodes(output)

cli/src/utils/markdown-renderer.tsx

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ const processor = unified().use(remarkParse)
8989

9090
type MarkdownNode = Content | Root
9191

92+
type InlineFallbackNode = Text | Strong | Emphasis
93+
9294
interface 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+
178351
const 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

Comments
 (0)