Files
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

267 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 代码高亮前端逻辑
*
* 服务端(inc/theme-highlight.php)已经把每个 <pre> 规范成了卡片结构:
* <figure class="cb hl-<key>" data-lang data-theme data-kind data-guess>
* <figcaption class="cb-bar"> 语言标签 + 复制按钮 </figcaption>
* <div class="cb-body">
* <div class="cb-ln">1 2 3 …</div> ← 行号,独立于 pre
* <pre class="hljs"><code class="hljs">…</code></pre>
* </div>
* </figure>
*
* 前端只做三件事:上色、复制、自动识别。
* 主题切换由后台设置决定,不在前台提供切换器。
*/
(function () {
'use strict';
var CFG = window.YANG_HL || {};
var L = CFG.labels || {};
/* ── 上色 ──────────────────────────────────────────────────
* 作者标了语言 → 按标记高亮。
* 没标 → 交给 runAuto(如果自动识别开着)。
*
* 🔴 必须先判断 hljs 在不在:脚本被 CDN 拦了 / 加载失败时,
* 直接调 hljs.highlightAll 会抛 TypeError,后面的复制绑定
* 全都执行不到 —— 表现成"整块代码没有任何按钮反应"。
* 这类故障排查起来极其费时,所以要显式挡一下。 */
function paint () {
if (typeof window.hljs === 'undefined') {
return false;
}
if (typeof hljs.highlightAll === 'function') {
hljs.highlightAll();
}
fixPlaintextClass();
return true;
}
/* ── 修掉 hljs 留下的 language-undefined ──────────────────
* 🔴 实测(真实浏览器):hljs 11.12.0 的 highlightElement() 处理
* `<code class="hljs language-plaintext">` 之后,会把 class 改写成
* **`hljs language-undefined`**。
*
* 原因是 plaintext 的定义只有 {name, aliases, disableAutodetect,
* rawDefinition},**没有 `language` 字段**,而 highlightElement
* 内部用 `result.language` 拼了 className —— 于是拼出字面量 "undefined"。
*
* 后果不是报错(页面一切正常、颜色也对,因为 plaintext 本来就不上色),
* 但 class 被污染成 `language-undefined`:
* · 任何人按 `[class*="language-"]` 统计语言都会多数出一个"undefined"
* · 自动化判据会被它误导(本项目的探针就吃到这个假数据)
* 所以这里显式改回 language-plaintext。
*
* 判据必须直接查 class 字面量,不能只看"有没有颜色"——
* plaintext 本来就没颜色,看颜色永远看不出这个问题。 */
function fixPlaintextClass () {
var codes = document.querySelectorAll('code.language-undefined');
for (var i = 0; i < codes.length; i++) {
var cb = codes[i].closest ? codes[i].closest('.cb') : null;
// 只修我们自己卡片里、且 data-lang 确实是 plaintext 的
if (!cb) { continue; }
if (cb.getAttribute('data-lang') !== 'plaintext') { continue; }
codes[i].className = 'hljs language-plaintext';
}
}
/* 判断 highlightAuto 的返回是不是"真的识别出来了"
*
* 🔴 绝不能用 `if (r && r.language)`:
* 实测 hljs 11.12.0 对纯文本/中文内容的 highlightAuto 返回
* `{language: 'undefined', relevance: 0}` —— 注意是**字符串**
* 'undefined',不是 undefined 值。用真值判断会把它当成功,
* 于是 class 写成 `language-undefined`、标签写成 labelOf('undefined')
* 再被 toUpperCase() 变成「UNDEFINED」,直接显示在页面上。
*
* 另有一个真实的正确性隐患:英文短句会被猜成 sql(relevance 只有 1)。
* relevance 太低说明"没有可信匹配",这种也应该当作没认出来。 */
function autoOk (r) {
if (!r || !r.value) { return false; }
var lang = r.language;
if (!lang || lang === 'undefined' || lang === 'null') { return false; }
if (typeof lang !== 'string') { return false; }
// relevance 为 0 = 完全没有命中任何语法特征
if (!r.relevance || r.relevance <= 0) { return false; }
// 语言必须在 hljs 里真的注册过,否则写进 class 就是白板
if (typeof hljs.getLanguage === 'function' && !hljs.getLanguage(lang)) {
return false;
}
return true;
}
/* ── 自动识别 ────────────────────────────────────────────────
* 🔴 这个开关必须是**真的**:拨开就真跑一次 highlightAuto 并写回,
* 拨关就把高亮还原成纯文本、标签藏起来。
* 原型阶段这版曾经是个空壳(只改了一个不存在元素的 display),
* 点了没有任何变化 —— 那种假开关什么都验证不了。
*
* 🔴 只对**没标语言**的块生效。有标记的永远按标记来,
* 因为自动识别在站内实测准确率只有 22%,不能让它覆盖作者的标注。 */
function runAuto (on) {
if (typeof window.hljs === 'undefined') {
return;
}
var boxes = document.querySelectorAll('.cb');
for (var i = 0; i < boxes.length; i++) {
(function (box) {
// 作者标了语言的一律不动
if (box.getAttribute('data-guess') === '0') {
return;
}
var code = box.querySelector('pre > code');
var tag = box.querySelector('.cb-lang');
if (!code) { return; }
// 原始源码:用属性缓存,避免从高亮后的 DOM 反解
var src = code.getAttribute('data-raw');
if (src === null) {
src = code.textContent;
code.setAttribute('data-raw', src);
}
if (on) {
var r = hljs.highlightAuto(src);
if (autoOk(r)) {
code.innerHTML = r.value;
code.className = 'hljs language-' + r.language;
if (tag) {
tag.textContent = labelOf(r.language);
// 保持虚线边框:这是猜的,不是作者标的
tag.setAttribute('data-guess', '1');
tag.style.display = '';
}
box.setAttribute('data-lang', r.language);
} else {
// 认不出来:保持纯文本,标签显示 —
/* 🔴 这里**必须留着** tag 上的 data-guess="1"。
* 认不出 ≠ 已确认。删掉它,CSS 的虚线+斜体就失效,
* 标签退回成实线正体的「—」—— 看上去和作者亲手写的
* 「纯文本」一模一样,等于用"已确认"的样式表示"没认出来",
* 恰好相反。作者没标语言这件事本身也仍然是"未确认"状态。
* (box 上的 data-guess="1" 也不能动:runAuto 靠它
* 判断"这块可以反复重新识别"。) */
code.textContent = src;
code.className = 'hljs';
if (tag) {
tag.textContent = '—';
tag.setAttribute('data-guess', '1');
}
}
} else {
/* 自动识别被关掉:还原成纯文本。
* 🔴 同样要保留 tag 上的 data-guess="1":
* 关掉识别之后"这块是什么语言"依然**未知**,标签还是「—」,
* 就必须还是虚线斜体。删掉它会让所有未标注块在关闭识别时
* 一齐变成实线正体,看起来像"作者确认过是纯文本"。 */
code.textContent = src;
code.className = 'hljs';
if (tag) {
tag.textContent = '—';
tag.setAttribute('data-guess', '1');
}
box.setAttribute('data-lang', '');
}
})(boxes[i]);
}
// runAuto 里也会重写 code.className,所以这里必须再修一次。
// (只放在 paint() 里不够:runAuto 是 DOMContentLoaded 之后又跑一遍的,
// 会把 paint() 刚修好的 class 覆盖掉。)
fixPlaintextClass();
}
/* 语言名 → 显示名
*
* 🔴 表里只能放**确实打包进 highlight.min.js 的语言**。
* 放进来但没打包 → hljs 认不出 → 代码块静默白板(只 console.warn,
* 不抛错)。所以 dos / dockerfile / nginx / powershell 已从映射链路
* 与语言白名单里移除,这里保留文案只是为了 highlightAuto 万一返回
* 它们时标签不是全大写,不影响上色。 */
function labelOf (lang) {
var M = {
bash: 'Bash', c: 'C', cpp: 'C++', csharp: 'C#', css: 'CSS',
diff: 'Diff', dos: 'Batch', dockerfile: 'Dockerfile', go: 'Go',
graphql: 'GraphQL', ini: 'INI', java: 'Java',
javascript: 'JavaScript', json: 'JSON', kotlin: 'Kotlin',
less: 'Less', lua: 'Lua', makefile: 'Makefile',
markdown: 'Markdown', nginx: 'Nginx', objectivec: 'Objective-C',
perl: 'Perl', php: 'PHP', plaintext: '纯文本',
powershell: 'PowerShell', python: 'Python', r: 'R', ruby: 'Ruby',
rust: 'Rust', scss: 'SCSS', shell: 'Shell', sql: 'SQL',
swift: 'Swift', typescript: 'TypeScript', xml: 'HTML', yaml: 'YAML'
};
return M[lang] || (lang ? lang.toUpperCase() : '');
}
/* ── 复制 ──────────────────────────────────────────────────
* 🔴 两个硬约束:
* 1. navigator.clipboard 只在安全上下文(HTTPS/localhost)存在,
* HTTP 下是 undefined,直接调抛 TypeError,表现成"点了没反应"。
* 必须留 execCommand 退路。
* 2. 取 textContent 而不是 innerHTML —— 高亮后代码里全是 <span>。
* 行号在 <pre> 外面,天然不会进 textContent,所以复制永远不含行号。 */
function copyText (text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-9999px';
ta.setAttribute('readonly', 'readonly');
document.body.appendChild(ta);
ta.select();
try { ta.setSelectionRange(0, text.length); } catch (e) { /* 老浏览器 */ }
var ok = false;
try { ok = document.execCommand('copy'); } catch (e2) { ok = false; }
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('execCommand failed'));
});
}
function flash (btn, cls, label) {
var old = btn.textContent;
btn.textContent = label;
btn.className = btn.className + ' ' + cls;
btn.disabled = true;
setTimeout(function () {
btn.textContent = old;
btn.className = btn.className.replace(' ' + cls, '');
btn.disabled = false;
}, 2000);
}
/* 事件委托:评论区的代码块是 AJAX 插进来的,不能绑在元素上 */
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-copy]') : null;
if (!btn) { return; }
var box = btn.closest('.cb');
var code = box ? box.querySelector('pre > code') : null;
if (!code) { return; }
e.preventDefault();
copyText(code.textContent).then(function () {
flash(btn, 'done', L.copied || '✓ 已复制');
}).catch(function () {
flash(btn, 'err', L.failed || '✗ 复制失败');
});
});
/* ── 初始状态 ────────────────────────────────────────────── */
function init () {
if (!paint()) {
return;
}
runAuto(CFG.autoDetect !== false);
}
// 供评论区 AJAX 插入新内容后重新调用
window.yangHlRefresh = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();