qwen-code/scripts/prepare-package.js
秦奇 c201ba2fcb perf(cli): code-split lowlight to cut startup V8 parse cost
Move the syntax-highlight engine out of the synchronously-parsed cli.js
entry into a separately-emitted chunk and load it via dynamic import on
the first code-block render. Until the chunk arrives, code blocks render
as plain text; the next React commit of the surrounding subtree picks up
the highlighted version, so users never see incorrect highlighting –
just an imperceptibly later transition for the very first code block.

Mechanics:
- esbuild config: switch entry to outdir + splitting:true so that
  `await import('lowlight')` produces an actual on-disk chunk that's
  only parsed by V8 when first needed.
- esbuild-shims: rename injected __dirname/__filename to qwen-prefixed
  symbols + use `define` to redirect free references. Previous inject
  collided with vendored libraries (yargs) that ship their own
  `var __dirname` ESM-compat polyfill once splitting flattens chunks.
- prepare-package: include the new chunks/ directory in the published
  package's files list.
- CodeColorizer: keep the public colorize{Code,Line} signatures and HAST
  rendering identical; on first call when the chunk hasn't loaded it
  returns the plain line and fires the dynamic import via a tiny
  standalone loader module.
- lowlightLoader (new): isolates the lazy-load surface to a module with
  zero transitive imports (no themeManager, settings, or core). This
  lets test-setup prime the cache without dragging the whole UI module
  graph into every test file, which was observed to perturb theme and
  settings test outcomes when CodeColorizer was imported directly.
- test-setup: await loadLowlight() once via the standalone loader so
  synchronous snapshot tests see the highlighted output deterministically.

Measurements (real $HOME, n=15 interleaved A/B vs main HEAD, macOS):

| Metric             | Before (mean±sd ms) | After (mean±sd ms) | Δ        | t      | p        |
| ------------------ | ------------------- | ------------------ | -------- | ------ | -------- |
| firstByte (wall)   | 1633.5 ± 88.7       | 1475.8 ± 73.3      | -157.7   | 5.31   | 1.33e-5  |
| idle (wall)        | 2048.7 ± 93.6       | 1902.3 ± 80.2      | -146.3   | 4.60   | 8.71e-5  |
| cli.js size        | 25 MB               | 6.9 MB             | -18.1 MB | —      | —        |

Both metrics clear the +50ms-or-10% Welch's t-test bar by an order of
magnitude. cli.js drops 72%; total payload (cli.js + chunks/) is
similar but only cli.js is parsed at module-eval time, which is the
phase that dominates the user-visible startup gap.

How to validate:
  npm run bundle
  ls dist/                         # cli.js + chunks/lowlight-*.js
  node dist/cli.js -y              # interactive UI still renders

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-05-12 19:48:47 +08:00

