'', '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 '' . "\n"; echo '' . "\n"; foreach (raijuSeoRenderablePages() as $page) { echo " \n"; echo ' ' . htmlspecialchars($page['url'], ENT_XML1, 'UTF-8') . "\n"; echo ' ' . htmlspecialchars(date('c', strtotime((string)$page['lastmod']) ?: time()), ENT_XML1, 'UTF-8') . "\n"; echo ' ' . htmlspecialchars((string)$page['changefreq'], ENT_XML1, 'UTF-8') . "\n"; echo ' ' . htmlspecialchars((string)$page['priority'], ENT_XML1, 'UTF-8') . "\n"; echo " \n"; } echo ''; 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; }