*/ private array $breadcrumbs = []; /** @var list JSON-LD graph nodes */ private array $jsonLd = []; /** @var array 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[] = ''; $out[] = ''; $title = $this->title !== '' ? $this->title : $site->name . ' – ' . $site->tagline; $description = $this->description !== '' ? $this->description : $site->description; $out[] = ''; $out[] = ''; $out[] = ''; $out[] = ''; $out[] = ''; $image = $this->ogImage ?? base_url('/og/default.png'); $out[] = ''; $out[] = ''; $out[] = ''; $out[] = ''; $out[] = ''; foreach ($this->alternates as $lang => $url) { $out[] = ''; } 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 ); } }