markdown-react-native
React Native bindings for the Discord Markdown parser (@discord/markdown-react-native),
implemented as a pure C++ TurboModule. The Rust parser is compiled to a native library
(ffi/c) and linked into a single C++ module
(cpp/NativeDiscordMarkdown.cpp) that both platforms share;
there is no per-call bridge traffic and no platform-specific parser code. The same library is
also callable from Kotlin and Swift.
Requires the New Architecture (React Native 0.77+).
Usage
import { Syntax, parse } from "@discord/markdown-react-native";
const blocks = parse("# Hello **world**");
// [{ type: "heading", value: { level: 1, content: [...] } }]
// Optionally pick the syntax to parse, as an OR of `Syntax` bits (`Syntax.ALL` by default):
const restricted = parse("**bold** _italic_", Syntax.BOLD);
const noMentions = parse("@everyone **hi**", Syntax.ALL & ~Syntax.MENTIONS);
// Optionally cap how much work a parse may do:
const bounded = parse(untrustedContent, null, 50_000);
parse runs synchronously on the JS thread and returns the same typed AST
(Block[] from @discord/markdown-types) as the wasm binding, so rendering code
can be shared between web and native. Snowflake IDs and timestamp values are BigInts, also
matching the wasm binding.
Because the parse blocks the JS thread, the third argument is worth knowing about: it is the
parser's fuel budget, which bounds the work an adversarial input can cause. Omitting it takes a
default sized for message-length content with room to spare, so most callers should. Content
that exhausts the budget throws parser ran out of fuel and produces no partial AST — a budget
set too low rejects legitimate content, so measure your own before tightening it.
For rich text, parseLossless(content, flags?, fuel?) returns the lossless tree instead: nodes
for what the markdown means, and tokens holding every character of content in order, down to
delimiters, escapes, markers and line endings, so joining their text gives it back exactly.
Every node and token has a span in UTF-16 code units, so content.slice(span.start, span.end)
is its text. The types are LosslessNode and friends in @discord/markdown-types.
Native usage (Kotlin and Swift)
Installing the package also gives the app's own native code a direct line to the parser, with no JS runtime involved. Each platform gets a small wrapper over the same parser the TurboModule uses:
import com.discord.markdown.DiscordMarkdown
val ast: String = DiscordMarkdown.parse("# Hello **world**")
val restricted = DiscordMarkdown.parse("**bold** _italic_", flags = DiscordMarkdown.Syntax.BOLD)
import DiscordMarkdown
let ast: String = try DiscordMarkdownParser.parse("# Hello **world**")
let restricted = try DiscordMarkdownParser.parse("**bold** _italic_", syntax: .bold)
Both return the AST as a JSON string, in the same shape as JS's except for 64-bit integers.
Those are plain JSON numbers at full precision, not {"$bigint": "…"} objects. Snowflakes are unsigned and may exceed Long.MAX_VALUE, so
decode them as UInt64 (Swift's JSONDecoder) or ULong (kotlinx.serialization). Timestamps
are signed: decode them as Int64 / Long. Android's built-in org.json is not enough, because
it reads any integer past Long.MAX_VALUE as a Double. Both take the same optional fuel budget as the JS
parse. Failures throw DiscordMarkdownException (Kotlin) or DiscordMarkdownError (Swift),
carrying the parser's message. Calls are synchronous and keep no state, so they are safe from
any thread.
parseLossless (Kotlin) and DiscordMarkdownParser.parseLossless (Swift) return the lossless
tree as JSON, with the same integers. Its spans count UTF-16 code units, so they index a Kotlin
String or Spannable directly, and in Swift are an NSRange into the content as an
NSString.
The wrappers live in android/src/main/java/com/discord/markdown/DiscordMarkdown.kt
(over JNI entry points in ffi/jni) and
ios/DiscordMarkdownParser.swift (over the C ABI, through the
DiscordMarkdownC module map in the xcframework). They ship inside the React Native package, so
they are only available to targets that link it: an iOS app extension, for example, cannot use
them without the React Native pod.
How it works
src/NativeDiscordMarkdown.tsis the TurboModule spec; codegen (configured throughcodegenConfiginpackage.json) generates the typed C++ base classNativeDiscordMarkdownCxxSpec.cpp/NativeDiscordMarkdown.cppimplements the spec by calling the Rust parser's C ABI (dmd_parseanddmd_parse_lossless).parseToAstStringandparseLosslessToAstStringare synchronous JSI methods — no bridge, no async hop.- Registration is declarative, with no app changes required:
- Android:
react-native.config.cjspoints autolinking atandroid/CMakeLists.txt; the generatedautolinking.cppinstantiates the module, and the C++ compiles inside the app's native build, linked against the Rust parser aslibdiscord_markdown_jni.so(ffi/jni, which re-exports the C ABI alongside its JNI entry points). The Android Gradle plugin packages that shared library into the APK, where the Kotlin wrapper loads the same copy. The Gradle project here runs codegen and builds the Kotlin wrapper (and runs cargo, in-repo). - iOS:
codegenConfig.ios.modulesProvidermaps the module toios/DiscordMarkdownModuleProvider.mm.
- Android:
- The JS wrapper decodes the returned JSON. The TurboModule passes the C ABI's
js_bigintsflag, so 64-bit integers cross the boundary as{"$bigint": "…"}wrappers (seeffi/c/src/bigint.rs) and are revived asBigInts, since a plainJSON.parsewould corrupt them as float64s. The Kotlin and Swift wrappers leave the flag off and get plain numbers.
Building
Inside this repo, the native builds drive cargo themselves; consumers from npm receive the
Rust parser prebuilt (android/prebuilt/, ios/DiscordMarkdownC.xcframework), built at
publish time.
Android (Gradle runs cargo-ndk on ffi/jni, for
armeabi-v7a, arm64-v8a, x86, and x86_64, or the subset in reactNativeArchitectures):
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
cargo install cargo-ndk
iOS (the example app's Podfile runs ios/build-rust.sh, which also
assembles the vendored xcframework; re-run it after changing the parser):
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
JS tests (the wrapper and the decoding layer; the parser and its C ABI, including the fuel
argument, are tested in cargo test -p discord-markdown-c):
pnpm test
Example app
example/ is a React Native playground app (a workspace package, like
site for web): a live markdown → AST view over the native parser, for manual
verification and development. From the repo root:
pnpm install
cd lib/react-native/example
pnpm android # or open example/android in Android Studio
cd ios && bundle exec pod install # then: pnpm ios, or open MarkdownExample.xcworkspace
End-to-end tests
The e2e_react_native CI jobs build the example app for
release on both platforms — exercising codegen, autolinking, and the in-repo Rust builds —
then drive it on an Android emulator and an iOS simulator with
Maestro (example/.maestro/parse.yaml),
asserting on the AST the app renders: default content parses to a heading with a
full-precision BigInt mention id, and typed content re-parses live. The app's
MainActivity.kt and AppDelegate.swift also round-trip content through the Kotlin or Swift
wrapper at launch, and the flow checks the result the app reports. They then pack the npm
tarball and assert the prebuilt Rust artifacts ship in it. The same flow runs locally with
maestro test -e APP_ID=… .maestro/parse.yaml against any running emulator or simulator.
Known limitations
- New Architecture only. On the old architecture
TurboModuleRegistry.getEnforcingthrows at import time. - Worklet runtimes (Reanimated, etc.) are separate JSI runtimes;
parseis only callable from the main JS runtime.