博客主题开发规范-第三方完整交付版
AiKlog 博客主题开发规范(第三方完整交付版)
版本:v1.2 · 2026-09-16 · 适用仓库:AiKlog(
web/src/themes/+ 服务器data/themes/) 读者:第三方主题开发方。 本文档 = 上游主题协议(AiKmap/AiKdex 同构契约)+ AiKlog 实战规范(elevated / parchment / minimal / emforum / brutal 等主题的开发与审查沉淀)合并整理而成,是唯一权威依据;与任何旧示例冲突时以本文档为准。 目标:开发方据此可独立交付一套完整主题(含内页),无需阅读系统源码。
修订记录
| 版本 | 日期 | 变更 | |---|---|---| | v1.2 | 2026-09-16 | 新增 §0.4 文章页样式视图隔离(通用约定):主题
style.css(列表 + 共享原子,构建插件自动加.th-<id>作用域)与post.css(文章专属,自动加.th-<id>.th-view-post作用域)二分,宿主列表/文章视图各加稳定根类(.th-view-list/.th-view-post),任何主题部署即正确、不再逐个手工加前缀;§5.1 由「3 条硬规则」改为「4 条」并明确作用域隔离已自动化;§0.1 / §1 交付物与目录树补post.css;§9.1 验收清单补视图隔离项。背景:brutal 文章内页样式跨视图泄漏回归 | | v1.1 | 2026-09-15 | 依据 EmForum v1.1.0 代码审查实录(5 处缺陷)与配套自检脚本在在产主题上的验证结果,补充:§0.1 交付物新增自检报告;§3.2 新增 hash 路由三条硬约束;§9 新增 §9.3 交付前机械自检(6 项);附录 C 新增 6 条反面教材(拆为 C.1/C.2);附录 B 新增在产主题索引;新增附录 E 配套资料索引;§0.2 补齐内置 id 清单 | | v1.0 | 2026-09-15 | 首版:上游协议 + AiKlog 实战沉淀合并整理 |配套资料(缺陷手册 / 自检脚本 / 开发技能)见 附录 E。
0. 总览:你要交付什么
0.1 交付物清单
一套完整主题 = 7 类文件(含可选 post.css)+ 1 份截图对照 + 1 份自检报告:
| # | 交付物 | 位置 | 是否必需 |
|---|---|---|---|
| 1 | manifest.js | web/src/themes/<id>/manifest.js | ✅ 必需 |
| 2 | index.js | web/src/themes/<id>/index.js | ✅ 必需 |
| 3 | <Id>View.vue 列表页入口 | web/src/themes/<id>/ | ✅ 必需 |
| 4 | style.css | web/src/themes/<id>/style.css | ✅ 必需(组件内 import './style.css';列表 + 共享原子,构建插件自动加 .th-<id> 作用域) |
| — | post.css(文章专属样式) | web/src/themes/<id>/post.css | ⭕ 声明 entries.post 时必需;构建插件自动加 .th-<id>.th-view-post 作用域,仅文章视图生效、零泄漏 |
| 5 | 内页组件(按 §4 契约,做几个交几个) | 同目录 | ⭕ 一期可只交列表,二期补齐 |
| 6 | ssr.css + manifest.json | 服务器 data/themes/<id>/ | ✅ 必需(否则文章页不跟随主题) |
| 7 | 设计稿并排对照截图(桌面 + 移动) | 随 PR 提供 | ✅ 验收用 |
| 8 | 自检报告(theme-lint.sh 运行输出) | 随 PR 提供 | ✅ 验收用(v1.1 新增) |
关于第 8 项:规范里的检查项分两类 —— 机械可判定的(类名交叉比对、令牌台账、hash 锚点、契约外字段)和需人工判断的。前者不要靠人眼,直接跑
theme-lint.sh(见附录 E),退出码0才算过。经验教训:这类缺陷不会让构建失败、也不会在 console 报警,但会在真实环境中被用户先发现(详见配套《常见缺陷清单与自检手册》第 0 节)。
0.2 主题 id 约定
- kebab-case,仅
[a-z0-9-],长度 ≤ 32(服务端正则^[a-z0-9][a-z0-9-]{0,31}$,非法值会被拒)。 - 交付前请确认 id 不重复:现有内置 id 为
default / aiklog / minimal / docs / paper / elevated / parchment / emforum / brutal(与settings.go白名单、web/src/themes/all.js的 import 列表逐项对应;default为系统兜底,无需 import)。 - ⚠️ 目录存在 ≠ 已接入:
web/src/themes/<id>/目录建好但没有在web/src/themes/all.jsimport、也没进settings.go白名单时,该主题既不会出现在控制台下拉,也不会进构建产物 —— 属"孤儿目录"。自查方式见附录 E 的自检脚本第 1 节,或直接grep两处。
0.3 三个必须理解的前提
- 双轨渲染:公开站有 SPA 交互版(
/app#/blog列表、/app#/post/…文章)和 SSR 静态页(/blog、/{slug})两条通道,同一套主题必须两边都覆盖(SPA 靠 Vue 组件 +style.css,SSR 靠ssr.css)。这是"列表好看、点进去变样"问题的根因,也是本规范 §4 的核心。 - 文章页也是双轨:人看走 SPA 文章页
#/post/{token}(系统提供,随主题走,可带评论/侧栏等插件);机器看走 SSR/{slug}(canonical / sitemap / 分享快照)。两者由同一份数据渲染 —— 主题要么声明entries.post接管 SPA 文章页,要么只投放ssr.css让 SSR 文章页跟随(此时 SPA 文章页由系统默认页渲染并挂.th-<id>,配色跟随、版式是系统的)。正文 HTML 由系统注入ctx.page.post.html,主题不负责渲染正文内容本身。 - 主题交付有两种形态(详见 §11):形态 A = 源码模块,需随前端一起构建(编译进
web/dist),不是运行时可热插的 zip;形态 B = 声明式主题包(zip),经应用中心 / 本地 zip 安装后,ssr.css与page.html均可运行时热投放(文件落盘即生效,不编译、不重启),作用于公网静态页;形态 A 的 SPA 交互页仍必须重新编译。
0.4 文章页样式视图隔离(通用约定,防跨视图泄漏)
踩坑经验:主题
style.css被全局import(让文章页也跟主题),若列表页与文章页复用同名类(如.bt-cover/.bt-side),裸选择器会跨视图泄漏,导致「封面被盖 / 侧栏错位 / 跑马灯闪烁」等回归。这是通用机制问题,不是某个主题的个案。
规则(一次生效所有主题,主题作者无需手加任何前缀):
style.css= 列表页 + 共享原子类(.bt-btn/.bt-tag等在列表与文章都用的),由构建插件(vite-theme-scope)自动加.th-<id>作用域,列表中文章两端都生效。post.css= 文章页专属样式(封面 / 正文排版 / 侧栏 / 评论等只在文章页出现的),由构建插件自动加.th-<id>.th-view-post作用域,仅文章视图命中,列表页永远拿不到 → 零泄漏。- 宿主配合:列表视图根包
.th-<id> .th-view-list,文章视图根包.th-<id> .th-view-post(已由BlogView/BlogPostView落实)。 - 因此文章专属选择器请写在
post.css;声明了entries.post就必须提供post.css(theme-lint.sh第 6 项会卡)。不要为了"通用"把文章样式塞进style.css。
1. 主题目录结构与最小可用骨架
web/src/themes/<theme-id>/
├── index.js # 注册入口(模块加载即注册)
├── manifest.js # 元数据 + 能力声明
├── <Id>View.vue # 列表页入口组件(默认页)
├── style.css # 主题样式(组件内 import):列表页 + 共享原子类(.bt-btn 等)
├── post.css # 文章页专属样式(声明 entries.post 时):构建插件自动加 .th-view-post 作用域,仅文章视图生效
├── <Id>PostView.vue # 内页:文章页(可选,见 §4.3)
├── <Id>CatView.vue # 内页:分类页(可选,见 §4.4)
├── <Id>AuthorView.vue # 内页:作者页(可选,见 §4.5)
├── <Id>TagView.vue # 内页:标签页(可选,见 §4.6)
├── <Id>ArchiveView.vue # 内页:归档页(可选,见 §4.7)
└── <Id>SearchView.vue # 内页:搜索页(可选,见 §4.8)
1.1 manifest.js(字段名大小写敏感,逐字段照抄)
export default {
id: 'aurora', // ✅ kebab-case 唯一标识
title: '极光 Aurora', // ✅ title(不是 name / nameZh)—— 切换器显示它
desc: '一句话描述(切换器副标题)', // ✅ desc(不是 description)
version: '1.0.0', // 语义化版本
pages: ['posts'], // 主题自带的页面路由键(通用博客主题固定 ['posts'])
tokens: { // 设计令牌说明(键名必须 --th-* 前缀)
'--th-ink': '正文色',
'--th-paper': '纸色底',
'--th-accent': '强调色',
},
ui: { themeSwitcher: true }, // 声明「主题页头自带切换器」→ 宿主不再补右上角悬浮版(见 §6)
dataSources: ['blog'], // 通用博客主题固定 ['blog'];采集型才用 ['collect']
dataScope: 'shared', // ✅ 公开主题固定 'shared';禁止 'library'
entries: { // 页面级入口声明(见 §4.10);不声明则系统用默认页 + 你的 tokens
// post: PostView, cat: CatView, author: AuthorView, ...
},
}
| 字段 | 必填 | 说明 |
|---|---|---|
| id | ✅ | 见 §0.2 |
| title | ✅ | 切换器 / SSR 下拉显示名 |
| desc | 建议 | 一句话气质描述 |
| version | ✅ | 语义化版本 |
| dataSources | ✅ | ['blog'] |
| dataScope | ✅ | 固定 'shared'(只展示「已分享」内容,系统安全默认) |
| tokens | 建议 | 仅作说明用途,实际变量写在 style.css 里 |
| ui.themeSwitcher | 条件 | 自带切换器时必须声明 true(见 §6) |
| pages | 可选 | 通用博客主题填 ['posts'] |
| entries | 二期 | 内页组件声明(见 §4.10) |
1.2 index.js(正确写法 —— 模块加载时直接注册)
import { defineAsyncComponent } from 'vue'
import manifest from './manifest.js'
import { registerTheme } from '../index.js'
const theme = {
...manifest,
entry: defineAsyncComponent(() => import('./AuroraView.vue')),
}
registerTheme(theme)
export default theme
❌ 错误写法(永远不会被调用,历史踩坑):
export default function (app, { registerTheme }) { /* 回调式,永不执行 */ }
1.3 接入系统(3 处,缺一不可)
- 注册 import:在
web/src/themes/all.js追加一行副作用导入(所有主题在此集中注册,列表页BlogView与文章页BlogPostView共用,不要再在BlogView.vue顶部散 import —— 否则会出现「列表可选、文章页却掉回默认皮」的割裂):import '@/themes/aurora' // 注册极光主题(集中注册表 all.js) - 控制台下拉自动出现(BlogManage 从注册表
listThemes()动态渲染,无需改代码)。 - ⚠️ 服务端白名单(最容易漏):把新 id 加进
server/internal/handler/settings.go的blog.theme白名单,否则控制台保存站点主题会被SETTING_INVALID拒绝:case "", "default", "aiklog", "minimal", "docs", "paper", "elevated", "parchment", "emforum", "aurora":
1.4 主题生效机制(localStorage 不是权威来源)
GET /api/v1/public/site → site.theme(settings 表 blog.theme,默认 aiklog)
↓
BlogView.applyServerTheme(site.theme) → 激活主题
↓
读者本地覆盖 localStorage['aiklog.theme.override'] 优先级更高(切肤用)
2. 数据契约(inject,不是 props)
BlogView 渲染主题时 <component :is="entry" /> 不传任何 props。数据一律通过 provide/inject:
import { inject, computed } from 'vue'
const raw = inject('themeContext')
// 兼容 ref 与普通对象两种包装(不同宿主版本)
const ctx = computed(() => raw?.value || raw || { posts: [], tags: [], loading: true })
const posts = computed(() => ctx.value.posts || [])
const tags = computed(() => ctx.value.tags || [])
const siteName = computed(() => ctx.value.siteName || '爱库录')
const postUrl = ctx.value.postUrl // 单篇地址生成器(函数)
const loading = computed(() => ctx.value.loading)
const error = computed(() => ctx.value.error || '')
2.1 themeContext 全字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| siteName | string | 站点名(后台「站点设置」) |
| siteDesc | string | 站点描述 |
| posts | Array | 公开文章列表(见 §2.2) |
| tags | Array | 当前恒为空数组(系统层未实现,见 §4.6) |
| postUrl(post) | function → string | 单篇地址生成器,返回 SPA 文章页地址 #/post/{token}?path=&slug=(人看主链,随主题走、可带评论等插件,见 §3.1 / §4.3) |
| postSsrUrl(post) | function → string | SSR 静态页地址 /{slug}(机器看:canonical / sitemap / 分享快照;分享按钮与 SEO 场景用这个,见 §2.3) |
| loading | boolean | 加载中(主题应展示骨架/文案) |
| error | string | 错误信息(空串 = 无错) |
| page | object | 二期:当前页面参数 { kind, cat, author, tag, archive, q }(见 §4.10) |
2.2 posts 条目的真实结构(逐字段核对,禁止臆造)
{
token: 'xxxxx', // 公开访问令牌
path: '分类/文件名.md', // 相对路径(分类 = 首段目录)
preview: '纯文本摘要', // 服务端已生成(AI 摘要或截断,可能为空串)
file: {
name: '文件名.md',
slug: '', // ⚠️ 早期恒为空串;**当前后端已返回真实 slug**(博客文件均有),可依赖;为兼容旧数据仍建议用 `path` 兜底
updated_at: 1726000000000, // 毫秒时间戳
size: 12345,
},
}
❌ 不存在的字段(历史上第三方臆造过,全部会渲染出空值):
id / title / summary / cover / readTime / views / contentHtml / 单篇 tags。
注:
created_at字段后端实际存在(公开列表public/posts会返回,博客场景值恒为 0),不要臆造为业务时间;正文更新时间请统一用file.updated_at。
2.3 标题 / 分类 / 日期的派生(与系统 SSR 保持一致)
// 标题:去扩展名(.md 和 .markdown 都要处理)→ 取末段
function title(p) {
const n = p.file?.name || p.path || ''
return String(n).replace(/\.(md|markdown)$/i, '').split('/').pop() || '无标题'
}
// 分类:path 首段;无 '/' 则归「未分类」
function cat(p) {
const path = p.path || ''
return path.includes('/') ? path.split('/')[0] : ''
}
// 日期:updated_at(毫秒;兼容秒级与字符串)
function date(p) {
const v = p.file?.updated_at
if (!v) return ''
const ms = typeof v === 'number' ? (v > 1e12 ? v : v * 1000) : Date.parse(v)
if (!Number.isFinite(ms)) return ''
const d = new Date(ms)
const z = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`
}
// 链接:必须用 postUrl(返回 SPA 文章页地址),不要自己拼路径
const href = (p) => (ctx.value.postUrl ? ctx.value.postUrl(p) : '#')
// 需要「分享链接 / 面向搜索引擎的静态页」时,用 postSsrUrl(返回 /{slug})
const shareHref = (p) => (ctx.value.postSsrUrl ? ctx.value.postSsrUrl(p) : '/blog')
2.4 列表排序(系统已统一,主题不要重排)
系统按「置顶优先 + 更新时间倒序」返回(置顶标记在 content_state.pinned)。
主题不要自行重排 posts,否则与 SSR / RSS 顺序不一致。
3. 页面契约总览(★ 内页规范核心)
3.1 渲染双通道 + 页面清单
| # | 页面 | SPA 路由(交互版) | SSR 路由(SEO 主页) | 主题职责 | 系统实现状态 |
|---|---|---|---|---|---|
| 1 | 列表页 | /app#/blog | /blog | Vue 组件(entry)+ style.css;SSR 靠 ssr.css | ✅ 已上线 |
| 2 | 文章页 | /app#/post/{token}?path=&slug= | /{slug}、/blog/{slug} | 可选:entries.post 接管 SPA 文章页;SSR 靠 ssr.css | ✅ 已上线(双轨) |
| 3 | 分类页 | /app#/blog/cat/:cat | 待补 | entries.category 组件 | ⚠️ 系统侧待补(§4.4) |
| 4 | 作者页 | /app#/blog/author/:author | 待补 | entries.author 组件 | ⚠️ 系统侧待补(§4.5) |
| 5 | 标签页 | /app#/blog?tag=xxx | 待补 | entries.tag 组件 | ❌ 数据未实现(§4.6) |
| 6 | 归档页 | /app#/blog/archive=YYYY-MM | 待补 | entries.archive 组件 | ⚠️ 从 posts 可派生(§4.7) |
| 7 | 搜索页 | /app#/blog?q=xxx | 待补 | entries.search 组件 | ⚠️ 前端可派生(§4.8) |
| 8 | 404 | — | 待补 | 无需处理(系统兜底) | ❌ 系统侧待补(§4.9) |
一期交付范围:页面 1 + 2(100% 可独立完成并验证)。 二期交付范围:页面 3~7(依赖系统侧补齐路由与
ctx.page,见 §10;主题侧按本文契约先实现组件即可,系统补齐后自动生效)。
3.2 页面级设计的统一要求
- 所有页面共用同一套设计令牌(
--th-*)、同一页头/页脚组件结构 —— 保证换页不割裂。 - 页面组件同样用
inject('themeContext')取数;内页额外从ctx.page读当前页参数(§4.10)。 - 未提供某内页组件的主题,系统用默认页 + 你的
tokens渲染 —— 即"配色字体一致、版式是系统的"。所以内页组件是加分项,ssr.css是必修项。 - ⚠️ 宿主公开页是 hash 路由(形如
#/blog?view=public)——#后面是路由状态,不是滚动锚点。由此产生三条硬约束(v1.1 新增,详见 §9.3 与附录 C.2):- 禁止把页内锚点写进
href(href="#y-2026")或location.hash—— 等于向 router 塞一段非法路由,可能跳转/白屏,刷新后地址栏还会残留异常 hash; - 页内跳转(年份/月份锚点、目录跳转、回到顶部等)一律用
<button>+el.scrollIntoView({ behavior: 'smooth' }); - 站内页面跳转一律用
ctx.postUrl(post)(SPA 文章页)或相对路径,不要手拼#/p/、#/post/;分享按钮/SEO 场景用ctx.postSsrUrl(post)(/{slug})。 - 注意这类问题在 iframe 预览与
file://打开时都不复现,只在真实地址栏环境下暴露 —— 别指望开发期自己发现。
- 禁止把页内锚点写进
4. 各页面详细规范
4.1 列表页(entry)—— 一期必做
- 渲染整页(含页头、导航、文章列表、页脚)。
- 数据:
ctx.posts;状态:loading/error/ 空态(三者都要处理)。 - 空态文案示例:
还没有公开文章。
4.2 列表页 SSR(/blog)—— 一期必做
SSR 列表页默认由系统模板渲染(blogHTMLTmpl),主题可用两种方式定制(二者可叠加):
- 只换肤:投放
ssr.css覆盖类名与变量(见 §5.4 + 附录 A)。不要改 Go 源码。 - 换版式:随包投放
page.html—— 系统在请求时优先使用它渲染列表页与文章页(v1.1 新增,免编译,见 §11)。page.html缺失或解析失败时自动回退内置模板,不影响公开页可用性。
4.3 文章页(双轨:SPA #/post/{token} + SSR /{slug})—— 一期必做
同一篇文章有两条人可到达的路径,职责不同,两条都要在:
| | SPA 文章页(人看主链) | SSR 静态页(机器看) |
|---|---|---|
| 地址 | /app#/post/{token}?path=…&slug=… | /{slug}(/blog/{slug} 兼容) |
| 渲染方 | 主题 entries.post(声明则接管)/系统默认 BlogPostView | 系统模板 + 你的 ssr.css |
| 你负责 | 声明 entries.post 组件(可选,强烈建议) | 投放 ssr.css(必修) |
| 用途 | 阅读体验、评论、侧栏、换肤 | canonical / sitemap / 分享快照 |
A. SSR 侧(必修,一期交付项)
- 正文由系统用 goldmark 渲染进
.body,主题不参与正文渲染。 - 主题职责 = 投放
ssr.css,覆盖:- 变量:
--ink / --ink2 / --ink3 / --paper / --line / --jade / --card / --font-d - 类名:
.wrap、header.site、nav.top、article h1、.meta、.body *、footer
- 变量:
- ⚠️ 列表页与文章页共用同一份
ssr.css→ 一次投放,两页同时跟随主题。 - ⚠️ 只覆盖模板中已存在的类名,不要臆造钩子(附录 A 是完整清单)。
B. SPA 侧(可选但强烈建议)
-
系统默认文章页(
BlogPostView)已可用:标题、正文、meta、返回列表、插件槽(评论 / 相关推荐 / AI 阅读 / 统计)、分享链接俱全,并自动挂.th-<id>class。 -
主题声明
entries.post后,整页换由你的组件渲染,从ctx.page.post取数:{ token, path, slug, title, html, cat, category, author, updatedAt, size }html是已渲染好的正文 HTML(系统已完成 Markdown 渲染),用v-html输出即可;ctx.posts仍是完整列表,可用于「相关文章 / 上下篇」;- 「相关文章」链接用
ctx.postUrl(post),「分享链接」用ctx.postSsrUrl(post)。
-
不声明时:系统默认版式 + 你的
.th-<id>钩子 —— 主题可在style.css里写.th-<你的id> { --th-ink: …; --th-paper: … }让默认文章页也跟随配色/字体。 -
⚠️ "列表是主题风、点进文章掉回系统默认皮"是最高频的体验割裂。要根治必须声明
entries.post。
⚠️ 不要指望用 SPA 文章页承接 SEO:hash 路由不会被搜索引擎当作独立页面收录,canonical 与 sitemap 仍指向 SSR
/{slug}。人看 SPA、机器看 SSR,两者互补,缺一即割裂。
4.4 分类页(entries.category)—— 二期
- 语义:分类 = 博客目录的首段子目录(
分类/文件名.md→ 分类分类)。 - SPA 路由:
/app#/blog/cat/:cat(对齐上游/blog/cat/{cat})。 - 数据:
ctx.page.cat(当前分类名)+ctx.posts(按path首段 ===cat过滤)。 - 组件契约(先按此实现,系统补齐路由后自动生效):
<template>
<div class="au-root">
<header class="au-head">
<a class="au-brand" :href="homeHref">{{ ctx.siteName }}</a>
<nav class="au-nav"><ThemeSwitch /><a :href="rssHref">RSS</a></nav>
</header>
<main class="au-main">
<h1 class="au-cat">分类:{{ cat }}</h1>
<p class="au-count">{{ list.length }} 篇</p>
<ul class="au-list">
<li v-for="p in list" :key="p.token">
<a :href="href(p)">{{ title(p) }}</a>
<span>{{ date(p) }}</span>
</li>
</ul>
<p v-if="!list.length" class="au-empty">该分类下暂无文章</p>
</main>
<footer class="au-foot">{{ ctx.siteName }}</footer>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import ThemeSwitch from '@/components/ThemeSwitch.vue'
import './style.css'
const raw = inject('themeContext')
const ctx = computed(() => raw?.value || raw || { posts: [] })
const page = computed(() => ctx.value.page || {})
const cat = computed(() => page.value.cat || '')
const homeHref = '/app#/blog?view=public'
const rssHref = '/api/v1/blog/feed.xml'
const list = computed(() =>
(ctx.value.posts || []).filter((p) => {
const c = (p.path || '').includes('/') ? p.path.split('/')[0] : ''
return c === cat.value
})
)
const title = (p) => String(p.file?.name || p.path || '').replace(/\.(md|markdown)$/i, '').split('/').pop() || '无标题'
const date = (p) => { /* 同 §2.3 */ return '' }
const href = (p) => (ctx.value.postUrl ? ctx.value.postUrl(p) : '#')
</script>
4.5 作者页(entries.author)—— 二期
- 语义:公开文章按作者过滤(系统作者白名单 + 每篇
author_id)。 - SPA 路由:
/app#/blog/author/:author(对齐上游/blog/author/{username})。 - 数据:
ctx.page.author+ctx.posts(按作者过滤;若posts未带作者字段,则由系统侧补file.author后启用)。 - 组件结构与分类页同构,仅过滤条件与标题不同(
作者:{{ author }})。
4.6 标签页(entries.tag)—— 二期(数据未实现)
⚠️ 重要现状:ctx.tags 当前恒为空数组,系统层标签过滤(?tag=)尚未实现。因此一期规范:
- 标签云区块必须
v-if="tags && tags.length"包裹(恒空时自动隐藏,不占位不留白); - 遍历对象是
{ name, count },必须写{{ t.name }}(写{{ t }}会渲染[object Object]); - 渲染为非链接
<span>,禁止输出?tag=死链(会 404); - 系统层实现后,主题把标签项改回
<a :href="'#/blog?tag=' + encodeURIComponent(t.name)">即可 —— 组件不需要重写。
4.7 归档页(entries.archive)—— 二期(可前端派生)
- 数据:从
ctx.posts按updated_at分组年月自行派生(系统不提供归档接口)。 - 路由:
#/blog/archive=YYYY-MM或#/blog?archive=YYYY-MM。 - 组件需自行实现「年月分组折叠/列表」排版。
4.8 搜索页(entries.search)—— 二期(可前端派生)
- 数据:
ctx.page.q+ 在ctx.posts内做客户端匹配(标题/摘要)。 - ⚠️ 客户端搜索只覆盖已加载的公开文章;后续系统若提供公开搜索接口再切换。
- 必须处理空关键词、无结果、加载中三态。
4.9 404 —— 系统兜底,主题无需处理
系统层补全前,未知 slug 由 SSR 兜底(站点默认主题样式 + 提示)。主题不要自行实现 404 页。
4.10 entries 声明契约 + ctx.page(二期)
主题在 manifest.js 声明要接管的页面组件:
import CatView from './CatView.vue'
import AuthorView from './AuthorView.vue'
// ...
export default {
// ...其他字段
entries: { cat: CatView, author: AuthorView, archive: ArchiveView, search: SearchView },
}
系统按 pageEntry(theme, page) 取入口:
list → theme.entry;post/cat/author/tag/archive/search → theme.entries[page],未声明则回退系统默认页(但套用你的 --th-* 令牌,保证整站气质一致)。
内页组件从 ctx.page 读当前页参数:
{ kind: 'post'|'cat'|'author'|'tag'|'archive'|'search', // 当前页面类型
cat?: string, author?: string, tag?: string,
archive?: string, q?: string, post?: object }
⚠️ 系统侧现状:列表页
BlogView提供themeContext(含posts等);文章页BlogPostView已 providectx.page并据entries.post分发(人看侧 SPA 文章页已可用)。二期仍待系统补齐的 SPA 内页路由(/app#/blog/cat/:cat等)与ctx.tags填充(§10),主题侧按本契约先实现组件即可。
5. 样式规范
5.1 四条硬规则
- 样式文件显式引入:入口组件里
import './style.css'(漏掉 = 整页无样式);文章页专属样式放同目录post.css并import './post.css'。 - Reset 必须完整(后代通配符不能漏 —— 历史踩坑):
.au-root, .au-root *, .au-root *::before, .au-root *::after { margin: 0; padding: 0; box-sizing: border-box; } - 类名带主题独有前缀(如
.au-/.bt-),CSS 变量统一--th-*前缀 —— 避免与宿主、其它主题撞名。 - 作用域隔离是自动的:构建插件
vite-theme-scope会给style.css自动加.th-<id>作用域、给post.css自动加.th-<id>.th-view-post作用域,你无需手写.th-<id>前缀。唯一必须遵守的约定:文章页专属选择器写进post.css(若写在style.css会泄漏到列表视图,详见 §0.4)。主题根类前缀(如.au-root)仍推荐(自文档化),但不再是隔离的必需条件——隔离由插件完成。
5.2 设计令牌(--th-*)
在主题根类上定义,供自身与系统默认页共用:
.au-root {
--th-ink: #1c1917; /* 正文 */
--th-ink-2: #57534e; /* 次要文字 */
--th-ink-3: #a8a29e; /* 弱化文字 */
--th-paper: #fafaf9; /* 页面底 */
--th-card: #ffffff; /* 卡片底 */
--th-line: #e7e5e4; /* 分隔线 */
--th-accent: #0f766e; /* 强调色 */
--th-font-d: Georgia, 'Songti SC', 'SimSun', serif; /* 标题字体 */
--th-font-b: system-ui, -apple-system, 'PingFang SC', sans-serif; /* 正文字体 */
}
5.3 其它约束
- 语义化标签:站名用
<div>/<p>,不要用<h1>(h1是文章页的,SSR 才该有)。 - 删除死 CSS:组件里无对应模板的规则必须删掉(历史教训:某主题带了 ~350 行文章页/评论/分页死代码,误导后续维护,15KB → 8KB)。
- 配色贴设计稿要逐值核对:渐变、噪点层、暗/亮底适配;亮色主题的占位渐变必须用低饱和同底色系,禁止把暗色渐变直接搬到亮底上。
- 移动端断点必须做(验收项)。
5.4 SSR 侧样式(ssr.css)
投放位置:服务器 data/themes/<id>/ssr.css(按可执行文件上级目录解析:bin/../data/themes,与存储根一致,不依赖 cwd)。
配套 data/themes/<id>/manifest.json:
{ "title": "极光 Aurora", "version": "1.0.0" }
ssr.css 内容形状(只覆盖附录 A 的类名与变量):
/* 外置主题:aurora —— 覆盖 SSR 列表页与文章页 */
:root {
--ink: #1c1917; --ink2: #57534e; --ink3: #a8a29e;
--paper: #fafaf9; --line: #e7e5e4; --jade: #0f766e; --card: #fff;
--font-d: Georgia, 'Songti SC', serif;
}
.wrap, header.site .in, footer { max-width: 640px; }
header.site { background: transparent; border-bottom-color: var(--line); }
.list li { border: none; border-bottom: 1px solid var(--line); border-radius: 0; padding: 22px 0; }
.list a { font-size: 22px; }
article h1 { font-size: 26px; }
.body pre { background: #1a2332; color: #d7e0ea; border-radius: 8px; }
系统加载机制(无需你操作):
blogThemeOptions()自动把data/themes/下目录加进 SSR 页头下拉;blogThemeIDFor()支持?theme=<id>预览;blogThemeCSS()优先读外置ssr.css(256KB 上限,按 mtime 缓存,热更新即生效)。
6. 主题切换器(ThemeSwitch)
组件:@/components/ThemeSwitch.vue,两种模式:
| 模式 | 用法 | 要求 |
|---|---|---|
| inline(默认) | 主题在自己的页头 nav 里渲染(紧邻 RSS) | manifest 必须声明 ui: { themeSwitcher: true } |
| dock | 宿主(BlogView)在右上角渲染的悬浮兜底版 | 无需主题操作 |
<nav class="au-nav">
<ThemeSwitch /> <!-- inline:主题自带切换器 -->
<a :href="rssHref">RSS</a>
</nav>
规则:
- 自带切换器 → 声明
ui.themeSwitcher: true。不声明 / 不渲染 → 宿主自动补 dock 版,保证任何主题都能换肤。 - 切换结果写
localStorage['aiklog.theme.override'](优先级高于站点设置);站长可「设为站点主题」变成全站默认。 - SSR 页(
/blog、/{slug})页头有原生<select name="theme">下拉,提交即?theme=预览(选项来自blogThemeOptions())。
7. 服务端接口约定(可选能力)
7.1 AI 问答(若主题实现)
- 端点:
POST /api/v1/public/blog/ask - 请求:
{ "question": "..." } - 响应:
{ "reply": "..." }—— 读reply,❌ 不是answer - 必须处理 HTTP 429 限流(每 5 分钟 8 次),给用户友好提示
- 输入为空时禁用按钮;请求中显示 loading
7.2 其它公开接口
| 用途 | 端点 |
|---|---|
| 公开文章列表 | GET /api/v1/public/posts |
| 站点信息(含当前主题) | GET /api/v1/public/site |
| RSS | GET /api/v1/blog/feed.xml |
| Sitemap | GET /api/v1/blog/sitemap.xml |
| 阅读计数 | POST /api/v1/public/blog/pv / GET /api/v1/public/blog/pv |
7.3 归档/统计
从 posts 自行派生(按 updated_at 分组年月、计数),不要指望接口提供。
8. 构建 · 部署 · 联调
8.1 构建命令
# 1) 前端构建
cd web && npm run build
# 2) 产物拷入 server 嵌入目录(覆盖前先清空)
rm -rf server/internal/handler/webdist && mkdir -p server/internal/handler/webdist
cp -r web/dist/* server/internal/handler/webdist/
# 3) 后端单二进制
cd server && go build -o bin/aiklog ./cmd/aikmap
# 4) 重启(演示站)
pkill -x aiklog; sleep 2
cd /opt/aiklog && setsid nohup ./bin/aiklog -addr 127.0.0.1:8780 -db data/aikmap.db \
> aiklog.log 2>&1 < /dev/null &
⚠️ 运维注意:swap 二进制必须先
pkill(否则 "Text file busy" 导致覆盖失败、跑旧二进制)。
8.2 投放 SSR 主题(无需重编译)
mkdir -p /opt/aiklog/data/themes/<id>
# 上传 ssr.css + manifest.json 到该目录即可(热生效)
8.3 预览与联调
| 目的 | 方式 |
|---|---|
| SSR 列表页预览某主题 | /blog?theme=<id> |
| SSR 文章页预览某主题 | /{slug}?theme=<id> |
| SPA 公开页 | /app#/blog?view=public |
| 切为站点主题 | 控制台「博客管理 → 站点设置 → 主题」或 PUT /api/v1/admin/settings {"key":"blog.theme","value":"<id>"} |
9. 验收清单
9.1 基础(一期必过)
- [ ]
npm run build零警告零错误 - [ ] console 无
[themes] 主题缺少 entry/id警告 - [ ] 列表页:加载中/错误/空态三态齐全;首篇不重复;置顶排序正确(不自行重排)
- [ ] 文章点击进入 SPA 文章页(
#/post/…),地址栏仍留在 SPA 内(不整页跳出) - [ ] 文章页「分享链接」可打开 SSR 页(
/{slug})且内容与 SPA 页一致 - [ ] 标签云在
tags为空时隐藏;有数据时显示t.name(不输出死链) - [ ] AI 问答读
reply、429 有提示(若实现) - [ ] 样式全部限定在主题独有类前缀内;切换其它主题无残留污染
- [ ] 若声明
entries.post:文章专属样式在post.css(style.css不含文章根选择器),列表页与文章页互不串样式(构建插件按视图自动隔离) - [ ]
settings.go白名单已加新 id,控制台能保存成功 - [ ] 移动端断点正常
- [ ] 与设计稿并排对比:导航/页脚/渐变/噪点/占位渐变齐全,尺寸一致
- [ ] ★
manifest.tokens与实际style.css的--th-*定义逐项一致(v1.1 新增,见 §9.3)
9.2 页面级(二期,做几个验几个)
- [ ] 投放
data/themes/<id>/manifest.json+ssr.css后,SSR/blog与/{slug}配色随主题变化 - [ ]
?theme=<id>预览两个 SSR 页均生效 - [ ] 自带切换器时 manifest 声明
ui.themeSwitcher: true且页头渲染<ThemeSwitch /> - [ ] 切换主题后 SSR 列表页与文章页气质一致(不出现"列表暗色、文章亮色"割裂)
- [ ] 切换主题后 SPA 公开页右上角(或页头)切换器可见、可切换、可恢复默认
- [ ] 文章页:声明
entries.post时 SPA 文章页为主题版式;未声明时为系统默认页并带.th-<id>钩子(§4.3) - [ ] 内页(分类/作者/归档/搜索):页头页脚与列表页同构,令牌一致;空态/加载态齐全
- [ ] 内页组件未声明时,系统默认页仍套用本主题
--th-*(不割裂)
9.3 交付前机械自检(v1.1 新增 · 提交前必须全过)
为什么单列一节:以下 6 项的共同特征是 —— 构建不会失败、console 不会报警、代码看起来完全正常,只有真正点进去用、或逐行读命名与文案时才会发现。人眼在这类检查上天然不可靠,因此尽量交给工具:跑
theme-lint.sh(附录 E),退出码0才算过前 5 项;第 6 项(grid/flex 子项min-width:0)需结合布局语义,当前由人工核对(脚本暂未自动化)。
cd web/src/themes/<id>
bash theme-lint.sh # 类名前缀自动推断;也可显式传参,如 bash theme-lint.sh bt-
echo $? # 0 = 通过;1 = 存在必修项
| # | 检查项 | 判定 | 依据 |
|---|---|---|---|
| 1 | 无悬空类(模板有类名、CSS 无规则)与无死 CSS | 脚本第 1 节两条输出均为空(动态拼接类需人工排除) | §5.1 |
| 2 | manifest.tokens 台账一致 | 脚本第 2 节无漏报、无虚报 | §1.1 · §5.2 |
| 3 | 无 hash 路由锚点 | 脚本第 3 节为空:既无 href="#x",也无 :href="'#' + x"、location.hash = | §3.1 · §8.3 |
| 4 | 无不可验证文案 | 脚本第 4 节 [!] 项逐条确认:每个形容词都能被真实字段证明 | §2.2 |
| 5 | 无契约外字段 | 脚本第 5 节为空:不出现 .views/.likes/.comments/.cover/.summary/.readTime/.comnum | §2.2 |
| 6 | grid/flex 子项已加 min-width: 0 | 人工核对(长标题、输入框撑破列宽的高频疏漏) | §5.3 |
两条容易被忽略、但与构建无关的硬要求:
- 页内跳转一律
button+scrollIntoView()—— 宿主公开页是 hash 路由(#/blog?view=public),#后面是路由状态而非滚动锚点。把#y-2026写进href等于向 router 塞一段非法路由,可能跳走或白屏,且刷新后地址栏残留。此问题在 iframe 预览与file://下均不复现,只在真实地址栏环境暴露。 - 文案只描述可证事实 —— 拿不准时用中性词(最新/近期/全部/本篇),而非品质词(热门/精选/推荐/排行)。一句话原则:描述位置安全(头条 ✓),描述品质危险(精选 ✗)。
10. 系统侧待补清单(内页前置,主题方不处理)
主题方按 §4 契约先实现内页组件即可;下列由系统侧补齐后自动生效。
「归属」列用于区分这些活该谁做(AiKlog 侧 / 上游侧),依据为对两个仓库的直接代码核对:
- 移植上游 —— 上游已有现成实现,AiKlog 照搬(不需要等上游);
- AiKlog 自研 —— 上游无此实现,由 AiKlog 自主扩展(AiKlog 侧地基已部分完成);
- AiKlog 独有 —— 该轨道上游根本没有,只能在 AiKlog 做。
结论:10 项全部属于 AiKlog 侧工作,无一项需要"等上游"(其中 3 项可直接照搬上游现成代码)。 但其中 3 个契约名(
entries枚举、ctx.page字段、tag/archive/search 路由)建议上报上游冻结为跨壳协议,否则同一主题包在不同壳行为不一致(详见AiKlog-上游需求清单.mdP2-4)。
| # | 待补项 | 影响页面 | 归属 | 依据(已核对代码位置) |
|---|---|---|---|---|
| 1 | SPA 路由 /blog/cat/:cat | 分类页 | 移植上游 | 上游 E14e 已实现:web/src/router/index.js + BlogView 的 cat computed + <PublicHome :cat> 过滤 + 分类导航条(上游 docs/功能模块档案.md:527) |
| 2 | SPA 路由 /blog/author/:author | 作者页 | 移植上游 | 上游已实现:路由 + BlogView 的 author computed + PublicHome 作者链接 + 服务端过滤 enrichAuthors()(上游 shares.go:222,支持 ?author=) |
| 3 | themeContext.page 注入(文章页已落地;分类/作者等内页路由待补) | 文章页 | AiKlog 自研(文章页已落地) | 上游 ctx 无 page;AiKlog themes/index.js 已实现 pageEntry() + 契约,BlogPostView 已 provide ctx.page 并按 entries.post 分发 → 建议上报冻结字段名 |
| 4 | entries 分发落地(文章页已调用 pageEntry 分发 entries.post;内页路由仍待补) | 文章页 | AiKlog 自研(已落地) | 上游无 entries / pageEntry;AiKlog themes/index.js 已实现 pageEntry() + 契约,BlogPostView 已据 entries.post 分发 |
| 5 | ctx.tags 填充 | 标签页 | AiKlog 自研 | 上游也是硬编码空数组(上游 BlogView.vue:68 tags: [])——两壳的标签数据源都缺 |
| 6 | ?tag= 过滤 + 公开标签接口 | 标签页 | AiKlog 自研 | 上游无 tag 路由/接口(上游公开页只有 /blog、/blog/cat/:cat、/blog/author/:author) |
| 7 | ?archive= / ?q= 路由与过滤 | 归档页 / 搜索页 | AiKlog 自研(可前端派生) | 上游无;posts 已含 created_at,归档可前端派生 |
| 8 | SSR 分类页 /blog/cat/{cat} | 分类页 SEO | AiKlog 独有 | 上游无 SSR(public_html.go 是 AiKlog 特有轨道)——只能在 AiKlog 做 |
| 9 | 404 兜底页 | 404 | AiKlog 自研 | 上游 SPA 路由无 :pathMatch 兜底 |
| 10 | posts[].file.author(作者对象) | 作者页 | 移植上游 | 上游 enrichAuthors() 已把 author 对象注入 file.author;AiKlog 已加 files.author_id + JOIN,仅差前端透出 |
归属统计:移植上游 3 项(#1/#2/#10)· AiKlog 自研 6 项(#3/#4/#5/#6/#7/#9)· AiKlog 独有 1 项(#8)· 需要等上游的:0 项。
11. 交付形态 B:声明式主题包(免编译,装 zip 即用)
v1.1 新增。形态 A(源码模块)见 §0–§10;本节是第三方分发/市场包的形态。
11.1 两种形态怎么选
| 维度 | 形态 A:完整主题(源码模块) | 形态 B:声明式主题包(zip) |
|---|---|---|
| 作用面 | 交互版 SPA(#/blog)+ 公网静态页 | 公网静态页 /blog、/blog/{slug}、/{slug} |
| 能做什么 | Vue 组件:内页、侧栏、搜索、AI 问答… | 换版式(page.html)+ 换肤(ssr.css) |
| 安装方式 | 源码入库 + 随主系统构建 | 应用中心一键安装 / 本地 zip 安装 |
| 需要重新编译主系统? | 需要(只有运维方有构建环境) | 不需要(管理员装完即生效) |
| 适合 | 官方主题、深度定制、要交互页 | 第三方分发、市场包、快速上线 |
⚠️ "重新编译"这件事决定了谁能装主题:形态 A 必须由具备 Node+Vite+Go 构建环境的一方执行;形态 B 任何管理员都能装。两者可组合:同一主题先以 B 上线公网站点,再由运维加入 A 获得交互版。
11.2 为什么形态 B 能免编译(机制,务必理解)
系统是双轨渲染:
- SPA 轨:主题 = Vue 单文件组件 → 静态 import → Vite 编译进
dist→go:embed进二进制 → 必须重新编译。 - SSR 轨(公网/SEO 轨):系统在每次请求时读取
data/themes/<id>/ssr.css与page.html(按 mtime 缓存)→ 文件落盘即生效,不编译、不重启。
形态 B 只作用于 SSR 轨,所以"装 zip 即用"成立。
11.3 包结构(白名单,路径大小写敏感)
| 包内路径(任选其一同义) | 落地为 | 用途 | 上限 |
|---|---|---|---|
| manifest.json / theme/manifest.json | data/themes/<id>/manifest.json | 元信息 | 512 KB |
| ssr.css / theme.css / theme/ssr.css / theme/theme.css | data/themes/<id>/ssr.css | 换肤 | 256 KB |
| page.html / theme/page.html | data/themes/<id>/page.html | 换版式 | 512 KB |
- 白名单之外的文件一律忽略(不会被落盘),可安全携带 README、设计稿等。
- 必须含
manifest.json,且至少含ssr.css或page.html之一,否则安装被拒(APP_THEME_EMPTY/MARKET_THEME_EMPTY)。
11.4 manifest.json
{
"id": "yourtheme",
"name": "你的主题名",
"version": "1.0.0",
"kind": "theme",
"description": "一句话说明",
"author": "你的署名"
}
kind必须为"theme"(否则按插件处理,不做主题投放)。id规则:小写字母/数字/./-,≤64 字符,不含..。id同时是目录名,确定后不要改。- 与形态 A 一致:
id是主题身份,切换/卸载都按它索引。
11.5 ssr.css
与 §5.4 同一套约束:只覆盖附录 A 已存在的类名与 8 个 CSS 变量,写自己的前缀规则即可,不要臆造钩子、不要写依赖 JS 的效果。一份样式同时作用于列表页与文章页。
11.6 page.html(换版式)
用 Go html/template 语法(模板引擎通用,写法接近 Jinja/Handlebars):
- 取值:
{{.Title}}、{{.SiteName}}… - 循环:
{{range .Items}} … {{end}} - 分支:
{{if .IsList}} … {{else}} … {{end}}
可用字段(顶层,全部可直接取用)
| 字段 | 含义 |
|---|---|
| SiteName / SiteTag | 站点名 / 副标题 |
| Title / Description | 页面标题 / 描述 |
| Canonical / RSS | 规范链接 / RSS 地址 |
| IsList | 是否列表页(true = 列表,false = 文章) |
| Items | 列表条目(见下) |
| BodyHTML | 文章正文 HTML(已由系统渲染,不要再转义) |
| Date / Cat | 日期 / 分类 |
| JSONLD | 结构化数据(放入 <script type="application/ld+json">) |
| ThemeID | 当前主题 id |
| ThemeCSS | 本主题 ssr.css 内容(放入 <style>) |
| Themes | 主题下拉项({ID, Title}) |
Items 条目字段:Slug(文章相对路径,拼接为 href="/{{.Slug}}")、Title、Preview、Date、Cat。
三条硬要求
- 必须包含
{{.ThemeCSS}}(放进<style>),否则你的ssr.css不会生效。 - 文章页正文用
{{.BodyHTML}}—— 系统已按安全类型处理,写成{{html .BodyHTML}}或手工转义都会导致正文显示异常。 - html/template 会移除 HTML 注释,不要用注释当自检标记(请用独有 class 名)。
主题下拉(可选,需要让读者切主题时加)
{{if .Themes}}<form method="get" action="">
<select name="theme" onchange="this.form.submit()">
{{range .Themes}}<option value="{{.ID}}"{{if eq .ID $.ThemeID}} selected{{end}}>{{.Title}}</option>{{end}}
</select>
</form>{{end}}
兜底保证:page.html 缺失、超限或语法错误时,系统会记一条日志并自动回退内置模板,公开页不会 500。
11.7 安装 · 生效 · 卸载
| 动作 | 方式 |
|---|---|
| 安装 | 应用中心「主题」→ 安装;或后台本地 zip 安装(POST /api/v1/admin/apps/install-zip,表单字段 file) |
| 即时预览(不影响线上) | /blog?theme=<id>、/blog/{slug}?theme=<id> |
| 设为站点主题 | 博客管理 → 主题下拉(已安装的外置主题会自动出现,不需要改前端或后端白名单) |
| 卸载 | 应用/插件列表中删除 → 同时清理 data/themes/<id>/ |
| 生效范围 | 公网静态页(列表 + 文章)。交互版 #/blog 未注册该主题时回退默认 |
11.8 形态 B 自检清单
- zip 内只有白名单文件;
manifest.json.kind == "theme";id合规 - 至少含
ssr.css或page.html page.html含{{.ThemeCSS}}?theme=<id>预览:列表页与文章页都正确(含正文、日期、分类、链接可点)- 空数据/无正文不报错(
{{if not .Items}}兜底) - 卸载后
/blog完全恢复(无残留样式、目录已清理) - 移动端(≤720px/960px)自测
11.9 示例包
docs/主题包示例-免编译/(manifest.json + ssr.css + page.html,压缩后即可直接安装测试)。
可照此结构改写:先跑通最小包,再逐步加样式与版式。
附录 A:SSR 模板钩子全表(ssr.css 可覆盖清单)
CSS 变量:--ink(正文)--ink2(次要)--ink3(弱化)--paper(底)--line(线)--jade(强调)--card(卡片)--font-d(标题字体)
类名结构(来自 public_html.go 的 blogHTMLTmpl):
| 选择器 | 用途 |
|---|---|
| .wrap | 内容容器(文章页/列表页共用) |
| header.site / header.site .in / header.site a / header.site span | 站点头部(站名、副标题) |
| nav.top / nav.top a / nav.top a:hover | 顶部导航(文章/RSS/官网) |
| .ssr-thsw / .ssr-thsw select | SSR 页头主题下拉 |
| article h1 | 文章标题 |
| .meta | 文章元信息(日期·分类·返回列表) |
| .body / .body h1,h2,h3 / .body pre / .body code / .body img / .body a | 正文(goldmark 产出) |
| .list / .list li / .list a / .list .p / .list .m | 列表页条目 |
| .lead / .lead a | 列表页引导语 |
| footer / footer a | 页脚 |
SSR 模板数据字段(只读,供理解结构):SiteName / SiteTag / Title / Description / Canonical / RSS / IsList / Items / BodyHTML / Date / Cat / JSONLD / ThemeID / ThemeCSS / Themes
附录 B:参考实现索引(内置主题)
| 主题 id | 气质 | 适合参考的点 |
|---|---|---|
| minimal | 单栏沉浸、衬线标题 | 最小可用骨架(推荐首次照抄) |
| docs | 技术文档风 | 宽版式、左侧色条列表 |
| paper | 暖纸 + 青绿顶栏 + 珊瑚强调 | 亮色分类占位渐变 |
| elevated | 高端暗色 | 暗色底 + 正文可读性、AI 侧栏 |
| parchment | 暖纸杂志(琥珀) | reset 补全、死 CSS 清理的正例 |
| emforum | 深蓝鎏金论坛风 | 版块页签式布局;v1.1.1 起为「令牌台账 / 悬空类 / 文案诚实性」全过的正例(其 v1.1.0 的 5 处缺陷构成配套手册的主要案例) |
| brutal | 新粗野(硬边框 + 硬阴影 + 高饱和撞色) | 无封面图数据时的纯 CSS 几何封面方案;v1.0.1 起令牌台账与悬空类全过 |
| aiklog | 门面样板 | 默认气质基准 |
阅读顺序建议:
minimal(骨架) →docs(版式) →elevated(暗色)。主题版本提示:
emforum请取 ≥ v1.1.1、brutal请取 ≥ v1.0.1 —— 更早版本存在 §9.3 可机械查出的缺陷(详见配套《常见缺陷清单与自检手册》)。
附录 C:常见错误与反面教材
C.1 协议与结构类
| 错误 | 后果 | 正确做法 |
|---|---|---|
| index.js 用回调式导出 | 主题永不注册 | 模块加载时直接 registerTheme |
| manifest 用 name/description | 切换器显示空 | 用 title/desc |
| 忘记 import './style.css' | 整页无样式 | 入口组件内显式引入 |
| reset 漏 .xx-root * | 后代 UA margin 未清 | 通配符四件套(§5.1.2) |
| 站名用 <h1> | SEO 语义冲突 | 用 <div>/<p> |
| 标签云 {{ t }} | 渲染 [object Object] | 写 {{ t.name }} |
| 标签云输出 ?tag= 链接 | 404 死链 | 一期用非链接 <span> |
| 让列表文章全部跳 SSR 静态页 | 人看侧脱离主题、无评论/侧栏 | 列表用 ctx.postUrl(SPA);分享/SEO 用 ctx.postSsrUrl |
| 手拼 #/p/、#/post/ 或自造链接 | 与系统路由不一致,易碎链 | 用 ctx.postUrl(post) / ctx.postSsrUrl(post) |
| 只做列表、不声明 entries.post | 点进文章掉回系统默认皮(体验割裂) | 声明 entries.post(见 §4.3B) |
| 主题根类未加 | 样式污染主应用 | 全部选择器挂 .xx-root |
| 忘记加 settings.go 白名单 | 控制台保存被拒 | §1.3.3 |
| 只交 SPA 组件、不交 ssr.css | 文章页不跟随主题 | 一期必修项 |
| 自行重排 posts | 与 SSR/RSS 顺序不一致 | 保持系统顺序 |
C.2 交付质量类(v1.1 新增 · 全部由 §9.3 机械自检覆盖)
这 6 条的共性是:构建不失败、console 不报警、代码看起来完全正常,人眼极易漏检 —— 因此不要靠复查,跑脚本。
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 页内锚点写进 href="#x" 或 location.hash | 宿主 hash 路由被篡改,可能跳转/白屏;刷新后地址栏残留异常 hash | button + scrollIntoView(),全程不碰 hash。两种写法都要查:href="#x" 与 :href="'#' + x" |
| 文案写「热门 / 精选」而数据只有时间序 | 承诺契约中不存在的能力,用户会认为排序坏了 / 数据造假 | 文案只描述可证事实。描述位置安全(头条 ✓),描述品质危险(精选 ✗) |
| 类名用 hot / rank / top 表达时间序列表 | 命名与视觉双重误导后续维护者按"热度"心智改需求 | 按事实命名:latest / recent / pinned |
| manifest.tokens 与实际 --th-* 不一致 | 令牌台账失真,基于台账的自动化(后台可编辑项、文档生成、跨主题比对)全部建立在错数据上 | 逐项对齐;属结构性遗忘,必须工具卡 |
| 模板有类名、CSS 无规则(悬空类) | 样式静默失效,当前靠继承"碰巧"正常,父级一动就塌 | 类名交叉比对,补齐或删除 |
| grid/flex 子项未加 min-width: 0 | 长标题、搜索框撑破列宽,窄屏溢出 | 所有 grid/flex 子项显式 min-width: 0 |
附录 D:上游协议出处(跨壳对齐依据)
| 内容 | 上游来源 |
|---|---|
| 主题对象字段(id/title/desc/version/entry/pages/tokens/dataSources/dataScope/ui) | AiKmap/AiKmap.cn docs/AiKlog-AiKdex侧插件协议清单.md §1.2 + §2.1 |
| entries 页面契约(list/post/tag/category/archive/search) | web/src/themes/index.js 协议注释 |
| 分类路由 /blog/cat/:cat、作者路由 /blog/author/:author | 上游 web/src/router/index.js(E14e / E14h-①) |
| 主题包(zip:manifest.json + dist/)与应用中心安装管线 | 上游 server/internal/handler/blog_market.go(E14h-9f 统一管线) |
| SSR 双轨(ssr.css 外置主题 + ?theme= 预览) | AiKlog 侧特有(server/internal/handler/public_html.go),上游无此机制 |
附录 E:配套资料索引(v1.1 新增)
本规范是唯一权威依据,但它不适合作为日常操作手册 —— 下面三份配套资料分别解决「照着做 / 别踩坑 / 自动查」:
| 资料 | 定位 | 什么时候用 | 权威源位置 |
|---|---|---|---|
| 《博客主题开发规范》(本文档) | 协议与契约的权威依据 | 开发前通读;有疑问时以本文档为准 | docs/博客主题开发规范-第三方完整交付版.md |
| 《常见缺陷清单与自检手册》 | 缺陷案例库 + 人工检查清单 | 提交前逐条过一遍;审查他人主题时对照 | docs/博客主题开发-常见缺陷清单与自检手册.md |
| theme-lint.sh | 机械自检工具(退出码 0 = 过) | 每次提交前跑;CI 里也可挂 | docs/theme-lint.sh |
| aiklog-theme-delivery 开发技能 | 端到端交付流程(含构建、打包、踩坑记录) | 用 AI 助手辅助开发主题时自动加载 | ~/.workbuddy/skills/aiklog-theme-delivery/SKILL.md |
E.1 三者关系(一句话)
规范说「应该怎么做」→ 手册说「别人在这里栽过」→ 脚本说「你现在有没有栽」。
规范来自协议设计,手册来自真实审查实录(EmForum v1.1.0 的 5 处缺陷 + 在产主题二次验证),脚本把手册里机械可判定的部分自动化。
E.2 手册里最高频的三类缺陷(务必先看)
- hash 路由锚点(🔴 功能隐患)—— 页内跳转写
href="#x"会篡改宿主路由状态; - 文案/命名臆造能力(🟠 语义失真)—— 「热门 / 精选 / 排行」而数据只有时间序;
- 令牌台账与悬空类(🟡 规范偏差)—— 不改不影响跑,改起来全靠工具查。
E.3 使用顺序建议
① 通读本文档 §0–§5 → 理解双轨渲染与数据契约
② 照抄 minimal 主题骨架 → 跑通「注册 → 取数 → 样式」最小闭环
③ 按 §4 补内页 → 每做一个内页,对照附录 C.2 自查
④ 提交前跑 theme-lint.sh → 退出码必须为 0
⑤ 对照手册 §4 清单逐条人工确认 → 机械项之外的部分(文案、语义、降级)
⑥ 按 §8 部署 + §9 验收 → 两侧 SSR 页气质一致才算完成
本规范由 AiKlog 侧整理,供第三方主题开发方使用。一期请先交付列表页 + 文章页(SSR)两部分,二期按 §4 补齐内页。 v1.1 起,规范与配套的《缺陷手册》《自检脚本》同步维护:每轮主题审查发现的新问题,先补进手册案例,再从中提炼机械可判定的条目回填至 §9.3 与附录 C.2。