chore: more refactorings and optimizations

This commit is contained in:
2026-02-22 09:54:42 +01:00
parent 011f318710
commit 03657e7a84
11 changed files with 485 additions and 78 deletions

72
package-lock.json generated
View File

@@ -178,7 +178,6 @@
"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",
@@ -751,7 +750,6 @@
"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",
@@ -828,7 +826,6 @@
"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"
}
@@ -850,7 +847,6 @@
"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",
@@ -946,7 +942,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -987,7 +982,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1382,6 +1376,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1403,6 +1398,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1419,6 +1415,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1433,6 +1430,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3980,7 +3978,6 @@
"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",
@@ -5187,7 +5184,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -5416,7 +5414,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -5427,7 +5424,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5535,7 +5531,6 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -5915,7 +5910,6 @@
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.18",
"fflate": "^0.8.2",
@@ -6102,7 +6096,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6136,7 +6129,6 @@
"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",
@@ -6659,7 +6651,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7355,7 +7346,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -7492,8 +7484,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3-cloud": {
"version": "1.2.8",
@@ -7771,7 +7762,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -7873,7 +7863,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.3.1",
@@ -8346,6 +8337,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8366,6 +8358,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -8392,6 +8385,16 @@
"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",
@@ -8499,7 +8502,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -8577,7 +8579,6 @@
"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",
@@ -10085,7 +10086,6 @@
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -10421,6 +10421,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -11690,6 +11691,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -11702,7 +11704,6 @@
"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"
@@ -12264,7 +12265,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12340,6 +12340,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -12357,6 +12358,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -12377,6 +12379,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -12392,6 +12395,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -12546,7 +12550,6 @@
"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"
}
@@ -12580,7 +12583,6 @@
"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",
@@ -12614,7 +12616,6 @@
"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",
@@ -12692,7 +12693,6 @@
"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,7 +12758,6 @@
"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"
},
@@ -12788,7 +12787,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -13093,6 +13093,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -13835,6 +13836,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -14114,8 +14116,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
@@ -14665,7 +14666,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14873,7 +14873,6 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -14956,7 +14955,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -15516,7 +15514,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -15594,7 +15591,6 @@
"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

@@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '..
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
import { buildSitemapAndFeeds } from './GenerationSitemapFeedService';
import { buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService';
import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService';
import { compareSitemapToHtml } from './SiteValidationDiffService';
import {
@@ -218,30 +218,57 @@ export class BlogGenerationEngine {
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
onProgress(5, 'Building sitemap XML...');
const {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
urls,
sitemapXml,
rssXml,
atomXml,
feedPosts,
} = buildSitemapAndFeeds({
baseUrl: options.baseUrl,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
});
let allTags = new Set<string>();
let allCategories = new Set<string>();
let yearMonths = new Map<string, Date>();
let years = new Map<number, Date>();
let yearMonthDays = new Map<string, Date>();
let urls: string[] = [];
let sitemapXml = '';
let rssXml = '';
let atomXml = '';
let feedPosts: PostData[] = [];
onProgress(8, 'Building RSS and Atom feeds...');
if (includeCore) {
onProgress(5, 'Building sitemap XML...');
const sitemapAndFeedResult = buildSitemapAndFeeds({
baseUrl: options.baseUrl,
projectName: options.projectName,
projectDescription: options.projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex: generationPostIndex,
includeFeeds: true,
});
allTags = sitemapAndFeedResult.allTags;
allCategories = sitemapAndFeedResult.allCategories;
yearMonths = sitemapAndFeedResult.yearMonths;
years = sitemapAndFeedResult.years;
yearMonthDays = sitemapAndFeedResult.yearMonthDays;
urls = sitemapAndFeedResult.urls;
sitemapXml = sitemapAndFeedResult.sitemapXml;
rssXml = sitemapAndFeedResult.rssXml;
atomXml = sitemapAndFeedResult.atomXml;
feedPosts = sitemapAndFeedResult.feedPosts;
onProgress(8, 'Building RSS and Atom feeds...');
} else if (includeCategory || includeTag || includeDate) {
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl: options.baseUrl,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
});
allTags = archiveMetadata.allTags;
allCategories = archiveMetadata.allCategories;
yearMonths = archiveMetadata.yearMonths;
years = archiveMetadata.years;
yearMonthDays = archiveMetadata.yearMonthDays;
feedPosts = archiveMetadata.feedPosts;
}
const htmlDir = path.join(options.dataDir, 'html');
await fs.mkdir(htmlDir, { recursive: true });
@@ -321,11 +348,16 @@ 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,
urlPath,
content,
knownDirectories: knownOutputDirectories,
hashCache: generatedHashCache,
});
let pagesGenerated = 0;

