From 224cfd8959201935ecab4fa065e22a7af4d61717 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 14:51:26 +0200 Subject: [PATCH 01/16] feat: integrate MD4C submodule for markdown parsing and add related tests --- .gitmodules | 3 + CONTRIBUTING.md | 78 +++++++++- RichText.podspec | 7 +- package.json | 1 + scripts/test-md4c.sh | 87 +++++++++++ shared/Makefile | 36 +++++ shared/test_md4c.c | 352 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 .gitmodules create mode 100755 scripts/test-md4c.sh create mode 100644 shared/Makefile create mode 100644 shared/test_md4c.c diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..be9174f3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "shared/MD4C"] + path = shared/MD4C + url = https://github.com/mity/md4c.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c10a83ce..ad3176ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,12 +13,68 @@ This project is a monorepo managed using [Yarn workspaces](https://yarnpkg.com/f To get started with the project, make sure you have the correct version of [Node.js](https://nodejs.org/) installed. See the [`.nvmrc`](./.nvmrc) file for the version used in this project. -Run `yarn` in the root directory to install the required dependencies for each package: +### Cloning the Repository + +When cloning this repository, make sure to include submodules: ```sh +# Clone with submodules (recommended) +git clone --recurse-submodules https://github.com/software-mansion-labs/react-native-rich-text.git + +# Or if you've already cloned without submodules: +git clone https://github.com/software-mansion-labs/react-native-rich-text.git +cd react-native-rich-text +git submodule add https://github.com/mity/md4c.git shared/MD4C +``` + +## Initial Setup + +This project uses git submodules for native dependencies. You need to initialize the submodules before installing dependencies: + +```sh + +# Install dependencies yarn ``` +> **Important**: The project depends on the MD4C submodule located in `shared/MD4C/`. Make sure to run the submodule initialization command before installing dependencies. + +### Troubleshooting Submodule Issues + +If you encounter issues with the submodule setup, here are common scenarios and solutions: + +**Scenario 1: Empty `shared/` directory** + +```sh +# Check if submodule is properly initialized +ls shared/MD4C/src/ +# If empty or missing, reinitialize: +git submodule update --init --recursive +``` + +**Scenario 2: `.gitmodules` exists but submodule content is missing** + +```sh +# This can happen when cloning without submodules +# Re-add the submodule manually: +git submodule add https://github.com/mity/md4c.git shared/MD4C +``` + +**Scenario 3: Submodule is in detached HEAD state** + +```sh +# Update to the latest commit +git submodule update --remote +``` + +> **Note**: If you continue having issues, try removing the submodule and re-adding it: +> +> ```sh +> git submodule deinit -f shared/MD4C +> rm -rf shared/MD4C +> git submodule add https://github.com/mity/md4c.git shared/MD4C +> ``` + > Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development without manually migrating. The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. @@ -76,6 +132,22 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` +### Testing MD4C Integration + +This project includes native markdown parsing capabilities using the MD4C library. To test the MD4C integration: + +```sh +# Run MD4C integration tests +./scripts/test-md4c.sh +``` + +This will test: + +- MD4C compilation and execution +- iOS podspec configuration + +> **Note**: The MD4C tests require the submodule to be properly initialized. If tests fail, make sure you've run `git submodule update --init --recursive`. + ### Commit message convention We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: @@ -119,6 +191,10 @@ The `package.json` file contains various scripts for common tasks: - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +Additional scripts for native development: + +- `./scripts/test-md4c.sh`: test MD4C integration and native setup. + ### Sending a pull request > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). diff --git a/RichText.podspec b/RichText.podspec index 1b55464c..23e8a0ee 100644 --- a/RichText.podspec +++ b/RichText.podspec @@ -13,8 +13,13 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/software-mansion-labs/react-native-rich-text.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,cpp}" + s.source_files = "ios/**/*.{h,m,mm,cpp}", "shared/MD4C/src/md4c.c", "shared/MD4C/src/entity.c" s.private_header_files = "ios/**/*.h" + + # Set header search paths to submodule + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '$(PODS_TARGET_SRCROOT)/shared/MD4C/src' + } install_modules_dependencies(s) diff --git a/package.json b/package.json index 3acec18d..c39c365b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "scripts": { "example": "yarn workspace react-native-rich-text-example", "test": "jest", + "test:md4c": "./scripts/test-md4c.sh", "typecheck": "tsc", "lint": "eslint \"**/*.{js,ts,tsx}\"", "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", diff --git a/scripts/test-md4c.sh b/scripts/test-md4c.sh new file mode 100755 index 00000000..301efac8 --- /dev/null +++ b/scripts/test-md4c.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# MD4C Test Script +# This script compiles and runs the MD4C integration test for both iOS and Android + +set -e # Exit on any error + +echo "๐Ÿงช Running MD4C Integration Tests" +echo "=================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to run a test and track results +run_test() { + local test_name="$1" + local test_command="$2" + + echo -e "${BLUE}Running: ${test_name}${NC}" + echo "Command: ${test_command}" + echo "" + + if eval "$test_command"; then + echo -e "${GREEN}โœ… ${test_name} PASSED${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}โŒ ${test_name} FAILED${NC}" + ((TESTS_FAILED++)) + fi + echo "" +} + +# Test Shared MD4C +echo "๐Ÿ”— Testing Shared MD4C Integration" +echo "===================================" +cd "$(dirname "$0")/../shared" + +# Check if MD4C submodule is initialized +if [ ! -d "MD4C/src" ]; then + echo "โŒ Shared MD4C submodule not found. Please run:" + echo " git submodule update --init --recursive" + exit 1 +fi + +# Test Shared MD4C +run_test "Shared MD4C Compilation" "make clean && make test_md4c" +run_test "Shared MD4C Execution" "./test_md4c && make clean" + +# Test Platform Integration +echo "" +echo "๐Ÿ”— Testing Platform Integration" +echo "===============================" + +# Test iOS integration (podspec references) +if [ -f "../RichText.podspec" ] && grep -q "shared/MD4C/src" "../RichText.podspec"; then + echo "โœ… iOS podspec correctly references shared/MD4C" + ((TESTS_PASSED++)) +else + echo "โŒ iOS podspec not configured for shared/MD4C" + echo " Expected: podspec to reference shared/MD4C/src" + ((TESTS_FAILED++)) +fi + + +# Summary +echo "" +echo "=====================================" +echo "๐Ÿ“Š Test Summary" +echo "=====================================" +echo -e "${GREEN}โœ… Tests Passed: ${TESTS_PASSED}${NC}" +echo -e "${RED}โŒ Tests Failed: ${TESTS_FAILED}${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}๐ŸŽ‰ All MD4C tests passed!${NC}" + exit 0 +else + echo -e "${RED}๐Ÿ’ฅ Some MD4C tests failed!${NC}" + exit 1 +fi diff --git a/shared/Makefile b/shared/Makefile new file mode 100644 index 00000000..ca418e87 --- /dev/null +++ b/shared/Makefile @@ -0,0 +1,36 @@ +# Makefile for MD4C test +CC = gcc +CFLAGS = -Wall -Wextra -std=c99 -I./MD4C/src +LDFLAGS = + +# Source files (from submodule) +MD4C_SOURCES = MD4C/src/md4c.c MD4C/src/entity.c +TEST_SOURCE = test_md4c.c + +# Target +TARGET = test_md4c + +# Default target +all: $(TARGET) + +# Build the test executable +$(TARGET): $(TEST_SOURCE) $(MD4C_SOURCES) + $(CC) $(CFLAGS) -o $(TARGET) $(TEST_SOURCE) $(MD4C_SOURCES) $(LDFLAGS) + +# Run the test +test: $(TARGET) + ./$(TARGET) + +# Clean up +clean: + rm -f $(TARGET) + +# Help target +help: + @echo "Available targets:" + @echo " all - Build the test executable" + @echo " test - Build and run the test" + @echo " clean - Remove build artifacts" + @echo " help - Show this help message" + +.PHONY: all test clean help diff --git a/shared/test_md4c.c b/shared/test_md4c.c new file mode 100644 index 00000000..7d2bcc1f --- /dev/null +++ b/shared/test_md4c.c @@ -0,0 +1,352 @@ +#include "MD4C/src/md4c.h" +#include +#include +#include + +// Callback functions for MD4C +static int enter_block_callback(MD_BLOCKTYPE type, void* detail, void* userdata); +static int leave_block_callback(MD_BLOCKTYPE type, void* detail, void* userdata); +static int enter_span_callback(MD_SPANTYPE type, void* detail, void* userdata); +static int leave_span_callback(MD_SPANTYPE type, void* detail, void* userdata); +static int text_callback(MD_TEXTTYPE type, const MD_CHAR* text, MD_SIZE size, void* userdata); + +int main() { + printf("MD4C Test - Starting markdown parsing test\n"); + printf("==========================================\n"); + + // Test markdown content with comprehensive features + const char* markdown = + "# Main Header\n" + "## Sub Header\n" + "### Level 3 Header\n" + "\n" + "This is a **bold** text and *italic* text.\n" + "You can also use __bold__ and _italic_ syntax.\n" + "\n" + "Here's some `inline code` and a [link](https://github.com/mity/md4c).\n" + "\n" + "## Code Block\n" + "```javascript\n" + "function hello() {\n" + " console.log('Hello, World!');\n" + "}\n" + "```\n" + "\n" + "## Lists\n" + "### Unordered List\n" + "- Item 1\n" + "- Item 2\n" + " - Nested item 2.1\n" + " - Nested item 2.2\n" + "- Item 3\n" + "\n" + "### Ordered List\n" + "1. First item\n" + "2. Second item\n" + " 1. Nested numbered item\n" + " 2. Another nested item\n" + "3. Third item\n" + "\n" + "## Blockquote\n" + "> This is a blockquote.\n" + "> It can span multiple lines.\n" + "> \n" + "> > Nested blockquote\n" + "\n" + "## Tables\n" + "| Header 1 | Header 2 | Header 3 |\n" + "|----------|----------|----------|\n" + "| Cell 1 | Cell 2 | Cell 3 |\n" + "| Cell 4 | Cell 5 | Cell 6 |\n" + "\n" + "## Task Lists\n" + "- [x] Completed task\n" + "- [ ] Incomplete task\n" + "- [x] Another completed task\n" + "\n" + "## Strikethrough and Emphasis\n" + "This text has ~~strikethrough~~ and ***bold italic*** text.\n" + "\n" + "## Horizontal Rule\n" + "---\n" + "\n" + "## Links and Images\n" + "Here's an [external link](https://example.com) and an ![image](https://example.com/image.png).\n" + "\n" + "## Auto-links\n" + "Visit https://github.com/mity/md4c for more info.\n" + "Email me at test@example.com for questions.\n" + "\n" + "## Line Breaks\n" + "This line has two spaces at the end. \n" + "This creates a line break.\n" + "\n" + "This line has a backslash at the end.\\\n" + "This also creates a line break.\n" + "\n" + "## Final Test\n" + "This is the end of our comprehensive markdown test! ๐ŸŽ‰"; + + printf("Input markdown:\n%s\n\n", markdown); + printf("Parsing results:\n"); + printf("================\n"); + + // Set up MD4C parser + MD_PARSER parser = {0}; + parser.abi_version = 0; + parser.flags = MD_FLAG_TABLES | MD_FLAG_TASKLISTS | MD_FLAG_STRIKETHROUGH | + MD_FLAG_PERMISSIVEURLAUTOLINKS | MD_FLAG_PERMISSIVEEMAILAUTOLINKS | + MD_FLAG_PERMISSIVEWWWAUTOLINKS; + parser.enter_block = enter_block_callback; + parser.leave_block = leave_block_callback; + parser.enter_span = enter_span_callback; + parser.leave_span = leave_span_callback; + parser.text = text_callback; + parser.debug_log = NULL; + parser.syntax = NULL; + + // Parse the markdown + int result = md_parse((MD_CHAR*)markdown, strlen(markdown), &parser, NULL); + + printf("\nParse result: %d (0 = success)\n", result); + + if (result == 0) { + printf("โœ… MD4C parsing successful!\n"); + } else { + printf("โŒ MD4C parsing failed!\n"); + } + + return result; +} + +// Block callbacks +static int enter_block_callback(MD_BLOCKTYPE type, void* detail, void* userdata) { + (void)userdata; // Suppress unused parameter warning + switch (type) { + case MD_BLOCK_H: + printf("๐Ÿ“ Entering header (level %d)\n", ((MD_BLOCK_H_DETAIL*)detail)->level); + break; + case MD_BLOCK_P: + printf("๐Ÿ“ Entering paragraph\n"); + break; + case MD_BLOCK_UL: + printf("๐Ÿ“ Entering unordered list\n"); + break; + case MD_BLOCK_OL: + printf("๐Ÿ“ Entering ordered list\n"); + break; + case MD_BLOCK_LI: + printf("๐Ÿ“ Entering list item\n"); + break; + case MD_BLOCK_CODE: + printf("๐Ÿ“ Entering code block\n"); + break; + case MD_BLOCK_QUOTE: + printf("๐Ÿ“ Entering blockquote\n"); + break; + case MD_BLOCK_TABLE: + printf("๐Ÿ“ Entering table\n"); + break; + case MD_BLOCK_THEAD: + printf("๐Ÿ“ Entering table header\n"); + break; + case MD_BLOCK_TBODY: + printf("๐Ÿ“ Entering table body\n"); + break; + case MD_BLOCK_TR: + printf("๐Ÿ“ Entering table row\n"); + break; + case MD_BLOCK_TH: + printf("๐Ÿ“ Entering table header cell\n"); + break; + case MD_BLOCK_TD: + printf("๐Ÿ“ Entering table data cell\n"); + break; + case MD_BLOCK_HR: + printf("๐Ÿ“ Entering horizontal rule\n"); + break; + default: + printf("๐Ÿ“ Entering block type %d\n", type); + break; + } + return 0; +} + +static int leave_block_callback(MD_BLOCKTYPE type, void* detail, void* userdata) { + (void)detail; (void)userdata; // Suppress unused parameter warnings + switch (type) { + case MD_BLOCK_H: + printf("๐Ÿ“ Leaving header\n"); + break; + case MD_BLOCK_P: + printf("๐Ÿ“ Leaving paragraph\n"); + break; + case MD_BLOCK_UL: + printf("๐Ÿ“ Leaving unordered list\n"); + break; + case MD_BLOCK_OL: + printf("๐Ÿ“ Leaving ordered list\n"); + break; + case MD_BLOCK_LI: + printf("๐Ÿ“ Leaving list item\n"); + break; + case MD_BLOCK_CODE: + printf("๐Ÿ“ Leaving code block\n"); + break; + case MD_BLOCK_QUOTE: + printf("๐Ÿ“ Leaving blockquote\n"); + break; + case MD_BLOCK_TABLE: + printf("๐Ÿ“ Leaving table\n"); + break; + case MD_BLOCK_THEAD: + printf("๐Ÿ“ Leaving table header\n"); + break; + case MD_BLOCK_TBODY: + printf("๐Ÿ“ Leaving table body\n"); + break; + case MD_BLOCK_TR: + printf("๐Ÿ“ Leaving table row\n"); + break; + case MD_BLOCK_TH: + printf("๐Ÿ“ Leaving table header cell\n"); + break; + case MD_BLOCK_TD: + printf("๐Ÿ“ Leaving table data cell\n"); + break; + case MD_BLOCK_HR: + printf("๐Ÿ“ Leaving horizontal rule\n"); + break; + default: + printf("๐Ÿ“ Leaving block type %d\n", type); + break; + } + return 0; +} + +// Span callbacks +static int enter_span_callback(MD_SPANTYPE type, void* detail, void* userdata) { + (void)userdata; // Suppress unused parameter warning + switch (type) { + case MD_SPAN_STRONG: + printf("๐Ÿ“ Entering bold text\n"); + break; + case MD_SPAN_EM: + printf("๐Ÿ“ Entering italic text\n"); + break; + case MD_SPAN_CODE: + printf("๐Ÿ“ Entering inline code\n"); + break; + case MD_SPAN_A: + { + MD_SPAN_A_DETAIL *a_detail = (MD_SPAN_A_DETAIL*)detail; + char* url = malloc(a_detail->href.size + 1); + if (url) { + memcpy(url, a_detail->href.text, a_detail->href.size); + url[a_detail->href.size] = '\0'; + printf("๐Ÿ“ Entering link to: %s\n", url); + free(url); + } + } + break; + case MD_SPAN_IMG: + { + MD_SPAN_IMG_DETAIL *img_detail = (MD_SPAN_IMG_DETAIL*)detail; + char* src = malloc(img_detail->src.size + 1); + char* title = malloc(img_detail->title.size + 1); + if (src) { + memcpy(src, img_detail->src.text, img_detail->src.size); + src[img_detail->src.size] = '\0'; + printf("๐Ÿ“ Entering image: %s", src); + if (title) { + memcpy(title, img_detail->title.text, img_detail->title.size); + title[img_detail->title.size] = '\0'; + printf(" (title: %s)", title); + free(title); + } + printf("\n"); + free(src); + } + } + break; + case MD_SPAN_DEL: + printf("๐Ÿ“ Entering strikethrough text\n"); + break; + case MD_SPAN_U: + printf("๐Ÿ“ Entering underlined text\n"); + break; + default: + printf("๐Ÿ“ Entering span type %d\n", type); + break; + } + return 0; +} + +static int leave_span_callback(MD_SPANTYPE type, void* detail, void* userdata) { + (void)detail; (void)userdata; // Suppress unused parameter warnings + switch (type) { + case MD_SPAN_STRONG: + printf("๐Ÿ“ Leaving bold text\n"); + break; + case MD_SPAN_EM: + printf("๐Ÿ“ Leaving italic text\n"); + break; + case MD_SPAN_CODE: + printf("๐Ÿ“ Leaving inline code\n"); + break; + case MD_SPAN_A: + printf("๐Ÿ“ Leaving link\n"); + break; + case MD_SPAN_IMG: + printf("๐Ÿ“ Leaving image\n"); + break; + case MD_SPAN_DEL: + printf("๐Ÿ“ Leaving strikethrough text\n"); + break; + case MD_SPAN_U: + printf("๐Ÿ“ Leaving underlined text\n"); + break; + default: + printf("๐Ÿ“ Leaving span type %d\n", type); + break; + } + return 0; +} + +// Text callback +static int text_callback(MD_TEXTTYPE type, const MD_CHAR* text, MD_SIZE size, void* userdata) { + (void)userdata; // Suppress unused parameter warning + // Create a null-terminated string for printing + char* text_str = malloc(size + 1); + if (text_str) { + memcpy(text_str, text, size); + text_str[size] = '\0'; + + switch (type) { + case MD_TEXT_NORMAL: + printf("๐Ÿ“ Text: \"%s\"\n", text_str); + break; + case MD_TEXT_CODE: + printf("๐Ÿ“ Code: \"%s\"\n", text_str); + break; + case MD_TEXT_HTML: + printf("๐Ÿ“ HTML: \"%s\"\n", text_str); + break; + case MD_TEXT_ENTITY: + printf("๐Ÿ“ Entity: \"%s\"\n", text_str); + break; + case MD_TEXT_BR: + printf("๐Ÿ“ Line break\n"); + break; + case MD_TEXT_SOFTBR: + printf("๐Ÿ“ Soft break\n"); + break; + default: + printf("๐Ÿ“ Text type %d: \"%s\"\n", type, text_str); + break; + } + + free(text_str); + } + return 0; +} From 379758f3d1e35d5965fb61815fd0ae2f120faf19 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 20:05:59 +0200 Subject: [PATCH 02/16] feat: enhance RichTextView with markdown rendering capabilities and link handling --- example/ios/Podfile.lock | 2 +- example/src/App.tsx | 74 +++++++- ios/RichTextView.mm | 285 ++++++++++++++++++++++++++--- ios/parser/MarkdownASTNode.h | 23 +++ ios/parser/MarkdownASTNode.m | 30 +++ ios/parser/MarkdownParser.h | 8 + ios/parser/MarkdownParser.mm | 168 +++++++++++++++++ ios/renderer/AttributedRenderer.h | 13 ++ ios/renderer/AttributedRenderer.m | 285 +++++++++++++++++++++++++++++ ios/renderer/NodeRenderer.h | 15 ++ ios/renderer/RenderContext.h | 11 ++ ios/renderer/RenderContext.m | 21 +++ ios/theme/HeaderConfig.h | 10 + ios/theme/HeaderConfig.m | 12 ++ ios/theme/RichTextTheme.h | 13 ++ ios/theme/RichTextTheme.m | 13 ++ package.json | 8 +- src/RichTextViewNativeComponent.ts | 54 +++++- src/index.tsx | 2 +- 19 files changed, 1003 insertions(+), 44 deletions(-) create mode 100644 ios/parser/MarkdownASTNode.h create mode 100644 ios/parser/MarkdownASTNode.m create mode 100644 ios/parser/MarkdownParser.h create mode 100644 ios/parser/MarkdownParser.mm create mode 100644 ios/renderer/AttributedRenderer.h create mode 100644 ios/renderer/AttributedRenderer.m create mode 100644 ios/renderer/NodeRenderer.h create mode 100644 ios/renderer/RenderContext.h create mode 100644 ios/renderer/RenderContext.m create mode 100644 ios/theme/HeaderConfig.h create mode 100644 ios/theme/HeaderConfig.m create mode 100644 ios/theme/RichTextTheme.h create mode 100644 ios/theme/RichTextTheme.m diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index c0f44f3c..f914ebc2 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2673,7 +2673,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 3eb9096cb139eb433965693bbe541d96eb3d3ec9 ReactCodegen: 4d203eddf6f977caa324640a20f92e70408d648b ReactCommon: ce5d4226dfaf9d5dacbef57b4528819e39d3a120 - RichText: bc45c6536e044f3c57b4f4f0aaa0dab911395d2f + RichText: c5ea51a2a21a79d2b3e670f73a75c25627a31181 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0 diff --git a/example/src/App.tsx b/example/src/App.tsx index 5f2757fd..e3671a24 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,23 +1,77 @@ -import { View, StyleSheet } from 'react-native'; +import { + StyleSheet, + ScrollView, + SafeAreaView, + Alert, + Linking, +} from 'react-native'; import { RichTextView } from 'react-native-rich-text'; +import type { HeaderConfig } from 'react-native-rich-text'; + +const HEADER_CONFIG: HeaderConfig = { + scale: 2.0, + isBold: true, +}; + +const sampleMarkdown = `#### Welcome to the React Native Markdown component! + +This is a simple text with links. + +Check out this [link to React Native](https://reactnative.dev) and this [GitHub repository](https://github.com/facebook/react-native). + +Built with โค๏ธ using React Native Fabric Architecture`; export default function App() { + const handleLinkPress = (event: { nativeEvent: { url: string } }) => { + const { url } = event.nativeEvent; + Alert.alert('Link Pressed!', `You tapped on: ${url}`, [ + { + text: 'Open in Browser', + onPress: () => { + Linking.openURL(url); + }, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ]); + }; + return ( - - - + + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - alignItems: 'center', - justifyContent: 'center', }, - box: { - width: 60, - height: 60, - marginVertical: 20, + scrollView: { + flex: 1, + }, + content: { + padding: 20, + }, + markdown: { + flex: 1, + padding: 10, + borderRadius: 8, + minHeight: 250, }, }); diff --git a/ios/RichTextView.mm b/ios/RichTextView.mm index 0141bd0f..39ba1952 100644 --- a/ios/RichTextView.mm +++ b/ios/RichTextView.mm @@ -1,4 +1,9 @@ #import "RichTextView.h" +#import "MarkdownParser.h" +#import "MarkdownASTNode.h" +#import "AttributedRenderer.h" +#import "RenderContext.h" +#import "RichTextTheme.h" #import #import @@ -9,12 +14,21 @@ using namespace facebook::react; -@interface RichTextView () +// Constants +static const CGFloat kDefaultFontSize = 16.0; +static const CGFloat kMinimumHeight = 100.0; +static const CGFloat kLabelPadding = 10.0; +@interface RichTextView () +- (void)setupTextView; +- (void)setupConstraints; +- (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTextViewProps &)props; +- (void)textTapped:(UITapGestureRecognizer *)recognizer; @end @implementation RichTextView { - UIView * _view; + UITextView * _textView; + MarkdownParser * _parser; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -22,28 +36,166 @@ + (ComponentDescriptorProvider)componentDescriptorProvider return concreteComponentDescriptorProvider(); } -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + self.backgroundColor = [UIColor clearColor]; + _parser = [[MarkdownParser alloc] init]; + + [self setupTextView]; + [self setupConstraints]; + } + + return self; +} - _view = [[UIView alloc] init]; +#pragma mark - Setup Methods - self.contentView = _view; - } +- (void)setupTextView { + _textView = [[UITextView alloc] init]; + _textView.translatesAutoresizingMaskIntoConstraints = NO; + _textView.text = @""; + _textView.font = [UIFont systemFontOfSize:kDefaultFontSize]; + _textView.backgroundColor = [UIColor clearColor]; + _textView.textColor = [UIColor blackColor]; + _textView.editable = NO; + _textView.scrollEnabled = NO; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + + // Add tap gesture recognizer + UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(textTapped:)]; + [_textView addGestureRecognizer:tapRecognizer]; + + [self addSubview:_textView]; +} - return self; +- (void)setupConstraints { + [NSLayoutConstraint activateConstraints:@[ + [_textView.topAnchor constraintEqualToAnchor:self.topAnchor + constant:kLabelPadding], + [_textView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor + constant:kLabelPadding], + [_textView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor + constant:-kLabelPadding], + [_textView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor + constant:-kLabelPadding], + [self.heightAnchor constraintGreaterThanOrEqualToConstant:kMinimumHeight] + ]]; } -- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps -{ +- (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTextViewProps &)props { + MarkdownASTNode *ast = [_parser parseMarkdown:markdownString]; + if (!ast) { + NSLog(@"RichTextView: Failed to parse markdown"); + return; + } + + AttributedRenderer *renderer = [AttributedRenderer new]; + RichTextTheme *theme = [RichTextTheme defaultTheme]; + + // FONT SIZE PROPAGATION: Pass the current fontSize to the theme system + // This ensures all renderers use the same base fontSize for consistent scaling + // - theme.baseFont will be used by all text elements (paragraphs, links, lists) + // - Header renderer will scale from this base font (H1 = baseFont + 12pt, etc.) + // - All other elements inherit this base size for consistent typography + if (_textView.font) { + theme.baseFont = _textView.font; + NSLog(@"๐ŸŽจ Theme baseFont set to: %@ (size: %.1f)", _textView.font.fontName, _textView.font.pointSize); + } + if (_textView.textColor) { theme.textColor = _textView.textColor; } + + const auto &headerConfig = props.headerConfig; + + theme.headerConfig.scale = headerConfig.scale > 0 ? headerConfig.scale : 2.0; + theme.headerConfig.isBold = headerConfig.isBold; + + RenderContext *renderContext = [RenderContext new]; + NSMutableAttributedString *attributedText = [renderer renderRoot:ast theme:theme context:renderContext]; + + // Add custom attributes for links + for (NSUInteger i = 0; i < renderContext.linkRanges.count; i++) { + NSValue *rangeValue = renderContext.linkRanges[i]; + NSRange range = [rangeValue rangeValue]; + NSString *url = renderContext.linkURLs[i]; + + // Add custom attribute for link detection + [attributedText addAttribute:@"linkURL" value:url range:range]; + NSLog(@"RichTextView: Added link %@ at range %@", url, NSStringFromRange(range)); + } + + _textView.attributedText = attributedText; +} + +- (void)updateProps:(Props::Shared const &)props + oldProps:(Props::Shared const &)oldProps { const auto &oldViewProps = *std::static_pointer_cast(_props); const auto &newViewProps = *std::static_pointer_cast(props); - - if (oldViewProps.color != newViewProps.color) { - NSString * colorToConvert = [[NSString alloc] initWithUTF8String: newViewProps.color.c_str()]; - [_view setBackgroundColor:[self hexStringToColor:colorToConvert]]; + + BOOL needsRerender = NO; + + // Handle markdown content changes + if (oldViewProps.markdown != newViewProps.markdown) { + NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; + [self renderMarkdownContent:markdownString withProps:newViewProps]; + needsRerender = YES; + } + + // Background color is always transparent - no color prop needed + + // Handle text color changes + if (oldViewProps.textColor != newViewProps.textColor) { + NSString *textColorString = [[NSString alloc] initWithUTF8String:newViewProps.textColor.c_str()]; + _textView.textColor = [self hexStringToColor:textColorString]; + needsRerender = YES; + } + + // Handle font size changes + // FONT SIZE SCALING: This fontSize becomes the base size for all text elements + // - Regular text: Uses fontSize directly (e.g., 18pt) + // - Headers: Scaled relative to fontSize (H1 = fontSize + 12, H2 = fontSize + 10, etc.) + // - Links: Use fontSize directly + // - Lists: Use fontSize directly + // - All other elements: Use fontSize as base reference + if (oldViewProps.fontSize != newViewProps.fontSize) { + CGFloat fontSize = newViewProps.fontSize > 0 ? newViewProps.fontSize : kDefaultFontSize; + _textView.font = [UIFont systemFontOfSize:fontSize]; + needsRerender = YES; + } + + // Handle font family changes + if (oldViewProps.fontFamily != newViewProps.fontFamily) { + NSString *fontFamily = [[NSString alloc] initWithUTF8String:newViewProps.fontFamily.c_str()]; + CGFloat currentSize = _textView.font.pointSize; + UIFont *newFont = [UIFont fontWithName:fontFamily size:currentSize]; + if (newFont) { + _textView.font = newFont; + NSLog(@"โœ… FontFamily applied: %@ (size: %.1f)", fontFamily, currentSize); + needsRerender = YES; + } else { + NSLog(@"โŒ FontFamily not found: %@, falling back to system font", fontFamily); + NSLog(@"๐Ÿ’ก Try these iOS font names: Helvetica, Helvetica-Bold, Arial-BoldMT, TimesNewRomanPSMT"); + // Fallback to system font with the same size + _textView.font = [UIFont systemFontOfSize:currentSize]; + needsRerender = YES; + } + } + + // Handle header config changes + if (oldViewProps.headerConfig.scale != newViewProps.headerConfig.scale || + oldViewProps.headerConfig.isBold != newViewProps.headerConfig.isBold) { + needsRerender = YES; + NSLog(@"๐ŸŽ›๏ธ HeaderConfig changed: scale %.1f->%.1f, bold %d->%d", + oldViewProps.headerConfig.scale, newViewProps.headerConfig.scale, + oldViewProps.headerConfig.isBold, newViewProps.headerConfig.isBold); + } + + // Re-render if any text styling changed + if (needsRerender && !newViewProps.markdown.empty()) { + NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; + [self renderMarkdownContent:markdownString withProps:newViewProps]; } [super updateProps:props oldProps:oldProps]; @@ -54,18 +206,97 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & return RichTextView.class; } -- hexStringToColor:(NSString *)stringToConvert -{ - NSString *noHashString = [stringToConvert stringByReplacingOccurrencesOfString:@"#" withString:@""]; - NSScanner *stringScanner = [NSScanner scannerWithString:noHashString]; +- (UIColor *)hexStringToColor:(NSString *)hexString { + if (!hexString.length) return nil; + + NSString *cleanHex = [hexString stringByReplacingOccurrencesOfString:@"#" withString:@""]; + if (cleanHex.length != 6) return nil; + + NSScanner *scanner = [NSScanner scannerWithString:cleanHex]; + unsigned hexValue; + if (![scanner scanHexInt:&hexValue]) return nil; + + CGFloat red = ((hexValue >> 16) & 0xFF) / 255.0f; + CGFloat green = ((hexValue >> 8) & 0xFF) / 255.0f; + CGFloat blue = (hexValue & 0xFF) / 255.0f; + + return [UIColor colorWithRed:red green:green blue:blue alpha:1.0f]; +} - unsigned hex; - if (![stringScanner scanHexInt:&hex]) return nil; - int r = (hex >> 16) & 0xFF; - int g = (hex >> 8) & 0xFF; - int b = (hex) & 0xFF; +#pragma mark - Touch Handling - return [UIColor colorWithRed:r / 255.0f green:g / 255.0f blue:b / 255.0f alpha:1.0f]; +- (void)textTapped:(UITapGestureRecognizer *)recognizer { + /* + * HOW LINK TAPPING WORKS: + * + * 1. SETUP PHASE (During Rendering): + * - Each link gets a custom @"linkURL" attribute attached to its text range + * - The URL is stored as the attribute's value + * - This creates an "invisible map" of where links are in the text + * + * Example: + * Text: "Check out this [link to React Native](https://reactnative.dev)" + * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + * | | | | | | | | | | | + * 0 5 10 15 20 25 30 35 40 45 50 + * + * Attributes: + * - Characters 15-29: @"linkURL" = "https://reactnative.dev" + * - Characters 0-14: no special attributes + * - Characters 30-50: no special attributes + * + * 2. TOUCH DETECTION PHASE (When User Taps): + * - UITapGestureRecognizer detects the tap + * - We get the tap coordinates relative to the text view + * - We adjust for text container insets to get precise text coordinates + */ + + UITextView *textView = (UITextView *)recognizer.view; + + // Location of the tap in text-container coordinates + NSLayoutManager *layoutManager = textView.layoutManager; + CGPoint location = [recognizer locationInView:textView]; + location.x -= textView.textContainerInset.left; + location.y -= textView.textContainerInset.top; + + /* + * 3. CHARACTER INDEX LOOKUP: + * - NSLayoutManager converts the tap coordinates to a character index + * - This tells us exactly which character in the text was tapped + * - Uses UIKit's built-in text layout system (very accurate) + */ + NSUInteger characterIndex; + characterIndex = [layoutManager characterIndexForPoint:location + inTextContainer:textView.textContainer + fractionOfDistanceBetweenInsertionPoints:NULL]; + + /* + * 4. LINK DETECTION: + * - We check if there's a @"linkURL" attribute at the tapped character + * - If found, we get the URL value and the effective range + * - If it's a link, we emit the onLinkPress event to React Native + * + * COMPLETE FLOW: + * 1. User taps โ†’ UITapGestureRecognizer fires + * 2. Get coordinates โ†’ Convert to text container coordinates + * 3. Find character โ†’ NSLayoutManager.characterIndexForPoint + * 4. Check attributes โ†’ Look for @"linkURL" at that character + * 5. If link found โ†’ Emit onLinkPress event with URL + * 6. React Native โ†’ Receives event and shows alert + */ + if (characterIndex < textView.textStorage.length) { + NSRange range; + NSString *url = [textView.attributedText attribute:@"linkURL" atIndex:characterIndex effectiveRange:&range]; + + if (url) { + // Emit onLinkPress event to React Native + const auto &eventEmitter = *std::static_pointer_cast(_eventEmitter); + eventEmitter.onLinkPress({ + .url = std::string([url UTF8String]) + }); + } + } } + @end diff --git a/ios/parser/MarkdownASTNode.h b/ios/parser/MarkdownASTNode.h new file mode 100644 index 00000000..03c8c773 --- /dev/null +++ b/ios/parser/MarkdownASTNode.h @@ -0,0 +1,23 @@ +#import + +typedef NS_ENUM(NSInteger, MarkdownNodeType) { + MarkdownNodeTypeDocument, + MarkdownNodeTypeParagraph, + MarkdownNodeTypeText, + MarkdownNodeTypeLink, + MarkdownNodeTypeHeading, + MarkdownNodeTypeLineBreak +}; + +@interface MarkdownASTNode : NSObject + +@property (nonatomic, assign) MarkdownNodeType type; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSMutableDictionary *attributes; +@property (nonatomic, strong) NSMutableArray *children; + +- (instancetype)initWithType:(MarkdownNodeType)type; +- (void)addChild:(MarkdownASTNode *)child; +- (void)setAttribute:(NSString *)key value:(NSString *)value; + +@end diff --git a/ios/parser/MarkdownASTNode.m b/ios/parser/MarkdownASTNode.m new file mode 100644 index 00000000..5b1d610e --- /dev/null +++ b/ios/parser/MarkdownASTNode.m @@ -0,0 +1,30 @@ +#import "MarkdownASTNode.h" + +@implementation MarkdownASTNode + +- (instancetype)initWithType:(MarkdownNodeType)type { + if (self = [super init]) { + _type = type; + _content = nil; + _attributes = [[NSMutableDictionary alloc] init]; + _children = [[NSMutableArray alloc] init]; + } + return self; +} + +- (void)addChild:(MarkdownASTNode *)child { + [_children addObject:child]; +} + +- (void)setAttribute:(NSString *)key value:(NSString *)value { + _attributes[key] = value; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"MarkdownASTNode(type=%ld, content=%@, children=%lu)", + (long)_type, + _content, + (unsigned long)_children.count]; +} + +@end diff --git a/ios/parser/MarkdownParser.h b/ios/parser/MarkdownParser.h new file mode 100644 index 00000000..260fdf95 --- /dev/null +++ b/ios/parser/MarkdownParser.h @@ -0,0 +1,8 @@ +#import +#import "MarkdownASTNode.h" + +@interface MarkdownParser : NSObject + +- (MarkdownASTNode *)parseMarkdown:(NSString *)markdown; + +@end \ No newline at end of file diff --git a/ios/parser/MarkdownParser.mm b/ios/parser/MarkdownParser.mm new file mode 100644 index 00000000..2dc58f3a --- /dev/null +++ b/ios/parser/MarkdownParser.mm @@ -0,0 +1,168 @@ +#import "MarkdownParser.h" +#import "MarkdownASTNode.h" +#import "md4c.h" + +// Context for MD4C callbacks +typedef struct { + MarkdownASTNode *root; + NSMutableArray *nodeStack; +} MD4CContext; + +static void addNodeToContext(MarkdownASTNode *node, MD4CContext *context) { + if (!node) return; + + if (context->root == nil) { + context->root = node; + } else { + MarkdownASTNode *parent = [context->nodeStack lastObject]; + [parent addChild:node]; + } + [context->nodeStack addObject:node]; +} + +static void addInlineNodeToContext(MarkdownASTNode *node, MD4CContext *context) { + if (!node) return; + + MarkdownASTNode *parent = [context->nodeStack lastObject]; + [parent addChild:node]; +} + +// MD4C callback functions +// Note: All callbacks return 0 for success (continue parsing) or non-zero for error (stop parsing) +static int md4c_enter_block_callback(MD_BLOCKTYPE type, void *detail, void *userdata) { + MD4CContext *context = (MD4CContext *)userdata; + MarkdownASTNode *node = nil; + + switch (type) { + case MD_BLOCK_DOC: + node = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; + break; + case MD_BLOCK_P: + node = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeParagraph]; + break; + case MD_BLOCK_H: { + node = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeHeading]; + if (detail) { + MD_BLOCK_H_DETAIL *h = (MD_BLOCK_H_DETAIL *)detail; + NSInteger level = (NSInteger)h->level; + [node setAttribute:@"level" value:@(level).stringValue]; + } + break; + } + default: + return 0; + } + + addNodeToContext(node, context); + return 0; +} + +static int md4c_leave_block_callback(MD_BLOCKTYPE type, void *detail, void *userdata) { + MD4CContext *context = (MD4CContext *)userdata; + + if ([context->nodeStack count] > 0) { + [context->nodeStack removeLastObject]; + } + + return 0; +} + +static int md4c_enter_span_callback(MD_SPANTYPE type, void *detail, void *userdata) { + MD4CContext *context = (MD4CContext *)userdata; + MarkdownASTNode *node = nil; + + switch (type) { + case MD_SPAN_A: { + node = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeLink]; + if (detail) { + MD_SPAN_A_DETAIL *linkDetail = (MD_SPAN_A_DETAIL *)detail; + if (linkDetail->href.size > 0) { + NSString *url = [[NSString alloc] initWithBytes:linkDetail->href.text + length:linkDetail->href.size + encoding:NSUTF8StringEncoding]; + [node setAttribute:@"url" value:url]; + } + } + break; + } + default: + return 0; + } + + addNodeToContext(node, context); + return 0; +} + +static int md4c_leave_span_callback(MD_SPANTYPE type, void *detail, void *userdata) { + MD4CContext *context = (MD4CContext *)userdata; + + if ([context->nodeStack count] > 0) { + [context->nodeStack removeLastObject]; + } + + return 0; +} + +static int md4c_text_callback(MD_TEXTTYPE type, const MD_CHAR *text, MD_SIZE size, void *userdata) { + MD4CContext *context = (MD4CContext *)userdata; + + // Handle soft/hard line breaks (MD4C provides these for explicit line breaks within paragraphs) + // Note: MD4C does NOT provide empty lines between blocks - those are added by renderers + if (type == MD_TEXT_SOFTBR || type == MD_TEXT_BR) { + MarkdownASTNode *brNode = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeLineBreak]; + addInlineNodeToContext(brNode, context); + return 0; + } + + if (size > 0) { + NSString *textString = [[NSString alloc] initWithBytes:text + length:size + encoding:NSUTF8StringEncoding]; + if (textString) { + MarkdownASTNode *textNode = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeText]; + textNode.content = textString; + addInlineNodeToContext(textNode, context); + } + } + + return 0; +} + +@implementation MarkdownParser + +- (MarkdownASTNode *)parseMarkdown:(NSString *)markdown { + if (!markdown.length) { + return [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; + } + + // Initialize context + MD4CContext context = { + .root = nil, + .nodeStack = [[NSMutableArray alloc] init] + }; + + // Configure MD4C parser + MD_PARSER parser = { + .enter_block = md4c_enter_block_callback, + .leave_block = md4c_leave_block_callback, + .enter_span = md4c_enter_span_callback, + .leave_span = md4c_leave_span_callback, + .text = md4c_text_callback, + .debug_log = NULL, + .syntax = NULL + }; + + // Parse the markdown + const char *markdownCString = markdown.UTF8String; + MD_SIZE markdownLength = (MD_SIZE)strlen(markdownCString); + int result = md_parse(markdownCString, markdownLength, &parser, &context); + + if (result != 0) { + NSLog(@"MarkdownParser: MD4C parsing failed with error: %d", result); + return [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; + } + + return context.root ?: [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; +} + +@end \ No newline at end of file diff --git a/ios/renderer/AttributedRenderer.h b/ios/renderer/AttributedRenderer.h new file mode 100644 index 00000000..f1619863 --- /dev/null +++ b/ios/renderer/AttributedRenderer.h @@ -0,0 +1,13 @@ +#import + +@class MarkdownASTNode; +@class RichTextTheme; +@class RenderContext; + +@interface AttributedRenderer : NSObject +- (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root + theme:(RichTextTheme *)theme + context:(RenderContext *)context; +@end + + diff --git a/ios/renderer/AttributedRenderer.m b/ios/renderer/AttributedRenderer.m new file mode 100644 index 00000000..02a73432 --- /dev/null +++ b/ios/renderer/AttributedRenderer.m @@ -0,0 +1,285 @@ +#import "AttributedRenderer.h" +#import "NodeRenderer.h" +#import "RenderContext.h" +#import "MarkdownASTNode.h" +#import "RichTextTheme.h" + +@interface ParagraphRenderer : NSObject +@end + +@interface TextRenderer : NSObject +@end + +@interface LinkRenderer : NSObject +@end + +@interface HeadingRenderer : NSObject +@end + +@interface AttributedRenderer (Helpers) +- (NSAttributedString *)createTextString:(NSString *)text + withFont:(UIFont *)font + color:(UIColor *)color; +- (void)renderChildrenOfNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context; +@end + +@implementation AttributedRenderer + +- (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root + theme:(RichTextTheme *)theme + context:(RenderContext *)context { + NSMutableAttributedString *out = [[NSMutableAttributedString alloc] init]; + [self renderNodeRecursive:root into:out theme:theme context:context isTopLevel:YES]; + return out; +} + +/** + * Recursively renders markdown AST nodes into attributed text. + * + * Uses recursive tree traversal to handle nested markdown elements like + * "**bold with [link](url) inside**". Each node type has its own renderer + * for modular, maintainable code. Performance: O(n) with shallow AST depth. + */ +- (void)renderNodeRecursive:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)out + theme:(RichTextTheme *)theme + context:(RenderContext *)context + isTopLevel:(BOOL)isTopLevel { + id renderer = [self rendererForNode:node]; + if (renderer) { + [renderer renderNode:node into:out withTheme:theme context:context]; + return; + } + // Fallback: render children + for (NSUInteger i = 0; i < node.children.count; i++) { + MarkdownASTNode *child = node.children[i]; + [self renderNodeRecursive:child into:out theme:theme context:context isTopLevel:NO]; + // Add spacing between paragraphs (MD4C doesn't provide empty lines between blocks) + // This is intentional rendering behavior to match markdown visual expectations + if (child.type == MarkdownNodeTypeParagraph && i < node.children.count - 1) { + NSAttributedString *spacing = [[NSAttributedString alloc] + initWithString:@"\n\n" + attributes:@{ + NSFontAttributeName: theme.baseFont, + NSForegroundColorAttributeName: theme.textColor + }]; + [out appendAttributedString:spacing]; + } + } +} + +- (id)rendererForNode:(MarkdownASTNode *)node { + switch (node.type) { + case MarkdownNodeTypeParagraph: return [ParagraphRenderer new]; + case MarkdownNodeTypeText: return [TextRenderer new]; + case MarkdownNodeTypeLink: return [LinkRenderer new]; + case MarkdownNodeTypeHeading: return [HeadingRenderer new]; + default: return nil; + } +} + +@end + +@implementation AttributedRenderer (Helpers) + +- (NSAttributedString *)createTextString:(NSString *)text + withFont:(UIFont *)font + color:(UIColor *)color { + return [[NSAttributedString alloc] initWithString:text + attributes:@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: color + }]; +} + +- (void)renderChildrenOfNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context { + for (MarkdownASTNode *child in node.children) { + id renderer = [self rendererForNode:child]; + if (renderer) { + [renderer renderNode:child + into:output + withTheme:theme + context:context]; + } + } +} + +@end + + +@implementation ParagraphRenderer +- (BOOL)canRender:(MarkdownASTNode *)node { return node.type == MarkdownNodeTypeParagraph; } +- (void)renderNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context { + for (MarkdownASTNode *child in node.children) { + switch (child.type) { + case MarkdownNodeTypeText: + if (child.content) { + // Use theme.baseFont directly (no scaling for regular text) + NSAttributedString *text = [[NSAttributedString alloc] + initWithString:child.content + attributes:@{ + NSFontAttributeName: theme.baseFont, // Direct fontSize usage + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:text]; + } + break; + + case MarkdownNodeTypeLink: { + LinkRenderer *linkRenderer = [LinkRenderer new]; + [linkRenderer renderNode:child + into:output + withTheme:theme + context:context]; + break; + } + + case MarkdownNodeTypeLineBreak: { + NSAttributedString *br = [[NSAttributedString alloc] + initWithString:@"\n" + attributes:@{ + NSFontAttributeName: theme.baseFont, + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:br]; + break; + } + + default: + // Fallback: render children + for (MarkdownASTNode *grand in child.children) { + if (grand.type == MarkdownNodeTypeText && grand.content) { + NSAttributedString *t = [[NSAttributedString alloc] + initWithString:grand.content + attributes:@{ + NSFontAttributeName: theme.baseFont, + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:t]; + } + } + break; + } + } +} +@end + +@implementation TextRenderer +- (BOOL)canRender:(MarkdownASTNode *)node { return node.type == MarkdownNodeTypeText; } +- (void)renderNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context { + if (!node.content) return; + + NSAttributedString *text = [[NSAttributedString alloc] + initWithString:node.content + attributes:@{ + NSFontAttributeName: theme.baseFont, + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:text]; +} +@end + +@implementation LinkRenderer +- (BOOL)canRender:(MarkdownASTNode *)node { return node.type == MarkdownNodeTypeLink; } +- (void)renderNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context { + NSUInteger start = output.length; + + // Render link children as text + for (MarkdownASTNode *child in node.children) { + if (child.type == MarkdownNodeTypeText && child.content) { + // Links use same fontSize as regular text + NSAttributedString *text = [[NSAttributedString alloc] + initWithString:child.content + attributes:@{ + NSFontAttributeName: theme.baseFont // Same fontSize as text + }]; + [output appendAttributedString:text]; + } + } + + // Apply link attributes to the range + NSUInteger len = output.length - start; + if (len > 0) { + NSRange range = NSMakeRange(start, len); + NSString *url = node.attributes[@"url"] ?: @""; + + [output addAttributes:@{ + NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle) + } range:range]; + [output addAttribute:NSLinkAttributeName + value:url + range:range]; + [context registerLinkRange:range url:url]; + } +} +@end + +@implementation HeadingRenderer +- (BOOL)canRender:(MarkdownASTNode *)node { return node.type == MarkdownNodeTypeHeading; } +- (void)renderNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context { + // Determine level from attributes (default 1) + NSInteger level = [node.attributes[@"level"] integerValue]; + if (level < 1 || level > 6) level = 1; + + // Scale header size using theme configuration + CGFloat size = MAX(12.0, theme.baseFont.pointSize + (7 - level) * theme.headerConfig.scale); + + // Start with base font, apply bold if needed + UIFont *font = [UIFont fontWithName:theme.baseFont.fontName size:size]; + + // If font is not bold but headerConfig wants bold, try bold version + if (![theme.baseFont.fontName containsString:@"Bold"] && theme.headerConfig.isBold) { + // For system fonts (no fontFamily specified), use system bold directly + if ([theme.baseFont.fontName hasPrefix:@".SFUI"]) { + font = [UIFont boldSystemFontOfSize:size]; + } else { + // For specified font families, try bold version + NSString *boldFontName = [NSString stringWithFormat:@"%@-Bold", theme.baseFont.fontName]; + UIFont *boldFont = [UIFont fontWithName:boldFontName size:size]; + font = boldFont ?: [UIFont boldSystemFontOfSize:size]; + } + } + + // Render text children + for (MarkdownASTNode *child in node.children) { + if (child.type == MarkdownNodeTypeText && child.content) { + NSAttributedString *text = [[NSAttributedString alloc] + initWithString:child.content + attributes:@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:text]; + } + } + + // Add spacing after heading (proportional to base fontSize) + NSAttributedString *spacing = [[NSAttributedString alloc] + initWithString:@"\n\n" + attributes:@{ + NSFontAttributeName: theme.baseFont, // Spacing proportional to fontSize + NSForegroundColorAttributeName: theme.textColor + }]; + [output appendAttributedString:spacing]; +} +@end + + diff --git a/ios/renderer/NodeRenderer.h b/ios/renderer/NodeRenderer.h new file mode 100644 index 00000000..f53df727 --- /dev/null +++ b/ios/renderer/NodeRenderer.h @@ -0,0 +1,15 @@ +#import + +@class MarkdownASTNode; +@class RichTextTheme; +@class RenderContext; + +@protocol NodeRenderer +- (BOOL)canRender:(MarkdownASTNode *)node; +- (void)renderNode:(MarkdownASTNode *)node + into:(NSMutableAttributedString *)output + withTheme:(RichTextTheme *)theme + context:(RenderContext *)context; +@end + + diff --git a/ios/renderer/RenderContext.h b/ios/renderer/RenderContext.h new file mode 100644 index 00000000..5af2c508 --- /dev/null +++ b/ios/renderer/RenderContext.h @@ -0,0 +1,11 @@ +#import + +@interface RenderContext : NSObject +@property (nonatomic, strong) NSMutableArray *linkRanges; // NSRange boxed +@property (nonatomic, strong) NSMutableArray *linkURLs; + +- (instancetype)init; +- (void)registerLinkRange:(NSRange)range url:(NSString *)url; +@end + + diff --git a/ios/renderer/RenderContext.m b/ios/renderer/RenderContext.m new file mode 100644 index 00000000..b80687e2 --- /dev/null +++ b/ios/renderer/RenderContext.m @@ -0,0 +1,21 @@ +#import "RenderContext.h" + +@implementation RenderContext + +- (instancetype)init { + if (self = [super init]) { + _linkRanges = [NSMutableArray array]; + _linkURLs = [NSMutableArray array]; + } + return self; +} + +- (void)registerLinkRange:(NSRange)range + url:(NSString *)url { + [self.linkRanges addObject:[NSValue valueWithRange:range]]; + [self.linkURLs addObject:url ?: @""]; +} + +@end + + diff --git a/ios/theme/HeaderConfig.h b/ios/theme/HeaderConfig.h new file mode 100644 index 00000000..38de122e --- /dev/null +++ b/ios/theme/HeaderConfig.h @@ -0,0 +1,10 @@ +#import + +@interface HeaderConfig : NSObject + +@property (nonatomic, assign) CGFloat scale; +@property (nonatomic, assign) BOOL isBold; + ++ (instancetype)defaultConfig; + +@end diff --git a/ios/theme/HeaderConfig.m b/ios/theme/HeaderConfig.m new file mode 100644 index 00000000..6393c42e --- /dev/null +++ b/ios/theme/HeaderConfig.m @@ -0,0 +1,12 @@ +#import "HeaderConfig.h" + +@implementation HeaderConfig + ++ (instancetype)defaultConfig { + HeaderConfig *config = [HeaderConfig new]; + config.scale = 2.0; // Default scaling factor + config.isBold = YES; // Headers are bold by default + return config; +} + +@end diff --git a/ios/theme/RichTextTheme.h b/ios/theme/RichTextTheme.h new file mode 100644 index 00000000..9c6f5e14 --- /dev/null +++ b/ios/theme/RichTextTheme.h @@ -0,0 +1,13 @@ +#import +#import +#import "HeaderConfig.h" + +@interface RichTextTheme : NSObject + +@property (nonatomic, strong) UIFont *baseFont; +@property (nonatomic, strong) UIColor *textColor; +@property (nonatomic, strong) HeaderConfig *headerConfig; + ++ (instancetype)defaultTheme; + +@end diff --git a/ios/theme/RichTextTheme.m b/ios/theme/RichTextTheme.m new file mode 100644 index 00000000..5b63e73f --- /dev/null +++ b/ios/theme/RichTextTheme.m @@ -0,0 +1,13 @@ +#import "RichTextTheme.h" + +@implementation RichTextTheme + ++ (instancetype)defaultTheme { + RichTextTheme *theme = [RichTextTheme new]; + theme.baseFont = [UIFont systemFontOfSize:16]; + theme.textColor = [UIColor blackColor]; + theme.headerConfig = [HeaderConfig defaultConfig]; + return theme; +} + +@end diff --git a/package.json b/package.json index c39c365b..bbcb0ca3 100644 --- a/package.json +++ b/package.json @@ -151,14 +151,16 @@ }, "codegenConfig": { "name": "RichTextViewSpec", - "type": "all", + "type": "components", "jsSrcsDir": "src", "android": { "javaPackageName": "com.richtext" }, "ios": { - "componentProvider": { - "RichTextView": "RichTextView" + "components": { + "RichTextView": { + "className": "RichTextView" + } } } }, diff --git a/src/RichTextViewNativeComponent.ts b/src/RichTextViewNativeComponent.ts index f0e19407..42ef6f2f 100644 --- a/src/RichTextViewNativeComponent.ts +++ b/src/RichTextViewNativeComponent.ts @@ -1,7 +1,57 @@ -import { codegenNativeComponent, type ViewProps } from 'react-native'; +import { + codegenNativeComponent, + type ViewProps, + type CodegenTypes, +} from 'react-native'; + +export interface HeaderConfig { + /** + * Header scaling factor relative to base fontSize. + * @default 2.0 + * @example + * fontSize=18, scale=2.0 โ†’ H1=30pt, H2=28pt, H6=20pt + */ + scale?: CodegenTypes.Double; + /** + * Make headers bold. + * @default true + * @note fontFamily takes precedence over this setting + */ + isBold?: boolean; +} interface NativeProps extends ViewProps { - color?: string; + /** + * Markdown content to render. + * Supports standard markdown syntax including headers, links, lists, etc. + */ + markdown?: string; + /** + * Base font size for all text elements (in points). + * - Regular text, links, lists: Use fontSize directly + * - Headers: Scaled relative to fontSize using headerConfig.scale + * @example + * fontSize=18 โ†’ text=18pt, H1=30pt, H2=28pt, H6=20pt + */ + fontSize?: CodegenTypes.Int32; + /** + * Font family name for all text elements. + * @note Takes precedence over headerConfig.isBold for boldness + */ + fontFamily?: string; + /** + * Text color in hex format. + */ + textColor?: string; + /** + * Header configuration for scaling and boldness. + */ + headerConfig?: HeaderConfig; + /** + * Callback fired when a link is pressed. + * Receives the URL that was tapped. + */ + onLinkPress?: CodegenTypes.BubblingEventHandler<{ url: string }>; } export default codegenNativeComponent('RichTextView'); diff --git a/src/index.tsx b/src/index.tsx index 41259a8a..c150e130 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,2 +1,2 @@ export { default as RichTextView } from './RichTextViewNativeComponent'; -export * from './RichTextViewNativeComponent'; +export type { HeaderConfig } from './RichTextViewNativeComponent'; From 0eba284b8bcd218b949ea3bb6917116086d27c54 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 20:09:56 +0200 Subject: [PATCH 03/16] refactor: remove unnecessary pragma marks in RichTextView for cleaner code --- ios/RichTextView.mm | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ios/RichTextView.mm b/ios/RichTextView.mm index 39ba1952..f00a5113 100644 --- a/ios/RichTextView.mm +++ b/ios/RichTextView.mm @@ -51,8 +51,6 @@ - (instancetype)initWithFrame:(CGRect)frame { return self; } -#pragma mark - Setup Methods - - (void)setupTextView { _textView = [[UITextView alloc] init]; _textView.translatesAutoresizingMaskIntoConstraints = NO; @@ -223,8 +221,6 @@ - (UIColor *)hexStringToColor:(NSString *)hexString { return [UIColor colorWithRed:red green:green blue:blue alpha:1.0f]; } -#pragma mark - Touch Handling - - (void)textTapped:(UITapGestureRecognizer *)recognizer { /* * HOW LINK TAPPING WORKS: From d5288ea5255aabea01159f0db1c4a3b31145e78b Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 20:39:14 +0200 Subject: [PATCH 04/16] fix: update font size handling in RichTextView to use props for consistent typography --- ios/RichTextView.mm | 34 +++------------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/ios/RichTextView.mm b/ios/RichTextView.mm index f00a5113..ba230fd1 100644 --- a/ios/RichTextView.mm +++ b/ios/RichTextView.mm @@ -55,7 +55,7 @@ - (void)setupTextView { _textView = [[UITextView alloc] init]; _textView.translatesAutoresizingMaskIntoConstraints = NO; _textView.text = @""; - _textView.font = [UIFont systemFontOfSize:kDefaultFontSize]; + _textView.font = [UIFont systemFontOfSize:16.0]; _textView.backgroundColor = [UIColor clearColor]; _textView.textColor = [UIColor blackColor]; _textView.editable = NO; @@ -94,15 +94,8 @@ - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTe AttributedRenderer *renderer = [AttributedRenderer new]; RichTextTheme *theme = [RichTextTheme defaultTheme]; - // FONT SIZE PROPAGATION: Pass the current fontSize to the theme system - // This ensures all renderers use the same base fontSize for consistent scaling - // - theme.baseFont will be used by all text elements (paragraphs, links, lists) - // - Header renderer will scale from this base font (H1 = baseFont + 12pt, etc.) - // - All other elements inherit this base size for consistent typography - if (_textView.font) { - theme.baseFont = _textView.font; - NSLog(@"๐ŸŽจ Theme baseFont set to: %@ (size: %.1f)", _textView.font.fontName, _textView.font.pointSize); - } + CGFloat fontSize = props.fontSize > 0 ? props.fontSize : kDefaultFontSize; + theme.baseFont = [UIFont systemFontOfSize:fontSize]; if (_textView.textColor) { theme.textColor = _textView.textColor; } const auto &headerConfig = props.headerConfig; @@ -121,7 +114,6 @@ - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTe // Add custom attribute for link detection [attributedText addAttribute:@"linkURL" value:url range:range]; - NSLog(@"RichTextView: Added link %@ at range %@", url, NSStringFromRange(range)); } _textView.attributedText = attributedText; @@ -134,63 +126,43 @@ - (void)updateProps:(Props::Shared const &)props BOOL needsRerender = NO; - // Handle markdown content changes if (oldViewProps.markdown != newViewProps.markdown) { NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; [self renderMarkdownContent:markdownString withProps:newViewProps]; needsRerender = YES; } - // Background color is always transparent - no color prop needed - - // Handle text color changes if (oldViewProps.textColor != newViewProps.textColor) { NSString *textColorString = [[NSString alloc] initWithUTF8String:newViewProps.textColor.c_str()]; _textView.textColor = [self hexStringToColor:textColorString]; needsRerender = YES; } - // Handle font size changes - // FONT SIZE SCALING: This fontSize becomes the base size for all text elements - // - Regular text: Uses fontSize directly (e.g., 18pt) - // - Headers: Scaled relative to fontSize (H1 = fontSize + 12, H2 = fontSize + 10, etc.) - // - Links: Use fontSize directly - // - Lists: Use fontSize directly - // - All other elements: Use fontSize as base reference if (oldViewProps.fontSize != newViewProps.fontSize) { CGFloat fontSize = newViewProps.fontSize > 0 ? newViewProps.fontSize : kDefaultFontSize; _textView.font = [UIFont systemFontOfSize:fontSize]; needsRerender = YES; } - // Handle font family changes if (oldViewProps.fontFamily != newViewProps.fontFamily) { NSString *fontFamily = [[NSString alloc] initWithUTF8String:newViewProps.fontFamily.c_str()]; CGFloat currentSize = _textView.font.pointSize; UIFont *newFont = [UIFont fontWithName:fontFamily size:currentSize]; if (newFont) { _textView.font = newFont; - NSLog(@"โœ… FontFamily applied: %@ (size: %.1f)", fontFamily, currentSize); needsRerender = YES; } else { - NSLog(@"โŒ FontFamily not found: %@, falling back to system font", fontFamily); - NSLog(@"๐Ÿ’ก Try these iOS font names: Helvetica, Helvetica-Bold, Arial-BoldMT, TimesNewRomanPSMT"); // Fallback to system font with the same size _textView.font = [UIFont systemFontOfSize:currentSize]; needsRerender = YES; } } - // Handle header config changes if (oldViewProps.headerConfig.scale != newViewProps.headerConfig.scale || oldViewProps.headerConfig.isBold != newViewProps.headerConfig.isBold) { needsRerender = YES; - NSLog(@"๐ŸŽ›๏ธ HeaderConfig changed: scale %.1f->%.1f, bold %d->%d", - oldViewProps.headerConfig.scale, newViewProps.headerConfig.scale, - oldViewProps.headerConfig.isBold, newViewProps.headerConfig.isBold); } - // Re-render if any text styling changed if (needsRerender && !newViewProps.markdown.empty()) { NSString *markdownString = [[NSString alloc] initWithUTF8String:newViewProps.markdown.c_str()]; [self renderMarkdownContent:markdownString withProps:newViewProps]; From a0bf8239fac1cbcaaa31546cbad237195c6b43d4 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 20:55:48 +0200 Subject: [PATCH 05/16] fix: improve error handling and null checks in MarkdownParser --- ios/parser/MarkdownParser.mm | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/ios/parser/MarkdownParser.mm b/ios/parser/MarkdownParser.mm index 2dc58f3a..453ea26e 100644 --- a/ios/parser/MarkdownParser.mm +++ b/ios/parser/MarkdownParser.mm @@ -9,27 +9,33 @@ } MD4CContext; static void addNodeToContext(MarkdownASTNode *node, MD4CContext *context) { - if (!node) return; + if (!node || !context || !context->nodeStack) return; if (context->root == nil) { context->root = node; } else { MarkdownASTNode *parent = [context->nodeStack lastObject]; - [parent addChild:node]; + if (parent) { + [parent addChild:node]; + } } [context->nodeStack addObject:node]; } static void addInlineNodeToContext(MarkdownASTNode *node, MD4CContext *context) { - if (!node) return; + if (!node || !context || !context->nodeStack) return; MarkdownASTNode *parent = [context->nodeStack lastObject]; - [parent addChild:node]; + if (parent) { + [parent addChild:node]; + } } // MD4C callback functions // Note: All callbacks return 0 for success (continue parsing) or non-zero for error (stop parsing) static int md4c_enter_block_callback(MD_BLOCKTYPE type, void *detail, void *userdata) { + if (!userdata) return 1; + MD4CContext *context = (MD4CContext *)userdata; MarkdownASTNode *node = nil; @@ -45,6 +51,8 @@ static int md4c_enter_block_callback(MD_BLOCKTYPE type, void *detail, void *user if (detail) { MD_BLOCK_H_DETAIL *h = (MD_BLOCK_H_DETAIL *)detail; NSInteger level = (NSInteger)h->level; + // Clamp level to valid range (1-6) + level = MAX(1, MIN(6, level)); [node setAttribute:@"level" value:@(level).stringValue]; } break; @@ -53,12 +61,17 @@ static int md4c_enter_block_callback(MD_BLOCKTYPE type, void *detail, void *user return 0; } - addNodeToContext(node, context); + if (node) { + addNodeToContext(node, context); + } return 0; } static int md4c_leave_block_callback(MD_BLOCKTYPE type, void *detail, void *userdata) { + if (!userdata) return 1; + MD4CContext *context = (MD4CContext *)userdata; + if (!context || !context->nodeStack) return 1; if ([context->nodeStack count] > 0) { [context->nodeStack removeLastObject]; @@ -68,6 +81,8 @@ static int md4c_leave_block_callback(MD_BLOCKTYPE type, void *detail, void *user } static int md4c_enter_span_callback(MD_SPANTYPE type, void *detail, void *userdata) { + if (!userdata) return 1; + MD4CContext *context = (MD4CContext *)userdata; MarkdownASTNode *node = nil; @@ -80,7 +95,9 @@ static int md4c_enter_span_callback(MD_SPANTYPE type, void *detail, void *userda NSString *url = [[NSString alloc] initWithBytes:linkDetail->href.text length:linkDetail->href.size encoding:NSUTF8StringEncoding]; - [node setAttribute:@"url" value:url]; + if (url) { + [node setAttribute:@"url" value:url]; + } } } break; @@ -89,12 +106,17 @@ static int md4c_enter_span_callback(MD_SPANTYPE type, void *detail, void *userda return 0; } - addNodeToContext(node, context); + if (node) { + addNodeToContext(node, context); + } return 0; } static int md4c_leave_span_callback(MD_SPANTYPE type, void *detail, void *userdata) { + if (!userdata) return 1; + MD4CContext *context = (MD4CContext *)userdata; + if (!context || !context->nodeStack) return 1; if ([context->nodeStack count] > 0) { [context->nodeStack removeLastObject]; @@ -104,7 +126,10 @@ static int md4c_leave_span_callback(MD_SPANTYPE type, void *detail, void *userda } static int md4c_text_callback(MD_TEXTTYPE type, const MD_CHAR *text, MD_SIZE size, void *userdata) { + if (!userdata) return 1; + MD4CContext *context = (MD4CContext *)userdata; + if (!context || !context->nodeStack) return 1; // Handle soft/hard line breaks (MD4C provides these for explicit line breaks within paragraphs) // Note: MD4C does NOT provide empty lines between blocks - those are added by renderers @@ -114,11 +139,11 @@ static int md4c_text_callback(MD_TEXTTYPE type, const MD_CHAR *text, MD_SIZE siz return 0; } - if (size > 0) { + if (size > 0 && text) { NSString *textString = [[NSString alloc] initWithBytes:text length:size encoding:NSUTF8StringEncoding]; - if (textString) { + if (textString && textString.length > 0) { MarkdownASTNode *textNode = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeText]; textNode.content = textString; addInlineNodeToContext(textNode, context); @@ -131,7 +156,7 @@ static int md4c_text_callback(MD_TEXTTYPE type, const MD_CHAR *text, MD_SIZE siz @implementation MarkdownParser - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown { - if (!markdown.length) { + if (!markdown || markdown.length == 0) { return [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; } @@ -141,7 +166,7 @@ - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown { .nodeStack = [[NSMutableArray alloc] init] }; - // Configure MD4C parser + // Configure MD4C parser with callbacks MD_PARSER parser = { .enter_block = md4c_enter_block_callback, .leave_block = md4c_leave_block_callback, @@ -152,8 +177,13 @@ - (MarkdownASTNode *)parseMarkdown:(NSString *)markdown { .syntax = NULL }; - // Parse the markdown + // Parse the markdown with proper error handling const char *markdownCString = markdown.UTF8String; + if (!markdownCString) { + NSLog(@"MarkdownParser: Failed to convert markdown to UTF-8"); + return [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; + } + MD_SIZE markdownLength = (MD_SIZE)strlen(markdownCString); int result = md_parse(markdownCString, markdownLength, &parser, &context); From dad7174dd2bf81de0249ac987009597eb29b99a1 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:20:11 +0200 Subject: [PATCH 06/16] feat: add custom font handling in RichTextView --- ios/RichTextView.mm | 51 ++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/ios/RichTextView.mm b/ios/RichTextView.mm index ba230fd1..d6edb5e2 100644 --- a/ios/RichTextView.mm +++ b/ios/RichTextView.mm @@ -24,6 +24,7 @@ - (void)setupTextView; - (void)setupConstraints; - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTextViewProps &)props; - (void)textTapped:(UITapGestureRecognizer *)recognizer; +- (UIFont *)createFontWithFamily:(NSString *)fontFamily size:(CGFloat)size; @end @implementation RichTextView { @@ -31,11 +32,15 @@ @implementation RichTextView { MarkdownParser * _parser; } +// MARK: - Component utils + + (ComponentDescriptorProvider)componentDescriptorProvider { return concreteComponentDescriptorProvider(); } +// MARK: - Init + - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { static const auto defaultProps = std::make_shared(); @@ -84,6 +89,8 @@ - (void)setupConstraints { ]]; } +// MARK: - Rendering + - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTextViewProps &)props { MarkdownASTNode *ast = [_parser parseMarkdown:markdownString]; if (!ast) { @@ -95,7 +102,10 @@ - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTe RichTextTheme *theme = [RichTextTheme defaultTheme]; CGFloat fontSize = props.fontSize > 0 ? props.fontSize : kDefaultFontSize; - theme.baseFont = [UIFont systemFontOfSize:fontSize]; + + // Create font with family and size + NSString *fontFamily = [[NSString alloc] initWithUTF8String:props.fontFamily.c_str()]; + theme.baseFont = [self createFontWithFamily:fontFamily size:fontSize]; if (_textView.textColor) { theme.textColor = _textView.textColor; } const auto &headerConfig = props.headerConfig; @@ -119,6 +129,8 @@ - (void)renderMarkdownContent:(NSString *)markdownString withProps:(const RichTe _textView.attributedText = attributedText; } +// MARK: - Props + - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps { const auto &oldViewProps = *std::static_pointer_cast(_props); @@ -138,24 +150,11 @@ - (void)updateProps:(Props::Shared const &)props needsRerender = YES; } - if (oldViewProps.fontSize != newViewProps.fontSize) { + if (oldViewProps.fontSize != newViewProps.fontSize || oldViewProps.fontFamily != newViewProps.fontFamily) { CGFloat fontSize = newViewProps.fontSize > 0 ? newViewProps.fontSize : kDefaultFontSize; - _textView.font = [UIFont systemFontOfSize:fontSize]; - needsRerender = YES; - } - - if (oldViewProps.fontFamily != newViewProps.fontFamily) { NSString *fontFamily = [[NSString alloc] initWithUTF8String:newViewProps.fontFamily.c_str()]; - CGFloat currentSize = _textView.font.pointSize; - UIFont *newFont = [UIFont fontWithName:fontFamily size:currentSize]; - if (newFont) { - _textView.font = newFont; - needsRerender = YES; - } else { - // Fallback to system font with the same size - _textView.font = [UIFont systemFontOfSize:currentSize]; - needsRerender = YES; - } + _textView.font = [self createFontWithFamily:fontFamily size:fontSize]; + needsRerender = YES; } if (oldViewProps.headerConfig.scale != newViewProps.headerConfig.scale || @@ -193,6 +192,8 @@ - (UIColor *)hexStringToColor:(NSString *)hexString { return [UIColor colorWithRed:red green:green blue:blue alpha:1.0f]; } +// MARK: - Gesture handling + - (void)textTapped:(UITapGestureRecognizer *)recognizer { /* * HOW LINK TAPPING WORKS: @@ -266,5 +267,21 @@ - (void)textTapped:(UITapGestureRecognizer *)recognizer { } } +// MARK: - Helper methods + +- (UIFont *)createFontWithFamily:(NSString *)fontFamily size:(CGFloat)size { + if (fontFamily && fontFamily.length > 0) { + UIFont *customFont = [UIFont fontWithName:fontFamily size:size]; + if (customFont) { + NSLog(@"๐ŸŽจ FontFamily applied: %@ (size: %.1f)", fontFamily, size); + return customFont; + } else { + NSLog(@"โš ๏ธ FontFamily not found: %@, falling back to system font", fontFamily); + } + } + + NSLog(@"๐ŸŽจ Using system font (size: %.1f)", size); + return [UIFont systemFontOfSize:size]; +} @end From 329019a50c1cdba57d9de0aded0c2eead969b306 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:23:34 +0200 Subject: [PATCH 07/16] docs: update CONTRIBUTING.md --- CONTRIBUTING.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad3176ef..f8a9ae94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -146,8 +146,6 @@ This will test: - MD4C compilation and execution - iOS podspec configuration -> **Note**: The MD4C tests require the submodule to be properly initialized. If tests fail, make sure you've run `git submodule update --init --recursive`. - ### Commit message convention We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: From ed7cf2fc356b684cdadfcebf55e0e4430bfc13cb Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:24:43 +0200 Subject: [PATCH 08/16] refactor: remove logging for font application in RichTextView --- ios/RichTextView.mm | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ios/RichTextView.mm b/ios/RichTextView.mm index d6edb5e2..1a74b1b9 100644 --- a/ios/RichTextView.mm +++ b/ios/RichTextView.mm @@ -273,14 +273,10 @@ - (UIFont *)createFontWithFamily:(NSString *)fontFamily size:(CGFloat)size { if (fontFamily && fontFamily.length > 0) { UIFont *customFont = [UIFont fontWithName:fontFamily size:size]; if (customFont) { - NSLog(@"๐ŸŽจ FontFamily applied: %@ (size: %.1f)", fontFamily, size); return customFont; - } else { - NSLog(@"โš ๏ธ FontFamily not found: %@, falling back to system font", fontFamily); } } - NSLog(@"๐ŸŽจ Using system font (size: %.1f)", size); return [UIFont systemFontOfSize:size]; } From ef43cb1de7ab2bde28b31eef27ea296f1474434e Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:53:35 +0200 Subject: [PATCH 09/16] chore: update CI configuration to enable recursive submodule checkout and add MD4C setup for iOS --- .github/workflows/ci.yml | 15 ++++++++++++++- scripts/setup-md4c.sh | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100755 scripts/setup-md4c.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee892cb5..1cd3dc33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -37,6 +39,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -50,6 +54,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -66,6 +72,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -111,7 +119,7 @@ jobs: - name: Build example for Android env: - JAVA_OPTS: "-XX:MaxHeapSize=6g" + JAVA_OPTS: '-XX:MaxHeapSize=6g' run: | yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" @@ -127,10 +135,15 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + submodules: recursive - name: Setup uses: ./.github/actions/setup + - name: Setup MD4C for iOS + run: ./scripts/setup-md4c.sh + - name: Cache turborepo for iOS uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: diff --git a/scripts/setup-md4c.sh b/scripts/setup-md4c.sh new file mode 100755 index 00000000..3995e872 --- /dev/null +++ b/scripts/setup-md4c.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Script to copy MD4C source files for iOS build +# This ensures the MD4C submodule files are available for compilation + +set -e + +echo "Setting up MD4C for iOS build..." + +# Create md4c directory in iOS if it doesn't exist +mkdir -p ios/md4c + +# Copy MD4C source files +if [ -d "shared/MD4C/src" ]; then + echo "Copying MD4C source files..." + cp shared/MD4C/src/md4c.h ios/md4c/ + cp shared/MD4C/src/md4c.c ios/md4c/ + echo "MD4C setup complete!" +else + echo "Error: MD4C submodule not found. Please run: git submodule update --init --recursive" + exit 1 +fi From 076f5cbc508601c56471353454a3631d6801ac75 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:56:20 +0200 Subject: [PATCH 10/16] chore: update .gitignore to include MD4C generated files --- .github/workflows/ci.yml | 3 +++ .gitignore | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd3dc33..1677d601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,9 @@ jobs: - name: Setup uses: ./.github/actions/setup + - name: Initialize submodules + run: git submodule update --init --recursive + - name: Setup MD4C for iOS run: ./scripts/setup-md4c.sh diff --git a/.gitignore b/.gitignore index 67f32126..902c1fab 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,6 @@ android/generated # React Native Nitro Modules nitrogen/ + +# MD4C generated files (copied from submodule) +ios/md4c/ From 13315fced30acb7ea77460aaba7702b029308a9b Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 21:58:45 +0200 Subject: [PATCH 11/16] chore: enhance CI workflow and improve MD4C submodule initialization --- .github/workflows/ci.yml | 11 +++++++++-- scripts/setup-md4c.sh | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1677d601..36b63949 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,15 +134,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Setup uses: ./.github/actions/setup - name: Initialize submodules - run: git submodule update --init --recursive + run: | + git submodule update --init --recursive + echo "Submodule status:" + git submodule status + echo "Checking MD4C directory:" + ls -la shared/ || echo "shared directory not found" + ls -la shared/MD4C/ || echo "MD4C submodule not found" - name: Setup MD4C for iOS run: ./scripts/setup-md4c.sh diff --git a/scripts/setup-md4c.sh b/scripts/setup-md4c.sh index 3995e872..028bbf9b 100755 --- a/scripts/setup-md4c.sh +++ b/scripts/setup-md4c.sh @@ -17,6 +17,19 @@ if [ -d "shared/MD4C/src" ]; then cp shared/MD4C/src/md4c.c ios/md4c/ echo "MD4C setup complete!" else - echo "Error: MD4C submodule not found. Please run: git submodule update --init --recursive" - exit 1 + echo "Error: MD4C submodule not found." + echo "Attempting to initialize submodule..." + git submodule update --init --recursive shared/MD4C + + if [ -d "shared/MD4C/src" ]; then + echo "Submodule initialized successfully, copying files..." + cp shared/MD4C/src/md4c.h ios/md4c/ + cp shared/MD4C/src/md4c.c ios/md4c/ + echo "MD4C setup complete!" + else + echo "Error: Failed to initialize MD4C submodule" + echo "Available directories:" + ls -la shared/ || echo "shared directory not found" + exit 1 + fi fi From 478f8ffaa5742440ddf79fb15bfb27635f49e858 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 22:06:27 +0200 Subject: [PATCH 12/16] chore: simplify CI workflow by removing MD4C setup --- .github/workflows/ci.yml | 25 +------------------------ scripts/setup-md4c.sh | 35 ----------------------------------- 2 files changed, 1 insertion(+), 59 deletions(-) delete mode 100755 scripts/setup-md4c.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b63949..e873b067 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -39,8 +37,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -54,8 +50,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -72,8 +66,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - submodules: recursive - name: Setup uses: ./.github/actions/setup @@ -134,26 +126,11 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup uses: ./.github/actions/setup - - name: Initialize submodules - run: | - git submodule update --init --recursive - echo "Submodule status:" - git submodule status - echo "Checking MD4C directory:" - ls -la shared/ || echo "shared directory not found" - ls -la shared/MD4C/ || echo "MD4C submodule not found" - - - name: Setup MD4C for iOS - run: ./scripts/setup-md4c.sh - - name: Cache turborepo for iOS uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: diff --git a/scripts/setup-md4c.sh b/scripts/setup-md4c.sh deleted file mode 100755 index 028bbf9b..00000000 --- a/scripts/setup-md4c.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash - -# Script to copy MD4C source files for iOS build -# This ensures the MD4C submodule files are available for compilation - -set -e - -echo "Setting up MD4C for iOS build..." - -# Create md4c directory in iOS if it doesn't exist -mkdir -p ios/md4c - -# Copy MD4C source files -if [ -d "shared/MD4C/src" ]; then - echo "Copying MD4C source files..." - cp shared/MD4C/src/md4c.h ios/md4c/ - cp shared/MD4C/src/md4c.c ios/md4c/ - echo "MD4C setup complete!" -else - echo "Error: MD4C submodule not found." - echo "Attempting to initialize submodule..." - git submodule update --init --recursive shared/MD4C - - if [ -d "shared/MD4C/src" ]; then - echo "Submodule initialized successfully, copying files..." - cp shared/MD4C/src/md4c.h ios/md4c/ - cp shared/MD4C/src/md4c.c ios/md4c/ - echo "MD4C setup complete!" - else - echo "Error: Failed to initialize MD4C submodule" - echo "Available directories:" - ls -la shared/ || echo "shared directory not found" - exit 1 - fi -fi From 1eca5047f078e4076e03c96c72e1cfbefb296e4c Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sun, 19 Oct 2025 22:08:01 +0200 Subject: [PATCH 13/16] chore: remove MD4C generated files from .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 902c1fab..67f32126 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,3 @@ android/generated # React Native Nitro Modules nitrogen/ - -# MD4C generated files (copied from submodule) -ios/md4c/ From 6edf35a62629855ba431aa1a4aeee577d17422aa Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 20 Oct 2025 16:10:06 +0200 Subject: [PATCH 14/16] chore: comment out Android and iOS build steps in CI workflow for simplification --- .github/workflows/ci.yml | 214 +++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e873b067..1ad6be0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,110 +57,110 @@ jobs: - name: Build package run: yarn prepare - build-android: - runs-on: ubuntu-latest - - env: - TURBO_CACHE_DIR: .turbo/android - - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup - uses: ./.github/actions/setup - - - name: Cache turborepo for Android - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: ${{ env.TURBO_CACHE_DIR }} - key: ${{ runner.os }}-turborepo-android-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turborepo-android- - - - name: Check turborepo cache for Android - run: | - TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:android').cache.status") - - if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then - echo "turbo_cache_hit=1" >> $GITHUB_ENV - fi - - - name: Install JDK - if: env.turbo_cache_hit != 1 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - with: - distribution: 'zulu' - java-version: '17' - - - name: Finalize Android SDK - if: env.turbo_cache_hit != 1 - run: | - /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" - - - name: Cache Gradle - if: env.turbo_cache_hit != 1 - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: | - ~/.gradle/wrapper - ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Build example for Android - env: - JAVA_OPTS: '-XX:MaxHeapSize=6g' - run: | - yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" - - build-ios: - runs-on: macos-latest - - env: - XCODE_VERSION: 16.3 - TURBO_CACHE_DIR: .turbo/ios - RCT_USE_RN_DEP: 1 - RCT_USE_PREBUILT_RNCORE: 1 - - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup - uses: ./.github/actions/setup - - - name: Cache turborepo for iOS - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: ${{ env.TURBO_CACHE_DIR }} - key: ${{ runner.os }}-turborepo-ios-${{ hashFiles('yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turborepo-ios- - - - name: Check turborepo cache for iOS - run: | - TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:ios').cache.status") - - if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then - echo "turbo_cache_hit=1" >> $GITHUB_ENV - fi - - - name: Use appropriate Xcode version - if: env.turbo_cache_hit != 1 - uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 - with: - xcode-version: ${{ env.XCODE_VERSION }} - - - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' - run: | - cd example - bundle install - bundle exec pod repo update --verbose - bundle exec pod install --project-directory=ios - - - name: Build example for iOS - run: | - yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" + # build-android: + # runs-on: ubuntu-latest + + # env: + # TURBO_CACHE_DIR: .turbo/android + + # steps: + # - name: Checkout + # uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # - name: Setup + # uses: ./.github/actions/setup + + # - name: Cache turborepo for Android + # uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + # with: + # path: ${{ env.TURBO_CACHE_DIR }} + # key: ${{ runner.os }}-turborepo-android-${{ hashFiles('yarn.lock') }} + # restore-keys: | + # ${{ runner.os }}-turborepo-android- + + # - name: Check turborepo cache for Android + # run: | + # TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:android').cache.status") + + # if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then + # echo "turbo_cache_hit=1" >> $GITHUB_ENV + # fi + + # - name: Install JDK + # if: env.turbo_cache_hit != 1 + # uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + # with: + # distribution: 'zulu' + # java-version: '17' + + # - name: Finalize Android SDK + # if: env.turbo_cache_hit != 1 + # run: | + # /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null" + + # - name: Cache Gradle + # if: env.turbo_cache_hit != 1 + # uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + # with: + # path: | + # ~/.gradle/wrapper + # ~/.gradle/caches + # key: ${{ runner.os }}-gradle-${{ hashFiles('example/android/gradle/wrapper/gradle-wrapper.properties') }} + # restore-keys: | + # ${{ runner.os }}-gradle- + + # - name: Build example for Android + # env: + # JAVA_OPTS: '-XX:MaxHeapSize=6g' + # run: | + # yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}" + + # build-ios: + # runs-on: macos-latest + + # env: + # XCODE_VERSION: 16.3 + # TURBO_CACHE_DIR: .turbo/ios + # RCT_USE_RN_DEP: 1 + # RCT_USE_PREBUILT_RNCORE: 1 + + # steps: + # - name: Checkout + # uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # - name: Setup + # uses: ./.github/actions/setup + + # - name: Cache turborepo for iOS + # uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + # with: + # path: ${{ env.TURBO_CACHE_DIR }} + # key: ${{ runner.os }}-turborepo-ios-${{ hashFiles('yarn.lock') }} + # restore-keys: | + # ${{ runner.os }}-turborepo-ios- + + # - name: Check turborepo cache for iOS + # run: | + # TURBO_CACHE_STATUS=$(node -p "($(yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" --dry=json)).tasks.find(t => t.task === 'build:ios').cache.status") + + # if [[ $TURBO_CACHE_STATUS == "HIT" ]]; then + # echo "turbo_cache_hit=1" >> $GITHUB_ENV + # fi + + # - name: Use appropriate Xcode version + # if: env.turbo_cache_hit != 1 + # uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + # with: + # xcode-version: ${{ env.XCODE_VERSION }} + + # - name: Install cocoapods + # if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' + # run: | + # cd example + # bundle install + # bundle exec pod repo update --verbose + # bundle exec pod install --project-directory=ios + + # - name: Build example for iOS + # run: | + # yarn turbo run build:ios --cache-dir="${{ env.TURBO_CACHE_DIR }}" From ea01c5659b6fb65e04b4cb0d51c04ef3e96887ed Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 20 Oct 2025 16:19:20 +0200 Subject: [PATCH 15/16] docs: remove submodule troubleshooting section in CONTRIBUTING.md --- CONTRIBUTING.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8a9ae94..013003ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,44 +37,6 @@ This project uses git submodules for native dependencies. You need to initialize yarn ``` -> **Important**: The project depends on the MD4C submodule located in `shared/MD4C/`. Make sure to run the submodule initialization command before installing dependencies. - -### Troubleshooting Submodule Issues - -If you encounter issues with the submodule setup, here are common scenarios and solutions: - -**Scenario 1: Empty `shared/` directory** - -```sh -# Check if submodule is properly initialized -ls shared/MD4C/src/ -# If empty or missing, reinitialize: -git submodule update --init --recursive -``` - -**Scenario 2: `.gitmodules` exists but submodule content is missing** - -```sh -# This can happen when cloning without submodules -# Re-add the submodule manually: -git submodule add https://github.com/mity/md4c.git shared/MD4C -``` - -**Scenario 3: Submodule is in detached HEAD state** - -```sh -# Update to the latest commit -git submodule update --remote -``` - -> **Note**: If you continue having issues, try removing the submodule and re-adding it: -> -> ```sh -> git submodule deinit -f shared/MD4C -> rm -rf shared/MD4C -> git submodule add https://github.com/mity/md4c.git shared/MD4C -> ``` - > Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development without manually migrating. The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. From d71c6ff7c93cae82a16d90c31c8d76dbea1902f0 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 20 Oct 2025 16:23:26 +0200 Subject: [PATCH 16/16] refactor: clean up comments --- ios/renderer/AttributedRenderer.m | 12 +++--------- ios/renderer/RenderContext.h | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/ios/renderer/AttributedRenderer.m b/ios/renderer/AttributedRenderer.m index 02a73432..35681266 100644 --- a/ios/renderer/AttributedRenderer.m +++ b/ios/renderer/AttributedRenderer.m @@ -123,11 +123,10 @@ - (void)renderNode:(MarkdownASTNode *)node switch (child.type) { case MarkdownNodeTypeText: if (child.content) { - // Use theme.baseFont directly (no scaling for regular text) NSAttributedString *text = [[NSAttributedString alloc] initWithString:child.content attributes:@{ - NSFontAttributeName: theme.baseFont, // Direct fontSize usage + NSFontAttributeName: theme.baseFont, NSForegroundColorAttributeName: theme.textColor }]; [output appendAttributedString:text]; @@ -199,20 +198,17 @@ - (void)renderNode:(MarkdownASTNode *)node context:(RenderContext *)context { NSUInteger start = output.length; - // Render link children as text for (MarkdownASTNode *child in node.children) { if (child.type == MarkdownNodeTypeText && child.content) { - // Links use same fontSize as regular text NSAttributedString *text = [[NSAttributedString alloc] initWithString:child.content attributes:@{ - NSFontAttributeName: theme.baseFont // Same fontSize as text + NSFontAttributeName: theme.baseFont }]; [output appendAttributedString:text]; } } - // Apply link attributes to the range NSUInteger len = output.length - start; if (len > 0) { NSRange range = NSMakeRange(start, len); @@ -258,7 +254,6 @@ - (void)renderNode:(MarkdownASTNode *)node } } - // Render text children for (MarkdownASTNode *child in node.children) { if (child.type == MarkdownNodeTypeText && child.content) { NSAttributedString *text = [[NSAttributedString alloc] @@ -271,11 +266,10 @@ - (void)renderNode:(MarkdownASTNode *)node } } - // Add spacing after heading (proportional to base fontSize) NSAttributedString *spacing = [[NSAttributedString alloc] initWithString:@"\n\n" attributes:@{ - NSFontAttributeName: theme.baseFont, // Spacing proportional to fontSize + NSFontAttributeName: theme.baseFont, NSForegroundColorAttributeName: theme.textColor }]; [output appendAttributedString:spacing]; diff --git a/ios/renderer/RenderContext.h b/ios/renderer/RenderContext.h index 5af2c508..7c72ad53 100644 --- a/ios/renderer/RenderContext.h +++ b/ios/renderer/RenderContext.h @@ -1,7 +1,7 @@ #import @interface RenderContext : NSObject -@property (nonatomic, strong) NSMutableArray *linkRanges; // NSRange boxed +@property (nonatomic, strong) NSMutableArray *linkRanges; @property (nonatomic, strong) NSMutableArray *linkURLs; - (instancetype)init;