Skip to content
Closed
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
51 changes: 51 additions & 0 deletions lib/process-exports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const EXPORT_PATTERNS = [
{
type: 'named',
regex: /module\.exports\s*=\s*\{([\s\S]+?)\};?\s*$/gm,
},
{
type: 'default',
regex: /module\.exports\s*=\s*(\w+);?\s*$/gm,
},
];

const splitExportNames = (exports) =>
exports
.split(',')
.map((line) => line.trim())
.filter(Boolean);

const addExportNames = (names, exportRegistry) => {
for (const name of names) {
if (exportRegistry.has(name)) {
throw new Error(`Duplicate export: ${name}`);
}
exportRegistry.add(name);
}
};

const processExports = (content, exportRegistry) => {
let result = content;
for (const { type, regex } of EXPORT_PATTERNS) {
result = result.replace(regex, (match, captured) => {
const names = type === 'named' ? splitExportNames(captured) : [captured];
addExportNames(names, exportRegistry);
return '\n';
});
}
return result;
};

const generateExportStatements = (exportRegistry) => {
if (exportRegistry.size === 0) return '';
const names = Array.from(exportRegistry);
const exportsList = names.map((name) => ` ${name}`).join(',\n');
return `export {\n${exportsList},\n};\n`;
};

module.exports = {
processExports,
generateExportStatements,
};
152 changes: 152 additions & 0 deletions lib/process-imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
'use strict';

const { builtinModules } = require('node:module');

const NODE_BUILTINS = new Set(
builtinModules
.map((name) => (name.startsWith('node:') ? name.slice(5) : name))
.filter(Boolean),
);

const IMPORT_PATTERNS = [
{
type: 'named',
regex: new RegExp(
'(?:const|let|var)\\s*\\{(?<namedImports>[^}]+)\\}\\s*=\\s*' +
'require\\(\\s*[\'"](?<specifier>[^\'"]+)[\'"]\\s*\\);?',
'g',
),
},
{
type: 'default',
regex: new RegExp(
'(?:const|let|var)\\s+(?<defaultName>[A-Za-z_$][\\w$]*)\\s*=\\s*' +
'require\\(\\s*[\'"](?<specifier>[^\'"]+)[\'"]\\s*\\);?',
'g',
),
},
{
type: 'sideEffect',
regex: /^require\(\s*['"](?<specifier>[^'"]+)['"]\s*\);?\s*$/gm,
},
{
type: 'named',
regex: new RegExp(
'import\\s*\\{(?<namedImports>[^}]+)\\}\\s*from\\s*' +
'[\'"](?<specifier>[^\'"]+)[\'"];?',
'g',
),
},
{
type: 'default',
regex: new RegExp(
'import\\s+(?<defaultName>[A-Za-z_$][\\w$]*)\\s*from\\s*' +
'[\'"](?<specifier>[^\'"]+)[\'"];?',
'g',
),
},
];

const parseNamedImports = (bindings) => {
const parts = bindings
.split(',')
.map((p) => {
const part = p.trim();
const localName = part.split('=')[0].trim();
if (localName === '') return part;

const colonIndex = localName.indexOf(':');
if (colonIndex !== -1) {
return localName.slice(0, colonIndex).trim();
}

if (localName.startsWith('...')) return '';

return localName;
})
.filter(Boolean);

return parts;
};

const isValidSpecifier = (specifier, filename, importStatement) => {
if (specifier.startsWith('node:') || NODE_BUILTINS.has(specifier)) {
console.error(
`Node built-in module is not allowed in bundle sources: ` +
`${filename}: ${importStatement.trim()}`,
);
return false;
}

if (specifier.endsWith('.js')) return false;

return true;
};

const processImports = (content, filename, importRegistry) => {
let result = content;
for (const pattern of IMPORT_PATTERNS) {
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);

result = result.replace(regex, (match, ...args) => {
const groups = args[args.length - 1];
const { specifier, defaultName, namedImports } = groups;

if (isValidSpecifier(specifier, filename, match)) {
if (!importRegistry.has(specifier)) {
importRegistry.set(specifier, {
defaultNames: new Set(),
named: new Set(),
isSideEffect: false,
});
}
const entry = importRegistry.get(specifier);

if (pattern.type === 'default') {
entry.defaultNames.add(defaultName);
} else if (pattern.type === 'sideEffect') {
entry.isSideEffect = true;
} else if (pattern.type === 'named') {
for (const name of parseNamedImports(namedImports)) {
entry.named.add(name);
}
}
}
return '\n';
});
}
return result;
};

