Search Templates and Widgets

On this page

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:

  1. Automatic Detection: The system automatically detects if your theme has search templates
  2. Smart Indexing: Generates a JSON search index containing posts and pages data
  3. 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 template
  • custom/find.html - Alternative search template
  • custom/lookup.html - Alternative search template
🚨
Important: All provided examples are functional but intended for demonstration purposes only. You're encouraged to implement your own markup, styles, and logic based on the generated Template Variables.

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>
💡
Tip: Template variables can be used inside <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

  1. 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."
---
  1. 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."
---
  1. Meaningful Titles: Use descriptive, searchable titles
  2. Categories and Tags: Use consistent categorization for posts
  3. 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

  1. Check template detection: Ensure you have a search.html, find.html, or lookup.html template
  2. Verify JSON file: Check if /content/themes/your-theme/assets/json/search-index.json exists
  3. Console errors: Check browser console for JavaScript errors
  4. Network tab: Verify the JSON file loads successfully

Empty Search Results

  1. Check content: Ensure your posts/pages have excerpt or seoDescription
  2. Verify status: Only published content is indexed
  3. Check frontmatter: Ensure required fields (title, slug) are present
  4. Restart the app: Restart the server to trigger a fresh index build (rarely needed in Aether)

Performance Issues

  1. Limit results: Reduce maxResults option
  2. Increase debounce: Increase debounceDelay to reduce search frequency
  3. 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 element
  • results (HTMLElement): Results container element
  • index (Array): Search index data
  • options (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.