- 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
106 lines
3.5 KiB
PHP
106 lines
3.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Libraries\FormatCatalog;
|
|
use App\Libraries\Pipeline\Youtube as YoutubeLib;
|
|
|
|
/**
|
|
* Renders every individual tool page (/{slug}).
|
|
*
|
|
* The page is 100% server-rendered HTML: H1, intro, formats, how-to and
|
|
* FAQ are all in the first response. Interactive processing loads a
|
|
* small per-kind JS module afterwards.
|
|
*/
|
|
final class Tools extends BaseController
|
|
{
|
|
public function show(string $slug): string
|
|
{
|
|
$tool = service('finder')->tool($slug);
|
|
|
|
if ($tool === null) {
|
|
return (new Errors())->notFound();
|
|
}
|
|
|
|
$related = service('relatedTools')->forTool($tool);
|
|
$conversions = service('relatedTools')->conversionsAround($tool);
|
|
$category = service('finder')->categoryById((int) $tool['category_id']);
|
|
$guides = $this->guidesFor($tool);
|
|
|
|
// capability flags for honest UX
|
|
$capabilities = [
|
|
'yt_dlp' => service('pipeline')->youtubeEnabled(),
|
|
'binaries' => $this->checkBinaries($tool),
|
|
];
|
|
|
|
$this->seo->title($tool['seo_title'])
|
|
->description($tool['seo_description'])
|
|
->canonical('/' . $tool['slug'])
|
|
->breadcrumbs([
|
|
['label' => $category['name'] ?? 'Tools', 'url' => '/' . ($category['slug'] ?? 'tools')],
|
|
['label' => $tool['name']],
|
|
])
|
|
->webApplication($tool['name'], (string) $tool['short_description'], $this->appCategory($tool))
|
|
->faqPage(is_array($tool['faqs']) ? $tool['faqs'] : [])
|
|
->howTo((string) $tool['name'], is_array($tool['how_to']) ? $tool['how_to'] : [])
|
|
->ogImage('/og/' . $tool['slug'] . '.png');
|
|
|
|
foreach (config('Site')->locales as $locale => $_label) {
|
|
if ($locale === config('Site')->defaultLocale) {
|
|
continue;
|
|
}
|
|
$this->seo->alternate($locale, '/' . $locale . '/' . $tool['slug']);
|
|
}
|
|
$this->seo->alternate('x-default', '/' . $tool['slug']);
|
|
|
|
$this->trackToolView($tool['slug']);
|
|
|
|
return $this->render('tool', [
|
|
'tool' => $tool,
|
|
'category' => $category,
|
|
'related' => $related,
|
|
'conversions' => $conversions,
|
|
'guides' => $guides,
|
|
'capabilities' => $capabilities,
|
|
]);
|
|
}
|
|
|
|
/** @return list<array> */
|
|
private function guidesFor(array $tool): array
|
|
{
|
|
$guides = model(\App\Models\GuideModel::class)->published(30);
|
|
$hits = [];
|
|
foreach ($guides as $guide) {
|
|
$linked = json_decode($guide['tool_slugs'] ?? '[]', true) ?: [];
|
|
if (in_array($tool['slug'], $linked, true)) {
|
|
$hits[] = $guide;
|
|
}
|
|
}
|
|
|
|
return array_slice($hits, 0, 3);
|
|
}
|
|
|
|
private function checkBinaries(array $tool): array
|
|
{
|
|
$ok = [];
|
|
foreach (($tool['requires_binaries'] ?? []) as $bin) {
|
|
$path = config('Site')->binaries[$bin] ?? null;
|
|
$ok[$bin] = $path !== null && is_executable($path);
|
|
}
|
|
|
|
return $ok;
|
|
}
|
|
|
|
private function appCategory(array $tool): string
|
|
{
|
|
return match (FormatCatalog::family((string) ($tool['primary_output_format'] ?? ''))) {
|
|
'video' => 'MultimediaApplication',
|
|
'audio' => 'MultimediaApplication',
|
|
'image' => 'DesignApplication',
|
|
default => 'UtilitiesApplication',
|
|
};
|
|
}
|
|
}
|