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() const declarations = new Map() 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 -- 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 = function (art) { return { name: String(art.id + this.id) } } const plugins: Promise = 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 = { 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 = () => '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') })) }