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)
+})