From 03657e7a84eab40511fe03a16410a3a7a46680a8 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 09:54:42 +0100 Subject: [PATCH] chore: more refactorings and optimizations --- package-lock.json | 72 ++++++----- src/main/engine/BlogGenerationEngine.ts | 80 +++++++++---- .../engine/BlogGenerationOutputService.ts | 28 ++++- .../engine/GenerationRouteRendererFactory.ts | 38 +++++- .../engine/GenerationSitemapFeedService.ts | 71 +++++++++-- src/main/engine/MetaEngine.ts | 30 +++++ src/main/engine/PreviewServer.ts | 2 + src/main/engine/SharedRouteRenderer.ts | 16 ++- tests/engine/BlogGenerationEngine.test.ts | 112 ++++++++++++++++++ .../BlogGenerationOutputService.test.ts | 73 ++++++++++++ tests/engine/MetaEngine.test.ts | 41 +++++++ 11 files changed, 485 insertions(+), 78 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b1c2f4..bb49ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/main/engine/BlogGenerationEngine.ts b/src/main/engine/BlogGenerationEngine.ts index e681674..6827834 100644 --- a/src/main/engine/BlogGenerationEngine.ts +++ b/src/main/engine/BlogGenerationEngine.ts @@ -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(); + let allCategories = new Set(); + let yearMonths = new Map(); + let years = new Map(); + let yearMonthDays = new Map(); + 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(); + const generatedHashCache = new Map(); + const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({ projectId, htmlDir, urlPath, content, + knownDirectories: knownOutputDirectories, + hashCache: generatedHashCache, }); let pagesGenerated = 0; diff --git a/src/main/engine/BlogGenerationOutputService.ts b/src/main/engine/BlogGenerationOutputService.ts index c9fcff4..173df73 100644 --- a/src/main/engine/BlogGenerationOutputService.ts +++ b/src/main/engine/BlogGenerationOutputService.ts @@ -33,6 +33,7 @@ export async function writeFileIfHashChanged(params: { filePath: string; relativePath: string; content: string; + hashCache?: Map; getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise; setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise; 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; + hashCache?: Map; + ensureDirectory?: (dirPath: string) => Promise; getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise; setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise; 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, diff --git a/src/main/engine/GenerationRouteRendererFactory.ts b/src/main/engine/GenerationRouteRendererFactory.ts index ac2136e..bcf0362 100644 --- a/src/main/engine/GenerationRouteRendererFactory.ts +++ b/src/main/engine/GenerationRouteRendererFactory.ts @@ -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>(); const postsByFilterPromiseCache = new Map>(); const publishedSnapshotByIdPromiseCache = new Map>(); @@ -201,12 +207,42 @@ export function createPreviewBackedGenerationRouteRenderer(params: { getActiveProjectContext: async () => projectContext, }); + const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map; canonicalMediaPathBySourcePath: Map }> = (async () => { + const canonicalPostPathBySlug = new Map(); + for (const post of params.publishedPostsForLookup) { + canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post)); + } + + const canonicalMediaPathBySourcePath = new Map(); + 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, }, }); diff --git a/src/main/engine/GenerationSitemapFeedService.ts b/src/main/engine/GenerationSitemapFeedService.ts index 571a538..c310288 100644 --- a/src/main/engine/GenerationSitemapFeedService.ts +++ b/src/main/engine/GenerationSitemapFeedService.ts @@ -32,6 +32,18 @@ export interface SitemapFeedBuildResult { feedPosts: PostData[]; } +export interface SitemapArchiveMetadata { + allTags: Set; + allCategories: Set; + yearMonths: Map; + years: Map; + yearMonthDays: Map; + 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, ']]]]>'); } -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(); const allCategories = new Set(); const yearMonths = new Map(); @@ -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, diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 9445f64..d4b5699 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -155,6 +155,10 @@ function normalizeCategorySettings(value: unknown): Record; 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; diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index 93bd33e..19fe209 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -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; diff --git a/src/main/engine/SharedRouteRenderer.ts b/src/main/engine/SharedRouteRenderer.ts index 3b4dc25..d958966 100644 --- a/src/main/engine/SharedRouteRenderer.ts +++ b/src/main/engine/SharedRouteRenderer.ts @@ -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( options: SharedRouteRenderOptions, services: SharedRouteRenderServices, ): Promise { - 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( 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, { diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 9fbecde..bc7b2b1 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -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) { diff --git a/tests/engine/BlogGenerationOutputService.test.ts b/tests/engine/BlogGenerationOutputService.test.ts index 8e9f9ce..29b407c 100644 --- a/tests/engine/BlogGenerationOutputService.test.ts +++ b/tests/engine/BlogGenerationOutputService.test.ts @@ -72,4 +72,77 @@ describe('BlogGenerationOutputService', () => { const saved = await readFile(path.join(htmlDir, 'section', 'page', 'index.html'), 'utf-8'); expect(saved).toBe(''); }); + + 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(); + + 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(); + + await writeHtmlPage({ + projectId: 'p', + htmlDir, + urlPath: 'section/page', + content: '', + getGeneratedFileHash: async () => null, + setGeneratedFileHash: async () => undefined, + computeHash: () => 'h', + ensureDirectory, + knownDirectories, + }); + + await writeHtmlPage({ + projectId: 'p', + htmlDir, + urlPath: 'section/page', + content: '', + getGeneratedFileHash: async () => 'h', + setGeneratedFileHash: async () => undefined, + computeHash: () => 'h', + ensureDirectory, + knownDirectories, + }); + + expect(ensureDirectory).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index c646594..5dca60f 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -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`);