mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:46:16 -08:00
146 lines
8.4 KiB
TypeScript
146 lines
8.4 KiB
TypeScript
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: string, globalName: string) {
|
|
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<string, string>()
|
|
const declarations = new Map<string, ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.ClassDeclaration>()
|
|
const definitions: ts.Statement[] = []
|
|
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')
|
|
const defaultDeclaration = defaultName && declarations.get(defaultName)
|
|
assert(defaultName && defaultDeclaration && ts.isClassDeclaration(defaultDeclaration), '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: string) => factory.createQualifiedName(factory.createIdentifier(definitionName), factory.createIdentifier(name))
|
|
const aliases: ts.TypeAliasDeclaration[] = []
|
|
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: string, nodes: ts.Statement[]) => 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') })
|
|
assert(code, 'Core bundle is empty')
|
|
const declaration = asGlobalDeclaration(code, 'Artplayer')
|
|
for (const compiler of [ts, compat])
|
|
assert.deepEqual(checkCoreEditorDeclaration(declaration, compiler as unknown as typeof ts), [], `Invalid editor declaration: TS ${compiler.version}`)
|
|
return declaration
|
|
}
|
|
|
|
export function checkCoreEditorDeclaration(code: string, compiler: typeof ts = 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') }))
|
|
}
|