Article Page
Whether it's a blog or a documentation site, we all need a page to display article content, and this page is the article page.
Article Page Template
The article page template is a .article.ts file located in the .kecare/ directory. The generator will call it when processing each article to generate the article detail page.
A Runnable Template
Create file .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>
`,
};
}
In summary, .article.ts only requires you to return an object containing urlPath, fsPath, and template. The generator can then correctly invoke the template for page generation. urlPath is the access path for the article, fsPath is the physical path where the file is generated, and template is the template string for the Vue component.
Page Components
We have invoked the <ArticleTheme/> component in the template, so next we should proceed to write the page component.
~/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>
In Vue, data is used in the form of text interpolation, such as <span>Article Title: {{ article.title }}</span>. article.html contains the HTML content converted from Markdown, rendered using the v-html directive. The specific styling is up to your imagination.
Styles
Since the article content is directly generated via v-html, if we want to control the generated content, we need to write a lot of CSS code. However, it's not a big problem. I can provide a template for most HTML tags here, which you can modify based on my version. You can then import it in the <style scoped> section of the article page.
// ~/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;
}
Sidebar
The sidebar consists of the navigation bar and the table of contents for the article structure. Let's go through them one by one.
Navigation Bar
We need to write the structure in the .kecare/menus/ directory now.
You can view the Menu System here. After writing the corresponding structure, when running the generator, it will traverse the *.menu.source.ts files in the directory, modify the link within them to the correct article paths, and generate a *.menu.generated.ts file.
Let's create a Sidebar template for a navigation bar
<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>
Next, you can add div elements for additional beautification~ Then call it in the article page.
// ~/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>
It's very simple, isn't it meow.
A page may have multiple navigation bars, so theme authors need to adapt accordingly.
Table of Contents
Kecare has already handled the structure of the table of contents for you when processing markdown articles.
Simply add class="kecare-sidebar" where you want the table of contents to appear.
// ~/components/Rsidebar.vue
<template>
<div>
<h3>
目录喵
</h3>
<div class="kecare-sidebar"></div>
</div>
</template>
<style>
在这里添加样式就好啦,class名看下方喵
</style>
That's all set meow, but you'll need to add the CSS styles yourself meow. The directory structure we generated looks like this meow.
<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>