@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- 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;
|