@extrachill/chat 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/css/chat.css CHANGED
@@ -149,6 +149,89 @@
149
149
  margin-top: 8px;
150
150
  }
151
151
 
152
+ /* Markdown content styles */
153
+ .ec-chat-message__bubble h1,
154
+ .ec-chat-message__bubble h2,
155
+ .ec-chat-message__bubble h3,
156
+ .ec-chat-message__bubble h4,
157
+ .ec-chat-message__bubble h5,
158
+ .ec-chat-message__bubble h6 {
159
+ margin: 12px 0 4px;
160
+ line-height: 1.3;
161
+ }
162
+
163
+ .ec-chat-message__bubble h1:first-child,
164
+ .ec-chat-message__bubble h2:first-child,
165
+ .ec-chat-message__bubble h3:first-child {
166
+ margin-top: 0;
167
+ }
168
+
169
+ .ec-chat-message__bubble h1 { font-size: 1.25em; }
170
+ .ec-chat-message__bubble h2 { font-size: 1.15em; }
171
+ .ec-chat-message__bubble h3 { font-size: 1.05em; }
172
+
173
+ .ec-chat-message__bubble ul,
174
+ .ec-chat-message__bubble ol {
175
+ margin: 6px 0;
176
+ padding-left: 1.5em;
177
+ }
178
+
179
+ .ec-chat-message__bubble li {
180
+ margin-bottom: 2px;
181
+ }
182
+
183
+ .ec-chat-message__bubble li + li {
184
+ margin-top: 2px;
185
+ }
186
+
187
+ .ec-chat-message__bubble code {
188
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
189
+ font-size: 0.875em;
190
+ padding: 1px 4px;
191
+ border-radius: 3px;
192
+ background: rgba(0, 0, 0, 0.06);
193
+ }
194
+
195
+ .ec-chat-message--user .ec-chat-message__bubble code {
196
+ background: rgba(255, 255, 255, 0.15);
197
+ }
198
+
199
+ .ec-chat-message__bubble pre {
200
+ margin: 8px 0;
201
+ padding: 10px 12px;
202
+ border-radius: 6px;
203
+ background: rgba(0, 0, 0, 0.06);
204
+ overflow-x: auto;
205
+ font-size: 0.85em;
206
+ line-height: 1.45;
207
+ }
208
+
209
+ .ec-chat-message--user .ec-chat-message__bubble pre {
210
+ background: rgba(255, 255, 255, 0.1);
211
+ }
212
+
213
+ .ec-chat-message__bubble pre code {
214
+ padding: 0;
215
+ background: none;
216
+ font-size: inherit;
217
+ }
218
+
219
+ .ec-chat-message__bubble a {
220
+ color: inherit;
221
+ text-decoration: underline;
222
+ text-underline-offset: 2px;
223
+ }
224
+
225
+ .ec-chat-message__bubble hr {
226
+ border: none;
227
+ border-top: 1px solid var(--ec-chat-border);
228
+ margin: 8px 0;
229
+ }
230
+
231
+ .ec-chat-message__bubble strong {
232
+ font-weight: 600;
233
+ }
234
+
152
235
  .ec-chat-message__timestamp {
153
236
  font-size: 11px;
154
237
  color: var(--ec-chat-text-muted);
@@ -7,7 +7,6 @@ export interface ChatMessageProps {
7
7
  contentFormat?: ContentFormat;
8
8
  /**
9
9
  * Custom content renderer. When provided, overrides contentFormat.
10
- * Use this to plug in your own markdown renderer (react-markdown, etc.).
11
10
  */
12
11
  renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
13
12
  /** Additional CSS class name on the outer wrapper. */
@@ -17,7 +16,7 @@ export interface ChatMessageProps {
17
16
  * Renders a single chat message bubble.
18
17
  *
19
18
  * User messages align right, assistant messages align left.
20
- * Content rendering is pluggable via `renderContent` or `contentFormat`.
19
+ * Markdown content is rendered via react-markdown (lazy-loaded).
21
20
  */
22
21
  export declare function ChatMessage({ message, contentFormat, renderContent, className, }: ChatMessageProps): import("react/jsx-runtime").JSX.Element;
23
22
  //# sourceMappingURL=ChatMessage.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ChatMessage.d.ts","sourceRoot":"","sources":["../../src/components/ChatMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEvF,MAAM,WAAW,gBAAgB;IAChC,6BAA6B;IAC7B,OAAO,EAAE,eAAe,CAAC;IACzB,6DAA6D;IAC7D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,CAAC,MAAM,CAAC,KAAK,SAAS,CAAC;IAC9E,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC3B,OAAO,EACP,aAA0B,EAC1B,aAAa,EACb,SAAS,GACT,EAAE,gBAAgB,2CAyBlB"}
1
+ {"version":3,"file":"ChatMessage.d.ts","sourceRoot":"","sources":["../../src/components/ChatMessage.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAkB,MAAM,OAAO,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAKvF,MAAM,WAAW,gBAAgB;IAChC,6BAA6B;IAC7B,OAAO,EAAE,eAAe,CAAC;IACzB,6DAA6D;IAC7D,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,eAAe,CAAC,MAAM,CAAC,KAAK,SAAS,CAAC;IAC9E,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC3B,OAAO,EACP,aAA0B,EAC1B,aAAa,EACb,SAAS,GACT,EAAE,gBAAgB,2CAyBlB"}
@@ -1,9 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { lazy, Suspense } from 'react';
3
+ import { markdownToHtml } from "../markdown.js";
4
+ const ReactMarkdown = lazy(() => import('react-markdown'));
2
5
  /**
3
6
  * Renders a single chat message bubble.
4
7
  *
5
8
  * User messages align right, assistant messages align left.
6
- * Content rendering is pluggable via `renderContent` or `contentFormat`.
9
+ * Markdown content is rendered via react-markdown (lazy-loaded).
7
10
  */
8
11
  export function ChatMessage({ message, contentFormat = 'markdown', renderContent, className, }) {
9
12
  const isUser = message.role === 'user';
@@ -14,13 +17,21 @@ export function ChatMessage({ message, contentFormat = 'markdown', renderContent
14
17
  ? renderContent(message.content, message.role)
15
18
  : _jsx(DefaultContent, { content: message.content, format: contentFormat }) }), message.timestamp && (_jsx("time", { className: `${baseClass}__timestamp`, dateTime: message.timestamp, title: new Date(message.timestamp).toLocaleString(), children: formatTime(message.timestamp) }))] }));
16
19
  }
