Compare commits

...

7 Commits

11 changed files with 1684 additions and 1052 deletions

View File

@@ -22,7 +22,7 @@
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<div class="title-text">
<img src="./img/logo.png" alt="Litruv" class="brand-logo">
<img src="./img/logo.png" alt="Logo" class="brand-logo">
<span class="divider" aria-hidden="true">/</span>
<span class="page-title">Documentation</span>
</div>
@@ -33,9 +33,9 @@
<div class="search-container">
<div class="search-box" role="search">
<i class="fas fa-search" aria-hidden="true"></i>
<input type="text" id="search-input" placeholder="Search docs..." aria-label="Search documentation">
<input type="text" id="search-input" placeholder="Search docs..." aria-label="Search documentation" role="searchbox">
<div class="keyboard-shortcut keyboard-shortcut-alt" aria-hidden="true">Alt+S</div>
<i class="fas fa-times" id="clear-search" aria-label="Clear search"></i>
<i class="fas fa-times" id="clear-search" aria-label="Clear search" role="button" tabindex="0"></i>
</div>
<div id="search-results" role="region" aria-label="Search results" aria-live="polite"></div>
</div>
@@ -64,7 +64,7 @@
<script>
hljs.highlightAll();
</script>
<script src="index.js"></script>
<script type="module" src="js/Documentation.js"></script>
<!-- Print title capture script -->
<script>
@@ -100,7 +100,7 @@
if (existingTitle) existingTitle.remove();
// Make sure the print header is accessible
brandLogo.setAttribute('alt', 'Litruv logo');
brandLogo.setAttribute('alt', 'Logo');
printHeader.setAttribute('aria-hidden', 'true'); // Hide from screen readers when printing
});

1039
index.js

File diff suppressed because it is too large Load Diff

566
js/DOMService.js Normal file
View File

