From 25e6e6dd1a10ad954abb7c9efc24459a41d72a81 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 31 Jul 2026 12:30:16 +0200 Subject: [PATCH 1/6] feat: plumbing --- .../src/EnrichedMarkdownNativeComponent.ts | 18 +++++++ .../EnrichedMarkdownTextNativeComponent.ts | 18 +++++++ .../src/normalizeMarkdownStyle.ts | 47 ++++++++++++++++++- .../src/normalizeMarkdownStyle.web.ts | 18 +++++++ .../src/types/MarkdownStyle.ts | 29 ++++++++++++ .../src/types/MarkdownStyleInternal.ts | 21 +++++++++ 6 files changed, 150 insertions(+), 1 deletion(-) 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..0a841d45 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts @@ -31,6 +31,30 @@ const getMonospaceFont = (): string => const defaultTextColor = normalizeColor('#1F2937')!; +// Base text color for code blocks; also the fallback for the syntax token types +// that "inherit" (operator, punctuation, variable, embedded). +const codeBlockTextColor = normalizeColor('#F3F4F6')!; + +// Provisional GitHub-light syntax palette. It is the single source of truth for +// per-token code colors: native reads these resolved values and holds no default +// of its own. The four inheriting tokens resolve to the code block base color. +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, +}; + // Explicit type annotation needed: Object.freeze breaks contextual typing, so // TypeScript widens literal 'auto' to `string` instead of `BlockTextAlign`. const baseHeader: { @@ -130,7 +154,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 +163,7 @@ const DEFAULT_NORMALIZED_STYLE = Object.freeze({ borderRadius: 8, borderWidth: 1, padding: 16, + syntaxColors: { ...DEFAULT_CODE_BLOCK_SYNTAX_COLORS }, }, link: { fontFamily: '', @@ -323,6 +348,26 @@ export const normalizeMarkdownStyle = ( paragraphColor; } + // mergeSubStyle deep-merges the nested syntaxColors object but only normalizes + // top-level color strings, so a user's nested override (e.g. '#ff0000') would + // reach native un-processColor'd. Normalize any string value here; the resolved + // defaults are already ColorValue and are skipped. Invalid values fall back to + // the default palette entry for that token. + if (style.codeBlock?.syntaxColors) { + const syntaxColors = ( + result.codeBlock as MarkdownStyleInternal['codeBlock'] + ).syntaxColors as unknown as Record; + for (const token in syntaxColors) { + if (typeof syntaxColors[token] === 'string') { + syntaxColors[token] = + normalizeColor(syntaxColors[token] as string) ?? + DEFAULT_CODE_BLOCK_SYNTAX_COLORS[ + token as keyof typeof DEFAULT_CODE_BLOCK_SYNTAX_COLORS + ]; + } + } + } + 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..ac9553c9 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts @@ -125,6 +125,24 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ borderRadius: 8, borderWidth: 1, padding: 16, + // Syntax highlighting is not applied on web yet — defaults kept for type + // compatibility and future parity. The four inheriting tokens use the base color. + 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..56c82ddd 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts @@ -57,12 +57,33 @@ interface ListStyleInternal extends BaseBlockStyleInternal { itemSpacing: number; } +// Resolved once by normalizeMarkdownStyle: every token has a concrete color +// (the 4 "inherit" tokens are resolved to the code block base color), so native +// applies colors by ordinal lookup with no fallback logic. +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 { From fb6bf3d11f7649e5ca0d330a8124b73deb3c571d Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 31 Jul 2026 12:30:43 +0200 Subject: [PATCH 2/6] feat: android --- .../markdown/renderer/CodeBlockRenderer.kt | 13 ++++++ .../markdown/styles/CodeBlockStyle.kt | 30 ++++++++++++++ .../utils/common/CodeBlockHighlighter.kt | 41 ++++++++----------- .../markdown/views/CodeBlockContainerView.kt | 2 +- 4 files changed, 61 insertions(+), 25 deletions(-) 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..354536af 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,17 @@ class CodeBlockRenderer( if (builder.length == contentStart) return val end = builder.length + + // Foreground-only syntax highlighting over the rendered code; a no-op when + // the module is compiled out, so the plain rendering is preserved. + 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..467e2e10 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,29 @@ data class CodeBlockStyle( val borderRadius: Float, val borderWidth: Float, val padding: Float, + // Syntax highlight colors resolved once, indexed by HighlightTokenType ordinal. + val syntaxColors: List, ) : BaseBlockStyle { companion object { + // Order must match HighlightTokenType in cpp/highlight/CodeBlockHighlighter.hpp. + 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 +56,14 @@ data class CodeBlockStyle( val borderWidth = parser.toPixelFromDIP(map.getDouble("borderWidth").toFloat()) val padding = parser.toPixelFromDIP(map.getDouble("padding").toFloat()) + // JS resolves all token colors; fall back to the base text color for any + // key that is somehow absent so the token still renders (as "inherit"). + 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 +77,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 } From d709b2989b37d956e14a7a8cbc28034a57f45b03 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 31 Jul 2026 12:31:11 +0200 Subject: [PATCH 3/6] feat: ios --- .../ios/renderer/CodeBlockRenderer.m | 5 ++ .../ios/styles/StyleConfig.h | 4 + .../ios/styles/StyleConfig.mm | 24 ++++++ .../ios/utils/ENRMCodeBlockHighlighter.h | 19 ++++- .../ios/utils/ENRMCodeBlockHighlighter.mm | 76 +++++++------------ .../ios/utils/StylePropsUtils.h | 25 ++++++ .../ios/views/ENRMCodeBlockContainerView.m | 2 +- 7 files changed, 103 insertions(+), 52 deletions(-) diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m index cb5288dc..594d69b2 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,10 @@ - (void)renderNodeContent:(MarkdownASTNode *)node ENRMApplyCodeBlockTextAttributes(output, contentRange, _config); + // Foreground-only syntax highlighting; a no-op when the module is compiled + // out, so the plain rendering is preserved. + 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..cc310996 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm @@ -4,6 +4,10 @@ #import #import +// Number of syntax highlight token types; mirrors HighlightTokenType in +// cpp/highlight/CodeBlockHighlighter.hpp. +static const NSInteger kENRMCodeBlockSyntaxColorCount = 14; + 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..cd36d816 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm @@ -1,71 +1,53 @@ #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; + // Token offsets are relative to the code string; range.location is where that + // code begins in `output`. Never color past the content range or the string. + 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..99260ad3 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,30 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty changed = YES; } + // Syntax highlight colors: one diff+set per token type. Token ordinals come + // from HighlightTokenType so they can't drift out of sync with the seam. +#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); From 2fec311f4f32da18f0f7a315bc82c7bbfbde33aa Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 31 Jul 2026 12:31:26 +0200 Subject: [PATCH 4/6] docs: style docs --- docs/STYLES.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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. From ec244996dc412051c54ccc4145b32d9f9eec8925 Mon Sep 17 00:00:00 2001 From: Ernest Date: Fri, 31 Jul 2026 12:31:45 +0200 Subject: [PATCH 5/6] docs: storybook --- .../block/CodeHighlight.stories.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeHighlight.stories.tsx 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..9381850e --- /dev/null +++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownText/block/CodeHighlight.stories.tsx @@ -0,0 +1,134 @@ +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'; + +// One document exercising every default-supported grammar at once, so a single +// story is the manual QA surface for syntax highlighting across languages. +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'); + +// GitHub-light palette; the four "inherit" tokens use the code block base color. +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 ( + + ); + }, +}; From ac4440edc30f353f29a0f3b65a7648e02bec14d2 Mon Sep 17 00:00:00 2001 From: Ernest Date: Mon, 3 Aug 2026 14:08:44 +0200 Subject: [PATCH 6/6] fix: resolve comments --- .../block/CodeHighlight.stories.tsx | 3 -- .../markdown/renderer/CodeBlockRenderer.kt | 2 - .../markdown/styles/CodeBlockStyle.kt | 4 -- .../ios/renderer/CodeBlockRenderer.m | 2 - .../ios/styles/StyleConfig.mm | 6 +-- .../ios/utils/ENRMCodeBlockHighlighter.mm | 2 - .../ios/utils/StylePropsUtils.h | 2 - .../src/normalizeMarkdownStyle.ts | 51 +++++++++++-------- .../src/normalizeMarkdownStyle.web.ts | 2 - .../src/types/MarkdownStyleInternal.ts | 3 -- 10 files changed, 32 insertions(+), 45 deletions(-) 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 index 9381850e..876b4989 100644 --- 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 @@ -5,8 +5,6 @@ import { githubFlavorArgTypes } from '../shared/storybookMarkdownStyles'; import { splitStyleControls } from '../shared/storybookStyleBuilders'; import type { TextStory } from '../shared/storyTypes'; -// One document exercising every default-supported grammar at once, so a single -// story is the manual QA surface for syntax highlighting across languages. const MARKDOWN = [ '```javascript', 'const greet = (name) => `hi ${name}`; // arrow fn', @@ -65,7 +63,6 @@ const MARKDOWN = [ '```', ].join('\n'); -// GitHub-light palette; the four "inherit" tokens use the code block base color. const BASE_TEXT_COLOR = '#f3f4f6'; const syntaxColorDefaults = { keyword: '#cf222e', 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 354536af..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 @@ -47,8 +47,6 @@ class CodeBlockRenderer( val end = builder.length - // Foreground-only syntax highlighting over the rendered code; a no-op when - // the module is compiled out, so the plain rendering is preserved. CodeBlockHighlighter.highlight( builder, CodeBlockNode.extractCode(node), 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 467e2e10..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,11 +15,9 @@ data class CodeBlockStyle( val borderRadius: Float, val borderWidth: Float, val padding: Float, - // Syntax highlight colors resolved once, indexed by HighlightTokenType ordinal. val syntaxColors: List, ) : BaseBlockStyle { companion object { - // Order must match HighlightTokenType in cpp/highlight/CodeBlockHighlighter.hpp. private val SYNTAX_COLOR_KEYS = listOf( "keyword", @@ -56,8 +54,6 @@ data class CodeBlockStyle( val borderWidth = parser.toPixelFromDIP(map.getDouble("borderWidth").toFloat()) val padding = parser.toPixelFromDIP(map.getDouble("padding").toFloat()) - // JS resolves all token colors; fall back to the base text color for any - // key that is somehow absent so the token still renders (as "inherit"). val syntaxMap = map.getMap("syntaxColors") val syntaxColors = SYNTAX_COLOR_KEYS.map { key -> diff --git a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m index 594d69b2..36c5dc77 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/CodeBlockRenderer.m @@ -56,8 +56,6 @@ - (void)renderNodeContent:(MarkdownASTNode *)node ENRMApplyCodeBlockTextAttributes(output, contentRange, _config); - // Foreground-only syntax highlighting; a no-op when the module is compiled - // out, so the plain rendering is preserved. ENRMApplyHighlightTokens(output, contentRange, ENRMCodeBlockExtractCode(node), ENRMCodeBlockLanguage(node), _config); // Horizontal padding is paragraph indentation in this flavor; the shared diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm index cc310996..cabaa087 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm @@ -1,12 +1,12 @@ #import "StyleConfig.h" +#include "CodeBlockHighlighter.hpp" #import "ENRMFontSlot.h" #import "FontUtils.h" #import #import -// Number of syntax highlight token types; mirrors HighlightTokenType in -// cpp/highlight/CodeBlockHighlighter.hpp. -static const NSInteger kENRMCodeBlockSyntaxColorCount = 14; +static const NSInteger kENRMCodeBlockSyntaxColorCount = + static_cast(Markdown::HighlightTokenType::Embedded) + 1; static inline NSString *normalizedFontWeight(NSString *fontWeight) { diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm index cd36d816..e1964cf5 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMCodeBlockHighlighter.mm @@ -20,8 +20,6 @@ BOOL ENRMApplyHighlightTokens(NSMutableAttributedString *output, NSRange range, return NO; } - // Token offsets are relative to the code string; range.location is where that - // code begins in `output`. Never color past the content range or the string. NSUInteger cap = MIN(NSMaxRange(range), output.length); BOOL applied = NO; for (const auto &token : tokens) { diff --git a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h index 99260ad3..03c8db57 100644 --- a/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/StylePropsUtils.h @@ -887,8 +887,6 @@ BOOL applyMarkdownStyleToConfig(StyleConfig *config, const MarkdownStyle &newSty changed = YES; } - // Syntax highlight colors: one diff+set per token type. Token ordinals come - // from HighlightTokenType so they can't drift out of sync with the seam. #define ENRM_SET_SYNTAX_COLOR(field, token) \ if (newStyle.codeBlock.syntaxColors.field != oldStyle.codeBlock.syntaxColors.field) { \ [config setCodeBlockSyntaxColor:RCTUIColorFromSharedColor(newStyle.codeBlock.syntaxColors.field) \ diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts index 0a841d45..65e66525 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts @@ -31,13 +31,8 @@ const getMonospaceFont = (): string => const defaultTextColor = normalizeColor('#1F2937')!; -// Base text color for code blocks; also the fallback for the syntax token types -// that "inherit" (operator, punctuation, variable, embedded). const codeBlockTextColor = normalizeColor('#F3F4F6')!; -// Provisional GitHub-light syntax palette. It is the single source of truth for -// per-token code colors: native reads these resolved values and holds no default -// of its own. The four inheriting tokens resolve to the code block base color. const DEFAULT_CODE_BLOCK_SYNTAX_COLORS = { keyword: normalizeColor('#CF222E')!, operatorColor: codeBlockTextColor, @@ -55,6 +50,13 @@ const DEFAULT_CODE_BLOCK_SYNTAX_COLORS = { 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: { @@ -348,25 +350,30 @@ export const normalizeMarkdownStyle = ( paragraphColor; } - // mergeSubStyle deep-merges the nested syntaxColors object but only normalizes - // top-level color strings, so a user's nested override (e.g. '#ff0000') would - // reach native un-processColor'd. Normalize any string value here; the resolved - // defaults are already ColorValue and are skipped. Invalid values fall back to - // the default palette entry for that token. - if (style.codeBlock?.syntaxColors) { - const syntaxColors = ( - result.codeBlock as MarkdownStyleInternal['codeBlock'] - ).syntaxColors as unknown as Record; - for (const token in syntaxColors) { - if (typeof syntaxColors[token] === 'string') { - syntaxColors[token] = - normalizeColor(syntaxColors[token] as string) ?? - DEFAULT_CODE_BLOCK_SYNTAX_COLORS[ - token as keyof typeof DEFAULT_CODE_BLOCK_SYNTAX_COLORS - ]; - } + 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); diff --git a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts index ac9553c9..9bc8214b 100644 --- a/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +++ b/packages/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts @@ -125,8 +125,6 @@ const DEFAULT_NORMALIZED_STYLE: MarkdownStyleInternal = Object.freeze({ borderRadius: 8, borderWidth: 1, padding: 16, - // Syntax highlighting is not applied on web yet — defaults kept for type - // compatibility and future parity. The four inheriting tokens use the base color. syntaxColors: { keyword: '#CF222E', operatorColor: '#F3F4F6', diff --git a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts index 56c82ddd..6be91c07 100644 --- a/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +++ b/packages/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts @@ -57,9 +57,6 @@ interface ListStyleInternal extends BaseBlockStyleInternal { itemSpacing: number; } -// Resolved once by normalizeMarkdownStyle: every token has a concrete color -// (the 4 "inherit" tokens are resolved to the code block base color), so native -// applies colors by ordinal lookup with no fallback logic. export interface CodeBlockSyntaxColorsInternal { keyword: string; operatorColor: string;