mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
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:
@@ -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",
|
||||
|
||||
Generated
-22
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
@import './components/VTextField';
|
||||
@import './components/VTabs';
|
||||
@import './components/VScrollbar';
|
||||
@import './components/HljsDark';
|
||||
@import './components/CodeBlockDark';
|
||||
|
||||
@import './pages/dashboards';
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user