20
+ /**
21
+ * Markdown rendered via lazy-loaded react-markdown.
22
+ * Falls back to the built-in lightweight parser while loading.
23
+ */
24
+ function MarkdownContent({ content }) {
25
+ return (_jsx(Suspense, { fallback: _jsx("div", { dangerouslySetInnerHTML: { __html: markdownToHtml(content) } }), children: _jsx(ReactMarkdown, { children: content }) }));
26
+ }
17
27
  function DefaultContent({ content, format }) {
18
28
  if (format === 'html') {
19
29
  return _jsx("div", { dangerouslySetInnerHTML: { __html: content } });
20
30
  }
21
- // For 'text' and 'markdown' (without a custom renderer), render as text
22
- // with basic paragraph splitting. Consumers should provide renderContent
23
- // for proper markdown support.
31
+ if (format === 'markdown') {
32
+ return _jsx(MarkdownContent, { content: content });
33
+ }
34
+ // Plain text — split on double newlines for paragraphs.
24
35
  return (_jsx(_Fragment, { children: content.split('\n\n').map((paragraph, i) => (_jsx("p", { children: paragraph }, i))) }));
25
36
  }
26
37
  function formatTime(iso) {
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export type { MessageRole, ToolCall, ToolResultMeta, ChatMessage, ContentFormat,
2
2
  export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult } from './api.ts';
3
3
  export { sendMessage, continueResponse, listSessions, loadSession, deleteSession, } from './api.ts';
4
4
  export { normalizeMessage, normalizeConversation, normalizeSession } from './normalizer.ts';
5
+ export { markdownToHtml } from './markdown.ts';
5
6
  export { ChatMessage as ChatMessageComponent, type ChatMessageProps, } from './components/ChatMessage.tsx';
6
7
  export { ChatMessages, type ChatMessagesProps, } from './components/ChatMessages.tsx';
7
8
  export { ChatInput, type ChatInputProps, } from './components/ChatInput.tsx';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjG,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjG,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { sendMessage, continueResponse, listSessions, loadSession, deleteSession, } from "./api.js";
2
2
  // Normalizer
3
3
  export { normalizeMessage, normalizeConversation, normalizeSession } from "./normalizer.js";
4
+ // Markdown
5
+ export { markdownToHtml } from "./markdown.js";
4
6
  // Components
5
7
  export { ChatMessage as ChatMessageComponent, } from "./components/ChatMessage.js";
6
8
  export { ChatMessages, } from "./components/ChatMessages.js";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lightweight markdown-to-HTML converter for chat messages.
3
+ *
4
+ * Handles the subset of markdown that LLM responses typically produce:
5
+ * headings, bold, italic, inline code, code blocks, links, lists,
6
+ * and paragraphs. Not a full CommonMark parser — just enough for
7
+ * clean chat rendering without heavy dependencies.
8
+ */
9
+ /**
10
+ * Parse markdown string into HTML.
11
+ */
12
+ export declare function markdownToHtml(source: string): string;
13
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAqCH;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAuFrD"}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Lightweight markdown-to-HTML converter for chat messages.
3
+ *
4
+ * Handles the subset of markdown that LLM responses typically produce:
5
+ * headings, bold, italic, inline code, code blocks, links, lists,
6
+ * and paragraphs. Not a full CommonMark parser — just enough for
7
+ * clean chat rendering without heavy dependencies.
8
+ */
9
+ const ESCAPED = {
10
+ '&': '&',
11
+ '<': '&lt;',
12
+ '>': '&gt;',
13
+ '"': '&quot;',
14
+ };
15
+ function escapeHtml(text) {
16
+ return text.replace(/[&<>"]/g, (ch) => ESCAPED[ch] ?? ch);
17
+ }
18
+ /**
19
+ * Convert inline markdown to HTML.
20
+ * Order matters — code spans must be processed before bold/italic
21
+ * to avoid mangling backtick contents.
22
+ */
23
+ function inlineMarkdown(text) {
24
+ let html = escapeHtml(text);
25
+ // Inline code (must come first to protect contents)
26
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
27
+ // Bold + italic
28
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
29
+ // Bold
30
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
31
+ // Italic
32
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
33
+ // Links [text](url)
34
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
35
+ return html;
36
+ }
37
+ /**
38
+ * Parse markdown string into HTML.
39
+ */
40
+ export function markdownToHtml(source) {
41
+ const lines = source.split('\n');
42
+ const output = [];
43
+ let i = 0;
44
+ while (i < lines.length) {
45
+ const line = lines[i];
46
+ // Fenced code block
47
+ if (line.startsWith('```')) {
48
+ const lang = line.slice(3).trim();
49
+ const codeLines = [];
50
+ i++;
51
+ while (i < lines.length && !lines[i].startsWith('```')) {
52
+ codeLines.push(escapeHtml(lines[i]));
53
+ i++;
54
+ }
55
+ i++; // skip closing ```
56
+ const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
57
+ output.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
58
+ continue;
59
+ }
60
+ // Heading
61
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
62
+ if (headingMatch) {
63
+ const level = headingMatch[1].length;
64
+ output.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
65
+ i++;
66
+ continue;
67
+ }
68
+ // Horizontal rule
69
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
70
+ output.push('<hr>');
71
+ i++;
72
+ continue;
73
+ }
74
+ // Unordered list
75
+ if (/^\s*[-*+]\s/.test(line)) {
76
+ const listItems = [];
77
+ while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
78
+ listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*[-*+]\s+/, ''))}</li>`);
79
+ i++;
80
+ }
81
+ output.push(`<ul>${listItems.join('')}</ul>`);
82
+ continue;
83
+ }
84
+ // Ordered list
85
+ if (/^\s*\d+[.)]\s/.test(line)) {
86
+ const listItems = [];
87
+ while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) {
88
+ listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*\d+[.)]\s+/, ''))}</li>`);
89
+ i++;
90
+ }
91
+ output.push(`<ol>${listItems.join('')}</ol>`);
92
+ continue;
93
+ }
94
+ // Empty line — skip (paragraph breaks handled by grouping)
95
+ if (line.trim() === '') {
96
+ i++;
97
+ continue;
98
+ }
99
+ // Paragraph — collect consecutive non-empty, non-special lines
100
+ const paraLines = [];
101
+ while (i < lines.length &&
102
+ lines[i].trim() !== '' &&
103
+ !lines[i].startsWith('```') &&
104
+ !lines[i].match(/^#{1,6}\s/) &&
105
+ !/^\s*[-*+]\s/.test(lines[i]) &&
106
+ !/^\s*\d+[.)]\s/.test(lines[i]) &&
107
+ !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim())) {
108
+ paraLines.push(lines[i]);
109
+ i++;
110
+ }
111
+ if (paraLines.length > 0) {
112
+ output.push(`<p>${inlineMarkdown(paraLines.join('\n'))}</p>`);
113
+ }
114
+ }
115
+ return output.join('');
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@extrachill/chat",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Chat UI components with built-in REST API client. Speaks the standard chat message format natively.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,10 +52,13 @@
52
52
  "react-dom": ">=18.0.0"
