refactor: Extract web-templates package and unify build/pack workflow

Moves export-html and insight templates from cli/assets to a new
dedicated web-templates package. Updates Dockerfile and build scripts
to use consolidated bundle/prepare:package/pack workflow.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-26 21:02:46 +08:00
parent 5926b37f4d
commit 9e4c5ee891
44 changed files with 1153 additions and 265 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
# Dependencies (npm ci installs fresh inside the container)
node_modules
**/node_modules
# Build artifacts (rebuilt from scratch inside the container)
dist
**/dist
# Version control
.git

1
.gitignore vendored
View file

@ -47,6 +47,7 @@ packages/*/coverage/
# Generated files
packages/cli/src/generated/
packages/core/src/generated/
packages/web-templates/src/generated/
.integration-tests/
packages/vscode-ide-companion/*.vsix

View file

@ -19,12 +19,12 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
COPY . /home/node/app
WORKDIR /home/node/app
# Install dependencies and build packages
# Use scripts/build.js which handles workspace dependencies in correct order
# Install dependencies, build workspaces, bundle into a single distributable, and pack
RUN npm ci \
&& npm run build \
&& npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \
&& npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist
&& npm run bundle \
&& npm run prepare:package \
&& cd dist && npm pack
# Runtime stage
FROM docker.io/library/node:20-slim
@ -61,9 +61,8 @@ RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy built packages from builder stage
COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/
COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/
# Copy bundled package from builder stage
COPY --from=builder /home/node/app/dist/*.tgz /tmp/
# Install built packages globally
RUN npm install -g /tmp/*.tgz \

View file

@ -254,9 +254,9 @@ export default tseslint.config(
'no-console': 'off',
},
},
// Settings for export-html assets
// Settings for web-templates assets
{
files: ['packages/cli/assets/export-html/**/*.{js,jsx,ts,tsx}'],
files: ['packages/web-templates/src/**/*.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.browser,
@ -271,6 +271,7 @@ export default tseslint.config(
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'no-console': 'off',
},
},
// Prettier config must be last

965
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@
"build:vscode": "node scripts/build_vscode_companion.js",
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"build:sandbox": "node scripts/build_sandbox.js -s",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present --parallel",
"test:ci": "npm run test:ci --workspaces --if-present --parallel && npm run test:scripts",
@ -50,7 +50,7 @@
"typecheck": "npm run typecheck --workspaces --if-present",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare": "husky && npm run build && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",
"release:version": "node scripts/version.js",
"telemetry": "node scripts/telemetry.js",

View file

@ -1,20 +0,0 @@
{
"name": "@qwen-code/cli-insight",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "node build.mjs"
},
"dependencies": {},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^5.0.0"
}
}

View file

