Skip to content
Open
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
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
package.json
package-lock.json
.idea
.vscode
node_modules
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,34 @@ npm install --save-dev metarhia-build
}
```

2. Ensure you have a `lib/` directory with the source files listed in `build.json`
Only `order` field is required. But you can use other options to customize the build process.
Config options:

```ts
type Mode = 'lib' | 'iife' | 'app';
interface Config {
order: string[];
mode?: Mode; // default 'lib'
libDir?: string; // default 'lib'
licensePath?: string; // default 'LICENSE'
appStaticDir?: string; // default 'application/static'
outputDir?: string; // default current directory
}
```

- Default mode is `lib`. It just concantenate files in the order specified in `build.json` order field. And converts all requires to imports and puts import once at the top of the file.

- When `mode` is `iife`, it will bundle everything into `modulename.iife.js`. This is useful for browser service worker usage - import with `importScripts()`. If a module has a dependency, user must provide it in the app prior to the module import.

- When `mode` is `app`, order field should contain dependency names and thus should be installed and available in `node_modules` directory. Builder will symlink all dependencies from `node_modules` into `appStaticDir` directory (for libs with es and iife - both files will be linked).

```json
{
"order": ["metaschema", "metaqr", "metautil"]
}
```

2. Ensure you have a `lib/` directory with the source files listed in `build.json` or set valid `libDir` option.

3. Add a build script to your `package.json`:

Expand Down Expand Up @@ -53,13 +80,28 @@ module.exports = [
npm run build
```

Arguments:

- `-c` or `--config` - path to config file
- `-m` or `--mode` - mode to build - overrides mode in config file

```bash
npm run build -- -c ./path/to/build.other.json -m iife
# or
metarhia-build --c ./path/to/build.other.json
# or
metarhia-build --config ./path/to/build.other.json
```

This will:

- Read files from `lib/` directory in the order specified in `build.json`
- Read files from `lib/` (or `libDir` option) directory in the order specified in `build.json` order field.
- Convert `require()` calls to `import` statements
- Convert same `import` or `require` in multiple files to a single import at the top of the file.
- Convert CommonJS `module.exports` to ES6 `export` statements
- Remove `'use strict'` declarations
- Remove internal submodules `require()` calls
- Bundle everything into `modulename.mjs` with a header containing version and license information
- Remove internal submodules `require()` or `import` calls
- Bundle everything into `modulename.mjs` (or ouputDir/modulename.mjs) with a header containing version and license information. When `mode` is `iife` bundle name will be `modulename.iife.js`.

