-
Notifications
You must be signed in to change notification settings - Fork 2
feat: hreflang tags, RSS feed, sitemap events #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
9ddb820
1e5d8fc
07ade9e
f882111
5db1fb1
6f2ecaf
7aa3eab
5098b05
8f91cdf
db9dc9d
606959f
9fa4bed
d724e0a
d179730
f205c5e
cc15967
95722cb
a8b559c
3a698e6
3fea916
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| <?php | ||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Controllers; | ||
|
|
||
| use App\Support\ConfigStore; | ||
| use App\Support\I18n; | ||
| use mysqli; | ||
| use Psr\Http\Message\ResponseInterface as Response; | ||
| use Psr\Http\Message\ServerRequestInterface as Request; | ||
|
|
||
| class FeedController | ||
| { | ||
| public function rssFeed(Request $request, Response $response, mysqli $db): Response | ||
| { | ||
| $baseUrl = SeoController::resolveBaseUrl($request); | ||
| $appName = (string) ConfigStore::get('app.name', 'Pinakes'); | ||
| $appDesc = (string) ConfigStore::get('app.footer_description', ''); | ||
| $locale = I18n::getLocale(); | ||
| $langCode = strtolower(substr($locale, 0, 2)); | ||
|
|
||
| $items = $this->getLatestBooks($db, $baseUrl); | ||
| $lastBuildDate = isset($items[0]['pubDate']) && $items[0]['pubDate'] !== '' | ||
| ? $items[0]['pubDate'] | ||
| : gmdate('r'); | ||
|
|
||
| $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; | ||
| $xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n"; | ||
| $xml .= '<channel>' . "\n"; | ||
| $xml .= ' <title>' . $this->xmlEscape($appName) . '</title>' . "\n"; | ||
| $xml .= ' <link>' . $this->xmlEscape($baseUrl) . '</link>' . "\n"; | ||
| $xml .= ' <description>' . $this->xmlEscape($appDesc) . '</description>' . "\n"; | ||
| $xml .= ' <language>' . $this->xmlEscape($langCode) . '</language>' . "\n"; | ||
| $xml .= ' <atom:link href="' . $this->xmlAttrEscape($baseUrl . '/feed.xml') . '" rel="self" type="application/rss+xml"/>' . "\n"; | ||
| $xml .= ' <lastBuildDate>' . $lastBuildDate . '</lastBuildDate>' . "\n"; | ||
|
|
||
| foreach ($items as $item) { | ||
| $xml .= ' <item>' . "\n"; | ||
| $xml .= ' <title>' . $this->xmlEscape($item['title']) . '</title>' . "\n"; | ||
| $xml .= ' <link>' . $this->xmlEscape($item['link']) . '</link>' . "\n"; | ||
| $xml .= ' <guid isPermaLink="true">' . $this->xmlEscape($item['link']) . '</guid>' . "\n"; | ||
| $xml .= ' <description>' . $this->xmlEscape($item['description']) . '</description>' . "\n"; | ||
| $xml .= ' <pubDate>' . $item['pubDate'] . '</pubDate>' . "\n"; | ||
| $xml .= ' </item>' . "\n"; | ||
| } | ||
|
|
||
| $xml .= '</channel>' . "\n"; | ||
| $xml .= '</rss>' . "\n"; | ||
|
|
||
| $response->getBody()->write($xml); | ||
| return $response->withHeader('Content-Type', 'application/rss+xml; charset=UTF-8'); | ||
| } | ||
|
|
||
| /** | ||
| * @return array<int, array{title: string, link: string, description: string, pubDate: string}> | ||
| */ | ||
| private function getLatestBooks(mysqli $db, string $baseUrl): array | ||
| { | ||
| $sql = " | ||
| SELECT l.id, l.titolo, l.descrizione_plain, l.created_at, | ||
| ( | ||
| SELECT a.nome | ||
| FROM libri_autori la | ||
| JOIN autori a ON la.autore_id = a.id | ||
| WHERE la.libro_id = l.id | ||
| ORDER BY CASE la.ruolo WHEN 'principale' THEN 0 ELSE 1 END, la.ordine_credito | ||
| LIMIT 1 | ||
| ) AS autore_principale, | ||
| e.nome AS editore | ||
| FROM libri l | ||
| LEFT JOIN editori e ON l.editore_id = e.id | ||
| WHERE l.deleted_at IS NULL | ||
| ORDER BY l.created_at DESC | ||
| LIMIT 50 | ||
| "; | ||
|
|
||
| $items = []; | ||
| $result = $db->query($sql); | ||
| if (!$result) { | ||
| return $items; | ||
| } | ||
|
|
||
| while ($row = $result->fetch_assoc()) { | ||
| $id = (int)($row['id'] ?? 0); | ||
| $title = (string)($row['titolo'] ?? ''); | ||
| if ($id <= 0 || $title === '') { | ||
| continue; | ||
| } | ||
|
|
||
| $author = (string)($row['autore_principale'] ?? ''); | ||
| $publisher = (string)($row['editore'] ?? ''); | ||
| $itemTitle = $title; | ||
| if ($author !== '') { | ||
| $itemTitle .= ' — ' . $author; | ||
| } | ||
|
|
||
| // Build description from plain text excerpt | ||
| $desc = (string)($row['descrizione_plain'] ?? ''); | ||
| if (mb_strlen($desc) > 300) { | ||
| $desc = mb_substr($desc, 0, 297) . '...'; | ||
| } | ||
| if ($desc === '' && $publisher !== '') { | ||
| $desc = $publisher; | ||
| } | ||
|
|
||
| $bookPath = book_path(['id' => $id, 'titolo' => $title, 'autore_principale' => $author]); | ||
| $link = $baseUrl . $bookPath; | ||
|
|
||
| $pubDate = ''; | ||
| if (!empty($row['created_at'])) { | ||
| try { | ||
| $dt = new \DateTimeImmutable((string)$row['created_at']); | ||
| $pubDate = $dt->format('r'); | ||
| } catch (\Throwable $e) { | ||
| $pubDate = gmdate('r'); | ||
| } | ||
| } | ||
|
|
||
| $items[] = [ | ||
| 'title' => $itemTitle, | ||
| 'link' => $link, | ||
| 'description' => $desc, | ||
| 'pubDate' => $pubDate, | ||
| ]; | ||
| } | ||
| $result->free(); | ||
|
|
||
| return $items; | ||
| } | ||
|
|
||
| private function xmlEscape(string $text): string | ||
| { | ||
| return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8'); | ||
| } | ||
|
|
||
| private function xmlAttrEscape(string $text): string | ||
| { | ||
| return $this->xmlEscape($text); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| <?php | ||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Support; | ||
|
|
||
| use App\Controllers\SeoController; | ||
|
|
||
| /** | ||
| * Generates hreflang alternate URLs for the current page. | ||
| * | ||
| * Given the current REQUEST_URI, this class produces alternate URLs | ||
| * for every active locale so search engines and LLMs can link | ||
| * language variants together. | ||
| */ | ||
| class HreflangHelper | ||
| { | ||
| /** | ||
| * Get hreflang alternate links for the current URL. | ||
| * | ||
| * @return array<int, array{hreflang: string, href: string}> | ||
| */ | ||
| public static function getAlternates(): array | ||
| { | ||
| $locales = I18n::getAvailableLocales(); | ||
| if (count($locales) < 2) { | ||
| return []; | ||
| } | ||
|
|
||
| $defaultLocale = I18n::getInstallationLocale(); | ||
| $basePath = HtmlHelper::getBasePath(); | ||
| $baseUrl = SeoController::resolveBaseUrl(); | ||
|
Comment on lines
+44
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve the installation subfolder when building alternate URLs. Lines 39-40 strip 🔧 Suggested fix $defaultLocale = I18n::getInstallationLocale();
$basePath = HtmlHelper::getBasePath();
$baseUrl = SeoController::resolveBaseUrl();
+ $baseHref = rtrim($baseUrl, '/');
+ $resolvedBasePath = rtrim((string)(parse_url($baseHref, PHP_URL_PATH) ?? ''), '/');
+ if ($basePath !== '' && $resolvedBasePath !== rtrim($basePath, '/')) {
+ $baseHref .= $basePath;
+ }
@@
$alternates[] = [
'hreflang' => $langCode,
- 'href' => $baseUrl . $fullPath,
+ 'href' => $baseHref . $fullPath,
];Also applies to: 90-92 🤖 Prompt for AI Agents |
||
|
|
||
| // Current request path, stripped of query string and base path | ||
| $rawUri = (string)($_SERVER['REQUEST_URI'] ?? '/'); | ||
| $requestUri = strtok($rawUri, '?') ?: '/'; | ||
|
|
||
| // Strip base path prefix | ||
| $corePath = $requestUri; | ||
| if ($basePath !== '' && str_starts_with($corePath, $basePath)) { | ||
| $corePath = substr($corePath, strlen($basePath)) ?: '/'; | ||
| } | ||
|
|
||
| // Detect current locale from path prefix and strip it | ||
| $currentLocale = $defaultLocale; | ||
| foreach ($locales as $localeCode => $langName) { | ||
| if ($localeCode === $defaultLocale) { | ||
| continue; | ||
| } | ||
| $prefix = '/' . strtolower(substr($localeCode, 0, 2)); | ||
| if ($corePath === $prefix || str_starts_with($corePath, $prefix . '/')) { | ||
| $currentLocale = $localeCode; | ||
| $corePath = substr($corePath, strlen($prefix)) ?: '/'; | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Build reverse map: translated path => route key (from default locale) | ||
| $reverseMap = self::buildReverseMap($locales, $currentLocale); | ||
|
|
||
| // Try to match corePath against known routes | ||
| $matchedKey = null; | ||
| $suffix = ''; | ||
| foreach ($reverseMap as $routePath => $routeKey) { | ||
| if ($corePath === $routePath) { | ||
| $matchedKey = $routeKey; | ||
| $suffix = ''; | ||
| break; | ||
| } | ||
| // Prefix match for entity routes (e.g. /autore/Name or /eventi/slug) | ||
| if (str_starts_with($corePath, $routePath . '/')) { | ||
| $matchedKey = $routeKey; | ||
| $suffix = substr($corePath, strlen($routePath)); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| $alternates = []; | ||
| foreach ($locales as $localeCode => $langName) { | ||
| $langCode = strtolower(substr($localeCode, 0, 2)); | ||
| $localePrefix = ($localeCode === $defaultLocale) ? '' : '/' . $langCode; | ||
|
|
||
| if ($matchedKey !== null) { | ||
| $translatedPath = RouteTranslator::getRouteForLocale($matchedKey, $localeCode); | ||
| $fullPath = $localePrefix . $translatedPath . $suffix; | ||
| } else { | ||
| // No known route matched — keep path as-is, just swap prefix | ||
| $fullPath = $localePrefix . $corePath; | ||
| } | ||
|
|
||
| $alternates[] = [ | ||
| 'hreflang' => $langCode, | ||
| 'href' => $baseUrl . $fullPath, | ||
| ]; | ||
| } | ||
|
|
||
| // Add x-default pointing to the default locale version | ||
| $defaultLangCode = strtolower(substr($defaultLocale, 0, 2)); | ||
| foreach ($alternates as $alt) { | ||
| if ($alt['hreflang'] === $defaultLangCode) { | ||
| $alternates[] = [ | ||
| 'hreflang' => 'x-default', | ||
| 'href' => $alt['href'], | ||
| ]; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| return $alternates; | ||
| } | ||
|
|
||
| /** | ||
| * Build a reverse map from translated route path => route key | ||
| * for the current locale, sorted longest-first for greedy matching. | ||
| * | ||
| * @param array<string, string> $locales | ||
| * @return array<string, string> path => route key | ||
| */ | ||
| private static function buildReverseMap(array $locales, string $currentLocale): array | ||
| { | ||
| $reverseMap = []; | ||
|
|
||
| // Get all route keys from JSON + fallback | ||
| $allKeys = self::getAllRouteKeys(); | ||
|
|
||
| foreach ($allKeys as $key) { | ||
| $path = RouteTranslator::getRouteForLocale($key, $currentLocale); | ||
| // Only map leaf routes, skip API/internal routes | ||
| if (str_starts_with($path, '/api/') || str_starts_with($path, '/admin/')) { | ||
| continue; | ||
| } | ||
| $reverseMap[$path] = $key; | ||
| } | ||
|
|
||
| // Sort by path length descending (longest first for greedy prefix matching) | ||
| uksort($reverseMap, function (string $a, string $b): int { | ||
| return strlen($b) <=> strlen($a); | ||
| }); | ||
|
|
||
| return $reverseMap; | ||
| } | ||
|
|
||
| /** | ||
| * Get all route keys from JSON files + fallback routes. | ||
| * | ||
| * @return array<int, string> | ||
| */ | ||
| private static function getAllRouteKeys(): array | ||
| { | ||
| $keys = RouteTranslator::getAvailableKeys(); | ||
|
|
||
| // Also scan JSON files for keys not in fallbackRoutes (e.g. "events") | ||
| $localeDir = __DIR__ . '/../../locale'; | ||
| $pattern = $localeDir . '/routes_*.json'; | ||
| foreach (glob($pattern) ?: [] as $file) { | ||
| $content = file_get_contents($file); | ||
| if ($content === false) { | ||
| continue; | ||
| } | ||
| $decoded = json_decode($content, true); | ||
| if (!is_array($decoded)) { | ||
| continue; | ||
| } | ||
| foreach (array_keys($decoded) as $key) { | ||
| if (is_string($key) && !in_array($key, $keys, true)) { | ||
| $keys[] = $key; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $keys; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.