feat: first cut at publishing tool

This commit is contained in:
2026-02-26 16:52:29 +01:00
parent 74d6035f4a
commit 1666e6bba9
21 changed files with 976 additions and 39 deletions

163
package-lock.json generated
View File

@@ -37,11 +37,13 @@
"liquidjs": "^10.24.0", "liquidjs": "^10.24.0",
"marked-react": "^3.0.2", "marked-react": "^3.0.2",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-scp": "^0.0.25",
"pyodide": "^0.29.3", "pyodide": "^0.29.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-arborist": "^3.4.3", "react-arborist": "^3.4.3",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",
@@ -184,6 +186,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -762,6 +765,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0", "@codemirror/view": "^6.23.0",
@@ -838,6 +842,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -859,6 +864,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz",
"integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -954,6 +960,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@@ -994,6 +1001,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@@ -1348,7 +1356,6 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1370,7 +1377,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1387,7 +1393,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1402,7 +1407,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -3959,6 +3963,7 @@
"resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz", "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.17.0.tgz",
"integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==", "integrity": "sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@libsql/core": "^0.17.0", "@libsql/core": "^0.17.0",
"@libsql/hrana-client": "^0.9.0", "@libsql/hrana-client": "^0.9.0",
@@ -5165,8 +5170,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -5401,6 +5405,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -5411,6 +5416,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -5518,6 +5524,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0", "@typescript-eslint/types": "8.56.0",
@@ -5897,6 +5904,7 @@
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.18", "@vitest/utils": "4.0.18",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -6083,6 +6091,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -6116,6 +6125,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -6475,6 +6485,15 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": { "node_modules/assert-plus": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
@@ -6630,6 +6649,15 @@
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
} }
}, },
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bidi-js": { "node_modules/bidi-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -6692,6 +6720,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6748,6 +6777,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/builder-util": { "node_modules/builder-util": {
"version": "26.4.1", "version": "26.4.1",
"resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.4.1.tgz",
@@ -7364,6 +7402,20 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/crc": { "node_modules/crc": {
"version": "3.8.0", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
@@ -7387,8 +7439,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true
"peer": true
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "10.1.0", "version": "10.1.0",
@@ -7525,7 +7576,8 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/d3-cloud": { "node_modules/d3-cloud": {
"version": "1.2.8", "version": "1.2.8",
@@ -7803,6 +7855,7 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.7.0", "app-builder-lib": "26.7.0",
"builder-util": "26.4.1", "builder-util": "26.4.1",
@@ -7904,8 +7957,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.1",
@@ -8378,7 +8430,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -8399,7 +8450,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -8426,16 +8476,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -8543,6 +8583,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -8620,6 +8661,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -10127,6 +10169,7 @@
"integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.7.6", "@asamuzakjp/dom-selector": "^6.7.6",
@@ -10462,7 +10505,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -11732,7 +11774,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -11745,6 +11786,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"dompurify": "3.2.7", "dompurify": "3.2.7",
"marked": "14.0.0" "marked": "14.0.0"
@@ -11787,6 +11829,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nan": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz",
"integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
@@ -11988,6 +12037,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-scp": {
"version": "0.0.25",
"resolved": "https://registry.npmjs.org/node-scp/-/node-scp-0.0.25.tgz",
"integrity": "sha512-vodQgKRuxRjrzwTXyRl0JsrF+mjc/P4M2jwIjg4gpdbNC3KBWptTVN9b5PqludLsN402MdKN6lGydlmXGGv1YA==",
"license": "MIT",
"dependencies": {
"ssh2": "^1.16.0"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
@@ -12306,6 +12364,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -12426,7 +12485,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -12444,7 +12502,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -12465,7 +12522,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -12481,7 +12537,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -12636,6 +12691,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@@ -12669,6 +12725,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0", "prosemirror-transform": "^1.0.0",
@@ -12702,6 +12759,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz",
"integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@@ -12792,6 +12850,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -12857,6 +12916,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -12886,8 +12946,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@@ -13192,7 +13251,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -13270,6 +13328,15 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/rsyncwrapper": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/rsyncwrapper/-/rsyncwrapper-3.1.0.tgz",
"integrity": "sha512-lIygqLFXwNn4TzK6zqij7Ey70+pRzcqOZFAXsJYUdw5rOeAP8ce1Xqt0BZQFUCTZCPBTOLc1wDfCzEk0T9Nxng==",
"license": "MIT",
"engines": {
"node": ">=8.1.3"
}
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -13305,7 +13372,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-filename": { "node_modules/sanitize-filename": {
@@ -13672,6 +13738,23 @@
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true "optional": true
}, },
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/ssri": { "node_modules/ssri": {
"version": "12.0.0", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@@ -13935,7 +14018,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -14215,7 +14297,8 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true, "devOptional": true,
"license": "0BSD" "license": "0BSD",
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
@@ -14730,6 +14813,12 @@
"@mixmark-io/domino": "^2.2.0" "@mixmark-io/domino": "^2.2.0"
} }
}, },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -14765,6 +14854,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -15063,6 +15153,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -15622,6 +15713,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.18", "@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18", "@vitest/mocker": "4.0.18",
@@ -15699,6 +15791,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.28", "@vue/compiler-dom": "3.5.28",
"@vue/compiler-sfc": "3.5.28", "@vue/compiler-sfc": "3.5.28",

