diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 38bee7c..95f0480 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,5 +1,5 @@
# Default owners
-* @croct-tech/js
+* @croct-tech/javascript-developer
# GitHub configurations
/.github/ @croct-tech/infra
diff --git a/.github/workflows/check-commit-style.yml b/.github/workflows/check-commit-style.yml
new file mode 100644
index 0000000..a5f471c
--- /dev/null
+++ b/.github/workflows/check-commit-style.yml
@@ -0,0 +1,23 @@
+name: Commit style
+
+on:
+ pull_request:
+ types:
+ - opened
+ - edited
+ - reopened
+ - synchronize
+ push:
+ branches:
+ - master
+ - 'dev/*'
+
+jobs:
+ check-commit-style:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check
+ uses: mristin/opinionated-commit-message@v3.0.0
+ with:
+ allow-one-liners: 'true'
+ skip-body-check: 'true'
diff --git a/.github/workflows/deploy-published-releases.yaml b/.github/workflows/deploy-release.yaml
similarity index 65%
rename from .github/workflows/deploy-published-releases.yaml
rename to .github/workflows/deploy-release.yaml
index cf5745f..b7cc211 100644
--- a/.github/workflows/deploy-published-releases.yaml
+++ b/.github/workflows/deploy-release.yaml
@@ -1,4 +1,4 @@
-name: Release
+name: Release deploy
on:
release:
@@ -6,18 +6,20 @@ on:
- published
jobs:
- deploy:
- runs-on: ubuntu-latest
+ deploy-release:
+ runs-on: ubuntu-18.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v2
with:
- node-version: 18
- registry-url: 'https://npm.pkg.github.com'
+ node-version: 16
+ registry-url: 'https://registry.npmjs.org'
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Cache dependencies
id: cache-dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v2
with:
path: node_modules
key: node_modules-${{ hashFiles('**/package-lock.json') }}
@@ -25,7 +27,6 @@ jobs:
- name: Install dependencies
if: steps.cache-dependencies.outputs.cache-hit != 'true'
run: |-
- echo "//npm.pkg.github.com/:_authToken=${{ secrets.GH_PACKAGES_TOKEN }}" >> ~/.npmrc
npm ci
rm -rf ~/.npmrc
@@ -43,14 +44,10 @@ jobs:
if: ${{ github.event.release.prerelease }}
run: |-
cd build
- npm publish --tag next
- env:
- NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ npm publish --access public --tag next
- name: Publish release to NPM
if: ${{ !github.event.release.prerelease }}
run: |-
cd build
- npm publish
- env:
- NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ npm publish --access public
diff --git a/.github/workflows/release-drafter.yaml b/.github/workflows/release-drafter.yaml
deleted file mode 100644
index 9af0eca..0000000
--- a/.github/workflows/release-drafter.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: Release Drafter
-
-on:
- push:
- branches:
- - master
- tags-ignore:
- - '**'
-
-jobs:
- release-draft:
- runs-on: ubuntu-latest
- steps:
- - name: Update release draft
- uses: release-drafter/release-drafter@v5
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/send-guidelines.yaml b/.github/workflows/send-guidelines.yaml
index 919c5f6..09af14a 100644
--- a/.github/workflows/send-guidelines.yaml
+++ b/.github/workflows/send-guidelines.yaml
@@ -27,4 +27,4 @@ jobs:
> about the change.
βοΈ 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.**
- Happy coding! π
\ No newline at end of file
+ Happy coding! π
diff --git a/.github/workflows/update-release-notes.yaml b/.github/workflows/update-release-notes.yaml
index 79a7eb5..57667b6 100644
--- a/.github/workflows/update-release-notes.yaml
+++ b/.github/workflows/update-release-notes.yaml
@@ -14,4 +14,4 @@ jobs:
- name: Update release draft
uses: release-drafter/release-drafter@v5
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/branch-validations.yaml b/.github/workflows/validate-branch.yaml
similarity index 52%
rename from .github/workflows/branch-validations.yaml
rename to .github/workflows/validate-branch.yaml
index 577586a..2fdf777 100644
--- a/.github/workflows/branch-validations.yaml
+++ b/.github/workflows/validate-branch.yaml
@@ -11,18 +11,22 @@ on:
- synchronize
- opened
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
jobs:
- security-checks:
- runs-on: ubuntu-latest
+ check-vulnerabilities:
+ runs-on: ubuntu-18.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v2
with:
- node-version: 18
+ node-version: 16
- name: Cache dependencies
id: cache-dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v2
with:
path: node_modules
key: node_modules-${{ hashFiles('**/package-lock.json') }}
@@ -34,19 +38,19 @@ jobs:
- name: Check dependency vulnerabilities
run: |-
npm i -g npm-audit-resolver@3.0.0-7
- npx check-audit --omit dev
+ npx check-audit
- validate:
- runs-on: ubuntu-latest
+ validate-code:
+ runs-on: ubuntu-18.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v2
with:
- node-version: 18
+ node-version: 16
- name: Cache dependencies
id: cache-dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v2
with:
path: node_modules
key: node_modules-${{ hashFiles('**/package-lock.json') }}
@@ -58,17 +62,17 @@ jobs:
- name: Check compilation errors
run: npm run validate
- lint:
- runs-on: ubuntu-latest
+ check-code-style:
+ runs-on: ubuntu-18.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v2
with:
- node-version: 18
+ node-version: 16
- name: Cache dependencies
id: cache-dependencies
- uses: actions/cache@v3
+ uses: actions/cache@v2
with:
path: node_modules
key: node_modules-${{ hashFiles('**/package-lock.json') }}
@@ -79,30 +83,3 @@ jobs:
- name: Check coding standard violations
run: npm run lint
-
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 18
-
- - name: Cache dependencies
- id: cache-dependencies
- uses: actions/cache@v3
- with:
- path: node_modules
- key: node_modules-${{ hashFiles('**/package-lock.json') }}
-
- - name: Install dependencies
- if: steps.cache-dependencies.outputs.cache-hit != 'true'
- run: npm ci
-
- - uses: paambaati/codeclimate-action@v5
- env:
- CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
- with:
- coverageCommand: npm test
- coverageLocations:
- ./coverage/lcov.info:lcov
diff --git a/README.md b/README.md
index 48b3563..e5fabd7 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
- TypeScript Project Title
+ MD Lite
- A brief description about the project.
+ A minimalist Markdown parser and render for basic formatting.
@@ -13,66 +13,109 @@
- π¦ Releases
+ π¦ Releases
Β·
- π Report Bug
+ π Report Bug
Β·
- β¨ Request Feature
+ β¨ Request Feature
+## Introduction
-## Introduction π
+This library provides a fast and simple Markdown parser with zero dependencies.
+Perfect for those who need to handle basic Markdown syntax like **bold**, *italic*, and [links](#) without the overhead of a full-featured Markdown parser.
-This library provides a minimalistic, lightning-fast Markdown parser with zero dependencies. Perfect for those who need to handle basic markdown syntax like **bold**, *italic*, and [links](#) without the bloat of more extensive libraries.
+**Features**
+- πͺΆ **Lightweight:** Zero dependencies and less than 2KB gzipped.
+- π **Cross-environment:** Works in Node.js and browsers.
+- βοΈ **Minimalist:** Supports _italic_, **bold**, ~~strikethrough~~, `inline code`, [links](https://croct.com), , and ΒΆ paragraphs.
+- π **Flexible:** Render whatever you want, from HTML to JSX.
-#### Features:
-- π« **Zero dependencies**: Designed with simplicity in mind.
-- β‘ **Fast derformance**: Optimized for speed without compromising on functionality.
-- π **Basic syntax support**: Handles **bold**, *italic*, and [links](#).
+### Who is this library for?
+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.
-#### Contribution:
-Feel free to contribute and enhance its capabilities while keeping the core principle of simplicity and speed intact.
-
----
-
-Spread the word and star β if you find it useful!
-
-# Instructions
-Follow the steps below to create a new repository:
-
-1. Customize the repository
- 1. Click on the _Use this template_ button at the top of this page
- 2. Clone the repository locally
- 3. Update the `README.md` and `package.json` with the new package information
-2. Setup Code Climate
- 1. Add the project to [Croct's code climate organization](https://codeclimate.com/accounts/5e714648faaa9c00fb000081/dashboard)
- 2. Go to **Repo Settings > Test coverage** and copy the "_TEST REPORTER ID_"
- 3. Go to **Repo Settings > Badges** and copy the maintainability and coverage badges to the `README.md`
- 4. On the Github repository page, go to **Settings > Secrets** and add a secret with name `CC_TEST_REPORTER_ID` and the ID from the previous step as value.
-
## Installation
We recommend using [NPM](https://www.npmjs.com) to install the package:
```sh
-npm install @croct/project-ts
+npm install @croct/md-lite
+```
+
+Alternatively, you can use [Yarn](https://yarnpkg.com):
+
+```sh
+yarn add @croct/md-lite
```
## Basic usage
-```typescript
-import {Example} from '@croct/project-ts';
+After installation, you can import and use the `parse` and `render` functions in your project.
+
+Here's how you can get started:
+
+### Parsing Markdown
+
+To parse a Markdown string into an AST, use the `parse` function:
+
+```ts
+import {parse} from '@croct/md-lite';
+
+const markdown = '**Hello**, [World](https://example.com)';
+
+const ast = parse(markdown);
+```
+
+### Rendering Markdown
+
+To render an AST into whatever you want, use the `render` function.
+
+You can pass an AST generated by the `parse` function or a string directly to the `render` function:
+
+```ts
+import {render} from '@croct/md-lite';
+
+const markdown = '**Hello**, [World](https://example.com)';
+
+const html = render(ast, {
+ fragment: node => node.children.join(''),
+ text: node => node.content,
+ bold: node => `${node.children}`,
+ italic: node => `${node.children}`,
+ strike: node => `${node.children}`,
+ code: node => `${node.content}
`,
+ image: node => ``,
+ link: node => `${node.children}`,
+ paragraph: node => `${node.children.join('')}
`,
+});
+```
+
+You can also render JSX as demonstrated below:
+
+```tsx
+import {render} from '@croct/md-lite';
+
+const markdown = '**Hello**, [World](https://example.com)';
-const example = new Example();
-example.displayBasicUsage();
+const jsx = render(ast, {
+ fragment: node => node.children,
+ text: node => node.content,
+ bold: node => {node.children},
+ italic: node => {node.children},
+ strike: node => {node.children},
+ code: node => {node.content}
,
+ image: node => ,
+ link: node => {node.children},
+ paragraph: node => {node.children}
,
+});
```
## Contributing
Contributions to the package are always welcome!
-- Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/project-ts/issues).
-- For major changes, please [open an issue](https://github.com/croct-tech/project-ts/issues) first to discuss what you would like to change.
+- Report any bugs or issues on the [issue tracker](https://github.com/croct-tech/md-lite-js/issues).
+- 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.
- Please make sure to update tests as appropriate.
## Testing
@@ -103,20 +146,15 @@ Before building the project, the dependencies must be installed:
npm install
```
-Then, to build the CommonJS module:
+Then, to build the project:
```sh
-npm run rollup
+npm run build
```
-The following command bundles a minified IIFE module for browsers:
-
-```
-npm run rollup-min
-```
## License
-Copyright Β© 2015-2022 Croct Limited, All Rights Reserved.
+Copyright Β© 2015-2023 Croct Limited, All Rights Reserved.
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.
diff --git a/package.json b/package.json
index 0417a54..8588b2e 100644
--- a/package.json
+++ b/package.json
@@ -1,43 +1,37 @@
{
- "name": "@croct/ts-project",
+ "name": "@croct/md-lite",
"version": "0.0.0-dev",
- "description": "A brief description about the project.",
+ "description": "A minimalist Markdown parser and render for basic formatting.",
"author": {
- "name": "Croct",
- "email": "lib+project-ts@croct.com",
- "url": "https://github.com/croct-tech/project-ts"
+ "name": "croct",
+ "email": "lib+md-lite@croct.com",
+ "url": "https://github.com/croct-tech/md-lite-js"
},
- "license": "UNLICENSED",
+ "license": "MIT",
"keywords": [
"croct",
- "personalization",
- "typescript"
+ "typescript",
+ "markdown"
],
"types": "index.d.ts",
"main": "index.js",
"repository": {
"type": "git",
- "url": "git+https://github.com/croct-tech/project-ts.git"
+ "url": "git+https://github.com/croct-tech/md-lite-js.git"
},
"bugs": {
- "url": "https://github.com/croct-tech/project-ts/issues"
+ "url": "https://github.com/croct-tech/md-lite-js/issues"
},
- "homepage": "https://github.com/croct-tech/project-ts",
+ "homepage": "https://github.com/croct-tech/md-lite-js",
"scripts": {
- "lint": "eslint 'src/**/*.ts' 'test/**/*.ts'",
- "test": "jest -c jest.config.js --coverage",
- "validate": "tsc --noEmit",
- "build": "tsc -p tsconfig.build.json"
+ "lint": "eslint 'src/**/*.ts'",
+ "validate": "tsd test",
+ "build": "tsc -p tsconfig.build.md-lite"
},
"devDependencies": {
"@croct/eslint-plugin": "^0.7.0",
- "@swc/jest": "^0.2.24",
- "@types/jest": "^29.0.0",
"@typescript-eslint/parser": "^6.0.0",
- "eslint": "^8.22",
- "jest": "^29.0.0",
- "jest-extended": "^4.0.0",
- "ts-node": "^10.8.2",
+ "eslint": "^8.13",
"typescript": "^5.0.0"
},
"files": [
diff --git a/src/.gitkeep b/src/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/ast.ts b/src/ast.ts
new file mode 100644
index 0000000..081c35d
--- /dev/null
+++ b/src/ast.ts
@@ -0,0 +1,39 @@
+type MarkdownNodeMap = {
+ bold: {
+ children: MarkdownNode,
+ },
+ italic: {
+ children: MarkdownNode,
+ },
+ strike: {
+ children: MarkdownNode,
+ },
+ code: {
+ content: string,
+ },
+ text: {
+ content: string,
+ },
+ link: {
+ href: string,
+ children: MarkdownNode,
+ },
+ image: {
+ src: string,
+ alt: string,
+ },
+ paragraph: {
+ children: MarkdownNode[],
+ },
+ fragment: {
+ children: MarkdownNode[],
+ },
+};
+
+export type MarkdownNodeType = keyof MarkdownNodeMap;
+
+export type MarkdownNode = {
+ [K in MarkdownNodeType]: MarkdownNodeMap[K] & {
+ type: K,
+ }
+}[T];
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..eca2853
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,3 @@
+export * from './ast';
+export * from './parsing';
+export * from './rendering';
diff --git a/src/parsing.ts b/src/parsing.ts
new file mode 100644
index 0000000..5415bde
--- /dev/null
+++ b/src/parsing.ts
@@ -0,0 +1,330 @@
+import {MarkdownNode} from './ast';
+
+export function parse(markdown: string): MarkdownNode {
+ return MarkdownParser.parse(markdown);
+}
+
+class MismatchError extends Error {
+ public constructor() {
+ super('Mismatched token');
+
+ Object.setPrototypeOf(this, MismatchError.prototype);
+ }
+}
+
+class MarkdownParser {
+ private readonly chars: string[];
+
+ private index = 0;
+
+ private static readonly NEWLINE = ['\r\n', '\r', '\n'];
+
+ private static readonly NEW_PARAGRAPH = MarkdownParser.NEWLINE
+ .flatMap(prefix => MarkdownParser.NEWLINE.map(suffix => prefix + suffix));
+
+ private constructor(input: string) {
+ this.chars = [...input];
+ }
+
+ public static parse(input: string): MarkdownNode {
+ return new MarkdownParser(input).parseNext();
+ }
+
+ private parseNext(end: string = ''): MarkdownNode {
+ const root: MarkdownNode<'fragment'> = {
+ type: 'fragment',
+ children: [],
+ };
+
+ let text = '';
+
+ while (!this.done) {
+ const escapedText = this.parseText('');
+
+ if (escapedText !== '') {
+ text += escapedText;
+
+ continue;
+ }
+
+ if (end !== '' && (this.matches(end) || this.matches(...MarkdownParser.NEWLINE))) {
+ break;
+ }
+
+ if (this.matches(...MarkdownParser.NEW_PARAGRAPH)) {
+ while (MarkdownParser.NEWLINE.includes(this.current)) {
+ this.advance();
+ }
+
+ if (text !== '' || root.children.length > 0) {
+ let paragraph: MarkdownNode = root.children[root.children.length - 1];
+
+ if (paragraph?.type !== 'paragraph') {
+ paragraph = {
+ type: 'paragraph',
+ children: root.children,
+ };
+
+ root.children = [paragraph];
+ }
+
+ if (text !== '') {
+ paragraph.children.push({
+ type: 'text',
+ content: text,
+ });
+
+ text = '';
+ }
+
+ root.children.push({
+ type: 'paragraph',
+ children: [],
+ });
+ }
+
+ continue;
+ }
+
+ const {index} = this;
+
+ let node: MarkdownNode|null = null;
+
+ try {
+ node = this.parseCurrent();
+ } catch (error) {
+ if (!(error instanceof MismatchError)) {
+ /* istanbul ignore next */
+ throw error;
+ }
+ }
+
+ if (node === null) {
+ this.seek(index);
+
+ text += this.current;
+
+ this.advance();
+
+ continue;
+ }
+
+ let parent = root.children[root.children.length - 1];
+
+ if (parent?.type !== 'paragraph') {
+ parent = root;
+ }
+
+ if (text !== '') {
+ parent.children.push({
+ type: 'text',
+ content: text,
+ });
+ }
+
+ text = '';
+
+ parent.children.push(node);
+ }
+
+ if (text !== '') {
+ let parent = root.children[root.children.length - 1];
+
+ if (parent?.type !== 'paragraph') {
+ parent = root;
+ }
+
+ parent.children.push({
+ type: 'text',
+ content: text,
+ });
+ }
+
+ const lastNode = root.children[root.children.length - 1];
+
+ if (lastNode?.type === 'paragraph' && lastNode.children.length === 0) {
+ root.children.pop();
+ }
+
+ if (root.children.length === 1) {
+ return root.children[0];
+ }
+
+ return root;
+ }
+
+ private parseCurrent(): MarkdownNode|null {
+ const char = this.lookAhead();
+
+ switch (char) {
+ case '*':
+ case '_': {
+ const delimiter = this.matches('**') ? '**' : char;
+
+ this.advance(delimiter.length);
+
+ const children = this.parseNext(delimiter);
+
+ this.match(delimiter);
+
+ return {
+ type: delimiter.length === 1 ? 'italic' : 'bold',
+ children: children,
+ };
+ }
+
+ case '~': {
+ this.match('~~');
+
+ const children = this.parseNext('~~');
+
+ this.match('~~');
+
+ return {
+ type: 'strike',
+ children: children,
+ };
+ }
+
+ case '`': {
+ if (this.matches('```')) {
+ return null;
+ }
+
+ const delimiter = this.matches('``') ? '``' : '`';
+
+ this.match(delimiter);
+
+ const content = this.parseText(delimiter).trim();
+
+ if (this.matches('```')) {
+ return null;
+ }
+
+ this.match(delimiter);
+
+ return {
+ type: 'code',
+ content: content,
+ };
+ }
+
+ case '!': {
+ this.advance();
+
+ this.match('[');
+
+ const alt = this.parseText(']');
+
+ this.match('](');
+
+ const src = this.parseText(')');
+
+ this.match(')');
+
+ return {
+ type: 'image',
+ src: src,
+ alt: alt,
+ };
+ }
+
+ case '[': {
+ this.advance();
+
+ const label = this.parseNext(']');
+
+ this.match('](');
+
+ const href = this.parseText(')');
+
+ this.match(')');
+
+ return {
+ type: 'link',
+ href: href,
+ children: label,
+ };
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ private parseText(end: string): string {
+ let text = '';
+
+ while (!this.done) {
+ if (this.current === '\\' && this.index + 1 < this.length) {
+ this.advance();
+
+ text += this.current;
+
+ this.advance();
+
+ continue;
+ }
+
+ if (end === '' || this.matches(end) || this.matches(...MarkdownParser.NEWLINE)) {
+ break;
+ }
+
+ text += this.current;
+
+ this.advance();
+ }
+
+ return text;
+ }
+
+ private get done(): boolean {
+ return this.index >= this.length;
+ }
+
+ private get length(): number {
+ return this.chars.length;
+ }
+
+ private get current(): string {
+ return this.chars[this.index];
+ }
+
+ private advance(length: number = 1): void {
+ this.index += length;
+ }
+
+ private seek(index: number): void {
+ this.index = index;
+ }
+
+ private matches(...lookahead: string[]): boolean {
+ return lookahead.some(substring => this.lookAhead(substring.length) === substring);
+ }
+
+ private match(...lookahead: string[]): void {
+ for (const substring of lookahead) {
+ if (this.lookAhead(substring.length) === substring) {
+ this.advance(substring.length);
+
+ return;
+ }
+ }
+
+ throw new MismatchError();
+ }
+
+ private lookAhead(length: number = 1): string {
+ if (length === 1) {
+ return this.current;
+ }
+
+ return this.getSlice(this.index, this.index + length);
+ }
+
+ private getSlice(start: number, end: number): string {
+ return this.chars
+ .slice(start, end)
+ .join('');
+ }
+}
diff --git a/src/rendering.ts b/src/rendering.ts
new file mode 100644
index 0000000..386a8d3
--- /dev/null
+++ b/src/rendering.ts
@@ -0,0 +1,104 @@
+import {MarkdownNode, MarkdownNodeType} from './ast';
+import {parse} from './parsing';
+
+type VisitedMarkdownNodeMap = {
+ bold: {
+ children: C,
+ },
+ italic: {
+ children: C,
+ },
+ strike: {
+ children: C,
+ },
+ code: {
+ content: string,
+ },
+ text: {
+ content: string,
+ },
+ image: {
+ src: string,
+ alt: string,
+ },
+ link: {
+ href: string,
+ children: C,
+ },
+ paragraph: {
+ children: C[],
+ },
+ fragment: {
+ children: C[],
+ },
+};
+
+export type VisitedMarkdownNode = {
+ [K in MarkdownNodeType]: {type: K} & VisitedMarkdownNodeMap[K]
+}[T];
+
+export interface MarkdownRenderer {
+ bold(node: VisitedMarkdownNode): T;
+ italic(node: VisitedMarkdownNode): T;
+ strike(node: VisitedMarkdownNode): T;
+ code(node: VisitedMarkdownNode): T;
+ text(node: VisitedMarkdownNode): T;
+ image(node: VisitedMarkdownNode): T;
+ link(node: VisitedMarkdownNode): T;
+ paragraph(node: VisitedMarkdownNode): T;
+ fragment(node: VisitedMarkdownNode): T;
+}
+
+export function render(markdown: string|MarkdownNode, visitor: MarkdownRenderer): T {
+ return visit(typeof markdown === 'string' ? parse(markdown) : markdown, visitor);
+}
+
+function visit(node: MarkdownNode, visitor: MarkdownRenderer): T {
+ switch (node.type) {
+ case 'image':
+ return visitor.image(node);
+
+ case 'link':
+ return visitor.link({
+ type: node.type,
+ href: node.href,
+ children: visit(node.children, visitor),
+ });
+
+ case 'bold':
+ return visitor.bold({
+ type: node.type,
+ children: visit(node.children, visitor),
+ });
+
+ case 'italic':
+ return visitor.italic({
+ type: node.type,
+ children: visit(node.children, visitor),
+ });
+
+ case 'strike':
+ return visitor.strike({
+ type: node.type,
+ children: visit(node.children, visitor),
+ });
+
+ case 'code':
+ return visitor.code(node);
+
+ case 'text':
+ return visitor.text(node);
+
+ case 'paragraph':
+ return visitor.paragraph({
+ type: node.type,
+ children: node.children.map(child => visit(child, visitor)),
+ });
+
+ case 'fragment':
+ return visitor.fragment({
+ type: node.type,
+ children: node.children.map(child => visit(child, visitor)),
+ });
+ }
+}
diff --git a/test/.gitkeep b/test/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/jestExtended.d.ts b/test/jestExtended.d.ts
deleted file mode 100644
index 9e6c1db..0000000
--- a/test/jestExtended.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-import 'jest-extended';
diff --git a/test/parsing.ts b/test/parsing.ts
new file mode 100644
index 0000000..9f32859
--- /dev/null
+++ b/test/parsing.ts
@@ -0,0 +1,1134 @@
+import {parse} from '../src/parsing';
+import {MarkdownNode} from '../src/ast';
+
+describe('A Markdown parser function', () => {
+ type ParsingScenario = {
+ input: string,
+ output: MarkdownNode,
+ };
+
+ it.each(Object.entries({
+ paragraphs: {
+ input: 'First paragraph.\n\nSecond paragraph.',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'First paragraph.',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Second paragraph.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ 'paragraphs with different newlines': {
+ input: 'First\n\nSecond\r\rThird\r\n\r\nFourth',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'First',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Second',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Third',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Fourth',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ 'paragraphs leading and trailing newlines': {
+ input: '\n\n\r\nFirst paragraph.\n\n\r\nSecond paragraph.\n\n\r\n',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'First paragraph.',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Second paragraph.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ 'empty paragraphs': {
+ input: '\n\r\r\n',
+ output: {
+ type: 'fragment',
+ children: [],
+ },
+ },
+ 'paragraphs multiple newlines': {
+ input: '\n\n\nFirst paragraph.\n\n\nSecond paragraph.\n\n\n\n',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'First paragraph.',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Second paragraph.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ 'mixed paragraphs and text': {
+ input: [
+ '**First**\n_paragraph_',
+ '[Second paragraph](ex)',
+ '![Third paragraph](ex)',
+ 'Fourth paragraph',
+ ].join('\n\n'),
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'First',
+ },
+ },
+ {
+ type: 'text',
+ content: '\n',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'paragraph',
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'link',
+ href: 'ex',
+ children: {
+ type: 'text',
+ content: 'Second paragraph',
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'image',
+ src: 'ex',
+ alt: 'Third paragraph',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'text',
+ content: 'Fourth paragraph',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ text: {
+ input: 'Hello, world!',
+ output: {
+ type: 'text',
+ content: 'Hello, world!',
+ },
+ },
+ bold: {
+ input: 'Hello, **world**!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'bold with start delimited escaped': {
+ input: 'Hello, \\**world**!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, *',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '*!',
+ },
+ ],
+ },
+ },
+ 'bold with end delimited escaped': {
+ input: 'Hello, **world\\**!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, *',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'world*',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'bold with escaped asterisk': {
+ input: 'Hello, **wor\\*\\*ld**!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'wor**ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'bold with newline': {
+ input: 'Hello, **\nworld**!',
+ output: {
+ type: 'text',
+ content: 'Hello, **\nworld**!',
+ },
+ },
+ 'bold unbalanced': {
+ input: 'Hello, **world***!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '*!',
+ },
+ ],
+ },
+ },
+ 'italic (underscore)': {
+ input: 'Hello, _world_!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'italic with start delimited escaped (underscore)': {
+ input: 'Hello, \\_world_!',
+ output: {
+ type: 'text',
+ content: 'Hello, _world_!',
+ },
+ },
+ 'italic with end delimited escaped (underscore)': {
+ input: 'Hello, _world\\_!',
+ output: {
+ type: 'text',
+ content: 'Hello, _world_!',
+ },
+ },
+ 'italic with escaped delimiter (underscore)': {
+ input: 'Hello, _wor\\_ld_!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'wor_ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'italic with newline (underscore)': {
+ input: 'Hello, _\nworld_!',
+ output: {
+ type: 'text',
+ content: 'Hello, _\nworld_!',
+ },
+ },
+ 'italic (asterisk)': {
+ input: 'Hello, *world*!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'italic with start delimited escaped (asterisk)': {
+ input: 'Hello, \\*world*!',
+ output: {
+ type: 'text',
+ content: 'Hello, *world*!',
+ },
+ },
+ 'italic with end delimited escaped (asterisk)': {
+ input: 'Hello, *world\\*!',
+ output: {
+ type: 'text',
+ content: 'Hello, *world*!',
+ },
+ },
+ 'italic with escaped delimiter (asterisk)': {
+ input: 'Hello, *wor\\*ld*!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'wor*ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'italic with newline (asterisk)': {
+ input: 'Hello, *\nworld*!',
+ output: {
+ type: 'text',
+ content: 'Hello, *\nworld*!',
+ },
+ },
+ 'bold and italic': {
+ input: 'Hello, ***world***!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'bold',
+ children: {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ strike: {
+ input: 'Hello, ~~world~~!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'strike',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'strike with start delimited escaped': {
+ input: 'Hello, \\~~world~~!',
+ output: {
+ type: 'text',
+ content: 'Hello, ~~world~~!',
+ },
+ },
+ 'strike with end delimited escaped': {
+ input: 'Hello, ~~world\\~~!',
+ output: {
+ type: 'text',
+ content: 'Hello, ~~world~~!',
+ },
+ },
+ 'strike with escaped asterisk': {
+ input: 'Hello, ~~wor\\~\\~ld~~!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'strike',
+ children: {
+ type: 'text',
+ content: 'wor~~ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'strike with newline': {
+ input: 'Hello, ~~\nworld~~!',
+ output: {
+ type: 'text',
+ content: 'Hello, ~~\nworld~~!',
+ },
+ },
+ 'code (single backtick)': {
+ input: 'Hello, `world`!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with trailing and leading whitespace (single backtick)': {
+ input: 'Hello, ` world `!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with start delimited escaped (single backtick)': {
+ input: 'Hello, \\`world`!',
+ output: {
+ type: 'text',
+ content: 'Hello, `world`!',
+ },
+ },
+ 'code with end delimited escaped (single backtick)': {
+ input: 'Hello, `world\\`!',
+ output: {
+ type: 'text',
+ content: 'Hello, `world`!',
+ },
+ },
+ 'code with escaped backtick (single backtick)': {
+ input: 'Hello, `wor\\`ld`!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'wor`ld',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code (double backtick)': {
+ input: 'Hello, ``world``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with trailing and leading whitespace (double backtick)': {
+ input: 'Hello, `` world ``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ }, {
+ type: 'code',
+ content: 'world',
+ }, {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with start delimited escaped (double backtick)': {
+ input: 'Hello, \\``world``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, `',
+ },
+ {
+ type: 'code',
+ content: 'world',
+ },
+ {
+ type: 'text',
+ content: '`!',
+ },
+ ],
+ },
+ },
+ 'code with end delimited escaped (double backtick)': {
+ input: 'Hello, ``world\\``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, `',
+ },
+ {
+ type: 'code',
+ content: 'world`',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with escaped backtick (double backtick)': {
+ input: 'Hello, ``wor\\`ld``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'wor`ld',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with unescaped backtick (double backtick)': {
+ input: 'Hello, ``wor`ld``!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'code',
+ content: 'wor`ld',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'code with newline': {
+ input: 'Hello, `\nworld`!',
+ output: {
+ type: 'text',
+ content: 'Hello, `\nworld`!',
+ },
+ },
+ link: {
+ input: 'Hello, [world](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'image.png',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'fenced code (unsupported)': {
+ input: 'Hello, ```world```!',
+ output: {
+ type: 'text',
+ content: 'Hello, ```world```!',
+ },
+ },
+ 'fenced code unbalanced (unsupported)': {
+ input: 'Hello, ``world```!',
+ output: {
+ type: 'text',
+ content: 'Hello, ``world```!',
+ },
+ },
+ 'link with start delimiter escaped': {
+ input: 'Hello, \\[world](image.png)!',
+ output: {
+ type: 'text',
+ content: 'Hello, [world](image.png)!',
+ },
+ },
+ 'link with escaped left bracket': {
+ input: 'Hello, [wor\\[ld](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'image.png',
+ children: {
+ type: 'text',
+ content: 'wor[ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'link with escaped right bracket': {
+ input: 'Hello, [wor\\]ld](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'image.png',
+ children: {
+ type: 'text',
+ content: 'wor]ld',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'link with escaped left parenthesis': {
+ input: 'Hello, [world](https://\\(example.com)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'https://(example.com',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'link with escaped right parenthesis': {
+ input: 'Hello, [world](image.png\\))!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'image.png)',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'link with formatted text': {
+ input: 'Hello, [**world**](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ }, {
+ type: 'link',
+ href: 'image.png',
+ children: {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ }, {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ image: {
+ input: 'Hello, ![world](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'image.png',
+ alt: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with start delimiter escaped': {
+ input: 'Hello, \\![world](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, !',
+ },
+ {
+ type: 'link',
+ href: 'image.png',
+ children: {
+ type: 'text',
+ content: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with escaped left bracket': {
+ input: 'Hello, ![wor\\[ld](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'image.png',
+ alt: 'wor[ld',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with escaped right bracket': {
+ input: 'Hello, ![wor\\]ld](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'image.png',
+ alt: 'wor]ld',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with escaped left parenthesis': {
+ input: 'Hello, ![world](https://\\(example.com)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'https://(example.com',
+ alt: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with escaped right parenthesis': {
+ input: 'Hello, ![world](image.png\\))!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'image.png)',
+ alt: 'world',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'image with formatted text': {
+ input: 'Hello, ![**world**](image.png)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'image',
+ src: 'image.png',
+ alt: '**world**',
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'link with image': {
+ input: 'Hello, [![world](image.png)](https://example.com)!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ },
+ {
+ type: 'link',
+ href: 'https://example.com',
+ children: {
+ type: 'image',
+ src: 'image.png',
+ alt: 'world',
+ },
+ },
+ {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ 'mixed formatting': {
+ input: 'Hello, **_~~`[world](image.png)`~~_**!',
+ output: {
+ type: 'fragment',
+ children: [
+ {
+ type: 'text',
+ content: 'Hello, ',
+ }, {
+ type: 'bold',
+ children: {
+ type: 'italic',
+ children: {
+ type: 'strike',
+ children: {
+ type: 'code',
+ content: '[world](image.png)',
+ },
+ },
+ },
+ }, {
+ type: 'text',
+ content: '!',
+ },
+ ],
+ },
+ },
+ }))('should parse %s', (_, {input, output}) => {
+ expect(parse(input)).toEqual(output);
+ });
+});
diff --git a/test/rendering.ts b/test/rendering.ts
new file mode 100644
index 0000000..a024b25
--- /dev/null
+++ b/test/rendering.ts
@@ -0,0 +1,159 @@
+import {MarkdownNode} from '../src/ast';
+import {MarkdownRenderer, render, VisitedMarkdownNode} from '../src';
+
+describe('A Markdown render function', () => {
+ class HtmlRenderer implements MarkdownRenderer {
+ public fragment(node: VisitedMarkdownNode): string {
+ return node.children.join('');
+ }
+
+ public text(node: VisitedMarkdownNode): string {
+ return node.content;
+ }
+
+ public bold(node: VisitedMarkdownNode): string {
+ return `${node.children}`;
+ }
+
+ public italic(node: VisitedMarkdownNode): string {
+ return `${node.children}`;
+ }
+
+ public strike(node: VisitedMarkdownNode): string {
+ return `${node.children}`;
+ }
+
+ public code(node: VisitedMarkdownNode): string {
+ return `${node.content}
`;
+ }
+
+ public image(node: VisitedMarkdownNode): string {
+ return ``;
+ }
+
+ public link(node: VisitedMarkdownNode): string {
+ return `${node.children}`;
+ }
+
+ public paragraph(node: VisitedMarkdownNode): string {
+ return `${node.children.join('')}
`;
+ }
+ }
+
+ const tree: MarkdownNode = {
+ type: 'fragment',
+ children: [
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'bold',
+ children: {
+ type: 'text',
+ content: 'Bold',
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'Italic',
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'bold',
+ children: {
+ type: 'italic',
+ children: {
+ type: 'text',
+ content: 'Bold and italic',
+ },
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'strike',
+ children: {
+ type: 'text',
+ content: 'Strike',
+ },
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'code',
+ content: 'Code',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'image',
+ src: 'https://example.com/image.png',
+ alt: 'Image',
+ },
+ ],
+ },
+ {
+ type: 'paragraph',
+ children: [
+ {
+ type: 'link',
+ href: 'https://example.com',
+ children: {
+ type: 'text',
+ content: 'Link',
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ const markdown = [
+ '**Bold**',
+ '*Italic*',
+ '***Bold and italic***',
+ '~~Strike~~',
+ '`Code`',
+ '![Image](https://example.com/image.png)',
+ '[Link](https://example.com)',
+ ].join('\n\n');
+
+ const html = [
+ 'Bold
',
+ 'Italic
',
+ 'Bold and italic
',
+ 'Strike
',
+ 'Code
',
+ '',
+ 'Link
',
+ ].join('');
+
+ it('should render a Markdown tree', () => {
+ expect(render(tree, new HtmlRenderer())).toBe(html);
+ });
+
+ it('should parse and render a Markdown string', () => {
+ expect(render(markdown, new HtmlRenderer())).toBe(html);
+ });
+});
diff --git a/tsconfig.build.json b/tsconfig.build.json
index 8978d8b..3e4c3e5 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -8,6 +8,7 @@
"exclude": [
"node_modules",
"build",
+ "**/*.test-d.ts",
"**/*.test.ts"
]
-}
\ No newline at end of file
+}
diff --git a/tsconfig.json b/tsconfig.json
index dbfa77a..057be94 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,9 +2,7 @@
"compilerOptions": {
"module": "CommonJS",
"target": "ES2020",
- "esModuleInterop": true,
"moduleResolution": "Node",
- "stripInternal": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"noImplicitAny": true,
@@ -14,11 +12,11 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"strictNullChecks": true,
+ "strict": true,
"removeComments": false,
"noEmit": true,
"downlevelIteration": true,
"declaration": true,
- "lib": ["es6"]
},
"include": [
"src/**/*.ts",