fix(editor): 支持完整 Shiki 语言并修复语言标识与图标展示
This commit is contained in:
@@ -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,13 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
|
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 { 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 { useThemeStore } from '@/stores/theme'
|
||||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||||
import { EditorView as CodeMirror } from '@codemirror/view'
|
import { EditorView as CodeMirror } from '@codemirror/view'
|
||||||
|
import { renderMarkdown } from '@/utils/markdown'
|
||||||
|
|
||||||
type EditorComponent = { getEditor: () => Editor | undefined }
|
type EditorComponent = { getEditor: () => Editor | undefined }
|
||||||
|
|
||||||
@@ -48,6 +49,30 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('VisualMarkdownEditor formatting toolbars', () => {
|
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 => {
|
it.each(['github-light', 'github-dark'] as const)('keeps Shiki %s mappings after Crepe merges its defaults', async theme => {
|
||||||
useThemeStore().codeBlockTheme = theme
|
useThemeStore().codeBlockTheme = theme
|
||||||
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
|
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
|
||||||
@@ -57,10 +82,11 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
|||||||
const matching = config.languages.filter(item => item.alias.includes('python'))
|
const matching = config.languages.filter(item => item.alias.includes('python'))
|
||||||
expect(matching).toHaveLength(1)
|
expect(matching).toHaveLength(1)
|
||||||
for (const name of ['Java', 'Go', 'Rust']) {
|
for (const name of ['Java', 'Go', 'Rust']) {
|
||||||
const language = config.languages.find(item => item.name === name)
|
const language = config.languages.find(item => item.name === name.toLowerCase())
|
||||||
expect(language, `${name} remains available`).toBeDefined()
|
expect(language, `${name} remains available`).toBeDefined()
|
||||||
const support = await language!.load()
|
const view = new CodeMirror({ doc: 'class Example {}', extensions: [...config.extensions, await language!.load()] })
|
||||||
expect(support.language.parser.parse('class Example {}').length).toBe(16)
|
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()] })
|
const cm = new CodeMirror({ doc: 'print("Hello")', extensions: [...config.extensions, await matching[0]!.load()] })
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
|||||||
import { basicSetup } from 'codemirror'
|
import { basicSetup } from 'codemirror'
|
||||||
import { keymap } from '@codemirror/view'
|
import { keymap } from '@codemirror/view'
|
||||||
import { indentWithTab } from '@codemirror/commands'
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
import { shikiEditorTheme, shikiLanguages } from './shikiCodeMirror'
|
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
|
||||||
|
import './language-icons.css'
|
||||||
import { installLanguagePickerPopover } from './languagePickerPopover'
|
import { installLanguagePickerPopover } from './languagePickerPopover'
|
||||||
import {
|
import {
|
||||||
createCodeBlockCommand,
|
createCodeBlockCommand,
|
||||||
@@ -179,7 +180,8 @@ onMounted(async () => {
|
|||||||
// Replace both AFTER feature configuration to avoid default grammar collisions.
|
// Replace both AFTER feature configuration to avoid default grammar collisions.
|
||||||
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
|
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
|
||||||
...config,
|
...config,
|
||||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme, config.languages),
|
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||||
|
renderLanguage: renderCodeLanguage,
|
||||||
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
|
||||||
})))
|
})))
|
||||||
crepe.editor.use(fontSizeMarkdownPlugin)
|
crepe.editor.use(fontSizeMarkdownPlugin)
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { afterEach, expect, it } from 'vitest'
|
import { afterEach, expect, it } from 'vitest'
|
||||||
import { Compartment } from '@codemirror/state'
|
import { Compartment } from '@codemirror/state'
|
||||||
import { LanguageDescription } from '@codemirror/language'
|
import { bundledLanguagesInfo } from 'shiki/langs'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
|
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
|
||||||
import { getCodeTokenizer } from '@/utils/markdown'
|
import { getCodeTokenizer } from '@/utils/markdown'
|
||||||
@@ -39,19 +39,29 @@ it('reconfigures language and theme without modifying the document', async () =>
|
|||||||
|
|
||||||
it('offers fenced-code aliases and retains the LaTeX selector', () => {
|
it('offers fenced-code aliases and retains the LaTeX selector', () => {
|
||||||
const languages = shikiLanguages('github-light')
|
const languages = shikiLanguages('github-light')
|
||||||
expect(languages.find(item => item.name === 'Python')?.alias).toContain('py')
|
expect(languages.find(item => item.name === 'python')?.alias).toContain('py')
|
||||||
expect(languages.find(item => item.name === 'LaTeX')?.alias).toContain('latex')
|
expect(languages.find(item => item.name === 'latex')?.alias).toContain('latex')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves original loaders and metadata while replacing supported languages', async () => {
|
it('offers every bundled Shiki language and alias', () => {
|
||||||
const originalPython = LanguageDescription.of({ name: 'Python', alias: ['py', 'custom-python'], extensions: ['py'], filename: /^SConstruct$/, load: () => shikiLanguage('text', 'github-light') })
|
const languages = shikiLanguages('github-light')
|
||||||
const originalRust = LanguageDescription.of({ name: 'Rust', alias: ['rs'], extensions: ['rs'], load: () => shikiLanguage('text', 'github-light') })
|
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
|
||||||
const languages = shikiLanguages('github-dark', [originalPython, originalRust])
|
for (const info of bundledLanguagesInfo) {
|
||||||
expect(languages.find(item => item.name === 'Rust')).toBe(originalRust)
|
const language = languages.find(item => item.alias.includes(info.id))!
|
||||||
const python = languages.find(item => item.name === 'Python')!
|
expect(language, info.id).toBeDefined()
|
||||||
expect(languages.filter(item => item.alias.includes('python'))).toHaveLength(1)
|
expect(language.name).toBe(info.id)
|
||||||
expect(python.alias).toContain('custom-python')
|
expect(language.alias).toContain(info.name.toLowerCase())
|
||||||
expect(python.extensions).toEqual(['py'])
|
for (const alias of info.aliases ?? []) expect(language.alias).toContain(alias.toLowerCase())
|
||||||
expect(python.filename).toBe(originalPython.filename)
|
}
|
||||||
expect(await python.load()).not.toBe(await originalPython.load())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
|
||||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
||||||
|
import { bundledLanguagesInfo } from 'shiki/langs'
|
||||||
import { getCodeTokenizer } from '@/utils/markdown'
|
import { getCodeTokenizer } from '@/utils/markdown'
|
||||||
|
|
||||||
type CodeTheme = 'github-light' | 'github-dark'
|
type CodeTheme = 'github-light' | 'github-dark'
|
||||||
|
|
||||||
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
|
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
|
||||||
const tokenize = await getCodeTokenizer(theme)
|
const tokenize = await getCodeTokenizer(theme, language)
|
||||||
const highlights = ViewPlugin.fromClass(class {
|
const highlights = ViewPlugin.fromClass(class {
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
|
|
||||||
@@ -39,42 +40,22 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
|
|||||||
return new LanguageSupport(parser, highlights)
|
return new LanguageSupport(parser, highlights)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shikiLanguages(theme: CodeTheme, originalLanguages: readonly LanguageDescription[] = []): LanguageDescription[] {
|
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
|
||||||
const overrides = [
|
return [
|
||||||
{ name: 'C', alias: ['c'] },
|
...bundledLanguagesInfo.map(info => LanguageDescription.of({
|
||||||
{ name: 'C++', alias: ['cpp', 'c++'] },
|
name: info.id,
|
||||||
{ name: 'Python', alias: ['python', 'py'] },
|
alias: [info.name, ...(info.aliases ?? [])],
|
||||||
{ name: 'JavaScript', alias: ['javascript', 'js'] },
|
load: () => shikiLanguage(info.id, theme),
|
||||||
{ name: 'TypeScript', alias: ['typescript', 'ts'] },
|
})),
|
||||||
{ name: 'HTML', alias: ['html'] },
|
LanguageDescription.of({ name: 'text', alias: ['Plain text', 'txt', 'plaintext'], load: () => shikiLanguage('text', theme) }),
|
||||||
{ name: 'CSS', alias: ['css'] },
|
]
|
||||||
{ name: 'JSON', alias: ['json'] },
|
}
|
||||||
{ name: 'Shell', alias: ['shell', 'bash', 'sh'] },
|
const languageLabels = new Map(bundledLanguagesInfo.flatMap(info =>
|
||||||
{ name: 'SQL', alias: ['sql'] },
|
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.name] as const),
|
||||||
{ name: 'Markdown', alias: ['markdown', 'md'] },
|
))
|
||||||
{ name: 'Plain text', alias: ['text', 'plaintext'] },
|
export function renderCodeLanguage(language: string): string {
|
||||||
{ name: 'LaTeX', alias: ['latex'] },
|
return languageLabels.get(language.toLowerCase()) ?? (['text', 'txt', 'plaintext'].includes(language.toLowerCase()) ? 'Plain text' : language)
|
||||||
].map(({ name, alias }) => LanguageDescription.of({
|
|
||||||
name, alias, load: () => shikiLanguage(alias[0]!, theme),
|
|
||||||
}))
|
|
||||||
// Keep Crepe's full registry and lazy loaders for languages without Shiki grammars.
|
|
||||||
const remaining = new Map(overrides.map(language => [language.name.toLowerCase(), language]))
|
|
||||||
const languages = originalLanguages.map(original => {
|
|
||||||
const key = original.name.toLowerCase()
|
|
||||||
const replacement = remaining.get(key)
|
|
||||||
if (!replacement) return original
|
|
||||||
remaining.delete(key)
|
|
||||||
return LanguageDescription.of({
|
|
||||||
name: original.name,
|
|
||||||
alias: [...new Set([...original.alias, ...replacement.alias])],
|
|
||||||
extensions: original.extensions,
|
|
||||||
filename: original.filename,
|
|
||||||
load: () => replacement.load(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return [...languages, ...remaining.values()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shikiEditorTheme(theme: CodeTheme) {
|
export function shikiEditorTheme(theme: CodeTheme) {
|
||||||
return EditorView.theme({
|
return EditorView.theme({
|
||||||
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
|
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
|
||||||
|
|||||||
@@ -10,4 +10,12 @@ describe('Shiki GitHub 双主题', () => {
|
|||||||
expect(html).toContain('--shiki-light')
|
expect(html).toContain('--shiki-light')
|
||||||
expect(html).toContain('--shiki-dark')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { createHighlighterCore } from 'shiki/core'
|
import { createHighlighterCore } from 'shiki/core'
|
||||||
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||||
import css from '@shikijs/langs/css'
|
import { bundledLanguagesInfo } from 'shiki/langs'
|
||||||
import c from '@shikijs/langs/c'
|
|
||||||
import cpp from '@shikijs/langs/cpp'
|
|
||||||
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 githubDark from '@shikijs/themes/github-dark'
|
import githubDark from '@shikijs/themes/github-dark'
|
||||||
import githubLight from '@shikijs/themes/github-light'
|
import githubLight from '@shikijs/themes/github-light'
|
||||||
|
|
||||||
@@ -21,30 +11,46 @@ 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, c, cpp],
|
langs: [],
|
||||||
engine: createJavaScriptRegexEngine(),
|
engine: createOnigurumaEngine(import('shiki/wasm')),
|
||||||
})
|
})
|
||||||
|
|
||||||
const languageAliases: Record<string, string> = {
|
const languageAliases = new Map(bundledLanguagesInfo.flatMap(info =>
|
||||||
bash: 'shell', js: 'javascript', md: 'markdown', plaintext: 'text', py: 'python', sh: 'shell', ts: 'typescript',
|
[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> {
|
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
|
||||||
const shiki = await highlighter
|
const { shiki, language } = await loadCodeLanguage(requestedLanguage)
|
||||||
const language = languageAliases[requestedLanguage] ?? requestedLanguage
|
|
||||||
const loadedLanguage = shiki.getLoadedLanguages().includes(language as never) ? language : 'markdown'
|
|
||||||
return shiki.codeToHtml(source, {
|
return shiki.codeToHtml(source, {
|
||||||
lang: loadedLanguage,
|
lang: language,
|
||||||
themes: { light: 'github-light', dark: 'github-dark' },
|
themes: { light: 'github-light', dark: 'github-dark' },
|
||||||
defaultColor: false,
|
defaultColor: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Share the initialized grammar/theme registry with editable code blocks. */
|
/** Share the initialized grammar/theme registry with editable code blocks. */
|
||||||
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark') {
|
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', requestedLanguage = 'text') {
|
||||||
const shiki = await highlighter
|
const { shiki } = await loadCodeLanguage(requestedLanguage)
|
||||||
return (source: string, requestedLanguage: string) => {
|
return (source: string, requestedLanguage: string) => {
|
||||||
const language = languageAliases[requestedLanguage] ?? requestedLanguage
|
const language = languageAliases.get(requestedLanguage.toLowerCase()) ?? 'text'
|
||||||
return shiki.codeToTokens(source, {
|
return shiki.codeToTokens(source, {
|
||||||
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
|
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
Reference in New Issue
Block a user