diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeHighlight.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeHighlight.stories.tsx new file mode 100644 index 00000000..876b4989 --- /dev/null +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeHighlight.stories.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { EnrichedMarkdownTextStory } from '../EnrichedMarkdownTextStory'; +import { storyMeta } from '../shared/storyMeta'; +import { githubFlavorArgTypes } from '../shared/storybookMarkdownStyles'; +import { splitStyleControls } from '../shared/storybookStyleBuilders'; +import type { TextStory } from '../shared/storyTypes'; + +const MARKDOWN = [ + '```javascript', + 'const greet = (name) => `hi ${name}`; // arrow fn', + 'export default greet(42);', + '```', + '', + '```typescript', + 'type Pair = { left: T; right: T };', + 'function swap(p: Pair): Pair {', + ' return { left: p.right, right: p.left };', + '}', + '```', + '', + '```python', + 'def fib(n: int) -> int:', + ' return n if n < 2 else fib(n - 1) + fib(n - 2) # recursion', + '```', + '', + '```json', + '{ "id": 7, "tags": ["a", "b"], "active": true }', + '```', + '', + '```go', + 'package main', + 'func main() { println("hello") }', + '```', + '', + '```rust', + 'fn main() { let x: u32 = 3; println!("{x}"); }', + '```', + '', + '```c', + '#include ', + 'int main(void) { return 0; }', + '```', + '', + '```java', + 'record Point(int x, int y) {}', + '```', + '', + '```bash', + 'for f in *.ts; do echo "$f"; done', + '```', + '', + '```css', + '.title { color: #cf222e; font-weight: 600; }', + '```', + '', + '```html', + 'go', + '```', + '', + '```yaml', + 'name: build', + 'on: [push]', + '```', +].join('\n'); + +const BASE_TEXT_COLOR = '#f3f4f6'; +const syntaxColorDefaults = { + keyword: '#cf222e', + operatorColor: BASE_TEXT_COLOR, + punctuation: BASE_TEXT_COLOR, + string: '#0a3069', + number: '#0550ae', + constant: '#0550ae', + comment: '#6e7781', + function: '#8250df', + type: '#953800', + variable: BASE_TEXT_COLOR, + property: '#0550ae', + tag: '#116329', + attribute: '#0550ae', + embedded: BASE_TEXT_COLOR, +}; + +type SyntaxColorControls = typeof syntaxColorDefaults; + +const colorControl = (token: keyof SyntaxColorControls) => ({ + control: 'color' as const, + description: `markdownStyle.codeBlock.syntaxColors.${token}`, +}); + +const argTypes = { + ...githubFlavorArgTypes( + 'commonmark — highlighted spans inside the single TextView. github — highlighted block component.' + ), + keyword: colorControl('keyword'), + operatorColor: colorControl('operatorColor'), + punctuation: colorControl('punctuation'), + string: colorControl('string'), + number: colorControl('number'), + constant: colorControl('constant'), + comment: colorControl('comment'), + function: colorControl('function'), + type: colorControl('type'), + variable: colorControl('variable'), + property: colorControl('property'), + tag: colorControl('tag'), + attribute: colorControl('attribute'), + embedded: colorControl('embedded'), +}; + +export default storyMeta('Block', 'Code Highlight'); + +export const Default: TextStory = { + args: { + markdown: MARKDOWN, + flavor: 'github', + ...syntaxColorDefaults, + }, + argTypes, + render: (args) => { + const { controls, rest } = splitStyleControls(args, syntaxColorDefaults); + return ( + + ); + }, +}; diff --git a/docs/STYLES.md b/docs/STYLES.md index 737f4e1d..4dd35a41 100644 --- a/docs/STYLES.md +++ b/docs/STYLES.md @@ -319,6 +319,28 @@ function App() { | `borderRadius` | `number` | Corner radius | | `borderWidth` | `number` | Border width | | `padding` | `number` | Inner padding | +| `syntaxColors` | `object` | Per-token syntax highlight colors (see below) | + +#### `syntaxColors` + +Per-token foreground colors for syntax highlighting, keyed on the highlight token type. Any key you omit falls back to the default GitHub-light palette; `operatorColor`, `punctuation`, `variable`, and `embedded` default to the code block's base `color` (i.e. no visible recolor). Colors only take visible effect when the optional syntax highlighting module is compiled in; otherwise code blocks render uncolored. + +| Property | Type | Description | +|----------|------|-------------| +| `keyword` | `string` | Keywords (e.g. `if`, `return`) | +| `operatorColor` | `string` | Operators (e.g. `+`, `=>`). Named `operatorColor` because `operator` is reserved in the native layer | +| `punctuation` | `string` | Brackets, delimiters, punctuation | +| `string` | `string` | String and character literals | +| `number` | `string` | Numeric literals | +| `constant` | `string` | Constants and booleans | +| `comment` | `string` | Comments | +| `function` | `string` | Function and method names | +| `type` | `string` | Types and classes | +| `variable` | `string` | Variables and parameters | +| `property` | `string` | Object properties and fields | +| `tag` | `string` | Markup tags | +| `attribute` | `string` | Markup attributes | +| `embedded` | `string` | Embedded/injected language regions | > [!NOTE] > Inside list items, code blocks (background included) indent to the item's content column. diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt index 53c43816..61535ea4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt @@ -7,6 +7,8 @@ import android.text.style.LineHeightSpan import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.CodeBlockSpan import com.swmansion.enriched.markdown.spans.MarginBottomSpan +import com.swmansion.enriched.markdown.utils.common.CodeBlockHighlighter +import com.swmansion.enriched.markdown.utils.common.CodeBlockNode import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE import com.swmansion.enriched.markdown.utils.text.span.applyMarginTop @@ -44,6 +46,15 @@ class CodeBlockRenderer( if (builder.length == contentStart) return val end = builder.length + + CodeBlockHighlighter.highlight( + builder, + CodeBlockNode.extractCode(node), + CodeBlockNode.language(node), + style, + contentStart, + ) + val padding = style.padding.toInt() // Apply background, borders, and horizontal padding to content only diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt index 1832558f..907389bf 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt @@ -15,8 +15,27 @@ data class CodeBlockStyle( val borderRadius: Float, val borderWidth: Float, val padding: Float, + val syntaxColors: List, ) : BaseBlockStyle { companion object { + private val SYNTAX_COLOR_KEYS = + listOf( + "keyword", + "operatorColor", + "punctuation", + "string", + "number", + "constant", + "comment", + "function", + "type", + "variable", + "property", + "tag", + "attribute", + "embedded", + ) + fun fromReadableMap( map: ReadableMap, parser: StyleParser, @@ -35,6 +54,12 @@ data class CodeBlockStyle( val borderWidth = parser.toPixelFromDIP(map.getDouble("borderWidth").toFloat()) val padding = parser.toPixelFromDIP(map.getDouble("padding").toFloat()) + val syntaxMap = map.getMap("syntaxColors") + val syntaxColors = + SYNTAX_COLOR_KEYS.map { key -> + syntaxMap?.let { parser.parseOptionalColor(it, key) } ?: color + } + return CodeBlockStyle( fontSize, fontFamily, @@ -48,6 +73,7 @@ data class CodeBlockStyle( borderRadius, borderWidth, padding, + syntaxColors, ) } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt index abbf2e44..6ef15f6b 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt @@ -1,9 +1,9 @@ package com.swmansion.enriched.markdown.utils.common -import android.graphics.Color import android.text.Spannable import android.text.style.ForegroundColorSpan import android.util.Log +import com.swmansion.enriched.markdown.styles.CodeBlockStyle import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE /** @@ -54,10 +54,19 @@ object CodeBlockHighlighter { language: String, ): IntArray? + /** + * Applies token colors onto [target]. Token offsets are relative to [code]; + * [offset] is where that code begins in [target] (0 for the github container + * view which highlights the code string itself, contentStart for the + * commonmark flavor which renders code inline). A no-op when highlighting is + * unavailable, so the plain rendering is preserved. + */ fun highlight( - plainCode: Spannable, + target: Spannable, code: String, language: String?, + style: CodeBlockStyle, + offset: Int = 0, ) { val tokens = try { @@ -66,32 +75,16 @@ object CodeBlockHighlighter { null } ?: return + val colors = style.syntaxColors var i = 0 while (i + 2 < tokens.size) { - val start = tokens[i] - val end = tokens[i + 1] - val color = HighlightTokenType.entries.getOrNull(tokens[i + 2])?.let(::colorForToken) - if (color != null && start >= 0 && end > start && end <= plainCode.length) { - plainCode.setSpan(ForegroundColorSpan(color), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) + val start = offset + tokens[i] + val end = offset + tokens[i + 1] + val type = HighlightTokenType.entries.getOrNull(tokens[i + 2]) + if (type != null && type.ordinal < colors.size && tokens[i] >= 0 && end > start && end <= target.length) { + target.setSpan(ForegroundColorSpan(colors[type.ordinal]), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE) } i += 3 } } - - // TODO: provisional palette (GitHub light scheme); replace with themable - // per-token colors on CodeBlockStyle when the highlighting module lands. - private fun colorForToken(type: HighlightTokenType): Int? = - when (type) { - HighlightTokenType.Keyword -> Color.parseColor("#CF222E") - HighlightTokenType.String -> Color.parseColor("#0A3069") - HighlightTokenType.Number -> Color.parseColor("#0550AE") - HighlightTokenType.Constant -> Color.parseColor("#0550AE") - HighlightTokenType.Comment -> Color.parseColor("#6E7781") - HighlightTokenType.Function -> Color.parseColor("#8250DF") - HighlightTokenType.Type -> Color.parseColor("#953800") - HighlightTokenType.Property -> Color.parseColor("#0550AE") - HighlightTokenType.Tag -> Color.parseColor("#116329") - HighlightTokenType.Attribute -> Color.parseColor("#0550AE") - else -> null - } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt index aa3e1649..f0a70659 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt @@ -161,7 +161,7 @@ class CodeBlockContainerView( } val plainCode = buildCodeText(code, codeBlockStyle) - CodeBlockHighlighter.highlight(plainCode, code, language) + CodeBlockHighlighter.highlight(plainCode, code, language, codeBlockStyle) textView.text = plainCode } diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m index cb5288dc..36c5dc77 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m @@ -1,6 +1,7 @@ #import "CodeBlockRenderer.h" #import "CodeBlockBackground.h" #import "ENRMCodeBlockContent.h" +#import "ENRMCodeBlockHighlighter.h" #import "LastElementUtils.h" #import "MarkdownASTNode.h" #import "ParagraphStyleUtils.h" @@ -55,6 +56,8 @@ - (void)renderNodeContent:(MarkdownASTNode *)node ENRMApplyCodeBlockTextAttributes(output, contentRange, _config); + ENRMApplyHighlightTokens(output, contentRange, ENRMCodeBlockExtractCode(node), ENRMCodeBlockLanguage(node), _config); + // Horizontal padding is paragraph indentation in this flavor; the shared // helper already forced the LTR left-aligned base style. NSMutableParagraphStyle *baseStyle = [getOrCreateParagraphStyle(output, contentStart) mutableCopy]; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index 1355ebc7..89ff9e9c 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h @@ -316,6 +316,10 @@ NS_ASSUME_NONNULL_BEGIN - (CGFloat)codeBlockPadding; - (void)setCodeBlockPadding:(CGFloat)newValue; - (UIFont *)codeBlockFont; +// Syntax highlight colors, indexed by HighlightTokenType ordinal. Resolved on +// the JS side; the getter returns a cached color with no allocation. +- (RCTUIColor *_Nullable)codeBlockSyntaxColorForToken:(NSInteger)tokenType; +- (void)setCodeBlockSyntaxColor:(RCTUIColor *)newValue forToken:(NSInteger)tokenType; // Thematic break properties - (RCTUIColor *)thematicBreakColor; - (void)setThematicBreakColor:(RCTUIColor *)newValue; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm index 9b9596cd..cabaa087 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm @@ -1,9 +1,13 @@ #import "StyleConfig.h" +#include "CodeBlockHighlighter.hpp" #import "ENRMFontSlot.h" #import "FontUtils.h" #import #import +static const NSInteger kENRMCodeBlockSyntaxColorCount = + static_cast(Markdown::HighlightTokenType::Embedded) + 1; + static inline NSString *normalizedFontWeight(NSString *fontWeight) { // If nil or empty string, return nil to let RCTFont use fontFamily as-is @@ -189,6 +193,7 @@ @implementation StyleConfig { CGFloat _codeBlockBorderRadius; CGFloat _codeBlockBorderWidth; CGFloat _codeBlockPadding; + RCTUIColor *_codeBlockSyntaxColors[kENRMCodeBlockSyntaxColorCount]; ENRMFontSlot *_codeBlockFont; // Thematic break properties RCTUIColor *_thematicBreakColor; @@ -463,6 +468,9 @@ - (id)copyWithZone:(NSZone *)zone copy->_codeBlockBorderRadius = _codeBlockBorderRadius; copy->_codeBlockBorderWidth = _codeBlockBorderWidth; copy->_codeBlockPadding = _codeBlockPadding; + for (NSInteger i = 0; i < kENRMCodeBlockSyntaxColorCount; i++) { + copy->_codeBlockSyntaxColors[i] = [_codeBlockSyntaxColors[i] copy]; + } copy->_thematicBreakColor = [_thematicBreakColor copy]; copy->_thematicBreakHeight = _thematicBreakHeight; copy->_thematicBreakMarginTop = _thematicBreakMarginTop; @@ -2110,6 +2118,22 @@ - (UIFont *)codeBlockFont return _codeBlockFont.cachedFont; } +- (RCTUIColor *)codeBlockSyntaxColorForToken:(NSInteger)tokenType +{ + if (tokenType < 0 || tokenType >= kENRMCodeBlockSyntaxColorCount) { + return nil; + } + return _codeBlockSyntaxColors[tokenType]; +} + +- (void)setCodeBlockSyntaxColor:(RCTUIColor *)newValue forToken:(NSInteger)tokenType +{ + if (tokenType < 0 || tokenType >= kENRMCodeBlockSyntaxColorCount) { + return; + } + _codeBlockSyntaxColors[tokenType] = newValue; +} + // Thematic break properties - (RCTUIColor *)thematicBreakColor { diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h index 93bf2ea6..1ba308cf 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.h @@ -2,6 +2,8 @@ #import +@class StyleConfig; + NS_ASSUME_NONNULL_BEGIN #ifdef __cplusplus @@ -13,11 +15,20 @@ extern "C" { /// /// Applies token colors as foreground-color attributes onto a mutable copy of /// the plain styled code, so highlighting can never change text metrics and -/// the block height measured from the plain string stays valid. Returns nil -/// when highlighting is unavailable (module compiled out, unknown language, -/// parse failure); callers keep the plain attributed code. +/// the block height measured from the plain string stays valid. Token colors +/// come from the config's resolved per-token palette. Returns nil when +/// highlighting is unavailable (module compiled out, unknown language, parse +/// failure); callers keep the plain attributed code. NSAttributedString *_Nullable ENRMHighlightedAttributedCode(NSAttributedString *plainCode, NSString *code, - NSString *_Nullable language); + NSString *_Nullable language, StyleConfig *config); + +/// Applies token foreground colors from the config's palette onto `output` +/// within `range` (range.location is where the code begins in output). Used by +/// the commonmark flavor, which renders code inline instead of as a container. +/// Returns whether any color was applied. No-op when highlighting is +/// unavailable, so callers keep the plain rendering. +BOOL ENRMApplyHighlightTokens(NSMutableAttributedString *output, NSRange range, NSString *code, + NSString *_Nullable language, StyleConfig *config); #ifdef __cplusplus } diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm index f5271bff..e1964cf5 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm @@ -1,71 +1,51 @@ #import "ENRMCodeBlockHighlighter.h" #include "CodeBlockHighlighter.hpp" #import "ENRMUIKit.h" +#import "StyleConfig.h" -static RCTUIColor *ENRMHexColor(uint32_t rgb) -{ - return [RCTUIColor colorWithRed:((rgb >> 16) & 0xFF) / 255.0 - green:((rgb >> 8) & 0xFF) / 255.0 - blue:(rgb & 0xFF) / 255.0 - alpha:1.0]; -} - -// TODO: provisional palette (GitHub light scheme); replace with themable -// per-token colors on the code block style when the highlighting module lands. -static RCTUIColor *ENRMColorForToken(Markdown::HighlightTokenType type) -{ - switch (type) { - case Markdown::HighlightTokenType::Keyword: - return ENRMHexColor(0xCF222E); - case Markdown::HighlightTokenType::String: - return ENRMHexColor(0x0A3069); - case Markdown::HighlightTokenType::Number: - case Markdown::HighlightTokenType::Constant: - case Markdown::HighlightTokenType::Property: - case Markdown::HighlightTokenType::Attribute: - return ENRMHexColor(0x0550AE); - case Markdown::HighlightTokenType::Comment: - return ENRMHexColor(0x6E7781); - case Markdown::HighlightTokenType::Function: - return ENRMHexColor(0x8250DF); - case Markdown::HighlightTokenType::Type: - return ENRMHexColor(0x953800); - case Markdown::HighlightTokenType::Tag: - return ENRMHexColor(0x116329); - default: - return nil; - } -} - -NSAttributedString *ENRMHighlightedAttributedCode(NSAttributedString *plainCode, NSString *code, - NSString *_Nullable language) +BOOL ENRMApplyHighlightTokens(NSMutableAttributedString *output, NSRange range, NSString *code, + NSString *_Nullable language, StyleConfig *config) { if (code.length == 0) { - return nil; + return NO; } std::vector tokens; try { tokens = Markdown::highlightCode(code.UTF8String ?: "", language.UTF8String ?: ""); } catch (...) { - return nil; + return NO; } if (tokens.empty()) { - return nil; + return NO; } - NSMutableAttributedString *highlighted = [plainCode mutableCopy]; - NSUInteger length = highlighted.length; + NSUInteger cap = MIN(NSMaxRange(range), output.length); BOOL applied = NO; for (const auto &token : tokens) { - RCTUIColor *color = ENRMColorForToken(token.type); - if (!color || token.end <= token.start || token.end > length) { + RCTUIColor *color = [config codeBlockSyntaxColorForToken:(NSInteger)token.type]; + if (!color || token.end <= token.start) { continue; } - [highlighted addAttribute:NSForegroundColorAttributeName - value:color - range:NSMakeRange(token.start, token.end - token.start)]; + NSUInteger start = range.location + token.start; + NSUInteger end = range.location + token.end; + if (end > cap) { + continue; + } + [output addAttribute:NSForegroundColorAttributeName value:color range:NSMakeRange(start, end - start)]; applied = YES; } + return applied; +} + +NSAttributedString *ENRMHighlightedAttributedCode(NSAttributedString *plainCode, NSString *code, + NSString *_Nullable language, StyleConfig *config) +{ + if (code.length == 0) { + return nil; + } + + NSMutableAttributedString *highlighted = [plainCode mutableCopy]; + BOOL applied = ENRMApplyHighlightTokens(highlighted, NSMakeRange(0, highlighted.length), code, language, config); return applied ? highlighted : nil; } diff --git a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h index f812cd6d..03c8db57 100644 --- a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h @@ -10,6 +10,7 @@ * mapping once and instantiate it for each type. */ +#import "CodeBlockHighlighter.hpp" #import "ParagraphStyleUtils.h" #import "StyleConfig.h" #import @@ -886,6 +887,28 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty changed = YES; } +#define ENRM_SET_SYNTAX_COLOR(field, token) \ + if (newStyle.codeBlock.syntaxColors.field != oldStyle.codeBlock.syntaxColors.field) { \ + [config setCodeBlockSyntaxColor:RCTUIColorFromSharedColor(newStyle.codeBlock.syntaxColors.field) \ + forToken:(NSInteger)Markdown::HighlightTokenType::token]; \ + changed = YES; \ + } + ENRM_SET_SYNTAX_COLOR(keyword, Keyword) + ENRM_SET_SYNTAX_COLOR(operatorColor, Operator) + ENRM_SET_SYNTAX_COLOR(punctuation, Punctuation) + ENRM_SET_SYNTAX_COLOR(string, String) + ENRM_SET_SYNTAX_COLOR(number, Number) + ENRM_SET_SYNTAX_COLOR(constant, Constant) + ENRM_SET_SYNTAX_COLOR(comment, Comment) + ENRM_SET_SYNTAX_COLOR(function, Function) + ENRM_SET_SYNTAX_COLOR(type, Type) + ENRM_SET_SYNTAX_COLOR(variable, Variable) + ENRM_SET_SYNTAX_COLOR(property, Property) + ENRM_SET_SYNTAX_COLOR(tag, Tag) + ENRM_SET_SYNTAX_COLOR(attribute, Attribute) + ENRM_SET_SYNTAX_COLOR(embedded, Embedded) +#undef ENRM_SET_SYNTAX_COLOR + // ── Thematic Break ───────────────────────────────────────────────────────── if (newStyle.thematicBreak.color != oldStyle.thematicBreak.color) { diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m index 83dc55d9..5def6442 100644 --- a/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m +++ b/packages/react-native-enriched-markdown/ios/views/ENRMCodeBlockContainerView.m @@ -324,7 +324,7 @@ - (void)applyCodeBlockNode:(MarkdownASTNode *)node } NSAttributedString *plainCode = [self plainAttributedCode]; - NSAttributedString *highlighted = ENRMHighlightedAttributedCode(plainCode, _cachedCode, _cachedLanguage); + NSAttributedString *highlighted = ENRMHighlightedAttributedCode(plainCode, _cachedCode, _cachedLanguage, _config); _attributedCode = highlighted ?: plainCode; _codeSize = ENRMCodeBlockCodeSize(_attributedCode); diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts index ba437dbb..53b32a07 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts @@ -44,12 +44,30 @@ interface ListStyleInternal extends BaseBlockStyleInternal { itemSpacing: CodegenTypes.Float; } +interface CodeBlockSyntaxColorsInternal { + keyword: ColorValue; + operatorColor: ColorValue; + punctuation: ColorValue; + string: ColorValue; + number: ColorValue; + constant: ColorValue; + comment: ColorValue; + function: ColorValue; + type: ColorValue; + variable: ColorValue; + property: ColorValue; + tag: ColorValue; + attribute: ColorValue; + embedded: ColorValue; +} + interface CodeBlockStyleInternal extends BaseBlockStyleInternal { backgroundColor: ColorValue; borderColor: ColorValue; borderRadius: CodegenTypes.Float; borderWidth: CodegenTypes.Float; padding: CodegenTypes.Float; + syntaxColors: CodeBlockSyntaxColorsInternal; } interface LinkStyleInternal { diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts index 5cf11fcb..d74ffbf8 100644 --- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts @@ -44,12 +44,30 @@ interface ListStyleInternal extends BaseBlockStyleInternal { itemSpacing: CodegenTypes.Float; } +interface CodeBlockSyntaxColorsInternal { + keyword: ColorValue; + operatorColor: ColorValue; + punctuation: ColorValue; + string: ColorValue; + number: ColorValue; + constant: ColorValue; + comment: ColorValue; + function: ColorValue; + type: ColorValue; + variable: ColorValue; + property: ColorValue; + tag: ColorValue; + attribute: ColorValue; + embedded: ColorValue; +} + interface CodeBlockStyleInternal extends BaseBlockStyleInternal { backgroundColor: ColorValue; borderColor: ColorValue; borderRadius: CodegenTypes.Float; borderWidth: CodegenTypes.Float; padding: CodegenTypes.Float; + syntaxColors: CodeBlockSyntaxColorsInternal; } interface LinkStyleInternal { diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts index 36258965..65e66525 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts @@ -31,6 +31,32 @@ const getMonospaceFont = (): string => const defaultTextColor = normalizeColor('#1F2937')!; +const codeBlockTextColor = normalizeColor('#F3F4F6')!; + +const DEFAULT_CODE_BLOCK_SYNTAX_COLORS = { + keyword: normalizeColor('#CF222E')!, + operatorColor: codeBlockTextColor, + punctuation: codeBlockTextColor, + string: normalizeColor('#0A3069')!, + number: normalizeColor('#0550AE')!, + constant: normalizeColor('#0550AE')!, + comment: normalizeColor('#6E7781')!, + function: normalizeColor('#8250DF')!, + type: normalizeColor('#953800')!, + variable: codeBlockTextColor, + property: normalizeColor('#0550AE')!, + tag: normalizeColor('#116329')!, + attribute: normalizeColor('#0550AE')!, + embedded: codeBlockTextColor, +}; + +const INHERIT_SYNTAX_TOKENS = new Set([ + 'operatorColor', + 'punctuation', + 'variable', + 'embedded', +]); + // Explicit type annotation needed: Object.freeze breaks contextual typing, so // TypeScript widens literal 'auto' to `string` instead of `BlockTextAlign`. const baseHeader: { @@ -130,7 +156,7 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ fontSize: 14, fontFamily: getMonospaceFont(), fontWeight: '', - color: normalizeColor('#F3F4F6')!, + color: codeBlockTextColor, lineHeight: Platform.select({ ios: 20, android: 22, default: 22 })!, marginTop: 0, marginBottom: 16, @@ -139,6 +165,7 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ borderRadius: 8, borderWidth: 1, padding: 16, + syntaxColors: { ...DEFAULT_CODE_BLOCK_SYNTAX_COLORS }, }, link: { fontFamily: '', @@ -323,6 +350,31 @@ export const normalizeMarkdownStyle = ( paragraphColor; } + const codeBlock = result.codeBlock as MarkdownStyleInternal['codeBlock']; + const userSyntaxColors = style.codeBlock?.syntaxColors as + | Record + | undefined; + const resolvedSyntaxColors: Record = {}; + for (const token in DEFAULT_CODE_BLOCK_SYNTAX_COLORS) { + const userValue = userSyntaxColors?.[token]; + if (typeof userValue === 'string') { + resolvedSyntaxColors[token] = + normalizeColor(userValue) ?? + DEFAULT_CODE_BLOCK_SYNTAX_COLORS[ + token as keyof typeof DEFAULT_CODE_BLOCK_SYNTAX_COLORS + ]; + } else if (INHERIT_SYNTAX_TOKENS.has(token)) { + resolvedSyntaxColors[token] = codeBlock.color; + } else { + resolvedSyntaxColors[token] = + DEFAULT_CODE_BLOCK_SYNTAX_COLORS[ + token as keyof typeof DEFAULT_CODE_BLOCK_SYNTAX_COLORS + ]; + } + } + (codeBlock as unknown as Record).syntaxColors = + resolvedSyntaxColors; + const finalResult = Object.freeze(result) as unknown as MarkdownStyleInternal; refCache.set(style, finalResult); structuralCache.unshift({ style, result: finalResult }); diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts index 82022a24..9bc8214b 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts @@ -125,6 +125,22 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ borderRadius: 8, borderWidth: 1, padding: 16, + syntaxColors: { + keyword: '#CF222E', + operatorColor: '#F3F4F6', + punctuation: '#F3F4F6', + string: '#0A3069', + number: '#0550AE', + constant: '#0550AE', + comment: '#6E7781', + function: '#8250DF', + type: '#953800', + variable: '#F3F4F6', + property: '#0550AE', + tag: '#116329', + attribute: '#0550AE', + embedded: '#F3F4F6', + }, }, link: { fontFamily: '', diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts index 16355468..567ec551 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyle.ts @@ -50,12 +50,41 @@ interface ListStyle extends BaseBlockStyle { itemSpacing?: number; } +/** + * Per-token syntax highlight colors for fenced code blocks, keyed on the + * tree-sitter highlight token types. Any key omitted falls back to the default + * palette (Operator/Punctuation/Variable/Embedded inherit the code block's + * base `color`). Colors only take visible effect when the optional syntax + * highlighting module is compiled in; otherwise code blocks render uncolored. + */ +interface CodeBlockSyntaxColors { + keyword?: string; + /** + * Color for operator tokens. Named `operatorColor` (not `operator`) because + * `operator` is a reserved word in the generated native (C++) struct. + */ + operatorColor?: string; + punctuation?: string; + string?: string; + number?: string; + constant?: string; + comment?: string; + function?: string; + type?: string; + variable?: string; + property?: string; + tag?: string; + attribute?: string; + embedded?: string; +} + interface CodeBlockStyle extends BaseBlockStyle { backgroundColor?: string; borderColor?: string; borderRadius?: number; borderWidth?: number; padding?: number; + syntaxColors?: CodeBlockSyntaxColors; } export interface LinkStyle { diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts index e2eddfb4..6be91c07 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts @@ -57,12 +57,30 @@ interface ListStyleInternal extends BaseBlockStyleInternal { itemSpacing: number; } +export interface CodeBlockSyntaxColorsInternal { + keyword: string; + operatorColor: string; + punctuation: string; + string: string; + number: string; + constant: string; + comment: string; + function: string; + type: string; + variable: string; + property: string; + tag: string; + attribute: string; + embedded: string; +} + interface CodeBlockStyleInternal extends BaseBlockStyleInternal { backgroundColor: string; borderColor: string; borderRadius: number; borderWidth: number; padding: number; + syntaxColors: CodeBlockSyntaxColorsInternal; } interface LinkStyleInternal {