setContentType('text/plain; charset=utf-8')->setBody($body); } /** Sitemap index pointing at split children. */ public function sitemapIndex(): \CodeIgniter\HTTP\Response { $base = rtrim(base_url(), '/'); $tools = service('finder')->countTools(); $parts = max(1, (int) ceil($tools / self::SITEMAP_URLS_PER_FILE)); $entries = []; for ($i = 1; $i <= $parts; ++$i) { $entries[] = ['loc' => "{$base}/sitemap-tools-{$i}.xml"]; } foreach (['categories', 'guides', 'pages'] as $kind) { $entries[] = ['loc' => "{$base}/sitemap-{$kind}.xml"]; } $xml = '' . '' . implode('', array_map(static fn (array $e): string => '' . htmlspecialchars($e['loc'], ENT_XML1) . '', $entries)) . ''; return response()->setContentType('application/xml; charset=utf-8')->setBody($xml); } public function sitemapTools(int $page = 1): \CodeIgniter\HTTP\Response { $tools = service('finder')->all(); usort($tools, static fn ($a, $b) => strcmp($a['slug'], $b['slug'])); $chunks = array_chunk($tools, self::SITEMAP_URLS_PER_FILE) ?: [[]]; $slice = $chunks[$page - 1] ?? []; $urls = ''; foreach ($slice as $tool) { $priority = (int) $tool['popularity'] > 4000 ? '1.0' : '0.7'; $urls .= $this->urlEntry('/' . $tool['slug'], $tool['seo_title'] ?? $tool['name'], 'weekly', $priority); } return $this->xmlResponse($urls); } public function sitemapCategories(): \CodeIgniter\HTTP\Response { $urls = ''; foreach (service('finder')->categories() as $category) { $urls .= $this->urlEntry('/' . $category['slug'], $category['name'] ?? '', 'weekly', '0.9'); } return $this->xmlResponse($urls); } public function sitemapGuides(): \CodeIgniter\HTTP\Response { $urls = ''; foreach (model(\App\Models\GuideModel::class)->published(1000) as $guide) { $urls .= $this->urlEntry('/blog/' . $guide['slug'], $guide['title'] ?? '', 'monthly', '0.6', $guide['updated_at'] ?? null); } return $this->xmlResponse($urls); } public function sitemapPages(): \CodeIgniter\HTTP\Response { $urls = $this->urlEntry('/', config('Site')->name . ' – Free Online Media Tools', 'daily', '1.0') . $this->urlEntry('/tools', 'All Tools', 'daily', '0.8') . $this->urlEntry('/tools/popular', 'Popular Tools', 'daily', '0.7') . $this->urlEntry('/blog', 'Guides', 'daily', '0.6') . $this->urlEntry('/privacy', 'Privacy Policy', 'yearly', '0.2') . $this->urlEntry('/terms', 'Terms of Service', 'yearly', '0.2') . $this->urlEntry('/cookies', 'Cookie Policy', 'yearly', '0.2') . $this->urlEntry('/dmca', 'DMCA Policy', 'yearly', '0.2') . $this->urlEntry('/contact', 'Contact', 'yearly', '0.2'); return $this->xmlResponse($urls); } private function urlEntry(string $path, string $title = '', string $freq = 'weekly', string $priority = '0.7', ?string $lastmod = null): string { $loc = htmlspecialchars(rtrim(base_url(), '/') . $path, ENT_XML1); return "{$loc}" . ($lastmod !== null && $lastmod !== '' ? '' . date('Y-m-d', strtotime((string) $lastmod)) . '' : '') . "{$freq}{$priority}"; } private function xmlResponse(string $urls): \CodeIgniter\HTTP\Response { return response()->setContentType('application/xml; charset=utf-8')->setBody( '' . '' . $urls . '' ); } // ----------------------------------------------------------------- // Social cards: deterministic brand images cached to disk. // ----------------------------------------------------------------- public function ogImage(string $slug): \CodeIgniter\HTTP\Response { $cacheDir = WRITEPATH . 'og'; is_dir($cacheDir) || mkdir($cacheDir, 0755, true); $safe = preg_replace('/[^a-z0-9-]/', '', $slug) ?: 'default'; $file = $cacheDir . '/' . $safe . '.png'; if (! is_file($file)) { $this->renderOgCard($file, $this->cardData($safe)); } return response()->download($file, null)->setContentType('image/png'); } private function cardData(string $slug): array { if ($slug === 'default') { return [config('Site')->name, config('Site')->tagline, '']; } $tool = service('finder')->tool($slug); if ($tool !== null) { return [$tool['name'], (string) $tool['short_description'], strtoupper((string) ($tool['primary_output_format'] ?? ''))]; } foreach (service('finder')->categories() as $c) { if ($c['slug'] === $slug) { return [$c['name'], (string) ($c['tagline'] ?? ''), count(service('finder')->toolsInCategory($slug)) . ' tools']; } } if (str_starts_with($slug, 'blog-')) { $guide = model(\App\Models\GuideModel::class)->findBySlug(substr($slug, 5)); return $guide !== null ? [$guide['title'], (string) $guide['excerpt'], 'GUIDE'] : [config('Site')->name, config('Site')->tagline, '']; } return [config('Site')->name, config('Site')->tagline, '']; } /** Draws a branded 1200x630 card with GD. */ private function renderOgCard(string $target, array $lines): void { [$title, $subtitle, $badge] = $lines; $w = 1200; $h = 630; $img = imagecreatetruecolor($w, $h); // background gradient (indigo -> violet) $top = imagecolorallocate($img, 49, 46, 129); $bot = imagecolorallocate($img, 109, 40, 217); for ($y = 0; $y < $h; ++$y) { $t = $y / $h; $color = imagecolorallocate( $img, (int) round(49 + (109 - 49) * $t), (int) round(46 + (40 - 46) * $t), (int) round(129 + (217 - 129) * $t) ); imageline($img, 0, $y, $w, $y, $color); } $white = imagecolorallocate($img, 255, 255, 255); $faint = imagecolorallocate($img, 224, 224, 255); // badge chip if ($badge !== '') { imagefilledrectangle($img, 60, 70, 60 + min(360, 40 + strlen($badge) * 16), 120, imagecolorallocate($img, 255, 255, 255)); imagestring($img, 5, 80, 84, substr($badge, 0, 24), imagecolorallocate($img, 76, 29, 149)); } // title wrapped $y = $badge !== '' ? 190 : 150; foreach (str_split(mb_substr($title, 0, 90), 30) as $line) { imagestring($img, 5, 62, $y, mb_substr($line, 0, 28), $white); $y += 42; } $y += 10; // subtitle wrapped foreach (str_split(mb_substr($subtitle, 0, 160), 52) as $i => $line) { if ($i > 2) { break; } imagestring($img, 3, 64, $y, mb_substr($line, 0, 50), $faint); $y += 26; } // brand footer imagestring($img, 4, 62, $h - 70, config('Site')->name . ' — ' . config('Site')->tagline, $white); imagepng($img, $target, 6); imagedestroy($img); } public function manifest(): \CodeIgniter\HTTP\Response { $site = config('Site'); $manifest = [ 'name' => $site->name, 'short_name' => $site->name, 'description' => $site->description, 'start_url' => '/', 'display' => 'standalone', 'background_color' => '#312e81', 'theme_color' => '#4c1d95', 'icons' => [ ['src' => '/favicon.svg', 'sizes' => 'any', 'type' => 'image/svg+xml'], ['src' => '/og/default.png', 'sizes' => '512x512', 'type' => 'image/png'], ], ]; return response()->setContentType('application/manifest+json')->setBody(json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } /** * Offline-capable shell: caches the tool directory so users can find * tools offline. Never pretends server-side processing works offline. */ public function serviceWorker(): \CodeIgniter\HTTP\Response { $js = <<<'JS' const CACHE = 'toolvana-v1'; const SHELL = ['/tools', '/offline.html']; self.addEventListener('install', (event) => { event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); }); self.addEventListener('activate', (event) => { event.waitUntil(clients.claim()); }); self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (event.request.method !== 'GET' || url.pathname.startsWith('/api') || url.pathname.startsWith('/admin')) return; // static assets: cache-first if (url.pathname.startsWith('/assets/') || url.pathname === '/favicon.svg') { event.respondWith(caches.open(CACHE).then(async (cache) => { const hit = await cache.match(event.request); const fresh = fetch(event.request).then((res) => { cache.put(event.request, res.clone()); return res; }).catch(() => hit); return hit || fresh; })); return; } // pages: network-first with offline fallback event.respondWith(fetch(event.request).catch(() => caches.open(CACHE).then((cache) => cache.match(url.pathname).then((hit) => hit || cache.match('/offline.html'))) )); }); JS; return response()->setContentType('application/javascript')->setBody($js); } public function openSearch(): \CodeIgniter\HTTP\Response { $site = config('Site'); $xml = '' . '' . htmlspecialchars($site->name, ENT_XML1) . '' . '' . htmlspecialchars('Search ' . $site->name . ' tools', ENT_XML1) . '' . 'UTF-8' . '' . ''; return response()->setContentType('application/opensearchdescription+xml')->setBody($xml); } }