├── .codeclimate.yml ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature-request.md ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── check-commit-style.yml │ ├── check-required-labels.yaml │ ├── deploy-release.yaml │ ├── send-guidelines.yaml │ ├── update-release-notes.yaml │ └── validate-branch.yaml ├── .gitignore ├── LICENSE ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── ast.ts ├── index.ts ├── parsing.ts └── rendering.ts ├── test ├── parsing.test.ts └── rendering.test.ts ├── tsconfig.build.json └── tsconfig.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 4 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 500 12 | method-complexity: 13 | config: 14 | threshold: 5 15 | method-count: 16 | config: 17 | threshold: 30 18 | method-lines: 19 | config: 20 | threshold: 100 21 | nested-control-flow: 22 | config: 23 | threshold: 4 24 | return-statements: 25 | config: 26 | threshold: 4 27 | identical-code: 28 | config: 29 | threshold: 80 30 | similar-code: 31 | enabled: false 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // Workaround for https://github.com/eslint/eslint/issues/3458 2 | require('@rushstack/eslint-patch/modern-module-resolution'); 3 | 4 | module.exports = { 5 | extends: ['plugin:@croct/typescript'], 6 | plugins: ['@croct'], 7 | parserOptions: { 8 | project: ['**/tsconfig.json'], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default owners 2 | * @croct-tech/javascript-developer 3 | 4 | # GitHub configurations 5 | /.github/ @croct-tech/infra 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug report" 3 | about: "Create a report to help us improve" 4 | labels: bug 5 | --- 6 | 7 | ## 🐞 Bug report 8 | Please describe the problem you are experiencing. 9 | 10 | ### Steps to reproduce 11 | Please provide a minimal code snippet and the detailed steps for reproducing the issue. 12 | 13 | 1. Step 1 14 | 2. Step 2 15 | 3. Step 3 16 | 17 | ### Expected behavior 18 | Please describe the behavior you are expecting. 19 | 20 | ### Screenshots 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | ### Additional context 24 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✨ Feature request" 3 | about: "Suggest an idea for this project" 4 | labels: enhancement 5 | --- 6 | 7 | ## ✨ Feature request 8 | Please provide a brief explanation of the feature. 9 | 10 | ### Motivation 11 | Please share the motivation for the new feature, and what problem it is solving. 12 | 13 | ### Example 14 | If the proposal involves a new or changed API, please include a basic code example. 15 | 16 | ### Alternatives 17 | Please describe the alternative solutions or features you've considered. 18 | 19 | ### Additional context 20 | Please provide any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | Please include a summary of the change and which issue is addressed. 3 | 4 | - Fixes #(issue 1) 5 | - Fixes #(issue 2) 6 | - Fixes #(issue 3) 7 | 8 | ### Checklist 9 | 10 | - [ ] My code follows the style guidelines of this project 11 | - [ ] I have performed a self-review of my own code 12 | - [ ] I have commented my code, particularly in hard-to-understand areas 13 | - [ ] I have made corresponding changes to the documentation 14 | - [ ] My changes generate no new warnings 15 | - [ ] I have added tests that prove my fix is effective or that my feature works 16 | - [ ] New and existing unit tests pass locally with my changes 17 | - [ ] Any dependent changes have been merged and published in downstream modules 18 | - [ ] I have checked my code and corrected any misspellings -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$NEXT_PATCH_VERSION' 2 | tag-template: '$NEXT_PATCH_VERSION' 3 | prerelease: true 4 | categories: 5 | - title: '🚀 Features' 6 | labels: 7 | - feature 8 | - title: '🔧 Enhancements' 9 | labels: 10 | - enhancement 11 | - title: '🐞 Bug Fixes' 12 | labels: 13 | - bug 14 | - title: '🚧 Maintenance' 15 | labels: 16 | - maintenance 17 | change-template: '- $TITLE (#$NUMBER), thanks [$AUTHOR](https://github.com/$AUTHOR)!' 18 | sort-by: merged_at 19 | sort-direction: descending 20 | branches: 21 | - master 22 | exclude-labels: 23 | - wontfix 24 | - duplicate 25 | - invalid 26 | - question 27 | no-changes-template: 'This release contains minor changes and bugfixes.' 28 | template: |- 29 | ## What's Changed 30 | 31 | $CHANGES 32 | 33 | 🎉 **Thanks to all contributors helping with this release:** 34 | $CONTRIBUTORS 35 | -------------------------------------------------------------------------------- /.github/workflows/check-commit-style.yml: -------------------------------------------------------------------------------- 1 | name: Commit style 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | - synchronize 10 | push: 11 | branches: 12 | - master 13 | - 'dev/*' 14 | 15 | jobs: 16 | check-commit-style: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check 20 | uses: mristin/opinionated-commit-message@v3.1.1 21 | with: 22 | allow-one-liners: 'true' 23 | skip-body-check: 'true' 24 | -------------------------------------------------------------------------------- /.github/workflows/check-required-labels.yaml: -------------------------------------------------------------------------------- 1 | name: Label requirements 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - synchronize 7 | - reopened 8 | - labeled 9 | - unlabeled 10 | 11 | jobs: 12 | check-labels: 13 | name: Check labels 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: docker://agilepathway/pull-request-label-checker:latest 17 | with: 18 | any_of: maintenance,feature,bug,enhancement 19 | repo_token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release deploy 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 18 16 | registry-url: 'https://registry.npmjs.org' 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | 20 | - name: Cache dependencies 21 | id: cache-dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: node_modules 25 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 26 | 27 | - name: Install dependencies 28 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 29 | run: |- 30 | npm ci 31 | rm -rf ~/.npmrc 32 | 33 | - name: Build package 34 | run: |- 35 | npm run build 36 | 37 | - name: Prepare release 38 | run: |- 39 | cp package.json LICENSE README.md build/ 40 | cd build 41 | sed -i -e "s~\"version\": \"0.0.0-dev\"~\"version\": \"${GITHUB_REF##*/}\"~" package.json 42 | 43 | - name: Publish pre-release to NPM 44 | if: ${{ github.event.release.prerelease }} 45 | run: |- 46 | cd build 47 | npm publish --access public --tag next 48 | 49 | - name: Publish release to NPM 50 | if: ${{ !github.event.release.prerelease }} 51 | run: |- 52 | cd build 53 | npm publish --access public 54 | -------------------------------------------------------------------------------- /.github/workflows/send-guidelines.yaml: -------------------------------------------------------------------------------- 1 | name: Guidelines 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | jobs: 8 | send-guidelines: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: wow-actions/auto-comment@v1 12 | with: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | pullRequestOpened: | 15 | 👋 @{{ author }} 16 | **Thanks for your contribution!** 17 | The approval and merge process is almost fully automated 🧙 18 | Here's how it works: 19 | 1. You open a new pull request 20 | 2. Automated tests check the code 21 | 3. Maintainers review the code 22 | 4. Once approved, the PR is ready to merge. 23 | 24 | > 👉 **Omit the extended description** 25 | > Please remove the commit body before merging the pull request. 26 | > Instead, include the pull request number in the title to provide the full context 27 | > about the change. 28 | 29 | ☝️ Lastly, the title for the commit will come from the pull request title. So please provide a descriptive title that summarizes the changes in **50 characters or less using the imperative mood.** 30 | Happy coding! 🎉 31 | -------------------------------------------------------------------------------- /.github/workflows/update-release-notes.yaml: -------------------------------------------------------------------------------- 1 | name: Release notes 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - '**' 9 | 10 | jobs: 11 | update-notes: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Update release draft 15 | uses: release-drafter/release-drafter@v6 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/validate-branch.yaml: -------------------------------------------------------------------------------- 1 | name: Validations 2 | 3 | on: 4 | push: 5 | tags-ignore: 6 | - '**' 7 | branches: 8 | - master 9 | pull_request: 10 | types: 11 | - synchronize 12 | - opened 13 | 14 | jobs: 15 | security-checks: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | 23 | - name: Cache dependencies 24 | id: cache-dependencies 25 | uses: actions/cache@v4 26 | with: 27 | path: node_modules 28 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 29 | 30 | - name: Install dependencies 31 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 32 | run: npm ci 33 | 34 | - name: Check dependency vulnerabilities 35 | run: |- 36 | npm i -g npm-audit-resolver@3.0.0-7 37 | npx check-audit --omit dev 38 | 39 | validate: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: 18 46 | 47 | - name: Cache dependencies 48 | id: cache-dependencies 49 | uses: actions/cache@v4 50 | with: 51 | path: node_modules 52 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 53 | 54 | - name: Install dependencies 55 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 56 | run: npm ci 57 | 58 | - name: Check compilation errors 59 | run: npm run validate 60 | 61 | lint: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: actions/setup-node@v4 66 | with: 67 | node-version: 18 68 | 69 | - name: Cache dependencies 70 | id: cache-dependencies 71 | uses: actions/cache@v4 72 | with: 73 | path: node_modules 74 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 75 | 76 | - name: Install dependencies 77 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 78 | run: npm ci 79 | 80 | - name: Check coding standard violations 81 | run: npm run lint 82 | 83 | test: 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: actions/setup-node@v4 88 | with: 89 | node-version: 18 90 | 91 | - name: Cache dependencies 92 | id: cache-dependencies 93 | uses: actions/cache@v4 94 | with: 95 | path: node_modules 96 | key: node_modules-${{ hashFiles('**/package-lock.json') }} 97 | 98 | - name: Install dependencies 99 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 100 | run: npm ci 101 | 102 | - uses: paambaati/codeclimate-action@v9 103 | env: 104 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 105 | with: 106 | coverageCommand: npm test 107 | coverageLocations: 108 | ./coverage/lcov.info:lcov 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | coverage/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Croct.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Croct 4 | 5 |
6 | MD Lite 7 |
8 | A minimalist Markdown parser and render for basic formatting. 9 |

10 |

11 | Version 12 | Build 13 | 14 | 15 | Gzipped bundle size 16 |
17 |
18 | 📦 Releases 19 | · 20 | 🐞 Report Bug 21 | · 22 | ✨ Request Feature 23 |

