mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(docs): [SITE-02] generate and validate standalone editor types
This commit is contained in:
1 parent
1a5440a67f
commit
76499e7a3e
30 files changed
+2955
-459
No files matched your search
+2
-95
@@ -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))
|
||||
@@ -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.
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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') }))
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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') }))
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user