文章页
无论是博客还是文档站,我们都需要一个页面来显示文章内容,这个页面就是文章页。
文章页模板
文章页模板是一个 .article.ts 文件,存放在 .kecare/ 目录下。生成器会在处理每篇文章时调用它,用于生成文章详情页面。
一个可运行模板
创建文件 .kecare/foo-bar.article.ts:
import { join } from 'node:path';
import type { ArticleVariant, KecareContext } from "kecare";
// 标识这是一个文章详情类型的模板
export const type = 'article-detail';
export async function generator(context: KecareContext, article: ArticleVariant) {
// 导入菜单数据
const navItemsModule = await import(join(context.projectPath, '.kecare', 'menus', 'test.menu.generated.ts'));
const navItems = navItemsModule.navItems;
return {
// 文章详情页面的 URL 路径
urlPath: ['articles', article.lang, article.hash].join('/'),
// 文件生成的物理路径
fsPath: join(context.projectPath, 'app', 'pages', 'articles', article.lang, `${article.hash}.vue`),
// Vue 组件模板
template: `
<script setup lang="ts">
import ArticleTheme from '~/components/Theme/article-theme.vue';
import type { NavItem } from 'kecare';
// 文章数据
const article = \`${encodeURIComponent(JSON.stringify(article))}\`;
// 菜单导航项
const navItems: NavItem[] = ${JSON.stringify(navItems)};
// 设置页面标题
useHead({
title: '${article.title}',
});
</script>
<template>
<ArticleTheme
:article="JSON.parse(decodeURIComponent(article))"
:navItems="navItems"
/>
</template>
`,
};
}
总而言之,.article.ts 仅需你返回一个包含 urlPath、fsPath 和 template 的对象即可,生成器就能正确调用模板进行页面生成。urlPath 是文章的访问路径,fsPath 是文件生成的物理路径,template 是 Vue 组件的模板字符串。
页面组件
我们在模板中调用了 <ArticleTheme/> 组件,所以接下来我们就应该编写页面组件。
~/components/Theme/article-theme.vue
<script lang="ts" setup>
import type { ArticleVariant, NavItem } from "kecare";
// 请主题作者务必启用KecareSDK 代码高亮、目录栏,复制markdown,等都是SDK提供的
const route = useRoute()
onMounted(async () => {
await nextTick();
await kecareSDK!.mounted(props.article.hash, route.path);
});
// 声明组件接收的 props
const props = defineProps<{
article: ArticleVariant;
navItems: NavItem[];
}>();
</script>
<template>
<div class="article-container">
<div class="article-header">
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-meta">
<span>作者: {{ article.author }}</span>
<span>|</span>
<span>发布于: {{ article.frontMatter.date }}</span>
</div>
</div>
<!-- 文章内容 -->
<div class="article-content" v-html="article.html"></div>
<!-- 文章版权信息 -->
<div class="article-copyright">
<p>本文作者: {{ article.author }}</p>
<p>版权声明: 转载请注明出处</p>
</div>
</div>
</template>
<style scoped>
@import url(~/assets/article.css);
</style>
在 Vue 中,使用数据的形式是文本插值即 <span>文章标题: {{ article.title }}</span> 这种形式。article.html 包含了 Markdown 转换后的 HTML 内容,使用 v-html 指令渲染。具体样式就要你展开想象了。
样式
由于文章内容是直接通过 v-html 生成出来的,我们如果想要控制生成出来的内容,需要写好多CSS代码,不过问题不大,我这里可以提供一个大部分的html标签模板,可以在我的基础上进行修改。在文章页的 style scoped部分引入即可
// ~/assets/articles.css
/* 代码块语言标签样式 */
.article-content:deep(code[data-lang]::before) {
content: attr(data-lang);
position: absolute;
top: 0;
left: 13%;
padding: 10px 10px;
background: #282c34;
color: white;
border-bottom-left-radius: 5px;
font-size: 15px;
}
/* 行内代码样式 */
.article-content :deep(code:not(pre code)) {
color: #4fc3f7;
background: rgba(79, 195, 247, 0.12);
padding: 3px 6px;
border-radius: 6px;
font-family: Consolas, Monaco, monospace;
font-size: 0.9em;
margin: 0 2px;
border: 1px solid rgba(79, 195, 247, 0.2);
}
/* 代码块容器样式 */
.article-content :deep(pre) {
background: #282c34;
color: #abb2bf;
font-family: Consolas, Monaco, monospace;
line-height: 1.6;
font-size: 0.95rem;
padding: 45px 20px 20px;
overflow-x: auto;
border-radius: 16px;
border: 1px solid rgba(135, 206, 235, 0.3);
box-shadow: 0 10px 30px rgba(79, 195, 247, 0.15);
position: relative;
}
/* 标签面板中的代码块样式 */
.article-content .md-tabs__panels :deep(pre) {
background: #ff0000;
color: #ff0000;
border-radius: 0px;
}
/* 代码块装饰圆点样式(红黄绿) */
.article-content :deep(pre)::before {
content: "";
position: absolute;
top: 15px;
left: 20px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff5f56;
box-shadow:
20px 0 0 #ffbd2e,
40px 0 0 #27c93f;
opacity: 0.8;
}
/* 代码块内的代码元素样式 */
.article-content :deep(pre code) {
background: transparent;
padding: 0;
border-radius: 0;
color: inherit;
border: none;
font-family: inherit;
}
/* 代码块滚动条样式 */
.article-content :deep(pre)::-webkit-scrollbar {
height: 8px;
background-color: #282c34;
border-bottom-right-radius: 16px;
border-bottom-left-radius: 16px;
}
/* 滚动条滑块样式 */
.article-content :deep(pre)::-webkit-scrollbar-thumb {
background: #87ceeb;
border-radius: 4px;
cursor: pointer;
}
/* 滚动条滑块悬停样式 */
.article-content :deep(pre)::-webkit-scrollbar-thumb:hover {
background: #4fc3f7;
}
/* 链接样式 */
.article-content :deep(a) {
color: #4fc3f7;
text-decoration: none;
border-bottom: 1px solid rgba(79, 195, 247, 0.3);
padding: 0 2px;
transition: all 0.3s ease;
position: relative;
border-radius: 4px;
}
/* 链接悬停样式 */
.article-content :deep(a:hover) {
background: rgba(79, 195, 247, 0.15);
border-bottom-color: #4fc3f7;
color: #4dd0e1;
}
/* 链接点击样式 */
.article-content :deep(a:active) {
transform: translateY(1px);
}
/* 表格样式 */
.article-content :deep(table) {
width: 100%;
max-width: 100%;
border-collapse: separate;
/* 为了圆角,必须用 separate 而不是 collapse */
border-spacing: 0;
margin: 30px 0;
overflow: hidden;
border: 1px solid rgba(135, 206, 235, 0.3);
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
background: #282c34;
font-size: 0.95rem;
}
/* 表头单元格样式 */
.article-content :deep(th) {
background: rgba(79, 195, 247, 0.1);
color: #4fc3f7;
font-weight: 600;
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid rgba(135, 206, 235, 0.2);
}
/* 表格数据单元格样式 */
.article-content :deep(td) {
padding: 12px 16px;
color: #abb2bf;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background 0.2s;
}
/* 最后一行单元格样式 */
.article-content :deep(tr:last-child td) {
border-bottom: none;
}
/* 偶数行样式 */
.article-content :deep(tr:nth-child(even)) {
background: rgba(255, 255, 255, 0.02);
}
/* 行悬停样式 */
.article-content :deep(tr:hover td) {
background: rgba(79, 195, 247, 0.08);
color: #fff;
}
/* 引用块样式 */
.article-content :deep(blockquote) {
margin: 30px 0;
padding: 20px 24px;
background: rgba(247, 0, 67, 0.05);
border-radius: 0 12px 12px 0;
border-left: 4px solid #f70043;
color: #ff00a3;
font-style: italic;
line-height: 1.8;
position: relative;
}
/* 引用块装饰样式 */
.article-content :deep(blockquote)::after {
content: "Pamper";
position: absolute;
top: -10px;
right: 20px;
font-size: 6rem;
color: rgba(79, 195, 247, 0.1);
font-family: serif;
pointer-events: none;
line-height: 1;
}
/* 引用块内段落样式 */
.article-content :deep(blockquote p) {
margin: 0;
}
/* 列表样式 */
.article-content :deep(ul),
.article-content :deep(ol) {
padding-left: 20px;
margin: 20px 0;
color: #000000;
}
/* 列表项样式 */
.article-content :deep(li) {
margin-bottom: 10px;
line-height: 1.8;
position: relative;
}
/* 无序列表样式 */
.article-content :deep(ul) {
list-style: none;
}
/* 无序列表项标记样式 */
.article-content :deep(ul li::before) {
content: "";
position: absolute;
left: -20px;
top: 10px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #4fc3f7;
box-shadow: 0 0 8px rgba(79, 195, 247, 0.6);
transition: all 0.3s ease;
}
/* 无序列表项标记悬停样式 */
.article-content :deep(ul li:hover::before) {
transform: scale(1.5);
background: #4dd0e1;
}
/* 有序列表样式 */
.article-content :deep(ol) {
list-style-type: decimal;
}
/* 有序列表项标记样式 */
.article-content :deep(ol li::marker) {
color: #4fc3f7;
font-weight: bold;
font-family: Consolas, Monaco, monospace;
font-size: 1.1em;
}
/* 一级标题样式 */
.article-content :deep(h1) {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
margin: 40px 0 20px;
position: relative;
color: #4fc3f7;
padding-bottom: 15px;
border-bottom: 3px solid rgba(79, 195, 247, 0.3);
}
/* 一级标题装饰样式 */
.article-content :deep(h1)::before {
content: "Pamper";
position: absolute;
top: -50px;
right: 20px;
font-size: 6rem;
color: rgba(255, 0, 68, 0.1);
font-family: serif;
pointer-events: none;
line-height: 1;
font-style: italic
}
/* 二级标题样式 */
.article-content :deep(h2) {
font-size: 2rem;
font-weight: 700;
line-height: 1.3;
margin: 35px 0 18px;
position: relative;
color: #4dd0e1;
padding-bottom: 12px;
border-bottom: 2px solid rgba(77, 208, 225, 0.3);
}
/* 三级标题样式 */
.article-content :deep(h3) {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.4;
margin: 30px 0 15px;
position: relative;
color: #87ceeb;
padding-left: 15px;
border-left: 4px solid #87ceeb;
}
/* 四级标题样式 */
.article-content :deep(h4) {
font-size: 1.3rem;
font-weight: 600;
line-height: 1.5;
margin: 25px 0 12px;
position: relative;
color: #80deea;
padding-left: 12px;
border-left: 3px solid #80deea;
}
/* 五级标题样式 */
.article-content :deep(h5) {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.5;
margin: 20px 0 10px;
position: relative;
color: #b2ebf2;
padding-left: 10px;
border-left: 2px solid #b2ebf2;
}
侧边栏
侧边栏分别有导航栏和文章结构的目录栏,让我们一个一个讲起
导航栏
我们需要现在.kecare/menus/目录下,编写结构。
这里可以查看菜单系统。编写完对应的结构之后,在运行生成器时,生成器会遍历目录下的*.menu.source.ts将其中的 link 修改为正确的文章路径,并生成一个*.menu.generated.ts
让我们编写一个导航栏的Sidebar模板
<script setup lang="ts">
// ~/components/Lsidebar.vue
import type { NavItem } from 'kecare'
defineOptions({
name: 'Lsidebar', //定义个name,方便后续自我调用用的
})
const props = withDefaults(defineProps<{
items: NavItem[]
level?: number // 该level用于渲染层级
}>(), {
level: 0,
})
const isLinkItem = ( //类型收缩,来判断是分组项,还是链接项
item: NavItem
): item is { text: string; link: string; level: number } => {
return 'link' in item
}
const paddingStyle = computed(() => { // 每层缩进12px
return `padding-left: ${props.level * 12}px;`
})
function keyOf(item: NavItem) { // 为每个项生成唯一key,链接项用"l:路径",分组项用"g:标题:层级"
return isLinkItem(item) ? `l:${item.link}` : `g:${item.text}:${props.level}`
}
</script>
<template>
<ul class="sidebar" :style="paddingStyle">
<li v-for="item in props.items" :key="keyOf(item)">
<!-- 情况1: 链接项 - 直接渲染为可点击的导航链接 -->
<!-- isLinkItem(item): 判断item是否包含link属性 -->
<NuxtLink v-if="isLinkItem(item)" :to="item.link" class="sidebar-link">
{{ item.text }}
</NuxtLink>
<!-- 情况2: 分组项 - 渲染为导航组 -->
<div v-else class="sidebar-group">
<!-- 分组标题文本 -->
<span>{{ item.text }}</span>
<!-- 递归渲染子导航项 -->
<!-- 关键点: 组件递归调用自身,传入子项列表和层级+1 -->
<!-- 这使得导航树可以无限层级嵌套 -->
<Lsidebar :items="item.items" :level="props.level + 1" />
</div>
</li>
</ul>
</template>
接下来可自行添加 div 进行额外的美化啦~让后在文章页中调用即
// ~/components/landing.vue
<template>
<div v-if="props.navItems !== null">
<div class='Lsidebar' v-if="props.navItems?.length">
<Lsidebar/>
</div>
<div v-else>
暂无目录喵
</div>
</div>
</template>
很简单是不是喵。
一个页面可能会有很多个导航栏,所以需要主题作者自行适配了
目录栏
Kecare 在进行对markdown文章处理的时候,就已经替你处理好了目录的结构。
只需要在想要出现目录栏的地方,添加 class="kecare-sidebar" 即可
// ~/components/Rsidebar.vue
<template>
<div>
<h3>
目录喵
</h3>
<div class="kecare-sidebar"></div>
</div>
</template>
<style>
在这里添加样式就好啦,class名看下方喵
</style>
这样就好了喵,但是 CSS样式是需要你自己补充的喵,我们生成出来的目录结构是这样的喵
<ul class="toc-list">
<!-- 顶层标题项 -->
<li class="toc-item">
<a class="toc-link" data-target="heading-id" title="标题文本">标题文本</a>
<!-- 子标题列表(如果有子标题) -->
<ul class="toc-sublist">
<li class="toc-subitem">
<a class="toc-sublink" data-target="sub-heading-id" title="子标题文本">子标题文本</a>
<!-- 可以继续嵌套 -->
<ul class="toc-sublist">
<li class="toc-subitem">
<a class="toc-sublink" data-target="..." title="...">...</a>
</li>
</ul>
</li>
</ul>
</li>
</ul>