@djangocfg/ui-tools 2.1.298 → 2.1.300

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.
Files changed (28) hide show
  1. package/README.md +126 -2
  2. package/dist/{DocsLayout-74WIW7L3.mjs → DocsLayout-MWRKNFXR.mjs} +3 -3
  3. package/dist/{DocsLayout-74WIW7L3.mjs.map → DocsLayout-MWRKNFXR.mjs.map} +1 -1
  4. package/dist/{DocsLayout-IA55EXRN.cjs → DocsLayout-NWJUF42A.cjs} +48 -48
  5. package/dist/{DocsLayout-IA55EXRN.cjs.map → DocsLayout-NWJUF42A.cjs.map} +1 -1
  6. package/dist/{chunk-2BBXP3DH.mjs → chunk-CKD7GNE5.mjs} +220 -187
  7. package/dist/chunk-CKD7GNE5.mjs.map +1 -0
  8. package/dist/{chunk-Q6FNLXLZ.cjs → chunk-SEXWBCLX.cjs} +256 -222
  9. package/dist/chunk-SEXWBCLX.cjs.map +1 -0
  10. package/dist/index.cjs +13 -9
  11. package/dist/index.d.cts +82 -59
  12. package/dist/index.d.ts +82 -59
  13. package/dist/index.mjs +4 -4
  14. package/package.json +6 -6
  15. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +69 -0
  16. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +60 -0
  17. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +171 -0
  18. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +202 -0
  19. package/src/components/markdown/MarkdownMessage/components.tsx +154 -0
  20. package/src/components/markdown/MarkdownMessage/index.ts +13 -0
  21. package/src/components/markdown/MarkdownMessage/linkRules.ts +83 -0
  22. package/src/components/markdown/MarkdownMessage/plainText.ts +50 -0
  23. package/src/components/markdown/MarkdownMessage/sanitize.ts +78 -0
  24. package/src/components/markdown/MarkdownMessage/types.ts +104 -0
  25. package/src/components/markdown/index.ts +6 -1
  26. package/dist/chunk-2BBXP3DH.mjs.map +0 -1
  27. package/dist/chunk-Q6FNLXLZ.cjs.map +0 -1
  28. 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;