const generateEntryStatements = (
specifier,
{ isSideEffect, defaultNames, named },
) => {
const sideEffect = isSideEffect ? [`import '${specifier}';`] : [];

const defaultStmts = [...defaultNames].map(
(name) => `import ${name} from '${specifier}';`,
);

const namedStmt = named.size > 0
? [`import { ${[...named].join(', ')} } from '${specifier}';`]
: [];

return [...sideEffect, ...defaultStmts, ...namedStmt];
};

const generateImportStatements = (importRegistry) => {
if (importRegistry.size === 0) return '';
return (
Array.from(importRegistry.entries())
.flatMap(([depName, entry]) =>
generateEntryStatements(`./${depName}.js`, entry),
)
.join('\n') + '\n\n'
);
};

module.exports = {
processImports,
generateImportStatements,
};
69 changes: 38 additions & 31 deletions metarhia-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,41 @@

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

const moduleExportsPattern = /module\.exports\s*=\s*(\{([^}]+)\}|(\w+));?\s*$/m;

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};`;
};
const {
processImports,
generateImportStatements,
} = require('./lib/process-imports');
const {
processExports,
generateExportStatements,
} = require('./lib/process-exports');

const importRegistryMap = new Map();
const exportRegistrySet = new Set();

const processFile = (libDir, 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);
content = content.replace(/'use strict';?\n{0,2}/g, '');
content = processImports(content, filename, importRegistryMap);
content = processExports(content, exportRegistrySet);
return content;
};

const generateBundleHeader = (packageJson, cwd) => {
const licenseText = fs.readFileSync(path.join(cwd, 'LICENSE'), 'utf8');
const licenseLines = licenseText.split('\n');
const licenseName = licenseLines[0];
const copyrightLine = licenseLines[2];
const packageName = packageJson.name.split('/').pop();

return (
`// Generated by metarhia-build. Don't edit this file.\n` +
`// ${copyrightLine}\n` +
`// Version ${packageJson.version} ${packageName} ${licenseName}\n\n`
);
};

const build = (cwd) => {
const buildConfigPath = path.join(cwd, 'build.json');
console.log({ buildConfigPath });
Expand All @@ -46,21 +51,23 @@ const build = (cwd) => {
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 header =
`// ${copyrightLine}\n` +
`// Version ${packageJson.version} ${packageName} ${licenseName}\n\n`;
const bundle = [];
for (const filename of fileOrder) {
const content = processFile(libDir, filename);
bundle.push(`// ${filename}\n`);
bundle.push(content + '\n');
}
const content = header + bundle.join('\n').replaceAll('\n\n\n', '\n\n');

const header = generateBundleHeader(packageJson, cwd);
const importsBlock = generateImportStatements(importRegistryMap);
const exportsBlock = generateExportStatements(exportRegistrySet);

const content =
header +
importsBlock +
bundle.join('\n').replaceAll(/\n{3,}/g, '\n\n') +
exportsBlock;
fs.writeFileSync(outputFile, content, 'utf8');
console.log(`Bundle created: ${outputFile}`);
};
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
"name": "metarhia-build",
"version": "0.0.1",
"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",
Expand Down
10 changes: 5 additions & 5 deletions test/build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ test('build: creates bundle with correct structure', async () => {
assert.ok(output.includes('// test2.js'));
assert.ok(output.includes('// test3.js'));

// Check exports
assert.ok(output.includes('export { test1 };'));
assert.ok(output.includes('export { test2 };'));
// Check exports (single block at end of bundle)
assert.ok(output.includes('export {'));
assert.ok(output.includes(' test1,'));
assert.ok(output.includes(' test2,'));

// Check that require() calls are removed
assert.ok(!output.includes('require('));
Expand Down Expand Up @@ -100,7 +100,7 @@ test('build: converts single identifier module.exports', async () => {
const output = fs.readFileSync(outputFile, 'utf8');

// test2.js has module.exports = test2; (single identifier)
assert.ok(output.includes('export { test2 };'));
assert.ok(output.includes(' test2,'));

// Clean up
fs.unlinkSync(outputFile);
Expand All @@ -113,7 +113,7 @@ test('build: converts single property module.exports', async () => {
const output = fs.readFileSync(outputFile, 'utf8');

// test1.js has module.exports = { test1 };
assert.ok(output.includes('export { test1 };'));
assert.ok(output.includes(' test1,'));

// Clean up
fs.unlinkSync(outputFile);
Expand Down
Loading