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,未解包覆盖服务器主题目录。
This commit is contained in:
@@ -58,6 +58,11 @@ require get_template_directory() . '/inc/theme-navwalker.php';
|
|||||||
// 对象存储配置
|
// 对象存储配置
|
||||||
require get_template_directory() . '/inc/theme-dogecloud.php';
|
require get_template_directory() . '/inc/theme-dogecloud.php';
|
||||||
|
|
||||||
|
// CDN 加速(地址改写:CDN 地址 + 白名单 + 黑名单)。
|
||||||
|
// 必须排在 theme-core.php 之后:它要用 kratos_option 与 wp_parse_url。
|
||||||
|
// 挂在 template_redirect 上开输出缓冲,所以本文件晚加载不影响生效。
|
||||||
|
require get_template_directory() . '/inc/theme-cdn.php';
|
||||||
|
|
||||||
// 邮件模板:必须先于 theme-mail.php 加载,
|
// 邮件模板:必须先于 theme-mail.php 加载,
|
||||||
// 因为后者在发信时会调用 yang_mail_render()
|
// 因为后者在发信时会调用 yang_mail_render()
|
||||||
require get_template_directory() . '/inc/theme-mail-template.php';
|
require get_template_directory() . '/inc/theme-mail-template.php';
|
||||||
|
|||||||
@@ -0,0 +1,728 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CDN 加速
|
||||||
|
*
|
||||||
|
* ── 做什么 ────────────────────────────────────────────────────
|
||||||
|
* 把页面里指向本站的静态资源地址,前缀换成用户配置的 CDN 地址。
|
||||||
|
* 参考 CDN Enabler 插件的思路,但只保留三件事:CDN 地址、白名单、黑名单。
|
||||||
|
*
|
||||||
|
* ── 为什么在输出缓冲层做,而不是改 enqueue ────────────────────
|
||||||
|
* 主题自己 enqueue 的资源只有十来个,可以精确改;
|
||||||
|
* 但**文章正文里的图片、插件输出的样式、Gutenberg 区块的图片**都绕过
|
||||||
|
* enqueue,直接写死在 HTML 里。要覆盖这些,只能在最终 HTML 上做。
|
||||||
|
* 而且这样做完,主题自身资源也自动被覆盖,不必两套逻辑。
|
||||||
|
*
|
||||||
|
* ── 安全底线(硬编码,不受规则影响)──────────────────────────
|
||||||
|
* 站点被 CDN 改坏最常见的原因是「把页面地址也改了」:
|
||||||
|
* 导航链接变成 CDN 域名 → 用户点一下就离开本站 → 全站 404。
|
||||||
|
* 所以下面这些一律不改写,且**不能**通过白名单放开:
|
||||||
|
* · 以 / 结尾的地址(页面、栏目页)
|
||||||
|
* · .php / .html / .htm 等页面或动态后缀
|
||||||
|
* · wp-admin / wp-login / wp-json / wp-cron 等后台与接口路径
|
||||||
|
* · 非本站域名(外链、别人家的 CDN)
|
||||||
|
* · data: / mailto: / tel: / javascript: 等非 http(s) 协议
|
||||||
|
*
|
||||||
|
* @author Yongquan Yang <yangyq.net>
|
||||||
|
* @license MIT License
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时默认值
|
||||||
|
*
|
||||||
|
* 与 3.2.32 的教训一致:`std` 只在后台预填和恢复默认时有用,
|
||||||
|
* **运行时取不到**。所以这里单独给一份,供 kratos_option() 回落。
|
||||||
|
*/
|
||||||
|
function yang_cdn_defaults()
|
||||||
|
{
|
||||||
|
return array(
|
||||||
|
'cdn_url' => '',
|
||||||
|
'cdn_include' => '',
|
||||||
|
'cdn_exclude' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一个 CDN 配置
|
||||||
|
*
|
||||||
|
* 注意空字符串的处理:用户可能明确把 CDN 地址清空(想关掉功能),
|
||||||
|
* 那时库里存的是 '',键存在。`isset()` 挡不住这种情况 ——
|
||||||
|
* 它只判"键在不在"。所以直接返回,由调用方判空。
|
||||||
|
*/
|
||||||
|
function yang_cdn_opt($key)
|
||||||
|
{
|
||||||
|
$d = yang_cdn_defaults();
|
||||||
|
$v = kratos_option($key, isset($d[$key]) ? $d[$key] : '');
|
||||||
|
return is_string($v) ? $v : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CDN 地址
|
||||||
|
*
|
||||||
|
* 返回 array(scheme, host, base_path),无法解析时返回 null。
|
||||||
|
* 允许用户只填 `cdn.example.com`(不带协议)—— 这时按当前站点协议补全,
|
||||||
|
* 因为在 HTTPS 站点上引用 http 资源会被浏览器拦成混合内容。
|
||||||
|
*/
|
||||||
|
function yang_cdn_parse_url($raw)
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去掉尾部斜杠(后面拼路径时会自己加),但保留根路径语义
|
||||||
|
$raw = rtrim($raw, '/');
|
||||||
|
|
||||||
|
if (strpos($raw, '//') === 0) {
|
||||||
|
$raw = 'https:' . $raw;
|
||||||
|
} elseif (!preg_match('#^[a-z][a-z0-9+.\-]*://#i', $raw)) {
|
||||||
|
$raw = 'https://' . $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = wp_parse_url($raw);
|
||||||
|
if (empty($p['host'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = !empty($p['scheme']) ? strtolower($p['scheme']) : 'https';
|
||||||
|
if ($scheme !== 'http' && $scheme !== 'https') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'scheme' => $scheme,
|
||||||
|
'host' => strtolower($p['host']),
|
||||||
|
'path' => isset($p['path']) ? rtrim($p['path'], '/') : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一行规则编译成一个判断函数
|
||||||
|
*
|
||||||
|
* ── 规则语法(一行一条)──────────────────────────────────────
|
||||||
|
* `*` 全部
|
||||||
|
* `jpg` / `.jpg` / `*.jpg` 扩展名
|
||||||
|
* `jpg,png` / `.jpg .png` 扩展名列表(逗号或空格分隔)
|
||||||
|
* `/wp-content/uploads/` 路径前缀(以 / 开头且不含通配符)
|
||||||
|
* 两端带 `*` 的写法 shell 通配,`*` 可跨 `/`
|
||||||
|
* (例:星号 + `/uploads/` + 星号)
|
||||||
|
* `uploads` 路径子串
|
||||||
|
* `~regex~` 正则,对完整 URL 做 preg_match
|
||||||
|
* `#` `;` 开头 注释
|
||||||
|
*
|
||||||
|
* ── 为什么把「前缀 / 子串 / 通配」混在一个字段里 ──────────────
|
||||||
|
* 用户明确要求「规则的制定要非常的灵活」,而分成多个字段会让后台变复杂
|
||||||
|
* (这正是他说"不要弄得太复杂"的地方)。所以按**字面形态**判断,
|
||||||
|
* 让一行文字自己说明它是什么规则。
|
||||||
|
*
|
||||||
|
* 返回 null 表示这行不是有效规则(空行、注释、写错的括号)。
|
||||||
|
*/
|
||||||
|
function yang_cdn_compile_rule($line)
|
||||||
|
{
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($line[0] === '#' || $line[0] === ';') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正则:用 ~...~ 包裹。必须是整行包裹,避免把路径里的 ~ 误当正则。
|
||||||
|
if (strlen($line) > 2 && $line[0] === '~' && substr($line, -1) === '~') {
|
||||||
|
$pattern = $line;
|
||||||
|
// 先校验一次,写错的正则不能留着 —— 它会在每个资源上抛 warning,
|
||||||
|
// 一个页面几十个资源就是几十条日志,把日志刷满还托慢页面。
|
||||||
|
if (@preg_match($pattern, '') === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return function ($url, $path) use ($pattern) {
|
||||||
|
return preg_match($pattern, $url) === 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部
|
||||||
|
if ($line === '*') {
|
||||||
|
return function ($url, $path) {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展名列表:`jpg,png,webp` / `.jpg .png` / `*.jpg,*.png`
|
||||||
|
// 判据 = 每一段都长得像扩展名(字母数字开头,不含 / 和 *)
|
||||||
|
$parts = preg_split('/[\s,]+/', $line, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
if (count($parts) > 0) {
|
||||||
|
$exts = array();
|
||||||
|
$all_ext = true;
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
$p = ltrim($p, '*');
|
||||||
|
$p = ltrim($p, '.');
|
||||||
|
if ($p === '' || !preg_match('/^[A-Za-z0-9]+$/', $p)) {
|
||||||
|
$all_ext = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$exts[] = strtolower($p);
|
||||||
|
}
|
||||||
|
if ($all_ext && !empty($exts)) {
|
||||||
|
// ── 单个纯字母词的两义并存 ──────────────────────────
|
||||||
|
//
|
||||||
|
// `jpg` 和 `uploads` 在字面上分不出谁是扩展名、谁是路径片段:
|
||||||
|
// 两个都是纯字母,都过得了上面的检查。若一律当扩展名,
|
||||||
|
// 用户写 `uploads` 想按目录筛就会完全失效(而且没有任何提示,
|
||||||
|
// 只是"规则没反应",最难查)。
|
||||||
|
//
|
||||||
|
// 所以单个词同时按**扩展名**和**路径子串**匹配:
|
||||||
|
// `jpg` → 扩展名命中 .jpg 文件(路径里也极少含 jpg)
|
||||||
|
// `uploads` → 子串命中 /wp-content/uploads/...(几乎没有
|
||||||
|
// 扩展名叫 uploads 的文件,不会误伤)
|
||||||
|
// 两个含义天然互斥,同时挂上不会造成歧义,反而省掉一次猜。
|
||||||
|
//
|
||||||
|
// 多个词(`jpg,png`)**只**按扩展名列表处理:那串文字整体
|
||||||
|
// 不可能出现在路径里,按子串匹配恒假,留着只会让人误解。
|
||||||
|
$one_word = (count($exts) === 1);
|
||||||
|
$needle = $one_word ? $exts[0] : '';
|
||||||
|
return function ($url, $path) use ($exts, $one_word, $needle) {
|
||||||
|
$e = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
if ($e !== '' && in_array($e, $exts, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($one_word && strlen($needle) > 1) {
|
||||||
|
return strpos(strtolower($path), $needle) !== false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 含通配符 → shell 通配(`*` 跨 `/`)
|
||||||
|
if (strpos($line, '*') !== false || strpos($line, '?') !== false) {
|
||||||
|
// 用 preg_quote 转义其余字符,再把占位符换回通配语义
|
||||||
|
$re = preg_quote($line, '#');
|
||||||
|
$re = str_replace(array('\*', '\?'), array('.*', '.'), $re);
|
||||||
|
$pattern = '#^' . $re . '$#i';
|
||||||
|
return function ($url, $path) use ($pattern) {
|
||||||
|
return preg_match($pattern, $path) === 1
|
||||||
|
|| preg_match($pattern, $url) === 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以 / 开头 → 路径前缀(含自身)
|
||||||
|
if ($line[0] === '/') {
|
||||||
|
$prefix = strtolower($line);
|
||||||
|
return function ($url, $path) use ($prefix) {
|
||||||
|
$p = strtolower($path);
|
||||||
|
return $p === rtrim($prefix, '/') || stripos($p, $prefix) === 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余 → 路径子串(不区分大小写)
|
||||||
|
$needle = strtolower($line);
|
||||||
|
return function ($url, $path) use ($needle) {
|
||||||
|
return strpos(strtolower($path), $needle) !== false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把整段文本编译成一串规则
|
||||||
|
*/
|
||||||
|
function yang_cdn_compile_list($raw)
|
||||||
|
{
|
||||||
|
$out = array();
|
||||||
|
foreach (preg_split('/\r\n|\r|\n/', (string) $raw) as $line) {
|
||||||
|
$fn = yang_cdn_compile_rule($line);
|
||||||
|
if ($fn !== null) {
|
||||||
|
$out[] = $fn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则编译缓存的登记处
|
||||||
|
*
|
||||||
|
* ── 为什么需要手动清 ────────────────────────────────────────
|
||||||
|
* 规则编译是有成本的(每行都要判形态、正则还要先试编译),所以
|
||||||
|
* yang_cdn_should_rewrite() 里用 static 缓存一份。但 static 一旦写进去
|
||||||
|
* 就跟着进程走,任何"改了配置马上要看到新结果"的场合都会拿到旧规则:
|
||||||
|
* · 后台的「测试这条地址」按钮 —— 用户刚在输入框里改完规则,还没保存
|
||||||
|
* · 自测脚本 —— 一个进程里要试几十种配置
|
||||||
|
* 所以把缓存句柄放在全局里,提供这个函数显式清掉。
|
||||||
|
*
|
||||||
|
* 后台测试用的是"临时覆盖层"(改 $GLOBALS['__yang_cdn_override']),
|
||||||
|
* 不写库、不触发 sanitize,所以不会像 3.2.11 的 SMTP 那样递归。
|
||||||
|
*/
|
||||||
|
function yang_cdn_reset_cache()
|
||||||
|
{
|
||||||
|
$GLOBALS['__yang_cdn_rules'] = null;
|
||||||
|
$GLOBALS['__yang_cdn_hosts'] = null;
|
||||||
|
$GLOBALS['__yang_cdn_base'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读一份(编译好的)规则,带缓存
|
||||||
|
*/
|
||||||
|
function yang_cdn_rules()
|
||||||
|
{
|
||||||
|
if (isset($GLOBALS['__yang_cdn_rules']) && is_array($GLOBALS['__yang_cdn_rules'])) {
|
||||||
|
return $GLOBALS['__yang_cdn_rules'];
|
||||||
|
}
|
||||||
|
$GLOBALS['__yang_cdn_rules'] = array(
|
||||||
|
'include' => yang_cdn_compile_list(yang_cdn_opt('cdn_include')),
|
||||||
|
'exclude' => yang_cdn_compile_list(yang_cdn_opt('cdn_exclude')),
|
||||||
|
);
|
||||||
|
return $GLOBALS['__yang_cdn_rules'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个地址是否该走 CDN
|
||||||
|
*
|
||||||
|
* $url 完整地址(可能是绝对地址,也可能是 /path 这种相对地址)
|
||||||
|
* $path 已经剥掉域名与查询串的纯路径
|
||||||
|
*
|
||||||
|
* ── 顺序很重要 ───────────────────────────────────────────────
|
||||||
|
* ① 硬性排除(页面/动态/后台/非本站)→ 无论规则怎么写都不改
|
||||||
|
* ② 黑名单 → 命中就退出
|
||||||
|
* ③ 白名单为空 = 全通过;否则必须命中至少一条
|
||||||
|
*/
|
||||||
|
function yang_cdn_should_rewrite($url, $path)
|
||||||
|
{
|
||||||
|
$rules = yang_cdn_rules();
|
||||||
|
$include_cache = $rules['include'];
|
||||||
|
$exclude_cache = $rules['exclude'];
|
||||||
|
|
||||||
|
// ① 硬性排除 ----------------------------------------------------
|
||||||
|
$lower_path = strtolower($path);
|
||||||
|
|
||||||
|
// 以 / 结尾 = 页面/栏目页。改了会让导航跳到 CDN 域名,全站 404。
|
||||||
|
if ($lower_path === '' || substr($lower_path, -1) === '/') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面与动态后缀
|
||||||
|
$ext = strtolower(pathinfo($lower_path, PATHINFO_EXTENSION));
|
||||||
|
$never = array('php', 'php3', 'php4', 'php5', 'phtml', 'html', 'htm', 'shtml',
|
||||||
|
'asp', 'aspx', 'jsp', 'cgi', 'pl', 'do', 'action');
|
||||||
|
if ($ext !== '' && in_array($ext, $never, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 没有扩展名也不改:通常是接口、短链、伪静态页
|
||||||
|
if ($ext === '' && strpos(basename($lower_path), '.') === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台与接口路径
|
||||||
|
foreach (array('/wp-admin/', '/wp-login.php', '/wp-json/', '/wp-cron.php',
|
||||||
|
'/xmlrpc.php', '/wp-comments-post.php', '/feed/') as $bad) {
|
||||||
|
if (strpos($lower_path, $bad) !== false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 黑名单 ------------------------------------------------------
|
||||||
|
foreach ($exclude_cache as $fn) {
|
||||||
|
if ($fn($url, $path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 白名单(空 = 全通过)----------------------------------------
|
||||||
|
if (empty($include_cache)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($include_cache as $fn) {
|
||||||
|
if ($fn($url, $path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个地址换成 CDN 地址
|
||||||
|
*
|
||||||
|
* 返回 false 表示不改。返回字符串表示换好的地址。
|
||||||
|
*/
|
||||||
|
function yang_cdn_rewrite_one($url, $site_hosts, $cdn)
|
||||||
|
{
|
||||||
|
$raw = $url;
|
||||||
|
|
||||||
|
// 非 http(s) 一律不动:data:、mailto:、tel:、javascript:、about:blank …
|
||||||
|
if (preg_match('#^(data|mailto|tel|javascript|about|blob|ftp):#i', $raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 协议相对地址 `//host/path` —— 必须按绝对地址处理,
|
||||||
|
// 否则下面按「相对路径」拼 base 会得到 `https://cdn.com//host/path`。
|
||||||
|
if (strpos($raw, '//') === 0) {
|
||||||
|
$abs = 'https:' . $raw;
|
||||||
|
$p = wp_parse_url($abs);
|
||||||
|
$host = isset($p['host']) ? strtolower($p['host']) : '';
|
||||||
|
if ($host === '' || !in_array($host, $site_hosts, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return yang_cdn_build($p, $cdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绝对地址:只有本站域名才改
|
||||||
|
if (preg_match('#^https?://#i', $raw)) {
|
||||||
|
$p = wp_parse_url($raw);
|
||||||
|
if (empty($p['host'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$host = strtolower($p['host']);
|
||||||
|
if (!in_array($host, $site_hosts, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return yang_cdn_build($p, $cdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对地址(以 / 开头,或者纯相对路径)
|
||||||
|
$p = wp_parse_url($raw);
|
||||||
|
if (empty($p['path'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$p['host'] = '';
|
||||||
|
|
||||||
|
return yang_cdn_build($p, $cdn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用解析好的 URL 分量拼出 CDN 地址
|
||||||
|
*
|
||||||
|
* 查询串与锚点原样保留 —— 版本号参数(`?ver=3.2.33`)丢了会让浏览器
|
||||||
|
* 一直用 CDN 上的旧缓存,是这类功能最常见的事故。
|
||||||
|
*/
|
||||||
|
function yang_cdn_build($p, $cdn)
|
||||||
|
{
|
||||||
|
$path = isset($p['path']) ? $p['path'] : '';
|
||||||
|
if ($path === '' || $path[0] !== '/') {
|
||||||
|
$path = '/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!yang_cdn_should_rewrite(
|
||||||
|
(isset($p['host']) && $p['host'] !== '' ? 'https://' . $p['host'] : '') . $path,
|
||||||
|
$path
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = $cdn['scheme'] . '://' . $cdn['host'] . $cdn['path'] . $path;
|
||||||
|
if (isset($p['query']) && $p['query'] !== '') {
|
||||||
|
$out .= '?' . $p['query'];
|
||||||
|
}
|
||||||
|
if (isset($p['fragment']) && $p['fragment'] !== '') {
|
||||||
|
$out .= '#' . $p['fragment'];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点自己的域名列表(要改写哪些域名)
|
||||||
|
*
|
||||||
|
* home_url / site_url 都算 —— WordPress 允许把站点装在子目录里,
|
||||||
|
* 也允许 home 与 site 不同域(前台与后台分开)。漏掉一个就会有一半
|
||||||
|
* 资源不走 CDN,而且很难发现是哪个域名漏了。
|
||||||
|
*/
|
||||||
|
function yang_cdn_site_hosts()
|
||||||
|
{
|
||||||
|
if (isset($GLOBALS['__yang_cdn_hosts']) && is_array($GLOBALS['__yang_cdn_hosts'])) {
|
||||||
|
return $GLOBALS['__yang_cdn_hosts'];
|
||||||
|
}
|
||||||
|
$hosts = array();
|
||||||
|
foreach (array(home_url('/'), site_url('/')) as $u) {
|
||||||
|
$p = wp_parse_url($u);
|
||||||
|
if (!empty($p['host'])) {
|
||||||
|
$hosts[] = strtolower($p['host']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 带 www 与不带 www 都要认:站点地址填了 www,而正文里可能写裸域名
|
||||||
|
foreach ($hosts as $h) {
|
||||||
|
if (strpos($h, 'www.') === 0) {
|
||||||
|
$hosts[] = substr($h, 4);
|
||||||
|
} else {
|
||||||
|
$hosts[] = 'www.' . $h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$hosts = array_values(array_unique($hosts));
|
||||||
|
$GLOBALS['__yang_cdn_hosts'] = $hosts;
|
||||||
|
return $hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题站点的根路径
|
||||||
|
*
|
||||||
|
* 站点装在子目录(`example.com/blog`)时,资源地址是
|
||||||
|
* `/blog/wp-content/...`。拼 CDN 地址时要把这段剥掉,否则会变成
|
||||||
|
* `cdn.com/blog/wp-content/...`,而 CDN 回源根目录下并没有 blog。
|
||||||
|
* 但用户的规则是按完整路径写的,所以**判规则时用原路径,拼接时才剥**。
|
||||||
|
*/
|
||||||
|
function yang_cdn_site_base()
|
||||||
|
{
|
||||||
|
if (isset($GLOBALS['__yang_cdn_base'])) {
|
||||||
|
return $GLOBALS['__yang_cdn_base'];
|
||||||
|
}
|
||||||
|
$p = wp_parse_url(home_url('/'));
|
||||||
|
$GLOBALS['__yang_cdn_base'] = isset($p['path']) ? rtrim($p['path'], '/') : '';
|
||||||
|
return $GLOBALS['__yang_cdn_base'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改写一整段 HTML
|
||||||
|
*
|
||||||
|
* 只碰「资源地址可能出现的位置」,不去正则所有 http 字符串 ——
|
||||||
|
* 后者会把正文里提到的网址、分享链接、评论内容一起改掉,
|
||||||
|
* 而且这类误改极难发现(读者点文章里的引用链接会跑到 CDN)。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* src= / href= 的静态资源(脚本、样式、图片、视频、字体)
|
||||||
|
* srcset=(响应式图片,一个属性里多组地址 + 宽度描述符)
|
||||||
|
* 内联样式与 <style> 里的 url(...)
|
||||||
|
*
|
||||||
|
* 不改 href 里指向页面/文档的链接 —— 由 yang_cdn_should_rewrite 的
|
||||||
|
* 硬性排除兜住(页面与 .html/.php 都不改)。
|
||||||
|
*/
|
||||||
|
function yang_cdn_rewrite_html($html)
|
||||||
|
{
|
||||||
|
$cdn = yang_cdn_parse_url(yang_cdn_opt('cdn_url'));
|
||||||
|
if ($cdn === null) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
if (strpos($html, 'http') === false && strpos($html, 'src=') === false
|
||||||
|
&& strpos($html, 'href=') === false && strpos($html, 'url(') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hosts = yang_cdn_site_hosts();
|
||||||
|
$base = yang_cdn_site_base();
|
||||||
|
|
||||||
|
// 包一层:把站点子目录剥掉再拼 CDN
|
||||||
|
$build = function ($p) use ($cdn, $base) {
|
||||||
|
if ($base !== '' && !empty($p['path'])) {
|
||||||
|
$pp = $p['path'];
|
||||||
|
if ($pp === $base || strpos($pp, $base . '/') === 0) {
|
||||||
|
$stripped = substr($pp, strlen($base));
|
||||||
|
$p['path'] = ($stripped === '' ? '/' : $stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return yang_cdn_build($p, $cdn);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── srcset:先处理,因为它的格式(多组 + 描述符)不通用 ──
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'#(\ssrcset\s*=\s*)(["\'])(.*?)\2#is',
|
||||||
|
function ($m) use ($hosts, $build) {
|
||||||
|
$items = array();
|
||||||
|
$changed = false;
|
||||||
|
foreach (explode(',', $m[3]) as $item) {
|
||||||
|
$item = trim($item);
|
||||||
|
if ($item === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 每组 = 地址 + 可选描述符(`2x`、`480w`)
|
||||||
|
$sp = preg_split('/\s+/', $item, 2);
|
||||||
|
$u = $sp[0];
|
||||||
|
$desc = isset($sp[1]) ? ' ' . $sp[1] : '';
|
||||||
|
if (preg_match('#^(https?:)?//#i', $u)) {
|
||||||
|
$p = wp_parse_url(preg_match('#^//#', $u) ? 'https:' . $u : $u);
|
||||||
|
$host = isset($p['host']) ? strtolower($p['host']) : '';
|
||||||
|
if ($host !== '' && in_array($host, $hosts, true)) {
|
||||||
|
$new = $build($p);
|
||||||
|
if ($new !== false) {
|
||||||
|
$u = $new;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($u !== '' && $u[0] === '/' && substr($u, 0, 2) !== '//') {
|
||||||
|
$p = wp_parse_url($u);
|
||||||
|
$p['host'] = '';
|
||||||
|
$new = $build($p);
|
||||||
|
if ($new !== false) {
|
||||||
|
$u = $new;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$items[] = $u . $desc;
|
||||||
|
}
|
||||||
|
if (!$changed) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
return $m[1] . $m[2] . implode(', ', $items) . $m[2];
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── src= / href= ──
|
||||||
|
// 值里可能带查询串(`?ver=`),用 [^"']* 而不是「到 ? 为止」,
|
||||||
|
// 否则会截断成半截地址。
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'#(\s(?:src|href)\s*=\s*)(["\'])((?:(?:https?:)?//|/)[^"\']*)\2#is',
|
||||||
|
function ($m) use ($hosts, $build) {
|
||||||
|
$new = yang_cdn_rewrite_abs($m[3], $hosts, $build);
|
||||||
|
if ($new === false) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
return $m[1] . $m[2] . $new . $m[2];
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 内联样式与 <style> 里的 url() ──
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'#url\(\s*(["\']?)((?:(?:https?:)?//|/)[^)"\']*)\1\s*\)#i',
|
||||||
|
function ($m) use ($hosts, $build) {
|
||||||
|
$new = yang_cdn_rewrite_abs($m[2], $hosts, $build);
|
||||||
|
if ($new === false) {
|
||||||
|
return $m[0];
|
||||||
|
}
|
||||||
|
return 'url(' . $m[1] . $new . $m[1] . ')';
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改写一个「属性值里的地址」
|
||||||
|
*
|
||||||
|
* 与 yang_cdn_rewrite_one 的区别:这个函数只负责认形态(绝对/协议相对/相对),
|
||||||
|
* 真正的拼接交给外部传进来的 $build(它负责剥站点子目录)。
|
||||||
|
*/
|
||||||
|
function yang_cdn_rewrite_abs($u, $hosts, $build)
|
||||||
|
{
|
||||||
|
if ($u === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^//#', $u)) {
|
||||||
|
$p = wp_parse_url('https:' . $u);
|
||||||
|
$host = isset($p['host']) ? strtolower($p['host']) : '';
|
||||||
|
if ($host === '' || !in_array($host, $hosts, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $build($p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^https?://#i', $u)) {
|
||||||
|
$p = wp_parse_url($u);
|
||||||
|
$host = isset($p['host']) ? strtolower($p['host']) : '';
|
||||||
|
if ($host === '' || !in_array($host, $hosts, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return $build($p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($u[0] === '/') {
|
||||||
|
$p = wp_parse_url($u);
|
||||||
|
if (empty($p['path'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$p['host'] = '';
|
||||||
|
return $build($p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出缓冲:整页改写
|
||||||
|
*
|
||||||
|
* ── 为什么用 ob_start 而不是各种 the_content 过滤器 ──────────
|
||||||
|
* 资源地址散落在主题模板、插件输出、区块渲染、小工具里,
|
||||||
|
* 逐个挂钩子必然漏。整页缓冲是唯一能一次覆盖全的地方。
|
||||||
|
*
|
||||||
|
* ── 什么时候不改 ────────────────────────────────────────────
|
||||||
|
* 后台、REST、AJAX、feed、cron、预览、定制器:这些地方的地址带 CDN
|
||||||
|
* 会妨碍编辑与调试(编辑器里看不到真实图片、REST 返回的地址对不上库)。
|
||||||
|
*/
|
||||||
|
function yang_cdn_buffer_start()
|
||||||
|
{
|
||||||
|
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defined('REST_REQUEST') && REST_REQUEST) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defined('WP_CLI') && WP_CLI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (function_exists('is_feed') && is_feed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 定制器预览:用户正在改主题设置,看到的应该是真实地址
|
||||||
|
if (isset($_GET['customize_changeset_uuid']) || isset($_GET['customize_preview'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 站点还没配 CDN 地址 —— 直接不接管,零开销
|
||||||
|
if (yang_cdn_parse_url(yang_cdn_opt('cdn_url')) === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start('yang_cdn_buffer_callback');
|
||||||
|
}
|
||||||
|
add_action('template_redirect', 'yang_cdn_buffer_start', 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出缓冲回调
|
||||||
|
*
|
||||||
|
* 只处理 HTML。图片、CSS、JS、XML 直接原样返回 ——
|
||||||
|
* 对二进制内容调 preg_replace 既没用又可能损坏数据。
|
||||||
|
*/
|
||||||
|
function yang_cdn_buffer_callback($html)
|
||||||
|
{
|
||||||
|
if (!is_string($html) || $html === '') {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
// 只看开头一小段判断类型,别对整页做正则
|
||||||
|
$head = substr($html, 0, 200);
|
||||||
|
if (stripos($head, '<!doctype') === false && stripos($head, '<html') === false
|
||||||
|
&& stripos($head, '<head') === false) {
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
return yang_cdn_rewrite_html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台「测试这条地址走不走 CDN」
|
||||||
|
*
|
||||||
|
* 给后台一个只读的诊断入口:用户填了规则,不用去前台翻源码就能确认
|
||||||
|
* 「我这个图片到底走不走 CDN」。返回数组,供 AJAX 与自测脚本共用。
|
||||||
|
*/
|
||||||
|
function yang_cdn_test_url($url)
|
||||||
|
{
|
||||||
|
$cdn = yang_cdn_parse_url(yang_cdn_opt('cdn_url'));
|
||||||
|
$r = array(
|
||||||
|
'input' => $url,
|
||||||
|
'cdn' => $cdn === null ? '' : ($cdn['scheme'] . '://' . $cdn['host'] . $cdn['path']),
|
||||||
|
'matched' => false,
|
||||||
|
'output' => $url,
|
||||||
|
'reason' => '',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($cdn === null) {
|
||||||
|
$r['reason'] = 'CDN 地址未填写或无法解析';
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = wp_parse_url($url);
|
||||||
|
if (empty($p['path'])) {
|
||||||
|
$r['reason'] = '这个地址没有路径部分';
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
$path = $p['path'];
|
||||||
|
$r['path'] = $path;
|
||||||
|
|
||||||
|
if (!yang_cdn_should_rewrite($url, $path)) {
|
||||||
|
$r['reason'] = '被排除(硬性规则或黑名单),或未命中白名单';
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
$r['matched'] = true;
|
||||||
|
$r['output'] = yang_cdn_rewrite_one($url, yang_cdn_site_hosts(), $cdn);
|
||||||
|
if ($r['output'] === false) {
|
||||||
|
$r['matched'] = false;
|
||||||
|
$r['output'] = $url;
|
||||||
|
$r['reason'] = '域名不是本站(或地址形态无法识别)';
|
||||||
|
} else {
|
||||||
|
$r['reason'] = '已命中,将改写到 CDN';
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
+78
-1
@@ -872,7 +872,84 @@ function kratos_options()
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
* 十一、自定义代码 —— 兜底用,放最后
|
* 十二、CDN 加速 —— 三个输入框,不多加
|
||||||
|
*
|
||||||
|
* 用户明确要求「一共就这 3 个选项,不要弄得太复杂」。
|
||||||
|
* 所以这里没有开关、没有自动开关、没有服务商选择,只有:
|
||||||
|
* CDN 地址 / 白名单 / 黑名单
|
||||||
|
* 地址留空 = 功能关闭。这比再加一个「启用」勾选框更省事,
|
||||||
|
* 也不会出现「勾了启用但没填地址」这种半配置状态。
|
||||||
|
*
|
||||||
|
* 匹配逻辑(写进说明里,让用户看得到):
|
||||||
|
* 先看黑名单 → 命中就保持原地址
|
||||||
|
* 黑名单没命中 → 再看白名单
|
||||||
|
* 黑名单优先级高于白名单
|
||||||
|
* ══════════════════════════════════════════════════════════ */
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('CDN 加速', 'kratos'),
|
||||||
|
'type' => 'heading',
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('CDN 地址', 'kratos'),
|
||||||
|
'desc' => __('资源的加速前缀,例如 <code>https://cdn.example.com</code>。可以不带协议(自动按 https 补全)。<b>留空即关闭整个功能</b>,站点行为与不装这项功能完全一致。', 'kratos'),
|
||||||
|
'id' => 'cdn_url',
|
||||||
|
'std' => '',
|
||||||
|
'type' => 'text',
|
||||||
|
'placeholder' => 'https://cdn.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('白名单', 'kratos'),
|
||||||
|
'desc' => __('命中这些规则的文件<b>才</b>走 CDN。一行一条,<code>#</code> 或 <code>;</code> 开头的行是注释。<b>留空 = 所有静态文件都走 CDN</b>。', 'kratos'),
|
||||||
|
'id' => 'cdn_include',
|
||||||
|
'std' => '',
|
||||||
|
'type' => 'code',
|
||||||
|
'settings' => array('rows' => 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('白名单写法', 'kratos'),
|
||||||
|
'desc' => __('按<b>路径</b>匹配(正则规则除外,它对完整地址匹配)。下面每种写法都可以直接抄:', 'kratos')
|
||||||
|
. '<br><code>*</code> —— 全部静态文件'
|
||||||
|
. '<br><code>jpg</code> 或 <code>.jpg</code> 或 <code>*.jpg</code> —— 扩展名'
|
||||||
|
. '<br><code>jpg,png,webp</code> 或 <code>.jpg .png</code> —— 一串扩展名,命中任一'
|
||||||
|
. '<br><code>/wp-content/uploads/</code> —— 以这个路径开头(以斜杠开头、不含星号)'
|
||||||
|
. '<br><code>*/uploads/*</code> —— 通配符,<code>*</code> 可以跨斜杠'
|
||||||
|
. '<br><code>uploads</code> —— 路径里含这几个字(子串)'
|
||||||
|
. '<br><code>~\/20\d\d\/~</code> —— 正则,对完整地址匹配',
|
||||||
|
'type' => 'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('黑名单', 'kratos'),
|
||||||
|
'desc' => __('命中的文件<b>即使符合白名单也不走 CDN</b>,保持原地址。一行一条,写法与白名单完全相同。<b>黑名单优先于白名单</b>:两边都命中时按黑名单处理。', 'kratos'),
|
||||||
|
'id' => 'cdn_exclude',
|
||||||
|
'std' => '',
|
||||||
|
'type' => 'code',
|
||||||
|
'settings' => array('rows' => 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('这些地址永远不走 CDN', 'kratos'),
|
||||||
|
'desc' => __('下面这些不受两套名单影响,是硬性排除 —— 把它们改到 CDN 会让站点直接坏掉,所以不给开关:'
|
||||||
|
. '<br>· <b>以斜杠结尾</b>的地址(页面、栏目页、导航链接):改了会让访客跳去 CDN 域名,全站 404'
|
||||||
|
. '<br>· <b>页面与动态后缀</b>:<code>php</code>、<code>html</code>、<code>htm</code>、<code>asp</code> 等'
|
||||||
|
. '<br>· <b>没有后缀</b>的地址:通常是接口、短链、伪静态页'
|
||||||
|
. '<br>· <b>后台与接口路径</b>:<code>wp-admin</code>、<code>wp-login.php</code>、<code>wp-json</code>、<code>wp-cron.php</code>、<code>xmlrpc.php</code>、<code>/feed/</code>'
|
||||||
|
. '<br>· <b>非本站域名</b>:外链图片、别人家的 CDN'
|
||||||
|
. '<br>· <b>非 http(s) 协议</b>:<code>data:</code>、<code>mailto:</code>、<code>tel:</code>、<code>javascript:</code>', 'kratos'),
|
||||||
|
'type' => 'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
$options[] = array(
|
||||||
|
'name' => __('后台与接口里不改写', 'kratos'),
|
||||||
|
'desc' => __('为了不影响编辑与调试,下面这些地方<b>保持原地址</b>:后台页面、REST 接口、AJAX 请求、feed 订阅、WP-Cron、定制器预览、命令行。只有访客看到的前台页面才改写。', 'kratos'),
|
||||||
|
'type' => 'info',
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════
|
||||||
|
* 十三、自定义代码 —— 兜底用,放最后
|
||||||
*
|
*
|
||||||
* 3.2.29 起这两项默认都为空:站点自己的通用样式
|
* 3.2.29 起这两项默认都为空:站点自己的通用样式
|
||||||
* (段落缩进、h2 蓝条、列表缩进)已并入 assets/css/site.css,
|
* (段落缩进、h2 蓝条、列表缩进)已并入 assets/css/site.css,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*
|
/*
|
||||||
Theme Name: Kratos_Yang
|
Theme Name: Kratos_Yang
|
||||||
Text Domain: kratos_Yang
|
Text Domain: kratos_Yang
|
||||||
Version: 3.2.33
|
Version: 3.2.34
|
||||||
Requires PHP: 7.0
|
Requires PHP: 7.0
|
||||||
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
|
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
|
||||||
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好
|
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好
|
||||||
|
|||||||
Reference in New Issue
Block a user