@discord/markdown-react-native
v0.7.0
Published
React Native (JSI) bindings for parsing Discord Markdown
Readme
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 static 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.
Requires the New Architecture (React Native 0.77+).
Usage
import { parse, unparse } from "@discord/markdown-react-native";
const blocks = parse("# Hello **world**");
// [{ type: "heading", value: { level: 1, content: [...] } }]
// Optionally restrict which rules parse:
const restricted = parse("**bold** _italic_", ["bold"]);
// Optionally cap how much work a parse may do:
const bounded = parse(untrustedContent, null, 50_000);
// And back again:
const markdown = unparse(blocks);
// "# Hello **world**"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.
unparse is the inverse, and also runs synchronously. It is not byte-exact for everything,
because the AST does not retain every detail of how the source was written: text is not
re-escaped, italics always come back as _, and unordered lists always as * . Everything else
round trips exactly.
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.
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_unparse).parseToAstStringandunparseFromAstStringare 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. The Gradle project here only runs codegen (and cargo, in-repo). - iOS:
codegenConfig.ios.modulesProvidermaps the module toios/DiscordMarkdownModuleProvider.mm.
- Android:
- The JS wrapper decodes the returned JSON. 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.unparsere-applies them on the way out, sinceJSON.stringifyrefuses to write aBigInt.
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 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-ndkiOS (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-iosJS 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 testExample 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.xcworkspaceEnd-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. 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.
