mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 05:26:15 -08:00
refactor(build): [SITE-BUILD-01] stage outputs and stabilize documentation builds
This commit is contained in:
1 parent
078cd9ef72
commit
a468cb2c39
72 files changed
+2396
-748
No files matched your search
+3
-11
@@ -1,15 +1,7 @@
|
||||
import process from 'node:process'
|
||||
import spawn from 'cross-spawn'
|
||||
import { buildDocumentation, BuildExitError } from './site-build/docs.ts'
|
||||
|
||||
const proc = spawn('npm', ['run', 'build'], {
|
||||
cwd: './packages/artplayer-vitepress/',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
proc.on('error', (error) => {
|
||||
await buildDocumentation(process.cwd()).catch((error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
proc.on('close', (code) => {
|
||||
process.exitCode = code ?? 1
|
||||
process.exitCode = error instanceof BuildExitError ? error.code : 1
|
||||
})
|
||||
+3
-47
@@ -1,51 +1,7 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import cpy from 'cpy'
|
||||
import { glob } from 'glob'
|
||||
import { build as viteBuild } from 'vite'
|
||||
import { getViteBuildConfig, toPascalCase } from './utils.js'
|
||||
import { buildLanguages } from './site-build/i18n.ts'
|
||||
|
||||
const basePath = 'packages/artplayer'
|
||||
const i18nSrcDir = path.join(basePath, 'src/i18n')
|
||||
const distDir = path.join(basePath, 'dist/i18n')
|
||||
const compiledPath = path.resolve('docs/compiled/i18n')
|
||||
|
||||
const entries = glob.sync('*.{js,ts}', {
|
||||
cwd: i18nSrcDir,
|
||||
ignore: ['index.{js,ts}', 'publish.{js,ts}', 'zh-cn.{js,ts}', '*.d.ts'],
|
||||
}).map(f => path.join(i18nSrcDir, f))
|
||||
|
||||
async function buildI18n() {
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const baseName = path.basename(entry, path.extname(entry))
|
||||
const globalName = `artplayerI18n${toPascalCase(baseName)}`
|
||||
|
||||
// Build UMD and ESM formats
|
||||
for (const [format, ext] of [['umd', '.js'], ['es', '.mjs']]) {
|
||||
const config = getViteBuildConfig({
|
||||
entry,
|
||||
outDir: distDir,
|
||||
name: globalName,
|
||||
format,
|
||||
fileName: `${baseName}${ext}`,
|
||||
})
|
||||
await viteBuild(config)
|
||||
}
|
||||
|
||||
console.log(`✅ Built i18n: ${baseName}`)
|
||||
}
|
||||
|
||||
await cpy(path.join(distDir, '*'), compiledPath, { flat: true })
|
||||
console.log('✨ Finished building i18n')
|
||||
}
|
||||
|
||||
buildI18n().catch((err) => {
|
||||
console.error('❌ Build i18n failed:', err)
|
||||
await buildLanguages(process.cwd()).catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
# i18n and documentation builds
|
||||
|
||||
Run commands from the repository root with `.node-version` and Yarn Classic
|
||||
1.22.22. `scripts/build-i18n.js` and `scripts/build-docs.js` are checked JS CLI
|
||||
adapters; the actual orchestration lives in these strict TypeScript modules.
|
||||
|
||||
```sh
|
||||
yarn build:i18n
|
||||
yarn build:docs
|
||||
yarn test:site-build
|
||||
yarn typecheck:docs-tools
|
||||
yarn test:browser document-site.spec.js
|
||||
```
|
||||
|
||||
`build:docs` generates site-owned browser assets first, then uses the exact Yarn
|
||||
executable from the current Yarn invocation to run the existing workspace
|
||||
`build` script with an explicit staged `--outDir`. Running `node build-docs.js`
|
||||
without a Yarn environment now reports how to invoke the supported command;
|
||||
it never falls back to npm or another package manager. The child version is
|
||||
verified before creating staging output. No translation or model service runs.
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `i18n.ts` | Sorted language entries, typed Vite configuration, exact output set, identical distribution/demo copies |
|
||||
| `docs.ts` | Pinned Yarn child, child error/exit propagation, required site index |
|
||||
| `artifacts.ts` | Fixed output directories, exclusive build ownership, snapshots, staging, replacement and caught-failure recovery |
|
||||
| `packages/artplayer-vitepress/build/markdown.ts` | Wrap the installed VitePress code-group renderer with stable page/group/tab IDs |
|
||||
|
||||
The stateless path validation and hashing helpers in `../documentation/files.ts`
|
||||
are shared by documentation source tools and these build tools. No player runtime
|
||||
depends on them. Vite configuration here preserves the language build's es2020,
|
||||
esbuild minification, default export, UMD/ESM names and existing browser aliases.
|
||||
It intentionally does not pull unrelated library worker/banner transforms into
|
||||
the language-only builder. Language sources are unchanged; index, publish,
|
||||
zh-cn and declaration files remain excluded from standalone builds. JS/TS entry
|
||||
duplicates fail before any output replacement. The current 11 languages produce
|
||||
22 files in each of `packages/artplayer/dist/i18n` and `docs/compiled/i18n`.
|
||||
|
||||
The docs workspace explicitly declares `type: module`, matching its existing
|
||||
ESM config/theme files and new build module. VitePress 1.6.3 generates random
|
||||
code-group input IDs by default, changing otherwise identical builds. The local
|
||||
Markdown hook preserves its original HTML and active-tab handling, replacing
|
||||
only generated group names and input/label IDs with page-relative hashes and
|
||||
ordinals. IDs remain unique between groups; labels keep their associations.
|
||||
Never patch installed VitePress or rewrite emitted chunks after hashing. When
|
||||
upgrading VitePress, verify this renderer hook and actual tab switching again.
|
||||
|
||||
## Replacement and recovery
|
||||
|
||||
Builds acquire an exclusive per-kind lock in `refactor/.cache/site-build/`, then
|
||||
create unique staged output directories. Existing output fingerprints are captured
|
||||
before compilation. All compilation and validation finish before any destination
|
||||
is renamed. Existing files edited during compilation cause a rejection. Complete
|
||||
staged trees replace the owned targets; stale generated files disappear from both
|
||||
i18n destinations, and other package output directories are untouched.
|
||||
|
||||
Replacement moves old directories to backups, then moves new directories into
|
||||
place. A caught rename failure restores previous directories in reverse order.
|
||||
If someone changes an installed output during replacement, recovery refuses to
|
||||
overwrite that edit and retains the original backup. The error gives the retained
|
||||
run path. Staging cleanup only removes a checked run directory under the cache.
|
||||
The lock is released on settled success/failure. Symbolic links in artifact trees
|
||||
are rejected, including roots that resolve outside the workspace.
|
||||
|
||||
This is **not an atomic multi-directory transaction for concurrent readers**:
|
||||
there is a short rename gap, and the two i18n trees are installed sequentially.
|
||||
Power loss or forced process termination can retain a lock and partial replacement.
|
||||
Do not infer that a lock file means a process is alive, or automatically delete an
|
||||
old lock. Verify process state, inspect output/backup trees and restore the intended
|
||||
version before explicitly removing a stale lock. Cleanup errors may occur after
|
||||
successful installation; inspect the actual outputs rather than assuming failure
|
||||
means nothing changed. The tools do not promise hostile filesystem race isolation.
|
||||
|
||||
## Verification and remaining ownership
|
||||
|
||||
`test:site-build` uses real filesystem failures, fixed old-source reproduction,
|
||||
the actual Vite compiler and all 11 dictionaries in UMD/global, CJS, AMD and ESM
|
||||
forms, including historical `window['artplayer-i18n-*']` aliases. Syntax failure
|
||||
must preserve both old outputs. A real Yarn fixture child verifies exit code 23,
|
||||
success, staging and package-manager selection. Intentional compiler failure in
|
||||
that test prints a red build message; the enclosing rejection assertion must pass.
|
||||
|
||||
The browser smoke loads generated Chinese/English deep pages and actual built
|
||||
assets in three browser engines, then clicks Run Code. Its separate editor
|
||||
destination is intercepted only to observe the URL; it does not revalidate all
|
||||
editor actions or claim complete search/links/example coverage. It also checks
|
||||
native radio selection and visible code blocks after clicking the built code-group
|
||||
labels. SITE-03 still
|
||||
owns desktop editor UI migration, SITE-04 semantic/bilingual documentation and
|
||||
SITE-05 full site/search/link acceptance. No npm/Pages publication or remote CI
|
||||
run follows from a successful local build. No dependencies were added.
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { ownedPath, sha256 } from '../documentation/files.ts'
|
||||
|
||||
const targets = {
|
||||
i18n: ['packages/artplayer/dist/i18n', 'docs/compiled/i18n'],
|
||||
docs: ['docs/document'],
|
||||
} as const
|
||||
|
||||
export function treeFingerprint(directory: string): string | null {
|
||||
if (!fs.existsSync(directory))
|
||||
return null
|
||||
const entries: string[] = []
|
||||
function visit(current: string, relative: string): void {
|
||||
assert(
|
||||
fs.lstatSync(current).isDirectory(),
|
||||
'Artifact root must be a directory',
|
||||
)
|
||||
for (const item of fs
|
||||
.readdirSync(current, { withFileTypes: true })
|
||||
.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const name = `${relative}/${item.name}`
|
||||
assert(
|
||||
!item.isSymbolicLink(),
|
||||
'Artifact tree must not contain symbolic links',
|
||||
)
|
||||
if (item.isDirectory()) {
|
||||
entries.push(`directory:${name}`)
|
||||
visit(path.join(current, item.name), name)
|
||||
}
|
||||
else {
|
||||
assert(item.isFile(), 'Unexpected artifact entry')
|
||||
entries.push(
|
||||
`${name}:${sha256(fs.readFileSync(path.join(current, item.name)).toString('base64'))}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(directory, '')
|
||||
return sha256(entries.join('\n'))
|
||||
}
|
||||
|
||||
export async function stageArtifacts(
|
||||
root: string,
|
||||
kind: keyof typeof targets,
|
||||
produce: (stages: string[]) => Promise<void>,
|
||||
rename = fs.renameSync,
|
||||
): Promise<void> {
|
||||
const cache = ownedPath(root, 'refactor/.cache/site-build')
|
||||
fs.mkdirSync(cache, { recursive: true })
|
||||
const lock = path.join(cache, `${kind}.lock`)
|
||||
const handle = fs.openSync(lock, 'wx')
|
||||
let run: string | undefined
|
||||
let retain = false
|
||||
try {
|
||||
run = fs.mkdtempSync(path.join(cache, `${kind}-`))
|
||||
const rows = targets[kind].map((relative, index) => {
|
||||
const destination = ownedPath(root, relative)
|
||||
const stage = path.join(run!, `output-${index}`)
|
||||
fs.mkdirSync(stage)
|
||||
return {
|
||||
relative,
|
||||
destination,
|
||||
stage,
|
||||
backup: path.join(run!, `backup-${index}`),
|
||||
before: treeFingerprint(destination),
|
||||
saved: false,
|
||||
installed: false,
|
||||
after: '',
|
||||
}
|
||||
})
|
||||
await produce(rows.map(row => row.stage))
|
||||
for (const row of rows) {
|
||||
assert(fs.readdirSync(row.stage).length, 'Build produced no artifacts')
|
||||
row.after = treeFingerprint(row.stage)!
|
||||
assert.equal(
|
||||
treeFingerprint(ownedPath(root, row.relative)),
|
||||
row.before,
|
||||
'Artifact output changed while building',
|
||||
)
|
||||
}
|
||||
try {
|
||||
for (const row of rows) {
|
||||
assert.equal(ownedPath(root, row.relative), row.destination)
|
||||
assert.equal(
|
||||
treeFingerprint(row.destination),
|
||||
row.before,
|
||||
'Artifact output changed during replacement',
|
||||
)
|
||||
fs.mkdirSync(path.dirname(row.destination), { recursive: true })
|
||||
if (row.before !== null) {
|
||||
rename(row.destination, row.backup)
|
||||
row.saved = true
|
||||
}
|
||||
rename(row.stage, row.destination)
|
||||
row.installed = true
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const errors = [error]
|
||||
for (const row of [...rows].reverse()) {
|
||||
try {
|
||||
if (row.installed) {
|
||||
assert.equal(
|
||||
treeFingerprint(row.destination),
|
||||
row.after,
|
||||
'Concurrent output edit prevents rollback',
|
||||
)
|
||||
fs.renameSync(row.destination, row.stage)
|
||||
}
|
||||
if (row.saved) {
|
||||
assert(
|
||||
!fs.existsSync(row.destination),
|
||||
'Rollback target is occupied',
|
||||
)
|
||||
fs.renameSync(row.backup, row.destination)
|
||||
}
|
||||
}
|
||||
catch (failure) {
|
||||
retain = true
|
||||
errors.push(failure)
|
||||
}
|
||||
}
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
`Artifact replacement failed. ${retain ? `Inspect retained backups: ${run}` : 'Previous outputs restored.'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
if (run && !retain) {
|
||||
assert.equal(path.dirname(run), cache)
|
||||
assert.equal(fs.realpathSync(run), run)
|
||||
fs.rmSync(run, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
finally {
|
||||
fs.closeSync(handle)
|
||||
fs.unlinkSync(lock)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { ownedPath } from '../documentation/files.ts'
|
||||
import { stageArtifacts } from './artifacts.ts'
|
||||
|
||||
export class BuildExitError extends Error {
|
||||
declare readonly code: number
|
||||
constructor(code: number) {
|
||||
super(`Documentation build exited with code ${code}`)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function yarnExecutable(): string {
|
||||
const executable = process.env.npm_execpath
|
||||
assert(
|
||||
executable && process.env.npm_config_user_agent?.split(' ')[0] === 'yarn/1.22.22',
|
||||
'Run yarn build:docs with Yarn Classic 1.22.22',
|
||||
)
|
||||
const result = spawnSync(process.execPath, [executable, '--version'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
assert(
|
||||
result.status === 0 && result.stdout.trim() === '1.22.22',
|
||||
'Expected Yarn Classic 1.22.22',
|
||||
)
|
||||
return executable
|
||||
}
|
||||
|
||||
export async function buildDocumentation(root: string): Promise<void> {
|
||||
const yarn = yarnExecutable()
|
||||
await stageArtifacts(root, 'docs', async ([stage]) => {
|
||||
assert(stage)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[yarn, 'run', 'build', '--outDir', stage],
|
||||
{
|
||||
cwd: ownedPath(root, 'packages/artplayer-vitepress'),
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
},
|
||||
)
|
||||
child.once('error', reject)
|
||||
child.once('close', code =>
|
||||
code === 0 ? resolve() : reject(new BuildExitError(code ?? 1)))
|
||||
})
|
||||
assert(
|
||||
fs.existsSync(path.join(stage, 'index.html')),
|
||||
'Documentation build produced no index.html',
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { InlineConfig } from 'vite'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { globSync } from 'glob'
|
||||
import { build } from 'vite'
|
||||
import { ownedPath } from '../documentation/files.ts'
|
||||
import { stageArtifacts } from './artifacts.ts'
|
||||
|
||||
export function languageEntries(root: string): string[] {
|
||||
const directory = ownedPath(root, 'packages/artplayer/src/i18n')
|
||||
const entries = globSync('*.{js,ts}', {
|
||||
cwd: directory,
|
||||
ignore: ['index.{js,ts}', 'publish.{js,ts}', 'zh-cn.{js,ts}', '*.d.ts'],
|
||||
}).sort()
|
||||
assert(entries.length, 'No standalone language entries')
|
||||
const names = entries.map(entry =>
|
||||
path.basename(entry, path.extname(entry)),
|
||||
)
|
||||
assert.equal(
|
||||
new Set(names).size,
|
||||
names.length,
|
||||
'Duplicate JS/TS language entries',
|
||||
)
|
||||
return entries.map(entry => path.join(directory, entry))
|
||||
}
|
||||
|
||||
export function languageConfig(
|
||||
entry: string,
|
||||
outDir: string,
|
||||
format: 'umd' | 'es',
|
||||
): InlineConfig {
|
||||
const name = path.basename(entry, path.extname(entry))
|
||||
return {
|
||||
configFile: false,
|
||||
publicDir: false,
|
||||
logLevel: 'warn',
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: false,
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
lib: {
|
||||
entry,
|
||||
name: `artplayerI18n${name.replace(/(^|-)([a-z])/g, (_match, _prefix: string, char: string) => char.toUpperCase())}`,
|
||||
formats: [format],
|
||||
fileName: () => `${name}${format === 'umd' ? '.js' : '.mjs'}`,
|
||||
},
|
||||
rollupOptions: { output: { exports: 'default' } },
|
||||
},
|
||||
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildLanguages(
|
||||
root: string,
|
||||
compile: (config: InlineConfig) => Promise<unknown> = build,
|
||||
): Promise<void> {
|
||||
const entries = languageEntries(root)
|
||||
await stageArtifacts(root, 'i18n', async ([dist, compiled]) => {
|
||||
assert(dist && compiled)
|
||||
for (const entry of entries) {
|
||||
for (const format of ['umd', 'es'] as const)
|
||||
await compile(languageConfig(entry, dist, format))
|
||||
}
|
||||
const expected = entries
|
||||
.flatMap(entry =>
|
||||
['.js', '.mjs'].map(
|
||||
ext => path.basename(entry, path.extname(entry)) + ext,
|
||||
),
|
||||
)
|
||||
.sort()
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(dist).sort(),
|
||||
expected,
|
||||
'Unexpected language artifact set',
|
||||
)
|
||||
for (const name of expected)
|
||||
fs.copyFileSync(path.join(dist, name), path.join(compiled, name))
|
||||
})
|
||||
console.log(
|
||||
`Built ${entries.length} standalone languages in UMD/ESM; both output directories replaced`,
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,10 @@
|
||||
"documentation/**/*.ts",
|
||||
"editor-declarations/**/*.ts",
|
||||
"editor-types.mjs",
|
||||
"plugin-editor-types.mjs"
|
||||
"plugin-editor-types.mjs",
|
||||
"site-build/**/*.ts",
|
||||
"build-i18n.js",
|
||||
"build-docs.js",
|
||||
"../packages/artplayer-vitepress/build/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user