Skip to content

Commit

Permalink
feat(rehype): support fine-grain integration, close #64
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Dec 28, 2023
1 parent 2c33ada commit 890ef64
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 118 deletions.
41 changes: 40 additions & 1 deletion docs/packages/rehype.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeShikiji from 'rehype-shikiji'
import { expect, test } from 'vitest'

const file = await unified()
.use(remarkParse)
Expand All @@ -38,6 +37,46 @@ const file = await unified()
.process(await fs.readFile('./input.md'))
```

## Fine-grained Bundle

By default, the full bundle of `shikiji` will be imported. If you are Shikiji's [fine-grained bundle](/guide/install#fine-grained-bundle), you can import `rehypeShikijiFromHighlighter` from `rehype-shikiji/core` and pass your own highlighter:

```ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import rehypeShikijiFromHighlighter from 'rehype-shikiji/core'

import { fromHighlighter } from 'markdown-it-shikiji/core'
import { getHighlighterCore } from 'shikiji/core'
import { getWasmInlined } from 'shikiji/wasm'

const highlighter = await getHighlighterCore({
themes: [
import('shikiji/themes/vitesse-light.mjs')
],
langs: [
import('shikiji/langs/javascript.mjs'),
],
loadWasm: getWasmInlined
})

const raw = await fs.readFile('./input.md')
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeShikijiFromHighlighter, highlighter, {
// or `theme` for a single theme
themes: {
light: 'vitesse-light',
dark: 'vitesse-dark',
}
})
.use(rehypeStringify)
.processSync(raw) // it's also possible to process synchronously
```

## Features

### Line Highlight
Expand Down
1 change: 1 addition & 0 deletions packages/rehype-shikiji/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: [
'src/index.ts',
'src/core.ts',
],
declaration: true,
rollup: {
Expand Down
15 changes: 15 additions & 0 deletions packages/rehype-shikiji/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,26 @@
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./core": {
"types": "./dist/core.d.mts",
"default": "./dist/core.mjs"
}
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"typesVersions": {
"*": {
"core": [
"./dist/core.d.mts"
],
"*": [
"./dist/*",
"./*"
]
}
},
"files": [
"dist"
],
Expand Down
131 changes: 131 additions & 0 deletions packages/rehype-shikiji/src/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { addClassToHast } from 'shikiji/core'
import type { CodeOptionsMeta, CodeOptionsThemes, CodeToHastOptions, HighlighterGeneric, TransformerOptions } from 'shikiji/core'
import type { Element, Root } from 'hast'
import type { BuiltinTheme } from 'shikiji'
import type { Plugin } from 'unified'
import { toString } from 'hast-util-to-string'
import { visit } from 'unist-util-visit'
import { parseHighlightLines } from '../../shared/line-highlight'

export interface RehypeShikijiExtraOptions {
/**
* Add `highlighted` class to lines defined in after codeblock
*
* @default true
*/
highlightLines?: boolean | string

/**
* Add `language-*` class to code element
*
* @default false
*/
addLanguageClass?: boolean

/**
* Custom meta string parser
* Return an object to merge with `meta`
*/
parseMetaString?: (
metaString: string,
node: Element,
tree: Root
) => Record<string, any> | undefined | null
}

export type RehypeShikijiCoreOptions =
& CodeOptionsThemes<BuiltinTheme>
& TransformerOptions
& CodeOptionsMeta
& RehypeShikijiExtraOptions

const rehypeShikijiFromHighlighter: Plugin<[HighlighterGeneric<any, any>, RehypeShikijiCoreOptions], Root> = function (
highlighter,
options,
) {
const {
highlightLines = true,
addLanguageClass = false,
parseMetaString,
...rest
} = options

const prefix = 'language-'

return function (tree) {
visit(tree, 'element', (node, index, parent) => {
if (!parent || index == null || node.tagName !== 'pre')
return

const head = node.children[0]

if (
!head
|| head.type !== 'element'
|| head.tagName !== 'code'
|| !head.properties
)
return

const classes = head.properties.className

if (!Array.isArray(classes))
return

const language = classes.find(
d => typeof d === 'string' && d.startsWith(prefix),
)

if (typeof language !== 'string')
return

const code = toString(head as any)
const attrs = (head.data as any)?.meta
const meta = parseMetaString?.(attrs, node, tree) || {}

const codeOptions: CodeToHastOptions = {
...rest,
lang: language.slice(prefix.length),
meta: {
...rest.meta,
...meta,
__raw: attrs,
},
}

if (addLanguageClass) {
codeOptions.transformers ||= []
codeOptions.transformers.push({
name: 'rehype-shikiji:code-language-class',
code(node) {
addClassToHast(node, language)
return node
},
})
}

if (highlightLines && typeof attrs === 'string') {
const lines = parseHighlightLines(attrs)
if (lines) {
const className = highlightLines === true
? 'highlighted'
: highlightLines

codeOptions.transformers ||= []
codeOptions.transformers.push({
name: 'rehype-shikiji:line-class',
line(node, line) {
if (lines.includes(line))
addClassToHast(node, className)
return node
},
})
}
}
const fragment = highlighter.codeToHast(code, codeOptions)
parent.children.splice(index, 1, ...fragment.children)
})
}
}

export default rehypeShikijiFromHighlighter
135 changes: 18 additions & 117 deletions packages/rehype-shikiji/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,137 +1,38 @@
import type { BuiltinLanguage, BuiltinTheme, CodeOptionsMeta, CodeOptionsThemes, CodeToHastOptions, LanguageInput, TransformerOptions } from 'shikiji'
import { addClassToHast, bundledLanguages, getHighlighter } from 'shikiji'
import { toString } from 'hast-util-to-string'
import { visit } from 'unist-util-visit'
import type { LanguageInput } from 'shikiji/core'
import type { BuiltinLanguage, BuiltinTheme } from 'shikiji'
import { bundledLanguages, getHighlighter } from 'shikiji'
import type { Plugin } from 'unified'
import type { Element, Root } from 'hast'
import { parseHighlightLines } from '../../shared/line-highlight'
import type { Root } from 'hast'
import rehypeShikijiFromHighlighter from './core'
import type { RehypeShikijiCoreOptions } from './core'

export type RehypeShikijiOptions = CodeOptionsThemes<BuiltinTheme>
& TransformerOptions
& CodeOptionsMeta
export type RehypeShikijiOptions = RehypeShikijiCoreOptions
& {
/**
* Language names to include.
*
* @default Object.keys(bundledLanguages)
*/
langs?: Array<LanguageInput | BuiltinLanguage>

/**
* Add `highlighted` class to lines defined in after codeblock
*
* @default true
*/
highlightLines?: boolean | string

/**
* Add `language-*` class to code element
*
* @default false
*/
addLanguageClass?: boolean

/**
* Custom meta string parser
* Return an object to merge with `meta`
*/
parseMetaString?: (
metaString: string,
node: Element,
tree: Root
) => Record<string, any> | undefined | null
}

const rehypeShikiji: Plugin<[RehypeShikijiOptions], Root> = function (options = {} as any) {
const {
highlightLines = true,
addLanguageClass = false,
parseMetaString,
...rest
} = options

const prefix = 'language-'
const rehypeShikiji: Plugin<[RehypeShikijiOptions], Root> = function (
options = {} as any,
) {
const themeNames = ('themes' in options ? Object.values(options.themes) : [options.theme]).filter(Boolean) as BuiltinTheme[]
const langs = options.langs || Object.keys(bundledLanguages)

// eslint-disable-next-line ts/no-this-alias
const ctx = this
const promise = getHighlighter({
themes: themeNames,
langs: options.langs || Object.keys(bundledLanguages) as BuiltinLanguage[],
langs,
})
.then(highlighter => rehypeShikijiFromHighlighter.call(ctx, highlighter, options))

return async function (tree) {
const highlighter = await promise

visit(tree, 'element', (node, index, parent) => {
if (!parent || index == null || node.tagName !== 'pre')
return

const head = node.children[0]

if (
!head
|| head.type !== 'element'
|| head.tagName !== 'code'
|| !head.properties
)
return

const classes = head.properties.className

if (!Array.isArray(classes))
return

const language = classes.find(
d => typeof d === 'string' && d.startsWith(prefix),
)

if (typeof language !== 'string')
return

const code = toString(head as any)
const attrs = (head.data as any)?.meta
const meta = parseMetaString?.(attrs, node, tree) || {}

const codeOptions: CodeToHastOptions = {
...rest,
lang: language.slice(prefix.length),
meta: {
...rest.meta,
...meta,
__raw: attrs,
},
}

if (addLanguageClass) {
codeOptions.transformers ||= []
codeOptions.transformers.push({
name: 'rehype-shikiji:code-language-class',
code(node) {
addClassToHast(node, language)
return node
},
})
}

if (highlightLines && typeof attrs === 'string') {
const lines = parseHighlightLines(attrs)
if (lines) {
const className = highlightLines === true
? 'highlighted'
: highlightLines

codeOptions.transformers ||= []
codeOptions.transformers.push({
name: 'rehype-shikiji:line-class',
line(node, line) {
if (lines.includes(line))
addClassToHast(node, className)
return node
},
})
}
}
const fragment = highlighter.codeToHast(code, codeOptions)
parent.children.splice(index, 1, ...fragment.children)
})
const handler = await promise as any
return handler!(tree) as Root
}
}

Expand Down
Loading

0 comments on commit 890ef64

Please sign in to comment.