diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index d0d970b..d9792d8 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { extractStaticPageLinks } from './mindspace-chat-save.mjs'; import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs'; +import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs'; /** * Public HTML finish-sync invariants (regression guard — do not simplify away). @@ -398,6 +399,7 @@ export function materializeMissingPublicHtmlWrites({ messages, publishDir }) { fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.writeFileSync(destination, artifact.content, 'utf8'); materialized.push(artifact.relativePath); + scheduleWorkspaceHtmlThumbnailSidecars(root, artifact.relativePath); } catch { skipped.push(artifact.relativePath); } diff --git a/mindspace-public-route.mjs b/mindspace-public-route.mjs index 7025b55..4be24d4 100644 --- a/mindspace-public-route.mjs +++ b/mindspace-public-route.mjs @@ -191,6 +191,20 @@ export async function resolveMindSpacePublicRequest({ }; } + // og:image points at .thumbnail.png while only the SVG sidecar may exist on disk. + // Serve resolves lazily via ensureThumbnailPng in the MindSpace public file handler. + if (/\.thumbnail\.png$/i.test(resolvedPath)) { + const svgSibling = resolvedPath.replace(/\.png$/i, '.svg'); + if (fs.existsSync(svgSibling)) { + return { + action: 'serve', + filePath: resolvedPath, + ownerKey, + targetDir, + }; + } + } + return { action: 'not_found', reason: 'missing_file', ownerKey }; } diff --git a/mindspace-public-route.test.mjs b/mindspace-public-route.test.mjs index af1f143..05853aa 100644 --- a/mindspace-public-route.test.mjs +++ b/mindspace-public-route.test.mjs @@ -120,3 +120,30 @@ test('resolveMindSpacePublicRequest coordinates canonical redirect, serve, and n ownerKey, }); }); + +test('resolveMindSpacePublicRequest serves missing thumbnail png when svg sidecar exists', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-public-thumb-')); + const ownerKey = '123e4567-e89b-12d3-a456-426614174000'; + const targetDir = path.join(root, 'MindSpace', ownerKey); + const publicDir = path.join(targetDir, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'report.thumbnail.svg'), ''); + + const served = await resolveMindSpacePublicRequest({ + h5Root: root, + requestPath: `/${ownerKey}/public/report.thumbnail.png`, + }); + assert.equal(served.action, 'serve'); + assert.equal(served.filePath, path.join(publicDir, 'report.thumbnail.png')); + assert.equal(fs.existsSync(served.filePath), false); + + const stillMissing = await resolveMindSpacePublicRequest({ + h5Root: root, + requestPath: `/${ownerKey}/public/missing.thumbnail.png`, + }); + assert.deepEqual(stillMissing, { + action: 'not_found', + reason: 'missing_file', + ownerKey, + }); +}); diff --git a/mindspace-workspace-thumbnails.mjs b/mindspace-workspace-thumbnails.mjs index 25abd75..59bc396 100644 --- a/mindspace-workspace-thumbnails.mjs +++ b/mindspace-workspace-thumbnails.mjs @@ -1,6 +1,7 @@ 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) { @@ -15,14 +16,34 @@ 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); - return ensureHtmlThumbnail(publishDir, thumbRel, content, { + const svg = await ensureHtmlThumbnail(publishDir, thumbRel, content, { ...meta, contentBaseDir: path.dirname(htmlPath), }); + scheduleWorkspaceThumbnailPng(publishDir, htmlRelativePath); + return svg; } export async function readWorkspaceThumbnailIfExists(publishDir, htmlRelativePath) { diff --git a/mindspace-workspace-thumbnails.test.mjs b/mindspace-workspace-thumbnails.test.mjs index 7cd0b99..efbd417 100644 --- a/mindspace-workspace-thumbnails.test.mjs +++ b/mindspace-workspace-thumbnails.test.mjs @@ -27,3 +27,16 @@ test('ensureWorkspaceHtmlThumbnail writes sidecar on html save', async () => { assert.match(svg, /运动|普拉提618/i); await fs.access(path.join(root, 'pilates.thumbnail.svg')); }); + +test('ensureWorkspaceHtmlThumbnail schedules raster png sidecar', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'workspace-thumb-png-')); + const html = ` + PNG sidecar + + `; + await fs.writeFile(path.join(root, 'demo.html'), html, 'utf8'); + await ensureWorkspaceHtmlThumbnail(root, 'demo.html', html, { title: 'PNG sidecar' }); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + await fs.access(path.join(root, 'demo.thumbnail.png')); +});