Merge pull request #13 from rfc1437/claude/add-blog-sitemap-generator-UHC76
feat: add sitemap generator to Blog menu
This commit is contained in:
62
package-lock.json
generated
62
package-lock.json
generated
@@ -171,7 +171,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",
|
||||
@@ -745,7 +744,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",
|
||||
@@ -822,7 +820,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"
|
||||
}
|
||||
@@ -844,7 +841,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",
|
||||
@@ -940,7 +936,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -981,7 +976,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1376,6 +1370,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1397,6 +1392,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1413,6 +1409,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1427,6 +1424,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3775,7 +3773,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",
|
||||
@@ -4964,7 +4961,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",
|
||||
@@ -5186,7 +5184,6 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -5197,7 +5194,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -5415,7 +5411,6 @@
|
||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -5612,7 +5607,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",
|
||||
@@ -6135,7 +6129,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6821,7 +6814,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",
|
||||
@@ -6958,8 +6952,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/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
@@ -7215,7 +7208,6 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7297,7 +7289,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",
|
||||
@@ -7770,6 +7763,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7790,6 +7784,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7923,7 +7918,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -8836,7 +8830,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -9143,7 +9137,6 @@
|
||||
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -9435,6 +9428,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -10675,6 +10669,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -10687,7 +10682,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"
|
||||
@@ -11185,7 +11179,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11261,6 +11254,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -11278,6 +11272,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -11288,6 +11283,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -11303,6 +11299,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -11457,7 +11454,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"
|
||||
}
|
||||
@@ -11491,7 +11487,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",
|
||||
@@ -11525,7 +11520,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",
|
||||
@@ -11603,7 +11597,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"
|
||||
}
|
||||
@@ -11613,7 +11606,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"
|
||||
},
|
||||
@@ -11643,7 +11635,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",
|
||||
@@ -11905,6 +11898,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12017,7 +12011,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-filename": {
|
||||
@@ -12622,6 +12616,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -12888,8 +12883,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",
|
||||
@@ -13426,7 +13420,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13707,7 +13700,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14267,7 +14259,6 @@
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
@@ -14345,7 +14336,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",
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ProjectMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
dataPath?: string; // Custom path for project data
|
||||
publicUrl?: string; // Public base URL for the published blog (e.g., https://example.com)
|
||||
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
||||
defaultAuthor?: string; // Default author for new posts and media
|
||||
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
||||
@@ -48,10 +49,21 @@ function sanitizeMaxPostsPerPage(value: unknown): number | undefined {
|
||||
return rounded;
|
||||
}
|
||||
|
||||
function sanitizePublicUrl(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||
return {
|
||||
...metadata,
|
||||
publicUrl,
|
||||
maxPostsPerPage,
|
||||
};
|
||||
}
|
||||
@@ -173,6 +185,7 @@ export class MetaEngine extends EventEmitter {
|
||||
name: normalizedUpdates.name || '',
|
||||
description: normalizedUpdates.description,
|
||||
dataPath: normalizedUpdates.dataPath,
|
||||
publicUrl: normalizedUpdates.publicUrl,
|
||||
mainLanguage: normalizedUpdates.mainLanguage,
|
||||
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||
|
||||
@@ -86,6 +86,50 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildSitemapUrl(
|
||||
loc: string,
|
||||
lastmod: string,
|
||||
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never',
|
||||
priority: string,
|
||||
): string {
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${escapeXml(loc)}</loc>`,
|
||||
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
||||
` <changefreq>${changefreq}</changefreq>`,
|
||||
` <priority>${priority}</priority>`,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolvePublicBaseUrl(publicUrl?: string): string | null {
|
||||
const trimmed = (publicUrl || '').trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
||||
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
// ============ Git Handlers ============
|
||||
|
||||
@@ -724,6 +768,7 @@ export function registerIpcHandlers(): void {
|
||||
return {
|
||||
name: metadata.name || undefined,
|
||||
description: metadata.description || undefined,
|
||||
publicUrl: metadata.publicUrl || undefined,
|
||||
mainLanguage: metadata.mainLanguage || undefined,
|
||||
};
|
||||
} catch {
|
||||
@@ -822,7 +867,7 @@ export function registerIpcHandlers(): void {
|
||||
return engine.getProjectMetadata();
|
||||
});
|
||||
|
||||
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => {
|
||||
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => {
|
||||
const engine = getMetaEngine();
|
||||
await engine.updateProjectMetadata(updates);
|
||||
return engine.getProjectMetadata();
|
||||
@@ -1277,6 +1322,194 @@ export function registerIpcHandlers(): void {
|
||||
return engine.runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
|
||||
});
|
||||
|
||||
// ============ Sitemap Generation ============
|
||||
|
||||
safeHandle('blog:generateSitemap', async () => {
|
||||
const projectEngine = getProjectEngine();
|
||||
const postEngine = getPostEngine();
|
||||
const metaEngine = getMetaEngine();
|
||||
const project = await projectEngine.getActiveProject();
|
||||
if (!project) {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
postEngine.setProjectContext(project.id, dataDir);
|
||||
metaEngine.setProjectContext(project.id, dataDir);
|
||||
|
||||
if (!metaEngine.isInitialized()) {
|
||||
await metaEngine.syncOnStartup();
|
||||
}
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata();
|
||||
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
|
||||
if (!baseUrl) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'Public URL Required',
|
||||
message: 'Sitemap generation requires a public URL.',
|
||||
detail: 'Set Project → Public URL in Settings before generating a sitemap.',
|
||||
});
|
||||
throw new Error('Project public URL is not configured');
|
||||
}
|
||||
|
||||
const taskId = `sitemap-generate-${Date.now()}`;
|
||||
|
||||
return taskManager.runTask({
|
||||
id: taskId,
|
||||
name: 'Generate Sitemap',
|
||||
execute: async (onProgress) => {
|
||||
onProgress(0, 'Loading posts...');
|
||||
|
||||
const publishedCandidates = await postEngine.getPostsFiltered({ status: 'published' });
|
||||
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
|
||||
|
||||
const draftPublishedSnapshots = await Promise.all(
|
||||
draftCandidates.map(async (post) => postEngine.getPublishedVersion(post.id)),
|
||||
);
|
||||
|
||||
const publishedPostById = new Map<string, PostData>();
|
||||
for (const post of publishedCandidates) {
|
||||
publishedPostById.set(post.id, post);
|
||||
}
|
||||
for (const snapshot of draftPublishedSnapshots) {
|
||||
if (snapshot) {
|
||||
publishedPostById.set(snapshot.id, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
const publishedPosts = Array.from(publishedPostById.values())
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
onProgress(10, `Found ${publishedPosts.length} published posts`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Collect all unique tags, categories, and year/month/day archives
|
||||
const allTags = new Set<string>();
|
||||
const allCategories = new Set<string>();
|
||||
const yearMonths = new Map<string, Date>(); // key -> most recent post date
|
||||
const years = new Map<number, Date>(); // year -> most recent post date
|
||||
const yearMonthDays = new Map<string, Date>(); // YYYY/MM/DD -> most recent post date
|
||||
|
||||
const postUrls: Array<{ loc: string; lastmod: string }> = [];
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
const tags = post.tags || [];
|
||||
const categories = post.categories || [];
|
||||
|
||||
for (const tag of tags) allTags.add(tag);
|
||||
for (const cat of categories) allCategories.add(cat);
|
||||
|
||||
// Build canonical post URL using shared helpers
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
const postUrl = `${baseUrl}${canonicalPath}`;
|
||||
const updatedAt = post.updatedAt;
|
||||
postUrls.push({ loc: postUrl, lastmod: updatedAt.toISOString() });
|
||||
|
||||
// Track archives
|
||||
const year = createdAt.getFullYear();
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||
const ymKey = `${year}/${month}`;
|
||||
const ymdKey = `${year}/${month}/${day}`;
|
||||
|
||||
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||
yearMonths.set(ymKey, updatedAt);
|
||||
}
|
||||
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||
years.set(year, updatedAt);
|
||||
}
|
||||
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||
yearMonthDays.set(ymdKey, updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(40, 'Building sitemap XML...');
|
||||
|
||||
// Build XML sitemap
|
||||
const urls: string[] = [];
|
||||
|
||||
// Homepage
|
||||
urls.push(buildSitemapUrl(baseUrl + '/', now, 'daily', '1.0'));
|
||||
|
||||
// Individual posts
|
||||
for (const post of postUrls) {
|
||||
urls.push(buildSitemapUrl(post.loc, post.lastmod, 'monthly', '0.8'));
|
||||
}
|
||||
|
||||
onProgress(55, 'Adding archive pages...');
|
||||
|
||||
// Year archives
|
||||
for (const [year, lastmod] of Array.from(years.entries()).sort((a, b) => b[0] - a[0])) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${year}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||
}
|
||||
|
||||
// Year/Month archives
|
||||
for (const [ym, lastmod] of Array.from(yearMonths.entries()).sort().reverse()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${ym}`, lastmod.toISOString(), 'monthly', '0.5'));
|
||||
}
|
||||
|
||||
// Year/Month/Day archives
|
||||
for (const [ymd, lastmod] of Array.from(yearMonthDays.entries()).sort().reverse()) {
|
||||
urls.push(buildSitemapUrl(`${baseUrl}/${ymd}`, lastmod.toISOString(), 'monthly', '0.4'));
|
||||
}
|
||||
|
||||
onProgress(70, 'Adding category pages...');
|
||||
|
||||
// Category pages
|
||||
for (const category of Array.from(allCategories).sort()) {
|
||||
urls.push(buildSitemapUrl(
|
||||
`${baseUrl}/category/${encodeURIComponent(category)}`,
|
||||
now,
|
||||
'weekly',
|
||||
'0.6',
|
||||
));
|
||||
}
|
||||
|
||||
onProgress(80, 'Adding tag pages...');
|
||||
|
||||
// Tag pages
|
||||
for (const tag of Array.from(allTags).sort()) {
|
||||
urls.push(buildSitemapUrl(
|
||||
`${baseUrl}/tag/${encodeURIComponent(tag)}`,
|
||||
now,
|
||||
'weekly',
|
||||
'0.6',
|
||||
));
|
||||
}
|
||||
|
||||
onProgress(90, 'Writing sitemap file...');
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Write to html folder in the project data directory
|
||||
const htmlDir = path.join(dataDir, 'html');
|
||||
await fsPromises.mkdir(htmlDir, { recursive: true });
|
||||
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
||||
await fsPromises.writeFile(sitemapPath, xml, 'utf-8');
|
||||
|
||||
onProgress(100, `Sitemap generated with ${urls.length} URLs`);
|
||||
|
||||
return {
|
||||
path: sitemapPath,
|
||||
urlCount: urls.length,
|
||||
postCount: postUrls.length,
|
||||
tagCount: allTags.size,
|
||||
categoryCount: allCategories.size,
|
||||
archiveCount: years.size + yearMonths.size + yearMonthDays.size,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Event Forwarding ============
|
||||
|
||||
// Forward engine events to renderer
|
||||
|
||||
@@ -318,6 +318,7 @@ function createApplicationMenu(): Menu {
|
||||
buildSharedMenuItem('reindexText'),
|
||||
{ type: 'separator' },
|
||||
buildSharedMenuItem('metadataDiff'),
|
||||
buildSharedMenuItem('generateSitemap'),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -249,6 +249,11 @@ export const electronAPI: ElectronAPI = {
|
||||
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => ipcRenderer.invoke('metadataDiff:syncFileToDb', postIds, field, groupLabel),
|
||||
},
|
||||
|
||||
// Blog operations
|
||||
blog: {
|
||||
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
|
||||
},
|
||||
|
||||
// AI Chat (OpenCode Zen API integration)
|
||||
chat: {
|
||||
// API Key Management
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ProjectMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
dataPath?: string;
|
||||
publicUrl?: string;
|
||||
mainLanguage?: string;
|
||||
defaultAuthor?: string;
|
||||
maxPostsPerPage?: number;
|
||||
@@ -510,7 +511,7 @@ export interface ElectronAPI {
|
||||
showItemInFolder: (itemPath: string) => Promise<void>;
|
||||
selectFolder: (title?: string) => Promise<string | null>;
|
||||
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
||||
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
||||
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
|
||||
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
||||
triggerMenuAction: (action: string) => Promise<void>;
|
||||
};
|
||||
@@ -524,7 +525,7 @@ export interface ElectronAPI {
|
||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>;
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>;
|
||||
};
|
||||
tags: {
|
||||
getAll: () => Promise<TagData[]>;
|
||||
@@ -589,6 +590,16 @@ export interface ElectronAPI {
|
||||
syncDbToFile: (postIds: string[], groupLabel: string) => Promise<{ success: number; failed: number }>;
|
||||
syncFileToDb: (postIds: string[], field: string, groupLabel: string) => Promise<{ success: number; failed: number }>;
|
||||
};
|
||||
blog: {
|
||||
generateSitemap: () => Promise<{
|
||||
path: string;
|
||||
urlCount: number;
|
||||
postCount: number;
|
||||
tagCount: number;
|
||||
categoryCount: number;
|
||||
archiveCount: number;
|
||||
}>;
|
||||
};
|
||||
chat: {
|
||||
// API Key Management
|
||||
checkReady: () => Promise<ChatReadyStatus>;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AppMenuAction =
|
||||
| 'rebuildDatabase'
|
||||
| 'reindexText'
|
||||
| 'metadataDiff'
|
||||
| 'generateSitemap'
|
||||
| 'about'
|
||||
| 'viewOnGitHub'
|
||||
| 'reportIssue';
|
||||
@@ -85,6 +86,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
||||
{ label: 'Rebuild Database from Files', action: 'rebuildDatabase' },
|
||||
{ label: 'Reindex Search Text', action: 'reindexText' },
|
||||
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
|
||||
{ label: 'Generate Sitemap', action: 'generateSitemap' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -113,6 +115,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
||||
rebuildDatabase: 'menu:rebuildDatabase',
|
||||
reindexText: 'menu:reindexText',
|
||||
metadataDiff: 'menu:metadataDiff',
|
||||
generateSitemap: 'menu:generateSitemap',
|
||||
about: 'menu:about',
|
||||
};
|
||||
|
||||
|
||||
@@ -277,6 +277,17 @@ const App: React.FC = () => {
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:generateSitemap', async () => {
|
||||
try {
|
||||
await window.electronAPI?.blog.generateSitemap();
|
||||
} catch (error) {
|
||||
console.error('Sitemap generation failed:', error);
|
||||
showToast.error('Sitemap generation failed');
|
||||
}
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
// Import completion event - refresh posts and media stores
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.import.onComplete(async (data) => {
|
||||
|
||||
@@ -107,6 +107,7 @@ export const SettingsView: React.FC = () => {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectDescription, setProjectDescription] = useState('');
|
||||
const [projectDataPath, setProjectDataPath] = useState('');
|
||||
const [projectPublicUrl, setProjectPublicUrl] = useState('');
|
||||
const [defaultProjectPath, setDefaultProjectPath] = useState('');
|
||||
const [projectMainLanguage, setProjectMainLanguage] = useState('en');
|
||||
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
|
||||
@@ -145,8 +146,13 @@ export const SettingsView: React.FC = () => {
|
||||
setDefaultProjectPath(path);
|
||||
});
|
||||
|
||||
// Load project metadata (includes mainLanguage and defaultAuthor)
|
||||
// Load project metadata (includes public URL, language, and default author)
|
||||
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
|
||||
if (metadata?.publicUrl) {
|
||||
setProjectPublicUrl(metadata.publicUrl);
|
||||
} else {
|
||||
setProjectPublicUrl('');
|
||||
}
|
||||
if (metadata?.mainLanguage) {
|
||||
setProjectMainLanguage(metadata.mainLanguage);
|
||||
}
|
||||
@@ -256,6 +262,7 @@ export const SettingsView: React.FC = () => {
|
||||
name: projectName.trim() || activeProject.name,
|
||||
description: projectDescription.trim(),
|
||||
dataPath: projectDataPath.trim() || undefined,
|
||||
publicUrl: projectPublicUrl.trim() || undefined,
|
||||
mainLanguage: projectMainLanguage,
|
||||
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
||||
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
||||
@@ -280,7 +287,7 @@ export const SettingsView: React.FC = () => {
|
||||
};
|
||||
|
||||
// Keywords for each section for search filtering
|
||||
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page'];
|
||||
const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page'];
|
||||
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
|
||||
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
|
||||
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
|
||||
@@ -346,6 +353,20 @@ export const SettingsView: React.FC = () => {
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="project-public-url"
|
||||
label="Public URL"
|
||||
description="The public base URL of your published blog (used for sitemap generation)."
|
||||
>
|
||||
<input
|
||||
id="project-public-url"
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
value={projectPublicUrl}
|
||||
onChange={(e) => setProjectPublicUrl(e.target.value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="project-language"
|
||||
label="Main Language"
|
||||
|
||||
@@ -590,6 +590,30 @@ describe('MetaEngine', () => {
|
||||
expect(metadata?.maxPostsPerPage).toBe(42);
|
||||
});
|
||||
|
||||
it('should set and get publicUrl in project metadata', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'My Blog',
|
||||
publicUrl: 'https://example.com/blog',
|
||||
});
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata();
|
||||
expect(metadata?.publicUrl).toBe('https://example.com/blog');
|
||||
});
|
||||
|
||||
it('should persist publicUrl to filesystem', async () => {
|
||||
await metaEngine.setProjectMetadata({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://example.com',
|
||||
});
|
||||
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
|
||||
const content = mockFiles.get(projectPath);
|
||||
const parsed = JSON.parse(content!);
|
||||
expect(parsed.publicUrl).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should sanitize invalid maxPostsPerPage values from filesystem', async () => {
|
||||
const metaDir = metaEngine.getMetaDir();
|
||||
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock('electron', () => ({
|
||||
dialog: {
|
||||
showOpenDialog: vi.fn(),
|
||||
showSaveDialog: vi.fn(),
|
||||
showMessageBox: vi.fn(),
|
||||
},
|
||||
shell: {
|
||||
openPath: vi.fn(),
|
||||
@@ -52,6 +53,7 @@ const mockPostEngine = {
|
||||
publishPost: vi.fn(),
|
||||
discardChanges: vi.fn(),
|
||||
hasPublishedVersion: vi.fn(),
|
||||
getPublishedVersion: vi.fn(),
|
||||
isSlugAvailable: vi.fn(),
|
||||
generateUniqueSlug: vi.fn(),
|
||||
rebuildDatabaseFromFiles: vi.fn(),
|
||||
@@ -168,6 +170,7 @@ const mockGitEngine = {
|
||||
const mockTaskManager = {
|
||||
getAllTasks: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
@@ -1437,6 +1440,436 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Blog Handlers ============
|
||||
describe('Blog Handlers', () => {
|
||||
describe('blog:generateSitemap', () => {
|
||||
it('should call taskManager.runTask with sitemap generation task', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data'
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
|
||||
// Mock post engine to return published posts and drafts
|
||||
const mockPublishedPosts = [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
slug: 'test-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||
tags: ['tag1', 'tag2'],
|
||||
categories: ['category1'],
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
projectId: 'test-project',
|
||||
slug: 'another-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-02-10T12:00:00Z'),
|
||||
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
||||
tags: ['tag2', 'tag3'],
|
||||
categories: ['category2'],
|
||||
},
|
||||
];
|
||||
|
||||
const mockDraftPosts = [
|
||||
{
|
||||
id: 'post-3',
|
||||
projectId: 'test-project',
|
||||
slug: 'draft-post',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-03-01T08:00:00Z'),
|
||||
updatedAt: new Date('2024-03-01T08:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return mockPublishedPosts;
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return mockDraftPosts;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
// Mock fs.writeFile
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
// Mock taskManager.runTask to execute the task immediately
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
const onProgress = vi.fn();
|
||||
return await task.execute(onProgress);
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:generateSitemap');
|
||||
|
||||
// Verify taskManager.runTask was called
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^sitemap-generate-\d+$/),
|
||||
name: 'Generate Sitemap',
|
||||
execute: expect.any(Function),
|
||||
})
|
||||
);
|
||||
|
||||
// Verify result contains expected data
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
path: expect.stringContaining('sitemap.xml'),
|
||||
postCount: 2, // Only published posts, not drafts
|
||||
tagCount: 3, // tag1, tag2, tag3
|
||||
categoryCount: 2, // category1, category2
|
||||
})
|
||||
);
|
||||
|
||||
// Verify fs operations
|
||||
expect(mkdir).toHaveBeenCalledWith('/mock/data/dir/html', { recursive: true });
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('sitemap.xml'),
|
||||
expect.stringContaining('<?xml version="1.0" encoding="UTF-8"?>'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when no active project', async () => {
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(null);
|
||||
|
||||
await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('No active project');
|
||||
|
||||
expect(mockTaskManager.runTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter out draft and archived posts from sitemap', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data'
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
|
||||
const mockPublishedPosts = [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockDraftPosts = [
|
||||
{
|
||||
id: 'post-2',
|
||||
projectId: 'test-project',
|
||||
slug: 'draft-post',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-02-10T12:00:00Z'),
|
||||
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockArchivedPosts = [
|
||||
{
|
||||
id: 'post-3',
|
||||
projectId: 'test-project',
|
||||
slug: 'archived-post',
|
||||
status: 'archived',
|
||||
createdAt: new Date('2024-03-01T08:00:00Z'),
|
||||
updatedAt: new Date('2024-03-01T08:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return mockPublishedPosts;
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return mockDraftPosts;
|
||||
}
|
||||
if (filter.status === 'archived') {
|
||||
return mockArchivedPosts;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
const onProgress = vi.fn();
|
||||
return await task.execute(onProgress);
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:generateSitemap');
|
||||
|
||||
// Verify only published posts are included
|
||||
expect(result.postCount).toBe(1);
|
||||
|
||||
// Verify the sitemap XML only contains the published post
|
||||
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
||||
const sitemapXml = writeFileCall[1] as string;
|
||||
|
||||
expect(sitemapXml).toContain('published-post');
|
||||
expect(sitemapXml).not.toContain('draft-post');
|
||||
expect(sitemapXml).not.toContain('archived-post');
|
||||
});
|
||||
|
||||
it('should include published snapshot for drafts with a former published version', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data',
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
|
||||
const publishedPost = {
|
||||
id: 'post-published',
|
||||
projectId: 'test-project',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
};
|
||||
|
||||
const neverPublishedDraft = {
|
||||
id: 'post-draft-new',
|
||||
projectId: 'test-project',
|
||||
slug: 'draft-no-published-version',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-02-10T12:00:00Z'),
|
||||
updatedAt: new Date('2024-02-12T09:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
};
|
||||
|
||||
const draftWithPublishedVersion = {
|
||||
id: 'post-draft-with-published',
|
||||
projectId: 'test-project',
|
||||
slug: 'draft-current-slug',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-03-01T08:00:00Z'),
|
||||
updatedAt: new Date('2024-03-03T08:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
};
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return [publishedPost];
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return [neverPublishedDraft, draftWithPublishedVersion];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
|
||||
if (id !== 'post-draft-with-published') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
projectId: 'test-project',
|
||||
slug: 'published-snapshot-slug',
|
||||
status: 'published',
|
||||
createdAt: new Date('2023-10-05T07:00:00Z'),
|
||||
updatedAt: new Date('2023-10-20T09:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
};
|
||||
});
|
||||
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
const onProgress = vi.fn();
|
||||
return await task.execute(onProgress);
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:generateSitemap');
|
||||
|
||||
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'published' });
|
||||
expect(mockPostEngine.getPostsFiltered).toHaveBeenCalledWith({ status: 'draft' });
|
||||
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-new');
|
||||
expect(mockPostEngine.getPublishedVersion).toHaveBeenCalledWith('post-draft-with-published');
|
||||
|
||||
expect(result.postCount).toBe(2);
|
||||
|
||||
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
||||
const sitemapXml = writeFileCall[1] as string;
|
||||
|
||||
expect(sitemapXml).toContain('published-post');
|
||||
expect(sitemapXml).toContain('published-snapshot-slug');
|
||||
expect(sitemapXml).not.toContain('draft-no-published-version');
|
||||
expect(sitemapXml).not.toContain('draft-current-slug');
|
||||
});
|
||||
|
||||
it('should use canonical path helpers for post URLs', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data'
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
|
||||
const mockPublishedPosts = [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
slug: 'my-test-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-03-25T10:00:00Z'),
|
||||
updatedAt: new Date('2024-03-26T15:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return mockPublishedPosts;
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
const onProgress = vi.fn();
|
||||
return await task.execute(onProgress);
|
||||
});
|
||||
|
||||
await invokeHandler('blog:generateSitemap');
|
||||
|
||||
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
||||
const sitemapXml = writeFileCall[1] as string;
|
||||
|
||||
// Verify canonical URL format: /YYYY/MM/DD/slug
|
||||
expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/my-test-post');
|
||||
});
|
||||
|
||||
it('should show setup dialog and abort when project public URL is missing', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data',
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
});
|
||||
|
||||
const { dialog } = await import('electron');
|
||||
|
||||
await expect(invokeHandler('blog:generateSitemap')).rejects.toThrow('Project public URL is not configured');
|
||||
|
||||
expect(dialog.showMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'warning',
|
||||
title: 'Public URL Required',
|
||||
}),
|
||||
);
|
||||
expect(mockTaskManager.runTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use project public URL from metadata as sitemap base URL', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data',
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com/',
|
||||
});
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
slug: 'public-url-test-post',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-03-25T10:00:00Z'),
|
||||
updatedAt: new Date('2024-03-26T15:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
const onProgress = vi.fn();
|
||||
return await task.execute(onProgress);
|
||||
});
|
||||
|
||||
await invokeHandler('blog:generateSitemap');
|
||||
|
||||
const writeFileCall = vi.mocked(writeFile).mock.calls[0];
|
||||
const sitemapXml = writeFileCall[1] as string;
|
||||
|
||||
expect(sitemapXml).toContain('https://blog.example.com/2024/03/25/public-url-test-post');
|
||||
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Error Handling ============
|
||||
describe('Error Handling', () => {
|
||||
it('should silently handle "Database is closing" errors', async () => {
|
||||
|
||||
@@ -43,8 +43,8 @@ describe('SettingsView Diff Preferences', () => {
|
||||
meta: {
|
||||
...(window as any).electronAPI?.meta,
|
||||
getCategories: vi.fn().mockResolvedValue(['article', 'picture', 'aside', 'page']),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75 }),
|
||||
updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12 }),
|
||||
getProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 75, publicUrl: 'https://example.com' }),
|
||||
updateProjectMetadata: vi.fn().mockResolvedValue({ maxPostsPerPage: 12, publicUrl: 'https://example.com' }),
|
||||
},
|
||||
chat: {
|
||||
...(window as any).electronAPI?.chat,
|
||||
@@ -92,4 +92,19 @@ describe('SettingsView Diff Preferences', () => {
|
||||
expect.objectContaining({ maxPostsPerPage: 75 })
|
||||
);
|
||||
});
|
||||
|
||||
it('includes project public URL in metadata save payload', async () => {
|
||||
render(<SettingsView />);
|
||||
|
||||
await screen.findByDisplayValue('https://example.com');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save project settings/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ publicUrl: 'https://example.com' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user