@djangocfg/widget-code 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- package/src/use-collapsible-content.ts +236 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Components } from 'react-markdown';
|
|
3
|
+
import Mermaid from '@djangocfg/widget-diagram';
|
|
4
|
+
import { ANCHOR } from '../role-tokens';
|
|
5
|
+
import { CodeBlock, CodeBlockFallback } from './CodeBlock';
|
|
6
|
+
import { extractTextFromChildren } from './plainText';
|
|
7
|
+
|
|
8
|
+
/** Body / heading size classes per `size` level. */
|
|
9
|
+
const SIZE_CLASSES: Record<'xs' | 'sm' | 'base', {
|
|
10
|
+
text: string;
|
|
11
|
+
headingBase: string;
|
|
12
|
+
headingSm: string;
|
|
13
|
+
}> = {
|
|
14
|
+
xs: { text: 'text-xs', headingBase: 'text-sm', headingSm: 'text-xs' },
|
|
15
|
+
sm: { text: 'text-sm', headingBase: 'text-base', headingSm: 'text-sm' },
|
|
16
|
+
base: { text: 'text-base', headingBase: 'text-lg', headingSm: 'text-base' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the chat-tuned markdown component map.
|
|
21
|
+
*
|
|
22
|
+
* Body text size follows `size` (xs/sm/base → 12/14/16px). Heading
|
|
23
|
+
* sizes are scaled a notch above the body so inline-in-chat headings
|
|
24
|
+
* stand out without dominating.
|
|
25
|
+
*/
|
|
26
|
+
export function createMarkdownComponents(
|
|
27
|
+
isUser: boolean = false,
|
|
28
|
+
size: 'xs' | 'sm' | 'base' = 'sm',
|
|
29
|
+
codeTheme: 'dark' | 'light' | 'auto' = 'auto',
|
|
30
|
+
): Components {
|
|
31
|
+
const { text: textSize, headingBase, headingSm } = SIZE_CLASSES[size];
|
|
32
|
+
// Code blocks / diagrams only have a binary compact mode — treat the
|
|
33
|
+
// smallest text level as compact, larger ones as roomy.
|
|
34
|
+
const isCompact = size === 'xs';
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
h1: ({ children }) => (
|
|
38
|
+
<h1 className={`${headingBase} font-semibold mb-2 mt-3 first:mt-0`}>{children}</h1>
|
|
39
|
+
),
|
|
40
|
+
h2: ({ children }) => (
|
|
41
|
+
<h2 className={`${headingSm} font-semibold mb-2 mt-3 first:mt-0`}>{children}</h2>
|
|
42
|
+
),
|
|
43
|
+
h3: ({ children }) => (
|
|
44
|
+
<h3 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h3>
|
|
45
|
+
),
|
|
46
|
+
h4: ({ children }) => (
|
|
47
|
+
<h4 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h4>
|
|
48
|
+
),
|
|
49
|
+
h5: ({ children }) => (
|
|
50
|
+
<h5 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h5>
|
|
51
|
+
),
|
|
52
|
+
h6: ({ children }) => (
|
|
53
|
+
<h6 className={`${headingSm} font-medium mb-1 mt-2 first:mt-0`}>{children}</h6>
|
|
54
|
+
),
|
|
55
|
+
|
|
56
|
+
p: ({ children }) => (
|
|
57
|
+
<p className={`${textSize} mb-4 last:mb-0 leading-relaxed break-words`}>{children}</p>
|
|
58
|
+
),
|
|
59
|
+
|
|
60
|
+
ul: ({ children }) => (
|
|
61
|
+
<ul className={`list-disc list-inside mb-2 space-y-1 ${textSize}`}>{children}</ul>
|
|
62
|
+
),
|
|
63
|
+
ol: ({ children }) => (
|
|
64
|
+
<ol className={`list-decimal list-inside mb-2 space-y-1 ${textSize}`}>{children}</ol>
|
|
65
|
+
),
|
|
66
|
+
li: ({ children }) => <li className="break-words">{children}</li>,
|
|
67
|
+
|
|
68
|
+
// `target` / `rel` for external links are NOT set here — the
|
|
69
|
+
// rehype-external-links plugin tags them on the rehype side, so
|
|
70
|
+
// every `<a>` that sanitize let through gets the same security
|
|
71
|
+
// treatment regardless of which renderer (default vs linkRules
|
|
72
|
+
// override) emitted it. Doing it twice here would just duplicate
|
|
73
|
+
// attributes; doing it only here would miss the linkRules path.
|
|
74
|
+
a: ({ href, children, ...rest }) => (
|
|
75
|
+
<a
|
|
76
|
+
{...rest}
|
|
77
|
+
href={href}
|
|
78
|
+
className={`${textSize} ${isUser ? ANCHOR.user : ANCHOR.assistant}`}
|
|
79
|
+
>
|
|
80
|
+
{children}
|
|
81
|
+
</a>
|
|
82
|
+
),
|
|
83
|
+
|
|
84
|
+
pre: ({ children }) => {
|
|
85
|
+
let codeContent = '';
|
|
86
|
+
let language = 'plaintext';
|
|
87
|
+
|
|
88
|
+
if (React.isValidElement(children)) {
|
|
89
|
+
const child = children;
|
|
90
|
+
if (
|
|
91
|
+
child.type === 'code'
|
|
92
|
+
|| (typeof child.type === 'function' && child.type.name === 'code')
|
|
93
|
+
) {
|
|
94
|
+
const codeProps = child.props as {
|
|
95
|
+
className?: string;
|
|
96
|
+
children?: React.ReactNode;
|
|
97
|
+
};
|
|
98
|
+
const rawClassName = codeProps.className;
|
|
99
|
+
language = rawClassName?.replace(/language-/, '').trim() || 'plaintext';
|
|
100
|
+
codeContent = extractTextFromChildren(codeProps.children).trim();
|
|
101
|
+
} else {
|
|
102
|
+
codeContent = extractTextFromChildren(children).trim();
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
codeContent = extractTextFromChildren(children).trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!codeContent) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="my-3 p-3 bg-muted rounded text-sm text-muted-foreground">
|
|
111
|
+
No content available
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (language === 'mermaid') {
|
|
117
|
+
// Inline render fits the bubble width; the Mermaid component
|
|
118
|
+
// owns its own click-to-fullscreen modal which sizes against
|
|
119
|
+
// the viewport, so we don't cap it here. A previous version
|
|
120
|
+
// hardcoded `max-w-[600px]` and that constraint leaked into
|
|
121
|
+
// the fullscreen modal too — diagram rendered at 600px in the
|
|
122
|
+
// middle of an empty viewport.
|
|
123
|
+
return (
|
|
124
|
+
<div className="my-3 w-full">
|
|
125
|
+
<Mermaid chart={codeContent} isCompact={isCompact} />
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return <CodeBlock code={codeContent} language={language} isUser={isUser} isCompact={isCompact} codeTheme={codeTheme} />;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.warn('CodeBlock failed, using fallback:', error);
|
|
135
|
+
return <CodeBlockFallback code={codeContent} language={language} isUser={isUser} isCompact={isCompact} codeTheme={codeTheme} />;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
code: ({ children, className }) => {
|
|
140
|
+
// Inside <pre>: let pre handle styling.
|
|
141
|
+
if (className?.includes('language-')) {
|
|
142
|
+
return <code className={className}>{children}</code>;
|
|
143
|
+
}
|
|
144
|
+
// Inline `<code>` uses the design system's `--code-inline`
|
|
145
|
+
// token. One semantic chip surface across both themes; on a
|
|
146
|
+
// user bubble we still palette-switch with `text-primary-
|
|
147
|
+
// foreground` because the inline chip blends INTO the bubble
|
|
148
|
+
// — there's no panel boundary like a fence has. We trade a
|
|
149
|
+
// perfect "code surface" tone here for legible body inheritance,
|
|
150
|
+
// matching ChatGPT's behaviour in coloured user bubbles.
|
|
151
|
+
const inlineCodeClass = isUser
|
|
152
|
+
? 'bg-primary-foreground/15 text-primary-foreground'
|
|
153
|
+
: 'bg-code-inline text-code-inline-foreground';
|
|
154
|
+
return (
|
|
155
|
+
<code className={`px-1 py-0.5 rounded font-mono text-[0.875em] ${inlineCodeClass} break-all`}>
|
|
156
|
+
{extractTextFromChildren(children)}
|
|
157
|
+
</code>
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// Modern chat convention drops italic on blockquotes — italic +
|
|
162
|
+
// tight bubble = hard to read. Border-left at 2px (4px reads
|
|
163
|
+
// heavy in a 320–480px bubble). On the saturated user bubble we
|
|
164
|
+
// use a primary-foreground tint; on the assistant bubble we use
|
|
165
|
+
// the muted-foreground role for de-emphasis.
|
|
166
|
+
blockquote: ({ children }) => {
|
|
167
|
+
const cls = isUser
|
|
168
|
+
? 'border-primary-foreground/40 text-primary-foreground/80'
|
|
169
|
+
: 'border-border text-muted-foreground';
|
|
170
|
+
return (
|
|
171
|
+
<blockquote className={`${textSize} border-l-2 pl-3 my-3 break-words ${cls}`}>
|
|
172
|
+
{children}
|
|
173
|
+
</blockquote>
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
// Tables: outer wrapper handles overflow, inner `<table>`
|
|
178
|
+
// inherits the chat-density text size. Borders / header use
|
|
179
|
+
// semantic tokens — `border-code-border` for the assistant
|
|
180
|
+
// (matches the code-fence panel for visual cohesion when both
|
|
181
|
+
// appear in the same reply); primary-foreground/N for the user
|
|
182
|
+
// bubble so lines read against the saturated `bg-primary`.
|
|
183
|
+
table: ({ children }) => (
|
|
184
|
+
<div className="overflow-x-auto my-3">
|
|
185
|
+
<table className={`min-w-full ${textSize} border-collapse`}>{children}</table>
|
|
186
|
+
</div>
|
|
187
|
+
),
|
|
188
|
+
thead: ({ children }) => (
|
|
189
|
+
<thead className={isUser ? 'bg-primary-foreground/10' : 'bg-muted/40'}>
|
|
190
|
+
{children}
|
|
191
|
+
</thead>
|
|
192
|
+
),
|
|
193
|
+
tbody: ({ children }) => <tbody>{children}</tbody>,
|
|
194
|
+
tr: ({ children }) => (
|
|
195
|
+
<tr className={isUser ? 'border-b border-primary-foreground/15' : 'border-b border-border'}>
|
|
196
|
+
{children}
|
|
197
|
+
</tr>
|
|
198
|
+
),
|
|
199
|
+
th: ({ children }) => {
|
|
200
|
+
const borderCls = isUser ? 'border-primary-foreground/25' : 'border-border';
|
|
201
|
+
return (
|
|
202
|
+
<th className={`px-2 py-1.5 text-left font-semibold border-b ${borderCls} break-words`}>
|
|
203
|
+
{children}
|
|
204
|
+
</th>
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
td: ({ children }) => <td className="px-2 py-1.5 break-words">{children}</td>,
|
|
208
|
+
|
|
209
|
+
// Soft separator. ChatGPT / Slack / Linear strip the visible
|
|
210
|
+
// line, Claude.ai keeps a hairline. We follow Claude — present
|
|
211
|
+
// but quiet. Palette switches by role so the hairline reads on
|
|
212
|
+
// both surfaces.
|
|
213
|
+
hr: () => (
|
|
214
|
+
<hr
|
|
215
|
+
className={`my-4 border-0 h-px ${
|
|
216
|
+
isUser ? 'bg-primary-foreground/20' : 'bg-border'
|
|
217
|
+
}`}
|
|
218
|
+
/>
|
|
219
|
+
),
|
|
220
|
+
|
|
221
|
+
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
|
222
|
+
em: ({ children }) => <em className="italic">{children}</em>,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MarkdownMessage — chat-tuned markdown renderer.
|
|
3
|
+
*
|
|
4
|
+
* Public API:
|
|
5
|
+
* - `MarkdownMessage` (default + named) — the React component.
|
|
6
|
+
* - `MarkdownMessageProps` — its full prop shape.
|
|
7
|
+
* - `LinkRule` — the declarative link-handling primitive.
|
|
8
|
+
* - `extractTextFromChildren` — utility for callers that build their
|
|
9
|
+
* own custom `a` renderers and need a string label.
|
|
10
|
+
* - `urlChipRule` — opt-in built-in `LinkRule` that renders a bare
|
|
11
|
+
* web URL as a compact `<UrlChip>` (favicon + middle-ellipsis).
|
|
12
|
+
* - `ActionRow` — dumb action row (copy button), takes `visible`
|
|
13
|
+
* from its owner.
|
|
14
|
+
* - `ChatMessageRow` — opinionated bubble + action-row wrapper
|
|
15
|
+
* with state-driven hover, close-delay and touch handling.
|
|
16
|
+
*/
|
|
17
|
+
export { MarkdownMessage, default } from './MarkdownMessage';
|
|
18
|
+
export type { MarkdownMessageProps, LinkRule } from './types';
|
|
19
|
+
export { extractTextFromChildren } from './plainText';
|
|
20
|
+
export { urlChipRule } from './builtinRules';
|
|
21
|
+
export { ActionRow } from './ActionRow';
|
|
22
|
+
export { ChatMessageRow } from './ChatMessageRow';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ComponentProps, ComponentType } from 'react';
|
|
3
|
+
import type { Components } from 'react-markdown';
|
|
4
|
+
import type { LinkRule } from './types';
|
|
5
|
+
|
|
6
|
+
// react-markdown's `Components['a']` is `keyof IntrinsicElements | ComponentType<…>`,
|
|
7
|
+
// so it isn't necessarily a function we can directly call. This is the
|
|
8
|
+
// component-shaped variant — what callers practically pass when they
|
|
9
|
+
// override `a`.
|
|
10
|
+
type AComponent = ComponentType<ComponentProps<'a'>>;
|
|
11
|
+
|
|
12
|
+
/** Run every rule's `preprocess` hook in order. Errors in a single
|
|
13
|
+
* rule are logged and skipped — the other rules still run. */
|
|
14
|
+
export function applyPreprocess(
|
|
15
|
+
source: string,
|
|
16
|
+
rules: readonly LinkRule[] | undefined,
|
|
17
|
+
): string {
|
|
18
|
+
if (!rules || rules.length === 0) return source;
|
|
19
|
+
let s = source;
|
|
20
|
+
for (const rule of rules) {
|
|
21
|
+
if (!rule.preprocess) continue;
|
|
22
|
+
try {
|
|
23
|
+
s = rule.preprocess(s);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.warn(
|
|
27
|
+
`[MarkdownMessage] linkRule "${rule.name ?? '(anonymous)'}" preprocess threw; skipping`,
|
|
28
|
+
err,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Union of `extraHrefProtocols` and any `protocols` declared by rules. */
|
|
36
|
+
export function collectProtocols(
|
|
37
|
+
extraHrefProtocols: readonly string[] | undefined,
|
|
38
|
+
rules: readonly LinkRule[] | undefined,
|
|
39
|
+
): readonly string[] | undefined {
|
|
40
|
+
const set = new Set<string>();
|
|
41
|
+
if (extraHrefProtocols) for (const p of extraHrefProtocols) set.add(p);
|
|
42
|
+
if (rules) {
|
|
43
|
+
for (const r of rules) {
|
|
44
|
+
if (r.protocols) for (const p of r.protocols) set.add(p);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return set.size === 0 ? undefined : Array.from(set);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build a custom `a` renderer that dispatches to the first matching
|
|
51
|
+
* rule, falling through to `callerA` (or the built-in chat anchor)
|
|
52
|
+
* for anything no rule claims. */
|
|
53
|
+
export function buildLinkRulesComponent(
|
|
54
|
+
rules: readonly LinkRule[],
|
|
55
|
+
isUser: boolean,
|
|
56
|
+
callerA: NonNullable<Components['a']> | undefined,
|
|
57
|
+
): NonNullable<Components['a']> {
|
|
58
|
+
const Renderer: AComponent = (props) => {
|
|
59
|
+
const { href, children } = props;
|
|
60
|
+
if (typeof href === 'string') {
|
|
61
|
+
for (const rule of rules) {
|
|
62
|
+
if (rule.match(href)) {
|
|
63
|
+
return React.createElement(
|
|
64
|
+
React.Fragment,
|
|
65
|
+
null,
|
|
66
|
+
rule.render({ href, children, isUser }),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Defer to the caller's `a` override if any. We only call it when
|
|
72
|
+
// it's a function/component — string-form intrinsic overrides
|
|
73
|
+
// (`'a' | 'span' | …`) aren't a thing react-markdown supports for
|
|
74
|
+
// tag overrides, but the type still admits the union, so guard.
|
|
75
|
+
if (callerA && typeof callerA === 'function') {
|
|
76
|
+
const Caller = callerA as AComponent;
|
|
77
|
+
return React.createElement(Caller, props);
|
|
78
|
+
}
|
|
79
|
+
// Fall through to the built-in anchor by rendering a plain `<a>`.
|
|
80
|
+
return React.createElement('a', props, children);
|
|
81
|
+
};
|
|
82
|
+
return Renderer as NonNullable<Components['a']>;
|
|
83
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/** Recursively concatenate the text content of a React.ReactNode tree.
|
|
4
|
+
* Used by the markdown renderers (e.g. `<pre>` extracting code) and
|
|
5
|
+
* by consumers that need a plain-string label out of link children. */
|
|
6
|
+
export function extractTextFromChildren(children: React.ReactNode): string {
|
|
7
|
+
if (typeof children === 'string') return children;
|
|
8
|
+
if (typeof children === 'number') return String(children);
|
|
9
|
+
if (React.isValidElement(children)) {
|
|
10
|
+
const props = children.props as { children?: React.ReactNode };
|
|
11
|
+
return extractTextFromChildren(props.children);
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(children)) {
|
|
14
|
+
return children.map(extractTextFromChildren).join('');
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Auto-detect whether `text` should bypass ReactMarkdown and render as
|
|
21
|
+
* a flat `<div whitespace-pre-wrap>`. Used as the *fallback* when the
|
|
22
|
+
* caller doesn't pass `plainText` explicitly to `MarkdownMessage`.
|
|
23
|
+
*
|
|
24
|
+
* The signal we trust: short, single-paragraph, no markdown markers.
|
|
25
|
+
* Anything longer / multi-paragraph / structurally suggestive falls
|
|
26
|
+
* through to the markdown pipeline — false negatives there are cheap
|
|
27
|
+
* (markdown renders prose correctly), false positives in the prose
|
|
28
|
+
* branch surface as escaped `*` / `#` / etc. so we err markdown-ward.
|
|
29
|
+
*
|
|
30
|
+
* Thresholds were picked from chat-bubble UX research (see ChatGPT,
|
|
31
|
+
* Claude.ai, WhatsApp): roughly "would a person have written this in
|
|
32
|
+
* one keystroke without thinking about formatting?". If you find them
|
|
33
|
+
* too tight or too loose, tune here — every consumer goes through
|
|
34
|
+
* `MarkdownMessage` so the change is universal.
|
|
35
|
+
*/
|
|
36
|
+
export function looksLikePlainProse(text: string): boolean {
|
|
37
|
+
const trimmed = text.trim();
|
|
38
|
+
// Empty / whitespace-only — render as plain (cheap path, nothing to parse).
|
|
39
|
+
if (trimmed.length === 0) return true;
|
|
40
|
+
// Long enough that it's likely a structured assistant reply.
|
|
41
|
+
if (trimmed.length > 500) return false;
|
|
42
|
+
// Paragraph break → almost certainly markdown territory.
|
|
43
|
+
if (/\n\s*\n/.test(trimmed)) return false;
|
|
44
|
+
// Many single-line breaks → probably a list or stanza.
|
|
45
|
+
const newlineCount = (trimmed.match(/\n/g) || []).length;
|
|
46
|
+
if (newlineCount > 4) return false;
|
|
47
|
+
// Any markdown marker → defer to ReactMarkdown.
|
|
48
|
+
if (hasMarkdownSyntax(trimmed)) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Affordance test: does this string look like markdown? Used to skip
|
|
53
|
+
* the (heavier) ReactMarkdown pipeline when the content is pure
|
|
54
|
+
* prose. NOT a validator — false negatives are fine; false positives
|
|
55
|
+
* cost a render but render correctly. */
|
|
56
|
+
export function hasMarkdownSyntax(text: string): boolean {
|
|
57
|
+
// Newlines after trim → render as markdown so paragraphs work.
|
|
58
|
+
if (text.trim().includes('\n')) return true;
|
|
59
|
+
|
|
60
|
+
// Inline HTML tags (`<br>`, `<b>`, `<code>`, …) — common in OpenAPI
|
|
61
|
+
// descriptions. Without this branch the fast path would escape them
|
|
62
|
+
// and the user sees literal angle brackets.
|
|
63
|
+
if (/<\/?[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?\/?>/.test(text)) return true;
|
|
64
|
+
|
|
65
|
+
const patterns = [
|
|
66
|
+
/^#{1,6}\s/m, // Headers
|
|
67
|
+
/\*\*[^*]+\*\*/, // Bold
|
|
68
|
+
/\*[^*]+\*/, // Italic
|
|
69
|
+
/__[^_]+__/, // Bold (underscore)
|
|
70
|
+
/_[^_]+_/, // Italic (underscore)
|
|
71
|
+
/\[.+\]\(.+\)/, // Links
|
|
72
|
+
/!\[.*\]\(.+\)/, // Images
|
|
73
|
+
/```[\s\S]*```/, // Code blocks
|
|
74
|
+
/`[^`]+`/, // Inline code
|
|
75
|
+
/^\s*[-*+]\s/m, // Unordered lists
|
|
76
|
+
/^\s*\d+\.\s/m, // Ordered lists
|
|
77
|
+
/^\s*>/m, // Blockquotes
|
|
78
|
+
/\|.+\|/, // Tables
|
|
79
|
+
/^---+$/m, // Horizontal rules
|
|
80
|
+
/~~[^~]+~~/, // Strikethrough
|
|
81
|
+
];
|
|
82
|
+
return patterns.some((p) => p.test(text));
|
|
83
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defaultSchema } from 'rehype-sanitize';
|
|
2
|
+
|
|
3
|
+
/** Sanitize schema. Typed loosely (`Record<string, unknown>`) so this
|
|
4
|
+
* package's published types don't try to re-export
|
|
5
|
+
* `hast-util-sanitize`'s internal Schema type — that breaks consumers
|
|
6
|
+
* whose TS doesn't have the transitive dependency in scope. */
|
|
7
|
+
export type SanitizeSchema = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
// Allow-list HTML that OpenAPI descriptions commonly use — we want
|
|
10
|
+
// `<b>`/`<code>`/`<br>` to render, not to appear as literal text.
|
|
11
|
+
// Built on top of rehype-sanitize's default schema so we keep the
|
|
12
|
+
// XSS protection (no `<script>`, no `on*` handlers, no `javascript:`
|
|
13
|
+
// URLs); we just extend the tag allow-list with safe presentational
|
|
14
|
+
// elements that are in wide use in API docs.
|
|
15
|
+
export const HTML_SCHEMA_BASE: SanitizeSchema = {
|
|
16
|
+
...defaultSchema,
|
|
17
|
+
tagNames: [
|
|
18
|
+
...(defaultSchema.tagNames ?? []),
|
|
19
|
+
'br',
|
|
20
|
+
'b',
|
|
21
|
+
'i',
|
|
22
|
+
'u',
|
|
23
|
+
's',
|
|
24
|
+
'sub',
|
|
25
|
+
'sup',
|
|
26
|
+
'small',
|
|
27
|
+
'mark',
|
|
28
|
+
'kbd',
|
|
29
|
+
'code',
|
|
30
|
+
'pre',
|
|
31
|
+
'details',
|
|
32
|
+
'summary',
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Build a sanitize schema with extra href-URL protocols allowed on
|
|
37
|
+
* `<a>`. Default whitelists `http(s)/mailto/xmpp/irc(s)` — pass extras
|
|
38
|
+
* here (e.g. `'cmdop'`, `'obsidian'`) to opt them in. */
|
|
39
|
+
export function buildSchema(extraProtocols: readonly string[] | undefined): SanitizeSchema {
|
|
40
|
+
if (!extraProtocols || extraProtocols.length === 0) return HTML_SCHEMA_BASE;
|
|
41
|
+
const baseProtocols =
|
|
42
|
+
(HTML_SCHEMA_BASE as { protocols?: Record<string, string[]> }).protocols
|
|
43
|
+
?? (defaultSchema as { protocols?: Record<string, string[]> }).protocols
|
|
44
|
+
?? {};
|
|
45
|
+
const baseHref = baseProtocols.href ?? ['http', 'https', 'mailto', 'xmpp', 'irc', 'ircs'];
|
|
46
|
+
return {
|
|
47
|
+
...HTML_SCHEMA_BASE,
|
|
48
|
+
protocols: {
|
|
49
|
+
...baseProtocols,
|
|
50
|
+
href: [...baseHref, ...extraProtocols],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build a `urlTransform` for ReactMarkdown that lets opted-in schemes
|
|
56
|
+
* pass through verbatim. react-markdown's default strips `href` for
|
|
57
|
+
* unrecognised schemes BEFORE sanitize runs — even with our extended
|
|
58
|
+
* sanitize schema, custom schemes would arrive with `href=""`. */
|
|
59
|
+
export function buildUrlTransform(extraProtocols: readonly string[] | undefined) {
|
|
60
|
+
if (!extraProtocols || extraProtocols.length === 0) return undefined;
|
|
61
|
+
const lower = extraProtocols.map((p) => p.toLowerCase() + ':');
|
|
62
|
+
return (url: string): string => {
|
|
63
|
+
const u = url.trim().toLowerCase();
|
|
64
|
+
for (const p of lower) {
|
|
65
|
+
if (u.startsWith(p)) return url;
|
|
66
|
+
}
|
|
67
|
+
// Fallback: replicate react-markdown's defaults for safe schemes
|
|
68
|
+
// and relative paths. Anything else → empty string so the
|
|
69
|
+
// anchor's href is dropped (XSS guard).
|
|
70
|
+
//
|
|
71
|
+
// The bare-name branch must NOT contain `:`. It exists for a relative
|
|
72
|
+
// target like `intro.md`, and with a colon in the class the whole of
|
|
73
|
+
// `javascript:alert(1)` matched it — the guard returned the payload
|
|
74
|
+
// verbatim, which is the one thing this function exists to prevent.
|
|
75
|
+
// (rehype-sanitize's `protocols.href` allow-list caught it downstream, so
|
|
76
|
+
// nothing shipped exploitable; a layer of defence-in-depth that does not
|
|
77
|
+
// defend is still broken.) A scheme is what a colon before the first
|
|
78
|
+
// `/`, `?` or `#` means, so excluding it here leaves relative paths
|
|
79
|
+
// intact and rejects every scheme not named above.
|
|
80
|
+
if (
|
|
81
|
+
/^(https?:|mailto:|tel:|xmpp:|irc:|ircs:|#|\/|\.\/|\.\.\/|\?)/i.test(u)
|
|
82
|
+
|| /^[a-z0-9._~!$&'()*+,;=@%-]+$/i.test(u)
|
|
83
|
+
) {
|
|
84
|
+
return url;
|
|
85
|
+
}
|
|
86
|
+
return '';
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-markdown block splitter + boundary buffering.
|
|
3
|
+
*
|
|
4
|
+
* Purpose: render markdown LIVE during streaming without (a) re-parsing
|
|
5
|
+
* the whole document on every token (O(n²)) and (b) flashing raw syntax
|
|
6
|
+
* for half-written constructs. Approach (per plan105 §3.1 + §3.3):
|
|
7
|
+
*
|
|
8
|
+
* 1. Block-split: split accumulating markdown into top-level blocks at
|
|
9
|
+
* blank-line boundaries, RESPECTING fenced code blocks (a blank line
|
|
10
|
+
* inside ``` does NOT end the block). Each completed block has stable
|
|
11
|
+
* `raw` source, so the consumer can memoize blocks 0..N-1 and only
|
|
12
|
+
* re-parse the tail → ~O(n).
|
|
13
|
+
* 2. Boundary buffering: the trailing block — the one still receiving
|
|
14
|
+
* tokens — is held back as RAW plain text until it reaches a safe
|
|
15
|
+
* boundary (a trailing newline). This kills the "flash of incomplete
|
|
16
|
+
* `**bold` / `[link](`" without any fragile syntax repair.
|
|
17
|
+
* 3. Fence awareness: if the stream ends INSIDE an unclosed ``` fence,
|
|
18
|
+
* the tail is rendered as an in-progress code block (we virtually
|
|
19
|
+
* close the fence for that render only) instead of as broken markdown
|
|
20
|
+
* bleeding into the rest of the message.
|
|
21
|
+
*
|
|
22
|
+
* No `marked` / lexer dependency — a blank-line splitter that tracks fence
|
|
23
|
+
* state is sufficient for the safe-80% path and adds zero deps. The actual
|
|
24
|
+
* per-block rendering still goes through the full ReactMarkdown pipeline.
|
|
25
|
+
*
|
|
26
|
+
* This module is pure (no React) so it is trivially testable.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A fenced-code-block opener: ``` or ~~~, optionally indented up to 3
|
|
30
|
+
* spaces, with an optional info string. Per CommonMark the closing fence
|
|
31
|
+
* must use the same char and be at least as long; we keep it simple and
|
|
32
|
+
* match the char + length, which covers real LLM output. */
|
|
33
|
+
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
34
|
+
|
|
35
|
+
interface FenceState {
|
|
36
|
+
open: boolean;
|
|
37
|
+
/** The exact fence marker that opened the current block (e.g. "```"). */
|
|
38
|
+
marker: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does `line` open/close a fence given the current state? Returns the
|
|
42
|
+
* next fence state. A line that closes uses the same char, >= length, and
|
|
43
|
+
* no trailing info string. */
|
|
44
|
+
function stepFence(line: string, state: FenceState): FenceState {
|
|
45
|
+
const m = FENCE_RE.exec(line);
|
|
46
|
+
if (!m) return state;
|
|
47
|
+
// Groups 2 and 3 are non-optional in FENCE_RE, so a match always fills them.
|
|
48
|
+
const marker = m[2] ?? '';
|
|
49
|
+
const info = (m[3] ?? '').trim();
|
|
50
|
+
if (!state.open) {
|
|
51
|
+
// Any fence line opens a block (info string allowed on open).
|
|
52
|
+
return { open: true, marker };
|
|
53
|
+
}
|
|
54
|
+
// Inside a fence: a bare fence of the same char and >= length closes it.
|
|
55
|
+
// Group 2 matches 3+ chars, so both markers are non-empty here; the `[0]`
|
|
56
|
+
// reads compare `undefined` only for a state that can't occur.
|
|
57
|
+
const sameChar = marker[0] === state.marker[0];
|
|
58
|
+
const longEnough = marker.length >= state.marker.length;
|
|
59
|
+
if (sameChar && longEnough && info === '') {
|
|
60
|
+
return { open: false, marker: '' };
|
|
61
|
+
}
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SplitResult {
|
|
66
|
+
/** Completed, stable top-level blocks (safe to memoize + parse). */
|
|
67
|
+
blocks: string[];
|
|
68
|
+
/** The trailing in-flight block, or '' if the content ended on a
|
|
69
|
+
* clean boundary. Held back from `blocks` so it can be rendered raw
|
|
70
|
+
* (boundary buffering) until it completes. */
|
|
71
|
+
tail: string;
|
|
72
|
+
/** True when `tail` is the body of an unterminated fenced code block —
|
|
73
|
+
* the consumer should render it as an in-progress code block rather
|
|
74
|
+
* than raw prose. */
|
|
75
|
+
tailInOpenFence: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Split `content` into stable blocks + a trailing in-flight block.
|
|
80
|
+
*
|
|
81
|
+
* Boundary rule: a block is "complete" once a blank line follows it AND
|
|
82
|
+
* we are not inside an open fence. The text after the last such boundary
|
|
83
|
+
* is the `tail`. If the whole content ends with a newline and no fence is
|
|
84
|
+
* open, the tail is empty (everything promoted to stable blocks).
|
|
85
|
+
*
|
|
86
|
+
* @param content raw accumulated markdown (never mutated)
|
|
87
|
+
*/
|
|
88
|
+
export function splitStreamingBlocks(content: string): SplitResult {
|
|
89
|
+
if (content.length === 0) {
|
|
90
|
+
return { blocks: [], tail: '', tailInOpenFence: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = content.split('\n');
|
|
94
|
+
const blocks: string[] = [];
|
|
95
|
+
let current: string[] = [];
|
|
96
|
+
let fence: FenceState = { open: false, marker: '' };
|
|
97
|
+
|
|
98
|
+
// Track fence state at the START of the current accumulating block so we
|
|
99
|
+
// know, at the end, whether the tail is inside an open fence.
|
|
100
|
+
for (let i = 0; i < lines.length; i++) {
|
|
101
|
+
const line = lines[i];
|
|
102
|
+
if (line === undefined) continue;
|
|
103
|
+
const wasOpen = fence.open;
|
|
104
|
+
fence = stepFence(line, fence);
|
|
105
|
+
|
|
106
|
+
const isBlank = line.trim() === '';
|
|
107
|
+
// A blank line outside any fence is a block boundary. We only cut when
|
|
108
|
+
// we have accumulated something — leading/again-blank lines collapse.
|
|
109
|
+
if (isBlank && !fence.open && !wasOpen) {
|
|
110
|
+
if (current.length > 0) {
|
|
111
|
+
blocks.push(current.join('\n'));
|
|
112
|
+
current = [];
|
|
113
|
+
}
|
|
114
|
+
// Drop the blank separator itself.
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
current.push(line);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Whatever remains is the in-flight tail. If the original content ended
|
|
121
|
+
// with a trailing newline AND we're on a clean boundary (no open fence),
|
|
122
|
+
// the tail is genuinely complete prose — but we still keep it as `tail`
|
|
123
|
+
// unless it was already closed by a blank line above. To maximize stable
|
|
124
|
+
// blocks, promote the tail to a completed block when it ends on a clean
|
|
125
|
+
// newline boundary and no fence is open.
|
|
126
|
+
let tail = current.join('\n');
|
|
127
|
+
let tailInOpenFence = fence.open;
|
|
128
|
+
|
|
129
|
+
if (!tailInOpenFence && tail.length > 0 && content.endsWith('\n')) {
|
|
130
|
+
// Content ended on a newline and the tail isn't inside a fence — treat
|
|
131
|
+
// it as a finished block so it parses (and memoizes) immediately.
|
|
132
|
+
blocks.push(tail);
|
|
133
|
+
tail = '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { blocks, tail, tailInOpenFence };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Prepare the in-flight tail for rendering.
|
|
141
|
+
*
|
|
142
|
+
* - If the tail is inside an open fence, virtually close it so it renders
|
|
143
|
+
* as an in-progress code block (not raw text bleeding downward). The
|
|
144
|
+
* close is appended to a COPY — stored content is never mutated.
|
|
145
|
+
* - Otherwise return the tail untouched; the consumer renders it as plain
|
|
146
|
+
* `whitespace-pre-wrap` text (boundary buffering — no syntax repair).
|
|
147
|
+
*
|
|
148
|
+
* NOTE: we deliberately do NOT auto-close dangling `**` / `[` / inline
|
|
149
|
+
* backticks here (plan105 §3.2, deferred / flag-gated). The only fence we
|
|
150
|
+
* close is the block code fence, because an unclosed block fence would
|
|
151
|
+
* otherwise swallow the rest of the message and the close is unambiguous.
|
|
152
|
+
*/
|
|
153
|
+
export function renderableTail(
|
|
154
|
+
tail: string,
|
|
155
|
+
tailInOpenFence: boolean,
|
|
156
|
+
): { source: string; asCode: boolean } {
|
|
157
|
+
if (!tailInOpenFence) return { source: tail, asCode: false };
|
|
158
|
+
// Re-derive the opening fence marker so we close with a matching one.
|
|
159
|
+
const firstLine = tail.split('\n', 1)[0] ?? '';
|
|
160
|
+
const m = FENCE_RE.exec(firstLine);
|
|
161
|
+
const marker = m?.[2] ?? '```';
|
|
162
|
+
// Ensure the close sits on its own line.
|
|
163
|
+
const needsNewline = !tail.endsWith('\n');
|
|
164
|
+
return {
|
|
165
|
+
source: `${tail}${needsNewline ? '\n' : ''}${marker}`,
|
|
166
|
+
asCode: true,
|
|
167
|
+
};
|
|
168
|
+
}
|