vanilla-builder 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // gzip y brotli asíncronos (en el pool de hilos de libuv) y calculados una sola vez por contenido:
2
+ // el informe de tamaños y los archivos .gz/.br precomprimidos comparten el mismo resultado.
3
+ import zlib from 'node:zlib';
4
+ import { promisify } from 'node:util';
5
+
6
+ const gzip = promisify(zlib.gzip);
7
+ const brotli = promisify(zlib.brotliCompress);
8
+ const cache = new WeakMap();
9
+
10
+ function entry(buf) {
11
+ let e = cache.get(buf);
12
+ if (!e) cache.set(buf, (e = {}));
13
+ return e;
14
+ }
15
+
16
+ export function gzipOf(buf) {
17
+ const e = entry(buf);
18
+ return (e.gzip ??= gzip(buf, { level: 9 }));
19
+ }
20
+
21
+ export function brotliOf(buf) {
22
+ const e = entry(buf);
23
+ return (e.brotli ??= brotli(buf, {
24
+ params: {
25
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
26
+ [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
27
+ [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
28
+ },
29
+ }));
30
+ }
31
+
32
+ /** Ejecuta fn sobre cada elemento con como mucho `limit` tareas a la vez; conserva el orden del resultado. */
33
+ export async function mapLimit(items, limit, fn) {
34
+ const out = new Array(items.length);
35
+ let next = 0;
36
+ const lane = async () => {
37
+ while (next < items.length) {
38
+ const i = next++;
39
+ out[i] = await fn(items[i], i);
40
+ }
41
+ };
42
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, lane));
43
+ return out;
44
+ }
package/src/config.js CHANGED
@@ -1,190 +1,280 @@
1
- // Carga, combina y valida la configuración (builder.config.json / .mjs + opciones de la CLI).
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { pathToFileURL } from 'node:url';
5
- import { BuildError } from './util.js';
6
-
7
- export const CONFIG_FILES = ['builder.config.json', 'builder.config.mjs', 'builder.config.js'];
8
-
9
- export const DEFAULTS = {
10
- root: '.',
11
- outDir: 'dist',
12
- entries: 'auto',
13
- include: [],
14
- ignore: [],
15
- defaultIgnore: true,
16
- unreferenced: 'process',
17
- base: '/',
18
- hash: false,
19
- sourcemap: false,
20
- clean: true,
21
- verify: true,
22
- sri: 'auto',
23
- html: { minify: true, removeComments: true, collapseWhitespace: 'auto' },
24
- css: { minify: true, bundle: true, target: 'esnext' },
25
- js: {
26
- minify: true,
27
- bundle: true,
28
- target: 'auto',
29
- mergeClassic: 'auto',
30
- keepNames: false,
31
- drop: [],
32
- legalComments: 'eof',
33
- define: {},
34
- chunkDir: 'assets',
35
- minifier: 'esbuild',
36
- },
37
- json: { minify: true },
38
- obfuscate: false,
39
- // Librerías de terceros: se detectan solas y no se ofuscan salvo que se pida.
40
- dependencies: { detect: true, obfuscate: false, include: [], exclude: [] },
41
- compress: false,
42
- };
43
-
44
- const NESTED = ['html', 'css', 'js', 'json', 'dependencies'];
45
- const ENUMS = {
46
- unreferenced: ['process', 'copy', 'skip'],
47
- sri: ['auto', true, false],
48
- sourcemap: [false, true, 'hidden', 'inline'],
49
- 'js.mergeClassic': ['auto', true, false],
50
- 'js.legalComments': ['none', 'inline', 'eof'],
51
- 'html.collapseWhitespace': ['auto', true, false],
52
- 'js.minifier': ['esbuild', 'terser'],
53
- compress: [false, true, 'gzip', 'brotli'],
54
- };
55
- const OBF_PRESETS = ['low', 'medium', 'high', 'max'];
56
-
57
- function levenshtein(a, b) {
58
- const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
59
- for (let j = 1; j <= b.length; j++) d[0][j] = j;
60
- for (let i = 1; i <= a.length; i++)
61
- for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
62
- return d[a.length][b.length];
63
- }
64
-
65
- function suggest(key, options) {
66
- const best = options.map((o) => [o, levenshtein(key, o)]).sort((x, y) => x[1] - y[1])[0];
67
- return best && best[1] <= 3 ? ` ¿Quisiste decir "${best[0]}"?` : '';
68
- }
69
-
70
- function checkKeys(obj, defaults, prefix, errors) {
71
- for (const key of Object.keys(obj)) {
72
- if (key === '$schema') continue;
73
- if (!(key in defaults)) errors.push(`Opción desconocida "${prefix}${key}".${suggest(key, Object.keys(defaults))}`);
74
- }
75
- }
76
-
77
- const asList = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
78
-
79
- export function normalizeConfig(raw, errors = []) {
80
- const user = raw ?? {};
81
- if (typeof user !== 'object' || Array.isArray(user)) throw new BuildError('La configuración debe ser un objeto JSON.');
82
- checkKeys(user, DEFAULTS, '', errors);
83
- const cfg = { ...DEFAULTS, ...user };
84
- for (const k of NESTED) {
85
- const v = user[k];
86
- if (v === false) cfg[k] = k === 'dependencies' ? { ...DEFAULTS[k], detect: false } : { ...DEFAULTS[k], minify: false };
87
- else if (v === true || v == null) cfg[k] = { ...DEFAULTS[k] };
88
- else if (typeof v === 'object') {
89
- checkKeys(v, DEFAULTS[k], `${k}.`, errors);
90
- cfg[k] = { ...DEFAULTS[k], ...v };
91
- } else errors.push(`"${k}" debe ser un objeto o true/false.`);
92
- }
93
- for (const k of ['include', 'ignore']) {
94
- cfg[k] = asList(cfg[k]);
95
- if (cfg[k].some((x) => typeof x !== 'string')) errors.push(`"${k}" debe ser una lista de rutas o patrones (texto).`);
96
- }
97
- for (const k of ['include', 'exclude']) {
98
- cfg.dependencies[k] = asList(cfg.dependencies[k]);
99
- if (cfg.dependencies[k].some((x) => typeof x !== 'string')) errors.push(`"dependencies.${k}" debe ser una lista de rutas o patrones (texto).`);
100
- }
101
- for (const k of ['detect', 'obfuscate']) if (typeof cfg.dependencies[k] !== 'boolean') errors.push(`"dependencies.${k}" debe ser true o false.`);
102
- if (cfg.entries !== 'auto') {
103
- cfg.entries = asList(cfg.entries);
104
- if (!cfg.entries.length || cfg.entries.some((x) => typeof x !== 'string'))
105
- errors.push('"entries" debe ser "auto" o una lista de archivos/patrones.');
106
- }
107
- for (const [key, allowed] of Object.entries(ENUMS)) {
108
- const [a, b] = key.split('.');
109
- const value = b ? cfg[a][b] : cfg[a];
110
- if (!allowed.includes(value)) errors.push(`"${key}" debe ser uno de: ${allowed.map((x) => JSON.stringify(x)).join(', ')}.`);
111
- }
112
- if (typeof cfg.outDir !== 'string' || !cfg.outDir.trim()) errors.push('"outDir" debe ser una ruta.');
113
- if (typeof cfg.base !== 'string' || !cfg.base.startsWith('/')) errors.push('"base" debe empezar con "/" (ej. "/" o "/mi-sitio/").');
114
- else if (!cfg.base.endsWith('/')) cfg.base += '/';
115
- if (typeof cfg.hash !== 'boolean') errors.push('"hash" debe ser true o false.');
116
- cfg.js.drop = asList(cfg.js.drop);
117
- if (cfg.js.drop.some((d) => !['console', 'debugger'].includes(d))) errors.push('"js.drop" solo admite "console" y "debugger".');
118
- if (typeof cfg.js.chunkDir !== 'string' || /(^|\/)\.\.(\/|$)|^\//.test(cfg.js.chunkDir))
119
- errors.push('"js.chunkDir" debe ser una carpeta relativa.');
120
- for (const k of ['css', 'js']) {
121
- const t = cfg[k].target;
122
- if (!(typeof t === 'string' || (Array.isArray(t) && t.every((x) => typeof x === 'string'))))
123
- errors.push(`"${k}.target" debe ser texto o lista (ej. "es2020" o ["chrome90","safari15"]).`);
124
- }
125
- const o = cfg.obfuscate;
126
- if (o === true) cfg.obfuscate = 'medium';
127
- else if (!(o === false || OBF_PRESETS.includes(o) || (o && typeof o === 'object' && !Array.isArray(o))))
128
- errors.push(`"obfuscate" debe ser false, true, ${OBF_PRESETS.map((p) => `"${p}"`).join(', ')} o un objeto de opciones de javascript-obfuscator.`);
129
- return cfg;
130
- }
131
-
132
- async function readConfigFile(file) {
133
- if (file.endsWith('.json')) {
134
- const text = fs.readFileSync(file, 'utf8').replace(/^/, '');
135
- try {
136
- return JSON.parse(text);
137
- } catch (err) {
138
- throw new BuildError(`No pude leer ${path.basename(file)}: JSON inválido (${err.message}).`);
139
- }
140
- }
141
- const mod = await import(`${pathToFileURL(file).href}?t=${fs.statSync(file).mtimeMs}`);
142
- return typeof mod.default === 'function' ? await mod.default() : mod.default;
143
- }
144
-
145
- export function findConfigFile(dir) {
146
- for (const name of CONFIG_FILES) {
147
- const file = path.join(dir, name);
148
- if (fs.existsSync(file)) return file;
149
- }
150
- return null;
151
- }
152
-
153
- /**
154
- * Resuelve la configuración final.
155
- * @param {{dir?: string, config?: string, overrides?: object, cwd?: string}} opts
156
- */
157
- export async function loadConfig({ dir = '.', config, overrides = {}, cwd = process.cwd() } = {}) {
158
- const projectDir = path.resolve(cwd, dir);
159
- if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) throw new BuildError(`La carpeta del proyecto no existe: ${projectDir}`);
160
- const configFile = config ? path.resolve(cwd, config) : findConfigFile(projectDir);
161
- if (config && !fs.existsSync(configFile)) throw new BuildError(`No existe el archivo de configuración: ${configFile}`);
162
- const fileCfg = configFile ? await readConfigFile(configFile) : {};
163
- const merged = mergeOverrides(fileCfg ?? {}, overrides);
164
- const errors = [];
165
- const cfg = normalizeConfig(merged, errors);
166
- if (errors.length) throw new BuildError('La configuración tiene errores:', errors);
167
- const baseDir = configFile ? path.dirname(configFile) : projectDir;
168
- cfg.root = path.resolve(baseDir, cfg.root);
169
- if (!fs.existsSync(cfg.root)) throw new BuildError(`"root" no existe: ${cfg.root}`);
170
- // esbuild resuelve los enlaces simbólicos de lo que importa; si la raíz no es la ruta real, el mismo
171
- // módulo aparece con dos rutas (pasa en macOS con /tmp → /private/tmp, o al abrir el proyecto por un enlace).
172
- cfg.root = fs.realpathSync(cfg.root);
173
- cfg.outDir = path.resolve(cfg.root, cfg.outDir);
174
- cfg.configFile = configFile;
175
- return cfg;
176
- }
177
-
178
- function mergeOverrides(base, over) {
179
- const out = { ...base };
180
- for (const [k, v] of Object.entries(over)) {
181
- if (v === undefined) continue;
182
- if (NESTED.includes(k) && v && typeof v === 'object' && !Array.isArray(v)) {
183
- const prev = base[k];
184
- out[k] = { ...(prev && typeof prev === 'object' ? prev : prev === false ? { minify: false } : {}), ...v };
185
- } else if (['include', 'ignore'].includes(k)) {
186
- out[k] = [...asList(base[k]), ...asList(v)];
187
- } else out[k] = v;
188
- }
189
- return out;
190
- }
1
+ // Carga, combina y valida la configuración (builder.config.json / .mjs + opciones de la CLI).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { BuildError } from './util.js';
6
+ import { L } from './i18n.js';
7
+ import { resolveBrowsers } from './browsers.js';
8
+ import { validateBudgets } from './budgets.js';
9
+ import { validatePlugins, loadPlugins } from './plugins.js';
10
+
11
+ export const CONFIG_FILES = ['builder.config.json', 'builder.config.mjs', 'builder.config.js'];
12
+
13
+ export const DEFAULTS = {
14
+ root: '.',
15
+ outDir: 'dist',
16
+ entries: 'auto',
17
+ include: [],
18
+ ignore: [],
19
+ defaultIgnore: true,
20
+ unreferenced: 'process',
21
+ base: '/',
22
+ hash: false,
23
+ sourcemap: false,
24
+ clean: true,
25
+ verify: true,
26
+ sri: 'auto',
27
+ html: { minify: true, removeComments: true, collapseWhitespace: 'auto' },
28
+ css: { minify: true, bundle: true, target: 'esnext' },
29
+ js: {
30
+ minify: true,
31
+ bundle: true,
32
+ target: 'auto',
33
+ mergeClassic: 'auto',
34
+ keepNames: false,
35
+ drop: [],
36
+ legalComments: 'eof',
37
+ define: {},
38
+ chunkDir: 'assets',
39
+ minifier: 'esbuild',
40
+ },
41
+ json: { minify: true },
42
+ obfuscate: false,
43
+ // Librerías de terceros: se detectan solas y no se ofuscan salvo que se pida.
44
+ dependencies: { detect: true, obfuscate: false, include: [], exclude: [] },
45
+ compress: false,
46
+ // Caché persistente de las transformaciones lentas (ofuscación, terser) entre compilaciones.
47
+ cache: true,
48
+ // Hilos para ofuscar/comprimir en paralelo: "auto" (núcleos - 1, máx. 8), un número, o 0 (sin hilos).
49
+ workers: 'auto',
50
+ // Navegadores objetivo: 'auto' (browserslist del proyecto si existe), false, o una consulta de browserslist.
51
+ browsers: 'auto',
52
+ // Optimizar PNG (sin pérdida) y JPEG/WebP (calidad indicada) con sharp: false, true o { quality: 80 }.
53
+ images: false,
54
+ // Presupuestos de tamaño por patrón de salida (o "total"): { "js/**/*.js": "50 KB", "total": "1 MB gzip" }.
55
+ budgets: false,
56
+ // Plugins: objetos con ganchos (builder.config.mjs) o nombres de paquete. Ver src/plugins.js.
57
+ plugins: [],
58
+ };
59
+
60
+ const NESTED = ['html', 'css', 'js', 'json', 'dependencies'];
61
+ const ENUMS = {
62
+ unreferenced: ['process', 'copy', 'skip'],
63
+ sri: ['auto', true, false],
64
+ sourcemap: [false, true, 'hidden', 'inline'],
65
+ 'js.mergeClassic': ['auto', true, false],
66
+ 'js.legalComments': ['none', 'inline', 'eof'],
67
+ 'html.collapseWhitespace': ['auto', true, false],
68
+ 'js.minifier': ['esbuild', 'terser'],
69
+ compress: [false, true, 'gzip', 'brotli'],
70
+ };
71
+ const OBF_PRESETS = ['low', 'medium', 'high', 'max'];
72
+
73
+ function levenshtein(a, b) {
74
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
75
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
76
+ for (let i = 1; i <= a.length; i++)
77
+ for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
78
+ return d[a.length][b.length];
79
+ }
80
+
81
+ function suggest(key, options) {
82
+ const best = options.map((o) => [o, levenshtein(key, o)]).sort((x, y) => x[1] - y[1])[0];
83
+ return best && best[1] <= 3 ? L(` ¿Quisiste decir "${best[0]}"?`, ` Did you mean "${best[0]}"?`) : '';
84
+ }
85
+
86
+ function checkKeys(obj, defaults, prefix, errors) {
87
+ for (const key of Object.keys(obj)) {
88
+ if (key === '$schema') continue;
89
+ if (!(key in defaults))
90
+ errors.push(
91
+ L(
92
+ `Opción desconocida "${prefix}${key}".${suggest(key, Object.keys(defaults))}`,
93
+ `Unknown option "${prefix}${key}".${suggest(key, Object.keys(defaults))}`,
94
+ ),
95
+ );
96
+ }
97
+ }
98
+
99
+ const asList = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
100
+
101
+ export function normalizeConfig(raw, errors = []) {
102
+ const user = raw ?? {};
103
+ if (typeof user !== 'object' || Array.isArray(user))
104
+ throw new BuildError(L('La configuración debe ser un objeto JSON.', 'The configuration must be a JSON object.'));
105
+ checkKeys(user, DEFAULTS, '', errors);
106
+ const cfg = { ...DEFAULTS, ...user };
107
+ for (const k of NESTED) {
108
+ const v = user[k];
109
+ if (v === false) cfg[k] = k === 'dependencies' ? { ...DEFAULTS[k], detect: false } : { ...DEFAULTS[k], minify: false };
110
+ else if (v === true || v == null) cfg[k] = { ...DEFAULTS[k] };
111
+ else if (typeof v === 'object') {
112
+ checkKeys(v, DEFAULTS[k], `${k}.`, errors);
113
+ cfg[k] = { ...DEFAULTS[k], ...v };
114
+ } else errors.push(L(`"${k}" debe ser un objeto o true/false.`, `"${k}" must be an object or true/false.`));
115
+ }
116
+ for (const k of ['include', 'ignore']) {
117
+ cfg[k] = asList(cfg[k]);
118
+ if (cfg[k].some((x) => typeof x !== 'string'))
119
+ errors.push(L(`"${k}" debe ser una lista de rutas o patrones (texto).`, `"${k}" must be a list of paths or patterns (text).`));
120
+ }
121
+ for (const k of ['include', 'exclude']) {
122
+ cfg.dependencies[k] = asList(cfg.dependencies[k]);
123
+ if (cfg.dependencies[k].some((x) => typeof x !== 'string'))
124
+ errors.push(
125
+ L(`"dependencies.${k}" debe ser una lista de rutas o patrones (texto).`, `"dependencies.${k}" must be a list of paths or patterns (text).`),
126
+ );
127
+ }
128
+ for (const k of ['detect', 'obfuscate'])
129
+ if (typeof cfg.dependencies[k] !== 'boolean')
130
+ errors.push(L(`"dependencies.${k}" debe ser true o false.`, `"dependencies.${k}" must be true or false.`));
131
+ if (cfg.entries !== 'auto') {
132
+ cfg.entries = asList(cfg.entries);
133
+ if (!cfg.entries.length || cfg.entries.some((x) => typeof x !== 'string'))
134
+ errors.push(L('"entries" debe ser "auto" o una lista de archivos/patrones.', '"entries" must be "auto" or a list of files/patterns.'));
135
+ }
136
+ for (const [key, allowed] of Object.entries(ENUMS)) {
137
+ const [a, b] = key.split('.');
138
+ const value = b ? cfg[a][b] : cfg[a];
139
+ if (!allowed.includes(value))
140
+ errors.push(
141
+ L(
142
+ `"${key}" debe ser uno de: ${allowed.map((x) => JSON.stringify(x)).join(', ')}.`,
143
+ `"${key}" must be one of: ${allowed.map((x) => JSON.stringify(x)).join(', ')}.`,
144
+ ),
145
+ );
146
+ }
147
+ if (typeof cfg.outDir !== 'string' || !cfg.outDir.trim()) errors.push(L('"outDir" debe ser una ruta.', '"outDir" must be a path.'));
148
+ if (typeof cfg.base !== 'string' || !cfg.base.startsWith('/'))
149
+ errors.push(L('"base" debe empezar con "/" (ej. "/" o "/mi-sitio/").', '"base" must start with "/" (e.g. "/" or "/my-site/").'));
150
+ else if (!cfg.base.endsWith('/')) cfg.base += '/';
151
+ if (typeof cfg.hash !== 'boolean') errors.push(L('"hash" debe ser true o false.', '"hash" must be true or false.'));
152
+ const b = cfg.browsers;
153
+ if (!(
154
+ b === 'auto' ||
155
+ b === false ||
156
+ (typeof b === 'string' && b.trim()) ||
157
+ (Array.isArray(b) && b.length && b.every((x) => typeof x === 'string'))
158
+ ))
159
+ errors.push(
160
+ L(
161
+ '"browsers" debe ser "auto", false o una consulta de browserslist (texto o lista).',
162
+ '"browsers" must be "auto", false or a browserslist query (text or list).',
163
+ ),
164
+ );
165
+ const im = cfg.images;
166
+ if (!(
167
+ typeof im === 'boolean' ||
168
+ (im &&
169
+ typeof im === 'object' &&
170
+ !Array.isArray(im) &&
171
+ Object.keys(im).every((k) => k === 'quality') &&
172
+ (im.quality === undefined || (Number.isInteger(im.quality) && im.quality >= 1 && im.quality <= 100)))
173
+ ))
174
+ errors.push(L('"images" debe ser true, false o { "quality": 1-100 }.', '"images" must be true, false or { "quality": 1-100 }.'));
175
+ errors.push(...validateBudgets(cfg.budgets));
176
+ errors.push(...validatePlugins(cfg.plugins));
177
+ if (typeof cfg.cache !== 'boolean') errors.push(L('"cache" debe ser true o false.', '"cache" must be true or false.'));
178
+ if (!(cfg.workers === 'auto' || (Number.isInteger(cfg.workers) && cfg.workers >= 0 && cfg.workers <= 64)))
179
+ errors.push(L('"workers" debe ser "auto" o un número entre 0 y 64.', '"workers" must be "auto" or a number between 0 and 64.'));
180
+ cfg.js.drop = asList(cfg.js.drop);
181
+ if (cfg.js.drop.some((d) => !['console', 'debugger'].includes(d)))
182
+ errors.push(L('"js.drop" solo admite "console" y "debugger".', '"js.drop" only accepts "console" and "debugger".'));
183
+ if (typeof cfg.js.chunkDir !== 'string' || /(^|\/)\.\.(\/|$)|^\//.test(cfg.js.chunkDir))
184
+ errors.push(L('"js.chunkDir" debe ser una carpeta relativa.', '"js.chunkDir" must be a relative folder.'));
185
+ for (const k of ['css', 'js']) {
186
+ const t = cfg[k].target;
187
+ if (!(typeof t === 'string' || (Array.isArray(t) && t.every((x) => typeof x === 'string'))))
188
+ errors.push(
189
+ L(
190
+ `"${k}.target" debe ser texto o lista (ej. "es2020" o ["chrome90","safari15"]).`,
191
+ `"${k}.target" must be text or a list (e.g. "es2020" or ["chrome90","safari15"]).`,
192
+ ),
193
+ );
194
+ }
195
+ const o = cfg.obfuscate;
196
+ if (o === true) cfg.obfuscate = 'medium';
197
+ else if (!(o === false || OBF_PRESETS.includes(o) || (o && typeof o === 'object' && !Array.isArray(o))))
198
+ errors.push(
199
+ L(
200
+ `"obfuscate" debe ser false, true, ${OBF_PRESETS.map((p) => `"${p}"`).join(', ')} o un objeto de opciones de javascript-obfuscator.`,
201
+ `"obfuscate" must be false, true, ${OBF_PRESETS.map((p) => `"${p}"`).join(', ')} or an object of javascript-obfuscator options.`,
202
+ ),
203
+ );
204
+ return cfg;
205
+ }
206
+
207
+ async function readConfigFile(file) {
208
+ if (file.endsWith('.json')) {
209
+ const text = fs.readFileSync(file, 'utf8').replace(/^/, '');
210
+ try {
211
+ return JSON.parse(text);
212
+ } catch (err) {
213
+ throw new BuildError(
214
+ L(
215
+ `No pude leer ${path.basename(file)}: JSON inválido (${err.message}).`,
216
+ `Could not read ${path.basename(file)}: invalid JSON (${err.message}).`,
217
+ ),
218
+ );
219
+ }
220
+ }
221
+ const mod = await import(`${pathToFileURL(file).href}?t=${fs.statSync(file).mtimeMs}`);
222
+ return typeof mod.default === 'function' ? await mod.default() : mod.default;
223
+ }
224
+
225
+ export function findConfigFile(dir) {
226
+ for (const name of CONFIG_FILES) {
227
+ const file = path.join(dir, name);
228
+ if (fs.existsSync(file)) return file;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * Resuelve la configuración final.
235
+ * @param {{dir?: string, config?: string, overrides?: object, cwd?: string}} opts
236
+ */
237
+ export async function loadConfig({ dir = '.', config, overrides = {}, cwd = process.cwd() } = {}) {
238
+ const projectDir = path.resolve(cwd, dir);
239
+ if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory())
240
+ throw new BuildError(L(`La carpeta del proyecto no existe: ${projectDir}`, `The project folder does not exist: ${projectDir}`));
241
+ const configFile = config ? path.resolve(cwd, config) : findConfigFile(projectDir);
242
+ if (config && !fs.existsSync(configFile))
243
+ throw new BuildError(L(`No existe el archivo de configuración: ${configFile}`, `The configuration file does not exist: ${configFile}`));
244
+ const fileCfg = configFile ? await readConfigFile(configFile) : {};
245
+ const merged = mergeOverrides(fileCfg ?? {}, overrides);
246
+ const errors = [];
247
+ const cfg = normalizeConfig(merged, errors);
248
+ if (errors.length) throw new BuildError(L('La configuración tiene errores:', 'The configuration has errors:'), errors);
249
+ const baseDir = configFile ? path.dirname(configFile) : projectDir;
250
+ cfg.root = path.resolve(baseDir, cfg.root);
251
+ if (!fs.existsSync(cfg.root)) throw new BuildError(L(`"root" no existe: ${cfg.root}`, `"root" does not exist: ${cfg.root}`));
252
+ // esbuild resuelve los enlaces simbólicos de lo que importa; si la raíz no es la ruta real, el mismo
253
+ // módulo aparece con dos rutas (pasa en macOS con /tmp → /private/tmp, o al abrir el proyecto por un enlace).
254
+ cfg.root = fs.realpathSync(cfg.root);
255
+ cfg.outDir = path.resolve(cfg.root, cfg.outDir);
256
+ cfg.configFile = configFile;
257
+ // Navegadores objetivo (browserslist): solo cambian los targets que siguen con su valor por defecto.
258
+ cfg.plugins = await loadPlugins(cfg.plugins, cfg.root);
259
+ cfg.browserTargets = await resolveBrowsers(cfg.root, cfg.browsers);
260
+ const targets = cfg.browserTargets?.targets;
261
+ if (targets?.length) {
262
+ if (cfg.css.target === DEFAULTS.css.target) cfg.css.target = targets;
263
+ if (cfg.js.target === DEFAULTS.js.target) cfg.js.target = targets;
264
+ }
265
+ return cfg;
266
+ }
267
+
268
+ function mergeOverrides(base, over) {
269
+ const out = { ...base };
270
+ for (const [k, v] of Object.entries(over)) {
271
+ if (v === undefined) continue;
272
+ if (NESTED.includes(k) && v && typeof v === 'object' && !Array.isArray(v)) {
273
+ const prev = base[k];
274
+ out[k] = { ...(prev && typeof prev === 'object' ? prev : prev === false ? { minify: false } : {}), ...v };
275
+ } else if (['include', 'ignore'].includes(k)) {
276
+ out[k] = [...asList(base[k]), ...asList(v)];
277
+ } else out[k] = v;
278
+ }
279
+ return out;
280
+ }
package/src/deps.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Detección de librerías de terceros (jQuery, Bootstrap, paquetes de npm...).
2
2
  // No se ofuscan: no aporta nada, las hace más lentas y puede romper su licencia.
3
+ import { L } from './i18n.js';
3
4
 
4
5
  const DEP_DIRS = /(^|\/)(node_modules|bower_components|jspm_packages|web_modules|vendors?|third[-_]?party|external|externals)\//i;
5
6
  const MIN_NAME = /[.-](min|prod|production)\.m?js$|\.umd(\.min)?\.m?js$/i;
@@ -116,25 +117,88 @@ const KNOWN = new RegExp(
116
117
  'i',
117
118
  );
118
119
 
120
+ // Marca escrita por el usuario en el primer comentario del archivo.
121
+ const PRAGMA = /@builder-(dependency|dependencia|own|propio)\b/i;
122
+ // Envoltorio UMD/AMD/CommonJS: el formato con el que se publican las librerías para usarlas con <script>.
123
+ const UMD = [
124
+ /typeof\s+define\s*===?\s*["']function["']\s*&&\s*define\.amd/,
125
+ /typeof\s+exports\s*===?\s*["']object["']\s*&&\s*typeof\s+module\s*!==?\s*["']undefined["']/,
126
+ /typeof\s+module\s*===?\s*["']object["']\s*&&\s*(?:typeof\s+)?module\.exports/,
127
+ ];
128
+
129
+ /**
130
+ * Marca explícita en el propio archivo: un comentario inicial con @builder-dependency o @builder-own.
131
+ * @returns {'dependency'|'own'|null}
132
+ */
133
+ export function dependencyPragma(code = '') {
134
+ const head = code.slice(0, 2000);
135
+ const comment = /^\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)/.exec(head)?.[0] ?? '';
136
+ const m = PRAGMA.exec(comment);
137
+ if (!m) return null;
138
+ return /^(dependency|dependencia)$/i.test(m[1]) ? 'dependency' : 'own';
139
+ }
140
+
141
+ /** Nombre "de paquete" de un archivo: jquery-3.7.1.min.js → jquery, chart.umd.js → chart. */
142
+ export function packageLikeName(fileName) {
143
+ return fileName
144
+ .toLowerCase()
145
+ .replace(/\.(m?js|cjs)$/, '')
146
+ .replace(/[.-](min|prod|production|umd|esm|es|module|cjs|iife|bundle|browser|global|dist)\b/g, '')
147
+ .replace(/[@.-]v?\d+(\.\d+){1,3}(-[\w.]+)?$/, '')
148
+ .replace(/[.-](min|prod|production|umd|esm|es|module|cjs|iife|bundle|browser|global|dist)$/g, '');
149
+ }
150
+
151
+ /**
152
+ * Nombres de los paquetes declarados en el package.json del proyecto, normalizados
153
+ * (chart.js → chart, @popperjs/core → popperjs), para reconocer copias a mano en js/.
154
+ * @returns {Map<string, string>} nombre normalizado → nombre en package.json
155
+ */
156
+ export function packageNames(pkg) {
157
+ const names = new Map();
158
+ // devDependencies no: suelen ser herramientas (eslint, prettier…) y darían falsos positivos.
159
+ for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) {
160
+ for (const full of Object.keys(pkg?.[field] ?? {})) {
161
+ const lower = full.toLowerCase();
162
+ const [scope, bare] = lower.startsWith('@') ? lower.slice(1).split('/') : [null, lower];
163
+ for (const n of [bare, scope]) {
164
+ if (!n || n.length < 3) continue;
165
+ for (const key of [n, n.replace(/\.js$/, '')]) if (!names.has(key)) names.set(key, full);
166
+ }
167
+ }
168
+ }
169
+ // Nombres demasiado genéricos para concluir nada solo por el nombre del archivo.
170
+ for (const generic of ['core', 'utils', 'util', 'app', 'main', 'index', 'common', 'types', 'lib', 'api']) names.delete(generic);
171
+ return names;
172
+ }
173
+
119
174
  /**
120
175
  * ¿Parece una librería de terceros? Devuelve el motivo o null.
121
176
  * @param {string} rel ruta relativa (POSIX)
122
177
  * @param {string} code contenido
178
+ * @param {{packages?: Map<string, string>}} [hints] nombres de package.json del proyecto
123
179
  */
124
- export function detectDependency(rel, code = '') {
125
- if (DEP_DIRS.test(rel)) return `está en ${rel.match(DEP_DIRS)[2]}/`;
180
+ export function detectDependency(rel, code = '', hints = {}) {
181
+ if (DEP_DIRS.test(rel)) return L(`está en ${rel.match(DEP_DIRS)[2]}/`, `in ${rel.match(DEP_DIRS)[2]}/`);
126
182
  const name = rel.split('/').pop();
127
- if (KNOWN.test(name)) return 'nombre de librería conocida';
128
- if (MIN_NAME.test(name)) return 'versión ya compilada (.min.js / .umd.js)';
183
+ if (KNOWN.test(name)) return L('nombre de librería conocida', 'well-known library name');
184
+ if (MIN_NAME.test(name)) return L('versión ya compilada (.min.js / .umd.js)', 'already compiled build (.min.js / .umd.js)');
185
+ const pkgName = packageLikeName(name);
186
+ if (hints.packages?.has(pkgName))
187
+ return L(
188
+ `se llama como el paquete "${hints.packages.get(pkgName)}" de tu package.json`,
189
+ `named like the "${hints.packages.get(pkgName)}" package in your package.json`,
190
+ );
191
+ const edges = code.length > 8000 ? code.slice(0, 5000) + code.slice(-3000) : code;
192
+ if (UMD.some((re) => re.test(edges))) return L('envoltorio UMD/AMD (formato de librería publicada)', 'UMD/AMD wrapper (published library format)');
129
193
  // Cabecera de librería publicada: /*! o @license con número de versión (jQuery v3.7.1, Bootstrap v5.3.0...).
130
194
  // Una cabecera propia sin versión ("/*! Mi app (c) 2026 */") no basta.
131
195
  const header = /^\s*\/\*[\s\S]*?\*\//.exec(code.slice(0, 3000))?.[0] ?? '';
132
196
  if ((/^\s*\/\*!/.test(header) || /@(license|preserve)\b/.test(header)) && /\bv?\d+\.\d+(\.\d+)?\b/.test(header))
133
- return 'cabecera de licencia con versión';
197
+ return L('cabecera de licencia con versión', 'license header with a version');
134
198
  // Código ya minificado: pocas líneas muy largas.
135
199
  if (code.length > 4000) {
136
200
  const lines = code.split('\n').length;
137
- if (code.length / lines > 500) return 'código ya minificado';
201
+ if (code.length / lines > 500) return L('código ya minificado', 'already minified code');
138
202
  }
139
203
  return null;
140
204
  }