@@ -0,0 +1,566 @@
/**
* Service responsible for DOM manipulation and UI rendering.
* @class DOMService
*/
export class DOMService {
/**
* Creates a new DOMService instance.
* @param {EventBus} eventBus - The event bus for communication.
*/
constructor(eventBus) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {DOMElements} */
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')
};
/** @type {number} */
this.headerOffset = 60;
}
/**
* Sets up the mobile menu toggle functionality.
*/
setupMobileMenu() {
this.elements.menuButton.addEventListener('click', () => {
const isExpanded = this.elements.leftSidebar.classList.toggle('show');
this.elements.menuButton.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (isExpanded) {
this.enableFocusTrap();
} else {
this.disableFocusTrap();
}
});
this.elements.content.addEventListener('click', () => {
this.elements.leftSidebar.classList.remove('show');
this.elements.menuButton.setAttribute('aria-expanded', 'false');
this.disableFocusTrap();
});
}
/**
* Enables focus trap within the mobile sidebar.
* @private
*/
enableFocusTrap() {
this.focusTrapHandler = (e) => {
if (window.innerWidth > 1000) return;
if (!this.elements.leftSidebar.classList.contains('show')) return;
const focusableElements = this.elements.leftSidebar.querySelectorAll(
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
if (e.key === 'Escape') {
this.elements.leftSidebar.classList.remove('show');
this.elements.menuButton.setAttribute('aria-expanded', 'false');
this.elements.menuButton.focus();
this.disableFocusTrap();
}
};
document.addEventListener('keydown', this.focusTrapHandler);
const firstFocusable = this.elements.leftSidebar.querySelector(
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
firstFocusable.focus();
}
}
/**
* Disables the focus trap.
* @private
*/
disableFocusTrap() {
if (this.focusTrapHandler) {
document.removeEventListener('keydown', this.focusTrapHandler);
this.focusTrapHandler = null;
}
}
/**
* Sets the main content area HTML.
* @param {string} html - The HTML content to render.
*/
setContent(html) {
this.elements.content.innerHTML = html;
this.elements.content.className = 'markdown-content';
hljs.highlightAll();
}
/**
* Sets the document title and page title display.
* @param {string} title - The title to set.
*/
setTitle(title) {
document.title = `${window.originalDocTitle} / ${title}`;
this.elements.titleText.textContent = title;
}
/**
* Displays an error message in the content area.
* @param {string} message - The error message to display.
*/
setError(message) {
this.elements.content.innerHTML = `<div class="error">${message}</div>`;
}
/**
* Creates a file index item (folder or file) in the sidebar.
* @param {Document} doc - The document to create an item for.
* @param {HTMLElement} container - The container element to append to.
* @param {number} [level=0] - The nesting level for indentation.
*/
createFileIndexItem(doc, container, level = 0) {
if (doc.type === 'folder') {
this.createFolderItem(doc, container, level);
} else {
this.createFileItem(doc, container, level);
}
}
/**
* Creates a folder item in the sidebar.
* @param {Document} doc - The folder document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFolderItem(doc, container, level) {
const folderDiv = document.createElement('div');
const isOpen = doc.defaultOpen === true;
folderDiv.className = 'folder' + (isOpen ? ' open' : '');
folderDiv.dataset.path = doc.title;
folderDiv.style.paddingLeft = `${level * 0.8}rem`;
const folderHeader = document.createElement('div');
folderHeader.className = 'folder-header';
folderHeader.setAttribute('role', 'treeitem');
folderHeader.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
folderHeader.setAttribute('tabindex', '0');
const iconClass = doc.icon || `fas fa-folder${isOpen ? '-open' : ''}`;
if (doc.path && doc.metadata?.showfolderpage !== false) {
folderHeader.innerHTML = this.createFolderHeaderWithFile(iconClass, doc);
this.setupFolderListeners(folderDiv, folderHeader, doc);
} else {
folderHeader.innerHTML = this.createFolderHeaderBasic(iconClass, doc);
this.setupBasicFolderListeners(folderDiv, folderHeader, doc);
}
const folderContent = document.createElement('div');
folderContent.className = 'folder-content';
doc.items.forEach(item => this.createFileIndexItem(item, folderContent, level + 1));
folderDiv.appendChild(folderHeader);
folderDiv.appendChild(folderContent);
container.appendChild(folderDiv);
}
/**
* Sets up listeners for basic folder toggle (no folder page).
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupBasicFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', () => {
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
folderHeader.click();
}
});
}
/**
* Creates a file item link in the sidebar.
* @param {Document} doc - The file document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFileItem(doc, container, level) {
const link = document.createElement('a');
link.href = `?${doc.slug}`;
link.textContent = doc.title || doc.path.split('/').pop().replace('.md', '');
link.dataset.path = doc.path;
link.dataset.slug = doc.slug;
link.style.paddingLeft = `${(level * 0.6) + 0.8}rem`;
link.setAttribute('role', 'treeitem');
link.onclick = (e) => {
e.preventDefault();
this.setLinkLoading(link);
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', link.href);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
};
container.appendChild(link);
}
/**
* Sets a link to loading state.
* @param {HTMLAnchorElement} link - The link element.
*/
setLinkLoading(link) {
this.clearAllLoading();
link.classList.add('loading');
}
/**
* Clears loading state from all links.
*/
clearAllLoading() {
this.elements.fileIndex.querySelectorAll('a.loading, .folder-link.loading').forEach(link => {
link.classList.remove('loading');
});
}
/**
* Updates the active document highlight in the sidebar.
* @param {string} path - The path of the active document.
*/
updateActiveDocument(path) {
this.clearAllLoading();
this.elements.fileIndex.querySelectorAll('a').forEach(link => {
const isActive = link.dataset.path === path;
link.classList.toggle('active', isActive);
if (isActive) {
link.setAttribute('aria-current', 'page');
} else {
link.removeAttribute('aria-current');
}
});
}
/**
* Creates the document outline from headings.
* @param {NodeListOf<HTMLHeadingElement>} headings - The heading elements.
* @returns {Map<HTMLHeadingElement, HTMLAnchorElement>} Map of headings to their outline links.
*/
createOutline(headings) {
this.elements.outline.innerHTML = '';
const headingLinks = new Map();
headings.forEach(heading => {
if (!heading.id) {
heading.id = heading.textContent.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
const link = this.createOutlineLink(heading);
headingLinks.set(heading, link);
this.elements.outline.appendChild(link);
this.addHeadingFoldToggle(heading);
});
return headingLinks;
}
/**
* Creates HTML for a folder header with a file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderWithFile(iconClass, doc) {
const showFolderPage = doc.showfolderpage !== 'false';
return `
<div class="folder-icons">
<i class="${iconClass} folder-icon" aria-hidden="true"></i>
</div>
<span>${doc.title}</span>
${showFolderPage ? `
<a href="?${doc.slug}" class="folder-link" title="View folder page" aria-label="View ${doc.title} folder page">
<i class="fas fa-file-alt" aria-hidden="true"></i>
</a>` : ''}`;
}
/**
* Creates HTML for a basic folder header without file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderBasic(iconClass, doc) {
return `
<div class="folder-icons">
<i class="${iconClass} folder-icon" aria-hidden="true"></i>
</div>
<span>${doc.title}</span>`;
}
/**
* Sets up event listeners for a folder with a navigable page.
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', (e) => {
if (e.target.closest('.folder-link')) {
e.preventDefault();
const folderLink = e.target.closest('.folder-link');
this.setFolderLinkLoading(folderLink);
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', `?${doc.slug}`);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
return;
}
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.target.closest('.folder-link')) {
e.target.closest('.folder-link').click();
} else {
folderHeader.click();
}
}
});
}
/**
* Sets a folder link to loading state.
* @param {HTMLAnchorElement} link - The folder link element.
*/
setFolderLinkLoading(link) {
this.clearAllLoading();
link.classList.add('loading');
}
/**
* Scrolls smoothly to an element with header offset.
* @param {HTMLElement} element - The element to scroll to.
*/
scrollToElement(element) {
if (!element) return;
const rect = element.getBoundingClientRect();
const absoluteElementTop = rect.top + window.scrollY;
const middle = absoluteElementTop - (this.headerOffset + 20);
window.scrollTo({
top: middle,
behavior: 'smooth'
});
}
/**
* Creates an outline link for a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @returns {HTMLAnchorElement} The created link element.
* @private
*/
createOutlineLink(heading) {
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';
link.onclick = (e) => {
e.preventDefault();
history.pushState(null, '', link.href);
this.scrollToElement(heading);
heading.classList.remove('highlight');
void heading.offsetWidth;
heading.classList.add('highlight');
};
return link;
}
/**
* Adds a fold/unfold toggle button to a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @private
*/
addHeadingFoldToggle(heading) {
const toggleBtn = document.createElement('span');
toggleBtn.innerHTML = `<svg width="10" height="10" viewBox="0 0 10 10" style="transform: rotate(90deg); transition: transform 0.2s;">
<path d="M3 2L7 5L3 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
toggleBtn.style.cursor = 'pointer';
toggleBtn.style.userSelect = 'none';
toggleBtn.style.marginLeft = '0.5em';
toggleBtn.style.display = 'inline-flex';
toggleBtn.style.alignItems = 'center';
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)';
let next = heading.nextElementSibling;
const currentLevel = parseInt(heading.tagName[1]);
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;
}
}
});
heading.appendChild(toggleBtn);
}
/**
* Sets up search input event handlers.
* @param {SearchService} searchService - The search service instance.
*/
setupSearch(searchService) {
let searchTimeout;
this.elements.searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
searchTimeout = setTimeout(() => {
const results = searchService.search(query);
this.renderSearchResults(results);
}, 200);
});
this.elements.clearSearch.addEventListener('click', () => {
this.elements.searchInput.value = '';
this.elements.searchResults.innerHTML = '';
this.elements.searchResults.style.display = 'none';
this.elements.searchResults.setAttribute('aria-hidden', 'true');
});
}
/**
* Renders search results in the dropdown.
* @param {Array<SearchResult>} results - The search results to render.
*/
renderSearchResults(results) {
const container = this.elements.searchResults;
container.innerHTML = '';
if (results.length === 0 || !this.elements.searchInput.value) {
container.style.display = 'none';
container.setAttribute('aria-hidden', 'true');
return;
}
container.setAttribute('aria-hidden', 'false');
const resultCount = results.length === 1 ? '1 search result found' : `${results.length} search results found`;
container.setAttribute('aria-label', resultCount);
results.forEach(result => {
const div = document.createElement('div');
div.className = 'search-result';
const icon = document.createElement('i');
icon.className = result.type === 'folder' ? 'fas fa-folder' :
result.type === 'header' ? 'fas fa-hashtag' : 'fas fa-file-alt';
const link = document.createElement('a');
link.href = `?${result.slug}`;
link.innerHTML = `
${icon.outerHTML}
<div class="search-result-content">
<div class="search-result-title">${result.title}</div>
<div class="search-result-path">${result.location}</div>
</div>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
this.elements.searchInput.value = '';
container.style.display = 'none';
const [baseSlug, hash] = result.slug.split('#');
history.pushState(null, '', link.href);
this.eventBus.emit('navigation:requested', {
slug: baseSlug,
hash: hash ? `#${hash}` : '',
fromSearch: true
});
});
div.appendChild(link);
container.appendChild(div);
});
container.style.display = 'block';
}
}
/**
* @typedef {Object} DOMElements
* @property {HTMLElement} content - The main content container.
* @property {HTMLElement} outline - The document outline container.
* @property {HTMLElement} fileIndex - The file index container.
* @property {HTMLElement} titleText - The page title element.
* @property {HTMLElement} leftSidebar - The left sidebar element.
* @property {HTMLElement} menuButton - The mobile menu button.
* @property {HTMLElement} header - The header element.
* @property {HTMLInputElement} searchInput - The search input field.
* @property {HTMLElement} searchResults - The search results container.
* @property {HTMLElement} clearSearch - The clear search button.
*/

