Commit Graph
60 Commits
Author SHA1 Message Date
laoyang 51c16209a0 4.1.1 修设置页链接无法新窗口打开(target 被 wp_kses 剥掉) #33
用户反馈:设置页「数据导出服务」那个链接点下去在本页跳走了,
把正在改的设置页顶掉。

根因不是链接写错 —— 源码里 target="_blank" 一直在。是框架渲染 desc 时
走 wp_kses($explain_value, $allowedtags)
(inc/options-framework/includes/class-options-interface.php:210/315),
而 WordPress 全局 $allowedtags['a'] 只有 href + title,target 和 rel
被静默剥掉。实测:

  输入 <a href="..." target="_blank" rel="noopener">数据导出服务</a>
  输出 <a href="...">数据导出服务</a>          ← target/rel 消失

修法:在 kratos_options() 开头给 $allowedtags['a'] 补上 target / rel。
只放宽这两个属性,不放宽别的。时机是对的 —— _optionsframework_options()
在第 47 行就调 kratos_options(),早于所有 wp_kses 调用。

影响的不止这一个链接:robots.txt、设置-阅读、两个翻译 API 的链接
全都是同样情况,一并修好(共 5 处)。

线上真实环境验证:
  ALLOWED_BEFORE=href,title
  ALLOWED_AFTER=href,title,target,rel
  HAS_TARGET=YES

版本号 4.1.0 → 4.1.1:同一版本号重发 PUC 认不出(它用 version_compare
判严格大于),已装 4.1.0 的站点永远拿不到这次修复。readme 按用户确认
**并入现有 V4.1.0 节**,不新增一节(节数仍 57)。

补的判据(都做了注入反证):
- _mkzip410.py 11c 段 5 条:白名单补了 target/rel、补的位置在
  kratos_options() 内、「数据导出服务」链接带 target、没拿 window.open 绕过
- _mkzip410_falsify.py 17/18 组:删白名单必须变红,18/18 抓到
- _readme410.py 改造成替换模式 + 反证(不换首节必须红,42/0)

顺带修两个已过期的地方:
- _mkzip410_falsify.py 的「版本号没改」注入锚点写死 "Version: 4.1.0",
  升 4.1.1 后锚点未命中、反证报"没抓到"(其实是注入脚本自己过期)
  ⇒ 改成动态读当前版本号
- _publish410.sh 的 readme 首节判据要求 == 本版号,但 4.1.1 并入
  V4.1.0 节 ⇒ 改成「首节是 4.1.x」+「本版修复说明真在里面」两头都验

线上已发布:md5 bd12b67f2c4ca5bec5053a02e49f2fe6
PUC 实测 HAS_UPDATE=YES(已装 4.1.0 → 新 4.1.1)
2026-10-28 17:45:19 +08:00
laoyang 3b8244d7d3 修 4.1.0 后台文案里残留的 Markdown 星号(用户会看到字面 ** ) #33
线上真实 WP 环境预演 render_shell() 时发现两处文案把 Markdown 的
`**加粗**` 原样拼进了 HTML,浏览器会直接显示两个星号:

- inc/theme-stat.php:1128  render_shell 的授权步骤
  `用**你自己的百度账号**登录` → 用<b>…</b>
- inc/theme-options.php:837  stat_track_enable 的 desc
  `代码本身**不会被删除**` → <b>…</b>

同一批里 readme 也中过一处(`这十三项**都能单独关掉**`),
已在 _readme410.py 里修掉并重新生成 readme。

补的判据(都做了注入反证,不是恒真):
- _mkzip410.py 第 11b 段:剥注释后扫 PHP 字符串里的 `**…**`,
  83 通过 / 0 失败
- _mkzip410_falsify.py 第 16 组:把 <b> 还原成 ** 必须变红,16/16 抓到
- _readme410.py 第 8 段:本版节里不许有 Markdown 星号,
  注入后 38/1、恢复后 39/0

顺带修两个判据脚本自身的缺陷:
- _stat410.php 自己跑(文档写的 `php _stat410.php`)会因
  $GLOBALS['__theme_dir'] 未定义而 Fatal error ⇒ 加回落路径 +
  找不到文件时 exit 2 明说"没跑任何判据,这不是通过"
- _publish410.sh 的 LOCAL_RD 默认值指向上一版遗留的
  /d/tmp/readme410.html ⇒ 首跑把旧 readme 传上去、出现 1 条失败,
  重跑手工设环境变量又"好了"⇒ 默认值改成 _readme410.py 的真实
  输出路径,且默认路径不存在时直接停

线上已重发:md5 5cf4e1b3242a2e092233fd573d0196d4
主题目录仍是 4.0.0(未被覆盖),等用户后台点升级。
2026-10-28 17:18:54 +08:00
laoyang c125388ab7 4.1.0 主题设置页加访问统计面板,接百度统计 API 直出后台 #33
新增 inc/theme-stat.php(约 1814 行),把百度统计的数据直接显示在
主题设置页,不用再跳去百度后台看。

主要设计决策:
- 凭据(API Key / Secret / refresh_token)存 wp-content/uploads/ 下
  独立文件(0600),不写 options 表、不在表单回显 —— 换主题/升级不丢
- 13 个指标全部实现,用户可在「面板显示哪些指标」里各别开关
  (每次 API 调用都占配额,只看几个数字的可以关掉其余)
- 面板走 AJAX 懒加载「先出壳、后取数」:同步渲染 8 个请求 ×5 秒超时
  会让设置页最坏卡 40 秒
- 缓存成功 1800 秒 / 失败 300 秒:接口挂了不至于每次刷新都等超时
- "--"(百度接口的"无数据")渲染成破折号,不转成 0 —— 否则
  "暂无数据"会被显示成"0 次访问"
- footer.php 的统计代码外面加埋点开关,关掉不停用代码本身

改动:
- inc/theme-stat.php   新建
- inc/theme-options.php 新增 8 个控件
- footer.php           统计代码套埋点开关
- functions.php        require 新模块
- style.css            版本号 4.0.0 → 4.1.0

验证:
- php -l 全量 98 文件 0 失败
- 正向判据 145/0,反证 19 组全抓到
- 真实接口连通性 10/0(OAuth 401 与 error_code 110 两种错误格式实测)
- 打包自检 82/0,其反证 15 组全抓到
2026-10-28 16:33:41 +08:00
laoyang 76c69b31e8 正文标题自动编号,与目录编号共用同一份计算 #30
需求:目录里有编号、正文标题没有,读长文时要回头去目录找对应关系。
本版给 h1/h2/h3 加编号,样式为灰色小号(标题字号的 0.62 倍),
不抢标题视觉重心,与目录 .yang-toc-num 同风格。

编号规则(编号范围跟着后台 toc_levels 设置走,默认 1/2/3):
- 按「实际出现的最外层」归一,没有 h1 时 h2 直接是一级(1/2/3),
  不会编成 2/2.1。天然满足用户"h1 没有就是 h2 一级"的要求。
- 作者已手写编号的标题跳过并占位(1./1、/1)/1.2/一、/(一)/第一章
  等写法),不叠加成"3.1 1. xxx",但编号序列不错位。

实现要点:
- 抽出 yang_toc_pick / yang_toc_depths / yang_toc_number_map 三个纯函数,
  目录与正文共用同一份编号计算,杜绝两边漂移(用户痛点正是"两边不一致")。
- 插入按 id 回填:条目清单只来自 scan() 那一份,id 是唯一键。
- 用 preg_match_all + PREG_OFFSET_CAPTURE 拿字节偏移、从后往前拼接插入。
  早期写成 preg_replace_callback('#<h([1-6])([^>]*)>(.{0,120})#is'),
  那个 .{0,120} 贪婪跨过块边界,导致 5 个标题只有第 1 个拿到编号且不报错。
- 幂等用 strncmp(substr($html,$end), $needle, strlen($needle)),不手数字节
  (写成固定 26 而实际 25 会让幂等静默失效、编号插两层)。

CSS 修一个自己造的问题:编号把标题撑高 3px。
line-height:inherit 无效——同行高下小字号半行距更大,盒底反而更往下探
(h2 46→49)。改为 display:inline-block + line-height:1 成为原子行内盒,
实测 46→46、37→37。八组隔离试验数据见 _tooling/_diag362b.js。

验证(全部本地/线上实跑,非推断):
- 正向判据 43/0;反证 22/0(10 组注入均红在预期条目)
- 线上 4 篇真实文章 20/0(含"只有 h3"、"h1+h2"、"h2+h3"三种形态)
- 新旧两版目录 HTML 逐字节比对 7/0(4/4 篇相同,证明重构没改坏老行为)
- 几何判据 68/0,含两条反证:回到 inline+inherit ⇒ 高度差 3.00px;
  编号 display:block 掉行 ⇒ G3 差 14.88px
- 打包自检 59/0 + 4 组注入反证全红
2026-10-28 08:27:53 +08:00
laoyang d55667a394 自动目录两份默认状态分别设置,老配置平滑迁移 #27
正文那份与浮层那份的「默认状态」拆成两项,各管各的:

- theme-toc.php:toc_default 拆成 inline_default / float_default;
  normalize 加四分支迁移段(新键合法→用它;新键非法→回落该键默认;
  新键缺失 + 老键存在→用老键;都不存在→各键默认);
  读库键列表继续读老键 'default',不读就拿不到迁移值,老站点会被静默重置;
  正文的 open 与浮层的 is_open 各读自己的键,互不引用。
- theme-options.php:两个独立下拉分别落在「正文里的目录」「浮动目录」
  分组末尾;共用分组里那句「对两份同时生效」已过时,改写。
- 新增 yang_toc_admin_legacy_values() 挂 option_kratos(仅 is_admin):
  后台读配置时把老键的值内存投影到缺失的新键上。不投影的话,
  老站点会出现「页面按 hide 渲染、后台下拉框却显示 show」的错位。
  只在读取时投影,库不动,降级回旧版仍有退路。
- style.css 版本号 3.2.59(本版未改前台 CSS,kratos.min.css 与 JS 均未动)。

验证:桩判据 61/0,反证 16 组 36/0,真实 WP 判据 30/0,
回归 _toc330_logic 101/0、_test_toc 53/0,打包自检 42/0。
2026-10-27 22:31:26 +08:00
laoyang 1a31e412b0 写作增强:翻译服务商选了谁才显示谁对应的账号输入框 #26
之前四个密钥框(百度 APPID/密钥、有道应用 ID/密钥)无论选哪家服务商
都全摆在设置页上,四组输入框里永远有三组用不上。

改动:
- inc/theme-options.php:新增 $yang_prov_class() 闭包,统一生成字段
  的 class。服务端就按「当前已保存的服务商」把 yq-cond-hide 打好,
  首帧不闪。分组标记 yang-prov / yang-prov-<key> 写在每个字段的
  'class' 上,是**唯一的分组声明处**,JS 不再抄服务商清单。
  原本一个 shared 的 info 块拆成三块,每家一句自己的说明。
- inc/options-framework/js/options-custom.js:重写
  yq_toggle_writing_slug_fields()。两层条件(总开关 + 服务商归属)
  必须在同一函数内合成一个结果再 toggleClass —— toggleClass 的
  force 是绝对赋值,拆成两个函数各 toggle 一次的话结果只取决于
  注册顺序。change 事件同时监听 #w_on_slug 与 #w_provider。
- style.css:3.2.57 -> 3.2.58

两处刻意避开的老坑:
- 用 class + display:none !important,不用 jQuery show()/hide(),
  后者会把行内 display 写成 block,回退 3.2.28 的 flow-root 修复。
- 隐藏不 disabled 输入框,密钥字段是「留空=不修改」语义,disabled
  不随表单提交,会把已保存的密钥冲掉。
