├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── build.config.ts ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src └── index.ts ├── test └── index.test.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | opencollective: antfu 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | run_install: false 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | cache: pnpm 24 | 25 | - run: pnpm i -g @antfu/ni 26 | - run: nci 27 | - run: nr lint 28 | - run: nr typecheck 29 | 30 | test: 31 | runs-on: ${{ matrix.os }} 32 | 33 | strategy: 34 | matrix: 35 | node: [lts/*] 36 | os: [ubuntu-latest, windows-latest, macos-latest] 37 | fail-fast: false 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: pnpm/action-setup@v4 42 | with: 43 | run_install: false 44 | - name: Set node ${{ matrix.node }} 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: ${{ matrix.node }} 48 | cache: pnpm 49 | 50 | - run: pnpm i -g @antfu/ni 51 | - run: nci 52 | - run: nr build 53 | - run: nr test 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | id-token: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | registry-url: https://registry.npmjs.org/ 24 | 25 | - run: pnpm dlx changelogithub 26 | env: 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | 29 | # # Uncomment the following lines to publish to npm on CI 30 | # 31 | # - run: pnpm install 32 | # - run: pnpm publish -r --access public 33 | # env: 34 | # NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 35 | # NPM_CONFIG_PROVENANCE: true 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shell-emulator=true 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Disable the default formatter, use eslint instead 3 | "prettier.enable": false, 4 | "editor.formatOnSave": false, 5 | 6 | // Auto fix 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit", 9 | "source.organizeImports": "never" 10 | }, 11 | 12 | // Silent the stylistic rules in you IDE, but still auto fix them 13 | "eslint.rules.customizations": [ 14 | { "rule": "style/*", "severity": "off" }, 15 | { "rule": "*-indent", "severity": "off" }, 16 | { "rule": "*-spacing", "severity": "off" }, 17 | { "rule": "*-spaces", "severity": "off" }, 18 | { "rule": "*-order", "severity": "off" }, 19 | { "rule": "*-dangle", "severity": "off" }, 20 | { "rule": "*-newline", "severity": "off" }, 21 | { "rule": "*quotes", "severity": "off" }, 22 | { "rule": "*semi", "severity": "off" } 23 | ], 24 | 25 | // Enable eslint for all supported languages 26 | "eslint.validate": [ 27 | "javascript", 28 | "javascriptreact", 29 | "typescript", 30 | "typescriptreact", 31 | "vue", 32 | "html", 33 | "markdown", 34 | "json", 35 | "jsonc", 36 | "yaml" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/antfu/contribute 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anthony Fu 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 | # markdown-it-async 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![bundle][bundle-src]][bundle-href] 6 | [![JSDocs][jsdocs-src]][jsdocs-href] 7 | [![License][license-src]][license-href] 8 | 9 | Enhance [`markdown-it`](https://github.com/markdown-it/markdown-it) to support **async** highlight function. 10 | 11 | This package is a wrapper on top of `markdown-it` instead of a fork. Updates from `markdown-it` will be reflected without as transient dependencies. 12 | 13 | ## Usage 14 | 15 | ```ts 16 | import MarkdownItAsync from 'markdown-it-async' 17 | 18 | const md = MarkdownItAsync({ 19 | async highlight(code, lang) { 20 | const { codeToHtml } = await import('shiki') 21 | return await codeToHtml(code, { lang, theme: 'vitesse-dark' }) 22 | } 23 | }) 24 | 25 | // Note you need to use `renderAsync` instead of `render` 26 | const html = await md.renderAsync(markdown) 27 | ``` 28 | 29 | ## Opt-in Warning 30 | 31 | If you integrate this package into your project, and want to make sure you have every usage of `md.render` migrated to `md.renderAsync`, you can enable the `warnOnSyncRender` option. 32 | 33 | ```ts 34 | const md = MarkdownItAsync({ 35 | warnOnSyncRender: true 36 | }) 37 | 38 | md.render('Hello') // This will throw a conole warning 39 | ``` 40 | 41 | ## How it works? 42 | 43 | This package is a thin wrapper around `markdown-it` to support async highlight function. It uses [the approach suggested in `markdown-it`'s docs](https://github.com/markdown-it/markdown-it/blob/master/docs/development.md#i-need-async-rule-how-to-do-it), by putting placeholders in sync mode and then replace them with async results. 44 | 45 | The goal is to make it easy to use and absorb the "hack" into the library itself. 46 | 47 | ## Sponsors 48 | 49 |

50 | 51 | 52 | 53 |

54 | 55 | ## License 56 | 57 | [MIT](./LICENSE) License © 2024-PRESENT [Anthony Fu](https://github.com/antfu) 58 | 59 | 60 | 61 | [npm-version-src]: https://img.shields.io/npm/v/markdown-it-async?style=flat&colorA=080f12&colorB=1fa669 62 | [npm-version-href]: https://npmjs.com/package/markdown-it-async 63 | [npm-downloads-src]: https://img.shields.io/npm/dm/markdown-it-async?style=flat&colorA=080f12&colorB=1fa669 64 | [npm-downloads-href]: https://npmjs.com/package/markdown-it-async 65 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/markdown-it-async?style=flat&colorA=080f12&colorB=1fa669&label=minzip 66 | [bundle-href]: https://bundlephobia.com/result?p=markdown-it-async 67 | [license-src]: https://img.shields.io/github/license/antfu/markdown-it-async.svg?style=flat&colorA=080f12&colorB=1fa669 68 | [license-href]: https://github.com/antfu/markdown-it-async/blob/main/LICENSE 69 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669 70 | [jsdocs-href]: https://www.jsdocs.io/package/markdown-it-async 71 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import { glob } from 'tinyglobby' 3 | import { defineBuildConfig } from 'unbuild' 4 | 5 | export default defineBuildConfig({ 6 | entries: [ 7 | 'src/index', 8 | ], 9 | declaration: 'node16', 10 | clean: true, 11 | hooks: { 12 | 'build:done': async () => { 13 | for (const file of await glob('./dist/*.d.{cts,mts,ts}')) { 14 | let content = await fs.readFile(file, 'utf-8') 15 | // Override `options` type on dist dts only 16 | let newContent = content.replace( 17 | 'class MarkdownItAsync extends MarkdownIt {', 18 | 'class MarkdownItAsync extends MarkdownIt {\n // @ts-ignore\n options: MarkdownItAsyncOptions', 19 | ) 20 | if (content === newContent) 21 | throw new Error(`Failed to replace for ${file}`) 22 | content = newContent 23 | newContent = content.replace( 24 | 'import MarkdownIt', 25 | 'import type MarkdownIt', 26 | ) 27 | if (content === newContent) 28 | throw new Error(`Failed to replace for ${file}`) 29 | await fs.writeFile(file, newContent, 'utf-8') 30 | } 31 | }, 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu( 5 | { 6 | type: 'lib', 7 | }, 8 | ) 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-it-async", 3 | "type": "module", 4 | "version": "2.2.0", 5 | "packageManager": "pnpm@10.5.2", 6 | "description": "Enhance markdown-it to support async highlight function.", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu/markdown-it-async#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu/markdown-it-async.git" 14 | }, 15 | "bugs": "https://github.com/antfu/markdown-it-async/issues", 16 | "keywords": [], 17 | "sideEffects": false, 18 | "exports": { 19 | ".": "./dist/index.mjs" 20 | }, 21 | "main": "./dist/index.mjs", 22 | "module": "./dist/index.mjs", 23 | "types": "./dist/index.d.mts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "build": "unbuild", 29 | "dev": "unbuild --stub", 30 | "lint": "eslint .", 31 | "prepublishOnly": "nr build", 32 | "release": "bumpp && pnpm publish", 33 | "start": "tsx src/index.ts", 34 | "test": "vitest", 35 | "typecheck": "tsc --noEmit", 36 | "prepare": "simple-git-hooks" 37 | }, 38 | "dependencies": { 39 | "@types/markdown-it": "^14.1.2", 40 | "markdown-it": "^14.1.0" 41 | }, 42 | "devDependencies": { 43 | "@antfu/eslint-config": "^4.4.0", 44 | "@antfu/ni": "^23.3.1", 45 | "@types/node": "^22.13.9", 46 | "bumpp": "^10.0.3", 47 | "eslint": "^9.21.0", 48 | "lint-staged": "^15.4.3", 49 | "pnpm": "^10.5.2", 50 | "shiki": "^3.1.0", 51 | "simple-git-hooks": "^2.11.1", 52 | "tinyglobby": "^0.2.12", 53 | "tsx": "^4.19.3", 54 | "typescript": "^5.8.2", 55 | "unbuild": "^3.5.0", 56 | "vite": "^6.2.0", 57 | "vitest": "^3.0.7" 58 | }, 59 | "simple-git-hooks": { 60 | "pre-commit": "pnpm lint-staged" 61 | }, 62 | "lint-staged": { 63 | "*": "eslint --fix" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - docs 4 | - packages/* 5 | - examples/* 6 | onlyBuiltDependencies: 7 | - esbuild 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PluginSimple as MarkdownItPluginSimple, 3 | PluginWithOptions as MarkdownItPluginWithOptions, 4 | PluginWithParams as MarkdownItPluginWithParams, 5 | Options, 6 | PresetName, 7 | } from 'markdown-it' 8 | import MarkdownIt from 'markdown-it' 9 | 10 | export type PluginSimple = ((md: MarkdownItAsync) => void) 11 | export type PluginWithOptions = ((md: MarkdownItAsync, options?: T) => void) 12 | export type PluginWithParams = ((md: MarkdownItAsync, ...params: any[]) => void) 13 | 14 | export interface MarkdownItAsyncOptions extends Omit { 15 | /** 16 | * Highlighter function for fenced code blocks. 17 | * Highlighter `function (str, lang, attrs)` should return escaped HTML. It can 18 | * also return empty string if the source was not changed and should be escaped 19 | * externally. If result starts with string | Promise) | null | undefined 23 | 24 | /** 25 | * Emit warning when calling `md.render` instead of `md.renderAsync`. 26 | * 27 | * @default false 28 | */ 29 | warnOnSyncRender?: boolean 30 | } 31 | 32 | export type { MarkdownItAsyncOptions as Options } 33 | 34 | const placeholder = (id: string, code: string): string => `
${code}
` 35 | const placeholderRe = /
[\s\S]*?<\/code><\/pre>/g
 36 | 
 37 | function randStr(): string {
 38 |   return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
 39 | }
 40 | 
 41 | export type MarkdownItAsyncPlaceholderMap = Map, str: string, lang: string, attrs: string]>
 42 | 
 43 | export class MarkdownItAsync extends MarkdownIt {
 44 |   placeholderMap: MarkdownItAsyncPlaceholderMap
 45 |   private disableWarn = false
 46 | 
 47 |   constructor(presetName: PresetName, options?: MarkdownItAsyncOptions)
 48 |   constructor(options?: MarkdownItAsyncOptions)
 49 |   constructor(...args: any[]) {
 50 |     const map: MarkdownItAsyncPlaceholderMap = new Map()
 51 |     const options = args.length === 2 ? args[1] : args[0]
 52 |     if (options && 'highlight' in options)
 53 |       options.highlight = wrapHightlight(options.highlight, map)
 54 |     super(...args as [])
 55 |     this.placeholderMap = map
 56 |   }
 57 | 
 58 |   use(plugin: PluginSimple): this
 59 |   use(plugin: MarkdownItPluginSimple): this
 60 |   use(plugin: PluginWithOptions, options?: T): this
 61 |   use(plugin: MarkdownItPluginWithOptions, options?: T): this
 62 |   use(plugin: PluginWithParams, ...params: any[]): this
 63 |   use(plugin: MarkdownItPluginWithParams, ...params: any[]): this
 64 |   // implementation
 65 |   use(plugin: any, ...params: any[]): this {
 66 |     return super.use(plugin, ...params)
 67 |   }
 68 | 
 69 |   render(src: string, env?: any): string {
 70 |     if ((this.options as MarkdownItAsyncOptions).warnOnSyncRender && !this.disableWarn) {
 71 |       console.warn('[markdown-it-async] Please use `md.renderAsync` instead of `md.render`')
 72 |     }
 73 |     return super.render(src, env)
 74 |   }
 75 | 
 76 |   async renderAsync(src: string, env?: any): Promise {
 77 |     this.options.highlight = wrapHightlight(this.options.highlight, this.placeholderMap)
 78 |     this.disableWarn = true
 79 |     const result = this.render(src, env)
 80 |     this.disableWarn = false
 81 |     return replaceAsync(result, placeholderRe, async (match, id) => {
 82 |       if (!this.placeholderMap.has(id))
 83 |         throw new Error(`Unknown highlight placeholder id: ${id}`)
 84 |       const [promise, _str, lang, _attrs] = this.placeholderMap.get(id)!
 85 |       const result = await promise || ''
 86 |       this.placeholderMap.delete(id)
 87 |       if (result.startsWith('${result}
` 91 | }) 92 | } 93 | } 94 | 95 | export function createMarkdownItAsync(presetName: PresetName, options?: MarkdownItAsyncOptions): MarkdownItAsync 96 | export function createMarkdownItAsync(options?: MarkdownItAsyncOptions): MarkdownItAsync 97 | export function createMarkdownItAsync(...args: any[]): MarkdownItAsync { 98 | return new MarkdownItAsync(...args) 99 | } 100 | 101 | // https://github.com/dsblv/string-replace-async/blob/main/index.js 102 | export function replaceAsync(string: string, searchValue: RegExp, replacer: (...args: string[]) => Promise): Promise { 103 | try { 104 | if (typeof replacer === 'function') { 105 | const values: Promise[] = [] 106 | String.prototype.replace.call(string, searchValue, (...args) => { 107 | values.push(replacer(...args)) 108 | return '' 109 | }) 110 | return Promise.all(values).then((resolvedValues) => { 111 | return String.prototype.replace.call(string, searchValue, () => { 112 | return resolvedValues.shift() || '' 113 | }) 114 | }) 115 | } 116 | else { 117 | return Promise.resolve( 118 | String.prototype.replace.call(string, searchValue, replacer), 119 | ) 120 | } 121 | } 122 | catch (error) { 123 | return Promise.reject(error) 124 | } 125 | } 126 | 127 | type NotNull = T extends null | undefined ? never : T 128 | 129 | const wrappedSet = new WeakSet>() 130 | 131 | function escapeHtml(unsafe: string): string { 132 | return unsafe 133 | .replace(/&/g, '&') 134 | .replace(//g, '>') 136 | .replace(/"/g, '"') 137 | .replace(/'/g, ''') 138 | } 139 | 140 | function wrapHightlight(highlight: MarkdownItAsyncOptions['highlight'], map: MarkdownItAsyncPlaceholderMap): Options['highlight'] { 141 | if (!highlight) 142 | return undefined 143 | 144 | if (wrappedSet.has(highlight as any)) 145 | return highlight as any 146 | 147 | const wrapped: NotNull = (str, lang, attrs) => { 148 | const promise = highlight(str, lang, attrs) 149 | if (typeof promise === 'string') 150 | return promise 151 | const id = randStr() 152 | map.set(id, [promise, str, lang, attrs]) 153 | let code = str 154 | if (code.endsWith('\n')) 155 | code = code.slice(0, -1) 156 | code = escapeHtml(code) 157 | return placeholder(id, code) 158 | } 159 | 160 | wrappedSet.add(wrapped) 161 | return wrapped 162 | } 163 | 164 | export default createMarkdownItAsync 165 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import createMarkdownIt from 'markdown-it' 2 | import { codeToHtml, createHighlighter } from 'shiki' 3 | import { describe, expect, it, vi } from 'vitest' 4 | import createMarkdownItAsync from '../src' 5 | 6 | const fixture = ` 7 | # Hello 8 | 9 | Some code 10 | 11 | \`\`\`ts 12 | console.log('Hello') 13 | \`\`\` 14 | ` 15 | 16 | describe('markdown-it-async', async () => { 17 | using shiki = await createHighlighter({ 18 | themes: ['vitesse-light'], 19 | langs: ['ts'], 20 | }) 21 | 22 | const mds = createMarkdownIt({ 23 | highlight(str, lang) { 24 | return shiki.codeToHtml(str, { lang, theme: 'vitesse-light' }) 25 | }, 26 | }) 27 | const expectedResult = mds.render(fixture) 28 | 29 | it('exported', async () => { 30 | const mda = createMarkdownItAsync({ 31 | async highlight(str, lang) { 32 | return await codeToHtml(str, { 33 | lang, 34 | theme: 'vitesse-light', 35 | }) 36 | }, 37 | }) 38 | 39 | expect(expectedResult) 40 | .toEqual(await mda.renderAsync(fixture)) 41 | }) 42 | 43 | it('via optons set', async () => { 44 | const mda = createMarkdownItAsync() 45 | 46 | mda.use((md) => { 47 | // @ts-expect-error cast, patched in dist 48 | md.options.highlight = async (str, lang) => { 49 | return await codeToHtml(str, { 50 | lang, 51 | theme: 'vitesse-light', 52 | }) 53 | } 54 | }) 55 | 56 | expect(expectedResult).toEqual(await mda.renderAsync(fixture)) 57 | }) 58 | 59 | it('warn', () => { 60 | const mda = createMarkdownItAsync({ 61 | warnOnSyncRender: true, 62 | }) 63 | 64 | const spy = vi 65 | .spyOn(console, 'warn') 66 | .mockImplementation(() => {}) 67 | 68 | mda.render(fixture) 69 | 70 | expect.soft(spy) 71 | .toHaveBeenCalledWith('[markdown-it-async] Please use `md.renderAsync` instead of `md.render`') 72 | 73 | spy.mockRestore() 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "skipDefaultLibCheck": true, 13 | "skipLibCheck": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------