河南第一深情 河南第一深情

用户手册-第一个博客主题

目录

面向想自己开发博客主题的用户(也面向代为开发的 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>

**四个硬性约定**(否则页面会难看/功能缺失):

  1. **页脚沉底**:`body { display:flex; flex-direction:column; min-height:100vh; } body > main { flex:1; } body > footer { margin-top:auto; }`(`blog-shared.css` 已兜底,主题自有样式可覆盖)。
  2. **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`。
  3. **暗色模式**:`theme.js` 会在 `<html>` 上切换 `class="dark"`,你的 CSS 需提供 `html.dark { … }` 变量覆盖。
  4. **共享动效**:`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 节编写。

完成后无需重启:

  1. 打开后台 **设置** → 点主题行右侧「更多」,弹窗(支持搜索)中出现 "Minimal";
  2. 选中即应用(或画廊选中后保存)→ 已发布文章自动按新主题重建;
  3. 博客前端立即生效。

**开发调试技巧**:改模板后,到文章编辑页点「发布」(增量重建该篇)或设置页点「全量重建」即可看到效果;模板语法错误会在发布时抛出,检查后台 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 即可。

评论

评论加载中…