文章页

无论是博客还是文档站,我们都需要一个页面来显示文章内容,这个页面就是文章页。

文章页模板

文章页模板是一个 .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 仅需你返回一个包含 urlPathfsPathtemplate 的对象即可,生成器就能正确调用模板进行页面生成。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>

关于文章页的所有组件都讲完啦,快贡献你的主题吧

文章作者:
文章链接:kecare.me/articles/989b9235
版权声明: 博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源