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
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user