forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { ThemeRegistration } from 'shiki'
2import { createHighlighterCore, type HighlighterCore } from 'shiki/core'
3import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
4
5let highlighter: HighlighterCore | null = null
6
7function replaceThemeColors(
8 theme: ThemeRegistration,
9 replacements: Record<string, string>,
10): ThemeRegistration {
11 let themeString = JSON.stringify(theme)
12 for (const [oldColor, newColor] of Object.entries(replacements)) {
13 themeString = themeString.replaceAll(oldColor, newColor)
14 themeString = themeString.replaceAll(oldColor.toLowerCase(), newColor)
15 themeString = themeString.replaceAll(oldColor.toUpperCase(), newColor)
16 }
17 return JSON.parse(themeString)
18}
19
20export async function getShikiHighlighter(): Promise<HighlighterCore> {
21 if (!highlighter) {
22 highlighter = await createHighlighterCore({
23 themes: [
24 import('@shikijs/themes/github-dark'),
25 import('@shikijs/themes/github-light').then(t =>
26 replaceThemeColors(t.default ?? t, {
27 '#22863A': '#227436', // green
28 '#E36209': '#BA4D02', // orange
29 '#D73A49': '#CD3443', // red
30 '#B31D28': '#AC222F', // red
31 }),
32 ),
33 ],
34 langs: [
35 // Core web languages
36 import('@shikijs/langs/javascript'),
37 import('@shikijs/langs/typescript'),
38 import('@shikijs/langs/json'),
39 import('@shikijs/langs/jsonc'),
40 import('@shikijs/langs/html'),
41 import('@shikijs/langs/css'),
42 import('@shikijs/langs/scss'),
43 import('@shikijs/langs/less'),
44
45 // Frameworks
46 import('@shikijs/langs/vue'),
47 import('@shikijs/langs/jsx'),
48 import('@shikijs/langs/tsx'),
49 import('@shikijs/langs/svelte'),
50 import('@shikijs/langs/astro'),
51 import('@shikijs/langs/glimmer-js'),
52 import('@shikijs/langs/glimmer-ts'),
53
54 // Shell/CLI
55 import('@shikijs/langs/bash'),
56 import('@shikijs/langs/shell'),
57
58 // Config/Data formats
59 import('@shikijs/langs/yaml'),
60 import('@shikijs/langs/toml'),
61 import('@shikijs/langs/xml'),
62 import('@shikijs/langs/markdown'),
63
64 // Other languages
65 import('@shikijs/langs/diff'),
66 import('@shikijs/langs/sql'),
67 import('@shikijs/langs/graphql'),
68 import('@shikijs/langs/python'),
69 import('@shikijs/langs/rust'),
70 import('@shikijs/langs/go'),
71 ],
72 langAlias: {
73 gjs: 'glimmer-js',
74 gts: 'glimmer-ts',
75 },
76 engine: createJavaScriptRegexEngine(),
77 })
78 }
79 return highlighter
80}
81
82/**
83 * Synchronously highlight a code block using an already-initialized highlighter.
84 * Use this when you have already awaited getShikiHighlighter() and need to
85 * highlight multiple blocks without async overhead (e.g., in marked renderers).
86 *
87 * @param shiki - The initialized Shiki highlighter instance
88 * @param code - The code to highlight
89 * @param language - The language identifier (e.g., 'typescript', 'bash')
90 * @returns HTML string with syntax highlighting
91 */
92export function highlightCodeSync(shiki: HighlighterCore, code: string, language: string): string {
93 const loadedLangs = shiki.getLoadedLanguages()
94
95 if (loadedLangs.includes(language as never)) {
96 try {
97 let html = shiki.codeToHtml(code, {
98 lang: language,
99 themes: { light: 'github-light', dark: 'github-dark' },
100 defaultColor: 'dark',
101 })
102 // Remove inline style from <pre> tag so CSS can control appearance
103 html = html.replace(/<pre([^>]*) style="[^"]*"/, '<pre$1')
104 // Shiki doesn't encode > in text content (e.g., arrow functions =>)
105 // We need to encode them for HTML validation
106 return escapeRawGt(html)
107 } catch {
108 // Fall back to plain
109 }
110 }
111
112 // Plain code block for unknown languages
113 const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
114 return `<pre><code class="language-${language}">${escaped}</code></pre>\n`
115}
116
117/**
118 * Highlight a code block with syntax highlighting (async convenience wrapper).
119 * Initializes the highlighter if needed, then delegates to highlightCodeSync.
120 *
121 * @param code - The code to highlight
122 * @param language - The language identifier (e.g., 'typescript', 'bash')
123 * @returns HTML string with syntax highlighting
124 */
125export async function highlightCodeBlock(code: string, language: string): Promise<string> {
126 const shiki = await getShikiHighlighter()
127 return highlightCodeSync(shiki, code, language)
128}
129
130/**
131 * Escape raw > characters in HTML text content.
132 * Shiki outputs > without encoding in constructs like arrow functions (=>).
133 * This replaces > that appear in text content (after >) but not inside tags.
134 *
135 * @internal Exported for testing
136 */
137export function escapeRawGt(html: string): string {
138 // Match > that appears after a closing tag or other > (i.e., in text content)
139 // Pattern: after </...> or after >, match any > that isn't starting a tag
140 return html.replace(/>([^<]*)/g, (match, textContent) => {
141 // Encode any > in the text content portion
142 const escapedText = textContent.replace(/>/g, '>')
143 return `>${escapedText}`
144 })
145}