Skip to content

Commit 634face

Browse files
committedFeb 17, 2025
feat(core): expose flatTokenVariants utility, refactor utils folder
1 parent d619571 commit 634face

File tree

10 files changed

+341
-340
lines changed

10 files changed

+341
-340
lines changed
 

‎packages/core/src/highlight/code-to-tokens.ts

+3-40
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { CodeToTokensOptions, GrammarState, ShikiInternal, ThemedToken, ThemedTokenWithVariants, TokensResult } from '@shikijs/types'
1+
import type { CodeToTokensOptions, GrammarState, ShikiInternal, ThemedToken, TokensResult } from '@shikijs/types'
22
import { ShikiError } from '@shikijs/types'
33
import { getLastGrammarStateFromMap, setLastGrammarStateToMap } from '../textmate/grammar-state'
4-
import { applyColorReplacements, getTokenStyleObject, resolveColorReplacements } from '../utils'
4+
import { applyColorReplacements, flatTokenVariants, resolveColorReplacements } from '../utils'
55
import { codeToTokensBase } from './code-to-tokens-base'
66
import { codeToTokensWithThemes } from './code-to-tokens-themes'
77

@@ -51,7 +51,7 @@ export function codeToTokens(
5151
const themeRegs = themes.map(t => internal.getTheme(t.theme))
5252
const themesOrder = themes.map(t => t.color)
5353
tokens = themeTokens
54-
.map(line => line.map(token => mergeToken(token, themesOrder, cssVariablePrefix, defaultColor)))
54+
.map(line => line.map(token => flatTokenVariants(token, themesOrder, cssVariablePrefix, defaultColor)))
5555

5656
if (grammarState)
5757
setLastGrammarStateToMap(tokens, grammarState)
@@ -95,40 +95,3 @@ export function codeToTokens(
9595
grammarState,
9696
}
9797
}
98-
99-
function mergeToken(
100-
merged: ThemedTokenWithVariants,
101-
variantsOrder: string[],
102-
cssVariablePrefix: string,
103-
defaultColor: string | boolean,
104-
): ThemedToken {
105-
const token: ThemedToken = {
106-
content: merged.content,
107-
explanation: merged.explanation,
108-
offset: merged.offset,
109-
}
110-
111-
const styles = variantsOrder.map(t => getTokenStyleObject(merged.variants[t]))
112-
113-
// Get all style keys, for themes that missing some style, we put `inherit` to override as needed
114-
const styleKeys = new Set(styles.flatMap(t => Object.keys(t)))
115-
const mergedStyles: Record<string, string> = {}
116-
117-
styles.forEach((cur, idx) => {
118-
for (const key of styleKeys) {
119-
const value = cur[key] || 'inherit'
120-
121-
if (idx === 0 && defaultColor) {
122-
mergedStyles[key] = value
123-
}
124-
else {
125-
const keyName = key === 'color' ? '' : key === 'background-color' ? '-bg' : `-${key}`
126-
const varKey = cssVariablePrefix + variantsOrder[idx] + (key === 'color' ? '' : keyName)
127-
mergedStyles[varKey] = value
128-
}
129-
}
130-
})
131-
132-
token.htmlStyle = mergedStyles
133-
return token
134-
}

‎packages/core/src/textmate/grammar-state.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { GrammarState as GrammarStateInterface, GrammarStateMapKey } from '@shikijs/types'
22
import type { StateStack, StateStackImpl } from '@shikijs/vscode-textmate'
33

4+
import { ShikiError } from '@shikijs/types'
45
import { INITIAL } from '@shikijs/vscode-textmate'
5-
import { ShikiError } from '../../../types/src/error'
66
import { toArray } from '../utils'
77
import { warnDeprecated } from '../warn'
88

‎packages/core/src/transformer-decorations.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
ShikiTransformerContextSource,
1010
} from '@shikijs/types'
1111
import type { Element, ElementContent } from 'hast'
12-
import { ShikiError } from '../../types/src/error'
12+
import { ShikiError } from '@shikijs/types'
1313
import { addClassToHast, createPositionConverter, splitTokens } from './utils'
1414