## License & Contributors

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const init = require('eslint-config-metarhia');
module.exports = [
...init,
{
files: ['*.mjs'],
files: ['*.mjs', 'test/fixtures/lib/test4.js'],
languageOptions: {
sourceType: 'module',
},
Expand Down
105 changes: 46 additions & 59 deletions metarhia-build.js
Original file line number Diff line number Diff line change
@@ -1,68 +1,55 @@
#!/usr/bin/env node
'use strict';

const fs = require('node:fs');
const path = require('node:path');

const cwd = process.cwd();
const buildConfigPath = path.join(cwd, 'build.json');
const buildConfigContent = fs.readFileSync(buildConfigPath, 'utf8');
const buildConfig = JSON.parse(buildConfigContent);
const fileOrder = buildConfig.order;

const libDir = path.join(cwd, 'lib');
const packageJsonPath = path.join(cwd, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
const packageName = packageJson.name.split('/').pop();
const outputFile = path.join(cwd, `${packageName}.mjs`);
const licenseText = fs.readFileSync(path.join(cwd, 'LICENSE'), 'utf8');
const licenseLines = licenseText.split('\n');
const licenseName = licenseLines[0];
const copyrightLine = licenseLines[2];
const { parseArgs } = require('node:util');
const {
loadConfig,
loadPackageJson,
loadLicense,
validateSourceFiles,
} = require('./src/config/loader');
const { modeExecutors } = require('./src/modes');
const logger = require('./src/utils/logger');

const main = async () => {
const { values: args } = parseArgs({
options: {
config: { type: 'string', short: 'c' },
mode: { type: 'string', short: 'm' },
},
allowPositionals: true,
});

const cwd = process.cwd();
const configPath = args.config || 'build.json';
const configOverride = { mode: args.mode };

const config = loadConfig(configPath, cwd, configOverride);
const packageJson = loadPackageJson(cwd);
const license = loadLicense(cwd, config.licensePath);

if (['lib', 'iife'].includes(config.mode)) {
validateSourceFiles(config.order, config.libDir, cwd);
}

const moduleExportsPattern = /module\.exports\s*=\s*(\{([^}]+)\}|(\w+));?\s*$/m;
const executor = modeExecutors[config.mode];
if (!executor) {
throw new Error(`Unknown mode: ${config.mode}`);
}

const convertModuleExports = (match, fullMatch, exports, identifier) => {
if (identifier) return `export { ${identifier} };`;
const exportNames = exports
.split(',')
.map((line) => line.trim())
.filter((line) => line !== '');
if (exportNames.length === 1) return `export { ${exportNames[0]} };`;
const exportsList = exportNames.map((name) => ` ${name}`).join(',\n');
return `export {\n${exportsList},\n};`;
await executor(config, packageJson, license);
};

const processFile = (filename) => {
const filePath = path.join(libDir, filename);
let content = fs.readFileSync(filePath, 'utf8');
content = content.replace(`'use strict';\n\n`, '');
const lines = content.split('\n');
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes('require(')) continue;
filteredLines.push(line);
}
content = filteredLines.join('\n');
content = content.replace(moduleExportsPattern, convertModuleExports);
return content;
};
if (!process.stdin.isTTY && process.env.NODE_ENV !== 'test') {
// Running as a script file or input is redirected
// Do nothing
return;
}

const build = () => {
const header =
`// ${copyrightLine}\n` +
`// Version ${packageJson.version} ${packageName} ${licenseName}\n\n`;
const bundle = [];
for (const filename of fileOrder) {
const content = processFile(filename);
bundle.push(`// ${filename}\n`);
bundle.push(content + '\n');
main().catch((error) => {
logger.error(`Build failed: ${error.message}`);
if (error.stack) {
logger.error(error.stack);
}
const content = header + bundle.join('\n').replaceAll('\n\n\n', '\n\n');
fs.writeFileSync(outputFile, content, 'utf8');
console.log(`Bundle created: ${outputFile}`);
};

build();
process.exit(1);
});
50 changes: 50 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@
"name": "metarhia-build",
"version": "0.0.0",
"author": "Timur Shemsedinov <timur.shemsedinov@gmail.com>",
"contributors": [
"Oleksandr Korneiko <oleksandr.korneiko@gmail.com>"
],
"license": "MIT",
"description": "Metarhia Module Builder",
"description": "Metarhia Module Builder - bundles metarhia libs into a single ES module file",
"keywords": [
"node.js",
"metarhia",
"module",
"builder"
],
"main": "metarhia-build.js",
"files": [
"metarhia-build.js",
"src/*"
],
"bin": {
"metarhia-build": "./metarhia-build.js"
"metarhia-build": "metarhia-build.js"
},
"engines": {
"node": ">=18"
Expand All @@ -39,5 +46,8 @@
"eslint": "^9.35.0",
"eslint-config-metarhia": "^9.1.5",
"prettier": "^3.3.3"
},
"dependencies": {
"metaschema": "^2.2.2"
}
}
71 changes: 71 additions & 0 deletions src/bundler/bundler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';

const { resolveFilePath, readFileSync } = require('../utils/file-utils');
const {
processImports,
generateImportStatements,
} = require('../transforms/imports');
const {
transformToESMExport,
extractExportNames,
removeExports,
} = require('../transforms/exports');
const { generateBundleHeader, wrapInRegion } = require('./regions');

class Bundler {
constructor(config, packageJson, license) {
this.config = config;
this.packageJson = packageJson;
this.license = license;
this.importRegistry = new Map();
this.exportNames = [];
}

processFile(filename) {
const filePath = resolveFilePath(
this.config.cwd,
this.config.libDir,
filename,
);
let content = readFileSync(filePath, `processing ${filename}`);

content = content.replace(/'use strict';?\n{0,2}/g, '');
content = processImports(content, filename, this.importRegistry);

if (this.config.mode === 'iife') {
const names = extractExportNames(content);
this.exportNames.push(...names);
content = removeExports(content);
} else {
content = transformToESMExport(content, this.config.mode);
}

return content;
}

generateBundle() {
const header = generateBundleHeader(
this.packageJson,
this.license.licenseName,
this.license.copyrightLine,
);

const chunks = this.config.order.map((filename) => {
const content = this.processFile(filename);
return wrapInRegion(filename, content);
});

const importsBlock = generateImportStatements(this.importRegistry);
const bundleContent = chunks.join('\n').replaceAll(/\n{3,}/g, '\n\n');

return {
header,
importsBlock,
bundleContent,
importRegistry: this.importRegistry,
exportNames: this.exportNames,
};
}
}

module.exports = { Bundler };
Loading
Loading