Skip to content

Commit fc565a0

Browse files
authored
Add documentation search feature (#154)
1 parent 1bfc2d1 commit fc565a0

33 files changed

Lines changed: 4146 additions & 774 deletions

app/assets/SearchComponent.vue

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script setup lang="ts">
2+
import { ref, watch } from 'vue'
3+
import axios from 'axios'
4+
5+
/**
6+
* Configuration Constants
7+
*/
8+
const minLength: number = 3
9+
const dataUrl: string = '/api/search'
10+
11+
/**
12+
* Reactive Variables
13+
*/
14+
const searchQuery = ref<string>('')
15+
const loading = ref<boolean>(false)
16+
const error = ref<any>(null)
17+
const data = ref<ResultData>({
18+
count: 0,
19+
size: 0,
20+
page: 0,
21+
rows: []
22+
})
23+
24+
/**
25+
* Api fetch function
26+
*/
27+
async function fetch() {
28+
if (searchQuery.value.length < minLength) {
29+
return
30+
}
31+
32+
loading.value = true
33+
axios
34+
.get<ResultData>(dataUrl, {
35+
params: {
36+
q: searchQuery.value
37+
}
38+
})
39+
.then((response) => {
40+
data.value = response.data
41+
})
42+
.catch((err) => {
43+
error.value = err.response.data
44+
})
45+
.finally(() => {
46+
loading.value = false
47+
})
48+
}
49+
50+
/**
51+
* Watchers
52+
*/
53+
watch(searchQuery, async () => {
54+
fetch()
55+
})
56+
57+
/**
58+
* Computed Properties
59+
*/
60+
const placeholder = ref<string>(`Type at least ${minLength} characters to search`)
61+
62+
/**
63+
* Interfaces
64+
*/
65+
interface ResultData {
66+
count: number
67+
size: number
68+
page: number
69+
rows: Result[]
70+
}
71+
72+
interface Result {
73+
title: string
74+
slug: string
75+
route: string
76+
snippet: string
77+
score: number
78+
version: string
79+
}
80+
</script>
81+
82+
<template>
83+
<div class="uk-margin-small uk-inline uk-width-expand">
84+
<span class="uk-form-icon" uk-icon="icon: search"></span>
85+
<input
86+
class="uk-input"
87+
type="text"
88+
placeholder="Search Documentation"
89+
aria-label="Search Documentation"
90+
uk-toggle="target: #search-modal" />
91+
</div>
92+
93+
<!-- This is the modal -->
94+
<div id="search-modal" uk-modal>
95+
<div class="uk-modal-dialog">
96+
<button class="uk-modal-close-default" type="button" uk-close></button>
97+
<div class="uk-modal-header">
98+
<h2 class="uk-modal-title">Search Documentation</h2>
99+
</div>
100+
101+
<div class="uk-modal-body">
102+
<div class="uk-margin-small uk-inline uk-width-expand">
103+
<span class="uk-form-icon" uk-icon="icon: search"></span>
104+
<input
105+
class="uk-input"
106+
v-model="searchQuery"
107+
type="text"
108+
:placeholder="placeholder"
109+
aria-label="Search Documentation"
110+
autofocus
111+
tabindex="1" />
112+
</div>
113+
114+
<div class="uk-margin" uk-overflow-auto>
115+
<div v-if="loading" class="uk-text-center">
116+
<div uk-spinner></div>
117+
</div>
118+
<div v-else-if="error" class="uk-alert-danger" uk-alert>
119+
<p>{{ error }}</p>
120+
</div>
121+
<div
122+
v-else-if="data.rows.length === 0 && searchQuery.length >= minLength"
123+
class="uk-text-center uk-text-muted">
124+
<p>No results found</p>
125+
</div>
126+
<ul v-else-if="data.rows.length > 0" class="uk-list uk-list-divider">
127+
<li v-for="row in data.rows" :key="row.route">
128+
<a :href="row.route" class="uk-link-reset">
129+
<h4 class="uk-margin-remove">{{ row.title }}</h4>
130+
<p class="uk-text-small" v-html="row.snippet"></p>
131+
</a>
132+
</li>
133+
</ul>
134+
</div>
135+
</div>
136+
137+
<div class="uk-modal-footer uk-text-right">
138+
<button class="uk-button uk-button-primary uk-modal-close" type="button">
139+
Close
140+
</button>
141+
</div>
142+
</div>
143+
</div>
144+
</template>

app/assets/search.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createApp } from 'vue'
2+
import SearchComponent from './SearchComponent.vue'
3+
createApp(SearchComponent).mount('#search-box')
4+
createApp(SearchComponent).mount('#search-box-mobile')

