import express from 'express';
import { readFile, readdir } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
import { createHighlighter } from 'shiki';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = 3000;
const window = new JSDOM('').window;
const purify = DOMPurify(window);
let highlighter = null;
async function initHighlighter() {
highlighter = await createHighlighter({
themes: ['github-dark', 'github-light'],
langs: ['javascript', 'typescript', 'html', 'css', 'json', 'markdown', 'yaml', 'python', 'bash', 'sql', 'xml']
});
}
initHighlighter();
function getCategory(filename) {
const ext = filename.split('.').pop().toLowerCase();
const categories = {
svg: ['svg'],
mermaid: ['mmd'],
markup: ['html', 'xml'],
image: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'],
code: ['js', 'ts', 'mjs', 'jsx', 'tsx', 'css', 'scss', 'less', 'json', 'yaml', 'yml', 'toml', 'ini', 'conf', 'cfg', 'py', 'php', 'rb', 'sh', 'sql', 'md', 'txt', 'log'],
markdown: ['md', 'markdown']
};
for (const [cat, exts] of Object.entries(categories)) {
if (exts.includes(ext)) return cat;
}
return 'unknown';
}
function getLanguage(filename) {
const ext = filename.split('.').pop().toLowerCase();
const langMap = {
js: 'javascript', mjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript',
html: 'html', xml: 'xml',
css: 'css', scss: 'css', less: 'css',
json: 'json', yaml: 'yaml', yml: 'yaml',
py: 'python', sh: 'bash', md: 'markdown',
sql: 'sql', svg: 'xml'
};
return langMap[ext] || 'plaintext';
}
function highlightCode(code, lang) {
if (highlighter) {
const supportedLangs = highlighter.getLoadedLanguages();
if (supportedLangs.includes(lang)) {
return highlighter.codeToHtml(code, { lang, theme: 'github-dark' });
}
}
try {
return hljs.highlight(code, { language: lang }).value;
} catch {
return hljs.highlightAuto(code).value;
}
}
function escapeHtml(text) {
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function generatePreviewPage(filename, content) {
const category = getCategory(filename);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preview: ${filename}</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #1e1e1e; color: #fff; min-height: 100vh; }
.header { background: #2d2d2d; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #3d3d3d; }
.header h1 { font-size: 1.2em; color: #58a6ff; }
.file-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 15px 20px; background: #252525; border-bottom: 1px solid #3d3d3d; }
.file-list a { padding: 6px 12px; background: #3d3d3d; color: #fff; text-decoration: none; border-radius: 4px; font-size: 0.85em; transition: background 0.2s; }
.file-list a:hover { background: #58a6ff; color: #fff; }
.file-list a.current { background: #58a6ff; }
.preview-container { padding: 20px; max-width: 100%; overflow-x: auto; }
pre code.hljs { padding: 20px; border-radius: 8px; font-size: 14px; line-height: 1.5; }
.mermaid { background: #fff; padding: 20px; border-radius: 8px; margin: 20px; }
.markdown-body { padding: 20px; max-width: 900px; margin: 0 auto; }
.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 20px 0 10px; border-bottom: 1px solid #3d3d3d; padding-bottom: 8px; }
.markdown-body p { margin: 15px 0; line-height: 1.7; }
.markdown-body code { background: #3d3d3d; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
.markdown-body pre { background: #2d2d2d; padding: 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; }
.markdown-body pre code { background: none; padding: 0; }
.markdown-body ul, .markdown-body ol { margin: 15px 0; padding-left: 25px; }
.markdown-body li { margin: 8px 0; }
.markdown-body blockquote { border-left: 4px solid #58a6ff; padding-left: 15px; margin: 15px 0; color: #aaa; }
.markdown-body img { max-width: 100%; border-radius: 8px; margin: 15px 0; }
.markdown-body a { color: #58a6ff; }
.markdown-body table { border-collapse: collapse; margin: 15px 0; width: 100%; }
.markdown-body th, .markdown-body td { border: 1px solid #3d3d3d; padding: 10px; text-align: left; }
.markdown-body th { background: #2d2d2d; }
.image-container { padding: 20px; text-align: center; }
.image-container img { max-width: 100%; height: auto; border-radius: 8px; }
.svg-container { padding: 20px; background: #fff; border-radius: 8px; display: inline-block; }
.svg-container img { max-width: 100%; }
.error { color: #ff6b6b; padding: 20px; }
</style>
</head>
<body>
<div class="header">
<h1>Preview: ${filename}</h1>
</div>
<div class="file-list" id="fileList"></div>
<div class="preview-container" id="preview">${content}</div>
<script>
async function loadFileList() {
try {
const res = await fetch('/files');
const files = await res.json();
const list = document.getElementById('fileList');
const current = '${filename}';
list.innerHTML = files.map(f =>
'<a href="?file=' + encodeURIComponent(f) + '"' + (f === current ? ' class="current"' : '') + '>' + f + '</a>'
).join('');
} catch (e) { console.error('Failed to load file list:', e); }
}
loadFileList();
</script>
</body>
</html>`;
}
async function renderContent(filename, content) {
const category = getCategory(filename);
const cleanContent = purify.sanitize(content, { USE_PROFILES: { html: true } });
if (category === 'code') {
const lang = getLanguage(filename);
return `<pre><code class="hljs language-${lang}">${escapeHtml(content)}</code></pre>`;
}
if (category === 'markdown') {
const { marked } = await import('marked');
const { markedHighlight } = await import('marked-highlight');
const { marked: configuredMarked } = marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
return highlightCode(code, lang || 'plaintext');
}
}));
const html = await configuredMarked(content);
return `<div class="markdown-body">${html}</div>`;
}
if (category === 'mermaid') {
return `<div class="mermaid">${cleanContent}</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
</script>`;
}
if (category === 'svg') {
return `<div class="svg-container">${cleanContent}</div>`;
}
if (category === 'image') {
return `<div class="image-container"><img src="/raw/${filename}" alt="${filename}"></div>`;
}
if (category === 'markup') {
return `<div class="code-container"><pre><code class="hljs language-html">${escapeHtml(content)}</code></pre></div>`;
}
return `<pre><code>${escapeHtml(content)}</code></pre>`;
}
app.get('/', async (req, res) => {
const file = req.query.file;
if (!file) {
try {
const files = await readdir(__dirname);
const html = generatePreviewPage('', `
<div style="padding: 40px; text-align: center;">
<h2 style="margin-bottom: 20px;">Preview Server</h2>
<p style="color: #888;">Use ?file=filename to preview a file.</p>
<div class="file-list" style="margin-top: 20px; justify-content: center;">
${files.map(f => `<a href="?file=${encodeURIComponent(f)}">${f}</a>`).join('')}
</div>
</div>
`);
res.send(html);
} catch (err) {
res.send(generatePreviewPage('', `<p class="error">Error: ${err.message}</p>`));
}
return;
}
const filePath = join(__dirname, file);
try {
const content = await readFile(filePath, 'utf-8');
const rendered = await renderContent(file, content);
const html = generatePreviewPage(file, rendered);
res.send(html);
} catch (err) {
res.send(generatePreviewPage(file, `<p class="error">Error loading file: ${err.message}</p>`));
}
});
app.get('/raw/:file', async (req, res) => {
const filePath = join(__dirname, req.params.file);
try {
res.sendFile(filePath);
} catch (err) {
res.status(404).send('File not found');
}
});
app.get('/files', async (req, res) => {
try {
const files = await readdir(__dirname);
res.json(files);
} catch (err) {
res.json([]);
}
});
app.listen(PORT, () => {
console.log(`Preview server running at http://localhost:${PORT}`);
});