diff --git a/app/assets/SearchComponent.vue b/app/assets/SearchComponent.vue new file mode 100644 index 00000000..1b9c24cd --- /dev/null +++ b/app/assets/SearchComponent.vue @@ -0,0 +1,144 @@ + + + diff --git a/app/assets/search.ts b/app/assets/search.ts new file mode 100644 index 00000000..bea09225 --- /dev/null +++ b/app/assets/search.ts @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import SearchComponent from './SearchComponent.vue' +createApp(SearchComponent).mount('#search-box') +createApp(SearchComponent).mount('#search-box-mobile') diff --git a/app/config/default.php b/app/config/default.php index 8d0443f1..9132897b 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,23 @@ ], 'latest' => '6.0', ], + 'search' => [ + 'min_length' => 3, // Minimum length of search query + 'default_size' => 25, // Default 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..e47a979b --- /dev/null +++ b/app/src/Controller/SearchController.php @@ -0,0 +1,58 @@ +getQueryParams(); + + $this->sprunje + ->setQuery($params['q'] ?? '') + ->setVersion($params['version'] ?? null) + ->setPage((int) ($params['page'] ?? 1)); + + // Only set size if explicitly provided + if (isset($params['size'])) { + $this->sprunje->setSize((int) $params['size']); + } + + return $this->sprunje->toResponse($response); + } +} diff --git a/app/src/Documentation/DocumentationRepository.php b/app/src/Documentation/DocumentationRepository.php index 70eb226b..6c5f8d31 100644 --- a/app/src/Documentation/DocumentationRepository.php +++ b/app/src/Documentation/DocumentationRepository.php @@ -283,7 +283,7 @@ protected function getAdjacentPage(PageResource $page, int $offset): ?PageResour * * @return array Array keyed by page slug */ - protected function getFlattenedTree(?string $version = null): array + public function getFlattenedTree(?string $version = null): array { $tree = $this->getTree($version); $flat = []; 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/IndexedPage.php b/app/src/Search/IndexedPage.php new file mode 100644 index 00000000..2057db8b --- /dev/null +++ b/app/src/Search/IndexedPage.php @@ -0,0 +1,30 @@ +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); + + $index = $this->cache->get($cacheKey); + + // If cache is empty, try to build the index first + if (!is_array($index)) { + $this->buildIndex($version); + $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 + { + $pages = $this->repository->getFlattenedTree($version->id); + + /** @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): IndexedPage + { + $frontMatter = $page->getFrontMatter(); + + return new IndexedPage( + title: $page->getTitle(), + slug: $page->getSlug(), + route: $page->getRoute(), + content: $this->stripHtmlTags($page->getContent()), + version: $page->getVersion()->id, + keywords: $this->extractFieldAsString($frontMatter, 'keywords'), + metadata: $this->extractMetadata($frontMatter), + ); + } + + /** + * Extract a frontmatter field as string. + * + * @param array $frontMatter + * @param string $field + * + * @return string + */ + protected function extractFieldAsString(array $frontMatter, string $field): string + { + if (!isset($frontMatter[$field])) { + return ''; + } + + $value = $frontMatter[$field]; + + return is_array($value) ? implode(' ', $value) : (string) $value; + } + + /** + * Extract metadata fields as concatenated string. + * + * @param array $frontMatter + * + * @return string + */ + protected function extractMetadata(array $frontMatter): string + { + $fields = $this->config->get('learn.search.metadata_fields', []); + $values = []; + + foreach ($fields as $field) { + $value = $this->extractFieldAsString($frontMatter, $field); + if ($value !== '') { + $values[] = $value; + } + } + + return implode(' ', $values); + } + + /** + * 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 + { + // Remove script/style tags with content + $html = preg_replace('/<(script|style)[^>]*>.*?<\/\1>/is', '', $html) ?? $html; + + // Add spaces around block elements to prevent word concatenation + $html = preg_replace('/<(div|p|h[1-6]|li|pre|code|blockquote)[^>]*>/i', ' ', $html) ?? $html; + + // Strip all remaining HTML tags and decode entities + $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Normalize whitespace + return trim(preg_replace('/\s+/', ' ', $text) ?? $text); + } + + /** + * 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/SearchResult.php b/app/src/Search/SearchResult.php new file mode 100644 index 00000000..0de57449 --- /dev/null +++ b/app/src/Search/SearchResult.php @@ -0,0 +1,46 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'title' => $this->title, + 'slug' => $this->slug, + 'route' => $this->route, + 'snippet' => $this->snippet, + 'score' => $this->score, + 'version' => $this->version, + ]; + } +} diff --git a/app/src/Search/SearchService.php b/app/src/Search/SearchService.php new file mode 100644 index 00000000..4bc634a8 --- /dev/null +++ b/app/src/Search/SearchService.php @@ -0,0 +1,305 @@ + $index + * + * @return array + */ + public function performSearch(string $query, array $index): array + { + $query = trim($query); + + // Validate query length + $minLength = $this->config->getInt('learn.search.min_length', 3); + if ($query === '' || mb_strlen($query) < $minLength) { + throw new InvalidArgumentException("Query must be at least {$minLength} characters long"); + } + + $hasWildcards = str_contains($query, '*') || str_contains($query, '?'); + $wildcardRegex = $hasWildcards ? $this->buildWildcardRegex($query) : null; + + $results = []; + foreach ($index as $page) { + $matches = $this->searchInPage($page, $query, $wildcardRegex); + $score = $this->calculateScore($matches); + + if ($score > 0) { + $results[] = $this->createSearchResult($page, $matches, $score, $query); + } + } + + // Sort by weighted score (descending) + usort($results, fn ($a, $b) => $b->score <=> $a->score); + + $maxResults = $this->config->get('learn.search.max_results', 1000); + + return array_slice($results, 0, $maxResults); + } + + /** + * Build wildcard regex pattern from query. + * + * @param string $query + * + * @return string + */ + protected function buildWildcardRegex(string $query): string + { + $pattern = preg_quote($query, '/'); + $pattern = str_replace(['\*', '\?'], ['.*', '.'], $pattern); + + return '/' . $pattern . '/i'; + } + + /** + * Search in all page fields. + * + * @param IndexedPage $page + * @param string $query + * @param string|null $wildcardRegex + * + * @return array> + */ + protected function searchInPage(IndexedPage $page, string $query, ?string $wildcardRegex): array + { + if ($wildcardRegex !== null) { + return [ + 'title' => $this->searchWithWildcard($wildcardRegex, $page->title), + 'keywords' => $this->searchWithWildcard($wildcardRegex, $page->keywords), + 'metadata' => $this->searchWithWildcard($wildcardRegex, $page->metadata), + 'content' => $this->searchWithWildcard($wildcardRegex, $page->content), + ]; + } + + return [ + 'title' => $this->searchPlain($query, $page->title), + 'keywords' => $this->searchPlain($query, $page->keywords), + 'metadata' => $this->searchPlain($query, $page->metadata), + 'content' => $this->searchPlain($query, $page->content), + ]; + } + + /** + * Calculate weighted score from matches. + * + * @param array> $matches + * + * @return int + */ + protected function calculateScore(array $matches): int + { + return count($matches['title']) * self::SCORE_TITLE + + count($matches['keywords']) * self::SCORE_KEYWORDS + + count($matches['metadata']) * self::SCORE_METADATA + + count($matches['content']) * self::SCORE_CONTENT; + } + + /** + * Create search result with snippet. + * + * @param IndexedPage $page + * @param array> $matches + * @param int $score + * @param string $query + * + * @return SearchResult + */ + protected function createSearchResult(IndexedPage $page, array $matches, int $score, string $query): SearchResult + { + // Determine best snippet source by priority + $snippetData = $this->selectSnippetSource($page, $matches); + + return new SearchResult( + title: $page->title, + slug: $page->slug, + route: $page->route, + snippet: $this->generateSnippet($snippetData['content'], $snippetData['position'], $query), + score: $score, + version: $page->version, + ); + } + + /** + * Select the best snippet source from matches. + * + * @param IndexedPage $page + * @param array> $matches + * + * @return array{content: string, position: int} + */ + protected function selectSnippetSource(IndexedPage $page, array $matches): array + { + $priority = [ + 'title' => $page->title, + 'keywords' => $page->keywords, + 'metadata' => $page->metadata, + 'content' => $page->content, + ]; + + foreach ($priority as $field => $content) { + if (isset($matches[$field]) && count($matches[$field]) > 0) { + return ['content' => $content, 'position' => $matches[$field][0]]; + } + } + + return ['content' => '', 'position' => 0]; + } + + /** + * 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 (default to empty array if preg_split fails) + // @phpstan-ignore-next-line : preg_split can return false, but only if an error occurs, which we can ignore here. + $words = preg_split('/\s+/', $content) ?: []; + $offset = 0; + + 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 + * @param string $query Search query for highlighting + * + * @return string Snippet with context and highlighted matches + */ + protected function generateSnippet(string $content, int $matchPosition, string $query): 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); + + // Highlight matches in the snippet + $snippet = $this->highlightMatches($snippet, $query); + + // Add ellipsis if we're not at the beginning/end + if ($start > 0) { + $snippet = '...' . $snippet; + } + if ($end < mb_strlen($content)) { + $snippet .= '...'; + } + + return $snippet; + } + + /** + * Highlight query matches in text with strong tags. + * + * @param string $text Text to highlight + * @param string $query Search query + * + * @return string Text with highlighted matches + */ + protected function highlightMatches(string $text, string $query): string + { + // Check if query contains wildcards + $hasWildcards = str_contains($query, '*') || str_contains($query, '?'); + + if ($hasWildcards) { + // Build regex for wildcard matching + $regex = $this->buildWildcardRegex($query); + // Replace wildcards in display (remove delimiters and flags) + $pattern = substr($regex, 1, -2); // Remove / and /i + + return preg_replace('/(' . $pattern . ')/iu', '$1', $text) ?? $text; + } + + // Simple case-insensitive replacement for plain text + return preg_replace('/(' . preg_quote($query, '/') . ')/iu', '$1', $text) ?? $text; + } +} diff --git a/app/src/Search/SearchSprunje.php b/app/src/Search/SearchSprunje.php new file mode 100644 index 00000000..5eefe26a --- /dev/null +++ b/app/src/Search/SearchSprunje.php @@ -0,0 +1,114 @@ + + */ +class SearchSprunje extends StaticSprunje +{ + /** @var string|null Documentation version to search */ + protected ?string $version = null; + + /** @var string Search query */ + protected string $query = ''; + + public function __construct( + protected SearchService $searchService, + protected SearchIndex $searchIndex, + protected Config $config + ) { + // Set default size and version from config + $this->size = $this->config->getInt('learn.search.default_size', 10); + $this->version = $this->config->getString('learn.versions.latest'); + } + + /** + * Set the search query. + * + * @param string $query Search query + * + * @throws InvalidArgumentException + * + * @return static + */ + public function setQuery(string $query): static + { + $this->query = $query; + + return $this; + } + + /** + * Set the documentation version. + * + * @param string|null $version Documentation version + * + * @return static + */ + public function setVersion(?string $version): static + { + $this->version = $version ?? $this->config->get('learn.versions.latest'); + + return $this; + } + + /** + * Get the documentation version. + * + * @return string|null + */ + public function getVersion(): ?string + { + return $this->version; + } + + /** + * Get the base collection of items to process. + * + * @return Collection + */ + public function getItems(): Collection + { + // No version specified means no results + if ($this->version === null) { + return collect([]); + } + + // Get the index from cache + $index = $this->searchIndex->getIndex($this->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->query, $index); + + // Convert to Collection for compatibility + $collection = collect($results); + + return $collection; + } +} diff --git a/app/src/Search/StaticSprunje.php b/app/src/Search/StaticSprunje.php new file mode 100644 index 00000000..04f55ee9 --- /dev/null +++ b/app/src/Search/StaticSprunje.php @@ -0,0 +1,230 @@ +addErrors(['size' => ['Size must be null or at least 1']]); + + throw $e; + } + + $this->size = $size; + + return $this; + } + + /** + * Get the page size. + * + * @return int|null + */ + public function getSize(): ?int + { + return $this->size; + } + + /** + * Set the page number (1-based). + * + * @param int $page Page number + * + * @throws ValidationException + * + * @return static + */ + public function setPage(int $page): static + { + if ($page < 1) { + $e = new ValidationException(); + $e->addErrors(['page' => ['Page must be at least 1']]); + + throw $e; + } + + $this->page = $page; + + return $this; + } + + /** + * Get the page number. + * + * @return int + */ + public function getPage(): int + { + return $this->page; + } + + /** + * 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->getResultSet(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $response->getBody()->write($payload); + + return $response->withHeader('Content-Type', 'application/json'); + } + + /** + * Execute processing to get the complete result set with metadata. + * + * Returns an array containing `count` (total items), `size` (page size), + * `page` (current page), and `rows` (the filtered result set). + * + * @return array + */ + public function getResultSet(): array + { + $collection = $this->getItems(); + + // Count unfiltered total + $count = $this->count($collection); + + // Paginate + $collection = $this->applyPagination($collection); + + // Execute query - only apply select if not wildcard/empty + if ($this->columns !== []) { + $collection = $collection->select($this->columns); // @phpstan-ignore-line + } + + $collection = collect($collection); + + // Perform any additional transformations on the dataset + $collection = $this->applyTransformations($collection); + + // Return complete result set with metadata + return [ + $this->countKey => $count, + $this->sizeKey => $this->size ?? 0, + $this->pageKey => $this->page, + $this->rowsKey => $collection->values()->toArray(), + ]; + } + + /** + * Get the base collection of items to process. + * + * @return Collection + */ + abstract public function getItems(): Collection; + + /** + * Apply pagination based on the `page` and `size` options. + * + * @param Collection $collection + * + * @return Collection + */ + public function applyPagination(Collection $collection): Collection + { + if ($this->size !== null) { + // Page is 1-based, so subtract 1 for offset calculation + $offset = $this->size * ($this->page - 1); + $collection = $collection->skip($offset)->take($this->size); + } + + return $collection; + } + + /** + * 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 in the collection. + * + * @param Collection $collection + * + * @return int + */ + protected function count(Collection $collection): int + { + return $collection->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/templates/content/navigation/sidebar.html.twig b/app/templates/content/navigation/sidebar.html.twig index 080d5a29..64faf3ee 100644 --- a/app/templates/content/navigation/sidebar.html.twig +++ b/app/templates/content/navigation/sidebar.html.twig @@ -1,4 +1,3 @@ {% import "macros/sidebar.html.twig" as sidebar %} -
Documentation
{{ sidebar.tree(menu, page) }} diff --git a/app/templates/content/scripts_site.html.twig b/app/templates/content/scripts_site.html.twig index 3ddff7ca..409fb6a6 100644 --- a/app/templates/content/scripts_site.html.twig +++ b/app/templates/content/scripts_site.html.twig @@ -1,6 +1,6 @@ {# This file should contain the list of javascript script to include site wide. It will be injected into the base template #} {# The `app` entry from Webpack config is loaded here #} {% block scripts_site %} - {# {{ encore_entry_script_tags('app') }} #} {{ vite_js('main.ts') }} + {{ vite_js('search.ts') }} {% endblock %} \ No newline at end of file diff --git a/app/templates/content/stylesheets_site.html.twig b/app/templates/content/stylesheets_site.html.twig index 55d32206..3f752ed2 100644 --- a/app/templates/content/stylesheets_site.html.twig +++ b/app/templates/content/stylesheets_site.html.twig @@ -1,7 +1,8 @@ {# This file should contain the list of css to include site wide. It will be injected into the base template headers #} {# The `app` entry from Webpack config is loaded here #} {% block stylesheets_site %} - {# {{ encore_entry_link_tags('app') }} #} {{ vite_css('main.ts') }} + {{ vite_css('search.ts') }} {{ vite_preload('main.ts') }} + {{ vite_preload('search.ts') }} {% endblock %} \ No newline at end of file diff --git a/app/templates/partials/sidebar.html.twig b/app/templates/partials/sidebar.html.twig index f8d4b093..6e8f39d2 100644 --- a/app/templates/partials/sidebar.html.twig +++ b/app/templates/partials/sidebar.html.twig @@ -1,5 +1,7 @@