1515
interface TransformerDecorationsInternalContext {

‎packages/core/src/utils.ts

-298
This file was deleted.

‎packages/core/src/utils/colors.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { ThemeRegistrationAny, TokenizeWithThemeOptions } from '@shikijs/types'
2+
3+
export function resolveColorReplacements(
4+
theme: ThemeRegistrationAny | string,
5+
options?: TokenizeWithThemeOptions,
6+
): Record<string, string | undefined> {
7+
const replacements = typeof theme === 'string' ? {} : { ...theme.colorReplacements }
8+
const themeName = typeof theme === 'string' ? theme : theme.name
9+
for (const [key, value] of Object.entries(options?.colorReplacements || {})) {
10+
if (typeof value === 'string')
11+
replacements[key] = value
12+
else if (key === themeName)
13+
Object.assign(replacements, value)
14+
}
15+
return replacements
16+
}
17+
18+
export function applyColorReplacements(color: string, replacements?: Record<string, string | undefined>): string
19+
export function applyColorReplacements(color?: string | undefined, replacements?: Record<string, string | undefined>): string | undefined
20+
export function applyColorReplacements(color?: string, replacements?: Record<string, string | undefined>): string | undefined {
21+
if (!color)
22+
return color
23+
return replacements?.[color?.toLowerCase()] || color
24+
}

‎packages/core/src/utils/general.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type {
2+
MaybeArray,
3+
MaybeGetter,
4+
PlainTextLanguage,
5+
SpecialLanguage,
6+
SpecialTheme,
7+
ThemeInput,
8+
} from '@shikijs/types'
9+
10+
export function toArray<T>(x: MaybeArray<T>): T[] {
11+
return Array.isArray(x) ? x : [x]
12+
}
13+
14+
/**
15+
* Normalize a getter to a promise.
16+
*/
17+
export async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> {
18+
return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then(r => r.default || r)
19+
}
20+
21+
/**
22+
* Check if the language is plaintext that is ignored by Shiki.
23+
*
24+
* Hard-coded plain text languages: `plaintext`, `txt`, `text`, `plain`.
25+
*/
26+
export function isPlainLang(lang: string | null | undefined): lang is PlainTextLanguage {
27+
return !lang || ['plaintext', 'txt', 'text', 'plain'].includes(lang)
28+
}
29+
30+
/**
31+
* Check if the language is specially handled or bypassed by Shiki.
32+
*
33+
* Hard-coded languages: `ansi` and plaintexts like `plaintext`, `txt`, `text`, `plain`.
34+
*/
35+
export function isSpecialLang(lang: any): lang is SpecialLanguage {
36+
return lang === 'ansi' || isPlainLang(lang)
37+
}
38+
39+
/**
40+
* Check if the theme is specially handled or bypassed by Shiki.
41+
*
42+
* Hard-coded themes: `none`.
43+
*/
44+
export function isNoneTheme(theme: string | ThemeInput | null | undefined): theme is 'none' {
45+
return theme === 'none'
46+
}
47+
48+
/**
49+
* Check if the theme is specially handled or bypassed by Shiki.
50+
*
51+
* Hard-coded themes: `none`.
52+
*/
53+
export function isSpecialTheme(theme: string | ThemeInput | null | undefined): theme is SpecialTheme {
54+
return isNoneTheme(theme)
55+
}

‎packages/core/src/utils/hast.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Element } from 'hast'
2+
3+
/**
4+
* Utility to append class to a hast node
5+
*
6+
* If the `property.class` is a string, it will be splitted by space and converted to an array.
7+
*/
8+
export function addClassToHast(node: Element, className: string | string[]): Element {
9+
if (!className)
10+
return node
11+
node.properties ||= {}
12+
node.properties.class ||= []
13+
if (typeof node.properties.class === 'string')
14+
node.properties.class = node.properties.class.split(/\s+/g)
15+
if (!Array.isArray(node.properties.class))
16+
node.properties.class = []
17+
18+
const targets = Array.isArray(className) ? className : className.split(/\s+/g)
19+
for (const c of targets) {
20+
if (c && !node.properties.class.includes(c))
21+
node.properties.class.push(c)
22+
}
23+
return node
24+
}

