feat(app): syntax highlighting and copy button for chat code blocks (#1062)
Adds Lezer-based highlighting to fenced code blocks in assistant messages, with a hover/tap copy button. Colors move into the theme as `theme.colors.syntax`, so the chat blocks, file preview, and diff viewer share one StyleSheet path — token color updates flow through the Unistyles native ShadowRegistry with no React re-renders on the chat hot path.
This commit is contained in:
@@ -15,13 +15,8 @@ import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightToken,
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -36,8 +31,6 @@ interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
colorMap: Record<HighlightStyle, string>;
|
||||
baseColor: string;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
@@ -99,18 +92,8 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
colorMap,
|
||||
baseColor,
|
||||
}: CodeLineProps) {
|
||||
const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) {
|
||||
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
||||
const gutterTextStyle = useMemo(
|
||||
() => [codeLineStyles.gutterText, { color: baseColor }],
|
||||
[baseColor],
|
||||
);
|
||||
const keyedTokens = useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
@@ -118,17 +101,13 @@ const CodeLine = React.memo(function CodeLine({
|
||||
return (
|
||||
<View style={codeLineStyles.line}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={gutterTextStyle}>
|
||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||
{String(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<CodeLineToken
|
||||
key={key}
|
||||
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
|
||||
text={token.text}
|
||||
/>
|
||||
<CodeLineToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -136,13 +115,11 @@ const CodeLine = React.memo(function CodeLine({
|
||||
});
|
||||
|
||||
interface CodeLineTokenProps {
|
||||
color: string;
|
||||
text: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
function CodeLineToken({ color, text }: CodeLineTokenProps) {
|
||||
const style = useMemo(() => ({ color }), [color]);
|
||||
return <Text style={style}>{text}</Text>;
|
||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
@@ -155,6 +132,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
@@ -178,9 +156,6 @@ function FilePreviewBody({
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||
@@ -258,14 +233,7 @@ function FilePreviewBody({
|
||||
const codeLines = (
|
||||
<View>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine
|
||||
key={key}
|
||||
tokens={tokens}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
colorMap={colorMap}
|
||||
baseColor={baseColor}
|
||||
/>
|
||||
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
code: string;
|
||||
language: string | null | undefined;
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
}
|
||||
|
||||
// Fence info strings ("```ts", "```typescript", "```ts {1,3}") map to the
|
||||
// extension-based parser table in @getpaseo/highlight. Aliases here only
|
||||
// cover names that don't already match an extension key in parsers.ts.
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
typescript: "ts",
|
||||
javascript: "js",
|
||||
python: "py",
|
||||
rust: "rs",
|
||||
golang: "go",
|
||||
"c++": "cpp",
|
||||
objc: "m",
|
||||
"objective-c": "m",
|
||||
markdown: "md",
|
||||
elixir: "ex",
|
||||
};
|
||||
|
||||
function fenceLanguageToExtension(info: string | null | undefined): string | null {
|
||||
if (!info) return null;
|
||||
const first = info.trim().split(/\s+/)[0]?.toLowerCase();
|
||||
if (!first) return null;
|
||||
const normalized = first.replace(/^\./, "");
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||
// theme-independent (colors are applied at render time), so the key is just
|
||||
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||
// transcript, code blocks rarely repeat beyond a handful of distinct shapes.
|
||||
class LRUCache<K, V> {
|
||||
private readonly map = new Map<K, V>();
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.map.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
else if (this.map.size >= this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest !== undefined) this.map.delete(oldest);
|
||||
}
|
||||
this.map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const tokenizationCache = new LRUCache<string, KeyedLine[]>(200);
|
||||
|
||||
export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
code,
|
||||
language,
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
}: HighlightedCodeBlockProps) {
|
||||
// Box styles (bg / padding / border / radius / margin) go on the wrapper View
|
||||
// so the absolute copy button positions relative to the visible code area,
|
||||
// not to a parent that includes the Text's own marginVertical.
|
||||
const { containerStyle, innerTextStyle } = useMemo(
|
||||
() => splitFenceStyle(inheritedStyles, textStyle),
|
||||
[inheritedStyles, textStyle],
|
||||
);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${code}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(code, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [code, language]);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const controlsVisible = isHovered || isNative || isCompact;
|
||||
const getCode = useCallback(() => code, [code]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{keyedLines ? (
|
||||
<Text style={innerTextStyle}>
|
||||
{keyedLines.map((line, lineIndex) => (
|
||||
<React.Fragment key={line.key}>
|
||||
{lineIndex > 0 ? "\n" : null}
|
||||
{line.tokens.map(({ key, token }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={innerTextStyle}>{code}</Text>
|
||||
)}
|
||||
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
interface KeyedLine {
|
||||
key: string;
|
||||
tokens: KeyedToken[];
|
||||
}
|
||||
|
||||
function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
return {
|
||||
key: `line-${lineIndex}`,
|
||||
tokens: tokens.map((token, tokenIndex) => ({
|
||||
key: `${lineIndex}-${tokenIndex}`,
|
||||
token,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenSpanProps {
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
||||
if (!token.style) return token.text;
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
});
|
||||
|
||||
interface SplitStyles {
|
||||
containerStyle: StyleProp<ViewStyle>;
|
||||
innerTextStyle: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const CONTAINER_BASE: ViewStyle = { position: "relative" };
|
||||
const WEB_SELECTABLE: TextStyle = isWeb ? ({ userSelect: "text" } as TextStyle) : {};
|
||||
|
||||
function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): SplitStyles {
|
||||
const { fontFamily, fontSize, color, ...box } = textStyle;
|
||||
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
||||
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
||||
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
||||
if (color !== undefined) textOnly.color = color;
|
||||
return {
|
||||
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
||||
innerTextStyle: [inheritedStyles, textOnly],
|
||||
};
|
||||
}
|
||||
|
||||
interface CopyButtonProps {
|
||||
getCode: () => string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetRef.current) clearTimeout(resetRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(async () => {
|
||||
const content = getCode();
|
||||
if (!content) return;
|
||||
await Clipboard.setStringAsync(content);
|
||||
setCopied(true);
|
||||
if (resetRef.current) clearTimeout(resetRef.current);
|
||||
resetRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
resetRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
}, [getCode]);
|
||||
|
||||
const visibilityStyle = visible
|
||||
? copyButtonStyles.containerVisible
|
||||
: copyButtonStyles.containerHidden;
|
||||
const wrapperStyle = useMemo(
|
||||
() => [copyButtonStyles.container, visibilityStyle],
|
||||
[visibilityStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={wrapperStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copied ? "Copied" : "Copy code"}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const iconColor = hovered
|
||||
? copyButtonStyles.iconHoveredColor.color
|
||||
: copyButtonStyles.iconColor.color;
|
||||
return copied ? (
|
||||
<Check size={14} color={iconColor} />
|
||||
) : (
|
||||
<Copy size={14} color={iconColor} />
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const copyButtonStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[2],
|
||||
right: theme.spacing[2],
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
containerVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
containerHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconHoveredColor: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -74,6 +74,7 @@ import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markd
|
||||
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import {
|
||||
@@ -1650,13 +1651,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
@@ -1665,13 +1666,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
|
||||
@@ -54,11 +54,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "@/components/diff-scroll";
|
||||
import {
|
||||
darkHighlightColors,
|
||||
lightHighlightColors,
|
||||
type HighlightStyle as HighlightStyleKey,
|
||||
} from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||
@@ -136,8 +132,6 @@ function expandAllButtonStyle({
|
||||
return [styles.expandAllButton, (Boolean(hovered) || pressed) && styles.diffStatusRowHovered];
|
||||
}
|
||||
|
||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
wrapLines?: boolean;
|
||||
@@ -157,31 +151,14 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
|
||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||
}
|
||||
|
||||
function HighlightedToken({
|
||||
text,
|
||||
color,
|
||||
lineHeight,
|
||||
}: {
|
||||
text: string;
|
||||
color: string;
|
||||
lineHeight: number;
|
||||
}) {
|
||||
const tokenStyle = useMemo(() => ({ color, lineHeight }), [color, lineHeight]);
|
||||
return <Text style={tokenStyle}>{text}</Text>;
|
||||
function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const lineHeight = theme.lineHeight.diff;
|
||||
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
const colors = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
return colors[style as HighlightStyleKey] ?? baseColor;
|
||||
};
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
|
||||
[lineHeight, wrapLines],
|
||||
@@ -195,12 +172,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
return (
|
||||
<Text style={containerStyle}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<HighlightedToken
|
||||
key={key}
|
||||
text={token.text}
|
||||
color={getTokenColor(token.style)}
|
||||
lineHeight={lineHeight}
|
||||
/>
|
||||
<HighlightedToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
// Per-token color styles for syntax-highlighted code. Each value reads a
|
||||
// theme path so the Unistyles Babel plugin tracks the dependency and updates
|
||||
// native style values on theme changes — no React re-render of consumers.
|
||||
// Used by message code blocks (hot path), file preview, and the diff viewer.
|
||||
export const syntaxTokenStyles = StyleSheet.create((theme) => ({
|
||||
base: { color: theme.colors.foreground },
|
||||
keyword: { color: theme.colors.syntax.keyword },
|
||||
comment: { color: theme.colors.syntax.comment },
|
||||
string: { color: theme.colors.syntax.string },
|
||||
number: { color: theme.colors.syntax.number },
|
||||
literal: { color: theme.colors.syntax.literal },
|
||||
function: { color: theme.colors.syntax.function },
|
||||
definition: { color: theme.colors.syntax.definition },
|
||||
class: { color: theme.colors.syntax.class },
|
||||
type: { color: theme.colors.syntax.type },
|
||||
tag: { color: theme.colors.syntax.tag },
|
||||
attribute: { color: theme.colors.syntax.attribute },
|
||||
property: { color: theme.colors.syntax.property },
|
||||
variable: { color: theme.colors.syntax.variable },
|
||||
operator: { color: theme.colors.syntax.operator },
|
||||
punctuation: { color: theme.colors.syntax.punctuation },
|
||||
regexp: { color: theme.colors.syntax.regexp },
|
||||
escape: { color: theme.colors.syntax.escape },
|
||||
meta: { color: theme.colors.syntax.meta },
|
||||
heading: { color: theme.colors.syntax.heading },
|
||||
link: { color: theme.colors.syntax.link },
|
||||
}));
|
||||
|
||||
type SyntaxTokenStyleValue = (typeof syntaxTokenStyles)["base"];
|
||||
|
||||
// Accepts a plain string so diff tokens (server-typed `string | null`) and
|
||||
// @getpaseo/highlight tokens (typed `HighlightStyle | null`) share one path.
|
||||
// Unknown styles fall back to the base color.
|
||||
export function syntaxTokenStyleFor(style: string | null | undefined): SyntaxTokenStyleValue {
|
||||
if (!style) return syntaxTokenStyles.base;
|
||||
const indexed = syntaxTokenStyles as unknown as Record<string, SyntaxTokenStyleValue>;
|
||||
return indexed[style] ?? syntaxTokenStyles.base;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
|
||||
|
||||
export const baseColors = {
|
||||
// Base colors
|
||||
white: "#ffffff",
|
||||
@@ -518,6 +520,7 @@ function buildDarkTheme(semanticColors: ReturnType<typeof buildDarkSemanticColor
|
||||
colors: {
|
||||
...semanticColors,
|
||||
palette: baseColors,
|
||||
syntax: darkHighlightColors,
|
||||
},
|
||||
shadow: darkShadow,
|
||||
...commonTheme,
|
||||
@@ -535,6 +538,7 @@ export const lightTheme = {
|
||||
colors: {
|
||||
...lightSemanticColors,
|
||||
palette: baseColors,
|
||||
syntax: lightHighlightColors,
|
||||
},
|
||||
shadow: {
|
||||
sm: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// The runtime sets `sourceInfo` on fence/code nodes (tokensToAST.js sets
|
||||
// `sourceInfo: token.info`), but the shipped .d.ts omits the field. The
|
||||
// re-export is what marks this file as a module so the `declare module`
|
||||
// below augments the package instead of shadowing it.
|
||||
export type { ASTNode } from "react-native-markdown-display";
|
||||
|
||||
declare module "react-native-markdown-display" {
|
||||
interface ASTNode {
|
||||
sourceInfo?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user