mirror of
https://github.com/litruv/Docs-Viewer.git
synced 2026-07-24 02:36:07 +10:00
Enhance build-docs.js to extract headers from markdown files and include them in metadata
This commit is contained in:
@@ -1,7 +1,21 @@
|
|||||||
//get all *.md files in ./docs, reccursively, and store them in an array, with their properties in the first block, they're from obsidian
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
function extractHeaders(content) {
|
||||||
|
const headerRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
const headers = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = headerRegex.exec(content)) !== null) {
|
||||||
|
// Don't include h1 headers as they're typically the title
|
||||||
|
if (match[1].length > 1) {
|
||||||
|
headers.push(match[2].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
function parseFrontMatter(content) {
|
function parseFrontMatter(content) {
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
let metadata = {};
|
let metadata = {};
|
||||||
@@ -20,9 +34,15 @@ function parseFrontMatter(content) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentText = lines.slice(contentStart).join('\n').trim();
|
||||||
|
const headers = extractHeaders(contentText);
|
||||||
|
if (headers.length > 0) {
|
||||||
|
metadata.headers = headers;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadata,
|
metadata,
|
||||||
content: lines.slice(contentStart).join('\n').trim()
|
content: contentText
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +144,6 @@ function loadIndexTemplate() {
|
|||||||
const indexPath = path.join(__dirname, 'index.json');
|
const indexPath = path.join(__dirname, 'index.json');
|
||||||
try {
|
try {
|
||||||
const indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
const indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||||
// Strip out documents array, keeping all other properties
|
|
||||||
delete indexData.documents;
|
delete indexData.documents;
|
||||||
return indexData;
|
return indexData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,12 +175,10 @@ function combineIndexes(dir) {
|
|||||||
const indexData = loadIndexTemplate();
|
const indexData = loadIndexTemplate();
|
||||||
const documents = readAllMarkdownFiles(dir);
|
const documents = readAllMarkdownFiles(dir);
|
||||||
|
|
||||||
// Add the documents array with new content
|
|
||||||
indexData.documents = documents;
|
indexData.documents = documents;
|
||||||
writeIndexFile(indexData);
|
writeIndexFile(indexData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace console.log with combineIndexes call
|
|
||||||
combineIndexes(path.join(__dirname, 'docs'));
|
combineIndexes(path.join(__dirname, 'docs'));
|
||||||
|
|
||||||
module.exports = readAllMarkdownFiles;
|
module.exports = readAllMarkdownFiles;
|
||||||
|
|||||||
140
index.js
140
index.js
@@ -49,6 +49,7 @@ class SearchService {
|
|||||||
if (doc.type === 'folder') {
|
if (doc.type === 'folder') {
|
||||||
const currentPath = parentPath ? `${parentPath} / ${doc.title}` : doc.title;
|
const currentPath = parentPath ? `${parentPath} / ${doc.title}` : doc.title;
|
||||||
if (doc.path) {
|
if (doc.path) {
|
||||||
|
if (doc.showfolderpage !== 'false') {
|
||||||
this.searchIndex.push({
|
this.searchIndex.push({
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
path: doc.path,
|
path: doc.path,
|
||||||
@@ -57,6 +58,19 @@ class SearchService {
|
|||||||
type: 'folder'
|
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) {
|
if (doc.items) {
|
||||||
this.processDocuments(doc.items, currentPath);
|
this.processDocuments(doc.items, currentPath);
|
||||||
}
|
}
|
||||||
@@ -68,6 +82,18 @@ class SearchService {
|
|||||||
location: parentPath,
|
location: parentPath,
|
||||||
type: 'file'
|
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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -77,21 +103,23 @@ class SearchService {
|
|||||||
query = query.toLowerCase();
|
query = query.toLowerCase();
|
||||||
|
|
||||||
return this.searchIndex
|
return this.searchIndex
|
||||||
.filter(item => {
|
.map(item => {
|
||||||
const titleMatch = item.title.toLowerCase().includes(query);
|
const titleLower = item.title.toLowerCase();
|
||||||
const pathMatch = item.path.toLowerCase().includes(query);
|
let score = 0;
|
||||||
const locationMatch = item.location.toLowerCase().includes(query);
|
|
||||||
return titleMatch || pathMatch || locationMatch;
|
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 };
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.filter(item => item.score > 0)
|
||||||
// Prioritize exact matches in title
|
.sort((a, b) => b.score - a.score)
|
||||||
const aTitle = a.title.toLowerCase();
|
.slice(0, 10);
|
||||||
const bTitle = b.title.toLowerCase();
|
|
||||||
if (aTitle === query) return -1;
|
|
||||||
if (bTitle === query) return 1;
|
|
||||||
return aTitle.localeCompare(bTitle);
|
|
||||||
})
|
|
||||||
.slice(0, 10); // Limit to 10 results
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +184,12 @@ class DOMService {
|
|||||||
titleText: document.querySelector('.title-text .page-title'),
|
titleText: document.querySelector('.title-text .page-title'),
|
||||||
leftSidebar: document.querySelector('.left-sidebar'),
|
leftSidebar: document.querySelector('.left-sidebar'),
|
||||||
menuButton: document.querySelector('.menu-button'),
|
menuButton: document.querySelector('.menu-button'),
|
||||||
header: document.querySelector('title-bar'), // Add header element
|
header: document.querySelector('title-bar'),
|
||||||
searchInput: document.getElementById('search-input'),
|
searchInput: document.getElementById('search-input'),
|
||||||
searchResults: document.getElementById('search-results'),
|
searchResults: document.getElementById('search-results'),
|
||||||
clearSearch: document.getElementById('clear-search')
|
clearSearch: document.getElementById('clear-search')
|
||||||
};
|
};
|
||||||
this.headerOffset = 60; // Fixed header heightfsetHeight || 0; // Get header height
|
this.headerOffset = 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupMobileMenu() {
|
setupMobileMenu() {
|
||||||
@@ -196,14 +224,24 @@ class DOMService {
|
|||||||
|
|
||||||
const folderHeader = document.createElement('div');
|
const folderHeader = document.createElement('div');
|
||||||
folderHeader.className = 'folder-header';
|
folderHeader.className = 'folder-header';
|
||||||
|
|
||||||
const iconClass = doc.icon || `fas fa-folder${doc.defaultOpen !== false ? '-open' : ''}`;
|
const iconClass = doc.icon || `fas fa-folder${doc.defaultOpen !== false ? '-open' : ''}`;
|
||||||
|
|
||||||
folderHeader.innerHTML = doc.path ?
|
// Only add folder click handler if showfolderpage is not false
|
||||||
this.createFolderHeaderWithFile(iconClass, doc) :
|
if (doc.path && doc.metadata?.showfolderpage !== false) {
|
||||||
this.createFolderHeaderBasic(iconClass, doc);
|
folderHeader.innerHTML = this.createFolderHeaderWithFile(iconClass, doc);
|
||||||
|
|
||||||
this.setupFolderListeners(folderDiv, folderHeader, doc);
|
this.setupFolderListeners(folderDiv, folderHeader, doc);
|
||||||
|
} else {
|
||||||
|
folderHeader.innerHTML = this.createFolderHeaderBasic(iconClass, doc);
|
||||||
|
// Only setup the folder open/close functionality
|
||||||
|
folderHeader.addEventListener('click', () => {
|
||||||
|
folderDiv.classList.toggle('open');
|
||||||
|
if (!doc.icon) {
|
||||||
|
const icon = folderHeader.querySelector('.folder-icon');
|
||||||
|
icon.classList.toggle('fa-folder-closed');
|
||||||
|
icon.classList.toggle('fa-folder-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const folderContent = document.createElement('div');
|
const folderContent = document.createElement('div');
|
||||||
folderContent.className = 'folder-content';
|
folderContent.className = 'folder-content';
|
||||||
@@ -263,14 +301,16 @@ class DOMService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFolderHeaderWithFile(iconClass, doc) {
|
createFolderHeaderWithFile(iconClass, doc) {
|
||||||
|
const showFolderPage = doc.showfolderpage !== 'false';
|
||||||
return `
|
return `
|
||||||
<div class="folder-icons">
|
<div class="folder-icons">
|
||||||
<i class="${iconClass} folder-icon"></i>
|
<i class="${iconClass} folder-icon"></i>
|
||||||
</div>
|
</div>
|
||||||
<span>${doc.title}</span>
|
<span>${doc.title}</pan>
|
||||||
|
${showFolderPage ? `
|
||||||
<a href="?${doc.slug}" class="folder-link" title="View folder page">
|
<a href="?${doc.slug}" class="folder-link" title="View folder page">
|
||||||
<i class="fas fa-file-alt"></i>
|
<i class="fas fa-file-alt"></i>
|
||||||
</a>`;
|
</a>` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
createFolderHeaderBasic(iconClass, doc) {
|
createFolderHeaderBasic(iconClass, doc) {
|
||||||
@@ -402,7 +442,9 @@ class DOMService {
|
|||||||
div.className = 'search-result';
|
div.className = 'search-result';
|
||||||
|
|
||||||
const icon = document.createElement('i');
|
const icon = document.createElement('i');
|
||||||
icon.className = result.type === 'folder' ? 'fas fa-folder' : 'fas fa-file-alt';
|
icon.className = result.type === 'folder' ? 'fas fa-folder' :
|
||||||
|
result.type === 'header' ? 'fas fa-hashtag' :
|
||||||
|
'fas fa-file-alt';
|
||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = `?${result.slug}`;
|
link.href = `?${result.slug}`;
|
||||||
@@ -418,8 +460,13 @@ class DOMService {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.elements.searchInput.value = '';
|
this.elements.searchInput.value = '';
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
|
const [baseSlug, hash] = result.slug.split('#');
|
||||||
history.pushState(null, '', link.href);
|
history.pushState(null, '', link.href);
|
||||||
this.eventBus.emit('navigation:requested', { slug: result.slug });
|
this.eventBus.emit('navigation:requested', {
|
||||||
|
slug: baseSlug,
|
||||||
|
hash: hash ? `#${hash}` : '',
|
||||||
|
fromSearch: true // Add this flag
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
div.appendChild(link);
|
div.appendChild(link);
|
||||||
@@ -472,7 +519,6 @@ class DocumentService {
|
|||||||
return link.replace(/^<a /, `<a${attrs} `);
|
return link.replace(/^<a /, `<a${attrs} `);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add custom heading renderer to make headers clickable - without link icon
|
|
||||||
const originalHeading = renderer.heading.bind(renderer);
|
const originalHeading = renderer.heading.bind(renderer);
|
||||||
renderer.heading = (text, level) => {
|
renderer.heading = (text, level) => {
|
||||||
const escapedText = text.toLowerCase()
|
const escapedText = text.toLowerCase()
|
||||||
@@ -610,15 +656,12 @@ class NavigationService {
|
|||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
window.addEventListener('popstate', async () => {
|
window.addEventListener('popstate', async () => {
|
||||||
const search = window.location.search;
|
const search = window.location.search;
|
||||||
// If no search params or just '?', use default page
|
const hash = window.location.hash;
|
||||||
const slug = (search === '' || search === '?') ?
|
const slug = (search === '' || search === '?') ?
|
||||||
window._indexData.defaultPage :
|
window._indexData.defaultPage :
|
||||||
search.replace(/^\?/, '');
|
search.replace(/^\?/, '').split('#')[0];
|
||||||
|
|
||||||
this.eventBus.emit('navigation:requested', {
|
this.eventBus.emit('navigation:requested', { slug, hash });
|
||||||
slug,
|
|
||||||
hash: window.location.hash
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
@@ -626,7 +669,6 @@ class NavigationService {
|
|||||||
if (target) {
|
if (target) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const slug = target.href.split('?').pop();
|
const slug = target.href.split('?').pop();
|
||||||
// Use clean URL format without equal sign
|
|
||||||
history.pushState(null, '', `?${slug}`);
|
history.pushState(null, '', `?${slug}`);
|
||||||
this.eventBus.emit('navigation:requested', { slug });
|
this.eventBus.emit('navigation:requested', { slug });
|
||||||
}
|
}
|
||||||
@@ -647,9 +689,9 @@ class Documentation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.eventBus.on('navigation:requested', async ({ slug }) => {
|
this.eventBus.on('navigation:requested', async ({ slug, hash }) => {
|
||||||
slug = slug.replace(/^[?=]/, '');
|
slug = slug.replace(/^[?=]/, '');
|
||||||
await this.loadDocumentBySlug(slug);
|
await this.loadDocumentBySlug(slug, hash);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventBus.on('document:load', async ({ path }) => {
|
this.eventBus.on('document:load', async ({ path }) => {
|
||||||
@@ -692,7 +734,6 @@ class Documentation {
|
|||||||
this.indexData.documents.forEach(doc =>
|
this.indexData.documents.forEach(doc =>
|
||||||
this.domService.createFileIndexItem(doc, this.domService.elements.fileIndex));
|
this.domService.createFileIndexItem(doc, this.domService.elements.fileIndex));
|
||||||
|
|
||||||
// Simplified URL parameter handling
|
|
||||||
const search = window.location.search;
|
const search = window.location.search;
|
||||||
const slug = search === '' || search === '?'
|
const slug = search === '' || search === '?'
|
||||||
? this.indexData.defaultPage
|
? this.indexData.defaultPage
|
||||||
@@ -735,29 +776,30 @@ class Documentation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadDocumentBySlug(slug) {
|
async loadDocumentBySlug(slug, hash) {
|
||||||
const doc = this.indexService.findDocumentBySlug(this.indexData.documents, slug);
|
const [baseSlug] = slug.split('#');
|
||||||
|
const doc = this.indexService.findDocumentBySlug(this.indexData.documents, baseSlug);
|
||||||
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
this.domService.setError(`Document not found: ${slug}`);
|
this.domService.setError(`Document not found: ${baseSlug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doc.type === 'folder') {
|
if (doc.type === 'folder') {
|
||||||
if (doc.path) {
|
if (doc.path) {
|
||||||
await this.loadDocument(doc.path);
|
await this.loadDocument(doc.path, hash);
|
||||||
} else if (doc.items?.length > 0) {
|
} else if (doc.items?.length > 0) {
|
||||||
await this.loadDocument(doc.items[0].path);
|
await this.loadDocument(doc.items[0].path, hash);
|
||||||
} else {
|
} else {
|
||||||
this.domService.setError('This folder is empty.');
|
this.domService.setError('This folder is empty.');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadDocument(doc.path);
|
await this.loadDocument(doc.path, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadDocument(path) {
|
async loadDocument(path, hash, fromSearch = false) {
|
||||||
try {
|
try {
|
||||||
const { content, metadata, marked, title } = await this.documentService.loadDocument(path);
|
const { content, metadata, marked, title } = await this.documentService.loadDocument(path);
|
||||||
|
|
||||||
@@ -768,10 +810,8 @@ class Documentation {
|
|||||||
const headings = document.querySelectorAll('h2, h3, h4, h5, h6');
|
const headings = document.querySelectorAll('h2, h3, h4, h5, h6');
|
||||||
const headingLinks = this.domService.createOutline(headings);
|
const headingLinks = this.domService.createOutline(headings);
|
||||||
|
|
||||||
// Add click handlers to all headers
|
|
||||||
headings.forEach(heading => {
|
headings.forEach(heading => {
|
||||||
heading.addEventListener('click', (e) => {
|
heading.addEventListener('click', (e) => {
|
||||||
// Don't trigger if clicking on fold toggle
|
|
||||||
if (e.target.closest('svg') || e.target.closest('.header-anchor')) return;
|
if (e.target.closest('svg') || e.target.closest('.header-anchor')) return;
|
||||||
|
|
||||||
const id = heading.id;
|
const id = heading.id;
|
||||||
@@ -789,12 +829,16 @@ class Documentation {
|
|||||||
|
|
||||||
window._currentPath = path;
|
window._currentPath = path;
|
||||||
|
|
||||||
if (window.location.hash) {
|
if (hash) {
|
||||||
const element = document.getElementById(window.location.hash.slice(1));
|
const element = document.getElementById(hash.slice(1));
|
||||||
if (element) {
|
if (element) {
|
||||||
|
const delay = fromSearch ? 300 : 100;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.domService.scrollToElement(element);
|
this.domService.scrollToElement(element);
|
||||||
}, 100);
|
element.classList.remove('highlight');
|
||||||
|
void element.offsetWidth;
|
||||||
|
element.classList.add('highlight');
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|||||||
Reference in New Issue
Block a user