This commit is contained in:
2026-01-01 15:19:58 +08:00
parent 93880becef
commit 73ebd02cdb
25 changed files with 14913 additions and 880 deletions

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,6 +1,4 @@
<script lang="ts" setup>
import type { BlogPost } from "~/types/blog";
// Function to parse dates in the format "1st Mar 2023"
function parseCustomDate(dateStr: string): Date {
// Remove ordinal indicators (st, nd, rd, th)
@@ -16,8 +14,8 @@ const { data } = await useAsyncData("recent-post", () =>
.then((data) => {
return data
.sort((a, b) => {
const aDate = parseCustomDate(a.meta.date as string);
const bDate = parseCustomDate(b.meta.date as string);
const aDate = parseCustomDate(a.date as string);
const bDate = parseCustomDate(b.date as string);
return bDate.getTime() - aDate.getTime();
})
.slice(0, 3);
@@ -26,16 +24,15 @@ const { data } = await useAsyncData("recent-post", () =>
const formattedData = computed(() => {
return data.value?.map((articles) => {
const meta = articles.meta as unknown as BlogPost;
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: meta.image || "/not-found.jpg",
alt: meta.alt || "no alter data available",
date: meta.date || "not-date-available",
tags: meta.tags || [],
published: meta.published || false,
image: articles.image || "/not-found.jpg",
alt: articles.alt || "no alter data available",
date: articles.date || "not-date-available",
tags: articles.tags || [],
published: articles.published || false,
};
});
});

View File

@@ -24,7 +24,7 @@ const siteConfig = {
{ name: "Email", url: "mailto:i@rhen.cloud" },
{ name: "Telegram", url: "https://t.me/RhenCloud" },
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
{ name: "Blog", url: "https://blog.rhen.cloud" },
{ name: "X", url: "https://x.com/RhenCloud75" },
],
navbar: {
@@ -53,6 +53,13 @@ const siteConfig = {
},
},
comment: {
twikoo: {
enable: true,
envId: "https://twikoo.rhen.cloud",
},
},
umami: {
enable: false,
url: "https://cloud.umami.is/script.js",

View File

@@ -1,24 +1,23 @@
<script lang="ts" setup>
import type { BlogPost } from "~/types/blog";
// import type { BlogPost } from "~/types/blog";
import { formatDate } from "~/utils/helper";
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 posts = computed(() => {
return data.value?.map((articles) => {
const meta = articles.meta as unknown as BlogPost;
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: meta.image || "/not-found.jpg",
alt: meta.alt || "no alter data available",
image: articles.image || "/not-found.jpg",
alt: articles.alt || "no alter data available",
date: formatDate(articles.date) || "not-date-available",
tags: meta.tags || [],
published: meta.published || false,
tags: articles.tags || [],
published: articles.published || false,
};
});
});

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { BlogPost } from "@/types/blog";
import Comment from "~/components/blog/Comment.vue";
import { seoData } from "~/data";
import { formatDate } from "~/utils/helper";
@@ -12,17 +13,15 @@ const { data: articles, error } = await useAsyncData(`blog-post-${path}`, () =>
if (error.value) navigateTo("/404");
const data = computed<BlogPost>(() => {
const meta = articles?.value?.meta as unknown as BlogPost;
return {
title: articles.value?.title || "no-title available",
description: articles.value?.description || "no-description available",
image: meta?.image || "/not-found.jpg",
alt: meta?.alt || "no alter data available",
date: meta?.date || "not-date-available",
tags: meta?.tags || [],
published: meta?.published || false,
categories: meta?.categories || [],
meta: meta || {},
image: articles.value?.image || "/not-found.jpg",
alt: articles.value?.alt || "no alter data available",
date: articles.value?.date || "not-date-available",
tags: articles.value?.tags || [],
published: articles.value?.published || false,
categories: articles.value?.categories || [],
path: path || "",
};
});
@@ -54,7 +53,7 @@ useHead({
:title="data.title"
:image="data.image"
:alt="data.alt"
:date="formatDate(articles.date)"
:date="formatDate(data.date)"
:description="data.description"
:tags="data.tags" />
<div
@@ -64,6 +63,9 @@ useHead({
<p>No content found.</p>
</template>
</ContentRenderer>
<ClientOnly>
<Comment />
</ClientOnly>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
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());
@@ -11,16 +11,15 @@ const searchTest = ref("");
const formattedData = computed(() => {
return (
data.value?.map((articles) => {
const meta = articles.meta as unknown as BlogPost;
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: meta.image || "/not-found.jpg",
alt: meta.alt || "no alter data available",
date: meta.date || "not-date-available",
tags: meta.tags || [],
published: meta.published || false,
image: articles.image || "/not-found.jpg",
alt: articles.alt || "no alter data available",
date: articles.date || "not-date-available",
tags: articles.tags || [],
published: articles.published || false,
};
}) || []
);

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { BlogPost } from "@/types/blog";
// import type { BlogPost } from "@/types/blog";
const route = useRoute();
// 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}`, () =>
queryCollection("content")
.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
}),
),
queryCollection("content").where("published", "=", true).all(),
);
const formattedData = computed(() => {
return (
data.value
?.map((articles) => {
const meta = articles.meta as unknown as BlogPost;
?.filter((article) =>
article.categories?.map((cat) => cat.toLowerCase()).includes(category.value),
)
.map((articles) => {
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: meta.image || "/blogs-img/blog.jpg",
alt: meta.alt || "no alter data available",
ogImage: meta.ogImage || "/blogs-img/blog.jpg",
date: meta.date || "not-date-available",
tags: meta.tags || [],
published: meta.published || false,
image: articles.image || "/blogs-img/blog.jpg",
alt: articles.alt || "no alter data available",
date: articles.date || "not-date-available",
tags: articles.tags || [],
published: articles.published || false,
};
})
.filter((post) => post.published) || [] // Ensure only published posts are shown
}) || []
);
});
@@ -67,11 +57,10 @@ useHead({
:key="post.title"
:path="post.path"
:title="post.title"
:date="post.date"
:date="formatDate(post.date)"
:description="post.description"
:image="post.image"
:alt="post.alt"
:og-image="post.ogImage"
:tags="post.tags"
:published="post.published" />
<BlogEmpty v-if="formattedData.length === 0" />

View File

@@ -1,12 +1,12 @@
<script lang="ts" setup>
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();
data.value?.forEach((blog) => {
const categories: Array<string> = (blog.meta.categories as string[]) || [];
const categories: Array<string> = (blog.categories as string[]) || [];
categories.forEach((category) => {
if (allTags.has(category)) {
const cnt = allTags.get(category);

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup>
import type { BlogPost } from "@/types/blog";
const route = useRoute();
const tag = computed(() => {
@@ -16,26 +15,28 @@ const { data } = await useAsyncData(`tag-data-${tag.value}`, () =>
.all()
.then((articles) =>
articles.filter((article) => {
const meta = article.meta as unknown as BlogPost;
return meta.published && meta.tags.some((t) => t.toLowerCase() === tag.value.toLowerCase());
return (
article.published && article.tags.some((t) => t.toLowerCase() === tag.value.toLowerCase())
);
}),
),
);
const formattedData = computed(() => {
return data.value?.map((articles) => {
const meta = articles.meta as unknown as BlogPost;
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: meta.image || "/blogs-img/blog.jpg",
alt: meta.alt || "no alter data available",
date: formatDate(articles.date) || "not-date-available",
tags: meta.tags || [],
published: meta.published || false,
};
});
return (
data.value?.map((articles) => {
return {
path: articles.path,
title: articles.title || "no-title available",
description: articles.description || "no-description available",
image: articles.image || "/blogs-img/blog.jpg",
alt: articles.alt || "no alter data available",
date: formatDate(articles.date) || "not-date-available",
tags: articles.tags || [],
published: articles.published || false,
};
}) || []
);
});
useHead({

View File

@@ -1,12 +1,12 @@
<script lang="ts" setup>
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();
data.value?.forEach((blog) => {
const tags: Array<string> = (blog.meta.tags as string[]) || [];
const tags: Array<string> = (blog.tags as string[]) || [];
tags.forEach((tag) => {
if (allTags.has(tag)) {
const cnt = allTags.get(tag);

View File

@@ -0,0 +1,9 @@
import twikoo from "twikoo";
export default defineNuxtPlugin(() => {
return {
provide: {
twikoo,
},
};
});

View File

@@ -7,14 +7,5 @@ export interface BlogPost {
tags: string[];
categories: string[];
published: boolean;
meta: BlogPostMeta; // 添加 meta 属性
path: string; // 添加 path 属性
}
interface BlogPostMeta {
image?: string;
alt?: string;
date?: string;
tags?: string[];
// 根据实际情况添加其他可能的属性
path: string;
}

25
app/types/twikoo.d.ts vendored Normal file
View 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;
}
}