用户手册-第一个博客主题
目录
面向想自己开发博客主题的用户(也面向代为开发的 AI)。读完这篇你就能从零做出一个可被平台发现、可在设置页配置、可随文章发布的完整主题。**全程不需要改任何 Python 代码。**
---
1. 架构一览
templates/themes/<你的主题key>/ ← 主题包(Jinja2 模板 + 清单)
theme.json ← 元信息 + 可配置项 schema(必需)
base.html ← 基础布局(必需)
index.html ← 博客首页(必需)
post.html ← 文章页(必需)
static/blog/css/theme-<key>.css ← 主题样式(在 base.html 里引入)
templates/themes/partials/ ← 平台共享组件(勿改动,直接 include)
head_common.html ← SEO/og 头
comments.html ← 评论组件
userbar.html ← 顶部用户条
cats.html ← 分类筛选栏(首页用)
pager.html ← 分页导航(首页用)
平台启动时自动扫描 `templates/themes/` 下的目录:只要存在合法的 `theme.json`,主题就会出现在后台设置页的**「更多」主题弹窗**里(设置页画廊固定展示 6 个预置主题,导入的主题通过「更多」按钮的弹窗呈现,支持搜索),用户选择后新发布文章与博客前端即用该主题渲染,**保存设置时自动重建全部已发布文章页**。
key 命名规则:小写字母/数字/连字符(目录名即主题 key),不要以 `_` 开头(会被扫描器跳过)。
---
2. theme.json —— 元信息与设置项
{
"name": "MyTheme",
"description": "一句话介绍(显示在设置页主题卡片上,同时也是弹窗搜索的关键词)",
"version": "1.0.0",
"options": [
{ "key": "accent", "label": "强调色", "type": "color", "default": "#0969da" },
{ "key": "columns", "label": "每行列数", "type": "select", "choices": ["1", "2", "3"], "default": "3" },
{ "key": "show_cover", "label": "显示封面", "type": "bool", "default": true },
{ "key": "hero_title", "label": "首页大标题", "type": "text", "default": "记录与分享" }
]
}
- `options` 支持四种类型:`color`(取色器)/ `bool`(开关)/ `select`(下拉)/ `text`(文本框)。
- 用户保存设置后,取值会注入模板变量 **`themeOptions`**(如 `themeOptions.accent`),键名就是 `options[].key`。
- 每个用户独立保存一份(per-user),互不影响。
- `bool` 的值在模板里是真布尔:`{% if themeOptions.show_cover is not false %}`。
---
3. base.html —— 基础布局
推荐直接复制任意现有主题的 `base.html` 改造。必须保留的骨架(以 simple 为例):
<!DOCTYPE html>
<html lang="zh-CN" id="theme-simple"> {# id 换成你的 key,测试/调试用 #}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ siteInfo.title }}{% endblock %}</title>
{% block seo %}{% endblock %}
{% include "themes/partials/head_common.html" %} {# SEO 头:图标/og:title/og:description/og:url/og:image/twitter/robots,自动注入 #}
<link rel="stylesheet" href="/static/blog/css/blog-shared.css"> {# 共享样式:页脚沉底、分类栏、分页、动效 #}
<link rel="stylesheet" href="/static/blog/css/theme-<key>.css">
<style>:root { --accent: {{ themeOptions.accent or '#0969da' }}; }</style> {# 主题配置 → CSS 变量 #}
{% block head_extra %}{% endblock %} {# 文章页加 highlight.js 等用 #}
</head>
<body>
{% include "themes/simple/partials/header.html" %} {# 头部导航可换成你自己的 #}
<main>
{% block content %}{% endblock %}
</main>
<footer>…</footer>
{% block scripts %}{% endblock %}
<script src="/static/blog/js/theme.js"></script> {# 暗色模式切换(含 localStorage 记忆) #}
</body>
</html>
**四个硬性约定**(否则页面会难看/功能缺失):
- **页脚沉底**:`body { display:flex; flex-direction:column; min-height:100vh; } body > main { flex:1; } body > footer { margin-top:auto; }`(`blog-shared.css` 已兜底,主题自有样式可覆盖)。
- **SEO 头**:保留 `{% block seo %}{% endblock %}` 和 `{% include "themes/partials/head_common.html" %}`。`head_common.html` 已自动输出 `og:type / og:title / og:description / og:url / og:image / twitter:*`(首页用站点信息、文章页自动切换为文章信息),主题**不要重复输出这些 og 标签**;`{% block seo %}` 里只需放 `meta description` 和 `canonical`。
- **暗色模式**:`theme.js` 会在 `<html>` 上切换 `class="dark"`,你的 CSS 需提供 `html.dark { … }` 变量覆盖。
- **共享动效**:`blog-shared.css` 提供页面淡入、卡片上浮渐入、按钮按压缩放等全局动效与过渡,主题 CSS 不要对这些行为做反向覆盖。
导航链接契约
| 链接 | 变量 | 说明 |
|---|---|---|
| 广场 | `{{ siteInfo.plaza_url }}` | 平台博客广场(**必须用这个绝对 URL**,自定义域名下相对路径会 404) |
| 管理 | `{{ siteInfo.admin_url }}` | 平台管理端(作者已登录直达,访客被引导登录) |
| RSS | `{{ siteInfo.urls.rss }}` | 用户自己的 RSS(自定义域名下指向该域名) |
| 站点 logo/作者名 | `{{ siteInfo.urls.home }}` | 用户博客首页 |
---
4. 模板上下文
4.1 通用变量(所有页面可用)
siteInfo.title 站点标题("xxx 的博客")
siteInfo.author 作者显示名(昵称 || 用户名)
siteInfo.author_avatar 作者头像 URL
siteInfo.icon 头像(无头像时自动回退占位图)
siteInfo.description 站点描述(用户在设置页填写的介绍,单行化)
siteInfo.logo_description 用户在设置页填的多行介绍(换行已转 <br>)
siteInfo.theme 当前主题 key
siteInfo.urls.home / .rss
siteInfo.plaza_url / .admin_url
siteInfo.social.email 作者邮箱
themeOptions 主题设置项(见 theme.json)
user_home 是否为用户博客前端(区别于平台 feed)
is_owner 访客是否作者本人(自定义域名下恒为 false)
baseUrl 平台 BASE_URL(如 https://app.collegehub.cn)
abs 若非空则模板里可用 `abs('/xxx')` 生成绝对 URL
viewer_user_id() 当前登录用户 id(无登录返回 '',发布管线请求外渲染也安全)
4.2 index.html —— 博客首页
posts 当前页的文章卡片数组(每页 10 篇,按发布时间倒序):
posts[n].title / .href / .summary / .date / .cover / .author / .author_avatar
posts[n].category 分类名(未分类为空串;列表卡片**不展示**分类徽标,见下方约定)
blog_categories 分类筛选栏数据(作者已发布文章的分类聚合):
blog_categories[n].name / .count(无分类时为空数组)
pagination 分页上下文(结构固定,交给 pager.html 渲染):
pagination.page / .total / .has_prev / .has_next
pagination.home_url 博客首页 URL(分类栏用它拼 ?cat= 链接)
pagination.base_url 当前过滤态下的翻页基址(已含 ?cat=)
pagination.filter_cat 当前选中的分类名(空 = 全部)
**index.html 的固定结构约定**(平台组件自动隐藏空状态,主题只管 include):
{% extends "themes/<key>/base.html" %}
{% block title %}{{ siteInfo.title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ siteInfo.description }}">
<link rel="canonical" href="{% if user_home %}{{ siteInfo.urls.home }}{% else %}{{ abs('/blog/') if abs else (baseUrl ~ '/blog/') }}{% endif %}">
{% endblock %}
{% block content %}
{% include "themes/partials/cats.html" %} {# ⭐ 分类筛选栏:必须放列表之前 #}
<div class="post-list">
{% for p in posts %}
<a class="card" href="{{ p.href }}">
{% if p.cover %}<img src="{{ p.cover }}" alt="">{% endif %}
<h2>{{ p.title }}</h2>
<p>{{ p.summary }}</p>
<span>{{ p.date }} · {{ p.author }}</span>
</a>
{% else %}
<p>还没有文章。</p>
{% endfor %}
</div>
{% include "themes/partials/pager.html" %} {# ⭐ 分页导航:必须放列表之后、endblock 之前 #}
{% endblock %}
设计约定:分类浏览入口统一走顶部筛选栏(`.nb-cats`,由 `cats.html` + `blog-shared.css` 渲染,选中项高亮,移动端横向滑动),**列表卡片不放分类徽标**;文章多于 10 篇时底部自动出现分页(`.nb-pager`),翻页链接自动携带分类参数,两个组件在单页/无分类时自动隐藏。
4.3 post.html —— 文章页
post.title 标题(页面唯一 <h1>,主题不要再渲染第二个 h1)
post.href /blog/<slug>/
post.summary SEO 描述
post.category 分类名(未分类为空串)
post.date 发布日期(YYYY-MM-DD)
post.publishedAt 同上(语义明确版)
post.cover 封面 URL(无则空串)
post.html ⭐ 正文 HTML(已净化,用 {{ post.html | safe }} 输出)
post.toc 目录数组:[{ 'id': 'h-0', 'text': '标题', 'level': 2|3 }]
post.is_draft 草稿标记(预览时显示"草稿预览"横幅)
post.author / .author_avatar
post.comments 评论数组(.author/.avatar/.content/.created_at)
post.comment_count
prev / next 上一篇/下一篇卡片(结构同 posts 元素,可能为 None)
recommendPosts 推荐阅读(最多 3 篇)
文章页骨架示例(含分类链接的标准写法):
{% extends "themes/<key>/base.html" %}
{% block title %}{{ post.title }} - {{ siteInfo.title }}{% endblock %}
{% block content %}
<article>
<h1 class="post-title">{{ post.title }}</h1>
<div class="meta">
{{ post.date }} · {{ post.author }}
{% if post.category %}<a href="{{ siteInfo.urls.home }}?cat={{ post.category|urlencode }}">{{ post.category }}</a>{% endif %}
</div>
{% if post.is_draft %}<div class="draft-banner">草稿预览</div>{% endif %}
{% if post.cover %}<img class="cover" src="{{ post.cover }}" alt="">{% endif %}
<div class="post-body">{{ post.html | safe }}</div>
<div class="toc">
{% for t in post.toc %}
<a class="toc-level-{{ t.level }}" href="#{{ t.id }}">{{ t.text }}</a>
{% endfor %}
</div>
<nav class="around">
{% if prev %}<a href="{{ prev.href }}">← {{ prev.title }}</a>{% endif %}
{% if next %}<a href="{{ next.href }}">{{ next.title }} →</a>{% endif %}
</nav>
</article>
{% include "themes/partials/comments.html" %} {# 评论组件(自带 JS) #}
{% endblock %}
---
5. 正文块 HTML 类约定(post.html 输出的结构)
`{{ post.html | safe }}` 输出的都是平台统一生成的净化 HTML,你的 CSS 只需覆盖这些类:
| 类名 | 对应块 | 说明 |
|---|---|---|
| `nb-h2` / `nb-h3` / `nb-h4` | 一级/二级/三级标题 | 自带 `id`(TOC 锚点),建议加 `scroll-margin-top` |
| `nb-p` | 正文段落 | |
| `nb-quote` | 引用 | |
| `nb-code` | 代码块 | `<pre class="nb-code"><code data-lang="python">`;代码块永不解析 HTML |
| `nb-list` | 列表(ul/ol) | |
| `nb-image` | 图片 | `<figure class="nb-image"><img><figcaption>` |
| `nb-link` | 链接卡片 | `.nb-link-title` + `.nb-link-url` |
| `nb-comments` 等评论类 | 评论区 | 由 `themes/partials/comments.html` 组件生成,已有默认样式,可直接覆盖 |
| `nb-cats` / `.nb-cat` / `.nb-cat-n` | 分类筛选栏 | 由 `cats.html` 生成,`blog-shared.css` 有默认样式,可微调 |
| `.nb-pager` / `.nb-pager-link` / `.nb-pager-info` | 分页导航 | 由 `pager.html` 生成,同上 |
图片仅允许库内资源(`/assets/<uuid>.webp`)与 http(s) 外链;所有文本已 bleach 过滤,主题层**不要**对用户数据使用 `| safe`(`post.html` 除外)。
---
6. 主题 CSS 建议骨架
/* theme-<key>.css */
:root { /* 亮色变量 */
--t-accent: #0969da; --t-bg: #fff; --t-text: #1f2328;
--t-muted: #6b7280; --t-border: #e5e7eb;
}
html.dark { /* 暗色覆盖 */
--t-bg: #0f1115; --t-text: #e5e7eb; --t-muted: #9ca3af; --t-border: #262b36;
}
body { background: var(--t-bg); color: var(--t-text);
font: 16px/1.8 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex; flex-direction: column; min-height: 100vh; }
body > main { flex: 1 0 auto; } /* 页脚沉底 */
body > footer { margin-top: auto; }
a { color: inherit; text-decoration: none; }
/* 正文块(见第 5 节)…… */
/* 分类栏/分页:blog-shared.css 已有默认样式,仅需按主题微调颜色圆角 */
@media (max-width: 640px) { /* 移动端:避免按钮竖排与横向溢出 */ }
---
7. 从零做一个最小主题(walkthrough)
mkdir -p templates/themes/minimal
**templates/themes/minimal/theme.json**
{
"name": "Minimal",
"description": "极简单栏主题",
"version": "1.0.0",
"options": [
{ "key": "accent", "label": "强调色", "type": "color", "default": "#111111" }
]
}
**templates/themes/minimal/base.html / index.html / post.html** —— 按 3、4 节骨架填写。
**static/blog/css/theme-minimal.css** —— 按 6 节编写。
完成后无需重启:
- 打开后台 **设置** → 点主题行右侧「更多」,弹窗(支持搜索)中出现 "Minimal";
- 选中即应用(或画廊选中后保存)→ 已发布文章自动按新主题重建;
- 博客前端立即生效。
**开发调试技巧**:改模板后,到文章编辑页点「发布」(增量重建该篇)或设置页点「全量重建」即可看到效果;模板语法错误会在发布时抛出,检查后台 flash 提示或 `journalctl -u app1.cloudstudio.site.service`。
---
8. 上线自检清单
- [ ] `theme.json` 合法(`python3 -c "import json;json.load(open('templates/themes/<key>/theme.json'))"`)
- [ ] 「更多」主题弹窗出现你的主题(搜索名称/介绍能命中),颜色/开关等设置修改后前端生效
- [ ] 首页 include 了 `cats.html`(列表前)与 `pager.html`(列表后),无分类/单页时它们自动隐藏
- [ ] 首页/文章页在**亮色与暗色**模式下都可读
- [ ] 移动端(≤640px)无横向滚动条、按钮不竖排(分类栏横向滑动)
- [ ] 页面有且只有一个 `<h1>`(文章页 = 标题)
- [ ] 保留 `head_common.html` include;**没有**重复输出 og:title/og:description 等标签
- [ ] 长代码块/长链接不撑破布局(`overflow-x: auto`)
- [ ] 导航链接使用 `siteInfo.plaza_url / admin_url / urls.rss`,**不硬编码 `/blog/`**
- [ ] 文章页分类链接用 `{{ siteInfo.urls.home }}?cat={{ post.category|urlencode }}`,不硬编码首页地址
- [ ] 深浅色下封面图占位(无封面文章)不破相
9. 主题包导入(无需登录服务器)
后台 **设置 → 导入主题包(ZIP)** 可直接上传安装主题,打包方式:
my-theme/ ← 顶层目录名即主题 key(也可在表单手填)
theme.json ← 必需
base.html ← 必需
index.html ← 必需
post.html ← 必需
theme-xxx.css 等静态资源(可选)
- 系统限制:ZIP ≤10MB、≤100 个文件、解压总量 ≤20MB;
- 只允许模板/样式/图片类文件(.html/.css/.js/.json/.svg/.png/.jpg/.webp/.gif/.md/.txt),`.py` 等一律拒绝;
- 拒绝路径穿越(`../`)与绝对路径;同名主题需勾选「覆盖同名主题」才能更新;
- 导入的主题在设置页「更多」弹窗中呈现(画廊固定展示 6 个预置主题);
- ⚠️ Jinja 模板具有服务端渲染能力(模板内可写表达式),**只导入自己写的或审查过代码的主题包**。
10. 已知边界
- 正文块 HTML 结构由平台统一生成,主题只能改样式、不能改结构;
- 共享组件(`comments.html` / `cats.html` / `pager.html` / `head_common.html`)结构与逻辑由平台维护,主题只做样式覆盖;分类每页 10 篇的分页策略由平台控制;
- 列表卡片不展示分类徽标是平台统一设计约定,分类浏览入口只有顶部筛选栏与文章页分类链接;
- `archive.html` 已废弃(归档功能已下线),主题不需要提供;
- 平台升级若新增块类型或组件,会在 `BLOG_README.md` 公告对应类名,主题补几行 CSS 即可。
评论