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.
package/src/builder.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // Orquestador: descubre dependencias, procesa cada tipo de archivo, verifica y publica la salida.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import zlib from 'node:zlib';
4
+ import { createHeavy, defaultCacheDir, autoWorkers } from './pool.js';
5
+ import { gzipOf, brotliOf, mapLimit } from './compress.js';
5
6
  import { scanProject, matchFiles, globMatcher } from './files.js';
6
7
  import { resolveRef, classifyUrl, parseSrcset } from './urls.js';
7
8
  import { analyzeJs } from './jsinfo.js';
@@ -18,9 +19,13 @@ import {
18
19
  levelOf,
19
20
  } from './js.js';
20
21
  import { resolveObfuscatorOptions, ensureObfuscator } from './obfuscate.js';
21
- import { detectDependency } from './deps.js';
22
+ import { detectDependency, dependencyPragma, packageNames } from './deps.js';
22
23
  import { assertSafeOutDir, writeStaging, swapIntoPlace, discardStaging, writeManifest, MANIFEST } from './output.js';
23
24
  import { verifyOutput } from './verify.js';
25
+ import { rewriteTsImports } from './jsimports.js';
26
+ import { IMAGE_EXT, ensureSharp, imageOptions, optimizeImage } from './images.js';
27
+ import { checkBudgets } from './budgets.js';
28
+ import { TRANSFORM_EXT, runHook, runTransform } from './plugins.js';
24
29
  import { buildReport } from './report.js';
