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(shiki): Support italic, bold and underline styles #2079

Merged
merged 2 commits into from
Jun 9, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion playground/shared/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default defineNuxtConfig({
fields: ['icon']
},
highlight: {
theme: 'one-dark-pro',
theme: 'material-palenight',
preload: ['json', 'js', 'ts', 'html', 'css', 'vue']
}
}
Expand Down
120 changes: 88 additions & 32 deletions src/runtime/transformers/shiki/highlighter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getHighlighter, BUNDLED_LANGUAGES, BUNDLED_THEMES, Lang, Theme as ShikiTheme, Highlighter } from 'shiki-es'
import { getHighlighter, BUNDLED_LANGUAGES, BUNDLED_THEMES, Lang, Theme as ShikiTheme, Highlighter, FontStyle } from 'shiki-es'
import { consola } from 'consola'
import type { ModuleOptions } from '../../../module'
import { createSingleton } from '../utils'
import mdcTMLanguage from './languages/mdc.tmLanguage'
import type { MarkdownNode, HighlighterOptions, Theme, HighlightThemedToken, HighlightThemedTokenLine, TokenColorMap } from './types'
import type { MarkdownNode, HighlighterOptions, Theme, HighlightThemedToken, HighlightThemedTokenLine, TokenStyleMap, HighlightThemedTokenStyle } from './types'

