@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,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
+ }