Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri
- **Description-inclusive search** — Header search, admin book search, and unified search all query book descriptions
- **HTML-free search column** — New `descrizione_plain` column stores `strip_tags()` version of description for clean search results

**SEO & LLM Readiness (PR #92):**
- **Hreflang alternate tags** — Every frontend page emits `<link rel="alternate" hreflang>` for all active locales plus `x-default`, enabling search engines and AI models to link language variants together
- **RSS 2.0 feed** — `/feed.xml` endpoint with the latest 50 books (title, author, description, publication date), autodiscovery `<link>` in layout, and `Feed:` directive in robots.txt
- **Sitemap expansion** — Events now included in sitemap with locale-prefixed URLs; feed.xml added as global entry
- **RSS icon in footer** — SVG feed icon next to the "Powered by Pinakes" attribution

**Bug Fixes:**
- **CSV export cleanup** — `descrizione` follows `sottotitolo`, HTML tags stripped for clean output
- **Auto-hook registration** — New plugin hooks auto-registered on page load if missing from database
Expand Down Expand Up @@ -264,7 +270,7 @@ Automatic emails for:
- **AJAX search** with instant results and relevance ranking
- **AJAX filters**: genre, publisher, availability, publication year, format
- **Patrons can leave reviews and ratings** (configurable)
- **Built-in SEO tooling**: sitemap, clean URLs, Schema.org metadata tags
- **Built-in SEO tooling**: sitemap, clean URLs, Schema.org metadata, hreflang tags, RSS 2.0 feed
- **Cookie-consent banner** and privacy tools (GDPR-compliant)

### Dewey Decimal Classification
Expand Down Expand Up @@ -500,6 +506,8 @@ If Pinakes helps your library, please ⭐ the repository!
- `app/Controllers/UserWishlistController.php` – Wishlist UX
- `app/Views/frontend/catalog.php` – Public catalog filters
- `app/Controllers/SeoController.php` – Sitemap + robots.txt
- `app/Controllers/FeedController.php` – RSS 2.0 feed
- `app/Support/HreflangHelper.php` – Hreflang alternate URL generation
- `storage/plugins/` – Plugin directory (all pre-installed plugins)

---
Expand Down
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 = gmdate('r');
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);
}
}
88 changes: 88 additions & 0 deletions app/Controllers/SeoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@

namespace App\Controllers;

use App\Support\ConfigStore;
use App\Support\HtmlHelper;
use App\Support\I18n;
use App\Support\RouteTranslator;
use App\Support\SitemapGenerator;
use mysqli;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpNotFoundException;

class SeoController
{
Expand All @@ -33,12 +36,97 @@ public function robots(Request $request, Response $response): Response
'Disallow: ' . $basePath . RouteTranslator::route('register'),
'',
'Sitemap: ' . $baseUrl . '/sitemap.xml',
'Feed: ' . $baseUrl . '/feed.xml',
];

if (ConfigStore::get('seo.llms_txt_enabled', '0') === '1') {
$lines[] = 'llms.txt: ' . $baseUrl . '/llms.txt';
}

$response->getBody()->write(implode("\n", $lines) . "\n");
return $response->withHeader('Content-Type', 'text/plain; charset=UTF-8');
}

