import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { ensureThumbnailPng } from './mindspace-thumbnail-png.mjs'; import { ensureHtmlThumbnail } from './mindspace-thumbnails.mjs'; export function workspaceThumbnailRelativePath(htmlRelativePath) { const normalized = String(htmlRelativePath ?? '').replace(/^\/+/, ''); const dir = path.posix.dirname(normalized); const base = path.basename(normalized, path.extname(normalized)); const thumb = `${base}.thumbnail.svg`; return dir === '.' ? thumb : path.posix.join(dir, thumb); } export function workspaceThumbnailAbsolutePath(publishDir, htmlRelativePath) { return path.join(publishDir, workspaceThumbnailRelativePath(htmlRelativePath)); } function scheduleWorkspaceThumbnailPng(publishDir, htmlRelativePath) { queueMicrotask(() => { void Promise.resolve() .then(() => { const svgPath = workspaceThumbnailAbsolutePath(publishDir, htmlRelativePath); if (fs.existsSync(svgPath)) ensureThumbnailPng(svgPath); }) .catch(() => {}); }); } /** Fire-and-forget SVG + PNG sidecars after public HTML is written (must not block Finish/chat). */ export function scheduleWorkspaceHtmlThumbnailSidecars(publishDir, htmlRelativePath) { queueMicrotask(() => { void ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath).catch(() => {}); }); } export async function ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath, html, meta = {}) { const htmlPath = path.join(publishDir, htmlRelativePath); const content = html ?? (await fsPromises.readFile(htmlPath, 'utf8')); const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath); const svg = await ensureHtmlThumbnail(publishDir, thumbRel, content, { ...meta, contentBaseDir: path.dirname(htmlPath), }); scheduleWorkspaceThumbnailPng(publishDir, htmlRelativePath); return svg; } export async function readWorkspaceThumbnailIfExists(publishDir, htmlRelativePath) { try { return await fsPromises.readFile(workspaceThumbnailAbsolutePath(publishDir, htmlRelativePath), 'utf8'); } catch { return null; } } async function scanPublishTree(publishRoot) { if (!fs.existsSync(publishRoot)) return; const entries = await fsPromises.readdir(publishRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue; const userDir = path.join(publishRoot, entry.name); await scanUserHtmlFiles(userDir); } } async function scanUserHtmlFiles(userDir) { const walk = async (dir) => { const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === '.agents' || entry.name === 'node_modules') continue; await walk(full); continue; } if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue; const rel = path.relative(userDir, full); await ensureWorkspaceHtmlThumbnail(userDir, rel).catch(() => {}); } }; await walk(userDir); } export function startWorkspaceThumbnailWatcher(publishRoot) { if (!fs.existsSync(publishRoot)) { fs.mkdirSync(publishRoot, { recursive: true }); } void scanPublishTree(publishRoot); const pending = new Map(); const schedule = (userDir, relativePath) => { const key = path.join(userDir, relativePath); const existing = pending.get(key); if (existing) clearTimeout(existing); pending.set( key, setTimeout(() => { pending.delete(key); void ensureWorkspaceHtmlThumbnail(userDir, relativePath).catch(() => {}); }, 400), ); }; const attachUserWatcher = (userDir) => { if (!fs.existsSync(userDir)) return; void scanUserHtmlFiles(userDir); try { fs.watch(userDir, { recursive: true }, (_event, filename) => { if (!filename || !String(filename).endsWith('.html')) return; if (String(filename).endsWith('.thumbnail.svg')) return; schedule(userDir, filename); }); } catch { // Some platforms disallow recursive watch; startup scan still runs. } }; for (const entry of fs.readdirSync(publishRoot, { withFileTypes: true })) { if (entry.isDirectory() && entry.name !== 'wiki' && !entry.name.startsWith('.')) { attachUserWatcher(path.join(publishRoot, entry.name)); } } try { fs.watch(publishRoot, (_event, filename) => { if (!filename) return; const userDir = path.join(publishRoot, filename); if (fs.existsSync(userDir) && fs.statSync(userDir).isDirectory()) { attachUserWatcher(userDir); } }); } catch { // ignore } }