app/config/default.php

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
declare(strict_types=1);
44

55
/*
6-
* UserFrosting (http://www.userfrosting.com)
6+
* UserFrosting Learn (http://www.userfrosting.com)
77
*
8-
* @link https://github.com/userfrosting/UserFrosting
9-
* @copyright Copyright (c) 2013-2024 Alexander Weissman & Louis Charette
10-
* @license https://github.com/userfrosting/UserFrosting/blob/master/LICENSE.md (MIT License)
8+
* @link https://github.com/userfrosting/Learn
9+
* @copyright Copyright (c) 2025 Alexander Weissman & Louis Charette
10+
* @license https://github.com/userfrosting/Learn/blob/main/LICENSE.md (MIT License)
1111
*/
1212

1313
/*
@@ -26,23 +26,18 @@
2626
],
2727
],
2828

29-
/**
30-
* Disable cache
31-
*/
32-
'cache' => [
33-
'driver' => 'array',
34-
],
29+
// TODO : Disable page cache by default in dev mode, but keep search cache enabled.
3530

3631
/**
37-
* ----------------------------------------------------------------------
38-
* Learn Settings
39-
*
40-
* Settings for the documentation application.
41-
* - Cache : Enable/disable caching of documentation pages and menu.
42-
* - Key : Cache key prefix for cached documentation pages and menu.
43-
* - TTL : Time to live for cached documentation pages and menu, in seconds.
44-
* ----------------------------------------------------------------------
45-
*/
32+
* ----------------------------------------------------------------------
33+
* Learn Settings
34+
*
35+
* Settings for the documentation application.
36+
* - Cache : Enable/disable caching of documentation pages and menu.
37+
* - Key : Cache key prefix for cached documentation pages and menu.
38+
* - TTL : Time to live for cached documentation pages and menu, in seconds.
39+
* ----------------------------------------------------------------------
40+
*/
4641
'learn' => [
4742
'cache' => [
4843
'key' => 'learn.%1$s.%2$s',
@@ -59,6 +54,23 @@
5954
],
6055
'latest' => '6.0',
6156
],
57+
'search' => [
58+
'min_length' => 3, // Minimum length of search query
59+
'default_size' => 25, // Default number of results per page
60+
'snippet_length' => 150, // Length of content snippets in results
61+
'max_results' => 150, // Maximum number of results to consider for pagination
62+
'cache' => [
63+
'key' => 'learn.search.%1$s', // %1$s = keyword hash
64+
'ttl' => 86400 * 30, // 30 days
65+
],
66+
'index' => [
67+
'key' => 'learn.index.%1$s', // %1$s = version
68+
'ttl' => 86400 * 30, // 30 days
69+
70+
// Metadata fields to include in the search index
71+
'metadata_fields' => ['description', 'tags', 'category', 'author'],
72+
],
73+
],
6274
],
6375

