Using JavaScript in Stati
Stati is a static site generator, but that doesn’t mean your sites have to be purely static. You can add interactivity and dynamic behavior using JavaScript in three main ways: inline scripts in templates, external JavaScript files, and build-time JavaScript via hooks.
Table of Contents
- Method 1: Inline Scripts in Templates
- Method 2: External JavaScript Files
- Method 3: Build Hooks (Build-Time JavaScript)
- Comparison & Best Practices
Method 1: Inline Scripts in Templates
The most straightforward way to add JavaScript is by including inline <script> tags directly in your Eta templates. This is ideal for small, page-specific scripts that enhance the user experience.
When to Use
- Small interactive features: Scroll-to-top buttons, mobile navigation toggles, etc.
- Page-specific behavior: Form validation, animations specific to one page
- Quick enhancements: When you don’t need a separate file
Example: Scroll to Top Button
In your layout.eta template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= stati.page.title %></title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main>
<%~ stati.content %>
</main>
<!-- Scroll to top button -->
<button id="scroll-to-top" class="scroll-btn hidden">
↑ Back to Top
</button>
<script>
// Scroll to top functionality
const scrollBtn = document.getElementById('scroll-to-top');
window.addEventListener('scroll', () => {
if (window.pageYOffset > 300) {
scrollBtn.classList.remove('hidden');
} else {
scrollBtn.classList.add('hidden');
}
});
scrollBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
</script>
</body>
</html>
Example: Mobile Navigation Toggle
<!-- Header with mobile menu -->
<header>
<nav>
<button id="menu-toggle" class="mobile-menu-btn">
Menu
</button>
<ul id="nav-menu" class="nav-menu">
<li><a href="/">Home</a></li>
<li><a href="/docs/">Docs</a></li>
<li><a href="/blog/">Blog</a></li>
</ul>
</nav>
</header>
<script>
// Mobile menu toggle
const menuToggle = document.getElementById('menu-toggle');
const navMenu = document.getElementById('nav-menu');
menuToggle.addEventListener('click', () => {
navMenu.classList.toggle('active');
menuToggle.setAttribute(
'aria-expanded',
navMenu.classList.contains('active')
);
});
// Close menu on outside click
document.addEventListener('click', (e) => {
if (!menuToggle.contains(e.target) && !navMenu.contains(e.target)) {
navMenu.classList.remove('active');
menuToggle.setAttribute('aria-expanded', 'false');
}
});
</script>
Example: Loading External Libraries
You can also use inline scripts to initialize external libraries:
<head>
<!-- Load particles.js library -->
<script src="https://cdn.jsdelivr.net/particles.js/2.0.0/particles.min.js" defer></script>
</head>
<body>
<div id="particles-js"></div>
<script>
// Wait for library to load
window.addEventListener('DOMContentLoaded', () => {
if (typeof particlesJS !== 'undefined') {
particlesJS('particles-js', {
particles: {
number: { value: 80 },
color: { value: '#ffffff' },
shape: { type: 'circle' },
opacity: { value: 0.5 },
size: { value: 3 }
}
});
}
});
</script>
</body>
Inline Script Advantages
- ✅ Simple and direct
- ✅ No build step required
- ✅ Easy to understand and maintain
- ✅ Perfect for small scripts
- ✅ Can use Eta template variables in scripts
Inline Script Considerations
- ⚠️ Can clutter templates if scripts get large
- ⚠️ Not easily reusable across pages
- ⚠️ No module system (unless using ES modules)
- ⚠️ Increases HTML file size
Method 2: External JavaScript Files
For larger scripts or reusable functionality, place your JavaScript in separate files within the public/ directory. Stati will copy these files to your build output automatically.
When to Use External Files
- Large scripts: Any JavaScript over 20-30 lines
- Reusable functionality: Code used across multiple pages
- Third-party integrations: Analytics, chat widgets, etc.
- Module-based code: When you want to use ES modules
- Separation of concerns: Keep templates clean and focused
File Structure
my-stati-site/
├── public/
│ ├── js/
│ │ ├── main.js # Main site JavaScript
│ │ ├── analytics.js # Analytics tracking
│ │ ├── search.js # Search functionality
│ │ └── theme-switcher.js # Dark mode toggle
│ └── styles.css
└── site/
├── layout.eta
└── index.md
Example: Theme Switcher
Create public/js/theme-switcher.js:
// Theme switcher with localStorage persistence
(function() {
const STORAGE_KEY = 'stati-theme';
const DARK_CLASS = 'dark';
// Get saved theme or default to light
function getTheme() {
return localStorage.getItem(STORAGE_KEY) || 'light';
}
// Save theme preference
function saveTheme(theme) {
localStorage.setItem(STORAGE_KEY, theme);
}
// Apply theme to document
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add(DARK_CLASS);
} else {
document.documentElement.classList.remove(DARK_CLASS);
}
}
// Toggle between themes
function toggleTheme() {
const currentTheme = getTheme();
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
saveTheme(newTheme);
applyTheme(newTheme);
return newTheme;
}
// Initialize theme on page load
function init() {
const theme = getTheme();
applyTheme(theme);
// Set up toggle button
const toggleBtn = document.getElementById('theme-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const newTheme = toggleTheme();
toggleBtn.setAttribute('aria-label',
`Switch to ${newTheme === 'light' ? 'dark' : 'light'} mode`
);
});
}
}
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
Include in your layout.eta:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><%= stati.page.title %></title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
<button id="theme-toggle" aria-label="Switch to dark mode">
🌙 Toggle Theme
</button>
</header>
<main>
<%~ stati.content %>
</main>
<!-- Load external script -->
<script src="/js/theme-switcher.js"></script>
</body>
</html>
Example: Search Functionality
Create public/js/search.js:
// Client-side search using Fuse.js
class SiteSearch {
constructor(searchIndex) {
this.fuse = new Fuse(searchIndex, {
keys: ['title', 'description', 'content'],
threshold: 0.3,
includeMatches: true,
minMatchCharLength: 2
});
}
search(query) {
if (!query || query.trim().length < 2) {
return [];
}
return this.fuse.search(query).slice(0, 10);
}
renderResults(results, container) {
if (results.length === 0) {
container.innerHTML = '<p class="no-results">No results found</p>';
return;
}
const html = results.map(result => `
<article class="search-result">
<h3><a href="${result.item.url}">${result.item.title}</a></h3>
<p>${result.item.description}</p>
</article>
`).join('');
container.innerHTML = html;
}
}
// Initialize search when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (!searchInput || !searchResults) return;
// Load search index
const response = await fetch('/search-index.json');
const searchIndex = await response.json();
const search = new SiteSearch(searchIndex);
// Handle search input
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const results = search.search(e.target.value);
search.renderResults(results, searchResults);
}, 300);
});
});
Example: Using ES Modules
Create public/js/modules/utils.js:
// Utility functions module
export function formatDate(date, locale = 'en-US') {
return new Date(date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
export function slugify(text) {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
Create public/js/main.js:
// Main application entry point
import { formatDate, debounce, slugify } from './modules/utils.js';
class StatiApp {
constructor() {
this.init();
}
init() {
this.setupDateFormatting();
this.setupSearch();
this.setupNavigation();
}
setupDateFormatting() {
document.querySelectorAll('[data-timestamp]').forEach(el => {
const timestamp = el.dataset.timestamp;
el.textContent = formatDate(timestamp);
});
}
setupSearch() {
const searchInput = document.getElementById('search');
if (searchInput) {
searchInput.addEventListener('input',
debounce(this.handleSearch.bind(this), 300)
);
}
}
handleSearch(event) {
const query = event.target.value;
console.log('Searching for:', query);
// Implement search logic
}
setupNavigation() {
// Add active states, smooth scrolling, etc.
}
}
// Initialize app
new StatiApp();
Include in your template with module type:
<script type="module" src="/js/main.js"></script>
External File Advantages
- ✅ Clean separation of concerns
- ✅ Reusable across multiple pages
- ✅ Better for version control and code review
- ✅ Can use modern JavaScript features and modules
- ✅ Easier to test and debug
- ✅ Can be minified and bundled
External File Considerations
- ⚠️ Requires separate HTTP request (can be mitigated with HTTP/2 or bundling)
- ⚠️ Cannot directly access Eta template variables
- ⚠️ Need to manage script loading order
Method 3: Build Hooks (Build-Time JavaScript)
The most powerful way to use JavaScript in Stati is through build hooks in your stati.config.js. These hooks run during the build process and can modify content, fetch data, generate files, and more.
When to Use Build Hooks
- Data fetching: Load data from APIs at build time
- Content transformation: Process markdown, images, etc.
- Dynamic page generation: Create pages programmatically
- Integration with external services: CMS, analytics, databases
- Build-time calculations: Reading time, related posts, etc.
Hook Types
Stati provides four main hooks:
beforeAll- Runs once before the build startsbeforeRender- Runs before rendering each pageafterRender- Runs after rendering each pageafterAll- Runs once after the build completes
Example: Fetch Data at Build Time
In your stati.config.js:
import { defineConfig } from '@stati/core';
export default defineConfig({
site: {
title: 'My Stati Site',
baseUrl: 'https://example.com'
},
hooks: {
// Fetch data before building
beforeAll: async (ctx) => {
console.log('Fetching external data...');
// Fetch from API
const response = await fetch('https://api.example.com/products');
const products = await response.json();
// Store in context for use in pages
ctx.globalData = { products };
console.log(`Loaded ${products.length} products`);
}
}
});
Access the data in your templates:
<h1>Our Products</h1>
<div class="products">
<% stati.globalData.products.forEach(product => { %>
<article class="product">
<h2><%= product.name %></h2>
<p><%= product.description %></p>
<span class="price">$<%= product.price %></span>
</article>
<% }); %>
</div>
Example: Calculate Reading Time
export default defineConfig({
hooks: {
beforeRender: async (ctx) => {
// Calculate reading time for blog posts
if (ctx.page.url.startsWith('/blog/')) {
const wordCount = ctx.page.content.split(/\s+/).length;
const readingTime = Math.ceil(wordCount / 200); // 200 words per minute
// Add to page front matter
ctx.page.frontMatter.readingTime = readingTime;
ctx.page.frontMatter.wordCount = wordCount;
}
}
}
});
Use in your template:
<% if (stati.page.readingTime) { %>
<p class="meta">
<%= stati.page.readingTime %> min read • <%= stati.page.wordCount %> words
</p>
<% } %>
Example: Generate Related Posts
export default defineConfig({
hooks: {
beforeRender: async (ctx) => {
if (ctx.page.url.startsWith('/blog/')) {
// Get all blog posts
const blogPosts = ctx.pages
.filter(p => p.url.startsWith('/blog/') && p.url !== ctx.page.url)
.sort((a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date));
// Find related posts by tags
const currentTags = ctx.page.frontMatter.tags || [];
const relatedPosts = blogPosts
.filter(post => {
const postTags = post.frontMatter.tags || [];
return postTags.some(tag => currentTags.includes(tag));
})
.slice(0, 3);
ctx.page.frontMatter.relatedPosts = relatedPosts;
}
}
}
});
Display related posts:
<% if (stati.relatedPosts && stati.relatedPosts.length > 0) { %>
<aside class="related-posts">
<h2>Related Posts</h2>
<ul>
<% stati.relatedPosts.forEach(post => { %>
<li>
<a href="<%= post.url %>">
<%= post.frontMatter.title %>
</a>
</li>
<% }); %>
</ul>
</aside>
<% } %>
Example: Generate Search Index
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export default defineConfig({
hooks: {
afterAll: async (ctx) => {
console.log('Generating search index...');
// Create search index from all pages
const searchIndex = ctx.pages.map(page => ({
title: page.frontMatter.title || 'Untitled',
description: page.frontMatter.description || '',
url: page.url,
content: page.content
.replace(/<[^>]*>/g, '') // Strip HTML
.substring(0, 500) // First 500 chars
}));
// Write to public directory
const indexPath = join(ctx.config.outDir, 'search-index.json');
await writeFile(indexPath, JSON.stringify(searchIndex, null, 2));
console.log(`Generated search index with ${searchIndex.length} entries`);
}
}
});
Example: Integration with CMS
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
// Fetch content from headless CMS
const cmsContent = await fetchFromCMS();
// Generate markdown files
for (const item of cmsContent) {
const markdown = `---
title: ${item.title}
date: ${item.date}
author: ${item.author}
---
${item.content}
`;
const filePath = join(
ctx.config.siteDir,
'blog',
`${item.slug}.md`
);
await writeFile(filePath, markdown);
}
console.log(`Generated ${cmsContent.length} posts from CMS`);
}
}
});
async function fetchFromCMS() {
const response = await fetch('https://cms.example.com/api/posts', {
headers: {
'Authorization': `Bearer ${process.env.CMS_TOKEN}`
}
});
return response.json();
}
Build Hook Advantages
- ✅ Most powerful option
- ✅ Can fetch and process data at build time
- ✅ Generate dynamic content from external sources
- ✅ Full access to Node.js APIs
- ✅ Pre-render data that would otherwise require client-side fetching
- ✅ Better performance (no runtime data fetching)
- ✅ Better SEO (content is in the HTML)
Build Hook Considerations
- ⚠️ Runs at build time, not runtime
- ⚠️ Changes require rebuilding
- ⚠️ Can slow down builds if doing heavy processing
- ⚠️ Requires understanding of the build pipeline
Comparison & Best Practices
Quick Reference Table
| Method | Best For | Performance | Complexity | Runs At |
|---|---|---|---|---|
| Inline Scripts | Small UI enhancements, page-specific logic | Good (inline) | Low | Runtime (browser) |
| External Files | Reusable functionality, large scripts | Good (cached) | Medium | Runtime (browser) |
| Build Hooks | Data fetching, content generation, SEO | Excellent (build-time) | High | Build time (Node.js) |
When to Use Each Method
Use Inline Scripts when:
- Adding small interactive features (less than 30 lines)
- Implementing page-specific behavior
- Need quick access to Eta template variables
- Don’t need to reuse the code
Use External Files when:
- Building reusable components
- Working with larger scripts
- Want to use ES modules
- Need better code organization
- Want to leverage browser caching
Use Build Hooks when:
- Fetching data from APIs
- Generating pages programmatically
- Processing content at build time
- Need access to all pages/content
- Want to optimize performance
Best Practices
Performance
-
Load scripts efficiently:
<!-- Defer non-critical scripts --> <script src="/js/analytics.js" defer></script> <!-- Use async for independent scripts --> <script src="/js/ads.js" async></script> <!-- Inline critical scripts --> <script>/* Critical code here */</script> -
Minimize and bundle in production:
// Use a bundler like esbuild or rollup // in your build process -
Use build hooks to pre-compute expensive operations:
// Calculate at build time instead of runtime hooks: { beforeRender: async (ctx) => { ctx.page.frontMatter.processedData = expensiveCalculation(ctx.page.content); } }
Organization
-
Keep related code together:
public/ ├── js/ │ ├── main.js │ ├── components/ │ │ ├── search.js │ │ ├── navigation.js │ │ └── theme.js │ └── utils/ │ ├── dom.js │ └── format.js -
Use consistent naming conventions:
// Use descriptive names public/js/theme-switcher.js // Good public/js/script.js // Bad -
Document your build hooks:
export default defineConfig({ hooks: { /** * Fetches latest blog posts from external API * and generates static pages at build time */ beforeAll: async (ctx) => { // Implementation } } });
Security
-
Sanitize user input:
function sanitize(html) { const div = document.createElement('div'); div.textContent = html; return div.innerHTML; } -
Use Content Security Policy:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.example.com;"> -
Validate data in build hooks:
beforeAll: async (ctx) => { const data = await fetchExternalData(); // Validate before using if (!isValidData(data)) { throw new Error('Invalid data from API'); } ctx.globalData = data; }
Combining Methods
You can (and should) use multiple methods together:
// stati.config.js - Build hooks for data
export default defineConfig({
hooks: {
beforeAll: async (ctx) => {
// Fetch blog posts
ctx.globalData = await fetchBlogPosts();
},
beforeRender: async (ctx) => {
// Calculate reading time
ctx.page.frontMatter.readingTime =
calculateReadingTime(ctx.page.content);
}
}
});
<!-- layout.eta - Mix of inline and external -->
<!DOCTYPE html>
<html>
<head>
<title><%= stati.page.title %></title>
<!-- External reusable code -->
<script src="/js/theme-switcher.js" defer></script>
<script src="/js/analytics.js" async></script>
</head>
<body>
<!-- Use data from build hooks -->
<nav>
<% stati.globalData.blogPosts.slice(0, 5).forEach(post => { %>
<a href="<%= post.url %>"><%= post.title %></a>
<% }); %>
</nav>
<!-- Inline script for page-specific behavior -->
<script>
// Highlight current nav item
const currentPath = window.location.pathname;
document.querySelectorAll('nav a').forEach(link => {
if (link.getAttribute('href') === currentPath) {
link.classList.add('active');
}
});
</script>
</body>
</html>
Summary
Stati gives you three powerful ways to use JavaScript:
- Inline Scripts - Perfect for small, page-specific enhancements
- External Files - Best for reusable, well-organized code
- Build Hooks - Ideal for data fetching and build-time processing
Choose the method that best fits your needs, and don’t hesitate to combine them for maximum effectiveness. The key is to use build hooks for anything that can be computed at build time, external files for reusable functionality, and inline scripts for small page-specific features.
For more information: