Files
deepseek beaf0e1f37 Toolvana: production-ready media tools platform (CodeIgniter 4)
- 134-tool registry with programmatic SEO (unique titles/H1/descriptions,
  JSON-LD graphs, sitemap index, canonical 301 enforcement via required filter)
- DB-backed job queue (SKIP LOCKED) with drivers: Ffmpeg, Images (GD),
  Pdf (qpdf/gs/poppler), Youtube (thumbnails), Qr (server-side PNG)
- Security: SSRF guard, MIME validation, rate limits, API-key auth,
  bcrypt admin login, security headers
- Admin panel: dashboard, tools/categories/guides CRUD, SEO audit,
  analytics, job inspector with retry, system health, feature flags
- Docker deployment (nginx + web/api FPM pools + scalable workers),
  PHPUnit suite (19 tests / 1139 assertions), PWA manifest + service worker
2026-08-23 07:10:30 +00:00

252 lines
8.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* SEO metadata + JSON-LD builder. One instance per request, rendered by
* the layout partial (views/partials/seo.php).
*
* Only structured data the visible page actually supports is emitted —
* no fake ratings or reviews anywhere.
*/
final class Seo
{
private string $title = '';
private string $description = '';
private string $canonical = '';
private string $ogType = 'website';
private ?string $ogImage = null;
/** @var list<array{position:int,name:string,item:string}> */
private array $breadcrumbs = [];
/** @var list<array> JSON-LD graph nodes */
private array $jsonLd = [];
/** @var array<string,string> hreflang lang => url */
private array $alternates = [];
private bool $indexable = true;
public function title(string $title): self
{
$this->title = $title;
return $this;
}
public function description(string $description): self
{
$this->description = mb_substr(trim($description), 0, 320);
return $this;
}
public function canonical(string $pathOrUrl): self
{
$this->canonical = str_starts_with($pathOrUrl, 'http')
? $pathOrUrl
: base_url('/' . ltrim($pathOrUrl, '/'));
return $this;
}
/**
* Breadcrumb trail: [['label' => 'YouTube Tools', 'url' => '/youtube-tools'], ...]
* Home is prepended automatically and BreadcrumbList JSON-LD emitted.
*/
public function breadcrumbs(array $items): self
{
$trail = [['label' => 'Home', 'url' => base_url('/')]];
foreach ($items as $item) {
$trail[] = [
'label' => $item['label'],
'url' => isset($item['url']) ? base_url($item['url']) : null,
];
}
$this->breadcrumbs = array_map(
static fn (array $t, int $i): array => [
'position' => $i + 1,
'name' => $t['label'],
'item' => $t['url'] ?? current_url(),
],
$trail,
range(0, count($trail) - 1)
);
return $this;
}
public function webApplication(string $name, string $description, string $category = 'MultimediaApplication'): self
{
$this->jsonLd[] = [
'@type' => 'WebApplication',
'name' => $name,
'url' => $this->canonical ?: base_url('/'),
'description' => $description,
'applicationCategory' => $category,
'operatingSystem' => 'Any',
'browserRequirements' => 'Requires JavaScript when processing files',
'publisher' => ['@type' => 'Organization', 'name' => config('Site')->name],
];
return $this;
}
public function faqPage(array $faqs): self
{
if ($faqs === []) {
return $this;
}
$entities = [];
foreach ($faqs as $faq) {
if (($faq['q'] ?? '') === '' || ($faq['a'] ?? '') === '') {
continue;
}
$entities[] = [
'@type' => 'Question',
'name' => $faq['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => strip_tags($faq['a'])],
];
}
if ($entities !== []) {
$this->jsonLd[] = ['@type' => 'FAQPage', 'mainEntity' => $entities];
}
return $this;
}
/** HowTo steps: [['name' => ..., 'text' => ...], ...] */
public function howTo(string $name, array $steps): self
{
if ($steps === []) {
return $this;
}
$items = [];
foreach (array_values($steps) as $i => $step) {
$items[] = [
'@type' => 'HowToStep',
'position' => $i + 1,
'name' => $step['name'] ?? ('Step ' . ($i + 1)),
'text' => $step['text'] ?? '',
];
}
$this->jsonLd[] = ['@type' => 'HowTo', 'name' => $name, 'step' => $items];
return $this;
}
public function article(string $headline, string $bodyText, string $publishedAt, string $authorName): self
{
$this->ogType = 'article';
$this->jsonLd[] = [
'@type' => 'Article',
'headline' => mb_substr($headline, 0, 110),
'description' => mb_substr(strip_tags($bodyText), 0, 200),
'datePublished' => $publishedAt,
'dateModified' => $publishedAt,
'author' => ['@type' => 'Organization', 'name' => $authorName],
'publisher' => ['@type' => 'Organization', 'name' => $authorName],
'mainEntityOfPage' => $this->canonical ?: current_url(),
];
return $this;
}
public function noIndex(): self
{
$this->indexable = false;
return $this;
}
public function ogImage(?string $path): self
{
$this->ogImage = $path;
return $this;
}
public function alternate(string $locale, string $url): self
{
$this->alternates[$locale] = $url;
return $this;
}
// -----------------------------------------------------------------
// Renderers used by the layout
// -----------------------------------------------------------------
public function renderTitle(): string
{
return esc($this->title);
}
public function renderHead(): string
{
$site = config('Site');
$out = [];
$canonical = $this->canonical ?: current_url();
$robots = $this->indexable
? 'index, follow, max-image-preview:large'
: 'noindex, nofollow';
$out[] = '<link rel="canonical" href="' . esc($canonical) . '">';
$out[] = '<meta name="robots" content="' . $robots . '">';
$title = $this->title !== '' ? $this->title : $site->name . ' ' . $site->tagline;
$description = $this->description !== '' ? $this->description : $site->description;
$out[] = '<meta property="og:title" content="' . esc($title) . '">';
$out[] = '<meta property="og:description" content="' . esc($description) . '">';
$out[] = '<meta property="og:type" content="' . esc($this->ogType) . '">';
$out[] = '<meta property="og:url" content="' . esc($canonical) . '">';
$out[] = '<meta property="og:site_name" content="' . esc($site->name) . '">';
$image = $this->ogImage ?? base_url('/og/default.png');
$out[] = '<meta property="og:image" content="' . esc($image) . '">';
$out[] = '<meta name="twitter:card" content="summary_large_image">';
$out[] = '<meta name="twitter:title" content="' . esc($title) . '">';
$out[] = '<meta name="twitter:description" content="' . esc($description) . '">';
$out[] = '<meta name="twitter:image" content="' . esc($image) . '">';
foreach ($this->alternates as $lang => $url) {
$out[] = '<link rel="alternate" hreflang="' . esc($lang) . '" href="' . esc($url) . '">';
}
return implode("\n", $out);
}
public function renderJsonLd(): string
{
$graph = [];
// WebSite + SearchAction on every page (site-wide sitelinks searchbox)
if (uri_string() === '') {
$graph[] = [
'@type' => 'WebSite',
'name' => config('Site')->name,
'url' => base_url('/'),
'potentialAction' => [
'@type' => 'SearchAction',
'target' => base_url('/search') . '?q={search_term_string}',
'query-input' => 'required name=search_term_string',
],
];
}
if ($this->breadcrumbs !== []) {
$graph[] = [
'@type' => 'BreadcrumbList',
'itemListElement' => $this->breadcrumbs,
];
}
return json_encode(
['@context' => 'https://schema.org', '@graph' => [...$graph, ...$this->jsonLd]],
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}