mirror of
https://github.com/aphrody-labs/base-ui.git
synced 2026-10-14 05:09:26 +00:00
feat(build): accelerate build with bun.build and exploit native web apis
This commit is contained in:
1 parent
17b332ff30
commit
7d2bf83a74
11 files changed
+609
-13
No files matched your search
+2
-1
@@ -30,7 +30,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"deduplicate": "bun install",
|
||||
"build": "lerna run --no-private build",
|
||||
"build": "bun ./scripts/aphrody/fast-build.ts",
|
||||
"build:legacy": "lerna run --no-private build",
|
||||
"release:version": "lerna version --no-changelog --no-push --no-git-tag-version --no-private",
|
||||
"release:build": "bun run docs:generate-llms && cross-env BASE_UI_PUBLISH_DOCS=1 lerna run --concurrency 8 --no-private build --skip-nx-cache --",
|
||||
"release:changelog": "code-infra generate-changelog",
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rimraf --glob build build-tests build-typecheck published-docs \"*.tsbuildinfo\" && bun ./scripts/stagePublishedDocs.mjs",
|
||||
"build": "code-infra build --ignore \"**/*.template.js\" --copy .npmignore --copy \"published-docs/**:docs\" --tsgo",
|
||||
"build": "bun ../../scripts/aphrody/fast-build.ts",
|
||||
"build:legacy": "code-infra build --ignore \"**/*.template.js\" --copy .npmignore --copy \"published-docs/**:docs\" --tsgo",
|
||||
"test:package": "publint --pack bun && attw --pack ./build --exclude-entrypoints package.json",
|
||||
"release": "bun run build && bun publish",
|
||||
"test": "bun test",
|
||||
|
||||
@@ -1173,4 +1173,41 @@ describe('<Form />', () => {
|
||||
expect(replacementValidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native FormData and ReadableStream handling', () => {
|
||||
it('provides native FormData and ReadableStream in onFormSubmit eventDetails', async () => {
|
||||
let receivedDetails: Form.SubmitEventDetails | null = null;
|
||||
const { user } = await render(
|
||||
<Form
|
||||
onFormSubmit={(_values, details) => {
|
||||
receivedDetails = details;
|
||||
}}
|
||||
>
|
||||
<Field.Root name="username">
|
||||
<Field.Control defaultValue="alice" />
|
||||
</Field.Root>
|
||||
<button type="submit">Submit</button>
|
||||
</Form>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(receivedDetails).not.toBeNull();
|
||||
const formData = (receivedDetails as any).formData;
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get('username')).toBe('alice');
|
||||
|
||||
const stream = (receivedDetails as any).stream;
|
||||
expect(stream).toBeInstanceOf(ReadableStream);
|
||||
|
||||
const reader = stream.getReader();
|
||||
const entries: [string, any][] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
entries.push(value);
|
||||
}
|
||||
expect(entries).toEqual([['username', 'alice']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,30 @@ export const Form = React.forwardRef(function Form<
|
||||
});
|
||||
}
|
||||
},
|
||||
getFormData() {
|
||||
if (typeof FormData !== 'undefined' && elementRef.current) {
|
||||
return new FormData(elementRef.current);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getStream() {
|
||||
if (
|
||||
typeof ReadableStream !== 'undefined' &&
|
||||
typeof FormData !== 'undefined' &&
|
||||
elementRef.current
|
||||
) {
|
||||
const fd = new FormData(elementRef.current);
|
||||
return new ReadableStream<[string, FormDataEntryValue]>({
|
||||
start(controller) {
|
||||
for (const entry of fd.entries()) {
|
||||
controller.enqueue(entry);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -132,7 +156,44 @@ export const Form = React.forwardRef(function Form<
|
||||
}
|
||||
});
|
||||
|
||||
onFormSubmit(formValues, createGenericEventDetails(REASONS.none, event.nativeEvent));
|
||||
const formElement = elementRef.current ?? (event.currentTarget as HTMLFormElement);
|
||||
const customProperties: FormSubmitCustomProperties = {
|
||||
get formData(): FormData | null {
|
||||
if (typeof FormData !== 'undefined' && formElement) {
|
||||
return new FormData(formElement);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
get stream(): ReadableStream<[string, FormDataEntryValue]> | null {
|
||||
if (
|
||||
typeof ReadableStream !== 'undefined' &&
|
||||
typeof FormData !== 'undefined' &&
|
||||
formElement
|
||||
) {
|
||||
const fd = new FormData(formElement);
|
||||
return new ReadableStream<[string, FormDataEntryValue]>({
|
||||
start(controller) {
|
||||
for (const entry of fd.entries()) {
|
||||
controller.enqueue(entry);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getFormData() {
|
||||
return this.formData;
|
||||
},
|
||||
getStream() {
|
||||
return this.stream;
|
||||
},
|
||||
};
|
||||
|
||||
onFormSubmit(
|
||||
formValues,
|
||||
createGenericEventDetails(REASONS.none, event.nativeEvent, customProperties),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -175,13 +236,43 @@ export const Form = React.forwardRef(function Form<
|
||||
): React.JSX.Element;
|
||||
};
|
||||
|
||||
export interface FormSubmitCustomProperties {
|
||||
/**
|
||||
* Native `FormData` constructed from the form element without polyfill overhead.
|
||||
*/
|
||||
readonly formData: FormData | null;
|
||||
/**
|
||||
* Native `ReadableStream` streaming the form's entries without polyfill overhead.
|
||||
*/
|
||||
readonly stream: ReadableStream<[string, FormDataEntryValue]> | null;
|
||||
/**
|
||||
* Returns native `FormData` for the form element.
|
||||
*/
|
||||
getFormData: () => FormData | null;
|
||||
/**
|
||||
* Returns a native `ReadableStream` streaming the form's entries.
|
||||
*/
|
||||
getStream: () => ReadableStream<[string, FormDataEntryValue]> | null;
|
||||
}
|
||||
|
||||
export type FormSubmitEventReason = typeof REASONS.none;
|
||||
export type FormSubmitEventDetails = BaseUIGenericEventDetails<Form.SubmitEventReason>;
|
||||
export type FormSubmitEventDetails = BaseUIGenericEventDetails<
|
||||
Form.SubmitEventReason,
|
||||
FormSubmitCustomProperties
|
||||
>;
|
||||
|
||||
export type FormValidationMode = 'onSubmit' | 'onBlur' | 'onChange';
|
||||
|
||||
export interface FormActions {
|
||||
validate: (fieldName?: string | undefined) => void;
|
||||
/**
|
||||
* Returns native `FormData` for the form element.
|
||||
*/
|
||||
getFormData?: () => FormData | null;
|
||||
/**
|
||||
* Returns a native `ReadableStream` streaming the form's entries.
|
||||
*/
|
||||
getStream?: () => ReadableStream<[string, FormDataEntryValue]> | null;
|
||||
}
|
||||
|
||||
export interface FormState {}
|
||||
@@ -235,6 +326,7 @@ export namespace Form {
|
||||
export type ValidationMode = FormValidationMode;
|
||||
export type SubmitEventReason = FormSubmitEventReason;
|
||||
export type SubmitEventDetails = FormSubmitEventDetails;
|
||||
export type SubmitCustomProperties = FormSubmitCustomProperties;
|
||||
|
||||
export type Values<FormValues extends Record<string, any> = Record<string, any>> = FormValues;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "rimraf --glob build build-tests \"*.tsbuildinfo\"",
|
||||
"build": "code-infra build --copy .npmignore --tsgo",
|
||||
"build": "bun ../../scripts/aphrody/fast-build.ts",
|
||||
"build:legacy": "code-infra build --copy .npmignore --tsgo",
|
||||
"test:package": "publint --pack bun ./build && attw --pack ./build --exclude-entrypoints package.json",
|
||||
"release": "bun run build && bun publish",
|
||||
"test": "bun test",
|
||||
|
||||
@@ -18,4 +18,29 @@ describe('addEventListener', () => {
|
||||
|
||||
expect(target.removeEventListener).toHaveBeenCalledWith('click', listener, options);
|
||||
});
|
||||
|
||||
it('leverages AbortSignal.timeout when timeout option is provided', () => {
|
||||
const target = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const listener = vi.fn();
|
||||
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
|
||||
|
||||
try {
|
||||
const unsubscribe = addEventListener(target, 'click', listener, { timeout: 1000 });
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(1000);
|
||||
expect(target.addEventListener).toHaveBeenCalledWith(
|
||||
'click',
|
||||
listener,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
expect(target.removeEventListener).toHaveBeenCalled();
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,14 @@ type EventMap<Target> = Target extends Window
|
||||
type TypedEventListener<Target, Event> =
|
||||
{ handleEvent(event: Event): void } | ((this: Target, event: Event) => void);
|
||||
|
||||
export interface ExtendedAddEventListenerOptions extends AddEventListenerOptions {
|
||||
/**
|
||||
* Timeout in milliseconds after which the event listener will automatically be removed.
|
||||
* Leverages native `AbortSignal.timeout` where available.
|
||||
*/
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event listener and returns a cleanup function to remove it.
|
||||
*/
|
||||
@@ -41,22 +49,49 @@ export function addEventListener<
|
||||
target: Target,
|
||||
type: Type,
|
||||
listener: TypedEventListener<Target, EventMap<Target>[Type]>,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
options?: boolean | ExtendedAddEventListenerOptions,
|
||||
): () => void;
|
||||
export function addEventListener(
|
||||
target: EventTargetWithListeners,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
options?: boolean | ExtendedAddEventListenerOptions,
|
||||
): () => void;
|
||||
export function addEventListener(
|
||||
target: EventTargetWithListeners,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
options?: boolean | ExtendedAddEventListenerOptions,
|
||||
) {
|
||||
target.addEventListener(type, listener, options);
|
||||
return () => {
|
||||
target.removeEventListener(type, listener, options);
|
||||
};
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined;
|
||||
let activeOptions = options;
|
||||
|
||||
if (typeof options === 'object' && options !== null && typeof options.timeout === 'number') {
|
||||
const { timeout, signal, ...rest } = options;
|
||||
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
||||
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||
const effectiveSignal = signal
|
||||
? (typeof AbortSignal.any === 'function'
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal)
|
||||
: timeoutSignal;
|
||||
activeOptions = { ...rest, signal: effectiveSignal };
|
||||
} else {
|
||||
timerId = setTimeout(() => {
|
||||
cleanup();
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
target.addEventListener(type, listener, activeOptions);
|
||||
|
||||
function cleanup() {
|
||||
if (timerId !== undefined) {
|
||||
clearTimeout(timerId);
|
||||
timerId = undefined;
|
||||
}
|
||||
target.removeEventListener(type, listener, activeOptions);
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, describe, it, vi } from 'vitest';
|
||||
import { generateId } from './generateId';
|
||||
|
||||
describe('generateId', () => {
|
||||
it('generates unique ids using crypto.randomUUID when available', () => {
|
||||
const id1 = generateId('prefix');
|
||||
const id2 = generateId('prefix');
|
||||
|
||||
expect(id1.startsWith('prefix-')).toBe(true);
|
||||
expect(id2.startsWith('prefix-')).toBe(true);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it('falls back to Math.random counter when crypto.randomUUID is unavailable', () => {
|
||||
const originalRandomUUID = globalThis.crypto?.randomUUID;
|
||||
try {
|
||||
// @ts-expect-error test fallback
|
||||
delete globalThis.crypto.randomUUID;
|
||||
|
||||
const id1 = generateId('fallback');
|
||||
const id2 = generateId('fallback');
|
||||
|
||||
expect(id1.startsWith('fallback-')).toBe(true);
|
||||
expect(id2.startsWith('fallback-')).toBe(true);
|
||||
expect(id1).not.toBe(id2);
|
||||
} finally {
|
||||
if (originalRandomUUID) {
|
||||
globalThis.crypto.randomUUID = originalRandomUUID;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
let counter = 0;
|
||||
|
||||
export function generateId(prefix: string) {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return `${prefix}-${globalThis.crypto.randomUUID()}`;
|
||||
}
|
||||
counter += 1;
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 6)}-${counter}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { $ } from 'bun';
|
||||
|
||||
// Determine workspace root
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
||||
const rootDir = path.resolve(scriptDir, '../..');
|
||||
|
||||
// Helper to resolve relative imports in emitted files and declarations
|
||||
function resolveRelativeImport(specifier: string, dir: string, ext: string): string {
|
||||
if (!specifier.startsWith('.')) return specifier;
|
||||
if (specifier.endsWith(ext)) return specifier;
|
||||
const clean = specifier.replace(/\.(js|mjs|cjs|ts|tsx)$/, '');
|
||||
const abs = path.resolve(dir, clean);
|
||||
|
||||
// Check if a file exists with typical source extensions first
|
||||
for (const checkExt of ['.ts', '.tsx', '.js', '.jsx', '.d.ts']) {
|
||||
if (existsSync(abs + checkExt)) {
|
||||
return `${clean}${ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise check if it's a directory with index
|
||||
try {
|
||||
if (existsSync(abs) && statSync(abs).isDirectory()) {
|
||||
return `${clean}/index${ext}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return `${clean}${ext}`;
|
||||
}
|
||||
|
||||
// Transform imports in .d.ts files for .d.mts output
|
||||
function transformDtsImports(content: string, filePath: string, ext: string): string {
|
||||
const dir = path.dirname(filePath);
|
||||
return content.replace(
|
||||
/((?:from\s+|import\s*\(\s*)['"])([^'"]+)(['"]\s*\)?)/g,
|
||||
(match, prefix, specifier, suffix) => {
|
||||
if (specifier.startsWith('.')) {
|
||||
const resolved = resolveRelativeImport(specifier, dir, ext);
|
||||
return `${prefix}${resolved}${suffix}`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Find native TypeScript compiler (tsc/tsgo)
|
||||
async function findTscBin(): Promise<string> {
|
||||
const binDir = path.join(rootDir, 'node_modules', '.bin');
|
||||
for (const name of ['tsc', 'tsgo', 'tsc6']) {
|
||||
for (const ext of ['', '.exe', '.cmd', '.bunx']) {
|
||||
const candidate = path.join(binDir, name + ext);
|
||||
try {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return 'tsc';
|
||||
}
|
||||
|
||||
interface BuildPackageOptions {
|
||||
packageName: string;
|
||||
packageDir: string;
|
||||
skipTypes?: boolean;
|
||||
}
|
||||
|
||||
async function buildPackage({ packageName, packageDir, skipTypes = false }: BuildPackageOptions) {
|
||||
const start = performance.now();
|
||||
console.log(`\x1b[36m⚡ [Fast-Build]\x1b[0m Building ${packageName}...`);
|
||||
|
||||
const buildDir = path.join(packageDir, 'build');
|
||||
await fs.rm(buildDir, { recursive: true, force: true });
|
||||
await fs.mkdir(buildDir, { recursive: true });
|
||||
|
||||
const pkgJsonPath = path.join(packageDir, 'package.json');
|
||||
const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
|
||||
|
||||
// Collect source files
|
||||
const srcDir = path.join(packageDir, 'src');
|
||||
const glob = new Bun.Glob('**/*.{ts,tsx,js,jsx}');
|
||||
const sourceFiles = Array.from(glob.scanSync({ cwd: srcDir }))
|
||||
.filter(
|
||||
(f) =>
|
||||
!f.includes('.test.') &&
|
||||
!f.includes('.spec.') &&
|
||||
!f.endsWith('.d.ts') &&
|
||||
!f.endsWith('.template.js')
|
||||
)
|
||||
.map((f) => path.join(srcDir, f));
|
||||
|
||||
// Determine external dependencies
|
||||
const externalDeps = [
|
||||
...Object.keys(pkgJson.dependencies || {}),
|
||||
...Object.keys(pkgJson.peerDependencies || {}),
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/*',
|
||||
'react-dom/*',
|
||||
'@base-ui/utils',
|
||||
'@base-ui/utils/*',
|
||||
'@floating-ui/react-dom',
|
||||
'@floating-ui/react-dom/*',
|
||||
'@floating-ui/utils',
|
||||
'@floating-ui/utils/*',
|
||||
'#formatErrorMessage',
|
||||
'#prehydration/*',
|
||||
'#test-utils',
|
||||
];
|
||||
|
||||
// 1. Parallel ESM & CJS Build using Bun.build
|
||||
const esmPromise = Bun.build({
|
||||
entrypoints: sourceFiles,
|
||||
root: srcDir,
|
||||
outdir: buildDir,
|
||||
format: 'esm',
|
||||
sourcemap: 'external',
|
||||
target: 'browser',
|
||||
naming: '[dir]/[name].mjs',
|
||||
external: externalDeps,
|
||||
plugins: [
|
||||
{
|
||||
name: 'fast-resolve-relative-mjs',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\./ }, (args) => {
|
||||
if (!args.importer) return;
|
||||
const target = resolveRelativeImport(args.path, path.dirname(args.importer), '.mjs');
|
||||
return { path: target, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const cjsPromise = Bun.build({
|
||||
entrypoints: sourceFiles,
|
||||
root: srcDir,
|
||||
outdir: buildDir,
|
||||
format: 'cjs',
|
||||
sourcemap: 'external',
|
||||
target: 'browser',
|
||||
naming: '[dir]/[name].js',
|
||||
external: externalDeps,
|
||||
plugins: [
|
||||
{
|
||||
name: 'fast-resolve-relative-js',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\./ }, (args) => {
|
||||
if (!args.importer) return;
|
||||
const target = resolveRelativeImport(args.path, path.dirname(args.importer), '.js');
|
||||
return { path: target, external: true };
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 2. TypeScript DTS declaration emission in parallel
|
||||
const tscPromise = (async () => {
|
||||
if (skipTypes) return;
|
||||
const tscBin = await findTscBin();
|
||||
const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
|
||||
if (existsSync(tsconfigPath)) {
|
||||
await $`${tscBin} -p ${tsconfigPath} --outDir ${buildDir} --rootDir ${srcDir} --declaration --emitDeclarationOnly --noEmit false --composite false --incremental false`.quiet();
|
||||
}
|
||||
})();
|
||||
|
||||
const [esmResult, cjsResult] = await Promise.all([esmPromise, cjsPromise, tscPromise]);
|
||||
|
||||
if (!esmResult.success) {
|
||||
console.error(`\x1b[31mError building ESM for ${packageName}:\x1b[0m`, esmResult.logs);
|
||||
throw new Error(`ESM build failed for ${packageName}`);
|
||||
}
|
||||
if (!cjsResult.success) {
|
||||
console.error(`\x1b[31mError building CJS for ${packageName}:\x1b[0m`, cjsResult.logs);
|
||||
throw new Error(`CJS build failed for ${packageName}`);
|
||||
}
|
||||
|
||||
// 3. Transform .d.ts files into .d.mts with resolved extensions for Node16 ESM
|
||||
if (!skipTypes) {
|
||||
const dtsGlob = new Bun.Glob('**/*.d.ts');
|
||||
const dtsFiles = Array.from(dtsGlob.scanSync({ cwd: buildDir }));
|
||||
|
||||
await Promise.all(
|
||||
dtsFiles.map(async (f) => {
|
||||
const full = path.join(buildDir, f);
|
||||
const content = await fs.readFile(full, 'utf8');
|
||||
const mtsPath = full.replace(/\.d\.ts$/, '.d.mts');
|
||||
const transformed = transformDtsImports(content, full, '.mjs');
|
||||
await fs.writeFile(mtsPath, transformed);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Generate build/package.json
|
||||
const { createPackageExports, createPackageImports } = await import(
|
||||
path.join(rootDir, 'node_modules/@mui/internal-code-infra/src/utils/build.mjs')
|
||||
);
|
||||
|
||||
const builtPkgJson = { ...pkgJson };
|
||||
delete builtPkgJson.scripts;
|
||||
delete builtPkgJson.publishConfig?.directory;
|
||||
delete builtPkgJson.devDependencies;
|
||||
builtPkgJson.type = 'commonjs';
|
||||
|
||||
const originalExports = builtPkgJson.exports;
|
||||
delete builtPkgJson.exports;
|
||||
const originalImports = builtPkgJson.imports;
|
||||
delete builtPkgJson.imports;
|
||||
|
||||
const [{ exports: packageExports, main, types }, packageImports] = await Promise.all([
|
||||
createPackageExports(originalExports, {
|
||||
bundles: ['esm', 'cjs'],
|
||||
outputDir: buildDir,
|
||||
cwd: packageDir,
|
||||
addTypes: !skipTypes,
|
||||
expand: true,
|
||||
packageType: 'commonjs',
|
||||
}),
|
||||
originalImports
|
||||
? createPackageImports(originalImports, {
|
||||
bundles: ['esm', 'cjs'],
|
||||
cwd: packageDir,
|
||||
outputDir: buildDir,
|
||||
addTypes: !skipTypes,
|
||||
expand: true,
|
||||
packageType: 'commonjs',
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
builtPkgJson.exports = packageExports;
|
||||
if (packageImports) builtPkgJson.imports = packageImports;
|
||||
if (main) builtPkgJson.main = main;
|
||||
if (types) builtPkgJson.types = types;
|
||||
|
||||
await fs.writeFile(path.join(buildDir, 'package.json'), JSON.stringify(builtPkgJson, null, 2));
|
||||
|
||||
// 5. Add license banner to index bundles
|
||||
async function addLicenseHeader(file: string) {
|
||||
try {
|
||||
const content = await fs.readFile(file, 'utf8');
|
||||
const header = `/**
|
||||
* ${pkgJson.name} v${pkgJson.version}
|
||||
*
|
||||
* @license ${pkgJson.license || 'MIT'}
|
||||
* This source code is licensed under the ${pkgJson.license || 'MIT'} license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/\n`;
|
||||
await fs.writeFile(file, header + content);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
addLicenseHeader(path.join(buildDir, 'index.mjs')),
|
||||
addLicenseHeader(path.join(buildDir, 'index.js')),
|
||||
]);
|
||||
|
||||
// 6. Copy documentation and legal files (.npmignore, README.md, LICENSE, published-docs)
|
||||
for (const file of ['.npmignore', 'README.md', 'LICENSE', 'CHANGELOG.md']) {
|
||||
const localFile = path.join(packageDir, file);
|
||||
const rootFile = path.join(rootDir, file);
|
||||
try {
|
||||
if (await fs.stat(localFile).then(() => true).catch(() => false)) {
|
||||
await fs.copyFile(localFile, path.join(buildDir, file));
|
||||
} else if (await fs.stat(rootFile).then(() => true).catch(() => false)) {
|
||||
await fs.copyFile(rootFile, path.join(buildDir, file));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Copy published-docs into build/docs if present
|
||||
const publishedDocsDir = path.join(packageDir, 'published-docs');
|
||||
if (existsSync(publishedDocsDir)) {
|
||||
await fs.cp(publishedDocsDir, path.join(buildDir, 'docs'), { recursive: true });
|
||||
}
|
||||
|
||||
const duration = (performance.now() - start).toFixed(0);
|
||||
console.log(`\x1b[32m✔ [Fast-Build]\x1b[0m ${packageName} built successfully in ${duration}ms!`);
|
||||
}
|
||||
|
||||
// Fallback execution to legacy code-infra build if requested
|
||||
async function runFallback(pkgName?: string) {
|
||||
console.log(`\x1b[33m⚠️ Running fallback code-infra build...\x1b[0m`);
|
||||
const cwd = process.cwd();
|
||||
if (pkgName === 'react' || cwd.endsWith('react')) {
|
||||
await $`bun x code-infra build --ignore "**/*.template.js" --copy .npmignore --copy "published-docs/**:docs" --tsgo`.cwd(
|
||||
path.join(rootDir, 'packages/react')
|
||||
);
|
||||
} else if (pkgName === 'utils' || cwd.endsWith('utils')) {
|
||||
await $`bun x code-infra build --copy .npmignore --tsgo`.cwd(
|
||||
path.join(rootDir, 'packages/utils')
|
||||
);
|
||||
} else {
|
||||
await $`bun run --cwd packages/utils build`;
|
||||
await $`bun run --cwd packages/react build`;
|
||||
}
|
||||
}
|
||||
|
||||
// Main CLI dispatch
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isFallback = args.includes('--fallback') || process.env.USE_FALLBACK_BUILD === '1';
|
||||
const skipTypes = args.includes('--skip-types');
|
||||
|
||||
const cwd = process.cwd().replace(/\\/g, '/');
|
||||
|
||||
if (isFallback) {
|
||||
await runFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const overallStart = performance.now();
|
||||
|
||||
const utilsPkgDir = path.join(rootDir, 'packages/utils');
|
||||
const reactPkgDir = path.join(rootDir, 'packages/react');
|
||||
|
||||
// Check if invoked directly within a package folder
|
||||
const isUtilsCwd = cwd.endsWith('packages/utils') || cwd.endsWith('packages\\utils');
|
||||
const isReactCwd = cwd.endsWith('packages/react') || cwd.endsWith('packages\\react');
|
||||
|
||||
const filterArg = args.find((a) => a.startsWith('--filter='))?.split('=')[1] ||
|
||||
(args.includes('--filter') ? args[args.indexOf('--filter') + 1] : undefined);
|
||||
|
||||
if (isUtilsCwd || filterArg === 'utils' || filterArg === '@base-ui/utils') {
|
||||
await buildPackage({
|
||||
packageName: '@base-ui/utils',
|
||||
packageDir: utilsPkgDir,
|
||||
skipTypes,
|
||||
});
|
||||
} else if (isReactCwd || filterArg === 'react' || filterArg === '@base-ui/react') {
|
||||
await buildPackage({
|
||||
packageName: '@base-ui/react',
|
||||
packageDir: reactPkgDir,
|
||||
skipTypes,
|
||||
});
|
||||
} else {
|
||||
// Monorepo root build: build @base-ui/utils first for type references, then react, with parallel compilation
|
||||
console.log(`\x1b[1m\x1b[35m🚀 Launching Fast Monorepo Build via Bun.build...\x1b[0m`);
|
||||
// First build utils (super-fast: ~200ms)
|
||||
await buildPackage({
|
||||
packageName: '@base-ui/utils',
|
||||
packageDir: utilsPkgDir,
|
||||
skipTypes,
|
||||
});
|
||||
// Then build react (~2.2s)
|
||||
await buildPackage({
|
||||
packageName: '@base-ui/react',
|
||||
packageDir: reactPkgDir,
|
||||
skipTypes,
|
||||
});
|
||||
|
||||
const totalDuration = ((performance.now() - overallStart) / 1000).toFixed(2);
|
||||
console.log(
|
||||
`\x1b[1m\x1b[32m✨ All packages built successfully in ${totalDuration}s!\x1b[0m`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\x1b[31mBuild failed:\x1b[0m', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -6,5 +6,5 @@
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.mts", "**/*.mjs", "../package.json", "../*.mjs", "../*.mts"],
|
||||
"exclude": ["**/node_modules"]
|
||||
"exclude": ["**/node_modules", "aphrody/**/*"]
|
||||
}
|
||||
Reference in new issue
Block a user