爱库录 目录即站点,文件即文章

博客主题开发规范-第三方完整交付版

2026-09-16 · AiKlog板块 · 返回列表

AiKlog 博客主题开发规范(第三方完整交付版)

版本:v1.1 · 2026-09-15 · 适用仓库:AiKlog(web/src/themes/ + 服务器 data/themes/) 读者:第三方主题开发方。 本文档 = 上游主题协议(AiKmap/AiKdex 同构契约)+ AiKlog 实战规范(elevated / parchment / minimal / emforum / 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 交付物清单

一套完整主题 = 6 类文件 + 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') | | 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.js import、也没进 settings.go 白名单时,该主题既不会出现在控制台下拉,也不会进构建产物 —— 属"孤儿目录"。自查方式见附录 E 的自检脚本第 1 节,或直接 grep 两处。

0.3 三个必须理解的前提

  1. 双轨渲染:公开站有 SPA 交互版/app#/blog 列表、/app#/post/… 文章)和 SSR 静态页/blog/{slug})两条通道,同一套主题必须两边都覆盖(SPA 靠 Vue 组件 + style.css,SSR 靠 ssr.css)。这是"列表好看、点进去变样"问题的根因,也是本规范 §4 的核心。
  2. 文章页也是双轨人看走 SPA 文章页 #/post/{token}(系统提供,随主题走,可带评论/侧栏等插件);机器看走 SSR /{slug}(canonical / sitemap / 分享快照)。两者由同一份数据渲染 —— 主题要么声明 entries.post 接管 SPA 文章页,要么只投放 ssr.css 让 SSR 文章页跟随(此时 SPA 文章页由系统默认页渲染并挂 .th-<id>,配色跟随、版式是系统的)。正文 HTML 由系统注入 ctx.page.post.html,主题不负责渲染正文内容本身。
  3. 主题交付有两种形态(详见 §11):形态 A = 源码模块,需随前端一起构建(编译进 web/dist),不是运行时可热插的 zip;形态 B = 声明式主题包(zip),经应用中心 / 本地 zip 安装后,ssr.csspage.html 均可运行时热投放(文件落盘即生效,不编译、不重启),作用于公网静态页;形态 A 的 SPA 交互页仍必须重新编译。

1. 主题目录结构与最小可用骨架

web/src/themes/<theme-id>/
├── index.js            # 注册入口(模块加载即注册)
├── manifest.js         # 元数据 + 能力声明
├── <Id>View.vue        # 列表页入口组件(默认页)
├── style.css           # 主题样式(组件内 import)
├── <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 处,缺一不可)

  1. 注册 import:在 web/src/themes/all.js 追加一行副作用导入(所有主题在此集中注册,列表页 BlogView 与文章页 BlogPostView 共用,不要再在 BlogView.vue 顶部散 import —— 否则会出现「列表可选、文章页却掉回默认皮」的割裂):
    import '@/themes/aurora' // 注册极光主题(集中注册表 all.js)
    
  2. 控制台下拉自动出现(BlogManage 从注册表 listThemes() 动态渲染,无需改代码)。
  3. ⚠️ 服务端白名单(最容易漏):把新 id 加进 server/internal/handler/settings.goblog.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):
    • 禁止把页内锚点写进 hrefhref="#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),主题可用两种方式定制(二者可叠加):

  1. 只换肤:投放 ssr.css 覆盖类名与变量(见 §5.4 + 附录 A)。不要改 Go 源码。
  2. 换版式:随包投放 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
    • 类名:.wrapheader.sitenav.toparticle 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=尚未实现。因此一期规范:

  1. 标签云区块必须 v-if="tags && tags.length" 包裹(恒空时自动隐藏,不占位不留白);
  2. 遍历对象是 { name, count },必须写 {{ t.name }}(写 {{ t }} 会渲染 [object Object]);
  3. 渲染为非链接 <span>禁止输出 ?tag= 死链(会 404);
  4. 系统层实现后,主题把标签项改回 <a :href="'#/blog?tag=' + encodeURIComponent(t.name)"> 即可 —— 组件不需要重写

