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