@extrachill/chat 0.2.2 → 0.3.0

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);
@@ -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,EAAW,MAAM,OAAO,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,IAAI,eAAe,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAGvF,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,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { markdownToHtml } from "../markdown.js";
2
4
  /**
3
5
  * Renders a single chat message bubble.
4
6
  *
@@ -15,12 +17,17 @@ export function ChatMessage({ message, contentFormat = 'markdown', renderContent
15
17
  : _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
18
  }
17
19
  function DefaultContent({ content, format }) {
18
- if (format === 'html') {
19
- return _jsx("div", { dangerouslySetInnerHTML: { __html: content } });
20
+ const html = useMemo(() => {
21
+ if (format === 'html')
22
+ return content;
23
+ if (format === 'markdown')
24
+ return markdownToHtml(content);
25
+ return null;
26
+ }, [content, format]);
27
+ if (html !== null) {
28
+ return _jsx("div", { dangerouslySetInnerHTML: { __html: html } });
20
29
  }
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.
30
+ // Plain text split on double newlines for paragraphs.
24
31
  return (_jsx(_Fragment, { children: content.split('\n\n').map((paragraph, i) => (_jsx("p", { children: paragraph }, i))) }));
25
32
  }
26
33
  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.0",
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",
@@ -1,5 +1,6 @@
1
- import { type ReactNode } from 'react';
1
+ import { type ReactNode, useMemo } from 'react';
2
2
  import type { ChatMessage as ChatMessageType, ContentFormat } from '../types/index.ts';
3
+ import { markdownToHtml } from '../markdown.ts';
3
4
 
4
5
  export interface ChatMessageProps {
5
6
  /** The message to render. */
@@ -59,13 +60,17 @@ interface DefaultContentProps {
59
60
  }
60
61
 
61
62
  function DefaultContent({ content, format }: DefaultContentProps) {
62
- if (format === 'html') {
63
- return <div dangerouslySetInnerHTML={{ __html: content }} />;
63
+ const html = useMemo(() => {
64
+ if (format === 'html') return content;
65
+ if (format === 'markdown') return markdownToHtml(content);
66
+ return null;
67
+ }, [content, format]);
68
+
69
+ if (html !== null) {
70
+ return <div dangerouslySetInnerHTML={{ __html: html }} />;
64
71
  }
65
72
 
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.
73
+ // Plain text split on double newlines for paragraphs.
69
74
  return (
70
75
  <>
71
76
  {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
+ }