diff --git a/build/src/ApiGenerator/Application/ApiSearchGenerator.php b/build/src/ApiGenerator/Application/ApiSearchGenerator.php
index 1ae0a56..33bee84 100644
--- a/build/src/ApiGenerator/Application/ApiSearchGenerator.php
+++ b/build/src/ApiGenerator/Application/ApiSearchGenerator.php
@@ -112,12 +112,30 @@ private function generateDocumentationSearchItems(): array
// Remove frontmatter
$content = preg_replace('/\+\+\+.*?\+\+\+/s', '', $content);
- // Remove markdown formatting and clean content
+ // Extract code blocks content (preserves important terms like SrcDirs, :pairs, etc.)
+ preg_match_all('/```\w*\n?([\s\S]*?)```/', $content, $codeBlocks);
+ $codeContent = '';
+ if (!empty($codeBlocks[1])) {
+ $codeContent = implode(' ', $codeBlocks[1]);
+ // Clean code content: remove extra whitespace but preserve all characters
+ $codeContent = preg_replace('/\s+/', ' ', trim($codeContent));
+ }
+
+ // Remove code blocks from main content
+ $content = preg_replace('/```[\s\S]*?```/', ' ', $content);
+
+ // Remove markdown formatting but preserve colons (:) for keywords like :pairs, :keys
+ // Remove: # (headers), ` (backticks), * (bold/italic), [] (links), () (links)
$content = preg_replace('/[#`*\[\]()]/', ' ', $content);
+
+ // Clean up whitespace
$content = preg_replace('/\s+/', ' ', trim($content));
- // Limit content length for search index
- $content = substr($content, 0, 500);
+ // Combine code content with main content (code first for better matching)
+ $content = trim($codeContent . ' ' . $content);
+
+ // Increase content length for search index to capture more content
+ $content = substr($content, 0, 200);
$result[] = [
'id' => 'doc_' . pathinfo($file, PATHINFO_FILENAME),
diff --git a/config.toml b/config.toml
index f2bef92..0b3747f 100644
--- a/config.toml
+++ b/config.toml
@@ -8,7 +8,16 @@ description = "The official website of the Phel language. Phel is a functional p
compile_sass = false
# Whether to build a search index to be used later on by a JavaScript library
-build_search_index = false
+build_search_index = true
+
+[search]
+# Include page content in the search index
+include_title = true
+include_description = true
+include_path = false
+include_content = true
+# Include more content to make search more comprehensive
+truncate_content_length = 5000
generate_feeds = false
diff --git a/css/components/search.css b/css/components/search.css
index bc46c79..97e7282 100644
--- a/css/components/search.css
+++ b/css/components/search.css
@@ -82,6 +82,7 @@
max-width: none;
width: 100%;
flex: 1;
+ margin-left: var(--space-md);
}
}
@@ -166,6 +167,7 @@
border-bottom: 2px solid transparent;
border-radius: inherit;
transition: border-color var(--duration-fast) var(--ease-out);
+ border-bottom: 1px solid var(--color-light-border);
}
.search-modal__icon {
@@ -228,17 +230,71 @@
background-color: var(--color-light-link);
border-color: var(--color-light-link);
color: white;
- transform: scale(1.05);
+}
+
+/* Search Filters */
+.search-modal__filters {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-sm);
+ padding: var(--space-md) var(--space-lg);
+ margin: 0;
+}
+
+.search-filter {
+ padding: 0.5rem 2.5rem;
+ margin: 0;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ line-height: 1.5;
+ color: var(--color-gray-dark);
+ background: var(--color-light-bg-secondary);
+ border: 1px solid var(--color-light-border);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--ease-out);
+ white-space: nowrap;
+ letter-spacing: 0.01em;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
+ text-align: center;
+}
+
+.search-filter:hover {
+ background: var(--color-light-surface);
+ border-color: var(--color-light-link);
+ color: var(--color-light-link);
+ box-shadow: 0 2px 4px rgba(99, 102, 241, 0.1);
+}
+
+.search-filter--active {
+ background: var(--color-light-link);
+ border-color: var(--color-light-link);
+ color: white;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
+}
+
+.search-filter--active:hover {
+ background: var(--color-light-link);
+ border-color: var(--color-light-link);
+ color: white;
+ opacity: 0.95;
+ box-shadow: 0 3px 10px rgba(99, 102, 241, 0.3);
+}
+
+@media (max-width: 768px) {
+ .search-filter {
+ padding: 0.45rem 0.9rem;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ flex: 1;
+ }
}
.search-modal__results {
max-height: 60vh;
overflow-y: auto;
- display: none;
-}
-
-.search-modal__results[style*="display: block"] {
- display: block;
+ display: flex;
+ flex-direction: column;
}
.search-modal__results::-webkit-scrollbar {
@@ -263,10 +319,15 @@
.search-modal__results-list {
list-style: none;
- padding: var(--space-sm);
+ padding: 0 var(--space-sm) var(--space-sm);
margin: 0;
}
+.search-modal__results-list:not(:empty) {
+ border-top: 1px solid var(--color-light-border);
+ padding-top: var(--space-md);
+}
+
.search-modal__results-list li {
margin: 0;
padding: 0;
@@ -364,7 +425,6 @@
.search-results__item strong {
color: var(--color-light-link);
font-weight: 600;
- font-size: var(--text-base);
}
.search-results__item .title {
@@ -383,6 +443,31 @@
width: 100%;
}
+/* Highlighted search terms - only in descriptions, not titles */
+.search-results__item .desc strong {
+ color: var(--color-light-link);
+ font-weight: 700;
+ background: rgba(99, 102, 241, 0.1);
+ padding: 0.1em 0.2em;
+ border-radius: var(--radius-sm);
+}
+
+.search-results__item .fn-name strong {
+ color: inherit;
+ font-weight: inherit;
+ background: rgba(99, 102, 241, 0.15);
+ padding: 0.1em 0.2em;
+ border-radius: var(--radius-sm);
+}
+
+/* Documentation titles - highlighted matches get slightly bolder */
+.search-results__item .title strong {
+ font-weight: 700;
+ background: rgba(99, 102, 241, 0.12);
+ padding: 0.1em 0.2em;
+ border-radius: var(--radius-sm);
+}
+
@media (max-width: 768px) {
.search-modal {
padding: 8px;
@@ -505,7 +590,37 @@
background-color: var(--color-dark-text-accent);
border-color: var(--color-dark-text-accent);
color: var(--color-dark-bg);
- transform: scale(1.05);
+}
+
+/* Dark mode search filters */
+
+.dark .search-filter {
+ color: var(--color-dark-text-primary);
+ border-color: rgba(255, 255, 255, 0.15);
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+}
+
+.dark .search-filter:hover {
+ background: rgba(191, 164, 255, 0.12);
+ border-color: var(--color-dark-text-accent);
+ color: var(--color-dark-text-accent);
+ box-shadow: 0 2px 6px rgba(191, 164, 255, 0.15);
+}
+
+.dark .search-filter--active {
+ background: var(--color-dark-text-accent);
+ border-color: var(--color-dark-text-accent);
+ color: var(--color-dark-bg);
+ box-shadow: 0 2px 8px rgba(191, 164, 255, 0.3);
+}
+
+.dark .search-filter--active:hover {
+ background: var(--color-dark-text-accent);
+ border-color: var(--color-dark-text-accent);
+ color: var(--color-dark-bg);
+ opacity: 0.95;
+ box-shadow: 0 3px 10px rgba(191, 164, 255, 0.35);
}
.dark .search-modal__results::-webkit-scrollbar-thumb {
@@ -519,6 +634,10 @@
background-clip: padding-box;
}
+.dark .search-modal__results-list:not(:empty) {
+ border-top-color: var(--color-dark-border);
+}
+
.dark .search-modal__results-list li.selected .search-results__item {
background: var(--color-dark-surface-hover);
}
@@ -558,9 +677,26 @@
}
.dark .search-results__item .title {
- color: var(--color-dark-text-primary);
+ color: var(--color-dark-text-accent);
}
.dark .search-results__item .desc {
color: var(--color-dark-text-primary);
}
+
+/* Highlighted search terms in dark mode - only in descriptions, not titles */
+.dark .search-results__item .desc strong {
+ color: var(--color-dark-text-accent);
+ background: rgba(191, 164, 255, 0.15);
+}
+
+.dark .search-results__item .fn-name strong {
+ color: inherit;
+ background: rgba(191, 164, 255, 0.2);
+}
+
+/* Documentation titles in dark mode - highlighted matches */
+.dark .search-results__item .title strong {
+ font-weight: 700;
+ background: rgba(191, 164, 255, 0.18);
+}
diff --git a/static/search.js b/static/search.js
index d3a2854..c4853cc 100644
--- a/static/search.js
+++ b/static/search.js
@@ -10,9 +10,11 @@ const searchModalBackdrop = document.getElementById("search-modal-backdrop");
const searchInput = document.getElementById("search");
const searchResults = document.getElementById("search-results");
const searchResultsItems = document.getElementById("search-results__items");
+const searchFilters = document.getElementById("search-filters");
let searchItemSelected = null;
let resultsItemsIndex = -1;
+let activeFilter = 'all'; // Track active filter: 'all', 'docs', 'api'
////////////////////////////////////
// Viewport Height Handler for Mobile
@@ -63,11 +65,6 @@ function openSearchModal() {
const isMobile = window.innerWidth <= 768;
const delay = isIOS ? 300 : (isMobile ? 200 : 100);
- // On mobile, hide results when input is empty (input stays visible)
- if (isMobile && searchResults) {
- searchResults.style.display = searchInput.value.trim() === "" ? "none" : "block";
- }
-
setTimeout(() => {
searchInput.focus();
// For mobile, ensure keyboard shows up
@@ -108,7 +105,7 @@ function closeSearchModal() {
}
searchInput.value = "";
- searchResults.style.display = "none";
+ searchResultsItems.innerHTML = "";
searchItemSelected = null;
resultsItemsIndex = -1;
}
@@ -140,6 +137,35 @@ if (searchInputWrapper) {
});
}
+////////////////////////////////////
+// Search Filters
+////////////////////////////////////
+
+// Handle filter button clicks
+if (searchFilters) {
+ searchFilters.addEventListener('click', function(e) {
+ if (e.target.classList.contains('search-filter')) {
+ const filterValue = e.target.getAttribute('data-filter');
+
+ // Update active filter
+ activeFilter = filterValue;
+
+ // Update button states
+ searchFilters.querySelectorAll('.search-filter').forEach(btn => {
+ btn.classList.remove('search-filter--active');
+ });
+ e.target.classList.add('search-filter--active');
+
+ // Re-run search with new filter
+ if (searchInput.value.trim() !== '') {
+ // Trigger search by dispatching keyup event
+ const event = new KeyboardEvent('keyup', { key: 'a' });
+ searchInput.dispatchEvent(event);
+ }
+ }
+ });
+}
+
////////////////////////////////////
// Keyboard shortcuts
////////////////////////////////////
@@ -274,7 +300,8 @@ function initSearch() {
return token;
};
- const index = elasticlunr(function () {
+ // Create API index
+ const apiIndex = elasticlunr(function () {
this.addField("name");
this.addField("desc");
this.addField("title");
@@ -285,7 +312,7 @@ function initSearch() {
elasticlunr.tokenizer.seperator = /[\s~~]+/;
});
- // Custom tokenizer to handle symbols with '/'
+ // Custom tokenizer to handle symbols with '/', ':', and camelCase
const originalTokenizer = elasticlunr.tokenizer;
elasticlunr.tokenizer = function (obj, metadata) {
if (obj == null) {
@@ -298,16 +325,56 @@ function initSearch() {
}, []);
}
- const str = obj.toString().toLowerCase();
- const tokens = originalTokenizer(str, metadata);
+ const originalStr = obj.toString();
+ const str = originalStr.toLowerCase();
+ let tokens = originalTokenizer(str, metadata);
+
+ // Handle camelCase: split "SrcDirs" into ["src", "dirs", "srcdirs"]
+ const camelCaseMatch = originalStr.match(/[a-z]+|[A-Z][a-z]*/g);
+ if (camelCaseMatch && camelCaseMatch.length > 1) {
+ const camelCaseLower = camelCaseMatch.map(s => s.toLowerCase()).join('');
+ if (camelCaseLower && !tokens.includes(camelCaseLower)) {
+ tokens.push(camelCaseLower);
+ }
+ // Also add individual parts
+ camelCaseMatch.forEach(part => {
+ const partLower = part.toLowerCase();
+ if (partLower && !tokens.includes(partLower)) {
+ tokens.push(partLower);
+ }
+ });
+ }
- // Add additional tokens for strings containing '/'
+ // Handle strings with '/' (namespaces)
if (str.includes('/')) {
const parts = str.split('/');
- if (parts.length > 1) {
- const lastPart = parts[parts.length - 1];
- if (lastPart) {
- tokens.push(lastPart);
+ parts.forEach(part => {
+ if (part && !tokens.includes(part)) {
+ tokens.push(part);
+ }
+ });
+ }
+
+ // Handle strings with ':' (keywords like :pairs, :keys)
+ // Preserve the colon version and the version without colon
+ if (str.includes(':')) {
+ const colonParts = str.split(':');
+ colonParts.forEach((part, index) => {
+ if (part && !tokens.includes(part)) {
+ tokens.push(part);
+ }
+ // Add version with colon prefix for keywords
+ if (index > 0 && colonParts[0] === '') {
+ const keyword = ':' + part;
+ if (!tokens.includes(keyword)) {
+ tokens.push(keyword);
+ }
+ }
+ });
+ // Also add the full string if it starts with :
+ if (str.startsWith(':')) {
+ if (!tokens.includes(str)) {
+ tokens.push(str);
}
}
}
@@ -315,8 +382,19 @@ function initSearch() {
return tokens;
};
- // Load symbols into elasticlunr object
- window.searchIndexApi.forEach(item => index.addDoc(item));
+ // Load API symbols into elasticlunr index
+ if (window.searchIndexApi) {
+ window.searchIndexApi.forEach(item => apiIndex.addDoc(item));
+ }
+
+ // Load Zola documentation index
+ let docsIndex = null;
+ if (window.searchIndex) {
+ docsIndex = elasticlunr.Index.load(window.searchIndex);
+ }
+
+ // Create combined search object
+ const searchIndices = { api: apiIndex, docs: docsIndex };
// Search on input
searchInput.addEventListener("keyup", function (keyboardEvent) {
@@ -326,20 +404,20 @@ function initSearch() {
searchItemSelected = null;
resultsItemsIndex = -1;
- debounce(showResults(index), 150)();
+ debounce(showResults(searchIndices), 150)();
});
- // Hide results when user clears the search field
+ // Hide results list when user clears the search field
searchInput.addEventListener("search", () => {
if (searchInput.value === "") {
- searchResults.style.display = "none";
+ searchResultsItems.innerHTML = "";
}
});
// Show results when input is focused and has value
searchInput.addEventListener("focus", function () {
if (searchInput.value.trim() !== "") {
- showResults(index)();
+ showResults(searchIndices)();
}
});
}
@@ -359,19 +437,18 @@ function debounce(func, wait) {
};
}
-function showResults(index) {
+function showResults(searchIndices) {
return function () {
const term = searchInput.value.trim();
- searchResults.style.display = term === "" ? "none" : "block";
searchResultsItems.innerHTML = "";
if (term === "") {
- searchResults.style.display = "none";
return;
}
- const options = {
+ // Search options for API index
+ const apiOptions = {
bool: "OR",
fields: {
name: {boost: 3},
@@ -382,31 +459,203 @@ function showResults(index) {
expand: true
};
- const results = index.search(term, options);
+ // Search options for docs index
+ const docsOptions = {
+ bool: "OR",
+ fields: {
+ title: {boost: 2},
+ body: {boost: 1}
+ },
+ expand: true
+ };
+
+ // Helper function to highlight matched term
+ function highlightTerm(text, searchTerm) {
+ if (!text) return text;
+ // Escape special regex characters in search term
+ const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(`(${escapedTerm})`, 'gi');
+ return text.replace(regex, '$1');
+ }
+
+ // Helper function to extract snippet around the search term
+ function getSnippetAroundTerm(text, searchTerm, snippetLength = 150) {
+ if (!text) return '';
+
+ const lowerText = text.toLowerCase();
+ const lowerTerm = searchTerm.toLowerCase();
+ const termIndex = lowerText.indexOf(lowerTerm);
+
+ if (termIndex === -1) {
+ // Term not found, return beginning of text
+ return text.substring(0, snippetLength) + (text.length > snippetLength ? '...' : '');
+ }
+
+ // Calculate start and end positions for the snippet
+ const halfSnippet = Math.floor(snippetLength / 2);
+ let start = Math.max(0, termIndex - halfSnippet);
+ let end = Math.min(text.length, termIndex + lowerTerm.length + halfSnippet);
+
+ // Adjust to avoid cutting words
+ if (start > 0) {
+ const spaceIndex = text.indexOf(' ', start);
+ if (spaceIndex !== -1 && spaceIndex < termIndex) {
+ start = spaceIndex + 1;
+ }
+ }
+ if (end < text.length) {
+ const spaceIndex = text.lastIndexOf(' ', end);
+ if (spaceIndex !== -1 && spaceIndex > termIndex + lowerTerm.length) {
+ end = spaceIndex;
+ }
+ }
+
+ let snippet = text.substring(start, end);
+ if (start > 0) snippet = '...' + snippet;
+ if (end < text.length) snippet = snippet + '...';
+
+ // Highlight the matched term
+ return highlightTerm(snippet, searchTerm);
+ }
+
+ // Search API index
+ let apiResults = [];
+ if (searchIndices.api) {
+ apiResults = searchIndices.api.search(term, apiOptions).map(result => {
+ // Apply snippet extraction and highlighting to API results
+ const doc = result.doc;
+
+ // Check if this is actually an API function or a documentation item
+ const isApiFunction = doc.type === 'api';
+ const isDocumentation = doc.type === 'documentation';
+
+ if (isApiFunction) {
+ // API function result
+ const cleanDoc = {};
+ if (doc.id !== undefined) cleanDoc.id = doc.id;
+ if (doc.name !== undefined) cleanDoc.name = highlightTerm(doc.name, term);
+ if (doc.signature !== undefined) cleanDoc.signature = doc.signature;
+ if (doc.desc !== undefined) cleanDoc.desc = getSnippetAroundTerm(doc.desc, term);
+ if (doc.anchor !== undefined) cleanDoc.anchor = doc.anchor;
+
+ return {
+ ref: result.ref,
+ score: result.score,
+ doc: cleanDoc,
+ source: 'api'
+ };
+ } else if (isDocumentation) {
+ // Documentation item from API index
+ return {
+ ref: result.ref,
+ score: result.score,
+ doc: {
+ id: doc.id,
+ title: highlightTerm(doc.title || 'Untitled', term),
+ content: getSnippetAroundTerm(doc.content || '', term),
+ url: doc.url,
+ type: 'documentation'
+ },
+ source: 'docs'
+ };
+ }
+ return null;
+ }).filter(r => r !== null);
+ }
+
+ // Search documentation index
+ let docsResults = [];
+ if (searchIndices.docs) {
+ docsResults = searchIndices.docs.search(term, docsOptions).map(result => {
+ // The doc is already included in the result from elasticlunr
+ const doc = result.doc;
+ // Convert URL to relative path for proper linking
+ let url = result.ref;
+ try {
+ const urlObj = new URL(result.ref);
+ url = urlObj.pathname;
+ } catch (e) {
+ // Already a relative path
+ }
+ return {
+ ref: result.ref,
+ score: result.score,
+ doc: {
+ id: result.ref,
+ title: highlightTerm(doc.title || 'Untitled', term),
+ content: getSnippetAroundTerm(doc.body, term),
+ url: url,
+ type: 'documentation'
+ },
+ source: 'docs'
+ };
+ });
+ }
- if (results.length === 0) {
+ // Combine and sort results by score
+ let allResults = [...apiResults, ...docsResults]
+ .sort((a, b) => b.score - a.score);
+
+ // Apply active filter
+ if (activeFilter === 'docs') {
+ allResults = allResults.filter(result => result.source === 'docs');
+ } else if (activeFilter === 'api') {
+ allResults = allResults.filter(result => result.source === 'api');
+ }
+ // 'all' filter shows everything (no filtering needed)
+
+ // Deduplicate results by normalized URL (remove trailing slashes)
+ const seenUrls = new Set();
+ const uniqueResults = allResults.filter(result => {
+ const url = result.doc.url || result.doc.anchor || '';
+ // Normalize URL: remove trailing slash for comparison
+ const normalizedUrl = url.replace(/\/$/, '');
+ if (seenUrls.has(normalizedUrl)) {
+ return false;
+ }
+ seenUrls.add(normalizedUrl);
+ return true;
+ });
+
+ // Separate release pages from other results
+ const releaseResults = [];
+ const regularResults = [];
+
+ uniqueResults.forEach(result => {
+ const url = result.doc.url || '';
+ if (url.includes('/releases/')) {
+ releaseResults.push(result);
+ } else {
+ regularResults.push(result);
+ }
+ });
+
+ // Put regular results first, then release pages
+ const sortedResults = [...regularResults, ...releaseResults];
+
+ if (sortedResults.length === 0) {
let emptyResult = {
- name: "Symbol not found",
+ name: "No results found",
signature: "",
- desc: "Cannot provide any Phel symbol. Try something else",
+ desc: "Cannot find any matching content. Try something else",
anchor: "#",
type: "empty"
};
- createMenuItem(emptyResult, null);
+ createMenuItem(emptyResult, null, activeFilter);
return;
}
- const numberOfResults = Math.min(results.length, MAX_ITEMS);
+ const numberOfResults = Math.min(sortedResults.length, MAX_ITEMS);
for (let i = 0; i < numberOfResults; i++) {
- createMenuItem(results[i].doc, i);
+ createMenuItem(sortedResults[i].doc, i, activeFilter);
}
}
}
-function createMenuItem(result, index) {
+function createMenuItem(result, index, filter) {
const item = document.createElement("li");
- item.innerHTML = formatSearchResultItem(result);
+ item.innerHTML = formatSearchResultItem(result, filter);
item.addEventListener("mouseenter", (mouseEvent) => {
removeSelectedClassFromSearchResult();
@@ -422,38 +671,48 @@ function createMenuItem(result, index) {
searchResultsItems.appendChild(item);
}
-function formatSearchResultItem(item) {
+function formatSearchResultItem(item, filter) {
+ // Determine if we should show the badge
+ const showDocsBadge = filter !== 'docs';
+ const showApiBadge = filter !== 'api';
+
if (item.type === "documentation") {
- return ``
+ const badge = showDocsBadge
+ ? `Docs`
+ : '';
+ return ``
+ `