Skip to content

Commit

Permalink
feat: support grammar injection (#48)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu authored Dec 16, 2023
1 parent 0294ad4 commit c176b23
Show file tree
Hide file tree
Showing 12 changed files with 344 additions and 198 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"lint-staged": "^15.2.0",
"markdown-it": "^14.0.0",
"markdown-it-shikiji": "workspace:*",
"ofetch": "^1.3.3",
"pnpm": "^8.12.1",
"prettier": "^3.1.1",
"rimraf": "^5.0.5",
Expand Down
33 changes: 25 additions & 8 deletions packages/shikiji-core/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import type { IOnigLib, RegistryOptions } from './textmate'
import type { LanguageRegistration } from './types'

export class Resolver implements RegistryOptions {
private readonly languageMap: { [langIdOrAlias: string]: LanguageRegistration } = {}
private readonly scopeToLangMap: { [scope: string]: LanguageRegistration } = {}
private readonly _langs = new Map<string, LanguageRegistration>()
private readonly _scopeToLang = new Map<string, LanguageRegistration>()
private readonly _injections = new Map<string, string[]>()

private readonly _onigLibPromise: Promise<IOnigLib>

constructor(onigLibPromise: Promise<IOnigLib>, langs: LanguageRegistration[]) {
this._onigLibPromise = onigLibPromise

langs.forEach(i => this.addLanguage(i))
}

Expand All @@ -18,20 +18,37 @@ export class Resolver implements RegistryOptions {
}

public getLangRegistration(langIdOrAlias: string): LanguageRegistration {
return this.languageMap[langIdOrAlias]
return this._langs.get(langIdOrAlias)!
}

public async loadGrammar(scopeName: string): Promise<any> {
return this.scopeToLangMap[scopeName]
return this._scopeToLang.get(scopeName)!
}

public addLanguage(l: LanguageRegistration) {
this.languageMap[l.name] = l
this._langs.set(l.name, l)
if (l.aliases) {
l.aliases.forEach((a) => {
this.languageMap[a] = l
this._langs.set(a, l)
})
}
this._scopeToLang.set(l.scopeName, l)
if (l.injectTo) {
l.injectTo.forEach((i) => {
if (!this._injections.get(i))
this._injections.set(i, [])
this._injections.get(i)!.push(l.scopeName)
})
}
this.scopeToLangMap[l.scopeName] = l
}

public getInjections(scopeName: string): string[] | undefined {
const scopeParts = scopeName.split('.')
let injections: string[] = []
for (let i = 1; i <= scopeParts.length; i++) {
const subScopeName = scopeParts.slice(0, i).join('.')
injections = [...injections, ...(this._injections.get(subScopeName) || [])]
}
return injections
}
}
8 changes: 8 additions & 0 deletions packages/shikiji-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ export interface LanguageRegistration extends RawGrammar {
embeddedLangs?: string[]
balancedBracketSelectors?: string[]
unbalancedBracketSelectors?: string[]

/**
* Inject this language to other scopes.
* Same as `injectTo` in VSCode's `contributes.grammars`.
*
* @see https://code.visualstudio.com/api/language-extensions/syntax-highlight-guide#injection-grammars
*/
injectTo?: string[]
}

export interface CodeToThemedTokensOptions<Languages = string, Themes = string> {
Expand Down
194 changes: 4 additions & 190 deletions packages/shikiji/scripts/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,194 +1,8 @@
import fs from 'fs-extra'
import { BUNDLED_LANGUAGES, BUNDLED_THEMES } from 'shiki'
import fg from 'fast-glob'

const allLangFiles = await fg('*.json', {
cwd: './node_modules/shiki/languages',
absolute: true,
onlyFiles: true,
})

const comments = `
/**
* Generated by scripts/prepare.ts
*/
`.trim()
import { prepareLangs } from './prepare/langs'
import { prepareTheme } from './prepare/themes'

await fs.ensureDir('./src/assets/langs')
await fs.emptyDir('./src/assets/langs')

allLangFiles.sort()
for (const file of allLangFiles) {
const content = await fs.readJSON(file)
const lang = BUNDLED_LANGUAGES.find(i => i.id === content.name)
if (!lang) {
console.warn(`unknown ${content.name}`)
continue
}

const json = {
...content,
name: content.name || lang.id,
scopeName: content.scopeName || lang.scopeName,
displayName: lang.displayName,
aliases: lang.aliases,
embeddedLangs: lang.embeddedLangs,
balancedBracketSelectors: lang.balancedBracketSelectors,
unbalancedBracketSelectors: lang.unbalancedBracketSelectors,
}

// F# and Markdown has circular dependency
if (lang.id === 'fsharp')
json.embeddedLangs = json.embeddedLangs.filter((i: string) => i !== 'markdown')

const embedded = (json.embeddedLangs || []) as string[]

await fs.writeFile(`./src/assets/langs/${lang.id}.ts`, `${comments}
import type { LanguageRegistration } from 'shikiji-core'
${embedded.map(i => `import ${i.replace(/[^\w]/g, '_')} from './${i}'`).join('\n')}
const lang = Object.freeze(${JSON.stringify(json)}) as unknown as LanguageRegistration
export default [
${[
...embedded.map(i => ` ...${i.replace(/[^\w]/g, '_')}`),
' lang',
].join(',\n') || ''}
]
`, 'utf-8')
}

async function writeLanguageBundleIndex(fileName: string, ids: string[]) {
const bundled = ids.map(id => BUNDLED_LANGUAGES.find(i => i.id === id)!)

const info = bundled.map(i => ({
id: i.id,
name: i.displayName,
aliases: i.aliases,
import: `__(() => import('./langs/${i.id}')) as DynamicLangReg__`,
}) as const)
.sort((a, b) => a.id.localeCompare(b.id))

const type = info.flatMap(i => [...i.aliases || [], i.id]).sort().map(i => `'${i}'`).join(' | ')

await fs.writeFile(
`src/assets/${fileName}.ts`,
`${comments}
import type { LanguageRegistration } from 'shikiji-core'
type DynamicLangReg = () => Promise<{ default: LanguageRegistration[] }>
export interface BundledLanguageInfo {
id: string
name: string
import: DynamicLangReg
aliases?: string[]
}
export const bundledLanguagesInfo: BundledLanguageInfo[] = ${JSON.stringify(info, null, 2).replace(/"__|__"/g, '').replace(/"/g, '\'')}
export const bundledLanguagesBase = Object.fromEntries(bundledLanguagesInfo.map(i => [i.id, i.import]))
export const bundledLanguagesAlias = Object.fromEntries(bundledLanguagesInfo.flatMap(i => i.aliases?.map(a => [a, i.import]) || []))
export type BuiltinLanguage = ${type}
export const bundledLanguages = {
...bundledLanguagesBase,
...bundledLanguagesAlias,
} as Record<BuiltinLanguage, DynamicLangReg>
`,
'utf-8',
)

await fs.writeJSON(
`src/assets/${fileName}.json`,
BUNDLED_LANGUAGES.map(i => ({
id: i.id,
name: i.displayName,
aliases: i.aliases,
})),
{ spaces: 2 },
)
}

await writeLanguageBundleIndex('langs', BUNDLED_LANGUAGES.map(i => i.id))
// await writeLanguageBundleIndex('langs-common', BundleCommonLangs)

const themes = BUNDLED_THEMES.sort()
.filter(i => i !== 'css-variables')
.map((id) => {
const theme = fs.readJSONSync(`./node_modules/shiki/themes/${id}.json`)

return {
id,
name: guessThemeName(id, theme),
type: guessThemeType(id, theme),
import: `__(() => import('shiki/themes/${id}.json')) as unknown as DynamicThemeReg__`,
}
})

await fs.writeFile(
'src/assets/themes.ts',
`${comments}
import type { ThemeRegistrationRaw } from 'shikiji-core'
type DynamicThemeReg = () => Promise<{ default: ThemeRegistrationRaw }>
export interface BundledThemeInfo {
id: string
name: string
type: 'light' | 'dark'
import: DynamicThemeReg
}
export const bundledThemesInfo: BundledThemeInfo[] = ${JSON.stringify(themes, null, 2).replace(/"__|__"/g, '')}
export type BuiltinTheme = ${themes.map(i => `'${i.id}'`).join(' | ')}
export const bundledThemes = Object.fromEntries(bundledThemesInfo.map(i => [i.id, i.import])) as Record<BuiltinTheme, DynamicThemeReg>
`,
'utf-8',
)

await fs.writeJSON(
'src/assets/themes.json',
BUNDLED_THEMES
.filter(i => i !== 'css-variables')
.map(i => ({
id: i,
})),
{ spaces: 2 },
)

function isLightColor(hex: string) {
const [r, g, b] = hex.slice(1).match(/.{2}/g)!.map(i => Number.parseInt(i, 16))
return (r * 299 + g * 587 + b * 114) / 1000 > 128
}

function guessThemeType(id: string, theme: any) {
let color
if (id.includes('dark') || id.includes('dimmed') || id.includes('black'))
color = 'dark'
else if (id.includes('light') || id.includes('white') || id === 'slack-ochin')
color = 'light'
else if (theme.colors.background)
color = isLightColor(theme.colors.background) ? 'light' : 'dark'
else if (theme.colors['editor.background'])
color = isLightColor(theme.colors['editor.background']) ? 'light' : 'dark'
else if (theme.colors.foreground)
color = isLightColor(theme.colors.foreground) ? 'dark' : 'light'
else
color = 'light'
return color
}

function guessThemeName(id: string, theme: any) {
if (theme.displayName)
return theme.displayName
let name: string = theme.name || id
name = name.split(/[_-]/).map(i => i[0].toUpperCase() + i.slice(1)).join(' ')
name = name.replace(/github/ig, 'GitHub')
return name
}
await prepareLangs()
await prepareTheme()
5 changes: 5 additions & 0 deletions packages/shikiji/scripts/prepare/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const COMMENT_HEAD = `
/**
* Generated by scripts/prepare.ts
*/
`.trim()
61 changes: 61 additions & 0 deletions packages/shikiji/scripts/prepare/injections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Download and prepare grammar injections

import fs from 'fs-extra'
import { fetch } from 'ofetch'
import { COMMENT_HEAD } from './constants'

interface Injection {
name: string
contents: any[]
/**
* Bundle into a language
*/
toLang?: string
}

export async function prepareInjections() {
const injections = (await Promise.all([
prepareVueInjections(),
])).flat()

for (const injection of injections) {
await fs.writeFile(
`src/assets/langs/${injection.name}.ts`,
`${COMMENT_HEAD}
import type { LanguageRegistration } from 'shikiji-core'
export default ${JSON.stringify(injection.contents, null, 2)} as unknown as LanguageRegistration[]
`,
'utf-8',
)
}

return injections
}

export async function prepareVueInjections(): Promise<Injection> {
const base = 'https://github.com/vuejs/language-tools/blob/master/extensions/vscode/'
const pkgJson = await fetchJson(`${base}package.json?raw=true`)
const grammars = pkgJson.contributes.grammars as any[]
const injections = await Promise.all(grammars
.filter(i => i.injectTo)
.map(async (i) => {
const content = await fetchJson(`${new URL(i.path, base).href}?raw=true`)
return {
...content,
name: i.language,
injectTo: i.injectTo,
}
}),
)

return {
name: 'vue-injections',
toLang: 'vue',
contents: injections,
}
}

export function fetchJson(url: string) {
return fetch(url).then(res => res.json())
}
Loading

0 comments on commit c176b23

Please sign in to comment.