feat: moved html code for macros to templates

This commit is contained in:
2026-02-24 06:59:32 +01:00
parent 4ad7aa4862
commit cb445674ce
7 changed files with 252 additions and 38 deletions

View File

@@ -1,4 +1,5 @@
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs';
import { marked } from 'marked'; import { marked } from 'marked';
import { Liquid } from 'liquidjs'; import { Liquid } from 'liquidjs';
import type { MediaData } from './MediaEngine'; import type { MediaData } from './MediaEngine';
@@ -402,6 +403,41 @@ export function normalizeMacroName(name: string): string {
return name; 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<string, string>();
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, unknown>): string {
return macroLiquid.parseAndRenderSync(readMacroTemplateSource(templateName), context);
}
export function buildCanonicalMediaPath(media: MediaData): string { export function buildCanonicalMediaPath(media: MediaData): string {
const year = media.createdAt.getFullYear(); const year = media.createdAt.getFullYear();
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0'); const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
@@ -478,7 +514,7 @@ export function renderGalleryMacro(
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const requestedColumns = parseIntegerParam(params.columns); const requestedColumns = parseIntegerParam(params.columns);
const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3; const columns = requestedColumns && requestedColumns >= 1 && requestedColumns <= 6 ? requestedColumns : 3;
const caption = params.caption ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : ''; const caption = params.caption || '';
const linkedImages = mediaItems const linkedImages = mediaItems
.filter((media) => { .filter((media) => {
@@ -492,16 +528,21 @@ export function renderGalleryMacro(
}) })
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const groupName = `gallery-${escapeHtml(postId || 'post')}`; const groupName = `gallery-${postId || 'post'}`;
const galleryItems = linkedImages.map((media) => { const items = linkedImages.map((media) => ({
const mediaPath = escapeHtml(buildCanonicalMediaPath(media)); media_path: buildCanonicalMediaPath(media),
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename); group_name: groupName,
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename); title: media.caption || 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>`; alt: media.alt || media.title || media.originalName || media.filename,
}).join(''); }));
const content = galleryItems || `<div class="gallery-empty">${escapeHtml(translateRender(language, 'render.gallery.empty'))}</div>`; return renderMacroTemplate('gallery', {
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>`; columns,
post_id: postId,
caption,
items,
empty_label: translateRender(language, 'render.gallery.empty'),
});
} }
export function renderPhotoArchiveMacro( export function renderPhotoArchiveMacro(
@@ -522,40 +563,43 @@ export function renderPhotoArchiveMacro(
rootClasses.push('photo-archive-full-year'); rootClasses.push('photo-archive-full-year');
} }
const dataAttrs: string[] = []; const dataAttrs: Array<{ name: string; value: string }> = [];
if (yearParam === null) { if (yearParam === null) {
dataAttrs.push('data-recent="10"'); dataAttrs.push({ name: 'data-recent', value: '10' });
} else { } else {
dataAttrs.push(`data-year="${escapeHtml(String(yearParam))}"`); dataAttrs.push({ name: 'data-year', value: String(yearParam) });
if (monthParam !== null) { 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 renderableMedia = mediaItems.filter((media) => isRenderableImage(media));
const buckets = buildPhotoArchiveBuckets(renderableMedia, params); const buckets = buildPhotoArchiveBuckets(renderableMedia, params);
if (buckets.length === 0) { const months = buckets.map((bucket) => {
const emptyLabel = escapeHtml(translateRender(language, 'render.photoArchive.empty'));
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container"><div class="photo-archive-empty">${emptyLabel}</div></div></div>`;
}
const monthsHtml = buckets.map((bucket) => {
const monthName = translateRender(language, `render.month.${bucket.month}`); const monthName = translateRender(language, `render.month.${bucket.month}`);
const label = `${monthName} ${bucket.year}`; const label = `${monthName} ${bucket.year}`;
const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`; const groupName = `photo-archive-${bucket.year}-${String(bucket.month).padStart(2, '0')}`;
const itemsHtml = bucket.media.map((media) => { const items = bucket.media.map((media) => ({
const mediaPath = escapeHtml(buildCanonicalMediaPath(media)); media_path: buildCanonicalMediaPath(media),
const title = escapeHtml(media.caption || media.title || media.originalName || media.filename); group_name: groupName,
const alt = escapeHtml(media.alt || media.title || media.originalName || media.filename); title: media.caption || 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>`; alt: media.alt || media.title || media.originalName || media.filename,
}).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>`; return {
}).join(''); label,
items,
};
});
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`; return renderMacroTemplate('photo-archive', {
root_classes: rootClasses.join(' '),
data_attrs: dataAttrs,
months,
empty_label: translateRender(language, 'render.photoArchive.empty'),
});
} }
export function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageEntry[], renderLanguage: string): string { export function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageEntry[], renderLanguage: string): string {
@@ -567,7 +611,14 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420; const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420;
if (tagUsage.length === 0) { if (tagUsage.length === 0) {
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico"><div class="tag-cloud-empty">${escapeHtml(translateRender(language, 'render.tagCloud.empty'))}</div></div>`; 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)); const minCount = Math.min(...tagUsage.map((entry) => entry.count));
@@ -590,8 +641,14 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
const wordsJson = escapeHtml(JSON.stringify(words)); const wordsJson = escapeHtml(JSON.stringify(words));
const ariaLabel = escapeHtml(translateRender(language, 'render.tagCloud.ariaLabel')); return renderMacroTemplate('tag-cloud', {
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico" data-tag-cloud-words="${wordsJson}" data-width="${width}" data-height="${height}"><svg class="tag-cloud-canvas" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" aria-label="${ariaLabel}"></svg></div>`; 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 { export function isExternalOrSpecialUrl(value: string): boolean {
@@ -715,18 +772,18 @@ export function renderMacro(
if (normalizedName === 'youtube') { if (normalizedName === 'youtube') {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const id = escapeHtml(params.id || ''); const id = (params.id || '').trim();
const title = escapeHtml(params.title || translateRender(language, 'render.video.youtubeTitle')); const title = (params.title || translateRender(language, 'render.video.youtubeTitle')).trim();
if (!id) return ''; 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>`; return renderMacroTemplate('youtube', { id, title });
} }
if (normalizedName === 'vimeo') { if (normalizedName === 'vimeo') {
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage); const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
const id = escapeHtml(params.id || ''); const id = (params.id || '').trim();
const title = escapeHtml(params.title || translateRender(language, 'render.video.vimeoTitle')); const title = (params.title || translateRender(language, 'render.video.vimeoTitle')).trim();
if (!id) return ''; 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>`; return renderMacroTemplate('vimeo', { id, title });
} }
if (normalizedName === 'gallery') { if (normalizedName === 'gallery') {

View File

@@ -0,0 +1,30 @@
<div
class="macro-gallery gallery-cols-{{ columns }}"
data-post-id="{{ post_id | escape }}"
data-columns="{{ columns }}"
data-lightbox="true"
>
<div class="gallery-container gallery-lightbox">
{%- if items.size > 0 -%}
{%- for item in items -%}
<a
class="gallery-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
{%- else -%}
<div class="gallery-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>
{%- if caption -%}
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
{%- endif -%}
</div>

View File

@@ -0,0 +1,33 @@
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
<div class="photo-archive-container">
{%- if months.size > 0 -%}
{%- for month in months -%}
<div class="photo-archive-month-wrapper">
<div class="photo-archive-month">
<div class="photo-archive-month-label">
<span>{{ month.label | escape }}</span>
</div>
<div class="photo-archive-gallery">
{%- for item in month.items -%}
<a
class="photo-archive-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
</div>
</div>
</div>
{%- endfor -%}
{%- else -%}
<div class="photo-archive-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>
</div>

View File

@@ -0,0 +1,19 @@
<div
class="macro-tag-cloud"
data-tag-cloud="true"
data-orientation="{{ orientation }}"
data-color-distribution="quantile"
data-color-easing="0.7"
data-color-theme="pico"{%- if words_json -%} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
>
{%- if words_json -%}
<svg
class="tag-cloud-canvas"
viewBox="0 0 {{ width }} {{ height }}"
preserveAspectRatio="xMidYMid meet"
aria-label="{{ aria_label | escape }}"
></svg>
{%- else -%}
<div class="tag-cloud-empty">{{ empty_label | escape }}</div>
{%- endif -%}
</div>

View File

@@ -0,0 +1,9 @@
<div class="macro-vimeo">
<iframe
src="https://player.vimeo.com/video/{{ id | escape }}"
title="{{ title | escape }}"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -0,0 +1,9 @@
<div class="macro-youtube">
<iframe
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
title="{{ title | escape }}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -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>): 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<string>(['m-1']),
[],
'en',
);
expect(html).toContain('class="macro-gallery gallery-cols-4"');
expect(html).toContain('class="gallery-item"');
});
});