update
This commit is contained in:
32
app/components/blog/Comment.vue
Normal file
32
app/components/blog/Comment.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import siteConfig from "~/config";
|
||||||
|
import type { TwikooAPI } from "~/types/twikoo";
|
||||||
|
|
||||||
|
const { $twikoo } = useNuxtApp() as unknown as { $twikoo: TwikooAPI };
|
||||||
|
const isLoading = ref(true);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
$twikoo({
|
||||||
|
envId: siteConfig.comment.twikoo.envId,
|
||||||
|
el: "#twikoo",
|
||||||
|
path: useRoute().path,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 评论加载完毕后隐藏占位符
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoading.value = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 加载占位符 -->
|
||||||
|
<div v-if="isLoading" class="space-y-3 animate-pulse">
|
||||||
|
<div class="h-12 bg-zinc-200 dark:bg-zinc-800 rounded"></div>
|
||||||
|
<div class="h-20 bg-zinc-200 dark:bg-zinc-800 rounded"></div>
|
||||||
|
<div class="h-8 w-24 bg-zinc-200 dark:bg-zinc-800 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 评论容器 -->
|
||||||
|
<div v-show="!isLoading" id="twikoo" class="twikoo-container"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
// 行内代码组件
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<code
|
|
||||||
class="bg-primary-10 text-primary dark:text-primary px-1.5 py-0.5 rounded-md font-mono text-[0.9em]"
|
|
||||||
><slot
|
|
||||||
/></code>
|
|
||||||
</template>
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
code?: string;
|
|
||||||
language?: string;
|
|
||||||
filename?: string;
|
|
||||||
highlights?: number[];
|
|
||||||
meta?: string;
|
|
||||||
class?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
code: "",
|
|
||||||
language: "text", // Nuxt Content 已经做了此处理
|
|
||||||
highlights: () => [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
interface CodeblockMeta {
|
|
||||||
icon?: string;
|
|
||||||
wrap?: boolean;
|
|
||||||
[meta: string]: string | boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = computed(() => {
|
|
||||||
if (!props.meta) return {};
|
|
||||||
|
|
||||||
return props.meta.split(" ").reduce((acc: CodeblockMeta, item) => {
|
|
||||||
const [key, value] = item.split("=");
|
|
||||||
acc[key!] = value ?? true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const appConfig = useAppConfig();
|
|
||||||
const compConf = appConfig.component.codeblock;
|
|
||||||
|
|
||||||
const rows = computed(() => props.code.split("\n").length - 1);
|
|
||||||
const collapsible = computed(() => !meta.value.expand && rows.value > compConf.triggerRows);
|
|
||||||
const [isCollapsed, toggleCollapsed] = useToggle(collapsible.value);
|
|
||||||
|
|
||||||
const icon = computed(
|
|
||||||
() => meta.value.icon || getFileIcon(props.filename) || getLangIcon(props.language),
|
|
||||||
);
|
|
||||||
const isWrap = ref(meta.value.wrap);
|
|
||||||
|
|
||||||
const codeblock = useTemplateRef("codeblock");
|
|
||||||
const { copy, copied } = useCopy(codeblock);
|
|
||||||
|
|
||||||
const shikiStore = useShikiStore();
|
|
||||||
const rawHtml = ref(escapeHtml(props.code));
|
|
||||||
|
|
||||||
function getIndent() {
|
|
||||||
if (meta.value.indent) return meta.value.indent;
|
|
||||||
|
|
||||||
if (["json", "jsonc", "yaml", "yml"].includes(props.language)) return 2;
|
|
||||||
|
|
||||||
return compConf.indent;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const shiki = await shikiStore.load();
|
|
||||||
await shikiStore.loadLang(props.language);
|
|
||||||
// 处理 Markdown 高亮内代码块中的语言
|
|
||||||
// 加载 TeX 语言有概率导致 LaTeX 语言高亮炸掉
|
|
||||||
if (props.language === "markdown" || props.language.startsWith("md")) {
|
|
||||||
const mdLangRegex = /^\s*`{3,}(\S+)/gm;
|
|
||||||
const langs = Array.from(props.code.matchAll(mdLangRegex))
|
|
||||||
.map((match) => match[1])
|
|
||||||
.filter((lang) => lang !== undefined);
|
|
||||||
await shikiStore.loadLang(...langs);
|
|
||||||
}
|
|
||||||
|
|
||||||
rawHtml.value = shiki.codeToHtml(
|
|
||||||
props.code.trimEnd(),
|
|
||||||
shikiStore.getOptions(
|
|
||||||
props.language,
|
|
||||||
[compConf.enableIndentGuide ? "ignoreRenderWhitespace" : "ignoreRenderIndentGuides"],
|
|
||||||
{ meta: { indent: getIndent() } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<figure
|
|
||||||
class="z-codeblock"
|
|
||||||
:class="{ collapsed: collapsible && isCollapsed, collapsible }"
|
|
||||||
:style="{
|
|
||||||
'--collapsed-rows': compConf.collapsedRows,
|
|
||||||
'--tab-size': (meta.indent as string) || compConf.tabSize,
|
|
||||||
}">
|
|
||||||
<figcaption>
|
|
||||||
<span v-if="filename" class="filename"> <Icon :name="icon" /> {{ filename }} </span>
|
|
||||||
<span v-else />
|
|
||||||
<!-- 语言不采用绝对定位,因为和文件名占据互斥空间 -->
|
|
||||||
<span v-if="language" class="language">{{ language }}</span>
|
|
||||||
<div class="operations">
|
|
||||||
<button @click="isWrap = !isWrap">
|
|
||||||
{{ isWrap ? "横向滚动" : "自动换行" }}
|
|
||||||
</button>
|
|
||||||
<button @click="copy()">
|
|
||||||
{{ copied ? "已复制" : "复制" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</figcaption>
|
|
||||||
|
|
||||||
<!-- 嘿嘿,不要换行 -->
|
|
||||||
<pre
|
|
||||||
ref="codeblock"
|
|
||||||
class="shiki scrollcheck-x"
|
|
||||||
:class="[props.class, { wrap: isWrap }]"
|
|
||||||
v-html="rawHtml" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="collapsible"
|
|
||||||
class="toggle-btn"
|
|
||||||
:aria-label="isCollapsed ? '展开代码块' : '折叠代码块'"
|
|
||||||
@click="toggleCollapsed()">
|
|
||||||
<Icon
|
|
||||||
class="toggle-icon"
|
|
||||||
:class="{ 'is-collapsed': isCollapsed }"
|
|
||||||
name="ph:caret-double-up-bold" />
|
|
||||||
<span class="toggle-tip">{{ rows }} 行</span>
|
|
||||||
</button>
|
|
||||||
</figure>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.z-codeblock {
|
|
||||||
--line-height: 1.4em;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
overflow: clip;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background-color: var(--c-bg-2);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: var(--line-height);
|
|
||||||
tab-size: var(--tab-size, 4);
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
pre {
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: calc(var(--line-height) * var(--collapsed-rows) + 3rem);
|
|
||||||
mask-image: linear-gradient(to top, transparent 2rem, #fff 4rem);
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
margin: 0.5em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.collapsible pre {
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
figcaption {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1em;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
padding: 0 1em;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
> .filename {
|
|
||||||
padding: 0.2em 0.8em;
|
|
||||||
border-radius: 0 0 0.5em 0.5em;
|
|
||||||
background-color: var(--c-border);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
> .language {
|
|
||||||
opacity: 0.4;
|
|
||||||
height: 0;
|
|
||||||
transform: translateY(0.2em);
|
|
||||||
}
|
|
||||||
|
|
||||||
> .operations {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
padding: 0 0.6em;
|
|
||||||
border-end-start-radius: 0.5em;
|
|
||||||
background-color: var(--c-bg-2);
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
:hover > & {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
> button {
|
|
||||||
opacity: 0.4;
|
|
||||||
padding: 0.2em 0.4em;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
// 如果填写 0 会在 calc() 时出错
|
|
||||||
--start-offset: 4em;
|
|
||||||
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-inline-start: var(--start-offset);
|
|
||||||
|
|
||||||
&.wrap {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line) {
|
|
||||||
&::before {
|
|
||||||
content: attr(data-line);
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-start: 0;
|
|
||||||
width: var(--start-offset);
|
|
||||||
padding-inline-end: 1em;
|
|
||||||
background-color: var(--c-bg-2);
|
|
||||||
text-align: end;
|
|
||||||
color: var(--c-text-3);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.highlight {
|
|
||||||
&::before {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
outline: 0.2em solid var(--ld-bg-active);
|
|
||||||
background-color: var(--ld-bg-active);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
position: absolute;
|
|
||||||
inset: auto 0 0;
|
|
||||||
margin: 0.8em;
|
|
||||||
padding: 0.2em;
|
|
||||||
border-radius: 0.5em;
|
|
||||||
background-color: var(--c-bg-3);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--c-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&.is-collapsed {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
:hover > & {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-tip {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
inset: auto 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
:hover > & {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, onMounted } from "vue";
|
|
||||||
|
|
||||||
interface CodeblockMeta {
|
|
||||||
icon?: string;
|
|
||||||
wrap?: boolean;
|
|
||||||
indent?: string | boolean;
|
|
||||||
expand?: boolean;
|
|
||||||
[meta: string]: string | boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
code: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
language: {
|
|
||||||
type: String,
|
|
||||||
default: "text",
|
|
||||||
},
|
|
||||||
filename: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
highlights: {
|
|
||||||
type: Array as () => number[],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
class: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(props.code);
|
|
||||||
|
|
||||||
const meta = computed(() => {
|
|
||||||
if (!props.meta) return {};
|
|
||||||
|
|
||||||
return props.meta.split(" ").reduce((acc: CodeblockMeta, item) => {
|
|
||||||
const [key, value] = item.split("=");
|
|
||||||
acc[key!] = value ?? true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = computed(() => props.code.split("\n").length - 1);
|
|
||||||
const collapsible = computed(() => !meta.value.expand && rows.value > 20);
|
|
||||||
const [isCollapsed, toggleCollapsed] = useToggle(collapsible.value);
|
|
||||||
|
|
||||||
const isWrap = ref(meta.value.wrap ?? false);
|
|
||||||
|
|
||||||
const codeblock = useTemplateRef("codeblock");
|
|
||||||
const copied = ref(false);
|
|
||||||
|
|
||||||
async function copy() {
|
|
||||||
if (!codeblock.value) return;
|
|
||||||
try {
|
|
||||||
const text = codeblock.value.textContent || "";
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
copied.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copied.value = false;
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("复制失败:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileIcon(filename?: string): string | undefined {
|
|
||||||
if (!filename) return undefined;
|
|
||||||
|
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
|
||||||
const iconMap: Record<string, string> = {
|
|
||||||
json: "i-ph-file-json",
|
|
||||||
ts: "i-ph-file-ts",
|
|
||||||
js: "i-ph-file-js",
|
|
||||||
vue: "i-ph-file-vue",
|
|
||||||
py: "i-ph-file-py",
|
|
||||||
sh: "i-ph-terminal-window-duotone",
|
|
||||||
bash: "i-ph-terminal-window-duotone",
|
|
||||||
yml: "i-ph-file-yaml",
|
|
||||||
yaml: "i-ph-file-yaml",
|
|
||||||
md: "i-ph-file-md",
|
|
||||||
html: "i-ph-file-html",
|
|
||||||
css: "i-ph-file-css",
|
|
||||||
xml: "i-ph-file-xml",
|
|
||||||
};
|
|
||||||
|
|
||||||
return iconMap[ext!];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLangIcon(language?: string): string | undefined {
|
|
||||||
if (!language) return undefined;
|
|
||||||
|
|
||||||
const iconMap: Record<string, string> = {
|
|
||||||
javascript: "i-ph-file-js",
|
|
||||||
typescript: "i-ph-file-ts",
|
|
||||||
python: "i-ph-file-py",
|
|
||||||
json: "i-ph-file-json",
|
|
||||||
bash: "i-ph-terminal-window-duotone",
|
|
||||||
shell: "i-ph-terminal-window-duotone",
|
|
||||||
sh: "i-ph-terminal-window-duotone",
|
|
||||||
vue: "i-ph-file-vue",
|
|
||||||
html: "i-ph-file-html",
|
|
||||||
css: "i-ph-file-css",
|
|
||||||
scss: "i-ph-file-css",
|
|
||||||
yaml: "i-ph-file-yaml",
|
|
||||||
yml: "i-ph-file-yaml",
|
|
||||||
xml: "i-ph-file-xml",
|
|
||||||
markdown: "i-ph-file-md",
|
|
||||||
md: "i-ph-file-md",
|
|
||||||
};
|
|
||||||
|
|
||||||
return iconMap[language];
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = computed(() => {
|
|
||||||
return meta.value.icon || getFileIcon(props.filename) || getLangIcon(props.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每一行添加 data-line 属性并隐藏首尾空行
|
|
||||||
if (import.meta.client) {
|
|
||||||
onMounted(() => {
|
|
||||||
if (!codeblock.value) return;
|
|
||||||
|
|
||||||
// 兼容不同的代码块结构(包括 text 类型)
|
|
||||||
let lines = Array.from(codeblock.value.querySelectorAll(".line"));
|
|
||||||
|
|
||||||
// 如果没有 .line 元素,按 \n 分割创建行
|
|
||||||
if (lines.length === 0) {
|
|
||||||
const html = codeblock.value.innerHTML;
|
|
||||||
const textLines = html.split("\n");
|
|
||||||
|
|
||||||
// 清空预先存在的内容
|
|
||||||
codeblock.value.innerHTML = "";
|
|
||||||
|
|
||||||
// 为每一行创建包装元素
|
|
||||||
textLines.forEach((lineHtml, index) => {
|
|
||||||
const lineEl = document.createElement("div");
|
|
||||||
lineEl.className = "line";
|
|
||||||
lineEl.innerHTML = lineHtml;
|
|
||||||
lineEl.setAttribute("data-line", String(index + 1));
|
|
||||||
codeblock.value!.appendChild(lineEl);
|
|
||||||
});
|
|
||||||
|
|
||||||
lines = Array.from(codeblock.value.querySelectorAll(".line"));
|
|
||||||
} else {
|
|
||||||
// 原有逻辑:为 .line 元素设置 data-line
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
if (!line.getAttribute("data-line")) {
|
|
||||||
line.setAttribute("data-line", String(index + 1));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏首尾空行
|
|
||||||
let firstNonEmptyIndex = -1;
|
|
||||||
let lastNonEmptyIndex = -1;
|
|
||||||
|
|
||||||
// 找到第一个和最后一个非空行
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
const text = (line.textContent || "").trim();
|
|
||||||
if (text) {
|
|
||||||
if (firstNonEmptyIndex === -1) firstNonEmptyIndex = index;
|
|
||||||
lastNonEmptyIndex = index;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 隐藏首尾空行
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
if (index < firstNonEmptyIndex || index > lastNonEmptyIndex) {
|
|
||||||
(line as HTMLElement).style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<figure
|
|
||||||
class="group relative overflow-hidden my-4 rounded-lg bg-(--c-bg-2,#0f1419) text-sm leading-relaxed border border-(--c-border,#2a2e38)"
|
|
||||||
:class="{
|
|
||||||
'is-collapsed': collapsible && isCollapsed,
|
|
||||||
'is-collapsible': collapsible,
|
|
||||||
}"
|
|
||||||
:style="{ '--tab-size': (meta.indent as string) || 4 }">
|
|
||||||
<!-- 顶部标题栏 -->
|
|
||||||
<figcaption
|
|
||||||
class="sticky top-0 z-20 flex items-center justify-between gap-3 px-4 py-2.5 bg-(--c-bg-2,#0f1419) border-b border-(--c-border,#2a2e38) backdrop-blur-sm">
|
|
||||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
|
||||||
<!-- 文件名 -->
|
|
||||||
<span
|
|
||||||
v-if="filename"
|
|
||||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-(--c-bg-3,#1a1f2e) text-xs font-mono text-(--c-text-1,#e8eaed) whitespace-nowrap border border-(--c-border,#2a2e38)">
|
|
||||||
<Icon v-if="icon" :name="icon" class="w-3.5 h-3.5 shrink-0 opacity-80" />
|
|
||||||
<span class="truncate font-semibold">{{ filename }}</span>
|
|
||||||
</span>
|
|
||||||
<!-- 语言标签 -->
|
|
||||||
<span
|
|
||||||
v-if="language"
|
|
||||||
class="text-xs text-(--c-text-2,#a8adb8) font-mono font-medium tracking-wide">
|
|
||||||
{{ language }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="flex items-center gap-1 opacity-60 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
|
||||||
title="切换代码换行"
|
|
||||||
@click="isWrap = !isWrap">
|
|
||||||
{{ isWrap ? "换行" : "滚动" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2.5 py-1.5 rounded-md text-xs font-mono bg-(--c-bg-3,#1a1f2e) hover:bg-(--c-border,#2a2e38) transition-colors text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) border border-transparent hover:border-(--c-border,#2a2e38)"
|
|
||||||
title="复制代码"
|
|
||||||
@click="copy()">
|
|
||||||
{{ copied ? "✓ 已复制" : "复制" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</figcaption>
|
|
||||||
|
|
||||||
<!-- 代码块 -->
|
|
||||||
<pre
|
|
||||||
ref="codeblock"
|
|
||||||
class="overflow-auto m-0 text-(--c-text-1,#e8eaed) shiki"
|
|
||||||
:style="{
|
|
||||||
paddingLeft: '1rem',
|
|
||||||
paddingRight: '1rem',
|
|
||||||
paddingTop: '0.3rem',
|
|
||||||
paddingBottom: '0.3rem',
|
|
||||||
// paddingBottom: collapsible ? '3rem' : '0.5rem',
|
|
||||||
fontSize: '14px',
|
|
||||||
tabSize: (meta.indent as string) || '4',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
}"
|
|
||||||
:class="[props.class, { 'whitespace-pre-wrap': isWrap }]">
|
|
||||||
<slot />
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
<!-- 折叠/展开按钮 -->
|
|
||||||
<button
|
|
||||||
v-if="collapsible"
|
|
||||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-2 rounded-lg bg-(--c-bg-3,#1a1f2e) border border-(--c-border,#2a2e38) text-(--c-text-2,#a8adb8) hover:text-(--c-text-1,#e8eaed) hover:bg-(--c-border,#2a2e38) transition-all duration-200 cursor-pointer flex items-center gap-2 text-xs font-mono font-medium"
|
|
||||||
:aria-label="isCollapsed ? '展开代码块' : '折叠代码块'"
|
|
||||||
@click="toggleCollapsed()">
|
|
||||||
<Icon
|
|
||||||
class="w-4 h-4 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-180': isCollapsed }"
|
|
||||||
name="ph:caret-down-bold" />
|
|
||||||
<span>{{ rows }} 行</span>
|
|
||||||
</button>
|
|
||||||
</figure>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.is-collapsed {
|
|
||||||
:deep(pre) {
|
|
||||||
max-height: calc(1.5em * 20 + 3.5rem);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(pre::after) {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3.5rem;
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(15, 20, 25, 0.3) 60%,
|
|
||||||
var(--c-bg-2, #0f1419) 100%
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line) {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
padding-left: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line::before) {
|
|
||||||
content: attr(data-line);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
width: 3rem;
|
|
||||||
padding-right: 0.5rem;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--c-text-3, #6c7280);
|
|
||||||
user-select: none;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-family: "Fira Code", "Monaco", monospace;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line:empty) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line.highlight) {
|
|
||||||
background-color: rgba(248, 113, 113, 0.08);
|
|
||||||
border-left: 2px solid rgba(248, 113, 113, 0.3);
|
|
||||||
margin-left: -3.5rem;
|
|
||||||
padding-left: calc(3.5rem - 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.line.highlight::before) {
|
|
||||||
color: var(--c-text-2, #a8adb8);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(code) {
|
|
||||||
/* font-family: "JetBrains Mono", "Fira Code", "Monaco", monospace; */
|
|
||||||
font-size: 0.95em;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
font-variant-ligatures: common-ligatures;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useClipboard } from "@vueuse/core";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
code?: string;
|
|
||||||
language?: string | null;
|
|
||||||
filename?: string | null;
|
|
||||||
highlights?: number[];
|
|
||||||
meta?: string | null;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
code: "",
|
|
||||||
language: null,
|
|
||||||
filename: null,
|
|
||||||
highlights: undefined,
|
|
||||||
meta: null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { copy, copied } = useClipboard();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute right-2 top-2 flex gap-1">
|
|
||||||
<span
|
|
||||||
v-if="filename"
|
|
||||||
class="block py-1 px-2 text-xs rounded border border-solid border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-slate-700 z-10"
|
|
||||||
>{{ filename }}</span
|
|
||||||
>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="block border border-solid border-gray-300 dark:border-gray-600 rounded p-1 bg-gray-100 dark:bg-slate-700 z-10"
|
|
||||||
@click="copy(code)">
|
|
||||||
<svg
|
|
||||||
v-if="copied"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="block text-green-500"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M12.736 3.97a.733.733 0 0 1 1.047 0c.286.289.29.756.01 1.05L7.88 12.01a.733.733 0 0 1-1.065.02L3.217 8.384a.757.757 0 0 1 0-1.06.733.733 0 0 1 1.047 0l3.052 3.093 5.4-6.425a.247.247 0 0 1 .02-.022Z" />
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="block"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z" />
|
|
||||||
<path
|
|
||||||
d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* pre code .line::before {
|
|
||||||
content: attr(line);
|
|
||||||
position: absolute;
|
|
||||||
left: -6.25rem;
|
|
||||||
color: #cccccc;
|
|
||||||
user-select: none;
|
|
||||||
} */
|
|
||||||
@import "~/assets/css/code.css";
|
|
||||||
</style>
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BlogPost } from "~/types/blog";
|
|
||||||
|
|
||||||
// Function to parse dates in the format "1st Mar 2023"
|
// Function to parse dates in the format "1st Mar 2023"
|
||||||
function parseCustomDate(dateStr: string): Date {
|
function parseCustomDate(dateStr: string): Date {
|
||||||
// Remove ordinal indicators (st, nd, rd, th)
|
// Remove ordinal indicators (st, nd, rd, th)
|
||||||
@@ -16,8 +14,8 @@ const { data } = await useAsyncData("recent-post", () =>
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data
|
return data
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aDate = parseCustomDate(a.meta.date as string);
|
const aDate = parseCustomDate(a.date as string);
|
||||||
const bDate = parseCustomDate(b.meta.date as string);
|
const bDate = parseCustomDate(b.date as string);
|
||||||
return bDate.getTime() - aDate.getTime();
|
return bDate.getTime() - aDate.getTime();
|
||||||
})
|
})
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
@@ -26,16 +24,15 @@ const { data } = await useAsyncData("recent-post", () =>
|
|||||||
|
|
||||||
const formattedData = computed(() => {
|
const formattedData = computed(() => {
|
||||||
return data.value?.map((articles) => {
|
return data.value?.map((articles) => {
|
||||||
const meta = articles.meta as unknown as BlogPost;
|
|
||||||
return {
|
return {
|
||||||
path: articles.path,
|
path: articles.path,
|
||||||
title: articles.title || "no-title available",
|
title: articles.title || "no-title available",
|
||||||
description: articles.description || "no-description available",
|
description: articles.description || "no-description available",
|
||||||
image: meta.image || "/not-found.jpg",
|
image: articles.image || "/not-found.jpg",
|
||||||
alt: meta.alt || "no alter data available",
|
alt: articles.alt || "no alter data available",
|
||||||
date: meta.date || "not-date-available",
|
date: articles.date || "not-date-available",
|
||||||
tags: meta.tags || [],
|
tags: articles.tags || [],
|
||||||
published: meta.published || false,
|
published: articles.published || false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const siteConfig = {
|
|||||||
{ name: "Email", url: "mailto:i@rhen.cloud" },
|
{ name: "Email", url: "mailto:i@rhen.cloud" },
|
||||||
{ name: "Telegram", url: "https://t.me/RhenCloud" },
|
{ name: "Telegram", url: "https://t.me/RhenCloud" },
|
||||||
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
|
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
|
||||||
{ name: "Blog", url: "https://blog.rhen.cloud" },
|
{ name: "X", url: "https://x.com/RhenCloud75" },
|
||||||
],
|
],
|
||||||
|
|
||||||
navbar: {
|
navbar: {
|
||||||
@@ -53,6 +53,13 @@ const siteConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
comment: {
|
||||||
|
twikoo: {
|
||||||
|
enable: true,
|
||||||
|
envId: "https://twikoo.rhen.cloud",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
umami: {
|
umami: {
|
||||||
enable: false,
|
enable: false,
|
||||||
url: "https://cloud.umami.is/script.js",
|
url: "https://cloud.umami.is/script.js",
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BlogPost } from "~/types/blog";
|
// import type { BlogPost } from "~/types/blog";
|
||||||
import { formatDate } from "~/utils/helper";
|
import { formatDate } from "~/utils/helper";
|
||||||
|
|
||||||
const { data } = await useAsyncData("all-archive-post", () =>
|
const { data } = await useAsyncData("all-archive-post", () =>
|
||||||
queryCollection("content").order("date", "DESC").all(),
|
queryCollection("content").where("published", "=", true).order("date", "DESC").all(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// const sortedData = computed(() => {
|
// const sortedData = computed(() => {
|
||||||
const posts = computed(() => {
|
const posts = computed(() => {
|
||||||
return data.value?.map((articles) => {
|
return data.value?.map((articles) => {
|
||||||
const meta = articles.meta as unknown as BlogPost;
|
|
||||||
return {
|
return {
|
||||||
path: articles.path,
|
path: articles.path,
|
||||||
title: articles.title || "no-title available",
|
title: articles.title || "no-title available",
|
||||||
description: articles.description || "no-description available",
|
description: articles.description || "no-description available",
|
||||||
image: meta.image || "/not-found.jpg",
|
image: articles.image || "/not-found.jpg",
|
||||||
alt: meta.alt || "no alter data available",
|
alt: articles.alt || "no alter data available",
|
||||||
date: formatDate(articles.date) || "not-date-available",
|
date: formatDate(articles.date) || "not-date-available",
|
||||||
tags: meta.tags || [],
|
tags: articles.tags || [],
|
||||||
published: meta.published || false,
|
published: articles.published || false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BlogPost } from "@/types/blog";
|
import type { BlogPost } from "@/types/blog";
|
||||||
|
import Comment from "~/components/blog/Comment.vue";
|
||||||
import { seoData } from "~/data";
|
import { seoData } from "~/data";
|
||||||
import { formatDate } from "~/utils/helper";
|
import { formatDate } from "~/utils/helper";
|
||||||
|
|
||||||
@@ -12,17 +13,15 @@ const { data: articles, error } = await useAsyncData(`blog-post-${path}`, () =>
|
|||||||
if (error.value) navigateTo("/404");
|
if (error.value) navigateTo("/404");
|
||||||
|
|
||||||
const data = computed<BlogPost>(() => {
|
const data = computed<BlogPost>(() => {
|
||||||
const meta = articles?.value?.meta as unknown as BlogPost;
|
|
||||||
return {
|
return {
|
||||||
title: articles.value?.title || "no-title available",
|
title: articles.value?.title || "no-title available",
|
||||||
description: articles.value?.description || "no-description available",
|
description: articles.value?.description || "no-description available",
|
||||||
image: meta?.image || "/not-found.jpg",
|
image: articles.value?.image || "/not-found.jpg",
|
||||||
alt: meta?.alt || "no alter data available",
|
alt: articles.value?.alt || "no alter data available",
|
||||||
date: meta?.date || "not-date-available",
|
date: articles.value?.date || "not-date-available",
|
||||||
tags: meta?.tags || [],
|
tags: articles.value?.tags || [],
|
||||||
published: meta?.published || false,
|
published: articles.value?.published || false,
|
||||||
categories: meta?.categories || [],
|
categories: articles.value?.categories || [],
|
||||||
meta: meta || {},
|
|
||||||
path: path || "",
|
path: path || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -54,7 +53,7 @@ useHead({
|
|||||||
:title="data.title"
|
:title="data.title"
|
||||||
:image="data.image"
|
:image="data.image"
|
||||||
:alt="data.alt"
|
:alt="data.alt"
|
||||||
:date="formatDate(articles.date)"
|
:date="formatDate(data.date)"
|
||||||
:description="data.description"
|
:description="data.description"
|
||||||
:tags="data.tags" />
|
:tags="data.tags" />
|
||||||
<div
|
<div
|
||||||
@@ -64,6 +63,9 @@ useHead({
|
|||||||
<p>No content found.</p>
|
<p>No content found.</p>
|
||||||
</template>
|
</template>
|
||||||
</ContentRenderer>
|
</ContentRenderer>
|
||||||
|
<ClientOnly>
|
||||||
|
<Comment />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import type { BlogPost } from "~/types/blog";
|
// import type { BlogPost } from "~/types/blog";
|
||||||
|
|
||||||
const { data } = await useAsyncData("all-blog-post", () => queryCollection("content").all());
|
const { data } = await useAsyncData("all-blog-post", () => queryCollection("content").all());
|
||||||
|
|
||||||
@@ -11,16 +11,15 @@ const searchTest = ref("");
|
|||||||
const formattedData = computed(() => {
|
const formattedData = computed(() => {
|
||||||
return (
|
return (
|
||||||
data.value?.map((articles) => {
|
data.value?.map((articles) => {
|
||||||
const meta = articles.meta as unknown as BlogPost;
|
|
||||||
return {
|
return {
|
||||||
path: articles.path,
|
path: articles.path,
|
||||||
title: articles.title || "no-title available",
|
title: articles.title || "no-title available",
|
||||||
description: articles.description || "no-description available",
|
description: articles.description || "no-description available",
|
||||||
image: meta.image || "/not-found.jpg",
|
image: articles.image || "/not-found.jpg",
|
||||||
alt: meta.alt || "no alter data available",
|
alt: articles.alt || "no alter data available",
|
||||||
date: meta.date || "not-date-available",
|
date: articles.date || "not-date-available",
|
||||||
tags: meta.tags || [],
|
tags: articles.tags || [],
|
||||||
published: meta.published || false,
|
published: articles.published || false,
|
||||||
};
|
};
|
||||||
}) || []
|
}) || []
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BlogPost } from "@/types/blog";
|
// import type { BlogPost } from "@/types/blog";
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// Take category from route params & ensure it's a valid string
|
// Take category from route params & ensure it's a valid string
|
||||||
@@ -13,37 +13,27 @@ const category = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data } = await useAsyncData(`category-data-${category.value}`, () =>
|
const { data } = await useAsyncData(`category-data-${category.value}`, () =>
|
||||||
queryCollection("content")
|
queryCollection("content").where("published", "=", true).all(),
|
||||||
.all()
|
|
||||||
.then((articles) =>
|
|
||||||
articles.filter((article) => {
|
|
||||||
const meta = article.meta as unknown as BlogPost;
|
|
||||||
return (
|
|
||||||
meta.published &&
|
|
||||||
meta.categories?.map((cat) => cat.toLowerCase()).includes(category.value)
|
|
||||||
); // Case-insensitive matching
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedData = computed(() => {
|
const formattedData = computed(() => {
|
||||||
return (
|
return (
|
||||||
data.value
|
data.value
|
||||||
?.map((articles) => {
|
?.filter((article) =>
|
||||||
const meta = articles.meta as unknown as BlogPost;
|
article.categories?.map((cat) => cat.toLowerCase()).includes(category.value),
|
||||||
|
)
|
||||||
|
.map((articles) => {
|
||||||
return {
|
return {
|
||||||
path: articles.path,
|
path: articles.path,
|
||||||
title: articles.title || "no-title available",
|
title: articles.title || "no-title available",
|
||||||
description: articles.description || "no-description available",
|
description: articles.description || "no-description available",
|
||||||
image: meta.image || "/blogs-img/blog.jpg",
|
image: articles.image || "/blogs-img/blog.jpg",
|
||||||
alt: meta.alt || "no alter data available",
|
alt: articles.alt || "no alter data available",
|
||||||
ogImage: meta.ogImage || "/blogs-img/blog.jpg",
|
date: articles.date || "not-date-available",
|
||||||
date: meta.date || "not-date-available",
|
tags: articles.tags || [],
|
||||||
tags: meta.tags || [],
|
published: articles.published || false,
|
||||||
published: meta.published || false,
|
|
||||||
};
|
};
|
||||||
})
|
}) || []
|
||||||
.filter((post) => post.published) || [] // Ensure only published posts are shown
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,11 +57,10 @@ useHead({
|
|||||||
:key="post.title"
|
:key="post.title"
|
||||||
:path="post.path"
|
:path="post.path"
|
||||||
:title="post.title"
|
:title="post.title"
|
||||||
:date="post.date"
|
:date="formatDate(post.date)"
|
||||||
:description="post.description"
|
:description="post.description"
|
||||||
:image="post.image"
|
:image="post.image"
|
||||||
:alt="post.alt"
|
:alt="post.alt"
|
||||||
:og-image="post.ogImage"
|
|
||||||
:tags="post.tags"
|
:tags="post.tags"
|
||||||
:published="post.published" />
|
:published="post.published" />
|
||||||
<BlogEmpty v-if="formattedData.length === 0" />
|
<BlogEmpty v-if="formattedData.length === 0" />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const { data } = await useAsyncData("all-blog-post-by-category", () =>
|
const { data } = await useAsyncData("all-blog-post-by-category", () =>
|
||||||
queryCollection("content").all(),
|
queryCollection("content").select("path", "categories").where("published", "=", true).all(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allTags = new Map();
|
const allTags = new Map();
|
||||||
|
|
||||||
data.value?.forEach((blog) => {
|
data.value?.forEach((blog) => {
|
||||||
const categories: Array<string> = (blog.meta.categories as string[]) || [];
|
const categories: Array<string> = (blog.categories as string[]) || [];
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
if (allTags.has(category)) {
|
if (allTags.has(category)) {
|
||||||
const cnt = allTags.get(category);
|
const cnt = allTags.get(category);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { BlogPost } from "@/types/blog";
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const tag = computed(() => {
|
const tag = computed(() => {
|
||||||
@@ -16,26 +15,28 @@ const { data } = await useAsyncData(`tag-data-${tag.value}`, () =>
|
|||||||
.all()
|
.all()
|
||||||
.then((articles) =>
|
.then((articles) =>
|
||||||
articles.filter((article) => {
|
articles.filter((article) => {
|
||||||
const meta = article.meta as unknown as BlogPost;
|
return (
|
||||||
return meta.published && meta.tags.some((t) => t.toLowerCase() === tag.value.toLowerCase());
|
article.published && article.tags.some((t) => t.toLowerCase() === tag.value.toLowerCase())
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formattedData = computed(() => {
|
const formattedData = computed(() => {
|
||||||
return data.value?.map((articles) => {
|
return (
|
||||||
const meta = articles.meta as unknown as BlogPost;
|
data.value?.map((articles) => {
|
||||||
return {
|
return {
|
||||||
path: articles.path,
|
path: articles.path,
|
||||||
title: articles.title || "no-title available",
|
title: articles.title || "no-title available",
|
||||||
description: articles.description || "no-description available",
|
description: articles.description || "no-description available",
|
||||||
image: meta.image || "/blogs-img/blog.jpg",
|
image: articles.image || "/blogs-img/blog.jpg",
|
||||||
alt: meta.alt || "no alter data available",
|
alt: articles.alt || "no alter data available",
|
||||||
date: formatDate(articles.date) || "not-date-available",
|
date: formatDate(articles.date) || "not-date-available",
|
||||||
tags: meta.tags || [],
|
tags: articles.tags || [],
|
||||||
published: meta.published || false,
|
published: articles.published || false,
|
||||||
};
|
};
|
||||||
});
|
}) || []
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const { data } = await useAsyncData("all-blog-post-by-tags", () =>
|
const { data } = await useAsyncData("all-blog-post-by-tags", () =>
|
||||||
queryCollection("content").all(),
|
queryCollection("content").select("path", "tags").where("published", "=", true).all(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allTags = new Map();
|
const allTags = new Map();
|
||||||
|
|
||||||
data.value?.forEach((blog) => {
|
data.value?.forEach((blog) => {
|
||||||
const tags: Array<string> = (blog.meta.tags as string[]) || [];
|
const tags: Array<string> = (blog.tags as string[]) || [];
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
if (allTags.has(tag)) {
|
if (allTags.has(tag)) {
|
||||||
const cnt = allTags.get(tag);
|
const cnt = allTags.get(tag);
|
||||||
|
|||||||
9
app/plugins/twikoo.client.ts
Normal file
9
app/plugins/twikoo.client.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import twikoo from "twikoo";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
twikoo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -7,14 +7,5 @@ export interface BlogPost {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
published: boolean;
|
published: boolean;
|
||||||
meta: BlogPostMeta; // 添加 meta 属性
|
path: string;
|
||||||
path: string; // 添加 path 属性
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogPostMeta {
|
|
||||||
image?: string;
|
|
||||||
alt?: string;
|
|
||||||
date?: string;
|
|
||||||
tags?: string[];
|
|
||||||
// 根据实际情况添加其他可能的属性
|
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/types/twikoo.d.ts
vendored
Normal file
25
app/types/twikoo.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface TwikooConfig {
|
||||||
|
envId: string;
|
||||||
|
region?: string;
|
||||||
|
el?: string | HTMLElement;
|
||||||
|
path?: string;
|
||||||
|
lang?: string;
|
||||||
|
theme?: string;
|
||||||
|
locale?: Record<string, string>;
|
||||||
|
[key: string]: string | HTMLElement | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwikooAPI {
|
||||||
|
(config: TwikooConfig): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "twikoo" {
|
||||||
|
export const twikoo: TwikooAPI;
|
||||||
|
export default twikoo;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "#app" {
|
||||||
|
interface NuxtApp {
|
||||||
|
$twikoo: TwikooAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineCollection, defineContentConfig } from "@nuxt/content";
|
import { defineCollection, defineContentConfig } from "@nuxt/content";
|
||||||
|
import { asSeoCollection } from "@nuxtjs/seo/content";
|
||||||
import { asRobotsCollection } from "@nuxtjs/robots/content";
|
import { asRobotsCollection } from "@nuxtjs/robots/content";
|
||||||
import { asSitemapCollection } from "@nuxtjs/sitemap/content";
|
import { asSitemapCollection } from "@nuxtjs/sitemap/content";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -14,10 +15,21 @@ export default defineContentConfig({
|
|||||||
type: "page",
|
type: "page",
|
||||||
source: "blogs/**/*.md",
|
source: "blogs/**/*.md",
|
||||||
}),
|
}),
|
||||||
schema: z.object({
|
...asSeoCollection({
|
||||||
date: z.date(),
|
type: "page",
|
||||||
|
source: "blogs/**/*.md",
|
||||||
}),
|
}),
|
||||||
indexes: [{ columns: ["date"] }],
|
schema: z.object({
|
||||||
|
published: z.boolean(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.date(),
|
||||||
|
categories: z.array(z.string()),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
image: z.string(),
|
||||||
|
alt: z.string(),
|
||||||
|
}),
|
||||||
|
// indexes: [{ columns: ["date"] }],
|
||||||
}),
|
}),
|
||||||
about: defineCollection({
|
about: defineCollection({
|
||||||
type: "page",
|
type: "page",
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
title: "在 Windows 下配置 Fish"
|
|
||||||
published: true
|
|
||||||
date: 2025-10-03
|
|
||||||
updatedDate: 2025-10-03
|
|
||||||
description: "在 Windows 下配置 Fish"
|
|
||||||
image: https://img.rhen.cloud/file/Blog/1761401028478_PixPin_2025-10-03_15-44-52.png
|
|
||||||
alt: "在 Windows 下配置 Fish"
|
|
||||||
tags: ["Development", "Windows", "Fish"]
|
|
||||||
categories: ["Technology"]
|
|
||||||
---
|
|
||||||
|
|
||||||
## 前言
|
|
||||||
|
|
||||||
在经历了 Linux 下一系列的生态折磨后,我最终回归了 Windows 的怀抱。
|
|
||||||
|
|
||||||
但是 Powershell 实在是太难用了。所以,Fish,启动!
|
|
||||||
|
|
||||||
## 安装 Fish
|
|
||||||
|
|
||||||
依据 [Fish 官网](https://fishshell.com/),我们有三种方式在 Windows 上安装 Fish:
|
|
||||||
|
|
||||||
- 通过 Cygwin 安装
|
|
||||||
- 通过 MSYS2 安装
|
|
||||||
- 通过 WSL 安装
|
|
||||||
|
|
||||||
~~作为一个 Arch 用户,肯定是秒选用 pacman 做包管理的 MSYS2。~~
|
|
||||||
|
|
||||||
### 安装 MSYS2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scoop install msys2
|
|
||||||
```
|
|
||||||
|
|
||||||
安装完后会提示`Please run 'msys2' now for the MSYS2 setup to complete!`
|
|
||||||
|
|
||||||
依照提示运行`msys2`
|
|
||||||
|
|
||||||
这样就成功进入 MSYS2 的环境了。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 配置 MSYS2
|
|
||||||
|
|
||||||
#### 更换软件源
|
|
||||||
|
|
||||||
在 MSYS2 环境下运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sed -i "s#mirror.msys2.org/#mirrors.ustc.edu.cn/msys2/#g" /etc/pacman.d/mirrorlist*
|
|
||||||
pacman -Syy
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 更改 home 目录
|
|
||||||
|
|
||||||
修改`/etc/nsswitch.conf`文档
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Begin /etc/nsswitch.conf
|
|
||||||
|
|
||||||
passwd: files db
|
|
||||||
group: files db
|
|
||||||
|
|
||||||
db_enum: cache builtin
|
|
||||||
|
|
||||||
# db_home: cygwin desc # 修改此行
|
|
||||||
db_home: /c/Users/%U
|
|
||||||
db_shell: cygwin desc
|
|
||||||
db_gecos: cygwin desc
|
|
||||||
|
|
||||||
# End /etc/nsswitch.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
完成,可以愉快的使用 Fish 了!
|
|
||||||
|
|
||||||

|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "示例展示:展示本博客所有功能"
|
title: "示例展示:展示本博客所有功能"
|
||||||
published: true
|
published: false
|
||||||
date: 2025-12-31
|
date: 2025-12-31
|
||||||
updatedDate: 2025-12-31
|
updatedDate: 2025-12-31
|
||||||
description: "一篇演示文档,展示站点可用的组件、标记和布局用法,方便检视和测试。"
|
description: "一篇演示文档,展示站点可用的组件、标记和布局用法,方便检视和测试。"
|
||||||
|
|||||||
@@ -6,23 +6,19 @@ export default defineNuxtConfig({
|
|||||||
srcDir: "app",
|
srcDir: "app",
|
||||||
css: ["./app/assets/css/main.css"],
|
css: ["./app/assets/css/main.css"],
|
||||||
|
|
||||||
components: {
|
// components: {
|
||||||
dirs: [
|
// dirs: [
|
||||||
{
|
// {
|
||||||
path: "~/components",
|
// path: "~/components",
|
||||||
extensions: ["vue"],
|
// extensions: ["vue"],
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
path: "~/components/content",
|
// path: "~/components/content",
|
||||||
extensions: ["vue"],
|
// extensions: ["vue"],
|
||||||
prefix: "Prose",
|
// prefix: "Prose",
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
|
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4,
|
|
||||||
},
|
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
"@nuxt/icon",
|
"@nuxt/icon",
|
||||||
@@ -31,8 +27,8 @@ export default defineNuxtConfig({
|
|||||||
"@nuxt/eslint",
|
"@nuxt/eslint",
|
||||||
"@vueuse/nuxt",
|
"@vueuse/nuxt",
|
||||||
"@nuxtjs/robots",
|
"@nuxtjs/robots",
|
||||||
|
"@nuxtjs/seo",
|
||||||
"@nuxtjs/sitemap",
|
"@nuxtjs/sitemap",
|
||||||
"nuxt-og-image",
|
|
||||||
"@nuxt/content",
|
"@nuxt/content",
|
||||||
"@nuxtjs/color-mode",
|
"@nuxtjs/color-mode",
|
||||||
// "@nuxtjs/tailwindcss",
|
// "@nuxtjs/tailwindcss",
|
||||||
@@ -47,22 +43,36 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
charset: "utf-16",
|
charset: "utf-8",
|
||||||
viewport: "width=device-width,initial-scale=1",
|
viewport: "width=device-width,initial-scale=1,maximum-scale=5",
|
||||||
title: siteConfig.siteMeta.title,
|
title: siteConfig.siteMeta.title,
|
||||||
titleTemplate: `%s - ${siteConfig.siteMeta.title}`,
|
titleTemplate: `%s - ${siteConfig.siteMeta.title}`,
|
||||||
|
meta: [
|
||||||
|
{ name: "author", content: siteConfig.siteMeta.author },
|
||||||
|
{ name: "language", content: "zh-CN" },
|
||||||
|
{ name: "theme-color", content: siteConfig.theme.color },
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: "canonical", href: siteConfig.siteMeta.url },
|
||||||
|
{ rel: "alternate", hreflang: "zh-CN", href: siteConfig.siteMeta.url },
|
||||||
|
{ rel: "dns-prefetch", href: siteConfig.siteMeta.url },
|
||||||
|
{ rel: "preconnect", href: siteConfig.siteMeta.url },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
pageTransition: { name: "page", mode: "out-in" },
|
pageTransition: { name: "page", mode: "out-in" },
|
||||||
layoutTransition: { name: "layout", mode: "out-in" },
|
layoutTransition: { name: "layout", mode: "out-in" },
|
||||||
},
|
},
|
||||||
|
|
||||||
sitemap: {
|
sitemap: {
|
||||||
sources: [siteConfig.siteMeta.url],
|
sources: ["/api/__sitemap__/urls"],
|
||||||
},
|
},
|
||||||
|
robots: { groups: [{ userAgent: ["GPTBot", "ChatGPT-User"], disallow: ["/"] }] },
|
||||||
|
|
||||||
site: {
|
site: {
|
||||||
url: siteConfig.siteMeta.url,
|
url: siteConfig.siteMeta.url,
|
||||||
name: siteConfig.siteMeta.title,
|
name: siteConfig.siteMeta.title,
|
||||||
|
description: siteConfig.siteMeta.description,
|
||||||
|
author: siteConfig.siteMeta.author,
|
||||||
},
|
},
|
||||||
|
|
||||||
typescript: {
|
typescript: {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"node": "24.12.0"
|
"node": "24.12.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chinese-fonts/maple-mono-cn": "^2.0.0",
|
|
||||||
"@formkit/auto-animate": "0.9.0",
|
"@formkit/auto-animate": "0.9.0",
|
||||||
"@nuxt/content": "3.8.2",
|
"@nuxt/content": "3.8.2",
|
||||||
"@nuxt/fonts": "0.12.1",
|
"@nuxt/fonts": "0.12.1",
|
||||||
@@ -31,10 +30,12 @@
|
|||||||
"@nuxt/ui": "^4.3.0",
|
"@nuxt/ui": "^4.3.0",
|
||||||
"@nuxtjs/color-mode": "4.0.0",
|
"@nuxtjs/color-mode": "4.0.0",
|
||||||
"@nuxtjs/robots": "5.6.7",
|
"@nuxtjs/robots": "5.6.7",
|
||||||
|
"@nuxtjs/seo": "^3.3.0",
|
||||||
"@nuxtjs/sitemap": "7.5.0",
|
"@nuxtjs/sitemap": "7.5.0",
|
||||||
"feed": "5.1.0",
|
"feed": "5.1.0",
|
||||||
"nuxt": "4.2.2",
|
"nuxt": "4.2.2",
|
||||||
"nuxt-og-image": "5.1.13",
|
"nuxt-og-image": "5.1.13",
|
||||||
|
"twikoo": "^1.6.44",
|
||||||
"vue": "3.5.26"
|
"vue": "3.5.26"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
14719
pnpm-lock.yaml
generated
Normal file
14719
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
public/robots.txt
Normal file
0
public/robots.txt
Normal file
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"extends": "./.nuxt/tsconfig.json",
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
"vueCompilerOptions": {
|
"compilerOptions": {
|
||||||
"globalTypesPath": "./node_modules/.vue-global-types"
|
"skipLibCheck": true,
|
||||||
|
"typeRoots": ["./node_modules/@types", "./app/types"]
|
||||||
}
|
}
|
||||||
|
// "vueCompilerOptions": {
|
||||||
|
// "globalTypesPath": "./node_modules/.vue-global-types"
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user