Files

267 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/**
* 代码高亮前端逻辑
*
* 服务端(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();
}
})();