@ -1,96 +0,0 @@
import { access, readdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const assetsDir = dirname(fileURLToPath(import.meta.url));
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const entries = await readdir(assetsDir, { withFileTypes: true });
const assetBuilds = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const assetPath = join(assetsDir, entry.name);
const buildPath = join(assetPath, 'build.mjs');
const packageJsonPath = join(assetPath, 'package.json');
let hasBuild = false;
let hasPackageJson = false;
try {
await access(buildPath);
hasBuild = true;
} catch {
// ignore missing build.mjs
}
try {
await access(packageJsonPath);
hasPackageJson = true;
} catch {
// ignore missing package.json
}
if (hasBuild || hasPackageJson) {
assetBuilds.push({
name: entry.name,
assetPath,
buildPath,
useNpm: hasPackageJson,
});
}
}
if (assetBuilds.length === 0) {
process.exit(0);
}
const runCommand = ({ command, args, cwd, label }) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} failed for ${cwd}.`));
}
});
});
const runBuild = async (asset) => {
if (asset.useNpm) {
await runCommand({
command: npmCommand,
args: ['install'],
cwd: asset.assetPath,
label: `npm install`,
});
await runCommand({
command: npmCommand,
args: ['run', 'build'],
cwd: asset.assetPath,
label: `npm run build`,
});
return;
}
await runCommand({
command: process.execPath,
args: [asset.buildPath],
cwd: asset.assetPath,
label: `Node build`,
});
};
await Promise.all(assetBuilds.map((asset) => runBuild(asset)));

View file

@ -19,8 +19,7 @@
}
},
"scripts": {
"build:assets": "node ./assets/parallel-build.mjs",
"build": "npm run build:assets && node ../../scripts/build_package.js",
"build": "node ../../scripts/build_package.js",
"start": "node dist/index.js",
"debug": "node --inspect-brk dist/index.js",
"lint": "eslint . --ext .ts,.tsx",
@ -41,6 +40,7 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
"@qwen-code/web-templates": "file:../web-templates",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { INSIGHT_JS, INSIGHT_CSS } from '../templates/insightTemplate.js';
import { INSIGHT_JS, INSIGHT_CSS } from '@qwen-code/web-templates';
import type { InsightData } from '../types/StaticInsightTypes.js';
export class TemplateRenderer {

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
*/
import type { ExportSessionData } from '../types.js';
import { HTML_TEMPLATE } from './htmlTemplate.js';
import { EXPORT_HTML_TEMPLATE as HTML_TEMPLATE } from '@qwen-code/web-templates';
/**
* Escapes JSON for safe embedding in HTML.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,54 @@
import { spawn } from 'node:child_process';
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const assetsDir = join(dirname(fileURLToPath(import.meta.url)), 'src');
await mkdir(join(assetsDir, 'generated'), { recursive: true });
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const assetBuilds = [
{
name: 'insight',
assetPath: join(assetsDir, 'insight'),
buildPath: join(assetsDir, 'insight', 'build.mjs'),
},
{
name: 'export-html',
assetPath: join(assetsDir, 'export-html'),
buildPath: join(assetsDir, 'export-html', 'build.mjs'),
},
];
const runCommand = ({ command, args, cwd, label }) =>
new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} failed for ${cwd}.`));
}
});
});
const runBuild = async (asset) => {
await runCommand({
command: process.execPath,
args: [asset.buildPath],
cwd: asset.assetPath,
label: `Node build`,
});
};
console.log('Building web-templates...');
await Promise.all(assetBuilds.map((asset) => runBuild(asset)));
console.log('Successfully built all web-templates.');

View file

@ -0,0 +1,39 @@
{
"name": "@qwen-code/web-templates",
"version": "0.10.0",
"description": "Web templates bundled as embeddable JS/CSS strings",
"repository": {
"type": "git",
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs && tsc --build --clean && tsc",
"build:templates": "node build.mjs"
},
"files": [
"dist"
],
"dependencies": {},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.3.3",
"vite": "^5.0.0"
},
"engines": {
"node": ">=20"
}
}

View file

