Compare commits

..
12 changed files with 552 additions and 23 deletions
+4
View File
@@ -11,8 +11,12 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "6.11.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "6.12.4",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.0",
"@codemirror/view": "6.43.9",
"@element-plus/icons-vue": "^2.3.2",
"@milkdown/crepe": "7.22.1",
"@milkdown/kit": "7.22.1",
+12
View File
@@ -8,12 +8,24 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: 6.11.0
version: 6.11.0
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.2
'@codemirror/language':
specifier: 6.12.4
version: 6.12.4
'@codemirror/state':
specifier: 6.7.1
version: 6.7.1
'@codemirror/theme-one-dark':
specifier: ^6.1.0
version: 6.1.3
'@codemirror/view':
specifier: 6.43.9
version: 6.43.9
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.41(typescript@5.9.3))
@@ -0,0 +1,45 @@
// Usage: node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
// Source: @iconify-json/vscode-icons 1.2.76 (MIT). No runtime network requests.
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { bundledLanguagesInfo } from 'shiki/langs'
const source = process.argv[2]
if (!source) throw new Error('Provide the extracted vscode-icons package directory')
const data = JSON.parse(readFileSync(resolve(source, 'icons.json'), 'utf8'))
const overrides = {
ahk: 'autohotkey', ahk2: 'autohotkey', asm: 'assembly', bat: 'bat',
'angular-html': 'angular', 'angular-ts': 'angular',
'common-lisp': 'lisp', 'emacs-lisp': 'lisp',
'fortran-fixed-form': 'fortran', 'fortran-free-form': 'fortran',
'git-commit': 'git', 'git-rebase': 'git',
jsonc: 'json', jsonl: 'json', shellscript: 'shell', shellsession: 'shell',
jsx: 'reactjs', tsx: 'reactts', latex: 'tex', bibtex: 'bibtex',
'objective-c': 'objectivec', 'objective-cpp': 'objectivecpp',
dart: 'dartlang', d: 'dlang', v: 'vlang', gdshader: 'godot',
fish: 'shell', 'ssh-config': 'shell', 'vue-html': 'vue', 'vue-vine': 'vue',
'html-derivative': 'html', qss: 'qt', rbs: 'ruby',
}
const groups = new Map()
const unmatched = []
for (const info of [...bundledLanguagesInfo, { id: 'text', name: 'Text' }]) {
const candidates = [overrides[info.id], info.id, ...(info.aliases ?? []), info.name.toLowerCase().replace(/\s+/g, '')].filter(Boolean)
const icon = candidates.map(name => `file-type-${name}`).find(name => data.icons[name])
if (!icon) { unmatched.push(info.id); continue }
const ids = groups.get(icon) ?? []
ids.push(info.id)
groups.set(icon, ids)
}
const base = '.milkdown-host .language-list-item[data-language]'
const svgUrl = icon => {
const item = data.icons[icon]
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${item.width ?? data.width ?? 32} ${item.height ?? data.height ?? 32}">${item.body}</svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
let css = `/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
for (const [icon, ids] of groups) {
css += ids.map(id => `${base}[data-language="${id}"]::before`).join(',\n') + ` { background-image: ${svgUrl(icon)}; }\n`
}
writeFileSync(new URL('../src/features/editor/language-icons.css', import.meta.url), css)
writeFileSync(new URL('../src/features/editor/language-icons-LICENSE.txt', import.meta.url), readFileSync(resolve(source, 'license.txt')))
console.log(`${bundledLanguagesInfo.length + 1 - unmatched.length} languages mapped; generic file icon for: ${unmatched.join(', ')}`)
@@ -3,10 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -45,6 +49,51 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, 0)))
})
for (let attempt = 0; attempt < 100 && !wrapper.find('.language-button').exists(); attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await wrapper.get('.language-button').trigger('click')
const item = wrapper.get(`.language-list-item[data-language="${id}"]`)
expect(item.text()).toBe(label)
await item.trigger('click')
const markdown = editor.action(getMarkdown())
expect(markdown).toContain(`\`\`\`${id}\n`)
const html = await renderMarkdown(markdown)
expect(new Set([...html.matchAll(/--shiki-light:([^;" ]+)/g)].map(match => match[1])).size).toBeGreaterThan(1)
const reopened = mount(VisualMarkdownEditor, { props: { initialContent: markdown }, attachTo: document.body })
mounted.push(reopened)
const restored = await waitForEditor(reopened)
expect(restored.action(ctx => ctx.get(editorViewCtx).state.doc.firstChild?.attrs.language)).toBe(id)
})
it.each(['github-light', 'github-dark'] as const)('keeps Shiki %s mappings after Crepe merges its defaults', async theme => {
useThemeStore().codeBlockTheme = theme
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const config = editor.action(ctx => ctx.get(codeBlockConfig.key))
const matching = config.languages.filter(item => item.alias.includes('python'))
expect(matching).toHaveLength(1)
for (const name of ['Java', 'Go', 'Rust']) {
const language = config.languages.find(item => item.name === name.toLowerCase())
expect(language, `${name} remains available`).toBeDefined()
const view = new CodeMirror({ doc: 'class Example {}', extensions: [...config.extensions, await language!.load()] })
try { expect(view.dom.querySelector('.shiki-token')).not.toBeNull() }
finally { view.destroy() }
}
const cm = new CodeMirror({ doc: 'print("Hello")', extensions: [...config.extensions, await matching[0]!.load()] })
try {
const string = [...cm.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(el => el.textContent?.includes('Hello'))
expect(string?.style.color.toUpperCase()).toBe(theme === 'github-dark' ? '#9ECBFF' : '#032F62')
} finally { cm.destroy() }
})
it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
@@ -2,7 +2,13 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -34,6 +40,7 @@ const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
@@ -118,7 +125,6 @@ onMounted(async () => {
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false,
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
@@ -170,6 +176,14 @@ onMounted(async () => {
},
},
})
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
@@ -180,13 +194,14 @@ onMounted(async () => {
})
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
applyProofingPreferences()
loading.value = false
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { void crepe?.destroy() })
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -288,7 +303,10 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.language-picker[popover]) { position: fixed !important; inset: auto; left: var(--picker-left) !important; top: var(--picker-top) !important; margin: 0; padding: 0; border: 0; overflow: visible; background: transparent; color: var(--color-text-primary); }
.milkdown-host :deep(.language-picker .language-list) { height: auto; max-height: var(--picker-list-height, 280px); }
.milkdown-host :deep(.language-picker .list-wrapper) { width: min(260px, calc(100vw - 24px)); border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Roberto Huertas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
function sync() {
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) {
search.autocomplete = 'off'
search.spellcheck = false
}
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16
const above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
})
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
}
const observer = new MutationObserver(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
root.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
return () => {
observer.disconnect()
root.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
}
}
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { Compartment } from '@codemirror/state'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
const view = new EditorView({ doc: 'print("Hello")', extensions: [support] })
editors.push(view)
const tokenize = await getCodeTokenizer(theme)
const expected = tokenize('print("Hello")', 'python')[0]!.find(token => token.content.includes('Hello'))!
const colored = [...view.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(node => node.textContent?.includes('Hello'))!
expect(colored).toBeDefined()
const sample = document.createElement('span'); sample.style.color = expected.color!
expect(colored.style.color).toBe(sample.style.color)
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'def hello():\n return 42' } })
expect(view.state.doc.toString()).toContain('return 42')
expect(view.dom.querySelectorAll('.shiki-token').length).toBeGreaterThan(2)
expect(view.dom.textContent).toContain('return 42')
})
it('reconfigures language and theme without modifying the document', async () => {
const config = new Compartment()
const view = new EditorView({ doc: 'const answer = 42', extensions: [config.of(await shikiLanguage('javascript', 'github-light'))] })
editors.push(view)
const before = view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color
view.dispatch({ effects: config.reconfigure(await shikiLanguage('javascript', 'github-dark')) })
expect(view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color).not.toBe(before)
expect(view.state.doc.toString()).toBe('const answer = 42')
view.dispatch({ effects: config.reconfigure(await shikiLanguage('text', 'github-dark')) })
expect(view.state.doc.toString()).toBe('const answer = 42')
})
it('offers fenced-code aliases and retains the LaTeX selector', () => {
const languages = shikiLanguages('github-light')
expect(languages.find(item => item.name === 'python')?.alias).toContain('py')
expect(languages.find(item => item.name === 'latex')?.alias).toContain('latex')
})
it('offers every bundled Shiki language and alias', () => {
const languages = shikiLanguages('github-light')
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
for (const info of bundledLanguagesInfo) {
const language = languages.find(item => item.alias.includes(info.id))!
expect(language, info.id).toBeDefined()
expect(language.name).toBe(info.id)
expect(language.alias).toContain(info.name.toLowerCase())
for (const alias of info.aliases ?? []) expect(language.alias).toContain(alias.toLowerCase())
}
})
it('loads every bundled grammar and produces tokens with both GitHub themes', async () => {
for (const info of bundledLanguagesInfo) {
for (const theme of ['github-light', 'github-dark'] as const) {
const tokenize = await getCodeTokenizer(theme, info.id)
const tokens = tokenize('example = 42', info.id).flat()
expect(tokens.map(token => token.content).join(''), info.id).toBe('example = 42')
expect(tokens.every(token => token.color), info.id).toBe(true)
}
}
}, 120000)
@@ -0,0 +1,64 @@
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { bundledLanguagesInfo } from 'shiki/langs'
import { getCodeTokenizer } from '@/utils/markdown'
type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) { this.decorations = this.highlight(view) }
update(update: ViewUpdate) {
if (update.docChanged) this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
const from = offset
offset += token.content.length
if (from === offset) return []
const fontStyle = token.fontStyle ?? 0
return [Decoration.mark({
class: 'shiki-token',
attributes: { style: `color:${token.color};font-style:${fontStyle & 1 ? 'italic' : 'normal'};font-weight:${fontStyle & 2 ? 'bold' : 'normal'};text-decoration:${fontStyle & 4 ? 'underline' : 'none'}` },
}).range(from, offset)]
})
})
return Decoration.set(ranges)
}
}, { decorations: value => value.decorations })
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
return new LanguageSupport(parser, highlights)
}
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
return [
...bundledLanguagesInfo.map(info => LanguageDescription.of({
name: info.id,
alias: [info.name, ...(info.aliases ?? [])],
load: () => shikiLanguage(info.id, theme),
})),
LanguageDescription.of({ name: 'text', alias: ['Plain text', 'txt', 'plaintext'], load: () => shikiLanguage('text', theme) }),
]
}
const languageLabels = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.name] as const),
))
export function renderCodeLanguage(language: string): string {
return languageLabels.get(language.toLowerCase()) ?? (['text', 'txt', 'plaintext'].includes(language.toLowerCase()) ? 'Plain text' : language)
}
export function shikiEditorTheme(theme: CodeTheme) {
return EditorView.theme({
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
'.cm-gutters': { color: 'var(--color-code-muted)', backgroundColor: 'var(--color-code-background)' },
}, { dark: theme === 'github-dark' })
}
+8
View File
@@ -10,4 +10,12 @@ describe('Shiki GitHub 双主题', () => {
expect(html).toContain('--shiki-light')
expect(html).toContain('--shiki-dark')
})
it('按需加载原先未支持的语言,并解析 Shiki 别名', async () => {
const source = 'fn main() { let answer = 42; }'
const [rust, alias] = await Promise.all([highlightCode(source, 'rust'), highlightCode(source, 'rs')])
expect(alias).toBe(rust)
expect(rust).toContain('github-dark')
expect(new Set([...rust.matchAll(/--shiki-light:([^;" ]+)/g)].map(match => match[1])).size).toBeGreaterThan(1)
})
})
+38 -18
View File
@@ -1,16 +1,8 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import css from '@shikijs/langs/css'
import html from '@shikijs/langs/html'
import javascript from '@shikijs/langs/javascript'
import json from '@shikijs/langs/json'
import markdown from '@shikijs/langs/markdown'
import python from '@shikijs/langs/python'
import shell from '@shikijs/langs/shellscript'
import sql from '@shikijs/langs/sql'
import typescript from '@shikijs/langs/typescript'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
@@ -19,25 +11,53 @@ marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({
themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
engine: createJavaScriptRegexEngine(),
langs: [],
engine: createOnigurumaEngine(import('shiki/wasm')),
})
const languageAliases: Record<string, string> = {
bash: 'shell', js: 'javascript', md: 'markdown', plaintext: 'text', py: 'python', sh: 'shell', ts: 'typescript',
const languageAliases = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.id] as const),
))
const languageLoads = new Map<string, Promise<void>>()
const languageLoaders = new Map(bundledLanguagesInfo.map(info => [info.id, info.import]))
async function loadCodeLanguage(requestedLanguage: string) {
const shiki = await highlighter
const language = languageAliases.get(requestedLanguage.toLowerCase())
if (!language) return { shiki, language: 'text' as const }
let loading = languageLoads.get(language)
if (!loading) {
loading = shiki.loadLanguage(languageLoaders.get(language)!).catch(error => {
languageLoads.delete(language)
throw error
})
languageLoads.set(language, loading)
}
await loading
return { shiki, language }
}
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
const shiki = await highlighter
const language = languageAliases[requestedLanguage] ?? requestedLanguage
const loadedLanguage = shiki.getLoadedLanguages().includes(language as never) ? language : 'markdown'
const { shiki, language } = await loadCodeLanguage(requestedLanguage)
return shiki.codeToHtml(source, {
lang: loadedLanguage,
lang: language,
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false,
})
}
/** Share the initialized grammar/theme registry with editable code blocks. */
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', requestedLanguage = 'text') {
const { shiki } = await loadCodeLanguage(requestedLanguage)
return (source: string, requestedLanguage: string) => {
const language = languageAliases.get(requestedLanguage.toLowerCase()) ?? 'text'
return shiki.codeToTokens(source, {
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
theme,
}).tokens
}
}
export async function renderMarkdown(source: string): Promise<string> {
const html = marked.parse(source, { async: false }) as string
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')