303
js/DocumentService.js Normal file
View File

@@ -0,0 +1,303 @@
/**
* Service responsible for loading and processing markdown documents.
* @class DocumentService
*/
export class DocumentService {
/**
* Creates a new DocumentService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {IndexService} indexService - The index service for document lookups.
*/
constructor(eventBus, indexService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {IndexService} */
this.indexService = indexService;
/** @type {Promise<marked>} */
this.markedPromise = this.initializeMarked();
/** @type {Map<string, LoadedDocument>} */
this.documentCache = new Map();
/** @type {number} */
this.cacheMaxSize = 20;
}
/**
* Initializes the marked library with custom renderer.
* @returns {Promise<marked>} Promise resolving to the marked instance.
* @private
*/
async initializeMarked() {
const marked = await new Promise((resolve) => {
if (typeof window.marked !== 'undefined') {
resolve(window.marked);
} else {
window.addEventListener('load', () => resolve(window.marked));
}
});
const renderer = new marked.Renderer();
this.setupRenderer(renderer);
marked.setOptions({
breaks: true,
gfm: true,
renderer: renderer
});
return marked;
}
/**
* Configures the marked renderer with custom handlers.
* @param {marked.Renderer} renderer - The renderer to configure.
* @private
*/
setupRenderer(renderer) {
const originalLink = renderer.link.bind(renderer);
const originalImage = renderer.image.bind(renderer);
renderer.code = this.renderCode;
renderer.image = (href, title, text) => {
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${titleAttr} loading="lazy">`;
};
renderer.link = (href, title, text) => {
const isExternal = href.startsWith('http');
const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const link = originalLink(href, title, text);
if (!isExternal && href.startsWith('?')) {
return link.replace(/^<a /, '<a data-internal="true" ');
}
return link.replace(/^<a /, `<a${attrs} `);
};
const originalHeading = renderer.heading.bind(renderer);
renderer.heading = (text, level) => {
const escapedText = text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
const id = escapedText;
return `<h${level} id="${id}" class="clickable-header">
${text}
</h${level}>`;
};
}
/**
* Renders a code block with syntax highlighting.
* @param {string} code - The code content.
* @param {string} language - The language for highlighting.
* @returns {string} The rendered HTML.
* @private
*/
renderCode(code, language) {
let highlighted;
if (language && hljs.getLanguage(language)) {
highlighted = hljs.highlight(code, { language }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
language = '';
}
return `<pre class="hljs ${language ? "language-" + language : ""}"><code>${highlighted}</code></pre>`;
}
/**
* Extracts frontmatter metadata from document content.
* @param {string} content - The raw document content.
* @returns {ExtractedDocument} The extracted metadata and content.
*/
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) {
const frontmatterEntries = lines.slice(1, endMetadata)
.map(line => line.match(/^([\w-]+):\s*(.*)$/))
.filter(Boolean)
.map(([, key, value]) => {
if (key === 'defaultOpen') {
return [key, value.trim().toLowerCase() === 'true'];
} else if (key === 'sort') {
return [key, parseInt(value.trim(), 10)];
} else {
return [key, value.trim()];
}
});
metadata = Object.fromEntries(frontmatterEntries);
contentStart = endMetadata + 1;
}
}
return {
metadata,
content: lines.slice(contentStart).join('\n').trim()
};
}
/**
* Loads and processes a document from a path.
* @param {string} path - The document path to load.
* @returns {Promise<LoadedDocument>} The loaded document data.
* @throws {Error} If the document fails to load.
*/
async loadDocument(path) {
if (this.documentCache.has(path)) {
return this.documentCache.get(path);
}
try {
const [response, marked] = await Promise.all([
fetch(path),
this.markedPromise
]);
let rawContent = await response.text();
const { metadata, content } = this.extractMetadata(rawContent);
if (metadata.defaultOpen !== undefined) {
metadata.defaultOpen = metadata.defaultOpen === true || metadata.defaultOpen === 'true';
}
if (metadata.sort !== undefined) {
metadata.sort = typeof metadata.sort === 'number' ? metadata.sort : parseInt(metadata.sort, 10);
}
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);
const result = {
content: processedContent,
metadata,
marked,
title: titleContent
};
if (this.documentCache.size >= this.cacheMaxSize) {
const firstKey = this.documentCache.keys().next().value;
this.documentCache.delete(firstKey);
}
this.documentCache.set(path, result);
return result;
} catch (error) {
throw new Error('Failed to load document');
}
}
/**
* Finds a document in the global index by path.
* @param {string} path - The document path.
* @returns {Document|null} The found document or null.
* @private
*/
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;
}
/**
* Ensures the content has a title heading.
* @param {string} content - The document content.
* @param {string} title - The title to add.
* @returns {string} The content with title.
* @private
*/
ensureTitle(content, title) {
content = content.replace(/^#\s+.*$/m, '').trim();
return `# ${title}\n\n${content}`;
}
/**
* Processes wiki-style links [[Link]] into markdown links.
* @param {string} content - The content to process.
* @returns {string} The processed content.
* @private
*/
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;
});
}
/**
* Processes wiki-style image embeds into HTML.
* @param {string} content - The content to process.
* @param {string} basePath - The base path for relative images.
* @returns {string} The processed content.
* @private
*/
processImages(content, basePath) {
return content.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
const mediaPath = `./docs/images/${filename}`;
if (filename.toLowerCase().endsWith('.mp4')) {
return `\n<video controls width="100%" preload="metadata">
<source src="${mediaPath}" type="video/mp4">
Your browser does not support the video tag.
</video>\n\n`;
}
return `\n<img src="${mediaPath}" alt="${filename}" loading="lazy">\n\n`;
});
}
/**
* Clears the entire document cache.
*/
clearCache() {
this.documentCache.clear();
}
/**
* Invalidates a specific document from the cache.
* @param {string} path - The document path to invalidate.
*/
invalidateDocument(path) {
this.documentCache.delete(path);
}
}
/**
* @typedef {Object} ExtractedDocument
* @property {Object} metadata - The document metadata.
* @property {string} content - The document content without frontmatter.
*/
/**
* @typedef {Object} LoadedDocument
* @property {string} content - The processed markdown content.
* @property {Object} metadata - The document metadata.
* @property {marked} marked - The marked instance.
* @property {string} title - The document title.
*/

