diff --git a/index.js b/index.js index 48b4122..0c6402a 100644 --- a/index.js +++ b/index.js @@ -1,375 +1,424 @@ -let originalDocTitle = document.title; - -let markedPromise = new Promise((resolve) => { - if (typeof marked !== 'undefined') { - resolve(marked); - } else { - window.addEventListener('load', () => resolve(marked)); +class EventBus { + constructor() { + this.events = {}; } -}); - -async function initializeMarked() { - const marked = await markedPromise; - const renderer = new marked.Renderer(); - const originalLink = renderer.link.bind(renderer); - - renderer.code = (code, language) => { - let highlighted; - if (language && hljs.getLanguage(language)) { - highlighted = hljs.highlight(code, { language }).value; - language = language; - } else { - highlighted = hljs.highlightAuto(code).value; - language = ''; + on(event, callback) { + if (!this.events[event]) { + this.events[event] = []; } - return `
${highlighted}`;
- };
+ this.events[event].push(callback);
+ return () => this.off(event, callback);
+ }
- renderer.link = (href, title, text) => {
- const isExternal = href.startsWith('http') || href.startsWith('https');
- const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
- const link = originalLink(href, title, text);
- if (!isExternal && href.startsWith('?')) {
- return link.replace(/^ cb !== callback);
+ }
+
+ emit(event, data) {
+ if (!this.events[event]) return;
+ this.events[event].forEach(callback => callback(data));
+ }
}
-function createFileIndexItem(doc, container, level = 0) {
- if (doc.type === 'folder') {
- const folderDiv = document.createElement('div');
- folderDiv.className = 'folder' + (doc.defaultOpen !== false ? ' open' : '');
- folderDiv.dataset.path = doc.title;
- folderDiv.style.paddingLeft = `${level * 0.8}rem`;
+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;
+ }
- const folderHeader = document.createElement('div');
- folderHeader.className = 'folder-header';
+ 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')
+ };
+ }
+
+ setupMobileMenu() {
+ this.elements.menuButton.addEventListener('click', () =>
+ this.elements.leftSidebar.classList.toggle('show'));
+
+ 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 = findDocumentByTitle(indexData.documents, targetTitle);
- if (doc) {
- return `[${displayText || doc.title}](?${doc.slug})`;
- } else {
- return match;
- }
+ const doc = this.indexService.findDocumentByTitle(
+ window._indexData.documents,
+ targetTitle
+ );
+ return doc ? `[${displayText || doc.title}](?${doc.slug})` : match;
});
+ }
- processedContent = `# ${titleContent}\n\n${processedContent}`;
-
- let finalContent = processedContent.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
+ processImages(content, basePath) {
+ return content.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
const mediaPath = `${basePath}/images/${filename}`;
if (filename.toLowerCase().endsWith('.mp4')) {
@@ -381,169 +430,163 @@ async function loadDocument(path) {
return `\n\n\n`;
});
+ }
+}
- // Track current page for navigation
- window._currentPath = path;
+class NavigationService {
+ constructor(eventBus, documentService) {
+ this.eventBus = eventBus;
+ this.documentService = documentService;
+ this.setupEventListeners();
+ }
- // Add Discord-style underline support: __text__ -> text
- finalContent = finalContent.replace(/__(.*?)__/g, '$1');
+ setupEventListeners() {
+ window.addEventListener('popstate', async () => {
+ const slug = new URLSearchParams(window.location.search).toString()
+ .replace(/^=/, '').replace(/^\?/, '');
+ this.eventBus.emit('navigation:requested', { slug });
+ });
- document.querySelector('.title-text .page-title').textContent = titleContent;
-
- documentContent.className = 'markdown-content';
- documentContent.innerHTML = marked.parse(finalContent);
- hljs.highlightAll();
-
- documentOutline.innerHTML = '';
- const headings = documentContent.querySelectorAll('h2, h3, h4, h5, h6');
- const headingLinks = new Map();
-
- headings.forEach(heading => {
- if (!heading.id) {
- heading.id = heading.textContent.toLowerCase()
- .replace(/[^\w\s-]/g, '')
- .replace(/\s+/g, '-');
- }
-
- const link = document.createElement('a');
- link.href = `${window.location.pathname}${window.location.search}#${heading.id}`;
- link.textContent = heading.textContent;
- link.style.paddingLeft = (heading.tagName[1] * 15) + 'px';
- headingLinks.set(heading, link);
-
- heading.style.scrollMarginTop = 'var(--title-bar-height)';
-
- link.onclick = (e) => {
+ document.addEventListener('click', async (e) => {
+ const target = e.target.closest('a[data-internal="true"]');
+ if (target) {
e.preventDefault();
- history.pushState(null, '', link.href);
- if (heading) {
- heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
- heading.classList.remove('highlight');
- void heading.offsetWidth;
- heading.classList.add('highlight');
- } else {
- window.scrollTo({ top: 0, behavior: 'smooth' });
- }
- };
-
- documentOutline.appendChild(link);
-
- /* Add header folding toggle */
- const toggleBtn = document.createElement('span');
- toggleBtn.innerHTML = ``;
- toggleBtn.style.cursor = 'pointer';
- toggleBtn.style.userSelect = 'none';
- toggleBtn.style.marginLeft = '0.5em';
- toggleBtn.style.display = 'inline-flex';
- toggleBtn.style.alignItems = 'center';
- heading.appendChild(toggleBtn);
-
- toggleBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- const svg = toggleBtn.querySelector('svg');
- const isFolded = svg.style.transform === 'rotate(90deg)';
- svg.style.transform = isFolded ? 'rotate(0deg)' : 'rotate(90deg)';
- const currentLevel = parseInt(heading.tagName[1]);
- let next = heading.nextElementSibling;
- while (next) {
- if (!/^H[1-6]$/.test(next.tagName)) {
- next.style.display = isFolded ? 'none' : '';
- next = next.nextElementSibling;
- } else {
- const nextLevel = parseInt(next.tagName[1]);
- if (nextLevel <= currentLevel) {
- break;
- }
- next.style.display = isFolded ? 'none' : '';
- next = next.nextElementSibling;
- }
- }
- });
+ const slug = target.href.split('?').pop();
+ history.pushState({ slug }, '', target.href);
+ this.eventBus.emit('navigation:requested', { slug });
+ }
+ });
+ }
+}
+
+class Documentation {
+ constructor() {
+ this.eventBus = new EventBus();
+ this.indexService = new 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();
+ }
+
+ setupEventListeners() {
+ this.eventBus.on('navigation:requested', async ({ slug }) => {
+ slug = slug.replace(/^[?=]/, '');
+ await this.loadDocumentBySlug(slug);
});
- const observer = new IntersectionObserver((entries) => {
- const visibleHeadings = entries
- .filter(entry => entry.isIntersecting)
- .sort((a, b) => a.target.offsetTop - b.target.offsetTop);
- if (visibleHeadings.length) {
- documentOutline.querySelectorAll('a').forEach(a => a.classList.remove('active'));
- const topHeading = visibleHeadings[0].target;
- const link = headingLinks.get(topHeading);
- if (link) link.classList.add('active');
- }
- }, {
- rootMargin: '-48px 0px -60% 0px',
- threshold: [0, 0.25, 0.5, 0.75, 1]
+ 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);
+ }
+ });
+ }
+
+ 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.domService.elements.fileIndex.innerHTML = '';
+ this.indexData.documents.forEach(doc =>
+ this.domService.createFileIndexItem(doc, this.domService.elements.fileIndex));
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const slug = urlParams.get('') || urlParams.get('page') || 'welcome';
+ await this.loadDocumentBySlug(slug);
+ } catch (error) {
+ this.domService.setError(`Failed to load documentation index: ${error.message}`);
+ }
+ }
+
+ async loadDocumentBySlug(slug) {
+ const doc = this.indexService.findDocumentBySlug(this.indexData.documents, slug);
+
+ if (!doc) {
+ this.domService.setError(`Document not found: ${slug}`);
+ return;
+ }
+
+ if (doc.type === 'folder') {
+ if (doc.path) {
+ await this.loadDocument(doc.path);
+ } else if (doc.items?.length > 0) {
+ await this.loadDocument(doc.items[0].path);
+ } else {
+ this.domService.setError('This folder is empty.');
+ }
+ return;
+ }
+
+ await this.loadDocument(doc.path);
+ }
+
+ async loadDocument(path) {
+ 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);
+
+ this.setupScrollObserver(headings, headingLinks);
+
+ window._currentPath = path;
+
+ 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.disconnect();
-
- } catch (error) {
- document.getElementById('document-content').innerHTML =
- '