本文档是主题包的唯一契约。请全文读完再生成代码,生成后必须逐项过一遍 §7 自检清单。
任何与本文冲突的写法都会导致 500 报错或功能缺失。
---
0. 五条铁律(违反即 500)
extends/include路径里的主题 key 必须等于包顶层目录名。
包目录叫 my-theme,就必须写 {% extends "themes/my-theme/base.html" %}。
不存在 url_for、不存在 abs()、不存在任何函数调用生成 URL——所有静态资源一律写死字面路径,如 /static/blog/css/theme-my-theme.css。
- CSS/JS 文件名必须是
theme-<key>.css/theme-<key>.js(<key>= 包目录名),放在包根目录。导入时平台自动复制到/static/blog/css/、/static/blog/js/,模板中按此固定路径引用。其他文件名的静态资源不会被部署。 - 评论组件必须同时有两条:模板里
{% include "themes/simple/partials/comments.html" %}(注意是simple,不是partials)+<script src="/static/blog/js/comment.js"></script>。缺任何一条,评论区永久显示"加载中"。 - 不要对用户数据使用
| safe,唯一例外是{{ post.html | safe }}和{{ siteInfo.logo_description_html | safe }}(这两个已是安全 HTML)。 - 不要重复输出 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>加/移除darkclass,所有暗色样式写在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 / description | str | 博客名 / 博客介绍(单行,可直接进 meta) | |
author / avatar | str | 作者昵称 / 头像 URL(可能为空) | |
logo_description_html | str | 多行介绍的安全 HTML(` | safe` 输出) |
urls.home | str | 博客首页绝对地址(自定义域名下是完整 URL) | |
urls.rss | str | RSS 地址 | |
plaza_url / admin_url | str | 广场 / 管理后台绝对地址 |
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 必须逐项确认再输出)
- 包目录名 = theme.json 所在目录名 = 所有模板里
extends "themes/<目录名>/..."的目录名 —— 三处完全一致? - 模板中没有
url_for、abs(、request.、session、config等任何 Flask 侧调用? - 所有静态资源都是字面路径
/static/blog/...,且 CSS/JS 文件名带主题 key(theme-<key>.css)? head_common.html已 include;blog-shared.css已引入;未重复输出任何og:标签?- index:
cats.html在列表前、pager.html在列表后?遍历的是posts(已分页切片),不是自造变量? - post:
post.html | safe是唯一 safe?prev/next都有{% if %}保护? - 评论两条都在:
themes/simple/partials/comments.htmlinclude +comment.jsscript? themeOptions的 URL/text 输出前已转义或校验协议?- 移动端适配:375px 宽度下无横向溢出(长词
overflow-wrap: anywhere,宽表格/代码块交给容器滚动)? - 封面图容器必须固定尺寸:封面列/块用固定宽度高度 +
object-fit: cover(img 绝对定位或容器定高),原图宽高比不得影响卡片高度(否则竖图会把列表撑得参差不齐)? preview.jpg尺寸约 900×560(可选但强烈建议)?
全部为"是"才算完成。任何一条不确定,回到对应章节重读。
评论