@ -8,32 +8,15 @@ import prettier from 'prettier';
const assetsDir = dirname(fileURLToPath(import.meta.url));
const srcDir = join(assetsDir, 'src');
const assetsDistDir = join(assetsDir, 'dist');
const packageDistDir = join(
assetsDir,
'..',
'..',
'dist',
'assets',
'export-html',
);
const templateModulePath = join(
assetsDir,
'..',
'..',
'src',
'ui',
'utils',
'export',
'formatters',
'htmlTemplate.ts',
);
const packageJsonPath = join(assetsDir, 'package.json');
const generatedDir = join(assetsDir, '..', 'generated');
await mkdir(generatedDir, { recursive: true });
await mkdir(assetsDistDir, { recursive: true });
await mkdir(packageDistDir, { recursive: true });
const templateModulePath = join(generatedDir, 'exportHtmlTemplate.ts');
const packageJsonPath = join(assetsDir, 'package.json');
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'));
const dependencyVersions = packageJson?.dependencies ?? {};
const getDependencyVersion = (name) => {
const version = dependencyVersions[name];
if (!version) {
@ -101,5 +84,4 @@ const formattedTemplateModule = await prettier.format(templateModule, {
});
await writeFile(join(assetsDistDir, 'index.html'), htmlOutput);
await writeFile(join(packageDistDir, 'index.html'), htmlOutput);
await writeFile(templateModulePath, formattedTemplateModule);

View file

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 933 B

Before After
Before After

View file

@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { INSIGHT_JS, INSIGHT_CSS } from './generated/insightTemplate.js';
export { HTML_TEMPLATE as EXPORT_HTML_TEMPLATE } from './generated/exportHtmlTemplate.js';

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-undef */
import { writeFile, readFile } from 'node:fs/promises';
import { writeFile, readFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { build } from 'vite';
@ -8,16 +8,10 @@ import { build } from 'vite';
const assetsDir = dirname(fileURLToPath(import.meta.url));
const distDir = join(assetsDir, 'dist');
const templateModulePath = join(
assetsDir,
'..',
'..',
'src',
'services',
'insight',
'templates',
'insightTemplate.ts',
);
const generatedDir = join(assetsDir, '..', 'generated');
await mkdir(generatedDir, { recursive: true });
const templateModulePath = join(generatedDir, 'insightTemplate.ts');
console.log('Building insight assets with Vite...');
await build();

View file

@ -0,0 +1,9 @@
{
"name": "@qwen-code/cli-insight",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "node build.mjs"
}
}

View file

@ -1,6 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View file

@ -14,7 +14,7 @@ import {
} from './Qualitative';
import { ShareCard, type Theme } from './ShareCard';
import './styles.css';
import { InsightData } from './types';
import type { InsightData } from './types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from 'react';

View file

@ -1,4 +1,4 @@
import { InsightData } from './types';
import type { InsightData } from './types';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from 'react';
@ -198,7 +198,7 @@ function ActivityHeatmap({
const startDayOfWeek = oneYearAgo.getDay();
// Generate month labels
const monthLabels: { x: number; text: string }[] = [];
const monthLabels: Array<{ x: number; text: string }> = [];
let lastMonth = -1;
let lastX = -100; // Initialize with a value far to the left
@ -216,7 +216,7 @@ function ActivityHeatmap({
// Approximate width of a month label is about 25-30px
if (x - lastX > 30) {
monthLabels.push({
x: x,
x,
text: months[currentMonth],
});
lastX = x;

View file

@ -1,6 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from 'react';
import { InsightData } from './types';
import type { InsightData } from './types';
// Header Component
export function Header({

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState } from 'react';
import { DashboardCards, HeatmapSection } from './Charts';
import { InsightData, QualitativeData } from './types';
import type { InsightData, QualitativeData } from './types';
import { CopyButton, MarkdownText } from './Components';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from 'react';
@ -72,7 +72,7 @@ export function ProjectAreas({
}: {
qualitative: QualitativeData;
topGoals?: Record<string, number>;
topTools?: Record<string, number> | [string, number][];
topTools?: Record<string, number> | Array<[string, number]>;
}) {
const { projectAreas } = qualitative;

View file

@ -1,6 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from 'react';
import { InsightData } from './types';
import type { InsightData } from './types';
/**
* Theme configuration for the share card
@ -494,7 +494,7 @@ function ActiveHoursChart({
function buildMiniHeatmap(
heatmap: Record<string, number>,
theme: ThemeConfig,
): { color: string }[] {
): Array<{ color: string }> {
const today = new Date();
const weeksToShow = 26;
const totalDays = weeksToShow * 7;
@ -504,7 +504,7 @@ function buildMiniHeatmap(
// Align to the beginning of the week (Sunday)
startDate.setDate(startDate.getDate() - startDate.getDay());
const cells: { color: string }[] = [];
const cells: Array<{ color: string }> = [];
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + (6 - endDate.getDay())); // end of this week
@ -529,7 +529,7 @@ function heatColor(val: number, theme: ThemeConfig): string {
return theme.heatmapColors[3];
}
function MiniHeatmapGrid({ cells }: { cells: { color: string }[] }) {
function MiniHeatmapGrid({ cells }: { cells: Array<{ color: string }> }) {
const rows = 7;
const cols = Math.ceil(cells.length / rows);
const cellSize = 14;

View file

@ -2,7 +2,7 @@
* Dev entry point injects mock data then mounts the app.
* Used by `vite` dev server via index.html.
*/
import { InsightData } from './types';
import type { InsightData } from './types';
const MOCK_DATA: InsightData = {
heatmap: {

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { InsightData } from '../../../src/services/insight/types/StaticInsightTypes';
import { QualitativeInsights as QualitativeData } from '../../../src/services/insight/types/QualitativeInsightTypes';
import type { InsightData } from '../../../src/services/insight/types/StaticInsightTypes';
import type { QualitativeInsights as QualitativeData } from '../../../src/services/insight/types/QualitativeInsightTypes';
declare global {
interface Window {

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "src/insight", "src/export-html"]
}

View file

@ -36,13 +36,15 @@ execSync('npm run generate', { stdio: 'inherit', cwd: root });
// Build in dependency order:
// 1. test-utils (no internal dependencies)
// 2. core (foundation package)
// 3. cli (depends on core, test-utils)
// 4. webui (shared UI components - used by vscode companion)
// 5. sdk (no internal dependencies)
// 6. vscode-ide-companion (depends on webui)
// 3. web-templates (embeddable web templates - used by cli)
// 4. cli (depends on core, test-utils, web-templates)
// 5. webui (shared UI components - used by vscode companion)
// 6. sdk (no internal dependencies)
// 7. vscode-ide-companion (depends on webui)
const buildOrder = [
'packages/test-utils',
'packages/core',
'packages/web-templates',
'packages/cli',
'packages/webui',
'packages/sdk-typescript',

View file

@ -19,9 +19,9 @@
import { execSync } from 'node:child_process';
import {
chmodSync,
existsSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
@ -85,64 +85,26 @@ if (!image.length) {
);
}
// Build in dependency order to ensure packages are built before their dependents
// This is the same order as defined in build.js
const buildOrder = [
'packages/test-utils',
'packages/core',
'packages/cli',
'packages/webui',
'packages/sdk-typescript',
'packages/vscode-ide-companion',
];
if (!argv.s) {
execSync('npm install', { stdio: 'inherit' });
// Build in dependency order instead of using --workspaces
for (const workspace of buildOrder) {
execSync(`npm run build --workspace=${workspace}`, {
stdio: 'inherit',
});
execSync('npm run build', { stdio: 'inherit' });
console.log('bundling...');
execSync('npm run bundle', { stdio: 'inherit' });
console.log('preparing package...');
execSync('npm run prepare:package', { stdio: 'inherit' });
console.log('packing...');
const distDir = join(process.cwd(), 'dist');
for (const f of readdirSync(distDir)) {
if (f.endsWith('.tgz')) {
rmSync(join(distDir, f), { force: true });
}
}
execSync('npm pack', { stdio: 'ignore', cwd: distDir });
}
console.log('packing @qwen-code/qwen-code ...');
const cliPackageDir = join('packages', 'cli');
rmSync(join(cliPackageDir, 'dist', 'qwen-code-*.tgz'), { force: true });
execSync(
`npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist`,
{
stdio: 'ignore',
},
);
console.log('packing @qwen-code/qwen-code-core ...');
const corePackageDir = join('packages', 'core');
rmSync(join(corePackageDir, 'dist', 'qwen-code-core-*.tgz'), {
force: true,
});
execSync(
`npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist`,
{ stdio: 'ignore' },
);
const packageVersion = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
).version;
chmodSync(
join(cliPackageDir, 'dist', `qwen-code-qwen-code-${packageVersion}.tgz`),
0o755,
);
chmodSync(
join(
corePackageDir,
'dist',
`qwen-code-qwen-code-core-${packageVersion}.tgz`,
),
0o755,
);
const buildStdout = process.env.VERBOSE ? 'inherit' : 'ignore';
// Determine the appropriate shell based on OS