Skip to content

Commit ef19a82

Browse files
committed
Fix multiline exports and imports
1 parent 1208cbd commit ef19a82

File tree

11 files changed

+532
-37
lines changed

11 files changed

+532
-37
lines changed

eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const init = require('eslint-config-metarhia');
55
module.exports = [
66
...init,
77
{
8-
files: ['*.mjs'],
8+
files: ['*.mjs', 'test/fixtures/lib/test4.js'],
99
languageOptions: {
1010
sourceType: 'module',
1111
},

lib/process-exports.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict';
2+
3+
const MODULE_EXPORTS_PATTERN =
4+
/module\.exports\s*=\s*(\{([\s\S]+?)\}|(\w+));?\s*$/m;
5+
6+
const splitExports = (exports) =>
7+
exports
8+
.split(',')
9+
.map((line) => line.trim())
10+
.filter((line) => line !== '');
11+
12+
const transformToESM = (identifier, exports) => {
13+
if (identifier) return `export { ${identifier} };`;
14+
15+
const exportNames = splitExports(exports);
16+
if (exportNames.length === 1) return `export { ${exportNames[0]} };`;
17+
18+
const exportsList = exportNames.map((name) => ` ${name}`).join(',\n');
19+
return `export {\n${exportsList},\n};`;
20+
};
21+
22+
const transformToESMExport = (content) =>
23+
content.replace(
24+
new RegExp(MODULE_EXPORTS_PATTERN, 'gm'),
25+
(match, fullMatch, exports, identifier) =>
26+
transformToESM(identifier, exports),
27+
);
28+
29+
module.exports = {
30+
transformToESMExport,
31+
};

lib/process-imports.js

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
'use strict';
2+
3+
const { builtinModules } = require('node:module');
4+
5+
const NODE_BUILTINS = new Set(
6+
builtinModules
7+
.map((name) => (name.startsWith('node:') ? name.slice(5) : name))
8+
.filter(Boolean),
9+
);
10+
11+
const IMPORT_PATTERNS = {
12+
REQUIRE_DESTRUCTURING: new RegExp(
13+
'(?:const|let|var)\\s*\\{([^}]+)\\}\\s*=\\s*' +
14+
'require\\(\\s*[\'"]([^\'"]+)[\'"]\\s*\\);?',
15+
'g',
16+
),
17+
REQUIRE_ASSIGNMENT: new RegExp(
18+
'(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*' +
19+
'require\\(\\s*[\'"]([^\'"]+)[\'"]\\s*\\);?',
20+
'g',
21+
),
22+
REQUIRE_SIDE_EFFECT: /^require\(\s*['"]([^'"]+)['"]\s*\);?\s*$/gm,
23+
IMPORT_DESTRUCTURING: new RegExp(
24+
'import\\s*\\{([^}]+)\\}\\s*from\\s*[\'"]([^\'"]+)[\'"];?',
25+
'g',
26+
),
27+
IMPORT_ASSIGNMENT: new RegExp(
28+
'import\\s+([A-Za-z_$][\\w$]*)\\s*from\\s*[\'"]([^\'"]+)[\'"];?',
29+
'g',
30+
),
31+
};
32+
33+
const parseDestructuringBindings = (bindings) => {
34+
const parts = bindings
35+
.split(',')
36+
.map((p) => p.trim())
37+
.map((part) => {
38+
const localName = part.split('=')[0].trim();
39+
if (localName === '') return part;
40+
41+
const colonIndex = localName.indexOf(':');
42+
if (colonIndex !== -1) {
43+
return localName.slice(0, colonIndex).trim();
44+
}
45+
46+
if (localName.startsWith('...')) return '';
47+
48+
return localName;
49+
})
50+
.filter(Boolean);
51+
52+
return parts;
53+
};
54+
55+
const ensureImportEntry = (importRegistry, specifier) => {
56+
let entry = importRegistry.get(specifier);
57+
if (!entry) {
58+
entry = {
59+
defaultNames: new Set(),
60+
named: new Set(),
61+
sideEffect: false,
62+
};
63+
importRegistry.set(specifier, entry);
64+
}
65+
return entry;
66+
};
67+
68+
const addDefaultImport = (importRegistry, specifier, localName) => {
69+
const entry = ensureImportEntry(importRegistry, specifier);
70+
entry.defaultNames.add(localName);
71+
};
72+
73+
const addNamedImport = (importRegistry, specifier, localName) => {
74+
const entry = ensureImportEntry(importRegistry, specifier);
75+
entry.named.add(localName);
76+
};
77+
78+
const addSideEffectImport = (importRegistry, specifier) => {
79+
const entry = ensureImportEntry(importRegistry, specifier);
80+
entry.sideEffect = true;
81+
};
82+
83+
const isRelativePath = (specifier) =>
84+
specifier.startsWith('./') ||
85+
specifier.startsWith('../') ||
86+
specifier.startsWith('../../');
87+
88+
const validateSpecifier = (specifier, filename, lineNumber, line) => {
89+
if (specifier.startsWith('node:')) {
90+
const msg =
91+
`Node built-in require is not allowed in bundle sources: ` +
92+
`${filename}:${lineNumber}: ${line.trim()}`;
93+
console.error(msg);
94+
return { valid: false, shouldKeep: false, isExternal: false };
95+
}
96+
97+
if (isRelativePath(specifier)) {
98+
return { valid: true, shouldKeep: false, isExternal: false };
99+
}
100+
101+
if (NODE_BUILTINS.has(specifier)) {
102+
const msg =
103+
`Node built-in module imported from bundle source: ` +
104+
`${filename}:${lineNumber}: ${specifier}`;
105+
console.warn(msg);
106+
return { valid: true, shouldKeep: false, isExternal: false };
107+
}
108+
109+
return { valid: true, shouldKeep: false, isExternal: true };
110+
};
111+
112+
const processMatch = (patternName, match, filename, importRegistry) => {
113+
let specifier, bindings, localName;
114+
115+
switch (patternName) {
116+
case 'REQUIRE_DESTRUCTURING':
117+
case 'IMPORT_DESTRUCTURING':
118+
bindings = match[1];
119+
specifier = match[2];
120+
break;
121+
case 'REQUIRE_ASSIGNMENT':
122+
case 'IMPORT_ASSIGNMENT':
123+
localName = match[1];
124+
specifier = match[2];
125+
break;
126+
case 'REQUIRE_SIDE_EFFECT':
127+
specifier = match[1];
128+
break;
129+
}
130+
131+
const validation = validateSpecifier(specifier, filename, 0, match[0]);
132+
if (!validation.valid) return;
133+
134+
if (isRelativePath(specifier) || !validation.isExternal) return;
135+
136+
if (patternName.includes('DESTRUCTURING')) {
137+
const parsedBindings = parseDestructuringBindings(bindings);
138+
for (const name of parsedBindings) {
139+
addNamedImport(importRegistry, specifier, name);
140+
}
141+
} else if (patternName.includes('ASSIGNMENT')) {
142+
addDefaultImport(importRegistry, specifier, localName);
143+
} else if (patternName === 'REQUIRE_SIDE_EFFECT') {
144+
addSideEffectImport(importRegistry, specifier);
145+
}
146+
};
147+
148+
const processImports = (content, filename, importRegistry) => {
149+
let result = content;
150+
151+
for (const [patternName, pattern] of Object.entries(IMPORT_PATTERNS)) {
152+
const regex = new RegExp(pattern.source, pattern.flags);
153+
let match;
154+
while ((match = regex.exec(content)) !== null) {
155+
processMatch(patternName, match, filename, importRegistry);
156+
}
157+
result = result.replace(pattern, '\n');
158+
}
159+
160+
return result;
161+
};
162+
163+
const generateImportStatements = (importRegistry) => {
164+
if (importRegistry.size === 0) return '';
165+
const lines = [];
166+
167+
for (const [depName, entry] of importRegistry.entries()) {
168+
const specifier = `./${depName}.js`;
169+
if (entry.sideEffect) lines.push(`import '${specifier}';`);
170+
171+
const namedParts = Array.from(entry.named);
172+
173+
if (entry.defaultNames.size === 0 && namedParts.length === 0) continue;
174+
175+
if (entry.defaultNames.size <= 1) {
176+
const defaultName =
177+
entry.defaultNames.size === 1 ? [...entry.defaultNames][0] : null;
178+
if (defaultName && namedParts.length > 0) {
179+
const stmt =
180+
`import ${defaultName}, { ${namedParts.join(', ')} } ` +
181+
`from '${specifier}';`;
182+
lines.push(stmt);
183+
} else if (defaultName) {
184+
lines.push(`import ${defaultName} from '${specifier}';`);
185+
} else {
186+
lines.push(`import { ${namedParts.join(', ')} } from '${specifier}';`);
187+
}
188+
continue;
189+
}
190+
191+
for (const defaultName of entry.defaultNames) {
192+
lines.push(`import ${defaultName} from '${specifier}';`);
193+
}
194+
if (namedParts.length > 0) {
195+
lines.push(`import { ${namedParts.join(', ')} } from '${specifier}';`);
196+
}
197+
}
198+
199+
return lines.join('\n') + '\n\n';
200+
};
201+
202+
module.exports = {
203+
processImports,
204+
generateImportStatements,
205+
};

metarhia-build.js

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,37 @@
33

44
const fs = require('node:fs');
55
const path = require('node:path');
6+
const {
7+
processImports,
8+
generateImportStatements,
9+
} = require('./lib/process-imports');
10+
const { transformToESMExport } = require('./lib/process-exports');
611

7-
const moduleExportsPattern = /module\.exports\s*=\s*(\{([^}]+)\}|(\w+));?\s*$/m;
8-
9-
const convertModuleExports = (match, fullMatch, exports, identifier) => {
10-
if (identifier) return `export { ${identifier} };`;
11-
const exportNames = exports
12-
.split(',')
13-
.map((line) => line.trim())
14-
.filter((line) => line !== '');
15-
if (exportNames.length === 1) return `export { ${exportNames[0]} };`;
16-
const exportsList = exportNames.map((name) => ` ${name}`).join(',\n');
17-
return `export {\n${exportsList},\n};`;
18-
};
12+
const importRegistry = new Map();
1913

2014
const processFile = (libDir, filename) => {
2115
const filePath = path.join(libDir, filename);
2216
let content = fs.readFileSync(filePath, 'utf8');
23-
content = content.replace(`'use strict';\n\n`, '');
24-
const lines = content.split('\n');
25-
const filteredLines = [];
26-
for (let i = 0; i < lines.length; i++) {
27-
const line = lines[i];
28-
if (line.includes('require(')) continue;
29-
filteredLines.push(line);
30-
}
31-
content = filteredLines.join('\n');
32-
content = content.replace(moduleExportsPattern, convertModuleExports);
17+
content = content.replace(/'use strict';?\n{0,2}/g, '');
18+
content = processImports(content, filename, importRegistry);
19+
content = transformToESMExport(content);
3320
return content;
3421
};
3522

23+
const generateBundleHeader = (packageJson, cwd) => {
24+
const licenseText = fs.readFileSync(path.join(cwd, 'LICENSE'), 'utf8');
25+
const licenseLines = licenseText.split('\n');
26+
const licenseName = licenseLines[0];
27+
const copyrightLine = licenseLines[2];
28+
const packageName = packageJson.name.split('/').pop();
29+
30+
return (
31+
`// Generated by metarhia-build. Don't edit this file.\n` +
32+
`// ${copyrightLine}\n` +
33+
`// Version ${packageJson.version} ${packageName} ${licenseName}\n\n`
34+
);
35+
};
36+
3637
const build = (cwd) => {
3738
const buildConfigPath = path.join(cwd, 'build.json');
3839
console.log({ buildConfigPath });
@@ -46,21 +47,19 @@ const build = (cwd) => {
4647
const packageJson = JSON.parse(packageJsonContent);
4748
const packageName = packageJson.name.split('/').pop();
4849
const outputFile = path.join(cwd, `${packageName}.mjs`);
49-
const licenseText = fs.readFileSync(path.join(cwd, 'LICENSE'), 'utf8');
50-
const licenseLines = licenseText.split('\n');
51-
const licenseName = licenseLines[0];
52-
const copyrightLine = licenseLines[2];
5350

54-
const header =
55-
`// ${copyrightLine}\n` +
56-
`// Version ${packageJson.version} ${packageName} ${licenseName}\n\n`;
5751
const bundle = [];
5852
for (const filename of fileOrder) {
5953
const content = processFile(libDir, filename);
6054
bundle.push(`// ${filename}\n`);
6155
bundle.push(content + '\n');
6256
}
63-
const content = header + bundle.join('\n').replaceAll('\n\n\n', '\n\n');
57+
58+
const header = generateBundleHeader(packageJson, cwd);
59+
const importsBlock = generateImportStatements(importRegistry);
60+
61+
const content =
62+
header + importsBlock + bundle.join('\n').replaceAll(/\n{3,}/g, '\n\n');
6463
fs.writeFileSync(outputFile, content, 'utf8');
6564
console.log(`Bundle created: ${outputFile}`);
6665
};

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
"name": "metarhia-build",
33
"version": "0.0.1",
44
"author": "Timur Shemsedinov <timur.shemsedinov@gmail.com>",
5+
"contributors": [
6+
"Oleksandr Korneiko <oleksandr.korneiko@gmail.com>"
7+
],
58
"license": "MIT",
6-
"description": "Metarhia Module Builder",
9+
"description": "Metarhia Module Builder - bundles metarhia libs into a single ES module file",
710
"keywords": [
811
"node.js",
912
"metarhia",

0 commit comments

Comments
 (0)