378
js/Documentation.js Normal file
View File

@@ -0,0 +1,378 @@
import { EventBus } from './EventBus.js';
import { IndexService } from './IndexService.js';
import { SearchService } from './SearchService.js';
import { DOMService } from './DOMService.js';
import { DocumentService } from './DocumentService.js';
import { NavigationService } from './NavigationService.js';
/**
* Main documentation application class that orchestrates all services.
* @class Documentation
*/
class Documentation {
constructor() {
/** @type {EventBus} */
this.eventBus = new EventBus();
/** @type {IndexService} */
this.indexService = new IndexService();
/** @type {SearchService} */
this.searchService = new SearchService(this.eventBus, this.indexService);
/** @type {DOMService} */
this.domService = new DOMService(this.eventBus);
/** @type {DocumentService} */
this.documentService = new DocumentService(this.eventBus, this.indexService);
/** @type {NavigationService} */
this.navigationService = new NavigationService(this.eventBus, this.documentService);
/** @type {IndexData|null} */
this.indexData = null;
this.setupEventListeners();
this.setupKeyboardShortcuts();
}
/**
* Loads a custom CSS file if specified in the index.json.
* @param {string} customCSSPath - Path to the custom CSS file relative to document root.
*/
loadCustomCSS(customCSSPath) {
if (!customCSSPath) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = customCSSPath;
document.head.appendChild(link);
}
/**
* Sets up application-wide event listeners.
* @private
*/
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);
}
});
}
/**
* Sets up keyboard shortcuts for navigation and search.
* @private
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
const isTyping = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) ||
document.activeElement.isContentEditable;
if (this.isSearchShortcut(e) && !isTyping) {
e.preventDefault();
this.focusSearch();
}
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && e.altKey && !isTyping) {
e.preventDefault();
this.navigatePages(e.key === 'ArrowDown' ? 1 : -1);
}
});
}
/**
* Checks if the key event is a search shortcut.
* @param {KeyboardEvent} e - The keyboard event.
* @returns {boolean} True if it's a search shortcut.
* @private
*/
isSearchShortcut(e) {
return ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.metaKey &&
(!e.altKey || (e.altKey && (e.key === 's' || e.key === 'S'))));
}
/**
* Focuses the search input and opens mobile sidebar if needed.
* @private
*/
focusSearch() {
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
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');
}
}
}
}
/**
* Navigates to the next or previous page in the file index.
* @param {number} direction - 1 for next, -1 for previous.
* @private
*/
navigatePages(direction) {
const fileLinks = Array.from(document.querySelectorAll('#file-index a'));
if (fileLinks.length === 0) return;
const activeLink = document.querySelector('#file-index a.active');
if (!activeLink) return;
const activeIndex = fileLinks.indexOf(activeLink);
if (activeIndex === -1) return;
let targetIndex;
if (direction === 1) {
targetIndex = activeIndex < fileLinks.length - 1 ? activeIndex + 1 : 0;
} else {
targetIndex = activeIndex > 0 ? activeIndex - 1 : fileLinks.length - 1;
}
const targetLink = fileLinks[targetIndex];
if (targetLink) {
targetLink.click();
targetLink.scrollIntoView({ block: 'nearest' });
}
}
/**
* Initializes the documentation application.
* @returns {Promise<void>}
*/
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;
if (data.customCSS) {
this.loadCustomCSS(data.customCSS);
}
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}`);
}
}
/**
* Populates the author information in the sidebar.
* @param {Author} author - The author data.
*/
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 = `<i class="${s.icon}"></i>`;
socials.appendChild(link);
});
}
}
}
/**
* Loads a document by its slug.
* @param {string} slug - The document slug.
* @param {string} [hash] - Optional hash for scrolling to a section.
* @returns {Promise<void>}
*/
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);
}
/**
* Loads and renders a document from a path.
* @param {string} path - The document path.
* @param {string} [hash] - Optional hash for scrolling to a section.
* @param {boolean} [fromSearch=false] - Whether navigation came from search.
* @returns {Promise<void>}
*/
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.');
}
}
/**
* Sets up an intersection observer for outline highlighting.
* @param {NodeListOf<HTMLHeadingElement>} headings - The heading elements.
* @param {Map<HTMLHeadingElement, HTMLAnchorElement>} headingLinks - Map of headings to links.
* @returns {IntersectionObserver} The created observer.
* @private
*/
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;
}
}
/**
* @typedef {Object} IndexData
* @property {string} defaultPage - The default page slug.
* @property {Object} metadata - Site metadata.
* @property {Author} author - Author information.
* @property {Array<Document>} documents - The document tree.
* @property {string} [customCSS] - Optional custom CSS path.
*/
/**
* @typedef {Object} Author
* @property {string} [name] - Author name.
* @property {string} [role] - Author role.
* @property {Array<Social>} [socials] - Social media links.
*/
/**
* @typedef {Object} Social
* @property {string} url - The social media URL.
* @property {string} title - The link title.
* @property {string} icon - The Font Awesome icon class.
*/
window.originalDocTitle = document.title;
const docs = new Documentation();
// Expose to global scope for console access
window.docs = docs;

44
js/EventBus.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* A simple publish-subscribe event bus for decoupled communication between components.
* @class EventBus
*/
export class EventBus {
constructor() {
/** @type {Object.<string, Function[]>} */
this.events = {};
}
/**
* Subscribe to an event.
* @param {string} event - The event name to subscribe to.
* @param {Function} callback - The callback function to execute when the event is emitted.
* @returns {Function} An unsubscribe function that removes this listener.
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return () => this.off(event, callback);
}
/**
* Unsubscribe from an event.
* @param {string} event - The event name to unsubscribe from.
* @param {Function} callback - The callback function to remove.
*/
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
/**
* Emit an event to all subscribers.
* @param {string} event - The event name to emit.
* @param {*} data - The data to pass to all subscribers.
*/
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
}

89
js/IndexService.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* Service responsible for document index operations and lookups.
* @class IndexService
*/
export class IndexService {
/**
* Finds a document by its slug in the document tree.
* @param {Array<Document>} documents - The documents to search.
* @param {string} slug - The slug to find.
* @returns {Document|null} The found document or null.
*/
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;
}
/**
* Finds a document by its title, path, or slug.
* @param {Array<Document>} documents - The documents to search.
* @param {string} title - The title to find.
* @returns {Document|null} The found document or 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;
}
/**
* Finds all parent folders for a given document path.
* @param {Array<Document>} documents - The documents to search.
* @param {string} path - The path to find parents for.
* @param {Array<Document>} [parentFolders=[]] - Accumulated parent folders.
* @returns {Array<Document>} The parent folders.
*/
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;
}
}
/**
* @typedef {Object} Document
* @property {string} title - The document title.
* @property {string} [path] - The file path.
* @property {string} slug - The URL slug.
* @property {'folder'|'file'} [type] - The document type.
* @property {Array<Document>} [items] - Child documents for folders.
* @property {Array<string>} [headers] - Document headers.
* @property {string} [icon] - Custom icon class.
* @property {boolean} [defaultOpen] - Whether folder is open by default.
* @property {string} [showfolderpage] - Whether to show folder page.
* @property {Object} [metadata] - Additional metadata.
*/

44
js/NavigationService.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* Service responsible for handling navigation events and browser history.
* @class NavigationService
*/
export class NavigationService {
/**
* Creates a new NavigationService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {DocumentService} documentService - The document service.
*/
constructor(eventBus, documentService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {DocumentService} */
this.documentService = documentService;
this.setupEventListeners();
}
/**
* Sets up browser history and internal link event listeners.
* @private
*/
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 });
}
});
}
}

200
js/SearchService.js Normal file
View File

@@ -0,0 +1,200 @@
/**
* Service responsible for building and querying the search index.
* @class SearchService
*/
export class SearchService {
/**
* Creates a new SearchService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {IndexService} indexService - The index service for document lookups.
*/
constructor(eventBus, indexService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {IndexService} */
this.indexService = indexService;
/** @type {Array<SearchIndexItem>} */
this.searchIndex = [];
/** @type {Map<string, Array<SearchResult>>} */
this.searchCache = new Map();
/** @type {number} */
this.cacheMaxSize = 50;
}
/**
* Builds the search index from a list of documents.
* @param {Array<Document>} documents - The documents to index.
*/
buildSearchIndex(documents) {
this.searchIndex = [];
this.searchCache.clear();
this.processDocuments(documents);
}
/**
* Recursively processes documents and adds them to the search index.
* @param {Array<Document>} documents - The documents to process.
* @param {string} [parentPath=''] - The parent path for breadcrumb display.
* @private
*/
processDocuments(documents, parentPath = '') {
documents.forEach(doc => {
if (doc.type === 'folder') {
this.processFolderDocument(doc, parentPath);
} else {
this.processFileDocument(doc, parentPath);
}
});
}
/**
* Processes a folder document and its children.
* @param {Document} doc - The folder document to process.
* @param {string} parentPath - The parent path for breadcrumb display.
* @private
*/
processFolderDocument(doc, parentPath) {
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) {
this.addHeadersToIndex(doc, currentPath, doc.title);
}
}
if (doc.items) {
this.processDocuments(doc.items, currentPath);
}
}
/**
* Processes a file document.
* @param {Document} doc - The file document to process.
* @param {string} parentPath - The parent path for breadcrumb display.
* @private
*/
processFileDocument(doc, parentPath) {
this.searchIndex.push({
title: doc.title,
path: doc.path,
slug: doc.slug,
location: parentPath,
type: 'file'
});
if (doc.headers) {
this.addHeadersToIndex(doc, parentPath, doc.title);
}
}
/**
* Adds document headers to the search index.
* @param {Document} doc - The document containing headers.
* @param {string} location - The location path for display.
* @param {string} docTitle - The document title.
* @private
*/
addHeadersToIndex(doc, location, docTitle) {
doc.headers.forEach(header => {
this.searchIndex.push({
title: header,
path: doc.path,
slug: `${doc.slug}#${this.slugifyHeader(header)}`,
location: `${location} / ${docTitle}`,
type: 'header'
});
});
}
/**
* Converts a header text to a URL-friendly slug.
* @param {string} header - The header text to slugify.
* @returns {string} The slugified header.
* @private
*/
slugifyHeader(header) {
return header.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
/**
* Searches the index for documents matching the query.
* @param {string} query - The search query.
* @returns {Array<SearchResult>} The search results sorted by relevance.
*/
search(query) {
if (!query) return [];
query = query.toLowerCase();
if (this.searchCache.has(query)) {
return this.searchCache.get(query);
}
const results = this.searchIndex
.map(item => this.scoreItem(item, query))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10);
if (this.searchCache.size >= this.cacheMaxSize) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
this.searchCache.set(query, results);
return results;
}
/**
* Calculates a relevance score for a search index item.
* @param {SearchIndexItem} item - The item to score.
* @param {string} query - The search query (lowercase).
* @returns {SearchResult} The item with its score.
* @private
*/
scoreItem(item, query) {
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 };
}
/**
* Clears the search result cache.
*/
clearCache() {
this.searchCache.clear();
}
}
/**
* @typedef {Object} SearchIndexItem
* @property {string} title - The item title.
* @property {string} path - The file path.
* @property {string} slug - The URL slug.
* @property {string} location - The breadcrumb location.
* @property {'folder'|'file'|'header'} type - The item type.
*/
/**
* @typedef {SearchIndexItem & {score: number}} SearchResult
*/