‎packages/core/src/utils/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './colors'
2+
export * from './general'
3+
export * from './hast'
4+
export * from './strings'
5+
export * from './tokens'

‎packages/core/src/utils/strings.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { HighlighterGeneric, Position } from '@shikijs/types'
2+
3+
/**
4+
* Split a string into lines, each line preserves the line ending.
5+
*/
6+
export function splitLines(code: string, preserveEnding = false): [string, number][] {
7+
const parts = code.split(/(\r?\n)/g)
8+
let index = 0
9+
const lines: [string, number][] = []
10+
for (let i = 0; i < parts.length; i += 2) {
11+
const line = preserveEnding
12+
? parts[i] + (parts[i + 1] || '')
13+
: parts[i]
14+
lines.push([line, index])
15+
index += parts[i].length
16+
index += parts[i + 1]?.length || 0
17+
}
18+
return lines
19+
}
20+
21+
/**
22+
* Creates a converter between index and position in a code block.
23+
*
24+
* Overflow/underflow are unchecked.
25+
*/
26+
export function createPositionConverter(code: string): {
27+
lines: string[]
28+
indexToPos: (index: number) => Position
29+
posToIndex: (line: number, character: number) => number
30+
} {
31+
const lines = splitLines(code, true).map(([line]) => line)
32+
33+
function indexToPos(index: number): Position {
34+
if (index === code.length) {
35+
return {
36+
line: lines.length - 1,
37+
character: lines[lines.length - 1].length,
38+
}
39+
}
40+
41+
let character = index
42+
let line = 0
43+
for (const lineText of lines) {
44+
if (character < lineText.length)
45+
break
46+
character -= lineText.length
47+
line++
48+
}
49+
return { line, character }
50+
}
51+
52+
function posToIndex(line: number, character: number): number {
53+
let index = 0
54+
for (let i = 0; i < line; i++)
55+
index += lines[i].length
56+
57+
index += character
58+
return index
59+
}
60+
61+
return {
62+
lines,
63+
indexToPos,
64+
posToIndex,
65+
}
66+
}
67+
68+
/**
69+
* Guess embedded languages from given code and highlighter.
70+
*
71+
* When highlighter is provided, only bundled languages will be included.
72+
*/
73+
export function guessEmbeddedLanguages(
74+
code: string,
75+
_lang: string | undefined,
76+
highlighter?: HighlighterGeneric<any, any>,
77+
): string[] {
78+
const langs = new Set<string>()
79+
// For HTML code blocks like Vue SFC
80+
for (const match of code.matchAll(/lang=["']([\w-]+)["']/g)) {
81+
langs.add(match[1])
82+
}
83+
// For markdown code blocks
84+
for (const match of code.matchAll(/(?:```|~~~)([\w-]+)/g)) {
85+
langs.add(match[1])
86+
}
87+
// For latex
88+
for (const match of code.matchAll(/\\begin\{([\w-]+)\}/g)) {
89+
langs.add(match[1])
90+
}
91+
92+
if (!highlighter)
93+
return Array.from(langs)
94+
95+
// Only include known languages
96+
const bundle = highlighter.getBundledLanguages()
97+
return Array.from(langs)
98+
.filter(l => l && bundle[l])
99+
}

‎packages/core/src/utils/tokens.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { ThemedToken, ThemedTokenWithVariants, TokenStyles } from '@shikijs/types'
2+
import { FontStyle } from '@shikijs/vscode-textmate'
3+
4+
/**
5+
* Split a token into multiple tokens by given offsets.
6+
*
7+
* The offsets are relative to the token, and should be sorted.
8+
*/
9+
export function splitToken<
10+
T extends Pick<ThemedToken, 'content' | 'offset'>,
11+
>(
12+
token: T,
13+
offsets: number[],
14+
): T[] {
15+
let lastOffset = 0
16+
const tokens: T[] = []
17+
18+
for (const offset of offsets) {
19+
if (offset > lastOffset) {
20+
tokens.push({
21+
...token,
22+
content: token.content.slice(lastOffset, offset),
23+
offset: token.offset + lastOffset,
24+
})
25+
}
26+
lastOffset = offset
27+
}
28+
29+
if (lastOffset < token.content.length) {
30+
tokens.push({
31+
...token,
32+
content: token.content.slice(lastOffset),
33+
offset: token.offset + lastOffset,
34+
})
35+
}
36+
37+
return tokens
38+
}
39+
40+
/**
41+
* Split 2D tokens array by given breakpoints.
42+
*/
43+
export function splitTokens<
44+
T extends Pick<ThemedToken, 'content' | 'offset'>,
45+
>(
46+
tokens: T[][],
47+
breakpoints: number[] | Set<number>,
48+
): T[][] {
49+
const sorted = Array
50+
.from(breakpoints instanceof Set ? breakpoints : new Set(breakpoints))
51+
.sort((a, b) => a - b)
52+
53+
if (!sorted.length)
54+
return tokens
55+
56+
return tokens.map((line) => {
57+
return line.flatMap((token) => {
58+
const breakpointsInToken = sorted
59+
.filter(i => token.offset < i && i < token.offset + token.content.length)
60+
.map(i => i - token.offset)
61+
.sort((a, b) => a - b)
62+
63+
if (!breakpointsInToken.length)
64+
return token
65+
66+
return splitToken(token, breakpointsInToken)
67+
})
68+
})
69+
}
70+
71+
export function flatTokenVariants(
72+
merged: ThemedTokenWithVariants,
73+
variantsOrder: string[],
74+
cssVariablePrefix: string,
75+
defaultColor: string | boolean,
76+
): ThemedToken {
77+
const token: ThemedToken = {
78+
content: merged.content,
79+
explanation: merged.explanation,
80+
offset: merged.offset,
81+
}
82+
83+
const styles = variantsOrder.map(t => getTokenStyleObject(merged.variants[t]))
84+
85+
// Get all style keys, for themes that missing some style, we put `inherit` to override as needed
86+
const styleKeys = new Set(styles.flatMap(t => Object.keys(t)))
87+
const mergedStyles: Record<string, string> = {}
88+
89+
styles.forEach((cur, idx) => {
90+
for (const key of styleKeys) {
91+
const value = cur[key] || 'inherit'
92+
93+
if (idx === 0 && defaultColor) {
94+
mergedStyles[key] = value
95+
}
96+
else {
97+
const keyName = key === 'color' ? '' : key === 'background-color' ? '-bg' : `-${key}`
98+
const varKey = cssVariablePrefix + variantsOrder[idx] + (key === 'color' ? '' : keyName)
99+
mergedStyles[varKey] = value
100+
}
101+
}
102+
})
103+
104+
token.htmlStyle = mergedStyles
105+
return token
106+
}
107+
108+
export function getTokenStyleObject(token: TokenStyles): Record<string, string> {
109+
const styles: Record<string, string> = {}
110+
if (token.color)
111+
styles.color = token.color
112+
if (token.bgColor)
113+
styles['background-color'] = token.bgColor
114+
if (token.fontStyle) {
115+
if (token.fontStyle & FontStyle.Italic)
116+
styles['font-style'] = 'italic'
117+
if (token.fontStyle & FontStyle.Bold)
118+
styles['font-weight'] = 'bold'
119+
if (token.fontStyle & FontStyle.Underline)
120+
styles['text-decoration'] = 'underline'
121+
}
122+
return styles
123+
}
124+
125+
export function stringifyTokenStyle(token: string | Record<string, string>): string {
126+
if (typeof token === 'string')
127+
return token
128+
return Object.entries(token).map(([key, value]) => `${key}:${value}`).join(';')
129+
}

0 commit comments

Comments
 (0)
Please sign in to comment.