feat(quickdock): 文件预览接入 Prism 语法高亮,修复深色模式运算符底色与 show_file 高亮失效
This commit is contained in:
parent
26f31a09ec
commit
165bcdb264
@ -4,21 +4,10 @@
|
||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
||||
<div class="sfc-actions">
|
||||
<button
|
||||
v-if="canCopy"
|
||||
type="button"
|
||||
class="sfc-btn"
|
||||
@click="copyContent"
|
||||
>
|
||||
<button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent">
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sfc-btn sfc-btn-download"
|
||||
@click="handleDownload"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button type="button" class="sfc-btn sfc-btn-download" @click="handleDownload">下载</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -72,14 +61,13 @@
|
||||
<template v-else-if="fileType === 'pdf'">
|
||||
<PdfPreview :source="contentUrl" />
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import Prism from 'prismjs';
|
||||
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
|
||||
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
|
||||
import PdfPreview from './PdfPreview.vue';
|
||||
|
||||
@ -127,30 +115,32 @@ const canPreview = computed(() => {
|
||||
return ['text', 'code', 'json', 'csv', 'markdown', 'image', 'pdf'].includes(fileType.value);
|
||||
});
|
||||
|
||||
const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value));
|
||||
const canCopy = computed(() =>
|
||||
['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value)
|
||||
);
|
||||
|
||||
const isAndroidApp = computed(() => {
|
||||
return typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
|
||||
typeof (window as any).AndroidThemeBridge !== 'undefined';
|
||||
return (
|
||||
typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
|
||||
typeof (window as any).AndroidThemeBridge !== 'undefined'
|
||||
);
|
||||
});
|
||||
|
||||
const metaText = computed(() => {
|
||||
if (!rawContent.value) return '';
|
||||
const sizeKB = (new Blob([rawContent.value]).size / 1024).toFixed(1);
|
||||
return `${sizeKB} KB`;
|
||||
});
|
||||
// 根据路径推断 Prism 语言(修复历史遗留:原代码引用了未定义的 displayLang,
|
||||
// 高亮从未真正生效,静默 fallback 为纯文本)
|
||||
const displayLang = computed(() => prismLangForPath(props.path));
|
||||
|
||||
const highlightedCode = computed(() => {
|
||||
if (!rawContent.value) return '';
|
||||
let content = rawContent.value;
|
||||
if (fileType.value === 'json') {
|
||||
try { content = JSON.stringify(JSON.parse(content), null, 2); } catch {}
|
||||
try {
|
||||
content = JSON.stringify(JSON.parse(content), null, 2);
|
||||
} catch {
|
||||
// JSON 解析失败时保留原内容
|
||||
}
|
||||
}
|
||||
try {
|
||||
const grammar = Prism.languages[displayLang.value];
|
||||
if (grammar) return Prism.highlight(content, grammar, displayLang.value);
|
||||
} catch {}
|
||||
return content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return highlightCode(content, displayLang.value);
|
||||
});
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
@ -160,7 +150,7 @@ const renderedMarkdown = computed(() => {
|
||||
|
||||
const csvParsed = computed(() => {
|
||||
if (fileType.value !== 'csv' || !rawContent.value) return null;
|
||||
const lines = rawContent.value.split(/\r?\n/).filter(l => l.trim());
|
||||
const lines = rawContent.value.split(/\r?\n/).filter((l) => l.trim());
|
||||
const parseLine = (line: string): string[] => {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
@ -168,13 +158,17 @@ const csvParsed = computed(() => {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuote) {
|
||||
if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; }
|
||||
else if (ch === '"') inQuote = false;
|
||||
if (ch === '"' && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else if (ch === '"') inQuote = false;
|
||||
else current += ch;
|
||||
} else {
|
||||
if (ch === '"') inQuote = true;
|
||||
else if (ch === ',') { result.push(current); current = ''; }
|
||||
else current += ch;
|
||||
else if (ch === ',') {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else current += ch;
|
||||
}
|
||||
}
|
||||
result.push(current);
|
||||
@ -193,20 +187,54 @@ const csvTruncated = computed(() => csvParsed.value?.truncated || false);
|
||||
function inferShowFileType(path: string): string {
|
||||
const extMap: Record<string, string> = {
|
||||
'.txt': 'text',
|
||||
'.md': 'markdown', '.markdown': 'markdown',
|
||||
'.json': 'json', '.json5': 'json',
|
||||
'.csv': 'csv', '.tsv': 'csv',
|
||||
'.yaml': 'text', '.yml': 'text', '.toml': 'text',
|
||||
'.ini': 'text', '.cfg': 'text', '.conf': 'text', '.log': 'text',
|
||||
'.js': 'code', '.ts': 'code', '.jsx': 'code', '.tsx': 'code',
|
||||
'.vue': 'code', '.py': 'code', '.rb': 'code', '.go': 'code',
|
||||
'.rs': 'code', '.java': 'code', '.c': 'code', '.h': 'code',
|
||||
'.cpp': 'code', '.hpp': 'code', '.cs': 'code', '.php': 'code',
|
||||
'.swift': 'code', '.kt': 'code', '.sh': 'code', '.bash': 'code',
|
||||
'.html': 'code', '.htm': 'code', '.css': 'code', '.scss': 'code',
|
||||
'.less': 'code', '.xml': 'code', '.svg': 'image', '.sql': 'code',
|
||||
'.png': 'image', '.jpg': 'image', '.jpeg': 'image',
|
||||
'.gif': 'image', '.webp': 'image', '.bmp': 'image', '.ico': 'image',
|
||||
'.md': 'markdown',
|
||||
'.markdown': 'markdown',
|
||||
'.json': 'json',
|
||||
'.json5': 'json',
|
||||
'.csv': 'csv',
|
||||
'.tsv': 'csv',
|
||||
'.yaml': 'text',
|
||||
'.yml': 'text',
|
||||
'.toml': 'text',
|
||||
'.ini': 'text',
|
||||
'.cfg': 'text',
|
||||
'.conf': 'text',
|
||||
'.log': 'text',
|
||||
'.js': 'code',
|
||||
'.ts': 'code',
|
||||
'.jsx': 'code',
|
||||
'.tsx': 'code',
|
||||
'.vue': 'code',
|
||||
'.py': 'code',
|
||||
'.rb': 'code',
|
||||
'.go': 'code',
|
||||
'.rs': 'code',
|
||||
'.java': 'code',
|
||||
'.c': 'code',
|
||||
'.h': 'code',
|
||||
'.cpp': 'code',
|
||||
'.hpp': 'code',
|
||||
'.cs': 'code',
|
||||
'.php': 'code',
|
||||
'.swift': 'code',
|
||||
'.kt': 'code',
|
||||
'.sh': 'code',
|
||||
'.bash': 'code',
|
||||
'.html': 'code',
|
||||
'.htm': 'code',
|
||||
'.css': 'code',
|
||||
'.scss': 'code',
|
||||
'.less': 'code',
|
||||
'.xml': 'code',
|
||||
'.svg': 'image',
|
||||
'.sql': 'code',
|
||||
'.png': 'image',
|
||||
'.jpg': 'image',
|
||||
'.jpeg': 'image',
|
||||
'.gif': 'image',
|
||||
'.webp': 'image',
|
||||
'.bmp': 'image',
|
||||
'.ico': 'image',
|
||||
'.avif': 'image',
|
||||
'.pdf': 'pdf'
|
||||
};
|
||||
@ -244,7 +272,12 @@ async function loadContent() {
|
||||
const resp = await fetch(buildContentUrl());
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
||||
try {
|
||||
const j = await resp.json();
|
||||
msg = j.error || msg;
|
||||
} catch {
|
||||
// 响应体非 JSON 时沿用 statusText
|
||||
}
|
||||
error.value = msg || '加载失败';
|
||||
return;
|
||||
}
|
||||
@ -278,7 +311,12 @@ async function handleDownload() {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { const j = await resp.json(); msg = j.error || msg; } catch {}
|
||||
try {
|
||||
const j = await resp.json();
|
||||
msg = j.error || msg;
|
||||
} catch {
|
||||
// 响应体非 JSON 时沿用 statusText
|
||||
}
|
||||
throw new Error(msg || '下载失败');
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
@ -301,8 +339,12 @@ async function copyContent() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawContent.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
} catch {
|
||||
// 剪贴板不可用时静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
function openFullImage() {
|
||||
@ -378,7 +420,9 @@ onMounted(() => {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--surface-muted);
|
||||
@ -445,7 +489,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@keyframes sfc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sfc-code {
|
||||
@ -527,5 +573,4 @@ onMounted(() => {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -2,44 +2,53 @@
|
||||
<transition name="qd-preview-slide">
|
||||
<aside v-if="previewPath" class="qd-preview">
|
||||
<section class="qd-preview__panel">
|
||||
<header class="qd-preview__header">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 2.5V5H12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
|
||||
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
|
||||
<button class="qd-preview__close" title="关闭" @click="close">
|
||||
<header class="qd-preview__header">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
d="M4 2.5h5.5L12 5v8.5H4V2.5z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 2.5V5H12"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.3"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="qd-preview__body">
|
||||
<div v-if="loading" class="qd-preview__loading">加载中…</div>
|
||||
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
|
||||
<template v-else>
|
||||
<div v-for="(line, i) in lines" :key="i" class="qd-code-line">
|
||||
<span class="line-no">{{ i + 1 }}</span>
|
||||
<span class="line-text">{{ line }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
|
||||
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
|
||||
<button class="qd-preview__close" title="关闭" @click="close">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="qd-preview__body">
|
||||
<div v-if="loading" class="qd-preview__loading">加载中…</div>
|
||||
<div v-else-if="error" class="qd-preview__error">{{ error }}</div>
|
||||
<template v-else>
|
||||
<!-- 行号列 + 整段高亮 pre:Prism 的 token 可能跨行(多行注释/字符串), -->
|
||||
<!-- 不能按行拆分 v-html,行 hover 用绝对定位高亮条实现 -->
|
||||
<div class="qd-code" @mousemove="onCodeMouseMove" @mouseleave="hoverLine = -1">
|
||||
<div
|
||||
v-if="hoverLine >= 0"
|
||||
class="qd-code-hoverline"
|
||||
:style="{ top: `${hoverLine * LINE_HEIGHT}px` }"
|
||||
></div>
|
||||
<div class="qd-code-gutter" aria-hidden="true">
|
||||
<span v-for="n in lineCount" :key="n">{{ n }}</span>
|
||||
</div>
|
||||
<pre class="qd-code-pre"><code v-html="highlightedHtml"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</transition>
|
||||
@ -49,6 +58,12 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
|
||||
|
||||
/** 与 quickdock.css 中 .qd-code 的 font-size(12px) × line-height(1.75) 保持同步 */
|
||||
const LINE_HEIGHT = 21;
|
||||
/** 超过该字符数的文件跳过语法高亮(仍显示纯文本与行号),避免大文件解析卡顿 */
|
||||
const HIGHLIGHT_MAX_CHARS = 100 * 1024;
|
||||
|
||||
/**
|
||||
* 文件预览侧边栏(占位列,位于快捷窗口列右侧、git/终端面板左侧)
|
||||
@ -107,9 +122,24 @@ const PREVIEWABLE_EXTS = new Set([
|
||||
const quickDock = useQuickDockStore();
|
||||
const { previewPath } = storeToRefs(quickDock);
|
||||
|
||||
const lines = ref<string[]>([]);
|
||||
const rawText = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const hoverLine = ref(-1);
|
||||
|
||||
const previewLang = computed(() => {
|
||||
if (rawText.value.length > HIGHLIGHT_MAX_CHARS) return null;
|
||||
return prismLangForPath(previewPath.value || '');
|
||||
});
|
||||
|
||||
const highlightedHtml = computed(() => {
|
||||
const html = highlightCode(rawText.value, previewLang.value);
|
||||
// pre 中末尾的单个 \n 不产生视觉空行;文本以换行结尾时补一个,
|
||||
// 保证行号列与代码视觉行一一对齐
|
||||
return rawText.value.endsWith('\n') ? html + '\n' : html;
|
||||
});
|
||||
|
||||
const lineCount = computed(() => (rawText.value ? rawText.value.split('\n').length : 0));
|
||||
|
||||
const fileName = computed(() => {
|
||||
const p = previewPath.value || '';
|
||||
@ -134,8 +164,9 @@ watch(
|
||||
previewPath,
|
||||
async (path) => {
|
||||
const mySeq = ++loadSeq;
|
||||
lines.value = [];
|
||||
rawText.value = '';
|
||||
error.value = '';
|
||||
hoverLine.value = -1;
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
@ -154,7 +185,7 @@ watch(
|
||||
if (mySeq !== loadSeq) {
|
||||
return;
|
||||
}
|
||||
lines.value = text.split('\n');
|
||||
rawText.value = text;
|
||||
} catch (err: any) {
|
||||
if (mySeq !== loadSeq) {
|
||||
return;
|
||||
@ -169,6 +200,13 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onCodeMouseMove(event: MouseEvent) {
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const idx = Math.floor((event.clientY - rect.top) / LINE_HEIGHT);
|
||||
hoverLine.value = idx >= 0 && idx < lineCount.value ? idx : -1;
|
||||
}
|
||||
|
||||
function close() {
|
||||
quickDock.closePreview();
|
||||
}
|
||||
|
||||
@ -851,29 +851,49 @@
|
||||
color: var(--state-danger);
|
||||
}
|
||||
|
||||
.qd-code-line {
|
||||
/* 行高 = 12px × 1.75 = 21px,与 FilePreviewPanel.vue 中 LINE_HEIGHT 保持同步 */
|
||||
.qd-code {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.qd-code-line:hover {
|
||||
.qd-code-hoverline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 21px;
|
||||
background: var(--hover-bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.qd-code-line .line-no {
|
||||
.qd-code-gutter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
padding-right: 12px;
|
||||
box-sizing: border-box;
|
||||
text-align: right;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.qd-code-line .line-text {
|
||||
.qd-code-gutter span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qd-code-pre {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding-right: 14px;
|
||||
font: inherit;
|
||||
white-space: pre;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
2
static/src/env.d.ts
vendored
2
static/src/env.d.ts
vendored
@ -5,9 +5,9 @@ declare module '*.vue' {
|
||||
}
|
||||
|
||||
declare module 'prismjs';
|
||||
declare module 'prismjs/components/*';
|
||||
declare module 'katex/contrib/auto-render';
|
||||
declare module '*.md' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
|
||||
15
static/src/styles/base/_prism-overrides.scss
Normal file
15
static/src/styles/base/_prism-overrides.scss
Normal file
@ -0,0 +1,15 @@
|
||||
// 覆盖 prismjs 默认主题(prism.css)的运算符底色。
|
||||
//
|
||||
// 默认主题中 `.token.operator / .entity / .url`(含 `.language-css .token.string`)
|
||||
// 带有 `background: hsla(0, 0%, 100%, .5)` 半透明白底——浅色主题下几乎不可见,
|
||||
// 深色主题下会渲染成一层灰色色块(典型如 `=` / `==` 出现灰底)。
|
||||
// 这里只去掉底色、保留字符变色,高亮颜色本身仍由 prism.css 提供。
|
||||
// 该覆盖全局生效:代码块、show_file 卡片、快捷窗口文件预览共用同一套 Prism 主题。
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
background: none;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
@use './base/reset';
|
||||
@use './base/tokens';
|
||||
@use './base/global';
|
||||
@use './base/prism-overrides';
|
||||
@use './layout/app-shell';
|
||||
@use './layout/panels';
|
||||
@use './components/sidebar/conversation';
|
||||
|
||||
136
static/src/utils/prismHighlight.ts
Normal file
136
static/src/utils/prismHighlight.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Prism 语法高亮共享模块。
|
||||
*
|
||||
* 集中完成两件事:
|
||||
* 1. 注册常用语言包(prismjs 核心只自带 markup/css/clike/javascript,
|
||||
* 其余语言必须显式 import;在此统一注册后,CodeBlock / ShowFileCard /
|
||||
* 快捷窗口文件预览等所有使用 Prism 的地方自动获益)。
|
||||
* 2. 提供「路径 → Prism 语言 id」映射与「文本 → 高亮 HTML」的纯函数。
|
||||
*
|
||||
* 颜色样式复用 main.ts 引入的 prismjs/themes/prism.css(与代码块渲染同款),
|
||||
* 本模块不产出任何颜色相关代码。
|
||||
*/
|
||||
|
||||
import Prism from 'prismjs';
|
||||
|
||||
// ---- 语言包注册(注意 import 顺序即依赖顺序:被依赖的语言必须先加载) ----
|
||||
import 'prismjs/components/prism-markup-templating'; // php 依赖
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp'; // 依赖 c
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-php'; // 依赖 markup-templating
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-powershell';
|
||||
import 'prismjs/components/prism-batch';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-scss';
|
||||
import 'prismjs/components/prism-sass';
|
||||
import 'prismjs/components/prism-less';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx'; // 依赖 jsx + typescript
|
||||
import 'prismjs/components/prism-toml';
|
||||
import 'prismjs/components/prism-ini';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
|
||||
/** 扩展名(小写、带点)→ Prism 语言 id */
|
||||
const EXT_TO_PRISM_LANG: Record<string, string> = {
|
||||
'.js': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
'.cjs': 'javascript',
|
||||
'.jsx': 'jsx',
|
||||
'.ts': 'typescript',
|
||||
'.mts': 'typescript',
|
||||
'.cts': 'typescript',
|
||||
'.tsx': 'tsx',
|
||||
'.vue': 'markup',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.go': 'go',
|
||||
'.rs': 'rust',
|
||||
'.java': 'java',
|
||||
'.c': 'c',
|
||||
'.h': 'c',
|
||||
'.cpp': 'cpp',
|
||||
'.hpp': 'cpp',
|
||||
'.cc': 'cpp',
|
||||
'.hh': 'cpp',
|
||||
'.cxx': 'cpp',
|
||||
'.cs': 'csharp',
|
||||
'.php': 'php',
|
||||
'.swift': 'swift',
|
||||
'.kt': 'kotlin',
|
||||
'.kts': 'kotlin',
|
||||
'.sh': 'bash',
|
||||
'.bash': 'bash',
|
||||
'.zsh': 'bash',
|
||||
'.ps1': 'powershell',
|
||||
'.bat': 'batch',
|
||||
'.cmd': 'batch',
|
||||
'.html': 'markup',
|
||||
'.htm': 'markup',
|
||||
'.xhtml': 'markup',
|
||||
'.xml': 'markup',
|
||||
'.svg': 'markup',
|
||||
'.css': 'css',
|
||||
'.scss': 'scss',
|
||||
'.sass': 'sass',
|
||||
'.less': 'less',
|
||||
'.json': 'json',
|
||||
'.json5': 'json',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.toml': 'toml',
|
||||
'.ini': 'ini',
|
||||
'.cfg': 'ini',
|
||||
'.conf': 'ini',
|
||||
'.sql': 'sql',
|
||||
'.md': 'markdown',
|
||||
'.markdown': 'markdown'
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据文件路径推断 Prism 语言 id。
|
||||
* @returns 语言 id;无法推断(如 .txt/.log/.csv 或无扩展名)返回 null。
|
||||
*/
|
||||
export function prismLangForPath(path: string): string | null {
|
||||
const name = path.split('/').pop() || '';
|
||||
const dotIdx = name.lastIndexOf('.');
|
||||
if (dotIdx < 0) return null;
|
||||
const ext = name.slice(dotIdx).toLowerCase();
|
||||
return EXT_TO_PRISM_LANG[ext] || null;
|
||||
}
|
||||
|
||||
/** HTML 转义(Prism 不可用时的兜底渲染) */
|
||||
export function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对整段代码做语法高亮,返回可安全用于 v-html 的 HTML 字符串。
|
||||
* 语言未注册或高亮过程抛错时,回退为转义后的纯文本。
|
||||
*/
|
||||
export function highlightCode(content: string, lang: string | null): string {
|
||||
if (lang) {
|
||||
const grammar = Prism.languages[lang];
|
||||
if (grammar) {
|
||||
try {
|
||||
return Prism.highlight(content, grammar, lang);
|
||||
} catch {
|
||||
// fallthrough 到纯文本兜底
|
||||
}
|
||||
}
|
||||
}
|
||||
return escapeHtml(content);
|
||||
}
|
||||
|
||||
export { Prism };
|
||||
Loading…
Reference in New Issue
Block a user