diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 124901c..af0facf 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -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; } @@ -120,6 +122,11 @@ interface MediaEngineContract { setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void; } +interface PostMediaEngineContract { + getLinkedMediaDataForPost: (postId: string) => Promise>; + 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, +): 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(); + 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, + postId: string, + mediaItems: MediaData[], + linkedMediaIds: Set | null, +): string { + const requestedColumns = parseIntegerParam(params.columns); + const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3; + const caption = params.caption ? `` : ''; + + 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 `${alt}`; + }).join(''); + + const content = galleryItems || ''; + return ``; +} + +function renderPhotoArchiveMacro(params: Record, 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 `
No photos found for this archive.
`; + } + + 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 `${alt}`; + }).join(''); + + return `
${escapeHtml(label)}
`; + }).join(''); + + return `
${monthsHtml}
`; +} + 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, postId: string): string { - if (name === 'youtube') { +function renderMacro( + name: string, + params: Record, + postId: string, + mediaItems: MediaData[], + linkedMediaIds: Set | 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 `
`; } - if (name === 'vimeo') { + if (normalizedName === 'vimeo') { const id = escapeHtml(params.id || ''); const title = escapeHtml(params.title || 'Vimeo video'); if (!id) return ''; return `
`; } - if (name === 'gallery') { - const columns = escapeHtml(params.columns || '3'); - const caption = params.caption ? `
${escapeHtml(params.caption)}
` : ''; - return ``; + 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 `
Photo archive preview is not interactive yet.
`; + if (normalizedName === 'photo_archive') { + return renderPhotoArchiveMacro(params, mediaItems); } return ''; @@ -416,6 +590,7 @@ function recordToMap(record: unknown): Map { export class PreviewServer { private readonly postEngine: PostEngineContract; private readonly mediaEngine: MediaEngineContract; + private readonly postMediaEngine: PostMediaEngineContract; private readonly settingsEngine: MetaEngineContract; private readonly getActiveProjectContext: () => Promise; private readonly liquid: Liquid; @@ -425,6 +600,7 @@ export class PreviewServer { constructor(dependencies?: Partial) { 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(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()) { diff --git a/src/main/engine/templates/partials/styles.liquid b/src/main/engine/templates/partials/styles.liquid index 9f2f68f..e72def5 100644 --- a/src/main/engine/templates/partials/styles.liquid +++ b/src/main/engine/templates/partials/styles.liquid @@ -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; } diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index ad8bfb4..7701263 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -103,6 +103,15 @@ function makeMediaEngine(mediaItems: Array<{ id: string; filename: string; origi }; } +function makePostMediaEngine(linksByPostId: Record>) { + 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');