14
package-lock.json generated
View File

@@ -1240,9 +1240,9 @@
}
},
"node_modules/morgan": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
"integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==",
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1250,7 +1250,7 @@
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-headers": "~1.0.2"
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
@@ -1443,9 +1443,9 @@
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -131,7 +131,7 @@ body {
border-bottom: 1px solid var(--ifm-border-color);
}
search-result:last-child {
.search-result:last-child {
border-bottom: none;
}
@@ -276,6 +276,38 @@ search-result:last-child {
display: block;
padding: 0.4rem 0.8rem;
font-size: 0.875rem;
position: relative;
transition: background-color 0.2s ease;
}
#file-index a.loading {
pointer-events: none;
color: var(--ifm-color-content-secondary);
}
#file-index a.loading::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
height: 2px;
background: var(--ifm-color-primary);
animation: loading-progress 1s ease-in-out infinite;
}
@keyframes loading-progress {
0% {
width: 0%;
left: 0;
}
50% {
width: 70%;
left: 0;
}
100% {
width: 0%;
left: 100%;
}
}
#file-index a:hover {
@@ -334,6 +366,21 @@ search-result:last-child {
right: 0;
top: 50%;
transform: translateY(-50%);
transition: opacity 0.2s ease;
}
.folder-link.loading {
opacity: 1;
color: var(--ifm-color-primary);
}
.folder-link.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.folder-header:hover .folder-link {