feat: better previews and consistent previews

This commit is contained in:
2026-02-17 06:47:57 +01:00
parent 4ce1654f47
commit b2db7c6df0
15 changed files with 508 additions and 1241 deletions

View File

@@ -63,6 +63,25 @@ const PREVIEW_ASSETS = {
},
} as const;
const PREVIEW_IMAGE_ASSETS = {
'prev.png': {
modulePath: 'lightbox2/dist/images/prev.png',
contentType: 'image/png',
},
'next.png': {
modulePath: 'lightbox2/dist/images/next.png',
contentType: 'image/png',
},
'close.png': {
modulePath: 'lightbox2/dist/images/close.png',
contentType: 'image/png',
},
'loading.gif': {
modulePath: 'lightbox2/dist/images/loading.gif',
contentType: 'image/gif',
},
} as const;
function clampMaxPostsPerPage(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_MAX_POSTS_PER_PAGE;
@@ -415,6 +434,12 @@ export class PreviewServer {
return;
}
const imageAsset = await this.resolveImageAsset(pathname);
if (imageAsset) {
this.respondAsset(res, imageAsset.contentType, imageAsset.body);
return;
}
const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir);
if (mediaAsset) {
this.respondAsset(res, mediaAsset.contentType, mediaAsset.body);
@@ -705,6 +730,27 @@ export class PreviewServer {
}
}
private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
const match = pathname.match(/^\/images\/([^/]+)$/);
if (!match) return null;
const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS;
const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName];
if (!assetDefinition) return null;
try {
const absolutePath = require.resolve(assetDefinition.modulePath);
const body = await readFile(absolutePath);
return {
contentType: assetDefinition.contentType,
body,
};
} catch (error) {
console.error(`[PreviewServer] Failed to read image asset: ${assetDefinition.modulePath}`, error);
return null;
}
}
private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> {
const match = pathname.match(/^\/media\/(.+)$/);
if (!match || !dataDir) return null;

View File

@@ -30,6 +30,22 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>):
});
}
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
return `/${year}/${month}/${day}/${slug}`;
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
}
const parsed = new Date(post.createdAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
export function registerIpcHandlers(): void {
// ============ Git Handlers ============
@@ -256,6 +272,19 @@ export function registerIpcHandlers(): void {
return engine.getPost(id);
});
safeHandle('posts:getPreviewUrl', async (_, id: string) => {
const engine = getPostEngine();
const post = await engine.getPost(id);
if (!post) {
return null;
}
const createdAt = resolvePostCreatedAt(post);
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
return `http://127.0.0.1:4123${canonicalPath}`;
});
safeHandle('posts:getAll', async (_, options?: PaginationOptions) => {
const engine = getPostEngine();
return engine.getAllPosts(options);

View File

@@ -6,11 +6,14 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea
import { media } from './database/schema';
import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine';
import { PreviewServer } from './engine/PreviewServer';
let mainWindow: BrowserWindow | null = null;
let previewServer: PreviewServer | null = null;
let activePreviewPostId: string | null = null;
const PREVIEW_SERVER_PORT = 4123;
const BLOG_PREVIEW_POST_MENU_ID = 'blog.previewPost';
// Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === 'development';
@@ -109,6 +112,42 @@ async function openPreviewInBrowser(): Promise<void> {
await shell.openExternal(`${previewServer.getBaseUrl()}/`);
}
function buildCanonicalPostPath(createdAt: Date, slug: string): string {
const year = createdAt.getFullYear();
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
const day = String(createdAt.getDate()).padStart(2, '0');
return `/${year}/${month}/${day}/${slug}`;
}
function setPreviewPostMenuEnabled(enabled: boolean): void {
const appMenu = Menu.getApplicationMenu();
const previewPostMenuItem = appMenu?.getMenuItemById(BLOG_PREVIEW_POST_MENU_ID);
if (previewPostMenuItem) {
previewPostMenuItem.enabled = enabled;
}
}
async function openActivePostPreviewInBrowser(): Promise<void> {
if (!activePreviewPostId) {
return;
}
const postEngine = getPostEngine();
const post = await postEngine.getPost(activePreviewPostId);
if (!post) {
setPreviewPostMenuEnabled(false);
return;
}
if (!previewServer) {
previewServer = new PreviewServer();
}
await previewServer.start(PREVIEW_SERVER_PORT);
const canonicalPath = buildCanonicalPostPath(post.createdAt, post.slug);
await shell.openExternal(`${previewServer.getBaseUrl()}${canonicalPath}`);
}
async function startPreviewServerOnAppStart(): Promise<void> {
if (!previewServer) {
previewServer = new PreviewServer();
@@ -265,9 +304,15 @@ function createApplicationMenu(): Menu {
{ type: 'separator' },
{
label: 'Preview Post',
id: BLOG_PREVIEW_POST_MENU_ID,
enabled: false,
accelerator: 'CmdOrCtrl+Shift+V',
click: () => {
mainWindow?.webContents.send('menu:previewPost');
click: async () => {
try {
await openActivePostPreviewInBrowser();
} catch (error) {
console.error('Failed to preview active post in browser:', error);
}
},
},
{ type: 'separator' },
@@ -445,6 +490,11 @@ async function initialize(): Promise<void> {
// Register IPC handlers
registerIpcHandlers();
ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => {
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
});
// Initialize and register chat handlers
initializeChatHandlers(() => mainWindow);
registerChatHandlers();

View File

@@ -52,6 +52,7 @@ export const electronAPI: ElectronAPI = {
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getPreviewUrl: (id: string) => ipcRenderer.invoke('posts:getPreviewUrl', id),
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
@@ -140,6 +141,7 @@ export const electronAPI: ElectronAPI = {
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
},
// Meta (tags, categories, and project metadata)

View File

@@ -430,6 +430,7 @@ export interface ElectronAPI {
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getPreviewUrl: (id: string) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
@@ -508,6 +509,7 @@ export interface ElectronAPI {
selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>;
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
setPreviewPostTarget: (postId: string | null) => Promise<void>;
};
meta: {
getTags: () => Promise<string[]>;

View File

@@ -193,10 +193,31 @@
}
.editor-toolbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.editor-toolbar-left {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
justify-content: flex-start;
}
.editor-toolbar-center {
display: flex;
align-items: center;
justify-content: center;
}
.editor-toolbar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.editor-mode-toggle {
@@ -233,7 +254,6 @@
border: none;
cursor: pointer;
transition: background-color 0.15s;
margin-left: auto;
}
.gallery-button:hover {
@@ -261,107 +281,26 @@
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
padding: 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.6;
overflow: hidden;
position: relative;
}
/* Hydration loading overlay */
.preview-hydrating-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(30, 30, 30, 0.85);
.editor-preview-frame {
width: 100%;
height: 100%;
border: none;
background: var(--vscode-editor-background);
}
.editor-preview-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 4px;
}
.preview-hydrating-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-foreground);
font-size: 14px;
}
.preview-hydrating-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--vscode-panel-border);
border-top-color: var(--vscode-focusBorder);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.editor-preview .preview-content {
max-width: 800px;
margin: 0 auto;
}
.editor-preview h1,
.editor-preview h2,
.editor-preview h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--vscode-foreground);
}
.editor-preview h1 { font-size: 2em; }
.editor-preview h2 { font-size: 1.5em; }
.editor-preview h3 { font-size: 1.25em; }
.editor-preview code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
}
.editor-preview pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.editor-preview pre code {
background: none;
padding: 0;
}
.editor-preview blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 16px;
margin-left: 0;
color: var(--vscode-descriptionForeground);
}
.editor-preview a {
color: var(--vscode-textLink-foreground);
}
.editor-preview a:hover {
color: var(--vscode-textLink-activeForeground);
}
.editor-preview img {
max-width: 100%;
border-radius: 6px;
cursor: pointer;
}
.editor-field-row {
display: flex;
gap: 12px;

View File

@@ -16,10 +16,8 @@ import { ImportAnalysisView } from '../ImportAnalysisView';
import { MetadataDiffPanel } from '../MetadataDiffPanel';
import { GitDiffView } from '../GitDiffView/GitDiffView';
import { AutoSaveManager, getContrastColor } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
import { marked } from 'marked';
import './Editor.css';
/** Get display name for media: prefer title over originalName */
@@ -116,380 +114,6 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
});
};
// Render a macro synchronously for preview
const renderMacroSync = (name: string, params: Record<string, string>, postId?: string): string => {
const macro = getMacro(name);
if (!macro) {
return `<span class="macro-error">Unknown macro: ${name}</span>`;
}
try {
const result = macro.render(params, { postId, isPreview: true });
// If it returns a promise, show loading state (shouldn't happen for gallery)
if (result instanceof Promise) {
return `<div class="macro-loading">Loading ${name}...</div>`;
}
return result;
} catch (e) {
return `<span class="macro-error">Error rendering ${name}</span>`;
}
};
// Simple markdown to HTML converter for preview
export const markdownToHtml = (markdown: string, postId?: string): string => {
// First, render macros
const macros = parseMacros(markdown);
let result = markdown;
// Replace macros from end to start to preserve positions
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
const rendered = renderMacroSync(macro.name, macro.params, postId);
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
}
return marked.parse(result, {
gfm: true,
breaks: false,
async: false,
}) as string;
};
/**
* Hydrate gallery elements in the preview with actual linked media
*/
const hydrateGalleries = async (
container: HTMLElement,
postId: string,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => {
const galleries = container.querySelectorAll('.macro-gallery[data-post-id]');
for (const gallery of galleries) {
const galleryPostId = gallery.getAttribute('data-post-id');
if (!galleryPostId || galleryPostId !== postId) continue;
const galleryContainer = gallery.querySelector('.gallery-container');
if (!galleryContainer) continue;
try {
// Load linked media for this post
const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
if (!linkedData || linkedData.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
continue;
}
// Filter to images only (media is nested in the link object)
const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/'));
if (images.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>';
continue;
}
// Build gallery grid (column count is handled via CSS class on parent)
galleryContainer.innerHTML = images.map((link, index) => `
<div class="gallery-item" data-index="${index}">
<img
src="bds-media://${link.media.id}"
alt="${link.media.alt || link.media.originalName}"
title="${link.media.title || link.media.originalName}"
/>
</div>
`).join('');
// Set up lightbox click handlers
const items = galleryContainer.querySelectorAll('.gallery-item');
const imageData = images.map(link => ({
src: `bds-media://${link.media.id}`,
alt: link.media.alt || link.media.originalName,
}));
items.forEach((item, index) => {
item.addEventListener('click', () => onImageClick(index, imageData));
});
} catch (error) {
console.error('Failed to hydrate gallery:', error);
galleryContainer.innerHTML = '<div class="gallery-error">Failed to load gallery</div>';
}
}
};
const FULL_MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
// Track photo_archive hydration state to prevent duplicate runs
const photoArchiveHydratingCache = new WeakSet<HTMLElement>();
/**
* Hydrate photo_archive elements in the preview with actual media from the given year/month.
*/
const hydratePhotoArchive = async (
container: HTMLElement,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) => {
// Match both year-based and recent-based archives
const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]');
if (archives.length === 0) {
return;
}
// Check if we're already hydrating (prevent duplicate runs)
if (photoArchiveHydratingCache.has(container)) {
console.log('[photo_archive] Skipping duplicate hydration for container');
return;
}
photoArchiveHydratingCache.add(container);
try {
await doHydratePhotoArchive(container, onImageClick, archives);
} finally {
// Clear the hydrating flag after a delay to allow for content changes
setTimeout(() => photoArchiveHydratingCache.delete(container), 500);
}
};
/**
* Internal implementation of photo_archive hydration
*/
const doHydratePhotoArchive = async (
_container: HTMLElement,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
archives: NodeListOf<Element>
) => {
// Collect media for archive rendering based on current macros.
type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date };
const archiveData: Array<{
element: Element;
mode: 'single-month' | 'full-year' | 'recent';
year?: number;
month?: number;
images?: ImageData[];
// Map key is "YYYY-MM" for recent mode, or month number (1-12) for year mode
monthlyImages?: Map<string | number, ImageData[]>;
showYearInLabel?: boolean;
}> = [];
console.log(`[photo_archive] Processing ${archives.length} archive macro(s)`);
for (const archive of archives) {
const recentStr = archive.getAttribute('data-recent');
const yearStr = archive.getAttribute('data-year');
const monthStr = archive.getAttribute('data-month');
if (recentStr) {
// Recent mode: get last N months with images
const recentCount = parseInt(recentStr, 10) || 10;
console.log(`[photo_archive] Recent mode: fetching last ${recentCount} months with images`);
// Fetch all images (no filter)
const allMedia = await window.electronAPI?.media.filter({});
const allImages = (allMedia || []).filter(m => m.mimeType?.startsWith('image/'));
// Group by year-month and sort by most recent
const monthlyMap = new Map<string, ImageData[]>();
for (const img of allImages) {
if (!img.createdAt) continue;
const date = new Date(img.createdAt);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-based
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
if (!monthlyMap.has(key)) {
monthlyMap.set(key, []);
}
monthlyMap.get(key)!.push(img);
}
// Sort by key descending (newest first) and take top N
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
const recentMonthlyImages = new Map<string, ImageData[]>();
for (const key of sortedKeys) {
const images = monthlyMap.get(key)!;
recentMonthlyImages.set(key, images);
}
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
archiveData.push({
element: archive,
mode: 'recent',
monthlyImages: recentMonthlyImages,
showYearInLabel: true
});
console.log(`[photo_archive] Recent: ${totalImages} images across ${recentMonthlyImages.size} months`);
} else if (yearStr) {
const year = parseInt(yearStr, 10);
const month = monthStr ? parseInt(monthStr, 10) : undefined;
if (!year) continue;
if (month !== undefined) {
// Single month view
const mediaItems = await window.electronAPI?.media.filter({
year,
month: month - 1, // API uses 0-based month
});
const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/'));
archiveData.push({ element: archive, mode: 'single-month', year, month, images });
console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`);
} else {
// Full year view - collect all months, tracking which month each image belongs to
const monthlyImages = new Map<number, ImageData[]>();
for (let m = 0; m < 12; m++) {
const mediaItems = await window.electronAPI?.media.filter({
year,
month: m,
});
const images = (mediaItems || []).filter(item => item.mimeType?.startsWith('image/'));
if (images.length > 0) {
monthlyImages.set(m + 1, images); // Store with 1-based month key
}
}
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
archiveData.push({ element: archive, mode: 'full-year', year, month: undefined, monthlyImages });
console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`);
}
}
}
// Render galleries
for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) {
const archiveContainer = element.querySelector('.photo-archive-container');
if (!archiveContainer) continue;
try {
// Render the gallery
let html = '';
if (mode === 'single-month' && month !== undefined && images && year) {
// Single month view
if (images.length === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
continue;
}
html = buildMonthGallery(month, year, images, onImageClick, false);
} else if (mode === 'recent' && monthlyImages) {
// Recent mode - keys are "YYYY-MM" strings
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No recent photos found</div>`;
continue;
}
// Sort by key descending (newest first) - keys are "YYYY-MM" strings
const sortedEntries = Array.from(monthlyImages.entries())
.sort((a, b) => (b[0] as string).localeCompare(a[0] as string));
html = sortedEntries.map(([key, imgs]) => {
// Parse "YYYY-MM" to get year and month
const [yearStr, monthStr] = (key as string).split('-');
const entryYear = parseInt(yearStr, 10);
const entryMonth = parseInt(monthStr, 10);
return `<div class="photo-archive-month-wrapper">${buildMonthGallery(entryMonth, entryYear, imgs, onImageClick, true)}</div>`;
}).join('');
} else if (mode === 'full-year' && monthlyImages && year) {
// Full year view - keys are month numbers
if (monthlyImages.size === 0) {
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
continue;
}
// Sort months ascending (January first)
const sortedMonths = Array.from(monthlyImages.entries())
.sort((a, b) => (a[0] as number) - (b[0] as number));
html = sortedMonths.map(([m, imgs]) =>
`<div class="photo-archive-month-wrapper">${buildMonthGallery(m as number, year, imgs, onImageClick, showYearInLabel || false)}</div>`
).join('');
}
archiveContainer.innerHTML = html;
// Set up click handlers for all images
setupPhotoArchiveClickHandlers(archiveContainer, onImageClick);
} catch (error) {
console.error('Failed to hydrate photo archive:', error);
archiveContainer.innerHTML = '<div class="photo-archive-error">Failed to load photo archive</div>';
}
}
console.log('[photo_archive] Hydration complete.');
};
/**
* Build HTML for a single month's gallery with rotated month label
* @param month - 1-based month number (1 = January)
* @param year - The year
* @param images - Array of image data
* @param _onImageClick - Click handler (unused in template, set up separately)
* @param showYear - Whether to include the year in the label (e.g., "January 2024")
*/
function buildMonthGallery(
month: number,
year: number,
images: { id: string; originalName: string; alt?: string }[],
_onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
showYear: boolean = false
): string {
const monthName = FULL_MONTH_NAMES[month - 1];
const labelText = showYear ? `${monthName} ${year}` : monthName;
return `
<div class="photo-archive-month" data-month="${month}" data-year="${year}">
<div class="photo-archive-month-label">
<span>${labelText}</span>
</div>
<div class="photo-archive-gallery gallery-lightbox">
${images.map((img, index) => `
<div class="photo-archive-item" data-index="${index}" data-media-id="${img.id}">
<img
src="bds-media://${img.id}"
alt="${img.alt || img.originalName}"
title="${img.title || img.originalName}"
/>
</div>
`).join('')}
</div>
</div>
`;
}
/**
* Set up click handlers for photo archive gallery items
*/
function setupPhotoArchiveClickHandlers(
container: Element,
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
) {
// Find all month galleries
const monthGalleries = container.querySelectorAll('.photo-archive-month');
monthGalleries.forEach(monthGallery => {
const items = monthGallery.querySelectorAll('.photo-archive-item');
const imageData = Array.from(items).map(item => {
const img = item.querySelector('img');
return {
src: img?.getAttribute('src') || '',
alt: img?.getAttribute('alt') || '',
};
});
items.forEach((item, index) => {
item.addEventListener('click', () => onImageClick(index, imageData));
});
});
}
interface PostEditorProps {
postId: string;
}
@@ -543,17 +167,13 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const hydrationOverlayRef = useRef<HTMLDivElement>(null);
const isHydratingRef = useRef(false);
const previewContentRef = useRef<HTMLDivElement>(null);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
const [showPostSearch, setShowPostSearch] = useState(false);
const [showMediaSearch, setShowMediaSearch] = useState(false);
const editorRef = useRef<unknown>(null);
const previewRef = useRef<HTMLDivElement>(null);
const isDirty = checkIsDirty(postId);
@@ -608,66 +228,29 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
// Extract images from resolved content for lightbox
const images = useMarkdownImages(resolvedContent);
// Combine regular images with gallery images for lightbox
const allImages = useMemo(() => {
// If gallery images are set, use those; otherwise use extracted images
return galleryImages.length > 0 ? galleryImages : images;
}, [images, galleryImages]);
// Hydrate galleries and photo archives when in preview mode
useEffect(() => {
if (editorMode !== 'preview' || !previewRef.current || !previewContentRef.current) return;
if (editorMode !== 'preview') return;
let cancelled = false;
setPreviewUrl(null);
// Helper to show/hide the overlay without triggering React re-render
const showOverlay = (show: boolean) => {
if (hydrationOverlayRef.current) {
hydrationOverlayRef.current.style.display = show ? 'flex' : 'none';
}
};
// Set content immediately if not hydrating
// During hydration, we skip updating to preserve the hydrated DOM
if (!isHydratingRef.current) {
previewContentRef.current.innerHTML = markdownToHtml(resolvedContent, postId);
}
// Small delay to ensure DOM is updated
const timer = setTimeout(async () => {
if (cancelled || !previewRef.current) return;
const lightboxHandler = (index: number, imgs: { src: string; alt: string }[]) => {
setGalleryImages(imgs);
setLightboxIndex(index);
setLightboxOpen(true);
};
// Check if there are photo_archive macros that need hydration
const hasPhotoArchives = previewRef.current.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]').length > 0;
if (hasPhotoArchives) {
isHydratingRef.current = true;
showOverlay(true);
}
try {
await hydrateGalleries(previewRef.current, postId, lightboxHandler);
window.electronAPI?.posts.getPreviewUrl(postId)
.then((url) => {
if (!cancelled) {
await hydratePhotoArchive(previewRef.current, lightboxHandler);
setPreviewUrl(url);
}
} finally {
// Always reset hydration state when complete - the ref is global to the component
isHydratingRef.current = false;
showOverlay(false);
}
}, 100);
})
.catch((error) => {
console.error('Failed to load post preview URL:', error);
if (!cancelled) {
setPreviewUrl(null);
}
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [editorMode, postId, resolvedContent]);
}, [editorMode, postId]);
// Track latest values for auto-save on unmount/switch
const pendingChangesRef = useRef<{
@@ -1264,57 +847,63 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
<div className="editor-body">
<div className="editor-toolbar">
<label>Content</label>
<div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => handleEditorModeChange('wysiwyg')}
title="Visual editor"
>
Visual
</button>
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => handleEditorModeChange('markdown')}
title="Markdown source"
>
Markdown
</button>
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => handleEditorModeChange('preview')}
title="Read-only preview"
>
Preview
</button>
<div className="editor-toolbar-left">
<label>Content</label>
</div>
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
{editorMode === 'markdown' && (
<>
<div className="editor-toolbar-center">
<div className="editor-mode-toggle">
<button
className="insert-post-link-button"
onClick={() => setShowPostSearch(true)}
title="Link to post (Ctrl+K)"
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => handleEditorModeChange('wysiwyg')}
title="Visual editor"
>
📝
Visual
</button>
<button
className="insert-media-button"
onClick={() => setShowMediaSearch(true)}
title="Insert image from media library"
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => handleEditorModeChange('markdown')}
title="Markdown source"
>
🖼
Markdown
</button>
</>
)}
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => handleEditorModeChange('preview')}
title="Read-only preview"
>
Preview
</button>
</div>
</div>
<div className="editor-toolbar-right">
{images.length > 0 && (
<button
className="gallery-button"
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
title={`View ${images.length} image(s)`}
>
📷 {images.length}
</button>
)}
{editorMode === 'markdown' && (
<>
<button
className="insert-post-link-button"
onClick={() => setShowPostSearch(true)}
title="Link to post (Ctrl+K)"
>
📝
</button>
<button
className="insert-media-button"
onClick={() => setShowMediaSearch(true)}
title="Insert image from media library"
>
🖼
</button>
</>
)}
</div>
</div>
{editorMode === 'wysiwyg' && (
@@ -1353,27 +942,26 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
)}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body" ref={previewRef}>
<div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}>
<div className="preview-hydrating-content">
<div className="preview-hydrating-spinner" />
<span>Loading photo archive...</span>
</div>
</div>
<div
className="preview-content"
ref={previewContentRef}
/>
<div className="editor-preview">
{previewUrl ? (
<iframe
className="editor-preview-frame"
src={previewUrl}
title="Post preview"
/>
) : (
<div className="editor-preview-loading">Loading preview...</div>
)}
</div>
)}
</div>
{/* Lightbox for viewing images in content */}
<Lightbox
images={allImages}
images={images}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
onClose={() => { setLightboxOpen(false); }}
/>
</div>
@@ -2197,6 +1785,13 @@ export const Editor: React.FC = () => {
const showMetadataDiff = activeTab?.type === 'metadata-diff';
const showGitDiff = activeTab?.type === 'git-diff';
useEffect(() => {
const activePostId = activeTab?.type === 'post' ? activeTab.id : null;
window.electronAPI?.app.setPreviewPostTarget(activePostId).catch((error) => {
console.error('Failed to sync preview post target:', error);
});
}, [activeTab]);
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => {
if (activeView === 'posts' && selectedPostId && !isLoading) {

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'self' http://127.0.0.1:4123; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
<title>Blogging Desktop Server</title>
</head>
<body>

View File

@@ -174,6 +174,14 @@ describe('PreviewServer', () => {
const lightboxJsResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.js`);
expect(lightboxJsResponse.status).toBe(200);
expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript');
const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`);
expect(lightboxPrevImageResponse.status).toBe(200);
expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');
const lightboxLoadingImageResponse = await fetch(`${server.getBaseUrl()}/images/loading.gif`);
expect(lightboxLoadingImageResponse.status).toBe(200);
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
});
it('limits list routes to 50 posts', async () => {

View File

@@ -114,4 +114,167 @@ describe('main bootstrap preview behavior', () => {
expect(mockPreviewStart).toHaveBeenCalledWith(4123);
expect(mockApp.whenReady).toHaveBeenCalled();
});
it('enables Blog Preview Post only for active post tab and opens canonical URL', async () => {
const mockApp = {
name: 'bDS',
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn(),
quit: vi.fn(),
};
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
class MockBrowserWindow {
static getAllWindows = mockBrowserWindowGetAllWindows;
loadURL = vi.fn();
loadFile = vi.fn();
on = vi.fn();
isDestroyed = vi.fn(() => false);
webContents = {
on: vi.fn(),
send: vi.fn(),
openDevTools: vi.fn(),
toggleDevTools: vi.fn(),
};
}
const findMenuItemById = (template: any[], id: string): any | null => {
for (const item of template) {
if (item && item.id === id) {
return item;
}
if (item?.submenu && Array.isArray(item.submenu)) {
const found = findMenuItemById(item.submenu, id);
if (found) return found;
}
}
return null;
};
let capturedTemplate: any[] = [];
const menuObject = {
getMenuItemById: (id: string) => findMenuItemById(capturedTemplate, id),
};
const ipcMainHandle = vi.fn();
const shellOpenExternal = vi.fn().mockResolvedValue(undefined);
vi.doMock('electron', () => ({
app: mockApp,
BrowserWindow: MockBrowserWindow,
Menu: {
buildFromTemplate: vi.fn((template: any[]) => {
capturedTemplate = template;
return menuObject;
}),
setApplicationMenu: vi.fn(),
getApplicationMenu: vi.fn(() => menuObject),
},
ipcMain: {
on: vi.fn(),
handle: ipcMainHandle,
removeHandler: vi.fn(),
},
protocol: {
registerSchemesAsPrivileged: vi.fn(),
handle: vi.fn(),
},
net: {
fetch: vi.fn(),
},
shell: {
openExternal: shellOpenExternal,
openPath: vi.fn(),
},
}));
const mockPreviewStart = vi.fn().mockResolvedValue(4123);
const mockPreviewStop = vi.fn().mockResolvedValue(undefined);
const mockPreviewGetBaseUrl = vi.fn(() => 'http://127.0.0.1:4123');
class MockPreviewServer {
start = mockPreviewStart;
stop = mockPreviewStop;
getBaseUrl = mockPreviewGetBaseUrl;
}
vi.doMock('../../src/main/engine/PreviewServer', () => ({
PreviewServer: MockPreviewServer,
}));
const postCreatedAt = new Date('2026-02-17T10:00:00.000Z');
const getPost = vi.fn().mockResolvedValue({
id: 'post-42',
slug: 'current-post',
createdAt: postCreatedAt,
});
vi.doMock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
getPost,
})),
}));
vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
initializeLocal: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
})),
})),
})),
})),
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
})),
}));
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
}));
vi.doMock('../../src/main/database/schema', () => ({
media: {},
}));
vi.doMock('drizzle-orm', () => ({
eq: vi.fn(),
}));
vi.doMock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
})),
}));
await import('../../src/main/main');
await new Promise((resolve) => setTimeout(resolve, 0));
const previewMenuItem = findMenuItemById(capturedTemplate, 'blog.previewPost');
expect(previewMenuItem).toBeTruthy();
expect(previewMenuItem.enabled).toBe(false);
const setPreviewTargetCall = ipcMainHandle.mock.calls.find((call: unknown[]) => call[0] === 'app:setPreviewPostTarget');
expect(setPreviewTargetCall).toBeTruthy();
const setPreviewTargetHandler = setPreviewTargetCall?.[1] as ((event: unknown, postId: string | null) => Promise<void>);
await setPreviewTargetHandler({}, 'post-42');
expect(previewMenuItem.enabled).toBe(true);
await previewMenuItem.click();
expect(getPost).toHaveBeenCalledWith('post-42');
expect(shellOpenExternal).toHaveBeenCalledWith('http://127.0.0.1:4123/2026/02/17/current-post');
await setPreviewTargetHandler({}, null);
expect(previewMenuItem.enabled).toBe(false);
});
});

View File

@@ -701,6 +701,29 @@ describe('IPC Handlers', () => {
});
});
describe('posts:getPreviewUrl', () => {
it('should return canonical preview URL for an existing post', async () => {
mockPostEngine.getPost.mockResolvedValue(createMockPost({
id: 'post-1',
slug: 'my-post',
createdAt: new Date('2026-02-16T12:00:00.000Z'),
}));
const result = await invokeHandler('posts:getPreviewUrl', 'post-1');
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post');
});
it('should return null when post does not exist', async () => {
mockPostEngine.getPost.mockResolvedValue(null);
const result = await invokeHandler('posts:getPreviewUrl', 'missing-post');
expect(result).toBeNull();
});
});
describe('posts:getAll', () => {
it('should return paginated posts from PostEngine', async () => {
const mockPosts = [

View File

@@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest';
import { markdownToHtml } from '../../../src/renderer/components/Editor/Editor';
describe('Editor markdown preview rendering', () => {
it('renders continuous blockquote lines as a single blockquote paragraph (CommonMark softbreak behavior)', () => {
const markdown = [
'> Georg Bauer',
'> Am Krug 40',
'> 48151 Münster',
'> eMail: gb at rfc1437.de',
].join('\n');
const html = markdownToHtml(markdown, 'post-1');
expect((html.match(/<blockquote>/g) ?? []).length).toBe(1);
expect((html.match(/<p>/g) ?? []).length).toBe(1);
expect(html).not.toContain('<br');
expect(html).toContain('Georg Bauer');
expect(html).toContain('eMail: gb at rfc1437.de');
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { render, act } from '@testing-library/react';
import { render, act, fireEvent } from '@testing-library/react';
let markdownUpdatedHandler: ((ctx: unknown, markdown: string, prevMarkdown: string) => void) | null = null;
@@ -161,6 +161,7 @@ describe('Editor visual mode persistence', () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null);
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/2026/02/16/test-post');
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
useAppStore.setState({
@@ -200,4 +201,46 @@ describe('Editor visual mode persistence', () => {
unmount?.();
});
});
it('uses canonical preview server URL in preview mode iframe', async () => {
const { getByTitle, container } = render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(getByTitle('Read-only preview'));
await Promise.resolve();
});
expect((window as any).electronAPI.posts.getPreviewUrl).toHaveBeenCalledWith('post-1');
const frame = container.querySelector('.editor-preview-frame') as HTMLIFrameElement | null;
expect(frame).not.toBeNull();
expect(frame?.getAttribute('src')).toBe('http://127.0.0.1:4123/2026/02/16/test-post');
expect(container.querySelector('.preview-content')).toBeNull();
});
it('renders mode toggle in centered toolbar section', async () => {
const { container } = render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const centerSection = container.querySelector('.editor-toolbar-center');
expect(centerSection).not.toBeNull();
const modeToggle = centerSection?.querySelector('.editor-mode-toggle');
expect(modeToggle).not.toBeNull();
const modeButtons = modeToggle?.querySelectorAll('button');
expect(modeButtons?.length).toBe(3);
});
});

View File

@@ -1,628 +0,0 @@
/**
* Tests for photo_archive hydration logic
*
* Tests the actual hydration path used by Editor.tsx to verify
* that year/month parameters correctly filter images.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import photoArchiveMacro from '../../../src/renderer/macros/definitions/photo_archive';
import { parseMacros, getMacro, registerMacro, clearMacros } from '../../../src/renderer/macros/registry';
/**
* Replicate the exact markdownToHtml and renderMacroSync from Editor.tsx
*/
function renderMacroSync(name: string, params: Record<string, string>, postId?: string): string {
const macro = getMacro(name);
if (!macro) {
return `<span class="macro-error">Unknown macro: ${name}</span>`;
}
try {
const result = macro.render(params, { postId, isPreview: true });
if (result instanceof Promise) {
return `<div class="macro-loading">Loading ${name}...</div>`;
}
return result;
} catch (e) {
return `<span class="macro-error">Error rendering ${name}</span>`;
}
}
function markdownToHtml(markdown: string, postId?: string): string {
const macros = parseMacros(markdown);
let result = markdown;
// Replace macros from end to start to preserve positions
for (let i = macros.length - 1; i >= 0; i--) {
const macro = macros[i];
const rendered = renderMacroSync(macro.name, macro.params, postId);
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
}
return result;
}
// Mock media data: 6 months in 2020, 6 months in 2019, 1 image per month
function createMockMediaDatabase() {
const media: Array<{
id: string;
originalName: string;
mimeType: string;
createdAt: Date;
}> = [];
// 2020: January through June (6 months)
for (let month = 0; month < 6; month++) {
media.push({
id: `img-2020-${month + 1}`,
originalName: `photo-2020-${month + 1}.jpg`,
mimeType: 'image/jpeg',
createdAt: new Date(Date.UTC(2020, month, 15)),
});
}
// 2019: July through December (6 months)
for (let month = 6; month < 12; month++) {
media.push({
id: `img-2019-${month + 1}`,
originalName: `photo-2019-${month + 1}.jpg`,
mimeType: 'image/jpeg',
createdAt: new Date(Date.UTC(2019, month, 15)),
});
}
return media;
}
// Simulate the media.filter API behavior from MediaEngine
function createMockMediaFilter(allMedia: ReturnType<typeof createMockMediaDatabase>) {
return async (filter: { year?: number; month?: number }) => {
let result = [...allMedia];
if (filter.year !== undefined) {
const startOfYear = new Date(Date.UTC(filter.year, 0, 1));
const endOfYear = new Date(Date.UTC(filter.year + 1, 0, 1));
result = result.filter(m => m.createdAt >= startOfYear && m.createdAt < endOfYear);
}
if (filter.month !== undefined && filter.year !== undefined) {
const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1));
result = result.filter(m => m.createdAt >= startOfMonth && m.createdAt < endOfMonth);
}
return result;
};
}
// Extract the core hydration logic to test it
type ImageData = { id: string; originalName: string; mimeType: string; createdAt?: Date };
interface ArchiveResult {
mode: 'single-month' | 'full-year' | 'recent';
year?: number;
month?: number;
images?: ImageData[];
monthlyImages?: Map<string | number, ImageData[]>;
totalImages: number;
monthCount: number;
}
/**
* Simulates the hydration logic from Editor.tsx doHydratePhotoArchive
*/
async function hydratePhotoArchive(
dataAttrs: { recent?: string; year?: string; month?: string },
mediaFilter: (filter: { year?: number; month?: number }) => Promise<ImageData[]>
): Promise<ArchiveResult> {
const { recent: recentStr, year: yearStr, month: monthStr } = dataAttrs;
if (recentStr) {
// Recent mode: get last N months with images
const recentCount = parseInt(recentStr, 10) || 10;
// Fetch all images (no filter)
const allMedia = await mediaFilter({});
const allImages = allMedia.filter(m => m.mimeType?.startsWith('image/'));
// Group by year-month and sort by most recent
const monthlyMap = new Map<string, ImageData[]>();
for (const img of allImages) {
if (!img.createdAt) continue;
const date = new Date(img.createdAt);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-based
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
if (!monthlyMap.has(key)) {
monthlyMap.set(key, []);
}
monthlyMap.get(key)!.push(img);
}
// Sort by key descending (newest first) and take top N
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
const recentMonthlyImages = new Map<string, ImageData[]>();
for (const key of sortedKeys) {
recentMonthlyImages.set(key, monthlyMap.get(key)!);
}
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
return {
mode: 'recent',
monthlyImages: recentMonthlyImages,
totalImages,
monthCount: recentMonthlyImages.size,
};
} else if (yearStr) {
const year = parseInt(yearStr, 10);
const month = monthStr ? parseInt(monthStr, 10) : undefined;
if (month !== undefined) {
// Single month view
const mediaItems = await mediaFilter({
year,
month: month - 1, // API uses 0-based month
});
const images = mediaItems.filter(m => m.mimeType?.startsWith('image/'));
return {
mode: 'single-month',
year,
month,
images,
totalImages: images.length,
monthCount: images.length > 0 ? 1 : 0,
};
} else {
// Full year view - collect all months
const monthlyImages = new Map<number, ImageData[]>();
for (let m = 0; m < 12; m++) {
const mediaItems = await mediaFilter({
year,
month: m,
});
const images = mediaItems.filter(item => item.mimeType?.startsWith('image/'));
if (images.length > 0) {
monthlyImages.set(m + 1, images); // Store with 1-based month key
}
}
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
return {
mode: 'full-year',
year,
monthlyImages,
totalImages,
monthCount: monthlyImages.size,
};
}
}
throw new Error('No valid data attributes provided');
}
describe('photo_archive hydration', () => {
let mockMedia: ReturnType<typeof createMockMediaDatabase>;
let mockMediaFilter: ReturnType<typeof createMockMediaFilter>;
beforeEach(() => {
mockMedia = createMockMediaDatabase();
mockMediaFilter = createMockMediaFilter(mockMedia);
});
describe('mock database setup', () => {
it('should have 12 images total', () => {
expect(mockMedia).toHaveLength(12);
});
it('should have 6 images in 2020 (Jan-Jun)', async () => {
const images2020 = await mockMediaFilter({ year: 2020 });
expect(images2020).toHaveLength(6);
});
it('should have 6 images in 2019 (Jul-Dec)', async () => {
const images2019 = await mockMediaFilter({ year: 2019 });
expect(images2019).toHaveLength(6);
});
it('should have 1 image in Feb 2020', async () => {
const imagesFeb2020 = await mockMediaFilter({ year: 2020, month: 1 }); // 0-based month
expect(imagesFeb2020).toHaveLength(1);
});
});
describe('recent mode (no parameters)', () => {
it('should return 10 months of images when data-recent="10"', async () => {
const result = await hydratePhotoArchive(
{ recent: '10' },
mockMediaFilter
);
expect(result.mode).toBe('recent');
// We have 12 months total, but recent=10 should give us 10 months
expect(result.monthCount).toBe(10);
expect(result.totalImages).toBe(10);
});
it('should return months sorted newest first', async () => {
const result = await hydratePhotoArchive(
{ recent: '10' },
mockMediaFilter
);
const monthKeys = Array.from(result.monthlyImages!.keys()) as string[];
// First should be 2020-06, last should be 2019-09 (skipping Jul, Aug of 2019)
expect(monthKeys[0]).toBe('2020-06');
expect(monthKeys[monthKeys.length - 1]).toBe('2019-09');
});
it('should not require post-media linking side effects during preview hydration', async () => {
const linkMany = vi.fn();
const unlinkMany = vi.fn();
const result = await hydratePhotoArchive(
{ recent: '10' },
mockMediaFilter
);
expect(result.mode).toBe('recent');
expect(linkMany).not.toHaveBeenCalled();
expect(unlinkMany).not.toHaveBeenCalled();
});
});
describe('year mode (year parameter only)', () => {
it('should return only 2019 images when year="2019"', async () => {
const result = await hydratePhotoArchive(
{ year: '2019' },
mockMediaFilter
);
expect(result.mode).toBe('full-year');
expect(result.year).toBe(2019);
expect(result.totalImages).toBe(6);
expect(result.monthCount).toBe(6);
});
it('should return only 2020 images when year="2020"', async () => {
const result = await hydratePhotoArchive(
{ year: '2020' },
mockMediaFilter
);
expect(result.mode).toBe('full-year');
expect(result.year).toBe(2020);
expect(result.totalImages).toBe(6);
expect(result.monthCount).toBe(6);
});
it('should NOT use recent mode when year is provided', async () => {
const result = await hydratePhotoArchive(
{ year: '2019' },
mockMediaFilter
);
// Should be full-year, NOT recent
expect(result.mode).toBe('full-year');
expect(result.mode).not.toBe('recent');
});
});
describe('year+month mode', () => {
it('should return 1 image for Feb 2020', async () => {
const result = await hydratePhotoArchive(
{ year: '2020', month: '2' },
mockMediaFilter
);
expect(result.mode).toBe('single-month');
expect(result.year).toBe(2020);
expect(result.month).toBe(2);
expect(result.totalImages).toBe(1);
});
it('should return 0 images for a month with no images', async () => {
const result = await hydratePhotoArchive(
{ year: '2020', month: '12' }, // December 2020 has no images
mockMediaFilter
);
expect(result.mode).toBe('single-month');
expect(result.totalImages).toBe(0);
});
});
describe('full flow: macro render → DOM → hydration', () => {
/**
* Helper to extract data attributes from rendered macro HTML
*/
function extractDataAttrsFromMacroHtml(html: string): { recent?: string; year?: string; month?: string } {
const dom = new JSDOM(html);
const el = dom.window.document.querySelector('.macro-photo-archive');
if (!el) throw new Error('No .macro-photo-archive element found in HTML');
return {
recent: el.getAttribute('data-recent') || undefined,
year: el.getAttribute('data-year') || undefined,
month: el.getAttribute('data-month') || undefined,
};
}
it('should render data-recent when no params and hydrate to recent mode', async () => {
// Render macro with no parameters
const html = photoArchiveMacro.render({}, { postId: 'test-post', isPreview: true });
// Extract data attributes
const dataAttrs = extractDataAttrsFromMacroHtml(html);
// Should have data-recent, NOT data-year
expect(dataAttrs.recent).toBe('10');
expect(dataAttrs.year).toBeUndefined();
// Hydrate using these attributes
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
expect(result.mode).toBe('recent');
expect(result.monthCount).toBe(10);
});
it('should render data-year when year param given and hydrate to full-year mode', async () => {
// Render macro with year="2019"
const html = photoArchiveMacro.render({ year: '2019' }, { postId: 'test-post', isPreview: true });
// Extract data attributes
const dataAttrs = extractDataAttrsFromMacroHtml(html);
// Should have data-year, NOT data-recent
expect(dataAttrs.year).toBe('2019');
expect(dataAttrs.recent).toBeUndefined();
// Hydrate using these attributes
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
expect(result.mode).toBe('full-year');
expect(result.year).toBe(2019);
expect(result.totalImages).toBe(6);
});
it('should render data-year and data-month when both params given', async () => {
// Render macro with year="2020" month="2"
const html = photoArchiveMacro.render({ year: '2020', month: '2' }, { postId: 'test-post', isPreview: true });
// Extract data attributes
const dataAttrs = extractDataAttrsFromMacroHtml(html);
// Should have data-year and data-month, NOT data-recent
expect(dataAttrs.year).toBe('2020');
expect(dataAttrs.month).toBe('2');
expect(dataAttrs.recent).toBeUndefined();
// Hydrate using these attributes
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
expect(result.mode).toBe('single-month');
expect(result.year).toBe(2020);
expect(result.month).toBe(2);
expect(result.totalImages).toBe(1);
});
it('BUG: year="2020" should NOT load recent images', async () => {
// This test verifies the bug the user reported
const htmlWithYear = photoArchiveMacro.render({ year: '2020' }, { postId: 'test-post', isPreview: true });
const htmlWithoutYear = photoArchiveMacro.render({}, { postId: 'test-post', isPreview: true });
const attrsWithYear = extractDataAttrsFromMacroHtml(htmlWithYear);
const attrsWithoutYear = extractDataAttrsFromMacroHtml(htmlWithoutYear);
// The attributes MUST be different
expect(attrsWithYear).not.toEqual(attrsWithoutYear);
// With year: should have data-year, NOT data-recent
expect(attrsWithYear.year).toBe('2020');
expect(attrsWithYear.recent).toBeUndefined();
// Without year: should have data-recent, NOT data-year
expect(attrsWithoutYear.recent).toBe('10');
expect(attrsWithoutYear.year).toBeUndefined();
// Hydrate both and verify different results
const resultWithYear = await hydratePhotoArchive(attrsWithYear, mockMediaFilter);
const resultWithoutYear = await hydratePhotoArchive(attrsWithoutYear, mockMediaFilter);
// With year=2020: should have 6 images (Jan-Jun 2020)
expect(resultWithYear.mode).toBe('full-year');
expect(resultWithYear.totalImages).toBe(6);
// Without year: should have 10 images (recent 10 months)
expect(resultWithoutYear.mode).toBe('recent');
expect(resultWithoutYear.totalImages).toBe(10);
});
});
describe('full flow from markdown: parseMacros → render → hydrate', () => {
beforeEach(() => {
clearMacros();
registerMacro(photoArchiveMacro);
});
/**
* Helper to extract data attributes from rendered macro HTML
*/
function extractDataAttrsFromMacroHtml(html: string): { recent?: string; year?: string; month?: string } {
const dom = new JSDOM(html);
const el = dom.window.document.querySelector('.macro-photo-archive');
if (!el) throw new Error('No .macro-photo-archive element found in HTML');
return {
recent: el.getAttribute('data-recent') || undefined,
year: el.getAttribute('data-year') || undefined,
month: el.getAttribute('data-month') || undefined,
};
}
/**
* Simulates what Editor.tsx does: parse markdown → render macros → extract attrs → hydrate
*/
async function fullFlowFromMarkdown(markdown: string): Promise<ArchiveResult> {
// Step 1: Parse macros from markdown (like parseMacros in registry.ts)
const macros = parseMacros(markdown);
expect(macros.length).toBeGreaterThan(0);
const macro = macros[0];
expect(macro.name).toBe('photo_archive');
// Step 2: Get macro definition and render (like renderMacroSync in Editor.tsx)
const definition = getMacro(macro.name);
expect(definition).toBeDefined();
const html = definition!.render(macro.params, { postId: 'test-post', isPreview: true });
// Step 3: Parse HTML and extract data attributes (like querySelector in hydratePhotoArchive)
const dataAttrs = extractDataAttrsFromMacroHtml(html);
// Step 4: Hydrate using the extracted attributes
return hydratePhotoArchive(dataAttrs, mockMediaFilter);
}
it('[[photo_archive]] should load recent 10 months', async () => {
const result = await fullFlowFromMarkdown('Some text [[photo_archive]] more text');
expect(result.mode).toBe('recent');
expect(result.monthCount).toBe(10);
expect(result.totalImages).toBe(10);
});
it('[[photo_archive year="2020"]] should load only 2020 images', async () => {
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2020"]] more text');
expect(result.mode).toBe('full-year');
expect(result.year).toBe(2020);
expect(result.totalImages).toBe(6);
expect(result.monthCount).toBe(6);
});
it('[[photo_archive year="2019"]] should load only 2019 images', async () => {
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2019"]] more text');
expect(result.mode).toBe('full-year');
expect(result.year).toBe(2019);
expect(result.totalImages).toBe(6);
expect(result.monthCount).toBe(6);
});
it('[[photo_archive year="2020" month="2"]] should load only Feb 2020', async () => {
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2020" month="2"]] more text');
expect(result.mode).toBe('single-month');
expect(result.year).toBe(2020);
expect(result.month).toBe(2);
expect(result.totalImages).toBe(1);
});
it('BUG REPRO: params should be correctly parsed from markdown', () => {
// Step 1: Parse macros with year parameter
const macrosWithYear = parseMacros('[[photo_archive year="2020"]]');
expect(macrosWithYear).toHaveLength(1);
expect(macrosWithYear[0].params).toEqual({ year: '2020' });
// Step 2: Parse macros without parameters
const macrosWithoutParams = parseMacros('[[photo_archive]]');
expect(macrosWithoutParams).toHaveLength(1);
expect(macrosWithoutParams[0].params).toEqual({});
// Step 3: Verify the params are DIFFERENT
expect(macrosWithYear[0].params).not.toEqual(macrosWithoutParams[0].params);
});
});
describe('Editor.tsx markdownToHtml exact flow', () => {
beforeEach(() => {
clearMacros();
registerMacro(photoArchiveMacro);
});
it('USER BUG: [[photo_archive year=2016]] (unquoted) should NOT produce data-recent', () => {
// This is the EXACT user scenario - unquoted parameter value
const markdown = '[[photo_archive year=2016]]';
// Step 1: Parse macros
const macros = parseMacros(markdown);
console.log('Parsed macros (unquoted):', JSON.stringify(macros, null, 2));
expect(macros).toHaveLength(1);
// BUG: This fails because PARAM_REGEX only matches quoted values
expect(macros[0].params.year).toBe('2016');
// Step 2: Render via markdownToHtml
const html = markdownToHtml(markdown, 'test-post');
console.log('Rendered HTML (unquoted):', html);
// Step 3: Check the HTML does NOT have data-recent
expect(html).not.toContain('data-recent=');
expect(html).toContain('data-year="2016"');
});
it('USER BUG: [[photo_archive year="2016"]] (quoted) should NOT produce data-recent', () => {
// This is the EXACT user scenario
const markdown = '[[photo_archive year="2016"]]';
// Step 1: Parse macros
const macros = parseMacros(markdown);
console.log('Parsed macros:', JSON.stringify(macros, null, 2));
expect(macros).toHaveLength(1);
expect(macros[0].params.year).toBe('2016');
// Step 2: Render via markdownToHtml
const html = markdownToHtml(markdown, 'test-post');
console.log('Rendered HTML:', html);
// Step 3: Check the HTML does NOT have data-recent
expect(html).not.toContain('data-recent=');
expect(html).toContain('data-year="2016"');
});
it('markdownToHtml with [[photo_archive]] produces data-recent', () => {
const html = markdownToHtml('Test [[photo_archive]] end', 'post-123');
expect(html).toContain('data-recent="10"');
expect(html).not.toContain('data-year=');
});
it('markdownToHtml with [[photo_archive year="2020"]] produces data-year', () => {
const html = markdownToHtml('Test [[photo_archive year="2020"]] end', 'post-123');
expect(html).toContain('data-year="2020"');
expect(html).not.toContain('data-recent=');
});
it('markdownToHtml with [[photo_archive year="2020" month="2"]] produces both', () => {
const html = markdownToHtml('Test [[photo_archive year="2020" month="2"]] end', 'post-123');
expect(html).toContain('data-year="2020"');
expect(html).toContain('data-month="2"');
expect(html).not.toContain('data-recent=');
});
it('CRITICAL BUG TEST: verify params flow correctly through markdownToHtml', () => {
// This tests the exact code path in Editor.tsx
const markdown = 'Content with [[photo_archive year="2019"]] macro';
// 1. parseMacros should extract params correctly
const macros = parseMacros(markdown);
expect(macros[0].params.year).toBe('2019');
// 2. markdownToHtml should produce correct HTML
const html = markdownToHtml(markdown, 'test-post');
// 3. HTML should have data-year, NOT data-recent
const dom = new JSDOM(html);
const el = dom.window.document.querySelector('.macro-photo-archive');
expect(el).not.toBeNull();
expect(el!.getAttribute('data-year')).toBe('2019');
expect(el!.getAttribute('data-recent')).toBeNull();
});
});
});

View File

@@ -0,0 +1,16 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
describe('renderer CSP for preview iframe', () => {
it('allows framing local preview server origin', () => {
const htmlPath = path.resolve(process.cwd(), 'src/renderer/index.html');
const html = readFileSync(htmlPath, 'utf8');
expect(html).toMatch(/Content-Security-Policy/i);
expect(html).toMatch(/frame-src\s+'self'\s+http:\/\/127\.0\.0\.1:4123/);
expect(html).not.toMatch(/unsafe-eval/);
});
});