@djangocfg/ui-tools 2.1.298 → 2.1.299
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/README.md +126 -2
- package/dist/{DocsLayout-74WIW7L3.mjs → DocsLayout-MWRKNFXR.mjs} +3 -3
- package/dist/{DocsLayout-74WIW7L3.mjs.map → DocsLayout-MWRKNFXR.mjs.map} +1 -1
- package/dist/{DocsLayout-IA55EXRN.cjs → DocsLayout-NWJUF42A.cjs} +48 -48
- package/dist/{DocsLayout-IA55EXRN.cjs.map → DocsLayout-NWJUF42A.cjs.map} +1 -1
- package/dist/{chunk-2BBXP3DH.mjs → chunk-CKD7GNE5.mjs} +220 -187
- package/dist/chunk-CKD7GNE5.mjs.map +1 -0
- package/dist/{chunk-Q6FNLXLZ.cjs → chunk-SEXWBCLX.cjs} +256 -222
- package/dist/chunk-SEXWBCLX.cjs.map +1 -0
- package/dist/index.cjs +13 -9
- package/dist/index.d.cts +82 -59
- package/dist/index.d.ts +82 -59
- package/dist/index.mjs +4 -4
- package/package.json +6 -6
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +69 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +60 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +171 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +202 -0
- package/src/components/markdown/MarkdownMessage/components.tsx +154 -0
- package/src/components/markdown/MarkdownMessage/index.ts +13 -0
- package/src/components/markdown/MarkdownMessage/linkRules.ts +83 -0
- package/src/components/markdown/MarkdownMessage/plainText.ts +50 -0
- package/src/components/markdown/MarkdownMessage/sanitize.ts +78 -0
- package/src/components/markdown/MarkdownMessage/types.ts +104 -0
- package/src/components/markdown/index.ts +6 -1
- package/dist/chunk-2BBXP3DH.mjs.map +0 -1
- package/dist/chunk-Q6FNLXLZ.cjs.map +0 -1
- package/src/components/markdown/MarkdownMessage.tsx +0 -721
|
@@ -1,721 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React from 'react';
|
|
4
|
-
import ReactMarkdown from 'react-markdown';
|
|
5
|
-
import rehypeRaw from 'rehype-raw';
|
|
6
|
-
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
|
7
|
-
import remarkGfm from 'remark-gfm';
|
|
8
|
-
|
|
9
|
-
// Allow-list HTML that OpenAPI descriptions commonly use — we want
|
|
10
|
-
// ``<b>``/``<code>``/``<br>`` to render, not to appear as literal text.
|
|
11
|
-
// Built on top of ``rehype-sanitize``'s default schema so we keep the
|
|
12
|
-
// XSS protection (no ``<script>``, no ``on*`` handlers, no ``javascript:``
|
|
13
|
-
// URLs); we just extend the tag allow-list with safe presentational
|
|
14
|
-
// elements that are in wide use in API docs.
|
|
15
|
-
const HTML_SCHEMA_BASE = {
|
|
16
|
-
...defaultSchema,
|
|
17
|
-
tagNames: [
|
|
18
|
-
...(defaultSchema.tagNames ?? []),
|
|
19
|
-
'br',
|
|
20
|
-
'b',
|
|
21
|
-
'i',
|
|
22
|
-
'u',
|
|
23
|
-
's',
|
|
24
|
-
'sub',
|
|
25
|
-
'sup',
|
|
26
|
-
'small',
|
|
27
|
-
'mark',
|
|
28
|
-
'kbd',
|
|
29
|
-
'code',
|
|
30
|
-
'pre',
|
|
31
|
-
'details',
|
|
32
|
-
'summary',
|
|
33
|
-
],
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// Build a sanitize schema with extra href-URL protocols allowed on
|
|
37
|
-
// `<a>`. The default schema only lets `http(s)/mailto/xmpp/irc(s)`
|
|
38
|
-
// through; consumers that emit custom URI schemes (e.g. `cmdop://`,
|
|
39
|
-
// `obsidian://`, `vscode://`) need to opt them in so their custom
|
|
40
|
-
// link renderer actually receives the href instead of `undefined`.
|
|
41
|
-
function buildSchema(extraProtocols: readonly string[] | undefined) {
|
|
42
|
-
if (!extraProtocols || extraProtocols.length === 0) return HTML_SCHEMA_BASE;
|
|
43
|
-
const baseProtocols = (HTML_SCHEMA_BASE as { protocols?: Record<string, string[]> }).protocols
|
|
44
|
-
?? (defaultSchema as { protocols?: Record<string, string[]> }).protocols
|
|
45
|
-
?? {};
|
|
46
|
-
const baseHref = baseProtocols.href ?? ['http', 'https', 'mailto', 'xmpp', 'irc', 'ircs'];
|
|
47
|
-
return {
|
|
48
|
-
...HTML_SCHEMA_BASE,
|
|
49
|
-
protocols: {
|
|
50
|
-
...baseProtocols,
|
|
51
|
-
href: [...baseHref, ...extraProtocols],
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// react-markdown applies its own `defaultUrlTransform` BEFORE rehype
|
|
57
|
-
// sanitize runs, which strips `href` for any protocol it doesn't
|
|
58
|
-
// recognise — so even with our extended sanitize schema, custom
|
|
59
|
-
// schemes like `cmdop://` would arrive at the renderer with
|
|
60
|
-
// `href=""`. Build a urlTransform that lets the listed schemes pass
|
|
61
|
-
// through verbatim and falls back to react-markdown's default for
|
|
62
|
-
// everything else.
|
|
63
|
-
function buildUrlTransform(extraProtocols: readonly string[] | undefined) {
|
|
64
|
-
if (!extraProtocols || extraProtocols.length === 0) return undefined;
|
|
65
|
-
const lower = extraProtocols.map((p) => p.toLowerCase() + ':');
|
|
66
|
-
return (url: string): string => {
|
|
67
|
-
const u = url.trim().toLowerCase();
|
|
68
|
-
for (const p of lower) {
|
|
69
|
-
if (u.startsWith(p)) return url;
|
|
70
|
-
}
|
|
71
|
-
// Fallback: replicate react-markdown's defaults for safe schemes
|
|
72
|
-
// and relative paths. Anything else → empty string so the
|
|
73
|
-
// anchor's href is dropped (XSS guard).
|
|
74
|
-
if (
|
|
75
|
-
/^(https?:|mailto:|tel:|xmpp:|irc:|ircs:|#|\/|\.\/|\.\.\/|\?)/i.test(u)
|
|
76
|
-
|| /^[a-z0-9._~!$&'()*+,;=:@%-]+$/i.test(u)
|
|
77
|
-
) {
|
|
78
|
-
return url;
|
|
79
|
-
}
|
|
80
|
-
return '';
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
import { CopyButton } from '@djangocfg/ui-core/components';
|
|
85
|
-
import { useResolvedTheme } from '@djangocfg/ui-core/hooks';
|
|
86
|
-
|
|
87
|
-
import Mermaid from '../../tools/Mermaid';
|
|
88
|
-
import PrettyCode from '../../tools/PrettyCode';
|
|
89
|
-
import { useCollapsibleContent } from './useCollapsibleContent';
|
|
90
|
-
|
|
91
|
-
import type { Components } from 'react-markdown';
|
|
92
|
-
|
|
93
|
-
// Helper function to extract text content from React children
|
|
94
|
-
const extractTextFromChildren = (children: React.ReactNode): string => {
|
|
95
|
-
if (typeof children === 'string') {
|
|
96
|
-
return children;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (typeof children === 'number') {
|
|
100
|
-
return String(children);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (React.isValidElement(children)) {
|
|
104
|
-
const props = children.props as { children?: React.ReactNode };
|
|
105
|
-
return extractTextFromChildren(props.children);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (Array.isArray(children)) {
|
|
109
|
-
return children.map(extractTextFromChildren).join('');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return '';
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export interface MarkdownMessageProps {
|
|
116
|
-
/** Markdown content to render */
|
|
117
|
-
content: string;
|
|
118
|
-
/** Additional CSS classes */
|
|
119
|
-
className?: string;
|
|
120
|
-
/** Whether the message is from the user (affects styling) */
|
|
121
|
-
isUser?: boolean;
|
|
122
|
-
/** Use compact size (text-xs instead of text-sm) */
|
|
123
|
-
isCompact?: boolean;
|
|
124
|
-
/**
|
|
125
|
-
* Per-tag overrides merged on top of the built-in renderers.
|
|
126
|
-
*
|
|
127
|
-
* Use this when you need custom rendering for a specific tag without
|
|
128
|
-
* losing the chat-tuned defaults (links, code blocks with copy, etc).
|
|
129
|
-
* Example — render `cmdop://machine/<uuid>` links as a chip:
|
|
130
|
-
*
|
|
131
|
-
* ```tsx
|
|
132
|
-
* <MarkdownMessage
|
|
133
|
-
* content={text}
|
|
134
|
-
* extraHrefProtocols={['cmdop']}
|
|
135
|
-
* customComponents={{
|
|
136
|
-
* a: ({ href, children }) =>
|
|
137
|
-
* href?.startsWith('cmdop://machine/')
|
|
138
|
-
* ? <MachineChip href={href}>{children}</MachineChip>
|
|
139
|
-
* : <a href={href}>{children}</a>,
|
|
140
|
-
* }}
|
|
141
|
-
* />
|
|
142
|
-
* ```
|
|
143
|
-
*
|
|
144
|
-
* If you provide a renderer for a tag the built-in version is replaced
|
|
145
|
-
* for that tag; other tags keep the defaults.
|
|
146
|
-
*
|
|
147
|
-
* Important: when `customComponents` is set, the plain-text fast path
|
|
148
|
-
* is bypassed for the affected content so your renderers always run.
|
|
149
|
-
*/
|
|
150
|
-
customComponents?: Partial<Components>;
|
|
151
|
-
/**
|
|
152
|
-
* Extra URL protocols allowed in `<a href>` after sanitize.
|
|
153
|
-
*
|
|
154
|
-
* The default schema strips anything that isn't
|
|
155
|
-
* `http(s)/mailto/xmpp/irc(s)` — your custom renderer would receive
|
|
156
|
-
* `href={undefined}` for `cmdop://…` / `obsidian://…` etc. Listing
|
|
157
|
-
* the bare scheme here (e.g. `'cmdop'`, NOT `'cmdop://'`) opts it in.
|
|
158
|
-
*
|
|
159
|
-
* Use carefully: every protocol you add increases what the rendered
|
|
160
|
-
* HTML can do via clicks. Stick to schemes you control.
|
|
161
|
-
*/
|
|
162
|
-
extraHrefProtocols?: readonly string[];
|
|
163
|
-
/**
|
|
164
|
-
* Enable collapsible "Read more..." functionality
|
|
165
|
-
* When enabled, long content will be truncated with a toggle button
|
|
166
|
-
* @default false
|
|
167
|
-
*/
|
|
168
|
-
collapsible?: boolean;
|
|
169
|
-
/**
|
|
170
|
-
* Maximum character length before showing "Read more..."
|
|
171
|
-
* Only applies when collapsible is true
|
|
172
|
-
* If both maxLength and maxLines are set, the stricter limit applies
|
|
173
|
-
*/
|
|
174
|
-
maxLength?: number;
|
|
175
|
-
/**
|
|
176
|
-
* Maximum number of lines before showing "Read more..."
|
|
177
|
-
* Only applies when collapsible is true
|
|
178
|
-
* If both maxLength and maxLines are set, the stricter limit applies
|
|
179
|
-
*/
|
|
180
|
-
maxLines?: number;
|
|
181
|
-
/**
|
|
182
|
-
* Custom "Read more" button text
|
|
183
|
-
* @default "Read more..."
|
|
184
|
-
*/
|
|
185
|
-
readMoreLabel?: string;
|
|
186
|
-
/**
|
|
187
|
-
* Custom "Show less" button text
|
|
188
|
-
* @default "Show less"
|
|
189
|
-
*/
|
|
190
|
-
showLessLabel?: string;
|
|
191
|
-
/**
|
|
192
|
-
* Start expanded (only applies when collapsible is true)
|
|
193
|
-
* @default false
|
|
194
|
-
*/
|
|
195
|
-
defaultExpanded?: boolean;
|
|
196
|
-
/**
|
|
197
|
-
* Callback when collapsed state changes
|
|
198
|
-
*/
|
|
199
|
-
onCollapseChange?: (isCollapsed: boolean) => void;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Code block component with copy functionality
|
|
203
|
-
interface CodeBlockProps {
|
|
204
|
-
code: string;
|
|
205
|
-
language: string;
|
|
206
|
-
isUser: boolean;
|
|
207
|
-
isCompact?: boolean;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, isCompact = false }) => {
|
|
211
|
-
const theme = useResolvedTheme();
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<div className="relative group my-3">
|
|
215
|
-
{/* Copy button */}
|
|
216
|
-
<CopyButton
|
|
217
|
-
value={code}
|
|
218
|
-
variant="ghost"
|
|
219
|
-
className={`
|
|
220
|
-
absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
|
|
221
|
-
h-8 w-8
|
|
222
|
-
${isUser
|
|
223
|
-
? 'hover:bg-white/20 text-white'
|
|
224
|
-
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
|
225
|
-
}
|
|
226
|
-
`}
|
|
227
|
-
title="Copy code"
|
|
228
|
-
/>
|
|
229
|
-
|
|
230
|
-
{/* Code content */}
|
|
231
|
-
<PrettyCode
|
|
232
|
-
data={code}
|
|
233
|
-
language={language}
|
|
234
|
-
className={isCompact ? 'text-xs' : 'text-sm'}
|
|
235
|
-
customBg={isUser ? "bg-white/10" : "bg-muted dark:bg-muted"}
|
|
236
|
-
mode={theme}
|
|
237
|
-
isCompact={isCompact}
|
|
238
|
-
/>
|
|
239
|
-
</div>
|
|
240
|
-
);
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// Custom components for markdown in chat
|
|
244
|
-
// Base size: text-sm (14px) for normal, text-xs (12px) for compact
|
|
245
|
-
const createMarkdownComponents = (isUser: boolean = false, isCompact: boolean = false): Components => {
|
|
246
|
-
// Text size classes based on compact mode
|
|
247
|
-
const textSize = isCompact ? 'text-xs' : 'text-sm';
|
|
248
|
-
const headingBase = isCompact ? 'text-sm' : 'text-base';
|
|
249
|
-
const headingSm = isCompact ? 'text-xs' : 'text-sm';
|
|
250
|
-
|
|
251
|
-
return {
|
|
252
|
-
// Headings - scaled for chat context
|
|
253
|
-
h1: ({ children }) => (
|
|
254
|
-
<h1 className={`${headingBase} font-bold mb-2 mt-3 first:mt-0`}>{children}</h1>
|
|
255
|
-
),
|
|
256
|
-
h2: ({ children }) => (
|
|
257
|
-
<h2 className={`${headingSm} font-bold mb-2 mt-3 first:mt-0`}>{children}</h2>
|
|
258
|
-
),
|
|
259
|
-
h3: ({ children }) => (
|
|
260
|
-
<h3 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h3>
|
|
261
|
-
),
|
|
262
|
-
h4: ({ children }) => (
|
|
263
|
-
<h4 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h4>
|
|
264
|
-
),
|
|
265
|
-
h5: ({ children }) => (
|
|
266
|
-
<h5 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h5>
|
|
267
|
-
),
|
|
268
|
-
h6: ({ children }) => (
|
|
269
|
-
<h6 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h6>
|
|
270
|
-
),
|
|
271
|
-
|
|
272
|
-
// Paragraphs - optimized for chat readability
|
|
273
|
-
p: ({ children }) => (
|
|
274
|
-
<p className={`${textSize} mb-4 last:mb-0 leading-7 break-words font-light`}>{children}</p>
|
|
275
|
-
),
|
|
276
|
-
|
|
277
|
-
// Lists - compact
|
|
278
|
-
ul: ({ children }) => (
|
|
279
|
-
<ul className={`list-disc list-inside mb-2 space-y-1 ${textSize}`}>{children}</ul>
|
|
280
|
-
),
|
|
281
|
-
ol: ({ children }) => (
|
|
282
|
-
<ol className={`list-decimal list-inside mb-2 space-y-1 ${textSize}`}>{children}</ol>
|
|
283
|
-
),
|
|
284
|
-
li: ({ children }) => (
|
|
285
|
-
<li className="break-words">{children}</li>
|
|
286
|
-
),
|
|
287
|
-
|
|
288
|
-
// Links - appropriate for chat context
|
|
289
|
-
a: ({ href, children }) => (
|
|
290
|
-
<a
|
|
291
|
-
href={href}
|
|
292
|
-
className={`${textSize} ${
|
|
293
|
-
isUser
|
|
294
|
-
? 'text-white/90 underline hover:text-white'
|
|
295
|
-
: 'text-primary underline hover:text-primary/80'
|
|
296
|
-
} transition-colors break-all`}
|
|
297
|
-
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
298
|
-
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
299
|
-
>
|
|
300
|
-
{children}
|
|
301
|
-
</a>
|
|
302
|
-
),
|
|
303
|
-
|
|
304
|
-
// Code blocks - using CodeBlock component with copy functionality
|
|
305
|
-
pre: ({ children }) => {
|
|
306
|
-
// Extract code content and language
|
|
307
|
-
let codeContent = '';
|
|
308
|
-
let language = 'plaintext';
|
|
309
|
-
|
|
310
|
-
if (React.isValidElement(children)) {
|
|
311
|
-
const child = children;
|
|
312
|
-
|
|
313
|
-
if (child.type === 'code' || (typeof child.type === 'function' && child.type.name === 'code')) {
|
|
314
|
-
const codeProps = child.props as { className?: string; children?: React.ReactNode };
|
|
315
|
-
const rawClassName = codeProps.className;
|
|
316
|
-
language = rawClassName?.replace(/language-/, '').trim() || 'plaintext';
|
|
317
|
-
codeContent = extractTextFromChildren(codeProps.children).trim();
|
|
318
|
-
} else {
|
|
319
|
-
codeContent = extractTextFromChildren(children).trim();
|
|
320
|
-
}
|
|
321
|
-
} else {
|
|
322
|
-
codeContent = extractTextFromChildren(children).trim();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// If still no content, show placeholder
|
|
326
|
-
if (!codeContent) {
|
|
327
|
-
return (
|
|
328
|
-
<div className="my-3 p-3 bg-muted rounded text-sm text-muted-foreground">
|
|
329
|
-
No content available
|
|
330
|
-
</div>
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Handle Mermaid diagrams separately
|
|
335
|
-
if (language === 'mermaid') {
|
|
336
|
-
return (
|
|
337
|
-
<div className="my-3 max-w-full overflow-x-auto">
|
|
338
|
-
<Mermaid chart={codeContent} className="max-w-[600px] mx-auto" isCompact={isCompact} />
|
|
339
|
-
</div>
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Try to use CodeBlock component, fallback to simple pre if it fails
|
|
344
|
-
try {
|
|
345
|
-
return <CodeBlock code={codeContent} language={language} isUser={isUser} isCompact={isCompact} />;
|
|
346
|
-
} catch (error) {
|
|
347
|
-
// Fallback to simple pre element with copy button
|
|
348
|
-
console.warn('CodeBlock failed, using fallback:', error);
|
|
349
|
-
return (
|
|
350
|
-
<div className="relative group my-3">
|
|
351
|
-
<CopyButton
|
|
352
|
-
value={codeContent}
|
|
353
|
-
variant="ghost"
|
|
354
|
-
className={`
|
|
355
|
-
absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
|
|
356
|
-
h-8 w-8
|
|
357
|
-
${isUser
|
|
358
|
-
? 'hover:bg-white/20 text-white'
|
|
359
|
-
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
|
|
360
|
-
}
|
|
361
|
-
`}
|
|
362
|
-
title="Copy code"
|
|
363
|
-
/>
|
|
364
|
-
<pre className={`
|
|
365
|
-
p-3 rounded text-xs font-mono overflow-x-auto
|
|
366
|
-
${isUser
|
|
367
|
-
? 'bg-white/10 text-white'
|
|
368
|
-
: 'bg-muted text-foreground'
|
|
369
|
-
}
|
|
370
|
-
`}>
|
|
371
|
-
<code>{codeContent}</code>
|
|
372
|
-
</pre>
|
|
373
|
-
</div>
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
|
|
378
|
-
// Inline code
|
|
379
|
-
code: ({ children, className }) => {
|
|
380
|
-
// If it's inside a pre tag, let pre handle it
|
|
381
|
-
if (className?.includes('language-')) {
|
|
382
|
-
return <code className={className}>{children}</code>;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Extract text content safely
|
|
386
|
-
const codeContent = extractTextFromChildren(children);
|
|
387
|
-
|
|
388
|
-
// Inline code styling
|
|
389
|
-
return (
|
|
390
|
-
<code className="px-1.5 py-0.5 rounded text-xs font-mono bg-muted text-foreground break-all">
|
|
391
|
-
{codeContent}
|
|
392
|
-
</code>
|
|
393
|
-
);
|
|
394
|
-
},
|
|
395
|
-
|
|
396
|
-
// Blockquotes
|
|
397
|
-
blockquote: ({ children }) => (
|
|
398
|
-
<blockquote className={`${textSize} border-l-2 border-border pl-3 my-2 italic text-muted-foreground break-words`}>
|
|
399
|
-
{children}
|
|
400
|
-
</blockquote>
|
|
401
|
-
),
|
|
402
|
-
|
|
403
|
-
// Tables - compact for chat
|
|
404
|
-
table: ({ children }) => (
|
|
405
|
-
<div className="overflow-x-auto my-3">
|
|
406
|
-
<table className={`min-w-full ${textSize} border-collapse`}>
|
|
407
|
-
{children}
|
|
408
|
-
</table>
|
|
409
|
-
</div>
|
|
410
|
-
),
|
|
411
|
-
thead: ({ children }) => (
|
|
412
|
-
<thead className="bg-muted/50">
|
|
413
|
-
{children}
|
|
414
|
-
</thead>
|
|
415
|
-
),
|
|
416
|
-
tbody: ({ children }) => (
|
|
417
|
-
<tbody>{children}</tbody>
|
|
418
|
-
),
|
|
419
|
-
tr: ({ children }) => (
|
|
420
|
-
<tr className="border-b border-border/50">{children}</tr>
|
|
421
|
-
),
|
|
422
|
-
th: ({ children }) => (
|
|
423
|
-
<th className="px-2 py-1 text-left font-medium break-words">{children}</th>
|
|
424
|
-
),
|
|
425
|
-
td: ({ children }) => (
|
|
426
|
-
<td className="px-2 py-1 break-words">{children}</td>
|
|
427
|
-
),
|
|
428
|
-
|
|
429
|
-
// Horizontal rule
|
|
430
|
-
hr: () => (
|
|
431
|
-
<hr className="my-3 border-0 h-px bg-border" />
|
|
432
|
-
),
|
|
433
|
-
|
|
434
|
-
// Strong and emphasis
|
|
435
|
-
strong: ({ children }) => (
|
|
436
|
-
<strong className="font-semibold">{children}</strong>
|
|
437
|
-
),
|
|
438
|
-
em: ({ children }) => (
|
|
439
|
-
<em className="italic">{children}</em>
|
|
440
|
-
),
|
|
441
|
-
};};
|
|
442
|
-
|
|
443
|
-
// Check if content contains markdown syntax or line breaks
|
|
444
|
-
const hasMarkdownSyntax = (text: string): boolean => {
|
|
445
|
-
// If there are line breaks (after trim), treat as markdown for proper paragraph rendering
|
|
446
|
-
if (text.trim().includes('\n')) {
|
|
447
|
-
return true;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Inline HTML tags (``<br>``, ``<b>``, ``<code>``, …) — used widely
|
|
451
|
-
// in OpenAPI descriptions. Without this branch the plain-text fast
|
|
452
|
-
// path would escape them and the user sees literal angle brackets.
|
|
453
|
-
// The regex matches opening, closing, and self-closing tags without
|
|
454
|
-
// being too clever about malformed HTML; it's an affordance test,
|
|
455
|
-
// not a validator.
|
|
456
|
-
if (/<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?\/?>/.test(text)) {
|
|
457
|
-
return true;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Common markdown patterns
|
|
461
|
-
const markdownPatterns = [
|
|
462
|
-
/^#{1,6}\s/m, // Headers
|
|
463
|
-
/\*\*[^*]+\*\*/, // Bold
|
|
464
|
-
/\*[^*]+\*/, // Italic
|
|
465
|
-
/__[^_]+__/, // Bold (underscore)
|
|
466
|
-
/_[^_]+_/, // Italic (underscore)
|
|
467
|
-
/\[.+\]\(.+\)/, // Links
|
|
468
|
-
/!\[.*\]\(.+\)/, // Images
|
|
469
|
-
/```[\s\S]*```/, // Code blocks
|
|
470
|
-
/`[^`]+`/, // Inline code
|
|
471
|
-
/^\s*[-*+]\s/m, // Unordered lists
|
|
472
|
-
/^\s*\d+\.\s/m, // Ordered lists
|
|
473
|
-
/^\s*>/m, // Blockquotes
|
|
474
|
-
/\|.+\|/, // Tables
|
|
475
|
-
/^---+$/m, // Horizontal rules
|
|
476
|
-
/~~[^~]+~~/, // Strikethrough
|
|
477
|
-
];
|
|
478
|
-
|
|
479
|
-
return markdownPatterns.some(pattern => pattern.test(text));
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Read more / Show less toggle button
|
|
484
|
-
*/
|
|
485
|
-
interface CollapseToggleProps {
|
|
486
|
-
isCollapsed: boolean;
|
|
487
|
-
onClick: () => void;
|
|
488
|
-
readMoreLabel: string;
|
|
489
|
-
showLessLabel: string;
|
|
490
|
-
isUser: boolean;
|
|
491
|
-
isCompact: boolean;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const CollapseToggle: React.FC<CollapseToggleProps> = ({
|
|
495
|
-
isCollapsed,
|
|
496
|
-
onClick,
|
|
497
|
-
readMoreLabel,
|
|
498
|
-
showLessLabel,
|
|
499
|
-
isUser,
|
|
500
|
-
isCompact,
|
|
501
|
-
}) => {
|
|
502
|
-
const textSize = isCompact ? 'text-xs' : 'text-sm';
|
|
503
|
-
|
|
504
|
-
return (
|
|
505
|
-
<button
|
|
506
|
-
type="button"
|
|
507
|
-
onClick={onClick}
|
|
508
|
-
className={`
|
|
509
|
-
${textSize} font-medium cursor-pointer
|
|
510
|
-
transition-colors duration-200
|
|
511
|
-
${isUser
|
|
512
|
-
? 'text-white/80 hover:text-white'
|
|
513
|
-
: 'text-primary hover:text-primary/80'
|
|
514
|
-
}
|
|
515
|
-
inline-flex items-center gap-1
|
|
516
|
-
mt-1
|
|
517
|
-
`}
|
|
518
|
-
>
|
|
519
|
-
{isCollapsed ? (
|
|
520
|
-
<>
|
|
521
|
-
{readMoreLabel}
|
|
522
|
-
<svg
|
|
523
|
-
className="w-3 h-3"
|
|
524
|
-
fill="none"
|
|
525
|
-
stroke="currentColor"
|
|
526
|
-
viewBox="0 0 24 24"
|
|
527
|
-
>
|
|
528
|
-
<path
|
|
529
|
-
strokeLinecap="round"
|
|
530
|
-
strokeLinejoin="round"
|
|
531
|
-
strokeWidth={2}
|
|
532
|
-
d="M19 9l-7 7-7-7"
|
|
533
|
-
/>
|
|
534
|
-
</svg>
|
|
535
|
-
</>
|
|
536
|
-
) : (
|
|
537
|
-
<>
|
|
538
|
-
{showLessLabel}
|
|
539
|
-
<svg
|
|
540
|
-
className="w-3 h-3"
|
|
541
|
-
fill="none"
|
|
542
|
-
stroke="currentColor"
|
|
543
|
-
viewBox="0 0 24 24"
|
|
544
|
-
>
|
|
545
|
-
<path
|
|
546
|
-
strokeLinecap="round"
|
|
547
|
-
strokeLinejoin="round"
|
|
548
|
-
strokeWidth={2}
|
|
549
|
-
d="M5 15l7-7 7 7"
|
|
550
|
-
/>
|
|
551
|
-
</svg>
|
|
552
|
-
</>
|
|
553
|
-
)}
|
|
554
|
-
</button>
|
|
555
|
-
);
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* MarkdownMessage - Renders markdown content with syntax highlighting and GFM support
|
|
560
|
-
*
|
|
561
|
-
* Features:
|
|
562
|
-
* - GitHub Flavored Markdown (GFM) support
|
|
563
|
-
* - Syntax highlighted code blocks with copy button
|
|
564
|
-
* - Mermaid diagram rendering
|
|
565
|
-
* - Tables, lists, blockquotes
|
|
566
|
-
* - User/assistant styling modes
|
|
567
|
-
* - Plain text optimization (skips ReactMarkdown for simple text)
|
|
568
|
-
* - Collapsible "Read more..." for long messages
|
|
569
|
-
*
|
|
570
|
-
* @example
|
|
571
|
-
* ```tsx
|
|
572
|
-
* <MarkdownMessage content="# Hello\n\nThis is **bold** text." />
|
|
573
|
-
*
|
|
574
|
-
* // User message styling
|
|
575
|
-
* <MarkdownMessage content="Some content" isUser />
|
|
576
|
-
*
|
|
577
|
-
* // Collapsible long content (for chat apps)
|
|
578
|
-
* <MarkdownMessage
|
|
579
|
-
* content={longText}
|
|
580
|
-
* collapsible
|
|
581
|
-
* maxLength={300}
|
|
582
|
-
* maxLines={5}
|
|
583
|
-
* />
|
|
584
|
-
* ```
|
|
585
|
-
*/
|
|
586
|
-
export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
587
|
-
content,
|
|
588
|
-
className = "",
|
|
589
|
-
isUser = false,
|
|
590
|
-
isCompact = false,
|
|
591
|
-
customComponents,
|
|
592
|
-
extraHrefProtocols,
|
|
593
|
-
collapsible = false,
|
|
594
|
-
maxLength,
|
|
595
|
-
maxLines,
|
|
596
|
-
readMoreLabel = "Read more...",
|
|
597
|
-
showLessLabel = "Show less",
|
|
598
|
-
defaultExpanded = false,
|
|
599
|
-
onCollapseChange,
|
|
600
|
-
}) => {
|
|
601
|
-
// Trim content to remove leading/trailing whitespace and empty lines
|
|
602
|
-
const trimmedContent = content.trim();
|
|
603
|
-
|
|
604
|
-
// Collapsible content logic - use defaults when collapsible is enabled
|
|
605
|
-
const collapsibleOptions = React.useMemo(() => {
|
|
606
|
-
if (!collapsible) return {};
|
|
607
|
-
// Default limits when collapsible is enabled but no limits specified
|
|
608
|
-
const effectiveMaxLength = maxLength ?? 1000;
|
|
609
|
-
const effectiveMaxLines = maxLines ?? 10;
|
|
610
|
-
return { maxLength: effectiveMaxLength, maxLines: effectiveMaxLines, defaultExpanded };
|
|
611
|
-
}, [collapsible, maxLength, maxLines, defaultExpanded]);
|
|
612
|
-
|
|
613
|
-
const {
|
|
614
|
-
isCollapsed,
|
|
615
|
-
toggleCollapsed,
|
|
616
|
-
displayContent,
|
|
617
|
-
shouldCollapse,
|
|
618
|
-
} = useCollapsibleContent(
|
|
619
|
-
trimmedContent,
|
|
620
|
-
collapsible ? collapsibleOptions : {}
|
|
621
|
-
);
|
|
622
|
-
|
|
623
|
-
// Call onCollapseChange when state changes
|
|
624
|
-
React.useEffect(() => {
|
|
625
|
-
if (collapsible && shouldCollapse && onCollapseChange) {
|
|
626
|
-
onCollapseChange(isCollapsed);
|
|
627
|
-
}
|
|
628
|
-
}, [isCollapsed, collapsible, shouldCollapse, onCollapseChange]);
|
|
629
|
-
|
|
630
|
-
const components = React.useMemo(() => {
|
|
631
|
-
const base = createMarkdownComponents(isUser, isCompact);
|
|
632
|
-
return customComponents ? { ...base, ...customComponents } : base;
|
|
633
|
-
}, [isUser, isCompact, customComponents]);
|
|
634
|
-
|
|
635
|
-
const schema = React.useMemo(() => buildSchema(extraHrefProtocols), [extraHrefProtocols]);
|
|
636
|
-
const urlTransform = React.useMemo(() => buildUrlTransform(extraHrefProtocols), [extraHrefProtocols]);
|
|
637
|
-
|
|
638
|
-
const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
|
|
639
|
-
const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
|
|
640
|
-
|
|
641
|
-
// For plain text without markdown, render directly without ReactMarkdown.
|
|
642
|
-
// Skip the fast path when caller provided custom renderers — they may
|
|
643
|
-
// need to fire on plain `[label](custom://…)` links that the fast path
|
|
644
|
-
// would print verbatim.
|
|
645
|
-
const isPlainText = !customComponents && !hasMarkdownSyntax(displayContent);
|
|
646
|
-
|
|
647
|
-
// Render plain text - use CSS white-space: pre-line to preserve newlines
|
|
648
|
-
if (isPlainText) {
|
|
649
|
-
return (
|
|
650
|
-
<span className={`${textSizeClass} leading-7 break-words whitespace-pre-line font-light ${className}`}>
|
|
651
|
-
{displayContent}
|
|
652
|
-
{collapsible && shouldCollapse && (
|
|
653
|
-
<>
|
|
654
|
-
{isCollapsed && '... '}
|
|
655
|
-
<CollapseToggle
|
|
656
|
-
isCollapsed={isCollapsed}
|
|
657
|
-
onClick={toggleCollapsed}
|
|
658
|
-
readMoreLabel={readMoreLabel}
|
|
659
|
-
showLessLabel={showLessLabel}
|
|
660
|
-
isUser={isUser}
|
|
661
|
-
isCompact={isCompact}
|
|
662
|
-
/>
|
|
663
|
-
</>
|
|
664
|
-
)}
|
|
665
|
-
</span>
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Render markdown
|
|
670
|
-
return (
|
|
671
|
-
<div className={className}>
|
|
672
|
-
<div
|
|
673
|
-
className={`
|
|
674
|
-
prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
|
|
675
|
-
${isUser ? 'prose-invert' : 'dark:prose-invert'}
|
|
676
|
-
[&>*]:leading-7
|
|
677
|
-
`}
|
|
678
|
-
style={{
|
|
679
|
-
// Inherit colors from parent - fixes issues with external CSS variables
|
|
680
|
-
'--tw-prose-body': 'inherit',
|
|
681
|
-
'--tw-prose-headings': 'inherit',
|
|
682
|
-
'--tw-prose-bold': 'inherit',
|
|
683
|
-
'--tw-prose-links': 'inherit',
|
|
684
|
-
color: 'inherit',
|
|
685
|
-
} as React.CSSProperties}
|
|
686
|
-
>
|
|
687
|
-
<ReactMarkdown
|
|
688
|
-
remarkPlugins={[remarkGfm]}
|
|
689
|
-
// ``rehype-raw`` parses inline HTML in the source (OpenAPI
|
|
690
|
-
// ``description`` fields often contain ``<br>`` / ``<b>`` /
|
|
691
|
-
// ``<code>``). ``rehype-sanitize`` with our extended schema
|
|
692
|
-
// runs after it so the allowed tags pass through while
|
|
693
|
-
// anything risky (scripts, event handlers, javascript: urls)
|
|
694
|
-
// is stripped.
|
|
695
|
-
rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
|
|
696
|
-
components={components}
|
|
697
|
-
// ``urlTransform`` runs in remark-rehype before sanitize.
|
|
698
|
-
// Without overriding it, react-markdown's default would
|
|
699
|
-
// strip `href` for any custom scheme (cmdop://…) — making
|
|
700
|
-
// sanitize whitelist moot. Only override when the caller
|
|
701
|
-
// opted into extra protocols.
|
|
702
|
-
urlTransform={urlTransform}
|
|
703
|
-
>
|
|
704
|
-
{displayContent}
|
|
705
|
-
</ReactMarkdown>
|
|
706
|
-
</div>
|
|
707
|
-
{collapsible && shouldCollapse && (
|
|
708
|
-
<CollapseToggle
|
|
709
|
-
isCollapsed={isCollapsed}
|
|
710
|
-
onClick={toggleCollapsed}
|
|
711
|
-
readMoreLabel={readMoreLabel}
|
|
712
|
-
showLessLabel={showLessLabel}
|
|
713
|
-
isUser={isUser}
|
|
714
|
-
isCompact={isCompact}
|
|
715
|
-
/>
|
|
716
|
-
)}
|
|
717
|
-
</div>
|
|
718
|
-
);
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
export default MarkdownMessage;
|