diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 75696b0..fa085e7 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import fs from 'node:fs'; import { marked } from 'marked'; import { Liquid } from 'liquidjs'; import type { MediaData } from './MediaEngine'; @@ -402,6 +403,41 @@ export function normalizeMacroName(name: string): string { return name; } +export function resolveMacroTemplateRoots(options?: { + moduleDir?: string; + cwd?: string; + resourcesPath?: string; +}): string[] { + return resolvePageRendererTemplateRoots(options).map((root) => path.resolve(root, 'macros')); +} + +const macroTemplateCache = new Map(); +const macroLiquid = new Liquid({ cache: true }); + +function readMacroTemplateSource(templateName: string): string { + const cached = macroTemplateCache.get(templateName); + if (cached) { + return cached; + } + + const candidatePaths = resolveMacroTemplateRoots().map((root) => path.join(root, `${templateName}.liquid`)); + for (const candidatePath of candidatePaths) { + if (!fs.existsSync(candidatePath)) { + continue; + } + + const source = fs.readFileSync(candidatePath, 'utf8'); + macroTemplateCache.set(templateName, source); + return source; + } + + throw new Error(`Macro template not found: ${templateName}`); +} + +function renderMacroTemplate(templateName: string, context: Record): string { + return macroLiquid.parseAndRenderSync(readMacroTemplateSource(templateName), context); +} + export function buildCanonicalMediaPath(media: MediaData): string { const year = media.createdAt.getFullYear(); const month = String(media.createdAt.getMonth() + 1).padStart(2, '0'); @@ -478,7 +514,7 @@ export function renderGalleryMacro( const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); const requestedColumns = parseIntegerParam(params.columns); const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3; - const caption = params.caption ? `` : ''; + const caption = params.caption || ''; const linkedImages = mediaItems .filter((media) => { @@ -492,16 +528,21 @@ export function renderGalleryMacro( }) .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 groupName = `gallery-${postId || 'post'}`; + const items = linkedImages.map((media) => ({ + media_path: buildCanonicalMediaPath(media), + group_name: groupName, + title: media.caption || media.title || media.originalName || media.filename, + alt: media.alt || media.title || media.originalName || media.filename, + })); - const content = galleryItems || ``; - return ``; + return renderMacroTemplate('gallery', { + columns, + post_id: postId, + caption, + items, + empty_label: translateRender(language, 'render.gallery.empty'), + }); } export function renderPhotoArchiveMacro( @@ -522,40 +563,43 @@ export function renderPhotoArchiveMacro( rootClasses.push('photo-archive-full-year'); } - const dataAttrs: string[] = []; + const dataAttrs: Array<{ name: string; value: string }> = []; if (yearParam === null) { - dataAttrs.push('data-recent="10"'); + dataAttrs.push({ name: 'data-recent', value: '10' }); } else { - dataAttrs.push(`data-year="${escapeHtml(String(yearParam))}"`); + dataAttrs.push({ name: 'data-year', value: String(yearParam) }); if (monthParam !== null) { - dataAttrs.push(`data-month="${escapeHtml(String(monthParam))}"`); + dataAttrs.push({ name: 'data-month', value: String(monthParam) }); } } const renderableMedia = mediaItems.filter((media) => isRenderableImage(media)); const buckets = buildPhotoArchiveBuckets(renderableMedia, params); - if (buckets.length === 0) { - const emptyLabel = escapeHtml(translateRender(language, 'render.photoArchive.empty')); - return `
${emptyLabel}
`; - } - - const monthsHtml = buckets.map((bucket) => { + const months = buckets.map((bucket) => { const monthName = translateRender(language, `render.month.${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(''); + const items = bucket.media.map((media) => ({ + media_path: buildCanonicalMediaPath(media), + group_name: groupName, + title: media.caption || media.title || media.originalName || media.filename, + alt: media.alt || media.title || media.originalName || media.filename, + })); - return `
${escapeHtml(label)}
`; - }).join(''); + return { + label, + items, + }; + }); - return `
${monthsHtml}
`; + return renderMacroTemplate('photo-archive', { + root_classes: rootClasses.join(' '), + data_attrs: dataAttrs, + months, + empty_label: translateRender(language, 'render.photoArchive.empty'), + }); } export function renderTagCloudMacro(params: Record, tagUsage: TagUsageEntry[], renderLanguage: string): string { @@ -567,7 +611,14 @@ export function renderTagCloudMacro(params: Record, tagUsage: Ta const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420; if (tagUsage.length === 0) { - return `
${escapeHtml(translateRender(language, 'render.tagCloud.empty'))}
`; + return renderMacroTemplate('tag-cloud', { + orientation, + words_json: '', + width, + height, + aria_label: translateRender(language, 'render.tagCloud.ariaLabel'), + empty_label: translateRender(language, 'render.tagCloud.empty'), + }); } const minCount = Math.min(...tagUsage.map((entry) => entry.count)); @@ -590,8 +641,14 @@ export function renderTagCloudMacro(params: Record, tagUsage: Ta const wordsJson = escapeHtml(JSON.stringify(words)); - const ariaLabel = escapeHtml(translateRender(language, 'render.tagCloud.ariaLabel')); - return `
`; + return renderMacroTemplate('tag-cloud', { + orientation, + words_json: wordsJson, + width, + height, + aria_label: translateRender(language, 'render.tagCloud.ariaLabel'), + empty_label: translateRender(language, 'render.tagCloud.empty'), + }); } export function isExternalOrSpecialUrl(value: string): boolean { @@ -715,18 +772,18 @@ export function renderMacro( if (normalizedName === 'youtube') { const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); - const id = escapeHtml(params.id || ''); - const title = escapeHtml(params.title || translateRender(language, 'render.video.youtubeTitle')); + const id = (params.id || '').trim(); + const title = (params.title || translateRender(language, 'render.video.youtubeTitle')).trim(); if (!id) return ''; - return `
`; + return renderMacroTemplate('youtube', { id, title }); } if (normalizedName === 'vimeo') { const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); - const id = escapeHtml(params.id || ''); - const title = escapeHtml(params.title || translateRender(language, 'render.video.vimeoTitle')); + const id = (params.id || '').trim(); + const title = (params.title || translateRender(language, 'render.video.vimeoTitle')).trim(); if (!id) return ''; - return `
`; + return renderMacroTemplate('vimeo', { id, title }); } if (normalizedName === 'gallery') { diff --git a/src/main/engine/templates/macros/gallery.liquid b/src/main/engine/templates/macros/gallery.liquid new file mode 100644 index 0000000..f30ee85 --- /dev/null +++ b/src/main/engine/templates/macros/gallery.liquid @@ -0,0 +1,30 @@ + diff --git a/src/main/engine/templates/macros/photo-archive.liquid b/src/main/engine/templates/macros/photo-archive.liquid new file mode 100644 index 0000000..c3964c0 --- /dev/null +++ b/src/main/engine/templates/macros/photo-archive.liquid @@ -0,0 +1,33 @@ +
+
+ {%- if months.size > 0 -%} + {%- for month in months -%} +
+
+
+ {{ month.label | escape }} +
+ +
+
+ {%- endfor -%} + {%- else -%} +
{{ empty_label | escape }}
+ {%- endif -%} +
+
diff --git a/src/main/engine/templates/macros/tag-cloud.liquid b/src/main/engine/templates/macros/tag-cloud.liquid new file mode 100644 index 0000000..6632a61 --- /dev/null +++ b/src/main/engine/templates/macros/tag-cloud.liquid @@ -0,0 +1,19 @@ +
+ {%- if words_json -%} + + {%- else -%} +
{{ empty_label | escape }}
+ {%- endif -%} +
diff --git a/src/main/engine/templates/macros/vimeo.liquid b/src/main/engine/templates/macros/vimeo.liquid new file mode 100644 index 0000000..f93a8fa --- /dev/null +++ b/src/main/engine/templates/macros/vimeo.liquid @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/main/engine/templates/macros/youtube.liquid b/src/main/engine/templates/macros/youtube.liquid new file mode 100644 index 0000000..eaf293e --- /dev/null +++ b/src/main/engine/templates/macros/youtube.liquid @@ -0,0 +1,9 @@ +
+ +
diff --git a/tests/engine/PageRenderer.macros.test.ts b/tests/engine/PageRenderer.macros.test.ts new file mode 100644 index 0000000..1cc5b4a --- /dev/null +++ b/tests/engine/PageRenderer.macros.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { MediaData } from '../../src/main/engine/MediaEngine'; +import { + renderMacro, + resolveMacroTemplateRoots, +} from '../../src/main/engine/PageRenderer'; + +function createMedia(overrides?: Partial): MediaData { + return { + id: 'media-1', + filename: 'photo.jpg', + originalName: 'photo.jpg', + mimeType: 'image/jpeg', + size: 1024, + createdAt: new Date('2026-02-20T10:00:00.000Z'), + updatedAt: new Date('2026-02-20T10:00:00.000Z'), + tags: [], + ...overrides, + }; +} + +describe('PageRenderer macro template roots', () => { + it('includes macros subfolder for packaged app builds', () => { + const roots = resolveMacroTemplateRoots({ + moduleDir: '/Applications/Blogging Desktop Server.app/Contents/Resources/app.asar/dist/main/engine', + cwd: '/tmp/runtime-cwd', + resourcesPath: '/Applications/Blogging Desktop Server.app/Contents/Resources', + }); + + expect(roots).toContain('/Applications/Blogging Desktop Server.app/Contents/Resources/templates/macros'); + }); +}); + +describe('PageRenderer macros', () => { + it('renders youtube macro html', () => { + const html = renderMacro('youtube', { id: 'abc123' }, '', [], null, [], 'en'); + expect(html).toContain('class="macro-youtube"'); + expect(html).toContain('https://www.youtube.com/embed/abc123?rel=0'); + }); + + it('renders gallery macro html from linked media', () => { + const media = createMedia({ id: 'm-1' }); + + const html = renderMacro( + 'gallery', + { columns: '4' }, + 'post-1', + [media], + new Set(['m-1']), + [], + 'en', + ); + + expect(html).toContain('class="macro-gallery gallery-cols-4"'); + expect(html).toContain('class="gallery-item"'); + }); +}); \ No newline at end of file