├── .deepsource.toml ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── codeql.yml │ └── perf.yml ├── .gitignore ├── .reassure └── .gitkeep ├── .watchmanconfig ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── dark-theme-01.png └── light-theme-01.png ├── babel.config.js ├── biome.json ├── dangerfile.ts ├── examples └── react-native-marked-sample │ ├── .gitignore │ ├── App.tsx │ ├── app.json │ ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash-icon.png │ ├── babel.config.js │ ├── const.ts │ ├── index.ts │ ├── metro.config.js │ ├── package.json │ ├── tsconfig.json │ └── yarn.lock ├── lefthook.yml ├── package.json ├── reassure-tests.sh ├── renovate.json ├── src ├── components │ ├── MDImage.tsx │ ├── MDSvg.tsx │ └── MDTable.tsx ├── hooks │ └── useMarkdown.ts ├── index.ts ├── lib │ ├── Markdown.tsx │ ├── Parser.tsx │ ├── Renderer.tsx │ ├── __perf__ │ │ └── Markdown.perf-test.tsx │ ├── __tests__ │ │ ├── Markdown.spec.tsx │ │ ├── Renderer.spec.tsx │ │ └── __snapshots__ │ │ │ ├── Markdown.spec.tsx.snap │ │ │ └── Renderer.spec.tsx.snap │ └── types.ts ├── theme │ ├── __tests__ │ │ └── styles.spec.ts │ ├── colors.ts │ ├── spacing.ts │ ├── styles.ts │ └── types.ts └── utils │ ├── __tests__ │ ├── handlers.spec.ts │ ├── svg.spec.ts │ ├── table.spec.ts │ └── url.spec.ts │ ├── handlers.ts │ ├── svg.ts │ ├── table.ts │ └── url.ts ├── tsconfig.json └── yarn.lock /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = ["*/src/*/__test__/**"] 4 | 5 | [[analyzers]] 6 | name = "javascript" 7 | 8 | [analyzers.meta] 9 | plugins = ["react"] 10 | dialect = "typescript" 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pbxproj -text 2 | # specific for windows script files 3 | *.bat text eol=crlf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [gmsgowtham] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/gmsgowtham'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: gmsgowtham 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Reproducible Code** 17 | Provide a minimal repo/code example that reproduces the issue. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Environment** 26 | 27 | - **React Native Version:** 28 | Specify the version of React Native you are using. 29 | 30 | - **react-native-marked Version:** 31 | Specify the version of react-native-marked you are using. 32 | 33 | - **Platform:** 34 | Specify the platform where you encountered the issue (e.g., iOS, Android). 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: gmsgowtham 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22.14.0 19 | cache: "yarn" 20 | - name: Install dependencies and Build 21 | run: yarn install --frozen-lockfile 22 | - name: Lint code 23 | run: yarn lint 24 | - name: Test 25 | run: yarn test --collectCoverage --silent 26 | - name: Coveralls 27 | uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['main'] 20 | schedule: 21 | - cron: '35 3 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['typescript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v3 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | with: 73 | category: '/language:${{matrix.language}}' 74 | -------------------------------------------------------------------------------- /.github/workflows/perf.yml: -------------------------------------------------------------------------------- 1 | name: Perf 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22.14.0 19 | cache: "yarn" 20 | - name: Run performance tests 21 | run: ./reassure-tests.sh 22 | - name: Run Danger.js 23 | run: yarn danger ci 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | jsconfig.json 11 | 12 | # Xcode 13 | # 14 | build/ 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata 24 | *.xccheckout 25 | *.moved-aside 26 | DerivedData 27 | *.hmap 28 | *.ipa 29 | *.xcuserstate 30 | project.xcworkspace 31 | 32 | # Android/IJ 33 | # 34 | .classpath 35 | .cxx 36 | .gradle 37 | .idea 38 | .project 39 | .settings 40 | local.properties 41 | android.iml 42 | 43 | # Cocoapods 44 | # 45 | example/ios/Pods 46 | 47 | # Ruby 48 | example/vendor/ 49 | 50 | # node.js 51 | # 52 | node_modules/ 53 | npm-debug.log 54 | yarn-debug.log 55 | yarn-error.log 56 | 57 | # BUCK 58 | buck-out/ 59 | \.buckd/ 60 | android/app/libs 61 | android/keystores/debug.keystore 62 | 63 | # Expo 64 | .expo/* 65 | 66 | # generated by bob 67 | dist/ 68 | 69 | # test 70 | coverage 71 | .reassure/current.perf 72 | -------------------------------------------------------------------------------- /.reassure/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/.reassure/.gitkeep -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | webappsbygowtham@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. 4 | 5 | ## Development workflow 6 | 7 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: 8 | 9 | ```sh 10 | yarn 11 | ``` 12 | 13 | > While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development. 14 | 15 | While developing, you can run the [example app](/examples/) to test your changes. Any changes you make in your library's JavaScript code will be reflected in the example app without a rebuild. If you change any native code, then you'll need to rebuild the example app. 16 | 17 | To run example app: 18 | 19 | ```sh 20 | cd examples/react-native-marked-sample 21 | yarn install 22 | ``` 23 | 24 | To run the example app on Android: 25 | 26 | ```sh 27 | yarn android 28 | ``` 29 | 30 | To run the example app on iOS: 31 | 32 | ```sh 33 | yarn ios 34 | ``` 35 | 36 | To run the example app on web: 37 | 38 | ```sh 39 | yarn web 40 | ``` 41 | 42 | Make sure your code passes TypeScript and Lint. Run the following to verify: 43 | 44 | ```sh 45 | yarn typescript 46 | yarn lint 47 | ``` 48 | 49 | To fix formatting errors, run the following: 50 | 51 | ```sh 52 | yarn format 53 | ``` 54 | 55 | Remember to add tests for your change if possible. Run the unit tests by: 56 | 57 | ```sh 58 | yarn test 59 | ``` 60 | 61 | ### Commit message convention 62 | 63 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages: 64 | 65 | - `fix`: bug fixes, e.g. fix crash due to deprecated method. 66 | - `feat`: new features, e.g. add new method to the module. 67 | - `refactor`: code refactor, e.g. migrate from class components to hooks. 68 | - `docs`: changes into documentation, e.g. add usage example for the module.. 69 | - `test`: adding or updating tests, e.g. add integration tests using detox. 70 | - `chore`: tooling changes, e.g. change CI config. 71 | 72 | Our pre-commit hooks verify that your commit message matches this format when committing. 73 | 74 | ### Linting and tests 75 | 76 | [Biome](https://biomejs.dev/), [TypeScript](https://www.typescriptlang.org/) 77 | 78 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [Biome](https://biomejs.dev/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. 79 | 80 | Our pre-commit hooks verify that the linter and tests pass when committing. 81 | 82 | ### Publishing to npm 83 | 84 | We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. 85 | 86 | To publish new versions, run the following: 87 | 88 | ```sh 89 | yarn release 90 | ``` 91 | 92 | ### Scripts 93 | 94 | The `package.json` file contains various scripts for common tasks: 95 | 96 | - `yarn bootstrap`: setup project by installing all dependencies and pods. 97 | - `yarn typescript`: type-check files with TypeScript. 98 | - `yarn lint`: lint files with Biome. 99 | - `yarn format`: format files with Biome. 100 | - `yarn test`: run unit tests with Jest. 101 | - `yarn example start`: start the Metro server for the example app. 102 | - `yarn example android`: run the example app on Android. 103 | - `yarn example ios`: run the example app on iOS. 104 | 105 | ### Sending a pull request 106 | 107 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). 108 | 109 | When you're sending a pull request: 110 | 111 | - Prefer small pull requests focused on one change. 112 | - Verify that linters and tests are passing. 113 | - Review the documentation to make sure it looks good. 114 | - Follow the pull request template when opening a pull request. 115 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. 116 | 117 | ### Repo visualization 118 | 119 | ![Visualization of this repo](./diagram.svg) 120 | 121 | ## Code of Conduct 122 | 123 | ### Our Pledge 124 | 125 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 126 | 127 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 128 | 129 | ### Our Standards 130 | 131 | Examples of behavior that contributes to a positive environment for our community include: 132 | 133 | - Demonstrating empathy and kindness toward other people 134 | - Being respectful of differing opinions, viewpoints, and experiences 135 | - Giving and gracefully accepting constructive feedback 136 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 137 | - Focusing on what is best not just for us as individuals, but for the overall community 138 | 139 | Examples of unacceptable behavior include: 140 | 141 | - The use of sexualized language or imagery, and sexual attention or 142 | advances of any kind 143 | - Trolling, insulting or derogatory comments, and personal or political attacks 144 | - Public or private harassment 145 | - Publishing others' private information, such as a physical or email 146 | address, without their explicit permission 147 | - Other conduct which could reasonably be considered inappropriate in a 148 | professional setting 149 | 150 | ### Enforcement Responsibilities 151 | 152 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 153 | 154 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 155 | 156 | ### Scope 157 | 158 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 159 | 160 | ### Enforcement 161 | 162 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. 163 | 164 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 165 | 166 | ### Enforcement Guidelines 167 | 168 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 169 | 170 | #### 1. Correction 171 | 172 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 173 | 174 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 175 | 176 | #### 2. Warning 177 | 178 | **Community Impact**: A violation through a single incident or series of actions. 179 | 180 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 181 | 182 | #### 3. Temporary Ban 183 | 184 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 185 | 186 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 187 | 188 | #### 4. Permanent Ban 189 | 190 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 191 | 192 | **Consequence**: A permanent ban from any sort of public interaction within the community. 193 | 194 | ### Attribution 195 | 196 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 197 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 198 | 199 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 200 | 201 | [homepage]: https://www.contributor-covenant.org 202 | 203 | For answers to common questions about this code of conduct, see the FAQ at 204 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gowtham G 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-marked 2 | 3 | [![GitHub license](https://img.shields.io/github/license/gmsgowtham/react-native-marked)](https://github.com/gmsgowtham/react-native-marked/blob/main/LICENSE) 4 | [![CI](https://github.com/gmsgowtham/react-native-marked/actions/workflows/build.yml/badge.svg)](https://github.com/gmsgowtham/react-native-marked/actions/workflows/build.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/gmsgowtham/react-native-marked/badge.svg?branch=main)](https://coveralls.io/github/gmsgowtham/react-native-marked?branch=main) 6 | [![npm](https://img.shields.io/npm/v/react-native-marked)](https://www.npmjs.com/package/react-native-marked) 7 | [![npm](https://img.shields.io/npm/dw/react-native-marked)](https://www.npmjs.com/package/react-native-marked) 8 | 9 | Markdown renderer for React Native powered by 10 | [marked.js](https://marked.js.org/) with built-in theming support 11 | 12 | ## Installation 13 | 14 | #### For React Native 0.76 and above, please use the latest version. 15 | ```sh 16 | yarn add react-native-marked@rc react-native-svg 17 | ``` 18 | 19 | #### For React Native 0.75 and below, please use version 6. 20 | ```sh 21 | yarn add react-native-marked@6.0.7 react-native-svg 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### Using Component 27 | 28 | ```tsx 29 | import * as React from "react"; 30 | import Markdown from "react-native-marked"; 31 | 32 | const ExampleComponent = () => { 33 | return ( 34 | 40 | ); 41 | }; 42 | 43 | export default ExampleComponent; 44 | ``` 45 | 46 | #### [Props](https://github.com/gmsgowtham/react-native-marked/blob/main/src/lib/types.ts#L17) 47 | 48 | | Prop | Description | Type | Optional? | 49 | |---------------|----------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------| 50 | | value | Markdown value | string | false | 51 | | flatListProps | Props for customizing the underlying FlatList used | `Omit, 'data' \| 'renderItem' \| 'horizontal'>`


