@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/package.json +119 -0
  4. package/src/diff-viewer/DiffViewer.tsx +168 -0
  5. package/src/diff-viewer/README.md +33 -0
  6. package/src/diff-viewer/components/CopyButton.tsx +49 -0
  7. package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
  8. package/src/diff-viewer/components/SplitView.tsx +270 -0
  9. package/src/diff-viewer/components/UnifiedView.tsx +189 -0
  10. package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
  11. package/src/diff-viewer/hooks/useDiff.ts +47 -0
  12. package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
  13. package/src/diff-viewer/index.ts +22 -0
  14. package/src/diff-viewer/lazy.tsx +25 -0
  15. package/src/diff-viewer/types.ts +125 -0
  16. package/src/diff-viewer/utils/computeDiff.ts +171 -0
  17. package/src/editor/README.md +187 -0
  18. package/src/editor/components/DiffEditor.tsx +158 -0
  19. package/src/editor/components/Editor.tsx +293 -0
  20. package/src/editor/components/index.ts +2 -0
  21. package/src/editor/context/EditorProvider.tsx +212 -0
  22. package/src/editor/context/index.ts +1 -0
  23. package/src/editor/editor.stories.tsx +397 -0
  24. package/src/editor/hooks/index.ts +4 -0
  25. package/src/editor/hooks/useEditor.ts +36 -0
  26. package/src/editor/hooks/useEditorTheme.ts +91 -0
  27. package/src/editor/hooks/useLanguage.ts +29 -0
  28. package/src/editor/hooks/useMonaco.ts +79 -0
  29. package/src/editor/index.ts +16 -0
  30. package/src/editor/lazy.tsx +76 -0
  31. package/src/editor/lib/index.ts +2 -0
  32. package/src/editor/lib/languages.ts +227 -0
  33. package/src/editor/lib/themes.ts +42 -0
  34. package/src/editor/types/index.ts +137 -0
  35. package/src/editor/workers/index.ts +1 -0
  36. package/src/editor/workers/setup.ts +58 -0
  37. package/src/index.ts +10 -0
  38. package/src/json-editor/JsonEditor.tsx +115 -0
  39. package/src/json-editor/index.ts +1 -0
  40. package/src/json-editor/lazy.tsx +24 -0
  41. package/src/markdown-message/ActionRow.tsx +53 -0
  42. package/src/markdown-message/ChatMessageRow.tsx +103 -0
  43. package/src/markdown-message/CodeBlock.tsx +137 -0
  44. package/src/markdown-message/CollapseToggle.tsx +69 -0
  45. package/src/markdown-message/MarkdownMessage.tsx +376 -0
  46. package/src/markdown-message/README.md +221 -0
  47. package/src/markdown-message/builtinRules.tsx +92 -0
  48. package/src/markdown-message/components.tsx +224 -0
  49. package/src/markdown-message/index.ts +22 -0
  50. package/src/markdown-message/linkRules.ts +83 -0
  51. package/src/markdown-message/plainText.ts +83 -0
  52. package/src/markdown-message/sanitize.ts +88 -0
  53. package/src/markdown-message/streamingBlocks.ts +168 -0
  54. package/src/markdown-message/types.ts +168 -0
  55. package/src/panel-header/CodePanelHeader.tsx +121 -0
  56. package/src/panel-header/index.ts +1 -0
  57. package/src/pretty-code/PrettyCode.client.tsx +402 -0
  58. package/src/pretty-code/README.md +81 -0
  59. package/src/pretty-code/index.tsx +72 -0
  60. package/src/pretty-code/lazy.tsx +69 -0
  61. package/src/pretty-code/pretty-code.stories.tsx +353 -0
  62. package/src/pretty-code/registerPrismLanguages.ts +116 -0
  63. package/src/role-tokens.ts +39 -0
  64. 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;