Skip to content

Commit b7af16a

Browse files
authored
fix: support underscore italics in markdown renderer (#504)
1 parent 76221b9 commit b7af16a

5 files changed

Lines changed: 99 additions & 11 deletions

File tree

packages/editor/demoPlan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration
22
33
## Overview
4-
Add real-time collaboration features to the editor using **[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)** and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*.
4+
Add real-time collaboration features to the editor using _**[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)**_ and *[operational transforms](https://en.wikipedia.org/wiki/Operational_transformation)*.
55
66
## Phase 1: Infrastructure
77
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { renderInlineMarkdown } from "./renderInlineMarkdown";
3+
4+
const isElement = (node: unknown): node is { type: string; props: { children?: unknown } } =>
5+
typeof node === "object" && node !== null && "type" in node && "props" in node;
6+
7+
const types = (nodes: unknown[]) => nodes.map((node) => (isElement(node) ? node.type : typeof node));
8+
9+
describe("renderInlineMarkdown", () => {
10+
it("renders underscore emphasis", () => {
11+
const nodes = renderInlineMarkdown("_text_");
12+
expect(nodes).toHaveLength(1);
13+
expect(isElement(nodes[0])).toBe(true);
14+
expect((nodes[0] as { type: string; props: { children?: unknown } }).type).toBe("em");
15+
expect((nodes[0] as { type: string; props: { children?: unknown } }).props.children).toBe("text");
16+
});
17+
18+
it("renders underscore emphasis in context", () => {
19+
const nodes = renderInlineMarkdown("foo _bar_ baz");
20+
expect(nodes.filter(isElement)).toHaveLength(1);
21+
expect(types(nodes)).toContain("em");
22+
expect((nodes.find(isElement) as { type: string; props: { children?: unknown } }).props.children).toBe("bar");
23+
expect(nodes.filter((node) => typeof node === "string").join("")).toBe("foo baz");
24+
});
25+
26+
it("keeps intraword underscores literal", () => {
27+
expect(renderInlineMarkdown("snake_case")).toEqual(["snake_case"]);
28+
expect(renderInlineMarkdown("foo_bar_baz")).toEqual(["foo_bar_baz"]);
29+
expect(renderInlineMarkdown("__init__")).toEqual(["__init__"]);
30+
});
31+
32+
it("renders underscore emphasis after other inline tokens", () => {
33+
const boldNodes = renderInlineMarkdown("**bold**_italic_");
34+
expect(types(boldNodes)).toEqual(["strong", "em"]);
35+
expect((boldNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");
36+
37+
const codeNodes = renderInlineMarkdown("`code`_italic_");
38+
expect(types(codeNodes)).toEqual(["code", "em"]);
39+
expect((codeNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");
40+
41+
const linkNodes = renderInlineMarkdown("[link](https://example.com)_italic_");
42+
expect(types(linkNodes)).toEqual(["a", "em"]);
43+
expect((linkNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");
44+
});
45+
});

packages/review-editor/utils/renderInlineMarkdown.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22

33
/**
4-
* Renders simple inline markdown: `code`, **bold**, *italic*, and
4+
* Renders simple inline markdown: `code`, **bold**, *italic*, _italic_, and
55
* fenced code blocks (```...```). Enough for review comments.
66
*/
77
export function renderInlineMarkdown(text: string): React.ReactNode[] {
@@ -36,8 +36,8 @@ function renderInline(text: string, startKey: number): React.ReactNode[] {
3636
const nodes: React.ReactNode[] = [];
3737
let key = startKey;
3838

39-
// Match inline patterns: [text](url), `code`, **bold**, *italic*, bare URLs
40-
const regex = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|https?:\/\/[^\s<)\]]+)/g;
39+
// Match inline patterns: [text](url), `code`, **bold**, *italic*, _italic_, bare URLs
40+
const regex = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|`[^`]+`|\*\*[^*]+\*\*|(?<!\w)_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)|\*[^*]+\*|https?:\/\/[^\s<)\]]+)/g;
4141
let lastIndex = 0;
4242
let match: RegExpExecArray | null;
4343

@@ -59,6 +59,9 @@ function renderInline(text: string, startKey: number): React.ReactNode[] {
5959
nodes.push(<code key={key++} className="inline-code">{token.slice(1, -1)}</code>);
6060
} else if (token.startsWith('**')) {
6161
nodes.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
62+
} else if (token.startsWith('_')) {
63+
const italicText = match[4];
64+
nodes.push(<em key={key++}>{italicText}</em>);
6265
} else if (token.startsWith('*')) {
6366
nodes.push(<em key={key++}>{token.slice(1, -1)}</em>);
6467
} else if (token.startsWith('http')) {

packages/ui/components/Viewer.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -763,27 +763,40 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }>
763763
};
764764

765765
/**
766-
* Renders inline markdown: **bold**, *italic*, `code`, [links](url)
766+
* Renders inline markdown: **bold**, *italic*, _italic_, `code`, [links](url)
767767
*/
768768
const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string) => void; imageBaseDir?: string; onImageClick?: (src: string, alt: string) => void }> = ({ text, onOpenLinkedDoc, imageBaseDir, onImageClick }) => {
769769
const parts: React.ReactNode[] = [];
770770
let remaining = text;
771771
let key = 0;
772+
let previousChar = '';
772773

773774
while (remaining.length > 0) {
774775
// Bold: **text** ([\s\S]+? allows matching across hard line breaks)
775776
let match = remaining.match(/^\*\*([\s\S]+?)\*\*/);
776777
if (match) {
777778
parts.push(<strong key={key++} className="font-semibold"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></strong>);
778779
remaining = remaining.slice(match[0].length);
780+
previousChar = match[0][match[0].length - 1] || previousChar;
779781
continue;
780782
}
781783

782-
// Italic: *text*
784+
// Italic: *text* or _text_ (avoid intraword underscores)
783785
match = remaining.match(/^\*([\s\S]+?)\*/);
784786
if (match) {
785787
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
786788
remaining = remaining.slice(match[0].length);
789+
previousChar = match[0][match[0].length - 1] || previousChar;
790+
continue;
791+
}
792+
793+
match = !/\w/.test(previousChar)
794+
? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
795+
: null;
796+
if (match) {
797+
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
798+
remaining = remaining.slice(match[0].length);
799+
previousChar = match[0][match[0].length - 1] || previousChar;
787800
continue;
788801
}
789802

@@ -796,6 +809,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
796809
</code>
797810
);
798811
remaining = remaining.slice(match[0].length);
812+
previousChar = match[0][match[0].length - 1] || previousChar;
799813
continue;
800814
}
801815

@@ -830,6 +844,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
830844
);
831845
}
832846
remaining = remaining.slice(match[0].length);
847+
previousChar = match[0][match[0].length - 1] || previousChar;
833848
continue;
834849
}
835850

@@ -850,6 +865,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
850865
/>
851866
);
852867
remaining = remaining.slice(match[0].length);
868+
previousChar = match[0][match[0].length - 1] || previousChar;
853869
continue;
854870
}
855871

@@ -905,6 +921,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
905921
);
906922
}
907923
remaining = remaining.slice(match[0].length);
924+
previousChar = match[0][match[0].length - 1] || previousChar;
908925
continue;
909926
}
910927

@@ -917,17 +934,21 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
917934
}
918935
parts.push(<br key={key++} />);
919936
remaining = remaining.slice(match.index + match[0].length);
937+
previousChar = '\n';
920938
continue;
921939
}
922940

923941
// Find next special character or consume one regular character
924-
const nextSpecial = remaining.slice(1).search(/[\*`\[!]/);
942+
const nextSpecial = remaining.slice(1).search(/[\*_`\[!]/);
925943
if (nextSpecial === -1) {
926944
parts.push(remaining);
945+
previousChar = remaining[remaining.length - 1] || previousChar;
927946
break;
928947
} else {
929-
parts.push(remaining.slice(0, nextSpecial + 1));
948+
const plainText = remaining.slice(0, nextSpecial + 1);
949+
parts.push(plainText);
930950
remaining = remaining.slice(nextSpecial + 1);
951+
previousChar = plainText[plainText.length - 1] || previousChar;
931952
}
932953
}
933954

packages/ui/components/plan-diff/PlanCleanDiffView.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
587587
const parts: React.ReactNode[] = [];
588588
let remaining = text;
589589
let key = 0;
590+
let previousChar = "";
590591

591592
while (remaining.length > 0) {
592593
// Bold: **text** ([\s\S]+? allows matching across hard line breaks)
@@ -598,14 +599,26 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
598599
</strong>
599600
);
600601
remaining = remaining.slice(match[0].length);
602+
previousChar = match[0][match[0].length - 1] || previousChar;
601603
continue;
602604
}
603605

604-
// Italic: *text*
606+
// Italic: *text* or _text_ (avoid intraword underscores)
605607
match = remaining.match(/^\*([\s\S]+?)\*/);
606608
if (match) {
607609
parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
608610
remaining = remaining.slice(match[0].length);
611+
previousChar = match[0][match[0].length - 1] || previousChar;
612+
continue;
613+
}
614+
615+
match = !/\w/.test(previousChar)
616+
? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
617+
: null;
618+
if (match) {
619+
parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
620+
remaining = remaining.slice(match[0].length);
621+
previousChar = match[0][match[0].length - 1] || previousChar;
609622
continue;
610623
}
611624

@@ -620,6 +633,7 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
620633
</code>
621634
);
622635
remaining = remaining.slice(match[0].length);
636+
previousChar = match[0][match[0].length - 1] || previousChar;
623637
continue;
624638
}
625639

@@ -637,6 +651,7 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
637651
</a>
638652
);
639653
remaining = remaining.slice(match[0].length);
654+
previousChar = match[0][match[0].length - 1] || previousChar;
640655
continue;
641656
}
642657

@@ -649,16 +664,20 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
649664
}
650665
parts.push(<br key={key++} />);
651666
remaining = remaining.slice(match.index + match[0].length);
667+
previousChar = "\n";
652668
continue;
653669
}
654670

655-
const nextSpecial = remaining.slice(1).search(/[*`\[!]/);
671+
const nextSpecial = remaining.slice(1).search(/[\*_`\[!]/);
656672
if (nextSpecial === -1) {
657673
parts.push(remaining);
674+
previousChar = remaining[remaining.length - 1] || previousChar;
658675
break;
659676
} else {
660-
parts.push(remaining.slice(0, nextSpecial + 1));
677+
const plainText = remaining.slice(0, nextSpecial + 1);
678+
parts.push(plainText);
661679
remaining = remaining.slice(nextSpecial + 1);
680+
previousChar = plainText[plainText.length - 1] || previousChar;
662681
}
663682
}
664683

0 commit comments

Comments
 (0)