refactor(core): [CORE-21] generate compatible public and editor declarations

This commit is contained in:
Harvey Zhao committed 2026-09-11 17:56:05 +08:00
1 parent ee895a558f
commit faaa3e8c2d
123 files changed
+31305 -1712

No files matched your search

+2 -10
View File
@@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { ESLint } from 'eslint'
import { glob } from 'glob'
import { generateCoreEditorDeclaration } from './editor-types.mjs'
function ensureDirExists(filePath) {
const dir = path.dirname(filePath)
@@ -26,17 +27,8 @@ function parsePluginInfo(pluginPath) {
}
const reg = /^import.*$/gim
const artplayerTS = glob.sync('packages/artplayer/types/*.d.ts')
const artplayerTSoutput = path.join('docs/assets/ts/artplayer.d.ts')
let code = ''
for (let index = 0; index < artplayerTS.length; index++) {
const type = artplayerTS[index]
code += `${String(fs.readFileSync(type)).replace(reg, '')}\n`
}
code.replace('export default ', '')
code += `export = Artplayer;\nexport as namespace Artplayer;\n`
const code = generateCoreEditorDeclaration()
ensureDirExists(artplayerTSoutput)
fs.writeFileSync(artplayerTSoutput, code.trim())
console.log(`✨ Built ${artplayerTSoutput}`);
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { ESLint } from 'eslint'
import ts from 'typescript'
const workspace = fileURLToPath(new URL('../', import.meta.url))
const normalize = value => value.replaceAll('\\', '/')
export async function generateCoreDeclarations({ root = workspace, write = false } = {}) {
const packageRoot = path.join(root, 'packages/artplayer')
const sourceRoot = path.join(packageRoot, 'public')
const outputRoot = path.join(packageRoot, 'types')
const files = ts.sys.readDirectory(sourceRoot, ['.ts', '.mts', '.cts']).sort()
assert(files.length > 0, 'No public TypeScript sources found')
assert(files.every(file => !/\.d\.[cm]?ts$/.test(file)), 'Public sources must be TypeScript, not copied declaration artifacts')
const options = {
strict: true,
skipLibCheck: false,
types: [],
target: ts.ScriptTarget.ES2020,
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
declaration: true,
emitDeclarationOnly: true,
noEmitOnError: true,
rootDir: sourceRoot,
outDir: outputRoot,
newLine: ts.NewLineKind.LineFeed,
}
const outputs = new Map()
const program = ts.createProgram(files, options)
const formatHost = { getCurrentDirectory: () => root, getCanonicalFileName: name => name, getNewLine: () => '\n' }
const errors = ts.getPreEmitDiagnostics(program)
assert.equal(errors.length, 0, ts.formatDiagnosticsWithColorAndContext(errors, formatHost))
for (const source of program.getSourceFiles()) {
if (program.isSourceFileDefaultLibrary(source))
continue
assert(files.includes(source.fileName), `Public declarations depend on a non-public source: ${normalize(path.relative(root, source.fileName))}`)
}
const emitted = program.emit(undefined, (file, text) => {
const relative = normalize(path.relative(outputRoot, file))
assert(!relative.startsWith('../') && !path.isAbsolute(relative) && /\.d\.[cm]?ts$/.test(relative), `Unexpected declaration output: ${file}`)
outputs.set(relative, text)
})
assert.equal(emitted.emitSkipped, false, ts.formatDiagnosticsWithColorAndContext(emitted.diagnostics, formatHost))
assert.equal(outputs.size, files.length, 'Each public source must emit one declaration')
const eslint = new ESLint({ cwd: workspace, fix: true, fixTypes: ['layout'] })
const generated = new Map()
for (const [relative, content] of outputs) {
const source = `public/${relative.replace('.d.', '.')}`
// Declaration emit omits line comments. This bridge intentionally merges
// the constructor value, instance alias and CJS named-type namespace.
const bridge = ['artplayer.d.cts', 'runtime.d.cts', 'runtime.d.ts'].includes(relative) ? '/* eslint-disable ts/no-redeclare -- Constructor, instance and named types share the export. */\n' : ''
const code = `// Generated from ${source} by yarn build:types. Do not edit.\n${bridge}${content}`
const [result] = await eslint.lintText(code, { filePath: path.join(workspace, 'packages/artplayer/types', relative) })
if (result.errorCount) {
const formatter = await eslint.loadFormatter('stylish')
throw new Error(formatter.format([result]))
}
generated.set(relative, result.output || code)
}
const existing = ts.sys.readDirectory(outputRoot, ['.d.ts', '.d.mts', '.d.cts'])
for (const file of existing)
assert(generated.has(normalize(path.relative(outputRoot, file))), `Declaration has no public source: ${file}`)
const changed = []
for (const [relative, text] of generated) {
const file = path.join(outputRoot, relative)
const actual = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n') : null
if (actual === text)
continue
changed.push(relative)
if (write) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, text)
}
}
assert(write || changed.length === 0, `Generated core declarations are stale: ${changed.join(', ')}. Run yarn build:types.`)
return { files: [...generated.keys()].sort(), changed }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
assert(process.argv.length === 3 && ['--write', '--check'].includes(process.argv[2]), 'Use build-types.mjs --write or --check')
const result = await generateCoreDeclarations({ write: process.argv[2] === '--write' })
console.log(`Core declarations ${process.argv[2]}: ${result.files.length} files; ${result.changed.length} changed`)
}
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateDtsBundle } from 'dts-bundle-generator'
import ts from 'typescript'
import compat from 'typescript-compat'
const root = fileURLToPath(new URL('../', import.meta.url))
const factory = ts.factory
/** Put flattened definitions in a private scope so public aliases cannot refer to themselves. */
export function asGlobalDeclaration(code, globalName) {
assert(/^[A-Z_$][\w$]*$/i.test(globalName), 'Invalid editor global name')
const source = ts.createSourceFile('bundle.d.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const exports = new Map()
const declarations = new Map()
const definitions = []
for (const node of source.statements) {
if (ts.isExportDeclaration(node)) {
assert(!node.moduleSpecifier && node.exportClause && ts.isNamedExports(node.exportClause), 'Editor bundle must contain no unresolved exports')
for (const item of node.exportClause.elements)
exports.set(item.name.text, (item.propertyName || item.name).text)
continue
}
assert(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node), `Unsupported editor declaration: ${ts.SyntaxKind[node.kind]}`)
assert(node.name, 'Editor definitions must be named')
const name = node.name.text
assert(!declarations.has(name), `Duplicate bundled declaration: ${name}`)
declarations.set(name, node)
if (node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword))
exports.set(name, name)
definitions.push(factory.replaceModifiers(node, [factory.createModifier(ts.SyntaxKind.ExportKeyword)]))
}
const defaultName = exports.get('default')
assert(defaultName && ts.isClassDeclaration(declarations.get(defaultName)), 'Core editor requires the actual default constructor class')
const definitionName = `${globalName}Definitions`
assert(!declarations.has(definitionName), 'Editor definition namespace collides with source')
const qualified = name => factory.createQualifiedName(factory.createIdentifier(definitionName), factory.createIdentifier(name))
const aliases = []
for (const [name, local] of exports) {
if (name === 'default')
continue
const declaration = declarations.get(local)
assert(declaration, `Missing bundled export: ${local}`)
const parameters = declaration.typeParameters
aliases.push(factory.createTypeAliasDeclaration(
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
name,
parameters,
factory.createTypeReferenceNode(qualified(local), parameters?.map(parameter => factory.createTypeReferenceNode(parameter.name))),
))
}
const namespace = (name, nodes) => factory.createModuleDeclaration(
[factory.createModifier(ts.SyntaxKind.DeclareKeyword)],
factory.createIdentifier(name),
factory.createModuleBlock(nodes),
ts.NodeFlags.Namespace,
)
const statements = [
namespace(definitionName, definitions),
factory.createVariableStatement([factory.createModifier(ts.SyntaxKind.DeclareKeyword)], factory.createVariableDeclarationList([
factory.createVariableDeclaration(globalName, undefined, factory.createTypeQueryNode(qualified(defaultName))),
], ts.NodeFlags.Const)),
factory.createTypeAliasDeclaration(undefined, globalName, undefined, factory.createTypeReferenceNode(qualified(defaultName))),
namespace(globalName, aliases),
factory.createExportAssignment(undefined, true, factory.createIdentifier(globalName)),
factory.createNamespaceExportDeclaration(factory.createIdentifier(globalName)),
]
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
return `// Generated from packages/artplayer/public/artplayer.ts by yarn build:ts. Do not edit.\n/* eslint-disable ts/no-redeclare, ts/no-namespace -- UMD constructor and named types share the global export. */\n${printer.printFile(factory.updateSourceFile(source, statements))}`
}
export function generateCoreEditorDeclaration() {
const require = createRequire(import.meta.url)
const bundlerRequire = createRequire(require.resolve('dts-bundle-generator'))
assert.equal(bundlerRequire('typescript'), ts, 'Editor bundler must use the pinned workspace compiler')
const [code] = generateDtsBundle([{
filePath: path.join(root, 'packages/artplayer/public/artplayer.ts'),
output: { exportReferencedTypes: false, noBanner: true },
}], { preferredConfigPath: path.join(root, 'scripts/tsconfig.editor.json') })
const declaration = asGlobalDeclaration(code, 'Artplayer')
for (const compiler of [ts, compat])
assert.deepEqual(checkCoreEditorDeclaration(declaration, compiler), [], `Invalid editor declaration: TS ${compiler.version}`)
return declaration
}
export function checkCoreEditorDeclaration(code, compiler = ts) {
const folder = path.join(root, 'refactor/.cache/editor-semantic')
const declaration = path.join(folder, 'artplayer.d.ts')
const sources = new Map([
[declaration, code],
[path.join(folder, 'global.ts'), `
const option: Artplayer.OptionInput = { container: '#player' }
const player: Artplayer = new Artplayer(option, function (art) {
const same: Artplayer = this
const other: Artplayer = art
void [same, other]
})
const factory: Artplayer.PluginFactory<Artplayer, { name: string }> = function (art) {
return { name: String(art.id + this.id) }
}
const plugins: Promise<Artplayer.Plugins> = player.plugins.add(factory)
const emitter: Artplayer.Emitter<{ value: [number] }> = new Artplayer.Emitter()
emitter.on('value', count => count.toFixed()).emit('value', 2)
const language: NonNullable<Artplayer.I18n['en']> = { Play: 'Play' }
const configuration: Artplayer.Config = Artplayer.config
// @ts-expect-error Private flattened definitions must not leak into the editor global scope.
const leaked: ArtplayerDefinitions.Config = Artplayer.config
// @ts-expect-error Required container is not optional.
new Artplayer({ url: '' })
// @ts-expect-error Named aliases must not degrade to any through circular definitions.
const invalidOption: Artplayer.Option = { container: 123, url: '' }
// @ts-expect-error Plugin callback results retain the selected result type.
const invalidFactory: Artplayer.PluginFactory<Artplayer, number> = () => 'bad'
// @ts-expect-error Generic event payloads stay checked.
emitter.emit('value', 'bad')
void [plugins, language, configuration, invalidOption, invalidFactory, leaked]
`],
[path.join(folder, 'module.ts'), `
import Player = require('./artplayer')
const player: Player = new Player({ container: '#player' })
const option: Player.OptionInput = { container: '#player' }
// @ts-expect-error The UMD export is the constructor, not a default wrapper object.
Player.default
void [player, option]
`],
])
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], target: compiler.ScriptTarget.ES2020, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], module: compiler.ModuleKind.CommonJS, moduleResolution: compiler.ModuleResolutionKind.NodeJs }
const host = compiler.createCompilerHost(options)
const getSourceFile = host.getSourceFile.bind(host)
const exists = host.fileExists.bind(host)
const directoryExists = host.directoryExists.bind(host)
host.fileExists = file => sources.has(path.resolve(file)) || exists(file)
host.directoryExists = directory => path.resolve(directory) === folder || directoryExists(directory)
host.getSourceFile = (file, version, onError, create) => sources.has(path.resolve(file))
? compiler.createSourceFile(file, sources.get(path.resolve(file)), version, true)
: getSourceFile(file, version, onError, create)
const program = compiler.createProgram([...sources.keys()], options, host)
for (const file of program.getSourceFiles())
assert(sources.has(path.resolve(file.fileName)) || program.isSourceFileDefaultLibrary(file), `Editor types escaped the standalone bundle: ${file.fileName}`)
return compiler.getPreEmitDiagnostics(program).map(diagnostic => ({ code: diagnostic.code, message: compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n') }))
}
+6 -5
View File
@@ -25,7 +25,7 @@ export function checkFiles(manifest, files, historical = []) {
visit(manifest.exports)
for (const file of historical.filter(file => /^package\/(?:dist|types)\//.test(file)))
assert(files.includes(file), `Historical distribution file removed: ${file}`)
assert(!files.some(file => /^package\/(?:src|node_modules)\//.test(file)), 'Source/dependencies leaked into package')
assert(!files.some(file => /^package\/(?:src|public|node_modules)\//.test(file)), 'Source/dependencies leaked into package')
assert(!files.includes('package/tsconfig.json'), 'Implementation tsconfig leaked into package')
}
@@ -75,7 +75,7 @@ export async function checkPackages({ release = false } = {}) {
const output = fs.mkdtempSync(path.join(parent, 'run-'))
const snapshot = path.join(output, 'build')
fs.mkdirSync(snapshot)
for (const file of ['package.json', 'yarn.lock', 'tsconfig.json', 'tsconfig.base.json']) fs.copyFileSync(path.join(workspace, file), path.join(snapshot, file))
for (const file of ['package.json', 'yarn.lock', 'tsconfig.json', 'tsconfig.base.json', 'eslint.config.js']) fs.copyFileSync(path.join(workspace, file), path.join(snapshot, file))
fs.cpSync(path.join(workspace, 'types'), path.join(snapshot, 'types'), { recursive: true })
fs.cpSync(path.join(workspace, 'scripts'), path.join(snapshot, 'scripts'), { recursive: true })
fs.symlinkSync(path.join(workspace, 'node_modules'), path.join(snapshot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
@@ -83,7 +83,7 @@ export async function checkPackages({ release = false } = {}) {
const source = path.join(workspace, 'packages', name)
fs.cpSync(source, path.join(snapshot, 'packages', name), { recursive: true, filter: file => !['dist', 'node_modules'].includes(path.basename(file)) })
}
fs.writeFileSync(path.join(output, 'build.log'), run(['scripts/build.js', ...names], snapshot) + run(['scripts/build-i18n.js'], snapshot))
fs.writeFileSync(path.join(output, 'build.log'), run(['scripts/build-types.mjs', '--write'], snapshot) + run(['scripts/build.js', ...names], snapshot) + run(['scripts/build-i18n.js'], snapshot))
const baseline = await publishedConsumer()
const installed = consumerDirectory()
try {
@@ -118,11 +118,12 @@ export async function checkPackages({ release = false } = {}) {
const runtime = runtimeConsumer(installed)
assert.deepEqual(runtime.observations.api, oldRuntime.observations.api, 'Published API shape/defaults changed')
const types = typeConsumers(installed)
const report = { task: 'ENG-07', capturedAt: new Date().toISOString(), node: process.versions.node, packages, runtime, publishedRuntime: oldRuntime, types, knownTypeBlockers: types.reduce((sum, result) => sum + result.diagnostics.length, 0) }
const preciseTypes = typeConsumers(installed, { precise: true })
const report = { task: 'ENG-07', capturedAt: new Date().toISOString(), node: process.versions.node, packages, runtime, publishedRuntime: oldRuntime, types, preciseTypes, knownTypeBlockers: [...types, ...preciseTypes].reduce((sum, result) => sum + result.diagnostics.length, 0) }
writeJson(path.join(output, 'report.json'), report)
writeJson(path.join(output, 'browser-artifacts.json'), artifacts)
writeJson(path.join(parent, 'latest.json'), { output: path.relative(workspace, output).replaceAll('\\', '/') })
console.log(`Installed tarball contracts passed: ${runtime.checks.length} runtime checks; ${types.filter(t => !t.diagnostics.length).length}/${types.length} type modes. Report: ${output}`)
console.log(`Installed tarball contracts passed: ${runtime.checks.length} runtime checks; ${types.filter(t => !t.diagnostics.length).length}/${types.length} legacy type modes; ${preciseTypes.filter(t => !t.diagnostics.length).length}/${preciseTypes.length} precise type modes. Report: ${output}`)
if (release)
assert.equal(report.knownTypeBlockers, 0, 'Known type blockers remain; this candidate is not release-ready')
return report
+11 -7
View File
@@ -7,6 +7,7 @@ import process from 'node:process'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import compat from 'typescript-compat'
import runtimeCompat from 'typescript-runtime-compat'
export const workspace = fileURLToPath(new URL('../', import.meta.url))
export const names = ['artplayer', 'artplayer-plugin-chapter']
@@ -33,18 +34,21 @@ export function runtimeConsumer(dir, { baseline = false } = {}) {
return JSON.parse(run(['runtime.cjs'], dir))
}
export function typeConsumers(dir) {
export function typeConsumers(dir, { precise = false } = {}) {
const results = []
for (const [compiler, mode] of [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'bundler-esm'], [compat, 'node10-commonjs'], [ts, 'nodenext-esm']]) {
const matrix = precise
? [ts, runtimeCompat].flatMap(compiler => ['node10-commonjs', 'nodenext-cjs', 'bundler-esm', 'nodenext-esm'].map(mode => [compiler, mode]))
: [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'bundler-esm'], [compat, 'node10-commonjs'], [ts, 'nodenext-esm']]
for (const [compiler, mode] of matrix) {
const next = mode.startsWith('nodenext')
const extension = next ? (mode.endsWith('-cjs') ? 'cts' : 'mts') : 'ts'
const inputs = ['public', 'declaration-inputs', 'declaration-legacy', 'plugins-public', 'playback-public', 'chapter-options', 'chapter-exports', 'language-value', 'language', 'legacy-plugin']
const inputs = precise ? ['runtime-public', 'runtime-construction'] : ['public', 'declaration-inputs', 'declaration-legacy', 'plugins-public', 'playback-public', 'chapter-options', 'chapter-exports', 'language-value', 'language', 'legacy-plugin']
if (mode === 'nodenext-cjs')
inputs.push('commonjs')
inputs.push(precise ? 'runtime-commonjs' : 'commonjs')
const files = inputs.map((name) => {
const file = path.join(dir, `${name}.${extension}`)
const folder = ['language', 'legacy-plugin'].includes(name) ? 'refactor/fixtures/consumers' : 'test/types'
fs.copyFileSync(path.join(workspace, folder, `${name}.${name === 'commonjs' ? 'cts' : 'ts'}`), file)
fs.copyFileSync(path.join(workspace, folder, `${name}.${name.endsWith('commonjs') ? 'cts' : 'ts'}`), file)
return file
})
const options = {
@@ -71,10 +75,10 @@ export function typeConsumers(dir) {
line: d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : null,
message: compiler.flattenDiagnosticMessageText(d.messageText, '\n').replaceAll(`${dir.replaceAll('\\', '/')}/node_modules/`, '<workspace>/packages/'),
}))
const expected = mode === 'nodenext-esm' ? readJson(path.join(workspace, 'test/types/known-diagnostics.json')).diagnostics : []
const expected = !precise && mode === 'nodenext-esm' ? readJson(path.join(workspace, 'test/types/known-diagnostics.json')).diagnostics : []
const historical = readJson(path.join(workspace, 'refactor/baselines/consumers.json')).types
const known = readJson(path.join(workspace, 'test/package/known-types.json')).cases
for (const fixture of ['language.ts', 'legacy-plugin.ts']) {
for (const fixture of precise ? [] : ['language.ts', 'legacy-plugin.ts']) {
if (!known.some(item => item.fixture === fixture && item.mode === mode))
continue
const original = historical.find(result => result.mode === mode && result.fixture === fixture)
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"skipLibCheck": false,
"types": [],
"declaration": true,
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2020", "DOM"],
"esModuleInterop": true
}
}
+12
View File
@@ -5,6 +5,7 @@ import process from 'node:process'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import compat from 'typescript-compat'
import runtimeCompat from 'typescript-runtime-compat'
const root = fileURLToPath(new URL('../', import.meta.url))
const relative = name => path.relative(root, name).replaceAll('\\', '/')
@@ -48,6 +49,7 @@ export function runTypechecks() {
const dependencies = read('package.json').devDependencies
assert.equal(ts.version, dependencies.typescript)
assert.equal(`npm:typescript@${compat.version}`, dependencies['typescript-compat'])
assert.equal(`npm:typescript@${runtimeCompat.version}`, dependencies['typescript-runtime-compat'])
const configs = [path.join(root, 'tsconfig.json')]
for (const name of fs.readdirSync(path.join(root, 'packages'))) {
const config = path.join(root, 'packages', name, 'tsconfig.json')
@@ -78,6 +80,16 @@ export function runTypechecks() {
assert.deepEqual(checkConsumer(compiler, mode, fs.readFileSync(path.join(root, 'test/types/commonjs.cts'), 'utf8')), [], 'CommonJS export assignment consumer failed')
console.log(`Consumers passed: TS ${compiler.version} ${mode}`)
}
const runtimeFixtures = ['test/types/runtime-leaf-consumer.ts', 'test/types/runtime-public.ts', 'test/types/runtime-construction.ts']
for (const compiler of [ts, runtimeCompat]) {
for (const mode of ['node10-commonjs', 'nodenext-cjs', 'bundler-esm', 'nodenext-esm']) {
for (const fixture of runtimeFixtures)
assert.deepEqual(checkConsumer(compiler, mode, fs.readFileSync(path.join(root, fixture), 'utf8')), [], `Runtime declarations failed: TS ${compiler.version} ${mode} ${fixture}`)
if (mode === 'nodenext-cjs')
assert.deepEqual(checkConsumer(compiler, mode, fs.readFileSync(path.join(root, 'test/types/runtime-commonjs.cts'), 'utf8')), [], `Runtime CJS export assignment failed: TS ${compiler.version}`)
console.log(`Runtime declaration consumers passed: TS ${compiler.version} ${mode}`)
}
}
console.log(`TypeScript production source files checked: ${sourceCount}; unmigrated JS is not counted`)
}