mirror of
https://github.com/litruv/Docs-Viewer.git
synced 2026-07-24 02:36:07 +10:00
440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
let originalDocTitle = document.title;
|
|
|
|
let markedPromise = new Promise((resolve) => {
|
|
if (typeof marked !== 'undefined') {
|
|
resolve(marked);
|
|
} else {
|
|
window.addEventListener('load', () => resolve(marked));
|
|
}
|
|
});
|
|
|
|
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 = '';
|
|
}
|
|
return `<pre class="hljs ${language ? "language-" + language : ""}"><code>${highlighted}</code></pre>`;
|
|
};
|
|
|
|
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(/^<a /, '<a data-internal="true" ');
|
|
}
|
|
return link.replace(/^<a /, `<a${attrs} `);
|
|
};
|
|
|
|
marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
renderer: renderer
|
|
});
|
|
|
|
return marked;
|
|
}
|
|
|
|
function createFileIndexItem(doc, container, level = 0) {
|
|
if (doc.type === 'folder') {
|
|
const folderDiv = document.createElement('div');
|
|
folderDiv.className = 'folder open';
|
|
folderDiv.dataset.path = doc.title;
|
|
folderDiv.style.paddingLeft = `${level * 0.8}rem`;
|
|
|
|
const folderHeader = document.createElement('div');
|
|
folderHeader.className = 'folder-header';
|
|
folderHeader.innerHTML = `
|
|
<i class="fas fa-folder-open folder-icon"></i>
|
|
<span>${doc.title}</span>
|
|
`;
|
|
folderDiv.appendChild(folderHeader);
|
|
|
|
const folderContent = document.createElement('div');
|
|
folderContent.className = 'folder-content';
|
|
doc.items.forEach(item => createFileIndexItem(item, folderContent, level + 1));
|
|
folderDiv.appendChild(folderContent);
|
|
|
|
folderHeader.addEventListener('click', () => {
|
|
folderDiv.classList.toggle('open');
|
|
folderHeader.querySelector('.folder-icon').classList.toggle('fa-folder-closed');
|
|
folderHeader.querySelector('.folder-icon').classList.toggle('fa-folder-open');
|
|
});
|
|
|
|
container.appendChild(folderDiv);
|
|
} else {
|
|
const link = document.createElement('a');
|
|
link.href = `?${doc.slug}`;
|
|
const displayTitle = doc.title || doc.path.split('/').pop().replace('.md', '');
|
|
link.textContent = displayTitle;
|
|
link.dataset.path = doc.path;
|
|
link.style.paddingLeft = `${level * 0.8 + 1.2}rem`;
|
|
|
|
link.onclick = (e) => {
|
|
e.preventDefault();
|
|
loadDocument(doc.path);
|
|
history.pushState(null, '', link.href);
|
|
const leftSidebar = document.querySelector('.left-sidebar');
|
|
if (window.innerWidth <= 1000) {
|
|
leftSidebar.classList.remove('show');
|
|
}
|
|
};
|
|
container.appendChild(link);
|
|
}
|
|
}
|
|
|
|
function 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 findParentFolders([item], path).length > 0;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (found) {
|
|
parentFolders.push(doc);
|
|
doc.items.forEach(item => {
|
|
if (item.type === 'folder') {
|
|
findParentFolders([item], path, parentFolders);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return parentFolders;
|
|
}
|
|
|
|
function findDocumentBySlug(documents, slug) {
|
|
for (const doc of documents) {
|
|
if (doc.type === 'folder') {
|
|
const found = findDocumentBySlug(doc.items, slug);
|
|
if (found) return found;
|
|
} else if (doc.slug === slug) {
|
|
return doc;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadIndex() {
|
|
try {
|
|
const response = await fetch('index.json');
|
|
const data = await response.json();
|
|
const fileIndex = document.getElementById('file-index');
|
|
|
|
fileIndex.innerHTML = '';
|
|
|
|
data.documents.forEach(doc => createFileIndexItem(doc, fileIndex));
|
|
|
|
const queryString = window.location.search;
|
|
if (queryString) {
|
|
const slug = queryString.substring(1);
|
|
const matchingDoc = findDocumentBySlug(data.documents, slug);
|
|
|
|
if (matchingDoc) {
|
|
await loadDocument(matchingDoc.path);
|
|
}
|
|
} else {
|
|
const defaultDoc = findDocumentBySlug(data.documents, 'welcome');
|
|
if (defaultDoc) {
|
|
await loadDocument(defaultDoc.path);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('document-content').innerHTML =
|
|
'<div class="error">Failed to load documentation index.</div>';
|
|
}
|
|
}
|
|
|
|
window.addEventListener('popstate', async () => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const slug = urlParams.toString().replace(/^=/, '').replace(/^\?/, '');
|
|
if (slug) {
|
|
const response = await fetch('index.json');
|
|
const data = await response.json();
|
|
const matchingDoc = findDocumentBySlug(data.documents, slug);
|
|
if (matchingDoc) {
|
|
await loadDocument(matchingDoc.path);
|
|
}
|
|
}
|
|
});
|
|
|
|
function extractMetadata(content) {
|
|
const lines = content.trim().split('\n');
|
|
let metadata = {};
|
|
let contentStart = 0;
|
|
|
|
if (lines[0].trim() === '---') {
|
|
let endMetadata = -1;
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
if (lines[i].trim() === '---') {
|
|
endMetadata = i;
|
|
break;
|
|
}
|
|
const line = lines[i].trim();
|
|
if (line) {
|
|
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
if (match) {
|
|
metadata[match[1]] = match[2].trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (endMetadata !== -1) {
|
|
contentStart = endMetadata + 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
metadata,
|
|
content: lines.slice(contentStart).join('\n').trim()
|
|
};
|
|
}
|
|
|
|
function findDocumentByTitle(documents, title) {
|
|
for (const doc of documents) {
|
|
if (doc.type === 'folder') {
|
|
const found = findDocumentByTitle(doc.items, title);
|
|
if (found) return found;
|
|
} else if (doc.title === title || doc.path.endsWith(title + '.md')) {
|
|
return doc;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function loadDocument(path) {
|
|
try {
|
|
const documentContent = document.getElementById('document-content');
|
|
const documentOutline = document.getElementById('document-outline');
|
|
const basePath = path.substring(0, path.lastIndexOf('/'));
|
|
|
|
document.querySelectorAll('#file-index a').forEach(link => {
|
|
link.classList.toggle('active', link.dataset.path === path);
|
|
|
|
if (link.dataset.path === path) {
|
|
const response = fetch('index.json')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
const parentFolders = findParentFolders(data.documents, path);
|
|
parentFolders.forEach(folder => {
|
|
const folderDiv = document.querySelector(`.folder[data-path="${folder.title}"]`);
|
|
if (folderDiv) {
|
|
folderDiv.classList.add('open');
|
|
const icon = folderDiv.querySelector('.folder-icon');
|
|
icon.classList.remove('fa-folder-closed');
|
|
icon.classList.add('fa-folder-open');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
const [response, marked] = await Promise.all([
|
|
fetch(path),
|
|
initializeMarked()
|
|
]);
|
|
|
|
let rawContent = await response.text();
|
|
const { metadata, content } = extractMetadata(rawContent);
|
|
|
|
const indexResponse = await fetch('index.json');
|
|
const indexData = await indexResponse.json();
|
|
const docEntry = findDocumentByPath(indexData.documents, path);
|
|
|
|
const titleContent = metadata.title || (docEntry && docEntry.title) || path.split('/').pop().replace('.md', '');
|
|
|
|
document.title = `${originalDocTitle} / ${titleContent}`;
|
|
document.querySelector('.title-text .page-title').textContent = titleContent;
|
|
|
|
let processedContent = content.replace(/\[\[(.*?)\]\]/g, (match, linkText) => {
|
|
if (linkText.match(/\.(png|jpg|jpeg|gif|mp4|webm)$/i)) {
|
|
return match;
|
|
}
|
|
|
|
const doc = findDocumentByTitle(indexData.documents, linkText);
|
|
if (doc) {
|
|
return `[${doc.title}](?${doc.slug})`;
|
|
}
|
|
return match;
|
|
});
|
|
|
|
processedContent = `# ${titleContent}\n\n${processedContent}`;
|
|
|
|
let finalContent = processedContent.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
|
|
const mediaPath = `${basePath}/images/${filename}`;
|
|
|
|
if (filename.toLowerCase().endsWith('.mp4')) {
|
|
return `\n<video controls width="100%">
|
|
<source src="${mediaPath}" type="video/mp4">
|
|
Your browser does not support the video tag.
|
|
</video>\n\n`;
|
|
}
|
|
|
|
return `\n\n\n`;
|
|
});
|
|
|
|
// Add Discord-style underline support: __text__ -> <u>text</u>
|
|
finalContent = finalContent.replace(/__(.*?)__/g, '<u>$1</u>');
|
|
|
|
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) => {
|
|
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 = `<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';
|
|
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)';
|
|
let next = heading.nextElementSibling;
|
|
while (next && !/^H[1-6]$/.test(next.tagName)) {
|
|
next.style.display = isFolded ? 'none' : '';
|
|
next = next.nextElementSibling;
|
|
}
|
|
});
|
|
});
|
|
|
|
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]
|
|
});
|
|
|
|
headings.forEach(heading => observer.observe(heading));
|
|
|
|
return () => observer.disconnect();
|
|
|
|
} catch (error) {
|
|
document.getElementById('document-content').innerHTML =
|
|
'<div class="error">Error loading document. Please try again.</div>';
|
|
}
|
|
}
|
|
|
|
function findDocumentByPath(documents, path) {
|
|
for (const doc of documents) {
|
|
if (doc.type === 'folder') {
|
|
const found = findDocumentByPath(doc.items, path);
|
|
if (found) return found;
|
|
} else if (doc.path === path) {
|
|
return doc;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function fixImagePaths(basePath, content) {
|
|
return content.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
|
|
const imagePath = basePath + '/images/' + filename;
|
|
return ``;
|
|
});
|
|
}
|
|
|
|
function setupMobileMenu() {
|
|
const menuButton = document.querySelector('.menu-button');
|
|
const leftSidebar = document.querySelector('.left-sidebar');
|
|
const content = document.querySelector('.content');
|
|
|
|
menuButton.addEventListener('click', () => {
|
|
leftSidebar.classList.toggle('show');
|
|
});
|
|
|
|
content.addEventListener('click', () => {
|
|
if (leftSidebar.classList.contains('show')) {
|
|
leftSidebar.classList.remove('show');
|
|
}
|
|
});
|
|
}
|
|
|
|
window.addEventListener('load', () => {
|
|
setupMobileMenu();
|
|
loadIndex().catch(error => {
|
|
document.getElementById('document-content').innerHTML =
|
|
'<div class="error">Failed to load documentation. Please try refreshing the page.</div>';
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', async (e) => {
|
|
const target = e.target.closest('a[data-internal="true"]');
|
|
if (target) {
|
|
e.preventDefault();
|
|
const slug = target.href.split('?').pop();
|
|
const indexData = await fetch('index.json').then(res => res.json());
|
|
const matchingDoc = findDocumentBySlug(indexData.documents, slug);
|
|
if (matchingDoc) {
|
|
await loadDocument(matchingDoc.path);
|
|
history.pushState(null, '', target.href);
|
|
}
|
|
}
|
|
});
|