vanilla-builder 0.2.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.
package/src/config.js CHANGED
@@ -3,6 +3,10 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
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';
6
10
 
7
11
  export const CONFIG_FILES = ['builder.config.json', 'builder.config.mjs', 'builder.config.js'];
8
12
 
@@ -43,6 +47,14 @@ export const DEFAULTS = {
43
47
  cache: true,
44
48
  // Hilos para ofuscar/comprimir en paralelo: "auto" (núcleos - 1, máx. 8), un número, o 0 (sin hilos).
45
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: [],
46
58
  };
47
59
 
48
60
  const NESTED = ['html', 'css', 'js', 'json', 'dependencies'];
@@ -68,13 +80,19 @@ function levenshtein(a, b) {
68
80
 
69
81
  function suggest(key, options) {
70
82
  const best = options.map((o) => [o, levenshtein(key, o)]).sort((x, y) => x[1] - y[1])[0];
71
- return best && best[1] <= 3 ? ` ¿Quisiste decir "${best[0]}"?` : '';
83
+ return best && best[1] <= 3 ? L(` ¿Quisiste decir "${best[0]}"?`, ` Did you mean "${best[0]}"?`) : '';
72
84
  }
73
85
 
74
86
  function checkKeys(obj, defaults, prefix, errors) {
75
87
  for (const key of Object.keys(obj)) {
76
88
  if (key === '$schema') continue;
77
- if (!(key in defaults)) errors.push(`Opción desconocida "${prefix}${key}".${suggest(key, Object.keys(defaults))}`);
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
+ );
78
96
  }
79
97
  }
80
98
 
@@ -82,7 +100,8 @@ const asList = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
82
100
 
83
101
  export function normalizeConfig(raw, errors = []) {
84
102
  const user = raw ?? {};
85
- if (typeof user !== 'object' || Array.isArray(user)) throw new BuildError('La configuración debe ser un objeto JSON.');
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.'));
86
105
  checkKeys(user, DEFAULTS, '', errors);
87
106
  const cfg = { ...DEFAULTS, ...user };
88
107
  for (const k of NESTED) {
@@ -92,47 +111,96 @@ export function normalizeConfig(raw, errors = []) {
92
111
  else if (typeof v === 'object') {
93
112
  checkKeys(v, DEFAULTS[k], `${k}.`, errors);
94
113
  cfg[k] = { ...DEFAULTS[k], ...v };
95
- } else errors.push(`"${k}" debe ser un objeto o true/false.`);
114
+ } else errors.push(L(`"${k}" debe ser un objeto o true/false.`, `"${k}" must be an object or true/false.`));
96
115
  }
97
116
  for (const k of ['include', 'ignore']) {
98
117
  cfg[k] = asList(cfg[k]);
99
- if (cfg[k].some((x) => typeof x !== 'string')) errors.push(`"${k}" debe ser una lista de rutas o patrones (texto).`);
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).`));
100
120
  }
101
121
  for (const k of ['include', 'exclude']) {
102
122
  cfg.dependencies[k] = asList(cfg.dependencies[k]);
103
- if (cfg.dependencies[k].some((x) => typeof x !== 'string')) errors.push(`"dependencies.${k}" debe ser una lista de rutas o patrones (texto).`);
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
+ );
104
127
  }
105
- for (const k of ['detect', 'obfuscate']) if (typeof cfg.dependencies[k] !== 'boolean') errors.push(`"dependencies.${k}" debe ser true o false.`);
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.`));
106
131
  if (cfg.entries !== 'auto') {
107
132
  cfg.entries = asList(cfg.entries);
108
133
  if (!cfg.entries.length || cfg.entries.some((x) => typeof x !== 'string'))
109
- errors.push('"entries" debe ser "auto" o una lista de archivos/patrones.');
134
+ errors.push(L('"entries" debe ser "auto" o una lista de archivos/patrones.', '"entries" must be "auto" or a list of files/patterns.'));
110
135
  }
111
136
  for (const [key, allowed] of Object.entries(ENUMS)) {
112
137
  const [a, b] = key.split('.');
113
138
  const value = b ? cfg[a][b] : cfg[a];
114
- if (!allowed.includes(value)) errors.push(`"${key}" debe ser uno de: ${allowed.map((x) => JSON.stringify(x)).join(', ')}.`);
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
+ );
115
146
  }
