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
14 changes: 8 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@graphql-typed-document-node/core": "^3.2.0",
"@hookform/resolvers": "^4.1.3",
"@mdxeditor/editor": "^3.32.3",
"@middy/core": "^6.4.4",
"@neondatabase/serverless": "^0.10.4",
"@next/env": "^15.1.6",
Expand All @@ -63,10 +62,14 @@
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-query": "^5.67.1",
"@theguild/remark-mermaid": "^0.2.0",
"@tiptap/extension-bubble-menu": "^2.5.8",
"@tiptap/pm": "^2.11.3",
"@tiptap/react": "^2.11.3",
"@tiptap/starter-kit": "^2.11.2",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-bold": "^3.29.2",
"@tiptap/extension-underline": "^3.29.2",
"@tiptap/extensions": "^3.29.2",
"@tiptap/markdown": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2",
"@uploadthing/react": "^7.3.0",
"@vercel/toolbar": "^0.1.30",
"@vitejs/plugin-react": "^4.3.4",
Expand Down Expand Up @@ -118,7 +121,6 @@
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"tiny-invariant": "^1.3.3",
"tiptap-markdown": "^0.8.10",
"tslib": "^2.8.1",
"turbo": "^2.5.4",
"uploadthing": "^7.5.2",
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/matrix/__tests__/chat-markup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildRichReplyMatrixContent,
chatMarkupLooksFormatted,
chatMarkupToHtml,
matrixTextEventContentWithOptionalFormatting,
parseChatMarkup,
} from '../chat-markup';
Expand All @@ -15,6 +16,13 @@ describe('chatMarkupLooksFormatted', () => {
it('detects bold', () => {
expect(chatMarkupLooksFormatted('a **b** c')).toBe(true);
});
it('detects lists and headings', () => {
expect(chatMarkupLooksFormatted('- one\n- two')).toBe(true);
expect(chatMarkupLooksFormatted('# Title')).toBe(true);
});
it('detects underline', () => {
expect(chatMarkupLooksFormatted('__hi__')).toBe(true);
});
});

describe('matrixTextEventContentWithOptionalFormatting', () => {
Expand All @@ -28,13 +36,47 @@ describe('matrixTextEventContentWithOptionalFormatting', () => {
expect('formatted_body' in r && r.formatted_body).toContain('<strong>');
expect('formatted_body' in r && r.formatted_body).toContain('hi');
});
it('adds lists and headings to formatted_body', () => {
const r = matrixTextEventContentWithOptionalFormatting(
'# Hello\n- one\n- two\n1. a',
);
expect('formatted_body' in r && r.formatted_body).toContain('<h1>');
expect('formatted_body' in r && r.formatted_body).toContain('<ul>');
expect('formatted_body' in r && r.formatted_body).toContain('<ol>');
expect('formatted_body' in r && r.formatted_body).toContain('<li>');
});
it('adds underline to formatted_body', () => {
const r = matrixTextEventContentWithOptionalFormatting('__hi__');
expect('formatted_body' in r && r.formatted_body).toContain('<u>');
});
});

describe('parseChatMarkup', () => {
it('parses nested bold and italic', () => {
const nodes = parseChatMarkup('**a *b* c**');
expect(JSON.stringify(nodes)).toContain('bold');
});
it('parses unordered lists', () => {
const nodes = parseChatMarkup('- one\n- two');
expect(nodes).toEqual([
{
type: 'ul',
items: [
[{ type: 'text', value: 'one' }],
[{ type: 'text', value: 'two' }],
],
},
]);
});
});

describe('chatMarkupToHtml', () => {
it('renders a heading and bullet list', () => {
const html = chatMarkupToHtml('## Title\n- a\n- b');
expect(html).toContain('<h2>');
expect(html).toContain('<ul>');
expect(html).toContain('<li>a</li>');
});
});

