Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/editor/demoPlan.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const DEMO_PLAN_CONTENT = `# Implementation Plan: Real-time Collaboration

## Overview
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)*.
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)*.

## Phase 1: Infrastructure

Expand Down
45 changes: 45 additions & 0 deletions packages/review-editor/utils/renderInlineMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from "bun:test";
import { renderInlineMarkdown } from "./renderInlineMarkdown";

const isElement = (node: unknown): node is { type: string; props: { children?: unknown } } =>
typeof node === "object" && node !== null && "type" in node && "props" in node;

const types = (nodes: unknown[]) => nodes.map((node) => (isElement(node) ? node.type : typeof node));

describe("renderInlineMarkdown", () => {
it("renders underscore emphasis", () => {
const nodes = renderInlineMarkdown("_text_");
expect(nodes).toHaveLength(1);
expect(isElement(nodes[0])).toBe(true);
expect((nodes[0] as { type: string; props: { children?: unknown } }).type).toBe("em");
expect((nodes[0] as { type: string; props: { children?: unknown } }).props.children).toBe("text");
});

it("renders underscore emphasis in context", () => {
const nodes = renderInlineMarkdown("foo _bar_ baz");
expect(nodes.filter(isElement)).toHaveLength(1);
expect(types(nodes)).toContain("em");
expect((nodes.find(isElement) as { type: string; props: { children?: unknown } }).props.children).toBe("bar");
expect(nodes.filter((node) => typeof node === "string").join("")).toBe("foo baz");
});

it("keeps intraword underscores literal", () => {
expect(renderInlineMarkdown("snake_case")).toEqual(["snake_case"]);
expect(renderInlineMarkdown("foo_bar_baz")).toEqual(["foo_bar_baz"]);
expect(renderInlineMarkdown("__init__")).toEqual(["__init__"]);
});

it("renders underscore emphasis after other inline tokens", () => {
const boldNodes = renderInlineMarkdown("**bold**_italic_");
expect(types(boldNodes)).toEqual(["strong", "em"]);
expect((boldNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");

const codeNodes = renderInlineMarkdown("`code`_italic_");
expect(types(codeNodes)).toEqual(["code", "em"]);
expect((codeNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");

const linkNodes = renderInlineMarkdown("[link](https://example.com)_italic_");
expect(types(linkNodes)).toEqual(["a", "em"]);
expect((linkNodes[1] as { type: string; props: { children?: unknown } }).props.children).toBe("italic");
});
});
9 changes: 6 additions & 3 deletions packages/review-editor/utils/renderInlineMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

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

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

Expand All @@ -59,6 +59,9 @@ function renderInline(text: string, startKey: number): React.ReactNode[] {
nodes.push(<code key={key++} className="inline-code">{token.slice(1, -1)}</code>);
} else if (token.startsWith('**')) {
nodes.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
} else if (token.startsWith('_')) {
const italicText = match[4];
nodes.push(<em key={key++}>{italicText}</em>);
} else if (token.startsWith('*')) {
nodes.push(<em key={key++}>{token.slice(1, -1)}</em>);
} else if (token.startsWith('http')) {
Expand Down
29 changes: 25 additions & 4 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -763,27 +763,40 @@ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }>
};

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

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

// Italic: *text*
// Italic: *text* or _text_ (avoid intraword underscores)
match = remaining.match(/^\*([\s\S]+?)\*/);
if (match) {
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

match = !/\w/.test(previousChar)
? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
: null;
if (match) {
parts.push(<em key={key++}><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={match[1]} onOpenLinkedDoc={onOpenLinkedDoc} /></em>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

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

Expand Down Expand Up @@ -830,6 +844,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
);
}
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

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

Expand Down Expand Up @@ -905,6 +921,7 @@ const InlineMarkdown: React.FC<{ text: string; onOpenLinkedDoc?: (path: string)
);
}
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

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

// Find next special character or consume one regular character
const nextSpecial = remaining.slice(1).search(/[\*`\[!]/);
const nextSpecial = remaining.slice(1).search(/[\*_`\[!]/);
if (nextSpecial === -1) {
parts.push(remaining);
previousChar = remaining[remaining.length - 1] || previousChar;
break;
} else {
parts.push(remaining.slice(0, nextSpecial + 1));
const plainText = remaining.slice(0, nextSpecial + 1);
parts.push(plainText);
remaining = remaining.slice(nextSpecial + 1);
previousChar = plainText[plainText.length - 1] || previousChar;
}
}

Expand Down
25 changes: 22 additions & 3 deletions packages/ui/components/plan-diff/PlanCleanDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ const InlineMarkdown: React.FC<{ text: string }> = ({ text }) => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
let previousChar = "";

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

// Italic: *text*
// Italic: *text* or _text_ (avoid intraword underscores)
match = remaining.match(/^\*([\s\S]+?)\*/);
if (match) {
parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

match = !/\w/.test(previousChar)
? remaining.match(/^_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)/)
: null;
if (match) {
parts.push(<em key={key++}><InlineMarkdown text={match[1]} /></em>);
remaining = remaining.slice(match[0].length);
previousChar = match[0][match[0].length - 1] || previousChar;
continue;
}

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

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

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

const nextSpecial = remaining.slice(1).search(/[*`\[!]/);
const nextSpecial = remaining.slice(1).search(/[\*_`\[!]/);
if (nextSpecial === -1) {
parts.push(remaining);
previousChar = remaining[remaining.length - 1] || previousChar;
break;
} else {
parts.push(remaining.slice(0, nextSpecial + 1));
const plainText = remaining.slice(0, nextSpecial + 1);
parts.push(plainText);
remaining = remaining.slice(nextSpecial + 1);
previousChar = plainText[plainText.length - 1] || previousChar;
}
}

Expand Down