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/js.js CHANGED
@@ -4,12 +4,13 @@ import fs from 'node:fs';
4
4
  import * as esbuild from 'esbuild';
5
5
  import * as acorn from 'acorn';
6
6
  import * as walk from 'acorn-walk';
7
- import { analyzeJs, compareGlobals } from './jsinfo.js';
7
+ import { analyzeJs, compareGlobals, isolateTempGlobals } from './jsinfo.js';
8
8
  import { importOptional } from './optional.js';
9
9
  import { replaceWithMap } from './sourcemap.js';
10
10
  import { relFrom, toPosix, BuildError, rootPrefix, hasOwn, shortHash, jsOutPath } from './util.js';
11
11
  import { classifyUrl } from './urls.js';
12
12
  import { formatMessage, esbuildErrorText } from './css.js';
13
+ import { L } from './i18n.js';
13
14
 
14
15
  /** Marcador de "raíz de salida" que se sustituye por la ruta relativa real de cada archivo generado. */
15
16
  export const MARK = '__VB_ROOT__';
@@ -55,50 +56,75 @@ export async function processClassic(ctx, { code, id, info, obfuscate, sourcemap
55
56
  const notes = [];
56
57
  let out = code;
57
58
  let map = null;
59
+ let allowPrefix = null;
58
60
  if (js.minify) {
59
61
  const auto = await targetForLevel(info.level);
60
- const attempts = [{ target: js.target === 'auto' ? auto : js.target, keepNames: js.keepNames, label: 'completa' }];
61
- if (js.target !== 'auto' || js.keepNames) attempts.push({ target: auto, keepNames: false, label: 'sin transpilar' });
62
- attempts.push({ target: auto, keepNames: false, whitespace: true, label: 'solo espacios' });
62
+ const attempts = [{ target: js.target === 'auto' ? auto : js.target, keepNames: js.keepNames, label: L('completa', 'full') }];
63
+ if (js.target !== 'auto' || js.keepNames) attempts.push({ target: auto, keepNames: false, label: L('sin transpilar', 'without transpiling') });
64
+ attempts.push({ target: auto, keepNames: false, whitespace: true, label: L('solo espacios', 'whitespace only') });
63
65
  let done = false;
66
+ // Mismo prefijo que usa el ofuscador para sus nombres: las comprobaciones posteriores ya lo aceptan.
67
+ const tempPrefix = `_${shortHash(id, 6)}`;
68
+ const transform = (input, a, extra = {}) =>
69
+ esbuild.transform(input, {
70
+ loader: 'js',
71
+ target: a.target,
72
+ charset: 'ascii',
73
+ supported: SUPPORTED,
74
+ legalComments: js.legalComments,
75
+ drop: js.drop,
76
+ define: js.define,
77
+ keepNames: a.keepNames,
78
+ sourcemap: sourcemap ? 'external' : false,
79
+ sourcefile: id,
80
+ ...(a.whitespace ? { minifyWhitespace: true } : { minify: true }),
81
+ logLevel: 'silent',
82
+ ...extra,
83
+ });
64
84
  for (const a of attempts) {
65
85
  try {
66
- const r = await esbuild.transform(code, {
67
- loader: 'js',
68
- target: a.target,
69
- charset: 'ascii',
70
- supported: SUPPORTED,
71
- legalComments: js.legalComments,
72
- drop: js.drop,
73
- define: js.define,
74
- keepNames: a.keepNames,
75
- sourcemap: sourcemap ? 'external' : false,
76
- sourcefile: id,
77
- ...(a.whitespace ? { minifyWhitespace: true } : { minify: true }),
78
- logLevel: 'silent',
79
- });
80
- const cmp = compareGlobals(info.top, r.code);
86
+ const r = await transform(code, a);
87
+ let candidate = r.code;
88
+ let cmp = compareGlobals(info.top, candidate);
89
+ let isolated = false;
90
+ if (!cmp.ok && !a.whitespace && a.target !== auto) {
91
+ // Al adaptar la sintaxis, esbuild declara temporales globales (var _a). Se adapta sin minificar
92
+ // (nombres reconocibles), se aíslan con un prefijo propio del archivo y después se minifica:
93
+ // la minificación no renombra globales de un script clásico ni crea temporales nuevas.
94
+ const lowered = await transform(code, a, { minify: false, minifyWhitespace: false, sourcemap: false });
95
+ const iso = isolateTempGlobals(lowered.code, info.top, tempPrefix);
96
+ if (iso) {
97
+ const r2 = await transform(iso, a, { sourcemap: false });
98
+ const cmp2 = compareGlobals(info.top, r2.code, tempPrefix);
99
+ if (cmp2.ok) {
100
+ candidate = r2.code;
101
+ cmp = cmp2;
102
+ isolated = true;
103
+ }
104
+ }
105
+ }
81
106
  if (cmp.ok) {
82
- out = r.code;
83
- map = r.map || null;
107
+ out = candidate;
108
+ map = isolated ? null : r.map || null;
109
+ if (isolated) allowPrefix = tempPrefix;
84
110
  done = true;
85
111
  break;
86
112
  }
87
- notes.push(`minificación ${a.label} descartada: ${cmp.reason}`);
113
+ notes.push(L(`minificación ${a.label} descartada: ${cmp.reason}`, `${a.label} minification discarded: ${cmp.reason}`));
88
114
  } catch (err) {
89
- notes.push(`minificación ${a.label} falló: ${firstLine(err)}`);
115
+ notes.push(L(`minificación ${a.label} falló: ${firstLine(err)}`, `${a.label} minification failed: ${firstLine(err)}`));
90
116
  }
91
117
  }
92
- if (!done) notes.push('se deja sin minificar para no romperlo');
118
+ if (!done) notes.push(L('se deja sin minificar para no romperlo', 'left unminified so it does not break'));
93
119
  }
94
120
  if (obfuscate) {
95
121
  const o = await ctx.heavy.obfuscate(out, { isModule: false, options: ctx.obfuscatorOptions, id, globals: info.top });
96
122
  if (o.code) {
97
123
  out = o.code;
98
124
  map = null;
99
- } else notes.push(`no se ofuscó: ${o.reason}`);
125
+ } else notes.push(L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`));
100
126
  } else if (out !== code) {
101
- const t = await terserPass(ctx, out, { isModule: false, level: info.level, globals: info.top });
127
+ const t = await terserPass(ctx, out, { isModule: false, level: info.level, globals: info.top, allowPrefix });
102
128
  if (t !== out) {
103
129
  out = t;
104
130
  map = null;
@@ -133,7 +159,7 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
133
159
  out = r.code;
134
160
  map = r.map || null;
135
161
  } catch (err) {
136
- notes.push(`no se pudo minificar: ${firstLine(err)}`);
162
+ notes.push(L(`no se pudo minificar: ${firstLine(err)}`, `could not minify: ${firstLine(err)}`));
137
163
  }
138
164
  }
139
165
  if (obfuscate) {
@@ -141,7 +167,7 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
141
167
  if (o.code) {
142
168
  out = o.code;
143
169
  map = null;
144
- } else notes.push(`no se ofuscó: ${o.reason}`);
170
+ } else notes.push(L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`));
145
171
  } else if (out !== code) {
146
172
  const t = await terserPass(ctx, out, { isModule: true, level: info.level });
147
173
  if (t !== out) {
@@ -155,7 +181,7 @@ export async function processModuleStandalone(ctx, { code, id, info, obfuscate,
155
181
  /** Ofusca un módulo ya generado (paquete, chunk o worker). */
156
182
  export async function obfuscateModule(ctx, code, id) {
157
183
  const o = await ctx.heavy.obfuscate(code, { isModule: true, options: ctx.obfuscatorOptions, id });
158
- return o.code ? { code: o.code } : { code, note: `no se ofuscó: ${o.reason}` };
184
+ return o.code ? { code: o.code } : { code, note: L(`no se ofuscó: ${o.reason}`, `not obfuscated: ${o.reason}`) };
159
185
  }
160
186
 
161
187
  /** Aplica un import map (solo "imports"; con "scopes" se usa el modo sin empaquetar). */
@@ -219,8 +245,10 @@ function modulePlugin(ctx, group, state) {
219
245
  if (!info.ok) return code;
220
246
  state.levels.push(info.level ?? 9999);
221
247
  if (!local) return code;
222
- for (const u of info.unsafeUrls) state.unsafe.push(`new URL(variable, import.meta.url) en ${id}: ${u}`);
223
- for (const u of info.unsafeImports) state.unsafe.push(`import() con una ruta calculada en ${id}: ${u}`);
248
+ for (const u of info.unsafeUrls)
249
+ state.unsafe.push(L(`new URL(variable, import.meta.url) en ${id}: ${u}`, `new URL(variable, import.meta.url) in ${id}: ${u}`));
250
+ for (const u of info.unsafeImports)
251
+ state.unsafe.push(L(`import() con una ruta calculada en ${id}: ${u}`, `import() with a computed path in ${id}: ${u}`));
224
252
  if (state.mode === 'scan') ctx.noteJsInfo(id, info, baseDir, { module: true });
225
253
  if (!info.urls.length) return code;
226
254
  let out = '';
@@ -287,18 +315,30 @@ function modulePlugin(ctx, group, state) {
287
315
  ctx.reportBroken(importer, spec, r);
288
316
  return { path: spec, external: true };
289
317
  }
290
- ctx.requireStandalone(r.rel, `importado como CSS desde ${importer}`);
318
+ ctx.requireStandalone(r.rel, L(`importado como CSS desde ${importer}`, `imported as CSS from ${importer}`));
291
319
  return { path: `${MARK}/${r.rel}`, external: true };
292
320
  }
293
321
  const mapped = mapSpecifier(ctx, group.importMap, spec, baseDir);
294
322
  if (mapped?.external) return { path: spec, external: true };
295
323
  if (mapped?.rel) return { path: ctx.abs(mapped.rel) };
296
324
  if (mapped?.abs) return { path: mapped.abs };
297
- if (mapped?.broken) return { errors: [{ text: `El import map de la página apunta "${spec}" a "${mapped.target}", que no existe.` }] };
325
+ if (mapped?.broken)
326
+ return {
327
+ errors: [
328
+ {
329
+ text: L(
330
+ `El import map de la página apunta "${spec}" a "${mapped.target}", que no existe.`,
331
+ `The page import map points "${spec}" to "${mapped.target}", which does not exist.`,
332
+ ),
333
+ },
334
+ ],
335
+ };
298
336
  if (spec.startsWith('/')) {
299
337
  const r = ctx.resolve(spec, baseDir);
300
338
  if (r.status === 'ok') return { path: ctx.abs(r.rel) };
301
- return { errors: [{ text: `No existe "${spec}" (importado desde ${importer}).` }] };
339
+ return {
340
+ errors: [{ text: L(`No existe "${spec}" (importado desde ${importer}).`, `"${spec}" does not exist (imported from ${importer}).`) }],
341
+ };
302
342
  }
303
343
  return undefined;
304
344
  };
@@ -324,17 +364,21 @@ let terser = null;
324
364
 
325
365
  /** Carga terser (opcional) para la compresión máxima. */
326
366
  export async function ensureTerser(projectRoot) {
327
- terser = await importOptional('terser', projectRoot, 'para la compresión máxima (js.minifier: "terser")');
367
+ terser = await importOptional(
368
+ 'terser',
369
+ projectRoot,
370
+ L('para la compresión máxima (js.minifier: "terser")', 'for maximum compression (js.minifier: "terser")'),
371
+ );
328
372
  }
329
373
 
330
374
  /**
331
375
  * Segunda pasada con terser (varias pasadas de compresión). Solo se acepta si el resultado
332
376
  * es más pequeño y conserva globales (scripts) o exportaciones/importaciones (módulos).
333
377
  */
334
- export async function terserPass(ctx, code, { isModule, level, globals }) {
378
+ export async function terserPass(ctx, code, { isModule, level, globals, allowPrefix = null }) {
335
379
  const js = ctx.cfg.js;
336
380
  if (!terser || js.minifier !== 'terser' || !js.minify || ctx.cfg.sourcemap) return code;
337
- return ctx.heavy.terser(code, { isModule, level, globals: isModule ? undefined : globals, keepNames: js.keepNames });
381
+ return ctx.heavy.terser(code, { isModule, level, globals: isModule ? undefined : globals, allowPrefix, keepNames: js.keepNames });
338
382
  }
339
383
 
340
384
  /** Bytes de origen que aportaron código a una salida (incluye paquetes de node_modules). */
@@ -462,16 +506,23 @@ export async function buildModuleGroup(ctx, group) {
462
506
  try {
463
507
  scan = await esbuild.build({ ...common, target: 'esnext', plugins: [modulePlugin(ctx, group, scanState)] });
464
508
  } catch (err) {
465
- throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
509
+ throw new BuildError(
510
+ L('No se pudieron empaquetar los módulos JavaScript:', 'Could not bundle the JavaScript modules:'),
511
+ esbuildErrorText(err, ctx.root).split('\n'),
512
+ );
466
513
  }
467
514
  const inputs = Object.keys(scan.metafile.inputs)
468
515
  .map((p) => relFrom(ctx.root, path.resolve(ctx.root, p)))
469
516
  .filter((rel) => rel && ctx.files.has(rel));
470
517
  const dynamicWarnings = scan.warnings.filter((w) => w.id === 'unsupported-dynamic-import');
471
518
  let reason = null;
472
- if (!cfg.js.bundle) reason = 'js.bundle está desactivado';
519
+ if (!cfg.js.bundle) reason = L('js.bundle está desactivado', 'js.bundle is disabled');
473
520
  else if (group.forcePerFile) reason = group.forcePerFile;
474
- else if (dynamicWarnings.length) reason = `hay import() con rutas que no se pueden seguir (${formatMessage(dynamicWarnings[0], ctx.root)})`;
521
+ else if (dynamicWarnings.length)
522
+ reason = L(
523
+ `hay import() con rutas que no se pueden seguir (${formatMessage(dynamicWarnings[0], ctx.root)})`,
524
+ `there are import() calls with paths that cannot be followed (${formatMessage(dynamicWarnings[0], ctx.root)})`,
525
+ );
475
526
  else if (scanState.unsafe.length) reason = scanState.unsafe[0];
476
527
  if (reason) return { mode: 'per-file', reason, inputs, consumed: new Set() };
477
528
  for (const w of scan.warnings) ctx.warn(`JS: ${formatMessage(w, ctx.root)}`);
@@ -486,7 +537,10 @@ export async function buildModuleGroup(ctx, group) {
486
537
  emitState.vendorOut = vendor.outByAbs;
487
538
  } catch (err) {
488
539
  ctx.warn(
489
- `no se pudieron separar las dependencias en archivos vendor (${esbuildErrorText(err, ctx.root).split('\n')[0]}); van dentro del paquete y se ofuscarán con él`,
540
+ L(
541
+ `no se pudieron separar las dependencias en archivos vendor (${esbuildErrorText(err, ctx.root).split('\n')[0]}); van dentro del paquete y se ofuscarán con él`,
542
+ `could not split dependencies into vendor files (${esbuildErrorText(err, ctx.root).split('\n')[0]}); they stay in the bundle and will be obfuscated with it`,
543
+ ),
490
544
  );
491
545
  }
492
546
  }
@@ -502,7 +556,10 @@ export async function buildModuleGroup(ctx, group) {
502
556
  plugins: [modulePlugin(ctx, group, emitState)],
503
557
  });
504
558
  } catch (err) {
505
- throw new BuildError('No se pudieron empaquetar los módulos JavaScript:', esbuildErrorText(err, ctx.root).split('\n'));
559
+ throw new BuildError(
560
+ L('No se pudieron empaquetar los módulos JavaScript:', 'Could not bundle the JavaScript modules:'),
561
+ esbuildErrorText(err, ctx.root).split('\n'),
562
+ );
506
563
  }
507
564
  const maps = new Map();
508
565
  const files = [];
package/src/jsinfo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Análisis estático de JavaScript con acorn: nivel de sintaxis, globales, workers y rutas.
2
2
  import * as acorn from 'acorn';
3
3
  import * as walk from 'acorn-walk';
4
+ import { L } from './i18n.js';
4
5
 
5
6
  const VERSIONS = [5, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026];
6
7
  const LATEST = 'latest';
@@ -249,6 +250,51 @@ export function checkSyntax(code, isModule) {
249
250
  }
250
251
  }
251
252
 
253
+ // Nombres que esbuild crea al adaptar sintaxis a navegadores antiguos: temporales (_a, _b2) y ayudantes (__async).
254
+ const ESBUILD_TEMP = /^(_[a-z]\d*|__[A-Za-z]+\d*)$/;
255
+
256
+ /**
257
+ * Al transpilar un script clásico, esbuild puede declarar temporales en el ámbito global (var _a).
258
+ * Si TODAS las globales nuevas son de ese tipo, se renombran en todo el archivo a `${prefix}nombre`
259
+ * (único por archivo), así no pueden chocar con otros scripts. Devuelve el código nuevo, o null si no aplica.
260
+ * El renombrado es consistente en todos los ámbitos, así que no cambia el significado del código.
261
+ */
262
+ export function isolateTempGlobals(code, before, prefix) {
263
+ let ast;
264
+ try {
265
+ ast = parse(code, 'script', LATEST);
266
+ } catch {
267
+ return null;
268
+ }
269
+ const top = globalBindings(
270
+ ast,
271
+ ast.body.some((s) => s.directive === 'use strict'),
272
+ );
273
+ const fresh = [...top.keys()].filter((n) => !before.has(n));
274
+ if (!fresh.length || !fresh.every((n) => ESBUILD_TEMP.test(n))) return null;
275
+ const rename = new Map(fresh.map((n) => [n, `${prefix}${n}`]));
276
+ const edits = new Map();
277
+ walk.fullAncestor(ast, (node, _state, ancestors) => {
278
+ if (node.type !== 'Identifier' || !rename.has(node.name)) return;
279
+ const p = ancestors[ancestors.length - 2];
280
+ const to = rename.get(node.name);
281
+ if (p) {
282
+ if (p.type === 'MemberExpression' && p.property === node && !p.computed) return;
283
+ if (p.type === 'Property' && p.shorthand) {
284
+ // { _a } → { _a: nuevo }: la clave se conserva, el valor se renombra.
285
+ edits.set(node.start, { start: node.start, end: node.end, text: `${node.name}: ${to}` });
286
+ return;
287
+ }
288
+ if ((p.type === 'Property' || p.type === 'MethodDefinition' || p.type === 'PropertyDefinition') && p.key === node && !p.computed) return;
289
+ if (p.type === 'LabeledStatement' || p.type === 'BreakStatement' || p.type === 'ContinueStatement') return;
290
+ }
291
+ if (!edits.has(node.start)) edits.set(node.start, { start: node.start, end: node.end, text: to });
292
+ });
293
+ let out = code;
294
+ for (const e of [...edits.values()].sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
295
+ return out;
296
+ }
297
+
252
298
  /**
253
299
  * Compara las globales de un script antes y después de transformarlo.
254
300
  * `allowPrefix` permite nombres nuevos con ese prefijo (los que añade el ofuscador).
@@ -259,7 +305,7 @@ export function compareGlobals(before, afterCode, allowPrefix = null) {
259
305
  try {
260
306
  ast = parse(afterCode, 'script', LATEST);
261
307
  } catch (err) {
262
- return { ok: false, reason: `el resultado no es JS válido (${err.message})` };
308
+ return { ok: false, reason: L(`el resultado no es JS válido (${err.message})`, `the result is not valid JS (${err.message})`) };
263
309
  }
264
310
  const after = {
265
311
  top: globalBindings(
@@ -268,12 +314,25 @@ export function compareGlobals(before, afterCode, allowPrefix = null) {
268
314
  ),
269
315
  };
270
316
  for (const [name, kind] of before) {
271
- if (!after.top.has(name)) return { ok: false, reason: `desaparece la global "${name}"` };
272
- if (after.top.get(name) !== kind) return { ok: false, reason: `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})` };
317
+ if (!after.top.has(name)) return { ok: false, reason: L(`desaparece la global "${name}"`, `the global "${name}" disappears`) };
318
+ if (after.top.get(name) !== kind)
319
+ return {
320
+ ok: false,
321
+ reason: L(
322
+ `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})`,
323
+ `the global "${name}" changes kind (${kind} → ${after.top.get(name)})`,
324
+ ),
325
+ };
273
326
  }
274
327
  for (const name of after.top.keys()) {
275
328
  if (!before.has(name) && !(allowPrefix && name.startsWith(allowPrefix)))
276
- return { ok: false, reason: `aparece una global nueva "${name}" que podría chocar con otros scripts` };
329
+ return {
330
+ ok: false,
331
+ reason: L(
332
+ `aparece una global nueva "${name}" que podría chocar con otros scripts`,
333
+ `a new global "${name}" appears that could clash with other scripts`,
334
+ ),
335
+ };
277
336
  }
278
337
  return { ok: true };
279
338
  }
package/src/obfuscate.js CHANGED
@@ -3,6 +3,7 @@ import * as acorn from 'acorn';
3
3
  import { shortHash } from './util.js';
4
4
  import { compareGlobals, moduleShape, checkSyntax } from './jsinfo.js';
5
5
  import { importOptional } from './optional.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  // Nunca se activan solos: cambian el comportamiento o rompen código que comparte globales.
8
9
  const FORCED_SAFE = { renameGlobals: false, renameProperties: false, sourceMap: false, target: 'browser' };
@@ -101,7 +102,7 @@ let engine = null;
101
102
  /** Carga javascript-obfuscator (instalado junto a Builder o en el proyecto). */
102
103
  export async function ensureObfuscator(projectRoot) {
103
104
  if (!engine) {
104
- const mod = await importOptional('javascript-obfuscator', projectRoot, 'para ofuscar el JavaScript');
105
+ const mod = await importOptional('javascript-obfuscator', projectRoot, L('para ofuscar el JavaScript', 'to obfuscate JavaScript'));
105
106
  engine = mod.default ?? mod;
106
107
  }
107
108
  return engine;
@@ -143,15 +144,18 @@ export function obfuscateSafe(code, { isModule, options, id, globals }) {
143
144
  try {
144
145
  out = engine.obfuscate(body, opts).getObfuscatedCode();
145
146
  } catch (err) {
146
- return { code: null, reason: `el ofuscador falló (${String(err.message).split('\n')[0]})` };
147
+ return {
148
+ code: null,
149
+ reason: L(`el ofuscador falló (${String(err.message).split('\n')[0]})`, `the obfuscator failed (${String(err.message).split('\n')[0]})`),
150
+ };
147
151
  }
148
152
  if (isModule) {
149
153
  const err = checkSyntax(out, true);
150
- if (err) return { code: null, reason: `resultado inválido (${err})` };
154
+ if (err) return { code: null, reason: L(`resultado inválido (${err})`, `invalid result (${err})`) };
151
155
  const a = moduleShape(body);
152
156
  const b = moduleShape(out);
153
157
  if (!a || !b || a.exports !== b.exports || a.imports !== b.imports)
154
- return { code: null, reason: 'cambiaron las exportaciones o importaciones del módulo' };
158
+ return { code: null, reason: L('cambiaron las exportaciones o importaciones del módulo', 'the module exports or imports changed') };
155
159
  } else {
156
160
  const cmp = compareGlobals(globals, out, prefix);
157
161
  if (!cmp.ok) return { code: null, reason: cmp.reason };
package/src/optional.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { BuildError } from './util.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  const cache = new Map();
8
9
 
@@ -25,9 +26,12 @@ export async function importOptional(name, projectRoot, purpose) {
25
26
  }
26
27
  }
27
28
  if (!mod)
28
- throw new BuildError(`Falta el paquete opcional "${name}" ${purpose}.`, [
29
- `Instálalo una vez con: npm install --save-dev ${name}`,
30
- 'Builder lo carga solo cuando hace falta, por eso no viene incluido (así la instalación base es ligera).',
29
+ throw new BuildError(L(`Falta el paquete opcional "${name}" ${purpose}.`, `Missing optional package "${name}" ${purpose}.`), [
30
+ L(`Instálalo una vez con: npm install --save-dev ${name}`, `Install it once with: npm install --save-dev ${name}`),
31
+ L(
32
+ 'Builder lo carga solo cuando hace falta, por eso no viene incluido (así la instalación base es ligera).',
33
+ 'Builder loads it only when needed, so it is not bundled (this keeps the base install small).',
34
+ ),
31
35
  ]);
32
36
  cache.set(name, mod);
33
37
  return mod;
package/src/output.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { BuildError, isInside, retryFs, copyDir } from './util.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  export const MANIFEST = '.builder-manifest.json';
8
9
 
@@ -12,18 +13,40 @@ export function assertSafeOutDir(cfg, { force = false } = {}) {
12
13
  const root = path.resolve(cfg.root);
13
14
  const parsed = path.parse(out);
14
15
  const same = (a, b) => path.relative(a, b) === '';
15
- if (same(out, root)) throw new BuildError('"outDir" no puede ser la propia carpeta del proyecto: se borraría el código fuente.');
16
- if (isInside(out, root)) throw new BuildError(`"outDir" (${out}) contiene al proyecto; se borraría el código fuente.`);
17
- if (same(out, parsed.root)) throw new BuildError('"outDir" no puede ser la raíz de una unidad.');
18
- if (same(out, os.homedir())) throw new BuildError('"outDir" no puede ser tu carpeta de usuario.');
16
+ if (same(out, root))
17
+ throw new BuildError(
18
+ L(
19
+ '"outDir" no puede ser la propia carpeta del proyecto: se borraría el código fuente.',
20
+ '"outDir" cannot be the project folder itself: the source code would be deleted.',
21
+ ),
22
+ );
23
+ if (isInside(out, root))
24
+ throw new BuildError(
25
+ L(
26
+ `"outDir" (${out}) contiene al proyecto; se borraría el código fuente.`,
27
+ `"outDir" (${out}) contains the project; the source code would be deleted.`,
28
+ ),
29
+ );
30
+ if (same(out, parsed.root)) throw new BuildError(L('"outDir" no puede ser la raíz de una unidad.', '"outDir" cannot be the root of a drive.'));
31
+ if (same(out, os.homedir())) throw new BuildError(L('"outDir" no puede ser tu carpeta de usuario.', '"outDir" cannot be your home folder.'));
19
32
  if (!cfg.clean || force) return;
20
33
  if (fs.existsSync(out)) {
21
- if (!fs.statSync(out).isDirectory()) throw new BuildError(`"outDir" existe y no es una carpeta: ${out}`);
34
+ if (!fs.statSync(out).isDirectory())
35
+ throw new BuildError(L(`"outDir" existe y no es una carpeta: ${out}`, `"outDir" exists and is not a folder: ${out}`));
22
36
  const entries = fs.readdirSync(out);
23
37
  if (entries.length && !entries.includes(MANIFEST))
24
- throw new BuildError(`La carpeta de salida ${out} ya tiene archivos y no la creó Builder. No la borro para no perder datos.`, [
25
- 'Elige otra carpeta con --out, vacíala tú mismo o usa --force si estás seguro.',
26
- ]);
38
+ throw new BuildError(
39
+ L(
40
+ `La carpeta de salida ${out} ya tiene archivos y no la creó Builder. No la borro para no perder datos.`,
41
+ `The output folder ${out} already has files and was not created by Builder. Not deleting it, to avoid losing data.`,
42
+ ),
43
+ [
44
+ L(
45
+ 'Elige otra carpeta con --out, vacíala tú mismo o usa --force si estás seguro.',
46
+ 'Choose another folder with --out, empty it yourself, or use --force if you are sure.',
47
+ ),
48
+ ],
49
+ );
27
50
  }
28
51
  }
29
52
 
@@ -89,7 +112,12 @@ export async function swapIntoPlace(cfg, staging) {
89
112
  await rmrf(staging);
90
113
  } catch (copyErr) {
91
114
  if (exists) await retryFs(() => fs.promises.rename(old, out)).catch(() => {});
92
- throw new BuildError(`No pude colocar la salida en ${out}: ${copyErr.message ?? err.message}`);
115
+ throw new BuildError(
116
+ L(
117
+ `No pude colocar la salida en ${out}: ${copyErr.message ?? err.message}`,
118
+ `Could not place the output in ${out}: ${copyErr.message ?? err.message}`,
119
+ ),
120
+ );
93
121
  }
94
122
  }
95
123
  if (exists) await rmrf(old).catch(() => {});
package/src/plugins.js ADDED
@@ -0,0 +1,126 @@
1
+ // Plugins: ganchos sencillos para casos que Builder no cubre.
2
+ //
3
+ // export default {
4
+ // plugins: [
5
+ // {
6
+ // name: 'version',
7
+ // filter: /\.(html|js)$/, // opcional: a qué archivos se aplica transform
8
+ // transform: ({ path, code }) => code.replaceAll('__VERSION__', '1.2.3'),
9
+ // buildStart: ({ config, mode }) => {},
10
+ // buildEnd: ({ result, outDir }) => {},
11
+ // },
12
+ // ],
13
+ // };
14
+ //
15
+ // En builder.config.json se nombran paquetes: "plugins": ["mi-plugin", ["otro-plugin", { "opcion": 1 }]].
16
+ // Un paquete exporta el objeto plugin o una función (opciones) => plugin.
17
+ //
18
+ // transform cambia el CÓDIGO FUENTE (HTML, CSS, JS, TS) antes de procesarlo: así el hash, el SRI
19
+ // y la verificación final se calculan sobre el resultado del plugin.
20
+ import path from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { BuildError } from './util.js';
24
+ import { L } from './i18n.js';
25
+
26
+ export const TRANSFORM_EXT = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']);
27
+ const HOOKS = ['transform', 'buildStart', 'buildEnd'];
28
+
29
+ /** Errores de validación de la opción "plugins" (antes de cargar paquetes). */
30
+ export function validatePlugins(list) {
31
+ if (!Array.isArray(list)) return [L('"plugins" debe ser una lista.', '"plugins" must be a list.')];
32
+ const errors = [];
33
+ list.forEach((p, i) => {
34
+ const ok =
35
+ typeof p === 'string' ||
36
+ (Array.isArray(p) && typeof p[0] === 'string' && p.length <= 2) ||
37
+ (p && typeof p === 'object' && !Array.isArray(p) && typeof p.name === 'string');
38
+ if (!ok)
39
+ errors.push(
40
+ L(
41
+ `"plugins[${i}]" debe ser un nombre de paquete, [nombre, opciones] o un objeto con "name".`,
42
+ `"plugins[${i}]" must be a package name, [name, options] or an object with "name".`,
43
+ ),
44
+ );
45
+ else if (p && typeof p === 'object' && !Array.isArray(p)) {
46
+ for (const k of Object.keys(p))
47
+ if (!['name', 'filter', ...HOOKS].includes(k))
48
+ errors.push(
49
+ L(
50
+ `"plugins[${i}].${k}" no es un gancho conocido (${HOOKS.join(', ')}).`,
51
+ `"plugins[${i}].${k}" is not a known hook (${HOOKS.join(', ')}).`,
52
+ ),
53
+ );
54
+ }
55
+ });
56
+ return errors;
57
+ }
58
+
59
+ /** Carga los plugins nombrados como paquete (desde el proyecto) y devuelve objetos plugin. */
60
+ export async function loadPlugins(list, root) {
61
+ const out = [];
62
+ for (const entry of list) {
63
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
64
+ out.push(entry);
65
+ continue;
66
+ }
67
+ const [name, options] = Array.isArray(entry) ? entry : [entry, undefined];
68
+ let mod;
69
+ try {
70
+ const req = createRequire(path.join(root, 'package.json'));
71
+ mod = await import(pathToFileURL(req.resolve(name)).href);
72
+ } catch (err) {
73
+ throw new BuildError(L(`No se pudo cargar el plugin "${name}": ${err.message}`, `Could not load the plugin "${name}": ${err.message}`), [
74
+ L(`Instálalo en el proyecto: npm install --save-dev ${name}`, `Install it in the project: npm install --save-dev ${name}`),
75
+ ]);
76
+ }
77
+ const exported = mod.default ?? mod;
78
+ const plugin = typeof exported === 'function' ? await exported(options ?? {}) : exported;
79
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
80
+ throw new BuildError(
81
+ L(
82
+ `El plugin "${name}" debe exportar un objeto con "name" o una función que lo devuelva.`,
83
+ `The plugin "${name}" must export an object with "name" or a function that returns one.`,
84
+ ),
85
+ );
86
+ out.push(plugin);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ async function call(plugin, hook, arg, where = '') {
92
+ try {
93
+ return await plugin[hook](arg);
94
+ } catch (err) {
95
+ throw new BuildError(
96
+ L(
97
+ `El plugin "${plugin.name}" falló en ${hook}${where}: ${err.message}`,
98
+ `The plugin "${plugin.name}" failed in ${hook}${where}: ${err.message}`,
99
+ ),
100
+ );
101
+ }
102
+ }
103
+
104
+ /** Ejecuta un gancho sin valor de retorno (buildStart, buildEnd) en todos los plugins, en orden. */
105
+ export async function runHook(plugins, hook, arg) {
106
+ for (const p of plugins) if (typeof p[hook] === 'function') await call(p, hook, arg);
107
+ }
108
+
109
+ /** Pasa el código de un archivo por los transform de todos los plugins, en orden. */
110
+ export async function runTransform(plugins, rel, code) {
111
+ let current = code;
112
+ for (const p of plugins) {
113
+ if (typeof p.transform !== 'function') continue;
114
+ if (p.filter instanceof RegExp && !p.filter.test(rel)) continue;
115
+ const r = await call(p, 'transform', { path: rel, code: current }, ` (${rel})`);
116
+ if (typeof r === 'string') current = r;
117
+ else if (r != null)
118
+ throw new BuildError(
119
+ L(
120
+ `El plugin "${p.name}" devolvió algo que no es texto en transform (${rel}).`,
121
+ `The plugin "${p.name}" returned something that is not text from transform (${rel}).`,
122
+ ),
123
+ );
124
+ }
125
+ return current;
126
+ }