Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08fd62e7c5 | ||
|
|
41bf2c53d4 | ||
|
|
ed37099ba1 |
@@ -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",
|
||||
|
||||
Generated
+12
@@ -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' })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user