(`'data'`, `'renderItem'`, and `'horizontal'` props are omitted and cannot be overridden.) | true | 52 | | styles | Styles for parsed components | [MarkedStyles](src/theme/types.ts) | true | 53 | | theme | Props for customizing colors and spacing for all components,and it will get overridden with custom component style applied via 'styles' prop | [UserTheme](src/theme/types.ts) | true | 54 | | baseUrl | A prefix url for any relative link | string | true | 55 | | renderer | Custom component Renderer | [RendererInterface](src/lib/types.ts) | true | 56 | 57 | 58 | ### Using hook 59 | 60 | `useMarkdown` hook will return list of elements that can be rendered using a list component of your choice. 61 | 62 | ```tsx 63 | import React, { Fragment } from "react"; 64 | import { ScrollView, useColorScheme } from "react-native"; 65 | import { useMarkdown, type useMarkdownHookOptions } from "react-native-marked"; 66 | 67 | const CustomComponent = () => { 68 | const colorScheme = useColorScheme(); 69 | const options: useMarkdownHookOptions = { 70 | colorScheme 71 | } 72 | const elements = useMarkdown("# Hello world", options); 73 | return ( 74 | 75 | {elements.map((element, index) => { 76 | return {element} 77 | })} 78 | 79 | ); 80 | }; 81 | ``` 82 | 83 | #### Options 84 | 85 | | Option | Description | Type | Optional? | 86 | |-------------|----------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------|-----------| 87 | | colorScheme | Device color scheme ("dark" or "light") | ColorSchemeName | false | 88 | | styles | Styles for parsed components | [MarkedStyles](src/theme/types.ts) | true | 89 | | theme | Props for customizing colors and spacing for all components,and it will get overridden with custom component style applied via 'styles' prop | [UserTheme](src/theme/types.ts) | true | 90 | | baseUrl | A prefix url for any relative link | string | true | 91 | | renderer | Custom component Renderer | [RendererInterface](src/lib/types.ts) | true | 92 | | tokenizer | Generate custom tokens | [MarkedTokenizer](src/lib/types.ts) | true | 93 | 94 | 95 | ## Examples 96 | 97 | - CodeSandbox: https://codesandbox.io/s/react-native-marked-l2hpi3?file=/src/App.js 98 | 99 | ## Supported elements 100 | 101 | - [x] Headings (1 to 6) 102 | - [x] Paragraph 103 | - [x] Emphasis (bold, italic, and strikethrough) 104 | - [x] Link 105 | - [x] Image 106 | - [x] Blockquote 107 | - [x] Inline Code 108 | - [x] Code Block 109 | - [x] List (ordered, unordered) 110 | - [x] Horizontal Rule 111 | - [x] Table 112 | - [ ] HTML 113 | 114 | Ref: [CommonMark](https://commonmark.org/help/) 115 | 116 | > HTML will be treated as plain text. Please refer [issue#290](https://github.com/gmsgowtham/react-native-marked/issues/290) for a potential solution 117 | 118 | ## Advanced 119 | ### Using custom components 120 | 121 | > Custom components can be used to override elements, i.e. Code Highlighting, Fast Image integration 122 | 123 | #### Example 124 | 125 | ```tsx 126 | import React, { ReactNode, Fragment } from "react"; 127 | import { Text, ScrollView } from "react-native"; 128 | import type { ImageStyle, TextStyle } from "react-native"; 129 | import Markdown, { Renderer, useMarkdown } from "react-native-marked"; 130 | import type { RendererInterface } from "react-native-marked"; 131 | import FastImage from "react-native-fast-image"; 132 | 133 | class CustomRenderer extends Renderer implements RendererInterface { 134 | constructor() { 135 | super(); 136 | } 137 | 138 | codespan(text: string, _styles?: TextStyle): ReactNode { 139 | return ( 140 | 141 | {text} 142 | 143 | ); 144 | } 145 | 146 | image(uri: string, _alt?: string, _style?: ImageStyle): ReactNode { 147 | return ( 148 | 154 | ); 155 | } 156 | } 157 | 158 | const renderer = new CustomRenderer(); 159 | 160 | const ExampleComponent = () => { 161 | return ( 162 | 169 | ); 170 | }; 171 | 172 | // Alternate using hook 173 | const ExampleComponentWithHook = () => { 174 | const elements = useMarkdown("`Hello world`", { renderer }); 175 | 176 | return ( 177 | 178 | {elements.map((element, index) => { 179 | return {element} 180 | })} 181 | 182 | ) 183 | } 184 | 185 | export default ExampleComponent; 186 | ``` 187 | 188 | > Please refer to [RendererInterface](src/lib/types.ts) for all the overrides 189 | 190 | > Note: 191 | > 192 | > For `key` property for a component, you can use the `getKey` method from Renderer class. 193 | 194 | #### Example 195 | 196 | Overriding default codespan tokenizer to include LaTeX. 197 | 198 | ```tsx 199 | 200 | import React, { ReactNode } from "react"; 201 | import Markdown, { Renderer, MarkedTokenizer, MarkedLexer } from "react-native-marked"; 202 | import type { RendererInterface, CustomToken } from "react-native-marked"; 203 | 204 | class CustomTokenizer extends Tokenizer { 205 | codespan(src: string): Tokens.Codespan | undefined { 206 | const match = src.match(/^\$+([^\$\n]+?)\$+/); 207 | if (match?.[1]) { 208 | return { 209 | type: "codespan", 210 | raw: match[0], 211 | text: match[1].trim(), 212 | }; 213 | } 214 | 215 | return super.codespan(src); 216 | } 217 | } 218 | 219 | class CustomRenderer extends Renderer implements RendererInterface { 220 | codespan(text: string, styles?: TextStyle): ReactNode { 221 | return ( 222 | 223 | {text} 224 | 225 | ) 226 | } 227 | } 228 | 229 | const renderer = new CustomRenderer(); 230 | const tokenizer = new CustomTokenizer(); 231 | 232 | const ExampleComponent = () => { 233 | return ( 234 | 242 | ); 243 | }; 244 | ``` 245 | 246 | #### Example 247 | 248 | ## Screenshots 249 | 250 | | Dark Theme | Light Theme | 251 | |:-------------------------------------------------------------:|:----------------------------------------------------------------:| 252 | | ![Dark theme](assets/dark-theme-01.png?raw=true 'Dark Theme') | ![Light theme](assets/light-theme-01.png?raw=true 'Light Theme') | 253 | 254 | ## Contributing 255 | 256 | See the [contributing guide](CONTRIBUTING.md) to learn how to contribute to the 257 | repository and the development workflow. 258 | 259 | ## License 260 | 261 | MIT 262 | 263 | --- 264 | 265 | Made with 266 | [create-react-native-library](https://github.com/callstack/react-native-builder-bob) 267 | 268 | ## Built using 269 | 270 | - [Marked](https://marked.js.org/) 271 | - [@jsamr/react-native-li](https://github.com/jsamr/react-native-li) 272 | - [react-native-reanimated-table](https://github.com/dohooo/react-native-reanimated-table) 273 | - [react-native-svg](https://github.com/software-mansion/react-native-svg) 274 | - [svg-parser](https://github.com/Rich-Harris/svg-parser) 275 | - [github-slugger](https://github.com/Flet/github-slugger) 276 | - [html-entities](https://github.com/mdevils/html-entities) 277 | 278 | 279 | Buy Me A Coffee 280 | -------------------------------------------------------------------------------- /assets/dark-theme-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/assets/dark-theme-01.png -------------------------------------------------------------------------------- /assets/light-theme-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/assets/light-theme-01.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["module:@react-native/babel-preset"], 3 | }; 4 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": false 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "correctness": { 11 | "noRenderReturnValue": "off" 12 | }, 13 | "suspicious": { 14 | "noArrayIndexKey": "info" 15 | } 16 | } 17 | }, 18 | "formatter": { 19 | "enabled": true 20 | }, 21 | "files": { 22 | "ignore": ["node_modules/", "coverage/", "dist/"] 23 | }, 24 | "javascript": { 25 | "jsxRuntime": "reactClassic" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dangerfile.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { dangerReassure } from "reassure"; 3 | 4 | dangerReassure({ 5 | inputFilePath: path.join(__dirname, ".reassure/output.md"), 6 | }); 7 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from "react"; 2 | import { 3 | SafeAreaView, 4 | StyleSheet, 5 | StatusBar, 6 | useColorScheme, 7 | Text, 8 | type TextStyle, 9 | } from "react-native"; 10 | import Markdown, { 11 | Renderer, 12 | MarkedTokenizer, 13 | type RendererInterface, 14 | type Tokens, 15 | } from "react-native-marked"; 16 | import { MD_STRING } from "./const"; 17 | 18 | class CustomTokenizer extends MarkedTokenizer { 19 | codespan(this: MarkedTokenizer, src: string): Tokens.Codespan | undefined { 20 | const match = src.match(/^\$+([^\$\n]+?)\$+/); 21 | if (match?.[1]) { 22 | return { 23 | type: "codespan", 24 | raw: match[0], 25 | text: match[1].trim(), 26 | }; 27 | } 28 | 29 | return super.codespan(src); 30 | } 31 | } 32 | 33 | const tokenizer = new CustomTokenizer(); 34 | 35 | class CustomRenderer extends Renderer implements RendererInterface { 36 | codespan(text: string, _styles?: TextStyle): ReactNode { 37 | return ( 38 | 39 | {text} 40 | 41 | ); 42 | } 43 | } 44 | 45 | const renderer = new CustomRenderer(); 46 | 47 | export default function App() { 48 | const theme = useColorScheme(); 49 | const isLightTheme = theme === "light"; 50 | return ( 51 | <> 52 | 56 | 57 | 65 | 66 | 67 | ); 68 | } 69 | 70 | const styles = StyleSheet.create({ 71 | container: { 72 | paddingHorizontal: 16, 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "react-native-marked-example", 4 | "slug": "react-native-marked-example", 5 | "description": "Example app for react-native-marked", 6 | "version": "1.0.0", 7 | "orientation": "portrait", 8 | "icon": "./assets/icon.png", 9 | "userInterfaceStyle": "light", 10 | "newArchEnabled": true, 11 | "splash": { 12 | "image": "./assets/splash-icon.png", 13 | "resizeMode": "contain", 14 | "backgroundColor": "#ffffff" 15 | }, 16 | "ios": { 17 | "supportsTablet": true, 18 | "bundleIdentifier": "com.rnmarked.sample" 19 | }, 20 | "android": { 21 | "package": "com.rnmarked.sample", 22 | "adaptiveIcon": { 23 | "foregroundImage": "./assets/adaptive-icon.png", 24 | "backgroundColor": "#ffffff" 25 | } 26 | }, 27 | "web": { 28 | "favicon": "./assets/favicon.png" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/examples/react-native-marked-sample/assets/adaptive-icon.png -------------------------------------------------------------------------------- /examples/react-native-marked-sample/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/examples/react-native-marked-sample/assets/favicon.png -------------------------------------------------------------------------------- /examples/react-native-marked-sample/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/examples/react-native-marked-sample/assets/icon.png -------------------------------------------------------------------------------- /examples/react-native-marked-sample/assets/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmsgowtham/react-native-marked/3425de35b9534631031a68b678fa3bc0c04a41cf/examples/react-native-marked-sample/assets/splash-icon.png -------------------------------------------------------------------------------- /examples/react-native-marked-sample/babel.config.js: -------------------------------------------------------------------------------- 1 | const path = require("node:path"); 2 | const pak = require("../../package.json"); 3 | 4 | module.exports = (api) => { 5 | api.cache(true); 6 | 7 | return { 8 | presets: ["babel-preset-expo"], 9 | plugins: [ 10 | [ 11 | "module-resolver", 12 | { 13 | extensions: [".tsx", ".ts", ".js", ".json"], 14 | alias: { 15 | // For development, we want to alias the library to the source 16 | [pak.name]: path.join(__dirname, "..", "..", pak.source), 17 | }, 18 | }, 19 | ], 20 | ], 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/const.ts: -------------------------------------------------------------------------------- 1 | const MD_STRING = ` 2 | # h1 Heading 3 | ## h2 Heading 4 | ## ~~_h2 Heading with Emphasis_~~ 5 | ### h3 Heading 6 | #### h4 Heading 7 | ##### h5 Heading 8 | ###### h6 Heading 9 | 10 | 11 | ## Horizontal Rules 12 | 13 | ___ 14 | 15 | --- 16 | 17 | *** 18 | 19 | ## Emphasis 20 | 21 | **This is bold text** 22 | 23 | __This is bold text__ 24 | 25 | *This is italic text* 26 | 27 | _This is italic text_ 28 | 29 | ~~Strikethrough~~ 30 | 31 | 32 | ## Blockquotes 33 | 34 | 35 | > Blockquotes can also be nested... 36 | >> ...by using additional greater-than signs right next to each other... 37 | > > > ...or with spaces between arrows. 38 | 39 | 40 | ## Lists 41 | 42 | Unordered 43 | 44 | + Create a list by starting a line with \`+\`, \`-\`, or \`*\` 45 | + Sub-lists are made by indenting 2 spaces: 46 | - Marker character change forces new list start: 47 | * Ac tristique libero volutpat at 48 | + Facilisis in pretium nisl aliquet 49 | - Nulla volutpat aliquam velit 50 | + Very easy! 51 | 52 | Ordered 53 | 54 | 1. Lorem ipsum dolor sit amet 55 | 2. Consectetur adipiscing elit 56 | 3. Integer molestie lorem at massa 57 | 58 | 59 | 1. You can use sequential numbers... 60 | 1. ...or keep all the numbers as \`1.\` 61 | 62 | Start numbering with offset: 63 | 64 | 57. foo 65 | 1. bar 66 | 67 | 68 | ## Code 69 | 70 | Inline \`code\` 71 | 72 | Indented code 73 | 74 | // Some comments 75 | line 1 of code 76 | line 2 of code 77 | line 3 of code 78 | 79 | 80 | Block code "fences" 81 | 82 | \`\`\` 83 | Sample text here... 84 | \`\`\` 85 | 86 | Syntax highlighting 87 | 88 | \`\`\` js 89 | var foo = function (bar) { 90 | return bar++; 91 | }; 92 | 93 | console.log(foo(5)); 94 | \`\`\` 95 | 96 | ## Tables 97 | 98 | | Option | Description | 99 | | ------ | ----------- | 100 | | data | path to data files to supply the data that will be passed into templates. | 101 | | engine | engine to be used for processing templates. Handlebars is the default. | 102 | | ext | extension to be used for dest files. | 103 | 104 | Right aligned columns 105 | 106 | | Option | Description | 107 | | ------:| -----------:| 108 | | data | path to data files to supply the data that will be passed into templates. | 109 | | engine | engine to be used for processing templates. Handlebars is the default. | 110 | | ext | extension to be used for dest files. | 111 | 112 | 113 | ## Links 114 | 115 | [link text](http://dev.nodeca.com) 116 | 117 | [link with title](http://nodeca.github.io/pica/demo/ "title text!") 118 | 119 | Autoconverted link https://github.com/nodeca/pica (enable linkify to see) 120 | 121 | 122 | ## Images 123 | 124 | ![svg](https://www.svgrepo.com/show/513268/beer.svg) 125 | ![Minion](https://octodex.github.com/images/minion.png) 126 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 127 | 128 | Like links, Images also have a footnote style syntax 129 | 130 | ![Alt text][id] 131 | 132 | With a reference later in the document defining the URL location: 133 | 134 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 135 | 136 | [![SVG Repo](https://www.svgrepo.com/show/513268/beer.svg "SVG Repo")](https://www.svgrepo.com) 137 | 138 | $ latex code $\n\n\` other code\` 139 | `; 140 | 141 | export { MD_STRING }; 142 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/index.ts: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from "expo"; 2 | 3 | import App from "./App"; 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App); 9 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require("node:path"); 2 | const escapeString = require("escape-string-regexp"); 3 | const { getDefaultConfig } = require("@expo/metro-config"); 4 | const exclusionList = require("metro-config/src/defaults/exclusionList"); 5 | const pak = require("../../package.json"); 6 | 7 | const root = path.resolve(__dirname, "../.."); 8 | 9 | const modules = [ 10 | "react-native-web", 11 | ...Object.keys({ 12 | ...pak.peerDependencies, 13 | }), 14 | ]; 15 | 16 | const defaultConfig = getDefaultConfig(__dirname); 17 | 18 | module.exports = { 19 | ...defaultConfig, 20 | 21 | projectRoot: __dirname, 22 | watchFolders: [root], 23 | 24 | // We need to make sure that only one version is loaded for peerDependencies 25 | // So we block them at the root, and alias them to the versions in example's node_modules 26 | resolver: { 27 | ...defaultConfig.resolver, 28 | 29 | blacklistRE: exclusionList( 30 | modules.map( 31 | (m) => 32 | new RegExp( 33 | `^${escapeString(path.join(root, "node_modules", m))}\\/.*$`, 34 | ), 35 | ), 36 | ), 37 | 38 | extraNodeModules: modules.reduce((acc, name) => { 39 | acc[name] = path.join(__dirname, "node_modules", name); 40 | return acc; 41 | }, {}), 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-marked-example", 3 | "description": "Example app for react-native-marked", 4 | "version": "1.0.0", 5 | "private": true, 6 | "main": "index.ts", 7 | "scripts": { 8 | "start": "expo start", 9 | "android": "expo start --android", 10 | "ios": "expo start --ios", 11 | "web": "expo start --web" 12 | }, 13 | "dependencies": { 14 | "@expo/metro-runtime": "~4.0.1", 15 | "expo": "~52.0.41", 16 | "expo-status-bar": "~2.0.1", 17 | "react": "18.3.1", 18 | "react-dom": "18.3.1", 19 | "react-native": "0.76.7", 20 | "react-native-svg": "15.8.0", 21 | "react-native-web": "~0.19.13" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.25.2", 25 | "@babel/runtime": "^7.26.0", 26 | "@types/react": "~18.3.12", 27 | "babel-plugin-module-resolver": "^5.0.2", 28 | "typescript": "^5.3.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/react-native-marked-sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | // Avoid expo-cli auto-generating a tsconfig 5 | }, 6 | "exclude": [] 7 | } 8 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | lint: 4 | glob: '*.{js,ts,jsx,tsx}' 5 | run: yarn run biome check {staged_files} 6 | format: 7 | glob: '*.{js,ts,jsx,tsx}' 8 | run: yarn run biome format {staged_files} --write && git add {staged_files} 9 | commit-msg: 10 | parallel: true 11 | commands: 12 | commitlint: 13 | run: yarn run commitlint --edit 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-marked", 3 | "version": "7.0.1-rc.0", 4 | "description": "Markdown renderer for React Native powered by marked.js", 5 | "main": "dist/commonjs/index", 6 | "module": "dist/module/index", 7 | "types": "dist/typescript/index.d.ts", 8 | "react-native": "src/index", 9 | "source": "src/index", 10 | "files": [ 11 | "src", 12 | "dist", 13 | "android", 14 | "ios", 15 | "cpp", 16 | "react-native-marked.podspec", 17 | "!dist/typescript/example", 18 | "!android/build", 19 | "!ios/build", 20 | "!**/__tests__", 21 | "!**/__fixtures__", 22 | "!**/__mocks__", 23 | "!**/__perf__" 24 | ], 25 | "scripts": { 26 | "typescript": "tsc --noEmit", 27 | "lint": "biome check ./", 28 | "format": "biome format ./ --write", 29 | "build": "bob build", 30 | "prepare": "yarn build", 31 | "release": "yarn build && release-it", 32 | "release:rc": "yarn build && release-it --preRelease=rc", 33 | "release:exclude-pre": "yarn build && release-it --git.tagExclude='*[-]*'", 34 | "test": "jest --passWithNoTests", 35 | "test:updateSnapshot": "jest --updateSnapshot", 36 | "reassure": "reassure" 37 | }, 38 | "keywords": ["react-native", "markdown", "react-native markdown"], 39 | "repository": "https://github.com/gmsgowtham/react-native-marked", 40 | "author": "Gowtham G (https://github.com/gmsgowtham)", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/gmsgowtham/react-native-marked/issues" 44 | }, 45 | "homepage": "https://github.com/gmsgowtham/react-native-marked#readme", 46 | "publishConfig": { 47 | "registry": "https://registry.npmjs.org/" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "7.26.10", 51 | "@babel/helper-explode-assignable-expression": "7.18.6", 52 | "@babel/preset-env": "7.26.9", 53 | "@biomejs/biome": "1.9.4", 54 | "@commitlint/config-conventional": "19.8.0", 55 | "@evilmartians/lefthook": "1.11.8", 56 | "@react-native/babel-preset": "0.78.2", 57 | "@release-it/conventional-changelog": "10.0.0", 58 | "@testing-library/react-native": "v14.0.0-alpha.1", 59 | "@types/jest": "29.5.14", 60 | "@types/node": "22.14.0", 61 | "@types/react": "19.1.0", 62 | "@types/svg-parser": "^2.0.3", 63 | "commitlint": "19.8.0", 64 | "danger": "12.3.4", 65 | "husky": "9.1.7", 66 | "jest": "29.7.0", 67 | "jest-environment-jsdom": "29.7.0", 68 | "pod-install": "0.3.7", 69 | "postinstall-postinstall": "2.1.0", 70 | "react": "19.1.0", 71 | "react-native": "0.78.2", 72 | "react-native-builder-bob": "0.39.1", 73 | "react-native-svg": "15.11.2", 74 | "reassure": "1.4.0", 75 | "release-it": "19.0.1", 76 | "typescript": "5.8.3", 77 | "universal-test-renderer": "0.6.0" 78 | }, 79 | "peerDependencies": { 80 | "react": ">=16.8.6", 81 | "react-native": ">=0.76.0", 82 | "react-native-svg": ">=12.3.0" 83 | }, 84 | "jest": { 85 | "preset": "react-native", 86 | "testEnvironment": "jsdom", 87 | "modulePathIgnorePatterns": [ 88 | "/examples/*/node_modules", 89 | "/dist/" 90 | ], 91 | "transformIgnorePatterns": [ 92 | "node_modules/(?!(jest-)?react-native|@react-native|@react-native-community|github-slugger)" 93 | ] 94 | }, 95 | "commitlint": { 96 | "extends": ["@commitlint/config-conventional"], 97 | "rules": { 98 | "type-enum": [ 99 | 2, 100 | "always", 101 | [ 102 | "build", 103 | "chore", 104 | "ci", 105 | "docs", 106 | "feat", 107 | "fix", 108 | "perf", 109 | "refactor", 110 | "revert", 111 | "style", 112 | "test", 113 | "todo", 114 | "bump" 115 | ] 116 | ] 117 | } 118 | }, 119 | "release-it": { 120 | "git": { 121 | "commitMessage": "chore: release ${version}", 122 | "tagName": "v${version}" 123 | }, 124 | "npm": { 125 | "publish": true 126 | }, 127 | "github": { 128 | "release": true, 129 | "web": true 130 | }, 131 | "plugins": { 132 | "@release-it/conventional-changelog": { 133 | "preset": "angular", 134 | "ignoreRecommendedBump": true 135 | } 136 | } 137 | }, 138 | "react-native-builder-bob": { 139 | "source": "src", 140 | "output": "dist", 141 | "targets": [ 142 | "commonjs", 143 | "module", 144 | [ 145 | "typescript", 146 | { 147 | "project": "tsconfig.json", 148 | "tsc": "./node_modules/.bin/tsc" 149 | } 150 | ] 151 | ] 152 | }, 153 | "dependencies": { 154 | "@jsamr/counter-style": "2.0.2", 155 | "@jsamr/react-native-li": "2.3.1", 156 | "github-slugger": "2.0.0", 157 | "html-entities": "2.6.0", 158 | "marked": "15.0.8", 159 | "react-native-reanimated-table": "0.0.2", 160 | "svg-parser": "2.0.4" 161 | }, 162 | "engines": { 163 | "node": ">=18" 164 | }, 165 | "resolutions": { 166 | "@types/react": "19.1.0", 167 | "**/pretty-format/react-is": "19.1.0" 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /reassure-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | BASELINE_BRANCH=${BASELINE_BRANCH:="main"} 5 | 6 | # Required for `git switch` on CI 7 | git fetch origin 8 | 9 | # Gather baseline perf measurements 10 | git switch "$BASELINE_BRANCH" 11 | yarn install --frozen-lockfile 12 | TEST_RUNNER_ARG="--silent" yarn reassure --baseline 13 | 14 | # Gather current perf measurements & compare results 15 | git switch --detach - 16 | yarn install --frozen-lockfile 17 | TEST_RUNNER_ARG="--silent" yarn reassure 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"] 4 | } 5 | -------------------------------------------------------------------------------- /src/components/MDImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | type FunctionComponent, 3 | memo, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import { 8 | ActivityIndicator, 9 | ImageBackground, 10 | Image, 11 | type ImageStyle, 12 | } from "react-native"; 13 | 14 | type MDImageProps = { 15 | uri: string; 16 | label?: string; 17 | alt?: string; 18 | style?: ImageStyle; 19 | }; 20 | 21 | type MDImageState = { 22 | isLoading: boolean; 23 | aspectRatio: number | undefined; 24 | }; 25 | 26 | const MDImage: FunctionComponent = ({ 27 | uri, 28 | label, 29 | alt = "Image", 30 | style, 31 | }) => { 32 | const [imageState, setImageState] = useState({ 33 | isLoading: true, 34 | aspectRatio: undefined, 35 | }); 36 | 37 | useEffect(() => { 38 | fetchOriginalSizeFromRemoteImage(); 39 | }, []); 40 | 41 | /** 42 | * Fetches image dimension 43 | * Sets aspect ratio if resolved 44 | */ 45 | const fetchOriginalSizeFromRemoteImage = () => { 46 | Image.getSize( 47 | uri, 48 | (width: number, height: number) => { 49 | if (width > 0 && height > 0) { 50 | setImageState({ isLoading: false, aspectRatio: width / height }); 51 | } else { 52 | setImageState({ isLoading: false, aspectRatio: undefined }); 53 | } 54 | }, 55 | () => { 56 | setImageState((current) => { 57 | return { 58 | ...current, 59 | isLoading: false, 60 | }; 61 | }); 62 | }, 63 | ); 64 | }; 65 | 66 | return ( 67 | 80 | {imageState.isLoading ? ( 81 | 85 | ) : null} 86 | 87 | ); 88 | }; 89 | 90 | export default memo(MDImage); 91 | -------------------------------------------------------------------------------- /src/components/MDSvg.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | memo, 3 | type FunctionComponent, 4 | useEffect, 5 | useState, 6 | useRef, 7 | } from "react"; 8 | import { ActivityIndicator, View, type LayoutChangeEvent } from "react-native"; 9 | import { SvgFromXml } from "react-native-svg"; 10 | import { getSvgDimensions } from "./../utils/svg"; 11 | 12 | type MDSvgProps = { 13 | uri: string; 14 | alt?: string; 15 | }; 16 | 17 | type MdSvgState = { 18 | viewBox: string; 19 | width: number; 20 | height: number; 21 | svg: string; 22 | isLoading: boolean; 23 | error: boolean; 24 | aspectRatio?: number; 25 | }; 26 | 27 | const MDSvg: FunctionComponent = ({ uri, alt = "image" }) => { 28 | const isFirstLoad = useRef(false); 29 | const [layoutWidth, setLayoutWidth] = useState(0); 30 | const [svgState, setSvgState] = useState({ 31 | viewBox: "", 32 | width: 0, 33 | height: 0, 34 | svg: "", 35 | isLoading: true, 36 | error: false, 37 | aspectRatio: undefined, 38 | }); 39 | useEffect(() => { 40 | const fetchSvg = async () => { 41 | try { 42 | const res = await fetch(uri); 43 | const text = await res.text(); 44 | if (res.status !== 200) { 45 | throw new Error("Status is not 200"); 46 | } 47 | const { viewBox, width, height } = getSvgDimensions(text); 48 | 49 | setSvgState({ 50 | width, 51 | height, 52 | viewBox, 53 | svg: text, 54 | isLoading: false, 55 | error: false, 56 | aspectRatio: width / height, 57 | }); 58 | } catch (e) { 59 | setSvgState((state) => ({ 60 | ...state, 61 | error: true, 62 | isLoading: false, 63 | })); 64 | } 65 | }; 66 | 67 | fetchSvg(); 68 | }, [uri]); 69 | 70 | const onLayout = (event: LayoutChangeEvent) => { 71 | if (!isFirstLoad.current) { 72 | setLayoutWidth(event.nativeEvent.layout.width ?? 0); 73 | isFirstLoad.current = true; 74 | } 75 | }; 76 | 77 | const getWidth = () => { 78 | if (layoutWidth && svgState.width) { 79 | return Math.min(layoutWidth, svgState.width); 80 | } 81 | return "100%"; 82 | }; 83 | 84 | return ( 85 | 89 | {svgState.isLoading ? ( 90 | 91 | ) : ( 92 | 103 | )} 104 | 105 | ); 106 | }; 107 | 108 | export default memo(MDSvg); 109 | -------------------------------------------------------------------------------- /src/components/MDTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, type FunctionComponent, type ReactNode } from "react"; 2 | import { View, ScrollView, type ViewStyle } from "react-native"; 3 | import { Table, TableWrapper, Cell } from "react-native-reanimated-table"; 4 | 5 | type MDTableProps = { 6 | header: ReactNode[][]; 7 | rows: ReactNode[][][]; 8 | widthArr: number[]; 9 | rowStyle?: ViewStyle; 10 | cellStyle?: ViewStyle; 11 | borderColor?: string; 12 | borderWidth?: number; 13 | tableStyle?: ViewStyle; 14 | }; 15 | 16 | const MDTable: FunctionComponent = ({ 17 | header, 18 | rows, 19 | widthArr, 20 | cellStyle, 21 | rowStyle, 22 | tableStyle, 23 | borderColor, 24 | borderWidth, 25 | }) => { 26 | return ( 27 | 28 | 29 | 30 | {header.map((headerCol, index) => { 31 | return ( 32 | {headerCol}} 36 | /> 37 | ); 38 | })} 39 | 40 | {rows.map((rowData, index) => { 41 | return ( 42 | 43 | {rowData.map((cellData, cellIndex) => { 44 | return ( 45 | {cellData}} 49 | /> 50 | ); 51 | })} 52 | 53 | ); 54 | })} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default memo(MDTable); 61 | -------------------------------------------------------------------------------- /src/hooks/useMarkdown.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, type ReactNode } from "react"; 2 | import { lexer, type Tokenizer } from "marked"; 3 | import type { MarkedStyles, UserTheme } from "./../theme/types"; 4 | import Parser from "../lib/Parser"; 5 | import Renderer from "../lib/Renderer"; 6 | import getStyles from "./../theme/styles"; 7 | import type { ColorSchemeName } from "react-native"; 8 | import type { RendererInterface } from "../lib/types"; 9 | 10 | export interface useMarkdownHookOptions { 11 | colorScheme?: ColorSchemeName; 12 | renderer?: RendererInterface; 13 | theme?: UserTheme; 14 | styles?: MarkedStyles; 15 | baseUrl?: string; 16 | tokenizer?: Tokenizer; 17 | } 18 | 19 | const useMarkdown = ( 20 | value: string, 21 | options?: useMarkdownHookOptions, 22 | ): ReactNode[] => { 23 | const styles = useMemo( 24 | () => getStyles(options?.styles, options?.colorScheme, options?.theme), 25 | [options?.styles, options?.theme, options?.colorScheme], 26 | ); 27 | 28 | const parser = useMemo( 29 | () => 30 | new Parser({ 31 | styles: styles, 32 | baseUrl: options?.baseUrl, 33 | renderer: options?.renderer ?? new Renderer(), 34 | }), 35 | [options?.renderer, options?.baseUrl, styles], 36 | ); 37 | 38 | const elements = useMemo(() => { 39 | const tokens = lexer(value, { 40 | gfm: true, 41 | tokenizer: options?.tokenizer, 42 | }); 43 | return parser.parse(tokens); 44 | }, [value, parser, options?.tokenizer]); 45 | 46 | return elements; 47 | }; 48 | 49 | export default useMarkdown; 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Tokenizer as MarkedTokenizer, marked } from "marked"; 2 | import type { Token, Tokens } from "marked"; 3 | import Markdown from "./lib/Markdown"; 4 | import Renderer from "./lib/Renderer"; 5 | import useMarkdown, { type useMarkdownHookOptions } from "./hooks/useMarkdown"; 6 | import type { 7 | MarkdownProps, 8 | ParserOptions, 9 | RendererInterface, 10 | } from "./lib/types"; 11 | 12 | const MarkedLexer = marked.lexer; 13 | 14 | export type { 15 | MarkdownProps, 16 | ParserOptions, 17 | RendererInterface, 18 | useMarkdownHookOptions, 19 | Token, 20 | Tokens, 21 | }; 22 | 23 | export { useMarkdown, MarkedLexer, Renderer, MarkedTokenizer }; 24 | 25 | export default Markdown; 26 | -------------------------------------------------------------------------------- /src/lib/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, type ReactElement, type ReactNode } from "react"; 2 | import { FlatList, useColorScheme } from "react-native"; 3 | import type { MarkdownProps } from "./types"; 4 | import useMarkdown from "../hooks/useMarkdown"; 5 | 6 | const Markdown = ({ 7 | value, 8 | flatListProps, 9 | theme, 10 | baseUrl, 11 | renderer, 12 | styles, 13 | tokenizer, 14 | }: MarkdownProps) => { 15 | const colorScheme = useColorScheme(); 16 | 17 | const rnElements = useMarkdown(value, { 18 | theme, 19 | baseUrl, 20 | renderer, 21 | colorScheme, 22 | styles, 23 | tokenizer, 24 | }); 25 | 26 | const renderItem = useCallback(({ item }: { item: ReactNode }) => { 27 | return item as ReactElement; 28 | }, []); 29 | 30 | const keyExtractor = useCallback( 31 | (_: ReactNode, index: number) => index.toString(), 32 | [], 33 | ); 34 | 35 | return ( 36 | 48 | ); 49 | }; 50 | 51 | export default Markdown; 52 | -------------------------------------------------------------------------------- /src/lib/Parser.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import type { TextStyle, ViewStyle, ImageStyle } from "react-native"; 3 | import type { Token, Tokens } from "marked"; 4 | import { decode } from "html-entities"; 5 | import type { MarkedStyles } from "../theme/types"; 6 | import type { RendererInterface, ParserOptions } from "./types"; 7 | import { getValidURL } from "./../utils/url"; 8 | import { getTableColAlignmentStyle } from "./../utils/table"; 9 | 10 | class Parser { 11 | private renderer: RendererInterface; 12 | private styles: MarkedStyles; 13 | private headingStylesMap: Record; 14 | private baseUrl: string; 15 | 16 | constructor(options: ParserOptions) { 17 | this.styles = { ...options.styles }; 18 | this.baseUrl = options.baseUrl ?? ""; 19 | this.renderer = options.renderer; 20 | this.headingStylesMap = { 21 | 1: this.styles.h1, 22 | 2: this.styles.h2, 23 | 3: this.styles.h3, 24 | 4: this.styles.h4, 25 | 5: this.styles.h5, 26 | 6: this.styles.h6, 27 | }; 28 | } 29 | 30 | parse(tokens?: Token[]) { 31 | return this._parse(tokens); 32 | } 33 | 34 | private _parse( 35 | tokens?: Token[], 36 | styles?: ViewStyle | TextStyle | ImageStyle, 37 | ): ReactNode[] { 38 | if (!tokens) return []; 39 | 40 | const elements: ReactNode[] = tokens.map((token) => { 41 | return this._parseToken(token, styles); 42 | }); 43 | return elements.filter((element) => element !== null); 44 | } 45 | 46 | private _parseToken( 47 | token: Token, 48 | styles?: ViewStyle | TextStyle | ImageStyle, 49 | ): ReactNode { 50 | switch (token.type) { 51 | case "paragraph": { 52 | const children = this.getNormalizedSiblingNodesForBlockAndInlineTokens( 53 | token.tokens ?? [], 54 | this.styles.text, 55 | ); 56 | 57 | return this.renderer.paragraph(children, this.styles.paragraph); 58 | } 59 | case "blockquote": { 60 | const children = this.parse(token.tokens); 61 | return this.renderer.blockquote(children, this.styles.blockquote); 62 | } 63 | case "heading": { 64 | const styles = this.headingStylesMap[token.depth]; 65 | 66 | if (this.hasDuplicateTextChildToken(token)) { 67 | return this.renderer.heading(token.text, styles, token.depth); 68 | } 69 | 70 | const children = this._parse(token.tokens, styles); 71 | return this.renderer.heading(children, styles, token.depth); 72 | } 73 | case "code": { 74 | return this.renderer.code( 75 | token.text, 76 | token.lang, 77 | this.styles.code, 78 | this.styles.em, 79 | ); 80 | } 81 | case "hr": { 82 | return this.renderer.hr(this.styles.hr); 83 | } 84 | case "list": { 85 | let startIndex = Number.parseInt(token.start.toString()); 86 | if (Number.isNaN(startIndex)) { 87 | startIndex = 1; 88 | } 89 | const li = (token as Tokens.List).items.map((item) => { 90 | const children = item.tokens.flatMap((cItem) => { 91 | if (cItem.type === "text") { 92 | /* getViewNode since tokens could contain a block like elements (i.e. img) */ 93 | const childTokens = (cItem as Tokens.Text).tokens || []; 94 | const listChildren = 95 | this.getNormalizedSiblingNodesForBlockAndInlineTokens( 96 | childTokens, 97 | this.styles.li, 98 | ); 99 | // return this.renderer.listItem(listChildren, this.styles.li); 100 | return listChildren; 101 | } 102 | 103 | /* Parse the nested token */ 104 | return this._parseToken(cItem); 105 | }); 106 | 107 | return this.renderer.listItem(children, this.styles.li); 108 | }); 109 | 110 | return this.renderer.list( 111 | token.ordered, 112 | li, 113 | this.styles.list, 114 | this.styles.li, 115 | startIndex, 116 | ); 117 | } 118 | case "escape": { 119 | return this.renderer.escape(token.text, { 120 | ...this.styles.text, 121 | ...styles, 122 | }); 123 | } 124 | case "link": { 125 | // Don't render anchors without text and children 126 | if (token.text.trim().length < 1 || !token.tokens) { 127 | return null; 128 | } 129 | 130 | // Note: Linking Images (https://www.markdownguide.org/basic-syntax/#linking-images) are wrapped 131 | // in paragraph token, so will be handled via `getNormalizedSiblingNodesForBlockAndInlineTokens` 132 | const linkStyle = { 133 | ...this.styles.link, 134 | ...styles, 135 | // To override color and fontStyle properties 136 | color: this.styles.link?.color, 137 | fontStyle: this.styles.link?.fontStyle, 138 | }; 139 | const href = getValidURL(this.baseUrl, token.href); 140 | 141 | if (this.hasDuplicateTextChildToken(token)) { 142 | return this.renderer.link(token.text, href, linkStyle); 143 | } 144 | 145 | const children = this._parse(token.tokens, linkStyle); 146 | return this.renderer.link(children, href, linkStyle); 147 | } 148 | case "image": { 149 | return this.renderer.image( 150 | token.href, 151 | token.text || token.title, 152 | this.styles.image, 153 | ); 154 | } 155 | case "strong": { 156 | const boldStyle = { 157 | ...this.styles.strong, 158 | ...styles, 159 | }; 160 | if (this.hasDuplicateTextChildToken(token)) { 161 | return this.renderer.strong(token.text, boldStyle); 162 | } 163 | 164 | const children = this._parse(token.tokens, boldStyle); 165 | return this.renderer.strong(children, boldStyle); 166 | } 167 | case "em": { 168 | const italicStyle = { 169 | ...this.styles.em, 170 | ...styles, 171 | }; 172 | if (this.hasDuplicateTextChildToken(token)) { 173 | return this.renderer.em(token.text, italicStyle); 174 | } 175 | 176 | const children = this._parse(token.tokens, italicStyle); 177 | return this.renderer.em(children, italicStyle); 178 | } 179 | case "codespan": { 180 | return this.renderer.codespan(decode(token.text), { 181 | ...this.styles.codespan, 182 | ...styles, 183 | }); 184 | } 185 | case "br": { 186 | return this.renderer.br(); 187 | } 188 | case "del": { 189 | const strikethroughStyle = { 190 | ...this.styles.strikethrough, 191 | ...styles, 192 | }; 193 | if (this.hasDuplicateTextChildToken(token)) { 194 | return this.renderer.del(token.text, strikethroughStyle); 195 | } 196 | 197 | const children = this._parse(token.tokens, strikethroughStyle); 198 | return this.renderer.del(children, strikethroughStyle); 199 | } 200 | case "text": 201 | return this.renderer.text(token.raw, { 202 | ...this.styles.text, 203 | ...styles, 204 | }); 205 | case "html": { 206 | console.warn( 207 | "react-native-marked: rendering html from markdown is not supported", 208 | ); 209 | return this.renderer.html(token.raw, { 210 | ...this.styles.text, 211 | ...styles, 212 | }); 213 | } 214 | case "table": { 215 | const header = (token as Tokens.Table).header.map((row, i) => 216 | this._parse(row.tokens, { 217 | ...getTableColAlignmentStyle(token.align[i]), 218 | }), 219 | ); 220 | 221 | const rows = (token as Tokens.Table).rows.map((cols) => 222 | cols.map((col, i) => 223 | this._parse(col.tokens, { 224 | ...getTableColAlignmentStyle(token.align[i]), 225 | }), 226 | ), 227 | ); 228 | 229 | return this.renderer.table( 230 | header, 231 | rows, 232 | this.styles.table, 233 | this.styles.tableRow, 234 | this.styles.tableCell, 235 | ); 236 | } 237 | default: { 238 | return null; 239 | } 240 | } 241 | } 242 | 243 | private getNormalizedSiblingNodesForBlockAndInlineTokens( 244 | tokens: Token[], 245 | textStyle?: TextStyle, 246 | ): ReactNode[] { 247 | let tokenRenderQueue: Token[] = []; 248 | const siblingNodes: ReactNode[] = []; 249 | for (const t of tokens) { 250 | /** 251 | * To avoid inlining images 252 | * Currently supports images, link images 253 | * Note: to be extend for other token types 254 | */ 255 | if ( 256 | t.type === "image" || 257 | (t.type === "link" && 258 | t.tokens && 259 | t.tokens[0] && 260 | t.tokens[0].type === "image") 261 | ) { 262 | // Render existing inline tokens in the queue 263 | const parsed = this._parse(tokenRenderQueue); 264 | if (parsed.length > 0) { 265 | siblingNodes.push(this.renderer.text(parsed, textStyle)); 266 | } 267 | 268 | // Render the current block token 269 | if (t.type === "image") { 270 | siblingNodes.push(this._parseToken(t)); 271 | } else if (t.type === "link" && t.tokens && t.tokens[0]) { 272 | const imageToken = t.tokens[0] as Tokens.Image; 273 | const href = getValidURL(this.baseUrl, t.href); 274 | siblingNodes.push( 275 | this.renderer.linkImage( 276 | href, 277 | imageToken.href, 278 | imageToken.text ?? imageToken.title ?? "", 279 | this.styles.image, 280 | ), 281 | ); 282 | } 283 | 284 | tokenRenderQueue = []; 285 | continue; 286 | } 287 | tokenRenderQueue = [...tokenRenderQueue, t]; 288 | } 289 | 290 | /* Remaining temp tokens if any */ 291 | if (tokenRenderQueue.length > 0) { 292 | siblingNodes.push(this.renderer.text(this.parse(tokenRenderQueue), {})); 293 | } 294 | 295 | return siblingNodes; 296 | } 297 | 298 | // To avoid duplicate text node nesting when there are no child tokens with text emphasis (i.e., italic) 299 | // ref: https://github.com/gmsgowtham/react-native-marked/issues/522 300 | private hasDuplicateTextChildToken(token: Token): boolean { 301 | if (!("tokens" in token)) { 302 | return false; 303 | } 304 | 305 | if ( 306 | token.tokens && 307 | token.tokens.length === 1 && 308 | token.tokens[0]?.type === "text" 309 | ) { 310 | return true; 311 | } 312 | 313 | return false; 314 | } 315 | } 316 | 317 | export default Parser; 318 | -------------------------------------------------------------------------------- /src/lib/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from "react"; 2 | import { 3 | ScrollView, 4 | View, 5 | Text, 6 | TouchableHighlight, 7 | type TextStyle, 8 | type ViewStyle, 9 | type ImageStyle, 10 | Dimensions, 11 | } from "react-native"; 12 | import MarkedList from "@jsamr/react-native-li"; 13 | import Disc from "@jsamr/counter-style/presets/disc"; 14 | import Decimal from "@jsamr/counter-style/presets/decimal"; 15 | import Slugger from "github-slugger"; 16 | import MDImage from "./../components/MDImage"; 17 | import { onLinkPress } from "../utils/handlers"; 18 | import type { RendererInterface } from "./types"; 19 | import { getTableWidthArr } from "../utils/table"; 20 | import MDSvg from "./../components/MDSvg"; 21 | import MDTable from "./../components/MDTable"; 22 | 23 | class Renderer implements RendererInterface { 24 | private slugPrefix = "react-native-marked-ele"; 25 | private slugger: Slugger; 26 | private windowWidth: number; 27 | constructor() { 28 | this.slugger = new Slugger(); 29 | const { width } = Dimensions.get("window"); 30 | this.windowWidth = width; 31 | } 32 | 33 | paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode { 34 | return this.getViewNode(children, styles); 35 | } 36 | 37 | blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode { 38 | return this.getBlockquoteNode(children, styles); 39 | } 40 | 41 | heading(text: string | ReactNode[], styles?: TextStyle): ReactNode { 42 | return this.getTextNode(text, styles); 43 | } 44 | 45 | code( 46 | text: string, 47 | _language?: string, 48 | containerStyle?: ViewStyle, 49 | textStyle?: TextStyle, 50 | ): ReactNode { 51 | return ( 52 | 57 | {/* 58 | Wrapped in View node to avoid the following error 59 | Error: Cannot add a child that doesn't have a YogaNode to a parent without a measure function! 60 | ref: https://github.com/facebook/react-native/issues/18773 61 | */} 62 | {this.getTextNode(text, textStyle)} 63 | 64 | ); 65 | } 66 | 67 | hr(styles?: ViewStyle): ReactNode { 68 | return this.getViewNode(null, styles); 69 | } 70 | 71 | listItem(children: ReactNode[], styles?: ViewStyle): ReactNode { 72 | return this.getViewNode(children, styles); 73 | } 74 | 75 | list( 76 | ordered: boolean, 77 | li: ReactNode[], 78 | listStyle?: ViewStyle, 79 | textStyle?: TextStyle, 80 | startIndex?: number, 81 | ): ReactNode { 82 | return ( 83 | 90 | {li.map((node) => node)} 91 | 92 | ); 93 | } 94 | 95 | escape(text: string, styles?: TextStyle): ReactNode { 96 | return this.getTextNode(text, styles); 97 | } 98 | 99 | link( 100 | children: string | ReactNode[], 101 | href: string, 102 | styles?: TextStyle, 103 | ): ReactNode { 104 | return ( 105 | 113 | {children} 114 | 115 | ); 116 | } 117 | 118 | image(uri: string, alt?: string, style?: ImageStyle): ReactNode { 119 | const key = this.getKey(); 120 | if (uri.endsWith(".svg")) { 121 | return ; 122 | } 123 | return ; 124 | } 125 | 126 | strong(children: string | ReactNode[], styles?: TextStyle): ReactNode { 127 | return this.getTextNode(children, styles); 128 | } 129 | 130 | em(children: string | ReactNode[], styles?: TextStyle): ReactNode { 131 | return this.getTextNode(children, styles); 132 | } 133 | 134 | codespan(text: string, styles?: TextStyle): ReactNode { 135 | return this.getTextNode(text, styles); 136 | } 137 | 138 | br(): ReactNode { 139 | return this.getTextNode("\n", {}); 140 | } 141 | 142 | del(children: string | ReactNode[], styles?: TextStyle): ReactNode { 143 | return this.getTextNode(children, styles); 144 | } 145 | 146 | text(text: string | ReactNode[], styles?: TextStyle): ReactNode { 147 | return this.getTextNode(text, styles); 148 | } 149 | 150 | html(text: string | ReactNode[], styles?: TextStyle): ReactNode { 151 | return this.getTextNode(text, styles); 152 | } 153 | 154 | linkImage( 155 | href: string, 156 | imageUrl: string, 157 | alt?: string, 158 | style?: ImageStyle, 159 | ): ReactNode { 160 | const imageNode = this.image(imageUrl, alt, style); 161 | return ( 162 | 168 | {imageNode} 169 | 170 | ); 171 | } 172 | 173 | table( 174 | header: ReactNode[][], 175 | rows: ReactNode[][][], 176 | tableStyle?: ViewStyle, 177 | rowStyle?: ViewStyle, 178 | cellStyle?: ViewStyle, 179 | ): React.ReactNode { 180 | const widthArr = getTableWidthArr(header.length, this.windowWidth); 181 | const { borderWidth, borderColor } = tableStyle || {}; 182 | return ( 183 | 192 | ); 193 | } 194 | 195 | getKey(): string { 196 | return this.slugger.slug(this.slugPrefix); 197 | } 198 | 199 | private getTextNode( 200 | children: string | ReactNode[], 201 | styles?: TextStyle, 202 | ): ReactNode { 203 | return ( 204 | 205 | {children} 206 | 207 | ); 208 | } 209 | 210 | private getViewNode( 211 | children: ReactNode[] | null, 212 | styles?: ViewStyle, 213 | ): ReactNode { 214 | return ( 215 | 216 | {children} 217 | 218 | ); 219 | } 220 | 221 | private getBlockquoteNode( 222 | children: ReactNode[], 223 | styles?: ViewStyle, 224 | ): ReactNode { 225 | return ( 226 | 227 | {children} 228 | 229 | ); 230 | } 231 | } 232 | 233 | export default Renderer; 234 | -------------------------------------------------------------------------------- /src/lib/__perf__/Markdown.perf-test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import { screen } from "@testing-library/react-native"; 4 | import { measureRenders } from "reassure"; 5 | import Markdown from "../Markdown"; 6 | import type { MarkedStyles, UserTheme } from "../../theme/types"; 7 | 8 | const mdString = `Markdown Quick Reference 9 | ======================== 10 | 11 | This guide is a very brief overview, with examples, of the syntax that [Markdown] supports. It is itself written in Markdown and you can copy the samples over to the left-hand pane for experimentation. It's shown as *text* and not *rendered HTML*. 12 | 13 | [Markdown]: http://daringfireball.net/projects/markdown/ 14 | 15 | 16 | Simple Text Formatting 17 | ====================== 18 | 19 | First thing is first. You can use *stars* or _underscores_ for italics. **Double stars** and __double underscores__ for bold. ***Three together*** for ___both___. 20 | 21 | Paragraphs are pretty easy too. Just have a blank line between chunks of text. 22 | 23 | > This chunk of text is in a block quote. Its multiple lines will all be 24 | > indented a bit from the rest of the text. 25 | > 26 | > > Multiple levels of block quotes also work. 27 | 28 | Sometimes you want to include code, such as when you are explaining how \`

