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

@@ -9,6 +9,7 @@
- [Working with posts](#working-with-posts) - [Working with posts](#working-with-posts)
- [Working with pages](#working-with-pages) - [Working with pages](#working-with-pages)
- [Working with media](#working-with-media) - [Working with media](#working-with-media)
- [Using macros](#using-macros)
- [Organizing with tags](#organizing-with-tags) - [Organizing with tags](#organizing-with-tags)
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr) - [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control) - [Using Git (Source Control)](#using-git-source-control)
@@ -143,6 +144,56 @@ After placing media in content, run a quick preview pass to confirm placement an
--- ---
## Using macros
Macros let you insert dynamic content blocks directly inside post/page Markdown by using `[[macro_name ...]]` syntax. bDS expands these macros during preview and generated output using local assets only.
Use macros when you need reusable rich blocks (for example embedded videos, media galleries, archive grids, or computed tag clouds) without writing raw HTML.
### Supported macros
- `[[youtube id="VIDEO_ID" title="Optional title"]]`
- Embeds a YouTube video.
- `id` is required.
- `title` is optional (used for accessibility label).
- `[[vimeo id="VIDEO_ID" title="Optional title"]]`
- Embeds a Vimeo video.
- `id` is required.
- `title` is optional.
- `[[gallery columns="3" caption="Optional caption"]]`
- Renders a lightbox-enabled gallery using media linked to the current post.
- `columns` is optional (`1` to `6`, default `3`).
- `caption` is optional.
- `[[photo_archive year="2025" month="2"]]`
- Renders a photo archive grid from media dates.
- `year` is optional (when omitted, recent months are shown).
- `month` is optional (used with `year` for a single month view).
- Legacy alias `[[photo_album ...]]` is also supported.
- `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]`
- Builds a word cloud from published tag usage counts.
- Word size scales by usage quantity.
- Word color scales by quantity from least to most: blue → green → yellow → orange → red.
- Clicking a word opens that tag archive route.
- `orientation` is optional and supports:
- `horizontal` (all words horizontal)
- `mixed_hv` (mix of horizontal and vertical)
- `mixed_diagonal` (mix of horizontal/vertical/diagonal angles)
- `width` and `height` are optional (defaults `900` and `420`).
### Key takeaways
- Macros are inserted directly in Markdown and expanded during preview/publish rendering.
- Use macro parameters to control behavior without leaving the editor.
- `tag_cloud` is data-driven and links directly into tag archive navigation.
[↑ Back to In this article](#in-this-article)
---
## Organizing with tags ## Organizing with tags
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading. Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer serve users. The Tags section exists to keep taxonomy useful and prevent search and filtering quality from degrading.

78
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11", "@xmldom/xmldom": "^0.8.11",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"dropbox": "^10.34.0", "dropbox": "^10.34.0",
@@ -171,6 +172,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -744,6 +746,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0", "@codemirror/view": "^6.23.0",
@@ -820,6 +823,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -841,6 +845,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz",
"integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -936,6 +941,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@@ -976,6 +982,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@@ -1370,7 +1377,6 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1392,7 +1398,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1409,7 +1414,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1424,7 +1428,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -3773,6 +3776,7 @@
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz", "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==", "integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@libsql/core": "^0.17.0", "@libsql/core": "^0.17.0",
"@libsql/hrana-client": "^0.9.0", "@libsql/hrana-client": "^0.9.0",
@@ -4961,8 +4965,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -5184,6 +5187,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -5194,6 +5198,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -5411,6 +5416,7 @@
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.18", "@vitest/utils": "4.0.18",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -5607,6 +5613,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -6129,6 +6136,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6814,8 +6822,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true
"peer": true
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "10.1.0", "version": "10.1.0",
@@ -6952,7 +6959,23 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
},
"node_modules/d3-cloud": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/d3-cloud/-/d3-cloud-1.2.8.tgz",
"integrity": "sha512-K0qBFkgystNlgFW/ufdwIES5kDiC8cGJxMw4ULzN9UU511v89A6HXs1X8vUPxqurehzqJZS5KzZI4c8McT+4UA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "^1.0.3"
}
},
"node_modules/d3-cloud/node_modules/d3-dispatch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
"license": "BSD-3-Clause"
}, },
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
@@ -7208,6 +7231,7 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.7.0", "app-builder-lib": "26.7.0",
"builder-util": "26.4.1", "builder-util": "26.4.1",
@@ -7289,8 +7313,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.1",
@@ -7763,7 +7786,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -7784,7 +7806,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -7918,6 +7939,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -8830,7 +8852,7 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -9137,6 +9159,7 @@
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.7.6", "@asamuzakjp/dom-selector": "^6.7.6",
@@ -9428,7 +9451,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -10669,7 +10691,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -10682,6 +10703,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"dompurify": "3.2.7", "dompurify": "3.2.7",
"marked": "14.0.0" "marked": "14.0.0"
@@ -11179,6 +11201,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11254,7 +11277,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -11272,7 +11294,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -11283,7 +11304,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -11299,7 +11319,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -11454,6 +11473,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@@ -11487,6 +11507,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0", "prosemirror-transform": "^1.0.0",
@@ -11520,6 +11541,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@@ -11597,6 +11619,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -11606,6 +11629,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -11635,8 +11659,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@@ -11898,7 +11921,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -12011,7 +12033,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-filename": { "node_modules/sanitize-filename": {
@@ -12616,7 +12638,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -12883,7 +12904,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true, "devOptional": true,
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
@@ -13420,6 +13442,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -13700,6 +13723,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -14259,6 +14283,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.18", "@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18", "@vitest/mocker": "4.0.18",
@@ -14336,6 +14361,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.28", "@vue/compiler-dom": "3.5.28",
"@vue/compiler-sfc": "3.5.28", "@vue/compiler-sfc": "3.5.28",

View File

@@ -70,6 +70,7 @@
"@picocss/pico": "^2.1.1", "@picocss/pico": "^2.1.1",
"@xmldom/xmldom": "^0.8.11", "@xmldom/xmldom": "^0.8.11",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"d3-cloud": "^1.2.8",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"dropbox": "^10.34.0", "dropbox": "^10.34.0",

View File

@@ -514,7 +514,7 @@ export class BlogGenerationEngine {
pico_stylesheet_href: getPicoStylesheetHref(sanitizePicoTheme(options.picoTheme)), 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); const rewriteContext = this.buildHtmlRewriteContext(publishedPosts);
let pagesGenerated = 0; let pagesGenerated = 0;

View File

@@ -107,6 +107,64 @@ export interface PostMediaEngineContract {
export interface PostEngineContract { export interface PostEngineContract {
getPost: (id: string) => Promise<PostData | null>; 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 }> = { 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', modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
contentType: 'application/javascript; charset=utf-8', 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 = { 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>`; 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 { export function isExternalOrSpecialUrl(value: string): boolean {
const normalized = value.trim(); const normalized = value.trim();
if (!normalized) return false; if (!normalized) return false;
@@ -479,6 +579,7 @@ export function renderMacro(
postId: string, postId: string,
mediaItems: MediaData[], mediaItems: MediaData[],
linkedMediaIds: Set<string> | null, linkedMediaIds: Set<string> | null,
tagUsage: TagUsageEntry[],
): string { ): string {
const normalizedName = normalizeMacroName(name); const normalizedName = normalizeMacroName(name);
@@ -504,6 +605,10 @@ export function renderMacro(
return renderPhotoArchiveMacro(params, mediaItems); return renderPhotoArchiveMacro(params, mediaItems);
} }
if (normalizedName === 'tag_cloud') {
return renderTagCloudMacro(params, tagUsage);
}
return ''; return '';
} }
@@ -581,11 +686,13 @@ export function recordToMap(record: unknown): Map<string, string> {
export class PageRenderer { export class PageRenderer {
private readonly mediaEngine: MediaEngineContract; private readonly mediaEngine: MediaEngineContract;
private readonly postMediaEngine: PostMediaEngineContract; private readonly postMediaEngine: PostMediaEngineContract;
private readonly postEngineForMacros?: PostEngineContract;
private readonly liquid: Liquid; private readonly liquid: Liquid;
constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract) { constructor(mediaEngine: MediaEngineContract, postMediaEngine: PostMediaEngineContract, postEngineForMacros?: PostEngineContract) {
this.mediaEngine = mediaEngine; this.mediaEngine = mediaEngine;
this.postMediaEngine = postMediaEngine; this.postMediaEngine = postMediaEngine;
this.postEngineForMacros = postEngineForMacros;
const templateRoots = [ const templateRoots = [
path.resolve(__dirname, 'templates'), path.resolve(__dirname, 'templates'),
@@ -608,10 +715,15 @@ export class PageRenderer {
}; };
const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content); const needsMediaLookup = /\[\[(gallery|photo_archive|photo_album)\b/i.test(content);
const needsTagCloudLookup = /\[\[(tag_cloud)\b/i.test(content);
const mediaItems = needsMediaLookup const mediaItems = needsMediaLookup
? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[]) ? await this.mediaEngine.getAllMedia().catch(() => [] as MediaData[])
: []; : [];
const tagUsage = needsTagCloudLookup
? await this.getTagUsageData()
: [];
const linkedMediaIds = needsMediaLookup && postId const linkedMediaIds = needsMediaLookup && postId
? await this.postMediaEngine.getLinkedMediaDataForPost(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))) .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 withMacros = content.replace(/\[\[(\w+)(?:\s+([^\]]+))?\]\]/g, (_match, macroName: string, rawParams: string | undefined) => {
const params = parseMacroParams(rawParams); 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 }); 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( buildListTemplateContext(
posts: PostData[], posts: PostData[],
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,

View File

@@ -78,7 +78,7 @@ export class PreviewServer {
projectDescription: activeProject?.description ?? undefined, 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> { async start(preferredPort = 0): Promise<number> {

View File

@@ -6,5 +6,120 @@
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" /> <link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" /> <link rel="stylesheet" href="/assets/lightbox.min.css" />
{% render 'partials/styles' %} {% render 'partials/styles' %}
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/lightbox.min.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> </head>

View File

@@ -4,7 +4,7 @@
main { display: grid; gap: 1rem; } 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 { 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; } .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; } .gallery-container { display: grid; gap: .5rem; }
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; } .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)); } .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-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-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)); } .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-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 { 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; } .archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }

View File

@@ -177,6 +177,7 @@ describe('PreviewServer', () => {
expect(rootHtml).toContain('href="/assets/pico.min.css"'); expect(rootHtml).toContain('href="/assets/pico.min.css"');
expect(rootHtml).toContain('href="/assets/lightbox.min.css"'); expect(rootHtml).toContain('href="/assets/lightbox.min.css"');
expect(rootHtml).toContain('src="/assets/lightbox.min.js"'); expect(rootHtml).toContain('src="/assets/lightbox.min.js"');
expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"');
expect(rootHtml).not.toContain('cdn.jsdelivr.net'); expect(rootHtml).not.toContain('cdn.jsdelivr.net');
const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`); const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`);
@@ -191,6 +192,10 @@ describe('PreviewServer', () => {
expect(lightboxJsResponse.status).toBe(200); expect(lightboxJsResponse.status).toBe(200);
expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript'); expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript');
const d3CloudJsResponse = await fetch(`${server.getBaseUrl()}/assets/d3.layout.cloud.js`);
expect(d3CloudJsResponse.status).toBe(200);
expect(d3CloudJsResponse.headers.get('content-type')).toContain('application/javascript');
const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`); const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`);
expect(lightboxPrevImageResponse.status).toBe(200); expect(lightboxPrevImageResponse.status).toBe(200);
expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png'); expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');
@@ -200,6 +205,90 @@ describe('PreviewServer', () => {
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif'); expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
}); });
it('renders tag_cloud macro with normalized tag usage and tag archive links', async () => {
const posts = [
makePost({
id: 'tags-1',
slug: 'tag-cloud-source',
title: 'Tag Cloud Source',
tags: ['TypeScript', 'Electron'],
content: '[[tag_cloud]]',
}),
makePost({
id: 'tags-2',
slug: 'second',
title: 'Second',
tags: ['TypeScript'],
}),
makePost({
id: 'tags-3',
slug: 'third',
title: 'Third',
tags: ['Electron', 'SQLite', 'TypeScript'],
}),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/posts/tag-cloud-source`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('class="macro-tag-cloud"');
expect(html).toContain('data-tag-cloud="true"');
expect(html).toContain('data-orientation="horizontal"');
expect(html).toContain('TypeScript');
expect(html).toContain('/tag/TypeScript/');
expect(html).toContain('/tag/Electron/');
expect(html).toContain('/tag/SQLite/');
expect(html).toContain('&quot;color&quot;:&quot;rgb(');
expect(html).toContain('&quot;color&quot;:&quot;red&quot;');
expect(html).toContain('&quot;color&quot;:&quot;blue&quot;');
});
it('supports tag_cloud orientation parameter modes', async () => {
const posts = [
makePost({
id: 'orientation-1',
slug: 'orientation-hv',
title: 'Orientation HV',
tags: ['alpha', 'beta'],
content: '[[tag_cloud orientation="mixed_hv"]]',
}),
makePost({
id: 'orientation-2',
slug: 'orientation-diagonal',
title: 'Orientation Diagonal',
tags: ['alpha'],
content: '[[tag_cloud orientation="mixed_diagonal"]]',
}),
];
server = new PreviewServer({
postEngine: makeEngine(posts),
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const hvResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-hv`);
expect(hvResponse.status).toBe(200);
const hvHtml = await hvResponse.text();
expect(hvHtml).toContain('data-orientation="mixed-hv"');
const diagonalResponse = await fetch(`${server.getBaseUrl()}/posts/orientation-diagonal`);
expect(diagonalResponse.status).toBe(200);
const diagonalHtml = await diagonalResponse.text();
expect(diagonalHtml).toContain('data-orientation="mixed-diagonal"');
});
it('uses selected pico theme stylesheet from project metadata', async () => { it('uses selected pico theme stylesheet from project metadata', async () => {
server = new PreviewServer({ server = new PreviewServer({
postEngine: makeEngine([makePost()]), postEngine: makeEngine([makePost()]),

View File

@@ -16,6 +16,18 @@ describe('documentation structure and presentation', () => {
expect(markdown).toContain('## Who this guide is for'); expect(markdown).toContain('## Who this guide is for');
}); });
it('documents all supported macros in the user guide', () => {
const docPath = path.resolve(process.cwd(), 'DOCUMENTATION.md');
const markdown = readFileSync(docPath, 'utf8');
expect(markdown).toContain('## Using macros');
expect(markdown).toContain('[[youtube');
expect(markdown).toContain('[[vimeo');
expect(markdown).toContain('[[gallery');
expect(markdown).toContain('[[photo_archive');
expect(markdown).toContain('[[tag_cloud');
});
it('scopes Pico conditional styling to the documentation view', () => { it('scopes Pico conditional styling to the documentation view', () => {
const viewPath = path.resolve( const viewPath = path.resolve(
process.cwd(), process.cwd(),