Files
paseo/packages/app/src/styles/markdown-styles.ts
T
Christoph LeiterandClaude Opus 5 3d69b0f458 fix(app): render markdown verbatim instead of substituting characters (#3253)
markdown-it's typographer option rewrites characters after parsing, so
a literal (c) came out as ©. Agents label steps and options (a), (b),
(c) all the time, and an ordinary list rendering as (a), (b), © stops
your eye every time.

The same option turns ... into an ellipsis, --- into an em dash, and
straight quotes into curly ones. Agent output is text people paste
back into a shell or a file, so a curly quote in --name="my repo"
looks correct on screen and fails when pasted. The markdown file
preview had the same problem, showing characters that are not in the
file on disk.

Every in-app markdown surface now goes through one factory in
utils/markdown-parser.ts, which leaves typographer off: agent chat,
plan cards, pull request comment bodies and the file preview. The plan
card was rendering through its library's built-in parser because it
never passed a markdownit prop, and that default has typographer on.

Link behaviour is unchanged. The assistant parser keeps its file://
permission and no other surface gains it, and linkify stays on for
chat and the default renderer and off for plan cards, matching what
each did before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:27 +00:00

423 lines
12 KiB
TypeScript

import { FONT_SIZE, type Theme } from "./theme";
import { isWeb } from "@/constants/platform";
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
function contentHeadingSize(contentSize: number, tier: keyof typeof FONT_SIZE): number {
return Math.round(contentSize * (FONT_SIZE[tier] / FONT_SIZE.base));
}
function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_SIZE): number {
return Math.round(contentHeadingSize(contentSize, tier) * 1.3);
}
/**
* Creates comprehensive markdown styles for react-native-markdown-display.
*
* Usage:
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
* <Markdown style={markdownStyles} markdownit={parser}>{content}</Markdown>
*
* Always pass `markdownit` from `@/utils/markdown-parser`. Omit it and
* react-native-markdown-display builds its own parser with `typographer: true`,
* which rewrites a literal `(c)` as ©.
*/
export function createMarkdownStyles(theme: Theme) {
return {
// =========================================================================
// BASE STYLES
// =========================================================================
body: {
...webSelectableTextStyle,
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
// Prose line-height scales with the content size, not the
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
lineHeight: Math.round(theme.fontSize.content * 1.4),
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
},
text: {
...webSelectableTextStyle,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
paragraph: {
marginTop: 0,
marginBottom: theme.spacing[3],
flexWrap: "wrap" as const,
flexDirection: "row" as const,
alignItems: "flex-start" as const,
justifyContent: "flex-start" as const,
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
},
// =========================================================================
// HEADINGS
// =========================================================================
heading1: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "4xl"),
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "4xl"),
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading2: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "3xl"),
fontWeight: theme.fontWeight.bold,
color: theme.colors.foreground,
marginTop: theme.spacing[6],
marginBottom: theme.spacing[3],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "3xl"),
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingBottom: theme.spacing[2],
},
heading3: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
},
heading4: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
},
heading5: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
},
heading6: {
...webSelectableTextStyle,
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foregroundMuted,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
// =========================================================================
// TEXT FORMATTING
// =========================================================================
strong: {
...webSelectableTextStyle,
fontWeight: theme.fontWeight.medium,
},
em: {
...webSelectableTextStyle,
fontStyle: "italic" as const,
},
s: {
...webSelectableTextStyle,
textDecorationLine: "line-through" as const,
color: theme.colors.foregroundMuted,
},
link: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
blocklink: {
...webSelectableTextStyle,
color: theme.colors.accentBright,
textDecorationLine: "none" as const,
flexShrink: 1,
minWidth: 0,
overflowWrap: "anywhere" as const,
},
// =========================================================================
// CODE
// =========================================================================
code_inline: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
borderWidth: 0,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
code_block: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[2],
},
fence: {
...webSelectableTextStyle,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[3],
},
pre: {
marginVertical: theme.spacing[2],
},
// =========================================================================
// TABLES
// =========================================================================
table: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[3],
},
thead: {
backgroundColor: theme.colors.surface2,
},
tbody: {},
th: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderBottomWidth: 1,
borderRightWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
textAlign: "left" as const,
},
tr: {
borderBottomWidth: 1,
borderColor: theme.colors.border,
flexDirection: "row" as const,
},
td: {
...webSelectableTextStyle,
padding: theme.spacing[2],
borderRightWidth: 1,
borderColor: theme.colors.border,
color: theme.colors.foreground,
fontSize: theme.fontSize.content,
flex: 1,
},
// =========================================================================
// LISTS
// =========================================================================
bullet_list: {
paddingLeft: 0,
width: "100%" as const,
},
ordered_list: {
paddingLeft: 0,
width: "100%" as const,
},
list_item: {
marginBottom: theme.spacing[1],
flexDirection: "row" as const,
alignItems: "flex-start" as const,
flexShrink: 1,
},
bullet_list_content: {
flex: 1,
flexShrink: 1,
},
ordered_list_content: {
flex: 1,
flexShrink: 1,
},
bullet_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.content,
lineHeight: Math.round(theme.fontSize.content * 1.4),
},
ordered_list_icon: {
...webSelectableTextStyle,
color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.content,
fontWeight: theme.fontWeight.normal,
lineHeight: Math.round(theme.fontSize.content * 1.4),
minWidth: 12,
},
// =========================================================================
// BLOCKQUOTE
// =========================================================================
blockquote: {
backgroundColor: theme.colors.surface1,
color: `${theme.colors.foreground}cc`,
borderLeftWidth: 4,
borderLeftColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[3],
paddingBottom: 0,
marginVertical: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
// =========================================================================
// HORIZONTAL RULE
// =========================================================================
hr: {
backgroundColor: theme.colors.border,
height: 1,
marginVertical: 10,
},
// =========================================================================
// IMAGES
// =========================================================================
image: {
borderRadius: theme.borderRadius.md,
marginVertical: theme.spacing[2],
},
// =========================================================================
// BREAKS
// =========================================================================
hardbreak: {
height: theme.spacing[2],
},
softbreak: {},
};
}
/**
* Creates a smaller variant of markdown styles for compact UI elements
* like thought bubbles, tooltips, or side panels.
*/
export function createCompactMarkdownStyles(theme: Theme) {
const baseStyles = createMarkdownStyles(theme);
return {
...baseStyles,
body: {
...baseStyles.body,
fontSize: theme.fontSize.content,
lineHeight: Math.round(theme.fontSize.content * 1.4),
},
heading1: {
...baseStyles.heading1,
fontSize: contentHeadingSize(theme.fontSize.content, "2xl"),
marginTop: theme.spacing[4],
marginBottom: theme.spacing[2],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "2xl"),
},
heading2: {
...baseStyles.heading2,
fontSize: contentHeadingSize(theme.fontSize.content, "xl"),
marginTop: theme.spacing[3],
marginBottom: theme.spacing[2],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "xl"),
},
heading3: {
...baseStyles.heading3,
fontSize: contentHeadingSize(theme.fontSize.content, "lg"),
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: contentHeadingLineHeight(theme.fontSize.content, "lg"),
},
paragraph: {
...baseStyles.paragraph,
marginBottom: theme.spacing[2],
},
code_inline: {
...baseStyles.code_inline,
fontSize: theme.fontSize.code,
},
code_block: {
...baseStyles.code_block,
fontSize: theme.fontSize.code,
padding: theme.spacing[2],
},
fence: {
...baseStyles.fence,
fontSize: theme.fontSize.code,
padding: theme.spacing[2],
},
};
}