mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
refactor(docs): [SITE-AI-DOCS-01] make generation offline and translation draft based
This commit is contained in:
1 parent
647f3e7f12
commit
078cd9ef72
27 files changed
+8023
-3644
No files matched your search
@@ -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
File diff suppressed because it is too large.
Load diff
+7
-6
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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 首帧或其它开放问题的状态。
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 原
|
||||
|
||||
@@ -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 |
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
@@ -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`,
|
||||
],
|
||||
])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user