@djangocfg/ui-tools 2.1.297 → 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-3HNAQQRE.mjs → DocsLayout-MWRKNFXR.mjs} +3 -3
- package/dist/{DocsLayout-3HNAQQRE.mjs.map → DocsLayout-MWRKNFXR.mjs.map} +1 -1
- package/dist/{DocsLayout-O4ONSD67.cjs → DocsLayout-NWJUF42A.cjs} +48 -48
- package/dist/{DocsLayout-O4ONSD67.cjs.map → DocsLayout-NWJUF42A.cjs.map} +1 -1
- package/dist/{chunk-DKJTH4GE.mjs → chunk-CKD7GNE5.mjs} +236 -186
- package/dist/chunk-CKD7GNE5.mjs.map +1 -0
- package/dist/{chunk-QTO5LWMK.cjs → chunk-SEXWBCLX.cjs} +272 -221
- 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-DKJTH4GE.mjs.map +0 -1
- package/dist/chunk-QTO5LWMK.cjs.map +0 -1
- package/src/components/markdown/MarkdownMessage.tsx +0 -686
|
@@ -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
|
+
}
|