Preview: index.js

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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}`);
});