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

2026-10-20 · 用户手册

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

本文档是主题包的唯一契约。请全文读完再生成代码,生成后必须逐项过一遍 §7 自检清单。
任何与本文冲突的写法都会导致 500 报错或功能缺失。

---

0. 五条铁律(违反即 500)

  1. extends / include 路径里的主题 key 必须等于包顶层目录名。

包目录叫 my-theme,就必须写 {% extends "themes/my-theme/base.html" %}。
不存在 url_for、不存在 abs()、不存在任何函数调用生成 URL——所有静态资源一律写死字面路径,如 /static/blog/css/theme-my-theme.css。

  1. CSS/JS 文件名必须是 theme-<key>.css / theme-<key>.js(<key> = 包目录名),放在包根目录。导入时平台自动复制到 /static/blog/css/、/static/blog/js/,模板中按此固定路径引用。其他文件名的静态资源不会被部署。
  2. 评论组件必须同时有两条:模板里 {% include "themes/simple/partials/comments.html" %}(注意是 simple,不是 partials)+ <script src="/static/blog/js/comment.js"></script>。缺任何一条,评论区永久显示"加载中"。
  3. 不要对用户数据使用 | safe,唯一例外是 {{ post.html | safe }} 和 {{ siteInfo.logo_description_html | safe }}(这两个已是安全 HTML)。
  4. 不要重复输出 Open Graph 标签。head_common.html 已自动输出全套 og:*(首页用站点信息、文章页自动切文章信息),主题的 {% block seo %} 里只放 <meta name="description"> 和 <link rel="canonical">。

---

1. 包结构

my-theme/                 ← 顶层目录名即主题 key(小写字母/数字/连字符,不以 _ 开头)
    theme.json            ← 必需
    base.html             ← 必需
    index.html            ← 必需(博客首页)
    post.html             ← 必需(文章页)
    preview.jpg           ← 可选:预览图(900×560 首页截图最佳,也支持 png/webp)
    theme-my-theme.css    ← 可选:主题样式(文件名必须带 key)
    theme-my-theme.js     ← 可选:主题脚本
  • 打包:把 my-theme/ 整个目录压成 ZIP(保留顶层目录)。总大小 ≤10MB,文件数 ≤100,单文件 ≤20MB。
  • 允许的文件扩展名:.html .css .js .json .jpg .jpeg .png .webp .svg .txt .md。其他一律被拒收。
  • 导入表单的 key 可留空:平台自动从包顶层目录名推导(非法字符转 -,中文目录名则用时间戳兜底),一般不需要手填。
  • 导入后自动归入设置页「更多」弹窗的用户主题分组(内置 6 个官方主题为 Simple / Paper / Fukasawa / Illusion / Magazine / Firefly)。

---

2. theme.json

{
  "name": "我的主题",
  "description": "一句话介绍(用于弹窗搜索,不显示在卡片上)",
  "version": "1.0.0",
  "options": [
    { "key": "accent",     "label": "强调色",       "type": "color",  "default": "#0969da" },
    { "key": "show_cover", "label": "列表显示封面", "type": "bool",   "default": true },
    { "key": "columns",    "label": "每行列数",     "type": "select", "choices": ["2", "3"], "default": "3" },
    { "key": "hero_text",  "label": "首页大标题",   "type": "text",   "default": "记录与分享" }
  ]
}
  • type 只允许 color / bool / select(必须有 choices 数组)/ text。
  • options 可以为空数组 []。
  • 模板中通过 themeOptions.<key> 读取(未设置时返回 default)。选项值是用户输入的字符串:拼进 HTML 属性前用 {{ ... | e }} 转义;URL 类选项输出前必须校验以 http:// 或 https:// 开头:
{% set wp = themeOptions.wallpaper or '' %}
{% if wp.startswith('http://') or wp.startswith('https://') %}
:root { --wallpaper: url("{{ wp | e }}"); }
{% endif %}

---

3. base.html 完整骨架(可直接复制,替换 KEY 与样式即可)

