refactor(tooling): [MOD-PLUGIN-01] generate typed plugins with safe output handling

This commit is contained in:
Harvey Zhao committed 2026-09-14 10:19:19 +08:00
1 parent 07d5bfef2e
commit 28c7abb2bb
32 files changed
+958 -161

No files matched your search

+7 -5
View File
@@ -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": {
+160
View File
@@ -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"
}
]
}
+7
View File
@@ -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。
@@ -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.
+1
View File
@@ -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 |
+5 -3
View File
@@ -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<br>Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo |
| MOD-02 | workspace<br>整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | todo |
| MOD-PLUGIN-01 | workspace<br>迁移插件生成器与类型化模板 | ENG-06, SITE-03 | 保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南 | 防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布 | M | done |
| MOD-02 | workspace<br>整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03, MOD-PLUGIN-01 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | todo |
| MOD-03 | workspace<br>测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益 | M | todo |
| MOD-04 | workspace<br>测量并优化重型插件/proxy | PKG-DANMUKU-09, PKG-MASK-06, PKG-MB-10, ENG-08 | 帧/队列/推理/音画同步与资源长期运行比较 | 不改默认算法/阈值,性能改善有证据;无收益则保留旧实现 | M | todo |
| MOD-05 | workspace<br>完成工具链与性能采用决策 | 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)
+15
View File
@@ -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、
+1
View File
@@ -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 |
+22
View File
@@ -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."
}
]
}
+22 -1
View File
@@ -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",
+5
View File
@@ -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` 包含十个
+86
View File
@@ -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.
+16
View File
@@ -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 <lowercase-plugin-name>')
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.')
}
+6 -102
View File
@@ -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}`)
+96
View File
@@ -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<string, string>, 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)
}
}
+50
View File
@@ -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<string, string> {
if (!/^[a-z]+(?:-[a-z]+)*$/.test(name))
throw new Error('Use lowercase words separated by single hyphens for the plugin name')
const replacements: Record<string, string> = {
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<string, string>()
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
}
+56 -1
View File
@@ -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
+29 -8
View File
@@ -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"
}
@@ -0,0 +1,4 @@
declare module '*.less?inline' {
const css: string
export default css
}
-27
View File
@@ -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
}
+9
View File
@@ -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}} })
+1 -1
View File
@@ -1,3 +1,3 @@
.artplayer-plugin-{{name}} {
//
//
}
@@ -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)
}
}
@@ -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)
}
})
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "types"]
}
@@ -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<string, never>
export interface Result { name: '{{export}}' }
export type Factory = (option?: Option) => (art: Artplayer) => Result
export type RuntimeFactory = Factory & { default: Factory }
@@ -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'
@@ -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}}
@@ -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}}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"lib": ["ES2022", "DOM"],
"allowImportingTsExtensions": true,
"checkJs": true
},
"include": ["plugin/*.ts", "plugin/create.js"]
}
+6
View File
@@ -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.
+216
View File
@@ -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('<html><head></head><body></body></html>')
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)
})