Files
Docs-Viewer/js/DocumentService.js

304 lines
10 KiB
JavaScript

/**
* 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.
*/