From 1543af6edc5ee6832e72ed6d90197cdc33afec59 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 20 Feb 2026 22:02:41 +0100 Subject: [PATCH] fix: simpler structure in the template --- src/main/engine/BlogGenerationEngine.ts | 5 +- src/main/engine/PageRenderer.ts | 13 +- src/main/engine/PreviewServer.ts | 7 +- src/main/engine/assets/tagCloudRuntime.ts | 272 +++++++++++++++++ .../engine/templates/partials/head.liquid | 274 +----------------- tests/engine/BlogGenerationEngine.test.ts | 3 + tests/engine/PreviewServer.test.ts | 6 + 7 files changed, 301 insertions(+), 279 deletions(-) create mode 100644 src/main/engine/assets/tagCloudRuntime.ts diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 11f4b14..7fc0dc8 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -611,9 +611,10 @@ export class BlogGenerationEngine { await fs.mkdir(imagesDir, { recursive: true }); for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) { - const sourcePath = require.resolve(definition.modulePath); const destPath = path.join(assetsDir, filename); - const content = await readFile(sourcePath); + const content = definition.sourceText !== undefined + ? Buffer.from(definition.sourceText, 'utf-8') + : await readFile(require.resolve(definition.modulePath as string)); await fs.writeFile(destPath, content); } diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 84179d8..de0071a 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -4,6 +4,7 @@ import { Liquid } from 'liquidjs'; import type { MediaData } from './MediaEngine'; import type { PostData } from './PostEngine'; import { PICO_THEME_NAMES } from '../shared/picoThemes'; +import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime'; export interface HtmlRewriteContext { canonicalPostPathBySlug: Map; @@ -110,6 +111,12 @@ export interface PostEngineContract { getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise; } +export interface PreviewAssetDefinition { + contentType: string; + modulePath?: string; + sourceText?: string; +} + export interface TagUsageEntry { tag: string; count: number; @@ -131,7 +138,7 @@ export function normalizeTagCloudOrientation(value: string | undefined): TagClou return 'horizontal'; } -export const PREVIEW_ASSETS: Record = { +export const PREVIEW_ASSETS: Record = { 'pico.min.css': { modulePath: '@picocss/pico/css/pico.min.css', contentType: 'text/css; charset=utf-8', @@ -157,6 +164,10 @@ export const PREVIEW_ASSETS: Record 1) { + return 1; + } + + return value; + } + + function parseCssColor(colorValue) { + if (typeof colorValue !== 'string') { + return null; + } + + const value = colorValue.trim(); + if (!value) { + return null; + } + + const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1]; + if (hex.length === 3) { + return [ + Number.parseInt(hex[0] + hex[0], 16), + Number.parseInt(hex[1] + hex[1], 16), + Number.parseInt(hex[2] + hex[2], 16), + ]; + } + + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; + } + + const rgbMatch = value.match(/^rgba?\\(([^)]+)\\)$/i); + if (rgbMatch) { + const channels = rgbMatch[1] + .split(',') + .map((channel) => channel.trim()) + .slice(0, 3) + .map((channel) => { + if (channel.endsWith('%')) { + return Math.round((Number.parseFloat(channel) / 100) * 255); + } + + return Math.round(Number.parseFloat(channel)); + }); + + if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel))) { + return channels.map((channel) => Math.max(0, Math.min(255, channel))); + } + } + + return null; + } + + function interpolateColor(fromColor, toColor, t) { + return [ + Math.round(fromColor[0] + ((toColor[0] - fromColor[0]) * t)), + Math.round(fromColor[1] + ((toColor[1] - fromColor[1]) * t)), + Math.round(fromColor[2] + ((toColor[2] - fromColor[2]) * t)), + ]; + } + + function mixColor(fromColor, toColor, weight) { + return interpolateColor(fromColor, toColor, clamp01(weight)); + } + + function colorToCss(color) { + return 'rgb(' + color[0] + ',' + color[1] + ',' + color[2] + ')'; + } + + function getPicoThemeStops() { + const style = window.getComputedStyle(document.documentElement); + + const blue = parseCssColor(style.getPropertyValue('--pico-secondary')) || [74, 99, 146]; + const green = parseCssColor(style.getPropertyValue('--pico-ins-color')) || [53, 117, 56]; + const red = parseCssColor(style.getPropertyValue('--pico-del-color')) || [183, 72, 72]; + + const yellow = mixColor(green, red, 0.45); + const orange = mixColor(green, red, 0.72); + + return [blue, green, yellow, orange, red]; + } + + function interpolateStops(stops, value) { + if (!Array.isArray(stops) || stops.length === 0) { + return 'currentColor'; + } + + if (stops.length === 1) { + return colorToCss(stops[0]); + } + + const clamped = clamp01(value); + const scaled = clamped * (stops.length - 1); + const lowerIndex = Math.floor(scaled); + const upperIndex = Math.min(stops.length - 1, lowerIndex + 1); + const localT = scaled - lowerIndex; + + return colorToCss(interpolateColor(stops[lowerIndex], stops[upperIndex], localT)); + } + + function resolveQuantileColorMap(words) { + const counts = Array.from( + new Set(words.map((word) => Number(word.count)).filter((count) => Number.isFinite(count))) + ).sort((a, b) => a - b); + + const quantiles = new Map(); + if (counts.length === 0) { + return quantiles; + } + + if (counts.length === 1) { + quantiles.set(counts[0], 1); + return quantiles; + } + + counts.forEach((count, index) => { + quantiles.set(count, index / (counts.length - 1)); + }); + + return quantiles; + } + + function applyThemeAwareColors(words, container) { + const gammaRaw = Number.parseFloat(container.getAttribute('data-color-easing') || '0.7'); + const gamma = Number.isFinite(gammaRaw) && gammaRaw > 0 ? gammaRaw : 0.7; + const quantiles = resolveQuantileColorMap(words); + const stops = getPicoThemeStops(); + + return words.map((word) => { + const count = Number(word.count); + const quantile = quantiles.get(count) ?? 0; + const eased = Math.pow(clamp01(quantile), gamma); + + return { + ...word, + color: interpolateStops(stops, eased), + }; + }); + } + + 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 colorDistribution = container.getAttribute('data-color-distribution') || 'quantile'; + const colorTheme = container.getAttribute('data-color-theme') || 'pico'; + const coloredWords = colorDistribution === 'quantile' && colorTheme === 'pico' + ? applyThemeAwareColors(words, container) + : words; + + 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(coloredWords.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(); + } +})(); +`; diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index 754ce29..7f9b55c 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -7,278 +7,6 @@ {% render 'partials/styles' %} + - diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 9cdbd93..cbe16c9 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -184,6 +184,7 @@ describe('BlogGenerationEngine', () => { expect(await fileExists(path.join(tempDir, 'html', 'assets', 'pico.min.css'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true); + expect(await fileExists(path.join(tempDir, 'html', 'assets', 'tag-cloud.js'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true); expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true); @@ -209,6 +210,8 @@ describe('BlogGenerationEngine', () => { expect(html).toContain('data-template="post-list"'); expect(html).toContain('/assets/pico.min.css'); expect(html).toContain('/assets/lightbox.min.css'); + expect(html).toContain('/assets/tag-cloud.js'); + expect(html).not.toContain('function parseWords('); expect(html).toContain('archive-day-marker'); expect(html).toContain('15.01.2025'); expect(html).toContain('14.01.2025'); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index d4910c5..509c8bb 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -178,6 +178,8 @@ describe('PreviewServer', () => { expect(rootHtml).toContain('href="/assets/lightbox.min.css"'); expect(rootHtml).toContain('src="/assets/lightbox.min.js"'); expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"'); + expect(rootHtml).toContain('src="/assets/tag-cloud.js"'); + expect(rootHtml).not.toContain('function parseWords('); expect(rootHtml).not.toContain('cdn.jsdelivr.net'); const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`); @@ -196,6 +198,10 @@ describe('PreviewServer', () => { expect(d3CloudJsResponse.status).toBe(200); expect(d3CloudJsResponse.headers.get('content-type')).toContain('application/javascript'); + const tagCloudJsResponse = await fetch(`${server.getBaseUrl()}/assets/tag-cloud.js`); + expect(tagCloudJsResponse.status).toBe(200); + expect(tagCloudJsResponse.headers.get('content-type')).toContain('application/javascript'); + const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`); expect(lightboxPrevImageResponse.status).toBe(200); expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');