Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
735bc48
Initial plan
Copilot Jan 13, 2026
cc01465
Add search feature implementation with API, indexing, and tests
Copilot Jan 13, 2026
07b03ae
Address code review feedback: optimize search performance and improve…
Copilot Jan 13, 2026
1a1decc
Fix error handling in preg_replace and cache retrieval
Copilot Jan 13, 2026
0e1a628
Address code review feedback: add config values, validation, metadata…
Copilot Jan 18, 2026
09445cb
Update tests for new index structure and validation behavior
Copilot Jan 18, 2026
54b1f48
Integrate Sprunje for pagination and response formatting
Copilot Jan 18, 2026
6869f42
Fix Sprunje integration issues: pagination, validation, and error han…
Copilot Jan 18, 2026
c984e86
Improve code comments for clarity and accuracy
Copilot Jan 18, 2026
4e8b2e0
Major refactor: fix config structure, Sprunje integration, and addres…
Copilot Jan 18, 2026
56f2180
Update tests for NotFoundException error handling
Copilot Jan 18, 2026
6059737
Improve SearchSprunje: use named DummySearchModel class and fix comments
Copilot Jan 18, 2026
f0b6e57
Remove slop, apply better sprunje
lcharette Jan 23, 2026
2e7bc01
Add size & page to output, complete tests, fix default options
lcharette Jan 24, 2026
2def405
Replace options magic keys with proper getters and setters
lcharette Jan 25, 2026
47bd291
Refactor StaticSprunje: Remove getArray, getModels -> getResultSet a…
lcharette Jan 25, 2026
baa146c
Replace magic array with objects
lcharette Jan 25, 2026
3bd361e
Index should be fetched if cache is empty
lcharette Jan 25, 2026
6af0323
Simplify SearchIndex & SearchService
lcharette Jan 25, 2026
99d41fc
Review tests
lcharette Jan 26, 2026
7349745
Replace matches with score + add score multiplier to constant
lcharette Jan 27, 2026
539956e
Add Search UI
lcharette Jan 27, 2026
213c558
Highlight query in snippet
lcharette Jan 27, 2026
6466fb1
Change default size, skip pages
lcharette Jan 27, 2026
a130442
Merge branch 'main' into copilot/add-documentation-search-feature
lcharette Jan 27, 2026
f586eca
Fix style
lcharette Jan 27, 2026
c591ee6
Fix test
lcharette Jan 27, 2026
a3e3177
Add search to mobile
lcharette Jan 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions app/assets/SearchComponent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import axios from 'axios'

/**
* Configuration Constants
*/
const minLength: number = 3
const dataUrl: string = '/api/search'

/**
* Reactive Variables
*/
const searchQuery = ref<string>('')
const loading = ref<boolean>(false)
const error = ref<any>(null)
const data = ref<ResultData>({
count: 0,
size: 0,
page: 0,
rows: []
})

/**
* Api fetch function
*/
async function fetch() {
if (searchQuery.value.length < minLength) {
return
}

loading.value = true
axios
.get<ResultData>(dataUrl, {
params: {
q: searchQuery.value
}
})
.then((response) => {
data.value = response.data
})
.catch((err) => {
error.value = err.response.data
})
.finally(() => {
loading.value = false
})
}

/**
* Watchers
*/
watch(searchQuery, async () => {
fetch()
})

/**
* Computed Properties
*/
const placeholder = ref<string>(`Type at least ${minLength} characters to search`)

/**
* Interfaces
*/
interface ResultData {
count: number
size: number
page: number
rows: Result[]
}

interface Result {
title: string
slug: string
route: string
snippet: string
score: number
version: string
}
</script>

<template>
<div class="uk-margin-small uk-inline uk-width-expand">
<span class="uk-form-icon" uk-icon="icon: search"></span>
<input
class="uk-input"
type="text"
placeholder="Search Documentation"
aria-label="Search Documentation"
uk-toggle="target: #search-modal" />
</div>

<!-- This is the modal -->
<div id="search-modal" uk-modal>
<div class="uk-modal-dialog">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h2 class="uk-modal-title">Search Documentation</h2>
</div>

