fix(editor): 修复语言菜单裁剪并接入 GitHub Shiki 配色
This commit is contained in:
@@ -11,8 +11,12 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/commands": "6.11.0",
|
||||||
"@codemirror/lang-markdown": "^6.5.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/theme-one-dark": "^6.1.0",
|
||||||
|
"@codemirror/view": "6.43.9",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@milkdown/crepe": "7.22.1",
|
"@milkdown/crepe": "7.22.1",
|
||||||
"@milkdown/kit": "7.22.1",
|
"@milkdown/kit": "7.22.1",
|
||||||
|
|||||||
Generated
+12
@@ -8,12 +8,24 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@codemirror/commands':
|
||||||
|
specifier: 6.11.0
|
||||||
|
version: 6.11.0
|
||||||
'@codemirror/lang-markdown':
|
'@codemirror/lang-markdown':
|
||||||
specifier: ^6.5.0
|
specifier: ^6.5.0
|
||||||
version: 6.5.2
|
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':
|
'@codemirror/theme-one-dark':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
|
'@codemirror/view':
|
||||||
|
specifier: 6.43.9
|
||||||
|
version: 6.43.9
|
||||||
'@element-plus/icons-vue':
|
'@element-plus/icons-vue':
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2(vue@3.5.41(typescript@5.9.3))
|
version: 2.3.2(vue@3.5.41(typescript@5.9.3))
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { TextSelection } from '@milkdown/kit/prose/state'
|
|||||||
import { getMarkdown } from '@milkdown/kit/utils'
|
import { getMarkdown } from '@milkdown/kit/utils'
|
||||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
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'
|
||||||
|
|
||||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||||
|
|
||||||
@@ -45,6 +48,20 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||||
|
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)
|
||||||
|
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 () => {
|
it('applies bold from the top toolbar to the selected text', async () => {
|
||||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
|
||||||
mounted.push(wrapper)
|
mounted.push(wrapper)
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { Link } from '@element-plus/icons-vue'
|
import { Link } from '@element-plus/icons-vue'
|
||||||
import { Crepe } from '@milkdown/crepe'
|
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 } from './shikiCodeMirror'
|
||||||
|
import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||||
import {
|
import {
|
||||||
createCodeBlockCommand,
|
createCodeBlockCommand,
|
||||||
toggleEmphasisCommand,
|
toggleEmphasisCommand,
|
||||||
@@ -34,6 +39,7 @@ const editorRoot = ref<HTMLElement | null>(null)
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const fontSizeInput = ref(16)
|
const fontSizeInput = ref(16)
|
||||||
let crepe: Crepe | null = null
|
let crepe: Crepe | null = null
|
||||||
|
let disposeLanguagePicker: (() => void) | undefined
|
||||||
|
|
||||||
function applyProofingPreferences() {
|
function applyProofingPreferences() {
|
||||||
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
|
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
|
||||||
@@ -118,7 +124,6 @@ onMounted(async () => {
|
|||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
|
||||||
[Crepe.Feature.CodeMirror]: {
|
[Crepe.Feature.CodeMirror]: {
|
||||||
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
|
|
||||||
previewOnlyByDefault: false,
|
previewOnlyByDefault: false,
|
||||||
searchPlaceholder: t('搜索语言', 'Search languages'),
|
searchPlaceholder: t('搜索语言', 'Search languages'),
|
||||||
noResultText: t('没有匹配的语言', 'No matching language'),
|
noResultText: t('没有匹配的语言', 'No matching language'),
|
||||||
@@ -170,6 +175,13 @@ 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),
|
||||||
|
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
||||||
|
})))
|
||||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||||
crepe.on((listener) => {
|
crepe.on((listener) => {
|
||||||
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
|
||||||
@@ -180,13 +192,14 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
await crepe.create()
|
await crepe.create()
|
||||||
|
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
|
||||||
applyProofingPreferences()
|
applyProofingPreferences()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
|
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
|
||||||
|
|
||||||
onBeforeUnmount(() => { void crepe?.destroy() })
|
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
|
||||||
|
|
||||||
defineExpose({ getEditor: () => crepe?.editor })
|
defineExpose({ getEditor: () => crepe?.editor })
|
||||||
</script>
|
</script>
|
||||||
@@ -288,7 +301,10 @@ defineExpose({ getEditor: () => crepe?.editor })
|
|||||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
.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(.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(.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-editor),
|
||||||
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
|
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
|
||||||
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
|
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
|
||||||
|
|||||||
@@ -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,43 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, expect, it } from 'vitest'
|
||||||
|
import { Compartment } from '@codemirror/state'
|
||||||
|
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')
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
||||||
|
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
||||||
|
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)
|
||||||
|
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 [
|
||||||
|
{ name: 'C', alias: ['c'] },
|
||||||
|
{ name: 'C++', alias: ['cpp', 'c++'] },
|
||||||
|
{ name: 'Python', alias: ['python', 'py'] },
|
||||||
|
{ name: 'JavaScript', alias: ['javascript', 'js'] },
|
||||||
|
{ name: 'TypeScript', alias: ['typescript', 'ts'] },
|
||||||
|
{ name: 'HTML', alias: ['html'] },
|
||||||
|
{ name: 'CSS', alias: ['css'] },
|
||||||
|
{ name: 'JSON', alias: ['json'] },
|
||||||
|
{ name: 'Shell', alias: ['shell', 'bash', 'sh'] },
|
||||||
|
{ name: 'SQL', alias: ['sql'] },
|
||||||
|
{ name: 'Markdown', alias: ['markdown', 'md'] },
|
||||||
|
{ name: 'Plain text', alias: ['text', 'plaintext'] },
|
||||||
|
{ name: 'LaTeX', alias: ['latex'] },
|
||||||
|
].map(({ name, alias }) => LanguageDescription.of({
|
||||||
|
name, alias, load: () => shikiLanguage(alias[0]!, theme),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { marked } from 'marked'
|
|||||||
import { createHighlighterCore } from 'shiki/core'
|
import { createHighlighterCore } from 'shiki/core'
|
||||||
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
||||||
import css from '@shikijs/langs/css'
|
import css from '@shikijs/langs/css'
|
||||||
|
import c from '@shikijs/langs/c'
|
||||||
|
import cpp from '@shikijs/langs/cpp'
|
||||||
import html from '@shikijs/langs/html'
|
import html from '@shikijs/langs/html'
|
||||||
import javascript from '@shikijs/langs/javascript'
|
import javascript from '@shikijs/langs/javascript'
|
||||||
import json from '@shikijs/langs/json'
|
import json from '@shikijs/langs/json'
|
||||||
@@ -19,7 +21,7 @@ marked.setOptions({ gfm: true, breaks: true })
|
|||||||
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||||
const highlighter = createHighlighterCore({
|
const highlighter = createHighlighterCore({
|
||||||
themes: [githubLight, githubDark],
|
themes: [githubLight, githubDark],
|
||||||
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
|
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql, c, cpp],
|
||||||
engine: createJavaScriptRegexEngine(),
|
engine: createJavaScriptRegexEngine(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -38,6 +40,18 @@ export async function highlightCode(source: string, requestedLanguage = 'text'):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Share the initialized grammar/theme registry with editable code blocks. */
|
||||||
|
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark') {
|
||||||
|
const shiki = await highlighter
|
||||||
|
return (source: string, requestedLanguage: string) => {
|
||||||
|
const language = languageAliases[requestedLanguage] ?? requestedLanguage
|
||||||
|
return shiki.codeToTokens(source, {
|
||||||
|
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
|
||||||
|
theme,
|
||||||
|
}).tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function renderMarkdown(source: string): Promise<string> {
|
export async function renderMarkdown(source: string): Promise<string> {
|
||||||
const html = marked.parse(source, { async: false }) as string
|
const html = marked.parse(source, { async: false }) as string
|
||||||
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
||||||
|
|||||||
Reference in New Issue
Block a user