0 && ob_get_level() >= self::$own_buffer_level ) { if ( ! ob_end_flush() ) { break; } } self::$own_buffer_level = 0; } /** * sitemap 缓冲的回调:把里面的地址换成当前语言的版本。 * * XML 不能交给 HTML 解析器(会被 libxml 改得面目全非),只要把 * 换掉就够了。不是 sitemap 的 XML 原样放行。 * * @param string $xml 输出内容。 * @return string */ public static function filter_xml( $xml ) { if ( '' === self::$language || ! self::is_sitemap_xml( (string) $xml ) ) { return $xml; } return self::filter_sitemap( $xml ); } /** * 请求路径看起来是不是指向一个 XML 文件。 * * sitemap 不管由核心还是插件生成,地址末段都是 .xml:核心 /wp-sitemap.xml、 * Yoast /sitemap_index.xml、Google XML Sitemaps /sitemap.xml。 * * @param string $path 站内路径。 * @return bool */ private static function looks_like_xml( $path ) { return (bool) preg_match( '@\.xml$@i', (string) $path ); } /** * 模板缓冲过滤器:把页面里的原文换成译文。 * * @param string $html 页面 HTML。 * @return string */ public static function filter_output( $html ) { if ( '' === trim( (string) $html ) || '' === self::$language ) { return $html; } $from = APGT_Settings::default_language(); $to = self::$language; if ( '' === $from || $from === $to ) { return $html; } $with_urls = APGT_Translator::translate_urls_enabled(); $texts = APGT_Html::extract( $html, $with_urls, $types, $optional ); if ( empty( $texts ) ) { return $html; } $md5s = array_keys( $texts ); $map = APGT_Store::get_trans_map( $md5s, $from, $to ); // 覆盖率不够时先补翻,补完再决定要不要输出译文。 $settings = APGT_Settings::get_settings(); if ( ! empty( $settings['realtime_trans'] ) && count( $map ) < count( $texts ) ) { $filled = self::realtime_translate( $texts, $types, $from, $to ); if ( $filled ) { $map = APGT_Store::get_trans_map( $md5s, $from, $to ); } } APGT_Progress::save( self::$path, $to, count( $texts ), count( $map ) ); // 达不到设定比例就原样输出,避免半中半英的页面被搜索引擎收录。 // 只数访客看得见的条目:JSON-LD 里的标题、说明是给搜索引擎的元数据,缺一条 // 不该让整页退回原文。2.3.0 起才收 JSON-LD,老页面这几条都还没译,按全部 // 条目算的话,升级后已译完的页面会一夜之间全变回原文。 $required = array_diff_key( $texts, $optional ); $ratio = count( $required ) > 0 ? ( count( array_intersect_key( $map, $required ) ) / count( $required ) ) * 100 : 100; $need = max( 0, min( 100, (int) $settings['realtime_ratio'] ) ); if ( $ratio + 0.0001 < $need ) { self::mark_uncacheable(); self::send_status( 'source', $ratio, $need ); return $html; } self::send_status( 'translated', $ratio, $need ); return APGT_Html::replace( $html, $map, APGT_Translator::text_replace_rules( $to ), $with_urls, // 站内链接统一挂到当前语言目录下,否则点一下就跳回原文站点。 self::language_dir( $to ), array( 'html_attrs' => self::html_attributes( $to ), 'og_locale' => APGT_Settings::og_locale( $to ), 'in_language' => APGT_Settings::hreflang_code( $to ), ) ); } /** * 告诉页面缓存插件:这一份别存。 * * 译文没到位、原文顶上时,输出的是一份暂时的页面。页面缓存要是把它存在 * /es/ 这个地址下,译文补齐以后访客拿到的还是缓存里的原文,清缓存也只 * 能等下一次访问再存一份原文——只要还有缺口就永远如此。 * * @return void */ private static function mark_uncacheable() { // WP Super Cache、W3 Total Cache、WP Rocket、LiteSpeed Cache 等都认这个常量。 if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- 页面缓存插件约定的常量名。 } // 服务器与 CDN 一层的缓存看的是响应头。 if ( ! headers_sent() ) { nocache_headers(); } } /** * 在响应头里说明这一页是怎么出的,方便对着 curl -I 排查「为什么没翻译」。 * * @param string $served 'translated' 或 'source'。 * @param float $ratio 覆盖率(0–100)。 * @param int $need 设定的门槛(0–100)。 * @return void */ private static function send_status( $served, $ratio, $need ) { if ( headers_sent() ) { return; } header( sprintf( 'X-APGT-Status: %s; coverage=%d%%; required=%d%%', $served, (int) floor( $ratio + 0.0001 ), (int) $need ) ); } /** * 译文页面 上该有的语言属性。 * * 主题给的 lang 是站点源语言,/es/ 页面上还写着 en-US,搜索引擎和读屏 * 软件都会认错语言;RTL 语言还得带 dir="rtl",浏览器才按从右往左排版。 * 目标语言不是 RTL 时 dir 给空串,由回填那边把原有的 dir 去掉——源站 * 是阿拉伯语、译成英语的时候,不能留着 rtl。 * * @param string $iso 目标语言。 * @return array lang、dir 两项;dir 为空串表示去掉该属性。 */ private static function html_attributes( $iso ) { return array( 'lang' => APGT_Settings::hreflang_code( $iso ), 'dir' => APGT_Settings::is_rtl( $iso ) ? 'rtl' : '', ); } /** * 输出内容是不是 sitemap XML。 * * 不看请求地址而看内容本身:WordPress 内置的是 /wp-sitemap*.xml, * Yoast 是 /sitemap_index.xml,别的插件各有各的命名,认内容才不会漏。 * * @param string $content 输出内容。 * @return bool */ private static function is_sitemap_xml( $content ) { $head = ltrim( substr( $content, 0, 512 ) ); if ( 0 !== strpos( $head, ',一并处理; * 、 这些资源地址不匹配,也不该动。 * 引用的 xsl 同理不受影响。 * * @param string $xml 原始 XML。 * @return string */ private static function filter_sitemap( $xml ) { return preg_replace_callback( '@(.*?)@is', function ( $m ) { $url = html_entity_decode( trim( $m[1] ), ENT_QUOTES | ENT_XML1, 'UTF-8' ); $new = self::localize_url( $url ); return '' . htmlspecialchars( $new, ENT_QUOTES | ENT_XML1, 'UTF-8' ) . ''; }, $xml ); } /** * 把一个站内地址换成当前语言的地址。 * * 站外地址、以及不该翻译的路径(wp-admin、静态资源等)原样返回。 * * @param string $url 原始地址。 * @return string */ public static function localize_url( $url ) { $url = trim( (string) $url ); if ( '' === $url || '' === self::$language ) { return $url; } $host = wp_parse_url( $url, PHP_URL_HOST ); if ( $host ) { $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( ! $site_host || strtolower( $host ) !== strtolower( $site_host ) ) { return $url; } } // 拆出 scheme://host 与站内路径。 $prefix = ''; if ( preg_match( '@^([a-z][a-z0-9+.\-]*:)?//[^/]*@i', $url, $m ) ) { $prefix = $m[0]; $path = substr( $url, strlen( $prefix ) ); } else { $path = $url; } if ( '' === $path ) { $path = '/'; } if ( '/' !== $path[0] ) { return $url; } $dir = '/' . self::language_dir( self::$language ); if ( 0 === strpos( $path, $dir . '/' ) || $path === $dir ) { return $url; // 已经是本语言的地址。 } // 后台、REST、静态资源这些翻了只会 404。 if ( ! APGT_Html::is_public_path( $path ) ) { return $url; } if ( APGT_Translator::translate_urls_enabled() ) { $from = APGT_Settings::default_language(); if ( '' !== $from ) { $trans = APGT_Store::uri_translation( $path, $from, self::$language ); if ( '' !== $trans ) { $path = $trans; } } } return $prefix . $dir . $path; } /** * 开缓冲区接管 robots.txt 的输出。 * * 与 end_robots_buffer() 成对,两者挂在同一个 do_robots 动作的两端。 * * @return void */ public static function start_robots_buffer() { self::$robots_buffer = ob_start(); } /** * 关掉 robots.txt 缓冲,把补过语言 sitemap 的内容打印出去。 * * @return void */ public static function end_robots_buffer() { if ( ! self::$robots_buffer ) { return; } self::$robots_buffer = false; $output = (string) ob_get_clean(); // robots.txt 是纯文本,内容全部来自核心与其它插件的输出,原样打印。 echo self::filter_robots( $output ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } /** * robots.txt 里补上各语言的 sitemap。 * * 不写死地址,而是把已有的 Sitemap 行按语言复制一份——这样无论 sitemap * 由 WordPress 内置还是 Yoast / Google XML Sitemaps 之类插件生成, * 都能自动跟上,不用为每个插件写适配。 * * @param string $output robots.txt 的完整内容。 * @return string */ private static function filter_robots( $output ) { if ( ! get_option( 'blog_public' ) ) { return $output; } $langs = self::visible_languages(); if ( empty( $langs ) ) { return $output; } if ( ! preg_match_all( '@^\s*Sitemap:\s*(\S+)@im', $output, $matches ) ) { return $output; } $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); $existing = array_map( 'strtolower', $matches[1] ); $dir_map = APGT_Settings::dir_to_iso_map(); $added = array(); foreach ( $matches[1] as $url ) { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( $host && $site_host && strtolower( $host ) !== strtolower( $site_host ) ) { continue; } $path = (string) wp_parse_url( $url, PHP_URL_PATH ); // Sitemap 指令按 sitemaps.org 的协议只认 XML。有的插件(Google XML // Sitemaps 就是一例)会把给人看的 HTML 版也写进 robots.txt,那份 // 内容和 XML 版是同一批链接,跟着按语言复制一遍,等于让搜索引擎 // 把每个语言的同一批地址再抓一轮。 $ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ); if ( in_array( $ext, array( 'html', 'htm' ), true ) ) { continue; } // 已经是某个语言的 sitemap 就别再往上套目录了,否则重复执行会 // 生成 /ja/en/sitemap.xml 这种越滚越长的地址。 $first = strtok( ltrim( $path, '/' ), '/' ); if ( '' !== (string) $first && isset( $dir_map[ $first ] ) ) { continue; } foreach ( array_keys( $langs ) as $iso ) { $lang_url = self::url_with_dir( $url, self::language_dir( $iso ) ); if ( '' === $lang_url || in_array( strtolower( $lang_url ), $existing, true ) ) { continue; } $existing[] = strtolower( $lang_url ); $added[] = 'Sitemap: ' . $lang_url; } } if ( empty( $added ) ) { return $output; } return rtrim( $output, "\n" ) . "\n" . implode( "\n", $added ) . "\n"; } /** * 给一个站内地址加上语言目录。 * * @param string $url 原始地址。 * @param string $dir 语言目录(不带斜杠)。 * @return string 加不了时返回空串。 */ private static function url_with_dir( $url, $dir ) { if ( '' === $dir ) { return ''; } $prefix = ''; if ( preg_match( '@^([a-z][a-z0-9+.\-]*:)?//[^/]*@i', $url, $m ) ) { $prefix = $m[0]; $path = substr( $url, strlen( $prefix ) ); } else { $path = $url; } if ( '' === $path ) { $path = '/'; } if ( '/' !== $path[0] ) { return ''; } if ( 0 === strpos( $path, '/' . $dir . '/' ) || $path === '/' . $dir ) { return ''; } return $prefix . '/' . $dir . $path; } /** * 当前访客是不是搜索引擎爬虫。 * * 给语言管理里的「展示对象」用:有些站点只想把某个语言暴露给搜索引擎, * 或者反过来只给真人看。判断依据只有 UA,够用但不精确,所以只用来决定 * 露不露出入口,不拿它拦访问。 * * @return bool */ public static function is_crawler() { static $cached = null; if ( null !== $cached ) { return $cached; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $cached = '' !== $ua && (bool) preg_match( '@(googlebot|bingbot|baiduspider|yandex(bot)?|duckduckbot|slurp|sogou|exabot|facebot|' . 'facebookexternalhit|twitterbot|linkedinbot|applebot|petalbot|bytespider|ahrefsbot|' . 'semrushbot|mj12bot|dotbot|seznambot|crawler|spider|bot/)@i', $ua ); return $cached; } /** * 按「展示对象」判断某个语言要不要露出入口。 * * mode:0 所有访客、1 仅搜索引擎、2 仅普通用户。 * * @param array $row 语言配置行。 * @return bool */ public static function language_visible( array $row ) { $mode = isset( $row['mode'] ) ? (string) $row['mode'] : '0'; // 用 static:: 而不是 self::,子类可以覆盖爬虫判定(也方便测试)。 if ( '1' === $mode ) { return static::is_crawler(); } if ( '2' === $mode ) { return ! static::is_crawler(); } return true; } /** * 取当前访客可见的目标语言。 * * @return array iso => 配置行 */ public static function visible_languages() { $out = array(); foreach ( APGT_Settings::target_languages() as $iso => $row ) { if ( static::language_visible( $row ) ) { $out[ $iso ] = $row; } } return $out; } /** * 取某个语言的 URL 目录(语言管理里可自定义,默认就是 iso)。 * * @param string $iso 语言代码。 * @return string 不带斜杠,例如 en。 */ public static function language_dir( $iso ) { if ( '' === $iso ) { return ''; } $targets = APGT_Settings::target_languages(); if ( isset( $targets[ $iso ]['dir'] ) && '' !== $targets[ $iso ]['dir'] ) { return trim( (string) $targets[ $iso ]['dir'], '/' ); } return $iso; } /** * 取当前页面在某语言下的完整地址。 * * 开了 URL 翻译且这条路径有译文时用译文路径,这样地址栏、hreflang、 * 语言菜单三处指向的是同一个地址。 * * @param string $iso 语言代码;空串表示原文。 * @return string */ public static function language_url( $iso ) { if ( '' === $iso ) { return home_url( self::$path ); } $path = self::$path; if ( APGT_Translator::translate_urls_enabled() ) { $from = APGT_Settings::default_language(); if ( '' !== $from ) { $trans = APGT_Store::uri_translation( $path, $from, $iso ); if ( '' !== $trans ) { $path = $trans; } } } return home_url( '/' . self::language_dir( $iso ) . $path ); } /** * 实时补翻当前页面缺失的条目。 * * 行为与后台「AI 翻译」按钮一致:只翻没有译文的,结果直接入库。 * 抢不到锁就跳过——别让多个访客同时对同一批文本发请求。 * * @param array $texts md5 => 原文。 * @param array $types md5 => text|uri。 * @param string $from 源语言。 * @param string $to 目标语言。 * @return int 新增译文条数。 */ private static function realtime_translate( array $texts, $types, $from, $to ) { $existing = APGT_Store::get_trans_map( array_keys( $texts ), $from, $to ); $pending = array(); foreach ( $texts as $md5 => $text ) { if ( isset( $existing[ $md5 ] ) ) { continue; } if ( mb_strlen( $text, 'UTF-8' ) > APGT_Translator::MAX_TEXT_LENGTH ) { continue; } $pending[ $md5 ] = $text; } if ( empty( $pending ) ) { return 0; } // 只锁当前页面+语言:同时来访的两个页面各翻各的,互不排队。 $key = APGT_Lock::key( self::$path, $to ); $owner = APGT_Lock::make_owner(); if ( ! APGT_Lock::acquire( $key, $owner ) ) { return 0; } // 访客嫌慢关掉页面是常事,但这次翻译得跑完——token 已经花出去了, // 半途而废等于下次访问再花一遍。 APGT_Translator::prepare_long_task(); // 每完成一次 AI 请求给锁续期,长任务不会被当成僵尸抢走。 APGT_Lock::keep_alive( $key, $owner ); $saved = 0; try { $result = APGT_Translator::translate_texts( $pending, $from, $to, APGT_Translator::text_replace_rules( $to ) ); foreach ( $result['trans'] as $md5 => $trans ) { if ( ! isset( $pending[ $md5 ] ) || '' === trim( $trans ) ) { continue; } $ok = APGT_Store::save( array( 'text_source' => $pending[ $md5 ], 'text_trans' => $trans, 'language_from' => $from, 'language_to' => $to, 'source_url' => self::$path, 'engine' => 'ai', 'type' => isset( $types[ $md5 ] ) ? $types[ $md5 ] : 'text', ) ); if ( $ok ) { ++$saved; } } } catch ( \Throwable $e ) { // 实时翻译失败不能连累页面,记一笔继续输出原文。 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[APGT] live translation failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } APGT_Lock::release( $key, $owner ); return $saved; } /** * 输出 hreflang。 * * @return void */ public static function render_hreflang() { $settings = APGT_Settings::get_settings(); if ( empty( $settings['show_hreflang'] ) ) { return; } $from = APGT_Settings::default_language(); if ( '' === $from ) { return; } // 按「展示对象」过滤:只给搜索引擎/只给真人看的语言,对另一方不露出。 $targets = self::visible_languages(); if ( empty( $targets ) ) { return; } $meta = APGT_Settings::language_meta(); $links = array(); $source_url = self::language_url( '' ); // 原文版本同时充当 x-default。 $default_name = isset( $meta[ $from ]['hreflang_name'] ) && '' !== $meta[ $from ]['hreflang_name'] ? $meta[ $from ]['hreflang_name'] : $from; $links[ $default_name ] = $source_url; $links['x-default'] = $source_url; foreach ( $targets as $iso => $row ) { $url = self::language_url( $iso ); $name = ! empty( $row['hreflang_name'] ) ? $row['hreflang_name'] : $iso; $links[ $name ] = $url; // 同一份译文可以覆盖多个地区变体,例如 zh-Hant 之于 zh-TW/zh-HK。 $areas = array_filter( array_map( 'trim', explode( ',', (string) $row['hreflang_area'] ) ) ); foreach ( $areas as $area ) { $links[ $area ] = $url; } } echo "\n"; foreach ( $links as $hreflang => $url ) { printf( '' . "\n", esc_attr( $hreflang ), esc_url( $url ) ); } self::render_og_alternates( $from, $targets ); } /** * 输出 og:locale:alternate,列出当前页面的其它语言版本。 * * 当前语言自己的 og:locale 由 SEO 插件(Yoast、Rank Math 等)输出,译文页 * 上由整页替换改成目标语言,这里只补「还有哪些语言」。跟着 hreflang * 开关走:两者说的是同一件事,一个给搜索引擎,一个给社交平台。 * * @param string $from 源语言。 * @param array $targets 当前访客可见的目标语言。 * @return void */ private static function render_og_alternates( $from, array $targets ) { $current = '' === self::$language ? $from : self::$language; $locales = array(); foreach ( array_merge( array( $from ), array_keys( $targets ) ) as $iso ) { if ( $iso === $current ) { continue; } $locale = APGT_Settings::og_locale( $iso ); if ( '' !== $locale ) { $locales[ $locale ] = true; } } // 与当前语言同一个区域设置的不算「其它版本」。 unset( $locales[ APGT_Settings::og_locale( $current ) ] ); foreach ( array_keys( $locales ) as $locale ) { printf( '' . "\n", esc_attr( $locale ) ); } } /** * 加载语言菜单的样式与脚本。 * * @return void */ public static function enqueue_assets() { self::enqueue_skip_spacing(); if ( ! self::switcher_enabled() ) { return; } self::enqueue_switcher_style(); wp_enqueue_script( 'apgt-language-switch', APGT_PLUGIN_URL . 'assets/js/language-switch.js', array(), APGT_VERSION, true ); $settings = APGT_Settings::get_settings(); wp_localize_script( 'apgt-language-switch', 'apgtSwitcher', array( 'container' => (string) $settings['menu_container'], ) ); } /** * 加载切换器样式:插件默认 / 用户自定义 / 不加载,三选一。 * * 三种情况用的都是同一个句柄 apgt-language-switch,主题里已有的 * wp_dequeue_style( 'apgt-language-switch' ) 照样有效。 * * 自定义样式存在数据库里,只经 wp_add_inline_style() 输出,不落文件。 * 输出前再校验一遍(防止有人绕过设置页直接改库),不合格或为空就退回 * 默认样式——切换器变成一堆没样式的链接,比难看更糟。 * * @return void */ private static function enqueue_switcher_style() { $style = APGT_Settings::switcher_style(); /** * 是否加载语言切换器的样式。 * * 主题想完全自己写切换器样式时返回 false,比按句柄 dequeue 稳妥。 * * @param bool $enqueue 按设置算出的结果(「不加载样式」时为 false)。 * @param string $style 设置里的样式来源:default、custom 或 none。 */ if ( ! apply_filters( 'apgt_enqueue_switcher_styles', 'none' !== $style, $style ) ) { return; } if ( 'custom' === $style ) { /** * 自定义切换器样式的内容(仅「用我自己的 CSS」时)。 * * 返回值同样要过校验:含 < 或超长会被丢弃,改用默认样式。 * * @param string $css 设置里保存的 CSS。 */ $css = (string) apply_filters( 'apgt_switcher_css', APGT_Settings::get_switcher_css() ); if ( '' !== trim( $css ) && '' === APGT_Settings::switcher_css_error( $css ) ) { wp_register_style( 'apgt-language-switch', false, array(), APGT_VERSION ); wp_enqueue_style( 'apgt-language-switch' ); wp_add_inline_style( 'apgt-language-switch', $css ); return; } } wp_enqueue_style( 'apgt-language-switch', APGT_PLUGIN_URL . 'assets/css/language-switch.css', array(), APGT_VERSION ); } /** * 给「不要翻译」的元素补一点左右内边距。 * * 中文、日文这些语言词与词之间本来就不写空格,作者标了不要翻译的那截 * (多半是品牌名、型号)常常紧挨着前后文写。原文里挤在一起还看得出来, * 译文里 AI 重排过语序,就成了「产品ABC说明」这种糊成一团的样子。 * 与其指望每个人都记得手动补空格,不如默认给这类元素留出间距。 * * 只认 class 属性整个就等于这个标记的元素:这种写法说明它是专门为 * 「别翻这段」套上去的一层 span。带着别的类名(`class="btn notranslate"`) * 的是主题自己的元素,有自己的排版,不能替人家加内边距。 * translate 属性是 HTML 标准写法,取值就那么几个,直接按值匹配。 * * 样式跟着语言菜单的开关走不合适——菜单关了这些元素照样在,所以单独 * 挂一份内联样式,不额外多一个请求。 * * @return void */ private static function enqueue_skip_spacing() { // 与 APGT_Html::has_skip_marker() 认的标记保持一致,含 translate="0"。 $css = '[class="not-translate"],[class="notranslate"],[translate="no"],[translate="0"]' . '{padding-left:4px;padding-right:4px}'; // 没有 src 的句柄:核心自己也这么挂纯内联样式。 wp_register_style( 'apgt-skip-spacing', false, array(), APGT_VERSION ); wp_enqueue_style( 'apgt-skip-spacing' ); wp_add_inline_style( 'apgt-skip-spacing', $css ); } /** * 语言切换器的语言列表。 * * 插件自带的切换器和公开函数 apgt_get_switcher_languages() 共用这一份, * 主题自己渲染切换器时拿到的数据与插件看到的完全一致。第一项是源语言, * 其后是当前访客可见的目标语言。旗帜总是带上,显不显示由调用方决定。 * * @return array[] 每项 iso、label、url、hreflang、flag、active。 */ public static function switcher_items() { $from = APGT_Settings::default_language(); if ( '' === $from ) { return array(); } $meta = APGT_Settings::language_meta(); $isos = array_merge( array( $from ), array_keys( self::visible_languages() ) ); $items = array(); foreach ( $isos as $iso ) { $is_source = $iso === $from; $items[] = array( 'iso' => $iso, 'label' => isset( $meta[ $iso ]['name_origin'] ) && '' !== $meta[ $iso ]['name_origin'] ? $meta[ $iso ]['name_origin'] : $iso, 'url' => self::language_url( $is_source ? '' : $iso ), 'hreflang' => APGT_Settings::hreflang_code( $iso ), 'flag' => APGT_Settings::flag( $iso ), 'active' => $is_source ? '' === self::$language : $iso === self::$language, ); } /** * 语言切换器的语言列表,插件切换器与 apgt_get_switcher_languages() 共用。 * * @param array[] $items 每项 iso、label、url、hreflang、flag、active。 */ return (array) apply_filters( 'apgt_switcher_languages', $items ); } /** * 输出语言切换菜单。 * * 下面这些类名是对外约定(readme 里列出,主题的 CSS 依赖它们),改名 * 等于不兼容变更:#apgt-language-switch、.apgt-switcher、.apgt-switcher-btn、 * .apgt-switcher-current、.apgt-switcher-caret、.apgt-switcher-menu、 * .apgt-switcher-item、.apgt-switcher-flag,状态类 .is-active(这里输出) * 与 .is-docked、.is-open、.is-flipped(language-switch.js 加)。 * * 默认浮动在右上角,可拖动、位置记在 localStorage;在设置里填了容器 id * 的话,脚本会把它搬进那个元素,跟着主题的版式走。 * * @return void */ public static function render_switcher() { if ( ! self::switcher_enabled() ) { return; } $settings = APGT_Settings::get_settings(); $show_flag = ! empty( $settings['show_flag'] ); $items = self::switcher_items(); if ( empty( $items ) ) { return; } // 过滤器可能被主题改过,缺字段的补齐,免得渲染时报 notice。 $defaults = array( 'iso' => '', 'label' => '', 'url' => '', 'hreflang' => '', 'flag' => '', 'active' => false, ); foreach ( $items as $i => $item ) { $items[ $i ] = array_merge( $defaults, (array) $item ); if ( ! $show_flag ) { $items[ $i ]['flag'] = ''; } } $items = array_values( $items ); $current = $items[0]; foreach ( $items as $item ) { if ( $item['active'] ) { $current = $item; } } ?> 0; } /** * 当前请求是不是后台抓原文。 * * 正经标记是 X-APGT-Source 请求头:插件抓页面时地址一个字符都不改, * 免得站点的伪静态或安全规则见到多余的查询参数就回 404。 * apgt_src 参数继续认,一是老数据,二是排查时可以手工在浏览器里加上, * 直接看到未经替换的原页面。 * * @return bool */ private static function is_source_request() { if ( isset( $_SERVER['HTTP_X_APGT_SOURCE'] ) ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset( $_GET['apgt_src'] ); } /** * 取 home_url 的路径部分(子目录安装时非空)。 * * @return string */ private static function home_path() { $path = wp_parse_url( home_url(), PHP_URL_PATH ); return is_string( $path ) ? rtrim( $path, '/' ) : ''; } /** * 取请求相对站点根的路径(去掉子目录前缀与查询串)。 * * @param string $uri 原始 REQUEST_URI。 * @return string 以 / 开头。 */ private static function request_path( $uri ) { $path = wp_parse_url( $uri, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { return '/'; } $home = self::home_path(); if ( '' !== $home && 0 === strpos( $path, $home ) ) { $path = substr( $path, strlen( $home ) ); } return '' === $path ? '/' : '/' . ltrim( $path, '/' ); } /** * 末段看起来像文件名(带扩展名)就不补末尾斜杠。 * * @param string $rest 路径。 * @return bool */ private static function looks_like_file( $rest ) { $last = basename( $rest ); return '' !== $last && false !== strpos( $last, '.' ); } }