+
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 @@