116
- if (typeof cfg.outDir !== 'string' || !cfg.outDir.trim()) errors.push('"outDir" debe ser una ruta.');
117
- if (typeof cfg.base !== 'string' || !cfg.base.startsWith('/')) errors.push('"base" debe empezar con "/" (ej. "/" o "/mi-sitio/").');
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/").'));
118
150
  else if (!cfg.base.endsWith('/')) cfg.base += '/';
119
- if (typeof cfg.hash !== 'boolean') errors.push('"hash" debe ser true o false.');
120
- if (typeof cfg.cache !== 'boolean') errors.push('"cache" debe ser true o false.');
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.'));
121
178
  if (!(cfg.workers === 'auto' || (Number.isInteger(cfg.workers) && cfg.workers >= 0 && cfg.workers <= 64)))
122
- errors.push('"workers" debe ser "auto" o un número entre 0 y 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.'));
123
180
  cfg.js.drop = asList(cfg.js.drop);
124
- if (cfg.js.drop.some((d) => !['console', 'debugger'].includes(d))) errors.push('"js.drop" solo admite "console" y "debugger".');
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".'));
125
183
  if (typeof cfg.js.chunkDir !== 'string' || /(^|\/)\.\.(\/|$)|^\//.test(cfg.js.chunkDir))
126
- errors.push('"js.chunkDir" debe ser una carpeta relativa.');
184
+ errors.push(L('"js.chunkDir" debe ser una carpeta relativa.', '"js.chunkDir" must be a relative folder.'));
127
185
  for (const k of ['css', 'js']) {
128
186
  const t = cfg[k].target;
129
187
  if (!(typeof t === 'string' || (Array.isArray(t) && t.every((x) => typeof x === 'string'))))
130
- errors.push(`"${k}.target" debe ser texto o lista (ej. "es2020" o ["chrome90","safari15"]).`);
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
+ );
131
194
  }
132
195
  const o = cfg.obfuscate;
133
196
  if (o === true) cfg.obfuscate = 'medium';
134
197
  else if (!(o === false || OBF_PRESETS.includes(o) || (o && typeof o === 'object' && !Array.isArray(o))))
135
- errors.push(`"obfuscate" debe ser false, true, ${OBF_PRESETS.map((p) => `"${p}"`).join(', ')} o un objeto de opciones de javascript-obfuscator.`);
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
+ );
136
204
  return cfg;
137
205
  }
138
206
 
@@ -142,7 +210,12 @@ async function readConfigFile(file) {
142
210
  try {
143
211
  return JSON.parse(text);
144
212
  } catch (err) {
145
- throw new BuildError(`No pude leer ${path.basename(file)}: JSON inválido (${err.message}).`);
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
+ );
146
219
  }
147
220
  }
148
221
  const mod = await import(`${pathToFileURL(file).href}?t=${fs.statSync(file).mtimeMs}`);
@@ -163,22 +236,32 @@ export function findConfigFile(dir) {
163
236
  */
164
237
  export async function loadConfig({ dir = '.', config, overrides = {}, cwd = process.cwd() } = {}) {
165
238
  const projectDir = path.resolve(cwd, dir);
166
- if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) throw new BuildError(`La carpeta del proyecto no existe: ${projectDir}`);
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}`));
167
241
  const configFile = config ? path.resolve(cwd, config) : findConfigFile(projectDir);
168
- if (config && !fs.existsSync(configFile)) throw new BuildError(`No existe el archivo de configuración: ${configFile}`);
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}`));
169
244
  const fileCfg = configFile ? await readConfigFile(configFile) : {};
170
245
  const merged = mergeOverrides(fileCfg ?? {}, overrides);
171
246
  const errors = [];
172
247
  const cfg = normalizeConfig(merged, errors);
173
- if (errors.length) throw new BuildError('La configuración tiene errores:', errors);
248
+ if (errors.length) throw new BuildError(L('La configuración tiene errores:', 'The configuration has errors:'), errors);
174
249
  const baseDir = configFile ? path.dirname(configFile) : projectDir;
175
250
  cfg.root = path.resolve(baseDir, cfg.root);
176
- if (!fs.existsSync(cfg.root)) throw new BuildError(`"root" no existe: ${cfg.root}`);
251
+ if (!fs.existsSync(cfg.root)) throw new BuildError(L(`"root" no existe: ${cfg.root}`, `"root" does not exist: ${cfg.root}`));
177
252
  // esbuild resuelve los enlaces simbólicos de lo que importa; si la raíz no es la ruta real, el mismo
