Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
- find-pkg
- http-errors
- jsonld-tools
- makage
- nested-obj
- node-api-client
- schema-sdk
Expand Down
13 changes: 13 additions & 0 deletions packages/makage/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

## 0.1.0 - 2024-11-22

### Added

- Initial release
- `makage clean` command for cross-platform directory removal
- `makage copy` command with `--flat` option
- `makage readme-footer` command for concatenating README with footer
- `makage assets` command for common asset copying
- `makage build-ts` command for TypeScript compilation
- Zero dependencies - uses only Node.js built-in modules
202 changes: 202 additions & 0 deletions packages/makage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# makage

<p align="center">
<img src="https://raw.githubusercontent.com/hyperweb-io/dev-utils/refs/heads/main/docs/img/logo.svg" width="80">
<br />
Tiny build helper for monorepo packages
<br />
<a href="https://github.com/hyperweb-io/dev-utils/actions/workflows/ci.yml">
<img height="20" src="https://github.com/hyperweb-io/dev-utils/actions/workflows/ci.yml/badge.svg" />
</a>
<a href="https://github.com/hyperweb-io/dev-utils/blob/main/LICENSE">
<img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
</a>
</p>

`makage` is a tiny, cross-platform build helper that replaces common build tools like `cpy` and `rimraf` with zero dependencies. It provides essential commands for managing package builds in monorepos.

> **makage** = `make` + `package`. A delightful portmanteau, like brunch for build tools—except makage actually gets things done.

## Assumptions

This tool is designed for monorepos that follow a specific package structure. If you use packages the way we do, `makage` assumes:

- **`dist/` folder** - Your build output goes to a `dist/` directory
- **pnpm workspaces** - You're using pnpm workspace protocol for internal dependencies
- **`publishConfig.directory` set to `dist`** - Your `package.json` publishes from the `dist/` folder
- This enables tree-shaking with deep imports and modular development
- **Shared LICENSE** - The `LICENSE` file in the monorepo root is copied to each package when published
- **Assets copied to `dist/`** - Before publishing, assets (LICENSE, README, package.json) are copied into `dist/` so pnpm can publish them from there
- **Optional: `FOOTER.md`** - If present in a package directory, it will be appended to `README.md` before being copied to `dist/`

These conventions allow for clean package distribution while maintaining a modular development structure.

## Features

- **Cross-platform copy** - Copy files with `--flat` option (replacement for `cpy`)
- **Cross-platform clean** - Recursively remove directories (replacement for `rimraf`)
- **README + Footer concatenation** - Combine README with footer content before publishing
- **Assets helper** - One-command copying of LICENSE, README, and package.json
- **Build TypeScript helper** - Run both CJS and ESM TypeScript builds
- **Update workspace dependencies** - Automatically convert internal package references to `workspace:*`
- **Zero dependencies** - Uses only Node.js built-in modules

## Install

```sh
npm install makage
```

## Usage

### CLI Commands

```bash
# Clean build directories
makage clean dist

# Copy files to destination
makage copy ../../LICENSE README.md package.json dist --flat

# Concatenate README with footer
makage readme-footer --source README.md --footer FOOTER.md --dest dist/README.md

# Copy standard assets (LICENSE, package.json, README+FOOTER)
makage assets

# Build TypeScript (both CJS and ESM)
makage build-ts

# Update workspace dependencies
makage update-workspace
```

### Package.json Scripts

Replace your existing build scripts with `makage`:

```json
{
"scripts": {
"clean": "makage clean dist",
"build": "makage clean dist && makage build-ts && makage assets",
"prepublishOnly": "npm run build"
}
}
```

Or using the simplified pattern:

```json
{
"scripts": {
"copy": "makage assets",
"clean": "makage clean dist",
"build": "npm run clean && tsc && tsc -p tsconfig.esm.json && npm run copy"
}
}
```

## Commands

### `makage clean <path...>`

Recursively removes one or more paths.

```bash
makage clean dist build temp
```

### `makage copy [...sources] <dest> [--flat]`

Copy files to a destination directory.

- Use `--flat` to copy files directly into the destination without preserving directory structure
- Last argument is the destination, all others are sources

```bash
makage copy ../../LICENSE README.md dist --flat
```

### `makage readme-footer --source <file> --footer <file> --dest <file>`

Concatenate a README with a footer file, separated by a horizontal rule.

```bash
makage readme-footer --source README.md --footer FOOTER.md --dest dist/README.md
```

### `makage assets`

Combines common asset copying tasks:
1. Copies `../../LICENSE` to `dist/`
2. Copies `package.json` to `dist/`
3. Concatenates `README.md` + `FOOTER.md` into `dist/README.md`

```bash
makage assets
```

### `makage build-ts`

Runs TypeScript compilation for both CommonJS and ESM:
1. `tsc` (CommonJS)
2. `tsc -p tsconfig.esm.json` (ESM)

```bash
makage build-ts
```

### `makage update-workspace`

