399 lines
16 KiB
PHP
399 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* SEO/AEO/GEO helpers for public metadata, sitemaps, robots, and health checks.
|
|
*/
|
|
|
|
require_once __DIR__ . '/page-path.php';
|
|
require_once __DIR__ . '/../admin/lang/i18n.php';
|
|
|
|
function raijuSeoRoot(): string {
|
|
return dirname(__DIR__);
|
|
}
|
|
|
|
function raijuSeoSettings(): array {
|
|
$path = raijuSeoRoot() . '/content/settings.json';
|
|
if (!is_file($path)) return [];
|
|
$settings = json_decode((string)file_get_contents($path), true);
|
|
return is_array($settings) ? $settings : [];
|
|
}
|
|
|
|
function raijuSeoBaseUrl(): string {
|
|
$settings = raijuSeoSettings();
|
|
$configured = trim((string)($settings['seo']['siteUrl'] ?? ''));
|
|
if ($configured !== '') {
|
|
return rtrim($configured, '/');
|
|
}
|
|
|
|
$host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? '');
|
|
if ($host === '') {
|
|
return '';
|
|
}
|
|
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
|
return ($https ? 'https://' : 'http://') . $host;
|
|
}
|
|
|
|
function raijuSeoPageUrl(string $lang, string $slug): string {
|
|
$base = raijuSeoBaseUrl();
|
|
$path = raijuPageUrlPath($lang, $slug);
|
|
if ($path !== '/') {
|
|
$path = rtrim($path, '/') . '/';
|
|
}
|
|
return $base . $path;
|
|
}
|
|
|
|
function raijuSeoCurrentUrl(): string {
|
|
$base = raijuSeoBaseUrl();
|
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
|
return $base . $path;
|
|
}
|
|
|
|
function raijuSeoLoadPageData(?string $contentPage): array {
|
|
if (!$contentPage || !raijuPageIsValidContentKey($contentPage)) {
|
|
return [];
|
|
}
|
|
$path = raijuSeoRoot() . '/content/pages/' . $contentPage . '.json';
|
|
if (!is_file($path)) return [];
|
|
$data = json_decode((string)file_get_contents($path), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
function raijuSeoTextFromHtml(string $html): string {
|
|
return trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($html), ENT_QUOTES, 'UTF-8')));
|
|
}
|
|
|
|
function raijuSeoFirstImage(array $data): string {
|
|
$candidates = [];
|
|
$walk = function ($value) use (&$walk, &$candidates) {
|
|
if (!is_array($value)) return;
|
|
if (!empty($value['src']) && is_string($value['src'])) $candidates[] = $value['src'];
|
|
if (!empty($value['image']) && is_string($value['image'])) $candidates[] = $value['image'];
|
|
foreach ($value as $child) {
|
|
if (is_array($child)) $walk($child);
|
|
}
|
|
};
|
|
$walk($data);
|
|
foreach ($candidates as $candidate) {
|
|
if ($candidate !== '' && !str_starts_with($candidate, 'data:') && raijuSeoIsSupportedOgImage($candidate)) return $candidate;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function raijuSeoIsSupportedOgImage(string $path): bool {
|
|
$path = trim($path);
|
|
if ($path === '') return false;
|
|
$urlPath = parse_url($path, PHP_URL_PATH);
|
|
if (!is_string($urlPath) || $urlPath === '') return false;
|
|
$ext = strtolower(pathinfo($urlPath, PATHINFO_EXTENSION));
|
|
return in_array($ext, ['jpg', 'jpeg', 'png'], true);
|
|
}
|
|
|
|
function raijuSeoNormalizeAssetPath(string $path): string {
|
|
$path = trim($path);
|
|
if (str_starts_with($path, '../assets/images/')) {
|
|
return '/assets/images/' . substr($path, strlen('../assets/images/'));
|
|
}
|
|
if (str_starts_with($path, 'assets/images/')) {
|
|
return '/' . $path;
|
|
}
|
|
return $path;
|
|
}
|
|
|
|
function raijuSeoAbsoluteAssetUrl(string $path): string {
|
|
$path = raijuSeoNormalizeAssetPath($path);
|
|
if ($path === '') return '';
|
|
if (preg_match('#^https?://#i', $path)) return $path;
|
|
return rtrim(raijuSeoBaseUrl(), '/') . '/' . ltrim($path, '/');
|
|
}
|
|
|
|
function raijuSeoLocalAssetPath(string $path): string {
|
|
$path = raijuSeoNormalizeAssetPath($path);
|
|
if (!str_starts_with($path, '/assets/images/')) return '';
|
|
$relative = substr($path, strlen('/assets/images/'));
|
|
if ($relative === '' || str_contains($relative, '..') || str_contains($relative, "\0")) return '';
|
|
return raijuSeoRoot() . '/assets/images/' . $relative;
|
|
}
|
|
|
|
function raijuSeoOgImageMeta(string $path, string $alt = ''): array {
|
|
if (!raijuSeoIsSupportedOgImage($path)) {
|
|
return ['url' => '', 'width' => null, 'height' => null, 'alt' => ''];
|
|
}
|
|
|
|
$meta = [
|
|
'url' => raijuSeoAbsoluteAssetUrl($path),
|
|
'width' => null,
|
|
'height' => null,
|
|
'alt' => raijuSeoTextFromHtml($alt),
|
|
];
|
|
|
|
$localPath = raijuSeoLocalAssetPath($path);
|
|
if ($localPath !== '' && is_file($localPath)) {
|
|
$size = @getimagesize($localPath);
|
|
if (is_array($size)) {
|
|
$meta['width'] = (int)($size[0] ?? 0) ?: null;
|
|
$meta['height'] = (int)($size[1] ?? 0) ?: null;
|
|
}
|
|
}
|
|
|
|
return $meta;
|
|
}
|
|
|
|
/** Site-wide search engine block (staging): one switch for robots.txt, meta robots, X-Robots-Tag and sitemap. */
|
|
function raijuSeoSiteBlocked(): bool {
|
|
return !empty(raijuSeoSettings()['seo']['noindexSite']);
|
|
}
|
|
|
|
function raijuSeoRobots(array $seo, array $visibility = []): string {
|
|
if (($visibility['status'] ?? 'public') === 'private' || raijuSeoSiteBlocked()) {
|
|
return 'noindex, nofollow';
|
|
}
|
|
$robots = trim((string)($seo['robots'] ?? ''));
|
|
return $robots !== '' ? $robots : 'index, follow';
|
|
}
|
|
|
|
function raijuSeoContext(array $args): array {
|
|
$contentPage = $args['contentPage'] ?? null;
|
|
$data = $args['data'] ?? raijuSeoLoadPageData($contentPage);
|
|
$seo = is_array($data['seo'] ?? null) ? $data['seo'] : [];
|
|
$visibility = is_array($data['visibility'] ?? null) ? $data['visibility'] : [];
|
|
$lang = $args['currentLang'] ?? ($data['lang'] ?? (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'));
|
|
$slug = $args['currentPage'] ?? '';
|
|
if ($slug === '' && $contentPage) {
|
|
$page = raijuPageParseContentKey((string)$contentPage);
|
|
if ($page !== null) $slug = $page['path'];
|
|
}
|
|
$title = trim((string)($seo['title'] ?? '')) ?: trim((string)($args['pageTitle'] ?? ($data['title'] ?? '')));
|
|
$description = trim((string)($seo['description'] ?? '')) ?: trim((string)($args['pageDescription'] ?? ($data['description'] ?? '')));
|
|
$canonical = trim((string)($seo['canonical'] ?? ''));
|
|
if ($canonical === '' && $slug !== '') {
|
|
$canonical = raijuSeoPageUrl((string)$lang, (string)$slug);
|
|
}
|
|
$ogTitle = trim((string)($seo['ogTitle'] ?? '')) ?: $title;
|
|
$ogDescription = trim((string)($seo['ogDescription'] ?? '')) ?: $description;
|
|
$settings = raijuSeoSettings();
|
|
$defaultOgImage = trim((string)($settings['seo']['defaultOgImage'] ?? ''));
|
|
$ogImage = trim((string)($seo['ogImage'] ?? '')) ?: ($defaultOgImage ?: raijuSeoFirstImage($data));
|
|
$ogImageMeta = raijuSeoOgImageMeta($ogImage, $ogTitle);
|
|
|
|
return [
|
|
'title' => $title,
|
|
'description' => $description,
|
|
'robots' => raijuSeoRobots($seo, $visibility),
|
|
'canonical' => $canonical,
|
|
'ogTitle' => $ogTitle,
|
|
'ogDescription' => $ogDescription,
|
|
'ogImage' => $ogImageMeta['url'],
|
|
'ogImageWidth' => $ogImageMeta['width'],
|
|
'ogImageHeight' => $ogImageMeta['height'],
|
|
'ogImageAlt' => $ogImageMeta['alt'],
|
|
'lang' => $lang,
|
|
'slug' => $slug,
|
|
'data' => $data,
|
|
'seo' => $seo,
|
|
];
|
|
}
|
|
|
|
function raijuSeoPageHasH1(array $data): bool {
|
|
foreach (($data['sections'] ?? []) as $section) {
|
|
if (!is_array($section) || !empty($section['hidden'])) continue;
|
|
if (($section['type'] ?? '') === 'heading' && ($section['level'] ?? '') === 'h1' && trim((string)($section['text'] ?? '')) !== '') return true;
|
|
if (($section['type'] ?? '') === 'text' && ($section['titleTag'] ?? '') === 'h1' && trim((string)($section['title'] ?? '')) !== '') return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function raijuSeoMissingImageAlts(array $data): int {
|
|
$missing = 0;
|
|
$walk = function ($value) use (&$walk, &$missing) {
|
|
if (!is_array($value)) return;
|
|
if (!empty($value['src']) && is_string($value['src']) && trim((string)($value['alt'] ?? '')) === '') $missing++;
|
|
if (!empty($value['image']) && is_string($value['image']) && array_key_exists('alt', $value) && trim((string)$value['alt']) === '') $missing++;
|
|
foreach ($value as $child) {
|
|
if (is_array($child)) $walk($child);
|
|
}
|
|
};
|
|
$walk($data);
|
|
return $missing;
|
|
}
|
|
|
|
function raijuSeoHealth(array $context): array {
|
|
$score = 100;
|
|
$issues = [];
|
|
$warn = function (string $message, int $points) use (&$score, &$issues) {
|
|
$score -= $points;
|
|
$issues[] = $message;
|
|
};
|
|
|
|
$titleLen = mb_strlen($context['title'] ?? '', 'UTF-8');
|
|
if ($titleLen < 20) $warn(t('seo.title_short'), 18);
|
|
if ($titleLen > 70) $warn(t('seo.title_long'), 10);
|
|
|
|
$descLen = mb_strlen($context['description'] ?? '', 'UTF-8');
|
|
if ($descLen < 70) $warn(t('seo.description_short'), 18);
|
|
if ($descLen > 170) $warn(t('seo.description_long'), 8);
|
|
|
|
if (empty($context['canonical'])) $warn(t('seo.canonical_missing'), 12);
|
|
if (stripos($context['robots'] ?? '', 'noindex') !== false) $warn(t('seo.noindex'), 20);
|
|
if (!empty($context['data']['sections']) && !raijuSeoPageHasH1($context['data'] ?? [])) {
|
|
$warn(t('seo.h1_missing'), 14);
|
|
}
|
|
if (empty($context['ogImage'])) $warn(t('seo.image_missing'), 8);
|
|
$missingAlts = raijuSeoMissingImageAlts($context['data'] ?? []);
|
|
if ($missingAlts > 0) $warn(t('seo.alt_missing', ['count' => $missingAlts]), min(14, $missingAlts * 4));
|
|
|
|
$score = max(0, min(100, $score));
|
|
$status = $score >= 85 ? 'green' : ($score >= 60 ? 'yellow' : 'red');
|
|
return [
|
|
'status' => $status,
|
|
'score' => $score,
|
|
'label' => t('seo.' . $status),
|
|
'issues' => $issues ?: [t('seo.no_issues')],
|
|
];
|
|
}
|
|
|
|
function raijuSeoJsonLd(array $context, array $langLinks = []): array {
|
|
$settings = raijuSeoSettings();
|
|
$siteName = $settings['branding']['name'] ?? (defined('SITE_NAME') ? SITE_NAME : 'Website');
|
|
$base = raijuSeoBaseUrl();
|
|
$canonical = $context['canonical'] ?: raijuSeoCurrentUrl();
|
|
$graph = [
|
|
[
|
|
'@type' => 'WebSite',
|
|
'@id' => $base . '/#website',
|
|
'url' => $base . '/',
|
|
'name' => $siteName,
|
|
],
|
|
[
|
|
'@type' => 'WebPage',
|
|
'@id' => $canonical . '#webpage',
|
|
'url' => $canonical,
|
|
'name' => $context['title'],
|
|
'description' => $context['description'],
|
|
'inLanguage' => $context['lang'],
|
|
'isPartOf' => ['@id' => $base . '/#website'],
|
|
],
|
|
];
|
|
if (!empty($context['seo']['answerSummary'])) {
|
|
$graph[1]['abstract'] = raijuSeoTextFromHtml((string)$context['seo']['answerSummary']);
|
|
}
|
|
|
|
if (!empty($settings['seo']['organizationName'])) {
|
|
$graph[] = [
|
|
'@type' => 'Organization',
|
|
'@id' => $base . '/#organization',
|
|
'name' => $settings['seo']['organizationName'],
|
|
'url' => $base . '/',
|
|
'logo' => raijuSeoAbsoluteAssetUrl((string)($settings['branding']['logo'] ?? $settings['favicon'] ?? '')),
|
|
];
|
|
}
|
|
|
|
if (!empty($context['data']['breadcrumb']) && is_array($context['data']['breadcrumb'])) {
|
|
$items = [];
|
|
$pos = 1;
|
|
foreach ($context['data']['breadcrumb'] as $crumb) {
|
|
if (!is_array($crumb) || empty($crumb['label'])) continue;
|
|
$items[] = [
|
|
'@type' => 'ListItem',
|
|
'position' => $pos++,
|
|
'name' => (string)$crumb['label'],
|
|
'item' => !empty($crumb['href']) ? raijuSeoAbsoluteAssetUrl((string)$crumb['href']) : $canonical,
|
|
];
|
|
}
|
|
if ($items) {
|
|
$graph[] = ['@type' => 'BreadcrumbList', 'itemListElement' => $items];
|
|
}
|
|
}
|
|
|
|
if (!empty($context['data']['faq']['entries']) && is_array($context['data']['faq']['entries'])) {
|
|
$faqItems = [];
|
|
foreach ($context['data']['faq']['entries'] as $entry) {
|
|
if (!is_array($entry) || !empty($entry['hidden']) || empty($entry['question']) || empty($entry['answer'])) continue;
|
|
$faqItems[] = [
|
|
'@type' => 'Question',
|
|
'name' => raijuSeoTextFromHtml((string)$entry['question']),
|
|
'acceptedAnswer' => ['@type' => 'Answer', 'text' => raijuSeoTextFromHtml((string)$entry['answer'])],
|
|
];
|
|
}
|
|
if ($faqItems) $graph[] = ['@type' => 'FAQPage', 'mainEntity' => $faqItems];
|
|
}
|
|
|
|
return ['@context' => 'https://schema.org', '@graph' => $graph];
|
|
}
|
|
|
|
function raijuSeoRenderablePages(): array {
|
|
if (raijuSeoSiteBlocked()) return [];
|
|
$pages = [];
|
|
$defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
|
|
foreach (glob(raijuSeoRoot() . '/content/pages/[a-z][a-z]_*.json') ?: [] as $file) {
|
|
$name = basename($file, '.json');
|
|
$page = raijuPageParseContentKey($name);
|
|
if ($page === null) continue;
|
|
$data = json_decode((string)file_get_contents($file), true);
|
|
if (!is_array($data)) continue;
|
|
$visibility = is_array($data['visibility'] ?? null) ? $data['visibility'] : [];
|
|
$seo = is_array($data['seo'] ?? null) ? $data['seo'] : [];
|
|
if (($visibility['status'] ?? 'public') === 'private') continue;
|
|
if (($seo['sitemap'] ?? true) === false) continue;
|
|
if (stripos(raijuSeoRobots($seo, $visibility), 'noindex') !== false) continue;
|
|
$lang = $page['lang'];
|
|
$slug = $page['path'];
|
|
$pages[] = [
|
|
'lang' => $lang,
|
|
'slug' => $slug,
|
|
'url' => raijuSeoPageUrl($lang, $slug),
|
|
'lastmod' => $data['lastModified'] ?? date('c', filemtime($file)),
|
|
'priority' => $seo['priority'] ?? ($slug === 'home' && $lang === $defaultLang ? '1.0' : '0.6'),
|
|
'changefreq' => $seo['changefreq'] ?? 'monthly',
|
|
];
|
|
}
|
|
foreach (glob(raijuSeoRoot() . '/content/news/*.json') ?: [] as $file) {
|
|
$post = json_decode((string)file_get_contents($file), true);
|
|
if (!is_array($post) || !empty($post['hidden'])) continue;
|
|
$slug = trim((string)($post['slug'] ?? basename($file, '.json')));
|
|
if ($slug === '') continue;
|
|
$lang = trim((string)($post['lang'] ?? $defaultLang));
|
|
$path = $lang === $defaultLang ? '/news/' . $slug : '/' . $lang . '/news/' . $slug;
|
|
$pages[] = [
|
|
'lang' => $lang,
|
|
'slug' => 'news/' . $slug,
|
|
'url' => rtrim(raijuSeoBaseUrl(), '/') . $path,
|
|
'lastmod' => $post['lastModified'] ?? ($post['date'] ?? date('c', filemtime($file))),
|
|
'priority' => '0.5',
|
|
'changefreq' => 'monthly',
|
|
];
|
|
}
|
|
return $pages;
|
|
}
|
|
|
|
function raijuSeoServeSitemap(): void {
|
|
header('Content-Type: application/xml; charset=utf-8');
|
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
|
foreach (raijuSeoRenderablePages() as $page) {
|
|
echo " <url>\n";
|
|
echo ' <loc>' . htmlspecialchars($page['url'], ENT_XML1, 'UTF-8') . "</loc>\n";
|
|
echo ' <lastmod>' . htmlspecialchars(date('c', strtotime((string)$page['lastmod']) ?: time()), ENT_XML1, 'UTF-8') . "</lastmod>\n";
|
|
echo ' <changefreq>' . htmlspecialchars((string)$page['changefreq'], ENT_XML1, 'UTF-8') . "</changefreq>\n";
|
|
echo ' <priority>' . htmlspecialchars((string)$page['priority'], ENT_XML1, 'UTF-8') . "</priority>\n";
|
|
echo " </url>\n";
|
|
}
|
|
echo '</urlset>';
|
|
exit;
|
|
}
|
|
|
|
function raijuSeoServeRobots(): void {
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
$settings = raijuSeoSettings();
|
|
$disallowAll = !empty($settings['seo']['noindexSite']);
|
|
echo "User-agent: *\n";
|
|
if ($disallowAll) {
|
|
echo "Disallow: /\n";
|
|
} else {
|
|
echo "Disallow: /admin/\nDisallow: /api/\nDisallow: /content/\nDisallow: /backups/\n";
|
|
}
|
|
$base = raijuSeoBaseUrl();
|
|
// A blocked site has an empty sitemap; do not advertise it.
|
|
if ($base !== '' && !$disallowAll) echo "\nSitemap: " . $base . "/sitemap.xml\n";
|
|
exit;
|
|
}
|