<div class="uk-modal-body">
<div class="uk-margin-small uk-inline uk-width-expand">
<span class="uk-form-icon" uk-icon="icon: search"></span>
<input
class="uk-input"
v-model="searchQuery"
type="text"
:placeholder="placeholder"
aria-label="Search Documentation"
autofocus
tabindex="1" />
</div>

<div class="uk-margin" uk-overflow-auto>
<div v-if="loading" class="uk-text-center">
<div uk-spinner></div>
</div>
<div v-else-if="error" class="uk-alert-danger" uk-alert>
<p>{{ error }}</p>
</div>
<div
v-else-if="data.rows.length === 0 && searchQuery.length >= minLength"
class="uk-text-center uk-text-muted">
<p>No results found</p>
</div>
<ul v-else-if="data.rows.length > 0" class="uk-list uk-list-divider">
<li v-for="row in data.rows" :key="row.route">
<a :href="row.route" class="uk-link-reset">
<h4 class="uk-margin-remove">{{ row.title }}</h4>
<p class="uk-text-small" v-html="row.snippet"></p>
</a>
</li>
</ul>
</div>
</div>

<div class="uk-modal-footer uk-text-right">
<button class="uk-button uk-button-primary uk-modal-close" type="button">
Close
</button>
</div>
</div>
</div>
</template>
4 changes: 4 additions & 0 deletions app/assets/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import SearchComponent from './SearchComponent.vue'
createApp(SearchComponent).mount('#search-box')
createApp(SearchComponent).mount('#search-box-mobile')
50 changes: 31 additions & 19 deletions app/config/default.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/

/*
Expand All @@ -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',
Expand All @@ -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'],
],
],
],

/*
Expand Down
3 changes: 2 additions & 1 deletion app/src/Bakery/BakeCommandListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public function __invoke(BakeCommandEvent $event): void
$event->setCommands([
'debug',
'assets:build',
'clear-cache'
'clear-cache',
'search:index'
]);
}
}
92 changes: 92 additions & 0 deletions app/src/Bakery/SearchIndexCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

/*
* UserFrosting Learn (http://www.userfrosting.com)
*
* @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)
*/

namespace UserFrosting\Learn\Bakery;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use UserFrosting\Bakery\WithSymfonyStyle;
use UserFrosting\Learn\Search\SearchIndex;

/**
* Bakery command to rebuild the search index for documentation.
*/
class SearchIndexCommand extends Command
{
use WithSymfonyStyle;

/**
* @param SearchIndex $searchIndex
*/
public function __construct(
protected SearchIndex $searchIndex,
) {
parent::__construct();
}

/**
* {@inheritdoc}
*/
protected function configure(): void
{
$this->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;
}
}
58 changes: 58 additions & 0 deletions app/src/Controller/SearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

/*
* UserFrosting Learn (http://www.userfrosting.com)
*
* @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)
*/

namespace UserFrosting\Learn\Controller;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use UserFrosting\Learn\Search\SearchSprunje;

/**
* Controller for the documentation search API.
*/
class SearchController
{
public function __construct(
protected SearchSprunje $sprunje,
) {
}

/**
* Search documentation pages.
* Request type: GET.
*
* Query parameters:
* - q: Search query (required, min length from config)
* - page: Page number for pagination (optional, default 1)
* - size: Number of results per page (optional, default from config, null means all results)
* - version: Documentation version (optional, defaults to latest)
*
* @param Request $request
* @param Response $response
*/
public function search(Request $request, Response $response): Response
{
$params = $request->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);
}
}
2 changes: 1 addition & 1 deletion app/src/Documentation/DocumentationRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ protected function getAdjacentPage(PageResource $page, int $offset): ?PageResour
*
* @return array<string, PageResource> Array keyed by page slug
*/
protected function getFlattenedTree(?string $version = null): array
public function getFlattenedTree(?string $version = null): array
{
$tree = $this->getTree($version);
$flat = [];
Expand Down
Loading
Loading