refactor(docs): [SITE-AI-DOCS-01] make generation offline and translation draft based

This commit is contained in:
Harvey Zhao committed 2026-09-14 08:22:44 +08:00
1 parent 647f3e7f12
commit 078cd9ef72
27 files changed
+8023 -3644

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
{
"schemaVersion": 1,
"generation": "offline-source-preserving",
"sources": [
{
"file": "packages/artplayer-vitepress/docs/en/advanced/built-in.md",
"sha256Lf": "5d56bce1f12b10363643edb8f655bbda1ed656b9646561c2e422ca9eb9a7b055"
},
{
"file": "packages/artplayer-vitepress/docs/en/advanced/class.md",
"sha256Lf": "57ae1273515faa31ae28672f80a44d7b6f4aee918b59b814c638e3fd311f56ba"
},
{
"file": "packages/artplayer-vitepress/docs/en/advanced/event.md",
"sha256Lf": "e11360c89bc91ed4f68ce9a40d335c1726ef006a23950d5d9ce4475789aecb7e"
},
{
"file": "packages/artplayer-vitepress/docs/en/advanced/global.md",
"sha256Lf": "01380ae04fedd94dd91f5f31c51d4894c458dad3268cf38119b54a8fe73ee46c"
},
{
"file": "packages/artplayer-vitepress/docs/en/advanced/plugin.md",
"sha256Lf": "b6defaab71f22becbc8d3141a2b2ac746c9fbe24eba31326837c2e8ce8b59057"
},
{
"file": "packages/artplayer-vitepress/docs/en/advanced/property.md",
"sha256Lf": "041d33b8d81cbb928ea07504885ceccab4becf3476bf82120013d5f2840c31b7"
},
{
"file": "packages/artplayer-vitepress/docs/en/component/contextmenu.md",
"sha256Lf": "df86b1d08d01288e68fa861e3f645109d6305868785a9005906c95dc86280f1f"
},
{
"file": "packages/artplayer-vitepress/docs/en/component/controls.md",
"sha256Lf": "cd516c25f6d27c52da58e41dd98b360975f1e43b1d42b460ea3a2e21c6a3c2b2"
},
{
"file": "packages/artplayer-vitepress/docs/en/component/layers.md",
"sha256Lf": "bd8a5d0b996159b1fb563caa9f74cce3bcf97c4bca9eea99f1cd75ceacc6a0d3"
},
{
"file": "packages/artplayer-vitepress/docs/en/component/setting.md",
"sha256Lf": "652e85b02f9de60ff794cb3527a3e4ae514a3441cacebf340c1a82d09ce06f08"
},
{
"file": "packages/artplayer-vitepress/docs/en/index.md",
"sha256Lf": "adec563b74e5d690f97a710bf58478d04092f0de199674152e68da817f412ee5"
},
{
"file": "packages/artplayer-vitepress/docs/en/start/i18n.md",
"sha256Lf": "1551bf939aab1ee942d8480df891640109d18aa9adc016adf1d075bee5ef1de9"
},
{
"file": "packages/artplayer-vitepress/docs/en/start/option.md",
"sha256Lf": "72776482f31a3240462d72d56912b2e9f96583a12f36b6556abdf379ca1d3fdf"
},
{
"file": "docs/assets/ts/artplayer-plugin-ads.d.ts",
"sha256Lf": "bf128828d5b104fc844a4f134a02162e57472d8c927a50df7b3878f4c5ed7652"
},
{
"file": "docs/assets/ts/artplayer-plugin-ambilight.d.ts",
"sha256Lf": "9f06ccb7398053dca642ca369aa47f54cbb3e4dd4d5e252e999e59e496d8eee8"
},
{
"file": "docs/assets/ts/artplayer-plugin-asr.d.ts",
"sha256Lf": "5096f097636c018bc05c497324c271af3fefaebccda7fa3e9cd43b63477704ad"
},
{
"file": "docs/assets/ts/artplayer-plugin-audio-track.d.ts",
"sha256Lf": "604af2a3790eb7c583ba150c402dca0a3e458469cc74913b51fee2f3d3882541"
},
{
"file": "docs/assets/ts/artplayer-plugin-auto-thumbnail.d.ts",
"sha256Lf": "f66bba7f633202c2ef359a989269c643b75d91594364f7ed6fb67f9898aeac03"
},
{
"file": "docs/assets/ts/artplayer-plugin-chapter.d.ts",
"sha256Lf": "d5bd68579659afa44158c894e12372cedd6f958415ced370a65c294550aa828d"
},
{
"file": "docs/assets/ts/artplayer-plugin-chromecast.d.ts",
"sha256Lf": "0a0b932081cac0efbcedea28dc1077484963b7f406808378704062661a4c0338"
},
{
"file": "docs/assets/ts/artplayer-plugin-danmuku-mask.d.ts",
"sha256Lf": "06c6e938876edcc0ac2c8dcd1e1be4596b0f056685a3523d4c9836f2f2f90362"
},
{
"file": "docs/assets/ts/artplayer-plugin-danmuku.d.ts",
"sha256Lf": "e3421bdbdfc6bea1b0b633300350fff1a745d8bc21c1430b6154a23fa3ad0415"
},
{
"file": "docs/assets/ts/artplayer-plugin-dash-control.d.ts",
"sha256Lf": "c7e3c83f3377b615e503a270af12915dcc82fe3f2cfabdae396632855a27f73f"
},
{
"file": "docs/assets/ts/artplayer-plugin-document-pip.d.ts",
"sha256Lf": "42faa0a6549149efd01b46c3c59246bd4dc33f2383b63b04e9f5376de29fdb8e"
},
{
"file": "docs/assets/ts/artplayer-plugin-hls-control.d.ts",
"sha256Lf": "6a623bb6ba4cc0598a3dd1aab5ef7ca4fbae11973a787885eccba10cc7aa2c7c"
},
{
"file": "docs/assets/ts/artplayer-plugin-jassub.d.ts",
"sha256Lf": "ce713e0a61a81bfc31087ea1a880a99398ef7c2b833e567c91e4e98f5ae6f621"
},
{
"file": "docs/assets/ts/artplayer-plugin-multiple-subtitles.d.ts",
"sha256Lf": "edb15207df69a695311e849555cf989bfc32da20c2456731b6814de5400567bb"
},
{
"file": "docs/assets/ts/artplayer-plugin-vast.d.ts",
"sha256Lf": "b21ed9a852b3ec3cc9b409cce08e91905e42abdc1d642acc9b412c33c83287b7"
},
{
"file": "docs/assets/ts/artplayer-plugin-vtt-thumbnail.d.ts",
"sha256Lf": "d82c0fe08bcf9c60e2a634e9f1fd8ccc75dc8e0ddbfa8262cccaefc9604aa61b"
},
{
"file": "docs/assets/ts/artplayer-proxy-canvas.d.ts",
"sha256Lf": "83c408e0724a598f9ff32d68d752f39e03feb69ba4f6a49ba02f9d4df898453e"
},
{
"file": "docs/assets/ts/artplayer-proxy-mediabunny.d.ts",
"sha256Lf": "acbaa0a25f20ac15b3d79db00b3a16167f84452ab58933110f42dcb3262fbd3e"
},
{
"file": "docs/assets/ts/artplayer-tool-iframe.d.ts",
"sha256Lf": "c06c6819146ba64bd6ee498871d9e19ab5a9e88c9cc7bed2c0c4968fa39dfed7"
},
{
"file": "docs/assets/ts/artplayer-tool-thumbnail.d.ts",
"sha256Lf": "9b72d10a1fe964d54fcf4774770074058ea739b4f734b52b8f4adebb37fc83e6"
},
{
"file": "docs/assets/ts/artplayer.d.ts",
"sha256Lf": "652c0fdf605e87203526777a05851bd69c95c1660d5ff29eab19c00967c9a985"
},
{
"file": "docs/assets/ts/artplayer-i18n.d.ts",
"sha256Lf": "60851e22ea50ed540f1be0f5a110077cdd5e6ab87345a675838f6c6333517405"
},
{
"file": "docs/assets/example/ads.js",
"sha256Lf": "c84a97d2b1970c323a0c2538b846312bff38513e10de271fe5f239953da88cd3"
},
{
"file": "docs/assets/example/ambilight.js",
"sha256Lf": "f97c1404886ba6474b4e14339c563fd2de50b7d50fdc4c9d59d85a3142a1ad52"
},
{
"file": "docs/assets/example/asr.js",
"sha256Lf": "bb7fd5eee3df887b7e0c72a6f42f74f0253350f9bc884f73cb9c2996f0e35724"
},
{
"file": "docs/assets/example/asr.local.js",
"sha256Lf": "32371e51ac5f55edaa146002e7e4594ec056bf198db8b1e62e6d68ec2ea48f30"
},
{
"file": "docs/assets/example/audio.track.js",
"sha256Lf": "9ed26b2ee006e1a430db68b2a1b308c4801dd3a4cc87c74e8a8f5ccf3456ac8d"
},
{
"file": "docs/assets/example/auto.thumbnail.js",
"sha256Lf": "e0f706bbbab8e2d0f3201ea6b91f1d5530c6bb65f5c1745dc8ade9acc6d66295"
},
{
"file": "docs/assets/example/canvas.js",
"sha256Lf": "95a971773e93cfb340a07eebcd49e333825f49ce0d167514ad98a188cd027799"
},
{
"file": "docs/assets/example/chapter.js",
"sha256Lf": "93f2378a1b7a105721511d34fd632ec7745b4b20c472072c410f76b25383c96b"
},
{
"file": "docs/assets/example/chromecast.js",
"sha256Lf": "edd4d232a667a3ca5bae247dca3c557431975b9ae38dc56cc590b57e327d048f"
},
{
"file": "docs/assets/example/danmuku.js",
"sha256Lf": "3f91461e2466f13ed4e72e6bd69986461192d8c6a9ca866fa347599f6958e021"
},
{
"file": "docs/assets/example/danmuku.mask.js",
"sha256Lf": "c0fe4f2d4cd60738583c4bfeebc24bc3365d8ca382e6f4ff86e1056f5a2729a8"
},
{
"file": "docs/assets/example/dash.control.js",
"sha256Lf": "edc4326f03262a222e06bb780aab05c903ee162829dec60970e738c1e260eef2"
},
{
"file": "docs/assets/example/dash.js",
"sha256Lf": "49a7837016b6f0e8ac045e6aba7bb95e1997e6fd834243b4c62d0c97a2938776"
},
{
"file": "docs/assets/example/document.pip.js",
"sha256Lf": "2600948ff0128e2e77fe08fbefa9be96543d8fcd81421534dfbfe9cb84e91fde"
},
{
"file": "docs/assets/example/flv.js",
"sha256Lf": "51e052e5423318aa25c69eae90a5181c1325edf2d0975fa169308eadb6b395eb"
},
{
"file": "docs/assets/example/hls.control.js",
"sha256Lf": "b02fd290d79999790501447fad95736642344b9b30af3ccdbe353f80c7d53a6e"
},
{
"file": "docs/assets/example/hls.js",
"sha256Lf": "7f82d60442223ced637daa896199607ca9bd52ac50dabbb47c75561f84e9ff48"
},
{
"file": "docs/assets/example/iframe.js",
"sha256Lf": "a2129e0ce772a58ae635fe1b784d65324155655cd2fc3c537ed511dffcb2be7a"
},
{
"file": "docs/assets/example/index.js",
"sha256Lf": "7fd8b85433ced107add0e0b3731ef192113435330d976166cbb63992b7625571"
},
{
"file": "docs/assets/example/jassub.js",
"sha256Lf": "4c93c554aa0a65ac19c1386bb0cfd9df6545f95b8a6f65541a0db8c072975f6d"
},
{
"file": "docs/assets/example/mediabunny.js",
"sha256Lf": "dad4cecb76b2e5ce9164a4da715793911123e8fd2bc4b93f4bcc86a7053bad7d"
},
{
"file": "docs/assets/example/mobile.js",
"sha256Lf": "c117fcd0e8b0f3da3745f5b97d2544b52aea49f28a7779704b0455f2a54d00e3"
},
{
"file": "docs/assets/example/mpegts.js",
"sha256Lf": "2e819af74536e02fdbccbf3379467dec7b1cf6f3aff7e46c370355320485a482"
},
{
"file": "docs/assets/example/multiple.subtitles.js",
"sha256Lf": "d9ccbd334d4c77f26826144870d6700e4b62f1f00b7f8f49817b3ac2583e5cc4"
},
{
"file": "docs/assets/example/setting.test.js",
"sha256Lf": "84d6489b87556df556cbf567c9e72895366e0abeba524b9bb67ef9561d1dda60"
},
{
"file": "docs/assets/example/thumbnail.js",
"sha256Lf": "e166845905f131dea0979eb77471f5531a59b93ab2dc28fef1b6791adb42fc30"
},
{
"file": "docs/assets/example/tool.thumbnail.js",
"sha256Lf": "ef830a5f499e14c09d51aa31aaa0a194ea1f910b27f14ef97c0ca8f67d529a54"
},
{
"file": "docs/assets/example/vast.js",
"sha256Lf": "ce93b48fabc2f1fd39fdaa8af3ae9f86a420c9aa65551e2e196d754070f36064"
},
{
"file": "docs/assets/example/vtt.thumbnail.js",
"sha256Lf": "05465b0da63b23626a3032a9c1f44d3e78ed501a313aaed885ac232ab720ffee"
},
{
"file": "docs/assets/example/webtorrent.js",
"sha256Lf": "11e6541fbaeb2f854b43ed4ea2bea36f411505343dc79a3f3a9ede9d856b2226"
},
{
"file": "docs/assets/ts/artplayer-plugin-vast.LICENSE.txt",
"sha256Lf": "967db6e5026a2fd3cc24d8a04e5c78859f543d0c0f6d4f9599c103e4e93c9b29"
}
],
"outputSha256Lf": "2dac26861645bdf8f449eb8a3b5e197ecf0bb93e124a4f038ef3d71e6783ad08"
}
+6073 -3174
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -40,15 +40,15 @@
"test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js",
"dev": "npx cross-env NODE_ENV=development node ./scripts/dev.js",
"build": "npx cross-env NODE_ENV=production node ./scripts/build.js",
"lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/browser/**/*.ts\" \"scripts/{docs-smoke,editor-declarations}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix docs/assets/js/common.js",
"lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/browser/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix docs/assets/js/common.js",
"build:all": "yarn ci:build && yarn lint",
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/browser/**/*.ts\" \"scripts/{docs-smoke,editor-declarations}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix docs/assets/js/common.js",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/browser/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix docs/assets/js/common.js",
"check:plan": "node refactor/scripts/plan.mjs --check",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js",
"test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:test && yarn build:docs && yarn test:imports",
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
@@ -124,7 +124,8 @@
"check:editor-types": "node scripts/build-ts.js --check",
"build:site-assets": "node scripts/build-site-assets.mjs",
"check:site-assets": "node scripts/build-site-assets.mjs --check",
"typecheck:site-assets": "node node_modules/typescript/bin/tsc -p packages/artplayer-vitepress/browser/tsconfig.json --noEmit"
"typecheck:site-assets": "node node_modules/typescript/bin/tsc -p packages/artplayer-vitepress/browser/tsconfig.json --noEmit",
"check:llm": "node scripts/build-llm.js --check"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+7 -3
View File
@@ -57,9 +57,13 @@ The repository generators have different ownership:
complete playback or plugin behavior. See its maintenance README and SITE-SMOKE-01.
- `scripts/build-i18n.js`: core language source bundles and copies to `docs/compiled/i18n/`.
- `scripts/build-docs.js`: this VitePress build.
- `scripts/build-llm.js` and `scripts/trans-docs.js`: explicit remote DeepSeek
operations, with a required key. They are not part of `ci:build` or `build:all`.
Do not invoke them just to inspect or regenerate local documentation.
- `scripts/build-llm.js`: offline source-preserving `docs/llms.txt` and fingerprint
manifest. `yarn check:llm` checks drift in CI; `ci:build` regenerates after types.
- `scripts/trans-docs.js`: local plan by default; explicit `--remote` creates a
reviewable draft, `--validate <draft>` checks edited drafts, and `--apply <draft>`
replaces selected English files with stale-input and rollback checks. Remote
translation is never part of CI. See [tool maintenance](../../scripts/documentation/README.md)
for the migration from destructive translation, module boundaries and limits.
## Browser boundaries and ownership
@@ -0,0 +1,148 @@
{
"task": "SITE-AI-DOCS-01",
"baseline": "647f3e7f12b1026f7d0de0830080d2d17b23d14d",
"node": "24.21.0",
"yarn": "1.22.22",
"scope": {
"corpusSources": 66,
"translatedSourceFilesChecked": 13,
"newTests": 12,
"actualTranslationCalls": 0,
"originalDocsUnchanged": true,
"playerRuntimeChanged": false,
"publicTypesChanged": false,
"lockUnchanged": true,
"newDependencies": 0
},
"logs": {
"targeted": {
"file": "refactor/.cache/ai-docs-targeted.log",
"sha256": "a583dd7b803b79f4978e821f4ccc5da20cc71aa937c2221cc35c3926c950f67a",
"exitCode": 0,
"pass": 18
},
"finalNewTests": {
"file": "refactor/.cache/ai-docs-tests-final.log",
"sha256": "004aac934d67c1afcf39bfb8df81a70b47800d3fcab5677671f999ca357f75a9",
"exitCode": 0,
"pass": 12
},
"baseline": {
"file": "refactor/.cache/ai-docs-baseline.log",
"sha256": "5141e44306af3f8ffb9394bd82052130f844b4646f0b6df90e3e35a7ef90ddd6",
"exitCode": 0,
"pass": 522
},
"ci": {
"file": "refactor/.cache/ai-docs-ci.log",
"sha256": "9e694e53f168ec66c0dec0534bf7cf864f04dd1a5cccb8ab57ab26d9a3731b79",
"exitCode": 0,
"pass": 50
},
"rootLint": {
"file": "refactor/.cache/ai-docs-root-lint.log",
"sha256": "ad86c4dffcbe4299d3562782b81dff61e3861c85f67ab1150c54d5ba087972e1",
"exitCode": 0
},
"finalTargetLint": {
"file": "refactor/.cache/ai-docs-target-lint.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"exitCode": 0
},
"finalTypes": {
"file": "refactor/.cache/ai-docs-types-final.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"exitCode": 0
},
"toolchain": {
"file": "refactor/.cache/ai-docs-toolchain.log",
"sha256": "b7128a2d927b05010f6af07089db147ec84f7c388d29f2d231e93184e5a7e005",
"exitCode": 0
},
"corpus": {
"file": "refactor/.cache/ai-docs-corpus.log",
"sha256": "e771bcea47e40862939d82de90baff765f8c679dd1b6d8414c6d767257e6543b",
"exitCode": 0
},
"finalRiskAndInventory": {
"file": "refactor/.cache/ai-docs-status-final.log",
"sha256": "bec1072cc3711bb89e98c717eda1b06c9f476ed0fd13f252238ceca6ff466caf",
"exitCode": 0,
"pass": 3
},
"generatedSourceWhitespace": {
"file": "refactor/.cache/ai-docs-whitespace.log",
"sha256": "068d10caf7b7d2719bfeafc79a704d91bcb7e19b05e2b8b495f106a380da2710",
"exitCode": 2
}
},
"sources": [
{
"file": "scripts/build-llm.js",
"sha256": "0de87f528ab5f27aef4a02fa7219818bc9d76797aa1a8015b8f54c4e168fa3d2"
},
{
"file": "scripts/trans-docs.js",
"sha256": "126fe0a92d441e065a5db1a69c0492931542c2b66a4f3c1f5b92051bc702d276"
},
{
"file": "scripts/tsconfig.docs.json",
"sha256": "381835bb07fc3245b6cbf6077ef74159d2fc75808bea5c074d6af0f9565612ea"
},
{
"file": "package.json",
"sha256": "400a1f3aa48f023099eaca0ab35dc686d4b4570f73861d6c3b8046d29fde25ed"
},
{
"file": "test/documentation-pipeline.test.js",
"sha256": "29e60c7a2a2f184486b85d1288c4abed6cd9e90cc830dbec347d56b3a3845afe"
},
{
"file": "scripts/documentation/cli.ts",
"sha256": "82026bc026adecd3c97b0ee5adbe5b25a4dd1e679b2a7fdecde3f18a96919af6"
},
{
"file": "scripts/documentation/corpus.ts",
"sha256": "f916f63822b9e53a26d2017c0004a576c1fbdf0af0f839c296c8e3d2a14bb2c1"
},
{
"file": "scripts/documentation/files.ts",
"sha256": "f445f39e96176a754590d6fb336f2df54ca7735cbc8b3b95057f4c2bb1fb3263"
},
{
"file": "scripts/documentation/markdown.ts",
"sha256": "8b0f690348ece7f645bc00ece144528656fed069c569e0ae204c773486c34264"
},
{
"file": "scripts/documentation/README.md",
"sha256": "fcf5085edb2e9a6dcdedb2515b3a8224ad5d0a940f6de189382da2d50b87b6ad"
},
{
"file": "scripts/documentation/remote.ts",
"sha256": "368ae90bea04b267392b6c85cd8a77f0f72986ca0f17eadb0b6c5655d8562f05"
},
{
"file": "scripts/documentation/translation.ts",
"sha256": "52dd973ae30341ebebe39c01011b87b0a5f37702297263f10a1417dad2dc10bd"
}
],
"outputs": [
{
"file": "docs/llms.txt",
"sha256": "2dac26861645bdf8f449eb8a3b5e197ecf0bb93e124a4f038ef3d71e6783ad08"
},
{
"file": "docs/llms.manifest.json",
"sha256": "bb9b81870f74706965975b998ecf40a8c8496ad1d6cfe46d049de0d84212bbbf"
}
],
"outputSha256Lf": "2dac26861645bdf8f449eb8a3b5e197ecf0bb93e124a4f038ef3d71e6783ad08",
"limitations": [
"No paid/remote translation or English quality acceptance",
"Caught apply failures can roll back; process crash/power loss is not a multi-file transaction",
"Concurrent writer conflicts are reported, not overwritten",
"No browser/device/full VitePress/remote CI/Pages/npm validation in this tool task",
"522 baseline tests preceded final risk/status updates, which receive targeted checks",
"git diff --check reports original English :::warning trailing space and generated bundle EOF separator; non-corpus source diff check passes. Original Markdown whitespace is intentionally preserved."
]
}
+27 -3
View File
@@ -18126,11 +18126,11 @@
},
{
"file": "scripts/build-llm.js",
"sha256Lf": "6159c455938c8a40bb3e570f8160d28aac6a1808ec428023d027f974791edb4e"
"sha256Lf": "0de87f528ab5f27aef4a02fa7219818bc9d76797aa1a8015b8f54c4e168fa3d2"
},
{
"file": "scripts/trans-docs.js",
"sha256Lf": "c78c685c9d4145cfe7f4cbdc8c24773bba9ea4fa4c2f171b96ef42c269edd581"
"sha256Lf": "126fe0a92d441e065a5db1a69c0492931542c2b66a4f3c1f5b92051bc702d276"
},
{
"file": "scripts/projects.js",
@@ -18200,6 +18200,30 @@
"file": "scripts/editor-declarations/vast-sdk.ts",
"sha256Lf": "6da9176284b7a697a922b90befa64263e016f12eef483793cc524bdfc028ad05"
},
{
"file": "scripts/documentation/cli.ts",
"sha256Lf": "82026bc026adecd3c97b0ee5adbe5b25a4dd1e679b2a7fdecde3f18a96919af6"
},
{
"file": "scripts/documentation/corpus.ts",
"sha256Lf": "f916f63822b9e53a26d2017c0004a576c1fbdf0af0f839c296c8e3d2a14bb2c1"
},
{
"file": "scripts/documentation/files.ts",
"sha256Lf": "f445f39e96176a754590d6fb336f2df54ca7735cbc8b3b95057f4c2bb1fb3263"
},
{
"file": "scripts/documentation/markdown.ts",
"sha256Lf": "8b0f690348ece7f645bc00ece144528656fed069c569e0ae204c773486c34264"
},
{
"file": "scripts/documentation/remote.ts",
"sha256Lf": "368ae90bea04b267392b6c85cd8a77f0f72986ca0f17eadb0b6c5655d8562f05"
},
{
"file": "scripts/documentation/translation.ts",
"sha256Lf": "52dd973ae30341ebebe39c01011b87b0a5f37702297263f10a1417dad2dc10bd"
},
{
"file": "packages/artplayer-vitepress/browser/editor-loader.ts",
"sha256Lf": "7cfdc82726d164d450b21680d950ec3f508482f1ab743febee473855248568e1"
@@ -18224,7 +18248,7 @@
"build:i18n": "node ./scripts/build-i18n.js",
"build:test": "node ./scripts/build-test.js",
"build:all": "yarn ci:build && yarn lint",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:test && yarn build:docs && yarn test:imports",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports",
"build:types": "node scripts/build-types.mjs --write"
},
"siteManifest": {
@@ -0,0 +1,65 @@
# SITE-AI-DOCS-01 离线文档与翻译草稿
起点 `647f3e7f12b1026f7d0de0830080d2d17b23d14d`。从 SITE-03 拆出 LLM
文档和翻译工具链,父任务保留 i18n/VitePress 编排、桌面 common.js UI 迁移。
核心/插件运行时、公开类型、DOM/CSS、分发入口、版本及 yarn.lock 均未修改。
## 实现及兼容
- `scripts/documentation/` 以严格 TS 拆分 CLI、来源聚合、Markdown 保护、请求、
草稿调度/应用和文件操作。旧 build-llm.js/trans-docs.js 命令路径保留为 checked
JS 适配层。模块 README 记录数据流、状态、资源、命令、维护入口及限制。
- 固定起点旧函数的隔离复现确认:翻译 main 在第一次请求前递归删除英文目录,
API 失败后人工维护页面消失;启发式 fence 修补破坏包含 `##`/`:::` 的合法
代码;最后一次 429 退出循环后返回 undefined。新流程不沿用这些缺陷。
- build:llm 改为离线、确定顺序、保留 LF 归一后的源文本。输出仍为 docs/llms.txt,
增加来源/output SHA-256 清单。当前 66 个来源:13 英文 Markdown、实际加载的
22 编辑器声明、30 示例、1 VAST 类型 notices;不收入未加载的遗留 WebSR 声明。
原三组标题保留,增加 notices。完整源码替代远程摘要,内容体积变化有意为之。
- trans:docs 默认只显示计划;只有显式 --remote 才读取 dotenv/key、请求原
DeepSeek endpoint/model。生成独立 ignored 草稿,不写英文目录。翻译范围保持
index/advanced/component/start,共 13 篇,没有顺便生成 Danmuku 英文页面。
老自动化需改为 --remote、复核草稿、--apply;这是内部维护命令的明确缺陷修正,
不涉及消费包 API。请求仍可能产生服务费用,本任务没有执行实际远程请求。
- 保护并恢复代码/HTML/指令块,校验内联代码、链接和 Markdown 结构;分段有界,
不拆 UTF-16 代理对。无效响应拒绝而非修补。单 worker 失败中止并等待所有
worker;429/5xx/传输失败有界重试,认证/无效 JSON/空响应直接失败。计时覆盖
响应正文读取,finally 清理,失败 HTTP body 取消。
- --validate 允许复核后修改草稿散文,但必须保持结构和源/目标指纹;它不证明
翻译质量。--apply 全集预检、逐文件复检、临近临时文件替换,捕获失败后逆序
恢复原始字节。过期源/目标、映射篡改、目录越界以及悬空链接均拒绝。未选中
的英文页面保留;并发作者改写时拒绝覆盖该文件并报告 aggregate error。
- 回滚不是跨文件崩溃事务:原始字节只在进程内,断电/强杀可能部分应用;目录
检查也不是针对恶意并发文件系统攻击的沙箱。README 明确先保留 Git 状态、
失败后检查 diff 和草稿。这些限制不伪称为全局事务保证。
## 工程与验证
无新增依赖。使用 Node24.21.0/Yarn1.22.22、现有 TS5.9.3、glob13.0.6、
dotenv17.2.4 和 Markdown 工具链。新增 check:llm 进入 ci:check,离线 build:llm
在 ci:build 的 build:ts 后执行;trans:docs --remote 不进入 CI。文档模块进入
根 lint/docs-tools 严格检查,测试进入 test:node,当前站点清单同步 TS 文件指纹。
- 新增 12 项测试:旧缺陷复现、全部 13 篇实际源文件往返、保护标记/结构拒绝、
请求失败、并发取消/等待、复核应用、过期输入、路径篡改/链接、回滚及并发修改、
重试/认证/无效响应、离线可复现与默认不联网。真实 loopback HTTP 服务复现
headers 已到而 body 卡住,超时能够中止;服务/连接在 finally 关闭。
- 连同编辑器生成、文档退出码、站点清单共 18 项通过;最后补充悬空链接拒绝后
12 项再次通过、目标 lint 和严格类型复验通过。CI 回归 50 项通过。
- 根 lint 最终 0 error/1 既有生成声明 warning;一次新增 README 空行错误已修正。
工具链、只读来源一致性及计划/风险/站点清单检查通过。完整基线结果与源/产物
指纹见 [验证清单](../baselines/documentation-pipeline-validation.json)。
- 全量 git diff --check 报告两处生成语料空白:英文 start/i18n.md 第 9 行原有
`:::warning ` 尾空格和语料末尾分隔空行。保留源文本是此输出的用途,没有
修剪 Markdown 或伪称全量空白检查通过;排除该生成语料后的源码检查通过。
当前中英文 Markdown 文件与起点完全相同。没有调用 DeepSeek、应用真实翻译、
验证英文语义质量、生成完整 docs/document、执行浏览器或设备/远端 CI/Pages/npm。
既有播放器/插件验收仍由各自任务完成,本任务不扩大这些验收结论。
## 交接与回退
按独立任务提交,随后运行提交审计;无推送或发布。生成文件仅通过源码工具
更新。回退本提交会恢复旧远程和删除行为,优先针对问题修复;若整体回退,应
停用旧 trans:docs 自动调用以免重现数据丢失。下一步继续 SITE-03 剩余生成和
桌面 UI,不改变 VAST 默认行为、Auto Thumbnail 首帧或其它开放问题的状态。
+7
View File
@@ -119,3 +119,10 @@ Pages 先调用同一检查/构建 workflow,成功后上传同一次运行生
| npm 权限、trusted publisher、候选发布 | 未配置、未执行 | CI-03/CI-04 |
参考:[GitHub Pages 自定义 workflow](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)、[setup-node](https://github.com/actions/setup-node)、[actionlint 1.7.12](https://github.com/rhysd/actionlint/releases/tag/v1.7.12)。本轮已核对选用 Actions 的实际 action.yml 和发布 tag 对应 SHA;后续升级需重新检查。
## SITE-AI-DOCS-01 文档生成补充
`ci:check` 新增 `yarn check:llm`,只读校验 `docs/llms.txt` 和来源指纹清单。
`ci:build` 在 `build:ts` 后运行离线 `build:llm`,不读取 API key 或请求模型。
文档 TS 模块进入根 lint 和 docs-tools 严格类型检查,文档流程回归进入
`test:node`。`trans:docs --remote` 不进入 CI;远端工作流运行结果仍待独立验收。
+6 -4
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 241 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 242 项,范围 22 个包及工作区/示例。
状态:todo 59 / doing 15 / blocked 0 / done 167 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 59 / doing 15 / blocked 0 / done 168 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -35,7 +35,7 @@
| artplayer-proxy-mediabunny | 1.2.0 | PKG-MB-01, PKG-MB-02, PKG-MB-03, PKG-MB-04, PKG-MB-05, PKG-MB-06, PKG-MB-07, PKG-MB-08, PKG-MB-09, PKG-MB-10 |
| artplayer-tool-iframe | 1.1.0 | PKG-IFRAME-01, PKG-IFRAME-02, PKG-IFRAME-03, PKG-IFRAME-04, PKG-IFRAME-05, PKG-IFRAME-06 |
| artplayer-tool-thumbnail | 4.4.0 | PKG-TOOL-THUMB-01, PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-05, PKG-TOOL-THUMB-06 |
| artplayer-vitepress | 1.1.0 | SITE-01, SITE-SMOKE-01, SITE-02, SITE-LOAD-01, SITE-03, SITE-04, SITE-05, SITE-06, SITE-07 |
| artplayer-vitepress | 1.1.0 | SITE-01, SITE-SMOKE-01, SITE-02, SITE-LOAD-01, SITE-AI-DOCS-01, SITE-03, SITE-04, SITE-05, SITE-06, SITE-07 |
## 0 规划
@@ -384,7 +384,8 @@
| SITE-SMOKE-01 | artplayer-vitepress, workspace<br>迁移示例生成器并验证真实就绪与清理 | SITE-01 | TS Markdown 解析/生成与浏览器运行模块;旧命令和 URL、稳定实例就绪 smoke、错误与资源清理 | 复现旧 malformed 死循环并保证有限失败;保留全部既有示例内容、生成确定且 check 只读;不再以 100ms 作为成功,验证原生就绪、异常、取消与清理;三浏览器检查,明确延迟交互与全部 233 示例仍需 EX-03 | M | done |
| SITE-02 | artplayer-vitepress<br>整理声明与示例生成器 | SITE-01, ENG-04, ENG-06, SITE-SMOKE-01 | build-ts/build-test 生成链的可验证 TS 脚本 | 不靠字符串拼接掩盖声明错误,生成示例有真实断言或仅标 smoke;替换固定 100ms 成功判定,明确异步错误、清理和生成覆盖限制;坏代码块解析必须终止,覆盖 malformed 分支不推进 regexp 的回归 | M | done |
| SITE-LOAD-01 | artplayer-vitepress, workspace<br>修复文档示例加载顺序与导航边界 | SITE-02 | 共享 TS loader、移动入口与 Run Code/语言路由,保留 URL 和使用方式 | 旧版复现 define 丢失和依赖乱序;真实浏览器验证成功/失败恢复、顺序、去重、编码/优先级及 localhost/127 路由和语言跳转;不以此替代 SITE-03 构建/翻译流程 | M | done |
| SITE-03 | artplayer-vitepress<br>整理 i18n/文档/LLM 生成流程 | SITE-02, SITE-LOAD-01 | build-i18n/build-docs/build-llm/trans-docs 的任务边界和错误处理;桌面 common.js 自有 UI 的 TS 迁移与模块拆分 | 原命令兼容、生成可复现,翻译步骤不隐式运行远程服务;覆盖移动 loader 失败恢复 define、脚本依赖顺序、localhost/127.0.0.1 Run Code 目标和语言重定向;桌面剩余 UI 完成 TS 职责拆分并回归运行、导入与持久设置 | M | todo |
| SITE-AI-DOCS-01 | artplayer-vitepress, workspace<br>重构可复现 LLM 文档与安全翻译草稿流程 | SITE-LOAD-01, SITE-02 | TS 离线文档聚合、显式远程翻译、草稿校验和可回退应用,保留命令与目标路径 | 默认不读密钥/调用网络;LLM 内容可复现并保留来源;旧翻译破坏性行为有复现;错误/限流/超时/结构损坏/过期/路径越界拒绝且英文文档不丢失;未执行付费翻译不计服务验收 | M | done |
| SITE-03 | artplayer-vitepress<br>整理 i18n/文档/LLM 生成流程 | SITE-02, SITE-LOAD-01, SITE-AI-DOCS-01 | build-i18n/build-docs/build-llm/trans-docs 的任务边界和错误处理;桌面 common.js 自有 UI 的 TS 迁移与模块拆分 | 原命令兼容、生成可复现,翻译步骤不隐式运行远程服务;覆盖移动 loader 失败恢复 define、脚本依赖顺序、localhost/127.0.0.1 Run Code 目标和语言重定向;桌面剩余 UI 完成 TS 职责拆分并回归运行、导入与持久设置 | M | todo |
| SITE-04 | artplayer-vitepress<br>交叉核对逐包持续维护的文档 | CORE-21, SITE-03, PKG-CHAPTER-04, PKG-AMBILIGHT-04, PKG-AUDIO-04, PKG-AUTO-THUMB-04, PKG-VTT-THUMB-04, PKG-HLS-04, PKG-DASH-04, PKG-MULTI-SUB-04, PKG-JASSUB-04, PKG-MASK-04, PKG-ASR-04, PKG-ADS-04, PKG-VAST-04, PKG-CAST-04, PKG-DPIP-04, PKG-CANVAS-04, PKG-IFRAME-04, PKG-TOOL-THUMB-04, PKG-DANMUKU-06, PKG-MB-08 | 已随实现更新的中文/英文 API、包内实现地图、旧 JS 示例及已知能力限制的全包核对 | 未把缺环境的能力写成已验证,静态核对不等待设备任务;最终 demo 仍由 EX-03 验收;按 SITE-01 声明成员/候选标题清单逐项语义核对,补齐缺失的双语插件说明和 Danmuku 英文入口 | M | todo |
| SITE-05 | artplayer-vitepress<br>构建文档站和验证链接/示例 | SITE-04, EX-01, EX-02, SITE-07 | VitePress 构建、链接与嵌入 demo 检查 | 文档构建、链接、嵌入路径与声明注入通过;真实完整 demo 保留 EX-03 独立门槛;核对 ENG-PM-01 登记的搜索 peer 范围和真实搜索行为 | M | todo |
| SITE-06 | artplayer-vitepress<br>文档站交付验收 | SITE-05 | 维护指南和站点变更记录 | 未手改 generated 目录,旧 URL 可用、部署与检查分离 | M | todo |
@@ -611,6 +612,7 @@
- SITE-SMOKE-01: [记录](changes/2026-09-14-SITE-SMOKE-01-documentation-smoke.md) [记录](baselines/docs-smoke-validation.json) [记录](scripts/docs-smoke.test.mjs)
- SITE-02: [记录](changes/2026-09-14-SITE-02-editor-declarations.md) [记录](baselines/editor-declarations-validation.json) [记录](changes/2026-09-14-SITE-SMOKE-01-documentation-smoke.md)
- SITE-LOAD-01: [记录](changes/2026-09-14-SITE-LOAD-01-site-loading.md) [记录](baselines/site-loading-validation.json)
- SITE-AI-DOCS-01: [记录](changes/2026-09-14-SITE-AI-DOCS-01-documentation-pipeline.md) [记录](baselines/documentation-pipeline-validation.json)
- SITE-07: [记录](site-inventory.md) [记录](baselines/site-provenance.json)
- EX-01: [记录](changes/2026-09-14-EX-01-react-consumer.md) [记录](baselines/react-consumer-validation.json) [记录](scripts/react-consumer.mjs)
- EX-02: [记录](changes/2026-09-14-EX-02-vue-consumer.md) [记录](baselines/vue-consumer-validation.json) [记录](scripts/vue-consumer.mjs)
+17
View File
@@ -1,5 +1,22 @@
# 进度与证据
## SITE-AI-DOCS-01 文档工具完成
将 build-llm/trans-docs 拆为严格 TS 模块,保留旧命令路径。离线 LLM 文件含
66 个原始来源及指纹清单,生成/只读检查进入 CI;翻译默认只显示计划,显式
远程操作先生成草稿,校验后应用,捕获写入失败时回滚并保护并发作者修改。
已复现并修复旧工具先删英文、错误修补代码块及最后 429 返回 undefined。
新增 12 项测试、目标组合 18 项、完整 baseline 522 项、CI 回归 50 项通过。
严格类型、工具链和 lint 通过(根 lint 1 条既有 warning);最终悬空链接修复
后复跑新测试/目标 lint/类型。当前中英文源文档与起点一致,无远程翻译或
发布。崩溃/断电不具备跨文件事务保证,英文质量与浏览器验收未计入本任务。
见[变更](changes/2026-09-14-SITE-AI-DOCS-01-documentation-pipeline.md)和
[证据](baselines/documentation-pipeline-validation.json)。当前 242 项:168 done、
15 doing、59 todo。下一步 SITE-03 剩余 i18n/VitePress 编排和桌面 UI 迁移;
VAST 默认行为、Auto Thumbnail 首帧及各包最终验收保持开放。独立本地提交并
审计,无推送或发布。
## SITE-LOAD-01 示例加载与导航完成
从 SITE-03 拆出共享 TS loader、移动入口与 Run Code/语言导航;恢复 define 原
+1
View File
@@ -255,3 +255,4 @@
| AUTO-THUMB-CANVAS-01 | resolved / 已复现 | Private thumbnail canvas retains sheet dimensions after completion or cancellation while callbacks retain the element | PKG-AUTO-THUMB-07 |
| EX-REACT-LIFE-01 | resolved / 已复现 | React getInstance exception leaks the constructed player | EX-01 |
| EX-REACT-ENTRY-01 | resolved / 源码/产物事实 | React HTML references absent main.jsx instead of the existing TSX entry | EX-01 |
| SITE-TRANS-DATA-01 | resolved / 已复现 | Translation deletes English before requests and repairs valid fences destructively; exhausted rate limits return undefined | SITE-AI-DOCS-01 |
+22
View File
@@ -5753,6 +5753,28 @@
"compatibleResolution": "The HTML now loads main.tsx; the unchanged App call builds and loads controlled media in all six installed consumer browser profiles.",
"closureCriteria": "Actual installed consumer and original App entry pass without changing the wrapper props, default configuration or effect dependency contract.",
"resolutionRationale": "The HTML now loads main.tsx; the unchanged App call builds and loads controlled media in all six installed consumer browser profiles."
},
{
"id": "SITE-TRANS-DATA-01",
"title": "Translation deletes English before requests and repairs valid fences destructively; exhausted rate limits return undefined",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"SITE-AI-DOCS-01"
],
"evidence": [
"refactor/changes/2026-09-14-SITE-AI-DOCS-01-documentation-pipeline.md",
"refactor/baselines/documentation-pipeline-validation.json",
"test/documentation-pipeline.test.js"
],
"compatibleResolution": "Keep CLI paths and translation source scope; make default local, request into isolated drafts, validate and explicitly apply with caught-error rollback. Preserve player public APIs.",
"closureCriteria": "Frozen old-function tests reproduce deletion, fence damage and undefined 429; candidate tests retain existing English on failed/damaged/stale requests and verify reviewed apply, rollback, cancellation and bounded request/body lifetime.",
"resolutionEvidence": [
"refactor/changes/2026-09-14-SITE-AI-DOCS-01-documentation-pipeline.md",
"refactor/baselines/documentation-pipeline-validation.json",
"test/documentation-pipeline.test.js"
],
"resolutionRationale": "Twelve candidate regressions pass, including actual 13-file Markdown round-trip and local HTTP stalled body. No live translation was applied. Power loss and hostile concurrent filesystem mutation are explicitly outside transaction guarantees."
}
]
}
+1 -1
View File
@@ -78,7 +78,7 @@ export function captureSiteInventory() {
pages: demo.pages,
baselinePaths: { examplesAdded: demo.examples.filter(row => !historical.examples.some(old => old.source === row.source)).map(row => row.source), examplesRemoved: historical.examples.filter(row => !demo.examples.some(now => now.source === row.source)).map(row => row.source), htmlAdded: demo.pages.filter(row => !historical.pages.some(old => old.source === row.source)).map(row => row.source), htmlRemoved: historical.pages.filter(row => !demo.pages.some(now => now.source === row.source)).map(row => row.source) },
editorDeclarations: [...read('docs/assets/js/common.js').matchAll(/'\.\/assets\/ts\/([^']+\.d\.ts)'/g)].map(match => ({ file: `docs/assets/ts/${match[1]}`, exists: fs.existsSync(path.join(root, 'docs/assets/ts', match[1])), owner: 'SITE-02' })),
scripts: [...scriptFiles, 'scripts/build-site-assets.mjs', ...files('scripts/docs-smoke').filter(file => file.endsWith('.ts')), ...files('scripts/editor-declarations').filter(file => file.endsWith('.ts')), ...files('packages/artplayer-vitepress/browser').filter(file => file.endsWith('.ts'))].map(file => ({ file, sha256Lf: hash(read(file)) })),
scripts: [...scriptFiles, 'scripts/build-site-assets.mjs', ...files('scripts/docs-smoke').filter(file => file.endsWith('.ts')), ...files('scripts/editor-declarations').filter(file => file.endsWith('.ts')), ...files('scripts/documentation').filter(file => file.endsWith('.ts')), ...files('packages/artplayer-vitepress/browser').filter(file => file.endsWith('.ts'))].map(file => ({ file, sha256Lf: hash(read(file)) })),
generationCommands: Object.fromEntries(Object.entries(json('package.json').scripts).filter(([name]) => /^(?:build:(?:types|ts|test|i18n|docs|llm|all)|ci:build)$/.test(name))),
siteManifest: json('packages/artplayer-vitepress/package.json'),
assets,
+4 -3
View File
@@ -77,12 +77,13 @@ loadScript 暂时覆盖 window.define:桌面成功/失败均恢复,移动失
| build:test | 中文 Run Code → docs/test/test.js | 排除 en/plugin/public/.vitepress;100ms done 只是 smoke,含生成时间戳;SITE-02 |
| build:i18n | 核心语言源 → dist/i18n、compiled/i18n | 排除内置语言/发布辅助,逐包构建;SITE-03 |
| build:docs | VitePress 源 → docs/document | 当前 npm 子进程只是运行脚本;SITE-03 统一 Yarn 编排 |
| build:llm | 英文文档、编辑器声明、示例 → docs/llms.txt | 明确远程 DeepSeek 操作;SITE-03 |
| trans-docs.js | 中文 Markdown → 英文 Markdown | 明确远程 DeepSeek 操作;SITE-03 |
| build:llm | 英文文档、实际编辑器声明、示例、声明 notices → docs/llms.txt + manifest | SITE-AI-DOCS-01 离线可复现;check:llm 只读检查 |
| trans-docs.js | 中文 Markdown → 草稿 → 经检查应用英文 Markdown | 默认只显示计划;显式 --remote 请求,--validate / --apply 分步处理 |
build:test 的 malformed 分支在 continue 前不推进 regexp,存在同一坏块重复扫描的源码路径;
SITE-02 必须加入可终止的坏文档反例。未运行该路径或把它计作已修复。
build:all/ci:build 当前不调用远程翻译或 LLM。SITE-03 应保留这一边界。
build:all/ci:build 不调用远程翻译;SITE-AI-DOCS-01 将离线 build:llm 纳入
ci:build,将只读 check:llm 纳入 ci:check。远程草稿与英文质量复核仍单独执行。
`scripts/projects.js` 明确排除 VitePress 的库构建;配置 base=/document/,输出到仓库 docs/document。
Pages 工作流消费已验证的 docs artifact,部署与检查分离。manifest 未设 private,且没有
+23 -1
View File
@@ -4168,6 +4168,27 @@
"baselines/site-loading-validation.json"
]
},
{
"id": "SITE-AI-DOCS-01",
"phase": "6 文档与消费者",
"title": "重构可复现 LLM 文档与安全翻译草稿流程",
"scope": [
"artplayer-vitepress",
"workspace"
],
"dependsOn": [
"SITE-LOAD-01",
"SITE-02"
],
"status": "done",
"risk": "M",
"deliverable": "TS 离线文档聚合、显式远程翻译、草稿校验和可回退应用,保留命令与目标路径",
"acceptance": "默认不读密钥/调用网络;LLM 内容可复现并保留来源;旧翻译破坏性行为有复现;错误/限流/超时/结构损坏/过期/路径越界拒绝且英文文档不丢失;未执行付费翻译不计服务验收",
"evidence": [
"changes/2026-09-14-SITE-AI-DOCS-01-documentation-pipeline.md",
"baselines/documentation-pipeline-validation.json"
]
},
{
"id": "SITE-03",
"phase": "6 文档与消费者",
@@ -4177,7 +4198,8 @@
],
"dependsOn": [
"SITE-02",
"SITE-LOAD-01"
"SITE-LOAD-01",
"SITE-AI-DOCS-01"
],
"status": "todo",
"risk": "M",
+2 -189
View File
@@ -1,191 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { glob } from 'glob'
import { runCorpus } from './documentation/cli.ts'
dotenv.config()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '../packages/artplayer-vitepress')
const dostDir = path.resolve(__dirname, '../docs')
const outputFile = path.resolve(__dirname, '../docs/llms.txt')
const API_URL = 'https://api.deepseek.com/v1/chat/completions'
const API_KEY = process.env.DEEPSEEK_API_KEY
const MAX_CONCURRENT_REQUESTS = 3 // 每类并发请求数
const REQUEST_TIMEOUT = 60000 // 请求超时时间 60秒
const MAX_RETRIES = 3 // 最大重试次数
if (!API_KEY) {
console.error('❌ Missing DEEPSEEK_API_KEY in .env file')
process.exit(1)
}
async function getFiles(pattern) {
const files = await glob(pattern.replace(/\\/g, '/'), { nodir: true })
return files.sort()
}
function readFiles(files) {
let content = ''
for (const f of files) {
console.log(`📄 Reading: ${f}`)
content += `\n\n===== ${path.basename(f)} =====\n\n`
content += fs.readFileSync(f, 'utf8')
}
return content
}
function splitText(text, maxLen = 8000) {
const paragraphs = text.split(/\n{2,}/)
const chunks = []
let buffer = ''
for (const p of paragraphs) {
if ((`${buffer}\n\n${p}`).length > maxLen) {
chunks.push(buffer)
buffer = ''
}
buffer += `\n\n${p}`
}
if (buffer.trim())
chunks.push(buffer)
return chunks
}
async function askDeepSeek(prompt, text, tag, id) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`🧠 [${tag}] Sending chunk ${id} (attempt ${attempt}/${MAX_RETRIES})`)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const res = await fetch(API_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: 'You are a professional technical writer.' },
{ role: 'user', content: `${prompt}\n\n${text}` },
],
temperature: 0.3,
}),
})
clearTimeout(timeout)
// 处理限流情况
if (res.status === 429) {
const delay = 2 ** attempt * 1000
console.log(`⏳ [${tag}] Rate limited, waiting ${delay}ms...`)
await new Promise(r => setTimeout(r, delay))
continue
}
if (!res.ok) {
const err = await res.text()
console.error(`[${tag}] API Error:`, err)
throw new Error(`DeepSeek API error: ${res.status}`)
}
const data = await res.json()
if (!data.choices || !data.choices[0]) {
throw new Error('Invalid API response: missing choices')
}
return data.choices[0].message?.content?.trim() || ''
}
catch (err) {
if (err.name === 'AbortError') {
console.error(`[${tag}] Request timeout for chunk ${id}`)
}
if (attempt === MAX_RETRIES) {
throw err
}
const delay = 1000 * attempt
console.log(`⚠️ [${tag}] Retry ${attempt}/${MAX_RETRIES} after ${delay}ms...`)
await new Promise(r => setTimeout(r, delay))
}
}
}
async function runConcurrent(tasks, limit = MAX_CONCURRENT_REQUESTS) {
const results = []
const executing = new Set()
for (const task of tasks) {
const p = task().finally(() => executing.delete(p))
results.push(p)
executing.add(p)
if (executing.size >= limit) {
await Promise.race(executing)
}
}
return Promise.all(results)
}
async function summarizeSection(title, files, type) {
if (files.length === 0)
return ''
const text = readFiles(files)
const chunks = splitText(text, 8000)
let prompt = ''
if (type === 'docs') {
prompt = `
You are preparing documentation content for an AI model to learn ArtPlayer.
Reorganize the following documentation into a clean, readable plain text format.
Keep all code blocks, examples, and configuration options fully intact.
Add minimal explanations or clarifications in natural English before or after each block if helpful.
Do not use Markdown syntax (no #, **, or lists). Preserve indentation for code.`
}
else if (type === 'ts') {
prompt = `
You are analyzing TypeScript declaration files that define the APIs of ArtPlayer.
Your goal is to produce a developer-oriented plain text output that preserves the actual type definitions,
interfaces, classes, and comments exactly as they are, while adding concise explanations
above or below them when necessary to clarify their purpose or usage.
Keep all code in the output, formatted as plain text (no Markdown).`
}
else if (type === 'js') {
prompt = `
You are analyzing example JavaScript files demonstrating how to use ArtPlayer and its plugins.
Produce a plain text output that includes the full example code and short inline explanations
describing what each example shows, which APIs it uses, and what feature it demonstrates.
Keep all code exactly as-is, formatted as plain text, without Markdown syntax.`
}
const tasks = chunks.map((chunk, i) => () => askDeepSeek(prompt, chunk, type, i + 1))
const results = await runConcurrent(tasks, MAX_CONCURRENT_REQUESTS)
const merged = results.join('\n\n')
return `\n\n===== ${title} =====\n\n${merged}`
}
async function main() {
console.log('🚀 Building llms.txt using DeepSeek...')
const [mdFiles, tsFiles, jsFiles] = await Promise.all([
getFiles(`${rootDir}/docs/en/**/*.md`),
getFiles(`${dostDir}/assets/ts/*.d.ts`),
getFiles(`${dostDir}/assets/example/*.js`),
])
// 并行执行三个大类任务
const [docs, ts, js] = await Promise.all([
summarizeSection('Documentation Summary', mdFiles, 'docs'),
summarizeSection('Type Definitions Overview', tsFiles, 'ts'),
summarizeSection('Examples Summary', jsFiles, 'js'),
])
const finalText = [docs, ts, js].filter(Boolean).join('\n\n')
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
fs.writeFileSync(outputFile, finalText, 'utf8')
console.log(`✅ LLMs text built successfully at ${outputFile}`)
}
main().catch((err) => {
console.error('❌ Build failed:', err)
process.exit(1)
})
runCorpus(process.argv.slice(2))
+89
View File
@@ -0,0 +1,89 @@
# Documentation generation and translation
Run from the repository root with Node from `.node-version` and Yarn Classic
1.22.22. The old `build-llm.js` and `trans-docs.js` paths remain checked JS CLI
adapters. These tools do not change player APIs or published declarations.
## Commands and outputs
```sh
yarn build:llm
yarn check:llm
yarn trans:docs
yarn trans:docs --remote
yarn trans:docs --validate refactor/.cache/translations/draft-EXAMPLE
yarn trans:docs --apply refactor/.cache/translations/draft-EXAMPLE
yarn typecheck:docs-tools
node --test test/documentation-pipeline.test.js
```
`build:llm` is now offline and deterministic. It preserves complete source text
(LF-normalized) from English Markdown, the actual editor declaration list,
examples and the VAST declaration notices. It writes the existing `docs/llms.txt`
path plus `docs/llms.manifest.json` with source/output SHA-256 fingerprints.
`check:llm` checks without writing; CI checks it and `ci:build` regenerates it
after editor declarations. Regenerate after changing any input. Content presence
does not prove API correctness, example playback or English coverage; SITE-04
and EX-03 still own those reviews. Unloaded legacy WebSR declarations are not
included simply because they remain on disk.
`trans:docs` now only reports a local plan by default. Only `--remote` imports
dotenv, reads `DEEPSEEK_API_KEY` and calls the existing DeepSeek endpoint/model.
It writes a unique ignored draft directory, never the live English documents.
Its scope remains index, advanced, component and start; adding plugin translation
is separate work. Remote requests may incur provider charges. CI never calls them.
Review the draft's prose and diff before applying it. If prose needs editing,
edit the draft then run `--validate`: it verifies structure and source/target
fingerprints and records the edited draft hashes. It cannot judge translation
quality. `--apply` checks all fingerprints and structure again before replacing
the selected English files. Unselected pages remain. Rebuild site assets, the
LLM bundle and the documentation site after applying reviewed translations.
This intentionally replaces the old destructive default, which removed the
English directory before the first request. Existing automation that intended
remote translation must explicitly select `--remote`, review, then `--apply`.
No actual remote translation or paid service acceptance was performed for this
migration; local tests inject responses or use a loopback HTTP server.
## Ownership and failure behavior
| Module | Responsibility |
| ---------------- | -------------------------------------------------------------------------------- |
| `cli.ts` | Argument dispatch; offline default; remote credential boundary |
| `corpus.ts` | Ordered source inventory and deterministic output/manifest |
| `markdown.ts` | Protected block placeholders, structural validation, bounded chunks |
| `remote.ts` | Request/body timeout, bounded retry, response validation and cancellation |
| `translation.ts` | Draft state, worker cancellation/join, fingerprint validation and apply/rollback |
| `files.ts` | LF reads, hashes, owned paths and adjacent temporary-file replacement |
Draft state advances incomplete -> complete -> applied; a failed request or
invalid response leaves a failed draft for diagnosis. One worker failure aborts
the shared signal and waits for every worker before returning. HTTP 429/5xx and
transport failures have bounded retries; invalid/empty JSON and authorization
fail without retrying. The per-attempt timeout covers body reading too. Every
timer is cleared and rejected HTTP bodies are cancelled.
Code, inline code, HTML, links and structural Markdown tokens must survive.
Fenced blocks and directives are restored from local originals, not model text.
An invalid result is rejected instead of heuristically inserting code fences.
Unusual Markdown or a chunk boundary may be rejected; inspect the retained
draft and improve protection rather than weakening validation. This is not a
Markdown sanitizer or proof that prose preserves meaning.
Apply preflights the whole set, rechecks each write, uses adjacent temporary
files and rolls back completed writes on caught failures, preserving original
bytes. A concurrent edit prevents rollback of that file, producing an aggregate
error instead of overwriting the other writer. **This is not a multi-file crash
transaction**: backups live in process memory, and power loss or forced process
termination can leave a partial apply. Commit existing English changes before
applying, keep the draft, and inspect Git diff after any failure. Path validation
rejects traversal and existing junctions/symlinks outside the owned root; it is
not a defense against hostile concurrent filesystem mutation.
No new dependencies were added: use the root's pinned TypeScript 5.9.3,
markdown-it types 14.1.2, glob 13.0.6 and dotenv 17.2.4. For follow-up maintenance,
start from the responsible module, rerun its filesystem/request regressions,
strict docs-tools types and root lint, then regenerate/check the source bundle
and site inventory. Browser/player testing is needed when a change also affects
page content or runtime, not as a substitute for these tool failure tests.
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import process from 'node:process'
import { buildCorpus } from './corpus.ts'
import { atomicWrite, ownedPath, read } from './files.ts'
import { translateChunk } from './remote.ts'
import {
applyTranslationDraft,
createTranslationDraft,
translationPlan,
validateTranslationDraft,
} from './translation.ts'
export function runCorpus(args: string[], root = process.cwd()): void {
assert(
args.every(arg => arg === '--check'),
'Use yarn build:llm [--check]; generation is offline',
)
const outputs = buildCorpus(root)
for (const [relative, text] of outputs) {
const file = ownedPath(root, relative)
if (args.includes('--check'))
assert.equal(read(file), text, `LLM source bundle drift: ${relative}`)
else atomicWrite(file, text)
}
console.log(
`Offline LLM source bundle ${args.includes('--check') ? 'checked' : 'generated'}: ${outputs.size} outputs`,
)
}
export async function runTranslation(
args: string[],
root = process.cwd(),
): Promise<void> {
if (!args.length || (args.length === 1 && args[0] === '--plan')) {
console.log(
JSON.stringify(
{
mode: 'plan-only',
files: translationPlan(root),
next: 'Use --remote to create a reviewable draft; --apply <draft-directory> applies validated files. No network or English writes occurred.',
},
null,
2,
),
)
return
}
if (args.length === 2 && args[0] === '--validate' && args[1]) {
validateTranslationDraft(root, args[1])
console.log(
'Draft structure and source/target fingerprints validated; English files unchanged',
)
return
}
if (args.length === 2 && args[0] === '--apply' && args[1]) {
applyTranslationDraft(root, args[1])
console.log(
'Validated translation draft applied; rebuild site assets and LLM bundle before checking generated output',
)
return
}
assert(
args.length === 1 && args[0] === '--remote',
'Use yarn trans:docs [--plan | --remote | --validate <draft-directory> | --apply <draft-directory>]',
)
const dotenv = await import('dotenv')
dotenv.config({ path: ownedPath(root, '.env'), quiet: true })
const key = process.env.DEEPSEEK_API_KEY
assert(key, 'Missing DEEPSEEK_API_KEY; no translation requests were made')
const directory = await createTranslationDraft(root, (content, signal) =>
translateChunk(content, { key, signal }))
console.log(
`Translation draft ready: ${directory}. Review files before using --apply.`,
)
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import { globSync } from 'glob'
import ts from 'typescript'
import { ownedPath, read, sha256 } from './files.ts'
export function buildCorpus(root: string): Map<string, string> {
const common = ts.createSourceFile(
'common.js',
read(ownedPath(root, 'docs/assets/js/common.js')),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS,
)
const declarations: string[] = []
let lists = 0
function visit(node: ts.Node): void {
if (
ts.isVariableDeclaration(node)
&& ts.isIdentifier(node.name)
&& node.name.text === 'libUris'
) {
lists++
assert(
node.initializer && ts.isArrayLiteralExpression(node.initializer),
'Expected literal editor library list',
)
for (const element of node.initializer.elements) {
assert(
ts.isStringLiteral(element)
&& /^\.\/assets\/ts\/[\w.-]+\.d\.ts$/.test(element.text),
'Unexpected editor library path',
)
declarations.push(`docs/${element.text.slice(2)}`)
}
}
ts.forEachChild(node, visit)
}
visit(common)
assert(
lists === 1
&& declarations.length
&& new Set(declarations).size === declarations.length,
'Missing or duplicate editor library list',
)
const sections = [
{
title: 'Documentation Summary',
files: globSync('packages/artplayer-vitepress/docs/en/**/*.md', {
cwd: root,
posix: true,
}).sort(),
},
{ title: 'Type Definitions Overview', files: declarations },
{
title: 'Examples Summary',
files: globSync('docs/assets/example/*.js', {
cwd: root,
posix: true,
}).sort(),
},
{
title: 'Third-party Type Notices',
files: ['docs/assets/ts/artplayer-plugin-vast.LICENSE.txt'],
},
]
const sources: { file: string, sha256Lf: string }[] = []
let text
= 'ArtPlayer documentation source bundle\nGenerated offline by yarn build:llm. Source text is preserved after LF normalization.\nThese are source references, not proof that every example or documented feature has passed release review.\n'
for (const section of sections) {
assert(
section.files.length,
`Empty documentation section: ${section.title}`,
)
text += `\n===== ${section.title} =====\n`
for (const file of section.files) {
const content = read(ownedPath(root, file))
sources.push({ file, sha256Lf: sha256(content) })
text += `\n===== ${file} =====\n\n${content}\n`
}
}
return new Map([
['docs/llms.txt', text],
[
'docs/llms.manifest.json',
`${JSON.stringify({ schemaVersion: 1, generation: 'offline-source-preserving', sources, outputSha256Lf: sha256(text) }, null, 2)}\n`,
],
])
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
export function sha256(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex')
}
export function read(file: string): string {
return fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n')
}
export function ownedPath(root: string, relative: string): string {
assert(
relative
&& !relative.includes('\\')
&& !path.isAbsolute(relative)
&& !relative.split('/').includes('..'),
'Invalid documentation path',
)
const base = fs.realpathSync(root)
const file = path.resolve(base, relative)
let ancestor = file
while (true) {
try {
fs.lstatSync(ancestor)
break
}
catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
throw error
const parent = path.dirname(ancestor)
assert(parent !== ancestor, 'No existing documentation path ancestor')
ancestor = parent
}
}
const inside = path.relative(base, fs.realpathSync(ancestor))
assert(
inside !== '..'
&& !inside.startsWith(`..${path.sep}`)
&& !path.isAbsolute(inside),
'Documentation path escapes workspace',
)
return file
}
export function atomicWrite(file: string, content: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true })
const temporary = `${file}.${crypto.randomUUID()}.tmp`
try {
fs.writeFileSync(temporary, content, { flag: 'wx' })
fs.renameSync(temporary, file)
}
finally {
if (fs.existsSync(temporary))
fs.unlinkSync(temporary)
}
}
+150
View File
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict'
import MarkdownIt from 'markdown-it'
import { sha256 } from './files.ts'
const parser = new MarkdownIt({ html: true })
export function markdownSignature(source: string): string[] {
const result: string[] = []
function visit(tokens: ReturnType<MarkdownIt['parse']>): void {
for (const token of tokens) {
if (
[
'fence',
'code_block',
'code_inline',
'html_block',
'html_inline',
].includes(token.type)
) {
result.push(JSON.stringify([token.type, token.info, token.content]))
}
if (
[
'heading_open',
'bullet_list_open',
'ordered_list_open',
'list_item_open',
'blockquote_open',
'table_open',
'tr_open',
'th_open',
'td_open',
].includes(token.type)
) {
result.push(JSON.stringify([token.type, token.tag]))
}
if (token.type === 'link_open')
result.push(`link:${token.attrGet('href')}`)
if (token.type === 'image')
result.push(`image:${token.attrGet('src')}`)
if (token.children)
visit(token.children)
}
}
visit(parser.parse(source, {}))
result.push(...source.split('\n').filter(line => /^\s*:::/.test(line)))
return result
}
export function protectMarkdown(source: string): {
masked: string
restore: (translated: string) => string
} {
const lines = source.split('\n')
const ranges: [number, number][] = []
for (const token of parser.parse(source, {})) {
if (
!token.map
|| !['fence', 'code_block', 'html_block'].includes(token.type)
) {
continue
}
if (token.type === 'fence') {
const closing = lines[token.map[1] - 1]?.trim() || ''
assert(
closing.length >= token.markup.length
&& [...closing].every(char => char === token.markup[0]),
'Unclosed source code fence',
)
}
ranges.push([...token.map])
}
lines.forEach((line, index) => {
if (/^\s*:::/.test(line))
ranges.push([index, index + 1])
})
const merged: [number, number][] = []
for (const range of ranges.sort((a, b) => a[0] - b[0])) {
const previous = merged[merged.length - 1]
if (previous && range[0] <= previous[1])
previous[1] = Math.max(previous[1], range[1])
else merged.push(range)
}
const prefix = `ARTPLAYER_KEEP_${sha256(source).slice(0, 16)}_`
assert(!source.includes(prefix), 'Protection marker collision')
const blocks: { marker: string, content: string }[] = []
const output: string[] = []
let cursor = 0
for (const [start, end] of merged) {
output.push(...lines.slice(cursor, start))
const marker = `${prefix}${blocks.length}_END`
blocks.push({ marker, content: lines.slice(start, end).join('\n') })
output.push(marker)
cursor = end
}
output.push(...lines.slice(cursor))
return {
masked: output.join('\n'),
restore(translated: string): string {
let result = translated
for (const block of blocks) {
assert(
result.split(block.marker).length === 2,
'Translation lost or duplicated a protected block',
)
result = result.replace(block.marker, () => block.content)
}
assert(
!result.includes(prefix),
'Unexpected protection marker in translation',
)
assert.deepEqual(
markdownSignature(result),
markdownSignature(source),
'Translation changed code, HTML, links or Markdown structure',
)
return result
},
}
}
export function splitTranslation(masked: string, limit = 4000): string[] {
assert(
Number.isInteger(limit) && limit >= 128,
'Invalid translation chunk limit',
)
const chunks: string[] = []
let current = ''
for (let line of masked.split('\n')) {
while (line.length > limit) {
if (current.trim())
chunks.push(current)
current = ''
let end = limit
if (/[\uD800-\uDBFF]/.test(line.charAt(end - 1)))
end--
chunks.push(line.slice(0, end))
line = line.slice(end)
}
if (current.length + line.length + 1 > limit) {
if (current.trim())
chunks.push(current)
current = ''
}
current += `${current ? '\n' : ''}${line}`
}
if (current.trim())
chunks.push(current)
return chunks
}
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import { setTimeout as delay } from 'node:timers/promises'
interface RemoteOptions {
key: string
request?: typeof fetch
signal?: AbortSignal
timeoutMs?: number
retries?: number
wait?: (ms: number) => Promise<unknown>
}
function object(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object'
? (value as Record<string, unknown>)
: {}
}
export async function translateChunk(
content: string,
options: RemoteOptions,
): Promise<string> {
assert(options.key, 'Missing DEEPSEEK_API_KEY')
const retries = options.retries ?? 3
assert(
Number.isInteger(retries) && retries > 0 && retries <= 5,
'Invalid retry limit',
)
const timeoutMs = options.timeoutMs ?? 60000
assert(
Number.isFinite(timeoutMs) && timeoutMs > 0,
'Invalid request timeout',
)
for (let attempt = 1; attempt <= retries; attempt++) {
options.signal?.throwIfAborted()
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(new Error('Translation request timed out')),
timeoutMs,
)
const signal = options.signal
? AbortSignal.any([controller.signal, options.signal])
: controller.signal
let retry = false
try {
const response = await (options.request ?? fetch)(
'https://api.deepseek.com/v1/chat/completions',
{
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${options.key}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: 0.2,
messages: [
{
role: 'user',
content: `Translate this Chinese technical Markdown to English. Preserve structure, inline code, URLs and every ARTPLAYER_KEEP marker exactly once. Return only Markdown, without an outer code fence or explanations.\n\n${content}`,
},
],
}),
},
)
retry = response.status === 429 || response.status >= 500
if (!response.ok)
await response.body?.cancel()
assert(response.ok, `Translation HTTP ${response.status}`)
const data: unknown = await response.json().catch((error: unknown) => {
if (signal.aborted)
throw error
assert.fail('Invalid translation JSON response')
})
const choices = object(data).choices
const text = object(
object(Array.isArray(choices) ? choices[0] : undefined).message,
).content
assert(
typeof text === 'string' && text.trim(),
'Invalid or empty translation response',
)
return text.trim()
}
catch (error) {
if (options.signal?.aborted)
throw error
if (!(error instanceof assert.AssertionError))
retry = true
if (!retry || attempt === retries)
throw error
}
finally {
clearTimeout(timeout)
}
await (options.wait ?? delay)(500 * attempt)
}
throw new Error('Translation retries exhausted')
}
+273
View File
@@ -0,0 +1,273 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { globSync } from 'glob'
import { atomicWrite, ownedPath, read, sha256 } from './files.ts'
import {
markdownSignature,
protectMarkdown,
splitTranslation,
} from './markdown.ts'
interface TranslationEntry {
source: string
target: string
relative: string
sourceHash: string
targetHash: string | null
translatedHash?: string
}
interface Draft {
schemaVersion: 1
status: 'incomplete' | 'failed' | 'complete' | 'applied'
entries: TranslationEntry[]
error?: string
}
const docs = 'packages/artplayer-vitepress/docs'
const drafts = 'refactor/.cache/translations'
export function translationPlan(root: string): TranslationEntry[] {
return [
'index.md',
...globSync('{advanced,component,start}/**/*.md', {
cwd: ownedPath(root, docs),
posix: true,
}).sort(),
].map((relative) => {
const source = `${docs}/${relative}`
const target = `${docs}/en/${relative}`
const destination = ownedPath(root, target)
return {
source,
target,
relative,
sourceHash: sha256(read(ownedPath(root, source))),
targetHash: fs.existsSync(destination) ? sha256(read(destination)) : null,
}
})
}
export async function createTranslationDraft(
root: string,
translate: (content: string, signal: AbortSignal) => Promise<string>,
concurrency = 3,
): Promise<string> {
assert(Number.isInteger(concurrency) && concurrency > 0 && concurrency <= 5)
const entries = translationPlan(root)
const cache = ownedPath(root, drafts)
fs.mkdirSync(cache, { recursive: true })
const directory = fs.mkdtempSync(path.join(cache, 'draft-'))
const manifest: Draft = { schemaVersion: 1, status: 'incomplete', entries }
const save = () =>
atomicWrite(
path.join(directory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
save()
const controller = new AbortController()
let cursor = 0
let failure: unknown
async function worker(): Promise<void> {
try {
while (!controller.signal.aborted) {
const entry = entries[cursor++]
if (!entry)
return
const source = read(ownedPath(root, entry.source))
assert.equal(
sha256(source),
entry.sourceHash,
'Source changed before translation',
)
const protectedSource = protectMarkdown(source)
const chunks: string[] = []
for (const chunk of splitTranslation(protectedSource.masked)) {
controller.signal.throwIfAborted()
chunks.push(await translate(chunk, controller.signal))
}
controller.signal.throwIfAborted()
const translated = protectedSource.restore(chunks.join('\n\n'))
assert(translated.trim(), 'Empty translated document')
atomicWrite(ownedPath(directory, entry.relative), translated)
entry.translatedHash = sha256(translated)
save()
}
}
catch (error) {
if (!controller.signal.aborted)
failure = error
controller.abort(error)
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, entries.length) }, worker),
)
if (controller.signal.aborted) {
manifest.status = 'failed'
manifest.error
= failure instanceof Error ? failure.message : 'Translation failed'
save()
throw new Error(
`Translation draft failed; existing English files unchanged. Draft: ${directory}. ${manifest.error}`,
)
}
manifest.status = 'complete'
save()
return directory
}
function inspectDraft(root: string, directory: string, checkHash = true) {
const relative = path
.relative(fs.realpathSync(root), path.resolve(directory))
.split(path.sep)
.join('/')
assert(
relative.startsWith(`${drafts}/draft-`),
'Apply requires an owned translation draft',
)
const safeDirectory = ownedPath(root, relative)
const manifest = JSON.parse(
read(ownedPath(safeDirectory, 'manifest.json')),
) as Draft
assert(
manifest.schemaVersion === 1
&& manifest.status === 'complete'
&& Array.isArray(manifest.entries),
'Draft is not complete',
)
const current = translationPlan(root)
assert.equal(
manifest.entries.length,
current.length,
'Translation source set changed',
)
const changes = current.map((entry, index) => {
const saved = manifest.entries[index]
assert(
saved
&& saved.source === entry.source
&& saved.target === entry.target
&& saved.relative === entry.relative,
'Invalid draft file mapping',
)
assert.equal(
saved.sourceHash,
entry.sourceHash,
'Source changed since translation',
)
assert.equal(
saved.targetHash,
entry.targetHash,
'English document changed since translation',
)
const content = read(ownedPath(safeDirectory, entry.relative))
if (checkHash) {
assert.equal(
sha256(content),
saved.translatedHash,
'Draft content changed without validation',
)
}
assert.deepEqual(
markdownSignature(content),
markdownSignature(read(ownedPath(root, entry.source))),
'Draft changed protected Markdown',
)
const file = ownedPath(root, entry.target)
return {
...entry,
file,
content,
before: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null,
}
})
return { safeDirectory, manifest, changes }
}
export function validateTranslationDraft(
root: string,
directory: string,
): void {
const { safeDirectory, manifest, changes } = inspectDraft(
root,
directory,
false,
)
changes.forEach((change, index) => {
const entry = manifest.entries[index]
assert(entry)
entry.translatedHash = sha256(change.content)
})
atomicWrite(
ownedPath(safeDirectory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
}
export function applyTranslationDraft(
root: string,
directory: string,
write: typeof atomicWrite = atomicWrite,
): void {
const { safeDirectory, manifest, changes } = inspectDraft(root, directory)
const applied: typeof changes = []
try {
for (const change of changes) {
assert.equal(
sha256(read(ownedPath(root, change.source))),
change.sourceHash,
'Source changed during apply',
)
assert.equal(
read(ownedPath(safeDirectory, change.relative)),
change.content,
'Draft changed during apply',
)
assert.equal(ownedPath(root, change.target), change.file)
const currentHash = fs.existsSync(change.file)
? sha256(read(change.file))
: null
assert.equal(
currentHash,
change.targetHash,
'English document changed during apply',
)
write(change.file, change.content)
applied.push(change)
}
for (const change of changes) {
assert.equal(
sha256(read(ownedPath(root, change.source))),
change.sourceHash,
'Source changed during apply',
)
}
manifest.status = 'applied'
atomicWrite(
ownedPath(safeDirectory, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
)
}
catch (error) {
const failures: unknown[] = [error]
for (const change of applied.reverse()) {
try {
assert.equal(
sha256(read(change.file)),
sha256(change.content),
'Concurrent change prevents safe rollback',
)
if (change.before === null)
fs.unlinkSync(change.file)
else atomicWrite(change.file, change.before)
}
catch (failure) {
failures.push(failure)
}
}
throw new AggregateError(
failures,
'Draft apply failed; inspect errors and retained draft before retrying',
)
}
}
+2 -260
View File
@@ -1,262 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { glob } from 'glob'
import { runTranslation } from './documentation/cli.ts'
dotenv.config()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '../packages/artplayer-vitepress')
const srcDirs = ['docs/advanced', 'docs/component', 'docs/start']
const indexFile = 'docs/index.md'
const outputRoot = path.join(rootDir, 'docs/en')
const API_URL = 'https://api.deepseek.com/v1/chat/completions'
const API_KEY = process.env.DEEPSEEK_API_KEY
// 配置常量
const MAX_CONCURRENT_REQUESTS = 5 // 并发请求数
const REQUEST_TIMEOUT = 60000 // 请求超时时间 60秒
const MAX_RETRIES = 3 // 最大重试次数
const CHUNK_SIZE = 4000 // 分块大小
if (!API_KEY) {
console.error('❌ Missing DEEPSEEK_API_KEY in .env file')
process.exit(1)
}
// ==================== Markdown 修复功能 ====================
/**
* 修复 Markdown 代码块闭合问题
* - 遇到 ## 标题时补上遗漏的 ```
* - 遇到 ::: 时补上遗漏的 ```
* - 文件末尾检查是否需要补上
*/
function fixMarkdownCodeBlocks(content) {
const lines = content.split('\n')
const fixedLines = []
let inCodeBlock = false
let fixes = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// 检查是否进入代码块
if (line.startsWith('```') && !inCodeBlock) {
inCodeBlock = true
fixedLines.push(line)
continue
}
// 检查是否正常退出代码块
if (line.startsWith('```') && inCodeBlock) {
inCodeBlock = false
fixedLines.push(line)
continue
}
// 在代码块中遇到标题(缺少 ```)
if (inCodeBlock && line.startsWith('## ')) {
fixedLines.push('```')
fixedLines.push('')
inCodeBlock = false
fixes++
}
// 在代码块中遇到 :::(admonition)
if (inCodeBlock && line.startsWith(':::')) {
fixedLines.push('```')
fixedLines.push('')
inCodeBlock = false
fixes++
}
fixedLines.push(line)
}
// 文件末尾仍在代码块中
if (inCodeBlock) {
fixedLines.push('```')
fixes++
}
return { content: fixedLines.join('\n'), fixes }
}
// ==================== 翻译功能 ====================
function splitMarkdown(text, maxLen = CHUNK_SIZE) {
const lines = text.split('\n')
const chunks = []
let buffer = ''
let insideCodeBlock = false
for (const line of lines) {
if (line.trim().startsWith('```'))
insideCodeBlock = !insideCodeBlock
if ((`${buffer}\n${line}`).length > maxLen && !insideCodeBlock) {
chunks.push(buffer)
buffer = ''
}
buffer += `\n${line}`
}
if (buffer.trim())
chunks.push(buffer)
return chunks
}
function cleanTranslation(text) {
return text
.replace(/^```(markdown|md)?/gi, '')
.replace(/```$/g, '')
.replace(/^(Here(')s|Below is|Translation|The English version|Here is)[::]?\s*/gi, '')
.replace(/(Translation completed\.?|End of translation\.?)$/gi, '')
.trim()
}
async function callDeepSeekAPI(prompt, tag, chunkId) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.log(`🔹 [${tag}] Translating chunk ${chunkId} (attempt ${attempt}/${MAX_RETRIES})`)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const res = await fetch(API_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: prompt }],
temperature: 0.2,
}),
})
clearTimeout(timeout)
// 处理限流情况
if (res.status === 429) {
const delay = 2 ** attempt * 1000
console.log(`⏳ [${tag}] Rate limited, waiting ${delay}ms...`)
await new Promise(r => setTimeout(r, delay))
continue
}
if (!res.ok) {
const error = await res.text()
console.error(`[${tag}] API Error:`, error)
throw new Error(`DeepSeek API error: ${res.status}`)
}
const data = await res.json()
if (!data.choices || !data.choices[0]) {
throw new Error('Invalid API response: missing choices')
}
return data.choices[0].message?.content?.trim() || ''
}
catch (err) {
if (err.name === 'AbortError') {
console.error(`[${tag}] Request timeout for chunk ${chunkId}`)
}
if (attempt === MAX_RETRIES) {
throw err
}
const delay = 1000 * attempt
console.log(`⚠️ [${tag}] Retry ${attempt}/${MAX_RETRIES} after ${delay}ms...`)
await new Promise(r => setTimeout(r, delay))
}
}
}
async function translateText(text, fileName) {
const chunks = splitMarkdown(text, CHUNK_SIZE)
let result = ''
for (let i = 0; i < chunks.length; i++) {
const prompt = `
You are a professional English technical translator for documentation.
Translate the following Markdown content from Simplified Chinese to fluent English.
Keep all Markdown structure, code blocks, and formatting intact.
Return ONLY the translated Markdown content — do NOT add any explanations, prefixes, or summaries.
Text to translate:
${chunks[i]}
`.trim()
const raw = await callDeepSeekAPI(prompt, fileName, `${i + 1}/${chunks.length}`)
const translated = cleanTranslation(raw)
result += `\n\n${translated}`
}
return result.trim()
}
async function processFile(inputPath, outputPath) {
const content = fs.readFileSync(inputPath, 'utf8')
const fileName = path.basename(inputPath)
console.log(`🌍 Translating: ${inputPath}`)
// 1. 翻译
let translated = await translateText(content, fileName)
// 2. 修复 Markdown 语法
const { content: fixed, fixes } = fixMarkdownCodeBlocks(translated)
if (fixes > 0) {
console.log(`🔧 [${fileName}] Fixed ${fixes} unclosed code blocks`)
translated = fixed
}
// 3. 保存
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, translated, 'utf8')
console.log(`✅ Saved: ${outputPath}`)
}
async function runWithConcurrency(tasks, limit = MAX_CONCURRENT_REQUESTS) {
const results = []
const executing = new Set()
for (const task of tasks) {
const p = task().finally(() => executing.delete(p))
results.push(p)
executing.add(p)
if (executing.size >= limit) {
await Promise.race(executing)
}
}
return Promise.all(results)
}
async function main() {
console.log('🚀 Starting translation...')
fs.rmSync(outputRoot, { recursive: true, force: true })
fs.mkdirSync(outputRoot, { recursive: true })
const indexPath = path.join(rootDir, indexFile)
const indexOut = path.join(outputRoot, 'index.md')
await processFile(indexPath, indexOut)
const tasks = []
for (const dir of srcDirs) {
const fullDir = path.join(rootDir, dir)
const files = await glob(`${fullDir}/**/*.md`)
for (const file of files) {
const relative = path.relative(rootDir, file)
const outputFile = path.join(outputRoot, relative.replace(/^docs[\\/]/, ''))
tasks.push(() => processFile(file, outputFile))
}
}
console.log(`🧠 Total files: ${tasks.length}, concurrency: ${MAX_CONCURRENT_REQUESTS}`)
await runWithConcurrency(tasks, MAX_CONCURRENT_REQUESTS)
console.log('🎉 Translation complete!')
}
main().catch((err) => {
console.error('❌ Translation failed:', err)
process.exit(1)
})
await runTranslation(process.argv.slice(2))
+3
View File
@@ -17,6 +17,9 @@
"build-test.js",
"build-ts.js",
"build-site-assets.mjs",
"build-llm.js",
"trans-docs.js",
"documentation/**/*.ts",
"editor-declarations/**/*.ts",
"editor-types.mjs",
"plugin-editor-types.mjs"
+11
View File
@@ -146,3 +146,14 @@ semantic editor declarations. `yarn test:danmuku-types-package` packs and instal
the packages outside the workspace, verifies exact member bytes and frozen offline
reinstallation, and checks historical and current compiler consumers. These type
checks do not replace the native browser suite or final distribution acceptance.
## Documentation pipeline regressions
`node --test test/documentation-pipeline.test.js` covers the frozen old translator's
delete-before-request, broken fence repair and exhausted 429 behavior; the new
draft workflow checks failure, worker cancellation/join, reviewed apply, stale
inputs, path escape, rollback and concurrent edits. Existing source Markdown
round-trips and the offline source corpus are checked against actual inputs.
A loopback HTTP server verifies a stalled response body times out. Mock responses
test bounded retries and invalid data; no paid translation is performed and these
tests do not certify English prose quality. Included in `test:node`.
+493
View File
@@ -0,0 +1,493 @@
import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Exercise the real local generation and draft filesystem contracts.
import test from 'node:test'
import vm from 'node:vm'
import ts from 'typescript'
import { runTranslation } from '../scripts/documentation/cli.ts'
import { buildCorpus } from '../scripts/documentation/corpus.ts'
import {
atomicWrite,
ownedPath,
read,
sha256,
} from '../scripts/documentation/files.ts'
import {
protectMarkdown,
splitTranslation,
} from '../scripts/documentation/markdown.ts'
import { translateChunk } from '../scripts/documentation/remote.ts'
import {
applyTranslationDraft,
createTranslationDraft,
translationPlan,
validateTranslationDraft,
} from '../scripts/documentation/translation.ts'
const root = process.cwd()
const cache = path.resolve('refactor/.cache')
const docs = 'packages/artplayer-vitepress/docs'
const source
= '# 说明\n\nKeep `api` and [link](./path).\n\n```md\n## inside code\n::: not an admonition\n```\n\n说明正文\n'
function fixture() {
const directory = fs.mkdtempSync(path.join(cache, 'ai-docs-test-'))
for (const [file, text] of [
[`${docs}/index.md`, source],
[`${docs}/start/option.md`, source],
[`${docs}/en/index.md`, 'Original index\r\n'],
[`${docs}/en/start/option.md`, 'Original option\r\n'],
[`${docs}/en/manual.md`, 'Preserve manually maintained extra page'],
])
atomicWrite(ownedPath(directory, file), text)
return directory
}
function remove(directory) {
const relative = path.relative(cache, fs.realpathSync(directory))
assert(relative.startsWith('ai-docs-test-') && !relative.includes(path.sep))
fs.rmSync(directory, { recursive: true, force: true })
}
function snapshot(directory) {
return ['index.md', 'start/option.md', 'manual.md'].map(file =>
fs.readFileSync(ownedPath(directory, `${docs}/en/${file}`), 'utf8'),
)
}
const translator = async chunk => chunk.replaceAll('说明', 'Description')
test('old translator deletes English before failure and corrupts legitimate Markdown code; old final 429 returns undefined', async () => {
const old = execFileSync(
'git',
['show', '647f3e7f12b1026f7d0de0830080d2d17b23d14d:scripts/trans-docs.js'],
{ encoding: 'utf8' },
)
const parsed = ts.createSourceFile(
'old.js',
old,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JS,
)
const functionCode = name =>
parsed.statements
.find(node => ts.isFunctionDeclaration(node) && node.name.text === name)
.getText(parsed)
const directory = fixture()
try {
const outputRoot = ownedPath(directory, `${docs}/en`)
const context = vm.createContext({
fs: {
...fs,
rmSync(target, options) {
assert.equal(path.resolve(target), outputRoot)
fs.rmSync(target, options)
},
},
path,
rootDir: ownedPath(directory, 'packages/artplayer-vitepress'),
outputRoot,
indexFile: 'docs/index.md',
processFile: async () => {
throw new Error('API failure')
},
console: { log() {} },
})
await assert.rejects(
vm.runInContext(`${functionCode('main')} main()`, context),
/API failure/,
)
assert(!fs.existsSync(path.join(outputRoot, 'manual.md')))
const damaged = vm.runInNewContext(
`${functionCode('fixMarkdownCodeBlocks')} fixMarkdownCodeBlocks(input)`,
{ input: source },
)
assert.notEqual(damaged.content, source)
assert(damaged.fixes > 0)
const rateLimited = vm.runInNewContext(
`${functionCode('callDeepSeekAPI')} callDeepSeekAPI('text','fixture',1)`,
{
MAX_RETRIES: 3,
REQUEST_TIMEOUT: 60000,
API_URL: 'fixture',
API_KEY: 'fixture',
AbortController,
fetch: async () => ({ status: 429 }),
setTimeout: (callback, ms) => {
if (ms !== 60000)
queueMicrotask(callback)
return 1
},
clearTimeout() {},
console: { log() {}, error() {} },
},
)
assert.equal(await rateLimited, undefined)
}
finally {
remove(directory)
}
})
test('Markdown protection preserves code and rejects lost markers or inline code/link changes; chunks stay bounded', () => {
const protectedSource = protectMarkdown(source)
assert(!protectedSource.masked.includes('inside code'))
assert.equal(protectedSource.restore(protectedSource.masked), source)
assert.throws(
() => protectedSource.restore('lost all blocks'),
/lost or duplicated/,
)
assert.throws(
() =>
protectedSource.restore(
protectedSource.masked.replace('`api`', '`wrong`'),
),
/changed code/,
)
assert.throws(
() =>
protectedSource.restore(
protectedSource.masked.replace('./path', './wrong'),
),
/changed code/,
)
assert.throws(() => protectMarkdown('```js\nunterminated'), /Unclosed/)
const chunks = splitTranslation(
`${'😀'.repeat(500)}\n${protectedSource.masked}`,
128,
)
assert(
chunks.every(
chunk =>
chunk.length > 0
&& chunk.length <= 128
&& !/[\uD800-\uDBFF]$/.test(chunk),
),
)
assert(chunks.some(chunk => chunk.includes('ARTPLAYER_KEEP_')))
})
test('failed or damaged translation only leaves a failed draft and never changes existing English', async () => {
for (const translate of [
async () => {
throw new Error('request failed')
},
async () => 'lost placeholders',
]) {
const directory = fixture()
try {
const before = snapshot(directory)
await assert.rejects(
createTranslationDraft(directory, translate, 2),
/draft failed/,
)
assert.deepEqual(snapshot(directory), before)
const drafts = fs.readdirSync(
ownedPath(directory, 'refactor/.cache/translations'),
)
const manifest = JSON.parse(
read(
ownedPath(
directory,
`refactor/.cache/translations/${drafts[0]}/manifest.json`,
),
),
)
assert.equal(manifest.status, 'failed')
}
finally {
remove(directory)
}
}
})
test('all 13 existing translation sources retain protected structure through chunking', () => {
const entries = translationPlan(root)
assert.equal(entries.length, 13)
for (const entry of entries) {
const text = read(ownedPath(root, entry.source))
const protectedSource = protectMarkdown(text)
assert.equal(
protectedSource.restore(protectedSource.masked),
text,
entry.relative,
)
protectedSource.restore(
splitTranslation(protectedSource.masked).join('\n\n'),
)
}
})
test('a failing translation cancels and awaits its active sibling before returning', async () => {
const directory = fixture()
let calls = 0
let siblingStopped = false
try {
const before = snapshot(directory)
await assert.rejects(
createTranslationDraft(
directory,
async (_chunk, signal) => {
if (++calls === 1) {
await new Promise(resolve => setImmediate(resolve))
throw new Error('First request failed')
}
await new Promise(resolve =>
signal.addEventListener('abort', resolve, { once: true }),
)
await new Promise(resolve => setImmediate(resolve))
siblingStopped = true
signal.throwIfAborted()
return ''
},
2,
),
/First request failed/,
)
assert.equal(calls, 2)
assert.equal(siblingStopped, true)
assert.deepEqual(snapshot(directory), before)
}
finally {
remove(directory)
}
})
test('rollback refuses to overwrite a concurrent English edit', async () => {
const directory = fixture()
try {
const draft = await createTranslationDraft(directory, translator)
let written
assert.throws(
() =>
applyTranslationDraft(directory, draft, (file, content) => {
if (written) {
atomicWrite(written, 'Concurrent author edit')
throw new Error('Second write failed')
}
atomicWrite(file, content)
written = file
}),
error =>
error instanceof AggregateError
&& error.errors.some(item => /Concurrent change/.test(item.message)),
)
assert.equal(read(written), 'Concurrent author edit')
assert.equal(snapshot(directory)[1], 'Original option\r\n')
}
finally {
remove(directory)
}
})
test('draft validation permits reviewed prose edits and apply preserves unselected pages', async () => {
const directory = fixture()
try {
const before = snapshot(directory)
const draft = await createTranslationDraft(directory, translator)
assert.deepEqual(snapshot(directory), before)
const file = ownedPath(draft, 'index.md')
atomicWrite(
file,
read(file).replace('Description', 'Reviewed description'),
)
assert.throws(
() => applyTranslationDraft(directory, draft),
/without validation/,
)
validateTranslationDraft(directory, draft)
applyTranslationDraft(directory, draft)
const after = snapshot(directory)
assert(after[0].includes('Reviewed description'))
assert(after[1].includes('Description'))
assert.equal(after[2], before[2])
assert.throws(
() => applyTranslationDraft(directory, draft),
/not complete/,
)
}
finally {
remove(directory)
}
})
test('stale sources, changed targets, path tampering and mid-apply failures are rejected without wiping English', async () => {
for (const mode of ['source', 'target', 'mapping', 'write-failure']) {
const directory = fixture()
try {
const draft = await createTranslationDraft(directory, translator)
if (mode === 'source') {
atomicWrite(
ownedPath(directory, `${docs}/index.md`),
'# Edited source',
)
}
if (mode === 'target') {
atomicWrite(
ownedPath(directory, `${docs}/en/index.md`),
'Manual English edit',
)
}
if (mode === 'mapping') {
const file = ownedPath(draft, 'manifest.json')
const manifest = JSON.parse(read(file))
manifest.entries[0].target = '../escape.md'
atomicWrite(file, JSON.stringify(manifest))
}
const before = snapshot(directory)
let writes = 0
assert.throws(() =>
applyTranslationDraft(directory, draft, (file, content) => {
if (mode === 'write-failure' && ++writes === 2)
throw new Error('Injected rename failure')
atomicWrite(file, content)
}),
)
assert.deepEqual(snapshot(directory), before)
}
finally {
remove(directory)
}
}
})
test('owned paths reject traversal and a junction outside the fixture root', () => {
const directory = fixture()
const external = fixture()
const link = path.join(directory, 'linked')
try {
assert.throws(() => ownedPath(directory, '../escape.md'), /Invalid/)
fs.symlinkSync(
external,
link,
process.platform === 'win32' ? 'junction' : 'dir',
)
assert.throws(() => ownedPath(directory, 'linked/new.md'), /escapes/)
fs.unlinkSync(link)
fs.symlinkSync(path.join(external, 'missing'), link, process.platform === 'win32' ? 'junction' : 'dir')
assert.throws(() => ownedPath(directory, 'linked/new.md'))
}
finally {
if (fs.lstatSync(link, { throwIfNoEntry: false }))
fs.unlinkSync(link)
remove(directory)
remove(external)
}
})
test('remote request rejects final rate limits, invalid/empty responses and auth without unbounded retries', async () => {
for (const [status, body, callsExpected] of [
[429, {}, 3],
[401, {}, 1],
[200, {}, 1],
[200, { choices: [{ message: { content: '' } }] }, 1],
]) {
let calls = 0
await assert.rejects(
translateChunk('fixture', {
key: 'fixture-key',
wait: async () => {},
request: async (_url, options) => {
calls++
assert.equal(options.headers.Authorization, 'Bearer fixture-key')
return Response.json(body, { status })
},
}),
)
assert.equal(calls, callsExpected)
}
let calls = 0
const text = await translateChunk('fixture', {
key: 'fixture-key',
wait: async () => {},
request: async () =>
++calls === 1
? new Response('', { status: 503 })
: Response.json({ choices: [{ message: { content: 'Translated' } }] }),
})
assert.equal(text, 'Translated')
assert.equal(calls, 2)
let invalidCalls = 0
await assert.rejects(translateChunk('fixture', {
key: 'fixture-key',
request: async () => {
invalidCalls++
return new Response('Invalid provider response body')
},
}), /Invalid translation JSON response/)
assert.equal(invalidCalls, 1)
})
test('timeout covers reading a real HTTP response body, not only receiving its headers', async () => {
const server = http.createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.write('{"choices":[')
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
try {
const address = server.address()
await assert.rejects(
translateChunk('fixture', {
key: 'fixture-key',
timeoutMs: 80,
retries: 1,
request: (_url, options) =>
fetch(`http://127.0.0.1:${address.port}`, options),
}),
/abort|timed out/i,
)
}
finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}
})
test('offline corpus is exact and deterministic; default translation and unknown flags never call the network', async () => {
const outputs = buildCorpus(root)
assert.deepEqual(outputs, buildCorpus(root))
for (const [file, content] of outputs)
assert.equal(read(ownedPath(root, file)), content)
const manifest = JSON.parse(outputs.get('docs/llms.manifest.json'))
for (const entry of manifest.sources) {
const text = read(ownedPath(root, entry.file))
assert.equal(sha256(text), entry.sha256Lf)
assert(outputs.get('docs/llms.txt').includes(text))
}
const original = globalThis.fetch
globalThis.fetch = async () => {
throw new Error('Unexpected network')
}
const directory = fixture()
try {
const before = snapshot(directory)
/* eslint-disable no-console -- Silence the CLI plan while checking its no-network behavior. */
const originalLog = console.log
try {
console.log = () => {}
await runTranslation([], directory)
}
finally {
console.log = originalLog
}
/* eslint-enable no-console */
await assert.rejects(runTranslation(['--unknown'], directory), /Use yarn/)
assert.deepEqual(snapshot(directory), before)
assert.equal(translationPlan(directory).length, 2)
const result = spawnSync(
process.execPath,
['scripts/build-llm.js', '--check'],
{
cwd: root,
env: { ...process.env, DEEPSEEK_API_KEY: '' },
encoding: 'utf8',
},
)
assert.equal(result.status, 0, result.stderr)
}
finally {
globalThis.fetch = original
remove(directory)
}
})