// Re-create logger locally as utils cannot be imported from here
const logger = consola.withTag('@nuxt/content')
Expand Down Expand Up @@ -119,6 +119,15 @@ export const useShikiHighlighter = createSingleton((opts?: Exclude<ModuleOptions
// Highlight code
const coloredTokens = Object.entries(theme).map(([key, theme]) => {
const tokens = highlighter.codeToThemedTokens(code, lang, theme, { includeExplanation: false })
.map(line => line.map(token => ({
content: token.content,
style: {
[key]: {
color: token.color,
fontStyle: token.fontStyle
}
}
})))
return {
key,
theme,
Expand All @@ -144,7 +153,7 @@ export const useShikiHighlighter = createSingleton((opts?: Exclude<ModuleOptions

const getHighlightedAST = async (code: string, lang: Lang, theme: Theme, opts?: Partial<HighlighterOptions>): Promise<Array<MarkdownNode>> => {
const lines = await getHighlightedTokens(code, lang, theme)
const { highlights = [], colorMap = {} } = opts || {}
const { highlights = [], styleMap = {} } = opts || {}

return lines.map((line, lineIndex) => {
// Add line break to all lines except last
Expand All @@ -167,37 +176,40 @@ export const useShikiHighlighter = createSingleton((opts?: Exclude<ModuleOptions
}
})

function getColorProps (token: { color?: string | object }) {
if (!token.color) {
function getSpanProps (token: HighlightThemedToken) {
if (!token.style) {
return {}
}
if (typeof token.color === 'string') {
return { style: { color: token.color } }
}
const key = Object.values(token.color).join('')
if (!colorMap[key]) {
colorMap[key] = {
colors: token.color,
className: 'ct-' + Math.random().toString(16).substring(2, 8) // hash(key)
// TODO: generate unique key for style
// Or simply using `JSON.stringify(token.style)` would be easier to understand,
// but not sure about the impact on performance
const key = Object.values(token.style).map(themeStyle => Object.values(themeStyle).join('')).join('')
if (!styleMap[key]) {
styleMap[key] = {
style: token.style,
// Using the hash value of the style as the className,
// ensure that the className remains stable over multiple compilations,
// which facilitates content caching.
className: 'ct-' + hash(key)
}
}
return { class: colorMap[key].className }
return { class: styleMap[key].className }
}

function tokenSpan (token: { content: string, color?: string | object }) {
function tokenSpan (token: HighlightThemedToken) {
return {
type: 'element',
tag: 'span',
props: getColorProps(token),
props: getSpanProps(token),
children: [{ type: 'text', value: token.content }]
}
}
}

const getHighlightedCode = async (code: string, lang: Lang, theme: Theme, opts?: Partial<HighlighterOptions>) => {
const colorMap = opts?.colorMap || {}
const styleMap = opts?.styleMap || {}
const highlights = opts?.highlights || []
const ast = await getHighlightedAST(code, lang, theme, { colorMap, highlights })
const ast = await getHighlightedAST(code, lang, theme, { styleMap, highlights })

function renderNode (node: any) {
if (node.type === 'text') {
Expand All @@ -209,22 +221,44 @@ export const useShikiHighlighter = createSingleton((opts?: Exclude<ModuleOptions

return {
code: ast.map(renderNode).join(''),
styles: generateStyles(colorMap)
styles: generateStyles(styleMap)
}
}

const generateStyles = (colorMap: TokenColorMap) => {
const colors: string[] = []
for (const colorClass of Object.values(colorMap)) {
Object.entries(colorClass.colors).forEach(([variant, color]) => {
if (variant === 'default') {
colors.unshift(`.${colorClass.className}{color:${color}}`)
} else {
colors.push(`.${variant} .${colorClass.className}{color:${color}}`)
const generateStyles = (styleMap: TokenStyleMap) => {
const styles: string[] = []
for (const styleToken of Object.values(styleMap)) {
const defaultStyle = styleToken.style.default
const hasColor = !!defaultStyle?.color
const hasBold = isBold(defaultStyle)
const hasItalic = isItalic(defaultStyle)
const hasUnderline = isUnderline(defaultStyle)
const themeStyles = Object.entries(styleToken.style).map(([variant, style]) => {
const styleText = [
// If the default theme has a style, but the current theme does not have one,
// we need to override to reset style
['color', style.color || (hasColor ? 'unset' : '')],
['font-weight', isBold(style) ? 'bold' : hasBold ? 'unset' : ''],
['font-style', isItalic(style) ? 'italic' : hasItalic ? 'unset' : ''],
['text-decoration', isUnderline(style) ? 'bold' : hasUnderline ? 'unset' : '']
]
.filter(kv => kv[1])
.map(kv => kv.join(':') + ';')
.join('')
return { variant, styleText }
})

const defaultThemeStyle = themeStyles.find(themeStyle => themeStyle.variant === 'default')
themeStyles.forEach((themeStyle) => {
if (themeStyle.variant === 'default') {
styles.push(`.${styleToken.className}{${themeStyle.styleText}}`)
} else if (themeStyle.styleText !== defaultThemeStyle?.styleText) {
// Skip if same as default theme
styles.push(`.${themeStyle.variant} .${styleToken.className}{${themeStyle.styleText}}`)
}
})
}
return colors.join('\n')
return styles.join('\n')
}

return {
Expand All @@ -237,7 +271,6 @@ export const useShikiHighlighter = createSingleton((opts?: Exclude<ModuleOptions

function mergeLines (line1: HighlightThemedTokenLine, line2: HighlightThemedTokenLine) {
const mergedTokens: HighlightThemedToken[] = []
const getColors = (h: HighlightThemedTokenLine, i: number) => typeof h.tokens[i].color === 'string' ? { [h.key]: h.tokens[i].color } : h.tokens[i].color as object

const right = {
key: line1.key,
Expand All @@ -255,9 +288,9 @@ function mergeLines (line1: HighlightThemedTokenLine, line2: HighlightThemedToke
if (rightToken.content === leftToken.content) {
mergedTokens.push({
content: rightToken.content,
color: {
...getColors(right, index),
...getColors(left, index)
style: {
...right.tokens[index].style,
...left.tokens[index].style
}
})
index += 1
Expand Down Expand Up @@ -288,3 +321,26 @@ function mergeLines (line1: HighlightThemedTokenLine, line2: HighlightThemedToke
}
return mergedTokens
}

function isBold (style?: HighlightThemedTokenStyle) {
return style && style.fontStyle === FontStyle.Bold
}

function isItalic (style?: HighlightThemedTokenStyle) {
return style && style.fontStyle === FontStyle.Italic
}

function isUnderline (style?: HighlightThemedTokenStyle) {
return style && style.fontStyle === FontStyle.Underline
}

/**
* An insecure but simple and fast hash method.
* https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=4261728#gistcomment-4261728
*/
function hash (str: string) {
return Array.from(str)
.reduce((s, c) => Math.imul(31, s) + c.charCodeAt(0) | 0, 0)
.toString()
.slice(-6)
}
20 changes: 10 additions & 10 deletions src/runtime/transformers/shiki/shiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { visit } from 'unist-util-visit'
import type { MarkdownRoot } from '../../types'
import { defineTransformer } from '../utils'
import { useShikiHighlighter } from './highlighter'
import type { TokenColorMap, MarkdownNode } from './types'
import type { MarkdownNode, TokenStyleMap } from './types'

export default defineTransformer({
name: 'highlight',
Expand All @@ -25,7 +25,7 @@ export default defineTransformer({
if (!document) {
return
}
const colorMap: TokenColorMap = {}
const styleMap: TokenStyleMap = {}
const codeBlocks: any[] = []
const inlineCodes: any = []
visit(
Expand All @@ -40,27 +40,27 @@ export default defineTransformer({
}
)

await Promise.all(codeBlocks.map((node: MarkdownNode) => highlightBlock(node, colorMap)))
await Promise.all(inlineCodes.map((node: MarkdownNode) => highlightInline(node, colorMap)))
await Promise.all(codeBlocks.map((node: MarkdownNode) => highlightBlock(node, styleMap)))
await Promise.all(inlineCodes.map((node: MarkdownNode) => highlightInline(node, styleMap)))

// Inject token colors at the end of the document
if (Object.values(colorMap).length) {
if (Object.values(styleMap).length) {
document?.children.push({
type: 'element',
tag: 'style',
children: [{ type: 'text', value: shikiHighlighter.generateStyles(colorMap) }]
children: [{ type: 'text', value: shikiHighlighter.generateStyles(styleMap) }]
})
}
}

/**
* Highlight inline code
*/
async function highlightInline (node: MarkdownNode, colorMap: TokenColorMap) {
async function highlightInline (node: MarkdownNode, styleMap: TokenStyleMap) {
const code = node.children![0].value!

// Fetch highlighted tokens
const lines = await shikiHighlighter.getHighlightedAST(code, node.props!.lang || node.props!.language, options.theme, { colorMap })
const lines = await shikiHighlighter.getHighlightedAST(code, node.props!.lang || node.props!.language, options.theme, { styleMap })

// Generate highlighted children
node.children = lines[0].children
Expand All @@ -72,11 +72,11 @@ export default defineTransformer({
/**
* Highlight a code block
*/
async function highlightBlock (node: MarkdownNode, colorMap: TokenColorMap) {
async function highlightBlock (node: MarkdownNode, styleMap: TokenStyleMap) {
const { code, language: lang, highlights = [] } = node.props!

const innerCodeNode = node.children![0].children![0]
innerCodeNode.children = await shikiHighlighter.getHighlightedAST(code, lang, options.theme, { colorMap, highlights })
innerCodeNode.children = await shikiHighlighter.getHighlightedAST(code, lang, options.theme, { styleMap, highlights })

return node
}
Expand Down
13 changes: 9 additions & 4 deletions src/runtime/transformers/shiki/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Theme as ShikiTheme } from 'shiki-es'
import { Theme as ShikiTheme, IThemedToken } from 'shiki-es'
export type { MarkdownNode } from '../../types'

export type Theme = ShikiTheme | Record<string, ShikiTheme>

export type TokenColorMap = Record<string, {colors: any, className: string}>
export type HighlightThemedTokenStyle = Pick<IThemedToken, 'color' | 'fontStyle'>

export type TokenStyleMap = Record<string, {
style: Record<string, HighlightThemedTokenStyle>
className: string
}>

export interface HighlightParams {
code: string
Expand All @@ -12,13 +17,13 @@ export interface HighlightParams {
}

export interface HighlighterOptions {
colorMap: TokenColorMap
styleMap: TokenStyleMap
highlights: Array<number>
}

export interface HighlightThemedToken {
content: string
color?: string | Record<string, string>
style?: Record<string, HighlightThemedTokenStyle>
}

export interface HighlightThemedTokenLine {
Expand Down