25
30
  import {
26
31
  BuildError,
@@ -29,6 +34,8 @@ import {
29
34
  BOM,
30
35
  extOf,
31
36
  JS_EXT,
37
+ TS_EXT,
38
+ jsOutPath,
32
39
  CSS_EXT,
33
40
  HTML_EXT,
34
41
  shortHash,
@@ -37,10 +44,11 @@ import {
37
44
  minifyJsonText,
38
45
  hasOwn,
39
46
  } from './util.js';
47
+ import { L } from './i18n.js';
40
48
 
41
49
  export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
42
50
 
43
- const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language']);
51
+ const MERGE_ATTRS = new Set(['src', 'type', 'defer', 'charset', 'language', 'data-builder']);
44
52
  const typeOfExt = (ext) =>
45
53
  HTML_EXT.has(ext)
46
54
  ? 'html'
@@ -88,6 +96,10 @@ class Context {
88
96
  this.depIncluded = globMatcher(cfg.dependencies.include);
89
97
  this.depExcluded = globMatcher(cfg.dependencies.exclude);
90
98
  this.depCache = new Map();
99
+ this.depMarks = new Map();
100
+ this.tsCode = new Map();
101
+ this.sourceOverrides = new Map();
102
+ this.pkgNames = null;
91
103
  this.vendorPackages = [];
92
104
  this.chunkDir = cfg.js.chunkDir.replace(/\/+$/, '');
93
105
  }
@@ -122,6 +134,10 @@ class Context {
122
134
  }
123
135
 
124
136
  readText(rel, { kind = 'text', charset } = {}) {
137
+ // TypeScript: como JS se lee la versión ya transpilada (sin tipos; ya incluye los cambios de los plugins).
138
+ if (kind === 'js' && this.tsCode.has(rel)) return { text: this.tsCode.get(rel), bom: false, encoding: 'utf8' };
139
+ // Código fuente cambiado por un plugin (transform): es el que se procesa a partir de aquí.
140
+ if (this.sourceOverrides.has(rel)) return { ...this.sourceOverrides.get(rel), encoding: 'utf8' };
125
141
  const key = `${rel}\0${kind}`;
126
142
  if (this.textCache.has(key)) return this.textCache.get(key);
127
143
  const buf = fs.readFileSync(this.abs(rel));
@@ -132,7 +148,13 @@ class Context {
132
148
  // latin1 conserva cada byte tal cual: lo que no se edita sale idéntico.
133
149
  text = buf.toString('latin1');
134
150
  encoding = 'latin1';
135
- this.warn('no es UTF-8; se conserva su codificación original y no se minifica', rel);
151
+ this.warn(
152
+ L(
153
+ 'no es UTF-8; se conserva su codificación original y no se minifica',
154
+ 'is not UTF-8; its original encoding is kept and it is not minified',
155
+ ),
156
+ rel,
157
+ );
136
158
  } else {
137
159
  let label = (charset || 'windows-1252').toLowerCase();
138
160
  try {
@@ -142,7 +164,13 @@ class Context {
142
164
  text = new TextDecoder(label).decode(buf);
143
165
  }
144
166
  encoding = label;
145
- this.note(`no es UTF-8; se leyó como ${label} y la salida usa escapes ASCII (no depende de la codificación)`, rel);
167
+ this.note(
168
+ L(
169
+ `no es UTF-8; se leyó como ${label} y la salida usa escapes ASCII (no depende de la codificación)`,
170
+ `is not UTF-8; it was read as ${label} and the output uses ASCII escapes (independent of the encoding)`,
171
+ ),
172
+ rel,
173
+ );
146
174
  }
147
175
  }
148
176
  const stripped = stripBom(text);
@@ -163,17 +191,36 @@ class Context {
163
191
  if (this.brokenKeys.has(key)) return;
164
192
  this.brokenKeys.add(key);
165
193
  this.broken.push({ from, url, status: r.status, target: r.rel });
166
- if (r.status === 'ignored') this.warn(`referencia a "${url}", pero ese archivo está excluido (ignore). No se publicará.`, from);
167
- else if (r.status === 'case') this.warn(`"${url}" solo existe como "${r.rel}" (otras mayúsculas): en un servidor Linux daría 404.`, from);
168
- else if (r.status === 'missing') this.warn(`"${url}" no existe (ya estaba rota en el código fuente).`, from);
169
- else if (r.status === 'outside') this.note(`"${url}" apunta fuera del proyecto; se deja igual.`, from);
194
+ if (r.status === 'ignored')
195
+ this.warn(
196
+ L(
197
+ `referencia a "${url}", pero ese archivo está excluido (ignore). No se publicará.`,
198
+ `references "${url}", but that file is excluded (ignore). It will not be published.`,
199
+ ),
200
+ from,
201
+ );
202
+ else if (r.status === 'case')
203
+ this.warn(
204
+ L(
205
+ `"${url}" solo existe como "${r.rel}" (otras mayúsculas): en un servidor Linux daría 404.`,
206
+ `"${url}" only exists as "${r.rel}" (different capitalization): it would be a 404 on a Linux server.`,
207
+ ),
208
+ from,
209
+ );
210
+ else if (r.status === 'missing')
211
+ this.warn(
212
+ L(`"${url}" no existe (ya estaba rota en el código fuente).`, `"${url}" does not exist (it was already broken in the source code).`),
213
+ from,
214
+ );
215
+ else if (r.status === 'outside')
216
+ this.note(L(`"${url}" apunta fuera del proyecto; se deja igual.`, `"${url}" points outside the project; left as is.`), from);
170
217
  }
171
218
 
172
219
  registerAsset(rel, from) {
173
220
  if (!this.assets.has(rel)) this.assets.set(rel, new Set());
174
221
  if (from) this.assets.get(rel).add(from);
175
222
  const ext = extOf(rel);
176
- if (JS_EXT.has(ext) || CSS_EXT.has(ext)) this.requireStandalone(rel, `referenciado desde ${from}`);
223
+ if (JS_EXT.has(ext) || CSS_EXT.has(ext)) this.requireStandalone(rel, L(`referenciado desde ${from}`, `referenced from ${from}`));
177
224
  }
178
225
 
179
226
  assetOut(rel) {
@@ -184,9 +231,9 @@ class Context {
184
231
  urlTarget(rel, from) {
185
232
  const ext = extOf(rel);
186
233
  if (JS_EXT.has(ext) || CSS_EXT.has(ext)) {
187
- if (!this.workers.has(rel)) this.requireStandalone(rel, `new URL() en ${from}`);
234
+ if (!this.workers.has(rel)) this.requireStandalone(rel, L(`new URL() en ${from}`, `new URL() in ${from}`));
188
235
  } else this.registerAsset(rel, from);
189
- return rel;
236
+ return jsOutPath(rel);
190
237
  }
191
238
 
192
239
  requireStandalone(rel, reason, extra = {}) {
@@ -197,7 +244,14 @@ class Context {
197
244
  registerWorker(rel, info) {
198
245
  const prev = this.workers.get(rel);
199
246
  if (prev) {
200
- if (prev.module !== info.module) this.warn(`se usa como worker clásico y de módulo a la vez; se trata como módulo`, rel);
247
+ if (prev.module !== info.module)
248
+ this.warn(
249
+ L(
250
+ `se usa como worker clásico y de módulo a la vez; se trata como módulo`,
251
+ `used as both a classic and a module worker; treated as a module`,
252
+ ),
253
+ rel,
254
+ );
201
255
  prev.module = prev.module || info.module;
202
256
  return;
203
257
  }
@@ -208,7 +262,13 @@ class Context {
208
262
  noteJsInfo(id, info, baseDir) {
209
263
  for (const w of info.workers) {
210
264
  if (!w.spec) {
211
- this.warn(`${w.kind} con una ruta que no es texto fijo; Builder no puede seguirla (el archivo se procesará suelto si existe).`, id);
265
+ this.warn(
266
+ L(
267
+ `${w.kind} con una ruta que no es texto fijo; Builder no puede seguirla (el archivo se procesará suelto si existe).`,
268
+ `${w.kind} with a path that is not fixed text; Builder cannot follow it (the file will be processed on its own if it exists).`,
269
+ ),
270
+ id,
271
+ );
212
272
  continue;
213
273
  }
214
274
  const c = classifyUrl(w.spec);
@@ -219,7 +279,7 @@ class Context {
219
279
  this.reportBroken(id, w.spec, this.resolve(w.spec, baseDir));
220
280
  continue;
221
281
  }
222
- if (w.kind === 'importScripts') this.requireStandalone(rel, `importScripts en ${id}`, { classic: true });
282
+ if (w.kind === 'importScripts') this.requireStandalone(rel, L(`importScripts en ${id}`, `importScripts in ${id}`), { classic: true });
223
283
  else this.registerWorker(rel, { module: w.module, kind: w.kind, from: id });
224
284
  }
225
285
  for (const s of info.strings) {
@@ -235,23 +295,53 @@ class Context {
235
295
  }
236
296
 
237
297
  hasOwnOutput(rel) {
238
- const o = this.outputs.get(rel);
298
+ const o = this.outputs.get(rel) ?? (TS_EXT.has(extOf(rel)) ? this.outputs.get(jsOutPath(rel)) : undefined);
239
299
  return !!o && o.sources?.includes(rel);
240
300
  }
241
301
 
242
302
  /** ¿Es una librería de terceros? (a mano en dependencies.include/exclude, o detectada). */
303
+ // Prioridad: configuración (include/exclude) > marca en el HTML o en el archivo > detección automática.
243
304
  dependencyReason(rel) {
244
305
  if (this.depCache.has(rel)) return this.depCache.get(rel);
245
306
  const deps = this.cfg.dependencies;
307
+ const isJs = JS_EXT.has(extOf(rel)) && this.files.has(rel);
308
+ const text = isJs ? this.readText(rel, { kind: 'js' }).text : '';
309
+ const pragma = isJs ? dependencyPragma(text) : null;
310
+ const mark = this.depMarks.get(rel);
246
311
  let reason = null;
247
312
  if (this.depExcluded(rel)) reason = null;
248
313
  else if (this.depIncluded(rel)) reason = 'marcado a mano (dependencies.include)';
249
- else if (/(^|\/)node_modules\//.test(rel)) reason = 'paquete de npm';
250
- else if (deps.detect && JS_EXT.has(extOf(rel)) && this.files.has(rel)) reason = detectDependency(rel, this.readText(rel, { kind: 'js' }).text);
314
+ else if (mark === 'own' || pragma === 'own') reason = null;
315
+ else if (mark === 'dependency') reason = L('marcado en el HTML (data-builder="dependency")', 'marked in the HTML (data-builder="dependency")');
316
+ else if (pragma === 'dependency')
317
+ reason = L('marcado en el propio archivo (@builder-dependency)', 'marked in the file itself (@builder-dependency)');
318
+ else if (/(^|\/)node_modules\//.test(rel)) reason = L('paquete de npm', 'npm package');
319
+ else if (deps.detect && isJs) reason = detectDependency(rel, text, { packages: this.packageHints() });
251
320
  this.depCache.set(rel, reason);
252
321
  return reason;
253
322
  }
254
323
 
324
+ /** Paquetes del package.json más cercano (para reconocer librerías copiadas a mano). */
325
+ packageHints() {
326
+ if (this.pkgNames) return this.pkgNames;
327
+ this.pkgNames = new Map();
328
+ for (let dir = this.root, i = 0; i < 10; i++) {
329
+ const file = path.join(dir, 'package.json');
330
+ if (fs.existsSync(file)) {
331
+ try {
332
+ this.pkgNames = packageNames(JSON.parse(fs.readFileSync(file, 'utf8')));
333
+ } catch {
334
+ // package.json ilegible: no aporta pistas
335
+ }
336
+ break;
337
+ }
338
+ const parent = path.dirname(dir);
339
+ if (parent === dir) break;
340
+ dir = parent;
341
+ }
342
+ return this.pkgNames;
343
+ }
344
+
255
345
  shouldObfuscate(rels) {
256
346
  if (!this.obfuscatorOptions) return false;
257
347
  const list = rels.filter(Boolean);
@@ -263,23 +353,35 @@ class Context {
263
353
  /** Registra un archivo de salida; nunca sobrescribe en silencio un contenido distinto. */
264
354
  emit(outRel, data, meta) {
265
355
  const norm = path.posix.normalize(outRel);
266
- if (norm.startsWith('../') || path.posix.isAbsolute(norm) || norm === MANIFEST) throw new BuildError(`Ruta de salida inválida: ${outRel}`);
356
+ if (norm.startsWith('../') || path.posix.isAbsolute(norm) || norm === MANIFEST)
357
+ throw new BuildError(L(`Ruta de salida inválida: ${outRel}`, `Invalid output path: ${outRel}`));
267
358
  const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, meta.encoding === 'latin1' ? 'latin1' : 'utf8');
268
359
  const prev = this.outputs.get(norm);
269
360
  if (prev) {
270
361
  const same = prev.copyFrom ? fs.readFileSync(prev.copyFrom).equals(buf) : prev.data.equals(buf);
271
362
  if (!same)
272
- throw new BuildError(`Dos resultados distintos quieren escribirse en "${norm}".`, [
273
- `Primero: ${(prev.sources ?? []).join(', ') || prev.action}`,
274
- `Después: ${(meta.sources ?? []).join(', ') || meta.action}`,
275
- 'Suele pasar si el mismo archivo se usa con import maps distintos o si una carpeta del proyecto se llama igual que js.chunkDir.',
276
- ]);
363
+ throw new BuildError(
364
+ L(`Dos resultados distintos quieren escribirse en "${norm}".`, `Two different results want to be written to "${norm}".`),
365
+ [
366
+ L(`Primero: ${(prev.sources ?? []).join(', ') || prev.action}`, `First: ${(prev.sources ?? []).join(', ') || prev.action}`),
367
+ L(`Después: ${(meta.sources ?? []).join(', ') || meta.action}`, `Second: ${(meta.sources ?? []).join(', ') || meta.action}`),
368
+ L(
369
+ 'Suele pasar si el mismo archivo se usa con import maps distintos o si una carpeta del proyecto se llama igual que js.chunkDir.',
370
+ 'This usually happens when the same file is used with different import maps, or when a project folder has the same name as js.chunkDir.',
371
+ ),
372
+ ],
373
+ );
277
374
  prev.sources = [...new Set([...(prev.sources ?? []), ...(meta.sources ?? [])])];
278
375
  return norm;
279
376
  }
280
377
  const lower = norm.toLowerCase();
281
378
  if (this.outLower.has(lower) && this.outLower.get(lower) !== norm)
282
- this.warn(`"${norm}" y "${this.outLower.get(lower)}" solo se diferencian en mayúsculas; en Windows/macOS se pisarían.`);
379
+ this.warn(
380
+ L(
381
+ `"${norm}" y "${this.outLower.get(lower)}" solo se diferencian en mayúsculas; en Windows/macOS se pisarían.`,
382
+ `"${norm}" and "${this.outLower.get(lower)}" differ only in capitalization; on Windows/macOS they would overwrite each other.`,
383
+ ),
384
+ );
283
385
  this.outLower.set(lower, norm);
284
386
  this.outputs.set(norm, { ...meta, data: buf });
285
387
  return norm;
@@ -287,6 +389,8 @@ class Context {
287
389
 
288
390
  emitCopy(outRel, rel, action = 'copy') {
289
391
  const f = this.files.get(rel);
392
+ const override = this.sourceOverrides.get(rel);
393
+ if (override) return this.emit(outRel, (override.bom ? BOM : '') + override.text, { type: typeOfExt(f.ext), action, sources: [rel] });
290
394
  const prev = this.outputs.get(outRel);
291
395
  if (prev) {
292
396
  if (prev.sources?.includes(rel)) return outRel;
@@ -348,7 +452,7 @@ function loadPages(ctx) {
348
452
  try {
349
453
  model = parseHtml(text);
350
454
  } catch (err) {
351
- ctx.warn(`no se pudo analizar el HTML (${err.message}); se copia tal cual`, rel);
455
+ ctx.warn(L(`no se pudo analizar el HTML (${err.message}); se copia tal cual`, `could not parse the HTML (${err.message}); copied as is`), rel);
352
456
  continue;
353
457
  }
354
458
  const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
@@ -357,7 +461,13 @@ function loadPages(ctx) {
357
461
  const c = classifyUrl(model.base);
358
462
  if (c.type === 'external') {
359
463
  page.baseExternal = true;
360
- ctx.warn(`<base href="${model.base}"> apunta fuera del sitio; sus rutas relativas no se pueden comprobar y se dejan igual.`, rel);
464
+ ctx.warn(
465
+ L(
466
+ `<base href="${model.base}"> apunta fuera del sitio; sus rutas relativas no se pueden comprobar y se dejan igual.`,
467
+ `<base href="${model.base}"> points outside the site; its relative paths cannot be checked and are left as they are.`,
468
+ ),
469
+ rel,
470
+ );
361
471
  } else if (c.type === 'root' || c.type === 'relative') {
362
472
  const p = c.type === 'root' ? (c.path.startsWith(ctx.base) ? c.path.slice(ctx.base.length) : c.path.slice(1)) : path.posix.join(dir, c.path);
363
473
  page.baseDir = path.posix
@@ -372,6 +482,93 @@ function loadPages(ctx) {
372
482
  }
373
483
 
374
484
  /** Decide qué páginas se procesan completas (entradas + enlazadas) y cuáles en modo conservador. */
485
+ /** Pasa el código fuente por los plugins con transform (en paralelo entre archivos, en orden entre plugins). */
486
+ async function applyTransforms(ctx) {
487
+ const plugins = ctx.cfg.plugins.filter((p) => typeof p.transform === 'function');
488
+ if (!plugins.length) return;
489
+ const list = [...ctx.files.keys()].filter((rel) => TRANSFORM_EXT.has(extOf(rel)));
490
+ await mapLimit(list, 16, async (rel) => {
491
+ const text = decodeUtf8(fs.readFileSync(ctx.abs(rel)));
492
+ if (text == null) return; // otras codificaciones: se respetan los bytes originales
493
+ const src = stripBom(text);
494
+ const out = await runTransform(plugins, rel, src.text);
495
+ if (out !== src.text) ctx.sourceOverrides.set(rel, { text: out, bom: src.bom });
496
+ });
497
+ }
498
+
499
+ /**
500
+ * Transpila cada archivo TypeScript a JavaScript (solo quita los tipos: no baja el nivel de sintaxis).
501
+ * Usa el tsconfig.json del proyecto si existe. Lo que falle se avisa y sigue el camino normal
502
+ * (se copia sin cambios), así que nunca se pierde nada.
503
+ */
504
+ async function transpileTypeScript(ctx) {
505
+ const list = [...ctx.files.keys()].filter((rel) => TS_EXT.has(extOf(rel)));
506
+ if (!list.length) return;
507
+ let tsconfigRaw;
508
+ const tsconfig = path.join(ctx.root, 'tsconfig.json');
509
+ if (fs.existsSync(tsconfig)) tsconfigRaw = fs.readFileSync(tsconfig, 'utf8');
510
+ const esbuild = await import('esbuild');
511
+ await mapLimit(list, 32, async (rel) => {
512
+ const source = ctx.readText(rel, { kind: 'ts' }).text;
513
+ try {
514
+ const r = await esbuild.transform(source, {
515
+ loader: 'ts',
516
+ sourcefile: rel,
517
+ target: 'esnext',
518
+ tsconfigRaw,
519
+ logLevel: 'silent',
520
+ supported: { 'import-attributes': true },
521
+ });
522
+ const dir = path.posix.dirname(rel);
523
+ // "./x" → la ruta que existirá en la salida (x.ts → x.js, x/index.ts → x/index.js…).
524
+ const complete = (spec) => {
525
+ const base = path.posix.normalize(path.posix.join(dir, spec));
526
+ for (const cand of ['.ts', '.mts', '.js', '.mjs', '/index.ts', '/index.js'].map((s) => base + s)) {
527
+ if (ctx.files.has(cand)) return spec + jsOutPath(cand).slice(base.length);
528
+ }
529
+ return null;
530
+ };
531
+ ctx.tsCode.set(rel, rewriteTsImports(r.code, complete));
532
+ } catch (err) {
533
+ const msg = String(err.errors?.[0]?.text ?? err.message).split('\n')[0];
534
+ ctx.warn(L(`TypeScript no válido (${msg}); se copia sin transpilar`, `invalid TypeScript (${msg}); copied without transpiling`), rel);
535
+ }
536
+ });
537
+ }
538
+
539
+ /** <script src="..." data-builder="dependency|own">: el usuario marca librerías (o código propio) en el HTML. */
540
+ const MARK_VALUES = { dependency: 'dependency', dependencia: 'dependency', dep: 'dependency', own: 'own', propio: 'own' };
541
+ function markDependencies(ctx, pages) {
542
+ for (const page of pages.values()) {
543
+ for (const s of page.model.scripts) {
544
+ if (!s.src || !s.attrs.has('data-builder')) continue;
545
+ const raw = String(s.attrs.get('data-builder') ?? '')
546
+ .trim()
547
+ .toLowerCase();
548
+ const value = MARK_VALUES[raw];
549
+ if (!value) {
550
+ ctx.warn(
551
+ L(`data-builder="${raw}" no se reconoce; usa "dependency" o "own"`, `data-builder="${raw}" is not recognized; use "dependency" or "own"`),
552
+ page.rel,
553
+ );
554
+ continue;
555
+ }
556
+ const res = ctx.resolve(s.src.value, page.baseDir);
557
+ if (res.status !== 'ok') continue;
558
+ const prev = ctx.depMarks.get(res.rel);
559
+ if (prev && prev !== value)
560
+ ctx.warn(
561
+ L(
562
+ `"${res.rel}" está marcado como "${prev}" y como "${value}" en distintas páginas; se usa "${prev}"`,
563
+ `"${res.rel}" is marked as "${prev}" and as "${value}" on different pages; using "${prev}"`,
564
+ ),
565
+ page.rel,
566
+ );
567
+ else ctx.depMarks.set(res.rel, value);
568
+ }
569
+ }
570
+ }
571
+
375
572
  function selectEntries(ctx, pages) {
376
573
  const cfg = ctx.cfg;
377
574
  const extraEntries = { modules: [], classic: [], css: [] };
@@ -380,7 +577,7 @@ function selectEntries(ctx, pages) {
380
577
  queue = [...pages.values()].filter((p) => p.model.isDocument).map((p) => p.rel);
381
578
  } else {
382
579
  const { matched, missing } = matchFiles(ctx.files, cfg.entries);
383
- if (missing.length) throw new BuildError('Algunas entradas de "entries" no existen:', missing);
580
+ if (missing.length) throw new BuildError(L('Algunas entradas de "entries" no existen:', 'Some "entries" do not exist:'), missing);
384
581
  for (const rel of matched) {
385
582
  const ext = extOf(rel);
386
583
  if (HTML_EXT.has(ext)) queue.push(rel);
@@ -388,7 +585,11 @@ function selectEntries(ctx, pages) {
388
585
  else if (JS_EXT.has(ext)) {
389
586
  const info = ctx.jsInfo(rel);
390
587
  (info.ok && (info.hasModuleSyntax || info.hasImportMeta) ? extraEntries.modules : extraEntries.classic).push(rel);
391
- } else ctx.warn(`"${rel}" no es HTML, JS ni CSS; se copiará como recurso`, 'entries');
588
+ } else
589
+ ctx.warn(
590
+ L(`"${rel}" no es HTML, JS ni CSS; se copiará como recurso`, `"${rel}" is not HTML, JS or CSS; it will be copied as an asset`),
591
+ 'entries',
592
+ );
392
593
  }
393
594
  }
394
595
  const full = new Set();
@@ -447,7 +648,10 @@ function parseImportMap(ctx, page, item) {
447
648
  const json = JSON.parse(item.inline.code);
448
649
  return { json, imports: json.imports ?? {}, scopes: json.scopes, baseDir: page.baseDir };
449
650
  } catch (err) {
450
- ctx.warn(`el import map no es JSON válido (${err.message}); se ignora`, page.rel);
651
+ ctx.warn(
652
+ L(`el import map no es JSON válido (${err.message}); se ignora`, `the import map is not valid JSON (${err.message}); ignored`),
653
+ page.rel,
654
+ );
451
655
  return null;
452
656
  }
453
657
  }
@@ -478,16 +682,23 @@ function classicRuns(ctx, page) {
478
682
  continue;
479
683
  }
480
684
  const rel = res.rel;
481
- if (!JS_EXT.has(extOf(rel))) ctx.note(`<script src> apunta a "${rel}" (extensión no habitual); se trata como JavaScript`, page.rel);
685
+ if (!JS_EXT.has(extOf(rel)))
686
+ ctx.note(
687
+ L(
688
+ `<script src> apunta a "${rel}" (extensión no habitual); se trata como JavaScript`,
689
+ `<script src> points to "${rel}" (unusual extension); treated as JavaScript`,
690
+ ),
691
+ page.rel,
692
+ );
482
693
  const info = ctx.jsInfo(rel, false);
483
694
  const attrs = s.attrs;
484
695
  const reasons = [];
485
- if (!mergeOn) reasons.push('unión desactivada');
486
- if (!info.ok) reasons.push('no se pudo analizar');
487
- if (s.inTemplate) reasons.push('dentro de <template>');
696
+ if (!mergeOn) reasons.push(L('unión desactivada', 'merging disabled'));
697
+ if (!info.ok) reasons.push(L('no se pudo analizar', 'could not be parsed'));
698
+ if (s.inTemplate) reasons.push(L('dentro de <template>', 'inside <template>'));
488
699
  if (attrs.has('async')) reasons.push('async');
489
- if (info.usesCurrentScript) reasons.push('usa document.currentScript');
490
- if (info.usesDocumentWrite) reasons.push('usa document.write');
700
+ if (info.usesCurrentScript) reasons.push(L('usa document.currentScript', 'uses document.currentScript'));
701
+ if (info.usesDocumentWrite) reasons.push(L('usa document.write', 'uses document.write'));
491
702
  const extra = [...attrs.keys()].filter((a) => !MERGE_ATTRS.has(a));
492
703
  if (extra.length) reasons.push(`atributos ${extra.join(', ')}`);
493
704
  const obf = ctx.shouldObfuscate([rel]);
@@ -534,20 +745,77 @@ export async function runPipeline(cfg, opts = {}) {
534
745
  const t0 = Date.now();
535
746
  if (mode === 'build') assertSafeOutDir(cfg, { force: opts.force });
536
747
  const ctx = new Context(cfg, { ...opts, mode });
748
+ const workers = cfg.workers === 'auto' ? autoWorkers() : cfg.workers;
749
+ ctx.heavy = createHeavy({ root: cfg.root, workers, cacheDir: cfg.cache && mode === 'build' ? defaultCacheDir(cfg.root) : null });
750
+ try {
751
+ return await pipeline(ctx, cfg, opts, mode, t0);
752
+ } finally {
753
+ await ctx.heavy.close();
754
+ }
755
+ }
756
+
757
+ async function pipeline(ctx, cfg, opts, mode, t0) {
537
758
  const log = opts.log ?? (() => {});
759
+ const bt = cfg.browserTargets;
760
+ if (bt?.missing)
761
+ ctx.warn(
762
+ L(
763
+ `hay navegadores objetivo en ${bt.from}, pero falta el paquete "browserslist"; se ignoran (instálalo con: npm install --save-dev browserslist)`,
764
+ `target browsers are set in ${bt.from}, but the "browserslist" package is missing; they are ignored (install it with: npm install --save-dev browserslist)`,
765
+ ),
766
+ );
767
+ else if (bt?.targets?.length) {
768
+ log(L(`Navegadores (${bt.from}): ${bt.targets.join(', ')}`, `Browsers (${bt.from}): ${bt.targets.join(', ')}`));
769
+ if (bt.ignored.length)
770
+ ctx.note(
771
+ L(
772
+ `navegadores sin motor equivalente en esbuild (se usan los demás): ${bt.ignored.join(', ')}`,
773
+ `browsers without an equivalent esbuild engine (the others are used): ${bt.ignored.join(', ')}`,
774
+ ),
775
+ );
776
+ } else if (bt && !bt.targets?.length)
777
+ ctx.warn(
778
+ L(
779
+ `la consulta de navegadores de ${bt.from} no da ningún navegador conocido; se ignora`,
780
+ `the browsers query in ${bt.from} matches no known browser; ignored`,
781
+ ),
782
+ );
783
+ // Tiempo de cada fase (perf.phases): para saber dónde se va el tiempo en proyectos reales.
784
+ const phases = [];
785
+ let phaseStart = Date.now();
786
+ let phaseName = 'setup';
787
+ const mark = (next) => {
788
+ const now = Date.now();
789
+ phases.push([phaseName, now - phaseStart]);
790
+ phaseName = next;
791
+ phaseStart = now;
792
+ };
538
793
  if (ctx.obfuscatorOptions) await ensureObfuscator(cfg.root);
539
794
  if (cfg.js.minifier === 'terser' && cfg.js.minify) await ensureTerser(cfg.root);
795
+ const images = mode === 'build' ? imageOptions(cfg.images) : null;
796
+ const sharp = images ? await ensureSharp(cfg.root) : null;
540
797
 
798
+ mark('pages');
541
799
  // 1. Páginas y entradas
800
+ await runHook(cfg.plugins, 'buildStart', { config: cfg, mode });
801
+ await applyTransforms(ctx);
802
+ await transpileTypeScript(ctx);
542
803
  const pages = loadPages(ctx);
804
+ markDependencies(ctx, pages);
543
805
  const extra = selectEntries(ctx, pages);
544
806
  const fullPages = [...pages.values()].filter((p) => p.full);
545
807
  const partialPages = [...pages.values()].filter((p) => !p.full);
546
- log(`Páginas: ${fullPages.length} completas, ${partialPages.length} en modo conservador`);
808
+ log(
809
+ L(
810
+ `Páginas: ${fullPages.length} completas, ${partialPages.length} en modo conservador`,
811
+ `Pages: ${fullPages.length} complete, ${partialPages.length} in conservative mode`,
812
+ ),
813
+ );
547
814
  const htmlBlob = [];
548
815
  for (const p of fullPages) collectRefs(ctx, p);
549
816
  for (const p of partialPages) collectRefs(ctx, p);
550
817
 
818
+ mark('modules');
551
819
  // 2. Módulos ES agrupados por import map
552
820
  const groups = new Map();
553
821
  const groupFor = (page, importMap) => {
@@ -560,7 +828,7 @@ export async function runPipeline(cfg, opts = {}) {
560
828
  entries: new Set(),
561
829
  virtual: new Map(),
562
830
  pages: new Set(),
563
- forcePerFile: importMap?.scopes ? 'el import map usa "scopes"' : null,
831
+ forcePerFile: importMap?.scopes ? L('el import map usa "scopes"', 'the import map uses "scopes"') : null,
564
832
  vendorSplit: !!ctx.obfuscatorOptions && !cfg.dependencies.obfuscate,
565
833
  });
566
834
  return groups.get(key);
@@ -569,7 +837,11 @@ export async function runPipeline(cfg, opts = {}) {
569
837
  const preloadLinks = [];
570
838
  for (const page of fullPages) {
571
839
  const map = page.model.importMaps[0] ? parseImportMap(ctx, page, page.model.importMaps[0]) : null;
572
- if (page.model.importMaps.length > 1) ctx.warn('tiene varios import maps; solo se usa el primero para empaquetar', page.rel);
840
+ if (page.model.importMaps.length > 1)
841
+ ctx.warn(
842
+ L('tiene varios import maps; solo se usa el primero para empaquetar', 'has several import maps; only the first one is used for bundling'),
843
+ page.rel,
844
+ );
573
845
  let vIndex = 0;
574
846
  for (const s of page.model.scripts) {
575
847
  if (s.kind !== 'module' || s.inTemplate) continue;
@@ -589,7 +861,7 @@ export async function runPipeline(cfg, opts = {}) {
589
861
  const outDir = page.baseDir ? `${page.baseDir}/` : '';
590
862
  g.virtual.set(id, {
591
863
  code: s.inline.code,
592
- label: `${page.rel} (módulo inline ${vIndex})`,
864
+ label: L(`${page.rel} (módulo inline ${vIndex})`, `${page.rel} (inline module ${vIndex})`),
593
865
  baseDir: page.baseDir,
594
866
  out: `${outDir}__vb_inline_${shortHash(id)}`,
595
867
  page,
@@ -617,9 +889,15 @@ export async function runPipeline(cfg, opts = {}) {
617
889
  if (!g.entries.size && !g.virtual.size) continue;
618
890
  const res = await buildModuleGroup(ctx, { ...g, hash: g.hash });
619
891
  if (res.mode === 'per-file') {
620
- ctx.note(`módulos sin empaquetar (cada archivo minificado en su sitio): ${res.reason}`, [...g.pages].join(', ') || 'entries');
621
- for (const rel of res.inputs) ctx.requireStandalone(rel, 'módulo (modo sin empaquetar)', { module: true });
622
- for (const rel of g.entries) entryOut.set(`${g.key}\0${rel}`, rel);
892
+ ctx.note(
893
+ L(
894
+ `módulos sin empaquetar (cada archivo minificado en su sitio): ${res.reason}`,
895
+ `modules not bundled (each file minified in place): ${res.reason}`,
896
+ ),
897
+ [...g.pages].join(', ') || 'entries',
898
+ );
899
+ for (const rel of res.inputs) ctx.requireStandalone(rel, L('módulo (modo sin empaquetar)', 'module (unbundled mode)'), { module: true });
900
+ for (const rel of g.entries) entryOut.set(`${g.key}\0${rel}`, jsOutPath(rel));
623
901
  for (const [id, v] of g.virtual) {
624
902
  const info = analyzeJs(v.code, { module: true });
625
903
  if (!info.ok) continue;
@@ -631,44 +909,51 @@ export async function runPipeline(cfg, opts = {}) {
631
909
  }
632
910
  continue;
633
911
  }
634
- log(`Módulos: ${g.entries.size} entradas, ${res.inputs.length} archivos, target ${res.target}`);
635
- for (const o of res.outputs) {
636
- const sources = o.inputs.length ? o.inputs : o.entry ? [o.entry] : [];
637
- let code = o.code;
638
- let map = o.map;
912
+ log(
913
+ L(
914
+ `Módulos: ${g.entries.size} entradas, ${res.inputs.length} archivos, target ${res.target}`,
915
+ `Modules: ${g.entries.size} entries, ${res.inputs.length} files, target ${res.target}`,
916
+ ),
917
+ );
918
+ // Ofuscar/comprimir cada salida en paralelo; registrar después en el orden original (salida determinista).
919
+ const finals = await mapLimit(res.outputs, 64, async (o) => {
639
920
  const obfRels = o.virtualId ? [g.virtual.get(o.virtualId).page.rel] : o.isChunk ? o.inputs : [o.entry];
640
921
  if (!o.isVendor && ctx.shouldObfuscate(obfRels)) {
641
- const r = obfuscateModule(ctx, code, o.outRel);
642
- if (r.note) ctx.warn(r.note, o.outRel);
643
- else {
644
- code = r.code;
645
- map = null;
646
- }
647
- } else {
648
- const t = await terserPass(ctx, code, { isModule: true, level: levelOf(res.target) });
649
- if (t !== code) {
650
- code = t;
651
- map = null;
652
- }
922
+ const r = await obfuscateModule(ctx, o.code, o.outRel);
923
+ return r.note ? { code: o.code, map: o.map, warn: r.note } : { code: r.code, map: null };
653
924
  }
925
+ const t = await terserPass(ctx, o.code, { isModule: true, level: levelOf(res.target) });
926
+ return t !== o.code ? { code: t, map: null } : { code: o.code, map: o.map };
927
+ });
928
+ for (const [i, o] of res.outputs.entries()) {
929
+ const sources = o.inputs.length ? o.inputs : o.entry ? [o.entry] : [];
930
+ const { code, map, warn } = finals[i];
931
+ if (warn) ctx.warn(warn, o.outRel);
654
932
  if (o.virtualId) {
655
933
  const v = g.virtual.get(o.virtualId);
656
934
  inlineModuleCode.set(o.virtualId, code.replace(/\n$/, ''));
657
- for (const rel of o.inputs) ctx.cover(rel, 'empaquetado', `${v.page.rel} (módulo inline)`);
935
+ for (const rel of o.inputs) ctx.cover(rel, L('empaquetado', 'bundled'), L(`${v.page.rel} (módulo inline)`, `${v.page.rel} (inline module)`));
658
936
  continue;
659
937
  }
660
938
  const action = o.isVendor ? 'vendor' : o.isChunk ? 'chunk' : 'bundle';
661
939
  const outRel = ctx.emitCode(o.outRel, code, map, { type: 'js', module: true, action, sources, before: o.before || sumSizes(ctx, sources) });
662
940
  if (o.entry) entryOut.set(`${g.key}\0${o.entry}`, outRel);
663
- for (const rel of o.inputs) ctx.cover(rel, o.isVendor ? 'empaquetado (dependencia, sin ofuscar)' : 'empaquetado', outRel);
941
+ for (const rel of o.inputs)
942
+ ctx.cover(
943
+ rel,
944
+ o.isVendor ? L('empaquetado (dependencia, sin ofuscar)', 'bundled (dependency, not obfuscated)') : L('empaquetado', 'bundled'),
945
+ outRel,
946
+ );
664
947
  }
665
948
  for (const spec of res.vendor) if (!ctx.vendorPackages.includes(spec)) ctx.vendorPackages.push(spec);
666
949
  // esbuild puede incrustar un módulo entero (p. ej. constantes) sin que figure en ninguna salida.
667
950
  for (const rel of res.consumed) {
668
- if (!ctx.coverage.get(rel)?.some((s) => s.startsWith('empaquetado'))) ctx.cover(rel, 'empaquetado (incrustado por completo en el paquete)');
951
+ if (!ctx.coverage.get(rel)?.some((s) => s.startsWith(L('empaquetado', 'bundled'))))
952
+ ctx.cover(rel, L('empaquetado (incrustado por completo en el paquete)', 'bundled (fully inlined into the bundle)'));
669
953
  }
670
954
  }
671
955
 
956
+ mark('css');
672
957
  // 3. CSS enlazado
673
958
  const cssOut = new Map();
674
959
  const cssEntries = new Set(extra.css);
@@ -679,22 +964,27 @@ export async function runPipeline(cfg, opts = {}) {
679
964
  if (res.status === 'ok') cssEntries.add(res.rel);
680
965
  else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
681
966
  }
682
- for (const rel of cssEntries) {
967
+ const cssList = [...cssEntries];
968
+ const cssBuilt = await mapLimit(cssList, 16, (rel) => {
969
+ const hashed = cfg.hash && !extra.css.includes(rel);
970
+ return buildCss(ctx, { entry: rel, outRel: hashed ? `${ctx.chunkDir}/${path.posix.basename(rel)}` : rel });
971
+ });
972
+ for (const [ci, rel] of cssList.entries()) {
683
973
  const hashed = cfg.hash && !extra.css.includes(rel);
684
- const provisional = hashed ? `${ctx.chunkDir}/${path.posix.basename(rel)}` : rel;
685
- const r = await buildCss(ctx, { entry: rel, outRel: provisional });
974
+ const r = cssBuilt[ci];
686
975
  if (r.error) {
687
- ctx.warn(`no se pudo procesar el CSS (${r.error}); se copia tal cual`, rel);
976
+ ctx.warn(L(`no se pudo procesar el CSS (${r.error}); se copia tal cual`, `could not process the CSS (${r.error}); copied as is`), rel);
688
977
  cssOut.set(rel, ctx.emitCopy(rel, rel, 'copy-fallback'));
689
- ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
978
+ ctx.cover(rel, L('copiado sin cambios (error al procesar)', 'copied unchanged (processing error)'), rel);
690
979
  continue;
691
980
  }
692
981
  const outRel = hashed ? ctx.hashedPath(rel, r.code, '.css') : rel;
693
982
  ctx.emitCode(outRel, r.code, r.map, { type: 'css', action: 'bundle', sources: r.inputs, before: sumSizes(ctx, r.inputs) });
694
983
  cssOut.set(rel, outRel);
695
- for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado' : 'empaquetado', outRel);
984
+ for (const i of r.inputs) ctx.cover(i, i === rel ? L('procesado', 'processed') : L('empaquetado', 'bundled'), outRel);
696
985
  }
697
986
 
987
+ mark('white-space');
698
988
  // 4. Detección de white-space: pre (para no colapsar espacios del HTML)
699
989
  let collapse = cfg.html.collapseWhitespace;
700
990
  if (collapse === 'auto') {
@@ -704,19 +994,35 @@ export async function runPipeline(cfg, opts = {}) {
704
994
  collapse = !pre;
705
995
  if (pre)
706
996
  ctx.note(
707
- 'se usa white-space: pre/pre-wrap/pre-line en el CSS; el HTML no colapsa espacios para no alterar ningún texto (html.collapseWhitespace: true lo fuerza)',
997
+ L(
998
+ 'se usa white-space: pre/pre-wrap/pre-line en el CSS; el HTML no colapsa espacios para no alterar ningún texto (html.collapseWhitespace: true lo fuerza)',
999
+ 'white-space: pre/pre-wrap/pre-line is used in the CSS; HTML whitespace is not collapsed so no text changes (html.collapseWhitespace: true forces it)',
1000
+ ),
708
1001
  );
709
1002
  }
710
1003
 
1004
+ mark('classic scripts');
711
1005
  // 5. Scripts clásicos por página
712
- const classicCache = new Map();
1006
+ // Primero se planifican todas las páginas, luego se minifica/ofusca cada grupo distinto en paralelo
1007
+ // y por último se registra todo en el orden original.
1008
+ const pageRuns = [];
1009
+ const uniqueRuns = new Map();
713
1010
  for (const page of fullPages) {
714
1011
  for (const run of classicRuns(ctx, page)) {
715
- const files = run.items.map((c) => c.rel);
716
- const key = `${files.join('|')}\0${run.items[0].key}`;
1012
+ const key = `${run.items.map((c) => c.rel).join('|')}\0${run.items[0].key}`;
1013
+ pageRuns.push({ page, run, key });
1014
+ if (!uniqueRuns.has(key)) uniqueRuns.set(key, run);
1015
+ }
1016
+ }
1017
+ const runKeys = [...uniqueRuns.keys()];
1018
+ const runResults = await mapLimit(runKeys, 64, (key) => computeClassicRun(ctx, uniqueRuns.get(key)));
1019
+ const computedRuns = new Map(runKeys.map((k, i) => [k, runResults[i]]));
1020
+ const classicCache = new Map();
1021
+ {
1022
+ for (const { page, run, key } of pageRuns) {
717
1023
  let out = classicCache.get(key);
718
1024
  if (!out) {
719
- out = await processClassicRun(ctx, run, page);
1025
+ out = commitClassicRun(ctx, run, page, computedRuns.get(key));
720
1026
  classicCache.set(key, out);
721
1027
  }
722
1028
  if (out.merged) {
@@ -736,6 +1042,7 @@ export async function runPipeline(cfg, opts = {}) {
736
1042
  }
737
1043
  }
738
1044
 
1045
+ mark('html edits');
739
1046
  // 6. Edición de cada página: módulos, CSS, inline, preload
740
1047
  for (const page of fullPages) {
741
1048
  for (const m of moduleRefs.filter((x) => x.page === page)) {
@@ -761,9 +1068,11 @@ export async function runPipeline(cfg, opts = {}) {
761
1068
  }
762
1069
  for (const page of pages.values()) await processInline(ctx, page, collapse);
763
1070
 
1071
+ mark('workers and standalone');
764
1072
  // 7. Workers, archivos sueltos y rutas mencionadas (hasta que no aparezca nada nuevo)
765
1073
  await drainQueues(ctx, cssOut);
766
1074
 
1075
+ mark('unreferenced');
767
1076
  // 8. Archivos no referenciados
768
1077
  const unref = [];
769
1078
  for (const [rel, f] of ctx.files) {
@@ -773,14 +1082,16 @@ export async function runPipeline(cfg, opts = {}) {
773
1082
  for (const rel of unref) {
774
1083
  if (ctx.coverage.has(rel)) continue;
775
1084
  const f = ctx.files.get(rel);
776
- if (cfg.unreferenced === 'process' || f.forced) ctx.requireStandalone(rel, 'no referenciado (se procesa suelto)');
1085
+ if (cfg.unreferenced === 'process' || f.forced)
1086
+ ctx.requireStandalone(rel, L('no referenciado (se procesa suelto)', 'unreferenced (processed on its own)'));
777
1087
  else if (cfg.unreferenced === 'copy') {
778
1088
  ctx.emitCopy(rel, rel);
779
- ctx.cover(rel, 'no referenciado: copiado', rel);
780
- } else ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
1089
+ ctx.cover(rel, L('no referenciado: copiado', 'unreferenced: copied'), rel);
1090
+ } else ctx.cover(rel, L('no referenciado: omitido (unreferenced: skip)', 'unreferenced: skipped (unreferenced: skip)'));
781
1091
  }
782
1092
  await drainQueues(ctx, cssOut);
783
1093
 
1094
+ mark('final html');
784
1095
  // 9. Preloads y HTML final
785
1096
  for (const { page, l } of preloadLinks) {
786
1097
  const res = ctx.resolve(l.href.value, page.baseDir);
@@ -791,7 +1102,13 @@ export async function runPipeline(cfg, opts = {}) {
791
1102
  (ctx.hasOwnOutput(res.rel) || ctx.assets.has(res.rel) ? res.rel : null);
792
1103
  if (target == null) {
793
1104
  page.edits.push({ start: l.start, end: l.end, text: '' });
794
- ctx.note(`se quita <link rel="${l.rels.join(' ')}" href="${l.href.value}">: ese archivo ahora va dentro de un paquete`, page.rel);
1105
+ ctx.note(
1106
+ L(
1107
+ `se quita <link rel="${l.rels.join(' ')}" href="${l.href.value}">: ese archivo ahora va dentro de un paquete`,
1108
+ `removed <link rel="${l.rels.join(' ')}" href="${l.href.value}">: that file is now inside a bundle`,
1109
+ ),
1110
+ page.rel,
1111
+ );
795
1112
  } else if (target !== res.rel) {
796
1113
  page.edits.push({ start: l.href.start, end: l.href.end, text: attrText('href', urlFor(ctx, page, l.href.value, target)) });
797
1114
  }
@@ -800,29 +1117,35 @@ export async function runPipeline(cfg, opts = {}) {
800
1117
  for (const page of pages.values()) {
801
1118
  const referenced = page.full || ctx.pageRefs.has(page.rel) || ctx.mentions.has(page.rel) || ctx.files.get(page.rel).forced;
802
1119
  if (!referenced && cfg.unreferenced === 'skip') {
803
- ctx.cover(page.rel, 'no referenciado: omitido (unreferenced: skip)');
1120
+ ctx.cover(page.rel, L('no referenciado: omitido (unreferenced: skip)', 'unreferenced: skipped (unreferenced: skip)'));
804
1121
  continue;
805
1122
  }
806
1123
  if (!referenced && cfg.unreferenced === 'copy') {
807
1124
  ctx.emitCopy(page.rel, page.rel);
808
- ctx.cover(page.rel, 'no referenciado: copiado', page.rel);
1125
+ ctx.cover(page.rel, L('no referenciado: copiado', 'unreferenced: copied'), page.rel);
809
1126
  continue;
810
1127
  }
811
1128
  await finalizePage(ctx, page, collapse);
812
1129
  }
813
1130
 
1131
+ mark('assets');
814
1132
  // 10. Recursos: todo lo demás se copia (sin perder nada)
1133
+ const imageJobs = [];
815
1134
  for (const [rel, f] of ctx.files) {
816
1135
  if (ctx.hasOwnOutput(rel)) {
817
- if (!ctx.coverage.has(rel)) ctx.cover(rel, 'procesado', rel);
1136
+ if (!ctx.coverage.has(rel)) ctx.cover(rel, L('procesado', 'processed'), rel);
818
1137
  continue;
819
1138
  }
820
1139
  const cov = ctx.coverage.get(rel);
821
1140
  // Empaquetar solo absorbe JS y CSS; un JSON importado se publica también por si se pide con fetch().
822
- if (cov && (JS_EXT.has(f.ext) || CSS_EXT.has(f.ext) || HTML_EXT.has(f.ext) || cov.some((s) => s.startsWith('no referenciado')))) continue;
1141
+ if (
1142
+ cov &&
1143
+ (JS_EXT.has(f.ext) || CSS_EXT.has(f.ext) || HTML_EXT.has(f.ext) || cov.some((s) => s.startsWith(L('no referenciado', 'unreferenced'))))
1144
+ )
1145
+ continue;
823
1146
  const referenced = ctx.assets.has(rel) || ctx.mentions.has(rel);
824
1147
  if (cfg.unreferenced === 'skip' && !referenced && !f.forced) {
825
- ctx.cover(rel, 'no referenciado: omitido (unreferenced: skip)');
1148
+ ctx.cover(rel, L('no referenciado: omitido (unreferenced: skip)', 'unreferenced: skipped (unreferenced: skip)'));
826
1149
  continue;
827
1150
  }
828
1151
  if ((f.ext === '.json' || f.ext === '.webmanifest') && cfg.json.minify) {
@@ -830,30 +1153,72 @@ export async function runPipeline(cfg, opts = {}) {
830
1153
  const min = raw == null ? null : minifyJsonText(stripBom(raw).text);
831
1154
  if (min != null && Buffer.byteLength(min) < f.size) {
832
1155
  ctx.emit(rel, min, { type: 'json', action: 'minify', sources: [rel], before: f.size });
833
- ctx.cover(rel, 'JSON minificado', rel);
1156
+ ctx.cover(rel, L('JSON minificado', 'JSON minified'), rel);
834
1157
  continue;
835
1158
  }
836
1159
  }
1160
+ if (sharp && IMAGE_EXT.has(f.ext)) {
1161
+ imageJobs.push({ rel, f });
1162
+ continue;
1163
+ }
837
1164
  ctx.emitCopy(rel, rel);
838
- ctx.cover(rel, 'copiado', rel);
1165
+ ctx.cover(rel, L('copiado', 'copied'), rel);
839
1166
  }
1167
+ // Imágenes: en paralelo (sharp usa sus propios hilos) y con caché por contenido; se registran en orden.
1168
+ const optimized = await mapLimit(imageJobs, 8, async ({ rel, f }) => {
1169
+ const buf = fs.readFileSync(f.abs);
1170
+ const r = await ctx.heavy.memo(`image:${f.ext}:q${images.quality}:sharp${sharp.versions?.sharp ?? '?'}`, buf, async () => {
1171
+ const o = await optimizeImage(sharp, buf, f.ext, images);
1172
+ return o.data ? { b64: o.data.toString('base64') } : { skip: o.skip };
1173
+ });
1174
+ return r.b64 ? { data: Buffer.from(r.b64, 'base64') } : r;
1175
+ });
1176
+ imageJobs.forEach(({ rel, f }, i) => {
1177
+ const o = optimized[i];
1178
+ if (o.data) {
1179
+ ctx.emit(rel, o.data, { type: 'asset', action: 'image', sources: [rel], before: f.size });
1180
+ ctx.cover(rel, L('imagen optimizada', 'image optimized'), rel);
1181
+ } else {
1182
+ ctx.emitCopy(rel, rel);
1183
+ ctx.cover(rel, L(`copiado (${o.skip})`, `copied (${o.skip})`), rel);
1184
+ }
1185
+ });
840
1186
 
1187
+ mark('warnings');
841
1188
  // 11. Avisos de rutas mencionadas en el código que ya no existirán
842
1189
  for (const [rel, from] of ctx.mentions) {
843
- if (!ctx.outputs.has(rel) && ctx.coverage.get(rel)?.every((s) => s.startsWith('empaquetado'))) {
844
- ctx.warn(`"${rel}" se menciona como texto en ${from} pero ya no existe por separado (va dentro de un paquete).`);
1190
+ if (!ctx.outputs.has(rel) && ctx.coverage.get(rel)?.every((s) => s.startsWith(L('empaquetado', 'bundled')))) {
1191
+ ctx.warn(
1192
+ L(
1193
+ `"${rel}" se menciona como texto en ${from} pero ya no existe por separado (va dentro de un paquete).`,
1194
+ `"${rel}" is mentioned as text in ${from} but no longer exists on its own (it is inside a bundle).`,
1195
+ ),
1196
+ );
845
1197
  }
846
1198
  }
847
1199
  if (cfg.hash) {
848
1200
  for (const [rel, from] of ctx.mentions) {
849
1201
  if ((cssOut.get(rel) ?? rel) !== rel)
850
- ctx.note(`"${rel}" se menciona como texto en ${from}: además de la versión con hash se publica en su ruta original.`);
1202
+ ctx.note(
1203
+ L(
1204
+ `"${rel}" se menciona como texto en ${from}: además de la versión con hash se publica en su ruta original.`,
1205
+ `"${rel}" is mentioned as text in ${from}: besides the hashed version, it is published at its original path.`,
1206
+ ),
1207
+ );
851
1208
  }
852
1209
  }
853
1210
 
1211
+ mark('precompression');
854
1212
  // 12. Precompresión .gz / .br para servidores que sirven archivos comprimidos (nginx gzip_static, etc.)
855
- if (cfg.compress && mode === 'build') precompress(ctx, cfg.compress === true ? ['gzip', 'brotli'] : [cfg.compress]);
1213
+ if (cfg.compress && mode === 'build') await precompress(ctx, cfg.compress === true ? ['gzip', 'brotli'] : [cfg.compress]);
856
1214
 
1215
+ mark('size report');
1216
+ const report = await buildReport(ctx.outputs, ctx.heavy);
1217
+ const budgets = checkBudgets(cfg.budgets, report);
1218
+ for (const p of budgets.unmatched)
1219
+ ctx.warn(L(`el presupuesto "${p}" no coincide con ningún archivo de salida`, `the budget "${p}" does not match any output file`));
1220
+ // En analyze los excesos son avisos; en build detienen la compilación antes de escribir nada.
1221
+ if (mode === 'analyze') for (const v of budgets.violations) ctx.warn(v);
857
1222
  const result = {
858
1223
  version: VERSION,
859
1224
  root: cfg.root,
@@ -873,16 +1238,28 @@ export async function runPipeline(cfg, opts = {}) {
873
1238
  .map((rel) => ({ rel, reason: ctx.dependencyReason(rel) })),
874
1239
  vendor: ctx.vendorPackages,
875
1240
  obfuscated: !!ctx.obfuscatorOptions,
876
- report: buildReport(ctx.outputs),
1241
+ report,
1242
+ budgets,
1243
+ perf: Object.assign(ctx.heavy.stats, { phases }),
877
1244
  verify: null,
878
1245
  durationMs: 0,
879
1246
  };
880
1247
  if (mode === 'analyze') {
1248
+ mark('fin');
881
1249
  result.durationMs = Date.now() - t0;
882
1250
  return result;
883
1251
  }
884
1252
 
1253
+ mark('write and verify');
885
1254
  // 12. Escritura, verificación y publicación
1255
+ if (budgets.violations.length)
1256
+ throw new BuildError(
1257
+ L(
1258
+ `Se superaron ${budgets.violations.length} presupuesto(s) de tamaño; no se tocó ${cfg.outDir}.`,
1259
+ `${budgets.violations.length} size budget(s) exceeded; ${cfg.outDir} was not touched.`,
1260
+ ),
1261
+ budgets.violations,
1262
+ );
886
1263
  let staging;
887
1264
  try {
888
1265
  staging = await writeStaging(cfg, ctx.outputs);
@@ -890,7 +1267,13 @@ export async function runPipeline(cfg, opts = {}) {
890
1267
  result.verify = await verifyOutput(staging, ctx.outputs, ctx);
891
1268
  if (result.verify.errors.length) {
892
1269
  if (!opts.keepFailed) await discardStaging(staging);
893
- throw new BuildError(`La verificación encontró ${result.verify.errors.length} problema(s); no se tocó ${cfg.outDir}.`, result.verify.errors);
1270
+ throw new BuildError(
1271
+ L(
1272
+ `La verificación encontró ${result.verify.errors.length} problema(s); no se tocó ${cfg.outDir}.`,
1273
+ `Verification found ${result.verify.errors.length} problem(s); ${cfg.outDir} was not touched.`,
1274
+ ),
1275
+ result.verify.errors,
1276
+ );
894
1277
  }
895
1278
  }
896
1279
  await writeManifest(staging, {
@@ -900,6 +1283,8 @@ export async function runPipeline(cfg, opts = {}) {
900
1283
  files: [...ctx.outputs.keys()].sort(),
901
1284
  });
902
1285
  await swapIntoPlace(cfg, staging);
1286
+ await runHook(cfg.plugins, 'buildEnd', { result, outDir: cfg.outDir });
1287
+ mark('fin');
903
1288
  } catch (err) {
904
1289
  if (staging && !opts.keepFailed) await discardStaging(staging);
905
1290
  throw err;
@@ -910,26 +1295,21 @@ export async function runPipeline(cfg, opts = {}) {
910
1295
 
911
1296
  const COMPRESS_TYPES = new Set(['html', 'css', 'js', 'json', 'svg', 'text', 'map']);
912
1297
 
913
- function precompress(ctx, kinds) {
914
- for (const [rel, o] of [...ctx.outputs]) {
915
- if (!COMPRESS_TYPES.has(o.type)) continue;
1298
+ async function precompress(ctx, kinds) {
1299
+ const items = [...ctx.outputs].filter(([, o]) => COMPRESS_TYPES.has(o.type));
1300
+ // En paralelo (pool de hilos de libuv); se registra en orden. El informe reutiliza estos mismos resultados.
1301
+ const done = await mapLimit(items, 64, async ([, o]) => {
916
1302
  const buf = o.copyFrom ? fs.readFileSync(o.copyFrom) : o.data;
917
- if (buf.length < 1024) continue; // no compensa
918
- if (kinds.includes('gzip')) {
919
- const gz = zlib.gzipSync(buf, { level: 9 });
920
- if (gz.length < buf.length) ctx.emit(`${rel}.gz`, gz, { type: 'compressed', action: 'gzip', sources: o.sources });
921
- }
922
- if (kinds.includes('brotli')) {
923
- const br = zlib.brotliCompressSync(buf, {
924
- params: {
925
- [zlib.constants.BROTLI_PARAM_QUALITY]: 11,
926
- [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
927
- [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
928
- },
929
- });
930
- if (br.length < buf.length) ctx.emit(`${rel}.br`, br, { type: 'compressed', action: 'brotli', sources: o.sources });
931
- }
932
- }
1303
+ if (buf.length < 1024) return null; // no compensa
1304
+ const [gz, br] = await Promise.all([kinds.includes('gzip') ? gzipOf(buf) : null, kinds.includes('brotli') ? brotliOf(buf) : null]);
1305
+ return { buf, gz, br };
1306
+ });
1307
+ items.forEach(([rel, o], i) => {
1308
+ const d = done[i];
1309
+ if (!d) return;
1310
+ if (d.gz && d.gz.length < d.buf.length) ctx.emit(`${rel}.gz`, d.gz, { type: 'compressed', action: 'gzip', sources: o.sources });
1311
+ if (d.br && d.br.length < d.buf.length) ctx.emit(`${rel}.br`, d.br, { type: 'compressed', action: 'brotli', sources: o.sources });
1312
+ });
933
1313
  }
934
1314
 
935
1315
  function sumSizes(ctx, rels) {
@@ -947,31 +1327,54 @@ function markModuleImport(ctx, spec, baseDir, from, importMap) {
947
1327
  .map((k) => importMap.imports[k] + spec.slice(k.length))[0];
948
1328
  if (target) {
949
1329
  const r = ctx.resolve(target, importMap.baseDir);
950
- if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
1330
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, L(`importado desde ${from}`, `imported from ${from}`), { module: true });
951
1331
  }
952
1332
  }
953
1333
  return;
954
1334
  }
955
1335
  const r = ctx.resolve(spec, baseDir);
956
- if (r.status === 'ok') ctx.requireStandalone(r.rel, `importado desde ${from}`, { module: true });
1336
+ if (r.status === 'ok') ctx.requireStandalone(r.rel, L(`importado desde ${from}`, `imported from ${from}`), { module: true });
957
1337
  else ctx.reportBroken(from, spec, r);
958
1338
  }
959
1339
 
960
- async function processClassicRun(ctx, run, page) {
1340
+ /** Parte costosa y sin efectos de un grupo de scripts clásicos: unir, minificar y ofuscar. */
1341
+ async function computeClassicRun(ctx, run) {
961
1342
  const items = run.items;
962
1343
  const first = items[0];
963
- if (items.length === 1 && !first.info.ok) {
964
- ctx.warn(`no se pudo analizar (${first.info.error}); se copia sin cambios`, first.rel);
965
- const outRel = ctx.emitCopy(first.rel, first.rel, 'copy-fallback');
966
- ctx.cover(first.rel, 'copiado sin cambios (no se pudo analizar)', outRel);
967
- return { merged: false, outs: [{ outRel, changed: false }] };
968
- }
1344
+ if (items.length === 1 && !first.info.ok) return { copy: true };
1345
+ let mergeError = null;
969
1346
  if (items.length > 1) {
970
1347
  // Separadores con ; y salto de línea: evitan que un archivo sin ; final se "pegue" al siguiente.
971
1348
  const code = items.map((c) => ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') }).text.replace(/\s*$/, '\n')).join(';\n');
972
1349
  const info = analyzeJs(code, { module: false });
973
- if (info.ok) {
974
- const r = await processClassic(ctx, { code, id: items.map((c) => c.rel).join('+'), info, obfuscate: first.obf });
1350
+ if (info.ok) return { merged: await processClassic(ctx, { code, id: items.map((c) => c.rel).join('+'), info, obfuscate: first.obf }) };
1351
+ mergeError = info.error;
1352
+ }
1353
+ const separate = await Promise.all(
1354
+ items.map((c) => {
1355
+ const { text } = ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') });
1356
+ return processClassic(ctx, { code: text, id: c.rel, info: c.info, obfuscate: c.obf, sourcemap: !!ctx.cfg.sourcemap });
1357
+ }),
1358
+ );
1359
+ return { separate, mergeError };
1360
+ }
1361
+
1362
+ /** Registra el resultado de computeClassicRun (salidas, cobertura y notas) en orden. */
1363
+ function commitClassicRun(ctx, run, page, comp) {
1364
+ const items = run.items;
1365
+ const first = items[0];
1366
+ if (comp.copy) {
1367
+ ctx.warn(
1368
+ L(`no se pudo analizar (${first.info.error}); se copia sin cambios`, `could not be parsed (${first.info.error}); copied unchanged`),
1369
+ first.rel,
1370
+ );
1371
+ const outRel = ctx.emitCopy(first.rel, first.rel, 'copy-fallback');
1372
+ ctx.cover(first.rel, L('copiado sin cambios (no se pudo analizar)', 'copied unchanged (could not be parsed)'), outRel);
1373
+ return { merged: false, outs: [{ outRel, changed: false }] };
1374
+ }
1375
+ {
1376
+ if (comp.merged) {
1377
+ const r = comp.merged;
975
1378
  for (const n of r.notes) ctx.note(n, `scripts unidos (${items.map((c) => c.rel).join(', ')})`);
976
1379
  const outRel = ctx.emitCode(`${ctx.chunkDir}/scripts-${shortHash(r.code)}.js`, r.code, null, {
977
1380
  type: 'js',
@@ -982,21 +1385,25 @@ async function processClassicRun(ctx, run, page) {
982
1385
  items.map((c) => c.rel),
983
1386
  ),
984
1387
  });
985
- for (const c of items) ctx.cover(c.rel, 'unido', outRel);
1388
+ for (const c of items) ctx.cover(c.rel, L('unido', 'merged'), outRel);
986
1389
  return { merged: true, outRel };
987
1390
  }
988
- ctx.warn(`no se pudieron unir (${info.error}); se procesan por separado`, page.rel);
1391
+ if (comp.mergeError)
1392
+ ctx.warn(
1393
+ L(`no se pudieron unir (${comp.mergeError}); se procesan por separado`, `could not be merged (${comp.mergeError}); processed separately`),
1394
+ page.rel,
1395
+ );
989
1396
  }
990
1397
  // Un solo archivo, o una unión fallida: cada uno se procesa en su sitio.
991
1398
  const outs = [];
992
- for (const c of items) {
993
- const { text } = ctx.readText(c.rel, { kind: 'js', charset: c.s.attrs.get('charset') });
994
- const r = await processClassic(ctx, { code: text, id: c.rel, info: c.info, obfuscate: c.obf, sourcemap: !!ctx.cfg.sourcemap });
1399
+ for (const [i, c] of items.entries()) {
1400
+ const r = comp.separate[i];
995
1401
  for (const n of r.notes) ctx.note(n, c.rel);
996
- if (c.reasons.length && c.reasons[0] !== 'unión desactivada') ctx.note(`no se une con otros scripts: ${c.reasons.join(', ')}`, c.rel);
997
- const outRel = ctx.cfg.hash ? ctx.hashedPath(c.rel, r.code, '.js') : c.rel;
1402
+ if (c.reasons.length && c.reasons[0] !== L('unión desactivada', 'merging disabled'))
1403
+ ctx.note(L(`no se une con otros scripts: ${c.reasons.join(', ')}`, `not merged with other scripts: ${c.reasons.join(', ')}`), c.rel);
1404
+ const outRel = ctx.cfg.hash ? ctx.hashedPath(c.rel, r.code, '.js') : jsOutPath(c.rel);
998
1405
  ctx.emitCode(outRel, r.code, r.map, { type: 'js', action: 'minify', sources: [c.rel], before: ctx.files.get(c.rel).size });
999
- ctx.cover(c.rel, 'procesado', outRel);
1406
+ ctx.cover(c.rel, L('procesado', 'processed'), outRel);
1000
1407
  outs.push({ outRel, changed: true });
1001
1408
  }
1002
1409
  return { merged: false, outs };
@@ -1009,7 +1416,7 @@ async function processInline(ctx, page) {
1009
1416
  if (s.src) {
1010
1417
  if (!page.full) {
1011
1418
  const res = ctx.resolve(s.src.value, page.baseDir);
1012
- if (res.status === 'ok') ctx.requireStandalone(res.rel, `script en ${page.rel}`, { module: s.kind === 'module' });
1419
+ if (res.status === 'ok') ctx.requireStandalone(res.rel, L(`script en ${page.rel}`, `script in ${page.rel}`), { module: s.kind === 'module' });
1013
1420
  else if (res.status !== 'skip') ctx.reportBroken(page.rel, s.src.value, res);
1014
1421
  }
1015
1422
  continue;
@@ -1018,7 +1425,10 @@ async function processInline(ctx, page) {
1018
1425
  if (s.kind === 'classic') {
1019
1426
  const info = analyzeJs(s.inline.code, { module: false });
1020
1427
  if (!info.ok) {
1021
- ctx.note(`un <script> inline no es JS estándar (¿plantilla?); se deja igual`, page.rel);
1428
+ ctx.note(
1429
+ L(`un <script> inline no es JS estándar (¿plantilla?); se deja igual`, `an inline <script> is not standard JS (a template?); left as is`),
1430
+ page.rel,
1431
+ );
1022
1432
  continue;
1023
1433
  }
1024
1434
  ctx.noteJsInfo(page.rel, info, page.baseDir);
@@ -1048,7 +1458,7 @@ async function processInline(ctx, page) {
1048
1458
  for (const l of page.model.links) {
1049
1459
  if (l.type !== 'stylesheet') continue;
1050
1460
  const res = ctx.resolve(l.href.value, page.baseDir);
1051
- if (res.status === 'ok') ctx.requireStandalone(res.rel, `hoja de estilos en ${page.rel}`);
1461
+ if (res.status === 'ok') ctx.requireStandalone(res.rel, L(`hoja de estilos en ${page.rel}`, `stylesheet in ${page.rel}`));
1052
1462
  else if (res.status !== 'skip') ctx.reportBroken(page.rel, l.href.value, res);
1053
1463
  }
1054
1464
  }
@@ -1060,7 +1470,13 @@ async function processInline(ctx, page) {
1060
1470
  outRel: page.baseDir ? `${page.baseDir}/__page.html` : '__page.html',
1061
1471
  });
1062
1472
  if (r.error) {
1063
- ctx.note(`un <style> no se pudo minificar (${r.error.split('\n')[0]}); se deja igual`, page.rel);
1473
+ ctx.note(
1474
+ L(
1475
+ `un <style> no se pudo minificar (${r.error.split('\n')[0]}); se deja igual`,
1476
+ `a <style> could not be minified (${r.error.split('\n')[0]}); left as is`,
1477
+ ),
1478
+ page.rel,
1479
+ );
1064
1480
  continue;
1065
1481
  }
1066
1482
  if (r.code !== st.code) page.edits.push({ start: st.start, end: st.end, text: escapeInlineStyle(r.code) });
@@ -1097,7 +1513,7 @@ async function finalizePage(ctx, page, collapse) {
1097
1513
  baseDir: page.baseDir,
1098
1514
  baseExternal: page.baseExternal,
1099
1515
  });
1100
- ctx.cover(page.rel, page.full ? 'procesado' : 'procesado (modo conservador)', page.rel);
1516
+ ctx.cover(page.rel, page.full ? L('procesado', 'processed') : L('procesado (modo conservador)', 'processed (conservative mode)'), page.rel);
1101
1517
  }
1102
1518
 
1103
1519
  /** Procesa workers y archivos que deben existir sueltos en su ruta original, hasta agotar la cola. */
@@ -1108,7 +1524,7 @@ async function drainQueues(ctx, cssOut) {
1108
1524
  for (const [rel] of ctx.mentions) {
1109
1525
  const ext = extOf(rel);
1110
1526
  if ((JS_EXT.has(ext) || CSS_EXT.has(ext)) && !ctx.hasOwnOutput(rel) && !ctx.standalone.has(rel))
1111
- ctx.requireStandalone(rel, `mencionado como texto en ${ctx.mentions.get(rel)}`);
1527
+ ctx.requireStandalone(rel, L(`mencionado como texto en ${ctx.mentions.get(rel)}`, `mentioned as text in ${ctx.mentions.get(rel)}`));
1112
1528
  else if (!JS_EXT.has(ext) && !CSS_EXT.has(ext) && !HTML_EXT.has(ext)) ctx.registerAsset(rel, ctx.mentions.get(rel));
1113
1529
  }
1114
1530
  for (const [rel, w] of ctx.workers) {
@@ -1119,72 +1535,99 @@ async function drainQueues(ctx, cssOut) {
1119
1535
  if (w.module) {
1120
1536
  const r = await buildModuleWorker(ctx, rel);
1121
1537
  if (r.error) {
1122
- ctx.warn(`no se pudo empaquetar el worker (${r.error.split('\n')[0]}); se procesa suelto`, rel);
1538
+ ctx.warn(
1539
+ L(
1540
+ `no se pudo empaquetar el worker (${r.error.split('\n')[0]}); se procesa suelto`,
1541
+ `could not bundle the worker (${r.error.split('\n')[0]}); processed on its own`,
1542
+ ),
1543
+ rel,
1544
+ );
1123
1545
  ctx.requireStandalone(rel, 'worker (respaldo)', { module: true });
1124
1546
  continue;
1125
1547
  }
1126
1548
  let code = r.code;
1127
1549
  if (ctx.shouldObfuscate([rel])) {
1128
- const o = obfuscateModule(ctx, code, rel);
1550
+ const o = await obfuscateModule(ctx, code, rel);
1129
1551
  if (o.note) ctx.warn(o.note, rel);
1130
1552
  code = o.code;
1131
1553
  } else code = await terserPass(ctx, code, { isModule: true, level: 2020 });
1132
1554
  const sources = [...r.consumed];
1133
- ctx.emitCode(rel, code, code === r.code ? r.map : null, {
1555
+ ctx.emitCode(jsOutPath(rel), code, code === r.code ? r.map : null, {
1134
1556
  type: 'js',
1135
1557
  module: true,
1136
1558
  action: 'worker',
1137
1559
  sources,
1138
1560
  before: sumSizes(ctx, sources),
1139
1561
  });
1140
- for (const s of sources) ctx.cover(s, s === rel ? 'procesado (worker)' : 'empaquetado', rel);
1141
- } else ctx.requireStandalone(rel, `worker clásico (${w.kind})`, { classic: true });
1562
+ for (const s of sources)
1563
+ ctx.cover(s, s === rel ? L('procesado (worker)', 'processed (worker)') : L('empaquetado', 'bundled'), jsOutPath(rel));
1564
+ } else ctx.requireStandalone(rel, L(`worker clásico (${w.kind})`, `classic worker (${w.kind})`), { classic: true });
1142
1565
  }
1566
+ // Análisis en orden, trabajo pesado en paralelo, registro otra vez en orden.
1567
+ const jobs = [];
1143
1568
  for (const [rel, s] of ctx.standalone) {
1144
1569
  if (ctx.done.has(`s:${rel}`)) continue;
1145
1570
  ctx.done.add(`s:${rel}`);
1146
1571
  progressed = true;
1147
1572
  if (ctx.hasOwnOutput(rel) || !ctx.files.has(rel)) continue;
1148
- await processStandalone(ctx, rel, s, cssOut);
1573
+ const job = planStandalone(ctx, rel, s, cssOut);
1574
+ if (job) jobs.push(job);
1149
1575
  }
1576
+ const results = await mapLimit(jobs, 64, (j) => j.run());
1577
+ jobs.forEach((j, i) => j.commit(results[i]));
1150
1578
  if (!progressed) return;
1151
1579
  }
1152
- throw new BuildError('Demasiadas rondas al resolver dependencias (¿ciclo inesperado?).');
1580
+ throw new BuildError(
1581
+ L('Demasiadas rondas al resolver dependencias (¿ciclo inesperado?).', 'Too many rounds resolving dependencies (unexpected cycle?).'),
1582
+ );
1153
1583
  }
1154
1584
 
1155
- async function processStandalone(ctx, rel, s, cssOut) {
1585
+ /**
1586
+ * Prepara un archivo suelto: el análisis (que descubre más dependencias) se hace ya, en orden;
1587
+ * devuelve {run, commit}: run() es la parte costosa y sin efectos, commit() registra el resultado.
1588
+ */
1589
+ function planStandalone(ctx, rel, s, cssOut) {
1156
1590
  const ext = extOf(rel);
1157
1591
  const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
1158
1592
  if (CSS_EXT.has(ext)) {
1159
- const r = await buildCss(ctx, { entry: rel, outRel: rel });
1160
- if (r.error) {
1161
- ctx.warn(`no se pudo procesar el CSS (${r.error.split('\n')[0]}); se copia tal cual`, rel);
1162
- ctx.emitCopy(rel, rel, 'copy-fallback');
1163
- ctx.cover(rel, 'copiado sin cambios (error al procesar)', rel);
1164
- return;
1165
- }
1166
- ctx.emitCode(rel, r.code, r.map, { type: 'css', action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1167
- for (const i of r.inputs) ctx.cover(i, i === rel ? 'procesado (suelto)' : 'empaquetado', rel);
1168
- if (!cssOut.has(rel)) cssOut.set(rel, rel);
1169
- return;
1593
+ // buildCss registra los recursos de url() mientras compila: eso solo añade trabajo a la siguiente ronda.
1594
+ return {
1595
+ run: () => buildCss(ctx, { entry: rel, outRel: rel }),
1596
+ commit: (r) => {
1597
+ if (r.error) {
1598
+ ctx.warn(
1599
+ L(
1600
+ `no se pudo procesar el CSS (${r.error.split('\n')[0]}); se copia tal cual`,
1601
+ `could not process the CSS (${r.error.split('\n')[0]}); copied as is`,
1602
+ ),
1603
+ rel,
1604
+ );
1605
+ ctx.emitCopy(rel, rel, 'copy-fallback');
1606
+ ctx.cover(rel, L('copiado sin cambios (error al procesar)', 'copied unchanged (processing error)'), rel);
1607
+ return;
1608
+ }
1609
+ ctx.emitCode(rel, r.code, r.map, { type: 'css', action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1610
+ for (const i of r.inputs) ctx.cover(i, i === rel ? L('procesado (suelto)', 'processed (standalone)') : L('empaquetado', 'bundled'), rel);
1611
+ if (!cssOut.has(rel)) cssOut.set(rel, rel);
1612
+ },
1613
+ };
1170
1614
  }
1171
1615
  if (!JS_EXT.has(ext)) {
1172
1616
  ctx.registerAsset(rel, s.reason);
1173
- return;
1617
+ return null;
1174
1618
  }
1175
1619
  const auto = ctx.jsInfo(rel, 'auto');
1176
1620
  const isModule = !s.classic && (s.module || (auto.ok && (auto.hasModuleSyntax || auto.hasImportMeta)) || (!auto.ok && auto.isModule));
1177
1621
  const info = isModule ? ctx.jsInfo(rel, true) : ctx.jsInfo(rel, false);
1178
1622
  if (!info.ok) {
1179
- ctx.warn(`no se pudo analizar (${info.error}); se copia sin cambios`, rel);
1623
+ ctx.warn(L(`no se pudo analizar (${info.error}); se copia sin cambios`, `could not be parsed (${info.error}); copied unchanged`), rel);
1180
1624
  ctx.emitCopy(rel, rel, 'copy-fallback');
1181
- ctx.cover(rel, 'copiado sin cambios (no se pudo analizar)', rel);
1182
- return;
1625
+ ctx.cover(rel, L('copiado sin cambios (no se pudo analizar)', 'copied unchanged (could not be parsed)'), rel);
1626
+ return null;
1183
1627
  }
1184
1628
  ctx.noteJsInfo(rel, info, dir);
1185
1629
  const { text } = ctx.readText(rel, { kind: 'js' });
1186
1630
  const obfuscate = ctx.shouldObfuscate([rel]);
1187
- let r;
1188
1631
  if (isModule) {
1189
1632
  for (const imp of info.imports) if (imp.spec) markModuleImport(ctx, imp.spec, dir, rel, null);
1190
1633
  for (const u of info.urls) {
@@ -1192,11 +1635,15 @@ async function processStandalone(ctx, rel, s, cssOut) {
1192
1635
  if (res.status === 'ok') ctx.urlTarget(res.rel, rel);
1193
1636
  else ctx.reportBroken(rel, u.spec, res);
1194
1637
  }
1195
- r = await processModuleStandalone(ctx, { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap });
1196
- } else {
1197
- r = await processClassic(ctx, { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap });
1198
1638
  }
1199
- for (const n of r.notes) ctx.note(n, rel);
1200
- ctx.emitCode(rel, r.code, r.map, { type: 'js', module: isModule, action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1201
- ctx.cover(rel, `procesado suelto (${s.reason})`, rel);
1639
+ const job = { code: text, id: rel, info, obfuscate, sourcemap: !!ctx.cfg.sourcemap };
1640
+ return {
1641
+ run: () => (isModule ? processModuleStandalone(ctx, job) : processClassic(ctx, job)),
1642
+ commit: (r) => {
1643
+ for (const n of r.notes) ctx.note(n, rel);
1644
+ const out = jsOutPath(rel);
1645
+ ctx.emitCode(out, r.code, r.map, { type: 'js', module: isModule, action: 'standalone', sources: [rel], before: ctx.files.get(rel).size });
1646
+ ctx.cover(rel, L(`procesado suelto (${s.reason})`, `processed standalone (${s.reason})`), out);
1647
+ },
1648
+ };
1202
1649
  }