Skip to content
Merged
12 changes: 11 additions & 1 deletion apps/web/src/components/markdown-preview/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownRender, { enableKatex, enableMermaid } from 'markstream-vue'
import MarkdownRender, { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import ThemedMermaidBlock from '@/components/themed-mermaid-block/index.vue'
import { useSettingsStore } from '@/store/settings'
import { registerSharedMarkdownComponents } from '@/components/markdown'

Expand All @@ -13,6 +14,10 @@ const props = withDefaults(defineProps<{

enableKatex()
enableMermaid()
// Global mermaid override so the appearance preference wins over the markstream
// default (which only follows the host renderer's isDark flag); one registration
// covers chat + file preview + any other MarkdownRender call site.
setCustomComponents({ mermaid: ThemedMermaidBlock })
// File preview reuses the chat's design-system node components (library
// Checkbox task markers, link-language footnotes). It keeps markstream's own
// Monaco code block, so no code_block override here.
Expand All @@ -24,6 +29,10 @@ const codeBlockMonacoOptions = computed(() => ({
fontFamily: settings.codeFontStack,
fontSize: settings.codeFontSizePx,
}))
const codeBlockTheme = computed(() => ({
light: settings.shikiThemeLight,
dark: settings.shikiThemeDark,
}))
const codeFontRenderKey = computed(() => settings.codeFontStack)
</script>

Expand All @@ -39,6 +48,7 @@ const codeFontRenderKey = computed(() => settings.codeFontStack)
:show-tooltips="false"
:mermaid-props="{ showTooltips: false }"
:code-block-monaco-options="codeBlockMonacoOptions"
:theme="codeBlockTheme"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward Shiki themes through code-block props

For MarkdownRender's built-in code block renderer, the unified theme option is forwarded through code-block-props; passing it as a top-level theme prop here is ignored. In file previews and release notes that use the default Markstream code block, selecting a custom Shiki theme therefore leaves code blocks on the library default theme, so wrap this as :code-block-props="{ theme: codeBlockTheme }" or use the dedicated code-block theme props.

Useful? React with 👍 / 👎.

custom-id="file-preview-md"
/>
</div>
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/monaco-editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,10 @@ function buildMarkdownHeadingSymbols(model: Monaco.editor.ITextModel, monaco: ty
// The applied light/dark mode is driven by the `.dark` class on <html> (set by the
// color-mode store, which also honors "system"). Read it straight from the DOM so we
// pick the right base theme even when the preference is "system".
function resolveThemeName(): 'vitesse-dark' | 'vitesse-light' {
return document.documentElement.classList.contains('dark') ? 'vitesse-dark' : 'vitesse-light'
function resolveThemeName(): string {
return document.documentElement.classList.contains('dark')
? settings.shikiThemeDark
: settings.shikiThemeLight
}

const {
Expand All @@ -128,7 +130,7 @@ const {
getEditorView,
} = useMonaco({
theme: resolveThemeName(),
themes: ['vitesse-dark', 'vitesse-light'],
themes: [settings.shikiThemeDark, settings.shikiThemeLight],
readOnly: props.readonly,
automaticLayout: true,
autoScrollInitial: false,
Expand Down Expand Up @@ -346,6 +348,11 @@ watch(editorFontFamily, (fontFamily) => {
watch([() => props.language, () => props.filename], () => {
setLanguage(resolveLanguage())
})

watch(
() => [settings.shikiThemeLight, settings.shikiThemeDark] as const,
() => { void syncEditorTheme() },
)
</script>

<template>
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/components/themed-mermaid-block/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MermaidBlockNode, type CodeBlockNode } from 'markstream-vue'
import { useSettingsStore } from '@/store/settings'
import { applyMermaidThemeToSource, resolveMermaidIsDark } from '@/store/settings/mermaid'

defineOptions({ inheritAttrs: false })

const props = defineProps<{
node: CodeBlockNode
loading?: boolean
isDark?: boolean
}>()

const settings = useSettingsStore()

const themedNode = computed<CodeBlockNode>(() => {
if (settings.mermaidTheme === 'auto') return props.node
const content = props.node?.content ?? ''
const next = applyMermaidThemeToSource(content, settings.mermaidTheme)
if (next === content) return props.node
return { ...props.node, content: next }
Comment on lines +19 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Write the Mermaid theme into node.code

The Mermaid renderer receives the same CodeBlockNode shape as other fenced blocks, whose source is carried on node.code/node.raw (the existing chat code block reads those fields), but this wrapper reads and writes only a new content field. When a user selects forest or neutral, the injected init directive is ignored by MermaidBlockNode, so the new Mermaid theme preference does not actually change those diagrams.

Useful? React with 👍 / 👎.

})

const themedIsDark = computed(() =>
resolveMermaidIsDark(settings.mermaidTheme, Boolean(props.isDark)),
)
</script>

<template>
<MermaidBlockNode
v-bind="$attrs"
:node="themedNode"
:loading="loading"
:is-dark="themedIsDark"
/>
</template>
73 changes: 62 additions & 11 deletions apps/web/src/composables/useShikiHighlighter.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { ref } from 'vue'
import { getLanguageByFilename } from '@/components/file-manager/utils'
import { useSettingsStore } from '@/store/settings'

import type { HighlighterGeneric, BundledLanguage, BundledTheme } from 'shiki'

type Highlighter = HighlighterGeneric<BundledLanguage, BundledTheme>

let highlighterPromise: Promise<Highlighter> | null = null
const loadedLangs = new Set<string>(['plaintext'])
const loadedThemes = new Set<string>()

async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import('shiki').then((m) =>
m.createHighlighter({ themes: ['github-dark', 'github-light'], langs: [] }),
m.createHighlighter({ themes: [], langs: [] }),
)
}
return highlighterPromise
Expand All @@ -27,19 +29,42 @@ async function ensureLang(hl: Highlighter, lang: string) {
}
}

async function ensureTheme(hl: Highlighter, theme: BundledTheme) {
if (loadedThemes.has(theme)) return
try {
await hl.loadTheme(theme)
loadedThemes.add(theme)
} catch {
loadedThemes.add(theme)
}
}

export function useShikiHighlighter() {
const settings = useSettingsStore()
const html = ref('')
const loading = ref(false)

const activeThemes = () => ({
light: settings.shikiThemeLight as BundledTheme,
dark: settings.shikiThemeDark as BundledTheme,
})
Comment on lines +47 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-highlight mounted code blocks after theme changes

These settings are only read when highlight()/highlightLang() is called, but existing consumers such as pages/home/components/code-block.vue watch only the code, language, and filename. If a user changes Settings → Appearance while a chat code fence or tool diff is already mounted, the HTML keeps the old Shiki colors until the message remounts or the code changes, so the new theme preference is not applied to the main code-block surfaces.

Useful? React with 👍 / 👎.


async function ensurePairedThemes(hl: Highlighter) {
const themes = activeThemes()
await Promise.all([ensureTheme(hl, themes.light), ensureTheme(hl, themes.dark)])
return themes
}

async function highlight(code: string, filename: string) {
loading.value = true
try {
const lang = getLanguageByFilename(filename)
const hl = await getHighlighter()
await ensureLang(hl, lang)
const themes = await ensurePairedThemes(hl)
html.value = hl.codeToHtml(code, {
lang: loadedLangs.has(lang) ? lang : 'plaintext',
themes: { light: 'github-light', dark: 'github-dark' },
themes,
})
} catch {
html.value = `<pre>${escapeHtml(code)}</pre>`
Expand All @@ -56,9 +81,10 @@ export function useShikiHighlighter() {
const normalized = (lang || 'plaintext').toLowerCase()
const hl = await getHighlighter()
await ensureLang(hl, normalized)
const themes = await ensurePairedThemes(hl)
html.value = hl.codeToHtml(code, {
lang: loadedLangs.has(normalized) ? normalized : 'plaintext',
themes: { light: 'github-light', dark: 'github-dark' },
themes,
})
} catch {
html.value = `<pre>${escapeHtml(code)}</pre>`
Expand All @@ -73,8 +99,8 @@ export function useShikiHighlighter() {
const lang = getLanguageByFilename(filename)
const hl = await getHighlighter()
await ensureLang(hl, lang)
const themes = await ensurePairedThemes(hl)
const effectiveLang = loadedLangs.has(lang) ? lang : 'plaintext'
const themes = { light: 'github-light', dark: 'github-dark' }

const oldHtml = oldText
? hl.codeToHtml(oldText, { lang: effectiveLang, themes })
Expand All @@ -95,19 +121,44 @@ export function useShikiHighlighter() {

async function highlightLanguage(code: string, lang: string, options: {
theme?: BundledTheme
// Explicit dual-theme override. Pass when the call site needs to dodge the
// `.dark .shiki span` !important rule that ships in the design system: set
// both halves to the same theme and shiki emits `--shiki-dark` equal to the
// light value, so the override resolves back to the picked colors.
themes?: { light: BundledTheme, dark: BundledTheme }
transparentPre?: boolean
} = {}) {
loading.value = true
try {
const hl = await getHighlighter()
await ensureLang(hl, lang)
html.value = hl.codeToHtml(code, {
lang: loadedLangs.has(lang) ? lang : 'plaintext',
...(options.theme
? { theme: options.theme }
: { themes: { light: 'github-light', dark: 'github-dark' } }),
transformers: options.transparentPre ? [transparentPreTransformer] : undefined,
})
const effectiveLang = loadedLangs.has(lang) ? lang : 'plaintext'
const transformers = options.transparentPre ? [transparentPreTransformer] : undefined
if (options.theme) {
await ensureTheme(hl, options.theme)
html.value = hl.codeToHtml(code, {
lang: effectiveLang,
theme: options.theme,
transformers,
})
} else if (options.themes) {
await Promise.all([
ensureTheme(hl, options.themes.light),
ensureTheme(hl, options.themes.dark),
])
html.value = hl.codeToHtml(code, {
lang: effectiveLang,
themes: options.themes,
transformers,
})
} else {
const themes = await ensurePairedThemes(hl)
html.value = hl.codeToHtml(code, {
lang: effectiveLang,
themes,
transformers,
})
}
} catch {
html.value = `<pre>${escapeHtml(code)}</pre>`
} finally {
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@
"codeFontFamilyDescription": "Overrides the default code font",
"codeFontSize": "Code Font Size",
"codeFontSizeDescription": "Applies to code editors, code blocks, and the terminal",
"syntaxHighlighting": "Syntax Highlighting",
"shikiThemeLight": "Light theme",
"shikiThemeLightDescription": "Applied to code blocks and editors in light mode",
"shikiThemeDark": "Dark theme",
"shikiThemeDarkDescription": "Applied to code blocks and editors in dark mode",
"shikiThemeSearch": "Search themes…",
"shikiThemeEmpty": "No themes found.",
"colorSchemes": {
"memoh": "Memoh",
"ocean": "Ocean",
Expand All @@ -223,7 +230,12 @@
"forest": "Subtle green tones",
"rose": "Warm accents and soft contrast",
"amber": "Golden highlights with clear status indicators"
}
},
"codeHighlight": "Code highlight",
"codeHighlightDescription": "Themes applied to code blocks and editors.",
"diagrams": "Diagrams",
"mermaidTheme": "Mermaid Theme",
"mermaidThemeDescription": "Applies to rendered Mermaid diagrams."
},
"keyboard": {
"title": "Keyboard Shortcuts",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@
"codeFontFamilyDescription": "標準のコードフォントを上書きします",
"codeFontSize": "コードフォントサイズ",
"codeFontSizeDescription": "コードエディタ、コードブロック、ターミナルに適用されます",
"syntaxHighlighting": "Syntax Highlight",
"shikiThemeLight": "Light テーマ",
"shikiThemeLightDescription": "Light モード時のコードブロックとエディタに適用されます",
"shikiThemeDark": "Dark テーマ",
"shikiThemeDarkDescription": "Dark モード時のコードブロックとエディタに適用されます",
"shikiThemeSearch": "テーマを検索…",
"shikiThemeEmpty": "該当するテーマがありません。",
"colorSchemes": {
"memoh": "Memoh",
"ocean": "海",
Expand All @@ -217,7 +224,12 @@
"forest": "控えめなグリーントーン",
"rose": "温かみのあるアクセントと柔らかなコントラスト",
"amber": "明確なステータスインジケーターを備えた金色のハイライト"
}
},
"codeHighlight": "コードハイライト",
"codeHighlightDescription": "コードブロックとエディタに適用される配色。",
"diagrams": "ダイアグラム",
"mermaidTheme": "Mermaid テーマ",
"mermaidThemeDescription": "レンダリング済みの Mermaid 図表に適用されます。"
},
"keyboard": {
"title": "キーボードショートカット",
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@
"codeFontFamilyDescription": "替换默认代码字体",
"codeFontSize": "代码字号",
"codeFontSizeDescription": "应用于代码编辑器、代码块与终端",
"syntaxHighlighting": "语法高亮",
"shikiThemeLight": "浅色主题",
"shikiThemeLightDescription": "浅色模式下用于代码块与编辑器",
"shikiThemeDark": "深色主题",
"shikiThemeDarkDescription": "深色模式下用于代码块与编辑器",
"shikiThemeSearch": "搜索主题…",
"shikiThemeEmpty": "未找到主题。",
"colorSchemes": {
"memoh": "Memoh",
"ocean": "海洋",
Expand All @@ -219,7 +226,12 @@
"forest": "沉静的绿色调方案",
"rose": "温暖的玫瑰色,柔和的视觉对比",
"amber": "明亮的琥珀色,清晰的状态指示"
}
},
"codeHighlight": "代码高亮",
"codeHighlightDescription": "应用于代码块与编辑器的配色方案。",
"diagrams": "图表",
"mermaidTheme": "Mermaid 主题",
"mermaidThemeDescription": "应用于已渲染的 Mermaid 图表。"
},
"keyboard": {
"title": "快捷键",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/pages/about/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
:typewriter="false"
:fade="false"
:show-tooltips="false"
:theme="codeBlockTheme"
custom-id="release-notes"
/>
</div>
Expand Down Expand Up @@ -192,6 +193,7 @@ import { useI18n } from 'vue-i18n'
import SettingsRow from '@/components/settings/row.vue'
import SettingsSection from '@/components/settings/section.vue'
import { useCapabilitiesStore } from '@/store/capabilities'
import { useSettingsStore } from '@/store/settings'
import { useUpdateStore } from '@/store/update'

const GITHUB_REPO = 'memohai/memoh'
Expand All @@ -209,7 +211,12 @@ const { serverVersion, commitHash } = storeToRefs(capabilitiesStore)
const normalizedServerVersion = computed(() => (serverVersion.value ?? '').replace(/^v/i, ''))

const update = useUpdateStore()
const settingsStore = useSettingsStore()
const isDark = useDark()
const codeBlockTheme = computed(() => ({
light: settingsStore.shikiThemeLight,
dark: settingsStore.shikiThemeDark,
}))

const links: ResourceLink[] = [
{ icon: Github, labelKey: 'about.github', href: `https://github.com/${GITHUB_REPO}` },
Expand Down
Loading
Loading