diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d65a384..c226a6d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -9,6 +9,7 @@ - [Working with posts](#working-with-posts) - [Working with pages](#working-with-pages) - [Working with media](#working-with-media) +- [Using macros](#using-macros) - [Organizing with tags](#organizing-with-tags) - [Importing from WordPress (WXR)](#importing-from-wordpress-wxr) - [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 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. diff --git a/package-lock.json b/package-lock.json index ff7384b..18e4557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@picocss/pico": "^2.1.1", "@xmldom/xmldom": "^0.8.11", "chokidar": "^5.0.0", + "d3-cloud": "^1.2.8", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", @@ -171,6 +172,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -744,6 +746,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -820,6 +823,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -841,6 +845,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -936,6 +941,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -976,6 +982,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1370,7 +1377,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1392,7 +1398,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1409,7 +1414,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1424,7 +1428,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -3773,6 +3776,7 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz", "integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==", "license": "MIT", + "peer": true, "dependencies": { "@libsql/core": "^0.17.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", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5184,6 +5187,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5194,6 +5198,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5411,6 +5416,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -5607,6 +5613,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6129,6 +6136,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6814,8 +6822,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -6952,7 +6959,23 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "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": { "version": "4.0.1", @@ -7208,6 +7231,7 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7289,8 +7313,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { "version": "3.3.1", @@ -7763,7 +7786,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7784,7 +7806,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7918,6 +7939,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8830,7 +8852,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9137,6 +9159,7 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9428,7 +9451,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10669,7 +10691,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -10682,6 +10703,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -11179,6 +11201,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11254,7 +11277,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11272,7 +11294,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11283,7 +11304,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11299,7 +11319,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11454,6 +11473,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -11487,6 +11507,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^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", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -11597,6 +11619,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11606,6 +11629,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11635,8 +11659,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11898,7 +11921,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12011,7 +12033,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -12616,7 +12638,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12883,7 +12904,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", @@ -13420,6 +13442,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13700,6 +13723,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14259,6 +14283,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -14336,6 +14361,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", diff --git a/package.json b/package.json index ad0798d..8d8e495 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@picocss/pico": "^2.1.1", "@xmldom/xmldom": "^0.8.11", "chokidar": "^5.0.0", + "d3-cloud": "^1.2.8", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.1", "dropbox": "^10.34.0", diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 261bc11..11f4b14 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -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; diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index a04edba..560df6a 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -107,6 +107,64 @@ export interface PostMediaEngineContract { export interface PostEngineContract { getPost: (id: string) => Promise; + getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise; +} + +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 = { @@ -131,6 +189,10 @@ export const PREVIEW_ASSETS: Record, mediaIte return `
${monthsHtml}
`; } +export function renderTagCloudMacro(params: Record, 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 `
No tags found.
`; + } + + 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 `
`; +} + 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 | 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 { 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(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 { + if (!this.postEngineForMacros?.getPostsFiltered) { + return []; + } + + const posts = await this.postEngineForMacros.getPostsFiltered({ status: 'published' }).catch(() => [] as PostData[]); + const tagCounts = new Map(); + + for (const post of posts) { + const postTags = Array.isArray(post.tags) ? post.tags : []; + const uniqueTags = new Set( + 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, diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index d3c7b1a..250c19b 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -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 { diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index d3b227b..1fe5899 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -6,5 +6,120 @@ {% render 'partials/styles' %} + + diff --git a/src/main/engine/templates/partials/styles.liquid b/src/main/engine/templates/partials/styles.liquid index 3a128d8..d8aec90 100644 --- a/src/main/engine/templates/partials/styles.liquid +++ b/src/main/engine/templates/partials/styles.liquid @@ -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; } diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 5a89ba6..0e5ba75 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -177,6 +177,7 @@ describe('PreviewServer', () => { expect(rootHtml).toContain('href="/assets/pico.min.css"'); 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).not.toContain('cdn.jsdelivr.net'); const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`); @@ -191,6 +192,10 @@ describe('PreviewServer', () => { expect(lightboxJsResponse.status).toBe(200); 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`); expect(lightboxPrevImageResponse.status).toBe(200); expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png'); @@ -200,6 +205,90 @@ describe('PreviewServer', () => { 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('"color":"rgb('); + expect(html).toContain('"color":"red"'); + expect(html).toContain('"color":"blue"'); + }); + + 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 () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), diff --git a/tests/renderer/documentationStructure.test.ts b/tests/renderer/documentationStructure.test.ts index 4d58ecb..b2686a1 100644 --- a/tests/renderer/documentationStructure.test.ts +++ b/tests/renderer/documentationStructure.test.ts @@ -16,6 +16,18 @@ describe('documentation structure and presentation', () => { 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', () => { const viewPath = path.resolve( process.cwd(),