@djangocfg/widget-code 0.1.1
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/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- package/src/use-collapsible-content.ts +236 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo } from 'react';
|
|
4
|
+
import ReactMarkdown from 'react-markdown';
|
|
5
|
+
import rehypeExternalLinks from 'rehype-external-links';
|
|
6
|
+
import rehypeRaw from 'rehype-raw';
|
|
7
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
8
|
+
import remarkBreaks from 'remark-breaks';
|
|
9
|
+
import remarkEmoji from 'remark-emoji';
|
|
10
|
+
import remarkGfm from 'remark-gfm';
|
|
11
|
+
import remarkSmartypants from 'remark-smartypants';
|
|
12
|
+
import type { Components } from 'react-markdown';
|
|
13
|
+
|
|
14
|
+
import { useCollapsibleContent } from '../use-collapsible-content';
|
|
15
|
+
import type { MarkdownMessageProps } from './types';
|
|
16
|
+
import { buildSchema, buildUrlTransform } from './sanitize';
|
|
17
|
+
import { looksLikePlainProse } from './plainText';
|
|
18
|
+
import { createMarkdownComponents } from './components';
|
|
19
|
+
import { CollapseToggle } from './CollapseToggle';
|
|
20
|
+
import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
|
|
21
|
+
import { renderableTail, splitStreamingBlocks } from './streamingBlocks';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared remark plugin chain. Hoisted to a module constant so its
|
|
25
|
+
* reference is stable across every block render in streaming mode —
|
|
26
|
+
* otherwise a fresh array each render would defeat ReactMarkdown's own
|
|
27
|
+
* internal memoization and force every block to re-process.
|
|
28
|
+
*
|
|
29
|
+
* Order is load-bearing:
|
|
30
|
+
* 1. `remark-gfm` — tables, strikethrough, autolinks, task lists.
|
|
31
|
+
* 2. `remark-breaks` — chat convention: single `\n` → `<br>`.
|
|
32
|
+
* 3. `remark-smartypants` — typographic substitutions.
|
|
33
|
+
* 4. `remark-emoji` — `:smile:` → 😄.
|
|
34
|
+
*/
|
|
35
|
+
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkSmartypants, remarkEmoji] as const;
|
|
36
|
+
|
|
37
|
+
/** Props for a single rendered markdown block. Kept small so the memo
|
|
38
|
+
* comparator is cheap and precise. */
|
|
39
|
+
interface MarkdownBlockProps {
|
|
40
|
+
source: string;
|
|
41
|
+
components: Partial<Components>;
|
|
42
|
+
schema: ReturnType<typeof buildSchema>;
|
|
43
|
+
urlTransform: ReturnType<typeof buildUrlTransform> | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One memoized markdown block. In streaming mode the parent block-splits
|
|
48
|
+
* the document and renders each completed block through this; the memo
|
|
49
|
+
* comparator skips re-parsing any block whose `source` (and render config)
|
|
50
|
+
* is unchanged — so as tokens append to the tail, blocks 0..N-1 stay put.
|
|
51
|
+
*/
|
|
52
|
+
const MarkdownBlock = memo(
|
|
53
|
+
function MarkdownBlockRaw({ source, components, schema, urlTransform }: MarkdownBlockProps) {
|
|
54
|
+
return (
|
|
55
|
+
<ReactMarkdown
|
|
56
|
+
remarkPlugins={REMARK_PLUGINS as unknown as React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']}
|
|
57
|
+
// rehype-raw parses inline HTML; rehype-sanitize (extended schema)
|
|
58
|
+
// runs after to keep XSS guards; rehype-external-links tags <a>.
|
|
59
|
+
// SAME chain as the non-streaming path → identical safety posture.
|
|
60
|
+
rehypePlugins={[
|
|
61
|
+
rehypeRaw,
|
|
62
|
+
[rehypeSanitize, schema],
|
|
63
|
+
[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
|
|
64
|
+
]}
|
|
65
|
+
components={components}
|
|
66
|
+
urlTransform={urlTransform}
|
|
67
|
+
>
|
|
68
|
+
{source}
|
|
69
|
+
</ReactMarkdown>
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
(a, b) =>
|
|
73
|
+
a.source === b.source &&
|
|
74
|
+
a.components === b.components &&
|
|
75
|
+
a.schema === b.schema &&
|
|
76
|
+
a.urlTransform === b.urlTransform,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/** Body text size class for the plain-text / prose wrapper. */
|
|
80
|
+
const SIZE_TO_TEXT: Record<'xs' | 'sm' | 'base', string> = {
|
|
81
|
+
xs: 'text-xs',
|
|
82
|
+
sm: 'text-sm',
|
|
83
|
+
base: 'text-base',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Tailwind-typography modifier per size level. */
|
|
87
|
+
const SIZE_TO_PROSE: Record<'xs' | 'sm' | 'base', string> = {
|
|
88
|
+
xs: 'prose-xs',
|
|
89
|
+
sm: 'prose-sm',
|
|
90
|
+
base: 'prose-base',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* MarkdownMessage — chat-tuned markdown renderer.
|
|
95
|
+
*
|
|
96
|
+
* Features:
|
|
97
|
+
* - GitHub Flavored Markdown (GFM) via remark-gfm
|
|
98
|
+
* - Syntax-highlighted code blocks with Copy button
|
|
99
|
+
* - Mermaid diagram rendering (` ```mermaid ` fence)
|
|
100
|
+
* - Tables, lists, blockquotes scaled for chat density
|
|
101
|
+
* - User vs assistant styling modes (`isUser`)
|
|
102
|
+
* - Plain-text fast path: skips ReactMarkdown when content has no
|
|
103
|
+
* markdown syntax (cheaper render, preserves newlines via CSS)
|
|
104
|
+
* - Optional collapsible "Read more..." for long messages
|
|
105
|
+
*
|
|
106
|
+
* Custom URL schemes (chat mentions, deep-links, custom file viewers)
|
|
107
|
+
* are best handled with the declarative `linkRules` prop — see the
|
|
108
|
+
* type definition in `./types.ts` and the storybook for examples.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```tsx
|
|
112
|
+
* <MarkdownMessage content="# Hello\n\nThis is **bold** text." />
|
|
113
|
+
*
|
|
114
|
+
* // User message styling
|
|
115
|
+
* <MarkdownMessage content="Some content" isUser />
|
|
116
|
+
*
|
|
117
|
+
* // Custom URL scheme via linkRules
|
|
118
|
+
* <MarkdownMessage
|
|
119
|
+
* content="Talk to [Vps-audi](cmdop://machine/abc-123)"
|
|
120
|
+
* linkRules={[machineMentionRule]}
|
|
121
|
+
* />
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* Memoised: re-renders only when props change. `content` is the main
|
|
125
|
+
* trigger — all other props (className, isUser, size, etc.) are
|
|
126
|
+
* compared by value/reference. `onCollapseChange` is compared by
|
|
127
|
+
* reference — callers should stabilise it with useCallback.
|
|
128
|
+
*/
|
|
129
|
+
function MarkdownMessageRaw({
|
|
130
|
+
content,
|
|
131
|
+
className = '',
|
|
132
|
+
isUser = false,
|
|
133
|
+
size,
|
|
134
|
+
isCompact = false,
|
|
135
|
+
plainText,
|
|
136
|
+
customComponents,
|
|
137
|
+
extraHrefProtocols,
|
|
138
|
+
linkRules,
|
|
139
|
+
collapsible = false,
|
|
140
|
+
maxLength,
|
|
141
|
+
maxLines,
|
|
142
|
+
readMoreLabel = 'Read more...',
|
|
143
|
+
showLessLabel = 'Show less',
|
|
144
|
+
defaultExpanded = false,
|
|
145
|
+
onCollapseChange,
|
|
146
|
+
streaming = false,
|
|
147
|
+
codeTheme = 'auto',
|
|
148
|
+
}: MarkdownMessageProps) {
|
|
149
|
+
// Pre-process content through any rules that requested it. Done
|
|
150
|
+
// before trim so a rule can rewrite multi-line shapes too.
|
|
151
|
+
const preprocessed = React.useMemo(
|
|
152
|
+
() => applyPreprocess(content, linkRules),
|
|
153
|
+
[content, linkRules],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Union of `extraHrefProtocols` and any `protocols` declared by rules.
|
|
157
|
+
const effectiveProtocols = React.useMemo(
|
|
158
|
+
() => collectProtocols(extraHrefProtocols, linkRules),
|
|
159
|
+
[extraHrefProtocols, linkRules],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Effective custom components: merge linkRules' synthesized `a`
|
|
163
|
+
// renderer with any caller-provided `customComponents`. linkRules
|
|
164
|
+
// wins when both target the same href (rule is the more specific
|
|
165
|
+
// declarative claim by design).
|
|
166
|
+
const effectiveCustomComponents = React.useMemo<Partial<Components> | undefined>(() => {
|
|
167
|
+
if (!linkRules || linkRules.length === 0) return customComponents;
|
|
168
|
+
const callerA = customComponents?.a;
|
|
169
|
+
const aRenderer = buildLinkRulesComponent(linkRules, isUser, callerA);
|
|
170
|
+
return { ...(customComponents ?? {}), a: aRenderer };
|
|
171
|
+
}, [customComponents, linkRules, isUser]);
|
|
172
|
+
|
|
173
|
+
const trimmedContent = preprocessed.trim();
|
|
174
|
+
|
|
175
|
+
// Collapsible content logic — defaults kick in only when enabled.
|
|
176
|
+
const collapsibleOptions = React.useMemo(() => {
|
|
177
|
+
if (!collapsible) return {};
|
|
178
|
+
return {
|
|
179
|
+
maxLength: maxLength ?? 1000,
|
|
180
|
+
maxLines: maxLines ?? 10,
|
|
181
|
+
defaultExpanded,
|
|
182
|
+
};
|
|
183
|
+
}, [collapsible, maxLength, maxLines, defaultExpanded]);
|
|
184
|
+
|
|
185
|
+
const { isCollapsed, toggleCollapsed, displayContent, shouldCollapse } =
|
|
186
|
+
useCollapsibleContent(trimmedContent, collapsible ? collapsibleOptions : {});
|
|
187
|
+
|
|
188
|
+
React.useEffect(() => {
|
|
189
|
+
if (collapsible && shouldCollapse && onCollapseChange) {
|
|
190
|
+
onCollapseChange(isCollapsed);
|
|
191
|
+
}
|
|
192
|
+
}, [isCollapsed, collapsible, shouldCollapse, onCollapseChange]);
|
|
193
|
+
|
|
194
|
+
// `size` wins when set explicitly; otherwise fall back to the
|
|
195
|
+
// legacy binary `isCompact` so existing callers keep working.
|
|
196
|
+
const resolvedSize: 'xs' | 'sm' | 'base' = size ?? (isCompact ? 'xs' : 'sm');
|
|
197
|
+
|
|
198
|
+
const components = React.useMemo(() => {
|
|
199
|
+
const base = createMarkdownComponents(isUser, resolvedSize, codeTheme);
|
|
200
|
+
return effectiveCustomComponents ? { ...base, ...effectiveCustomComponents } : base;
|
|
201
|
+
}, [isUser, resolvedSize, codeTheme, effectiveCustomComponents]);
|
|
202
|
+
|
|
203
|
+
const schema = React.useMemo(() => buildSchema(effectiveProtocols), [effectiveProtocols]);
|
|
204
|
+
const urlTransform = React.useMemo(
|
|
205
|
+
() => buildUrlTransform(effectiveProtocols),
|
|
206
|
+
[effectiveProtocols],
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const textSizeClass = SIZE_TO_TEXT[resolvedSize];
|
|
210
|
+
const proseClass = SIZE_TO_PROSE[resolvedSize];
|
|
211
|
+
|
|
212
|
+
// Resolve plain-vs-markdown branch:
|
|
213
|
+
// 1. Caller-passed `plainText` wins outright (explicit beats clever).
|
|
214
|
+
// 2. Caller-supplied non-`a` customComponents force the markdown
|
|
215
|
+
// pipeline — those overrides only matter on real markdown nodes.
|
|
216
|
+
// 3. Otherwise auto-detect via `looksLikePlainProse` (short,
|
|
217
|
+
// single-paragraph, no markdown markers). This is the chat-bubble
|
|
218
|
+
// WhatsApp/Telegram heuristic — "would the human have written
|
|
219
|
+
// this in one keystroke?".
|
|
220
|
+
const customComponentsBeyondLinks = React.useMemo(() => {
|
|
221
|
+
if (!customComponents) return false;
|
|
222
|
+
return Object.keys(customComponents).some((k) => k !== 'a');
|
|
223
|
+
}, [customComponents]);
|
|
224
|
+
const isPlainText = plainText !== undefined
|
|
225
|
+
? plainText
|
|
226
|
+
: !customComponentsBeyondLinks && looksLikePlainProse(displayContent);
|
|
227
|
+
|
|
228
|
+
// Streaming mode is only meaningful on the markdown path and when not
|
|
229
|
+
// collapsing (collapse is a finished-message concern). `plainText` and
|
|
230
|
+
// `collapsible` both win over it.
|
|
231
|
+
const isStreamingMarkdown = streaming && !isPlainText && !collapsible;
|
|
232
|
+
|
|
233
|
+
// Block-split the in-flight document. Recomputed only when content
|
|
234
|
+
// changes; cheap (single string pass). Stable blocks feed memoized
|
|
235
|
+
// <MarkdownBlock>s so blocks 0..N-1 don't re-parse per token.
|
|
236
|
+
const split = React.useMemo(
|
|
237
|
+
() => (isStreamingMarkdown ? splitStreamingBlocks(displayContent) : null),
|
|
238
|
+
[isStreamingMarkdown, displayContent],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (isPlainText) {
|
|
242
|
+
// <div> + whitespace-pre-wrap: respects newlines AND collapses
|
|
243
|
+
// double spaces, which is what users mean when they hit Enter
|
|
244
|
+
// twice. <span> would break flow inside a flex bubble.
|
|
245
|
+
//
|
|
246
|
+
// leading-snug (1.375), not leading-relaxed (1.625) — `pre-wrap`
|
|
247
|
+
// makes every `\n` a hard line break, so the relaxed leading
|
|
248
|
+
// turns multi-line user messages into airy ladders. snug matches
|
|
249
|
+
// WhatsApp/Telegram bubble density.
|
|
250
|
+
return (
|
|
251
|
+
// `select-text` keeps prose selectable under native-host
|
|
252
|
+
// `body { user-select: none }` (Wails/Electron); no-op on web.
|
|
253
|
+
<div
|
|
254
|
+
className={`select-text ${textSizeClass} font-normal antialiased leading-snug break-words whitespace-pre-wrap ${className}`}
|
|
255
|
+
>
|
|
256
|
+
{displayContent}
|
|
257
|
+
{collapsible && shouldCollapse && (
|
|
258
|
+
<>
|
|
259
|
+
{isCollapsed && '... '}
|
|
260
|
+
<CollapseToggle
|
|
261
|
+
isCollapsed={isCollapsed}
|
|
262
|
+
onClick={toggleCollapsed}
|
|
263
|
+
readMoreLabel={readMoreLabel}
|
|
264
|
+
showLessLabel={showLessLabel}
|
|
265
|
+
isUser={isUser}
|
|
266
|
+
isCompact={resolvedSize === 'xs'}
|
|
267
|
+
/>
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Prose wrapper class — shared by the static and streaming branches so
|
|
275
|
+
// typography is byte-identical whether rendered in one pass or block by
|
|
276
|
+
// block.
|
|
277
|
+
const proseWrapperClass = `
|
|
278
|
+
prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass} font-normal antialiased
|
|
279
|
+
${isUser ? 'prose-invert' : 'dark:prose-invert'}
|
|
280
|
+
[&>*]:leading-relaxed
|
|
281
|
+
[&>*:first-child]:mt-0 [&>*:last-child]:mb-0
|
|
282
|
+
[&_p]:my-2
|
|
283
|
+
[&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
|
|
284
|
+
[&_li]:my-1 [&_li>p]:my-0
|
|
285
|
+
[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
|
|
286
|
+
[&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
|
|
287
|
+
[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
|
|
288
|
+
[&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
|
|
289
|
+
`;
|
|
290
|
+
const proseWrapperStyle = {
|
|
291
|
+
// Inherit colors from parent — fixes issues with external CSS
|
|
292
|
+
// variables overriding prose tokens.
|
|
293
|
+
'--tw-prose-body': 'inherit',
|
|
294
|
+
'--tw-prose-headings': 'inherit',
|
|
295
|
+
'--tw-prose-bold': 'inherit',
|
|
296
|
+
'--tw-prose-links': 'inherit',
|
|
297
|
+
color: 'inherit',
|
|
298
|
+
} as React.CSSProperties;
|
|
299
|
+
|
|
300
|
+
// ── Streaming branch ──────────────────────────────────────────────────
|
|
301
|
+
// Render completed blocks as memoized markdown; hold the in-flight tail
|
|
302
|
+
// as raw text (boundary buffering) — or as an in-progress code block if
|
|
303
|
+
// it's inside an unterminated fence. Same sanitize/components chain, so
|
|
304
|
+
// identical XSS posture; just invoked per block instead of once.
|
|
305
|
+
if (isStreamingMarkdown && split) {
|
|
306
|
+
const tail = renderableTail(split.tail, split.tailInOpenFence);
|
|
307
|
+
return (
|
|
308
|
+
<div className={`select-text ${className}`}>
|
|
309
|
+
<div className={proseWrapperClass} style={proseWrapperStyle}>
|
|
310
|
+
{split.blocks.map((source, i) => (
|
|
311
|
+
<MarkdownBlock
|
|
312
|
+
// Index keys: streaming only appends/edits the tail, so
|
|
313
|
+
// earlier blocks never reorder — index keys avoid unmount
|
|
314
|
+
// churn while keeping the memo identity stable.
|
|
315
|
+
key={i}
|
|
316
|
+
source={source}
|
|
317
|
+
components={components}
|
|
318
|
+
schema={schema}
|
|
319
|
+
urlTransform={urlTransform}
|
|
320
|
+
/>
|
|
321
|
+
))}
|
|
322
|
+
{tail.source &&
|
|
323
|
+
(tail.asCode ? (
|
|
324
|
+
// Unterminated fence → render as an in-progress code block so
|
|
325
|
+
// it doesn't swallow the rest of the message. The close fence
|
|
326
|
+
// is virtual (appended to a copy); stored content is untouched.
|
|
327
|
+
<MarkdownBlock
|
|
328
|
+
key="tail-code"
|
|
329
|
+
source={tail.source}
|
|
330
|
+
components={components}
|
|
331
|
+
schema={schema}
|
|
332
|
+
urlTransform={urlTransform}
|
|
333
|
+
/>
|
|
334
|
+
) : (
|
|
335
|
+
// Boundary buffering: the trailing incomplete line/block stays
|
|
336
|
+
// raw `whitespace-pre-wrap` until a newline promotes it — no
|
|
337
|
+
// flash of half-written `**bold` / `[link](`.
|
|
338
|
+
<div className="whitespace-pre-wrap break-words leading-relaxed">
|
|
339
|
+
{tail.source}
|
|
340
|
+
</div>
|
|
341
|
+
))}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Static branch (finished message / non-streaming) ──────────────────
|
|
348
|
+
return (
|
|
349
|
+
// `select-text` keeps prose + code blocks selectable under native-host
|
|
350
|
+
// `body { user-select: none }` (Wails/Electron); no-op on web.
|
|
351
|
+
<div className={`select-text ${className}`}>
|
|
352
|
+
<div className={proseWrapperClass} style={proseWrapperStyle}>
|
|
353
|
+
<MarkdownBlock
|
|
354
|
+
source={displayContent}
|
|
355
|
+
components={components}
|
|
356
|
+
schema={schema}
|
|
357
|
+
urlTransform={urlTransform}
|
|
358
|
+
/>
|
|
359
|
+
</div>
|
|
360
|
+
{collapsible && shouldCollapse && (
|
|
361
|
+
<CollapseToggle
|
|
362
|
+
isCollapsed={isCollapsed}
|
|
363
|
+
onClick={toggleCollapsed}
|
|
364
|
+
readMoreLabel={readMoreLabel}
|
|
365
|
+
showLessLabel={showLessLabel}
|
|
366
|
+
isUser={isUser}
|
|
367
|
+
isCompact={isCompact}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export const MarkdownMessage = memo(MarkdownMessageRaw);
|
|
375
|
+
|
|
376
|
+
export default MarkdownMessage;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# MarkdownMessage
|
|
2
|
+
|
|
3
|
+
Chat-tuned, read-only markdown renderer. Drop-in for any chat bubble — assistant or user.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { MarkdownMessage } from '@djangocfg/widget-code/markdown-message';
|
|
7
|
+
|
|
8
|
+
<MarkdownMessage content={text} isUser={false} />
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it gives you
|
|
12
|
+
|
|
13
|
+
| Feature | Source |
|
|
14
|
+
|---|---|
|
|
15
|
+
| GitHub Flavored Markdown — tables, strikethrough, autolinks, task lists | `remark-gfm` |
|
|
16
|
+
| Single `\n` becomes a hard line break (chat convention) | `remark-breaks` |
|
|
17
|
+
| Smart typography — `"…"` → `"…"`, `--` → `—`, `...` → `…` | `remark-smartypants` |
|
|
18
|
+
| Emoji shortcodes — `:rocket:` → 🚀 | `remark-emoji` |
|
|
19
|
+
| External links auto-tagged with `target="_blank" rel="noopener noreferrer"` | `rehype-external-links` |
|
|
20
|
+
| Inline HTML, parsed and sanitized (XSS-safe) | `rehype-raw` + `rehype-sanitize` |
|
|
21
|
+
| Syntax-highlighted code fences with copy button | `<PrettyCode>` |
|
|
22
|
+
| Mermaid diagrams (` ```mermaid ` fence) with click-to-fullscreen | `<Mermaid>` |
|
|
23
|
+
| Custom URL schemes via declarative `linkRules` | `./linkRules.ts` |
|
|
24
|
+
| Opt-in URL chips (bare URLs → favicon + middle-ellipsis chip) | `urlChipRule` · `./builtinRules.tsx` |
|
|
25
|
+
| Plain-text fast path — short messages skip the markdown pipeline | `./plainText.ts` |
|
|
26
|
+
| Optional collapsible "Read more" for long replies | `useCollapsibleContent` |
|
|
27
|
+
| Hover-revealed copy action under the bubble (separate `<ActionRow>` export) | `<CopyButton>` (ui-core) |
|
|
28
|
+
| User vs assistant palette via `isUser` flag | semantic theme tokens |
|
|
29
|
+
|
|
30
|
+
## Why these plugins
|
|
31
|
+
|
|
32
|
+
Chat bubbles aren't documents. We picked plugins that fix the gap between CommonMark
|
|
33
|
+
and how people actually type in chats:
|
|
34
|
+
|
|
35
|
+
- **`remark-breaks`** — CommonMark collapses single newlines into spaces. LLMs and
|
|
36
|
+
users routinely write joke punchlines, poems, and dialogue separated by single
|
|
37
|
+
newlines. Without this, "— Доктор, я общаюсь только через мессенджер.\n— Что жена
|
|
38
|
+
говорит?" renders as one run-on line. ChatGPT, Slack, Discord, Linear all do this.
|
|
39
|
+
- **`remark-smartypants`** — Cheap "humanized" polish: ASCII quotes become typographic
|
|
40
|
+
quotes, double-dash becomes em-dash, three dots become an ellipsis. Same rule as
|
|
41
|
+
Medium / Substack. Applied before emoji so quotes around shortcodes still curl.
|
|
42
|
+
- **`remark-emoji`** — Shortcode → Unicode (`:tada:` → 🎉). Already-Unicode emoji
|
|
43
|
+
pass through untouched.
|
|
44
|
+
- **`rehype-external-links`** — One pass at the end tags every `<a>` that survived
|
|
45
|
+
sanitize. Replaces ad-hoc `target=_blank` checks in the `a` renderer; works for
|
|
46
|
+
custom `linkRules`-supplied anchors too.
|
|
47
|
+
|
|
48
|
+
Plugin order in `MarkdownMessage.tsx` is load-bearing — see the inline comment.
|
|
49
|
+
|
|
50
|
+
## Plain-text fast path
|
|
51
|
+
|
|
52
|
+
`MarkdownMessage` looks at the content and decides:
|
|
53
|
+
|
|
54
|
+
- **Short, single-paragraph, no markdown markers** → render as flat
|
|
55
|
+
`<div whitespace-pre-wrap>`. Cheaper, preserves newlines verbatim, doesn't
|
|
56
|
+
parse `*stars*` or `#hash` that the user happened to type.
|
|
57
|
+
- **Anything else** → full ReactMarkdown pipeline.
|
|
58
|
+
|
|
59
|
+
Override with `plainText={true | false}` if you know better than the heuristic
|
|
60
|
+
(common case: user-typed bubbles always pass `plainText` so their `*` stays as `*`).
|
|
61
|
+
|
|
62
|
+
## Custom URL schemes (`linkRules`)
|
|
63
|
+
|
|
64
|
+
`linkRules` is the supported way to plug in app-specific URL handlers (`cmdop://`,
|
|
65
|
+
`obsidian://`, custom file links, mention chips). Each rule:
|
|
66
|
+
|
|
67
|
+
- declares its `protocols` (whitelisted into the sanitizer);
|
|
68
|
+
- optionally `preprocess`es the source string;
|
|
69
|
+
- `match`es an href and `render`s a custom node.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
const rules: LinkRule[] = [
|
|
73
|
+
{
|
|
74
|
+
name: 'cmdop-machine-mention',
|
|
75
|
+
protocols: ['cmdop'],
|
|
76
|
+
match: (href) => href.startsWith('cmdop://machine/'),
|
|
77
|
+
render: ({ href, children }) => <MentionChip id={href} label={children} />,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
<MarkdownMessage content="Talk to [Server-A](cmdop://machine/abc-123)" linkRules={rules} />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The plain `a` renderer keeps its palette and typography; `linkRules` only fires
|
|
85
|
+
when both the `protocols` and the `match` predicate accept the href. See
|
|
86
|
+
`linkRules.ts` for the sanitize-extension logic.
|
|
87
|
+
|
|
88
|
+
### Built-in `urlChipRule` (URL chips)
|
|
89
|
+
|
|
90
|
+
`urlChipRule` (exported from `./builtinRules`) is a shipped, **opt-in** `LinkRule`
|
|
91
|
+
that renders a URL as a compact `<UrlChip>` — favicon + domain + middle-ellipsis
|
|
92
|
+
path (`github.com/…/README.md`), the same chip the chat composer shows while you
|
|
93
|
+
type a URL. It is not applied automatically; a host appends it to its own
|
|
94
|
+
`linkRules`:
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { MarkdownMessage, urlChipRule } from '@djangocfg/widget-code/markdown-message';
|
|
98
|
+
|
|
99
|
+
<MarkdownMessage content={text} linkRules={[urlChipRule]} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In the Chat tool this is wired by `ChatConfig.linkChips` (off by default); when
|
|
103
|
+
on, `MessageBubble` passes `linkRules={[urlChipRule]}`.
|
|
104
|
+
|
|
105
|
+
Behaviour:
|
|
106
|
+
|
|
107
|
+
- **Bare URLs only.** It chips a link whose rendered label *is* the href (a
|
|
108
|
+
plain autolink, trailing-slash difference tolerated). A `[labeled](url)` link
|
|
109
|
+
keeps the author's text and renders as a normal styled link — the rule falls
|
|
110
|
+
back to an `<a>` using the chat `ANCHOR` tokens so it stays colored +
|
|
111
|
+
underlined, not stripped to plain text.
|
|
112
|
+
- **Scope is `http(s)` only** (`match: /^https?:\/\//i`). `mailto:` / custom
|
|
113
|
+
schemes (`cmdop://`, …) are left to other rules, so it needs no `protocols`
|
|
114
|
+
whitelist.
|
|
115
|
+
- **`www.` host promotion.** Its `preprocess` hook rewrites bare `www.host…`
|
|
116
|
+
tokens to `https://www.host…` before parsing — GFM only autolinks tokens with
|
|
117
|
+
a scheme, so without this a bare `www.` host would reach neither the rule nor
|
|
118
|
+
the default anchor and render as plain text. The promotion is boundary-guarded
|
|
119
|
+
so it never double-prefixes an already-schemed URL.
|
|
120
|
+
|
|
121
|
+
`UrlChip` itself is the surface-agnostic chip in `src/common/chips` — see
|
|
122
|
+
[`common/chips/README.md`](../../../../common/chips/README.md).
|
|
123
|
+
|
|
124
|
+
## Copy action (`<ChatMessageRow>` + `<ActionRow>`)
|
|
125
|
+
|
|
126
|
+
Two pieces, two responsibilities:
|
|
127
|
+
|
|
128
|
+
- **`<ActionRow>`** — dumb leaf. Knows the side (`isUser`) and the
|
|
129
|
+
text to copy (`value`). Owner controls visibility via `visible`.
|
|
130
|
+
- **`<ChatMessageRow>`** — opinionated wrapper. Owns the bubble +
|
|
131
|
+
action-row layout, hover state with a 250 ms close delay, touch
|
|
132
|
+
fallback, focus management, and absolute positioning for the row
|
|
133
|
+
so a hidden row never claims vertical space.
|
|
134
|
+
|
|
135
|
+
The split exists because:
|
|
136
|
+
|
|
137
|
+
1. `MarkdownMessage` doesn't know whether it lives in a saturated
|
|
138
|
+
`bg-primary` bubble or a neutral card — putting the copy icon
|
|
139
|
+
inside would force it to fight the bubble's palette.
|
|
140
|
+
2. CSS-only `group-hover` bridges flicker when the cursor crosses
|
|
141
|
+
the gap between the bubble and the (initially-invisible) row.
|
|
142
|
+
State + a small close timeout is the same pattern Radix Tooltip
|
|
143
|
+
uses for the same reason.
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { MarkdownMessage, ChatMessageRow, ActionRow } from '@djangocfg/widget-code/markdown-message';
|
|
147
|
+
|
|
148
|
+
function ChatBubble({ role, content }) {
|
|
149
|
+
const isUser = role === 'user';
|
|
150
|
+
return (
|
|
151
|
+
<ChatMessageRow
|
|
152
|
+
isUser={isUser}
|
|
153
|
+
actions={(visible) => (
|
|
154
|
+
<ActionRow value={content} isUser={isUser} visible={visible} />
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
className={
|
|
159
|
+
isUser
|
|
160
|
+
? 'bg-primary text-primary-foreground rounded-xl px-3 py-2'
|
|
161
|
+
: 'bg-card border border-border rounded-xl px-3 py-2'
|
|
162
|
+
}
|
|
163
|
+
>
|
|
164
|
+
<MarkdownMessage content={content} isUser={isUser} plainText={isUser} />
|
|
165
|
+
</div>
|
|
166
|
+
</ChatMessageRow>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Behaviour:
|
|
172
|
+
|
|
173
|
+
- **Desktop:** hidden until you hover anywhere over the row (bubble
|
|
174
|
+
or its action area). 250 ms close delay so cursor travel from
|
|
175
|
+
bubble to button never flickers. Fades over 150 ms.
|
|
176
|
+
- **Touch (`@media (hover: none), (pointer: coarse)`):** always
|
|
177
|
+
visible at full opacity, in-flow under the bubble (so it claims
|
|
178
|
+
honest layout space — touch users have no hover, the affordance
|
|
179
|
+
must always be reachable).
|
|
180
|
+
- **Keyboard:** `focus-within` opens the row, `blur` outside the row
|
|
181
|
+
schedules the same close timeout.
|
|
182
|
+
- **Side:** the row sits on the same side as the bubble (`isUser`
|
|
183
|
+
flips alignment); the icon itself is never mirrored.
|
|
184
|
+
|
|
185
|
+
Pass the original `content` string to `ActionRow.value` so collapsed
|
|
186
|
+
bubbles still copy the full text, not the truncated `displayContent`.
|
|
187
|
+
|
|
188
|
+
The `actions` prop is a render-prop (`(visible, isUser) => ReactNode`)
|
|
189
|
+
so it's already future-proof for richer rows like
|
|
190
|
+
`[Copy] [Like] [Regenerate]`.
|
|
191
|
+
|
|
192
|
+
## Files
|
|
193
|
+
|
|
194
|
+
| File | Purpose |
|
|
195
|
+
|---|---|
|
|
196
|
+
| `MarkdownMessage.tsx` | Main component — composes plugins, runs heuristic, renders |
|
|
197
|
+
| `components.tsx` | The `Components` map — typography, lists, table, code, blockquote, hr |
|
|
198
|
+
| `CodeBlock.tsx` | Code-fence renderer (`PrettyCode`) + plain `<pre>` fallback |
|
|
199
|
+
| `linkRules.ts` | Declarative custom-URL primitive — schema & helpers |
|
|
200
|
+
| `builtinRules.tsx` | Shipped opt-in rules — `urlChipRule` (bare URL → `<UrlChip>`) |
|
|
201
|
+
| `plainText.ts` | `looksLikePlainProse` heuristic + `extractTextFromChildren` utility |
|
|
202
|
+
| `sanitize.ts` | Extended rehype-sanitize schema + `urlTransform` builder |
|
|
203
|
+
| `CollapseToggle.tsx` | "Read more / Show less" affordance for long replies |
|
|
204
|
+
| `ActionRow.tsx` | Dumb action row (copy button); owner toggles `visible` |
|
|
205
|
+
| `ChatMessageRow.tsx` | Bubble + action-row wrapper with hover state and touch fallback |
|
|
206
|
+
| `MarkdownMessage.story.tsx` | Storybook — kitchen sink, link rules, mermaid, soft breaks, plugin sampler |
|
|
207
|
+
|
|
208
|
+
## Stories
|
|
209
|
+
|
|
210
|
+
Open the storybook and look at:
|
|
211
|
+
|
|
212
|
+
- `Markdown Message / Link Rules` — declarative `linkRules` API in three modes.
|
|
213
|
+
- `Markdown Message / Chat Bubbles` — plain-text fast path vs heuristic vs long reply.
|
|
214
|
+
- `Markdown Message / Soft Line Breaks` — the `remark-breaks` regression case (joke,
|
|
215
|
+
poem, dialogue).
|
|
216
|
+
- `Markdown Message / Chat Plugins` — emoji shortcodes, smart typography, external
|
|
217
|
+
link tagging, in one bubble.
|
|
218
|
+
- `Markdown Message / Kitchen Sink` — every block element together, used to spot
|
|
219
|
+
spacing regressions at a glance.
|
|
220
|
+
- `Markdown Message / Mermaid Diagrams` — flowchart + sequence diagram in a bubble.
|
|
221
|
+
- `Markdown Message / Copy Actions` — hover-revealed copy button on user and assistant bubbles.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in `LinkRule`s shipped with `MarkdownMessage`. These are opt-in —
|
|
5
|
+
* a host enables them (e.g. via `ChatConfig.linkChips`) by appending them
|
|
6
|
+
* to its own `linkRules` array; they are NOT applied automatically.
|
|
7
|
+
*
|
|
8
|
+
* ── `urlChipRule` ──
|
|
9
|
+
*
|
|
10
|
+
* Renders a BARE web URL (a plain `https://…` autolink, where the markdown
|
|
11
|
+
* link text === the href) as a compact `<UrlChip>` (favicon + domain +
|
|
12
|
+
* middle-ellipsis path) — the same chip the composer shows while you type a
|
|
13
|
+
* URL. This makes a URL read identically across the composer and the
|
|
14
|
+
* rendered bubble (plan22's reuse goal).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately CHIPS ONLY bare URLs:
|
|
17
|
+
* - `https://github.com/wailsapp/wails` → chip (children === href)
|
|
18
|
+
* - `[the repo](https://github.com/...)` → NOT chipped — the author chose
|
|
19
|
+
* custom link text, so we honour it and render a normal styled link
|
|
20
|
+
* (fall through to the built-in anchor).
|
|
21
|
+
*
|
|
22
|
+
* Scope is `http(s)` only — `mailto:` / custom schemes (`cmdop://`, …) are
|
|
23
|
+
* left to other rules. Because it only matches `http(s)`, it needs no extra
|
|
24
|
+
* `protocols` whitelist.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type React from 'react';
|
|
28
|
+
|
|
29
|
+
import { UrlChip } from '@djangocfg/widget-editor/chips';
|
|
30
|
+
import { ANCHOR } from '../role-tokens';
|
|
31
|
+
import { extractTextFromChildren } from './plainText';
|
|
32
|
+
import type { LinkRule } from './types';
|
|
33
|
+
|
|
34
|
+
/** True when the link's rendered label is just the href itself (a bare
|
|
35
|
+
* autolinked URL), i.e. the author did NOT supply custom link text. We
|
|
36
|
+
* compare the flattened text label to the href, tolerating a trailing
|
|
37
|
+
* slash difference that markdown autolinkers sometimes introduce. */
|
|
38
|
+
function isBareUrlLink(href: string, children: React.ReactNode): boolean {
|
|
39
|
+
const label = extractTextFromChildren(children).trim();
|
|
40
|
+
if (!label) return true; // no visible text → treat as bare
|
|
41
|
+
const strip = (s: string) => s.replace(/\/+$/, '');
|
|
42
|
+
return strip(label) === strip(href);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Promote bare `www.host…` tokens to `https://www.host…` BEFORE markdown
|
|
47
|
+
* parsing. remark-gfm only autolinks tokens with a scheme, so a bare `www.`
|
|
48
|
+
* host would otherwise reach neither this rule nor the default anchor — it
|
|
49
|
+
* renders as unstyled plain text (the reported bug). Adding the scheme lets
|
|
50
|
+
* gfm autolink it so the chip rule can claim it, matching the composer
|
|
51
|
+
* (whose autolink whitelist also chips `www.` hosts).
|
|
52
|
+
*
|
|
53
|
+
* Guards against double-prefixing: only matches a `www.` that is preceded by
|
|
54
|
+
* a whitespace / line-start / `(` boundary AND not already part of a
|
|
55
|
+
* `://www.` or `](…www.` URL.
|
|
56
|
+
*/
|
|
57
|
+
function promoteWwwHosts(source: string): string {
|
|
58
|
+
return source.replace(
|
|
59
|
+
/(^|[\s(])(www\.[^\s)<>]+)/gim,
|
|
60
|
+
(_m, lead: string, host: string) => `${lead}https://${host}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const urlChipRule: LinkRule = {
|
|
65
|
+
name: 'url-chip',
|
|
66
|
+
// Promote bare `www.` hosts so gfm autolinks them (then we chip them).
|
|
67
|
+
preprocess: promoteWwwHosts,
|
|
68
|
+
// Only real web URLs. Leave mailto:/custom schemes to other rules.
|
|
69
|
+
match: (href) => /^https?:\/\//i.test(href),
|
|
70
|
+
render: ({ href, children, isUser }) => {
|
|
71
|
+
// Honour author-chosen link text: a `[label](url)` link with custom
|
|
72
|
+
// text is NOT chipped — render a normal styled link so the chosen words
|
|
73
|
+
// stay the link. Use the SAME anchor styling as the built-in renderer
|
|
74
|
+
// (`ANCHOR`) so it reads as a link (colored + underlined), not plain
|
|
75
|
+
// text — otherwise claiming the href here strips the default styling.
|
|
76
|
+
if (!isBareUrlLink(href, children)) {
|
|
77
|
+
return (
|
|
78
|
+
<a
|
|
79
|
+
href={href}
|
|
80
|
+
target="_blank"
|
|
81
|
+
rel="noopener noreferrer"
|
|
82
|
+
className={isUser ? ANCHOR.user : ANCHOR.assistant}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</a>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return <UrlChip href={href} />;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default urlChipRule;
|