Files
wordpress-theme-aksrt/src/Core/SEOHead.php
T
kate522andClaude Opus 5 3ce5bfc20c feat(branding): default the site icons to assets/img/favicon.png
- Point the 浏览器图标 / 移动端图标 schema defaults (PHP + VUE) at
  assets/img/favicon.png instead of an empty media field.
- Fall back to the same PNG in Settings::favicon_url() when no custom
  favicon and no native site_icon are configured.
- Add Settings::mobile_icon_url() and emit apple-touch-icon from SEOHead;
  the 移动端图标 field previously had no consumer and never reached the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:00:50 +08:00

401 lines
15 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
/**
* @package Jio
*/
namespace Jio\Core;
defined('ABSPATH') || exit;
class SEOHead {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// seo_enabled 主开关:关闭时不挂载任何 wp_head 输出
if (class_exists('\Jio\Settings\Settings')) {
$seo = \Jio\Settings\Settings::get_seo_settings();
if (empty($seo['seo_enabled'])) return;
}
// 单一出口:所有 SEO/OG/JSON-LD 输出集中在 priority 2。
add_action('wp_head', [$this, 'render'], 2);
}
/**
* 站点级 SEO 配置(带 per-request memo)。
*
* @return array<string,mixed>
*/
private function config(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
if (class_exists('\Jio\Settings\Settings')) {
$s = \Jio\Settings\Settings::get_all();
} else {
$s = [];
}
$cache = [
'connector' => trim((string) ($s['seo_connector'] ?? '-')) ?: '-',
'site_title' => trim((string) ($s['seo_title'] ?? '')),
'description' => trim((string) ($s['seo_description'] ?? ($s['site_description'] ?? ''))),
'keywords' => trim((string) ($s['seo_keywords'] ?? '')),
'canonical' => !isset($s['canonical_tag']) || !empty($s['canonical_tag']),
'og_enabled' => empty($s['disable_og_meta']),
'jsonld' => !isset($s['seo_jsonld']) || !empty($s['seo_jsonld']),
'jsonld_single' => !isset($s['seo_jsonld_single']) || !empty($s['seo_jsonld_single']),
'jsonld_page' => !isset($s['seo_jsonld_page']) || !empty($s['seo_jsonld_page']),
'default_image' => trim((string) ($s['seo_image_default'] ?? '')),
'always_default' => !empty($s['seo_image_always_default']),
];
return $cache;
}
/**
* 解析当前请求的规范 SEO 负载。
*
* @return array{title:string,description:string,keywords:string,canonical:string,image:string,og_type:string,object:?\WP_Post}
*/
private function resolve_payload(): array {
$cfg = $this->config();
$site_name = get_bloginfo('name');
$payload = [
'title' => wp_get_document_title(),
'description' => $cfg['description'],
'keywords' => $cfg['keywords'],
'canonical' => home_url(isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '/'),
'image' => $cfg['default_image'],
'og_type' => 'website',
'object' => null,
];
if (is_singular()) {
$post = get_queried_object();
if ($post instanceof \WP_Post) {
$payload['object'] = $post;
$payload['canonical'] = (string) get_permalink($post);
$payload['og_type'] = ($post->post_type === 'post') ? 'article' : 'website';
// 单篇 SEO 覆盖:per-post meta → 文章字段 → 站点默认。
$meta = $this->post_seo_meta($post->ID);
$payload['title'] = $meta['title'] !== '' ? $meta['title'] : wp_strip_all_tags(get_the_title($post));
if ($meta['description'] !== '') {
$payload['description'] = $meta['description'];
} else {
$excerpt = has_excerpt($post)
? wp_strip_all_tags(get_the_excerpt($post))
: wp_trim_words(wp_strip_all_tags((string) $post->post_content), 40, '…');
$payload['description'] = trim((string) $excerpt);
}
if ($meta['keywords'] !== '') {
$payload['keywords'] = $meta['keywords'];
} elseif ($payload['keywords'] === '') {
$payload['keywords'] = $this->terms_to_keywords($post);
}
$payload['image'] = $this->resolve_image($post, $cfg);
}
} elseif (is_home() || is_front_page()) {
if ($cfg['site_title'] !== '') {
$payload['title'] = $cfg['site_title'];
}
$payload['canonical'] = home_url('/');
} elseif (is_category() || is_tag() || is_tax()) {
$term = get_queried_object();
if ($term instanceof \WP_Term) {
$payload['canonical'] = (string) get_term_link($term);
$meta = $this->term_seo_meta((int) $term->term_id);
if ($meta['title'] !== '') {
$payload['title'] = $meta['title'];
}
if ($meta['description'] !== '') {
$payload['description'] = $meta['description'];
} else {
$term_desc = trim(wp_strip_all_tags((string) term_description($term)));
if ($term_desc !== '') {
$payload['description'] = $term_desc;
}
}
if ($meta['keywords'] !== '') {
$payload['keywords'] = $meta['keywords'];
}
}
}
if ($cfg['always_default'] && $cfg['default_image'] !== '') {
$payload['image'] = $cfg['default_image'];
}
/**
* 允许扩展(如 AI SEO)在最终输出前调整 head 负载。
*
* @param array $payload 规范 SEO 负载。
*/
return apply_filters('jio_seo_head_payload', $payload);
}
/**
* 读取单篇 SEO 覆盖(per-post meta)。
*
* @param int $post_id Post ID.
* @return array{title:string,description:string,keywords:string}
*/
private function post_seo_meta(int $post_id): array {
$get = static function (string $key) use ($post_id): string {
return trim((string) get_post_meta($post_id, $key, true));
};
return [
'title' => $get('_jio_seo_title'),
'description' => $get('_jio_seo_description'),
'keywords' => $get('_jio_seo_keywords'),
];
}
/**
* 读取 term SEO 覆盖。
*
* @return array{title:string,description:string,keywords:string}
*/
private function term_seo_meta(int $term_id): array {
$get = static function (string $key) use ($term_id): string {
return trim((string) get_term_meta($term_id, $key, true));
};
return [
'title' => $get('_jio_seo_title'),
'description' => $get('_jio_seo_description'),
'keywords' => $get('_jio_seo_keywords'),
];
}
/**
* 由分类/标签生成关键词回退。
*/
private function terms_to_keywords(\WP_Post $post): string {
$names = [];
foreach ((array) get_the_category($post->ID) as $cat) {
if ($cat instanceof \WP_Term) {
$names[] = $cat->name;
}
}
$tags = get_the_terms($post, 'post_tag');
if (is_array($tags)) {
foreach ($tags as $tag) {
$names[] = $tag->name;
}
}
return implode(',', array_slice(array_values(array_unique(array_filter($names))), 0, 10));
}
/**
* 解析单篇缩略图:特色图 → 内容首图 → 站点默认 → logo。
*/
private function resolve_image(\WP_Post $post, array $cfg): string {
if (has_post_thumbnail($post)) {
$image = (string) get_the_post_thumbnail_url($post, 'large');
if ($image !== '') {
return $image;
}
}
if (function_exists('jio_get_first_content_image_url')) {
$image = (string) jio_get_first_content_image_url($post->ID);
if ($image !== '') {
return $image;
}
}
if ($cfg['default_image'] !== '') {
return $cfg['default_image'];
}
if (class_exists('\Jio\Settings\Settings')) {
return (string) \Jio\Settings\Settings::logo_url();
}
return '';
}
/**
* 输出 head(favicon + canonical + description/keywords + OG/Twitter + JSON-LD)。
*/
public function render(): void {
$cfg = $this->config();
$payload = $this->resolve_payload();
echo "\n<!-- Jio SEO -->\n";
// Favicon(无站点图标时回退);移动端图标走 apple-touch-icon。
if (function_exists('jio_get_favicon_url')) {
$favicon = (string) jio_get_favicon_url();
if ($favicon !== '' && !has_site_icon()) {
printf('<link rel="icon" href="%s">' . "\n", esc_url($favicon));
if (class_exists('\Jio\Settings\Settings')) {
$mobile_icon = \Jio\Settings\Settings::mobile_icon_url();
if ($mobile_icon !== '') {
printf('<link rel="apple-touch-icon" href="%s">' . "\n", esc_url($mobile_icon));
}
}
}
}
// Canonical。
if ($cfg['canonical'] && !is_404() && $payload['canonical'] !== '') {
printf('<link rel="canonical" href="%s">' . "\n", esc_url($payload['canonical']));
}
// Description / Keywords。
if ($payload['description'] !== '') {
printf('<meta name="description" content="%s">' . "\n", esc_attr($payload['description']));
}
if ($payload['keywords'] !== '') {
printf('<meta name="keywords" content="%s">' . "\n", esc_attr($payload['keywords']));
}
// OpenGraph + Twitter。
if ($cfg['og_enabled']) {
$this->render_open_graph($payload);
}
// JSON-LD。
if ($cfg['jsonld']) {
$this->render_jsonld($payload, $cfg);
}
echo "<!-- /Jio SEO -->\n";
}
/**
* 输出 OpenGraph + Twitter Card。
*/
private function render_open_graph(array $payload): void {
$site_name = get_bloginfo('name');
$locale = str_replace('-', '_', get_locale());
$og = [
['property' => 'og:type', 'content' => $payload['og_type']],
['property' => 'og:locale', 'content' => $locale],
['property' => 'og:site_name', 'content' => $site_name],
['property' => 'og:title', 'content' => $payload['title']],
['property' => 'og:url', 'content' => $payload['canonical']],
];
if ($payload['description'] !== '') {
$og[] = ['property' => 'og:description', 'content' => $payload['description']];
}
if ($payload['image'] !== '') {
$og[] = ['property' => 'og:image', 'content' => $payload['image']];
}
if ($payload['og_type'] === 'article' && $payload['object'] instanceof \WP_Post) {
$post = $payload['object'];
$published = get_the_date('c', $post);
$modified = get_the_modified_date('c', $post);
$author = get_the_author_meta('display_name', (int) $post->post_author);
if ($published) $og[] = ['property' => 'article:published_time', 'content' => $published];
if ($modified) $og[] = ['property' => 'article:modified_time', 'content' => $modified];
if ($author) $og[] = ['property' => 'article:author', 'content' => $author];
$cats = get_the_category($post->ID);
if (!empty($cats)) {
$og[] = ['property' => 'article:section', 'content' => $cats[0]->name];
}
$tags = get_the_terms($post, 'post_tag');
if (is_array($tags)) {
foreach ($tags as $tag) {
$og[] = ['property' => 'article:tag', 'content' => $tag->name];
}
}
}
foreach ($og as $tag) {
printf('<meta property="%s" content="%s">' . "\n", esc_attr($tag['property']), esc_attr($tag['content']));
}
$twitter = [
['name' => 'twitter:card', 'content' => $payload['image'] !== '' ? 'summary_large_image' : 'summary'],
['name' => 'twitter:title', 'content' => $payload['title']],
];
if ($payload['description'] !== '') {
$twitter[] = ['name' => 'twitter:description', 'content' => $payload['description']];
}
if ($payload['image'] !== '') {
$twitter[] = ['name' => 'twitter:image', 'content' => $payload['image']];
}
foreach ($twitter as $tag) {
printf('<meta name="%s" content="%s">' . "\n", esc_attr($tag['name']), esc_attr($tag['content']));
}
}
/**
* 输出 JSON-LD 结构化数据。
*/
private function render_jsonld(array $payload, array $cfg): void {
$graph = [];
// WebSite(全站)。
$graph[] = [
'@type' => 'WebSite',
'@id' => home_url('/#website'),
'url' => home_url('/'),
'name' => get_bloginfo('name'),
'inLanguage' => get_locale(),
];
if (is_singular('post') && $cfg['jsonld_single'] && $payload['object'] instanceof \WP_Post) {
$post = $payload['object'];
$node = [
'@type' => 'Article',
'headline' => $payload['title'],
'url' => $payload['canonical'],
'datePublished' => get_the_date('c', $post),
'dateModified' => get_the_modified_date('c', $post),
'author' => [
'@type' => 'Person',
'name' => get_the_author_meta('display_name', (int) $post->post_author),
],
'publisher' => [
'@type' => 'Organization',
'name' => get_bloginfo('name'),
],
];
if ($payload['description'] !== '') {
$node['description'] = $payload['description'];
}
if ($payload['image'] !== '') {
$node['image'] = $payload['image'];
}
$graph[] = $node;
} elseif (is_page() && $cfg['jsonld_page'] && $payload['object'] instanceof \WP_Post) {
$graph[] = [
'@type' => 'WebPage',
'url' => $payload['canonical'],
'name' => $payload['title'],
];
}
if (empty($graph)) {
return;
}
$doc = [
'@context' => 'https://schema.org',
'@graph' => $graph,
];
echo '<script type="application/ld+json">'
. wp_json_encode($doc, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. '</script>' . "\n";
}
}