feat: tag cloud. macro
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user