describe('buildRichReplyMatrixContent', () => {
Expand Down
121 changes: 112 additions & 9 deletions packages/core/src/matrix/chat-markup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Discord-like plaintext markup for chat: **bold**, *italic*, ~~strike~~,
* `code`, ||spoiler||, and > blockquote lines. Used for composer → Matrix HTML
* and timeline rendering.
* Discord-like plaintext markup for chat: **bold**, *italic*, __underline__,
* ~~strike~~, `code`, ||spoiler||, > blockquotes, # headings, and - / 1. lists.
* Used for composer → Matrix HTML and timeline rendering.
*/

import {
Expand All @@ -14,11 +14,15 @@ export type MarkupNode =
| { type: 'text'; value: string }
| { type: 'bold'; children: MarkupNode[] }
| { type: 'italic'; children: MarkupNode[] }
| { type: 'underline'; children: MarkupNode[] }
| { type: 'strike'; children: MarkupNode[] }
| { type: 'code'; value: string }
| { type: 'spoiler'; children: MarkupNode[] }
| { type: 'linebreak' }
| { type: 'blockquote'; children: MarkupNode[] };
| { type: 'blockquote'; children: MarkupNode[] }
| { type: 'heading'; level: 1 | 2 | 3 | 4; children: MarkupNode[] }
| { type: 'ul'; items: MarkupNode[][] }
| { type: 'ol'; items: MarkupNode[][] };

function escapeHtml(s: string): string {
return s
Expand All @@ -38,13 +42,35 @@ export function chatMarkupLooksFormatted(plain: string): boolean {
/~~/.test(plain) ||
/\|\|/.test(plain) ||
/`/.test(plain) ||
/__/.test(plain) ||
/(^|\n)>\s?/m.test(plain) ||
/(^|\n)#{1,4}\s+\S/m.test(plain) ||
/(^|\n)(\s{0,3})([-*+]|\d+\.)\s+\S/m.test(plain) ||
/\*[^*\n]+\*/.test(plain)
);
}

function parseHeadingLine(
line: string,
): { level: 1 | 2 | 3 | 4; text: string } | null {
const match = line.match(/^(#{1,4})\s+(.*)$/);
if (!match) return null;
const level = match[1]!.length as 1 | 2 | 3 | 4;
return { level, text: match[2] ?? '' };
Comment thread
n0umen0n marked this conversation as resolved.
}

function parseUnorderedListItem(line: string): string | null {
const match = line.match(/^(\s{0,3})[-*+]\s+(.*)$/);
return match ? match[2] ?? '' : null;
}

function parseOrderedListItem(line: string): string | null {
const match = line.match(/^(\s{0,3})\d+\.\s+(.*)$/);
return match ? match[2] ?? '' : null;
}

/**
* Parse block structure (lines, blockquotes) then inline marks.
* Parse block structure (lines, blockquotes, headings, lists) then inline marks.
*/
export function parseChatMarkup(plain: string): MarkupNode[] {
const normalized = plain.replace(/\r\n/g, '\n');
Expand Down Expand Up @@ -87,6 +113,41 @@ export function parseChatMarkup(plain: string): MarkupNode[] {
continue;
}

const heading = parseHeadingLine(line);
if (heading) {
out.push({
type: 'heading',
level: heading.level,
children: parseInlineMarkup(heading.text),
});
i += 1;
continue;
}

const ulItems: MarkupNode[][] = [];
while (i < lines.length) {
const item = parseUnorderedListItem(lines[i]!);
if (item === null) break;
ulItems.push(parseInlineMarkup(item));
i += 1;
}
if (ulItems.length > 0) {
out.push({ type: 'ul', items: ulItems });
continue;
}

const olItems: MarkupNode[][] = [];
while (i < lines.length) {
const item = parseOrderedListItem(lines[i]!);
if (item === null) break;
olItems.push(parseInlineMarkup(item));
i += 1;
}
if (olItems.length > 0) {
out.push({ type: 'ol', items: olItems });
continue;
}

out.push(...parseInlineMarkup(line));
if (i < lines.length - 1) {
out.push({ type: 'linebreak' });
Expand All @@ -98,7 +159,13 @@ export function parseChatMarkup(plain: string): MarkupNode[] {
return out;
}

type DelimKind = 'code' | 'spoiler' | 'bold' | 'strike' | 'italic';
type DelimKind =
| 'code'
| 'spoiler'
| 'bold'
| 'underline'
| 'strike'
| 'italic';

function findNextDelimiter(
s: string,
Expand Down Expand Up @@ -127,6 +194,10 @@ function findNextDelimiter(
consider('bold', idx, 2);
break;
}
if (s.slice(idx, idx + 2) === '__') {
consider('underline', idx, 2);
break;
}
if (s.slice(idx, idx + 2) === '~~') {
consider('strike', idx, 2);
break;
Expand Down Expand Up @@ -163,6 +234,10 @@ function findClosingDelimiter(
const i = s.indexOf('**', innerStart);
return i === -1 ? null : { innerEnd: i, len: 2 };
}
case 'underline': {
const i = s.indexOf('__', innerStart);
return i === -1 ? null : { innerEnd: i, len: 2 };
}
case 'strike': {
const i = s.indexOf('~~', innerStart);
return i === -1 ? null : { innerEnd: i, len: 2 };
Expand Down Expand Up @@ -217,6 +292,8 @@ function parseInlineMarkup(s: string): MarkupNode[] {
? ({ type: 'bold', children } as MarkupNode)
: kind === 'italic'
? ({ type: 'italic', children } as MarkupNode)
: kind === 'underline'
? ({ type: 'underline', children } as MarkupNode)
: kind === 'strike'
? ({ type: 'strike', children } as MarkupNode)
: ({ type: 'spoiler', children } as MarkupNode);
Expand All @@ -227,7 +304,7 @@ function parseInlineMarkup(s: string): MarkupNode[] {
return nodes;
}

function nodesToHtml(nodes: MarkupNode[]): string {
export function nodesToHtml(nodes: MarkupNode[]): string {
const parts: string[] = [];
for (const n of nodes) {
switch (n.type) {
Expand All @@ -246,6 +323,9 @@ function nodesToHtml(nodes: MarkupNode[]): string {
case 'italic':
parts.push('<em>', nodesToHtml(n.children), '</em>');
break;
case 'underline':
parts.push('<u>', nodesToHtml(n.children), '</u>');
break;
case 'strike':
parts.push('<del>', nodesToHtml(n.children), '</del>');
break;
Expand All @@ -259,13 +339,37 @@ function nodesToHtml(nodes: MarkupNode[]): string {
case 'blockquote':
parts.push('<blockquote>', nodesToHtml(n.children), '</blockquote>');
break;
case 'heading': {
const tag = `h${n.level}`;
parts.push(`<${tag}>`, nodesToHtml(n.children), `</${tag}>`);
break;
}
case 'ul':
parts.push(
'<ul>',
...n.items.map((item) => `<li>${nodesToHtml(item)}</li>`),
'</ul>',
);
break;
case 'ol':
parts.push(
'<ol>',
...n.items.map((item) => `<li>${nodesToHtml(item)}</li>`),
'</ol>',
);
break;
default:
break;
}
}
return parts.join('');
}

/** Parse chat markup to Matrix HTML (always; caller decides when to attach). */
export function chatMarkupToHtml(plain: string): string {
return nodesToHtml(parseChatMarkup(plain)).trim();
}

/**
* If markup is present, returns Matrix `format` + `formatted_body` alongside `body`.
*/
Expand All @@ -280,8 +384,7 @@ export function matrixTextEventContentWithOptionalFormatting(body: string):
if (!trimmed || !chatMarkupLooksFormatted(body)) {
return { body };
}
const tree = parseChatMarkup(body);
const html = nodesToHtml(tree).trim();
const html = chatMarkupToHtml(body);
if (!html) {
return { body };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ export function CreateAgreementBaseFields({
<div className="overflow-hidden rounded-lg border border-border/80 bg-background-2 shadow-inner focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background-2">
<RichTextEditor
editorRef={null}
bordered={false}
markdown={descriptionValue}
translation={translateEditor}
placeholder={contentPlaceholder}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,7 @@ export const CreateSignalForm = ({
<div className="overflow-hidden rounded-lg border border-border/80 bg-background-2 shadow-inner focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background-2">
<RichTextEditor
editorRef={null}
bordered={false}
markdown={descriptionValue}
translation={translateEditor}
placeholder={t('descriptionPlaceholder')}
Expand Down
21 changes: 21 additions & 0 deletions packages/epics/src/common/ai-panel/ai-panel-message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,27 @@ function renderInlineMarkdown(text: string): React.ReactNode {
continue;
}

if (token.type === 'italic') {
nodes.push(
<em key={`md-inline-${partIndex++}`} className="italic">
{token.value}
</em>,
);
continue;
}

if (token.type === 'underline') {
nodes.push(
<u
key={`md-inline-${partIndex++}`}
className="underline underline-offset-2"
>
{token.value}
</u>,
);
continue;
}

if (token.type === 'inlineCode') {
nodes.push(
<code
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { parseSimpleMatrixHtml } from '../parse-simple-matrix-html';

describe('parseSimpleMatrixHtml', () => {
it('parses headings, lists, and underline', () => {
const nodes = parseSimpleMatrixHtml(
'<h2>Title</h2><ul><li>one</li><li><strong>two</strong></li></ul><ol><li>a</li></ol><u>under</u>',
);
expect(nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'heading', level: 2 }),
expect.objectContaining({ type: 'ul' }),
expect.objectContaining({ type: 'ol' }),
expect.objectContaining({ type: 'underline' }),
]),
);
});
});
Loading
Loading