6476
/*

app/src/Bakery/BakeCommandListener.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ public function __invoke(BakeCommandEvent $event): void
2424
$event->setCommands([
2525
'debug',
2626
'assets:build',
27-
'clear-cache'
27+
'clear-cache',
28+
'search:index'
2829
]);
2930
}
3031
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* UserFrosting Learn (http://www.userfrosting.com)
7+
*
8+
* @link https://github.com/userfrosting/Learn
9+
* @copyright Copyright (c) 2025 Alexander Weissman & Louis Charette
10+
* @license https://github.com/userfrosting/Learn/blob/main/LICENSE.md (MIT License)
11+
*/
12+
13+
namespace UserFrosting\Learn\Bakery;
14+
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Input\InputOption;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
use UserFrosting\Bakery\WithSymfonyStyle;
20+
use UserFrosting\Learn\Search\SearchIndex;
21+
22+
/**
23+
* Bakery command to rebuild the search index for documentation.
24+
*/
25+
class SearchIndexCommand extends Command
26+
{
27+
use WithSymfonyStyle;
28+
29+
/**
30+
* @param SearchIndex $searchIndex
31+
*/
32+
public function __construct(
33+
protected SearchIndex $searchIndex,
34+
) {
35+
parent::__construct();
36+
}
37+
38+
/**
39+
* {@inheritdoc}
40+
*/
41+
protected function configure(): void
42+
{
43+
$this->setName('search:index')
44+
->setDescription('Build or rebuild the search index for documentation')
45+
->addOption(
46+
'doc-version',
47+
null,
48+
InputOption::VALUE_OPTIONAL,
49+
'Documentation version to index (omit to index all versions)'
50+
)
51+
->addOption(
52+
'clear',
53+
null,
54+
InputOption::VALUE_NONE,
55+
'Clear the search index before rebuilding'
56+
);
57+
}
58+
59+
/**
60+
* {@inheritdoc}
61+
*/
62+
protected function execute(InputInterface $input, OutputInterface $output): int
63+
{
64+
$this->io->title('Documentation Search Index');
65+
66+
/** @var string|null $version */
67+
$version = $input->getOption('doc-version');
68+
$clear = $input->getOption('clear');
69+
70+
// Clear index if requested
71+
if ($clear === true) {
72+
$this->io->writeln('Clearing search index...');
73+
$this->searchIndex->clearIndex($version);
74+
$this->io->success('Search index cleared.');
75+
}
76+
77+
// Build index
78+
$versionText = $version !== null ? "version {$version}" : 'all versions';
79+
$this->io->writeln("Building search index for {$versionText}...");
80+
81+
try {
82+
$count = $this->searchIndex->buildIndex($version);
83+
$this->io->success("Search index built successfully. Indexed {$count} pages.");
84+
} catch (\Exception $e) {
85+
$this->io->error("Failed to build search index: {$e->getMessage()}");
86+
87+
return Command::FAILURE;
88+
}
89+
90+
return Command::SUCCESS;
91+
}
92+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* UserFrosting Learn (http://www.userfrosting.com)
7+
*
8+
* @link https://github.com/userfrosting/Learn
9+
* @copyright Copyright (c) 2025 Alexander Weissman & Louis Charette
10+
* @license https://github.com/userfrosting/Learn/blob/main/LICENSE.md (MIT License)
11+
*/
12+
13+
namespace UserFrosting\Learn\Controller;
14+
15+
use Psr\Http\Message\ResponseInterface as Response;
16+
use Psr\Http\Message\ServerRequestInterface as Request;
17+
use UserFrosting\Learn\Search\SearchSprunje;
18+
19+
/**
20+
* Controller for the documentation search API.
21+
*/
22+
class SearchController
23+
{
24+
public function __construct(
25+
protected SearchSprunje $sprunje,
26+
) {
27+
}
28+
29+
/**
30+
* Search documentation pages.
31+
* Request type: GET.
32+
*
33+
* Query parameters:
34+
* - q: Search query (required, min length from config)
35+
* - page: Page number for pagination (optional, default 1)
36+
* - size: Number of results per page (optional, default from config, null means all results)
37+
* - version: Documentation version (optional, defaults to latest)
38+
*
39+
* @param Request $request
40+
* @param Response $response
41+
*/
42+
public function search(Request $request, Response $response): Response
43+
{
44+
$params = $request->getQueryParams();
45+
46+
$this->sprunje
47+
->setQuery($params['q'] ?? '')
48+
->setVersion($params['version'] ?? null)
49+
->setPage((int) ($params['page'] ?? 1));
50+
51+
// Only set size if explicitly provided
52+
if (isset($params['size'])) {
53+
$this->sprunje->setSize((int) $params['size']);
54+
}
55+
56+
return $this->sprunje->toResponse($response);
57+
}
58+
}

app/src/Documentation/DocumentationRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ protected function getAdjacentPage(PageResource $page, int $offset): ?PageResour
283283
*
284284
* @return array<string, PageResource> Array keyed by page slug
285285
*/
286-
protected function getFlattenedTree(?string $version = null): array
286+
public function getFlattenedTree(?string $version = null): array
287287
{
288288
$tree = $this->getTree($version);
289289
$flat = [];

0 commit comments

Comments
 (0)