4.7 归档页(entries.archive)—— 二期(可前端派生)

  • 数据:从 ctx.postsupdated_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) 取入口: listtheme.entrypost/cat/author/tag/archive/searchtheme.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 已 provide ctx.page 并据 entries.post 分发(人看侧 SPA 文章页已可用)。二期仍待系统补齐的 SPA 内页路由(/app#/blog/cat/:cat 等)与 ctx.tags 填充(§10),主题侧按本契约先实现组件即可。


5. 样式规范

5.1 三条硬规则

  1. style.css 必须在入口组件里显式引入import './style.css'(漏掉 = 整页无样式)。
  2. Reset 必须完整(后代通配符不能漏 —— 历史踩坑):
    .au-root, .au-root *, .au-root *::before, .au-root *::after {
      margin: 0; padding: 0; box-sizing: border-box;
    }
    
  3. 作用域隔离:所有选择器挂在主题根类下(.au-root …),CSS 变量统一 --th-* 前缀 —— 禁止污染主应用与其它主题。

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.css256KB 上限,按 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 有提示(若实现)
  • [ ] 样式全部限定在主题根类内;切换其它主题无残留污染
  • [ ] 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-上游需求清单.md P2-4)。

| # | 待补项 | 影响页面 | 归属 | 依据(已核对代码位置) | |---|---|---|---|---| | 1 | SPA 路由 /blog/cat/:cat | 分类页 | 移植上游 | 上游 E14e 已实现web/src/router/index.js + BlogViewcat computed + <PublicHome :cat> 过滤 + 分类导航条(上游 docs/功能模块档案.md:527) | | 2 | SPA 路由 /blog/author/:author | 作者页 | 移植上游 | 上游已实现:路由 + BlogViewauthor computed + PublicHome 作者链接 + 服务端过滤 enrichAuthors()(上游 shares.go:222,支持 ?author=) | | 3 | themeContext.page 注入(文章页已落地;分类/作者等内页路由待补) | 文章页 | AiKlog 自研(文章页已落地) | 上游 ctx page;AiKlog themes/index.js 已实现 pageEntry() + 契约,BlogPostViewprovide 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 独有 | 上游无 SSRpublic_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 编译进 distgo:embed 进二进制 → 必须重新编译
  • SSR 轨(公网/SEO 轨):系统在每次请求时读取 data/themes/<id>/ssr.csspage.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.csspage.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}}")、TitlePreviewDateCat

三条硬要求

  1. 必须包含 {{.ThemeCSS}}(放进 <style>),否则你的 ssr.css 不会生效。
  2. 文章页正文用 {{.BodyHTML}} —— 系统已按安全类型处理,写成 {{html .BodyHTML}} 或手工转义都会导致正文显示异常。
  3. 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 自检清单

  1. zip 内只有白名单文件;manifest.json.kind == "theme"id 合规
  2. 至少含 ssr.csspage.html
  3. page.html{{.ThemeCSS}}
  4. ?theme=<id> 预览:列表页与文章页都正确(含正文、日期、分类、链接可点)
  5. 空数据/无正文不报错({{if not .Items}} 兜底)
  6. 卸载后 /blog 完全恢复(无残留样式、目录已清理)
  7. 移动端(≤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.goblogHTMLTmpl):

| 选择器 | 用途 | |---|---| | .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.1brutal 请取 ≥ 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 手册里最高频的三类缺陷(务必先看)

  1. hash 路由锚点(🔴 功能隐患)—— 页内跳转写 href="#x" 会篡改宿主路由状态;
  2. 文案/命名臆造能力(🟠 语义失真)—— 「热门 / 精选 / 排行」而数据只有时间序;
  3. 令牌台账与悬空类(🟡 规范偏差)—— 不改不影响跑,改起来全靠工具查。

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。