diff --git a/package.json b/package.json index a5d2a471a..68e3c928e 100644 --- a/package.json +++ b/package.json @@ -40,14 +40,14 @@ "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,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix", + "lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix", "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,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build}/**/*.ts\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix", + "lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix", "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/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.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/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.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 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: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:scaffold && 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", @@ -127,7 +127,9 @@ "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", "test:site-build": "node --test test/site-build.test.js test/build-docs.test.js test/site-markdown.test.js", - "test:site-editor": "node --test test/site-editor.test.js" + "test:site-editor": "node --test test/site-editor.test.js", + "typecheck:scaffold": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.scaffold.json --noEmit", + "test:scaffold": "node --test test/plugin-scaffold.test.js" }, "browserslist": "last 1 Chrome version", "devDependencies": { diff --git a/refactor/baselines/scaffold-validation.json b/refactor/baselines/scaffold-validation.json new file mode 100644 index 000000000..632f3cc4d --- /dev/null +++ b/refactor/baselines/scaffold-validation.json @@ -0,0 +1,160 @@ +{ + "task": "MOD-PLUGIN-01", + "date": "2026-09-14", + "baselineCommit": "07d5bfef2e815e8038362bb836b9c496c7ef9862", + "environment": { + "os": "Windows", + "node": "24.21.0", + "yarn": "1.22.22", + "typescript": "5.9.3", + "classicTypescript": "4.3.5" + }, + "results": { + "scaffold": { + "passed": 11, + "failed": 0, + "skipped": 0 + }, + "ci": { + "passed": 50, + "failed": 0 + }, + "strictTypes": "passed", + "toolchain": "passed", + "lint": { + "errors": 0, + "warnings": 1, + "existingWarning": "docs/assets/ts/artplayer.d.ts unused ts/no-namespace directive" + }, + "generatedPackage": { + "formats": [ + "UMD es2020", + "UMD es2015 legacy", + "ESM es2020" + ], + "exports": [ + "ESM default", + "CommonJS direct and .default", + "legacy ESM and CommonJS" + ], + "types": [ + "strict source", + "TS5.9.3 NodeNext .mts/.cts", + "TS4.3.5 classic positive/negative" + ], + "lint": "zero warnings", + "dom": "linkedom duplicate stylesheet and no-DOM imports" + }, + "baseline": { + "passed": 522, + "failed": 0, + "skipped": 0 + } + }, + "limitations": [ + "No real browser playback or remote CI execution", + "No new real workspace; integration output is an ignored fixture", + "No all-files atomicity or killed-process recovery", + "No new dependencies or changes to yarn.lock" + ], + "sourceFiles": [ + { + "path": "scripts/plugin/create.js", + "sha256": "ebc413b32037561698efd3c19776681cca57f259f09fab86e8d06d419b9a6647" + }, + { + "path": "scripts/plugin/cli.ts", + "sha256": "0aa466a75c6e808ab652ca5409134e8618c1b0d96908c4a27362927c2f5084e4" + }, + { + "path": "scripts/plugin/render.ts", + "sha256": "339485dd33bb6bf1e2602f89d65d88e6a4aea6aa4133f19c64b6cda9de466090" + }, + { + "path": "scripts/plugin/publish.ts", + "sha256": "41604d49c22c5e30e69c17a4664055c2cd6442de473b85e3550a5cc96c6aca0f" + }, + { + "path": "scripts/tsconfig.scaffold.json", + "sha256": "54388f6b1cc1c4173fcce70bbacd9512469f89bb19145be6aeb8c66eb6bd6fbc" + }, + { + "path": "scripts/plugin/template/package.json", + "sha256": "3a8b700ba7f662cba8324678743339acdc1db33b7f9e7f37908971ff7125c982" + }, + { + "path": "scripts/plugin/template/src/index.ts.tpl", + "sha256": "53183f3a3476c1c3225cf57c167b4bd24df7d5c46949b38889ebbe9326fceb72" + }, + { + "path": "scripts/plugin/template/src/stylesheet.ts.tpl", + "sha256": "c9239de63c8b3a675fb0f8b1bd9f1a33544cae29864b8d16076a379ad89a5427" + }, + { + "path": "scripts/plugin/template/src/assets.d.ts.tpl", + "sha256": "abc216473dd0869001b5a88ed7add80f0449e71d1d3af872a6df9631f96e4641" + }, + { + "path": "scripts/plugin/template/src/style.less", + "sha256": "64c690eacbc33b44517d1fe84c53dbce3b76d97ed54ea273c6400119b46033d2" + }, + { + "path": "scripts/plugin/template/types/api.d.ts.tpl", + "sha256": "24dd42ddae63405c6752528077b435f8ee398bc233efaab064707475a049b48a" + }, + { + "path": "scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts.tpl", + "sha256": "82d68fc8311076f9e9466fb0e73927390501770dc02d2fdc828a637638f69949" + }, + { + "path": "scripts/plugin/template/types/artplayer-plugin-{{name}}.d.mts.tpl", + "sha256": "63dbe5a3e754f380abb7de53e3d8de7074d424892b4f724465111c139cb65b71" + }, + { + "path": "scripts/plugin/template/test/plugin.test.mjs.tpl", + "sha256": "e60bf566d665ac0d77a4d41899f26b01a24acf2516204e285f87c763f5b6ff57" + }, + { + "path": "scripts/plugin/template/tsconfig.json", + "sha256": "528081768e5c3a6361dd0a2cfc59f953257caeae70bd5305b2db2fe4d98009ad" + }, + { + "path": "test/plugin-scaffold.test.js", + "sha256": "ac9580ea49e937abc5129324ee5de930320dea59ba556f71ad9b1612a224f981" + } + ], + "logs": [ + { + "path": "refactor/.cache/scaffold-initial.log", + "sha256": "6cd75297d36e46b1e4f9e462d296383dd76304d2befed6c6662d1fe21e74f5f0" + }, + { + "path": "refactor/.cache/scaffold-generated-lint-initial.log", + "sha256": "99d420f2c2e4a76248b59bb8593297f127337d4eb5ddb81463e9e4ea80269bbc" + }, + { + "path": "refactor/.cache/scaffold-final.log", + "sha256": "8505990bf0131dfa3598fa985cdbc7e294c6a62a2f1e905c864a082ac63f0179" + }, + { + "path": "refactor/.cache/scaffold-types.log", + "sha256": "4bff36c4b15e17ba27c5065a7db072e324500a827be1e11ebb60d370271ff336" + }, + { + "path": "refactor/.cache/scaffold-lint.log", + "sha256": "928ec73d5f8ea3b16e4bc051293d425692bd393df790abecb9ee320adecdff62" + }, + { + "path": "refactor/.cache/scaffold-ci.log", + "sha256": "6cd851ff61057e007c24ff0898578c42dabb634096cb8b45d36bde7fd3114be0" + }, + { + "path": "refactor/.cache/scaffold-toolchain.log", + "sha256": "1d5e2306c57bc1e90ae5625e0924e56cde9e27eb3cf89c64bfe735a36dd42863" + }, + { + "path": "refactor/.cache/scaffold-baseline.log", + "sha256": "2ef047976b63d707e3e4fde2e3475b4d0b53c699c6120826870f0bec3816d971" + } + ] +} diff --git a/refactor/build-development.md b/refactor/build-development.md index 845e2ee3a..af0432e33 100644 --- a/refactor/build-development.md +++ b/refactor/build-development.md @@ -1,5 +1,12 @@ # 已实现的 JS/TS 构建与开发入口 +MOD-PLUGIN-01 将 `yarn create:plugin` 的实现拆为严格 TS 渲染/文件写入/CLI, +保留旧 JS 命令入口。生成 TS 工厂、CJS/ESM 声明、分发消费测试与维护文档; +拒绝覆盖既有包/示例,失败时回退自己的写入并保留外部修改。 +运行 `yarn typecheck:scaffold` 与 `yarn test:scaffold`,具体限制和模块地图见 +[生成器维护指南](../scripts/plugin/README.md)。MOD-02 继续负责 dev/build/utils, +本子任务不代表全部工程脚本已经迁移。 + ENG-06 保留原来的 Vite 7.3.6、Terser、三种库产物及路径,增加选包参数和 TS 入口;未切换 bundler 或运行时依赖。固定工具链仍为 Node 24.21.0 / Yarn 1.22.22。 ENG-12 将库配置的 publicDir 设为 false,防止 core/public 的声明源码被复制到 dist。 diff --git a/refactor/changes/2026-09-14-MOD-PLUGIN-01-scaffold.md b/refactor/changes/2026-09-14-MOD-PLUGIN-01-scaffold.md new file mode 100644 index 000000000..e8a37f72f --- /dev/null +++ b/refactor/changes/2026-09-14-MOD-PLUGIN-01-scaffold.md @@ -0,0 +1,73 @@ +# MOD-PLUGIN-01: TypeScript plugin scaffold + +## Scope and compatibility + +The original generator at `07d5bfef2e815e8038362bb836b9c496c7ef9862` overwrites an +existing dotted demo file, and its README uses the hyphenated name instead of that +actual file name. The frozen implementation is executed in a fixture to reproduce +both defects. The old command and valid name/global/example mapping remain; invalid +names with empty segments and extra arguments now fail before writing. No existing +package, public declaration, built distribution, or real example was regenerated. + +`MOD-PLUGIN-01` is a child dependency of MOD-02. It finishes the generator/template +part independently; development/build/shared-config migration remains with MOD-02. + +## Implementation and maintenance + +The checked JS command shim delegates to strict TS CLI, pure rendering and filesystem +publication modules. Rendering validates placeholders, name and output collisions. +Publication stages complete files, exclusively reserves a new package and hard-links +without replacing existing destinations. Caught failures roll back owned unchanged +files and empty directories, preserving edits and concurrent output with recovery +paths. Existing symlink/junction destinations are rejected. This is per-file atomic +publication, not all-files/crash atomicity or adversarial filesystem protection. + +The new template includes TS source, a once-per-document stylesheet import with an +SSR guard, explicit shared option/result declarations, distinct CJS/ESM declaration +entries, main/module/legacy formats, direct/default CommonJS calls, package-consumer +tests and a maintenance README. The demo links to the actual dotted example. +The factory remains synchronous. New package version starts at 1.0.0; the existing +packages' independent next-major targets are unchanged. + +The module map, failure recovery boundaries and reproducible commands live in +[scripts/plugin/README.md](../../scripts/plugin/README.md). New dependencies: none. +Generated manifests declare the ArtPlayer host as a peer; generation does not install +or update the root lock, inventories or release scope. Root `test:scaffold`, +`typecheck:scaffold`, lint coverage, Node tests and CI typecheck now cover this tool. + +## Verification and actual limits + +- Node 24.21.0, Yarn Classic 1.22.22, Windows. Strict toolchain check passed. +- `yarn typecheck:scaffold`: strict TS modules plus JS shim passed. +- `yarn test:scaffold`: 11 passed, zero skipped. Tests cover the frozen defects, + malformed template/name input, existing/competing outputs, mid-write rollback, + retention of externally edited files, redirected directories and CLI invocation + from another directory. A generated fixture is linted with zero warnings, built + through the real three-format production CLI, then consumed through actual + ESM/CJS/legacy package exports. Strict source, TS 5.9.3 NodeNext import/require and + TS 4.3.5 classic consumers pass positive and negative cases. Repeated stylesheet + loading and no-DOM import are checked with linkedom/VM, not real media playback. +- `yarn lint`: passed with the one pre-existing unused directive warning in + `docs/assets/ts/artplayer.d.ts`. +- `yarn test:ci`: 50 passed. This proves local workflow-policy regressions, not a + remote GitHub run. +- `yarn test:baseline`: 522 passed, zero skipped; frozen package/runtime/type and + refactor workflow regression checks remain intact. +- Initial integration failure was the nested Node test runner's inherited context, + which suppressed the child reporter. The runner now removes NODE_TEST_CONTEXT; + child test completion is asserted. Generated-output lint then found manifest + array ordering and the old template Less indentation; both templates corrected. + Initial logs are retained separately from the successful run. + +Evidence and source/log hashes: [scaffold-validation.json](../baselines/scaffold-validation.json). +Fixtures are ignored and removed through checked paths; no 23rd real workspace was +created. No frozen reinstall was needed because dependencies and yarn.lock are +unchanged. No browser playback, device, full release or publication claims arise +from this tooling task. Future generated plugin features need their own tests. + +## Rollback and next work + +Revert this task's dedicated commit to restore the previous generator, templates and +script integration; that also restores its known overwrite defect. Existing package +artifacts require no rollback. MOD-02 will migrate the remaining build/development +responsibilities while preserving command behavior. Local commit only; no push/npm. diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md index 3b601b9e7..97f5e4a6f 100644 --- a/refactor/ci-setup.md +++ b/refactor/ci-setup.md @@ -11,6 +11,7 @@ | `yarn typecheck` | 根/迁移包严格检查、当前与兼容 TS 消费;历史 NodeNext ESM 错误单独核对,见 typechecking.md | | `yarn typecheck:react` / `yarn typecheck:vue` | 原 React TSX / Vue SFC 示例严格检查,ci:check 同时执行对应 lint | | `yarn typecheck:docs-tools` / `yarn check:docs-smoke` | 严格检查 TS 示例/声明生成器及 JS/MJS 门面;只读核对确定性生成的 readiness smoke,ci:check 执行 | +| `yarn typecheck:scaffold` / `yarn test:scaffold` | 严格检查插件生成器与旧 JS 入口;生成包真实构建/类型/分发消费和写入失败回归;分别接入 ci:check 与 test:node | | `yarn check:editor-types` | 只读核对全部编辑器声明、SDK notices 和实际 libUris;主 TS 5.9.3/历史 4.3.5 整组语义检查,ci:check 执行 | | `yarn build:site-assets` / `yarn check:site-assets` | 从站点 browser/ TS 生成三个经典脚本;check 只读,root build:docs 先生成;完整 VitePress 构建另行验收 | | `yarn typecheck:site-assets` | 严格检查站点 browser/ 模块;生成脚本由 typecheck:docs-tools 的 checkJs 覆盖;均进入 ci:check | diff --git a/refactor/plan.md b/refactor/plan.md index 430812b07..57e7b0941 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -2,9 +2,9 @@ > 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。 -基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 243 项,范围 22 个包及工作区/示例。 +基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 244 项,范围 22 个包及工作区/示例。 -状态:todo 57 / doing 16 / blocked 0 / done 170 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 57 / doing 16 / blocked 0 / done 171 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -400,7 +400,8 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | | MOD-01 | workspace
Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo | -| MOD-02 | workspace
整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | todo | +| MOD-PLUGIN-01 | workspace
迁移插件生成器与类型化模板 | ENG-06, SITE-03 | 保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南 | 防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布 | M | done | +| MOD-02 | workspace
整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03, MOD-PLUGIN-01 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | todo | | MOD-03 | workspace
测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益 | M | todo | | MOD-04 | workspace
测量并优化重型插件/proxy | PKG-DANMUKU-09, PKG-MASK-06, PKG-MB-10, ENG-08 | 帧/队列/推理/音画同步与资源长期运行比较 | 不改默认算法/阈值,性能改善有证据;无收益则保留旧实现 | M | todo | | MOD-05 | workspace
完成工具链与性能采用决策 | MOD-01, MOD-02, MOD-03, MOD-04 | 最终 runtime/packageManager/构建配置及性能台账 | 干净安装和全包检查通过;Bun 未采用有理由,不为状态强行切换 | M | todo | @@ -620,6 +621,7 @@ - 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) +- MOD-PLUGIN-01: [记录](changes/2026-09-14-MOD-PLUGIN-01-scaffold.md) [记录](baselines/scaffold-validation.json) - PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) [记录](type-compatibility-policy.md) [记录](baselines/factory-compatibility-validation.json) [记录](changes/2026-09-13-PKG-FACTORY-01-compatible-types.md) - CORE-25: [记录](changes/2026-09-13-CORE-25-defaults-ssr.md) [记录](baselines/defaults-ssr-validation.json) - ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index 7c6bfe166..18168d73f 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,20 @@ # 进度与证据 +## MOD-PLUGIN-01 插件生成器与 TS 模板完成 + +生成器拆为严格 TS 的模板渲染、文件写入和 CLI,保留 create.js 旧命令入口及 +合法命名规则。冻结旧脚本复现示例覆盖和 README 链接不匹配;候选拒绝覆盖, +失败时回退自己的未修改文件,保留外部修改并报告恢复路径。生成 TS 工厂、 +CJS/ESM 声明、三格式分发消费测试和维护指南,不改既有包或添加真实 workspace。 +11 项目标测试、522 项 baseline、50 项 CI 回归通过;生成包 lint 零 warning, +源码及新旧编译器严格检查通过,全仓 lint 保留 1 条既有 warning。初始子进程 +测试报告问题及模板格式失败日志保留;详见[变更记录](changes/2026-09-14-MOD-PLUGIN-01-scaffold.md) +和[证据](baselines/scaffold-validation.json)。没有新增依赖、安装、推送或发布。 +DOM 证据是 linkedom,不是未来插件功能的真实播放验收;文件写入不承诺进程 +被杀后的自动恢复。244 项变为 171 done、16 doing、57 todo。下一步 MOD-02 +继续 dev/build/utils 等剩余工程脚本;VAST 默认行为选择仍待用户答复。 + + ## VTT Thumbnail 五核心组合检查点 PKG-VTT-THUMB-05 进入 doing。冻结真实核心5.1.6的197成员,和已有5.1.7、 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 40ba14464..9a2ac9fdc 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -261,3 +261,4 @@ | SITE-EDITOR-FAILURE-01 | resolved / 已复现 | Desktop editor evaluates raw TypeScript and cannot settle FileReader errors or boot with denied storage | SITE-03 | | SITE-EDITOR-AMD-01 | resolved / 已复现 | Late Monaco language AMD initialization races with example dependency loading | SITE-03 | | VTT-CORE-NAME-01 | open / 已复现 | Published VTT 1.0.1 control name conflicts with the core placeholder introduced in 5.1.7 | PKG-VTT-THUMB-05, REL-08 | +| SCAFFOLD-OUTPUT-01 | resolved / 已复现 | Plugin generation overwrites existing examples and links to a different example name | MOD-PLUGIN-01 | diff --git a/refactor/risks.json b/refactor/risks.json index 014586231..921d5c644 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -5892,6 +5892,28 @@ "compatibleResolution": "Preserve current vtt-thumbnail and reserved core control identities; distinguish the actual working 1.0.1/5.1.6 pair from the already failing 5.1.7/5.4.0/candidate pair. Decide the supported historical pairing/migration or a compatible accommodation during release review without hiding duplicate-control failures.", "closureCriteria": "Historical working and failing browser pairs remain reproducible; any claimed accommodation preserves reserved-control ownership and duplicate-registration contracts, or an explicit reviewed historical support/migration boundary is recorded.", "workspaceState": "Final three-engine suite passes nine assertions of this historical rejection, not nine successful compatibility cells. No production control behavior was changed." + }, + { + "id": "SCAFFOLD-OUTPUT-01", + "title": "Plugin generation overwrites existing examples and links to a different example name", + "confirmation": "reproduced", + "status": "resolved", + "owners": [ + "MOD-PLUGIN-01" + ], + "evidence": [ + "refactor/changes/2026-09-14-MOD-PLUGIN-01-scaffold.md", + "refactor/baselines/scaffold-validation.json", + "test/plugin-scaffold.test.js" + ], + "compatibleResolution": "Keep valid CLI and package/global/example names; reject existing outputs and generate the README from the actual example name.", + "closureCriteria": "Reproduce both historical defects and verify candidate collision/rollback behavior and a built typed package.", + "resolutionEvidence": [ + "refactor/changes/2026-09-14-MOD-PLUGIN-01-scaffold.md", + "refactor/baselines/scaffold-validation.json", + "test/plugin-scaffold.test.js" + ], + "resolutionRationale": "Frozen old generator reproduces overwrite and mismatched URL. Candidate refuses old or concurrent output, preserves external edits on rollback, generates matching dotted URLs and passes actual three-format package consumers and strict types." } ] } diff --git a/refactor/tasks.json b/refactor/tasks.json index 4597e7c33..09216bb76 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -4427,6 +4427,26 @@ "acceptance": "Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件", "evidence": [] }, + { + "id": "MOD-PLUGIN-01", + "phase": "7 工具链与性能", + "title": "迁移插件生成器与类型化模板", + "scope": [ + "workspace" + ], + "dependsOn": [ + "ENG-06", + "SITE-03" + ], + "status": "done", + "risk": "M", + "deliverable": "保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南", + "acceptance": "防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布", + "evidence": [ + "changes/2026-09-14-MOD-PLUGIN-01-scaffold.md", + "baselines/scaffold-validation.json" + ] + }, { "id": "MOD-02", "phase": "7 工具链与性能", @@ -4436,7 +4456,8 @@ ], "dependsOn": [ "ENG-06", - "SITE-03" + "SITE-03", + "MOD-PLUGIN-01" ], "status": "todo", "risk": "M", diff --git a/refactor/typechecking.md b/refactor/typechecking.md index 18c9ea0c7..b99aeeb64 100644 --- a/refactor/typechecking.md +++ b/refactor/typechecking.md @@ -1,5 +1,10 @@ # 已实现的类型检查与迁移入口 +`yarn typecheck:scaffold` 检查 scripts/plugin 的严格 TS 模块及旧 JS 命令入口。 +模板本身保留占位符,`yarn test:scaffold` 对实际渲染包执行源码检查和 +TS 5.9.3 NodeNext / TS 4.3.5 classic 消费者正反例;生成声明地图见 +[插件生成器维护指南](../scripts/plugin/README.md)。 + PKG-ADS-04 已协调公开声明:根入口保留历史输入,/runtime 使用准确输入,同一 JS 实现不进行字符串时长转换。CJS/ESM 桥、五种消费者模式、实际旧声明对照与真实 Monaco 生成运行已接入。`ads-source.ts` 检查源码与公开工厂的赋值关系;`ads.ts` 包含十个 diff --git a/scripts/plugin/README.md b/scripts/plugin/README.md new file mode 100644 index 000000000..48caea954 --- /dev/null +++ b/scripts/plugin/README.md @@ -0,0 +1,86 @@ +# Plugin scaffold maintenance + +Run `yarn create:plugin some-name` from the repository. The historical +`node scripts/plugin/create.js some-name` path remains available and always targets +the repository containing that script, even if the current directory differs. +Use Node from `.node-version` and Yarn Classic 1.22.22. `--help` writes nothing. +Names must be lowercase words separated by single hyphens; malformed names and +extra arguments now fail before writing. Existing valid CLI names keep their +package/global/example naming convention (`some-name` / `artplayerPluginSomeName` +/ `some.name.js`). The README demo now uses the actual dotted example name. + +## Ownership and write sequence + +| Module | Responsibility | +| --- | --- | +| `create.js` | Checked JS compatibility shim; repository root, argv and failure exit status | +| `cli.ts` | Validate command shape, render then publish, print next steps | +| `render.ts` | Read text templates into a deterministic relative-path/content map; validate names, placeholders and output collisions | +| `publish.ts` | Preflight destinations, stage complete text, exclusively reserve the package name and link files, roll back owned writes on failure | +| `template/` | Generated package source, declarations, manifest, built-package tests and maintenance guide | + +Templates ending in `.tpl` lose that suffix. `{{name}}`, `{{export}}` and +`{{example}}` are the only replacements, including in filenames. Templates are +UTF-8 text; links and unknown placeholders fail during rendering. Invalid TS +placeholder identifiers are intentionally stored as `.tpl`; lint the renderer +normally, and typecheck/build its rendered output through the integration test. + +The writer refuses existing packages (including empty directories), examples and +redirected destination paths. An exclusive temporary directory inside `packages/` +holds the complete text before the final package is reserved. Hard links publish +each complete file without overwriting another writer. Filesystem support for +same-volume hard links is required; failure is reported and owned writes roll back. +Staging never contains a top-level package.json and is ignored by project discovery. + +On a caught failure, only files still matching this operation's written contents +are removed, followed by empty directories created by this operation. Changed files +and nonempty directories remain, with recovery paths in the error and its cause. +The staged directory is removed only after checking its actual path and parent. +The injected link function is an internal test seam for write failure/collision +cases, not a CLI option. Publication is atomic per file, **not** a transaction over +all files or crash recovery: a killed process may leave a partial new package or +staging directory for manual inspection. This is not protection against an actor +actively replacing filesystem ancestors between checks. + +## Generated package contract + +The synchronous TS factory returns its registration name, with a self `.default` +alias for CommonJS users. The three existing build formats and `/legacy` entry +remain. CommonJS/older-TS `.d.ts` and ESM `.d.mts` share `types/api.d.ts`, also used +by the runtime source. The initial options type deliberately has no fields; +implement explicit owned fields and behavior tests when developing the plugin. +Styles are injected once per document at module evaluation, with a no-DOM import +guard. During document loading this now inserts the style immediately instead of +scheduling a DOMContentLoaded handler; imports after loading remain supported. +These are templates for new packages; +the command never rewrites an existing plugin's API or stylesheet behavior. + +Generation does not install dependencies, change the root Yarn lock or inventories, +or publish. New packages start at 1.0.0; the existing 22 packages' separate major +upgrade policy remains unchanged. Before adding a new real workspace to CI, update +the explicit package/demo/type/contract coverage and release scope through its own +task, then update only the root lock. The scaffold's `artplayer: "*"` peer is a +declaration of the host dependency, not evidence for every historical version. + +## Validation + +```sh +yarn typecheck:scaffold +yarn test:scaffold +yarn lint +``` + +`test/plugin-scaffold.test.js` freezes the former generator at +`07d5bfef2e815e8038362bb836b9c496c7ef9862`, reproduces overwritten examples and +incorrect demo names, then checks validation, concurrent collision, rollback, +externally edited output retention and redirected directories. It runs the actual +old CLI path from a different directory and builds a generated fixture using +`scripts/build.js`. Built package tests exercise package export conditions, aliases +and synchronous results; strict source, NodeNext ESM/CJS and TS 4.3.5 consumers are +compiled. The DOM test checks duplicate stylesheet/import ownership with linkedom; +it is not browser playback evidence for a future plugin's features. + +Fixtures live under the ignored `refactor/.cache/` and are removed through checked +paths. Tests do not add a 23rd workspace or alter real examples. Root `test:node` +includes this suite, and `ci:check` includes the strict scaffold typecheck. Remote +CI execution remains a separate release requirement. diff --git a/scripts/plugin/cli.ts b/scripts/plugin/cli.ts new file mode 100644 index 000000000..1a3e6e902 --- /dev/null +++ b/scripts/plugin/cli.ts @@ -0,0 +1,16 @@ +import { publishPlugin } from './publish.ts' +import { renderPlugin } from './render.ts' + +export function createPlugin(root: string, args: string[]): void { + if (args.length === 1 && args[0] === '--help') { + console.log('Usage: yarn create:plugin ') + return + } + if (args.length !== 1 || !args[0]) + throw new Error('Provide exactly one plugin name. Use yarn create:plugin --help') + const name = args[0] + publishPlugin(root, name, renderPlugin(name)) + console.log(`Created artplayer-plugin-${name} and example ${name.replaceAll('-', '.')}.js`) + console.log(`Next: yarn build artplayer-plugin-${name}; yarn workspace artplayer-plugin-${name} test`) + console.log('Register the new workspace/demo in the refactor inventories and update the root Yarn lock before CI.') +} diff --git a/scripts/plugin/create.js b/scripts/plugin/create.js index 7df945fcb..f2d6281a0 100644 --- a/scripts/plugin/create.js +++ b/scripts/plugin/create.js @@ -1,107 +1,11 @@ -import fs from 'node:fs' -import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import { createPlugin } from './cli.ts' -// Get the current file path -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Get the plugin name from command line arguments -const args = process.argv.slice(2) -const pluginName = args[0] - -if (!pluginName) { - console.error('Please provide a plugin name, e.g., npm run create:plugin somePluginName') - process.exit(1) +try { + createPlugin(fileURLToPath(new URL('../../', import.meta.url)), process.argv.slice(2)) } - -// Validate plugin name -const pluginNameRegex = /^[a-z-]+$/ -if (!pluginNameRegex.test(pluginName)) { - console.error('Invalid plugin name. Only lowercase letters and hyphens are allowed.') - process.exit(1) +catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 } - -// Convert plugin name to export name (PascalCase) -const exportName = pluginName - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join('') -const exportNameFull = `artplayerPlugin${exportName}` - -// Define paths -const templateDir = path.join(__dirname, 'template') -const destDir = path.join(__dirname, '../../packages', `artplayer-plugin-${pluginName}`) -const exampleDir = path.join(__dirname, '../../docs/assets/example') -const exampleFileName = `${pluginName.replace(/-/g, '.')}.js` -const exampleFile = path.join(exampleDir, exampleFileName) - -// Check if a plugin with the same name already exists -if (fs.existsSync(destDir)) { - console.error(`Plugin ${pluginName} already exists. Please choose another name.`) - process.exit(1) -} - -// Create the destination directory -fs.mkdirSync(destDir, { recursive: true }) - -// Read files from the template directory and copy them to the destination directory -function copyTemplateFiles(templateDir, destDir) { - const files = fs.readdirSync(templateDir) - - files.forEach((file) => { - const templateFilePath = path.join(templateDir, file) - let destFilePath = path.join(destDir, file) - - const stats = fs.statSync(templateFilePath) - - if (stats.isDirectory()) { - fs.mkdirSync(destFilePath, { recursive: true }) - copyTemplateFiles(templateFilePath, destFilePath) - } - else { - // Replace placeholders in the file name - destFilePath = destFilePath.replace(/\{\{name\}\}/g, pluginName) - - let content = fs.readFileSync(templateFilePath, 'utf8') - - // Replace placeholders in the file content - content = content.replace(/\{\{name\}\}/g, pluginName) - content = content.replace(/\{\{export\}\}/g, exportNameFull) - - fs.writeFileSync(destFilePath, content) - } - }) -} - -// Create the example file -function createExampleFile() { - const exampleContent = ` -// npm i artplayer-plugin-${pluginName} -// import ${exportNameFull} from 'artplayer-plugin-${pluginName}'; - -var art = new Artplayer({ - container: '.artplayer-app', - url: '/assets/sample/video.mp4', - plugins: [ - ${exportNameFull}({ - // - }), - ], -}); - `.trim() - - if (!fs.existsSync(exampleDir)) { - fs.mkdirSync(exampleDir, { recursive: true }) - } - - fs.writeFileSync(exampleFile, exampleContent) - console.log(`Example file created at ${exampleFile}`) -} - -// Start copying template files and creating example file -copyTemplateFiles(templateDir, destDir) -createExampleFile() - -console.log(`Plugin ${pluginName} has been successfully created at ${destDir}`) diff --git a/scripts/plugin/publish.ts b/scripts/plugin/publish.ts new file mode 100644 index 000000000..00b78ea37 --- /dev/null +++ b/scripts/plugin/publish.ts @@ -0,0 +1,96 @@ +import fs from 'node:fs' +import path from 'node:path' + +function checkedPath(root: string, relative: string): string { + const target = path.resolve(root, relative) + const within = path.relative(root, target) + if (!within || within.startsWith('..') || path.isAbsolute(within)) + throw new Error(`Output escapes the repository: ${relative}`) + let current = root + for (const part of within.split(path.sep)) { + current = path.join(current, part) + try { + if (fs.lstatSync(current).isSymbolicLink()) + throw new Error(`Refusing redirected output: ${current}`) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error + } + } + return target +} + +function removeStage(stage: string, packages: string): void { + if (fs.realpathSync(stage) !== stage || path.dirname(stage) !== packages || !path.basename(stage).startsWith('.artplayer-scaffold-')) + throw new Error(`Refusing redirected scaffold cleanup: ${stage}`) + fs.rmSync(stage, { recursive: true, force: true }) +} + +export function publishPlugin(root: string, name: string, files: ReadonlyMap, link: typeof fs.linkSync = fs.linkSync): void { + root = fs.realpathSync(root) + const packages = checkedPath(root, 'packages') + if (!fs.statSync(packages).isDirectory()) + throw new Error('The repository packages directory is missing') + const destination = checkedPath(root, `packages/artplayer-plugin-${name}`) + if (fs.existsSync(destination)) + throw new Error(`Plugin ${name} already exists`) + for (const relative of files.keys()) { + if (fs.existsSync(checkedPath(root, relative))) + throw new Error(`Refusing to overwrite existing output: ${relative}`) + } + const stage = fs.mkdtempSync(path.join(packages, '.artplayer-scaffold-')) + const createdDirectories: string[] = [] + const createdFiles: { target: string, content: string }[] = [] + function directory(target: string) { + if (fs.existsSync(target)) + return + directory(path.dirname(target)) + fs.mkdirSync(target) + createdDirectories.push(target) + } + try { + let index = 0 + for (const content of files.values()) + fs.writeFileSync(path.join(stage, String(index++)), content, { flag: 'wx' }) + // Reserve the package name without replacing even an empty concurrently-created directory. + fs.mkdirSync(destination) + createdDirectories.push(destination) + index = 0 + for (const [relative, content] of files) { + const target = checkedPath(root, relative) + directory(path.dirname(target)) + link(path.join(stage, String(index++)), target) + createdFiles.push({ target, content }) + } + } + catch (error) { + const retained: string[] = [] + for (const { target, content } of createdFiles.reverse()) { + try { + if (fs.lstatSync(target).isSymbolicLink() || fs.readFileSync(target, 'utf8') !== content) + retained.push(target) + else fs.unlinkSync(target) + } + catch (failure) { + if ((failure as NodeJS.ErrnoException).code !== 'ENOENT') + retained.push(target) + } + } + for (const directory of createdDirectories.reverse()) { + try { + fs.rmdirSync(directory) + } + catch (failure) { + if ((failure as NodeJS.ErrnoException).code !== 'ENOENT') + retained.push(directory) + } + } + if (retained.length) + throw new Error(`Scaffolding failed; preserve changed outputs for recovery: ${retained.join(', ')}`, { cause: error }) + throw error + } + finally { + removeStage(stage, packages) + } +} diff --git a/scripts/plugin/render.ts b/scripts/plugin/render.ts new file mode 100644 index 000000000..ad5b534ff --- /dev/null +++ b/scripts/plugin/render.ts @@ -0,0 +1,50 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export function renderPlugin(name: string, template = fileURLToPath(new URL('./template', import.meta.url))): Map { + if (!/^[a-z]+(?:-[a-z]+)*$/.test(name)) + throw new Error('Use lowercase words separated by single hyphens for the plugin name') + const replacements: Record = { + name, + export: `artplayerPlugin${name.split('-').map(word => word[0]!.toUpperCase() + word.slice(1)).join('')}`, + example: name.replaceAll('-', '.'), + } + const replace = (text: string) => text.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + if (!Object.hasOwn(replacements, key)) + throw new Error(`Unknown template placeholder: ${key}`) + return replacements[key]! + }) + const files = new Map() + function visit(directory: string, relative = '') { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const source = path.join(directory, entry.name) + const target = path.posix.join(relative, entry.name) + if (entry.isSymbolicLink()) + throw new Error(`Template links are not supported: ${source}`) + if (entry.isDirectory()) { + visit(source, target) + } + else if (entry.isFile()) { + const output = `packages/artplayer-plugin-${name}/${replace(target).replace(/\.tpl$/, '')}` + if (files.has(output)) + throw new Error(`Duplicate template output: ${output}`) + files.set(output, replace(fs.readFileSync(source, 'utf8')).replaceAll('\r\n', '\n')) + } + else { + throw new Error(`Unsupported template entry: ${source}`) + } + } + } + visit(template) + files.set(`docs/assets/example/${replacements.example}.js`, `// npm i artplayer-plugin-${name} +// import ${replacements.export} from 'artplayer-plugin-${name}'; + +var art = new Artplayer({ + container: '.artplayer-app', + url: '/assets/sample/video.mp4', + plugins: [${replacements.export}({})], +}); +`) + return files +} diff --git a/scripts/plugin/template/README.md b/scripts/plugin/template/README.md index 1a10713fd..82f5ae847 100644 --- a/scripts/plugin/template/README.md +++ b/scripts/plugin/template/README.md @@ -4,7 +4,62 @@ ## Demo -[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-{{name}}/index.js&example={{name}}) +[Local demo](http://localhost:8082/?libs=./uncompiled/artplayer-plugin-{{name}}/index.js&example={{example}}) + +Run `yarn dev artplayer-plugin-{{name}}` from the repository root first. +The generated example is `docs/assets/example/{{example}}.js`. + +## Usage + +```js +import {{export}} from 'artplayer-plugin-{{name}}' + +const art = new Artplayer({ + container: '.artplayer-app', + url: '/assets/sample/video.mp4', + plugins: [{{export}}({})], +}) +``` + +The factory also supports direct CommonJS `require`, its `.default` alias, +the `/legacy` entry, and the browser global `{{export}}`. +This scaffold only registers a name; implement and test the intended behavior. + +## Maintenance + +| File | Responsibility | +| --- | --- | +| `src/index.ts` | Synchronous plugin factory and registration result | +| `src/stylesheet.ts` | One stylesheet per document on module evaluation; safe to import without a DOM | +| `src/style.less` | Plugin styles; retain the package selector when adding UI | +| `src/assets.d.ts` | Build-time inline stylesheet declaration | +| `types/api.d.ts` | Owned option/result types shared by source and public entrypoints | +| `types/artplayer-plugin-{{name}}.d.ts` | CommonJS and older TypeScript entry | +| `types/artplayer-plugin-{{name}}.d.mts` | Native ESM declaration entry | +| `test/plugin.test.mjs` | Built ESM/CommonJS/legacy synchronous factory and alias contracts | + +Replace the empty `Option` contract with explicit fields when implementing behavior. +Keep the return value synchronous unless the plugin explicitly requires an async contract. +Keep source and both declaration entrypoints consistent; test real consumers when changing them. +The stylesheet has document lifetime and is shared by instances. Resources added by the +plugin (listeners, timers, requests, DOM, workers) need per-instance ownership and cleanup +on `art.on('destroy', ...)`; guard late callbacks and source changes. Add behavior-specific +normal, error, concurrency and cleanup tests, plus real browser checks for media/UI work. + +Use the repository Node version from `.node-version` and Yarn Classic 1.22.22: + +```sh +yarn workspace artplayer-plugin-{{name}} typecheck +yarn build artplayer-plugin-{{name}} +yarn workspace artplayer-plugin-{{name}} test +``` + +Build before testing; the tests consume the actual distribution files. ESM and modern +UMD target es2020, legacy UMD targets es2015. The generator uses the existing toolchain; +it does not install dependencies, publish, or update the root lockfile. Register this +workspace/demo in the refactor inventories and validation mappings before CI, and update +only the root `yarn.lock`. The declared ArtPlayer peer does not itself prove compatibility: +test the core versions and devices required by the implemented features. ## License diff --git a/scripts/plugin/template/package.json b/scripts/plugin/template/package.json index d603d9f56..de0251da0 100644 --- a/scripts/plugin/template/package.json +++ b/scripts/plugin/template/package.json @@ -19,20 +19,41 @@ ], "exports": { ".": { - "types": "./types/artplayer-plugin-{{name}}.d.ts", - "import": "./dist/artplayer-plugin-{{name}}.mjs", - "require": "./dist/artplayer-plugin-{{name}}.js" + "import": { + "types": "./types/artplayer-plugin-{{name}}.d.mts", + "default": "./dist/artplayer-plugin-{{name}}.mjs" + }, + "require": { + "types": "./types/artplayer-plugin-{{name}}.d.ts", + "default": "./dist/artplayer-plugin-{{name}}.js" + }, + "default": "./dist/artplayer-plugin-{{name}}.js" }, "./legacy": { - "types": "./types/artplayer-plugin-{{name}}.d.ts", - "import": "./dist/artplayer-plugin-{{name}}.legacy.js", - "require": "./dist/artplayer-plugin-{{name}}.legacy.js" - - } + "import": { + "types": "./types/artplayer-plugin-{{name}}.d.mts", + "default": "./dist/artplayer-plugin-{{name}}.legacy.js" + }, + "require": { + "types": "./types/artplayer-plugin-{{name}}.d.ts", + "default": "./dist/artplayer-plugin-{{name}}.legacy.js" + }, + "default": "./dist/artplayer-plugin-{{name}}.legacy.js" + }, + "./dist/*": "./dist/*", + "./package.json": "./package.json" }, "main": "dist/artplayer-plugin-{{name}}.js", "module": "./dist/artplayer-plugin-{{name}}.mjs", "types": "types/artplayer-plugin-{{name}}.d.ts", "legacy": "dist/artplayer-plugin-{{name}}.legacy.js", + "files": ["README.md", "dist", "types"], + "scripts": { + "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit", + "test": "node --test test/plugin.test.mjs" + }, + "peerDependencies": { + "artplayer": "*" + }, "browserslist": "last 1 Chrome version" } diff --git a/scripts/plugin/template/src/assets.d.ts.tpl b/scripts/plugin/template/src/assets.d.ts.tpl new file mode 100644 index 000000000..18619f390 --- /dev/null +++ b/scripts/plugin/template/src/assets.d.ts.tpl @@ -0,0 +1,4 @@ +declare module '*.less?inline' { + const css: string + export default css +} diff --git a/scripts/plugin/template/src/index.js b/scripts/plugin/template/src/index.js deleted file mode 100644 index 0a7f66a0f..000000000 --- a/scripts/plugin/template/src/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import style from './style.less?inline'; - -export default function {{export}}(option) { - return (art) => { - return { - name: '{{export}}', - }; - }; -} - -if (typeof document !== 'undefined') { - const id = 'artplayer-plugin-{{name}}'; - let $style = document.getElementById(id) - if (!$style) { - $style = document.createElement('style') - $style.id = id - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - document.head.appendChild($style) - }) - } - else { - (document.head || document.documentElement).appendChild($style) - } - } - $style.textContent = style -} \ No newline at end of file diff --git a/scripts/plugin/template/src/index.ts.tpl b/scripts/plugin/template/src/index.ts.tpl new file mode 100644 index 000000000..17e7b1b9f --- /dev/null +++ b/scripts/plugin/template/src/index.ts.tpl @@ -0,0 +1,9 @@ +import type Artplayer from 'artplayer' +import type { Option, Result } from '../types/api.js' +import './stylesheet' + +function {{export}}(_option: Option = {}) { + return (_art: Artplayer): Result => ({ name: '{{export}}' }) +} + +export default Object.assign({{export}}, { default: {{export}} }) diff --git a/scripts/plugin/template/src/style.less b/scripts/plugin/template/src/style.less index 9944f23d9..1d7cb7c42 100644 --- a/scripts/plugin/template/src/style.less +++ b/scripts/plugin/template/src/style.less @@ -1,3 +1,3 @@ .artplayer-plugin-{{name}} { - // + // } diff --git a/scripts/plugin/template/src/stylesheet.ts.tpl b/scripts/plugin/template/src/stylesheet.ts.tpl new file mode 100644 index 000000000..c1e4ce54a --- /dev/null +++ b/scripts/plugin/template/src/stylesheet.ts.tpl @@ -0,0 +1,12 @@ +import css from './style.less?inline' + +// The stylesheet belongs to the module, not individual player instances. +if (typeof document !== 'undefined') { + const id = 'artplayer-plugin-{{name}}' + if (!document.getElementById(id)) { + const style = document.createElement('style') + style.id = id + style.textContent = css + ;(document.head || document.documentElement).appendChild(style) + } +} diff --git a/scripts/plugin/template/test/plugin.test.mjs.tpl b/scripts/plugin/template/test/plugin.test.mjs.tpl new file mode 100644 index 000000000..8ab2337a2 --- /dev/null +++ b/scripts/plugin/template/test/plugin.test.mjs.tpl @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +// eslint-disable-next-line test/no-import-node-test -- Verify the generated distribution contracts. +import test from 'node:test' +import plugin from 'artplayer-plugin-{{name}}' +import legacy from 'artplayer-plugin-{{name}}/legacy' + +test('classic factory and historical default alias return the synchronous plugin result', () => { + const require = createRequire(import.meta.url) + for (const factory of [plugin, legacy, require('artplayer-plugin-{{name}}'), require('artplayer-plugin-{{name}}/legacy')]) { + assert.equal(factory.default, factory) + const result = factory({})({}) + assert.deepEqual(result, { name: '{{export}}' }) + assert.equal(result instanceof Promise, false) + } +}) diff --git a/scripts/plugin/template/tsconfig.json b/scripts/plugin/template/tsconfig.json new file mode 100644 index 000000000..a38881ff2 --- /dev/null +++ b/scripts/plugin/template/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "types"] +} diff --git a/scripts/plugin/template/types/api.d.ts.tpl b/scripts/plugin/template/types/api.d.ts.tpl new file mode 100644 index 000000000..8f23d717c --- /dev/null +++ b/scripts/plugin/template/types/api.d.ts.tpl @@ -0,0 +1,7 @@ +import type Artplayer from 'artplayer' + +// Replace this empty option type with the documented options your plugin owns. +export type Option = Record +export interface Result { name: '{{export}}' } +export type Factory = (option?: Option) => (art: Artplayer) => Result +export type RuntimeFactory = Factory & { default: Factory } diff --git a/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.mts.tpl b/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.mts.tpl new file mode 100644 index 000000000..3f03303f2 --- /dev/null +++ b/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.mts.tpl @@ -0,0 +1,5 @@ +import type { RuntimeFactory } from './api.js' + +declare const {{export}}: RuntimeFactory +export default {{export}} +export type { Factory, Option, Result, RuntimeFactory } from './api.js' diff --git a/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts b/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts deleted file mode 100644 index 92cef767b..000000000 --- a/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type Artplayer from 'artplayer'; - -interface Option { - // -} - -interface Result { - name: '{{export}}'; -} - -declare const {{export}}: (option: Option) => (art: Artplayer) => Result; - -export default {{export}} \ No newline at end of file diff --git a/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts.tpl b/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts.tpl new file mode 100644 index 000000000..7542be54a --- /dev/null +++ b/scripts/plugin/template/types/artplayer-plugin-{{name}}.d.ts.tpl @@ -0,0 +1,11 @@ +import type { RuntimeFactory } from './api.js' + +declare const {{export}}: RuntimeFactory +// eslint-disable-next-line ts/no-redeclare -- Expose types on the CommonJS factory. +declare namespace {{export}} { + type Option = import('./api.js').Option + type Result = import('./api.js').Result + type Factory = import('./api.js').Factory + type RuntimeFactory = import('./api.js').RuntimeFactory +} +export = {{export}} diff --git a/scripts/tsconfig.scaffold.json b/scripts/tsconfig.scaffold.json new file mode 100644 index 000000000..e382529cd --- /dev/null +++ b/scripts/tsconfig.scaffold.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ES2022", "DOM"], + "allowImportingTsExtensions": true, + "checkJs": true + }, + "include": ["plugin/*.ts", "plugin/create.js"] +} diff --git a/test/README.md b/test/README.md index e834c7b73..4ccb1ff27 100644 --- a/test/README.md +++ b/test/README.md @@ -1,5 +1,11 @@ # Tests and fixture ownership +`yarn test:scaffold` covers old plugin-generator defects, exclusive writes and +rollback, the historical CLI path, and actual generated package builds and type +consumers. It runs in ignored fixtures without adding a workspace or real demo; +see `scripts/plugin/README.md`. `yarn typecheck:scaffold` checks its TS modules and +JS command shim. Both are wired into the existing Node/CI checks. + `site-loading.test.js` freezes the old mobile loader's failure/race reproduction and checks shared loader ownership, retries/cancellation, query encoding and language rules. Actual local pages and Monaco are covered by site-loading.spec.js. diff --git a/test/plugin-scaffold.test.js b/test/plugin-scaffold.test.js new file mode 100644 index 000000000..21ba3947f --- /dev/null +++ b/test/plugin-scaffold.test.js @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import { execFileSync, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +// eslint-disable-next-line test/no-import-node-test -- Exercise scaffold rollback and actual generated consumers. +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import vm from 'node:vm' +import { parseHTML } from 'linkedom' +import { createPlugin } from '../scripts/plugin/cli.ts' +import { publishPlugin } from '../scripts/plugin/publish.ts' +import { renderPlugin } from '../scripts/plugin/render.ts' + +const repository = fileURLToPath(new URL('../', import.meta.url)) +const cache = path.join(repository, 'refactor/.cache') +const baseline = '07d5bfef2e815e8038362bb836b9c496c7ef9862' +const name = 'scaffold-test' +const packageName = `artplayer-plugin-${name}` + +function fixture(t) { + fs.mkdirSync(cache, { recursive: true }) + const root = fs.mkdtempSync(path.join(cache, 'plugin-scaffold-')) + fs.mkdirSync(path.join(root, 'packages')) + t.after(() => { + assert.equal(fs.realpathSync(root), root) + assert.equal(path.dirname(root), fs.realpathSync(cache)) + assert.ok(path.basename(root).startsWith('plugin-scaffold-')) + fs.rmSync(root, { recursive: true, force: true }) + }) + return root +} + +function write(root, relative, text) { + const file = path.join(root, relative) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, text) + return file +} + +function run(args, cwd) { + const env = { ...process.env } + delete env.NODE_TEST_CONTEXT + const result = spawnSync(process.execPath, args, { cwd, env, encoding: 'utf8', timeout: 120000 }) + assert.equal(result.status, 0, `${args.join(' ')}\n${result.stdout}\n${result.stderr}\n${result.error || ''}`) + return result.stdout +} + +test('historical generator overwrites an existing example and produces a mismatched demo link', (t) => { + const root = fixture(t) + const files = execFileSync('git', ['ls-tree', '-r', '--name-only', baseline, 'scripts/plugin'], { cwd: repository, encoding: 'utf8' }).trim().split(/\r?\n/) + for (const relative of files) + write(root, relative, execFileSync('git', ['show', `${baseline}:${relative}`], { cwd: repository })) + write(root, 'package.json', '{"type":"module"}') + const example = write(root, 'docs/assets/example/scaffold.test.js', '// owned example') + run(['scripts/plugin/create.js', name], root) + assert.notEqual(fs.readFileSync(example, 'utf8'), '// owned example') + const readme = fs.readFileSync(path.join(root, `packages/${packageName}/README.md`), 'utf8') + assert.match(readme, /example=scaffold-test/) + assert.equal(fs.existsSync(path.join(root, 'docs/assets/example/scaffold-test.js')), false) +}) + +test('rendering creates consistent names and deterministic typed templates without writing', () => { + const files = renderPlugin(name) + assert.deepEqual(files, renderPlugin(name)) + assert.ok(files.has(`packages/${packageName}/src/index.ts`)) + assert.ok(files.has('docs/assets/example/scaffold.test.js')) + assert.match(files.get(`packages/${packageName}/README.md`), /example=scaffold.test/) + for (const [file, content] of files) { + assert.doesNotMatch(file, /\{\{|\.tpl$/) + assert.doesNotMatch(content, /\{\{/) + } + for (const invalid of ['', '-', '-name', 'name-', 'two--words', '../escape', 'Upper', 'foo/bar', 'a_b']) + assert.throws(() => renderPlugin(invalid), /lowercase/) +}) + +test('unknown placeholders and duplicate rendered paths fail before publishing', (t) => { + const root = fixture(t) + const template = path.join(root, 'template') + write(root, 'template/index.ts.tpl', '{{toString}}') + assert.throws(() => renderPlugin(name, template), /Unknown template placeholder/) + fs.writeFileSync(path.join(template, 'index.ts.tpl'), 'export {}') + write(root, 'template/index.ts', 'export {}') + assert.throws(() => renderPlugin(name, template), /Duplicate template output/) + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) +}) + +test('existing packages and examples are preserved including empty package directories', (t) => { + const root = fixture(t) + const example = write(root, 'docs/assets/example/scaffold.test.js', '// owned example') + assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /overwrite/) + assert.equal(fs.readFileSync(example, 'utf8'), '// owned example') + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) + fs.mkdirSync(path.join(root, `packages/${packageName}`)) + assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /already exists/) +}) + +test('partial write failure removes only generated files and newly created directories', (t) => { + const root = fixture(t) + const sentinel = write(root, 'docs/keep.txt', 'keep') + let count = 0 + assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => { + if (++count === 5) + throw new Error('injected write failure') + fs.linkSync(from, to) + }), /injected write failure/) + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) + assert.deepEqual(fs.readdirSync(path.join(root, 'docs')), ['keep.txt']) + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'keep') +}) + +test('a concurrent example collision cannot overwrite the other writer', (t) => { + const root = fixture(t) + assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => { + if (to.endsWith('scaffold.test.js')) + fs.writeFileSync(to, '// concurrent writer', { flag: 'wx' }) + fs.linkSync(from, to) + }), /preserve changed outputs/) + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) + assert.equal(fs.readFileSync(path.join(root, 'docs/assets/example/scaffold.test.js'), 'utf8'), '// concurrent writer') +}) + +test('rollback retains an externally edited generated file and reports its recovery path', (t) => { + const root = fixture(t) + let first + assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => { + if (first) { + fs.writeFileSync(first, 'external edit') + throw new Error('injected interruption') + } + fs.linkSync(from, to) + first = to + }), error => error.message.includes(first) && error.cause.message === 'injected interruption') + assert.equal(fs.readFileSync(first, 'utf8'), 'external edit') + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [packageName]) +}) + +test('redirected output directories are rejected before writing through them', (t) => { + const root = fixture(t) + const outside = fixture(t) + fs.symlinkSync(outside, path.join(root, 'docs'), 'junction') + assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /redirected output/) + assert.deepEqual(fs.readdirSync(outside), ['packages']) + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) + fs.unlinkSync(path.join(root, 'docs')) +}) + +test('CLI validates arguments and builds a complete package without installing or changing the lock', (t) => { + const root = fixture(t) + const lock = write(root, 'yarn.lock', '# existing lock\n') + assert.throws(() => createPlugin(root, [name, 'extra']), /exactly one/) + createPlugin(root, ['--help']) + assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), []) + createPlugin(root, [name]) + assert.equal(fs.readFileSync(lock, 'utf8'), '# existing lock\n') + for (const [relative, content] of renderPlugin(name)) + assert.equal(fs.readFileSync(path.join(root, relative), 'utf8'), content) +}) + +test('legacy CLI path uses its repository root even when launched from another working directory', (t) => { + const root = fixture(t) + const elsewhere = fixture(t) + fs.cpSync(path.join(repository, 'scripts/plugin'), path.join(root, 'scripts/plugin'), { recursive: true }) + write(root, 'package.json', '{"type":"module"}') + const command = path.join(root, 'scripts/plugin/create.js') + run([command, name], elsewhere) + assert.ok(fs.existsSync(path.join(root, `packages/${packageName}/src/index.ts`))) + assert.deepEqual(fs.readdirSync(path.join(elsewhere, 'packages')), []) + const invalid = spawnSync(process.execPath, [command, '../escape'], { cwd: elsewhere, encoding: 'utf8' }) + assert.equal(invalid.status, 1) + assert.match(invalid.stderr, /lowercase/) +}) + +test('generated package passes real production builds, consumers, strict types and stylesheet lifecycle', (t) => { + const root = fixture(t) + createPlugin(root, [name]) + fs.copyFileSync(path.join(repository, 'tsconfig.base.json'), path.join(root, 'tsconfig.base.json')) + const pkg = path.join(root, `packages/${packageName}`) + const compiler = path.join(repository, 'node_modules/typescript/bin/tsc') + run([path.join(repository, 'node_modules/eslint/bin/eslint.js'), '--no-ignore', '--max-warnings', '0', 'src', 'types', 'test/plugin.test.mjs', 'package.json'], pkg) + run([compiler, '-p', 'tsconfig.json', '--noEmit'], pkg) + const output = run([path.join(repository, 'scripts/build.js'), packageName], root) + assert.match(output, /Finished building 1 package/) + const testOutput = run(['--test', 'test/plugin.test.mjs'], pkg) + assert.match(testOutput, /pass 1/) + const consumer = `import plugin from '${packageName}' +import legacy from '${packageName}/legacy' +import type Artplayer from 'artplayer' +declare const art: Artplayer +const result: {name:'artplayerPluginScaffoldTest'} = plugin({})(art) +legacy.default()(art) +// @ts-expect-error The scaffold owns no option fields yet. +plugin({unknown: true}) +// @ts-expect-error Registration requires an ArtPlayer instance. +plugin()({}) +void result +` + write(pkg, 'test/consumer.mts', consumer) + write(pkg, 'test/consumer.cts', consumer.replace(`import plugin from '${packageName}'`, `import plugin = require('${packageName}')`)) + write(pkg, 'test/tsconfig.json', JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [], module: 'NodeNext', moduleResolution: 'NodeNext', target: 'ES2020', esModuleInterop: true }, include: ['consumer.mts', 'consumer.cts'] })) + run([compiler, '-p', 'test/tsconfig.json'], pkg) + write(pkg, 'test/classic.ts', consumer.replaceAll(`'${packageName}/legacy'`, `'../types/${packageName}'`).replaceAll(`'${packageName}'`, `'../types/${packageName}'`)) + write(pkg, 'test/tsconfig.classic.json', JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [], module: 'CommonJS', moduleResolution: 'Node', target: 'ES2020', esModuleInterop: true }, files: ['classic.ts'] })) + run([path.join(repository, 'node_modules/typescript-compat/bin/tsc'), '-p', 'test/tsconfig.classic.json'], pkg) + const { document } = parseHTML('') + const context = vm.createContext({ document }) + for (const suffix of ['.js', '.legacy.js']) { + const code = fs.readFileSync(path.join(pkg, `dist/${packageName}${suffix}`), 'utf8') + vm.runInNewContext(code, {}) // Importing the UMD bundle without a DOM must work. + vm.runInContext(code, context) + vm.runInContext(code, context) + assert.equal(context.artplayerPluginScaffoldTest.default, context.artplayerPluginScaffoldTest) + assert.equal(context.artplayerPluginScaffoldTest()({}).name, 'artplayerPluginScaffoldTest') + } + assert.equal(document.querySelectorAll(`style#${packageName}`).length, 1) +})