@schlessera/brain-ui-react 0.7.2 → 0.9.0

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 (72) hide show
  1. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  2. package/dist/components/chat/brain-markdown.js +93 -55
  3. package/dist/components/chat/brain-markdown.js.map +1 -1
  4. package/dist/components/chat/chat-page.d.ts.map +1 -1
  5. package/dist/components/chat/chat-page.js +10 -1
  6. package/dist/components/chat/chat-page.js.map +1 -1
  7. package/dist/components/chat/copy-button.d.ts +6 -0
  8. package/dist/components/chat/copy-button.d.ts.map +1 -0
  9. package/dist/components/chat/copy-button.js +16 -0
  10. package/dist/components/chat/copy-button.js.map +1 -0
  11. package/dist/components/chat/mermaid-block.d.ts +14 -0
  12. package/dist/components/chat/mermaid-block.d.ts.map +1 -0
  13. package/dist/components/chat/mermaid-block.js +55 -0
  14. package/dist/components/chat/mermaid-block.js.map +1 -0
  15. package/dist/components/chat/mermaid-share.d.ts +5 -0
  16. package/dist/components/chat/mermaid-share.d.ts.map +1 -0
  17. package/dist/components/chat/mermaid-share.js +80 -0
  18. package/dist/components/chat/mermaid-share.js.map +1 -0
  19. package/dist/components/chat/mermaid-viewer.d.ts +6 -0
  20. package/dist/components/chat/mermaid-viewer.d.ts.map +1 -0
  21. package/dist/components/chat/mermaid-viewer.js +266 -0
  22. package/dist/components/chat/mermaid-viewer.js.map +1 -0
  23. package/dist/components/chat/message-share.d.ts.map +1 -1
  24. package/dist/components/chat/message-share.js +7 -4
  25. package/dist/components/chat/message-share.js.map +1 -1
  26. package/dist/components/chat/share-block.d.ts.map +1 -1
  27. package/dist/components/chat/share-block.js +5 -2
  28. package/dist/components/chat/share-block.js.map +1 -1
  29. package/dist/components/files/file-viewer.d.ts.map +1 -1
  30. package/dist/components/files/file-viewer.js +30 -5
  31. package/dist/components/files/file-viewer.js.map +1 -1
  32. package/dist/components/share/share-menu.d.ts +8 -1
  33. package/dist/components/share/share-menu.d.ts.map +1 -1
  34. package/dist/components/share/share-menu.js +10 -5
  35. package/dist/components/share/share-menu.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/client-environment.d.ts +29 -0
  41. package/dist/lib/client-environment.d.ts.map +1 -0
  42. package/dist/lib/client-environment.js +153 -0
  43. package/dist/lib/client-environment.js.map +1 -0
  44. package/dist/lib/mermaid-theme.d.ts +4 -0
  45. package/dist/lib/mermaid-theme.d.ts.map +1 -0
  46. package/dist/lib/mermaid-theme.js +221 -0
  47. package/dist/lib/mermaid-theme.js.map +1 -0
  48. package/dist/lib/mermaid.d.ts +62 -0
  49. package/dist/lib/mermaid.d.ts.map +1 -0
  50. package/dist/lib/mermaid.js +244 -0
  51. package/dist/lib/mermaid.js.map +1 -0
  52. package/dist/stores/file-store.d.ts.map +1 -1
  53. package/dist/stores/file-store.js +9 -2
  54. package/dist/stores/file-store.js.map +1 -1
  55. package/dist/styles.css +1 -1
  56. package/package.json +3 -2
  57. package/src/components/chat/brain-markdown.tsx +109 -80
  58. package/src/components/chat/chat-page.tsx +20 -1
  59. package/src/components/chat/copy-button.tsx +26 -0
  60. package/src/components/chat/mermaid-block.tsx +106 -0
  61. package/src/components/chat/mermaid-share.ts +91 -0
  62. package/src/components/chat/mermaid-viewer.tsx +336 -0
  63. package/src/components/chat/message-share.ts +7 -4
  64. package/src/components/chat/share-block.tsx +5 -2
  65. package/src/components/files/file-viewer.tsx +37 -5
  66. package/src/components/share/share-menu.tsx +20 -6
  67. package/src/index.ts +5 -0
  68. package/src/lib/client-environment.ts +152 -0
  69. package/src/lib/mermaid-theme.ts +246 -0
  70. package/src/lib/mermaid.ts +287 -0
  71. package/src/stores/file-store.ts +12 -2
  72. package/src/theme.css +10 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Mermaid runtime + fence helpers.
3
+ *
4
+ * Mermaid is ~2MB, so it loads on first use via dynamic import; nothing here
5
+ * pulls it in at module scope. Rendering is tolerant of partial sources
6
+ * (streaming deltas): parse is gated and every failure resolves to null
7
+ * instead of throwing, so callers keep the previous good SVG. Results are
8
+ * cached per (theme, source), which makes the per-token re-render of a
9
+ * streaming message a cache hit.
10
+ */
11
+
12
+ import { mermaidThemeVariables, type MermaidTheme } from "./mermaid-theme.js";
13
+
14
+ export type { MermaidTheme };
15
+
16
+ type MermaidApi = typeof import("mermaid").default;
17
+
18
+ let mermaidPromise: Promise<MermaidApi> | null = null;
19
+
20
+ function loadMermaid(): Promise<MermaidApi> {
21
+ if (!mermaidPromise) {
22
+ mermaidPromise = import("mermaid").then((mod) => mod.default);
23
+ }
24
+ return mermaidPromise;
25
+ }
26
+
27
+ /** Config shared by both themes; only `themeVariables` differs per render. */
28
+ function baseConfig(theme: MermaidTheme) {
29
+ return {
30
+ startOnLoad: false,
31
+ // "strict" sanitizes label HTML and blocks script/click payloads —
32
+ // diagram sources arrive from the model and from repo files.
33
+ securityLevel: "strict" as const,
34
+ // Never inject mermaid's own error SVG into the document; failures
35
+ // surface as a null render result and the caller shows the source.
36
+ suppressErrorRendering: true,
37
+ theme: "base" as const,
38
+ themeVariables: mermaidThemeVariables(theme),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Renders are serialized through this chain because the theme is applied by
44
+ * re-calling mermaid.initialize(), which mutates GLOBAL config — a dark
45
+ * in-app render and a light share render would otherwise race and one would
46
+ * come out in the other's palette.
47
+ *
48
+ * The obvious alternative, a per-diagram `%%{init: …}%%` directive, does not
49
+ * work: mermaid rewrites every `'` to `"` before JSON.parse-ing a directive
50
+ * (chunk-NSK5VX7P), so a quoted font stack makes the whole directive
51
+ * unparseable and it is silently dropped; and its themeVariables sanitizer
52
+ * rejects any value outside /^[\\d "#%(),.;A-Za-z]+$/, which blanks every
53
+ * hyphenated CSS keyword (`system-ui`, `-apple-system`). A dropped directive
54
+ * fails SILENTLY — the diagram renders in whatever the last global config
55
+ * was — so this path is not worth the theming it appears to buy.
56
+ */
57
+ let renderChain: Promise<unknown> = Promise.resolve();
58
+
59
+ function serialized<T>(fn: () => Promise<T>): Promise<T> {
60
+ const run = renderChain.then(fn, fn);
61
+ renderChain = run.then(
62
+ () => undefined,
63
+ () => undefined
64
+ );
65
+ return run;
66
+ }
67
+
68
+ const svgCache = new Map<string, string>();
69
+ const CACHE_MAX = 100;
70
+ let renderSeq = 0;
71
+
72
+ function cacheKey(source: string, theme: MermaidTheme): string {
73
+ return `${theme}\u0000${source}`;
74
+ }
75
+
76
+ /** Synchronous cache lookup, so a remounted block can show its SVG without a flash. */
77
+ export function peekMermaidSvg(source: string, theme: MermaidTheme = "dark"): string | null {
78
+ return svgCache.get(cacheKey(source.trim(), theme)) ?? null;
79
+ }
80
+
81
+ /**
82
+ * Render a mermaid source to SVG markup. Resolves null when the source does
83
+ * not parse (e.g. an incomplete streaming fence) or rendering fails — never
84
+ * throws and never mutates the document beyond mermaid's temp container.
85
+ */
86
+ export async function renderMermaidSvg(
87
+ source: string,
88
+ theme: MermaidTheme = "dark"
89
+ ): Promise<string | null> {
90
+ const trimmed = source.trim();
91
+ if (!trimmed) return null;
92
+ const key = cacheKey(trimmed, theme);
93
+ const hit = svgCache.get(key);
94
+ if (hit !== undefined) return hit;
95
+ try {
96
+ const mermaid = await loadMermaid();
97
+ return await serialized(async () => {
98
+ // Re-check inside the lock: an identical render may have been queued
99
+ // ahead of this one while both were waiting.
100
+ const queued = svgCache.get(key);
101
+ if (queued !== undefined) return queued;
102
+ mermaid.initialize(baseConfig(theme));
103
+ const ok = await mermaid.parse(trimmed, { suppressErrors: true });
104
+ if (!ok) return null;
105
+ const { svg } = await mermaid.render(`brain-mermaid-${++renderSeq}`, trimmed);
106
+ if (svgCache.size >= CACHE_MAX) {
107
+ const oldest = svgCache.keys().next().value;
108
+ if (oldest !== undefined) svgCache.delete(oldest);
109
+ }
110
+ svgCache.set(key, svg);
111
+ return svg;
112
+ });
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export interface MermaidFence {
119
+ /** Offset of the opening fence line start. */
120
+ start: number;
121
+ /** Offset just past the closing fence line (and its newline, if present). */
122
+ end: number;
123
+ /** Diagram source between the fences, without the trailing newline. */
124
+ source: string;
125
+ /** Leading spaces of the opening fence line (a list-indented fence). */
126
+ indent: string;
127
+ }
128
+
129
+ const FENCE_LINE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
130
+
131
+ /**
132
+ * Find every *closed* ```mermaid fence in a markdown string. A line scanner
133
+ * (not a regex over the whole text) so mermaid fences nested inside other
134
+ * code blocks are not matched, and an unterminated streaming fence is simply
135
+ * not returned.
136
+ */
137
+ export function findMermaidFences(md: string): MermaidFence[] {
138
+ const out: MermaidFence[] = [];
139
+ const lines = md.split("\n");
140
+ let offset = 0;
141
+ let open: {
142
+ char: string;
143
+ len: number;
144
+ mermaid: boolean;
145
+ start: number;
146
+ contentStart: number;
147
+ indent: string;
148
+ } | null = null;
149
+
150
+ for (const line of lines) {
151
+ const lineEnd = Math.min(offset + line.length + 1, md.length);
152
+ const m = FENCE_LINE_RE.exec(line);
153
+ if (open) {
154
+ if (m && m[2][0] === open.char && m[2].length >= open.len && m[3].trim() === "") {
155
+ if (open.mermaid) {
156
+ out.push({
157
+ start: open.start,
158
+ end: lineEnd,
159
+ source: md.slice(open.contentStart, offset).replace(/\n$/, ""),
160
+ indent: open.indent,
161
+ });
162
+ }
163
+ open = null;
164
+ }
165
+ } else if (m) {
166
+ const info = m[3].trim();
167
+ // A backtick fence's info string may not contain backticks (CommonMark).
168
+ if (!(m[2][0] === "`" && info.includes("`"))) {
169
+ const lang = info.split(/\s+/)[0]?.toLowerCase() ?? "";
170
+ open = {
171
+ char: m[2][0],
172
+ len: m[2].length,
173
+ mermaid: lang === "mermaid" || lang === "mmd",
174
+ start: offset,
175
+ contentStart: lineEnd,
176
+ indent: m[1],
177
+ };
178
+ }
179
+ }
180
+ offset += line.length + 1;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /**
186
+ * Replace each closed mermaid fence via `replacement`; returning null keeps
187
+ * the fence verbatim. Pure — rendering is injected, so this is unit-testable
188
+ * without a DOM.
189
+ */
190
+ export function replaceMermaidFences(
191
+ md: string,
192
+ replacement: (fence: MermaidFence, index: number) => string | null
193
+ ): string {
194
+ const fences = findMermaidFences(md);
195
+ let out = md;
196
+ for (let i = fences.length - 1; i >= 0; i--) {
197
+ const r = replacement(fences[i], i);
198
+ if (r == null) continue;
199
+ out = out.slice(0, fences[i].start) + r + out.slice(fences[i].end);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Inline every closed mermaid fence as a pre-rendered `<div class="mermaid-figure"><svg…>`
206
+ * block. Used by the share pipeline: the PNG/PDF renderer runs the page with
207
+ * JavaScript disabled and all network denied, so the diagram must already be
208
+ * SVG by the time the markdown reaches the server. Defaults to the light
209
+ * theme to match the share template. Fences that fail to render are
210
+ * left as code fences — the pre-feature behavior.
211
+ */
212
+ /**
213
+ * The server's render route caps `content` at 512 KiB. Inlining can expand a
214
+ * few-hundred-byte fence into a multi-kilobyte SVG, so stay safely below the
215
+ * cap and leave any fence that would cross it as source — a shared code block
216
+ * beats a rejected request.
217
+ */
218
+ const INLINE_BUDGET_CHARS = 480_000;
219
+
220
+ /** Pure inlining step: substitute pre-rendered SVGs (by fence index) under the budget. */
221
+ export function inlineRenderedFences(
222
+ md: string,
223
+ rendered: (string | null)[],
224
+ budget: number = INLINE_BUDGET_CHARS
225
+ ): string {
226
+ let total = md.length;
227
+ return replaceMermaidFences(md, (fence, i) => {
228
+ const svg = rendered[i];
229
+ if (!svg) return null;
230
+ // Kept to a single line: marked treats <div> as an HTML block that a
231
+ // blank line would terminate, so newlines inside the SVG must go. The
232
+ // fence's own indentation is preserved so a list-nested fence doesn't
233
+ // break out of its list in the shared document.
234
+ const html = `\n${fence.indent}<div class="mermaid-figure">${svg.replace(/[\r\n]+/g, " ")}</div>\n`;
235
+ const expanded = total + html.length - (fence.end - fence.start);
236
+ if (expanded > budget) return null;
237
+ total = expanded;
238
+ return html;
239
+ });
240
+ }
241
+
242
+ export async function inlineMermaidDiagrams(
243
+ md: string,
244
+ theme: MermaidTheme = "light"
245
+ ): Promise<string> {
246
+ const fences = findMermaidFences(md);
247
+ if (fences.length === 0) return md;
248
+ const rendered = await Promise.all(fences.map((f) => renderMermaidSvg(f.source, theme)));
249
+ return inlineRenderedFences(md, rendered);
250
+ }
251
+
252
+ const SVG_OPEN_TAG_RE = /<svg\b[^>]*>/i;
253
+ const VIEWBOX_RE = /\bviewBox\s*=\s*"\s*[\d.+-]+\s+[\d.+-]+\s+([\d.+-]+)\s+([\d.+-]+)\s*"/i;
254
+
255
+ /**
256
+ * Give a mermaid SVG an intrinsic pixel size taken from its viewBox.
257
+ *
258
+ * Mermaid ships its diagrams as `style="max-width: Npx"` with no width/height
259
+ * attribute, which is right for a responsive page and wrong for an export: a
260
+ * standalone .svg file then has no intrinsic size, and the PNG/PDF renderer
261
+ * lays it out at the full body width no matter how small the diagram is.
262
+ * Sizing it here is what lets the share page shrink-wrap the figure.
263
+ *
264
+ * Returns the input unchanged when there is no parseable viewBox — an
265
+ * un-sized export beats a corrupted one.
266
+ */
267
+ export function sizeSvgForExport(svg: string, maxWidth = 1200): string {
268
+ const open = SVG_OPEN_TAG_RE.exec(svg);
269
+ if (!open) return svg;
270
+ const tag = open[0];
271
+ const vb = VIEWBOX_RE.exec(tag);
272
+ if (!vb) return svg;
273
+ const vbW = Number(vb[1]);
274
+ const vbH = Number(vb[2]);
275
+ if (!Number.isFinite(vbW) || !Number.isFinite(vbH) || vbW <= 0 || vbH <= 0) return svg;
276
+ const width = Math.max(1, Math.min(maxWidth, Math.round(vbW)));
277
+ const height = Math.max(1, Math.round((width / vbW) * vbH));
278
+ const sized = tag
279
+ .replace(/\s(?:width|height|style)\s*=\s*"[^"]*"/gi, "")
280
+ .replace(/<svg\b/i, `<svg width="${width}" height="${height}"`);
281
+ return svg.slice(0, open.index) + sized + svg.slice(open.index + tag.length);
282
+ }
283
+
284
+ /** Whether a repo path is a standalone mermaid source file (previewable as a diagram). */
285
+ export function isMermaidPath(path: string): boolean {
286
+ return /\.(mmd|mermaid)$/i.test(path);
287
+ }
@@ -7,9 +7,19 @@ import type {
7
7
  } from "@schlessera/brain-ui-sdk/protocol";
8
8
  import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
9
9
  import { API_BASE } from "../lib/backend.js";
10
+ import { isMermaidPath } from "../lib/mermaid.js";
10
11
 
11
12
  export type ViewMode = "preview" | "raw";
12
13
 
14
+ /** Kinds/paths the viewer can render as a preview (vs raw text only). */
15
+ function hasPreview(content: FileContentResponse): boolean {
16
+ return (
17
+ content.kind === "markdown" ||
18
+ content.kind === "html" ||
19
+ (content.kind === "text" && isMermaidPath(content.path))
20
+ );
21
+ }
22
+
13
23
  const FRONTMATTER_COLLAPSED_KEY = "brain-ui:frontmatter-collapsed";
14
24
 
15
25
  function readFrontmatterCollapsed(): boolean {
@@ -204,9 +214,9 @@ export const useFileStore = create<FileState>((set, get) => ({
204
214
  set({ currentContent: content, contentLoading: false });
205
215
  // Default mode: prefer preview when available; persist otherwise
206
216
  const { viewMode } = get();
207
- if (content.kind !== "markdown" && content.kind !== "html" && viewMode === "preview") {
217
+ if (!hasPreview(content) && viewMode === "preview") {
208
218
  set({ viewMode: "raw" });
209
- } else if ((content.kind === "markdown" || content.kind === "html") && viewMode === "raw") {
219
+ } else if (hasPreview(content) && viewMode === "raw") {
210
220
  // Keep raw if user toggled — but on a fresh open default back to preview
211
221
  set({ viewMode: "preview" });
212
222
  }
package/src/theme.css CHANGED
@@ -316,6 +316,16 @@
316
316
  font-size: inherit;
317
317
  }
318
318
 
319
+ /* Mermaid diagrams (rendered from ```mermaid fences) */
320
+ .brain-prose .mermaid-figure {
321
+ margin: 0.75rem 0;
322
+ text-align: center;
323
+ }
324
+ .brain-prose .mermaid-figure svg {
325
+ max-width: 100%;
326
+ height: auto;
327
+ }
328
+
319
329
  /* Blockquotes */
320
330
  .brain-prose blockquote {
321
331
  border-left: 3px solid #e09f3e;