\` HTML tags work, or maybe you are a programmer and you are discussing \`someMethod()\`. 29 | 30 | If you want to include code and have new 31 | lines preserved, indent the line with a tab 32 | or at least four spaces: 33 | 34 | Extra spaces work here too. 35 | This is also called preformatted text and it is useful for showing examples. 36 | The text will stay as text, so any *markdown* or HTML you add will 37 | not show up formatted. This way you can show markdown examples in a 38 | markdown document. 39 | 40 | > You can also use preformatted text with your blockquotes 41 | > as long as you add at least five spaces. 42 | 43 | 44 | Headings 45 | ======== 46 | 47 | There are a couple of ways to make headings. Using three or more equals signs on a line under a heading makes it into an "h1" style. Three or more hyphens under a line makes it "h2" (slightly smaller). You can also use multiple pound symbols (\`#\`) before and after a heading. Pounds after the title are ignored. Here are some examples: 48 | 49 | This is H1 50 | ========== 51 | 52 | This is H2 53 | ---------- 54 | 55 | # This is H1 56 | ## This is H2 57 | ### This is H3 with some extra pounds ### 58 | #### You get the idea #### 59 | ##### I don't need extra pounds at the end 60 | ###### H6 is the max 61 | 62 | 63 | Links 64 | ===== 65 | 66 | Let's link to a few sites. First, let's use the bare URL, like . Great for text, but ugly for HTML. 67 | Next is an inline link to [Google](https://www.google.com). A little nicer. 68 | This is a reference-style link to [Wikipedia] [1]. 69 | Lastly, here's a pretty link to [Yahoo]. The reference-style and pretty links both automatically use the links defined below, but they could be defined *anywhere* in the markdown and are removed from the HTML. The names are also case insensitive, so you can use [YaHoO] and have it link properly. 70 | 71 | [1]: https://www.wikipedia.org 72 | [Yahoo]: https://www.yahoo.com 73 | 74 | Title attributes may be added to links by adding text after a link. 75 | This is the [inline link](https://www.bing.com "Bing") with a "Bing" title. 76 | You can also go to [W3C] [2] and maybe visit a [friend]. 77 | 78 | [2]: https://w3c.org (The W3C puts out specs for web-based things) 79 | [Friend]: https://facebook.com "Facebook!" 80 | 81 | Email addresses in plain text are not linked: test@example.com. 82 | Email addresses wrapped in angle brackets are linked: . 83 | They are also obfuscated so that email harvesting spam robots hopefully won't get them. 84 | 85 | 86 | Lists 87 | ===== 88 | 89 | * This is a bulleted list 90 | * Great for shopping lists 91 | - You can also use hyphens 92 | + Or plus symbols 93 | 94 | The above is an "unordered" list. Now, on for a bit of order. 95 | 96 | 1. Numbered lists are also easy 97 | 2. Just start with a number 98 | 3738762. However, the actual number doesn't matter when converted to HTML. 99 | 1. This will still show up as 4. 100 | 101 | You might want a few advanced lists: 102 | 103 | - This top-level list is wrapped in paragraph tags 104 | - This generates an extra space between each top-level item. 105 | 106 | - You do it by adding a blank line 107 | 108 | - This nested list also has blank lines between the list items. 109 | 110 | - How to create nested lists 111 | 1. Start your regular list 112 | 2. Indent nested lists with two spaces 113 | 3. Further nesting means you should indent with two more spaces 114 | * This line is indented with four spaces. 115 | 116 | - List items can be quite lengthy. You can keep typing and either continue 117 | them on the next line with no indentation. 118 | 119 | - Alternately, if that looks ugly, you can also 120 | indent the next line a bit for a prettier look. 121 | 122 | - You can put large blocks of text in your list by just indenting with two spaces. 123 | 124 | This is formatted the same as code, but you can inspect the HTML and find that it's just wrapped in a \`

\` tag and *won't* be shown as preformatted text. 125 | 126 | You can keep adding more and more paragraphs to a single list item by adding the traditional blank line and then keep on indenting the paragraphs with two spaces. 127 | 128 | You really only need to indent the first line, 129 | but that looks ugly. 130 | 131 | - Lists support blockquotes 132 | 133 | > Just like this example here. By the way, you can 134 | > nest lists inside blockquotes! 135 | > - Fantastic! 136 | 137 | - Lists support preformatted text 138 | 139 | You just need to indent an additional four spaces. 140 | 141 | 142 | Even More 143 | ========= 144 | 145 | Horizontal Rule 146 | --------------- 147 | 148 | If you need a horizontal rule you just need to put at least three hyphens, asterisks, or underscores on a line by themselves. You can also even put spaces between the characters. 149 | 150 | --- 151 | **************************** 152 | _ _ _ _ _ _ _ 153 | 154 | Those three all produced horizontal lines. Keep in mind that three hyphens under any text turns that text into a heading, so add a blank like if you use hyphens. 155 | 156 | Images 157 | ------ 158 | 159 | Images work exactly like links, but they have exclamation points in front. They work with references and titles too. 160 | 161 | ![Google Logo](https://www.google.com/images/errors/logo_sm.gif) and ![Happy]. 162 | 163 | [Happy]: https://wpclipart.com/smiley/happy/simple_colors/smiley_face_simple_green_small.png ("Smiley face") 164 | 165 | 166 | Inline HTML 167 | ----------- 168 | 169 | If markdown is too limiting, you can just insert your own crazy HTML. Span-level HTML can *still* use markdown. Block level elements must be separated from text by a blank line and must not have any spaces before the opening and closing HTML. 170 | 171 |

172 | It is a pity, but markdown does **not** work in here for most markdown parsers. 173 | [Marked] handles it pretty well. 174 |
175 | `; 176 | 177 | const styles = StyleSheet.create({ 178 | em: { 179 | fontSize: 12, 180 | }, 181 | strong: { 182 | fontSize: 12, 183 | }, 184 | strikethrough: { 185 | fontSize: 12, 186 | }, 187 | text: { 188 | fontSize: 12, 189 | }, 190 | paragraph: { 191 | borderWidth: 1, 192 | }, 193 | link: { 194 | fontSize: 12, 195 | }, 196 | blockquote: { 197 | borderWidth: 1, 198 | }, 199 | h1: { 200 | fontSize: 12, 201 | }, 202 | h2: { 203 | fontSize: 12, 204 | }, 205 | h3: { 206 | fontSize: 12, 207 | }, 208 | h4: { 209 | fontSize: 12, 210 | }, 211 | h5: { 212 | fontSize: 12, 213 | }, 214 | h6: { 215 | fontSize: 12, 216 | }, 217 | codespan: { 218 | fontSize: 12, 219 | }, 220 | code: { 221 | borderWidth: 1, 222 | }, 223 | hr: { 224 | borderWidth: 1, 225 | }, 226 | list: { 227 | borderWidth: 1, 228 | }, 229 | li: { 230 | fontSize: 12, 231 | }, 232 | image: { 233 | width: "100%", 234 | }, 235 | table: { 236 | borderWidth: 1, 237 | }, 238 | tableRow: { 239 | borderWidth: 1, 240 | }, 241 | tableCell: { 242 | borderWidth: 1, 243 | }, 244 | }); 245 | 246 | const theme: UserTheme = { 247 | colors: { 248 | background: "#ffffff", 249 | link: "#58a6ff", 250 | border: "#d0d7de", 251 | code: "#161b22", 252 | text: "#ffffff", 253 | }, 254 | spacing: { 255 | xs: 3, 256 | s: 6, 257 | m: 9, 258 | l: 18, 259 | }, 260 | }; 261 | 262 | describe("Perf test", () => { 263 | it("Renders markdown", async () => { 264 | const scenario = async () => { 265 | await screen.queryByText("Markdown Quick Reference"); 266 | await screen.queryByText("Inline HTML"); 267 | await screen.queryByText( 268 | "If markdown is too limiting, you can just insert your own crazy HTML. Span-level HTML can *still* use markdown. Block level elements must be separated from text by a blank line and must not have any spaces before the opening and closing HTML.", 269 | ); 270 | }; 271 | measureRenders( 272 | , 273 | { 274 | scenario, 275 | }, 276 | ); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /src/lib/__tests__/Markdown.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from "react"; 2 | import { render, screen, waitFor } from "@testing-library/react-native"; 3 | import { Text, type TextStyle } from "react-native"; 4 | import Markdown from "../Markdown"; 5 | import Renderer from "../Renderer"; 6 | import type { RendererInterface } from "../types"; 7 | import { Tokenizer, type Tokens } from "marked"; 8 | 9 | // https://www.markdownguide.org/basic-syntax/#headings 10 | describe("Headings", () => { 11 | it("Heading level 1", () => { 12 | const r = render(); 13 | expect(screen.queryByText("Heading level 1")).toBeTruthy(); 14 | const tree = r.toJSON(); 15 | expect(tree).toMatchSnapshot(); 16 | }); 17 | it("Heading level 2", () => { 18 | const r = render(); 19 | expect(screen.queryByText("Heading level 2")).toBeTruthy(); 20 | const tree = r.toJSON(); 21 | expect(tree).toMatchSnapshot(); 22 | }); 23 | it("Heading level 3", () => { 24 | const r = render(); 25 | expect(screen.queryByText("Heading level 3")).toBeTruthy(); 26 | const tree = r.toJSON(); 27 | expect(tree).toMatchSnapshot(); 28 | }); 29 | it("Heading level 4", () => { 30 | const r = render(); 31 | expect(screen.queryByText("Heading level 4")).toBeTruthy(); 32 | const tree = r.toJSON(); 33 | expect(tree).toMatchSnapshot(); 34 | }); 35 | it("Heading level 5", () => { 36 | const r = render(); 37 | expect(screen.queryByText("Heading level 5")).toBeTruthy(); 38 | const tree = r.toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | }); 41 | it("Heading level 6", () => { 42 | const r = render(); 43 | expect(screen.queryByText("Heading level 6")).toBeTruthy(); 44 | const tree = r.toJSON(); 45 | expect(tree).toMatchSnapshot(); 46 | }); 47 | it("Alternate Syntax: Heading level 1", () => { 48 | const r = render(); 49 | expect(screen.queryByText("Heading level 1")).toBeTruthy(); 50 | const tree = r.toJSON(); 51 | expect(tree).toMatchSnapshot(); 52 | }); 53 | it("Alternate Syntax: Heading level 2", () => { 54 | const r = render(); 55 | expect(screen.queryByText("Heading level 2")).toBeTruthy(); 56 | const tree = r.toJSON(); 57 | expect(tree).toMatchSnapshot(); 58 | }); 59 | it("Best Practice", () => { 60 | const r = render( 61 | , 66 | ); 67 | expect(screen.queryByText("Heading")).toBeTruthy(); 68 | expect( 69 | screen.queryByText("Try to put a blank line before..."), 70 | ).toBeTruthy(); 71 | expect(screen.queryByText("...and after a heading.")).toBeTruthy(); 72 | const tree = r.toJSON(); 73 | expect(tree).toMatchSnapshot(); 74 | }); 75 | it("Heading with text emphasis", () => { 76 | const r = render(); 77 | expect(screen.queryByText("Heading level 2")).toBeTruthy(); 78 | const tree = r.toJSON(); 79 | expect(tree).toMatchSnapshot(); 80 | }); 81 | }); 82 | 83 | // https://www.markdownguide.org/basic-syntax/#paragraphs-1 84 | describe("Paragraphs", () => { 85 | it("Paragraph", () => { 86 | const r = render( 87 | , 92 | ); 93 | expect(screen.queryByText("I really like using Markdown.")).toBeTruthy(); 94 | expect( 95 | screen.queryByText( 96 | "I think I'll use it to format all of my documents from now on.", 97 | ), 98 | ).toBeTruthy(); 99 | const tree = r.toJSON(); 100 | expect(tree).toMatchSnapshot(); 101 | }); 102 | it("Paragraph with Image", async () => { 103 | const r = render( 104 | , 109 | ); 110 | await waitFor(() => { 111 | expect( 112 | screen.queryByText( 113 | "Here, I'll guide you through sending desktop notifications to offline users when they have new chat messages.", 114 | ), 115 | ).toBeTruthy(); 116 | expect( 117 | screen.queryAllByTestId("react-native-marked-md-image"), 118 | ).toBeDefined(); 119 | const tree = r.toJSON(); 120 | expect(tree).toMatchSnapshot(); 121 | }); 122 | }); 123 | }); 124 | 125 | describe("Line Breaks", () => { 126 | it("Trailing New Line Character", () => { 127 | const r = render( 128 | , 131 | ); 132 | expect( 133 | screen.queryByText( 134 | "First line with a backslash after. And the next line.", 135 | ), 136 | ).toBeTruthy(); 137 | const tree = r.toJSON(); 138 | expect(tree).toMatchSnapshot(); 139 | }); 140 | 141 | it("Trailing slash", () => { 142 | const r = render( 143 | , 147 | ); 148 | expect( 149 | screen.queryByText("First line with a backslash after."), 150 | ).toBeTruthy(); 151 | expect(screen.queryByText("And the next line.")).toBeTruthy(); 152 | const tree = r.toJSON(); 153 | expect(tree).toMatchSnapshot(); 154 | }); 155 | }); 156 | 157 | // https://www.markdownguide.org/basic-syntax/#emphasis 158 | describe("Emphasis", () => { 159 | it("Bold", () => { 160 | const r = render(); 161 | expect(screen.queryByText("is")).toBeTruthy(); 162 | const tree = r.toJSON(); 163 | expect(tree).toMatchSnapshot(); 164 | }); 165 | it("Italic", () => { 166 | const r = render(); 167 | expect(screen.queryByText("cat")).toBeTruthy(); 168 | const tree = r.toJSON(); 169 | expect(tree).toMatchSnapshot(); 170 | }); 171 | it("Strikethrough", () => { 172 | const r = render(); 173 | expect(screen.queryByText("cat")).toBeTruthy(); 174 | const tree = r.toJSON(); 175 | expect(tree).toMatchSnapshot(); 176 | }); 177 | it("Bold and Italic", () => { 178 | const r = render( 179 | , 180 | ); 181 | expect(screen.queryByText("very")).toBeTruthy(); 182 | const tree = r.toJSON(); 183 | expect(tree).toMatchSnapshot(); 184 | }); 185 | }); 186 | 187 | // https://www.markdownguide.org/basic-syntax/#blockquotes-1 188 | describe("Blockquotes", () => { 189 | it("Blockquote", () => { 190 | const r = render( 191 | Dorothy followed her through many of the beautiful rooms in her castle." 194 | } 195 | />, 196 | ); 197 | expect( 198 | screen.queryByText( 199 | "Dorothy followed her through many of the beautiful rooms in her castle.", 200 | ), 201 | ).toBeTruthy(); 202 | const tree = r.toJSON(); 203 | expect(tree).toMatchSnapshot(); 204 | }); 205 | it("Blockquotes with Multiple Paragraphs", () => { 206 | const r = render( 207 | Dorothy followed her through many of the beautiful rooms in her castle.\n>\n> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood." 210 | } 211 | />, 212 | ); 213 | expect( 214 | screen.queryByText( 215 | "Dorothy followed her through many of the beautiful rooms in her castle.", 216 | ), 217 | ).toBeTruthy(); 218 | expect( 219 | screen.queryByText( 220 | "The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.", 221 | ), 222 | ).toBeTruthy(); 223 | const tree = r.toJSON(); 224 | expect(tree).toMatchSnapshot(); 225 | }); 226 | it("Nested Blockquotes", () => { 227 | const r = render( 228 | Dorothy followed her through many of the beautiful rooms in her castle.\n>\n\n>> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood." 231 | } 232 | />, 233 | ); 234 | expect( 235 | screen.queryByText( 236 | "Dorothy followed her through many of the beautiful rooms in her castle.", 237 | ), 238 | ).toBeTruthy(); 239 | expect( 240 | screen.queryByText( 241 | "The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.", 242 | ), 243 | ).toBeTruthy(); 244 | const tree = r.toJSON(); 245 | expect(tree).toMatchSnapshot(); 246 | }); 247 | it("Blockquotes with Other Elements", () => { 248 | const r = render( 249 | #### The quarterly results look great!\n>\n> - Revenue was off the chart.\n> - Profits were higher than ever.\n>\n> *Everything* is going according to **plan**." 252 | } 253 | />, 254 | ); 255 | expect( 256 | screen.queryByText("The quarterly results look great!"), 257 | ).toBeTruthy(); 258 | expect(screen.queryByText("Revenue was off the chart.")).toBeTruthy(); 259 | expect(screen.queryByText("Profits were higher than ever.")).toBeTruthy(); 260 | expect(screen.queryByText("Everything")).toBeTruthy(); 261 | expect(screen.queryByText("is going according to")).toBeTruthy(); 262 | expect(screen.queryByText("plan")).toBeTruthy(); 263 | const tree = r.toJSON(); 264 | expect(tree).toMatchSnapshot(); 265 | }); 266 | }); 267 | 268 | // https://www.markdownguide.org/basic-syntax/#lists-1 269 | describe("Lists", () => { 270 | it("Ordered Lists", () => { 271 | const r = render( 272 | , 277 | ); 278 | expect(screen.queryByText("First item")).toBeTruthy(); 279 | expect(screen.queryByText("Second item")).toBeTruthy(); 280 | expect(screen.queryByText("Third item")).toBeTruthy(); 281 | expect(screen.queryByText("Indented item1")).toBeTruthy(); 282 | expect(screen.queryByText("Indented item2")).toBeTruthy(); 283 | expect(screen.queryByText("Fourth item")).toBeTruthy(); 284 | const tree = r.toJSON(); 285 | expect(tree).toMatchSnapshot(); 286 | }); 287 | it("Ordered Lists: With Start Offset", () => { 288 | const r = render(); 289 | expect(screen.queryByText("foo")).toBeTruthy(); 290 | expect(screen.queryByText("bar")).toBeTruthy(); 291 | expect(screen.queryByText("baz")).toBeTruthy(); 292 | expect(screen.queryByText("57.")).toBeTruthy(); 293 | expect(screen.queryByText("58.")).toBeTruthy(); 294 | expect(screen.queryByText("59.")).toBeTruthy(); 295 | const tree = r.toJSON(); 296 | expect(tree).toMatchSnapshot(); 297 | }); 298 | it("Unordered Lists", () => { 299 | const r = render( 300 | , 305 | ); 306 | expect(screen.queryByText("First item")).toBeTruthy(); 307 | expect(screen.queryByText("Second item")).toBeTruthy(); 308 | expect(screen.queryByText("Third item")).toBeTruthy(); 309 | expect(screen.queryByText("Indented item1")).toBeTruthy(); 310 | expect(screen.queryByText("Indented item2")).toBeTruthy(); 311 | expect(screen.queryByText("Fourth item")).toBeTruthy(); 312 | const tree = r.toJSON(); 313 | expect(tree).toMatchSnapshot(); 314 | }); 315 | it("Elements in Lists: Paragraphs", () => { 316 | const r = render( 317 | , 322 | ); 323 | expect(screen.queryByText("This is the first list item.")).toBeTruthy(); 324 | expect(screen.queryByText("Here's the second list item.")).toBeTruthy(); 325 | expect( 326 | screen.queryByText( 327 | "I need to add another paragraph below the second list item.", 328 | ), 329 | ).toBeTruthy(); 330 | expect(screen.queryByText("And here's the third list item.")).toBeTruthy(); 331 | const tree = r.toJSON(); 332 | expect(tree).toMatchSnapshot(); 333 | }); 334 | it("Elements in Lists: Blockquotes", () => { 335 | const r = render( 336 | A blockquote would look great below the second list item.\n\n- And here's the third list item." 339 | } 340 | />, 341 | ); 342 | expect(screen.queryByText("This is the first list item.")).toBeTruthy(); 343 | expect(screen.queryByText("Here's the second list item.")).toBeTruthy(); 344 | expect( 345 | screen.queryByText( 346 | "A blockquote would look great below the second list item.", 347 | ), 348 | ).toBeTruthy(); 349 | expect(screen.queryByText("And here's the third list item.")).toBeTruthy(); 350 | const tree = r.toJSON(); 351 | expect(tree).toMatchSnapshot(); 352 | }); 353 | it("Elements in Lists: Code Blocks", () => { 354 | const r = render( 355 | \n \n \n \n\n* And here's the third list item." 358 | } 359 | />, 360 | ); 361 | expect(screen.queryByText("This is the first list item.")).toBeTruthy(); 362 | expect(screen.queryByText("Here's the second list item.")).toBeTruthy(); 363 | expect(screen.queryByText("And here's the third list item.")).toBeTruthy(); 364 | const tree = r.toJSON(); 365 | expect(tree).toMatchSnapshot(); 366 | }); 367 | it("Elements in Lists: Images", async () => { 368 | const r = render( 369 | , 374 | ); 375 | await waitFor(() => { 376 | expect( 377 | screen.queryByText("Open the file containing the Linux mascot."), 378 | ).toBeTruthy(); 379 | expect(screen.queryByText("Marvel at its beauty.")).toBeTruthy(); 380 | expect(screen.queryByText("Close the file.")).toBeTruthy(); 381 | expect( 382 | screen.queryAllByTestId("react-native-marked-md-image"), 383 | ).toBeDefined(); 384 | const tree = r.toJSON(); 385 | expect(tree).toMatchSnapshot(); 386 | }); 387 | }); 388 | it("Elements in Lists: Lists", () => { 389 | const r = render( 390 | , 395 | ); 396 | expect(screen.queryByText("First item")).toBeTruthy(); 397 | expect(screen.queryByText("Second item")).toBeTruthy(); 398 | expect(screen.queryByText("Third item")).toBeTruthy(); 399 | expect(screen.queryByText("Indented item1")).toBeTruthy(); 400 | expect(screen.queryByText("Indented item2")).toBeTruthy(); 401 | expect(screen.queryByText("Fourth item")).toBeTruthy(); 402 | const tree = r.toJSON(); 403 | expect(tree).toMatchSnapshot(); 404 | }); 405 | }); 406 | 407 | // https://www.markdownguide.org/basic-syntax/#code 408 | describe("Code", () => { 409 | it("Code Span", () => { 410 | const r = render( 411 | , 412 | ); 413 | expect(screen.queryByText("At the command prompt, type")).toBeTruthy(); 414 | expect(screen.queryByText("'nano'")).toBeTruthy(); 415 | const tree = r.toJSON(); 416 | expect(tree).toMatchSnapshot(); 417 | }); 418 | it("Code Blocks", () => { 419 | const r = render( 420 | \n \n \n "} 422 | />, 423 | ); 424 | const tree = r.toJSON(); 425 | expect(tree).toMatchSnapshot(); 426 | }); 427 | it("Code Blocks (backtick)", () => { 428 | const r = render( 429 | \n \n \n \n```"} 431 | />, 432 | ); 433 | const tree = r.toJSON(); 434 | expect(tree).toMatchSnapshot(); 435 | }); 436 | it("Code Blocks (backtick), no ending backtick", () => { 437 | const r = render( 438 | \n \n \n "} 440 | />, 441 | ); 442 | 443 | const tree = r.toJSON(); 444 | expect(tree).toMatchSnapshot(); 445 | }); 446 | }); 447 | 448 | // https://www.markdownguide.org/basic-syntax/#horizontal-rules 449 | describe("Horizontal Rules", () => { 450 | it("Asterisks", () => { 451 | const r = render(); 452 | const tree = r.toJSON(); 453 | expect(tree).toMatchSnapshot(); 454 | }); 455 | it("Dashes", () => { 456 | const r = render(); 457 | const tree = r.toJSON(); 458 | expect(tree).toMatchSnapshot(); 459 | }); 460 | it("Underscores", () => { 461 | const r = render(); 462 | const tree = r.toJSON(); 463 | expect(tree).toMatchSnapshot(); 464 | }); 465 | it("Horizontal Rule with Paragraph", () => { 466 | const r = render( 467 | , 472 | ); 473 | expect( 474 | screen.queryByText("Try to put a blank line before..."), 475 | ).toBeTruthy(); 476 | expect(screen.queryByText("...and after a horizontal rule.")).toBeTruthy(); 477 | const tree = r.toJSON(); 478 | expect(tree).toMatchSnapshot(); 479 | }); 480 | }); 481 | 482 | // https://www.markdownguide.org/basic-syntax/#links 483 | describe("Links", () => { 484 | it("Basic", () => { 485 | const r = render( 486 | , 491 | ); 492 | expect(screen.queryByText("My favorite search engine is")).toBeTruthy(); 493 | expect(screen.queryByText("Duck Duck Go")).toBeTruthy(); 494 | const tree = r.toJSON(); 495 | expect(tree).toMatchSnapshot(); 496 | }); 497 | it("Titles", () => { 498 | const r = render( 499 | , 504 | ); 505 | expect(screen.queryByText("My favorite search engine is")).toBeTruthy(); 506 | expect(screen.queryByText("Duck Duck Go")).toBeTruthy(); 507 | const tree = r.toJSON(); 508 | expect(tree).toMatchSnapshot(); 509 | }); 510 | it("URLs and Email Addresses", () => { 511 | const r = render( 512 | \n\n"} 514 | />, 515 | ); 516 | expect(screen.queryByText("https://www.markdownguide.org")).toBeTruthy(); 517 | expect(screen.queryByText("fake@example.com")).toBeTruthy(); 518 | const tree = r.toJSON(); 519 | expect(tree).toMatchSnapshot(); 520 | }); 521 | it("Formatting Links", () => { 522 | const r = render( 523 | , 528 | ); 529 | expect(screen.queryByText("EFF")).toBeTruthy(); 530 | expect(screen.queryByText("Markdown Guide")).toBeTruthy(); 531 | expect(screen.queryByText("code")).toBeTruthy(); 532 | const tree = r.toJSON(); 533 | expect(tree).toMatchSnapshot(); 534 | }); 535 | it("Links without text, (no render)", () => { 536 | const r = render( 537 | , 542 | ); 543 | expect(screen.queryByText("Table of Contents")).toBeTruthy(); 544 | const tree = r.toJSON(); 545 | expect(tree).toMatchSnapshot(); 546 | }); 547 | }); 548 | 549 | // https://www.markdownguide.org/basic-syntax/#images-1 550 | describe("Images", () => { 551 | it("Render", async () => { 552 | const r = render( 553 | , 558 | ); 559 | await waitFor(() => { 560 | expect( 561 | screen.queryAllByTestId("react-native-marked-md-image"), 562 | ).toBeDefined(); 563 | const tree = r.toJSON(); 564 | expect(tree).toMatchSnapshot(); 565 | }); 566 | }); 567 | it("Linking Images", async () => { 568 | const r = render( 569 | , 574 | ); 575 | await waitFor(() => { 576 | expect( 577 | screen.queryAllByTestId("react-native-marked-md-image"), 578 | ).toBeDefined(); 579 | const tree = r.toJSON(); 580 | expect(tree).toMatchSnapshot(); 581 | }); 582 | }); 583 | it("SVG images", async () => { 584 | const r = render( 585 | , 588 | ); 589 | await waitFor(() => { 590 | expect( 591 | screen.queryAllByTestId("react-native-marked-md-svg"), 592 | ).toBeDefined(); 593 | const tree = r.toJSON(); 594 | expect(tree).toMatchSnapshot(); 595 | }); 596 | }); 597 | it("SVG Linking", async () => { 598 | const r = render( 599 | , 604 | ); 605 | await waitFor(() => { 606 | expect( 607 | screen.queryAllByTestId("react-native-marked-md-svg"), 608 | ).toBeDefined(); 609 | const tree = r.toJSON(); 610 | expect(tree).toMatchSnapshot(); 611 | }); 612 | }); 613 | }); 614 | 615 | // https://www.markdownguide.org/basic-syntax/#escaping-characters 616 | describe("Escaping Characters", () => { 617 | it("Render", () => { 618 | const r = render( 619 | , 624 | ); 625 | expect(screen.queryByText("*")).toBeTruthy(); 626 | expect( 627 | screen.queryByText( 628 | "Without the backslash, this would be a bullet in an unordered list.", 629 | ), 630 | ).toBeTruthy(); 631 | const tree = r.toJSON(); 632 | expect(tree).toMatchSnapshot(); 633 | }); 634 | }); 635 | 636 | // https://www.markdownguide.org/basic-syntax/#html 637 | describe("HTML", () => { 638 | it("Render", () => { 639 | const r = render( 640 | word is italic."} 642 | />, 643 | ); 644 | const tree = r.toJSON(); 645 | expect(tree).toMatchSnapshot(); 646 | }); 647 | }); 648 | 649 | // https://www.markdownguide.org/extended-syntax/#tables 650 | describe("Tables", () => { 651 | it("Basic", () => { 652 | const r = render( 653 | , 661 | ); 662 | const tree = r.toJSON(); 663 | expect(screen.queryByText("Syntax")).toBeTruthy(); 664 | expect(screen.queryByText("Description")).toBeTruthy(); 665 | expect(screen.queryByText("Header")).toBeTruthy(); 666 | expect(screen.queryByText("Title")).toBeTruthy(); 667 | expect(screen.queryByText("Paragraph")).toBeTruthy(); 668 | expect(screen.queryByText("Text")).toBeTruthy(); 669 | expect(tree).toMatchSnapshot(); 670 | }); 671 | it("Different Cell Widths", () => { 672 | const r = render( 673 | , 681 | ); 682 | const tree = r.toJSON(); 683 | expect(screen.queryByText("Syntax")).toBeTruthy(); 684 | expect(screen.queryByText("Description")).toBeTruthy(); 685 | expect(screen.queryByText("Header")).toBeTruthy(); 686 | expect(screen.queryByText("Title")).toBeTruthy(); 687 | expect(screen.queryByText("Paragraph")).toBeTruthy(); 688 | expect(screen.queryByText("Text")).toBeTruthy(); 689 | expect(tree).toMatchSnapshot(); 690 | }); 691 | it("Alignment", () => { 692 | const r = render( 693 | , 701 | ); 702 | const tree = r.toJSON(); 703 | expect(screen.queryByText("Syntax")).toBeTruthy(); 704 | expect(screen.queryByText("Description")).toBeTruthy(); 705 | expect(screen.queryByText("Test Text")).toBeTruthy(); 706 | expect(screen.queryByText("Header")).toBeTruthy(); 707 | expect(screen.queryByText("Title")).toBeTruthy(); 708 | expect(screen.queryByText("Here's this")).toBeTruthy(); 709 | expect(screen.queryByText("Paragraph")).toBeTruthy(); 710 | expect(screen.queryByText("Text")).toBeTruthy(); 711 | expect(screen.queryByText("And more")).toBeTruthy(); 712 | expect(tree).toMatchSnapshot(); 713 | }); 714 | it("Pipe Character", () => { 715 | const r = render( 716 | , 724 | ); 725 | const tree = r.toJSON(); 726 | expect(screen.queryByText("Syntax")).toBeTruthy(); 727 | expect(screen.queryByText("Description")).toBeTruthy(); 728 | expect(screen.queryByText("Test Text")).toBeTruthy(); 729 | expect(screen.queryByText("Header")).toBeTruthy(); 730 | expect(screen.queryByText("Title")).toBeTruthy(); 731 | expect(screen.queryByText("Here's | this")).toBeTruthy(); 732 | expect(screen.queryByText("Paragraph")).toBeTruthy(); 733 | expect(screen.queryByText("Text")).toBeTruthy(); 734 | expect(screen.queryByText("And more")).toBeTruthy(); 735 | expect(tree).toMatchSnapshot(); 736 | }); 737 | it("Emphasis, Code, Links", () => { 738 | const r = render( 739 | , 748 | ); 749 | const tree = r.toJSON(); 750 | expect(screen.queryByText("This will also be italic")).toBeTruthy(); 751 | expect(screen.queryByText("You can combine them")).toBeTruthy(); 752 | expect(screen.queryByText("left foo")).toBeTruthy(); 753 | expect(screen.queryByText("right foo")).toBeTruthy(); 754 | expect(screen.queryByText("left bar")).toBeTruthy(); 755 | expect(screen.queryByText("right bar")).toBeTruthy(); 756 | expect(screen.queryByText("left baz")).toBeTruthy(); 757 | expect(screen.queryByText("link")).toBeTruthy(); 758 | expect(tree).toMatchSnapshot(); 759 | }); 760 | it("Images", async () => { 761 | const r = render( 762 | , 769 | ); 770 | await waitFor(() => { 771 | expect( 772 | screen.queryAllByTestId("react-native-marked-md-image"), 773 | ).toBeDefined(); 774 | const tree = r.toJSON(); 775 | expect(screen.queryByText("Hello")).toBeTruthy(); 776 | expect(screen.queryByText("Bingo")).toBeTruthy(); 777 | expect(screen.queryByText("This also works for me.")).toBeTruthy(); 778 | expect(tree).toMatchSnapshot(); 779 | }); 780 | }); 781 | }); 782 | 783 | describe("Renderer override", () => { 784 | it("Custom", () => { 785 | const fn = jest.fn( 786 | (text: string, styles?: TextStyle): ReactNode => ( 787 | 788 | {text} 789 | 790 | ), 791 | ); 792 | const style: TextStyle = { 793 | color: "#ff0000", 794 | }; 795 | class CustomRenderer extends Renderer implements RendererInterface { 796 | codespan = fn; 797 | } 798 | 799 | const r = render( 800 | , 805 | ); 806 | const tree = r.toJSON(); 807 | expect(tree).toMatchSnapshot(); 808 | expect(screen.queryByText("hello")).toBeTruthy(); 809 | }); 810 | }); 811 | describe("Tokenizer", () => { 812 | it("Custom", () => { 813 | const codespanFn = jest.fn( 814 | (text: string, styles?: TextStyle): ReactNode => ( 815 | 816 | {text} 817 | 818 | ), 819 | ); 820 | const style: TextStyle = { 821 | color: "#ff0000", 822 | }; 823 | class CustomRenderer extends Renderer implements RendererInterface { 824 | codespan = codespanFn; 825 | } 826 | 827 | class CustomTokenizer extends Tokenizer { 828 | codespan(src: string): Tokens.Codespan | undefined { 829 | const match = src.match(/^\$+([^\$\n]+?)\$+/); 830 | if (match?.[1]) { 831 | return { 832 | type: "codespan", 833 | raw: match[0], 834 | text: match[1].trim(), 835 | }; 836 | } 837 | 838 | return super.codespan(src); 839 | } 840 | } 841 | 842 | const r = render( 843 | , 849 | ); 850 | const tree = r.toJSON(); 851 | expect(tree).toMatchSnapshot(); 852 | expect(screen.queryByText("hello")).toBeTruthy(); 853 | expect(screen.queryByText("latex code")).toBeTruthy(); 854 | }); 855 | }); 856 | -------------------------------------------------------------------------------- /src/lib/__tests__/Renderer.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Linking, type ColorSchemeName } from "react-native"; 2 | import { 3 | fireEvent, 4 | render, 5 | screen, 6 | waitFor, 7 | } from "@testing-library/react-native"; 8 | import Renderer from "../Renderer"; 9 | import getStyles from "../../theme/styles"; 10 | import type { MarkedStyles } from "../../theme/types"; 11 | import type { ReactElement } from "react"; 12 | 13 | jest.mock("react-native/Libraries/Linking/Linking", () => ({ 14 | openURL: jest.fn(() => Promise.resolve("mockResolve")), 15 | })); 16 | 17 | const renderer = new Renderer(); 18 | const userStyles: MarkedStyles = { 19 | text: { 20 | fontSize: 24, 21 | }, 22 | list: { 23 | padding: 24, 24 | }, 25 | }; 26 | 27 | describe("Renderer", () => { 28 | const themes: ColorSchemeName[] = ["light", "dark"]; 29 | for (const theme of themes) { 30 | const styles = getStyles(userStyles, theme); 31 | describe(`${theme} theme`, () => { 32 | describe("Text Nodes", () => { 33 | it("returns a Text node", () => { 34 | const TextNode = renderer.text("Hello world", styles.text); 35 | 36 | const r = render(TextNode as ReactElement); 37 | expect(screen.queryByText("Hello world")).toBeTruthy(); 38 | const tree = r.toJSON(); 39 | expect(tree).toMatchSnapshot(); 40 | }); 41 | 42 | it("returns a wrapped Text node", () => { 43 | const TextNodeChild = renderer.text("Hello world", {}); 44 | const TextNode = renderer.text([TextNodeChild], styles.text); 45 | const r = render(TextNode as ReactElement); 46 | expect(screen.queryByText("Hello world")).toBeTruthy(); 47 | const tree = r.toJSON(); 48 | expect(tree).toMatchSnapshot(); 49 | }); 50 | 51 | it("returns a wrapped Text node with styles", () => { 52 | const TextNodeChild = renderer.text("Hello world", styles.text); 53 | const TextNode = renderer.text([TextNodeChild], styles.text); 54 | const r = render(TextNode as ReactElement); 55 | expect(screen.queryByText("Hello world")).toBeTruthy(); 56 | const tree = r.toJSON(); 57 | expect(tree).toMatchSnapshot(); 58 | }); 59 | }); 60 | describe("Link Nodes", () => { 61 | it("returns a Text Link node", () => { 62 | const LinkNode = renderer.link( 63 | "Link", 64 | "https://example.com", 65 | styles.link, 66 | ); 67 | const r = render(LinkNode as ReactElement); 68 | expect(screen.queryByText("Link")).toBeTruthy(); 69 | const link = screen.queryByText("Link"); 70 | if (link) { 71 | fireEvent.press(link); 72 | } 73 | expect(Linking.openURL).toHaveBeenCalled(); 74 | const tree = r.toJSON(); 75 | expect(tree).toMatchSnapshot(); 76 | }); 77 | }); 78 | describe("getImageLinkNode", () => { 79 | it("returns a Image Link node", async () => { 80 | const LinkNode = renderer.linkImage( 81 | "https://example.com", 82 | "https://dummyimage.com/100x100/fff/aaa", 83 | "Hello world", 84 | ); 85 | await waitFor(() => { 86 | const tree = render(LinkNode as ReactElement).toJSON(); 87 | expect(tree).toMatchSnapshot(); 88 | }); 89 | }); 90 | }); 91 | describe("View Nodes", () => { 92 | it("returns a paragraph View node", () => { 93 | const TextNode = renderer.text("Hello world", styles.text); 94 | const LinkNode = renderer.link( 95 | "Link", 96 | "https://example.com", 97 | styles.link, 98 | ); 99 | const ViewNode = renderer.paragraph( 100 | [TextNode, LinkNode], 101 | styles.paragraph, 102 | ); 103 | 104 | const r = render(ViewNode as ReactElement); 105 | expect(screen.queryByText("Hello world")).toBeTruthy(); 106 | expect(screen.queryByText("Link")).toBeTruthy(); 107 | const tree = r.toJSON(); 108 | expect(tree).toMatchSnapshot(); 109 | }); 110 | 111 | it("returns a hr View node", () => { 112 | const ViewNode = renderer.hr(styles.hr); 113 | const r = render(ViewNode as ReactElement); 114 | const tree = r.toJSON(); 115 | expect(tree).toMatchSnapshot(); 116 | }); 117 | }); 118 | describe("Table Nodes", () => { 119 | it("returns a Table", () => { 120 | const TextNode1 = renderer.text("Hello world 1"); 121 | const TextNode2 = renderer.text("Hello world 2", styles.strong); 122 | const TextNode3 = renderer.text("Hello world 3", styles.em); 123 | const TextNode4 = renderer.text("Hello world 4", styles.text); 124 | const TextNode5 = renderer.text("Hello world 5", styles.link); 125 | const headers = [[TextNode1], [TextNode2]]; 126 | const rows = [[[TextNode3]], [[TextNode4, TextNode5]]]; 127 | const Table = renderer.table( 128 | headers, 129 | rows, 130 | styles.table, 131 | styles.tableRow, 132 | styles.tableCell, 133 | ); 134 | const r = render(Table as ReactElement); 135 | expect(screen.queryByText("Hello world 1")).toBeTruthy(); 136 | expect(screen.queryByText("Hello world 2")).toBeTruthy(); 137 | expect(screen.queryByText("Hello world 3")).toBeTruthy(); 138 | expect(screen.queryByText("Hello world 4")).toBeTruthy(); 139 | expect(screen.queryByText("Hello world 5")).toBeTruthy(); 140 | const tree = r.toJSON(); 141 | expect(tree).toMatchSnapshot(); 142 | }); 143 | it("returns a Table without styles", () => { 144 | const TextNode1 = renderer.text("Hello world 1"); 145 | const TextNode2 = renderer.text("Hello world 2", styles.strong); 146 | const TextNode3 = renderer.text("Hello world 3", styles.em); 147 | const TextNode4 = renderer.text("Hello world 4", styles.text); 148 | const TextNode5 = renderer.text("Hello world 5", styles.link); 149 | const headers = [[TextNode1], [TextNode2]]; 150 | const rows = [[[TextNode3]], [[TextNode4, TextNode5]]]; 151 | const Table = renderer.table(headers, rows); 152 | const r = render(Table as ReactElement); 153 | expect(screen.queryByText("Hello world 1")).toBeTruthy(); 154 | expect(screen.queryByText("Hello world 2")).toBeTruthy(); 155 | expect(screen.queryByText("Hello world 3")).toBeTruthy(); 156 | expect(screen.queryByText("Hello world 4")).toBeTruthy(); 157 | expect(screen.queryByText("Hello world 5")).toBeTruthy(); 158 | const tree = r.toJSON(); 159 | expect(tree).toMatchSnapshot(); 160 | }); 161 | }); 162 | describe("getCodeBlockNode", () => { 163 | it("returns a Code block (horizontal ScrollView)", () => { 164 | const CodeBlock = renderer.code( 165 | "print('hello')", 166 | "", 167 | styles.code, 168 | styles.em, 169 | ); 170 | const r = render(CodeBlock as ReactElement); 171 | expect(screen.queryByText("print('hello')")).toBeTruthy(); 172 | const tree = r.toJSON(); 173 | expect(tree).toMatchSnapshot(); 174 | }); 175 | }); 176 | describe("getBlockquoteNode", () => { 177 | it("returns a Blockquote", () => { 178 | const TextNode = renderer.text("Hello world", styles.text); 179 | const LinkNode = renderer.link( 180 | "Link", 181 | "https://example.com", 182 | styles.link, 183 | ); 184 | const Blockquote = renderer.blockquote( 185 | [TextNode, LinkNode], 186 | styles.blockquote, 187 | ); 188 | 189 | const r = render(Blockquote as ReactElement); 190 | expect(screen.queryByText("Hello world")).toBeTruthy(); 191 | expect(screen.queryByText("Link")).toBeTruthy(); 192 | const tree = r.toJSON(); 193 | expect(tree).toMatchSnapshot(); 194 | }); 195 | }); 196 | describe("getImageNode", () => { 197 | it("returns a Image", async () => { 198 | const ImageNode = renderer.image( 199 | "https://picsum.photos/100/100", 200 | "Hello world", 201 | ); 202 | await waitFor(() => { 203 | const tree = render(ImageNode as ReactElement).toJSON(); 204 | expect(tree).toMatchSnapshot(); 205 | }); 206 | }); 207 | }); 208 | describe("getListNode", () => { 209 | it("returns Ordered List", () => { 210 | const TextNode1 = renderer.text("Hello world 1", styles.li); 211 | const TextNode2 = renderer.text("Hello world 2", styles.li); 212 | const TextNode3 = renderer.text("Hello world 3", styles.li); 213 | const OL = renderer.list( 214 | true, 215 | [TextNode1, TextNode2, TextNode3], 216 | styles.list, 217 | styles.li, 218 | ); 219 | const r = render(OL as ReactElement); 220 | expect(screen.queryByText("Hello world 1")).toBeTruthy(); 221 | expect(screen.queryByText("Hello world 2")).toBeTruthy(); 222 | expect(screen.queryByText("Hello world 3")).toBeTruthy(); 223 | const tree = r.toJSON(); 224 | expect(tree).toMatchSnapshot(); 225 | }); 226 | it("returns Un-Ordered List", () => { 227 | const TextNode1 = renderer.text("Hello world 1", styles.li); 228 | const TextNode2 = renderer.text("Hello world 2", styles.li); 229 | const TextNode3 = renderer.text("Hello world 3", styles.li); 230 | const OL = renderer.list( 231 | false, 232 | [TextNode1, TextNode2, TextNode3], 233 | styles.list, 234 | styles.li, 235 | ); 236 | const r = render(OL as ReactElement); 237 | expect(screen.queryByText("Hello world 1")).toBeTruthy(); 238 | expect(screen.queryByText("Hello world 2")).toBeTruthy(); 239 | expect(screen.queryByText("Hello world 3")).toBeTruthy(); 240 | const tree = r.toJSON(); 241 | expect(tree).toMatchSnapshot(); 242 | }); 243 | }); 244 | }); 245 | } 246 | }); 247 | -------------------------------------------------------------------------------- /src/lib/__tests__/__snapshots__/Renderer.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Renderer dark theme Link Nodes returns a Text Link node 1`] = ` 4 | 18 | Link 19 | 20 | `; 21 | 22 | exports[`Renderer dark theme Table Nodes returns a Table 1`] = ` 23 | 26 | 27 | 39 | 46 | 57 | 64 | 67 | Hello world 1 68 | 69 | 70 | 71 | 82 | 89 | 100 | Hello world 2 101 | 102 | 103 | 104 | 105 | 112 | 123 | 130 | 141 | Hello world 3 142 | 143 | 144 | 145 | 146 | 153 | 164 | 171 | 181 | Hello world 4 182 | 183 | 194 | Hello world 5 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | `; 203 | 204 | exports[`Renderer dark theme Table Nodes returns a Table without styles 1`] = ` 205 | 208 | 209 | 221 | 222 | 233 | 234 | 237 | Hello world 1 238 | 239 | 240 | 241 | 252 | 253 | 264 | Hello world 2 265 | 266 | 267 | 268 | 269 | 270 | 281 | 282 | 293 | Hello world 3 294 | 295 | 296 | 297 | 298 | 299 | 310 | 311 | 321 | Hello world 4 322 | 323 | 334 | Hello world 5 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | `; 343 | 344 | exports[`Renderer dark theme Text Nodes returns a Text node 1`] = ` 345 | 355 | Hello world 356 | 357 | `; 358 | 359 | exports[`Renderer dark theme Text Nodes returns a wrapped Text node 1`] = ` 360 | 370 | 374 | Hello world 375 | 376 | 377 | `; 378 | 379 | exports[`Renderer dark theme Text Nodes returns a wrapped Text node with styles 1`] = ` 380 | 390 | 400 | Hello world 401 | 402 | 403 | `; 404 | 405 | exports[`Renderer dark theme View Nodes returns a hr View node 1`] = ` 406 | 415 | `; 416 | 417 | exports[`Renderer dark theme View Nodes returns a paragraph View node 1`] = ` 418 | 425 | 435 | Hello world 436 | 437 | 451 | Link 452 | 453 | 454 | `; 455 | 456 | exports[`Renderer dark theme getBlockquoteNode returns a Blockquote 1`] = ` 457 | 467 | 477 | Hello world 478 | 479 | 493 | Link 494 | 495 | 496 | `; 497 | 498 | exports[`Renderer dark theme getCodeBlockNode returns a Code block (horizontal ScrollView) 1`] = ` 499 | 509 | 510 | 511 | 522 | print('hello') 523 | 524 | 525 | 526 | 527 | `; 528 | 529 | exports[`Renderer dark theme getImageLinkNode returns a Image Link node 1`] = ` 530 | 551 | 560 | 586 | 590 | 591 | 592 | `; 593 | 594 | exports[`Renderer dark theme getImageNode returns a Image 1`] = ` 595 | 604 | 630 | 634 | 635 | `; 636 | 637 | exports[`Renderer dark theme getListNode returns Ordered List 1`] = ` 638 | 651 | 658 | 677 | 1.  678 | 679 | 680 | 691 | Hello world 1 692 | 693 | 694 | `; 695 | 696 | exports[`Renderer dark theme getListNode returns Un-Ordered List 1`] = ` 697 | 710 | 717 | 736 | •  737 | 738 | 739 | 750 | Hello world 1 751 | 752 | 753 | `; 754 | 755 | exports[`Renderer light theme Link Nodes returns a Text Link node 1`] = ` 756 | 770 | Link 771 | 772 | `; 773 | 774 | exports[`Renderer light theme Table Nodes returns a Table 1`] = ` 775 | 778 | 779 | 791 | 798 | 809 | 816 | 819 | Hello world 1 820 | 821 | 822 | 823 | 834 | 841 | 852 | Hello world 2 853 | 854 | 855 | 856 | 857 | 864 | 875 | 882 | 893 | Hello world 3 894 | 895 | 896 | 897 | 898 | 905 | 916 | 923 | 933 | Hello world 4 934 | 935 | 946 | Hello world 5 947 | 948 | 949 | 950 | 951 | 952 | 953 | 954 | `; 955 | 956 | exports[`Renderer light theme Table Nodes returns a Table without styles 1`] = ` 957 | 960 | 961 | 973 | 974 | 985 | 986 | 989 | Hello world 1 990 | 991 | 992 | 993 | 1004 | 1005 | 1016 | Hello world 2 1017 | 1018 | 1019 | 1020 | 1021 | 1022 | 1033 | 1034 | 1045 | Hello world 3 1046 | 1047 | 1048 | 1049 | 1050 | 1051 | 1062 | 1063 | 1073 | Hello world 4 1074 | 1075 | 1086 | Hello world 5 1087 | 1088 | 1089 | 1090 | 1091 | 1092 | 1093 | 1094 | `; 1095 | 1096 | exports[`Renderer light theme Text Nodes returns a Text node 1`] = ` 1097 | 1107 | Hello world 1108 | 1109 | `; 1110 | 1111 | exports[`Renderer light theme Text Nodes returns a wrapped Text node 1`] = ` 1112 | 1122 | 1126 | Hello world 1127 | 1128 | 1129 | `; 1130 | 1131 | exports[`Renderer light theme Text Nodes returns a wrapped Text node with styles 1`] = ` 1132 | 1142 | 1152 | Hello world 1153 | 1154 | 1155 | `; 1156 | 1157 | exports[`Renderer light theme View Nodes returns a hr View node 1`] = ` 1158 | 1167 | `; 1168 | 1169 | exports[`Renderer light theme View Nodes returns a paragraph View node 1`] = ` 1170 | 1177 | 1187 | Hello world 1188 | 1189 | 1203 | Link 1204 | 1205 | 1206 | `; 1207 | 1208 | exports[`Renderer light theme getBlockquoteNode returns a Blockquote 1`] = ` 1209 | 1219 | 1229 | Hello world 1230 | 1231 | 1245 | Link 1246 | 1247 | 1248 | `; 1249 | 1250 | exports[`Renderer light theme getCodeBlockNode returns a Code block (horizontal ScrollView) 1`] = ` 1251 | 1261 | 1262 | 1263 | 1274 | print('hello') 1275 | 1276 | 1277 | 1278 | 1279 | `; 1280 | 1281 | exports[`Renderer light theme getImageLinkNode returns a Image Link node 1`] = ` 1282 | 1303 | 1312 | 1338 | 1342 | 1343 | 1344 | `; 1345 | 1346 | exports[`Renderer light theme getImageNode returns a Image 1`] = ` 1347 | 1356 | 1382 | 1386 | 1387 | `; 1388 | 1389 | exports[`Renderer light theme getListNode returns Ordered List 1`] = ` 1390 | 1403 | 1410 | 1429 | 1.  1430 | 1431 | 1432 | 1443 | Hello world 1 1444 | 1445 | 1446 | `; 1447 | 1448 | exports[`Renderer light theme getListNode returns Un-Ordered List 1`] = ` 1449 | 1462 | 1469 | 1488 | •  1489 | 1490 | 1491 | 1502 | Hello world 1 1503 | 1504 | 1505 | `; 1506 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import type { 3 | FlatListProps, 4 | ViewStyle, 5 | TextStyle, 6 | ImageStyle, 7 | } from "react-native"; 8 | import type { MarkedStyles, UserTheme } from "./../theme/types"; 9 | import type { Tokenizer } from "marked"; 10 | 11 | export interface ParserOptions { 12 | styles?: MarkedStyles; 13 | baseUrl?: string; 14 | renderer: RendererInterface; 15 | } 16 | 17 | export interface MarkdownProps extends Partial { 18 | value: string; 19 | flatListProps?: Omit< 20 | FlatListProps, 21 | "data" | "renderItem" | "horizontal" 22 | >; 23 | theme?: UserTheme; 24 | tokenizer?: Tokenizer; 25 | } 26 | 27 | export type TableColAlignment = "center" | "left" | "right" | null; 28 | 29 | export interface RendererInterface { 30 | paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode; 31 | blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode; 32 | heading( 33 | text: string | ReactNode[], 34 | styles?: TextStyle, 35 | depth?: number, 36 | ): ReactNode; 37 | code( 38 | text: string, 39 | language?: string, 40 | containerStyle?: ViewStyle, 41 | textStyle?: TextStyle, 42 | ): ReactNode; 43 | hr(styles?: ViewStyle): ReactNode; 44 | listItem(children: ReactNode[], styles?: ViewStyle): ReactNode; 45 | list( 46 | ordered: boolean, 47 | li: ReactNode[], 48 | listStyle?: ViewStyle, 49 | textStyle?: TextStyle, 50 | startIndex?: number, 51 | ): ReactNode; 52 | escape(text: string, styles?: TextStyle): ReactNode; 53 | link( 54 | children: string | ReactNode[], 55 | href: string, 56 | styles?: TextStyle, 57 | ): ReactNode; 58 | image(uri: string, alt?: string, style?: ImageStyle): ReactNode; 59 | strong(children: string | ReactNode[], styles?: TextStyle): ReactNode; 60 | em(children: string | ReactNode[], styles?: TextStyle): ReactNode; 61 | codespan(text: string, styles?: TextStyle): ReactNode; 62 | br(): ReactNode; 63 | del(children: string | ReactNode[], styles?: TextStyle): ReactNode; 64 | text(text: string | ReactNode[], styles?: TextStyle): ReactNode; 65 | html(text: string | ReactNode[], styles?: TextStyle): ReactNode; 66 | linkImage( 67 | href: string, 68 | imageUrl: string, 69 | alt?: string, 70 | style?: ImageStyle, 71 | ): ReactNode; 72 | table( 73 | header: ReactNode[][], 74 | rows: ReactNode[][][], 75 | tableStyle?: ViewStyle, 76 | rowStyle?: ViewStyle, 77 | cellStyle?: ViewStyle, 78 | ): ReactNode; 79 | } 80 | -------------------------------------------------------------------------------- /src/theme/__tests__/styles.spec.ts: -------------------------------------------------------------------------------- 1 | import colors from "../colors"; 2 | import type { MarkedStyles } from "../types"; 3 | import getStyles from "./../styles"; 4 | 5 | describe("getStyles", () => { 6 | it("light scheme", () => { 7 | const styles = getStyles({}, "light"); 8 | expect(styles.text?.color).toBe(colors.light.text); 9 | }); 10 | it("dark scheme", () => { 11 | const styles = getStyles({}, "dark"); 12 | expect(styles.text?.color).toBe(colors.dark.text); 13 | }); 14 | it("default scheme", () => { 15 | const styles = getStyles({}, null); 16 | expect(styles.text?.color).toBe(colors.light.text); 17 | }); 18 | it("user styles, light scheme", () => { 19 | const styles = getStyles( 20 | { 21 | text: { 22 | color: "#aaa", 23 | padding: 2, 24 | }, 25 | }, 26 | "light", 27 | ); 28 | expect(styles.text?.color).toBe("#aaa"); 29 | expect(styles.text?.padding).toBe(2); 30 | }); 31 | it("user styles, dark scheme", () => { 32 | const styles = getStyles( 33 | { 34 | text: { 35 | color: "#aaa", 36 | padding: 2, 37 | }, 38 | }, 39 | "dark", 40 | ); 41 | expect(styles.text?.color).toBe("#aaa"); 42 | expect(styles.text?.padding).toBe(2); 43 | }); 44 | it("user styles, default scheme", () => { 45 | const styles = getStyles( 46 | { 47 | text: { 48 | color: "#aaa", 49 | padding: 2, 50 | }, 51 | }, 52 | null, 53 | ); 54 | expect(styles.text?.color).toBe("#aaa"); 55 | expect(styles.text?.padding).toBe(2); 56 | }); 57 | it("light scheme, custom theme", () => { 58 | const customColors = { 59 | background: "#aaa", 60 | code: "#bbb", 61 | text: "#ccc", 62 | link: "#ddd", 63 | border: "#eee", 64 | }; 65 | const styles = getStyles({}, "light", { colors: customColors }); 66 | expect(styles.code?.backgroundColor).toBe(customColors.code); 67 | expect(styles.text?.color).toBe(customColors.text); 68 | expect(styles.link?.color).toBe(customColors.link); 69 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 70 | }); 71 | it("dark scheme, custom theme", () => { 72 | const customColors = { 73 | background: "#aaa", 74 | code: "#bbb", 75 | text: "#ccc", 76 | link: "#ddd", 77 | border: "#eee", 78 | }; 79 | const styles = getStyles({}, "dark", { colors: customColors }); 80 | expect(styles.code?.backgroundColor).toBe(customColors.code); 81 | expect(styles.text?.color).toBe(customColors.text); 82 | expect(styles.link?.color).toBe(customColors.link); 83 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 84 | }); 85 | it("default scheme, custom theme", () => { 86 | const customColors = { 87 | background: "#aaa", 88 | code: "#bbb", 89 | text: "#ccc", 90 | link: "#ddd", 91 | border: "#eee", 92 | }; 93 | const styles = getStyles({}, null, { colors: customColors }); 94 | expect(styles.code?.backgroundColor).toBe(customColors.code); 95 | expect(styles.text?.color).toBe(customColors.text); 96 | expect(styles.link?.color).toBe(customColors.link); 97 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 98 | }); 99 | it("light scheme, custom theme, spacing", () => { 100 | const customColors = { 101 | background: "#aaa", 102 | code: "#bbb", 103 | text: "#ccc", 104 | link: "#ddd", 105 | border: "#eee", 106 | }; 107 | const spacing = { xs: 10, s: 20, m: 30, l: 40 }; 108 | const styles = getStyles({}, "light", { spacing, colors: customColors }); 109 | expect(styles.code?.backgroundColor).toBe(customColors.code); 110 | expect(styles.text?.color).toBe(customColors.text); 111 | expect(styles.link?.color).toBe(customColors.link); 112 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 113 | 114 | expect(styles.h6?.marginVertical).toBe(spacing.xs); 115 | expect(styles.h1?.paddingBottom).toBe(spacing.s); 116 | expect(styles.h1?.marginVertical).toBe(spacing.m); 117 | expect(styles.code?.padding).toBe(spacing.l); 118 | }); 119 | it("dark scheme, custom theme, spacing", () => { 120 | const customColors = { 121 | background: "#aaa", 122 | code: "#bbb", 123 | text: "#ccc", 124 | link: "#ddd", 125 | border: "#eee", 126 | }; 127 | const spacing = { xs: 10, s: 20, m: 30, l: 40 }; 128 | const styles = getStyles({}, "dark", { spacing, colors: customColors }); 129 | expect(styles.code?.backgroundColor).toBe(customColors.code); 130 | expect(styles.text?.color).toBe(customColors.text); 131 | expect(styles.link?.color).toBe(customColors.link); 132 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 133 | 134 | expect(styles.h6?.marginVertical).toBe(spacing.xs); 135 | expect(styles.h1?.paddingBottom).toBe(spacing.s); 136 | expect(styles.h1?.marginVertical).toBe(spacing.m); 137 | expect(styles.code?.padding).toBe(spacing.l); 138 | }); 139 | it("default scheme, custom theme, spacing", () => { 140 | const customColors = { 141 | background: "#aaa", 142 | code: "#bbb", 143 | text: "#ccc", 144 | link: "#ddd", 145 | border: "#eee", 146 | }; 147 | const spacing = { xs: 10, s: 20, m: 30, l: 40 }; 148 | const styles = getStyles({}, null, { spacing, colors: customColors }); 149 | expect(styles.code?.backgroundColor).toBe(customColors.code); 150 | expect(styles.text?.color).toBe(customColors.text); 151 | expect(styles.link?.color).toBe(customColors.link); 152 | expect(styles.hr?.borderBottomColor).toBe(customColors.border); 153 | 154 | expect(styles.h6?.marginVertical).toBe(spacing.xs); 155 | expect(styles.h1?.paddingBottom).toBe(spacing.s); 156 | expect(styles.h1?.marginVertical).toBe(spacing.m); 157 | expect(styles.code?.padding).toBe(spacing.l); 158 | }); 159 | it("user styles, custom theme, spacing", () => { 160 | const customColors = { 161 | background: "#aaa", 162 | code: "#bbb", 163 | text: "#ccc", 164 | link: "#ddd", 165 | border: "#eee", 166 | }; 167 | const spacing = { xs: 10, s: 20, m: 30, l: 40 }; 168 | 169 | const userStyles: MarkedStyles = { 170 | code: { 171 | backgroundColor: "#222", 172 | padding: 4, 173 | }, 174 | text: { 175 | color: "#333", 176 | }, 177 | link: { 178 | color: "#444", 179 | }, 180 | hr: { 181 | borderBottomColor: "#555", 182 | }, 183 | h1: { 184 | paddingBottom: 2, 185 | marginVertical: 3, 186 | }, 187 | h6: { 188 | marginVertical: 1, 189 | }, 190 | }; 191 | 192 | const styles = getStyles(userStyles, "light", { 193 | spacing, 194 | colors: customColors, 195 | }); 196 | expect(styles.code?.backgroundColor).toBe("#222"); 197 | expect(styles.text?.color).toBe("#333"); 198 | expect(styles.link?.color).toBe("#444"); 199 | expect(styles.hr?.borderBottomColor).toBe("#555"); 200 | 201 | expect(styles.h6?.marginVertical).toBe(1); 202 | expect(styles.h1?.paddingBottom).toBe(2); 203 | expect(styles.h1?.marginVertical).toBe(3); 204 | expect(styles.code?.padding).toBe(4); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/theme/colors.ts: -------------------------------------------------------------------------------- 1 | import type { ColorValue } from "react-native"; 2 | 3 | export interface ColorsPropType { 4 | code: ColorValue; 5 | link: ColorValue; 6 | text: ColorValue; 7 | border: ColorValue; 8 | /** 9 | * @deprecated Use flatlist containerStyle or style prop for setting background color 10 | */ 11 | background?: ColorValue; 12 | } 13 | 14 | const colors: Record<"light" | "dark", ColorsPropType> = { 15 | light: { 16 | background: "#ffffff", 17 | code: "#f6f8fa", 18 | link: "#58a6ff", 19 | text: "#333333", 20 | border: "#d0d7de", 21 | }, 22 | dark: { 23 | background: "#000000", 24 | code: "#161b22", 25 | link: "#58a6ff", 26 | text: "#ffffff", 27 | border: "#30363d", 28 | }, 29 | }; 30 | 31 | export default colors; 32 | -------------------------------------------------------------------------------- /src/theme/spacing.ts: -------------------------------------------------------------------------------- 1 | export type SpacingKeysType = "xs" | "s" | "m" | "l"; 2 | 3 | const padding: Record = { 4 | xs: 2, 5 | s: 4, 6 | m: 8, 7 | l: 16, 8 | }; 9 | 10 | export default padding; 11 | -------------------------------------------------------------------------------- /src/theme/styles.ts: -------------------------------------------------------------------------------- 1 | import { StyleSheet, type ColorSchemeName } from "react-native"; 2 | import spacing from "./spacing"; 3 | import colors, { type ColorsPropType } from "./colors"; 4 | import type { MarkedStyles, UserTheme } from "./types"; 5 | 6 | const getFontStyles = (mdColors: ColorsPropType) => { 7 | return StyleSheet.create({ 8 | regular: { 9 | fontSize: 16, 10 | lineHeight: 24, 11 | color: mdColors.text, 12 | }, 13 | heading: { 14 | fontWeight: "500", 15 | color: mdColors.text, 16 | }, 17 | }); 18 | }; 19 | 20 | const getStyles = ( 21 | userStyles?: MarkedStyles, 22 | colorScheme?: ColorSchemeName, 23 | userTheme?: UserTheme, 24 | ): MarkedStyles => { 25 | const mdColors = { ...colors[colorScheme || "light"], ...userTheme?.colors }; 26 | const mdSpacing = { ...spacing, ...userTheme?.spacing }; 27 | 28 | const fontStyle = getFontStyles(mdColors); 29 | return StyleSheet.create({ 30 | em: StyleSheet.flatten([ 31 | fontStyle.regular, 32 | { 33 | fontStyle: "italic", 34 | }, 35 | userStyles?.em, 36 | ]), 37 | strong: StyleSheet.flatten([ 38 | fontStyle.regular, 39 | { 40 | fontWeight: "bold", 41 | }, 42 | userStyles?.strong, 43 | ]), 44 | strikethrough: StyleSheet.flatten([ 45 | fontStyle.regular, 46 | { 47 | textDecorationLine: "line-through", 48 | textDecorationStyle: "solid", 49 | }, 50 | userStyles?.strikethrough, 51 | ]), 52 | text: StyleSheet.flatten([fontStyle.regular, userStyles?.text]), 53 | paragraph: StyleSheet.flatten([ 54 | { 55 | paddingVertical: mdSpacing.m, 56 | }, 57 | userStyles?.paragraph, 58 | ]), 59 | link: StyleSheet.flatten([ 60 | fontStyle.regular, 61 | { 62 | fontStyle: "italic", 63 | color: mdColors.link, 64 | }, 65 | userStyles?.link, 66 | ]), 67 | blockquote: StyleSheet.flatten([ 68 | { 69 | borderLeftColor: mdColors.border, 70 | paddingLeft: mdSpacing.l, 71 | borderLeftWidth: mdSpacing.s, 72 | opacity: 0.8, 73 | }, 74 | userStyles?.blockquote, 75 | ]), 76 | h1: StyleSheet.flatten([ 77 | fontStyle.heading, 78 | { 79 | fontSize: 32, 80 | lineHeight: 40, 81 | fontWeight: "bold", 82 | marginVertical: mdSpacing.m, 83 | letterSpacing: 0, 84 | paddingBottom: mdSpacing.s, 85 | borderBottomColor: mdColors.border, 86 | borderBottomWidth: 1, 87 | }, 88 | userStyles?.h1, 89 | ]), 90 | h2: StyleSheet.flatten([ 91 | fontStyle.heading, 92 | { 93 | fontSize: 28, 94 | lineHeight: 36, 95 | marginVertical: mdSpacing.m, 96 | paddingBottom: mdSpacing.s, 97 | borderBottomColor: mdColors.border, 98 | borderBottomWidth: 1, 99 | }, 100 | userStyles?.h2, 101 | ]), 102 | h3: StyleSheet.flatten([ 103 | fontStyle.heading, 104 | { 105 | fontSize: 24, 106 | lineHeight: 32, 107 | marginVertical: mdSpacing.s, 108 | }, 109 | userStyles?.h3, 110 | ]), 111 | h4: StyleSheet.flatten([ 112 | fontStyle.heading, 113 | { 114 | fontSize: 22, 115 | lineHeight: 28, 116 | marginVertical: mdSpacing.s, 117 | }, 118 | userStyles?.h4, 119 | ]), 120 | h5: StyleSheet.flatten([ 121 | fontStyle.regular, 122 | fontStyle.heading, 123 | { 124 | marginVertical: mdSpacing.xs, 125 | }, 126 | userStyles?.h5, 127 | ]), 128 | h6: StyleSheet.flatten([ 129 | fontStyle.heading, 130 | { 131 | fontSize: 14, 132 | lineHeight: 20, 133 | marginVertical: mdSpacing.xs, 134 | }, 135 | userStyles?.h6, 136 | ]), 137 | codespan: StyleSheet.flatten([ 138 | fontStyle.regular, 139 | { 140 | fontStyle: "italic", 141 | backgroundColor: mdColors.code, 142 | fontWeight: "300", 143 | }, 144 | userStyles?.codespan, 145 | ]), 146 | code: StyleSheet.flatten([ 147 | { 148 | padding: mdSpacing.l, 149 | backgroundColor: mdColors.code, 150 | minWidth: "100%", 151 | }, 152 | userStyles?.code, 153 | ]), 154 | hr: StyleSheet.flatten([ 155 | { 156 | borderBottomWidth: 1, 157 | borderBottomColor: mdColors.border, 158 | marginVertical: mdSpacing.s, 159 | }, 160 | userStyles?.hr, 161 | ]), 162 | list: StyleSheet.flatten([userStyles?.list]), 163 | li: StyleSheet.flatten([ 164 | fontStyle.regular, 165 | { 166 | flexShrink: 1, 167 | }, 168 | userStyles?.li, 169 | ]), 170 | image: StyleSheet.flatten([ 171 | { 172 | resizeMode: "cover", 173 | }, 174 | userStyles?.image, 175 | ]), 176 | table: StyleSheet.flatten([ 177 | { 178 | borderWidth: 1, 179 | borderColor: mdColors.border, 180 | }, 181 | userStyles?.table, 182 | ]), 183 | tableRow: StyleSheet.flatten([ 184 | { 185 | flexDirection: "row", 186 | }, 187 | userStyles?.tableRow, 188 | ]), 189 | tableCell: StyleSheet.flatten([ 190 | { 191 | padding: mdSpacing.s, 192 | }, 193 | userStyles?.tableCell, 194 | ]), 195 | }); 196 | }; 197 | 198 | export default getStyles; 199 | -------------------------------------------------------------------------------- /src/theme/types.ts: -------------------------------------------------------------------------------- 1 | import type { ImageStyle, TextStyle, ViewStyle } from "react-native"; 2 | import type { ColorsPropType } from "./colors"; 3 | import type { SpacingKeysType } from "./spacing"; 4 | 5 | export interface MarkedStyles { 6 | em?: TextStyle; 7 | strong?: TextStyle; 8 | strikethrough?: TextStyle; 9 | text?: TextStyle; 10 | paragraph?: ViewStyle; 11 | link?: TextStyle; 12 | blockquote?: ViewStyle; 13 | h1?: TextStyle; 14 | h2?: TextStyle; 15 | h3?: TextStyle; 16 | h4?: TextStyle; 17 | h5?: TextStyle; 18 | h6?: TextStyle; 19 | codespan?: TextStyle; 20 | code?: ViewStyle; 21 | hr?: ViewStyle; 22 | list?: ViewStyle; 23 | li?: TextStyle; 24 | image?: ImageStyle; 25 | table?: ViewStyle; 26 | tableRow?: ViewStyle; 27 | tableCell?: ViewStyle; 28 | } 29 | 30 | export interface UserTheme { 31 | colors?: ColorsPropType; 32 | spacing?: Record; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/__tests__/handlers.spec.ts: -------------------------------------------------------------------------------- 1 | import { Linking } from "react-native"; 2 | import { onLinkPress } from "../handlers"; 3 | 4 | jest.mock("react-native/Libraries/Linking/Linking", () => ({ 5 | openURL: jest.fn(() => Promise.resolve("mockResolve")), 6 | })); 7 | 8 | describe("onLinkPress", () => { 9 | it("Good url", async () => { 10 | const cb = onLinkPress("https://example.com"); 11 | cb.call(null); 12 | expect(Linking.openURL).toHaveBeenCalled(); 13 | }); 14 | 15 | it("Bad url", async () => { 16 | Linking.openURL = jest.fn(() => Promise.reject("mockReject")); 17 | const cb = onLinkPress("example"); 18 | cb.call(null); 19 | expect(Linking.openURL).toHaveBeenCalled(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/__tests__/svg.spec.ts: -------------------------------------------------------------------------------- 1 | import { getSvgDimensions } from "../svg"; 2 | 3 | describe("getSvgDimensions", () => { 4 | it("svg with width, height, viewBox attribute", () => { 5 | expect(getSvgDimensions(SVG_WITH_WIDTH_HEIGHT)).toStrictEqual({ 6 | width: 800, 7 | height: 800, 8 | viewBox: "0 0 64 64", 9 | }); 10 | }); 11 | it("svg without width, height", () => { 12 | expect(getSvgDimensions(SVG_WITHOUT_WIDTH_HEIGHT)).toStrictEqual({ 13 | width: 0, 14 | height: 0, 15 | viewBox: "0 0 64 64", 16 | }); 17 | }); 18 | it("svg without width, height, viewBox", () => { 19 | expect(getSvgDimensions(SVG_WITHOUT_WIDTH_HEIGHT_VIEW_BOX)).toStrictEqual({ 20 | width: 0, 21 | height: 0, 22 | viewBox: "", 23 | }); 24 | }); 25 | }); 26 | 27 | const SVG_WITH_WIDTH_HEIGHT = ` 28 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | 38 | 39 | 43 | 44 | 57 | 58 | 60 | 62 | 64 | 86 | 87 | 89 | 90 | 91 | 92 | `; 93 | 94 | const SVG_WITHOUT_WIDTH_HEIGHT = ` 95 | 96 | 97 | 98 | 99 | 101 | 102 | 103 | 105 | 106 | 110 | 111 | 124 | 125 | 127 | 129 | 131 | 153 | 154 | 156 | 157 | 158 | 159 | `; 160 | 161 | const SVG_WITHOUT_WIDTH_HEIGHT_VIEW_BOX = ` 162 | 163 | 164 | 165 | 166 | 168 | 169 | 170 | 172 | 173 | 177 | 178 | 191 | 192 | 194 | 196 | 198 | 220 | 221 | 223 | 224 | 225 | 226 | `; 227 | -------------------------------------------------------------------------------- /src/utils/__tests__/table.spec.ts: -------------------------------------------------------------------------------- 1 | import { getTableWidthArr, getTableColAlignmentStyle } from "./../table"; 2 | 3 | describe("getTableWidthArr", () => { 4 | const windowWidth = 360; 5 | const colWidth = Math.floor(360 * (1.3 / 3)); 6 | it("negative", () => { 7 | expect(getTableWidthArr(-2, windowWidth)).toStrictEqual([]); 8 | }); 9 | it("zero", () => { 10 | expect(getTableWidthArr(0, windowWidth)).toStrictEqual([]); 11 | }); 12 | it("positive", () => { 13 | expect(getTableWidthArr(2, windowWidth)).toStrictEqual( 14 | Array(2).fill(colWidth), 15 | ); 16 | }); 17 | it("random", () => { 18 | const random = Math.floor(100 * Math.random()); 19 | expect(getTableWidthArr(random, windowWidth)).toStrictEqual( 20 | Array(random).fill(colWidth), 21 | ); 22 | }); 23 | }); 24 | 25 | describe("getTableColAlignmentStyle", () => { 26 | it("center", () => { 27 | expect(getTableColAlignmentStyle("center")).toStrictEqual({ 28 | textAlign: "center", 29 | }); 30 | }); 31 | it("left", () => { 32 | expect(getTableColAlignmentStyle("left")).toStrictEqual({ 33 | textAlign: "left", 34 | }); 35 | }); 36 | it("right", () => { 37 | expect(getTableColAlignmentStyle("right")).toStrictEqual({ 38 | textAlign: "right", 39 | }); 40 | }); 41 | it("undefined", () => { 42 | expect(getTableColAlignmentStyle()).toStrictEqual({ 43 | textAlign: "left", 44 | }); 45 | }); 46 | it("null", () => { 47 | expect(getTableColAlignmentStyle(null)).toStrictEqual({ 48 | textAlign: "left", 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/utils/__tests__/url.spec.ts: -------------------------------------------------------------------------------- 1 | import { getValidURL } from "../url"; 2 | 3 | describe("getValidURL", () => { 4 | it("abs with http prefix", () => { 5 | expect( 6 | getValidURL("https://www.example.com", "http://www.example.com/path"), 7 | ).toBe("http://www.example.com/path"); 8 | }); 9 | it("abs with https prefix", () => { 10 | expect( 11 | getValidURL("https://www.example.com", "https://www.example.com/path"), 12 | ).toBe("https://www.example.com/path"); 13 | }); 14 | it("abs with no prefix", () => { 15 | expect(getValidURL("https://www.example.com", "/path")).toBe( 16 | "https://www.example.com/path", 17 | ); 18 | }); 19 | it("relative with no prefix", () => { 20 | expect(getValidURL("https://www.example.com", "path")).toBe( 21 | "https://www.example.com/path", 22 | ); 23 | }); 24 | it("prefix with trailing slash", () => { 25 | expect(getValidURL("https://www.example.com/", "path")).toBe( 26 | "https://www.example.com/path", 27 | ); 28 | }); 29 | it("empty prefix", () => { 30 | expect(getValidURL("", "path")).toBe("/path"); 31 | }); 32 | it("ignores prefix value for non-http URLs", () => { 33 | expect(getValidURL("", "mailto:example.com")).toBe("mailto:example.com"); 34 | expect(getValidURL("https://www.example.com", "mailto:example.com")).toBe( 35 | "mailto:example.com", 36 | ); 37 | expect(getValidURL("", "tel:0123456789")).toBe("tel:0123456789"); 38 | expect(getValidURL("https://www.example.com", "tel:0123456789")).toBe( 39 | "tel:0123456789", 40 | ); 41 | expect(getValidURL("", "slack://open")).toBe("slack://open"); 42 | expect(getValidURL("https://www.example.com", "slack://open")).toBe( 43 | "slack://open", 44 | ); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/handlers.ts: -------------------------------------------------------------------------------- 1 | import { Linking } from "react-native"; 2 | 3 | export const onLinkPress = (url: string) => () => { 4 | Linking.openURL(url) 5 | .then(() => null) 6 | .catch((e) => { 7 | console.warn("URL can't be opened", e); 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | import type { ElementNode } from "svg-parser"; 2 | import { parse } from "svg-parser"; 3 | 4 | export const getSvgDimensions = (svg: string) => { 5 | const parsed = parse(svg); 6 | 7 | const rootChild = parsed.children[0] as ElementNode; 8 | 9 | return { 10 | width: Number.parseInt(String(rootChild.properties?.width ?? "0")), 11 | height: Number.parseInt(String(rootChild.properties?.height ?? "0")), 12 | viewBox: String(rootChild.properties?.viewBox ?? ""), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/table.ts: -------------------------------------------------------------------------------- 1 | import type { TextStyle } from "react-native"; 2 | import type { TableColAlignment } from "../lib/types"; 3 | 4 | export const getTableWidthArr = (totalCols: number, windowWidth: number) => { 5 | if (totalCols < 1) { 6 | return []; 7 | } 8 | 9 | return Array(totalCols) 10 | .fill(0) 11 | .map(() => { 12 | return Math.floor(windowWidth * (1.3 / 3)); 13 | }); 14 | }; 15 | 16 | export const getTableColAlignmentStyle = ( 17 | alignment?: TableColAlignment, 18 | ): TextStyle => { 19 | switch (alignment) { 20 | case "center": 21 | return { textAlign: "center" }; 22 | case "left": 23 | return { textAlign: "left" }; 24 | case "right": 25 | return { textAlign: "right" }; 26 | default: 27 | return { textAlign: "left" }; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const getValidURL = (prefix: string, path: string) => { 2 | let _prefix = prefix; 3 | // remove trailing slash from prefix 4 | if (_prefix.endsWith("/")) { 5 | _prefix = _prefix.slice(0, -1); 6 | } 7 | 8 | // consider path a valid url if it starts with a scheme name followed by a semicolon 9 | // i.e. https://example.com, mailto:person@example.com, tel:1234567, slack://open 10 | const urlPattern = /^[a-z]+:/i; 11 | if (urlPattern.test(path)) { 12 | return path; 13 | } 14 | 15 | // absolute path 16 | if (path.startsWith("/")) { 17 | return `${_prefix}${path}`; 18 | } 19 | 20 | // relative path 21 | return `${_prefix}/${path}`; 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "react-native-marked": ["./src/index"] 6 | }, 7 | "allowUnreachableCode": false, 8 | "allowUnusedLabels": false, 9 | "esModuleInterop": true, 10 | "verbatimModuleSyntax": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "jsx": "react", 13 | "lib": ["esnext"], 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "noImplicitUseStrict": false, 19 | "noStrictGenericChecks": false, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "strict": true, 26 | "target": "esnext", 27 | "declaration": true 28 | }, 29 | "exclude": ["examples", "dangerfile.ts"] 30 | } 31 | --------------------------------------------------------------------------------