178
253
  // módulo aparece con dos rutas (pasa en macOS con /tmp → /private/tmp, o al abrir el proyecto por un enlace).
179
254
  cfg.root = fs.realpathSync(cfg.root);
180
255
  cfg.outDir = path.resolve(cfg.root, cfg.outDir);
181
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
+ }
182
265
  return cfg;
183
266
  }
184
267
 
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;
@@ -177,23 +178,27 @@ export function packageNames(pkg) {
177
178
  * @param {{packages?: Map<string, string>}} [hints] nombres de package.json del proyecto
178
179
  */
179
180
  export function detectDependency(rel, code = '', hints = {}) {
180
- if (DEP_DIRS.test(rel)) return `está en ${rel.match(DEP_DIRS)[2]}/`;
181
+ if (DEP_DIRS.test(rel)) return L(`está en ${rel.match(DEP_DIRS)[2]}/`, `in ${rel.match(DEP_DIRS)[2]}/`);
181
182
  const name = rel.split('/').pop();
182
- if (KNOWN.test(name)) return 'nombre de librería conocida';
183
- 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)');
184
185
  const pkgName = packageLikeName(name);
185
- if (hints.packages?.has(pkgName)) return `se llama como el paquete "${hints.packages.get(pkgName)}" de tu package.json`;
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
+ );
186
191
  const edges = code.length > 8000 ? code.slice(0, 5000) + code.slice(-3000) : code;
187
- if (UMD.some((re) => re.test(edges))) return 'envoltorio UMD/AMD (formato de librería publicada)';
192
+ if (UMD.some((re) => re.test(edges))) return L('envoltorio UMD/AMD (formato de librería publicada)', 'UMD/AMD wrapper (published library format)');
188
193
  // Cabecera de librería publicada: /*! o @license con número de versión (jQuery v3.7.1, Bootstrap v5.3.0...).
189
194
  // Una cabecera propia sin versión ("/*! Mi app (c) 2026 */") no basta.
190
195
  const header = /^\s*\/\*[\s\S]*?\*\//.exec(code.slice(0, 3000))?.[0] ?? '';
191
196
  if ((/^\s*\/\*!/.test(header) || /@(license|preserve)\b/.test(header)) && /\bv?\d+\.\d+(\.\d+)?\b/.test(header))
192
- return 'cabecera de licencia con versión';
197
+ return L('cabecera de licencia con versión', 'license header with a version');
193
198
  // Código ya minificado: pocas líneas muy largas.
194
199
  if (code.length > 4000) {
195
200
  const lines = code.split('\n').length;
196
- 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');
197
202
  }
198
203
  return null;
199
204
  }
package/src/files.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import picomatch from 'picomatch';
5
5
  import { CONFIG_FILES } from './config.js';
6
6
  import { relFrom, toPosix, extOf } from './util.js';
7
+ import { L } from './i18n.js';
7
8
 
8
9
  /** Archivos de desarrollo que nunca deberían publicarse. Se desactiva con defaultIgnore:false. */
