Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions app/config/default.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@
],
'latest' => '6.0',
],
'search' => [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make key name more descriptive, result in a sub array, index should be under search.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e8b2e0. Moved learn.index configuration under learn.search.index with metadata_fields array.

'min_length' => 3,
'default_page' => 1,
'default_size' => 10,
'max_size' => 100,
'snippet_length' => 150,
'max_results' => 1000,
'results_cache_ttl' => 3600,
'results_cache_key' => 'learn.search-results.%1$s.%2$s.%3$s.%4$s',
'index' => [
'key' => 'learn.search-index.%1$s',
'ttl' => 86400 * 7, // 7 days
'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;
}
}
74 changes: 74 additions & 0 deletions app/src/Controller/SearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?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\Config\Config;
use UserFrosting\Learn\Search\SearchService;
use UserFrosting\Learn\Search\SearchSprunje;
use UserFrosting\Sprinkle\Core\Exceptions\NotFoundException;

/**
* Controller for the documentation search API.
*/
class SearchController
{
public function __construct(
protected SearchService $searchService,
protected Config $config,
) {
}

/**
* Search documentation pages.
* Request type: GET.
*
* Query parameters:
* - q: Search query (required, min length from config)
* - version: Documentation version to search (optional, defaults to latest)
* - page: Page number for pagination (optional, from config)
* - size: Number of results per page (optional, from config, max from config)
*
* @param Request $request
* @param Response $response
*/
public function search(Request $request, Response $response): Response
{
$params = $request->getQueryParams();

// Get query parameter
$query = $params['q'] ?? '';

// Create Sprunje which validates query length in its constructor
try {
// Prepare options for Sprunje
$sprunjeOptions = [
'query' => $query,
'version' => $params['version'] ?? null,
'page' => isset($params['page']) ? (int) $params['page'] : null,
'size' => $params['size'] ?? null,
'format' => 'json',
];

// Create and execute Sprunje (validates query length in constructor)
$sprunje = new SearchSprunje($this->searchService, $this->config, $sprunjeOptions);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inject Sprunje

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e8b2e0. SearchController now creates SearchSprunje directly (cannot inject as it requires query-specific options). Sprunje is instantiated per request with query parameters.


// Return response via Sprunje
return $sprunje->toResponse($response);
} catch (\InvalidArgumentException $e) {
// Throw NotFoundException for empty/invalid queries
throw new NotFoundException($e->getMessage());
}
}
}
5 changes: 5 additions & 0 deletions app/src/MyRoutes.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@

use Slim\App;
use UserFrosting\Learn\Controller\DocumentationController;
use UserFrosting\Learn\Controller\SearchController;
use UserFrosting\Learn\Middleware\TwigGlobals;
use UserFrosting\Routes\RouteDefinitionInterface;

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)
Expand Down
19 changes: 18 additions & 1 deletion app/src/Recipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,7 +38,8 @@
class Recipe implements
SprinkleRecipe,
EventListenerRecipe,
TwigExtensionRecipe
TwigExtensionRecipe,
BakeryRecipe
{
/**
* Return the Sprinkle name.
Expand Down Expand Up @@ -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,
];
}

Expand Down
32 changes: 32 additions & 0 deletions app/src/Search/DummySearchModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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\Search;

use Illuminate\Database\Eloquent\Model;

/**
* Dummy model used by SearchSprunje to satisfy Sprunje's type requirements.
* This model is never actually used for database queries.
*/
class DummySearchModel extends Model
{
/**
* @var string The table associated with the model (not used)
*/
protected $table = 'search_dummy';

/**
* @var bool Indicates if the model should be timestamped
*/
public $timestamps = false;
}
Loading
Loading