Repository: shikijs/shiki
Pull request #764 (2024-08-31 20:11:11)

feat: synchronous Shiki usage
This PR introduces two exports `createShikiInternalSync` and `createHighlighterCoreSync` that allow completely synchronous usage of Shiki. (made possible by forking `vscode-textmate` as https://github.com/shikijs/vscode-textmate)

For example:

```ts
import { createHighlighterCoreSync, createJavaScriptRegexEngine } from 'shiki/core'
import js from 'shiki/core/langs/javascript.mjs'
import nord from 'shiki/core/themes/nord.mjs'

const shiki = createHighlighterCoreSync({
  themes: [nord],
  langs: [js],
  engine: createJavaScriptRegexEngine()
})

const html = shiki.highlight('console.log(1)', { lang: 'js', theme: 'nord' }) 
```

These APIs require explicit `engine` field to be provided, and all `langs` and `themes` should be provided as already-resolved objects. Combining with https://github.com/shikijs/shiki/pull/761, it's able to achieve full synchronous.

If you still want to use Oniguruma, it's also possible if you are able to init the engine beforehand:

```ts
import { createHighlighterCoreSync, createWasmOnigEngine } from 'shiki/core'
import js from 'shiki/core/langs/javascript.mjs'
import nord from 'shiki/core/themes/nord.mjs'

// Load this somewhere beforehand
const engine = await createWasmOnigEngine(import('shiki/wasm'))

const shiki = createHighlighterCoreSync({
  themes: [nord],
  langs: [js],
  engine, // if a resolved engine passed in, the rest can still be synced.
})

const html = shiki.highlight('console.log(1)', { lang: 'js', theme: 'nord' }) 
```

Additionally, this PR also provides two additional properties to the highlighter instance, `loadThemeSync` and `loadLanugageSync`:

```ts
import { createHighlighterCoreSync, createWasmOnigEngine } from 'shiki/core'
import js from 'shiki/core/langs/javascript.mjs'
import nord from 'shiki/core/themes/nord.mjs'

const shiki = createHighlighterCoreSync({
  // ...
})

// Instead of:
await shiki.loadLanguage(js)
// Now you can have:
shiki.loadLanguageSync(js)

const html = shiki.highlight('console.log(1)', { lang: 'js', theme: 'nord' }) 
```

> [!NOTE]
> Note that these features are only available to the fine-grain bundle, the main bundled usage would still need asynchronous to load langs/themes on-demand.


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-matsu* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | f58e1e756bd6ebe3bcc509aedc79c01289dce874 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/shiki-matsu/deploys/66d378e0f9b8f2000850d186 |
netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-next* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | f58e1e756bd6ebe3bcc509aedc79c01289dce874 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/shiki-next/deploys/66d378e00e96550008a9adac |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/shikijs/shiki/pull/764?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.40%. Comparing base [(`eb842a3`)](https://app.codecov.io/gh/shikijs/shiki/commit/eb842a315681650f3806bbf54e8a97dd7c854d70?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs) to head [(`f58e1e7`)](https://app.codecov.io/gh/shikijs/shiki/commit/f58e1e756bd6ebe3bcc509aedc79c01289dce874?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #764      +/-   ##
==========================================
+ Coverage   92.33%   92.40%   +0.07%     
==========================================
  Files          68       70       +2     
  Lines        4489     4531      +42     
  Branches      999     1006       +7     
==========================================
+ Hits         4145     4187      +42     
  Misses        339      339              
  Partials        5        5              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/shikijs/shiki/pull/764?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/shikijs/shiki/pull/764?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.40%. Comparing base [(`eb842a3`)](https://app.codecov.io/gh/shikijs/shiki/commit/eb842a315681650f3806bbf54e8a97dd7c854d70?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs) to head [(`ac5c238`)](https://app.codecov.io/gh/shikijs/shiki/commit/ac5c2380cd8f22bd9f2d7459e3dc928d9a0b4282?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #764      +/-   ##
==========================================
+ Coverage   92.33%   92.40%   +0.07%     
==========================================
  Files          68       70       +2     
  Lines        4489     4531      +42     
  Branches      999     1006       +7     
==========================================
+ Hits         4145     4187      +42     
  Misses        339      339              
  Partials        5        5              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/shikijs/shiki/pull/764?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=shikijs).


Patch:
diff --git a/bench/engines.bench.ts b/bench/engines.bench.ts
index e71b6293..23767644 100644
--- a/bench/engines.bench.ts
+++ b/bench/engines.bench.ts
@@ -1,39 +1,43 @@
+/* eslint-disable no-console */
 import fs from 'node:fs/promises'
 import { bench, describe } from 'vitest'
 import type { BundledLanguage } from 'shiki'
 import { createHighlighter, createJavaScriptRegexEngine, createWasmOnigEngine } from 'shiki'
 import type { ReportItem } from '../scripts/report-engine-js-compat'
 
-describe('engines', async () => {
-  const js = createJavaScriptRegexEngine()
-  const wasm = await createWasmOnigEngine(() => import('shiki/wasm'))
+const js = createJavaScriptRegexEngine()
+const wasm = await createWasmOnigEngine(() => import('shiki/wasm'))
 
-  // Run `npx jiti scripts/report-engine-js-compat.ts` to generate the report first
-  const report = await fs.readFile('../scripts/report-engine-js-compat.json', 'utf-8').then(JSON.parse) as ReportItem[]
-  const langs = report.filter(i => i.highlightMatch === true).map(i => i.lang) as BundledLanguage[]
-  const samples = await Promise.all(langs.map(lang => fs.readFile(`../tm-grammars-themes/samples/${lang}.sample`, 'utf-8')))
+const RANGE = [0, 20]
 
-  const shikiJs = await createHighlighter({
-    langs,
-    themes: ['vitesse-dark'],
-    engine: js,
-  })
+// Run `npx jiti scripts/report-engine-js-compat.ts` to generate the report first
+const report = await fs.readFile(new URL('../scripts/report-engine-js-compat.json', import.meta.url), 'utf-8').then(JSON.parse) as ReportItem[]
+const langs = report.filter(i => i.highlightMatch === true).map(i => i.lang).slice(...RANGE) as BundledLanguage[]
+// Clone https://github.com/shikijs/textmate-grammars-themes to `../tm-grammars-themes`
+const samples = await Promise.all(langs.map(lang => fs.readFile(`../tm-grammars-themes/samples/${lang}.sample`, 'utf-8')))
 
-  const shikiWasm = await createHighlighter({
-    langs,
-    themes: ['vitesse-dark'],
-    engine: wasm,
-  })
+console.log('Benchmarking engines with', langs.length, 'languages')
+
+const shikiJs = await createHighlighter({
+  langs,
+  themes: ['vitesse-dark'],
+  engine: js,
+})
 
-  bench('js', () => {
-    for (const lang of langs) {
+const shikiWasm = await createHighlighter({
+  langs,
+  themes: ['vitesse-dark'],
+  engine: wasm,
+})
+
+for (const lang of langs) {
+  describe(lang, () => {
+    bench('js', () => {
       shikiJs.codeToTokensBase(samples[langs.indexOf(lang)], { lang, theme: 'vitesse-dark' })
-    }
-  }, { warmupIterations: 10, iterations: 30 })
+    })
 
-  bench('wasm', () => {
-    for (const lang of langs) {
+    bench('wasm', () => {
       shikiWasm.codeToTokensBase(samples[langs.indexOf(lang)], { lang, theme: 'vitesse-dark' })
-    }
-  }, { warmupIterations: 10, iterations: 30 })
-})
+    })
+  })
+}
diff --git a/packages/core/src/constructors/highlighter.ts b/packages/core/src/constructors/highlighter.ts
index c221dac7..628bc3df 100644
--- a/packages/core/src/constructors/highlighter.ts
+++ b/packages/core/src/constructors/highlighter.ts
@@ -5,6 +5,7 @@ import { codeToTokensBase, getLastGrammarState } from '../highlight/code-to-toke
 import { codeToTokensWithThemes } from '../highlight/code-to-tokens-themes'
 import type { HighlighterCore, HighlighterCoreOptions } from '../types'
 import { createShikiInternal } from './internal'
+import { createShikiInternalSync } from './internal-sync'
 
 /**
  * Create a Shiki core highlighter instance, with no languages or themes bundled.
@@ -27,6 +28,29 @@ export async function createHighlighterCore(options: HighlighterCoreOptions = {}
   }
 }
 
+/**
+ * Create a Shiki core highlighter instance, with no languages or themes bundled.
+ * Wasm and each language and theme must be loaded manually.
+ *
+ * Synchronous version of `createHighlighterCore`, which requires to provide the engine and all themes and languages upfront.
+ *
+ * @see http://shiki.style/guide/install#fine-grained-bundle
+ */
+export function createHighlighterCoreSync(options: HighlighterCoreOptions<true> = {}): HighlighterCore {
+  const internal = createShikiInternalSync(options)
+
+  return {
+    getLastGrammarState: (code, options) => getLastGrammarState(internal, code, options),
+    codeToTokensBase: (code, options) => codeToTokensBase(internal, code, options),
+    codeToTokensWithThemes: (code, options) => codeToTokensWithThemes(internal, code, options),
+    codeToTokens: (code, options) => codeToTokens(internal, code, options),
+    codeToHast: (code, options) => codeToHast(internal, code, options),
+    codeToHtml: (code, options) => codeToHtml(internal, code, options),
+    ...internal,
+    getInternalContext: () => internal,
+  }
+}
+
 export function makeSingletonHighlighterCore(createHighlighter: typeof createHighlighterCore) {
   let _shiki: ReturnType<typeof createHighlighterCore>
 
diff --git a/packages/core/src/constructors/internal-sync.ts b/packages/core/src/constructors/internal-sync.ts
new file mode 100644
index 00000000..86b44c88
--- /dev/null
+++ b/packages/core/src/constructors/internal-sync.ts
@@ -0,0 +1,133 @@
+import type {
+  HighlighterCoreOptions,
+  LanguageInput,
+  LanguageRegistration,
+  MaybeArray,
+  ShikiInternal,
+  SpecialLanguage,
+  SpecialTheme,
+  ThemeInput,
+  ThemeRegistrationAny,
+  ThemeRegistrationResolved,
+} from '../types'
+import { Registry } from '../textmate/registry'
+import { Resolver } from '../textmate/resolver'
+import { normalizeTheme } from '../textmate/normalize-theme'
+import { ShikiError } from '../error'
+import { resolveLangs, resolveThemes } from '../textmate/getters-resolve'
+
+let instancesCount = 0
+
+/**
+ * Get the minimal shiki context for rendering.
+ *
+ * Synchronous version of `createShikiInternal`, which requires to provide the engine and all themes and languages upfront.
+ */
+export function createShikiInternalSync(options: HighlighterCoreOptions<true>): ShikiInternal {
+  instancesCount += 1
+  if (options.warnings !== false && instancesCount >= 10 && instancesCount % 10 === 0)
+    console.warn(`[Shiki] ${instancesCount} instances have been created. Shiki is supposed to be used as a singleton, consider refactoring your code to cache your highlighter instance; Or call \`highlighter.dispose()\` to release unused instances.`)
+
+  let isDisposed = false
+
+  if (!options.engine)
+    throw new ShikiError('`engine` option is required for synchronous mode')
+
+  const langs = (options.langs || []).flat(1)
+  const themes = (options.themes || []).flat(1).map(normalizeTheme)
+
+  const resolver = new Resolver(options.engine, langs)
+  const _registry = new Registry(resolver, themes, langs, options.langAlias)
+
+  let _lastTheme: string | ThemeRegistrationAny
+
+  function getLanguage(name: string | LanguageRegistration) {
+    ensureNotDisposed()
+    const _lang = _registry.getGrammar(typeof name === 'string' ? name : name.name)
+    if (!_lang)
+      throw new ShikiError(`Language \`${name}\` not found, you may need to load it first`)
+    return _lang
+  }
+
+  function getTheme(name: string | ThemeRegistrationAny): ThemeRegistrationResolved {
+    if (name === 'none')
+      return { bg: '', fg: '', name: 'none', settings: [], type: 'dark' }
+    ensureNotDisposed()
+    const _theme = _registry.getTheme(name)
+    if (!_theme)
+      throw new ShikiError(`Theme \`${name}\` not found, you may need to load it first`)
+    return _theme
+  }
+
+  function setTheme(name: string | ThemeRegistrationAny) {
+    ensureNotDisposed()
+    const theme = getTheme(name)
+    if (_lastTheme !== name) {
+      _registry.setTheme(theme)
+      _lastTheme = name
+    }
+    const colorMap = _registry.getColorMap()
+    return {
+      theme,
+      colorMap,
+    }
+  }
+
+  function getLoadedThemes() {
+    ensureNotDisposed()
+    return _registry.getLoadedThemes()
+  }
+
+  function getLoadedLanguages() {
+    ensureNotDisposed()
+    return _registry.getLoadedLanguages()
+  }
+
+  function loadLanguageSync(...langs: MaybeArray<LanguageRegistration>[]) {
+    ensureNotDisposed()
+    _registry.loadLanguages(langs.flat(1))
+  }
+
+  async function loadLanguage(...langs: (LanguageInput | SpecialLanguage)[]) {
+    return loadLanguageSync(await resolveLangs(langs))
+  }
+
+  async function loadThemeSync(...themes: MaybeArray<ThemeRegistrationAny>[]) {
+    ensureNotDisposed()
+    for (const theme of themes.flat(1)) {
+      _registry.loadTheme(theme)
+    }
+  }
+
+  async function loadTheme(...themes: (ThemeInput | SpecialTheme)[]) {
+    ensureNotDisposed()
+    return loadThemeSync(await resolveThemes(themes))
+  }
+
+  function ensureNotDisposed() {
+    if (isDisposed)
+      throw new ShikiError('Shiki instance has been disposed')
+  }
+
+  function dispose() {
+    if (isDisposed)
+      return
+    isDisposed = true
+    _registry.dispose()
+    instancesCount -= 1
+  }
+
+  return {
+    setTheme,
+    getTheme,
+    getLanguage,
+    getLoadedThemes,
+    getLoadedLanguages,
+    loadLanguage,
+    loadLanguageSync,
+    loadTheme,
+    loadThemeSync,
+    dispose,
+    [Symbol.dispose]: dispose,
+  }
+}
diff --git a/packages/core/src/constructors/internal.ts b/packages/core/src/constructors/internal.ts
index 207e5dbd..bbdd6023 100644
--- a/packages/core/src/constructors/internal.ts
+++ b/packages/core/src/constructors/internal.ts
@@ -1,22 +1,11 @@
 import type {
   HighlighterCoreOptions,
-  LanguageInput,
-  LanguageRegistration,
   LoadWasmOptions,
-  MaybeGetter,
   ShikiInternal,
-  SpecialLanguage,
-  SpecialTheme,
-  ThemeInput,
-  ThemeRegistrationAny,
-  ThemeRegistrationResolved,
 } from '../types'
-import { Registry } from '../textmate/registry'
-import { Resolver } from '../textmate/resolver'
-import { normalizeTheme } from '../textmate/normalize-theme'
-import { isSpecialLang, isSpecialTheme } from '../utils'
-import { ShikiError } from '../error'
 import { createWasmOnigEngine } from '../engines/wasm'
+import { resolveLangs, resolveThemes } from '../textmate/getters-resolve'
+import { createShikiInternalSync } from './internal-sync'
 
 let _defaultWasmLoader: LoadWasmOptions | undefined
 /**
@@ -27,130 +16,27 @@ export function setDefaultWasmLoader(_loader: LoadWasmOptions) {
   _defaultWasmLoader = _loader
 }
 
-let instancesCount = 0
-
 /**
  * Get the minimal shiki context for rendering.
  */
 export async function createShikiInternal(options: HighlighterCoreOptions = {}): Promise<ShikiInternal> {
-  instancesCount += 1
-  if (options.warnings !== false && instancesCount >= 10 && instancesCount % 10 === 0)
-    console.warn(`[Shiki] ${instancesCount} instances have been created. Shiki is supposed to be used as a singleton, consider refactoring your code to cache your highlighter instance; Or call \`highlighter.dispose()\` to release unused instances.`)
-
-  let isDisposed = false
-
-  async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> {
-    return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then(r => r.default || r)
-  }
-
-  async function resolveLangs(langs: (LanguageInput | SpecialLanguage)[]) {
-    return Array.from(new Set((await Promise.all(
-      langs
-        .filter(l => !isSpecialLang(l))
-        .map(async lang => await normalizeGetter(lang as LanguageInput).then(r => Array.isArray(r) ? r : [r])),
-    )).flat()))
-  }
-
   const [
     themes,
     langs,
+    engine,
   ] = await Promise.all([
-    Promise.all((options.themes || []).map(normalizeGetter)).then(r => r.map(normalizeTheme)),
+    resolveThemes(options.themes || []),
     resolveLangs(options.langs || []),
+    (options.engine || createWasmOnigEngine(options.loadWasm || _defaultWasmLoader)),
   ] as const)
 
-  const resolver = new Resolver(
-    await (options.engine || createWasmOnigEngine(options.loadWasm || _defaultWasmLoader)),
+  return createShikiInternalSync({
+    ...options,
+    loadWasm: undefined,
+    themes,
     langs,
-  )
-
-  const _registry = new Registry(resolver, themes, langs, options.langAlias)
-  await _registry.init()
-
-  let _lastTheme: string | ThemeRegistrationAny
-
-  function getLanguage(name: string | LanguageRegistration) {
-    ensureNotDisposed()
-    const _lang = _registry.getGrammar(typeof name === 'string' ? name : name.name)
-    if (!_lang)
-      throw new ShikiError(`Language \`${name}\` not found, you may need to load it first`)
-    return _lang
-  }
-
-  function getTheme(name: string | ThemeRegistrationAny): ThemeRegistrationResolved {
-    if (name === 'none')
-      return { bg: '', fg: '', name: 'none', settings: [], type: 'dark' }
-    ensureNotDisposed()
-    const _theme = _registry.getTheme(name)
-    if (!_theme)
-      throw new ShikiError(`Theme \`${name}\` not found, you may need to load it first`)
-    return _theme
-  }
-
-  function setTheme(name: string | ThemeRegistrationAny) {
-    ensureNotDisposed()
-    const theme = getTheme(name)
-    if (_lastTheme !== name) {
-      _registry.setTheme(theme)
-      _lastTheme = name
-    }
-    const colorMap = _registry.getColorMap()
-    return {
-      theme,
-      colorMap,
-    }
-  }
-
-  function getLoadedThemes() {
-    ensureNotDisposed()
-    return _registry.getLoadedThemes()
-  }
-
-  function getLoadedLanguages() {
-    ensureNotDisposed()
-    return _registry.getLoadedLanguages()
-  }
-
-  async function loadLanguage(...langs: (LanguageInput | SpecialLanguage)[]) {
-    ensureNotDisposed()
-    await _registry.loadLanguages(await resolveLangs(langs))
-  }
-
-  async function loadTheme(...themes: (ThemeInput | SpecialTheme)[]) {
-    ensureNotDisposed()
-    await Promise.all(
-      themes.map(async theme =>
-        isSpecialTheme(theme)
-          ? null
-          : _registry.loadTheme(await normalizeGetter(theme)),
-      ),
-    )
-  }
-
-  function ensureNotDisposed() {
-    if (isDisposed)
-      throw new ShikiError('Shiki instance has been disposed')
-  }
-
-  function dispose() {
-    if (isDisposed)
-      return
-    isDisposed = true
-    _registry.dispose()
-    instancesCount -= 1
-  }
-
-  return {
-    setTheme,
-    getTheme,
-    getLanguage,
-    getLoadedThemes,
-    getLoadedLanguages,
-    loadLanguage,
-    loadTheme,
-    dispose,
-    [Symbol.dispose]: dispose,
-  }
+    engine,
+  })
 }
 
 /**
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ef785d7e..b37b4fab 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -4,6 +4,7 @@ export * from './types'
 // Constructors
 export * from './constructors/highlighter'
 export * from './constructors/bundle-factory'
+export { createShikiInternalSync } from './constructors/internal-sync'
 export { createShikiInternal, getShikiInternal, setDefaultWasmLoader } from './constructors/internal'
 
 // Engines
diff --git a/packages/core/src/textmate/getters-resolve.ts b/packages/core/src/textmate/getters-resolve.ts
new file mode 100644
index 00000000..15965a0c
--- /dev/null
+++ b/packages/core/src/textmate/getters-resolve.ts
@@ -0,0 +1,25 @@
+import type { LanguageInput, LanguageRegistration, SpecialLanguage, SpecialTheme, ThemeInput, ThemeRegistrationResolved } from '../types'
+import { isSpecialLang, isSpecialTheme, normalizeGetter } from '../utils'
+import { normalizeTheme } from './normalize-theme'
+
+/**
+ * Resolve
+ */
+export async function resolveLangs(langs: (LanguageInput | SpecialLanguage)[]): Promise<LanguageRegistration[]> {
+  return Array.from(new Set((await Promise.all(
+    langs
+      .filter(l => !isSpecialLang(l))
+      .map(async lang => await normalizeGetter(lang as LanguageInput).then(r => Array.isArray(r) ? r : [r])),
+  )).flat()))
+}
+
+export async function resolveThemes(themes: (ThemeInput | SpecialTheme)[]): Promise<ThemeRegistrationResolved[]> {
+  const resolved = await Promise.all(
+    themes.map(async theme =>
+      isSpecialTheme(theme)
+        ? null
+        : normalizeTheme(await normalizeGetter(theme)),
+    ),
+  )
+  return resolved.filter(i => !!i)
+}
diff --git a/packages/core/src/textmate/registry.ts b/packages/core/src/textmate/registry.ts
index 9cf66f16..82401485 100644
--- a/packages/core/src/textmate/registry.ts
+++ b/packages/core/src/textmate/registry.ts
@@ -23,8 +23,8 @@ export class Registry extends TextMateRegistry {
   ) {
     super(_resolver)
 
-    _themes.forEach(t => this.loadTheme(t))
-    _langs.forEach(l => this.loadLanguage(l))
+    this._themes.map(t => this.loadTheme(t))
+    this.loadLanguages(this._langs)
   }
 
   public getTheme(theme: ThemeRegistrationAny | string) {
@@ -79,7 +79,7 @@ export class Registry extends TextMateRegistry {
     return this._resolvedGrammars.get(name)
   }
 
-  public async loadLanguage(lang: LanguageRegistration) {
+  public loadLanguage(lang: LanguageRegistration): void {
     if (this.getGrammar(lang.name))
       return
 
@@ -97,7 +97,7 @@ export class Registry extends TextMateRegistry {
 
     // @ts-expect-error Private members, set this to override the previous grammar (that can be a stub)
     this._syncRegistry._rawGrammars.set(lang.scopeName, lang)
-    const g = await this.loadGrammarWithConfiguration(lang.scopeName, 1, grammarConfig) as Grammar
+    const g = this.loadGrammarWithConfiguration(lang.scopeName, 1, grammarConfig) as Grammar
     g.name = lang.name
     this._resolvedGrammars.set(lang.name, g)
     if (lang.aliases) {
@@ -118,16 +118,11 @@ export class Registry extends TextMateRegistry {
         this._syncRegistry?._injectionGrammars?.delete(e.scopeName)
         // @ts-expect-error clear cache
         this._syncRegistry?._grammars?.delete(e.scopeName)
-        await this.loadLanguage(this._langMap.get(e.name)!)
+        this.loadLanguage(this._langMap.get(e.name)!)
       }
     }
   }
 
-  async init() {
-    this._themes.map(t => this.loadTheme(t))
-    await this.loadLanguages(this._langs)
-  }
-
   public override dispose(): void {
     super.dispose()
     this._resolvedThemes.clear()
@@ -137,7 +132,7 @@ export class Registry extends TextMateRegistry {
     this._loadedThemesCache = null
   }
 
-  public async loadLanguages(langs: LanguageRegistration[]) {
+  public loadLanguages(langs: LanguageRegistration[]) {
     for (const lang of langs)
       this.resolveEmbeddedLanguages(lang)
 
@@ -155,7 +150,7 @@ export class Registry extends TextMateRegistry {
       this._resolver.addLanguage(lang)
 
     for (const [_, lang] of langsGraphArray)
-      await this.loadLanguage(lang)
+      this.loadLanguage(lang)
   }
 
   public getLoadedLanguages() {
diff --git a/packages/core/src/types/engines.ts b/packages/core/src/types/engines.ts
index a2396c23..dd5ea752 100644
--- a/packages/core/src/types/engines.ts
+++ b/packages/core/src/types/engines.ts
@@ -1,4 +1,5 @@
 import type { OnigScanner, OnigString } from '@shikijs/vscode-textmate'
+import type { Awaitable } from './utils'
 
 export interface PatternScanner extends OnigScanner {}
 
@@ -12,8 +13,6 @@ export interface RegexEngine {
   createString: (s: string) => RegexEngineString
 }
 
-type Awaitable<T> = T | Promise<T>
-
 export interface WebAssemblyInstantiator {
   (importObject: Record<string, Record<string, WebAssembly.ImportValue>> | undefined): Promise<WebAssemblyInstance>
 }
diff --git a/packages/core/src/types/highlighter.ts b/packages/core/src/types/highlighter.ts
index a4a4def6..76a84e69 100644
--- a/packages/core/src/types/highlighter.ts
+++ b/packages/core/src/types/highlighter.ts
@@ -4,6 +4,7 @@ import type { LanguageInput, LanguageRegistration, ResolveBundleKey, SpecialLang
 import type { SpecialTheme, ThemeInput, ThemeRegistrationAny, ThemeRegistrationResolved } from './themes'
 import type { CodeToTokensBaseOptions, CodeToTokensOptions, CodeToTokensWithThemesOptions, GrammarState, ThemedToken, ThemedTokenWithVariants, TokensResult } from './tokens'
 import type { CodeToHastOptions } from './options'
+import type { MaybeArray } from './utils'
 
 /**
  * Internal context of Shiki, core textmate logic
@@ -13,10 +14,19 @@ export interface ShikiInternal<BundledLangKeys extends string = never, BundledTh
    * Load a theme to the highlighter, so later it can be used synchronously.
    */
   loadTheme: (...themes: (ThemeInput | BundledThemeKeys | SpecialTheme)[]) => Promise<void>
+  /**
+   * Load a theme registration synchronously.
+   */
+  loadThemeSync: (...themes: MaybeArray<ThemeRegistrationAny>[]) => void
+
   /**
    * Load a language to the highlighter, so later it can be used synchronously.
    */
   loadLanguage: (...langs: (LanguageInput | BundledLangKeys | SpecialLanguage)[]) => Promise<void>
+  /**
+   * Load a language registration synchronously.
+   */
+  loadLanguageSync: (...langs: MaybeArray<LanguageRegistration>[]) => void
 
   /**
    * Get the registered theme object
diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts
index 5238799d..868f8fff 100644
--- a/packages/core/src/types/options.ts
+++ b/packages/core/src/types/options.ts
@@ -1,38 +1,40 @@
 import type { LoadWasmOptions, RegexEngine } from '../types'
-import type { StringLiteralUnion } from './utils'
-import type { LanguageInput, SpecialLanguage } from './langs'
+import type { Awaitable, MaybeArray, StringLiteralUnion } from './utils'
+import type { LanguageInput, LanguageRegistration, SpecialLanguage } from './langs'
 import type { SpecialTheme, ThemeInput, ThemeRegistrationAny } from './themes'
 import type { TransformerOptions } from './transformers'
 import type { TokenizeWithThemeOptions, TokensResult } from './tokens'
 import type { DecorationOptions } from './decorations'
 
-export interface HighlighterCoreOptions {
+export interface HighlighterCoreOptions<Sync extends boolean = false> {
+  /**
+   * Custom RegExp engine.
+   */
+  engine?: Sync extends true ? RegexEngine : Awaitable<RegexEngine>
   /**
    * Theme names, or theme registration objects to be loaded upfront.
    */
-  themes?: ThemeInput[]
+  themes?: Sync extends true ? MaybeArray<ThemeRegistrationAny>[] : ThemeInput[]
   /**
    * Language names, or language registration objects to be loaded upfront.
    */
-  langs?: LanguageInput[]
+  langs?: Sync extends true ? MaybeArray<LanguageRegistration>[] : LanguageInput[]
   /**
    * Alias of languages
    * @example { 'my-lang': 'javascript' }
    */
   langAlias?: Record<string, string>
-  /**
-   * Load wasm file from a custom path or using a custom function.
-   */
-  loadWasm?: LoadWasmOptions
   /**
    * Emit console warnings to alert users of potential issues.
    * @default true
    */
   warnings?: boolean
+
+  // TODO: Deprecate this option after docs for engines are updated.
   /**
-   * Custom RegExp engine.
+   * Load wasm file from a custom path or using a custom function.
    */
-  engine?: RegexEngine | Promise<RegexEngine>
+  loadWasm?: Sync extends true ? never : LoadWasmOptions
 }
 
 export interface BundledHighlighterOptions<L extends string, T extends string> extends Pick<HighlighterCoreOptions, 'warnings' | 'engine'> {
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 4a28d81a..957a7353 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -1,6 +1,18 @@
 import type { Element } from 'hast'
 import { FontStyle } from './types'
-import type { MaybeArray, PlainTextLanguage, Position, SpecialLanguage, SpecialTheme, ThemeInput, ThemeRegistrationAny, ThemedToken, TokenStyles, TokenizeWithThemeOptions } from './types'
+import type {
+  MaybeArray,
+  MaybeGetter,
+  PlainTextLanguage,
+  Position,
+  SpecialLanguage,
+  SpecialTheme,
+  ThemeInput,
+  ThemeRegistrationAny,
+  ThemedToken,
+  TokenStyles,
+  TokenizeWithThemeOptions,
+} from './types'
 
 export function toArray<T>(x: MaybeArray<T>): T[] {
   return Array.isArray(x) ? x : [x]
@@ -146,6 +158,13 @@ export function splitTokens<
   })
 }
 
+/**
+ * Normalize a getter to a promise.
+ */
+export async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> {
+  return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then(r => r.default || r)
+}
+
 export function resolveColorReplacements(
   theme: ThemeRegistrationAny | string,
   options?: TokenizeWithThemeOptions,


Test patch:
diff --git a/packages/markdown-it/test/index.test.ts b/packages/markdown-it/test/index.test.ts
index 1c0d7b38..00a712d9 100644
--- a/packages/markdown-it/test/index.test.ts
+++ b/packages/markdown-it/test/index.test.ts
@@ -19,7 +19,8 @@ it('run for base', async () => {
   const result = md.render(await fs.readFile(new URL('./fixtures/a.md', import.meta.url), 'utf-8'))
 
   expect(result).toMatchFileSnapshot('./fixtures/a.out.html')
-})
+}, { timeout: 10_000 })
+
 it('run for fallback language', async () => {
   const md = MarkdownIt()
   md.use(await Shiki({
@@ -36,7 +37,8 @@ it('run for fallback language', async () => {
   const result = md.render(await fs.readFile(new URL('./fixtures/b.md', import.meta.url), 'utf-8'))
 
   expect(result).toMatchFileSnapshot('./fixtures/b.out.html')
-})
+}, { timeout: 10_000 })
+
 it('run for default language', async () => {
   const md = MarkdownIt()
   md.use(await Shiki({
@@ -53,4 +55,4 @@ it('run for default language', async () => {
   const result = md.render(await fs.readFile(new URL('./fixtures/c.md', import.meta.url), 'utf-8'))
 
   expect(result).toMatchFileSnapshot('./fixtures/c.out.html')
-})
+}, { timeout: 10_000 })
diff --git a/packages/shiki/test/core-sync.test.ts b/packages/shiki/test/core-sync.test.ts
new file mode 100644
index 00000000..e4aab94a
--- /dev/null
+++ b/packages/shiki/test/core-sync.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { createHighlighterCoreSync, createJavaScriptRegexEngine } from '../src/core'
+
+import js from '../src/assets/langs/javascript'
+import nord from '../src/assets/themes/nord'
+
+describe('should', () => {
+  const engine = createJavaScriptRegexEngine()
+
+  it('works', () => {
+    const shiki = createHighlighterCoreSync({
+      themes: [nord],
+      langs: [js],
+      engine,
+    })
+
+    expect(shiki.codeToHtml('console.log("Hi")', { lang: 'javascript', theme: 'nord' }))
+      .toMatchInlineSnapshot(`"<pre class="shiki nord" style="background-color:#2e3440ff;color:#d8dee9ff" tabindex="0"><code><span class="line"><span style="color:#D8DEE9">console</span><span style="color:#ECEFF4">.</span><span style="color:#88C0D0">log</span><span style="color:#D8DEE9FF">(</span><span style="color:#ECEFF4">"</span><span style="color:#A3BE8C">Hi</span><span style="color:#ECEFF4">"</span><span style="color:#D8DEE9FF">)</span></span></code></pre>"`)
+  })
+
+  it('dynamic load sync theme and lang', async () => {
+    const shiki = createHighlighterCoreSync({
+      themes: [nord],
+      langs: [
+        js,
+        // Load the grammar upfront (await outside of the function)
+        await import('../src/assets/langs/c').then(r => r.default),
+      ],
+      engine,
+    })
+
+    shiki.loadLanguageSync(await import('../src/assets/langs/python').then(m => m.default))
+    shiki.loadThemeSync(await import('../dist/themes/vitesse-light.mjs').then(m => m.default))
+
+    expect(shiki.getLoadedLanguages())
+      .toMatchInlineSnapshot(`
+        [
+          "javascript",
+          "c",
+          "python",
+          "js",
+          "py",
+        ]
+      `)
+    expect(shiki.getLoadedThemes())
+      .toMatchInlineSnapshot(`
+        [
+          "nord",
+          "vitesse-light",
+        ]
+      `)
+
+    expect(shiki.codeToHtml('print 1', { lang: 'python', theme: 'vitesse-light' }))
+      .toMatchInlineSnapshot(`"<pre class="shiki vitesse-light" style="background-color:#ffffff;color:#393a34" tabindex="0"><code><span class="line"><span style="color:#998418">print</span><span style="color:#2F798A"> 1</span></span></code></pre>"`)
+  })
+})
diff --git a/packages/shiki/test/core.test.ts b/packages/shiki/test/core.test.ts
index 2addcb3f..72bb4155 100644
--- a/packages/shiki/test/core.test.ts
+++ b/packages/shiki/test/core.test.ts
@@ -13,7 +13,7 @@ describe('should', () => {
   it('works', async () => {
     const shiki = await createHighlighterCore({
       themes: [nord],
-      langs: [js as any],
+      langs: [js],
       loadWasm: {
         instantiator: obj => WebAssembly.instantiate(wasmBinary, obj),
       },