9
10
  export const DEFAULT_IGNORE = [
@@ -85,7 +86,7 @@ export function scanProject(cfg) {
85
86
  try {
86
87
  entries = fs.readdirSync(dir, { withFileTypes: true });
87
88
  } catch (err) {
88
- ignored.push({ rel: relFrom(root, dir) || '.', reason: `no se pudo leer (${err.code})` });
89
+ ignored.push({ rel: relFrom(root, dir) || '.', reason: L(`no se pudo leer (${err.code})`, `could not be read (${err.code})`) });
89
90
  return;
90
91
  }
91
92
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -100,9 +101,10 @@ export function scanProject(cfg) {
100
101
  const st = fs.statSync(abs);
101
102
  isFile = st.isFile();
102
103
  isDir = false;
103
- if (st.isDirectory()) ignored.push({ rel, reason: 'enlace simbólico a carpeta (no se sigue)' });
104
+ if (st.isDirectory())
105
+ ignored.push({ rel, reason: L('enlace simbólico a carpeta (no se sigue)', 'symbolic link to a folder (not followed)') });
104
106
  } catch {
105
- ignored.push({ rel, reason: 'enlace simbólico roto' });
107
+ ignored.push({ rel, reason: L('enlace simbólico roto', 'broken symbolic link') });
106
108
  continue;
107
109
  }
108
110
  }
@@ -113,7 +115,10 @@ export function scanProject(cfg) {
113
115
  const byUser = isUser(rel) || isUser(probe);
114
116
  const wanted = cfg.include.some((p) => normalizePattern(p).startsWith(`${rel}/`) || p.startsWith('**'));
115
117
  if ((byUser || isDefault(probe)) && !wanted) {
116
- ignored.push({ rel: `${rel}/`, reason: byUser ? 'ignore (configuración)' : 'excluido por defecto' });
118
+ ignored.push({
119
+ rel: `${rel}/`,
120
+ reason: byUser ? L('ignore (configuración)', 'ignore (configuration)') : L('excluido por defecto', 'excluded by default'),
121
+ });
117
122
  continue;
118
123
  }
119
124
  walk(abs);
@@ -123,9 +128,9 @@ export function scanProject(cfg) {
123
128
  const forced = isIncluded(rel);
124
129
  let reason = null;
125
130
  if (!forced) {
126
- if (isSecret(rel)) reason = 'posible secreto (excluido por seguridad)';
127
- else if (isUser(rel)) reason = 'ignore (configuración)';
128
- else if (isDefault(rel)) reason = 'excluido por defecto';
131
+ if (isSecret(rel)) reason = L('posible secreto (excluido por seguridad)', 'possible secret (excluded for safety)');
132
+ else if (isUser(rel)) reason = L('ignore (configuración)', 'ignore (configuration)');
133
+ else if (isDefault(rel)) reason = L('excluido por defecto', 'excluded by default');
129
134
  }
130
135
  if (reason) {
131
136
  ignored.push({ rel, reason });
package/src/html.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // HTML: extrae referencias con su posición exacta, aplica ediciones puntuales y minifica con verificación.
2
2
  import { parse } from 'parse5';
3
+ import { L } from './i18n.js';
3
4
 
4
5
  const JS_TYPES = new Set([
5
6
  '',
@@ -215,7 +216,7 @@ export function applyEdits(text, edits) {
215
216
  let out = text;
216
217
  let limit = Infinity;
217
218
  for (const e of sorted) {
218
- if (e.end > limit) throw new Error(`Ediciones superpuestas en la posición ${e.start}`);
219
+ if (e.end > limit) throw new Error(L(`Ediciones superpuestas en la posición ${e.start}`, `Overlapping edits at position ${e.start}`));
219
220
  out = out.slice(0, e.start) + e.text + out.slice(e.end);
220
221
  limit = e.start;
221
222
  }
@@ -324,7 +325,7 @@ export async function minifyHtml(text, { removeComments, collapseWhitespace }) {
324
325
  try {
325
326
  out = applyEdits(text, minifyEdits(text, { removeComments, collapseWhitespace }));
326
327
  } catch (err) {
327
- return { code: text, note: `no se pudo minificar el HTML (${err.message})` };
328
+ return { code: text, note: L(`no se pudo minificar el HTML (${err.message})`, `could not minify the HTML (${err.message})`) };
328
329
  }
329
330
  const before = htmlSignature(text);
330
331
  const after = htmlSignature(out);
@@ -332,7 +333,13 @@ export async function minifyHtml(text, { removeComments, collapseWhitespace }) {
332
333
  let i = 0;
333
334
  while (i < before.length && before[i] === after[i]) i++;
334
335
  const near = before.slice(Math.max(0, i - 40), i + 40).replace(/\s+/g, ' ');
335
- return { code: text, note: `la minificación cambiaba la estructura cerca de «${near}»; se deja sin minificar` };
336
+ return {
337
+ code: text,
338
+ note: L(
339
+ `la minificación cambiaba la estructura cerca de «${near}»; se deja sin minificar`,
340
+ `minification changed the structure near «${near}»; left unminified`,
341
+ ),
342
+ };
336
343
  }
337
344
  return { code: out };
338
345
  }
package/src/i18n.js ADDED
@@ -0,0 +1,33 @@
1
+ // Idioma de los mensajes: inglés o español.
2
+ // Prioridad: --lang / setLang() > BUILDER_LANG > LC_ALL / LC_MESSAGES / LANG > idioma del sistema > inglés.
3
+ // Los campos de --json, la configuración y los nombres de fase no se traducen nunca.
4
+
5
+ function detect() {
6
+ const env = process.env;
7
+ const explicit = env.BUILDER_LANG;
8
+ if (explicit) return /^es/i.test(explicit) ? 'es' : 'en';
9
+ const posix = env.LC_ALL || env.LC_MESSAGES || env.LANG;
10
+ // "C" y "POSIX" no dicen nada del idioma de la persona: se mira el del sistema.
11
+ if (posix && !/^(C|POSIX)(\.|$)/i.test(posix)) return /^es/i.test(posix) ? 'es' : 'en';
12
+ try {
13
+ return /^es\b/i.test(Intl.DateTimeFormat().resolvedOptions().locale) ? 'es' : 'en';
14
+ } catch {
15
+ return 'en';
16
+ }
17
+ }
18
+
19
+ let lang = detect();
20
+
21
+ /** Cambia el idioma ('en' o 'es'). */
22
+ export function setLang(value) {
23
+ lang = /^es/i.test(String(value)) ? 'es' : 'en';
24
+ }
25
+
26
+ export function getLang() {
27
+ return lang;
28
+ }
29
+
30
+ /** Devuelve el texto en el idioma activo. Ambos textos se escriben juntos en el código. */
31
+ export function L(es, en) {
32
+ return lang === 'es' ? es : en;
33
+ }
package/src/images.js ADDED
@@ -0,0 +1,61 @@
1
+ // Optimización de imágenes con sharp (dependencia opcional). Mismo formato y mismo nombre: no cambia ninguna referencia.
2
+ // - PNG: recompresión sin pérdida, comprobada píxel a píxel.
3
+ // - JPEG y WebP: se recodifican con la calidad indicada (con pérdida, visualmente equivalente).
4
+ // Se conservan los metadatos (orientación EXIF, perfil de color). Si el resultado no es más pequeño, se deja el original.
5
+ import { importOptional } from './optional.js';
6
+ import { L } from './i18n.js';
7
+
8
+ export const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp']);
9
+
10
+ let sharpMod = null;
11
+
12
+ export async function ensureSharp(root) {
13
+ if (!sharpMod) {
14
+ const mod = await importOptional('sharp', root, L('para optimizar imágenes (--images)', 'to optimize images (--images)'));
15
+ sharpMod = mod.default ?? mod;
16
+ }
17
+ return sharpMod;
18
+ }
19
+
20
+ /** Opciones normalizadas de "images": false, true o { quality }. */
21
+ export function imageOptions(setting) {
22
+ if (!setting) return null;
23
+ const quality = typeof setting === 'object' && Number.isInteger(setting.quality) ? setting.quality : 80;
24
+ return { quality };
25
+ }
26
+
27
+ async function samePixels(sharp, a, b) {
28
+ const [ra, rb] = await Promise.all([
29
+ sharp(a).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
30
+ sharp(b).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
31
+ ]);
32
+ return ra.info.width === rb.info.width && ra.info.height === rb.info.height && ra.data.equals(rb.data);
33
+ }
34
+
35
+ /**
36
+ * @returns {Promise<{data: Buffer} | {skip: string}>} la imagen optimizada, o el motivo para dejar la original.
37
+ */
38
+ export async function optimizeImage(sharp, buf, ext, { quality }) {
39
+ let meta;
40
+ try {
41
+ meta = await sharp(buf).metadata();
42
+ } catch (err) {
43
+ return { skip: L(`no se pudo leer la imagen (${err.message})`, `could not read the image (${err.message})`) };
44
+ }
45
+ if ((meta.pages ?? 1) > 1) return { skip: L('imagen animada', 'animated image') };
46
+ const base = () => sharp(buf).keepMetadata();
47
+ let out;
48
+ try {
49
+ if (ext === '.png') out = await base().png({ compressionLevel: 9, effort: 10, palette: false, adaptiveFiltering: true }).toBuffer();
50
+ else if (ext === '.webp') out = await base().webp({ quality, effort: 6 }).toBuffer();
51
+ else out = await base().jpeg({ quality, mozjpeg: true }).toBuffer();
52
+ } catch (err) {
53
+ return { skip: L(`sharp falló (${err.message})`, `sharp failed (${err.message})`) };
54
+ }
55
+ if (out.length >= buf.length) return { skip: L('ya estaba optimizada', 'already optimized') };
56
+ const after = await sharp(out).metadata();
57
+ if (after.width !== meta.width || after.height !== meta.height || after.format !== meta.format)
58
+ return { skip: L('el resultado no conserva tamaño o formato', 'the result does not keep size or format') };
59
+ if (ext === '.png' && !(await samePixels(sharp, buf, out))) return { skip: L('los píxeles no coinciden', 'pixels do not match') };
60
+ return { data: out };
61
+ }