mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(tooling): [MOD-02] split library build and development into typed modules
This commit is contained in:
1 parent
28c7abb2bb
commit
401d4cdd65
36 files changed
+1644
-427
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
})
|
||||
@@ -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`.
|
||||
@@ -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`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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*/, '')
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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') },
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)!`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+22
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
@@ -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
@@ -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'
|
||||
Reference in new issue
Block a user