Updates all internal package dependencies to use the `workspace:*` protocol. This is useful in monorepos when you want to ensure all cross-package references use workspace linking.

Run from the monorepo root:

```bash
makage update-workspace
```

This will:
1. Scan all packages in the `packages/` directory
2. Identify internal package names
3. Update all dependencies, devDependencies, peerDependencies, and optionalDependencies
4. Convert version numbers to `workspace:*` for internal packages

## Programmatic Usage

You can also use `makage` commands programmatically:

```typescript
import { runCopy, runClean, runAssets } from 'makage';

async function build() {
await runClean(['dist']);
// ... your build steps
await runAssets([]);
}
```

## Why makage?

Most monorepo packages need the same basic build operations:
- Clean output directories
- Copy LICENSE and README to distribution
- Build TypeScript for both CJS and ESM

Instead of installing multiple dependencies (`cpy`, `rimraf`, etc.) in every package, `makage` provides these essentials with zero dependencies, using only Node.js built-in modules.

## Development

When first cloning the repo:

```bash
pnpm install
pnpm build
```

Run tests:

```bash
pnpm test
```
31 changes: 31 additions & 0 deletions packages/makage/__tests__/clean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import fs from 'node:fs/promises';
import { runClean } from '../src/commands/clean';

jest.mock('node:fs/promises');

const mockedFs = fs as jest.Mocked<typeof fs>;

describe('runClean', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedFs.rm.mockResolvedValue(undefined);
});

it('should remove a single path', async () => {
await runClean(['dist']);

expect(mockedFs.rm).toHaveBeenCalledWith('dist', { recursive: true, force: true });
});

it('should remove multiple paths', async () => {
await runClean(['dist', 'build', 'temp']);

expect(mockedFs.rm).toHaveBeenCalledWith('dist', { recursive: true, force: true });
expect(mockedFs.rm).toHaveBeenCalledWith('build', { recursive: true, force: true });
expect(mockedFs.rm).toHaveBeenCalledWith('temp', { recursive: true, force: true });
});

it('should throw error if no paths provided', async () => {
await expect(runClean([])).rejects.toThrow('clean requires at least one path');
});
});
47 changes: 47 additions & 0 deletions packages/makage/__tests__/copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from 'node:fs/promises';
import { runCopy } from '../src/commands/copy';

jest.mock('node:fs/promises');

const mockedFs = fs as jest.Mocked<typeof fs>;

describe('runCopy', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.copyFile.mockResolvedValue(undefined);
mockedFs.stat.mockResolvedValue({
isDirectory: () => false,
} as any);
});

it('should copy a single file to destination', async () => {
await runCopy(['README.md', 'dist', '--flat']);

expect(mockedFs.mkdir).toHaveBeenCalledWith('dist', { recursive: true });
expect(mockedFs.copyFile).toHaveBeenCalledWith('README.md', 'dist/README.md');
});

it('should copy multiple files to destination', async () => {
await runCopy(['file1.txt', 'file2.txt', 'dist', '--flat']);

expect(mockedFs.copyFile).toHaveBeenCalledWith('file1.txt', 'dist/file1.txt');
expect(mockedFs.copyFile).toHaveBeenCalledWith('file2.txt', 'dist/file2.txt');
});

it('should throw error if less than 2 arguments', async () => {
await expect(runCopy(['onlyDest'])).rejects.toThrow(
'copy requires at least one source and one destination'
);
});

it('should throw error if source is a directory', async () => {
mockedFs.stat.mockResolvedValue({
isDirectory: () => true,
} as any);

await expect(runCopy(['somedir', 'dist'])).rejects.toThrow(
'Directories not yet supported as sources: somedir'
);
});
});
43 changes: 43 additions & 0 deletions packages/makage/__tests__/readmeFooter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fs from 'node:fs/promises';
import { runReadmeFooter } from '../src/commands/readmeFooter';

jest.mock('node:fs/promises');

const mockedFs = fs as jest.Mocked<typeof fs>;

describe('runReadmeFooter', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.writeFile.mockResolvedValue(undefined);
});

it('should concatenate README and FOOTER files', async () => {
mockedFs.readFile
.mockResolvedValueOnce('# README Content\n\nSome text' as any)
.mockResolvedValueOnce('## Footer\n\nFooter text' as any);

await runReadmeFooter([
'--source',
'README.md',
'--footer',
'FOOTER.md',
'--dest',
'dist/README.md',
]);

expect(mockedFs.readFile).toHaveBeenCalledWith('README.md', 'utf8');
expect(mockedFs.readFile).toHaveBeenCalledWith('FOOTER.md', 'utf8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(
'dist/README.md',
'# README Content\n\nSome text\n\n---\n\n## Footer\n\nFooter text\n',
'utf8'
);
});

it('should throw error if missing required arguments', async () => {
await expect(runReadmeFooter(['--source', 'README.md'])).rejects.toThrow(
'readme-footer requires --source <file> --footer <file> --dest <file>'
);
});
});
18 changes: 18 additions & 0 deletions packages/makage/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
};
Loading