Files
wordpress-kratos-yang/inc/theme-highlight.php
T
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

591 lines
22 KiB
PHP
Raw 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.
<?php
/**
* 代码高亮(highlight.js 自托管)
*
* 取代原来的 Prism(wp-editormd 自带)+ Enlighter 插件。
*
* ── 为什么要在服务端做这么多事 ────────────────────────────────
* 站内 284 个代码块里有 **80 个是裸 `<pre>`(没有 `<code>` 子元素)**,
* 占 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 '';
}
/**
* 把一个 <pre> 片段规范化成"代码块卡片"的结构
*
* 输入可能是四种形态中的任意一种(站内都实测存在):
* A. <pre><code class="language-python">…</code></pre> ← 标准,204 个
* B. <pre class="language-python"><code>…</code></pre> ← 类在 pre 上
* C. <pre><code>…</code></pre> ← 无标记,需自动识别
* D. <pre>…</pre> ← 裸 pre,80 个,必须处理
*
* 输出统一是:
* <figure class="cb" data-lang="…">
* <figcaption class="cb-bar">
* <span class="cb-lang">…</span>
* <span class="cb-actions"><button class="cb-copy">复制</button></span>
* </figcaption>
* <div class="cb-body">
* <div class="cb-ln" aria-hidden="true"><span>1</span>…</div>
* <pre class="hljs"><code class="hljs language-…">…</code></pre>
* </div>
* </figure>
*
* 🔴 行号为什么放在 <pre> **外面**的独立槽里:
* ① 放里面就得逐行切 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 = '';
// 先把可能存在的 <code …> 外壳剥出来,取它的 class 和内容
if (preg_match('#^\s*<code\b([^>]*)>(.*)</code>\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 转义过的(&lt; &amp;),
// 这里绝不能再 esc_html 一次,否则会变成 &amp;lt; 双转义,页面上显示 &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 .= '<span>' . $i . '</span>';
}
$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 '<figure class="cb hl-' . esc_attr($theme_key) . '"'
. ' data-lang="' . esc_attr($lang) . '"'
. ' data-theme="' . esc_attr($theme_key) . '"'
. ' data-kind="' . esc_attr($kind) . '"'
. ' data-guess="' . $is_guess . '">'
. '<figcaption class="cb-bar">'
. '<span class="cb-lang"' . ($is_guess === '1' ? ' data-guess="1"' : '') . '>'
. ($label !== '' ? esc_html($label) : '—')
. '</span>'
. '<span class="cb-actions">'
. '<button type="button" class="cb-copy" data-copy>复制</button>'
. '</span>'
. '</figcaption>'
. '<div class="cb-body">'
. '<div class="cb-ln" aria-hidden="true">' . $gutter . '</div>'
. '<pre class="hljs"><code' . $lang_class . '>' . $body . '</code></pre>'
. '</div>'
. '</figure>';
}
/**
* 正文过滤器:把文章里的所有 <pre> 规范成代码块卡片
*
* 挂在 the_content 上,优先级 9 —— 要在 wpautop(10) **之前**。
* 为什么必须早于 wpautop:裸 <pre> 被 wpautop 处理时会被包进 <p>,
* 而 <p> 的 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, '<pre') === false) {
return $content;
}
// 选项关掉就原样返回("启用开关")
if (function_exists('kratos_option')
&& !yang_hl_opt_bool('a_highlight_enable', true)) {
return $content;
}
$out = preg_replace_callback(
'#<pre\b([^>]*)>(.*?)</pre>#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 而不是手写 <link>:
* 站点的 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);