@djangocfg/ui-tools 2.1.303 → 2.1.307
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-6ECALRLD.mjs → DocsLayout-W5JLRNSZ.mjs} +3 -3
- package/dist/{DocsLayout-6ECALRLD.mjs.map → DocsLayout-W5JLRNSZ.mjs.map} +1 -1
- package/dist/{DocsLayout-ASPSECYR.cjs → DocsLayout-ZXD2CUOH.cjs} +48 -48
- package/dist/{DocsLayout-ASPSECYR.cjs.map → DocsLayout-ZXD2CUOH.cjs.map} +1 -1
- package/dist/{chunk-K35OF7OB.mjs → chunk-6HNAPVZ2.mjs} +20 -7
- package/dist/chunk-6HNAPVZ2.mjs.map +1 -0
- package/dist/{chunk-PFKR6ZPZ.cjs → chunk-FYLR232K.cjs} +24 -7
- package/dist/chunk-FYLR232K.cjs.map +1 -0
- package/dist/index.cjs +10 -10
- package/dist/index.mjs +4 -4
- package/package.json +10 -6
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +103 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +27 -2
- package/src/components/markdown/MarkdownMessage/README.md +111 -0
- package/src/components/markdown/MarkdownMessage/components.tsx +8 -3
- package/dist/chunk-K35OF7OB.mjs.map +0 -1
- package/dist/chunk-PFKR6ZPZ.cjs.map +0 -1
|
@@ -545,6 +545,109 @@ export const KitchenSink = () => (
|
|
|
545
545
|
</div>
|
|
546
546
|
);
|
|
547
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
|
+
|
|
548
651
|
// Mermaid diagrams. Live-renders the fence body via the <Mermaid>
|
|
549
652
|
// tool (lazy-loaded). Useful as a manual smoke test for: (1) the
|
|
550
653
|
// `language === 'mermaid'` branch in components.tsx, (2) the SVG
|
|
@@ -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';
|
|
@@ -197,11 +201,32 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
197
201
|
} as React.CSSProperties}
|
|
198
202
|
>
|
|
199
203
|
<ReactMarkdown
|
|
200
|
-
|
|
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]}
|
|
201
218
|
// rehype-raw parses inline HTML in the source; rehype-sanitize
|
|
202
219
|
// (with our extended schema) runs after to keep XSS guards
|
|
203
220
|
// (no scripts, no on* handlers, no javascript: urls).
|
|
204
|
-
|
|
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
|
+
]}
|
|
205
230
|
components={components}
|
|
206
231
|
// urlTransform runs in remark-rehype before sanitize. Without
|
|
207
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.
|
|
@@ -51,16 +51,21 @@ export function createMarkdownComponents(
|
|
|
51
51
|
),
|
|
52
52
|
li: ({ children }) => <li className="break-words">{children}</li>,
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// `target` / `rel` for external links are NOT set here — the
|
|
55
|
+
// rehype-external-links plugin tags them on the rehype side, so
|
|
56
|
+
// every `<a>` that sanitize let through gets the same security
|
|
57
|
+
// treatment regardless of which renderer (default vs linkRules
|
|
58
|
+
// override) emitted it. Doing it twice here would just duplicate
|
|
59
|
+
// attributes; doing it only here would miss the linkRules path.
|
|
60
|
+
a: ({ href, children, ...rest }) => (
|
|
55
61
|
<a
|
|
62
|
+
{...rest}
|
|
56
63
|
href={href}
|
|
57
64
|
className={`${textSize} ${
|
|
58
65
|
isUser
|
|
59
66
|
? 'text-white/90 underline hover:text-white'
|
|
60
67
|
: 'text-primary underline hover:text-primary/80'
|
|
61
68
|
} transition-colors break-all`}
|
|
62
|
-
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
63
|
-
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
64
69
|
>
|
|
65
70
|
{children}
|
|
66
71
|
</a>
|