`(没有 `` 子元素)**, * 占 28.2%。插件留下的历史内容大量是这个形态。如果只在前端跑 * `hljs.highlightAll()`(它默认只找 `pre code`),这 80 块会整块没有标签、 * 没有复制按钮、也拿不到语言名。所以必须服务端先规范化 DOM。 * * ── 为什么语言标签要在服务端定 ────────────────────────────── * 自动识别(highlightAuto)在站内 ≥4 行的 18 个未标注代码块上只对了 4 个(22%)。 * 它可以用,但**绝不能让它冒充作者标注**。所以: * · 作者标了 language-xxx → 标签实线,直接信任 * · 没标 → 服务端不猜,标签留空让前端识别后填, * 并打上 data-guess="1"(虚线 + 斜体) * * @package kratos-yang */ if (!defined('ABSPATH')) { exit; } /** * 读一个布尔型主题选项 * * 🔴 三条必须一起做对,少一条就会出现"后台勾了、前台没生效": * ① **key 必须带 `a_` 前缀**。选项框架里 id 是 `a_highlight_enable`, * 而 kratos_option() 是直接拿这个 id 去 $options 数组里取的, * 写成 `highlight_enable` 永远取不到,只会静默返回默认值。 * ② **checkbox 存的是字符串 `'1'`,不是布尔 true**。 * 直接 (bool) 转没事,但 `=== true` 判断一定假。 * 这里按 `=== '1'` 判,与 theme-lightbox.php 的写法保持一致。 * ③ **默认值必须运行时提供**。`std` 只服务后台预填与"恢复默认", * kratos_option($id, $default) 的 $default 只在库里没有这个键时才用, * 所以运行时默认值只能靠调用处传进来 —— 这也正是这个函数存在的意义: * 把默认值集中在这里,和 yang_hl_enabled() 对外暴露的口径对齐。 * * @param string $key 选项 id(带 a_ 前缀) * @param bool $default 运行时默认值 * @return bool */ function yang_hl_opt_bool($key, $default = true) { if (!function_exists('kratos_option')) { return $default; } $v = kratos_option($key, $default ? '1' : '0'); // checkbox 勾选时存 '1';取消勾选时框架可能存 '' 或不存 return $v === '1' || $v === 1 || $v === true; } /** * 站内语言标记 → hljs 语言名 + 显示名的映射 * * 🔴 `text` 必须映射到 `plaintext`。 * 站内有 **47 个代码块**写的是 `language-text`,而 highlight.js 根本没有 * 叫 `text` 的语言。不映射的话这 47 块会落到"未知语言"分支: * 不报错、不上色、只是白板 —— 属于最难发现的一类故障。 * 已实测确认 hljs 11.12.0 的 listLanguages() 里没有 text/txt/plain。 * * @return array */ function yang_hl_lang_map() { return array( // —— 纯文本:必须显式映射,否则静默不上色 'text' => 'plaintext', 'txt' => 'plaintext', 'plain' => 'plaintext', 'plaintext' => 'plaintext', 'nohighlight' => 'plaintext', // —— 同义写法 'html' => 'xml', 'htm' => 'xml', 'vue' => 'xml', 'svg' => 'xml', 'py' => 'python', 'js' => 'javascript', 'jsx' => 'javascript', 'ts' => 'typescript', 'tsx' => 'typescript', 'yml' => 'yaml', 'sh' => 'bash', 'zsh' => 'bash', 'shell' => 'bash', 'console' => 'bash', 'terminal' => 'bash', 'ps' => 'powershell', 'ps1' => 'powershell', 'bat' => 'dos', 'batch' => 'dos', 'cmd' => 'dos', 'c++' => 'cpp', 'cs' => 'csharp', 'c#' => 'csharp', 'golang' => 'go', 'rs' => 'rust', 'rb' => 'ruby', 'kt' => 'kotlin', 'objectivec' => 'objectivec', 'objc' => 'objectivec', 'dockerfile' => 'dockerfile', 'docker' => 'dockerfile', 'nginx' => 'nginx', 'sql' => 'sql', ); } /** * 把任意写法的语言标记规范成 hljs 认识的语言名 * * @param string $raw 原始标记(可能带 language- / lang- 前缀,也可能是 brush: 写法) * @return string 规范化后的名字;无法识别返回空串 */ function yang_hl_norm_lang($raw) { $raw = strtolower(trim((string) $raw)); if ($raw === '') { return ''; } // 三种历史写法: // class="language-python" Prism / hljs 标准 // class="lang-python" 部分编辑器 // class="brush: python;" SyntaxHighlighter 遗留 if (strpos($raw, 'brush:') === 0) { $raw = trim(str_replace(array('brush:', ';'), '', $raw)); } $raw = preg_replace('/^(language|lang)-/', '', $raw); $raw = trim($raw); $map = yang_hl_lang_map(); if (isset($map[$raw])) { return $map[$raw]; } // hljs 官方的语言名直接用 static $known = null; if ($known === null) { // 与 assets/js/hljs/highlight.min.js 打包的语言一致(36 种) $known = array_flip(array( 'bash', 'c', 'cpp', 'csharp', 'css', 'diff', 'go', 'graphql', 'ini', 'java', 'javascript', 'json', 'kotlin', 'less', 'lua', 'makefile', 'markdown', 'objectivec', 'perl', 'php', 'plaintext', 'powershell', 'python', 'r', 'ruby', 'rust', 'scss', 'shell', 'sql', 'swift', 'typescript', 'xml', 'yaml', 'dos', 'dockerfile', 'nginx', )); } return isset($known[$raw]) ? $raw : ''; } /** * 语言的显示名(代码块左上角那个标签) * * @param string $lang 规范化后的语言名 * @return string */ function yang_hl_label($lang) { $labels = array( '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', ); if (isset($labels[$lang])) { return $labels[$lang]; } return $lang === '' ? '' : strtoupper($lang); } /** * 允许高亮的语言白名单(与打包进 hljs 的一致) * * @return array */ function yang_hl_languages() { return array_keys(array_flip(array( 'bash', 'c', 'cpp', 'csharp', 'css', 'diff', 'go', 'graphql', 'ini', 'java', 'javascript', 'json', 'kotlin', 'less', 'lua', 'makefile', 'markdown', 'objectivec', 'perl', 'php', 'plaintext', 'powershell', 'python', 'r', 'ruby', 'rust', 'scss', 'sql', 'swift', 'typescript', 'xml', 'yaml', 'dos', 'dockerfile', 'nginx', ))); } /** * 从 pre/code 元素的 class 里取出语言标记 * * @param string $class class 属性原文 * @return string */ function yang_hl_pick_lang_from_class($class) { $class = trim((string) $class); if ($class === '') { return ''; } // 🔴 先整串匹配 `brush: xxx;` 这种写法,再按空格切。 // 为什么必须先整串匹配:SyntaxHighlighter 的写法是 // `class="brush: python;"`,**里面带一个空格**。 // 如果直接按 \s+ 切,会切成 ['brush:', 'python;'] 两个 token, // 哪个都不满足 `strpos($c,'brush:')===0` 之后取语言的逻辑, // 结果这个语言标记被彻底丢掉 —— 已由测试抓出。 if (preg_match('/brush:\s*([a-z0-9+#.\-]+)\s*;?/i', $class, $bm)) { $lang = yang_hl_norm_lang($bm[1]); if ($lang !== '') { return $lang; } } foreach (preg_split('/\s+/', $class) as $c) { if ($c === '') { continue; } if (preg_match('/^(language|lang)-/i', $c)) { return yang_hl_norm_lang($c); } } return ''; } /** * 把一个
 片段规范化成"代码块卡片"的结构
 *
 * 输入可能是四种形态中的任意一种(站内都实测存在):
 *   A. 
…
← 标准,204 个 * B.
…
← 类在 pre 上 * C.
…
← 无标记,需自动识别 * D.
…
← 裸 pre,80 个,必须处理 * * 输出统一是: *
*
* … * *
*
* *
…
*
*
* * 🔴 行号为什么放在
 **外面**的独立槽里:
 *    ① 放里面就得逐行切 span,而那些 span 是真实文本节点,
 *       复制时 textContent 会一并带走 —— 直接违背"复制不带行号"。
 *    ② 用 CSS 计数器画在 span::before 上也不行:hljs 输出的是 token span,
 *       **没有行结构**,没有任何元素能挂 ::before 的行号。
 *       本轮原型阶段就是这么写的,结果是"CSS 规则一条不少、body 类也挂上了、
 *       画面上却一行行号都没有"。
 *
 * @param array  $m           preg_match_all 的匹配组
 * @param string $inner_pre   pre 内部原文(已含未处理的 HTML)
 * @return string
 */
function yang_hl_render_block($inner_pre, $attr_class = '')
{
    $code = $inner_pre;
    $lang = '';

    // 先把可能存在的  外壳剥出来,取它的 class 和内容
    if (preg_match('#^\s*]*)>(.*)\s*$#is', $code, $m)) {
        $attrs = $m[1];
        $code = $m[2];
        if (preg_match('/class\s*=\s*"([^"]*)"/i', $attrs, $cm)
            || preg_match("/class\s*=\s*'([^']*)'/i", $attrs, $cm)) {
            $lang = yang_hl_pick_lang_from_class($cm[1]);
        }
    }
    // pre 自己的 class 也可能是语言来源(形态 B)
    if ($lang === '' && $attr_class !== '') {
        $lang = yang_hl_pick_lang_from_class($attr_class);
    }

    // 代码内容保持原样:它已经是 HTML 转义过的(< &),
    // 这里绝不能再 esc_html 一次,否则会变成 &lt; 双转义,页面上显示 <
    $body = $code;

    // 行数:按换行符算,末尾的那个换行不算多一行
    $raw_text = html_entity_decode(wp_strip_all_tags($body), ENT_QUOTES, 'UTF-8');
    $n = substr_count($raw_text, "\n");
    if (substr($raw_text, -1) !== "\n") {
        $n++;
    }
    $n = max(1, $n);

    $gutter = '';
    for ($i = 1; $i <= $n; $i++) {
        $gutter .= '' . $i . '';
    }

    $label = yang_hl_label($lang);
    $is_guess = ($lang === '') ? '1' : '0';

    // 🔴 data-kind 必须**服务端直接输出**,不能等 JS 去设。
    //    深色配色的 token 颜色是浅色的,如果卡片初始没有 data-kind="dark",
    //    在 JS 执行前的这一小段时间里就是"白底 + 浅色字",
    //    用户会看到代码块闪一下变空 —— 这正是现有 Prism 那个 bug 的观感。
    //    同理 data-theme 也要服务端定,风格切换是后续 JS 的事。
    $theme_key = yang_hl_theme_key();
    $all_themes = yang_hl_themes();
    $kind = isset($all_themes[$theme_key]) ? $all_themes[$theme_key][1] : 'light';

    // 语言名写进 class 供 hljs 用;没识别到就先留空,由前端 highlightAuto 补
    $lang_class = ($lang !== '' && $lang !== 'plaintext')
        ? ' class="hljs language-' . esc_attr($lang) . '"'
        : ' class="hljs"';

    return '
' . '
' . '' . ($label !== '' ? esc_html($label) : '—') . '' . '' . '' . '' . '
' . '
' . '' . '
' . $body . '
' . '
' . '
'; } /** * 正文过滤器:把文章里的所有
 规范成代码块卡片
 *
 * 挂在 the_content 上,优先级 9 —— 要在 wpautop(10) **之前**。
 * 为什么必须早于 wpautop:裸 
 被 wpautop 处理时会被包进 

, * 而

的 white-space:normal 会把行首缩进折叠掉,代码缩进就没了。 * 已在真实 WP 7.1.3 上验证:pre 存在时 wpautop 会正确跳过。 * * @param string $content * @return string */ function yang_hl_filter_content($content) { if (is_feed() || is_admin()) { return $content; } if (strpos($content, ']*)>(.*?)

#is', function ($m) { $attrs = isset($m[1]) ? $m[1] : ''; $cls = ''; if (preg_match('/class\s*=\s*"([^"]*)"/i', $attrs, $cm) || preg_match("/class\s*=\s*'([^']*)'/i", $attrs, $cm)) { $cls = $cm[1]; } // wp-editormd 会产出 class="editor-md-pre",那是纯样式类,不是语言 $cls = preg_replace('/\beditor-md-pre\b/i', '', $cls); return yang_hl_render_block($m[2], $cls); }, $content ); // preg_replace_callback 失败会返回 null —— 绝不能把 null 当内容输出 return ($out === null) ? $content : $out; } add_filter('the_content', 'yang_hl_filter_content', 9); /** * 是否启用代码高亮(供模板判断要不要加载资源) * * @return bool */ function yang_hl_enabled() { if (function_exists('kratos_option')) { return yang_hl_opt_bool('a_highlight_enable', true); } return true; } /** * 当前主题配色 key(20 套之一) * * @return string */ function yang_hl_theme_key() { $key = function_exists('kratos_option') ? (string) kratos_option('a_highlight_theme', 'github') : 'github'; $valid = yang_hl_themes(); if (!isset($valid[$key])) { $key = 'github'; } return $key; } /** * 20 套配色:10 浅 + 10 深 * * 深色组必须存在:技术文章里把代码块做成深色能压住浅色正文, * 突出代码本身;同时一篇文章并列给 Python / Java 两种实现时, * 也靠配色区分阅读节奏。 * * @return array key => array(label, kind) */ function yang_hl_themes() { return array( // —— 浅色 10 套 'github' => array('GitHub', 'light'), 'vs' => array('Visual Studio', 'light'), 'atom-one-light' => array('Atom One Light', 'light'), 'idea' => array('IntelliJ IDEA', 'light'), 'xcode' => array('Xcode', 'light'), 'stackoverflow-light' => array('StackOverflow Light', 'light'), 'intellij-light' => array('IntelliJ Light', 'light'), 'googlecode' => array('Google Code', 'light'), 'magula' => array('Magula', 'light'), 'color-brewer' => array('Color Brewer', 'light'), // —— 深色 10 套 'github-dark' => array('GitHub Dark', 'dark'), 'atom-one-dark' => array('Atom One Dark', 'dark'), 'monokai' => array('Monokai', 'dark'), 'github-dark-dimmed' => array('GitHub Dark Dimmed', 'dark'), 'a11y-dark' => array('A11y Dark', 'dark'), 'stackoverflow-dark' => array('StackOverflow Dark', 'dark'), 'night-owl' => array('Night Owl', 'dark'), 'srcery' => array('Srcery', 'dark'), 'panda-syntax-dark' => array('Panda Syntax Dark', 'dark'), 'tomorrow-night-blue' => array('Tomorrow Night Blue', 'dark'), ); } /** * 前端需要的配置(用 wp_localize_script 传,不内联拼字符串) * * @return array */ function yang_hl_js_config() { $key = yang_hl_theme_key(); $all = yang_hl_themes(); $kind = isset($all[$key]) ? $all[$key][1] : 'light'; return array( 'theme' => $key, 'kind' => $kind, 'showLn' => (bool) (function_exists('kratos_option') ? yang_hl_opt_bool('a_highlight_line_numbers', false) : false), 'showCopy' => (bool) (function_exists('kratos_option') ? yang_hl_opt_bool('a_highlight_copy_button', true) : true), 'autoDetect' => (bool) (function_exists('kratos_option') ? yang_hl_opt_bool('a_highlight_autodetect', true) : true), 'labels' => array( 'copy' => '复制', 'copied' => '✓ 已复制', 'failed' => '✗ 复制失败', 'guess' => '可能', ), ); } /** * 加载前端资源 * * 🔴 为什么必须用 wp_enqueue_style / wp_enqueue_script 而不是手写 : * 站点的 CDN 是**整页输出缓冲改写**,只认标准标签里的 src= / href=。 * 手拼的标签可能被改写坏掉,而 wp_enqueue 生成的是标准形态。 * * 🔴 配色 CSS 只加载当前选中的**一套**,不是 20 套全加载。 * 20 套合计约 50KB(gzip 后 ~15KB),全加载没必要; * 而且多套同时在场时,后加载的规则会压前面的,切换主题很容易"看起来没变"。 */ function yang_hl_enqueue() { if (!yang_hl_enabled()) { return; } // 后台、feed、登录页都不需要 if (is_admin() || is_feed()) { return; } /* 🔴 版本号必须**从主题头读**,不能写死。 * 写死的后果:以后主题升到 3.2.50,这里还报 3.2.42 ⇒ * 浏览器认定 assets 没变,继续用旧缓存。改 CSS/JS 完全不生效, * 而 style.css 的版本号却是新的 —— 排查起来极度迷惑 * (本项目历史上被 OPcache/缓存这类"文件名对、内容旧"坑过多次)。 * 读主题头用 wp_get_theme()->get('Version'),WordPress 自带缓存,无额外开销。 */ $theme = wp_get_theme(); $ver = $theme && $theme->get('Version') ? $theme->get('Version') : '3.2.42'; $uri = get_template_directory_uri(); $key = yang_hl_theme_key(); // 配色(先加载,让 .cb 的骨架样式能覆盖它的 padding/背景) $style_file = get_template_directory() . '/assets/hljs/styles/' . $key . '.min.css'; if (file_exists($style_file)) { wp_enqueue_style( 'yang-hl-theme', $uri . '/assets/hljs/styles/' . $key . '.min.css', array(), $ver ); } // 卡片骨架样式 wp_enqueue_style( 'yang-hl', $uri . '/assets/css/highlight.css', array('yang-hl-theme'), $ver ); // hljs 本体 wp_enqueue_script( 'yang-hl-lib', $uri . '/assets/hljs/highlight.min.js', array(), $ver, true ); // 业务脚本 wp_enqueue_script( 'yang-hl', $uri . '/assets/js/highlight.js', array('yang-hl-lib'), $ver, true ); // 配置通过 wp_localize_script 传,不内联拼字符串(避免转义问题和 CDN 改写) wp_localize_script('yang-hl', 'YANG_HL', yang_hl_js_config()); // 行号开关是 body 上的类,服务端直接输出类名避免闪烁 if (function_exists('kratos_option') && yang_hl_opt_bool('a_highlight_line_numbers', false)) { add_filter('body_class', function ($classes) { $classes[] = 'opt-ln'; return $classes; }); } } add_action('wp_enqueue_scripts', 'yang_hl_enqueue', 20);