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 `` + `
` + `
` - + `${item.title}` - + `Docs` + + `${item.title || ''}` + + badge + `
` - + `${item.content}` + + `${item.content || ''}` + `
`; } else if (item.type === "empty") { - return `` + return `` + `
` + `
` + `
` - + `${item.name} ` - + `${item.signature}` + + `${item.name || ''} ` + + `${item.signature || ''}` + `
` + `
` - + `${item.desc}` + + `${item.desc || ''}` + `
`; } else { - return `` + const badge = showApiBadge + ? `API` + : ''; + return `` + `
` + `
` + `
` - + `${item.name} ` - + `${item.signature}` + + `${item.name || ''} ` + + `${item.signature || ''}` + `
` - + `API` + + badge + `
` - + `${item.desc}` + + `${item.desc || ''}` + `
`; } } diff --git a/templates/base.html b/templates/base.html index 573e161..3557a44 100644 --- a/templates/base.html +++ b/templates/base.html @@ -47,6 +47,7 @@ + diff --git a/templates/header.html b/templates/header.html index e5faf77..03446c4 100644 --- a/templates/header.html +++ b/templates/header.html @@ -104,6 +104,11 @@
+
+ + + +