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:
lzt404 committed 2026-09-19 20:42:30 +08:00
1 parent c9c0c4cf1c
commit bac67c1524
25 files changed
+888 -9

No files matched your search

+14 -2
View File
@@ -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
+2
View File
@@ -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 账号授权
![OAuth 账号授权](docs/screenshots/zh-CN/2.png)
+159
View File
@@ -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=="],
}
}
+37
View File
@@ -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 报错,以免静默发布错误内容。
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
## 新機能
- バージョン管理ページに更新内容を追加しました。最新版の変更点と公開日を確認できます。
- 更新内容の展開と折りたたみに対応し、リリースページへのリンクを追加しました。
- 更新内容は画面の言語に合わせて表示されます。その言語の内容がない場合はメッセージを表示します。
+5
View File
@@ -0,0 +1,5 @@
## 新增
- 版本管理页新增软件更新说明,可直接查看最新版的更新内容和发布日期。
- 更新说明支持展开与收起,并提供完整发布说明入口。
- 更新说明随界面语言切换;当前语言的内容暂缺时直接显示提示。
+5
View File
@@ -0,0 +1,5 @@
## 新增
- 版本管理頁新增軟體更新說明,可直接查看最新版的更新內容和發布日期。
- 更新說明支援展開與收合,並提供完整發布說明入口。
- 更新說明隨介面語言切換;缺少目前語言的內容時會直接顯示提示。
+2 -1
View File
@@ -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",
+8
View File
@@ -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(
+63
View File
@@ -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();
}
+24
View File
@@ -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,
+5 -1
View File
@@ -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>>,
+35
View File
@@ -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
View File
@@ -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;
+105
View File
@@ -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>
);
}
+9
View File
@@ -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': 'アプリを更新中',
+9
View File
@@ -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',
+9
View File
@@ -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
View File
@@ -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;
+10 -2
View File
@@ -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
View File
@@ -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;
}
+100
View File
@@ -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'
+ '![tracking](https://example.com/tracking.png)\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');
});
});
+7
View File
@@ -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 });
}
+79
View File
@@ -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');
}));
});