From b67ffbd38adba5aa2f214ce5b60e870e04244f3c Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 13:24:41 +0100 Subject: [PATCH] feat: source highlighting for code blocks --- package-lock.json | 81 ++++++----- package.json | 1 + src/main/engine/BlogGenerationEngine.ts | 10 +- .../engine/BlogGenerationOutputService.ts | 82 ++++++++++- src/main/engine/PageRenderer.ts | 39 ++++- .../engine/assets/codeEnhancementsRuntime.ts | 137 ++++++++++++++++++ .../engine/templates/partials/head.liquid | 3 + .../engine/templates/partials/styles.liquid | 24 +++ .../BlogGenerationOutputService.test.ts | 70 +++++++++ tests/engine/PreviewServer.test.ts | 32 ++++ 10 files changed, 432 insertions(+), 47 deletions(-) create mode 100644 src/main/engine/assets/codeEnhancementsRuntime.ts diff --git a/package-lock.json b/package-lock.json index bb49ba8..bbac03b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.5", + "@highlightjs/cdn-assets": "^11.11.1", "@libsql/client": "^0.17.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", @@ -178,6 +179,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", @@ -750,6 +752,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", @@ -826,6 +829,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" } @@ -847,6 +851,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", @@ -942,6 +947,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -982,6 +988,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1376,7 +1383,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1398,7 +1404,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1415,7 +1420,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1430,7 +1434,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2669,6 +2672,15 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@highlightjs/cdn-assets": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/@highlightjs/cdn-assets/-/cdn-assets-11.11.1.tgz", + "integrity": "sha512-VEPdHzwelZ12hEX18BHduqxMZGolcUsrbeokHYxOUIm8X2+M7nx5QPtPeQgRxR9XjhdLv4/7DD5BWOlSrJ3k7Q==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3978,6 +3990,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", @@ -5184,8 +5197,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", @@ -5414,6 +5426,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5424,6 +5437,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5531,6 +5545,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5910,6 +5925,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -6096,6 +6112,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6129,6 +6146,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", @@ -6651,6 +6669,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7346,8 +7365,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", @@ -7484,7 +7502,8 @@ "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", @@ -7762,6 +7781,7 @@ "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.7.0", "builder-util": "26.4.1", @@ -7863,8 +7883,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", @@ -8337,7 +8356,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8358,7 +8376,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8385,16 +8402,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -8502,6 +8509,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8579,6 +8587,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10086,6 +10095,7 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10421,7 +10431,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11691,7 +11700,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11704,6 +11712,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" @@ -12265,6 +12274,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12340,7 +12350,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -12358,7 +12367,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12379,7 +12387,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12395,7 +12402,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12550,6 +12556,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" } @@ -12583,6 +12590,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", @@ -12616,6 +12624,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", @@ -12693,6 +12702,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" } @@ -12758,6 +12768,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" }, @@ -12787,8 +12798,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", @@ -13093,7 +13103,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13836,7 +13845,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14116,7 +14124,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", @@ -14666,6 +14675,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14955,6 +14965,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15514,6 +15525,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -15591,6 +15603,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 0cb6c88..df46bc3 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.5", + "@highlightjs/cdn-assets": "^11.11.1", "@libsql/client": "^0.17.0", "@milkdown/kit": "^7.18.0", "@milkdown/plugin-block": "^7.18.0", diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index 6827834..94bd616 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -307,6 +307,8 @@ export class BlogGenerationEngine { let sitemapWritten = false; let rssWritten = false; let atomWritten = false; + const generatedHashCache = new Map(); + const knownOutputDirectories = new Set(); if (includeCore) { onProgress(10, 'Writing sitemap and feeds...'); @@ -333,7 +335,10 @@ export class BlogGenerationEngine { reportUnitProgress('Atom feed written'); onProgress(15, 'Copying assets...'); - await copyPreviewAssets(htmlDir); + await copyPreviewAssets(htmlDir, { + projectId: options.projectId, + hashCache: generatedHashCache, + }); reportUnitProgress('Assets copied'); } @@ -348,9 +353,6 @@ export class BlogGenerationEngine { }, }); - const knownOutputDirectories = new Set(); - const generatedHashCache = new Map(); - const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({ projectId, htmlDir, diff --git a/src/main/engine/BlogGenerationOutputService.ts b/src/main/engine/BlogGenerationOutputService.ts index 173df73..344cf2f 100644 --- a/src/main/engine/BlogGenerationOutputService.ts +++ b/src/main/engine/BlogGenerationOutputService.ts @@ -4,6 +4,25 @@ import * as path from 'node:path'; import { getGeneratedFileHash, setGeneratedFileHash } from '../database/generatedFileHashStore'; import { PREVIEW_ASSETS, PREVIEW_IMAGE_ASSETS } from './PageRenderer'; +type PreviewAssetMap = Record; +type PreviewImageAssetMap = Record; + +function toBuffer(value: unknown): Buffer { + if (Buffer.isBuffer(value)) { + return value; + } + + if (typeof value === 'string') { + return Buffer.from(value, 'utf-8'); + } + + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + + return Buffer.alloc(0); +} + export function normalizeGeneratedUrlPath(urlPath: string): string { const trimmed = (urlPath || '').trim(); if (!trimmed || trimmed === '/') { @@ -28,6 +47,10 @@ export function computeContentHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } +export function computeBufferHash(content: Buffer): string { + return crypto.createHash('sha256').update(content).digest('hex'); +} + export async function writeFileIfHashChanged(params: { projectId: string; filePath: string; @@ -104,24 +127,67 @@ export async function writeHtmlPage(params: { }); } -export async function copyPreviewAssets(htmlDir: string): Promise { +export async function copyPreviewAssets(htmlDir: string, options?: { + projectId?: string; + hashCache?: Map; + previewAssets?: PreviewAssetMap; + previewImageAssets?: PreviewImageAssetMap; + readModuleFile?: (modulePath: string) => Promise; + getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise; + setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise; +}): Promise { const assetsDir = path.join(htmlDir, 'assets'); const imagesDir = path.join(htmlDir, 'images'); await fs.mkdir(assetsDir, { recursive: true }); await fs.mkdir(imagesDir, { recursive: true }); - for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) { + const projectId = options?.projectId; + const hashCache = options?.hashCache; + const readModuleFile = options?.readModuleFile ?? (async (modulePath: string) => toBuffer(await fs.readFile(require.resolve(modulePath)))); + const getHash = options?.getGeneratedFileHash ?? getGeneratedFileHash; + const setHash = options?.setGeneratedFileHash ?? setGeneratedFileHash; + const previewAssets = options?.previewAssets ?? PREVIEW_ASSETS; + const previewImageAssets = options?.previewImageAssets ?? PREVIEW_IMAGE_ASSETS; + + const writeBinaryIfHashChanged = async (filePath: string, relativePath: string, content: Buffer): Promise => { + if (!projectId) { + await fs.writeFile(filePath, content); + return; + } + + const hash = computeBufferHash(content); + let previousHash: string | null; + if (hashCache && hashCache.has(relativePath)) { + previousHash = hashCache.get(relativePath) ?? null; + } else { + previousHash = await getHash(projectId, relativePath); + hashCache?.set(relativePath, previousHash); + } + + if (previousHash === hash) { + return; + } + + await fs.writeFile(filePath, content); + await setHash(projectId, relativePath, hash); + hashCache?.set(relativePath, hash); + }; + + for (const [filename, definition] of Object.entries(previewAssets)) { const destPath = path.join(assetsDir, filename); + const relativePath = path.posix.join('assets', filename); const content = definition.sourceText !== undefined ? Buffer.from(definition.sourceText, 'utf-8') - : await fs.readFile(require.resolve(definition.modulePath as string)); - await fs.writeFile(destPath, content); + : toBuffer(await readModuleFile(definition.modulePath as string)); + + await writeBinaryIfHashChanged(destPath, relativePath, content); } - for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) { - const sourcePath = require.resolve(definition.modulePath); + for (const [filename, definition] of Object.entries(previewImageAssets)) { const destPath = path.join(imagesDir, filename); - const content = await fs.readFile(sourcePath); - await fs.writeFile(destPath, content); + const relativePath = path.posix.join('images', filename); + const content = toBuffer(await readModuleFile(definition.modulePath)); + + await writeBinaryIfHashChanged(destPath, relativePath, content); } } diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 17b61dc..f487248 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -5,6 +5,7 @@ import type { MediaData } from './MediaEngine'; import type { PostData } from './PostEngine'; import type { MenuDocument, MenuItemData } from './MenuEngine'; import { PICO_THEME_NAMES } from '../shared/picoThemes'; +import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime'; import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime'; import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n'; @@ -135,6 +136,29 @@ export interface PreviewAssetDefinition { sourceText?: string; } +function annotateCodeBlocksWithLanguage(html: string): string { + if (!html) { + return html; + } + + return html.replace(/]*)>/gi, (fullMatch, rawAttributes: string) => { + if (/\bdata-code-language\s*=/.test(rawAttributes)) { + return fullMatch; + } + + const classMatch = rawAttributes.match(/\bclass\s*=\s*"([^"]*)"/i); + const classList = classMatch?.[1] ?? ''; + const languageMatch = classList.match(/(?:^|\s)language-([\w.+-]+)(?:\s|$)/i); + const language = languageMatch?.[1]?.toLowerCase(); + + if (!language) { + return fullMatch; + } + + return ``; + }); +} + export interface TagUsageEntry { tag: string; count: number; @@ -178,6 +202,18 @@ export const PREVIEW_ASSETS: Record = { modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js', contentType: 'application/javascript; charset=utf-8', }, + 'highlight.min.css': { + modulePath: '@highlightjs/cdn-assets/styles/github-dark.min.css', + contentType: 'text/css; charset=utf-8', + }, + 'highlight.min.js': { + modulePath: '@highlightjs/cdn-assets/highlight.min.js', + contentType: 'application/javascript; charset=utf-8', + }, + 'code-enhancements.js': { + contentType: 'application/javascript; charset=utf-8', + sourceText: CODE_ENHANCEMENTS_RUNTIME_JS, + }, 'd3.layout.cloud.js': { modulePath: 'd3-cloud/build/d3.layout.cloud.js', contentType: 'application/javascript; charset=utf-8', @@ -829,7 +865,8 @@ export class PageRenderer { }); const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false }); - return rewriteRenderedHtmlUrls(markdownHtml, rewriteContext); + const annotatedMarkdownHtml = annotateCodeBlocksWithLanguage(markdownHtml); + return rewriteRenderedHtmlUrls(annotatedMarkdownHtml, rewriteContext); }); } diff --git a/src/main/engine/assets/codeEnhancementsRuntime.ts b/src/main/engine/assets/codeEnhancementsRuntime.ts new file mode 100644 index 0000000..dedefc5 --- /dev/null +++ b/src/main/engine/assets/codeEnhancementsRuntime.ts @@ -0,0 +1,137 @@ +export const CODE_ENHANCEMENTS_RUNTIME_JS = String.raw`(function () { + function resolveCodeLanguage(codeElement) { + if (!codeElement) { + return ''; + } + + var direct = codeElement.getAttribute('data-code-language'); + if (typeof direct === 'string' && direct.trim()) { + return direct.trim().toLowerCase(); + } + + var className = codeElement.className || ''; + var classMatch = className.match(/(?:^|\s)language-([\w.+-]+)/i); + if (classMatch && classMatch[1]) { + return classMatch[1].toLowerCase(); + } + + return ''; + } + + function fallbackCopy(value) { + var textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } + } + + async function copyCodeToClipboard(value) { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return fallbackCopy(value); + } + } + + return fallbackCopy(value); + } + + function ensureCopyButton(preElement, codeElement) { + if (!preElement || preElement.querySelector(':scope > .code-copy-button')) { + return; + } + + preElement.classList.add('code-block-enhanced'); + + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'code-copy-button'; + button.setAttribute('aria-hidden', 'true'); + + var icon = document.createElement('span'); + icon.className = 'code-copy-icon'; + icon.textContent = '⧉'; + button.appendChild(icon); + + button.addEventListener('click', async function () { + var codeText = codeElement.textContent || ''; + var copied = await copyCodeToClipboard(codeText); + preElement.classList.remove('code-copy-failed'); + preElement.classList.remove('code-copy-success'); + preElement.classList.add(copied ? 'code-copy-success' : 'code-copy-failed'); + + if (copied) { + icon.textContent = '✓'; + window.setTimeout(function () { + preElement.classList.remove('code-copy-success'); + icon.textContent = '⧉'; + }, 1200); + return; + } + + window.setTimeout(function () { + preElement.classList.remove('code-copy-failed'); + }, 1200); + }); + + preElement.appendChild(button); + } + + function highlightCodeBlock(codeElement) { + var highlighter = window.hljs; + if (!highlighter || typeof highlighter.highlightElement !== 'function') { + return; + } + + if (codeElement.getAttribute('data-code-highlighted') === 'true') { + return; + } + + try { + highlighter.highlightElement(codeElement); + codeElement.setAttribute('data-code-highlighted', 'true'); + } catch { + } + } + + function initCodeBlocks() { + var codeNodes = document.querySelectorAll('pre > code'); + codeNodes.forEach(function (codeElement) { + var preElement = codeElement.parentElement; + if (!preElement || preElement.tagName !== 'PRE') { + return; + } + + var language = resolveCodeLanguage(codeElement); + if (language) { + codeElement.setAttribute('data-code-language', language); + preElement.setAttribute('data-code-language', language); + } + + ensureCopyButton(preElement, codeElement); + highlightCodeBlock(codeElement); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCodeBlocks, { once: true }); + } else { + initCodeBlocks(); + } +})(); +`; diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index 3d59fdb..73bde80 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -5,9 +5,12 @@ {% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %} + {% render 'partials/styles' %} + + diff --git a/src/main/engine/templates/partials/styles.liquid b/src/main/engine/templates/partials/styles.liquid index 0501b90..ba28dbf 100644 --- a/src/main/engine/templates/partials/styles.liquid +++ b/src/main/engine/templates/partials/styles.liquid @@ -18,6 +18,30 @@ .blog-menu-item-with-children:hover > .blog-menu-submenu, .blog-menu-item-with-children:focus-within > .blog-menu-submenu { display: block; } .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 pre { position: relative; overflow-x: auto; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .3rem; margin: .9rem 0; padding: .85rem .9rem; background: var(--pico-code-background-color, rgba(33, 38, 45, .82)); } + .post pre code { display: block; font-size: .88rem; line-height: 1.5; } + .code-copy-button { + position: absolute; + top: .4rem; + right: .4rem; + border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); + background: var(--pico-card-background-color, var(--card-background-color)); + color: var(--pico-muted-color, var(--muted-color)); + border-radius: .25rem; + width: 1.8rem; + height: 1.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + opacity: .88; + } + .code-copy-button:hover, + .code-copy-button:focus-visible { opacity: 1; color: var(--pico-color, var(--color)); } + .code-copy-icon { font-size: .95rem; line-height: 1; } + .code-copy-success .code-copy-button { color: var(--pico-ins-color, rgb(53, 117, 56)); border-color: var(--pico-ins-color, rgb(53, 117, 56)); } + .code-copy-failed .code-copy-button { color: var(--pico-del-color, rgb(183, 72, 72)); border-color: var(--pico-del-color, rgb(183, 72, 72)); } .post iframe { width: 100%; min-height: 20rem; } .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; } diff --git a/tests/engine/BlogGenerationOutputService.test.ts b/tests/engine/BlogGenerationOutputService.test.ts index 29b407c..81b5588 100644 --- a/tests/engine/BlogGenerationOutputService.test.ts +++ b/tests/engine/BlogGenerationOutputService.test.ts @@ -2,6 +2,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { + copyPreviewAssets, normalizeGeneratedUrlPath, urlPathToHtmlIndexPath, writeFileIfHashChanged, @@ -145,4 +146,73 @@ describe('BlogGenerationOutputService', () => { expect(ensureDirectory).toHaveBeenCalledTimes(1); }); + + it('copies preview assets with hash checks so unchanged files are not rewritten', async () => { + const tempRoot = path.join('/tmp', makeTempName()); + const htmlDir = path.join(tempRoot, 'html'); + await mkdir(htmlDir, { recursive: true }); + + const hashStore = new Map(); + const getHash = vi.fn(async (_projectId: string, relativePath: string) => hashStore.get(relativePath) ?? null); + const setHash = vi.fn(async (_projectId: string, relativePath: string, hash: string) => { + hashStore.set(relativePath, hash); + }); + + const hashCache = new Map(); + + await copyPreviewAssets(htmlDir, { + projectId: 'project-a', + hashCache, + previewAssets: { + 'runtime.js': { + contentType: 'application/javascript; charset=utf-8', + sourceText: 'console.log("runtime");', + }, + }, + previewImageAssets: { + 'pixel.png': { + modulePath: 'virtual:pixel.png', + contentType: 'image/png', + }, + }, + readModuleFile: async (modulePath: string) => { + if (modulePath === 'virtual:pixel.png') { + return Buffer.from([0, 1, 2, 3]); + } + throw new Error(`Unexpected module path: ${modulePath}`); + }, + getGeneratedFileHash: getHash, + setGeneratedFileHash: setHash, + }); + + await copyPreviewAssets(htmlDir, { + projectId: 'project-a', + hashCache, + previewAssets: { + 'runtime.js': { + contentType: 'application/javascript; charset=utf-8', + sourceText: 'console.log("runtime");', + }, + }, + previewImageAssets: { + 'pixel.png': { + modulePath: 'virtual:pixel.png', + contentType: 'image/png', + }, + }, + readModuleFile: async (modulePath: string) => { + if (modulePath === 'virtual:pixel.png') { + return Buffer.from([0, 1, 2, 3]); + } + throw new Error(`Unexpected module path: ${modulePath}`); + }, + getGeneratedFileHash: getHash, + setGeneratedFileHash: setHash, + }); + + expect(setHash).toHaveBeenCalledTimes(2); + expect(await readFile(path.join(htmlDir, 'assets', 'runtime.js'), 'utf-8')).toBe('console.log("runtime");'); + const imageContent = await readFile(path.join(htmlDir, 'images', 'pixel.png')); + expect(Array.from(imageContent)).toEqual([0, 1, 2, 3]); + }); }); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 07a6a8a..614aa56 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -304,7 +304,10 @@ describe('PreviewServer', () => { expect(rootHtml).toContain('href="/assets/pico.min.css"'); expect(rootHtml).toContain('href="/assets/lightbox.min.css"'); + expect(rootHtml).toContain('href="/assets/highlight.min.css"'); expect(rootHtml).toContain('src="/assets/lightbox.min.js"'); + expect(rootHtml).toContain('src="/assets/highlight.min.js"'); + expect(rootHtml).toContain('src="/assets/code-enhancements.js"'); expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"'); expect(rootHtml).toContain('src="/assets/tag-cloud.js"'); expect(rootHtml).not.toContain('function parseWords('); @@ -322,6 +325,18 @@ describe('PreviewServer', () => { expect(lightboxJsResponse.status).toBe(200); expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript'); + const highlightCssResponse = await fetch(`${server.getBaseUrl()}/assets/highlight.min.css`); + expect(highlightCssResponse.status).toBe(200); + expect(highlightCssResponse.headers.get('content-type')).toContain('text/css'); + + const highlightJsResponse = await fetch(`${server.getBaseUrl()}/assets/highlight.min.js`); + expect(highlightJsResponse.status).toBe(200); + expect(highlightJsResponse.headers.get('content-type')).toContain('application/javascript'); + + const codeEnhancementsResponse = await fetch(`${server.getBaseUrl()}/assets/code-enhancements.js`); + expect(codeEnhancementsResponse.status).toBe(200); + expect(codeEnhancementsResponse.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'); @@ -339,6 +354,23 @@ describe('PreviewServer', () => { expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif'); }); + it('keeps markdown code block html minimal and includes code language metadata', async () => { + const postWithCode = makePost({ + content: '```python\nprint("hello")\n```', + }); + + server = new PreviewServer({ + postEngine: makeEngine([postWithCode]), + settingsEngine: makeSettings(50), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const html = await (await fetch(`${server.getBaseUrl()}/`)).text(); + expect(html).toContain(''); + }); + it('does not set project context or run startup sync for static asset requests', async () => { const postEngine = makeEngine([makePost()]); const mediaEngine = {