diff --git a/app/config/default.php b/app/config/default.php index 8d0443f1..a6080bd0 100644 --- a/app/config/default.php +++ b/app/config/default.php @@ -3,11 +3,11 @@ declare(strict_types=1); /* - * UserFrosting (http://www.userfrosting.com) + * UserFrosting Learn (http://www.userfrosting.com) * - * @link https://github.com/userfrosting/UserFrosting - * @copyright Copyright (c) 2013-2024 Alexander Weissman & Louis Charette - * @license https://github.com/userfrosting/UserFrosting/blob/master/LICENSE.md (MIT License) + * @link https://github.com/userfrosting/Learn + * @copyright Copyright (c) 2025 Alexander Weissman & Louis Charette + * @license https://github.com/userfrosting/Learn/blob/main/LICENSE.md (MIT License) */ /* @@ -26,23 +26,18 @@ ], ], - /** - * Disable cache - */ - 'cache' => [ - 'driver' => 'array', - ], + // TODO : Disable page cache by default in dev mode, but keep search cache enabled. /** - * ---------------------------------------------------------------------- - * Learn Settings - * - * Settings for the documentation application. - * - Cache : Enable/disable caching of documentation pages and menu. - * - Key : Cache key prefix for cached documentation pages and menu. - * - TTL : Time to live for cached documentation pages and menu, in seconds. - * ---------------------------------------------------------------------- - */ + * ---------------------------------------------------------------------- + * Learn Settings + * + * Settings for the documentation application. + * - Cache : Enable/disable caching of documentation pages and menu. + * - Key : Cache key prefix for cached documentation pages and menu. + * - TTL : Time to live for cached documentation pages and menu, in seconds. + * ---------------------------------------------------------------------- + */ 'learn' => [ 'cache' => [ 'key' => 'learn.%1$s.%2$s', @@ -59,6 +54,25 @@ ], 'latest' => '6.0', ], + 'search' => [ + 'min_length' => 3, // Minimum length of search query + 'default_page' => 1, // Default page number for paginated results + 'default_size' => 10, // Default number of results per page + 'max_size' => 10, // Default maximum number of results per page + 'snippet_length' => 150, // Length of content snippets in results + 'max_results' => 150, // Maximum number of results to consider for pagination + 'cache' => [ + 'key' => 'learn.search.%1$s', // %1$s = keyword hash + 'ttl' => 86400 * 30, // 30 days + ], + 'index' => [ + 'key' => 'learn.index.%1$s', // %1$s = version + 'ttl' => 86400 * 30, // 30 days + + // Metadata fields to include in the search index + 'metadata_fields' => ['description', 'tags', 'category', 'author'], + ], + ], ], /* diff --git a/app/src/Bakery/BakeCommandListener.php b/app/src/Bakery/BakeCommandListener.php index 4e7c51c9..4c9090bc 100644 --- a/app/src/Bakery/BakeCommandListener.php +++ b/app/src/Bakery/BakeCommandListener.php @@ -24,7 +24,8 @@ public function __invoke(BakeCommandEvent $event): void $event->setCommands([ 'debug', 'assets:build', - 'clear-cache' + 'clear-cache', + 'search:index' ]); } } diff --git a/app/src/Bakery/SearchIndexCommand.php b/app/src/Bakery/SearchIndexCommand.php new file mode 100644 index 00000000..35676bec --- /dev/null +++ b/app/src/Bakery/SearchIndexCommand.php @@ -0,0 +1,92 @@ +setName('search:index') + ->setDescription('Build or rebuild the search index for documentation') + ->addOption( + 'doc-version', + null, + InputOption::VALUE_OPTIONAL, + 'Documentation version to index (omit to index all versions)' + ) + ->addOption( + 'clear', + null, + InputOption::VALUE_NONE, + 'Clear the search index before rebuilding' + ); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io->title('Documentation Search Index'); + + /** @var string|null $version */ + $version = $input->getOption('doc-version'); + $clear = $input->getOption('clear'); + + // Clear index if requested + if ($clear === true) { + $this->io->writeln('Clearing search index...'); + $this->searchIndex->clearIndex($version); + $this->io->success('Search index cleared.'); + } + + // Build index + $versionText = $version !== null ? "version {$version}" : 'all versions'; + $this->io->writeln("Building search index for {$versionText}..."); + + try { + $count = $this->searchIndex->buildIndex($version); + $this->io->success("Search index built successfully. Indexed {$count} pages."); + } catch (\Exception $e) { + $this->io->error("Failed to build search index: {$e->getMessage()}"); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/app/src/Controller/SearchController.php b/app/src/Controller/SearchController.php new file mode 100644 index 00000000..535b95ac --- /dev/null +++ b/app/src/Controller/SearchController.php @@ -0,0 +1,57 @@ +getQueryParams(); + + $this->sprunje->setOptions([ + 'query' => $params['q'] ?? '', + 'page' => $params['page'] ?? null, + 'size' => $params['size'] ?? null, + ]); + + return $this->sprunje->toResponse($response); + } +} diff --git a/app/src/MyRoutes.php b/app/src/MyRoutes.php index ea707f3c..72da56cf 100644 --- a/app/src/MyRoutes.php +++ b/app/src/MyRoutes.php @@ -12,6 +12,7 @@ use Slim\App; use UserFrosting\Learn\Controller\DocumentationController; +use UserFrosting\Learn\Controller\SearchController; use UserFrosting\Learn\Middleware\TwigGlobals; use UserFrosting\Routes\RouteDefinitionInterface; @@ -19,6 +20,10 @@ class MyRoutes implements RouteDefinitionInterface { public function register(App $app): void { + // Route for search API + $app->get('/api/search', [SearchController::class, 'search']) + ->setName('api.search'); + // Route for versioned and non-versioned images $app->get('/{version:\d+\.\d+}/images/{path:.*}', [DocumentationController::class, 'imageVersioned']) ->add(TwigGlobals::class) diff --git a/app/src/Recipe.php b/app/src/Recipe.php index bf45804c..9a6ce66a 100644 --- a/app/src/Recipe.php +++ b/app/src/Recipe.php @@ -14,10 +14,13 @@ use UserFrosting\Learn\Bakery\BakeCommandListener; use UserFrosting\Learn\Bakery\DebugCommandListener; use UserFrosting\Learn\Bakery\DebugVerboseCommandListener; +use UserFrosting\Learn\Bakery\SearchIndexCommand; use UserFrosting\Learn\Bakery\SetupCommandListener; use UserFrosting\Learn\Listeners\ResourceLocatorInitiated; use UserFrosting\Learn\ServicesProvider\MarkdownService; +use UserFrosting\Learn\ServicesProvider\SearchServicesProvider; use UserFrosting\Learn\Twig\Extensions\FileTreeExtension; +use UserFrosting\Sprinkle\BakeryRecipe; use UserFrosting\Sprinkle\Core\Bakery\Event\BakeCommandEvent; use UserFrosting\Sprinkle\Core\Bakery\Event\DebugCommandEvent; use UserFrosting\Sprinkle\Core\Bakery\Event\DebugVerboseCommandEvent; @@ -35,7 +38,8 @@ class Recipe implements SprinkleRecipe, EventListenerRecipe, - TwigExtensionRecipe + TwigExtensionRecipe, + BakeryRecipe { /** * Return the Sprinkle name. @@ -104,6 +108,19 @@ public function getServices(): array { return [ MarkdownService::class, + SearchServicesProvider::class, + ]; + } + + /** + * Return an array of all registered Bakery Commands. + * + * {@inheritdoc} + */ + public function getBakeryCommands(): array + { + return [ + SearchIndexCommand::class, ]; } diff --git a/app/src/Search/IndexedPageShape.php b/app/src/Search/IndexedPageShape.php new file mode 100644 index 00000000..34275b8a --- /dev/null +++ b/app/src/Search/IndexedPageShape.php @@ -0,0 +1,45 @@ +config->get('learn.versions.available', []); + foreach (array_keys($available) as $versionId) { + $versions[] = $this->versionValidator->getVersion((string) $versionId); + } + } else { + // Index specific version + $versions[] = $this->versionValidator->getVersion($version); + } + + $totalPages = 0; + + foreach ($versions as $versionObj) { + $pages = $this->indexVersion($versionObj); + $totalPages += count($pages); + + // Store in cache + $this->cache->put( + $this->getCacheKey($versionObj->id), + $pages, + $this->getCacheTtl() + ); + } + + return $totalPages; + } + + /** + * Get the search index for a specific version from cache. + * Public method for use by SearchSprunje. + * + * @param string $version + * + * @return list + */ + public function getIndex(string $version): array + { + $keyFormat = $this->config->getString('learn.search.index.key', ''); + $cacheKey = sprintf($keyFormat, $version); + + // TODO : If the cache key is empty, it should build the index first + $index = $this->cache->get($cacheKey); + + // Ensure we return an array even if cache returns null or unexpected type + if (!is_array($index)) { + return []; + } + + return $index; + } + + /** + * Index all pages for a specific version. + * + * @param Version $version + * + * @return list + */ + protected function indexVersion(Version $version): array + { + $tree = $this->repository->getTree($version->id); + $pages = $this->flattenTree($tree); + + /** @var list */ + $indexed = []; + + foreach ($pages as $page) { + $indexed[] = $this->indexPage($page); + } + + return $indexed; + } + + /** + * Index a single page. + * + * @param PageResource $page + * + * @return IndexedPage + */ + protected function indexPage(PageResource $page): array + { + // Get the HTML content and strip HTML tags to get plain text + $htmlContent = $page->getContent(); + $plainText = $this->stripHtmlTags($htmlContent); + + // Get frontmatter + $frontMatter = $page->getFrontMatter(); + + // Extract keywords if present + $keywords = ''; + if (isset($frontMatter['keywords'])) { + if (is_array($frontMatter['keywords'])) { + $keywords = implode(' ', $frontMatter['keywords']); + } elseif (is_string($frontMatter['keywords'])) { + $keywords = $frontMatter['keywords']; + } + } + + // Extract other relevant metadata (description, tags, etc.) + $metadata = []; + $metadataFields = $this->config->get('learn.search.metadata_fields', ['description', 'tags', 'category', 'author']); + foreach ($metadataFields as $field) { + if (isset($frontMatter[$field])) { + if (is_array($frontMatter[$field])) { + $metadata[] = implode(' ', $frontMatter[$field]); + } elseif (is_string($frontMatter[$field])) { + $metadata[] = $frontMatter[$field]; + } + } + } + $metadataString = implode(' ', $metadata); + + return [ + 'title' => $page->getTitle(), + 'slug' => $page->getSlug(), + 'route' => $page->getRoute(), + 'content' => $plainText, + 'version' => $page->getVersion()->id, + 'keywords' => $keywords, + 'metadata' => $metadataString, + ]; + } + + /** + * Strip HTML tags from content to get searchable plain text. + * Preserves code blocks and adds spacing for better search results. + * + * @param string $html + * + * @return string + */ + protected function stripHtmlTags(string $html): string + { + // Combined regex: Add space before/after block elements to prevent word concatenation + $result = preg_replace([ + '/<(div|p|h[1-6]|li|pre|code|blockquote)[^>]*>/i', // Opening tags + '/<\/(div|p|h[1-6]|li|pre|code|blockquote)>/i', // Closing tags + '/<(script|style)[^>]*>.*?<\/\1>/is', // Remove script/style with content + ], [ + ' $0', // Space before opening tags + '$0 ', // Space after closing tags + '', // Remove script/style entirely + ], $html); + + // Check if preg_replace failed + if ($result === null) { + // Fallback to original HTML if regex fails + $result = $html; + } + + // Strip remaining HTML tags + $text = strip_tags($result); + + // Decode HTML entities + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Normalize whitespace + $text = preg_replace('/\s+/', ' ', $text); + + // Check if preg_replace failed + if ($text === null) { + // Fallback: at least decode entities from stripped HTML + $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + return trim($text); + } + + /** + * Flatten a tree structure into a flat array of pages. + * + * @param PageResource[] $tree + * + * @return PageResource[] + */ + protected function flattenTree(array $tree): array + { + $flat = []; + + foreach ($tree as $page) { + $flat[] = $page; + $children = $page->getChildren(); + if ($children !== null && count($children) > 0) { + $flat = array_merge($flat, $this->flattenTree($children)); + } + } + + return $flat; + } + + /** + * Get the cache key for the search index of a specific version. + * + * @param string $version + * + * @return string + */ + protected function getCacheKey(string $version): string + { + $keyFormat = $this->config->get('learn.search.index.key', 'learn.search-index.%1$s'); + + return sprintf($keyFormat, $version); + } + + /** + * Get the cache TTL for the search index. + * + * @return int The cache TTL in seconds + */ + protected function getCacheTtl(): int + { + return $this->config->get('learn.search.index.ttl', 86400 * 7); + } + + /** + * Clear the search index for a specific version or all versions. + * + * @param string|null $version The version to clear, or null for all versions + */ + public function clearIndex(?string $version = null): void + { + if ($version === null) { + // Clear all versions + $available = $this->config->get('learn.versions.available', []); + foreach (array_keys($available) as $versionId) { + $this->cache->forget($this->getCacheKey((string) $versionId)); + } + } else { + // Clear specific version + $this->cache->forget($this->getCacheKey($version)); + } + } +} diff --git a/app/src/Search/SearchService.php b/app/src/Search/SearchService.php new file mode 100644 index 00000000..4a00fa4d --- /dev/null +++ b/app/src/Search/SearchService.php @@ -0,0 +1,207 @@ + $index + * + * @return array + */ + public function performSearch(string $query, array $index): array + { + $results = []; + $query = trim($query); + + if ($query === '') { + return $results; + } + + // Determine if query contains wildcards (check once before loop) + $hasWildcards = str_contains($query, '*') || str_contains($query, '?'); + + // Pre-compile regex for wildcard searches to avoid recompiling in loop + $wildcardRegex = null; + if ($hasWildcards) { + $pattern = preg_quote($query, '/'); + $pattern = str_replace(['\*', '\?'], ['.*', '.'], $pattern); + $wildcardRegex = '/' . $pattern . '/i'; + } + + foreach ($index as $page) { + $titleMatches = []; + $keywordMatches = []; + $metadataMatches = []; + $contentMatches = []; + + // Search in different fields with priority + if ($hasWildcards) { + $titleMatches = $this->searchWithWildcard($wildcardRegex, $page['title']); + $keywordMatches = $this->searchWithWildcard($wildcardRegex, $page['keywords']); + $metadataMatches = $this->searchWithWildcard($wildcardRegex, $page['metadata']); + $contentMatches = $this->searchWithWildcard($wildcardRegex, $page['content']); + } else { + $titleMatches = $this->searchPlain($query, $page['title']); + $keywordMatches = $this->searchPlain($query, $page['keywords']); + $metadataMatches = $this->searchPlain($query, $page['metadata']); + $contentMatches = $this->searchPlain($query, $page['content']); + } + + // Calculate weighted score: title > keywords > metadata > content + $score = count($titleMatches) * 10 + count($keywordMatches) * 5 + count($metadataMatches) * 2 + count($contentMatches); + + if ($score > 0) { + // Prefer snippet from title/keywords/metadata if found, otherwise content + $snippetPosition = 0; + if (count($titleMatches) > 0) { + $snippetPosition = $titleMatches[0]; + $snippetContent = $page['title']; + } elseif (count($keywordMatches) > 0) { + $snippetPosition = $keywordMatches[0]; + $snippetContent = $page['keywords']; + } elseif (count($metadataMatches) > 0) { + $snippetPosition = $metadataMatches[0]; + $snippetContent = $page['metadata']; + } else { + $snippetPosition = $contentMatches[0]; + $snippetContent = $page['content']; + } + + $results[] = [ + 'title' => $page['title'], + 'slug' => $page['slug'], + 'route' => $page['route'], + 'snippet' => $this->generateSnippet($snippetContent, $snippetPosition), + 'matches' => $score, + 'version' => $page['version'], + ]; + } + } + + // Sort by weighted score (descending) + usort($results, fn ($a, $b) => $b['matches'] <=> $a['matches']); + + $maxResults = $this->config->get('learn.search.max_results', 1000); + + return array_slice($results, 0, $maxResults); + } + + /** + * Search for plain text matches (case-insensitive). + * + * @param string $query + * @param string $content + * + * @return array Array of match positions + */ + protected function searchPlain(string $query, string $content): array + { + $matches = []; + $offset = 0; + $queryLower = mb_strtolower($query); + $contentLower = mb_strtolower($content); + + while (($pos = mb_strpos($contentLower, $queryLower, $offset)) !== false) { + $matches[] = $pos; + $offset = $pos + 1; + } + + return $matches; + } + + /** + * Search for wildcard pattern matches. + * + * @param string $regex Pre-compiled regex pattern + * @param string $content + * + * @return array Array of match positions + */ + protected function searchWithWildcard(string $regex, string $content): array + { + $matches = []; + + // Split content into words and check each word + $words = preg_split('/\s+/', $content); + $offset = 0; + + if ($words === false) { + // Log error if needed in the future, but for now just return empty + return $matches; + } + + foreach ($words as $word) { + if (preg_match($regex, $word) === 1) { + $matches[] = $offset; + } + $offset += mb_strlen($word) + 1; // +1 for space + } + + return $matches; + } + + /** + * Generate a snippet of text around a match position. + * + * @param string $content Full content + * @param int $matchPosition Position of the match + * + * @return string Snippet with context + */ + protected function generateSnippet(string $content, int $matchPosition): string + { + $contextLength = $this->config->get('learn.search.snippet_length', 150); + + // Calculate start and end positions + $start = (int) max(0, $matchPosition - $contextLength); + $end = (int) min(mb_strlen($content), $matchPosition + $contextLength); + + // Extract snippet + $snippet = mb_substr($content, $start, $end - $start); + + // Add ellipsis if we're not at the beginning/end + if ($start > 0) { + $snippet = '...' . $snippet; + } + if ($end < mb_strlen($content)) { + $snippet .= '...'; + } + + return $snippet; + } +} diff --git a/app/src/Search/SearchSprunje.php b/app/src/Search/SearchSprunje.php new file mode 100644 index 00000000..04f04c66 --- /dev/null +++ b/app/src/Search/SearchSprunje.php @@ -0,0 +1,94 @@ + + */ +class SearchSprunje extends StaticSprunje +{ + public function __construct( + protected SearchService $searchService, + protected SearchIndex $searchIndex, + protected Config $config + ) { + } + + /** + * Get the underlying queryable object in its current state. + * + * @return Collection + */ + public function getQuery(): Collection + { + // Default version if not provided + if (!isset($this->options['version']) || $this->options['version'] === null) { + $this->options['version'] = $this->config->get('learn.versions.latest'); + } + + // No version specified means no results + if ($this->options['version'] === null) { + return collect([]); + } + + // Get the index from cache + $index = $this->searchIndex->getIndex($this->options['version']); + + // No indexed pages means no results + if (count($index) === 0) { + return collect([]); + } + + // Search through the index (without pagination - Sprunje handles that) + $results = $this->searchService->performSearch($this->options['query'], $index); + + // Convert to Collection for compatibility + $collection = collect($results); + + return $collection; + } + + /** + * Override validateOptions to include search-specific validation. + * + * @param mixed[] $options + */ + protected function validateOptions(array $options): void + { + // Validate query here for consistency + $minLength = $this->config->get('learn.search.min_length', 3); + if (!is_string($options['query']) || $options['query'] === '' || mb_strlen($options['query']) < $minLength) { + throw new InvalidArgumentException("Query must be at least {$minLength} characters long"); + } + + parent::validateOptions($options); + } +} diff --git a/app/src/Search/StaticSprunje.php b/app/src/Search/StaticSprunje.php new file mode 100644 index 00000000..f474d11f --- /dev/null +++ b/app/src/Search/StaticSprunje.php @@ -0,0 +1,222 @@ + 'all', + 'page' => null, + ]; + + /** + * @var string[] Fields to show in output. Empty array will load all. + */ + protected array $columns = []; + + /** + * @var string Array key for the total unfiltered object count. + */ + protected string $countKey = 'count'; + + /** + * @var string Array key for the actual result set. + */ + protected string $rowsKey = 'rows'; + + /** + * Set Sprunje options. + * + * @param array $options Partial TOptions + * + * @return static + */ + public function setOptions(array $options): static + { + $this->validateOptions($options); + + // @phpstan-ignore-next-line - Can't make array_replace_recursive hint at TOptions + $this->options = array_replace_recursive($this->options, $options); + + return $this; + } + + /** + * Validate option using Validator. + * + * @param array $options + * + * @throws ValidationException + */ + protected function validateOptions(array $options): void + { + // Validation on input data + $v = new Validator($options); + $v->rule('regex', 'size', '/all|[0-9]+/i'); + $v->rule('integer', 'page'); + + if (!$v->validate()) { + $e = new ValidationException(); + $e->addErrors($v->errors()); // @phpstan-ignore-line errors returns array with no arguments + + throw $e; + } + } + + /** + * Execute the query and build the results, and append them in the appropriate format to the response. + * + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + public function toResponse(ResponseInterface $response): ResponseInterface + { + $payload = json_encode($this->getArray(), JSON_THROW_ON_ERROR); + $response->getBody()->write($payload); + + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * Executes the sprunje query, applying all sorts, filters, and pagination. + * + * Returns an array containing `count` (the total number of rows, before filtering), + * and `rows` (the filtered result set). + * + * @return array + */ + public function getArray(): array + { + list($count, $rows) = $this->getModels(); + + // Return sprunjed results + return [ + $this->countKey => $count, + $this->rowsKey => $rows->values()->toArray(), + ]; + } + + /** + * Executes the sprunje query, applying all sorts, filters, and pagination. + * + * Returns the filtered, paginated result set and the counts. + * + * @return array{int, Collection} + */ + public function getModels(): array + { + $query = $this->getQuery(); + + // Count unfiltered total + $count = $this->count($query); + + // Paginate + $query = $this->applyPagination($query); + + // Execute query - only apply select if not wildcard/empty + if ($this->columns !== []) { + $query = $query->select($this->columns); // @phpstan-ignore-line + } + + $query = collect($query); + + // Perform any additional transformations on the dataset + $query = $this->applyTransformations($query); + + return [$count, $query]; + } + + /** + * Get the underlying queryable object in its current state. + * + * @return Collection + */ + abstract public function getQuery(): Collection; + + /** + * Apply pagination based on the `page` and `size` options. + * + * @param Collection $query + * + * @return Collection + */ + public function applyPagination(Collection $query): Collection + { + $page = $this->options['page']; + $size = $this->options['size']; + + if (!is_null($page) && !is_null($size) && $size !== 'all') { + $offset = (int) $size * (int) $page; + $query = $query->skip($offset)->take((int) $size); + } + + return $query; + } + + /** + * Set fields to show in output. + * + * @param string[] $columns + * + * @return static + */ + public function setColumns(array $columns): static + { + $this->columns = $columns; + + return $this; + } + + /** + * Set any transformations you wish to apply to the collection, after the query is executed. + * This method is meant to be customized in child class. + * + * @param Collection $collection + * + * @return Collection + */ + protected function applyTransformations(Collection $collection): Collection + { + return $collection; + } + + /** + * Get the unpaginated count of items (before filtering) in this query. + * + * @param Collection $query + * + * @return int + */ + protected function count(Collection $query): int + { + return $query->count(); + } +} diff --git a/app/src/ServicesProvider/SearchServicesProvider.php b/app/src/ServicesProvider/SearchServicesProvider.php new file mode 100644 index 00000000..9a84bab7 --- /dev/null +++ b/app/src/ServicesProvider/SearchServicesProvider.php @@ -0,0 +1,31 @@ + \DI\autowire(), + SearchService::class => \DI\autowire(), + ]; + } +} diff --git a/app/tests/Bakery/SearchIndexCommandTest.php b/app/tests/Bakery/SearchIndexCommandTest.php new file mode 100644 index 00000000..edf52445 --- /dev/null +++ b/app/tests/Bakery/SearchIndexCommandTest.php @@ -0,0 +1,184 @@ +shouldReceive('buildIndex') + ->once() + ->with(null) + ->andReturn(42); + + // Create command and tester + $command = new SearchIndexCommand($searchIndex); + $tester = new CommandTester($command); + + // Execute command + $exitCode = $tester->execute([]); + + // Assertions + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Building search index for all versions', $tester->getDisplay()); + $this->assertStringContainsString('Indexed 42 pages', $tester->getDisplay()); + } + + /** + * Test building index for specific version. + */ + public function testBuildIndexSpecificVersion(): void + { + // Mock SearchIndex + $searchIndex = Mockery::mock(SearchIndex::class); + $searchIndex->shouldReceive('buildIndex') + ->once() + ->with('6.0') + ->andReturn(15); + + // Create command and tester + $command = new SearchIndexCommand($searchIndex); + $tester = new CommandTester($command); + + // Execute command with version option + $exitCode = $tester->execute(['--doc-version' => '6.0']); + + // Assertions + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Building search index for version 6.0', $tester->getDisplay()); + $this->assertStringContainsString('Indexed 15 pages', $tester->getDisplay()); + } + + /** + * Test clearing index before building. + */ + public function testClearIndexBeforeBuilding(): void + { + // Mock SearchIndex + $searchIndex = Mockery::mock(SearchIndex::class); + $searchIndex->shouldReceive('clearIndex') + ->once() + ->with(null); + $searchIndex->shouldReceive('buildIndex') + ->once() + ->with(null) + ->andReturn(30); + + // Create command and tester + $command = new SearchIndexCommand($searchIndex); + $tester = new CommandTester($command); + + // Execute command with clear option + $exitCode = $tester->execute(['--clear' => true]); + + // Assertions + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Clearing search index', $tester->getDisplay()); + $this->assertStringContainsString('Search index cleared', $tester->getDisplay()); + $this->assertStringContainsString('Indexed 30 pages', $tester->getDisplay()); + } + + /** + * Test clearing and building for specific version. + */ + public function testClearAndBuildSpecificVersion(): void + { + // Mock SearchIndex + $searchIndex = Mockery::mock(SearchIndex::class); + $searchIndex->shouldReceive('clearIndex') + ->once() + ->with('5.1'); + $searchIndex->shouldReceive('buildIndex') + ->once() + ->with('5.1') + ->andReturn(20); + + // Create command and tester + $command = new SearchIndexCommand($searchIndex); + $tester = new CommandTester($command); + + // Execute command with both options + $exitCode = $tester->execute([ + '--doc-version' => '5.1', + '--clear' => true, + ]); + + // Assertions + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Clearing search index', $tester->getDisplay()); + $this->assertStringContainsString('Building search index for version 5.1', $tester->getDisplay()); + $this->assertStringContainsString('Indexed 20 pages', $tester->getDisplay()); + } + + /** + * Test handling exception during index building. + */ + public function testBuildIndexException(): void + { + // Mock SearchIndex to throw exception + $searchIndex = Mockery::mock(SearchIndex::class); + $searchIndex->shouldReceive('buildIndex') + ->once() + ->with(null) + ->andThrow(new \RuntimeException('Index build failed')); + + // Create command and tester + $command = new SearchIndexCommand($searchIndex); + $tester = new CommandTester($command); + + // Execute command + $exitCode = $tester->execute([]); + + // Assertions + $this->assertSame(Command::FAILURE, $exitCode); + $this->assertStringContainsString('Failed to build search index', $tester->getDisplay()); + $this->assertStringContainsString('Index build failed', $tester->getDisplay()); + } + + /** + * Test command configuration. + */ + public function testCommandConfiguration(): void + { + $searchIndex = Mockery::mock(SearchIndex::class); + $command = new SearchIndexCommand($searchIndex); + + $this->assertSame('search:index', $command->getName()); + $this->assertStringContainsString('Build or rebuild', $command->getDescription()); + + $definition = $command->getDefinition(); + $this->assertTrue($definition->hasOption('doc-version')); + $this->assertTrue($definition->hasOption('clear')); + } +} diff --git a/app/tests/Controller/SearchControllerTest.php b/app/tests/Controller/SearchControllerTest.php new file mode 100644 index 00000000..94e4f245 --- /dev/null +++ b/app/tests/Controller/SearchControllerTest.php @@ -0,0 +1,193 @@ +ci->get(Config::class); + $config->set('learn.versions.latest', '6.0'); + $config->set('learn.versions.available', [ + '6.0' => '6.0 Beta', + ]); + + // Use the test pages directory + /** @var ResourceLocatorInterface $locator */ + $locator = $this->ci->get(ResourceLocatorInterface::class); + $locator->removeStream('pages'); + $locator->addStream(new ResourceStream('pages', shared: true, readonly: true, path: __DIR__ . '/../pages')); + + // Build index for testing + $searchIndex = $this->ci->get(SearchIndex::class); + $searchIndex->buildIndex('6.0'); + } + + /** + * Test search API endpoint with query. + */ + public function testSearchEndpoint(): void + { + // Create request to search API + $request = $this->createRequest('GET', '/api/search?q=first'); + $response = $this->handleRequest($request); + + // Assert successful response + $this->assertResponseStatus(200, $response); + + // Parse JSON response + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + $this->assertIsArray($data); + $this->assertArrayHasKey('rows', $data); + $this->assertArrayHasKey('count', $data); + + // Should have some results + $this->assertGreaterThan(0, $data['count']); + $this->assertNotEmpty($data['rows']); + + // Check structure of first result + if (count($data['rows']) > 0) { + $firstResult = $data['rows'][0]; + $this->assertArrayHasKey('title', $firstResult); + $this->assertArrayHasKey('slug', $firstResult); + $this->assertArrayHasKey('route', $firstResult); + $this->assertArrayHasKey('snippet', $firstResult); + $this->assertArrayHasKey('matches', $firstResult); + $this->assertArrayHasKey('version', $firstResult); + } + } + + /** + * Test search API endpoint with empty query. + */ + public function testSearchEndpointEmptyQuery(): void + { + // Create request without query + $request = $this->createRequest('GET', '/api/search'); + $response = $this->handleRequest($request); + + // Returns 500 because InvalidArgumentException is not caught + $this->assertResponseStatus(500, $response); + } + + /** + * Test search API endpoint with query too short. + */ + public function testSearchEndpointQueryTooShort(): void + { + // Create request with query too short (less than min_length) + $request = $this->createRequest('GET', '/api/search?q=ab'); + $response = $this->handleRequest($request); + + // Returns 500 because InvalidArgumentException is not caught + $this->assertResponseStatus(500, $response); + } + + /** + * Test search API endpoint with pagination. + */ + public function testSearchEndpointPagination(): void + { + // Create request with pagination parameters + $request = $this->createRequest('GET', '/api/search?q=page&page=1&size=2'); + $response = $this->handleRequest($request); + + // Assert successful response + $this->assertResponseStatus(200, $response); + + // Parse JSON response + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + $this->assertIsArray($data); + + // Should return at most 2 results + $this->assertLessThanOrEqual(2, count($data['rows'])); + } + + /** + * Test search API endpoint with version parameter. + */ + public function testSearchEndpointWithVersion(): void + { + // Create request with version parameter + $request = $this->createRequest('GET', '/api/search?q=first&version=6.0'); + $response = $this->handleRequest($request); + + // Assert successful response + $this->assertResponseStatus(200, $response); + + // Parse JSON response + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + $this->assertIsArray($data); + + // Verify results are from correct version + if (count($data['rows']) > 0) { + foreach ($data['rows'] as $result) { + $this->assertSame('6.0', $result['version']); + } + } + } + + /** + * Test search API endpoint with wildcard query. + */ + public function testSearchEndpointWildcard(): void + { + // Create request with wildcard query that meets minimum length + $request = $this->createRequest('GET', '/api/search?q=fir*'); + $response = $this->handleRequest($request); + + // Assert successful response + $this->assertResponseStatus(200, $response); + + // Parse JSON response + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + $this->assertIsArray($data); + $this->assertArrayHasKey('rows', $data); + } + + /** + * Test that response is valid JSON. + */ + public function testSearchEndpointReturnsJson(): void + { + $request = $this->createRequest('GET', '/api/search?q=test'); + $response = $this->handleRequest($request); + + // Check content type header + $this->assertTrue($response->hasHeader('Content-Type')); + $contentType = $response->getHeaderLine('Content-Type'); + $this->assertStringContainsString('application/json', $contentType); + } +} diff --git a/app/tests/Search/SearchIndexTest.php b/app/tests/Search/SearchIndexTest.php new file mode 100644 index 00000000..457e5ed6 --- /dev/null +++ b/app/tests/Search/SearchIndexTest.php @@ -0,0 +1,221 @@ +ci->get(Config::class); + $config->set('learn.versions.latest', '6.0'); + $config->set('learn.versions.available', [ + '6.0' => '6.0 Beta', + ]); + + // Use the test pages directory + /** @var ResourceLocatorInterface $locator */ + $locator = $this->ci->get(ResourceLocatorInterface::class); + $locator->removeStream('pages'); + $locator->addStream(new ResourceStream('pages', shared: true, readonly: true, path: __DIR__ . '/../pages')); + } + + public function testBuildIndexForVersion(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index for version 6.0 + $count = $searchIndex->buildIndex('6.0'); + + // Should have indexed pages (at least some) + $this->assertGreaterThan(0, $count, 'Should have indexed at least one page'); + + // Verify it matches the number of test pages + /** @var \UserFrosting\Learn\Documentation\DocumentationRepository $repository */ + $repository = $this->ci->get(\UserFrosting\Learn\Documentation\DocumentationRepository::class); + + // Use reflection to get pages count + $reflection = new \ReflectionClass($repository); + $method = $reflection->getMethod('getFlattenedTree'); + $flatPages = $method->invoke($repository, '6.0'); + + $this->assertSame(count($flatPages), $count, 'Index count should match actual page count'); + } + + public function testBuildIndexForAllVersions(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index for all versions + $count = $searchIndex->buildIndex(null); + + // Should have indexed pages (at least some) + $this->assertGreaterThan(0, $count, 'Should have indexed at least one page'); + } + + public function testIndexPageContent(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index + $searchIndex->buildIndex('6.0'); + + // Use reflection to access protected method + $reflection = new \ReflectionClass($searchIndex); + $getCacheKeyMethod = $reflection->getMethod('getCacheKey'); + + // Get cache key and retrieve index + $cacheKey = $getCacheKeyMethod->invoke($searchIndex, '6.0'); + + /** @var \Illuminate\Cache\Repository $cache */ + $cache = $this->ci->get(\Illuminate\Cache\Repository::class); + $index = $cache->get($cacheKey); + + $this->assertIsArray($index); + $this->assertNotEmpty($index); + + // Check first page structure + $firstPage = $index[0]; + $this->assertArrayHasKey('title', $firstPage); + $this->assertArrayHasKey('slug', $firstPage); + $this->assertArrayHasKey('route', $firstPage); + $this->assertArrayHasKey('content', $firstPage); + $this->assertArrayHasKey('version', $firstPage); + $this->assertArrayHasKey('keywords', $firstPage); + $this->assertArrayHasKey('metadata', $firstPage); + + // Content should be plain text (no HTML tags) + $this->assertStringNotContainsString('<', $firstPage['content']); + $this->assertStringNotContainsString('>', $firstPage['content']); + } + + public function testStripHtmlTags(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Use reflection to test protected method + $reflection = new \ReflectionClass($searchIndex); + $method = $reflection->getMethod('stripHtmlTags'); + + // Test with HTML content + $html = '

