chore: switch dashboard code blocks highlight to shiki (#7497)

* fix: switch dashboard code blocks to shiki and sync theme rendering

* fix: harden and optimize dashboard shiki highlighting
This commit is contained in:
若月千鸮
2026-04-13 08:43:36 +08:00
committed by GitHub
parent 6131386893
commit 5f0178bc73
19 changed files with 361 additions and 262 deletions
+1 -2
View File
@@ -14,7 +14,6 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"qrcode": "^1.5.4",
"@guolao/vue-monaco-editor": "^1.5.4",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
@@ -25,7 +24,6 @@
"date-fns": "2.30.0",
"dompurify": "^3.3.2",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.23",
@@ -35,6 +33,7 @@
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"vee-validate": "4.11.3",
-22
View File
@@ -42,9 +42,6 @@ importers:
event-source-polyfill:
specifier: ^1.0.31
version: 1.0.31
highlight.js:
specifier: ^11.11.1
version: 11.11.1
js-md5:
specifier: ^0.8.3
version: 0.8.3
@@ -540,79 +537,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1919,10 +1903,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
@@ -4933,8 +4913,6 @@ snapshots:
he@1.2.0: {}
highlight.js@11.11.1: {}
hookified@1.15.1: {}
html-void-elements@3.0.0: {}
@@ -1,4 +1,4 @@
/* Auto-generated MDI subset – 248 icons */
/* Auto-generated MDI subset – 247 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -236,10 +236,6 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
+3 -2
View File
@@ -678,7 +678,7 @@ import {
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
@@ -696,6 +696,7 @@ import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useSessions, type Session } from "@/composables/useSessions";
import {
useMessages,
@@ -720,7 +721,7 @@ const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
setCustomComponents("chat-message", {
ref: RefNode,
code_block: MarkdownCodeBlockNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
const route = useRoute();
@@ -237,7 +237,7 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref } from "vue";
import axios from "axios";
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
@@ -247,6 +247,7 @@ import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import type {
ChatContent,
ChatRecord,
@@ -270,7 +271,7 @@ const props = withDefaults(
setCustomComponents("chat-message", {
ref: RefNode,
code_block: MarkdownCodeBlockNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
const { tm } = useModuleI18n("features/chat");
@@ -183,7 +183,6 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
import axios from 'axios';
import { useToast } from '@/utils/toast'
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
@@ -174,7 +174,7 @@ import {
ref,
} from "vue";
import axios from "axios";
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import ChatInput from "@/components/chat/ChatInput.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
@@ -183,6 +183,7 @@ import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
useMessages,
@@ -201,7 +202,7 @@ const props = withDefaults(defineProps<{ configId?: string | null }>(), {
setCustomComponents("chat-message", {
ref: RefNode,
code_block: MarkdownCodeBlockNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
const { tm } = useModuleI18n("features/chat");
@@ -24,7 +24,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { createHighlighter } from 'shiki';
import { ensureShikiLanguages, escapeHtml, renderShikiCode } from '@/utils/shiki';
const props = defineProps({
toolCall: {
@@ -82,13 +82,15 @@ const highlightedCode = computed(() => {
return '';
}
try {
return shikiHighlighter.value.codeToHtml(code.value, {
lang: 'python',
theme: props.isDark ? 'min-dark' : 'github-light'
});
return renderShikiCode(
shikiHighlighter.value,
code.value,
'python',
props.isDark ? 'dark' : 'light'
);
} catch (err) {
console.error('Failed to highlight code:', err);
return `<pre><code>${code.value}</code></pre>`;
return `<pre><code>${escapeHtml(code.value)}</code></pre>`;
}
});
@@ -101,10 +103,7 @@ const displayExpanded = computed(() => {
onMounted(async () => {
try {
shikiHighlighter.value = await createHighlighter({
themes: ['min-dark', 'github-light'],
langs: ['python']
});
shikiHighlighter.value = await ensureShikiLanguages(['python']);
shikiReady.value = true;
} catch (err) {
console.error('Failed to initialize Shiki:', err);
@@ -139,6 +138,20 @@ onMounted(async () => {
overflow-x: auto;
}
:deep(.code-highlighted pre.shiki) {
margin: 0;
padding: 16px;
border-radius: 6px;
overflow: auto;
}
:deep(.code-highlighted pre.shiki code) {
display: block;
padding: 0;
background: transparent;
border-radius: 0;
}
.code-fallback {
margin: 0;
padding: 12px;
@@ -5,7 +5,6 @@ import axios from 'axios';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
enableKatex();
enableMermaid();
+176 -105
View File
@@ -1,11 +1,16 @@
<script setup>
import { ref, watch, computed, onUnmounted } from "vue";
import { useTheme } from "vuetify";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "axios";
import DOMPurify from "dompurify";
import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
import {
escapeHtml,
ensureShikiLanguages,
normalizeShikiLanguage,
renderShikiCode,
} from "@/utils/shiki";
// 1. 在 setup 作用域创建 MarkdownIt 实例
const md = new MarkdownIt({
@@ -41,6 +46,7 @@ const props = defineProps({
const emit = defineEmits(["update:show"]);
const { t, locale } = useI18n();
const theme = useTheme();
const content = ref(null);
const error = ref(null);
@@ -48,7 +54,103 @@ const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const lastRenderId = ref(0);
const scrollContainer = ref(null);
const renderedHtml = ref("");
const isDark = computed(() => theme.global.current.value.dark);
const MARKDOWN_SANITIZE_OPTIONS = {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"details",
"summary",
"div",
"span",
"input",
"button",
"svg",
"rect",
"path",
"polyline",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"title",
"class",
"id",
"target",
"rel",
"type",
"checked",
"disabled",
"open",
"align",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
"data-code-block-index",
],
};
const CODE_BLOCK_SANITIZE_OPTIONS = {
ALLOWED_TAGS: ["div", "span", "button", "svg", "rect", "path", "polyline", "pre", "code"],
ALLOWED_ATTR: [
"class",
"title",
"type",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
"style",
"tabindex",
],
};
function slugifyHeading(text, slugCounts) {
const base = (text || "")
@@ -71,104 +173,62 @@ onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
});
// 渲染后的 HTML
const renderedHtml = computed(() => {
// 强制依赖 locale,确保语言切换时重新渲染
const _ = locale?.value;
if (!content.value) return "";
function sanitizeHighlightedBlock(html) {
return DOMPurify.sanitize(html, CODE_BLOCK_SANITIZE_OPTIONS);
}
async function updateRenderedHtml() {
const source = content.value;
const renderId = ++lastRenderId.value;
void locale?.value;
if (!source) {
renderedHtml.value = "";
return;
}
let highlighter = null;
const env = {};
const tokens = md.parse(source, env);
try {
const languages = tokens
.filter((token) => token.type === "fence")
.map((token) => normalizeShikiLanguage(token.info));
highlighter = await ensureShikiLanguages(languages);
} catch (err) {
console.error("Failed to initialize Shiki for README dialog:", err);
}
if (renderId !== lastRenderId.value) return;
const highlightedBlocks = [];
// 设置 fence 规则,直接使用当前作用域的 t 函数
md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx];
const lang = token.info.trim() || "";
const lang = normalizeShikiLanguage(token.info);
const code = token.content;
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: md.utils.escapeHtml(code);
return `<div class="code-block-wrapper">
${lang ? `<span class="code-lang-label">${lang}</span>` : ""}
const escapedLangLabel =
lang && lang !== "text" ? escapeHtml(lang) : "";
const highlighted = highlighter
? renderShikiCode(highlighter, code, lang, isDark.value ? "dark" : "light")
: `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`;
const html = sanitizeHighlightedBlock(`<div class="code-block-wrapper">
${escapedLangLabel ? `<span class="code-lang-label">${escapedLangLabel}</span>` : ""}
<button class="copy-code-btn" title="${t("core.common.copy")}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<pre class="hljs"><code class="language-${lang}">${highlighted}</code></pre>
</div>`;
${highlighted}
</div>`);
const placeholderIndex = highlightedBlocks.push(html) - 1;
return `<div data-code-block-index="${placeholderIndex}"></div>`;
};
const rawHtml = md.render(content.value);
const rawHtml = md.renderer.render(tokens, md.options, env);
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"details",
"summary",
"div",
"span",
"input",
"button",
"svg",
"rect",
"path",
"polyline",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"title",
"class",
"id",
"target",
"rel",
"type",
"checked",
"disabled",
"open",
"align",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
],
});
const cleanHtml = DOMPurify.sanitize(rawHtml, MARKDOWN_SANITIZE_OPTIONS);
// 3. 后处理方案:完全隔离,安全性最高
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
@@ -185,15 +245,21 @@ const renderedHtml = computed(() => {
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 强制所有外部链接使用安全的 _blank 策略
if (href && (href.startsWith("http") || href.startsWith("//"))) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
return tempDiv.innerHTML;
});
tempDiv.querySelectorAll("[data-code-block-index]").forEach((placeholder) => {
const index = Number(placeholder.getAttribute("data-code-block-index"));
placeholder.outerHTML = highlightedBlocks[index] || "";
});
if (renderId === lastRenderId.value) {
renderedHtml.value = tempDiv.innerHTML;
}
}
const modeConfig = computed(() => {
if (props.mode === "changelog") {
@@ -279,6 +345,10 @@ watch(
{ immediate: true },
);
watch([content, locale, isDark], () => {
updateRenderedHtml();
}, { immediate: true });
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
@@ -549,22 +619,32 @@ const showActionArea = computed(() => {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
:deep(.markdown-body pre.hljs) {
:deep(.markdown-body pre.shiki) {
padding: 16px;
padding-top: 32px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #0d1117;
border-radius: 6px;
margin: 0;
border: 1px solid rgba(128, 128, 128, 0.18);
}
:deep(.markdown-body pre.hljs code) {
:deep(.markdown-body pre.shiki code) {
background-color: transparent;
padding: 0;
border-radius: 0;
color: #c9d1d9;
color: inherit;
}
:deep(.markdown-body pre.shiki .line) {
display: block;
min-height: 1.45em;
}
:deep(.markdown-body pre.shiki.shiki-fallback) {
background-color: #f6f8fa;
color: #24292f;
}
:deep(.markdown-body ul),
:deep(.markdown-body ol) {
@@ -679,13 +759,4 @@ const showActionArea = computed(() => {
margin-top: 12px;
}
:deep(.markdown-body .hljs-keyword),
:deep(.markdown-body .hljs-selector-tag),
:deep(.markdown-body .hljs-title),
:deep(.markdown-body .hljs-section),
:deep(.markdown-body .hljs-doctag),
:deep(.markdown-body .hljs-name),
:deep(.markdown-body .hljs-strong) {
font-weight: bold;
}
</style>
@@ -0,0 +1,40 @@
<template>
<MarkdownCodeBlockNode
:key="themeRenderKey"
v-bind="forwardedBindings"
>
<template
v-for="(_, slotName) in $slots"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps || {}" />
</template>
</MarkdownCodeBlockNode>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownCodeBlockNode } from "markstream-vue";
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{
node: Record<string, unknown>;
isDark?: boolean;
}>(),
{
isDark: false,
},
);
const attrs = useAttrs();
const forwardedBindings = computed(() => ({
...attrs,
...props,
}));
const themeRenderKey = computed(() => (props.isDark ? "dark" : "light"));
</script>
@@ -9,7 +9,6 @@ import { useCommonStore } from '@/stores/common';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
import { router } from '@/router';
import { useRoute } from 'vue-router';
@@ -0,0 +1,19 @@
.v-theme--PurpleThemeDark {
.shiki.shiki-themes,
.shiki.shiki-themes span {
color: var(--shiki-dark) !important;
}
.shiki.shiki-themes {
background-color: var(--shiki-dark-bg) !important;
}
.markstream-vue {
--border: 217.2 32.6% 17.5%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
}
}
@@ -1,107 +0,0 @@
// highlight.js dark mode overrides
// Scoped to the dark Vuetify theme so the default github.css light styles work in light mode.
.v-theme--PurpleThemeDark {
.hljs {
background: transparent;
color: #adbac7;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: #f47067;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #dcbdfb;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-variable {
color: #6cb6ff;
}
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #96d0ff;
}
.hljs-built_in,
.hljs-symbol {
color: #f69d50;
}
.hljs-code,
.hljs-comment,
.hljs-formula {
color: #768390;
}
.hljs-name,
.hljs-quote,
.hljs-selector-pseudo,
.hljs-selector-tag {
color: #8ddb8c;
}
.hljs-subst {
color: #adbac7;
}
.hljs-section {
color: #316dca;
font-weight: bold;
}
.hljs-bullet {
color: #eac55f;
}
.hljs-emphasis {
color: #adbac7;
font-style: italic;
}
.hljs-strong {
color: #adbac7;
font-weight: bold;
}
.hljs-addition {
color: #b4f1b4;
background-color: #1b4721;
}
.hljs-deletion {
color: #ffd8d3;
background-color: #78191b;
}
// markstream-vue dark mode variables override
// markstream-vue expects a `.dark` ancestor to activate its dark palette,
// but Vuetify uses `.v-theme--PurpleThemeDark` instead.
.markstream-vue {
--border: 217.2 32.6% 17.5%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
@import './components/VTextField';
@import './components/VTabs';
@import './components/VScrollbar';
@import './components/HljsDark';
@import './components/CodeBlockDark';
@import './pages/dashboards';
+91
View File
@@ -0,0 +1,91 @@
import { getSingletonHighlighter } from "shiki";
export const SHIKI_THEMES = {
light: "github-light",
dark: "github-dark",
};
let highlighterPromise;
function normalizeLanguage(language) {
const normalized = (language || "text").trim().split(/\s+/, 1)[0].toLowerCase();
return normalized || "text";
}
export function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export async function getShikiHighlighter() {
if (!highlighterPromise) {
highlighterPromise = getSingletonHighlighter({
themes: Object.values(SHIKI_THEMES),
langs: ["text"],
});
}
return highlighterPromise;
}
export async function ensureShikiLanguages(languages = []) {
const highlighter = await getShikiHighlighter();
const languagesToLoad = [...new Set(languages.map(normalizeLanguage))].filter(
(language) => language !== "text",
);
await Promise.all(
languagesToLoad.map((language) =>
highlighter.loadLanguage(language).catch((err) => {
console.warn(`Failed to load Shiki language "${language}".`, err);
}),
),
);
return highlighter;
}
export function renderShikiCode(highlighter, code, language, colorMode = "auto") {
const normalizedLanguage = normalizeLanguage(language);
const options =
colorMode === "dark"
? { lang: normalizedLanguage, theme: SHIKI_THEMES.dark }
: colorMode === "light"
? { lang: normalizedLanguage, theme: SHIKI_THEMES.light }
: { lang: normalizedLanguage, themes: SHIKI_THEMES };
try {
return highlighter.codeToHtml(code, options);
} catch (err) {
console.warn(
`Failed to render code with Shiki language "${normalizedLanguage}". Falling back to plain text.`,
err,
);
const fallbackOptions =
colorMode === "dark"
? { lang: "text", theme: SHIKI_THEMES.dark }
: colorMode === "light"
? { lang: "text", theme: SHIKI_THEMES.light }
: { lang: "text", themes: SHIKI_THEMES };
return highlighter.codeToHtml(code, fallbackOptions);
}
}
export function collectMarkdownFenceLanguages(markdownIt, markdown) {
if (!markdown) return [];
return markdownIt
.parse(markdown, {})
.filter((token) => token.type === "fence")
.map((token) => normalizeLanguage(token.info));
}
export function normalizeShikiLanguage(language) {
return normalizeLanguage(language);
}
-1
View File
@@ -200,7 +200,6 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useToast } from '@/utils/toast';
import { MarkdownRender } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'highlight.js/styles/github.css';
type StepState = 'pending' | 'completed' | 'skipped';
type ComputerAccessRuntime = 'local' | 'none';