210 lines
6.1 KiB
JavaScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Prepares the bundled CLI package for npm publishing
* This script adds publishing metadata (package.json, README, LICENSE) to dist/
* All runtime assets (cli.js, vendor/, *.sb) are already in dist/ from the bundle step
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const cliBundlePath = path.join(distDir, 'cli.js');
const vendorDir = path.join(distDir, 'vendor');
// Verify dist directory and bundle exist
if (!fs.existsSync(distDir)) {
console.error('Error: dist/ directory not found');
console.error('Please run "npm run bundle" first');
process.exit(1);
}
if (!fs.existsSync(cliBundlePath)) {
console.error(`Error: Bundle not found at ${cliBundlePath}`);
console.error('Please run "npm run bundle" first');
process.exit(1);
}
if (!fs.existsSync(vendorDir)) {
console.error(`Error: Vendor directory not found at ${vendorDir}`);
console.error('Please run "npm run bundle" first');
process.exit(1);
}
const bundledDocsDir = path.join(distDir, 'bundled', 'qc-helper', 'docs');
if (!fs.existsSync(bundledDocsDir)) {
console.error(`Error: Bundled docs not found at ${bundledDocsDir}`);
console.error('Please run "npm run bundle" first');
process.exit(1);
}
// Copy README and LICENSE
console.log('Copying documentation files...');
const filesToCopy = ['README.md', 'LICENSE'];
for (const file of filesToCopy) {
const sourcePath = path.join(rootDir, file);
const destPath = path.join(distDir, file);
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied ${file}`);
} else {
console.warn(`Warning: ${file} not found at ${sourcePath}`);
}
}
// Copy locales folder
console.log('Copying locales folder...');
const localesSourceDir = path.join(
rootDir,
'packages',
'cli',
'src',
'i18n',
'locales',
);
const localesDestDir = path.join(distDir, 'locales');
if (fs.existsSync(localesSourceDir)) {
// Recursive copy function
function copyRecursiveSync(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
copyRecursiveSync(srcPath, destPath);
}
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursiveSync(localesSourceDir, localesDestDir);
console.log('Copied locales folder');
} else {
console.warn(`Warning: locales folder not found at ${localesSourceDir}`);
}
// Copy extensions folder
console.log('Copying extension examples folder...');
const extensionExamplesDir = path.join(
rootDir,
'packages',
'cli',
'src',
'commands',
'extensions',
'examples',
);
const extensionExamplesDestDir = path.join(distDir, 'examples');
if (fs.existsSync(extensionExamplesDir)) {
// Recursive copy function
function copyRecursiveSync(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
copyRecursiveSync(srcPath, destPath);
}
} else {
fs.copyFileSync(src, dest);
}
}
copyRecursiveSync(extensionExamplesDir, extensionExamplesDestDir);
console.log('Copied extension examples folder');
} else {
console.warn(
`Warning: extension examples folder not found at ${extensionExamplesDir}`,
);
}
// Copy package.json from root and modify it for publishing
console.log('Creating package.json for distribution...');
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
// Create a clean package.json for the published package
const distPackageJson = {
name: rootPackageJson.name,
version: rootPackageJson.version,
description:
rootPackageJson.description || 'Qwen Code - AI-powered coding assistant',
repository: rootPackageJson.repository,
type: 'module',
main: 'cli.js',
bin: {
qwen: 'cli.js',
},
files: [
'cli.js',
'chunks',
'vendor',
'*.sb',
'README.md',
'LICENSE',
'locales',
'bundled',
],
config: rootPackageJson.config,
dependencies: {},
optionalDependencies: {
'@lydell/node-pty': '1.2.0-beta.10',
'@lydell/node-pty-darwin-arm64': '1.2.0-beta.10',
'@lydell/node-pty-darwin-x64': '1.2.0-beta.10',
'@lydell/node-pty-linux-x64': '1.2.0-beta.10',
'@lydell/node-pty-win32-arm64': '1.2.0-beta.10',
'@lydell/node-pty-win32-x64': '1.2.0-beta.10',
'@teddyzhu/clipboard': '0.0.5',
'@teddyzhu/clipboard-darwin-arm64': '0.0.5',
'@teddyzhu/clipboard-darwin-x64': '0.0.5',
'@teddyzhu/clipboard-linux-x64-gnu': '0.0.5',
'@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5',
'@teddyzhu/clipboard-win32-x64-msvc': '0.0.5',
'@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5',
},
engines: rootPackageJson.engines,
};
fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(distPackageJson, null, 2) + '\n',
);
console.log('\n✅ Package prepared for publishing at dist/');
console.log('\nPackage structure:');
// Use Node.js to list directory contents (cross-platform)
const distFiles = fs.readdirSync(distDir);
for (const file of distFiles) {
const filePath = path.join(distDir, file);
const stats = fs.statSync(filePath);
const size = stats.isDirectory() ? '<DIR>' : formatBytes(stats.size);
console.log(` ${size.padEnd(12)} ${file}`);
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}