feat: source highlighting for code blocks

This commit is contained in:
2026-02-22 13:24:41 +01:00
parent ac75bcb1ac
commit b67ffbd38a
10 changed files with 432 additions and 47 deletions

81
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -307,6 +307,8 @@ export class BlogGenerationEngine {
let sitemapWritten = false;
let rssWritten = false;
let atomWritten = false;
const generatedHashCache = new Map<string, string | null>();
const knownOutputDirectories = new Set<string>();
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<string>();
const generatedHashCache = new Map<string, string | null>();
const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,

View File

@@ -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<string, { contentType: string; modulePath?: string; sourceText?: string }>;
type PreviewImageAssetMap = Record<string, { modulePath: string; contentType: string }>;
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<void> {
export async function copyPreviewAssets(htmlDir: string, options?: {
projectId?: string;
hashCache?: Map<string, string | null>;
previewAssets?: PreviewAssetMap;
previewImageAssets?: PreviewImageAssetMap;
readModuleFile?: (modulePath: string) => Promise<Buffer>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
}): Promise<void> {
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 destPath = path.join(assetsDir, 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);
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<void> => {
if (!projectId) {
await fs.writeFile(filePath, content);
return;
}
for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) {
const sourcePath = require.resolve(definition.modulePath);
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')
: toBuffer(await readModuleFile(definition.modulePath as string));
await writeBinaryIfHashChanged(destPath, relativePath, content);
}
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);
}
}

View File

@@ -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(/<code\b([^>]*)>/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 `<code${rawAttributes} data-code-language="${escapeHtml(language)}">`;
});
}
export interface TagUsageEntry {
tag: string;
count: number;
@@ -178,6 +202,18 @@ export const PREVIEW_ASSETS: Record<string, PreviewAssetDefinition> = {
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);
});
}

View File

@@ -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();
}
})();
`;

View File

@@ -5,9 +5,12 @@
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
{% render 'partials/styles' %}
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/tag-cloud.js"></script>
<script defer src="/assets/lightbox.min.js"></script>

View File

@@ -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; }

View File

@@ -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<string, string>();
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<string, string | null>();
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]);
});
});

View File

@@ -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('<code class="language-python" data-code-language="python">');
});
it('does not set project context or run startup sync for static asset requests', async () => {
const postEngine = makeEngine([makePost()]);
const mediaEngine = {