rp-markdown-docs
Version:
A modern, beautiful documentation generator that converts markdown files into interactive HTML documentation sites
337 lines (289 loc) • 12.5 kB
JavaScript
// Modern Documentation App
class DocumentationApp {
constructor() {
this.config = window.__DOCS_CONFIG__ || {};
this.currentPage = null;
this.searchIndex = [];
this.darkMode = localStorage.getItem('darkMode') === 'true';
this.init();
}
async init() {
this.setupDOM();
this.setupEventListeners();
this.setupTheme();
await this.loadSearchIndex();
this.handleRouting();
// Remove loading screen
const loading = document.querySelector('.loading');
if (loading) {
loading.style.opacity = '0';
setTimeout(() => loading.remove(), 300);
}
}
setupDOM() {
const root = document.getElementById('root');
root.innerHTML = `
<div class="docs-container">
<header class="docs-header">
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="btn btn-ghost mobile-menu-btn" style="display: none;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 32px; height: 32px; background: linear-gradient(135deg, var(--primary-500), var(--accent-500)); border-radius: 8px; display: flex; align-items: center; justify-content: center;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
</div>
<div>
<h1 style="margin: 0; font-size: 1.25rem; font-weight: 700;">${this.config.title || 'Documentation'}</h1>
<p style="margin: 0; font-size: 0.75rem; color: var(--neutral-500);">Technical Reference</p>
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<button class="btn btn-ghost theme-toggle">
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: none;">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</div>
</header>
<div style="display: flex; flex: 1; overflow: hidden;">
<aside class="docs-sidebar">
<div style="padding: 1.5rem;">
<div style="margin-bottom: 1.5rem;">
<input type="text" class="search-input" placeholder="Search documentation..." />
</div>
<nav class="docs-nav"></nav>
</div>
</aside>
<main class="docs-content">
<div class="content-area"></div>
</main>
</div>
</div>
<div class="mobile-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 30;"></div>
`;
}
setupEventListeners() {
// Theme toggle
document.querySelector('.theme-toggle').addEventListener('click', () => {
this.toggleTheme();
});
// Mobile menu
const mobileMenuBtn = document.querySelector('.mobile-menu-btn');
const sidebar = document.querySelector('.docs-sidebar');
const overlay = document.querySelector('.mobile-overlay');
mobileMenuBtn.addEventListener('click', () => {
sidebar.classList.toggle('open');
overlay.style.display = sidebar.classList.contains('open') ? 'block' : 'none';
});
overlay.addEventListener('click', () => {
sidebar.classList.remove('open');
overlay.style.display = 'none';
});
// Search
const searchInput = document.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
this.handleSearch(e.target.value);
});
// Handle responsive
this.handleResize();
window.addEventListener('resize', () => this.handleResize());
// Handle navigation
window.addEventListener('popstate', () => this.handleRouting());
}
setupTheme() {
document.documentElement.classList.toggle('dark', this.darkMode);
this.updateThemeIcon();
}
toggleTheme() {
this.darkMode = !this.darkMode;
localStorage.setItem('darkMode', this.darkMode.toString());
document.documentElement.classList.toggle('dark', this.darkMode);
this.updateThemeIcon();
}
updateThemeIcon() {
const sunIcon = document.querySelector('.sun-icon');
const moonIcon = document.querySelector('.moon-icon');
if (this.darkMode) {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
} else {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
}
}
handleResize() {
const mobileMenuBtn = document.querySelector('.mobile-menu-btn');
const isMobile = window.innerWidth < 1024;
mobileMenuBtn.style.display = isMobile ? 'block' : 'none';
if (!isMobile) {
document.querySelector('.docs-sidebar').classList.remove('open');
document.querySelector('.mobile-overlay').style.display = 'none';
}
}
async loadSearchIndex() {
try {
const response = await fetch('./search-index.json');
this.searchIndex = await response.json();
} catch (error) {
console.warn('Search index not found');
this.searchIndex = this.config.pages || [];
}
}
handleSearch(query) {
const nav = document.querySelector('.docs-nav');
if (!query.trim()) {
this.renderNavigation();
return;
}
const results = this.searchIndex.filter(page =>
page.title.toLowerCase().includes(query.toLowerCase()) ||
page.content.toLowerCase().includes(query.toLowerCase()) ||
(page.tags && page.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase())))
);
nav.innerHTML = `
<div style="margin-bottom: 1rem;">
<h3 style="margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--neutral-700);">
Search Results (${results.length})
</h3>
</div>
${results.map(page => `
<a href="${page.path}" class="nav-item" data-page="${page.id}">
<div style="font-weight: 500;">${this.highlightText(page.title, query)}</div>
${page.content ? `<div style="font-size: 0.75rem; color: var(--neutral-500); margin-top: 0.25rem;">${this.highlightText(page.content.substring(0, 100), query)}...</div>` : ''}
</a>
`).join('')}
`;
this.attachNavListeners();
}
highlightText(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<mark style="background: var(--primary-200); color: var(--primary-800);">$1</mark>');
}
renderNavigation() {
const nav = document.querySelector('.docs-nav');
nav.innerHTML = this.renderNavItems(this.config.navigation || []);
this.attachNavListeners();
}
renderNavItems(items, depth = 0) {
return items.map(item => {
const hasChildren = item.children && item.children.length > 0;
const isActive = this.currentPage === item.id;
return `
<div>
<a href="${item.path || '#'}" class="nav-item ${isActive ? 'active' : ''}" data-page="${item.id}" style="padding-left: ${depth * 1 + 1}rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
${hasChildren ?
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9,18 15,12 9,6"></polyline></svg>' :
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14,2 14,8 20,8"></polyline></svg>'
}
<span>${item.title}</span>
</div>
</a>
${hasChildren ? `<div style="margin-left: 1rem;">${this.renderNavItems(item.children, depth + 1)}</div>` : ''}
</div>
`;
}).join('');
}
attachNavListeners() {
document.querySelectorAll('.nav-item').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const pageId = link.dataset.page;
this.navigateToPage(pageId);
// Close mobile menu
if (window.innerWidth < 1024) {
document.querySelector('.docs-sidebar').classList.remove('open');
document.querySelector('.mobile-overlay').style.display = 'none';
}
});
});
}
navigateToPage(pageId) {
const page = this.findPage(pageId);
if (!page) return;
this.currentPage = pageId;
history.pushState({ pageId }, page.title, page.path);
this.renderPage(page);
this.updateActiveNav();
}
findPage(pageId) {
return this.config.pages?.find(page => page.id === pageId);
}
renderPage(page) {
const contentArea = document.querySelector('.content-area');
if (!page) {
contentArea.innerHTML = `
<div style="text-align: center; padding: 4rem 2rem;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="color: var(--neutral-400); margin-bottom: 1.5rem;">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
</svg>
<h2 style="color: var(--neutral-700); margin-bottom: 0.5rem;">Welcome to Documentation</h2>
<p style="color: var(--neutral-500);">Select a topic from the sidebar to get started.</p>
</div>
`;
return;
}
contentArea.innerHTML = `
<article class="prose fade-in">
${page.metadata?.description ? `
<div style="background: var(--primary-50); border: 1px solid var(--primary-200); border-radius: 0.75rem; padding: 1.5rem; margin-bottom: 2rem;">
<p style="margin: 0; color: var(--primary-800);">${page.metadata.description}</p>
</div>
` : ''}
${page.content}
${page.metadata?.lastModified ? `
<div style="margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--neutral-200); font-size: 0.875rem; color: var(--neutral-500);">
Last updated: ${new Date(page.metadata.lastModified).toLocaleDateString()}
</div>
` : ''}
</article>
`;
// Scroll to top
contentArea.scrollTop = 0;
// Update document title
document.title = `${page.title} - ${this.config.title}`;
}
updateActiveNav() {
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === this.currentPage);
});
}
handleRouting() {
const path = window.location.pathname;
const page = this.config.pages?.find(p => p.path === path) || this.config.pages?.[0];
if (page) {
this.currentPage = page.id;
this.renderPage(page);
}
this.renderNavigation();
this.updateActiveNav();
}
}
// Initialize app when DOM is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new DocumentationApp());
} else {
new DocumentationApp();
}