@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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/{DocsLayout-ZHNRRAKR.mjs → DocsLayout-W5JLRNSZ.mjs} +3 -3
  3. package/dist/{DocsLayout-ZHNRRAKR.mjs.map → DocsLayout-W5JLRNSZ.mjs.map} +1 -1
  4. package/dist/{DocsLayout-4PQLBZHE.cjs → DocsLayout-ZXD2CUOH.cjs} +48 -48
  5. package/dist/{DocsLayout-4PQLBZHE.cjs.map → DocsLayout-ZXD2CUOH.cjs.map} +1 -1
  6. package/dist/{Mermaid.client-XFQ74OYN.mjs → Mermaid.client-SXRRI2YW.mjs} +43 -6
  7. package/dist/Mermaid.client-SXRRI2YW.mjs.map +1 -0
  8. package/dist/{Mermaid.client-RSWUUHIL.cjs → Mermaid.client-W76R5AKJ.cjs} +43 -6
  9. package/dist/Mermaid.client-W76R5AKJ.cjs.map +1 -0
  10. package/dist/{chunk-47NGNO5U.mjs → chunk-6HNAPVZ2.mjs} +86 -42
  11. package/dist/chunk-6HNAPVZ2.mjs.map +1 -0
  12. package/dist/{chunk-M4BLG3RZ.cjs → chunk-FYLR232K.cjs} +90 -42
  13. package/dist/chunk-FYLR232K.cjs.map +1 -0
  14. package/dist/index.cjs +11 -11
  15. package/dist/index.d.cts +7 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.mjs +5 -5
  18. package/package.json +10 -6
  19. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +43 -26
  20. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +311 -0
  21. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +35 -5
  22. package/src/components/markdown/MarkdownMessage/README.md +111 -0
  23. package/src/components/markdown/MarkdownMessage/components.tsx +77 -17
  24. package/src/tools/Mermaid/Mermaid.client.tsx +10 -1
  25. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +76 -3
  26. package/src/tools/Mermaid/index.tsx +7 -0
  27. package/dist/Mermaid.client-RSWUUHIL.cjs.map +0 -1
  28. package/dist/Mermaid.client-XFQ74OYN.mjs.map +0 -1
  29. package/dist/chunk-47NGNO5U.mjs.map +0 -1
  30. 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.302",
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.302",
95
- "@djangocfg/ui-core": "^2.1.302",
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.302",
147
+ "@djangocfg/i18n": "^2.1.304",
144
148
  "@djangocfg/playground": "workspace:*",
145
- "@djangocfg/typescript-config": "^2.1.302",
146
- "@djangocfg/ui-core": "^2.1.302",
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, isUser, isCompact = false }) => {
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={isCompact ? 'text-xs' : 'text-sm'}
31
- customBg={isUser ? 'bg-white/10' : 'bg-muted dark:bg-muted'}
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
- <div className="relative group my-3">
43
- <CopyButton
44
- value={code}
45
- variant="ghost"
46
- className={`
47
- absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
48
- h-8 w-8
49
- ${isUser
50
- ? 'hover:bg-white/20 text-white'
51
- : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
52
- }
53
- `}
54
- title="Copy code"
55
- />
56
- <pre className={`
57
- p-3 rounded text-xs font-mono overflow-x-auto
58
- ${isUser ? 'bg-white/10 text-white' : 'bg-muted text-foreground'}
59
- `}>
60
- <code>{code}</code>
61
- </pre>
62
- </div>
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>&lt;li&gt;</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>&lt;Mermaid&gt;</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-0 [&_p+p]:mt-2
181
- [&_ul]:my-2 [&_ol]:my-2 [&_pre]:my-2 [&_blockquote]:my-2
182
- [&_h1]:mt-3 [&_h1]:mb-1 [&_h2]:mt-3 [&_h2]:mb-1 [&_h3]:mt-2 [&_h3]:mb-1
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
- remarkPlugins={[remarkGfm]}
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
- rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
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.