forked from fa/breadcrumb-the-shire
580 lines
18 KiB
PHP
580 lines
18 KiB
PHP
<?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 = [
|
|
'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
|
|
{
|
|
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) {
|
|
$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-3])([^>]*)>(.*?)<\/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 "<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];
|
|
$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;
|
|
}
|
|
}
|