feat: moved html code for macros to templates
This commit is contained in:
@@ -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<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 {
|
||||
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 ? `<figcaption class="gallery-caption">${escapeHtml(params.caption)}</figcaption>` : '';
|
||||
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 `<a class="gallery-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
||||
}).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 || `<div class="gallery-empty">${escapeHtml(translateRender(language, 'render.gallery.empty'))}</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>`;
|
||||
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 `<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 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 `<a class="photo-archive-item" href="${mediaPath}" data-lightbox="${groupName}" data-title="${title}"><img src="${mediaPath}" alt="${alt}" loading="lazy" /></a>`;
|
||||
}).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 `<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 {
|
||||
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 {
|
||||
@@ -567,7 +611,14 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
|
||||
const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420;
|
||||
|
||||
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));
|
||||
@@ -590,8 +641,14 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
|
||||
|
||||
const wordsJson = escapeHtml(JSON.stringify(words));
|
||||
|
||||
const ariaLabel = escapeHtml(translateRender(language, 'render.tagCloud.ariaLabel'));
|
||||
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>`;
|
||||
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 `<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') {
|
||||
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 `<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') {
|
||||
|
||||
30
src/main/engine/templates/macros/gallery.liquid
Normal file
30
src/main/engine/templates/macros/gallery.liquid
Normal 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>
|
||||
33
src/main/engine/templates/macros/photo-archive.liquid
Normal file
33
src/main/engine/templates/macros/photo-archive.liquid
Normal 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>
|
||||
19
src/main/engine/templates/macros/tag-cloud.liquid
Normal file
19
src/main/engine/templates/macros/tag-cloud.liquid
Normal 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>
|
||||
9
src/main/engine/templates/macros/vimeo.liquid
Normal file
9
src/main/engine/templates/macros/vimeo.liquid
Normal 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>
|
||||
9
src/main/engine/templates/macros/youtube.liquid
Normal file
9
src/main/engine/templates/macros/youtube.liquid
Normal 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>
|
||||
57
tests/engine/PageRenderer.macros.test.ts
Normal file
57
tests/engine/PageRenderer.macros.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user