fix(editor): 接入完整 Shiki 语言支持与 GitHub 双主题,修复语言菜单并添加图标 #25

Merged
Kronecker merged 3 commits from fix/editor-shiki-language-picker into main 2026-09-05 17:19:15 +08:00
9 changed files with 356 additions and 79 deletions
Showing only changes of commit 08fd62e7c5 - Show all commits
@@ -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 { 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 }
@@ -48,6 +49,30 @@ 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 })
@@ -57,10 +82,11 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
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)
const language = config.languages.find(item => item.name === name.toLowerCase())
expect(language, `${name} remains available`).toBeDefined()
const support = await language!.load()
expect(support.language.parser.parse('class Example {}').length).toBe(16)
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 {
@@ -6,7 +6,8 @@ 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 { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import {
createCodeBlockCommand,
@@ -179,7 +180,8 @@ onMounted(async () => {
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme, config.languages),
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
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
import { afterEach, expect, it } from 'vitest'
import { Compartment } from '@codemirror/state'
import { LanguageDescription } from '@codemirror/language'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
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', () => {
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')
expect(languages.find(item => item.name === 'python')?.alias).toContain('py')
expect(languages.find(item => item.name === 'latex')?.alias).toContain('latex')
})
it('preserves original loaders and metadata while replacing supported languages', async () => {
const originalPython = LanguageDescription.of({ name: 'Python', alias: ['py', 'custom-python'], extensions: ['py'], filename: /^SConstruct$/, load: () => shikiLanguage('text', 'github-light') })
const originalRust = LanguageDescription.of({ name: 'Rust', alias: ['rs'], extensions: ['rs'], load: () => shikiLanguage('text', 'github-light') })
const languages = shikiLanguages('github-dark', [originalPython, originalRust])
expect(languages.find(item => item.name === 'Rust')).toBe(originalRust)
const python = languages.find(item => item.name === 'Python')!
expect(languages.filter(item => item.alias.includes('python'))).toHaveLength(1)
expect(python.alias).toContain('custom-python')
expect(python.extensions).toEqual(['py'])
expect(python.filename).toBe(originalPython.filename)
expect(await python.load()).not.toBe(await originalPython.load())
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)
+17 -36
View File
@@ -1,11 +1,12 @@
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)
const tokenize = await getCodeTokenizer(theme, language)
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
@@ -39,42 +40,22 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
return new LanguageSupport(parser, highlights)
}
export function shikiLanguages(theme: CodeTheme, originalLanguages: readonly LanguageDescription[] = []): LanguageDescription[] {
const overrides = [
{ 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),
}))
// 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 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)' },
+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)
})
})
+29 -23
View File
@@ -1,18 +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 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 { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
@@ -21,30 +11,46 @@ marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({
themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql, c, cpp],
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') {
const shiki = await highlighter
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', requestedLanguage = 'text') {
const { shiki } = await loadCodeLanguage(requestedLanguage)
return (source: string, requestedLanguage: string) => {
const language = languageAliases[requestedLanguage] ?? requestedLanguage
const language = languageAliases.get(requestedLanguage.toLowerCase()) ?? 'text'
return shiki.codeToTokens(source, {
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
theme,