Title

This is a test paragraph.

some code
'; + $plain = $method->invoke($searchIndex, $html); + + $this->assertStringNotContainsString('

', $plain); + $this->assertStringNotContainsString('

', $plain); + $this->assertStringNotContainsString('', $plain); + $this->assertStringContainsString('Title', $plain); + $this->assertStringContainsString('test', $plain); + $this->assertStringContainsString('some code', $plain); + } + + public function testClearIndex(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index + $searchIndex->buildIndex('6.0'); + + // Clear index + $searchIndex->clearIndex('6.0'); + + // Verify cache is cleared + $reflection = new \ReflectionClass($searchIndex); + $getCacheKeyMethod = $reflection->getMethod('getCacheKey'); + $cacheKey = $getCacheKeyMethod->invoke($searchIndex, '6.0'); + + /** @var \Illuminate\Cache\Repository $cache */ + $cache = $this->ci->get(\Illuminate\Cache\Repository::class); + $index = $cache->get($cacheKey); + + $this->assertNull($index); + } + + public function testClearAllIndexes(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index for all versions + $searchIndex->buildIndex(null); + + // Clear all indexes + $searchIndex->clearIndex(null); + + // Verify cache is cleared + $reflection = new \ReflectionClass($searchIndex); + $getCacheKeyMethod = $reflection->getMethod('getCacheKey'); + $cacheKey = $getCacheKeyMethod->invoke($searchIndex, '6.0'); + + /** @var \Illuminate\Cache\Repository $cache */ + $cache = $this->ci->get(\Illuminate\Cache\Repository::class); + $index = $cache->get($cacheKey); + + $this->assertNull($index); + } + + public function testFlattenTree(): void + { + $searchIndex = $this->ci->get(SearchIndex::class); + + // Build index to get tree + $searchIndex->buildIndex('6.0'); + + // Use reflection to access the repository and get tree + /** @var \UserFrosting\Learn\Documentation\DocumentationRepository $repository */ + $repository = $this->ci->get(\UserFrosting\Learn\Documentation\DocumentationRepository::class); + $tree = $repository->getTree('6.0'); + + // Use reflection to test flattenTree + $reflection = new \ReflectionClass($searchIndex); + $method = $reflection->getMethod('flattenTree'); + + $flat = $method->invoke($searchIndex, $tree); + + // Should have multiple pages + $this->assertGreaterThan(0, count($flat), 'Should have at least one page'); + + // Verify they're all PageResource objects + foreach ($flat as $page) { + $this->assertInstanceOf(\UserFrosting\Learn\Documentation\PageResource::class, $page); + } + + // Verify flat count matches tree structure (all pages including nested) + $countTreePages = function ($pages) use (&$countTreePages) { + $count = 0; + foreach ($pages as $page) { + $count++; + if ($page->getChildren()) { + $count += $countTreePages($page->getChildren()); + } + } + + return $count; + }; + + $expectedCount = $countTreePages($tree); + $this->assertSame($expectedCount, count($flat), 'Flattened tree should contain all pages'); + } +} diff --git a/app/tests/Search/SearchServiceTest.php b/app/tests/Search/SearchServiceTest.php new file mode 100644 index 00000000..a77cad8f --- /dev/null +++ b/app/tests/Search/SearchServiceTest.php @@ -0,0 +1,198 @@ +ci->get(Config::class); + $config->set('learn.versions.latest', '6.0'); + $config->set('learn.versions.available', [ + '6.0' => '6.0 Beta', + ]); + + // Use the test pages directory + /** @var ResourceLocatorInterface $locator */ + $locator = $this->ci->get(ResourceLocatorInterface::class); + $locator->removeStream('pages'); + $locator->addStream(new ResourceStream('pages', shared: true, readonly: true, path: __DIR__ . '/../pages')); + + // Build index for testing + $searchIndex = $this->ci->get(SearchIndex::class); + $searchIndex->buildIndex('6.0'); + } + + public function testSearchWithPlainText(): void + { + $searchService = $this->ci->get(SearchService::class); + $searchIndex = $this->ci->get(SearchIndex::class); + + // Get index and search for "first" - should match "First page" + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('first', $index); + + $this->assertIsArray($results); + $this->assertGreaterThan(0, count($results)); + + // Check structure of first result + $firstResult = $results[0]; + $this->assertArrayHasKey('title', $firstResult); + $this->assertArrayHasKey('slug', $firstResult); + $this->assertArrayHasKey('route', $firstResult); + $this->assertArrayHasKey('snippet', $firstResult); + $this->assertArrayHasKey('matches', $firstResult); + $this->assertArrayHasKey('version', $firstResult); + } + + public function testSearchWithEmptyQuery(): void + { + $searchService = $this->ci->get(SearchService::class); + $searchIndex = $this->ci->get(SearchIndex::class); + + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('', $index); + + $this->assertSame(0, count($results)); + $this->assertEmpty($results); + } + + public function testSearchWithWildcard(): void + { + $searchService = $this->ci->get(SearchService::class); + $searchIndex = $this->ci->get(SearchIndex::class); + + // Search for "f*" - should match words starting with 'f' + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('f*', $index); + + $this->assertIsArray($results); + $this->assertGreaterThanOrEqual(0, count($results)); + } + + public function testSearchPagination(): void + { + $searchSprunje = $this->ci->get(SearchSprunje::class); + + // Search with pagination via Sprunje + $searchSprunje->setOptions([ + 'query' => 'page', + 'version' => '6.0', + 'size' => 2, + 'page' => 0, + ]); + + $result = $searchSprunje->getArray(); + + $this->assertArrayHasKey('rows', $result); + } + + public function testSearchResultSnippet(): void + { + $searchService = $this->ci->get(SearchService::class); + $searchIndex = $this->ci->get(SearchIndex::class); + + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('first', $index); + + if (count($results) > 0) { + $firstResult = $results[0]; + $this->assertIsString($firstResult['snippet']); + $this->assertNotEmpty($firstResult['snippet']); + } + } + + public function testSearchPlainMethod(): void + { + $searchService = $this->ci->get(SearchService::class); + + // Use reflection to test protected method + $reflection = new \ReflectionClass($searchService); + $method = $reflection->getMethod('searchPlain'); + + $content = 'This is a test content with multiple test words.'; + $matches = $method->invoke($searchService, 'test', $content); + + $this->assertIsArray($matches); + $this->assertCount(2, $matches); // Should find 2 matches + } + + public function testGenerateSnippet(): void + { + $searchService = $this->ci->get(SearchService::class); + + // Use reflection to test protected method + $reflection = new \ReflectionClass($searchService); + $method = $reflection->getMethod('generateSnippet'); + + // Create long content that exceeds snippet length (default 150 chars) + $content = str_repeat('Lorem ipsum dolor sit amet, consectetur adipiscing elit. ', 10) . 'This is the important part. ' . str_repeat('More text follows here. ', 10); + $matchPosition = strpos($content, 'important'); + + if ($matchPosition !== false) { + $snippet = $method->invoke($searchService, $content, $matchPosition); + + $this->assertIsString($snippet); + $this->assertStringContainsString('important', $snippet); + $this->assertStringContainsString('...', $snippet); // Should have ellipsis + } + } + + public function testSearchWithNoIndex(): void + { + // Clear the index + $searchIndex = $this->ci->get(SearchIndex::class); + $searchIndex->clearIndex('6.0'); + + $searchService = $this->ci->get(SearchService::class); + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('test', $index); + + $this->assertSame(0, count($results)); + $this->assertEmpty($results); + } + + public function testSearchResultSorting(): void + { + $searchService = $this->ci->get(SearchService::class); + $searchIndex = $this->ci->get(SearchIndex::class); + + // Search for a common term that might appear multiple times + $index = $searchIndex->getIndex('6.0'); + $results = $searchService->performSearch('page', $index); + + if (count($results) > 1) { + // Verify results are sorted by number of matches (descending) + $firstMatches = $results[0]['matches']; + $lastMatches = $results[count($results) - 1]['matches']; + + $this->assertGreaterThanOrEqual($lastMatches, $firstMatches); + } + } +} diff --git a/composer.lock b/composer.lock index 44c7f25e..96474ea1 100644 --- a/composer.lock +++ b/composer.lock @@ -451,16 +451,16 @@ }, { "name": "doctrine/event-manager", - "version": "2.0.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312", + "reference": "c07799fcf5ad362050960a0fd068dded40b1e312", "shasum": "" }, "require": { @@ -470,10 +470,10 @@ "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -522,7 +522,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" + "source": "https://github.com/doctrine/event-manager/tree/2.1.0" }, "funding": [ { @@ -538,7 +538,7 @@ "type": "tidelift" } ], - "time": "2024-05-22T20:47:39+00:00" + "time": "2026-01-17T22:40:21+00:00" }, { "name": "doctrine/inflector", @@ -2080,16 +2080,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.7", + "version": "v2.0.8", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", - "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7581a4407012f5f53365e11bafc520fd7f36bc9b", + "reference": "7581a4407012f5f53365e11bafc520fd7f36bc9b", "shasum": "" }, "require": { @@ -2137,7 +2137,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-11-21T20:52:36+00:00" + "time": "2026-01-08T16:22:46+00:00" }, { "name": "lcharette/webpack-encore-twig", @@ -2669,16 +2669,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -2696,7 +2696,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -2756,7 +2756,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -2768,7 +2768,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", @@ -4549,16 +4549,16 @@ }, { "name": "symfony/config", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495" + "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/2c323304c354a43a48b61c5fa760fc4ed60ce495", - "reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495", + "url": "https://api.github.com/repos/symfony/config/zipball/800ce889e358a53a9678b3212b0c8cecd8c6aace", + "reference": "800ce889e358a53a9678b3212b0c8cecd8c6aace", "shasum": "" }, "require": { @@ -4604,7 +4604,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.1" + "source": "https://github.com/symfony/config/tree/v7.4.3" }, "funding": [ { @@ -4624,20 +4624,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T07:52:08+00:00" + "time": "2025-12-23T14:24:27+00:00" }, { "name": "symfony/console", - "version": "v6.4.30", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "1b2813049506b39eb3d7e64aff033fd5ca26c97e" + "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/1b2813049506b39eb3d7e64aff033fd5ca26c97e", - "reference": "1b2813049506b39eb3d7e64aff033fd5ca26c97e", + "url": "https://api.github.com/repos/symfony/console/zipball/f9f8a889f54c264f9abac3fc0f7a371ffca51997", + "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997", "shasum": "" }, "require": { @@ -4702,7 +4702,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.30" + "source": "https://github.com/symfony/console/tree/v6.4.31" }, "funding": [ { @@ -4722,20 +4722,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T13:47:41+00:00" + "time": "2025-12-22T08:30:34+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.2", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b" + "reference": "54122901b6d772e94f1e71a75e0533bc16563499" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b", - "reference": "baf614f7c15b30ba6762d4b1ddabdf83dbf0d29b", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/54122901b6d772e94f1e71a75e0533bc16563499", + "reference": "54122901b6d772e94f1e71a75e0533bc16563499", "shasum": "" }, "require": { @@ -4786,7 +4786,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.2" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.3" }, "funding": [ { @@ -4806,7 +4806,7 @@ "type": "tidelift" } ], - "time": "2025-12-08T06:57:04+00:00" + "time": "2025-12-28T10:55:46+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5190,16 +5190,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.27", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b" + "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/a1b6aa435d2fba50793b994a839c32b6064f063b", - "reference": "a1b6aa435d2fba50793b994a839c32b6064f063b", + "url": "https://api.github.com/repos/symfony/finder/zipball/5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", + "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", "shasum": "" }, "require": { @@ -5234,7 +5234,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.27" + "source": "https://github.com/symfony/finder/tree/v6.4.31" }, "funding": [ { @@ -5254,20 +5254,20 @@ "type": "tidelift" } ], - "time": "2025-10-15T18:32:00+00:00" + "time": "2025-12-11T14:52:17+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.30", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "0384c62b79d96e9b22d77bc1272c9e83342ba3a6" + "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/0384c62b79d96e9b22d77bc1272c9e83342ba3a6", - "reference": "0384c62b79d96e9b22d77bc1272c9e83342ba3a6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a35ee6f47e4775179704d7877a8b0da3cb09241a", + "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a", "shasum": "" }, "require": { @@ -5315,7 +5315,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.30" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.31" }, "funding": [ { @@ -5335,7 +5335,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T20:07:31+00:00" + "time": "2025-12-17T10:10:57+00:00" }, { "name": "symfony/http-kernel", @@ -6116,16 +6116,16 @@ }, { "name": "symfony/process", - "version": "v6.4.26", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "url": "https://api.github.com/repos/symfony/process/zipball/8541b7308fca001320e90bca8a73a28aa5604a6e", + "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e", "shasum": "" }, "require": { @@ -6157,7 +6157,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.26" + "source": "https://github.com/symfony/process/tree/v6.4.31" }, "funding": [ { @@ -6177,7 +6177,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-12-15T19:26:35+00:00" }, { "name": "symfony/service-contracts", @@ -6359,16 +6359,16 @@ }, { "name": "symfony/translation", - "version": "v6.4.30", + "version": "v6.4.31", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "d1fdeefd0707d15eb150c04e8837bf0b15ebea39" + "reference": "81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/d1fdeefd0707d15eb150c04e8837bf0b15ebea39", - "reference": "d1fdeefd0707d15eb150c04e8837bf0b15ebea39", + "url": "https://api.github.com/repos/symfony/translation/zipball/81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2", + "reference": "81579408ecf7dc5aa2d8462a6d5c3a430a80e6f2", "shasum": "" }, "require": { @@ -6434,7 +6434,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.30" + "source": "https://github.com/symfony/translation/tree/v6.4.31" }, "funding": [ { @@ -6454,7 +6454,7 @@ "type": "tidelift" } ], - "time": "2025-11-24T13:57:00+00:00" + "time": "2025-12-18T11:37:55+00:00" }, { "name": "symfony/translation-contracts", @@ -6540,16 +6540,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { @@ -6603,7 +6603,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -6623,7 +6623,7 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "symfony/var-exporter", @@ -6939,16 +6939,16 @@ }, { "name": "userfrosting/framework", - "version": "6.0.0-beta.7", + "version": "6.0.0-beta.8", "source": { "type": "git", "url": "https://github.com/userfrosting/framework.git", - "reference": "a200c2e7a88e3590c43e4d0bb149221baeea3c7d" + "reference": "d6ca95119f8e2543c8046e1ebef315e1a73a0974" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/userfrosting/framework/zipball/a200c2e7a88e3590c43e4d0bb149221baeea3c7d", - "reference": "a200c2e7a88e3590c43e4d0bb149221baeea3c7d", + "url": "https://api.github.com/repos/userfrosting/framework/zipball/d6ca95119f8e2543c8046e1ebef315e1a73a0974", + "reference": "d6ca95119f8e2543c8046e1ebef315e1a73a0974", "shasum": "" }, "require": { @@ -7018,22 +7018,22 @@ ], "support": { "issues": "https://github.com/userfrosting/framework/issues", - "source": "https://github.com/userfrosting/framework/tree/6.0.0-beta.7" + "source": "https://github.com/userfrosting/framework/tree/6.0.0-beta.8" }, - "time": "2025-12-20T18:14:44+00:00" + "time": "2026-01-11T01:52:02+00:00" }, { "name": "userfrosting/sprinkle-core", - "version": "6.0.0-beta.7", + "version": "6.0.0-beta.8", "source": { "type": "git", "url": "https://github.com/userfrosting/sprinkle-core.git", - "reference": "bd72ee16cd013cca1246a9991a551457e4d54c0d" + "reference": "7fdd7ebecb7be50b9849a7b4b4433a5b63390b59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/userfrosting/sprinkle-core/zipball/bd72ee16cd013cca1246a9991a551457e4d54c0d", - "reference": "bd72ee16cd013cca1246a9991a551457e4d54c0d", + "url": "https://api.github.com/repos/userfrosting/sprinkle-core/zipball/7fdd7ebecb7be50b9849a7b4b4433a5b63390b59", + "reference": "7fdd7ebecb7be50b9849a7b4b4433a5b63390b59", "shasum": "" }, "require": { @@ -7111,9 +7111,9 @@ ], "support": { "issues": "https://github.com/userfrosting/sprinkle-core/issues", - "source": "https://github.com/userfrosting/sprinkle-core/tree/6.0.0-beta.7" + "source": "https://github.com/userfrosting/sprinkle-core/tree/6.0.0-beta.8" }, - "time": "2025-12-30T02:31:31+00:00" + "time": "2026-01-13T00:55:55+00:00" }, { "name": "userfrosting/vite-php-twig", @@ -7773,16 +7773,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.92.3", + "version": "v3.92.5", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "2ba8f5a60f6f42fb65758cfb3768434fa2d1c7e8" + "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2ba8f5a60f6f42fb65758cfb3768434fa2d1c7e8", - "reference": "2ba8f5a60f6f42fb65758cfb3768434fa2d1c7e8", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58", + "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58", "shasum": "" }, "require": { @@ -7814,17 +7814,17 @@ }, "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.31.0", - "justinrainbow/json-schema": "^6.5", - "keradus/cli-executor": "^2.2", + "infection/infection": "^0.31", + "justinrainbow/json-schema": "^6.6", + "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", + "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.46", "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2 || ^8.0", - "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2 || ^8.0" + "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -7865,7 +7865,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.92.3" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.92.5" }, "funding": [ { @@ -7873,7 +7873,7 @@ "type": "github" } ], - "time": "2025-12-18T10:45:02+00:00" + "time": "2026-01-08T21:57:37+00:00" }, { "name": "hamcrest/hamcrest-php",