diff --git a/packages/web-templates/src/export-html/build.mjs b/packages/web-templates/src/export-html/build.mjs index 2f5c70bf29b..f2f01c1e9b1 100644 --- a/packages/web-templates/src/export-html/build.mjs +++ b/packages/web-templates/src/export-html/build.mjs @@ -1,40 +1,89 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { build } from 'esbuild'; +import { build as viteBuild } from 'vite'; import { buildConfig } from './esbuild.config.mjs'; import prettier from 'prettier'; +const require = createRequire(import.meta.url); const assetsDir = dirname(fileURLToPath(import.meta.url)); const srcDir = join(assetsDir, 'src'); const assetsDistDir = join(assetsDir, 'dist'); const generatedDir = join(assetsDir, '..', 'generated'); +const webuiDir = join(assetsDir, '..', '..', '..', 'webui'); await mkdir(generatedDir, { recursive: true }); await mkdir(assetsDistDir, { 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) { - throw new Error(`Missing ${name} dependency version in package.json.`); - } - // Handle various version formats: - // - "^0.1.0" -> "0.1.0" - // - "~1.2.3" -> "1.2.3" - // - "latest" -> "latest" - // - "^0.1.0@latest" -> "0.1.0" (remove npm tag suffix) - const versionWithoutPrefix = version.replace(/^[^0-9a-zA-Z]+/, ''); - // Remove npm tag suffix (e.g., "0.1.0@latest" -> "0.1.0") - return versionWithoutPrefix.replace(/@.+$/, ''); -}; -const webuiVersion = getDependencyVersion('@qwen-code/webui'); const reactUmdVersion = '18.2.0'; const reactDomUmdVersion = '18.2.0'; +// Build the @qwen-code/webui viewer as a UMD bundle from webui sources and +// inline it into the template below. The exported HTML must stay +// self-contained: @qwen-code/webui is not published as part of the release +// flow, and resolving it from a CDN at view time would retroactively change +// how every already-generated export renders. Building it here (instead of +// reading packages/webui/dist) keeps the template build independent of +// webui's own build, which runs after web-templates in scripts/build.js and +// is skipped entirely by --cli-only builds. +const webuiDistDir = join(assetsDistDir, 'webui'); +await viteBuild({ + configFile: false, + root: webuiDir, + logLevel: 'warn', + css: { + postcss: { + plugins: [ + require('tailwindcss')({ + presets: [require(join(webuiDir, 'tailwind.preset.cjs'))], + content: [join(webuiDir, 'src', '**', '*.{ts,tsx}')], + }), + require('autoprefixer'), + ], + }, + }, + build: { + outDir: webuiDistDir, + emptyOutDir: true, + minify: true, + sourcemap: false, + cssCodeSplit: false, + lib: { + entry: join(webuiDir, 'src', 'index.ts'), + name: 'QwenCodeWebUI', + formats: ['umd'], + fileName: () => 'index.umd.js', + }, + rollupOptions: { + external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react-dom/client': 'ReactDOM', + 'react/jsx-runtime': 'ReactJSXRuntime', + }, + assetFileNames: 'styles.[ext]', + }, + }, + }, +}); +const webuiJs = (await readFile(join(webuiDistDir, 'index.umd.js'), 'utf8')) + // Escape for inlining into a @@ -54,14 +103,30 @@ const htmlTemplate = await readFile(join(srcDir, 'index.html'), 'utf8'); const faviconSvg = await readFile(join(srcDir, 'favicon.svg'), 'utf8'); const faviconData = encodeURIComponent(faviconSvg.trim()); +// Function-form replacers: the bundles are untrusted replacement content, +// and a string replacement would interpret `$&`/`$'`/`` $` `` sequences in +// them as substitution patterns, corrupting the inlined code. const htmlOutput = htmlTemplate - .replace('__INLINE_CSS__', css.trim()) - .replace('__INLINE_SCRIPT__', jsBundle.text.trim()) + .replace('__INLINE_CSS__', () => css.trim()) + .replace('__INLINE_SCRIPT__', () => jsBundle.text.trim()) + .replace('__WEBUI_UMD_JS__', () => webuiJs.trim()) + .replace('__WEBUI_CSS__', () => webuiCss.trim()) .replaceAll('__REACT_UMD_VERSION__', reactUmdVersion) .replaceAll('__REACT_DOM_UMD_VERSION__', reactDomUmdVersion) - .replaceAll('__WEBUI_VERSION__', webuiVersion) - .replace('__FAVICON_SVG__', faviconSvg.trim()) - .replace('__FAVICON_DATA__', faviconData); + .replace('__FAVICON_SVG__', () => faviconSvg.trim()) + .replace('__FAVICON_DATA__', () => faviconData); + +// A dropped or renamed .replace() above would otherwise still exit 0 and +// ship a template that throws at view time. +const residualPlaceholder = + /__(INLINE_CSS|INLINE_SCRIPT|WEBUI_UMD_JS|WEBUI_CSS|FAVICON_SVG|FAVICON_DATA)__/.exec( + htmlOutput, + ); +if (residualPlaceholder) { + throw new Error( + `Unreplaced placeholder ${residualPlaceholder[0]} in export HTML template.`, + ); +} const templateModule = `/** * @license diff --git a/packages/web-templates/src/export-html/package.json b/packages/web-templates/src/export-html/package.json index 934da462d1f..f08eaf57264 100644 --- a/packages/web-templates/src/export-html/package.json +++ b/packages/web-templates/src/export-html/package.json @@ -5,9 +5,6 @@ "scripts": { "build": "node build.mjs" }, - "dependencies": { - "@qwen-code/webui": "latest" - }, "devDependencies": { "esbuild": "^0.25.0" } diff --git a/packages/web-templates/src/export-html/src/index.html b/packages/web-templates/src/export-html/src/index.html index 393f96f36dc..910c27600a8 100644 --- a/packages/web-templates/src/export-html/src/index.html +++ b/packages/web-templates/src/export-html/src/index.html @@ -31,11 +31,12 @@ window['react/jsx-runtime'] = jsxRuntime; window['react/jsx-dev-runtime'] = jsxRuntime; - - + +