Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
142 changes: 142 additions & 0 deletions app/Controllers/FeedController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?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>' . $this->xmlEscape($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>' . $this->xmlEscape($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) {
error_log('FeedController::getLatestBooks query failed: ' . $db->error);
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) {
error_log('FeedController: invalid created_at for book ID ' . $id . ': ' . (string)$row['created_at']);
$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);
}
}
37 changes: 35 additions & 2 deletions app/Controllers/LibriController.php
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,40 @@ public function index(Request $request, Response $response, mysqli $db): Respons
$repo = new \App\Models\BookRepository($db);
$libri = $repo->listWithAuthors(100);

// Resolve genre names for URL filter display
$params = $request->getQueryParams();
$genreFilterName = '';
$subgenreFilterName = '';
$genreId = (int) ($params['genere'] ?? $params['genere_filter'] ?? 0);
$subgenreId = (int) ($params['sottogenere'] ?? $params['sottogenere_filter'] ?? 0);
if ($genreId > 0 || $subgenreId > 0) {
$lookupId = $subgenreId > 0 ? $subgenreId : $genreId;
$stmt = $db->prepare('SELECT nome FROM generi WHERE id = ?');
if ($stmt) {
$stmt->bind_param('i', $lookupId);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
if ($subgenreId > 0) {
$subgenreFilterName = $row['nome'] ?? '';
} else {
$genreFilterName = $row['nome'] ?? '';
}
$stmt->close();
}
if ($genreId > 0 && $subgenreId > 0) {
$stmt2 = $db->prepare('SELECT nome FROM generi WHERE id = ?');
if ($stmt2) {
$stmt2->bind_param('i', $genreId);
$stmt2->execute();
$row2 = $stmt2->get_result()->fetch_assoc();
$genreFilterName = $row2['nome'] ?? '';
$stmt2->close();
}
}
}

ob_start();
$data = ['libri' => $libri];
$data = ['libri' => $libri, 'genreFilterName' => $genreFilterName, 'subgenreFilterName' => $subgenreFilterName];
// extract($data);
require __DIR__ . '/../Views/libri/index.php';
$content = ob_get_clean();
Expand Down Expand Up @@ -2825,7 +2857,7 @@ public function exportCsv(Request $request, Response $response, mysqli $db): Res
$escapedRow = array_map(function ($field) use ($delimiter) {
$field = str_replace('"', '""', (string) $field);
// Quote if contains delimiter, newline, or quotes
if (strpos($field, $delimiter) !== false || strpos($field, "\n") !== false || strpos($field, '"') !== false) {
if (strpos($field, $delimiter) !== false || strpos($field, "\n") !== false || strpos($field, "\r") !== false || strpos($field, '"') !== false) {
return '"' . $field . '"';
}
return $field;
Expand Down Expand Up @@ -2925,6 +2957,7 @@ private function normalizeDescriptionForCsv(string $html): string
$text = preg_replace('/<(?:\/?(?:p|div|li|ul|ol|h[1-6]|blockquote|tr|th|td)\b[^>]*|br\b[^>]*\/?)>/i', "\n", $html);
$text = html_entity_decode(strip_tags((string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = str_replace("\xC2\xA0", ' ', (string) $text);
$text = str_replace("\r", '', $text);
$text = (string) preg_replace("/[ \t]+/", ' ', $text);
$text = (string) preg_replace("/\n{3,}/", "\n\n", $text);
return trim($text);
Expand Down
89 changes: 89 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,98 @@ 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 = (string) ConfigStore::get('app.name', 'Pinakes');
$appDesc = (string) ConfigStore::get('app.footer_description', '');
$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 events WHERE is_active = 1) AS events"
);
if (!$stats) {
error_log('SeoController::llmsTxt stats query failed: ' . $db->error);
}
$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 $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);

// 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
Loading