/** * Markdown rendering engine with syntax highlighting and image resolution * Converts markdown to styled HTML for novel-reader UI */ const fs = require('fs'); const path = require('path'); // Lazy load dependencies let marked = null; let hljs = null; let matter = null; /** * Escape HTML entities to prevent XSS in mermaid content * @param {string} str - String to escape * @returns {string} - Escaped string */ function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Initialize markdown dependencies */ function initDependencies() { if (!marked) { const { Marked } = require('marked'); hljs = require('highlight.js'); marked = new Marked({ gfm: true, breaks: true }); // Custom extension for code blocks (handles mermaid specially) // marked v17+ requires extensions array for custom token handling const mermaidExtension = { name: 'mermaidCodeBlock', level: 'block', renderer(token) { // This is called for code tokens if (token.type === 'code') { const code = token.text || ''; const language = token.lang || ''; // Handle mermaid code blocks - render as div for client-side processing if (language === 'mermaid') { return `
${escapeHtml(code)}`;
}
// Regular code blocks with syntax highlighting
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, { language }).value;
return `${highlighted}`;
} catch {
// Fall through to default
}
}
// Auto-detect language or plain text
const highlighted = hljs.highlightAuto(code).value;
return `${highlighted}`;
}
return false; // Use default renderer for other tokens
}
};
// Use the renderer override approach for marked v17+
marked.use({
renderer: {
code(token) {
const code = typeof token === 'string' ? token : (token.text || '');
const language = typeof token === 'string' ? '' : (token.lang || '');
// Handle mermaid code blocks - render as div for client-side processing
if (language === 'mermaid') {
return `${escapeHtml(code)}`;
}
// Regular code blocks with syntax highlighting
if (language && hljs.getLanguage(language)) {
try {
const highlighted = hljs.highlight(code, { language }).value;
return `${highlighted}`;
} catch {
// Fall through to default
}
}
// Auto-detect language or plain text
const highlighted = hljs.highlightAuto(code).value;
return `${highlighted}`;
}
}
});
matter = require('gray-matter');
}
}
/**
* Resolve a single image source path to /file/ route
* @param {string} src - Image source path
* @param {string} basePath - Base directory path
* @returns {string} - Resolved path or original if absolute URL
*/
function resolveImageSrc(src, basePath) {
// Skip absolute URLs
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/file')) {
return src;
}
// Resolve relative path to absolute /file/ route
// Use URL encoding to handle special chars and Windows paths (D:\...)
const absolutePath = path.resolve(basePath, src);
return `/file/${encodeURIComponent(absolutePath)}`;
}
/**
* Resolve relative image paths to /file/ routes
* Supports both inline and reference-style markdown images
* @param {string} markdown - Markdown content
* @param {string} basePath - Base directory path
* @returns {string} - Markdown with resolved image paths
*/
function resolveImages(markdown, basePath) {
let result = markdown;
// 1. Handle inline images:  or 
const inlineImgRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
result = result.replace(inlineImgRegex, (match, alt, src) => {
const resolvedSrc = resolveImageSrc(src, basePath);
return ``;
});
// 2. Handle reference-style image definitions: [label]: src or [label]: src "title"
// These appear at the end of the document like: [Step 1 Initial]: ./screenshots/step1.png
const refDefRegex = /^\[([^\]]+)\]:\s*(\S+)(?:\s+"[^"]*")?$/gm;
result = result.replace(refDefRegex, (match, label, src) => {
const resolvedSrc = resolveImageSrc(src, basePath);
return `[${label}]: ${resolvedSrc}`;
});
return result;
}
/**
* Generate table of contents from headings
* @param {string} html - Rendered HTML
* @returns {Array<{level: number, id: string, text: string}>} - TOC items
*/
function generateTOC(html) {
const headings = [];
// Match h1-h3 with id attribute
const regex = /