feat: display multilingual release notes in version management
- Support Simplified Chinese, Traditional Chinese, English, and Japanese - Follow the UI language and show a notice when notes are unavailable - Avoid language and content fallbacks - Generate Release bodies and update manifests from per-version Markdown - Add collapsible notes, publication dates, and links to full releases - Test publishing, legacy manifest compatibility, and localized rendering
This commit is contained in:
1 parent
c9c0c4cf1c
commit
bac67c1524
25 files changed
+888
-9
No files matched your search
@@ -309,6 +309,16 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Prepare shared release notes
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.version.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-notes.mjs \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--output "$RUNNER_TEMP/easycli-release-notes.json" \
|
||||
--body-output "$RUNNER_TEMP/easycli-release-notes.md"
|
||||
- name: Generate portable update manifests
|
||||
shell: bash
|
||||
env:
|
||||
@@ -321,7 +331,8 @@ jobs:
|
||||
--platform "$platform" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" \
|
||||
--tag "$RELEASE_TAG"
|
||||
--tag "$RELEASE_TAG" \
|
||||
--release-notes "$RUNNER_TEMP/easycli-release-notes.json"
|
||||
done
|
||||
- name: Publish release
|
||||
shell: bash
|
||||
@@ -339,7 +350,7 @@ jobs:
|
||||
esac
|
||||
|
||||
prerelease="$MANUAL_PRERELEASE"
|
||||
release_args=(--title "EasyCLIProxyAPI $RELEASE_TAG" --generate-notes)
|
||||
release_args=(--title "EasyCLIProxyAPI $RELEASE_TAG" --notes-file "$RUNNER_TEMP/easycli-release-notes.md")
|
||||
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
|
||||
if [[ "$RELEASE_TAG" == *-* ]]; then
|
||||
prerelease=true
|
||||
@@ -355,6 +366,7 @@ jobs:
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$RELEASE_TAG" artifacts/* --clobber
|
||||
gh release edit "$RELEASE_TAG" --notes-file "$RUNNER_TEMP/easycli-release-notes.md"
|
||||
else
|
||||
gh release create "$RELEASE_TAG" "${release_args[@]}" artifacts/*
|
||||
fi
|
||||
@@ -49,6 +49,8 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image
|
||||
|
||||
每次启动软件时,软件和内核会各自在后台自动检查一次更新;之后每进入 **版本管理** 页面 5 次(第 5、10、15 次,以此类推)自动检查一次,也可随时通过各自的 **检查更新** 按钮手动检查。页面内部刷新不重复计数,重启软件后次数归零。修改下载源不会自动检查更新,重新进入页面也会保留已保存的下载源。检查不会自动下载或安装更新。
|
||||
|
||||
**版本管理** 页面同时展示当前界面语言的最新版软件更新说明,升级后仍可查看。发布者在 [`docs/release-notes/`](docs/release-notes/README.md) 中按版本分别编辑简体中文、繁体中文、英文和日文 Markdown,CI 会将内容用于 GitHub Release 正文和应用更新清单。当前语言的说明缺失时直接提示,不回退到其他语言。
|
||||
|
||||
### OAuth 账号授权
|
||||
|
||||

|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
@@ -221,66 +222,192 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
@@ -289,16 +416,48 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# 维护更新说明
|
||||
|
||||
每个版本建立与发布 tag 同名的目录,分别维护四份 Markdown:
|
||||
|
||||
```text
|
||||
docs/release-notes/v0.2.98/
|
||||
zh-CN.md # 简体中文
|
||||
zh-TW.md # 繁體中文
|
||||
en.md # English
|
||||
ja.md # 日本語
|
||||
```
|
||||
|
||||
发布新版本时,创建对应目录并为每种语言编写正文和分类标题。版本号和日期由页面自动展示,不需要重复写入正文。支持标题、列表、加粗、代码和 HTTP/HTTPS 链接;应用内不加载图片或原始 HTML。简体中文格式示例:
|
||||
|
||||
```markdown
|
||||
## 新增
|
||||
|
||||
- 描述新增的功能。
|
||||
|
||||
## 优化
|
||||
|
||||
- 描述体验上的改进。
|
||||
|
||||
## 修复
|
||||
|
||||
- 描述修复的问题。
|
||||
```
|
||||
|
||||
将说明文件随代码提交到运行发布工作流的提交中,再发布对应 tag。不要把文案写进 CI YAML。当前的 `v0.2.98/` 是本次功能的待发布说明,实际发布其他版本时请同步调整目录名。
|
||||
|
||||
发布流程读取这些文件后生成两份输出:GitHub Release 正文按语言分节展示;Windows、Linux、macOS 更新清单的 `releaseNotes` 保存语言到 Markdown 的映射,例如 `{"zh-CN":"## 新增\n…","en":"## Added\n…"}`。应用检查版本时一并获取,切换界面语言会立即切换到对应正文,无需额外请求。清单中的发布日期使用本次生成清单的时间。
|
||||
|
||||
**不做语言或内容回退。** 文件不存在或只有空白时,清单不包含该语言的正文,应用直接提示“暂未获取到当前语言的更新说明”。即使其他语言有内容,也不会代替显示。GitHub Release 对应语言一节同样显示缺失提示。不会读取旧的单文件正文、已有 Release 文本或 GitHub 自动生成内容来补齐。
|
||||
|
||||
旧清单没有按语言组织的 `releaseNotes` 时,同样显示提示,不影响版本检查和升级。已经更新到最新版也能继续查看当前语言的说明。
|
||||
|
||||
重新运行发布工作流会同步更新 Release 正文和清单;仅在 GitHub 网页修改正文不会改变已经上传的清单。说明文件的权限或读取错误会使 CI 报错,以免静默发布错误内容。
|
||||
@@ -0,0 +1,5 @@
|
||||
## Added
|
||||
|
||||
- The version management page now shows software release notes and the latest version's publication date.
|
||||
- Release notes can be expanded or collapsed, with a link to the full release page.
|
||||
- Release notes follow the interface language. A message is shown when notes in that language are unavailable.
|
||||
@@ -0,0 +1,5 @@
|
||||
## 新機能
|
||||
|
||||
- バージョン管理ページに更新内容を追加しました。最新版の変更点と公開日を確認できます。
|
||||
- 更新内容の展開と折りたたみに対応し、リリースページへのリンクを追加しました。
|
||||
- 更新内容は画面の言語に合わせて表示されます。その言語の内容がない場合はメッセージを表示します。
|
||||
@@ -0,0 +1,5 @@
|
||||
## 新增
|
||||
|
||||
- 版本管理页新增软件更新说明,可直接查看最新版的更新内容和发布日期。
|
||||
- 更新说明支持展开与收起,并提供完整发布说明入口。
|
||||
- 更新说明随界面语言切换;当前语言的内容暂缺时直接显示提示。
|
||||
@@ -0,0 +1,5 @@
|
||||
## 新增
|
||||
|
||||
- 版本管理頁新增軟體更新說明,可直接查看最新版的更新內容和發布日期。
|
||||
- 更新說明支援展開與收合,並提供完整發布說明入口。
|
||||
- 更新說明隨介面語言切換;缺少目前語言的內容時會直接顯示提示。
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { validateAppVersion } from './version.mjs';
|
||||
import { validateReleaseNotes } from './release-notes.mjs';
|
||||
|
||||
export function portableUpdateManifestName(platform) {
|
||||
const normalizedPlatform = String(platform).trim().toLowerCase();
|
||||
@@ -31,6 +32,7 @@ export async function generatePortableUpdateManifest({
|
||||
gitcodeRepository,
|
||||
tag: rawTag,
|
||||
publishedAt = new Date().toISOString(),
|
||||
releaseNotes,
|
||||
}) {
|
||||
const resolvedDirectory = resolve(directory ?? 'artifacts');
|
||||
const platformSpecs = {
|
||||
@@ -62,6 +64,7 @@ export async function generatePortableUpdateManifest({
|
||||
if (Number.isNaN(Date.parse(publishedAt))) {
|
||||
throw new Error(`Invalid publishedAt: ${publishedAt}`);
|
||||
}
|
||||
if (releaseNotes !== undefined) validateReleaseNotes(releaseNotes);
|
||||
|
||||
const assets = {};
|
||||
for (const arch of ['amd64', 'aarch64']) {
|
||||
@@ -91,6 +94,7 @@ export async function generatePortableUpdateManifest({
|
||||
version,
|
||||
publishedAt,
|
||||
releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`,
|
||||
...(releaseNotes === undefined ? {} : { releaseNotes }),
|
||||
assets,
|
||||
};
|
||||
|
||||
@@ -116,6 +120,9 @@ async function main() {
|
||||
? [resolve(requestedOutput)]
|
||||
: portableUpdateManifestNames(platform).map((name) => join(directory, name));
|
||||
const publishedAt = new Date().toISOString();
|
||||
const releaseNotes = args.has('--release-notes')
|
||||
? JSON.parse(await readFile(resolve(args.get('--release-notes')), 'utf8'))
|
||||
: undefined;
|
||||
let manifest;
|
||||
for (const output of outputs) {
|
||||
manifest = await generatePortableUpdateManifest({
|
||||
@@ -126,6 +133,7 @@ async function main() {
|
||||
gitcodeRepository: args.get('--gitcode-repository'),
|
||||
tag: args.get('--tag'),
|
||||
publishedAt,
|
||||
releaseNotes,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { validateAppVersion } from './version.mjs';
|
||||
|
||||
export const releaseNotesLanguages = [
|
||||
{ locale: 'zh-CN', label: '简体中文', missing: '暂未提供该版本的简体中文更新说明。' },
|
||||
{ locale: 'zh-TW', label: '繁體中文', missing: '暫未提供此版本的繁體中文更新說明。' },
|
||||
{ locale: 'en', label: 'English', missing: 'Release notes for this version are not available in English.' },
|
||||
{ locale: 'ja', label: '日本語', missing: 'このバージョンの日本語の更新内容はありません。' },
|
||||
];
|
||||
|
||||
// Only explicitly authored translations are published. Missing languages stay missing.
|
||||
export async function resolveReleaseNotes({ root = process.cwd(), tag }) {
|
||||
const version = validateAppVersion(String(tag ?? '').trim().replace(/^v/, ''), 'release tag');
|
||||
const directory = join(root, 'docs', 'release-notes', `v${version}`);
|
||||
const notes = {};
|
||||
for (const { locale } of releaseNotesLanguages) {
|
||||
try {
|
||||
const text = (await readFile(join(directory, `${locale}.md`), 'utf8')).trim();
|
||||
if (text) notes[locale] = `${text}\n`;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function validateReleaseNotes(notes) {
|
||||
if (!notes || typeof notes !== 'object' || Array.isArray(notes)) {
|
||||
throw new Error('Release notes must be an object keyed by locale');
|
||||
}
|
||||
for (const [locale, text] of Object.entries(notes)) {
|
||||
if (!releaseNotesLanguages.some((language) => language.locale === locale)
|
||||
|| typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error(`Invalid release notes for locale: ${locale}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseNotesBody(notes) {
|
||||
validateReleaseNotes(notes);
|
||||
return `${releaseNotesLanguages.map(({ locale, label, missing }) => (
|
||||
`# ${label}\n\n${notes[locale]?.trim() || missing}`
|
||||
)).join('\n\n---\n\n')}\n`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 2) {
|
||||
args.set(process.argv[index], process.argv[index + 1]);
|
||||
}
|
||||
if (!args.get('--output') || !args.get('--body-output')) {
|
||||
throw new Error('--output and --body-output are required');
|
||||
}
|
||||
const notes = await resolveReleaseNotes({ tag: args.get('--tag') });
|
||||
await writeFile(resolve(args.get('--output')), `${JSON.stringify(notes, null, 2)}\n`, 'utf8');
|
||||
await writeFile(resolve(args.get('--body-output')), releaseNotesBody(notes), 'utf8');
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -3,6 +3,28 @@ use super::*;
|
||||
pub(crate) const PORTABLE_UPDATE_HELPER_ACK_FILE: &str = "update-helper-started.ack";
|
||||
const PORTABLE_UPDATE_HELPER_START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) fn deserialize_release_notes<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<HashMap<String, String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
// Untagged legacy text and invalid entries cannot identify a translation.
|
||||
// Treat them as unavailable instead of failing the entire version check.
|
||||
let mut notes = HashMap::new();
|
||||
if let Some(translations) = value.as_object() {
|
||||
for locale in ["zh-CN", "zh-TW", "en", "ja"] {
|
||||
if let Some(text) = translations.get(locale).and_then(|value| value.as_str()) {
|
||||
if !text.trim().is_empty() {
|
||||
notes.insert(locale.to_string(), text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(notes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_version_source_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
@@ -204,6 +226,8 @@ pub(crate) async fn check_app_update(
|
||||
latest_version,
|
||||
update_available,
|
||||
release_url: manifest.release_url,
|
||||
release_notes: manifest.release_notes,
|
||||
published_at: manifest.published_at,
|
||||
auto_update_supported,
|
||||
download_size_bytes: asset.map(|value| value.size_bytes),
|
||||
unsupported_reason,
|
||||
|
||||
@@ -51,7 +51,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
collections::{HashMap, HashSet},
|
||||
env, fs,
|
||||
fs::File,
|
||||
io::{self, Read, Seek, SeekFrom, Write},
|
||||
@@ -460,6 +460,8 @@ struct AppUpdateInfo {
|
||||
latest_version: String,
|
||||
update_available: bool,
|
||||
release_url: String,
|
||||
release_notes: HashMap<String, String>,
|
||||
published_at: String,
|
||||
auto_update_supported: bool,
|
||||
download_size_bytes: Option<u64>,
|
||||
unsupported_reason: Option<String>,
|
||||
@@ -472,6 +474,8 @@ struct PortableUpdateManifest {
|
||||
version: String,
|
||||
published_at: String,
|
||||
release_url: String,
|
||||
#[serde(default, deserialize_with = "deserialize_release_notes")]
|
||||
release_notes: HashMap<String, String>,
|
||||
assets: std::collections::HashMap<String, PortableUpdateAsset>,
|
||||
#[serde(default)]
|
||||
full_assets: Option<std::collections::HashMap<String, PortableUpdateAsset>>,
|
||||
|
||||
@@ -79,6 +79,7 @@ fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest {
|
||||
schema_version: 1,
|
||||
version: version.to_string(),
|
||||
published_at: "2026-07-24T00:00:00.000Z".to_string(),
|
||||
release_notes: HashMap::new(),
|
||||
release_url: format!(
|
||||
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v{version}"
|
||||
),
|
||||
@@ -98,6 +99,40 @@ fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_manifest_uses_only_explicit_locale_release_notes() {
|
||||
let mut value = serde_json::json!({
|
||||
"schemaVersion": 1,
|
||||
"version": "1.2.3",
|
||||
"publishedAt": "2026-09-19T00:00:00Z",
|
||||
"releaseUrl": "https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v1.2.3",
|
||||
"assets": {}
|
||||
});
|
||||
let legacy: PortableUpdateManifest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(legacy.release_notes.is_empty());
|
||||
value["releaseNotes"] = serde_json::json!({
|
||||
"zh-CN": "## 新增\n\n- 更新说明。\n",
|
||||
"en": "## Added\n\n- Release notes.\n",
|
||||
"ja": null,
|
||||
"zh-TW": " ",
|
||||
"fr": "Unsupported locale"
|
||||
});
|
||||
let with_notes: PortableUpdateManifest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(
|
||||
with_notes.release_notes.get("zh-CN").map(String::as_str),
|
||||
Some("## 新增\n\n- 更新说明。\n")
|
||||
);
|
||||
assert_eq!(with_notes.release_notes.len(), 2);
|
||||
assert!(with_notes.release_notes.contains_key("en"));
|
||||
assert!(!with_notes.release_notes.contains_key("ja"));
|
||||
assert!(!with_notes.release_notes.contains_key("zh-TW"));
|
||||
for unlocalized in [serde_json::Value::Null, serde_json::json!("Legacy notes")] {
|
||||
value["releaseNotes"] = unlocalized;
|
||||
let unavailable: PortableUpdateManifest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(unavailable.release_notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_manifest_requires_both_matching_github_assets() {
|
||||
let manifest = portable_update_test_manifest("1.2.3");
|
||||
|
||||
+3
-1
@@ -12,13 +12,15 @@ import {
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { getCurrentLocale, translate, useI18n } from './i18n';
|
||||
import { getCurrentLocale, translate, useI18n, type AppLocale } from './i18n';
|
||||
|
||||
export type AppUpdateInfo = {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
releaseUrl: string;
|
||||
releaseNotes: Partial<Record<AppLocale, string>> | null;
|
||||
publishedAt: string;
|
||||
autoUpdateSupported: boolean;
|
||||
downloadSizeBytes: number | null;
|
||||
unsupportedReason: string | null;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { ChevronDown, ExternalLink, FileText } from 'lucide-react';
|
||||
import Markdown from 'react-markdown';
|
||||
import type { AppUpdateInfo } from '../appUpdate';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type Props = {
|
||||
info: Pick<AppUpdateInfo, 'latestVersion' | 'releaseNotes' | 'publishedAt'> | null;
|
||||
checking: boolean;
|
||||
failed: boolean;
|
||||
onOpenUrl: (url?: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function releaseNotesUrl(value: string): string {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function AppReleaseNotes({ info, checking, failed, onOpenUrl }: Props) {
|
||||
const { t, formatDate, locale } = useI18n();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const contentId = useId();
|
||||
const headingId = useId();
|
||||
const notes = info?.releaseNotes?.[locale]?.trim();
|
||||
const publishedAt = info?.publishedAt && Number.isFinite(Date.parse(info.publishedAt))
|
||||
? info.publishedAt
|
||||
: null;
|
||||
const emptyMessage = info
|
||||
? t('appUpdate.notes.empty')
|
||||
: checking
|
||||
? t('appUpdate.notes.loading')
|
||||
: failed
|
||||
? t('appUpdate.notes.failed')
|
||||
: t('appUpdate.notes.notChecked');
|
||||
|
||||
return (
|
||||
<section className="app-release-notes" aria-labelledby={headingId}>
|
||||
<header className="app-release-notes-heading">
|
||||
<div>
|
||||
<FileText size={17} aria-hidden="true" />
|
||||
<h2 id={headingId}>{t('appUpdate.notes.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-release-notes-toggle"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
{t(expanded ? 'appUpdate.notes.collapse' : 'appUpdate.notes.expand')}
|
||||
<ChevronDown size={15} className={expanded ? 'is-expanded' : ''} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div id={contentId} hidden={!expanded}>
|
||||
<div className="app-release-notes-body">
|
||||
{info ? (
|
||||
<div className="app-release-notes-meta">
|
||||
<strong>{`v${info.latestVersion.replace(/^v/i, '')}`}</strong>
|
||||
<span className="app-release-notes-latest">{t('appUpdate.latest')}</span>
|
||||
{publishedAt ? (
|
||||
<time dateTime={publishedAt}>
|
||||
{t('appUpdate.notes.publishedAt', {
|
||||
date: formatDate(publishedAt, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
})}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{notes ? (
|
||||
<div className="app-release-notes-markdown" lang={locale}>
|
||||
<Markdown
|
||||
skipHtml
|
||||
disallowedElements={['img']}
|
||||
urlTransform={releaseNotesUrl}
|
||||
components={{
|
||||
h1: ({ children }) => <h3 className="release-notes-subtitle">{children}</h3>,
|
||||
h2: ({ children }) => <h3 className="release-notes-category">{children}</h3>,
|
||||
a: ({ href, children }) => href ? (
|
||||
<a href={href} onClick={(event) => { event.preventDefault(); void onOpenUrl(href); }}>
|
||||
{children}
|
||||
</a>
|
||||
) : <span>{children}</span>,
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="app-release-notes-empty" role="status">{emptyMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
<footer className="app-release-notes-footer">
|
||||
<button type="button" className="app-release-notes-toggle" onClick={() => void onOpenUrl()}>
|
||||
{t('appUpdate.notes.openRelease')}
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1394,6 +1394,15 @@ export const jaOverrides = {
|
||||
'appUpdate.catalog.unchanged': 'モデルカタログは最新です',
|
||||
'appUpdate.installNow': '今すぐ更新',
|
||||
'appUpdate.openRelease': 'ダウンロードページを開く',
|
||||
'appUpdate.notes.title': 'ソフトウェアの更新内容',
|
||||
'appUpdate.notes.expand': '展開',
|
||||
'appUpdate.notes.collapse': '折りたたむ',
|
||||
'appUpdate.notes.publishedAt': '{date}公開',
|
||||
'appUpdate.notes.openRelease': 'リリースノート全体を見る',
|
||||
'appUpdate.notes.empty': '現在の言語の更新内容を取得できませんでした。',
|
||||
'appUpdate.notes.loading': '更新内容を取得中…',
|
||||
'appUpdate.notes.failed': '更新内容を取得できませんでした。後でもう一度確認してください。',
|
||||
'appUpdate.notes.notChecked': 'ソフトウェアの更新を確認すると、最新版の更新内容が表示されます。',
|
||||
'appUpdate.confirmTitle': 'アプリ更新をインストール',
|
||||
'appUpdate.confirmDescription': '{version} をダウンロードしてインストールします。アプリは再起動し、管理中のコアは一時停止後に復帰します。',
|
||||
'appUpdate.progressTitle': 'アプリを更新中',
|
||||
|
||||
@@ -1394,6 +1394,15 @@ export const en: Record<MessageKey, string> = {
|
||||
'appUpdate.catalog.unchanged': 'Model catalog is already up to date',
|
||||
'appUpdate.installNow': 'Update Now',
|
||||
'appUpdate.openRelease': 'Open Download Page',
|
||||
'appUpdate.notes.title': 'Release notes',
|
||||
'appUpdate.notes.expand': 'Expand',
|
||||
'appUpdate.notes.collapse': 'Collapse',
|
||||
'appUpdate.notes.publishedAt': 'Published {date}',
|
||||
'appUpdate.notes.openRelease': 'View full release notes',
|
||||
'appUpdate.notes.empty': 'Release notes are not available in the current language.',
|
||||
'appUpdate.notes.loading': 'Loading release notes…',
|
||||
'appUpdate.notes.failed': 'Unable to load release notes. Please check again later.',
|
||||
'appUpdate.notes.notChecked': 'Check for software updates to see the latest release notes.',
|
||||
'appUpdate.confirmTitle': 'Install Application Update',
|
||||
'appUpdate.confirmDescription': 'Version {version} will be downloaded and installed. The app will restart, and a managed core will briefly stop and resume.',
|
||||
'appUpdate.progressTitle': 'Updating Application',
|
||||
|
||||
@@ -1393,6 +1393,15 @@ export const zhCN = {
|
||||
'appUpdate.catalog.unchanged': '模型目录已是最新版本',
|
||||
'appUpdate.installNow': '立即升级',
|
||||
'appUpdate.openRelease': '打开下载页面',
|
||||
'appUpdate.notes.title': '软件更新说明',
|
||||
'appUpdate.notes.expand': '展开',
|
||||
'appUpdate.notes.collapse': '收起',
|
||||
'appUpdate.notes.publishedAt': '{date}发布',
|
||||
'appUpdate.notes.openRelease': '查看完整发布说明',
|
||||
'appUpdate.notes.empty': '暂未获取到当前语言的更新说明。',
|
||||
'appUpdate.notes.loading': '正在获取更新说明…',
|
||||
'appUpdate.notes.failed': '暂时无法获取更新说明,请稍后重新检查。',
|
||||
'appUpdate.notes.notChecked': '检查软件更新后,将显示最新版的更新说明。',
|
||||
'appUpdate.confirmTitle': '安装软件更新',
|
||||
'appUpdate.confirmDescription': '将下载并安装 {version}。应用会自动重启,正在运行的托管内核会短暂停止并恢复。',
|
||||
'appUpdate.progressTitle': '正在升级软件',
|
||||
|
||||
+12
-1
@@ -7,5 +7,16 @@ export { en, zhCN };
|
||||
export type { MessageKey };
|
||||
export type MessageVariables = Record<string, string | number>;
|
||||
|
||||
export const zhTW: Record<MessageKey, string> = createTraditionalMessages(zhCN);
|
||||
export const zhTW: Record<MessageKey, string> = {
|
||||
...createTraditionalMessages(zhCN),
|
||||
'appUpdate.notes.title': '軟體更新說明',
|
||||
'appUpdate.notes.expand': '展開',
|
||||
'appUpdate.notes.collapse': '收合',
|
||||
'appUpdate.notes.publishedAt': '{date}發布',
|
||||
'appUpdate.notes.openRelease': '查看完整發布說明',
|
||||
'appUpdate.notes.empty': '尚未取得目前語言的更新說明。',
|
||||
'appUpdate.notes.loading': '正在取得更新說明…',
|
||||
'appUpdate.notes.failed': '暫時無法取得更新說明,請稍後重新檢查。',
|
||||
'appUpdate.notes.notChecked': '檢查軟體更新後,將顯示最新版的更新說明。',
|
||||
};
|
||||
export const ja: Record<MessageKey, string> = jaOverrides;
|
||||
@@ -16,6 +16,7 @@ import { useI18n } from '../i18n';
|
||||
import { useAppUpdate } from '../appUpdate';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { createVersionManagementVisitTracker } from '../services/versionManagementVisits';
|
||||
import { AppReleaseNotes } from '../components/AppReleaseNotes';
|
||||
|
||||
export type CoreInstallResult = {
|
||||
version: string;
|
||||
@@ -319,9 +320,9 @@ export function VersionManagementPage() {
|
||||
setCancellingInstall(false);
|
||||
};
|
||||
|
||||
const openAppRelease = async () => {
|
||||
const openAppRelease = async (url = appUpdate?.releaseUrl || APP_RELEASE_URL) => {
|
||||
try {
|
||||
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
|
||||
await invoke('open_external_url', { url });
|
||||
} catch (error) {
|
||||
showNotice({ key: 'kernel.error.openUpdate', variables: { error: String(error) } }, 'error');
|
||||
}
|
||||
@@ -711,6 +712,13 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<AppReleaseNotes
|
||||
key={appUpdate?.latestVersion ?? 'pending'}
|
||||
info={appUpdate}
|
||||
checking={checkingAppUpdate}
|
||||
failed={Boolean(appUpdateError)}
|
||||
onOpenUrl={openAppRelease}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{customMirrorDialogOpen ? (
|
||||
|
||||
+176
-1
@@ -1124,7 +1124,8 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.version-source-row,
|
||||
.version-list-item {
|
||||
.version-list-item,
|
||||
.app-release-notes {
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 14px;
|
||||
background: var(--theme-fffdf8);
|
||||
@@ -1382,6 +1383,180 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-release-notes {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-release-notes-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 20px 26px;
|
||||
}
|
||||
|
||||
.app-release-notes-heading > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-release-notes-heading h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-panel-title);
|
||||
}
|
||||
|
||||
.app-release-notes-heading svg {
|
||||
flex: none;
|
||||
color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.app-release-notes-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 3px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-6d6760);
|
||||
font-size: var(--font-size-meta);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-release-notes-toggle:hover {
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.app-release-notes-toggle .is-expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.app-release-notes-body {
|
||||
padding: 0 26px 20px;
|
||||
}
|
||||
|
||||
.app-release-notes-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--theme-e8e5dc);
|
||||
}
|
||||
|
||||
.app-release-notes-meta strong {
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
font-size: var(--font-size-panel-title);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-release-notes-latest {
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
background: var(--theme-dce8df);
|
||||
color: var(--theme-2f6b3f);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.app-release-notes-meta time {
|
||||
margin-left: auto;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.app-release-notes-markdown {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, auto) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 18px 22px;
|
||||
margin-top: 20px;
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown > * {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown > .release-notes-category {
|
||||
grid-column: 1;
|
||||
max-width: 160px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 5px;
|
||||
background: var(--theme-faf9f5);
|
||||
color: var(--theme-6d6760);
|
||||
font-size: var(--font-size-meta);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown > .release-notes-subtitle {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown ul,
|
||||
.app-release-notes-markdown ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown li + li {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown li p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown a {
|
||||
color: var(--theme-3f6f98);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.app-release-notes-markdown pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf9f5);
|
||||
}
|
||||
|
||||
.app-release-notes-markdown blockquote {
|
||||
border-left: 3px solid var(--theme-e3e1db);
|
||||
padding-left: 14px;
|
||||
color: var(--theme-6d6760);
|
||||
}
|
||||
|
||||
.app-release-notes-empty {
|
||||
margin: 18px 0 0;
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.app-release-notes-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 26px;
|
||||
border-top: 1px solid var(--theme-e8e5dc);
|
||||
background: var(--theme-faf9f5);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-release-notes-heading { padding: 16px; }
|
||||
.app-release-notes-body { padding: 0 16px 16px; }
|
||||
.app-release-notes-footer { padding: 10px 16px; }
|
||||
.app-release-notes-markdown { gap: 14px 12px; }
|
||||
.app-release-notes-markdown > .release-notes-category { max-width: 80px; }
|
||||
.app-release-notes-meta time { width: 100%; margin-left: 0; }
|
||||
}
|
||||
|
||||
.client-api-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AppReleaseNotes, releaseNotesUrl } from '../src/components/AppReleaseNotes';
|
||||
import { I18nProvider, supportedLocales, translate, type AppLocale } from '../src/i18n';
|
||||
|
||||
const translations = {
|
||||
'zh-CN': '## 新增\n\n- 支持 **更新说明**。\n',
|
||||
'zh-TW': '## 新增\n\n- 支援 **更新說明**。\n',
|
||||
en: '## Added\n\n- Supports **release notes**.\n',
|
||||
ja: '## 新機能\n\n- **更新内容**を表示します。\n',
|
||||
};
|
||||
const info = {
|
||||
latestVersion: '0.2.98',
|
||||
publishedAt: '2026-09-19T00:00:00Z',
|
||||
releaseNotes: translations,
|
||||
};
|
||||
|
||||
function renderNotes(overrides: Partial<Parameters<typeof AppReleaseNotes>[0]> = {}, locale: AppLocale = 'zh-CN') {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
// The provider reads the saved UI language on mount; effects do not run during SSR.
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true, value: { localStorage: { getItem: () => locale } },
|
||||
});
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider>
|
||||
<AppReleaseNotes info={info} checking={false} failed={false} onOpenUrl={() => {}} {...overrides} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
} finally {
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
}
|
||||
|
||||
describe('localized release notes display', () => {
|
||||
test('renders versioned Markdown with date and release link without requiring an available update', () => {
|
||||
const html = renderNotes();
|
||||
expect(html).toContain('v0.2.98');
|
||||
expect(html).toMatch(/<time datetime="2026-09-19T00:00:00Z"/i);
|
||||
expect(html).toContain('release-notes-category');
|
||||
expect(html).toContain('<strong>更新说明</strong>');
|
||||
expect(html).toContain('查看完整发布说明');
|
||||
expect(html).toContain('aria-expanded="true"');
|
||||
});
|
||||
|
||||
test.each(supportedLocales)('renders only the %s translation', (locale) => {
|
||||
const html = renderNotes({}, locale);
|
||||
const body = html.split('app-release-notes-markdown')[1].split('</footer>')[0];
|
||||
expect(body).toContain('lang="' + locale + '"');
|
||||
const expected = { 'zh-CN': '更新说明', 'zh-TW': '更新說明', en: 'release notes', ja: '更新内容' };
|
||||
for (const [language, text] of Object.entries(expected)) {
|
||||
if (language === locale) expect(body).toContain('<strong>' + text + '</strong>');
|
||||
else expect(body).not.toContain('<strong>' + text + '</strong>');
|
||||
}
|
||||
});
|
||||
|
||||
test.each(supportedLocales)('shows a localized message when %s is missing, even if all other languages exist', (locale) => {
|
||||
const remaining: Partial<Record<AppLocale, string>> = { ...translations };
|
||||
delete remaining[locale];
|
||||
for (const releaseNotes of [remaining, { ...remaining, [locale]: ' \n' }, {}, null]) {
|
||||
const html = renderNotes({ info: { ...info, releaseNotes } }, locale);
|
||||
expect(html).toContain(translate(locale, 'appUpdate.notes.empty'));
|
||||
expect(html).not.toContain('app-release-notes-markdown');
|
||||
}
|
||||
});
|
||||
|
||||
test('distinguishes loading and failure states', () => {
|
||||
expect(renderNotes({ info: null, checking: true })).toContain('正在获取更新说明');
|
||||
expect(renderNotes({ info: null, failed: true })).toContain('暂时无法获取更新说明');
|
||||
expect(renderNotes({ info: null })).toContain('检查软件更新后');
|
||||
expect(renderNotes({ info: { ...info, publishedAt: 'invalid' } })).not.toContain('<time');
|
||||
});
|
||||
|
||||
test('uses fully localized Traditional Chinese labels and missing-content notice', () => {
|
||||
expect(renderNotes({}, 'zh-TW')).toContain('軟體更新說明');
|
||||
expect(renderNotes({ info: { ...info, releaseNotes: {} } }, 'zh-TW'))
|
||||
.toContain('尚未取得目前語言的更新說明。');
|
||||
});
|
||||
|
||||
test('does not render remote HTML, images, or executable links', () => {
|
||||
const html = renderNotes({ info: { ...info, releaseNotes: { 'zh-CN':
|
||||
'<script>alert(1)</script>\n\n<img src="https://example.com/raw.png" onerror="alert(2)">\n\n'
|
||||
+ '\n\n[unsafe](javascript:alert%281%29)\n\n'
|
||||
+ '[release](https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v0.2.98)',
|
||||
} } });
|
||||
expect(html).not.toContain('<script');
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('javascript:');
|
||||
expect(html).not.toContain('tracking.png');
|
||||
expect(html).toContain('href="https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v0.2.98"');
|
||||
});
|
||||
|
||||
test('only permits web links for the native opener', () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///C:/test', 'data:text/html,hi', '/relative', '#section']) {
|
||||
expect(releaseNotesUrl(url)).toBe('');
|
||||
}
|
||||
expect(releaseNotesUrl('https://example.com/notes')).toBe('https://example.com/notes');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ test('macOS manifest CLI publishes both update channels', async () => {
|
||||
);
|
||||
}
|
||||
const script = fileURLToPath(new URL('../scripts/manifest.mjs', import.meta.url));
|
||||
const notes = { 'zh-CN': '## 新增\n\n- 软件更新说明。\n', en: '## Added\n\n- Release notes.\n' };
|
||||
const notesPath = join(root, 'release-notes.json');
|
||||
await writeFile(notesPath, JSON.stringify(notes), 'utf8');
|
||||
const result = spawnSync('node', [
|
||||
script,
|
||||
'--directory',
|
||||
@@ -42,6 +45,8 @@ test('macOS manifest CLI publishes both update channels', async () => {
|
||||
'router-for-me/EasyCLIProxyAPI',
|
||||
'--tag',
|
||||
'v1.2.3',
|
||||
'--release-notes',
|
||||
notesPath,
|
||||
], { encoding: 'utf8' });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
@@ -53,6 +58,8 @@ test('macOS manifest CLI publishes both update channels', async () => {
|
||||
await readFile(join(root, 'portable-update-darwin.json'), 'utf8'),
|
||||
);
|
||||
expect(legacy).toEqual(primary);
|
||||
expect(primary.releaseNotes).toEqual(notes);
|
||||
expect(primary.releaseNotes.ja).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { releaseNotesBody, resolveReleaseNotes, validateReleaseNotes } from '../scripts/release-notes.mjs';
|
||||
|
||||
async function withNotesRoot(run: (root: string, directory: string) => Promise<void>) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'easycli-release-notes-'));
|
||||
const directory = join(root, 'docs', 'release-notes', 'v1.2.3');
|
||||
try {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await run(root, directory);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('localized release notes publication', () => {
|
||||
test('keeps each authored translation under its exact locale', () => withNotesRoot(async (root, directory) => {
|
||||
const translations = {
|
||||
'zh-CN': '## 新增\n\n- 更新说明。\n',
|
||||
'zh-TW': '## 新增\n\n- 更新說明。\n',
|
||||
en: '## Added\n\n- Release notes.\n',
|
||||
ja: '## 新機能\n\n- 更新内容。\n',
|
||||
};
|
||||
for (const [locale, body] of Object.entries(translations)) {
|
||||
await writeFile(join(directory, locale + '.md'), body);
|
||||
}
|
||||
expect(await resolveReleaseNotes({ root, tag: '1.2.3' })).toEqual(translations);
|
||||
const body = releaseNotesBody(translations);
|
||||
for (const text of Object.values(translations)) expect(body).toContain(text.trim());
|
||||
}));
|
||||
|
||||
test('never copies another language into a missing or blank translation', () => withNotesRoot(async (root, directory) => {
|
||||
await writeFile(join(directory, 'zh-CN.md'), '## 新增\n\n- 中文独有正文。\n');
|
||||
await writeFile(join(directory, 'en.md'), ' \n');
|
||||
const notes = await resolveReleaseNotes({ root, tag: 'v1.2.3' });
|
||||
expect(notes).toEqual({ 'zh-CN': '## 新增\n\n- 中文独有正文。\n' });
|
||||
const englishSection = releaseNotesBody(notes).split('# English\n\n')[1].split('\n\n---')[0];
|
||||
expect(englishSection).toBe('Release notes for this version are not available in English.');
|
||||
expect(englishSection).not.toContain('中文独有正文');
|
||||
}));
|
||||
|
||||
test('missing versions and legacy flat files produce no translations', () => withNotesRoot(async (root) => {
|
||||
await writeFile(join(root, 'docs', 'release-notes', 'v1.2.3.md'), 'Legacy single-language text');
|
||||
expect(await resolveReleaseNotes({ root, tag: 'v1.2.3' })).toEqual({});
|
||||
expect(await resolveReleaseNotes({ root, tag: 'v9.9.9' })).toEqual({});
|
||||
expect(releaseNotesBody({})).not.toContain('Legacy single-language text');
|
||||
}));
|
||||
|
||||
test('validates localized maps without requiring every translation', () => {
|
||||
expect(() => validateReleaseNotes({})).not.toThrow();
|
||||
expect(() => validateReleaseNotes({ en: 'English only' })).not.toThrow();
|
||||
for (const invalid of ['Legacy text', null, [], { en: 123 }, { ja: ' ' }, { fr: 'Other language' }]) {
|
||||
expect(() => validateReleaseNotes(invalid)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI creates matching localized JSON and multilingual Release body', () => withNotesRoot(async (root, directory) => {
|
||||
await writeFile(join(directory, 'en.md'), '## Added\n\n- Release notes.\n');
|
||||
const script = fileURLToPath(new URL('../scripts/release-notes.mjs', import.meta.url));
|
||||
const jsonPath = join(root, 'notes.json');
|
||||
const bodyPath = join(root, 'notes.md');
|
||||
const result = spawnSync('node', [script, '--tag', 'v1.2.3', '--output', jsonPath, '--body-output', bodyPath], {
|
||||
cwd: root, encoding: 'utf8',
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
const notes = JSON.parse(await readFile(jsonPath, 'utf8'));
|
||||
expect(notes).toEqual({ en: '## Added\n\n- Release notes.\n' });
|
||||
expect(await readFile(bodyPath, 'utf8')).toBe(releaseNotesBody(notes));
|
||||
}));
|
||||
|
||||
test('rejects tags that could escape the release notes directory', () => withNotesRoot(async (root) => {
|
||||
await expect(resolveReleaseNotes({ root, tag: '../../README' })).rejects.toThrow('Invalid release tag');
|
||||
}));
|
||||
});
|
||||
Reference in new issue
Block a user