├── .npmignore ├── .babelrc ├── .gitignore ├── doc └── images │ ├── ios-1.png │ ├── ios-10.png │ ├── ios-2.png │ ├── ios-3.png │ ├── ios-4.png │ ├── ios-5.png │ ├── ios-6.png │ ├── ios-7.png │ ├── ios-8.png │ ├── ios-9.png │ ├── android-1.png │ ├── android-2.png │ ├── android-3.png │ ├── android-4.png │ ├── android-5.png │ ├── android-6.png │ ├── android-7.png │ ├── android-8.png │ ├── android-9.png │ ├── android-10.png │ └── style-example.png ├── .prettierrc.js ├── src ├── lib │ ├── util │ │ ├── getUniqueID.js │ │ ├── Token.js │ │ ├── hasParents.js │ │ ├── stringToTokens.js │ │ ├── openUrl.js │ │ ├── splitTextNonTextNodes.js │ │ ├── removeTextStyleProps.js │ │ ├── renderInlineAsText.js │ │ ├── flattenInlineTokens.js │ │ ├── convertAdditionalStyles.js │ │ ├── groupTextTokens.js │ │ ├── getTokenTypeByToken.js │ │ ├── omitListItemParagraph.js │ │ ├── cleanupTokens.js │ │ └── tokensToAST.js │ ├── data │ │ └── textStyleProps.js │ ├── parser.js │ ├── styles.js │ ├── AstRenderer.js │ └── renderRules.js ├── index.d.ts └── index.js ├── .github └── workflows │ ├── checks.yml │ └── npm-publish.yml ├── .eslintrc.js ├── LICENSE ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | /example 2 | /.idea 3 | /bin 4 | /docs 5 | /doc 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "module:metro-react-native-babel-preset" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | /.DS_Store 4 | yarn-error.log 5 | yarn.lock 6 | .eslintcache 7 | -------------------------------------------------------------------------------- /doc/images/ios-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-1.png -------------------------------------------------------------------------------- /doc/images/ios-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-10.png -------------------------------------------------------------------------------- /doc/images/ios-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-2.png -------------------------------------------------------------------------------- /doc/images/ios-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-3.png -------------------------------------------------------------------------------- /doc/images/ios-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-4.png -------------------------------------------------------------------------------- /doc/images/ios-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-5.png -------------------------------------------------------------------------------- /doc/images/ios-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-6.png -------------------------------------------------------------------------------- /doc/images/ios-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-7.png -------------------------------------------------------------------------------- /doc/images/ios-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-8.png -------------------------------------------------------------------------------- /doc/images/ios-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/ios-9.png -------------------------------------------------------------------------------- /doc/images/android-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-1.png -------------------------------------------------------------------------------- /doc/images/android-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-2.png -------------------------------------------------------------------------------- /doc/images/android-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-3.png -------------------------------------------------------------------------------- /doc/images/android-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-4.png -------------------------------------------------------------------------------- /doc/images/android-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-5.png -------------------------------------------------------------------------------- /doc/images/android-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-6.png -------------------------------------------------------------------------------- /doc/images/android-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-7.png -------------------------------------------------------------------------------- /doc/images/android-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-8.png -------------------------------------------------------------------------------- /doc/images/android-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-9.png -------------------------------------------------------------------------------- /doc/images/android-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/android-10.png -------------------------------------------------------------------------------- /doc/images/style-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RonRadtke/react-native-markdown-display/HEAD/doc/images/style-example.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | useTabs: false, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/util/getUniqueID.js: -------------------------------------------------------------------------------- 1 | let uuid = new Date().getTime(); 2 | 3 | export default function getUniqueID() { 4 | uuid++; 5 | return `rnmr_${uuid.toString(16)}`; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/util/Token.js: -------------------------------------------------------------------------------- 1 | export default class Token { 2 | constructor(type, nesting = 0, children = null, block = false) { 3 | this.type = type; 4 | this.nesting = nesting; 5 | this.children = children; 6 | this.block = block; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/util/hasParents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Array} parents 4 | * @param {string} type 5 | * @return {boolean} 6 | */ 7 | export default function hasParents(parents, type) { 8 | return parents.findIndex((el) => el.type === type) > -1; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/util/stringToTokens.js: -------------------------------------------------------------------------------- 1 | export function stringToTokens(source, markdownIt) { 2 | let result = []; 3 | try { 4 | result = markdownIt.parse(source, {}); 5 | } catch (err) { 6 | console.warn(err); 7 | } 8 | 9 | return result; 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/util/openUrl.js: -------------------------------------------------------------------------------- 1 | import {Linking} from 'react-native'; 2 | 3 | export default function openUrl(url, customCallback) { 4 | if (customCallback) { 5 | const result = customCallback(url); 6 | if (url && result && typeof result === 'boolean') { 7 | Linking.openURL(url); 8 | } 9 | } else if (url) { 10 | Linking.openURL(url); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/util/splitTextNonTextNodes.js: -------------------------------------------------------------------------------- 1 | export default function splitTextNonTextNodes(children) { 2 | return children.reduce( 3 | (acc, curr) => { 4 | if (curr.type.displayName === 'Text') { 5 | acc.textNodes.push(curr); 6 | } else { 7 | acc.nonTextNodes.push(curr); 8 | } 9 | 10 | return acc; 11 | }, 12 | {textNodes: [], nonTextNodes: []}, 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/util/removeTextStyleProps.js: -------------------------------------------------------------------------------- 1 | import textStyleProps from '../data/textStyleProps'; 2 | 3 | export default function removeTextStyleProps(style) { 4 | const intersection = textStyleProps.filter((value) => 5 | Object.keys(style).includes(value), 6 | ); 7 | 8 | const obj = {...style}; 9 | 10 | intersection.forEach((value) => { 11 | delete obj[value]; 12 | }); 13 | 14 | return obj; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/util/renderInlineAsText.js: -------------------------------------------------------------------------------- 1 | export default function renderInlineAsText(tokens) { 2 | var result = ''; 3 | 4 | for (var i = 0, len = tokens.length; i < len; i++) { 5 | if (tokens[i].type === 'text') { 6 | result += tokens[i].content; 7 | } else if (tokens[i].type === 'image') { 8 | result += renderInlineAsText(tokens[i].children); 9 | } 10 | } 11 | 12 | return result; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/util/flattenInlineTokens.js: -------------------------------------------------------------------------------- 1 | export default function flattenTokens(tokens) { 2 | return tokens.reduce((acc, curr) => { 3 | if (curr.type === 'inline' && curr.children && curr.children.length > 0) { 4 | const children = flattenTokens(curr.children); 5 | while (children.length) { 6 | acc.push(children.shift()); 7 | } 8 | } else { 9 | acc.push(curr); 10 | } 11 | 12 | return acc; 13 | }, []); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/data/textStyleProps.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'textShadowOffset', 3 | 'color', 4 | 'fontSize', 5 | 'fontStyle', 6 | 'fontWeight', 7 | 'lineHeight', 8 | 'textAlign', 9 | 'textDecorationLine', 10 | 'textShadowColor', 11 | 'fontFamily', 12 | 'textShadowRadius', 13 | 'includeFontPadding', 14 | 'textAlignVertical', 15 | 'fontVariant', 16 | 'letterSpacing', 17 | 'textDecorationColor', 18 | 'textDecorationStyle', 19 | 'textTransform', 20 | 'writingDirection', 21 | ]; 22 | -------------------------------------------------------------------------------- /src/lib/util/convertAdditionalStyles.js: -------------------------------------------------------------------------------- 1 | import cssToReactNative from 'css-to-react-native'; 2 | 3 | export default function convertAdditionalStyles(style) { 4 | const rules = style.split(';'); 5 | 6 | const tuples = rules 7 | .map((rule) => { 8 | let [key, value] = rule.split(':'); 9 | 10 | if (key && value) { 11 | key = key.trim(); 12 | value = value.trim(); 13 | return [key, value]; 14 | } else { 15 | return null; 16 | } 17 | }) 18 | .filter((x) => { 19 | return x != null; 20 | }); 21 | 22 | const conv = cssToReactNative(tuples); 23 | 24 | return conv; 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Code checks 2 | 3 | on: 4 | push: 5 | branches: [ "master", "main" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master", "main" ] 9 | 10 | jobs: 11 | checks: 12 | name: Code checks 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - name: Install dependencies 19 | run: npm install --ci 20 | 21 | - name: Run ESLint 22 | run: npm run lint 23 | 24 | - name: Run Prettier 25 | run: npm run format-check 26 | 27 | # TODO: type checks... 28 | -------------------------------------------------------------------------------- /src/lib/util/groupTextTokens.js: -------------------------------------------------------------------------------- 1 | import Token from './Token'; 2 | 3 | export default function groupTextTokens(tokens) { 4 | const result = []; 5 | 6 | let hasGroup = false; 7 | 8 | tokens.forEach((token, index) => { 9 | if (!token.block && !hasGroup) { 10 | hasGroup = true; 11 | result.push(new Token('textgroup', 1)); 12 | result.push(token); 13 | } else if (!token.block && hasGroup) { 14 | result.push(token); 15 | } else if (token.block && hasGroup) { 16 | hasGroup = false; 17 | result.push(new Token('textgroup', -1)); 18 | result.push(token); 19 | } else { 20 | result.push(token); 21 | } 22 | }); 23 | 24 | return result; 25 | } 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@react-native-community', 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/eslint-recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:import/errors', 8 | 'plugin:import/warnings', 9 | 'plugin:import/typescript', 10 | 'plugin:react/recommended', 11 | 'plugin:react-hooks/recommended', 12 | 'prettier', 13 | ], 14 | plugins: ['@typescript-eslint'], 15 | parserOptions: { 16 | ecmaVersion: 6, 17 | sourceType: 'module', 18 | ecmaFeatures: { 19 | jsx: true, 20 | }, 21 | }, 22 | settings: { 23 | react: { 24 | version: require('./package.json').peerDependencies.react, 25 | }, 26 | 'import/ignore': ['node_modules'], 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/parser.js: -------------------------------------------------------------------------------- 1 | import tokensToAST from './util/tokensToAST'; 2 | import {stringToTokens} from './util/stringToTokens'; 3 | import {cleanupTokens} from './util/cleanupTokens'; 4 | import groupTextTokens from './util/groupTextTokens'; 5 | import omitListItemParagraph from './util/omitListItemParagraph'; 6 | 7 | /** 8 | * 9 | * @param {string} source 10 | * @param {function} [renderer] 11 | * @param {AstRenderer} [markdownIt] 12 | * @return {View} 13 | */ 14 | export default function parser(source, renderer, markdownIt) { 15 | if (Array.isArray(source)) { 16 | return renderer(source); 17 | } 18 | 19 | let tokens = stringToTokens(source, markdownIt); 20 | tokens = cleanupTokens(tokens); 21 | tokens = groupTextTokens(tokens); 22 | tokens = omitListItemParagraph(tokens); 23 | 24 | const astTree = tokensToAST(tokens); 25 | 26 | return renderer(astTree); 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/util/getTokenTypeByToken.js: -------------------------------------------------------------------------------- 1 | const regSelectOpenClose = /_open|_close/g; 2 | 3 | /** 4 | * 5 | * @example { 6 | "type": "heading_open", 7 | "tag": "h1", 8 | "attrs": null, 9 | "map": [ 10 | 1, 11 | 2 12 | ], 13 | "nesting": 1, 14 | "level": 0, 15 | "children": null, 16 | "content": "", 17 | "markup": "#", 18 | "info": "", 19 | "meta": null, 20 | "block": true, 21 | "hidden": false 22 | } 23 | * @param token 24 | * @return {String} 25 | */ 26 | export default function getTokenTypeByToken(token) { 27 | let cleanedType = 'unknown'; 28 | 29 | if (token.type) { 30 | cleanedType = token.type.replace(regSelectOpenClose, ''); 31 | } 32 | 33 | switch (cleanedType) { 34 | case 'heading': { 35 | cleanedType = `${cleanedType}${token.tag.substr(1)}`; 36 | break; 37 | } 38 | default: { 39 | break; 40 | } 41 | } 42 | 43 | return cleanedType; 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will publish a package to npm when a release is created. 2 | # https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | # TODO: add tests or leave them out, add build steps...? 5 | 6 | name: npm publish 7 | 8 | on: 9 | release: 10 | types: [created] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 16 20 | - run: npm ci 21 | # - run: npm test 22 | 23 | publish-npm: 24 | needs: build 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: 16 31 | registry-url: https://registry.npmjs.org/ 32 | - run: npm ci 33 | # - run: npm run build 34 | - run: npm publish 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 - 2019 Mient-jan Stelling and Tom Pickard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/util/omitListItemParagraph.js: -------------------------------------------------------------------------------- 1 | export default function omitListItemParagraph(tokens) { 2 | // used to ensure that we remove the correct ending paragraph token 3 | let depth = null; 4 | return tokens.filter((token, index) => { 5 | // update depth if we've already removed a starting paragraph token 6 | if (depth !== null) { 7 | depth = depth + token.nesting; 8 | } 9 | 10 | // check for a list_item token followed by paragraph token (to remove) 11 | if (token.type === 'list_item' && token.nesting === 1 && depth === null) { 12 | const next = index + 1 in tokens ? tokens[index + 1] : null; 13 | if (next && next.type === 'paragraph' && next.nesting === 1) { 14 | depth = 0; 15 | return true; 16 | } 17 | } else if (token.type === 'paragraph') { 18 | if (token.nesting === 1 && depth === 1) { 19 | // remove the paragraph token immediately after the list_item token 20 | return false; 21 | } else if (token.nesting === -1 && depth === 0) { 22 | // remove the ending paragraph token; reset depth 23 | depth = null; 24 | return false; 25 | } 26 | } 27 | return true; 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/util/cleanupTokens.js: -------------------------------------------------------------------------------- 1 | import getTokenTypeByToken from './getTokenTypeByToken'; 2 | import flattenInlineTokens from './flattenInlineTokens'; 3 | import renderInlineAsText from './renderInlineAsText'; 4 | 5 | export function cleanupTokens(tokens) { 6 | tokens = flattenInlineTokens(tokens); 7 | tokens.forEach((token) => { 8 | token.type = getTokenTypeByToken(token); 9 | 10 | // set image and hardbreak to block elements 11 | if (token.type === 'image' || token.type === 'hardbreak') { 12 | token.block = true; 13 | } 14 | 15 | // Set img alt text 16 | if (token.type === 'image') { 17 | token.attrs[token.attrIndex('alt')][1] = renderInlineAsText( 18 | token.children, 19 | ); 20 | } 21 | }); 22 | 23 | /** 24 | * changing a link token to a blocklink to fix issue where link tokens with 25 | * nested non text tokens breaks component 26 | */ 27 | const stack = []; 28 | tokens = tokens.reduce((acc, token, index) => { 29 | if (token.type === 'link' && token.nesting === 1) { 30 | stack.push(token); 31 | } else if ( 32 | stack.length > 0 && 33 | token.type === 'link' && 34 | token.nesting === -1 35 | ) { 36 | if (stack.some((stackToken) => stackToken.block)) { 37 | stack[0].type = 'blocklink'; 38 | stack[0].block = true; 39 | token.type = 'blocklink'; 40 | token.block = true; 41 | } 42 | 43 | stack.push(token); 44 | 45 | while (stack.length) { 46 | acc.push(stack.shift()); 47 | } 48 | } else if (stack.length > 0) { 49 | stack.push(token); 50 | } else { 51 | acc.push(token); 52 | } 53 | 54 | return acc; 55 | }, []); 56 | 57 | return tokens; 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/util/tokensToAST.js: -------------------------------------------------------------------------------- 1 | import getUniqueID from './getUniqueID'; 2 | import getTokenTypeByToken from './getTokenTypeByToken'; 3 | 4 | /** 5 | * 6 | * @param {{type: string, tag:string, content: string, children: *, attrs: Array, meta, info, block: boolean}} token 7 | * @param {number} tokenIndex 8 | * @return {{type: string, content, tokenIndex: *, index: number, attributes: {}, children: *}} 9 | */ 10 | function createNode(token, tokenIndex) { 11 | const type = getTokenTypeByToken(token); 12 | const content = token.content; 13 | 14 | let attributes = {}; 15 | 16 | if (token.attrs) { 17 | attributes = token.attrs.reduce((prev, curr) => { 18 | const [name, value] = curr; 19 | return {...prev, [name]: value}; 20 | }, {}); 21 | } 22 | 23 | return { 24 | type, 25 | sourceType: token.type, 26 | sourceInfo: token.info, 27 | sourceMeta: token.meta, 28 | block: token.block, 29 | markup: token.markup, 30 | key: getUniqueID() + '_' + type, 31 | content, 32 | tokenIndex, 33 | index: 0, 34 | attributes, 35 | children: tokensToAST(token.children), 36 | }; 37 | } 38 | 39 | /** 40 | * 41 | * @param {Array<{type: string, tag:string, content: string, children: *, attrs: Array}>}tokens 42 | * @return {Array} 43 | */ 44 | export default function tokensToAST(tokens) { 45 | let stack = []; 46 | let children = []; 47 | 48 | if (!tokens || tokens.length === 0) { 49 | return []; 50 | } 51 | 52 | for (let i = 0; i < tokens.length; i++) { 53 | const token = tokens[i]; 54 | const astNode = createNode(token, i); 55 | 56 | if ( 57 | !( 58 | astNode.type === 'text' && 59 | astNode.children.length === 0 && 60 | astNode.content === '' 61 | ) 62 | ) { 63 | astNode.index = children.length; 64 | 65 | if (token.nesting === 1) { 66 | children.push(astNode); 67 | stack.push(children); 68 | children = astNode.children; 69 | } else if (token.nesting === -1) { 70 | children = stack.pop(); 71 | } else if (token.nesting === 0) { 72 | children.push(astNode); 73 | } 74 | } 75 | } 76 | 77 | return children; 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ronradtke/react-native-markdown-display", 3 | "version": "8.1.0", 4 | "description": "Markdown renderer for react-native, with CommonMark spec support + adds syntax extensions & sugar (URL autolinking, typographer), originally created by Mient-jan Stelling as react-native-markdown-renderer", 5 | "main": "src/index.js", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "format": "prettier -w src", 9 | "format-check": "prettier --check src", 10 | "lint": "eslint --cache ./src", 11 | "lint-fix": "eslint --fix --cache ./src" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/RonRadtke/react-native-markdown-display.git" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "react-native", 20 | "native", 21 | "markdown", 22 | "commonmark", 23 | "markdown-it" 24 | ], 25 | "author": "Michael Lohmann, Daniel Maslowski, Ron Radtke and maintainers of the original library", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/RonRadtke/react-native-markdown-display/issues" 29 | }, 30 | "homepage": "https://github.com/RonRadtke/react-native-markdown-display", 31 | "dependencies": { 32 | "css-to-react-native": "^3.2.0", 33 | "markdown-it": "^13.0.1", 34 | "prop-types": "^15.7.2", 35 | "react-native-fit-image": "^1.5.5" 36 | }, 37 | "peerDependencies": { 38 | "react": ">=16.2.0", 39 | "react-native": ">=0.50.4" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.21.0", 43 | "@babel/runtime": "^7.21.0", 44 | "@react-native-community/eslint-config": "^3.2.0", 45 | "@types/markdown-it": "^12.2.3", 46 | "@types/react-native": ">=0.50.0", 47 | "@typescript-eslint/eslint-plugin": "^5.54.0", 48 | "@typescript-eslint/parser": "^5.53.0", 49 | "eslint": "^8.26.0", 50 | "eslint-config-defaults": "^9.0.0", 51 | "eslint-config-prettier": "^8.7.0", 52 | "eslint-plugin-import": "^2.27.5", 53 | "eslint-plugin-react": "^7.32.2", 54 | "eslint-plugin-react-hooks": "^4.6.0", 55 | "eslint-plugin-react-native": "^4.0.0", 56 | "metro-react-native-babel-preset": "^0.76.0", 57 | "prettier": "^2.8.4", 58 | "typescript": "^4.9.5" 59 | }, 60 | "directories": { 61 | "doc": "doc" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-classes-per-file 2 | import MarkdownIt from 'markdown-it'; 3 | import Token from 'markdown-it/lib/token'; 4 | import {ComponentType, ReactNode} from 'react'; 5 | import {StyleSheet, View} from 'react-native'; 6 | 7 | export function getUniqueID(): string; 8 | 9 | export function openUrl(url: string): void; 10 | 11 | export function hasParents(parents: any[], type: string): boolean; 12 | 13 | export type RenderFunction = ( 14 | node: ASTNode, 15 | children: ReactNode[], 16 | parentNodes: ASTNode[], 17 | styles: any, 18 | styleObj?: any, 19 | // must have this so that we can have fixed overrides with more arguments 20 | ...args: any 21 | ) => ReactNode; 22 | 23 | export type RenderLinkFunction = ( 24 | node: ASTNode, 25 | children: ReactNode[], 26 | parentNodes: ASTNode[], 27 | styles: any, 28 | onLinkPress?: (url: string) => boolean, 29 | ) => ReactNode; 30 | 31 | export type RenderImageFunction = ( 32 | node: ASTNode, 33 | children: ReactNode[], 34 | parentNodes: ASTNode[], 35 | styles: any, 36 | allowedImageHandlers: string[], 37 | defaultImageHandler: string, 38 | ) => ReactNode; 39 | 40 | export interface RenderRules { 41 | [name: string]: RenderFunction | undefined; 42 | 43 | link?: RenderLinkFunction; 44 | blocklink?: RenderLinkFunction; 45 | image?: RenderImageFunction; 46 | } 47 | 48 | export const renderRules: RenderRules; 49 | 50 | export interface MarkdownParser { 51 | parse: (value: string, options: any) => Token[]; 52 | } 53 | 54 | export interface ASTNode { 55 | type: string; 56 | sourceType: string; // original source token name 57 | sourceInfo: string; 58 | sourceMeta: any; 59 | key: string; 60 | content: string; 61 | markup: string; 62 | tokenIndex: number; 63 | index: number; 64 | attributes: Record; 65 | children: ASTNode[]; 66 | } 67 | 68 | export class AstRenderer { 69 | constructor(renderRules: RenderRules, style?: any); 70 | 71 | getRenderFunction(type: string): RenderFunction; 72 | 73 | renderNode(node: any, parentNodes: ReadonlyArray): ReactNode; 74 | 75 | render(nodes: ReadonlyArray): View; 76 | } 77 | 78 | export function parser( 79 | source: string, 80 | renderer: (node: ASTNode) => View, 81 | parser: MarkdownParser, 82 | ): any; 83 | 84 | export function stringToTokens( 85 | source: string, 86 | markdownIt: MarkdownParser, 87 | ): Token[]; 88 | 89 | export function tokensToAST(tokens: ReadonlyArray): ASTNode[]; 90 | 91 | export interface MarkdownProps { 92 | rules?: RenderRules; 93 | style?: StyleSheet.NamedStyles; 94 | renderer?: AstRenderer; 95 | markdownit?: MarkdownIt; 96 | mergeStyle?: boolean; 97 | debugPrintTree?: boolean; 98 | onLinkPress?: (url: string) => boolean; 99 | children: string; 100 | } 101 | 102 | type MarkdownStatic = ComponentType; 103 | export const Markdown: MarkdownStatic; 104 | export type Markdown = MarkdownStatic; 105 | export {MarkdownIt}; 106 | export default Markdown; 107 | -------------------------------------------------------------------------------- /src/lib/styles.js: -------------------------------------------------------------------------------- 1 | import {Platform} from 'react-native'; 2 | 3 | // this is converted to a stylesheet internally at run time with StyleSheet.create( 4 | export const styles = { 5 | // The main container 6 | body: {}, 7 | 8 | // Headings 9 | heading1: { 10 | flexDirection: 'row', 11 | fontSize: 32, 12 | }, 13 | heading2: { 14 | flexDirection: 'row', 15 | fontSize: 24, 16 | }, 17 | heading3: { 18 | flexDirection: 'row', 19 | fontSize: 18, 20 | }, 21 | heading4: { 22 | flexDirection: 'row', 23 | fontSize: 16, 24 | }, 25 | heading5: { 26 | flexDirection: 'row', 27 | fontSize: 13, 28 | }, 29 | heading6: { 30 | flexDirection: 'row', 31 | fontSize: 11, 32 | }, 33 | 34 | // Horizontal Rule 35 | hr: { 36 | backgroundColor: '#000000', 37 | height: 1, 38 | }, 39 | 40 | // Emphasis 41 | strong: { 42 | fontWeight: 'bold', 43 | }, 44 | em: { 45 | fontStyle: 'italic', 46 | }, 47 | s: { 48 | textDecorationLine: 'line-through', 49 | }, 50 | 51 | // Blockquotes 52 | blockquote: { 53 | backgroundColor: '#F5F5F5', 54 | borderColor: '#CCC', 55 | borderLeftWidth: 4, 56 | marginLeft: 5, 57 | paddingHorizontal: 5, 58 | }, 59 | 60 | // Lists 61 | bullet_list: {}, 62 | ordered_list: {}, 63 | list_item: { 64 | flexDirection: 'row', 65 | justifyContent: 'flex-start', 66 | }, 67 | // @pseudo class, does not have a unique render rule 68 | bullet_list_icon: { 69 | marginLeft: 10, 70 | marginRight: 10, 71 | }, 72 | // @pseudo class, does not have a unique render rule 73 | bullet_list_content: { 74 | flex: 1, 75 | }, 76 | // @pseudo class, does not have a unique render rule 77 | ordered_list_icon: { 78 | marginLeft: 10, 79 | marginRight: 10, 80 | }, 81 | // @pseudo class, does not have a unique render rule 82 | ordered_list_content: { 83 | flex: 1, 84 | }, 85 | 86 | // Code 87 | code_inline: { 88 | borderWidth: 1, 89 | borderColor: '#CCCCCC', 90 | backgroundColor: '#f5f5f5', 91 | padding: 10, 92 | borderRadius: 4, 93 | ...Platform.select({ 94 | ['ios']: { 95 | fontFamily: 'Courier New', 96 | }, 97 | ['android']: { 98 | fontFamily: 'monospace', 99 | }, 100 | }), 101 | }, 102 | code_block: { 103 | borderWidth: 1, 104 | borderColor: '#CCCCCC', 105 | backgroundColor: '#f5f5f5', 106 | padding: 10, 107 | borderRadius: 4, 108 | ...Platform.select({ 109 | ['ios']: { 110 | fontFamily: 'Courier New', 111 | }, 112 | ['android']: { 113 | fontFamily: 'monospace', 114 | }, 115 | }), 116 | }, 117 | fence: { 118 | borderWidth: 1, 119 | borderColor: '#CCCCCC', 120 | backgroundColor: '#f5f5f5', 121 | padding: 10, 122 | borderRadius: 4, 123 | ...Platform.select({ 124 | ['ios']: { 125 | fontFamily: 'Courier New', 126 | }, 127 | ['android']: { 128 | fontFamily: 'monospace', 129 | }, 130 | }), 131 | }, 132 | 133 | // Tables 134 | table: { 135 | borderWidth: 1, 136 | borderColor: '#000000', 137 | borderRadius: 3, 138 | }, 139 | thead: {}, 140 | tbody: {}, 141 | th: { 142 | flex: 1, 143 | padding: 5, 144 | }, 145 | tr: { 146 | borderBottomWidth: 1, 147 | borderColor: '#000000', 148 | flexDirection: 'row', 149 | }, 150 | td: { 151 | flex: 1, 152 | padding: 5, 153 | }, 154 | 155 | // Links 156 | link: { 157 | textDecorationLine: 'underline', 158 | // Hackaround for artificial spacing. The link is rendered inside a `Text` as 159 | // a `Pressable` with another `Text` inside. See also: 160 | // https://stackoverflow.com/questions/66590167/vertically-align-pressable-inside-a-text-component/66590787 161 | marginBottom: -4, 162 | }, 163 | blocklink: { 164 | flex: 1, 165 | borderColor: '#000000', 166 | borderBottomWidth: 1, 167 | }, 168 | 169 | // Images 170 | image: { 171 | flex: 1, 172 | }, 173 | 174 | // Text Output 175 | text: {}, 176 | textgroup: {}, 177 | paragraph: { 178 | marginTop: 10, 179 | marginBottom: 10, 180 | flexWrap: 'wrap', 181 | flexDirection: 'row', 182 | alignItems: 'flex-start', 183 | justifyContent: 'flex-start', 184 | width: '100%', 185 | }, 186 | hardbreak: { 187 | width: '100%', 188 | height: 1, 189 | }, 190 | softbreak: {}, 191 | 192 | // Believe these are never used but retained for completeness 193 | pre: {}, 194 | inline: {}, 195 | span: {}, 196 | }; 197 | -------------------------------------------------------------------------------- /src/lib/AstRenderer.js: -------------------------------------------------------------------------------- 1 | import {StyleSheet} from 'react-native'; 2 | 3 | import getUniqueID from './util/getUniqueID'; 4 | import convertAdditionalStyles from './util/convertAdditionalStyles'; 5 | 6 | import textStyleProps from './data/textStyleProps'; 7 | 8 | export default class AstRenderer { 9 | /** 10 | * 11 | * @param {Object.} renderRules 12 | * @param {any} style 13 | */ 14 | constructor( 15 | renderRules, 16 | style, 17 | onLinkPress, 18 | maxTopLevelChildren, 19 | topLevelMaxExceededItem, 20 | allowedImageHandlers, 21 | defaultImageHandler, 22 | debugPrintTree, 23 | ) { 24 | this._renderRules = renderRules; 25 | this._style = style; 26 | this._onLinkPress = onLinkPress; 27 | this._maxTopLevelChildren = maxTopLevelChildren; 28 | this._topLevelMaxExceededItem = topLevelMaxExceededItem; 29 | this._allowedImageHandlers = allowedImageHandlers; 30 | this._defaultImageHandler = defaultImageHandler; 31 | this._debugPrintTree = debugPrintTree; 32 | } 33 | 34 | /** 35 | * 36 | * @param {string} type 37 | * @return {string} 38 | */ 39 | getRenderFunction = (type) => { 40 | const renderFunction = this._renderRules[type]; 41 | 42 | if (!renderFunction) { 43 | console.warn( 44 | `Warning, unknown render rule encountered: ${type}. 'unknown' render rule used (by default, returns null - nothing rendered)`, 45 | ); 46 | return this._renderRules.unknown; 47 | } 48 | 49 | return renderFunction; 50 | }; 51 | 52 | /** 53 | * 54 | * @param node 55 | * @param parentNodes 56 | * @return {*} 57 | */ 58 | renderNode = (node, parentNodes, isRoot = false) => { 59 | const renderFunction = this.getRenderFunction(node.type); 60 | const parents = [...parentNodes]; 61 | 62 | if (this._debugPrintTree === true) { 63 | let str = ''; 64 | 65 | for (let a = 0; a < parents.length; a++) { 66 | str = str + '-'; 67 | } 68 | 69 | console.log(`${str}${node.type}`); 70 | } 71 | 72 | parents.unshift(node); 73 | 74 | // calculate the children first 75 | let children = node.children.map((value) => { 76 | return this.renderNode(value, parents); 77 | }); 78 | 79 | // render any special types of nodes that have different renderRule function signatures 80 | 81 | if (node.type === 'link' || node.type === 'blocklink') { 82 | return renderFunction( 83 | node, 84 | children, 85 | parentNodes, 86 | this._style, 87 | this._onLinkPress, 88 | ); 89 | } 90 | 91 | if (node.type === 'image') { 92 | return renderFunction( 93 | node, 94 | children, 95 | parentNodes, 96 | this._style, 97 | this._allowedImageHandlers, 98 | this._defaultImageHandler, 99 | ); 100 | } 101 | 102 | // We are at the bottom of some tree - grab all the parent styles 103 | // this effectively grabs the styles from parents and 104 | // applies them in order of priority parent (least) to child (most) 105 | // to allow styling global, then lower down things individually 106 | 107 | // we have to handle list_item seperately here because they have some child 108 | // pseudo classes that need the additional style props from parents passed down to them 109 | if (children.length === 0 || node.type === 'list_item') { 110 | const styleObj = {}; 111 | 112 | for (let a = parentNodes.length - 1; a > -1; a--) { 113 | // grab and additional attributes specified by markdown-it 114 | let refStyle = {}; 115 | 116 | if ( 117 | parentNodes[a].attributes && 118 | parentNodes[a].attributes.style && 119 | typeof parentNodes[a].attributes.style === 'string' 120 | ) { 121 | refStyle = convertAdditionalStyles(parentNodes[a].attributes.style); 122 | } 123 | 124 | // combine in specific styles for the object 125 | if (this._style[parentNodes[a].type]) { 126 | refStyle = { 127 | ...refStyle, 128 | ...StyleSheet.flatten(this._style[parentNodes[a].type]), 129 | }; 130 | 131 | // workaround for list_items and their content cascading down the tree 132 | if (parentNodes[a].type === 'list_item') { 133 | let contentStyle = {}; 134 | 135 | if (parentNodes[a + 1].type === 'bullet_list') { 136 | contentStyle = this._style.bullet_list_content; 137 | } else if (parentNodes[a + 1].type === 'ordered_list') { 138 | contentStyle = this._style.ordered_list_content; 139 | } 140 | 141 | refStyle = { 142 | ...refStyle, 143 | ...StyleSheet.flatten(contentStyle), 144 | }; 145 | } 146 | } 147 | 148 | // then work out if any of them are text styles that should be used in the end. 149 | const arr = Object.keys(refStyle); 150 | 151 | for (let b = 0; b < arr.length; b++) { 152 | if (textStyleProps.includes(arr[b])) { 153 | styleObj[arr[b]] = refStyle[arr[b]]; 154 | } 155 | } 156 | } 157 | 158 | return renderFunction(node, children, parentNodes, this._style, styleObj); 159 | } 160 | 161 | // cull top level children 162 | 163 | if ( 164 | isRoot === true && 165 | this._maxTopLevelChildren && 166 | children.length > this._maxTopLevelChildren 167 | ) { 168 | children = children.slice(0, this._maxTopLevelChildren); 169 | children.push(this._topLevelMaxExceededItem); 170 | } 171 | 172 | // render anythign else that has a normal signature 173 | 174 | return renderFunction(node, children, parentNodes, this._style); 175 | }; 176 | 177 | /** 178 | * 179 | * @param nodes 180 | * @return {*} 181 | */ 182 | render = (nodes) => { 183 | const root = {type: 'body', key: getUniqueID(), children: nodes}; 184 | return this.renderNode(root, [], true); 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Markdown component 3 | * @author Mient-jan Stelling + contributors 4 | */ 5 | 6 | import React, {useMemo} from 'react'; 7 | import {StyleSheet, Text} from 'react-native'; 8 | import PropTypes from 'prop-types'; 9 | import parser from './lib/parser'; 10 | import getUniqueID from './lib/util/getUniqueID'; 11 | import hasParents from './lib/util/hasParents'; 12 | import openUrl from './lib/util/openUrl'; 13 | import tokensToAST from './lib/util/tokensToAST'; 14 | import renderRules from './lib/renderRules'; 15 | import AstRenderer from './lib/AstRenderer'; 16 | import MarkdownIt from 'markdown-it'; 17 | import removeTextStyleProps from './lib/util/removeTextStyleProps'; 18 | import {styles} from './lib/styles'; 19 | import {stringToTokens} from './lib/util/stringToTokens'; 20 | import FitImage from 'react-native-fit-image'; 21 | import textStyleProps from './lib/data/textStyleProps'; 22 | 23 | export { 24 | getUniqueID, 25 | openUrl, 26 | hasParents, 27 | renderRules, 28 | AstRenderer, 29 | parser, 30 | stringToTokens, 31 | tokensToAST, 32 | MarkdownIt, 33 | styles, 34 | removeTextStyleProps, 35 | FitImage, 36 | textStyleProps, 37 | }; 38 | 39 | // we use StyleSheet.flatten here to make sure we have an object, in case someone 40 | // passes in a StyleSheet.create result to the style prop 41 | const getStyle = (mergeStyle, style) => { 42 | let useStyles = {}; 43 | 44 | if (mergeStyle === true && style !== null) { 45 | // make sure we get anything user defuned 46 | Object.keys(style).forEach((value) => { 47 | useStyles[value] = { 48 | ...StyleSheet.flatten(style[value]), 49 | }; 50 | }); 51 | 52 | // combine any existing styles 53 | Object.keys(styles).forEach((value) => { 54 | useStyles[value] = { 55 | ...styles[value], 56 | ...StyleSheet.flatten(style[value]), 57 | }; 58 | }); 59 | } else { 60 | useStyles = { 61 | ...styles, 62 | }; 63 | 64 | if (style !== null) { 65 | Object.keys(style).forEach((value) => { 66 | useStyles[value] = { 67 | ...StyleSheet.flatten(style[value]), 68 | }; 69 | }); 70 | } 71 | } 72 | 73 | Object.keys(useStyles).forEach((value) => { 74 | useStyles['_VIEW_SAFE_' + value] = removeTextStyleProps(useStyles[value]); 75 | }); 76 | 77 | return StyleSheet.create(useStyles); 78 | }; 79 | 80 | const getRenderer = ( 81 | textcomponent, 82 | renderer, 83 | rules, 84 | style, 85 | mergeStyle, 86 | onLinkPress, 87 | maxTopLevelChildren, 88 | topLevelMaxExceededItem, 89 | allowedImageHandlers, 90 | defaultImageHandler, 91 | debugPrintTree, 92 | ) => { 93 | if (renderer && rules) { 94 | console.warn( 95 | 'react-native-markdown-display you are using renderer and rules at the same time. This is not possible, props.rules is ignored', 96 | ); 97 | } 98 | 99 | if (renderer && style) { 100 | console.warn( 101 | 'react-native-markdown-display you are using renderer and style at the same time. This is not possible, props.style is ignored', 102 | ); 103 | } 104 | 105 | // these checks are here to prevent extra overhead. 106 | if (renderer) { 107 | if (!(typeof renderer === 'function') || renderer instanceof AstRenderer) { 108 | return renderer; 109 | } else { 110 | throw new Error( 111 | 'Provided renderer is not compatible with function or AstRenderer. please change', 112 | ); 113 | } 114 | } else { 115 | const useStyles = getStyle(mergeStyle, style); 116 | 117 | return new AstRenderer( 118 | { 119 | ...renderRules(textcomponent), 120 | ...(rules || {}), 121 | }, 122 | useStyles, 123 | onLinkPress, 124 | maxTopLevelChildren, 125 | topLevelMaxExceededItem, 126 | allowedImageHandlers, 127 | defaultImageHandler, 128 | debugPrintTree, 129 | ); 130 | } 131 | }; 132 | 133 | const Markdown = React.memo( 134 | ({ 135 | children, 136 | textcomponent = Text, 137 | renderer = null, 138 | rules = null, 139 | style = null, 140 | mergeStyle = true, 141 | markdownit = MarkdownIt({ 142 | typographer: true, 143 | }), 144 | onLinkPress, 145 | maxTopLevelChildren = null, 146 | topLevelMaxExceededItem = ..., 147 | allowedImageHandlers = [ 148 | 'data:image/png;base64', 149 | 'data:image/gif;base64', 150 | 'data:image/jpeg;base64', 151 | 'https://', 152 | 'http://', 153 | ], 154 | defaultImageHandler = 'https://', 155 | debugPrintTree = false, 156 | }) => { 157 | const momoizedRenderer = useMemo( 158 | () => 159 | getRenderer( 160 | textcomponent, 161 | renderer, 162 | rules, 163 | style, 164 | mergeStyle, 165 | onLinkPress, 166 | maxTopLevelChildren, 167 | topLevelMaxExceededItem, 168 | allowedImageHandlers, 169 | defaultImageHandler, 170 | debugPrintTree, 171 | ), 172 | [ 173 | textcomponent, 174 | maxTopLevelChildren, 175 | onLinkPress, 176 | renderer, 177 | rules, 178 | style, 179 | mergeStyle, 180 | topLevelMaxExceededItem, 181 | allowedImageHandlers, 182 | defaultImageHandler, 183 | debugPrintTree, 184 | ], 185 | ); 186 | 187 | const momoizedParser = useMemo(() => markdownit, [markdownit]); 188 | 189 | return parser(children, momoizedRenderer.render, momoizedParser); 190 | }, 191 | ); 192 | 193 | Markdown.displayName = 'Markdown'; 194 | 195 | Markdown.propTypes = { 196 | children: PropTypes.oneOfType([PropTypes.node, PropTypes.array]).isRequired, 197 | textcomponent: PropTypes.elementType, 198 | renderer: PropTypes.oneOfType([ 199 | PropTypes.func, 200 | PropTypes.instanceOf(AstRenderer), 201 | ]), 202 | onLinkPress: PropTypes.func, 203 | maxTopLevelChildren: PropTypes.number, 204 | topLevelMaxExceededItem: PropTypes.any, 205 | rules: (props, propName, componentName) => { 206 | let invalidProps = []; 207 | const prop = props[propName]; 208 | 209 | if (!prop) { 210 | return; 211 | } 212 | 213 | if (typeof prop === 'object') { 214 | invalidProps = Object.keys(prop).filter( 215 | (key) => typeof prop[key] !== 'function', 216 | ); 217 | } 218 | 219 | if (typeof prop !== 'object') { 220 | return new Error( 221 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Must be of shape {[index:string]:function} `, 222 | ); 223 | } else if (invalidProps.length > 0) { 224 | return new Error( 225 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`. These ` + 226 | `props are not of type function \`${invalidProps.join(', ')}\` `, 227 | ); 228 | } 229 | }, 230 | markdownit: PropTypes.instanceOf(MarkdownIt), 231 | style: PropTypes.any, 232 | mergeStyle: PropTypes.bool, 233 | allowedImageHandlers: PropTypes.arrayOf(PropTypes.string), 234 | defaultImageHandler: PropTypes.string, 235 | debugPrintTree: PropTypes.bool, 236 | }; 237 | 238 | export default Markdown; 239 | -------------------------------------------------------------------------------- /src/lib/renderRules.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Pressable, View, Platform, StyleSheet} from 'react-native'; 3 | import FitImage from 'react-native-fit-image'; 4 | 5 | import openUrl from './util/openUrl'; 6 | import hasParents from './util/hasParents'; 7 | 8 | import textStyleProps from './data/textStyleProps'; 9 | 10 | const renderRules = (Text) => ({ 11 | // when unknown elements are introduced, so it wont break 12 | unknown: (node, children, parent, styles) => null, 13 | 14 | // The main container 15 | body: (node, children, parent, styles) => ( 16 | 17 | {children} 18 | 19 | ), 20 | // Headings 21 | heading1: (node, children, parent, styles) => ( 22 | 23 | {children} 24 | 25 | ), 26 | heading2: (node, children, parent, styles) => ( 27 | 28 | {children} 29 | 30 | ), 31 | heading3: (node, children, parent, styles) => ( 32 | 33 | {children} 34 | 35 | ), 36 | heading4: (node, children, parent, styles) => ( 37 | 38 | {children} 39 | 40 | ), 41 | heading5: (node, children, parent, styles) => ( 42 | 43 | {children} 44 | 45 | ), 46 | heading6: (node, children, parent, styles) => ( 47 | 48 | {children} 49 | 50 | ), 51 | // Horizontal Rule 52 | hr: (node, children, parent, styles) => ( 53 | 54 | ), 55 | // Emphasis 56 | strong: (node, children, parent, styles) => ( 57 | 58 | {children} 59 | 60 | ), 61 | em: (node, children, parent, styles) => ( 62 | 63 | {children} 64 | 65 | ), 66 | s: (node, children, parent, styles) => ( 67 | 68 | {children} 69 | 70 | ), 71 | // Blockquotes 72 | blockquote: (node, children, parent, styles) => ( 73 | 74 | {children} 75 | 76 | ), 77 | // Lists 78 | bullet_list: (node, children, parent, styles) => ( 79 | 80 | {children} 81 | 82 | ), 83 | ordered_list: (node, children, parent, styles) => ( 84 | 85 | {children} 86 | 87 | ), 88 | // this is a unique and quite annoying render rule because it has 89 | // child items that can be styled (the list icon and the list content) 90 | // outside of the AST tree so there are some work arounds in the 91 | // AST renderer specifically to get the styling right here 92 | list_item: (node, children, parent, styles, inheritedStyles = {}) => { 93 | // we need to grab any text specific stuff here that is applied on the list_item style 94 | // and apply it onto bullet_list_icon. the AST renderer has some workaround code to make 95 | // the content classes apply correctly to the child AST tree items as well 96 | // as code that forces the creation of the inheritedStyles object for list_items 97 | const refStyle = { 98 | ...inheritedStyles, 99 | ...StyleSheet.flatten(styles.list_item), 100 | }; 101 | 102 | const arr = Object.keys(refStyle); 103 | 104 | const modifiedInheritedStylesObj = {}; 105 | 106 | for (let b = 0; b < arr.length; b++) { 107 | if (textStyleProps.includes(arr[b])) { 108 | modifiedInheritedStylesObj[arr[b]] = refStyle[arr[b]]; 109 | } 110 | } 111 | 112 | if (hasParents(parent, 'bullet_list')) { 113 | return ( 114 | 115 | 119 | {Platform.select({ 120 | android: '\u2022', 121 | ios: '\u00B7', 122 | default: '\u2022', 123 | })} 124 | 125 | {children} 126 | 127 | ); 128 | } 129 | 130 | if (hasParents(parent, 'ordered_list')) { 131 | const orderedListIndex = parent.findIndex( 132 | (el) => el.type === 'ordered_list', 133 | ); 134 | 135 | const orderedList = parent[orderedListIndex]; 136 | let listItemNumber; 137 | 138 | if (orderedList.attributes && orderedList.attributes.start) { 139 | listItemNumber = orderedList.attributes.start + node.index; 140 | } else { 141 | listItemNumber = node.index + 1; 142 | } 143 | 144 | return ( 145 | 146 | 147 | {listItemNumber} 148 | {node.markup} 149 | 150 | {children} 151 | 152 | ); 153 | } 154 | 155 | // we should not need this, but just in case 156 | return ( 157 | 158 | {children} 159 | 160 | ); 161 | }, 162 | 163 | // Code 164 | code_inline: (node, children, parent, styles, inheritedStyles = {}) => ( 165 | 166 | {node.content} 167 | 168 | ), 169 | code_block: (node, children, parent, styles, inheritedStyles = {}) => { 170 | // we trim new lines off the end of code blocks because the parser sends an extra one. 171 | let {content} = node; 172 | 173 | if ( 174 | typeof node.content === 'string' && 175 | node.content.charAt(node.content.length - 1) === '\n' 176 | ) { 177 | content = node.content.substring(0, node.content.length - 1); 178 | } 179 | 180 | return ( 181 | 182 | {content} 183 | 184 | ); 185 | }, 186 | fence: (node, children, parent, styles, inheritedStyles = {}) => { 187 | // we trim new lines off the end of code blocks because the parser sends an extra one. 188 | let {content} = node; 189 | 190 | if ( 191 | typeof node.content === 'string' && 192 | node.content.charAt(node.content.length - 1) === '\n' 193 | ) { 194 | content = node.content.substring(0, node.content.length - 1); 195 | } 196 | 197 | return ( 198 | 199 | {content} 200 | 201 | ); 202 | }, 203 | 204 | // Tables 205 | table: (node, children, parent, styles) => ( 206 | 207 | {children} 208 | 209 | ), 210 | thead: (node, children, parent, styles) => ( 211 | 212 | {children} 213 | 214 | ), 215 | tbody: (node, children, parent, styles) => ( 216 | 217 | {children} 218 | 219 | ), 220 | th: (node, children, parent, styles) => ( 221 | 222 | {children} 223 | 224 | ), 225 | tr: (node, children, parent, styles) => ( 226 | 227 | {children} 228 | 229 | ), 230 | td: (node, children, parent, styles) => ( 231 | 232 | {children} 233 | 234 | ), 235 | // Links 236 | link: (node, children, parent, styles, onLinkPress) => ( 237 | openUrl(node.attributes.href, onLinkPress)} 241 | > 242 | {children} 243 | 244 | ), 245 | blocklink: (node, children, parent, styles, onLinkPress) => ( 246 | openUrl(node.attributes.href, onLinkPress)} 250 | style={styles.blocklink} 251 | > 252 | {children} 253 | 254 | ), 255 | // Images 256 | image: ( 257 | node, 258 | children, 259 | parent, 260 | styles, 261 | allowedImageHandlers, 262 | defaultImageHandler, 263 | ) => { 264 | const {src, alt} = node.attributes; 265 | 266 | // we check that the source starts with at least one of the elements in allowedImageHandlers 267 | const show = 268 | allowedImageHandlers.filter((value) => { 269 | return src.toLowerCase().startsWith(value.toLowerCase()); 270 | }).length > 0; 271 | 272 | if (show === false && defaultImageHandler === null) { 273 | return null; 274 | } 275 | 276 | const imageProps = { 277 | indicator: true, 278 | key: node.key, 279 | style: styles._VIEW_SAFE_image, 280 | source: { 281 | uri: show === true ? src : `${defaultImageHandler}${src}`, 282 | }, 283 | }; 284 | 285 | if (alt) { 286 | imageProps.accessible = true; 287 | imageProps.accessibilityLabel = alt; 288 | } 289 | 290 | return ; 291 | }, 292 | 293 | // Text Output 294 | text: (node, children, parent, styles, inheritedStyles = {}) => ( 295 | 296 | {node.content} 297 | 298 | ), 299 | textgroup: (node, children, parent, styles) => ( 300 | 301 | {children} 302 | 303 | ), 304 | paragraph: (node, children, parent, styles) => ( 305 | 306 | {children} 307 | 308 | ), 309 | hardbreak: (node, children, parent, styles) => ( 310 | 311 | {'\n'} 312 | 313 | ), 314 | softbreak: (node, children, parent, styles) => ( 315 | 316 | {'\n'} 317 | 318 | ), 319 | // Believe these are never used but retained for completeness 320 | pre: (node, children, parent, styles) => ( 321 | 322 | {children} 323 | 324 | ), 325 | inline: (node, children, parent, styles) => ( 326 | 327 | {children} 328 | 329 | ), 330 | span: (node, children, parent, styles) => ( 331 | 332 | {children} 333 | 334 | ), 335 | }); 336 | 337 | export default renderRules; 338 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Markdown Display [![npm version](https://badge.fury.io/js/@ronradtke%2Freact-native-markdown-display.svg)](https://badge.fury.io/js/@ronradtke%2Freact-native-markdown-display) [![Known Vulnerabilities](https://snyk.io/test/github/iamacup/react-native-markdown-display/badge.svg)](https://snyk.io/test/github/iamacup/react-native-markdown-display) 2 | 3 | It a 100% compatible CommonMark renderer, a react-native markdown renderer done right. This is __not__ a web-view markdown renderer but a renderer that uses native components for all its elements. These components can be overwritten and styled as needed. 4 | 5 | ### Compatibility with react-native-markdown-renderer 6 | 7 | This is intended to be a replacement for react-native-markdown-renderer, with a variety of bug fixes and enhancements - **Due to how the new style rules work, there may be some tweaking needed**, [see how to style stuff section below](#How-to-style-stuff) 8 | 9 | ### Install 10 | 11 | #### Yarn 12 | ```npm 13 | yarn add @ronradtke/react-native-markdown-display 14 | ``` 15 | 16 | #### NPM 17 | ```npm 18 | npm install -S @ronradtke/react-native-markdown-display 19 | ``` 20 | 21 | ### Get Started 22 | 23 | ```jsx 24 | import React from 'react'; 25 | import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; 26 | 27 | import Markdown from '@ronradtke/react-native-markdown-display'; 28 | 29 | const copy = `# h1 Heading 8-) 30 | 31 | **This is some bold text!** 32 | 33 | This is normal text 34 | `; 35 | 36 | const App: () => React$Node = () => { 37 | return ( 38 | <> 39 | 40 | 41 | 45 | 46 | {copy} 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default App; 55 | ``` 56 | You can find this example [here](https://snack.expo.dev/@hassieb/react-native-markdown-display-ordered-list) 57 | 58 | This next example worked with `"react-native-markdown-display": "^7.0.0-alpha.2",` on React`18.1.0`, React Native `0.70.5` via the Expo command `npx create-expo-app --template` with typescript selected. 59 | 60 | ```jsx 61 | import React from "react"; 62 | import { StyleSheet, SafeAreaView, ScrollView, StatusBar } from "react-native"; 63 | import { useTheme } from "@react-navigation/native"; 64 | 65 | import Markdown from "react-native-markdown-display"; 66 | 67 | const copy = `# h1 Heading 8-) 68 | 69 | **This is some bold text!** 70 | 71 | This is normal text 72 | `; 73 | 74 | const MarkdownWrapper: React.FC = ({ children }) => { 75 | const { colors } = useTheme(); 76 | // @ts-ignore 77 | return {children}; 78 | }; 79 | 80 | const App: () => React.ReactElement = () => { 81 | return ( 82 | <> 83 | 84 | 85 | 89 | {copy} 90 | 91 | 92 | 93 | ); 94 | }; 95 | 96 | export const styles = (props: any) => 97 | StyleSheet.create({ 98 | text: { 99 | color: props.colors.text, 100 | }, 101 | }); 102 | 103 | export default App; 104 | ``` 105 | 106 | With text input 107 | 108 | ```jsx 109 | import React from "react"; 110 | import { 111 | StyleSheet, 112 | SafeAreaView, 113 | ScrollView, 114 | StatusBar, 115 | TextInput, 116 | } from "react-native"; 117 | import { useTheme } from "@react-navigation/native"; 118 | 119 | import Markdown from "react-native-markdown-display"; 120 | 121 | const copy = `# h1 Heading 8-) 122 | 123 | **This is some bold text!** 124 | 125 | This is normal text 126 | `; 127 | 128 | const MarkdownWrapper: React.FC = ({ children }) => { 129 | const { colors } = useTheme(); 130 | // @ts-ignore 131 | return {children}; 132 | }; 133 | 134 | const App: () => React.ReactElement = () => { 135 | const [text, onChangeText] = React.useState(copy); 136 | const { colors } = useTheme(); 137 | return ( 138 | <> 139 | 140 | 141 | 145 | 151 | {text} 152 | 153 | 154 | 155 | ); 156 | }; 157 | 158 | export const styles = (props: any) => 159 | StyleSheet.create({ 160 | text: { 161 | color: props.colors.text, 162 | }, 163 | input: { 164 | height: 80, 165 | margin: 12, 166 | borderWidth: 1, 167 | padding: 10, 168 | }, 169 | }); 170 | 171 | export default App; 172 | ``` 173 | 174 | ### Props and Functions 175 | 176 | The `` object takes the following common props: 177 | 178 | | Property | Default | Required | Description 179 | | --- | --- | --- | --- 180 | | `children` | N/A | `true` | The markdown string to render, or the [pre-processed tree](#pre-processing) 181 | | `style` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/styles.js) | `false` | An object to override the styling for the various rules, [see style section below](#rules-and-styles) for more info 182 | | `mergeStyle` | `true` | `false` | If true, when a style is supplied, the individual items are merged with the default styles instead of overwriting them 183 | | `rules` | [source](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/renderRules.js) | `false` | An object of rules that specify how to render each markdown item, [see rules section below](#rules) for more info 184 | | `onLinkPress` | `import { Linking } from 'react-native';` and `Linking.openURL(url);` | `false` | A handler function to change click behaviour, [see handling links section below](#handling-links) for more info 185 | | `debugPrintTree` | `false` | `false` | Will print the AST tree to the console to help you see what the markdown is being translated to 186 | 187 | 188 | And some additional, less used options: 189 | 190 | | Property | Default | Required | Description 191 | | --- | --- | --- | --- 192 | | `renderer` | `instanceOf(AstRenderer)` | `false` | Used to specify a custom renderer, you can not use the rules or styles props with a custom renderer. 193 | | `markdownit` | `instanceOf(MarkdownIt)` | `false` | A custom markdownit instance with your configuration, default is `MarkdownIt({typographer: true})` 194 | | `maxTopLevelChildren` | `null` | `false` | If defined as a number will only render out first `n` many top level children, then will try to render out `topLevelMaxExceededItem` 195 | | `topLevelMaxExceededItem` | `...` | `false` | Will render when `maxTopLevelChildren` is hit. Make sure to give it a key! 196 | | `allowedImageHandlers` | `['data:image/png;base64', 'data:image/gif;base64', 'data:image/jpeg;base64', 'https://', 'http://']` | `false` | Any image that does not start with one of these will have the `defaultImageHandler` value prepended to it (unless `defaultImageHandler` is null in which case it won't try to render anything) 197 | | `defaultImageHandler` | `http://` | `false` | Will be prepended to an image url if it does not start with something in the `allowedImageHandlers` array, if this is set to null, it won't try to recover but will just not render anything instead. 198 | 199 | 200 | # Syntax Support 201 | 202 |
Headings 203 |

204 | 205 | ``` 206 | # h1 Heading 8-) 207 | ## h2 Heading 208 | ### h3 Heading 209 | #### h4 Heading 210 | ##### h5 Heading 211 | ###### h6 Heading 212 | ``` 213 | 214 | | iOS | Android 215 | | --- | --- 216 | | | 217 | 218 |

219 |
220 | 221 | 222 |
Horizontal Rules 223 |

224 | 225 | ``` 226 | Some text above 227 | ___ 228 | 229 | Some text in the middle 230 | 231 | --- 232 | 233 | Some text below 234 | ``` 235 | 236 | | iOS | Android 237 | | --- | --- 238 | | | 239 | 240 | 241 |

242 |
243 | 244 | 245 | 246 |
Emphasis 247 |

248 | 249 | ``` 250 | **This is bold text** 251 | 252 | __This is bold text__ 253 | 254 | *This is italic text* 255 | 256 | _This is italic text_ 257 | 258 | ~~Strikethrough~~ 259 | ``` 260 | 261 | | iOS | Android 262 | | --- | --- 263 | | | 264 | 265 |

266 |
267 | 268 | 269 |
Blockquotes 270 |

271 | 272 | ``` 273 | > Blockquotes can also be nested... 274 | >> ...by using additional greater-than signs right next to each other... 275 | > > > ...or with spaces between arrows. 276 | ``` 277 | 278 | | iOS | Android 279 | | --- | --- 280 | | | 281 | 282 |

283 |
284 | 285 | 286 |
Lists 287 |

288 | 289 | ``` 290 | Unordered 291 | 292 | + Create a list by starting a line with `+`, `-`, or `*` 293 | + Sub-lists are made by indenting 2 spaces: 294 | - Marker character change forces new list start: 295 | * Ac tristique libero volutpat at 296 | + Facilisis in pretium nisl aliquet. This is a very long list item that will surely wrap onto the next line. 297 | - Nulla volutpat aliquam velit 298 | + Very easy! 299 | 300 | Ordered 301 | 302 | 1. Lorem ipsum dolor sit amet 303 | 2. Consectetur adipiscing elit. This is a very long list item that will surely wrap onto the next line. 304 | 3. Integer molestie lorem at massa 305 | 306 | Start numbering with offset: 307 | 308 | 57. foo 309 | 58. bar 310 | ``` 311 | 312 | | iOS | Android 313 | | --- | --- 314 | | | 315 | 316 |

317 |
318 | 319 | 320 |
Code 321 |

322 | 323 | ``` 324 | Inline \`code\` 325 | 326 | Indented code 327 | 328 | // Some comments 329 | line 1 of code 330 | line 2 of code 331 | line 3 of code 332 | 333 | 334 | Block code "fences" 335 | 336 | \`\`\` 337 | Sample text here... 338 | \`\`\` 339 | 340 | Syntax highlighting 341 | 342 | \`\`\` js 343 | var foo = function (bar) { 344 | return bar++; 345 | }; 346 | 347 | console.log(foo(5)); 348 | \`\`\` 349 | ``` 350 | 351 | | iOS | Android 352 | | --- | --- 353 | | | 354 | 355 |

356 |
357 | 358 | 359 |
Tables 360 |

361 | 362 | ``` 363 | | Option | Description | 364 | | ------ | ----------- | 365 | | data | path to data files to supply the data that will be passed into templates. | 366 | | engine | engine to be used for processing templates. Handlebars is the default. | 367 | | ext | extension to be used for dest files. | 368 | 369 | Right aligned columns 370 | 371 | | Option | Description | 372 | | ------:| -----------:| 373 | | data | path to data files to supply the data that will be passed into templates. | 374 | | engine | engine to be used for processing templates. Handlebars is the default. | 375 | | ext | extension to be used for dest files. | 376 | ``` 377 | 378 | | iOS | Android 379 | | --- | --- 380 | | | 381 | 382 |

383 |
384 | 385 |
Links 386 |

387 | 388 | ``` 389 | [link text](https://www.google.com) 390 | 391 | [link with title](https://www.google.com "title text!") 392 | 393 | Autoconverted link https://www.google.com (enable linkify to see) 394 | ``` 395 | 396 | | iOS | Android 397 | | --- | --- 398 | | | 399 | 400 |

401 |
402 | 403 |
Images 404 |

405 | 406 | ``` 407 | ![Minion](https://octodex.github.com/images/minion.png) 408 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 409 | 410 | Like links, Images also have a footnote style syntax 411 | 412 | ![Alt text][id] 413 | 414 | With a reference later in the document defining the URL location: 415 | 416 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 417 | ``` 418 | 419 | | iOS | Android 420 | | --- | --- 421 | | | 422 | 423 |

424 |
425 | 426 | 427 |
Typographic Replacements 428 |

429 | 430 | ``` 431 | Enable typographer option to see result. 432 | 433 | (c) (C) (r) (R) (tm) (TM) (p) (P) +- 434 | 435 | test.. test... test..... test?..... test!.... 436 | 437 | !!!!!! ???? ,, -- --- 438 | 439 | "Smartypants, double quotes" and 'single quotes' 440 | ``` 441 | 442 | | iOS | Android 443 | | --- | --- 444 | | | 445 | 446 |

447 |
448 | 449 | 450 |
Plugins and Extensions 451 |

452 | 453 | Plugins for **extra** syntax support can be added using any markdown-it compatible plugins - [see plugins](https://www.npmjs.com/browse/keyword/markdown-it-plugin) for documentation from markdown-it. An example for integration follows: 454 | 455 | 456 | #### Step 1 457 | 458 | Identify the new components and integrate the plugin with a rendered component. We can use the `debugPrintTree` property to see what rules we are rendering: 459 | 460 | 461 | ```jsx 462 | import React from 'react'; 463 | import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; 464 | 465 | import Markdown, { MarkdownIt }from '@ronradtke/react-native-markdown-display'; 466 | import blockEmbedPlugin from 'markdown-it-block-embed'; 467 | 468 | const markdownItInstance = 469 | MarkdownIt({typographer: true}) 470 | .use(blockEmbedPlugin, { 471 | containerClassName: "video-embed" 472 | }); 473 | 474 | const copy = ` 475 | # Some header 476 | 477 | @[youtube](lJIrF4YjHfQ) 478 | `; 479 | 480 | const App: () => React$Node = () => { 481 | return ( 482 | <> 483 | 484 | 485 | 489 | 493 | {copy} 494 | 495 | 496 | 497 | 498 | ); 499 | }; 500 | 501 | export default App; 502 | 503 | ``` 504 | 505 | In the console, we will see the following rendered tree: 506 | 507 | ``` 508 | body 509 | -heading1 510 | --textgroup 511 | ---text 512 | -video 513 | ``` 514 | 515 | With the following error message: 516 | 517 | ``` 518 | Warning, unknown render rule encountered: video. 'unknown' render rule used (by default, returns null - nothing rendered) 519 | ``` 520 | 521 | 522 | #### Step 2 523 | 524 | We need to create the **render rules** and **styles** to handle this new **'video'** component 525 | 526 | 527 | ```jsx 528 | import React from 'react'; 529 | import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; 530 | 531 | import Markdown, { MarkdownIt }from '@ronradtke/react-native-markdown-display'; 532 | import blockEmbedPlugin from 'markdown-it-block-embed'; 533 | 534 | const markdownItInstance = 535 | MarkdownIt({typographer: true}) 536 | .use(blockEmbedPlugin, { 537 | containerClassName: "video-embed" 538 | }); 539 | 540 | const copy = ` 541 | # Some header 542 | 543 | @[youtube](lJIrF4YjHfQ) 544 | `; 545 | 546 | const App: () => React$Node = () => { 547 | return ( 548 | <> 549 | 550 | 551 | 555 | { 565 | // examine the node properties to see what video we need to render 566 | console.log(node); // expected output of this is in readme.md below this code snip 567 | 568 | return ( 569 | Return a video component instead of this text component! 570 | ); 571 | } 572 | 573 | }} 574 | > 575 | {copy} 576 | 577 | 578 | 579 | 580 | ); 581 | }; 582 | 583 | export default App; 584 | ``` 585 | 586 | And all of the video properties needed to render something meaningful are on the node, like this: 587 | 588 | ``` 589 | {type: "video", sourceType: "video", sourceInfo: {…}, sourceMeta: null, block: true, …} 590 | attributes: {} 591 | block: true 592 | children: [] 593 | content: "" 594 | index: 1 595 | key: "rnmr_1720a98f540_video" 596 | markup: "@[youtube](lJIrF4YjHfQ)" 597 | sourceInfo: 598 | service: YouTubeService 599 | env: PluginEnvironment {md: MarkdownIt, options: {…}, services: {…}} 600 | name: "youtube" 601 | options: 602 | height: 390 603 | width: 640 604 | serviceName: "youtube" 605 | videoID: "lJIrF4YjHfQ" 606 | videoReference: "lJIrF4YjHfQ" 607 | sourceMeta: null 608 | sourceType: "video" 609 | tokenIndex: 5 610 | type: "video" 611 | ``` 612 | 613 | #### Other Debugging 614 | 615 | You can do some additional debugging of what the markdown instance is spitting out like this: 616 | 617 | ```jsx 618 | import Markdown, { MarkdownIt }from '@ronradtke/react-native-markdown-display'; 619 | import blockEmbedPlugin from 'markdown-it-block-embed'; 620 | 621 | const markdownItInstance = 622 | MarkdownIt({typographer: true}) 623 | .use(blockEmbedPlugin, { 624 | containerClassName: "video-embed" 625 | }); 626 | 627 | const copy = ` 628 | # Some header 629 | 630 | @[youtube](lJIrF4YjHfQ) 631 | `; 632 | 633 | // this shows you the tree that is used by the react-native-markdown-display 634 | const astTree = markdownItInstance.parse(copy, {}); 635 | console.log(astTree); 636 | 637 | //this contains the html that would be generated - not used by react-native-markdown-display but useful for reference 638 | const html = markdownItInstance.render(copy); 639 | console.log(html); 640 | 641 | ``` 642 | 643 | The above code will output something like this: 644 | 645 | ``` 646 | astTree: 647 | 648 | (4) [Token, Token, Token, Token] 649 | 650 | 0: Token {type: "heading_open", tag: "h1", attrs: null, map: Array(2), nesting: 1, …} 651 | 1: Token {type: "inline", tag: "", attrs: null, map: Array(2), nesting: 0, …} 652 | 2: Token {type: "heading_close", tag: "h1", attrs: null, map: null, nesting: -1, …} 653 | 3: Token {type: "video", tag: "div", attrs: null, map: Array(2), nesting: 0, …} 654 | 655 | length: 4 656 | ``` 657 | 658 | ``` 659 | html: 660 | 661 | 662 |

Some header

663 |
664 | ``` 665 | 666 | 667 |

668 |
669 | 670 | 671 |
All Markdown for Testing 672 |

673 | 674 | This is all of the markdown in one place for testing that your applied styles work in all cases 675 | 676 | ``` 677 | Headings 678 | 679 | # h1 Heading 8-) 680 | ## h2 Heading 681 | ### h3 Heading 682 | #### h4 Heading 683 | ##### h5 Heading 684 | ###### h6 Heading 685 | 686 | 687 | Horizontal Rules 688 | 689 | Some text above 690 | ___ 691 | 692 | Some text in the middle 693 | 694 | --- 695 | 696 | Some text below 697 | 698 | 699 | Emphasis 700 | 701 | **This is bold text** 702 | 703 | __This is bold text__ 704 | 705 | *This is italic text* 706 | 707 | _This is italic text_ 708 | 709 | ~~Strikethrough~~ 710 | 711 | 712 | Blockquotes 713 | 714 | > Blockquotes can also be nested... 715 | >> ...by using additional greater-than signs right next to each other... 716 | > > > ...or with spaces between arrows. 717 | 718 | 719 | Lists 720 | 721 | Unordered 722 | 723 | + Create a list by starting a line with `+`, `-`, or `*` 724 | + Sub-lists are made by indenting 2 spaces: 725 | - Marker character change forces new list start: 726 | * Ac tristique libero volutpat at 727 | + Facilisis in pretium nisl aliquet. This is a very long list item that will surely wrap onto the next line. 728 | - Nulla volutpat aliquam velit 729 | + Very easy! 730 | 731 | Ordered 732 | 733 | 1. Lorem ipsum dolor sit amet 734 | 2. Consectetur adipiscing elit. This is a very long list item that will surely wrap onto the next line. 735 | 3. Integer molestie lorem at massa 736 | 737 | Start numbering with offset: 738 | 739 | 57. foo 740 | 58. bar 741 | 742 | 743 | Code 744 | 745 | Inline \`code\` 746 | 747 | Indented code 748 | 749 | // Some comments 750 | line 1 of code 751 | line 2 of code 752 | line 3 of code 753 | 754 | 755 | Block code "fences" 756 | 757 | \`\`\` 758 | Sample text here... 759 | \`\`\` 760 | 761 | Syntax highlighting 762 | 763 | \`\`\` js 764 | var foo = function (bar) { 765 | return bar++; 766 | }; 767 | 768 | console.log(foo(5)); 769 | \`\`\` 770 | 771 | 772 | Tables 773 | 774 | | Option | Description | 775 | | ------ | ----------- | 776 | | data | path to data files to supply the data that will be passed into templates. | 777 | | engine | engine to be used for processing templates. Handlebars is the default. | 778 | | ext | extension to be used for dest files. | 779 | 780 | Right aligned columns 781 | 782 | | Option | Description | 783 | | ------:| -----------:| 784 | | data | path to data files to supply the data that will be passed into templates. | 785 | | engine | engine to be used for processing templates. Handlebars is the default. | 786 | | ext | extension to be used for dest files. | 787 | 788 | 789 | Links 790 | 791 | [link text](https://www.google.com) 792 | 793 | [link with title](https://www.google.com "title text!") 794 | 795 | Autoconverted link https://www.google.com (enable linkify to see) 796 | 797 | 798 | Images 799 | 800 | ![Minion](https://octodex.github.com/images/minion.png) 801 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 802 | 803 | Like links, Images also have a footnote style syntax 804 | 805 | ![Alt text][id] 806 | 807 | With a reference later in the document defining the URL location: 808 | 809 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 810 | 811 | 812 | Typographic Replacements 813 | 814 | Enable typographer option to see result. 815 | 816 | (c) (C) (r) (R) (tm) (TM) (p) (P) +- 817 | 818 | test.. test... test..... test?..... test!.... 819 | 820 | !!!!!! ???? ,, -- --- 821 | 822 | "Smartypants, double quotes" and 'single quotes' 823 | 824 | ``` 825 | 826 |

827 |
828 | 829 | 830 | # Rules and Styles 831 | 832 | ### How to style stuff 833 | 834 | Text styles are applied in a way that makes it much more convenient to manage changes to global styles while also allowing fine tuning of individual elements. 835 | 836 | Think of the implementation like applying styles in CSS. changes to the `body` effect everything, but can be overwritten further down the style / component tree. 837 | 838 | **Be careful when styling 'text':** the text rule is not applied to all rendered text, most notably list bullet points. If you want to, for instance, color all text, change the `body` style. 839 | 840 | 841 |
Example 842 |

843 | 844 | 845 | 846 | ```jsx 847 | import React from 'react'; 848 | import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; 849 | 850 | import Markdown from '@ronradtke/react-native-markdown-display'; 851 | 852 | const copy = ` 853 | This is some text which is red because of the body style, which is also really small! 854 | 855 | \`\`\` 856 | //This is a code block woooo 857 | 858 | const cool = () => { 859 | console.log('????'); 860 | }; 861 | \`\`\` 862 | 863 | and some more small text 864 | 865 | # This is a h1 866 | ## this is a h2 867 | ### this is a h3 868 | `; 869 | 870 | const App: () => React$Node = () => { 871 | return ( 872 | <> 873 | 874 | 875 | 879 | 886 | {copy} 887 | 888 | 889 | 890 | 891 | ); 892 | }; 893 | 894 | export default App; 895 | ``` 896 | 897 |

898 |
899 | 900 | ### Styles 901 | 902 | Styles are used to override how certain rules are styled. The existing implementation is [here](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/styles.js) 903 | 904 | **NOTE:** By default styles are merged with the existing implementation, to change this, see the `mergeStyle` prop 905 | 906 |
Example Implementation 907 |

908 | 909 | ```jsx 910 | import React from 'react'; 911 | import { SafeAreaView, ScrollView, StatusBar, StyleSheet } from 'react-native'; 912 | 913 | import Markdown from '@ronradtke/react-native-markdown-display'; 914 | 915 | const styles = StyleSheet.create({ 916 | heading1: { 917 | fontSize: 32, 918 | backgroundColor: '#000000', 919 | color: '#FFFFFF', 920 | }, 921 | heading2: { 922 | fontSize: 24, 923 | }, 924 | heading3: { 925 | fontSize: 18, 926 | }, 927 | heading4: { 928 | fontSize: 16, 929 | }, 930 | heading5: { 931 | fontSize: 13, 932 | }, 933 | heading6: { 934 | fontSize: 11, 935 | } 936 | }); 937 | 938 | const copy = ` 939 | # h1 Heading 8-) 940 | ## h2 Heading 8-) 941 | ### h3 Heading 8-) 942 | 943 | | Option | Description | 944 | | ------ | ----------- | 945 | | data | path to data files to supply the data that will be passed into templates. | 946 | | engine | engine to be used for processing templates. Handlebars is the default. | 947 | | ext | extension to be used for dest files. | 948 | `; 949 | 950 | const App: () => React$Node = () => { 951 | return ( 952 | <> 953 | 954 | 955 | 959 | 962 | {copy} 963 | 964 | 965 | 966 | 967 | ); 968 | }; 969 | 970 | export default App; 971 | ``` 972 | 973 |

974 |
975 | 976 | ### Rules 977 | 978 | Rules are used to specify how you want certain elements to be displayed. The existing implementation is [here](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/renderRules.js) 979 | 980 |
Example Implementation 981 |

982 | 983 | ```jsx 984 | import React from 'react'; 985 | import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native'; 986 | 987 | import Markdown from '@ronradtke/react-native-markdown-display'; 988 | 989 | const rules = { 990 | heading1: (node, children, parent, styles) => 991 | 992 | >> H1 TEXT HERE >> "{children}" 993 | , 994 | heading2: (node, children, parent, styles) => 995 | 996 | >> H2 TEXT HERE >> "{children}" 997 | , 998 | heading3: (node, children, parent, styles) => 999 | 1000 | >> H3 TEXT HERE >> "{children}" 1001 | , 1002 | }; 1003 | 1004 | const copy = ` 1005 | # h1 Heading 8-) 1006 | ## h2 Heading 8-) 1007 | ### h3 Heading 8-) 1008 | 1009 | | Option | Description | 1010 | | ------ | ----------- | 1011 | | data | path to data files to supply the data that will be passed into templates. | 1012 | | engine | engine to be used for processing templates. Handlebars is the default. | 1013 | | ext | extension to be used for dest files. | 1014 | `; 1015 | 1016 | const App: () => React$Node = () => { 1017 | return ( 1018 | <> 1019 | 1020 | 1021 | 1025 | 1028 | {copy} 1029 | 1030 | 1031 | 1032 | 1033 | ); 1034 | }; 1035 | 1036 | export default App; 1037 | ``` 1038 | 1039 |

1040 |
1041 | 1042 | 1043 | ### All rules and their associated styles: 1044 | 1045 | | Render Rule | Style(s) | 1046 | | ------ | ----------- | 1047 | | `body` | `body` | 1048 | | `heading1` | `heading1` | 1049 | | `heading2` | `heading2` | 1050 | | `heading3` | `heading3` | 1051 | | `heading4` | `heading4` | 1052 | | `heading5` | `heading5` | 1053 | | `heading6` | `heading6` | 1054 | | `hr` | `hr` | 1055 | | `strong` | `strong` | 1056 | | `em` | `em` | 1057 | | `s` | `s` | 1058 | | `blockquote` | `blockquote` | 1059 | | `bullet_list` | `bullet_list` | 1060 | | `ordered_list` | `ordered_list` | 1061 | | `list_item` | `list_item` - This is a special case that contains a set of pseudo classes that don't align to the render rule: `ordered_list_icon`, `ordered_list_content`, `bullet_list_icon`, `bullet_list_content` | 1062 | | `code_inline` | `code_inline` | 1063 | | `code_block` | `code_block` | 1064 | | `fence` | `fence` | 1065 | | `table` | `table` | 1066 | | `thead` | `thead` | 1067 | | `tbody` | `tbody` | 1068 | | `th` | `th` | 1069 | | `tr` | `tr` | 1070 | | `td` | `td` | 1071 | | `link` | `link` | 1072 | | `blocklink` | `blocklink` | 1073 | | `image` | `image` | 1074 | | `text` | `text` | 1075 | | `textgroup` | `textgroup` | 1076 | | `paragraph` | `paragraph` | 1077 | | `hardbreak` | `hardbreak` | 1078 | | `softbreak` | `softbreak` | 1079 | | `pre` | `pre` | 1080 | | `inline` | `inline` | 1081 | | `span` | `span` | 1082 | 1083 | # Handling Links 1084 | 1085 | Links, by default, will be handled with the `import { Linking } from 'react-native';` import and `Linking.openURL(url);` call. 1086 | 1087 | It is possible to overwrite this behaviour in one of two ways: 1088 | 1089 |
onLinkPress Callback 1090 |

1091 | 1092 | ```jsx 1093 | import React from 'react'; 1094 | import { SafeAreaView, ScrollView, StatusBar } from 'react-native'; 1095 | 1096 | import Markdown from '@ronradtke/react-native-markdown-display'; 1097 | 1098 | const copy = `[This is a link!](https://github.com/iamacup/react-native-markdown-display/)`; 1099 | 1100 | const onLinkPress = (url) => { 1101 | if (url) { 1102 | // some custom logic 1103 | return false; 1104 | } 1105 | 1106 | // return true to open with `Linking.openURL 1107 | // return false to handle it yourself 1108 | return true 1109 | } 1110 | 1111 | const App: () => React$Node = () => { 1112 | return ( 1113 | <> 1114 | 1115 | 1116 | 1120 | 1123 | {copy} 1124 | 1125 | 1126 | 1127 | 1128 | ); 1129 | }; 1130 | 1131 | export default App; 1132 | ``` 1133 | 1134 |

1135 |
1136 | 1137 |
Using a Custom Rule 1138 |

1139 | 1140 | You will need to overwrite one or both of `link` and `blocklink`, the original defenitions can be [found here](https://github.com/iamacup/react-native-markdown-display/blob/master/src/lib/renderRules.js) 1141 | 1142 | Something like this with `yourCustomHandlerFunctionOrLogicHere`: 1143 | 1144 | ```jsx 1145 | import React from 'react'; 1146 | import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native'; 1147 | 1148 | import Markdown from '@ronradtke/react-native-markdown-display'; 1149 | 1150 | const copy = `[This is a link!](https://github.com/iamacup/react-native-markdown-display/)`; 1151 | 1152 | const rules = { 1153 | link: (node, children, parent, styles) => { 1154 | return ( 1155 | yourCustomHandlerFunctionOrLogicHere(node.attributes.href) }> 1156 | {children} 1157 | 1158 | ); 1159 | }, 1160 | }; 1161 | 1162 | const App: () => React$Node = () => { 1163 | return ( 1164 | <> 1165 | 1166 | 1167 | 1171 | 1174 | {copy} 1175 | 1176 | 1177 | 1178 | 1179 | ); 1180 | }; 1181 | 1182 | export default App; 1183 | ``` 1184 | 1185 |

1186 |
1187 | 1188 | 1189 | # Disabling Specific Types of Markdown 1190 | 1191 | You can dissable any type of markdown you want, which is very useful in a mobile environment, by passing the markdownit property like below. Note that for convenience we also export the `MarkdownIt` instance so you don't have to include it as a project dependency directly just to remove some types of markdown. 1192 | 1193 | This example will stop images and links. 1194 | 1195 | ```jsx 1196 | import React from 'react'; 1197 | import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native'; 1198 | 1199 | import Markdown, { MarkdownIt }from '@ronradtke/react-native-markdown-display'; 1200 | 1201 | const copy = ` 1202 | # This heading will show with formatting 1203 | 1204 | [but this link will just](be displayed as this text) 1205 | `; 1206 | 1207 | const App: () => React$Node = () => { 1208 | return ( 1209 | <> 1210 | 1211 | 1212 | 1216 | 1221 | {copy} 1222 | 1223 | 1224 | 1225 | 1226 | ); 1227 | }; 1228 | 1229 | export default App; 1230 | ``` 1231 | 1232 | A full list of things you can turn off is [here](https://github.com/markdown-it/markdown-it/blob/master/lib/presets/commonmark.js) 1233 | 1234 | 1235 | ### Pre Processing 1236 | 1237 | It is possible to need to pre-process the data outside of this library ([related discussion here](https://github.com/iamacup/react-native-markdown-display/issues/79)). As a result, you can pass an AST tree directly as the children like this: 1238 | 1239 | ```jsx 1240 | import React from 'react'; 1241 | import { SafeAreaView, ScrollView, StatusBar, Text } from 'react-native'; 1242 | 1243 | import Markdown, { MarkdownIt, tokensToAST, stringToTokens }from '@ronradtke/react-native-markdown-display'; 1244 | 1245 | const markdownItInstance = MarkdownIt({typographer: true}); 1246 | 1247 | const copy = ` 1248 | # Hello this is a title 1249 | 1250 | This is some text with **BOLD!** 1251 | `; 1252 | 1253 | const ast = tokensToAST(stringToTokens(copy, markdownItInstance)) 1254 | 1255 | const App: () => React$Node = () => { 1256 | return ( 1257 | <> 1258 | 1259 | 1260 | 1264 | 1266 | {ast} 1267 | 1268 | 1269 | 1270 | 1271 | ); 1272 | }; 1273 | 1274 | export default App; 1275 | ``` 1276 | 1277 | 1278 | ### Other Notes 1279 | 1280 | This is a fork of [react-native-markdown-renderer](https://github.com/mientjan/react-native-markdown-renderer), a library that unfortunately has not been updated for some time so i took all of the outstanding pull requests from that library and tested + merged as necessary. 1281 | --------------------------------------------------------------------------------