Search Templates and Widgets
Overview
Aether CMS provides a powerful, theme-based search functionality that automatically indexes your posts and custom pages. The search system is designed to be flexible, performant, and easy to implement, supporting both dedicated search pages and global search components.
How It Works
The search system operates on three core principles:
- Automatic Detection: The system automatically detects if your theme has search templates
- Smart Indexing: Generates a JSON search index containing posts and pages data
- Theme Assets: Stores the search index in your theme's assets folder for optimal performance
Architecture
Theme Structure:
├── custom/
│ └── search.html (optional - for dedicated search page)
├── assets/
│ └── json/
│ └── search-index.json (auto-generated)
└── templates/
└── layout.html
Getting Started
1. Enable Search (Automatic)
Search functionality is automatically enabled when you create any of these templates in your theme:
custom/search.html
- Main search templatecustom/find.html
- Alternative search templatecustom/lookup.html
- Alternative search template
2. Create a Search Page (Optional)
If you want a dedicated search page at /search
, create both:
Content File: content/data/custom/search.md
---
id: "search-page"
title: "Search"
slug: "search"
status: "published"
pageType: "custom"
seoDescription: "Search through all posts and pages on our site"
---
# Search
Use the search box below to find content across our entire site.
Template File: themes/your-theme/custom/search.html
<div class="search-container">
<!-- Search interface will go here -->
</div>
Template Variables
When a search template is detected, the following variables are automatically available:
Core Search Variables
Variable | Type | Description |
---|---|---|
isSearchPage |
boolean | true if this is a search template |
hasSearchIndex |
boolean | true if search index was generated successfully |
searchIndexUrl |
string | URL to the JSON search index file |
searchStats |
object | Metadata about the search index |
Search Stats Object
{
generatedAt: "2025-06-20T10:30:00.000Z",
totalItems: 150,
totalPosts: 75,
totalPages: 75,
themeName: "your-theme-name",
version: "1.0.0"
}
Search Index Structure
The JSON file at searchIndexUrl
contains:
{
"meta": {
"generatedAt": "2025-06-20T10:30:00.000Z",
"totalItems": 150,
"totalPosts": 75,
"totalPages": 75,
"themeName": "your-theme-name",
"version": "1.0.0"
},
"index": [
{
"id": "unique-id",
"title": "Page Title",
"path": "/page-url",
"content": "Searchable content from excerpt or seoDescription",
"excerpt": "Short excerpt",
"type": "post", // or "page"
"category": "Category Name", // posts only
"tags": ["tag1", "tag2"], // posts only
"parentPage": "parent-slug", // custom pages only
"publishDate": "2025-06-20T10:30:00.000Z",
"author": "Author Name"
},
...
]
}
Creating a Search Page
Basic Search Template
Create themes/your-theme/custom/search.html
:
<div class="search-page">
<h1>{{metadata.title}}</h1>
{{#if hasSearchIndex}}
<div class="search-container">
<div class="search-input-wrapper">
<input
type="text"
id="searchInput"
placeholder="Search posts and pages..."
autocomplete="off"
aria-label="Search"
/>
<button
type="button"
class="search-clear"
id="searchClear"
aria-label="Clear search"
style="display: none;"
>
×
</button>
</div>
<div id="searchResults" class="search-results" style="display: none;">
<div class="search-results-inner"></div>
</div>
<div id="searchLoading" class="search-loading" style="display: none;">
<p>Loading search index...</p>
</div>
<div id="searchError" class="search-error" style="display: none;">
<p>Search functionality is currently unavailable.</p>
</div>
</div>
{{#if searchStats}}
<div class="search-stats">
<small>
Search across {{searchStats.totalItems}} items ({{searchStats.totalPosts}} posts, {{searchStats.totalPages}}
pages)
</small>
</div>
{{/if}}
<script>
document.addEventListener("DOMContentLoaded", async function () {
const searchInput = document.getElementById("searchInput")
const searchResults = document.getElementById("searchResults")
const searchLoading = document.getElementById("searchLoading")
const searchError = document.getElementById("searchError")
// Show loading state
searchLoading.style.display = "block"
searchInput.disabled = true
try {
// Fetch search index
const response = await fetch("/content{{searchIndexUrl}}")
if (!response.ok) throw new Error("Failed to load search index")
const searchData = await response.json()
const searchIndex = searchData.index || []
// Hide loading
searchLoading.style.display = "none"
searchInput.disabled = false
// Initialize search functionality
initializeSearch(searchInput, searchResults, searchIndex)
searchInput.placeholder = `Search ${searchData.meta.totalItems} items...`
searchInput.focus()
} catch (error) {
console.error("Search error:", error)
searchLoading.style.display = "none"
searchError.style.display = "block"
searchInput.disabled = true
}
})
// Include search functionality (see Enhanced Search JavaScript section below)
function initializeSearch(input, results, index) {
// Search implementation here...
}
</script>
{{#else}}
<div class="search-unavailable">
<p>Search functionality is not available.</p>
</div>
{{/if}}
</div>
<script>
tags within templates — not in external JavaScript files.
Like the example above: const response = await fetch("/content{{searchIndexUrl}}")
.
Enhanced Search JavaScript
Include this search function in your template or as a separate JS file:
function initializeSearch(searchInput, searchResults, searchIndex, options = {}) {
const config = {
minQueryLength: 2,
debounceDelay: 250,
maxResults: 20,
previewLength: 150,
highlightClass: "highlight",
...options,
}
const searchCache = new Map()
function debounce(func, wait) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => func.apply(this, args), wait)
}
}
function performSearch(query) {
const trimmedQuery = query.trim()
if (!trimmedQuery || trimmedQuery.length < config.minQueryLength) {
hideResults()
return
}
// Check cache first
if (searchCache.has(trimmedQuery)) {
displayResults(searchCache.get(trimmedQuery), trimmedQuery)
return
}
const results = searchIndex
.map((item) => ({ ...item, score: calculateScore(item, trimmedQuery.toLowerCase()) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, config.maxResults)
searchCache.set(trimmedQuery, results)
displayResults(results, trimmedQuery)
}
function calculateScore(item, query) {
let score = 0
const title = item.title.toLowerCase()
const content = item.content.toLowerCase()
// Title matches (highest priority)
if (title.includes(query)) {
score += title === query ? 200 : title.startsWith(query) ? 150 : 100
}
// Content matches
if (content.includes(query)) {
score += 10 + (content.match(new RegExp(query, "g")) || []).length * 5
}
// Tag matches
if (item.tags && item.tags.some((tag) => tag.toLowerCase().includes(query))) {
score += 25
}
return score
}
function displayResults(results, query) {
const container = searchResults.querySelector(".search-results-inner")
container.innerHTML = ""
if (results.length === 0) {
container.innerHTML = `<div class="no-results">No results found for "${escapeHtml(query)}"</div>`
showResults()
return
}
results.forEach((result) => {
const item = createResultItem(result, query)
container.appendChild(item)
})
showResults()
}
function createResultItem(result, query) {
const item = document.createElement("div")
item.className = "search-result-item"
const highlightedTitle = highlightText(result.title, query)
const preview = getPreview(result.content, query)
const highlightedPreview = highlightText(preview, query)
item.innerHTML = `
<div class="result-title">${highlightedTitle}</div>
<div class="result-path">${escapeHtml(result.path)}</div>
<div class="result-preview">${highlightedPreview}</div>
<div class="result-meta">
<span class="result-type">${result.type}</span>
${result.category ? `<span class="result-category">${result.category}</span>` : ""}
</div>
`
item.addEventListener("click", () => {
window.location.href = result.path
})
return item
}
function getPreview(content, query) {
const index = content.toLowerCase().indexOf(query.toLowerCase())
let start = Math.max(0, index - config.previewLength / 2)
let end = Math.min(content.length, start + config.previewLength)
let preview = content.substring(start, end)
if (start > 0) preview = "..." + preview
if (end < content.length) preview += "..."
return preview
}
function highlightText(text, query) {
const regex = new RegExp(`(${escapeRegExp(query)})`, "gi")
return text.replace(regex, `<span class="${config.highlightClass}">$1</span>`)
}
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
function showResults() {
searchResults.style.display = "block"
}
function hideResults() {
searchResults.style.display = "none"
}
// Event listeners
searchInput.addEventListener(
"input",
debounce(function () {
performSearch(this.value)
}, config.debounceDelay)
)
document.addEventListener("click", (e) => {
if (!searchResults.contains(e.target) && e.target !== searchInput) {
hideResults()
}
})
return {
destroy() {
searchCache.clear()
},
}
}
Global Search Components
Header Search Bar
Create a reusable search widget for your header:
<!-- In your layout.html or header partial -->
<div class="header-search">
<div id="globalSearch" class="global-search-widget"></div>
</div>
<script>
class GlobalSearchWidget {
constructor(containerId) {
this.container = document.getElementById(containerId)
this.searchIndex = null
this.init()
}
async init() {
this.createHTML()
await this.loadSearchIndex()
if (this.searchIndex) this.initializeSearch()
}
createHTML() {
this.container.innerHTML = `
<div class="search-widget">
<input type="text"
class="search-input"
placeholder="Search..."
autocomplete="off">
<div class="search-dropdown" style="display: none;">
<div class="search-results"></div>
</div>
</div>
`
}
async loadSearchIndex() {
try {
// Get the current theme name from a meta tag or global variable
const themeName = document.querySelector('meta[name="theme"]')?.content || "default"
const response = await fetch(`/content/themes/${themeName}/assets/json/search-index.json`)
if (response.ok) {
const data = await response.json()
this.searchIndex = data.index || []
}
} catch (error) {
console.warn("Could not load search index:", error)
}
}
initializeSearch() {
const input = this.container.querySelector(".search-input")
const dropdown = this.container.querySelector(".search-dropdown")
const results = this.container.querySelector(".search-results")
// Use the same search logic as above, but with compact results
initializeSearch(input, dropdown, this.searchIndex, {
maxResults: 5,
previewLength: 80,
})
}
}
// Initialize global search
document.addEventListener("DOMContentLoaded", function () {
new GlobalSearchWidget("globalSearch")
})
</script>
Search Widget for Sidebars
<!-- Sidebar search widget -->
<div class="sidebar-widget search-widget">
<h3>Search Site</h3>
<div id="sidebarSearch"></div>
</div>
<script>
// Similar to global search but with different styling and behavior
document.addEventListener("DOMContentLoaded", async function () {
const container = document.getElementById("sidebarSearch")
if (!container) return
// Create compact search interface
container.innerHTML = `
<input type="text" class="sidebar-search-input" placeholder="Find content...">
<div class="sidebar-search-results" style="display: none;"></div>
`
// Load and initialize search
try {
const themeName = document.querySelector('meta[name="theme"]')?.content || "default"
const response = await fetch(`/content/themes/${themeName}/assets/json/search-index.json`)
const data = await response.json()
const input = container.querySelector(".sidebar-search-input")
const results = container.querySelector(".sidebar-search-results")
initializeSearch(input, results, data.index, {
maxResults: 3,
previewLength: 60,
debounceDelay: 300,
})
} catch (error) {
console.warn("Sidebar search not available:", error)
}
})
</script>
Styling Your Search
Basic CSS
/* Search container */
.search-container {
max-width: 600px;
margin: 0 auto;
}
/* Search input */
.search-input-wrapper {
position: relative;
margin-bottom: 1rem;
}
.search-input-wrapper input {
width: 100%;
padding: 12px 40px 12px 16px;
font-size: 16px;
border: 2px solid #ddd;
border-radius: 8px;
outline: none;
}
.search-input-wrapper input:focus {
border-color: #007cba;
}
/* Clear button */
.search-clear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #666;
}
/* Results container */
.search-results {
border: 1px solid #ddd;
border-radius: 8px;
background: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 400px;
overflow-y: auto;
}
/* Individual result */
.search-result-item {
padding: 12px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f5f5f5;
}
.search-result-item:last-child {
border-bottom: none;
}
/* Result content */
.result-title {
font-weight: bold;
margin-bottom: 4px;
color: #333;
}
.result-path {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.result-preview {
font-size: 14px;
color: #555;
line-height: 1.4;
}
.result-meta {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.result-type,
.result-category {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
}
/* Highlighting */
.highlight {
background-color: #ffeb3b;
font-weight: bold;
padding: 1px 2px;
border-radius: 2px;
}
/* States */
.search-loading,
.search-error,
.no-results {
padding: 20px;
text-align: center;
color: #666;
}
.search-stats {
text-align: center;
margin-top: 1rem;
color: #666;
font-size: 14px;
}
Content Best Practices
For Better Search Results
- Use Excerpts: Add meaningful excerpts to your content frontmatter
---
title: "Your Post Title"
excerpt: "A compelling summary that describes what this post is about and will appear in search results."
---
- SEO Descriptions: Use descriptive SEO descriptions as fallback
---
seoDescription: "Learn how to implement advanced search functionality in your Aether CMS website with this comprehensive guide."
---
- Meaningful Titles: Use descriptive, searchable titles
- Categories and Tags: Use consistent categorization for posts
- Content Structure: Organize custom pages with clear parent-child relationships
Content That Gets Indexed
Posts: All published posts with their:
- Title, content (from excerpt/seoDescription), category, tags, author, publish date
Custom Pages: All published custom pages with their:
- Title, content (from excerpt/seoDescription), parent page relationships, author, publish date
Not Indexed:
- Draft/unpublished content
- System pages
- The actual markdown content (only excerpt/seoDescription for performance)
Advanced Configuration
Custom Search Options
You can customize search behavior by passing options to initializeSearch()
:
const searchOptions = {
minQueryLength: 3, // Minimum characters to start searching
debounceDelay: 200, // Delay between keystrokes (ms)
maxResults: 15, // Maximum results to show
previewLength: 120, // Length of content preview
highlightClass: "match", // CSS class for highlighted text
caseSensitive: false, // Case sensitive search
searchInContent: true, // Search in content field
searchInTags: true, // Search in tags (posts only)
}
initializeSearch(searchInput, searchResults, searchIndex, searchOptions)
Multiple Search Interfaces
You can have multiple search interfaces on the same page:
// Header search - quick and compact
const headerSearch = initializeSearch(
document.getElementById("headerSearch"),
document.getElementById("headerResults"),
searchIndex,
{ maxResults: 5, previewLength: 60 }
)
// Main search page - comprehensive
const mainSearch = initializeSearch(
document.getElementById("mainSearch"),
document.getElementById("mainResults"),
searchIndex,
{ maxResults: 25, previewLength: 200 }
)
Troubleshooting
Search Not Working
- Check template detection: Ensure you have a
search.html
,find.html
, orlookup.html
template - Verify JSON file: Check if
/content/themes/your-theme/assets/json/search-index.json
exists - Console errors: Check browser console for JavaScript errors
- Network tab: Verify the JSON file loads successfully
Empty Search Results
- Check content: Ensure your posts/pages have
excerpt
orseoDescription
- Verify status: Only
published
content is indexed - Check frontmatter: Ensure required fields (title, slug) are present
- Restart the app: Restart the server to trigger a fresh index build (rarely needed in Aether)
Performance Issues
- Limit results: Reduce
maxResults
option - Increase debounce: Increase
debounceDelay
to reduce search frequency - Optimize content: Keep excerpts concise but meaningful
API Reference
Template Variables
Variable | Type | Description |
---|---|---|
isSearchPage |
boolean | True if current template is a search template |
hasSearchIndex |
boolean | True if search index was generated successfully |
searchIndexUrl |
string | URL to the JSON search index file |
searchStats.totalItems |
number | Total number of searchable items |
searchStats.totalPosts |
number | Number of posts in index |
searchStats.totalPages |
number | Number of pages in index |
searchStats.generatedAt |
string | ISO timestamp of index generation |
JavaScript API
`initializeSearch(input, results, index, options)`
Initializes search functionality on given elements.
Parameters:
input
(HTMLElement): Search input elementresults
(HTMLElement): Results container elementindex
(Array): Search index dataoptions
(Object): Configuration options
Returns: Object with destroy()
method for cleanup
Options:
minQueryLength
(number): Minimum query length (default: 2)debounceDelay
(number): Debounce delay in ms (default: 250)maxResults
(number): Maximum results to show (default: 20)previewLength
(number): Preview text length (default: 150)highlightClass
(string): CSS class for highlights (default: 'highlight')
Conclusion
The Aether CMS search functionality provides a powerful, flexible foundation for implementing search in your themes. Whether you need a simple search page, a global search bar, or complex search widgets, the system adapts to your needs while maintaining excellent performance and user experience.
Remember to test your search implementation thoroughly and provide meaningful content in your excerpts and SEO descriptions for the best user experience.