2026-10-27 21:40:00 +08:00
laoyang 0a5486e195 3.2.57 新增「写作增强」:发布时自动生成别名/标签/分类/摘要 #25
需求(工单 #25):写文章时自动生成别名(翻译)、自动生成标签、
自动选择分类、自动生成摘要,问主题能不能做、是否必须用插件。
结论:主题自己就能做,不依赖任何插件。

新增 inc/theme-writing.php(四个独立引擎 + 设置读写 + 密钥处理):

- 触发点:transition_post_status,且要求
  new_status === publish && old_status !== publish
  ⇒ 只在「第一次点发布」时动手,自动保存不触发,已发布的文章一概不碰。
- 幂等锁 _yang_writing_done:同一篇只做一次,避免反复覆写。

四道「不覆盖」守卫(用户逐条确认的决策):
- 摘要:只在 post_excerpt 为空时才写。
- 分类:只在文章还挂默认分类(未分类)时才动。
- 别名:只在「空」或「不是纯 ASCII」时才生成。判据不是"空"而是
  "不是纯 ASCII" —— 中文标题下 WP 生成的是 %e4%bf%a1... 这种编码串,
  它非空但不可用。已有英文别名的 966 篇一概不动(别名=URL,改了
  就是成批 404)。
- 标签:wp_set_post_tags(..., true) 追加,绝不删除手工标签。

修一个真实产品 bug:3.2.56 及以前 apply() 里写的是
list($suggest, $_) = yang_writing_make_tags(...),$new 被整个丢掉;
接着又拿 $suggest 去 get_term_by 二次分类 —— 而 $suggest 里每个词
都来自词典、必然全部命中 ⇒「新建」分支永远进不去 ⇒ 这条功能在真实
发布流程里一次都没生效过。改为 list($exist_hits, $new_cands),
两段分别复核后合并。

标签两级策略(新博客也能用):先匹配已有标签池,匹配不到才新建,
每篇新增数量受 w_tag_new_limit 约束(默认 3,设 0 = 永不新建)。

中文分词(服务器无 scws/jieba):先按虚词与标点切短语,再在短语
内部取 2~6 字 n-gram。不切句直接取窗口会产出「星等换算与赤」
这类跨词碎片,且字面上"出现 ≥2 次"能通过所有频次闸门 —— 这是
n-gram 的固有缺陷,只能靠切分规避,调阈值无效。
虚词表统一由 yang_writing_seps() 提供(闸门④ 复用同一函数)。

翻译:默认 MyMemory(免注册),可选百度/有道。任何失败都返回空串
跳过该项,绝不阻塞发布。

密钥:存独立选项 yang_writing_secrets,不进 kratos;password 类型 +
std 空串;页面只显示末 4 位;留空提交 = 不修改。

设置页:新增「写作增强」独立 heading 标签页,四个开关默认全关
(升级不改变现有行为)。framework 里 'requires' 这个键根本没被读取,
条件显示改用 options-custom.js 显式逻辑;info 类型不生成 .section
包裹层,补一条 .section-info 规则才能藏住服务商申请说明。

验证(发布前):
- 写作端到端 71 条、后台渲染 21 条、密钥路径 9 条,合计 101 条全过
- 反证 22 组(W1~W16 端到端 / A1~A6 后台)× R1~R14 发布包注入,
  全部让失败数从 0 增加
- 测试自身不污染:每节差分回滚 + 总闸门,标签库稳定 703 / 967 篇
- 发布走 theme-update/,主题目录仍是 3.2.56,未被覆盖
2026-10-27 16:05:43 +08:00
laoyang b7dcdc1602 分类挂件下拉面板列出全部层级并缩进 #24
需求(2026-10-27):把多级目录也放进去,现在只显示第一级,把其他的
也放进去,注意缩进。线上实测 29 个分类(8 一级 + 21 二级,无第三层),
因此面板从「只列一级」改成「有几层列几层」。

inc/theme-widgets.php
- get_tree() 从写死两层改成任意深度递归。带 $seen 已访问集合 +
  $max_depth=10 双重保护:parent 成环时只跳过、不抛异常 —— 侧栏
  挂件白屏会把整站带下去,宁可少显示一条也不能崩。
- 排序递归到所有层(原来只排前两层,第三层往后顺序与「按文章数排」不一致)。
- 新增 find_name():触发条文案与面板渲染共用同一套遍历。原来两段各自
  硬编码两层,层数一变必漏一处。
- 新增 render_level():widget() 面板改成单次递归调用。关闭 children
  时把 children 清空交给同一函数,不另写一份循环 —— 两份循环必然漂移。
- 下一层 <ul> 放在本层 li 内部(HTML 里 ul 的直接子元素只能是 li)。
- children 兜底值 false → true,**两处**(widget 第 1501 行、form 第
  1701 行)。线上实例 categories-3 没有这个键 ⇒ 走兜底 ⇒ 用户零操作生效。

assets/css/kratos.css
- .yang-cats-item 加 flex-wrap:wrap;子级 ul 用 flex:0 0 100% 独占
  一整行,落在父级 li 的内容盒里 ⇒ 缩进逐层累加且所有层共用一个右边界。
- 缩进只由子级 li 的 padding-left 给,**子级 ul 的 padding-left 归零**。
  第一版两处都给了 12px,几何实测每层缩进 24px(stepPx=[24,24,18]),
  第 3 层名字只剩 148px,收敛规则也一起失效。
- 第 4 层起收缩到 6px([style*="--cat-d:3"] 起),避免深层吃光面板宽度。
- 每层 padding-right 一律 0,右侧 12px 只由顶层统一给 ⇒ 任意深度的
  数字右沿都在同一竖线(数字要竖着比大小,错开一列就没法比)。

assets/css/kratos.min.css 重新压缩;style.css 版本号 → 3.2.56。

验证
- 端到端(真实 WordPress):120 通过 / 0 失败;旧代码 100 / 17
- 反证 20 组(R1~R19 + R13b)全部命中
- 视觉几何(线上真实页面):56 通过 / 0 失败
- 打包内容自检 26 项全过;发布判据 79 条全过
- 发布:update.json → 3.2.56,md5 0aa90b30e76d754de41febcb9271ddef
- 红线:服务器主题目录仍 3.2.55(未覆盖),等用户在后台点升级
2026-10-27 13:33:53 +08:00
laoyang f5fd363dce fix(widgets): 分类挂件改成「点开才占一行」的下拉面板 #24
需求改判:3.2.54 做的「父级常显 + 子级折叠树」被否,用户要的是
「不点只有一行,点开把所有一级分类拉出来」的真下拉面板。

## 改了什么

- widget() 重写:`<details class="yang-cats-drop">` + `<summary>` 触发条
  + `position:absolute` 浮层面板。收起时整个挂件只占一行(40.5px),
  展开时面板盖在下方内容上,不把后面的挂件推走。
- children 兜底值 true → false。线上实例的配置里本来没有这个键,
  所以走 pick() 兜底 ⇒ 改兜底就等于改线上行为,用户零操作即变成
  「只列一级分类」,不用进后台勾一次。
- 子级不再嵌套 <details>(3.2.54 是嵌套)。现在父级与子级的 DOM
  结构完全一致,3.2.54 那个「space-between 推不动、数字右边界差
  7.1px」的缺陷从结构上消失了,不需要 flex:1 补丁。
- 补一段渐进增强脚本,只做「点外部收起 / Esc 收起并还焦点」两件事,
  不接管开合本身(关掉 JS 仍能正常展开跳转)。
- CSS:.w-cats 区块整块重写。修掉子级 li 右内边距叠加的缺陷 ——
  子级 ul 嵌在已 padding:0 12px 的父级 li 里,子级 li 又写了一遍
  右内边距 ⇒ 叠加成 24px,导致子级数字比父级多缩进 12px、父子排不成
  一列,竖着比大小时会错位。改成 0 后父子数字同列。
- style.css 版本号 3.2.54 → 3.2.55(3.2.54 已落过 update.json,
  同号重发会让 WP 的 version_compare 判「没有新版本」)。

## 验证

- 端到端(线上真实 WordPress,96 项):全通过
- 反证:内容判据 12 组 + 端到端 3 组,全部真红,无一组空跑
- 视觉几何(真实浏览器 50 项):全通过
- 上一版(3.2.53)老代码跑同一套端到端判据:88 项失败
- 包内 PHP lint 96 个全过;zip 无 1980 时间戳(防 OPcache 误判)

## 发布

只写 theme-update/:zip + readme + update.json → 3.2.55。
线上主题目录仍是 3.2.54,等用户在后台点升级(红线)。
2026-10-27 13:00:37 +08:00
laoyang 4c1d67b86b 3.2.54 主题接管「文章分类」挂件:父子折叠树替代核心下拉框(#24)
原来侧栏那个分类挂件是 WP 核心的 WP_Widget_Categories,实例配置
dropdown=1,输出的是 <select> 下拉框。主题 CSS 里那套
`.widget_categories ul li` 只匹配 ul li,下拉框里没有 ul
⇒ 写了但一条都没生效。不是"没写样式",是样式和渲染形态对不上。

做法:沿用核心的 id_base `categories` 直接顶替(不是新建独立挂件)。
- option 名不变,_register_widgets() 照旧认领存量实例 categories-3
- 用户升级后什么都不用做:就地变新样式、位置不变、标题沿用
- 代价:update() 需容忍核心的 dropdown/hierarchical 老字段
  (不读也不显式删,用户重存表单时自然收敛掉)

呈现:父级常显 + 子级可折叠,用 <details>/<summary> 原生实现,零 JS。
父级链接放在 <summary> 里不会误触折叠 —— HTML 规范规定 <summary>
的"激活即折叠"只在点击目标非 interactive content 时触发。

显式 pad_counts:核心是在 show_count && hierarchical 时隐式打开的
(category-template.php:392)。不显式给的话父级数字 220→188,
用户会以为丢了 32 篇文章。

orderby=count 不交给 SQL:WP_Term_Query 翻成 ORDER BY tt.count
(原始值),而 pad_counts 是查询后由 PHP 补的 ⇒ 数字大的排在小的
后面。改成本地取回后按补过的 count 排,并做 strnatcasecmp tie-break。

分类名不 decode:实测 esc_html() 对 & 的输出恒为 &amp;,所以库里
存 & 还是存 &amp;,最终 HTML 字节一致、浏览器显示也一致。decode
是冗余且有多一层绕过风险。(建单时判断错了,此处自我纠正。)

删掉失效的 .widget_categories ul li 整套规则及 @media 里的宽度规则。

样式三个缺陷(线上真实页面量坐标才发现,不是看代码能看出来的):
1. 主题全局 ul>li{margin-bottom:6px;padding-left:2px;list-style:circle}
   (kratos.css:74) 未被重置 ⇒ 每行多 6px,8 行累积 42px 行距发虚
2. 同上 padding-left:2px 只覆盖了 :not(.has-children) ⇒ 带箭头行错位 2px
3. 有子级行的 li 里只有 <details> 一个 flex 子项,space-between 推不动;
   <details> 默认按内容收缩 ⇒ 数字右边界差 7.1px(实测
   [220.5,220.5,227.6,227.6,220.5])

验证:
- 端到端(真实 WP 加载待测副本)77 通过 / 0 失败
- 反证 12 组全部命中预期判据,无一组空跑
- CSS 视觉反向验证 3 组:三处缺陷逐个注回,全部被量出来
- 视觉几何 18 项全绿(行距/胶囊/对齐/留白/悬停/箭头)
- 用 3.2.53 老代码跑同一套判据:11 通过 / 57 失败

过程中修掉 5 条假判据(全在我自己的测试脚本里)。用老代码跑时它
居然"通过 16 条",逐条查全是假绿,根因同一个:否定式断言作用在
空靶上(与 pitfalls #424 同一族)。最典型的一条:
  /@media[^{]*\{[^}]*widget_categories/ 的 [^}]* 遇到块内第一个 }
  就截断 ⇒ 永远匹配不到 ⇒ 恒真。老代码 2354 行明明有那条规则。
改成按花括号配对切块后,老代码从「16 通过/50 失败」变「11/57」。

包 kratos_yang-3.2.54.zip:4783259 字节 / 237 成员 / 0 个 1980 时间戳
readme 45 个版本标题 · update.json → 3.2.54
服务器主题目录仍 3.2.53,未被覆盖
2026-10-27 12:18:53 +08:00
laoyang 75848f2ba2 3.2.53 后台小工具页恢复传统拖放面板(#23)
现象:用户在后台「外观 → 小工具」找不到可以拖动挂件的地方,看到的全是区块。

根因:WP 5.8 起后台小工具页默认走区块编辑器
(wp-admin/widgets-form-blocks.php),它没有「从左栏拖到右栏」这个动作,
只能点「+」再搜名字。本主题此前从未关掉它。

改动:
1. 关闭区块小工具编辑器,两道开关互为备份
   - add_filter('use_widgets_block_editor', '__return_false')
   - remove_theme_support('widgets-block-editor'),挂 after_setup_theme:20
     (核心 wp_setup_widgets_block_editor 挂在 priority 1 默认 add,
       所以 remove 必须晚于它,否则静默失败)
2. 4 个挂件(ad/about/tags/posts)构造函数的名字改为显式传第 2 参

关于第 2 点的性质说明(重要,避免误读成"修了缺陷"):
原先写 parent::__construct(false, false, $widget_ops) 时,
$widget->name 确实是 false,但后台列表/搜索读的**不是**它,而是
$wp_registered_widgets[...]['name']。后者经
wp_register_sidebar_widget() 里的 array_merge($widget, $options)
被 $widget_ops['name'] 覆盖成真名字。
实测(老代码):$widget->name = false,
$wp_registered_widgets['ad-1']['name'] = '图片广告'。
所以挂件名字从来没空过,用户找不到挂件与名字无关,纯粹是区块编辑器没有拖放。
第 2 点属于可读性清理(不再依赖隐式覆盖),不是用户可见缺陷的修复。

id_base 全部保持原值(ad/about/tags/posts),已有挂件实例不会丢。

验证:
- 真实 WP 7.1.3 加载待发布包,页面级 28 条 + 元数据 50 条判据全绿
- 老代码跑同一套判据:仅区块开关相关失败,名字类判据全部通过(印证上述判断)
- 9 组反证全部真红(含「只去掉一道开关仍应全绿」的冗余性验证)
- 96 个 PHP 文件全量 lint 通过
2026-10-27 09:21:02 +08:00
laoyang baa1d16fd1 3.2.52: 新增「最近评论」挂件(替代 Comments Widget Plus 插件)#22
主题自带一个好看的最近评论挂件,用户不必再装插件。

新挂件 widget_recent_comments(id_base = yang_recent_comments):
- 头像 40px 圆形 + object-fit:cover,带 max-width/max-height 双保险
- 摘要按字符截断(mb_substr),先剥标签再压空白,不走 wp_html_excerpt(按字节切半个汉字)
- 作者 · 相对时间 · 文章标题三段各自成 span,不拼 i18n 句式
- 三层字号:摘要 15px / 作者 14px / 文章 13px(文章字号继承自 meta)
- 条间虚线,最后一条去掉
- 后台九项设置:标题、条数、头像、头像尺寸、摘要、摘要字数、排除 pingback、评论类型、文章类型

实现要点:
- CSS 不依赖 .k-main .sidebar 祖先,只依到 .w-comments 自身
  (3.2.51 的红线:带祖先的选择器在后台「外观→小工具」预览里一条不生效)
- .w-comments 自己不写 padding,交给 widget 基类,避免与其它挂件错位
- 只查 status=approve 且 post_status=publish,草稿文章评论不露出

同时修一个前后台不一致的 bug:
- form() 的 post_type 原来走 pick(),pick() 把空串当"没填"回落 'post'
  ⇒ 老实例(存空串)在后台下拉框显示「文章」,前台却按「全部类型」渲染。
  改为 widget() 与 form() 都直接读原值,两处都识别空串/any 为「全部类型」。

验证:行为 112/0 · 行为反证 25/25 + CSS 10/10 · 几何 50/0 ·
线上真实页面 30/0 · 静态反证 23/0 · 发布脚本 22 组反证全真红。
2026-10-27 08:40:53 +08:00
laoyang c1dde099d7 「个人简介」挂件:头像/昵称/简介/背景四项可单独填 + 修大图撑爆 #21
一、头像 / 昵称 / 简介原来**只读主题设置**,挂件表单里没有字段
  · 用户点开侧栏「个人简介」只能改背景图,头像和那句话得跑去
    「主题设置 → 站长信息」改全站共用的那份。
  · 现在四项都是挂件实例字段:avatar / nickname / introduce / background。
  · 取值顺序:实例 → 主题设置 → 内置兜底,**留空即回落**,
    老站点升级后显示一点不变。
  · pick() 判空统一用「trim 后是不是空串」,不用 empty()
    (!empty('0') 是 false,昵称就叫「0」会被当没填)。
  · 简介用 sanitize_textarea_field(保留换行),URL 用 esc_url_raw
    (不做 HTML 实体化,避免与 esc_attr 双重转义)。

二、后台 form() 补齐五个字段
  · 头像 / 背景各带「选择图片」按钮(调媒体库),背景另有「清除」。
  · 输入框显示用户填的原始值,缩略图显示前台最终会用的那张,刻意分开。
  · 资源加载统一到 yang_widget_admin_assets(),不再每个挂件各挂一次。

三、大图撑爆挂件的真因:CSS 选择器依赖三层祖先
  · 老规则是 `.k-main .sidebar .w-about .wrapper img`,而后台
    「外观 → 小工具」预览里没有 `.k-main .sidebar` 这层祖先,
    规则一条都不生效 ⇒ 头像按原图 2136×2136 铺开、背景塌成 0。
    前台看着正常是因为前台恰好有那层祖先,问题被完全掩盖。
  · 改为只管 `.w-about` 内部元素,并补 max-width/max-height 双保险。
  · 老规则必须**删掉**而不是并存:它是 (0,3,1),新规则是 (0,2,1),
    并存时老规则在前台永远赢,新规则只对后台生效。
  · 保留 `.k-main .sidebar .w-about { padding:0 }`:通用规则
    `.k-main .sidebar .widget` 是 (0,3,0),少了这条会多出内边距、整体高 57px。
  · 头像保持 inline(display:block 会让它脱离 text-align:center 的居中)。
  · 顺带:object-fit 从 fill 改 cover(超宽图不再压扁);
    背景 url() 补引号(文件名带空格时不再整条声明作废)。

验证
  · 行为测试 67 条全通过;反向验证 13 组全部真红。
  · 浏览器几何 76 条全通过(前台/后台 × 四种极端原图)。
  · 修复前 CSS 跑同一套判据:38 条报红,精确复现用户症状
    (后台挂件高 2217、头像 2136×2136、横向滚动 2146)。
  · 线上真实页面 + 新版 CSS:16 条全通过。
2026-10-26 23:51:20 +08:00
laoyang 8367231e91 侧栏挂件一致性:标题可配、标签列数、重复 id、URL 转义 (3.2.50) #20
一并修掉上一轮列出的 5 处侧栏挂件遗留问题:

1. 标题不读 $args(4 个挂件标题在后台改不了)
   新增 yang_widget_title($args, $instance, $fallback, $id_base),5 个挂件
   统一走它:读 $instance['title'] → 空则回落 $fallback → 过 widget_title
   过滤器 → 输出用 $args['before_title']/after_title → 仍为空则整个节点不输出。
   四个挂件的 update() 补 title,form() 补输入框 + 「留空则不显示标题」说明。
   widget_tags 留空回落「标签聚合」,老站点显示不变。

2. 标签列数随屏宽反转(1440 一列、1180 反而两列)
   逐视口实测:真实断点在 1311→1310,@media(max-width:1310px) 只把格子压成
   110px 而不改侧栏宽度(侧栏 992px 以上恒为容器 1/3),于是格子变小反而能
   塞下 3 个。改为 .w-tags .item a / .widget_links ul li 用
   width: calc(50% - 2px) + 父容器 font-size:0(消掉 inline-block 之间的
   空白宽度),并在 1310 断点里删掉这两条 110px 规则(分类/归档的 110px
   不是网格,保留)。判据守的是「列数单调不增」而不是「某视口恰好两列」。

3. 重复 id nav-tab(桌面一份、手机一份)
   两份导航合并为一份,id 全部带实例后缀:yang_widget_uid($this) 取
   $widget->id(如 widget_posts-2),输出 {uid}-tab / {uid}-new 等,
   aria-controls / aria-labelledby 同步指向真实 id,两实例并用也不撞。

4. 搜索框固定 id(searchform / search / searchsubmit)
   同样改为 {uid}-form / {uid}-field / {uid}-submit,并补
   <label class="sr-only" for="{uid}-field">搜索</label>。

5. 图片 URL 未加引号 + 未转义
   src/href 一律加引号 + esc_url(),alt 用 wp_strip_all_tags + esc_attr,
   简介用 nl2br(esc_html())。背景图踩了一个自己写出来的坑:一开始写成
   esc_url() 套在 esc_attr() 里 = 双重转义,& 变 &amp;amp;,CSS 里拿到
   字面量 amp,背景图 404。改为 url() 内部用 esc_url_raw()(只做协议/字符
   清洗,不入实体),整个 style 属性交给 esc_attr() 统一转义一次。

顺带把侧栏标签页图标在窄屏隐藏(<1200px),避免三个 tab 挤在一起。

验证:
- _widgets_consistency.php        68 通过 / 0 失败
- _widgets_consistency_falsify.php 24 通过 / 0 失败(基线1·自检3·注入20)
- _widgets_hot_test.php           33 / 0
- _widgets_hot_falsify.php        20 / 0
- _widgets_hot_geom.js            24 / 0(真实浏览器几何)
- _widgets_ids_dom.js              5 / 0(真实 DOM 查重复 id)
- _widgets_visual20.js            30 / 0(像素级:图片 naturalWidth>0、
                                      标题装饰线、标签等宽、tab 点击)
- _widgets_icon_wide.js            7 / 0(断点精确在 1200)
- _widgets_tags_geom.js            5 / 0(列数单调性)
- 发布脚本 R1~R10 十组注入全部真红
2026-10-26 23:04:28 +08:00
laoyang d3070bea5b 3.2.49: 修「文章聚合→热点」只显示一条(换全站累计热度算法) #19
根因不是排序,是候选池:旧 SQL 先圈「最近 30 天发布」再按评论排序取 6。
本站实测 30 天只有 1 篇 post(7 天 0 / 90 天 1 / 180 天 6 / 365 天 15 / 全站 967),
候选池只有 1 条,LIMIT 6 也只能返回 1 条。

改成全站累计热度,三项取对数加权:
  3*log10(views+1) + 5*log10(love+1) + 4*log10(comments+1)
· 取 log10:views 中位 4981/最大 56075,而 love 最大 40、评论最大 93,
  不压缩则 views 一项吃掉全部权重。
· 权重 3/5/4(不是通用 1/3/5):用通用值实测前 8 名 6 篇是 2009 年老随笔
  —— 评论 1.97*5=9.86 > views 4.75*1=4.75,退化成「评论数排行榜」。
· 有意不加时间衰减:HN/Reddit 那套是「新内容榜」,会把老文压到看不见,
  正好回到原问题。要时效榜是另一个功能。
· 仍限定 post_type='post'(用户要求只看文章,不看 page;
  ID 217「留言本」/522「博客时间」核查过都是 page,天然被挡掉)。

顺手修掉三处旧问题:
· 删 date_default_timezone_set("PRC") —— 它会改掉整个 PHP 请求的全局时区
· 标题进属性改 esc_attr()(旧代码裸拼,含双引号会截断 title 属性)
· get_posts 参数从 'numberposts=6 & offset=0' 字符串改成数组
  (字符串里那个空格让参数名变成 ' offset',offset=0 实际从未生效)

后台:「统计天数」输入框下线(它控制的就是那个缩死候选池的时间窗),
update() 不再保存 days,老实例残留值下次保存时丢掉。

验证:
· 主测试 33/0(分数用独立实现重算比对,不调用产品函数算期望值)
· 反向验证 20/0(基线 2 + 框架自检 2 + 注入 8 组×2)
  八组注入:时间窗加回 / 去 post_type / 去 log10 / 权重改 1-3-5 /
  标题退裸拼 / 全局时区加回 / 排序改升序 / 后台加回统计天数
· 真浏览器几何 22/0(1440+1180 两视口):6 条真画出来、宽度等于面板、
  图标存在、不重叠、长标题走省略号不溢出
· 发布反证 8/8、线上包校验 10/10
2026-10-26 22:30:02 +08:00
laoyang 492d8e11a6 3.2.48: 归档倒序/字号/底边对齐/共N篇 + 评论标题线等长 + 工具条分隔线统一 #18
工单 #18 八条诉求逐条落地:

1. 月份、日期、年份全部倒序(最近的在最上面)
   - PHP 侧 `krsort($years[$y]['months'])`(原来是 ksort)
   - JS 侧 `Object.keys(...).sort((a,b)=>b-a)` —— 对象键序对纯数字整数键
     会强制升序,服务端顺序传不过来,必须前端自己再排一次
   - 年份本来就是 krsort,日期本来就是倒序,本次只补月份

2. 年份字体再大一点
   - 原来 CSS 写的 22px **从来没有生效过**:`.yang-archives .ar-year` 是
     (0,2,0),被 `.k-main .details .article .content h3` 的 (0,4,1) 压死,
     线上实际是 18px
   - 改为带 `.k-main` 前缀(提到 (0,5,0))+ 26px + 700
   - `.ar-month-title` 同一个坑,一并修(写 14px/600,线上实际 16px/500)

3. 「共 N 篇」太高、比年份还高 → 下移与年份底边对齐
   - `.ar-head` 的 `align-items: center` → `flex-end`
   - `.ar-year` 的 margin 显式清零(kratos 的 `h1..h6 { margin: 0 0 30px 0 }`)
   - 窄屏媒体查询同步改,否则手机上又回到居中
   - 注意 `flex-end` 对的是 margin box,所以 margin 不清零等于没改

4. 每月后面的裸数字 → 「共 N 篇」
   - PHP:`sprintf(esc_html__('共 %s 篇'), number_format_i18n(...))`
   - JS:同一个文案键 `i18n.monthCount`,服务端输出到 `yangArchivesData`
   - 新增 `monthCount` 键,**没有复用** `totalFmt`(两处语义不同,
     以后要分别改文案时不至于互相牵连)

5. 上一年/下一年按钮看不清 → 与「写下你的评论」同款观感
   - 平时:`1px solid #cfe6fb` 浅蓝框 + 透明底 + `--yq-primary-dark` 蓝字
   - 悬停:主色实底 + 白字(`.ar-nav-year` / `.ar-nav-arrow` 也要跟白,
     否则图标还是蓝的在蓝底上看不见)
   - 禁用态保持灰(`is-disabled` + opacity .42),不能跟可用态一个样

6. 评论「评论者」下面的灰线太短 → 延长到与评论分隔线等长、右侧到头
   - 🔴 这里改过一次方案:一开始以为把 h3 `flex: 1 1 auto` 撑宽就行,
     实测发现 `.comments-head` 是 flex 行,右边还有「写下你的评论」按钮
     (flex: 0 0 auto),h3 撑满也只能长到按钮左边缘 —— 原理上到不了右端
     (实测 h3 110.5px / li 561.98px,差的 451px 就是按钮 + gap)
   - 最终方案:**线从 h3 搬到 `.comments-head` 容器上**,
     h3 显式 `border-bottom: none` 干掉 kratos 的 #f0f0f0,
     否则会出现一短一长两条线,比原来更难看
   - 容器那条规则**还必须带 `.k-main` 前缀**((0,5,0)),否则压不过
     kratos 的 `.comments .title`。发布判据当场抓到这一条:注释里写了
     「带 .k-main 前缀」,代码里漏了 —— 注释不能当证据

7. 工具条分隔细线不统一 → 5 个按钮 4 条线
   - 原来 B/I 之间没有线,其余有 ⇒ 现在统一
   - 写成「每条后面跟一条」会在末尾多出贴容器边缘的孤线,所以是 5 按钮 4 线

8. 清理无用 JS/CSS
   - 四轮查证结论:**没有可安全删除的**,代码一行未动
   - 排查过程已作为评论发到工单

测试:归档单测 113/0(反向 13 真红)、归档 e2e 112/0(反向 24 真红)、
评论几何 157/0(反向含新增 5 条,覆盖"线搬回标题""两处都画"两种错误形态)。
线上真实页面几何探针发布前 17 通过/16 失败(每条都对应上面某一条诉求),
发布后要求全绿。
2026-10-26 18:42:40 +08:00
laoyang db2e9c4230 3.2.47: 修 [Archives] 首字母大写不生效(WP 短代码标签区分大小写)#17
用户线上实测:页面 /blogtime 写 [Archives] 原样输出 <p>[Archives]</p>,
看起来像"短代码没装/被 markdown 插件转义",实际是标签名大小写对不上。

根因(内核证据,wp-includes/shortcodes.php):
  · get_shortcode_regex() 用 implode('|', array_map('preg_quote',$tagnames))
    拼正则,不做任何大小写归一化
  · do_shortcode() 的 preg_match_all('/'.get_shortcode_regex().'/') 没有 i 修饰符
  · do_shortcode_tag() 用 $m[2] 精确查 $shortcode_tags
  ⇒ add_shortcode('archives') 只匹配全小写,[Archives] 完全匹配不上。

线上实测(真实 WP 环境跑 do_shortcode):
  [archives]  长度=209239  解析=是
  [Archives]  长度=10      解析=否(原样返回)
  [ARCHIVES]  长度=10      解析=否(原样返回)

3.2.46 里我写的注释"WP 短代码标签大小写不敏感、内部统一 strtolower"
是错的,这一版一并改正。

改动:
  · inc/theme-archives.php
    - 同时注册 archives / Archives / ARCHIVES 三种写法
      (不为任意混合大小写膨胀正则,[aRcHiVeS] 刻意不支持,已加断言锁住)
    - 修短代码签名:$atts, $content, $tag —— 补上中间的 $content
      WP 通过 call_user_func($fn, $attr, $content, $tag) 传三个参数,
      漏掉 $content 会让 $tag 落到第 2 个形参上,实际收到空串,
      shortcode_atts_{$tag} 退化成无主的 shortcode_atts_
    - shortcode_atts 第 3 参数改为实际命中的 $tag(原来写死 'archives')
  · style.css 版本号 3.2.46 -> 3.2.47

测试也一并修(这才是本轮最值得记的):
  · _archives_unit.php 的 add_shortcode 桩原来写了 strtolower($tag),
    比真实 WP 宽容 ⇒ 产品坏着、94 条断言全绿。
    现在照抄内核不归一化,并新增真实的 do_shortcode 解析实现。
  · ⑪ 组从"查注册表有没有 archives 这个键"改成"拿真实写法解析一遍"
  · 新增断言:三种写法都渲染出 .yang-archives、
    混合大小写原样返回(刻意不支持)、未注册标签原样保留、
    shortcode_atts 收到的 tag 恰好是实际命中的那个
  · _archives_unit_falsify.py 新增 R5~R8 四条注入:
    R5 只注册小写(3.2.46 的错误形态)→ 红 10 条
    R6 大写注册指向不存在的回调       → 红 4 条
    R7 shortcode_atts 的 tag 写死小写 → 红 2 条
    R8 签名去掉中间的 $content        → 红 3 条

验证:
  2j 评论关闭零输出        通过  54 / 0 失败
  2k 评论关闭反向验证      真红  13 / 0 无效
  2l 归档单测              通过 109 / 0 失败(原 94,新增 15 条)
  2m 归档端到端            通过 109 / 0 失败
  2n 归档端到端反向        真红  21 / 0 无效
  2o 归档源码级反向        真红  10 / 0 无效(原 6,新增 4 条)
  lint  96 个 PHP 文件全过

线上验证(真实 WordPress,不是模拟):
  补注册前:只 ["archives"],[Archives] 原样返回
  补注册后:["archives","Archives","ARCHIVES"],三种写法长度全是 209239

发布:已推 3.2.47 到 /theme-update/,update.json 已落 3.2.47;
      服务器 wp-content/themes/kratos_yang/ 仍为 3.2.46(未覆盖,
      等用户在后台点升级)。
2026-10-26 14:04:12 +08:00
laoyang 7047ab3ece 3.2.46: 新增 [Archives] 归档短代码 + 评论关闭时整块零输出 #15
需求一:[Archives] 短代码 —— 归档式全站文章列表
  · 在文章/页面正文写一行 [Archives],该位置渲染全站文章的年月归档
  · 默认显示今年,按月份归类;每篇带浏览/评论/点赞三个数
  · 上下两排各有一组「上一年 / 下一年」按钮(四角),JS 切换不刷新页面
  · 地址栏同步 ?arc_year=,刷新与分享链接都能保持同一年
  · 只在用到短代码的页面才加载 assets/js|css/archives.js|css
  · 参数:year="2025" 指定首年;month="0" 不按月分组

需求二:评论关闭时评论区整块消失
  · comments.php 在**任何输出之前** `if (!comments_open()) return;`
    —— 零字节,不是空容器、不是 display:none、不留提示文字
  · 已有评论的老文章关评论后也不再显示(需求原话:正文之外什么都没有)
  · inc/theme-comments.php 的资源守卫与之同口径:关评论时不加载
    约 10KB 的 comments.css / comments.js
  · 删掉 .comments-closed 样式与两处「评论已关闭」提示分支

开发中实测抓到并修掉的三个真实 bug(都是"测试把期望值写对之后"才显形):
  · 年份标题 / 导航年份过了 number_format_i18n() ⇒ 输出「2,025」,
    而 JS 侧 String(year) 输出「2025」⇒ 首屏与切年后不一致
  · 月份标题同样过 number_format_i18n(),与 JS 侧 String(m) 口径不一致
  · yang_archives_render_nav() 把 ar-nav-top 写死,而下排也调它生成
    ⇒ 下排被叫成 top(视觉上被更高特异性规则压住,看不出问题)

验证:
  归档  _archives_unit.php        94 通过 / 0 失败
        _archives_e2e.js        109 通过 / 0 失败
        _archives_e2e_falsify.js 反证 25 真红 / 0 无效
        _archives_unit_falsify.py 反证 6 真红 / 0 无效
  评论  _comments_closed_test.php 54 通过 / 0 失败
        _comments_closed_falsify.py 反证 13 真红 / 0 无效
  回归  _test_comments.php        64 通过 / 0 失败
        _hl_unit_run.py          24 通过 / 0 失败
  全量  96 个 PHP 文件语法检查通过;包内 lint 通过
  发布  已推 3.2.46 到 /theme-update/,update.json 已落 3.2.46;
        服务器 wp-content/themes/kratos_yang/ 仍为 3.2.45(未覆盖)

另:把 _archives_unit.php 从 `__DIR__` 定位改为接受根目录参数,
    否则它无法登记进 verify_all.sh,只能手动跑(手动跑 = 长期不跑)。
    5 个新套件已登记进 verify_all.sh(2j ~ 2o)。
2026-10-26 13:25:51 +08:00
laoyang 9da5e9e2df 后台代码配色预览不上色 + donate.png 404 一并修复 #14
Bug 1:后台预览的示例代码没有高亮。三层原因叠在一起。
  ① yang_hl_admin_assets() 从未 enqueue hljs 本体,而 yang_hl_enqueue()
     开头 `if (is_admin() || is_feed()) return;` 挡掉后台
     ⇒ 后台 window.hljs 恒 undefined ⇒ recolor() 静默 return。
     卡片的边框/顶栏是 data-kind 驱动的纯 CSS,切深浅配色时照样会变,
     看起来像"预览在工作,只是代码没颜色"。
  ② init() 里 applyTheme(sel.value, true) 传 noRecolor=true ⇒ 首屏永不上色,
     只有手动拨下拉框才有颜色(正是报障的"下面的示例代码并没有高亮")。
  ③ recolor() 缺 hljs 时只是 return,控制台零输出 ⇒ 极难定位。

  修法:
  - inc/theme-highlight.php:后台 enqueue hljs 本体,并作为 preview 脚本的
    硬依赖(不靠 WP 的加载顺序碰运气);前台 yang_hl_enqueue() 仍挡后台。
  - assets/js/highlight-admin.js:删掉 noRecolor 这个"能否跳过核心动作"的
    参数(调用方迟早会用错);首屏与切换走同一条路径。
  - 缺 hljs / 上色失败时 console.error 出声,并给 code 打
    data-unhighlighted、给卡片打 data-hl-failed;CSS 画橙色虚线框 +
    顶栏文字,让失败在画面上也看得出来,不再是一坨单色字。
  - 配色样式加载主路径改为 setTimeout(once, 125),link.onload 只作加速。
    onload 三情形不触发:样式表已在缓存、link 移除后重插、jsdom 无网络。

Bug 2:后台设置页报 donate.png 404。
  库里 g_donate_alipay / g_donate_wechat 存的是旧主题目录名
  `.../themes/kratos-master/assets/img/donate.png` 的绝对 URL。前台走
  yang_option_image() 有 yang_image_url_is_stale() 兜底,后台
  class-options-media-uploader.php 直接 echo $value,绕过检测。
  修在渲染处:预览图与"浏览文件"href 走检测后的 $preview(带
  function_exists 兜底),输入框仍显示库里的原值,不偷偷改用户数据。

版本 3.2.44 → 3.2.45。
2026-10-26 12:20:33 +08:00
laoyang 03b0bc6cc1 3.2.44: 后台配色实时预览 + 全站代码块语言标注统一 #13
需求 A —— 主题设置里选「代码配色」时能当场看到效果:
- 新增 assets/css/highlight-admin.css:只负责预览区布局,不改代码块外观
- 新增 assets/js/highlight-admin.js:监听下拉框,删旧 link 再插新 link,
  回调里重新上色
- inc/theme-highlight.php:新增 yang_hl_admin_assets()/yang_hl_admin_preview_html()
- inc/theme-options.php:在配色下拉框后插入 type=info 的预览面板

几个关键取舍(都有实测依据):
- 切换配色必须真正删掉旧 <link>。20 套配色的规则是裸选择器
  (.hljs-keyword 之类),同场会互相压制,切换看起来像「没反应」。
- 预览复用前台同一份 CSS,另写一套必然与前台不一致。
- 不用 highlightElement(会在已上色元素上再套一层,颜色变脏),
  改为从 textContent 取原文重新 highlight。
- 实测 hljs 的 HTML 转义无损(392 字符逐字符可还原),所以不需要额外的
  data-raw 暂存;那是两份状态,不同步就是 bug 源头。已删该冗余。

需求 B —— 全站代码块语言标注统一(数据侧,不在本次 diff 里):
- 284 块 / 71 篇全部统一为 <pre lang="xxx">,清掉 Enlighter 私有标记与
  code 上的 language-* 类
- 关键发现:站内没有「完全无标注」的块(0 个),但有 87 块标注无效
  (text 47 / generic 28 / raw 8 / jsp 2 / latex 2)—— 问题不是没标,是标是假的
- 87 块逐块判定:bash 174、plaintext 45、yaml 13、ini 9、xml 8、python 8、
  javascript 7、php 6、json 5、java 5、ruby 2、sql 2
- 判定表优先级必须高于「沿用旧标注」:产品把 generic/raw/text 兜底成
  plaintext,而 plaintext 在白名单里,旧逻辑会走 keep 把判定表整批丢弃
  (第一版实测 87 条只有 4 条生效,且结果看起来是成功的)
- 同一篇文章里有 11 对块原文完全相同,str_replace 会全局替换导致整片标错;
  改为按文内序号 + 内容签名定位

验证:
- 后台预览 jsdom 测试 22/0,反向注入 6 组全部真红
- 语言/属性单测 44/0 与 24/0,语言反向 10/0
- 独立审计脚本(另一套扫描逻辑)全绿;反向 3/3 真红
  (去掉 lang / 改成 cobol / 塞回 enlighter 各自都能抓出来)
- 真实页面 4 篇:data-guess 全为 0,正文里已无「猜出来的」代码块
2026-10-26 11:37:42 +08:00
laoyang 8f8cffd69e 3.2.43: 修代码块重影与语言识别,含 4 个静默 bug (#12)
排查结论:不是插件冲突,对手是主题自己的 kratos.css。

现象一「重影/阴影」的真实成因是 CSS 优先级 + 行高错位:
kratos.css 的 .content code(0,4,1) / .content pre code(0,4,2) 抢走了
border-radius:4px、color:#333、font-size:13px。字号被顶到 13px 后
行高算成 21.06px,而行号槽是 12.5px/20.25px,每行错开 0.81px,
30 行累计 24px —— 视觉上就是「两层东西叠在一起」。

现象二「多一个 copy 按钮」未能复现:2 个页面实测每张卡恰好 1 个
.cb-copy;Prism.plugins 只有 autoloader,copy-to-clipboard 未注册;
Enlighter 插件目录不存在。已请用户指认页面。

修复:
- highlight.css 的选择器提到与原主题同级 (0,4,2),靠出现更晚取胜,
  不用 !important。
- 顺带修掉 4 个静默 bug(都不报错、页面看着正常):
  1. header.php 从未调 body_class(),裸 <body> ⇒ 所有挂 body_class
     过滤器的类全部静默失效,包括行号开关 opt-ln(选项已开却不显示)。
  2. 79 个代码块的语言写在 <pre lang=> 或 data-enlighter-language 上,
     只读 <code> class 必然读不到 ⇒ 标签显示「—」。
  3. 白名单 4 项指向未打包语言(powershell/dos/dockerfile/nginx)。
     hljs 遇到不认识的语言不抛错,只 warn 一句就退回不高亮 ⇒
     整块白板。站内这 4 种用量均为 0,属潜伏 bug。
  4. hljs 把 plaintext 块改写成 language-undefined;highlightAuto
     对纯文本返回字符串 'undefined',if (r.language) 会误当真值。

顺带收敛:yang_hl_languages() 原本手写第二份清单,与 yang_hl_norm_lang()
里的表互相漂移,改为从 yang_hl_known_map() 单一来源派生。

验证:
- 单测 44 + 24 断言全通过
- 反向验证 10 组注入全部变红(含专门注入「恢复手写清单」验证新判据)
- 全站 284 块直连数据库校验:568 通过 / 0 失败,
  语言覆盖率 71.8% → 98.6%,残留与污染全 0
- 剩余 4 块显示「—」是正确降级(jsp×2、latex×2 未打包进 hljs)
- 夹具对照:新版 0 失败 vs 全旧版 10 失败
2026-10-26 11:00:47 +08:00
laoyang da0d9ae4a1 代码高亮整套自研:10 浅 + 10 深配色、语言标签、评论区贴代码 (#11)
不再依赖第三方高亮插件,全部由主题自己实现。

主题配色(20 套)
- 浅色 10:github / vs / atom-one-light / idea / xcode /
  stackoverflow-light / intellij-light / googlecode / magula / color-brewer
- 深色 10:github-dark / atom-one-dark / monokai / github-dark-dimmed /
  a11y-dark / stackoverflow-dark / night-owl / srcery /
  panda-syntax-dark / tomorrow-night-blue
- 深色卡片带 data-kind="dark",顶栏跟着换深色底
- 只加载当前选中的一套(20 套全加载会让后出现的规则压前面的,
  切换配色容易"看起来没变")

代码语言标签(用户要求:防止复制错语言)
- 作者标了 language-xxx → 实线边框、正体
- 自动识别猜出来的 → 虚线边框、斜体
- 认不出来 → 显示 "—",仍保持虚线斜体(不冒充作者标注)

行号
- 默认关;行号槽在 <pre> 外面,复制天然不带行号
- 改掉原主题的 word-break:break-all(任意字符断行,代码像乱码)
- 归零 hljs 主题自带的 pre code.hljs{padding:1em},
  否则代码被压低 1em、与行号槽错位 15.5px

复制
- 默认开;安全上下文判断 + execCommand 退路(HTTP 下 clipboard 是 undefined)
- 取 textContent 而非 innerHTML
- 默认 opacity:0、hover 显示、触屏覆盖回 1
  (旧写法只在 @media(hover:none) 里写 1,等于空操作)

自动识别
- 做成选项,默认开
- 只对没标语言的块生效;站内实测长块准确率 22%,不能覆盖作者标注

评论区贴代码
- 评论工具条加"代码"按钮 + 29 种语言面板
- kses 放行 pre 并给 pre/code 放开 class
  (WP 默认 pre 整条剥掉、code 属性全清,此前根本贴不了代码)
- 放行 pre 之后 wpautop/wptexturize/make_clickable 自动让路,
  代码里的连续空行、引号、URL 都不会被改写
- comments.js 用占位符保护代码块,避免 walk() 的 /\n{3,}/ 压缩

其它
- 资源版本号改为读主题头,不再写死(写死会导致以后升级后
  浏览器一直用旧缓存,改了 CSS/JS 完全不生效)
- 删除 footer.php 里旧的 Prism 兜底(两套高亮器同页会互相打架)

验收
- 用线上 284 个真实代码块跑产品代码:284/284 全部变卡片,内容丢字 0、双转义 0
- 47 个 text 标记全部映射到 plaintext(hljs 无 text 语言,不映射会静默不上色)
- 80 个裸 <pre>(无 code 子元素)全部覆盖
- 核心纯函数 65/65;核心反向 10/10 变红
- 浏览器端到端 32/32;行号几何逐行对齐偏差 0.00px,几何反向 4/4 变红
- 端到端反向 7/7 变红;语言标签反向 2/2 变红
- 全量 PHP 语法 95 文件 0 错误

发布:走 theme-update 通道(zip + readme + update.json),
未覆盖服务器主题目录;线上主题目录仍是 3.2.41,待后台升级。
2026-10-26 09:00:17 +08:00
laoyang 1cf4267c18 fix(lightbox): 无附件 ID 的图片改用 img 的 data-full 作原图兜底
3.2.40 初版把「原图地址」只做了两级兜底:附件 ID → href。
`yang_lightbox_img_src()`(读 data-full / data-large-file /
data-orig-file)当时是**定义了但没人调用**的死代码,等于白写。

后果是这一类结构取不到原图:

    <a href="xxx-300x200.png"><img src="xxx-300x200.png" data-full="xxx.png">

三处都是缩略图,只有 data-full 里是真原图,且 img 没有 wp-image 类
—— 编辑器之外插入的图、部分插件输出的图常是这种形态。用户点开
看到的还是那张小图,会认为灯箱根本没起作用。

改成三级兜底:附件 ID → img 上的 data-full 等属性 → href。
第二级取到的值必须再过一次「像不像图片」,因为有些插件会在
data-full 里放页面地址,不验就会让灯箱加载出一个 HTML 页面。

守卫补在服务端探针 B2 段(4 条,直接看输出里的 href,不看函数里
有没有出现那个字符串),并新增两条反向注入:
S12 砍掉第二级兜底、S13 去掉图片校验。同时更新 S2 的注入锚点 ——
它原来只删第一级,会被第二级救回来,注入等于没做。

Refs #10
2026-10-25 22:17:45 +08:00
laoyang 7125fdd3ce feat(lightbox): 正文图片点击在当前页放大、多图轮播、键盘切换
新增主题自带的图片灯箱,替代「点图片跳新标签页」的旧行为,
不再依赖第三方灯箱插件。

主要能力
· 正文里的图片链接改写成「在当前页覆盖展示原图」,不跳转
· 原图优先按附件 ID 取 full size(wp_get_attachment_image_src),
  取不到才退回 href 自己 —— 不在前端从缩略图文件名反推原图地址
· 原图地址写在 href 里而不是 data-full:主题的 CDN 是整页输出缓冲
  改写,只认 src= / href= 与 CSS url(),写 data-* 会绕回源站
· 多图左右箭头轮播(到头循环),键盘 ←↑ / →↓ 切换、Esc 关闭、
  Home / End 跳首尾,点空白关闭,打开时锁滚动并在关闭后归还焦点
· 两套遮罩配色(白色默认 / 深色)与九档不透明度(默认 80%)
· 图片本身始终保持不透明:透明度只作用在遮罩底色上

兼容性处理
· 剥掉第三方灯箱插件留下的触发属性(data-rel / data-imagelightbox /
  data-rl_title 等)。线上实测 100 篇文章 112 张图**全部**带这些属性,
  若只「让给插件」则主题灯箱永不生效;同时关掉开关时一律不动,
  插件可照常工作
· 移除 imgnofollow() 给图片链接加的 target="_blank" 与 rel,
  链接不再承担跳转职责
· 无 JS 时 href 仍在,右键「在新标签页打开图片」照常可用

可读性
· 半透明遮罩下底部说明条自带更实的底(≥0.9),并按「遮罩底下是
  纯黑」的最坏情况验证文字对比度 ≥ 4.5:1
· 行内不透明度样式用 [data-theme] 选择器,避免被样式表默认值压掉
  (裸类名优先级不足,会让深色配色下透明度静默失效)

验证
· 服务端正文改写探针 51 通过 / 0 失败(PHP 8.4 与 8.5)
· 浏览器真实交互探针 200 通过 / 0 失败(两配色 × 80%,含 50%/100%)
· 上述两者的反向验证 21 项注入全部按预期捕获
· 三个改动文件 php -l 通过

Refs #10
2026-10-25 22:05:24 +08:00
laoyang 203721b1fe fix(avatar): 注册用户保留原头像;人脸风格肤色只留黄种人区间
#8 邮箱能在本站用户表查到的,一律用他自己的头像,不再替换
   · 新增 yang_avatar_is_registered_email():get_user_by('email') 查表,
     邮箱先 trim + strtolower 规范化(导入用户/插件写库/老数据都有大小写混用)
   · 加请求内 static $memo 缓存:评论列表一页 20 条、同一个人常有 2~3 条,
     不缓存就是同一个邮箱反复打库(实测 20 次 → 1 次)
   · 新增 yang_avatar_keep_member() 读开关,运行时默认 '1'
   · 过滤器在拼 data: URI 之前拦截,命中即原样 return $avatar
   · 后台新增「注册用户保留原头像」复选框
   · 关于「注册用户自己也没有真 Gravatar」:WP 的 get_avatar() 从不检查图上
     是否存在,只拼 URL 交给浏览器;要分辨必须向 gravatar.com 发请求,
     而本功能坚持零外网调用(发布脚本会断言)。因此做成开关、默认保留,
     这类用户保持灰人像、不猜。

#9 肤色从 7 档收窄到 4 档,只留最浅到偏黄
   · 移除 d9a066(偏深)、c68642(深棕)、8d5524(最深)
   · 安全性依据:Prng::pick() 用 integer(0, count-1) 取索引,只消耗一次
     next(),与区间跨度无关 ⇒ 缩短数组不改变 PRNG 消耗次数,发型/衣着/
     表情/背景抽到的结果一点不变,只有肤色跟着变
     (已用 _v39_isolate.php 逐字节证明:7 色与 4 色渲染除肤色外完全相同)

顺带修掉一个老 bug:baseColor 错当肤色
   · bottts 的 baseColor 是机器人外壳色(19 种彩色),映射成肤色后外壳被
     涂成肉色 e8b98f / f1c27d。从 $map 里去掉 'baseColor' => 'skinColor'
   · 「base」这名字像是底色,但各家风格语义并不统一,不能按名字猜

开关位置(踩过的坑)
   · 新开关必须排在「头像风格」之前。风格列表是 18 张带缩略图的卡片、
     实测高约 2100px,排在它后面的控件会落到 y≈2327px,而设置页视口
     约 1000px —— 用户不滚三屏根本看不到。3.2.36 就是栽在这里。
     挪到之前后实测 y=216px、首屏可见。

验证
   _v39_probe.php              5 / 0   肤色收窄 + bottts 修复
   _v39_isolate.php           50 / 0   「只改肤色、其它不变」逐字节隔离证明
   _v39_member.php            19 / 0   注册用户判据(含缓存次数精确断言)
   _v39_falsify.py              8 / 8   上述判据反向
   _guard337_layout.py        25 / 0   布局守卫(新增开关位置三项)
   _guard337_layout_falsify.py  5 / 5
   _guard336_avatar.py        55 / 0   源码守卫
   _guard336_falsify.py       17 / 17
   _geo337_avatar_reach.py    34 / 0   真实几何 + 显隐交互
   _smoke18.py                18 / 0   18 套风格全量冒烟
   _verify4_prod.php          80 / 0   逐字节对官方(对配色无感,见 docblock)
   _pkgcheck339.py            52 / 0   包内内容契约
   _pkgcheck339_falsify.py    20 / 20  包内断言反向
   回归 88/0、56/0、77/0、13/0;93 个 php -l 全通过
   PHP 8.4.25 与 8.5.12 严格错误级别均 0 警告

Refs #8 #9
2026-10-25 20:19:27 +08:00
laoyang 16cc7d8328 3.2.38 自动头像补 4 套风格:avataaars / bottts 及其带底色版 #7
上一版只收录 14 套,把 avataaars、bottts 两个系列漏掉了。原因是 3.2.36
盘点时,这四套的 npm 包里 license 字段写的是「See LICENSE file」,被我
当成「措辞不明、先不收」。点进去看实际条款是
「Free for personal and commercial use」(设计者 Pablo Stanley,
来源 avataaars.com / bottts.com),而且不要求署名 —— 比收录的 CC BY
那十几套还宽松。方向判断反了,这是错误,不是许可限制。

改动:
- 新增 inc/avatar-styles/ 四份模板:avataaars、avataaars_neutral、
  bottts、bottts_neutral(合计 227 KB,风格总数 14 → 18)
- yang_avatar_styles() 注册四套中文名与说明,排在「首字母」之后
- avataaars 的 onPostCreate 三条规则逐条复刻:
  ① style 组为 circle 时清空背景;
  ② 背景恰好只剩 65c9ff 一项时清空(判「恰好只有一个」,不是「包含」);
  ③ 戴头巾且胡子概率非 100 时去掉胡子。
  为此 yang_avatar_post_create() 增加 $picked 形参(官方读
  components.style.name / components.top.name,不传拿不到),
  返回值由 2 项改为 3 项($on 会被改,必须带出来)。
- 后台风格说明由「都是 CC0 或 MIT 许可」改为「都可自由使用与商用,
  且不要求署名」—— 加了这四套后前者已不准确
- 文件头许可清单补上「作者自定义许可」一类

验证(先证明判据真能红,再谈通过):
- 逐字节 80/0:四套各 20 种种子(含中文、emoji、超长、空串)比对官方
  Node 库。第一版 avataaars 20 个全错,差异正是背景那块天蓝,补规则后对齐
- 逐字节反向 3/3:删分支、不清 65c9ff、返回值退 2 项,各红 20 项
- 规则白盒 9/0:circle / 恰好 65c9ff / 多色保留 / 含 65c9ff 不只一项保留 /
  头巾 10% 删胡子 / 头巾 100% 保留胡子 / 不戴头巾不受影响 / 别风格不受影响 /
  返回 3 项
- 规则反向 6/6:写坏任一分支,都恰好在预期那条上报错
- 原有 14 套回归 108/0(原 84 + 新 24)
- 18 套全量冒烟:每套合法 SVG、三种种子互异
- 严格错误级别 PHP 8.4.25 与 8.5.12(线上同版)各 47/0
- 几何+交互 29/0:18 卡片、18 单选、18 缩略图全部解码
- 源码守卫 55/0,反向 17/17
- 包内契约 34/0,反向 8/8
- 后台标签 88/0、CDN 56/0 与 77/0、3.2.28 13/0
- 93 个 php 文件 php -l 全通过

顺带修正两处错误的守卫(都是这次暴露出来的):
- _guard336_avatar.py 把 avataaars / bottts 错列进「CC BY 禁止收录」
  清单,改按实测许可证重写(CC BY 实为 13 套,不含这两个)
- _guard336_falsify.py 的 F2 锚点仍在匹配旧名「自动头像」,3.2.37
  改名后恒失效;版本锚点也停在上一个版本
2026-10-25 19:41:49 +08:00
laoyang 5c7a9e2c1c 3.2.37 把「自动头像」提成独立标签页,排在全站配置之后 #6
3.2.36 上线后反馈「后台主题设置没有看到头像这个选项」。逐项排查确认
功能是好的:服务器文件已更新(style.css 3.2.36、theme-options.php md5
与本地一致、avatar-styles 14 份齐全),真实后台 HTML 里 a_auto_enable
出现 5 次、of-avatar-style 97 次,CSS/JS 全部下发,无头浏览器点开开关
后 14 张卡片、14 个单选、缩略图全部出图。

真正的原因是位置:它排在「全站配置」的第 9 项,纵坐标 1058px,而浏览器
视口 1000px 高,前面 8 项(其中「404 页面图片」一项就占 301px)把它顶到
首屏之外,不往下滚看不见;左侧 12 个标签页也没有任何「头像」字样,光扫
标签栏同样摸不着。

改动:
- 把两个选项从「全站配置」里搬出来,改成独立 heading 分组,插在
  「全站配置」之后、「顶部导航」之前,成为第 2 个标签页
- 开关显示名从「自动头像」改为「启用自动头像」,避免与标签页同名、
  「勾选上面的自动头像」这类指代说不清
- 分组注释序号全部重排为 1..13 连续

现在标签栏第 2 个就是「自动头像」,进去第一项就是开关(组内前面 0 个
控件,纵坐标 134px)。

theme-avatar.php 一行没动:它的显隐 CSS 与 JS 全部按 section id 定位
(#section-a_auto_style / getElementById('a_auto_enable')),不依赖分组
序号,搬位置不受影响。

验证:
- 分组结构 17/0、几何+交互 29/0(视口 1600x1000,正是上一版把它顶出
  首屏的高度)
- 几何反向 6 项被捕获(主判据用「开关是所在分组第 1 项」,与像素无关)
- 源码守卫 20/0,反向 12/12 全部被捕获
- 包内契约 30/0,反向 8/8 全部被捕获
- 逐字节渲染 84/0,无 mbstring(php -n)84/0
- 严格错误级别下 PHP 8.4.25 与 8.5.12(线上同版)各 43/0
- 回归:后台标签 88/0、3.2.36 头像守卫 55/0、3.2.36 包契约 24/0、
  CDN 56/0 与 77/0、3.2.28 13/0
- 89 个 php 文件 php -l 全通过
2026-10-25 19:18:21 +08:00
laoyang 3fcaf25c58 3.2.36 新增「自动头像」:按邮箱本地生成,14 套 CC0/MIT 风格可选 #5
为没有头像的访客按邮箱确定性地生成一张 SVG 头像,替代 Gravatar
那个灰底"神秘人"(cn.gravatar.com 在国内已超时,不勾加速时头像
干脆是空白)。全程纯 PHP,零外网请求、零落盘。

后台「主题设置」新增「自动头像」复选框 + 风格列表:勾上之后才
出现 14 套风格,点一下即时换 16 格预览,保存后全站生效。

实现要点(都是踩出来的):
- PRNG 必须与 DiceBear 9.x 逐位一致:xorshift + hashSeed 全程跑在
  JS 的 int32 语义里,PHP 64 位整数要逐步 i32() 归一;
  charCodeAt 按 UTF-16 码元取值,emoji 必须真做代理对拆分。
- pickComponent 有简繁两版:只有 glass/rings/shapes/thumbs 额外取
  三次整数(rotation/offsetX/offsetY)。给简版也补三次 ⇒ PRNG 多走
  3N 步 ⇒ 31 套里 27 套全错。编译期算 pc_has_ints 才治得住。
- 自带人类调色板:官方默认配色几乎全单值,30 个邮箱只出 2 种配色,
  那才是"默认头像丑"的真因,光换画风解决不了。
- 只收录 CC0/MIT(13 CC0 + 1 MIT),CC BY 那 13 套要求署名,一律不收——
  主题装给用户用,用户不会维护署名,收进来等于转嫁合规义务。

接入 get_avatar 的两个关键修正:
- 必须先看原 src 是不是 Gravatar 家族域名再决定是否替换。原先无条件
  替换会把媒体库头像、外链头像、别的插件处理过的 data: 头像一起盖掉。
- 必须删 srcset。只改 src 留着 srcset,浏览器优先用 srcset,
  生成的图根本不会被加载(现象是"代码明明换了,页面还是老样子"),
  且不能重建 <img> 标签(会丢掉评论头像 CSS 依赖的 class)。

后台三处必须这么做:
- body class 由 PHP 输出(admin_body_class),否则每次进设置页/保存后
  重载都会先显示风格列表再藏起来,闪一下。
- 事件监听 radio 的 change,不能监听 label 的 click —— label 会把
  click 转发给关联 input,冒泡回来再触发一次 ⇒ 同一风格发两次请求
  (实测 hits 2→4)。
- 首屏已渲染的 16 格写进 JS 缓存,否则点一圈回到默认风格会白请求一次。

不依赖任何可选扩展:不用 mbstring(未安装时 mb_substr 是 Fatal error,
整站白屏)、不用 gd/imagick。UTF-8 切分/截断/大写全部自己实现。
PHP 7.0 语法,PHP 8.5 下 E_ALL 无任何 warning。

验证:
- 14 套 × 6 种子 = 84 个用例与 DiceBear 官方 Node 输出逐字节一致,
  PHP 8.4 / 8.5 / 无 mbstring(php -n)三种环境均 84/0。
- 静态守卫 55/0;反向验证 17 项注入全部被捕获。
- 后台面板浏览器验证 26/0(显隐、几何、点选、缓存、连点、零报错)。
- 包内内容契约 24/0;包校验反向验证 6 项注入全部被捕获。
- 单次渲染稳态 0.047~0.105ms,峰值内存 2~4MB。
2026-10-25 18:38:11 +08:00
laoyang adcf80af62 3.2.35 CDN 加速加启用开关 + 预制白/黑名单,勾了不填地址拒绝保存 #4
「启用 CDN 加速」复选框放在 CDN 段最前。不勾时下面填什么都不产生作用,
前台与没装该功能完全一致(三个入口 should_rewrite / rewrite_html /
buffer_start 都过 yang_cdn_active())。

预制名单写进输入框的 std,用户可直接增删改,保存后以用户内容为准:
  白名单 13 项(.avif .css .gif .jpeg .jpg .js .json .mp3 .mp4 .pdf
  .png .svg .webp);黑名单 13 项安全后缀(.php* .phtml .phar .env
  .sql .bak .log .ini .sh .htaccess)。同时 yang_cdn_defaults() 给同一份
  —— std 在运行时完全不生效,两处不同源就会出现「后台 13 项、前端 0 项」。

勾了开关但地址留空:保存被整单拒绝(validate_options 返回旧值),并
add_settings_error 提示。回调里绝不 update_option(会递归触发
sanitize_option_kratos,3.2.11 吃爆内存的路径)。另加 admin_init 进页面
提示,救「勾着但地址空」的老站点。

开关默认值按「地址是否非空」推算,老站点升级不丢加速;没填过地址的站点
默认不勾,避免第一次点保存就被「必须填地址」拦住。

顺带修掉 parse_url 对主机过于宽容导致的畸形地址(https://not a url at all
曾被当合法,会把全站静态资源拼成畸形地址)。

验证:_cdn335_logic.php 116/0 · _cdn335_falsify.php 22/0 ·
_admin335_cdn_check.py 74/0 · _admin335_falsify.py 23/0 ·
_pkgcheck335.py 117/0 · _pkgcheck335_falsify.py 27/0 ·
_cdn334_logic.php 92/0(向后兼容)· _cdn334_falsify.php 21/0
2026-10-25 17:10:06 +08:00
laoyang 40e09b6309 3.2.34 新增「CDN 加速」:地址 + 白名单 + 黑名单,黑名单优先 #3
后台「主题设置」新增第十一个标签页(邮件通知之后、自定义代码之前),
只有三个输入框:CDN 地址、白名单、黑名单。地址留空即关闭功能。

实现(inc/theme-cdn.php,新增):
- 输出缓冲挂在 template_redirect 优先级 1,改整页 HTML 的资源地址,
  覆盖主题 enqueue 之外的文章图片、插件输出、区块图片。
- 规则按字面形态自动识别七种写法:* / 扩展名 / 扩展名列表 /
  路径前缀 / 通配符 / 路径子串 / ~正则~。
- 单个纯字母词(jpg 与 uploads 分不出谁是扩展名)同时按扩展名与
  路径子串匹配,两个含义互斥,避免 uploads 规则静默失效。
- 黑名单先于白名单判定;硬性排除不受两套名单影响:以 / 结尾、
  页面/动态后缀、无扩展名、wp-admin/wp-json/wp-cron/xmlrpc/feed、
  非本站域名、非 http(s) 协议。
- 后台/REST/AJAX/feed/cron/定制器预览/CLI 一律不改写。
- 查询串与锚点原样保留(?ver= 丢了会一直吃旧缓存)。
- 子目录站:判规则用完整路径,拼 CDN 时剥掉站点子目录。
- 写错的正则编译时丢弃,避免每个资源抛一条 warning 刷满日志。

验证:
- inc/theme-cdn.php 逻辑探针 92/0,反向验证 21/0(M1~M15 逐条注入,
  含 M5 拆成三条覆盖协议相对/绝对/整页三条分支,M10/M13 组合注入)。
- 后台探针 56/0(真实渲染 + 几何 + 类型 + 说明块),反向验证 15/0。
- 包契约 69/0,反向验证 23/0(改 zip 内字节,不改源码)。
- 旧探针回归:toc330 101/0、admin330 88/0、admin328 13/0、
  pkgcheck331/332/333 全绿、e2e322/323 通过。
- 线上仅在 theme-update/ 发 zip + readme + update.json,未解包覆盖服务器主题目录。
2026-10-25 16:15:09 +08:00
laoyang e47bf37966 3.2.33 后台「主题设置」两栏顶边对齐 (#2)
用户反馈:「左侧标签栏和右侧设置框两个也不一样高,有一个高度差,看着
很别扭。」经测量纠正:不是高度不一样,是**顶边**位置不同 —— 左栏白
卡片 top=53、右栏白卡片 top=63,差 10px,左边高、右边低。

根因不在主题里,在 WordPress 后台自己的样式:
  #wpbody-content .metabox-holder { padding-top: 10px }
这条是给「一路往下竖排的 metabox」留顶部间距用的。3.2.24 把标签列
float 到左边、变成两栏并排之后,这 10px 就没人跟它配对了:左栏
(h2.nav-tab-wrapper) 不带这层 padding,右栏 (#optionsframework-metabox)
还带着,于是右栏被压低 10px。

修法:两栏并排时把这 10px 清零。
  #optionsframework-wrap > #optionsframework-metabox { padding-top: 0 }
只在 @media (min-width: 960px) 里清 —— 窄屏仍是标签横排在上一路往下,
那 10px 是该留的间距,不动。

没有采用「给左栏补 10px」的凑齐改法:那样左栏卡片内部会多出一段看不
见的空白,标签整体下移,问题只是从「两栏错位」变成「左栏内部偏空」。

底边仍不齐(左栏 467 固定、右栏 366~1276 随标签变),这是另一个问题,
本轮按用户界定不做,等确认后另开一单。

验证:
- _geo333_top.py        18/0(三档宽屏顶边差 0px;逐标签切 11 次顶边不变;
                        窄屏 700px 仍保留那 10px)
- _geo333_top_falsify.py 7/0(删规则/写成10px/补左栏padding/挪出媒体查询/
                        额外 margin/等价重写 0→0px,六种注入全部红对地方)
- _pkgcheck333.py       77/0 · _pkgcheck333_falsify.py 10/0
                        (C5 抓出真盲点:'float: left' in css 会命中别处,
                         判据改为锚在左栏块内部)
- 回归全绿:_admin330_tab_check 88/0 · _admin328_check 13/0 · _guard330 63/0
            _toc330_logic 101/0 · _test_toc 53/0 · _geo330_rail 73/0
            _geo331_inline 23/0,及对应六组 falsify 全部变红且红对地方
- 公网核验:md5 319d098f2fa5c5fb2764f25b8d55936c 本地/下载一致;
            包内 Version=3.2.33;padding-top:0 出现 1 次且在 960 媒体查询内;
            readme 24 条版本标题、历史 23 条全保留、ul/li/b/code 全配平
2026-10-25 15:36:55 +08:00
laoyang 71f8a733f5 后台把「正文目录」和「浮动目录」分成两块(#1)
浮动目录本来就没有手动/自动之分:它不读正文,没有地方写 [TOC],
要么显示要么不显示。代码层面早已如此(rail 只由 float 决定),
真正的问题是后台十项排成一列,「添加方式」「自动插入位置」跟在
浮动选项后面,看着像两种目录共用的设置。

- 新增三个 info 类型分组小标题:正文里的目录 / 浮动目录 /
  两份目录共用的设置。用 info 而不是 heading —— heading 会另起
  标签页,info 只渲染成一行 h4 + 说明,不进标签页菜单、不生成表单字段。
- 选项按归属重新分段:正文段(toc_inline / inline_align / mode /
  anchor)、浮动段(toc_float / float_side)、共用段(default /
  title / levels / numbering)。顺序变了,但选项 id 与存库键不变。
- 「添加方式」说明收窄为只管正文那一份;「浮动显示」说明补上
  「它没有手动/自动之分」;「自动插入位置」清掉过时的
  「目录位置 = 正文中间」字样。
- 顺带修掉「自动插入位置」控件被定义两遍(后台会渲染两个同名下拉)。

验证:guard330 63/0、其 falsify 15/15(新增 V37~V41 守三段归属)、
admin330_tab_check 88/0(新增分组几何判据)、其 falsify 16/16、
toc 逻辑 101/0 与 53/0、rail 几何 73/0、inline 几何 23/0、
包校验 69/0、其 falsify 13/13。
前台零变化:本次只动后台分组与文案,未改任何渲染逻辑。
2026-10-25 14:36:19 +08:00
laoyang 4a2d62b1aa 3.2.31 目录「正文内显示」与「浮动显示」拆成两个独立开关(#1)
原来「目录位置」是一个三选一下拉:正文中间 / 页面左侧 / 页面右侧,
等于把「放进正文」和「浮在页边」当成互斥的一件事 —— 想同时在正文
开头放一份、又在边上浮一份,做不到。

改成两个独立勾选,各自带子选项:

  · 在正文中显示 → 正文中的对齐(靠左 / 靠右)+ 自动插入位置
  · 浮动显示     → 浮动方向(页面左侧 / 页面右侧)

两个都勾就同时出现两份,只勾一个就只出现一种。四种组合都在后台
联动里逐个验证过(探针 70/0)。

新增能力「正文内靠左 / 靠右」是本单的主要新需求。实现上两个要点:

  1. 必须给正文目录一个宽度上限(max-width: min(100%, 28em))。
     块级盒子默认撑满正文栏,此时 margin:auto 没有剩余空间可分配,
     「靠左/靠右」会是一个属性正确、画面纹丝不动的空操作。
  2. 用 data-align 属性把方向带下去,CSS 只需两条规则,
     不随方向数量膨胀。属性在 yang_toc_slot_html() 里输出。

语义收窄:手写 [TOC] 以前会让整份自动处理全停(连带 rail=false),
现在只手管「正文那一份」—— 手写位置优先,浮动仍由 float 开关独立
决定。这样「精确定位正文位置」和「全站边上都有目录」可以并存。

顺手修掉一处默认值错配:checkbox 用 !empty() 判断会把「键不存在」
和「键明确为空」混成一件事,导致新装站点后台框显示勾上、页面却
不出目录。改用 array_key_exists() 区分的三态判断。

老配置不迁移:3.2.30 的 toc_place 直接作废,不做兼容。
默认 inline=1 / float=0,升级后页面与升级前一致。

重构:把内容过滤器拆成 yang_toc_apply()(纯执行层)+ 运行时壳,
使设置组合能在单进程单测里逐个喂进去(PHP 无法重置函数 static)。

验证
  逻辑 101/0 · 逻辑反向 15/15
  旧回归 53/0
  静态契约 58/0 · 反向 11/11
  后台几何+交互 70/0 · 反向 11/11
  侧栏几何 73/0 · 反向 8/8
  正文对齐几何 23/0 · 反向 5/5(含阴性对照)
  包内容契约 59/0 · 反向 9/9

正文对齐的几何探针量到真实的 Δleft = 348px(靠右比靠左),
而不是只断言 data-align 字符串存在。
2026-10-25 13:37:26 +08:00
laoyang 6f51d50a58 3.2.30: 文章目录进后台(新增「自动目录」标签页)—— 位置/插入点/默认展开可配,手写 [TOC] 永远优先
后台「主题设置」新增第 5 个标签页「自动目录」,7 个选项:
  目录位置   正文中间 / 页面左侧(浮动)/ 页面右侧(浮动)
  添加方式   手动(只认 [TOC])/ 自动
  自动插入位置  文章开头 / 第一个 H1、H2 之前 / 文章结尾
  默认状态   默认显示 / 默认隐藏
  另外:目录标题、收录层级(H1~H4)、是否显示序号

设计要点:
- 插入方式拆成 toc_mode(manual/auto) x toc_anchor(start/first/end) 两个正交
  字段,不合成一个四值下拉:侧栏模式下插入位置毫无意义,合并会产生歧义。
- 手写 [TOC] 绝对优先:yang_toc_plan() 里 $has_marker 最先返回,inline_at
  ='marker' 且 rail=false —— 否则同一篇会出现两份目录,老文章升级后突然多
  出一个目录。这条是刻意守住的。
- 纯函数 + 薄壳分层:normalize_settings / plan / inject / build_list 全部不查库,
  单进程可喂几十种组合;settings() 只负责读库 + 调纯函数 + static 缓存。
- <details open> 是唯一可靠的「默认收起」手段:展开态不接受 CSS 覆盖,只能
  由 open 属性决定,所以隐藏时必须不输出该属性。
- 侧栏收起态必须 translateX(±100%) 完全移出视口:只做 opacity 会让透明面板
  留在原位吃掉正文点击(探针 elementFromPoint 才抓得到)。
- yang_toc_rail_ready() 开关:侧栏在 wp_footer 输出,那时正文可能没走
  the_content(缓存插件/OEmbed),标题没 id ⇒ 侧栏链接全跳不动却看起来正常。
  宁可不出侧栏。
- 后台联动隐藏「插入位置」不用 jQuery show()/hide():会把 .section 的行内
  display 写成 block,悄悄回退 3.2.28 的 display:flow-root BFC 修复。改用
  class + display:none!important。

验证(全部在本轮重跑):
  _test_toc.php       53/0   旧 TOC 行为回归
  _toc330_logic.php   88/0   逻辑判据
  _toc330_falsify     13/13  注入全变红且红对地方
  _geo330_rail.js     73/0   侧栏真实几何(左/右 x 展开/收起 x 宽窄屏)
  _geo330_falsify     8/8
  _guard330.php       46/0   后台控件与运行时默认值双向一致
  _guard330_falsify   8/8
  _admin330_tab_check 36/0   新标签页几何 + 联动 + 载入态
  _admin330_falsify   8/8
  _admin328_check     13/0   3.2.28 布局回归

发布:只发 zip + readme + update.json 到 theme-update/,不动服务器主题目录,
用户在 WP 后台自行升级(沿用 3.2.24 起的策略)。
2026-10-25 12:18:52 +08:00
laoyang 677de0f24e 3.2.29: 把「自定义 CSS」里的站点样式并入主题公共样式 assets/css/site.css(段落缩进/h2 蓝条/列表缩进与行距),配置项默认置空并一次性清库迁移;自定义 CSS 内联输出改挂 kratos-site 以保持在最末 2026-10-25 10:36:29 +08:00
laoyang 420434aacf 3.2.28: 修后台设置页第一个配置项后面的大块空白(.section 建独立 BFC,不再被左侧浮动标签列的 clear:both 拉长到标签列等高;真实渲染实测 405px→93px) 2026-10-25 10:13:44 +08:00
laoyang 0239cbeb2a 3.2.27: 评论嵌套引导线按层变色(1层浅蓝/2层深蓝/3层点赞色/更深扩展色轮换,深层线恢复绘制);导航多级下拉修复(depth=2 截断改 0,深层父项 dropdown-submenu + hover 右侧弹出,移动端平铺) 2026-10-25 09:52:07 +08:00
laoyang 92ffced7cf 3.2.26: 评论布局重排(姓名/时间垂直居中于头像、正文全宽下移 10px,锚点黄底不再压作者徽章);引导线与子块留 8px 空隙并与缩进同源(--yq-my-indent);修深层缩进晚一层生效的存量 bug;后台设置页标题 margin 收编(项间空白 42→22px) 2026-10-24 23:58:55 +08:00
laoyang fb12d58067 3.2.25: 评论框改为所见即所得编辑器(B/I/颜色/表情可视化+序列化白名单+纯文本粘贴);后台资源 enqueue 版本号改跟 THEME_VERSION(修设置页重排被浏览器缓存挡住的问题) 2026-10-24 22:38:10 +08:00
laoyang fd27b4d218 3.2.24: 评论富文本工具条(B/I/颜色/表情移入)+ kses 放行 span+style + 后台设置页标签左列竖排重排 2026-10-24 21:59:06 +08:00
laoyang 460abcdb97 3.2.19: 修复关闭 wpDiscuz 后评论列表整段不显示
现象:#5264 等文章标题「评论(4)」与输入框都在,但 <ol> 内 0 字节。
只有单页评论的文章必现,多页文章反而正常。

根因:3.2.18 给 wp_list_comments() 传了 page=1 / per_page=10,
进入核心的"独立查询"分支。当这两个值与核心自己推导的
current_cpage / comments_per_page 恰好相等时,内层 if 不成立,
$_comments 一次都没被赋值(源码注释说会回退到 $wp_query->comments,
但那句注释下面没有回退代码),结果 paged_walk(null) 返回空串。

修复:
- 新增 yang_comment_page():按顶层评论分页 + get_children() 展平子孙。
  按顶层分页可避免父子被切到两页产生孤儿回复。
- comments.php 把结果当第二参数显式传给 wp_list_comments,
  走 null !== $comments 分支,绕开有问题的推导。

连带修掉的三处:
- Walker_Comment::end_el() 总会补 </li>,callback 再写一个导致
  </li></li>,子容器被挤成父评论的兄弟节点、CSS 栅格失效。
  yang_comment_html() 新增 $close_li,列表路径交给 walker 闭合。
- 核心 start_lvl() 固定输出 class="children",而样式表按
  .yang-children 写,类名不符导致缩进/栅格全失效。
  新增 Yang_Walker_Comment 覆盖 start_lvl()。
- AJAX「加载更多」原先只取顶层、只渲染顶层,吞掉全部子回复
  (实测某篇 10 条顶层名下 12 条回复丢失)。改为走同一 walker,
  has_more 也改成与首屏同口径的顶层计数。
2026-10-24 08:23:00 +08:00
laoyang 9c598fef48 3.2.18 补:评论提醒的三个开关在后台有界面
m_comment_notify / m_comment_approved_notify / m_keep_core_notify
原先只有代码在读、后台没有对应控件,用户想关也关不掉,只能改代码。

是 _static_315.php 的「模板读取的选项都有界面」这项契约检查抓出来的
(缺 m_comment_notify、m_comment_approved_notify 两项)。
默认值取 true,与代码里的 kratos_option(..., true) 一致。
2026-10-24 01:38:25 +08:00
laoyang cbe0f2dc59 3.2.18: 清明按节气换算、主题内 [TOC] 目录、重建评论区替代 wpDiscuz
三项功能:

1. 清明不再用固定 04-04
   清明是节气,日期在 4/4~4/6 之间浮动。硬编码 04-04 实测 1900-2100
   共 201 年里只有 76 年是对的(约 38%)。改为内嵌逐年表换算真实日期,
   超出 1900-2100 返回 null 明确放弃,不退化成近似值。
   兼容 3.2.17 及以前勾过的 '04-04' 键,升级后勾选不会被静默丢弃。

2. 主题内实现 [TOC]
   文章里写 [TOC],该位置输出本文目录,点击跳到对应标题。
   在 the_content 优先级 9 处理(早于 wpautop 10 / do_shortcode 11),
   保证"补 id"与"扫目录"用的是同一份文本,href 与 id 必然对得上。
   嵌套用深度归一化,h1→h3 跳级不会算错层级。
   scroll-margin-top 声明式让位,原生锚点/外部 #hash/后退全都生效。

3. 重建评论区
   旧实现的问题:列表渲染与 AJAX 渲染各写一份 HTML,已经分叉
   (AJAX 返回的新评论没有回复按钮);<li> 被 <div> 包着,嵌套回复错位。
   现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个
   is-new 类。同时修掉三处真实缺陷:
     · 评论项用 flex 会把 <ol class="yang-children"> 当成第三个 flex 项,
       子评论跑到父评论右侧、正文被挤到 156px、页面横向溢出 75px —— 改 grid
     · 成功提示在 exitReplyMode() 之前设置,被其末尾的 setStatus('','')
       擦掉,用户提交成功却看不到反馈 —— 调整顺序
     · 窄屏媒体查询打不过深层选择器(媒体查询不增加特异性),
       嵌套头像反向变大 —— 补深层覆盖

   与 wpDiscuz 解耦:邮件模块不再无条件 return false(那等于永久关掉
   notify_postauthor 且不报错);插件在场时不加载主题评论资源,
   避免双份表单互抢。

验证:PHP 语法 73/73;_static_qingming 36、_test_toc 53、
_test_comments 62、_test_comments_falsify 17、
_toc_geometry 121、_toc_geometry_falsify 9、
_comments_geometry 98、_comments_geometry_falsify 23,全部 0 失败。
线上端到端 29/0(真实文章渲染目录 + 真实评论数据渲染)。
2026-10-24 01:03:36 +08:00
laoyang 4beb09307c 3.2.17: 修复「保存设置时静默清空未提交的字段」
现象
在主题设置里填了「附加日期」(例如 10-23),过一阵发现它变回空了,
于是当天的哀悼效果不再出现,而其他设置都好好的,看不出哪里坏了。

根因
inc/options-framework/includes/class-options-framework-admin.php 的
validate_options() 里,对每个选项无条件取 $input[$id] 去 sanitize:

    $clean[$id] = apply_filters('of_sanitize_'.$type, $input[$id], $option);

一旦某次提交里没有这个键(分组没渲染、字段被条件隐藏、表单中途出错只
提交了一半……),$input[$id] 是未定义索引 ⇒ null ⇒ sanitize_text_field()
返回空串 ⇒ 把用户存了很久的配置写成空。而且"清成空串"和"用户主动清空"
在库里完全一样,事后无法区分,只表现为"我明明填过,怎么没了"。

实测证据(线上 PHP 8.5.12,同一套用例)
  修复前:3 通过 / 2 失败,失败项同时伴随
          PHP Warning: Undefined array key "g_rip_extra" ... line 252
  修复后:5 通过 / 0 失败

改法
- 循环外先取一次 get_option('kratos') 作 $saved_options(不在循环里查库)
- 提交了(array_key_exists($id,$input))才 sanitize
- 没提交则沿用 $saved_options 里的旧值;旧值也没有就不写这个键,
  让运行时的默认值继续生效
- checkbox / multicheck 保持原语义:这两类"没提交"确实等于"没勾",
  沿用旧值会导致开关关不掉。用 $is_toggle 单独判定,与"沿用旧值"互斥

为什么这个改动值得单独发一版
它不是界面问题,是**数据被静默改写**。凡是这种"用户没动、值却变了"的
缺陷,都会让人怀疑数据不可靠;而且每次后台保存都可能再触发一次。

验证
- 线上 _test_keep_old_value.php:5/5(修复前 3/5,用例 2 精确复现空串)
- 本地 _static_317.php:8/8,第 4 组自带注入(改成老写法必须变红)
- 全量流水线 verify_all.sh:rc=0
- 线上浏览器 _live315.py:纪念日分支 33/33、非纪念日分支 27/27
  (判据改为「由配置侧独立算 expect_show,再与页面渲染对比」,
   不再无条件断言"必须有灰度",也不再拿页面自身当预期值)
2026-10-23 23:18:51 +08:00
laoyang 0799268661 3.2.16: 删三项无用功能、选项重排为 10 组、修移动端提示条偏左
删除
- 微信二维码:配置项 s_wechat / 隐藏上传 s_wechat_url、footer 浮动块、
  默认图注册表条目、CSS 规则、wechat.png
- Font Awesome:选项 g_fontawesome、theme-core 入队、58KB 样式、
  assets/fonts/webfonts 全套字体(2.8MB,CSS 删后零引用)
- 多人模式:选项 multiusers、page-content / page-toolbar 两处分支

选项重排(9 组 → 10 组)
全站配置 / 顶部导航 / 首页配置 / 文章配置 / 站长信息 /
页脚配置 / 哀悼模式 / 收录与统计 / 邮件通知 / 自定义代码
哀悼 5 个字段从「首页配置」独立成「哀悼模式」——它不是首页专属功能。
g_404/g_chrome/g_animate/g_gravatar 等归「全站配置」,s_copyright 归「页脚配置」。

修复
- footer.php 版权行此前无条件硬编码,后台 s_copyright 填了不生效
- 附加日期命中时 yang_mourning_today_name() 返回空串,提示条退化成
  只说「今天是纪念日」而不给名字
- 小屏哀悼提示条 padding 写成 `7px 40px 7px 12px`(右宽左窄给关闭按钮
  让位),内容盒中心 181 vs 视口中心 195,整体偏左 14px。关闭按钮本就
  absolute 不占内部流,改为左右等宽 + 缩小按钮。桌面端同类错误 3.2.14 已修,
  此处漏了同一个断点。

加载顺序
theme-options.php 可能早于 theme-mourning.php / theme-custom-code.php 加载,
新增 yang_opt_* 四个 function_exists 包装,避免裸调用导致后台白屏。

验证:静态断言 62/0 · 后台面板 14/0 · 反向验证 7/7 与 44/0 ·
提示条 4 视口几何 20/0(注入旧 bug 精确复现 -14px)· 线上 3 视口 33/0
2026-10-23 22:56:10 +08:00
laoyang 96bbb3d1b5 v3.2.14 哀悼提示条改到整页最上方正中,只在纪念日当天出现
用户要求:哀悼说明「放在最上方的最中间,只在纪念日、哀悼模式开启时才显示,
其他时间不显示」。

改的过程中量出三个真问题:

1. 提示条与站点标题重叠 32px。提示条从 y=0 开始、高 40px,而导航栏
   .k-nav 是 position:absolute; top:0、z-index 1030;提示条 z-index 9990
   ⇒ 直接盖在 Logo 上。CSS 每个字都没写错,就是叠在一起。
2. 内容偏左 38px。为了给右侧关闭按钮让位写了 padding-right:46px、
   左侧只有 18px ⇒ 文字中心 602 vs 视口中心 640。"最中间"没做到。
3. 页脚有常驻的「纪念日哀悼说明」链接,一年 365 天都显示。平时打开
   站点的人既没看到黑白也不知在哀悼什么,这个入口本身就是困惑源。

修法:
- 提示条占文档流第一行、width:100%,内容交给内层 .ymb-inner 居中,
  左右 padding 等宽、关闭按钮 absolute 不占内部流 ⇒ 几何中心 = 视口中心。
- .k-nav 是绝对定位,body 的 padding 推不动它(这个原因单看代码想不到,
  是量几何量出来的)⇒ 单独补一条 body.has-yang-mourning-bar .k-nav{top:var(--ymb-h)}。
  偏移量由脚本读真实高度写入,窄屏换行后也准。
- 不用 fixed:那会滚动时顶部永远压一条深色带,内容从它下面穿过去。
- 移除页脚常驻入口与 wp_footer 注册,说明页入口只在提示条里。
  非哀悼日输出 0 字节。

验证:
- run_tests2 163 条(新增 9 条位置契约)
- _check_hidden.php 19 条(非哀悼日 0 字节、每个纪念日都能触发)
- mourning_bar_geometry.py 28 条(线上真实页面实测 4 种视口:
  顶部贴边、横跨整宽、居中误差 ≤1px、与标题和导航栏零重叠)
- falsify 44 条注入全被抓住(新增 9 条)

两个假绿当场被反面验证抓出并修掉:
- 判据照着 fixed 方案写,实现改成 relative 后 4 条断言集体假红
  (教训:设计前提变了,判据必须跟着改)。
- 页脚断言用 ob_start 取输出,但函数是 return 不是 echo ⇒ $fl 恒空
  ⇒ 注入"返回一个链接"仍全绿(教训:否定式断言在空输出上恒真)。
2026-10-23 20:43:25 +08:00
laoyang ef9a58dc51 v3.2.13 图片选项全部自带默认图,新装站点不再出现空洞/破图
根本原因:kratos_option() 运行时只读数据库,`std` 仅在后台表单预填和
「恢复默认」时生效,前台根本读不到。因此新装站点所有图片项都是空值。
另外 isset() 挡不住「用户清空」——of_sanitize_upload() 对空输入返回 '',
键存在且值为空串,调用方传的兜底值被绕过。

同时不能把绝对 URL 写进 std:它会随保存一起写库,主题目录改名/换 CDN/
迁站后永久 404。本站 s_wechat_url / g_donate_alipay / g_donate_wechat
三项就指向已删除的 kratos-master 目录,一直在破图(这三项是 hidden 字段,
后台界面上看不到,所以坏了很久没人发现)。

做法:
- 新增 inc/theme-option-defaults.php,图片默认值注册表(11 项),
  默认图一律运行时给出,不写数据库。
- yang_option_image() 三层判断:空/纯空白 → 默认图;指向已不存在的主题
  目录 → 默认图;文件不存在则不输出地址(避免换来一个 404)。
- 陈旧判断用 get_template()(主题 slug)而非 basename(目录名),避免本地
  开发目录名与 slug 不一致时误伤当前主题自己的图。
- 模板统一走取值函数:header(logo/favicon/banner)、footer(微信码/国旗)、
  404、index、article 缩略图、widgets 头像、page-toolbar、theme-core 赞赏码。
- 国旗与 favicon 的渲染抽成 yang_flag_block() / yang_favicon_tag(),
  空值时不输出空 src / 空 href。
- 后台 9 处 std 改为 yang_options_default_img(),与前台默认图同源;
  该包装函数自带 function_exists 防护,单独加载 theme-options.php 不会 Fatal。

新增图片(本地生成,合计 291KB):default-404 / default-share /
default-empty / default-avatar / default-banner / default-flag / favicon.ico。
favicon 为 WordPress 风格自绘(品牌蓝圆底 + 白色 W),非站长个人专用那张。

验证:run_tests 146 条(新增第 4 组图片默认值 22+ 条);falsify 35 条注入
全部能被抓住(新增 10 条);verify_all.sh 14 组全绿。
2026-10-23 19:50:07 +08:00
laoyang 704a9346e1 3.2.12 紧急修复:点「测试邮件」引发无限递归、内存耗尽
3.2.11 引入的事故。线上表现:
  Allowed memory size of 268435456 bytes exhausted
  in .../wp-includes/class-wp-block-parser.php on line 91
前台首页、后台登录页都正常,只有点那个按钮才炸。

根因不在区块解析器 —— 那只是递归链最后执行到的一行,看它会完全跑偏。

validate_options() 是 register_setting() 注册的 sanitize_callback,
WP 把它挂在 `sanitize_option_kratos` 过滤器上(wp-includes/option.php:3073)。
3.2.11 为了让"刚改的 SMTP 配置立刻生效",在这个回调里调了
update_option('kratos', $clean)。而 update_option() 内部**会再跑一遍**
同名过滤器 ⇒ 又调回 validate_options ⇒ 再 update_option ⇒ 无限递归。
每层都在复制 $clean 派生的大数组,直到吃满 256M。

本地实测确认递归存在:回调被调用 3 次、嵌套深度 2。

改法:不写库,改用一次性覆盖层。
- 分支里把 $clean 放进 $GLOBALS['__yang_options_override']
- kratos_option() 优先读它,取不到再读库
- 效果等价(点测试用的就是刚填的值),但不触发任何过滤器,不可能递归
- 覆盖层是本请求内的全局,不落库、不跨请求残留

assertions: run_tests 104 → 110
- 分支里**没有** update_option(这条是红线,直接对应本次事故)
- 分支里有覆盖层
- 覆盖层真生效:kratos_option 优先返回新值
- 覆盖层未命中的键仍读库(不能把别的配置一起抹掉)
- 撤掉覆盖层回到库值(不跨请求残留)
- 桩与产品代码一致:直接读 autoload.php 确认它也认覆盖层
  (桩比产品温和是这次漏检的根源之一 —— 桩里 update_option 不挂
   sanitize 过滤器,所以注入递归 bug 后 harness 根本不崩)

顺带修了两个测试基础设施问题:
1. wp_stub.php 的 kratos_option 与真实实现语义不一致。已同步,
   并加断言防止两边再次漂移(否则"测的是桩、不是产品代码")。
2. falsify 的 rebuild() 没把被 file_get_contents 读的 autoload.php
   复制进副本 ⇒ 新断言读不到文件 ⇒ 基线假红。

falsify 24 → 25:注入「分支里调 update_option」能抓住(靠静态红线,
不靠跑崩)、注入「去掉覆盖层」能抓住、注入「退回裸 wp_mail」能抓住。

全量 14 组全绿;run_tests 110 / run_tests2 154 / falsify 25。
2026-10-23 18:49:06 +08:00
laoyang 9a053169e9 3.2.11 后台「测试邮件」改走主题模板,并修发信早于落库
两个问题叠在一起,都不报错,只是「测了等于没测」:

1. 按钮发的是纯文本
   validate_options() 里 isset($_POST['sendmail']) 那个分支只有一句裸
   wp_mail(),正文是「恭喜您 SMTP 邮件服务配置成功!」。于是点测试收到的
   东西和真实评论通知(HTML 模板)完全不同,没法用它验收版式。
   主题里 yang_mail_send_test() 本来就是干这件事的,但全仓库零调用点 ——
   注释写着「后台测试按钮走这条」,实际上从没接上线。

2. 发信发生在配置落库之前
   validate_options() 是 register_setting 的回调,返回值之后才由 WP
   写进数据库。在分支里直接发信,主题配 PHPMailer 时读 kratos_option()
   拿到的还是上一次保存的主机/账号/密码。刚改完 SMTP 密码点测试,
   测的是旧密码,表现成「明明填对了却发不出去」。

改法:
- 分支里先 update_option('kratos', $clean) 再调 yang_send_test_mail()
- yang_send_test_mail() 优先调主题的 yang_mail_send_test();
  主题被换掉时退回原来的纯文本,不能因此白屏
- send_mail_notice() 从「一律发送完成」改成三态:WP_Error / wp_mail
  返回 false / 成功,前两者用 error 样式。原来密码错了也显示成功,
  用户会去邮箱里干等。

assertions: run_tests 95 → 104 条(新增 2k 组 8 条 + 1 条)
- sendmail 分支确实调用主题发信函数、确实先落库
- 落库位置在发信位置之前(判顺序,不判有没有)
- 分支里没有裸 wp_mail
- 发信函数对主题缺失做了兜底
- 提示语分三态,两类失败都用 error 样式

falsify 22 → 24 条:
- 注入「先发信后落库」⇒ 变红(这条一开始是假绿:判据比的是注释里
  那两个词谁先出现,注释里 update_option 和函数名都在,恒真。
  改成先剥注释再比代码里的位置)
- 注入「退回裸 wp_mail」⇒ 变红

顺带修了 falsify 的一个基础设施坑:options-framework 那批文件在工作区
是 CRLF,锚点里写 \n 对多行内容恒不匹配,报的是「命中 0 次」,
很像锚点选错了。inject() 现在先试原样、不中再把 \n 换 \r\n 重试一次。

全量 12 组 / 506 条断言全绿。
2026-10-23 18:28:53 +08:00
laoyang 6f50533e8e 3.2.10 修复邮件页脚显示字面量 &copy;
邮件正文会被 yang_mail_escape_for_wpdiscuz() 无差别地把 & 加倍,用来抵消
wpDiscuz 在过滤器之后又做的那一次 html_entity_decode —— 不加倍的话,访客
评论里的 <script> 会在解码时还原成活标签。

但这个替换是全局的,连模板骨架里手写的字符实体也一起被加倍。于是
&copy; 变成 &amp;copy;,能否正常显示完全取决于后面那次解码有没有执行:
走了解码 → 还原成实体 → 浏览器显示 ©;没走 → 只解析一次 → 收件人看到
字面量 &copy;。用户遇到的正是后者。

修法不是在转义函数上开口子(那会重新打开脚本注入口),而是让骨架不再
依赖实体:版权符号与 3px 分隔条的不换行空格一律输出 UTF-8 字符本身。
整封邮件本就声明 charset=UTF-8,这样无论被解码几次都不会变形。

用户数据(站名、标题、URL)仍走 esc_html/esc_url —— 那类 & 的转义是
必要的,加倍后能由那次解码还原,属于应当保留的一类。

assertions: run_tests 89 → 95 条(新增 1e 组 6 条)
- 骨架里零手写实体(0 次 decode 也不露原文)
- 经 0~5 次 html_entity_decode 始终是 ©、从不露实体原文
- URL/站名里的 & 仍被转义(不能把 esc_html 一起废掉)
- 分隔条是 U+00A0 裸字符,过过滤器后仍是裸字符

falsify 20 → 22 条,两处注入(页脚退回实体、分隔条退回实体)都能抓住。
全量流水线 14 组 / 465 条断言全绿。
2026-10-23 17:29:37 +08:00