├── .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 |
4 |
5 |
6 | MD Lite
7 |
8 | A minimalist Markdown parser and render for basic formatting.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
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 => `
`,
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 =>
,
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 | '',
170 | 'Fourth paragraph',
171 | ].join('\n\n'),
172 | output: {
173 | type: 'fragment',
174 | source: [
175 | '**First**\n_paragraph_',
176 | '[Second paragraph](ex)',
177 | '',
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: '',
229 | children: [
230 | {
231 | type: 'image',
232 | source: '',
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, !',
1213 | output: {
1214 | type: 'fragment',
1215 | source: 'Hello, !',
1216 | children: [
1217 | {
1218 | type: 'text',
1219 | source: 'Hello, ',
1220 | content: 'Hello, ',
1221 | },
1222 | {
1223 | type: 'image',
1224 | source: '',
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, \\!',
1238 | output: {
1239 | type: 'fragment',
1240 | source: 'Hello, \\!',
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, !',
1267 | output: {
1268 | type: 'fragment',
1269 | source: 'Hello, !',
1270 | children: [
1271 | {
1272 | type: 'text',
1273 | source: 'Hello, ',
1274 | content: 'Hello, ',
1275 | },
1276 | {
1277 | type: 'image',
1278 | source: '',
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, !',
1317 | output: {
1318 | type: 'fragment',
1319 | source: 'Hello, !',
1320 | children: [
1321 | {
1322 | type: 'text',
1323 | source: 'Hello, ',
1324 | content: 'Hello, ',
1325 | },
1326 | {
1327 | type: 'image',
1328 | source: '',
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, )!',
1342 | output: {
1343 | type: 'fragment',
1344 | source: 'Hello, )!',
1345 | children: [
1346 | {
1347 | type: 'text',
1348 | source: 'Hello, ',
1349 | content: 'Hello, ',
1350 | },
1351 | {
1352 | type: 'image',
1353 | source: ')',
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, !',
1367 | output: {
1368 | type: 'fragment',
1369 | source: 'Hello, !',
1370 | children: [
1371 | {
1372 | type: 'text',
1373 | source: 'Hello, ',
1374 | content: 'Hello, ',
1375 | },
1376 | {
1377 | type: 'image',
1378 | source: '',
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, [](https://example.com)!',
1392 | output: {
1393 | type: 'fragment',
1394 | source: 'Hello, [](https://example.com)!',
1395 | children: [
1396 | {
1397 | type: 'text',
1398 | source: 'Hello, ',
1399 | content: 'Hello, ',
1400 | },
1401 | {
1402 | type: 'link',
1403 | source: '[](https://example.com)',
1404 | href: 'https://example.com',
1405 | children: {
1406 | type: 'image',
1407 | source: '',
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 | '',
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: '',
175 | children: [
176 | {
177 | type: 'image',
178 | source: '',
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 |
--------------------------------------------------------------------------------