Improved MarkdownRenderer
MarkdownRenderer
A markdown-to-React-Native renderer that tokenizes a raw markdown string into typed block tokens, then maps each token to a themed block component — with inline formatting (bold/italic/strikethrough/links/mentions/hashtags), image/video previews, table rendering, and configurable truncation (by line count or character budget) all built in.
import { MarkdownRenderer } from "rn-markdown-editor/components";
<MarkdownRenderer
body={post.body}
scrollable
baseFontSize={15}
lineHeightMultiplier={1.5}
onPressLink={(href) => openBrowser(href)}
onPressUser={(username) => navigateToProfile(username)}
onPressHashtag={(tag) => navigateToTag(tag)}
maxBodyLength={280}
numberOfLines={4}
/>
How it works internally
Parsing and rendering are two separate passes. tokenize(body) turns the raw string into an array of typed Tokens (heading, paragraph, codeBlock, table, image, video, columns, etc.) once, memoized on body; a second stage (applyContentLimits) trims that token array before a third stage maps tokens to block components. None of these stages know about each other's internals — they only pass Token[] back and forth.
Truncation happens on tokens, not on rendered output. applyContentLimits walks tokens accumulating tokenTextLength, and once a token would exceed maxBodyLength it calls truncateToken to cut that one token down to the remaining character budget (lines first, then chars) and append an ellipsis, rather than truncating the whole rendered tree after the fact. This is what lets a table stop after a partial row or a list stop after a partial item instead of overflowing.
There are two entirely different rendering strategies gated by numberOfLines. When numberOfLines is set, the renderer builds one flattened <Text> tree (flatTextBlock) with inline segments joined by literal "\n", skipping anything unrenderable-as-text (images, video, tables, columns, horizontalRule) — because RN's numberOfLines truncation only works reliably on a single Text component. Without numberOfLines, it renders the full block list (blocks), where each token gets its own dedicated block component and images/video/tables render normally.
Inline parsing runs through one big alternation regex, not a chain of smaller matchers. renderInline in inline.tsx combines bold/italic/underline/strikethrough/superscript/code/image/link/mention/hashtag/bare-URL patterns into a single RegExp alternation, then dispatches on which capture group matched (m[1], m[3], m[5]…) rather than running each pattern separately over the text. Matched wrapper syntax (**, _, ~~, …) recurses back into renderInline on the captured inner text so nested emphasis (e.g. bold-inside-italic) resolves correctly.
Colors are reference-stabilized, not just value-stabilized. useStableColors keeps returning the same object reference across renders as long as every individual color field is unchanged, comparing field-by-field against a ref rather than doing a shallow prop diff — this keeps cc out of dependency arrays from invalidating memos on every render even when the caller passes a fresh colors object literal each time.
Optional callbacks are wrapped so identity changes don't blow up memoization. useStableCallback stores the latest onPressLink/onPressUser/onPressHashtag in a ref (updated via useEffect) and returns a stable wrapper function whose own identity only changes when the callback flips between defined and undefined — so passing a new inline arrow function from the parent on every render doesn't cascade into re-memoizing bp, blocks, etc.
Link handling has a platform fork baked in. openLink prefers a caller-supplied onPressLink; if none is given, it calls globalThis.open on web (with noopener,noreferrer) and Linking.openURL everywhere else, so consumers get sane default behavior without having to branch on Platform.OS themselves.
Hashtag pressability is conditional on the prop actually being passed, not just on disableLinks. bp.onPressHashtag is only populated when onPressHashtagProp$ exists; if the consumer never passed onPressHashtag, hashtags render as plain colored text with no onPress at all, rather than falling back to a no-op handler.
getFirstImage (in utils.ts) re-walks tokens independently of the renderer. Rather than hooking into render output, it re-runs tokenize on the same body and checks each token type for a src, including a separate inline-image regex for markdown images embedded inside paragraph/heading/blockquote/list/table/columns content — so it finds the same "first image" a full render would show, without rendering anything.
Key props (MarkdownRendererProps)
| Prop | Default | Description |
|---|---|---|
body | — | Raw markdown string to parse and render |
scrollable | false | Wraps output in a ScrollView |
baseFontSize | 14 | Base text size; scales code/sup/sub sizes relative to it |
lineHeightMultiplier | 1.6 | Multiplied by baseFontSize for line height |
colors | — | Per-instance color overrides (MarkdownColors), merged over theme defaults |
onPressLink / onPressUser / onPressHashtag | — | Press handlers for links, @mentions, #hashtags |
disableLinks | false | Renders links/mentions/hashtags/images-as-links as plain non-interactive text |
numberOfLines | — | Switches to single flattened <Text> mode with native line-clamping |
maxBodyLength | — | Character budget; truncates mid-token with an ellipsis once exceeded |
removeEmptyLine | false | Drops blank/HR/image/video tokens (noise removal, e.g. for previews) |
allowRenderImage | true | Skips image/imageRow tokens entirely when false |
renderVideo | true | Toggles video block rendering |
renderTable | true | Toggles table block rendering |
bodyColor | — | Overrides default text color for flat/paragraph text |
paddingHorizontal | 16 | Horizontal padding on the outer container |
imageViewerAccentColor | — | Accent color passed to the built-in ImageViewer lightbox |
isImageLoading | false | Passed through to image blocks for loading-state UI |
containerStyle | — | Merged onto the outer View/ScrollView content container |
Exported helper
| Function | Description |
|---|---|
getFirstImage(body) | Returns the URL of the first image (block-level or inline) in a markdown string, or null |