fix(editor): 支持完整 Shiki 语言并修复语言标识与图标展示

This commit is contained in:
2026-09-05 17:16:09 +08:00
parent 41bf2c53d4
commit 08fd62e7c5
9 changed files with 356 additions and 79 deletions
@@ -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)
+17 -36
View File
@@ -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)' },
+8
View File
@@ -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)
})
}) })
+29 -23
View File
@@ -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,