Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<T> = { left: T; right: T };',
'function swap<T>(p: Pair<T>): Pair<T> {',
' 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 <stdio.h>',
'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',
'<a href="/x" class="link">go</a>',
'```',
'',
'```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<SyntaxColorControls> = {
args: {
markdown: MARKDOWN,
flavor: 'github',
...syntaxColorDefaults,
},
argTypes,
render: (args) => {
const { controls, rest } = splitStyleControls(args, syntaxColorDefaults);
return (
<EnrichedMarkdownTextStory
title="Code Highlight"
description="Per-token syntax colors via markdownStyle.codeBlock.syntaxColors. Colors render only when the optional highlighting module is compiled in; otherwise code blocks stay plain. Retheme any token with the color controls, and switch flavor to compare the block and inline renderers."
{...rest}
style={{ codeBlock: { syntaxColors: controls } }}
/>
);
},
};
22 changes: 22 additions & 0 deletions docs/STYLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,27 @@ data class CodeBlockStyle(
val borderRadius: Float,
val borderWidth: Float,
val padding: Float,
val syntaxColors: List<Int>,
) : 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,
Expand All @@ -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,
Expand All @@ -48,6 +73,7 @@ data class CodeBlockStyle(
borderRadius,
borderWidth,
padding,
syntaxColors,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
#import "StyleConfig.h"
#include "CodeBlockHighlighter.hpp"
#import "ENRMFontSlot.h"
#import "FontUtils.h"
#import <React/RCTFont.h>
#import <React/RCTUtils.h>

static const NSInteger kENRMCodeBlockSyntaxColorCount =
static_cast<NSInteger>(Markdown::HighlightTokenType::Embedded) + 1;

static inline NSString *normalizedFontWeight(NSString *fontWeight)
{
// If nil or empty string, return nil to let RCTFont use fontFamily as-is
Expand Down Expand Up @@ -189,6 +193,7 @@ @implementation StyleConfig {
CGFloat _codeBlockBorderRadius;
CGFloat _codeBlockBorderWidth;
CGFloat _codeBlockPadding;
RCTUIColor *_codeBlockSyntaxColors[kENRMCodeBlockSyntaxColorCount];
ENRMFontSlot *_codeBlockFont;
// Thematic break properties
RCTUIColor *_thematicBreakColor;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
Loading
Loading