refactor(tooling): [MOD-02] split library build and development into typed modules

This commit is contained in:
Harvey Zhao committed 2026-09-14 10:46:25 +08:00
1 parent 28c7abb2bb
commit 401d4cdd65
36 files changed
+1644 -427

No files matched your search

+1 -44
View File
@@ -1,44 +1 @@
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { gzipSync } from 'node:zlib'
export function createBuildAnalysis() {
const root = process.cwd()
const parent = path.join(root, 'refactor/.cache/build-analysis')
fs.mkdirSync(parent, { recursive: true })
const directory = fs.mkdtempSync(path.join(parent, 'run-'))
fs.writeFileSync(path.join(parent, 'latest.json'), `${JSON.stringify({ output: path.relative(root, directory).replaceAll('\\', '/') })}\n`)
return { root, directory }
}
export function buildAnalysisPlugin({ root, directory }, name, format) {
return {
name: 'artplayer-build-analysis',
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle).filter(item => item.type === 'chunk')) {
const modules = Object.entries(chunk.modules).map(([id, info]) => {
const normalized = id.replaceAll('\\', '/')
const dependency = normalized.lastIndexOf('/node_modules/')
const file = id.startsWith('\0')
? `virtual:${id.slice(1)}`
: dependency === -1 ? path.relative(root, id).replaceAll('\\', '/') : normalized.slice(dependency + 1)
return { file, renderedBytes: Buffer.byteLength(info.code || ''), renderedCodeUnits: info.renderedLength, originalCodeUnits: info.originalLength, renderedExports: info.renderedExports, removedExports: info.removedExports }
}).sort((a, b) => b.renderedBytes - a.renderedBytes || a.file.localeCompare(b.file))
const report = {
schemaVersion: 1,
name,
format,
node: process.version,
file: chunk.fileName,
artifact: { bytes: Buffer.byteLength(chunk.code), gzip9: gzipSync(chunk.code, { level: 9 }).length, sha256: createHash('sha256').update(chunk.code).digest('hex') },
note: 'Rendered UTF-8 bytes precede final chunk minification; they are not additive compressed sizes or download measurements. Rollup lengths are separately recorded as UTF-16 code units. Original code units describe Rollup input, not raw TypeScript.',
modules,
}
fs.writeFileSync(path.join(directory, `${name}-${format}.json`), `${JSON.stringify(report, null, 2)}\n`)
}
},
}
}
export { buildAnalysisPlugin, createBuildAnalysis } from './library/analysis.ts'
+1 -97
View File
@@ -1,101 +1,5 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import cpy from 'cpy'
import { build as viteBuild } from 'vite'
import { buildAnalysisPlugin, createBuildAnalysis } from './build-analysis.mjs'
import { getEntryFile, selectProjects } from './projects.js'
import { getGlobalName, getProjects, getViteBuildConfig } from './utils.js'
const projects = getProjects()
const compiledPath = path.resolve('docs/compiled')
function getBanner(name, version) {
const noticeFile = path.join(projects[name], 'THIRD_PARTY_NOTICES')
const notices = fs.existsSync(noticeFile) ? fs.readFileSync(noticeFile, 'utf8').trim() : ''
const noticeBlock = notices ? `\n *\n${notices.split(/\r?\n/).map(line => line.trimEnd() ? ` * ${line.trimEnd()}` : ' *').join('\n')}` : ''
return `/*!
* ${name}.js v${version}
* Github: https://github.com/zhw2590582/ArtPlayer
* (c) 2017-${new Date().getFullYear()} Harvey Zhao
* Released under the MIT License.${noticeBlock}
*/`
}
const BUILD_FORMATS = {
main: { format: 'umd', ext: '.js', target: 'es2020', minify: 'terser' },
legacy: { format: 'umd', ext: '.legacy.js', target: 'es2015', minify: 'terser' },
esm: { format: 'es', ext: '.mjs', target: 'es2020', minify: false },
}
async function build(name, targetName, clean = false, analysis) {
const projectDir = projects[name]
const { version } = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'))
const distDir = path.join(projectDir, 'dist')
const entryFile = getEntryFile(projectDir)
if (clean && fs.existsSync(distDir)) {
if (fs.realpathSync(distDir) !== path.join(fs.realpathSync(projectDir), 'dist'))
throw new Error(`Refusing to clean redirected dist directory: ${distDir}`)
fs.rmSync(distDir, { recursive: true, force: true })
}
const startTime = Date.now()
const { format, ext, target, minify } = BUILD_FORMATS[targetName]
const fileName = `${name}${ext}`
const config = getViteBuildConfig({
entry: entryFile,
outDir: distDir,
name: getGlobalName(name),
format,
fileName,
minify,
target,
banner: getBanner(name, version),
})
// Terser options for all builds (preserve banner comments starting with /*!)
const isLegacy = targetName === 'legacy'
config.build.terserOptions = {
ecma: isLegacy ? 5 : 2020,
compress: { ecma: isLegacy ? 5 : 2020, comparisons: false, inline: 2 },
mangle: { safari10: true },
format: {
ecma: isLegacy ? 5 : 2020,
comments: /^!/,
ascii_only: true,
max_line_len: false, // No line length limit
},
}
if (analysis)
config.build.rollupOptions.plugins.push(buildAnalysisPlugin(analysis, name, targetName))
await viteBuild({ root: projectDir, ...config })
const outFile = path.join(distDir, fileName)
const size = (fs.statSync(outFile).size / 1024).toFixed(2)
await cpy(outFile, compiledPath, { flat: true })
console.log(`✨ Built@${targetName} ${name}@${version} Time@${Date.now() - startTime}ms Size@${size}kb`)
}
async function buildProject(name, analysis) {
await build(name, 'main', true, analysis)
await build(name, 'legacy', false, analysis)
await build(name, 'esm', false, analysis)
}
async function runBuild() {
const { names, analyze } = await selectProjects(projects, 'build')
const analysis = analyze ? createBuildAnalysis() : undefined
for (const name of names)
await buildProject(name, analysis)
if (analysis)
console.log(`Build analysis: ${analysis.directory}`)
if (names.length)
console.log(`✅ Finished building ${names.length} package(s)!`)
}
import { runBuild } from './library/production.ts'
runBuild().catch((error) => {
console.error('❌ Build failed:', error)
+2 -71
View File
@@ -1,76 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import servor from 'servor'
import openBrowser from 'servor/utils/openBrowser.js'
import { build as viteBuild } from 'vite'
import { getEntryFile, selectProjects } from './projects.js'
import { createRebuildQueue } from './rebuild.js'
import { getGlobalName, getProjects, getViteBuildConfig } from './utils.js'
import { runDevelop } from './library/development.ts'
const projects = getProjects()
async function develop(name, open) {
const projectPath = projects[name]
const uncompiledPath = path.resolve(`docs/uncompiled/${name}`)
let browserOpened = false
const { url } = await servor({
root: 'docs',
fallback: 'index.html',
reload: true,
port: 8082,
})
fs.mkdirSync(uncompiledPath, { recursive: true })
async function buildBundle() {
const startTime = Date.now()
try {
const config = getViteBuildConfig({
entry: getEntryFile(projectPath),
outDir: uncompiledPath,
name: getGlobalName(name),
format: 'iife',
fileName: 'index.js',
minify: false,
emptyOutDir: true,
})
config.define['process.env.NODE_ENV'] = JSON.stringify('development')
await viteBuild({ root: projectPath, ...config })
console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
if (open && !browserOpened) {
browserOpened = true
openBrowser(url)
}
}
catch (error) {
console.error(`[${name}] ❌ Build error:`, error)
}
}
const rebuild = createRebuildQueue(buildBundle)
await rebuild()
console.log(`[${name}] Demo: ${url}/?libs=./uncompiled/${name}/index.js`)
const srcPath = path.join(projectPath, 'src')
console.log(`[${name}] 👀 Watching ${srcPath}...`)
fs.watch(srcPath, { recursive: true }, async (_, filename) => {
if (filename) {
console.log(`[${name}] 📝 Changed: ${filename}`)
await rebuild()
}
})
}
(async () => {
const { names, open } = await selectProjects(projects, 'dev')
if (names.length)
await develop(names[0], open)
})().catch((error) => {
runDevelop().catch((error) => {
console.error('❌ Development server failed:', error)
process.exitCode = 1
})
+98
View File
@@ -0,0 +1,98 @@
# Library build and development tooling
Use Node from `.node-version` and Yarn Classic 1.22.22. The historical JS/MJS
entrypoints remain checked compatibility shims; implementation lives in strict TS.
No runtime/build dependency or bundler was replaced by this migration.
| Module | Responsibility |
| --- | --- |
| `projects.ts` | Deterministic workspace discovery, CLI selection, non-TTY failure, one JS/TS entry |
| `names.ts` | Historical camelCase/PascalCase library global names |
| `config.ts` | Vite configuration and resource/worker/output targets; typed with installed Vite declarations |
| `banner.ts` | Version/license/notices, UMD AMD/global identity, inline-worker banner removal |
| `production.ts` | Sequential package/format builds, checked dist cleanup, docs copy and optional analysis |
| `analysis.ts` | Per-build module and artifact size/hash reports outside dist |
| `development.ts` | Servor demo startup, initial IIFE build, source watcher, first-success browser opening |
| `rebuild.ts` | One active build Promise, coalesced pending changes and later retry after rejection |
| `vendor.d.ts` | Only the consumed prompts 2.4.2 / Servor 4.0.2 APIs; verified against installed sources |
`build.js` and `dev.js` own command execution/error reporting. `utils.js`,
`projects.js`, `rebuild.js` and `build-analysis.mjs` keep their previous named
exports. Importing implementation modules does not start a server/build or read
the current workspace; the explicit run functions do that work. TypeScript
annotation removal uses the canonical Node version; Node 20 consumer validation
concerns the built libraries, not executing these repository TS tools.
## Stable commands and build behavior
```sh
yarn build artplayer artplayer-plugin-chapter
yarn build all --analyze
yarn dev artplayer --no-open
yarn build --help
yarn dev --help
yarn typecheck:library
yarn test:library
```
No-argument TTY selection uses the same prompts UI. Missing names in non-TTY mode,
unknown/inherited project names and ambiguous JS/TS entries fail before cleaning
dist or starting the server. Build format order remains modern UMD `.js` es2020,
legacy UMD `.legacy.js` es2015, then ESM `.mjs` es2020. Main/legacy use the same
Terser options; ESM remains unminified. Notice text, worker handling, AMD globals,
default exports, copied docs paths and `--analyze` output remain intact. A missing
or non-string manifest version now fails before that package's dist cleanup.
Multi-package builds are sequential, not an all-packages transaction.
Vite types constrain configuration but do not replace source typechecking. Avoid
casts that hide incompatible plugin/output types. `config.ts` deliberately sets
`publicDir: false` so core declaration sources cannot leak into distribution files.
Keep unknown UMD wrappers as explicit failures when upgrading Vite/Terser.
Performance artifact verification fingerprints the JS entry shims and all code/JSON
under `scripts/library/`, including additions/removals. Do not reduce that coverage
to `config.ts` alone: banners, name mapping, project discovery or analysis may also
affect artifacts. Markdown maintenance changes are not build inputs.
## Development lifetime and remaining server work
CLI defaults remain port 8082 and docs/index.html, with output at
docs/uncompiled/<package>/index.js. The output directory is created before Servor
enumerates directories for its Linux file watchers. The internal `runDevelop`
function accepts a port only for isolated callers/tests; the CLI does not add a
new flag. Do not stop an unrelated server occupying 8082 to run a test.
Build errors are reported while keeping the source watcher alive. Changes during
a build schedule one subsequent build rather than concurrent writes. Browser
opening happens once after the first successful build; `--no-open` suppresses it.
Only the package src tree is watched; external dependencies/configuration changes
still require restart. Tests use real automatic reload rather than racing it with
a second manual navigation.
Servor still owns its HTTP server, docs watchers, reload sockets/timers and SIGINT
handler until process exit; its API exposes no close handle. MOD-DEV-01 tracks
replacing/adapting that lifetime and fixing its reproduced occupied-port exit code
zero. `node refactor/scripts/servor-port-probe.mjs` reproduces that upstream defect
in an isolated child without touching 8082. It is not a candidate-success test.
This migration does not claim those server limitations are resolved.
## Evidence and regression ownership
- `refactor/scripts/build.test.mjs`: CLI validation, serialization/error recovery,
real TS/JS/Less/SVG/inline-worker builds, CJS/global/AMD/ESM and analysis identity.
- `test/library-build.test.js`: public files never copied into library dist.
- `test/performance-report.test.js`: changed/new/removed TS build inputs cannot
reuse stale installed performance artifacts.
- `test/plugin-scaffold.test.js`: newly generated plugins use these real tools.
- `test/browser/library-development.spec.js`: real dev server in Chromium, Firefox
and WebKit, worker messaging, automatic reload, source edits and error recovery.
Uses fixture ports and disposes its own child; it does not test media playback.
- `node refactor/scripts/library-build-comparison.mjs`: copy current package sources
into a retained fixture, build all libraries with frozen pre-migration scripts
and current scripts at the same paths, compare all artifact hashes. This is an
explicit whole-build validation, not part of every quick unit test.
Remote Linux/macOS/Windows CI remains a separate release gate. The local browser
evidence for MOD-02 is Windows; source inspection of Linux watcher registration
does not substitute for a remote Linux run. Lerna and root installation hooks were
not changed. The scaffold's separate guide remains in `scripts/plugin/README.md`.
+45
View File
@@ -0,0 +1,45 @@
import type { Plugin } from 'vite'
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { gzipSync } from 'node:zlib'
export function createBuildAnalysis() {
const root = process.cwd()
const parent = path.join(root, 'refactor/.cache/build-analysis')
fs.mkdirSync(parent, { recursive: true })
const directory = fs.mkdtempSync(path.join(parent, 'run-'))
fs.writeFileSync(path.join(parent, 'latest.json'), `${JSON.stringify({ output: path.relative(root, directory).replaceAll('\\', '/') })}\n`)
return { root, directory }
}
export function buildAnalysisPlugin({ root, directory }: ReturnType<typeof createBuildAnalysis>, name: string, format: string): Plugin {
return {
name: 'artplayer-build-analysis',
generateBundle(_options, bundle) {
for (const chunk of Object.values(bundle).filter(item => item.type === 'chunk')) {
const modules = Object.entries(chunk.modules).map(([id, info]) => {
const normalized = id.replaceAll('\\', '/')
const dependency = normalized.lastIndexOf('/node_modules/')
const file = id.startsWith('\0')
? `virtual:${id.slice(1)}`
: dependency === -1 ? path.relative(root, id).replaceAll('\\', '/') : normalized.slice(dependency + 1)
return { file, renderedBytes: Buffer.byteLength(info.code || ''), renderedCodeUnits: info.renderedLength, originalCodeUnits: info.originalLength, renderedExports: info.renderedExports, removedExports: info.removedExports }
}).sort((a, b) => b.renderedBytes - a.renderedBytes || a.file.localeCompare(b.file))
const report = {
schemaVersion: 1,
name,
format,
node: process.version,
file: chunk.fileName,
artifact: { bytes: Buffer.byteLength(chunk.code), gzip9: gzipSync(chunk.code, { level: 9 }).length, sha256: createHash('sha256').update(chunk.code).digest('hex') },
note: 'Rendered UTF-8 bytes precede final chunk minification; they are not additive compressed sizes or download measurements. Rollup lengths are separately recorded as UTF-16 code units. Original code units describe Rollup input, not raw TypeScript.',
modules,
}
fs.writeFileSync(path.join(directory, `${name}-${format}.json`), `${JSON.stringify(report, null, 2)}\n`)
}
},
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { Plugin } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
export function getBanner(project: string, name: string, version: string): string {
const noticeFile = path.join(project, 'THIRD_PARTY_NOTICES')
const notices = fs.existsSync(noticeFile) ? fs.readFileSync(noticeFile, 'utf8').trim() : ''
const noticeBlock = notices ? `\n *\n${notices.split(/\r?\n/).map(line => line.trimEnd() ? ` * ${line.trimEnd()}` : ' *').join('\n')}` : ''
return `/*!
* ${name}.js v${version}
* Github: https://github.com/zhw2590582/ArtPlayer
* (c) 2017-${new Date().getFullYear()} Harvey Zhao
* Released under the MIT License.${noticeBlock}
*/`
}
export function bannerPlugin(name: string, banner: string): Plugin {
return {
name: 'add-banner-and-global',
generateBundle(outputOptions, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk')
continue
let code = chunk.code.replace(/`\/\*![\s\S]*?\*\/\n/g, '`')
if (!code.startsWith('/*!'))
code = `${banner}\n${code}`
if (outputOptions.format === 'umd') {
const wrapper = code.replace(/^\/\*![\s\S]*?\*\/\s*/, '').match(/^[!(\s]*function\s*\(\s*([\w$]+)\s*,\s*([\w$]+)\s*\)/)
if (!wrapper)
throw new Error(`Unrecognized UMD wrapper for ${name}`)
const global = wrapper[1]
const amd = /["']function["']\s*===?\s*typeof define\s*&&\s*define\.amd\s*\?\s*define\(([\w$]+)\)\s*:/
if (!amd.test(code))
throw new Error(`Unrecognized AMD branch for ${name}`)
code = code.replace(amd, `"function"==typeof define&&define.amd?(${global}.${name}=$1(),define(function(){return ${global}.${name}})):`)
}
chunk.code = code
}
},
}
}
export function workerBannerPlugin(): Plugin {
return {
name: 'remove-worker-banner',
generateBundle(_, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === 'chunk' && chunk.code.startsWith('/*!'))
chunk.code = chunk.code.replace(/^\/\*![\s\S]*?\*\/\s*/, '')
}
},
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { BuildOptions, InlineConfig, LibraryFormats, Plugin } from 'vite'
import { bannerPlugin, workerBannerPlugin } from './banner.ts'
export interface LibraryConfigOptions {
entry: string
outDir: string
name: string
format: LibraryFormats
fileName: string
minify?: boolean | 'esbuild' | 'terser'
target?: string | string[]
banner?: string
emptyOutDir?: boolean
}
type LibraryConfig = InlineConfig & {
build: BuildOptions & { rollupOptions: NonNullable<BuildOptions['rollupOptions']> & { plugins: Plugin[] } }
define: Record<string, string>
}
export function getViteBuildConfig(options: LibraryConfigOptions): LibraryConfig {
const { entry, outDir, name, format, fileName, minify = 'esbuild', target = 'es2020', banner, emptyOutDir = false } = options
return {
configFile: false,
publicDir: false,
logLevel: 'warn',
build: {
outDir,
emptyOutDir,
minify,
target,
lib: { entry, name, formats: [format], fileName: () => fileName },
rollupOptions: {
output: { exports: 'default' },
plugins: banner ? [bannerPlugin(name, banner)] : [],
},
},
worker: { format: 'es', plugins: () => [workerBannerPlugin()] },
define: { 'process.env.NODE_ENV': JSON.stringify(minify ? 'production' : 'development') },
}
}
+73
View File
@@ -0,0 +1,73 @@
import fs from 'node:fs'
import path from 'node:path'
import servor from 'servor'
import openBrowser from 'servor/utils/openBrowser.js'
import { build as viteBuild } from 'vite'
import { getViteBuildConfig } from './config.ts'
import { getGlobalName } from './names.ts'
import { getEntryFile, getProjects, selectProjects } from './projects.ts'
import { createRebuildQueue } from './rebuild.ts'
async function develop(projectPath: string, name: string, open: boolean, port: number) {
const uncompiledPath = path.resolve(`docs/uncompiled/${name}`)
let browserOpened = false
// Servor enumerates existing directories when installing its Linux watchers.
fs.mkdirSync(uncompiledPath, { recursive: true })
const { url } = await servor({
root: 'docs',
fallback: 'index.html',
reload: true,
port,
})
async function buildBundle() {
const startTime = Date.now()
try {
const config = getViteBuildConfig({
entry: getEntryFile(projectPath),
outDir: uncompiledPath,
name: getGlobalName(name),
format: 'iife',
fileName: 'index.js',
minify: false,
emptyOutDir: true,
})
config.define['process.env.NODE_ENV'] = JSON.stringify('development')
await viteBuild({ root: projectPath, ...config })
console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
if (open && !browserOpened) {
browserOpened = true
openBrowser(url)
}
}
catch (error) {
console.error(`[${name}] ❌ Build error:`, error)
}
}
const rebuild = createRebuildQueue(buildBundle)
await rebuild()
console.log(`[${name}] Demo: ${url}/?libs=./uncompiled/${name}/index.js`)
const srcPath = path.join(projectPath, 'src')
console.log(`[${name}] 👀 Watching ${srcPath}...`)
fs.watch(srcPath, { recursive: true }, async (_, filename) => {
if (filename) {
console.log(`[${name}] 📝 Changed: ${filename}`)
await rebuild()
}
})
}
export async function runDevelop({ port = 8082 }: { port?: number } = {}): Promise<void> {
const projects = getProjects()
const { names, open } = await selectProjects(projects, 'dev')
const name = names[0]
if (name)
await develop(projects[name]!, name, open, port)
}
+11
View File
@@ -0,0 +1,11 @@
export function toPascalCase(name: string): string {
return name.replace(/(^|-)([a-z])/g, (_, _prefix: string, letter: string) => letter.toUpperCase())
}
export function toCamelCase(name: string): string {
return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
}
export function getGlobalName(name: string): string {
return name === 'artplayer' || name.startsWith('artplayer-tool-') ? toPascalCase(name) : toCamelCase(name)
}
+88
View File
@@ -0,0 +1,88 @@
import fs from 'node:fs'
import path from 'node:path'
import cpy from 'cpy'
import { build as viteBuild } from 'vite'
import { buildAnalysisPlugin, createBuildAnalysis } from './analysis.ts'
import { getBanner } from './banner.ts'
import { getViteBuildConfig } from './config.ts'
import { getGlobalName } from './names.ts'
import { getEntryFile, getProjects, selectProjects } from './projects.ts'
const BUILD_FORMATS = {
main: { format: 'umd', ext: '.js', target: 'es2020', minify: 'terser' },
legacy: { format: 'umd', ext: '.legacy.js', target: 'es2015', minify: 'terser' },
esm: { format: 'es', ext: '.mjs', target: 'es2020', minify: false },
} as const
async function build(projectDir: string, compiledPath: string, name: string, targetName: keyof typeof BUILD_FORMATS, clean = false, analysis?: ReturnType<typeof createBuildAnalysis>) {
const manifest: unknown = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf-8'))
if (!manifest || typeof manifest !== 'object' || !('version' in manifest) || typeof manifest.version !== 'string')
throw new Error(`Missing package version: ${name}`)
const { version } = manifest
const distDir = path.join(projectDir, 'dist')
const entryFile = getEntryFile(projectDir)
if (clean && fs.existsSync(distDir)) {
if (fs.realpathSync(distDir) !== path.join(fs.realpathSync(projectDir), 'dist'))
throw new Error(`Refusing to clean redirected dist directory: ${distDir}`)
fs.rmSync(distDir, { recursive: true, force: true })
}
const startTime = Date.now()
const { format, ext, target, minify } = BUILD_FORMATS[targetName]
const fileName = `${name}${ext}`
const config = getViteBuildConfig({
entry: entryFile,
outDir: distDir,
name: getGlobalName(name),
format,
fileName,
minify,
target,
banner: getBanner(projectDir, name, version),
})
// Terser options for all builds (preserve banner comments starting with /*!)
const isLegacy = targetName === 'legacy'
config.build.terserOptions = {
ecma: isLegacy ? 5 : 2020,
compress: { ecma: isLegacy ? 5 : 2020, comparisons: false, inline: 2 },
mangle: { safari10: true },
format: {
ecma: isLegacy ? 5 : 2020,
comments: /^!/,
ascii_only: true,
max_line_len: false, // No line length limit
},
}
if (analysis)
config.build.rollupOptions.plugins.push(buildAnalysisPlugin(analysis, name, targetName))
await viteBuild({ root: projectDir, ...config })
const outFile = path.join(distDir, fileName)
const size = (fs.statSync(outFile).size / 1024).toFixed(2)
await cpy(outFile, compiledPath, { flat: true })
console.log(`✨ Built@${targetName} ${name}@${version} Time@${Date.now() - startTime}ms Size@${size}kb`)
}
async function buildProject(projectDir: string, compiledPath: string, name: string, analysis?: ReturnType<typeof createBuildAnalysis>) {
await build(projectDir, compiledPath, name, 'main', true, analysis)
await build(projectDir, compiledPath, name, 'legacy', false, analysis)
await build(projectDir, compiledPath, name, 'esm', false, analysis)
}
export async function runBuild(): Promise<void> {
const projects = getProjects()
const compiledPath = path.resolve('docs/compiled')
const { names, analyze } = await selectProjects(projects, 'build')
const analysis = analyze ? createBuildAnalysis() : undefined
for (const name of names)
await buildProject(projects[name]!, compiledPath, name, analysis)
if (analysis)
console.log(`Build analysis: ${analysis.directory}`)
if (names.length)
console.log(`✅ Finished building ${names.length} package(s)!`)
}
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import prompts from 'prompts'
export type Projects = Record<string, string>
export type Command = 'build' | 'dev'
export interface Selection { names: string[], open: boolean, analyze?: boolean, help?: boolean }
export function getProjects(root = process.cwd()): Projects {
const packages = path.join(root, 'packages')
return Object.fromEntries(fs.readdirSync(packages).sort()
.filter(name => name !== 'artplayer-vitepress' && fs.existsSync(path.join(packages, name, 'package.json')))
.map(name => [name, path.join(packages, name)]))
}
export function getEntryFile(projectDir: string): string {
const entries = ['index.js', 'index.ts'].map(name => path.join(projectDir, 'src', name)).filter(file => fs.existsSync(file) && fs.statSync(file).isFile())
if (entries.length !== 1)
throw new Error(`${path.basename(projectDir)} must have exactly one src/index.js or src/index.ts; found ${entries.length}`)
return entries[0]!
}
export function parseProjects(args: string[], projects: Projects, command: Command): Selection {
const names: string[] = []
let open = true
let analyze = false
for (const arg of args) {
if (arg === '--no-open' && command === 'dev')
open = false
else if (arg === '--analyze' && command === 'build')
analyze = true
else if (arg === '--help')
return { help: true, names: [], open }
else if (arg !== '--')
names.push(arg)
}
if (names.includes('all')) {
if (command !== 'build' || names.length !== 1)
throw new Error('Use build all on its own; dev accepts one package')
return { names: Object.keys(projects), open, analyze }
}
for (const name of names) {
if (!Object.hasOwn(projects, name))
throw new Error(`Unknown package: ${name}. Use yarn ${command} --help`)
}
if (command === 'dev' && names.length > 1)
throw new Error('dev accepts one package')
return { names: [...new Set(names)], open, analyze }
}
export async function selectProjects(projects: Projects, command: Command) {
const selection = parseProjects(process.argv.slice(2), projects, command)
if (selection.help) {
console.log(command === 'build' ? 'Usage: yarn build [all | package ...] [--analyze]' : 'Usage: yarn dev [package] [--no-open]')
return selection
}
if (!selection.names.length) {
if (!process.stdin.isTTY)
throw new Error(`Select a package explicitly in non-interactive mode: yarn ${command} artplayer`)
const { value } = await prompts({
type: 'select',
name: 'value',
message: `Which project do you want to ${command === 'build' ? 'build' : 'develop'}?`,
choices: Object.keys(projects).map(name => ({ title: name, value: name })),
})
selection.names = value ? [value] : []
}
for (const name of selection.names)
getEntryFile(projects[name]!)
return selection
}
+16
View File
@@ -0,0 +1,16 @@
export function createRebuildQueue(build: () => void | Promise<void>): () => Promise<void> {
let pending = false
let active: Promise<void> | undefined
return () => {
pending = true
if (!active) {
active = Promise.resolve().then(async () => {
while (pending) {
pending = false
await build()
}
}).finally(() => { active = undefined })
}
return active
}
}
+22
View File
@@ -0,0 +1,22 @@
// Only the installed APIs used by library tooling; not general SDK declarations.
declare module 'prompts' {
export default function prompts(question: {
type: 'select'
name: 'value'
message: string
choices: { title: string, value: string }[]
}): Promise<{ value?: string }>
}
declare module 'servor' {
export default function servor(options: {
root: string
fallback: string
reload: boolean
port: number
}): Promise<{ url: string }>
}
declare module 'servor/utils/openBrowser.js' {
export default function openBrowser(url: string): true | undefined
}
+4
View File
@@ -29,6 +29,10 @@ export function verifyPerformanceArtifacts(root, mapFile) {
toolchain[file] = digest(path.join(root, file))
assert.equal(digest(path.join(directory, 'build', file)), toolchain[file], `Stale build input: ${file}`)
}
const libraryInputs = location => Object.fromEntries(walk(path.join(location, 'scripts/library')).filter(file => /\.(?:[cm]?[jt]s|json)$/.test(file)).sort().map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
const currentLibrary = libraryInputs(root)
assert.deepEqual(libraryInputs(path.join(directory, 'build')), currentLibrary, 'Stale library build inputs; rerun yarn test:package')
Object.assign(toolchain, currentLibrary)
const inputs = []
for (const name of ['artplayer', 'artplayer-plugin-chapter']) {
const pkg = report.packages.find(pkg => pkg.name === name)
+1 -68
View File
@@ -1,68 +1 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import prompts from 'prompts'
export function getProjects(root = process.cwd()) {
const packages = path.join(root, 'packages')
return Object.fromEntries(fs.readdirSync(packages).sort()
.filter(name => name !== 'artplayer-vitepress' && fs.existsSync(path.join(packages, name, 'package.json')))
.map(name => [name, path.join(packages, name)]))
}
export function getEntryFile(projectDir) {
const entries = ['index.js', 'index.ts'].map(name => path.join(projectDir, 'src', name)).filter(file => fs.existsSync(file) && fs.statSync(file).isFile())
if (entries.length !== 1)
throw new Error(`${path.basename(projectDir)} must have exactly one src/index.js or src/index.ts; found ${entries.length}`)
return entries[0]
}
export function parseProjects(args, projects, command) {
const names = []
let open = true
let analyze = false
for (const arg of args) {
if (arg === '--no-open' && command === 'dev')
open = false
else if (arg === '--analyze' && command === 'build')
analyze = true
else if (arg === '--help')
return { help: true, names: [], open }
else if (arg !== '--')
names.push(arg)
}
if (names.includes('all')) {
if (command !== 'build' || names.length !== 1)
throw new Error('Use build all on its own; dev accepts one package')
return { names: Object.keys(projects), open, analyze }
}
for (const name of names) {
if (!Object.hasOwn(projects, name))
throw new Error(`Unknown package: ${name}. Use yarn ${command} --help`)
}
if (command === 'dev' && names.length > 1)
throw new Error('dev accepts one package')
return { names: [...new Set(names)], open, analyze }
}
export async function selectProjects(projects, command) {
const selection = parseProjects(process.argv.slice(2), projects, command)
if (selection.help) {
console.log(command === 'build' ? 'Usage: yarn build [all | package ...] [--analyze]' : 'Usage: yarn dev [package] [--no-open]')
return selection
}
if (!selection.names.length) {
if (!process.stdin.isTTY)
throw new Error(`Select a package explicitly in non-interactive mode: yarn ${command} artplayer`)
const { value } = await prompts({
type: 'select',
name: 'value',
message: `Which project do you want to ${command === 'build' ? 'build' : 'develop'}?`,
choices: Object.keys(projects).map(name => ({ title: name, value: name })),
})
selection.names = value ? [value] : []
}
for (const name of selection.names)
getEntryFile(projects[name])
return selection
}
export { getEntryFile, getProjects, parseProjects, selectProjects } from './library/projects.ts'
+1 -16
View File
@@ -1,16 +1 @@
export function createRebuildQueue(build) {
let pending = false
let active
return () => {
pending = true
if (!active) {
active = Promise.resolve().then(async () => {
while (pending) {
pending = false
await build()
}
}).finally(() => { active = undefined })
}
return active
}
}
export { createRebuildQueue } from './library/rebuild.ts'
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"allowImportingTsExtensions": true,
"checkJs": true
},
"include": ["library/**/*.ts", "build.js", "dev.js", "utils.js", "projects.js", "rebuild.js", "build-analysis.mjs"]
}
+3 -114
View File
@@ -1,114 +1,3 @@
export { getProjects } from './projects.js'
// Convert kebab-case to PascalCase: artplayer-plugin-ads -> ArtplayerPluginAds
export function toPascalCase(name) {
return name.replace(/(^|-)([a-z])/g, (_, _p, c) => c.toUpperCase())
}
// Convert kebab-case to camelCase: artplayer-plugin-ads -> artplayerPluginAds
export function toCamelCase(name) {
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}
// Get the global variable name for a package
// - artplayer, artplayer-tool-* -> PascalCase (class)
// - artplayer-plugin-*, artplayer-proxy-* -> camelCase (function)
export function getGlobalName(name) {
if (name === 'artplayer' || name.startsWith('artplayer-tool-')) {
return toPascalCase(name)
}
return toCamelCase(name)
}
// Get Vite build config
export function getViteBuildConfig(options) {
const {
entry,
outDir,
name,
format,
fileName,
minify = 'esbuild',
target = 'es2020',
banner,
emptyOutDir = false,
} = options
const config = {
configFile: false,
publicDir: false,
logLevel: 'warn',
build: {
outDir,
emptyOutDir,
minify,
target,
lib: {
entry,
name,
formats: [format],
fileName: () => fileName,
},
rollupOptions: {
output: {
exports: 'default',
},
plugins: banner
? [{
name: 'add-banner-and-global',
generateBundle(outputOptions, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === 'chunk') {
// Remove any inline worker banners (inside template literals)
// Match: `/*! ... */\n and replace with just `
let code = chunk.code.replace(/`\/\*![\s\S]*?\*\/\n/g, '`')
// Add main banner if not present
if (!code.startsWith('/*!')) {
code = `${banner}\n${code}`
}
// For UMD format, modify the wrapper to always expose global
// even when AMD loader is present (fix for RequireJS environments)
if (outputOptions.format === 'umd') {
const wrapper = code.replace(/^\/\*![\s\S]*?\*\/\s*/, '').match(/^[!(\s]*function\s*\(\s*([\w$]+)\s*,\s*([\w$]+)\s*\)/)
if (!wrapper)
throw new Error(`Unrecognized UMD wrapper for ${name}`)
const global = wrapper[1]
const amd = /["']function["']\s*===?\s*typeof define\s*&&\s*define\.amd\s*\?\s*define\(([\w$]+)\)\s*:/
if (!amd.test(code))
throw new Error(`Unrecognized AMD branch for ${name}`)
// Keep AMD and the global export identical regardless of minified parameter names.
code = code.replace(
amd,
`"function"==typeof define&&define.amd?(${global}.${name}=$1(),define(function(){return ${global}.${name}})):`,
)
}
chunk.code = code
}
}
},
}]
: [],
},
},
// Disable banner for inline workers
worker: {
format: 'es',
plugins: () => [{
name: 'remove-worker-banner',
generateBundle(_, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === 'chunk' && chunk.code.startsWith('/*!')) {
// Remove banner from worker code
chunk.code = chunk.code.replace(/^\/\*![\s\S]*?\*\/\s*/, '')
}
}
},
}],
},
define: {
'process.env.NODE_ENV': JSON.stringify(minify ? 'production' : 'development'),
},
}
return config
}
export { getViteBuildConfig } from './library/config.ts'
export { getGlobalName, toCamelCase, toPascalCase } from './library/names.ts'
export { getProjects } from './library/projects.ts'