@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
@@ -0,0 +1,171 @@
1
+ import type React from 'react';
2
+ import { defineStory } from '@djangocfg/playground';
3
+ import { MarkdownMessage } from './MarkdownMessage';
4
+ import type { LinkRule } from './types';
5
+
6
+ export default defineStory({
7
+ title: 'Components/Markdown Message',
8
+ component: MarkdownMessage,
9
+ description:
10
+ 'Chat markdown renderer. Stories cover the declarative `linkRules` API for handling custom URL schemes (e.g. cmdop://) without hand-writing a custom `a` renderer.',
11
+ });
12
+
13
+ // Canonical mention shape: `[label](cmdop://machine/<uuid>)`. The
14
+ // `@` prefix you sometimes see in composers (`@[label](href)`) is
15
+ // decorative; the chip itself reads as the mention indicator. The
16
+ // rule below strips the leading `@` in `preprocess` so the page
17
+ // doesn't show "@<chip>".
18
+ const CMDOP_CONTENT = `Talk to @[Vps-audi](cmdop://machine/abc-123) about deployment.
19
+
20
+ Inline reference to [Server-2](cmdop://machine/def-456) inside a sentence.
21
+
22
+ Plain links survive: [docs](https://example.com).
23
+ Local file: [file](cmdop://local?path=/tmp/x.md).`;
24
+
25
+ // Chip stand-in. Two variants — `subtle` for chips on neutral cards,
26
+ // `onPrimary` for chips inside a saturated `bg-primary` bubble.
27
+ function MentionChip({
28
+ id,
29
+ label,
30
+ variant = 'subtle',
31
+ }: {
32
+ id: string;
33
+ label: string;
34
+ variant?: 'subtle' | 'onPrimary';
35
+ }) {
36
+ const onClick = () => {
37
+ // eslint-disable-next-line no-console
38
+ console.log('[story] MentionChip clicked', { id, label });
39
+ if (typeof window !== 'undefined') alert(`Chip clicked: ${label} (${id})`);
40
+ };
41
+ const palette =
42
+ variant === 'onPrimary'
43
+ ? 'bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25'
44
+ : 'bg-primary/10 text-primary hover:bg-primary/20';
45
+ return (
46
+ <button
47
+ type="button"
48
+ onClick={onClick}
49
+ className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-sans font-medium align-baseline transition-colors ${palette}`}
50
+ >
51
+ <span
52
+ className={
53
+ variant === 'onPrimary'
54
+ ? 'h-1.5 w-1.5 rounded-full bg-primary-foreground/70'
55
+ : 'h-1.5 w-1.5 rounded-full bg-emerald-500'
56
+ }
57
+ aria-hidden
58
+ />
59
+ <span>{label}</span>
60
+ </button>
61
+ );
62
+ }
63
+
64
+ // Helper used by the chip rule: pull a clean string label from
65
+ // react-markdown's children (string, number, array, or React element).
66
+ function extractLabel(node: React.ReactNode): string {
67
+ if (typeof node === 'string') return node;
68
+ if (typeof node === 'number') return String(node);
69
+ if (Array.isArray(node)) return node.map(extractLabel).join('');
70
+ return '';
71
+ }
72
+
73
+ function makeRules(chipVariant: 'subtle' | 'onPrimary'): LinkRule[] {
74
+ return [
75
+ {
76
+ name: 'cmdop-machine-mention',
77
+ protocols: ['cmdop'],
78
+ // Strip the decorative leading `@` from `@[label](cmdop://machine/<id>)`
79
+ // so the chip alone reads as the mention indicator.
80
+ preprocess: (s) =>
81
+ s.replace(/(^|[^A-Za-z0-9_])@(\[[^\]]+\]\(cmdop:\/\/machine\/[^)\s]+\))/g, '$1$2'),
82
+ match: (href) => href.startsWith('cmdop://machine/'),
83
+ render: ({ href, children }) => {
84
+ const id = href.slice('cmdop://machine/'.length).trim();
85
+ const label = extractLabel(children) || id;
86
+ return <MentionChip id={id} label={label} variant={chipVariant} />;
87
+ },
88
+ },
89
+ {
90
+ name: 'cmdop-local-file',
91
+ protocols: ['cmdop'],
92
+ match: (href) => href.startsWith('cmdop://local'),
93
+ render: ({ href, children }) => (
94
+ <a
95
+ href={href}
96
+ onClick={(e) => {
97
+ e.preventDefault();
98
+ // eslint-disable-next-line no-console
99
+ console.log('[story] local file click', { href });
100
+ if (typeof window !== 'undefined') alert(`Local link: ${href}`);
101
+ }}
102
+ className={
103
+ chipVariant === 'onPrimary'
104
+ ? 'text-primary-foreground/90 underline hover:text-primary-foreground'
105
+ : 'text-primary underline hover:text-primary/80'
106
+ }
107
+ >
108
+ {children}
109
+ </a>
110
+ ),
111
+ },
112
+ ];
113
+ }
114
+
115
+ const subtleRules = makeRules('subtle');
116
+ const onPrimaryRules = makeRules('onPrimary');
117
+
118
+ export const LinkRules = () => (
119
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
120
+ <div>
121
+ <h3 className="mb-2 text-sm font-semibold">
122
+ Declarative rules — preferred API
123
+ </h3>
124
+ <p className="mb-3 text-xs text-muted-foreground">
125
+ Each rule declares its protocol, an optional preprocess pass,
126
+ a match predicate, and a render. <code>MarkdownMessage</code>
127
+ composes them: per-rule sanitize whitelist, custom <code>a</code>
128
+ renderer, and source rewrites all happen behind one prop.
129
+ </p>
130
+ <div className="rounded-lg border border-border bg-card p-4 text-card-foreground">
131
+ <MarkdownMessage
132
+ content={CMDOP_CONTENT}
133
+ isUser={false}
134
+ linkRules={subtleRules}
135
+ />
136
+ </div>
137
+ </div>
138
+
139
+ <div>
140
+ <h3 className="mb-2 text-sm font-semibold">
141
+ Inside a saturated bubble (<code>bg-primary</code>)
142
+ </h3>
143
+ <p className="mb-3 text-xs text-muted-foreground">
144
+ Same content, rules carrying the on-primary palette. Theme
145
+ tokens (<code>primary-foreground</code>) keep contrast in light
146
+ and dark themes.
147
+ </p>
148
+ <div className="rounded-xl bg-primary p-4 text-primary-foreground">
149
+ <MarkdownMessage
150
+ content={CMDOP_CONTENT}
151
+ isUser
152
+ linkRules={onPrimaryRules}
153
+ />
154
+ </div>
155
+ </div>
156
+
157
+ <div>
158
+ <h3 className="mb-2 text-sm font-semibold">
159
+ Without rules (control)
160
+ </h3>
161
+ <p className="mb-3 text-xs text-muted-foreground">
162
+ No rules → react-markdown's default urlTransform strips
163
+ <code> cmdop://</code> hrefs before render. Plain text falls
164
+ through; cmdop links degrade to label-only anchors.
165
+ </p>
166
+ <div className="rounded-lg border border-border bg-card p-4 text-card-foreground">
167
+ <MarkdownMessage content={CMDOP_CONTENT} isUser={false} />
168
+ </div>
169
+ </div>
170
+ </div>
171
+ );
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import rehypeRaw from 'rehype-raw';
6
+ import rehypeSanitize from 'rehype-sanitize';
7
+ import remarkGfm from 'remark-gfm';
8
+ import type { Components } from 'react-markdown';
9
+
10
+ import { useCollapsibleContent } from '../useCollapsibleContent';
11
+ import type { MarkdownMessageProps } from './types';
12
+ import { buildSchema, buildUrlTransform } from './sanitize';
13
+ import { hasMarkdownSyntax } from './plainText';
14
+ import { createMarkdownComponents } from './components';
15
+ import { CollapseToggle } from './CollapseToggle';
16
+ import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
17
+
18
+ /**
19
+ * MarkdownMessage — chat-tuned markdown renderer.
20
+ *
21
+ * Features:
22
+ * - GitHub Flavored Markdown (GFM) via remark-gfm
23
+ * - Syntax-highlighted code blocks with Copy button
24
+ * - Mermaid diagram rendering (` ```mermaid ` fence)
25
+ * - Tables, lists, blockquotes scaled for chat density
26
+ * - User vs assistant styling modes (`isUser`)
27
+ * - Plain-text fast path: skips ReactMarkdown when content has no
28
+ * markdown syntax (cheaper render, preserves newlines via CSS)
29
+ * - Optional collapsible "Read more..." for long messages
30
+ *
31
+ * Custom URL schemes (chat mentions, deep-links, custom file viewers)
32
+ * are best handled with the declarative `linkRules` prop — see the
33
+ * type definition in `./types.ts` and the storybook for examples.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * <MarkdownMessage content="# Hello\n\nThis is **bold** text." />
38
+ *
39
+ * // User message styling
40
+ * <MarkdownMessage content="Some content" isUser />
41
+ *
42
+ * // Custom URL scheme via linkRules
43
+ * <MarkdownMessage
44
+ * content="Talk to [Vps-audi](cmdop://machine/abc-123)"
45
+ * linkRules={[machineMentionRule]}
46
+ * />
47
+ * ```
48
+ */
49
+ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
50
+ content,
51
+ className = '',
52
+ isUser = false,
53
+ isCompact = false,
54
+ customComponents,
55
+ extraHrefProtocols,
56
+ linkRules,
57
+ collapsible = false,
58
+ maxLength,
59
+ maxLines,
60
+ readMoreLabel = 'Read more...',
61
+ showLessLabel = 'Show less',
62
+ defaultExpanded = false,
63
+ onCollapseChange,
64
+ }) => {
65
+ // Pre-process content through any rules that requested it. Done
66
+ // before trim so a rule can rewrite multi-line shapes too.
67
+ const preprocessed = React.useMemo(
68
+ () => applyPreprocess(content, linkRules),
69
+ [content, linkRules],
70
+ );
71
+
72
+ // Union of `extraHrefProtocols` and any `protocols` declared by rules.
73
+ const effectiveProtocols = React.useMemo(
74
+ () => collectProtocols(extraHrefProtocols, linkRules),
75
+ [extraHrefProtocols, linkRules],
76
+ );
77
+
78
+ // Effective custom components: merge linkRules' synthesized `a`
79
+ // renderer with any caller-provided `customComponents`. linkRules
80
+ // wins when both target the same href (rule is the more specific
81
+ // declarative claim by design).
82
+ const effectiveCustomComponents = React.useMemo<Partial<Components> | undefined>(() => {
83
+ if (!linkRules || linkRules.length === 0) return customComponents;
84
+ const callerA = customComponents?.a;
85
+ const aRenderer = buildLinkRulesComponent(linkRules, isUser, callerA);
86
+ return { ...(customComponents ?? {}), a: aRenderer };
87
+ }, [customComponents, linkRules, isUser]);
88
+
89
+ const trimmedContent = preprocessed.trim();
90
+
91
+ // Collapsible content logic — defaults kick in only when enabled.
92
+ const collapsibleOptions = React.useMemo(() => {
93
+ if (!collapsible) return {};
94
+ return {
95
+ maxLength: maxLength ?? 1000,
96
+ maxLines: maxLines ?? 10,
97
+ defaultExpanded,
98
+ };
99
+ }, [collapsible, maxLength, maxLines, defaultExpanded]);
100
+
101
+ const { isCollapsed, toggleCollapsed, displayContent, shouldCollapse } =
102
+ useCollapsibleContent(trimmedContent, collapsible ? collapsibleOptions : {});
103
+
104
+ React.useEffect(() => {
105
+ if (collapsible && shouldCollapse && onCollapseChange) {
106
+ onCollapseChange(isCollapsed);
107
+ }
108
+ }, [isCollapsed, collapsible, shouldCollapse, onCollapseChange]);
109
+
110
+ const components = React.useMemo(() => {
111
+ const base = createMarkdownComponents(isUser, isCompact);
112
+ return effectiveCustomComponents ? { ...base, ...effectiveCustomComponents } : base;
113
+ }, [isUser, isCompact, effectiveCustomComponents]);
114
+
115
+ const schema = React.useMemo(() => buildSchema(effectiveProtocols), [effectiveProtocols]);
116
+ const urlTransform = React.useMemo(
117
+ () => buildUrlTransform(effectiveProtocols),
118
+ [effectiveProtocols],
119
+ );
120
+
121
+ const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
122
+ const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
123
+
124
+ // Plain-text fast path: skip ReactMarkdown when content is pure
125
+ // prose. Bypassed when the caller wired up custom components or
126
+ // link rules — those need to fire on plain `[label](custom://…)`
127
+ // links the fast path would otherwise print verbatim.
128
+ const isPlainText =
129
+ !effectiveCustomComponents && !hasMarkdownSyntax(displayContent);
130
+
131
+ if (isPlainText) {
132
+ return (
133
+ <span
134
+ className={`${textSizeClass} leading-7 break-words whitespace-pre-line font-light ${className}`}
135
+ >
136
+ {displayContent}
137
+ {collapsible && shouldCollapse && (
138
+ <>
139
+ {isCollapsed && '... '}
140
+ <CollapseToggle
141
+ isCollapsed={isCollapsed}
142
+ onClick={toggleCollapsed}
143
+ readMoreLabel={readMoreLabel}
144
+ showLessLabel={showLessLabel}
145
+ isUser={isUser}
146
+ isCompact={isCompact}
147
+ />
148
+ </>
149
+ )}
150
+ </span>
151
+ );
152
+ }
153
+
154
+ return (
155
+ <div className={className}>
156
+ <div
157
+ className={`
158
+ prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
159
+ ${isUser ? 'prose-invert' : 'dark:prose-invert'}
160
+ [&>*]:leading-7
161
+ `}
162
+ style={{
163
+ // Inherit colors from parent — fixes issues with external
164
+ // CSS variables overriding prose tokens.
165
+ '--tw-prose-body': 'inherit',
166
+ '--tw-prose-headings': 'inherit',
167
+ '--tw-prose-bold': 'inherit',
168
+ '--tw-prose-links': 'inherit',
169
+ color: 'inherit',
170
+ } as React.CSSProperties}
171
+ >
172
+ <ReactMarkdown
173
+ remarkPlugins={[remarkGfm]}
174
+ // rehype-raw parses inline HTML in the source; rehype-sanitize
175
+ // (with our extended schema) runs after to keep XSS guards
176
+ // (no scripts, no on* handlers, no javascript: urls).
177
+ rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
178
+ components={components}
179
+ // urlTransform runs in remark-rehype before sanitize. Without
180
+ // overriding it, react-markdown's default strips `href` for
181
+ // unknown schemes — making our extended sanitize whitelist
182
+ // moot. Only set when the caller opted into extra protocols.
183
+ urlTransform={urlTransform}
184
+ >
185
+ {displayContent}
186
+ </ReactMarkdown>
187
+ </div>
188
+ {collapsible && shouldCollapse && (
189
+ <CollapseToggle
190
+ isCollapsed={isCollapsed}
191
+ onClick={toggleCollapsed}
192
+ readMoreLabel={readMoreLabel}
193
+ showLessLabel={showLessLabel}
194
+ isUser={isUser}
195
+ isCompact={isCompact}
196
+ />
197
+ )}
198
+ </div>
199
+ );
200
+ };
201
+
202
+ export default MarkdownMessage;
@@ -0,0 +1,154 @@
1
+ import React from 'react';
2
+ import type { Components } from 'react-markdown';
3
+ import Mermaid from '../../../tools/Mermaid';
4
+ import { CodeBlock, CodeBlockFallback } from './CodeBlock';
5
+ import { extractTextFromChildren } from './plainText';
6
+
7
+ /**
8
+ * Build the chat-tuned markdown component map.
9
+ *
10
+ * Base text size: text-sm (14px) for normal, text-xs (12px) for compact.
11
+ * Heading sizes are scaled down a notch from the prose defaults so
12
+ * inline-in-chat headings don't dominate.
13
+ */
14
+ export function createMarkdownComponents(
15
+ isUser: boolean = false,
16
+ isCompact: boolean = false,
17
+ ): Components {
18
+ const textSize = isCompact ? 'text-xs' : 'text-sm';
19
+ const headingBase = isCompact ? 'text-sm' : 'text-base';
20
+ const headingSm = isCompact ? 'text-xs' : 'text-sm';
21
+
22
+ return {
23
+ h1: ({ children }) => (
24
+ <h1 className={`${headingBase} font-bold mb-2 mt-3 first:mt-0`}>{children}</h1>
25
+ ),
26
+ h2: ({ children }) => (
27
+ <h2 className={`${headingSm} font-bold mb-2 mt-3 first:mt-0`}>{children}</h2>
28
+ ),
29
+ h3: ({ children }) => (
30
+ <h3 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h3>
31
+ ),
32
+ h4: ({ children }) => (
33
+ <h4 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h4>
34
+ ),
35
+ h5: ({ children }) => (
36
+ <h5 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h5>
37
+ ),
38
+ h6: ({ children }) => (
39
+ <h6 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h6>
40
+ ),
41
+
42
+ p: ({ children }) => (
43
+ <p className={`${textSize} mb-4 last:mb-0 leading-7 break-words font-light`}>{children}</p>
44
+ ),
45
+
46
+ ul: ({ children }) => (
47
+ <ul className={`list-disc list-inside mb-2 space-y-1 ${textSize}`}>{children}</ul>
48
+ ),
49
+ ol: ({ children }) => (
50
+ <ol className={`list-decimal list-inside mb-2 space-y-1 ${textSize}`}>{children}</ol>
51
+ ),
52
+ li: ({ children }) => <li className="break-words">{children}</li>,
53
+
54
+ a: ({ href, children }) => (
55
+ <a
56
+ href={href}
57
+ className={`${textSize} ${
58
+ isUser
59
+ ? 'text-white/90 underline hover:text-white'
60
+ : 'text-primary underline hover:text-primary/80'
61
+ } transition-colors break-all`}
62
+ target={href?.startsWith('http') ? '_blank' : undefined}
63
+ rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
64
+ >
65
+ {children}
66
+ </a>
67
+ ),
68
+
69
+ pre: ({ children }) => {
70
+ let codeContent = '';
71
+ let language = 'plaintext';
72
+
73
+ if (React.isValidElement(children)) {
74
+ const child = children;
75
+ if (
76
+ child.type === 'code'
77
+ || (typeof child.type === 'function' && child.type.name === 'code')
78
+ ) {
79
+ const codeProps = child.props as {
80
+ className?: string;
81
+ children?: React.ReactNode;
82
+ };
83
+ const rawClassName = codeProps.className;
84
+ language = rawClassName?.replace(/language-/, '').trim() || 'plaintext';
85
+ codeContent = extractTextFromChildren(codeProps.children).trim();
86
+ } else {
87
+ codeContent = extractTextFromChildren(children).trim();
88
+ }
89
+ } else {
90
+ codeContent = extractTextFromChildren(children).trim();
91
+ }
92
+
93
+ if (!codeContent) {
94
+ return (
95
+ <div className="my-3 p-3 bg-muted rounded text-sm text-muted-foreground">
96
+ No content available
97
+ </div>
98
+ );
99
+ }
100
+
101
+ if (language === 'mermaid') {
102
+ return (
103
+ <div className="my-3 max-w-full overflow-x-auto">
104
+ <Mermaid chart={codeContent} className="max-w-[600px] mx-auto" isCompact={isCompact} />
105
+ </div>
106
+ );
107
+ }
108
+
109
+ try {
110
+ return <CodeBlock code={codeContent} language={language} isUser={isUser} isCompact={isCompact} />;
111
+ } catch (error) {
112
+ // eslint-disable-next-line no-console
113
+ console.warn('CodeBlock failed, using fallback:', error);
114
+ return <CodeBlockFallback code={codeContent} language={language} isUser={isUser} isCompact={isCompact} />;
115
+ }
116
+ },
117
+
118
+ code: ({ children, className }) => {
119
+ // Inside <pre>: let pre handle styling.
120
+ if (className?.includes('language-')) {
121
+ return <code className={className}>{children}</code>;
122
+ }
123
+ return (
124
+ <code className="px-1.5 py-0.5 rounded text-xs font-mono bg-muted text-foreground break-all">
125
+ {extractTextFromChildren(children)}
126
+ </code>
127
+ );
128
+ },
129
+
130
+ blockquote: ({ children }) => (
131
+ <blockquote className={`${textSize} border-l-2 border-border pl-3 my-2 italic text-muted-foreground break-words`}>
132
+ {children}
133
+ </blockquote>
134
+ ),
135
+
136
+ table: ({ children }) => (
137
+ <div className="overflow-x-auto my-3">
138
+ <table className={`min-w-full ${textSize} border-collapse`}>{children}</table>
139
+ </div>
140
+ ),
141
+ thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
142
+ tbody: ({ children }) => <tbody>{children}</tbody>,
143
+ tr: ({ children }) => <tr className="border-b border-border/50">{children}</tr>,
144
+ th: ({ children }) => (
145
+ <th className="px-2 py-1 text-left font-medium break-words">{children}</th>
146
+ ),
147
+ td: ({ children }) => <td className="px-2 py-1 break-words">{children}</td>,
148
+
149
+ hr: () => <hr className="my-3 border-0 h-px bg-border" />,
150
+
151
+ strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
152
+ em: ({ children }) => <em className="italic">{children}</em>,
153
+ };
154
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * MarkdownMessage — chat-tuned markdown renderer.
3
+ *
4
+ * Public API:
5
+ * - `MarkdownMessage` (default + named) — the React component.
6
+ * - `MarkdownMessageProps` — its full prop shape.
7
+ * - `LinkRule` — the declarative link-handling primitive.
8
+ * - `extractTextFromChildren` — utility for callers that build their
9
+ * own custom `a` renderers and need a string label.
10
+ */
11
+ export { MarkdownMessage, default } from './MarkdownMessage';
12
+ export type { MarkdownMessageProps, LinkRule } from './types';
13
+ export { extractTextFromChildren } from './plainText';
@@ -0,0 +1,83 @@
1
+ import React from 'react';
2
+ import type { ComponentProps, ComponentType } from 'react';
3
+ import type { Components } from 'react-markdown';
4
+ import type { LinkRule } from './types';
5
+
6
+ // react-markdown's `Components['a']` is `keyof IntrinsicElements | ComponentType<…>`,
7
+ // so it isn't necessarily a function we can directly call. This is the
8
+ // component-shaped variant — what callers practically pass when they
9
+ // override `a`.
10
+ type AComponent = ComponentType<ComponentProps<'a'>>;
11
+
12
+ /** Run every rule's `preprocess` hook in order. Errors in a single
13
+ * rule are logged and skipped — the other rules still run. */
14
+ export function applyPreprocess(
15
+ source: string,
16
+ rules: readonly LinkRule[] | undefined,
17
+ ): string {
18
+ if (!rules || rules.length === 0) return source;
19
+ let s = source;
20
+ for (const rule of rules) {
21
+ if (!rule.preprocess) continue;
22
+ try {
23
+ s = rule.preprocess(s);
24
+ } catch (err) {
25
+ // eslint-disable-next-line no-console
26
+ console.warn(
27
+ `[MarkdownMessage] linkRule "${rule.name ?? '(anonymous)'}" preprocess threw; skipping`,
28
+ err,
29
+ );
30
+ }
31
+ }
32
+ return s;
33
+ }
34
+
35
+ /** Union of `extraHrefProtocols` and any `protocols` declared by rules. */
36
+ export function collectProtocols(
37
+ extraHrefProtocols: readonly string[] | undefined,
38
+ rules: readonly LinkRule[] | undefined,
39
+ ): readonly string[] | undefined {
40
+ const set = new Set<string>();
41
+ if (extraHrefProtocols) for (const p of extraHrefProtocols) set.add(p);
42
+ if (rules) {
43
+ for (const r of rules) {
44
+ if (r.protocols) for (const p of r.protocols) set.add(p);
45
+ }
46
+ }
47
+ return set.size === 0 ? undefined : Array.from(set);
48
+ }
49
+
50
+ /** Build a custom `a` renderer that dispatches to the first matching
51
+ * rule, falling through to `callerA` (or the built-in chat anchor)
52
+ * for anything no rule claims. */
53
+ export function buildLinkRulesComponent(
54
+ rules: readonly LinkRule[],
55
+ isUser: boolean,
56
+ callerA: NonNullable<Components['a']> | undefined,
57
+ ): NonNullable<Components['a']> {
58
+ const Renderer: AComponent = (props) => {
59
+ const { href, children } = props;
60
+ if (typeof href === 'string') {
61
+ for (const rule of rules) {
62
+ if (rule.match(href)) {
63
+ return React.createElement(
64
+ React.Fragment,
65
+ null,
66
+ rule.render({ href, children, isUser }),
67
+ );
68
+ }
69
+ }
70
+ }
71
+ // Defer to the caller's `a` override if any. We only call it when
72
+ // it's a function/component — string-form intrinsic overrides
73
+ // (`'a' | 'span' | …`) aren't a thing react-markdown supports for
74
+ // tag overrides, but the type still admits the union, so guard.
75
+ if (callerA && typeof callerA === 'function') {
76
+ const Caller = callerA as AComponent;
77
+ return React.createElement(Caller, props);
78
+ }
79
+ // Fall through to the built-in anchor by rendering a plain `<a>`.
80
+ return React.createElement('a', props, children);
81
+ };
82
+ return Renderer as NonNullable<Components['a']>;
83
+ }
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+
3
+ /** Recursively concatenate the text content of a React.ReactNode tree.
4
+ * Used by the markdown renderers (e.g. `<pre>` extracting code) and
5
+ * by consumers that need a plain-string label out of link children. */
6
+ export function extractTextFromChildren(children: React.ReactNode): string {
7
+ if (typeof children === 'string') return children;
8
+ if (typeof children === 'number') return String(children);
9
+ if (React.isValidElement(children)) {
10
+ const props = children.props as { children?: React.ReactNode };
11
+ return extractTextFromChildren(props.children);
12
+ }
13
+ if (Array.isArray(children)) {
14
+ return children.map(extractTextFromChildren).join('');
15
+ }
16
+ return '';
17
+ }
18
+
19
+ /** Affordance test: does this string look like markdown? Used to skip
20
+ * the (heavier) ReactMarkdown pipeline when the content is pure
21
+ * prose. NOT a validator — false negatives are fine; false positives
22
+ * cost a render but render correctly. */
23
+ export function hasMarkdownSyntax(text: string): boolean {
24
+ // Newlines after trim → render as markdown so paragraphs work.
25
+ if (text.trim().includes('\n')) return true;
26
+
27
+ // Inline HTML tags (`<br>`, `<b>`, `<code>`, …) — common in OpenAPI
28
+ // descriptions. Without this branch the fast path would escape them
29
+ // and the user sees literal angle brackets.
30
+ if (/<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?\/?>/.test(text)) return true;
31
+
32
+ const patterns = [
33
+ /^#{1,6}\s/m, // Headers
34
+ /\*\*[^*]+\*\*/, // Bold
35
+ /\*[^*]+\*/, // Italic
36
+ /__[^_]+__/, // Bold (underscore)
37
+ /_[^_]+_/, // Italic (underscore)
38
+ /\[.+\]\(.+\)/, // Links
39
+ /!\[.*\]\(.+\)/, // Images
40
+ /```[\s\S]*```/, // Code blocks
41
+ /`[^`]+`/, // Inline code
42
+ /^\s*[-*+]\s/m, // Unordered lists
43
+ /^\s*\d+\.\s/m, // Ordered lists
44
+ /^\s*>/m, // Blockquotes
45
+ /\|.+\|/, // Tables
46
+ /^---+$/m, // Horizontal rules
47
+ /~~[^~]+~~/, // Strikethrough
48
+ ];
49
+ return patterns.some((p) => p.test(text));
50
+ }