Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9ddb820
feat: add hreflang tags, RSS feed, and sitemap events
fabiodalez-dev Mar 12, 2026
1e5d8fc
fix: address CodeRabbit review findings
fabiodalez-dev Mar 12, 2026
07ade9e
refactor: consolidate xmlAttrEscape and add missing status assertion
fabiodalez-dev Mar 12, 2026
f882111
test: address CodeRabbit round-3 review findings
fabiodalez-dev Mar 12, 2026
5db1fb1
fix: address CodeRabbit round-4 review findings
fabiodalez-dev Mar 13, 2026
6f2ecaf
docs: add SEO/LLM readiness features to README changelog
fabiodalez-dev Mar 13, 2026
7aa3eab
feat: add dynamic /llms.txt endpoint with admin toggle
fabiodalez-dev Mar 13, 2026
5098b05
fix: address CodeRabbit review findings for llms.txt
fabiodalez-dev Mar 13, 2026
8f91cdf
fix: translate RSS Feed label and handle subfolder in test helper
fabiodalez-dev Mar 13, 2026
db9dc9d
fix: CSV \r column shift (#83), admin genre display (#90), error logging
fabiodalez-dev Mar 13, 2026
606959f
test: add E2E tests for #83 CSV alignment and #90 genre display
fabiodalez-dev Mar 13, 2026
9fa4bed
fix: enrich Book schema, add curatore field, fix review findings
fabiodalez-dev Mar 14, 2026
d724e0a
fix: address silent-failure-hunter review findings
fabiodalez-dev Mar 14, 2026
d179730
fix: persist curatore field in BookRepository create/update
fabiodalez-dev Mar 14, 2026
f205c5e
fix: apply host validation to both request paths, fix Google Books URL
fabiodalez-dev Mar 14, 2026
cc15967
fix: address all remaining review findings
fabiodalez-dev Mar 14, 2026
95722cb
chore: bump version to 0.5.0, move curatore migration to correct file
fabiodalez-dev Mar 14, 2026
a8b559c
docs: update README for v0.5.0 release
fabiodalez-dev Mar 14, 2026
3a698e6
test: fix seo-feed tests for empty DB and sr-only toggle
fabiodalez-dev Mar 14, 2026
3fea916
test: fix hreflang slug test to compare entity slug+id only
fabiodalez-dev Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions app/Controllers/FeedController.php
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);
}
}
1 change: 1 addition & 0 deletions app/Controllers/SeoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function robots(Request $request, Response $response): Response
'Disallow: ' . $basePath . RouteTranslator::route('register'),
'',
'Sitemap: ' . $baseUrl . '/sitemap.xml',
'Feed: ' . $baseUrl . '/feed.xml',
];

$response->getBody()->write(implode("\n", $lines) . "\n");
Expand Down
6 changes: 6 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@
return $controller->sitemap($request, $response, $db);
});

$app->get('/feed.xml', function ($request, $response) use ($app) {
$controller = new \App\Controllers\FeedController();
$db = $app->getContainer()->get('db');
return $controller->rssFeed($request, $response, $db);
});

// Public language switch endpoint
$app->get('/language/{locale}', function ($request, $response, $args) use ($app) {
$controller = new LanguageController();
Expand Down
172 changes: 172 additions & 0 deletions app/Support/HreflangHelper.php
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the installation subfolder when building alternate URLs.

Lines 39-40 strip $basePath from the request, but Line 92 only concatenates $baseUrl . $fullPath. When SeoController::resolveBaseUrl() falls back to host-only output, subfolder installs emit broken alternates like https://example.com/en/catalog instead of https://example.com/pinakes/en/catalog.

🔧 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
Verify each finding against the current code and only fix it if needed.

In `@app/Support/HreflangHelper.php` around lines 29 - 31, The alternate URL
builder is dropping the installation subfolder because
SeoController::resolveBaseUrl() can return a host-only base and the code later
simply concatenates $baseUrl . $fullPath; update the logic in HreflangHelper
(use the $basePath from HtmlHelper::getBasePath() together with $baseUrl and
$fullPath) so that when $baseUrl has no path you prepend or inject $basePath (or
always join rtrim($baseUrl,'/') and ltrim($basePath.'/'.$fullPath,'/')) before
emitting alternates; reference $basePath, $baseUrl, $fullPath and
SeoController::resolveBaseUrl() to locate and fix the concatenation where
alternates are built.


// 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)) ?: '/';
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;
}
}
Loading