>|null */ private static ?array $docGroupsCache = null; /** @var array|null */ private static ?array $allDocsCache = null; private static function docsDir(): ?string { $docsDir = realpath(dirname(__DIR__, 3) . '/docs'); if ($docsDir === false || !is_dir($docsDir)) { return null; } return $docsDir; } private static function humanizeGroupLabel(string $groupHeading): string { $knownGroups = [ 'Start hier (neue Entwickler)' => t('Getting started'), 'Architektur und Regeln' => t('Architecture & rules'), 'Frontend' => t('Frontend'), 'Fach- und Feature-Dokumentation' => t('Features'), 'API Referenz' => t('API reference'), 'Betriebswissen' => t('Operations'), ]; return $knownGroups[$groupHeading] ?? t($groupHeading); } private static function normalizePlainText(string $value): string { $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $normalized = preg_replace('/\s+/u', ' ', $decoded); return trim((string) $normalized); } private static function titleFromMarkdownFile(string $docPath, string $docSlug): string { $content = file_get_contents($docPath); if (!is_string($content) || trim($content) === '') { return str_replace('-', ' ', $docSlug); } if (preg_match('/^#\s+(.+)$/m', $content, $match)) { $title = self::normalizePlainText(strip_tags((string) $match[1])); if ($title !== '') { return $title; } } return str_replace('-', ' ', $docSlug); } /** * @param array> $groups * @return array> */ private static function ensureUniqueLabels(array $groups): array { $labelCounts = []; foreach ($groups as $items) { foreach ($items as $label) { $key = mb_strtolower(trim($label)); if ($key === '') { continue; } $labelCounts[$key] = ($labelCounts[$key] ?? 0) + 1; } } foreach ($groups as $groupLabel => $items) { foreach ($items as $docSlug => $label) { $key = mb_strtolower(trim($label)); if (($labelCounts[$key] ?? 0) > 1) { $groups[$groupLabel][$docSlug] = $label . ' (' . $docSlug . ')'; } } } return $groups; } /** * @return array> */ private static function parseDocGroupsFromIndex(string $indexFile, string $docsDir): array { if (!is_readable($indexFile)) { return []; } $indexContent = file_get_contents($indexFile); if (!is_string($indexContent) || trim($indexContent) === '') { return []; } $groups = []; $currentGroup = null; $lines = preg_split('/\R/', $indexContent) ?: []; foreach ($lines as $line) { $trimmedLine = trim($line); if (preg_match('/^##\s+(.+)$/', $trimmedLine, $headingMatch)) { $groupHeading = trim($headingMatch[1]); $currentGroup = $groupHeading !== '' ? self::humanizeGroupLabel($groupHeading) : ''; if ($currentGroup !== '' && !isset($groups[$currentGroup])) { $groups[$currentGroup] = []; } continue; } if ($currentGroup === null) { continue; } if (!preg_match('~^(?:\d+\.|-)\s+`?/docs/([a-z0-9-]+)\.md`?$~i', $trimmedLine, $docMatch)) { continue; } $docSlug = strtolower((string) $docMatch[1]); $docPath = $docsDir . DIRECTORY_SEPARATOR . $docSlug . '.md'; if (!is_readable($docPath)) { continue; } $groups[$currentGroup][$docSlug] = self::titleFromMarkdownFile($docPath, $docSlug); } $groups = array_filter( $groups, static fn (array $items): bool => $items !== [] ); return self::ensureUniqueLabels($groups); } /** * @return array> */ public static function docGroups(): array { if (self::$docGroupsCache !== null) { return self::$docGroupsCache; } $docsDir = self::docsDir(); if ($docsDir === null) { self::$docGroupsCache = []; return self::$docGroupsCache; } self::$docGroupsCache = self::parseDocGroupsFromIndex($docsDir . DIRECTORY_SEPARATOR . 'index.md', $docsDir); return self::$docGroupsCache; } /** * @return array */ public static function allDocs(): array { if (self::$allDocsCache !== null) { return self::$allDocsCache; } $allDocs = []; foreach (self::docGroups() as $items) { foreach ($items as $docSlug => $label) { $allDocs[$docSlug] = $label; } } self::$allDocsCache = $allDocs; return self::$allDocsCache; } public static function defaultSlug(): string { return array_key_first(self::allDocs()) ?: 'erste-schritte'; } public static function docPathBySlug(string $slug): ?string { $slug = trim(strtolower($slug)); if ($slug === '') { return null; } $allDocs = self::allDocs(); if (!array_key_exists($slug, $allDocs)) { return null; } $docsDir = self::docsDir(); if ($docsDir === null) { return null; } $path = $docsDir . DIRECTORY_SEPARATOR . $slug . '.md'; if (!is_readable($path)) { return null; } return $path; } public static function readMarkdown(string $slug): ?string { $path = self::docPathBySlug($slug); if ($path === null) { return null; } $rawMarkdown = file_get_contents($path); if (!is_string($rawMarkdown)) { return null; } return $rawMarkdown; } private static function markdownEnvironment(): Environment { $environment = new Environment([ 'heading_permalink' => [ 'html_class' => 'docs-heading-anchor', 'id_prefix' => '', 'fragment_prefix' => '', 'title' => '', 'symbol' => '', 'insert' => 'before', ], ]); $environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension(new GithubFlavoredMarkdownExtension()); $environment->addExtension(new TaskListExtension()); $environment->addExtension(new HeadingPermalinkExtension()); return $environment; } public static function renderMarkdown(string $rawMarkdown): string { $converter = new MarkdownConverter(self::markdownEnvironment()); return (string) $converter->convert($rawMarkdown); } private static function slugifyHeading(string $heading): string { $slug = strtolower((string) preg_replace('/[^a-z0-9]+/i', '-', $heading)); return trim($slug, '-'); } /** * @param array $seenAnchors */ private static function nextUniqueAnchor(string $baseAnchor, array &$seenAnchors): string { $anchor = $baseAnchor !== '' ? $baseAnchor : 'section'; if (!isset($seenAnchors[$anchor])) { $seenAnchors[$anchor] = 1; return $anchor; } $seenAnchors[$anchor]++; return $anchor . '-' . $seenAnchors[$anchor]; } /** * @param int[] $levels * @return array{html:string, headings:array} */ public static function prepareRenderedDocument(string $rawMarkdown, array $levels = [1, 2, 3]): array { $renderedHtml = self::renderMarkdown($rawMarkdown); $sourceHtml = $renderedHtml; preg_match_all('/]*)>(.*?)<\/h\1>/is', $sourceHtml, $headingBlocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); if ($headingBlocks === []) { return [ 'html' => $renderedHtml, 'headings' => [], ]; } $seenAnchors = []; $hasMissingId = false; $parsedHeadings = []; foreach ($headingBlocks as $headingBlock) { $level = (int) $headingBlock[1][0]; if (!in_array($level, [1, 2, 3], true)) { continue; } $attrs = (string) $headingBlock[2][0]; $html = (string) $headingBlock[3][0]; $text = self::normalizePlainText(strip_tags($html)); if ($text === '') { continue; } $id = ''; if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs, $idMatch)) { $id = trim((string) $idMatch[2]); } if ($id === '') { $id = self::nextUniqueAnchor(self::slugifyHeading($text), $seenAnchors); $hasMissingId = true; } else { $id = self::nextUniqueAnchor($id, $seenAnchors); } $fullHtml = (string) $headingBlock[0][0]; $start = (int) $headingBlock[0][1]; $end = $start + strlen($fullHtml); $parsedHeadings[] = [ 'level' => $level, 'attrs' => $attrs, 'html' => $html, 'text' => $text, 'anchor' => $id, 'start' => $start, 'end' => $end, ]; } if ($parsedHeadings === []) { return [ 'html' => $renderedHtml, 'headings' => [], ]; } if ($hasMissingId) { $headingIndex = 0; $renderedHtml = preg_replace_callback( '/]*)>(.*?)<\/h\1>/is', static function (array $match) use (&$headingIndex, $parsedHeadings): string { if (!isset($parsedHeadings[$headingIndex])) { return $match[0]; } $heading = $parsedHeadings[$headingIndex++]; $level = (string) $match[1]; $attrs = (string) $match[2]; $html = (string) $match[3]; if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs)) { return "{$html}"; } return "{$html}"; }, $renderedHtml ) ?? $renderedHtml; } $headings = []; $countHeadings = count($parsedHeadings); foreach ($parsedHeadings as $index => $heading) { $nextStart = $index + 1 < $countHeadings ? (int) $parsedHeadings[$index + 1]['start'] : strlen($sourceHtml); $bodyLength = max(0, $nextStart - (int) $heading['end']); $bodyHtml = substr($sourceHtml, (int) $heading['end'], $bodyLength); $bodyText = self::normalizePlainText(strip_tags($bodyHtml)); $level = (int) $heading['level']; if (!in_array($level, $levels, true)) { continue; } $headings[] = [ 'level' => $level, 'text' => (string) $heading['text'], 'anchor' => (string) $heading['anchor'], 'body' => $bodyText, ]; } return [ 'html' => $renderedHtml, 'headings' => $headings, ]; } private static function titleScore(string $needle, string $title): int { $title = mb_strtolower(trim($title)); if ($title === '' || $needle === '') { return 0; } if ($title === $needle) { return 1000; } if (str_starts_with($title, $needle)) { return 900; } if (str_contains($title, $needle)) { return 800; } return 0; } private static function sectionScore(string $needle, string $section, string $body): int { $section = mb_strtolower(trim($section)); $body = mb_strtolower(trim($body)); if ($needle === '') { return 0; } $score = 0; if ($section !== '') { if ($section === $needle) { $score = max($score, 700); } elseif (str_starts_with($section, $needle)) { $score = max($score, 650); } elseif (str_contains($section, $needle)) { $score = max($score, 600); } } if ($body !== '' && str_contains($body, $needle)) { $score = max($score, 500); } return $score; } private static function buildSnippet(string $body, string $needle): string { $body = self::normalizePlainText($body); if ($body === '') { return ''; } $maxLength = 160; $needlePos = mb_stripos($body, $needle); if ($needlePos === false) { $snippet = mb_substr($body, 0, $maxLength); if (mb_strlen($body) > $maxLength) { $snippet .= '...'; } return $snippet; } $start = max(0, $needlePos - 40); $snippet = mb_substr($body, $start, $maxLength); if ($start > 0) { $snippet = '... ' . ltrim($snippet); } if ($start + $maxLength < mb_strlen($body)) { $snippet = rtrim($snippet) . ' ...'; } return $snippet; } /** * @return array */ public static function search(string $query): array { $needle = mb_strtolower(trim($query)); if ($needle === '') { return []; } $resultsByKey = []; foreach (self::allDocs() as $slug => $title) { $rawMarkdown = self::readMarkdown($slug); if ($rawMarkdown === null) { continue; } $prepared = self::prepareRenderedDocument($rawMarkdown, [1, 2, 3]); $headings = $prepared['headings']; if ($headings === []) { $bodyText = self::normalizePlainText(strip_tags($prepared['html'])); $score = max( self::titleScore($needle, $title), self::sectionScore($needle, '', $bodyText) ); if ($score <= 0) { continue; } $key = $slug . '#'; $resultsByKey[$key] = [ 'slug' => $slug, 'title' => $title, 'section' => '', 'anchor' => '', 'snippet' => self::buildSnippet($bodyText, $needle), 'score' => $score, ]; continue; } $docScore = self::titleScore($needle, $title); if ($docScore > 0) { $firstHeading = $headings[0]; $docKey = $slug . '#' . (string) $firstHeading['anchor']; $resultsByKey[$docKey] = [ 'slug' => $slug, 'title' => $title, 'section' => '', 'anchor' => (string) $firstHeading['anchor'], 'snippet' => self::buildSnippet((string) $firstHeading['body'], $needle), 'score' => $docScore, ]; } foreach ($headings as $heading) { $sectionText = (string) $heading['text']; $anchor = (string) $heading['anchor']; $body = (string) $heading['body']; $score = self::sectionScore($needle, $sectionText, $body); if ($score <= 0) { continue; } $key = $slug . '#' . $anchor; $candidate = [ 'slug' => $slug, 'title' => $title, 'section' => $sectionText, 'anchor' => $anchor, 'snippet' => self::buildSnippet($body, $needle), 'score' => $score, ]; if (!isset($resultsByKey[$key]) || (int) $resultsByKey[$key]['score'] < $score) { $resultsByKey[$key] = $candidate; } } } $results = array_values($resultsByKey); usort($results, static function (array $a, array $b): int { $scoreCmp = ((int) $b['score']) <=> ((int) $a['score']); if ($scoreCmp !== 0) { return $scoreCmp; } $titleCmp = strcmp((string) $a['title'], (string) $b['title']); if ($titleCmp !== 0) { return $titleCmp; } return strcmp((string) $a['section'], (string) $b['section']); }); return $results; } }