refactor(docs): [SITE-02] generate and validate standalone editor types

This commit is contained in:
Harvey Zhao committed 2026-09-14 07:24:03 +08:00
1 parent 1a5440a67f
commit 76499e7a3e
30 files changed
+2955 -459

No files matched your search

+2 -95
View File
@@ -1,97 +1,4 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { ESLint } from 'eslint'
import { glob } from 'glob'
import compat from 'typescript-compat'
import { generateCoreEditorDeclaration } from './editor-types.mjs'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from './plugin-editor-types.mjs'
import { runEditorDeclarations } from './editor-declarations/generate.ts'
function ensureDirExists(filePath) {
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function parsePluginInfo(pluginPath) {
// Use path.basename to handle both Unix and Windows paths correctly
const file = path.basename(pluginPath)
const baseName = file.replace('.d.ts', '')
const classNames = { 'artplayer-tool-iframe': 'ArtplayerToolIframe', 'artplayer-tool-thumbnail': 'ArtplayerToolThumbnail' }
const name = classNames[baseName]
? classNames[baseName]
: baseName
.split('-')
.map((word, index) =>
index === 0 ? word : word[0].toUpperCase() + word.slice(1),
)
.join('')
return { name, file }
}
const reg = /^import.*$/gim
const artplayerTSoutput = path.join('docs/assets/ts/artplayer.d.ts')
const code = generateCoreEditorDeclaration()
ensureDirExists(artplayerTSoutput)
fs.writeFileSync(artplayerTSoutput, code.trim())
console.log(`✨ Built ${artplayerTSoutput}`);
(async function () {
const packageOf = file => path.basename(path.dirname(path.dirname(file)))
const available = glob.sync('packages/artplayer-*-*/types/*.d.ts').filter(file => path.basename(file) === `${packageOf(file)}.d.ts`)
const selected = process.argv.slice(2)
for (const name of selected)
assert(available.some(file => packageOf(file) === name), `Unknown declaration package: ${name}`)
const pluginsTS = selected.length ? available.filter(file => selected.includes(packageOf(file))) : available
const pluginFiles = []
for (let index = 0; index < pluginsTS.length; index++) {
const type = pluginsTS[index]
const { name, file } = parsePluginInfo(type)
const source = String(fs.readFileSync(type))
const semanticPlugin = ['artplayerPluginAutoThumbnail', 'artplayerPluginJassub', 'artplayerPluginDanmukuMask', 'artplayerPluginDanmuku', 'artplayerPluginChromecast', 'artplayerPluginAsr', 'artplayerPluginMultipleSubtitles', 'artplayerPluginVttThumbnail', 'artplayerPluginHlsControl', 'artplayerPluginAudioTrack', 'artplayerPluginDashControl', 'artplayerPluginAds', 'artplayerPluginAmbilight', 'artplayerProxyCanvas', 'artplayerProxyMediabunny', 'artplayerPluginDocumentPip', 'ArtplayerToolIframe', 'ArtplayerToolThumbnail'].includes(name)
const localTypes = name === 'artplayerProxyMediabunny' ? { './media': fs.readFileSync(path.join(path.dirname(type), 'media.d.ts'), 'utf8') } : {}
const code = semanticPlugin
? generatePluginEditorDeclaration(source, name, localTypes)
: `${source.replace(reg, '')}\nexport = ${name};\nexport as namespace ${name};\n`
if (semanticPlugin) {
const core = fs.readFileSync(artplayerTSoutput, 'utf8')
const diagnostics = [...checkPluginEditorDeclaration(code, core), ...checkPluginEditorDeclaration(code, core, '', compat)]
if (diagnostics.length)
throw new Error(`Invalid ${name} editor declaration: ${JSON.stringify(diagnostics)}`)
}
const output = path.join('docs/assets/ts', file)
ensureDirExists(output)
fs.writeFileSync(output, code.trim())
console.log(`✨ Built ${output}`)
pluginFiles.push(file)
}
pluginFiles.sort()
const languageFile = 'artplayer-i18n.d.ts'
fs.writeFileSync(path.join('docs/assets/ts', languageFile), `declare module 'artplayer/i18n/*' {\n const language: NonNullable<Artplayer.I18n['en']>\n export default language\n}\n`)
const allFiles = [...pluginFiles, 'artplayer.d.ts', languageFile]
const eslint = new ESLint({ fix: true, fixTypes: ['layout'] })
const results = await eslint.lintFiles(allFiles.map(file => path.join('docs/assets/ts', file)))
await ESLint.outputFixes(results)
if (results.some(result => result.errorCount)) {
const formatter = await eslint.loadFormatter('stylish')
throw new Error(formatter.format(results))
}
if (selected.length)
return
const commonJsPath = path.join('docs/assets/js/common.js')
const commonJsContent = fs.readFileSync(commonJsPath, 'utf-8')
const newLibUris = allFiles.map(file => `'./assets/ts/${file}'`).join(',\n ')
const newContent = commonJsContent.replace(
/let libUris = \[([\s\S]*?)\]/,
`let libUris = [\n ${newLibUris},\n ]`,
)
ensureDirExists(commonJsPath)
fs.writeFileSync(commonJsPath, newContent)
console.log(`✨ Updated libUris in ${commonJsPath}`)
})()
await runEditorDeclarations(process.argv.slice(2))
+70
View File
@@ -0,0 +1,70 @@
# Editor declarations
Run `yarn build:ts` from the repository root using Node 24.21.0 and Yarn 1.22.22.
Existing declaration URLs and package selection arguments remain supported.
`yarn build:ts artplayer-plugin-chapter` selects that package plus shared core
and language declarations. `yarn check:editor-types` checks the complete output
without writing. Unknown/duplicate selections fail before generating or writing.
| Module | Responsibility |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `../build-ts.js` | Existing CLI path, checked JavaScript shim |
| `generate.ts` | Discovery, names, formatting, whole-set validation, writes/checks and editor libUris |
| `core.ts` | Core public source bundling, constructor and named/generic aliases |
| `plugin.ts` | AST conversion of default exports, callable CommonJS namespaces, type aliases and Window augmentations |
| `syntax.ts` | Public compiler syntax diagnostics and supported dependency definition kinds |
| `dependencies.ts`, `vast-sdk.ts` | Type-only VAST SDK dependency closure; no advertising scripts |
| `validation.ts` | Standalone virtual compiler host, relative imports and rejection of hidden external dependencies |
| `../editor-types.mjs`, `../plugin-editor-types.mjs` | Existing tooling import paths forwarding to TypeScript |
`yarn typecheck:docs-tools` checks these modules and the original JS/MJS shims.
Orchestration depends on conversion and validation; conversion does not write.
Public package declarations remain the source of truth. Do not hand-edit
`docs/assets/ts/*.d.ts`.
All selected declarations are formatted in memory using layout-only ESLint fixes,
then checked together with TypeScript 5.9.3 and 4.3.5, without `skipLibCheck` or
ambient dependency discovery. This checks cross-package globals too. SDK methods
and type aliases retain upstream shapes; the generated SDK block has a narrowly
scoped lint exception for those two style rules. The two compiler packages have
nominally distinct AST types: the historical module is adapted at the boundary
and exercised as the real older compiler. Node native type stripping runs tools.
Legacy MJS helpers retain synchronous functions and diagnostic shapes; existing
package consumer tests still use them. Core retains its existing bundler and
private definitions namespace. Plugins preserve callable/default aliases and
exported type namespaces. Unsupported imports/re-exports and invalid syntax fail.
VAST's `Player` and `PlayerOptions` come from the installed, lockfile-pinned
`@glomex/vast-ima-player` 1.21.2 and `@alugha/ima` 2.1.0 declarations. A dedicated
type-only SDK entry avoids bundling a second copy of the linked workspace core.
SDK types stay in the plugin's module-private definitions namespace, not global
runtime values. The optional Window hook and required callback remain unchanged.
Adjacent generated `artplayer-plugin-vast.LICENSE.txt` preserves upstream notices.
This does not settle VAST's runtime default-behavior compatibility decision.
The editor loads 22 declarations: core, 20 ecosystem libraries and i18n. The
unreferenced legacy `artplayer-plugin-websr.d.ts` asset is not in the `common.js`
library list and is not owned or deleted here. Monaco tests follow the actual
list. AST updates require exactly one array-valued `libUris` declaration; missing
or ambiguous declarations fail. Package-only builds leave that list alone.
Generation validates all selected results before any writes, but does not provide
a multi-file filesystem transaction. `--check` normalizes CRLF for comparison,
writes nothing and rejects missing/stale output. No timestamps are generated.
Rebuild then check after changes to public types, SDK versions or formatting.
```sh
yarn typecheck:docs-tools
yarn build:ts
yarn check:editor-types
node --test test/editor-types.test.js
yarn test:baseline
yarn test:browser test/browser/editor-declarations.spec.js --workers=1
```
Browser coverage uses the actual repository Monaco assets, all loaded declarations,
positive/negative consumers and emitted Chapter code against controlled media.
VAST is type-checked only: no ad request or SDK/network/device acceptance. Full
editor UI, routes, example coverage and release reviews remain SITE-03/04/05,
EX-03 and release tasks. SITE-02 installs no new dependencies.
+145
View File
@@ -0,0 +1,145 @@
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') }))
}
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateDtsBundle } from 'dts-bundle-generator'
import ts from 'typescript'
import { parseDeclaration } from './syntax.ts'
export function vastSdkDeclarations(): ts.Statement[] {
const root = fileURLToPath(new URL('../../', import.meta.url))
const [code] = generateDtsBundle([{
filePath: path.join(root, 'scripts/editor-declarations/vast-sdk.ts'),
libraries: { inlinedLibraries: ['@glomex/vast-ima-player', '@alugha/ima'], allowedTypesLibraries: [] },
output: { noBanner: true, exportReferencedTypes: false },
}], { preferredConfigPath: path.join(root, 'scripts/tsconfig.editor.json') })
assert(code, 'SDK bundle is empty')
return [...parseDeclaration(code, 'vast-sdk.d.ts').statements].filter((node) => {
if (!ts.isExportDeclaration(node))
return true
assert(!node.moduleSpecifier && node.exportClause && ts.isNamedExports(node.exportClause)
&& node.exportClause.elements.length === 0, 'Unexpected SDK re-export')
return false
})
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { ESLint } from 'eslint'
import { globSync } from 'glob'
import ts from 'typescript'
import compat from 'typescript-compat'
import { generateCoreEditorDeclaration } from './core.ts'
import { vastSdkDeclarations } from './dependencies.ts'
import { generatePluginEditorDeclaration } from './plugin.ts'
import { checkStandaloneDeclarations } from './validation.ts'
export function pluginIdentity(file: string): { name: string, file: string, packageName: string } {
const packageName = path.basename(path.dirname(path.dirname(file)))
const names: Record<string, string> = { 'artplayer-tool-iframe': 'ArtplayerToolIframe', 'artplayer-tool-thumbnail': 'ArtplayerToolThumbnail' }
const name = names[packageName] || packageName.split('-').map((word, index) => index ? word.charAt(0).toUpperCase() + word.slice(1) : word).join('')
return { name, file: path.basename(file), packageName }
}
export function editorLibUris(code: string, files: string[]): string {
const source = ts.createSourceFile('common.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const lists: ts.ArrayLiteralExpression[] = []
const visit = (node: ts.Node): void => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === 'libUris') {
assert(node.initializer && ts.isArrayLiteralExpression(node.initializer), 'libUris must be an array initializer')
lists.push(node.initializer)
}
ts.forEachChild(node, visit)
}
visit(source)
assert(lists.length === 1 && lists[0], 'Expected exactly one libUris declaration')
const list = lists[0]
const value = `[\n ${files.map(file => `'./assets/ts/${file}'`).join(',\n ')},\n ]`
return code.slice(0, list.getStart(source)) + value + code.slice(list.end)
}
export async function generateEditorDeclarations(selected: string[] = []): Promise<Map<string, string>> {
const available = globSync('packages/artplayer-*-*/types/*.d.ts').filter(file => path.basename(file) === `${pluginIdentity(file).packageName}.d.ts`).sort()
for (const name of selected)
assert(available.some(file => pluginIdentity(file).packageName === name), `Unknown declaration package: ${name}`)
assert(new Set(selected).size === selected.length, 'Duplicate declaration package selection')
const sources = selected.length ? available.filter(file => selected.includes(pluginIdentity(file).packageName)) : available
const core = generateCoreEditorDeclaration()
const outputs = new Map<string, string>([['docs/assets/ts/artplayer.d.ts', core]])
const pluginFiles: string[] = []
for (const sourceFile of sources) {
const { name, file, packageName } = pluginIdentity(sourceFile)
const localTypes: Record<string, string> = packageName === 'artplayer-proxy-mediabunny' ? { './media': fs.readFileSync(path.join(path.dirname(sourceFile), 'media.d.ts'), 'utf8') } : {}
const dependencies = packageName === 'artplayer-plugin-vast' ? vastSdkDeclarations() : []
const code = generatePluginEditorDeclaration(fs.readFileSync(sourceFile, 'utf8'), name, localTypes, dependencies)
outputs.set(path.join('docs/assets/ts', file), code)
pluginFiles.push(file)
}
const languageFile = 'artplayer-i18n.d.ts'
outputs.set(path.join('docs/assets/ts', languageFile), `declare module 'artplayer/i18n/*' {\n const language: NonNullable<Artplayer.I18n['en']>\n export default language\n}\n`)
const eslint = new ESLint({ fix: true, fixTypes: ['layout'] })
for (const [file, code] of outputs) {
const [result] = await eslint.lintText(code, { filePath: file })
assert(result && !result.errorCount, `Invalid formatted editor declaration ${file}: ${JSON.stringify(result?.messages)}`)
outputs.set(file, result.output || code)
}
// Both actual compilers check the formatted files together, including global collisions.
const declarations = new Map([...outputs].map(([file, code]) => [path.basename(file), code]))
for (const compiler of [ts, compat as unknown as typeof ts])
assert.deepEqual(checkStandaloneDeclarations(declarations, compiler), [], `Invalid editor declarations (TS ${compiler.version})`)
if (!selected.length) {
const file = 'docs/assets/js/common.js'
outputs.set(file, editorLibUris(fs.readFileSync(file, 'utf8'), [...pluginFiles.sort(), 'artplayer.d.ts', languageFile]))
}
if (sources.some(file => pluginIdentity(file).packageName === 'artplayer-plugin-vast')) {
const notices = [['@glomex/vast-ima-player', 'LICENSE'], ['@alugha/ima', 'LICENSE.md']].map(([name, file]) => `${name}\n${fs.readFileSync(path.join('node_modules', name!, file!), 'utf8').replaceAll('\r\n', '\n').trim()}\n`).join('\n')
outputs.set('docs/assets/ts/artplayer-plugin-vast.LICENSE.txt', notices)
}
return outputs
}
export async function runEditorDeclarations(args: string[]): Promise<void> {
assert(args.every(arg => arg === '--check' || !arg.startsWith('-')), 'Use yarn build:ts [--check] [package ...]')
const outputs = await generateEditorDeclarations(args.filter(arg => arg !== '--check'))
for (const [file, code] of outputs) {
if (args.includes('--check')) {
assert.equal(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'), code.replaceAll('\r\n', '\n'), `Editor declaration drift: ${file}`)
}
else {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, code)
}
}
console.log(`Editor declarations ${args.includes('--check') ? 'checked' : 'generated'}: ${outputs.size} outputs; all selected declarations checked with current and compatibility compilers`)
}
+185
View File
@@ -0,0 +1,185 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { exportedDefinition, parseDeclaration } from './syntax.ts'
// Migrate each package explicitly; unknown imports/exports must not disappear silently.
export function generatePluginEditorDeclaration(code: string, name: string, localTypes: Record<string, string> = {}, dependencies: ts.Statement[] = []) {
assert(/^[a-z_$][\w$]*$/i.test(name), 'Invalid plugin global')
let source = parseDeclaration(code)
const expanded: ts.Statement[] = []
for (const node of source.statements) {
if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) {
expanded.push(node)
continue
}
assert(node.isTypeOnly, 'Unsupported plugin editor declaration: runtime re-export')
assert(ts.isStringLiteral(node.moduleSpecifier), 'Expected a literal type module')
const dependency = localTypes[node.moduleSpecifier.text]
assert(typeof dependency === 'string' && node.exportClause && ts.isNamedExports(node.exportClause), 'Unsupported editor type re-export')
const names = node.exportClause.elements.map((item) => {
assert(!item.propertyName, 'Renamed editor type re-export is unsupported')
return item.name.text
})
const types = parseDeclaration(dependency, 'dependency.d.ts')
const definitions = types.statements.filter(item => !ts.isImportDeclaration(item))
assert(definitions.every(item => ts.isInterfaceDeclaration(item) || ts.isTypeAliasDeclaration(item)), 'Editor dependencies must contain only types')
assert.deepEqual(definitions.map(item => item.name.text).sort(), names.sort(), 'Editor re-export must explicitly cover its local type definitions')
expanded.push(...types.statements)
}
// Reparse combined statements so comments and text positions belong to one file.
source = ts.createSourceFile('plugin.d.ts', expanded.map(node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())).join('\n'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
if (source.statements.some(node => ts.isExportAssignment(node) && node.isExportEquals))
return generateCommonJSPluginEditor(source, name)
const factory = ts.factory
const definitions: ts.Statement[] = []
const globals: ts.ModuleDeclaration[] = []
const aliases: ts.TypeAliasDeclaration[] = []
const internal = `${name}Definitions`
let exported = false
let callable = false
let classExport = false
for (const node of source.statements) {
if (ts.isImportDeclaration(node)) {
assert(ts.isStringLiteral(node.moduleSpecifier), 'Expected a literal import')
if (dependencies.length && node.moduleSpecifier.text === '@glomex/vast-ima-player') {
const bindings = node.importClause?.namedBindings
assert(node.importClause?.isTypeOnly && !node.importClause.name && bindings && ts.isNamedImports(bindings), 'Expected type-only SDK imports')
assert.deepEqual(bindings.elements.map((item) => {
assert(!item.propertyName)
return item.name.text
}).sort(), ['Player', 'PlayerOptions'])
continue
}
assert(node.importClause?.isTypeOnly && !node.importClause.namedBindings && node.importClause.name?.text === 'Artplayer' && node.moduleSpecifier.text === 'artplayer', 'Unsupported plugin editor import')
continue
}
if (ts.isModuleDeclaration(node)) {
assert(node.flags & ts.NodeFlags.GlobalAugmentation, 'Only explicit global augmentations are supported')
assert(node.body && ts.isModuleBlock(node.body) && node.body.statements.every(item => ts.isInterfaceDeclaration(item) && item.name.text === 'Window'), 'Only Window augmentations are supported')
globals.push(node)
continue
}
if (ts.isExportAssignment(node)) {
assert(!node.isExportEquals && ts.isIdentifier(node.expression) && node.expression.text === name, 'Unexpected plugin default export')
exported = true
continue
}
if (ts.isVariableStatement(node)) {
const values = node.declarationList.declarations
const value = values[0]
assert(values.length === 1 && value && ts.isIdentifier(value.name) && value.name.text === name && value.type && ts.isFunctionTypeNode(value.type), 'Unexpected plugin callable variable')
definitions.push(factory.replaceModifiers(node, [factory.createModifier(ts.SyntaxKind.ExportKeyword)]))
callable = true
continue
}
assert(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node), 'Unsupported plugin editor declaration')
assert(node.name && node.name.text !== internal, 'Invalid plugin declaration name')
definitions.push(factory.replaceModifiers(node, [factory.createModifier(ts.SyntaxKind.ExportKeyword)]))
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
assert.equal(node.name.text, name, 'Unexpected plugin callable')
callable = true
classExport = ts.isClassDeclaration(node)
continue
}
if (node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
aliases.push(factory.createTypeAliasDeclaration(
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
node.name,
node.typeParameters,
factory.createTypeReferenceNode(factory.createQualifiedName(factory.createIdentifier(internal), node.name), node.typeParameters?.map(parameter => factory.createTypeReferenceNode(parameter.name))),
))
}
}
assert(exported && callable, 'Plugin editor requires a default function or class')
const namespace = (identifier: string, statements: ts.Statement[]) => factory.createModuleDeclaration([factory.createModifier(ts.SyntaxKind.DeclareKeyword)], factory.createIdentifier(identifier), factory.createModuleBlock(statements), ts.NodeFlags.Namespace)
const statements = [
...globals,
namespace(internal, definitions),
factory.createVariableStatement([factory.createModifier(ts.SyntaxKind.DeclareKeyword)], factory.createVariableDeclarationList([
factory.createVariableDeclaration(name, undefined, factory.createTypeQueryNode(factory.createQualifiedName(factory.createIdentifier(internal), factory.createIdentifier(name)))),
], ts.NodeFlags.Const)),
...(classExport ? [factory.createTypeAliasDeclaration(undefined, name, undefined, factory.createTypeReferenceNode(factory.createQualifiedName(factory.createIdentifier(internal), factory.createIdentifier(name))))] : []),
namespace(name, aliases),
factory.createExportAssignment(undefined, true, factory.createIdentifier(name)),
factory.createNamespaceExportDeclaration(factory.createIdentifier(name)),
]
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
const dependencySource = dependencies[0]?.getSourceFile()
assert(dependencies.every(node => node.getSourceFile() === dependencySource), 'SDK definitions must share one source file')
const dependencyNamespace = dependencySource
? `/* eslint-disable ts/method-signature-style, ts/consistent-type-definitions -- Preserve upstream SDK declaration shapes. */\n${printer.printNode(ts.EmitHint.Unspecified, namespace(internal, dependencies.map(exportedDefinition)), dependencySource)}\n/* eslint-enable ts/method-signature-style, ts/consistent-type-definitions */\n`
: ''
return `// Generated from the package public declaration by yarn build:ts. Do not edit.\n/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */\n${dependencyNamespace}${statements.map(node => printer.printNode(ts.EmitHint.Unspecified, node, source)).join('\n')}\n`
}
function generateCommonJSPluginEditor(source: ts.SourceFile, name: string) {
const statements: ts.Statement[] = []
let namespace = false
let callable = false
let exported = false
let global = false
let classExport = false
for (const node of source.statements) {
if (ts.isImportDeclaration(node)) {
assert(ts.isStringLiteral(node.moduleSpecifier) && node.importClause?.isTypeOnly && !node.importClause.namedBindings && node.importClause.name?.text === 'Artplayer' && node.moduleSpecifier.text === 'artplayer', 'Unsupported CommonJS plugin editor import')
continue
}
if (ts.isModuleDeclaration(node)) {
assert.equal(node.name.text, name, 'Unexpected plugin namespace')
assert(node.body && ts.isModuleBlock(node.body) && node.body.statements.every(item => ts.isInterfaceDeclaration(item) || ts.isTypeAliasDeclaration(item)), 'Editor namespace must contain only public type declarations')
namespace = true
}
else if (ts.isClassDeclaration(node)) {
assert.equal(node.name?.text, name, 'Unexpected CommonJS editor class')
callable = true
classExport = true
}
else if (ts.isVariableStatement(node)) {
const definitions = node.declarationList.declarations
assert.equal(definitions.length, 1)
const definition = definitions[0]
assert(definition && ts.isIdentifier(definition.name) && definition.type)
assert.equal(definition.name.text, name)
assert(ts.isTypeReferenceNode(definition.type) && ts.isQualifiedName(definition.type.typeName)
&& ts.isIdentifier(definition.type.typeName.left) && definition.type.typeName.left.text === name && definition.type.typeName.right.text === 'Factory', 'Editor export must use its public Factory interface')
callable = true
}
else if (ts.isExportAssignment(node)) {
assert(node.isExportEquals && ts.isIdentifier(node.expression) && node.expression.text === name, 'Unexpected CommonJS plugin export')
exported = true
}
else if (ts.isNamespaceExportDeclaration(node)) {
assert.equal(node.name.text, name)
global = true
}
else {
assert.fail('Unsupported CommonJS plugin editor declaration')
}
statements.push(node)
}
assert(namespace && callable && exported && global, 'Incomplete CommonJS plugin editor declaration')
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
const bridge = classExport ? '' : '/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */\n'
return `// Generated from the package public declaration by yarn build:ts. Do not edit.\n${bridge}${statements.map(node => printer.printNode(ts.EmitHint.Unspecified, node, source)).join('\n')}\n`
}
export function checkPluginEditorDeclaration(code: string, core: string, consumer = '', compiler: typeof ts = ts) {
const folder = fileURLToPath(new URL('../../refactor/.cache/plugin-editor-semantic/', import.meta.url))
const sources = new Map([
[path.join(folder, 'artplayer.d.ts'), core],
[path.join(folder, 'plugin.d.ts'), code],
[path.join(folder, 'consumer.ts'), consumer],
])
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)
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), `Plugin editor types escaped the standalone bundle: ${file.fileName}`)
return compiler.getPreEmitDiagnostics(program).map(diagnostic => ({ code: diagnostic.code, message: compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n') }))
}
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import ts from 'typescript'
export function parseDeclaration(code: string, file = 'plugin.d.ts'): ts.SourceFile {
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const options: ts.CompilerOptions = { noLib: true, noResolve: true, types: [] }
const host = ts.createCompilerHost(options)
host.getSourceFile = name => name === file ? source : undefined
const program = ts.createProgram([file], options, host)
assert.equal(program.getSyntacticDiagnostics(source).length, 0, 'Invalid plugin declaration syntax')
return source
}
export function exportedDefinition(node: ts.Statement): ts.Statement {
assert(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node)
|| ts.isModuleDeclaration(node) || ts.isEnumDeclaration(node), 'Unsupported SDK declaration kind')
return ts.factory.replaceModifiers(node, [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)])
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
export function checkStandaloneDeclarations(files: Map<string, string>, compiler: typeof ts = ts) {
const folder = path.resolve(fileURLToPath(new URL('../../refactor/.cache/editor-check/', import.meta.url)))
const sources = new Map([...files].map(([file, code]) => [path.join(folder, file), code]))
const options: ts.CompilerOptions = { 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) => {
const code = sources.get(path.resolve(file))
return code === undefined ? getSourceFile(file, version, onError, create) : compiler.createSourceFile(file, code, version, true)
}
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, file: diagnostic.file && path.basename(diagnostic.file.fileName), message: compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n') }))
}
+2
View File
@@ -0,0 +1,2 @@
// Types only: the editor does not fetch or execute the advertising SDK.
export type { Player, PlayerOptions } from '@glomex/vast-ima-player'
+2 -143
View File
@@ -1,143 +1,2 @@
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') }))
}
// Preserve existing tooling imports while implementation lives in checked TypeScript.
export { asGlobalDeclaration, checkCoreEditorDeclaration, generateCoreEditorDeclaration } from './editor-declarations/core.ts'
+2 -160
View File
@@ -1,160 +1,2 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
// Migrate each package explicitly; unknown imports/exports must not disappear silently.
export function generatePluginEditorDeclaration(code, name, localTypes = {}) {
assert(/^[a-z_$][\w$]*$/i.test(name), 'Invalid plugin global')
let source = ts.createSourceFile('plugin.d.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
assert.equal(source.parseDiagnostics.length, 0, 'Invalid plugin declaration syntax')
const expanded = []
for (const node of source.statements) {
if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) {
expanded.push(node)
continue
}
assert(node.isTypeOnly, 'Unsupported plugin editor declaration: runtime re-export')
const dependency = localTypes[node.moduleSpecifier.text]
assert(typeof dependency === 'string' && node.exportClause && ts.isNamedExports(node.exportClause), 'Unsupported editor type re-export')
const names = node.exportClause.elements.map((item) => {
assert(!item.propertyName, 'Renamed editor type re-export is unsupported')
return item.name.text
})
const types = ts.createSourceFile('dependency.d.ts', dependency, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
assert.equal(types.parseDiagnostics.length, 0)
const definitions = types.statements.filter(item => !ts.isImportDeclaration(item))
assert(definitions.every(item => ts.isInterfaceDeclaration(item) || ts.isTypeAliasDeclaration(item)), 'Editor dependencies must contain only types')
assert.deepEqual(definitions.map(item => item.name.text).sort(), names.sort(), 'Editor re-export must explicitly cover its local type definitions')
expanded.push(...types.statements)
}
// Reparse combined statements so comments and text positions belong to one file.
source = ts.createSourceFile('plugin.d.ts', expanded.map(node => ts.createPrinter().printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())).join('\n'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
if (source.statements.some(node => ts.isExportAssignment(node) && node.isExportEquals))
return generateCommonJSPluginEditor(source, name)
const factory = ts.factory
const definitions = []
const aliases = []
const internal = `${name}Definitions`
let exported = false
let callable = false
let classExport = false
for (const node of source.statements) {
if (ts.isImportDeclaration(node)) {
assert(node.importClause?.isTypeOnly && !node.importClause.namedBindings && node.importClause.name?.text === 'Artplayer' && node.moduleSpecifier.text === 'artplayer', 'Unsupported plugin editor import')
continue
}
if (ts.isExportAssignment(node)) {
assert(!node.isExportEquals && ts.isIdentifier(node.expression) && node.expression.text === name, 'Unexpected plugin default export')
exported = true
continue
}
if (ts.isVariableStatement(node)) {
const values = node.declarationList.declarations
assert(values.length === 1 && values[0].name.text === name && ts.isFunctionTypeNode(values[0].type), 'Unexpected plugin callable variable')
definitions.push(factory.replaceModifiers(node, [factory.createModifier(ts.SyntaxKind.ExportKeyword)]))
callable = true
continue
}
assert(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node), 'Unsupported plugin editor declaration')
assert(node.name && node.name.text !== internal, 'Invalid plugin declaration name')
definitions.push(factory.replaceModifiers(node, [factory.createModifier(ts.SyntaxKind.ExportKeyword)]))
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) {
assert.equal(node.name.text, name, 'Unexpected plugin callable')
callable = true
classExport = ts.isClassDeclaration(node)
continue
}
if (node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
aliases.push(factory.createTypeAliasDeclaration(
[factory.createModifier(ts.SyntaxKind.ExportKeyword)],
node.name,
node.typeParameters,
factory.createTypeReferenceNode(factory.createQualifiedName(factory.createIdentifier(internal), node.name), node.typeParameters?.map(parameter => factory.createTypeReferenceNode(parameter.name))),
))
}
}
assert(exported && callable, 'Plugin editor requires a default function or class')
const namespace = (identifier, statements) => factory.createModuleDeclaration([factory.createModifier(ts.SyntaxKind.DeclareKeyword)], factory.createIdentifier(identifier), factory.createModuleBlock(statements), ts.NodeFlags.Namespace)
const statements = [
namespace(internal, definitions),
factory.createVariableStatement([factory.createModifier(ts.SyntaxKind.DeclareKeyword)], factory.createVariableDeclarationList([
factory.createVariableDeclaration(name, undefined, factory.createTypeQueryNode(factory.createQualifiedName(factory.createIdentifier(internal), factory.createIdentifier(name)))),
], ts.NodeFlags.Const)),
...(classExport ? [factory.createTypeAliasDeclaration(undefined, name, undefined, factory.createTypeReferenceNode(factory.createQualifiedName(factory.createIdentifier(internal), factory.createIdentifier(name))))] : []),
namespace(name, aliases),
factory.createExportAssignment(undefined, true, factory.createIdentifier(name)),
factory.createNamespaceExportDeclaration(factory.createIdentifier(name)),
]
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
return `// Generated from the package public declaration by yarn build:ts. Do not edit.\n/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */\n${statements.map(node => printer.printNode(ts.EmitHint.Unspecified, node, source)).join('\n')}\n`
}
function generateCommonJSPluginEditor(source, name) {
const statements = []
let namespace = false
let callable = false
let exported = false
let global = false
let classExport = false
for (const node of source.statements) {
if (ts.isImportDeclaration(node)) {
assert(node.importClause?.isTypeOnly && !node.importClause.namedBindings && node.importClause.name?.text === 'Artplayer' && node.moduleSpecifier.text === 'artplayer', 'Unsupported CommonJS plugin editor import')
continue
}
if (ts.isModuleDeclaration(node)) {
assert.equal(node.name.text, name, 'Unexpected plugin namespace')
assert(node.body && ts.isModuleBlock(node.body) && node.body.statements.every(item => ts.isInterfaceDeclaration(item) || ts.isTypeAliasDeclaration(item)), 'Editor namespace must contain only public type declarations')
namespace = true
}
else if (ts.isClassDeclaration(node)) {
assert.equal(node.name?.text, name, 'Unexpected CommonJS editor class')
callable = true
classExport = true
}
else if (ts.isVariableStatement(node)) {
const definitions = node.declarationList.declarations
assert.equal(definitions.length, 1)
const definition = definitions[0]
assert.equal(definition.name.text, name)
assert(ts.isTypeReferenceNode(definition.type) && ts.isQualifiedName(definition.type.typeName)
&& definition.type.typeName.left.text === name && definition.type.typeName.right.text === 'Factory', 'Editor export must use its public Factory interface')
callable = true
}
else if (ts.isExportAssignment(node)) {
assert(node.isExportEquals && node.expression.text === name, 'Unexpected CommonJS plugin export')
exported = true
}
else if (ts.isNamespaceExportDeclaration(node)) {
assert.equal(node.name.text, name)
global = true
}
else {
assert.fail('Unsupported CommonJS plugin editor declaration')
}
statements.push(node)
}
assert(namespace && callable && exported && global, 'Incomplete CommonJS plugin editor declaration')
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
const bridge = classExport ? '' : '/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */\n'
return `// Generated from the package public declaration by yarn build:ts. Do not edit.\n${bridge}${statements.map(node => printer.printNode(ts.EmitHint.Unspecified, node, source)).join('\n')}\n`
}
export function checkPluginEditorDeclaration(code, core, consumer = '', compiler = ts) {
const folder = fileURLToPath(new URL('../refactor/.cache/plugin-editor-semantic/', import.meta.url))
const sources = new Map([
[path.join(folder, 'artplayer.d.ts'), core],
[path.join(folder, 'plugin.d.ts'), code],
[path.join(folder, 'consumer.ts'), consumer],
])
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)
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), `Plugin editor types escaped the standalone bundle: ${file.fileName}`)
return compiler.getPreEmitDiagnostics(program).map(diagnostic => ({ code: diagnostic.code, message: compiler.flattenDiagnosticMessageText(diagnostic.messageText, '\n') }))
}
// Preserve existing tooling imports while implementation lives in checked TypeScript.
export { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from './editor-declarations/plugin.ts'
+16 -3
View File
@@ -1,10 +1,23 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"types": ["node"],
"lib": [
"ES2021",
"DOM",
"DOM.Iterable"
],
"types": [
"node"
],
"allowImportingTsExtensions": true,
"checkJs": true
},
"include": ["docs-smoke/**/*.ts", "build-test.js"]
"include": [
"docs-smoke/**/*.ts",
"build-test.js",
"build-ts.js",
"editor-declarations/**/*.ts",
"editor-types.mjs",
"plugin-editor-types.mjs"
]
}