Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(rehype): support lazy load languages #787

Merged
merged 8 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 67 additions & 55 deletions packages/rehype/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type {
CodeToHastOptions,
HighlighterGeneric,
} from '@shikijs/types'
import type { Element, Root } from 'hast'
import type { Root } from 'hast'
import type { Transformer } from 'unified'
import type { RehypeShikiHandler } from './handlers'
import type { RehypeShikiCoreOptions } from './types'
import { toString } from 'hast-util-to-string'
import { visit } from 'unist-util-visit'
import { InlineCodeProcessors } from './inline'
import { InlineCodeHandlers, PreHandler } from './handlers'

export * from './types'

Expand All @@ -17,7 +17,6 @@ function rehypeShikiFromHighlighter(
highlighter: HighlighterGeneric<any, any>,
options: RehypeShikiCoreOptions,
): Transformer<Root, Root> {
const langs = highlighter.getLoadedLanguages()
const {
addLanguageClass = false,
parseMetaString,
Expand All @@ -27,18 +26,10 @@ function rehypeShikiFromHighlighter(
onError,
stripEndNewline = true,
inline = false,
lazy = false,
...rest
} = options

/**
* Get the determined language of code block (with default language & fallbacks)
*/
function getLanguage(lang = defaultLanguage): string | undefined {
if (lang && fallbackLanguage && !langs.includes(lang))
return fallbackLanguage
return lang
}

function highlight(
lang: string,
code: string,
Expand Down Expand Up @@ -92,65 +83,86 @@ function rehypeShikiFromHighlighter(
}
}

function processPre(tree: Root, node: Element): Root | undefined {
const head = node.children[0]

if (
!head
|| head.type !== 'element'
|| head.tagName !== 'code'
|| !head.properties
) {
return
}
return (tree) => {
// use this queue if lazy is enabled
const languageQueue: string[] = []
const queue: (() => void)[] = []

const classes = head.properties.className
const languageClass = Array.isArray(classes)
? classes.find(
d => typeof d === 'string' && d.startsWith(languagePrefix),
)
: undefined
function getLanguage(lang: string | undefined): string | undefined {
if (!lang)
return defaultLanguage

const lang = getLanguage(
typeof languageClass === 'string'
? languageClass.slice(languagePrefix.length)
: undefined,
)
if (highlighter.getLoadedLanguages().includes(lang))
return lang

if (!lang)
return

const code = toString(head)
const metaString = head.data?.meta ?? head.properties.metastring?.toString() ?? ''
const meta = parseMetaString?.(metaString, node, tree) || {}
if (lazy) {
languageQueue.push(lang)
return lang
}

return highlight(lang, code, metaString, meta)
}
if (fallbackLanguage)
return fallbackLanguage
}

return function (tree) {
visit(tree, 'element', (node, index, parent) => {
let handler: RehypeShikiHandler | undefined

// needed for hast node replacement
if (!parent || index == null)
return

if (node.tagName === 'pre') {
const result = processPre(tree, node)

if (result) {
parent.children.splice(index, 1, ...result.children)
}

// don't look for the `code` node inside
return 'skip'
handler = PreHandler
}

if (node.tagName === 'code' && inline) {
const result = InlineCodeProcessors[inline]?.({ node, getLanguage, highlight })
if (result) {
parent.children.splice(index, 1, ...result.children)
handler = InlineCodeHandlers[inline]
}

if (!handler)
return

const res = handler(tree, node)
if (!res)
return

const lang = getLanguage(res.lang)
if (!lang)
return

const processNode = (): void => {
const meta = res.meta ? parseMetaString?.(res.meta, node, tree) : undefined

const fragment = highlight(lang, res.code, res.meta, meta ?? {})
if (!fragment)
return

if (res.type === 'inline') {
const head = fragment.children[0]
if (head.type === 'element' && head.tagName === 'pre') {
head.tagName = 'span'
}
}

parent.children.splice(index, 1, ...fragment.children)
}

if (lazy)
queue.push(processNode)
else
processNode()

// don't visit processed nodes
return 'skip'
})

if (lazy) {
return highlighter
.loadLanguage(...languageQueue)
.then(() => {
queue.forEach(fn => fn())
})
}
}
}

Expand Down
61 changes: 61 additions & 0 deletions packages/rehype/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Element, Root } from 'hast'
import type { RehypeShikiCoreOptions } from './types'
import { toString } from 'hast-util-to-string'

type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T

export type RehypeShikiHandler = (
tree: Root,
node: Element
) => {
type: 'inline' | 'pre'
meta?: string
lang?: string
code: string
} | undefined

export const InlineCodeHandlers: Record<Truthy<RehypeShikiCoreOptions['inline']>, RehypeShikiHandler> = {
'tailing-curly-colon': (_tree, node) => {
const raw = toString(node)
const match = raw.match(/(.+)\{:([\w-]+)\}$/)
if (!match)
return

return {
type: 'inline',
code: match[1] ?? raw,
lang: match.at(2),
}
},
}

const languagePrefix = 'language-'

export const PreHandler: RehypeShikiHandler = (_tree, node) => {
const head = node.children[0]

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

const classes = head.properties.className
const languageClass = Array.isArray(classes)
? classes.find(
d => typeof d === 'string' && d.startsWith(languagePrefix),
)
: undefined

return {
type: 'pre',
lang: typeof languageClass === 'string'
? languageClass.slice(languagePrefix.length)
: undefined,
code: toString(head),
meta: head.data?.meta ?? head.properties.metastring?.toString() ?? '',
}
}
42 changes: 0 additions & 42 deletions packages/rehype/src/inline.ts

This file was deleted.

10 changes: 10 additions & 0 deletions packages/rehype/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,19 @@ export interface RehypeShikiExtraOptions {

/**
* The fallback language to use when specified language is not loaded
*
* Ignored if `lazy` is enabled
*/
fallbackLanguage?: string

/**
* Load languages and themes on-demand.
* When enable, this would make requires the unified pipeline to be async.
*
* @default false
*/
lazy?: boolean

/**
* `mdast-util-to-hast` adds a newline to the end of code blocks
*
Expand Down
25 changes: 25 additions & 0 deletions packages/rehype/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ it('run', async () => {
expect(file.toString()).toMatchFileSnapshot('./fixtures/a.core.out.html')
})

it('run with lazy', async () => {
const highlighter = await createHighlighter({
themes: [
'vitesse-light',
],
langs: [],
fuma-nama marked this conversation as resolved.
Show resolved Hide resolved
})

const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeShikiFromHighlighter, highlighter, {
lazy: true,
theme: 'vitesse-light',
defaultLanguage: 'text',
transformers: [
transformerMetaHighlight(),
],
})
.use(rehypeStringify)
.process(await fs.readFile(new URL('./fixtures/a.md', import.meta.url)))

expect(file.toString()).toMatchFileSnapshot('./fixtures/a.core.out.html')
})

it('run with rehype-raw', async () => {
const highlighter = await createHighlighter({
themes: [
Expand Down
Loading