Files
memind/mindspace-workspace-thumbnails.mjs
T
john 4b15610aa8
Memind CI / Test, build, and release guards (pull_request) Successful in 2m47s
feat(image): complete reviewed generation delivery
2026-07-20 20:04:53 +08:00

149 lines
5.2 KiB
JavaScript

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';
function reportSidecarFailure(kind, htmlRelativePath, error) {
// A page can be removed immediately after a fire-and-forget publish event.
// That is a normal cancellation, not a thumbnail generation failure.
if (error?.code === 'ENOENT') return;
console.warn(
`[MindSpaceThumbnail] ${kind} failed for ${htmlRelativePath}:`,
error instanceof Error ? error.message : error,
);
}
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((error) => reportSidecarFailure('PNG sidecar', htmlRelativePath, error));
});
}
/** 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((error) =>
reportSidecarFailure('sidecar refresh', htmlRelativePath, error));
});
}
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
}
}