fetch('index.json') .then(response => response.json()) .then(data => { document.title = data.metadata.title || 'Documentation'; }) .catch(error => { console.error('Error loading title:', error); document.title = 'Documentation'; }); class EventBus { constructor() { this.events = {}; } on(event, callback) { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(callback); return () => this.off(event, callback); } off(event, callback) { if (!this.events[event]) return; this.events[event] = this.events[event].filter(cb => cb !== callback); } emit(event, data) { if (!this.events[event]) return; this.events[event].forEach(callback => callback(data)); } } class SearchService { constructor(eventBus, indexService) { this.eventBus = eventBus; this.indexService = indexService; this.searchIndex = []; } buildSearchIndex(documents) { this.searchIndex = []; this.processDocuments(documents); } processDocuments(documents, parentPath = '') { documents.forEach(doc => { if (doc.type === 'folder') { const currentPath = parentPath ? `${parentPath} / ${doc.title}` : doc.title; if (doc.path) { if (doc.showfolderpage !== 'false') { this.searchIndex.push({ title: doc.title, path: doc.path, slug: doc.slug, location: currentPath, type: 'folder' }); } if (doc.headers) { doc.headers.forEach(header => { this.searchIndex.push({ title: header, path: doc.path, slug: `${doc.slug}#${header.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')}`, location: `${currentPath} / ${doc.title}`, type: 'header' }); }); } } if (doc.items) { this.processDocuments(doc.items, currentPath); } } else { this.searchIndex.push({ title: doc.title, path: doc.path, slug: doc.slug, location: parentPath, type: 'file' }); if (doc.headers) { doc.headers.forEach(header => { this.searchIndex.push({ title: header, path: doc.path, slug: `${doc.slug}#${header.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')}`, location: `${parentPath} / ${doc.title}`, type: 'header' }); }); } } }); } search(query) { if (!query) return []; query = query.toLowerCase(); return this.searchIndex .map(item => { const titleLower = item.title.toLowerCase(); let score = 0; if (titleLower === query) score = 100; else if (titleLower.startsWith(query)) score = 80; else if (titleLower.includes(query)) score = 60; else if (item.path.toLowerCase().includes(query)) score = 40; else if (item.location.toLowerCase().includes(query)) score = 20; if (item.type === 'header') score += 5; return { ...item, score }; }) .filter(item => item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 10); } } class IndexService { findDocumentBySlug(documents, slug) { for (const doc of documents) { if (doc.slug === slug) return doc; if (doc.type === 'folder') { const found = this.findDocumentBySlug(doc.items, slug); if (found) return found; } } return null; } findDocumentByTitle(documents, title) { for (const doc of documents) { if (doc.type === 'folder') { const found = this.findDocumentByTitle(doc.items, title); if (found) return found; } else if (doc.title === title || doc.path.endsWith(title + '.md') || doc.slug === title.toLowerCase().replace(/ /g, '-')) { return doc; } } return null; } findParentFolders(documents, path, parentFolders = []) { for (const doc of documents) { if (doc.type === 'folder') { const found = doc.items.find(item => { if (item.path === path) return true; if (item.type === 'folder') { return this.findParentFolders([item], path).length > 0; } return false; }); if (found) { parentFolders.push(doc); doc.items.forEach(item => { if (item.type === 'folder') { this.findParentFolders([item], path, parentFolders); } }); } } } return parentFolders; } } class DOMService { constructor(eventBus) { this.eventBus = eventBus; this.elements = { content: document.getElementById('document-content'), outline: document.getElementById('document-outline'), fileIndex: document.getElementById('file-index'), titleText: document.querySelector('.title-text .page-title'), leftSidebar: document.querySelector('.left-sidebar'), menuButton: document.querySelector('.menu-button'), header: document.querySelector('title-bar'), searchInput: document.getElementById('search-input'), searchResults: document.getElementById('search-results'), clearSearch: document.getElementById('clear-search') }; this.headerOffset = 60; } setupMobileMenu() { this.elements.menuButton.addEventListener('click', () => { const isExpanded = this.elements.leftSidebar.classList.toggle('show'); this.elements.menuButton.setAttribute('aria-expanded', isExpanded ? 'true' : 'false'); }); this.elements.content.addEventListener('click', () => this.elements.leftSidebar.classList.remove('show')); } setContent(html) { this.elements.content.innerHTML = html; this.elements.content.className = 'markdown-content'; hljs.highlightAll(); } setTitle(title) { document.title = `${window.originalDocTitle} / ${title}`; this.elements.titleText.textContent = title; } setError(message) { this.elements.content.innerHTML = `
${highlighted}`;
}
extractMetadata(content) {
const lines = content.trim().split('\n');
let metadata = {};
let contentStart = 0;
if (lines[0].trim() === '---') {
let endMetadata = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
if (endMetadata !== -1) {
metadata = Object.fromEntries(
lines.slice(1, endMetadata)
.map(line => line.match(/^([\w-]+):\s*(.*)$/))
.filter(Boolean)
.map(([, key, value]) => [key, value.trim()])
);
contentStart = endMetadata + 1;
}
}
return {
metadata,
content: lines.slice(contentStart).join('\n').trim()
};
}
async loadDocument(path) {
try {
const [response, marked] = await Promise.all([
fetch(path),
this.markedPromise
]);
let rawContent = await response.text();
const { metadata, content } = this.extractMetadata(rawContent);
const basePath = path.substring(0, path.lastIndexOf('/'));
const indexDoc = this.findDocInIndex(path);
let processedContent = this.processWikiLinks(content);
processedContent = this.processImages(processedContent, basePath);
const titleContent = metadata.title || indexDoc?.title || path.split('/').pop().replace('.md', '');
processedContent = this.ensureTitle(processedContent, titleContent);
return {
content: processedContent,
metadata,
marked,
title: titleContent
};
} catch (error) {
throw new Error('Failed to load document');
}
}
findDocInIndex(path) {
let doc = window._indexData.documents.find(d => d.path === path);
if (!doc) {
for (const d of window._indexData.documents) {
if (d.type === 'folder' && d.items) {
doc = d.items.find(item => item.path === path);
if (doc) break;
}
}
}
return doc;
}
ensureTitle(content, title) {
content = content.replace(/^#\s+.*$/m, '').trim();
return `# ${title}\n\n${content}`;
}
processWikiLinks(content) {
return content.replace(/\[\[(.*?)\]\]/g, (match, linkText) => {
const [targetTitle, displayText] = linkText.split('|').map(s => s.trim());
if (targetTitle.match(/\.(png|jpg|jpeg|gif|mp4|webm)$/i)) {
return match;
}
const doc = this.indexService.findDocumentByTitle(
window._indexData.documents,
targetTitle
);
return doc ? `[${displayText || doc.title}](?${doc.slug})` : match;
});
}
processImages(content, basePath) {
return content.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
const mediaPath = `./docs/images/${filename}`;
if (filename.toLowerCase().endsWith('.mp4')) {
return `\n\n\n`;
}
return `\n\n\n`;
});
}
}
class NavigationService {
constructor(eventBus, documentService) {
this.eventBus = eventBus;
this.documentService = documentService;
this.setupEventListeners();
}
setupEventListeners() {
window.addEventListener('popstate', async () => {
const search = window.location.search;
const hash = window.location.hash;
const slug = (search === '' || search === '?') ?
window._indexData.defaultPage :
search.replace(/^\?/, '').split('#')[0];
this.eventBus.emit('navigation:requested', { slug, hash });
});
document.addEventListener('click', async (e) => {
const target = e.target.closest('a[data-internal="true"]');
if (target) {
e.preventDefault();
const slug = target.href.split('?').pop();
history.pushState(null, '', `?${slug}`);
this.eventBus.emit('navigation:requested', { slug });
}
});
}
}
class Documentation {
constructor() {
this.eventBus = new EventBus();
this.indexService = new IndexService();
this.searchService = new SearchService(this.eventBus, this.indexService);
this.domService = new DOMService(this.eventBus);
this.documentService = new DocumentService(this.eventBus, this.indexService);
this.navigationService = new NavigationService(this.eventBus, this.documentService);
this.setupEventListeners();
this.setupKeyboardShortcuts();
}
setupEventListeners() {
this.eventBus.on('navigation:requested', async ({ slug, hash }) => {
slug = slug.replace(/^[?=]/, '');
await this.loadDocumentBySlug(slug, hash);
});
this.eventBus.on('document:load', async ({ path }) => {
await this.loadDocument(path);
});
window.addEventListener('load', () => {
this.domService.setupMobileMenu();
this.initialize();
});
document.addEventListener('click', async (e) => {
const target = e.target.closest('a[data-internal="true"]');
if (target) {
e.preventDefault();
const slug = target.href.split('?').pop();
history.pushState(null, '', target.href);
await this.loadDocumentBySlug(slug);
}
});
}
setupKeyboardShortcuts() {
// Add keyboard shortcut for search (S key)
document.addEventListener('keydown', (e) => {
// Only trigger if user is not typing in an input, textarea, or contenteditable element
const isTyping = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) ||
document.activeElement.isContentEditable;
// Search shortcut (S key or Alt+S)
if (((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.metaKey &&
(!e.altKey || (e.altKey && (e.key === 's' || e.key === 'S')))) &&
!isTyping) {
e.preventDefault();
// Focus the search input
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
// If sidebar is closed on mobile, open it
const leftSidebar = document.querySelector('.left-sidebar');
const menuButton = document.querySelector('.menu-button');
if (window.innerWidth <= 1000 && leftSidebar &&
!leftSidebar.classList.contains('show')) {
leftSidebar.classList.add('show');
if (menuButton) {
menuButton.setAttribute('aria-expanded', 'true');
}
}
}
}
// Navigation with Alt+Up/Down to move between pages
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && e.altKey && !isTyping) {
e.preventDefault();
// Get all links from the file index
const fileLinks = Array.from(document.querySelectorAll('#file-index a'));
if (fileLinks.length === 0) return;
// Find the current active link
const activeLink = document.querySelector('#file-index a.active');
if (!activeLink) return;
// Find the index of the active link
const activeIndex = fileLinks.indexOf(activeLink);
if (activeIndex === -1) return;
// Determine the target link based on direction
let targetLink;
if (e.key === 'ArrowDown') {
// Go to next link (or first if at the end)
targetLink = activeIndex < fileLinks.length - 1 ?
fileLinks[activeIndex + 1] :
fileLinks[0];
} else {
// Go to previous link (or last if at the beginning)
targetLink = activeIndex > 0 ?
fileLinks[activeIndex - 1] :
fileLinks[fileLinks.length - 1];
}
// Navigate to the target page
if (targetLink) {
// Simulate a click on the link
targetLink.click();
// Ensure the target link is visible in the sidebar
targetLink.scrollIntoView({ block: 'nearest' });
}
}
});
}
async initialize() {
try {
const response = await fetch('index.json');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
this.indexData = data;
window._indexData = data;
this.searchService.buildSearchIndex(this.indexData.documents);
this.domService.setupSearch(this.searchService);
this.populateAuthorInfo(data.author);
window.originalDocTitle = data.metadata.site_name || 'Documentation';
document.title = window.originalDocTitle;
this.domService.elements.fileIndex.innerHTML = '';
this.indexData.documents.forEach(doc =>
this.domService.createFileIndexItem(doc, this.domService.elements.fileIndex));
const search = window.location.search;
const slug = search === '' || search === '?'
? this.indexData.defaultPage
: search.replace(/^\?/, '');
await this.loadDocumentBySlug(slug);
if (window.location.hash) {
setTimeout(() => {
const element = document.getElementById(window.location.hash.slice(1));
if (element) this.domService.scrollToElement(element);
}, 100);
}
} catch (error) {
this.domService.setError(`Failed to load documentation index: ${error.message}`);
}
}
populateAuthorInfo(author) {
const subtitleName = document.querySelector('.name');
const subtitleRole = document.querySelector('.role');
if (!subtitleName || !subtitleRole) return;
subtitleName.textContent = author.name || '';
subtitleRole.textContent = author.role || '';
const socials = document.querySelector('.social-links');
if (socials) {
socials.innerHTML = '';
if (author.socials) {
author.socials.forEach(s => {
const link = document.createElement('a');
link.href = s.url;
link.target = '_blank';
link.title = s.title;
link.innerHTML = ``;
socials.appendChild(link);
});
}
}
}
async loadDocumentBySlug(slug, hash) {
const [baseSlug] = slug.split('#');
const doc = this.indexService.findDocumentBySlug(this.indexData.documents, baseSlug);
if (!doc) {
this.domService.setError(`Document not found: ${baseSlug}`);
return;
}
if (doc.type === 'folder') {
if (doc.path) {
await this.loadDocument(doc.path, hash);
} else if (doc.items?.length > 0) {
await this.loadDocument(doc.items[0].path, hash);
} else {
this.domService.setError('This folder is empty.');
}
return;
}
await this.loadDocument(doc.path, hash);
}
async loadDocument(path, hash, fromSearch = false) {
try {
const { content, metadata, marked, title } = await this.documentService.loadDocument(path);
this.domService.setTitle(title);
this.domService.setContent(marked.parse(content));
this.domService.updateActiveDocument(path);
const headings = document.querySelectorAll('h2, h3, h4, h5, h6');
const headingLinks = this.domService.createOutline(headings);
headings.forEach(heading => {
heading.addEventListener('click', (e) => {
if (e.target.closest('svg') || e.target.closest('.header-anchor')) return;
const id = heading.id;
if (id) {
history.pushState(null, '', `${window.location.pathname}${window.location.search}#${id}`);
this.domService.scrollToElement(heading);
heading.classList.remove('highlight');
void heading.offsetWidth;
heading.classList.add('highlight');
}
});
});
this.setupScrollObserver(headings, headingLinks);
window._currentPath = path;
if (hash) {
const element = document.getElementById(hash.slice(1));
if (element) {
const delay = fromSearch ? 300 : 100;
setTimeout(() => {
this.domService.scrollToElement(element);
element.classList.remove('highlight');
void element.offsetWidth;
element.classList.add('highlight');
}, delay);
}
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (error) {
this.domService.setError('Error loading document. Please try again.');
}
}
setupScrollObserver(headings, headingLinks) {
const observer = new IntersectionObserver(
(entries) => {
const visibleHeadings = entries
.filter(entry => entry.isIntersecting)
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
if (visibleHeadings.length) {
this.domService.elements.outline
.querySelectorAll('a')
.forEach(a => a.classList.remove('active'));
const link = headingLinks.get(visibleHeadings[0].target);
if (link) link.classList.add('active');
}
},
{
rootMargin: '-48px 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
headings.forEach(heading => observer.observe(heading));
return observer;
}
}
window.originalDocTitle = document.title;
const docs = new Documentation();