@djangocfg/widget-media 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 +58 -0
- package/package.json +81 -0
- package/src/code/code.tsx +116 -0
- package/src/code/index.ts +10 -0
- package/src/diagram/diagram-source.tsx +29 -0
- package/src/diagram/diagram.tsx +64 -0
- package/src/diagram/index.ts +22 -0
- package/src/diagram/lazy.tsx +45 -0
- package/src/diagram/plugin.ts +62 -0
- package/src/diagram/render.ts +328 -0
- package/src/i18n/index.ts +60 -0
- package/src/image-viewer/components/ImageInfo.tsx +55 -0
- package/src/image-viewer/components/ImageToolbar.tsx +167 -0
- package/src/image-viewer/components/ImageViewer.tsx +508 -0
- package/src/image-viewer/components/LightboxChrome.tsx +157 -0
- package/src/image-viewer/components/MiddleEllipsis.tsx +46 -0
- package/src/image-viewer/components/index.ts +9 -0
- package/src/image-viewer/hooks/index.ts +12 -0
- package/src/image-viewer/hooks/useIdleChrome.ts +73 -0
- package/src/image-viewer/hooks/useImageLoading.ts +220 -0
- package/src/image-viewer/hooks/useImageTransform.ts +102 -0
- package/src/image-viewer/index.ts +65 -0
- package/src/image-viewer/lazy.tsx +33 -0
- package/src/image-viewer/types.ts +139 -0
- package/src/image-viewer/utils/constants.ts +90 -0
- package/src/image-viewer/utils/debug.ts +14 -0
- package/src/image-viewer/utils/index.ts +22 -0
- package/src/image-viewer/utils/lqip.ts +47 -0
- package/src/index.ts +10 -0
- package/src/internal/media-cache.ts +537 -0
- package/src/markdown/index.ts +6 -0
- package/src/markdown/markdown.tsx +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Reforms.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @djangocfg/widget-media
|
|
2
|
+
|
|
3
|
+
Media viewers for a conversation or a file preview: a zoom/pan image lightbox,
|
|
4
|
+
mermaid diagrams, markdown for a reader, and read-only code listings.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @djangocfg/widget-media @djangocfg/ui-core streamdown
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Exports
|
|
13
|
+
|
|
14
|
+
**Each viewer is its own subpath, and that is the contract.** The root barrel
|
|
15
|
+
carries the image viewer only; the other three pull a parser or a highlighter,
|
|
16
|
+
and a host mounting a lightbox must not pay for any of them.
|
|
17
|
+
|
|
18
|
+
| Subpath | What it is | What it costs |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `.` | `ImageViewer`, `LazyImageViewer`, its hooks, types and constants | `react-zoom-pan-pinch` |
|
|
21
|
+
| `./diagram` | `LazyDiagram`, `DiagramSource`, `diagramPlugin` | mermaid (~2.5 MB) — **always deferred** |
|
|
22
|
+
| `./markdown` | `Markdown` — for a reader, outside any conversation | streamdown |
|
|
23
|
+
| `./code` | `Code` — a read-only listing | streamdown + shiki |
|
|
24
|
+
| `./i18n` | `MediaCopy`, `DEFAULT_MEDIA_COPY` | — |
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { ImageViewer } from '@djangocfg/widget-media';
|
|
28
|
+
import { LazyDiagram } from '@djangocfg/widget-media/diagram';
|
|
29
|
+
|
|
30
|
+
<ImageViewer images={[{ name: 'chart.png', src }]} lightbox />
|
|
31
|
+
<LazyDiagram chart={source} streaming={isStreaming} />
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Copy
|
|
35
|
+
|
|
36
|
+
The package owns its copy, typed. Pass `copy` to override any subset; keys you
|
|
37
|
+
omit fall back to English, so translating one label does not blank the rest.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
<ImageViewer images={images} copy={{ zoomIn: 'Приблизить', close: 'Закрыть' }} />
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`MediaCopy` is the schema of record — a misspelled key is a `tsc` error, not
|
|
44
|
+
`undefined` rendered into a toolbar button.
|
|
45
|
+
|
|
46
|
+
## Image viewer
|
|
47
|
+
|
|
48
|
+
Zoom (wheel + presets 25/50/100/200/400%), pan by drag, rotate 90°, flip H/V,
|
|
49
|
+
fullscreen dialog, keyboard shortcuts (`+`/`-`, `0` reset, `r` rotate),
|
|
50
|
+
progressive loading with LQIP, checkerboard transparency ground, and cached
|
|
51
|
+
dimension display. `LazyImageViewer` ships from the same entry, so choosing it
|
|
52
|
+
costs no extra subpath.
|
|
53
|
+
|
|
54
|
+
## Verify
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pnpm --filter @djangocfg/widget-media check-types test
|
|
58
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@djangocfg/widget-media",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Media viewers for a conversation or a file preview: zoom/pan image lightbox, mermaid diagrams, markdown and code listings",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"image-viewer",
|
|
7
|
+
"lightbox",
|
|
8
|
+
"mermaid",
|
|
9
|
+
"markdown",
|
|
10
|
+
"streamdown",
|
|
11
|
+
"react",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "DjangoCFG",
|
|
16
|
+
"url": "https://djangocfg.com"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://djangocfg.com",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/markolofsen/django-cfg.git",
|
|
22
|
+
"directory": "widgets/media"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./src/index.ts",
|
|
27
|
+
"types": "./src/index.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/index.ts",
|
|
30
|
+
"./image-viewer": "./src/image-viewer/index.ts",
|
|
31
|
+
"./diagram": "./src/diagram/index.ts",
|
|
32
|
+
"./markdown": "./src/markdown/index.ts",
|
|
33
|
+
"./code": "./src/code/index.ts",
|
|
34
|
+
"./i18n": "./src/i18n/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"!src/**/*.test.ts",
|
|
39
|
+
"!src/**/*.test.tsx",
|
|
40
|
+
"!src/**/*.stories.tsx",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"check": "tsc --noEmit",
|
|
46
|
+
"check-types": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@streamdown/code": "^1.1.1",
|
|
52
|
+
"beautiful-mermaid": "^1.1.3",
|
|
53
|
+
"react-zoom-pan-pinch": "^3.7.0",
|
|
54
|
+
"rehype-harden": "^1.1.8",
|
|
55
|
+
"zustand": "^5.0.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@djangocfg/ui-core": "^2.1.542",
|
|
59
|
+
"lucide-react": "^0.545.0",
|
|
60
|
+
"react": "^19.0.0",
|
|
61
|
+
"streamdown": "^2.6.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@djangocfg/typescript-config": "^2.1.542",
|
|
65
|
+
"@djangocfg/ui-core": "^2.1.542",
|
|
66
|
+
"@storybook/react-vite": "^10.5.0",
|
|
67
|
+
"@types/node": "^25.9.5",
|
|
68
|
+
"@types/react": "19.2.15",
|
|
69
|
+
"@types/react-dom": "19.2.3",
|
|
70
|
+
"lucide-react": "0.545.0",
|
|
71
|
+
"react": "19.2.7",
|
|
72
|
+
"react-dom": "19.2.7",
|
|
73
|
+
"storybook": "^10.5.0",
|
|
74
|
+
"streamdown": "2.6.0",
|
|
75
|
+
"typescript": "^5.9.3",
|
|
76
|
+
"vitest": "^4.1.10"
|
|
77
|
+
},
|
|
78
|
+
"publishConfig": {
|
|
79
|
+
"access": "public"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { code } from '@streamdown/code';
|
|
4
|
+
import { memo } from 'react';
|
|
5
|
+
import { Streamdown } from 'streamdown';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A read-only code listing, outside any conversation.
|
|
9
|
+
*
|
|
10
|
+
* Rendered by feeding a fenced block to the same parser the rest of this
|
|
11
|
+
* package uses, rather than by calling a highlighter directly. That is the
|
|
12
|
+
* point: the transcript, the document preview and the setup wizard then share
|
|
13
|
+
* ONE highlighter, one theme and one set of supported languages, and a listing
|
|
14
|
+
* cannot drift from the code block beside it in a chat message.
|
|
15
|
+
*
|
|
16
|
+
* Replaces the retired `@djangocfg/ui-tools/pretty-code`, which was built on Prism while
|
|
17
|
+
* everything else here is on shiki. Two highlighters in one console means two
|
|
18
|
+
* themes to keep matched and two answers to "is this language supported".
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A language name shiki understands.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately `string` rather than a union imported from the highlighter: the
|
|
25
|
+
* predecessor exported Prism's own `Language` type, so every call site named
|
|
26
|
+
* its supplier in its signature and swapping the supplier became a
|
|
27
|
+
* type-level breaking change. An unknown name degrades to an unhighlighted
|
|
28
|
+
* listing, which is the same outcome a wrong union member would have produced.
|
|
29
|
+
*/
|
|
30
|
+
export type CodeLanguage = string;
|
|
31
|
+
|
|
32
|
+
export type CodeVariant = 'card' | 'plain';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Listing scale.
|
|
36
|
+
*
|
|
37
|
+
* `sm` (12px) is the wizard's density, where a snippet sits inside a step card
|
|
38
|
+
* beside prose; `base` (14px) is a standalone listing. The predecessor spelled
|
|
39
|
+
* this `isCompact`, a boolean that could only ever say "not the default" —
|
|
40
|
+
* naming both ends means a third density is a value rather than a second flag.
|
|
41
|
+
*/
|
|
42
|
+
export type CodeSize = 'sm' | 'base';
|
|
43
|
+
|
|
44
|
+
const SIZE_CLASS: Record<CodeSize, string> = {
|
|
45
|
+
sm: 'text-xs',
|
|
46
|
+
base: 'text-sm',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface CodeProps {
|
|
50
|
+
/** The source. An object is JSON-serialised, as the predecessor did. */
|
|
51
|
+
readonly data: string | object;
|
|
52
|
+
readonly language: CodeLanguage;
|
|
53
|
+
/**
|
|
54
|
+
* `card` — its own surface: border, ground, copy control.
|
|
55
|
+
* `plain` — no chrome, grows to fit. For embedding inside a container that
|
|
56
|
+
* already scrolls, where a second scroll surface traps the wheel.
|
|
57
|
+
*/
|
|
58
|
+
readonly variant?: CodeVariant;
|
|
59
|
+
readonly size?: CodeSize;
|
|
60
|
+
readonly className?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PLUGINS = { code };
|
|
64
|
+
|
|
65
|
+
/** Fences the source. A fence inside the source would end the block early. */
|
|
66
|
+
function fence(source: string, language: string): string {
|
|
67
|
+
const ticks = '`'.repeat(Math.max(3, longestTickRun(source) + 1));
|
|
68
|
+
return `${ticks}${language}\n${source}\n${ticks}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function longestTickRun(source: string): number {
|
|
72
|
+
let longest = 0;
|
|
73
|
+
let current = 0;
|
|
74
|
+
for (const character of source) {
|
|
75
|
+
current = character === '`' ? current + 1 : 0;
|
|
76
|
+
if (current > longest) longest = current;
|
|
77
|
+
}
|
|
78
|
+
return longest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const Code = memo(
|
|
82
|
+
({ data, language, variant = 'card', size = 'base', className }: CodeProps) => {
|
|
83
|
+
const source = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={`djc-code djc-code--${variant} ${SIZE_CLASS[size]} ${className ?? ''}`}
|
|
87
|
+
data-slot="code"
|
|
88
|
+
data-variant={variant}
|
|
89
|
+
data-size={size}
|
|
90
|
+
>
|
|
91
|
+
<Streamdown
|
|
92
|
+
mode="static"
|
|
93
|
+
// The source is complete, so the incomplete-markdown completion a
|
|
94
|
+
// chat needs would only be guessing at text that will not change.
|
|
95
|
+
parseIncompleteMarkdown={false}
|
|
96
|
+
plugins={PLUGINS}
|
|
97
|
+
// `plain` embeds in a container that already scrolls; a height cap
|
|
98
|
+
// here would add the second scroll surface the variant exists to
|
|
99
|
+
// avoid.
|
|
100
|
+
codeBlockMaxHeight={variant === 'plain' ? Infinity : 400}
|
|
101
|
+
controls={{ code: variant === 'card' }}
|
|
102
|
+
>
|
|
103
|
+
{fence(source, language)}
|
|
104
|
+
</Streamdown>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
(prev, next) =>
|
|
109
|
+
prev.data === next.data &&
|
|
110
|
+
prev.language === next.language &&
|
|
111
|
+
prev.variant === next.variant &&
|
|
112
|
+
prev.size === next.size &&
|
|
113
|
+
prev.className === next.className,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
Code.displayName = 'Code';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A read-only code listing, rendered through the same parser and highlighter
|
|
2
|
+
// as every other surface here — see `code.tsx` for why that matters.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
Code,
|
|
6
|
+
type CodeProps,
|
|
7
|
+
type CodeLanguage,
|
|
8
|
+
type CodeVariant,
|
|
9
|
+
type CodeSize,
|
|
10
|
+
} from './code';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { MermaidErrorComponentProps } from 'streamdown';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a fenced diagram shows when it cannot be drawn.
|
|
7
|
+
*
|
|
8
|
+
* Replaces streamdown's default, which paints red-on-red error text with the
|
|
9
|
+
* source folded into a `<details>`. Two reasons that default is wrong here.
|
|
10
|
+
*
|
|
11
|
+
* The source IS the answer. An agent that drew a diagram type our parser does
|
|
12
|
+
* not cover still said something; an error box says only that we failed, and
|
|
13
|
+
* hides what was said behind a disclosure.
|
|
14
|
+
*
|
|
15
|
+
* And most rejections are not errors at all. A diagram mid-stream is rejected
|
|
16
|
+
* on every token until its shape closes, so the default would flash a red box
|
|
17
|
+
* through the whole of a streamed diagram and then replace it with a picture.
|
|
18
|
+
*
|
|
19
|
+
* `retry` is deliberately unused: nothing here is transient. Re-running the
|
|
20
|
+
* same parser over the same source yields the same verdict, and when the source
|
|
21
|
+
* GROWS streamdown re-renders on its own.
|
|
22
|
+
*/
|
|
23
|
+
export function DiagramSource({ chart }: MermaidErrorComponentProps) {
|
|
24
|
+
return (
|
|
25
|
+
<pre className="chat-diagram-source" data-slot="diagram-source">
|
|
26
|
+
{chart.trim()}
|
|
27
|
+
</pre>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useMediaCopy, type MediaCopy } from '../i18n';
|
|
6
|
+
import { isDiagramComplete, renderDiagram } from './render';
|
|
7
|
+
|
|
8
|
+
export interface DiagramProps {
|
|
9
|
+
/** Mermaid source, possibly still arriving. */
|
|
10
|
+
readonly chart: string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the source is still being written.
|
|
13
|
+
*
|
|
14
|
+
* Supplied by the caller because only it knows: a fenced block knows its
|
|
15
|
+
* fence is open, a `show_media` block is finished the moment it exists. The
|
|
16
|
+
* shape of the source cannot answer this on its own — see `isDiagramComplete`,
|
|
17
|
+
* which is the second, weaker check applied underneath.
|
|
18
|
+
*/
|
|
19
|
+
readonly streaming?: boolean;
|
|
20
|
+
readonly className?: string;
|
|
21
|
+
/** Override the widget's English copy; omitted keys fall back to defaults. */
|
|
22
|
+
readonly copy?: Partial<MediaCopy>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A mermaid diagram, or its source when that is the honest answer.
|
|
27
|
+
*
|
|
28
|
+
* Both fallbacks show the SOURCE rather than an error box, because the source
|
|
29
|
+
* is what the agent actually said. A diagram that cannot be drawn is still an
|
|
30
|
+
* answer; an error box is not.
|
|
31
|
+
*/
|
|
32
|
+
export function Diagram({ chart, streaming = false, className, copy }: DiagramProps) {
|
|
33
|
+
const copyText = useMediaCopy(copy);
|
|
34
|
+
|
|
35
|
+
// Rendering is the expensive step and `chart` changes on every streamed
|
|
36
|
+
// token, so the guard is inside the memo: a diagram still being written must
|
|
37
|
+
// not be parsed at all, not merely have its output discarded.
|
|
38
|
+
const result = useMemo(() => {
|
|
39
|
+
if (streaming || !isDiagramComplete(chart)) return null;
|
|
40
|
+
return renderDiagram(chart);
|
|
41
|
+
}, [chart, streaming]);
|
|
42
|
+
|
|
43
|
+
if (result === null || !result.ok) {
|
|
44
|
+
return (
|
|
45
|
+
<pre className={`chat-diagram-source ${className ?? ''}`} data-slot="diagram-source">
|
|
46
|
+
{chart.trim()}
|
|
47
|
+
</pre>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={`chat-diagram ${className ?? ''}`}
|
|
54
|
+
data-slot="diagram"
|
|
55
|
+
// The parser escapes label text into `data-*` attributes, so a model
|
|
56
|
+
// cannot get markup out of a label and into the document. That is a
|
|
57
|
+
// security property of a dependency rather than of this file, so it is
|
|
58
|
+
// pinned by `injection.test.ts` instead of trusted.
|
|
59
|
+
dangerouslySetInnerHTML={{ __html: result.svg }}
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label={copyText.diagram}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Mermaid rendering, reached from both rails a diagram arrives on: a fenced
|
|
2
|
+
// block in prose (the streamdown plugin) and a `show_media` block (the
|
|
3
|
+
// component).
|
|
4
|
+
//
|
|
5
|
+
// EVERYTHING HERE IS DEFERRED. The parser is ~2.5 MB and most transcripts carry
|
|
6
|
+
// no diagram, so a consumer importing this barrel must not pay for one until a
|
|
7
|
+
// diagram appears — `LazyDiagram` splits it behind Suspense and the plugin
|
|
8
|
+
// behind its own async `render`. Pinned by `cost.test.ts`, which walks this
|
|
9
|
+
// file's static graph.
|
|
10
|
+
//
|
|
11
|
+
// The eager `Diagram` and the pure `render` are deliberately NOT re-exported.
|
|
12
|
+
// A barrel re-export is a static edge, so naming them here would pull the
|
|
13
|
+
// parser into every consumer and quietly undo the split — which is exactly what
|
|
14
|
+
// this file did on its first version. Reach for them by their own paths.
|
|
15
|
+
//
|
|
16
|
+
// Deliberately NOT re-exported from the package root — see `@dev/decisions.md`
|
|
17
|
+
// D4. A host rendering a read-only transcript pays for neither.
|
|
18
|
+
|
|
19
|
+
export type { DiagramProps } from './diagram';
|
|
20
|
+
export { LazyDiagram } from './lazy';
|
|
21
|
+
export { DiagramSource } from './diagram-source';
|
|
22
|
+
export { diagramPlugin, DiagramIncompleteError } from './plugin';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy-loaded Diagram.
|
|
5
|
+
*
|
|
6
|
+
* The renderer carries `beautiful-mermaid` (~2.5 MB), which no transcript needs
|
|
7
|
+
* until a diagram actually appears — and most never carry one. Splitting it out
|
|
8
|
+
* is what keeps the promise that a host rendering a read-only transcript pays
|
|
9
|
+
* for a parser it does not reach.
|
|
10
|
+
*
|
|
11
|
+
* This is the form every CONSUMER should mount. The eager `Diagram` stays
|
|
12
|
+
* exported for a host that has already decided it wants the chunk, and for the
|
|
13
|
+
* stories, where a Suspense boundary would only add a frame to assert around.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { lazy, Suspense } from 'react';
|
|
17
|
+
|
|
18
|
+
import type { DiagramProps } from './diagram';
|
|
19
|
+
|
|
20
|
+
export type { DiagramProps };
|
|
21
|
+
|
|
22
|
+
const DiagramImpl = lazy(() => import('./diagram').then((mod) => ({ default: mod.Diagram })));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The source, while the renderer arrives.
|
|
26
|
+
*
|
|
27
|
+
* The same thing every other fallback here shows, and for the same reason: it
|
|
28
|
+
* is the answer the agent gave. A spinner would replace content that is already
|
|
29
|
+
* in hand with a promise of content.
|
|
30
|
+
*/
|
|
31
|
+
function Fallback({ chart }: { readonly chart: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<pre className="chat-diagram-source" data-slot="diagram-source">
|
|
34
|
+
{chart.trim()}
|
|
35
|
+
</pre>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function LazyDiagram(props: DiagramProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Suspense fallback={<Fallback chart={props.chart} />}>
|
|
42
|
+
<DiagramImpl {...props} />
|
|
43
|
+
</Suspense>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The streamdown `DiagramPlugin` over our synchronous renderer.
|
|
2
|
+
//
|
|
3
|
+
// May import: streamdown (types only), ./render.
|
|
4
|
+
|
|
5
|
+
import type { DiagramPlugin } from 'streamdown';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Thrown when the source is not a diagram yet.
|
|
9
|
+
*
|
|
10
|
+
* A distinct type because the two rejections mean opposite things to a reader:
|
|
11
|
+
* an unsupported diagram is final and its source is the answer, while a partial
|
|
12
|
+
* one is simply not finished. Both fall back to the source, so nothing branches
|
|
13
|
+
* on this today — it exists so the error surface can tell them apart without
|
|
14
|
+
* matching on message text.
|
|
15
|
+
*/
|
|
16
|
+
export class DiagramIncompleteError extends Error {
|
|
17
|
+
constructor() {
|
|
18
|
+
super('diagram source is incomplete');
|
|
19
|
+
this.name = 'DiagramIncompleteError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A mermaid renderer for `Streamdown`'s plugin slot.
|
|
25
|
+
*
|
|
26
|
+
* WHY THE GUARD IS HERE AND NOT IN STREAMDOWN. Streamdown computes whether a
|
|
27
|
+
* fence is still open and forwards it to the code-block branch as
|
|
28
|
+
* `isIncomplete` — but the mermaid branch drops it, and its render effect is
|
|
29
|
+
* keyed on the chart alone. So every streamed token reaches this function, and
|
|
30
|
+
* the guard has to be ours or there is none.
|
|
31
|
+
*
|
|
32
|
+
* That matters because `renderDiagram` does NOT fail on a half-arrived source;
|
|
33
|
+
* it draws a plausible wrong picture. Rejecting is what keeps the source
|
|
34
|
+
* showing until the shape closes.
|
|
35
|
+
*
|
|
36
|
+
* `initialize` is a no-op: the plugin contract mirrors the mermaid runtime,
|
|
37
|
+
* which needs configuring before use. Ours reads the theme from CSS variables
|
|
38
|
+
* baked into the SVG, so there is no state to configure.
|
|
39
|
+
*/
|
|
40
|
+
export const diagramPlugin: DiagramPlugin = {
|
|
41
|
+
language: 'mermaid',
|
|
42
|
+
name: 'mermaid',
|
|
43
|
+
type: 'diagram',
|
|
44
|
+
getMermaid: () => ({
|
|
45
|
+
initialize: () => undefined,
|
|
46
|
+
// The `id` is streamdown's element handle. Ours goes unused: the SVG
|
|
47
|
+
// carries no ids to collide, which is what lets one diagram be rendered
|
|
48
|
+
// twice — inline and fullscreen — with no rewriting.
|
|
49
|
+
render: async (_id: string, source: string) => {
|
|
50
|
+
// Imported here rather than at the top so the parser is fetched by the
|
|
51
|
+
// first diagram, not by the transcript that might never contain one. The
|
|
52
|
+
// contract is already async, so this costs no extra round trip — and it
|
|
53
|
+
// is what keeps the plugin registrable at the package root without
|
|
54
|
+
// dragging 2.5 MB along with it.
|
|
55
|
+
const { isDiagramComplete, renderDiagram } = await import('./render');
|
|
56
|
+
if (!isDiagramComplete(source)) throw new DiagramIncompleteError();
|
|
57
|
+
const result = renderDiagram(source);
|
|
58
|
+
if (!result.ok) throw new Error(result.error);
|
|
59
|
+
return { svg: result.svg };
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
};
|