diff --git a/app/Controllers/SeoController.php b/app/Controllers/SeoController.php index 74b8d1db1..215a38b8c 100644 --- a/app/Controllers/SeoController.php +++ b/app/Controllers/SeoController.php @@ -73,7 +73,9 @@ public function llmsTxt(Request $request, Response $response, mysqli $db): Respo $baseUrl = self::resolveBaseUrl($request); $appName = (string) ConfigStore::get('app.name', 'Pinakes'); $appDesc = (string) ConfigStore::get('app.footer_description', ''); - $locales = I18n::getAvailableLocales(); + // The catalogue's content is written in the site language; the other + // interface translations do not make it available in those languages. + $locales = I18n::getContentLocales(); // Gather stats $stats = $db->query( diff --git a/app/Middleware/PrivateModeMiddleware.php b/app/Middleware/PrivateModeMiddleware.php index 1b39712e7..dfa19c6c2 100644 --- a/app/Middleware/PrivateModeMiddleware.php +++ b/app/Middleware/PrivateModeMiddleware.php @@ -48,6 +48,8 @@ class PrivateModeMiddleware implements MiddlewareInterface '/assets/', '/css/', '/js/', '/img/', '/images/', '/fonts/', '/uploads/settings/', '/installer', '/language/', '/health', '/favicon', '/robots.txt', + // Interface strings only, the same for every visitor: the login page needs them. + '/i18n/', '/.well-known/', // Test-only cache-flush endpoint (web.php): the route 404s unless the // server env sets PINAKES_E2E_CACHE_FLUSH=1, so allowing the prefix diff --git a/app/Routes/web.php b/app/Routes/web.php index 5d13f3acd..5b75cdbab 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -247,6 +247,25 @@ return $controller->llmsTxt($request, $response, $db); }); + // JavaScript translation table, one cacheable script per locale (layouts load + // it instead of inlining the whole table in every page). No ".js" in the path: + // nginx configs send every *.js to a static-file location that answers 404 + // without reaching PHP (.nginx.conf.example), and those configs are not + // updated with the code. + $app->get('/i18n/{locale:[a-z]{2}_[A-Z]{2}}', function ($request, $response, $args) { + $body = \App\Support\TranslationScript::body((string) $args['locale']); + if ($body === null) { + throw new \Slim\Exception\HttpNotFoundException($request); + } + $response->getBody()->write($body); + // The URL carries the file's version (?v=), so the content behind it never + // changes and the browser can keep it for a year. + return $response + ->withHeader('Content-Type', 'application/javascript; charset=UTF-8') + ->withHeader('Cache-Control', 'public, max-age=31536000, immutable') + ->withHeader('X-Content-Type-Options', 'nosniff'); + }); + // Public language switch endpoint $app->get('/language/{locale}', function ($request, $response, $args) use ($app) { $controller = new LanguageController(); diff --git a/app/Support/SessionPolicy.php b/app/Support/SessionPolicy.php index f5eb302e0..fb3b153cd 100644 --- a/app/Support/SessionPolicy.php +++ b/app/Support/SessionPolicy.php @@ -48,6 +48,9 @@ final class SessionPolicy '/feed.xml', '/llms.txt', '/language/', + // The JavaScript translation table: interface strings, the same for every + // visitor, cached by the browser for a year. + '/i18n/', // Only the genuinely public /uploads subtrees are sessionless. The // private ones — /uploads/digital/, /uploads/archives/documents/, // /uploads/storage/ — are deliberately NOT listed: PrivateModeMiddleware diff --git a/app/Support/SitemapGenerator.php b/app/Support/SitemapGenerator.php index 3f330fdd6..83dfdd413 100644 --- a/app/Support/SitemapGenerator.php +++ b/app/Support/SitemapGenerator.php @@ -67,6 +67,18 @@ class SitemapGenerator 'plugins' => 0, ]; + /** + * CMS slugs whose path is a legacy alias redirected (301) to a route, see the + * legacy redirects in app/Routes/web.php. + * + * @var array + */ + private const LEGACY_CMS_SLUGS = [ + 'about-us' => 'about', + 'privacy-policy' => 'privacy', + 'cookie-policy' => 'cookies', + ]; + public function __construct(mysqli $db, string $baseUrl) { $this->db = $db; @@ -421,7 +433,7 @@ private function getCmsEntries(): array } $entries[] = [ - 'loc' => $this->baseUrl . '/' . rawurlencode($slug), + 'loc' => $this->baseUrl . $this->cmsPagePath($slug), 'changefreq' => 'monthly', 'priority' => '0.6', 'lastmod' => $row['updated_at'] ?? $row['created_at'] ?? null, @@ -435,6 +447,37 @@ private function getCmsEntries(): array return $entries; } + /** + * An encoded slash (%2F) in a path is refused by Apache before PHP sees it + * (AllowEncodedSlashes Off), so a name such as "Seminar (2005 Ljubljana/…)" + * has no page that answers: listing it only reports a 404. + */ + private static function isRoutableName(string $name): bool + { + return !str_contains($name, '/') && !str_contains($name, '\\'); + } + + /** + * The same test in SQL, applied before LIMIT: filtering only the fetched rows + * would let names that have no page take places under the cap from valid ones. + * CHAR(92) is the backslash, which LIKE would read as its escape character. + */ + private static function routableNameSql(string $column): string + { + return "INSTR({$column}, '/') = 0 AND INSTR({$column}, CHAR(92)) = 0"; + } + + /** + * Path a CMS page is actually served at. A page seeded with another locale's + * slug (about-us and privacy-policy on an Italian install) only answers with a + * 301 to its route, so the sitemap lists the route instead of the redirect. + */ + private function cmsPagePath(string $slug): string + { + $pageId = CmsHelper::getPageIdFromSlug($slug) ?? (self::LEGACY_CMS_SLUGS[$slug] ?? null); + return $pageId !== null ? $this->routePath($pageId) : '/' . rawurlencode($slug); + } + /** * @return array> */ @@ -532,6 +575,7 @@ private function getAuthorEntries(int $cap): array FROM autori a JOIN libri_autori la ON la.autore_id = a.id JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('a.nome') . " GROUP BY a.id, a.nome, a.created_at ORDER BY a.created_at DESC LIMIT {$limit} @@ -541,7 +585,7 @@ private function getAuthorEntries(int $cap): array $authorPath = $this->routePath('author'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } @@ -585,6 +629,7 @@ private function getPublisherEntries(int $limit): array SELECT e.nome FROM editori e JOIN libri l ON ({$publisherMatch}) AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('e.nome') . " GROUP BY e.id, e.nome HAVING COUNT(l.id) > 0 ORDER BY e.nome ASC @@ -595,7 +640,7 @@ private function getPublisherEntries(int $limit): array $publisherPath = $this->routePath('publisher'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } @@ -624,6 +669,7 @@ private function getGenreEntries(int $limit): array SELECT g.nome FROM generi g JOIN libri l ON l.genere_id = g.id AND l.deleted_at IS NULL AND " . BookVisibility::catalogue($this->db, 'l') . " + WHERE " . self::routableNameSql('g.nome') . " GROUP BY g.id, g.nome HAVING COUNT(l.id) > 0 ORDER BY g.nome ASC @@ -634,7 +680,7 @@ private function getGenreEntries(int $limit): array $genrePath = $this->routePath('genre'); while ($row = $result->fetch_assoc()) { $name = trim((string)($row['nome'] ?? '')); - if ($name === '') { + if ($name === '' || !self::isRoutableName($name)) { continue; } diff --git a/app/Support/TranslationScript.php b/app/Support/TranslationScript.php new file mode 100644 index 000000000..2cc1bb496 --- /dev/null +++ b/app/Support/TranslationScript.php @@ -0,0 +1,70 @@ + $value !== $key, + ARRAY_FILTER_USE_BOTH + ); + $json = json_encode( + (object) $translations, + JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_HEX_APOS + ); + return 'window.i18nTranslations = ' . ($json !== false ? $json : '{}') . ";\n"; + } + + private static function file(string $locale): ?string + { + // Only a locale code reaches the filesystem: no path from the URL. + if (preg_match('/^[a-z]{2}_[A-Z]{2}$/D', $locale) !== 1) { + return null; + } + $file = dirname(__DIR__, 2) . '/locale/' . $locale . '.json'; + return is_file($file) ? $file : null; + } +} diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index c24134334..dc4bb29c7 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -149,12 +149,13 @@ // Open Graph Image - Ensure absolute URLs $baseUrl = HtmlHelper::getBaseUrl(); -if ($bookCover) { +// A book without a cover leaves $ogImage null, so the layout shares the site's +// social image instead of the grey placeholder. +$ogImage = null; +if ($bookCover && !str_ends_with((string) parse_url($bookCover, PHP_URL_PATH), '/uploads/copertine/placeholder.jpg')) { // $bookCover already includes base path via url(), make it absolute $isAbsolute = preg_match('#^(https?:)?//#', $bookCover); $ogImage = $isAbsolute ? $bookCover : absoluteUrl($bookCover); -} else { - $ogImage = absoluteUrl('/uploads/copertine/placeholder.jpg'); } // Breadcrumb Schema @@ -289,7 +290,7 @@ $bookSchema["description"] = strip_tags($bookDescription); } -if ($bookCover) { +if ($ogImage !== null) { $bookSchema["image"] = $ogImage; } diff --git a/app/Views/frontend/layout.php b/app/Views/frontend/layout.php index 70de483be..b3409930f 100644 --- a/app/Views/frontend/layout.php +++ b/app/Views/frontend/layout.php @@ -205,7 +205,8 @@ $documentSite = (string) $appName; if ($documentSite !== '' && $documentTitle !== '' && $documentTitle !== $documentSite) { $documentTitle = (string) preg_replace('/\s*[-–—|]\s*' . preg_quote($documentSite, '/') . '$/u', '', $documentTitle); - $documentTitle .= ' – ' . $documentSite; + // A home whose hero title is the site name arrives as "Site - Site". + $documentTitle = $documentTitle === $documentSite ? $documentSite : $documentTitle . ' – ' . $documentSite; } elseif ($documentTitle === '') { $documentTitle = $documentSite; } @@ -213,8 +214,18 @@ <?= HtmlHelper::e($documentTitle) ?> + + content=""> @@ -236,7 +247,8 @@ $resolvedDefaultOgImage = $defaultOgImagePath !== '' ? absoluteUrl($defaultOgImagePath) : ''; $ogTitle = $ogTitle ?? ($seoTitle ?? $title ?? $appName); - $ogDescription = $ogDescription ?? ($seoDescription ?? ($footerDescription ?: __('Esplora il nostro catalogo digitale'))); + $ogDescription = isset($ogDescription) && trim((string) $ogDescription) !== '' ? $ogDescription : $metaDescription; + $ogDescription = trim((string) preg_replace('/\s+/u', ' ', (string) $ogDescription)); $ogType = $ogType ?? 'website'; $ogUrl = $ogUrl ?? ($seoCanonical ?? HtmlHelper::getCurrentUrlWithoutQuery()); $ogImage = $ogImage ?? $resolvedDefaultOgImage; @@ -245,6 +257,7 @@ $twitterCard = $twitterCard ?? 'summary_large_image'; $twitterTitle = $twitterTitle ?? $ogTitle; $twitterDescription = $twitterDescription ?? $ogDescription; + $twitterDescription = trim((string) preg_replace('/\s+/u', ' ', (string) $twitterDescription)); $twitterImage = $twitterImage ?? $ogImage; $twitterImage = $twitterImage !== '' ? absoluteUrl($twitterImage) : ''; ?> @@ -1545,19 +1558,13 @@ + + &'; + file_put_contents($probeFile, json_encode([$dangerous => $dangerous . ' traduit'])); + $script = TranslationScript::body('zz_ZZ'); + check_seo(!str_contains((string) $script, '') && !str_contains((string) $script, '