Repository: shikijs/shiki
Pull request #932 (2025-02-15 17:39:16)

feat(shortcuts): guess embedded language and auto load in shortcuts
fix for https://github.com/vuejs/vitepress/pull/4564

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


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


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 7a1e51ec62ce5f54dc6be3c7f38f0305cd77f501 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/shiki-matsu/deploys/67b0d146bd4b38000887bae7 |

Patch:
diff --git a/packages/core/src/constructors/bundle-factory.ts b/packages/core/src/constructors/bundle-factory.ts
index 4fa55794..9bb46afe 100644
--- a/packages/core/src/constructors/bundle-factory.ts
+++ b/packages/core/src/constructors/bundle-factory.ts
@@ -134,6 +134,12 @@ export function createdBundledHighlighter<BundledLangs extends string, BundledTh
       loadTheme(...themes) {
         return core.loadTheme(...themes.map(resolveTheme))
       },
+      getBundledLanguages() {
+        return bundledLanguages
+      },
+      getBundledThemes() {
+        return bundledThemes
+      },
     }
   }
 
@@ -224,53 +230,58 @@ export function makeSingletonHighlighter<L extends string, T extends string>(
   return getSingletonHighlighter
 }
 
+export interface CreateSingletonShorthandsOptions<L extends string, T extends string> {
+  /**
+   * A custom function to guess embedded languages to be loaded.
+   */
+  guessEmbeddedLanguages?: (code: string, lang: string | undefined, highlighter: HighlighterGeneric<L, T>) => Awaitable<string[] | undefined>
+}
+
 export function createSingletonShorthands<L extends string, T extends string>(
   createHighlighter: CreateHighlighterFactory<L, T>,
+  config?: CreateSingletonShorthandsOptions<L, T>,
 ): ShorthandsBundle<L, T> {
   const getSingletonHighlighter = makeSingletonHighlighter(createHighlighter)
 
+  async function get(code: string, options: CodeToTokensOptions<L, T> | CodeToHastOptions<L, T>): Promise<HighlighterGeneric<L, T>> {
+    const shiki = await getSingletonHighlighter({
+      langs: [options.lang as L],
+      themes: ('theme' in options ? [options.theme] : Object.values(options.themes)) as T[],
+    })
+    const langs = await config?.guessEmbeddedLanguages?.(code, options.lang, shiki) as L[]
+    if (langs) {
+      await shiki.loadLanguage(...langs)
+    }
+    return shiki
+  }
+
   return {
     getSingletonHighlighter(options) {
       return getSingletonHighlighter(options)
     },
 
     async codeToHtml(code, options) {
-      const shiki = await getSingletonHighlighter({
-        langs: [options.lang as L],
-        themes: ('theme' in options ? [options.theme] : Object.values(options.themes)) as T[],
-      })
+      const shiki = await get(code, options)
       return shiki.codeToHtml(code, options)
     },
 
     async codeToHast(code, options) {
-      const shiki = await getSingletonHighlighter({
-        langs: [options.lang as L],
-        themes: ('theme' in options ? [options.theme] : Object.values(options.themes)) as T[],
-      })
+      const shiki = await get(code, options)
       return shiki.codeToHast(code, options)
     },
 
     async codeToTokens(code, options) {
-      const shiki = await getSingletonHighlighter({
-        langs: [options.lang as L],
-        themes: ('theme' in options ? [options.theme] : Object.values(options.themes)) as T[],
-      })
+      const shiki = await get(code, options)
       return shiki.codeToTokens(code, options)
     },
 
     async codeToTokensBase(code, options) {
-      const shiki = await getSingletonHighlighter({
-        langs: [options.lang as L],
-        themes: [options.theme as T],
-      })
+      const shiki = await get(code, options)
       return shiki.codeToTokensBase(code, options)
     },
 
     async codeToTokensWithThemes(code, options) {
-      const shiki = await getSingletonHighlighter({
-        langs: [options.lang as L],
-        themes: Object.values(options.themes).filter(Boolean) as T[],
-      })
+      const shiki = await get(code, options)
       return shiki.codeToTokensWithThemes(code, options)
     },
 
diff --git a/packages/core/src/constructors/highlighter.ts b/packages/core/src/constructors/highlighter.ts
index 5c8ec3c1..bb71e247 100644
--- a/packages/core/src/constructors/highlighter.ts
+++ b/packages/core/src/constructors/highlighter.ts
@@ -26,6 +26,8 @@ export async function createHighlighterCore(options: HighlighterCoreOptions<fals
     codeToTokens: (code, options) => codeToTokens(internal, code, options),
     codeToHast: (code, options) => codeToHast(internal, code, options),
     codeToHtml: (code, options) => codeToHtml(internal, code, options),
+    getBundledLanguages: () => ({}),
+    getBundledThemes: () => ({}),
     ...internal,
     getInternalContext: () => internal,
   }
@@ -49,6 +51,8 @@ export function createHighlighterCoreSync(options: HighlighterCoreOptions<true>)
     codeToTokens: (code, options) => codeToTokens(internal, code, options),
     codeToHast: (code, options) => codeToHast(internal, code, options),
     codeToHtml: (code, options) => codeToHtml(internal, code, options),
+    getBundledLanguages: () => ({}),
+    getBundledThemes: () => ({}),
     ...internal,
     getInternalContext: () => internal,
   }
diff --git a/packages/core/src/highlight/code-to-tokens-base.ts b/packages/core/src/highlight/code-to-tokens-base.ts
index 10dcc6c0..c4ae06b1 100644
--- a/packages/core/src/highlight/code-to-tokens-base.ts
+++ b/packages/core/src/highlight/code-to-tokens-base.ts
@@ -185,7 +185,7 @@ function _tokenizeWithTheme(
     let tokensWithScopesIndex
 
     if (options.includeExplanation) {
-      resultWithScopes = grammar.tokenizeLine(line, stateStack)
+      resultWithScopes = grammar.tokenizeLine(line, stateStack, tokenizeTimeLimit)
       tokensWithScopes = resultWithScopes.tokens
       tokensWithScopesIndex = 0
     }
diff --git a/packages/core/src/textmate/registry.ts b/packages/core/src/textmate/registry.ts
index 4550ea06..fe36b649 100644
--- a/packages/core/src/textmate/registry.ts
+++ b/packages/core/src/textmate/registry.ts
@@ -67,7 +67,6 @@ export class Registry extends TextMateRegistry {
       this._textmateThemeCache.set(theme, textmateTheme)
     }
 
-    // @ts-expect-error Access private `_syncRegistry`, but should work in runtime
     this._syncRegistry.setTheme(textmateTheme)
   }
 
@@ -100,7 +99,6 @@ export class Registry extends TextMateRegistry {
       unbalancedBracketSelectors: lang.unbalancedBracketSelectors || [],
     }
 
-    // @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 = this.loadGrammarWithConfiguration(lang.scopeName, 1, grammarConfig) as Grammar
     g.name = lang.name
@@ -119,9 +117,7 @@ export class Registry extends TextMateRegistry {
         this._resolvedGrammars.delete(e.name)
         // Reset cache
         this._loadedLanguagesCache = null
-        // @ts-expect-error clear cache
         this._syncRegistry?._injectionGrammars?.delete(e.scopeName)
-        // @ts-expect-error clear cache
         this._syncRegistry?._grammars?.delete(e.scopeName)
         this.loadLanguage(this._langMap.get(e.name)!)
       }
diff --git a/packages/monaco/src/index.ts b/packages/monaco/src/index.ts
index 8aede1ae..73163e90 100644
--- a/packages/monaco/src/index.ts
+++ b/packages/monaco/src/index.ts
@@ -1,7 +1,9 @@
 import type { ShikiInternal, ThemeRegistrationResolved } from '@shikijs/types'
-import type { StateStack } from '@shikijs/vscode-textmate'
 import type monacoNs from 'monaco-editor-core'
+import type { MonacoLineToken } from './types'
 import { EncodedTokenMetadata, INITIAL } from '@shikijs/vscode-textmate'
+import { TokenizerState } from './tokenizer'
+import { normalizeColor } from './utils'
 
 export interface MonacoTheme extends monacoNs.editor.IStandaloneThemeData { }
 
@@ -28,8 +30,10 @@ export function textmateThemeToMonacoTheme(theme: ThemeRegistrationResolved): Mo
   if (!rules) {
     rules = []
     const themeSettings = theme.settings || theme.tokenColors
+
     for (const { scope, settings: { foreground, background, fontStyle } } of themeSettings) {
       const scopes = Array.isArray(scope) ? scope : [scope]
+
       for (const s of scopes) {
         if (s && (foreground || background || fontStyle)) {
           rules.push({
@@ -108,13 +112,15 @@ export function shikiToMonaco(
   } = options
 
   const monacoLanguageIds = new Set(monaco.languages.getLanguages().map(l => l.id))
+
   for (const lang of highlighter.getLoadedLanguages()) {
     if (monacoLanguageIds.has(lang)) {
       monaco.languages.setTokensProvider(lang, {
         getInitialState() {
           return new TokenizerState(INITIAL)
         },
-        tokenize(line, state: TokenizerState) {
+
+        tokenize(line: string, state: TokenizerState) {
           if (line.length >= tokenizeMaxLineLength) {
             return {
               endState: state,
@@ -129,11 +135,13 @@ export function shikiToMonaco(
             console.warn(`Time limit reached when tokenizing line: ${line.substring(0, 100)}`)
 
           const tokensLength = result.tokens.length / 2
-          const tokens: any[] = []
+          const tokens: MonacoLineToken[] = []
+
           for (let j = 0; j < tokensLength; j++) {
             const startIndex = result.tokens[2 * j]
             const metadata = result.tokens[2 * j + 1]
             const color = normalizeColor(colorMap[EncodedTokenMetadata.getForeground(metadata)] || '')
+
             // Because Monaco only support one scope per token,
             // we workaround this to use color to trace back the scope
             const scope = findScopeByColor(color) || ''
@@ -146,52 +154,3 @@ export function shikiToMonaco(
     }
   }
 }
-
-class TokenizerState implements monacoNs.languages.IState {
-  constructor(
-    private _ruleStack: StateStack,
-  ) { }
-
-  public get ruleStack(): StateStack {
-    return this._ruleStack
-  }
-
-  public clone(): TokenizerState {
-    return new TokenizerState(this._ruleStack)
-  }
-
-  public equals(other: monacoNs.languages.IState): boolean {
-    if (
-      !other
-      || !(other instanceof TokenizerState)
-      || other !== this
-      || other._ruleStack !== this._ruleStack
-    ) {
-      return false
-    }
-
-    return true
-  }
-}
-
-function normalizeColor(color: undefined): undefined
-function normalizeColor(color: string | string[]): string
-function normalizeColor(color: string | string[] | undefined): string | undefined
-function normalizeColor(color: string | string[] | undefined): string | undefined {
-  // Some themes have an array of colors (not yet sure why), here we pick the first one
-  // https://github.com/shikijs/shiki/issues/894
-  // https://github.com/shikijs/textmate-grammars-themes/pull/117
-  if (Array.isArray(color))
-    color = color[0]
-
-  if (!color)
-    return undefined
-
-  color = (color.charCodeAt(0) === 35 ? color.slice(1) : color).toLowerCase()
-
-  // #RGB => #RRGGBB - Monaco does not support hex color with 3 or 4 digits
-  if (color.length === 3 || color.length === 4)
-    color = color.split('').map(c => c + c).join('')
-
-  return color
-}
diff --git a/packages/monaco/src/tokenizer.ts b/packages/monaco/src/tokenizer.ts
new file mode 100644
index 00000000..5dd3cca4
--- /dev/null
+++ b/packages/monaco/src/tokenizer.ts
@@ -0,0 +1,29 @@
+import type { StateStack } from '@shikijs/vscode-textmate'
+import type monacoNs from 'monaco-editor-core'
+
+export class TokenizerState implements monacoNs.languages.IState {
+  constructor(
+    private _ruleStack: StateStack,
+  ) { }
+
+  public get ruleStack(): StateStack {
+    return this._ruleStack
+  }
+
+  public clone(): TokenizerState {
+    return new TokenizerState(this._ruleStack)
+  }
+
+  public equals(other: monacoNs.languages.IState): boolean {
+    if (
+      !other
+      || !(other instanceof TokenizerState)
+      || other !== this
+      || other._ruleStack !== this._ruleStack
+    ) {
+      return false
+    }
+
+    return true
+  }
+}
diff --git a/packages/monaco/src/types.ts b/packages/monaco/src/types.ts
new file mode 100644
index 00000000..65b63034
--- /dev/null
+++ b/packages/monaco/src/types.ts
@@ -0,0 +1,4 @@
+export interface MonacoLineToken {
+  startIndex: number
+  scopes: string
+}
diff --git a/packages/monaco/src/utils.ts b/packages/monaco/src/utils.ts
new file mode 100644
index 00000000..f8ca6f7e
--- /dev/null
+++ b/packages/monaco/src/utils.ts
@@ -0,0 +1,21 @@
+export function normalizeColor(color: undefined): undefined
+export function normalizeColor(color: string | string[]): string
+export function normalizeColor(color: string | string[] | undefined): string | undefined
+export function normalizeColor(color: string | string[] | undefined): string | undefined {
+  // Some themes have an array of colors (not yet sure why), here we pick the first one
+  // https://github.com/shikijs/shiki/issues/894
+  // https://github.com/shikijs/textmate-grammars-themes/pull/117
+  if (Array.isArray(color))
+    color = color[0]
+
+  if (!color)
+    return undefined
+
+  color = (color.charCodeAt(0) === 35 ? color.slice(1) : color).toLowerCase()
+
+  // #RGB => #RRGGBB - Monaco does not support hex color with 3 or 4 digits
+  if (color.length === 3 || color.length === 4)
+    color = color.split('').map(c => c + c).join('')
+
+  return color
+}
diff --git a/packages/shiki/src/bundle-full.ts b/packages/shiki/src/bundle-full.ts
index e91c48e1..65c1285d 100644
--- a/packages/shiki/src/bundle-full.ts
+++ b/packages/shiki/src/bundle-full.ts
@@ -4,6 +4,7 @@ import type { BundledLanguage } from './langs-bundle-full'
 import type { BundledTheme } from './themes'
 import { createdBundledHighlighter, createSingletonShorthands, warnDeprecated } from './core'
 import { createOnigurumaEngine } from './engine-oniguruma'
+import { guessEmbeddedLanguages } from './guess'
 import { bundledLanguages } from './langs-bundle-full'
 import { bundledThemes } from './themes'
 
@@ -46,6 +47,7 @@ export const {
   BundledTheme
 >(
   createHighlighter,
+  { guessEmbeddedLanguages },
 )
 
 /**
diff --git a/packages/shiki/src/bundle-web.ts b/packages/shiki/src/bundle-web.ts
index 1c1dee94..6297fa5b 100644
--- a/packages/shiki/src/bundle-web.ts
+++ b/packages/shiki/src/bundle-web.ts
@@ -4,6 +4,7 @@ import type { BundledLanguage } from './langs-bundle-web'
 import type { BundledTheme } from './themes'
 import { createdBundledHighlighter, createSingletonShorthands, warnDeprecated } from './core'
 import { createOnigurumaEngine } from './engine-oniguruma'
+import { guessEmbeddedLanguages } from './guess'
 import { bundledLanguages } from './langs-bundle-web'
 import { bundledThemes } from './themes'
 
@@ -46,6 +47,7 @@ export const {
   BundledTheme
 >(
   createHighlighter,
+  { guessEmbeddedLanguages },
 )
 
 /**
diff --git a/packages/shiki/src/guess.ts b/packages/shiki/src/guess.ts
new file mode 100644
index 00000000..35c871ce
--- /dev/null
+++ b/packages/shiki/src/guess.ts
@@ -0,0 +1,26 @@
+import type { HighlighterGeneric } from '@shikijs/types'
+
+export function guessEmbeddedLanguages(
+  code: string,
+  _lang: string | undefined,
+  shiki: HighlighterGeneric<any, any>,
+): string[] {
+  const langs = new Set<string>()
+  // For HTML code blocks like Vue SFC
+  for (const match of code.matchAll(/lang=["']([\w-]+)["']/g)) {
+    langs.add(match[1])
+  }
+  // For markdown code blocks
+  for (const match of code.matchAll(/(?:```|~~~)([\w-]+)/g)) {
+    langs.add(match[1])
+  }
+  // For latex
+  for (const match of code.matchAll(/\\begin\{([\w-]+)\}/g)) {
+    langs.add(match[1])
+  }
+
+  // Only include known languages
+  const bundle = shiki.getBundledLanguages()
+  return Array.from(langs)
+    .filter(l => l && bundle[l])
+}
diff --git a/packages/types/src/highlighter.ts b/packages/types/src/highlighter.ts
index d91886ba..73962c58 100644
--- a/packages/types/src/highlighter.ts
+++ b/packages/types/src/highlighter.ts
@@ -143,6 +143,14 @@ export interface HighlighterGeneric<BundledLangKeys extends string, BundledTheme
    * @deprecated
    */
   getInternalContext: () => ShikiInternal
+  /**
+   * Get bundled languages object
+   */
+  getBundledLanguages: () => Record<BundledLangKeys, LanguageInput>
+  /**
+   * Get bundled themes object
+   */
+  getBundledThemes: () => Record<BundledThemeKeys, ThemeInput>
 }
 
 /**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f385004e..6aa9684f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -46,8 +46,8 @@ catalogs:
       specifier: ^0.4.4
       version: 0.4.4
     '@shikijs/vscode-textmate':
-      specifier: ^10.0.1
-      version: 10.0.1
+      specifier: ^10.0.2
+      version: 10.0.2
     '@types/fs-extra':
       specifier: ^11.0.4
       version: 11.0.4
@@ -495,7 +495,7 @@ importers:
     dependencies:
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       chalk:
         specifier: 'catalog:'
         version: 5.4.1
@@ -547,7 +547,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       shiki:
         specifier: workspace:*
         version: link:../shiki
@@ -569,7 +569,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       '@types/hast':
         specifier: ^3.0.4
         version: 3.0.4
@@ -584,7 +584,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       oniguruma-to-es:
         specifier: 'catalog:'
         version: 3.1.0
@@ -596,7 +596,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
     devDependencies:
       vscode-oniguruma:
         specifier: 1.7.0
@@ -654,7 +654,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
     devDependencies:
       monaco-editor-core:
         specifier: 'catalog:'
@@ -728,7 +728,7 @@ importers:
         version: link:../types
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       '@types/hast':
         specifier: ^3.0.4
         version: 3.0.4
@@ -797,7 +797,7 @@ importers:
     dependencies:
       '@shikijs/vscode-textmate':
         specifier: 'catalog:'
-        version: 10.0.1
+        version: 10.0.2
       '@types/hast':
         specifier: ^3.0.4
         version: 3.0.4
@@ -1895,8 +1895,8 @@ packages:
     cpu: [x64]
     os: [win32]
 
-  '@shikijs/vscode-textmate@10.0.1':
-    resolution: {integrity: sha512-fTIQwLF+Qhuws31iw7Ncl1R3HUDtGwIipiJ9iU+UsDUwMhegFcQKQHd51nZjb7CArq0MvON8rbgCGQYWHUKAdg==}
+  '@shikijs/vscode-textmate@10.0.2':
+    resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
   '@stylistic/eslint-plugin@3.0.1':
     resolution: {integrity: sha512-rQ3tcT5N2cynofJfbjUsnL4seoewTaOVBLyUEwtNldo7iNMPo3h/GUQk+Cl3iHEWwRxjq2wuH6q0FufQrbVL1A==}
@@ -6248,7 +6248,7 @@ snapshots:
   '@rollup/rollup-win32-x64-msvc@4.34.4':
     optional: true
 
-  '@shikijs/vscode-textmate@10.0.1': {}
+  '@shikijs/vscode-textmate@10.0.2': {}
 
   '@stylistic/eslint-plugin@3.0.1(eslint@9.19.0(jiti@2.4.2))(typescript@5.7.3)':
     dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3192595e..6e8c3db9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -19,7 +19,7 @@ catalog:
   '@rollup/plugin-node-resolve': ^16.0.0
   '@rollup/plugin-replace': ^6.0.2
   '@rollup/plugin-terser': ^0.4.4
-  '@shikijs/vscode-textmate': ^10.0.1
+  '@shikijs/vscode-textmate': ^10.0.2
   '@types/fs-extra': ^11.0.4
   '@types/hast': ^3.0.4
   '@types/markdown-it': ^14.1.2


Test patch:
diff --git a/packages/shiki/test/out/shorthand-markdown1.html b/packages/shiki/test/out/shorthand-markdown1.html
new file mode 100644
index 00000000..5755ac15
--- /dev/null
+++ b/packages/shiki/test/out/shorthand-markdown1.html
@@ -0,0 +1,14 @@
+<pre class="shiki vitesse-light" style="background-color:#ffffff;color:#393a34" tabindex="0"><code><span class="line"></span>
+<span class="line"><span style="color:#393A34">This is a markdown file</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#999999">```</span><span style="color:#393A34">js</span></span>
+<span class="line"><span style="color:#B07D48">console</span><span style="color:#999999">.</span><span style="color:#59873A">log</span><span style="color:#999999">(</span><span style="color:#B5695977">"</span><span style="color:#B56959">hello</span><span style="color:#B5695977">"</span><span style="color:#999999">)</span></span>
+<span class="line"><span style="color:#999999">```</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#999999">~~~</span><span style="color:#393A34">pug</span></span>
+<span class="line"><span style="color:#1E754F">div</span></span>
+<span class="line"><span style="color:#1E754F">  p</span><span style="color:#393A34"> hello</span></span>
+<span class="line"><span style="color:#999999">~~~</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#393A34">Even those grammars in markdown are lazy loaded, </span><span style="color:#999999">`</span><span style="color:#393A34">codeToHtml</span><span style="color:#999999">`</span><span style="color:#393A34"> shorthand should capture them and load automatically.</span></span>
+<span class="line"></span></code></pre>
\ No newline at end of file
diff --git a/packages/shiki/test/out/shorthand-markdown2.html b/packages/shiki/test/out/shorthand-markdown2.html
new file mode 100644
index 00000000..3a6fa0ca
--- /dev/null
+++ b/packages/shiki/test/out/shorthand-markdown2.html
@@ -0,0 +1,20 @@
+<pre class="shiki vitesse-light" style="background-color:#ffffff;color:#393a34" tabindex="0"><code><span class="line"></span>
+<span class="line"><span style="color:#393A34">Some other languages</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#999999">```</span><span style="color:#393A34">js</span></span>
+<span class="line"><span style="color:#B07D48">console</span><span style="color:#999999">.</span><span style="color:#59873A">log</span><span style="color:#999999">(</span><span style="color:#B5695977">"</span><span style="color:#B56959">hello</span><span style="color:#B5695977">"</span><span style="color:#999999">)</span></span>
+<span class="line"><span style="color:#999999">```</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#999999">~~~</span><span style="color:#393A34">python</span></span>
+<span class="line"><span style="color:#998418">print</span><span style="color:#999999">(</span><span style="color:#B5695977">"</span><span style="color:#B56959">hello</span><span style="color:#B5695977">"</span><span style="color:#999999">)</span></span>
+<span class="line"><span style="color:#999999">~~~</span></span>
+<span class="line"></span>
+<span class="line"><span style="color:#999999">```</span><span style="color:#393A34">html</span></span>
+<span class="line"><span style="color:#999999">&#x3C;</span><span style="color:#1E754F">div</span><span style="color:#B07D48"> class</span><span style="color:#999999">=</span><span style="color:#B5695977">"</span><span style="color:#B56959">foo</span><span style="color:#B5695977">"</span><span style="color:#999999">></span><span style="color:#393A34">bar</span><span style="color:#999999">&#x3C;/</span><span style="color:#1E754F">div</span><span style="color:#999999">></span></span>
+<span class="line"><span style="color:#999999">&#x3C;</span><span style="color:#1E754F">style</span><span style="color:#999999">></span></span>
+<span class="line"><span style="color:#999999">  .</span><span style="color:#B07D48">foo</span><span style="color:#999999"> {</span></span>
+<span class="line"><span style="color:#998418">    color</span><span style="color:#999999">:</span><span style="color:#A65E2B"> red</span><span style="color:#999999">;</span></span>
+<span class="line"><span style="color:#999999">  }</span></span>
+<span class="line"><span style="color:#999999">&#x3C;/</span><span style="color:#1E754F">style</span><span style="color:#999999">></span></span>
+<span class="line"><span style="color:#999999">```</span></span>
+<span class="line"></span></code></pre>
\ No newline at end of file
diff --git a/packages/shiki/test/shorthands-markdown.test.ts b/packages/shiki/test/shorthands-markdown.test.ts
new file mode 100644
index 00000000..89a470cd
--- /dev/null
+++ b/packages/shiki/test/shorthands-markdown.test.ts
@@ -0,0 +1,76 @@
+import { expect, it } from 'vitest'
+import { codeToHtml, getSingletonHighlighter } from '../src'
+
+const inputMarkdown1 = `
+This is a markdown file
+
+\`\`\`js
+console.log("hello")
+\`\`\`
+
+~~~pug
+div
+  p hello
+~~~
+
+Even those grammars in markdown are lazy loaded, \`codeToHtml\` shorthand should capture them and load automatically.
+`
+
+const inputMarkdown2 = `
+Some other languages
+
+\`\`\`js
+console.log("hello")
+\`\`\`
+
+~~~python
+print("hello")
+~~~
+
+\`\`\`html
+<div class="foo">bar</div>
+<style>
+  .foo {
+    color: red;
+  }
+</style>
+\`\`\`
+`
+
+it('codeToHtml', async () => {
+  const highlighter = await getSingletonHighlighter()
+  expect(highlighter.getLoadedLanguages())
+    .toEqual([])
+
+  await expect(await codeToHtml(inputMarkdown1, { lang: 'markdown', theme: 'vitesse-light' }))
+    .toMatchFileSnapshot(`out/shorthand-markdown1.html`)
+
+  expect.soft(highlighter.getLoadedLanguages())
+    .toContain('javascript')
+  expect.soft(highlighter.getLoadedLanguages())
+    .toContain('pug')
+
+  await expect(await codeToHtml(inputMarkdown2, { lang: 'markdown', theme: 'vitesse-light' }))
+    .toMatchFileSnapshot(`out/shorthand-markdown2.html`)
+
+  expect.soft(highlighter.getLoadedLanguages())
+    .toContain('python')
+  expect.soft(highlighter.getLoadedLanguages())
+    .toContain('html')
+
+  expect.soft(highlighter.getLoadedLanguages())
+    .toMatchInlineSnapshot(`
+      [
+        "javascript",
+        "css",
+        "html",
+        "pug",
+        "python",
+        "markdown",
+        "md",
+        "js",
+        "jade",
+        "py",
+      ]
+    `)
+})