- 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
96 lines
3.9 KiB
PHP
96 lines
3.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use League\CommonMark\Environment\Environment;
|
||
use League\CommonMark\Extension\Autolink\AutolinkExtension;
|
||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||
use League\CommonMark\Extension\Table\TableExtension;
|
||
use League\CommonMark\MarkdownConverter;
|
||
|
||
/**
|
||
* Guides cluster: /blog and /blog/{slug}.
|
||
*/
|
||
final class Guides extends BaseController
|
||
{
|
||
public function index(): string
|
||
{
|
||
$guides = model(\App\Models\GuideModel::class)->published(50);
|
||
|
||
$this->seo->title('Guides & Tutorials – Media How-Tos')
|
||
->description('Practical guides for converting, compressing and editing media: video, audio, images, GIFs, PDFs and YouTube workflows.')
|
||
->canonical('/blog')
|
||
->breadcrumbs([['label' => 'Blog']]);
|
||
|
||
return $this->render('guides_index', ['guides' => $guides]);
|
||
}
|
||
|
||
public function show(string $slug): string
|
||
{
|
||
$guide = model(\App\Models\GuideModel::class)->findBySlug($slug);
|
||
if ($guide === null) {
|
||
return (new Errors())->notFound();
|
||
}
|
||
|
||
model(\App\Models\GuideModel::class)->incrementViews((int) $guide['id']);
|
||
|
||
$linkedTools = [];
|
||
foreach (json_decode((string) ($guide['tool_slugs'] ?? '[]'), true) ?: [] as $toolSlug) {
|
||
if ($tool = service('finder')->tool((string) $toolSlug)) {
|
||
$linkedTools[] = $tool;
|
||
}
|
||
}
|
||
|
||
$related = array_values(array_filter(
|
||
model(\App\Models\GuideModel::class)->published(20),
|
||
static fn (array $g): bool => $g['slug'] !== $guide['slug']
|
||
));
|
||
|
||
$this->seo->title($guide['seo_title'] ?: $guide['title'])
|
||
->description($guide['seo_description'] ?: mb_substr($guide['excerpt'], 0, 300))
|
||
->canonical('/blog/' . $guide['slug'])
|
||
->breadcrumbs([['label' => 'Blog', 'url' => '/blog'], ['label' => $guide['title']]])
|
||
->article($guide['title'], (string) $guide['body_md'], (string) $guide['published_at'], config('Site')->name)
|
||
->ogImage('/og/blog-' . $guide['slug'] . '.png');
|
||
|
||
return $this->render('guide', [
|
||
'guide' => $guide,
|
||
'html' => self::markdown((string) $guide['body_md']),
|
||
'tools' => $linkedTools,
|
||
'related'=> array_slice($related, 0, 4),
|
||
]);
|
||
}
|
||
|
||
public function rss(): \CodeIgniter\HTTP\Response
|
||
{
|
||
$guides = model(\App\Models\GuideModel::class)->published(30);
|
||
$site = config('Site');
|
||
$items = '';
|
||
foreach ($guides as $g) {
|
||
$items .= "\n<item><title>" . htmlspecialchars((string) $g['title'], ENT_XML1)
|
||
. "</title><link>" . base_url('/blog/' . $g['slug']) . "</link>"
|
||
. "<description>" . htmlspecialchars((string) $g['excerpt'], ENT_XML1) . "</description>"
|
||
. "<pubDate>" . date(DATE_RSS, strtotime((string) $g['published_at'])) . "</pubDate>"
|
||
. "<guid>" . base_url('/blog/' . $g['slug']) . "</guid></item>";
|
||
}
|
||
$xml = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>'
|
||
. htmlspecialchars($site->name . ' Guides', ENT_XML1) . '</title><link>' . base_url('/blog')
|
||
. '</link><description>' . htmlspecialchars($site->tagline, ENT_XML1) . '</description>'
|
||
. $items . '</channel></rss>';
|
||
|
||
return response()->setContentType('application/rss+xml; charset=utf-8')->setBody($xml);
|
||
}
|
||
|
||
private static function markdown(string $text): string
|
||
{
|
||
$env = new Environment(['html_input' => 'strip', 'allow_unsafe_links' => false]);
|
||
$env->addExtension(new CommonMarkCoreExtension());
|
||
$env->addExtension(new AutolinkExtension());
|
||
$env->addExtension(new TableExtension());
|
||
|
||
return (new MarkdownConverter($env))->convert($text)->getContent();
|
||
}
|
||
}
|