public function llmsTxt(Request $request, Response $response, mysqli $db): Response
{
if (ConfigStore::get('seo.llms_txt_enabled', '0') !== '1') {
throw new HttpNotFoundException($request);
}

$baseUrl = self::resolveBaseUrl($request);
$appName = ConfigStore::get('app.name', 'Pinakes');
$appDesc = ConfigStore::get('app.footer_description', '');
$locale = I18n::getInstallationLocale();
$locales = I18n::getAvailableLocales();

// Gather stats
$stats = $db->query(
"SELECT
(SELECT COUNT(*) FROM libri WHERE deleted_at IS NULL) AS books,
(SELECT COUNT(*) FROM autori) AS authors,
(SELECT COUNT(*) FROM editori) AS publishers,
(SELECT COUNT(*) FROM generi) AS genres,
(SELECT COUNT(*) FROM events WHERE is_active = 1) AS events"
);
$row = $stats ? $stats->fetch_assoc() : [];
$bookCount = (int) ($row['books'] ?? 0);
$authorCount = (int) ($row['authors'] ?? 0);
$publisherCount = (int) ($row['publishers'] ?? 0);
$eventCount = (int) ($row['events'] ?? 0);

// Language list
$langNames = [];
foreach ($locales as $code => $name) {
$langNames[] = $name;
}
$languageList = implode(', ', $langNames);

// Build markdown
$lines = [];
$lines[] = '# ' . $appName;
$lines[] = '';

$descPart = $appDesc !== '' ? rtrim($appDesc, '.') . '. ' : '';
$summary = $descPart . sprintf(__('Collezione: %d libri, %d autori, %d editori.'), $bookCount, $authorCount, $publisherCount);
$lines[] = '> ' . $summary;
$lines[] = '';
$lines[] = sprintf(__('Catalogo bibliotecario gestito con [Pinakes](https://github.com/fabiodalez-dev/Pinakes). Disponibile in: %s.'), $languageList);
$lines[] = '';

// Main Pages
$lines[] = '## ' . __('Pagine Principali');
$lines[] = '- [' . __('Catalogo') . '](' . $baseUrl . RouteTranslator::route('catalog') . '): ' . __('Sfoglia e cerca la collezione completa');
$lines[] = '- [' . __('Chi Siamo') . '](' . $baseUrl . RouteTranslator::route('about') . '): ' . __('Informazioni sulla biblioteca');
$lines[] = '- [' . __('Contatti') . '](' . $baseUrl . RouteTranslator::route('contact') . '): ' . __('Informazioni di contatto');
if ($eventCount > 0) {
$lines[] = '- [' . __('Eventi') . '](' . $baseUrl . RouteTranslator::route('events') . '): ' . __('Calendario eventi culturali');
}
$lines[] = '- [' . __('Privacy') . '](' . $baseUrl . RouteTranslator::route('privacy') . '): ' . __('Informativa sulla privacy');
$lines[] = '';

// Feeds & Discovery
$lines[] = '## ' . __('Feed e Scoperta');
$lines[] = '- [' . __('Feed RSS') . '](' . $baseUrl . '/feed.xml): ' . __('Ultime aggiunte al catalogo (RSS 2.0)');
$lines[] = '- [Sitemap](' . $baseUrl . '/sitemap.xml): ' . __('Indice completo degli URL');
$lines[] = '';

// API (only if enabled)
if (ConfigStore::get('api.enabled', '0') === '1') {
$lines[] = '## API';
$lines[] = '- [SRU 1.2](' . $baseUrl . '/api/sru?operation=explain): ' . __('Interoperabilità bibliotecaria (MARCXML, Dublin Core)');
$lines[] = '';
}

// Optional
$lines[] = '## ' . __('Accesso');
$lines[] = '- [' . __('Accedi') . '](' . $baseUrl . RouteTranslator::route('login') . '): ' . __('Autenticazione utente');
$lines[] = '- [' . __('Registrati') . '](' . $baseUrl . RouteTranslator::route('register') . '): ' . __('Registrazione nuovo utente');
$lines[] = '';

$response->getBody()->write(implode("\n", $lines));
return $response->withHeader('Content-Type', 'text/plain; charset=UTF-8');
}

public static function resolveBaseUrl(?Request $request = null): string
{
$envUrl = getenv('APP_CANONICAL_URL') ?: ($_ENV['APP_CANONICAL_URL'] ?? '');
Expand Down
6 changes: 6 additions & 0 deletions app/Controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ private function resolveAdvancedSettings(SettingsRepository $repository): array
'sitemap_last_generated_at' => $repository->get('advanced', 'sitemap_last_generated_at', $config['sitemap_last_generated_at'] ?? ''),
'sitemap_last_generated_total' => (int) $repository->get('advanced', 'sitemap_last_generated_total', (string) ($config['sitemap_last_generated_total'] ?? 0)),
'api_enabled' => $repository->get('api', 'enabled', '0'),
'llms_txt_enabled' => $repository->get('seo', 'llms_txt_enabled', '0'),
];
}

Expand Down Expand Up @@ -622,6 +623,11 @@ public function updateAdvancedSettings(Request $request, Response $response, mys
ConfigStore::set('cookie_banner.show_marketing', true);
}

// Handle llms.txt toggle (stored in 'seo' category)
$llmsTxtEnabled = isset($data['llms_txt_enabled']) && $data['llms_txt_enabled'] === '1' ? '1' : '0';
$repository->set('seo', 'llms_txt_enabled', $llmsTxtEnabled);
ConfigStore::set('seo.llms_txt_enabled', $llmsTxtEnabled === '1');

// Handle catalogue mode setting (stored in 'system' category)
$catalogueMode = isset($data['catalogue_mode']) && $data['catalogue_mode'] === '1' ? '1' : '0';
$repository->set('system', 'catalogue_mode', $catalogueMode);
Expand Down
12 changes: 12 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@
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);
});

$app->get('/llms.txt', function ($request, $response) use ($app) {
$controller = new SeoController();
$db = $app->getContainer()->get('db');
return $controller->llmsTxt($request, $response, $db);
});

// Public language switch endpoint
$app->get('/language/{locale}', function ($request, $response, $args) use ($app) {
$controller = new LanguageController();
Expand Down
7 changes: 7 additions & 0 deletions app/Support/ConfigStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,13 @@ private static function loadDatabaseSettings(): array
}
}

if (!empty($raw['seo'])) {
self::$dbSettingsCache['seo'] = [];
foreach ($raw['seo'] as $key => $value) {
self::$dbSettingsCache['seo'][$key] = (string) $value;
}
}

if (!empty($raw['cms'])) {
self::$dbSettingsCache['cms'] = [];
foreach ($raw['cms'] as $key => $value) {
Expand Down
Loading