24 | 25 | ## Introduction 26 | 27 | This library provides a fast and simple Markdown parser with zero dependencies. 28 | Perfect for those who need to handle basic Markdown syntax without the overhead of a full-featured Markdown parser. 29 | 30 | **Features** 31 | 32 | - 🪶 **Lightweight:** Zero dependencies, about 1.5 KB gzipped. 33 | - 🌐 **Cross-environment:** Works in Node.js and browsers. 34 | - ✍️ **Minimalist:** Supports only _italic_, **bold**, ~~strikethrough~~, `inline code`, [links](#), 🖼️ images, and ¶ paragraphs. 35 | - 🛠 **Flexible:** Render whatever you want, from HTML to JSX. 36 | 37 | ### Who is this library for? 38 | 39 | If you're working on a project that requires rendering Markdown for short texts like titles, subtitles, and descriptions, but you don't need a full-featured Markdown parser, this library is for you. 40 | 41 | ## Installation 42 | 43 | We recommend using [NPM](https://www.npmjs.com) to install the package: 44 | 45 | ```sh 46 | npm install @croct/md-lite 47 | ``` 48 | 49 | Alternatively, you can use [Yarn](https://yarnpkg.com): 50 | 51 | ```sh 52 | yarn add @croct/md-lite 53 | ``` 54 | 55 | ## Basic usage 56 | 57 | The following sections show how to parse and render Markdown using this library. 58 | 59 | ### Parsing Markdown 60 | 61 | To parse a Markdown string into an AST, use the `parse` function: 62 | 63 | ```ts 64 | import {parse} from '@croct/md-lite'; 65 | 66 | const markdown = '**Hello**, [World](https://example.com)'; 67 | 68 | const ast = parse(markdown); 69 | ``` 70 | 71 | The `parse` function returns a tree-like structure called an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) (AST). 72 | You can find the full AST definition [here](/src/ast.ts). 73 | 74 | ### Rendering Markdown 75 | 76 | To render an AST into whatever you want, use the `render` function. 77 | It accepts as input either a Markdown string or an AST: 78 | 79 | ```ts 80 | import {render} from '@croct/md-lite'; 81 | 82 | // Passing a string as input is equivalent to calling `parse` first 83 | const markdown = '**Hello**, [World](https://example.com)'; 84 | 85 | const html = render(markdown, { 86 | fragment: node => node.children.join(''), 87 | text: node => node.content, 88 | bold: node => `${node.children}`, 89 | italic: node => `${node.children}`, 90 | strike: node => `${node.children}`, 91 | code: node => `${node.content}`, 92 | link: node => `${node.children}`, 93 | image: node => `${node.alt}`, 94 | paragraph: node => `

${node.children.join('')}

