Enhance build-docs.js to extract headers from markdown files and include them in metadata

This commit is contained in:
2025-03-24 17:29:17 +11:00
parent 3ab838a224
commit eda31a8066
2 changed files with 189 additions and 128 deletions

View File

@@ -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;

164
index.js
View File

@@ -49,13 +49,27 @@ 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) {
this.searchIndex.push({ if (doc.showfolderpage !== 'false') {
title: doc.title, this.searchIndex.push({
path: doc.path, title: doc.title,
slug: doc.slug, path: doc.path,
location: currentPath, slug: doc.slug,
type: 'folder' location: currentPath,
}); 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
} }
} }
@@ -113,8 +141,8 @@ class IndexService {
const found = this.findDocumentByTitle(doc.items, title); const found = this.findDocumentByTitle(doc.items, title);
if (found) return found; if (found) return found;
} else if (doc.title === title || } else if (doc.title === title ||
doc.path.endsWith(title + '.md') || doc.path.endsWith(title + '.md') ||
doc.slug === title.toLowerCase().replace(/ /g, '-')) { doc.slug === title.toLowerCase().replace(/ /g, '-')) {
return doc; return doc;
} }
} }
@@ -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>
<a href="?${doc.slug}" class="folder-link" title="View folder page"> ${showFolderPage ? `
<i class="fas fa-file-alt"></i> <a href="?${doc.slug}" class="folder-link" title="View folder page">
</a>`; <i class="fas fa-file-alt"></i>
</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' });