53
53
  },
54
54
  "devDependencies": {
55
- "typescript": "^5.7.0",
56
55
  "@types/react": "^18.0.0",
57
56
  "@types/react-dom": "^18.0.0",
58
57
  "react": "^18.0.0",
59
- "react-dom": "^18.0.0"
58
+ "react-dom": "^18.0.0",
59
+ "typescript": "^5.7.0"
60
+ },
61
+ "dependencies": {
62
+ "react-markdown": "^10.1.0"
60
63
  }
61
64
  }
@@ -1,5 +1,8 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode, lazy, Suspense } from 'react';
2
2
  import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
3
+ import { markdownToHtml } from '../markdown.ts';
4
+
5
+ const ReactMarkdown = lazy(() => import('react-markdown'));
3
6
 
4
7
  export interface ChatMessageProps {
5
8
  /** The message to render. */
@@ -8,7 +11,6 @@ export interface ChatMessageProps {
8
11
  contentFormat?: ContentFormat;
9
12
  /**
10
13
  * Custom content renderer. When provided, overrides contentFormat.
11
- * Use this to plug in your own markdown renderer (react-markdown, etc.).
12
14
  */
13
15
  renderContent?: (content: string, role: ChatMessageType['role']) => ReactNode;
14
16
  /** Additional CSS class name on the outer wrapper. */
@@ -19,7 +21,7 @@ export interface ChatMessageProps {
19
21
  * Renders a single chat message bubble.
20
22
  *
21
23
  * User messages align right, assistant messages align left.
22
- * Content rendering is pluggable via `renderContent` or `contentFormat`.
24
+ * Markdown content is rendered via react-markdown (lazy-loaded).
23
25
  */
24
26
  export function ChatMessage({
25
27
  message,
@@ -58,14 +60,32 @@ interface DefaultContentProps {
58
60
  format: ContentFormat;
59
61
  }
60
62
 
63
+ /**
64
+ * Markdown rendered via lazy-loaded react-markdown.
65
+ * Falls back to the built-in lightweight parser while loading.
66
+ */
67
+ function MarkdownContent({ content }: { content: string }) {
68
+ return (
69
+ <Suspense
70
+ fallback={
71
+ <div dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }} />
72
+ }
73
+ >
74
+ <ReactMarkdown>{content}</ReactMarkdown>
75
+ </Suspense>
76
+ );
77
+ }
78
+
61
79
  function DefaultContent({ content, format }: DefaultContentProps) {
62
80
  if (format === 'html') {
63
81
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
64
82
  }
65
83
 
66
- // For 'text' and 'markdown' (without a custom renderer), render as text
67
- // with basic paragraph splitting. Consumers should provide renderContent
68
- // for proper markdown support.
84
+ if (format === 'markdown') {
85
+ return <MarkdownContent content={content} />;
86
+ }
87
+
88
+ // Plain text — split on double newlines for paragraphs.
69
89
  return (
70
90
  <>
71
91
  {content.split('\n\n').map((paragraph, i) => (
package/src/index.ts CHANGED
@@ -26,6 +26,9 @@ export {
26
26
  // Normalizer
27
27
  export { normalizeMessage, normalizeConversation, normalizeSession } from './normalizer.ts';
28
28
 
29
+ // Markdown
30
+ export { markdownToHtml } from './markdown.ts';
31
+
29
32
  // Components
30
33
  export {
31
34
  ChatMessage as ChatMessageComponent,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Lightweight markdown-to-HTML converter for chat messages.
3
+ *
4
+ * Handles the subset of markdown that LLM responses typically produce:
5
+ * headings, bold, italic, inline code, code blocks, links, lists,
6
+ * and paragraphs. Not a full CommonMark parser — just enough for
7
+ * clean chat rendering without heavy dependencies.
8
+ */
9
+
10
+ const ESCAPED: Record<string, string> = {
11
+ '&': '&amp;',
12
+ '<': '&lt;',
13
+ '>': '&gt;',
14
+ '"': '&quot;',
15
+ };
16
+
17
+ function escapeHtml(text: string): string {
18
+ return text.replace(/[&<>"]/g, (ch) => ESCAPED[ch] ?? ch);
19
+ }
20
+
21
+ /**
22
+ * Convert inline markdown to HTML.
23
+ * Order matters — code spans must be processed before bold/italic
24
+ * to avoid mangling backtick contents.
25
+ */
26
+ function inlineMarkdown(text: string): string {
27
+ let html = escapeHtml(text);
28
+
29
+ // Inline code (must come first to protect contents)
30
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
31
+
32
+ // Bold + italic
33
+ html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
34
+ // Bold
35
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
36
+ // Italic
37
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
38
+
39
+ // Links [text](url)
40
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
41
+
42
+ return html;
43
+ }
44
+
45
+ /**
46
+ * Parse markdown string into HTML.
47
+ */
48
+ export function markdownToHtml(source: string): string {
49
+ const lines = source.split('\n');
50
+ const output: string[] = [];
51
+ let i = 0;
52
+
53
+ while (i < lines.length) {
54
+ const line = lines[i];
55
+
56
+ // Fenced code block
57
+ if (line.startsWith('```')) {
58
+ const lang = line.slice(3).trim();
59
+ const codeLines: string[] = [];
60
+ i++;
61
+ while (i < lines.length && !lines[i].startsWith('```')) {
62
+ codeLines.push(escapeHtml(lines[i]));
63
+ i++;
64
+ }
65
+ i++; // skip closing ```
66
+ const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
67
+ output.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
68
+ continue;
69
+ }
70
+
71
+ // Heading
72
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
73
+ if (headingMatch) {
74
+ const level = headingMatch[1].length;
75
+ output.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
76
+ i++;
77
+ continue;
78
+ }
79
+
80
+ // Horizontal rule
81
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
82
+ output.push('<hr>');
83
+ i++;
84
+ continue;
85
+ }
86
+
87
+ // Unordered list
88
+ if (/^\s*[-*+]\s/.test(line)) {
89
+ const listItems: string[] = [];
90
+ while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
91
+ listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*[-*+]\s+/, ''))}</li>`);
92
+ i++;
93
+ }
94
+ output.push(`<ul>${listItems.join('')}</ul>`);
95
+ continue;
96
+ }
97
+
98
+ // Ordered list
99
+ if (/^\s*\d+[.)]\s/.test(line)) {
100
+ const listItems: string[] = [];
101
+ while (i < lines.length && /^\s*\d+[.)]\s/.test(lines[i])) {
102
+ listItems.push(`<li>${inlineMarkdown(lines[i].replace(/^\s*\d+[.)]\s+/, ''))}</li>`);
103
+ i++;
104
+ }
105
+ output.push(`<ol>${listItems.join('')}</ol>`);
106
+ continue;
107
+ }
108
+
109
+ // Empty line — skip (paragraph breaks handled by grouping)
110
+ if (line.trim() === '') {
111
+ i++;
112
+ continue;
113
+ }
114
+
115
+ // Paragraph — collect consecutive non-empty, non-special lines
116
+ const paraLines: string[] = [];
117
+ while (
118
+ i < lines.length &&
119
+ lines[i].trim() !== '' &&
120
+ !lines[i].startsWith('```') &&
121
+ !lines[i].match(/^#{1,6}\s/) &&
122
+ !/^\s*[-*+]\s/.test(lines[i]) &&
123
+ !/^\s*\d+[.)]\s/.test(lines[i]) &&
124
+ !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim())
125
+ ) {
126
+ paraLines.push(lines[i]);
127
+ i++;
128
+ }
129
+ if (paraLines.length > 0) {
130
+ output.push(`<p>${inlineMarkdown(paraLines.join('\n'))}</p>`);
131
+ }
132
+ }
133
+
134
+ return output.join('');
135
+ }