@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,168 @@
1
+ import type React from 'react';
2
+ import type { Components } from 'react-markdown';
3
+
4
+ /**
5
+ * Declarative rule for handling a custom URL scheme inside markdown.
6
+ *
7
+ * Each rule owns one href shape (e.g. `cmdop://machine/<uuid>`,
8
+ * `obsidian://open?path=…`) and decides:
9
+ *
10
+ * 1. Whether the URL scheme survives sanitize (`protocols`).
11
+ * 2. How to massage the source markdown before render
12
+ * (`preprocess` — optional; useful for stripping decorative
13
+ * prefixes like the `@` in `@[label](href)` so the chip alone
14
+ * reads as the mention indicator).
15
+ * 3. Which links it owns (`match`), and how to render them
16
+ * (`render`, returning JSX).
17
+ *
18
+ * Rules compose: pass an array, and the renderer iterates top-to-bottom
19
+ * picking the first `match` hit per `<a>`. Anything no rule claims
20
+ * falls through to the built-in chat-link styling (or to a custom
21
+ * `customComponents.a` if both are provided).
22
+ *
23
+ * Prefer `linkRules` over `customComponents` + `extraHrefProtocols` —
24
+ * it's the same pieces with the boilerplate removed.
25
+ */
26
+ export interface LinkRule {
27
+ /** Pre-process the raw markdown source before any rendering happens.
28
+ * Pure / synchronous; identity-return when no transform applies. */
29
+ preprocess?: (source: string) => string;
30
+ /** Predicate against the resolved href. */
31
+ match: (href: string) => boolean;
32
+ /** Render the link this rule owns. `children` is the link's label
33
+ * (markdown-rendered React content). */
34
+ render: (props: {
35
+ href: string;
36
+ children: React.ReactNode;
37
+ isUser: boolean;
38
+ }) => React.ReactNode;
39
+ /** URL scheme(s) to whitelist on the sanitize side, e.g. `['cmdop']`.
40
+ * Bare schemes only — no `://`. Optional; omit for `http(s)` rules. */
41
+ protocols?: readonly string[];
42
+ /** Diagnostic label, surfaced in dev warnings. Optional. */
43
+ name?: string;
44
+ }
45
+
46
+ export interface MarkdownMessageProps {
47
+ /** Markdown content to render */
48
+ content: string;
49
+ /** Additional CSS classes */
50
+ className?: string;
51
+ /** Whether the message is from the user (affects styling) */
52
+ isUser?: boolean;
53
+ /**
54
+ * Body text size — drives `<p>/<li>/<table>` etc. and scales
55
+ * headings a notch above the body.
56
+ *
57
+ * - `xs` — text-xs (12px), dense
58
+ * - `sm` — text-sm (14px), default
59
+ * - `base` — text-base (16px), large / `appearance="full"` chats
60
+ *
61
+ * When set, `size` wins over `isCompact`.
62
+ * @default 'sm'
63
+ */
64
+ size?: 'xs' | 'sm' | 'base';
65
+ /**
66
+ * Use compact size (text-xs instead of text-sm).
67
+ * @deprecated Use `size` instead — `isCompact` maps to
68
+ * `size="xs"` (true) / `size="sm"` (false) and is ignored when
69
+ * `size` is set explicitly.
70
+ */
71
+ isCompact?: boolean;
72
+ /**
73
+ * Force the plain-text rendering path (single `<div>` with
74
+ * `white-space: pre-wrap`, no ReactMarkdown). Use this for
75
+ * user-authored content where markdown shouldn't be parsed.
76
+ *
77
+ * When `undefined` (default) we run a small heuristic
78
+ * (`looksLikePlainProse`): short single-paragraph text without
79
+ * markdown markers → plain; everything else → ReactMarkdown.
80
+ *
81
+ * Pass an explicit `true` / `false` to opt out of the heuristic
82
+ * entirely.
83
+ */
84
+ plainText?: boolean;
85
+ /**
86
+ * Per-tag overrides merged on top of the built-in renderers.
87
+ *
88
+ * Use this when you need custom rendering for a specific tag without
89
+ * losing the chat-tuned defaults (links, code blocks with copy, etc).
90
+ *
91
+ * For custom URL-scheme link handling, prefer `linkRules` — it's the
92
+ * same ergonomics with the per-consumer boilerplate removed.
93
+ */
94
+ customComponents?: Partial<Components>;
95
+ /**
96
+ * Extra URL protocols allowed in `<a href>` after sanitize.
97
+ *
98
+ * The default schema strips anything that isn't
99
+ * `http(s)/mailto/xmpp/irc(s)` — your custom renderer would receive
100
+ * `href={undefined}` for `cmdop://…` / `obsidian://…` etc. Listing
101
+ * the bare scheme here (e.g. `'cmdop'`, NOT `'cmdop://'`) opts it in.
102
+ *
103
+ * Use carefully: every protocol you add increases what the rendered
104
+ * HTML can do via clicks. Stick to schemes you control.
105
+ */
106
+ extraHrefProtocols?: readonly string[];
107
+ /**
108
+ * Declarative link rules — preferred over `customComponents` +
109
+ * `extraHrefProtocols` for chat-style apps that want pluggable
110
+ * URL-scheme handling without re-implementing the custom-`a`
111
+ * boilerplate per consumer.
112
+ *
113
+ * If you provide both `linkRules` and `extraHrefProtocols`, the
114
+ * effective protocol list is the union of the two.
115
+ */
116
+ linkRules?: readonly LinkRule[];
117
+ /**
118
+ * Enable collapsible "Read more..." functionality
119
+ * @default false
120
+ */
121
+ collapsible?: boolean;
122
+ /** Maximum character length before showing "Read more...". */
123
+ maxLength?: number;
124
+ /** Maximum number of lines before showing "Read more...". */
125
+ maxLines?: number;
126
+ /** Custom "Read more" button text. @default "Read more..." */
127
+ readMoreLabel?: string;
128
+ /** Custom "Show less" button text. @default "Show less" */
129
+ showLessLabel?: string;
130
+ /** Start expanded (only when `collapsible` is true). @default false */
131
+ defaultExpanded?: boolean;
132
+ /** Callback when collapsed state changes */
133
+ onCollapseChange?: (isCollapsed: boolean) => void;
134
+ /**
135
+ * Live-streaming mode. When `true`, the content is treated as a
136
+ * partial markdown document still receiving tokens:
137
+ *
138
+ * - Completed top-level blocks render as memoized markdown (blocks
139
+ * 0..N-1 don't re-parse as new tokens land → ~O(n), not O(n²)).
140
+ * - The trailing in-flight block is held back as raw `whitespace-pre-wrap`
141
+ * text until it reaches a safe boundary (a newline), so half-written
142
+ * `**bold` / `[link](` don't flash formatted-then-broken.
143
+ * - An unterminated fenced code block renders as an in-progress code
144
+ * block instead of swallowing the rest of the message.
145
+ *
146
+ * Ignored when `plainText` is `true` (plain wins outright) or when
147
+ * `collapsible` is set (collapsing is a post-stream concern). Has no
148
+ * effect on the finished message — pass `false` (the default) once the
149
+ * stream completes to get a single fully-parsed render.
150
+ *
151
+ * @default false
152
+ */
153
+ streaming?: boolean;
154
+ /**
155
+ * Code-fence surface palette.
156
+ *
157
+ * @default 'auto' — follow the host theme, the same resolution
158
+ * `PrettyCode` performs on its own. `'dark'` / `'light'` PIN the palette
159
+ * regardless of theme (e.g. printing on a light page).
160
+ *
161
+ * This defaulted to `'dark'`, which pinned every chat fence dark even in
162
+ * the light theme. `PrettyCode` treats `mode` as an explicit override
163
+ * (`mode ?? resolvedTheme`), so the pin also reached `CodePanelHeader`
164
+ * via `dark={isDarkMode}` — the "typescript" label and copy icon rendered
165
+ * in `text-white/40` on the panel, i.e. near-white on near-white.
166
+ */
167
+ codeTheme?: 'dark' | 'light' | 'auto';
168
+ }
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * CodePanelHeader — a slim, persistent header bar for the code panel
5
+ * (PrettyCode), GitHub / ChatGPT style: the detected language on the left,
6
+ * a copy button on the right. In flow (not an absolute hover overlay), so
7
+ * the copy button never floats over the code.
8
+ *
9
+ * Code panels are usually DARK (like the map basemap is always light), so
10
+ * the header defaults to FIXED on-dark colors — theme tokens
11
+ * (`text-foreground`, `border-border`) would render dark-on-dark / an
12
+ * over-bright divider on the dark panel. Pass `dark={false}` for a
13
+ * light-surfaced panel, which switches to muted semantic tokens.
14
+ */
15
+
16
+ import { useCallback, useState, type ReactNode } from 'react';
17
+
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+ import { Check, Copy, Maximize2 } from 'lucide-react';
20
+
21
+ export interface CodePanelHeaderProps {
22
+ /** Display label on the left (e.g. "typescript", "json", "bash"). */
23
+ language?: ReactNode;
24
+ /** Text copied when the copy button is pressed. Omit to hide copy. */
25
+ copyValue?: string;
26
+ /** a11y label for the copy button. */
27
+ copyLabel?: string;
28
+ /** When set, an expand (⤢) button is shown that calls this — used to open
29
+ * the panel full-screen when it's height-capped. */
30
+ onExpand?: () => void;
31
+ /** a11y / tooltip label for the expand button. */
32
+ expandLabel?: string;
33
+ /** Extra trailing actions, rendered left of the copy button. */
34
+ actions?: ReactNode;
35
+ /** Dark panel (default). `false` → light-surface muted tokens. */
36
+ dark?: boolean;
37
+ className?: string;
38
+ }
39
+
40
+ export function CodePanelHeader({
41
+ language,
42
+ copyValue,
43
+ copyLabel = 'Copy',
44
+ onExpand,
45
+ expandLabel = 'Expand',
46
+ actions,
47
+ dark = true,
48
+ className,
49
+ }: CodePanelHeaderProps) {
50
+ const [copied, setCopied] = useState(false);
51
+
52
+ const onCopy = useCallback(() => {
53
+ if (copyValue == null) return;
54
+ void navigator.clipboard?.writeText(copyValue).then(() => {
55
+ setCopied(true);
56
+ setTimeout(() => setCopied(false), 1400);
57
+ });
58
+ }, [copyValue]);
59
+
60
+ // Fixed on-dark vs semantic light. The hairline is faint so it never
61
+ // reads as a heavy rule across the panel.
62
+ const dividerCls = dark ? 'border-white/[0.06]' : 'border-border/60';
63
+ const labelCls = dark ? 'text-white/40' : 'text-muted-foreground';
64
+ const btnCls = dark
65
+ ? 'text-white/45 hover:bg-white/10 hover:text-white/80 focus-visible:ring-white/40'
66
+ : 'text-muted-foreground hover:bg-foreground/10 hover:text-foreground focus-visible:ring-ring';
67
+
68
+ return (
69
+ <div
70
+ className={cn(
71
+ 'flex items-center justify-between gap-2 border-b px-3 py-1',
72
+ dividerCls,
73
+ className,
74
+ )}
75
+ >
76
+ <span
77
+ className={cn(
78
+ 'min-w-0 truncate font-mono text-[11px] lowercase tracking-wide',
79
+ labelCls,
80
+ )}
81
+ >
82
+ {language}
83
+ </span>
84
+
85
+ <div className="flex shrink-0 items-center gap-0.5">
86
+ {actions}
87
+ {onExpand ? (
88
+ <button
89
+ type="button"
90
+ onClick={onExpand}
91
+ aria-label={expandLabel}
92
+ title={expandLabel}
93
+ className={cn(
94
+ 'inline-flex size-5 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1',
95
+ btnCls,
96
+ )}
97
+ >
98
+ <Maximize2 className="size-3" aria-hidden />
99
+ </button>
100
+ ) : null}
101
+ {copyValue != null ? (
102
+ <button
103
+ type="button"
104
+ onClick={onCopy}
105
+ aria-label={copyLabel}
106
+ className={cn(
107
+ 'inline-flex size-5 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-1',
108
+ btnCls,
109
+ )}
110
+ >
111
+ {copied ? (
112
+ <Check className="size-3" aria-hidden />
113
+ ) : (
114
+ <Copy className="size-3" aria-hidden />
115
+ )}
116
+ </button>
117
+ ) : null}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1 @@
1
+ export { CodePanelHeader, type CodePanelHeaderProps } from './CodePanelHeader';
@@ -0,0 +1,402 @@
1
+ 'use client';
2
+
3
+ import { Highlight, Language, themes } from 'prism-react-renderer';
4
+ import React, { useMemo, useRef } from 'react';
5
+
6
+ import { useAppT } from '@djangocfg/i18n';
7
+ import { useResolvedTheme } from '@djangocfg/ui-core/hooks';
8
+ import { CodePanelHeader } from '../panel-header';
9
+
10
+ // Surface palette — driven by the ui-core `--code*` semantic tokens so the
11
+ // code panel matches the rest of the theme system (neutral, preset-aware)
12
+ // instead of a hard-coded GitHub blue. The tokens are already tuned per
13
+ // theme (`light.css` / `dark.css`): a near-white gray in light, a near-black
14
+ // gray in dark — so the surface stays a calm neutral in either mode and never
15
+ // reads blue. Syntax colors still come from Prism's vsDark/github palettes.
16
+ const CODE_SURFACE_BG = 'var(--code)';
17
+ const CODE_INLINE_BG = 'var(--code-inline)';
18
+
19
+ // Corner radius comes from the ui-core token scale, never a hard-coded
20
+ // Tailwind step: `--radius-popover` is the container tier (the same one
21
+ // nav popovers and menu surfaces use), so the panel re-rounds with the
22
+ // active preset (`dense`, `windows`, …) instead of pinning 0.5rem forever.
23
+ const CODE_SURFACE_RADIUS = 'var(--radius-popover)';
24
+
25
+ // The panel is BORDERLESS: `--code` already separates it from the page in both
26
+ // themes (96% on a 98% light page, 9% on a 14.5% dark one), so a border only
27
+ // added a second boundary around a shape the fill had already drawn — most
28
+ // visible when the panel nests inside another card, where the two frames
29
+ // competed. Contrast does the separating; `--code-border` stays defined in the
30
+ // theme for hosts that genuinely need an outline.
31
+
32
+ // Load extra Prism grammars (``bash``, ``ruby``, ``java``, ``php``)
33
+ // that aren't in ``prism-react-renderer``'s default bundle. The hook
34
+ // below subscribes to its ready state and re-renders this component
35
+ // once loading completes, so the first tab click gets highlighted
36
+ // correctly even if it happened before the dynamic import resolved.
37
+ import { useEnsurePrismLanguages } from './registerPrismLanguages';
38
+
39
+ interface PrettyCodeProps {
40
+ data: string | object;
41
+ language: Language;
42
+ className?: string;
43
+ /**
44
+ * Force a specific Prism palette + surface. **Defaults to the resolved
45
+ * host theme** (light → light surface + GitHub palette, dark → dark
46
+ * surface + vsDark palette), so the panel matches the rest of the UI and
47
+ * its `--code*` tokens. Pass `mode` explicitly only to pin a palette
48
+ * regardless of theme — e.g. printing a PDF on a light page.
49
+ */
50
+ mode?: 'dark' | 'light';
51
+ inline?: boolean;
52
+ customBg?: string; // Custom background class
53
+ isCompact?: boolean; // Compact mode for smaller font sizes
54
+ /** @deprecated No-op since the toolbar moved into a persistent
55
+ * `CodePanelHeader` (no scroll-capturing overlay). Kept for API
56
+ * back-compat; will be removed in a future major. */
57
+ scrollIsolation?: boolean;
58
+ /**
59
+ * Line count at which the viewer starts to scroll instead of growing.
60
+ * ``undefined`` (default) = always grows to fit content, no scroll.
61
+ * Set e.g. ``50`` to cap short snippets inline and scroll long ones.
62
+ */
63
+ maxLines?: number;
64
+ /**
65
+ * When set, an expand (⤢) button appears in the header — but ONLY while
66
+ * the content is actually height-capped (i.e. exceeds `maxLines`). Lets a
67
+ * host open the full code full-screen. No-op when the code fits.
68
+ */
69
+ onExpand?: () => void;
70
+ /**
71
+ * Visual variant. ``"card"`` (default) ships full chrome (border,
72
+ * background, hover toolbar, optional internal scroll). ``"plain"``
73
+ * is chrome-less — use when embedding inside another scroll
74
+ * container so the surface manages its own chrome and scroll. */
75
+ variant?: 'card' | 'plain';
76
+ }
77
+
78
+ const PrettyCode = ({ data, language, className, mode, inline = false, customBg, isCompact = false, maxLines, onExpand, variant = 'card' }: PrettyCodeProps) => {
79
+ const containerRef = useRef<HTMLDivElement>(null);
80
+ const t = useAppT();
81
+
82
+ // Default to the host theme so the surface (`--code*` tokens) and the Prism
83
+ // palette move together; an explicit `mode` prop still pins a palette.
84
+ const resolvedTheme = useResolvedTheme();
85
+ const effectiveMode = mode ?? (resolvedTheme === 'light' ? 'light' : 'dark');
86
+
87
+ // Subscribe to the extra-grammars ready state. When ``bash`` /
88
+ // ``ruby`` / ``java`` / ``php`` finish loading, this hook triggers a
89
+ // re-render so the code block picks up the new grammar.
90
+ useEnsurePrismLanguages();
91
+
92
+ const labels = useMemo(() => ({
93
+ copyCode: t('tools.code.copyCode'),
94
+ noContent: t('tools.code.noContent'),
95
+ }), [t]);
96
+
97
+ // Font size based on compact mode
98
+ const fontSize = isCompact ? '0.75rem' : '0.875rem'; // 12px vs 14px
99
+
100
+ const isDarkMode = effectiveMode !== 'light';
101
+ const prismTheme = isDarkMode ? themes.vsDark : themes.github;
102
+ // The surface now reads the `--code*` tokens in BOTH modes — they already
103
+ // carry the right neutral per theme, so the panel matches the UI and never
104
+ // reads blue. (Previously light mode fell back to `bg-card` and dark mode
105
+ // hard-coded GitHub's #0d1117.)
106
+ const surfaceStyle = { backgroundColor: CODE_SURFACE_BG, borderRadius: CODE_SURFACE_RADIUS };
107
+ const inlineSurfaceStyle = { backgroundColor: CODE_INLINE_BG };
108
+
109
+ // Convert form object to JSON string with proper formatting
110
+ const contentJson = typeof data === 'string' ? data : JSON.stringify(data || {}, null, 2);
111
+
112
+ // Enable scroll only when content exceeds maxLines. Otherwise the block
113
+ // grows to fit — short snippets feel natural, long ones get a cap.
114
+ const lineCount = contentJson ? contentJson.split('\n').length : 0;
115
+ const lineHeightRatio = isCompact ? 1.4 : 1.5;
116
+ const fontSizePx = isCompact ? 12 : 14;
117
+ // Vertical padding of the <pre> (top + bottom, in px) — keep in sync
118
+ // with the padding string below.
119
+ const verticalPadPx = 16 + 12; // 1rem top + 0.75rem bottom (≈)
120
+ const shouldScroll = maxLines !== undefined && lineCount > maxLines;
121
+ const maxHeightPx = maxLines !== undefined
122
+ ? maxLines * fontSizePx * lineHeightRatio + verticalPadPx
123
+ : undefined;
124
+
125
+ // Handle empty content
126
+ if (!contentJson || contentJson.trim() === '') {
127
+ const emptyBgClass = customBg || '';
128
+ return (
129
+ <div
130
+ // Same borderless token-rounded surface as the populated panel — the
131
+ // empty state used `rounded-sm`, so a panel visibly changed shape as
132
+ // soon as content arrived.
133
+ className={`relative h-full rounded-[var(--radius-popover)] ${emptyBgClass} ${className || ''}`}
134
+ style={customBg ? undefined : surfaceStyle}
135
+ >
136
+ <div className="h-full overflow-auto p-4">
137
+ <p
138
+ className="text-sm italic text-muted-foreground"
139
+ role="status"
140
+ >
141
+ {labels.noContent}
142
+ </p>
143
+ </div>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ // Get display name for language badge
149
+ const getLanguageDisplayName = (lang: string): string => {
150
+ switch (lang.toLowerCase()) {
151
+ case 'bash':
152
+ case 'shell':
153
+ return 'Bash';
154
+ case 'python':
155
+ case 'py':
156
+ return 'Python';
157
+ case 'javascript':
158
+ case 'js':
159
+ return 'JavaScript';
160
+ case 'typescript':
161
+ case 'ts':
162
+ return 'TypeScript';
163
+ case 'json':
164
+ return 'JSON';
165
+ case 'yaml':
166
+ case 'yml':
167
+ return 'YAML';
168
+ case 'html':
169
+ return 'HTML';
170
+ case 'css':
171
+ return 'CSS';
172
+ case 'sql':
173
+ return 'SQL';
174
+ case 'xml':
175
+ return 'XML';
176
+ case 'markdown':
177
+ case 'md':
178
+ return 'Markdown';
179
+ case 'plaintext':
180
+ case 'text':
181
+ return 'Text';
182
+ case 'mermaid':
183
+ return 'Mermaid';
184
+ case 'ruby':
185
+ case 'rb':
186
+ return 'Ruby';
187
+ case 'java':
188
+ return 'Java';
189
+ case 'php':
190
+ return 'PHP';
191
+ case 'go':
192
+ case 'golang':
193
+ return 'Go';
194
+ default:
195
+ return lang.charAt(0).toUpperCase() + lang.slice(1);
196
+ }
197
+ };
198
+
199
+ // Normalize language for Prism - use only basic supported languages
200
+ const normalizedLanguage = (() => {
201
+ const lang = language.toLowerCase();
202
+
203
+ // Try basic languages that are definitely supported
204
+ switch (lang) {
205
+ case 'javascript':
206
+ case 'js':
207
+ return 'javascript';
208
+ case 'typescript':
209
+ case 'ts':
210
+ return 'typescript'; // Try TypeScript first
211
+ case 'python':
212
+ case 'py':
213
+ return 'python';
214
+ case 'json':
215
+ return 'json';
216
+ case 'css':
217
+ return 'css';
218
+ case 'html':
219
+ return 'markup';
220
+ case 'xml':
221
+ return 'markup';
222
+ case 'bash':
223
+ case 'shell':
224
+ case 'sh':
225
+ return 'bash';
226
+ case 'ruby':
227
+ case 'rb':
228
+ return 'ruby';
229
+ case 'java':
230
+ return 'java';
231
+ case 'php':
232
+ return 'php';
233
+ case 'go':
234
+ case 'golang':
235
+ return 'go';
236
+ case 'sql':
237
+ return 'sql';
238
+ case 'yaml':
239
+ case 'yml':
240
+ return 'yaml';
241
+ case 'markdown':
242
+ case 'md':
243
+ return 'markdown';
244
+ case 'mermaid':
245
+ return 'text'; // Mermaid is handled separately in MarkdownMessage
246
+ default:
247
+ // For unknown languages, try to use the original name first
248
+ // If it doesn't work, Prism will fallback to plain text
249
+ return lang || 'text';
250
+ }
251
+ })();
252
+
253
+ const displayLanguage = getLanguageDisplayName(language);
254
+
255
+ // Plain variant — chrome-less render for embedding inside other
256
+ // scroll containers. No border, no background, no hover toolbar, no
257
+ // internal scroll. The caller's ScrollArea/panel owns the chrome
258
+ // and scroll responsibilities.
259
+ if (variant === 'plain') {
260
+ return (
261
+ <Highlight theme={prismTheme} code={contentJson} language={normalizedLanguage as Language}>
262
+ {({ className: prismClassName, style, tokens, getLineProps, getTokenProps }) => {
263
+ const { backgroundColor: _bg, ...restStyle } = style;
264
+ return (
265
+ <pre
266
+ className={`${prismClassName} ${className || ''}`}
267
+ style={{
268
+ ...restStyle,
269
+ background: 'transparent',
270
+ margin: 0,
271
+ padding: isCompact ? '0.75rem 1rem' : '1rem',
272
+ fontSize,
273
+ lineHeight: lineHeightRatio,
274
+ fontFamily: 'monospace',
275
+ // ``break-all`` (not ``break-word``) so long unbroken
276
+ // strings without whitespace — typical of escaped
277
+ // JSON bodies in generated code samples — wrap at any
278
+ // character rather than overflowing the container.
279
+ whiteSpace: 'pre-wrap',
280
+ wordBreak: 'break-all',
281
+ overflowWrap: 'anywhere',
282
+ // Hard cap on width — parents (grid cells, panels)
283
+ // should constrain us but some callers render inside
284
+ // ``flex: 1 1 auto`` which lets the pre grow past
285
+ // intended bounds. ``max-width: 100%`` pins to parent.
286
+ maxWidth: '100%',
287
+ }}
288
+ >
289
+ {tokens.map((line, i) => (
290
+ <div key={i} {...getLineProps({ line })}>
291
+ {line.map((token, key) => (
292
+ <span key={key} {...getTokenProps({ token })} />
293
+ ))}
294
+ </div>
295
+ ))}
296
+ </pre>
297
+ );
298
+ }}
299
+ </Highlight>
300
+ );
301
+ }
302
+
303
+ if (inline) {
304
+ // Inline chip surface also comes from `--code-inline` via inlineSurfaceStyle.
305
+ const inlineBgClass = customBg || '';
306
+ return (
307
+ <Highlight theme={prismTheme} code={contentJson} language={normalizedLanguage as Language}>
308
+ {({ className: prismClassName, style, tokens, getTokenProps }) => (
309
+ <code
310
+ // `--radius-sm`, not the panel's container radius: an inline chip
311
+ // sits inside a line of prose, so it rounds like a small control
312
+ // rather than like a card.
313
+ className={`${prismClassName} ${inlineBgClass} px-2 py-1 rounded-[var(--radius-sm)] ${isCompact ? 'text-xs' : 'text-sm'} font-mono inline-block ${className || ''}`}
314
+ style={{
315
+ ...style,
316
+ ...(customBg ? undefined : inlineSurfaceStyle),
317
+ fontSize,
318
+ fontFamily: 'monospace',
319
+ }}
320
+ >
321
+ {tokens.map((line, i) =>
322
+ line.map((token, key) => (
323
+ <span key={`${i}-${key}`} {...getTokenProps({ token })} />
324
+ )),
325
+ )}
326
+ </code>
327
+ )}
328
+ </Highlight>
329
+ );
330
+ }
331
+
332
+ // Surface comes from the `--code*` tokens via `surfaceStyle` (both modes),
333
+ // unless the host pins its own `customBg` class.
334
+ const bgClass = customBg || '';
335
+
336
+ return (
337
+ <div
338
+ ref={containerRef}
339
+ // Radius lives in the CLASS, not in `surfaceStyle`: a host passing
340
+ // `customBg` drops the inline style entirely (see below), and the panel
341
+ // must stay rounded either way. Borderless by design — see the note on
342
+ // CODE_SURFACE_RADIUS.
343
+ className={`group relative overflow-hidden rounded-[var(--radius-popover)] ${bgClass} ${className || ''}`}
344
+ style={customBg ? undefined : surfaceStyle}
345
+ >
346
+ {/* Persistent header bar (GitHub / ChatGPT style): language label
347
+ left, copy button right, in flow — never floats over the code. */}
348
+ <CodePanelHeader
349
+ language={displayLanguage}
350
+ copyValue={contentJson}
351
+ copyLabel={labels.copyCode}
352
+ dark={isDarkMode}
353
+ // Expand only matters when the code is actually height-capped.
354
+ onExpand={onExpand && shouldScroll ? onExpand : undefined}
355
+ />
356
+
357
+ <div
358
+ className={shouldScroll ? 'h-full overflow-auto' : ''}
359
+ style={shouldScroll ? { maxHeight: maxHeightPx } : undefined}
360
+ // When the viewer scrolls internally, make it keyboard-reachable
361
+ // so users without a wheel/trackpad can scroll with arrow keys.
362
+ {...(shouldScroll
363
+ ? { tabIndex: 0, role: 'region', 'aria-label': displayLanguage }
364
+ : {})}
365
+ >
366
+ <Highlight theme={prismTheme} code={contentJson} language={normalizedLanguage as Language}>
367
+ {({ className: prismClassName, style, tokens, getLineProps, getTokenProps }) => {
368
+ // Remove background from Prism theme - we use our own via CSS
369
+ const { backgroundColor: _bg, ...restStyle } = style;
370
+ return (
371
+ <pre
372
+ className={prismClassName}
373
+ style={{
374
+ ...restStyle,
375
+ margin: 0,
376
+ // The header now occupies the top; the code body just needs
377
+ // even padding (no extra top room for an overlay anymore).
378
+ padding: '0.875rem 1rem',
379
+ fontSize,
380
+ lineHeight: lineHeightRatio,
381
+ fontFamily: 'monospace',
382
+ whiteSpace: 'pre-wrap',
383
+ wordBreak: 'break-word',
384
+ overflowWrap: 'break-word',
385
+ }}
386
+ >
387
+ {tokens.map((line, i) => (
388
+ <div key={i} {...getLineProps({ line })}>
389
+ {line.map((token, key) => (
390
+ <span key={key} {...getTokenProps({ token })} />
391
+ ))}
392
+ </div>
393
+ ))}
394
+ </pre>
395
+ )}}
396
+ </Highlight>
397
+ </div>
398
+ </div>
399
+ );
400
+ };
401
+
402
+ export default PrettyCode;