View File

@@ -33,6 +33,7 @@ export async function writeFileIfHashChanged(params: {
filePath: string;
relativePath: string;
content: string;
hashCache?: Map<string, string | null>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
@@ -42,13 +43,21 @@ export async function writeFileIfHashChanged(params: {
const hashFn = params.computeHash ?? computeContentHash;
const hash = hashFn(params.content);
const previousHash = await getHash(params.projectId, params.relativePath);
let previousHash: string | null;
if (params.hashCache && params.hashCache.has(params.relativePath)) {
previousHash = params.hashCache.get(params.relativePath) ?? null;
} else {
previousHash = await getHash(params.projectId, params.relativePath);
params.hashCache?.set(params.relativePath, previousHash);
}
if (previousHash === hash) {
return false;
}
await fs.writeFile(params.filePath, params.content, 'utf-8');
await setHash(params.projectId, params.relativePath, hash);
params.hashCache?.set(params.relativePath, hash);
return true;
}
@@ -57,6 +66,9 @@ export async function writeHtmlPage(params: {
htmlDir: string;
urlPath: string;
content: string;
knownDirectories?: Set<string>;
hashCache?: Map<string, string | null>;
ensureDirectory?: (dirPath: string) => Promise<void>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
computeHash?: (content: string) => string;
@@ -66,14 +78,26 @@ export async function writeHtmlPage(params: {
? path.join(params.htmlDir, normalizedPath, 'index.html')
: path.join(params.htmlDir, 'index.html');
const relativePath = normalizedPath ? `${normalizedPath}/index.html` : 'index.html';
const directoryPath = path.dirname(filePath);
const ensureDirectory = params.ensureDirectory ?? (async (dirPath: string) => {
await fs.mkdir(dirPath, { recursive: true });
});
await fs.mkdir(path.dirname(filePath), { recursive: true });
if (params.knownDirectories) {
if (!params.knownDirectories.has(directoryPath)) {
await ensureDirectory(directoryPath);
params.knownDirectories.add(directoryPath);
}
} else {
await ensureDirectory(directoryPath);
}
return writeFileIfHashChanged({
projectId: params.projectId,
filePath,
relativePath,
content: params.content,
hashCache: params.hashCache,
getGeneratedFileHash: params.getGeneratedFileHash,
setGeneratedFileHash: params.setGeneratedFileHash,
computeHash: params.computeHash,

View File

@@ -1,4 +1,5 @@
import type { CategoryRenderSettings } from './PageRenderer';
import { buildCanonicalPostPath } from './PageRenderer';
import type { MenuDocument } from './MenuEngine';
import type { ProjectMetadata } from './MetaEngine';
import type { PostData } from './PostEngine';
@@ -15,6 +16,7 @@ interface RenderContext {
};
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
}
@@ -89,6 +91,10 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
projectDescription: params.options.projectDescription,
};
params.engines.postEngine.setProjectContext(projectContext.projectId, projectContext.dataDir);
params.engines.mediaEngine.setProjectContext?.(projectContext.projectId, projectContext.dataDir, projectContext.dataDir);
params.engines.postMediaEngine.setProjectContext(projectContext.projectId);
const mediaItemsPromiseCache = new Map<string, Promise<unknown[]>>();
const postsByFilterPromiseCache = new Map<string, Promise<PostData[]>>();
const publishedSnapshotByIdPromiseCache = new Map<string, Promise<PostData | null>>();
@@ -201,12 +207,42 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
getActiveProjectContext: async () => projectContext,
});
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
const canonicalPostPathBySlug = new Map<string, string>();
for (const post of params.publishedPostsForLookup) {
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
}
const canonicalMediaPathBySourcePath = new Map<string, string>();
const mediaItems = await cachedMediaEngine.getAllMedia();
for (const media of mediaItems as Array<{ createdAt: Date; filename: string; originalName: string }>) {
const year = media.createdAt.getFullYear();
const month = String(media.createdAt.getMonth() + 1).padStart(2, '0');
const canonicalPath = `/media/${year}/${month}/${media.filename}`;
const originalNameKey = `media/${year}/${month}/${media.originalName}`.toLowerCase();
const filenameKey = `media/${year}/${month}/${media.filename}`.toLowerCase();
canonicalMediaPathBySourcePath.set(originalNameKey, canonicalPath);
canonicalMediaPathBySourcePath.set(filenameKey, canonicalPath);
}
return {
canonicalPostPathBySlug,
canonicalMediaPathBySourcePath,
};
})();
return createGenerationRouteRenderer({
renderWithContext: (pathname, context) => previewServer.renderRouteForContext(pathname, context),
renderWithContext: async (pathname, context) => previewServer.renderRouteForContext(pathname, {
...context,
htmlRewriteContext: await htmlRewriteContextPromise,
}),
context: {
projectContext,
metadata,
menu,
skipContextSetup: true,
maxPostsPerPage: params.maxPostsPerPage,
},
});

View File

@@ -32,6 +32,18 @@ export interface SitemapFeedBuildResult {
feedPosts: PostData[];
}
export interface SitemapArchiveMetadata {
allTags: Set<string>;
allCategories: Set<string>;
yearMonths: Map<string, Date>;
years: Map<number, Date>;
yearMonthDays: Map<string, Date>;
feedPosts: PostData[];
postUrls: Array<{ loc: string; lastmod: string }>;
pageUrls: Array<{ loc: string; lastmod: string }>;
latestPostUpdatedAt: string;
}
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
if (post.createdAt instanceof Date) {
return post.createdAt;
@@ -142,19 +154,19 @@ function escapeCdata(value: string): string {
return value.replace(/]]>/g, ']]]]><![CDATA[>');
}
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
export function collectSitemapArchiveMetadata(params: {
baseUrl: string;
maxPostsPerPage: number;
publishedPosts: PostData[];
publishedListPosts: PostData[];
}): SitemapArchiveMetadata {
const {
baseUrl,
projectName,
projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex,
includeFeeds,
} = params;
const now = new Date().toISOString();
const allTags = new Set<string>();
const allCategories = new Set<string>();
const yearMonths = new Map<string, Date>();
@@ -206,7 +218,53 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
}
}
const now = new Date().toISOString();
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
return {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
feedPosts,
postUrls,
pageUrls,
latestPostUpdatedAt,
};
}
export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): SitemapFeedBuildResult {
const {
baseUrl,
projectName,
projectDescription,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
postIndex,
includeFeeds,
} = params;
const archiveMetadata = collectSitemapArchiveMetadata({
baseUrl,
maxPostsPerPage,
publishedPosts,
publishedListPosts,
});
const {
allTags,
allCategories,
yearMonths,
years,
yearMonthDays,
postUrls,
pageUrls,
latestPostUpdatedAt,
feedPosts,
} = archiveMetadata;
const urls: string[] = [];
urls.push(buildSitemapUrl(`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0'));
@@ -259,7 +317,6 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
'',
].join('\n');
const feedPosts = publishedListPosts.slice(0, maxPostsPerPage);
if (!includeFeeds) {
return {
allTags,

View File

@@ -155,6 +155,10 @@ function normalizeCategorySettings(value: unknown): Record<string, CategoryRende
);
}
function isJsonParseError(error: unknown): boolean {
return error instanceof SyntaxError;
}
/**
* MetaEngine manages project metadata like available tags and categories.
*
@@ -447,6 +451,11 @@ export class MetaEngine extends EventEmitter {
const parsed = JSON.parse(content) as ProjectMetadata;
this.projectMetadata = normalizeProjectMetadata(parsed);
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse project metadata JSON, using null metadata:', error);
this.projectMetadata = null;
return;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load project metadata:', error);
throw error;
@@ -466,6 +475,10 @@ export class MetaEngine extends EventEmitter {
const parsed = JSON.parse(content) as Record<string, unknown>;
return normalizeCategoryMetadata(parsed);
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:', error);
return null;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load category metadata:', error);
throw error;
@@ -490,6 +503,11 @@ export class MetaEngine extends EventEmitter {
}
}
} catch (error) {
if (isJsonParseError(error)) {
console.warn('[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:', error);
this.categories.clear();
return;
}
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[MetaEngine] Failed to load categories:', error);
throw error;
@@ -653,6 +671,18 @@ export class MetaEngine extends EventEmitter {
// Handle project metadata
if (projectMetadataFileExists) {
await this.loadProjectMetadata();
if (!this.projectMetadata) {
const projectData = await this.fetchProjectFromDatabase();
if (!projectData) {
throw new Error(`Project not found in database: ${this.currentProjectId}`);
}
this.projectMetadata = {
name: projectData.name,
description: projectData.description || undefined,
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
};
await this.saveProjectMetadata();
}
if (this.projectMetadata?.dataPath !== undefined) {
const { dataPath: _dataPath, ...metadataWithoutDataPath } = this.projectMetadata;
this.projectMetadata = metadataWithoutDataPath;

View File

@@ -166,6 +166,8 @@ export class PreviewServer {
projectContext: ActiveProjectContext;
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
htmlRewriteContext?: HtmlRewriteContext;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
requestTheme?: string | null;
htmlThemeAttribute?: string;

View File

@@ -25,6 +25,8 @@ export interface SharedRouteRenderOptions {
projectContext: SharedActiveProjectContext;
metadata?: ProjectMetadata | null;
menu?: MenuDocument;
htmlRewriteContext?: HtmlRewriteContext;
skipContextSetup?: boolean;
maxPostsPerPage?: number;
requestTheme?: string | null;
htmlThemeAttribute?: string;
@@ -267,11 +269,13 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
options: SharedRouteRenderOptions,
services: SharedRouteRenderServices<TCategoryMetadata>,
): Promise<string | null> {
services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir);
services.postMediaEngine.setProjectContext(options.projectContext.projectId);
services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
if (!options.skipContextSetup) {
services.postEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.mediaEngine.setProjectContext?.(options.projectContext.projectId, options.projectContext.dataDir, options.projectContext.dataDir);
services.postMediaEngine.setProjectContext(options.projectContext.projectId);
services.settingsEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
services.menuEngine.setProjectContext(options.projectContext.projectId, options.projectContext.dataDir);
}
let metadata = options.metadata;
if (metadata === undefined) {
@@ -292,7 +296,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
const appliedTheme = sanitizePicoTheme(options.requestTheme)
?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = await services.buildHtmlRewriteContext();
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {

View File

@@ -640,6 +640,118 @@ describe('BlogGenerationEngine', () => {
expect(filteredCallCount).toBeLessThanOrEqual(8);
});
it('skips core sitemap and feed build phases for single-only generation', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 4; i += 1) {
posts.push(makePost({
id: `single-phase-${i}`,
slug: `single-phase-${i}`,
createdAt: new Date(`2025-${String(i + 1).padStart(2, '0')}-10T10:00:00Z`),
}));
}
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const onProgress = vi.fn();
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
sections: ['single'],
}, onProgress);
const progressMessages = onProgress.mock.calls.map((call) => String(call[1] ?? ''));
expect(progressMessages).not.toContain('Building sitemap XML...');
expect(progressMessages).not.toContain('Building RSS and Atom feeds...');
expect(progressMessages).not.toContain('Writing sitemap and feeds...');
});
it('skips sitemap XML build phase for archive-only generation sections', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 8; i += 1) {
posts.push(makePost({
id: `archive-only-${i}`,
slug: `archive-only-${i}`,
categories: [`cat-${i % 2}`],
tags: [`tag-${i % 3}`],
createdAt: new Date(`2025-${String((i % 4) + 1).padStart(2, '0')}-10T10:00:00Z`),
}));
}
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
const onProgress = vi.fn();
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
sections: ['category', 'tag', 'date'],
}, onProgress);
const progressMessages = onProgress.mock.calls.map((call) => String(call[1] ?? ''));
expect(progressMessages).not.toContain('Building sitemap XML...');
expect(progressMessages).not.toContain('Building RSS and Atom feeds...');
expect(progressMessages).not.toContain('Writing sitemap and feeds...');
});
it('does not rebuild canonical rewrite context for every generated html file', async () => {
const posts = [
makePost({ id: '1', slug: 'p1', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-15T10:00:00Z') }),
makePost({ id: '2', slug: 'p2', categories: ['news'], tags: ['t2'], createdAt: new Date('2025-01-14T10:00:00Z') }),
makePost({ id: '3', slug: 'p3', categories: ['news'], tags: ['t3'], createdAt: new Date('2025-01-13T10:00:00Z') }),
];
setupPosts(posts);
const pageRendererModule = await import('../../src/main/engine/PageRenderer');
const canonicalPathSpy = vi.spyOn(pageRendererModule, 'buildCanonicalPostPath');
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
maxPostsPerPage: 1,
}, vi.fn());
expect(canonicalPathSpy.mock.calls.length).toBeLessThanOrEqual(6);
canonicalPathSpy.mockRestore();
});
it('does not re-setup engine project context for every rendered html file', async () => {
const posts = [
makePost({ id: '1', slug: 'ctx-1', categories: ['news'], tags: ['t1'], createdAt: new Date('2025-01-15T10:00:00Z') }),
makePost({ id: '2', slug: 'ctx-2', categories: ['news'], tags: ['t2'], createdAt: new Date('2025-01-14T10:00:00Z') }),
makePost({ id: '3', slug: 'ctx-3', categories: ['news'], tags: ['t3'], createdAt: new Date('2025-01-13T10:00:00Z') }),
];
setupPosts(posts);
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
const engine = new BlogGenerationEngine();
await engine.generate({
projectId: 'test',
projectName: 'Test Blog',
dataDir: tempDir,
baseUrl: 'https://example.com',
maxPostsPerPage: 1,
}, vi.fn());
expect(mockPostEngine.setProjectContext.mock.calls.length).toBeLessThanOrEqual(2);
});
it('reduces repeated in-memory filtering across category tag and date generation', async () => {
const posts: PostData[] = [];
for (let i = 0; i < 30; i += 1) {

View File

@@ -72,4 +72,77 @@ describe('BlogGenerationOutputService', () => {
const saved = await readFile(path.join(htmlDir, 'section', 'page', 'index.html'), 'utf-8');
expect(saved).toBe('<html/>');
});
it('reuses in-run hash cache to avoid repeated hash reads for same file', async () => {
const tempRoot = path.join('/tmp', makeTempName());
await mkdir(tempRoot, { recursive: true });
const filePath = path.join(tempRoot, 'cached.txt');
const hashCache = new Map<string, string | null>();
const getHash = vi.fn().mockResolvedValue(null);
const setHash = vi.fn().mockResolvedValue(undefined);
const hashFn = vi.fn().mockReturnValue('h1');
await writeFileIfHashChanged({
projectId: 'p',
filePath,
relativePath: 'cached.txt',
content: 'hello',
getGeneratedFileHash: getHash,
setGeneratedFileHash: setHash,
computeHash: hashFn,
hashCache,
});
await writeFileIfHashChanged({
projectId: 'p',
filePath,
relativePath: 'cached.txt',
content: 'hello',
getGeneratedFileHash: getHash,
setGeneratedFileHash: setHash,
computeHash: hashFn,
hashCache,
});
expect(getHash).toHaveBeenCalledTimes(1);
});
it('avoids repeated directory ensure calls when known directory cache is provided', async () => {
const tempRoot = path.join('/tmp', makeTempName());
const htmlDir = path.join(tempRoot, 'html');
await mkdir(htmlDir, { recursive: true });
const ensureDirectory = vi.fn(async (dirPath: string) => {
await mkdir(dirPath, { recursive: true });
});
const knownDirectories = new Set<string>();
await writeHtmlPage({
projectId: 'p',
htmlDir,
urlPath: 'section/page',
content: '<html/>',
getGeneratedFileHash: async () => null,
setGeneratedFileHash: async () => undefined,
computeHash: () => 'h',
ensureDirectory,
knownDirectories,
});
await writeHtmlPage({
projectId: 'p',
htmlDir,
urlPath: 'section/page',
content: '<html/>',
getGeneratedFileHash: async () => 'h',
setGeneratedFileHash: async () => undefined,
computeHash: () => 'h',
ensureDirectory,
knownDirectories,
});
expect(ensureDirectory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -861,6 +861,47 @@ describe('MetaEngine', () => {
expect(metadata?.categoryMetadata?.news?.title).toBe('Newsroom');
});
it('should continue syncOnStartup when categories.json is malformed and recover from database categories', async () => {
const metaDir = metaEngine.getMetaDir();
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
name: 'Synced Project',
}));
mockFiles.set(normalizePath(`${metaDir}/categories.json`), '["news",');
mockPosts = [
{ categories: JSON.stringify(['db-cat']) },
];
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
const categories = await metaEngine.getCategories();
expect(categories).toContain('db-cat');
const metadata = await metaEngine.getProjectMetadata();
expect(metadata?.name).toBe('Synced Project');
});
it('should continue syncOnStartup when category-meta.json is malformed and keep valid project metadata', async () => {
const metaDir = metaEngine.getMetaDir();
mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({
name: 'Synced Project',
}));
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['news']));
mockFiles.set(normalizePath(`${metaDir}/category-meta.json`), '{"news":');
await expect(metaEngine.syncOnStartup()).resolves.toBeUndefined();
const metadata = await metaEngine.getProjectMetadata() as any;
expect(metadata?.name).toBe('Synced Project');
expect(metadata?.categoryMetadata?.news).toEqual(
expect.objectContaining({
renderInLists: true,
showTitle: true,
title: 'news',
}),
);
});
it('should create project.json with data from database during syncOnStartup if file does not exist', async () => {
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);