View File

@@ -97,11 +97,13 @@
"liquidjs": "^10.24.0", "liquidjs": "^10.24.0",
"marked-react": "^3.0.2", "marked-react": "^3.0.2",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-scp": "^0.0.25",
"pyodide": "^0.29.3", "pyodide": "^0.29.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-arborist": "^3.4.3", "react-arborist": "^3.4.3",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"rsyncwrapper": "^3.1.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"simple-git": "^3.31.1", "simple-git": "^3.31.1",
"snowball-stemmers": "^0.6.0", "snowball-stemmers": "^0.6.0",

View File

@@ -0,0 +1,370 @@
import { EventEmitter } from 'events';
import path from 'path';
import fs from 'fs/promises';
import { constants as fsConstants } from 'fs';
import { Client as scpClient, type ScpClient } from 'node-scp';
import rsync from 'rsyncwrapper';
export interface PublishCredentials {
sshHost: string;
sshUser: string;
sshRemotePath: string;
sshMode: 'scp' | 'rsync';
}
export interface PublishResult {
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}
type ProgressCallback = (progress: number, message: string) => void;
/** Files with these extensions are excluded from media uploads (metadata sidecars). */
const META_EXTENSION = '.meta';
export class PublishEngine extends EventEmitter {
private projectId: string | null = null;
private dataDir: string | null = null;
constructor() {
super();
}
setProjectContext(projectId: string, dataDir: string): void {
this.projectId = projectId;
this.dataDir = dataDir;
}
async uploadSite(
credentials: PublishCredentials,
onProgress: ProgressCallback,
): Promise<PublishResult> {
if (!this.dataDir || !this.projectId) {
throw new Error('No project context set');
}
this.validateCredentials(credentials);
const htmlDir = path.join(this.dataDir, 'html');
const thumbnailsDir = path.join(this.dataDir, 'thumbnails');
const mediaDir = path.join(this.dataDir, 'media');
// Verify the generated site exists
await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.');
const result: PublishResult = {
htmlFilesUploaded: 0,
thumbnailFilesUploaded: 0,
mediaFilesUploaded: 0,
filesSkipped: 0,
};
if (credentials.sshMode === 'rsync') {
await this.uploadViaRsync(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
} else {
await this.uploadViaScp(credentials, htmlDir, thumbnailsDir, mediaDir, result, onProgress);
}
onProgress(100, 'Upload complete');
return result;
}
// ── SCP mode ──────────────────────────────────────────────────────────────
private async uploadViaScp(
credentials: PublishCredentials,
htmlDir: string,
thumbnailsDir: string,
mediaDir: string,
result: PublishResult,
onProgress: ProgressCallback,
): Promise<void> {
const client = await scpClient({
host: credentials.sshHost,
username: credentials.sshUser,
agent: process.env.SSH_AUTH_SOCK,
});
try {
// Phase 1: html/ → remote root (033%)
onProgress(0, 'Uploading HTML files...');
const htmlResult = await this.scpUploadDirectory(
client,
htmlDir,
credentials.sshRemotePath,
(p, msg) => onProgress(Math.round(p * 0.33), msg),
);
result.htmlFilesUploaded = htmlResult.uploaded;
result.filesSkipped += htmlResult.skipped;
// Phase 2: thumbnails/ → remote/thumbnails/ (3366%)
onProgress(33, 'Uploading thumbnails...');
if (await this.directoryExists(thumbnailsDir)) {
const thumbResult = await this.scpUploadDirectory(
client,
thumbnailsDir,
path.posix.join(credentials.sshRemotePath, 'thumbnails'),
(p, msg) => onProgress(33 + Math.round(p * 0.33), msg),
);
result.thumbnailFilesUploaded = thumbResult.uploaded;
result.filesSkipped += thumbResult.skipped;
}
// Phase 3: media/ → remote/media/ (6699%), excluding .meta files
onProgress(66, 'Uploading media files...');
if (await this.directoryExists(mediaDir)) {
const mediaResult = await this.scpUploadDirectory(
client,
mediaDir,
path.posix.join(credentials.sshRemotePath, 'media'),
(p, msg) => onProgress(66 + Math.round(p * 0.33), msg),
(name) => !name.endsWith(META_EXTENSION),
);
result.mediaFilesUploaded = mediaResult.uploaded;
result.filesSkipped += mediaResult.skipped;
}
} finally {
client.close();
}
}
/**
* Recursively upload a local directory to a remote path via SCP/SFTP.
* Only uploads files that are newer than the remote version.
*/
private async scpUploadDirectory(
client: ScpClient,
localDir: string,
remoteDir: string,
onProgress: ProgressCallback,
fileFilter?: (name: string) => boolean,
): Promise<{ uploaded: number; skipped: number }> {
// Collect all files first for progress tracking
const files = await this.collectFiles(localDir, '', fileFilter);
let uploaded = 0;
let skipped = 0;
if (files.length === 0) {
onProgress(100, 'No files to upload');
return { uploaded: 0, skipped: 0 };
}
// Ensure remote directory exists
await this.scpEnsureDir(client, remoteDir);
// Track created directories to avoid redundant mkdir calls
const createdDirs = new Set<string>();
for (let i = 0; i < files.length; i++) {
const relativePath = files[i];
const localPath = path.join(localDir, relativePath);
const remotePath = path.posix.join(remoteDir, relativePath.split(path.sep).join('/'));
// Ensure parent directory exists on remote
const remoteParent = path.posix.dirname(remotePath);
if (!createdDirs.has(remoteParent)) {
await this.scpEnsureDir(client, remoteParent);
createdDirs.add(remoteParent);
}
// Check if we need to upload (compare mtime)
const localStat = await fs.stat(localPath);
const needsUpload = await this.scpNeedsUpload(client, remotePath, localStat.mtimeMs);
if (needsUpload) {
await client.uploadFile(localPath, remotePath);
uploaded++;
} else {
skipped++;
}
const progress = ((i + 1) / files.length) * 100;
onProgress(progress, `${uploaded} uploaded, ${skipped} skipped (${i + 1}/${files.length})`);
}
return { uploaded, skipped };
}
/**
* Check if a local file is newer than the remote file.
* Returns true if upload is needed.
*/
private async scpNeedsUpload(
client: ScpClient,
remotePath: string,
localMtimeMs: number,
): Promise<boolean> {
try {
const remoteStat = await client.stat(remotePath);
// SSH2 Stats.mtime is in seconds, localMtimeMs is in milliseconds
const remoteMtimeMs = remoteStat.mtime * 1000;
return localMtimeMs > remoteMtimeMs;
} catch {
// File doesn't exist on remote → needs upload
return true;
}
}
private async scpEnsureDir(client: ScpClient, remoteDir: string): Promise<void> {
try {
await client.mkdir(remoteDir, undefined, { recursive: true });
} catch {
// Directory may already exist
}
}
// ── rsync mode ────────────────────────────────────────────────────────────
private async uploadViaRsync(
credentials: PublishCredentials,
htmlDir: string,
thumbnailsDir: string,
mediaDir: string,
result: PublishResult,
onProgress: ProgressCallback,
): Promise<void> {
const remoteDest = `${credentials.sshUser}@${credentials.sshHost}:${credentials.sshRemotePath}`;
// Phase 1: html/ → remote root (033%)
onProgress(0, 'Syncing HTML files via rsync...');
const htmlCount = await this.rsyncDirectory(
htmlDir + '/',
remoteDest + '/',
);
result.htmlFilesUploaded = htmlCount;
onProgress(33, 'HTML sync complete');
// Phase 2: thumbnails/ → remote/thumbnails/ (3366%)
if (await this.directoryExists(thumbnailsDir)) {
onProgress(33, 'Syncing thumbnails via rsync...');
const thumbCount = await this.rsyncDirectory(
thumbnailsDir + '/',
remoteDest + '/thumbnails/',
);
result.thumbnailFilesUploaded = thumbCount;
}
onProgress(66, 'Thumbnails sync complete');
// Phase 3: media/ → remote/media/ (6699%), excluding .meta files
if (await this.directoryExists(mediaDir)) {
onProgress(66, 'Syncing media files via rsync...');
const mediaCount = await this.rsyncDirectory(
mediaDir + '/',
remoteDest + '/media/',
['*.meta'],
);
result.mediaFilesUploaded = mediaCount;
}
onProgress(99, 'Media sync complete');
}
/**
* Run rsync for a directory with incremental (--update --times) and recursive transfer.
* Returns estimated file count from stdout.
*/
private rsyncDirectory(
src: string,
dest: string,
exclude?: string[],
): Promise<number> {
return new Promise((resolve, reject) => {
rsync(
{
src,
dest,
ssh: true,
recursive: true,
times: true,
args: ['--update', '--compress'],
exclude: exclude || [],
},
(error, stdout, _stderr, _cmd) => {
if (error) {
reject(error);
} else {
// Count uploaded files from rsync stdout (each transferred file gets a line)
const lines = stdout.trim().split('\n').filter((l: string) => l.length > 0);
resolve(lines.length);
}
},
);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Recursively collect all file paths relative to baseDir, with optional filter.
*/
private async collectFiles(
baseDir: string,
prefix: string,
filter?: (name: string) => boolean,
): Promise<string[]> {
const files: string[] = [];
let entries;
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
if (entry.isDirectory()) {
const subFiles = await this.collectFiles(
path.join(baseDir, entry.name),
relativePath,
filter,
);
files.push(...subFiles);
} else if (entry.isFile()) {
if (!filter || filter(entry.name)) {
files.push(relativePath);
}
}
}
return files;
}
private validateCredentials(credentials: PublishCredentials): void {
if (!credentials.sshHost?.trim()) {
throw new Error('SSH host is required');
}
if (!credentials.sshUser?.trim()) {
throw new Error('SSH user is required');
}
if (!credentials.sshRemotePath?.trim()) {
throw new Error('Remote path is required');
}
}
private async ensureDirectoryExists(dirPath: string, errorMessage: string): Promise<void> {
try {
await fs.access(dirPath, fsConstants.F_OK);
} catch {
throw new Error(errorMessage);
}
}
private async directoryExists(dirPath: string): Promise<boolean> {
try {
await fs.access(dirPath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
}
// Singleton
let publishEngine: PublishEngine | null = null;
export function getPublishEngine(): PublishEngine {
if (!publishEngine) {
publishEngine = new PublishEngine();
}
return publishEngine;
}

View File

@@ -108,3 +108,9 @@ export {
type CreateScriptInput, type CreateScriptInput,
type UpdateScriptInput, type UpdateScriptInput,
} from './ScriptEngine'; } from './ScriptEngine';
export {
PublishEngine,
getPublishEngine,
type PublishCredentials,
type PublishResult,
} from './PublishEngine';

View File

@@ -18,6 +18,7 @@ import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuA
import { generateBlogmarkBookmarkletSource } from '../shared/blogmark'; import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers'; import { registerBlogHandlers } from './blogHandlers';
import { registerPublishHandlers } from './publishHandlers';
/** /**
* Wrap an IPC handler so that "Database is closing" errors during shutdown * Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -1433,6 +1434,7 @@ export function registerIpcHandlers(): void {
registerMetadataDiffHandlers(safeHandle); registerMetadataDiffHandlers(safeHandle);
registerBlogHandlers(safeHandle); registerBlogHandlers(safeHandle);
registerPublishHandlers(safeHandle);
// ============ Event Forwarding ============ // ============ Event Forwarding ============

View File

@@ -0,0 +1,30 @@
import { getProjectEngine } from '../engine/ProjectEngine';
import { getPublishEngine, type PublishCredentials } from '../engine/PublishEngine';
import { taskManager } from '../engine/TaskManager';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
export function registerPublishHandlers(safeHandle: SafeHandle): void {
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
const projectEngine = getProjectEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const publishEngine = getPublishEngine();
publishEngine.setProjectContext(project.id, project.dataPath!);
return taskManager.runTask({
id: `publish-upload-${Date.now()}`,
name: 'Upload Site',
groupId: 'publish',
groupName: 'Site Publishing',
execute: async (onProgress) => {
return publishEngine.uploadSite(credentials, (progress, message) => {
onProgress(progress, message);
});
},
});
});
}

View File

@@ -272,6 +272,12 @@ export const electronAPI: ElectronAPI = {
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'), regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
}, },
// Site publishing (SCP/rsync)
publish: {
uploadSite: (credentials: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) =>
ipcRenderer.invoke('publish:uploadSite', credentials),
},
menu: { menu: {
get: () => ipcRenderer.invoke('menu:get'), get: () => ipcRenderer.invoke('menu:get'),
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu), save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),

View File

@@ -709,6 +709,19 @@ export interface ElectronAPI {
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>; applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
regenerateCalendar: () => Promise<CalendarRegenerationResult>; regenerateCalendar: () => Promise<CalendarRegenerationResult>;
}; };
publish: {
uploadSite: (credentials: {
sshHost: string;
sshUser: string;
sshRemotePath: string;
sshMode: 'scp' | 'rsync';
}) => Promise<{
htmlFilesUploaded: number;
thumbnailFilesUploaded: number;
mediaFilesUploaded: number;
filesSkipped: number;
}>;
};
menu: { menu: {
get: () => Promise<MenuDocument>; get: () => Promise<MenuDocument>;
save: (menu: MenuDocument) => Promise<MenuDocument>; save: (menu: MenuDocument) => Promise<MenuDocument>;

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Site rendern", "menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen", "menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren", "menu.item.validateSite": "Website validieren",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server", "menu.item.about": "Über Blogging Desktop Server",
"menu.item.openDocumentation": "Dokumentation öffnen", "menu.item.openDocumentation": "Dokumentation öffnen",
"menu.item.openApiDocumentation": "API-Dokumentation", "menu.item.openApiDocumentation": "API-Dokumentation",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Render Site", "menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar", "menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site", "menu.item.validateSite": "Validate Site",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server", "menu.item.about": "About Blogging Desktop Server",
"menu.item.openDocumentation": "Open Documentation", "menu.item.openDocumentation": "Open Documentation",
"menu.item.openApiDocumentation": "API documentation", "menu.item.openApiDocumentation": "API documentation",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Renderizar sitio", "menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario", "menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio", "menu.item.validateSite": "Validar sitio",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server", "menu.item.about": "Acerca de Blogging Desktop Server",
"menu.item.openDocumentation": "Abrir documentación", "menu.item.openDocumentation": "Abrir documentación",
"menu.item.openApiDocumentation": "Documentación API", "menu.item.openApiDocumentation": "Documentación API",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Rendre le site", "menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier", "menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site", "menu.item.validateSite": "Valider le site",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server", "menu.item.about": "À propos de Blogging Desktop Server",
"menu.item.openDocumentation": "Ouvrir la documentation", "menu.item.openDocumentation": "Ouvrir la documentation",
"menu.item.openApiDocumentation": "Documentation API", "menu.item.openApiDocumentation": "Documentation API",

View File

@@ -41,6 +41,7 @@
"menu.item.generateSitemap": "Renderizza sito", "menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario", "menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito", "menu.item.validateSite": "Valida sito",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server", "menu.item.about": "Informazioni su Blogging Desktop Server",
"menu.item.openDocumentation": "Apri documentazione", "menu.item.openDocumentation": "Apri documentazione",
"menu.item.openApiDocumentation": "Documentazione API", "menu.item.openApiDocumentation": "Documentazione API",

View File

@@ -36,6 +36,7 @@ export type AppMenuAction =
| 'generateSitemap' | 'generateSitemap'
| 'regenerateCalendar' | 'regenerateCalendar'
| 'validateSite' | 'validateSite'
| 'uploadSite'
| 'openDocumentation' | 'openDocumentation'
| 'openApiDocumentation' | 'openApiDocumentation'
| 'about' | 'about'
@@ -132,6 +133,8 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' }, { label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' }, { label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' }, { label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: '', action: 'blog-separator-4', separator: true },
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
], ],
}, },
{ {
@@ -169,6 +172,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
generateSitemap: 'menu:generateSitemap', generateSitemap: 'menu:generateSitemap',
regenerateCalendar: 'menu:regenerateCalendar', regenerateCalendar: 'menu:regenerateCalendar',
validateSite: 'menu:validateSite', validateSite: 'menu:validateSite',
uploadSite: 'menu:uploadSite',
openDocumentation: 'menu:openDocumentation', openDocumentation: 'menu:openDocumentation',
openApiDocumentation: 'menu:openApiDocumentation', openApiDocumentation: 'menu:openApiDocumentation',
about: 'menu:about', about: 'menu:about',

View File

@@ -495,6 +495,27 @@ const App: React.FC = () => {
}) || (() => {}) }) || (() => {})
); );
unsubscribers.push(
window.electronAPI?.on('menu:uploadSite', async () => {
try {
const stored = localStorage.getItem('bds-credentials');
if (!stored) {
showToast.error(tr('app.uploadSiteNoCredentials'));
return;
}
const credentials = JSON.parse(stored);
if (!credentials.sshHost || !credentials.sshUser || !credentials.sshRemotePath) {
showToast.error(tr('app.uploadSiteNoCredentials'));
return;
}
await window.electronAPI?.publish.uploadSite(credentials);
} catch (error) {
console.error('Site upload failed:', error);
showToast.error(tr('app.uploadSiteFailed'));
}
}) || (() => {})
);
unsubscribers.push( unsubscribers.push(
window.electronAPI?.on('menu:openDocumentation', () => { window.electronAPI?.on('menu:openDocumentation', () => {
openSingletonToolTab(openTab, 'documentation'); openSingletonToolTab(openTab, 'documentation');

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen", "app.textReindexFailed": "Text-Neuindizierung fehlgeschlagen",
"app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen", "app.sitemapGenerationFailed": "Sitemap-Erstellung fehlgeschlagen",
"app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen", "app.calendarRegenerationFailed": "Kalender-Neuerstellung fehlgeschlagen",
"app.uploadSiteFailed": "Website-Upload fehlgeschlagen",
"app.uploadSiteNoCredentials": "Bitte konfigurieren Sie zuerst die SSH-Zugangsdaten in den Einstellungen.",
"app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden", "app.previewOpenFailed": "Ausgewählte Beitragsvorschau konnte nicht geöffnet werden",
"app.metadataDiff": "Metadaten-Diff", "app.metadataDiff": "Metadaten-Diff",
"app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien", "app.importComplete": "Import abgeschlossen: {posts} Beiträge, {media} Mediendateien",

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "Text reindex failed", "app.textReindexFailed": "Text reindex failed",
"app.sitemapGenerationFailed": "Sitemap generation failed", "app.sitemapGenerationFailed": "Sitemap generation failed",
"app.calendarRegenerationFailed": "Calendar regeneration failed", "app.calendarRegenerationFailed": "Calendar regeneration failed",
"app.uploadSiteFailed": "Site upload failed",
"app.uploadSiteNoCredentials": "Please configure SSH publishing credentials in Settings first.",
"app.previewOpenFailed": "Failed to open selected post preview", "app.previewOpenFailed": "Failed to open selected post preview",
"app.metadataDiff": "Metadata Diff", "app.metadataDiff": "Metadata Diff",
"app.importComplete": "Import complete: {posts} posts, {media} media files", "app.importComplete": "Import complete: {posts} posts, {media} media files",

View File

@@ -32,6 +32,8 @@
"app.textReindexFailed": "La reindexación de texto falló", "app.textReindexFailed": "La reindexación de texto falló",
"app.sitemapGenerationFailed": "La generación del sitemap falló", "app.sitemapGenerationFailed": "La generación del sitemap falló",
"app.calendarRegenerationFailed": "La regeneración del calendario falló", "app.calendarRegenerationFailed": "La regeneración del calendario falló",
"app.uploadSiteFailed": "Error al subir el sitio",
"app.uploadSiteNoCredentials": "Configure primero las credenciales SSH en Configuración.",
"app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada", "app.previewOpenFailed": "No se pudo abrir la vista previa de la entrada seleccionada",
"app.metadataDiff": "Diferencia de Metadatos", "app.metadataDiff": "Diferencia de Metadatos",
"app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia", "app.importComplete": "Importación completada: {posts} entradas, {media} archivos multimedia",

View File

@@ -31,8 +31,8 @@
"app.databaseRebuildFailed": "Échec de la reconstruction de la base de données", "app.databaseRebuildFailed": "Échec de la reconstruction de la base de données",
"app.textReindexFailed": "Échec de la réindexation du texte", "app.textReindexFailed": "Échec de la réindexation du texte",
"app.sitemapGenerationFailed": "Échec de la génération du sitemap", "app.sitemapGenerationFailed": "Échec de la génération du sitemap",
"app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.calendarRegenerationFailed": "Échec de la régénération du calendrier", "app.uploadSiteFailed": "Échec de la publication du site",
"app.previewOpenFailed": "Impossible douvrir laperçu de larticle sélectionné", "app.uploadSiteNoCredentials": "Veuillez d'abord configurer les identifiants SSH dans les paramètres.", "app.previewOpenFailed": "Impossible douvrir laperçu de larticle sélectionné",
"app.metadataDiff": "Diff Métadonnées", "app.metadataDiff": "Diff Métadonnées",
"app.importComplete": "Import terminé : {posts} articles, {media} fichiers média", "app.importComplete": "Import terminé : {posts} articles, {media} fichiers média",
"siteValidation.tabTitle": "Validation du site", "siteValidation.tabTitle": "Validation du site",

View File

@@ -31,8 +31,8 @@
"app.databaseRebuildFailed": "Ricostruzione database non riuscita", "app.databaseRebuildFailed": "Ricostruzione database non riuscita",
"app.textReindexFailed": "Reindicizzazione testo non riuscita", "app.textReindexFailed": "Reindicizzazione testo non riuscita",
"app.sitemapGenerationFailed": "Generazione sitemap non riuscita", "app.sitemapGenerationFailed": "Generazione sitemap non riuscita",
"app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.calendarRegenerationFailed": "Rigenerazione del calendario non riuscita", "app.uploadSiteFailed": "Caricamento del sito non riuscito",
"app.previewOpenFailed": "Impossibile aprire lanteprima del post selezionato", "app.uploadSiteNoCredentials": "Configurare prima le credenziali SSH nelle impostazioni.", "app.previewOpenFailed": "Impossibile aprire lanteprima del post selezionato",
"app.metadataDiff": "Diff Metadati", "app.metadataDiff": "Diff Metadati",
"app.importComplete": "Import completato: {posts} post, {media} file multimediali", "app.importComplete": "Import completato: {posts} post, {media} file multimediali",
"siteValidation.tabTitle": "Validazione sito", "siteValidation.tabTitle": "Validazione sito",

View File

@@ -0,0 +1,379 @@
/**
* PublishEngine Unit Tests
*
* Tests the site upload engine that publishes generated site content
* via SCP or rsync to a remote server.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import path from 'path';
import { PublishEngine, type PublishCredentials, type PublishResult } from '../../src/main/engine/PublishEngine';
// Hoist mock variables so they're available inside vi.mock factories
const {
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
mockReaddir, mockFsStat, mockAccess,
mockRsync,
} = vi.hoisted(() => {
const mockStat = vi.fn();
const mockUploadFile = vi.fn();
const mockMkdir = vi.fn();
const mockClose = vi.fn();
const mockScpClient = {
stat: mockStat,
uploadFile: mockUploadFile,
mkdir: mockMkdir,
close: mockClose,
};
const mockReaddir = vi.fn();
const mockFsStat = vi.fn();
const mockAccess = vi.fn();
const mockRsync = vi.fn((_options: any, callback: any) => {
callback(null, '', '', 'rsync command');
});
return {
mockStat, mockUploadFile, mockMkdir, mockClose, mockScpClient,
mockReaddir, mockFsStat, mockAccess,
mockRsync,
};
});
// Mock node-scp
vi.mock('node-scp', () => ({
Client: vi.fn().mockResolvedValue(mockScpClient),
default: vi.fn().mockResolvedValue(mockScpClient),
}));
// Mock rsyncwrapper
vi.mock('rsyncwrapper', () => ({
default: mockRsync,
}));
// Mock fs/promises
vi.mock('fs/promises', () => ({
default: {
readdir: (...args: any[]) => mockReaddir(...args),
stat: (...args: any[]) => mockFsStat(...args),
access: (...args: any[]) => mockAccess(...args),
},
readdir: (...args: any[]) => mockReaddir(...args),
stat: (...args: any[]) => mockFsStat(...args),
access: (...args: any[]) => mockAccess(...args),
}));
// Mock fs
vi.mock('fs', () => ({
default: { constants: { F_OK: 0 } },
constants: { F_OK: 0 },
}));
describe('PublishEngine', () => {
let engine: PublishEngine;
const dataDir = '/projects/test-project';
const defaultCredentials: PublishCredentials = {
sshHost: 'example.com',
sshUser: 'deploy',
sshRemotePath: '/var/www/html',
sshMode: 'scp',
};
beforeEach(() => {
vi.clearAllMocks();
engine = new PublishEngine();
engine.setProjectContext('test-project', dataDir);
// Default: directories exist, files are regular files
mockAccess.mockResolvedValue(undefined);
mockFsStat.mockResolvedValue({ isDirectory: () => false, isFile: () => true, mtimeMs: Date.now() });
mockReaddir.mockResolvedValue([]);
mockStat.mockRejectedValue(new Error('No such file')); // Remote file doesn't exist → upload
mockUploadFile.mockResolvedValue(undefined);
mockMkdir.mockResolvedValue(undefined);
mockClose.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor and project context', () => {
it('should be instantiated via getPublishEngine singleton', async () => {
const { getPublishEngine } = await import('../../src/main/engine/PublishEngine');
const e1 = getPublishEngine();
const e2 = getPublishEngine();
expect(e1).toBe(e2);
});
it('should throw if no project context is set', async () => {
const noContextEngine = new PublishEngine();
await expect(
noContextEngine.uploadSite(defaultCredentials, vi.fn()),
).rejects.toThrow('No project context');
});
});
describe('credential validation', () => {
it('should throw if sshHost is empty', async () => {
await expect(
engine.uploadSite({ ...defaultCredentials, sshHost: '' }, vi.fn()),
).rejects.toThrow('SSH host is required');
});
it('should throw if sshUser is empty', async () => {
await expect(
engine.uploadSite({ ...defaultCredentials, sshUser: '' }, vi.fn()),
).rejects.toThrow('SSH user is required');
});
it('should throw if sshRemotePath is empty', async () => {
await expect(
engine.uploadSite({ ...defaultCredentials, sshRemotePath: '' }, vi.fn()),
).rejects.toThrow('Remote path is required');
});
});
describe('directory validation', () => {
it('should throw if html directory does not exist', async () => {
mockAccess.mockImplementation(async (p: string) => {
if ((p as string).endsWith('html')) throw new Error('ENOENT');
});
await expect(
engine.uploadSite(defaultCredentials, vi.fn()),
).rejects.toThrow('Generated site not found');
});
});
describe('SCP mode upload', () => {
it('should upload html files to remote root', async () => {
// html/ contains index.html
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [{ name: 'index.html', isDirectory: () => false, isFile: () => true }];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: Date.now(),
});
const onProgress = vi.fn();
const result = await engine.uploadSite(defaultCredentials, onProgress);
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
expect(onProgress).toHaveBeenCalled();
});
it('should upload thumbnail files to remote thumbnails/', async () => {
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [];
}
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
return [{ name: 'thumb1.jpg', isDirectory: () => false, isFile: () => true }];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: Date.now(),
});
const result = await engine.uploadSite(defaultCredentials, vi.fn());
expect(result.thumbnailFilesUploaded).toBeGreaterThanOrEqual(0);
});
it('should only upload image files from media, not .meta sidecars', async () => {
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [];
}
if (dir === path.join(dataDir, 'thumbnails') && opts?.withFileTypes) {
return [];
}
if (dir === path.join(dataDir, 'media') && opts?.withFileTypes) {
return [
{ name: 'photo.jpg', isDirectory: () => false, isFile: () => true },
{ name: 'photo.jpg.meta', isDirectory: () => false, isFile: () => true },
{ name: 'document.pdf', isDirectory: () => false, isFile: () => true },
{ name: 'document.pdf.meta', isDirectory: () => false, isFile: () => true },
];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: Date.now(),
});
const result = await engine.uploadSite(defaultCredentials, vi.fn());
// Should upload photo.jpg and document.pdf, but NOT .meta files
expect(result.mediaFilesUploaded).toBeGreaterThanOrEqual(0);
});
it('should skip files that are not newer than remote', async () => {
const remoteTime = Date.now() / 1000; // SSH stats use seconds
const localTimeOlder = (remoteTime - 100) * 1000; // local is older (ms)
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [{ name: 'old.html', isDirectory: () => false, isFile: () => true }];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: localTimeOlder,
});
// Remote file exists and is newer
mockStat.mockResolvedValue({ mtime: remoteTime });
const result = await engine.uploadSite(defaultCredentials, vi.fn());
// File should be skipped since remote is newer
expect(mockUploadFile).not.toHaveBeenCalled();
expect(result.filesSkipped).toBeGreaterThan(0);
});
it('should upload files that are newer than remote', async () => {
const remoteTime = Date.now() / 1000 - 100; // Remote is 100s old
const localTimeNewer = Date.now(); // local is current (ms)
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [{ name: 'new.html', isDirectory: () => false, isFile: () => true }];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: localTimeNewer,
});
mockStat.mockResolvedValue({ mtime: remoteTime });
const result = await engine.uploadSite(defaultCredentials, vi.fn());
expect(mockUploadFile).toHaveBeenCalled();
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
});
it('should recurse into subdirectories', async () => {
mockReaddir.mockImplementation(async (dir: string, opts?: any) => {
if (dir === path.join(dataDir, 'html') && opts?.withFileTypes) {
return [
{ name: '2026', isDirectory: () => true, isFile: () => false },
];
}
if (dir === path.join(dataDir, 'html', '2026') && opts?.withFileTypes) {
return [
{ name: 'post.html', isDirectory: () => false, isFile: () => true },
];
}
return [];
});
mockFsStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
mtimeMs: Date.now(),
});
const result = await engine.uploadSite(defaultCredentials, vi.fn());
expect(mockMkdir).toHaveBeenCalled(); // Should create remote subdir
expect(result.htmlFilesUploaded).toBeGreaterThan(0);
});
it('should return a complete PublishResult', async () => {
mockReaddir.mockResolvedValue([]);
const result = await engine.uploadSite(defaultCredentials, vi.fn());
expect(result).toHaveProperty('htmlFilesUploaded');
expect(result).toHaveProperty('thumbnailFilesUploaded');
expect(result).toHaveProperty('mediaFilesUploaded');
expect(result).toHaveProperty('filesSkipped');
expect(typeof result.htmlFilesUploaded).toBe('number');
expect(typeof result.thumbnailFilesUploaded).toBe('number');
expect(typeof result.mediaFilesUploaded).toBe('number');
expect(typeof result.filesSkipped).toBe('number');
});
});
describe('rsync mode upload', () => {
const rsyncCredentials: PublishCredentials = {
...defaultCredentials,
sshMode: 'rsync',
};
it('should call rsync for html directory', async () => {
const rsync = (await import('rsyncwrapper')).default;
const result = await engine.uploadSite(rsyncCredentials, vi.fn());
expect(rsync).toHaveBeenCalled();
expect(result.htmlFilesUploaded).toBeGreaterThanOrEqual(0);
});
it('should use --update and --times flags for incremental transfer', async () => {
const rsync = (await import('rsyncwrapper')).default;
await engine.uploadSite(rsyncCredentials, vi.fn());
// Check that rsync was called with update semantics
const calls = vi.mocked(rsync).mock.calls;
expect(calls.length).toBeGreaterThan(0);
for (const [options] of calls) {
expect(options.args).toContain('--update');
expect(options.times).toBe(true);
expect(options.recursive).toBe(true);
}
});
it('should exclude .meta files when syncing media', async () => {
const rsync = (await import('rsyncwrapper')).default;
await engine.uploadSite(rsyncCredentials, vi.fn());
const calls = vi.mocked(rsync).mock.calls;
// Find the media sync call (dest contains /media)
const mediaCall = calls.find(([opts]) =>
typeof opts.dest === 'string' && opts.dest.includes('/media'),
);
if (mediaCall) {
expect(mediaCall[0].exclude).toContain('*.meta');
}
});
});
describe('progress reporting', () => {
it('should report progress through all three phases', async () => {
mockReaddir.mockResolvedValue([]);
const onProgress = vi.fn();
await engine.uploadSite(defaultCredentials, onProgress);
// Should have called onProgress at least once per phase
expect(onProgress).toHaveBeenCalled();
const progressValues = onProgress.mock.calls.map(([p]: [number]) => p);
// Should reach 100
expect(progressValues[progressValues.length - 1]).toBe(100);
});
});
});