`, 95 | }); 96 | ``` 97 | 98 | Here is an example of how to render the Markdown string above into JSX: 99 | 100 | ```tsx 101 | import {render} from '@croct/md-lite'; 102 | 103 | // Passing a string as input is equivalent to calling `parse` first 104 | const markdown = '**Hello**, [World](https://example.com)'; 105 | 106 | const jsx = render(markdown, { 107 | fragment: node => node.children, 108 | text: node => node.content, 109 | bold: node => {node.children}, 110 | italic: node => {node.children}, 111 | strike: node => {node.children}, 112 | code: node => {node.content}, 113 | link: node => {node.children}, 114 | image: node => {node.alt}, 115 | paragraph: node =>

{node.children}

, 116 | }); 117 | ``` 118 | 119 | #### Handling unsupported features 120 | 121 | In some cases, you might want to intentionally omit certain features from your 122 | rendered Markdown. For instance, if your platform doesn't support image rendering, 123 | ou can simply return the original source text instead of trying to display the image. 124 | 125 | ```ts 126 | import {render, unescape} from '@croct/md-lite'; 127 | 128 | render(markdown, { 129 | // ... other render functions 130 | image: node => unescape(node.source), 131 | }); 132 | ``` 133 | 134 | This code snippet will simply return the raw source code of the image node 135 | instead of trying to render it as an image. You can adapt this approach 136 | to handle any other unsupported feature by defining appropriate render 137 | functions and accessing the relevant data from the AST. 138 | 139 | ## Contributing 140 | 141 | Contributions to the package are always welcome! 142 | 143 | - Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/md-lite-js/issues). 144 | - For major changes, please [open an issue](https://github.com/croct-tech/md-lite-js/issues) first to discuss what you would like to change. 145 | - Please make sure to update tests as appropriate. 146 | 147 | ## Testing 148 | 149 | Before running the test suites, the development dependencies must be installed: 150 | 151 | ```sh 152 | npm install 153 | ``` 154 | 155 | Then, to run all tests: 156 | 157 | ```sh 158 | npm run test 159 | ``` 160 | 161 | Run the following command to check the code against the style guide: 162 | 163 | ```sh 164 | npm run lint 165 | ``` 166 | 167 | ## Building 168 | 169 | Before building the project, the dependencies must be installed: 170 | 171 | ```sh 172 | npm install 173 | ``` 174 | 175 | Then, to build the project: 176 | 177 | ```sh 178 | npm run build 179 | ``` 180 | 181 | 182 | ## License 183 | 184 | Copyright © 2015-2023 Croct Limited, All Rights Reserved. 185 | 186 | All information contained herein is, and remains the property of Croct Limited. The intellectual, design and technical concepts contained herein are proprietary to Croct Limited s and may be covered by U.S. and Foreign Patents, patents in process, and are protected by trade secret or copyright law. Dissemination of this information or reproduction of this material is strictly forbidden unless prior written permission is obtained from Croct Limited. 187 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts$': '@swc/jest', 4 | }, 5 | restoreMocks: true, 6 | resetMocks: true, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@croct/md-lite", 3 | "version": "0.0.0-dev", 4 | "description": "A minimalist Markdown parser and render for basic formatting.", 5 | "author": { 6 | "name": "croct", 7 | "email": "lib+md-lite@croct.com", 8 | "url": "https://github.com/croct-tech/md-lite-js" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "croct", 13 | "typescript", 14 | "markdown" 15 | ], 16 | "types": "index.d.ts", 17 | "main": "index.js", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/croct-tech/md-lite-js.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/croct-tech/md-lite-js/issues" 24 | }, 25 | "homepage": "https://github.com/croct-tech/md-lite-js", 26 | "scripts": { 27 | "lint": "eslint 'src/**/*.ts' 'test/**/*.ts'", 28 | "test": "jest -c jest.config.js --coverage", 29 | "validate": "tsc --noEmit", 30 | "build": "tsc -p tsconfig.build.json" 31 | }, 32 | "devDependencies": { 33 | "@croct/eslint-plugin": "^0.7.0", 34 | "@swc/jest": "^0.2.24", 35 | "@types/jest": "^29.0.0", 36 | "@typescript-eslint/parser": "^6.0.0", 37 | "eslint": "^8.22", 38 | "jest": "^29.0.0", 39 | "typescript": "^5.0.0" 40 | }, 41 | "files": [ 42 | "**/*.js", 43 | "**/*.ts" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>croct-tech/renovate-public-presets:js" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/ast.ts: -------------------------------------------------------------------------------- 1 | type MarkdownNodeMap = { 2 | text: { 3 | content: string, 4 | }, 5 | bold: { 6 | children: MarkdownNode, 7 | }, 8 | italic: { 9 | children: MarkdownNode, 10 | }, 11 | strike: { 12 | children: MarkdownNode, 13 | }, 14 | code: { 15 | content: string, 16 | }, 17 | link: { 18 | href: string, 19 | title?: string, 20 | children: MarkdownNode, 21 | }, 22 | image: { 23 | src: string, 24 | alt: string, 25 | }, 26 | paragraph: { 27 | children: MarkdownNode[], 28 | }, 29 | fragment: { 30 | children: MarkdownNode[], 31 | }, 32 | }; 33 | 34 | export type MarkdownNodeType = keyof MarkdownNodeMap; 35 | 36 | export type MarkdownNode = { 37 | [K in MarkdownNodeType]: MarkdownNodeMap[K] & { 38 | type: K, 39 | source: string, 40 | } 41 | }[T]; 42 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ast'; 2 | export * from './parsing'; 3 | export * from './rendering'; 4 | -------------------------------------------------------------------------------- /src/parsing.ts: -------------------------------------------------------------------------------- 1 | import {MarkdownNode} from './ast'; 2 | 3 | export function parse(markdown: string): MarkdownNode { 4 | return MarkdownParser.parse(markdown); 5 | } 6 | 7 | export function unescape(input: string): string { 8 | if (!input.includes('\\')) { 9 | // Optimization for cases where there are no escape sequences 10 | return input; 11 | } 12 | 13 | let text = ''; 14 | 15 | for (let index = 0; index < input.length; index++) { 16 | const char = input[index]; 17 | 18 | if (char === '\\' && index + 1 < input.length) { 19 | text += input[++index]; 20 | 21 | continue; 22 | } 23 | 24 | text += char; 25 | } 26 | 27 | return text; 28 | } 29 | 30 | class MismatchError extends Error { 31 | public constructor() { 32 | super('Mismatched token'); 33 | 34 | Object.setPrototypeOf(this, MismatchError.prototype); 35 | } 36 | } 37 | 38 | class MarkdownParser { 39 | private readonly chars: string[]; 40 | 41 | private index = 0; 42 | 43 | private static readonly NEWLINE = ['\r\n', '\r', '\n']; 44 | 45 | private static readonly NEW_PARAGRAPH = MarkdownParser.NEWLINE 46 | .flatMap(prefix => MarkdownParser.NEWLINE.map(suffix => prefix + suffix)); 47 | 48 | private constructor(input: string) { 49 | this.chars = [...input]; 50 | } 51 | 52 | public static parse(input: string): MarkdownNode { 53 | return new MarkdownParser(input).parseNext(); 54 | } 55 | 56 | private parseNext(end: string = ''): MarkdownNode { 57 | const root: MarkdownNode<'fragment'> = { 58 | type: 'fragment', 59 | children: [], 60 | source: '', 61 | }; 62 | 63 | const startIndex = this.index; 64 | 65 | let text = ''; 66 | 67 | let paragraphStartIndex = this.index; 68 | let textStartIndex = this.index; 69 | 70 | while (!this.done) { 71 | const escapedText = this.parseText(''); 72 | 73 | if (escapedText !== '') { 74 | text += escapedText; 75 | 76 | continue; 77 | } 78 | 79 | if (end !== '' && (this.matches(end) || this.matches(...MarkdownParser.NEWLINE))) { 80 | break; 81 | } 82 | 83 | if (this.matches(...MarkdownParser.NEW_PARAGRAPH)) { 84 | const paragraphEndIndex = this.index; 85 | 86 | while (MarkdownParser.NEWLINE.includes(this.current)) { 87 | this.advance(); 88 | } 89 | 90 | if (text !== '' || root.children.length > 0) { 91 | let paragraph: MarkdownNode = root.children[root.children.length - 1]; 92 | 93 | if (paragraph?.type !== 'paragraph') { 94 | paragraph = { 95 | type: 'paragraph', 96 | children: root.children, 97 | source: '', 98 | }; 99 | 100 | root.children = [paragraph]; 101 | } 102 | 103 | paragraph.source = this.getSlice(paragraphStartIndex, paragraphEndIndex); 104 | 105 | if (text !== '') { 106 | paragraph.children.push({ 107 | type: 'text', 108 | content: text, 109 | source: this.getSlice(textStartIndex, paragraphEndIndex), 110 | }); 111 | 112 | text = ''; 113 | } 114 | 115 | root.children.push({ 116 | type: 'paragraph', 117 | children: [], 118 | source: '', 119 | }); 120 | } 121 | 122 | paragraphStartIndex = this.index; 123 | textStartIndex = this.index; 124 | 125 | continue; 126 | } 127 | 128 | const nodeStartIndex = this.index; 129 | 130 | let node: MarkdownNode|null = null; 131 | 132 | try { 133 | node = this.parseCurrent(); 134 | } catch (error) { 135 | if (!(error instanceof MismatchError)) { 136 | /* istanbul ignore next */ 137 | throw error; 138 | } 139 | } 140 | 141 | if (node === null) { 142 | this.seek(nodeStartIndex); 143 | 144 | text += this.current; 145 | 146 | this.advance(); 147 | 148 | continue; 149 | } 150 | 151 | let parent = root.children[root.children.length - 1]; 152 | 153 | if (parent?.type !== 'paragraph') { 154 | parent = root; 155 | } 156 | 157 | if (text !== '') { 158 | parent.children.push({ 159 | type: 'text', 160 | content: text, 161 | source: this.getSlice(textStartIndex, nodeStartIndex), 162 | }); 163 | } 164 | 165 | text = ''; 166 | 167 | textStartIndex = this.index; 168 | 169 | parent.children.push(node); 170 | } 171 | 172 | if (text !== '') { 173 | let parent = root.children[root.children.length - 1]; 174 | 175 | if (parent?.type !== 'paragraph') { 176 | parent = root; 177 | } 178 | 179 | parent.children.push({ 180 | type: 'text', 181 | content: text, 182 | source: this.getSlice(textStartIndex, this.index), 183 | }); 184 | } 185 | 186 | const lastNode = root.children[root.children.length - 1]; 187 | 188 | if (lastNode?.type === 'paragraph') { 189 | if (lastNode.children.length === 0) { 190 | root.children.pop(); 191 | } else { 192 | lastNode.source = this.getSlice(paragraphStartIndex, this.index); 193 | } 194 | } 195 | 196 | if (root.children.length === 1) { 197 | return root.children[0]; 198 | } 199 | 200 | root.source = this.getSlice(startIndex, this.index); 201 | 202 | return root; 203 | } 204 | 205 | private parseCurrent(): MarkdownNode|null { 206 | const char = this.lookAhead(); 207 | const startIndex = this.index; 208 | 209 | switch (char) { 210 | case '*': 211 | case '_': { 212 | const delimiter = this.matches('**') ? '**' : char; 213 | 214 | this.advance(delimiter.length); 215 | 216 | const children = this.parseNext(delimiter); 217 | 218 | this.match(delimiter); 219 | 220 | return { 221 | type: delimiter.length === 1 ? 'italic' : 'bold', 222 | children: children, 223 | source: this.getSlice(startIndex, this.index), 224 | }; 225 | } 226 | 227 | case '~': { 228 | this.match('~~'); 229 | 230 | const children = this.parseNext('~~'); 231 | 232 | this.match('~~'); 233 | 234 | return { 235 | type: 'strike', 236 | children: children, 237 | source: this.getSlice(startIndex, this.index), 238 | }; 239 | } 240 | 241 | case '`': { 242 | if (this.matches('```')) { 243 | return null; 244 | } 245 | 246 | const delimiter = this.matches('``') ? '``' : '`'; 247 | 248 | this.match(delimiter); 249 | 250 | const content = this.parseText(delimiter).trim(); 251 | 252 | if (this.matches('```')) { 253 | return null; 254 | } 255 | 256 | this.match(delimiter); 257 | 258 | return { 259 | type: 'code', 260 | content: content, 261 | source: this.getSlice(startIndex, this.index), 262 | }; 263 | } 264 | 265 | case '!': { 266 | this.advance(); 267 | 268 | this.match('['); 269 | 270 | const alt = this.parseText(']'); 271 | 272 | this.match(']('); 273 | 274 | const src = this.parseText(')'); 275 | 276 | this.match(')'); 277 | 278 | return { 279 | type: 'image', 280 | src: src, 281 | alt: alt, 282 | source: this.getSlice(startIndex, this.index), 283 | }; 284 | } 285 | 286 | case '[': { 287 | this.advance(); 288 | 289 | const label = this.parseNext(']'); 290 | 291 | this.match(']('); 292 | 293 | const href = this.parseText(')', '"'); 294 | 295 | let title: string|undefined; 296 | 297 | if (this.matches('"')) { 298 | this.match('"'); 299 | 300 | title = this.parseText('"'); 301 | 302 | this.match('"'); 303 | } 304 | 305 | this.match(')'); 306 | 307 | return { 308 | type: 'link', 309 | href: href.trim(), 310 | ...(title !== undefined ? {title: title} : {}), 311 | children: label, 312 | source: this.getSlice(startIndex, this.index), 313 | }; 314 | } 315 | 316 | default: 317 | return null; 318 | } 319 | } 320 | 321 | private parseText(...end: string[]): string { 322 | let text = ''; 323 | 324 | while (!this.done) { 325 | if (this.current === '\\' && this.index + 1 < this.length) { 326 | this.advance(); 327 | 328 | text += this.current; 329 | 330 | this.advance(); 331 | 332 | continue; 333 | } 334 | 335 | if (end.some(token => (token === '' || this.matches(token))) || this.matches(...MarkdownParser.NEWLINE)) { 336 | break; 337 | } 338 | 339 | text += this.current; 340 | 341 | this.advance(); 342 | } 343 | 344 | return text; 345 | } 346 | 347 | private get done(): boolean { 348 | return this.index >= this.length; 349 | } 350 | 351 | private get length(): number { 352 | return this.chars.length; 353 | } 354 | 355 | private get current(): string { 356 | return this.chars[this.index]; 357 | } 358 | 359 | private advance(length: number = 1): void { 360 | this.index += length; 361 | } 362 | 363 | private seek(index: number): void { 364 | this.index = index; 365 | } 366 | 367 | private matches(...lookahead: string[]): boolean { 368 | return lookahead.some(substring => this.lookAhead(substring.length) === substring); 369 | } 370 | 371 | private match(...lookahead: string[]): void { 372 | for (const substring of lookahead) { 373 | if (this.lookAhead(substring.length) === substring) { 374 | this.advance(substring.length); 375 | 376 | return; 377 | } 378 | } 379 | 380 | throw new MismatchError(); 381 | } 382 | 383 | private lookAhead(length: number = 1): string { 384 | if (length === 1) { 385 | return this.current; 386 | } 387 | 388 | return this.getSlice(this.index, this.index + length); 389 | } 390 | 391 | private getSlice(start: number, end: number): string { 392 | return this.chars 393 | .slice(start, end) 394 | .join(''); 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/rendering.ts: -------------------------------------------------------------------------------- 1 | import {MarkdownNode, MarkdownNodeType} from './ast'; 2 | import {parse} from './parsing'; 3 | 4 | type VisitedMarkdownNodeMap = { 5 | text: { 6 | content: string, 7 | }, 8 | bold: { 9 | children: C, 10 | }, 11 | italic: { 12 | children: C, 13 | }, 14 | strike: { 15 | children: C, 16 | }, 17 | code: { 18 | content: string, 19 | }, 20 | link: { 21 | href: string, 22 | title?: string, 23 | children: C, 24 | }, 25 | image: { 26 | src: string, 27 | alt: string, 28 | }, 29 | paragraph: { 30 | children: C[], 31 | }, 32 | fragment: { 33 | children: C[], 34 | }, 35 | }; 36 | 37 | export type VisitedMarkdownNode = { 38 | [K in MarkdownNodeType]: { 39 | type: K, 40 | index: number, 41 | source: string, 42 | } & VisitedMarkdownNodeMap[K] 43 | }[T]; 44 | 45 | export interface MarkdownRenderer { 46 | text(node: VisitedMarkdownNode): T; 47 | bold(node: VisitedMarkdownNode): T; 48 | italic(node: VisitedMarkdownNode): T; 49 | strike(node: VisitedMarkdownNode): T; 50 | code(node: VisitedMarkdownNode): T; 51 | link(node: VisitedMarkdownNode): T; 52 | image(node: VisitedMarkdownNode): T; 53 | paragraph(node: VisitedMarkdownNode): T; 54 | fragment(node: VisitedMarkdownNode): T; 55 | } 56 | 57 | export function render(markdown: string|MarkdownNode, visitor: MarkdownRenderer): T { 58 | return visit(typeof markdown === 'string' ? parse(markdown) : markdown, visitor); 59 | } 60 | 61 | function visit(root: MarkdownNode, visitor: MarkdownRenderer): T { 62 | let index = 0; 63 | 64 | function visitNode(node: MarkdownNode): T { 65 | switch (node.type) { 66 | case 'text': 67 | return visitor.text({ 68 | ...node, 69 | index: index++, 70 | }); 71 | 72 | case 'bold': 73 | return visitor.bold({ 74 | type: node.type, 75 | children: visitNode(node.children), 76 | source: node.source, 77 | index: index++, 78 | }); 79 | 80 | case 'italic': 81 | return visitor.italic({ 82 | type: node.type, 83 | children: visitNode(node.children), 84 | index: index++, 85 | source: node.source, 86 | }); 87 | 88 | case 'strike': 89 | return visitor.strike({ 90 | type: node.type, 91 | children: visitNode(node.children), 92 | index: index++, 93 | source: node.source, 94 | }); 95 | 96 | case 'code': 97 | return visitor.code({ 98 | ...node, 99 | index: index++, 100 | }); 101 | 102 | case 'image': 103 | return visitor.image({ 104 | ...node, 105 | index: index++, 106 | }); 107 | 108 | case 'link': 109 | return visitor.link({ 110 | type: node.type, 111 | href: node.href, 112 | title: node.title, 113 | children: visitNode(node.children), 114 | index: index++, 115 | source: node.source, 116 | }); 117 | 118 | case 'paragraph': { 119 | return visitor.paragraph({ 120 | type: node.type, 121 | children: node.children.map(child => visitNode(child)), 122 | index: index++, 123 | source: node.source, 124 | }); 125 | } 126 | 127 | case 'fragment': 128 | return visitor.fragment({ 129 | type: node.type, 130 | children: node.children.map(child => visitNode(child)), 131 | index: index++, 132 | source: node.source, 133 | }); 134 | } 135 | } 136 | 137 | return visitNode(root); 138 | } 139 | -------------------------------------------------------------------------------- /test/parsing.test.ts: -------------------------------------------------------------------------------- 1 | import {parse, unescape} from '../src/parsing'; 2 | import {MarkdownNode} from '../src/ast'; 3 | 4 | describe('A Markdown parser function', () => { 5 | type ParsingScenario = { 6 | input: string, 7 | output: MarkdownNode, 8 | }; 9 | 10 | it.each(Object.entries({ 11 | paragraphs: { 12 | input: 'First paragraph.\n\nSecond paragraph.', 13 | output: { 14 | type: 'fragment', 15 | source: 'First paragraph.\n\nSecond paragraph.', 16 | children: [ 17 | { 18 | type: 'paragraph', 19 | source: 'First paragraph.', 20 | children: [ 21 | { 22 | type: 'text', 23 | source: 'First paragraph.', 24 | content: 'First paragraph.', 25 | }, 26 | ], 27 | }, 28 | { 29 | type: 'paragraph', 30 | source: 'Second paragraph.', 31 | children: [ 32 | { 33 | type: 'text', 34 | source: 'Second paragraph.', 35 | content: 'Second paragraph.', 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | }, 42 | 'paragraphs with different newlines': { 43 | input: 'First\n\nSecond\r\rThird\r\n\r\nFourth', 44 | output: { 45 | type: 'fragment', 46 | source: 'First\n\nSecond\r\rThird\r\n\r\nFourth', 47 | children: [ 48 | { 49 | type: 'paragraph', 50 | source: 'First', 51 | children: [ 52 | { 53 | type: 'text', 54 | source: 'First', 55 | content: 'First', 56 | }, 57 | ], 58 | }, 59 | { 60 | type: 'paragraph', 61 | source: 'Second', 62 | children: [ 63 | { 64 | type: 'text', 65 | source: 'Second', 66 | content: 'Second', 67 | }, 68 | ], 69 | }, 70 | { 71 | type: 'paragraph', 72 | source: 'Third', 73 | children: [ 74 | { 75 | type: 'text', 76 | source: 'Third', 77 | content: 'Third', 78 | }, 79 | ], 80 | }, 81 | { 82 | type: 'paragraph', 83 | source: 'Fourth', 84 | children: [ 85 | { 86 | type: 'text', 87 | source: 'Fourth', 88 | content: 'Fourth', 89 | }, 90 | ], 91 | }, 92 | ], 93 | }, 94 | }, 95 | 'paragraphs leading and trailing newlines': { 96 | input: '\n\n\r\nFirst paragraph.\n\n\r\nSecond paragraph.\n\n\r\n', 97 | output: { 98 | type: 'fragment', 99 | source: '\n\n\r\nFirst paragraph.\n\n\r\nSecond paragraph.\n\n\r\n', 100 | children: [ 101 | { 102 | type: 'paragraph', 103 | source: 'First paragraph.', 104 | children: [ 105 | { 106 | type: 'text', 107 | source: 'First paragraph.', 108 | content: 'First paragraph.', 109 | }, 110 | ], 111 | }, 112 | { 113 | type: 'paragraph', 114 | source: 'Second paragraph.', 115 | children: [ 116 | { 117 | type: 'text', 118 | source: 'Second paragraph.', 119 | content: 'Second paragraph.', 120 | }, 121 | ], 122 | }, 123 | ], 124 | }, 125 | }, 126 | 'empty paragraphs': { 127 | input: '\n\r\r\n', 128 | output: { 129 | type: 'fragment', 130 | source: '\n\r\r\n', 131 | children: [], 132 | }, 133 | }, 134 | 'paragraphs multiple newlines': { 135 | input: '\n\n\nFirst paragraph.\n\n\nSecond paragraph.\n\n\n\n', 136 | output: { 137 | type: 'fragment', 138 | source: '\n\n\nFirst paragraph.\n\n\nSecond paragraph.\n\n\n\n', 139 | children: [ 140 | { 141 | type: 'paragraph', 142 | source: 'First paragraph.', 143 | children: [ 144 | { 145 | type: 'text', 146 | source: 'First paragraph.', 147 | content: 'First paragraph.', 148 | }, 149 | ], 150 | }, 151 | { 152 | type: 'paragraph', 153 | source: 'Second paragraph.', 154 | children: [ 155 | { 156 | type: 'text', 157 | source: 'Second paragraph.', 158 | content: 'Second paragraph.', 159 | }, 160 | ], 161 | }, 162 | ], 163 | }, 164 | }, 165 | 'mixed paragraphs and text': { 166 | input: [ 167 | '**First**\n_paragraph_', 168 | '[Second paragraph](ex)', 169 | '![Third paragraph](ex)', 170 | 'Fourth paragraph', 171 | ].join('\n\n'), 172 | output: { 173 | type: 'fragment', 174 | source: [ 175 | '**First**\n_paragraph_', 176 | '[Second paragraph](ex)', 177 | '![Third paragraph](ex)', 178 | 'Fourth paragraph', 179 | ].join('\n\n'), 180 | children: [ 181 | { 182 | type: 'paragraph', 183 | source: '**First**\n_paragraph_', 184 | children: [ 185 | { 186 | type: 'bold', 187 | source: '**First**', 188 | children: { 189 | type: 'text', 190 | source: 'First', 191 | content: 'First', 192 | }, 193 | }, 194 | { 195 | type: 'text', 196 | source: '\n', 197 | content: '\n', 198 | }, 199 | { 200 | type: 'italic', 201 | source: '_paragraph_', 202 | children: { 203 | type: 'text', 204 | source: 'paragraph', 205 | content: 'paragraph', 206 | }, 207 | }, 208 | ], 209 | }, 210 | { 211 | type: 'paragraph', 212 | source: '[Second paragraph](ex)', 213 | children: [ 214 | { 215 | type: 'link', 216 | source: '[Second paragraph](ex)', 217 | href: 'ex', 218 | children: { 219 | type: 'text', 220 | source: 'Second paragraph', 221 | content: 'Second paragraph', 222 | }, 223 | }, 224 | ], 225 | }, 226 | { 227 | type: 'paragraph', 228 | source: '![Third paragraph](ex)', 229 | children: [ 230 | { 231 | type: 'image', 232 | source: '![Third paragraph](ex)', 233 | src: 'ex', 234 | alt: 'Third paragraph', 235 | }, 236 | ], 237 | }, 238 | { 239 | type: 'paragraph', 240 | source: 'Fourth paragraph', 241 | children: [ 242 | { 243 | type: 'text', 244 | source: 'Fourth paragraph', 245 | content: 'Fourth paragraph', 246 | }, 247 | ], 248 | }, 249 | ], 250 | }, 251 | }, 252 | text: { 253 | input: 'Hello, world!', 254 | output: { 255 | type: 'text', 256 | source: 'Hello, world!', 257 | content: 'Hello, world!', 258 | }, 259 | }, 260 | bold: { 261 | input: 'Hello, **world**!', 262 | output: { 263 | type: 'fragment', 264 | source: 'Hello, **world**!', 265 | children: [ 266 | { 267 | type: 'text', 268 | source: 'Hello, ', 269 | content: 'Hello, ', 270 | }, 271 | { 272 | type: 'bold', 273 | source: '**world**', 274 | children: { 275 | type: 'text', 276 | source: 'world', 277 | content: 'world', 278 | }, 279 | }, 280 | { 281 | type: 'text', 282 | source: '!', 283 | content: '!', 284 | }, 285 | ], 286 | }, 287 | }, 288 | 'bold with start delimited escaped': { 289 | input: 'Hello, \\**world**!', 290 | output: { 291 | type: 'fragment', 292 | source: 'Hello, \\**world**!', 293 | children: [ 294 | { 295 | type: 'text', 296 | source: 'Hello, \\*', 297 | content: 'Hello, *', 298 | }, 299 | { 300 | type: 'italic', 301 | source: '*world*', 302 | children: { 303 | type: 'text', 304 | source: 'world', 305 | content: 'world', 306 | }, 307 | }, 308 | { 309 | type: 'text', 310 | source: '*!', 311 | content: '*!', 312 | }, 313 | ], 314 | }, 315 | }, 316 | 'bold with end delimited escaped': { 317 | input: 'Hello, **world\\**!', 318 | output: { 319 | type: 'fragment', 320 | source: 'Hello, **world\\**!', 321 | children: [ 322 | { 323 | type: 'text', 324 | source: 'Hello, *', 325 | content: 'Hello, *', 326 | }, 327 | { 328 | type: 'italic', 329 | source: '*world\\**', 330 | children: { 331 | type: 'text', 332 | source: 'world\\*', 333 | content: 'world*', 334 | }, 335 | }, 336 | { 337 | type: 'text', 338 | source: '!', 339 | content: '!', 340 | }, 341 | ], 342 | }, 343 | }, 344 | 'bold with escaped asterisk': { 345 | input: 'Hello, **wor\\*\\*ld**!', 346 | output: { 347 | type: 'fragment', 348 | source: 'Hello, **wor\\*\\*ld**!', 349 | children: [ 350 | { 351 | type: 'text', 352 | source: 'Hello, ', 353 | content: 'Hello, ', 354 | }, 355 | { 356 | type: 'bold', 357 | source: '**wor\\*\\*ld**', 358 | children: { 359 | type: 'text', 360 | source: 'wor\\*\\*ld', 361 | content: 'wor**ld', 362 | }, 363 | }, 364 | { 365 | type: 'text', 366 | source: '!', 367 | content: '!', 368 | }, 369 | ], 370 | }, 371 | }, 372 | 'bold with newline': { 373 | input: 'Hello, **\nworld**!', 374 | output: { 375 | type: 'text', 376 | source: 'Hello, **\nworld**!', 377 | content: 'Hello, **\nworld**!', 378 | }, 379 | }, 380 | 'bold unbalanced': { 381 | input: 'Hello, **world***!', 382 | output: { 383 | type: 'fragment', 384 | source: 'Hello, **world***!', 385 | children: [ 386 | { 387 | type: 'text', 388 | source: 'Hello, ', 389 | content: 'Hello, ', 390 | }, 391 | { 392 | type: 'bold', 393 | source: '**world**', 394 | children: { 395 | type: 'text', 396 | source: 'world', 397 | content: 'world', 398 | }, 399 | }, 400 | { 401 | type: 'text', 402 | source: '*!', 403 | content: '*!', 404 | }, 405 | ], 406 | }, 407 | }, 408 | 'italic (underscore)': { 409 | input: 'Hello, _world_!', 410 | output: { 411 | type: 'fragment', 412 | source: 'Hello, _world_!', 413 | children: [ 414 | { 415 | type: 'text', 416 | source: 'Hello, ', 417 | content: 'Hello, ', 418 | }, 419 | { 420 | type: 'italic', 421 | source: '_world_', 422 | children: { 423 | type: 'text', 424 | source: 'world', 425 | content: 'world', 426 | }, 427 | }, 428 | { 429 | type: 'text', 430 | source: '!', 431 | content: '!', 432 | }, 433 | ], 434 | }, 435 | }, 436 | 'italic with start delimited escaped (underscore)': { 437 | input: 'Hello, \\_world_!', 438 | output: { 439 | type: 'text', 440 | source: 'Hello, \\_world_!', 441 | content: 'Hello, _world_!', 442 | }, 443 | }, 444 | 'italic with end delimited escaped (underscore)': { 445 | input: 'Hello, _world\\_!', 446 | output: { 447 | type: 'text', 448 | source: 'Hello, _world\\_!', 449 | content: 'Hello, _world_!', 450 | }, 451 | }, 452 | 'italic with escaped delimiter (underscore)': { 453 | input: 'Hello, _wor\\_ld_!', 454 | output: { 455 | type: 'fragment', 456 | source: 'Hello, _wor\\_ld_!', 457 | children: [ 458 | { 459 | type: 'text', 460 | source: 'Hello, ', 461 | content: 'Hello, ', 462 | }, 463 | { 464 | type: 'italic', 465 | source: '_wor\\_ld_', 466 | children: { 467 | type: 'text', 468 | source: 'wor\\_ld', 469 | content: 'wor_ld', 470 | }, 471 | }, 472 | { 473 | type: 'text', 474 | source: '!', 475 | content: '!', 476 | }, 477 | ], 478 | }, 479 | }, 480 | 'italic with newline (underscore)': { 481 | input: 'Hello, _\nworld_!', 482 | output: { 483 | type: 'text', 484 | source: 'Hello, _\nworld_!', 485 | content: 'Hello, _\nworld_!', 486 | }, 487 | }, 488 | 'italic (asterisk)': { 489 | input: 'Hello, *world*!', 490 | output: { 491 | type: 'fragment', 492 | source: 'Hello, *world*!', 493 | children: [ 494 | { 495 | type: 'text', 496 | source: 'Hello, ', 497 | content: 'Hello, ', 498 | }, 499 | { 500 | type: 'italic', 501 | source: '*world*', 502 | children: { 503 | type: 'text', 504 | source: 'world', 505 | content: 'world', 506 | }, 507 | }, 508 | { 509 | type: 'text', 510 | source: '!', 511 | content: '!', 512 | }, 513 | ], 514 | }, 515 | }, 516 | 'italic with start delimited escaped (asterisk)': { 517 | input: 'Hello, \\*world*!', 518 | output: { 519 | type: 'text', 520 | source: 'Hello, \\*world*!', 521 | content: 'Hello, *world*!', 522 | }, 523 | }, 524 | 'italic with end delimited escaped (asterisk)': { 525 | input: 'Hello, *world\\*!', 526 | output: { 527 | type: 'text', 528 | source: 'Hello, *world\\*!', 529 | content: 'Hello, *world*!', 530 | }, 531 | }, 532 | 'italic with escaped delimiter (asterisk)': { 533 | input: 'Hello, *wor\\*ld*!', 534 | output: { 535 | type: 'fragment', 536 | source: 'Hello, *wor\\*ld*!', 537 | children: [ 538 | { 539 | type: 'text', 540 | source: 'Hello, ', 541 | content: 'Hello, ', 542 | }, 543 | { 544 | type: 'italic', 545 | source: '*wor\\*ld*', 546 | children: { 547 | type: 'text', 548 | source: 'wor\\*ld', 549 | content: 'wor*ld', 550 | }, 551 | }, 552 | { 553 | type: 'text', 554 | source: '!', 555 | content: '!', 556 | }, 557 | ], 558 | }, 559 | }, 560 | 'italic with newline (asterisk)': { 561 | input: 'Hello, *\nworld*!', 562 | output: { 563 | type: 'text', 564 | source: 'Hello, *\nworld*!', 565 | content: 'Hello, *\nworld*!', 566 | }, 567 | }, 568 | 'bold and italic': { 569 | input: 'Hello, ***world***!', 570 | output: { 571 | type: 'fragment', 572 | source: 'Hello, ***world***!', 573 | children: [ 574 | { 575 | type: 'text', 576 | source: 'Hello, ', 577 | content: 'Hello, ', 578 | }, 579 | { 580 | type: 'bold', 581 | source: '***world***', 582 | children: { 583 | type: 'italic', 584 | source: '*world*', 585 | children: { 586 | type: 'text', 587 | source: 'world', 588 | content: 'world', 589 | }, 590 | }, 591 | }, 592 | { 593 | type: 'text', 594 | source: '!', 595 | content: '!', 596 | }, 597 | ], 598 | }, 599 | }, 600 | strike: { 601 | input: 'Hello, ~~world~~!', 602 | output: { 603 | type: 'fragment', 604 | source: 'Hello, ~~world~~!', 605 | children: [ 606 | { 607 | type: 'text', 608 | source: 'Hello, ', 609 | content: 'Hello, ', 610 | }, 611 | { 612 | type: 'strike', 613 | source: '~~world~~', 614 | children: { 615 | type: 'text', 616 | source: 'world', 617 | content: 'world', 618 | }, 619 | }, 620 | { 621 | type: 'text', 622 | source: '!', 623 | content: '!', 624 | }, 625 | ], 626 | }, 627 | }, 628 | 'strike with start delimited escaped': { 629 | input: 'Hello, \\~~world~~!', 630 | output: { 631 | type: 'text', 632 | source: 'Hello, \\~~world~~!', 633 | content: 'Hello, ~~world~~!', 634 | }, 635 | }, 636 | 'strike with end delimited escaped': { 637 | input: 'Hello, ~~world\\~~!', 638 | output: { 639 | type: 'text', 640 | source: 'Hello, ~~world\\~~!', 641 | content: 'Hello, ~~world~~!', 642 | }, 643 | }, 644 | 'strike with escaped asterisk': { 645 | input: 'Hello, ~~wor\\~\\~ld~~!', 646 | output: { 647 | type: 'fragment', 648 | source: 'Hello, ~~wor\\~\\~ld~~!', 649 | children: [ 650 | { 651 | type: 'text', 652 | source: 'Hello, ', 653 | content: 'Hello, ', 654 | }, 655 | { 656 | type: 'strike', 657 | source: '~~wor\\~\\~ld~~', 658 | children: { 659 | type: 'text', 660 | source: 'wor\\~\\~ld', 661 | content: 'wor~~ld', 662 | }, 663 | }, 664 | { 665 | type: 'text', 666 | source: '!', 667 | content: '!', 668 | }, 669 | ], 670 | }, 671 | }, 672 | 'strike with newline': { 673 | input: 'Hello, ~~\nworld~~!', 674 | output: { 675 | type: 'text', 676 | source: 'Hello, ~~\nworld~~!', 677 | content: 'Hello, ~~\nworld~~!', 678 | }, 679 | }, 680 | 'code (single backtick)': { 681 | input: 'Hello, `world`!', 682 | output: { 683 | type: 'fragment', 684 | source: 'Hello, `world`!', 685 | children: [ 686 | { 687 | type: 'text', 688 | source: 'Hello, ', 689 | content: 'Hello, ', 690 | }, 691 | { 692 | type: 'code', 693 | source: '`world`', 694 | content: 'world', 695 | }, 696 | { 697 | type: 'text', 698 | source: '!', 699 | content: '!', 700 | }, 701 | ], 702 | }, 703 | }, 704 | 'code with trailing and leading whitespace (single backtick)': { 705 | input: 'Hello, ` world `!', 706 | output: { 707 | type: 'fragment', 708 | source: 'Hello, ` world `!', 709 | children: [ 710 | { 711 | type: 'text', 712 | source: 'Hello, ', 713 | content: 'Hello, ', 714 | }, 715 | { 716 | type: 'code', 717 | source: '` world `', 718 | content: 'world', 719 | }, 720 | { 721 | type: 'text', 722 | source: '!', 723 | content: '!', 724 | }, 725 | ], 726 | }, 727 | }, 728 | 'code with start delimited escaped (single backtick)': { 729 | input: 'Hello, \\`world`!', 730 | output: { 731 | type: 'text', 732 | source: 'Hello, \\`world`!', 733 | content: 'Hello, `world`!', 734 | }, 735 | }, 736 | 'code with end delimited escaped (single backtick)': { 737 | input: 'Hello, `world\\`!', 738 | output: { 739 | type: 'text', 740 | source: 'Hello, `world\\`!', 741 | content: 'Hello, `world`!', 742 | }, 743 | }, 744 | 'code with escaped backtick (single backtick)': { 745 | input: 'Hello, `wor\\`ld`!', 746 | output: { 747 | type: 'fragment', 748 | source: 'Hello, `wor\\`ld`!', 749 | children: [ 750 | { 751 | type: 'text', 752 | source: 'Hello, ', 753 | content: 'Hello, ', 754 | }, 755 | { 756 | type: 'code', 757 | source: '`wor\\`ld`', 758 | content: 'wor`ld', 759 | }, 760 | { 761 | type: 'text', 762 | source: '!', 763 | content: '!', 764 | }, 765 | ], 766 | }, 767 | }, 768 | 'code (double backtick)': { 769 | input: 'Hello, ``world``!', 770 | output: { 771 | type: 'fragment', 772 | source: 'Hello, ``world``!', 773 | children: [ 774 | { 775 | type: 'text', 776 | source: 'Hello, ', 777 | content: 'Hello, ', 778 | }, 779 | { 780 | type: 'code', 781 | source: '``world``', 782 | content: 'world', 783 | }, 784 | { 785 | type: 'text', 786 | source: '!', 787 | content: '!', 788 | }, 789 | ], 790 | }, 791 | }, 792 | 'code with trailing and leading whitespace (double backtick)': { 793 | input: 'Hello, `` world ``!', 794 | output: { 795 | type: 'fragment', 796 | source: 'Hello, `` world ``!', 797 | children: [ 798 | { 799 | type: 'text', 800 | source: 'Hello, ', 801 | content: 'Hello, ', 802 | }, 803 | { 804 | type: 'code', 805 | source: '`` world ``', 806 | content: 'world', 807 | }, 808 | { 809 | type: 'text', 810 | source: '!', 811 | content: '!', 812 | }, 813 | ], 814 | }, 815 | }, 816 | 'code with start delimited escaped (double backtick)': { 817 | input: 'Hello, \\``world``!', 818 | output: { 819 | type: 'fragment', 820 | source: 'Hello, \\``world``!', 821 | children: [ 822 | { 823 | type: 'text', 824 | source: 'Hello, \\`', 825 | content: 'Hello, `', 826 | }, 827 | { 828 | type: 'code', 829 | source: '`world`', 830 | content: 'world', 831 | }, 832 | { 833 | type: 'text', 834 | source: '`!', 835 | content: '`!', 836 | }, 837 | ], 838 | }, 839 | }, 840 | 'code with end delimited escaped (double backtick)': { 841 | input: 'Hello, ``world\\``!', 842 | output: { 843 | type: 'fragment', 844 | source: 'Hello, ``world\\``!', 845 | children: [ 846 | { 847 | type: 'text', 848 | source: 'Hello, `', 849 | content: 'Hello, `', 850 | }, 851 | { 852 | type: 'code', 853 | source: '`world\\``', 854 | content: 'world`', 855 | }, 856 | { 857 | type: 'text', 858 | source: '!', 859 | content: '!', 860 | }, 861 | ], 862 | }, 863 | }, 864 | 'code with escaped backtick (double backtick)': { 865 | input: 'Hello, ``wor\\`ld``!', 866 | output: { 867 | type: 'fragment', 868 | source: 'Hello, ``wor\\`ld``!', 869 | children: [ 870 | { 871 | type: 'text', 872 | source: 'Hello, ', 873 | content: 'Hello, ', 874 | }, 875 | { 876 | type: 'code', 877 | source: '``wor\\`ld``', 878 | content: 'wor`ld', 879 | }, 880 | { 881 | type: 'text', 882 | source: '!', 883 | content: '!', 884 | }, 885 | ], 886 | }, 887 | }, 888 | 'code with unescaped backtick (double backtick)': { 889 | input: 'Hello, ``wor`ld``!', 890 | output: { 891 | type: 'fragment', 892 | source: 'Hello, ``wor`ld``!', 893 | children: [ 894 | { 895 | type: 'text', 896 | source: 'Hello, ', 897 | content: 'Hello, ', 898 | }, 899 | { 900 | type: 'code', 901 | source: '``wor`ld``', 902 | content: 'wor`ld', 903 | }, 904 | { 905 | type: 'text', 906 | source: '!', 907 | content: '!', 908 | }, 909 | ], 910 | }, 911 | }, 912 | 'code with newline': { 913 | input: 'Hello, `\nworld`!', 914 | output: { 915 | type: 'text', 916 | source: 'Hello, `\nworld`!', 917 | content: 'Hello, `\nworld`!', 918 | }, 919 | }, 920 | link: { 921 | input: 'Hello, [world](image.png)!', 922 | output: { 923 | type: 'fragment', 924 | source: 'Hello, [world](image.png)!', 925 | children: [ 926 | { 927 | type: 'text', 928 | source: 'Hello, ', 929 | content: 'Hello, ', 930 | }, 931 | { 932 | type: 'link', 933 | source: '[world](image.png)', 934 | href: 'image.png', 935 | children: { 936 | type: 'text', 937 | source: 'world', 938 | content: 'world', 939 | }, 940 | }, 941 | { 942 | type: 'text', 943 | source: '!', 944 | content: '!', 945 | }, 946 | ], 947 | }, 948 | }, 949 | 'link with spaces': { 950 | input: 'Hello, [world]( image.png )!', 951 | output: { 952 | type: 'fragment', 953 | source: 'Hello, [world]( image.png )!', 954 | children: [ 955 | { 956 | type: 'text', 957 | source: 'Hello, ', 958 | content: 'Hello, ', 959 | }, 960 | { 961 | type: 'link', 962 | source: '[world]( image.png )', 963 | href: 'image.png', 964 | children: { 965 | type: 'text', 966 | source: 'world', 967 | content: 'world', 968 | }, 969 | }, 970 | { 971 | type: 'text', 972 | source: '!', 973 | content: '!', 974 | }, 975 | ], 976 | }, 977 | }, 978 | 'fenced code (unsupported)': { 979 | input: 'Hello, ```world```!', 980 | output: { 981 | type: 'text', 982 | source: 'Hello, ```world```!', 983 | content: 'Hello, ```world```!', 984 | }, 985 | }, 986 | 'fenced code unbalanced (unsupported)': { 987 | input: 'Hello, ``world```!', 988 | output: { 989 | type: 'text', 990 | source: 'Hello, ``world```!', 991 | content: 'Hello, ``world```!', 992 | }, 993 | }, 994 | 'link with start delimiter escaped': { 995 | input: 'Hello, \\[world](image.png)!', 996 | output: { 997 | type: 'text', 998 | source: 'Hello, \\[world](image.png)!', 999 | content: 'Hello, [world](image.png)!', 1000 | }, 1001 | }, 1002 | 'link with escaped left bracket': { 1003 | input: 'Hello, [wor\\[ld](image.png)!', 1004 | output: { 1005 | type: 'fragment', 1006 | source: 'Hello, [wor\\[ld](image.png)!', 1007 | children: [ 1008 | { 1009 | type: 'text', 1010 | source: 'Hello, ', 1011 | content: 'Hello, ', 1012 | }, 1013 | { 1014 | type: 'link', 1015 | source: '[wor\\[ld](image.png)', 1016 | href: 'image.png', 1017 | children: { 1018 | type: 'text', 1019 | source: 'wor\\[ld', 1020 | content: 'wor[ld', 1021 | }, 1022 | }, 1023 | { 1024 | type: 'text', 1025 | source: '!', 1026 | content: '!', 1027 | }, 1028 | ], 1029 | }, 1030 | }, 1031 | 'link with escaped right bracket': { 1032 | input: 'Hello, [wor\\]ld](image.png)!', 1033 | output: { 1034 | type: 'fragment', 1035 | source: 'Hello, [wor\\]ld](image.png)!', 1036 | children: [ 1037 | { 1038 | type: 'text', 1039 | source: 'Hello, ', 1040 | content: 'Hello, ', 1041 | }, 1042 | { 1043 | type: 'link', 1044 | source: '[wor\\]ld](image.png)', 1045 | href: 'image.png', 1046 | children: { 1047 | type: 'text', 1048 | source: 'wor\\]ld', 1049 | content: 'wor]ld', 1050 | }, 1051 | }, 1052 | { 1053 | type: 'text', 1054 | source: '!', 1055 | content: '!', 1056 | }, 1057 | ], 1058 | }, 1059 | }, 1060 | 'link with escaped left parenthesis': { 1061 | input: 'Hello, [world](https://\\(example.com)!', 1062 | output: { 1063 | type: 'fragment', 1064 | source: 'Hello, [world](https://\\(example.com)!', 1065 | children: [ 1066 | { 1067 | type: 'text', 1068 | source: 'Hello, ', 1069 | content: 'Hello, ', 1070 | }, 1071 | { 1072 | type: 'link', 1073 | source: '[world](https://\\(example.com)', 1074 | href: 'https://(example.com', 1075 | children: { 1076 | type: 'text', 1077 | source: 'world', 1078 | content: 'world', 1079 | }, 1080 | }, 1081 | { 1082 | type: 'text', 1083 | source: '!', 1084 | content: '!', 1085 | }, 1086 | ], 1087 | }, 1088 | }, 1089 | 'link with escaped right parenthesis': { 1090 | input: 'Hello, [world](image.png\\))!', 1091 | output: { 1092 | type: 'fragment', 1093 | source: 'Hello, [world](image.png\\))!', 1094 | children: [ 1095 | { 1096 | type: 'text', 1097 | source: 'Hello, ', 1098 | content: 'Hello, ', 1099 | }, 1100 | { 1101 | type: 'link', 1102 | source: '[world](image.png\\))', 1103 | href: 'image.png)', 1104 | children: { 1105 | type: 'text', 1106 | source: 'world', 1107 | content: 'world', 1108 | }, 1109 | }, 1110 | { 1111 | type: 'text', 1112 | source: '!', 1113 | content: '!', 1114 | }, 1115 | ], 1116 | }, 1117 | }, 1118 | 'link with formatted text': { 1119 | input: 'Hello, [**world**](image.png)!', 1120 | output: { 1121 | type: 'fragment', 1122 | source: 'Hello, [**world**](image.png)!', 1123 | children: [ 1124 | { 1125 | type: 'text', 1126 | source: 'Hello, ', 1127 | content: 'Hello, ', 1128 | }, 1129 | { 1130 | type: 'link', 1131 | source: '[**world**](image.png)', 1132 | href: 'image.png', 1133 | children: { 1134 | type: 'bold', 1135 | source: '**world**', 1136 | children: { 1137 | type: 'text', 1138 | source: 'world', 1139 | content: 'world', 1140 | }, 1141 | }, 1142 | }, 1143 | { 1144 | type: 'text', 1145 | source: '!', 1146 | content: '!', 1147 | }, 1148 | ], 1149 | }, 1150 | }, 1151 | 'link with title': { 1152 | input: 'Hello, [world](image.png "The world")!', 1153 | output: { 1154 | type: 'fragment', 1155 | source: 'Hello, [world](image.png "The world")!', 1156 | children: [ 1157 | { 1158 | type: 'text', 1159 | source: 'Hello, ', 1160 | content: 'Hello, ', 1161 | }, 1162 | { 1163 | type: 'link', 1164 | source: '[world](image.png "The world")', 1165 | href: 'image.png', 1166 | title: 'The world', 1167 | children: { 1168 | type: 'text', 1169 | source: 'world', 1170 | content: 'world', 1171 | }, 1172 | }, 1173 | { 1174 | type: 'text', 1175 | source: '!', 1176 | content: '!', 1177 | }, 1178 | ], 1179 | }, 1180 | }, 1181 | 'link with title and escaped quote': { 1182 | input: 'Hello, [world](image.png "The \\"world\\"")!', 1183 | output: { 1184 | type: 'fragment', 1185 | source: 'Hello, [world](image.png "The \\"world\\"")!', 1186 | children: [ 1187 | { 1188 | type: 'text', 1189 | source: 'Hello, ', 1190 | content: 'Hello, ', 1191 | }, 1192 | { 1193 | type: 'link', 1194 | source: '[world](image.png "The \\"world\\"")', 1195 | href: 'image.png', 1196 | title: 'The "world"', 1197 | children: { 1198 | type: 'text', 1199 | source: 'world', 1200 | content: 'world', 1201 | }, 1202 | }, 1203 | { 1204 | type: 'text', 1205 | source: '!', 1206 | content: '!', 1207 | }, 1208 | ], 1209 | }, 1210 | }, 1211 | image: { 1212 | input: 'Hello, ![world](image.png)!', 1213 | output: { 1214 | type: 'fragment', 1215 | source: 'Hello, ![world](image.png)!', 1216 | children: [ 1217 | { 1218 | type: 'text', 1219 | source: 'Hello, ', 1220 | content: 'Hello, ', 1221 | }, 1222 | { 1223 | type: 'image', 1224 | source: '![world](image.png)', 1225 | src: 'image.png', 1226 | alt: 'world', 1227 | }, 1228 | { 1229 | type: 'text', 1230 | source: '!', 1231 | content: '!', 1232 | }, 1233 | ], 1234 | }, 1235 | }, 1236 | 'image with start delimiter escaped': { 1237 | input: 'Hello, \\![world](image.png)!', 1238 | output: { 1239 | type: 'fragment', 1240 | source: 'Hello, \\![world](image.png)!', 1241 | children: [ 1242 | { 1243 | type: 'text', 1244 | source: 'Hello, \\!', 1245 | content: 'Hello, !', 1246 | }, 1247 | { 1248 | type: 'link', 1249 | source: '[world](image.png)', 1250 | href: 'image.png', 1251 | children: { 1252 | type: 'text', 1253 | source: 'world', 1254 | content: 'world', 1255 | }, 1256 | }, 1257 | { 1258 | type: 'text', 1259 | source: '!', 1260 | content: '!', 1261 | }, 1262 | ], 1263 | }, 1264 | }, 1265 | 'image with escaped left bracket': { 1266 | input: 'Hello, ![wor\\[ld](image.png)!', 1267 | output: { 1268 | type: 'fragment', 1269 | source: 'Hello, ![wor\\[ld](image.png)!', 1270 | children: [ 1271 | { 1272 | type: 'text', 1273 | source: 'Hello, ', 1274 | content: 'Hello, ', 1275 | }, 1276 | { 1277 | type: 'image', 1278 | source: '![wor\\[ld](image.png)', 1279 | src: 'image.png', 1280 | alt: 'wor[ld', 1281 | }, 1282 | { 1283 | type: 'text', 1284 | source: '!', 1285 | content: '!', 1286 | }, 1287 | ], 1288 | }, 1289 | }, 1290 | 'image with escaped right bracket': { 1291 | input: 'Hello, ![wor\\]ld](image.png)!', 1292 | output: { 1293 | type: 'fragment', 1294 | source: 'Hello, ![wor\\]ld](image.png)!', 1295 | children: [ 1296 | { 1297 | type: 'text', 1298 | source: 'Hello, ', 1299 | content: 'Hello, ', 1300 | }, 1301 | { 1302 | type: 'image', 1303 | source: '![wor\\]ld](image.png)', 1304 | src: 'image.png', 1305 | alt: 'wor]ld', 1306 | }, 1307 | { 1308 | type: 'text', 1309 | source: '!', 1310 | content: '!', 1311 | }, 1312 | ], 1313 | }, 1314 | }, 1315 | 'image with escaped left parenthesis': { 1316 | input: 'Hello, ![world](https://\\(example.com)!', 1317 | output: { 1318 | type: 'fragment', 1319 | source: 'Hello, ![world](https://\\(example.com)!', 1320 | children: [ 1321 | { 1322 | type: 'text', 1323 | source: 'Hello, ', 1324 | content: 'Hello, ', 1325 | }, 1326 | { 1327 | type: 'image', 1328 | source: '![world](https://\\(example.com)', 1329 | src: 'https://(example.com', 1330 | alt: 'world', 1331 | }, 1332 | { 1333 | type: 'text', 1334 | source: '!', 1335 | content: '!', 1336 | }, 1337 | ], 1338 | }, 1339 | }, 1340 | 'image with escaped right parenthesis': { 1341 | input: 'Hello, ![world](image.png\\))!', 1342 | output: { 1343 | type: 'fragment', 1344 | source: 'Hello, ![world](image.png\\))!', 1345 | children: [ 1346 | { 1347 | type: 'text', 1348 | source: 'Hello, ', 1349 | content: 'Hello, ', 1350 | }, 1351 | { 1352 | type: 'image', 1353 | source: '![world](image.png\\))', 1354 | src: 'image.png)', 1355 | alt: 'world', 1356 | }, 1357 | { 1358 | type: 'text', 1359 | source: '!', 1360 | content: '!', 1361 | }, 1362 | ], 1363 | }, 1364 | }, 1365 | 'image with formatted text': { 1366 | input: 'Hello, ![**world**](image.png)!', 1367 | output: { 1368 | type: 'fragment', 1369 | source: 'Hello, ![**world**](image.png)!', 1370 | children: [ 1371 | { 1372 | type: 'text', 1373 | source: 'Hello, ', 1374 | content: 'Hello, ', 1375 | }, 1376 | { 1377 | type: 'image', 1378 | source: '![**world**](image.png)', 1379 | src: 'image.png', 1380 | alt: '**world**', 1381 | }, 1382 | { 1383 | type: 'text', 1384 | source: '!', 1385 | content: '!', 1386 | }, 1387 | ], 1388 | }, 1389 | }, 1390 | 'link with image': { 1391 | input: 'Hello, [![world](image.png)](https://example.com)!', 1392 | output: { 1393 | type: 'fragment', 1394 | source: 'Hello, [![world](image.png)](https://example.com)!', 1395 | children: [ 1396 | { 1397 | type: 'text', 1398 | source: 'Hello, ', 1399 | content: 'Hello, ', 1400 | }, 1401 | { 1402 | type: 'link', 1403 | source: '[![world](image.png)](https://example.com)', 1404 | href: 'https://example.com', 1405 | children: { 1406 | type: 'image', 1407 | source: '![world](image.png)', 1408 | src: 'image.png', 1409 | alt: 'world', 1410 | }, 1411 | }, 1412 | { 1413 | type: 'text', 1414 | source: '!', 1415 | content: '!', 1416 | }, 1417 | ], 1418 | }, 1419 | }, 1420 | 'mixed formatting': { 1421 | input: 'Hello, **_~~`[world](image.png)`~~_**!', 1422 | output: { 1423 | type: 'fragment', 1424 | source: 'Hello, **_~~`[world](image.png)`~~_**!', 1425 | children: [ 1426 | { 1427 | type: 'text', 1428 | source: 'Hello, ', 1429 | content: 'Hello, ', 1430 | }, 1431 | { 1432 | type: 'bold', 1433 | source: '**_~~`[world](image.png)`~~_**', 1434 | children: { 1435 | type: 'italic', 1436 | source: '_~~`[world](image.png)`~~_', 1437 | children: { 1438 | type: 'strike', 1439 | source: '~~`[world](image.png)`~~', 1440 | children: { 1441 | type: 'code', 1442 | source: '`[world](image.png)`', 1443 | content: '[world](image.png)', 1444 | }, 1445 | }, 1446 | }, 1447 | }, 1448 | { 1449 | type: 'text', 1450 | source: '!', 1451 | content: '!', 1452 | }, 1453 | ], 1454 | }, 1455 | }, 1456 | }))('should parse %s', (_, {input, output}) => { 1457 | expect(parse(input)).toEqual(output); 1458 | }); 1459 | 1460 | it.each([ 1461 | [ 1462 | 'Hello, \\*world\\*!', 1463 | 'Hello, *world*!', 1464 | ], 1465 | [ 1466 | '\\A\\B\\C', 1467 | 'ABC', 1468 | ], 1469 | [ 1470 | '\\\\\\', 1471 | '\\\\', 1472 | ], 1473 | [ 1474 | 'ABC\\', 1475 | 'ABC\\', 1476 | ], 1477 | [ 1478 | 'ABC\\D', 1479 | 'ABCD', 1480 | ], 1481 | [ 1482 | 'Not escaped', 1483 | 'Not escaped', 1484 | ], 1485 | ])('should unescape %s to %s', (input, output) => { 1486 | expect(unescape(input)).toEqual(output); 1487 | }); 1488 | }); 1489 | -------------------------------------------------------------------------------- /test/rendering.test.ts: -------------------------------------------------------------------------------- 1 | import {MarkdownNode} from '../src/ast'; 2 | import {MarkdownRenderer, render, VisitedMarkdownNode} from '../src'; 3 | 4 | describe('A Markdown render function', () => { 5 | class TestRenderer implements MarkdownRenderer { 6 | public fragment(node: VisitedMarkdownNode): MarkdownNode { 7 | return { 8 | type: 'fragment', 9 | source: node.source, 10 | children: node.children, 11 | }; 12 | } 13 | 14 | public text(node: VisitedMarkdownNode): MarkdownNode { 15 | return { 16 | type: 'text', 17 | source: node.source, 18 | content: node.content, 19 | }; 20 | } 21 | 22 | public bold(node: VisitedMarkdownNode): MarkdownNode { 23 | return { 24 | type: 'bold', 25 | source: node.source, 26 | children: node.children, 27 | }; 28 | } 29 | 30 | public italic(node: VisitedMarkdownNode): MarkdownNode { 31 | return { 32 | type: 'italic', 33 | source: node.source, 34 | children: node.children, 35 | }; 36 | } 37 | 38 | public strike(node: VisitedMarkdownNode): MarkdownNode { 39 | return { 40 | type: 'strike', 41 | source: node.source, 42 | children: node.children, 43 | }; 44 | } 45 | 46 | public code(node: VisitedMarkdownNode): MarkdownNode { 47 | return { 48 | type: 'code', 49 | source: node.source, 50 | content: node.content, 51 | }; 52 | } 53 | 54 | public image(node: VisitedMarkdownNode): MarkdownNode { 55 | return { 56 | type: 'image', 57 | source: node.source, 58 | alt: node.alt, 59 | src: node.src, 60 | }; 61 | } 62 | 63 | public link(node: VisitedMarkdownNode): MarkdownNode { 64 | return { 65 | type: 'link', 66 | source: node.source, 67 | href: node.href, 68 | title: node.title, 69 | children: node.children, 70 | }; 71 | } 72 | 73 | public paragraph(node: VisitedMarkdownNode): MarkdownNode { 74 | return { 75 | type: 'paragraph', 76 | source: node.source, 77 | children: node.children, 78 | }; 79 | } 80 | } 81 | 82 | const markdown = [ 83 | '**Bold**', 84 | '*Italic*', 85 | '***Bold and italic***', 86 | '~~Strike~~', 87 | '`Code`', 88 | '![Image](https://example.com/image.png)', 89 | '[Link](https://example.com)', 90 | '[Link with title](https://example.com "Link title")', 91 | ].join('\n\n'); 92 | 93 | const tree: MarkdownNode = { 94 | type: 'fragment', 95 | source: markdown, 96 | children: [ 97 | { 98 | type: 'paragraph', 99 | source: '**Bold**', 100 | children: [ 101 | { 102 | type: 'bold', 103 | source: '**Bold**', 104 | children: { 105 | type: 'text', 106 | source: 'Bold', 107 | content: 'Bold', 108 | }, 109 | }, 110 | ], 111 | }, 112 | { 113 | type: 'paragraph', 114 | source: '*Italic*', 115 | children: [ 116 | { 117 | type: 'italic', 118 | source: '*Italic*', 119 | children: { 120 | source: 'Italic', 121 | type: 'text', 122 | content: 'Italic', 123 | }, 124 | }, 125 | ], 126 | }, 127 | { 128 | type: 'paragraph', 129 | source: '***Bold and italic***', 130 | children: [ 131 | { 132 | type: 'bold', 133 | source: '***Bold and italic***', 134 | children: { 135 | type: 'italic', 136 | source: '*Bold and italic*', 137 | children: { 138 | type: 'text', 139 | source: 'Bold and italic', 140 | content: 'Bold and italic', 141 | }, 142 | }, 143 | }, 144 | ], 145 | }, 146 | { 147 | type: 'paragraph', 148 | source: '~~Strike~~', 149 | children: [ 150 | { 151 | type: 'strike', 152 | source: '~~Strike~~', 153 | children: { 154 | type: 'text', 155 | source: 'Strike', 156 | content: 'Strike', 157 | }, 158 | }, 159 | ], 160 | }, 161 | { 162 | type: 'paragraph', 163 | source: '`Code`', 164 | children: [ 165 | { 166 | type: 'code', 167 | source: '`Code`', 168 | content: 'Code', 169 | }, 170 | ], 171 | }, 172 | { 173 | type: 'paragraph', 174 | source: '![Image](https://example.com/image.png)', 175 | children: [ 176 | { 177 | type: 'image', 178 | source: '![Image](https://example.com/image.png)', 179 | src: 'https://example.com/image.png', 180 | alt: 'Image', 181 | }, 182 | ], 183 | }, 184 | { 185 | type: 'paragraph', 186 | source: '[Link](https://example.com)', 187 | children: [ 188 | { 189 | type: 'link', 190 | source: '[Link](https://example.com)', 191 | href: 'https://example.com', 192 | children: { 193 | type: 'text', 194 | source: 'Link', 195 | content: 'Link', 196 | }, 197 | }, 198 | ], 199 | }, 200 | { 201 | type: 'paragraph', 202 | source: '[Link with title](https://example.com "Link title")', 203 | children: [ 204 | { 205 | type: 'link', 206 | source: '[Link with title](https://example.com "Link title")', 207 | href: 'https://example.com', 208 | title: 'Link title', 209 | children: { 210 | type: 'text', 211 | source: 'Link with title', 212 | content: 'Link with title', 213 | }, 214 | }, 215 | ], 216 | }, 217 | ], 218 | }; 219 | 220 | it('should render a Markdown tree', () => { 221 | expect(render(tree, new TestRenderer())).toEqual(tree); 222 | }); 223 | 224 | it('should parse and render a Markdown string', () => { 225 | expect(render(markdown, new TestRenderer())).toEqual(tree); 226 | }); 227 | 228 | it('should assign a global index to each node', () => { 229 | const input = '**Bold**\n*Italic*\n***Bold and italic***\n'; 230 | 231 | const result = render(input, { 232 | fragment: node => node, 233 | text: node => node, 234 | bold: node => node, 235 | italic: node => node, 236 | strike: node => node, 237 | code: node => node, 238 | image: node => node, 239 | link: node => node, 240 | paragraph: node => node, 241 | }); 242 | 243 | expect(result).toEqual({ 244 | index: 10, 245 | type: 'fragment', 246 | source: '**Bold**\n*Italic*\n***Bold and italic***\n', 247 | children: [ 248 | { 249 | index: 1, 250 | type: 'bold', 251 | source: '**Bold**', 252 | children: { 253 | index: 0, 254 | type: 'text', 255 | content: 'Bold', 256 | source: 'Bold', 257 | }, 258 | }, 259 | { 260 | index: 2, 261 | type: 'text', 262 | content: '\n', 263 | source: '\n', 264 | }, 265 | { 266 | index: 4, 267 | type: 'italic', 268 | source: '*Italic*', 269 | children: { 270 | index: 3, 271 | type: 'text', 272 | content: 'Italic', 273 | source: 'Italic', 274 | }, 275 | }, 276 | { 277 | index: 5, 278 | type: 'text', 279 | content: '\n', 280 | source: '\n', 281 | }, 282 | { 283 | index: 8, 284 | type: 'bold', 285 | source: '***Bold and italic***', 286 | children: { 287 | index: 7, 288 | type: 'italic', 289 | source: '*Bold and italic*', 290 | children: { 291 | index: 6, 292 | type: 'text', 293 | content: 'Bold and italic', 294 | source: 'Bold and italic', 295 | }, 296 | }, 297 | }, 298 | { 299 | index: 9, 300 | type: 'text', 301 | content: '\n', 302 | source: '\n', 303 | }, 304 | ], 305 | }); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "rootDir": "src", 6 | "outDir": "build" 7 | }, 8 | "exclude": [ 9 | "node_modules", 10 | "build", 11 | "**/*.test-d.ts", 12 | "**/*.test.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2020", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "resolveJsonModule": true, 8 | "noImplicitAny": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitReturns": true, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "strictNullChecks": true, 15 | "strict": true, 16 | "removeComments": false, 17 | "noEmit": true, 18 | "downlevelIteration": true, 19 | "declaration": true, 20 | }, 21 | "include": [ 22 | "src/**/*.ts", 23 | "test/**/*.ts" 24 | ], 25 | "exclude": ["node_modules", "build"] 26 | } 27 | --------------------------------------------------------------------------------