@djangocfg/ui-tools 2.1.302 → 2.1.304
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 +1 -1
- package/dist/{DocsLayout-ZHNRRAKR.mjs → DocsLayout-W5JLRNSZ.mjs} +3 -3
- package/dist/{DocsLayout-ZHNRRAKR.mjs.map → DocsLayout-W5JLRNSZ.mjs.map} +1 -1
- package/dist/{DocsLayout-4PQLBZHE.cjs → DocsLayout-ZXD2CUOH.cjs} +48 -48
- package/dist/{DocsLayout-4PQLBZHE.cjs.map → DocsLayout-ZXD2CUOH.cjs.map} +1 -1
- package/dist/{Mermaid.client-XFQ74OYN.mjs → Mermaid.client-SXRRI2YW.mjs} +43 -6
- package/dist/Mermaid.client-SXRRI2YW.mjs.map +1 -0
- package/dist/{Mermaid.client-RSWUUHIL.cjs → Mermaid.client-W76R5AKJ.cjs} +43 -6
- package/dist/Mermaid.client-W76R5AKJ.cjs.map +1 -0
- package/dist/{chunk-47NGNO5U.mjs → chunk-6HNAPVZ2.mjs} +86 -42
- package/dist/chunk-6HNAPVZ2.mjs.map +1 -0
- package/dist/{chunk-M4BLG3RZ.cjs → chunk-FYLR232K.cjs} +90 -42
- package/dist/chunk-FYLR232K.cjs.map +1 -0
- package/dist/index.cjs +11 -11
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.mjs +5 -5
- package/package.json +10 -6
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +43 -26
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +311 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +35 -5
- package/src/components/markdown/MarkdownMessage/README.md +111 -0
- package/src/components/markdown/MarkdownMessage/components.tsx +77 -17
- package/src/tools/Mermaid/Mermaid.client.tsx +10 -1
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +76 -3
- package/src/tools/Mermaid/index.tsx +7 -0
- package/dist/Mermaid.client-RSWUUHIL.cjs.map +0 -1
- package/dist/Mermaid.client-XFQ74OYN.mjs.map +0 -1
- package/dist/chunk-47NGNO5U.mjs.map +0 -1
- package/dist/chunk-M4BLG3RZ.cjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.304",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"check": "tsc --noEmit"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/i18n": "^2.1.
|
|
95
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/i18n": "^2.1.304",
|
|
95
|
+
"@djangocfg/ui-core": "^2.1.304",
|
|
96
96
|
"consola": "^3.4.2",
|
|
97
97
|
"lodash-es": "^4.18.1",
|
|
98
98
|
"lucide-react": "^0.545.0",
|
|
@@ -129,9 +129,13 @@
|
|
|
129
129
|
"react-map-gl": "^8.1.0",
|
|
130
130
|
"react-markdown": "10.1.0",
|
|
131
131
|
"react-zoom-pan-pinch": "^3.7.0",
|
|
132
|
+
"rehype-external-links": "^3.0.0",
|
|
132
133
|
"rehype-raw": "^7.0.0",
|
|
133
134
|
"rehype-sanitize": "^6.0.0",
|
|
135
|
+
"remark-breaks": "^4.0.0",
|
|
136
|
+
"remark-emoji": "^5.0.2",
|
|
134
137
|
"remark-gfm": "4.0.1",
|
|
138
|
+
"remark-smartypants": "^3.0.2",
|
|
135
139
|
"vidstack": "next",
|
|
136
140
|
"wavesurfer.js": "^7.12.1"
|
|
137
141
|
},
|
|
@@ -140,10 +144,10 @@
|
|
|
140
144
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
141
145
|
},
|
|
142
146
|
"devDependencies": {
|
|
143
|
-
"@djangocfg/i18n": "^2.1.
|
|
147
|
+
"@djangocfg/i18n": "^2.1.304",
|
|
144
148
|
"@djangocfg/playground": "workspace:*",
|
|
145
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
146
|
-
"@djangocfg/ui-core": "^2.1.
|
|
149
|
+
"@djangocfg/typescript-config": "^2.1.304",
|
|
150
|
+
"@djangocfg/ui-core": "^2.1.304",
|
|
147
151
|
"@types/lodash-es": "^4.17.12",
|
|
148
152
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
149
153
|
"@types/node": "^24.7.2",
|
|
@@ -19,18 +19,34 @@ interface CodeBlockProps {
|
|
|
19
19
|
* branch below still ships its own button because the plain <pre>
|
|
20
20
|
* has no toolbar of its own.
|
|
21
21
|
*/
|
|
22
|
-
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language,
|
|
22
|
+
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isCompact = false }) => {
|
|
23
23
|
const theme = useResolvedTheme();
|
|
24
24
|
|
|
25
|
+
// Hoist derived values out of JSX (COMPONENTS.md
|
|
26
|
+
// "Data Preparation Before Render"):
|
|
27
|
+
// `textSizeClass` — chat-density font tier, so the JSX line
|
|
28
|
+
// stays a one-liner.
|
|
29
|
+
// The `--code` token (ui-core/styles/theme/tokens.css) handles the
|
|
30
|
+
// palette in both themes / both bubble roles; we don't branch on
|
|
31
|
+
// `isUser` here on purpose.
|
|
32
|
+
const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
|
|
33
|
+
|
|
25
34
|
return (
|
|
26
35
|
<div className="my-3">
|
|
27
36
|
<PrettyCode
|
|
28
37
|
data={code}
|
|
29
38
|
language={language}
|
|
30
|
-
className={
|
|
31
|
-
customBg=
|
|
39
|
+
className={textSizeClass}
|
|
40
|
+
customBg="bg-code"
|
|
32
41
|
mode={theme}
|
|
33
42
|
isCompact={isCompact}
|
|
43
|
+
// Disable click-to-scroll-isolation in chat markdown: code
|
|
44
|
+
// fences here are part of an assistant reply, not a docs
|
|
45
|
+
// viewer. Forcing the user to click into a small block to
|
|
46
|
+
// scroll past it interrupts the reading flow. The standalone
|
|
47
|
+
// PrettyCode use cases (docs, diff viewers, long code panes)
|
|
48
|
+
// keep the default `true`.
|
|
49
|
+
scrollIsolation={false}
|
|
34
50
|
/>
|
|
35
51
|
</div>
|
|
36
52
|
);
|
|
@@ -38,26 +54,27 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, is
|
|
|
38
54
|
|
|
39
55
|
/** Simple `<pre>` fallback used when CodeBlock throws (lazy module
|
|
40
56
|
* failure, missing PrettyCode peer, etc). */
|
|
41
|
-
export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
57
|
+
export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) => {
|
|
58
|
+
// See CodeBlock above for the spirit of this layout — palette
|
|
59
|
+
// pre-computed before render.
|
|
60
|
+
const copyHoverClass = isUser
|
|
61
|
+
? 'hover:bg-white/20 text-white'
|
|
62
|
+
: 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground';
|
|
63
|
+
const copyButtonClass =
|
|
64
|
+
`absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity ` +
|
|
65
|
+
`h-8 w-8 ${copyHoverClass}`;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="relative group my-3">
|
|
69
|
+
<CopyButton
|
|
70
|
+
value={code}
|
|
71
|
+
variant="ghost"
|
|
72
|
+
className={copyButtonClass}
|
|
73
|
+
title="Copy code"
|
|
74
|
+
/>
|
|
75
|
+
<pre className="p-3 rounded text-xs font-mono overflow-x-auto bg-code text-code-foreground border border-code-border">
|
|
76
|
+
<code>{code}</code>
|
|
77
|
+
</pre>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
@@ -213,6 +213,117 @@ curl -sSL cmdop.com/install.sh | bash
|
|
|
213
213
|
|
|
214
214
|
Если что-то не работает — скажи.`;
|
|
215
215
|
|
|
216
|
+
// Mermaid payload — exercises the `mermaid` fence branch in
|
|
217
|
+
// components.tsx (lines 101–106). MarkdownMessage hands the code body
|
|
218
|
+
// to <Mermaid> instead of PrettyCode when the fence language is
|
|
219
|
+
// `mermaid`. We pick a small flowchart + a sequence diagram so the
|
|
220
|
+
// story reflects what an LLM typically emits.
|
|
221
|
+
const MERMAID_CONTENT = `Here's the chat session lifecycle:
|
|
222
|
+
|
|
223
|
+
\`\`\`mermaid
|
|
224
|
+
flowchart LR
|
|
225
|
+
A[User types] --> B[ChatProvider]
|
|
226
|
+
B --> C{streaming?}
|
|
227
|
+
C -->|yes| D[CancelMessage]
|
|
228
|
+
C -->|no| E[SendMessage]
|
|
229
|
+
D --> E
|
|
230
|
+
E --> F[Agent stream]
|
|
231
|
+
F --> G[Tokens arrive]
|
|
232
|
+
G --> H[Reducer dispatch]
|
|
233
|
+
H --> I[Bubble re-renders]
|
|
234
|
+
\`\`\`
|
|
235
|
+
|
|
236
|
+
And the cancel flow as a sequence:
|
|
237
|
+
|
|
238
|
+
\`\`\`mermaid
|
|
239
|
+
sequenceDiagram
|
|
240
|
+
participant U as User
|
|
241
|
+
participant C as ChatInput
|
|
242
|
+
participant P as ChatProvider
|
|
243
|
+
participant S as ChatService
|
|
244
|
+
U->>C: Press Enter (mid-stream)
|
|
245
|
+
C->>P: sendMessage(text)
|
|
246
|
+
P->>S: CancelMessage
|
|
247
|
+
S-->>P: STREAM_CANCELLED
|
|
248
|
+
P->>S: SendMessage(text)
|
|
249
|
+
S-->>P: STREAM_START
|
|
250
|
+
\`\`\`
|
|
251
|
+
|
|
252
|
+
The renderer routes \`mermaid\` fences to the diagram component;
|
|
253
|
+
non-mermaid fences (above) keep going through PrettyCode.`;
|
|
254
|
+
|
|
255
|
+
// Kitchen-sink: every block element the renderer can produce, in one
|
|
256
|
+
// payload, so any spacing regression is visible at a glance. Pairs
|
|
257
|
+
// neighbouring elements that are easy to break — `p → hr → p`,
|
|
258
|
+
// `code → list`, `list → table → list`, blockquote-with-paragraphs.
|
|
259
|
+
const KITCHEN_SINK = `# Top-level heading
|
|
260
|
+
|
|
261
|
+
Short intro paragraph that sits right under the heading. The next
|
|
262
|
+
paragraph should have a clear gap from this one, not be glued to it.
|
|
263
|
+
|
|
264
|
+
Second paragraph — verifies \`p + p\` spacing.
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
After the divider, the next paragraph should still breathe. The
|
|
269
|
+
divider itself is a soft hairline, not a heavy 1px gray bar.
|
|
270
|
+
|
|
271
|
+
## Subheading h2
|
|
272
|
+
|
|
273
|
+
Plain *italic*, **bold**, and \`inline code\` mid-sentence. Inline
|
|
274
|
+
code should sit on the line, not push it taller.
|
|
275
|
+
|
|
276
|
+
### Subheading h3
|
|
277
|
+
|
|
278
|
+
A short list:
|
|
279
|
+
|
|
280
|
+
- Bullet one with a [link](https://example.com)
|
|
281
|
+
- Bullet two with \`inline code\`
|
|
282
|
+
- Bullet three — short
|
|
283
|
+
|
|
284
|
+
A numbered list:
|
|
285
|
+
|
|
286
|
+
1. First step
|
|
287
|
+
2. Second step with **bold** inside
|
|
288
|
+
3. Third step
|
|
289
|
+
|
|
290
|
+
A loose list (paragraph-wrapped \`<li>\`):
|
|
291
|
+
|
|
292
|
+
- First item
|
|
293
|
+
|
|
294
|
+
with a follow-up paragraph that should NOT explode the list.
|
|
295
|
+
|
|
296
|
+
- Second item, also loose.
|
|
297
|
+
|
|
298
|
+
#### Heading h4
|
|
299
|
+
|
|
300
|
+
> Blockquote without italic, with a left border. Lighter colour for
|
|
301
|
+
> de-emphasis. Multi-line quotes wrap cleanly.
|
|
302
|
+
>
|
|
303
|
+
> Second paragraph inside the same blockquote.
|
|
304
|
+
|
|
305
|
+
A code fence:
|
|
306
|
+
|
|
307
|
+
\`\`\`go
|
|
308
|
+
func add(a, b int) int {
|
|
309
|
+
// verifies code block spacing, rounded corners, dark bg
|
|
310
|
+
return a + b
|
|
311
|
+
}
|
|
312
|
+
\`\`\`
|
|
313
|
+
|
|
314
|
+
A table — wraps inside a horizontally scrollable container:
|
|
315
|
+
|
|
316
|
+
| Field | Type | Notes |
|
|
317
|
+
|---|---|---|
|
|
318
|
+
| id | string | UUID |
|
|
319
|
+
| name | string | display name |
|
|
320
|
+
| online | bool | last-heartbeat ≤ 60s |
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
Closing paragraph after a second divider, to verify spacing holds at
|
|
325
|
+
the very end too.`;
|
|
326
|
+
|
|
216
327
|
function ChatBubble({
|
|
217
328
|
role,
|
|
218
329
|
content,
|
|
@@ -383,3 +494,203 @@ export const ChatBubbles = () => (
|
|
|
383
494
|
</div>
|
|
384
495
|
</div>
|
|
385
496
|
);
|
|
497
|
+
|
|
498
|
+
// Kitchen-sink: stress-test every element the renderer can emit in a
|
|
499
|
+
// single bubble. If something regresses (e.g. paragraphs collapse, hr
|
|
500
|
+
// shows a heavy gray bar, table loses borders, list items glue to
|
|
501
|
+
// each other), it's visible at a glance.
|
|
502
|
+
//
|
|
503
|
+
// Numbers in MarkdownMessage.tsx come from research on ChatGPT /
|
|
504
|
+
// Claude.ai / Cursor 2025 — see KitchenSink output as the visual
|
|
505
|
+
// contract. Don't tune values by feel; pair-check against this view.
|
|
506
|
+
export const KitchenSink = () => (
|
|
507
|
+
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
|
508
|
+
<div>
|
|
509
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
510
|
+
Every block element in one bubble
|
|
511
|
+
</h3>
|
|
512
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
513
|
+
Visual contract for the renderer. Heading sizes downscaled
|
|
514
|
+
chat-style (h1 ≈ body), <code>p</code>/<code>ul</code>/
|
|
515
|
+
<code>ol</code>/<code>pre</code>/<code>blockquote</code>/
|
|
516
|
+
<code>hr</code>/<code>table</code> all share a coherent
|
|
517
|
+
rhythm. Loose lists keep their density — no exploded gap on
|
|
518
|
+
paragraph-wrapped <code><li></code>.
|
|
519
|
+
</p>
|
|
520
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
521
|
+
<ChatBubble
|
|
522
|
+
role="assistant"
|
|
523
|
+
content={KITCHEN_SINK}
|
|
524
|
+
rules={subtleRules}
|
|
525
|
+
/>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
|
|
529
|
+
<div>
|
|
530
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
531
|
+
Same payload on the user-side palette
|
|
532
|
+
</h3>
|
|
533
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
534
|
+
Verifies that <code>prose-invert</code> doesn't break any of
|
|
535
|
+
the new rules — saturated bubbles need the same spacing.
|
|
536
|
+
</p>
|
|
537
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
538
|
+
<ChatBubble
|
|
539
|
+
role="user"
|
|
540
|
+
content={KITCHEN_SINK}
|
|
541
|
+
rules={onPrimaryRules}
|
|
542
|
+
/>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
// Soft line breaks — `remark-breaks` enabled. LLMs (Claude / GPT) and
|
|
549
|
+
// chat users routinely write "joke punchline / poem stanza / dialogue"
|
|
550
|
+
// blocks separated by a single `\n` rather than a blank line. CommonMark
|
|
551
|
+
// would collapse those into one run-on paragraph; the chat convention
|
|
552
|
+
// (ChatGPT, Slack, Discord, Linear) preserves them as `<br>`. This
|
|
553
|
+
// story is the regression case for that.
|
|
554
|
+
const SOFT_BREAK_JOKE = `Приходит программист к врачу:
|
|
555
|
+
— Доктор, я со всеми общаюсь только через мессенджер. Даже с женой.
|
|
556
|
+
— И что жена говорит?
|
|
557
|
+
— Подождите, сейчас напишу и спрошу… 😄`;
|
|
558
|
+
|
|
559
|
+
const SOFT_BREAK_POEM = `Roses are red,
|
|
560
|
+
Violets are blue,
|
|
561
|
+
This line is short,
|
|
562
|
+
And so are you.`;
|
|
563
|
+
|
|
564
|
+
const SOFT_BREAK_DIALOGUE = `Customer: Why is my code not working?
|
|
565
|
+
Support: Have you tried turning it off and on again?
|
|
566
|
+
Customer: I'm a software engineer.
|
|
567
|
+
Support: Have you tried turning it off and on again?`;
|
|
568
|
+
|
|
569
|
+
export const SoftLineBreaks = () => (
|
|
570
|
+
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
|
571
|
+
<div>
|
|
572
|
+
<h3 className="mb-2 text-sm font-semibold">Joke (assistant bubble)</h3>
|
|
573
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
574
|
+
Single <code>\n</code> between lines should render as line
|
|
575
|
+
breaks, not collapse onto one row. This was the user-reported
|
|
576
|
+
regression — LLM punchlines surfaced as a wall of text.
|
|
577
|
+
</p>
|
|
578
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
579
|
+
<ChatBubble role="assistant" content={SOFT_BREAK_JOKE} rules={subtleRules} />
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<div>
|
|
584
|
+
<h3 className="mb-2 text-sm font-semibold">Poem (user bubble)</h3>
|
|
585
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
586
|
+
Same payload on the saturated user palette. Each line stays on
|
|
587
|
+
its own row.
|
|
588
|
+
</p>
|
|
589
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
590
|
+
<ChatBubble role="user" content={SOFT_BREAK_POEM} rules={onPrimaryRules} />
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<div>
|
|
595
|
+
<h3 className="mb-2 text-sm font-semibold">Dialogue (assistant bubble)</h3>
|
|
596
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
597
|
+
Speaker turns separated by single newlines.
|
|
598
|
+
</p>
|
|
599
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
600
|
+
<ChatBubble role="assistant" content={SOFT_BREAK_DIALOGUE} rules={subtleRules} />
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
// Plugin sampler — emoji shortcodes, smart typography, external link
|
|
607
|
+
// hardening. Verifies the three "lightweight chat" plugins added next
|
|
608
|
+
// to remark-breaks: remark-emoji, remark-smartypants, rehype-
|
|
609
|
+
// external-links. Each plugin contributes one transform; this story
|
|
610
|
+
// makes them visible in isolation.
|
|
611
|
+
const PLUGIN_SAMPLER = `### Plugin sampler
|
|
612
|
+
|
|
613
|
+
Emoji shortcodes work inline: :rocket: :tada: :+1: — alongside Unicode 🎉.
|
|
614
|
+
|
|
615
|
+
Smart typography: "double quotes", 'single quotes', em-dash --, ellipsis...
|
|
616
|
+
|
|
617
|
+
External link should open in a new tab: [Anthropic](https://anthropic.com).
|
|
618
|
+
Internal-style link stays in place: [#section](#section).
|
|
619
|
+
|
|
620
|
+
A line above —
|
|
621
|
+
soft break here — should still wrap (one \\n only).
|
|
622
|
+
And then a normal paragraph below.`;
|
|
623
|
+
|
|
624
|
+
export const ChatPlugins = () => (
|
|
625
|
+
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
|
626
|
+
<div>
|
|
627
|
+
<h3 className="mb-2 text-sm font-semibold">Assistant bubble</h3>
|
|
628
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
629
|
+
Three plugins firing at once: <code>remark-emoji</code>,
|
|
630
|
+
<code>remark-smartypants</code>, <code>rehype-external-links</code>.
|
|
631
|
+
Hover the Anthropic link — it should carry <code>target=_blank</code>
|
|
632
|
+
and <code>rel=noopener noreferrer</code> automatically.
|
|
633
|
+
</p>
|
|
634
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
635
|
+
<ChatBubble role="assistant" content={PLUGIN_SAMPLER} rules={subtleRules} />
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<div>
|
|
640
|
+
<h3 className="mb-2 text-sm font-semibold">User bubble</h3>
|
|
641
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
642
|
+
Same payload on the saturated palette.
|
|
643
|
+
</p>
|
|
644
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
645
|
+
<ChatBubble role="user" content={PLUGIN_SAMPLER} rules={onPrimaryRules} />
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// Mermaid diagrams. Live-renders the fence body via the <Mermaid>
|
|
652
|
+
// tool (lazy-loaded). Useful as a manual smoke test for: (1) the
|
|
653
|
+
// `language === 'mermaid'` branch in components.tsx, (2) the SVG
|
|
654
|
+
// scaling inside a chat-density bubble, (3) interaction with the
|
|
655
|
+
// surrounding markdown spacing rules (paragraph above/below the
|
|
656
|
+
// diagram should keep the same rhythm as around a code fence).
|
|
657
|
+
export const MermaidDiagrams = () => (
|
|
658
|
+
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
|
659
|
+
<div>
|
|
660
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
661
|
+
Mermaid in an assistant bubble
|
|
662
|
+
</h3>
|
|
663
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
664
|
+
Two diagrams (flowchart + sequence) wrapped in surrounding
|
|
665
|
+
prose. Verifies that <code>```mermaid</code> fences route to{' '}
|
|
666
|
+
<code><Mermaid></code> and that the SVG fits the
|
|
667
|
+
bubble's max-width without breaking the layout.
|
|
668
|
+
</p>
|
|
669
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
670
|
+
<ChatBubble
|
|
671
|
+
role="assistant"
|
|
672
|
+
content={MERMAID_CONTENT}
|
|
673
|
+
rules={subtleRules}
|
|
674
|
+
/>
|
|
675
|
+
</div>
|
|
676
|
+
</div>
|
|
677
|
+
|
|
678
|
+
<div>
|
|
679
|
+
<h3 className="mb-2 text-sm font-semibold">
|
|
680
|
+
Same payload on the user-side palette
|
|
681
|
+
</h3>
|
|
682
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
683
|
+
On a saturated <code>bg-primary</code> bubble the diagram
|
|
684
|
+
keeps its own surface — Mermaid renders SVG with its own
|
|
685
|
+
background, independent of the bubble palette.
|
|
686
|
+
</p>
|
|
687
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
688
|
+
<ChatBubble
|
|
689
|
+
role="user"
|
|
690
|
+
content={MERMAID_CONTENT}
|
|
691
|
+
rules={onPrimaryRules}
|
|
692
|
+
/>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
</div>
|
|
696
|
+
);
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import ReactMarkdown from 'react-markdown';
|
|
5
|
+
import rehypeExternalLinks from 'rehype-external-links';
|
|
5
6
|
import rehypeRaw from 'rehype-raw';
|
|
6
7
|
import rehypeSanitize from 'rehype-sanitize';
|
|
8
|
+
import remarkBreaks from 'remark-breaks';
|
|
9
|
+
import remarkEmoji from 'remark-emoji';
|
|
7
10
|
import remarkGfm from 'remark-gfm';
|
|
11
|
+
import remarkSmartypants from 'remark-smartypants';
|
|
8
12
|
import type { Components } from 'react-markdown';
|
|
9
13
|
|
|
10
14
|
import { useCollapsibleContent } from '../useCollapsibleContent';
|
|
@@ -177,9 +181,14 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
177
181
|
${isUser ? 'prose-invert' : 'dark:prose-invert'}
|
|
178
182
|
[&>*]:leading-relaxed
|
|
179
183
|
[&>*:first-child]:mt-0 [&>*:last-child]:mb-0
|
|
180
|
-
[&_p]:my-
|
|
181
|
-
[&_ul]:my-2 [&_ol]:my-2 [&
|
|
182
|
-
[&
|
|
184
|
+
[&_p]:my-2
|
|
185
|
+
[&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
|
|
186
|
+
[&_li]:my-1 [&_li>p]:my-0
|
|
187
|
+
[&_pre]:my-3
|
|
188
|
+
[&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
|
|
189
|
+
[&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
|
|
190
|
+
[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
|
|
191
|
+
[&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
|
|
183
192
|
`}
|
|
184
193
|
style={{
|
|
185
194
|
// Inherit colors from parent — fixes issues with external
|
|
@@ -192,11 +201,32 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
192
201
|
} as React.CSSProperties}
|
|
193
202
|
>
|
|
194
203
|
<ReactMarkdown
|
|
195
|
-
|
|
204
|
+
// Remark plugin order is load-bearing:
|
|
205
|
+
// 1. `remark-gfm` — tables, strikethrough, autolinks, task lists.
|
|
206
|
+
// 2. `remark-breaks` — chat convention: single `\n` → `<br>`
|
|
207
|
+
// (ChatGPT / Slack / Discord / Linear). CommonMark's
|
|
208
|
+
// default would collapse those into one paragraph and
|
|
209
|
+
// LLM punchlines / poems / dialogue would render as a
|
|
210
|
+
// run-on line. Goes BEFORE smartypants so quotes that
|
|
211
|
+
// land at line breaks still get the curly treatment.
|
|
212
|
+
// 3. `remark-smartypants` — typographic substitutions:
|
|
213
|
+
// "..." → …, -- → —, "x" → "x". Cheap "humanized"
|
|
214
|
+
// polish à la Medium / Substack.
|
|
215
|
+
// 4. `remark-emoji` — `:smile:` → 😄 (GitHub / Linear
|
|
216
|
+
// shortcode style). Leaves Unicode emoji untouched.
|
|
217
|
+
remarkPlugins={[remarkGfm, remarkBreaks, remarkSmartypants, remarkEmoji]}
|
|
196
218
|
// rehype-raw parses inline HTML in the source; rehype-sanitize
|
|
197
219
|
// (with our extended schema) runs after to keep XSS guards
|
|
198
220
|
// (no scripts, no on* handlers, no javascript: urls).
|
|
199
|
-
|
|
221
|
+
// rehype-external-links runs LAST so it tags every <a> that
|
|
222
|
+
// sanitize let through — externals get target=_blank +
|
|
223
|
+
// rel=noopener noreferrer in one pass, instead of every
|
|
224
|
+
// custom `a` renderer reimplementing the rule.
|
|
225
|
+
rehypePlugins={[
|
|
226
|
+
rehypeRaw,
|
|
227
|
+
[rehypeSanitize, schema],
|
|
228
|
+
[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
|
|
229
|
+
]}
|
|
200
230
|
components={components}
|
|
201
231
|
// urlTransform runs in remark-rehype before sanitize. Without
|
|
202
232
|
// overriding it, react-markdown's default strips `href` for
|
|
@@ -0,0 +1,111 @@
|
|
|
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/ui-tools';
|
|
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
|
+
| Plain-text fast path — short messages skip the markdown pipeline | `./plainText.ts` |
|
|
25
|
+
| Optional collapsible "Read more" for long replies | `useCollapsibleContent` |
|
|
26
|
+
| User vs assistant palette via `isUser` flag | semantic theme tokens |
|
|
27
|
+
|
|
28
|
+
## Why these plugins
|
|
29
|
+
|
|
30
|
+
Chat bubbles aren't documents. We picked plugins that fix the gap between CommonMark
|
|
31
|
+
and how people actually type in chats:
|
|
32
|
+
|
|
33
|
+
- **`remark-breaks`** — CommonMark collapses single newlines into spaces. LLMs and
|
|
34
|
+
users routinely write joke punchlines, poems, and dialogue separated by single
|
|
35
|
+
newlines. Without this, "— Доктор, я общаюсь только через мессенджер.\n— Что жена
|
|
36
|
+
говорит?" renders as one run-on line. ChatGPT, Slack, Discord, Linear all do this.
|
|
37
|
+
- **`remark-smartypants`** — Cheap "humanized" polish: ASCII quotes become typographic
|
|
38
|
+
quotes, double-dash becomes em-dash, three dots become an ellipsis. Same rule as
|
|
39
|
+
Medium / Substack. Applied before emoji so quotes around shortcodes still curl.
|
|
40
|
+
- **`remark-emoji`** — Shortcode → Unicode (`:tada:` → 🎉). Already-Unicode emoji
|
|
41
|
+
pass through untouched.
|
|
42
|
+
- **`rehype-external-links`** — One pass at the end tags every `<a>` that survived
|
|
43
|
+
sanitize. Replaces ad-hoc `target=_blank` checks in the `a` renderer; works for
|
|
44
|
+
custom `linkRules`-supplied anchors too.
|
|
45
|
+
|
|
46
|
+
Plugin order in `MarkdownMessage.tsx` is load-bearing — see the inline comment.
|
|
47
|
+
|
|
48
|
+
## Plain-text fast path
|
|
49
|
+
|
|
50
|
+
`MarkdownMessage` looks at the content and decides:
|
|
51
|
+
|
|
52
|
+
- **Short, single-paragraph, no markdown markers** → render as flat
|
|
53
|
+
`<div whitespace-pre-wrap>`. Cheaper, preserves newlines verbatim, doesn't
|
|
54
|
+
parse `*stars*` or `#hash` that the user happened to type.
|
|
55
|
+
- **Anything else** → full ReactMarkdown pipeline.
|
|
56
|
+
|
|
57
|
+
Override with `plainText={true | false}` if you know better than the heuristic
|
|
58
|
+
(common case: user-typed bubbles always pass `plainText` so their `*` stays as `*`).
|
|
59
|
+
|
|
60
|
+
## Custom URL schemes (`linkRules`)
|
|
61
|
+
|
|
62
|
+
`linkRules` is the supported way to plug in app-specific URL handlers (`cmdop://`,
|
|
63
|
+
`obsidian://`, custom file links, mention chips). Each rule:
|
|
64
|
+
|
|
65
|
+
- declares its `protocols` (whitelisted into the sanitizer);
|
|
66
|
+
- optionally `preprocess`es the source string;
|
|
67
|
+
- `match`es an href and `render`s a custom node.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
const rules: LinkRule[] = [
|
|
71
|
+
{
|
|
72
|
+
name: 'cmdop-machine-mention',
|
|
73
|
+
protocols: ['cmdop'],
|
|
74
|
+
match: (href) => href.startsWith('cmdop://machine/'),
|
|
75
|
+
render: ({ href, children }) => <MentionChip id={href} label={children} />,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
<MarkdownMessage content="Talk to [Server-A](cmdop://machine/abc-123)" linkRules={rules} />
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The plain `a` renderer keeps its palette and typography; `linkRules` only fires
|
|
83
|
+
when both the `protocols` and the `match` predicate accept the href. See
|
|
84
|
+
`linkRules.ts` for the sanitize-extension logic.
|
|
85
|
+
|
|
86
|
+
## Files
|
|
87
|
+
|
|
88
|
+
| File | Purpose |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `MarkdownMessage.tsx` | Main component — composes plugins, runs heuristic, renders |
|
|
91
|
+
| `components.tsx` | The `Components` map — typography, lists, table, code, blockquote, hr |
|
|
92
|
+
| `CodeBlock.tsx` | Code-fence renderer (`PrettyCode`) + plain `<pre>` fallback |
|
|
93
|
+
| `linkRules.ts` | Declarative custom-URL primitive — schema & helpers |
|
|
94
|
+
| `plainText.ts` | `looksLikePlainProse` heuristic + `extractTextFromChildren` utility |
|
|
95
|
+
| `sanitize.ts` | Extended rehype-sanitize schema + `urlTransform` builder |
|
|
96
|
+
| `CollapseToggle.tsx` | "Read more / Show less" affordance for long replies |
|
|
97
|
+
| `MarkdownMessage.story.tsx` | Storybook — kitchen sink, link rules, mermaid, soft breaks, plugin sampler |
|
|
98
|
+
|
|
99
|
+
## Stories
|
|
100
|
+
|
|
101
|
+
Open the storybook and look at:
|
|
102
|
+
|
|
103
|
+
- `Markdown Message / Link Rules` — declarative `linkRules` API in three modes.
|
|
104
|
+
- `Markdown Message / Chat Bubbles` — plain-text fast path vs heuristic vs long reply.
|
|
105
|
+
- `Markdown Message / Soft Line Breaks` — the `remark-breaks` regression case (joke,
|
|
106
|
+
poem, dialogue).
|
|
107
|
+
- `Markdown Message / Chat Plugins` — emoji shortcodes, smart typography, external
|
|
108
|
+
link tagging, in one bubble.
|
|
109
|
+
- `Markdown Message / Kitchen Sink` — every block element together, used to spot
|
|
110
|
+
spacing regressions at a glance.
|
|
111
|
+
- `Markdown Message / Mermaid Diagrams` — flowchart + sequence diagram in a bubble.
|