diff --git a/package.json b/package.json index d701901dc..8f3329bd1 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "@babel/preset-typescript": "^7.10.4", "@boost/cli": "^2.2.0", "@boost/common": "^2.2.2", - "@boost/event": "^2.2.0", "@boost/pipeline": "^2.1.3", "@boost/terminal": "^2.1.0", "@rollup/plugin-babel": "^5.2.1", diff --git a/src/Artifact.ts b/src/Artifact.ts new file mode 100644 index 000000000..4cf32b2ff --- /dev/null +++ b/src/Artifact.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-empty-function */ + +import Package from './Package'; +import { ArtifactFlags, BuildResult, ArtifactState } from './types'; + +export default abstract class Artifact { + flags: ArtifactFlags = {}; + + package: Package; + + result?: BuildResult; + + state: ArtifactState = 'pending'; + + constructor(pkg: Package) { + this.package = pkg; + } + + async boot(): Promise {} + + async build(): Promise {} + + async pack(): Promise {} + + isRunning(): boolean { + return this.state === 'booting' || this.state === 'building' || this.state === 'packing'; + } + + shouldSkip(): boolean { + return this.state === 'skipped' || this.state === 'failed'; + } + + abstract getLabel(): string; + + abstract getBuilds(): string[]; +} diff --git a/src/Build.ts b/src/Build.ts deleted file mode 100644 index fbf244820..000000000 --- a/src/Build.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Path } from '@boost/common'; -import { RollupCache } from 'rollup'; -import resolveTsConfig from './helpers/resolveTsConfig'; -import { - BuildFlags, - Format, - PackemonPackage, - Platform, - BuildResult, - BuildStatus, - Target, - FeatureFlags, -} from './types'; - -export default class Build { - cache?: RollupCache; - - flags: BuildFlags = {}; - - formats: Format[] = []; - - inputName: string = ''; - - inputPath: string = ''; - - meta: { - namespace: string; - workspaces: string[]; - }; - - package: PackemonPackage; - - packagePath: Path; - - platforms: Platform[] = []; - - result?: BuildResult; - - root: Path; - - status: BuildStatus = 'pending'; - - target: Target = 'legacy'; - - constructor(root: Path, pkg: PackemonPackage, workspaces: string[]) { - this.root = root; - this.package = pkg; - this.meta = { - namespace: '', - workspaces, - }; - - // Root workspace `package.json`s may not have this config block, - // but we need to load and parse them to extract feature flags. - this.packagePath = pkg.packemon?.path; - } - - get name(): string { - let { name } = this.package; - - if (this.inputName !== 'index') { - name += '/'; - name += this.inputName; - } - - return name; - } - - getFeatureFlags(): FeatureFlags { - const flags: FeatureFlags = { - workspaces: this.meta.workspaces, - }; - - // React - if (this.hasDependency('react')) { - flags.react = true; - } - - // TypeScript - const tsconfigPath = this.root.append('tsconfig.json'); - - if (this.hasDependency('typescript') || tsconfigPath.exists()) { - flags.typescript = true; - flags.decorators = Boolean( - resolveTsConfig(tsconfigPath)?.compilerOptions?.experimentalDecorators, - ); - } - - // Flow - const flowconfigPath = this.root.append('.flowconfig'); - - if (this.hasDependency('flow-bin') || flowconfigPath.exists()) { - flags.flow = true; - } - - return flags; - } - - hasDependency(name: string): boolean { - const pkg = this.package; - - return Boolean( - pkg.dependencies?.[name] || - pkg.devDependencies?.[name] || - pkg.peerDependencies?.[name] || - pkg.optionalDependencies?.[name], - ); - } -} diff --git a/src/BundleArtifact.ts b/src/BundleArtifact.ts new file mode 100644 index 000000000..ab42110ae --- /dev/null +++ b/src/BundleArtifact.ts @@ -0,0 +1,112 @@ +import { Path, toArray } from '@boost/common'; +import { rollup, RollupCache } from 'rollup'; +import Artifact from './Artifact'; +import { getRollupConfig } from './configs/rollup'; +import { Format, Platform } from './types'; + +export default class BundleArtifact extends Artifact { + cache?: RollupCache; + + formats: Format[] = []; + + // Path to the input file relative to the package + inputPath: string = ''; + + // Namespace for UMD bundles + namespace: string = ''; + + // Name of the output file without extension + outputName: string = ''; + + async build() { + const rollupConfig = getRollupConfig(this, this.package.getFeatureFlags()); + + // Skip build because of invalid config + if (!rollupConfig) { + this.state = 'skipped'; + + return; + } + + this.result = { + time: 0, + }; + + const { output = [], ...input } = rollupConfig; + const start = Date.now(); + const bundle = await rollup(input); + + if (bundle.cache) { + this.cache = bundle.cache; + } + + await Promise.all( + toArray(output).map(async (out) => { + const { originalFormat, ...outOptions } = out; + + try { + await bundle.write(outOptions); + + this.state = 'passed'; + } catch (error) { + this.state = 'failed'; + } + }), + ); + + this.result.time = Date.now() - start; + } + + getLabel(): string { + return this.outputName; + } + + getBuilds(): string[] { + return this.formats; + } + + getPlatform(format: Format): Platform { + if (format === 'cjs' || format === 'mjs') { + return 'node'; + } + + if (format === 'esm' || format === 'umd') { + return 'browser'; + } + + // "lib" is a shared format across all platforms, + // and when a package wants to support multiple platforms, + // we must down-level the "lib" format to the lowest platform. + if (this.flags.requiresSharedLib) { + const platforms = new Set(this.package.platforms); + + if (platforms.has('browser')) { + return 'browser'; + } else if (platforms.has('node')) { + return 'node'; + } + } + + return this.package.platforms[0]; + } + + getInputPath(): Path | null { + const inputPath = this.package.path.append(this.inputPath); + + if (inputPath.exists()) { + return inputPath; + } + + console.warn( + `Cannot find input "${this.inputPath}" for package "${this.package.contents.name}". Skipping package.`, + ); + + return null; + } + + getOutputPath(format: Format): Path { + const ext = format === 'cjs' || format === 'mjs' ? format : 'js'; + + return this.package.path.append(`${format}/${this.outputName}.${ext}`); + } +} diff --git a/src/Package.ts b/src/Package.ts new file mode 100644 index 000000000..9795d9154 --- /dev/null +++ b/src/Package.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/member-ordering */ + +import { Memoize, Path, toArray } from '@boost/common'; +import Artifact from './Artifact'; +import Project from './Project'; +import resolveTsConfig from './helpers/resolveTsConfig'; +import { FeatureFlags, PackemonPackage, Platform, Target } from './types'; + +export default class Package { + artifacts: Artifact[] = []; + + contents: PackemonPackage; + + path: Path; + + platforms: Platform[] = []; + + project: Project; + + root: boolean = false; + + target: Target = 'legacy'; + + constructor(project: Project, path: Path, contents: PackemonPackage) { + this.project = project; + this.path = path; + this.contents = contents; + + // Workspace root `package.json`s may not have this + if (contents.packemon) { + this.platforms = toArray(contents.packemon.platform); + this.target = contents.packemon.target; + } + } + + addArtifact(artifact: Artifact): Artifact { + this.artifacts.push(artifact); + + return artifact; + } + + getName(): string { + return this.contents.name; + } + + @Memoize() + getFeatureFlags(): FeatureFlags { + const flags: FeatureFlags = this.root ? {} : this.project.rootPackage.getFeatureFlags(); + flags.workspaces = this.project.workspaces; + + // React + if (this.hasDependency('react')) { + flags.react = true; + } + + // TypeScript + const tsconfigPath = this.project.root.append('tsconfig.json'); + + if (this.hasDependency('typescript') || tsconfigPath.exists()) { + flags.typescript = true; + flags.decorators = Boolean( + resolveTsConfig(tsconfigPath)?.compilerOptions?.experimentalDecorators, + ); + } + + // Flow + const flowconfigPath = this.project.root.append('.flowconfig'); + + if (this.hasDependency('flow-bin') || flowconfigPath.exists()) { + flags.flow = true; + } + + return flags; + } + + getJsonPath(): Path { + return this.path.append('package.json'); + } + + hasDependency(name: string): boolean { + const pkg = this.contents; + + return Boolean( + pkg.dependencies?.[name] || + pkg.devDependencies?.[name] || + pkg.peerDependencies?.[name] || + pkg.optionalDependencies?.[name], + ); + } + + isBuilt(): boolean { + return this.artifacts.every( + (artifact) => artifact.state !== 'pending' && !artifact.isRunning(), + ); + } +} diff --git a/src/Packemon.ts b/src/Packemon.ts index 9a109b214..a9f4202de 100644 --- a/src/Packemon.ts +++ b/src/Packemon.ts @@ -1,45 +1,41 @@ -/* eslint-disable no-param-reassign, require-atomic-updates, @typescript-eslint/member-ordering */ +/* eslint-disable no-param-reassign */ import fs from 'fs'; import { - Path, - Project, - Contract, Blueprint, - Predicates, - toArray, + Contract, + json, optimal, + Path, predicates, + Predicates, + toArray, WorkspacePackage, - Memoize, - json, } from '@boost/common'; -import { Event } from '@boost/event'; import { PooledPipeline, Context } from '@boost/pipeline'; import spdxLicenses from 'spdx-license-list'; -import { rollup } from 'rollup'; -import { getRollupConfig } from './configs/rollup'; -import Build from './Build'; +import Package from './Package'; +import Project from './Project'; +import Artifact from './Artifact'; +import BundleArtifact from './BundleArtifact'; import { - PackemonPackage, - PackemonOptions, + ArtifactFlags, + ArtifactState, Format, + PackemonOptions, + PackemonPackage, PackemonPackageConfig, - Platform, - BuildFlags, - FeatureFlags, + Phase, } from './types'; export default class Packemon extends Contract { - builds: Build[] = []; - root: Path; - project: Project; + packages: Package[] = []; - readonly onComplete = new Event('complete'); + phase: Phase = 'boot'; - readonly onBootProgress = new Event<[number, number]>('boot-progress'); + project: Project; constructor(cwd: string, options: PackemonOptions) { super(options); @@ -58,170 +54,37 @@ export default class Packemon extends Contract { }; } - async boot() { - const result = await this.getPackagesAndWorkspaces(); + async run() { + // Find packages and generate artifacts + await this.findPackages(); + await this.generateArtifacts(); - this.builds = this.generateBuilds(result.packages, result.workspaces); - } + // Bootstrap artifacts + this.phase = 'boot'; - async build() { - if (this.builds.length === 0) { - throw new Error('No builds found. Aborting.'); - } + await this.processArtifacts('booting', (artifact) => artifact.boot()); - const pipeline = new PooledPipeline(new Context()); - - pipeline.configure({ - concurrency: this.options.concurrency, - timeout: this.options.timeout, - }); + // Build artifacts + this.phase = 'build'; - this.builds.forEach((build) => { - pipeline.add(`Building ${build.name}`, () => this.buildWithRollup(build)); - }); + await this.processArtifacts('building', (artifact) => artifact.build()); - const result = await pipeline.run(); + // Package artifacts + this.phase = 'pack'; - // Mark all running builds as skipped - if (result.errors.length > 0) { - this.builds.forEach((build) => { - if (build.status === 'building') { - build.status = 'skipped'; - } - }); - } + await this.processArtifacts('packing', (artifact) => artifact.pack()); - return result; + // Done! + this.phase = 'done'; } - async buildWithRollup(build: Build) { - const rollupConfig = getRollupConfig(build, this.getFeatureFlags(build)); - - // Skip build because of invalid config - if (!rollupConfig) { - build.status = 'skipped'; - - return; - } - - build.status = 'building'; - - const { output = [], ...input } = rollupConfig; - const bundle = await rollup(input); - - // Cache the build - if (bundle.cache) { - build.cache = bundle.cache; - } - - // Write each build output - const start = Date.now(); - - build.result = { - output: [], - time: 0, - }; - - await Promise.all( - toArray(output).map(async (out) => { - const { originalFormat, ...outOptions } = out; - - try { - await bundle.write(outOptions); - - build.status = 'passed'; - } catch (error) { - build.status = 'failed'; - - throw error; - } - - build.result?.output.push({ - format: originalFormat!, - path: out.file!, - }); - }), - ); - - build.result.time = Date.now() - start; - } - - async pack() { - this.onComplete.emit([]); - } - - protected generateBuilds(packages: PackemonPackage[], workspaces: string[]): Build[] { - const builds: Build[] = []; - - packages.forEach((pkg) => { - const config = pkg.packemon; - const flags: BuildFlags = {}; - const formats = new Set(); - const platforms = new Set(); - - toArray(config.platform) - .sort() - .forEach((platform) => { - platforms.add(platform); - - if (formats.has('lib')) { - flags.requiresSharedLib = true; - } - - if (platform === 'node') { - formats.add('lib'); - formats.add('cjs'); - formats.add('mjs'); - } else if (platform === 'browser') { - formats.add('lib'); - formats.add('esm'); - - if (config.namespace) { - formats.add('umd'); - } - } - }); - - Object.entries(config.inputs).forEach(([name, path]) => { - const build = new Build(this.root, pkg, workspaces); - build.flags = flags; - build.formats = Array.from(formats); - build.meta.namespace = config.namespace; - build.platforms = Array.from(platforms); - build.target = config.target; - build.inputName = name; - build.inputPath = path; - - builds.push(build); - }); - }); - - return builds; - } - - protected getFeatureFlags(build: Build): FeatureFlags { - return { - ...this.getRootFeatureFlags(), - ...build.getFeatureFlags(), - }; - } - - @Memoize() - protected getRootFeatureFlags(): FeatureFlags { - return new Build(this.root, this.project.getPackage(), []).getFeatureFlags(); - } - - protected async getPackagesAndWorkspaces(): Promise<{ - packages: PackemonPackage[]; - workspaces: string[]; - }> { - const workspaces = this.project.getWorkspaceGlobs({ relative: true }); + protected async findPackages() { const pkgPaths: Path[] = []; - this.onBootProgress.emit([0, pkgPaths.length]); + this.project.workspaces = this.project.getWorkspaceGlobs({ relative: true }); // Multi package repo - if (workspaces.length > 0) { + if (this.project.workspaces.length > 0) { this.project.getWorkspacePackagePaths().forEach((filePath) => { pkgPaths.push(Path.create(filePath).append('package.json')); }); @@ -231,9 +94,6 @@ export default class Packemon extends Contract { pkgPaths.push(this.root.append('package.json')); } - this.onBootProgress.emit([0, pkgPaths.length]); - - let counter = 0; let packages: WorkspacePackage[] = await Promise.all( pkgPaths.map(async (pkgPath) => { const content = json.parse( @@ -241,9 +101,6 @@ export default class Packemon extends Contract { await fs.promises.readFile(pkgPath.path(), 'utf8'), ); - counter += 1; - this.onBootProgress.emit([counter, pkgPaths.length]); - return { metadata: this.project.createWorkspaceMetadata(pkgPath), package: content, @@ -251,22 +108,86 @@ export default class Packemon extends Contract { }), ); - this.onBootProgress.emit([pkgPaths.length, pkgPaths.length]); - // Skip `private` packages if (this.options.skipPrivate) { packages = packages.filter((pkg) => !pkg.package.private); } - return { - packages: this.validateAndPreparePackages(packages), - workspaces, - }; + this.packages = this.validateAndPreparePackages(packages); + } + + protected generateArtifacts() { + this.packages.forEach((pkg) => { + const config = pkg.contents.packemon; + const flags: ArtifactFlags = {}; + const formats = new Set(); + + pkg.platforms.sort().forEach((platform) => { + if (formats.has('lib')) { + flags.requiresSharedLib = true; + } + + if (platform === 'node') { + formats.add('lib'); + formats.add('cjs'); + formats.add('mjs'); + } else if (platform === 'browser') { + formats.add('lib'); + formats.add('esm'); + + if (config.namespace) { + formats.add('umd'); + } + } + }); + + Object.entries(config.inputs).forEach(([name, path]) => { + const artifact = new BundleArtifact(pkg); + artifact.flags = flags; + artifact.formats = Array.from(formats); + artifact.inputPath = path; + artifact.namespace = config.namespace; + artifact.outputName = name; + + pkg.addArtifact(artifact); + }); + }); + } + + protected async processArtifacts( + status: ArtifactState, + callback: (artifact: Artifact) => Promise, + ): Promise { + const pipeline = new PooledPipeline(new Context()); + + pipeline.configure({ + concurrency: this.options.concurrency, + timeout: this.options.timeout, + }); + + this.packages.forEach((pkg) => { + pkg.artifacts.forEach((artifact) => { + if (artifact.shouldSkip()) { + return; + } + + pipeline.add(`${status} ${artifact.getLabel()} (${pkg.getName()})`, () => { + artifact.state = status; + + return callback(artifact); + }); + }); + }); + + await pipeline.run(); + + // We need a short timeout so the CLI can refresh + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); } - protected validateAndPreparePackages( - packages: WorkspacePackage[], - ): PackemonPackage[] { + protected validateAndPreparePackages(packages: WorkspacePackage[]): Package[] { const spdxLicenseTypes = new Set( Object.keys(spdxLicenses).map((key) => key.toLocaleLowerCase()), ); @@ -282,39 +203,36 @@ export default class Packemon extends Contract { }; // Filter packages that only have packemon configured - const nextPackages: PackemonPackage[] = []; + const nextPackages: Package[] = []; - packages.forEach(({ metadata, package: pkg }) => { - if (!pkg.packemon) { + packages.forEach(({ metadata, package: contents }) => { + if (!contents.packemon) { return; } // Validate and set metadata - pkg.packemon = { - ...optimal(pkg.packemon, blueprint), - path: Path.create(metadata.packagePath), - }; + contents.packemon = optimal(contents.packemon, blueprint); // Validate licenses if (this.options.checkLicenses) { - if (pkg.license) { + if (contents.license) { toArray( - typeof pkg.license === 'string' - ? { type: pkg.license, url: spdxLicenses[pkg.license] } - : pkg.license, + typeof contents.license === 'string' + ? { type: contents.license, url: spdxLicenses[contents.license] } + : contents.license, ).forEach((license) => { if (!spdxLicenseTypes.has(license.type.toLocaleLowerCase())) { console.error( - `Invalid license ${license.type} for package "${pkg.name}". Must be an official SPDX license type.`, + `Invalid license ${license.type} for package "${contents.name}". Must be an official SPDX license type.`, ); } }); } else { - console.error(`No license found for package "${pkg.name}".`); + console.error(`No license found for package "${contents.name}".`); } } - nextPackages.push(pkg); + nextPackages.push(new Package(this.project, Path.create(metadata.packagePath), contents)); }); return nextPackages; diff --git a/src/Project.ts b/src/Project.ts new file mode 100644 index 000000000..5449b359b --- /dev/null +++ b/src/Project.ts @@ -0,0 +1,14 @@ +import { Memoize, Project as BaseProject } from '@boost/common'; +import Package from './Package'; + +export default class Project extends BaseProject { + workspaces: string[] = []; + + @Memoize() + get rootPackage(): Package { + const pkg = new Package(this, this.root, this.getPackage()); + pkg.root = true; + + return pkg; + } +} diff --git a/src/components/ArtifactRow.tsx b/src/components/ArtifactRow.tsx new file mode 100644 index 000000000..4827b4982 --- /dev/null +++ b/src/components/ArtifactRow.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Box } from 'ink'; +import Spinner from 'ink-spinner'; +import { formatMs } from '@boost/common'; +import { Style, StyleType } from '@boost/cli'; +import { figures } from '@boost/terminal'; +import Artifact from '../Artifact'; +import { ArtifactState } from '../types'; + +const STATE_COLORS: { [K in ArtifactState]?: StyleType } = { + pending: 'muted', + passed: 'success', + failed: 'failure', + skipped: 'warning', +}; + +const STATE_LABELS: { [K in ArtifactState]: string } = { + pending: '', + booting: 'Booting', + building: 'Building', + packing: 'Packing', + passed: 'Passed', + failed: 'Failed', + skipped: 'Skipped', +}; + +export interface ArtifactRowProps { + artifact: Artifact; +} + +export default function ArtifactRow({ artifact }: ArtifactRowProps) { + const { state } = artifact; + + return ( + + + + + + {artifact.getBuilds().map((build) => ( + + + + ))} + + + + + {artifact.isRunning() && ( + + )} + + + ); +} diff --git a/src/components/BootPhase.tsx b/src/components/BootPhase.tsx deleted file mode 100644 index f8d34d50d..000000000 --- a/src/components/BootPhase.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box } from 'ink'; -import ProgressBar from 'ink-progress-bar'; -import { Header } from '@boost/cli'; -import Packemon from '../Packemon'; - -export interface BootPhaseProps { - packemon: Packemon; - onBooted: () => void; -} - -export default function BootPhase({ packemon, onBooted }: BootPhaseProps) { - const [progress, setProgress] = useState(0); - - // Monitor generation progress - useEffect(() => { - return packemon.onBootProgress.listen((current, total) => { - setProgress(current / total); - }); - }, [packemon]); - - // Find all packages and generate builds on mount - useEffect(() => { - void packemon.boot().then(() => { - // Delay next phase to show progress bar - setTimeout(() => { - onBooted(); - }, 500); - }); - }, [packemon, onBooted]); - - return ( - -
- - - ); -} diff --git a/src/components/BuildList.tsx b/src/components/BuildList.tsx deleted file mode 100644 index 9bfe36405..000000000 --- a/src/components/BuildList.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box, useStdout } from 'ink'; -import Spinner from 'ink-spinner'; -import { formatMs } from '@boost/common'; -import { Style, StyleType } from '@boost/cli'; -import { screen } from '@boost/terminal'; -import Build from '../Build'; -import { BuildStatus } from '../types'; -import TargetPlatforms from './TargetPlatforms'; - -const STATUS_COLORS: { [K in BuildStatus]: StyleType } = { - pending: 'muted', - building: 'default', - passed: 'success', - failed: 'failure', - skipped: 'warning', -}; - -export interface BuildRowProps { - build: Build; -} - -export function BuildRow({ build }: BuildRowProps) { - return ( - - - - - - - {build.formats.map((format) => ( - - - - ))} - - {build.status === 'building' && ( - - - - )} - - {build.result && build.result.time > 0 && ( - - - - )} - - - - - - - ); -} - -const ROW_HEIGHT = 3; // 2 rows + 1 top margin - -export interface BuildListProps { - builds: Build[]; -} - -export default function BuildList({ builds }: BuildListProps) { - const [height, setHeight] = useState(screen.size().rows); - const { stdout } = useStdout(); - - useEffect(() => { - const handler = () => { - setHeight(screen.size().rows); - }; - - stdout?.on('resize', handler); - - return () => { - stdout?.off('resize', handler); - }; - }, [stdout]); - - // We dont want to show more builds than the amount of rows available in the terminal - const visibleBuilds = builds.slice(0, Math.floor(height / ROW_HEIGHT) - 1); - - return ( - <> - {visibleBuilds.map((build) => ( - - ))} - - ); -} diff --git a/src/components/BuildPhase.tsx b/src/components/BuildPhase.tsx deleted file mode 100644 index 3b65f7d0a..000000000 --- a/src/components/BuildPhase.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box } from 'ink'; -import { Header } from '@boost/cli'; -import BuildList from './BuildList'; -import Packemon from '../Packemon'; -import Build from '../Build'; - -export interface BuildPhaseProps { - packemon: Packemon; - onBuilt: () => void; -} - -export default function BuildPhase({ packemon, onBuilt }: BuildPhaseProps) { - const [errors, setErrors] = useState([]); - - // Start building packages on mount - useEffect(() => { - void packemon.build().then((result) => { - if (result.errors.length > 0) { - setErrors(result.errors); - } else { - onBuilt(); - } - }); - }, [packemon, onBuilt]); - - // Bubble up errors to the main application - if (errors.length > 0) { - throw errors[0]; - } - - // Update and sort build list states - const pendingBuilds: Build[] = []; - const runningBuilds: Build[] = []; - - packemon.builds.forEach((build) => { - if (build.status === 'building') { - runningBuilds.push(build); - } else if (build.status === 'pending') { - pendingBuilds.push(build); - } - }); - - // Phase label - let label = `Building ${runningBuilds.length} packages`; - - if (pendingBuilds.length > 0) { - label += ` (${pendingBuilds.length} pending)`; - } - - return ( - -
- - - ); -} diff --git a/src/components/Main.tsx b/src/components/Main.tsx index ade05fa63..307db3a7f 100644 --- a/src/components/Main.tsx +++ b/src/components/Main.tsx @@ -1,60 +1,83 @@ -import { Static } from 'ink'; -import React, { useState, useCallback, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Box, Static } from 'ink'; +import { Header } from '@boost/cli'; import Packemon from '../Packemon'; -import { Phase } from '../types'; -import BootPhase from './BootPhase'; -import BuildPhase from './BuildPhase'; -import { BuildRow } from './BuildList'; -import PackPhase from './PackPhase'; +import PackageList from './PackageList'; +import PackageRow from './PackageRow'; +import Package from '../Package'; + +const HEADER_LABELS = { + boot: 'Bootstrapping packages', + build: 'Building package artifacts', + pack: 'Packing for distribution', +}; export interface MainProps { packemon: Packemon; } export default function Main({ packemon }: MainProps) { - const [phase, setPhase] = useState('boot'); - const [, setCounter] = useState(0); + const [error, setError] = useState(); + const [counter, setCounter] = useState(0); + const [builtPackages, setBuiltPackages] = useState([]); - // Continuously re-render so that each build status is updated useEffect(() => { + // Continuously re-render so that states are updated const timer = setInterval(() => { setCounter((count) => count + 1); - }, 100); + }, 50); const clear = () => { clearInterval(timer); }; - packemon.onComplete.listen(clear); + // Run the packemon process on mount + void packemon + .run() + .then(() => { + // Give some time for the static elements to flush + setTimeout(clear, 150); + }) + .catch(setError); return clear; }, [packemon]); - // Handlers for advancing between phases - const handleBooted = useCallback(() => { - setPhase('build'); - }, []); + // Update built packages list on each re-render + useEffect(() => { + setBuiltPackages((prevBuilt) => { + const builtSet = new Set(prevBuilt); + const nextBuilt: Package[] = []; + + packemon.packages.forEach((pkg) => { + if (pkg.isBuilt() && !builtSet.has(pkg)) { + nextBuilt.push(pkg); + } + }); - const handleBuilt = useCallback(() => { - setPhase('pack'); - }, []); + return [...prevBuilt, ...nextBuilt]; + }); + }, [counter, packemon]); - const handlePacked = useCallback(() => { - setPhase('done'); - }, []); + // Bubble up errors to the program + if (error) { + throw error; + } + + const runningPackages = packemon.packages.filter((pkg) => !pkg.isBuilt()); return ( <> - {phase === 'boot' && } - - {phase === 'build' && } + + {(pkg) => } + - {phase === 'pack' && } + {packemon.phase !== 'done' && ( + +
- {phase === 'done' && ( - - {(build) => } - + {runningPackages.length > 0 && } + )} ); diff --git a/src/components/PackPhase.tsx b/src/components/PackPhase.tsx deleted file mode 100644 index fb91a5fc1..000000000 --- a/src/components/PackPhase.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { useEffect } from 'react'; -import { Box } from 'ink'; -import { Header } from '@boost/cli'; -import Packemon from '../Packemon'; - -export interface PackPhaseProps { - packemon: Packemon; - onPacked: () => void; -} - -export default function PackPhase({ packemon, onPacked }: PackPhaseProps) { - // Start packing packages on mount - useEffect(() => { - void packemon.pack().then(() => { - onPacked(); - }); - }, [packemon, onPacked]); - - return ( - -
- - ); -} diff --git a/src/components/PackageList.tsx b/src/components/PackageList.tsx new file mode 100644 index 000000000..1266c49f3 --- /dev/null +++ b/src/components/PackageList.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import { useStdout } from 'ink'; +import { screen } from '@boost/terminal'; +import Package from '../Package'; +import PackageRow from './PackageRow'; + +export interface PackageListProps { + packages: Package[]; +} + +export default function PackageList({ packages }: PackageListProps) { + const [height, setHeight] = useState(screen.size().rows); + const { stdout } = useStdout(); + + useEffect(() => { + const handler = () => { + setHeight(screen.size().rows); + }; + + stdout?.on('resize', handler); + + return () => { + stdout?.off('resize', handler); + }; + }, [stdout]); + + // We dont want to show more packages than the amount of rows available in the terminal + const visiblePackages: Package[] = []; + let currentHeight = 0; + + packages + .filter((pkg) => !pkg.isBuilt()) + .some((pkg) => { + // margin top (1) + name row (1) + artifacts (n) + const rowHeight = 2 + pkg.artifacts.length; + + // Not enough room to display another row + if (currentHeight + rowHeight > height) { + return true; + } + + visiblePackages.push(pkg); + currentHeight += rowHeight; + + return false; + }); + + if (visiblePackages.length === 0) { + return null; + } + + return ( + <> + {visiblePackages.map((pkg) => ( + + ))} + + ); +} diff --git a/src/components/PackageRow.tsx b/src/components/PackageRow.tsx new file mode 100644 index 000000000..aefb086bf --- /dev/null +++ b/src/components/PackageRow.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Box } from 'ink'; +import { Style } from '@boost/cli'; +import Package from '../Package'; +import ArtifactRow from './ArtifactRow'; +import TargetPlatforms from './TargetPlatforms'; + +export interface PackageRowProps { + package: Package; +} + +export default function PackageRow({ package: pkg }: PackageRowProps) { + return ( + + + + + + + + + + + + {pkg.artifacts.map((artifact) => ( + + ))} + + ); +} diff --git a/src/components/TargetPlatforms.tsx b/src/components/TargetPlatforms.tsx index c4c33baf5..e1bbffe4a 100644 --- a/src/components/TargetPlatforms.tsx +++ b/src/components/TargetPlatforms.tsx @@ -20,12 +20,22 @@ export interface TargetPlatformsProps { target: Target; } +function trimVersion(version: string) { + const parts = version.split('.'); + + while (parts[parts.length - 1] === '0') { + parts.pop(); + } + + return parts.join('.'); +} + export default function TargetPlatforms({ platforms, target }: TargetPlatformsProps) { const versions: string[] = []; platforms.forEach((platform) => { if (platform === 'node') { - versions.push(`${PLATFORMS[platform]} (v${NODE_TARGETS[target]})`); + versions.push(`${PLATFORMS[platform]} (v${trimVersion(NODE_TARGETS[target])})`); } else if (platform === 'browser') { versions.push(`${PLATFORMS[platform]} (${toArray(BROWSER_TARGETS[target]).join(', ')})`); } diff --git a/src/configs/babel.ts b/src/configs/babel.ts index 75e457bf7..8f02e556d 100644 --- a/src/configs/babel.ts +++ b/src/configs/babel.ts @@ -1,7 +1,7 @@ import { PluginItem, TransformOptions as ConfigStructure } from '@babel/core'; import { BROWSER_TARGETS, NODE_TARGETS } from '../constants'; import { Target, Format, Platform, FeatureFlags, BuildUnit } from '../types'; -import Build from '../Build'; +import BundleArtifact from '../BundleArtifact'; // https://babeljs.io/docs/en/babel-preset-env export interface PresetEnvOptions { @@ -82,7 +82,7 @@ function getSharedConfig( // The input config should only parse special syntax, not transform and downlevel. // This applies to all formats within a build target. export function getBabelInputConfig( - build: Build, + artifact: BundleArtifact, features: FeatureFlags, ): Omit { const plugins: PluginItem[] = []; @@ -103,7 +103,7 @@ export function getBabelInputConfig( ['@babel/plugin-proposal-private-methods', { loose: true }], ); - if (build.hasDependency('@babel/plugin-proposal-private-property-in-object')) { + if (artifact.package.hasDependency('@babel/plugin-proposal-private-property-in-object')) { plugins.push(['@babel/plugin-proposal-private-property-in-object', { loose: true }]); } } diff --git a/src/configs/rollup.ts b/src/configs/rollup.ts index 6d679d649..f6588b5cc 100644 --- a/src/configs/rollup.ts +++ b/src/configs/rollup.ts @@ -5,32 +5,13 @@ import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import { getBabelInputPlugin, getBabelOutputPlugin } from '@rollup/plugin-babel'; import { getBabelInputConfig, getBabelOutputConfig } from './babel'; -import Build from '../Build'; import { FeatureFlags, Format, BuildUnit } from '../types'; import { EXTENSIONS, EXCLUDE } from '../constants'; -import getPlatformFromBuild from '../helpers/getPlatformFromBuild'; +import BundleArtifact from '../BundleArtifact'; const sharedPlugins = [resolve({ extensions: EXTENSIONS, preferBuiltins: true }), commonjs()]; -function getInputFile(build: Build): string | null { - if (build.packagePath.append(build.inputPath).exists()) { - return build.inputPath; - } - - console.warn( - `Cannot find input "${build.inputName}" for package "${build.package.name}". Skipping package.`, - ); - - return null; -} - -function getOutputFile(build: Build, format: Format): string { - const ext = format === 'cjs' || format === 'mjs' ? format : 'js'; - - return `${format}/${build.inputName}.${ext}`; -} - -function getModuleFormat(format: Format): ModuleFormat { +function getRollupModuleFormat(format: Format): ModuleFormat { if (format === 'umd') { return 'umd'; } @@ -42,19 +23,22 @@ function getModuleFormat(format: Format): ModuleFormat { return 'cjs'; } -export function getRollupConfig(build: Build, features: FeatureFlags): RollupOptions | null { - const input = getInputFile(build); +export function getRollupConfig( + artifact: BundleArtifact, + features: FeatureFlags, +): RollupOptions | null { + const inputPath = artifact.getInputPath(); - if (!input) { + if (!inputPath) { return null; } - const packagePath = path.resolve(build.packagePath.append('package.json').path()); + const packagePath = path.resolve(artifact.package.getJsonPath().path()); const config: RollupOptions = { - cache: build.cache, + cache: artifact.cache, external: [packagePath], - input, + input: inputPath.path(), output: [], // Shared output plugins plugins: [ @@ -71,36 +55,36 @@ export function getRollupConfig(build: Build, features: FeatureFlags): RollupOpt ...sharedPlugins, // Declare Babel here so we can parse TypeScript/Flow getBabelInputPlugin({ - ...getBabelInputConfig(build, features), + ...getBabelInputConfig(artifact, features), babelHelpers: 'bundled', exclude: EXCLUDE, extensions: EXTENSIONS, - filename: build.packagePath.path(), + filename: artifact.package.path.path(), }), ], // Always treeshake for smaller builds treeshake: true, }; - // Add an output for each build format - config.output = build.formats.map((format) => { + // Add an output for each format + config.output = artifact.formats.map((format) => { const buildUnit: BuildUnit = { format, - platform: getPlatformFromBuild(format, build), - target: build.target, + platform: artifact.getPlatform(format), + target: artifact.package.target, }; const output: OutputOptions = { - file: getOutputFile(build, format), - format: getModuleFormat(format), + file: artifact.getOutputPath(format).path(), + format: getRollupModuleFormat(format), originalFormat: format, // Use const when not supporting old targets - preferConst: build.target !== 'legacy', + preferConst: artifact.package.target !== 'legacy', // Output specific plugins plugins: [ getBabelOutputPlugin({ ...getBabelOutputConfig(buildUnit, features), - filename: build.packagePath.path(), + filename: artifact.package.path.path(), }), ], // Disable source maps @@ -109,7 +93,7 @@ export function getRollupConfig(build: Build, features: FeatureFlags): RollupOpt if (format === 'umd') { output.extend = true; - output.name = build.meta.namespace; + output.name = artifact.namespace; output.noConflict = true; } diff --git a/src/helpers/getPlatformFromBuild.ts b/src/helpers/getPlatformFromBuild.ts deleted file mode 100644 index a643826df..000000000 --- a/src/helpers/getPlatformFromBuild.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Build from '../Build'; -import { Format, Platform } from '../types'; - -export default function getPlatformFromBuild(format: Format, build: Build): Platform { - if (format === 'cjs' || format === 'mjs') { - return 'node'; - } - - if (format === 'esm' || format === 'umd') { - return 'browser'; - } - - // "lib" is a shared format across all platforms, - // and when a package wants to support multiple platforms, - // we must down-level the "lib" format to the lowest platform. - if (build.flags.requiresSharedLib) { - const platforms = new Set(build.platforms); - - if (platforms.has('browser')) { - return 'browser'; - } else if (platforms.has('node')) { - return 'node'; - } - } - - return build.platforms[0]; -} diff --git a/src/index.ts b/src/index.ts index 987f2ca48..3a0a998e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,10 @@ * @license https://opensource.org/licenses/MIT */ -import Build from './Build'; import Packemon from './Packemon'; import { run } from './run'; export * from './constants'; export * from './types'; -export { Build, Packemon, run }; +export { Packemon, run }; diff --git a/src/types.ts b/src/types.ts index 2daef7258..7d888a6b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { PackageStructure, Path } from '@boost/common'; +import { PackageStructure } from '@boost/common'; import type { CompilerOptions } from 'typescript'; export type Platform = 'node' | 'browser'; // electron @@ -31,7 +31,7 @@ export type NodeFormat = export type Format = NodeFormat | BrowserFormat; -export type Phase = 'boot' | 'build' | 'pack'; +export type Phase = 'boot' | 'build' | 'pack' | 'done'; export interface PackemonPackageConfig { inputs: Record; @@ -41,9 +41,7 @@ export interface PackemonPackageConfig { } export interface PackemonPackage extends PackageStructure { - packemon: PackemonPackageConfig & { - path: Path; - }; + packemon: PackemonPackageConfig; } export interface PackemonOptions { @@ -54,22 +52,24 @@ export interface PackemonOptions { timeout: number; } -// BUILD PHASE +// ARTIFACTS -export type BuildStatus = 'pending' | 'building' | 'passed' | 'failed' | 'skipped'; - -export interface BuildFlags { +export interface ArtifactFlags { requiresSharedLib?: boolean; } -export interface BuildResultOutput { - format: Format; - path: string; -} +export type ArtifactState = + | 'pending' + | 'booting' + | 'building' + | 'packing' + | 'passed' + | 'failed' + | 'skipped'; export interface BuildResult { + [key: string]: unknown; time: number; - output: BuildResultOutput[]; } export interface BuildUnit {