{# 主题 KEY:my-theme —— 下面所有 KEY 字样必须替换为实际目录名 #}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ siteInfo.title }}{% endblock %}</title>
{% block seo %}
<meta name="description" content="{{ siteInfo.description }}">
{% endblock %}
{% include "themes/partials/head_common.html" %}
<link rel="stylesheet" href="/static/blog/css/blog-shared.css">
<link rel="stylesheet" href="/static/blog/css/theme-my-theme.css">
<script>
/* 暗色模式:localStorage 记忆 + 系统偏好,class 名必须是 html.dark(平台约定) */
(function () {
  var saved = localStorage.getItem('nb-theme');
  var dark = saved ? saved === 'dark'
    : window.matchMedia('(prefers-color-scheme: dark)').matches;
  if (dark) document.documentElement.classList.add('dark');
})();
</script>
{% block head_extra %}{% endblock %}
</head>
<body>

<header class="t-header">
  <a class="t-brand" href="{{ siteInfo.urls.home }}">
    {% if siteInfo.avatar %}<img class="t-avatar" src="{{ siteInfo.avatar }}" alt="{{ siteInfo.author }}">{% endif %}
    <span>{{ siteInfo.title }}</span>
  </a>
  <nav class="t-nav">
    <a href="{{ siteInfo.plaza_url }}">广场</a>
    <a href="{{ siteInfo.admin_url }}">管理</a>
    <a href="{{ siteInfo.urls.rss }}">订阅</a>
  </nav>
</header>

<main class="t-main">
{% block content %}{% endblock %}
</main>

<footer class="t-footer">
  <p>© {{ siteInfo.author }} · <a href="{{ siteInfo.plaza_url }}">OnePage</a></p>
</footer>

<script src="/static/blog/js/theme.js"></script>
</body>
</html>

固定要求:

  • {% include "themes/partials/head_common.html" %} 必须在 <head> 内(负责全部 SEO/og 标签)。
  • blog-shared.css 必须引入(表格、行内代码、nb-* 块类、分页器、分类栏的兜底样式都在里面)。
  • <script src="/static/blog/js/theme.js"></script> 必须在 </body> 前(深浅切换按钮的官方逻辑,按钮结构见 §6)。
  • 暗色模式约定:给 <html> 加/移除 dark class,所有暗色样式写在 html.dark 前缀下。

4. index.html(博客首页)完整骨架

{% extends "themes/my-theme/base.html" %}
{% block seo %}
<meta name="description" content="{{ siteInfo.description }}">
<link rel="canonical" href="{{ siteInfo.urls.home }}">
{% endblock %}

{% block content %}
<section class="t-hero">
  <h1>{{ themeOptions.hero_text or siteInfo.title }}</h1>
  <p>{{ siteInfo.description }}</p>
</section>

{# 分类筛选栏:必须在文章列表之前;无分类时自动隐藏 #}
{% include "themes/partials/cats.html" %}

<section class="t-list">
  {% for p in posts %}
  <article class="t-card">
    {% if p.cover %}<a href="{{ p.href }}"><img src="{{ p.cover }}" alt=""></a>{% endif %}
    <div>
      <h2><a href="{{ p.href }}">{{ p.title }}</a></h2>
      <p>{{ p.summary }}</p>
      <footer>
        <time>{{ p.date }}</time>
        {% if p.category %}<a class="t-cat" href="{{ pagination.home_url }}?cat={{ p.category | urlencode }}">{{ p.category }}</a>{% endif %}
      </footer>
    </div>
  </article>
  {% else %}
  <p class="t-empty">还没有文章。</p>
  {% endfor %}
</section>

{# 分页导航:必须在文章列表之后;单页时自动隐藏 #}
{% include "themes/partials/pager.html" %}
{% endblock %}

5. post.html(文章页)完整骨架

{% extends "themes/my-theme/base.html" %}
{% block title %}{{ post.title }} · {{ siteInfo.title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ post.summary }}">
<link rel="canonical" href="{{ post.href }}">
{% endblock %}

{% block content %}
<article class="t-post">
  <header>
    <h1>{{ post.title }}</h1>
    <p class="t-meta">
      <time>{{ post.date }}</time>
      {% if post.category %}<a href="{{ siteInfo.urls.home }}?cat={{ post.category | urlencode }}">{{ post.category }}</a>{% endif %}
      {% if post.is_draft %}<span>草稿预览</span>{% endif %}
    </p>
  </header>

  {# 正文唯一 safe 点:平台已 bleach 过滤 #}
  <div class="post-body">{{ post.html | safe }}</div>
</article>

<nav class="t-around">
  {% if prev %}<a href="{{ prev.href }}"><small>← 上一篇</small><b>{{ prev.title }}</b></a>{% endif %}
  {% if next %}<a href="{{ next.href }}"><small>下一篇 →</small><b>{{ next.title }}</b></a>{% endif %}
</nav>

{# 评论两条缺一不可(§0 铁律 3) #}
{% include "themes/simple/partials/comments.html" %}
<script src="/static/blog/js/comment.js"></script>
{% endblock %}

---

6. 变量参考(全部可用字段,除此之外没有别的)

siteInfo(对象):

字段类型说明
title / descriptionstr博客名 / 博客介绍(单行,可直接进 meta)
author / avatarstr作者昵称 / 头像 URL(可能为空)
logo_description_htmlstr多行介绍的安全 HTML(`safe` 输出)
urls.homestr博客首页绝对地址(自定义域名下是完整 URL)
urls.rssstrRSS 地址
plaza_url / admin_urlstr广场 / 管理后台绝对地址

posts(数组,首页每页 10 篇)每项 p:title、href(文章地址)、summary、date(已格式化字符串)、cover(可能为空串)、category(可能为空串)、author、author_avatar。

blog_categories(数组):[{name, count}],该作者全部已发布文章的分类计数。

pagination(对象):page、total(总页数)、has_prev、has_next、home_url、base_url(已带 ?cat= 过滤参数)。pager.html 自会使用,一般不用直接碰。

post(文章页对象):title、href、summary、date、cover、category、html(安全 HTML 正文)、toc、is_draft(bool)、author、author_avatar、comment_count。

prev / next(文章页):上一篇/下一篇的卡片(字段同 posts 项),可能为 None,必须 {% if %} 包裹。

recommendPosts(文章页数组):同作者推荐 3 篇,字段同 posts 项。

themeOptions:见 §2。

is_owner(bool):访问者是否博客主人,可用于显示管理按钮。

正文 HTML(post.html)里平台生成的类名(CSS 需要覆盖外观时用,兜底样式已在 blog-shared.css):nb-p、nb-h2/h3/h4、nb-quote、nb-code(pre,内部横向滚动)、nb-list、nb-image、nb-link、nb-table-wrap / nb-table,行内元素 code strong em del a。

深浅切换按钮(可选,放在 header):theme.js 自动接管点击逻辑,图标必须用内联 SVG,不要用 emoji(emoji 在部分系统显示为方框、无法跟随配色):

<button id="dark-toggle" title="切换深色模式" aria-label="切换深色模式">
  <svg class="icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
  <svg class="icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
</button>

配套 CSS(浅色显示月亮、深色显示太阳):

#dark-toggle svg { width: 1.15rem; height: 1.15rem; }
#dark-toggle .icon-dark { display: none; }
html.dark #dark-toggle .icon-dark { display: block; }
html.dark #dark-toggle .icon-light { display: none; }

---

7. 生成后自检清单(AI 必须逐项确认再输出)

  1. 包目录名 = theme.json 所在目录名 = 所有模板里 extends "themes/<目录名>/..." 的目录名 —— 三处完全一致?
  2. 模板中没有 url_for、abs(、request.、session、config 等任何 Flask 侧调用?
  3. 所有静态资源都是字面路径 /static/blog/...,且 CSS/JS 文件名带主题 key(theme-<key>.css)?
  4. head_common.html 已 include;blog-shared.css 已引入;未重复输出任何 og: 标签?
  5. index:cats.html 在列表前、pager.html 在列表后?遍历的是 posts(已分页切片),不是自造变量?
  6. post:post.html | safe 是唯一 safe?prev/next 都有 {% if %} 保护?
  7. 评论两条都在:themes/simple/partials/comments.html include + comment.js script?
  8. themeOptions 的 URL/text 输出前已转义或校验协议?
  9. 移动端适配:375px 宽度下无横向溢出(长词 overflow-wrap: anywhere,宽表格/代码块交给容器滚动)?
  10. 封面图容器必须固定尺寸:封面列/块用固定宽度高度 + object-fit: cover(img 绝对定位或容器定高),原图宽高比不得影响卡片高度(否则竖图会把列表撑得参差不齐)?
  11. preview.jpg 尺寸约 900×560(可选但强烈建议)?

全部为"是"才算完成。任何一条不确定,回到对应章节重读。

评论

评论加载中…