fix: galleries now work in preview

This commit is contained in:
2026-02-17 22:48:41 +01:00
parent 88f1ccf372
commit 5ee2611629
3 changed files with 323 additions and 13 deletions

View File

@@ -5,6 +5,7 @@ import { marked } from 'marked';
import { Liquid } from 'liquidjs';
import { getMetaEngine, type ProjectMetadata } from './MetaEngine';
import { getMediaEngine, type MediaData } from './MediaEngine';
import { getPostMediaEngine } from './PostMediaEngine';
import { getPostEngine, type PostData, type PostFilter } from './PostEngine';
import { getProjectEngine } from './ProjectEngine';
@@ -33,6 +34,7 @@ interface MetaEngineContract {
interface PreviewServerDependencies {
postEngine: PostEngineContract;
mediaEngine: MediaEngineContract;
postMediaEngine: PostMediaEngineContract;
settingsEngine: MetaEngineContract;
getActiveProjectContext: () => Promise<ActiveProjectContext>;
}
@@ -120,6 +122,11 @@ interface MediaEngineContract {
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
}
interface PostMediaEngineContract {
getLinkedMediaDataForPost: (postId: string) => Promise<Array<{ media: MediaData }>>;
setProjectContext: (projectId: string) => void;
}
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
const MIN_MAX_POSTS_PER_PAGE = 1;
const MAX_MAX_POSTS_PER_PAGE = 500;
@@ -216,6 +223,169 @@ function parseMacroParams(paramString: string | undefined): Record<string, strin
return params;
}
function parseIntegerParam(value: string | undefined): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) ? parsed : null;
}
function normalizeMacroName(name: string): string {
if (name === 'photo_album') {
return 'photo_archive';
}
return name;
}
function buildCanonicalMediaPath(media: MediaData): string {
const year = media.createdAt.getFullYear();
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
return `/media/${year}/${month}/${media.filename}`;
}
function isRenderableImage(media: MediaData): boolean {
if (media.mimeType?.toLowerCase().startsWith('image/')) {
return true;
}
const extension = path.extname(media.filename).toLowerCase();
return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.avif'].includes(extension);
}
function buildPhotoArchiveBuckets(
mediaItems: MediaData[],
params: Record<string, string>,
): Array<{ year: number; month: number; media: MediaData[] }> {
const yearParam = parseIntegerParam(params.year);
const monthParam = parseIntegerParam(params.month);
const filteredByDate = mediaItems.filter((media) => {
const year = media.createdAt.getFullYear();
const month = media.createdAt.getMonth() + 1;
if (yearParam !== null && year !== yearParam) {
return false;
}
if (monthParam !== null && month !== monthParam) {
return false;
}
return true;
});
const buckets = new Map<string, { year: number; month: number; media: MediaData[] }>();
for (const media of filteredByDate) {
const year = media.createdAt.getFullYear();
const month = media.createdAt.getMonth() + 1;
const key = `${year}-${String(month).padStart(2, '0')}`;
const existing = buckets.get(key);
if (existing) {
existing.media.push(media);
continue;
}
buckets.set(key, { year, month, media: [media] });
}
let orderedBuckets = Array.from(buckets.values())
.sort((a, b) => (b.year * 12 + b.month) - (a.year * 12 + a.month));
if (yearParam === null) {
orderedBuckets = orderedBuckets.slice(0, 10);
}
for (const bucket of orderedBuckets) {
bucket.media.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
return orderedBuckets;
}
function renderGalleryMacro(
params: Record<string, string>,
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
): string {
const requestedColumns = parseIntegerParam(params.columns);
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
const linkedImages = mediaItems
.filter((media) => {
if (!isRenderableImage(media)) {
return false;
}
const linkedByPostMedia = linkedMediaIds?.has(media.id) ?? false;
const linkedBySidecar = Array.isArray(media.linkedPostIds) && media.linkedPostIds.includes(postId);
return linkedByPostMedia || linkedBySidecar;
})
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const groupName = `gallery-${escapeHtml(postId || 'post')}`;
const galleryItems = linkedImages.map((media) => {
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
return `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
}).join('');
const content = galleryItems || '<div class="gallery-empty">No linked images found.</div>';
return `<div class="macro-gallery gallery-cols-${columns}" data-post-id="${escapeHtml(postId)}" data-columns="${columns}" data-lightbox="true"><div class="gallery-container gallery-lightbox">${content}</div>${caption}</div>`;
}
function renderPhotoArchiveMacro(params: Record<string, string>, mediaItems: MediaData[]): string {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const yearParam = parseIntegerParam(params.year);
const monthParam = parseIntegerParam(params.month);
const rootClasses = ['macro-photo-archive'];
if (yearParam === null) {
rootClasses.push('photo-archive-recent-months');
} else if (monthParam !== null) {
rootClasses.push('photo-archive-single-month');
} else {
rootClasses.push('photo-archive-full-year');
}
const dataAttrs: string[] = [];
if (yearParam === null) {
dataAttrs.push('data-recent="10"');
} else {
dataAttrs.push(`data-year="${escapeHtml(String(yearParam))}"`);
if (monthParam !== null) {
dataAttrs.push(`data-month="${escapeHtml(String(monthParam))}"`);
}
}
const renderableMedia = mediaItems.filter((media) => isRenderableImage(media));
const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
if (buckets.length === 0) {
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">No photos found for this archive.</div></div></div>`;
}
const monthsHtml = buckets.map((bucket) => {
const monthName = monthNames[bucket.month - 1] || String(bucket.month);
const label = `${monthName} ${bucket.year}`;
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
const itemsHtml = bucket.media.map((media) => {
const mediaPath = escapeHtml(buildCanonicalMediaPath(media));
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename);
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename);
return `<a class="photo-archive-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
}).join('');
return `<div class="photo-archive-month-wrapper"><div class="photo-archive-month"><div class="photo-archive-month-label"><span>${escapeHtml(label)}</span></div><div class="photo-archive-gallery">${itemsHtml}</div></div></div>`;
}).join('');
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
}
function isExternalOrSpecialUrl(value: string): boolean {
const normalized = value.trim();
if (!normalized) return false;
@@ -330,31 +500,35 @@ function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewriteContex
});
}
function renderMacro(name: string, params: Record<string, string>, postId: string): string {
if (name === 'youtube') {
function renderMacro(
name: string,
params: Record<string, string>,
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
): string {
const normalizedName = normalizeMacroName(name);
if (normalizedName === 'youtube') {
const id = escapeHtml(params.id || '');
const title = escapeHtml(params.title || 'YouTube video');
if (!id) return '';
return `<div class="macro-youtube"><iframe src="https://www.youtube.com/embed/${id}?rel=0" title="${title}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>`;
}
if (name === 'vimeo') {
if (normalizedName === 'vimeo') {
const id = escapeHtml(params.id || '');
const title = escapeHtml(params.title || 'Vimeo video');
if (!id) return '';
return `<div class="macro-vimeo"><iframe src="https://player.vimeo.com/video/${id}" title="${title}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>`;
}
if (name === 'gallery') {
const columns = escapeHtml(params.columns || '3');
const caption = params.caption ? `<figcaption>${escapeHtml(params.caption)}</figcaption>` : '';
return `<div class="macro-gallery" data-post-id="${escapeHtml(postId)}" data-columns="${columns}"><div>Gallery preview is not interactive yet.</div>${caption}</div>`;
if (normalizedName === 'gallery') {
return renderGalleryMacro(params, postId, mediaItems, linkedMediaIds);
}
if (name === 'photo_archive') {
const year = params.year ? ` data-year="${escapeHtml(params.year)}"` : '';
const month = params.month ? ` data-month="${escapeHtml(params.month)}"` : '';
return `<div class="macro-photo-archive"${year}${month}><div>Photo archive preview is not interactive yet.</div></div>`;
if (normalizedName === 'photo_archive') {
return renderPhotoArchiveMacro(params, mediaItems);
}
return '';
@@ -416,6 +590,7 @@ function recordToMap(record: unknown): Map<string, string> {
export class PreviewServer {
private readonly postEngine: PostEngineContract;
private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract;
private readonly settingsEngine: MetaEngineContract;
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
private readonly liquid: Liquid;
@@ -425,6 +600,7 @@ export class PreviewServer {
constructor(dependencies?: Partial<PreviewServerDependencies>) {
this.postEngine = dependencies?.postEngine ?? getPostEngine();
this.mediaEngine = dependencies?.mediaEngine ?? getMediaEngine();
this.postMediaEngine = dependencies?.postMediaEngine ?? getPostMediaEngine();
this.settingsEngine = dependencies?.settingsEngine ?? getMetaEngine();
this.getActiveProjectContext = dependencies?.getActiveProjectContext ?? (async () => {
const projectEngine = getProjectEngine();
@@ -458,9 +634,20 @@ export class PreviewServer {
canonicalMediaPathBySourcePath: recordToMap(canonicalMediaArg),
};
const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content);
const mediaItems = needsMediaLookup
? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[])
: [];
const linkedMediaIds = needsMediaLookup && postId
? await this.postMediaEngine.getLinkedMediaDataForPost(postId)
.then((links) => new Set<string>(links.map((link) => link.media?.id).filter((id): id is string => typeof id === 'string' && id.length > 0)))
.catch(() => null)
: null;
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
const params = parseMacroParams(rawParams);
return renderMacro(macroName.toLowerCase(), params, postId);
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds);
});
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
@@ -550,6 +737,7 @@ export class PreviewServer {
const context = await this.getActiveProjectContext();
this.postEngine.setProjectContext(context.projectId, context.dataDir);
this.mediaEngine.setProjectContext?.(context.projectId, context.dataDir, context.dataDir);
this.postMediaEngine.setProjectContext(context.projectId);
this.settingsEngine.setProjectContext(context.projectId, context.dataDir);
if (this.settingsEngine.isInitialized && this.settingsEngine.syncOnStartup && !this.settingsEngine.isInitialized()) {

View File

@@ -4,7 +4,24 @@
main { display: grid; gap: 1rem; }
.post { border: 1px solid var(--muted-border-color); padding: 1rem; background: var(--card-background-color); }
.post iframe { width: 100%; min-height: 20rem; }
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; }
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; margin: 1rem 0; }
.gallery-container { display: grid; gap: .5rem; }
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.macro-gallery.gallery-cols-3 .gallery-container { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.macro-gallery.gallery-cols-4 .gallery-container { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.macro-gallery.gallery-cols-5 .gallery-container { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.gallery-item, .photo-archive-item { display: block; overflow: hidden; border-radius: .25rem; }
.gallery-item img, .photo-archive-item img { display: block; width: 100%; height: auto; aspect-ratio: 1 / 1; object-fit: cover; }
.gallery-caption { margin-top: .5rem; text-align: center; color: var(--muted-color); font-size: .92rem; }
.gallery-empty, .photo-archive-empty { color: var(--muted-color); font-style: italic; }
.photo-archive-container { display: grid; gap: 1rem; }
.photo-archive-month { display: grid; grid-template-columns: 3.25rem 1fr; gap: .75rem; align-items: start; }
.photo-archive-month-label { display: flex; justify-content: center; align-items: center; }
.photo-archive-month-label span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .08em; text-transform: uppercase; color: var(--muted-color); }
.photo-archive-gallery { display: grid; gap: .5rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.photo-archive-single-month .photo-archive-gallery { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--muted-color); }
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }

View File

@@ -103,6 +103,15 @@ function makeMediaEngine(mediaItems: Array<{ id: string; filename: string; origi
};
}
function makePostMediaEngine(linksByPostId: Record<string, Array<{ media: { id: string } }>>) {
return {
setProjectContext: vi.fn(),
async getLinkedMediaDataForPost(postId: string) {
return linksByPostId[postId] ?? [];
},
};
}
describe('PreviewServer', () => {
let server: PreviewServer;
let tempDir: string | null = null;
@@ -777,6 +786,102 @@ describe('PreviewServer', () => {
expect(html).toContain('href="https://example.com/path"');
});
it('renders gallery and photo_album macros as interactive lightbox markup in preview', async () => {
const post = makePost({
id: 'macro-1',
slug: 'macro-preview',
title: 'Macro Preview',
content: [
'[[gallery columns="2" caption="Trip Photos"]]',
'[[photo_album year="2025" month="2"]]',
].join('\n\n'),
});
server = new PreviewServer({
postEngine: makeEngine([post]),
mediaEngine: makeMediaEngine([
{
id: 'media-1',
filename: 'linked-1.jpg',
originalName: 'linked-1.jpg',
createdAt: new Date('2025-02-10T10:00:00.000Z'),
linkedPostIds: ['macro-1'],
} as any,
{
id: 'media-2',
filename: 'linked-2.jpg',
originalName: 'linked-2.jpg',
createdAt: new Date('2025-02-12T10:00:00.000Z'),
linkedPostIds: ['macro-1'],
} as any,
{
id: 'media-3',
filename: 'archive.jpg',
originalName: 'archive.jpg',
createdAt: new Date('2025-02-09T10:00:00.000Z'),
linkedPostIds: [],
} as any,
]) as any,
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).not.toContain('Gallery preview is not interactive yet.');
expect(html).not.toContain('Photo archive preview is not interactive yet.');
expect(html).toContain('class="macro-gallery gallery-cols-2"');
expect(html).toContain('data-lightbox="gallery-macro-1"');
expect(html).toContain('/media/2025/02/linked-1.jpg');
expect(html).toContain('/media/2025/02/linked-2.jpg');
expect(html).toContain('Trip Photos');
expect(html).toContain('class="macro-photo-archive photo-archive-single-month"');
expect(html).toContain('data-lightbox="photo-archive-2025-02"');
expect(html).toContain('/media/2025/02/archive.jpg');
});
it('resolves gallery linked images via post-media links even when media.linkedPostIds is empty', async () => {
const post = makePost({
id: 'macro-junction-1',
slug: 'macro-junction-preview',
title: 'Macro Junction Preview',
content: '[[gallery columns="2"]]',
});
server = new PreviewServer({
postEngine: makeEngine([post]),
mediaEngine: makeMediaEngine([
{
id: 'junction-media-1',
filename: 'junction-1.jpg',
originalName: 'junction-1.jpg',
createdAt: new Date('2025-02-10T10:00:00.000Z'),
linkedPostIds: [],
} as any,
]) as any,
postMediaEngine: makePostMediaEngine({
'macro-junction-1': [{ media: { id: 'junction-media-1' } }],
}) as any,
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
} as any);
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).not.toContain('No linked images found.');
expect(html).toContain('/media/2025/02/junction-1.jpg');
});
it('serves media files from the active project data directory at /media/...', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-preview-media-'));
const mediaDir = path.join(tempDir, 'media', '2025', '02');