Files
breadcrumb-the-shire/lib/Service/Docs/DocsCatalogService.php

580 lines
18 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Service\Docs;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class DocsCatalogService
{
/** @var array<string, array<string, string>>|null */
private static ?array $docGroupsCache = null;
/** @var array<string, string>|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 = [
2026-03-04 15:56:58 +01:00
'Lernpfad (chronologisch)' => t('Learning path'),
'Konzepte (Explanation)' => t('Concepts'),
'Aufgabenanleitungen (How-to)' => t('How-to guides'),
'Referenz (Reference)' => t('Reference'),
'Betrieb (Operations)' => t('Operations'),
'Mitwirken (Contributor Guide)' => t('Contributor guide'),
'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<string, array<string, string>> $groups
* @return array<string, array<string, string>>
*/
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<string, array<string, string>>
*/
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<string, array<string, string>>
*/
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<string, string>
*/
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
{
2026-03-04 15:56:58 +01:00
return array_key_first(self::allDocs()) ?: '00-systemueberblick';
}
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<string, int> $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<int, array{level:int,text:string,anchor:string,body:string}>}
*/
public static function prepareRenderedDocument(string $rawMarkdown, array $levels = [1, 2, 3]): array
{
$renderedHtml = self::renderMarkdown($rawMarkdown);
$sourceHtml = $renderedHtml;
preg_match_all('/<h([1-3])([^>]*)>(.*?)<\/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) {
2026-02-23 12:58:19 +01:00
$level = (int) $headingBlock[1][0];
if (!in_array($level, [1, 2, 3], true)) {
continue;
}
2026-02-23 12:58:19 +01:00
$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)) {
2026-02-23 12:58:19 +01:00
$id = trim((string) $idMatch[2]);
}
if ($id === '') {
$id = self::nextUniqueAnchor(self::slugifyHeading($text), $seenAnchors);
$hasMissingId = true;
} else {
$id = self::nextUniqueAnchor($id, $seenAnchors);
}
2026-02-23 12:58:19 +01:00
$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-3])([^>]*)>(.*?)<\/h\1>/is',
static function (array $match) use (&$headingIndex, $parsedHeadings): string {
if (!isset($parsedHeadings[$headingIndex])) {
return $match[0];
}
$heading = $parsedHeadings[$headingIndex++];
2026-02-23 12:58:19 +01:00
$level = (string) $match[1];
$attrs = (string) $match[2];
$html = (string) $match[3];
if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs)) {
return "<h{$level}{$attrs}>{$html}</h{$level}>";
}
return "<h{$level}{$attrs} id=\"{$heading['anchor']}\">{$html}</h{$level}>";
},
$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<int, array{slug:string,title:string,section:string,anchor:string,snippet:string,score:int}>
*/
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];
2026-02-23 12:58:19 +01:00
$docKey = $slug . '#' . (string) $firstHeading['anchor'];
$resultsByKey[$docKey] = [
'slug' => $slug,
'title' => $title,
'section' => '',
2026-02-23 12:58:19 +01:00
'anchor' => (string) $firstHeading['anchor'],
'snippet' => self::buildSnippet((string) $firstHeading['body'], $needle),
'score' => $docScore,
];
}
foreach ($headings as $heading) {
2026-02-23 12:58:19 +01:00
$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;
}
}