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

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