Skip to content

Commit

Permalink
feat(markdown-it): support markdown-it-async integration (#902)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu authored Jan 22, 2025
1 parent 13533db commit 4d59c8f
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 47 deletions.
31 changes: 31 additions & 0 deletions docs/packages/markdown-it.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,34 @@ const md = MarkdownIt()

md.use(fromHighlighter(highlighter, { /* options */ }))
```

## With Shorthands

Shiki's [shorthands](/guide/shorthands) provides on-demand loading of themes and languages, but also makes the highlighting process asynchronous. Unfortunately, `markdown-it` itself [does NOT support async highlighting](https://github.com/markdown-it/markdown-it/blob/master/docs/development.md#i-need-async-rule-how-to-do-it) out of the box.

To workaround this, you can use [`markdown-it-async`](https://github.com/antfu/markdown-it-async) by [Anthony Fu](https://github.com/antfu). Where Shiki also provides an integration with it, you can import `fromAsyncCodeToHtml` from `@shikijs/markdown-it/async`.

````ts twoslash
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async'
import MarkdownItAsync from 'markdown-it-async'
import { codeToHtml } from 'shiki' // Or your custom shorthand bundle
// Initialize MarkdownIt instance with markdown-it-async
const md = MarkdownItAsync()

md.use(
fromAsyncCodeToHtml(
// Pass the codeToHtml function
codeToHtml,
{
themes: {
light: 'vitesse-light',
dark: 'vitesse-dark',
}
}
)
)

// Use `md.renderAsync` instead of `md.render`
const html = await md.renderAsync('# Title\n```ts\nconsole.log("Hello, World!")\n```')
````
1 change: 1 addition & 0 deletions packages/markdown-it/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default defineBuildConfig({
entries: [
'src/index.ts',
'src/core.ts',
'src/async.ts',
],
declaration: true,
rollup: {
Expand Down
18 changes: 17 additions & 1 deletion packages/markdown-it/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"./core": {
"types": "./dist/core.d.mts",
"default": "./dist/core.mjs"
},
"./async": {
"types": "./dist/async.d.mts",
"default": "./dist/async.mjs"
}
},
"main": "./dist/index.mjs",
Expand All @@ -35,6 +39,9 @@
"core": [
"./dist/core.d.mts"
],
"async": [
"./dist/async.d.mts"
],
"*": [
"./dist/*",
"./*"
Expand All @@ -49,12 +56,21 @@
"dev": "unbuild --stub",
"prepublishOnly": "nr build"
},
"peerDependencies": {
"markdown-it-async": "catalog:"
},
"peerDependenciesMeta": {
"markdown-it-async": {
"optional": true
}
},
"dependencies": {
"markdown-it": "catalog:",
"shiki": "workspace:*"
},
"devDependencies": {
"@shikijs/transformers": "workspace:*",
"@types/markdown-it": "catalog:"
"@types/markdown-it": "catalog:",
"markdown-it-async": "catalog:"
}
}
72 changes: 72 additions & 0 deletions packages/markdown-it/src/async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { CodeToHastOptions, ShikiTransformer } from 'shiki'
import type { MarkdownItShikiSetupOptions } from './core'

export type { MarkdownItShikiExtraOptions, MarkdownItShikiSetupOptions } from './common'

export function setupMarkdownWithCodeToHtml(
markdownit: MarkdownItAsync,
codeToHtml: (code: string, options: CodeToHastOptions<any, any>) => Promise<string>,
options: MarkdownItShikiSetupOptions,
): void {
const {
parseMetaString,
trimEndingNewline = true,
defaultLanguage = 'text',
} = options

markdownit.options.highlight = async (code, lang = 'text', attrs) => {
if (lang === '') {
lang = defaultLanguage as string
}
const meta = parseMetaString?.(attrs, code, lang) || {}
const codeOptions: CodeToHastOptions = {
...options,
lang,
meta: {
...options.meta,
...meta,
__raw: attrs,
},
}

const builtInTransformer: ShikiTransformer[] = []

builtInTransformer.push({
name: '@shikijs/markdown-it:block-class',
code(node) {
node.properties.class = `language-${lang}`
},
})

if (trimEndingNewline) {
if (code.endsWith('\n'))
code = code.slice(0, -1)
}

return await codeToHtml(
code,
{
...codeOptions,
transformers: [
...builtInTransformer,
...codeOptions.transformers || [],
],
},
)
}
}

/**
* Create a markdown-it-async plugin from a codeToHtml function.
*
* This plugin requires to be installed against a markdown-it-async instance.
*/
export function fromAsyncCodeToHtml(
codeToHtml: (code: string, options: CodeToHastOptions<any, any>) => Promise<string>,
options: MarkdownItShikiSetupOptions,
) {
return async function (markdownit: MarkdownItAsync) {
return setupMarkdownWithCodeToHtml(markdownit, codeToHtml, options)
}
}
48 changes: 48 additions & 0 deletions packages/markdown-it/src/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {
BuiltinLanguage,
BuiltinTheme,
CodeOptionsMeta,
CodeOptionsThemes,
LanguageInput,
TransformerOptions,
} from 'shiki'

export interface MarkdownItShikiExtraOptions {
/**
* Custom meta string parser
* Return an object to merge with `meta`
*/
parseMetaString?: (
metaString: string,
code: string,
lang: string,
) => Record<string, any> | undefined | null

/**
* markdown-it's highlight function will add a trailing newline to the code.
*
* This integration removes the trailing newline to the code by default,
* you can turn this off by passing false.
*
* @default true
*/
trimEndingNewline?: boolean

/**
* When lang of code block is empty string, it will work.
*
* @default 'text'
*/
defaultLanguage?: LanguageInput | BuiltinLanguage

/**
* When lang of code block is not included in langs of options, it will be as a fallback lang.
*/
fallbackLanguage?: LanguageInput | BuiltinLanguage
}

export type MarkdownItShikiSetupOptions =
& CodeOptionsThemes<BuiltinTheme>
& TransformerOptions
& CodeOptionsMeta
& MarkdownItShikiExtraOptions
47 changes: 2 additions & 45 deletions packages/markdown-it/src/core.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,12 @@
import type MarkdownIt from 'markdown-it'
import type {
BuiltinLanguage,
BuiltinTheme,
CodeOptionsMeta,
CodeOptionsThemes,
CodeToHastOptions,
HighlighterGeneric,
LanguageInput,
ShikiTransformer,
TransformerOptions,
} from 'shiki'
import type { MarkdownItShikiSetupOptions } from './common'

export interface MarkdownItShikiExtraOptions {
/**
* Custom meta string parser
* Return an object to merge with `meta`
*/
parseMetaString?: (
metaString: string,
code: string,
lang: string,
) => Record<string, any> | undefined | null

/**
* markdown-it's highlight function will add a trailing newline to the code.
*
* This integration removes the trailing newline to the code by default,
* you can turn this off by passing false.
*
* @default true
*/
trimEndingNewline?: boolean

/**
* When lang of code block is empty string, it will work.
*
* @default 'text'
*/
defaultLanguage?: LanguageInput | BuiltinLanguage

/**
* When lang of code block is not included in langs of options, it will be as a fallback lang.
*/
fallbackLanguage?: LanguageInput | BuiltinLanguage
}

export type MarkdownItShikiSetupOptions =
& CodeOptionsThemes<BuiltinTheme>
& TransformerOptions
& CodeOptionsMeta
& MarkdownItShikiExtraOptions
export type { MarkdownItShikiExtraOptions, MarkdownItShikiSetupOptions } from './common'

export function setupMarkdownIt(
markdownit: MarkdownIt,
Expand Down
3 changes: 2 additions & 1 deletion packages/markdown-it/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type MarkdownIt from 'markdown-it'
import type { BuiltinLanguage, BuiltinTheme, LanguageInput } from 'shiki'
import type { MarkdownItShikiSetupOptions } from './core'
import type { MarkdownItShikiSetupOptions } from './common'
import { bundledLanguages, createHighlighter } from 'shiki'
import { setupMarkdownIt } from './core'

Expand All @@ -19,6 +19,7 @@ export default async function markdownItShiki(options: MarkdownItShikiOptions) {
const themeNames = ('themes' in options
? Object.values(options.themes)
: [options.theme]).filter(Boolean) as BuiltinTheme[]

const highlighter = await createHighlighter({
themes: themeNames,
langs: options.langs || Object.keys(bundledLanguages) as BuiltinLanguage[],
Expand Down
19 changes: 19 additions & 0 deletions packages/markdown-it/test/async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import fs from 'node:fs/promises'
import MarkdownItAsync from 'markdown-it-async'
import { codeToHtml } from 'shiki'
import { expect, it } from 'vitest'
import { fromAsyncCodeToHtml } from '../src/async'

it('async', { timeout: 10_000 }, async () => {
const md = MarkdownItAsync()
md.use(fromAsyncCodeToHtml(codeToHtml, {
themes: {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
}))

const result = await md.renderAsync(await fs.readFile(new URL('./fixtures/a.md', import.meta.url), 'utf-8'))

await expect(result).toMatchFileSnapshot('./fixtures/a.async.out.html')
})
6 changes: 6 additions & 0 deletions packages/markdown-it/test/fixtures/a.async.out.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ catalog:
'@unocss/reset': ^65.4.2
'@vitest/coverage-v8': ^3.0.2
'@vueuse/core': ^12.4.0
markdown-it-async: ^1.1.1
ansi-sequence-parser: ^1.1.1
bumpp: ^9.10.1
cac: ^6.7.14
Expand Down

0 comments on commit 4d59c8f

Please sign in to comment.