chore(examples): add interactive entry selection for dev builds (#7012)

This commit is contained in:
白熱
2026-06-03 19:23:13 +08:00
committed by GitHub
parent ff33ab8100
commit 0a453590f3
9 changed files with 472 additions and 317 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { BuildOptions, Plugin, SameShape } from 'esbuild';
import { execSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { ignoreGlobalCssPlugin, removeClassnameNewlinesPlugin } from '@univerjs-infra/shared/esbuild';
import aliasPlugin from 'esbuild-plugin-alias';
import copyPlugin from 'esbuild-plugin-copy';
import vue3 from 'esbuild-plugin-vue3';
import stylePlugin from 'esbuild-style-plugin';
import * as React from 'react';
import tailwindcss from 'tailwindcss';
import { syncDemoArtifacts } from '../sync-demos';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nodeModules = path.resolve(process.cwd(), './node_modules');
const isReact16 = React.version.startsWith('16');
export interface IBuildArgs {
watch: boolean;
e2e: boolean;
all: boolean;
}
export function createEnvDefinitions(args: IBuildArgs): Record<string, string> {
const define: Record<string, string> = {
'process.env.NODE_ENV': args.watch ? '"development"' : '"production"',
'process.env.IS_E2E': args.e2e ? 'true' : 'false',
};
if (!args.watch) {
const gitCommitHash = args.e2e ? 'E2E' : execSync('git rev-parse --short HEAD').toString().trim();
const gitRefName = args.e2e ? 'E2E' : execSync('git symbolic-ref -q --short HEAD || git describe --tags --exact-match').toString().trim();
define['process.env.GIT_COMMIT_HASH'] = `"${gitCommitHash}"`;
define['process.env.GIT_REF_NAME'] = `"${gitRefName}"`;
define['process.env.BUILD_TIME'] = `"${new Date().toISOString()}"`;
}
return define;
}
export async function createBuildConfig(args: IBuildArgs): Promise<SameShape<BuildOptions, BuildOptions>> {
const isAll = !!args.all;
let entryPoints: string[];
if (args.watch && !isAll) {
const { selectEntries } = await import('../select-entries');
const result = await selectEntries();
entryPoints = result.entryPoints;
} else {
const { entryPoints: eps } = syncDemoArtifacts();
entryPoints = eps;
}
const config: SameShape<BuildOptions, BuildOptions> = {
bundle: true,
format: 'esm',
splitting: true,
color: true,
loader: { '.svg': 'file', '.ttf': 'file' },
sourcemap: args.watch,
minify: false,
target: 'chrome70',
plugins: [
ignoreGlobalCssPlugin(),
removeClassnameNewlinesPlugin(),
copyPlugin({
assets: {
from: ['./public/**/*'],
to: ['./'],
},
}),
stylePlugin({
postcss: {
plugins: [tailwindcss as unknown as { postcssPlugin: string }],
},
renderOptions: {
lessOptions: {
paths: [nodeModules],
},
},
}),
vue3() as unknown as Plugin,
],
entryPoints,
outdir: './local',
define: createEnvDefinitions(args),
};
if (isReact16) {
config.plugins?.push(
aliasPlugin({
'react-dom/client': path.resolve(__dirname, '../../src/client.ts'),
})
);
}
return config;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import esbuild from 'esbuild';
import cleanPlugin from 'esbuild-plugin-clean';
export function monacoBuildTask() {
const monacoEditorEntryPoints = [
'vs/language/json/json.worker.js',
'vs/language/css/css.worker.js',
'vs/language/html/html.worker.js',
'vs/language/typescript/ts.worker.js',
'vs/editor/editor.worker.js',
];
return esbuild.build({
entryPoints: monacoEditorEntryPoints.map((entry) => `./node_modules/monaco-editor/esm/${entry}`),
bundle: true,
color: true,
target: 'chrome70',
format: 'iife',
outbase: './node_modules/monaco-editor/esm/',
outdir: './local',
plugins: [
cleanPlugin({
patterns: ['./local'],
}),
],
});
}
export function nodeBuildTask() {
return esbuild.build({
bundle: true,
color: true,
minify: false,
target: 'chrome70',
entryPoints: [
'./src/node/cases/basic.ts',
'./src/node/sdk/worker.ts',
],
platform: 'node',
outdir: './dist',
define: {
'process.env.NODE_ENV': '"production"',
},
});
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import process from 'node:process';
const supportsColor = !!process.stdout.isTTY && process.env.NO_COLOR == null;
const ansi = {
reset: '\x1B[0m',
bold: '\x1B[1m',
dim: '\x1B[2m',
red: '\x1B[31m',
green: '\x1B[32m',
yellow: '\x1B[33m',
blue: '\x1B[34m',
cyan: '\x1B[36m',
gray: '\x1B[90m',
underline: '\x1B[4m',
} as const;
type TLogLevel = 'error' | 'info' | 'ready' | 'recover' | 'watch';
const levelStyleMap: Record<TLogLevel, string[]> = {
error: [ansi.bold, ansi.red],
info: [ansi.bold, ansi.blue],
ready: [ansi.bold, ansi.green],
recover: [ansi.bold, ansi.cyan],
watch: [ansi.bold, ansi.yellow],
};
function colorize(text: string, styles: string[]) {
if (!supportsColor || styles.length === 0) {
return text;
}
return `${styles.join('')}${text}${ansi.reset}`;
}
function formatLabel(level: TLogLevel) {
return colorize(`[${level}]`, levelStyleMap[level]);
}
export function formatDuration(durationMs: number) {
if (durationMs < 1000) {
return `${durationMs}ms`;
}
return `${(durationMs / 1000).toFixed(durationMs < 10_000 ? 1 : 0)}s`;
}
export function createLogger() {
function write(level: TLogLevel, message: string, method: 'error' | 'log' = 'log') {
console[method](`${formatLabel(level)} ${message}`);
}
return {
error(message: string) {
write('error', message, 'error');
},
info(message: string) {
write('info', message);
},
ready(message: string) {
write('ready', message);
},
recover(message: string) {
write('recover', message);
},
watch(message: string) {
write('watch', message);
},
muted(message: string) {
console.log(colorize(message, [ansi.dim, ansi.gray]));
},
url(url: string) {
return colorize(url, [ansi.bold, ansi.underline, ansi.cyan]);
},
};
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DemoDefinition } from './sync-demos';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import * as p from '@clack/prompts';
import fs from 'fs-extra';
import { discoverDemos, syncDemoArtifacts } from './sync-demos';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const EXAMPLES_ROOT = path.resolve(__dirname, '..');
const SELECTION_FILE = path.resolve(EXAMPLES_ROOT, 'entry-selection.json');
export interface ISelectionFile {
selected: string[];
known: string[];
}
export interface ISelectionResult {
entryPoints: string[];
selectedDirs: string[];
}
export async function selectEntries(): Promise<ISelectionResult> {
const demos = discoverDemos();
if (!process.stdout.isTTY) {
const { entryPoints } = syncDemoArtifacts();
return {
entryPoints,
selectedDirs: demos.map((d) => d.dir),
};
}
const saved = await readSelection();
const merged = mergeSelection(demos, saved);
const selectedDirs = await promptSelection(demos, merged);
await writeSelection(selectedDirs, demos);
const { entryPoints } = syncDemoArtifacts(selectedDirs);
return { entryPoints, selectedDirs };
}
export async function readSelection(filePath = SELECTION_FILE): Promise<ISelectionFile | null> {
try {
if (!await fs.pathExists(filePath)) {
return null;
}
const content = await fs.readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
// Backward compat: old format was just an array of strings
if (Array.isArray(parsed)) {
return { selected: parsed.filter((item): item is string => typeof item === 'string'), known: [] };
}
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.selected) && Array.isArray(parsed.known)) {
return {
selected: parsed.selected.filter((item: unknown): item is string => typeof item === 'string'),
known: parsed.known.filter((item: unknown): item is string => typeof item === 'string'),
};
}
return null;
} catch {
return null;
}
}
export function mergeSelection(demos: DemoDefinition[], saved: ISelectionFile | null): string[] {
const currentDirs = new Set(demos.map((d) => d.dir));
if (!saved) {
return [...currentDirs].sort();
}
const selectedValid = saved.selected.filter((dir) => currentDirs.has(dir));
const knownSet = new Set(saved.known);
const merged = new Set(selectedValid);
for (const dir of currentDirs) {
if (!knownSet.has(dir)) {
merged.add(dir); // new demo, default select
}
}
return [...merged].sort();
}
async function promptSelection(demos: DemoDefinition[], initial: string[]): Promise<string[]> {
const options = demos.map((demo) => ({
value: demo.dir,
label: demo.title,
}));
const initialSet = new Set(initial);
const result = await p.multiselect({
message: 'Select demo entries to start (a: toggle all, space: select, enter: confirm, esc: cancel)',
options,
initialValues: options.filter((opt) => initialSet.has(opt.value)).map((opt) => opt.value),
});
if (p.isCancel(result)) {
p.cancel('Operation cancelled');
process.exit(0);
}
return result;
}
async function writeSelection(selectedDirs: string[], demos: DemoDefinition[]): Promise<void> {
const file: ISelectionFile = {
selected: selectedDirs,
known: demos.map((d) => d.dir),
};
await fs.writeFile(SELECTION_FILE, `${JSON.stringify(file, null, 4)}\n`);
}
+9 -4
View File
@@ -141,6 +141,10 @@ export function discoverDemos() {
.filter((demo): demo is DemoDefinition => demo !== null);
}
function filterDemos(demos: DemoDefinition[], selectedDirs?: string[]): DemoDefinition[] {
return selectedDirs ? demos.filter((demo) => selectedDirs.includes(demo.dir)) : demos;
}
function syncDemoHtml(demos: DemoDefinition[]) {
fs.ensureDirSync(PUBLIC_DIR);
@@ -189,12 +193,13 @@ function syncDemosModule(demos: DemoDefinition[]) {
fs.writeFileSync(GENERATED_DEMOS_FILE, content);
}
export function getDemoEntryPoints() {
return ['./src/main.tsx', ...discoverDemos().flatMap((demo) => demo.entryPoints)];
export function getDemoEntryPoints(selectedDirs?: string[]) {
const demos = filterDemos(discoverDemos(), selectedDirs);
return ['./src/main.tsx', ...demos.flatMap((demo) => demo.entryPoints)];
}
export function syncDemoArtifacts() {
const demos = discoverDemos();
export function syncDemoArtifacts(selectedDirs?: string[]) {
const demos = filterDemos(discoverDemos(), selectedDirs);
syncDemoHtml(demos);
syncDemosModule(demos);