feat: tag cloud. macro

This commit is contained in:
2026-02-20 21:49:32 +01:00
parent 63c4b148e1
commit f69f42c647
10 changed files with 466 additions and 31 deletions

View File

@@ -514,7 +514,7 @@ export class BlogGenerationEngine {
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)),
};
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine);
const pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
let pagesGenerated = 0;

View File

@@ -107,6 +107,64 @@ export interface PostMediaEngineContract {
export interface PostEngineContract {
getPost: (id: string) => Promise<PostData | null>;
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
}
export interface TagUsageEntry {
tag: string;
count: number;
}
export type TagCloudOrientationMode = 'horizontal' | 'mixed-hv' | 'mixed-diagonal';
export function normalizeTagCloudOrientation(value: string | undefined): TagCloudOrientationMode {
const normalized = (value || '').trim().toLowerCase();
if (normalized === 'mixed_hv' || normalized === 'mixed-hv' || normalized === 'hv' || normalized === 'horizontal_vertical') {
return 'mixed-hv';
}
if (normalized === 'mixed_diagonal' || normalized === 'mixed-diagonal' || normalized === 'diagonal' || normalized === 'diag') {
return 'mixed-diagonal';
}
return 'horizontal';
}
function interpolateChannel(start: number, end: number, t: number): number {
return Math.round(start + ((end - start) * t));
}
export function resolveTagCloudColor(normalizedRatio: number): string {
if (normalizedRatio >= 1) {
return 'red';
}
if (normalizedRatio <= 0) {
return 'blue';
}
const palette = [
[0, 0, 255],
[0, 128, 0],
[255, 255, 0],
[255, 165, 0],
[255, 0, 0],
];
const scaled = normalizedRatio * (palette.length - 1);
const lowerIndex = Math.floor(scaled);
const upperIndex = Math.min(palette.length - 1, lowerIndex + 1);
const segmentRatio = scaled - lowerIndex;
const lowerColor = palette[lowerIndex];
const upperColor = palette[upperIndex];
const red = interpolateChannel(lowerColor[0], upperColor[0], segmentRatio);
const green = interpolateChannel(lowerColor[1], upperColor[1], segmentRatio);
const blue = interpolateChannel(lowerColor[2], upperColor[2], segmentRatio);
return `rgb(${red},${green},${blue})`;
}
export const PREVIEW_ASSETS: Record<string, { modulePath: string; contentType: string }> = {
@@ -131,6 +189,10 @@ export const PREVIEW_ASSETS: Record<string, { modulePath: string; contentType: s
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
contentType: 'application/javascript; charset=utf-8',
},
'd3.layout.cloud.js': {
modulePath: 'd3-cloud/build/d3.layout.cloud.js',
contentType: 'application/javascript; charset=utf-8',
},
};
export const PREVIEW_IMAGE_ASSETS = {
@@ -377,6 +439,44 @@ export function renderPhotoArchiveMacro(params: Record<string, string>, mediaIte
return `<div class="${rootClasses.join(' ')}" ${dataAttrs.join(' ')}><div class="photo-archive-container">${monthsHtml}</div></div>`;
}
export function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageEntry[]): string {
const widthParam = parseIntegerParam(params.width);
const heightParam = parseIntegerParam(params.height);
const orientation = normalizeTagCloudOrientation(params.orientation);
const width = widthParam && widthParam >= 320 && widthParam <= 1600 ? widthParam : 900;
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}"><div class="tag-cloud-empty">No tags found.</div></div>`;
}
const minCount = Math.min(...tagUsage.map((entry) => entry.count));
const maxCount = Math.max(...tagUsage.map((entry) => entry.count));
const minFont = 14;
const maxFont = 56;
const words = tagUsage.map((entry) => {
const ratio = maxCount === minCount
? 1
: (entry.count - minCount) / (maxCount - minCount);
const normalizedSize = maxCount === minCount
? Math.round((minFont + maxFont) / 2)
: Math.round(minFont + ((entry.count - minCount) / (maxCount - minCount)) * (maxFont - minFont));
return {
text: entry.tag,
size: normalizedSize,
count: entry.count,
color: resolveTagCloudColor(ratio),
url: `/tag/${encodeURIComponent(entry.tag)}/`,
};
});
const wordsJson = escapeHtml(JSON.stringify(words));
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" 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="Tag cloud"></svg></div>`;
}
export function isExternalOrSpecialUrl(value: string): boolean {
const normalized = value.trim();
if (!normalized) return false;
@@ -479,6 +579,7 @@ export function renderMacro(
postId: string,
mediaItems: MediaData[],
linkedMediaIds: Set<string> | null,
tagUsage: TagUsageEntry[],
): string {
const normalizedName = normalizeMacroName(name);
@@ -504,6 +605,10 @@ export function renderMacro(
return renderPhotoArchiveMacro(params, mediaItems);
}
if (normalizedName === 'tag_cloud') {
return renderTagCloudMacro(params, tagUsage);
}
return '';
}
@@ -581,11 +686,13 @@ export function recordToMap(record: unknown): Map<string, string> {
export class PageRenderer {
private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract;
private readonly postEngineForMacros?: PostEngineContract;
private readonly liquid: Liquid;
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract) {
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract, postEngineForMacros?: PostEngineContract) {
this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine;
this.postEngineForMacros = postEngineForMacros;
const templateRoots = [
path.resolve(__dirname, 'templates'),
@@ -608,10 +715,15 @@ export class PageRenderer {
};
const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content);
const needsTagCloudLookup = /\[\[(tag_cloud)\b/i.test(content);
const mediaItems = needsMediaLookup
? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[])
: [];
const tagUsage = needsTagCloudLookup
? await this.getTagUsageData()
: [];
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)))
@@ -620,7 +732,7 @@ export class PageRenderer {
const withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
const params = parseMacroParams(rawParams);
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds);
return renderMacro(macroName.toLowerCase(), params, postId, mediaItems, linkedMediaIds, tagUsage);
});
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
@@ -628,6 +740,32 @@ export class PageRenderer {
});
}
private async getTagUsageData(): Promise<TagUsageEntry[]> {
if (!this.postEngineForMacros?.getPostsFiltered) {
return [];
}
const posts = await this.postEngineForMacros.getPostsFiltered({ status: 'published' }).catch(() => [] as PostData[]);
const tagCounts = new Map<string, number>();
for (const post of posts) {
const postTags = Array.isArray(post.tags) ? post.tags : [];
const uniqueTags = new Set<string>(
postTags
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
);
for (const tag of uniqueTags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
return Array.from(tagCounts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => (b.count - a.count) || a.tag.localeCompare(b.tag));
}
buildListTemplateContext(
posts: PostData[],
rewriteContext: HtmlRewriteContext,

View File

@@ -78,7 +78,7 @@ export class PreviewServer {
projectDescription: activeProject?.description ?? undefined,
};
});
this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine);
this.pageRenderer = new PageRenderer(this.mediaEngine, this.postMediaEngine, this.postEngine);
}
async start(preferredPort = 0): Promise<number> {

View File

@@ -6,5 +6,120 @@
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
{% render 'partials/styles' %}
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/lightbox.min.js"></script>
<script>
(function () {
function parseWords(rawWords) {
if (!rawWords || typeof rawWords !== 'string') {
return [];
}
try {
const parsed = JSON.parse(rawWords);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function drawTagCloud(container) {
const cloudFactory = window.d3 && window.d3.layout && typeof window.d3.layout.cloud === 'function'
? window.d3.layout.cloud
: null;
if (!cloudFactory) {
return;
}
const rawWords = container.getAttribute('data-tag-cloud-words');
const words = parseWords(rawWords);
if (words.length === 0) {
return;
}
const width = Number.parseInt(container.getAttribute('data-width') || '900', 10) || 900;
const height = Number.parseInt(container.getAttribute('data-height') || '420', 10) || 420;
const orientation = container.getAttribute('data-orientation') || 'horizontal';
const resolveRotation = () => {
if (orientation === 'mixed-hv') {
return Math.random() < 0.5 ? 0 : 90;
}
if (orientation === 'mixed-diagonal') {
const diagonalAngles = [-60, -30, 0, 30, 60, 90];
const index = Math.floor(Math.random() * diagonalAngles.length);
return diagonalAngles[index];
}
return 0;
};
const svgNode = container.querySelector('svg.tag-cloud-canvas');
if (!svgNode) {
return;
}
while (svgNode.firstChild) {
svgNode.removeChild(svgNode.firstChild);
}
cloudFactory()
.size([width, height])
.words(words.map((word) => ({ ...word })))
.padding(4)
.rotate(() => resolveRotation())
.font('sans-serif')
.fontSize((word) => word.size)
.on('end', (layoutWords) => {
svgNode.setAttribute('viewBox', `0 0 ${width} ${height}`);
svgNode.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('transform', `translate(${width / 2},${height / 2})`);
for (const word of layoutWords) {
const textNode = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textNode.textContent = word.text;
textNode.setAttribute('text-anchor', 'middle');
textNode.setAttribute('transform', `translate(${word.x},${word.y})rotate(${word.rotate || 0})`);
textNode.style.fontFamily = 'sans-serif';
textNode.style.fontSize = `${word.size}px`;
textNode.style.fill = typeof word.color === 'string' && word.color.length > 0
? word.color
: 'currentColor';
textNode.style.cursor = 'pointer';
textNode.style.opacity = '0.9';
const titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
titleNode.textContent = `${word.text} (${word.count})`;
textNode.appendChild(titleNode);
textNode.addEventListener('click', () => {
if (word && typeof word.url === 'string' && word.url.length > 0) {
window.location.assign(word.url);
}
});
group.appendChild(textNode);
}
svgNode.appendChild(group);
})
.start();
}
function initTagClouds() {
const containers = document.querySelectorAll('[data-tag-cloud="true"]');
containers.forEach((container) => drawTagCloud(container));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTagClouds, { once: true });
} else {
initTagClouds();
}
})();
</script>
</head>

View File

@@ -4,7 +4,7 @@
main { display: grid; gap: 1rem; }
.post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); }
.post iframe { width: 100%; min-height: 20rem; }
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--pico-muted-border-color, var(--muted-border-color)); padding: .75rem; margin: 1rem 0; }
.macro-gallery, .macro-photo-archive, .macro-tag-cloud { border: 1px dashed var(--pico-muted-border-color, 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)); }
@@ -22,6 +22,9 @@
.photo-archive-month-label span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .08em; text-transform: uppercase; color: var(--pico-muted-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)); }
.macro-tag-cloud { min-height: 14rem; }
.tag-cloud-canvas { display: block; width: 100%; height: auto; min-height: 12rem; }
.tag-cloud-empty { color: var(--pico-muted-color, var(--muted-color)); font-style: italic; }
.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(--pico-muted-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; }