react-native-markdown-lite
v1.0.0
Published
Dependency-free markdown renderer for React Native, with a pure parser you can use on its own
Maintainers
Readme
react-native-markdown-lite
Description
A markdown renderer for React Native with no runtime dependencies, and a pure parser you can use without the renderer.
It was written for screens that show a document the app did not write — a licence text, a changelog, a README, a remote note — where the markdown is incidental and the budget for it is small. It renders to Text and View, and nothing else.
Features
- Zero runtime dependencies. About 7.5 kB gzipped for the renderer and the parser together, 5.7 kB for the parser alone. Metro does not tree-shake, so what a package weighs is what the app carries.
- A parser you can use on its own.
react-native-markdown-lite/parserimports neither React nor React Native, so it runs in Node, in a test or in a worker. - Styled by you. Every element takes its style from a
stylesprop merged over the defaults, key by key — no CSS-in-string layer and no theme object to learn, so an app's existing design tokens go straight in. - Safe with documents you did not write. Only expected URL schemes open, nesting is bounded, and nothing is fetched — see Untrusted documents.
- Accessible by default. Headings, links and image alts carry the roles a screen reader needs to navigate the document.
- TypeScript throughout, with the document model exported.
- ESM and CommonJS, with types for both.
Install
npm install react-native-markdown-litePeers: react >= 18, react-native >= 0.70. Nothing else.
Usage
import { Markdown } from 'react-native-markdown-lite';
const Screen = () => (
<ScrollView>
<Markdown value={'# Title\n\nSome **bold** text and a [link](https://example.com).'} />
</ScrollView>
);Styling
styles is layered on top of the defaults the way React Native composes a style array, so you only pass the properties you want to change and everything else stays put — including the layout an element is built from, like the row a list item needs to keep its bullet beside the text:
<Markdown
value={document}
styles={{
text: { color: theme.colors.onSurface, fontSize: 16 },
heading1: { color: theme.colors.onSurface, fontWeight: '700' },
link: { color: theme.colors.primary },
codeBlock: { backgroundColor: theme.colors.surfaceVariant },
}}
/>| Key | Applies to |
|---|---|
| body | the outer container (view) |
| text | every piece of text; the base the rest inherit from |
| paragraph, heading1…heading6 | block text |
| strong, emphasis, code, link, imageAlt | inline text |
| codeBlock (view), codeBlockText | fenced and indented code |
| list (view), listItem (view), listItemContent (view), marker | lists, their bullets or numbers, and the column the item's text lives in |
| blockquote (view) | quoted blocks |
| rule (view) | thematic breaks |
Props
| Prop | Type | Default | |
|---|---|---|---|
| value | string | — | the markdown source |
| styles | MarkdownStyles | {} | layered over the defaults |
| options | ParseOptions | { linkify: true } | parser options |
| onLinkPress | (href: string) => void | Linking.openURL | called instead of opening the link, with the href exactly as written |
| selectable | boolean | false | lets the reader select and copy |
| testID | string | — | passed to the container |
The parser on its own
import { parseMarkdown } from 'react-native-markdown-lite/parser';
parseMarkdown('# Title\n\n- one\n- two');
// [
// { type: 'heading', level: 1, children: [{ type: 'text', value: 'Title' }] },
// { type: 'list', ordered: false, start: 1, items: [ … ] },
// ]The document model is exported as MdDocument, MdBlock and MdInline, so a renderer of your own — a slide deck, a summariser, a search indexer — can walk it without reimplementing the parsing.
Untrusted documents
This package exists to render a document the app did not write — a licence text, a changelog, a remote note — so the three ways such a document could misbehave are closed off:
- Only
http,https,mailtoandtellinks open. A document cannot reach the OS withintent://orfile://, or fire a deep link back into your own app. The link still renders and still responds to a tap; it is only the default handler that refuses. PassonLinkPressand you receive every href exactly as written, including the refused ones, and decide for yourself. - Nesting is bounded. Blocks and inline spans stop being given structure past 32 levels and the rest is kept as text, so a file of nothing but
>characters cannot overflow the stack. - Nothing is fetched. Images render as their alt text.
Text is never executed or interpolated anywhere — it only ever becomes the children of a Text — so there is no injection surface to speak of beyond the above.
What it supports
ATX (# …) and setext (===) headings · paragraphs with soft and hard line breaks · fenced and indented code blocks · bullet and ordered lists, nested · blockquotes · thematic breaks · **bold**, *italic*, `code` · inline, reference and shortcut links · autolinks and bare URLs · images as alt text · backslash escapes.
What it does not
This is a pragmatic subset, not a CommonMark implementation. It is worth knowing which corners were cut and why:
- Tables are not parsed. They render as paragraphs with the pipes intact, which is honest but not pretty. A table is rarely legible at phone width anyway.
- Raw HTML is dropped, tags and comments alike, and the text they wrap is kept. React Native has no element to map
<div>onto. - Images are never loaded. The alt text renders instead.
- Emphasis uses a simpler rule than CommonMark's delimiter stack.
***both***falls back to literal text. - Footnotes, task lists, strikethrough and definition lists are not supported.
_inside a word never opens emphasis, on purpose:snake_caseand__dirnamemust survive.- Nesting stops at 32 levels. Past that the remaining text is kept, but flat. No real document comes close.
If your documents need any of these, reach for a full CommonMark implementation instead — this one trades coverage for weight, and that trade is only worth it when the subset is enough.
Contributing
npm ci
npm run verify # lint, typecheck, tests, build and a smoke test of the built artifactThe parser is tested in plain Node; the renderer is tested against a stub of the three React Native modules it touches, so the test run needs neither Metro nor the React Native jest preset. npm run smoke runs the built artifact the way a consumer resolves it, including the legacy parser/ path that bundlers without package exports take.
Releasing
Publishing takes two steps on purpose. CI can stage a release on its own, but making it public needs a person:
- Bump
versioninpackage.json, write theCHANGELOG.mdentry, and merge tomain. - Tag
vX.Y.Zand create the GitHub release. CI checks the tag againstpackage.json, builds, smoke-tests the artifact and runsnpm stage publish. The version is now in the registry but not installable. - Approve it, which is the step that asks for 2FA:
npm stage list react-native-markdown-lite # find the stage id
npm stage download <stage-id> # optional: inspect the exact tarball
npm stage approve <stage-id> # publishes itnpm stage reject <stage-id> throws the staged version away if something looks wrong, and the version number stays free.
This is why there is no npm token in this repository: the trusted publisher may stage, and only a human with 2FA can publish.
License
MIT © Gabriel Galilea
